Files
Notes/main/ai.js
2026-09-12 14:15:26 +08:00

939 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 主进程 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.aiErrorsmain / 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<string, unknown>} */ (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 后被拼了 pathURL 非法。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] || ''}`;
}
/**
* 过滤错误回显里的敏感 tokenOpenAI/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 风格 keysk-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 keyAIzaSy 开头 + 33 字符。33 是 Google 当前规范
.replace(/AIzaSy[A-Za-z0-9_-]{20,}/g, '[API_KEY]')
// JWTheader.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-authorizationQ3 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 tokenOpenAI 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):先剥 userinfohttps://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 / mockfallback 到 .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<string, AbortController>} */
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 fixhostname 校验 —— 之前只校验前缀,`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 不接受自定义 temperatureo1 固定为 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-816384
* 更低。结果是「越新、输出上限越高的模型,拿到的 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<string, string>,
* 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 类型的 Responsestatus 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 keyfetch 库 / 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 已经走过 sanitizeDetailkey 类 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_tokensgpt-5 / o-series 只接受后者)。
// o-series / gpt-5 也不接受自定义 temperatureo1 强制为 1发 0.2 会 400
const { capTokens, tokenField, includeTemperature } = pickOpenAITokenConfig(model);
/** @type {Record<string, any>} */
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 即登记(早于 postJsonfinally 清理,覆盖整个请求生命周期。
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.aiErrorsrenderer 直接 window.api?.aiErrors?.AI_ERROR。
// main/ai.js 内部仍用 ERR_* 命名别名line 59-63纯粹是阅读性无外部
// 调用方 —— 别名不出 module.exports避免「两个相同字面值漂移」风险。
AI_ERROR,
};