返回 DeepSeek-Reasonix
MarkdownHistory.tsx
根目录 / desktop / frontend / src / components / MarkdownHistory.tsx
1 import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2 import { hastBlockToJsx } from "../lib/hastJsx";
3 import { estimateHastBytes, markdownContentRevision, type MarkdownBlock, type MarkdownParseResult } from "../lib/markdownPipeline";
4 import { getMarkdownWorkerClient } from "../lib/markdownWorkerClient";
5 import { getTranscriptStore } from "../lib/transcriptStore";
6 import { MARKDOWN_AST_ELEMENT_PAGE, visibleMarkdownBlockCount } from "../lib/markdownDomBudget";
7 import { useT } from "../lib/i18n";
8 import { createComponents } from "./markdownComponents";
9 import { useChatFileCandidateReport } from "./ChatFileLinkContext";
10 import { MarkdownSourceTable } from "./MarkdownTable";
11 import "katex/dist/katex.min.css";
12 import "./harness-chat/MarkdownText.css";
13
14 const Block = memo(function Block({ block, components }: { block: MarkdownBlock; components: ReturnType<typeof createComponents> }) {
15 return block.virtualTable ? <MarkdownSourceTable data={block.virtualTable} /> : hastBlockToJsx(block, components);
16 });
17
18 let nextWorkerDocumentId = 1;
19
20 /** Worker parsing is independent of viewport geometry. Every block uses natural flow. */
21 const MarkdownHistory = memo(function MarkdownHistory({ text, streaming = false, plainStatusBlocks = false, cacheKey, fallback, onParsed, onError }: {
22 text: string; streaming?: boolean; plainStatusBlocks?: boolean; cacheKey?: string; fallback: ReactNode;
23 onParsed?: () => void; onError?: () => void;
24 }) {
25 const t = useT();
26 // The revision is a cache key for settled content only: a streaming body is
27 // never stored, so hashing it on every delta would be O(body) work per
28 // commit for a lookup that cannot hit.
29 const revision = useMemo(() => (streaming ? 0 : markdownContentRevision(text)), [streaming, text]);
30 const [parsed, setParsed] = useState<{ text: string; result: MarkdownParseResult }>();
31 const previous = useRef<MarkdownParseResult | undefined>(undefined);
32 const [workerDocumentId] = useState(() => `markdown-${nextWorkerDocumentId++}`);
33 const root = useRef<HTMLDivElement>(null);
34 const [visible, setVisible] = useState(typeof IntersectionObserver === "undefined");
35 const [elementBudget, setElementBudget] = useState<number>(MARKDOWN_AST_ELEMENT_PAGE);
36 useEffect(() => {
37 if (visible || typeof IntersectionObserver === "undefined" || !root.current) return;
38 const observer = new IntersectionObserver(entries => {
39 if (entries.some(entry => entry.isIntersecting)) { setVisible(true); observer.disconnect(); }
40 }, { rootMargin: "800px" });
41 observer.observe(root.current);
42 return () => observer.disconnect();
43 }, [visible]);
44 const components = useMemo(() => createComponents(plainStatusBlocks), [plainStatusBlocks]);
45 const cached = useMemo(
46 () => (!streaming && cacheKey ? getTranscriptStore().getMarkdown(cacheKey, revision) : undefined),
47 [cacheKey, revision, streaming],
48 );
49 useEffect(() => {
50 const client = getMarkdownWorkerClient();
51 return () => client.releaseDocument(workerDocumentId);
52 }, [workerDocumentId]);
53 useEffect(() => {
54 if (!visible && !streaming) return;
55 if (cached?.blocks) { onParsed?.(); return; }
56 let cancelled = false;
57 const request = getMarkdownWorkerClient().parseDocument(workerDocumentId, text, {
58 final: !streaming,
59 priority: streaming ? "interactive" : visible ? "visible" : "background",
60 });
61 void request.promise.then(result => {
62 if (cancelled || !result) return;
63 // Retain unchanged AST identities across stream publications and
64 // finalization, so React keeps native selection and code disclosure
65 // hosts. The comparison is the fingerprint the parse already computed:
66 // serializing both trees here cost O(blocks x block size) of string
67 // allocation on the main thread on every streamed commit.
68 const stable = previous.current?.blocks;
69 if (stable) result.blocks = result.blocks.map((block, index) =>
70 stable[index]?.key === block.key && stable[index]?.fingerprint === block.fingerprint ? stable[index] : block);
71 previous.current = result;
72 setParsed({ text, result });
73 if (cacheKey && !streaming) getTranscriptStore().setMarkdown(cacheKey, revision, {
74 source: text, blocks: result.blocks, selectionText: result.selectionText,
75 selectionRevision: result.selectionRevision,
76 bytes: text.length * 2 + result.selectionText.length * 2 + estimateHastBytes(result.blocks),
77 });
78 onParsed?.();
79 }).catch(() => { if (!cancelled) onError?.(); });
80 return () => { cancelled = true; request.cancel(); };
81 }, [cacheKey, cached, onError, onParsed, revision, streaming, text, visible, workerDocumentId]);
82 const result = cached?.blocks ? cached : parsed && (parsed.text === text || text.startsWith(parsed.text)) ? parsed.result : undefined;
83 // Only blocks the parser has already committed are reported, so a streaming
84 // answer never asks the host about a half-written path.
85 useChatFileCandidateReport(result?.blocks, revision);
86 const visibleBlockCount = result ? visibleMarkdownBlockCount(result.blocks, elementBudget) : 0;
87 const nodes = useMemo(() => result?.blocks.slice(0, visibleBlockCount)
88 .map(block => <Block key={block.key} block={block} components={components} />), [result, components, visibleBlockCount]);
89 const pending = !cached?.blocks && parsed && text.startsWith(parsed.text) ? text.slice(parsed.text.length) : "";
90 const hiddenBlocks = (result?.blocks.length ?? 0) - visibleBlockCount;
91 return <div ref={root} className="md" data-markdown-blocks={result?.blocks.length}
92 data-markdown-visible-blocks={visibleBlockCount}>
93 {result ? <>{nodes}{hiddenBlocks > 0 && <button type="button" className="btn"
94 onClick={() => setElementBudget(value => value + MARKDOWN_AST_ELEMENT_PAGE)}>
95 {t("chat.loadMoreBlocks", { count: hiddenBlocks })}
96 </button>}{!hiddenBlocks && pending && <span style={{ whiteSpace: "pre-wrap" }}>{pending}</span>}</> : fallback}
97 </div>;
98 });
99 export default MarkdownHistory;
100
100 lines Plain Text