返回 DeepSeek-Reasonix
errorPresentation.ts
根目录 / desktop / frontend / src / lib / errorPresentation.ts
1 import { t, getLocale, type DictKey, type Translator, type Locale } from "./i18n";
2 import type { WireEvent } from "./types";
3
4 export interface ErrorPresentation { summary: string; detail: string }
5
6 // Presentation only: callers must retain the original error for control flow,
7 // diagnostics and persistence. Never send translated messages back to tools.
8 export function errorDetail(error: unknown): string {
9 if (error instanceof Error) return error.message || error.name;
10 if (typeof error === "string") return error;
11 if (error == null) return "";
12 if (typeof error === "object") {
13 const record = error as { message?: unknown; code?: unknown };
14 if (typeof record.message === "string") return typeof record.code === "string" ? `${record.code}: ${record.message}` : record.message;
15 try { return JSON.stringify(error); } catch { return String(error); }
16 }
17 return String(error);
18 }
19
20 const codeKeys: Record<string, DictKey> = {
21 ECONNREFUSED: "error.connection", ENOTFOUND: "error.dns", EAI_AGAIN: "error.dns",
22 ECONNRESET: "error.interrupted", ETIMEDOUT: "error.timeout", ABORT_ERR: "error.cancelled",
23 ENOENT: "error.fileMissing", EACCES: "error.permission", EPERM: "error.permission",
24 ENOSPC: "error.diskFull", EROFS: "error.readOnly", context_length_exceeded: "error.context",
25 invalid_api_key: "error.auth", insufficient_quota: "error.quota", rate_limit_exceeded: "error.rateLimit",
26 stream_interrupted: "error.interrupted", empty_response: "error.empty", stale_generation: "error.conflict",
27 provider_connection: "error.connection", cancelled: "error.cancelled",
28 transport_protocol: "error.transportProtocol",
29 };
30
31 // Narrow legacy matches bridge errors from older services and OS libraries.
32 // Unknown errors deliberately keep a neutral summary instead of guessing.
33 const legacyKeys: [RegExp, DictKey][] = [
34 [/\b(?:insufficient_quota|insufficient balance|quota exhausted)\b|余额不足|額度不足|额度不足/i, "error.quota"],
35 [/\b(?:context_length_exceeded|context (?:window|length).*(?:exceed|limit)|maximum context length)\b|超出.*上下文/i, "error.context"],
36 [/\b(?:ENOSPC|no space left on device|disk full)\b/i, "error.diskFull"],
37 [/\b(?:EACCES|EPERM|permission denied|operation not permitted)\b/i, "error.permission"],
38 [/\b(?:ENOENT|no such file or directory|file not found)\b/i, "error.fileMissing"],
39 [/\b(?:EROFS|read-only file system)\b/i, "error.readOnly"],
40 [/\b(?:ENOTFOUND|EAI_AGAIN|no such host|name resolution failed)\b/i, "error.dns"],
41 [/\b(?:ECONNREFUSED|connection refused|failed to fetch|network request failed|networkerror)\b/i, "error.connection"],
42 [/\b(?:ECONNRESET|unexpected EOF|premature EOF|connection reset|model stream (?:interrupted|disconnected))\b/i, "error.interrupted"],
43 [/\b(?:ETIMEDOUT|deadline exceeded|timed out|(?:request|connection|response|stream)[ -]timeout)\b/i, "error.timeout"],
44 [/\b(?:context canceled|context cancelled|AbortError|operation (?:aborted|cancelled|canceled))\b/i, "error.cancelled"],
45 [/\b(?:empty (?:provider )?response|no content returned|zero.content response)\b/i, "error.empty"],
46 [/\b(?:invalid_api_key|invalid api key|authentication failed|unauthorized)\b/i, "error.auth"],
47 [/\b(?:rate_limit_exceeded|rate limit exceeded|too many requests)\b/i, "error.rateLimit"],
48 [/\b(?:session changed on disk|workspace mutation conflicts with persisted state|stale generation|runtime changed|writer is owned by another runtime)\b/i, "error.conflict"],
49 [/\b(?:unknown tool|tool not found|MCP server.*(?:unavailable|not connected|failed to start))\b/i, "error.toolUnavailable"],
50 [/\b(?:invalid JSON|unexpected token.*JSON|JSON.*syntax|invalid header field name)\b/i, "error.invalidInput"],
51 ];
52
53 function statusKey(status: number): DictKey | undefined {
54 if (status === 401) return "error.auth";
55 if (status === 402) return "error.quota";
56 if (status === 403) return "error.forbidden";
57 if (status === 404) return "error.endpoint";
58 if (status === 408 || status === 504) return "error.timeout";
59 if (status === 409) return "error.conflict";
60 if (status === 413) return "error.tooLarge";
61 if (status === 429) return "error.rateLimit";
62 if (status >= 500 && status <= 599) return "error.service";
63 if (status === 400 || status === 422) return "error.invalidInput";
64 }
65
66 export function presentError(error: unknown, translate: Translator = t, locale: Locale = getLocale(), diagnostic?: WireEvent["diagnostic"]): ErrorPresentation {
67 const detail = errorDetail(error).trim();
68 if (diagnostic?.kind === "transport_protocol") {
69 return { summary: translate("error.transportProtocol"), detail: [detail, diagnostic.transportCode ? `HTTP/2: ${diagnostic.transportCode}` : ""].filter(Boolean).join("\n") };
70 }
71 const structured = error && typeof error === "object" ? error as { code?: unknown; status?: unknown } : undefined;
72 const code = typeof structured?.code === "string" ? structured.code : detail;
73 const explicitStatus = typeof structured?.status === "number" ? structured.status : 0;
74 // Only interpret explicitly labelled HTTP status codes, never arbitrary
75 // numbers in paths, command output or provider messages.
76 const status = explicitStatus || Number(/^provider_http_([45]\d\d)$/.exec(code)?.[1] ?? /\b(?:HTTP(?:\/\d(?:\.\d)?)?\s*|status(?: code)?[:= ]+)([45]\d\d)\b/i.exec(detail)?.[1] ?? 0);
77 const key = codeKeys[code] ?? (status && ![400, 413, 429].includes(status) ? statusKey(status) : undefined)
78 ?? legacyKeys.find(([pattern]) => pattern.test(detail))?.[1] ?? statusKey(status);
79 if (key) return { summary: translate(key), detail };
80
81 // Preserve existing localized, actionable copy. A translated wrapper around
82 // an English exception is only a summary; keep the complete cause in details.
83 const firstLine = detail.split("\n")[0] ?? "";
84 if (locale === "en" && /[\u3400-\u9fff]/u.test(firstLine)) {
85 return { summary: translate("error.unknown"), detail };
86 }
87 if (locale === "en" && firstLine) {
88 const summary = firstLine.length > 240 ? `${firstLine.slice(0, 239)}…` : firstLine;
89 return { summary, detail: summary === detail ? "" : detail };
90 }
91 const localizedLine = /^[^:\n]{1,80}:\s*([\u3400-\u9fff].*)$/u.exec(firstLine)?.[1] ?? firstLine;
92 if (/^[\u3400-\u9fff]/u.test(localizedLine)) {
93 const boundary = /[::]\s*(?=[A-Za-z{\[])/u.exec(localizedLine);
94 const summary = boundary ? localizedLine.slice(0, boundary.index) : localizedLine;
95 return { summary, detail: summary === detail ? "" : detail };
96 }
97 return { summary: translate("error.unknown"), detail };
98 }
99
99 lines TYPESCRIPT