ДОМЕНЫ - ВОПРОСЫ - fix(ui-assistant-chat): render markdown bold, add readable block spacing, force scroll-to-bottom on send + ЮИ
This commit is contained in:
@@ -74,6 +74,95 @@ function CommentBubbleIcon({ commented }: { commented: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
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 renderAssistantMessageBody(text: string): JSX.Element[] {
|
||||
const blocks = splitAssistantMessageBlocks(text);
|
||||
return blocks.map((block, blockIndex) => {
|
||||
const lines = block.split("\n");
|
||||
return (
|
||||
<div key={`block-${blockIndex}`} className="assistant-msg-block">
|
||||
{lines.map((line, lineIndex) => (
|
||||
<p key={`line-${blockIndex}-${lineIndex}`} className={lineClassName(line)}>
|
||||
{renderInlineBold(line, `line-${blockIndex}-${lineIndex}`)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function AssistantPanel({
|
||||
sessionId,
|
||||
conversation,
|
||||
@@ -97,9 +186,17 @@ export function AssistantPanel({
|
||||
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 (listRef.current && stickToBottomRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
if (stickToBottomRef.current) {
|
||||
scrollChatToBottom();
|
||||
}
|
||||
}, [conversation]);
|
||||
|
||||
@@ -219,7 +316,7 @@ export function AssistantPanel({
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
<div className="assistant-msg-body">{item.text}</div>
|
||||
<div className="assistant-msg-body">{renderAssistantMessageBody(item.text)}</div>
|
||||
{item.role === "assistant" && item.debug ? (
|
||||
<details className="assistant-debug">
|
||||
<summary>Показать технический разбор</summary>
|
||||
@@ -247,7 +344,15 @@ export function AssistantPanel({
|
||||
<input type="checkbox" checked={useMock} onChange={(event) => onUseMockChange(event.target.checked)} />
|
||||
Mock-режим
|
||||
</label>
|
||||
<button type="button" className="assistant-send-btn" onClick={() => onSend()} disabled={busy || !inputValue.trim()}>
|
||||
<button
|
||||
type="button"
|
||||
className="assistant-send-btn"
|
||||
onClick={() => {
|
||||
scrollChatToBottom(true);
|
||||
void onSend();
|
||||
}}
|
||||
disabled={busy || !inputValue.trim()}
|
||||
>
|
||||
{busy ? "Выполняю..." : "Отправить"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user