NODEDC_1C/llm_normalizer/frontend/src/components/AssistantPanel.tsx

510 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from "react";
import type { AssistantConversationItem, AssistantSelectionChip } from "../state/types";
import { buildConversationExportForCopy } from "../utils/conversationExport";
import { JsonView } from "./JsonView";
import { PanelFrame } from "./PanelFrame";
interface AssistantPanelProps {
sessionId: string;
conversation: AssistantConversationItem[];
inputValue: string;
onInputChange: (value: string) => void;
selectedContextChip: AssistantSelectionChip | null;
onSelectContextChip: (value: AssistantSelectionChip) => void;
onClearContextChip: () => void;
useMock: boolean;
onUseMockChange: (value: boolean) => void;
onSend: () => Promise<void> | void;
onClear: () => void;
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 {
return role === "assistant" ? "Ассистент" : "Вы";
}
function shortTime(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return iso;
}
return date.toLocaleTimeString("ru-RU");
}
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;
}
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>
);
}
function normalizeAssistantMessageText(raw: string): string {
return raw
.replace(/\r\n?/g, "\n")
.replace(/([^\n])\s+(Блок\s+\d+\.)/gi, "$1\n\n$2")
.replace(/([^\n])\s+(\d+\.\s)/g, "$1\n$2");
}
function splitAssistantMessageBlocks(text: string): string[] {
const normalized = normalizeAssistantMessageText(text);
const lines = normalized.split("\n");
const blocks: string[] = [];
let current: string[] = [];
const flush = () => {
if (current.length === 0) return;
blocks.push(current.join("\n"));
current = [];
};
for (const rawLine of lines) {
const line = rawLine.trimEnd();
const trimmed = line.trim();
if (!trimmed) {
flush();
continue;
}
const isBlockHeading = /^Блок\s+\d+\./i.test(trimmed);
const isNumberedItem = /^\d+\.\s/.test(trimmed);
if ((isBlockHeading || isNumberedItem) && current.length > 0) {
flush();
}
current.push(line);
}
flush();
return blocks.length > 0 ? blocks : [text];
}
function renderInlineBold(text: string, keyPrefix: string): JSX.Element[] {
const result: JSX.Element[] = [];
const regex = /\*\*(.+?)\*\*/g;
let lastIndex = 0;
let partIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
result.push(<span key={`${keyPrefix}-t-${partIndex}`}>{text.slice(lastIndex, match.index)}</span>);
partIndex += 1;
}
result.push(<strong key={`${keyPrefix}-b-${partIndex}`}>{match[1]}</strong>);
partIndex += 1;
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
result.push(<span key={`${keyPrefix}-t-${partIndex}`}>{text.slice(lastIndex)}</span>);
}
return result.length > 0 ? result : [<span key={`${keyPrefix}-raw`}>{text}</span>];
}
function lineClassName(line: string): string {
const trimmed = line.trimStart();
if (/^Блок\s+\d+\./i.test(trimmed)) return "assistant-msg-line heading";
if (/^\d+\.\s/.test(trimmed)) return "assistant-msg-line numbered";
if (/^-\s/.test(trimmed)) return "assistant-msg-line bullet";
return "assistant-msg-line";
}
function buildSelectionPreview(label: string, maxChars = 40): string {
const normalized = label.replace(/\s+/g, " ").trim();
if (normalized.length <= maxChars) {
return normalized;
}
const firstWords = normalized.split(" ").slice(0, 3).join(" ").trim();
if (firstWords.length >= 10 && firstWords.length <= maxChars) {
return `${firstWords}`;
}
return `${normalized.slice(0, maxChars - 1).trimEnd()}`;
}
function stripBlockSelectionPrefix(line: string): string {
return line.replace(/\*\*(.+?)\*\*/g, "$1").replace(/^\d+\.\s*/, "").trim();
}
function extractSelectionAnchorText(block: string): string {
const firstLine = block
.replace(/\r\n?/g, "\n")
.split("\n")
.map((line) => line.trim())
.find(Boolean);
const normalizedFirstLine = stripBlockSelectionPrefix(firstLine ?? "");
const primarySegment = normalizedFirstLine.split("|")[0]?.trim() ?? normalizedFirstLine;
return primarySegment.replace(/\s+/g, " ").trim();
}
function isSelectableEntityBlock(block: string): boolean {
const firstLine = block
.replace(/\r\n?/g, "\n")
.split("\n")
.map((line) => line.trim())
.find(Boolean);
if (!firstLine || !/^\d+\.\s/.test(firstLine)) {
return false;
}
const normalizedFirstLine = stripBlockSelectionPrefix(firstLine);
return normalizedFirstLine.includes("|");
}
function buildSelectionChipForBlock(item: AssistantConversationItem, block: string): AssistantSelectionChip | null {
const normalizedBlock = block
.replace(/\r\n?/g, "\n")
.replace(/\*\*(.+?)\*\*/g, "$1")
.split("\n")
.map((line, index) => {
const trimmed = line.trim();
if (index === 0) {
return trimmed.replace(/^\d+\.\s*/, "");
}
return trimmed;
})
.filter(Boolean)
.join(" ")
.replace(/\s+/g, " ")
.trim();
if (!normalizedBlock) {
return null;
}
const anchorText = extractSelectionAnchorText(block) || normalizedBlock;
return {
message_id: item.message_id,
source_text: normalizedBlock,
anchor_text: anchorText,
preview_text: buildSelectionPreview(anchorText)
};
}
function renderAssistantMessageBody(
item: AssistantConversationItem,
selectedContextChip: AssistantSelectionChip | null,
onSelectContextChip: (value: AssistantSelectionChip) => void,
onClearContextChip: () => void
): JSX.Element[] {
const blocks = splitAssistantMessageBlocks(item.text);
return blocks.map((block, blockIndex) => {
const lines = block.split("\n");
const isSelectable = item.role === "assistant" && isSelectableEntityBlock(block);
const selectionChip = isSelectable ? buildSelectionChipForBlock(item, block) : null;
const selected =
Boolean(selectionChip) &&
selectedContextChip?.message_id === selectionChip?.message_id &&
selectedContextChip?.source_text === selectionChip?.source_text;
const body = lines.map((line, lineIndex) => (
<p key={`line-${blockIndex}-${lineIndex}`} className={lineClassName(line)}>
{renderInlineBold(line, `line-${blockIndex}-${lineIndex}`)}
</p>
));
if (!isSelectable || !selectionChip) {
return (
<div key={`block-${blockIndex}`} className="assistant-msg-block">
{body}
</div>
);
}
return (
<div
key={`block-${blockIndex}`}
className={selected ? "assistant-msg-block selectable active" : "assistant-msg-block selectable"}
role="button"
tabIndex={0}
onClick={() => {
if (selected) {
onClearContextChip();
return;
}
onSelectContextChip(selectionChip);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
if (selected) {
onClearContextChip();
return;
}
onSelectContextChip(selectionChip);
}}
>
{body}
</div>
);
});
}
export function AssistantPanel({
sessionId,
conversation,
inputValue,
onInputChange,
selectedContextChip,
onSelectContextChip,
onClearContextChip,
useMock,
onUseMockChange,
onSend,
onClear,
busy,
statusText,
errorMessage,
showCommentAction = false,
onCommentAssistantMessage,
isAssistantMessageCommented,
canCommentAssistantMessage
}: AssistantPanelProps) {
const listRef = useRef<HTMLDivElement | null>(null);
const stickToBottomRef = useRef(true);
const copyResetTimerRef = useRef<number | null>(null);
const [copyState, setCopyState] = useState<"idle" | "success" | "error">("idle");
const [copyModeLabel, setCopyModeLabel] = useState<"чат" | "тех">("чат");
function scrollChatToBottom(forceStick = false): void {
if (!listRef.current) return;
if (forceStick) {
stickToBottomRef.current = true;
}
listRef.current.scrollTop = listRef.current.scrollHeight;
}
useEffect(() => {
if (stickToBottomRef.current) {
scrollChatToBottom();
}
}, [conversation]);
useEffect(() => {
return () => {
if (copyResetTimerRef.current !== null) {
window.clearTimeout(copyResetTimerRef.current);
}
};
}, []);
async function handleCopyConversation(mode: "default" | "technical"): Promise<void> {
if (conversation.length === 0) {
return;
}
const exportText = buildConversationExportForCopy(sessionId, conversation, mode);
const copied = await copyTextToClipboard(exportText);
setCopyModeLabel(mode === "technical" ? "тех" : "чат");
setCopyState(copied ? "success" : "error");
if (copyResetTimerRef.current !== null) {
window.clearTimeout(copyResetTimerRef.current);
}
copyResetTimerRef.current = window.setTimeout(() => {
setCopyState("idle");
}, 2200);
}
function handleChatScroll(): void {
if (!listRef.current) return;
const node = listRef.current;
const distanceToBottom = node.scrollHeight - node.scrollTop - node.clientHeight;
stickToBottomRef.current = distanceToBottom < 16;
}
return (
<PanelFrame className="assistant-panel-frame" title="Режим ассистента">
<div className="assistant-live-shell">
<div className="assistant-toolbar">
<div className="assistant-toolbar-actions">
<button
type="button"
className="assistant-copy-btn"
onClick={() => {
void handleCopyConversation("default");
}}
disabled={conversation.length === 0}
title="Экспорт только user-facing чата"
>
Скопировать чат
</button>
<button
type="button"
className="assistant-copy-btn"
onClick={() => {
void handleCopyConversation("technical");
}}
disabled={conversation.length === 0}
title="Технический экспорт с debug payload"
>
Скопировать техчат
</button>
<button type="button" className="assistant-copy-btn" onClick={() => onClear()} disabled={busy && conversation.length === 0}>
Сбросить сессию
</button>
</div>
<div className="assistant-toolbar-meta">
{sessionId ? <span className="status-chip">{`session: ${sessionId}`}</span> : null}
<div className="assistant-toolbar-meta-right">
{statusText ? <span className="assistant-live-status">{statusText}</span> : null}
{copyState === "success" ? <span className="assistant-copy-feedback success">Скопировано ({copyModeLabel})</span> : null}
{copyState === "error" ? <span className="assistant-copy-feedback error">Ошибка копирования</span> : null}
</div>
</div>
{errorMessage ? <p className="error-text assistant-toolbar-error">{errorMessage}</p> : null}
</div>
<div ref={listRef} className="assistant-chat-list" onScroll={handleChatScroll}>
{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">
<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">
{renderAssistantMessageBody(item, selectedContextChip, onSelectContextChip, onClearContextChip)}
</div>
{item.role === "assistant" && item.debug ? (
<details className="assistant-debug">
<summary>Показать технический разбор</summary>
<JsonView value={item.debug} />
</details>
) : null}
</article>
);
})}
</div>
<div className="assistant-compose">
{selectedContextChip ? (
<div className="assistant-compose-context">
<span className="assistant-compose-context-label">Выбранный объект</span>
<div className="assistant-compose-context-pill" title={selectedContextChip.source_text}>
<span className="assistant-compose-context-pill-text">{selectedContextChip.preview_text}</span>
<button
type="button"
className="assistant-compose-context-clear"
onClick={onClearContextChip}
aria-label="Убрать выбранный объект"
title="Убрать выбранный объект"
>
×
</button>
</div>
</div>
) : null}
<label className="full-width">
Сообщение
<textarea
className="assistant-input-textarea"
value={inputValue}
onChange={(event) => onInputChange(event.target.value)}
rows={4}
placeholder={
selectedContextChip
? "Продолжите вопрос по выбранному объекту..."
: "Введите вопрос к данным компании..."
}
/>
</label>
<div className="button-row assistant-send-row">
<label className="checkbox-row">
<input type="checkbox" checked={useMock} onChange={(event) => onUseMockChange(event.target.checked)} />
Mock-режим
</label>
<button
type="button"
className="assistant-send-btn"
onClick={() => {
scrollChatToBottom(true);
void onSend();
}}
disabled={busy || !inputValue.trim()}
>
{busy ? "Выполняю..." : "Отправить"}
</button>
</div>
</div>
</div>
</PanelFrame>
);
}