Files
mcn-short-video/project/短视频脚本创作/V1.0/mcn-workshop/cli-backend.js
T

434 lines
19 KiB
JavaScript
Raw Normal View History

// 短视频工作台 - 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 };