返回 DeepSeek-Reasonix
controllerNotices.ts
根目录 / desktop / frontend / src / lib / controllerNotices.ts
1 import { asArray } from "./array";
2 import { t, type DictKey } from "./i18n";
3 import type { HistoryMessage, WireFinalReadiness, WireDecisionReceipt } from "./types";
4 import type { Item } from "./useController";
5 import { readPauseItem } from "./readPause";
6
7 export function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt): { items: Item[]; seq: number } {
8 if (quietTranscriptNoticeKey(rawText, code)) return { items, seq };
9 const text = localizedNoticeText(rawText, code);
10 if (quietTranscriptNoticeKey(text, code)) return { items, seq };
11 const trimmedDetail = detail?.trim();
12 return { items: [...items, { kind: "notice", id, level, text, ...(trimmedDetail ? { detail: trimmedDetail } : {}), ...(code ? { code } : {}), ...(decisionReceipt ? { decisionReceipt } : {}) }], seq: seq + 1 };
13 }
14
15 export function errorMessage(err: unknown): string {
16 if (err instanceof Error) return err.message;
17 if (typeof err === "string") return err;
18 return String(err || "");
19 }
20
21 const noticeCodeKeys: Record<string, DictKey> = {
22 final_readiness: "notice.finalReadiness",
23 historical_checks: "notice.historicalChecks",
24 search_sources_not_provided: "sources.notProvided",
25 empty_final: "notice.emptyFinal",
26 executor_handoff: "notice.executorHandoff",
27 tool_budget: "notice.toolBudget",
28 prompt_queued: "notice.promptQueued",
29 loop_guard: "notice.loopGuard",
30 workspace_lease: "notice.workspaceLease",
31 cancelled_turn_display: "notice.cancelledTurnDisplay",
32 protocol_recovery: "notice.protocolRecoveryBody",
33 recovery_paused: "notice.recoveryPausedBody",
34 completion_uncertain: "notice.completionUncertainBody",
35 session_recovery_forked: "recovery.noticeSavedCopy",
36 session_recovery_adopted: "recovery.noticeAdopted",
37 session_recovery_adopted_covered: "recovery.noticeAdoptedCovered",
38 session_recovery_depth_cap: "recovery.noticeKeptCurrent",
39 session_shutdown_recovery_forked: "recovery.noticeSavedCopy",
40 session_concurrent_writer: "recovery.noticeConcurrentWriter",
41 session_head_switched: "recovery.noticeHeadSwitched",
42 session_head_selected: "recovery.noticeHeadSelected",
43 decision_receipt: "notice.decisionReceiptTitle",
44 context_editing_fallback: "notice.contextEditingFallback",
45 turn_stalled: "notice.turnStalled",
46 };
47
48 const streamInterruptReasonCodeKeys: Record<string, DictKey> = {
49 stream_interrupted_idle_timeout: "notice.streamInterruptReason.idleTimeout",
50 stream_interrupted_premature_eof: "notice.streamInterruptReason.prematureEof",
51 stream_interrupted_connection_reset: "notice.streamInterruptReason.connectionReset",
52 };
53
54 export function localizedNoticeText(text: string, code?: string): string {
55 if (text === "Model reported the goal complete.") return t("notice.goalModelComplete");
56 if (code === "unapplied_steer") {
57 const separator = text.indexOf("\n");
58 const guidance = separator >= 0 ? text.slice(separator + 1) : text;
59 return t("notice.unappliedSteer", { guidance });
60 }
61 const streamReasonKey = code ? streamInterruptReasonCodeKeys[code] : undefined;
62 if (streamReasonKey) {
63 return t("notice.streamInterruptReason", { reason: t(streamReasonKey) });
64 }
65 const key = code ? noticeCodeKeys[code] : undefined;
66 return key ? t(key) : localizedBackendNoticeText(text);
67 }
68
69 const deliveryRequirementKeys: Record<string, DictKey> = {
70 project_check: "notice.deliveryRequirementProjectCheck",
71 todo: "notice.deliveryRequirementTodo",
72 criteria: "notice.deliveryRequirementCriteria",
73 verification: "notice.deliveryRequirementVerification",
74 review: "notice.deliveryRequirementReview",
75 signoff: "notice.deliveryRequirementSignoff",
76 action: "notice.deliveryRequirementAction",
77 mutation: "notice.deliveryRequirementMutation",
78 task: "notice.deliveryRequirementTask",
79 capability: "notice.deliveryRequirementCapability",
80 };
81
82 export function readinessMissingIds(readiness: WireFinalReadiness | undefined): string[] {
83 return asArray(readiness?.missing).map((id) => String(id));
84 }
85
86 export function deliveryReadinessDetail(readiness: WireFinalReadiness | undefined, fallback = ""): string {
87 const labels = asArray(readiness?.missing)
88 .map((id) => deliveryRequirementKeys[id])
89 .filter((key): key is DictKey => Boolean(key))
90 .map((key) => t(key));
91 return labels.length === 0
92 ? fallback
93 : t("notice.deliveryIncompleteMissing", { items: labels.join(t("notice.deliveryRequirementSeparator")) });
94 }
95
96 function localizedSessionAction(action: string): string {
97 switch (action.trim()) {
98 case "changing model": return t("status.actionChangingModel");
99 case "changing effort": return t("status.actionChangingEffort");
100 case "rebuilding settings": return t("status.actionRebuildingSettings");
101 case "switching sessions": return t("status.actionSwitchingSessions");
102 case "switching tabs": return t("status.actionSwitchingTabs");
103 case "autosave": return t("status.actionAutosave");
104 default: return action.trim() || t("status.actionCurrentSession");
105 }
106 }
107
108 function backendNoticeKey(msg: string): DictKey | "" {
109 switch (msg) {
110 case "Task status needs one more check; asking the assistant to finish or explain what is blocking it.": return "notice.finalReadiness";
111 case "No visible answer was produced; asking the assistant to respond again.": return "notice.emptyFinal";
112 case "The assistant answered before taking action; asking it to use the required tools.": return "notice.executorHandoff";
113 case "Tool round limit reached; asking the assistant to summarize progress.": return "notice.toolBudget";
114 case "The assistant is stuck retrying a blocked action; asking it to change approach.": return "notice.loopGuard";
115 case "Context is getting large; preserving cache until cleanup is needed.": return "notice.contextLarge";
116 case "Context cleanup skipped for now.": return "notice.contextCleanupSkipped";
117 case "Automatic context cleanup paused because the context window is too small.": return "notice.contextCleanupPaused";
118 case "Context was compacted without a generated summary.": return "notice.compactionNoSummary";
119 case "Goal is not ready to complete yet; continuing the remaining work.": return "notice.goalNotReady";
120 case "Goal still has unfinished task state; continuing the remaining work.": return "notice.goalUnfinished";
121 case "Job artifact migration failed.": return "notice.jobArtifactMigrationFailed";
122 case "Background job teardown timed out.": return "notice.jobTeardownTimeout";
123 case "Some plan-mode tool settings were ignored.": return "notice.planModeToolSettingsIgnored";
124 case "Some plan-mode command settings were ignored.": return "notice.planModeCommandSettingsIgnored";
125 case "Config migration did not complete.": return "notice.configMigrationIncomplete";
126 case "Provider connection settings were repaired.": return "notice.providerConnectionRepaired";
127 case "Selected model is missing its API key.": return "notice.modelMissingApiKey";
128 case "An MCP server failed to start.": return "notice.mcpServerFailed";
129 case "Some MCP servers failed to start; run /mcp for details.": return "notice.mcpServersFailed";
130 case "Guardian was disabled because its model was not found.": return "notice.guardianModelMissing";
131 case "Guardian was disabled because it could not start.": return "notice.guardianStartFailed";
132 default: return "";
133 }
134 }
135
136 export function localizedBackendNoticeText(text: string): string {
137 const msg = text.trim();
138 const autosave = /^Session autosave failed: (.+)$/s.exec(msg);
139 if (autosave) return t("status.sessionAutosaveFailed", { err: autosave[1] });
140 const saveBefore = /^Session save failed before (.+?): (.+)$/s.exec(msg);
141 if (saveBefore) return t("status.sessionSaveFailedBefore", { action: localizedSessionAction(saveBefore[1]), err: saveBefore[2] });
142 const modelFallback = /^model (.+) is no longer available; switched to (.+)$/s.exec(msg);
143 if (modelFallback) return t("status.modelFallbackSwitched", { model: modelFallback[1], fallback: modelFallback[2] });
144 const backgroundJob = /^background (.+) failed: needs attention$/s.exec(msg);
145 if (backgroundJob) return t("notice.backgroundJobFailed", { kind: backgroundJob[1] });
146 const canonical = backendNoticeKey(msg);
147 if (canonical) return t(canonical);
148 if (/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) || /^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg)) return t("recovery.noticeSavedCopy");
149 if (/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) || /^repeated save conflicts were detected; saved the current conflict copy in an isolated recovery branch$/i.test(msg) || /^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg)) return t("recovery.noticeKeptCurrent");
150 if (/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg)) return t("recovery.noticeAdoptedCovered");
151 if (/^session changed on disk; adopted the newer transcript$/i.test(msg)) return t("recovery.noticeAdopted");
152 return msg;
153 }
154
155 function recoveryNoticeDedupeKey(text: string, code?: string): string {
156 switch (code) {
157 case "session_recovery_forked":
158 case "session_shutdown_recovery_forked": return "recovery:saved-copy";
159 case "session_recovery_depth_cap": return "recovery:kept-current";
160 case "session_recovery_adopted_covered": return "recovery:adopted-covered";
161 case "session_recovery_adopted": return "recovery:adopted";
162 }
163 const msg = text.trim();
164 if (/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) || /^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg) || msg === t("recovery.noticeSavedCopy")) return "recovery:saved-copy";
165 if (/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) || /^repeated save conflicts were detected; saved the current conflict copy in an isolated recovery branch$/i.test(msg) || /^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg) || msg === t("recovery.noticeKeptCurrent")) return "recovery:kept-current";
166 if (/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg) || msg === t("recovery.noticeAdoptedCovered")) return "recovery:adopted-covered";
167 if (/^session changed on disk; adopted the newer transcript$/i.test(msg) || msg === t("recovery.noticeAdopted")) return "recovery:adopted";
168 return "";
169 }
170
171 export function quietTranscriptNoticeKey(text: string, code?: string): string {
172 const recovery = recoveryNoticeDedupeKey(text, code);
173 if (recovery) return recovery;
174 const msg = text.trim();
175 if (/^guardian enabled · model=.+$/i.test(msg)) return "startup:guardian-enabled";
176 if (/^\d+ MCP server\(s\) failed to start: .+ \u2014 run \/mcp for details$/i.test(msg)) return "startup:mcp-failures";
177 const directMCPFailure = /^mcp\s+([A-Za-z0-9._-]+):\s+.+$/i.exec(msg);
178 if (directMCPFailure && !["add", "auth", "config", "connect", "import", "mode", "remove"].includes(directMCPFailure[1].toLowerCase())) return "startup:mcp-failure";
179 if (/^plugin ".+" has been slow \d+ startups in a row \(last \d+ms, budget \d+ms\); demoting to background startup this session$/i.test(msg)) return "startup:plugin-demote";
180 if (/^.+ applied: session refreshed after the lease was released$/i.test(msg)) return "settings:deferred-refresh-applied";
181 return "";
182 }
183 // Windowed history and live read pauses share presentation without growing
184 // the transcript record store's unrelated paging and cache responsibilities.
185 export function historyNoticeItems(m: HistoryMessage, id: string): Item[] {
186 if (m.code === "incomplete_read") return [readPauseItem(m.readPause, id)];
187 if (m.content.trim() === "" && !m.decisionReceipt) return [];
188 if (quietTranscriptNoticeKey(m.content, m.code)) return [];
189 const text = localizedNoticeText(m.content, m.code);
190 if (quietTranscriptNoticeKey(text, m.code)) return [];
191 const detail = m.detail?.trim();
192 return [{ kind: "notice", id, level: m.level === "warn" ? "warn" : "info", text,
193 ...(detail ? { detail } : {}), ...(m.decisionReceipt ? { decisionReceipt: m.decisionReceipt } : {}) }];
194 }
195
195 lines TYPESCRIPT