4112 lines
169 KiB
TypeScript
4112 lines
169 KiB
TypeScript
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>
|
||
);
|
||
}
|
||
|
||
|