Files
telegram-bot/src/engines/codex.js
T

230 lines
8.8 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.
'use strict';
// Codex 引擎:spawn `codex exec --json`,逐行解析 JSONL 事件。
// Windows 上 codex 是 .cmd shimnode codex.js 的包裝),直接解析出 codex.js
// 用 node 執行,避開 shell:true 的引號問題與 DEP0190 警告。
const { spawn, execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
let cachedCommand = null;
// 在 PATH 中尋找 codex shim,回推 @openai/codex/bin/codex.js 的實際位置
function findCodexJs() {
const dirs = (process.env.PATH || '').split(path.delimiter);
for (const dir of dirs) {
if (!dir) continue;
const shim = path.join(dir, process.platform === 'win32' ? 'codex.cmd' : 'codex');
if (!fs.existsSync(shim)) continue;
const candidates = [
path.join(dir, 'node_modules', '@openai', 'codex', 'bin', 'codex.js'),
// unix 下 shim 是 symlink,指向 lib/node_modules 下的實體
path.join(dir, '..', 'lib', 'node_modules', '@openai', 'codex', 'bin', 'codex.js'),
];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
}
// 最後手段:問 npm 全域 root
try {
const root = execSync('npm root -g', { encoding: 'utf-8', windowsHide: true }).trim();
const c = path.join(root, '@openai', 'codex', 'bin', 'codex.js');
if (fs.existsSync(c)) return c;
} catch { /* ignore */ }
return null;
}
// 回傳 { cmd, baseArgs, shell }:實際 spawn 的指令與前置參數
function resolveCodexCommand(config) {
if (cachedCommand) return cachedCommand;
const custom = config && config.codexPath;
if (custom) {
cachedCommand = custom.endsWith('.js')
? { cmd: process.execPath, baseArgs: [custom], shell: false }
: { cmd: custom, baseArgs: [], shell: false };
return cachedCommand;
}
if (process.platform === 'win32') {
const js = findCodexJs();
if (js) {
cachedCommand = { cmd: process.execPath, baseArgs: [js], shell: false };
return cachedCommand;
}
// 找不到實體時退回 shell(.cmd 需要 shell 才能執行)
cachedCommand = { cmd: 'codex', baseArgs: [], shell: true };
return cachedCommand;
}
cachedCommand = { cmd: 'codex', baseArgs: [], shell: false };
return cachedCommand;
}
function killTree(proc) {
if (process.platform === 'win32') {
// node shim 的子程序(codex.exe)不會隨父程序死掉,要整棵砍
try { execSync(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true, stdio: 'ignore' }); } catch { /* ignore */ }
} else {
try { proc.kill('SIGKILL'); } catch { /* ignore */ }
}
}
const PROGRESS_LABELS = {
command_execution: '⚙️ 執行指令',
file_change: '✏️ 修改檔案',
mcp_tool_call: '🔧 呼叫工具',
web_search: '🔎 搜尋網路',
reasoning: '🤔 思考中',
};
/**
* 執行一輪 Codex 對話。
* @param {object} opts
* @param {object} opts.config 載入後的設定
* @param {string} opts.prompt 使用者 prompt(走 stdin,任意內容都安全)
* @param {string|null} opts.threadId 既有會話 idnull 表示開新會話
* @param {string[]} [opts.images] 附圖檔案路徑
* @param {(text: string) => void} [opts.onProgress] 進度回呼
* @returns {Promise<{text: string, threadId: string|null, usage: object|null, model: string|null}>}
*/
function run({ config, prompt, threadId, images = [], onProgress }) {
return new Promise((resolve, reject) => {
const { cmd, baseArgs, shell } = resolveCodexCommand(config);
const args = [...baseArgs, 'exec'];
if (threadId) args.push('resume', threadId);
args.push('--json', '--skip-git-repo-check');
if (threadId) {
// resume 子指令不吃 -s / -m / --color,改走 -c 設定覆寫
args.push('-c', `sandbox_mode="${config.sandbox}"`);
if (config.model) args.push('-c', `model="${config.model}"`);
} else {
args.push('--color', 'never', '-s', config.sandbox);
if (config.model) args.push('-m', config.model);
}
if (config.reasoningEffort) args.push('-c', `model_reasoning_effort="${config.reasoningEffort}"`);
if (config.serviceTier) args.push('-c', `service_tier="${config.serviceTier}"`);
if (config.networkAccess) args.push('-c', 'sandbox_workspace_write.network_access=true');
for (const img of images) args.push('-i', img);
args.push('-'); // prompt 從 stdin 讀
const proc = spawn(cmd, args, {
cwd: config.workDir,
env: { ...process.env },
shell,
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe'],
});
proc.stdin.write(prompt);
proc.stdin.end();
let buffer = '';
let stderrTail = '';
let resultThreadId = threadId || null;
let usage = null;
let turnCompleted = false;
let turnFailedMessage = null;
// item 層級的 error 可能只是警告(例如「service tier 不支援,將省略」),turn 仍會正常完成;
// 只有在 turn 沒完成時才把它當成失敗原因
let itemErrorMessage = null;
const messages = [];
const timer = setTimeout(() => {
killTree(proc);
reject(new Error(`Codex 逾時(${config.timeoutMinutes} 分鐘)`));
}, config.timeoutMinutes * 60 * 1000);
const handleEvent = (event) => {
const item = event.item;
switch (event.type) {
case 'thread.started':
if (event.thread_id) resultThreadId = event.thread_id;
break;
case 'turn.completed':
turnCompleted = true;
usage = event.usage || null;
break;
case 'turn.failed':
turnFailedMessage = event.error?.message || 'turn.failed(未提供原因)';
break;
case 'error':
turnFailedMessage = event.message || event.error?.message || 'Codex 回報錯誤';
break;
case 'item.started':
case 'item.updated': {
if (!onProgress || !item) break;
const label = PROGRESS_LABELS[item.type];
if (!label) break;
let detail = '';
if (item.type === 'command_execution' && item.command) {
detail = item.command.length > 300 ? item.command.slice(0, 300) + '...' : item.command;
}
onProgress(`${label}...${detail ? '\n' + detail : ''}`);
break;
}
case 'item.completed': {
if (!item) break;
if (item.type === 'agent_message' && item.text) {
messages.push(item.text);
if (onProgress) {
const t = item.text.trim();
onProgress('💬 ' + (t.length > 600 ? t.slice(0, 600) + '...' : t));
}
} else if (item.type === 'error' && item.message) {
itemErrorMessage = item.message;
} else if (item.type === 'reasoning' && item.text && onProgress) {
const t = item.text.trim();
onProgress('🤔 ' + (t.length > 600 ? t.slice(0, 600) + '...' : t));
}
break;
}
default:
break;
}
};
proc.stdout.on('data', (chunk) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop(); // 最後一段可能被截斷,留到下一批
for (const line of lines) {
if (!line.trim()) continue;
try {
handleEvent(JSON.parse(line));
} catch { /* 非 JSON 行(log 雜訊)直接忽略 */ }
}
});
proc.stderr.on('data', (chunk) => {
stderrTail = (stderrTail + chunk.toString()).slice(-2000);
if (process.env.DEBUG) console.error('[codex stderr]', chunk.toString());
});
proc.on('close', (code) => {
clearTimeout(timer);
if (!turnFailedMessage && !turnCompleted && itemErrorMessage) turnFailedMessage = itemErrorMessage;
if (turnFailedMessage) {
const e = new Error(`Codex 執行失敗:${turnFailedMessage}`);
e.threadId = resultThreadId; // 會話可能已建立,讓呼叫端能 resume 重試
// 暫時性連線問題(OpenAI 端切斷串流等),呼叫端可重試
e.transient = /stream disconnected|websocket closed|Falling back from WebSockets|connection reset|ECONNRESET|socket hang up|timed out/i.test(turnFailedMessage);
return reject(e);
}
if (turnCompleted || messages.length > 0) {
return resolve({ text: messages.join('\n\n'), threadId: resultThreadId, usage, model: config.model || null });
}
const hint = stderrTail
.split('\n')
.filter((l) => l.trim() && !/^\d{4}-\d{2}-\d{2}T.*(ERROR|WARN) codex_models_manager/.test(l))
.slice(-5)
.join('\n');
reject(new Error(`codex 結束(exit code ${code}${hint ? '\n' + hint : ''}`));
});
proc.on('error', (err) => {
clearTimeout(timer);
reject(new Error(`無法啟動 codex${err.message}(請確認已安裝 @openai/codex 並在 PATH 中)`));
});
});
}
module.exports = { run, runCodex: run, resolveCodexCommand, findCodexJs };