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