Stage 2 завершён: problem-first ответы и follow-up continuity - ассистент переведён от entity-heavy логики к problem-first ответам с problem-unit слоем, удержанием контекста в follow-up и очисткой пользовательского ответа от сырых технических ссылок.

This commit is contained in:
2026-03-26 14:53:52 +03:00
parent ece1abed76
commit 96353cfd48
2474 changed files with 21678 additions and 3292445 deletions
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import type { AssistantConversationItem } from "../state/types";
import { JsonView } from "./JsonView";
import { PanelFrame } from "./PanelFrame";
@@ -33,6 +33,77 @@ function shortTime(iso: string): string {
return date.toLocaleTimeString("ru-RU");
}
function stringifyDebug(value: unknown): string {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function buildConversationExport(sessionId: string, conversation: AssistantConversationItem[]): string {
const lines: string[] = [];
lines.push("# Assistant conversation export");
lines.push(`session_id: ${sessionId || "n/a"}`);
lines.push(`exported_at: ${new Date().toISOString()}`);
lines.push("");
for (let index = 0; index < conversation.length; index += 1) {
const item = conversation[index];
lines.push(`## ${index + 1}. ${item.role}`);
lines.push(`message_id: ${item.message_id}`);
lines.push(`created_at: ${item.created_at}`);
lines.push(`reply_type: ${item.reply_type ?? "n/a"}`);
if (item.trace_id) {
lines.push(`trace_id: ${item.trace_id}`);
}
lines.push("");
lines.push(item.text || "(empty)");
lines.push("");
if (item.role === "assistant" && item.debug) {
lines.push("### technical_breakdown_json");
lines.push("```json");
lines.push(stringifyDebug(item.debug));
lines.push("```");
lines.push("");
}
}
return lines.join("\n");
}
async function copyTextToClipboard(text: string): Promise<boolean> {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fall back to legacy path below.
}
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
textarea.style.pointerEvents = "none";
document.body.appendChild(textarea);
textarea.select();
let copied = false;
try {
copied = document.execCommand("copy");
} catch {
copied = false;
} finally {
document.body.removeChild(textarea);
}
return copied;
}
export function AssistantPanel({
sessionId,
conversation,
@@ -51,6 +122,8 @@ export function AssistantPanel({
errorMessage
}: AssistantPanelProps) {
const listRef = useRef<HTMLDivElement | null>(null);
const copyResetTimerRef = useRef<number | null>(null);
const [copyState, setCopyState] = useState<"idle" | "success" | "error">("idle");
useEffect(() => {
if (listRef.current) {
@@ -58,11 +131,51 @@ export function AssistantPanel({
}
}, [conversation, statusText]);
useEffect(() => {
return () => {
if (copyResetTimerRef.current !== null) {
window.clearTimeout(copyResetTimerRef.current);
}
};
}, []);
async function handleCopyConversation(): Promise<void> {
if (conversation.length === 0) {
return;
}
const exportText = buildConversationExport(sessionId, conversation);
const copied = await copyTextToClipboard(exportText);
setCopyState(copied ? "success" : "error");
if (copyResetTimerRef.current !== null) {
window.clearTimeout(copyResetTimerRef.current);
}
copyResetTimerRef.current = window.setTimeout(() => {
setCopyState("idle");
}, 2200);
}
return (
<PanelFrame
title="Режим ассистента"
subtitle="Диалоговый слой поверх normalizer, маршрутизации и factual retrieval."
actions={<span className="status-chip">{sessionId ? `session: ${sessionId}` : "новая сессия"}</span>}
actions={
<div className="assistant-panel-actions">
<button
type="button"
className="assistant-copy-btn"
onClick={() => {
void handleCopyConversation();
}}
disabled={conversation.length === 0}
>
Скопировать чат
</button>
{copyState === "success" ? <span className="assistant-copy-feedback success">Скопировано</span> : null}
{copyState === "error" ? <span className="assistant-copy-feedback error">Ошибка копирования</span> : null}
<span className="status-chip">{sessionId ? `session: ${sessionId}` : "новая сессия"}</span>
</div>
}
>
<div ref={listRef} className="assistant-chat-list">
{conversation.length === 0 ? (
+36
View File
@@ -117,6 +117,42 @@ body,
font-size: 0.78rem;
}
.assistant-panel-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.assistant-copy-btn {
background: transparent;
border-color: var(--line);
color: var(--text-main);
box-shadow: none;
transform: none;
}
.assistant-copy-btn:hover {
background: rgba(143, 255, 173, 0.14);
filter: none;
box-shadow: none;
transform: none;
}
.assistant-copy-feedback {
font-size: 0.76rem;
color: var(--text-muted);
}
.assistant-copy-feedback.success {
color: var(--lime-main);
}
.assistant-copy-feedback.error {
color: var(--danger);
}
input,
select,
textarea,