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