Initial import NDC_1C
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NDC AI Normalizer Playground</title>
|
||||
<script type="module" crossorigin src="/assets/index-B-bUXClb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D1qe5c2Q.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NDC AI Normalizer Playground</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1858
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "llm-normalizer-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5174",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 4174"
|
||||
},
|
||||
"dependencies": {
|
||||
"llm-normalizer-workspace": "file:..",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.24",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiClient } from "./api/client";
|
||||
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 { 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 type {
|
||||
AssistantConversationItem,
|
||||
ConnectionState,
|
||||
HistoryItem,
|
||||
NormalizeResultState,
|
||||
PromptState,
|
||||
QueryState,
|
||||
RuntimeRun,
|
||||
TabKey,
|
||||
UiMode
|
||||
} from "./state/types";
|
||||
|
||||
const SESSION_CONFIG_KEY = "ndc_normalizer_session_config_v1";
|
||||
const ASSISTANT_STAGES = ["Разбираю запрос", "Ищу данные", "Собираю ответ"];
|
||||
|
||||
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(" | ")}`;
|
||||
}
|
||||
|
||||
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 [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>("decomposition");
|
||||
const [assistantSessionId, setAssistantSessionId] = useState("");
|
||||
const [assistantConversation, setAssistantConversation] = useState<AssistantConversationItem[]>([]);
|
||||
const [assistantInput, setAssistantInput] = useState("");
|
||||
const [assistantBusy, setAssistantBusy] = useState(false);
|
||||
const [assistantStatus, setAssistantStatus] = useState("");
|
||||
const [assistantError, setAssistantError] = useState("");
|
||||
|
||||
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 cached = localStorage.getItem(SESSION_CONFIG_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached) as Partial<ConnectionState>;
|
||||
setConnection((prev) => ({
|
||||
...prev,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
void refreshHistory();
|
||||
void refreshPresets();
|
||||
void refreshRuns();
|
||||
}, []);
|
||||
|
||||
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();
|
||||
setPresetList(payload.presets ?? []);
|
||||
} 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,
|
||||
baseUrl: connection.baseUrl,
|
||||
temperature: connection.temperature,
|
||||
maxOutputTokens: connection.maxOutputTokens
|
||||
})
|
||||
);
|
||||
log("Local config saved (without API key).");
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
setBusy(true);
|
||||
setLastError("");
|
||||
try {
|
||||
const payload = await apiClient.testConnection(connection);
|
||||
setConnectionStatus(`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 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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resetAssistantSession() {
|
||||
setAssistantSessionId("");
|
||||
setAssistantConversation([]);
|
||||
setAssistantInput("");
|
||||
setAssistantStatus("");
|
||||
setAssistantError("");
|
||||
log("Assistant session reset.");
|
||||
}
|
||||
|
||||
async function sendAssistantMessage() {
|
||||
const userMessage = assistantInput.trim();
|
||||
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: "normalizer_v2_0_2",
|
||||
context: {
|
||||
periodHint: query.periodHint,
|
||||
businessContext: query.businessContext
|
||||
},
|
||||
useMock
|
||||
});
|
||||
setAssistantSessionId(response.session_id);
|
||||
setAssistantConversation(response.conversation);
|
||||
setAssistantStatus("Ответ готов");
|
||||
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 (!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">
|
||||
<div className="hero">
|
||||
<h1>NDC AI First Layer</h1>
|
||||
<p>Два режима в одном интерфейсе: диагностика декомпозиции и диалоговый ассистент на общем backend-контуре.</p>
|
||||
</div>
|
||||
|
||||
<div className="mode-switch-row">
|
||||
<button type="button" className={uiMode === "assistant" ? "tab active" : "tab"} onClick={() => setUiMode("assistant")}>
|
||||
Ассистент
|
||||
</button>
|
||||
<button type="button" className={uiMode === "decomposition" ? "tab active" : "tab"} onClick={() => setUiMode("decomposition")}>
|
||||
Декомпозиция
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{uiMode === "assistant" ? (
|
||||
<div className="layout-grid">
|
||||
<ConnectionPanel
|
||||
value={connection}
|
||||
onChange={setConnection}
|
||||
onSaveLocalConfig={saveLocalConfig}
|
||||
onTestConnection={testConnection}
|
||||
lastStatus={connectionStatus}
|
||||
busy={busy || assistantBusy}
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
|
||||
<AssistantPanel
|
||||
sessionId={assistantSessionId}
|
||||
conversation={assistantConversation}
|
||||
inputValue={assistantInput}
|
||||
onInputChange={setAssistantInput}
|
||||
periodHint={query.periodHint}
|
||||
onPeriodHintChange={(value) => setQuery((prev) => ({ ...prev, periodHint: value }))}
|
||||
businessContext={query.businessContext}
|
||||
onBusinessContextChange={(value) => setQuery((prev) => ({ ...prev, businessContext: value }))}
|
||||
useMock={useMock}
|
||||
onUseMockChange={setUseMock}
|
||||
onSend={sendAssistantMessage}
|
||||
onClear={resetAssistantSession}
|
||||
busy={assistantBusy}
|
||||
statusText={assistantStatus}
|
||||
errorMessage={assistantError}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="layout-grid">
|
||||
<ConnectionPanel
|
||||
value={connection}
|
||||
onChange={setConnection}
|
||||
onSaveLocalConfig={saveLocalConfig}
|
||||
onTestConnection={testConnection}
|
||||
lastStatus={connectionStatus}
|
||||
busy={busy}
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
|
||||
<QueryPanel
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
onApplyBatchFormat={applyBatchFormat}
|
||||
onNormalize={normalize}
|
||||
busy={busy}
|
||||
useMock={useMock}
|
||||
onUseMockChange={setUseMock}
|
||||
errorMessage={lastError}
|
||||
/>
|
||||
|
||||
<OutputPanel tab={activeTab} onTabChange={setActiveTab} result={result} appLogs={appLogs} />
|
||||
|
||||
<MetricsPanel result={result} />
|
||||
|
||||
<HistoryPanel items={historyItems} onRefresh={refreshHistory} onOpenTrace={openTrace} />
|
||||
|
||||
<RuntimePanel
|
||||
runs={runs}
|
||||
selectedRunId={selectedRunId}
|
||||
onSelectRun={setSelectedRunId}
|
||||
onStartRun={startRun}
|
||||
onFinishRun={finishRun}
|
||||
onRefreshRuns={refreshRuns}
|
||||
onRunEval={runEval}
|
||||
onCopyEvalReport={copyEvalReport}
|
||||
evalBusy={evalBusy}
|
||||
traceItems={runTrace}
|
||||
evalReport={evalReport}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import type {
|
||||
AssistantMessageResultState,
|
||||
AssistantConversationItem,
|
||||
ConnectionState,
|
||||
HistoryItem,
|
||||
NormalizeResultState,
|
||||
PromptState,
|
||||
RuntimeRun
|
||||
} from "../state/types";
|
||||
|
||||
const PREFIX = "/api";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${PREFIX}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {})
|
||||
}
|
||||
});
|
||||
const payload = (await response.json()) as T & { error?: { message?: string } };
|
||||
if (!response.ok) {
|
||||
const message = (payload as { error?: { message?: string } }).error?.message ?? "Ошибка запроса";
|
||||
throw new Error(message);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
async testConnection(connection: ConnectionState): Promise<{ ok: boolean; model: string; timestamp: string }> {
|
||||
return request("/openai/test-connection", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
apiKey: connection.apiKey,
|
||||
model: connection.model,
|
||||
baseUrl: connection.baseUrl
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
async normalize(params: {
|
||||
connection: ConnectionState;
|
||||
prompts: PromptState;
|
||||
promptVersion?: string;
|
||||
query: {
|
||||
userQuestion: string;
|
||||
periodHint?: string;
|
||||
businessContext?: string;
|
||||
expectedRoute?: string;
|
||||
};
|
||||
saveAsTestCase?: boolean;
|
||||
useMock?: boolean;
|
||||
}): Promise<NormalizeResultState> {
|
||||
return request("/normalize", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
apiKey: params.connection.apiKey,
|
||||
model: params.connection.model,
|
||||
baseUrl: params.connection.baseUrl,
|
||||
temperature: params.connection.temperature,
|
||||
maxOutputTokens: params.connection.maxOutputTokens,
|
||||
promptVersion: params.promptVersion,
|
||||
systemPrompt: params.prompts.systemPrompt,
|
||||
developerPrompt: params.prompts.developerPrompt,
|
||||
domainPrompt: params.prompts.domainPrompt,
|
||||
fewShotExamples: params.prompts.fewShotExamples,
|
||||
userQuestion: params.query.userQuestion,
|
||||
context: {
|
||||
period_hint: params.query.periodHint ?? "",
|
||||
business_context: params.query.businessContext ?? "",
|
||||
expected_route: params.query.expectedRoute ?? ""
|
||||
},
|
||||
saveAsTestCase: Boolean(params.saveAsTestCase),
|
||||
useMock: Boolean(params.useMock)
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
async loadHistory(): Promise<{ ok: boolean; items: HistoryItem[] }> {
|
||||
return request("/history");
|
||||
},
|
||||
|
||||
async loadTrace(traceId: string): Promise<{ ok: boolean; trace: unknown }> {
|
||||
return request(`/history/${traceId}`);
|
||||
},
|
||||
|
||||
async loadPresets(): Promise<{
|
||||
ok: boolean;
|
||||
presets: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
prompt_version: string;
|
||||
systemPrompt: string;
|
||||
developerPrompt: string;
|
||||
domainPrompt: string;
|
||||
schemaNotes?: string;
|
||||
fewShotExamples?: string;
|
||||
}>;
|
||||
}> {
|
||||
return request("/presets");
|
||||
},
|
||||
|
||||
async savePreset(input: {
|
||||
id?: string;
|
||||
name: string;
|
||||
prompt_version?: string;
|
||||
systemPrompt: string;
|
||||
developerPrompt: string;
|
||||
domainPrompt: string;
|
||||
schemaNotes?: string;
|
||||
fewShotExamples?: string;
|
||||
}): Promise<{ ok: boolean }> {
|
||||
return request("/presets/save", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
},
|
||||
|
||||
async runEval(input: {
|
||||
connection: ConnectionState;
|
||||
prompts: PromptState;
|
||||
promptVersion?: string;
|
||||
caseIds?: string[];
|
||||
useMock?: boolean;
|
||||
mode?: "standard" | "single-pass-strict";
|
||||
caseSetFile?: string;
|
||||
rawQuestions?: string;
|
||||
}): Promise<{ ok: boolean; report: unknown }> {
|
||||
return request("/eval/run", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
normalizeConfig: {
|
||||
apiKey: input.connection.apiKey,
|
||||
model: input.connection.model,
|
||||
baseUrl: input.connection.baseUrl,
|
||||
temperature: input.connection.temperature,
|
||||
maxOutputTokens: input.connection.maxOutputTokens,
|
||||
promptVersion: input.promptVersion,
|
||||
systemPrompt: input.prompts.systemPrompt,
|
||||
developerPrompt: input.prompts.developerPrompt,
|
||||
domainPrompt: input.prompts.domainPrompt,
|
||||
fewShotExamples: input.prompts.fewShotExamples
|
||||
},
|
||||
caseIds: input.caseIds,
|
||||
useMock: Boolean(input.useMock),
|
||||
mode: input.mode ?? "standard",
|
||||
caseSetFile: input.caseSetFile,
|
||||
rawQuestions: input.rawQuestions
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
async startRun(): Promise<{ ok: boolean; run: RuntimeRun; runId: string; sessionId: string; status: string }> {
|
||||
return request("/accounting-agent/v1/runs/start", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
initiator: "ndc_operator",
|
||||
source: "gui"
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
async finishRun(runId: string): Promise<{ ok: boolean; run: RuntimeRun }> {
|
||||
return request("/accounting-agent/v1/runs/finish", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
runId,
|
||||
status: "DONE",
|
||||
source: "gui",
|
||||
reason: "Остановлено оператором из GUI"
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
async listRuns(): Promise<{ ok: boolean; items: RuntimeRun[] }> {
|
||||
return request("/accounting-agent/v1/runs");
|
||||
},
|
||||
|
||||
async listResults(): Promise<{ ok: boolean; items: unknown[] }> {
|
||||
return request("/accounting-agent/v1/results");
|
||||
},
|
||||
|
||||
async runTrace(runId: string): Promise<{ ok: boolean; items: unknown[] }> {
|
||||
return request(`/accounting-agent/v1/trace/run/${runId}`);
|
||||
},
|
||||
|
||||
async sendAssistantMessage(input: {
|
||||
connection: ConnectionState;
|
||||
prompts: PromptState;
|
||||
userMessage: string;
|
||||
sessionId?: string;
|
||||
promptVersion?: string;
|
||||
context?: {
|
||||
periodHint?: string;
|
||||
businessContext?: string;
|
||||
};
|
||||
useMock?: boolean;
|
||||
}): Promise<AssistantMessageResultState> {
|
||||
return request("/assistant/message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
session_id: input.sessionId ?? "",
|
||||
mode: "assistant",
|
||||
message: input.userMessage,
|
||||
user_message: input.userMessage,
|
||||
apiKey: input.connection.apiKey,
|
||||
model: input.connection.model,
|
||||
baseUrl: input.connection.baseUrl,
|
||||
temperature: input.connection.temperature,
|
||||
maxOutputTokens: input.connection.maxOutputTokens,
|
||||
promptVersion: input.promptVersion ?? "normalizer_v2_0_2",
|
||||
systemPrompt: input.prompts.systemPrompt,
|
||||
developerPrompt: input.prompts.developerPrompt,
|
||||
domainPrompt: input.prompts.domainPrompt,
|
||||
fewShotExamples: input.prompts.fewShotExamples,
|
||||
context: {
|
||||
period_hint: input.context?.periodHint ?? "",
|
||||
business_context: input.context?.businessContext ?? ""
|
||||
},
|
||||
useMock: Boolean(input.useMock)
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
async loadAssistantSession(sessionId: string): Promise<{ ok: boolean; session: { items: AssistantConversationItem[] } }> {
|
||||
return request(`/assistant/session/${sessionId}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { AssistantConversationItem } from "../state/types";
|
||||
import { JsonView } from "./JsonView";
|
||||
import { PanelFrame } from "./PanelFrame";
|
||||
|
||||
interface AssistantPanelProps {
|
||||
sessionId: string;
|
||||
conversation: AssistantConversationItem[];
|
||||
inputValue: string;
|
||||
onInputChange: (value: string) => void;
|
||||
periodHint: string;
|
||||
onPeriodHintChange: (value: string) => void;
|
||||
businessContext: string;
|
||||
onBusinessContextChange: (value: string) => void;
|
||||
useMock: boolean;
|
||||
onUseMockChange: (value: boolean) => void;
|
||||
onSend: () => Promise<void> | void;
|
||||
onClear: () => void;
|
||||
busy: boolean;
|
||||
statusText: string;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
function roleLabel(role: AssistantConversationItem["role"]): string {
|
||||
return role === "assistant" ? "Ассистент" : "Вы";
|
||||
}
|
||||
|
||||
function shortTime(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return iso;
|
||||
}
|
||||
return date.toLocaleTimeString("ru-RU");
|
||||
}
|
||||
|
||||
export function AssistantPanel({
|
||||
sessionId,
|
||||
conversation,
|
||||
inputValue,
|
||||
onInputChange,
|
||||
periodHint,
|
||||
onPeriodHintChange,
|
||||
businessContext,
|
||||
onBusinessContextChange,
|
||||
useMock,
|
||||
onUseMockChange,
|
||||
onSend,
|
||||
onClear,
|
||||
busy,
|
||||
statusText,
|
||||
errorMessage
|
||||
}: AssistantPanelProps) {
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [conversation, statusText]);
|
||||
|
||||
return (
|
||||
<PanelFrame
|
||||
title="Режим ассистента"
|
||||
subtitle="Диалоговый слой поверх normalizer, маршрутизации и factual retrieval."
|
||||
actions={<span className="status-chip">{sessionId ? `session: ${sessionId}` : "новая сессия"}</span>}
|
||||
>
|
||||
<div ref={listRef} className="assistant-chat-list">
|
||||
{conversation.length === 0 ? (
|
||||
<div className="assistant-empty muted">Диалог пуст. Отправьте первый вопрос, чтобы запустить контур ассистента.</div>
|
||||
) : null}
|
||||
{conversation.map((item) => (
|
||||
<article key={item.message_id} className={`assistant-msg ${item.role}`}>
|
||||
<header className="assistant-msg-head">
|
||||
<strong>{roleLabel(item.role)}</strong>
|
||||
<span>{shortTime(item.created_at)}</span>
|
||||
</header>
|
||||
<div className="assistant-msg-body">{item.text}</div>
|
||||
{item.role === "assistant" && item.debug ? (
|
||||
<details className="assistant-debug">
|
||||
<summary>Показать технический разбор</summary>
|
||||
<JsonView value={item.debug} />
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="assistant-compose">
|
||||
<div className="grid-two">
|
||||
<label>
|
||||
Подсказка по периоду
|
||||
<input value={periodHint} onChange={(event) => onPeriodHintChange(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Бизнес-контекст
|
||||
<input value={businessContext} onChange={(event) => onBusinessContextChange(event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="full-width">
|
||||
Сообщение
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={(event) => onInputChange(event.target.value)}
|
||||
rows={4}
|
||||
placeholder="Введите вопрос к данным компании..."
|
||||
/>
|
||||
</label>
|
||||
<div className="button-row">
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={useMock} onChange={(event) => onUseMockChange(event.target.checked)} />
|
||||
Mock-режим
|
||||
</label>
|
||||
<button type="button" onClick={() => onSend()} disabled={busy || !inputValue.trim()}>
|
||||
{busy ? "Выполняю..." : "Отправить"}
|
||||
</button>
|
||||
<button type="button" onClick={() => onClear()} disabled={busy && conversation.length === 0}>
|
||||
Сбросить сессию
|
||||
</button>
|
||||
</div>
|
||||
{statusText ? <p className="diff-summary">{statusText}</p> : null}
|
||||
{errorMessage ? <p className="error-text">{errorMessage}</p> : null}
|
||||
</div>
|
||||
</PanelFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { PanelFrame } from "./PanelFrame";
|
||||
import type { ConnectionState } from "../state/types";
|
||||
|
||||
interface ConnectionPanelProps {
|
||||
value: ConnectionState;
|
||||
onChange: (next: ConnectionState) => void;
|
||||
onTestConnection: () => Promise<void> | void;
|
||||
onSaveLocalConfig: () => void;
|
||||
lastStatus: string;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
export function ConnectionPanel({
|
||||
value,
|
||||
onChange,
|
||||
onTestConnection,
|
||||
onSaveLocalConfig,
|
||||
lastStatus,
|
||||
busy
|
||||
}: ConnectionPanelProps) {
|
||||
return (
|
||||
<PanelFrame
|
||||
title="Подключение OpenAI"
|
||||
subtitle="Ключ живет только в памяти сессии (не пишется в localStorage)."
|
||||
actions={<span className="status-chip">{lastStatus || "Статус: не проверено"}</span>}
|
||||
>
|
||||
<div className="grid-two">
|
||||
<label>
|
||||
OpenAI API Key
|
||||
<input
|
||||
type="password"
|
||||
value={value.apiKey}
|
||||
onChange={(event) => onChange({ ...value, apiKey: event.target.value })}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Model ID
|
||||
<input value={value.model} onChange={(event) => onChange({ ...value, model: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Base URL
|
||||
<input value={value.baseUrl} onChange={(event) => onChange({ ...value, baseUrl: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Temperature
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={value.temperature}
|
||||
onChange={(event) => onChange({ ...value, temperature: Number(event.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Max output tokens
|
||||
<input
|
||||
type="number"
|
||||
value={value.maxOutputTokens}
|
||||
onChange={(event) => onChange({ ...value, maxOutputTokens: Number(event.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="button" onClick={() => onSaveLocalConfig()}>
|
||||
Сохранить локальную конфигурацию
|
||||
</button>
|
||||
<button type="button" onClick={() => onTestConnection()} disabled={busy}>
|
||||
{busy ? "Проверяем..." : "Проверить подключение"}
|
||||
</button>
|
||||
</div>
|
||||
</PanelFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { PanelFrame } from "./PanelFrame";
|
||||
import type { HistoryItem } from "../state/types";
|
||||
|
||||
interface HistoryPanelProps {
|
||||
items: HistoryItem[];
|
||||
onRefresh: () => Promise<void> | void;
|
||||
onOpenTrace: (traceId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function HistoryPanel({ items, onRefresh, onOpenTrace }: HistoryPanelProps) {
|
||||
return (
|
||||
<PanelFrame
|
||||
title="История нормализаций"
|
||||
subtitle="Короткий вопрос, confidence, route hint и статус валидации."
|
||||
actions={
|
||||
<button type="button" onClick={() => onRefresh()}>
|
||||
Обновить
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="history-list">
|
||||
{items.length === 0 ? <p className="muted">История пока пустая.</p> : null}
|
||||
{items.map((item) => (
|
||||
<button key={item.trace_id} type="button" className="history-item" onClick={() => onOpenTrace(item.trace_id)}>
|
||||
<div className="history-row">
|
||||
<strong>{item.route_hint ?? "route: n/a"}</strong>
|
||||
<span>{item.validation_passed ? "schema: ok" : "schema: fail"}</span>
|
||||
</div>
|
||||
<p>{item.question_short}</p>
|
||||
<div className="history-row">
|
||||
<span>{item.model}</span>
|
||||
<span>{new Date(item.timestamp).toLocaleString("ru-RU")}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PanelFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
interface JsonViewProps {
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export function JsonView({ value }: JsonViewProps) {
|
||||
return <pre className="json-view">{JSON.stringify(value ?? {}, null, 2)}</pre>;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { PanelFrame } from "./PanelFrame";
|
||||
import type { NormalizeResultState } from "../state/types";
|
||||
|
||||
interface MetricsPanelProps {
|
||||
result: NormalizeResultState | null;
|
||||
}
|
||||
|
||||
function metric(value: string | number | boolean | null | undefined): string {
|
||||
if (value === null || value === undefined || value === "") return "—";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function MetricsPanel({ result }: MetricsPanelProps) {
|
||||
return (
|
||||
<PanelFrame title="Runtime метрики" subtitle="trace_id, токены, latency и статус валидации.">
|
||||
<div className="metrics-grid">
|
||||
<div>
|
||||
<span>trace_id</span>
|
||||
<strong>{metric(result?.trace_id)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>request_started_at</span>
|
||||
<strong>{metric(result ? new Date(Date.now() - result.latency_ms).toISOString() : null)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>request_finished_at</span>
|
||||
<strong>{metric(result ? new Date().toISOString() : null)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>latency_ms</span>
|
||||
<strong>{metric(result?.latency_ms)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>input_tokens</span>
|
||||
<strong>{metric(result?.usage?.input_tokens)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>output_tokens</span>
|
||||
<strong>{metric(result?.usage?.output_tokens)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>total_tokens</span>
|
||||
<strong>{metric(result?.usage?.total_tokens)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>validation_status</span>
|
||||
<strong>{result?.validation?.passed ? "passed" : "failed"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>prompt_version</span>
|
||||
<strong>{metric(result?.prompt_version)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>schema_version</span>
|
||||
<strong>{metric(result?.schema_version)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</PanelFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { PanelFrame } from "./PanelFrame";
|
||||
import { JsonView } from "./JsonView";
|
||||
import type { NormalizeResultState, TabKey } from "../state/types";
|
||||
|
||||
interface OutputPanelProps {
|
||||
tab: TabKey;
|
||||
onTabChange: (tab: TabKey) => void;
|
||||
result: NormalizeResultState | null;
|
||||
appLogs: string[];
|
||||
}
|
||||
|
||||
const TAB_LABELS: Record<TabKey, string> = {
|
||||
normalized: "Normalized JSON",
|
||||
fragments: "Fragment View",
|
||||
scope: "Scope View",
|
||||
flags: "Flags View",
|
||||
route: "Route Simulation",
|
||||
raw: "Raw model output",
|
||||
validation: "Validation",
|
||||
logs: "Logs"
|
||||
};
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
export function OutputPanel({ tab, onTabChange, result, appLogs }: OutputPanelProps) {
|
||||
const tabs: TabKey[] = ["normalized", "fragments", "scope", "flags", "route", "raw", "validation", "logs"];
|
||||
const normalized = asObject(result?.normalized);
|
||||
const schemaVersion = String(normalized?.schema_version ?? "");
|
||||
const isV2 =
|
||||
schemaVersion === "normalized_query_v2" ||
|
||||
schemaVersion === "normalized_query_v2_0_1" ||
|
||||
schemaVersion === "normalized_query_v2_0_2";
|
||||
|
||||
const fragmentsView = isV2
|
||||
? {
|
||||
fragments: normalized?.fragments ?? [],
|
||||
discarded_fragments: normalized?.discarded_fragments ?? []
|
||||
}
|
||||
: { note: "Fragment View доступен для normalized_query_v2." };
|
||||
|
||||
const scopeView = isV2
|
||||
? {
|
||||
message_in_scope: normalized?.message_in_scope ?? null,
|
||||
scope_confidence: normalized?.scope_confidence ?? null,
|
||||
contains_multiple_tasks: normalized?.contains_multiple_tasks ?? null,
|
||||
global_notes: normalized?.global_notes ?? null
|
||||
}
|
||||
: { note: "Scope View доступен для normalized_query_v2." };
|
||||
|
||||
const flagsView = isV2
|
||||
? Array.isArray(normalized?.fragments)
|
||||
? (normalized?.fragments as Array<Record<string, unknown>>).map((fragment) => ({
|
||||
fragment_id: fragment.fragment_id ?? null,
|
||||
domain_relevance: fragment.domain_relevance ?? null,
|
||||
candidate_labels: fragment.candidate_labels ?? [],
|
||||
execution_readiness: fragment.execution_readiness ?? null,
|
||||
clarification_reason: fragment.clarification_reason ?? null,
|
||||
soft_assumption_used: fragment.soft_assumption_used ?? [],
|
||||
route_status: fragment.route_status ?? null,
|
||||
no_route_reason: fragment.no_route_reason ?? null,
|
||||
flags: fragment.flags ?? {}
|
||||
}))
|
||||
: []
|
||||
: { note: "Flags View доступен для normalized_query_v2." };
|
||||
|
||||
return (
|
||||
<PanelFrame title="Выходные данные" subtitle="Structured output и диагностические вкладки.">
|
||||
<div className="tab-row">
|
||||
{tabs.map((item) => (
|
||||
<button key={item} type="button" className={tab === item ? "tab active" : "tab"} onClick={() => onTabChange(item)}>
|
||||
{TAB_LABELS[item]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === "normalized" ? <JsonView value={result?.normalized ?? { note: "Нет данных." }} /> : null}
|
||||
{tab === "fragments" ? <JsonView value={fragmentsView} /> : null}
|
||||
{tab === "scope" ? <JsonView value={scopeView} /> : null}
|
||||
{tab === "flags" ? <JsonView value={flagsView} /> : null}
|
||||
{tab === "route" ? <JsonView value={result?.route_hint_summary ?? { note: "Нет данных." }} /> : null}
|
||||
{tab === "raw" ? <JsonView value={result?.raw_model_output ?? { note: "Нет данных." }} /> : null}
|
||||
{tab === "validation" ? <JsonView value={result?.validation ?? { note: "Нет данных." }} /> : null}
|
||||
{tab === "logs" ? <JsonView value={appLogs} /> : null}
|
||||
</PanelFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
interface PanelFrameProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function PanelFrame({ title, subtitle, actions, children }: PropsWithChildren<PanelFrameProps>) {
|
||||
return (
|
||||
<section className="panel-frame">
|
||||
<header className="panel-header">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{subtitle ? <p>{subtitle}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="panel-actions">{actions}</div> : null}
|
||||
</header>
|
||||
<div className="panel-body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { PanelFrame } from "./PanelFrame";
|
||||
import type { PromptState } from "../state/types";
|
||||
|
||||
interface PromptPresetListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
prompt_version: string;
|
||||
systemPrompt: string;
|
||||
developerPrompt: string;
|
||||
domainPrompt: string;
|
||||
schemaNotes?: string;
|
||||
fewShotExamples?: string;
|
||||
}
|
||||
|
||||
interface PromptPanelProps {
|
||||
value: PromptState;
|
||||
onChange: (next: PromptState) => void;
|
||||
presets: PromptPresetListItem[];
|
||||
selectedPresetId: string;
|
||||
onSelectPreset: (id: string) => void;
|
||||
onLoadPreset: () => void;
|
||||
onSavePreset: () => void;
|
||||
onResetDefaults: () => void;
|
||||
onDiffPrevious: () => void;
|
||||
presetName: string;
|
||||
onPresetNameChange: (name: string) => void;
|
||||
diffSummary: string;
|
||||
}
|
||||
|
||||
export function PromptPanel({
|
||||
value,
|
||||
onChange,
|
||||
presets,
|
||||
selectedPresetId,
|
||||
onSelectPreset,
|
||||
onLoadPreset,
|
||||
onSavePreset,
|
||||
onResetDefaults,
|
||||
onDiffPrevious,
|
||||
presetName,
|
||||
onPresetNameChange,
|
||||
diffSummary
|
||||
}: PromptPanelProps) {
|
||||
return (
|
||||
<PanelFrame title="Prompt Manager" subtitle="Системный, developer и domain уровни управляются отдельно.">
|
||||
<div className="grid-two">
|
||||
<label>
|
||||
Системный prompt
|
||||
<textarea
|
||||
value={value.systemPrompt}
|
||||
onChange={(event) => onChange({ ...value, systemPrompt: event.target.value })}
|
||||
rows={6}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Developer / Instruction prompt
|
||||
<textarea
|
||||
value={value.developerPrompt}
|
||||
onChange={(event) => onChange({ ...value, developerPrompt: event.target.value })}
|
||||
rows={6}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Domain prompt
|
||||
<textarea value={value.domainPrompt} onChange={(event) => onChange({ ...value, domainPrompt: event.target.value })} rows={6} />
|
||||
</label>
|
||||
<label>
|
||||
Schema notes
|
||||
<textarea value={value.schemaNotes} onChange={(event) => onChange({ ...value, schemaNotes: event.target.value })} rows={6} />
|
||||
</label>
|
||||
<label className="full-width">
|
||||
Few-shot examples
|
||||
<textarea
|
||||
value={value.fewShotExamples}
|
||||
onChange={(event) => onChange({ ...value, fewShotExamples: event.target.value })}
|
||||
rows={8}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<select value={selectedPresetId} onChange={(event) => onSelectPreset(event.target.value)}>
|
||||
<option value="">Выберите preset...</option>
|
||||
{presets.map((preset) => (
|
||||
<option value={preset.id} key={preset.id}>
|
||||
{preset.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={() => onLoadPreset()}>
|
||||
Загрузить preset
|
||||
</button>
|
||||
<input value={presetName} onChange={(event) => onPresetNameChange(event.target.value)} placeholder="Имя для сохранения" />
|
||||
<button type="button" onClick={() => onSavePreset()}>
|
||||
Сохранить preset
|
||||
</button>
|
||||
<button type="button" onClick={() => onDiffPrevious()}>
|
||||
Diff с предыдущим
|
||||
</button>
|
||||
<button type="button" onClick={() => onResetDefaults()}>
|
||||
Сбросить к default
|
||||
</button>
|
||||
</div>
|
||||
{diffSummary ? <p className="diff-summary">{diffSummary}</p> : null}
|
||||
</PanelFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { PanelFrame } from "./PanelFrame";
|
||||
import type { QueryState } from "../state/types";
|
||||
|
||||
interface QueryPanelProps {
|
||||
value: QueryState;
|
||||
onChange: (next: QueryState) => void;
|
||||
onApplyBatchFormat: () => void;
|
||||
onNormalize: (saveCase: boolean) => Promise<void> | void;
|
||||
busy: boolean;
|
||||
useMock: boolean;
|
||||
onUseMockChange: (next: boolean) => void;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
export function QueryPanel({
|
||||
value,
|
||||
onChange,
|
||||
onApplyBatchFormat,
|
||||
onNormalize,
|
||||
busy,
|
||||
useMock,
|
||||
onUseMockChange,
|
||||
errorMessage
|
||||
}: QueryPanelProps) {
|
||||
return (
|
||||
<PanelFrame title="Запрос пользователя" subtitle="NDC semantic front-end: нормализуем, но не отвечаем за бухгалтерскую суть.">
|
||||
<div className="grid-two">
|
||||
<label className="full-width">
|
||||
Raw user question
|
||||
<textarea
|
||||
value={value.userQuestion}
|
||||
onChange={(event) => onChange({ ...value, userQuestion: event.target.value })}
|
||||
rows={6}
|
||||
placeholder="Например: По каким покупателям у нас на конец июня висят отгрузки без оплаты..."
|
||||
/>
|
||||
</label>
|
||||
<label className="full-width">
|
||||
Batch queries (`;` separator)
|
||||
<textarea
|
||||
value={value.batchQuestionsRaw}
|
||||
onChange={(event) => onChange({ ...value, batchQuestionsRaw: event.target.value })}
|
||||
onBlur={() => onApplyBatchFormat()}
|
||||
rows={8}
|
||||
placeholder="Вопрос 1; Вопрос 2; Вопрос 3"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Optional period context
|
||||
<input value={value.periodHint} onChange={(event) => onChange({ ...value, periodHint: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Optional business context
|
||||
<input value={value.businessContext} onChange={(event) => onChange({ ...value, businessContext: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Optional expected route (eval)
|
||||
<input value={value.expectedRoute} onChange={(event) => onChange({ ...value, expectedRoute: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={useMock} onChange={(event) => onUseMockChange(event.target.checked)} />
|
||||
Mock-режим (без вызова OpenAI)
|
||||
</label>
|
||||
<button type="button" onClick={() => onApplyBatchFormat()} disabled={busy || !value.batchQuestionsRaw.trim()}>
|
||||
Применить `;` в переносы
|
||||
</button>
|
||||
<button type="button" onClick={() => onNormalize(false)} disabled={busy || !value.userQuestion.trim()}>
|
||||
{busy ? "Нормализуем..." : "Normalize"}
|
||||
</button>
|
||||
<button type="button" onClick={() => onNormalize(true)} disabled={busy || !value.userQuestion.trim()}>
|
||||
{busy ? "Сохраняем..." : "Normalize + Save as test case"}
|
||||
</button>
|
||||
</div>
|
||||
{errorMessage ? <p className="error-text">{errorMessage}</p> : null}
|
||||
</PanelFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { PanelFrame } from "./PanelFrame";
|
||||
import type { RuntimeRun } from "../state/types";
|
||||
import { JsonView } from "./JsonView";
|
||||
|
||||
interface RuntimePanelProps {
|
||||
runs: RuntimeRun[];
|
||||
selectedRunId: string;
|
||||
onSelectRun: (runId: string) => void;
|
||||
onStartRun: () => Promise<void> | void;
|
||||
onFinishRun: () => Promise<void> | void;
|
||||
onRefreshRuns: () => Promise<void> | void;
|
||||
onRunEval: () => Promise<void> | void;
|
||||
onCopyEvalReport: () => Promise<void> | void;
|
||||
evalBusy: boolean;
|
||||
traceItems: unknown[];
|
||||
evalReport: unknown;
|
||||
}
|
||||
|
||||
export function RuntimePanel({
|
||||
runs,
|
||||
selectedRunId,
|
||||
onSelectRun,
|
||||
onStartRun,
|
||||
onFinishRun,
|
||||
onRefreshRuns,
|
||||
onRunEval,
|
||||
onCopyEvalReport,
|
||||
evalBusy,
|
||||
traceItems,
|
||||
evalReport
|
||||
}: RuntimePanelProps) {
|
||||
return (
|
||||
<PanelFrame
|
||||
title="NDC Run Monitor"
|
||||
subtitle="Важно: кнопка Запустить run создает только run-сущность. Кнопка eval запускает batch-проверку normalizer v2.0.2."
|
||||
>
|
||||
<div className="button-row">
|
||||
<button type="button" onClick={() => onStartRun()}>
|
||||
Запустить run
|
||||
</button>
|
||||
<button type="button" onClick={() => onFinishRun()} disabled={!selectedRunId}>
|
||||
Завершить выбранный run
|
||||
</button>
|
||||
<button type="button" onClick={() => onRefreshRuns()}>
|
||||
Обновить runs
|
||||
</button>
|
||||
<button type="button" onClick={() => onRunEval()} disabled={evalBusy}>
|
||||
{evalBusy ? "Идет eval v2.0.2..." : "Запустить eval v2.0.2"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="runtime-grid">
|
||||
<div className="runtime-runs">
|
||||
{runs.map((run) => (
|
||||
<button
|
||||
key={run.runId}
|
||||
type="button"
|
||||
className={selectedRunId === run.runId ? "history-item selected" : "history-item"}
|
||||
onClick={() => onSelectRun(run.runId)}
|
||||
>
|
||||
<div className="history-row">
|
||||
<strong>{run.status}</strong>
|
||||
<span>{run.runId}</span>
|
||||
</div>
|
||||
<div className="history-row">
|
||||
<span>{run.sessionId}</span>
|
||||
<span>{new Date(run.updatedAt).toLocaleString("ru-RU")}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{runs.length === 0 ? <p className="muted">Нет активных запусков.</p> : null}
|
||||
</div>
|
||||
<div>
|
||||
<h3>Trace выбранного run</h3>
|
||||
<JsonView value={traceItems} />
|
||||
<div className="eval-report-wrap">
|
||||
<h3 style={{ marginTop: 12 }}>Отчет eval</h3>
|
||||
<JsonView value={evalReport ?? { note: "Eval пока не запускался" }} />
|
||||
<button type="button" className="copy-cube-button" title="Скопировать отчет eval" onClick={() => onCopyEvalReport()}>
|
||||
⧉
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ConnectionState, PromptState, QueryState } from "./types";
|
||||
|
||||
export const DEFAULT_CONNECTION: ConnectionState = {
|
||||
apiKey: "",
|
||||
model: "gpt-4o-mini",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
temperature: 0,
|
||||
maxOutputTokens: 700
|
||||
};
|
||||
|
||||
export const DEFAULT_PROMPTS: PromptState = {
|
||||
systemPrompt: "Ты semantic-normalizer для бухгалтерского ассистента NDC. Возвращай только JSON по схеме normalized_query_v2_0_2.",
|
||||
developerPrompt:
|
||||
"Сначала делай decomposition сообщения на task fragments, затем определяй domain scope и route-critical flags. Для каждого fragment заполняй execution_readiness + route_status + no_route_reason. Если fragment routable, не оставляй его в no_route.",
|
||||
domainPrompt:
|
||||
"Контур: данные текущего предприятия в 1С/NDC. In-scope: документы, проводки, взаиморасчеты, остатки, периодное закрытие, аномалии и контрольные проверки. Out-of-scope: общая теория, законы и оффтоп.",
|
||||
schemaNotes: "schema_version: normalized_query_v2_0_2. Строгий JSON без дополнительных полей.",
|
||||
fewShotExamples:
|
||||
"Q: Проверь по поставщикам хвосты и разложи цепочку документов/оплат. => fragment in_scope, flags: multi_entity + chain_explanation. Q: Как вообще по ФСБУ? => out_of_scope/generic_accounting."
|
||||
};
|
||||
|
||||
export const DEFAULT_QUERY: QueryState = {
|
||||
userQuestion: "",
|
||||
batchQuestionsRaw: "",
|
||||
periodHint: "",
|
||||
businessContext: "",
|
||||
expectedRoute: ""
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
export type TabKey = "normalized" | "fragments" | "scope" | "flags" | "route" | "raw" | "validation" | "logs";
|
||||
|
||||
export interface ConnectionState {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
temperature: number;
|
||||
maxOutputTokens: number;
|
||||
}
|
||||
|
||||
export interface PromptState {
|
||||
systemPrompt: string;
|
||||
developerPrompt: string;
|
||||
domainPrompt: string;
|
||||
schemaNotes: string;
|
||||
fewShotExamples: string;
|
||||
}
|
||||
|
||||
export interface QueryState {
|
||||
userQuestion: string;
|
||||
batchQuestionsRaw: string;
|
||||
periodHint: string;
|
||||
businessContext: string;
|
||||
expectedRoute: string;
|
||||
}
|
||||
|
||||
export interface NormalizeResultState {
|
||||
trace_id: string;
|
||||
ok: boolean;
|
||||
normalized: unknown;
|
||||
route_hint_summary: unknown;
|
||||
raw_model_output: unknown;
|
||||
validation: {
|
||||
passed: boolean;
|
||||
errors: string[];
|
||||
};
|
||||
usage: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
latency_ms: number;
|
||||
prompt_version: string;
|
||||
schema_version: string;
|
||||
}
|
||||
|
||||
export interface HistoryItem {
|
||||
trace_id: string;
|
||||
timestamp: string;
|
||||
model: string;
|
||||
question_short: string;
|
||||
confidence: string | null;
|
||||
validation_passed: boolean;
|
||||
route_hint: string | null;
|
||||
save_status: "saved";
|
||||
}
|
||||
|
||||
export interface RuntimeRun {
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
status: string;
|
||||
initiator: string;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type UiMode = "assistant" | "decomposition";
|
||||
|
||||
export type AssistantFallbackType = "none" | "out_of_scope" | "clarification" | "partial" | "unknown";
|
||||
export type AssistantReplyType =
|
||||
| "factual"
|
||||
| "factual_with_explanation"
|
||||
| "partial_coverage"
|
||||
| "clarification_required"
|
||||
| "out_of_scope"
|
||||
| "empty_but_valid"
|
||||
| "no_grounded_answer"
|
||||
| "route_mismatch_blocked"
|
||||
| "backend_error";
|
||||
export type RetrievalResultStatus = "ok" | "empty" | "partial" | "error";
|
||||
export type RetrievalResultType = "list" | "summary" | "object" | "chain" | "ranking";
|
||||
export type RetrievalConfidence = "high" | "medium" | "low";
|
||||
|
||||
export interface AssistantRequirement {
|
||||
requirement_id: string;
|
||||
source_fragment_id: string | null;
|
||||
requirement_text: string;
|
||||
subject_tokens: string[];
|
||||
status: "covered" | "partially_covered" | "uncovered" | "clarification_needed" | "out_of_scope";
|
||||
route: string | null;
|
||||
}
|
||||
|
||||
export interface RequirementCoverageReport {
|
||||
requirements_total: number;
|
||||
requirements_covered: number;
|
||||
requirements_uncovered: string[];
|
||||
requirements_partially_covered: string[];
|
||||
clarification_needed_for: string[];
|
||||
out_of_scope_requirements: string[];
|
||||
}
|
||||
|
||||
export interface AnswerGroundingCheck {
|
||||
status: "grounded" | "partial" | "no_grounded_answer" | "route_mismatch_blocked";
|
||||
route_subject_match: boolean;
|
||||
missing_requirements: string[];
|
||||
reasons: string[];
|
||||
why_included_summary: string[];
|
||||
selection_reason_summary: string[];
|
||||
}
|
||||
|
||||
export interface UnifiedRetrievalResult {
|
||||
fragment_id: string;
|
||||
requirement_ids: string[];
|
||||
route: string;
|
||||
status: RetrievalResultStatus;
|
||||
result_type: RetrievalResultType;
|
||||
items: Array<Record<string, unknown>>;
|
||||
summary: Record<string, unknown>;
|
||||
evidence: Array<Record<string, unknown>>;
|
||||
why_included: string[];
|
||||
selection_reason: string[];
|
||||
risk_factors: string[];
|
||||
business_interpretation: string[];
|
||||
confidence: RetrievalConfidence;
|
||||
limitations: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface AssistantDebugState {
|
||||
trace_id: string;
|
||||
prompt_version: string;
|
||||
schema_version: string;
|
||||
fallback_type: AssistantFallbackType;
|
||||
route_summary: unknown;
|
||||
fragments: unknown[];
|
||||
requirements_extracted: AssistantRequirement[];
|
||||
coverage_report: RequirementCoverageReport;
|
||||
routes: Array<Record<string, unknown>>;
|
||||
retrieval_status: Array<{
|
||||
fragment_id: string;
|
||||
requirement_ids: string[];
|
||||
route: string;
|
||||
status: RetrievalResultStatus;
|
||||
result_type: RetrievalResultType;
|
||||
}>;
|
||||
retrieval_results: UnifiedRetrievalResult[];
|
||||
answer_grounding_check: AnswerGroundingCheck;
|
||||
dropped_intent_segments: string[];
|
||||
normalized: unknown;
|
||||
}
|
||||
|
||||
export interface AssistantConversationItem {
|
||||
message_id: string;
|
||||
session_id: string;
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
reply_type: AssistantReplyType | null;
|
||||
created_at: string;
|
||||
trace_id: string | null;
|
||||
debug: AssistantDebugState | null;
|
||||
}
|
||||
|
||||
export interface AssistantMessageResultState {
|
||||
ok: boolean;
|
||||
session_id: string;
|
||||
assistant_reply: string;
|
||||
reply_type: AssistantReplyType;
|
||||
conversation_item: AssistantConversationItem;
|
||||
debug: AssistantDebugState;
|
||||
conversation: AssistantConversationItem[];
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Space+Grotesk:wght@500;700&display=swap");
|
||||
|
||||
:root {
|
||||
--bg-main: #060a08;
|
||||
--bg-soft: #0e1511;
|
||||
--bg-panel: rgba(15, 24, 19, 0.85);
|
||||
--bg-panel-accent: rgba(38, 62, 45, 0.45);
|
||||
--line: rgba(143, 255, 173, 0.3);
|
||||
--line-strong: rgba(143, 255, 173, 0.72);
|
||||
--text-main: #f3ffee;
|
||||
--text-muted: #9bb8a5;
|
||||
--lime-main: #8fffad;
|
||||
--lime-press: #59db83;
|
||||
--danger: #ff7e7e;
|
||||
--radius-lg: 20px;
|
||||
--radius-md: 14px;
|
||||
--shadow: 0 16px 44px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
font-family: "Manrope", "Segoe UI", sans-serif;
|
||||
background: radial-gradient(circle at 15% -10%, #1f3b2f 0%, transparent 40%),
|
||||
radial-gradient(circle at 90% 10%, #1d2f24 0%, transparent 35%),
|
||||
linear-gradient(165deg, var(--bg-main) 0%, #070d0a 100%);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.app-root {
|
||||
max-width: 1720px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px 42px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-bottom: 20px;
|
||||
padding: 20px 22px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(145deg, rgba(17, 29, 22, 0.94), rgba(10, 16, 13, 0.92));
|
||||
box-shadow: var(--shadow);
|
||||
animation: rise 0.5s ease-out;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-family: "Space Grotesk", "Manrope", sans-serif;
|
||||
margin: 0 0 8px;
|
||||
font-size: clamp(1.5rem, 3vw, 2.3rem);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.layout-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mode-switch-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.panel-frame {
|
||||
grid-column: span 12;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(165deg, var(--bg-panel), rgba(10, 17, 13, 0.88));
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
animation: rise 0.4s ease-out;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 14px 18px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(90deg, var(--bg-panel-accent), transparent 70%);
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.02rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 14px 18px 18px;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
color: var(--lime-main);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button {
|
||||
font-family: "Manrope", sans-serif;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border: 1px solid rgba(170, 255, 194, 0.26);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(7, 13, 10, 0.92);
|
||||
color: var(--text-main);
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--line-strong);
|
||||
box-shadow: 0 0 0 3px rgba(143, 255, 173, 0.2);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 86px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid rgba(176, 255, 199, 0.35);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #8fffad 0%, #62e286 100%);
|
||||
color: #08100a;
|
||||
font-weight: 700;
|
||||
font-size: 0.83rem;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
padding: 9px 14px;
|
||||
transition: transform 0.16s ease, filter 0.2s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
filter: brightness(1.04);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 20px rgba(127, 255, 170, 0.32);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.grid-two {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.tab-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
background: transparent;
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
border-color: var(--line-strong);
|
||||
background: linear-gradient(180deg, rgba(143, 255, 173, 0.25), rgba(143, 255, 173, 0.1));
|
||||
}
|
||||
|
||||
.assistant-chat-list {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 4px;
|
||||
border: 1px solid rgba(156, 255, 189, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(6, 10, 8, 0.65);
|
||||
}
|
||||
|
||||
.assistant-empty {
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.assistant-msg {
|
||||
border: 1px solid rgba(157, 255, 190, 0.2);
|
||||
border-radius: 12px;
|
||||
background: rgba(11, 18, 14, 0.74);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.assistant-msg.user {
|
||||
border-color: rgba(110, 198, 255, 0.38);
|
||||
background: rgba(13, 20, 24, 0.76);
|
||||
}
|
||||
|
||||
.assistant-msg.assistant {
|
||||
border-color: rgba(157, 255, 190, 0.25);
|
||||
}
|
||||
|
||||
.assistant-msg-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.assistant-msg-body {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.45;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.assistant-trace {
|
||||
margin-top: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.assistant-debug {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.assistant-debug summary {
|
||||
cursor: pointer;
|
||||
color: var(--lime-main);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.assistant-compose {
|
||||
margin-top: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.json-view {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
background: #050806;
|
||||
border: 1px solid rgba(156, 255, 189, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px;
|
||||
color: #d6ffdf;
|
||||
font-family: "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metrics-grid div {
|
||||
background: rgba(11, 18, 14, 0.7);
|
||||
border: 1px solid rgba(157, 255, 190, 0.24);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.metrics-grid span {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.metrics-grid strong {
|
||||
font-size: 0.84rem;
|
||||
color: var(--lime-main);
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 340px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(157, 255, 190, 0.24);
|
||||
background: rgba(11, 18, 14, 0.78);
|
||||
color: var(--text-main);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.history-item p {
|
||||
margin: 8px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.history-item.selected {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.history-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.runtime-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.runtime-runs {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.eval-report-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.copy-cube-button {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
min-width: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.diff-summary {
|
||||
margin-top: 10px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--lime-main);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
margin-top: 10px;
|
||||
color: var(--danger);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.metrics-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.grid-two,
|
||||
.runtime-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-root {
|
||||
padding: 18px 12px 24px;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/components/assistantpanel.tsx","./src/components/connectionpanel.tsx","./src/components/historypanel.tsx","./src/components/jsonview.tsx","./src/components/metricspanel.tsx","./src/components/outputpanel.tsx","./src/components/panelframe.tsx","./src/components/promptpanel.tsx","./src/components/querypanel.tsx","./src/components/runtimepanel.tsx","./src/state/defaults.ts","./src/state/types.ts"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8787",
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user