ДОМЕНЫ - ВОПРОСЫ - fix(ui-assistant-chat): render markdown bold, add readable block spacing, force scroll-to-bottom on send + ЮИ

This commit is contained in:
2026-04-12 17:53:31 +03:00
parent a717ea6b26
commit ae9daa50e7
10 changed files with 289 additions and 86 deletions
@@ -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>
+26 -1
View File
@@ -548,11 +548,36 @@ button:disabled {
}
.assistant-msg-body {
white-space: pre-wrap;
display: grid;
gap: 10px;
line-height: 1.35;
font-size: 0.84rem;
}
.assistant-msg-block {
display: grid;
gap: 4px;
}
.assistant-msg-line {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.assistant-msg-line.heading {
font-weight: 700;
letter-spacing: 0.01em;
}
.assistant-msg-line.numbered {
margin-top: 2px;
}
.assistant-msg-line strong {
font-weight: 800;
}
.assistant-trace {
margin-top: 6px;
color: var(--text-muted);