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