472 lines
12 KiB
JavaScript
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
|
|
};
|