ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Рефакторинг этапов 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
+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 {