1446 lines
57 KiB
TypeScript
1446 lines
57 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { apiClient } from "./api/client";
|
||
import { AssistantSamPanel } from "./components/AssistantSamPanel";
|
||
import { AutoRunsHistoryPanel } from "./components/AutoRunsHistoryPanel";
|
||
import { AssistantPanel } from "./components/AssistantPanel";
|
||
import { ConnectionPanel } from "./components/ConnectionPanel";
|
||
import { HistoryPanel } from "./components/HistoryPanel";
|
||
import { MetricsPanel } from "./components/MetricsPanel";
|
||
import { OutputPanel } from "./components/OutputPanel";
|
||
import { PanelFrame } from "./components/PanelFrame";
|
||
import { PromptPanel } from "./components/PromptPanel";
|
||
import { QueryPanel } from "./components/QueryPanel";
|
||
import { RuntimePanel } from "./components/RuntimePanel";
|
||
import { DEFAULT_CONNECTION, DEFAULT_PROMPTS, DEFAULT_QUERY } from "./state/defaults";
|
||
import { designConfig } from "../../../designconfig";
|
||
import type {
|
||
AssistantConversationItem,
|
||
AssistantAnnotationRecord,
|
||
AssistantSelectionChip,
|
||
ConnectionState,
|
||
HistoryItem,
|
||
NormalizeResultState,
|
||
PromptState,
|
||
QueryState,
|
||
RuntimeRun,
|
||
TabKey,
|
||
UiMode
|
||
} from "./state/types";
|
||
|
||
const SESSION_CONFIG_KEY = "ndc_normalizer_session_config_v1";
|
||
const AUTORUNS_LAYOUT_CONFIG_KEY = "ndc_autoruns_layout_config_v1";
|
||
const AUTORUNS_SAVE_EVENT = "ndc-autoruns-save";
|
||
const ASSISTANT_STAGES = ["Анализ запроса", "Получение данных", "Подготовка ответа"];
|
||
const DEFAULT_UI_MODE: UiMode = "autoruns";
|
||
const AUTOLOAD_PROMPT_VERSION = "normalizer_v2_0_2";
|
||
const ASSISTANT_PROMPT_VERSION = "address_query_runtime_v1";
|
||
const TAB_KEYS: TabKey[] = ["normalized", "fragments", "scope", "flags", "route", "raw", "validation", "logs"];
|
||
const DEFAULT_ASSISTANT_ANNOTATION_AUTHOR = "manual_reviewer";
|
||
|
||
interface AssistantCommentModalState {
|
||
open: boolean;
|
||
messageIndex: number;
|
||
rating: number;
|
||
comment: string;
|
||
annotationAuthor: string;
|
||
saving: boolean;
|
||
error: string;
|
||
}
|
||
|
||
function withTs(message: string): string {
|
||
return `[${new Date().toLocaleTimeString("ru-RU")}] ${message}`;
|
||
}
|
||
|
||
function diffPrompts(current: PromptState, previous: PromptState | null): string {
|
||
if (!previous) return "Previous preset is not selected.";
|
||
const fields: Array<keyof PromptState> = ["systemPrompt", "developerPrompt", "domainPrompt", "schemaNotes", "fewShotExamples"];
|
||
const changed = fields
|
||
.filter((field) => current[field] !== previous[field])
|
||
.map((field) => `${field}: ${Math.abs(current[field].length - previous[field].length)} chars delta`);
|
||
if (changed.length === 0) return "No changes against previous preset.";
|
||
return `Changed fields: ${changed.length}. ${changed.join(" | ")}`;
|
||
}
|
||
|
||
function buildAssistantFollowupMessage(inputValue: string, selectedChip: AssistantSelectionChip | null): string {
|
||
const trimmedInput = inputValue.trim();
|
||
if (!trimmedInput) {
|
||
return "";
|
||
}
|
||
if (!selectedChip) {
|
||
return trimmedInput;
|
||
}
|
||
|
||
const normalizedInput = trimmedInput.toLowerCase();
|
||
const selectionAnchor = selectedChip.anchor_text.trim();
|
||
const normalizedSelection = selectionAnchor.toLowerCase();
|
||
if (normalizedSelection && normalizedInput.includes(normalizedSelection)) {
|
||
return trimmedInput;
|
||
}
|
||
|
||
return `По выбранному объекту "${selectionAnchor}": ${trimmedInput}`;
|
||
}
|
||
|
||
export default function App() {
|
||
const [connection, setConnection] = useState<ConnectionState>(DEFAULT_CONNECTION);
|
||
const [prompts, setPrompts] = useState<PromptState>(DEFAULT_PROMPTS);
|
||
const [query, setQuery] = useState<QueryState>(DEFAULT_QUERY);
|
||
const [result, setResult] = useState<NormalizeResultState | null>(null);
|
||
const [historyItems, setHistoryItems] = useState<HistoryItem[]>([]);
|
||
const [appLogs, setAppLogs] = useState<string[]>([]);
|
||
const [activeTab, setActiveTab] = useState<TabKey>("normalized");
|
||
const [busy, setBusy] = useState(false);
|
||
const [modelsBusy, setModelsBusy] = useState(false);
|
||
const [modelOptions, setModelOptions] = useState<string[]>([]);
|
||
const [connectionStatus, setConnectionStatus] = useState("");
|
||
const [presetList, setPresetList] = useState<
|
||
Array<{
|
||
id: string;
|
||
name: string;
|
||
prompt_version: string;
|
||
systemPrompt: string;
|
||
developerPrompt: string;
|
||
domainPrompt: string;
|
||
schemaNotes?: string;
|
||
fewShotExamples?: string;
|
||
}>
|
||
>([]);
|
||
const [selectedPresetId, setSelectedPresetId] = useState("");
|
||
const [presetName, setPresetName] = useState("NDC custom preset");
|
||
const [previousPreset, setPreviousPreset] = useState<PromptState | null>(null);
|
||
const [diffSummary, setDiffSummary] = useState("");
|
||
const [useMock, setUseMock] = useState(false);
|
||
const [runs, setRuns] = useState<RuntimeRun[]>([]);
|
||
const [selectedRunId, setSelectedRunId] = useState("");
|
||
const [runTrace, setRunTrace] = useState<unknown[]>([]);
|
||
const [evalBusy, setEvalBusy] = useState(false);
|
||
const [evalReport, setEvalReport] = useState<unknown>(null);
|
||
const [lastError, setLastError] = useState("");
|
||
|
||
const [uiMode, setUiMode] = useState<UiMode>(DEFAULT_UI_MODE);
|
||
const [showAutorunsSettingsMode, setShowAutorunsSettingsMode] = useState(true);
|
||
const [showAutorunsAutoRunsMode, setShowAutorunsAutoRunsMode] = useState(true);
|
||
const [showAutorunsAssistantMode, setShowAutorunsAssistantMode] = useState(true);
|
||
const [showAutorunsDecompositionMode, setShowAutorunsDecompositionMode] = useState(true);
|
||
const [showAutorunsProgressMode, setShowAutorunsProgressMode] = useState(true);
|
||
const [showAutorunsCommentsMode, setShowAutorunsCommentsMode] = useState(true);
|
||
const [showAssistantConnectionMode, setShowAssistantConnectionMode] = useState(true);
|
||
const [showAssistantPromptMode, setShowAssistantPromptMode] = useState(true);
|
||
const [showAssistantChatMode, setShowAssistantChatMode] = useState(true);
|
||
const [showAssistantCommentsMode, setShowAssistantCommentsMode] = useState(true);
|
||
const [showAssistantSamMode, setShowAssistantSamMode] = useState(true);
|
||
const [showDecompositionConnectionMode, setShowDecompositionConnectionMode] = useState(true);
|
||
const [showDecompositionPromptMode, setShowDecompositionPromptMode] = useState(true);
|
||
const [showDecompositionQueryMode, setShowDecompositionQueryMode] = useState(true);
|
||
const [showDecompositionOutputMode, setShowDecompositionOutputMode] = useState(true);
|
||
const [showDecompositionMetricsMode, setShowDecompositionMetricsMode] = useState(true);
|
||
const [showDecompositionHistoryMode, setShowDecompositionHistoryMode] = useState(true);
|
||
const [showDecompositionRuntimeMode, setShowDecompositionRuntimeMode] = useState(true);
|
||
const [assistantSessionId, setAssistantSessionId] = useState("");
|
||
const [assistantConversation, setAssistantConversation] = useState<AssistantConversationItem[]>([]);
|
||
const [assistantInput, setAssistantInput] = useState("");
|
||
const [assistantSelectedChip, setAssistantSelectedChip] = useState<AssistantSelectionChip | null>(null);
|
||
const [assistantBusy, setAssistantBusy] = useState(false);
|
||
const [assistantStatus, setAssistantStatus] = useState("");
|
||
const [assistantError, setAssistantError] = useState("");
|
||
const [assistantAnnotations, setAssistantAnnotations] = useState<AssistantAnnotationRecord[]>([]);
|
||
const [assistantAnnotationsBusy, setAssistantAnnotationsBusy] = useState(false);
|
||
const [assistantCommentModal, setAssistantCommentModal] = useState<AssistantCommentModalState>({
|
||
open: false,
|
||
messageIndex: -1,
|
||
rating: 3,
|
||
comment: "",
|
||
annotationAuthor: DEFAULT_ASSISTANT_ANNOTATION_AUTHOR,
|
||
saving: false,
|
||
error: ""
|
||
});
|
||
const presetAutoloadDoneRef = useRef(false);
|
||
const skipPresetAutoloadRef = useRef(false);
|
||
const sharedConnectionSyncReadyRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
const root = document.documentElement;
|
||
const { colors } = designConfig;
|
||
root.style.setProperty("--rgb-background", colors.backgroundRgb);
|
||
root.style.setProperty("--rgb-surface-main", colors.mainSurfaceRgb);
|
||
root.style.setProperty("--rgb-surface-horizontal", colors.horizontalSurfaceRgb);
|
||
root.style.setProperty("--rgb-surface-focus", colors.focusSurfaceRgb);
|
||
root.style.setProperty("--rgb-assistant-chip", colors.assistantChipRgb);
|
||
root.style.setProperty("--rgb-assistant-chip-hover", colors.assistantChipHoverRgb);
|
||
root.style.setProperty("--rgb-assistant-chip-selected", colors.assistantChipSelectedRgb);
|
||
root.style.setProperty("--rgb-assistant-chip-selected-text", colors.assistantChipSelectedTextRgb);
|
||
root.style.setProperty("--rgb-active", colors.activeRgb);
|
||
root.style.setProperty("--rgb-active-text", colors.activeTextRgb);
|
||
root.style.setProperty("--rgb-text-main", colors.textMainRgb);
|
||
root.style.setProperty("--rgb-text-muted", colors.textMutedRgb);
|
||
root.style.setProperty("--rgb-danger", colors.dangerRgb);
|
||
root.style.setProperty("--rgb-scrollbar-track", colors.scrollbarTrackRgb);
|
||
root.style.setProperty("--rgb-scrollbar-thumb", colors.scrollbarThumbRgb);
|
||
root.style.setProperty("--rgb-scrollbar-thumb-hover", colors.scrollbarThumbHoverRgb);
|
||
root.style.setProperty("--mode-column-width", `${designConfig.layout.modeColumnWidthPx}px`);
|
||
root.style.setProperty("--mode-toggle-width", `${designConfig.layout.modeToggleWidthPx}px`);
|
||
}, []);
|
||
|
||
const log = (message: string) => {
|
||
setAppLogs((prev) => [withTs(message), ...prev].slice(0, 300));
|
||
};
|
||
|
||
function startAssistantStatusTicker(): () => void {
|
||
let index = 0;
|
||
setAssistantStatus(ASSISTANT_STAGES[0]);
|
||
const timer = window.setInterval(() => {
|
||
index = Math.min(index + 1, ASSISTANT_STAGES.length - 1);
|
||
setAssistantStatus(ASSISTANT_STAGES[index]);
|
||
}, 650);
|
||
return () => window.clearInterval(timer);
|
||
}
|
||
|
||
useEffect(() => {
|
||
const bootstrapSharedConnection = async () => {
|
||
const cached = localStorage.getItem(SESSION_CONFIG_KEY);
|
||
if (cached) {
|
||
try {
|
||
const parsed = JSON.parse(cached) as Partial<ConnectionState>;
|
||
setConnection((prev) => ({
|
||
...prev,
|
||
llmProvider: parsed.llmProvider === "local" ? "local" : "openai",
|
||
model: parsed.model ?? prev.model,
|
||
baseUrl: parsed.baseUrl ?? prev.baseUrl,
|
||
temperature: parsed.temperature ?? prev.temperature,
|
||
maxOutputTokens: parsed.maxOutputTokens ?? prev.maxOutputTokens
|
||
}));
|
||
} catch {
|
||
// ignore broken local cache
|
||
}
|
||
}
|
||
|
||
try {
|
||
const payload = await apiClient.loadSharedConnectionConfig();
|
||
if (payload.connection && payload.connection.llmProvider === "local") {
|
||
setConnection((prev) => ({
|
||
...prev,
|
||
llmProvider: "local",
|
||
model: payload.connection?.model ?? prev.model,
|
||
baseUrl: payload.connection?.baseUrl ?? prev.baseUrl,
|
||
temperature: payload.connection?.temperature ?? prev.temperature,
|
||
maxOutputTokens: payload.connection?.maxOutputTokens ?? prev.maxOutputTokens
|
||
}));
|
||
log(`Shared local LLM config loaded: ${payload.connection.model}`);
|
||
}
|
||
} catch (error) {
|
||
log(`Shared local config load error: ${error instanceof Error ? error.message : String(error)}`);
|
||
} finally {
|
||
sharedConnectionSyncReadyRef.current = true;
|
||
}
|
||
};
|
||
|
||
void bootstrapSharedConnection();
|
||
|
||
const cachedAutorunsLayout = localStorage.getItem(AUTORUNS_LAYOUT_CONFIG_KEY);
|
||
if (cachedAutorunsLayout) {
|
||
try {
|
||
const parsed = JSON.parse(cachedAutorunsLayout) as {
|
||
uiMode?: UiMode;
|
||
activeTab?: TabKey;
|
||
showAutorunsSettingsMode?: boolean;
|
||
showAutorunsAutoRunsMode?: boolean;
|
||
showAutorunsAssistantMode?: boolean;
|
||
showAutorunsDecompositionMode?: boolean;
|
||
showAutorunsProgressMode?: boolean;
|
||
showAutorunsCommentsMode?: boolean;
|
||
showAssistantConnectionMode?: boolean;
|
||
showAssistantPromptMode?: boolean;
|
||
showAssistantChatMode?: boolean;
|
||
showAssistantCommentsMode?: boolean;
|
||
showAssistantSamMode?: boolean;
|
||
showDecompositionConnectionMode?: boolean;
|
||
showDecompositionPromptMode?: boolean;
|
||
showDecompositionQueryMode?: boolean;
|
||
showDecompositionOutputMode?: boolean;
|
||
showDecompositionMetricsMode?: boolean;
|
||
showDecompositionHistoryMode?: boolean;
|
||
showDecompositionRuntimeMode?: boolean;
|
||
prompts?: PromptState;
|
||
};
|
||
if (parsed.uiMode === "decomposition") {
|
||
setUiMode("decomposition");
|
||
} else if (parsed.uiMode === "assistant" || parsed.uiMode === "autoruns") {
|
||
setUiMode("autoruns");
|
||
}
|
||
if (parsed.activeTab && TAB_KEYS.includes(parsed.activeTab)) {
|
||
setActiveTab(parsed.activeTab);
|
||
}
|
||
if (typeof parsed.showAutorunsSettingsMode === "boolean") {
|
||
setShowAutorunsSettingsMode(parsed.showAutorunsSettingsMode);
|
||
}
|
||
if (typeof parsed.showAutorunsAutoRunsMode === "boolean") {
|
||
setShowAutorunsAutoRunsMode(parsed.showAutorunsAutoRunsMode);
|
||
}
|
||
if (typeof parsed.showAutorunsAssistantMode === "boolean") {
|
||
setShowAutorunsAssistantMode(parsed.showAutorunsAssistantMode);
|
||
}
|
||
if (typeof parsed.showAutorunsDecompositionMode === "boolean") {
|
||
setShowAutorunsDecompositionMode(parsed.showAutorunsDecompositionMode);
|
||
}
|
||
if (typeof parsed.showAutorunsProgressMode === "boolean") {
|
||
setShowAutorunsProgressMode(parsed.showAutorunsProgressMode);
|
||
}
|
||
if (typeof parsed.showAutorunsCommentsMode === "boolean") {
|
||
setShowAutorunsCommentsMode(parsed.showAutorunsCommentsMode);
|
||
}
|
||
if (typeof parsed.showAssistantConnectionMode === "boolean") {
|
||
setShowAssistantConnectionMode(parsed.showAssistantConnectionMode);
|
||
}
|
||
if (typeof parsed.showAssistantPromptMode === "boolean") {
|
||
setShowAssistantPromptMode(parsed.showAssistantPromptMode);
|
||
}
|
||
if (typeof parsed.showAssistantChatMode === "boolean") {
|
||
setShowAssistantChatMode(parsed.showAssistantChatMode);
|
||
}
|
||
if (typeof parsed.showAssistantCommentsMode === "boolean") {
|
||
setShowAssistantCommentsMode(parsed.showAssistantCommentsMode);
|
||
}
|
||
if (typeof parsed.showAssistantSamMode === "boolean") {
|
||
setShowAssistantSamMode(parsed.showAssistantSamMode);
|
||
}
|
||
if (typeof parsed.showDecompositionConnectionMode === "boolean") {
|
||
setShowDecompositionConnectionMode(parsed.showDecompositionConnectionMode);
|
||
}
|
||
if (typeof parsed.showDecompositionPromptMode === "boolean") {
|
||
setShowDecompositionPromptMode(parsed.showDecompositionPromptMode);
|
||
}
|
||
if (typeof parsed.showDecompositionQueryMode === "boolean") {
|
||
setShowDecompositionQueryMode(parsed.showDecompositionQueryMode);
|
||
}
|
||
if (typeof parsed.showDecompositionOutputMode === "boolean") {
|
||
setShowDecompositionOutputMode(parsed.showDecompositionOutputMode);
|
||
}
|
||
if (typeof parsed.showDecompositionMetricsMode === "boolean") {
|
||
setShowDecompositionMetricsMode(parsed.showDecompositionMetricsMode);
|
||
}
|
||
if (typeof parsed.showDecompositionHistoryMode === "boolean") {
|
||
setShowDecompositionHistoryMode(parsed.showDecompositionHistoryMode);
|
||
}
|
||
if (typeof parsed.showDecompositionRuntimeMode === "boolean") {
|
||
setShowDecompositionRuntimeMode(parsed.showDecompositionRuntimeMode);
|
||
}
|
||
if (parsed.prompts) {
|
||
setPrompts((prev) => ({
|
||
...prev,
|
||
...parsed.prompts
|
||
}));
|
||
skipPresetAutoloadRef.current = true;
|
||
}
|
||
} catch {
|
||
// ignore broken local cache
|
||
}
|
||
}
|
||
|
||
void refreshHistory();
|
||
void refreshPresets();
|
||
void refreshRuns();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!sharedConnectionSyncReadyRef.current) {
|
||
return;
|
||
}
|
||
if (connection.llmProvider !== "local") {
|
||
return;
|
||
}
|
||
const timer = window.setTimeout(() => {
|
||
void apiClient
|
||
.saveSharedConnectionConfig(connection)
|
||
.catch((error) =>
|
||
log(`Shared local config sync error: ${error instanceof Error ? error.message : String(error)}`)
|
||
);
|
||
}, 250);
|
||
return () => window.clearTimeout(timer);
|
||
}, [connection.baseUrl, connection.llmProvider, connection.maxOutputTokens, connection.model, connection.temperature]);
|
||
|
||
async function refreshHistory() {
|
||
try {
|
||
const payload = await apiClient.loadHistory();
|
||
setHistoryItems(payload.items ?? []);
|
||
} catch (error) {
|
||
log(`History load error: ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
}
|
||
|
||
async function refreshPresets() {
|
||
try {
|
||
const payload = await apiClient.loadPresets();
|
||
const presets = payload.presets ?? [];
|
||
setPresetList(presets);
|
||
if (skipPresetAutoloadRef.current) {
|
||
presetAutoloadDoneRef.current = true;
|
||
return;
|
||
}
|
||
if (presetAutoloadDoneRef.current) {
|
||
return;
|
||
}
|
||
const presetForAutoload =
|
||
presets.find((item) => item.prompt_version === AUTOLOAD_PROMPT_VERSION) ??
|
||
presets.find((item) => item.id === "default-normalizer-v2_0_2");
|
||
if (!presetForAutoload) {
|
||
presetAutoloadDoneRef.current = true;
|
||
log(`Preset autoload skipped: ${AUTOLOAD_PROMPT_VERSION} not found.`);
|
||
return;
|
||
}
|
||
setSelectedPresetId(presetForAutoload.id);
|
||
setPreviousPreset(prompts);
|
||
setPrompts({
|
||
systemPrompt: presetForAutoload.systemPrompt,
|
||
developerPrompt: presetForAutoload.developerPrompt,
|
||
domainPrompt: presetForAutoload.domainPrompt,
|
||
schemaNotes: presetForAutoload.schemaNotes ?? "",
|
||
fewShotExamples: presetForAutoload.fewShotExamples ?? ""
|
||
});
|
||
presetAutoloadDoneRef.current = true;
|
||
log(`Preset autoloaded: ${presetForAutoload.name} (${presetForAutoload.prompt_version}).`);
|
||
} catch (error) {
|
||
log(`Presets load error: ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
}
|
||
|
||
async function refreshRuns() {
|
||
try {
|
||
const payload = await apiClient.listRuns();
|
||
setRuns(payload.items ?? []);
|
||
} catch (error) {
|
||
log(`Runs load error: ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
}
|
||
|
||
function saveLocalConfig() {
|
||
localStorage.setItem(
|
||
SESSION_CONFIG_KEY,
|
||
JSON.stringify({
|
||
model: connection.model,
|
||
llmProvider: connection.llmProvider,
|
||
baseUrl: connection.baseUrl,
|
||
temperature: connection.temperature,
|
||
maxOutputTokens: connection.maxOutputTokens
|
||
})
|
||
);
|
||
if (connection.llmProvider === "local") {
|
||
void apiClient
|
||
.saveSharedConnectionConfig(connection)
|
||
.then(() => {
|
||
log("Local config saved and synced to shared agent config (without API key).");
|
||
})
|
||
.catch((error) => {
|
||
log(`Local config saved, but shared sync failed: ${error instanceof Error ? error.message : String(error)}`);
|
||
});
|
||
return;
|
||
}
|
||
log("Local config saved (without API key).");
|
||
}
|
||
|
||
function saveAutorunsLayout() {
|
||
localStorage.setItem(
|
||
AUTORUNS_LAYOUT_CONFIG_KEY,
|
||
JSON.stringify({
|
||
uiMode,
|
||
activeTab,
|
||
showAutorunsSettingsMode,
|
||
showAutorunsAutoRunsMode,
|
||
showAutorunsAssistantMode,
|
||
showAutorunsDecompositionMode,
|
||
showAutorunsProgressMode,
|
||
showAutorunsCommentsMode,
|
||
showAssistantConnectionMode,
|
||
showAssistantPromptMode,
|
||
showAssistantChatMode,
|
||
showAssistantCommentsMode,
|
||
showAssistantSamMode,
|
||
showDecompositionConnectionMode,
|
||
showDecompositionPromptMode,
|
||
showDecompositionQueryMode,
|
||
showDecompositionOutputMode,
|
||
showDecompositionMetricsMode,
|
||
showDecompositionHistoryMode,
|
||
showDecompositionRuntimeMode,
|
||
prompts
|
||
})
|
||
);
|
||
window.dispatchEvent(new CustomEvent(AUTORUNS_SAVE_EVENT));
|
||
log("UI layout and prompts saved.");
|
||
}
|
||
|
||
async function testConnection() {
|
||
setBusy(true);
|
||
setLastError("");
|
||
try {
|
||
const payload = await apiClient.testConnection(connection);
|
||
if (payload.provider === "local") {
|
||
if (payload.model_found === true) {
|
||
setConnectionStatus(`LOCAL OK - ${payload.model}`);
|
||
log(`Local model is available: ${payload.model} (catalog size=${payload.models_count ?? "n/a"}).`);
|
||
} else if (payload.model_found === false) {
|
||
setConnectionStatus(`LOCAL OK, model not loaded - ${payload.model}`);
|
||
log(
|
||
`Local server is reachable, but model '${payload.model}' is not in loaded catalog. ` +
|
||
`Use 'Load model list' and select one of loaded models.`
|
||
);
|
||
} else {
|
||
setConnectionStatus(`LOCAL OK (model list unavailable) - ${payload.model}`);
|
||
log("Local server is reachable, but model catalog could not be verified.");
|
||
}
|
||
} else {
|
||
setConnectionStatus(`OPENAI OK - ${payload.model}`);
|
||
log(`OpenAI connection ok: ${payload.model}`);
|
||
}
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
setConnectionStatus("Connection error");
|
||
setLastError(`Test connection: ${message}`);
|
||
log(`Test connection error: ${message}`);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function reloadModels() {
|
||
setModelsBusy(true);
|
||
try {
|
||
const payload = await apiClient.listModels(connection);
|
||
const models = payload.models ?? [];
|
||
setModelOptions(models);
|
||
if (models.length > 0) {
|
||
setConnection((prev) => {
|
||
if (prev.model && models.includes(prev.model)) {
|
||
return prev;
|
||
}
|
||
return { ...prev, model: models[0] };
|
||
});
|
||
}
|
||
log(`Model catalog loaded (${connection.llmProvider}): ${models.length} items.`);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
log(`Load model list error: ${message}`);
|
||
} finally {
|
||
setModelsBusy(false);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
setModelOptions([]);
|
||
}, [connection.llmProvider, connection.baseUrl]);
|
||
|
||
async function normalize(saveAsCase: boolean) {
|
||
setBusy(true);
|
||
setLastError("");
|
||
try {
|
||
const payload = await apiClient.normalize({
|
||
connection,
|
||
prompts,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
query: {
|
||
userQuestion: query.userQuestion,
|
||
periodHint: query.periodHint,
|
||
businessContext: query.businessContext,
|
||
expectedRoute: query.expectedRoute
|
||
},
|
||
saveAsTestCase: saveAsCase,
|
||
useMock
|
||
});
|
||
setResult(payload);
|
||
setActiveTab("normalized");
|
||
log(`Normalize done: trace=${payload.trace_id}, validation=${payload.validation.passed ? "passed" : "failed"}`);
|
||
void refreshHistory();
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
setLastError(`Normalize: ${message}`);
|
||
log(`Normalize error: ${message}`);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function loadSelectedPreset() {
|
||
const selected = presetList.find((item) => item.id === selectedPresetId);
|
||
if (!selected) {
|
||
log("Preset is not selected.");
|
||
return;
|
||
}
|
||
setPreviousPreset(prompts);
|
||
setPrompts({
|
||
systemPrompt: selected.systemPrompt,
|
||
developerPrompt: selected.developerPrompt,
|
||
domainPrompt: selected.domainPrompt,
|
||
schemaNotes: selected.schemaNotes ?? "",
|
||
fewShotExamples: selected.fewShotExamples ?? ""
|
||
});
|
||
log(`Preset loaded: ${selected.name}`);
|
||
}
|
||
|
||
async function savePreset() {
|
||
try {
|
||
await apiClient.savePreset({
|
||
name: presetName || "NDC preset",
|
||
prompt_version: "normalizer_v2_0_2",
|
||
systemPrompt: prompts.systemPrompt,
|
||
developerPrompt: prompts.developerPrompt,
|
||
domainPrompt: prompts.domainPrompt,
|
||
schemaNotes: prompts.schemaNotes,
|
||
fewShotExamples: prompts.fewShotExamples
|
||
});
|
||
log("Preset saved.");
|
||
await refreshPresets();
|
||
} catch (error) {
|
||
log(`Preset save error: ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
}
|
||
|
||
function resetDefaults() {
|
||
setPrompts(DEFAULT_PROMPTS);
|
||
log("Prompt panel reset to defaults.");
|
||
}
|
||
|
||
function diffWithPrevious() {
|
||
const summary = diffPrompts(prompts, previousPreset);
|
||
setDiffSummary(summary);
|
||
log(summary);
|
||
}
|
||
|
||
function applyBatchFormat() {
|
||
const formatted = query.batchQuestionsRaw
|
||
.split(";")
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
.join("\n\n");
|
||
if (!formatted) {
|
||
return;
|
||
}
|
||
setQuery((prev) => ({
|
||
...prev,
|
||
batchQuestionsRaw: formatted
|
||
}));
|
||
log("Batch field formatted: `;` converted to blank-line separators.");
|
||
}
|
||
|
||
async function openTrace(traceId: string) {
|
||
try {
|
||
const payload = await apiClient.loadTrace(traceId);
|
||
const trace = payload.trace as Record<string, unknown>;
|
||
const normalized = (trace.parsed_normalized_json ?? null) as NormalizeResultState["normalized"];
|
||
setResult({
|
||
trace_id: String(trace.trace_id ?? traceId),
|
||
ok: Boolean((trace.validation_result as { passed?: boolean } | undefined)?.passed),
|
||
normalized,
|
||
route_hint_summary:
|
||
(trace.route_hint_summary as NormalizeResultState["route_hint_summary"] | undefined) ??
|
||
(normalized
|
||
? {
|
||
route_hint: (normalized as { route_hint?: string }).route_hint ?? null,
|
||
confidence: (normalized as { confidence?: { route_hint?: string } }).confidence?.route_hint ?? null
|
||
}
|
||
: null),
|
||
raw_model_output: trace.raw_model_response ?? {},
|
||
validation: (trace.validation_result as NormalizeResultState["validation"]) ?? { passed: false, errors: ["validation not found"] },
|
||
usage: (trace.usage as NormalizeResultState["usage"]) ?? { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||
latency_ms: Number(trace.latency_ms ?? 0),
|
||
prompt_version: String(trace.prompt_version ?? "unknown"),
|
||
schema_version: String(trace.schema_version ?? "unknown")
|
||
});
|
||
setActiveTab("raw");
|
||
setLastError("");
|
||
log(`Trace opened: ${traceId}`);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
setLastError(`Trace: ${message}`);
|
||
log(`Trace open error ${traceId}: ${message}`);
|
||
}
|
||
}
|
||
|
||
async function startRun() {
|
||
try {
|
||
const payload = await apiClient.startRun();
|
||
setSelectedRunId(payload.run.runId);
|
||
log(`Run started: ${payload.run.runId}`);
|
||
log("Tip: start run does not execute normalize by itself. Use 'Run eval v2.0.2' button.");
|
||
await refreshRuns();
|
||
} catch (error) {
|
||
log(`Run start error: ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
}
|
||
|
||
async function finishRun() {
|
||
if (!selectedRunId) return;
|
||
try {
|
||
await apiClient.finishRun(selectedRunId);
|
||
log(`Run finished: ${selectedRunId}`);
|
||
await refreshRuns();
|
||
} catch (error) {
|
||
log(`Run finish error: ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
}
|
||
|
||
async function runEval() {
|
||
setEvalBusy(true);
|
||
setLastError("");
|
||
try {
|
||
log("Starting eval in v2 contour.");
|
||
const rawQuestions = query.batchQuestionsRaw.trim() || query.userQuestion.trim();
|
||
if (!rawQuestions) {
|
||
throw new Error("Fill batch field or Raw user question first.");
|
||
}
|
||
const payload = await apiClient.runEval({
|
||
connection,
|
||
prompts,
|
||
promptVersion: "normalizer_v2_0_2",
|
||
mode: "single-pass-strict",
|
||
rawQuestions,
|
||
useMock
|
||
});
|
||
setEvalReport(payload.report);
|
||
log("Eval v2.0.2 run finished.");
|
||
const report = payload.report as { metrics?: Record<string, unknown>; run_id?: string };
|
||
if (report.run_id) {
|
||
log(`Eval run id: ${report.run_id}`);
|
||
}
|
||
if (report.metrics) {
|
||
const metrics = report.metrics;
|
||
log(
|
||
`Eval metrics v2.0.2: schema=${metrics.schema_validation_pass_rate ?? "n/a"}%, route_accuracy=${metrics.route_resolution_accuracy ?? "n/a"}%, no_route_precision=${metrics.no_route_precision ?? "n/a"}%, state_consistency=${metrics.execution_state_consistency_rate ?? "n/a"}%`
|
||
);
|
||
}
|
||
await refreshHistory();
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
if (message.includes("Legacy eval runner supports normalized_query_v1 only")) {
|
||
setEvalReport({
|
||
status: "plan_only",
|
||
prompt_version: "normalizer_v2",
|
||
reason: "backend eval runner is still legacy-v1 only",
|
||
plan_file: "reports/v2_pilot_eval_plan.md",
|
||
next_steps: [
|
||
"run cheap mock sanity for schema/fragment/scope",
|
||
"run small real batch (10-15 messages, temperature=0)",
|
||
"run challenge-30 replay with v2 metrics"
|
||
]
|
||
});
|
||
log("Backend is legacy-only for eval right now. Showing v2 pilot plan.");
|
||
} else {
|
||
setLastError(`Eval: ${message}`);
|
||
log(`Eval run error: ${message}`);
|
||
}
|
||
} finally {
|
||
setEvalBusy(false);
|
||
}
|
||
}
|
||
|
||
async function copyEvalReport() {
|
||
try {
|
||
const text = JSON.stringify(evalReport ?? {}, null, 2);
|
||
await navigator.clipboard.writeText(text);
|
||
log("Eval report copied to clipboard.");
|
||
} catch (error) {
|
||
log(`Eval report copy error: ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
}
|
||
|
||
const assistantAnnotationsByMessageId = useMemo(() => {
|
||
const map = new Map<string, AssistantAnnotationRecord>();
|
||
for (const item of assistantAnnotations) {
|
||
if (item.message_id) {
|
||
map.set(item.message_id, item);
|
||
}
|
||
}
|
||
return map;
|
||
}, [assistantAnnotations]);
|
||
|
||
const assistantCommentModalMessage =
|
||
assistantCommentModal.messageIndex >= 0 ? assistantConversation[assistantCommentModal.messageIndex] ?? null : null;
|
||
|
||
const assistantCommentModalQuestion = useMemo(() => {
|
||
if (assistantCommentModal.messageIndex < 0) return null;
|
||
for (let index = assistantCommentModal.messageIndex - 1; index >= 0; index -= 1) {
|
||
const candidate = assistantConversation[index];
|
||
if (candidate?.role === "user") {
|
||
return candidate;
|
||
}
|
||
}
|
||
return null;
|
||
}, [assistantCommentModal.messageIndex, assistantConversation]);
|
||
|
||
async function loadAssistantAnnotationsForSession(sessionId: string): Promise<void> {
|
||
if (!sessionId.trim()) {
|
||
setAssistantAnnotations([]);
|
||
return;
|
||
}
|
||
setAssistantAnnotationsBusy(true);
|
||
try {
|
||
const payload = await apiClient.loadAssistantAnnotations({
|
||
session_id: sessionId,
|
||
limit: 400
|
||
});
|
||
setAssistantAnnotations(payload.items ?? []);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
log(`Assistant annotations load error: ${message}`);
|
||
} finally {
|
||
setAssistantAnnotationsBusy(false);
|
||
}
|
||
}
|
||
|
||
function closeAssistantCommentModal(options?: { force?: boolean }) {
|
||
setAssistantCommentModal((prev) => {
|
||
if (prev.saving && !options?.force) {
|
||
return prev;
|
||
}
|
||
return {
|
||
open: false,
|
||
messageIndex: -1,
|
||
rating: 3,
|
||
comment: "",
|
||
annotationAuthor: DEFAULT_ASSISTANT_ANNOTATION_AUTHOR,
|
||
saving: false,
|
||
error: ""
|
||
};
|
||
});
|
||
}
|
||
|
||
function openAssistantCommentModal(item: AssistantConversationItem, index: number): void {
|
||
if (item.role !== "assistant") return;
|
||
const sessionIdFromState = assistantSessionId.trim();
|
||
const sessionIdFromItem = String(item.session_id ?? "").trim();
|
||
const resolvedSessionId = sessionIdFromState || sessionIdFromItem;
|
||
if (!resolvedSessionId) {
|
||
setAssistantError("Сначала получите ответ ассистента в активной сессии.");
|
||
return;
|
||
}
|
||
if (!sessionIdFromState && sessionIdFromItem) {
|
||
setAssistantSessionId(sessionIdFromItem);
|
||
}
|
||
const existing = assistantAnnotationsByMessageId.get(item.message_id) ?? null;
|
||
setAssistantCommentModal({
|
||
open: true,
|
||
messageIndex: index,
|
||
rating: existing?.rating ?? 3,
|
||
comment: existing?.comment ?? "",
|
||
annotationAuthor: existing?.annotation_author ?? DEFAULT_ASSISTANT_ANNOTATION_AUTHOR,
|
||
saving: false,
|
||
error: ""
|
||
});
|
||
}
|
||
|
||
function canCommentAssistantMessage(item: AssistantConversationItem): boolean {
|
||
return item.role === "assistant";
|
||
}
|
||
|
||
function isAssistantMessageCommented(item: AssistantConversationItem): boolean {
|
||
return item.role === "assistant" && assistantAnnotationsByMessageId.has(item.message_id);
|
||
}
|
||
|
||
async function submitAssistantCommentModal(): Promise<void> {
|
||
if (!assistantSessionId.trim()) {
|
||
setAssistantCommentModal((prev) => ({ ...prev, error: "Сессия ассистента не найдена." }));
|
||
return;
|
||
}
|
||
if (assistantCommentModal.messageIndex < 0) {
|
||
return;
|
||
}
|
||
if (!assistantCommentModal.comment.trim()) {
|
||
setAssistantCommentModal((prev) => ({ ...prev, error: "Добавьте комментарий." }));
|
||
return;
|
||
}
|
||
|
||
setAssistantCommentModal((prev) => ({ ...prev, saving: true, error: "" }));
|
||
try {
|
||
const payload = await apiClient.saveAssistantAnnotation({
|
||
session_id: assistantSessionId,
|
||
message_index: assistantCommentModal.messageIndex,
|
||
rating: assistantCommentModal.rating,
|
||
comment: assistantCommentModal.comment.trim(),
|
||
annotation_author: assistantCommentModal.annotationAuthor.trim() || undefined
|
||
});
|
||
setAssistantAnnotations((prev) => {
|
||
const next = [...prev];
|
||
const index = next.findIndex((item) => item.annotation_id === payload.annotation.annotation_id);
|
||
if (index >= 0) {
|
||
next[index] = payload.annotation;
|
||
} else {
|
||
next.unshift(payload.annotation);
|
||
}
|
||
return next.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at));
|
||
});
|
||
closeAssistantCommentModal({ force: true });
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
setAssistantCommentModal((prev) => ({ ...prev, saving: false, error: message }));
|
||
}
|
||
}
|
||
|
||
function resetAssistantSession() {
|
||
setAssistantSessionId("");
|
||
setAssistantConversation([]);
|
||
setAssistantInput("");
|
||
setAssistantSelectedChip(null);
|
||
setAssistantStatus("");
|
||
setAssistantError("");
|
||
setAssistantAnnotations([]);
|
||
closeAssistantCommentModal({ force: true });
|
||
log("Assistant session reset.");
|
||
}
|
||
|
||
async function sendAssistantMessage() {
|
||
const userMessage = buildAssistantFollowupMessage(assistantInput, assistantSelectedChip);
|
||
if (!userMessage) {
|
||
return;
|
||
}
|
||
|
||
setAssistantBusy(true);
|
||
setAssistantError("");
|
||
setAssistantInput("");
|
||
setAssistantConversation((prev) => [
|
||
...prev,
|
||
{
|
||
message_id: `local-${Date.now()}`,
|
||
session_id: assistantSessionId || "pending",
|
||
role: "user",
|
||
text: userMessage,
|
||
reply_type: null,
|
||
created_at: new Date().toISOString(),
|
||
trace_id: null,
|
||
debug: null
|
||
}
|
||
]);
|
||
|
||
const stopTicker = startAssistantStatusTicker();
|
||
try {
|
||
const response = await apiClient.sendAssistantMessage({
|
||
connection,
|
||
prompts,
|
||
userMessage,
|
||
sessionId: assistantSessionId || undefined,
|
||
promptVersion: ASSISTANT_PROMPT_VERSION,
|
||
useMock
|
||
});
|
||
setAssistantSessionId(response.session_id);
|
||
setAssistantConversation(response.conversation);
|
||
setAssistantStatus("Ответ готов");
|
||
await loadAssistantAnnotationsForSession(response.session_id);
|
||
log(`Assistant reply received: trace=${response.debug.trace_id}`);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
setAssistantError(message);
|
||
setAssistantStatus("Ошибка ассистента");
|
||
log(`Assistant error: ${message}`);
|
||
} finally {
|
||
stopTicker();
|
||
setAssistantBusy(false);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!assistantSessionId.trim()) {
|
||
setAssistantAnnotations([]);
|
||
return;
|
||
}
|
||
void loadAssistantAnnotationsForSession(assistantSessionId);
|
||
}, [assistantSessionId]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedRunId) {
|
||
setRunTrace([]);
|
||
return;
|
||
}
|
||
void apiClient
|
||
.runTrace(selectedRunId)
|
||
.then((payload) => setRunTrace(payload.items))
|
||
.catch((error) => log(`Run trace error: ${error instanceof Error ? error.message : String(error)}`));
|
||
}, [selectedRunId]);
|
||
|
||
return (
|
||
<main
|
||
className={`app-root ${
|
||
uiMode === "assistant" || uiMode === "decomposition" || uiMode === "autoruns" ? "app-root-autoruns" : ""
|
||
}`}
|
||
>
|
||
<header className="app-topbar">
|
||
<div className="mode-switch-row">
|
||
<button type="button" className={uiMode === "autoruns" ? "tab active" : "tab"} onClick={() => setUiMode("autoruns")}>
|
||
Управление ассистентом
|
||
</button>
|
||
<button type="button" className={uiMode === "decomposition" ? "tab active" : "tab"} onClick={() => setUiMode("decomposition")}>
|
||
Декомпозиция
|
||
</button>
|
||
<button type="button" className="tab" onClick={saveAutorunsLayout}>
|
||
Сохранить
|
||
</button>
|
||
</div>
|
||
|
||
{uiMode === "assistant" ? (
|
||
<div className="mode-switch-row mode-switch-row-right">
|
||
<button
|
||
type="button"
|
||
className={showAssistantConnectionMode ? "tab active" : "tab"}
|
||
onClick={() => setShowAssistantConnectionMode((prev) => !prev)}
|
||
>
|
||
LLM Connector
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={showAssistantPromptMode ? "tab active" : "tab"}
|
||
onClick={() => setShowAssistantPromptMode((prev) => !prev)}
|
||
>
|
||
Prompt Manager
|
||
</button>
|
||
<button type="button" className={showAssistantChatMode ? "tab active" : "tab"} onClick={() => setShowAssistantChatMode((prev) => !prev)}>
|
||
Режим ассистента
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={showAssistantCommentsMode ? "tab active" : "tab"}
|
||
onClick={() => setShowAssistantCommentsMode((prev) => !prev)}
|
||
>
|
||
Комментарии ассистента
|
||
</button>
|
||
<button type="button" className={showAssistantSamMode ? "tab active" : "tab"} onClick={() => setShowAssistantSamMode((prev) => !prev)}>
|
||
SAM
|
||
</button>
|
||
</div>
|
||
) : uiMode === "decomposition" ? (
|
||
<div className="mode-switch-row mode-switch-row-right">
|
||
<button
|
||
type="button"
|
||
className={showDecompositionConnectionMode ? "tab active" : "tab"}
|
||
onClick={() => setShowDecompositionConnectionMode((prev) => !prev)}
|
||
>
|
||
LLM
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={showDecompositionPromptMode ? "tab active" : "tab"}
|
||
onClick={() => setShowDecompositionPromptMode((prev) => !prev)}
|
||
>
|
||
Prompt
|
||
</button>
|
||
<button type="button" className={showDecompositionQueryMode ? "tab active" : "tab"} onClick={() => setShowDecompositionQueryMode((prev) => !prev)}>
|
||
Запрос
|
||
</button>
|
||
<button type="button" className={showDecompositionOutputMode ? "tab active" : "tab"} onClick={() => setShowDecompositionOutputMode((prev) => !prev)}>
|
||
Выход
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={showDecompositionMetricsMode ? "tab active" : "tab"}
|
||
onClick={() => setShowDecompositionMetricsMode((prev) => !prev)}
|
||
>
|
||
Метрики
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={showDecompositionHistoryMode ? "tab active" : "tab"}
|
||
onClick={() => setShowDecompositionHistoryMode((prev) => !prev)}
|
||
>
|
||
История
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={showDecompositionRuntimeMode ? "tab active" : "tab"}
|
||
onClick={() => setShowDecompositionRuntimeMode((prev) => !prev)}
|
||
>
|
||
NDC Run Monitor
|
||
</button>
|
||
</div>
|
||
) : uiMode === "autoruns" ? (
|
||
<div className="mode-switch-row mode-switch-row-right">
|
||
<button
|
||
type="button"
|
||
className={showAutorunsSettingsMode ? "tab active" : "tab"}
|
||
onClick={() => setShowAutorunsSettingsMode((prev) => !prev)}
|
||
>
|
||
Настройки
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={showAutorunsAutoRunsMode ? "tab active" : "tab"}
|
||
onClick={() => setShowAutorunsAutoRunsMode((prev) => !prev)}
|
||
>
|
||
Автопрогоны
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={showAutorunsAssistantMode ? "tab active" : "tab"}
|
||
onClick={() => setShowAutorunsAssistantMode((prev) => !prev)}
|
||
>
|
||
Режим ассистента
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={showAutorunsDecompositionMode ? "tab active" : "tab"}
|
||
onClick={() => setShowAutorunsDecompositionMode((prev) => !prev)}
|
||
>
|
||
Режим декомпозиции
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={showAutorunsProgressMode ? "tab active" : "tab"}
|
||
onClick={() => setShowAutorunsProgressMode((prev) => !prev)}
|
||
>
|
||
Прогресс/регресс
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={showAutorunsCommentsMode ? "tab active" : "tab"}
|
||
onClick={() => setShowAutorunsCommentsMode((prev) => !prev)}
|
||
>
|
||
Комментарии
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</header>
|
||
|
||
{uiMode === "assistant" ? (
|
||
<div className="layout-grid layout-grid-mode-columns">
|
||
<div className="mode-columns">
|
||
{showAssistantConnectionMode ? (
|
||
<div className="mode-col">
|
||
<ConnectionPanel
|
||
value={connection}
|
||
modelOptions={modelOptions}
|
||
modelsBusy={modelsBusy}
|
||
onChange={setConnection}
|
||
onReloadModels={reloadModels}
|
||
onSaveLocalConfig={saveLocalConfig}
|
||
onTestConnection={testConnection}
|
||
lastStatus={connectionStatus}
|
||
busy={busy || assistantBusy}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{showAssistantPromptMode ? (
|
||
<div className="mode-col mode-col-wide">
|
||
<PromptPanel
|
||
value={prompts}
|
||
onChange={setPrompts}
|
||
presets={presetList}
|
||
selectedPresetId={selectedPresetId}
|
||
onSelectPreset={setSelectedPresetId}
|
||
onLoadPreset={loadSelectedPreset}
|
||
onSavePreset={savePreset}
|
||
onResetDefaults={resetDefaults}
|
||
onDiffPrevious={diffWithPrevious}
|
||
presetName={presetName}
|
||
onPresetNameChange={setPresetName}
|
||
diffSummary={diffSummary}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{showAssistantChatMode ? (
|
||
<div className="mode-col mode-col-xwide">
|
||
<AssistantPanel
|
||
sessionId={assistantSessionId}
|
||
conversation={assistantConversation}
|
||
inputValue={assistantInput}
|
||
onInputChange={setAssistantInput}
|
||
selectedContextChip={assistantSelectedChip}
|
||
onSelectContextChip={setAssistantSelectedChip}
|
||
onClearContextChip={() => setAssistantSelectedChip(null)}
|
||
useMock={useMock}
|
||
onUseMockChange={setUseMock}
|
||
onSend={sendAssistantMessage}
|
||
onClear={resetAssistantSession}
|
||
busy={assistantBusy}
|
||
statusText={assistantStatus}
|
||
errorMessage={assistantError}
|
||
showCommentAction
|
||
onCommentAssistantMessage={openAssistantCommentModal}
|
||
isAssistantMessageCommented={isAssistantMessageCommented}
|
||
canCommentAssistantMessage={canCommentAssistantMessage}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{showAssistantCommentsMode ? (
|
||
<div className="mode-col">
|
||
<PanelFrame className="assistant-comments-frame" title="Комментарии ассистента">
|
||
<div className="assistant-comments-shell">
|
||
<div className="assistant-comments-toolbar">
|
||
<span className="muted">
|
||
{assistantSessionId ? `session: ${assistantSessionId}` : "Сессия не запущена"}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="tab"
|
||
onClick={() => void loadAssistantAnnotationsForSession(assistantSessionId)}
|
||
disabled={!assistantSessionId || assistantAnnotationsBusy}
|
||
>
|
||
{assistantAnnotationsBusy ? "Обновляю..." : "Обновить"}
|
||
</button>
|
||
</div>
|
||
<div className="assistant-comments-list">
|
||
{!assistantSessionId ? <p className="muted">Появится после первого ответа ассистента.</p> : null}
|
||
{assistantSessionId && assistantAnnotations.length === 0 && !assistantAnnotationsBusy ? (
|
||
<p className="muted">Комментариев по этой сессии пока нет.</p>
|
||
) : null}
|
||
{assistantAnnotations.map((item) => (
|
||
<article key={item.annotation_id} className="assistant-comment-item">
|
||
<div className="assistant-comment-head">
|
||
<strong>{`${"●".repeat(Math.max(1, Math.min(5, Math.round(item.rating))))}${"○".repeat(Math.max(0, 5 - Math.round(item.rating)))}`}</strong>
|
||
<span>{new Date(item.updated_at).toLocaleString("ru-RU")}</span>
|
||
</div>
|
||
{item.context.question_text ? <p>Q: {item.context.question_text}</p> : null}
|
||
{item.context.answer_text ? <p>A: {item.context.answer_text}</p> : null}
|
||
<p>{item.comment}</p>
|
||
<div className="assistant-comment-meta">
|
||
{item.context.trace_id ? <span>{`trace=${item.context.trace_id}`}</span> : null}
|
||
{item.context.reply_type ? <span>{`reply_type=${item.context.reply_type}`}</span> : null}
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</PanelFrame>
|
||
</div>
|
||
) : null}
|
||
|
||
{showAssistantSamMode ? (
|
||
<div className="mode-col">
|
||
<AssistantSamPanel
|
||
sessionId={assistantSessionId}
|
||
conversation={assistantConversation}
|
||
statusText={assistantStatus}
|
||
errorMessage={assistantError}
|
||
useMock={useMock}
|
||
appLogs={appLogs}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{!showAssistantConnectionMode &&
|
||
!showAssistantPromptMode &&
|
||
!showAssistantChatMode &&
|
||
!showAssistantCommentsMode &&
|
||
!showAssistantSamMode ? (
|
||
<div className="mode-columns-empty">Все панели режима ассистента скрыты. Включите нужные блоки справа в шапке.</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : uiMode === "decomposition" ? (
|
||
<div className="layout-grid layout-grid-mode-columns">
|
||
<div className="mode-columns">
|
||
{showDecompositionConnectionMode ? (
|
||
<div className="mode-col">
|
||
<ConnectionPanel
|
||
value={connection}
|
||
modelOptions={modelOptions}
|
||
modelsBusy={modelsBusy}
|
||
onChange={setConnection}
|
||
onReloadModels={reloadModels}
|
||
onSaveLocalConfig={saveLocalConfig}
|
||
onTestConnection={testConnection}
|
||
lastStatus={connectionStatus}
|
||
busy={busy}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{showDecompositionPromptMode ? (
|
||
<div className="mode-col mode-col-wide">
|
||
<PromptPanel
|
||
value={prompts}
|
||
onChange={setPrompts}
|
||
presets={presetList}
|
||
selectedPresetId={selectedPresetId}
|
||
onSelectPreset={setSelectedPresetId}
|
||
onLoadPreset={loadSelectedPreset}
|
||
onSavePreset={savePreset}
|
||
onResetDefaults={resetDefaults}
|
||
onDiffPrevious={diffWithPrevious}
|
||
presetName={presetName}
|
||
onPresetNameChange={setPresetName}
|
||
diffSummary={diffSummary}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{showDecompositionQueryMode ? (
|
||
<div className="mode-col">
|
||
<QueryPanel
|
||
value={query}
|
||
onChange={setQuery}
|
||
onApplyBatchFormat={applyBatchFormat}
|
||
onNormalize={normalize}
|
||
busy={busy}
|
||
useMock={useMock}
|
||
onUseMockChange={setUseMock}
|
||
errorMessage={lastError}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{showDecompositionOutputMode ? (
|
||
<div className="mode-col mode-col-xwide">
|
||
<OutputPanel tab={activeTab} onTabChange={setActiveTab} result={result} appLogs={appLogs} />
|
||
</div>
|
||
) : null}
|
||
|
||
{showDecompositionMetricsMode ? (
|
||
<div className="mode-col">
|
||
<MetricsPanel result={result} />
|
||
</div>
|
||
) : null}
|
||
|
||
{showDecompositionHistoryMode ? (
|
||
<div className="mode-col">
|
||
<HistoryPanel items={historyItems} onRefresh={refreshHistory} onOpenTrace={openTrace} />
|
||
</div>
|
||
) : null}
|
||
|
||
{showDecompositionRuntimeMode ? (
|
||
<div className="mode-col mode-col-xwide">
|
||
<RuntimePanel
|
||
runs={runs}
|
||
selectedRunId={selectedRunId}
|
||
onSelectRun={setSelectedRunId}
|
||
onStartRun={startRun}
|
||
onFinishRun={finishRun}
|
||
onRefreshRuns={refreshRuns}
|
||
onRunEval={runEval}
|
||
onCopyEvalReport={copyEvalReport}
|
||
evalBusy={evalBusy}
|
||
traceItems={runTrace}
|
||
evalReport={evalReport}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{!showDecompositionConnectionMode &&
|
||
!showDecompositionPromptMode &&
|
||
!showDecompositionQueryMode &&
|
||
!showDecompositionOutputMode &&
|
||
!showDecompositionMetricsMode &&
|
||
!showDecompositionHistoryMode &&
|
||
!showDecompositionRuntimeMode ? (
|
||
<div className="mode-columns-empty">Все панели режима декомпозиции скрыты. Включите нужные блоки справа в шапке.</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="layout-grid layout-grid-autoruns">
|
||
<AutoRunsHistoryPanel
|
||
connection={connection}
|
||
modelOptions={modelOptions}
|
||
modelsBusy={modelsBusy}
|
||
connectionStatus={connectionStatus}
|
||
connectionBusy={busy}
|
||
onConnectionChange={setConnection}
|
||
onReloadModels={reloadModels}
|
||
onSaveLocalConfig={saveLocalConfig}
|
||
onTestConnection={testConnection}
|
||
prompts={prompts}
|
||
onPromptsChange={setPrompts}
|
||
promptPresets={presetList}
|
||
selectedPresetId={selectedPresetId}
|
||
onSelectPreset={setSelectedPresetId}
|
||
onLoadPreset={loadSelectedPreset}
|
||
onSavePreset={savePreset}
|
||
onResetDefaults={resetDefaults}
|
||
onDiffPrevious={diffWithPrevious}
|
||
presetName={presetName}
|
||
onPresetNameChange={setPresetName}
|
||
diffSummary={diffSummary}
|
||
assistantPromptVersion={ASSISTANT_PROMPT_VERSION}
|
||
decompositionPromptVersion={AUTOLOAD_PROMPT_VERSION}
|
||
showSettingsMode={showAutorunsSettingsMode}
|
||
showAutoRunsMode={showAutorunsAutoRunsMode}
|
||
showAssistantMode={showAutorunsAssistantMode}
|
||
showDecompositionMode={showAutorunsDecompositionMode}
|
||
showProgressMode={showAutorunsProgressMode}
|
||
showCommentsMode={showAutorunsCommentsMode}
|
||
onLog={log}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{assistantCommentModal.open ? (
|
||
<div
|
||
className="autoruns-comment-modal-backdrop"
|
||
onClick={(event) => {
|
||
if (event.target === event.currentTarget) {
|
||
closeAssistantCommentModal();
|
||
}
|
||
}}
|
||
>
|
||
<div className="autoruns-comment-modal">
|
||
<h3>Комментарий к ответу ассистента</h3>
|
||
<p className="muted">Эта разметка хранится отдельно от комментариев автопрогонов.</p>
|
||
|
||
{assistantCommentModalQuestion ? (
|
||
<details className="autoruns-prompt-details" open>
|
||
<summary>Вопрос пользователя</summary>
|
||
<p className="autoruns-comment-quote">{assistantCommentModalQuestion.text}</p>
|
||
</details>
|
||
) : null}
|
||
{assistantCommentModalMessage ? (
|
||
<details className="autoruns-prompt-details" open>
|
||
<summary>Ответ ассистента</summary>
|
||
<p className="autoruns-comment-quote">{assistantCommentModalMessage.text}</p>
|
||
</details>
|
||
) : null}
|
||
|
||
<div className="autoruns-rating-row" role="group" aria-label="Рейтинг ответа">
|
||
{[1, 2, 3, 4, 5].map((value) => (
|
||
<button
|
||
key={value}
|
||
type="button"
|
||
className={assistantCommentModal.rating >= value ? "autoruns-rating-dot active" : "autoruns-rating-dot"}
|
||
onClick={() => setAssistantCommentModal((prev) => ({ ...prev, rating: value }))}
|
||
disabled={assistantCommentModal.saving}
|
||
aria-label={`Оценка ${value}`}
|
||
>
|
||
{assistantCommentModal.rating >= value ? "●" : "○"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="autoruns-form-grid">
|
||
<label>
|
||
Автор комментария
|
||
<input
|
||
value={assistantCommentModal.annotationAuthor}
|
||
onChange={(event) => setAssistantCommentModal((prev) => ({ ...prev, annotationAuthor: event.target.value }))}
|
||
placeholder="manual_reviewer"
|
||
disabled={assistantCommentModal.saving}
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<label>
|
||
Комментарий
|
||
<textarea
|
||
value={assistantCommentModal.comment}
|
||
onChange={(event) => setAssistantCommentModal((prev) => ({ ...prev, comment: event.target.value }))}
|
||
placeholder="Что именно не так в ответе и что проверить."
|
||
rows={4}
|
||
disabled={assistantCommentModal.saving}
|
||
/>
|
||
</label>
|
||
|
||
{assistantCommentModal.error ? <p className="error-text">{assistantCommentModal.error}</p> : null}
|
||
|
||
<div className="button-row">
|
||
<button type="button" onClick={() => void submitAssistantCommentModal()} disabled={assistantCommentModal.saving}>
|
||
{assistantCommentModal.saving ? "Сохраняю..." : "Готово"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="tab"
|
||
onClick={() => closeAssistantCommentModal()}
|
||
disabled={assistantCommentModal.saving}
|
||
>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</main>
|
||
);
|
||
}
|