返回 DeepSeek-Reasonix
canonicalTranscriptBackend.ts
根目录 / desktop / frontend / src / lib / canonicalTranscriptBackend.ts
1 import type { PersistentMessage } from "../generated/desktopContract.generated";
2 import { HistoryPreparingError } from "./historyPreparation";
3 import { canonicalUserDisplay } from "./canonicalUserDisplay";
4 import type { HistoryContentChunk, HistoryContentRef, HistoryEntry, HistoryMessage, HistorySlice, HistorySliceRequest, HistoryWindowPageView, HistoryWindowRequestView, MemoryCitation } from "./types";
5
6 const contentRecovery = new Map<string, () => void>();
7 export function registerTranscriptContentRecovery(tabId: string, recover: () => void): () => void {
8 contentRecovery.set(tabId, recover);
9 return () => { if (contentRecovery.get(tabId) === recover) contentRecovery.delete(tabId); };
10 }
11
12 function asWireObject(value: unknown): Record<string, unknown> {
13 return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
14 }
15
16 export function canonicalMessage(message: PersistentMessage, body: unknown): HistoryMessage {
17 const raw = asWireObject(body);
18 if (raw.role === "notice") return { role: "notice", messageId: String(raw.id ?? message.messageId), content: String(raw.content ?? ""), detail: typeof raw.detail === "string" ? raw.detail : undefined, code: typeof raw.code === "string" ? raw.code : undefined, level: raw.level === "warn" ? "warn" : "info" };
19 const decisionReceipt = asWireObject(raw.decision_receipt);
20 if (Object.keys(decisionReceipt).length > 0) {
21 return { role: "notice", messageId: String(raw.id ?? message.messageId), content: "", code: "decision_receipt", level: "info", decisionReceipt: decisionReceipt as unknown as HistoryMessage["decisionReceipt"] };
22 }
23 const readPause = asWireObject(raw.read_pause);
24 if (Boolean(raw.local_only) && Object.keys(readPause).length > 0) {
25 return { role: "notice", messageId: String(raw.id ?? message.messageId), content: "", code: "incomplete_read", level: "info", readPause: readPause as unknown as HistoryMessage["readPause"] };
26 }
27 const readiness = asWireObject(raw.final_readiness_recovery);
28 if (Boolean(raw.local_only) && readiness.pending === true) {
29 return { role: "notice", messageId: String(raw.id ?? message.messageId), content: "Final checks are still required before this task is complete.", code: "historical_checks", level: "info", readiness: { missing: Array.isArray(readiness.missing) ? readiness.missing.map(String) : undefined } };
30 }
31 const protocolRecovery = asWireObject(raw.protocol_recovery);
32 if (Boolean(raw.local_only) && protocolRecovery.state === "pending" && typeof protocolRecovery.id === "string") {
33 return { role: "notice", messageId: String(raw.id ?? message.messageId), content: "", code: "protocol_recovery", level: "info", pending: true, protocolRecovery: { id: protocolRecovery.id } };
34 }
35 const toolCalls = (Array.isArray(raw.tool_calls) ? raw.tool_calls as Record<string, unknown>[] : []).map(call => ({
36 resultObservation: message.toolObservations?.[String(call.id ?? "")],
37 id: String(call.id ?? ""), name: String(call.name ?? ""), arguments: String(call.arguments ?? ""),
38 resolvedName: typeof call.resolved_name === "string" ? call.resolved_name : undefined,
39 capabilityId: typeof call.capability_id === "string" ? call.capability_id : undefined,
40 resolvedReadOnly: typeof call.resolved_read_only === "boolean" ? call.resolved_read_only : undefined,
41 diff: typeof call.diff === "string" ? call.diff : undefined,
42 added: typeof call.added === "number" ? call.added : undefined,
43 removed: typeof call.removed === "number" ? call.removed : undefined,
44 }));
45 const presented = asWireObject(raw.presented_files);
46 const role = Boolean(raw.local_only) ? "assistant" : String(raw.role ?? message.role);
47 const display = role === "user" ? canonicalUserDisplay(raw, message.preview ?? "") : { role, content: String(raw.content ?? raw.raw_content ?? message.preview ?? "") };
48 return {
49 turnFinal: message.turnFinal ?? false,
50 samplingCount: message.samplingCount ?? undefined,
51 toolCount: message.toolCount ?? undefined,
52 turnDurationMs: message.turnDurationMs,
53 role: display.role,
54 messageId: String(raw.id ?? message.messageId),
55 submissionId: message.submissionId,
56 content: display.content,
57 reasoning: typeof raw.reasoning_content === "string" ? raw.reasoning_content : undefined,
58 createdAt: typeof raw.createdAt === "number" ? raw.createdAt : undefined,
59 workDurationMs: typeof raw.workDurationMs === "number" ? raw.workDurationMs : undefined,
60 toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
61 toolCallId: typeof raw.tool_call_id === "string" ? raw.tool_call_id : undefined,
62 toolName: typeof raw.name === "string" ? raw.name : undefined,
63 memoryCitations: Array.isArray(raw.memoryCitations) ? raw.memoryCitations as MemoryCitation[] : undefined,
64 serverSearch: Array.isArray(raw.server_search) ? raw.server_search as HistoryMessage["serverSearch"] : undefined,
65 execution: Object.keys(asWireObject(raw.tool_execution)).length > 0 ? raw.tool_execution as HistoryMessage["execution"] : undefined,
66 presentedFiles: Array.isArray(presented.files) ? presented.files as HistoryMessage["presentedFiles"] : undefined,
67 readCompletion: Object.keys(asWireObject(raw.read_completion)).length > 0 ? raw.read_completion as HistoryMessage["readCompletion"] : undefined,
68 };
69 }
70
71 export function resolvedHistoryField(message: HistoryMessage, field: string): string | undefined {
72 switch (field) {
73 case "content": return message.content;
74 case "reasoning": return message.reasoning;
75 case "submitText": return message.submitText;
76 case "detail": return message.detail;
77 case "code": return message.code;
78 case "summary": return message.summary;
79 case "archive": return message.archive;
80 case "toolResultError": return message.toolResultError;
81 default: return message.content;
82 }
83 }
84
85 // ── binding identity ────────────────────────────────────────────────────────
86 // A tab's history comes from exactly one place: the host that owns its
87 // binding. Crossing over on a failed call would let a transient local error
88 // (busy, conflict, timeout) be answered by a different service holding
89 // different data, so routing is decided by identity before the request, never
90 // by the outcome of one.
91 export type TranscriptBindingIdentity = "local" | "remote";
92
93 let bindingIdentityFor: ((tabId: string) => TranscriptBindingIdentity) | undefined;
94
95 /** Installed by the app layer, which is where tab metadata lives. */
96 export function setTranscriptBindingIdentity(resolver: (tabId: string) => TranscriptBindingIdentity): void {
97 bindingIdentityFor = resolver;
98 }
99
100 // An unregistered identity is a tab with no remote binding, which is what a
101 // local session is. This is a default, not a fallback: it never moves a
102 // request to the other service because the first one answered badly.
103 function identityFor(tabId: string): TranscriptBindingIdentity {
104 try {
105 return bindingIdentityFor?.(tabId) ?? "local";
106 } catch {
107 return "local";
108 }
109 }
110
111 export function entriesFor(messages: PersistentMessage[], snapshotSequence: number): HistoryEntry[] {
112 return messages.map(persistent => {
113 const entryId = `m:${persistent.messageId}`;
114 return {
115 entryId, turn: persistent.visibleTurn ?? 0, order: persistent.position,
116 message: canonicalMessage(persistent, persistent.inline),
117 refs: persistent.contentRef ? [{
118 entryId, field: "canonicalMessage", size: persistent.contentRef.bytes,
119 chunks: Math.max(1, Math.ceil(persistent.contentRef.bytes / (1 << 20))),
120 revision: snapshotSequence, revKnown: true, digest: persistent.contentRef.digest,
121 canonicalRef: persistent.contentRef,
122 }] : [],
123 };
124 });
125 }
126
127 export async function canonicalHistoryWindow(tabId: string, req: HistoryWindowRequestView): Promise<HistoryWindowPageView> {
128 const remote = identityFor(tabId) === "remote";
129 const { readCanonicalHistoryWindow } = await import("./canonicalHistoryWindow");
130 return readCanonicalHistoryWindow(tabId, req, remote);
131 }
132
133 function staleSlice(): HistorySlice {
134 return { entries: [], nextCursor: "", hasOlder: false, hasNewer: false, newerCursor: "", totalTurns: 0, startTurn: 0, endTurn: 0, stale: true, revision: 0 };
135 }
136
137 /** A window page in the page-shaped form the resident store already consumes. */
138 function sliceFromWindow(window: HistoryWindowPageView, source: string): HistorySlice {
139 return {
140 entries: window.entries,
141 nextCursor: window.olderCursor,
142 hasOlder: window.hasOlder,
143 newerCursor: window.newerCursor,
144 hasNewer: window.hasNewer,
145 totalTurns: window.totalTurns,
146 startTurn: window.startTurn,
147 endTurn: window.endTurn,
148 stale: false,
149 revision: window.revision,
150 revisionKnown: window.revisionKnown,
151 digest: window.digest,
152 source,
153 };
154 }
155
156 // turnWindowStatus maps a window status onto the page contract the store
157 // already understands. Empty pages alone are never an error, and a stale
158 // cursor is an answer rather than a failure.
159 function requireReadyWindow(window: HistoryWindowPageView): HistorySlice | undefined {
160 switch (window.status) {
161 case "ready": return sliceFromWindow(window, "window");
162 case "stale_cursor": return staleSlice();
163 case "preparing": throw new HistoryPreparingError();
164 case "failed": throw new Error("Session history is failed");
165 case "not_found": throw new Error("Session history is unavailable for this session");
166 default: return undefined;
167 }
168 }
169
170 export async function canonicalHistorySlice(tabId: string, req: HistorySliceRequest): Promise<HistorySlice> {
171 const cursor = req.cursor ?? "";
172 const limit = Math.min(100, Math.max(1, req.entries ?? 32));
173 if (cursor.startsWith("reasonix:message:")) {
174 const [id, sequence, generation] = cursor.slice("reasonix:message:".length).split(":");
175 const messageId = decodeURIComponent(id);
176 const snapshotSequence = Number(sequence);
177 if (!Number.isSafeInteger(snapshotSequence) || snapshotSequence < 0 || generation === undefined) return staleSlice();
178 const window = await canonicalHistoryWindow(tabId, { anchor: "message", messageId, snapshotSequence,
179 generation: decodeURIComponent(generation), direction: req.newer ? "newer" : "older", limit });
180 const ready = requireReadyWindow(window);
181 if (!ready) throw new Error("Transcript v2 requires an updated Desktop and Serve");
182 return ready;
183 }
184 // A cursor names a position inside a fixed canonical history window.
185 if (cursor !== "" || req.newer) {
186 const window = await canonicalHistoryWindow(tabId, {
187 anchor: "cursor",
188 cursor,
189 direction: req.newer ? "newer" : "older",
190 limit,
191 });
192 const ready = requireReadyWindow(window);
193 if (ready) return ready;
194 throw new Error("Transcript v2 requires an updated Desktop and Serve");
195 }
196 // The newest page carries the cursor for the same bidirectional window.
197 const window = await canonicalHistoryWindow(tabId, { anchor: "newest", direction: "older", limit });
198 const ready = requireReadyWindow(window);
199 if (ready) return { ...ready, source: "recent" };
200 throw new Error("Transcript v2 requires an updated Desktop and Serve");
201 }
202
203 /** Deferred bodies are loaded only after a transcript requests their content. */
204 export async function canonicalHistoryContent(tabID: string, ref: HistoryContentRef, chunkIndex: number): Promise<HistoryContentChunk> {
205 const remote = identityFor(tabID) === "remote";
206 const recover = contentRecovery.get(tabID);
207 const { readCanonicalHistoryContent } = await import("./canonicalHistoryContent");
208 return readCanonicalHistoryContent(tabID, ref, chunkIndex, remote, () => {
209 if (contentRecovery.get(tabID) === recover) recover?.();
210 });
211 }
212
212 lines TYPESCRIPT