返回 DeepSeek-Reasonix
Transcript.tsx
根目录 / desktop / frontend / src / components / Transcript.tsx
1 import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2 import { ArrowDown } from "lucide-react";
3 import { getTranscriptStore } from "../lib/transcriptStore";
4 import type { ControllerLiveStore, HistoryLoadOutcome, HistoryLoadTrigger, Item, LiveStream } from "../lib/useController";
5 import type { LocalSubmission } from "../lib/localSubmissionState";
6 import { forkTargetForAnswer, type ForkBlockReason, type ForkTargetSetView, type ForkTargetView } from "../lib/forkTargets";
7 import type { InvocationMetadataMap } from "../lib/invocationDisplay";
8 import { acquireMarkdownWorkerClient, releaseMarkdownWorkerClient } from "../lib/markdownWorkerClient";
9 import { ChatSource } from "../lib/chatViewSource";
10 import { ChatScrollController } from "../lib/chatScrollController";
11 import { ChatContentLoader } from "../lib/chatContentLoader";
12 import { ChatMountedOrder } from "../lib/chatMountedOrder";
13 import { ChatTurnJump } from "../lib/chatTurnJump";
14 import { findLoadedTurn, indexLoadedTurns, type LoadedTurnIndex } from "../lib/chatTurnRail";
15 import { getTranscriptOutlineStore } from "../lib/transcriptOutlineStore";
16 import { addBreadcrumb } from "../lib/breadcrumbs";
17 import { useT } from "../lib/i18n";
18 import { InvocationMetadataContext } from "./Message";
19 import { MarkdownImageTabContext } from "./MarkdownImageContext";
20 import { ChatFileScopeProvider } from "./ChatFileLinkContext";
21 import { ChatDetails, ChatNodeList, ChatRunning, type ChatActions } from "./ChatNodes";
22 import { Welcome } from "./Welcome";
23 import "./ChatTranscript.css";
24 const ChatTurnNavigator = lazy(() => import("./ChatTurnNavigator"));
25 export { NoticeCard } from "./TranscriptCards";
26
27 export type TranscriptProps = {
28 items: Item[];
29 localSubmissions?: readonly LocalSubmission[];
30 localSubmissionSendRevision?: number;
31 visibleSubmissionHandoffs?: Readonly<Record<string, { submissionId: string }>>;
32 live?: LiveStream;
33 liveStore?: ControllerLiveStore;
34 tabId?: string;
35 hostId?: string;
36 geometrySessionKey?: string;
37 footerHeight?: number;
38 onPrompt: (displayText: string, submitText?: string) => void;
39 onFork?: (target: ForkTargetView) => void;
40 /** Persisted fork boundaries of the shown session; undefined until the first read resolves. */
41 forkTargets?: ForkTargetSetView;
42 /** Non-null replaces every fork entry's own state, e.g. a surface that cannot create a child. */
43 forkBlocked?: ForkBlockReason | null;
44 running?: boolean;
45 hydrating?: boolean;
46 hasOlderHistory?: boolean;
47 hasNewerHistory?: boolean;
48 historyStartTurn?: number;
49 historyEndTurn?: number;
50 /** Total turns the snapshot reports, used to keep the rail area while the
51 * outline loads without showing it on a brand-new conversation. */
52 totalTurns?: number;
53 loadingOlderHistory?: boolean;
54 olderHistoryError?: string;
55 onLoadOlderHistory?: (targetTurn?: number, trigger?: HistoryLoadTrigger) => HistoryLoadOutcome | boolean | Promise<HistoryLoadOutcome | boolean>;
56 loadingNewerHistory?: boolean;
57 newerHistoryError?: string;
58 onLoadNewerHistory?: (latest?: boolean) => HistoryLoadOutcome | boolean | Promise<HistoryLoadOutcome | boolean>;
59 turnStartAt?: number;
60 invocationMetadata?: InvocationMetadataMap;
61 surfaceCommitToken?: string;
62 onSurfacePaintReady?: (token: string, outcome: "ready" | "degraded") => void;
63 };
64
65
66 /** Local and remote hosts share this natural-flow presentation adapter. */
67 export function Transcript(props: TranscriptProps) {
68 const sessionKey = props.geometrySessionKey || `tab:${props.tabId ?? "preview"}`;
69 return <ChatSession key={sessionKey} {...props} sessionKey={sessionKey} />;
70 }
71
72 function TranscriptConnection({ tabId }: { tabId?: string }) {
73 const store = getTranscriptStore();
74 const subscribe = useCallback((listener: () => void) => tabId ? store.subscribeState(tabId, listener) : () => {}, [store, tabId]);
75 const snapshot = useCallback(() => tabId ? store.states.get(tabId)?.transcriptConnection : undefined, [store, tabId]);
76 const status = useSyncExternalStore(subscribe, snapshot, snapshot);
77 const t = useT();
78 return status && status !== "connected" ? <p role="status">{t(status === "syncing" ? "chat.syncing" : "chat.disconnected")}</p> : null;
79 }
80
81 function ChatSession(props: TranscriptProps & { sessionKey: string }) {
82 const { sessionKey, tabId, items, live, liveStore, running = false, hydrating = false,
83 hasOlderHistory = false, hasNewerHistory = false, loadingOlderHistory = false, olderHistoryError,
84 loadingNewerHistory = false, newerHistoryError, turnStartAt,
85 onLoadOlderHistory, onLoadNewerHistory, onPrompt, onFork, onSurfacePaintReady, surfaceCommitToken } = props;
86 const t = useT();
87 const [source] = useState(() => new ChatSource(sessionKey));
88 const [mounts] = useState(() => new ChatMountedOrder());
89 const order = useSyncExternalStore(source.subscribeOrder, source.getOrderSnapshot, source.getOrderSnapshot);
90 const [scroll] = useState(() => new ChatScrollController(sessionKey));
91 const loader = useMemo(() => new ChatContentLoader(tabId), [tabId]);
92 const scroller = useRef<HTMLDivElement>(null);
93 const column = useRef<HTMLDivElement>(null);
94 const trigger = useRef<HTMLElement | null>(null);
95 const lifetime = useRef(0);
96 const [details, setDetails] = useState<string>();
97 const closeDetails = useCallback(() => { setDetails(undefined); }, []);
98 const openDetails = useCallback((key: string, element: HTMLElement) => { trigger.current = element; setDetails(key); }, []);
99 const recover = useCallback((id: string) => onPrompt(t("notice.protocolRecoveryAction"), `/recover-context ${id}`), [onPrompt, t]);
100 const actions = useMemo<ChatActions>(() => ({ openDetails, recover,
101 fork: onFork ? {
102 targetFor: (answerKey) => forkTargetForAnswer(props.forkTargets, answerKey),
103 loaded: props.forkTargets !== undefined,
104 verifiable: props.forkTargets?.verifiable ?? false,
105 blocked: props.forkBlocked ?? null,
106 create: onFork,
107 } : undefined }), [openDetails, recover, onFork, props.forkTargets, props.forkBlocked]);
108 useLayoutEffect(() => {
109 source.update({ items, live: props.hasNewerHistory ? undefined : liveStore?.getSnapshot(tabId) ?? live, running, hydrating,
110 localSubmissions: props.hasNewerHistory ? [] : props.localSubmissions,
111 visibleSubmissionHandoffs: props.visibleSubmissionHandoffs,
112 hasOlder: hasOlderHistory, loadingOlder: loadingOlderHistory, error: olderHistoryError,
113 startedAt: turnStartAt, historyStartTurn: props.historyStartTurn });
114 }, [source, items, props.localSubmissions, props.visibleSubmissionHandoffs, live, liveStore, tabId, running, hydrating, hasOlderHistory, loadingOlderHistory, olderHistoryError, turnStartAt, props.historyStartTurn, props.hasNewerHistory]);
115 useEffect(() => liveStore?.subscribe(tabId, () => source.updateLive(props.hasNewerHistory ? undefined : liveStore.getSnapshot(tabId))), [source, liveStore, tabId, props.hasNewerHistory]);
116 useLayoutEffect(() => {
117 if (scroller.current && column.current) scroll.attach(scroller.current, column.current);
118 return () => scroll.dispose();
119 }, [scroll]);
120 useEffect(() => {
121 loader.activate();
122 acquireMarkdownWorkerClient();
123 return () => { lifetime.current++; source.dispose(); mounts.dispose(); loader.dispose(); releaseMarkdownWorkerClient(); };
124 }, [source, mounts, loader]);
125 useLayoutEffect(() => {
126 if (!hydrating) scroll.ready();
127 scroll.layout();
128 }, [scroll, items, hydrating, props.footerHeight, details]);
129 useEffect(() => {
130 if (hydrating || !surfaceCommitToken || (items.length > 0 && order.length === 0)) return;
131 let paint = 0;
132 const frame = requestAnimationFrame(() => { paint = requestAnimationFrame(() => onSurfacePaintReady?.(surfaceCommitToken, "ready")); });
133 return () => { cancelAnimationFrame(frame); cancelAnimationFrame(paint); };
134 }, [hydrating, surfaceCommitToken, onSurfacePaintReady, items.length, order.length]);
135 const submissionRevision = props.localSubmissionSendRevision ?? 0;
136 const previousSubmissionRevision = useRef(submissionRevision);
137 useLayoutEffect(() => {
138 if (previousSubmissionRevision.current !== submissionRevision && running && !hydrating && !props.hasNewerHistory) scroll.toBottom();
139 previousSubmissionRevision.current = submissionRevision;
140 }, [submissionRevision, running, hydrating, scroll, props.hasNewerHistory]);
141 const position = useSyncExternalStore(scroll.subscribe, scroll.getSnapshot, scroll.getSnapshot);
142 const activeDetails = details && source.getNodeSnapshot(details)?.kind === "tool" ? details : undefined;
143 const drawerWasOpen = useRef(false);
144 useLayoutEffect(() => {
145 if (!activeDetails && drawerWasOpen.current) {
146 (trigger.current?.isConnected ? trigger.current : scroller.current)?.focus({ preventScroll: true });
147 trigger.current = null;
148 }
149 drawerWasOpen.current = Boolean(activeDetails);
150 }, [activeDetails]);
151 const [pagingError, setPagingError] = useState(false);
152 const [selectionBlocked, setSelectionBlocked] = useState(false);
153 // Manual paging and navigation jumps share one queue. A page already in
154 // flight is awaited rather than submitted twice, so a jump that collides
155 // with the button continues from that page instead of failing.
156 const pagingPromise = useRef<Promise<HistoryLoadOutcome> | null>(null);
157 const selectionInsideTranscript = () => {
158 const selection = window.getSelection?.();
159 return Boolean(selection && !selection.isCollapsed && scroller.current &&
160 ((selection.anchorNode && scroller.current.contains(selection.anchorNode)) ||
161 (selection.focusNode && scroller.current.contains(selection.focusNode))));
162 };
163 useEffect(() => {
164 const clear = () => { if (!selectionInsideTranscript()) setSelectionBlocked(false); };
165 document.addEventListener("selectionchange", clear);
166 return () => document.removeEventListener("selectionchange", clear);
167 }, []);
168 const loadPage = (direction: "older" | "newer" | "latest", trigger: HistoryLoadTrigger = "viewport-user"): Promise<HistoryLoadOutcome> => {
169 if (pagingPromise.current) return pagingPromise.current;
170 const load = direction === "older" ? () => onLoadOlderHistory?.(undefined, trigger) : () => onLoadNewerHistory?.(direction === "latest");
171 if (direction === "older" ? !onLoadOlderHistory : !onLoadNewerHistory) return Promise.resolve("empty");
172 if (selectionInsideTranscript()) { setSelectionBlocked(true); return Promise.resolve("empty"); }
173 const generation = lifetime.current;
174 setPagingError(false);
175 setSelectionBlocked(false);
176 scroll.beforeChange();
177 const run = (async (): Promise<HistoryLoadOutcome> => {
178 try {
179 // A host that still answers with a plain boolean is normalized here.
180 const result = await load();
181 if (result === true) return "loaded";
182 if (result === false) return "empty";
183 return result ?? "empty";
184 } catch {
185 if (generation === lifetime.current) setPagingError(true);
186 return "empty";
187 }
188 })();
189 pagingPromise.current = run;
190 void run.finally(() => { if (pagingPromise.current === run) pagingPromise.current = null; });
191 return run;
192 };
193 const loadOlder = (trigger: HistoryLoadTrigger = "viewport-user") => loadPage("older", trigger);
194 // The jump outlives a single render, so it reads the live paging state
195 // through refs rather than through the closure it was built with.
196 const loadOlderRef = useRef(loadOlder); loadOlderRef.current = loadOlder;
197 const hasOlderRef = useRef(false); hasOlderRef.current = hasOlderHistory && Boolean(onLoadOlderHistory);
198 const lifetimeRef = useRef(lifetime.current); lifetimeRef.current = lifetime.current;
199 // Rebuild the mounted identity index only when the mount advances, then
200 // resolve each target from it in constant time: scanning the mounted order
201 // per outline entry is quadratic on long conversations.
202 const turnIndex = useRef<{ order: readonly string[]; index: LoadedTurnIndex }>(undefined);
203 const jump = useMemo(() => new ChatTurnJump({
204 mounts, scroll,
205 loadOlder: () => loadOlderRef.current("question-jump"),
206 hasOlder: () => hasOlderRef.current,
207 resolveKey: (entry) => {
208 const order = mounts.getSnapshot();
209 if (turnIndex.current?.order !== order) {
210 turnIndex.current = {
211 order,
212 index: indexLoadedTurns(order, (key) => {
213 const node = source.getNodeSnapshot(key);
214 return node?.kind === "user" ? { id: node.item.id, messageId: node.item.messageId } : undefined;
215 }),
216 };
217 }
218 return findLoadedTurn(turnIndex.current.index, entry);
219 },
220 // The rail describes one snapshot. A replacement invalidates the locators
221 // this jump was resolved against, so it must not keep paging the new body.
222 currentSnapshotId: () => (tabId ? getTranscriptOutlineStore().getView(tabId).snapshotId : ""),
223 // Only a reader-initiated retry reaches this, and it is what lets a target
224 // resolve against a fresh cut instead of the recycled one.
225 refreshSnapshot: async (entry) => {
226 if (!tabId) return undefined;
227 const store = getTranscriptOutlineStore();
228 await store.refresh(tabId);
229 return store.resolve(tabId, entry);
230 },
231 isCurrent: () => lifetimeRef.current === lifetime.current,
232 }), [mounts, scroll, source, tabId]);
233 const jumpState = useSyncExternalStore(jump.subscribe, jump.getSnapshot, jump.getSnapshot);
234 useEffect(() => () => jump.dispose(), [jump]);
235 useEffect(() => {
236 if (jumpState.status !== "failed") return;
237 setPagingError(true);
238 addBreadcrumb("chat.jump", `turn jump failed: ${jumpState.reason ?? "unknown"}`);
239 }, [jumpState.status, jumpState.reason]);
240 return <InvocationMetadataContext.Provider value={props.invocationMetadata ?? {}}>
241 <MarkdownImageTabContext.Provider value={tabId ?? ""}>
242 <ChatFileScopeProvider scopeKey={source.sessionKey} tabId={tabId} hostId={props.hostId}>
243 <section className="chat-transcript">
244 <div className="chat-surface" inert={Boolean(activeDetails)}>
245 <Suspense fallback={null}><ChatTurnNavigator source={source} scroll={scroll} mounts={mounts}
246 tabId={tabId} knownTurns={props.totalTurns ?? 0}
247 busyTurn={jumpState.status === "loading" ? jumpState.turn : null}
248 failedTurn={jumpState.status === "failed" ? jumpState.turn : null}
249 failedReason={jumpState.reason}
250 // Every click takes the one transaction entry point, so a newer
251 // selection always supersedes a pending jump instead of racing it.
252 onNavigate={(target) => {
253 if (target.anchor.kind === "loaded") jump.jumpTo(target.anchor.key);
254 else void jump.jump(target.entry);
255 }}
256 onRetryJump={() => { void jump.retry(); }}
257 onCancelJump={() => jump.cancel()} /></Suspense>
258 <div ref={scroller} id={`reasonix-chat-transcript-${tabId ?? "local"}`} className="transcript chat-flow-scroll" tabIndex={0} data-transcript-render-mode="full"
259 data-transcript-hydrating={hydrating} data-scroll-mode={position.following ? "tail" : "reader"}>
260 <div ref={column} className="chat-column">
261 <TranscriptConnection tabId={tabId} />
262 {hydrating && <p role="status">{t("chat.loading")}</p>}
263 {(hasOlderHistory || hasNewerHistory) && <div className="chat-history-window" role="status">
264 <span>{t("chat.historyRange", { start: Math.max(1, (props.historyStartTurn ?? 0) + 1), end: Math.max(1, props.historyEndTurn ?? props.totalTurns ?? 0), total: props.totalTurns ?? 0 })}</span>
265 </div>}
266 {hasOlderHistory && <button className="btn chat-older" disabled={loadingOlderHistory} onClick={() => void loadOlder()}>{t(loadingOlderHistory ? "chat.loading" : "chat.loadOlder")}</button>}
267 {(olderHistoryError || pagingError) && <button className="btn" onClick={() => void loadOlder()}>{t("chat.loadFailed")}</button>}
268 {selectionBlocked && <p className="chat-history-selection" role="status">{t("chat.historySelectionBlocked")}</p>}
269 {!hydrating && items.length === 0 && !running && <Welcome onPrompt={onPrompt} />}
270 <ChatNodeList key={source.sessionKey} source={source} mounts={mounts} loader={loader} scroll={scroll} actions={actions} tabId={tabId} hostId={props.hostId} />
271 <ChatRunning source={source} />
272 {hasNewerHistory && <div className="chat-history-newer">
273 <button className="btn" disabled={loadingNewerHistory} onClick={() => void loadPage("newer")}>{t(loadingNewerHistory ? "chat.loading" : "chat.loadNewer")}</button>
274 <button className="btn" disabled={loadingNewerHistory} onClick={() => void loadPage("latest")}>{t("chat.toLatest")}</button>
275 </div>}
276 {newerHistoryError && <button className="btn" onClick={() => void loadPage("newer")}>{t("chat.loadFailed")}</button>}
277 </div>
278 </div>
279 <button className="btn chat-to-bottom" hidden={position.following} aria-label={t("chat.toLatest")} onClick={scroll.toBottom}><ArrowDown size={18} /></button>
280 </div>
281 {activeDetails && <ChatDetails key={activeDetails} source={source} nodeKey={activeDetails} loader={loader} onClose={closeDetails} onNavigate={setDetails} />}
282 </section>
283 </ChatFileScopeProvider>
284 </MarkdownImageTabContext.Provider>
285 </InvocationMetadataContext.Provider>;
286 }
287
287 lines Plain Text