1. 目录 mcn-work-shop -> mcn-workshop(空间分组名取会话 cwd 的目录名,只改字符串无效,必须真改名) 2. 全仓替换 mcn-work-shop -> mcn-workshop:37 文件 121 处;历史日志按沿革句规矩保留当时目录名 3. 任务进度可见:/api/run/status 产出 已运行时长/工具调用/最近动作/停滞判定(读会话日志尾部),前端新增右下角常驻面板,四处任务入口接入 4. 会话归属目录配置化:新增 config.sessionCwd(留空=工作台自身目录),现指向 D://AI技能//mcn-workshop,使任务显示为命名分组而非「未分组任务」 5. 修前端轮询静默缺陷:连续 3 次查询失败即提示服务断开(原逻辑静默空转到 15 分钟超时,用户零感知)
435 lines
19 KiB
JavaScript
435 lines
19 KiB
JavaScript
// 短视频工作台 - CodeBuddy CLI 后端(09-29 新增)
|
||
//
|
||
// 用途:把「工作台 → AI 模型」的通路从 Dify 换成 **本地 CodeBuddy CLI 进程**。
|
||
// 形态:spawn 一个 CLI 子进程,用 stdio 通信(**不经任何端口**),
|
||
// 解析 `--output-format stream-json` 的 NDJSON,把文本增量回调给上层。
|
||
//
|
||
// ⛔ 三个必须处理的坑(均为实测踩出,勿删注释):
|
||
//
|
||
// 1) SERVER__PORT / SERVER__HOST 必须删掉。
|
||
// CLI(含 -p 打印模式)内部会起一个 HTTP server,端口取自
|
||
// `parseInt(process.env.SERVER__PORT) || config.get("cell.server").port`。
|
||
// WorkBuddy 桌面进程给自己设了 SERVER__PORT,任何在其进程树内启动的 CLI
|
||
// 都会继承该值 → 去 listen 宿主已占用的端口 → `EADDRINUSE` → 进程卡死。
|
||
// (官方在 CLI 派生子进程时也是这么做的:`delete el.SERVER__PORT, delete el.SERVER__HOST`)
|
||
//
|
||
// 2) 必须显式指定 bundle,否则 launcher 可能命中 WorkBuddy 特供包或找不到产物。
|
||
// `CODEBUDDY_FORCE_LITE_WB_BUNDLE=1`(WorkBuddy 宿主注入)→ codebuddy-lite-wb.mjs
|
||
// `CODEBUDDY_FORCE_HEADLESS_BUNDLE=1` → codebuddy-headless.js(我们要这个)
|
||
// 两者都无 → dist/codebuddy(本安装里**不存在**,会 MODULE_NOT_FOUND)
|
||
//
|
||
// 3) 凭据无法从宿主继承(这是 WorkBuddy 的凭据保护设计,不是 bug)。
|
||
// WorkBuddy 用 4 个环境变量把凭据交给 CLI 子进程,CLI 读完**立即 delete**;
|
||
// 且凭据不落明文盘。→ 独立进程必须自备凭据:
|
||
// CODEBUDDY_API_KEY (个人开发,推荐)或
|
||
// CODEBUDDY_AUTH_TOKEN (已有 OAuth token,优先级最高)
|
||
// 另需配套 CODEBUDDY_INTERNET_ENVIRONMENT(中国版 = internal)。
|
||
//
|
||
// 认证优先级(官方):CODEBUDDY_AUTH_TOKEN > apiKeyHelper > CODEBUDDY_API_KEY
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const { spawn } = require('child_process');
|
||
|
||
// ---------------------------------------------------------------- CLI 定位
|
||
|
||
// WorkBuddy 桌面版自带的 CLI 位置(用环境变量推导,不写死盘符)
|
||
function candidateCliPaths() {
|
||
const out = [];
|
||
const localAppData = process.env.LOCALAPPDATA || '';
|
||
const appData = process.env.APPDATA || '';
|
||
const bases = [localAppData, appData].filter(Boolean);
|
||
for (const b of bases) {
|
||
out.push(path.join(b, 'Programs', 'WorkBuddy', 'resources', 'app.asar.unpacked', 'cli', 'bin', 'codebuddy'));
|
||
}
|
||
// 独立安装(npm -g / 原生安装器)→ PATH 里找
|
||
out.push('codebuddy');
|
||
return out;
|
||
}
|
||
|
||
// 返回 { cmd, args, kind } —— cmd 为可执行文件,args 为其前置参数
|
||
function resolveCli(explicitPath) {
|
||
const tries = [];
|
||
if (explicitPath) tries.push(explicitPath);
|
||
if (process.env.CODEBUDDY_CLI_PATH) tries.push(process.env.CODEBUDDY_CLI_PATH);
|
||
tries.push(...candidateCliPaths());
|
||
|
||
for (const p of tries) {
|
||
if (!p) continue;
|
||
if (p === 'codebuddy') return { cmd: 'codebuddy', args: [], kind: 'path' };
|
||
try {
|
||
if (fs.existsSync(p) && fs.statSync(p).isFile()) {
|
||
// 该 launcher 是 JS 文件(带 shebang)→ 用当前 node 执行,Windows 下最稳
|
||
return { cmd: process.execPath, args: [p], kind: 'bundled', script: p };
|
||
}
|
||
} catch (e) { /* 忽略,继续探测 */ }
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 凭据解析
|
||
|
||
// 凭据默认落点:**刻意放在仓库之外**。
|
||
// config.json 是 git 跟踪文件,绝不能让 key 落进去;本目录也不会被技能同步脚本复制。
|
||
function defaultCredentialFile() {
|
||
return path.join(os.homedir(), '.workbuddy', 'mcn-workshop', 'ai-cli.json');
|
||
}
|
||
|
||
// 读凭据文件:{"apiKey":"...","authToken":"..."}
|
||
function readCredentialFile(p) {
|
||
if (!p) return {};
|
||
try {
|
||
if (!fs.existsSync(p)) return {};
|
||
const j = JSON.parse(fs.readFileSync(p, 'utf8'));
|
||
return {
|
||
apiKey: typeof j.apiKey === 'string' ? j.apiKey.trim() : '',
|
||
authToken: typeof j.authToken === 'string' ? j.authToken.trim() : '',
|
||
file: p,
|
||
};
|
||
} catch (e) { return {}; }
|
||
}
|
||
|
||
/**
|
||
* 凭据优先级(高 → 低):
|
||
* 1) 调用方显式传入(config.json 的 ai.cli.apiKey / authToken)
|
||
* 2) 凭据文件(默认 ~/.workbuddy/mcn-workshop/ai-cli.json)
|
||
* 3) 环境变量 CODEBUDDY_API_KEY / CODEBUDDY_AUTH_TOKEN
|
||
* 返回 { apiKey, authToken, source }
|
||
*/
|
||
function resolveCredentials(o) {
|
||
o = o || {};
|
||
const f = readCredentialFile(o.credentialFile || defaultCredentialFile());
|
||
const apiKey = o.apiKey || f.apiKey || process.env.CODEBUDDY_API_KEY || '';
|
||
const authToken = o.authToken || f.authToken || process.env.CODEBUDDY_AUTH_TOKEN || '';
|
||
let source = '(未配置)';
|
||
if (o.authToken) source = 'config:authToken';
|
||
else if (o.apiKey) source = 'config:apiKey';
|
||
else if (f.authToken) source = 'file:authToken ' + f.file;
|
||
else if (f.apiKey) source = 'file:apiKey ' + f.file;
|
||
else if (process.env.CODEBUDDY_AUTH_TOKEN) source = 'env:CODEBUDDY_AUTH_TOKEN';
|
||
else if (process.env.CODEBUDDY_API_KEY) source = 'env:CODEBUDDY_API_KEY';
|
||
return { apiKey, authToken, source, credentialFile: f.file || defaultCredentialFile() };
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 环境构造
|
||
|
||
// 把「宿主 WorkBuddy 注入的、会干扰子 CLI 的」环境变量剔除,并注入凭据
|
||
function buildChildEnv(opts) {
|
||
const env = { ...process.env };
|
||
|
||
// ⛔ 坑 1:端口继承 → EADDRINUSE(见文件头注释)
|
||
delete env.SERVER__PORT;
|
||
delete env.SERVER__HOST;
|
||
|
||
// ⛔ 必须清掉:WorkBuddy 注入的 node shim / 会话上下文 / bundle 分流
|
||
delete env.NODE_OPTIONS;
|
||
env.CODEBUDDY_FORCE_LITE_WB_BUNDLE = ''; // 关掉 wb 特供包分流
|
||
env.CODEBUDDY_FORCE_HEADLESS_BUNDLE = '1'; // 【坑 2】强制官方 headless bundle
|
||
for (const k of Object.keys(env)) {
|
||
if (k.startsWith('CODEBUDDY_SESSION_') || k.startsWith('CODEBUDDY_CONVERSATION_') ||
|
||
k === 'CODEBUDDY_TOOL_CALL_ID' || k === 'CODEBUDDY_SERVICE_PROXY_URL' ||
|
||
k === 'CODEBUDDY_MCP_CONFIG' || // 宿主 MCP(weixinpay/sheetagent…)我们不要
|
||
k === 'CODEBUDDY_GATEWAY_PASSWORD' || k === 'CODEBUDDY_GATEWAY_AUTH' ||
|
||
k === 'WORKBUDDY_PAC_RPC_TOKEN' || k === 'WORKBUDDY_PAC_RPC_SOCKET' ||
|
||
k === 'SANDBOX_CENTER_IPC_ADDRESS' || k === 'BAGGAGE' || k === 'BASH_ENV') {
|
||
delete env[k];
|
||
}
|
||
}
|
||
delete env.CLAUDE_SESSION_ID;
|
||
|
||
// 隔离配置/数据目录:不污染 WorkBuddy 自身的配置与会话历史
|
||
env.CODEBUDDY_CONFIG_DIR = opts.homeDir;
|
||
env.CODEBUDDY_SAFE_DELETE_ENABLED = '0'; // 工作台场景不需要工具删除保护
|
||
env.CODEBUDDY_GATEWAY_DISABLE_API_DOCS = '1';
|
||
env.CODEBUDDY_DISABLE_TELEMETRY = '1';
|
||
// 官方建议:纯 -p 单发不支持后台任务,开着会返回明确错误
|
||
env.CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS = '1';
|
||
|
||
// 【坑 3】凭据:独立进程必须自备。
|
||
// 注意:宿主也会用同名变量注入凭据且读后即删——必须先把继承来的残留清掉,再写我们自己的,
|
||
// 否则可能带着一个「空壳/过期」的值进入子进程。
|
||
delete env.CODEBUDDY_API_KEY;
|
||
delete env.CODEBUDDY_AUTH_TOKEN;
|
||
const cred = resolveCredentials(opts);
|
||
if (cred.authToken) env.CODEBUDDY_AUTH_TOKEN = cred.authToken; // 优先级最高
|
||
if (cred.apiKey) env.CODEBUDDY_API_KEY = cred.apiKey;
|
||
if (opts.internetEnvironment) env.CODEBUDDY_INTERNET_ENVIRONMENT = opts.internetEnvironment;
|
||
if (opts.baseUrl) env.CODEBUDY_BASE_URL = opts.baseUrl;
|
||
|
||
// 本地回环不走代理(代理会把 127.0.0.1 也吃掉)
|
||
env.no_proxy = '127.0.0.1,localhost';
|
||
env.NO_PROXY = '127.0.0.1,localhost';
|
||
|
||
return env;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 流式执行
|
||
|
||
/**
|
||
* 跑一次 CLI,流式回调文本增量。
|
||
*
|
||
* @param {object} o
|
||
* @param {string} o.prompt 用户提示词
|
||
* @param {string} o.cwd 工作目录(决定 CLI 能看到哪些项目文件)
|
||
* @param {string} [o.homeDir] CLI 的配置/数据目录(默认 <os.tmpdir>/mcn-cli-home)
|
||
* @param {string} [o.model] 模型 ID
|
||
* @param {string} [o.apiKey] CODEBUDDY_API_KEY(优先级最高,一般留空走凭据文件)
|
||
* @param {string} [o.authToken] CODEBUDDY_AUTH_TOKEN
|
||
* @param {string} [o.credentialFile] 凭据文件路径,默认 ~/.workbuddy/mcn-workshop/ai-cli.json
|
||
* @param {string} [o.internetEnvironment] internal / ioa / 留空
|
||
* @param {string} [o.cliPath] 显式指定 CLI 路径
|
||
* @param {number} [o.timeoutMs]
|
||
* @param {boolean} [o.search] 是否允许联网搜索(默认 true)。true → 工具面收敛为 WebSearch+WebFetch
|
||
* @param {string} [o.tools] 显式工具白名单:'default' | 'none' | 'WebSearch,WebFetch'(留空=按 search 推导)
|
||
* @param {boolean} [o.strictMcp] 是否屏蔽宿主 MCP(默认 true)
|
||
* @param {string} [o.appendSystemPrompt] 追加系统提示(如搜索使用策略)
|
||
* @param {number} [o.maxTurns] 最大 agent 轮数(0=不限)
|
||
* @param {function} o.onDelta (text) => void 文本增量
|
||
* @param {function} [o.onThink] (text) => void 思考增量(可选)
|
||
* @param {function} [o.onEvent] (obj) => void 全部原始事件(可选,调试用)
|
||
* @param {AbortSignal} [o.signal]
|
||
* @returns {Promise<{ok:boolean, text:string, error?:string, sessionId?:string, usage?:object}>}
|
||
*/
|
||
function runCli(o) {
|
||
return new Promise((resolve) => {
|
||
const cli = resolveCli(o.cliPath);
|
||
if (!cli) {
|
||
return resolve({ ok: false, text: '', error: '未找到 CodeBuddy CLI(可在 config.json 的 ai.cli.path 指定,或设环境变量 CODEBUDDY_CLI_PATH)' });
|
||
}
|
||
|
||
const homeDir = o.homeDir || path.join(os.tmpdir(), 'mcn-cli-home');
|
||
try { fs.mkdirSync(homeDir, { recursive: true }); } catch (e) {}
|
||
|
||
const args = [
|
||
...cli.args,
|
||
'-p', o.prompt,
|
||
'--output-format', 'stream-json',
|
||
'--include-partial-messages',
|
||
'-y', // 非交互必需(文档明确要求)
|
||
...(o.model ? ['--model', o.model] : []),
|
||
...(o.permissionMode ? ['--permission-mode', o.permissionMode] : []),
|
||
];
|
||
|
||
// ---- 工具面收敛(既提速,也是安全边界)----
|
||
// 工作台的 AI 只负责「产出文本」,prompt 里已自带全部上下文(账号设定/需求/历史),
|
||
// 不需要读写文件、跑命令。默认只给联网搜索两个工具:
|
||
// · 启动快(不必加载 Bash/Edit/Write/... 的工具 schema)
|
||
// · 模型物理上碰不到本机文件系统
|
||
// o.search === false → 放开默认工具面,但明确禁用联网搜索
|
||
let tools = o.tools;
|
||
if (tools === undefined || tools === null || tools === '') {
|
||
tools = (o.search === false) ? 'default' : 'WebSearch,WebFetch';
|
||
}
|
||
if (tools === 'none') args.push('--tools', '');
|
||
else if (tools && tools !== 'default') args.push('--tools', tools);
|
||
if (o.search === false) args.push('--disallowedTools', 'WebSearch,WebFetch');
|
||
|
||
// ---- 屏蔽宿主 MCP ----
|
||
// 宿主会把 CODEBUDDY_MCP_CONFIG 注入进程树,里面挂着 weixinpay / sheetagent 等。
|
||
// 工作台场景一个都用不上,加载它们纯属浪费启动时间,还可能被模型误调。
|
||
if (o.strictMcp !== false) {
|
||
args.push('--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}');
|
||
}
|
||
|
||
// 推理开销:把首字延迟从几十秒压到十几秒的关键开关(minimal|low|medium|high|xhigh|max)
|
||
if (o.effort) args.push('--effort', o.effort);
|
||
|
||
if (o.appendSystemPrompt) args.push('--append-system-prompt', o.appendSystemPrompt);
|
||
if (parseInt(o.maxTurns, 10) > 0) args.push('--max-turns', String(parseInt(o.maxTurns, 10)));
|
||
|
||
const env = buildChildEnv({
|
||
homeDir,
|
||
apiKey: o.apiKey,
|
||
authToken: o.authToken,
|
||
internetEnvironment: o.internetEnvironment,
|
||
baseUrl: o.baseUrl,
|
||
});
|
||
|
||
let child;
|
||
try {
|
||
child = spawn(cli.cmd, args, {
|
||
cwd: o.cwd || process.cwd(),
|
||
env,
|
||
windowsHide: true,
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
} catch (e) {
|
||
return resolve({ ok: false, text: '', error: 'CLI 启动失败: ' + e.message });
|
||
}
|
||
|
||
const timeoutMs = o.timeoutMs || 300000;
|
||
let timer = setTimeout(() => { aborted = true; try { child.kill(); } catch (e) {} }, timeoutMs);
|
||
let aborted = false;
|
||
if (o.signal) {
|
||
if (o.signal.aborted) { aborted = true; try { child.kill(); } catch (e) {} }
|
||
else o.signal.addEventListener('abort', () => { aborted = true; try { child.kill(); } catch (e) {} }, { once: true });
|
||
}
|
||
|
||
let full = '';
|
||
let streamed = ''; // 已经通过 onDelta 推给前端的内容(用于终态比对)
|
||
let stderr = '';
|
||
let sessionId = '';
|
||
let usage = null;
|
||
let resultText = '';
|
||
let errText = '';
|
||
let sawDelta = false;
|
||
let buf = '';
|
||
// 每次工具调用的发生位置(= 当时的 full 长度)。
|
||
// 根因:模型在发起 WebSearch 前常先吐一句过程话术(如
|
||
// "I'll search for ... before responding."),它不是答案。
|
||
// 只保留「最后一次工具调用之后」的文本作为正文。
|
||
const toolMarks = [];
|
||
|
||
const handleLine = (line) => {
|
||
line = line.trim();
|
||
if (!line) return;
|
||
let ev;
|
||
try { ev = JSON.parse(line); } catch (e) { return; }
|
||
if (o.onEvent) { try { o.onEvent(ev); } catch (e) {} }
|
||
|
||
if (ev.type === 'system' && ev.subtype === 'init') {
|
||
sessionId = ev.session_id || sessionId;
|
||
return;
|
||
}
|
||
|
||
// 文本增量:stream_event → content_block_delta → text_delta
|
||
if (ev.type === 'stream_event' && ev.event) {
|
||
const e2 = ev.event;
|
||
// 工具调用开始的边界(用于切掉调用前的过程话术)
|
||
if (e2.type === 'content_block_start' && e2.content_block &&
|
||
(e2.content_block.type === 'tool_use' || e2.content_block.name)) {
|
||
toolMarks.push(full.length);
|
||
return;
|
||
}
|
||
if (e2.type === 'content_block_delta' && e2.delta) {
|
||
if (e2.delta.type === 'text_delta' && e2.delta.text) {
|
||
const prev = full;
|
||
full += e2.delta.text;
|
||
// CLI 自身的报错也会走文本通道(如未认证时整段就是一句英文报错)。
|
||
// 一旦发现开头是已知报错样板 → 不再当作回答下发给前端。
|
||
if (isCliBoilerplate(full)) {
|
||
errText = errText || full;
|
||
return;
|
||
}
|
||
// 已误判过一次的话,本段的累积都作废
|
||
if (isCliBoilerplate(prev)) { full = ''; errText = errText || prev; return; }
|
||
sawDelta = true;
|
||
streamed += e2.delta.text;
|
||
if (o.onDelta) { try { o.onDelta(e2.delta.text); } catch (e) {} }
|
||
} else if (e2.delta.type === 'thinking_delta' && e2.delta.thinking && o.onThink) {
|
||
try { o.onThink(e2.delta.thinking); } catch (e) {}
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 兜底:未开启/未产生增量流时,整段文本在 assistant 消息里
|
||
if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {
|
||
// 工具调用边界(未走增量流时,tool_use 只会出现在 assistant 消息里)
|
||
for (const c of ev.message.content) {
|
||
if (c && c.type === 'tool_use') toolMarks.push(full.length);
|
||
}
|
||
if (!sawDelta) {
|
||
const t = ev.message.content
|
||
.filter((c) => c && c.type === 'text' && typeof c.text === 'string')
|
||
.map((c) => c.text).join('');
|
||
if (t && !full) {
|
||
if (isCliBoilerplate(t)) { errText = errText || t; return; }
|
||
full = t;
|
||
streamed += t;
|
||
if (o.onDelta) { try { o.onDelta(t); } catch (e) {} }
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 终态
|
||
if (ev.type === 'result') {
|
||
sessionId = ev.session_id || sessionId;
|
||
usage = ev.usage || null;
|
||
if (ev.is_error) {
|
||
// 错误态:正文是报错文本,不能当作回答。
|
||
// CLI 把真实原因放在 `errors[]`(也有 `result` / `_meta.errorMessage` 兜底)
|
||
const e = Array.isArray(ev.errors) && ev.errors.length
|
||
? ev.errors.join(' | ')
|
||
: (typeof ev.result === 'string' && ev.result ? ev.result
|
||
: (ev._meta && ev._meta['codebuddy.ai/errorMessage']) || '');
|
||
errText = errText || String(e || '');
|
||
// 错误态下已累积的文本是报错样板(如 "Authentication required..."),
|
||
// 不是回答 → 丢弃,避免被当成正文展示
|
||
full = '';
|
||
resultText = '';
|
||
} else if (typeof ev.result === 'string') {
|
||
resultText = ev.result;
|
||
}
|
||
return;
|
||
}
|
||
};
|
||
|
||
child.stdout.on('data', (chunk) => {
|
||
buf += chunk.toString('utf8');
|
||
let i;
|
||
while ((i = buf.indexOf('\n')) >= 0) {
|
||
handleLine(buf.slice(0, i));
|
||
buf = buf.slice(i + 1);
|
||
}
|
||
});
|
||
child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
|
||
|
||
child.on('error', (e) => {
|
||
clearTimeout(timer);
|
||
resolve({ ok: false, text: full, error: 'CLI 进程错误: ' + e.message });
|
||
});
|
||
|
||
child.on('close', (code) => {
|
||
clearTimeout(timer);
|
||
if (buf.trim()) handleLine(buf);
|
||
// 切掉「最后一次工具调用之前」的过程话术:那部分不是答案。
|
||
// (例:模型先输出 "I'll search for ... before responding." 再发起 WebSearch)
|
||
const cut = toolMarks.length ? toolMarks[toolMarks.length - 1] : 0;
|
||
const answer = (cut > 0 && cut < full.length) ? full.slice(cut).trim() : full;
|
||
const text = answer || resultText;
|
||
if (aborted) return resolve({ ok: false, text, streamed, error: 'CLI 执行超时或被取消', sessionId, usage });
|
||
// 事件里明确报了错(如未认证)→ 优先按错误返回
|
||
if (errText) {
|
||
const raw = errText;
|
||
return resolve({ ok: false, text, streamed, error: messageForCliError(raw) || raw, sessionId, usage, rawStderr: raw });
|
||
}
|
||
if (code !== 0 && !text) {
|
||
const msg = (stderr || '').trim().split('\n').filter(Boolean).slice(-3).join(' | ');
|
||
return resolve({ ok: false, text, streamed, error: messageForCliError(msg) || ('CLI 退出码 ' + code), sessionId, usage, rawStderr: msg });
|
||
}
|
||
resolve({ ok: true, text, streamed, sessionId, usage });
|
||
});
|
||
});
|
||
}
|
||
|
||
// 判断一段文本是否是 CLI 自身给的报错样板(而不是模型回答)
|
||
// 实测:未认证时,assistant 文本整段就是这句英文
|
||
function isCliBoilerplate(text) {
|
||
const t = String(text || '').trim();
|
||
if (!t) return false;
|
||
return /^Authentication (required|failed)\. Please use \/login/i.test(t)
|
||
|| /^Failed to authenticate/i.test(t)
|
||
|| /^Invalid API key/i.test(t)
|
||
|| /^Unauthorized\b/i.test(t);
|
||
}
|
||
|
||
// 把 CLI 的原始英文报错翻成可操作的中文提示
|
||
function messageForCliError(msg) {
|
||
if (!msg) return '';
|
||
if (/Authentication required/i.test(msg)) {
|
||
return 'CodeBuddy 未认证:请在 config.json 的 ai.cli.apiKey 填入 API Key,'
|
||
+ '或设置环境变量 CODEBUDDY_API_KEY(中国版还需 CODEBUDDY_INTERNET_ENVIRONMENT=internal)。'
|
||
+ '原始信息: ' + msg;
|
||
}
|
||
if (/EADDRINUSE/i.test(msg)) {
|
||
return 'CLI 端口冲突(SERVER__PORT 未被清除): ' + msg;
|
||
}
|
||
return msg;
|
||
}
|
||
|
||
module.exports = { runCli, resolveCli, buildChildEnv, resolveCredentials, defaultCredentialFile };
|