返回 DeepSeek-Reasonix
Markdown.tsx
根目录 / desktop / frontend / src / components / Markdown.tsx
1 import { lazy, memo, startTransition, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
3 const MarkdownRenderer = lazy(() => import("./MarkdownRenderer"));
4 const STREAMING_TAIL_THRESHOLD = 8_000;
5 const FINALIZE_SETTLE_MS = 50;
6 const FINALIZE_IDLE_TIMEOUT_MS = 1_000;
7 const MARKDOWN_SECTION_TARGET_CHARS = 12_000;
8 const CROSS_SECTION_REFERENCE_RE = /(?:^ {0,3}\[[^\]\n]+\]:|\[\^[^\]\n]+\])/m;
9 const CROSS_SECTION_CONTAINER_RE = /^ {0,3}(?:>|(?:[-+*]|\d{1,9}[.)])(?:[ \t]+|$)|<)/;
10
11 function scanMarkdownSections(text: string): { boundaries: number[]; hasCrossSectionContainer: boolean } {
12 const boundaries = [0];
13 let lineStart = 0;
14 let fence: { marker: string; length: number } | null = null;
15 let displayMath = false;
16 let boundaryAfterFence = false;
17 let hasCrossSectionContainer = false;
18
19 const addBoundary = (offset: number) => {
20 if (offset > 0 && boundaries[boundaries.length - 1] !== offset) boundaries.push(offset);
21 };
22
23 while (lineStart < text.length) {
24 const newline = text.indexOf("\n", lineStart);
25 const lineEnd = newline === -1 ? text.length : newline + 1;
26 const line = text.slice(lineStart, newline === -1 ? text.length : newline).replace(/\r$/, "");
27 const trimmed = line.trim();
28
29 if (fence) {
30 const close = new RegExp(`^ {0,3}${fence.marker}{${fence.length},}[ \\t]*$`);
31 if (close.test(line)) {
32 fence = null;
33 boundaryAfterFence = true;
34 }
35 lineStart = lineEnd;
36 continue;
37 }
38
39 if (displayMath) {
40 if (trimmed === "$$") {
41 displayMath = false;
42 boundaryAfterFence = true;
43 }
44 lineStart = lineEnd;
45 continue;
46 }
47
48 if (boundaryAfterFence && trimmed !== "") {
49 addBoundary(lineStart);
50 boundaryAfterFence = false;
51 }
52
53 if (CROSS_SECTION_CONTAINER_RE.test(line)) {
54 hasCrossSectionContainer = true;
55 }
56 const fenceMatch = /^ {0,3}(`{3,}|~{3,})/.exec(line);
57 if (fenceMatch) {
58 addBoundary(lineStart);
59 fence = { marker: fenceMatch[1][0], length: fenceMatch[1].length };
60 } else if (trimmed === "$$") {
61 addBoundary(lineStart);
62 displayMath = true;
63 } else if (/^ {0,3}#{1,6}(?:[ \t]+|$)/.test(line)) {
64 addBoundary(lineStart);
65 }
66 lineStart = lineEnd;
67 }
68
69 return { boundaries, hasCrossSectionContainer };
70 }
71
72 // Stable top-level sections let React.memo retain completed Markdown while the
73 // streaming tail changes. Cross-section references intentionally stay in one
74 // renderer because their definitions can affect nodes anywhere in the document.
75 export function splitStableMarkdownSections(text: string): string[] {
76 if (text.length < MARKDOWN_SECTION_TARGET_CHARS || CROSS_SECTION_REFERENCE_RE.test(text)) return [text];
77 const { boundaries, hasCrossSectionContainer } = scanMarkdownSections(text);
78 if (hasCrossSectionContainer) return [text];
79 if (boundaries.length === 1) return [text];
80
81 const sections = boundaries.map((start, index) => text.slice(start, boundaries[index + 1] ?? text.length));
82 const chunks: string[] = [];
83 let current = "";
84 for (const section of sections) {
85 if (current && current.length + section.length > MARKDOWN_SECTION_TARGET_CHARS) {
86 chunks.push(current);
87 current = section;
88 } else {
89 current += section;
90 }
91 }
92 if (current) chunks.push(current);
93 return chunks.length > 1 ? chunks : [text];
94 }
95
96 type IdleWindow = Window & {
97 requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
98 cancelIdleCallback?: (handle: number) => void;
99 };
100
101 function scheduleMarkdownFinalization(callback: () => void): () => void {
102 const idleWindow = window as IdleWindow;
103 let cancelled = false;
104 let idleHandle: number | null = null;
105 let frameHandle: number | null = null;
106 const timeoutHandle = window.setTimeout(() => {
107 if (cancelled) return;
108 const run = () => {
109 if (cancelled) return;
110 startTransition(callback);
111 };
112 if (idleWindow.requestIdleCallback) {
113 idleHandle = idleWindow.requestIdleCallback(run, { timeout: FINALIZE_IDLE_TIMEOUT_MS });
114 } else {
115 frameHandle = requestAnimationFrame(run);
116 }
117 }, FINALIZE_SETTLE_MS);
118
119 return () => {
120 cancelled = true;
121 window.clearTimeout(timeoutHandle);
122 if (idleHandle !== null) idleWindow.cancelIdleCallback?.(idleHandle);
123 if (frameHandle !== null) cancelAnimationFrame(frameHandle);
124 };
125 }
126
127 export function streamingMarkdownCommitInterval(textLength: number): number {
128 if (textLength >= 32_000) return 300;
129 if (textLength >= 8_000) return 150;
130 return 50;
131 }
132
133 export function useRenderedMarkdownText(text: string, streaming: boolean): string {
134 const [renderedText, setRenderedText] = useState(text);
135 const latestTextRef = useRef(text);
136 const frameRef = useRef<number | null>(null);
137 const timeoutRef = useRef<number | null>(null);
138 const lastCommitAtRef = useRef(0);
139 const wasStreamingRef = useRef(streaming);
140 const finalizingTextRef = useRef<string | null>(null);
141 const cancelFinalizationRef = useRef<(() => void) | null>(null);
142 const finalizationStartedAtRef = useRef(0);
143 const finalizationLengthRef = useRef(0);
144
145 latestTextRef.current = text;
146
147 useLayoutEffect(() => {
148 const endedStreaming = wasStreamingRef.current && !streaming;
149 wasStreamingRef.current = streaming;
150 if (streaming) {
151 cancelFinalizationRef.current?.();
152 cancelFinalizationRef.current = null;
153 finalizingTextRef.current = null;
154 return;
155 }
156 lastCommitAtRef.current = 0;
157 if (frameRef.current !== null) {
158 cancelAnimationFrame(frameRef.current);
159 frameRef.current = null;
160 }
161 if (timeoutRef.current !== null) {
162 window.clearTimeout(timeoutRef.current);
163 timeoutRef.current = null;
164 }
165 if (renderedText === text) {
166 cancelFinalizationRef.current?.();
167 cancelFinalizationRef.current = null;
168 finalizingTextRef.current = null;
169 if (finalizationStartedAtRef.current > 0) {
170 performance.measure("reasonix:markdown-finalize", {
171 start: finalizationStartedAtRef.current,
172 end: performance.now(),
173 detail: { textLength: finalizationLengthRef.current },
174 });
175 finalizationStartedAtRef.current = 0;
176 finalizationLengthRef.current = 0;
177 }
178 return;
179 }
180
181 const canFinalizeWhenIdle =
182 (endedStreaming || finalizingTextRef.current !== null) &&
183 text.length >= STREAMING_TAIL_THRESHOLD &&
184 text.startsWith(renderedText);
185 if (canFinalizeWhenIdle) {
186 if (finalizingTextRef.current === text) return;
187 cancelFinalizationRef.current?.();
188 finalizingTextRef.current = text;
189 cancelFinalizationRef.current = scheduleMarkdownFinalization(() => {
190 cancelFinalizationRef.current = null;
191 finalizationStartedAtRef.current = performance.now();
192 finalizationLengthRef.current = latestTextRef.current.length;
193 setRenderedText(latestTextRef.current);
194 });
195 return;
196 }
197
198 cancelFinalizationRef.current?.();
199 cancelFinalizationRef.current = null;
200 finalizingTextRef.current = null;
201 setRenderedText(text);
202 }, [renderedText, streaming, text]);
203
204 useLayoutEffect(() => {
205 if (streaming) lastCommitAtRef.current = performance.now();
206 }, [renderedText, streaming]);
207
208 useEffect(() => {
209 if (!streaming || renderedText === text || frameRef.current !== null || timeoutRef.current !== null) return;
210 const commit = () => {
211 timeoutRef.current = null;
212 frameRef.current = requestAnimationFrame(() => {
213 frameRef.current = null;
214 setRenderedText(latestTextRef.current);
215 });
216 };
217 const now = performance.now();
218 const elapsed = lastCommitAtRef.current === 0 ? Number.POSITIVE_INFINITY : now - lastCommitAtRef.current;
219 const delay = streamingMarkdownCommitInterval(text.length) - elapsed;
220 if (delay <= 0) commit();
221 else timeoutRef.current = window.setTimeout(commit, delay);
222 }, [renderedText, streaming, text]);
223
224 useEffect(() => () => {
225 if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
226 if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current);
227 cancelFinalizationRef.current?.();
228 }, []);
229
230 return renderedText;
231 }
232
233 const StreamingMarkdownTail = memo(function StreamingMarkdownTail({ text }: { text: string }) {
234 const elementRef = useRef<HTMLDivElement>(null);
235 const previousTextRef = useRef("");
236
237 useLayoutEffect(() => {
238 const element = elementRef.current;
239 if (!element) return;
240 const previousText = previousTextRef.current;
241 const textNode = element.firstChild;
242 if (text.startsWith(previousText) && textNode?.nodeType === Node.TEXT_NODE) {
243 (textNode as Text).appendData(text.slice(previousText.length));
244 } else {
245 element.textContent = text;
246 }
247 previousTextRef.current = text;
248 }, [text]);
249
250 return <div ref={elementRef} className="md md--stream-tail" />;
251 });
252
253 export const Markdown = memo(function Markdown({
254 text,
255 plainStatusBlocks = false,
256 streaming = false,
257 }: {
258 text: string;
259 plainStatusBlocks?: boolean;
260 streaming?: boolean;
261 }) {
262 const renderedText = useRenderedMarkdownText(text, streaming);
263 const sections = useMemo(() => splitStableMarkdownSections(renderedText), [renderedText]);
264 const pendingText = text.length >= STREAMING_TAIL_THRESHOLD && text.startsWith(renderedText)
265 ? text.slice(renderedText.length)
266 : "";
267 return (
268 <>
269 <Suspense fallback={<div className="md">{renderedText}</div>}>
270 {sections.length === 1 ? (
271 <MarkdownRenderer text={renderedText} plainStatusBlocks={plainStatusBlocks} />
272 ) : (
273 <div className="md" data-markdown-sections={sections.length}>
274 {sections.map((section, index) => (
275 <MarkdownRenderer key={index} text={section} plainStatusBlocks={plainStatusBlocks} bare />
276 ))}
277 </div>
278 )}
279 </Suspense>
280 {pendingText && <StreamingMarkdownTail text={pendingText} />}
281 </>
282 );
283 });
284
284 lines Plain Text