返回 DeepSeek-Reasonix
transcriptSnapshotState.ts
根目录 / desktop / frontend / src / lib / transcriptSnapshotState.ts
1 import type { HistoryMessage, WireEvent } from "./types";
2 import type { Item, State } from "./useController";
3 import type { TranscriptRecord, TranscriptSnapshot } from "./transcriptProtocol";
4 import { canonicalUserConfirmations, settleLocalSubmissions, settleRebasedSubmissions } from "./localSubmissionState";
5
6 export function snapshotRecords(snapshot: TranscriptSnapshot): TranscriptRecord[] {
7 if (!Number.isSafeInteger(snapshot.totalRecords) || snapshot.totalRecords < 0) {
8 throw new Error("invalid transcript snapshot record count");
9 }
10 const records: TranscriptRecord[] = [];
11 const indexes = new Map<string, number>();
12 const normalize = (record: TranscriptRecord): TranscriptRecord => {
13 if (!Number.isSafeInteger(record.order) || record.order < 0 || record.order >= snapshot.totalRecords) {
14 throw new Error("invalid transcript snapshot record identity");
15 }
16 const derived = record.message.messageId ? `m:${record.message.messageId}`
17 : record.message.role === "tool" && record.message.toolCallId ? `tool:${record.message.toolCallId}` : undefined;
18 const referencedID = record.refs.find(ref => ref.recordId)?.recordId;
19 let id = record.id || record.message.recordId || derived || referencedID;
20 if (!id) {
21 if (record.refs.length > 0) throw new Error("transcript snapshot content identity missing");
22 id = `view:legacy:${snapshot.snapshotId || `${snapshot.identity.runtimeEpoch}:${snapshot.projectionRevision}`}:${record.order}`;
23 }
24 if (record.message.recordId && record.message.recordId !== id) {
25 throw new Error("transcript snapshot record identity mismatch");
26 }
27 if (record.refs.some(ref => ref.recordId !== id)) throw new Error("transcript snapshot content identity mismatch");
28 return { ...record, id, message: { ...record.message, recordId: id } };
29 };
30 for (const raw of snapshot.records ?? []) {
31 const record = normalize(raw);
32 if (indexes.has(record.id)) throw new Error("duplicate transcript snapshot record identity");
33 indexes.set(record.id, records.length);
34 records.push(record);
35 }
36 const activeIDs = new Set<string>();
37 for (const raw of snapshot.activeRecords ?? []) {
38 const record = normalize(raw);
39 if (activeIDs.has(record.id)) throw new Error("duplicate active transcript snapshot record identity");
40 activeIDs.add(record.id);
41 // A mutable owner may also fall inside the requested page. The backend
42 // omits that duplicate, but tolerate older/remote implementations that
43 // return it in both arrays and keep the active copy authoritative.
44 const index = indexes.get(record.id);
45 if (index !== undefined) records[index] = record;
46 else { indexes.set(record.id, records.length); records.push(record); }
47 }
48 return records.sort((a, b) => a.order - b.order);
49 }
50
51 type Convert = (messages: HistoryMessage[], prefix: string) => { items: Item[]; seq: number };
52 type ApplyEvent = (state: State, event: WireEvent) => State;
53
54 // Durable rows only match durable identities. Optimistic submission identity is
55 // owned by localSubmissionState and must never replace a canonical item id.
56 export function matchingSnapshotItem(items: Item[], item: Item): Item | undefined {
57 if (item.kind === "user") {
58 const users = items.filter((candidate): candidate is Extract<Item, { kind: "user" }> => candidate.kind === "user");
59 const message = item.messageId && users.find(candidate => candidate.messageId === item.messageId);
60 if (message) return message;
61 }
62 return items.find(candidate => candidate.id === item.id);
63 }
64
65 function recordItemOrder(records: TranscriptRecord[], convert: Convert): Record<string, number> {
66 const order: Record<string, number> = {};
67 for (const record of records) {
68 const items = convert([{ ...record.message, recordId: record.id }], "snapshot:").items;
69 items.forEach((item, index) => { order[item.id] = Math.min(order[item.id] ?? Infinity, record.order + index / (items.length + 1)); });
70 }
71 return order;
72 }
73
74 export function transcriptPageState(state: State, page: TranscriptSnapshot, convert: Convert): State {
75 const records = snapshotRecords({ ...page, activeRecords: [] });
76 const converted = convert(records.map((record) => ({ ...record.message, recordId: record.id })), "snapshot:");
77 const existing = new Map(state.items.map((item) => [item.id, item]));
78 const order = { ...state.transcriptItemOrder };
79 for (const [id, position] of Object.entries(recordItemOrder(records, convert))) order[id] = Math.min(order[id] ?? Infinity, position);
80 const prefix: Item[] = [];
81 for (const item of converted.items) {
82 const prior = existing.get(item.id) ?? matchingSnapshotItem(state.items, item);
83 if (!prior) { prefix.push(item); continue; }
84 if (prior.kind === "tool" && item.kind === "tool") {
85 prefix.push({ ...prior, args: prior.args || item.args, messageId: prior.messageId || item.messageId,
86 name: prior.name === "tool" ? item.name : prior.name, subject: prior.subject ?? item.subject,
87 summary: prior.summary ?? item.summary, fileDiff: prior.fileDiff ?? item.fileDiff });
88 } else prefix.push(prior.id === item.id ? prior : { ...prior, ...item, id: item.id } as Item);
89 }
90 const prefixIDs = new Set(prefix.map((item) => item.id));
91 const added = prefix.filter((item) => !existing.has(item.id)).length;
92 const users = records.map((record) => record.message.historyTurn).filter((turn): turn is number => typeof turn === "number" && turn > 0);
93 const items = [...prefix, ...state.items.filter((item) => !prefixIDs.has(item.id))];
94 items.sort((a, b) => (order[a.id] ?? Infinity) - (order[b.id] ?? Infinity));
95 return settleLocalSubmissions({ ...state, items, transcriptItemOrder: order,
96 seq: Math.max(state.seq, converted.seq), historyPrefixCount: state.historyPrefixCount + added,
97 historyStartTurn: Math.min(state.historyStartTurn, ...users.map((turn) => turn - 1)),
98 historyHasOlder: page.hasOlder, historyOlderLoading: false, historyOlderError: undefined,
99 historyHasNewer: false, historyNewerLoading: false, historyNewerError: undefined,
100 historyMutation: { seq: state.historyMutation.seq + 1, kind: "prepend" } }, items, canonicalUserConfirmations(converted.items));
101 }
102
103 /** One reducer transaction installs rows, runtime and the active attempt.
104 * The event projector advances coverage only after this function commits. */
105 export function transcriptSnapshotState(state: State, snapshot: TranscriptSnapshot, convert: Convert, applyEvent: ApplyEvent, clock: number, projectedItems?: Item[]): State {
106 const sessionId = snapshot.identity.sessionId;
107 if (state.transcriptSessionId && state.transcriptSessionId !== sessionId) {
108 state = { ...state, localSubmissions: {}, localSubmissionOrder: [], visibleSubmissionHandoffs: {},
109 pendingSubmissionId: undefined, pendingUser: undefined, sessionGen: state.sessionGen + 1 };
110 }
111 const records = snapshotRecords(snapshot);
112 const messages = records.map((record) => ({ ...record.message, recordId: record.id }));
113 const converted = projectedItems ? { items: projectedItems, seq: state.seq } : convert(messages, "snapshot:");
114 state = settleLocalSubmissions(state, converted.items, canonicalUserConfirmations(messages.map(message => ({
115 kind: message.role, messageId: message.messageId, submissionId: message.submissionId, turnId: message.turnId,
116 }))));
117 const active = snapshot.runtime.status === "queued" || snapshot.runtime.status === "in_progress" ||
118 snapshot.runtime.status === "waiting_user" || snapshot.runtime.status === "cancelling";
119 // Older serves and history-rebased projections omit message ids, so the
120 // id-keyed settlement above cannot retire an echo the server already owns.
121 state = settleRebasedSubmissions(state, state.items,
122 converted.items.filter((item): item is Extract<Item, { kind: "user" }> => item.kind === "user"), !active);
123 const order = projectedItems ? Object.fromEntries(projectedItems.map((item, index) => [item.id, index])) : recordItemOrder(records, convert);
124 const users = state.items.filter((item): item is Extract<Item, { kind: "user" }> => item.kind === "user");
125 const items = converted.items.map((item) => {
126 if (item.kind === "tool" && item.resultMissing && snapshot.runtime.status &&
127 ["queued", "in_progress", "waiting_user", "cancelling"].includes(snapshot.runtime.status) &&
128 messages.some(message => message.turnId === snapshot.runtime.turnId && message.toolCalls?.some(call => call.id === item.id))) {
129 return { ...item, status: "running" as const };
130 }
131 if (item.kind !== "user") return item;
132 const mounted = matchingSnapshotItem(users, item);
133 if (!mounted) return item;
134 const next = { ...mounted, ...item, id: item.id };
135 return Object.entries(next).every(([key, value]) => (mounted as unknown as Record<string, unknown>)[key] === value) ? mounted : next;
136 });
137 const hasLocalSubmission = Boolean(state.pendingSubmissionId && state.localSubmissions[state.pendingSubmissionId]
138 && state.localSubmissions[state.pendingSubmissionId].status !== "failed");
139 let next: State = {
140 ...state,
141 transcriptSessionId: sessionId,
142 transcriptProtocol: 1,
143 transcriptItemOrder: order,
144 discardTurn: false,
145 assistantSegmentOrdinal: active ? 1 : 0,
146 turnStartAt: snapshot.runtime.startedAt ?? (state.activeTurnId === snapshot.runtime.turnId ? state.turnStartAt : 0),
147 resolvedPromptId: undefined,
148 items: [...items, ...users.filter(user => user.failed && !items.some(item => item.id === user.id)),
149 ...state.items.filter(item => item.kind === "notice" && item.local)],
150 offscreenItems: undefined,
151 seq: Math.max(state.seq, converted.seq),
152 running: active || hasLocalSubmission,
153 turnActive: active,
154 pendingPrompt: false,
155 cancelRequested: snapshot.runtime.status === "cancelling",
156 cancellable: active || hasLocalSubmission,
157 activeTurnId: active ? snapshot.runtime.turnId : undefined,
158 turnPhase: active ? snapshot.runtime.phase : undefined,
159 completionSummary: snapshot.runtime.completionSummary,
160 runtimeStatusEpoch: snapshot.identity.runtimeEpoch,
161 runtimeStatusSeq: snapshot.coveredThroughSeq,
162 runtimeStatusSnapshotAt: clock,
163 turnLifecycleObservedAt: clock,
164 pendingUser: hasLocalSubmission ? state.pendingUser : undefined,
165 pendingSubmissionId: hasLocalSubmission ? state.pendingSubmissionId : undefined,
166 live: undefined,
167 currentAssistant: undefined,
168 streamAttemptJournal: undefined,
169 approval: undefined,
170 ask: undefined,
171 mcpInteraction: undefined,
172 retry: undefined,
173 promptArrivedAt: undefined,
174 promptArrivedId: undefined,
175 promptEpoch: state.promptEpoch + 1,
176 promptWaitStartedAt: undefined,
177 turnWaitAccumMs: 0,
178 hydrateHistoryLoaded: true,
179 hydratePlaceholderItems: undefined,
180 historyPrefixCount: items.length,
181 historyStartTurn: Math.max(0, Math.min(...messages.filter((m) => m.role === "user" && m.historyTurn).map((m) => m.historyTurn!), snapshot.totalTurns) - 1),
182 historyEndTurn: snapshot.totalTurns,
183 historyTotalTurns: snapshot.totalTurns,
184 historyHasOlder: snapshot.hasOlder,
185 historyHasNewer: false,
186 historyOlderLoading: false,
187 historyOlderError: undefined,
188 historyNewerLoading: false,
189 historyNewerError: undefined,
190 historyRevision: undefined,
191 historyDigest: undefined,
192 historyMutation: { seq: state.historyMutation.seq + 1, kind: "replace" },
193 };
194 for (const event of snapshot.runtime.pendingEvents ?? []) {
195 next = applyEvent(next, { ...event, runtimeEpoch: snapshot.identity.runtimeEpoch });
196 }
197 const attempts = snapshot.activeAttempts ?? [];
198 const attempt = attempts[attempts.length - 1];
199 if (active && attempt) {
200 const id = `m:${attempt.messageId}`;
201 const message = messages.find((message) => message.messageId === attempt.messageId);
202 const live = { id, text: message?.content ?? "", reasoning: message?.reasoning ?? "", reasoningComplete: false };
203 next = { ...next, currentAssistant: id, live, streamAttemptJournal: {
204 id: attempt.id,
205 baselineLive: { ...live, text: "", reasoning: "" },
206 baselineTurnArgChars: 0,
207 createdToolIds: message?.toolCalls?.map((tool) => tool.id).filter(Boolean) ?? [],
208 priorTools: {},
209 } };
210 }
211 return next;
212 }
213
213 lines TYPESCRIPT