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; 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 { 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 ( ); } 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({text.slice(lastIndex, match.index)}); partIndex += 1; } result.push({match[1]}); partIndex += 1; lastIndex = regex.lastIndex; } if (lastIndex < text.length) { result.push({text.slice(lastIndex)}); } return result.length > 0 ? result : [{text}]; } 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) => (

{renderInlineBold(line, `line-${blockIndex}-${lineIndex}`)}

)); if (!isSelectable || !selectionChip) { return (
{body}
); } return (
{ if (selected) { onClearContextChip(); return; } onSelectContextChip(selectionChip); }} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") { return; } event.preventDefault(); if (selected) { onClearContextChip(); return; } onSelectContextChip(selectionChip); }} > {body}
); }); } 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(null); const stickToBottomRef = useRef(true); const copyResetTimerRef = useRef(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 { 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 (
{sessionId ? {`session: ${sessionId}`} : null}
{statusText ? {statusText} : null} {copyState === "success" ? Скопировано ({copyModeLabel}) : null} {copyState === "error" ? Ошибка копирования : null}
{errorMessage ?

{errorMessage}

: null}
{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 (
{roleLabel(item.role)} {shortTime(item.created_at)}
{item.role === "assistant" && showCommentAction ? (
) : null}
{renderAssistantMessageBody(item, selectedContextChip, onSelectContextChip, onClearContextChip)}
{item.role === "assistant" && item.debug ? (
Показать технический разбор
) : null}
); })}
{selectedContextChip ? (
Выбранный объект
{selectedContextChip.preview_text}
) : null}