import { useCallback, useEffect, useMemo, useState } from "react";
import { apiClient } from "../api/client";
import type {
AutoRunCaseSummary,
AutoRunDetailResponse,
AutoRunDialogResponse,
AutoRunDomainCoverage,
AutoRunHistoryResponse,
AutoRunSummary,
ConnectionState,
PromptState
} from "../state/types";
import { JsonView } from "./JsonView";
import { PanelFrame } from "./PanelFrame";
interface AutoRunsHistoryPanelProps {
connection: ConnectionState;
prompts: PromptState;
assistantPromptVersion: string;
decompositionPromptVersion: string;
onLog?: (message: string) => void;
}
type UseMockFilter = "any" | "true" | "false";
interface AutoRunsFilters {
fromLocal: string;
toLocal: string;
target: string;
mode: string;
useMock: UseMockFilter;
promptContains: string;
limit: number;
}
const DEFAULT_FILTERS: AutoRunsFilters = {
fromLocal: "",
toLocal: "",
target: "all",
mode: "all",
useMock: "any",
promptContains: "",
limit: 120
};
function dateToInputValue(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hour = String(date.getHours()).padStart(2, "0");
const minute = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hour}:${minute}`;
}
function defaultFromDateValue(): string {
const date = new Date();
date.setDate(date.getDate() - 14);
return dateToInputValue(date);
}
function localInputToIso(value: string): string | undefined {
if (!value.trim()) return undefined;
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) return undefined;
return new Date(parsed).toISOString();
}
function formatDateTime(iso: string): string {
const parsed = Date.parse(iso);
if (!Number.isFinite(parsed)) return iso;
return new Date(parsed).toLocaleString("ru-RU");
}
function toPercent(closed: number, total: number): number {
if (total <= 0) return 0;
return Math.max(0, Math.min(100, Number(((closed / total) * 100).toFixed(1))));
}
function formatScore(value: number | null): string {
if (typeof value !== "number") return "n/a";
return `${value.toFixed(1)}%`;
}
function formatShortTarget(value: string): string {
if (value === "assistant_stage1") return "assistant/s1";
if (value === "assistant_stage2") return "assistant/s2";
if (value === "assistant_p0") return "assistant/p0";
return value;
}
function trendLabel(value: "up" | "down" | "flat"): string {
if (value === "up") return "Рост";
if (value === "down") return "Регресс";
return "Без изменений";
}
function getSelectedCase(cases: AutoRunCaseSummary[], caseId: string): AutoRunCaseSummary | null {
return cases.find((item) => item.case_id === caseId) ?? null;
}
function renderCoverageRows(items: AutoRunDomainCoverage[]) {
if (items.length === 0) {
return
Покрытие доменов пока не сформировано.
;
}
return (
{items.map((item) => {
const percent = toPercent(item.closed_cases, item.total_cases);
return (
{item.domain}
{item.closed_cases}/{item.total_cases} ({percent}%)
);
})}
);
}
export function AutoRunsHistoryPanel({
connection,
prompts,
assistantPromptVersion,
decompositionPromptVersion,
onLog
}: AutoRunsHistoryPanelProps) {
const [filters, setFilters] = useState({
...DEFAULT_FILTERS,
fromLocal: defaultFromDateValue()
});
const [history, setHistory] = useState(null);
const [runDetail, setRunDetail] = useState(null);
const [dialog, setDialog] = useState(null);
const [selectedRunId, setSelectedRunId] = useState("");
const [selectedCaseId, setSelectedCaseId] = useState("");
const [historyBusy, setHistoryBusy] = useState(false);
const [detailBusy, setDetailBusy] = useState(false);
const [dialogBusy, setDialogBusy] = useState(false);
const [errorText, setErrorText] = useState("");
const [showAssistantMode, setShowAssistantMode] = useState(true);
const [showDecompositionMode, setShowDecompositionMode] = useState(true);
const [showProgressMode, setShowProgressMode] = useState(true);
const activeRunSummary: AutoRunSummary | null =
history?.items.find((item) => item.run_id === selectedRunId) ?? null;
const activeCase = runDetail ? getSelectedCase(runDetail.cases, selectedCaseId) : null;
const log = useCallback(
(message: string) => {
onLog?.(`[autoruns] ${message}`);
},
[onLog]
);
const loadCaseDialog = useCallback(
async (runId: string, caseId: string) => {
setDialogBusy(true);
try {
const payload = await apiClient.loadAutoRunCaseDialog(runId, caseId);
setDialog(payload);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorText(`Диалог кейса: ${message}`);
log(`Dialog load error for ${runId}/${caseId}: ${message}`);
setDialog(null);
} finally {
setDialogBusy(false);
}
},
[log]
);
const loadRunDetail = useCallback(
async (runId: string, preferredCaseId?: string) => {
setDetailBusy(true);
try {
const payload = await apiClient.loadAutoRunDetail(runId);
setRunDetail(payload);
const nextCaseId =
(preferredCaseId && payload.cases.some((item) => item.case_id === preferredCaseId) ? preferredCaseId : "") ||
payload.cases[0]?.case_id ||
"";
setSelectedCaseId(nextCaseId);
if (nextCaseId) {
await loadCaseDialog(runId, nextCaseId);
} else {
setDialog(null);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorText(`Детализация прогона: ${message}`);
log(`Run detail load error for ${runId}: ${message}`);
setRunDetail(null);
setDialog(null);
} finally {
setDetailBusy(false);
}
},
[loadCaseDialog, log]
);
const loadHistory = useCallback(
async (options?: { keepSelection?: boolean; preferredRunId?: string; preferredCaseId?: string }) => {
setHistoryBusy(true);
setErrorText("");
try {
const payload = await apiClient.loadAutoRunsHistory({
from: localInputToIso(filters.fromLocal),
to: localInputToIso(filters.toLocal),
target: filters.target,
mode: filters.mode,
use_mock: filters.useMock,
prompt_contains: filters.promptContains.trim() || undefined,
limit: filters.limit
});
setHistory(payload);
const hasRuns = payload.items.length > 0;
if (!hasRuns) {
setSelectedRunId("");
setSelectedCaseId("");
setRunDetail(null);
setDialog(null);
return;
}
const keepSelection = options?.keepSelection ?? true;
const preferredRunId = options?.preferredRunId ?? "";
const preferredCaseId = options?.preferredCaseId ?? "";
const nextRunId =
keepSelection && preferredRunId && payload.items.some((item) => item.run_id === preferredRunId)
? preferredRunId
: payload.items[0].run_id;
setSelectedRunId(nextRunId);
await loadRunDetail(nextRunId, keepSelection ? preferredCaseId : undefined);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorText(`История прогонов: ${message}`);
log(`History load error: ${message}`);
} finally {
setHistoryBusy(false);
}
},
[filters.fromLocal, filters.limit, filters.mode, filters.promptContains, filters.target, filters.toLocal, filters.useMock, loadRunDetail, log]
);
useEffect(() => {
void loadHistory({ keepSelection: false });
}, [loadHistory]);
const dynamicColumns = useMemo(() => {
const columns = ["minmax(290px, 340px)", "minmax(300px, 360px)", "minmax(420px, 1fr)"];
if (showAssistantMode) columns.push("minmax(280px, 320px)");
if (showDecompositionMode) columns.push("minmax(280px, 320px)");
if (showProgressMode) columns.push("minmax(280px, 320px)");
return columns.join(" ");
}, [showAssistantMode, showDecompositionMode, showProgressMode]);
return (
}
>
Выдача прогонов
Всего
{history?.stats.runs_total ?? 0}
Средний score
{formatScore(history?.stats.avg_score_index ?? null)}
Тренд
{history ? trendLabel(history.stats.trend) : "n/a"}
Блокеры
{history?.stats.blocking_runs ?? 0}
{(history?.items ?? []).map((run) => (
))}
{(history?.items.length ?? 0) === 0 ?
За выбранный диапазон прогонов нет.
: null}
Диалог прогона
{(runDetail?.cases ?? []).map((item) => (
))}
{dialogBusy || detailBusy ?
Загружаю диалог...
: null}
{!dialogBusy && !detailBusy && (dialog?.messages.length ?? 0) === 0 ?
Диалог для этого кейса не найден.
: null}
{(dialog?.messages ?? []).map((item, index) => {
const role = item.role === "assistant" ? "assistant" : "user";
return (
{role === "assistant" ? "Система" : "Модель/вопрос"}
{item.created_at ? formatDateTime(item.created_at) : "n/a"}
{item.text}
{(item.trace_id || item.reply_type) && (
)}
);
})}
{showAssistantMode ? (
Режим ассистента
source:
{dialog?.source ?? "n/a"}
session:
{dialog?.session_id ?? "n/a"}
run target:
{activeRunSummary?.eval_target ?? "n/a"}
run score:
{formatScore(activeRunSummary?.score_index ?? null)}
Assistant mode payload
Case checks
Metric subscores
) : null}
{showDecompositionMode ? (
Режим декомпозиции
Case:
{activeCase?.case_id ?? "n/a"}
Domain:
{activeCase?.domain ?? "n/a"}
Query class:
{activeCase?.query_class ?? "n/a"}
Trace:
{activeCase?.trace_id ?? "n/a"}
Шаги декомпозиции
{(dialog?.decomposition.length ?? 0) > 0 ? (
{(dialog?.decomposition ?? []).map((item, index) => (
- {item}
))}
) : (
В логах кейса нет явной декомпозиции.
)}
) : null}
{showProgressMode ? (
Прогресс / регресс
Latest score
{formatScore(history?.stats.latest_score_index ?? null)}
Previous
{formatScore(history?.stats.previous_score_index ?? null)}
Trend
{history ? trendLabel(history.stats.trend) : "n/a"}
Quality gaps
{history?.stats.quality_gap_runs ?? 0}
Покрытие доменов (история)
{renderCoverageRows(history?.stats.domain_coverage ?? [])}
Покрытие доменов (выбранный прогон)
{renderCoverageRows(runDetail?.coverage.domain_coverage ?? [])}
) : null}
);
}