const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawn } = require('child_process'); const PROJECT_ROOT = path.resolve(__dirname, '..'); const CODEX_WORKSPACE = path.join(PROJECT_ROOT, '.codex-chat-workspace'); const DEFAULT_MODEL = process.env.CODEX_MODEL || ''; const DEFAULT_REASONING_EFFORT = process.env.CODEX_REASONING_EFFORT || 'medium'; const AUTH_STATUS_CACHE_MS = 15000; fs.mkdirSync(CODEX_WORKSPACE, { recursive: true }); let sdkPromise = null; let authStatusCache = { checkedAt: 0, value: null }; const loginState = { running: false, pid: null, startedAt: null, endedAt: null, exitCode: null, signal: null, lines: [] }; function getCodexBin() { const binName = process.platform === 'win32' ? 'codex.cmd' : 'codex'; const localBin = path.join(PROJECT_ROOT, 'node_modules', '.bin', binName); if (fs.existsSync(localBin)) { return localBin; } // 兜底使用全局安装的 codex;推荐仍通过 npm install 使用项目内版本。 return binName; } function getCodexHomeHint() { return process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); } function appendLoginLine(chunk) { const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk || ''); text.split(/\r?\n/).forEach(function (line) { if (!line.trim()) { return; } loginState.lines.push(line); }); if (loginState.lines.length > 120) { loginState.lines = loginState.lines.slice(-120); } } function runCodexCommand(args, options) { const finalOptions = options || {}; const timeoutMs = Number(finalOptions.timeoutMs || 15000); const input = finalOptions.input || ''; return new Promise(function (resolve) { let child; let done = false; let stdout = ''; let stderr = ''; function finish(result) { if (done) { return; } done = true; clearTimeout(timer); resolve({ code: Number.isFinite(Number(result.code)) ? Number(result.code) : null, signal: result.signal || null, stdout: stdout.trim(), stderr: stderr.trim(), error: result.error || null }); } const timer = setTimeout(function () { if (child && !child.killed) { child.kill('SIGTERM'); } finish({ code: null, signal: 'timeout', error: new Error('Codex 命令执行超时。') }); }, timeoutMs); try { child = spawn(getCodexBin(), args, { cwd: CODEX_WORKSPACE, env: process.env, shell: process.platform === 'win32' }); } catch (error) { finish({ code: null, signal: null, error: error }); return; } child.stdout && child.stdout.on('data', function (data) { stdout += data.toString('utf8'); }); child.stderr && child.stderr.on('data', function (data) { stderr += data.toString('utf8'); }); child.once('error', function (error) { finish({ code: null, signal: null, error: error }); }); child.once('exit', function (code, signal) { finish({ code: code, signal: signal }); }); if (input && child.stdin) { child.stdin.write(input); } child.stdin && child.stdin.end(); }); } async function loadCodexSdk() { if (!sdkPromise) { sdkPromise = import('@openai/codex-sdk'); } return sdkPromise; } function normalizeInput(input) { if (!Array.isArray(input)) { return []; } return input .filter(function (item) { return item && typeof item === 'object' && typeof item.role === 'string'; }) .map(function (item) { return { role: item.role, content: String(item.content || '') }; }) .filter(function (item) { return item.content.trim().length > 0; }); } function roleTitle(role) { if (role === 'developer' || role === 'system') { return '开发者指令'; } if (role === 'assistant') { return 'assistant'; } return 'user'; } function buildCodexPrompt(input) { const normalized = normalizeInput(input); if (normalized.length === 0) { return ''; } const blocks = normalized.map(function (item) { return '### ' + roleTitle(item.role) + '\n' + item.content.trim(); }); return [ '你现在是一个本地网页聊天工具背后的 AI 助手。', '下面是当前单个对话序列的上下文,包含开发者指令、摘要、历史消息和最后一条用户消息。', '请只回复最后一条 user 消息;不要复述角色标签;不要读取或混入其他对话序列。', '', blocks.join('\n\n') ].join('\n'); } function normalizeModel(model) { return String(model || DEFAULT_MODEL || '').trim(); } function normalizeReasoningEffort(value) { const allowed = ['minimal', 'low', 'medium', 'high', 'xhigh']; const normalized = String(value || DEFAULT_REASONING_EFFORT || 'medium').trim(); return allowed.includes(normalized) ? normalized : 'medium'; } function createThreadOptions(body) { const model = normalizeModel(body && body.model); return { // 作为聊天反代时,使用只读沙箱并禁用网络与 Web 搜索,降低本地暴露面。 sandboxMode: 'read-only', approvalPolicy: 'never', workingDirectory: CODEX_WORKSPACE, skipGitRepoCheck: true, networkAccessEnabled: false, webSearchMode: 'disabled', modelReasoningEffort: normalizeReasoningEffort(body && body.modelReasoningEffort), ...(model ? { model: model } : {}) }; } function createCodexClient() { return loadCodexSdk().then(function (mod) { const Codex = mod.Codex; return new Codex({ config: { // 不启用原始推理输出;前端只展示最终面向用户的回答。 show_raw_agent_reasoning: false } }); }); } function hasUsableAuth(status) { return status && status.installed && status.loggedIn; } async function getAuthStatus(options) { const finalOptions = options || {}; const now = Date.now(); if (!finalOptions.force && authStatusCache.value && now - authStatusCache.checkedAt < AUTH_STATUS_CACHE_MS) { return authStatusCache.value; } const result = await runCodexCommand(['login', 'status'], { timeoutMs: 12000 }); const installed = !result.error || result.error.code !== 'ENOENT'; const loggedIn = result.code === 0; const output = result.stdout || result.stderr || ''; const status = { installed: installed, loggedIn: loggedIn, authMode: loggedIn ? output.replace(/\s+/g, ' ').trim() : '', message: loggedIn ? output : (result.stderr || result.stdout || (result.error && result.error.message) || ''), codexHome: getCodexHomeHint(), checkedAt: new Date().toISOString() }; authStatusCache = { checkedAt: now, value: status }; return status; } async function ensureLoggedIn() { const status = await getAuthStatus({ force: false }); if (!status.installed) { const error = new Error('未找到 Codex CLI。请先运行 npm install,或全局安装 @openai/codex。'); error.statusCode = 503; throw error; } if (!hasUsableAuth(status)) { const error = new Error('Codex 尚未登录。请在设置页点击“打开官方 Codex 登录”或“设备码登录”。'); error.statusCode = 401; throw error; } } function startLogin(options) { const finalOptions = options || {}; if (loginState.running) { return getLoginState(); } const args = ['login']; if (finalOptions.deviceAuth) { args.push('--device-auth'); } loginState.running = true; loginState.pid = null; loginState.startedAt = new Date().toISOString(); loginState.endedAt = null; loginState.exitCode = null; loginState.signal = null; loginState.lines = [ '已启动 Codex 官方登录流程。', finalOptions.deviceAuth ? '当前模式:设备码登录。' : '当前模式:浏览器 OAuth 登录。' ]; let child; try { child = spawn(getCodexBin(), args, { cwd: CODEX_WORKSPACE, env: process.env, shell: process.platform === 'win32' }); } catch (error) { loginState.running = false; loginState.endedAt = new Date().toISOString(); loginState.exitCode = -1; appendLoginLine(error.message); return getLoginState(); } loginState.pid = child.pid || null; child.stdout && child.stdout.on('data', appendLoginLine); child.stderr && child.stderr.on('data', appendLoginLine); child.once('error', function (error) { loginState.running = false; loginState.endedAt = new Date().toISOString(); loginState.exitCode = -1; appendLoginLine(error.message); }); child.once('exit', function (code, signal) { loginState.running = false; loginState.endedAt = new Date().toISOString(); loginState.exitCode = Number.isFinite(Number(code)) ? Number(code) : null; loginState.signal = signal || null; authStatusCache.checkedAt = 0; appendLoginLine('Codex 登录命令已退出,退出码:' + String(loginState.exitCode)); }); child.stdin && child.stdin.end(); return getLoginState(); } function getLoginState() { return { running: loginState.running, pid: loginState.pid, startedAt: loginState.startedAt, endedAt: loginState.endedAt, exitCode: loginState.exitCode, signal: loginState.signal, log: loginState.lines.join('\n') }; } async function logoutCodex() { const result = await runCodexCommand(['logout'], { timeoutMs: 15000 }); authStatusCache.checkedAt = 0; return { ok: result.code === 0, stdout: result.stdout, stderr: result.stderr, error: result.error ? result.error.message : '' }; } async function runCodexOnce(body, signal) { await ensureLoggedIn(); const prompt = buildCodexPrompt(body.input || []); if (!prompt.trim()) { const error = new Error('缺少有效的对话输入。'); error.statusCode = 400; throw error; } const client = await createCodexClient(); const thread = client.startThread(createThreadOptions(body || {})); const result = await thread.run(prompt, { signal: signal }); return { text: result.finalResponse || '', threadId: thread.id || null, usage: result.usage || null }; } async function runCodexStream(body, signal, handlers) { await ensureLoggedIn(); const prompt = buildCodexPrompt(body.input || []); if (!prompt.trim()) { const error = new Error('缺少有效的对话输入。'); error.statusCode = 400; throw error; } const finalHandlers = handlers || {}; const client = await createCodexClient(); const thread = client.startThread(createThreadOptions(body || {})); const streamed = await thread.runStreamed(prompt, { signal: signal }); let fullText = ''; let usage = null; for await (const event of streamed.events) { if (event.type === 'item.started' || event.type === 'item.updated' || event.type === 'item.completed') { const item = event.item || {}; if (item.type === 'agent_message') { const nextText = String(item.text || ''); let delta = ''; if (nextText.startsWith(fullText)) { delta = nextText.slice(fullText.length); } else if (event.type === 'item.completed' && !fullText) { delta = nextText; } fullText = nextText; if (delta) { finalHandlers.onDelta && finalHandlers.onDelta(delta); } } if (item.type === 'error') { finalHandlers.onError && finalHandlers.onError(item.message || 'Codex 返回了错误。'); } } if (event.type === 'turn.completed') { usage = event.usage || null; } if (event.type === 'turn.failed') { const errorMessage = event.error && event.error.message ? event.error.message : 'Codex 流式响应失败。'; const error = new Error(errorMessage); error.statusCode = 500; throw error; } if (event.type === 'error') { const error = new Error(event.message || 'Codex 流式响应失败。'); error.statusCode = 500; throw error; } } return { text: fullText, threadId: thread.id || null, usage: usage }; } module.exports = { getAuthStatus, getLoginState, logoutCodex, runCodexOnce, runCodexStream, startLogin };