NODEDC_1C/llm_normalizer/frontend/src/components/AutoRunsHistoryPanel.tsx

4112 lines
169 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 { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent, type SyntheticEvent } from "react";
import { apiClient } from "../api/client";
import type {
AssistantConversationItem,
AssistantAnnotationRecord,
AssistantSelectionChip,
AsyncEvalRunJob,
AutoGenHistoryRecord,
AutoGenMode,
AutoRunAnnotationListItem,
AutoRunAnnotationRecord,
AutoRunCaseSummary,
AutoRunDetailResponse,
AutoRunDialogMessage,
AutoRunDialogResponse,
AutoRunDomainCoverage,
AutoRunHistoryResponse,
AutoRunPostAnalysisResponse,
AutoRunSummary,
ConnectionState,
ManualCaseDecision,
PromptState
} from "../state/types";
import { AssistantPanel } from "./AssistantPanel";
import { ConnectionPanel } from "./ConnectionPanel";
import { JsonView } from "./JsonView";
import { PanelFrame } from "./PanelFrame";
import { PromptPanel } from "./PromptPanel";
interface AutoRunsHistoryPanelProps {
connection: ConnectionState;
modelOptions: string[];
modelsBusy: boolean;
connectionStatus: string;
connectionBusy: boolean;
onConnectionChange: (next: ConnectionState) => void;
onReloadModels: () => Promise<void> | void;
onSaveLocalConfig: () => void;
onTestConnection: () => Promise<void> | void;
prompts: PromptState;
onPromptsChange: (next: PromptState) => void;
promptPresets: Array<{
id: string;
name: string;
prompt_version: string;
systemPrompt: string;
developerPrompt: string;
domainPrompt: string;
schemaNotes?: string;
fewShotExamples?: string;
}>;
selectedPresetId: string;
onSelectPreset: (id: string) => void;
onLoadPreset: () => void;
onSavePreset: () => void;
onResetDefaults: () => void;
onDiffPrevious: () => void;
presetName: string;
onPresetNameChange: (name: string) => void;
diffSummary: string;
assistantPromptVersion: string;
decompositionPromptVersion: string;
showSettingsMode: boolean;
showAutoRunsMode: boolean;
showAssistantMode: boolean;
showProgressMode: boolean;
showCommentsMode: boolean;
onLog?: (message: string) => void;
}
type UseMockFilter = "any" | "true" | "false";
type AutoGenPersonalityId = string;
interface AutoGenPersonalityDefinition {
id: AutoGenPersonalityId;
label: string;
domain: string;
defaultPrompt: string;
}
interface AutoRunsFilters {
fromLocal: string;
toLocal: string;
target: string;
mode: string;
useMock: UseMockFilter;
promptContains: string;
limit: number;
}
interface CommentModalState {
open: boolean;
caseId: string;
caseMessageIndex: number;
messageIndex: number;
rating: number;
comment: string;
manualCaseDecision: ManualCaseDecision;
annotationAuthor: string;
saving: boolean;
error: string;
}
interface AssistantLiveCommentModalState {
open: boolean;
messageIndex: number;
rating: number;
comment: string;
annotationAuthor: string;
saving: boolean;
error: string;
}
interface AssistantLiveSaveModalState {
open: boolean;
title: string;
saving: boolean;
error: string;
}
interface SavedSessionQuestionDeleteModalState {
open: boolean;
generationId: string;
questionIndex: number;
questionText: string;
saving: boolean;
error: string;
}
interface AutoGenDeleteModalState {
open: boolean;
generationId: string;
title: string;
saving: boolean;
error: string;
}
interface AutoGenSettingsState {
mode: AutoGenMode;
count: number;
personalityId: AutoGenPersonalityId;
personalityPrompts: Record<string, string>;
persistToEvalCases: boolean;
generatedBy: string;
}
const DEFAULT_FILTERS: AutoRunsFilters = {
fromLocal: "",
toLocal: "",
target: "all",
mode: "all",
useMock: "any",
promptContains: "",
limit: 120
};
const DEFAULT_MANUAL_DECISION: ManualCaseDecision = "needs_dialog_policy_fix";
const ALL_CASES_ID = "__all__";
const LIVE_RUN_ID_PREFIX = "__live__:";
const AUTORUNS_UI_CONFIG_KEY = "ndc_autoruns_ui_config_v1";
const AUTORUNS_SAVE_EVENT = "ndc-autoruns-save";
const ASSISTANT_STAGES = ["Анализ запроса", "Получение данных", "Подготовка ответа"];
function buildAssistantLiveFollowupMessage(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}`;
}
const AUTOGEN_PERSONALITIES: AutoGenPersonalityDefinition[] = [
{
id: "general",
label: "Общий контур",
domain: "",
defaultPrompt:
"Генерируй реалистичные живые вопросы бухгалтера по 1С. Добавляй разговорные формулировки и опечатки, но сохраняй бизнес-смысл."
}
];
function buildDefaultPersonalityPrompts(
personalities: AutoGenPersonalityDefinition[] = AUTOGEN_PERSONALITIES
): Record<string, string> {
return personalities.reduce((acc, item) => {
acc[item.id] = item.defaultPrompt;
return acc;
}, {} as Record<string, string>);
}
interface AutoRunsUiConfig {
filters?: Partial<AutoRunsFilters>;
analysisDate?: string;
autogenPersonalityPromptHeight?: number;
groupsExpanded?: {
filters?: boolean;
generationContext?: boolean;
autogen?: boolean;
savedSessions?: boolean;
};
autoGenSettings?: {
mode?: AutoGenMode;
count?: number;
personalityId?: string;
personalityPrompts?: Record<string, string>;
persistToEvalCases?: boolean;
generatedBy?: string;
};
annotationDecisionFilter?: ManualCaseDecision | "all";
hideResolvedAnnotations?: boolean;
}
type UnifiedCommentListItem =
| {
source: "autorun";
key: string;
updated_at: string;
rating: number;
autorun: AutoRunAnnotationListItem;
assistant: null;
}
| {
source: "assistant_live";
key: string;
updated_at: string;
rating: number;
autorun: null;
assistant: AssistantAnnotationRecord;
};
const DEFAULT_AUTOGEN_SETTINGS: AutoGenSettingsState = {
mode: "codex_creative",
count: 24,
personalityId: "general",
personalityPrompts: buildDefaultPersonalityPrompts(),
persistToEvalCases: true,
generatedBy: "manual_reviewer"
};
function normalizeAnalysisDateInput(value: string): string {
const normalized = String(value ?? "").trim();
return /^\d{4}-\d{2}-\d{2}$/.test(normalized) ? normalized : "";
}
function clampAutogenPromptHeight(value: number | null | undefined): number {
const numeric = typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : 160;
return Math.max(110, Math.min(520, numeric));
}
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 | null): string {
if (!iso) return "нет данных";
const parsed = Date.parse(iso);
if (!Number.isFinite(parsed)) return iso;
return new Date(parsed).toLocaleString("ru-RU");
}
function formatDialogStepTag(message: AutoRunDialogMessage): string | null {
const localIndex =
typeof message.case_message_index === "number"
? message.case_message_index
: typeof message.message_index === "number"
? message.message_index
: null;
if (localIndex === null || localIndex < 0) {
return null;
}
const turnNumber = Math.floor(localIndex / 2) + 1;
const turnLabel = String(turnNumber).padStart(3, "0");
const roleLabel = message.role === "assistant" ? "ответ" : "вопрос";
return `${turnLabel} ${roleLabel}`;
}
function formatAutoGenModeLabel(mode: AutoGenMode): string {
if (mode === "saved_user_sessions") {
return "Пользовательские сессии";
}
return mode;
}
function isAgentSemanticGeneration(item: AutoGenHistoryRecord | null | undefined): boolean {
if (!item) {
return false;
}
if (item.context?.agent_run === true) {
return true;
}
if (item.context?.saved_case_set_kind === "agent_semantic_scenario") {
return true;
}
return typeof item.title === "string" && item.title.trim().toUpperCase().startsWith("AGENT");
}
function formatAutoGenGenerationTitle(item: AutoGenHistoryRecord): string {
const fallback = item.title ?? formatDateTime(item.created_at);
if (isAgentSemanticGeneration(item) && !fallback.trim().toUpperCase().startsWith("AGENT")) {
return `AGENT | ${fallback}`;
}
return fallback;
}
function buildSavedSessionDefaultTitle(items: AssistantConversationItem[]): string {
const lastMessage = items[items.length - 1];
const timestamp = formatDateTime(lastMessage?.created_at ?? new Date().toISOString());
return `Ручная сессия ${timestamp}`;
}
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 "нет данных";
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 renderRatingDots(rating: number): string {
const safe = Math.max(1, Math.min(5, Math.round(rating)));
return `${"●".repeat(safe)}${"○".repeat(5 - safe)}`;
}
function renderCoverageRows(items: AutoRunDomainCoverage[]) {
if (items.length === 0) {
return <p className="muted">Покрытие доменов пока не сформировано.</p>;
}
return (
<div className="autoruns-coverage-list">
{items.map((item) => {
const percent = toPercent(item.closed_cases, item.total_cases);
return (
<div key={item.domain} className="autoruns-coverage-item">
<div className="autoruns-coverage-head">
<strong>{item.domain}</strong>
<span>
{item.closed_cases}/{item.total_cases} ({percent}%)
</span>
</div>
<div className="autoruns-coverage-bar">
<div style={{ width: `${percent}%` }} />
</div>
</div>
);
})}
</div>
);
}
function toLiveRunId(jobId: string): string {
return `${LIVE_RUN_ID_PREFIX}${jobId}`;
}
function isLiveRunId(runId: string): boolean {
return runId.startsWith(LIVE_RUN_ID_PREFIX);
}
function jobIdFromLiveRunId(runId: string): string {
return runId.startsWith(LIVE_RUN_ID_PREFIX) ? runId.slice(LIVE_RUN_ID_PREFIX.length) : "";
}
function buildLiveRunSummary(job: AsyncEvalRunJob): AutoRunSummary {
const timestamp = job.report_summary?.run_timestamp ?? job.created_at;
const openCases = Math.max(0, job.total_cases - job.completed_cases);
const liveRunId = toLiveRunId(job.job_id);
return {
run_id: liveRunId,
eval_target: job.eval_target,
run_timestamp: timestamp,
mode: "single-pass-strict",
llm_provider: null,
model: null,
use_mock: null,
analysis_date: job.report_summary?.analysis_date ?? job.analysis_date ?? null,
prompt_version: null,
schema_version: null,
suite_id: job.case_set_file,
cases_total: job.total_cases,
requests_total: null,
report_path: `async_job:${job.job_id}`,
score_index: job.report_summary?.score_index ?? null,
blocking_failures: 0,
quality_failures: 0,
closed_cases: job.completed_cases,
open_cases: openCases,
domain_coverage: [
{
domain: "runtime",
total_cases: job.total_cases,
closed_cases: job.completed_cases
}
]
};
}
function buildLiveRunDetail(job: AsyncEvalRunJob, requestedCaseId: string): {
detail: AutoRunDetailResponse;
dialog: AutoRunDialogResponse;
caseId: string;
} {
const summary = buildLiveRunSummary(job);
const cases = job.cases.map<AutoRunCaseSummary>((item) => ({
case_id: item.case_id,
domain: null,
query_class: null,
status: item.status === "completed" ? "closed" : item.status === "failed" ? "open" : "unknown",
score_index: null,
trace_id: null,
reply_type: null,
session_id: `${job.run_id}-${item.case_id}`,
dialog_available: item.messages.length > 0,
commented_count: 0,
latest_annotation_at: null,
avg_rating: null,
checks: null,
metric_subscores: null
}));
const hasCase = requestedCaseId !== ALL_CASES_ID && cases.some((item) => item.case_id === requestedCaseId);
const caseId = hasCase ? requestedCaseId : cases.length > 0 ? ALL_CASES_ID : "";
const detail: AutoRunDetailResponse = {
ok: true,
run: summary,
coverage: {
closed_cases: job.completed_cases,
open_cases: Math.max(0, job.total_cases - job.completed_cases),
domain_coverage: [
{
domain: "runtime",
total_cases: job.total_cases,
closed_cases: job.completed_cases
}
]
},
cases,
annotations_summary: { total: 0 },
report: job.report_summary
? {
run_id: job.report_summary.run_id,
run_timestamp: job.report_summary.run_timestamp,
score_index: job.report_summary.score_index,
cases_total: job.report_summary.cases_total,
analysis_date: job.report_summary.analysis_date ?? job.analysis_date ?? null
}
: {}
};
const messages: AutoRunDialogMessage[] = [];
let globalIndex = 0;
if (caseId === ALL_CASES_ID) {
for (const caseItem of job.cases) {
for (let index = 0; index < caseItem.messages.length; index += 1) {
const message = caseItem.messages[index];
messages.push({
...message,
message_index: globalIndex,
case_id: caseItem.case_id,
case_message_index: index,
commented: false,
annotation: null
});
globalIndex += 1;
}
}
} else if (caseId) {
const selected = job.cases.find((item) => item.case_id === caseId) ?? null;
for (let index = 0; index < (selected?.messages.length ?? 0); index += 1) {
const message = selected?.messages[index];
if (!message) continue;
messages.push({
...message,
message_index: index,
case_id: caseId,
case_message_index: index,
commented: false,
annotation: null
});
}
}
const dialog: AutoRunDialogResponse = {
ok: true,
run_id: summary.run_id,
case_id: caseId,
source: "assistant_session",
session_id: caseId === ALL_CASES_ID ? `${job.run_id}::__all__` : `${job.run_id}-${caseId}`,
messages,
decomposition: [],
assistant_mode: {
status: job.status,
completed_cases: job.completed_cases,
total_cases: job.total_cases
},
annotations: []
};
return {
detail,
dialog,
caseId
};
}
function CommentBubbleIcon({ commented }: { commented: boolean }) {
const className = commented ? "comment-icon-svg commented" : "comment-icon-svg";
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M5 6.5h14v9H11.5l-4.5 3v-3H5z" />
</svg>
);
}
function CommentResolvedIcon({ resolved }: { resolved: boolean }) {
return (
<svg className={resolved ? "resolve-icon-svg resolved" : "resolve-icon-svg"} viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<circle cx="8" cy="8" r="6.2" />
{resolved ? <path d="M5.1 8.2 7.2 10.3 11 6.5" /> : null}
</svg>
);
}
function CopyOutlineIcon() {
return (
<svg className="autoruns-copy-icon-svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="9" y="9" width="11" height="11" rx="2.2" />
<path d="M15 7V5.8a1.8 1.8 0 0 0-1.8-1.8H5.8A1.8 1.8 0 0 0 4 5.8v7.4A1.8 1.8 0 0 0 5.8 15H7" />
</svg>
);
}
function QuestionGripIcon() {
return (
<svg className="autoruns-question-grip-svg" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<circle cx="4" cy="4" r="1" />
<circle cx="8" cy="4" r="1" />
<circle cx="12" cy="4" r="1" />
<circle cx="4" cy="8" r="1" />
<circle cx="8" cy="8" r="1" />
<circle cx="12" cy="8" r="1" />
<circle cx="4" cy="12" r="1" />
<circle cx="8" cy="12" r="1" />
<circle cx="12" cy="12" r="1" />
</svg>
);
}
function CardChevronIcon({ expanded }: { expanded: boolean }) {
return (
<svg
className={expanded ? "autoruns-card-chevron-svg expanded" : "autoruns-card-chevron-svg"}
viewBox="0 0 16 16"
aria-hidden="true"
focusable="false"
>
<path d="M3.5 6.2 8 10.4l4.5-4.2" />
</svg>
);
}
function CardLaunchIcon() {
return (
<svg className="autoruns-card-launch-svg" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M5 3.8 12 8l-7 4.2Z" />
</svg>
);
}
function CardStopIcon() {
return (
<svg className="autoruns-card-stop-svg" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<rect x="4.2" y="4.2" width="7.6" height="7.6" rx="0.8" />
</svg>
);
}
function GroupChevronIcon({ expanded }: { expanded: boolean }) {
return (
<svg className={expanded ? "autoruns-group-chevron-svg expanded" : "autoruns-group-chevron-svg"} viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M3.5 6.2 8 10.4l4.5-4.2" />
</svg>
);
}
export function AutoRunsHistoryPanel({
connection,
modelOptions,
modelsBusy,
connectionStatus,
connectionBusy,
onConnectionChange,
onReloadModels,
onSaveLocalConfig,
onTestConnection,
prompts,
onPromptsChange,
promptPresets,
selectedPresetId,
onSelectPreset,
onLoadPreset,
onSavePreset,
onResetDefaults,
onDiffPrevious,
presetName,
onPresetNameChange,
diffSummary,
assistantPromptVersion,
decompositionPromptVersion,
showSettingsMode,
showAutoRunsMode,
showAssistantMode,
showProgressMode,
showCommentsMode,
onLog
}: AutoRunsHistoryPanelProps) {
const [filters, setFilters] = useState<AutoRunsFilters>({
...DEFAULT_FILTERS,
fromLocal: defaultFromDateValue()
});
const [analysisDate, setAnalysisDate] = useState("");
const [history, setHistory] = useState<AutoRunHistoryResponse | null>(null);
const [runDetail, setRunDetail] = useState<AutoRunDetailResponse | null>(null);
const [dialog, setDialog] = useState<AutoRunDialogResponse | null>(null);
const [annotations, setAnnotations] = useState<AutoRunAnnotationListItem[]>([]);
const [annotationDecisionFilter, setAnnotationDecisionFilter] = useState<ManualCaseDecision | "all">("all");
const [hideResolvedAnnotations, setHideResolvedAnnotations] = useState(false);
const [manualDecisionSchema, setManualDecisionSchema] = useState<Record<string, unknown> | null>(null);
const [availableManualDecisions, setAvailableManualDecisions] = useState<ManualCaseDecision[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState("");
const [selectedRunId, setSelectedRunId] = useState("");
const [selectedCaseId, setSelectedCaseId] = useState("");
const [autogenPersonalities, setAutogenPersonalities] = useState<AutoGenPersonalityDefinition[]>(AUTOGEN_PERSONALITIES);
const [autoGenSettings, setAutoGenSettings] = useState<AutoGenSettingsState>(DEFAULT_AUTOGEN_SETTINGS);
const [autoGenHistory, setAutoGenHistory] = useState<AutoGenHistoryRecord[]>([]);
const [selectedAutogenGenerationId, setSelectedAutogenGenerationId] = useState("");
const [expandedSavedSessionGenerationId, setExpandedSavedSessionGenerationId] = useState("");
const [editableGeneratedQuestions, setEditableGeneratedQuestions] = useState<string[]>([]);
const [generatedQuestionsBusy, setGeneratedQuestionsBusy] = useState(false);
const [editingQuestionIndex, setEditingQuestionIndex] = useState<number | null>(null);
const [editingQuestionDraft, setEditingQuestionDraft] = useState("");
const [draggingQuestionIndex, setDraggingQuestionIndex] = useState<number | null>(null);
const [dragOverQuestionIndex, setDragOverQuestionIndex] = useState<number | null>(null);
const [activeAsyncJob, setActiveAsyncJob] = useState<AsyncEvalRunJob | null>(null);
const [postAnalysis, setPostAnalysis] = useState<AutoRunPostAnalysisResponse | null>(null);
const [autoGenBusy, setAutoGenBusy] = useState(false);
const [autogenRunBusy, setAutogenRunBusy] = useState(false);
const [postAnalysisBusy, setPostAnalysisBusy] = useState(false);
const [autogenHistoryBusy, setAutogenHistoryBusy] = useState(false);
const [historyBusy, setHistoryBusy] = useState(false);
const [detailBusy, setDetailBusy] = useState(false);
const [dialogBusy, setDialogBusy] = useState(false);
const [annotationsBusy, setAnnotationsBusy] = useState(false);
const [annotationResolutionBusyId, setAnnotationResolutionBusyId] = useState("");
const [errorText, setErrorText] = useState("");
const [assistantLiveSessionId, setAssistantLiveSessionId] = useState("");
const [assistantLiveConversation, setAssistantLiveConversation] = useState<AssistantConversationItem[]>([]);
const [assistantLiveAnnotations, setAssistantLiveAnnotations] = useState<AssistantAnnotationRecord[]>([]);
const [assistantLiveInput, setAssistantLiveInput] = useState("");
const [assistantLiveSelectedChip, setAssistantLiveSelectedChip] = useState<AssistantSelectionChip | null>(null);
const [assistantLiveUseMock, setAssistantLiveUseMock] = useState(false);
const [assistantLiveBusy, setAssistantLiveBusy] = useState(false);
const [assistantLiveStatus, setAssistantLiveStatus] = useState("");
const [assistantLiveError, setAssistantLiveError] = useState("");
const [limitInput, setLimitInput] = useState(String(DEFAULT_FILTERS.limit));
const [autogenCountInput, setAutogenCountInput] = useState(String(DEFAULT_AUTOGEN_SETTINGS.count));
const [autogenPersonalityPromptHeight, setAutogenPersonalityPromptHeight] = useState(160);
const [runningAutogenGenerationId, setRunningAutogenGenerationId] = useState("");
const [autogenStopBusy, setAutogenStopBusy] = useState(false);
const [filtersGroupExpanded, setFiltersGroupExpanded] = useState(true);
const [generationContextGroupExpanded, setGenerationContextGroupExpanded] = useState(true);
const [autogenGroupExpanded, setAutogenGroupExpanded] = useState(true);
const [savedSessionsGroupExpanded, setSavedSessionsGroupExpanded] = useState(true);
const [commentModal, setCommentModal] = useState<CommentModalState>({
open: false,
caseId: "",
caseMessageIndex: -1,
messageIndex: -1,
rating: 3,
comment: "",
manualCaseDecision: DEFAULT_MANUAL_DECISION,
annotationAuthor: "manual_reviewer",
saving: false,
error: ""
});
const [assistantLiveCommentModal, setAssistantLiveCommentModal] = useState<AssistantLiveCommentModalState>({
open: false,
messageIndex: -1,
rating: 3,
comment: "",
annotationAuthor: "manual_reviewer",
saving: false,
error: ""
});
const [assistantLiveSaveModal, setAssistantLiveSaveModal] = useState<AssistantLiveSaveModalState>({
open: false,
title: "",
saving: false,
error: ""
});
const [savedSessionQuestionDeleteModal, setSavedSessionQuestionDeleteModal] = useState<SavedSessionQuestionDeleteModalState>({
open: false,
generationId: "",
questionIndex: -1,
questionText: "",
saving: false,
error: ""
});
const [autoGenDeleteModal, setAutoGenDeleteModal] = useState<AutoGenDeleteModalState>({
open: false,
generationId: "",
title: "",
saving: false,
error: ""
});
const initialLoadDoneRef = useRef(false);
const asyncJobPollTimerRef = useRef<number | null>(null);
const questionEditorRef = useRef<HTMLInputElement | null>(null);
const isSavedUserSessionsMode = autoGenSettings.mode === "saved_user_sessions";
const selectedPersonality = useMemo(
() => autogenPersonalities.find((item) => item.id === autoGenSettings.personalityId) ?? autogenPersonalities[0] ?? AUTOGEN_PERSONALITIES[0],
[autoGenSettings.personalityId, autogenPersonalities]
);
const visibleAutoGenHistory = useMemo(
() => autoGenHistory.filter((item) => item.mode === autoGenSettings.mode),
[autoGenHistory, autoGenSettings.mode]
);
const selectedAutogenGeneration = useMemo(
() => visibleAutoGenHistory.find((item) => item.generation_id === selectedAutogenGenerationId) ?? visibleAutoGenHistory[0] ?? null,
[selectedAutogenGenerationId, visibleAutoGenHistory]
);
const visibleAnnotations = useMemo(
() => (hideResolvedAnnotations ? annotations.filter((item) => !item.resolved) : annotations),
[annotations, hideResolvedAnnotations]
);
const selectedAnnotation = visibleAnnotations.find((item) => item.annotation_id === selectedAnnotationId) ?? null;
const modalMessage = dialog?.messages.find((item) => item.message_index === commentModal.messageIndex) ?? null;
const modalQuestion = useMemo(() => {
if (!dialog || commentModal.messageIndex < 0) return null;
for (let index = commentModal.messageIndex - 1; index >= 0; index -= 1) {
const candidate = dialog.messages[index];
if (candidate?.role === "user") {
return candidate;
}
}
return null;
}, [commentModal.messageIndex, dialog]);
const assistantLiveAnnotationsByMessageId = useMemo(() => {
const map = new Map<string, AssistantAnnotationRecord>();
for (const item of assistantLiveAnnotations) {
if (item.message_id) {
map.set(item.message_id, item);
}
}
return map;
}, [assistantLiveAnnotations]);
const assistantLiveCommentModalMessage =
assistantLiveCommentModal.messageIndex >= 0 ? assistantLiveConversation[assistantLiveCommentModal.messageIndex] ?? null : null;
const assistantLiveCommentModalQuestion = useMemo(() => {
if (assistantLiveCommentModal.messageIndex < 0) return null;
for (let index = assistantLiveCommentModal.messageIndex - 1; index >= 0; index -= 1) {
const candidate = assistantLiveConversation[index];
if (candidate?.role === "user") {
return candidate;
}
}
return null;
}, [assistantLiveCommentModal.messageIndex, assistantLiveConversation]);
const unifiedVisibleAnnotations = useMemo<UnifiedCommentListItem[]>(() => {
const autorunItems: UnifiedCommentListItem[] = visibleAnnotations.map((item) => ({
source: "autorun",
key: `autorun:${item.annotation_id}`,
updated_at: item.updated_at,
rating: item.rating,
autorun: item,
assistant: null
}));
const assistantItems: UnifiedCommentListItem[] = assistantLiveAnnotations.map((item) => ({
source: "assistant_live",
key: `assistant:${item.annotation_id}`,
updated_at: item.updated_at,
rating: item.rating,
autorun: null,
assistant: item
}));
return [...autorunItems, ...assistantItems].sort((left, right) => Date.parse(right.updated_at) - Date.parse(left.updated_at));
}, [assistantLiveAnnotations, visibleAnnotations]);
const annotationsAverageRating = useMemo(() => {
if (unifiedVisibleAnnotations.length === 0) return null;
const avg = unifiedVisibleAnnotations.reduce((acc, item) => acc + item.rating, 0) / unifiedVisibleAnnotations.length;
return Number(avg.toFixed(2));
}, [unifiedVisibleAnnotations]);
const runSelectItems = useMemo(() => {
const list = [...(history?.items ?? [])];
if (activeAsyncJob) {
list.unshift(buildLiveRunSummary(activeAsyncJob));
}
if (selectedRunId && !list.some((item) => item.run_id === selectedRunId) && runDetail?.run) {
list.unshift(runDetail.run);
}
return list;
}, [activeAsyncJob, history?.items, runDetail?.run, selectedRunId]);
const log = useCallback(
(message: string) => {
onLog?.(`[autoruns] ${message}`);
},
[onLog]
);
const loadAssistantLiveAnnotationsForSession = useCallback(
async (sessionIdRaw: string) => {
const sessionId = String(sessionIdRaw ?? "").trim();
if (!sessionId) {
setAssistantLiveAnnotations([]);
return;
}
try {
const payload = await apiClient.loadAssistantAnnotations({
session_id: sessionId,
limit: 400
});
setAssistantLiveAnnotations(payload.items ?? []);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(`Assistant live annotations load error: ${message}`);
}
},
[log]
);
const closeAssistantLiveCommentModal = useCallback((options?: { force?: boolean }) => {
setAssistantLiveCommentModal((prev) => {
if (prev.saving && !options?.force) {
return prev;
}
return {
open: false,
messageIndex: -1,
rating: 3,
comment: "",
annotationAuthor: "manual_reviewer",
saving: false,
error: ""
};
});
}, []);
const closeAssistantLiveSaveModal = useCallback((options?: { force?: boolean }) => {
setAssistantLiveSaveModal((prev) => {
if (prev.saving && !options?.force) {
return prev;
}
return {
open: false,
title: "",
saving: false,
error: ""
};
});
}, []);
const closeSavedSessionQuestionDeleteModal = useCallback((options?: { force?: boolean }) => {
setSavedSessionQuestionDeleteModal((prev) => {
if (prev.saving && !options?.force) {
return prev;
}
return {
open: false,
generationId: "",
questionIndex: -1,
questionText: "",
saving: false,
error: ""
};
});
}, []);
const closeAutoGenDeleteModal = useCallback((options?: { force?: boolean }) => {
setAutoGenDeleteModal((prev) => {
if (prev.saving && !options?.force) {
return prev;
}
return {
open: false,
generationId: "",
title: "",
saving: false,
error: ""
};
});
}, []);
const copyIdentifierToClipboard = useCallback(
async (event: React.SyntheticEvent, valueRaw: string, label: string) => {
event.stopPropagation();
event.preventDefault();
const value = String(valueRaw ?? "").trim();
if (!value) {
return;
}
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
} else {
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
log(`${label} copied: ${value}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorText(`Копирование ${label}: ${message}`);
log(`copy ${label} error: ${message}`);
}
},
[log]
);
function startAssistantLiveStatusTicker(): () => void {
let index = 0;
setAssistantLiveStatus(ASSISTANT_STAGES[0]);
const timer = window.setInterval(() => {
index = Math.min(index + 1, ASSISTANT_STAGES.length - 1);
setAssistantLiveStatus(ASSISTANT_STAGES[index]);
}, 650);
return () => window.clearInterval(timer);
}
const resetAssistantLiveSession = useCallback(() => {
setAssistantLiveSessionId("");
setAssistantLiveConversation([]);
setAssistantLiveAnnotations([]);
setAssistantLiveInput("");
setAssistantLiveSelectedChip(null);
setAssistantLiveStatus("");
setAssistantLiveError("");
closeAssistantLiveCommentModal({ force: true });
log("Live-чат ассистента в истории автопрогонов сброшен.");
}, [closeAssistantLiveCommentModal, log]);
const sendAssistantLiveMessage = useCallback(async () => {
const userMessage = buildAssistantLiveFollowupMessage(assistantLiveInput, assistantLiveSelectedChip);
if (!userMessage) {
return;
}
setAssistantLiveBusy(true);
setAssistantLiveError("");
setAssistantLiveInput("");
setAssistantLiveConversation((prev) => [
...prev,
{
message_id: `autoruns-live-${Date.now()}`,
session_id: assistantLiveSessionId || "pending",
role: "user",
text: userMessage,
reply_type: null,
created_at: new Date().toISOString(),
trace_id: null,
debug: null
}
]);
const stopTicker = startAssistantLiveStatusTicker();
try {
const response = await apiClient.sendAssistantMessage({
connection,
prompts,
userMessage,
sessionId: assistantLiveSessionId || undefined,
promptVersion: assistantPromptVersion,
useMock: assistantLiveUseMock
});
setAssistantLiveSessionId(response.session_id);
setAssistantLiveConversation(response.conversation);
await loadAssistantLiveAnnotationsForSession(response.session_id);
setAssistantLiveStatus("Ответ готов");
log(`Live-ответ ассистента получен: trace=${response.debug.trace_id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAssistantLiveError(message);
setAssistantLiveStatus("Ошибка ассистента");
log(`Live-чат ассистента: ошибка отправки сообщения: ${message}`);
} finally {
stopTicker();
setAssistantLiveBusy(false);
}
}, [
assistantLiveInput,
assistantLiveSelectedChip,
assistantLiveSessionId,
assistantLiveUseMock,
assistantPromptVersion,
connection,
loadAssistantLiveAnnotationsForSession,
log,
prompts
]);
const openAssistantLiveSaveModal = useCallback(() => {
if (!assistantLiveSessionId.trim() || assistantLiveConversation.length === 0) {
setAssistantLiveError("Сначала получите хотя бы один ответ в живой сессии ассистента.");
return;
}
setAssistantLiveError("");
setAssistantLiveSaveModal({
open: true,
title: buildSavedSessionDefaultTitle(assistantLiveConversation),
saving: false,
error: ""
});
}, [assistantLiveConversation, assistantLiveSessionId]);
const submitAssistantLiveSaveModal = useCallback(async () => {
const sessionId = assistantLiveSessionId.trim();
const title = assistantLiveSaveModal.title.trim();
if (!sessionId) {
setAssistantLiveSaveModal((prev) => ({ ...prev, error: "Активная сессия ассистента не найдена." }));
return;
}
if (!title) {
setAssistantLiveSaveModal((prev) => ({ ...prev, error: "Укажите название сессии." }));
return;
}
setAssistantLiveSaveModal((prev) => ({ ...prev, saving: true, error: "" }));
try {
const promptFingerprint = [
prompts.systemPrompt,
prompts.developerPrompt,
prompts.domainPrompt,
prompts.schemaNotes,
prompts.fewShotExamples
].join("||");
const payload = await apiClient.saveAutoRunAssistantSession({
session_id: sessionId,
title,
generated_by: autoGenSettings.generatedBy.trim() || undefined,
context: {
llm_provider: connection.llmProvider,
model: connection.model,
assistant_prompt_version: assistantPromptVersion,
decomposition_prompt_version: decompositionPromptVersion,
prompt_fingerprint: promptFingerprint
}
});
setAutoGenHistory((prev) => [payload.generation, ...prev.filter((item) => item.generation_id !== payload.generation.generation_id)]);
setAutoGenSettings((prev) => ({ ...prev, mode: "saved_user_sessions" }));
setSelectedAutogenGenerationId(payload.generation.generation_id);
closeAssistantLiveSaveModal({ force: true });
log(`Живая сессия сохранена в автопрогоны: ${payload.generation.generation_id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAssistantLiveSaveModal((prev) => ({ ...prev, saving: false, error: message }));
log(`Assistant live save error: ${message}`);
}
}, [
assistantLiveSaveModal.title,
assistantLiveSessionId,
assistantPromptVersion,
autoGenSettings.generatedBy,
closeAssistantLiveSaveModal,
connection.llmProvider,
connection.model,
decompositionPromptVersion,
log,
prompts.developerPrompt,
prompts.domainPrompt,
prompts.fewShotExamples,
prompts.schemaNotes,
prompts.systemPrompt
]);
const commitLimitInput = useCallback(
(raw: string) => {
const normalized = raw.trim();
if (!normalized) {
setLimitInput(String(filters.limit));
return;
}
if (!/^\d+$/.test(normalized)) {
setLimitInput(String(filters.limit));
return;
}
const parsed = Number.parseInt(normalized, 10);
if (!Number.isFinite(parsed)) {
setLimitInput(String(filters.limit));
return;
}
const next = Math.max(1, Math.min(500, parsed));
if (next !== filters.limit) {
setFilters((prev) => ({ ...prev, limit: next }));
}
setLimitInput(String(next));
},
[filters.limit]
);
const commitAutogenCountInput = useCallback(
(raw: string) => {
const normalized = raw.trim();
if (!normalized) {
setAutogenCountInput(String(autoGenSettings.count));
return;
}
if (!/^\d+$/.test(normalized)) {
setAutogenCountInput(String(autoGenSettings.count));
return;
}
const parsed = Number.parseInt(normalized, 10);
if (!Number.isFinite(parsed)) {
setAutogenCountInput(String(autoGenSettings.count));
return;
}
const next = Math.max(1, Math.min(200, parsed));
if (next !== autoGenSettings.count) {
setAutoGenSettings((prev) => ({ ...prev, count: next }));
}
setAutogenCountInput(String(next));
},
[autoGenSettings.count]
);
const commitAutogenPromptHeight = useCallback((height: number) => {
setAutogenPersonalityPromptHeight(clampAutogenPromptHeight(height));
}, []);
const captureAutogenPromptHeight = useCallback(
(event: SyntheticEvent<HTMLTextAreaElement>) => {
const nextHeight = event.currentTarget.offsetHeight;
if (Number.isFinite(nextHeight) && nextHeight > 0) {
commitAutogenPromptHeight(nextHeight);
}
},
[commitAutogenPromptHeight]
);
const loadAnnotations = useCallback(async () => {
setAnnotationsBusy(true);
try {
const payload = await apiClient.loadAutoRunAnnotations({
limit: 800,
manual_case_decision: annotationDecisionFilter
});
setAnnotations(payload.items);
setManualDecisionSchema(payload.manual_case_decision_schema ?? null);
setAvailableManualDecisions(payload.available_manual_case_decisions ?? []);
setSelectedAnnotationId((prev) => {
if (payload.items.length === 0) return "";
if (payload.items.some((item) => item.annotation_id === prev)) return prev;
return payload.items[0].annotation_id;
});
} catch (error) {
log(`Annotations load error: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setAnnotationsBusy(false);
}
}, [annotationDecisionFilter, log]);
const loadAutoGenHistory = useCallback(async () => {
setAutogenHistoryBusy(true);
try {
const payload = await apiClient.loadAutoRunAutogenHistory({ limit: 180 });
setAutoGenHistory(payload.items);
} catch (error) {
log(`Autogen history load error: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setAutogenHistoryBusy(false);
}
}, [log]);
const loadAutoGenPersonalityCatalog = useCallback(async () => {
try {
const payload = await apiClient.loadAutoRunAutogenPersonalityCatalog();
const normalized = payload.items
.map((item) => ({
id: String(item.id ?? "").trim(),
label: String(item.label ?? "").trim(),
domain: typeof item.domain === "string" ? item.domain.trim() : "",
defaultPrompt: String(item.default_prompt ?? "").trim()
}))
.filter((item) => item.id.length > 0 && item.label.length > 0);
if (normalized.length === 0) {
return;
}
setAutogenPersonalities(
normalized.map((item) => ({
id: item.id,
label: item.label,
domain: item.domain || "",
defaultPrompt:
item.defaultPrompt ||
"Генерируй реалистичные вопросы бухгалтера по выбранному профилю. Не выдумывай непокрытые возможности."
}))
);
} catch (error) {
log(`Autogen personality catalog load error: ${error instanceof Error ? error.message : String(error)}`);
}
}, [log]);
const loadPostAnalysis = useCallback(async () => {
setPostAnalysisBusy(true);
try {
const payload = await apiClient.loadAutoRunPostAnalysis({
run_id: selectedRunId && !isLiveRunId(selectedRunId) ? selectedRunId : undefined,
limit_per_queue: 30,
annotation_limit: 1500,
from: localInputToIso(filters.fromLocal),
to: localInputToIso(filters.toLocal),
target: filters.target,
mode: filters.mode,
use_mock: filters.useMock,
prompt_contains: filters.promptContains.trim() || undefined
});
setPostAnalysis(payload);
} catch (error) {
log(`Post-analysis load error: ${error instanceof Error ? error.message : String(error)}`);
setPostAnalysis(null);
} finally {
setPostAnalysisBusy(false);
}
}, [filters.fromLocal, filters.mode, filters.promptContains, filters.target, filters.toLocal, filters.useMock, log, selectedRunId]);
const generateAutogenBatch = useCallback(async () => {
setAutoGenBusy(true);
setErrorText("");
try {
if (autoGenSettings.mode === "saved_user_sessions") {
throw new Error("Пользовательские сессии сохраняются из живого чата, а не генерируются автоматически.");
}
const activePersonalityPrompt = autoGenSettings.personalityPrompts[autoGenSettings.personalityId] ?? "";
const promptFingerprint = [
prompts.systemPrompt,
prompts.developerPrompt,
prompts.domainPrompt,
prompts.schemaNotes,
prompts.fewShotExamples
]
.join("\n")
.slice(0, 900);
const payload = await apiClient.generateAutoRunQuestions({
mode: autoGenSettings.mode,
count: autoGenSettings.count,
domain: selectedPersonality.domain || undefined,
persist_to_eval_cases: autoGenSettings.persistToEvalCases,
generated_by: autoGenSettings.generatedBy.trim() || undefined,
llm: {
llm_provider: connection.llmProvider,
api_key: connection.apiKey,
model: connection.model,
base_url: connection.baseUrl,
temperature: connection.temperature,
max_output_tokens: connection.maxOutputTokens
},
context: {
llm_provider: connection.llmProvider,
model: connection.model,
assistant_prompt_version: assistantPromptVersion,
decomposition_prompt_version: decompositionPromptVersion,
prompt_fingerprint: promptFingerprint,
autogen_personality_id: selectedPersonality.id,
autogen_personality_prompt: activePersonalityPrompt.trim() || undefined
}
});
log(
`Generated ${payload.generation.count} questions (${payload.generation.mode}) id=${payload.generation.generation_id}` +
(payload.generation.saved_case_set_file ? ` saved=${payload.generation.saved_case_set_file}` : "")
);
setSelectedAutogenGenerationId(payload.generation.generation_id);
setEditableGeneratedQuestions([...(payload.generation.questions ?? [])]);
await loadAutoGenHistory();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorText(`Автогенерация: ${message}`);
log(`Autogen generate error: ${message}`);
} finally {
setAutoGenBusy(false);
}
}, [
assistantPromptVersion,
autoGenSettings.count,
autoGenSettings.generatedBy,
autoGenSettings.mode,
autoGenSettings.personalityId,
autoGenSettings.personalityPrompts,
autoGenSettings.persistToEvalCases,
connection.apiKey,
connection.baseUrl,
connection.llmProvider,
connection.maxOutputTokens,
connection.model,
connection.temperature,
decompositionPromptVersion,
loadAutoGenHistory,
log,
prompts.developerPrompt,
prompts.domainPrompt,
prompts.fewShotExamples,
prompts.schemaNotes,
prompts.systemPrompt,
selectedPersonality.domain,
selectedPersonality.id
]);
const loadCaseDialog = useCallback(
async (runId: string, caseId: string) => {
if (isLiveRunId(runId)) {
const liveJobId = jobIdFromLiveRunId(runId);
if (activeAsyncJob && activeAsyncJob.job_id === liveJobId) {
const live = buildLiveRunDetail(activeAsyncJob, caseId);
setSelectedRunId(runId);
setSelectedCaseId(live.caseId);
setDialog(live.dialog);
return;
}
setDialog(null);
return;
}
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}`);
setDialog(null);
log(`Dialog load error for ${runId}/${caseId}: ${message}`);
} finally {
setDialogBusy(false);
}
},
[activeAsyncJob, log]
);
const loadRunDetail = useCallback(
async (runId: string, preferredCaseId?: string) => {
if (isLiveRunId(runId)) {
const liveJobId = jobIdFromLiveRunId(runId);
if (activeAsyncJob && activeAsyncJob.job_id === liveJobId) {
const live = buildLiveRunDetail(activeAsyncJob, preferredCaseId ?? ALL_CASES_ID);
setSelectedRunId(runId);
setSelectedCaseId(live.caseId);
setRunDetail(live.detail);
setDialog(live.dialog);
return;
}
setSelectedRunId(runId);
setSelectedCaseId("");
setRunDetail(null);
setDialog(null);
return;
}
setDetailBusy(true);
try {
const payload = await apiClient.loadAutoRunDetail(runId);
setRunDetail(payload);
const nextCaseId =
(preferredCaseId &&
(preferredCaseId === ALL_CASES_ID || payload.cases.some((item) => item.case_id === preferredCaseId))
? preferredCaseId
: "") ||
(payload.cases.length > 0 ? ALL_CASES_ID : "") ||
"";
setSelectedRunId(runId);
setSelectedCaseId(nextCaseId);
if (nextCaseId) {
await loadCaseDialog(runId, nextCaseId);
} else {
setDialog(null);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorText(`Детализация прогона: ${message}`);
setRunDetail(null);
setDialog(null);
log(`Run detail load error for ${runId}: ${message}`);
} finally {
setDetailBusy(false);
}
},
[activeAsyncJob, 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);
if (payload.items.length === 0) {
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;
await loadRunDetail(nextRunId, keepSelection ? preferredCaseId : undefined);
void loadPostAnalysis();
} 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,
loadPostAnalysis,
loadRunDetail,
log
]
);
const stopAsyncJobPolling = useCallback(() => {
if (asyncJobPollTimerRef.current !== null) {
window.clearTimeout(asyncJobPollTimerRef.current);
asyncJobPollTimerRef.current = null;
}
}, []);
const pollAsyncJobStatus = useCallback(
async (jobId: string) => {
try {
const payload = await apiClient.loadEvalRunAsyncStatus(jobId);
setActiveAsyncJob(payload.job);
const liveRunId = toLiveRunId(jobId);
if (selectedRunId === liveRunId) {
const live = buildLiveRunDetail(payload.job, selectedCaseId || ALL_CASES_ID);
setRunDetail(live.detail);
setDialog(live.dialog);
setSelectedCaseId(live.caseId);
}
if (payload.job.status === "completed") {
stopAsyncJobPolling();
setAutogenRunBusy(false);
setAutogenStopBusy(false);
setRunningAutogenGenerationId("");
const finalRunId = payload.job.report_summary?.run_id ?? payload.job.run_id;
await loadHistory({
keepSelection: true,
preferredRunId: finalRunId || selectedRunId,
preferredCaseId: ALL_CASES_ID
});
await loadAutoGenHistory();
setActiveAsyncJob(null);
return;
}
if (payload.job.status === "failed") {
stopAsyncJobPolling();
setAutogenRunBusy(false);
setAutogenStopBusy(false);
setRunningAutogenGenerationId("");
setErrorText(`Запуск прогонов: ${payload.job.error ?? "неизвестная ошибка"}`);
log(`Autogen async run failed: ${payload.job.error ?? "unknown error"}`);
return;
}
if (payload.job.status === "canceled") {
stopAsyncJobPolling();
setAutogenRunBusy(false);
setAutogenStopBusy(false);
setRunningAutogenGenerationId("");
setActiveAsyncJob(null);
await loadHistory({ keepSelection: false });
await loadAutoGenHistory();
log(`Autogen async run canceled: job=${payload.job.job_id}`);
return;
}
stopAsyncJobPolling();
asyncJobPollTimerRef.current = window.setTimeout(() => {
void pollAsyncJobStatus(jobId);
}, 500);
} catch (error) {
stopAsyncJobPolling();
setAutogenRunBusy(false);
setAutogenStopBusy(false);
setRunningAutogenGenerationId("");
const message = error instanceof Error ? error.message : String(error);
setErrorText(`Запуск прогонов: ${message}`);
log(`Autogen async status error: ${message}`);
}
},
[loadAutoGenHistory, loadHistory, log, selectedCaseId, selectedRunId, stopAsyncJobPolling]
);
const runAutogenCampaign = useCallback(async (generationOverride?: AutoGenHistoryRecord, questionsOverride?: string[]) => {
stopAsyncJobPolling();
setAutogenRunBusy(true);
setErrorText("");
try {
const generation = generationOverride ?? selectedAutogenGeneration;
if (!generation) {
throw new Error("История автогенерации пуста. Сначала сгенерируйте пачку вопросов.");
}
const sourceQuestions =
questionsOverride ??
(selectedAutogenGeneration?.generation_id === generation.generation_id ? editableGeneratedQuestions : generation.questions);
const questionsForRun = sourceQuestions
.map((item) => item.trim())
.filter((item) => item.length > 0);
if (questionsForRun.length === 0) {
throw new Error("Нет вопросов для запуска: список пустой после ручного редактирования.");
}
const useMockForRun = filters.useMock === "true";
const effectiveAnalysisDate = normalizeAnalysisDateInput(analysisDate);
const useScenarioReplay = generation.mode === "saved_user_sessions";
const payload = await apiClient.startEvalRunAsync({
connection,
prompts,
promptVersion: assistantPromptVersion,
mode: "single-pass-strict",
caseSetFile: useScenarioReplay ? undefined : generation.saved_case_set_file ?? undefined,
useMock: useMockForRun,
evalTarget: "assistant_stage1",
questions: useScenarioReplay ? undefined : questionsForRun,
scenarioQuestions: useScenarioReplay ? questionsForRun : undefined,
scenarioTitle: useScenarioReplay ? generation.title ?? undefined : undefined,
analysisDate: useScenarioReplay ? undefined : effectiveAnalysisDate || undefined
});
const liveJob = payload.job;
setRunningAutogenGenerationId(generation.generation_id);
setAutogenStopBusy(false);
setActiveAsyncJob(liveJob);
const liveRunId = toLiveRunId(liveJob.job_id);
const live = buildLiveRunDetail(liveJob, ALL_CASES_ID);
setSelectedRunId(liveRunId);
setSelectedCaseId(live.caseId);
setRunDetail(live.detail);
setDialog(live.dialog);
log(
`Запущен async-прогон job=${liveJob.job_id}, run_id=${liveJob.run_id}, вопросов=${questionsForRun.length}` +
(generation.saved_case_set_file ? `, base_case_set=${generation.saved_case_set_file}` : "") +
(useScenarioReplay
? ", replay_mode=saved_user_session_scenario"
: effectiveAnalysisDate
? `, analysis_date=${effectiveAnalysisDate}`
: ", analysis_date=current_state")
);
void pollAsyncJobStatus(liveJob.job_id);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorText(`Запуск прогонов: ${message}`);
log(`Autogen run error: ${message}`);
setAutogenRunBusy(false);
setAutogenStopBusy(false);
setRunningAutogenGenerationId("");
}
}, [
analysisDate,
assistantPromptVersion,
connection,
editableGeneratedQuestions,
filters.useMock,
log,
pollAsyncJobStatus,
prompts,
selectedAutogenGeneration,
stopAsyncJobPolling
]);
const stopAutogenCampaign = useCallback(async () => {
const jobId = activeAsyncJob?.job_id ?? "";
if (!jobId) {
setAutogenRunBusy(false);
setAutogenStopBusy(false);
setRunningAutogenGenerationId("");
setActiveAsyncJob(null);
stopAsyncJobPolling();
return;
}
setAutogenStopBusy(true);
setErrorText("");
try {
const payload = await apiClient.cancelEvalRunAsync(jobId);
stopAsyncJobPolling();
setActiveAsyncJob(null);
setAutogenRunBusy(false);
setAutogenStopBusy(false);
setRunningAutogenGenerationId("");
await loadHistory({ keepSelection: false });
await loadAutoGenHistory();
log(`Autogen async run stopped: job=${payload.job.job_id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAutogenStopBusy(false);
setErrorText(`Остановка прогона: ${message}`);
log(`Autogen stop error: ${message}`);
}
}, [activeAsyncJob, loadAutoGenHistory, loadHistory, log, stopAsyncJobPolling]);
const openCommentModal = useCallback((message: AutoRunDialogMessage) => {
if (message.role !== "assistant") return;
const resolvedCaseId = message.case_id ?? selectedCaseId;
const resolvedCaseMessageIndex = message.case_message_index ?? message.message_index;
setCommentModal({
open: true,
caseId: resolvedCaseId,
caseMessageIndex: resolvedCaseMessageIndex,
messageIndex: message.message_index,
rating: message.annotation?.rating ?? 3,
comment: message.annotation?.comment ?? "",
manualCaseDecision: message.annotation?.manual_case_decision ?? DEFAULT_MANUAL_DECISION,
annotationAuthor: message.annotation?.annotation_author ?? autoGenSettings.generatedBy,
saving: false,
error: ""
});
}, [autoGenSettings.generatedBy, selectedCaseId]);
const closeCommentModal = useCallback((options?: { force?: boolean }) => {
setCommentModal((prev) => {
if (prev.saving && !options?.force) return prev;
return {
open: false,
caseId: "",
caseMessageIndex: -1,
messageIndex: -1,
rating: 3,
comment: "",
manualCaseDecision: DEFAULT_MANUAL_DECISION,
annotationAuthor: autoGenSettings.generatedBy,
saving: false,
error: ""
};
});
}, [autoGenSettings.generatedBy]);
const submitCommentModal = useCallback(async () => {
const targetRunId = selectedRunId;
const targetCaseId = commentModal.caseId;
const targetCaseMessageIndex = commentModal.caseMessageIndex;
if (!targetRunId || !targetCaseId || targetCaseMessageIndex < 0) return;
if (isLiveRunId(targetRunId)) {
setCommentModal((prev) => ({ ...prev, error: "Комментарий можно сохранить после завершения прогона." }));
return;
}
if (!commentModal.comment.trim()) {
setCommentModal((prev) => ({ ...prev, error: "Добавьте комментарий." }));
return;
}
setCommentModal((prev) => ({ ...prev, saving: true, error: "" }));
try {
await apiClient.saveAutoRunAnnotation({
run_id: targetRunId,
case_id: targetCaseId,
message_index: targetCaseMessageIndex,
rating: commentModal.rating,
comment: commentModal.comment.trim(),
manual_case_decision: commentModal.manualCaseDecision,
annotation_author: commentModal.annotationAuthor.trim() || undefined
});
closeCommentModal({ force: true });
void Promise.all([loadRunDetail(targetRunId, selectedCaseId), loadAnnotations(), loadPostAnalysis()]).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setErrorText(`Обновление после комментария: ${message}`);
log(`Comment refresh error: ${message}`);
});
} catch (error) {
setCommentModal((prev) => ({
...prev,
saving: false,
error: error instanceof Error ? error.message : String(error)
}));
}
}, [
closeCommentModal,
commentModal.annotationAuthor,
commentModal.caseId,
commentModal.caseMessageIndex,
commentModal.comment,
commentModal.manualCaseDecision,
commentModal.rating,
loadAnnotations,
loadPostAnalysis,
loadRunDetail,
log,
selectedCaseId,
selectedRunId
]);
const canCommentAssistantLiveMessage = useCallback((item: AssistantConversationItem): boolean => item.role === "assistant", []);
const isAssistantLiveMessageCommented = useCallback(
(item: AssistantConversationItem): boolean => item.role === "assistant" && assistantLiveAnnotationsByMessageId.has(item.message_id),
[assistantLiveAnnotationsByMessageId]
);
const openAssistantLiveCommentModal = useCallback(
(item: AssistantConversationItem, index: number) => {
if (item.role !== "assistant") {
return;
}
const sessionIdFromState = assistantLiveSessionId.trim();
const sessionIdFromItem = String(item.session_id ?? "").trim();
const resolvedSessionId = sessionIdFromState || sessionIdFromItem;
if (!resolvedSessionId) {
setAssistantLiveError("Сначала получите ответ ассистента в активной сессии.");
return;
}
if (!sessionIdFromState && sessionIdFromItem) {
setAssistantLiveSessionId(sessionIdFromItem);
}
const existing = assistantLiveAnnotationsByMessageId.get(item.message_id) ?? null;
setAssistantLiveError("");
setAssistantLiveCommentModal({
open: true,
messageIndex: index,
rating: existing?.rating ?? 3,
comment: existing?.comment ?? "",
annotationAuthor: existing?.annotation_author ?? "manual_reviewer",
saving: false,
error: ""
});
},
[assistantLiveAnnotationsByMessageId, assistantLiveSessionId]
);
const submitAssistantLiveCommentModal = useCallback(async () => {
if (assistantLiveCommentModal.messageIndex < 0) {
return;
}
if (!assistantLiveCommentModal.comment.trim()) {
setAssistantLiveCommentModal((prev) => ({ ...prev, error: "Добавьте комментарий." }));
return;
}
const modalMessage = assistantLiveConversation[assistantLiveCommentModal.messageIndex] ?? null;
const sessionId =
assistantLiveSessionId.trim() || (modalMessage?.role === "assistant" ? String(modalMessage.session_id ?? "").trim() : "");
if (!sessionId) {
setAssistantLiveCommentModal((prev) => ({ ...prev, error: "Сессия ассистента не найдена." }));
return;
}
setAssistantLiveCommentModal((prev) => ({ ...prev, saving: true, error: "" }));
try {
const payload = await apiClient.saveAssistantAnnotation({
session_id: sessionId,
message_index: assistantLiveCommentModal.messageIndex,
rating: assistantLiveCommentModal.rating,
comment: assistantLiveCommentModal.comment.trim(),
annotation_author: assistantLiveCommentModal.annotationAuthor.trim() || undefined
});
setAssistantLiveAnnotations((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));
});
closeAssistantLiveCommentModal({ force: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAssistantLiveCommentModal((prev) => ({ ...prev, saving: false, error: message }));
}
}, [
assistantLiveCommentModal.annotationAuthor,
assistantLiveCommentModal.comment,
assistantLiveCommentModal.messageIndex,
assistantLiveCommentModal.rating,
assistantLiveConversation,
assistantLiveSessionId,
closeAssistantLiveCommentModal
]);
const requestDeleteSavedSessionQuestion = useCallback(
(questionIndex: number) => {
if (!selectedAutogenGeneration || selectedAutogenGeneration.mode !== "saved_user_sessions") {
return;
}
const questionText = editableGeneratedQuestions[questionIndex] ?? "";
setSavedSessionQuestionDeleteModal({
open: true,
generationId: selectedAutogenGeneration.generation_id,
questionIndex,
questionText,
saving: false,
error: ""
});
},
[editableGeneratedQuestions, selectedAutogenGeneration]
);
const submitSavedSessionQuestionDelete = useCallback(async () => {
const generationId = savedSessionQuestionDeleteModal.generationId;
const questionIndex = savedSessionQuestionDeleteModal.questionIndex;
if (!generationId || questionIndex < 0) {
return;
}
const nextQuestions = editableGeneratedQuestions.filter((_, index) => index !== questionIndex);
if (nextQuestions.length === 0) {
setSavedSessionQuestionDeleteModal((prev) => ({
...prev,
error: "Нельзя удалить последний вопрос из сохраненной сессии."
}));
return;
}
setSavedSessionQuestionDeleteModal((prev) => ({ ...prev, saving: true, error: "" }));
try {
const payload = await apiClient.updateAutoRunAutogenQuestions({
generation_id: generationId,
questions: nextQuestions
});
setAutoGenHistory((prev) =>
prev.map((item) => (item.generation_id === generationId ? payload.generation : item))
);
setEditableGeneratedQuestions(payload.generation.questions);
closeSavedSessionQuestionDeleteModal({ force: true });
log(`Обновлена сохраненная сессия: ${generationId}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setSavedSessionQuestionDeleteModal((prev) => ({ ...prev, saving: false, error: message }));
log(`Saved session question delete error: ${message}`);
}
}, [
closeSavedSessionQuestionDeleteModal,
editableGeneratedQuestions,
log,
savedSessionQuestionDeleteModal.generationId,
savedSessionQuestionDeleteModal.questionIndex
]);
const updateGeneratedQuestions = useCallback(
async (nextQuestions: string[], options?: { successLog?: string; revertQuestions?: string[] }) => {
const generationId = selectedAutogenGeneration?.generation_id ?? "";
const revertQuestions = options?.revertQuestions ?? editableGeneratedQuestions;
setEditableGeneratedQuestions(nextQuestions);
if (!generationId) {
return true;
}
setGeneratedQuestionsBusy(true);
try {
const payload = await apiClient.updateAutoRunAutogenQuestions({
generation_id: generationId,
questions: nextQuestions
});
setAutoGenHistory((prev) =>
prev.map((item) => (item.generation_id === generationId ? payload.generation : item))
);
setEditableGeneratedQuestions([...(payload.generation.questions ?? [])]);
if (options?.successLog) {
log(options.successLog);
}
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setEditableGeneratedQuestions(revertQuestions);
setErrorText(`Вопросы к запуску: ${message}`);
log(`Autogen questions update error: ${message}`);
return false;
} finally {
setGeneratedQuestionsBusy(false);
}
},
[editableGeneratedQuestions, log, selectedAutogenGeneration]
);
const startQuestionEdit = useCallback(
(questionIndex: number) => {
setEditingQuestionIndex(questionIndex);
setEditingQuestionDraft(editableGeneratedQuestions[questionIndex] ?? "");
},
[editableGeneratedQuestions]
);
const stopQuestionEdit = useCallback(() => {
setEditingQuestionIndex(null);
setEditingQuestionDraft("");
}, []);
const commitQuestionEdit = useCallback(
async (questionIndex: number | null) => {
if (questionIndex === null) {
return;
}
const currentQuestion = editableGeneratedQuestions[questionIndex] ?? "";
const nextText = editingQuestionDraft.trim();
if (!nextText || nextText === currentQuestion) {
stopQuestionEdit();
return;
}
const nextQuestions = editableGeneratedQuestions.map((item, index) => (index === questionIndex ? nextText : item));
const saved = await updateGeneratedQuestions(nextQuestions, {
successLog: `Список вопросов обновлен: ${selectedAutogenGeneration?.generation_id ?? "local"}`,
revertQuestions: editableGeneratedQuestions
});
if (saved) {
stopQuestionEdit();
}
},
[editableGeneratedQuestions, editingQuestionDraft, selectedAutogenGeneration, stopQuestionEdit, updateGeneratedQuestions]
);
const handleQuestionEditorBlur = useCallback(() => {
void commitQuestionEdit(editingQuestionIndex);
}, [commitQuestionEdit, editingQuestionIndex]);
const handleQuestionEditorKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
void commitQuestionEdit(editingQuestionIndex);
return;
}
if (event.key === "Escape") {
event.preventDefault();
stopQuestionEdit();
}
},
[commitQuestionEdit, editingQuestionIndex, stopQuestionEdit]
);
const handleAddGeneratedQuestion = useCallback(async () => {
const nextQuestions = [...editableGeneratedQuestions, "Новый вопрос"];
const nextIndex = nextQuestions.length - 1;
const saved = await updateGeneratedQuestions(nextQuestions, {
successLog: `В список добавлен вопрос: ${selectedAutogenGeneration?.generation_id ?? "local"}`,
revertQuestions: editableGeneratedQuestions
});
if (saved) {
setEditingQuestionIndex(nextIndex);
setEditingQuestionDraft(nextQuestions[nextIndex]);
}
}, [editableGeneratedQuestions, selectedAutogenGeneration, updateGeneratedQuestions]);
const handleDeleteGeneratedQuestion = useCallback(
async (questionIndex: number) => {
if (editableGeneratedQuestions.length <= 1) {
setErrorText("В списке должен остаться хотя бы один вопрос.");
return;
}
const nextQuestions = editableGeneratedQuestions.filter((_, index) => index !== questionIndex);
const saved = await updateGeneratedQuestions(nextQuestions, {
successLog: `Из списка удален вопрос: ${selectedAutogenGeneration?.generation_id ?? "local"}`,
revertQuestions: editableGeneratedQuestions
});
if (!saved) {
return;
}
setEditingQuestionIndex((prev) => {
if (prev === null) return prev;
if (prev === questionIndex) return null;
if (prev > questionIndex) return prev - 1;
return prev;
});
setEditingQuestionDraft("");
},
[editableGeneratedQuestions, selectedAutogenGeneration, updateGeneratedQuestions]
);
const handleQuestionDragStart = useCallback(
(event: DragEvent<HTMLButtonElement>, questionIndex: number) => {
if (generatedQuestionsBusy) {
event.preventDefault();
return;
}
setDraggingQuestionIndex(questionIndex);
setDragOverQuestionIndex(questionIndex);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", String(questionIndex));
},
[generatedQuestionsBusy]
);
const handleQuestionDragOver = useCallback(
(event: DragEvent<HTMLDivElement>, questionIndex: number) => {
event.preventDefault();
if (dragOverQuestionIndex !== questionIndex) {
setDragOverQuestionIndex(questionIndex);
}
event.dataTransfer.dropEffect = "move";
},
[dragOverQuestionIndex]
);
const handleQuestionDrop = useCallback(
async (event: DragEvent<HTMLDivElement>, questionIndex: number) => {
event.preventDefault();
const fromIndex = draggingQuestionIndex;
setDragOverQuestionIndex(null);
setDraggingQuestionIndex(null);
if (fromIndex === null || fromIndex === questionIndex) {
return;
}
const nextQuestions = [...editableGeneratedQuestions];
const [movedQuestion] = nextQuestions.splice(fromIndex, 1);
nextQuestions.splice(questionIndex, 0, movedQuestion);
await updateGeneratedQuestions(nextQuestions, {
successLog: `Порядок вопросов обновлен: ${selectedAutogenGeneration?.generation_id ?? "local"}`,
revertQuestions: editableGeneratedQuestions
});
},
[draggingQuestionIndex, editableGeneratedQuestions, selectedAutogenGeneration, updateGeneratedQuestions]
);
const handleQuestionDragEnd = useCallback(() => {
setDraggingQuestionIndex(null);
setDragOverQuestionIndex(null);
}, []);
const toggleSavedSessionQuestions = useCallback((generationId: string) => {
setSelectedAutogenGenerationId(generationId);
setExpandedSavedSessionGenerationId((prev) => (prev === generationId ? "" : generationId));
}, []);
const openAutoGenDeleteModal = useCallback((item: AutoGenHistoryRecord) => {
setAutoGenDeleteModal({
open: true,
generationId: item.generation_id,
title: item.title ?? `${formatAutoGenModeLabel(item.mode)} ${formatDateTime(item.created_at)}`,
saving: false,
error: ""
});
}, []);
const submitAutoGenDeleteModal = useCallback(async () => {
const generationId = autoGenDeleteModal.generationId.trim();
if (!generationId) {
return;
}
setAutoGenDeleteModal((prev) => ({ ...prev, saving: true, error: "" }));
try {
const payload = await apiClient.deleteAutoRunAutogenHistoryRecord(generationId);
setAutoGenHistory((prev) => prev.filter((item) => item.generation_id !== payload.generation_id));
closeAutoGenDeleteModal({ force: true });
log(
`Удален набор автопрогона: ${payload.generation_id}` +
(payload.deleted_files.length > 0 ? `, files=${payload.deleted_files.length}` : "")
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAutoGenDeleteModal((prev) => ({ ...prev, saving: false, error: message }));
log(`Autogen record delete error: ${message}`);
}
}, [autoGenDeleteModal.generationId, closeAutoGenDeleteModal, log]);
const applyLocalAnnotationPatch = useCallback((annotation: AutoRunAnnotationRecord) => {
setAnnotations((prev) =>
prev.map((item) =>
item.annotation_id === annotation.annotation_id
? {
...item,
...annotation
}
: item
)
);
setDialog((prev) => {
if (!prev) return prev;
return {
...prev,
annotations: prev.annotations.map((item) => (item.annotation_id === annotation.annotation_id ? annotation : item)),
messages: prev.messages.map((item) => {
if (!item.annotation || item.annotation.annotation_id !== annotation.annotation_id) {
return item;
}
return {
...item,
commented: true,
annotation
};
})
};
});
}, []);
const toggleAnnotationResolved = useCallback(
async (annotation: AutoRunAnnotationRecord, nextResolved: boolean) => {
if (!annotation.annotation_id) return;
if (isLiveRunId(annotation.run_id)) {
setErrorText("\u0421\u0442\u0430\u0442\u0443\u0441 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f \u043c\u043e\u0436\u043d\u043e \u043c\u0435\u043d\u044f\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u0437\u0430\u0432\u0435\u0440\u0448\u0451\u043d\u043d\u044b\u0445 \u043f\u0440\u043e\u0433\u043e\u043d\u043e\u0432.");
return;
}
setAnnotationResolutionBusyId(annotation.annotation_id);
try {
const payload = await apiClient.updateAutoRunAnnotation({
annotation_id: annotation.annotation_id,
resolved: nextResolved,
resolved_by: autoGenSettings.generatedBy || undefined
});
applyLocalAnnotationPatch(payload.annotation);
void loadPostAnalysis();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorText(`\u0421\u043c\u0435\u043d\u0430 \u0441\u0442\u0430\u0442\u0443\u0441\u0430 \u043a\u0435\u0439\u0441\u0430: ${message}`);
log(`Annotation resolve toggle error: ${message}`);
} finally {
setAnnotationResolutionBusyId("");
}
},
[applyLocalAnnotationPatch, autoGenSettings.generatedBy, loadPostAnalysis, log]
);
const openAnnotationContext = useCallback(
async (annotation: AutoRunAnnotationRecord) => {
setSelectedAnnotationId(annotation.annotation_id);
await loadRunDetail(annotation.run_id, annotation.case_id);
if (!history?.items.some((item) => item.run_id === annotation.run_id)) {
setErrorText("Комментарий относится к прогону вне текущего фильтра. Детали загружены напрямую.");
}
},
[history?.items, loadRunDetail]
);
useEffect(() => {
if (initialLoadDoneRef.current) return;
initialLoadDoneRef.current = true;
void loadHistory({ keepSelection: false });
void loadAutoGenHistory();
void loadAutoGenPersonalityCatalog();
void loadPostAnalysis();
}, [loadAutoGenHistory, loadAutoGenPersonalityCatalog, loadHistory, loadPostAnalysis]);
useEffect(() => {
if (!initialLoadDoneRef.current) return;
void loadAnnotations();
}, [annotationDecisionFilter, loadAnnotations]);
useEffect(() => {
setSelectedAnnotationId((prev) => {
if (visibleAnnotations.length === 0) return "";
if (visibleAnnotations.some((item) => item.annotation_id === prev)) return prev;
return visibleAnnotations[0].annotation_id;
});
}, [visibleAnnotations]);
useEffect(() => {
setSelectedAutogenGenerationId((prev) => {
if (visibleAutoGenHistory.length === 0) return "";
if (prev && visibleAutoGenHistory.some((item) => item.generation_id === prev)) return prev;
return visibleAutoGenHistory[0].generation_id;
});
}, [visibleAutoGenHistory]);
useEffect(() => {
if (!selectedAutogenGeneration) {
setEditableGeneratedQuestions([]);
stopQuestionEdit();
setDraggingQuestionIndex(null);
setDragOverQuestionIndex(null);
return;
}
setEditableGeneratedQuestions([...selectedAutogenGeneration.questions]);
stopQuestionEdit();
setDraggingQuestionIndex(null);
setDragOverQuestionIndex(null);
}, [selectedAutogenGeneration, stopQuestionEdit]);
useEffect(() => {
if (editingQuestionIndex === null) {
return;
}
const timer = window.setTimeout(() => {
questionEditorRef.current?.focus();
questionEditorRef.current?.select();
}, 0);
return () => window.clearTimeout(timer);
}, [editingQuestionIndex]);
useEffect(() => {
if (!isSavedUserSessionsMode) {
setExpandedSavedSessionGenerationId("");
return;
}
if (
expandedSavedSessionGenerationId &&
!visibleAutoGenHistory.some((item) => item.generation_id === expandedSavedSessionGenerationId)
) {
setExpandedSavedSessionGenerationId("");
}
}, [expandedSavedSessionGenerationId, isSavedUserSessionsMode, visibleAutoGenHistory]);
useEffect(() => {
setLimitInput(String(filters.limit));
}, [filters.limit]);
useEffect(() => {
setAutogenCountInput(String(autoGenSettings.count));
}, [autoGenSettings.count]);
useEffect(() => {
if (!assistantLiveSessionId.trim()) {
setAssistantLiveAnnotations([]);
return;
}
void loadAssistantLiveAnnotationsForSession(assistantLiveSessionId);
}, [assistantLiveSessionId, loadAssistantLiveAnnotationsForSession]);
useEffect(() => {
if (!activeAsyncJob) return;
const liveRunId = toLiveRunId(activeAsyncJob.job_id);
if (selectedRunId !== liveRunId) return;
const live = buildLiveRunDetail(activeAsyncJob, selectedCaseId || ALL_CASES_ID);
setRunDetail(live.detail);
setDialog(live.dialog);
setSelectedCaseId(live.caseId);
}, [activeAsyncJob, selectedCaseId, selectedRunId]);
useEffect(() => {
return () => {
stopAsyncJobPolling();
};
}, [stopAsyncJobPolling]);
useEffect(() => {
if (autogenPersonalities.length === 0) return;
setAutoGenSettings((prev) => {
let changed = false;
const nextPrompts: Record<string, string> = { ...prev.personalityPrompts };
for (const item of autogenPersonalities) {
if (typeof nextPrompts[item.id] !== "string" || nextPrompts[item.id].trim().length === 0) {
nextPrompts[item.id] = item.defaultPrompt;
changed = true;
}
}
let nextPersonalityId = prev.personalityId;
if (!autogenPersonalities.some((item) => item.id === prev.personalityId)) {
nextPersonalityId = autogenPersonalities[0].id;
changed = true;
}
if (!changed) return prev;
return {
...prev,
personalityId: nextPersonalityId,
personalityPrompts: nextPrompts
};
});
}, [autogenPersonalities]);
useEffect(() => {
const raw = localStorage.getItem(AUTORUNS_UI_CONFIG_KEY);
if (!raw) return;
try {
const parsed = JSON.parse(raw) as AutoRunsUiConfig;
if (parsed.filters) {
const savedFilters = parsed.filters;
setFilters((prev) => ({
...prev,
...savedFilters,
limit: typeof savedFilters.limit === "number" ? Math.max(1, Math.min(500, savedFilters.limit)) : prev.limit
}));
}
if (typeof parsed.analysisDate === "string") {
setAnalysisDate(normalizeAnalysisDateInput(parsed.analysisDate));
}
if (typeof parsed.autogenPersonalityPromptHeight === "number") {
setAutogenPersonalityPromptHeight(clampAutogenPromptHeight(parsed.autogenPersonalityPromptHeight));
}
if (parsed.groupsExpanded) {
if (typeof parsed.groupsExpanded.filters === "boolean") {
setFiltersGroupExpanded(parsed.groupsExpanded.filters);
}
if (typeof parsed.groupsExpanded.generationContext === "boolean") {
setGenerationContextGroupExpanded(parsed.groupsExpanded.generationContext);
}
if (typeof parsed.groupsExpanded.autogen === "boolean") {
setAutogenGroupExpanded(parsed.groupsExpanded.autogen);
}
if (typeof parsed.groupsExpanded.savedSessions === "boolean") {
setSavedSessionsGroupExpanded(parsed.groupsExpanded.savedSessions);
}
}
if (parsed.autoGenSettings) {
setAutoGenSettings((prev) => {
const nextPrompts: Record<string, string> = {
...prev.personalityPrompts
};
const incomingPrompts = parsed.autoGenSettings?.personalityPrompts ?? {};
for (const [key, value] of Object.entries(incomingPrompts)) {
if (typeof value === "string" && key.trim().length > 0) {
nextPrompts[key.trim()] = value;
}
}
const nextPersonalityId =
typeof parsed.autoGenSettings?.personalityId === "string" && parsed.autoGenSettings.personalityId.trim().length > 0
? parsed.autoGenSettings.personalityId.trim()
: prev.personalityId;
return {
...prev,
mode:
parsed.autoGenSettings?.mode === "codex_creative" ||
parsed.autoGenSettings?.mode === "qwen_seed" ||
parsed.autoGenSettings?.mode === "saved_user_sessions"
? parsed.autoGenSettings.mode
: prev.mode,
count:
typeof parsed.autoGenSettings?.count === "number"
? Math.max(1, Math.min(200, parsed.autoGenSettings.count))
: prev.count,
personalityId: nextPersonalityId,
personalityPrompts: nextPrompts,
persistToEvalCases:
typeof parsed.autoGenSettings?.persistToEvalCases === "boolean"
? parsed.autoGenSettings.persistToEvalCases
: prev.persistToEvalCases,
generatedBy:
typeof parsed.autoGenSettings?.generatedBy === "string"
? parsed.autoGenSettings.generatedBy
: prev.generatedBy
};
});
}
if (
parsed.annotationDecisionFilter === "all" ||
(typeof parsed.annotationDecisionFilter === "string" && parsed.annotationDecisionFilter.length > 0)
) {
setAnnotationDecisionFilter(parsed.annotationDecisionFilter as ManualCaseDecision | "all");
}
if (typeof parsed.hideResolvedAnnotations === "boolean") {
setHideResolvedAnnotations(parsed.hideResolvedAnnotations);
}
} catch {
// ignore broken local cache
}
}, []);
const saveUiConfig = useCallback(() => {
const payload: AutoRunsUiConfig = {
filters,
analysisDate,
autogenPersonalityPromptHeight,
groupsExpanded: {
filters: filtersGroupExpanded,
generationContext: generationContextGroupExpanded,
autogen: autogenGroupExpanded,
savedSessions: savedSessionsGroupExpanded
},
autoGenSettings: {
mode: autoGenSettings.mode,
count: autoGenSettings.count,
personalityId: autoGenSettings.personalityId,
personalityPrompts: autoGenSettings.personalityPrompts,
persistToEvalCases: autoGenSettings.persistToEvalCases,
generatedBy: autoGenSettings.generatedBy
},
annotationDecisionFilter,
hideResolvedAnnotations
};
localStorage.setItem(AUTORUNS_UI_CONFIG_KEY, JSON.stringify(payload));
}, [
analysisDate,
annotationDecisionFilter,
autoGenSettings,
autogenGroupExpanded,
autogenPersonalityPromptHeight,
filters,
filtersGroupExpanded,
generationContextGroupExpanded,
hideResolvedAnnotations,
savedSessionsGroupExpanded
]);
useEffect(() => {
const onSave = () => {
saveUiConfig();
log("Сохранены настройки панели автопрогонов.");
};
window.addEventListener(AUTORUNS_SAVE_EVENT, onSave as EventListener);
return () => {
window.removeEventListener(AUTORUNS_SAVE_EVENT, onSave as EventListener);
};
}, [log, saveUiConfig]);
return (
<PanelFrame
className="autoruns-frame"
title=""
hideHeader
>
<div className="autoruns-columns">
{showSettingsMode ? (
<section className="autoruns-col autoruns-settings-col">
<div className="autoruns-col-header">
<h3>Настройки</h3>
</div>
<div className="autoruns-settings-stack">
<ConnectionPanel
embedded
value={connection}
modelOptions={modelOptions}
modelsBusy={modelsBusy}
onChange={onConnectionChange}
onReloadModels={onReloadModels}
onSaveLocalConfig={onSaveLocalConfig}
onTestConnection={onTestConnection}
lastStatus={connectionStatus}
busy={connectionBusy}
/>
<PromptPanel
embedded
value={prompts}
onChange={onPromptsChange}
presets={promptPresets}
selectedPresetId={selectedPresetId}
onSelectPreset={onSelectPreset}
onLoadPreset={onLoadPreset}
onSavePreset={onSavePreset}
onResetDefaults={onResetDefaults}
onDiffPrevious={onDiffPrevious}
presetName={presetName}
onPresetNameChange={onPresetNameChange}
diffSummary={diffSummary}
/>
</div>
</section>
) : null}
{showAutoRunsMode ? (
<section className="autoruns-col">
<div className="autoruns-col-header">
<h3>Автопрогоны</h3>
</div>
<div className="autoruns-group-heading">
<h4>Настройки выборки</h4>
<button
type="button"
className="autoruns-group-toggle"
onClick={() => setFiltersGroupExpanded((prev) => !prev)}
aria-label={filtersGroupExpanded ? "Скрыть группу настройки выборки" : "Показать группу настройки выборки"}
title={filtersGroupExpanded ? "Скрыть группу" : "Показать группу"}
>
<GroupChevronIcon expanded={filtersGroupExpanded} />
</button>
</div>
{filtersGroupExpanded ? (
<>
<div className="autoruns-form-grid">
<label>
Дата с
<input
type="datetime-local"
value={filters.fromLocal}
onChange={(event) => setFilters((prev) => ({ ...prev, fromLocal: event.target.value }))}
/>
</label>
<label>
Дата по
<input
type="datetime-local"
value={filters.toLocal}
onChange={(event) => setFilters((prev) => ({ ...prev, toLocal: event.target.value }))}
/>
</label>
<label>
Целевой контур
<select value={filters.target} onChange={(event) => setFilters((prev) => ({ ...prev, target: event.target.value }))}>
<option value="all">все</option>
{(history?.available.targets ?? []).map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
</label>
<label>
Режим
<select value={filters.mode} onChange={(event) => setFilters((prev) => ({ ...prev, mode: event.target.value }))}>
<option value="all">все</option>
{(history?.available.modes ?? []).map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
</label>
<label>
Использовать mock
<select
value={filters.useMock}
onChange={(event) => setFilters((prev) => ({ ...prev, useMock: event.target.value as UseMockFilter }))}
>
<option value="any">любой</option>
<option value="true">да</option>
<option value="false">нет</option>
</select>
</label>
<label>
Лимит
<input
type="number"
min={1}
max={500}
value={limitInput}
onChange={(event) => {
const raw = event.target.value;
if (raw === "" || /^\d+$/.test(raw)) {
setLimitInput(raw);
}
}}
onBlur={(event) => commitLimitInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
commitLimitInput((event.target as HTMLInputElement).value);
}
}}
/>
</label>
<label className="full-width">
Версия промпта содержит
<input
value={filters.promptContains}
onChange={(event) => setFilters((prev) => ({ ...prev, promptContains: event.target.value }))}
placeholder="normalizer_v2_0_2 / address_query_runtime_v1"
list="autoruns-prompt-versions"
/>
</label>
</div>
<datalist id="autoruns-prompt-versions">
{(history?.available.prompt_versions ?? []).map((item) => (
<option key={item} value={item} />
))}
</datalist>
<div className="button-row">
<button type="button" disabled={historyBusy} onClick={() => void loadHistory({ keepSelection: false })}>
{historyBusy ? "Обновляю..." : "Применить"}
</button>
<button
type="button"
className="tab"
onClick={() => {
setFilters({
...DEFAULT_FILTERS,
fromLocal: defaultFromDateValue()
});
setErrorText("");
}}
>
Сбросить фильтры
</button>
</div>
</>
) : null}
<div className="autoruns-group-heading">
<h4>Контур генерации</h4>
<button
type="button"
className="autoruns-group-toggle"
onClick={() => setGenerationContextGroupExpanded((prev) => !prev)}
aria-label={generationContextGroupExpanded ? "Скрыть группу контура генерации" : "Показать группу контура генерации"}
title={generationContextGroupExpanded ? "Скрыть группу" : "Показать группу"}
>
<GroupChevronIcon expanded={generationContextGroupExpanded} />
</button>
</div>
{generationContextGroupExpanded ? (
<div className="autoruns-meta-list">
<div>
<span>Провайдер:</span>
<strong>{connection.llmProvider}</strong>
</div>
<div>
<span>Модель:</span>
<strong>{connection.model || "нет данных"}</strong>
</div>
<div>
<span>Промпт ассистента:</span>
<strong>{assistantPromptVersion}</strong>
</div>
<div>
<span>Промпт декомпозиции:</span>
<strong>{decompositionPromptVersion}</strong>
</div>
</div>
) : null}
<div className="autoruns-group-heading">
<h4>Автопрогоны</h4>
<button
type="button"
className="autoruns-group-toggle"
onClick={() => setAutogenGroupExpanded((prev) => !prev)}
aria-label={autogenGroupExpanded ? "Скрыть группу автопрогонов" : "Показать группу автопрогонов"}
title={autogenGroupExpanded ? "Скрыть группу" : "Показать группу"}
>
<GroupChevronIcon expanded={autogenGroupExpanded} />
</button>
</div>
{autogenGroupExpanded ? (
<>
<div className="autoruns-form-grid">
<label>
Режимы
<select
value={autoGenSettings.mode}
onChange={(event) => setAutoGenSettings((prev) => ({ ...prev, mode: event.target.value as AutoGenMode }))}
>
<option value="codex_creative">codex_creative</option>
<option value="qwen_seed">qwen_seed</option>
<option value="saved_user_sessions">Пользовательские сессии</option>
</select>
</label>
{!isSavedUserSessionsMode ? (
<>
<label>
Кол-во
<input
type="number"
min={1}
max={200}
value={autogenCountInput}
onChange={(event) => {
const raw = event.target.value;
if (raw === "" || /^\d+$/.test(raw)) {
setAutogenCountInput(raw);
}
}}
onBlur={(event) => commitAutogenCountInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
commitAutogenCountInput((event.target as HTMLInputElement).value);
}
}}
/>
</label>
<label>
Личность автогенерации
<select
value={autoGenSettings.personalityId}
onChange={(event) =>
setAutoGenSettings((prev) => ({
...prev,
personalityId: event.target.value as AutoGenPersonalityId
}))
}
>
{autogenPersonalities.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
</label>
<label>
Кто генерирует
<input
value={autoGenSettings.generatedBy}
onChange={(event) => setAutoGenSettings((prev) => ({ ...prev, generatedBy: event.target.value }))}
placeholder="manual_reviewer"
/>
</label>
<label className="full-width">
Промпт личности
<textarea
className="autoruns-personality-prompt"
value={autoGenSettings.personalityPrompts[autoGenSettings.personalityId] ?? ""}
onChange={(event) =>
setAutoGenSettings((prev) => ({
...prev,
personalityPrompts: {
...prev.personalityPrompts,
[prev.personalityId]: event.target.value
}
}))
}
placeholder="Текст промпта для выбранной личности автогенерации"
style={{ height: `${autogenPersonalityPromptHeight}px` }}
onMouseUp={captureAutogenPromptHeight}
onTouchEnd={captureAutogenPromptHeight}
/>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={autoGenSettings.persistToEvalCases}
onChange={(event) => setAutoGenSettings((prev) => ({ ...prev, persistToEvalCases: event.target.checked }))}
/>
Сохранять кейс-сет в `eval_cases`
</label>
</>
) : null}
</div>
{!isSavedUserSessionsMode ? (
<div className="autoruns-form-grid">
<label>
Дата анализа (срез)
<input
type="date"
value={analysisDate}
onChange={(event) => setAnalysisDate(normalizeAnalysisDateInput(event.target.value))}
/>
</label>
<div className="button-row">
<button type="button" className="tab" disabled={!analysisDate} onClick={() => setAnalysisDate("")}>
Сбросить дату среза
</button>
</div>
</div>
) : null}
<div className="button-row">
{!isSavedUserSessionsMode ? (
<>
<button type="button" disabled={autoGenBusy} onClick={() => void generateAutogenBatch()}>
{autoGenBusy ? "Генерирую..." : "Сгенерировать пачку"}
</button>
<button type="button" className="tab" disabled={autogenHistoryBusy} onClick={() => void loadAutoGenHistory()}>
{autogenHistoryBusy ? "Обновляю..." : "Обновить историю"}
</button>
</>
) : null}
<button
type="button"
className="autoruns-run-launch-btn"
style={isSavedUserSessionsMode ? { display: "none" } : undefined}
disabled={
autogenStopBusy ||
(!autogenRunBusy && (editableGeneratedQuestions.length === 0 || !selectedAutogenGeneration))
}
onClick={() => void (autogenRunBusy ? stopAutogenCampaign() : runAutogenCampaign())}
>
{autogenRunBusy ? (autogenStopBusy ? "Останавливаю..." : "Остановить прогон") : "Запустить прогон"}
</button>
</div>
<div className="autoruns-form-grid">
<label className="full-width">
{isSavedUserSessionsMode ? "Сохраненная сессия" : "Кейс-сет для запуска"}
<select
value={selectedAutogenGenerationId}
onChange={(event) => setSelectedAutogenGenerationId(event.target.value)}
disabled={visibleAutoGenHistory.length === 0}
>
{visibleAutoGenHistory.length === 0 ? (
<option value="">
{isSavedUserSessionsMode ? "нет сохраненных сессий" : "нет генераций"}
</option>
) : null}
{visibleAutoGenHistory.map((item) => (
<option key={item.generation_id} value={item.generation_id}>
{formatDateTime(item.created_at)} | {formatAutoGenGenerationTitle(item) ?? formatAutoGenModeLabel(item.mode)} | {item.count}
</option>
))}
</select>
</label>
</div>
{false ? (
<>
{/* generated questions editor */}
<div className="autoruns-generated-questions">
<div className="autoruns-generated-questions-head">
<strong>Вопросы к запуску: {editableGeneratedQuestions.length}</strong>
<button
type="button"
className="tab"
onClick={() => setEditableGeneratedQuestions([...(selectedAutogenGeneration?.questions ?? [])])}
disabled={!selectedAutogenGeneration}
>
Восстановить
</button>
</div>
{editableGeneratedQuestions.length === 0 ? (
<p className="muted">
{isSavedUserSessionsMode
? "Список вопросов пуст. Сначала сохраните живую пользовательскую сессию."
: "Список вопросов пуст. Сгенерируйте пачку или восстановите из выбранной генерации."}
</p>
) : (
<div className="autoruns-generated-questions-list">
{editableGeneratedQuestions.map((question, index) => (
<div key={`${index}-${question.slice(0, 24)}`} className="autoruns-generated-question-item">
<span>{index + 1}. {question}</span>
<button
type="button"
className="autoruns-remove-question-btn"
onClick={() => {
if (isSavedUserSessionsMode) {
requestDeleteSavedSessionQuestion(index);
return;
}
setEditableGeneratedQuestions((prev) => prev.filter((_, itemIndex) => itemIndex !== index));
}}
title="Удалить вопрос из запуска"
aria-label="Удалить вопрос из запуска"
>
×
</button>
</div>
))}
</div>
)}
</div>
<p className="muted">
{isSavedUserSessionsMode
? "Запуск воспроизводит сохраненную пользовательскую сессию как один последовательный multi-turn сценарий assistant_stage1."
: "Запуск выполняет `assistant_stage1` eval по выбранному кейс-сету."}
</p>
</>
) : (
<>
<div className="autoruns-generated-questions" style={isSavedUserSessionsMode ? { display: "none" } : undefined}>
<div className="autoruns-generated-questions-head">
<strong>Вопросы к запуску: {editableGeneratedQuestions.length}</strong>
</div>
{editableGeneratedQuestions.length === 0 ? (
<p className="muted">
{isSavedUserSessionsMode
? "Список вопросов пуст. Сначала сохраните живую пользовательскую сессию."
: "Список вопросов пуст. Сгенерируйте пачку или добавьте вопрос вручную."}
</p>
) : (
<div className="autoruns-generated-questions-list">
{editableGeneratedQuestions.map((question, index) => (
<div
key={`${index}-${question.slice(0, 24)}`}
className={[
"autoruns-generated-question-item",
dragOverQuestionIndex === index ? "drag-over" : "",
draggingQuestionIndex === index ? "dragging" : "",
editingQuestionIndex === index ? "editing" : ""
].filter(Boolean).join(" ")}
onDragOver={(event) => handleQuestionDragOver(event, index)}
onDrop={(event) => void handleQuestionDrop(event, index)}
>
<button
type="button"
className="autoruns-question-grip-btn"
draggable={!generatedQuestionsBusy && editingQuestionIndex !== index}
disabled={generatedQuestionsBusy || editingQuestionIndex === index}
onDragStart={(event) => handleQuestionDragStart(event, index)}
onDragEnd={handleQuestionDragEnd}
title="Перетащить вопрос"
aria-label={`Перетащить вопрос ${index + 1}`}
>
<QuestionGripIcon />
</button>
{editingQuestionIndex === index ? (
<>
<input
ref={questionEditorRef}
className="autoruns-generated-question-input"
value={editingQuestionDraft}
onChange={(event) => setEditingQuestionDraft(event.target.value)}
onBlur={handleQuestionEditorBlur}
onKeyDown={handleQuestionEditorKeyDown}
placeholder="Текст вопроса"
disabled={generatedQuestionsBusy}
/>
<button
type="button"
className="autoruns-remove-question-btn"
onMouseDown={(event) => event.preventDefault()}
onClick={() => void handleDeleteGeneratedQuestion(index)}
title="Удалить вопрос"
aria-label={`Удалить вопрос ${index + 1}`}
disabled={generatedQuestionsBusy}
>
×
</button>
</>
) : (
<button
type="button"
className="autoruns-generated-question-text"
onDoubleClick={() => startQuestionEdit(index)}
title="Двойной клик для редактирования"
>
{index + 1}. {question}
</button>
)}
</div>
))}
</div>
)}
<button
type="button"
className="autoruns-add-question-btn"
onClick={() => void handleAddGeneratedQuestion()}
disabled={!selectedAutogenGeneration || generatedQuestionsBusy}
>
+
</button>
</div>
{!isSavedUserSessionsMode ? (
<p className="muted">Запуск выполняет `assistant_stage1` eval по выбранному кейс-сету.</p>
) : null}
</>
)}
</>
) : null}
<div className="autoruns-group-heading">
<h4>{isSavedUserSessionsMode ? "Сохраненные пользовательские сессии" : "История автогенераций"}</h4>
<button
type="button"
className="autoruns-group-toggle"
onClick={() => setSavedSessionsGroupExpanded((prev) => !prev)}
aria-label={
savedSessionsGroupExpanded
? isSavedUserSessionsMode
? "Скрыть группу сохраненных пользовательских сессий"
: "Скрыть группу истории автогенераций"
: isSavedUserSessionsMode
? "Показать группу сохраненных пользовательских сессий"
: "Показать группу истории автогенераций"
}
title={savedSessionsGroupExpanded ? "Скрыть группу" : "Показать группу"}
>
<GroupChevronIcon expanded={savedSessionsGroupExpanded} />
</button>
</div>
{savedSessionsGroupExpanded ? (
<div className="autoruns-autogen-list">
{autogenHistoryBusy ? (
<p className="muted">
{isSavedUserSessionsMode ? "Загружаю сохраненные пользовательские сессии..." : "Загружаю историю автогенераций..."}
</p>
) : null}
{!autogenHistoryBusy && visibleAutoGenHistory.length === 0 ? (
<p className="muted">
{isSavedUserSessionsMode ? "Сохраненные пользовательские сессии пока пусты." : "История автогенераций пока пустая."}
</p>
) : null}
{visibleAutoGenHistory.slice(0, 30).map((item) => {
const isRunningThisGeneration = autogenRunBusy && runningAutogenGenerationId === item.generation_id;
const isAnotherGenerationRunning = autogenRunBusy && runningAutogenGenerationId !== item.generation_id;
return (
<article
key={item.generation_id}
className={[
"autoruns-autogen-item",
selectedAutogenGenerationId === item.generation_id ? "selected" : "",
expandedSavedSessionGenerationId === item.generation_id ? "expanded" : "",
isSavedUserSessionsMode ? "saved-session" : ""
].filter(Boolean).join(" ")}
onClick={isSavedUserSessionsMode ? undefined : () => setSelectedAutogenGenerationId(item.generation_id)}
>
{isSavedUserSessionsMode ? (
<div className="autoruns-saved-session-topbar">
<button
type="button"
className="autoruns-saved-session-icon-btn"
disabled={autogenStopBusy || isAnotherGenerationRunning}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (isRunningThisGeneration) {
void stopAutogenCampaign();
return;
}
setSelectedAutogenGenerationId(item.generation_id);
void runAutogenCampaign(
item,
selectedAutogenGenerationId === item.generation_id ? editableGeneratedQuestions : item.questions
);
}}
title={isRunningThisGeneration ? "Остановить прогон" : "Запустить прогон"}
aria-label={`${isRunningThisGeneration ? "Остановить прогон" : "Запустить прогон"} для ${formatAutoGenGenerationTitle(item)}`}
>
{isRunningThisGeneration ? <CardStopIcon /> : <CardLaunchIcon />}
</button>
<button
type="button"
className="autoruns-autogen-delete-btn"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openAutoGenDeleteModal(item);
}}
title="Удалить сохраненный набор"
aria-label={`Удалить набор ${item.generation_id}`}
>
×
</button>
</div>
) : null}
<header>
<strong>{formatAutoGenGenerationTitle(item)}</strong>
<div className="autoruns-autogen-card-actions">
<span>{formatDateTime(item.created_at)}</span>
<button
type="button"
className="autoruns-autogen-delete-btn"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openAutoGenDeleteModal(item);
}}
title="Удалить сохраненный набор"
aria-label={`Удалить набор ${item.generation_id}`}
>
×
</button>
</div>
</header>
<div className="autoruns-run-meta autoruns-run-id-row">
<span>{item.generation_id}</span>
<span
role="button"
tabIndex={0}
className="autoruns-copy-run-id-btn"
onClick={(event) => void copyIdentifierToClipboard(event, item.generation_id, "set id")}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
void copyIdentifierToClipboard(event, item.generation_id, "set id");
}
}}
title="Скопировать id набора"
aria-label={`Скопировать id набора ${item.generation_id}`}
>
<CopyOutlineIcon />
</span>
</div>
<div className="autoruns-run-meta">
режим={formatAutoGenModeLabel(item.mode)}
</div>
<div className="autoruns-run-meta">
тип={isAgentSemanticGeneration(item) ? "АГЕНТНЫЙ ПРОГОН" : "АВТОПРОГОН"}
</div>
{isSavedUserSessionsMode ? (
<>
<div className="autoruns-saved-session-footer">
<button
type="button"
className="autoruns-saved-session-icon-btn"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
toggleSavedSessionQuestions(item.generation_id);
}}
title={
expandedSavedSessionGenerationId === item.generation_id ? "Скрыть вопросы" : "Показать вопросы"
}
aria-label={
expandedSavedSessionGenerationId === item.generation_id ? "Скрыть вопросы" : "Показать вопросы"
}
>
<CardChevronIcon expanded={expandedSavedSessionGenerationId === item.generation_id} />
</button>
</div>
<div
className={
expandedSavedSessionGenerationId === item.generation_id
? "autoruns-saved-session-questions expanded"
: "autoruns-saved-session-questions"
}
>
<div className="autoruns-generated-questions autoruns-generated-questions-embedded">
<div className="autoruns-generated-questions-head">
<strong>
Вопросы к запуску:{" "}
{selectedAutogenGenerationId === item.generation_id ? editableGeneratedQuestions.length : item.questions.length}
</strong>
</div>
{(selectedAutogenGenerationId === item.generation_id ? editableGeneratedQuestions : item.questions).length === 0 ? (
<p className="muted">Список вопросов пуст.</p>
) : (
<div className="autoruns-generated-questions-list">
{(selectedAutogenGenerationId === item.generation_id ? editableGeneratedQuestions : item.questions).map(
(question, index) => (
<div
key={`${item.generation_id}-${index}-${question.slice(0, 24)}`}
className={[
"autoruns-generated-question-item",
dragOverQuestionIndex === index && selectedAutogenGenerationId === item.generation_id ? "drag-over" : "",
draggingQuestionIndex === index && selectedAutogenGenerationId === item.generation_id ? "dragging" : "",
editingQuestionIndex === index && selectedAutogenGenerationId === item.generation_id ? "editing" : ""
].filter(Boolean).join(" ")}
onDragOver={(event) =>
selectedAutogenGenerationId === item.generation_id ? handleQuestionDragOver(event, index) : undefined
}
onDrop={(event) =>
selectedAutogenGenerationId === item.generation_id ? void handleQuestionDrop(event, index) : undefined
}
>
<button
type="button"
className="autoruns-question-grip-btn"
draggable={
selectedAutogenGenerationId === item.generation_id &&
!generatedQuestionsBusy &&
editingQuestionIndex !== index
}
disabled={
selectedAutogenGenerationId !== item.generation_id ||
generatedQuestionsBusy ||
editingQuestionIndex === index
}
onDragStart={(event) => {
setSelectedAutogenGenerationId(item.generation_id);
handleQuestionDragStart(event, index);
}}
onDragEnd={handleQuestionDragEnd}
title="Перетащить вопрос"
aria-label={`Перетащить вопрос ${index + 1}`}
>
<QuestionGripIcon />
</button>
{selectedAutogenGenerationId === item.generation_id && editingQuestionIndex === index ? (
<>
<input
ref={questionEditorRef}
className="autoruns-generated-question-input"
value={editingQuestionDraft}
onChange={(event) => setEditingQuestionDraft(event.target.value)}
onBlur={handleQuestionEditorBlur}
onKeyDown={handleQuestionEditorKeyDown}
placeholder="Текст вопроса"
disabled={generatedQuestionsBusy}
/>
<button
type="button"
className="autoruns-remove-question-btn"
onMouseDown={(event) => event.preventDefault()}
onClick={() => void handleDeleteGeneratedQuestion(index)}
title="Удалить вопрос"
aria-label={`Удалить вопрос ${index + 1}`}
disabled={generatedQuestionsBusy}
>
×
</button>
</>
) : (
<button
type="button"
className="autoruns-generated-question-text"
onDoubleClick={() => {
setSelectedAutogenGenerationId(item.generation_id);
startQuestionEdit(index);
}}
title="Двойной клик для редактирования"
>
{index + 1}. {question}
</button>
)}
</div>
)
)}
</div>
)}
<button
type="button"
className="autoruns-add-question-btn"
onClick={() => {
setSelectedAutogenGenerationId(item.generation_id);
void handleAddGeneratedQuestion();
}}
disabled={selectedAutogenGenerationId !== item.generation_id || generatedQuestionsBusy}
>
+
</button>
</div>
</div>
</>
) : null}
</article>
);
})}
</div>
) : null}
<details className="autoruns-prompt-details">
<summary>Копия активного промпта (только чтение)</summary>
<label>
Системный
<textarea readOnly value={prompts.systemPrompt} />
</label>
<label>
Разработчика
<textarea readOnly value={prompts.developerPrompt} />
</label>
<label>
Доменный
<textarea readOnly value={prompts.domainPrompt} />
</label>
<label>
Заметки по схеме
<textarea readOnly value={prompts.schemaNotes} />
</label>
<label>
Примеры few-shot
<textarea readOnly value={prompts.fewShotExamples} />
</label>
</details>
{errorText ? <p className="error-text">{errorText}</p> : null}
</section>
) : null}
<section className="autoruns-col">
<div className="autoruns-col-header">
<h3>Выдача прогонов</h3>
</div>
<div className="autoruns-stats-grid">
<div>
<span>Всего</span>
<strong>{(history?.stats.runs_total ?? 0) + (activeAsyncJob ? 1 : 0)}</strong>
</div>
<div>
<span>Средний score</span>
<strong>{formatScore(history?.stats.avg_score_index ?? null)}</strong>
</div>
<div>
<span>Тренд</span>
<strong>{history ? trendLabel(history.stats.trend) : "нет данных"}</strong>
</div>
<div>
<span>Блокеры</span>
<strong>{history?.stats.blocking_runs ?? 0}</strong>
</div>
</div>
<div className="autoruns-run-list">
{runSelectItems.map((run) => (
<button
key={run.run_id}
type="button"
className={selectedRunId === run.run_id ? "autoruns-run-item selected" : "autoruns-run-item"}
onClick={() => void loadRunDetail(run.run_id)}
>
<div className="autoruns-run-head">
<strong>{formatDateTime(run.run_timestamp)}</strong>
<span>{formatShortTarget(run.eval_target)}</span>
</div>
<div className="autoruns-run-meta autoruns-run-id-row">
<span>{run.run_id}</span>
<span
role="button"
tabIndex={0}
className="autoruns-copy-run-id-btn"
onClick={(event) => void copyIdentifierToClipboard(event, run.run_id, "run id")}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
void copyIdentifierToClipboard(event, run.run_id, "run id");
}
}}
title="Скопировать run id"
aria-label={`Скопировать run id ${run.run_id}`}
>
<CopyOutlineIcon />
</span>
</div>
<div className="autoruns-run-meta">
режим={run.mode ?? "нет данных"} | mock={String(run.use_mock)}
</div>
<div className="autoruns-run-meta">analysis_date={run.analysis_date ?? "current_state"}</div>
{run.llm_provider || run.model ? (
<div className="autoruns-run-meta">
llm={run.llm_provider ?? "нет данных"} | модель={run.model ?? "нет данных"}
</div>
) : null}
<div className="autoruns-run-meta">промпт={run.prompt_version ?? "нет данных"}</div>
<div className="autoruns-run-foot">
<span>оценка: {formatScore(run.score_index)}</span>
<span>
закрыто/открыто: {run.closed_cases}/{run.open_cases}
</span>
</div>
<div className="autoruns-run-foot">
<span>блокеры: {run.blocking_failures}</span>
<span>качество: {run.quality_failures}</span>
</div>
</button>
))}
{runSelectItems.length === 0 ? <p className="muted">За выбранный диапазон прогонов нет.</p> : null}
</div>
</section>
<section className="autoruns-col">
<div className="autoruns-col-header">
<h3>Диалог прогона</h3>
<div className="autoruns-dialog-toolbar">
<label>
Прогон
<select
value={selectedRunId}
onChange={(event) => {
const nextRunId = event.target.value;
void loadRunDetail(nextRunId);
}}
>
{runSelectItems.map((item) => (
<option key={item.run_id} value={item.run_id}>
{formatDateTime(item.run_timestamp)} | {item.run_id}
</option>
))}
</select>
</label>
<label>
Кейс
<select
value={selectedCaseId}
onChange={(event) => {
const nextCaseId = event.target.value;
setSelectedCaseId(nextCaseId);
if (selectedRunId && nextCaseId) {
void loadCaseDialog(selectedRunId, nextCaseId);
}
}}
>
{(runDetail?.cases.length ?? 0) > 0 ? <option value={ALL_CASES_ID}>ВСЕ кейсы подряд</option> : null}
{(runDetail?.cases ?? []).map((item) => (
<option key={item.case_id} value={item.case_id}>
{item.case_id} | {item.status}
</option>
))}
</select>
</label>
</div>
</div>
<div className="autoruns-case-list">
{(runDetail?.cases.length ?? 0) > 0 ? (
<button
key={ALL_CASES_ID}
type="button"
className={selectedCaseId === ALL_CASES_ID ? "autoruns-case-item selected" : "autoruns-case-item"}
onClick={() => {
setSelectedCaseId(ALL_CASES_ID);
if (selectedRunId) {
void loadCaseDialog(selectedRunId, ALL_CASES_ID);
}
}}
>
<span>ВСЕ кейсы подряд</span>
<span>{runDetail?.cases.length}</span>
</button>
) : null}
{(runDetail?.cases ?? []).map((item) => (
<button
key={item.case_id}
type="button"
className={selectedCaseId === item.case_id ? "autoruns-case-item selected" : "autoruns-case-item"}
onClick={() => {
setSelectedCaseId(item.case_id);
if (selectedRunId) {
void loadCaseDialog(selectedRunId, item.case_id);
}
}}
>
<span>{item.case_id}</span>
<span>
{item.status}
{item.commented_count > 0 ? ` | комм=${item.commented_count}` : ""}
</span>
</button>
))}
</div>
<div className="autoruns-dialog-view">
{dialogBusy || detailBusy ? <p className="muted">Загружаю диалог...</p> : null}
{!dialogBusy && !detailBusy && (dialog?.messages.length ?? 0) === 0 ? (
<p className="muted">Диалог для этого прогона не найден.</p>
) : null}
{(dialog?.messages ?? []).map((item, index) => {
const role = item.role === "assistant" ? "assistant" : "user";
return (
<article key={item.message_id ?? `${role}-${index}`} className={`autoruns-msg ${role}`}>
<header>
<strong>{role === "assistant" ? "Система" : "Модель/вопрос"}</strong>
<div className="autoruns-msg-head-actions">
{item.case_id ? <span className="autoruns-msg-case-tag">{item.case_id}</span> : null}
{formatDialogStepTag(item) ? (
<span className="autoruns-msg-case-tag">{formatDialogStepTag(item)}</span>
) : null}
<span>{item.created_at ? formatDateTime(item.created_at) : "нет данных"}</span>
{role === "assistant" && !isLiveRunId(selectedRunId) ? (
<>
<button
type="button"
className={item.commented ? "autoruns-comment-icon commented" : "autoruns-comment-icon"}
onClick={() => openCommentModal(item)}
title="\u041a\u043e\u043c\u043c\u0435\u043d\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043e\u0442\u0432\u0435\u0442 \u0441\u0438\u0441\u0442\u0435\u043c\u044b"
aria-label="\u041a\u043e\u043c\u043c\u0435\u043d\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043e\u0442\u0432\u0435\u0442 \u0441\u0438\u0441\u0442\u0435\u043c\u044b"
>
<CommentBubbleIcon commented={item.commented} />
</button>
{item.annotation ? (
<button
type="button"
className={item.annotation.resolved ? "autoruns-resolve-toggle resolved" : "autoruns-resolve-toggle"}
onClick={() => void toggleAnnotationResolved(item.annotation!, !item.annotation!.resolved)}
disabled={annotationResolutionBusyId === item.annotation.annotation_id}
title={
item.annotation.resolved
? "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u043d\u0435\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
: "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
}
aria-label={
item.annotation.resolved
? "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u043d\u0435\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
: "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
}
>
<CommentResolvedIcon resolved={item.annotation.resolved} />
</button>
) : null}
</>
) : null}
</div>
</header>
<p>{item.text}</p>
{role === "assistant" && item.annotation ? (
<div className="autoruns-msg-annotation">
<strong>{renderRatingDots(item.annotation.rating)}</strong>
<span>{item.annotation.comment}</span>
<span className="muted">
{item.annotation.manual_case_decision}
{item.annotation.annotation_author ? ` | ${item.annotation.annotation_author}` : ""}
</span>
</div>
) : null}
{(item.trace_id || item.reply_type) && (
<footer>
{item.trace_id ? <span>trace={item.trace_id}</span> : null}
{item.reply_type ? <span>reply_type={item.reply_type}</span> : null}
</footer>
)}
</article>
);
})}
</div>
</section>
{showAssistantMode ? (
<div className="autoruns-col autoruns-assistant-live-col">
<AssistantPanel
sessionId={assistantLiveSessionId}
conversation={assistantLiveConversation}
inputValue={assistantLiveInput}
onInputChange={setAssistantLiveInput}
selectedContextChip={assistantLiveSelectedChip}
onSelectContextChip={setAssistantLiveSelectedChip}
onClearContextChip={() => setAssistantLiveSelectedChip(null)}
useMock={assistantLiveUseMock}
onUseMockChange={setAssistantLiveUseMock}
onSend={sendAssistantLiveMessage}
onClear={resetAssistantLiveSession}
onSaveSession={openAssistantLiveSaveModal}
busy={assistantLiveBusy}
saveBusy={assistantLiveSaveModal.saving}
saveDisabled={!assistantLiveSessionId.trim() || assistantLiveConversation.length === 0 || assistantLiveBusy}
statusText={assistantLiveStatus}
errorMessage={assistantLiveError}
showSaveAction
showCommentAction
onCommentAssistantMessage={openAssistantLiveCommentModal}
isAssistantMessageCommented={isAssistantLiveMessageCommented}
canCommentAssistantMessage={canCommentAssistantLiveMessage}
/>
</div>
) : null}
{showProgressMode ? (
<section className="autoruns-col">
<div className="autoruns-col-header">
<h3>Прогресс / регресс</h3>
</div>
<div className="autoruns-stats-grid">
<div>
<span>Последний score</span>
<strong>{formatScore(history?.stats.latest_score_index ?? null)}</strong>
</div>
<div>
<span>Предыдущий</span>
<strong>{formatScore(history?.stats.previous_score_index ?? null)}</strong>
</div>
<div>
<span>Тренд</span>
<strong>{history ? trendLabel(history.stats.trend) : "нет данных"}</strong>
</div>
<div>
<span>Пробелы качества</span>
<strong>{history?.stats.quality_gap_runs ?? 0}</strong>
</div>
</div>
<h4>Покрытие доменов (история)</h4>
{renderCoverageRows(history?.stats.domain_coverage ?? [])}
<h4 style={{ marginTop: 14 }}>Покрытие доменов (выбранный прогон)</h4>
{renderCoverageRows(runDetail?.coverage.domain_coverage ?? [])}
<h4 style={{ marginTop: 14 }}>Очереди фиксов пост-анализа</h4>
{postAnalysisBusy ? <p className="muted">Собираю пост-анализ...</p> : null}
{!postAnalysisBusy ? (
<div className="autoruns-stats-grid">
{Object.entries(postAnalysis?.post_analysis.stats.by_queue ?? {}).map(([queue, total]) => (
<div key={queue}>
<span>{queue}</span>
<strong>{total}</strong>
</div>
))}
</div>
) : null}
<div className="autoruns-autogen-list">
{(postAnalysis?.post_analysis.recommended_regression_candidates ?? []).slice(0, 12).map((item) => (
<article key={item.annotation_id} className="autoruns-autogen-item">
<header>
<strong>{item.manual_case_decision}</strong>
<span>{item.rating}/5</span>
</header>
<div className="autoruns-run-meta">
{item.domain ?? "неизвестно"} / {item.query_class ?? "неизвестно"}
</div>
<p>{item.comment}</p>
</article>
))}
{!postAnalysisBusy && (postAnalysis?.post_analysis.recommended_regression_candidates.length ?? 0) === 0 ? (
<p className="muted">Рекомендованных кандидатов пока нет.</p>
) : null}
</div>
</section>
) : null}
{showCommentsMode ? (
<section className="autoruns-col">
<div className="autoruns-col-header">
<h3>Комментарии</h3>
</div>
<h4>Размеченные ответы</h4>
<div className="autoruns-comment-filter-row">
<label>
Фильтр решений
<select
value={annotationDecisionFilter}
onChange={(event) => setAnnotationDecisionFilter(event.target.value as ManualCaseDecision | "all")}
>
<option value="all">все</option>
{(availableManualDecisions.length > 0
? availableManualDecisions
: ((manualDecisionSchema?.enum as ManualCaseDecision[] | undefined) ?? [])
).map((decision) => (
<option key={decision} value={decision}>
{String(((manualDecisionSchema?.labels as Record<string, unknown> | undefined)?.[decision] ?? decision))}
</option>
))}
</select>
</label>
<button
type="button"
className="tab autoruns-resolved-filter-toggle"
onClick={() => setHideResolvedAnnotations((prev) => !prev)}
>
{hideResolvedAnnotations
? "\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0435"
: "\u0421\u043a\u0440\u044b\u0442\u044c \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0435"}
</button>
</div>
<div className="autoruns-stats-grid">
<div>
<span>Комментариев</span>
<strong>{unifiedVisibleAnnotations.length}</strong>
</div>
<div>
<span>Средний рейтинг</span>
<strong>{annotationsAverageRating === null ? "нет данных" : `${annotationsAverageRating.toFixed(2)} / 5`}</strong>
</div>
<div>
<span>Последний</span>
<strong>
{unifiedVisibleAnnotations.length > 0 ? formatDateTime(unifiedVisibleAnnotations[0].updated_at) : "нет данных"}
</strong>
</div>
<div>
<span>Статус</span>
<strong>{annotationsBusy ? "обновляю" : "готово"}</strong>
</div>
</div>
<div className="button-row">
<button type="button" disabled={annotationsBusy} onClick={() => void loadAnnotations()}>
{annotationsBusy ? "Обновляю..." : "Обновить список"}
</button>
<button type="button" className="tab" disabled={postAnalysisBusy} onClick={() => void loadPostAnalysis()}>
{postAnalysisBusy ? "Идет пост-анализ..." : "Обновить пост-анализ"}
</button>
</div>
<div className="autoruns-comments-list">
{annotationsBusy ? <p className="muted">Загружаю комментарии...</p> : null}
{!annotationsBusy && unifiedVisibleAnnotations.length === 0 ? (
<p className="muted">
{annotations.length === 0 && assistantLiveAnnotations.length === 0
? "Пока нет откомментированных ответов."
: "\u041d\u0435\u0442 \u043e\u0442\u043a\u0440\u044b\u0442\u044b\u0445 \u043a\u0435\u0439\u0441\u043e\u0432 \u043f\u043e \u0442\u0435\u043a\u0443\u0449\u0435\u043c\u0443 \u0444\u0438\u043b\u044c\u0442\u0440\u0443."}
</p>
) : null}
{unifiedVisibleAnnotations.map((item) => {
if (item.source === "assistant_live") {
const annotation = item.assistant;
return (
<article key={item.key} className="autoruns-comment-item">
<div className="autoruns-comment-head">
<strong>{renderRatingDots(annotation.rating)}</strong>
<div className="autoruns-comment-head-actions">
<span>{formatDateTime(annotation.updated_at)}</span>
</div>
</div>
<div className="autoruns-run-meta">live-session: {annotation.session_id}</div>
<div className="autoruns-run-meta">msg={annotation.message_index}</div>
<div className="autoruns-run-meta">
source=assistant_live
{annotation.annotation_author ? ` | author=${annotation.annotation_author}` : ""}
</div>
{annotation.context.question_text ? <p>Q: {annotation.context.question_text}</p> : null}
{annotation.context.answer_text ? <p>A: {annotation.context.answer_text}</p> : null}
<p>{annotation.comment}</p>
</article>
);
}
const annotation = item.autorun;
return (
<article
key={item.key}
className={selectedAnnotationId === annotation.annotation_id ? "autoruns-comment-item selected" : "autoruns-comment-item"}
onClick={() => void openAnnotationContext(annotation)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
void openAnnotationContext(annotation);
}
}}
>
<div className="autoruns-comment-head">
<strong>{renderRatingDots(annotation.rating)}</strong>
<div className="autoruns-comment-head-actions">
<span>{formatDateTime(annotation.updated_at)}</span>
<button
type="button"
className={annotation.resolved ? "autoruns-resolve-toggle resolved" : "autoruns-resolve-toggle"}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void toggleAnnotationResolved(annotation, !annotation.resolved);
}}
disabled={annotationResolutionBusyId === annotation.annotation_id}
title={
annotation.resolved
? "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u043d\u0435\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
: "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
}
aria-label={
annotation.resolved
? "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u043d\u0435\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
: "\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043a\u0435\u0439\u0441 \u043a\u0430\u043a \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0439"
}
>
<CommentResolvedIcon resolved={annotation.resolved} />
</button>
</div>
</div>
<div className="autoruns-run-meta">{annotation.run_id}</div>
<div className="autoruns-run-meta">
case={annotation.case_id} | msg={annotation.message_index}
</div>
<div className="autoruns-run-meta">
decision={annotation.manual_case_decision}
{annotation.annotation_author ? ` | author=${annotation.annotation_author}` : ""}
</div>
{annotation.resolved_at ? (
<div className="autoruns-run-meta">
{"\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043e"}: {formatDateTime(annotation.resolved_at)}
{annotation.resolved_by ? ` | by=${annotation.resolved_by}` : ""}
</div>
) : null}
{annotation.context.question_text ? <p>Q: {annotation.context.question_text}</p> : null}
{annotation.context.answer_text ? <p>A: {annotation.context.answer_text}</p> : null}
<p>{annotation.comment}</p>
</article>
);
})}
</div>
{selectedAnnotation ? (
<>
<h4>Тех-контекст брака</h4>
<div className="autoruns-meta-list">
<div>
<span>trace:</span>
<strong>{selectedAnnotation.technical_context.trace_id ?? "нет данных"}</strong>
</div>
<div>
<span>reply_type:</span>
<strong>{selectedAnnotation.technical_context.reply_type ?? "нет данных"}</strong>
</div>
<div>
<span>domain:</span>
<strong>{selectedAnnotation.technical_context.domain ?? "нет данных"}</strong>
</div>
<div>
<span>query_class:</span>
<strong>{selectedAnnotation.technical_context.query_class ?? "нет данных"}</strong>
</div>
</div>
<h4>JSON разбор</h4>
<JsonView
value={{
annotation_id: selectedAnnotation.annotation_id,
run_id: selectedAnnotation.run_id,
case_id: selectedAnnotation.case_id,
message_index: selectedAnnotation.message_index,
rating: selectedAnnotation.rating,
comment: selectedAnnotation.comment,
manual_case_decision: selectedAnnotation.manual_case_decision,
annotation_author: selectedAnnotation.annotation_author,
resolved: selectedAnnotation.resolved,
resolved_at: selectedAnnotation.resolved_at,
resolved_by: selectedAnnotation.resolved_by,
context: selectedAnnotation.context,
technical_context: selectedAnnotation.technical_context,
case_summary: selectedAnnotation.case_summary
? {
case_id: selectedAnnotation.case_summary.case_id,
domain: selectedAnnotation.case_summary.domain,
query_class: selectedAnnotation.case_summary.query_class,
checks: selectedAnnotation.case_summary.checks,
metric_subscores: selectedAnnotation.case_summary.metric_subscores
}
: null
}}
/>
</>
) : null}
</section>
) : null}
</div>
{assistantLiveSaveModal.open ? (
<div
className="autoruns-comment-modal-backdrop"
onClick={(event) => {
if (event.target === event.currentTarget) {
closeAssistantLiveSaveModal();
}
}}
>
<div className="autoruns-comment-modal">
<h3>Сохранить ручную сессию</h3>
<p className="muted">Технический чат будет сохранен в автопрогоны как пользовательская multi-turn сессия.</p>
<label>
Название
<input
value={assistantLiveSaveModal.title}
onChange={(event) => setAssistantLiveSaveModal((prev) => ({ ...prev, title: event.target.value }))}
placeholder="Например: НДС и склад на март 2020"
disabled={assistantLiveSaveModal.saving}
/>
</label>
{assistantLiveSaveModal.error ? <p className="error-text">{assistantLiveSaveModal.error}</p> : null}
<div className="button-row">
<button type="button" onClick={() => void submitAssistantLiveSaveModal()} disabled={assistantLiveSaveModal.saving}>
{assistantLiveSaveModal.saving ? "Сохраняю..." : "Сохранить"}
</button>
<button type="button" className="tab" onClick={() => closeAssistantLiveSaveModal()} disabled={assistantLiveSaveModal.saving}>
Отмена
</button>
</div>
</div>
</div>
) : null}
{savedSessionQuestionDeleteModal.open ? (
<div
className="autoruns-comment-modal-backdrop"
onClick={(event) => {
if (event.target === event.currentTarget) {
closeSavedSessionQuestionDeleteModal();
}
}}
>
<div className="autoruns-comment-modal">
<h3>Удалить вопрос</h3>
<p className="muted">Действительно удалить вопрос из сохраненной пользовательской сессии?</p>
<p className="autoruns-comment-quote">{savedSessionQuestionDeleteModal.questionText}</p>
{savedSessionQuestionDeleteModal.error ? <p className="error-text">{savedSessionQuestionDeleteModal.error}</p> : null}
<div className="button-row">
<button type="button" onClick={() => void submitSavedSessionQuestionDelete()} disabled={savedSessionQuestionDeleteModal.saving}>
{savedSessionQuestionDeleteModal.saving ? "Удаляю..." : "Да"}
</button>
<button
type="button"
className="tab"
onClick={() => closeSavedSessionQuestionDeleteModal()}
disabled={savedSessionQuestionDeleteModal.saving}
>
Нет
</button>
</div>
</div>
</div>
) : null}
{autoGenDeleteModal.open ? (
<div
className="autoruns-comment-modal-backdrop"
onClick={(event) => {
if (event.target === event.currentTarget) {
closeAutoGenDeleteModal();
}
}}
>
<div className="autoruns-comment-modal">
<h3>Удалить сохраненный набор</h3>
<p className="muted">Будет удалена карточка истории и связанный файл кейс-сета на бэке.</p>
<p className="autoruns-comment-quote">{autoGenDeleteModal.title}</p>
{autoGenDeleteModal.error ? <p className="error-text">{autoGenDeleteModal.error}</p> : null}
<div className="button-row">
<button type="button" onClick={() => void submitAutoGenDeleteModal()} disabled={autoGenDeleteModal.saving}>
{autoGenDeleteModal.saving ? "Удаляю..." : "Да"}
</button>
<button type="button" className="tab" onClick={() => closeAutoGenDeleteModal()} disabled={autoGenDeleteModal.saving}>
Нет
</button>
</div>
</div>
</div>
) : null}
{assistantLiveCommentModal.open ? (
<div
className="autoruns-comment-modal-backdrop"
onClick={(event) => {
if (event.target === event.currentTarget) {
closeAssistantLiveCommentModal();
}
}}
>
<div className="autoruns-comment-modal">
<h3>Комментарий к ответу ассистента</h3>
<p className="muted">Комментарий будет добавлен в общий список комментариев справа с меткой `assistant_live`.</p>
{assistantLiveCommentModalQuestion ? (
<details className="autoruns-prompt-details" open>
<summary>Вопрос пользователя</summary>
<p className="autoruns-comment-quote">{assistantLiveCommentModalQuestion.text}</p>
</details>
) : null}
{assistantLiveCommentModalMessage ? (
<details className="autoruns-prompt-details" open>
<summary>Ответ ассистента</summary>
<p className="autoruns-comment-quote">{assistantLiveCommentModalMessage.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={assistantLiveCommentModal.rating >= value ? "autoruns-rating-dot active" : "autoruns-rating-dot"}
onClick={() => setAssistantLiveCommentModal((prev) => ({ ...prev, rating: value }))}
disabled={assistantLiveCommentModal.saving}
aria-label={`Оценка ${value}`}
>
{assistantLiveCommentModal.rating >= value ? "●" : "○"}
</button>
))}
</div>
<div className="autoruns-form-grid">
<label>
Автор комментария
<input
value={assistantLiveCommentModal.annotationAuthor}
onChange={(event) => setAssistantLiveCommentModal((prev) => ({ ...prev, annotationAuthor: event.target.value }))}
placeholder="manual_reviewer"
disabled={assistantLiveCommentModal.saving}
/>
</label>
</div>
<label>
Комментарий
<textarea
value={assistantLiveCommentModal.comment}
onChange={(event) => setAssistantLiveCommentModal((prev) => ({ ...prev, comment: event.target.value }))}
placeholder="Что именно не так в ответе и что нужно исправить."
rows={4}
disabled={assistantLiveCommentModal.saving}
/>
</label>
{assistantLiveCommentModal.error ? <p className="error-text">{assistantLiveCommentModal.error}</p> : null}
<div className="button-row">
<button type="button" onClick={() => void submitAssistantLiveCommentModal()} disabled={assistantLiveCommentModal.saving}>
{assistantLiveCommentModal.saving ? "Сохраняю..." : "Готово"}
</button>
<button
type="button"
className="tab"
onClick={() => closeAssistantLiveCommentModal()}
disabled={assistantLiveCommentModal.saving}
>
Отмена
</button>
</div>
</div>
</div>
) : null}
{commentModal.open ? (
<div
className="autoruns-comment-modal-backdrop"
onClick={(event) => {
if (event.target === event.currentTarget) {
closeCommentModal();
}
}}
>
<div className="autoruns-comment-modal">
<h3>Комментарий к ответу системы</h3>
<p className="muted">Оцените ответ по 5-балльной шкале и добавьте комментарий по браку.</p>
{modalMessage ? (
<>
<details className="autoruns-prompt-details" open>
<summary>{"\u0412\u043e\u043f\u0440\u043e\u0441 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f"}</summary>
<p className="autoruns-comment-quote">
{modalQuestion?.text ?? "\u0412\u043e\u043f\u0440\u043e\u0441 \u0432 \u0434\u0438\u0430\u043b\u043e\u0433\u0435 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d."}
</p>
</details>
<details className="autoruns-prompt-details" open>
<summary>{"\u041e\u0442\u0432\u0435\u0442 \u0441\u0438\u0441\u0442\u0435\u043c\u044b"}</summary>
<p className="autoruns-comment-quote">{modalMessage.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={commentModal.rating >= value ? "autoruns-rating-dot active" : "autoruns-rating-dot"}
onClick={() => setCommentModal((prev) => ({ ...prev, rating: value }))}
disabled={commentModal.saving}
aria-label={`Оценка ${value}`}
>
{commentModal.rating >= value ? "●" : "○"}
</button>
))}
</div>
<div className="autoruns-form-grid">
<label>
Решение по кейсу
<select
value={commentModal.manualCaseDecision}
onChange={(event) =>
setCommentModal((prev) => ({ ...prev, manualCaseDecision: event.target.value as ManualCaseDecision }))
}
disabled={commentModal.saving}
>
{(availableManualDecisions.length > 0
? availableManualDecisions
: ((manualDecisionSchema?.enum as ManualCaseDecision[] | undefined) ?? [DEFAULT_MANUAL_DECISION])
).map((decision) => (
<option key={decision} value={decision}>
{String(((manualDecisionSchema?.labels as Record<string, unknown> | undefined)?.[decision] ?? decision))}
</option>
))}
</select>
</label>
<label>
Автор комментария
<input
value={commentModal.annotationAuthor}
onChange={(event) => setCommentModal((prev) => ({ ...prev, annotationAuthor: event.target.value }))}
placeholder="manual_reviewer"
disabled={commentModal.saving}
/>
</label>
</div>
<label>
Комментарий
<textarea
value={commentModal.comment}
onChange={(event) => setCommentModal((prev) => ({ ...prev, comment: event.target.value }))}
placeholder="Почему ответ бракованный, что именно пошло не так, какие технические детали проверить."
rows={4}
disabled={commentModal.saving}
/>
</label>
{commentModal.error ? <p className="error-text">{commentModal.error}</p> : null}
<div className="button-row">
<button type="button" onClick={() => void submitCommentModal()} disabled={commentModal.saving}>
{commentModal.saving ? "Сохраняю..." : "Готово"}
</button>
<button type="button" className="tab" onClick={() => closeCommentModal()} disabled={commentModal.saving}>
Отмена
</button>
</div>
</div>
</div>
) : null}
</PanelFrame>
);
}