返回 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 UnboundLiveSurfaceState = HydrateLiveState & {
29 hydrateHistoryLoaded?: boolean;
30 };
31
32 /** A surface may retain content only when the target session is provably the same. */
33 export function sameSessionHydrateIdentity(
34 target: SessionHydrateIdentity | undefined,
35 current: SessionHydrateIdentity | undefined,
36 ): boolean {
37 return sameSessionIdentity(target, current);
38 }
39
40 /**
41 * Adopt only a live runtime tail that has never been bound to persisted
42 * history. This is the compatibility bridge for background runtime events
43 * that predate the tab metadata snapshot: it must never retain a resident
44 * transcript merely because the tab id matches.
45 */
46 export function canAdoptUnboundLiveSurface(
47 target: SessionHydrateIdentity | undefined,
48 current: SessionHydrateIdentity | undefined,
49 state: UnboundLiveSurfaceState | undefined,
50 backendRunning: boolean,
51 targetRuntimeEpoch?: string,
52 currentRuntimeEpoch?: string,
53 ): boolean {
54 if (!backendRunning || !state) return false;
55 if (!sessionIdentityStableKey(target) || sessionIdentityStableKey(current)) return false;
56 if (state.hydrateHistoryLoaded || (state.historyTotalTurns ?? 0) > 0) return false;
57 if (state.historyRevision !== undefined || (state.historyDigest ?? "").trim()) return false;
58 if (!state.running && !state.turnActive) return false;
59 if (targetRuntimeEpoch && currentRuntimeEpoch && targetRuntimeEpoch !== currentRuntimeEpoch) return false;
60 return Boolean(
61 state.live ||
62 state.currentAssistant ||
63 state.pendingUser !== undefined ||
64 state.items.some((item) =>
65 (item.kind === "assistant" && item.streaming) ||
66 (item.kind === "tool" && item.status === "running"),
67 ),
68 );
69 }
70
71 export function shouldPreferResidentHistory(reset: boolean, preserveCachedHistory?: boolean): boolean {
72 return !reset && preserveCachedHistory !== false;
73 }
74
75 function sameHydrateFingerprint(state: HydrateLiveState | undefined, projection: HydrateProjection | undefined): boolean {
76 if (!state || !projection) return false;
77 const revision = projection.revision ?? 0;
78 const digest = (projection.digest ?? "").trim();
79 if (revision > 0 && state.historyRevision === revision) return true;
80 if (digest !== "" && (state.historyDigest ?? "") === digest) return true;
81 return false;
82 }
83
84 export function isStaleResidentProjection(
85 state: HydrateLiveState | undefined,
86 projection: HydrateProjection | undefined,
87 ): boolean {
88 if (!state || !projection || state.items.length === 0) return false;
89 if (projection.items.length >= state.items.length) return false;
90 return sameHydrateFingerprint(state, projection);
91 }
92
93 // A live turn is only "cached" once a history page has landed behind it.
94 // Without that, a session opened mid-stream reports a cached turn, skips the
95 // fetch, and streams over a blank transcript.
96 export function hasCachedLiveTurn(state: HydrateLiveState | undefined): boolean {
97 if (!state?.running && !state?.turnActive) return false;
98 if ((state.historyTotalTurns ?? 0) === 0) return false;
99 if (state.live || state.currentAssistant || state.pendingUser !== undefined) return true;
100 return state.items.some((item) =>
101 (item.kind === "assistant" && item.streaming) ||
102 (item.kind === "tool" && item.status === "running"),
103 );
104 }
105
106 export function hasReusableCachedTranscript(
107 state: (HydrateLiveState & { meta?: SessionHydrateIdentity }) | undefined,
108 target: SessionHydrateIdentity,
109 revision?: number,
110 digest?: string,
111 ): boolean {
112 if (!state || state.items.length === 0 || state.historyTotalTurns === 0) return false;
113 if (sessionIdentityStableKey(target) && !sameSessionHydrateIdentity(target, state.meta)) return false;
114 if (typeof revision === "number" && revision > 0) {
115 return state.historyRevision === revision && (digest ?? "") === (state.historyDigest ?? "");
116 }
117 if ((digest ?? "").trim() !== "") return state.historyDigest === digest;
118 // Missing backend fingerprints must not bless a resident page that already
119 // has one; the sidecar may be between atomic replacements.
120 return state.historyRevision === undefined && !state.historyDigest;
121 }
122
123 // An empty surface has to apply history or switch-back shows Welcome. A turn
124 // that has already streamed rows keeps them — but a tab with no history page
125 // behind it still gets one, prepended, instead of a blank transcript above the
126 // live turn. Only an already-hydrated live turn is left alone. An idle
127 // same-fingerprint resident page that is shorter than the visible transcript
128 // is skipped so Retry/clear cannot roll the chat back.
129 export function hydratedHistoryApplyMode(
130 skipHistory: boolean,
131 hasProjection: boolean,
132 foregroundTurnActive: boolean,
133 state: HydrateLiveState | undefined,
134 projection?: HydrateProjection,
135 ): HydratedHistoryApplyMode {
136 if (skipHistory || !hasProjection) return "skip";
137 if (!foregroundTurnActive) return isStaleResidentProjection(state, projection) ? "skip" : "replace";
138 if ((state?.items.length ?? 0) === 0 && !hasCachedLiveTurn(state)) return "replace";
139 return (state?.historyTotalTurns ?? 0) === 0 ? "prepend" : "skip";
140 }
141
142 type SignatureItem = {
143 kind: string;
144 id: string;
145 messageId?: string;
146 text?: string;
147 reasoning?: string;
148 name?: string;
149 level?: string;
150 trigger?: string;
151 messages?: number;
152 surfaceKey?: string;
153 generation?: number;
154 };
155
156 function itemSignature(item: SignatureItem): string {
157 switch (item.kind) {
158 case "tool": return `tool|${item.id}|${item.name ?? ""}`;
159 case "extension": return `extension|${item.surfaceKey ?? ""}|${item.generation ?? 0}`;
160 case "compaction": return `compaction|${item.trigger ?? ""}|${item.messages ?? 0}`;
161 default: return `${item.kind}|${item.level ?? ""}|${item.text ?? ""}|${item.reasoning ?? ""}`;
162 }
163 }
164
165 // A page read while its turn is live can already carry rows the live stream
166 // rendered. Only a suffix of the page can overlap a prefix of the live rows, so
167 // the longest such match is the duplicate set.
168 export function duplicateLiveItemIds(
169 pageItems: readonly SignatureItem[],
170 liveItems: readonly SignatureItem[],
171 ): string[] {
172 // Stable backend identities are independent of where a page cuts the live
173 // turn. In particular page [A,B,C] already covers live [A,B]. Content equality
174 // cannot establish this relation: two messages may intentionally be equal.
175 const identity = (item: SignatureItem) => item.messageId ? `m:${item.messageId}` : item.id;
176 const isIdentified = (item: SignatureItem) => !!item.messageId || item.id.startsWith("m:");
177 const canonicalIds = new Set(pageItems.filter((item) => isIdentified(item) || item.kind === "tool").map(identity));
178 const identified = liveItems.filter((item) => canonicalIds.has(identity(item))).map((item) => item.id);
179 if (pageItems.some(isIdentified) || liveItems.some(isIdentified)) return identified;
180 for (let k = Math.min(pageItems.length, liveItems.length); k > 0; k -= 1) {
181 let same = true;
182 for (let i = 0; i < k && same; i += 1) {
183 same = itemSignature(pageItems[pageItems.length - k + i]) === itemSignature(liveItems[i]);
184 }
185 if (same) return liveItems.slice(0, k).map((item) => item.id);
186 }
187 return [];
188 }
189
190 export function sameSessionPlaceholderItems<T>(
191 target: SessionHydrateIdentity | undefined,
192 prev: { meta?: SessionHydrateIdentity; items?: T[] } | undefined,
193 ): T[] | undefined {
194 return sameSessionHydrateIdentity(target, prev?.meta) ? prev?.items : undefined;
195 }
196
196 lines TYPESCRIPT