Files
mcn-short-video/project/短视频脚本创作/V1.0/mcn-workshop/cli-backend.js
T
maogeigei f77735b206 工作台:目录改名 mcn-workshop + 任务进度可见 + 会话归属配置化
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 分钟超时,用户零感知)
2026-10-08 13:09:06 +08:00

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