510 lines
17 KiB
TypeScript
510 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|