NODEDC_1C/llm_normalizer/frontend/src/App.tsx

1446 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}