返回 DeepSeek-Reasonix
chatViewSource.ts
根目录 / desktop / frontend / src / lib / chatViewSource.ts
1 import type { Item, LiveStream } from "./useController";
2 import { canonicalUserConfirmations, matchLocalSubmissions, type LocalSubmission } from "./localSubmissionState";
3 import type { PresentedFile } from "./types";
4 import { deriveTurnFiles, fileIdentity, type TurnFileView } from "./turnFiles";
5 import { recordFrontendDiagnostic } from "./frontendDiagnosticBridge";
6
7 export type PresentedFileView = PresentedFile & { toolCallId: string };
8
9 export type ChatNodeKey = string;
10 type Listener = () => void;
11 type ItemNode = { [K in Item["kind"]]: { kind: K; key: string; turnKey: string; item: Extract<Item, { kind: K }> } }[Item["kind"]];
12 export type ChatNode = ItemNode
13 | { kind: "reasoning"; key: string; turnKey: string; item: Extract<Item, { kind: "assistant" }> }
14 | { kind: "process"; key: string; turnKey: string; members: readonly string[]; collapsed: boolean; foldable: boolean; toolCallCount: number; messageCount: number; subagentCount: number; failureCount: number }
15 | { kind: "tail"; key: string; turnKey: string; answerKey?: string; turn?: number; latest: boolean; presentedFiles: readonly PresentedFileView[]; modifiedFiles: readonly TurnFileView[] };
16 export interface ChatStatus { running: boolean; hydrating: boolean; hasOlder: boolean; loadingOlder: boolean; error?: string; startedAt?: number }
17 export interface ChatInput extends ChatStatus {
18 items: readonly Item[];
19 localSubmissions?: readonly LocalSubmission[];
20 visibleSubmissionHandoffs?: Readonly<Record<string, { submissionId: string }>>;
21 live?: LiveStream;
22 historyStartTurn?: number;
23 }
24 export interface ChatViewSource {
25 getOrderSnapshot(): readonly ChatNodeKey[];
26 subscribeOrder(listener: Listener): () => void;
27 getNodeSnapshot(key: ChatNodeKey): ChatNode | undefined;
28 subscribeNode(key: ChatNodeKey, listener: Listener): () => void;
29 getStatusSnapshot(): ChatStatus;
30 subscribeStatus(listener: Listener): () => void;
31 dispose(): void;
32 }
33
34 const sameKeys = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every((key, i) => key === b[i]);
35 const sameReferences = (a: readonly unknown[] = [], b: readonly unknown[] = []) =>
36 a.length === b.length && a.every((item, index) => item === b[index]);
37 const shallowSame = (a: object, b: object) => Object.keys(a).length === Object.keys(b).length
38 && Object.entries(a).every(([key, value]) => value === (b as Record<string, unknown>)[key]);
39 const foldViews = new Map<string, Set<string>>();
40 const emptyChildren: readonly Extract<ChatNode, { kind: "tool" }>[] = [];
41 function proxyAuditCall(item: Item): string | undefined {
42 if (item.kind !== "notice" || item.code !== "capability_proxy_audit") return undefined;
43 try { return (JSON.parse(item.detail ?? "{}") as { callId?: string }).callId || undefined; } catch { return undefined; }
44 }
45
46 function submissionItem(submission: LocalSubmission): Extract<Item, { kind: "user" }> {
47 return {
48 kind: "user",
49 id: submission.localId,
50 submissionId: submission.submissionId,
51 messageId: submission.messageId,
52 turnId: submission.turnId,
53 submissionState: submission.status === "accepted" ? "confirmed" : submission.status,
54 text: submission.text,
55 submitText: submission.submitText,
56 failed: submission.status === "failed",
57 createdAt: submission.createdAt,
58 checkpointTurn: submission.checkpointTurn,
59 };
60 }
61
62 type DisplayEntry = { item: Item; local?: LocalSubmission };
63 function itemsWithLocalSubmissions(input: ChatInput): DisplayEntry[] {
64 const submissions = input.localSubmissions ?? [];
65 const items: DisplayEntry[] = uniqueUserItems(input.items).map(item => ({ item }));
66 const matched = new Set(matchLocalSubmissions(submissions, canonicalUserConfirmations(input.items)).map(match => match.submissionId));
67 const insertedAfter = new Map<string, number>();
68 for (const submission of [...submissions].sort((a, b) => a.sequence - b.sequence)) {
69 if (matched.has(submission.submissionId)) continue;
70 const item = { item: submissionItem(submission), local: submission };
71 const turnIndex = submission.turnId ? items.findIndex(candidate => candidate.item.turnId === submission.turnId) : -1;
72 if (!submission.anchorItemId && submission.placement !== "latest" && !input.hasOlder && (input.historyStartTurn ?? 0) === 0) {
73 items.splice(insertedAfter.get("") ?? 0, 0, item);
74 insertedAfter.set("", (insertedAfter.get("") ?? 0) + 1);
75 continue;
76 }
77 const anchor = submission.anchorItemId ? items.findIndex(candidate => candidate.item.id === submission.anchorItemId) : -1;
78 if (anchor < 0) {
79 if (turnIndex >= 0) items.splice(turnIndex, 0, item);
80 continue;
81 }
82 const offset = insertedAfter.get(submission.anchorItemId!) ?? 0;
83 items.splice(anchor + 1 + offset, 0, item);
84 insertedAfter.set(submission.anchorItemId!, offset + 1);
85 }
86 return items;
87 }
88
89 /** A reconstructable presentation projection. Controller/history remain authoritative. */
90 export class ChatSource implements ChatViewSource {
91 private order: readonly string[] = [];
92 private nodes = new Map<string, ChatNode>();
93 private children = new Map<string, readonly Extract<ChatNode, { kind: "tool" }>[]>();
94 private orderListeners = new Set<Listener>();
95 private statusListeners = new Set<Listener>();
96 private nodeListeners = new Map<string, Set<Listener>>();
97 private projectedGroups = new Map<string, {
98 user?: Extract<Item, { kind: "user" }>;
99 items: readonly Item[];
100 active: boolean;
101 latest: boolean;
102 present: readonly string[];
103 order: readonly string[];
104 }>();
105 private dirty = new Set<string>();
106 private orderDirty = false;
107 private statusDirty = false;
108 private scheduled = false;
109 private epoch = 0;
110 private input?: ChatInput;
111 private status: ChatStatus = { running: false, hydrating: true, hasOlder: false, loadingOlder: false };
112 private opened = new Set<string>();
113 private displayKeyBySubmission = new Map<string, string>();
114 private displayMessageBySubmission = new Map<string, string>();
115 private displayKeyByMessage = new Map<string, string>();
116 constructor(readonly sessionKey: string) { this.opened = new Set(foldViews.get(sessionKey)); }
117 getOrderSnapshot = () => this.order;
118 getNodeSnapshot = (key: string) => this.nodes.get(key);
119 getStatusSnapshot = () => this.status;
120 subscribeOrder = (listener: Listener) => { this.orderListeners.add(listener); return () => { this.orderListeners.delete(listener); }; };
121 subscribeStatus = (listener: Listener) => { this.statusListeners.add(listener); return () => { this.statusListeners.delete(listener); }; };
122 subscribeNode = (key: string, listener: Listener) => {
123 let listeners = this.nodeListeners.get(key);
124 if (!listeners) { listeners = new Set(); this.nodeListeners.set(key, listeners); }
125 listeners.add(listener);
126 return () => { listeners.delete(listener); if (!listeners.size) this.nodeListeners.delete(key); };
127 };
128 private put(node: ChatNode) {
129 const previous = this.nodes.get(node.key);
130 if (previous && shallowSame(previous, node)) return;
131 this.nodes.set(node.key, node);
132 this.dirty.add(node.key);
133 }
134 update(input: ChatInput) {
135 const previous = this.input;
136 this.input = input;
137 const { running, hydrating, hasOlder, loadingOlder, error, startedAt } = input;
138 const status = { running, hydrating, hasOlder, loadingOlder, error, startedAt };
139 if (!shallowSame(status, this.status)) { this.status = status; this.statusDirty = true; }
140 if (!previous || previous.items !== input.items || previous.visibleSubmissionHandoffs !== input.visibleSubmissionHandoffs || !sameReferences(previous.localSubmissions, input.localSubmissions)
141 || previous.running !== running || previous.hasOlder !== hasOlder) this.project(input);
142 this.updateLive(input.live, false);
143 this.schedule();
144 }
145 private project(input: ChatInput) {
146 for (const local of input.localSubmissions ?? []) if (local.messageId) this.displayMessageBySubmission.set(local.submissionId, local.messageId);
147 for (const [messageId, handoff] of Object.entries(input.visibleSubmissionHandoffs ?? {})) {
148 this.displayMessageBySubmission.set(handoff.submissionId, messageId);
149 }
150 const order: string[] = [];
151 const present = new Set<string>();
152 const groups: Array<{ key: string; turn?: number; user?: Extract<Item, { kind: "user" }>; items: Item[] }> = [];
153 let group: (typeof groups)[number] = { key: "history-head", items: [] };
154 groups.push(group);
155 for (const { item, local } of itemsWithLocalSubmissions(input)) {
156 if (item.kind === "user") {
157 const key = this.userDisplayKey(item, local, input.visibleSubmissionHandoffs);
158 group = { key, user: item, turn: item.checkpointTurn ?? item.historyTurn, items: [] };
159 groups.push(group);
160 } else group.items.push(item);
161 }
162 const groupKeys = new Set(groups.map(current => current.key));
163 for (const current of groups) {
164 if (!current.user && !current.items.length) continue;
165 const turnKey = current.key;
166 const active = current === groups[groups.length - 1] && input.running;
167 const latest = current === groups[groups.length - 1];
168 const cached = this.projectedGroups.get(turnKey);
169 if (cached && cached.user === current.user && cached.active === active && cached.latest === latest && sameReferences(cached.items, current.items)) {
170 cached.present.forEach(key => present.add(key));
171 order.push(...cached.order);
172 continue;
173 }
174 const groupPresent: string[] = [];
175 const groupOrderStart = order.length;
176 const add = (node: ChatNode, visible = true) => {
177 this.put(node); present.add(node.key); groupPresent.push(node.key); if (visible) order.push(node.key);
178 };
179 if (current.user) add({ kind: "user", key: turnKey, turnKey, item: current.user });
180 const answer = (current.items.find(item => item.kind === "assistant" && item.turnFinal)
181 ?? [...current.items].reverse().find(item => item.kind === "assistant" && item.turnFinal === undefined && item.text.trim())) as Extract<Item, { kind: "assistant" }> | undefined;
182 const answerIndex = answer ? current.items.indexOf(answer) : -1;
183 // Harness folds the completed process range, including recovered call errors.
184 // Terminal failures/recovery prompts remain independent and prevent auto-fold.
185 const failed = current.items.some((item, index) => item.kind === "assistant" && item.streaming
186 || item.kind === "tool" && item.status === "stopped"
187 || item.kind === "notice" && (item.action === "recover_context"
188 || index > answerIndex && !item.decisionReceipt && !item.completionSummary));
189 const mergedAudits = new Set(current.items.filter(item => {
190 const call = proxyAuditCall(item);
191 return call && current.items.some(tool => tool.kind === "tool" && tool.id === call);
192 }).map(item => item.id));
193 const members = current.items.flatMap(item => mergedAudits.has(item.id) ? [] : item.kind === "assistant"
194 ? [...(item !== answer ? [item.id] : []), `${item.id}:reasoning`]
195 : item.kind === "notice" && (item.level === "warn" || item.action === "recover_context")
196 || item.kind === "extension" && item.card.actions?.length ? [] : [item.id]);
197 const processKey = `${turnKey}:process`;
198 const old = this.nodes.get(processKey);
199 const stableMembers = old?.kind === "process" && sameKeys(old.members, members) ? old.members : members;
200 const hasProcess = current.items.some(item => item.kind === "tool" || item.kind === "phase" ||
201 item.kind === "assistant" && (item !== answer && item.text.trim() || item.reasoning.trim()));
202 const hasTrailingWork = current.items.slice(answerIndex + 1).some(item => item.kind === "tool" || item.kind === "phase" || item.kind === "assistant");
203 const foldable = Boolean(hasProcess && current.user && answer && !hasTrailingWork && !active && !failed && !current.user.failed);
204 const allCalls = current.items.filter((item): item is Extract<Item, { kind: "tool" }> => item.kind === "tool");
205 const calls = allCalls.filter(item => !item.parentId);
206 const subagentCount = calls.filter(item => ["task", "read_only_task", "parallel_tasks", "fleet", "subagent"].includes(item.name)).length;
207 add({ kind: "process", key: processKey, turnKey, members: stableMembers, foldable, collapsed: foldable && !this.opened.has(turnKey),
208 toolCallCount: calls.length - subagentCount, subagentCount,
209 messageCount: current.items.filter(item => item.kind === "assistant" && item !== answer && item.text.trim()).length,
210 failureCount: calls.filter(item => item.status === "error" || item.error).length });
211 for (const item of current.items) {
212 if (item.kind === "assistant") add({ kind: "reasoning", key: `${item.id}:reasoning`, turnKey, item });
213 add({ kind: item.kind, key: item.id, turnKey, item } as ItemNode, !mergedAudits.has(item.id) && !(item.kind === "tool" && item.parentId));
214 }
215 const declarations = allCalls.filter(item => item.name === "present" && item.status === "done" && !item.error && item.presentedFiles?.length);
216 const latestByPath = new Map<string, PresentedFileView>();
217 for (const call of declarations) {
218 for (const file of call.presentedFiles ?? []) latestByPath.set(file.path, { ...file, toolCallId: call.id });
219 }
220 const nextPresented = [...latestByPath.values()];
221 const presentedPaths = new Set(nextPresented.map(file => fileIdentity(file.path)));
222 const nextModified = deriveTurnFiles(allCalls).filter(file => !presentedPaths.has(fileIdentity(file.path)));
223 const tailKey = `${turnKey}:tail`;
224 const oldTail = this.nodes.get(tailKey);
225 const stablePresented = oldTail?.kind === "tail"
226 && oldTail.presentedFiles.length === nextPresented.length
227 && oldTail.presentedFiles.every((file, index) => file.path === nextPresented[index]?.path && file.description === nextPresented[index]?.description && file.toolCallId === nextPresented[index]?.toolCallId)
228 ? oldTail.presentedFiles : nextPresented;
229 const stableModified = oldTail?.kind === "tail"
230 && oldTail.modifiedFiles.length === nextModified.length
231 && oldTail.modifiedFiles.every((file, index) => file.path === nextModified[index]?.path && file.operation === nextModified[index]?.operation && file.toolCallId === nextModified[index]?.toolCallId)
232 ? oldTail.modifiedFiles : nextModified;
233 add({ kind: "tail", key: tailKey, turnKey, answerKey: answer?.id, turn: current.turn,
234 latest, presentedFiles: stablePresented, modifiedFiles: stableModified });
235 this.projectedGroups.set(turnKey, {
236 user: current.user, items: current.items, active, latest, present: groupPresent, order: order.slice(groupOrderStart),
237 });
238 }
239 for (const key of this.nodes.keys()) if (!present.has(key)) { this.nodes.delete(key); this.dirty.add(key); }
240 for (const key of this.projectedGroups.keys()) if (!groupKeys.has(key)) this.projectedGroups.delete(key);
241 for (const [submissionId, key] of this.displayKeyBySubmission) if (!groupKeys.has(key)) this.displayKeyBySubmission.delete(submissionId);
242 for (const submissionId of this.displayMessageBySubmission.keys()) if (!this.displayKeyBySubmission.has(submissionId)) this.displayMessageBySubmission.delete(submissionId);
243 for (const [messageId, key] of this.displayKeyByMessage) if (!groupKeys.has(key)) this.displayKeyByMessage.delete(messageId);
244 const children = new Map<string, Extract<ChatNode, { kind: "tool" }>[]>();
245 for (const node of this.nodes.values()) if (node.kind === "tool" && node.item.parentId) {
246 const list = children.get(node.item.parentId) ?? [];
247 list.push(node); children.set(node.item.parentId, list);
248 }
249 for (const key of new Set([...children.keys(), ...this.children.keys()])) {
250 const next = children.get(key) ?? emptyChildren;
251 const old = this.children.get(key) ?? emptyChildren;
252 if (next.length !== old.length || next.some((node, index) => node !== old[index])) {
253 this.children.set(key, next); this.dirty.add(`${key}:children`);
254 }
255 }
256 for (const key of this.opened) if (!groupKeys.has(key)) this.opened.delete(key);
257 if (!sameKeys(this.order, order)) { this.order = order; this.orderDirty = true; }
258 recordFrontendDiagnostic("transcript", "presentation", this.presentationStats());
259 }
260 private userDisplayKey(item: Extract<Item, { kind: "user" }>, local?: LocalSubmission, handoffs?: ChatInput["visibleSubmissionHandoffs"]): string {
261 if (local) {
262 const key = `submission:${local.submissionId}`;
263 this.displayKeyBySubmission.set(local.submissionId, key);
264 return key;
265 }
266 let key = item.messageId ? this.displayKeyByMessage.get(item.messageId) : undefined;
267 const submissionId = item.messageId ? handoffs?.[item.messageId]?.submissionId ?? item.submissionId : undefined;
268 const boundMessageId = submissionId ? this.displayMessageBySubmission.get(submissionId) : undefined;
269 if (!key && submissionId && (!boundMessageId || boundMessageId === item.messageId)) {
270 key = this.displayKeyBySubmission.get(submissionId);
271 }
272 key ??= item.id;
273 if (submissionId && this.displayKeyBySubmission.get(submissionId) === key && item.messageId) {
274 this.displayMessageBySubmission.set(submissionId, item.messageId);
275 }
276 if (item.messageId) this.displayKeyByMessage.set(item.messageId, key);
277 return key;
278 }
279 presentationStats() {
280 return { nodes: this.nodes.size, submissions: this.displayKeyBySubmission.size, messages: this.displayKeyByMessage.size };
281 }
282 /** Already frame-batched by the controller; no additional frame queue. */
283 updateLive(live: LiveStream | undefined, publish = true) {
284 if (live && this.status.running) {
285 const node = this.nodes.get(live.id);
286 if (node?.kind === "assistant") {
287 const item = { ...node.item, text: live.text, reasoning: live.reasoning, reasoningComplete: live.reasoningComplete, streaming: true };
288 if (!shallowSame(node.item, item)) {
289 this.put({ ...node, item });
290 const reasoning = this.nodes.get(`${node.key}:reasoning`);
291 if (reasoning?.kind === "reasoning") this.put({ ...reasoning, item });
292 }
293 }
294 }
295 if (publish) this.flush();
296 }
297 toggleProcess(turnKey: string) {
298 const node = this.nodes.get(`${turnKey}:process`);
299 if (node?.kind !== "process") return;
300 if (node.collapsed) this.opened.add(turnKey); else this.opened.delete(turnKey);
301 foldViews.delete(this.sessionKey); foldViews.set(this.sessionKey, new Set(this.opened));
302 if (foldViews.size > 100) foldViews.delete(foldViews.keys().next().value!);
303 this.put({ ...node, collapsed: node.foldable && !this.opened.has(turnKey) });
304 this.flush();
305 }
306 toolChildren(id: string) { return this.children.get(id) ?? emptyChildren; }
307 toolAudits(id: string): string[] {
308 return [...this.nodes.values()].flatMap(node => node.kind === "notice" && proxyAuditCall(node.item) === id
309 ? [`${node.item.text}\n${node.item.detail ?? ""}`] : []);
310 }
311 private schedule() {
312 if (this.scheduled) return;
313 this.scheduled = true;
314 const epoch = this.epoch;
315 queueMicrotask(() => { if (epoch === this.epoch) this.flush(); });
316 }
317 private flush() {
318 this.scheduled = false;
319 const dirty = this.dirty; this.dirty = new Set();
320 const orderDirty = this.orderDirty; this.orderDirty = false;
321 const statusDirty = this.statusDirty; this.statusDirty = false;
322 if (orderDirty) this.orderListeners.forEach(listener => listener());
323 if (statusDirty) this.statusListeners.forEach(listener => listener());
324 dirty.forEach(key => this.nodeListeners.get(key)?.forEach(listener => listener()));
325 }
326 dispose() {
327 this.epoch++; this.scheduled = false; this.dirty.clear();
328 this.orderListeners.clear(); this.statusListeners.clear(); this.nodeListeners.clear();
329 this.nodes.clear(); this.children.clear(); this.projectedGroups.clear();
330 this.displayKeyBySubmission.clear(); this.displayMessageBySubmission.clear(); this.displayKeyByMessage.clear();
331 this.order = []; this.input = undefined;
332 }
333 }
334 import { uniqueUserItems } from "./transcriptUserIdentity";
335
335 lines TYPESCRIPT