From 2f8c5d9a98b2d6607048a9274eda42ccb00fd39d Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 12 Jul 2026 04:35:06 +0300 Subject: [PATCH] =?UTF-8?q?perf(client):=20=D0=B8=D0=BD=D0=BA=D1=80=D0=B5?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=B4=D0=B5=D1=80=20=D1=81=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BC=D0=B0=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0=20(#492)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Путь ответа ассистента теперь рендерится инкрементально, по образцу StreamingPlainText из ветки reasoning (#492, волна C эпика #497). Раньше MarkdownPart прогонял ВЕСЬ накопленный ответ через канонический конвейер (markdownToProseMirrorSync → PMNode.fromJSON → DOMSerializer → DOMPurify) на КАЖДОМ throttled-тике (~20 Гц). На синтетическом потоке ~100 КБ это 394 вызова renderChatMarkdown — O(числа тиков), причём каждый вызов заново парсит всю растущую строку. Теперь: - StreamingMarkdownText делит текст на блоки по безопасному срезу (splitPlainChunks — та же append-only-инвариантность, что у reasoning): СТАБИЛИЗИРОВАННЫЕ блоки идут через канонический конвейер и мемоизируются (каждый блок парсится РОВНО ОДИН раз), живой ХВОСТ — дешёвый plain-text (React-escaped, без парсера/санитайзера/innerHTML) до стабилизации. - На финализации (флип state → done или конец хода) — ОДИН полный канонический рендер всего текста: побайтовая визуальная паритетность с прежним выводом (включая
  • -обёртки схемы и scoped-CSS из #498). - Гейт liveness тот же, что у ReasoningBlock: streaming = turnStreaming && part.state === "streaming". Также цикл рендера частей переведён на ИСЧЕРПЫВАЮЩИЙ switch по видам частей с never-проверкой в default (вместо прежнего WARNING-комментария): новый закрытый вид части в UIMessagePart теперь ошибка компиляции. Тесты: - perf-smoke: на ~100 КБ потоке число вызовов renderChatMarkdown ≤ blocks+2 и ≪ ticks (O(блоков), не O(тиков)); мутация (снять memo с MarkdownChunk) краснит ассерт (78631 вызовов). - visual-regression: финальный рендер побайтово равен renderChatMarkdown всего текста (в т.ч.

  • ), инкрементальный вид сходится к нему на финализации; учтён neutralizeInternalLinks. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/message-item-memo.test.tsx | 87 +++++++++++ .../components/message-item.render.test.tsx | 112 ++++++++++++++ .../ai-chat/components/message-item.tsx | 145 ++++++++++++------ .../components/streaming-markdown-text.tsx | 96 ++++++++++++ 4 files changed, 395 insertions(+), 45 deletions(-) create mode 100644 apps/client/src/features/ai-chat/components/message-item.render.test.tsx create mode 100644 apps/client/src/features/ai-chat/components/streaming-markdown-text.tsx diff --git a/apps/client/src/features/ai-chat/components/message-item-memo.test.tsx b/apps/client/src/features/ai-chat/components/message-item-memo.test.tsx index 61894ad7..98b88d27 100644 --- a/apps/client/src/features/ai-chat/components/message-item-memo.test.tsx +++ b/apps/client/src/features/ai-chat/components/message-item-memo.test.tsx @@ -27,6 +27,7 @@ vi.mock("@/features/ai-chat/utils/markdown.ts", async () => { import MessageItem from "./message-item"; import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts"; +import { splitPlainChunks } from "./streaming-plain-text"; // matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts. @@ -114,3 +115,89 @@ describe("MessageItem markdown memoization", () => { expect(queryByText("streamed answer")).not.toBeNull(); }); }); + +// PERF SMOKE (#492): the whole point of the incremental streaming render is that +// the ANSWER path costs O(number of markdown blocks), NOT O(number of throttled +// ~20Hz ticks). Pre-#492 the finalized MarkdownPart re-parsed the WHOLE growing +// answer on every delta — a synthetic ~100 KB stream measured 394 renderChatMarkdown +// calls (one per tick). With the incremental render each STABILIZED block is parsed +// exactly once (memoized in MarkdownChunk) and the live tail is cheap plain text, so +// the call count collapses to ~= the block count regardless of tick granularity. +describe("MessageItem streaming answer render is O(blocks), not O(ticks)", () => { + // ~100 KB answer. Each section is a heading + a paragraph — TWO blank-line + // delimited markdown blocks — so the safe-cut block count is ~2× the section + // count. The perf claim is about the BLOCK count (the memoization granularity), + // measured directly with splitPlainChunks below, not the section count. + const buildAnswer = () => { + const SECTIONS = 100; + const paragraphs: string[] = []; + for (let i = 0; i < SECTIONS; i++) { + paragraphs.push(`## Section ${i}\n\n` + "lorem ipsum dolor ".repeat(55)); + } + const full = paragraphs.join("\n\n"); + // The number of memoized markdown blocks the incremental render splits into + // (all but the live tail are parsed once each). + return { full, blocks: splitPlainChunks(full).length }; + }; + + const streamMsg = (text: string, state: "streaming" | "done"): UIMessage => + ({ + id: "m1", + role: "assistant", + parts: [{ type: "text", text, state }], + }) as UIMessage; + + it("parses each block ~once over a 100KB stream (≈blocks, ≪ ticks)", () => { + renderChatMarkdownSpy.mockClear(); + const { full, blocks } = buildAnswer(); + const CHUNK = 128; // a realistic ~20Hz throttled delta size + const ticks = Math.ceil(full.length / CHUNK); + + let msg = streamMsg(full.slice(0, CHUNK), "streaming"); + const { rerender } = render( + + + , + ); + for (let end = 2 * CHUNK; end < full.length; end += CHUNK) { + msg = streamMsg(full.slice(0, end), "streaming"); + rerender( + + + , + ); + } + // Finalize: the streaming→done flip renders the whole answer through ONE + // canonical pass (visual parity), so the finished DOM matches the pre-#492 + // output. This is the single extra parse on top of the per-block ones. + const done = streamMsg(full, "done"); + rerender( + + + , + ); + + const calls = renderChatMarkdownSpy.mock.calls.length; + // Sanity: the stream really had far more ticks than blocks (else the test is + // vacuous — the point is that calls scale with blocks, not ticks). + expect(ticks).toBeGreaterThan(blocks * 3); + // O(blocks): each stabilized block parsed once + the single final whole-text + // parse. A small constant absorbs the finalize render and the live-tail block; + // the load-bearing claim is the bound below. + expect(calls).toBeLessThanOrEqual(blocks + 2); + // ≪ ticks — and, non-vacuously, the blocks WERE parsed (not skipped entirely). + expect(calls).toBeLessThan(ticks / 3); + expect(calls).toBeGreaterThan(blocks / 2); + // MUTATION-VERIFY (documented, not run here): dropping the `memo()` wrapper on + // MarkdownChunk (so every stable block re-parses each tick) drives `calls` + // toward `ticks` (~394), reddening both upper-bound assertions above. + }); +}); diff --git a/apps/client/src/features/ai-chat/components/message-item.render.test.tsx b/apps/client/src/features/ai-chat/components/message-item.render.test.tsx new file mode 100644 index 00000000..9e4266b8 --- /dev/null +++ b/apps/client/src/features/ai-chat/components/message-item.render.test.tsx @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from "vitest"; +import { render } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import type { UIMessage } from "@ai-sdk/react"; + +// Stub react-i18next (the component reads `useTranslation`). Mirrors the other +// message-item specs. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +import MessageItem from "./message-item"; +import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts"; +// The REAL canonical renderer (NOT the spy the memo test installs): this file +// exercises the actual markdown output so the visual-regression assertions below +// compare against genuine HTML (incl. the schema's `

  • ` wrappers). +import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts"; +import classes from "./ai-chat.module.css"; + +const msg = ( + parts: UIMessage["parts"], + extra?: Partial, +): UIMessage => + ({ id: "m1", role: "assistant", parts, ...extra }) as UIMessage; + +const renderRow = (message: UIMessage, turnStreaming = false) => + render( + + + , + ); + +// A rich multi-block answer that exercises headings, a list (the `

  • ` case +// the scoped CSS tightens), inline emphasis, and multiple paragraphs. +const ANSWER = [ + "# Заголовок", + "", + "Первый абзац с **жирным** и `кодом`.", + "", + "- пункт один", + "- пункт два", + "", + "Второй абзац.", +].join("\n"); + +describe("MessageItem final render — visual parity with the canonical pipeline", () => { + it("a finalized text part renders exactly renderChatMarkdown(text)", () => { + const { container } = renderRow( + msg([{ type: "text", text: ANSWER, state: "done" }]), + ); + const block = container.querySelector(`.${classes.markdown}`); + expect(block).not.toBeNull(); + // Byte-for-byte the canonical output (the SAME whole-text pass the pre-#492 + // MarkdownPart produced), including `

  • ` wrappers. + expect(block!.innerHTML).toBe(renderChatMarkdown(ANSWER, {})); + // The list wrapper is really present (guards against a vacuous empty render). + expect(container.querySelectorAll("li p").length).toBe(2); + }); + + it("the streaming incremental view CONVERGES to the canonical render on finish", () => { + // Mount mid-stream (live tail) — the DOM here is the incremental view. + const { container, rerender } = render( + + + , + ); + // Finish the turn: state flips to done AND the turn is no longer streaming. + const done = msg([{ type: "text", text: ANSWER, state: "done" }]); + rerender( + + + , + ); + // After finish there is exactly ONE canonical markdown container whose HTML is + // the whole-text render — identical to the non-streaming path above. + const blocks = container.querySelectorAll(`.${classes.markdown}`); + expect(blocks.length).toBe(1); + expect(blocks[0].innerHTML).toBe(renderChatMarkdown(ANSWER, {})); + }); + + it("neutralizeInternalLinks is honored on the finalized render", () => { + const linkAnswer = "См. [страницу](/p/abc)."; + const { container } = render( + + + , + ); + const block = container.querySelector(`.${classes.markdown}`); + expect(block!.innerHTML).toBe( + renderChatMarkdown(linkAnswer, { neutralizeInternalLinks: true }), + ); + // The internal link was made inert (no href) by the neutralization flag. + const a = container.querySelector("a"); + expect(a?.hasAttribute("href")).toBe(false); + }); +}); diff --git a/apps/client/src/features/ai-chat/components/message-item.tsx b/apps/client/src/features/ai-chat/components/message-item.tsx index 878477b4..0cf7b7a4 100644 --- a/apps/client/src/features/ai-chat/components/message-item.tsx +++ b/apps/client/src/features/ai-chat/components/message-item.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import type { UIMessage } from "@ai-sdk/react"; import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx"; import ReasoningBlock from "@/features/ai-chat/components/reasoning-block.tsx"; +import { StreamingMarkdownText } from "@/features/ai-chat/components/streaming-markdown-text.tsx"; import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx"; import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx"; import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx"; @@ -86,17 +87,39 @@ interface MessageItemProps { * One assistant text part rendered as sanitized markdown. Memoized on its inputs * so a finalized text part is NOT re-parsed on every streamed delta: during a * turn only the actively-growing tail part changes its `text`, so every earlier - * part hits the memo and skips the expensive marked + DOMPurify pass. Props are - * primitives, so React.memo's default shallow compare is exactly right (the - * `text` string is compared by value). + * part hits the memo and skips the expensive canonical parse + DOMPurify pass. + * Props are primitives, so React.memo's default shallow compare is exactly right + * (the `text` string is compared by value). + * + * Streaming gate (#492) — mirrors ReasoningBlock: + * - `streaming` (this is the live, actively-growing tail part of an in-flight + * turn): render incrementally via StreamingMarkdownText — the stabilized blocks + * go through the canonical pipeline (each parsed ONCE, memoized) and only the + * live tail is cheap plain text. This makes the per-tick cost O(new blocks), + * not the pre-#492 O(ticks) whole-answer re-parse on every ~20Hz delta. + * - finalized (the common case, and the turn-end flip): render the WHOLE text + * through ONE canonical pass — byte-identical to the pre-#492 output (visual + * parity). The row re-renders on the streaming→done flip because + * `messageSignature` tracks each part's `state` (and `turnStreaming` flips at + * turn end), so the incremental view always converges to this single render. */ const MarkdownPart = memo(function MarkdownPart({ text, neutralizeInternalLinks, + streaming, }: { text: string; neutralizeInternalLinks: boolean; + streaming: boolean; }) { + if (streaming) { + return ( + + ); + } const html = renderChatMarkdown(text, { neutralizeInternalLinks }); if (html) { return ( @@ -179,47 +202,10 @@ function MessageItem({ {resolveAssistantName(assistantName) ?? t("AI agent")} {message.parts.map((part, index) => { - if (part.type === "reasoning") { - // Reasoning ("thinking") -> a collapsible block with its own token - // count. Empty/whitespace reasoning with no authoritative count carries - // nothing to show, so skip it (avoids an empty 0-token block). - const text = (part as { text?: string }).text ?? ""; - if (!text.trim() && !(reasoningTokens && reasoningTokens > 0)) - return null; - // Absent state (persisted rows) and "done" both mean finalized. - // `messageSignature` already includes each part's `state`, so the - // streaming→done flip changes the row signature and re-renders this - // row — which is what lets ReasoningBlock switch from chunked plain - // text to its one-time markdown parse (see reasoning-block.tsx). - // ALSO require the turn to be live: a part stranded at - // `state:"streaming"` after the turn ended (no `reasoning-end` — see - // the `turnStreaming` prop doc) must still finalize and parse. - const streaming = - turnStreaming && (part as { state?: string }).state === "streaming"; - return ( - - ); - } - - if (part.type === "text") { - // Skip empty/whitespace-only text parts (a streaming message often - // starts with an empty text part before the first token arrives); the - // typing indicator covers that gap until real content streams in. - if (!part.text.trim()) return null; - return ( - - ); - } - + // Tool parts (`tool-*` / `dynamic-tool`) are template-literal kinds, so + // they cannot be a `switch` case; the runtime guard handles them, and the + // switch below covers every CLOSED (literal-typed) part kind with a + // compile-time exhaustiveness check in its default. if (isToolPart(part.type)) { return ( a collapsible block with its own token + // count. Empty/whitespace reasoning with no authoritative count + // carries nothing to show, so skip it (avoids an empty 0-token block). + const text = part.text ?? ""; + if (!text.trim() && !(reasoningTokens && reasoningTokens > 0)) + return null; + // Absent state (persisted rows) and "done" both mean finalized. + // `messageSignature` already includes each part's `state`, so the + // streaming→done flip changes the row signature and re-renders this + // row — which is what lets ReasoningBlock switch from chunked plain + // text to its one-time markdown parse (see reasoning-block.tsx). + // ALSO require the turn to be live: a part stranded at + // `state:"streaming"` after the turn ended (no `reasoning-end` — see + // the `turnStreaming` prop doc) must still finalize and parse. + const streaming = turnStreaming && part.state === "streaming"; + return ( + + ); + } + + case "text": { + // Skip empty/whitespace-only text parts (a streaming message often + // starts with an empty text part before the first token arrives); the + // typing indicator covers that gap until real content streams in. + if (!part.text.trim()) return null; + // The live, actively-growing tail part of the in-flight turn renders + // incrementally (see MarkdownPart); a finalized part (persisted, or + // the turn-end flip) renders the whole text through one canonical + // pass. Same liveness rule as the reasoning branch above. + const streaming = turnStreaming && part.state === "streaming"; + return ( + + ); + } + + case "source-url": + case "source-document": + case "file": + case "step-start": + // Not surfaced in the chat bubble (v1) — same as the pre-#492 default. + return null; + + default: { + // Compile-time exhaustiveness over the CLOSED union members: every + // literal-typed part kind is handled above, so the only kinds that + // can reach here are the OPEN template-literal ones (`tool-*` — caught + // by the guard at runtime — and `data-*`) plus `dynamic-tool`. Adding + // a NEW closed part kind to UIMessagePart makes this assignment fail + // to compile, forcing it to be handled instead of silently ignored + // (this replaces the pre-#492 fall-through `return null` + WARNING). + const _exhaustive: + | `tool-${string}` + | "dynamic-tool" + | `data-${string}` = part.type; + void _exhaustive; + return null; + } + } })} {/* A persisted turn error (server stored it in metadata.error). Rendered here so it survives a thread remount and shows in reopened history. */} diff --git a/apps/client/src/features/ai-chat/components/streaming-markdown-text.tsx b/apps/client/src/features/ai-chat/components/streaming-markdown-text.tsx new file mode 100644 index 00000000..7a6fdca6 --- /dev/null +++ b/apps/client/src/features/ai-chat/components/streaming-markdown-text.tsx @@ -0,0 +1,96 @@ +import { memo, useMemo } from "react"; +import { splitPlainChunks } from "@/features/ai-chat/components/streaming-plain-text.tsx"; +import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts"; +import classes from "@/features/ai-chat/components/ai-chat.module.css"; + +/** + * One STABILIZED markdown block, rendered through the canonical pipeline and + * memoized on its string prop. During streaming only the TAIL chunk grows (the + * `splitPlainChunks` append-only invariant guarantees every earlier chunk is + * byte-identical across deltas), so React skips every stable block and each one + * is parsed by `renderChatMarkdown` EXACTLY ONCE — turning the pre-#492 + * "re-parse the whole accumulated answer on every ~20Hz tick" (O(ticks)) into + * O(number of blocks). The markup is DOMPurify-sanitized inside renderChatMarkdown + * before it reaches `dangerouslySetInnerHTML`. + * + * NOTE (transient streaming-only artifact): a safe cut is a blank-line boundary, + * so a construct that legitimately contains a blank line (e.g. a fenced code block + * with an empty line) can be split across chunks and render oddly WHILE it is still + * streaming. This is cosmetic and self-heals: the moment the part finalizes, + * MarkdownPart renders the WHOLE text through one canonical pass (visual parity + * with the pre-#492 output). The reasoning path makes the same trade (plain text + * while streaming, one markdown parse at the end). + */ +const MarkdownChunk = memo(function MarkdownChunk({ + text, + neutralizeInternalLinks, +}: { + text: string; + neutralizeInternalLinks: boolean; +}) { + const html = renderChatMarkdown(text, { neutralizeInternalLinks }); + if (html) { + return ( +
    + ); + } + // Malformed/unsupported markdown could not render synchronously: raw text. + return ( +
    + {text} +
    + ); +}); + +/** + * The cheap streaming-time stand-in for the finalized answer's one-time markdown + * parse (see MarkdownPart in message-item.tsx). Mirrors StreamingPlainText's + * chunked-memo pattern but renders the STABILIZED prefix as real markdown (each + * block parsed once, memoized) and only the LIVE tail as flat plain text — so the + * user sees formatted output for everything up to the last safe cut, and the not- + * yet-stable tail (which markdown-parsing every tick would make O(ticks)) stays a + * single cheap escaped text node until it stabilizes into a new block. + * + * `splitPlainChunks` yields chunks where, under append-only growth, every chunk + * except the LAST is immutable; the last chunk is the live tail. Index keys are + * therefore stable (a given index never changes to a different chunk's content). + */ +export function StreamingMarkdownText({ + text, + neutralizeInternalLinks, +}: { + text: string; + neutralizeInternalLinks: boolean; +}) { + const chunks = useMemo(() => splitPlainChunks(text), [text]); + return ( + <> + {chunks.map((chunk, index) => + index < chunks.length - 1 ? ( + + ) : ( + // The live tail: flat, React-escaped plain text (no markdown parse, no + // sanitizer, no innerHTML). `pre-wrap` preserves its newlines; trailing + // separator newlines are dropped at display time so the block gap comes + // from the markdown margins, not a doubled empty line (mirrors + // PlainChunk in streaming-plain-text.tsx). +
    + {chunk.replace(/\n+$/, "")} +
    + ), + )} + + ); +}