返回 DeepSeek-Reasonix
chatFileReferences.ts
根目录 / desktop / frontend / src / lib / chatFileReferences.ts
1 // Session-scoped owner for host-verified answer references.
2 //
3 // One store lives per mounted chat session. The transcript reports the paths a
4 // committed markdown block named; the store batches them, deduplicates against
5 // what it already asked, and calls the host once per batch. Results are cached
6 // by session, turn, and candidate, so a stream update or a remount re-renders
7 // from the cache instead of asking again.
8 //
9 // Everything that can outlive its session is generation-bound: switching the
10 // session, closing the tab, or changing the working directory disposes the
11 // store, and a late reply from an older generation is dropped rather than
12 // written into the replacement conversation.
13
14 import { app } from "./bridge";
15 import type { ChatFileReference, ChatFileReferenceRequest, ChatFileReferenceResult } from "../generated/desktopContract.generated";
16
17 /** Host verdict for one candidate. */
18 export interface ChatFileReferenceView {
19 key: string;
20 path: string;
21 status: "resolved" | "unavailable" | "unsupported";
22 displayPath?: string;
23 kind?: string;
24 actions: readonly string[];
25 reason?: string;
26 }
27
28 /** Batches are capped by the host contract; the store splits larger sets. */
29 export const CHAT_FILE_REFERENCE_BATCH_LIMIT = 64;
30
31 type TurnState = {
32 factsVersion: number;
33 snapshot: ReadonlyMap<string, ChatFileReferenceView>;
34 requested: Set<string>;
35 queue: ChatFileReferenceRequest[];
36 scheduled: boolean;
37 };
38
39 const emptyTurn: ReadonlyMap<string, ChatFileReferenceView> = new Map();
40
41 /** The wire type is a plain string; anything unrecognized is a refusal. */
42 function normalizeStatus(value: string): ChatFileReferenceView["status"] {
43 return value === "resolved" || value === "unsupported" ? value : "unavailable";
44 }
45
46 export class ChatFileReferenceStore {
47 private turns = new Map<string, TurnState>();
48 private listeners = new Set<() => void>();
49 private generation = 0;
50 private disposed = false;
51 private attachments = 0;
52
53 constructor(private readonly tabId: string, private readonly hostId: string = "local") {}
54
55 /** Only the local host can verify a path today; a remote host degrades to text. */
56 get supported(): boolean { return this.hostId === "local"; }
57
58 subscribe = (listener: () => void): (() => void) => {
59 this.listeners.add(listener);
60 return () => { this.listeners.delete(listener); };
61 };
62
63 getTurnSnapshot = (turnKey: string): ReadonlyMap<string, ChatFileReferenceView> =>
64 this.turns.get(turnKey)?.snapshot ?? emptyTurn;
65
66 /**
67 * Reports the candidates a committed block named. `factsVersion` identifies
68 * the turn's file facts: when it advances, earlier failures are re-asked,
69 * because a tool that just succeeded can turn a missing file into a real one.
70 */
71 report(turnKey: string, factsVersion: number, candidates: readonly ChatFileReferenceRequest[]): void {
72 if (this.disposed || !this.supported || !candidates.length) return;
73 const state = this.turnState(turnKey, factsVersion);
74 if (state.factsVersion !== factsVersion) {
75 state.factsVersion = factsVersion;
76 for (const entry of state.snapshot.values()) {
77 if (entry.status !== "resolved") state.requested.delete(entry.key);
78 }
79 }
80 let queued = false;
81 for (const candidate of candidates) {
82 if (!candidate.key || state.requested.has(candidate.key)) continue;
83 state.requested.add(candidate.key);
84 state.queue.push(candidate);
85 queued = true;
86 }
87 if (queued) this.schedule(turnKey, state);
88 }
89
90 /**
91 * React StrictMode replays mount effects, running a cleanup without a
92 * re-render. Disposal is therefore deferred and counted: a synchronous
93 * re-attach revives the same store instead of killing it for the session.
94 */
95 attach(): void {
96 this.attachments++;
97 }
98
99 detach(): void {
100 this.attachments--;
101 queueMicrotask(() => { if (this.attachments === 0) this.dispose(); });
102 }
103
104 dispose(): void {
105 if (this.disposed) return;
106 this.disposed = true;
107 this.generation++;
108 this.turns.clear();
109 this.listeners.clear();
110 }
111
112 private turnState(turnKey: string, factsVersion: number): TurnState {
113 let state = this.turns.get(turnKey);
114 if (!state) {
115 state = { factsVersion, snapshot: new Map(), requested: new Set(), queue: [], scheduled: false };
116 this.turns.set(turnKey, state);
117 }
118 return state;
119 }
120
121 /** One flush per microtask keeps a burst of committed blocks in one batch. */
122 private schedule(turnKey: string, state: TurnState): void {
123 if (state.scheduled) return;
124 state.scheduled = true;
125 queueMicrotask(() => {
126 state.scheduled = false;
127 if (!this.disposed) void this.flush(turnKey, state);
128 });
129 }
130
131 private async flush(turnKey: string, state: TurnState): Promise<void> {
132 while (state.queue.length) {
133 const batch = state.queue.splice(0, CHAT_FILE_REFERENCE_BATCH_LIMIT);
134 const generation = this.generation;
135 let verdicts: readonly ChatFileReference[];
136 try {
137 verdicts = (await this.resolve(turnKey, batch)).references;
138 } catch {
139 verdicts = batch.map(item => ({ key: item.key, path: item.path, status: "unsupported", actions: [], reason: "unavailable-host" }));
140 }
141 // A reply from a replaced session must never reach the new one.
142 if (this.disposed || generation !== this.generation) return;
143 this.apply(state, verdicts);
144 }
145 }
146
147 private resolve(turnKey: string, batch: ChatFileReferenceRequest[]): Promise<ChatFileReferenceResult> {
148 return app.ResolveChatFileReferencesForTab(this.tabId, turnKey, batch);
149 }
150
151 private apply(state: TurnState, references: readonly ChatFileReference[]): void {
152 if (!references.length) return;
153 const next = new Map(state.snapshot);
154 for (const reference of references) {
155 const previous = next.get(reference.path);
156 const status = normalizeStatus(reference.status);
157 // A resolved verdict outlives an earlier failure for the same file.
158 if (previous?.status === "resolved" && status !== "resolved") continue;
159 const value: ChatFileReferenceView = {
160 key: reference.key,
161 path: reference.path,
162 status,
163 displayPath: reference.displayPath,
164 kind: reference.kind,
165 actions: reference.actions ?? [],
166 reason: reference.reason,
167 };
168 next.set(reference.path, value);
169 if (status === "resolved" && value.displayPath) next.set(value.displayPath, value);
170 }
171 state.snapshot = next;
172 this.publish();
173 }
174
175 private publish(): void {
176 for (const listener of this.listeners) listener();
177 }
178 }
179
179 lines TYPESCRIPT