返回 DeepSeek-Reasonix
Markdown.tsx
根目录 / desktop / frontend / src / components / Markdown.tsx
1 import { lazy, memo, startTransition, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2 import { t } from "../lib/i18n";
3
4 async function loadMarkdownView<T>(component: Promise<T>): Promise<T> {
5 await Promise.all([import("./MarkdownImage.css"), import("./CodeBlock.css")]);
6 return component;
7 }
8
9 let historyView: typeof import("./MarkdownHistory").default | undefined;
10 let historyViewPromise: Promise<typeof import("./MarkdownHistory")> | undefined;
11 export function preloadMarkdownHistory(): Promise<typeof import("./MarkdownHistory")> {
12 return historyViewPromise ??= loadMarkdownView(import("./MarkdownHistory")).then(module => {
13 historyView = module.default;
14 return module;
15 });
16 }
17 const LazyMarkdownHistory = lazy(preloadMarkdownHistory);
18 const LiveMarkdownCode = lazy(() => loadMarkdownView(import("./markdownComponents")).then(module => ({ default: module.MarkdownCode })));
19 const STREAMING_TAIL_THRESHOLD = 8_000;
20 const FINALIZE_SETTLE_MS = 50;
21 const FINALIZE_IDLE_TIMEOUT_MS = 1_000;
22 const MARKDOWN_SECTION_TARGET_CHARS = 12_000;
23 const CROSS_SECTION_REFERENCE_RE = /(?:^ {0,3}\[[^\]\n]+\]:|\[\^[^\]\n]+\])/m;
24 const CROSS_SECTION_CONTAINER_RE = /^ {0,3}(?:>|(?:[-+*]|\d{1,9}[.)])(?:[ \t]+|$)|<)/;
25
26 function scanMarkdownSections(text: string): { boundaries: number[]; hasCrossSectionContainer: boolean } {
27 const boundaries = [0];
28 let lineStart = 0;
29 let fence: { marker: string; length: number } | null = null;
30 let displayMath = false;
31 let boundaryAfterFence = false;
32 let hasCrossSectionContainer = false;
33
34 const addBoundary = (offset: number) => {
35 if (offset > 0 && boundaries[boundaries.length - 1] !== offset) boundaries.push(offset);
36 };
37
38 while (lineStart < text.length) {
39 const newline = text.indexOf("\n", lineStart);
40 const lineEnd = newline === -1 ? text.length : newline + 1;
41 const line = text.slice(lineStart, newline === -1 ? text.length : newline).replace(/\r$/, "");
42 const trimmed = line.trim();
43
44 if (fence) {
45 const close = new RegExp(`^ {0,3}${fence.marker}{${fence.length},}[ \\t]*$`);
46 if (close.test(line)) {
47 fence = null;
48 boundaryAfterFence = true;
49 }
50 lineStart = lineEnd;
51 continue;
52 }
53
54 if (displayMath) {
55 if (trimmed === "$$") {
56 displayMath = false;
57 boundaryAfterFence = true;
58 }
59 lineStart = lineEnd;
60 continue;
61 }
62
63 if (boundaryAfterFence && trimmed !== "") {
64 addBoundary(lineStart);
65 boundaryAfterFence = false;
66 }
67
68 if (CROSS_SECTION_CONTAINER_RE.test(line)) {
69 hasCrossSectionContainer = true;
70 }
71 const fenceMatch = /^ {0,3}(`{3,}|~{3,})/.exec(line);
72 if (fenceMatch) {
73 addBoundary(lineStart);
74 fence = { marker: fenceMatch[1][0], length: fenceMatch[1].length };
75 } else if (trimmed === "$$") {
76 addBoundary(lineStart);
77 displayMath = true;
78 } else if (/^ {0,3}#{1,6}(?:[ \t]+|$)/.test(line)) {
79 addBoundary(lineStart);
80 }
81 lineStart = lineEnd;
82 }
83
84 return { boundaries, hasCrossSectionContainer };
85 }
86
87 // Stable top-level sections let React.memo retain completed Markdown while the
88 // streaming tail changes. Cross-section references intentionally stay in one
89 // renderer because their definitions can affect nodes anywhere in the document.
90 export function splitStableMarkdownSections(text: string): string[] {
91 if (text.length < MARKDOWN_SECTION_TARGET_CHARS || CROSS_SECTION_REFERENCE_RE.test(text)) return [text];
92 const { boundaries, hasCrossSectionContainer } = scanMarkdownSections(text);
93 if (hasCrossSectionContainer) return [text];
94 if (boundaries.length === 1) return [text];
95
96 const sections = boundaries.map((start, index) => text.slice(start, boundaries[index + 1] ?? text.length));
97 const chunks: string[] = [];
98 let current = "";
99 for (const section of sections) {
100 if (current && current.length + section.length > MARKDOWN_SECTION_TARGET_CHARS) {
101 chunks.push(current);
102 current = section;
103 } else {
104 current += section;
105 }
106 }
107 if (current) chunks.push(current);
108 return chunks.length > 1 ? chunks : [text];
109 }
110
111 type IdleWindow = Window & {
112 requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
113 cancelIdleCallback?: (handle: number) => void;
114 };
115
116 function scheduleMarkdownFinalization(callback: () => void): () => void {
117 const idleWindow = window as IdleWindow;
118 let cancelled = false;
119 let idleHandle: number | null = null;
120 let frameHandle: number | null = null;
121 const timeoutHandle = window.setTimeout(() => {
122 if (cancelled) return;
123 const run = () => {
124 if (cancelled) return;
125 startTransition(callback);
126 };
127 if (idleWindow.requestIdleCallback) {
128 idleHandle = idleWindow.requestIdleCallback(run, { timeout: FINALIZE_IDLE_TIMEOUT_MS });
129 } else {
130 frameHandle = requestAnimationFrame(run);
131 }
132 }, FINALIZE_SETTLE_MS);
133
134 return () => {
135 cancelled = true;
136 window.clearTimeout(timeoutHandle);
137 if (idleHandle !== null) idleWindow.cancelIdleCallback?.(idleHandle);
138 if (frameHandle !== null) cancelAnimationFrame(frameHandle);
139 };
140 }
141
142 export function streamingMarkdownCommitInterval(textLength: number): number {
143 if (textLength >= 32_000) return 300;
144 if (textLength >= 8_000) return 150;
145 return 50;
146 }
147
148 const STREAMING_LIST_ITEM_RE = /^ {0,3}(?:[*+-]|\d{1,9}[.)])(?:[ \t]+|$)/;
149 const STREAMING_THEMATIC_BREAK_RE = /^ {0,3}(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})[ \t]*$/;
150
151 function isStreamingListItemLine(line: string): boolean {
152 return STREAMING_LIST_ITEM_RE.test(line) && !STREAMING_THEMATIC_BREAK_RE.test(line);
153 }
154
155 // Live parse prefix: last completed block. A later list marker commits prior
156 // items only — the new item stays in the tail so indented continuations can join.
157 // An open code fence commits only up to the fence line: the tail renders the
158 // growing code with code styling (splitStreamingTailFence), so streaming a
159 // large block no longer re-parses the whole document on every commit.
160 export function streamingCommitTarget(text: string): string {
161 let lineStart = 0;
162 let fence: { marker: string; length: number } | null = null;
163 let fenceStart = 0;
164 let displayMath = false;
165 let boundary = 0;
166 while (lineStart < text.length) {
167 const newline = text.indexOf("\n", lineStart);
168 const lineEnd = newline === -1 ? text.length : newline + 1;
169 const line = text.slice(lineStart, newline === -1 ? text.length : newline).replace(/\r$/, "");
170 const terminated = newline !== -1;
171 if (fence) {
172 if (new RegExp(`^ {0,3}${fence.marker}{${fence.length},}[ \\t]*$`).test(line)) {
173 fence = null;
174 if (terminated) boundary = lineEnd;
175 }
176 } else if (displayMath) {
177 if (line.trim() === "$$") {
178 displayMath = false;
179 if (terminated) boundary = lineEnd;
180 }
181 } else {
182 const fenceMatch = /^ {0,3}(`{3,}|~{3,})/.exec(line);
183 if (fenceMatch) {
184 fence = { marker: fenceMatch[1][0], length: fenceMatch[1].length };
185 fenceStart = lineStart;
186 } else if (line.trim() === "$$") displayMath = true;
187 else if (terminated && line.trim() === "") boundary = lineEnd;
188 // A heading interrupts a paragraph, so a partial heading line already
189 // completes everything before it; a terminated one is itself complete.
190 else if (/^ {0,3}#{1,6}[ \t]+/.test(line)) boundary = terminated ? lineEnd : lineStart;
191 else if (isStreamingListItemLine(line)) boundary = lineStart;
192 }
193 lineStart = lineEnd;
194 }
195 return fence ? text.slice(0, fenceStart) : displayMath ? text : text.slice(0, boundary);
196 }
197
198 type StreamingTailFence = { head: string; lang: string; code: string };
199
200 // Split a streaming tail around an unclosed code fence so the fence body can
201 // render with code styling before the closing fence arrives. Bail out cheaply
202 // when no fence marker exists; otherwise mirror the fence state machine from
203 // streamingCommitTarget in one forward pass over the tail.
204 export function splitStreamingTailFence(text: string, includeJustClosed = false): StreamingTailFence | null {
205 if (!text.includes("```") && !text.includes("~~~")) return null;
206 let lineStart = 0;
207 let fence: { marker: string; length: number } | null = null;
208 let fenceStart = 0;
209 let fenceBodyStart = 0;
210 let lang = "";
211 while (lineStart < text.length) {
212 const newline = text.indexOf("\n", lineStart);
213 const lineEnd = newline === -1 ? text.length : newline + 1;
214 const line = text.slice(lineStart, newline === -1 ? text.length : newline).replace(/\r$/, "");
215 if (fence) {
216 if (new RegExp(`^ {0,3}${fence.marker}{${fence.length},}[ \\t]*$`).test(line)) {
217 // Keep the styled fence until the committed parser takes it over.
218 if (includeJustClosed && !text.slice(lineEnd).trim()) {
219 return { head: text.slice(0, fenceStart), lang, code: text.slice(fenceBodyStart, lineStart) };
220 }
221 fence = null;
222 }
223 } else {
224 const fenceMatch = /^ {0,3}(`{3,}|~{3,})([^\n]*)$/.exec(line);
225 if (fenceMatch && newline !== -1) {
226 fence = { marker: fenceMatch[1][0], length: fenceMatch[1].length };
227 fenceStart = lineStart;
228 fenceBodyStart = lineEnd;
229 lang = fenceMatch[2].trim().split(/\s+/)[0];
230 }
231 }
232 lineStart = lineEnd;
233 }
234 if (!fence) return null;
235 return { head: text.slice(0, fenceStart), lang, code: text.slice(fenceBodyStart) };
236 }
237
238 export function useRenderedMarkdownText(text: string, streaming: boolean, holdIdleFinalization = false): string {
239 const [renderedText, setRenderedText] = useState(() => streaming && splitStreamingTailFence(text) ? streamingCommitTarget(text) : text);
240 const latestTextRef = useRef(text);
241 const frameRef = useRef<number | null>(null);
242 const timeoutRef = useRef<number | null>(null);
243 const lastCommitAtRef = useRef(0);
244 const wasStreamingRef = useRef(streaming);
245 const finalizingTextRef = useRef<string | null>(null);
246 const cancelFinalizationRef = useRef<(() => void) | null>(null);
247 const finalizationStartedAtRef = useRef(0);
248 const finalizationLengthRef = useRef(0);
249
250 latestTextRef.current = text;
251
252 useLayoutEffect(() => {
253 const endedStreaming = wasStreamingRef.current && !streaming;
254 wasStreamingRef.current = streaming;
255 if (streaming) {
256 cancelFinalizationRef.current?.();
257 cancelFinalizationRef.current = null;
258 finalizingTextRef.current = null;
259 // A bounded live preview occasionally advances its window and drops an
260 // old prefix. Discard the stale parsed tree before paint; the complete
261 // replacement stays visible through StreamingMarkdownTail and is parsed
262 // later under the normal adaptive budget.
263 if (renderedText !== "" && !text.startsWith(renderedText)) {
264 setRenderedText("");
265 }
266 return;
267 }
268 lastCommitAtRef.current = 0;
269 if (frameRef.current !== null) {
270 cancelAnimationFrame(frameRef.current);
271 frameRef.current = null;
272 }
273 if (timeoutRef.current !== null) {
274 window.clearTimeout(timeoutRef.current);
275 timeoutRef.current = null;
276 }
277 if (renderedText === text) {
278 cancelFinalizationRef.current?.();
279 cancelFinalizationRef.current = null;
280 finalizingTextRef.current = null;
281 if (finalizationStartedAtRef.current > 0) {
282 performance.measure("reasonix:markdown-finalize", {
283 start: finalizationStartedAtRef.current,
284 end: performance.now(),
285 detail: { textLength: finalizationLengthRef.current },
286 });
287 finalizationStartedAtRef.current = 0;
288 finalizationLengthRef.current = 0;
289 }
290 return;
291 }
292
293 const canFinalizeWhenIdle =
294 (endedStreaming || finalizingTextRef.current !== null) &&
295 text.length >= STREAMING_TAIL_THRESHOLD &&
296 text.startsWith(renderedText);
297 if (canFinalizeWhenIdle) {
298 // The worker path owns the final parse of a completed stream: holding
299 // here keeps the committed prefix frozen instead of re-parsing the full
300 // document on the main thread at idle time.
301 if (holdIdleFinalization) return;
302 if (finalizingTextRef.current === text) return;
303 cancelFinalizationRef.current?.();
304 finalizingTextRef.current = text;
305 cancelFinalizationRef.current = scheduleMarkdownFinalization(() => {
306 cancelFinalizationRef.current = null;
307 finalizationStartedAtRef.current = performance.now();
308 finalizationLengthRef.current = latestTextRef.current.length;
309 setRenderedText(latestTextRef.current);
310 });
311 return;
312 }
313
314 cancelFinalizationRef.current?.();
315 cancelFinalizationRef.current = null;
316 finalizingTextRef.current = null;
317 setRenderedText(text);
318 }, [renderedText, streaming, text, holdIdleFinalization]);
319
320 useLayoutEffect(() => {
321 if (streaming) lastCommitAtRef.current = performance.now();
322 }, [renderedText, streaming]);
323
324 useEffect(() => {
325 if (!streaming || frameRef.current !== null || timeoutRef.current !== null) return;
326 if (streamingCommitTarget(text).length <= renderedText.length) return;
327 const commit = () => {
328 timeoutRef.current = null;
329 frameRef.current = requestAnimationFrame(() => {
330 frameRef.current = null;
331 // Recompute at commit time: only ever advance to a newer boundary.
332 const target = streamingCommitTarget(latestTextRef.current);
333 setRenderedText((prev) => (target.length > prev.length ? target : prev));
334 });
335 };
336 const now = performance.now();
337 const elapsed = lastCommitAtRef.current === 0 ? Number.POSITIVE_INFINITY : now - lastCommitAtRef.current;
338 const delay = streamingMarkdownCommitInterval(text.length) - elapsed;
339 if (delay <= 0) commit();
340 else timeoutRef.current = window.setTimeout(commit, delay);
341 }, [renderedText, streaming, text]);
342
343 useEffect(() => () => {
344 if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
345 if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current);
346 cancelFinalizationRef.current?.();
347 }, []);
348
349 return renderedText;
350 }
351
352 function StreamingMarkdownTail({ text }: { text: string }) {
353 const fence = splitStreamingTailFence(text, true);
354 if (!fence) return <span className="md" style={{ whiteSpace: "pre-wrap" }}>{text}</span>;
355 const value = fence.code.replace(/\n$/, "");
356 return <div className="md">
357 {fence.head && <span style={{ whiteSpace: "pre-wrap" }}>{fence.head}</span>}
358 <Suspense fallback={<pre className="code">{value}</pre>}>
359 <LiveMarkdownCode value={value} language={fence.lang} />
360 </Suspense>
361 </div>;
362 }
363
364 export const Markdown = memo(function Markdown({
365 text, plainStatusBlocks = false, streaming = false, cacheKey,
366 }: { text: string; plainStatusBlocks?: boolean; streaming?: boolean; cacheKey?: string; wasStreamed?: boolean }) {
367 const renderedText = useRenderedMarkdownText(text, streaming, false);
368 const [failed, setFailed] = useState<string>();
369 const onError = useCallback(() => setFailed(text), [text]);
370 const History = historyView ?? LazyMarkdownHistory;
371 const fallback = <div className="md" style={{ whiteSpace: "pre-wrap" }}>{text}</div>;
372 if (failed === text) return <><span className="chat-notice" role="status">{t("chat.parseFailed")}</span>{fallback}</>;
373 return <Suspense fallback={fallback}>
374 <History text={streaming ? renderedText : text} streaming={streaming}
375 plainStatusBlocks={plainStatusBlocks} cacheKey={cacheKey}
376 fallback={<span style={{ whiteSpace: "pre-wrap" }}>{streaming ? renderedText : text}</span>} onError={onError} />
377 {streaming && text.startsWith(renderedText) && text.length > renderedText.length &&
378 <StreamingMarkdownTail text={text.slice(renderedText.length)} />}
379 </Suspense>;
380 });
381
381 lines Plain Text