返回 DeepSeek-Reasonix
hydrateHistoryApply.ts
根目录 / desktop / frontend / src / lib / hydrateHistoryApply.ts
1 import { sameSessionIdentity, sessionIdentityStableKey, type SessionIdentity } from "./sessionIdentity";
2
3 /** Live-turn markers that a lagging history snapshot must not replace. */
4 export type HydrateLiveState = {
5 running?: boolean;
6 turnActive?: boolean;
7 live?: unknown;
8 currentAssistant?: unknown;
9 pendingUser?: unknown;
10 historyTotalTurns?: number;
11 items: ReadonlyArray<{ kind: string; streaming?: boolean; status?: string }>;
12 historyRevision?: number;
13 historyDigest?: string;
14 };
15
16 export type HydratedHistoryApplyMode = "replace" | "prepend" | "skip";
17
18 export type HydrateProjection = {
19 items: ReadonlyArray<unknown>;
20 revision?: number;
21 digest?: string;
22 };
23
24 export type SessionHydrateIdentity = SessionIdentity;
25
26 export type HydrateSurfacePolicy = "preserve-current" | "replace-surface";
27
28 type ActiveTabHydrationTarget = SessionHydrateIdentity & {
29 sessionRevision?: number;
30 sessionDigest?: string;
31 };
32
33 export type ActiveTabHydrationLoadOptions = ActiveTabHydrationTarget & {
34 preserveCachedHistory: boolean;
35 surfacePolicy?: HydrateSurfacePolicy;
36 };
37
38 export function activeTabHydrationPlan(
39 target: ActiveTabHydrationTarget,
40 current: SessionHydrateIdentity | undefined,
41 reset: boolean,
42 requestedPolicy?: HydrateSurfacePolicy,
43 requestedCache?: boolean,
44 ): {
45 sameSession: boolean;
46 surfacePolicy: HydrateSurfacePolicy;
47 loadOptions: ActiveTabHydrationLoadOptions;
48 } {
49 const sameSession = sameSessionHydrateIdentity(target, current);
50 const surfacePolicy = requestedPolicy ?? (sameSession ? "preserve-current" : "replace-surface");
51 if (surfacePolicy === "replace-surface") {
52 return {
53 sameSession,
54 surfacePolicy,
55 loadOptions: {
56 preserveCachedHistory: false,
57 surfacePolicy,
58 session: target.session,
59 sessionPath: target.sessionPath,
60 sessionRevision: target.sessionRevision,
61 sessionDigest: target.sessionDigest,
62 sessionGeneration: target.sessionGeneration,
63 },
64 };
65 }
66 return {
67 sameSession,
68 surfacePolicy,
69 loadOptions: {
70 preserveCachedHistory: sameSession && (requestedCache ?? !reset),
71 session: target.session,
72 sessionPath: target.sessionPath,
73 sessionRevision: target.sessionRevision,
74 sessionDigest: target.sessionDigest,
75 },
76 };
77 }
78
79 type UnboundLiveSurfaceState = HydrateLiveState & {
80 hydrateHistoryLoaded?: boolean;
81 };
82
83 /** A surface may retain content only when the target session is provably the same. */
84 export function sameSessionHydrateIdentity(
85 target: SessionHydrateIdentity | undefined,
86 current: SessionHydrateIdentity | undefined,
87 ): boolean {
88 return sameSessionIdentity(target, current);
89 }
90
91 /**
92 * Adopt only a live runtime tail that has never been bound to persisted
93 * history. This is the compatibility bridge for background runtime events
94 * that predate the tab metadata snapshot: it must never retain a resident
95 * transcript merely because the tab id matches.
96 */
97 export function canAdoptUnboundLiveSurface(
98 target: SessionHydrateIdentity | undefined,
99 current: SessionHydrateIdentity | undefined,
100 state: UnboundLiveSurfaceState | undefined,
101 backendRunning: boolean,
102 targetRuntimeEpoch?: string,
103 currentRuntimeEpoch?: string,
104 ): boolean {
105 if (!backendRunning || !state) return false;
106 if (!sessionIdentityStableKey(target) || sessionIdentityStableKey(current)) return false;
107 if (state.hydrateHistoryLoaded || (state.historyTotalTurns ?? 0) > 0) return false;
108 if (state.historyRevision !== undefined || (state.historyDigest ?? "").trim()) return false;
109 if (!state.running && !state.turnActive) return false;
110 if (targetRuntimeEpoch && currentRuntimeEpoch && targetRuntimeEpoch !== currentRuntimeEpoch) return false;
111 return Boolean(
112 state.live ||
113 state.currentAssistant ||
114 state.pendingUser !== undefined ||
115 state.items.some((item) =>
116 (item.kind === "assistant" && item.streaming) ||
117 (item.kind === "tool" && item.status === "running"),
118 ),
119 );
120 }
121
122 export function shouldPreferResidentHistory(reset: boolean, preserveCachedHistory?: boolean): boolean {
123 return !reset && preserveCachedHistory !== false;
124 }
125
126 function sameHydrateFingerprint(state: HydrateLiveState | undefined, projection: HydrateProjection | undefined): boolean {
127 if (!state || !projection) return false;
128 const revision = projection.revision ?? 0;
129 const digest = (projection.digest ?? "").trim();
130 if (revision > 0 && state.historyRevision === revision) return true;
131 if (digest !== "" && (state.historyDigest ?? "") === digest) return true;
132 return false;
133 }
134
135 export function isStaleResidentProjection(
136 state: HydrateLiveState | undefined,
137 projection: HydrateProjection | undefined,
138 ): boolean {
139 if (!state || !projection || state.items.length === 0) return false;
140 if (projection.items.length >= state.items.length) return false;
141 return sameHydrateFingerprint(state, projection);
142 }
143
144 // A live turn is only "cached" once a history page has landed behind it.
145 // Without that, a session opened mid-stream reports a cached turn, skips the
146 // fetch, and streams over a blank transcript.
147 export function hasCachedLiveTurn(state: HydrateLiveState | undefined): boolean {
148 if (!state?.running && !state?.turnActive) return false;
149 if ((state.historyTotalTurns ?? 0) === 0) return false;
150 if (state.live || state.currentAssistant || state.pendingUser !== undefined) return true;
151 return state.items.some((item) =>
152 (item.kind === "assistant" && item.streaming) ||
153 (item.kind === "tool" && item.status === "running"),
154 );
155 }
156
157 export function hasReusableCachedTranscript(
158 state: (HydrateLiveState & { meta?: SessionHydrateIdentity }) | undefined,
159 target: SessionHydrateIdentity,
160 revision?: number,
161 digest?: string,
162 ): boolean {
163 if (!state || state.items.length === 0 || state.historyTotalTurns === 0) return false;
164 if (sessionIdentityStableKey(target) && !sameSessionHydrateIdentity(target, state.meta)) return false;
165 if (typeof revision === "number" && revision > 0) {
166 return state.historyRevision === revision && (digest ?? "") === (state.historyDigest ?? "");
167 }
168 if ((digest ?? "").trim() !== "") return state.historyDigest === digest;
169 // Missing backend fingerprints must not bless a resident page that already
170 // has one; the sidecar may be between atomic replacements.
171 return state.historyRevision === undefined && !state.historyDigest;
172 }
173
174 // An empty surface has to apply history or switch-back shows Welcome. A turn
175 // that has already streamed rows keeps them — but a tab with no history page
176 // behind it still gets one, prepended, instead of a blank transcript above the
177 // live turn. Only an already-hydrated live turn is left alone. An idle
178 // same-fingerprint resident page that is shorter than the visible transcript
179 // is skipped so Retry/clear cannot roll the chat back.
180 export function hydratedHistoryApplyMode(
181 skipHistory: boolean,
182 hasProjection: boolean,
183 foregroundTurnActive: boolean,
184 state: HydrateLiveState | undefined,
185 projection?: HydrateProjection,
186 ): HydratedHistoryApplyMode {
187 if (skipHistory || !hasProjection) return "skip";
188 if (!foregroundTurnActive) return isStaleResidentProjection(state, projection) ? "skip" : "replace";
189 if ((state?.items.length ?? 0) === 0 && !hasCachedLiveTurn(state)) return "replace";
190 return (state?.historyTotalTurns ?? 0) === 0 ? "prepend" : "skip";
191 }
192
193 type SignatureItem = {
194 kind: string;
195 id: string;
196 messageId?: string;
197 text?: string;
198 reasoning?: string;
199 name?: string;
200 level?: string;
201 trigger?: string;
202 messages?: number;
203 surfaceKey?: string;
204 generation?: number;
205 };
206
207 function itemSignature(item: SignatureItem): string {
208 switch (item.kind) {
209 case "tool": return `tool|${item.id}|${item.name ?? ""}`;
210 case "extension": return `extension|${item.surfaceKey ?? ""}|${item.generation ?? 0}`;
211 case "compaction": return `compaction|${item.trigger ?? ""}|${item.messages ?? 0}`;
212 default: return `${item.kind}|${item.level ?? ""}|${item.text ?? ""}|${item.reasoning ?? ""}`;
213 }
214 }
215
216 // A page read while its turn is live can already carry rows the live stream
217 // rendered. Only a suffix of the page can overlap a prefix of the live rows, so
218 // the longest such match is the duplicate set.
219 export function duplicateLiveItemIds(
220 pageItems: readonly SignatureItem[],
221 liveItems: readonly SignatureItem[],
222 ): string[] {
223 // Stable backend identities are independent of where a page cuts the live
224 // turn. In particular page [A,B,C] already covers live [A,B]. Content equality
225 // cannot establish this relation: two messages may intentionally be equal.
226 const identity = (item: SignatureItem) => item.messageId ? `m:${item.messageId}` : item.id;
227 const isIdentified = (item: SignatureItem) => !!item.messageId || item.id.startsWith("m:");
228 const canonicalIds = new Set(pageItems.filter((item) => isIdentified(item) || item.kind === "tool").map(identity));
229 const identified = liveItems.filter((item) => canonicalIds.has(identity(item))).map((item) => item.id);
230 if (pageItems.some(isIdentified) || liveItems.some(isIdentified)) return identified;
231 for (let k = Math.min(pageItems.length, liveItems.length); k > 0; k -= 1) {
232 let same = true;
233 for (let i = 0; i < k && same; i += 1) {
234 same = itemSignature(pageItems[pageItems.length - k + i]) === itemSignature(liveItems[i]);
235 }
236 if (same) return liveItems.slice(0, k).map((item) => item.id);
237 }
238 return [];
239 }
240
241 export function sameSessionPlaceholderItems<T>(
242 target: SessionHydrateIdentity | undefined,
243 prev: { meta?: SessionHydrateIdentity; items?: T[] } | undefined,
244 ): T[] | undefined {
245 return sameSessionHydrateIdentity(target, prev?.meta) ? prev?.items : undefined;
246 }
247
247 lines TYPESCRIPT