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