ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Рефакторинг этапов Stage 3.8 - юи

This commit is contained in:
2026-04-12 00:54:55 +03:00
parent d65969d2ff
commit 8167bd228d
18 changed files with 1408 additions and 70 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NDC AI Normalizer Playground</title>
<script type="module" crossorigin src="/assets/index-DMMD5-xN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BfGkpjEM.css">
<script type="module" crossorigin src="/assets/index-B_Dz87Mp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D4dtBq8A.css">
</head>
<body>
<div id="root"></div>
+316 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { apiClient } from "./api/client";
import { AssistantSamPanel } from "./components/AssistantSamPanel";
import { AutoRunsHistoryPanel } from "./components/AutoRunsHistoryPanel";
@@ -7,6 +7,7 @@ import { ConnectionPanel } from "./components/ConnectionPanel";
import { HistoryPanel } from "./components/HistoryPanel";
import { MetricsPanel } from "./components/MetricsPanel";
import { OutputPanel } from "./components/OutputPanel";
import { PanelFrame } from "./components/PanelFrame";
import { PromptPanel } from "./components/PromptPanel";
import { QueryPanel } from "./components/QueryPanel";
import { RuntimePanel } from "./components/RuntimePanel";
@@ -14,6 +15,7 @@ import { DEFAULT_CONNECTION, DEFAULT_PROMPTS, DEFAULT_QUERY } from "./state/defa
import { designConfig } from "../../../designconfig";
import type {
AssistantConversationItem,
AssistantAnnotationRecord,
ConnectionState,
HistoryItem,
NormalizeResultState,
@@ -32,6 +34,17 @@ const DEFAULT_UI_MODE: UiMode = "assistant";
const AUTOLOAD_PROMPT_VERSION = "normalizer_v2_0_2";
const ASSISTANT_PROMPT_VERSION = "address_query_runtime_v1";
const TAB_KEYS: TabKey[] = ["normalized", "fragments", "scope", "flags", "route", "raw", "validation", "logs"];
const DEFAULT_ASSISTANT_ANNOTATION_AUTHOR = "manual_reviewer";
interface AssistantCommentModalState {
open: boolean;
messageIndex: number;
rating: number;
comment: string;
annotationAuthor: string;
saving: boolean;
error: string;
}
function withTs(message: string): string {
return `[${new Date().toLocaleTimeString("ru-RU")}] ${message}`;
@@ -91,6 +104,7 @@ export default function App() {
const [showAssistantConnectionMode, setShowAssistantConnectionMode] = useState(true);
const [showAssistantPromptMode, setShowAssistantPromptMode] = useState(true);
const [showAssistantChatMode, setShowAssistantChatMode] = useState(true);
const [showAssistantCommentsMode, setShowAssistantCommentsMode] = useState(true);
const [showAssistantSamMode, setShowAssistantSamMode] = useState(true);
const [showDecompositionConnectionMode, setShowDecompositionConnectionMode] = useState(true);
const [showDecompositionPromptMode, setShowDecompositionPromptMode] = useState(true);
@@ -105,6 +119,17 @@ export default function App() {
const [assistantBusy, setAssistantBusy] = useState(false);
const [assistantStatus, setAssistantStatus] = useState("");
const [assistantError, setAssistantError] = useState("");
const [assistantAnnotations, setAssistantAnnotations] = useState<AssistantAnnotationRecord[]>([]);
const [assistantAnnotationsBusy, setAssistantAnnotationsBusy] = useState(false);
const [assistantCommentModal, setAssistantCommentModal] = useState<AssistantCommentModalState>({
open: false,
messageIndex: -1,
rating: 3,
comment: "",
annotationAuthor: DEFAULT_ASSISTANT_ANNOTATION_AUTHOR,
saving: false,
error: ""
});
const presetAutoloadDoneRef = useRef(false);
const skipPresetAutoloadRef = useRef(false);
@@ -172,6 +197,7 @@ export default function App() {
showAssistantConnectionMode?: boolean;
showAssistantPromptMode?: boolean;
showAssistantChatMode?: boolean;
showAssistantCommentsMode?: boolean;
showAssistantSamMode?: boolean;
showDecompositionConnectionMode?: boolean;
showDecompositionPromptMode?: boolean;
@@ -209,6 +235,9 @@ export default function App() {
if (typeof parsed.showAssistantChatMode === "boolean") {
setShowAssistantChatMode(parsed.showAssistantChatMode);
}
if (typeof parsed.showAssistantCommentsMode === "boolean") {
setShowAssistantCommentsMode(parsed.showAssistantCommentsMode);
}
if (typeof parsed.showAssistantSamMode === "boolean") {
setShowAssistantSamMode(parsed.showAssistantSamMode);
}
@@ -331,6 +360,7 @@ export default function App() {
showAssistantConnectionMode,
showAssistantPromptMode,
showAssistantChatMode,
showAssistantCommentsMode,
showAssistantSamMode,
showDecompositionConnectionMode,
showDecompositionPromptMode,
@@ -619,12 +649,146 @@ export default function App() {
}
}
const assistantAnnotationsByMessageId = useMemo(() => {
const map = new Map<string, AssistantAnnotationRecord>();
for (const item of assistantAnnotations) {
if (item.message_id) {
map.set(item.message_id, item);
}
}
return map;
}, [assistantAnnotations]);
const assistantCommentModalMessage =
assistantCommentModal.messageIndex >= 0 ? assistantConversation[assistantCommentModal.messageIndex] ?? null : null;
const assistantCommentModalQuestion = useMemo(() => {
if (assistantCommentModal.messageIndex < 0) return null;
for (let index = assistantCommentModal.messageIndex - 1; index >= 0; index -= 1) {
const candidate = assistantConversation[index];
if (candidate?.role === "user") {
return candidate;
}
}
return null;
}, [assistantCommentModal.messageIndex, assistantConversation]);
async function loadAssistantAnnotationsForSession(sessionId: string): Promise<void> {
if (!sessionId.trim()) {
setAssistantAnnotations([]);
return;
}
setAssistantAnnotationsBusy(true);
try {
const payload = await apiClient.loadAssistantAnnotations({
session_id: sessionId,
limit: 400
});
setAssistantAnnotations(payload.items ?? []);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(`Assistant annotations load error: ${message}`);
} finally {
setAssistantAnnotationsBusy(false);
}
}
function closeAssistantCommentModal(options?: { force?: boolean }) {
setAssistantCommentModal((prev) => {
if (prev.saving && !options?.force) {
return prev;
}
return {
open: false,
messageIndex: -1,
rating: 3,
comment: "",
annotationAuthor: DEFAULT_ASSISTANT_ANNOTATION_AUTHOR,
saving: false,
error: ""
};
});
}
function openAssistantCommentModal(item: AssistantConversationItem, index: number): void {
if (item.role !== "assistant") return;
const sessionIdFromState = assistantSessionId.trim();
const sessionIdFromItem = String(item.session_id ?? "").trim();
const resolvedSessionId = sessionIdFromState || sessionIdFromItem;
if (!resolvedSessionId) {
setAssistantError("Сначала получите ответ ассистента в активной сессии.");
return;
}
if (!sessionIdFromState && sessionIdFromItem) {
setAssistantSessionId(sessionIdFromItem);
}
const existing = assistantAnnotationsByMessageId.get(item.message_id) ?? null;
setAssistantCommentModal({
open: true,
messageIndex: index,
rating: existing?.rating ?? 3,
comment: existing?.comment ?? "",
annotationAuthor: existing?.annotation_author ?? DEFAULT_ASSISTANT_ANNOTATION_AUTHOR,
saving: false,
error: ""
});
}
function canCommentAssistantMessage(item: AssistantConversationItem): boolean {
return item.role === "assistant";
}
function isAssistantMessageCommented(item: AssistantConversationItem): boolean {
return item.role === "assistant" && assistantAnnotationsByMessageId.has(item.message_id);
}
async function submitAssistantCommentModal(): Promise<void> {
if (!assistantSessionId.trim()) {
setAssistantCommentModal((prev) => ({ ...prev, error: "Сессия ассистента не найдена." }));
return;
}
if (assistantCommentModal.messageIndex < 0) {
return;
}
if (!assistantCommentModal.comment.trim()) {
setAssistantCommentModal((prev) => ({ ...prev, error: "Добавьте комментарий." }));
return;
}
setAssistantCommentModal((prev) => ({ ...prev, saving: true, error: "" }));
try {
const payload = await apiClient.saveAssistantAnnotation({
session_id: assistantSessionId,
message_index: assistantCommentModal.messageIndex,
rating: assistantCommentModal.rating,
comment: assistantCommentModal.comment.trim(),
annotation_author: assistantCommentModal.annotationAuthor.trim() || undefined
});
setAssistantAnnotations((prev) => {
const next = [...prev];
const index = next.findIndex((item) => item.annotation_id === payload.annotation.annotation_id);
if (index >= 0) {
next[index] = payload.annotation;
} else {
next.unshift(payload.annotation);
}
return next.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at));
});
closeAssistantCommentModal({ force: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAssistantCommentModal((prev) => ({ ...prev, saving: false, error: message }));
}
}
function resetAssistantSession() {
setAssistantSessionId("");
setAssistantConversation([]);
setAssistantInput("");
setAssistantStatus("");
setAssistantError("");
setAssistantAnnotations([]);
closeAssistantCommentModal({ force: true });
log("Assistant session reset.");
}
@@ -664,6 +828,7 @@ export default function App() {
setAssistantSessionId(response.session_id);
setAssistantConversation(response.conversation);
setAssistantStatus("Ответ готов");
await loadAssistantAnnotationsForSession(response.session_id);
log(`Assistant reply received: trace=${response.debug.trace_id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -676,6 +841,14 @@ export default function App() {
}
}
useEffect(() => {
if (!assistantSessionId.trim()) {
setAssistantAnnotations([]);
return;
}
void loadAssistantAnnotationsForSession(assistantSessionId);
}, [assistantSessionId]);
useEffect(() => {
if (!selectedRunId) {
setRunTrace([]);
@@ -728,6 +901,13 @@ export default function App() {
<button type="button" className={showAssistantChatMode ? "tab active" : "tab"} onClick={() => setShowAssistantChatMode((prev) => !prev)}>
Режим ассистента
</button>
<button
type="button"
className={showAssistantCommentsMode ? "tab active" : "tab"}
onClick={() => setShowAssistantCommentsMode((prev) => !prev)}
>
Комментарии ассистента
</button>
<button type="button" className={showAssistantSamMode ? "tab active" : "tab"} onClick={() => setShowAssistantSamMode((prev) => !prev)}>
SAM
</button>
@@ -862,10 +1042,57 @@ export default function App() {
busy={assistantBusy}
statusText={assistantStatus}
errorMessage={assistantError}
showCommentAction
onCommentAssistantMessage={openAssistantCommentModal}
isAssistantMessageCommented={isAssistantMessageCommented}
canCommentAssistantMessage={canCommentAssistantMessage}
/>
</div>
) : null}
{showAssistantCommentsMode ? (
<div className="mode-col">
<PanelFrame className="assistant-comments-frame" title="Комментарии ассистента">
<div className="assistant-comments-shell">
<div className="assistant-comments-toolbar">
<span className="muted">
{assistantSessionId ? `session: ${assistantSessionId}` : "Сессия не запущена"}
</span>
<button
type="button"
className="tab"
onClick={() => void loadAssistantAnnotationsForSession(assistantSessionId)}
disabled={!assistantSessionId || assistantAnnotationsBusy}
>
{assistantAnnotationsBusy ? "Обновляю..." : "Обновить"}
</button>
</div>
<div className="assistant-comments-list">
{!assistantSessionId ? <p className="muted">Появится после первого ответа ассистента.</p> : null}
{assistantSessionId && assistantAnnotations.length === 0 && !assistantAnnotationsBusy ? (
<p className="muted">Комментариев по этой сессии пока нет.</p>
) : null}
{assistantAnnotations.map((item) => (
<article key={item.annotation_id} className="assistant-comment-item">
<div className="assistant-comment-head">
<strong>{`${"●".repeat(Math.max(1, Math.min(5, Math.round(item.rating))))}${"○".repeat(Math.max(0, 5 - Math.round(item.rating)))}`}</strong>
<span>{new Date(item.updated_at).toLocaleString("ru-RU")}</span>
</div>
{item.context.question_text ? <p>Q: {item.context.question_text}</p> : null}
{item.context.answer_text ? <p>A: {item.context.answer_text}</p> : null}
<p>{item.comment}</p>
<div className="assistant-comment-meta">
{item.context.trace_id ? <span>{`trace=${item.context.trace_id}`}</span> : null}
{item.context.reply_type ? <span>{`reply_type=${item.context.reply_type}`}</span> : null}
</div>
</article>
))}
</div>
</div>
</PanelFrame>
</div>
) : null}
{showAssistantSamMode ? (
<div className="mode-col">
<AssistantSamPanel
@@ -879,7 +1106,11 @@ export default function App() {
</div>
) : null}
{!showAssistantConnectionMode && !showAssistantPromptMode && !showAssistantChatMode && !showAssistantSamMode ? (
{!showAssistantConnectionMode &&
!showAssistantPromptMode &&
!showAssistantChatMode &&
!showAssistantCommentsMode &&
!showAssistantSamMode ? (
<div className="mode-columns-empty">Все панели режима ассистента скрыты. Включите нужные блоки справа в шапке.</div>
) : null}
</div>
@@ -999,6 +1230,89 @@ export default function App() {
/>
</div>
)}
{assistantCommentModal.open ? (
<div
className="autoruns-comment-modal-backdrop"
onClick={(event) => {
if (event.target === event.currentTarget) {
closeAssistantCommentModal();
}
}}
>
<div className="autoruns-comment-modal">
<h3>Комментарий к ответу ассистента</h3>
<p className="muted">Эта разметка хранится отдельно от комментариев автопрогонов.</p>
{assistantCommentModalQuestion ? (
<details className="autoruns-prompt-details" open>
<summary>Вопрос пользователя</summary>
<p className="autoruns-comment-quote">{assistantCommentModalQuestion.text}</p>
</details>
) : null}
{assistantCommentModalMessage ? (
<details className="autoruns-prompt-details" open>
<summary>Ответ ассистента</summary>
<p className="autoruns-comment-quote">{assistantCommentModalMessage.text}</p>
</details>
) : null}
<div className="autoruns-rating-row" role="group" aria-label="Рейтинг ответа">
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
type="button"
className={assistantCommentModal.rating >= value ? "autoruns-rating-dot active" : "autoruns-rating-dot"}
onClick={() => setAssistantCommentModal((prev) => ({ ...prev, rating: value }))}
disabled={assistantCommentModal.saving}
aria-label={`Оценка ${value}`}
>
{assistantCommentModal.rating >= value ? "●" : "○"}
</button>
))}
</div>
<div className="autoruns-form-grid">
<label>
Автор комментария
<input
value={assistantCommentModal.annotationAuthor}
onChange={(event) => setAssistantCommentModal((prev) => ({ ...prev, annotationAuthor: event.target.value }))}
placeholder="manual_reviewer"
disabled={assistantCommentModal.saving}
/>
</label>
</div>
<label>
Комментарий
<textarea
value={assistantCommentModal.comment}
onChange={(event) => setAssistantCommentModal((prev) => ({ ...prev, comment: event.target.value }))}
placeholder="Что именно не так в ответе и что проверить."
rows={4}
disabled={assistantCommentModal.saving}
/>
</label>
{assistantCommentModal.error ? <p className="error-text">{assistantCommentModal.error}</p> : null}
<div className="button-row">
<button type="button" onClick={() => void submitAssistantCommentModal()} disabled={assistantCommentModal.saving}>
{assistantCommentModal.saving ? "Сохраняю..." : "Готово"}
</button>
<button
type="button"
className="tab"
onClick={() => closeAssistantCommentModal()}
disabled={assistantCommentModal.saving}
>
Отмена
</button>
</div>
</div>
</div>
) : null}
</main>
);
}
+26
View File
@@ -10,6 +10,8 @@ import type {
AutoRunDialogResponse,
AutoRunHistoryResponse,
AutoRunPostAnalysisResponse,
AssistantAnnotationsResponse,
AssistantAnnotationRecord,
AssistantMessageResultState,
AssistantConversationItem,
ConnectionState,
@@ -314,6 +316,30 @@ export const apiClient = {
return request(`/assistant/session/${sessionId}`);
},
async loadAssistantAnnotations(input?: {
session_id?: string;
limit?: number;
}): Promise<AssistantAnnotationsResponse> {
const params = new URLSearchParams();
if (input?.session_id) params.set("session_id", input.session_id);
if (typeof input?.limit === "number") params.set("limit", String(input.limit));
const query = params.toString();
return request(`/assistant/annotations${query ? `?${query}` : ""}`);
},
async saveAssistantAnnotation(input: {
session_id: string;
message_index: number;
rating: number;
comment: string;
annotation_author?: string;
}): Promise<{ ok: boolean; annotation: AssistantAnnotationRecord }> {
return request("/assistant/annotations", {
method: "POST",
body: JSON.stringify(input)
});
},
async loadAutoRunsHistory(input?: {
from?: string;
to?: string;
@@ -16,6 +16,10 @@ interface AssistantPanelProps {
busy: boolean;
statusText: string;
errorMessage: string;
showCommentAction?: boolean;
onCommentAssistantMessage?: (item: AssistantConversationItem, index: number) => void;
isAssistantMessageCommented?: (item: AssistantConversationItem, index: number) => boolean;
canCommentAssistantMessage?: (item: AssistantConversationItem, index: number) => boolean;
}
function roleLabel(role: AssistantConversationItem["role"]): string {
@@ -61,6 +65,15 @@ async function copyTextToClipboard(text: string): Promise<boolean> {
return copied;
}
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>
);
}
export function AssistantPanel({
sessionId,
conversation,
@@ -72,7 +85,11 @@ export function AssistantPanel({
onClear,
busy,
statusText,
errorMessage
errorMessage,
showCommentAction = false,
onCommentAssistantMessage,
isAssistantMessageCommented,
canCommentAssistantMessage
}: AssistantPanelProps) {
const listRef = useRef<HTMLDivElement | null>(null);
const stickToBottomRef = useRef(true);
@@ -162,11 +179,45 @@ export function AssistantPanel({
</div>
<div ref={listRef} className="assistant-chat-list" onScroll={handleChatScroll}>
{conversation.map((item) => (
{conversation.map((item, index) => {
const commentEnabled =
item.role === "assistant" &&
showCommentAction &&
typeof onCommentAssistantMessage === "function" &&
(typeof canCommentAssistantMessage === "function" ? canCommentAssistantMessage(item, index) : true);
const commented =
item.role === "assistant" && typeof isAssistantMessageCommented === "function"
? isAssistantMessageCommented(item, index)
: false;
return (
<article key={item.message_id} className={`assistant-msg ${item.role}`}>
<header className="assistant-msg-head">
<strong>{roleLabel(item.role)}</strong>
<span>{shortTime(item.created_at)}</span>
<div className="assistant-msg-head-main">
<strong>{roleLabel(item.role)}</strong>
<span>{shortTime(item.created_at)}</span>
</div>
{item.role === "assistant" && showCommentAction ? (
<div className="assistant-msg-head-actions">
<button
type="button"
className={commented ? "autoruns-comment-icon assistant-comment-btn commented" : "autoruns-comment-icon assistant-comment-btn"}
onClick={() => onCommentAssistantMessage?.(item, index)}
disabled={!commentEnabled}
title={
commentEnabled
? "Комментировать ответ ассистента"
: "Комментарий недоступен для этого сообщения"
}
aria-label={
commentEnabled
? "Комментировать ответ ассистента"
: "Комментарий недоступен для этого сообщения"
}
>
<CommentBubbleIcon commented={commented} />
</button>
</div>
) : null}
</header>
<div className="assistant-msg-body">{item.text}</div>
{item.role === "assistant" && item.debug ? (
@@ -176,13 +227,15 @@ export function AssistantPanel({
</details>
) : null}
</article>
))}
);
})}
</div>
<div className="assistant-compose">
<label className="full-width">
Сообщение
<textarea
className="assistant-input-textarea"
value={inputValue}
onChange={(event) => onInputChange(event.target.value)}
rows={4}
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type SyntheticEvent } from "react";
import { apiClient } from "../api/client";
import type {
AssistantConversationItem,
AssistantAnnotationRecord,
AsyncEvalRunJob,
AutoGenHistoryRecord,
AutoGenMode,
@@ -68,6 +69,16 @@ interface CommentModalState {
error: string;
}
interface AssistantLiveCommentModalState {
open: boolean;
messageIndex: number;
rating: number;
comment: string;
annotationAuthor: string;
saving: boolean;
error: string;
}
interface AutoGenSettingsState {
mode: AutoGenMode;
count: number;
@@ -117,6 +128,7 @@ function buildDefaultPersonalityPrompts(
interface AutoRunsUiConfig {
filters?: Partial<AutoRunsFilters>;
analysisDate?: string;
autogenPersonalityPromptHeight?: number;
autoGenSettings?: {
mode?: AutoGenMode;
count?: number;
@@ -143,6 +155,11 @@ function normalizeAnalysisDateInput(value: string): string {
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");
@@ -391,9 +408,6 @@ function CommentBubbleIcon({ commented }: { commented: boolean }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M5 6.5h14v9H11.5l-4.5 3v-3H5z" />
<circle className="comment-icon-dot" cx="9" cy="11" r="1.05" />
<circle className="comment-icon-dot" cx="12" cy="11" r="1.05" />
<circle className="comment-icon-dot" cx="15" cy="11" r="1.05" />
</svg>
);
}
@@ -462,6 +476,7 @@ export function AutoRunsHistoryPanel({
const [errorText, setErrorText] = useState("");
const [assistantLiveSessionId, setAssistantLiveSessionId] = useState("");
const [assistantLiveConversation, setAssistantLiveConversation] = useState<AssistantConversationItem[]>([]);
const [assistantLiveAnnotations, setAssistantLiveAnnotations] = useState<AssistantAnnotationRecord[]>([]);
const [assistantLiveInput, setAssistantLiveInput] = useState("");
const [assistantLiveUseMock, setAssistantLiveUseMock] = useState(false);
const [assistantLiveBusy, setAssistantLiveBusy] = useState(false);
@@ -469,6 +484,7 @@ export function AutoRunsHistoryPanel({
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 [commentModal, setCommentModal] = useState<CommentModalState>({
open: false,
caseId: "",
@@ -481,6 +497,15 @@ export function AutoRunsHistoryPanel({
saving: false,
error: ""
});
const [assistantLiveCommentModal, setAssistantLiveCommentModal] = useState<AssistantLiveCommentModalState>({
open: false,
messageIndex: -1,
rating: 3,
comment: "",
annotationAuthor: "manual_reviewer",
saving: false,
error: ""
});
const initialLoadDoneRef = useRef(false);
const asyncJobPollTimerRef = useRef<number | null>(null);
@@ -510,6 +535,27 @@ export function AutoRunsHistoryPanel({
}
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 annotationsAverageRating = useMemo(() => {
if (visibleAnnotations.length === 0) return null;
@@ -535,6 +581,44 @@ export function AutoRunsHistoryPanel({
[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 copyRunIdToClipboard = useCallback(
async (event: React.SyntheticEvent, runId: string) => {
event.stopPropagation();
@@ -580,11 +664,13 @@ export function AutoRunsHistoryPanel({
const resetAssistantLiveSession = useCallback(() => {
setAssistantLiveSessionId("");
setAssistantLiveConversation([]);
setAssistantLiveAnnotations([]);
setAssistantLiveInput("");
setAssistantLiveStatus("");
setAssistantLiveError("");
closeAssistantLiveCommentModal({ force: true });
log("Live-чат ассистента в истории автопрогонов сброшен.");
}, [log]);
}, [closeAssistantLiveCommentModal, log]);
const sendAssistantLiveMessage = useCallback(async () => {
const userMessage = assistantLiveInput.trim();
@@ -621,6 +707,7 @@ export function AutoRunsHistoryPanel({
});
setAssistantLiveSessionId(response.session_id);
setAssistantLiveConversation(response.conversation);
await loadAssistantLiveAnnotationsForSession(response.session_id);
setAssistantLiveStatus("Ответ готов");
log(`Live-ответ ассистента получен: trace=${response.debug.trace_id}`);
} catch (error) {
@@ -638,6 +725,7 @@ export function AutoRunsHistoryPanel({
assistantLiveUseMock,
assistantPromptVersion,
connection,
loadAssistantLiveAnnotationsForSession,
log,
prompts
]);
@@ -692,6 +780,20 @@ export function AutoRunsHistoryPanel({
[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 {
@@ -1202,6 +1304,94 @@ export function AutoRunsHistoryPanel({
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 applyLocalAnnotationPatch = useCallback((annotation: AutoRunAnnotationRecord) => {
setAnnotations((prev) =>
prev.map((item) =>
@@ -1316,6 +1506,14 @@ export function AutoRunsHistoryPanel({
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);
@@ -1375,6 +1573,9 @@ export function AutoRunsHistoryPanel({
if (typeof parsed.analysisDate === "string") {
setAnalysisDate(normalizeAnalysisDateInput(parsed.analysisDate));
}
if (typeof parsed.autogenPersonalityPromptHeight === "number") {
setAutogenPersonalityPromptHeight(clampAutogenPromptHeight(parsed.autogenPersonalityPromptHeight));
}
if (parsed.autoGenSettings) {
setAutoGenSettings((prev) => {
const nextPrompts: Record<string, string> = {
@@ -1431,6 +1632,7 @@ export function AutoRunsHistoryPanel({
const payload: AutoRunsUiConfig = {
filters,
analysisDate,
autogenPersonalityPromptHeight,
autoGenSettings: {
mode: autoGenSettings.mode,
count: autoGenSettings.count,
@@ -1443,7 +1645,7 @@ export function AutoRunsHistoryPanel({
hideResolvedAnnotations
};
localStorage.setItem(AUTORUNS_UI_CONFIG_KEY, JSON.stringify(payload));
}, [analysisDate, annotationDecisionFilter, autoGenSettings, filters, hideResolvedAnnotations]);
}, [analysisDate, annotationDecisionFilter, autoGenSettings, autogenPersonalityPromptHeight, filters, hideResolvedAnnotations]);
useEffect(() => {
const onSave = () => {
@@ -1596,9 +1798,6 @@ export function AutoRunsHistoryPanel({
</div>
<h4>Автогенерация вопросов</h4>
<p className="muted">
`qwen_seed` использует текущую LLM-модель из активного контура подключения (та же модель, что и для ответов ассистента).
</p>
<div className="autoruns-form-grid">
<label>
Режим генерации
@@ -1660,6 +1859,7 @@ export function AutoRunsHistoryPanel({
<label className="full-width">
Промпт личности
<textarea
className="autoruns-personality-prompt"
value={autoGenSettings.personalityPrompts[autoGenSettings.personalityId] ?? ""}
onChange={(event) =>
setAutoGenSettings((prev) => ({
@@ -1671,6 +1871,9 @@ export function AutoRunsHistoryPanel({
}))
}
placeholder="Текст промпта для выбранной личности автогенерации"
style={{ height: `${autogenPersonalityPromptHeight}px` }}
onMouseUp={captureAutogenPromptHeight}
onTouchEnd={captureAutogenPromptHeight}
/>
</label>
<label className="checkbox-row">
@@ -1698,9 +1901,6 @@ export function AutoRunsHistoryPanel({
</button>
</div>
</div>
<p className="muted">
Если дата среза задана, автопрогон анализирует данные на эту дату. Если поле пустое, используется текущее состояние.
</p>
<div className="button-row">
<button type="button" disabled={autoGenBusy} onClick={() => void generateAutogenBatch()}>
@@ -1711,7 +1911,7 @@ export function AutoRunsHistoryPanel({
</button>
<button
type="button"
className="tab"
className="autoruns-run-launch-btn"
disabled={autogenRunBusy || editableGeneratedQuestions.length === 0}
onClick={() => void runAutogenCampaign()}
>
@@ -1764,7 +1964,7 @@ export function AutoRunsHistoryPanel({
title="Удалить вопрос из запуска"
aria-label="Удалить вопрос из запуска"
>
X
+
</button>
</div>
))}
@@ -2071,6 +2271,10 @@ export function AutoRunsHistoryPanel({
busy={assistantLiveBusy}
statusText={assistantLiveStatus}
errorMessage={assistantLiveError}
showCommentAction
onCommentAssistantMessage={openAssistantLiveCommentModal}
isAssistantMessageCommented={isAssistantLiveMessageCommented}
canCommentAssistantMessage={canCommentAssistantLiveMessage}
/>
</div>
) : null}
@@ -2359,6 +2563,89 @@ export function AutoRunsHistoryPanel({
) : null}
</div>
{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">Комментарий сохраняется отдельно от комментариев автопрогонов.</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"
@@ -495,3 +495,32 @@ export interface AssistantMessageResultState {
debug: AssistantDebugState;
conversation: AssistantConversationItem[];
}
export interface AssistantAnnotationRecord {
annotation_id: string;
session_id: string;
message_id: string;
message_index: number;
rating: number;
comment: string;
annotation_author: string | null;
created_at: string;
updated_at: string;
context: {
trace_id: string | null;
reply_type: string | null;
question_text: string | null;
answer_text: string | null;
};
technical_context: Record<string, unknown> | null;
}
export interface AssistantAnnotationsResponse {
ok: boolean;
generated_at: string;
filters_applied: {
session_id: string | null;
limit: number;
};
items: AssistantAnnotationRecord[];
}
+182 -28
View File
@@ -363,6 +363,22 @@ textarea:focus {
textarea {
resize: vertical;
min-height: 86px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
}
.assistant-input-textarea,
.autoruns-personality-prompt {
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable both-edges;
border-bottom-right-radius: 6px;
}
.assistant-input-textarea::-webkit-scrollbar-corner,
.autoruns-personality-prompt::-webkit-scrollbar-corner {
background: rgb(var(--rgb-surface-horizontal));
}
button {
@@ -494,6 +510,7 @@ button:disabled {
.assistant-msg-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 0;
@@ -501,6 +518,35 @@ button:disabled {
color: var(--text-muted);
}
.assistant-msg-head-main {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.assistant-msg-head-actions {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.assistant-comment-btn {
cursor: pointer;
}
.assistant-comment-btn:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.assistant-msg.user .assistant-comment-btn {
color: rgba(var(--rgb-active-text), 0.92);
}
.assistant-msg-body {
white-space: pre-wrap;
line-height: 1.35;
@@ -539,6 +585,66 @@ button:disabled {
margin-left: auto;
}
.assistant-comments-frame .panel-body {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.assistant-comments-shell {
display: grid;
gap: 8px;
min-height: 0;
height: 100%;
}
.assistant-comments-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.assistant-comments-list {
display: grid;
gap: 8px;
overflow: auto;
min-height: 0;
padding-right: 2px;
}
.assistant-comment-item {
border: none;
border-radius: 10px;
background: rgb(var(--rgb-surface-horizontal));
padding: 8px;
display: grid;
gap: 6px;
}
.assistant-comment-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 0.75rem;
}
.assistant-comment-item p {
margin: 0;
white-space: pre-wrap;
font-size: 0.8rem;
}
.assistant-comment-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--text-muted);
font-size: 0.74rem;
}
.app-root-autoruns .assistant-panel-frame .panel-header {
position: sticky;
top: -12px;
@@ -733,7 +839,8 @@ button:disabled {
width: var(--mode-column-width);
height: 100%;
min-height: 0;
overflow: auto;
overflow-y: auto;
overflow-x: hidden;
border: none;
border-radius: 14px;
background: rgb(var(--rgb-surface-main));
@@ -1063,10 +1170,10 @@ button:disabled {
background: transparent;
color: rgb(var(--rgb-text-main));
border-radius: 0;
min-width: 20px;
min-height: 20px;
width: 20px;
height: 20px;
min-width: 16px;
min-height: 16px;
width: 16px;
height: 16px;
padding: 0;
line-height: 1;
box-shadow: none;
@@ -1074,24 +1181,37 @@ button:disabled {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0.92;
}
.autoruns-comment-icon:hover {
background: transparent;
color: rgb(var(--rgb-active));
color: rgb(var(--rgb-text-main));
opacity: 1;
box-shadow: none;
transform: none;
}
.autoruns-comment-icon.commented {
color: rgb(var(--rgb-active-text));
color: rgb(var(--rgb-active));
background: transparent;
box-shadow: none;
}
.autoruns-comment-icon:focus-visible {
outline: 1px solid rgba(var(--rgb-text-main), 0.7);
outline-offset: 1px;
}
.autoruns-comment-icon:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.comment-icon-svg {
width: 20px;
height: 20px;
width: 0.82rem;
height: 0.82rem;
stroke: currentColor;
stroke-width: 1.75;
stroke-linecap: round;
@@ -1099,17 +1219,9 @@ button:disabled {
fill: none;
}
.comment-icon-svg .comment-icon-dot {
fill: currentColor;
}
.comment-icon-svg.commented {
fill: rgb(var(--rgb-active));
stroke: rgb(var(--rgb-active));
}
.autoruns-comment-icon.commented .comment-icon-dot {
fill: rgb(var(--rgb-active-text));
fill: none;
}
.autoruns-resolve-toggle {
@@ -1224,6 +1336,21 @@ button:disabled {
font-size: 0.8rem;
}
.autoruns-run-launch-btn {
background: rgb(var(--rgb-active));
color: rgb(var(--rgb-active-text));
}
.autoruns-run-launch-btn:hover {
background: rgb(var(--rgb-surface-focus));
color: rgb(var(--rgb-text-main));
}
.autoruns-run-launch-btn:disabled {
background: rgba(var(--rgb-active), 0.38);
color: rgba(var(--rgb-active-text), 0.88);
}
.autoruns-generated-questions {
border: none;
border-radius: 10px;
@@ -1249,31 +1376,58 @@ button:disabled {
}
.autoruns-generated-question-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
position: relative;
display: block;
gap: 8px;
border: none;
border-radius: 9px;
background: rgb(var(--rgb-surface-focus));
padding: 6px 8px;
padding: 7px 30px 7px 8px;
font-size: 0.78rem;
}
.autoruns-generated-question-item span {
display: block;
white-space: pre-wrap;
}
.autoruns-remove-question-btn {
position: absolute;
top: 6px;
right: 6px;
flex: 0 0 auto;
border: none;
border-radius: 7px;
background: rgb(var(--rgb-surface-horizontal));
color: var(--text-main);
min-width: 24px;
height: 24px;
font-size: 0.75rem;
border-radius: 0;
background: transparent;
color: rgb(var(--rgb-text-main));
min-width: 16px;
width: 16px;
height: 16px;
padding: 0;
font-size: 1rem;
font-weight: 700;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
transform: rotate(45deg);
box-shadow: none;
transition: color 0.15s ease;
}
.autoruns-remove-question-btn:hover {
background: transparent;
color: rgb(var(--rgb-active-text));
box-shadow: none;
}
.autoruns-remove-question-btn:focus-visible {
outline: none;
}
.autoruns-personality-prompt {
resize: vertical;
min-height: 110px;
}
.autoruns-comment-item {