返回 DeepSeek-Reasonix
transcriptOutlineStore.ts
根目录 / desktop / frontend / src / lib / transcriptOutlineStore.ts
1 import { app } from "./bridge";
2 import type { TranscriptOutlineEntry, TranscriptOutlinePage, TranscriptOutlineRequest } from "./transcriptProtocol";
3
4 /** Reads one outline page. */
5 export type OutlineRead = (tabId: string, request: TranscriptOutlineRequest) => Promise<TranscriptOutlinePage>;
6
7 /**
8 * The host does not implement the outline protocol. This is a compatibility
9 * answer rather than a failure: it must not be presented as a retryable error,
10 * and it must not be mistaken for "this conversation has no turns".
11 */
12 export class OutlineUnsupported extends Error {}
13
14 export type TranscriptOutlineMode =
15 /** No outline protocol: the rail shows loaded turns only and claims nothing. */
16 | "legacy"
17 /** Bound to a snapshot, pages still arriving. The rail keeps its area. */
18 | "loading"
19 | "ready"
20 /** A real read failure. Known markers stay and a retry is offered. */
21 | "error";
22
23 export interface TranscriptOutlineView {
24 readonly mode: TranscriptOutlineMode;
25 readonly snapshotId: string;
26 readonly entries: readonly TranscriptOutlineEntry[];
27 readonly error?: string;
28 /** True when a read budget stopped the index short of the whole session. */
29 readonly truncated?: boolean;
30 }
31
32 const EMPTY_ENTRIES: readonly TranscriptOutlineEntry[] = [];
33 const LEGACY: TranscriptOutlineView = Object.freeze({ mode: "legacy", snapshotId: "", entries: EMPTY_ENTRIES });
34
35 // A hostile or buggy host drives this loop, so it is bounded like every other
36 // server-driven read in this codebase (MAX_REPLAY_PAGES, MAX_JUMP_PAGES).
37 // 64 pages of 1000 entries is far beyond any real conversation.
38 const MAX_OUTLINE_PAGES = 64;
39 const MAX_OUTLINE_ENTRIES = 20_000;
40
41 /**
42 * A budget running out keeps the turns already indexed and marks the view
43 * truncated, so a huge conversation still navigates. Discarding the whole
44 * index would trade a bounded cost for losing navigation entirely.
45 */
46 const TRUNCATED = "outline is incomplete";
47
48 /**
49 * The complete turn index of the installed snapshot, shared by the local
50 * controller and remote sessions. Paging the body changes what is mounted, not
51 * what exists, so this answers from one snapshot regardless of how much history
52 * the reader has loaded.
53 *
54 * Reads are fenced by tab generation and by snapshot identity: a response from a
55 * replaced session, a replaced snapshot, or a released tab can neither publish
56 * nor clear state.
57 */
58 export class TranscriptOutlineStore {
59 private readonly views = new Map<string, TranscriptOutlineView>();
60 private readonly listeners = new Map<string, Set<() => void>>();
61 private readonly generations = new Map<string, number>();
62 private readonly pending = new Map<string, Promise<void>>();
63 private readonly readers = new Map<string, OutlineRead>();
64 private readonly refreshers = new Map<string, () => Promise<void>>();
65
66 /**
67 * Bind a tab to the host that owns it. Local controllers and remote sessions
68 * share this index, so the owning hook registers the reader for its own tabs
69 * and a tab it never loaded stays in the legacy mode.
70 */
71 register(tabId: string, read: OutlineRead, refresh?: () => Promise<void>): void {
72 this.readers.set(tabId, read);
73 if (refresh) this.refreshers.set(tabId, refresh);
74 }
75
76 /**
77 * Have the owning session install a fresh snapshot, whatever the current view
78 * says. A navigation jump that hit a recycled cut needs this even when the
79 * outline itself still reads as ready, so it is not gated on the view's mode.
80 */
81 async refresh(tabId: string): Promise<void> {
82 const refresh = this.refreshers.get(tabId);
83 if (!refresh) throw new Error("transcript snapshot refresh is unavailable");
84 await refresh();
85 // The cut notification starts outline synchronization without blocking the
86 // controller commit. A reader retry, however, must wait until the fresh
87 // identity is usable before it resolves its target again.
88 const pending = this.pending.get(tabId);
89 if (pending) await pending;
90 const view = this.views.get(tabId);
91 if (view?.mode === "error") throw new Error(view.error || "transcript outline refresh failed");
92 }
93
94 /**
95 * A user-initiated retry after a recycled cut. A stale id cannot be read
96 * again, so the owning session installs a fresh snapshot first and this index
97 * re-aligns with it through the ordinary cut notification. The body is only
98 * replaced by that explicit request, never as an automatic reaction.
99 */
100 retry(tabId: string): Promise<void> {
101 const view = this.views.get(tabId);
102 if (view?.error && this.refreshers.has(tabId)) return this.refresh(tabId);
103 if (!view?.snapshotId) return Promise.resolve();
104 return this.load(tabId, view.snapshotId);
105 }
106
107 subscribe(tabId: string, listener: () => void): () => void {
108 let listeners = this.listeners.get(tabId);
109 if (!listeners) { listeners = new Set(); this.listeners.set(tabId, listeners); }
110 listeners.add(listener);
111 return () => { listeners.delete(listener); if (!listeners.size) this.listeners.delete(tabId); };
112 }
113
114 getView(tabId: string): TranscriptOutlineView {
115 return this.views.get(tabId) ?? LEGACY;
116 }
117
118 /** Resolve an entry again after refreshing its snapshot. Message identity
119 * wins because an optimistic mounted key can differ from the durable record
120 * id learned later in the same app session. */
121 resolve(tabId: string, target: TranscriptOutlineEntry): TranscriptOutlineEntry | undefined {
122 const entries = this.views.get(tabId)?.entries ?? EMPTY_ENTRIES;
123 if (target.messageId) {
124 const byMessage = entries.find(entry => entry.messageId === target.messageId);
125 if (byMessage) return byMessage;
126 }
127 return entries.find(entry => entry.id === target.id);
128 }
129
130 /** Fence and hide a cut being replaced while preserving the owning host
131 * binding. A failed refresh can therefore be retried instead of degrading
132 * permanently to the legacy rail. */
133 invalidate(tabId: string): void {
134 this.generations.set(tabId, (this.generations.get(tabId) ?? 0) + 1);
135 this.views.delete(tabId);
136 this.pending.delete(tabId);
137 this.publish(tabId);
138 }
139
140 /** Drop a tab's index and fence every read still in flight for it. */
141 release(tabId: string): void {
142 this.invalidate(tabId);
143 this.readers.delete(tabId);
144 this.refreshers.delete(tabId);
145 }
146
147 /**
148 * Align the index with the installed snapshot. An unchanged snapshot reuses
149 * the current index instead of re-reading it, and an absent host or an absent
150 * snapshot stays in the legacy mode.
151 */
152 sync(tabId: string, snapshotId: string | undefined): Promise<void> {
153 if (!snapshotId || !this.readers.has(tabId)) return Promise.resolve();
154 const view = this.views.get(tabId);
155 if (view && view.snapshotId === snapshotId && view.mode !== "error") return this.pending.get(tabId) ?? Promise.resolve();
156 return this.load(tabId, snapshotId);
157 }
158
159 /** Retry after a failure, or after the capability was reported absent. */
160 load(tabId: string, snapshotId: string): Promise<void> {
161 const read = this.readers.get(tabId);
162 if (!read) return Promise.resolve();
163 const generation = (this.generations.get(tabId) ?? 0) + 1;
164 this.generations.set(tabId, generation);
165 this.set(tabId, { mode: "loading", snapshotId, entries: EMPTY_ENTRIES });
166 const run = this.readAll(tabId, snapshotId, generation, read).finally(() => {
167 if (this.pending.get(tabId) === run) this.pending.delete(tabId);
168 });
169 this.pending.set(tabId, run);
170 return run;
171 }
172
173 private async readAll(tabId: string, snapshotId: string, generation: number, read: OutlineRead): Promise<void> {
174 const current = () => this.generations.get(tabId) === generation;
175 const entries: TranscriptOutlineEntry[] = [];
176 const seen = new Set<string>();
177 let truncated = false;
178 try {
179 let offset = 0;
180 for (let pages = 0; ; pages++) {
181 if (pages >= MAX_OUTLINE_PAGES) { truncated = true; break; }
182 const page = await read(tabId, { snapshotId, offset });
183 if (!current()) return;
184 if (page.stale) {
185 // The cut was recycled. Reporting it lets the caller install a fresh
186 // snapshot and resolve the target again; silently continuing would
187 // answer positions against a different revision.
188 this.set(tabId, { mode: "error", snapshotId, entries: EMPTY_ENTRIES, error: "outline snapshot expired" });
189 return;
190 }
191 if (page.snapshotId !== snapshotId) {
192 this.set(tabId, { mode: "error", snapshotId, entries: EMPTY_ENTRIES, error: "outline snapshot changed" });
193 return;
194 }
195 for (const entry of page.entries) {
196 // Identity is unique per turn; a duplicate would make the rail
197 // ambiguous and break keyed reconciliation.
198 if (seen.has(entry.id)) continue;
199 seen.add(entry.id);
200 entries.push(entry);
201 if (entries.length >= MAX_OUTLINE_ENTRIES) { truncated = true; break; }
202 }
203 if (truncated || page.done) break;
204 if (!Number.isSafeInteger(page.nextOffset) || page.nextOffset <= offset) {
205 this.set(tabId, { mode: "error", snapshotId, entries: EMPTY_ENTRIES, error: "outline cursor stalled" });
206 return;
207 }
208 offset = page.nextOffset;
209 }
210 if (!current()) return;
211 entries.sort((left, right) => left.order - right.order);
212 this.set(tabId, { mode: "ready", snapshotId, entries, error: truncated ? TRUNCATED : undefined, truncated });
213 } catch (error) {
214 if (!current()) return;
215 if (error instanceof OutlineUnsupported) {
216 // Keep any markers already known, and never report this as a network
217 // failure that the reader could retry.
218 this.set(tabId, { mode: "legacy", snapshotId: "", entries: EMPTY_ENTRIES });
219 return;
220 }
221 this.set(tabId, { mode: "error", snapshotId, entries: EMPTY_ENTRIES, error: message(error) });
222 }
223 }
224
225 private set(tabId: string, view: TranscriptOutlineView): void {
226 this.views.set(tabId, view);
227 this.publish(tabId);
228 }
229
230 private publish(tabId: string): void {
231 for (const listener of [...(this.listeners.get(tabId) ?? [])]) listener();
232 }
233 }
234
235 function message(error: unknown): string {
236 return error instanceof Error ? error.message : String(error);
237 }
238
239 // Bridge-backed singleton, matching the transcript store: the owning session
240 // hook registers its own reader, and a tab it never loaded stays legacy.
241 let singleton: TranscriptOutlineStore | undefined;
242
243 export function getTranscriptOutlineStore(): TranscriptOutlineStore {
244 singleton ??= new TranscriptOutlineStore();
245 return singleton;
246 }
247
248 /** Local tabs read the controller binding; absent means the host is older. */
249 export function localOutlineRead(tabId: string, request: TranscriptOutlineRequest): Promise<TranscriptOutlinePage> {
250 const read = app.TranscriptOutlineForTab;
251 if (typeof read !== "function") return Promise.reject(new OutlineUnsupported("transcript outline is unavailable"));
252 return read(tabId, request).catch((error: unknown) => { throw unavailable(error); });
253 }
254
255 /**
256 * Remote tabs read the negotiated Serve route. The Go client already refuses
257 * the request when the capability was not advertised, so an unavailable
258 * projection is translated here rather than retried.
259 */
260 export function remoteOutlineRead(tabId: string, request: TranscriptOutlineRequest): Promise<TranscriptOutlinePage> {
261 const read = app.RemoteTranscriptOutlineForTab;
262 if (typeof read !== "function") return Promise.reject(new OutlineUnsupported("remote transcript outline is unavailable"));
263 return read(tabId, request).catch((error: unknown) => { throw unavailable(error); });
264 }
265
266 /**
267 * The host answering "this controller has no outline projection" is a
268 * compatibility answer, not a failure: both transports must degrade to the
269 * loaded-turn rail rather than offer a retry that can never succeed.
270 */
271 function unavailable(error: unknown): unknown {
272 return message(error).toLowerCase().includes("transcript projection is unavailable")
273 ? new OutlineUnsupported("transcript outline is unavailable")
274 : error;
275 }
276
276 lines TYPESCRIPT