update
This commit is contained in:
938
main/ai.js
Normal file
938
main/ai.js
Normal file
@@ -0,0 +1,938 @@
|
||||
// 主进程 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<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 后被拼了 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<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 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<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 类型的 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<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 即登记(早于 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,
|
||||
};
|
||||
Reference in New Issue
Block a user