返回 DeepSeek-Reasonix
historyItems.ts
根目录 / desktop / frontend / src / lib / historyItems.ts
1 import { isShellToolName } from "./shellToolIdentity";
2 import { historyToolStatus } from "./historyToolStatus";
3 // historyItems converts durable HistoryMessage rows (and legacy HistoryPage
4 // payloads) into transcript Items for the single-shot hydration path. The
5 // windowed counterpart lives in transcriptStore; both projections must agree
6 // on item identity and tool call/result folding.
7 import { asArray } from "./array";
8 import { historicalResultNotice } from "./completionResultState";
9 import { appendNoticeItem, deliveryReadinessDetail, readinessMissingIds } from "./controllerNotices";
10 import { appendHistoryAttachmentRefs } from "./historyAttachmentRefs";
11 import { createUniqueItemIDAllocator } from "./historyItemIds";
12 import { t } from "./i18n";
13 import { upsertReadPause } from "./readPause";
14 import { historySearchAndAnswer } from "./searchTranscript";
15 import { fileDiffFromWire, summarizeFileDiff } from "./tools";
16 import type { HistoryMessage, HistoryPage, MemoryCitation } from "./types";
17 import type { Item } from "./useController";
18
19 /** Mirrors Go backend's ReadOnly() hints. */
20 export function isReadOnlyTool(name: string): boolean {
21 switch (name) {
22 case "read_file":
23 case "ls":
24 case "grep":
25 case "glob":
26 case "web_fetch":
27 case "web_search":
28 case "code_index":
29 case "bash_output":
30 case "waitJob":
31 case "todo_write":
32 case "read_skill":
33 return true;
34 default:
35 return false;
36 }
37 }
38
39 export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: string, startSeq = 0): { items: Item[]; seq: number } {
40 const resultByID = new Map<string, HistoryMessage>();
41 for (const m of messages) {
42 if (m.role === "tool" && m.toolCallId && !resultByID.has(m.toolCallId)) {
43 resultByID.set(m.toolCallId, m);
44 }
45 }
46 const positionalResults = positionalToolResults(messages);
47 const consumedPositionalToolIndexes = new Set(Array.from(positionalResults.values(), (result) => result.index));
48
49 let items: Item[] = [];
50 let seq = startSeq;
51 const consumedToolIDs = new Set<string>();
52 const uniqueItemID = createUniqueItemIDAllocator();
53 for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
54 const m = messages[messageIndex];
55 const recordItemId = m.recordId ? `record:${m.recordId}` : `${idPrefix}${seq}`;
56 if (m.role === "system") continue;
57 if (m.role === "phase") {
58 if (m.content.trim() !== "") {
59 items.push({ kind: "phase", id: recordItemId, text: m.content });
60 seq++;
61 }
62 continue;
63 }
64 if (m.role === "notice") {
65 if (m.code === "read_completion") {
66 const next = appendNoticeItem(items, seq, recordItemId, "info", m.content, m.detail, m.code);
67 items = next.items;
68 seq = next.seq;
69 continue;
70 }
71 if (m.code === "incomplete_read") {
72 items = upsertReadPause(items, m.readPause, recordItemId);
73 seq++;
74 continue;
75 }
76 if (m.completionReceipt || m.completionSummary) {
77 const result = historicalResultNotice(m, recordItemId);
78 if (result) { items.push(result); seq++; }
79 continue;
80 }
81 if (m.code === "protocol_recovery" && m.pending && m.protocolRecovery?.id) {
82 items.push({kind:"notice",id:recordItemId,level:"info",code:m.code,text:t("notice.protocolRecoveryBody"),action:"recover_context",recoveryId:m.protocolRecovery.id});
83 seq++;
84 continue;
85 }
86 if (m.code === "final_readiness" && m.pending) {
87 items.push({
88 kind: "notice",
89 id: recordItemId,
90 level: "info",
91 variant: "delivery",
92 title: t("notice.deliveryIncompleteTitle"),
93 text: t("notice.deliveryIncompleteBody"),
94 detail: deliveryReadinessDetail(m.readiness),
95 action: "continue_delivery",
96 missing: readinessMissingIds(m.readiness),
97 });
98 seq++;
99 continue;
100 }
101 if (m.content.trim() !== "" || m.decisionReceipt) {
102 const next = appendNoticeItem(items, seq, recordItemId, m.level === "warn" ? "warn" : "info", m.content, m.detail, m.code, m.decisionReceipt);
103 items = next.items;
104 seq = next.seq;
105 }
106 continue;
107 }
108 if (m.role === "compaction") {
109 items.push({
110 kind: "compaction",
111 id: recordItemId,
112 pending: Boolean(m.pending),
113 trigger: m.trigger ?? "",
114 messages: m.messages ?? 0,
115 summary: m.summary ?? "",
116 archive: m.archive ?? "",
117 });
118 seq++;
119 continue;
120 }
121 if (m.role === "user") {
122 if (m.content.trim() === "") continue;
123 items.push({ kind: "user", id: m.messageId ? `m:${m.messageId}` : recordItemId, messageId: m.messageId, submissionId: m.submissionId, turnId: m.turnId, text: appendHistoryAttachmentRefs(m.content, m.attachments), submitText: m.submitText, createdAt: m.createdAt, checkpointTurn: m.checkpointTurn, historyTurn: m.historyTurn });
124 seq++;
125 continue;
126 }
127 if (m.role === "assistant") {
128 const memoryCitations = asArray<MemoryCitation>(m.memoryCitations);
129 const messageItemId = m.messageId ? `m:${m.messageId}` : m.recordId ? recordItemId : undefined;
130 const built = historySearchAndAnswer(messageItemId ?? `${idPrefix}${seq}`, {
131 content: m.content,
132 reasoning: m.reasoning,
133 workDurationMs: m.workDurationMs,
134 turnDurationMs: m.turnDurationMs,
135 turnUsage: m.turnUsage,
136 createdAt: m.createdAt,
137 memoryCitations: memoryCitations.length > 0 ? memoryCitations : undefined,
138 serverSearch: m.serverSearch,
139 });
140 for (const item of built) {
141 item.turnId = m.turnId;
142 if (item.kind === "assistant") { item.id = messageItemId ?? `${idPrefix}${seq}`; item.streaming = Boolean(m.pending); }
143 items.push(item);
144 seq++;
145 }
146 if (m.pending && !built.some((item) => item.kind === "assistant")) {
147 items.push({ kind: "assistant", id: messageItemId ?? recordItemId, text: m.content, reasoning: m.reasoning ?? "", streaming: true, createdAt: m.createdAt });
148 seq++;
149 }
150 const toolCalls = m.toolCalls ?? [];
151 for (let callIndex = 0; callIndex < toolCalls.length; callIndex += 1) {
152 const tc = toolCalls[callIndex];
153 const positionalResult = tc.id ? undefined : positionalResults.get(positionalToolResultKey(messageIndex, callIndex));
154 const result = tc.id ? resultByID.get(tc.id) : positionalResult?.message;
155 if (tc.id) consumedToolIDs.add(tc.id);
156 const archived = Boolean(tc.argumentsArchived || result?.toolResultArchived);
157 const output = result?.toolResultArchived ? undefined : result?.content ?? "";
158 const error = result?.toolResultError || (output ? historyToolError(output) : undefined);
159 const fileDiff = fileDiffFromWire(tc);
160 items.push({
161 kind: "tool",
162 id: uniqueItemID(tc.id || "", m.recordId ? `${recordItemId}:tc${callIndex}` : `${idPrefix}tool${seq}`),
163 messageId: m.messageId,
164 parentId: tc.parentId,
165 argChars: tc.argChars,
166 startedAt: tc.startedAt,
167 name: tc.name,
168 args: tc.arguments ?? "",
169 readOnly: typeof tc.resolvedReadOnly === "boolean" ? tc.resolvedReadOnly : isReadOnlyTool(tc.name),
170 resolvedName: tc.resolvedName,
171 capabilityId: tc.capabilityId,
172 status: historyToolStatus(result, tc, error),
173 contentState: result && !result.toolResultArchived ? "ready" : "unloaded",
174 output,
175 error,
176 dataArchived: archived || undefined,
177 subject: tc.subject,
178 summary: summarizeFileDiff(fileDiff) || tc.summary,
179 fileDiff,
180 isShell: isShellToolName(tc.name) || (tc.id || "").startsWith("shell-"),
181 execution: result?.execution,
182 presentedFiles: result?.presentedFiles,
183 });
184 seq++;
185 }
186 continue;
187 }
188 if (m.role === "tool") {
189 if ((m.toolCallId && consumedToolIDs.has(m.toolCallId)) || consumedPositionalToolIndexes.has(messageIndex)) continue;
190 const output = m.toolResultArchived ? undefined : m.content;
191 const error = m.toolResultError || (output ? historyToolError(output) : undefined);
192 items.push({
193 kind: "tool",
194 id: uniqueItemID(m.toolCallId || "", m.recordId ? `${recordItemId}:tool` : `${idPrefix}tool${seq}`),
195 name: m.toolName || "tool",
196 args: "",
197 readOnly: isReadOnlyTool(m.toolName || "tool"),
198 status: error ? "error" : "done",
199 output,
200 error,
201 dataArchived: m.toolResultArchived || undefined,
202 isShell: isShellToolName(m.toolName || "") || (m.toolCallId || "").startsWith("shell-"),
203 execution: m.execution,
204 presentedFiles: m.presentedFiles,
205 });
206 seq++;
207 continue;
208 }
209 }
210 return { items, seq };
211 }
212
213 export function applyTurnCheckpoint(items: Item[], submissionId: string | undefined, turn: number | undefined): Item[] {
214 if (!submissionId) return items;
215 const validTurn = turn !== undefined && Number.isInteger(turn) && turn >= 0;
216 let changed = false;
217 const next = items.map((item) => {
218 if (item.kind !== "user" || item.submissionId !== submissionId) return item;
219 changed = true;
220 return { ...item, submissionId: undefined, checkpointTurn: item.checkpointTurn ?? (validTurn ? turn : undefined) };
221 });
222 return changed ? next : items;
223 }
224
225 export function historyPageItems(page: HistoryPage): { items: Item[]; seq: number; firstTurn: number } {
226 const converted = historyMessagesToItems(asArray(page.messages), `h${page.startTurn}-`, 0);
227 let historyTurn = page.startTurn + 1;
228 const items = converted.items.map((item) => {
229 if (item.kind !== "user") return item;
230 const user = { ...item, historyTurn };
231 historyTurn += 1;
232 return user;
233 });
234 return {
235 items,
236 seq: converted.seq,
237 // Legacy HistoryPage is 0-based while HistorySlice is 1-based. State uses
238 // the HistorySlice coordinate so every transcript consumer sees one model.
239 firstTurn: page.totalTurns > 0 ? page.startTurn + 1 : 0,
240 };
241 }
242
243 function positionalToolResults(messages: HistoryMessage[]): Map<string, { message: HistoryMessage; index: number }> {
244 const out = new Map<string, { message: HistoryMessage; index: number }>();
245 const consumed = new Set<number>();
246 for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
247 const message = messages[messageIndex];
248 const toolCalls = message.role === "assistant" ? message.toolCalls ?? [] : [];
249 if (toolCalls.length === 0) continue;
250 let resultIndex = messageIndex + 1;
251 for (let callIndex = 0; callIndex < toolCalls.length; callIndex += 1) {
252 if (toolCalls[callIndex].id) continue;
253 let matched = false;
254 while (resultIndex < messages.length) {
255 const candidate = messages[resultIndex];
256 if (candidate.role !== "tool") break;
257 const candidateIndex = resultIndex;
258 resultIndex += 1;
259 if (candidate.toolCallId || consumed.has(candidateIndex)) continue;
260 consumed.add(candidateIndex);
261 out.set(positionalToolResultKey(messageIndex, callIndex), { message: candidate, index: candidateIndex });
262 matched = true;
263 break;
264 }
265 if (!matched) break;
266 }
267 }
268 return out;
269 }
270
271 function positionalToolResultKey(messageIndex: number, callIndex: number): string {
272 return `${messageIndex}:${callIndex}`;
273 }
274
275 export function historyToolError(output: string): string | undefined {
276 const trimmed = output.trimStart();
277 if (
278 trimmed.startsWith("[error") ||
279 trimmed.startsWith("Error:") ||
280 trimmed.startsWith("error:") ||
281 trimmed.startsWith("blocked:")
282 ) {
283 return output;
284 }
285 return undefined;
286 }
287
287 lines TYPESCRIPT