Initial commit
This commit is contained in:
+471
@@ -0,0 +1,471 @@
|
||||
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
|
||||
};
|
||||
Reference in New Issue
Block a user