// 主进程 AI 代理 // ============================================================================ // // 通过 Node 18+ 内置 fetch 调用 LLM API。两条协议分支: // - aiProvider === 'openai'(默认)→ POST {baseURL}/chat/completions // Authorization: Bearer ... // - aiProvider === 'anthropic' → POST {baseURL}/v1/messages // x-api-key: ... + anthropic-version // // 不使用 openai / @anthropic-ai SDK(避免引入依赖;统一在主进程发起, // 隐藏 apiKey;统一错误结构供 renderer 弹 toast)。 // // 设计要点: // - 每个请求用一个 requestId 追踪;调用方通过 ai:cancel IPC 终止 // - 配置(provider / baseURL / apiKey / model / systemPrompt)从 // configStore.getConfig() 现读现用,用户改完设置不需要重启 // - 错误统一返回 { ok:false, error, message },error 是稳定的错误码字符串, // message 是用户能看的中文 // - API Key 不写日志;请求/响应调试时只用长度 + 状态码 // // 协议差异(两个分支各自完整处理;共享的 cancel / 校验 / 响应后处理 抽到 helper): // 请求体: // OpenAI : { model, temperature, max_tokens, max_completion_tokens, // messages: [{role, content}], stream:false } // Anthropic : { model, max_tokens, system, messages: [{role, content}], ... } // // 响应体: // OpenAI : choices[0].message.content (string) // choices[0].finish_reason === 'length' 截断 // Anthropic : content[].text (拼接所有 text 块) // stop_reason === 'max_tokens' 截断 // ============================================================================ 'use strict'; const CURRENT_FILE_EDIT_SYSTEM_PROMPT = '你是 Markdown 文本助手。输入 JSON 格式含 filename、currentMarkdown、userPrompt。请按 userPrompt 修改 currentMarkdown,然后直接输出 JSON 格式:{"content":"完整 Markdown"}。如果 userPrompt 与文档修改无关,或者是 {"content":"完整 Markdown"} 内容和 currentMarkdown 完全一致,直接简单回复即可,不需要回复 {"content":"完整 Markdown"}。'; const DEFAULT_TIMEOUT_MS = 300_000; // 内容长度硬上限:防止单次请求几 MB 把 LLM 计费用爆、把事件循环卡住。 // 1.5 MB ≈ 38 万字符 / 10 万行;超过的文档让用户拆分或手动改。 const MAX_CONTENT_BYTES = 1_500_000; // 响应体大小上限:防止恶意 / 错误配置的服务器返回几百 MB body 把主进程 OOM。 // 5 MB 对正常 AI 回复(几十 KB 到 1 MB)足够宽松。 const MAX_RESP_BYTES = 5 * 1024 * 1024; // tryParseJson 输入硬上限:getJsonCandidates 里的 fenced block 正则 + // indexOf/lastIndexOf/slice 在 5 MB 字符串上仍有可观 CPU 占用, // 且对对抗性输入(无闭合 ```)会让非贪婪量词扫到尾;提前砍掉尾巴, // 让 JSON 解析失败直接走 extractRawReply 的回退路径。 const MAX_PARSE_INPUT_BYTES = 2_000_000; // Anthropic API 当前稳定版本(2023-06-01 之后未再变) const ANTHROPIC_API_VERSION = '2023-06-01'; // audit fix (CQ-MED-7):错误码字面值与 renderer 端共享。preload 经 contextBridge // 把同一份 AI_ERROR 暴露到 window.api.aiErrors,main / renderer 永远引用同一对象, // 不再靠注释提醒同步。 const { AI_ERROR } = require('../shared/ai-errors.js'); const ERR_NOT_CONFIGURED = AI_ERROR.NOT_CONFIGURED; const ERR_TIMEOUT = AI_ERROR.ERR_TIMEOUT; const ERR_PROVIDER = AI_ERROR.ERR_PROVIDER; const ERR_FORMAT = AI_ERROR.ERR_FORMAT; const ERR_CANCELLED = AI_ERROR.ERR_CANCELLED; const TRUNCATED_MESSAGE = 'AI 修改结果不完整,请缩小文档或简化要求后重试。'; /** * 把超长输入砍到上限内(按 UTF-8 字节)。MAX_PARSE_INPUT_BYTES 之外的尾部 * 在多数 AI 模型回复里没有意义(远早于 JSON 边界)—— 直接截掉既防 ReDoS, * 又让正则 / indexOf 不再 O(n²) 退化。 * @param {string} message * @returns {string} */ function capForParse(message) { const s = String(message || ''); if (Buffer.byteLength(s, 'utf8') <= MAX_PARSE_INPUT_BYTES) return s; // 按字符截可能切到 UTF-8 序列中间;用 Buffer 切字节再转回字符串, // 最后若尾部半个 multi-byte 用 toString('utf8') 会被替换成 U+FFFD, // 但 JSON.parse 会立即抛 SyntaxError → 由 tryParseJson 的 catch 兜底。 return Buffer.from(s, 'utf8').subarray(0, MAX_PARSE_INPUT_BYTES).toString('utf8'); } /** * 提取 AI 消息中的 JSON 候选:trimmed 原文 / 三反引号代码块 / 第一个 { 到最后一个 }。 * @param {string} message * @returns {string[]} */ function getJsonCandidates(message) { const trimmed = capForParse(message).trim(); const candidates = [trimmed]; const fencedBlock = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); if (fencedBlock && fencedBlock[1]) { candidates.push(fencedBlock[1].trim()); } const jsonStart = trimmed.indexOf('{'); const jsonEnd = trimmed.lastIndexOf('}'); if (jsonStart !== -1 && jsonEnd > jsonStart) { candidates.push(trimmed.slice(jsonStart, jsonEnd + 1)); } return [...new Set(candidates)]; } function tryParseJson(message) { for (const candidate of getJsonCandidates(message)) { try { return JSON.parse(candidate); } catch { // continue } } return undefined; } /** * 在 message 中找第一个花括号配对的 JSON 对象,并把对象文本截出来。 * 简单字符串扫描 —— O(n) 处理嵌套 `{` `}` 和字符串字面量(避免 JSON 内容里的 * 引号 / 反斜杠把花括号配对误判)。找不到配对返回 null。 * * 与 getJsonCandidates 的 brace-pair 切片不同:这里还要在体内识别字符串里的 * 转义序列(`\"` / `\\`),因此专写一个实现而非复用 indexOf/lastIndexOf。 * * @param {string} s * @returns {string|null} */ function extractFirstJsonObject(s) { const str = String(s || ''); const len = str.length; let start = -1; let depth = 0; let inStr = false; let escape = false; for (let i = 0; i < len; i++) { const ch = str[i]; if (inStr) { if (escape) { escape = false; continue; } if (ch === '\\') { escape = true; continue; } if (ch === '"') { inStr = false; } continue; } if (ch === '"') { inStr = true; continue; } if (ch === '{') { if (start < 0) start = i; depth++; continue; } if (ch === '}') { if (depth === 0) continue; depth--; if (depth === 0 && start >= 0) return str.slice(start, i + 1); } } return null; } function looksLikeEditJson(message) { // audit fix:之前的 /"content"\s*:/ 太宽松 —— 用户提示词里只要含 `"content":` // 子串就会被当作「AI 改稿的 JSON 截断」,误报 TRUNCATED_MESSAGE。 // 第二版 `/\{[^{}]*"content"\s*:[^{}]*\}/` 又过紧 —— 当 content 字段后跟随嵌套 // 对象(例:`{"content":"x","patches":[{"op":"replace"}]}`)时 `[^{}]*` 立刻失配, // AI 返回"看似想输出 JSON 但 token 不够截断"的场景下,TRUNCATED 提示被静默吃掉。 // // 现在走 extractFirstJsonObject 取出第一个配对的 JSON 对象文本,再在体内找 // `"content"` 键(允许值跨多行、允许嵌套、字符串里的 " 不会干扰)。 // —— 简单纯文本("please edit {content} now")也不会命中,因为: // 1. 字符串里的花括号不算嵌套对象起点(inStr 分支已处理); // 2. 真正配对的对象才走 key 搜索。 // // 截断的 JSON(花括号未闭合 / 数组未闭合):extractFirstJsonObject 找不到配对对象, // 这里直接返回 false —— normalizeAssistantText 会走 raw 回退把残文本返回给用户。 // 用户能看到 AI 输出了什么,比直接弹"不完整"更直观。截断信号应当由上游 // finish_reason=length / stop_reason=max_tokens 在更早的路径触发,不依赖正文配对。 const obj = extractFirstJsonObject(message); if (!obj) return false; // 体内 key 检测:用 `"content"` 加 `:` 兜住常见间距(`"content" :` / `"content":`); // 不复用 contains('"content"') 是为了避免匹配键名包含 content 子串的字段 // (如 `"mycontent":1` —— 但这种情况极少见,多一道正则更稳)。 return /"content"\s*:/.test(obj); } /** * 从非 JSON 解析得到的对象里挑 reply/message/text/answer 字段当作纯文本。 * @param {unknown} parsed * @returns {string | null} */ function extractRawReply(parsed) { if (!parsed || typeof parsed !== 'object') return null; for (const key of ['reply', 'message', 'text', 'answer']) { const value = /** @type {Record} */ (parsed)[key]; if (typeof value === 'string') return value; } return null; } /** * 拼接 baseURL + path,自动处理末尾斜杠。 * @param {string} base * @param {string} path * @returns {string} */ function joinUrl(base, path) { // P3-3 fix (audit):拆分 query / fragment 后再拼。 // 否则 `https://gw.com?token=abc` 会被拼成 `https://gw.com?token=abc/v1/messages` // (query 后被拼了 path,URL 非法)。query / fragment 也可能在错误回显时 // 包含 api_key 之类敏感 token,所以一并禁止放在 baseURL 里。 // M2 fix (audit):剥掉 baseURL 里可能存在的 userinfo(`https://user:pass@host`)。 // fetch 会把 userinfo 当 Basic Auth 自动发送,把 API Key 当用户名/密码发给中转服务, // 敏感凭据直接泄露到第三方;错误回显里也会暴露凭据。 const s = String(base || ''); const m = s.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/); if (!m) return s; let b = m[1].replace(/\/+$/, ''); b = b.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/, '$1'); const p = String(path || '').replace(/^\/+/, ''); return `${b}/${p}${m[2] || ''}${m[3] || ''}`; } /** * 过滤错误回显里的敏感 token(OpenAI/Anthropic/proxy 可能在错误信息里 * echo URL 或 header)。API Key 不该出现在用户能看到的 toast 里。 * P2-2 fix (audit);Q3 fix (audit):补 Google API key / JWT / proxy-authorization。 * @param {string} detail * @returns {string} */ function sanitizeDetail(detail) { return String(detail || '') // OpenAI / 通用 OpenAI 风格 key:sk-xxx / sk-proj-xxx / sk-ant-xxx // M3 fix (audit):阈值 8 → 5,捕获被截断的 key(错误回显常见前缀模式 sk-12ab...)。 // 5 是保守下限:正常文本里 5 位随机 base64url 不常见 .replace(/sk-[A-Za-z0-9_-]{5,}/g, '[API_KEY]') // Google API key:AIzaSy 开头 + 33 字符。33 是 Google 当前规范 .replace(/AIzaSy[A-Za-z0-9_-]{20,}/g, '[API_KEY]') // JWT:header.payload.signature 三段 base64url;至少各 8 字符避免误伤短词 .replace(/eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[JWT]') // M3 fix (audit):先单独处理 JSON 风格 `"authorization": "Bearer sk-xxx"`—— // 旧正则 `[^\s,;&}"']+` 遇到 `"` 就停,只盖到 `"Bearer`,剩余 ` sk-xxx"` // 直接漏到下一步被文本截断逻辑保留。现在用单独 pattern 一次性吃掉整段带引号的值。 // audit fix (Phase O-L19):扩到 `authentication` / `www-authenticate` / `cookie` / // `set-cookie` —— 自部署中转 / Azure gateway 偶尔回 `Authentication: Bearer sk-xxx`, // LLM 自定义代理常通过 cookie 传 key。原始 key 不会泄露(line 229 的 sk-/AIzaSy // 兜底会替成 [API_KEY]),但 header 名这一行会在 detail 文本里残留。 .replace(/("(?:proxy-authorization|x-api-key|authorization|authentication|www-authenticate|cookie|set-cookie)"\s*:\s*)"[^"]*"/gi, '$1"[REDACTED]"') // 任意 header 里出现敏感 token —— 含 proxy-authorization(Q3 audit 新增) // 不再排除 `"` 和 `'`:让正则跨过引号吃掉值(与 JSON pattern 互补,命中 form-style) .replace(/(proxy-authorization|x-api-key|authorization|authentication|www-authenticate|cookie|set-cookie)\s*[:=]\s*[^\s,;&}]+/gi, '$1=[REDACTED]') // URL query 或 form body 里的 key/token .replace(/(api[_-]?key|token)\s*=\s*[^\s,;&}]+/gi, '$1=[REDACTED]'); } /** * 把 URL 中可能携带 secret 的部分脱敏再写到日志。 * 1. 去掉 query 和 fragment(用户 baseURL 不该带 ?api_key=xxx / #fragment,但有人会带) * 2. path 段里嵌入的 secret token(OpenAI sk-xxx / Google AIzaSy)也遮罩 * 保留 scheme + host + path,便于调试"请求打到哪个域名",但不带任何 secret。 * Q6 fix (audit)。 * @param {string} raw * @returns {string} */ function sanitizeUrl(raw) { return String(raw || '') // M2 fix (audit):先剥 userinfo(https://user:pass@host),否则 fetch Basic Auth // 凭据会被完整写到错误日志(即使 query/fragment 已剥,userinfo 仍在 host 前) .replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/, '$1') .replace(/[?#].*$/, '') // 去 query / fragment .replace(/\/(sk-[A-Za-z0-9_-]{8,})/g, '/[REDACTED]') // path 段里的 OpenAI key .replace(/\/(AIzaSy[A-Za-z0-9_-]{20,})/g, '/[REDACTED]'); // path 段里的 Google key } /** * 读 response body 到字符串,超过 MAX_RESP_BYTES 立刻中断。 * P2-3 fix (audit)。 * @param {Response} res * @returns {Promise<{ ok:true, text:string } | { ok:false, message:string }>} */ async function readBodyWithLimit(res) { if (!res.body || typeof res.body.getReader !== 'function') { // 旧版 fetch / mock:fallback 到 .text(),上限由 .text() 自带的内存限制兜底 try { const text = await res.text(); if (Buffer.byteLength(text, 'utf8') > MAX_RESP_BYTES) { return { ok: false, message: 'AI 响应体过大' }; } return { ok: true, text }; } catch { return { ok: false, message: '读取 AI 响应失败' }; } } const reader = res.body.getReader(); /** @type {Buffer[]} */ const chunks = []; let total = 0; while (true) { const { value, done } = await reader.read(); if (done) break; total += value.byteLength; if (total > MAX_RESP_BYTES) { try { reader.cancel(); } catch { /* ignore */ } return { ok: false, message: 'AI 响应体过大(超过 5 MB)' }; } chunks.push(Buffer.from(value)); } return { ok: true, text: Buffer.concat(chunks).toString('utf8') }; } /** * 创建主进程 AI 代理。 * @param {object} deps * @param {() => object} deps.getConfig - 取最新设置(configStore.getConfig);每次 runEdit 都会调用 * @param {typeof fetch} [deps.fetchImpl] - 注入 fetch(测试用) * @param {(key: string, payload: object) => void} [deps.log] - 日志钩子(不记录 apiKey) */ function createAiProxy({ getConfig, fetchImpl, log } = {}) { if (typeof getConfig !== 'function') { throw new Error('[ai] getConfig 必须是函数'); } const fetchFn = fetchImpl || ((...args) => fetch(...args)); const logFn = typeof log === 'function' ? log : () => {}; /** @type {Map} */ const pending = new Map(); /** requestId → 它的超时定时器。cancel 时一起清,避免 setTimeout 回调空转。 */ const timers = new Map(); /** audit fix (C4):请求生命周期登记集合(runEdit 入口 → finally),用于 * 真正拦截同 requestId 的重复进入 —— 之前的 pending 集合是 postJson 内部填的, * runEdit 同步阶段检查永远是空,已被注释自承为 dead code。 */ const inFlightRequestIds = new Set(); function cancel(requestId) { const controller = pending.get(requestId); const timer = timers.get(requestId); if (controller) { controller.abort(); pending.delete(requestId); } if (timer) { clearTimeout(timer); timers.delete(requestId); } } function cancelAll() { for (const controller of pending.values()) controller.abort(); pending.clear(); // C1 (audit):与 cancel() 一样同步清理 timeout,避免 setTimeout 回调空转 for (const timer of timers.values()) clearTimeout(timer); timers.clear(); } /** * 校验 baseURL 是否为合法 http(s) URL。 * 防止 javascript: / file: / data: 等伪协议触发 fetch TypeError 后报成"网络错误"误导用户。 * M1 (audit):旧版只走 fetch 抛错,错误信息不友好。 * @param {string} base * @returns {{ok:true, value:string} | {ok:false, reason:string}} */ function validateBaseUrl(base) { const s = String(base || '').trim(); if (!s) return { ok: false, reason: 'Base URL 不能为空' }; if (!/^https?:\/\//i.test(s)) { return { ok: false, reason: 'Base URL 必须以 http:// 或 https:// 开头' }; } // P2 fix:hostname 校验 —— 之前只校验前缀,`https:///etc/passwd`(缺少 host) // / `https:// host`(带空格)会被放行,然后 fetch 抛「ENOTFOUND / Invalid URL」 // 但错误信息毫无线索。new URL 直接拒绝这些畸形输入,给出可执行反馈。 try { const parsed = new URL(s); if (!parsed.hostname) { return { ok: false, reason: 'Base URL 缺少主机名' }; } // 主机名不能包含空白字符或 ASCII 控制字符(防御一些浏览器容忍的奇怪输入) // eslint-disable-next-line no-control-regex -- 控制字符范围是刻意检查的非法字符 if (/[\s\x00-\x1f]/.test(parsed.hostname)) { return { ok: false, reason: 'Base URL 主机名包含非法字符' }; } } catch (e) { return { ok: false, reason: 'Base URL 不是合法 URL:' + (e && e.message || '') }; } return { ok: true, value: s }; } /** * 按模型名路由 token 上限 + token 字段。 * H1 (audit):65536 远超多数模型上限(gpt-3.5=4096, gpt-4=8192, gpt-4o=16384), * 服务端可能直接 400 拒绝或抛 invalid_request_error。 * H2 (audit):gpt-5 / o-series 只接受 max_completion_tokens,旧字段会触发 * "Unsupported parameter" 错误;老模型反过来——只接受 max_tokens。 * H5 (audit):gpt-5 / o-series 不接受自定义 temperature(o1 固定为 1), * 发 0.2 会 400 invalid_request_error。 * @param {string} model * @returns {{ capTokens: number, tokenField: 'max_tokens' | 'max_completion_tokens', includeTemperature: boolean }} */ function pickOpenAITokenConfig(model) { const m = String(model || '').toLowerCase(); // gpt-5 / o-series → 必须用 max_completion_tokens;上限通常 ≥ 128k;不传 temperature if (/^(gpt-5|o1|o3|o4)/.test(m)) { return { capTokens: 32_000, tokenField: 'max_completion_tokens', includeTemperature: false }; } // gpt-4o / 4-turbo → max_tokens,上限 16k if (/^gpt-4o/.test(m) || /^gpt-4-turbo/.test(m)) { return { capTokens: 16_384, tokenField: 'max_tokens', includeTemperature: true }; } // 普通 gpt-4 → 8k if (/^gpt-4/.test(m)) { return { capTokens: 8_192, tokenField: 'max_tokens', includeTemperature: true }; } // gpt-3.5 → 4k if (/^gpt-3\.5/.test(m)) { return { capTokens: 4_096, tokenField: 'max_tokens', includeTemperature: true }; } // 未知模型(包括国产中转、自部署):保守值 + 老字段,最大限度兼容 return { capTokens: 4_096, tokenField: 'max_tokens', includeTemperature: true }; } /** * Anthropic 模型 max_tokens 上限路由。 * H1 (audit):Anthropic 不同模型上限差异很大(旧 haiku 4096 / sonnet-3-5 8192), * 65536 会被这些老模型 400 拒绝。 * * audit fix (round-13):补齐「5 系」与 4.6+ 命名。 * 之前的正则 `claude-(3-5|3\.5|3-7|sonnet-4|opus-4|4)` 要求 `claude-` 后面 * 紧跟这些片段,于是 claude-opus-5 / claude-sonnet-5 / claude-fable-5 / * claude-haiku-4-5 全部不命中 → 回退 8192,反而比 claude-opus-4-8(16384) * 更低。结果是「越新、输出上限越高的模型,拿到的 max_tokens 越小」: * 长笔记改写会在 8k 处被截断 → stop_reason: max_tokens → runEdit 走 * TRUNCATED_MESSAGE 分支报「AI 修改结果不完整」。这正是 Phase O-L17 * 想修掉的那类 bug,只是模型命名又演进了一代。 * * 现行分档(保守取值,远低于官方上限,避免中转/自部署网关拒绝): * - 5 系 + 4.6/4.7/4.8(官方 max output 128k)→ 32k * - 其余 4 系(含 haiku-4-5)+ 3-5/3-7 → 16k * - claude-3 老家族 → 8k * - 未知模型(国产中转 / 自部署)→ 8k,最大限度兼容 * @param {string} model * @returns {number} */ function pickAnthropicTokenConfig(model) { const m = String(model || '').toLowerCase(); // 5 系(opus-5 / sonnet-5 / fable-5 / mythos-5)与 4.6+ → 官方 128k 上限 if (/claude-(opus|sonnet|fable|mythos)-5/.test(m)) return 32_000; if (/claude-(opus|sonnet)-4-(6|7|8)/.test(m)) return 32_000; // 其余 4 系(sonnet-4-5 / opus-4-5 / haiku-4-5 / claude-4-*)+ 3-5 / 3-7 if (/claude-(3-5|3\.5|3-7|sonnet-4|opus-4|haiku-4|4)/.test(m)) return 16_384; if (/claude-3/.test(m)) return 8_192; return 8_192; // 未知模型回退到 8k —— 之前 4096 经常截断长 diff } /** * 抽象的 HTTP 调用 + 超时 + 取消 + 状态码错误处理。 * 不解析业务响应(OpenAI / Anthropic 各自的 JSON 结构在调用方处理)。 * * @param {{ * requestId: string, * url: string, * headers: Record, * body: object, * timeoutMs: number, * }} args * @returns {Promise<{ ok: true, json: any } | { ok: false, error: string, message: string }>} */ async function postJson({ requestId, url, headers, body, timeoutMs }) { const controller = new AbortController(); pending.set(requestId, controller); let timedOut = false; const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs); timers.set(requestId, timer); // audit fix:所有 exit 路径必须清理两个 Map,否则长时间使用会泄漏 // 内存(之前修复 cancelAll 只清理了 Map 没在 postJson 内清理 entry, // 现在统一走 helper 确保不遗漏)。 // // 关键:cleanup 只清理「自己的」controller / timer,不能按 requestId 无脑 // delete。如果 renderer 在我们 await fetchFn 的间隙用同一个 requestId 发起 // 了新请求(pending.has → cancel → 新 postJson),新请求会重新 // pending.set(requestId, newController)。随后旧 cleanup 在 microtask 阶段 // 触发 pending.delete(requestId),会把新请求的 controller 从 Map 里抹掉, // 导致新请求无法单独 cancel。比对 identity 再 delete 即可解决。 function cleanup() { clearTimeout(timer); if (pending.get(requestId) === controller) pending.delete(requestId); if (timers.get(requestId) === timer) timers.delete(requestId); } let res; try { // audit fix (Round 13 / Sec-H3):redirect: 'manual' 阻止 undici 跟随 3xx // 重定向到不同 origin 时复传自定义头。fetch 规范只会在 CORS 非通配头 // 集合里自动剥 `Authorization`,但 Anthropic 用的是 `x-api-key`(自定义头), // 不在脱敏名单里 —— 用户配置的中转 / 第三方网关一旦答 302 到攻击者域, // `x-api-key: sk-ant-...` 和当前笔记全文都会被转发出去。 // // 用 manual 后拿到的是 opaqueredirect 类型的 Response,status 0、body 不可读; // 我们在下文按 status === 0 / type === 'opaqueredirect' 显式报错给用户。 // 不支持 redirect 的真 endpoint 不会触发这条分支(2xx/4xx/5xx 都不是 3xx)。 res = await fetchFn(url, { method: 'POST', headers, body: JSON.stringify(body), signal: controller.signal, redirect: 'manual', }); } catch (e) { cleanup(); if (controller.signal.aborted) { if (timedOut) { return { ok: false, error: ERR_TIMEOUT, message: 'AI 请求超时,请缩小文档、简化要求或重试' }; } return { ok: false, error: ERR_CANCELLED, message: '已取消 AI 请求' }; } const msg = e instanceof Error ? e.message : String(e); if (/abort/i.test(msg)) { return { ok: false, error: timedOut ? ERR_TIMEOUT : ERR_CANCELLED, message: timedOut ? 'AI 请求超时,请缩小文档、简化要求或重试' : '已取消 AI 请求' }; } // M1 fix (audit):网络错误信息里也可能携带 api key(fetch 库 / DNS 错误里偶尔 // 会回显 URL 或 header),统一走 sanitizeDetail 防止泄露到 toast / 日志 return { ok: false, error: ERR_PROVIDER, message: `网络错误:${sanitizeDetail(msg)}` }; } cleanup(); // audit fix (Round 13 / Sec-H3):3xx 重定向在 manual 模式下表现为 status=0 // 且 type='opaqueredirect'。明示用户配置错了 Base URL,不要走"读 body 取错误信息" // 分支(那里会卡死读 body 或者报错信息误导成"格式错误")。 if (res.status === 0 || res.type === 'opaqueredirect') { return { ok: false, error: ERR_PROVIDER, message: 'Base URL 发生了重定向,请直接填写最终地址(出于 API Key 安全考虑,Notes 不会自动跟随重定向)', }; } if (!res.ok) { const status = res.status; let detail = ''; let errBody = null; // P2-3 fix (audit):用 readBodyWithLimit 限制响应体大小,防止 OOM // 审计修复 (Round 11 deep-fix P1-2):在 readBodyWithLimit 抛 AbortError 时 // (用户取消)不要静默走到下面 → bodyRead.ok === false 时仍正常报错 OK; // 但 reader 自身抛错会冒到 catch。检查 controller.signal.aborted 走「已取消」。 let bodyRead; try { bodyRead = await readBodyWithLimit(res); } catch (e) { if (controller && controller.signal && controller.signal.aborted) { return { ok: false, error: ERR_CANCELLED, message: '已取消 AI 请求' }; } throw e; } if (!bodyRead.ok) { // Q6 fix (audit):url 写入日志前 sanitizeUrl,去掉 query / fragment / path secret logFn('ai:http_error', { url: sanitizeUrl(url), status, detail: bodyRead.message }); return { ok: false, error: ERR_FORMAT, message: bodyRead.message }; } const text = bodyRead.text; if (text) { // 尝试解析为 JSON 取 error.message;不成功则把原文截断用作 detail try { const parsed = JSON.parse(text); errBody = parsed; if (parsed && parsed.error && typeof parsed.error.message === 'string') { detail = parsed.error.message; } } catch { // ignore } if (!detail) detail = text.length > 200 ? `${text.slice(0, 200)}…` : text; } // P2-2 fix (audit):过滤 detail 里的 api key / 敏感 token 后再 log + 回显 detail = sanitizeDetail(detail); // Q6 fix (audit):url 写入日志前 sanitizeUrl,去掉 query / fragment / path secret logFn('ai:http_error', { url: sanitizeUrl(url), status, detail }); if (status === 401 || status === 403) { return { ok: false, error: ERR_PROVIDER, message: 'API Key 无效或没有权限' }; } if (status === 404) { return { ok: false, error: ERR_PROVIDER, message: 'Base URL 或模型不存在' }; } if (status === 408 || status === 504) { return { ok: false, error: ERR_TIMEOUT, message: 'AI 请求超时,请缩小文档、简化要求或重试' }; } if (status === 429) { return { ok: false, error: ERR_PROVIDER, message: '请求过于频繁,请稍后重试' }; } if (status === 400 && errBody && errBody.error && errBody.error.type === 'invalid_request_error') { // audit fix (Round 8 A-1):这里过去用的是 `errBody.error.message` 原文, // 绕过了上面 line 525 的 sanitizeDetail —— 而 detail 正是同一个字符串 // 脱敏后的版本。自部署网关(Azure / LiteLLM / 各类中转)在 // invalid_request_error.message 里 echo 请求头或请求体的情况很常见, // 一旦回吐 `Bearer sk-...` 就会原样进 toast。改用已脱敏的 detail。 return { ok: false, error: ERR_PROVIDER, message: `请求参数错误:${detail || '服务端未提供详情'}` }; } if (status >= 500) { // audit fix (M3 regression):500 也带 sanitized detail, // 让上游服务器把真实错误("invalid token: sk-xxx")回吐时, // 用户能从 toast 看到「凭据有问题」而不是一句空泛的"服务不可用"。 // detail 已经走过 sanitizeDetail,key 类 token 已经被 [API_KEY] 替换。 return { ok: false, error: ERR_PROVIDER, message: `AI 服务暂时不可用(HTTP ${status})${detail ? ':' + detail : ''}`, }; } return { ok: false, error: ERR_PROVIDER, message: `AI 请求失败(HTTP ${status})${detail ? ':' + detail : ''}` }; } /** @type {any} */ let json; try { // P2-3 fix (audit):成功路径也走大小限制(恶意 / 错误配置的服务端可能 // 对 200 也返回大 body)。用 text + JSON.parse 替代 res.json()。 const bodyRead = await readBodyWithLimit(res); if (!bodyRead.ok) return { ok: false, error: ERR_FORMAT, message: bodyRead.message }; json = JSON.parse(bodyRead.text); } catch (e) { // 审计修复 (Round 11 deep-fix P1-2):用户取消时 readBodyWithLimit 内部 // reader.cancel() 抛 AbortError,外层 catch 之前把它误判成 "不是合法 JSON" // → UI 看到误导的格式错误。先检查 controller.signal.aborted 走「已取消」分支。 if (controller && controller.signal && controller.signal.aborted) { return { ok: false, error: ERR_CANCELLED, message: '已取消 AI 请求' }; } const msg = e instanceof Error ? e.message : String(e); if (/abort/i.test(msg)) { return { ok: false, error: ERR_CANCELLED, message: '已取消 AI 请求' }; } return { ok: false, error: ERR_FORMAT, message: 'AI 返回的不是合法 JSON' }; } return { ok: true, json }; } /** * 把模型原始文本回复归一化成 { content, responseFormat }: * - 解析成 { content: string } → responseFormat = 'json'(用于文档修改 diff) * - 其它情况 → responseFormat = 'raw'(普通对话回复) */ function normalizeAssistantText(message, originalContent) { const parsed = tryParseJson(message); if (parsed && typeof parsed === 'object' && typeof parsed.content === 'string') { const next = parsed.content; if (next === originalContent) { return { content: '当前文档无需修改。', responseFormat: 'raw' }; } return { content: next, responseFormat: 'json' }; } // audit fix (shared-M13):如果 JSON 解析成功但 content 不是字符串(典型 // 是 [] 数组 / 对象 / null / 数字),不要按「截断」处理 —— 模型是按规矩 // 返回 JSON 对象的,只是 content 的形状不是我们约定的字符串。把 message // 整体当 raw 回退返回给用户,至少他们能看到模型实际输出了什么,而不是 // 看到一个误导的「输出不完整,请重试」提示。 if (parsed && typeof parsed === 'object' && 'content' in parsed) { return { content: message, responseFormat: 'raw' }; } const rawReply = extractRawReply(parsed); if (rawReply) return { content: rawReply, responseFormat: 'raw' }; if (looksLikeEditJson(message)) { return null; // JSON 看起来想返回 {content:...} 但解析失败 —— 截断 } return { content: message, responseFormat: 'raw' }; } /** * OpenAI 兼容分支:POST {baseURL}/chat/completions。 */ async function runOpenAIEdit({ prompt, content, filename, requestId, timeoutMs }) { const config = (typeof getConfig === 'function' ? getConfig() : {}) || {}; const baseURL = String(config.aiBaseUrl || '').trim(); const apiKey = String(config.aiApiKey || '').trim(); const model = String(config.aiModel || '').trim(); const systemPrompt = String(config.aiSystemPrompt || '').trim() || CURRENT_FILE_EDIT_SYSTEM_PROMPT; if (!baseURL || !apiKey || !model) { return { ok: false, error: ERR_NOT_CONFIGURED, message: '请先在设置中填写 AI 的 Base URL、API Key 和模型名' }; } // M1 (audit):拒绝 javascript: / data: / file: 等伪协议,避免 fetch TypeError 报成"网络错误" const urlCheck = validateBaseUrl(baseURL); if (!urlCheck.ok) { return { ok: false, error: ERR_NOT_CONFIGURED, message: urlCheck.reason }; } // audit fix (Round 8 A-2):明文 HTTP + 非loopback + 已配置 API Key → 拒绝。 // 本地代理(Ollama / LM Studio / vllm)走 http://localhost / 127.0.0.1 / ::1 // 且不需要 Key —— apiKey 留空就过;这里有 apiKey 意味着第三方 API,必须 HTTPS。 // 静默放行会让 Key 在网线上裸奔到攻击者嗅探点,错误必须显式。 // localhost/127.0.0.1/::1 仍然放过:本地抓包门槛远高于公网,本地代理是合法场景。 if (/^http:\/\//i.test(urlCheck.value) && apiKey) { try { const parsed = new URL(urlCheck.value); const host = (parsed.hostname || '').toLowerCase(); const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1'; if (!isLoopback) { console.warn('[ai] 明文 HTTP + 非loopback + 已配 Key,拒绝请求以防泄露:', sanitizeUrl(urlCheck.value)); return { ok: false, error: ERR_NOT_CONFIGURED, message: '检测到 Base URL 使用明文 HTTP 且已配置 API Key;请改用 https:// 以避免 Key 在网络传输中被窃取。本地代理(localhost / 127.0.0.1)允许明文。', }; } } catch { /* validateBaseUrl 已校验过 URL,这里兜底 */ } } const url = joinUrl(baseURL, 'chat/completions'); // H1+H2+H5 (audit):按模型名路由 token 上限,避免 65536 超过多数模型上限被拒; // 同时按模型名决定发 max_tokens 还是 max_completion_tokens(gpt-5 / o-series 只接受后者)。 // o-series / gpt-5 也不接受自定义 temperature(o1 强制为 1,发 0.2 会 400)。 const { capTokens, tokenField, includeTemperature } = pickOpenAITokenConfig(model); /** @type {Record} */ const body = { model, [tokenField]: capTokens, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: JSON.stringify({ filename, currentMarkdown: content, userPrompt: prompt }), }, ], stream: false, }; if (includeTemperature) body.temperature = 0.2; logFn('ai:request', { provider: 'openai', url: sanitizeUrl(url), model, filename, promptLen: prompt.length, contentLen: content.length }); const result = await postJson({ requestId, url, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body, timeoutMs }); if (!result.ok) return result; const json = result.json; const choice = json && Array.isArray(json.choices) ? json.choices[0] : null; if (!choice) { return { ok: false, error: ERR_FORMAT, message: 'AI 返回了空结果' }; } if (choice.finish_reason === 'length') { return { ok: false, error: ERR_FORMAT, message: TRUNCATED_MESSAGE }; } if (choice.finish_reason === 'content_filter') { return { ok: false, error: ERR_FORMAT, message: 'AI 返回被服务方过滤,请调整要求后重试' }; } const message = choice.message && typeof choice.message.content === 'string' ? choice.message.content : ''; if (!message) { return { ok: false, error: ERR_FORMAT, message: 'AI 返回了空内容' }; } const normalized = normalizeAssistantText(message, content); if (normalized === null) { return { ok: false, error: ERR_FORMAT, message: TRUNCATED_MESSAGE }; } return { ok: true, id: requestId, content: normalized.content, responseFormat: normalized.responseFormat }; } /** * Anthropic 分支:POST {baseURL}/v1/messages。 * system 单独字段(不在 messages 里);messages 只有 user/assistant 两轮对话。 */ async function runAnthropicEdit({ prompt, content, filename, requestId, timeoutMs }) { const config = (typeof getConfig === 'function' ? getConfig() : {}) || {}; const baseURL = String(config.aiBaseUrl || '').trim(); const apiKey = String(config.aiApiKey || '').trim(); const model = String(config.aiModel || '').trim(); const systemPrompt = String(config.aiSystemPrompt || '').trim() || CURRENT_FILE_EDIT_SYSTEM_PROMPT; if (!baseURL || !apiKey || !model) { return { ok: false, error: ERR_NOT_CONFIGURED, message: '请先在设置中填写 AI 的 Base URL、API Key 和模型名' }; } // M1 (audit):同样校验协议前缀,与 OpenAI 分支一致 const urlCheck = validateBaseUrl(baseURL); if (!urlCheck.ok) { return { ok: false, error: ERR_NOT_CONFIGURED, message: urlCheck.reason }; } // audit fix (Round 8 A-2):明文 HTTP + 非loopback + 已配置 API Key → 拒绝。 // 与 OpenAI 分支共用同样防御,本地代理不需要 Key,留空即可。 if (/^http:\/\//i.test(urlCheck.value) && apiKey) { try { const parsed = new URL(urlCheck.value); const host = (parsed.hostname || '').toLowerCase(); const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1'; if (!isLoopback) { console.warn('[ai] 明文 HTTP + 非loopback + 已配 Key,拒绝请求以防泄露:', sanitizeUrl(urlCheck.value)); return { ok: false, error: ERR_NOT_CONFIGURED, message: '检测到 Base URL 使用明文 HTTP 且已配置 API Key;请改用 https:// 以避免 Key 在网络传输中被窃取。本地代理(localhost / 127.0.0.1)允许明文。', }; } } catch { /* validateBaseUrl 已校验过 URL */ } } const url = joinUrl(baseURL, 'v1/messages'); // H1 (audit):Anthropic 各模型 max_tokens 上限不同,超额会 400 const capTokens = pickAnthropicTokenConfig(model); const body = { model, max_tokens: capTokens, system: systemPrompt, messages: [ { role: 'user', content: JSON.stringify({ filename, currentMarkdown: content, userPrompt: prompt }), }, ], }; logFn('ai:request', { provider: 'anthropic', url: sanitizeUrl(url), model, filename, promptLen: prompt.length, contentLen: content.length }); const result = await postJson({ requestId, url, headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': ANTHROPIC_API_VERSION, }, body, timeoutMs }); if (!result.ok) return result; const json = result.json; // 拼接所有 type==='text' 的 content 块;忽略 tool_use / tool_result 等。 if (!json || !Array.isArray(json.content)) { return { ok: false, error: ERR_FORMAT, message: 'AI 返回了空结果' }; } if (json.stop_reason === 'max_tokens') { return { ok: false, error: ERR_FORMAT, message: TRUNCATED_MESSAGE }; } /** @type {string} */ let message = ''; let hasNonTextBlock = false; for (const block of json.content) { if (block && block.type === 'text' && typeof block.text === 'string') { message += block.text; } else if (block && block.type !== 'text') { // audit fix (shared-M11):tool_use / tool_result / image 等非文本块 // 当前版本不消费,但若整条 response 只有这些块就给一个明确的中文 // 提示,而不是误导用户「AI 返回了空内容」(实际是格式我们暂时不支持)。 hasNonTextBlock = true; } } if (!message) { // audit fix (shared-M11):把「模型只回了 tool_use / image 但没有正文」 // 与「模型真的没回东西」区分开 —— 后者才报「空内容」。 if (hasNonTextBlock) { return { ok: false, error: ERR_FORMAT, message: 'AI 返回了无法识别的内容(仅含 tool_use / 图像块,无文本回复)', }; } return { ok: false, error: ERR_FORMAT, message: 'AI 返回了空内容' }; } const normalized = normalizeAssistantText(message, content); if (normalized === null) { return { ok: false, error: ERR_FORMAT, message: TRUNCATED_MESSAGE }; } return { ok: true, id: requestId, content: normalized.content, responseFormat: normalized.responseFormat }; } /** * 执行一次 AI 修改请求。 * @param {{ prompt:string, content:string, filename:string, requestId:string }} input * @returns {Promise<{ ok:true, id:string, content:string, responseFormat:'json'|'raw' } | { ok:false, error:string, message:string }>} */ async function runEdit({ prompt, content, filename, requestId }) { if (typeof prompt !== 'string' || !prompt.trim()) { return { ok: false, error: 'INVALID_PROMPT', message: '修改要求不能为空' }; } if (typeof content !== 'string') { return { ok: false, error: 'INVALID_CONTENT', message: '当前文件内容无效' }; } // 字节上限用 UTF-8 编码长度近似(中文/emoji 一个字符多字节,会略高估) if (Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) { return { ok: false, error: 'CONTENT_TOO_LARGE', message: `文档过大(超过 ${(MAX_CONTENT_BYTES / 1024 / 1024).toFixed(1)} MB),请拆分后再让 AI 修改`, }; } if (Buffer.byteLength(prompt, 'utf8') > 64_000) { return { ok: false, error: 'PROMPT_TOO_LARGE', message: '提示词过长(超过 64 KB),请简化要求', }; } if (typeof filename !== 'string') { filename = ''; } if (typeof requestId !== 'string' || !requestId) { requestId = `ai-${Date.now()}-${Math.random().toString(36).slice(2)}`; } const config = (typeof getConfig === 'function' ? getConfig() : {}) || {}; const provider = String(config.aiProvider || 'openai').trim().toLowerCase(); const timeoutMs = DEFAULT_TIMEOUT_MS; // audit fix (C4):原 `if (pending.has(requestId)) cancel(requestId)` 是死代码 — // pending 是 postJson 内部填的,postJson 在 runEdit 异步返回之后才执行, // runEdit 入口检查 pending 时它一定是空的,双击防护其实由 renderer 端 // ai-chat-panel 的 _submitting 守门。这里改成真正的 inFlightRequestIds: // 进入 runEdit 即登记(早于 postJson),finally 清理,覆盖整个请求生命周期。 if (inFlightRequestIds.has(requestId)) { cancel(requestId); } inFlightRequestIds.add(requestId); // 注:token 上限不再用入参 maxTokens(已被 pick*TokenConfig 按模型路由取代) const baseArgs = { prompt, content, filename, requestId, timeoutMs }; try { if (provider === 'anthropic') { return await runAnthropicEdit(baseArgs); } if (provider === 'openai') { return await runOpenAIEdit(baseArgs); } // audit fix (2.5):未知 provider 不再静默按 OpenAI 走 —— 配置错了会让用户困惑。 // schema enum 已防,但 IPC 直调 / 老 settings 文件可能漏过来,这里明确报错。 return { ok: false, error: ERR_NOT_CONFIGURED, message: `未知 AI 服务提供方:${provider}(请在设置里选 OpenAI 兼容或 Anthropic 兼容)`, }; } finally { // audit fix (C4):无论成功 / 失败 / 抛错都清登记,下次同 requestId // 再来能正常进入;防 Set 缓慢增长。 inFlightRequestIds.delete(requestId); } } return { runEdit, cancel, cancelAll, }; } module.exports = { createAiProxy, // 共享错误码表 —— preload 经 contextBridge 把同一份 AI_ERROR 暴露到 // window.api.aiErrors,renderer 直接 window.api?.aiErrors?.AI_ERROR。 // main/ai.js 内部仍用 ERR_* 命名别名(line 59-63),纯粹是阅读性,无外部 // 调用方 —— 别名不出 module.exports,避免「两个相同字面值漂移」风险。 AI_ERROR, };