Files
2026-05-24 22:53:05 +09:00

472 lines
12 KiB
JavaScript

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
};