Stage 2 завершён: problem-first ответы и follow-up continuity - ассистент переведён от entity-heavy логики к problem-first ответам с problem-unit слоем, удержанием контекста в follow-up и очисткой пользовательского ответа от сырых технических ссылок.
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user