返回 DeepSeek-Reasonix
localSubmissionState.ts
根目录 / desktop / frontend / src / lib / localSubmissionState.ts
1 export type LocalSubmissionStatus = "sending" | "accepted" | "unknown" | "failed";
2
3 export interface LocalSubmission {
4 submissionId: string;
5 localId: string;
6 text: string;
7 submitText?: string;
8 createdAt: number;
9 sequence: number;
10 anchorItemId?: string;
11 placement?: "start" | "after" | "latest";
12 status: LocalSubmissionStatus;
13 messageId?: string;
14 turnId?: string;
15 checkpointTurn?: number;
16 /** The first terminal event consumed this submission's checkpoint authority. */
17 settled?: boolean;
18 }
19
20 export interface LocalSubmissionFields {
21 localSubmissions: Record<string, LocalSubmission>;
22 localSubmissionOrder: string[];
23 localSubmissionSendRevision: number;
24 visibleSubmissionHandoffs: Record<string, { submissionId: string }>;
25 }
26
27 export type CanonicalUserConfirmation = { messageId: string; submissionId?: string; turnId?: string };
28 type CanonicalUserIdentity = {
29 kind: string;
30 messageId?: string;
31 submissionId?: string;
32 turnId?: string;
33 };
34
35 export function canonicalUserConfirmations(items: readonly CanonicalUserIdentity[]): CanonicalUserConfirmation[] {
36 return items.flatMap(item => item.kind === "user" && item.messageId ? [{ messageId: item.messageId, submissionId: item.submissionId, turnId: item.turnId }] : []);
37 }
38
39 /** Identity matching is shared by state settlement and defensive presentation. */
40 export function matchLocalSubmissions(submissions: readonly LocalSubmission[], confirmations: readonly CanonicalUserConfirmation[]) {
41 const consumed = new Set<string>();
42 const matches: Array<{ submissionId: string; messageId: string }> = [];
43 for (const confirmation of confirmations) {
44 const available = (local: LocalSubmission) => !consumed.has(local.submissionId);
45 const local = submissions.find(local => available(local) && local.messageId === confirmation.messageId)
46 ?? submissions.find(local => available(local) && !local.messageId && Boolean(confirmation.submissionId) && local.submissionId === confirmation.submissionId);
47 if (!local) continue;
48 consumed.add(local.submissionId);
49 matches.push({ submissionId: local.submissionId, messageId: confirmation.messageId });
50 }
51 return matches;
52 }
53
54 export function pruneSubmissionHandoffs<T extends LocalSubmissionFields>(state: T, items: readonly CanonicalUserIdentity[]): T {
55 const visible = new Set(canonicalUserConfirmations(items).map(item => item.messageId));
56 const entries = Object.entries(state.visibleSubmissionHandoffs).filter(([id]) => visible.has(id));
57 return entries.length === Object.keys(state.visibleSubmissionHandoffs).length ? state
58 : { ...state, visibleSubmissionHandoffs: Object.fromEntries(entries) };
59 }
60
61 export function isUnknownSubmissionError(error: unknown): boolean {
62 return /timeout|timed out|network|connection|socket|channel.*closed|fetch failed|failed to fetch|\beof\b/i.test(error instanceof Error ? error.message : String(error));
63 }
64
65 export function orderedLocalSubmissions(state: LocalSubmissionFields): LocalSubmission[] {
66 return state.localSubmissionOrder.flatMap((submissionId) => {
67 const submission = state.localSubmissions[submissionId];
68 return submission ? [submission] : [];
69 });
70 }
71
72 export function beginLocalSubmission<T extends LocalSubmissionFields>(
73 state: T,
74 submission: Omit<LocalSubmission, "status">,
75 ): T {
76 return {
77 ...state,
78 localSubmissions: { ...state.localSubmissions, [submission.submissionId]: { ...submission, status: "sending" } },
79 localSubmissionOrder: state.localSubmissionOrder.includes(submission.submissionId)
80 ? state.localSubmissionOrder
81 : [...state.localSubmissionOrder, submission.submissionId],
82 localSubmissionSendRevision: state.localSubmissionSendRevision + 1,
83 };
84 }
85
86 export function updateLocalSubmission<T extends LocalSubmissionFields>(
87 state: T,
88 submissionId: string | undefined,
89 patch: Partial<LocalSubmission>,
90 ): T {
91 if (!submissionId) return state;
92 const current = state.localSubmissions[submissionId];
93 if (!current) return state;
94 if (current.messageId && patch.messageId && current.messageId !== patch.messageId) return state;
95 return {
96 ...state,
97 localSubmissions: { ...state.localSubmissions, [submissionId]: { ...current, ...patch, submissionId } },
98 };
99 }
100
101 export function removeLocalSubmission<T extends LocalSubmissionFields>(state: T, submissionId: string | undefined): T {
102 if (!submissionId || !state.localSubmissions[submissionId]) return state;
103 const localSubmissions = { ...state.localSubmissions };
104 delete localSubmissions[submissionId];
105 return {
106 ...state,
107 localSubmissions,
108 localSubmissionOrder: state.localSubmissionOrder.filter((candidate) => candidate !== submissionId),
109 };
110 }
111
112 /** Retire each local echo once its durable user message is installed. */
113 export function settleLocalSubmissions<T extends LocalSubmissionFields>(
114 state: T,
115 items: readonly CanonicalUserIdentity[],
116 confirmations: readonly CanonicalUserConfirmation[] = canonicalUserConfirmations(items),
117 ): T {
118 state = pruneSubmissionHandoffs(state, items);
119 if (state.localSubmissionOrder.length === 0) return state;
120 const remaining = { ...state.localSubmissions };
121 const matches = matchLocalSubmissions(orderedLocalSubmissions(state), confirmations);
122 const consumed = new Set(matches.map(match => match.submissionId));
123 const visible = new Set(canonicalUserConfirmations(items).map(item => item.messageId));
124 const handoffs = { ...state.visibleSubmissionHandoffs };
125 for (const match of matches) {
126 delete remaining[match.submissionId];
127 if (visible.has(match.messageId) && !handoffs[match.messageId]) handoffs[match.messageId] = { submissionId: match.submissionId };
128 }
129 if (consumed.size === 0) return state;
130 return {
131 ...state,
132 localSubmissions: remaining,
133 visibleSubmissionHandoffs: handoffs,
134 localSubmissionOrder: state.localSubmissionOrder.filter((submissionId) => !consumed.has(submissionId)),
135 };
136 }
137
138 export type RebasedUserRecord = { submissionId?: string; text: string; submitText?: string };
139 type EchoLike = { kind: string; id: string; text?: string; submitText?: string };
140
141 function submittedText(entry: { text: string; submitText?: string }): string {
142 return entry.submitText ?? entry.text;
143 }
144
145 /** User echoes that were already on screen when this submission was created. */
146 function preexistingEchoes(previousItems: readonly EchoLike[], local: LocalSubmission): EchoLike[] {
147 if (!local.anchorItemId) return local.placement === "start" ? [] : [...previousItems];
148 const anchor = previousItems.findIndex(item => item.id === local.anchorItemId);
149 return anchor < 0 ? [...previousItems] : previousItems.slice(0, anchor + 1);
150 }
151
152 /**
153 * A snapshot rebase (runtime rebuild, serve restart, model switch) may carry
154 * user records without the message ids settleLocalSubmissions keys on, so an
155 * echo the server already journaled would otherwise survive as a duplicate
156 * turn stuck at "processing". Retire only echoes the rebased projection
157 * provably owns: one whose submission id a durable record repeats, or — with
158 * no turn in flight — the oldest echo whose text the newest durable user
159 * record repeats. The text match is bounded by the echoes' own anchor: a
160 * record that already existed when the echo was created can never absorb it,
161 * so a lost re-send of an identical message stays visible. An active runtime
162 * never absorbs by text because the trailing record may be an older sibling
163 * of the in-flight submission.
164 */
165 export function settleRebasedSubmissions<T extends LocalSubmissionFields>(
166 state: T,
167 previousItems: readonly EchoLike[],
168 durable: readonly RebasedUserRecord[],
169 idle: boolean,
170 ): T {
171 const pending = orderedLocalSubmissions(state).filter(local => local.status !== "failed");
172 if (pending.length === 0) return state;
173 const consumed = new Set<string>();
174 const durableSubmissionIds = new Set(durable.map(record => record.submissionId).filter(Boolean));
175 for (const local of pending) if (durableSubmissionIds.has(local.submissionId)) consumed.add(local.submissionId);
176 const latest = durable[durable.length - 1];
177 if (idle && latest) {
178 const text = submittedText(latest);
179 const durableCopies = durable.filter(record => submittedText(record) === text).length;
180 const echo = pending.find(local => !consumed.has(local.submissionId) && submittedText(local) === text);
181 if (echo) {
182 const priorCopies = preexistingEchoes(previousItems, echo)
183 .filter(item => item.kind === "user" && item.text !== undefined && submittedText({ text: item.text, submitText: item.submitText }) === text).length;
184 if (durableCopies > priorCopies) consumed.add(echo.submissionId);
185 }
186 }
187 if (consumed.size === 0) return state;
188 const localSubmissions = { ...state.localSubmissions };
189 for (const submissionId of consumed) delete localSubmissions[submissionId];
190 return {
191 ...state,
192 localSubmissions,
193 localSubmissionOrder: state.localSubmissionOrder.filter(submissionId => !consumed.has(submissionId)),
194 };
195 }
196
197 export function checkpointLocalSubmission<T extends LocalSubmissionFields>(
198 state: T,
199 submissionId: string | undefined,
200 checkpointTurn: number | undefined,
201 ): T {
202 if (!submissionId) return state;
203 const current = state.localSubmissions[submissionId];
204 if (!current || current.settled) return state;
205 const validTurn = Number.isInteger(checkpointTurn) && checkpointTurn! >= 0;
206 return updateLocalSubmission(state, submissionId, {
207 checkpointTurn: validTurn ? checkpointTurn : current.checkpointTurn,
208 settled: true,
209 });
210 }
211
211 lines TYPESCRIPT