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