327 lines
13 KiB
JavaScript
327 lines
13 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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------- 环境构造
|
|||
|
|
|
|||
|
|
// 把「宿主 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 === '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';
|
|||
|
|
|
|||
|
|
// 【坑 3】凭据:独立进程必须自备
|
|||
|
|
const key = opts.apiKey || process.env.CODEBUDDY_API_KEY || '';
|
|||
|
|
const token = opts.authToken || process.env.CODEBUDDY_AUTH_TOKEN || '';
|
|||
|
|
if (token) env.CODEBUDDY_AUTH_TOKEN = token; // 优先级最高
|
|||
|
|
if (key) env.CODEBUDDY_API_KEY = key;
|
|||
|
|
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.internetEnvironment] internal / ioa / 留空
|
|||
|
|
* @param {string} [o.cliPath] 显式指定 CLI 路径
|
|||
|
|
* @param {number} [o.timeoutMs]
|
|||
|
|
* @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] : []),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
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 stderr = '';
|
|||
|
|
let sessionId = '';
|
|||
|
|
let usage = null;
|
|||
|
|
let resultText = '';
|
|||
|
|
let errText = '';
|
|||
|
|
let sawDelta = false;
|
|||
|
|
let buf = '';
|
|||
|
|
|
|||
|
|
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_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;
|
|||
|
|
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)) {
|
|||
|
|
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;
|
|||
|
|
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);
|
|||
|
|
const text = full || resultText;
|
|||
|
|
if (aborted) return resolve({ ok: false, text, error: 'CLI 执行超时或被取消', sessionId, usage });
|
|||
|
|
// 事件里明确报了错(如未认证)→ 优先按错误返回
|
|||
|
|
if (errText) {
|
|||
|
|
const raw = errText;
|
|||
|
|
return resolve({ ok: false, text, 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, error: messageForCliError(msg) || ('CLI 退出码 ' + code), sessionId, usage, rawStderr: msg });
|
|||
|
|
}
|
|||
|
|
resolve({ ok: true, text, 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 };
|