Initial commit
This commit is contained in:
+143
@@ -0,0 +1,143 @@
|
||||
<<<<<<< HEAD
|
||||
.idea/
|
||||
node-modules/
|
||||
=======
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
>>>>>>> origin/main
|
||||
@@ -0,0 +1,18 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 zhh
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
+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
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
# codexClient.cjs 说明
|
||||
|
||||
## 文件作用
|
||||
|
||||
这个文件是后端与本机 Codex CLI / `@openai/codex-sdk` 的适配层。它负责检查登录状态、启动登录、退出登录、构造 Codex 请求线程、执行普通回复和流式回复,并把这些能力导出给 Express 路由使用。
|
||||
|
||||
## 主要常量和状态
|
||||
|
||||
- `PROJECT_ROOT`:项目根目录。
|
||||
- `CODEX_WORKSPACE`:专门给 Codex SDK 使用的工作目录 `.codex-chat-workspace`。
|
||||
- `DEFAULT_MODEL`:来自环境变量 `CODEX_MODEL` 的默认模型。
|
||||
- `DEFAULT_REASONING_EFFORT`:来自环境变量 `CODEX_REASONING_EFFORT` 的默认推理强度。
|
||||
- `AUTH_STATUS_CACHE_MS`:登录状态缓存时间。
|
||||
- `sdkPromise`:缓存动态导入的 Codex SDK。
|
||||
- `authStatusCache`:缓存最近一次登录状态。
|
||||
- `loginState`:保存当前或最近一次登录命令的运行状态和日志。
|
||||
|
||||
文件加载时会创建 `CODEX_WORKSPACE` 目录。
|
||||
|
||||
## 主要函数
|
||||
|
||||
### `getCodexBin()`
|
||||
|
||||
查找 Codex CLI 可执行文件。优先使用项目内 `node_modules/.bin/codex`,找不到时回退到全局 `codex` 命令。
|
||||
|
||||
### `getCodexHomeHint()`
|
||||
|
||||
返回 Codex 本地配置目录提示。优先使用 `CODEX_HOME` 环境变量,否则使用用户目录下的 `.codex`。
|
||||
|
||||
### `appendLoginLine(chunk)`
|
||||
|
||||
把登录命令输出追加到 `loginState.lines`。它会跳过空行,并且只保留最近 120 行,避免日志无限增长。
|
||||
|
||||
### `runCodexCommand(args, options)`
|
||||
|
||||
执行 Codex CLI 命令的通用封装:
|
||||
|
||||
- 使用 `spawn` 启动命令。
|
||||
- 在 `CODEX_WORKSPACE` 下运行。
|
||||
- 收集 stdout 和 stderr。
|
||||
- 支持传入 stdin。
|
||||
- 支持超时,超时后终止子进程。
|
||||
- 最终返回退出码、信号、输出和错误对象。
|
||||
|
||||
它主要用于登录状态检查和退出登录。
|
||||
|
||||
### `loadCodexSdk()`
|
||||
|
||||
动态导入 `@openai/codex-sdk`,并用 `sdkPromise` 缓存导入结果,避免重复加载。
|
||||
|
||||
### `normalizeInput(input)`
|
||||
|
||||
清洗前端传入的消息数组:
|
||||
|
||||
- 只保留对象类型且有字符串 `role` 的项。
|
||||
- 将 `content` 转为字符串。
|
||||
- 去掉空内容消息。
|
||||
|
||||
### `roleTitle(role)`
|
||||
|
||||
把请求角色转换成写入提示词的标题:
|
||||
|
||||
- `developer` 或 `system` 显示为开发者指令。
|
||||
- `assistant` 显示为 assistant。
|
||||
- 其他角色显示为 user。
|
||||
|
||||
### `buildCodexPrompt(input)`
|
||||
|
||||
把结构化消息数组转换成单个 Codex prompt:
|
||||
|
||||
- 先标准化输入。
|
||||
- 将每条消息转换成 `### 角色标题` 加正文的文本块。
|
||||
- 在开头加入本地网页聊天工具的约束说明。
|
||||
- 要求模型只回复最后一条 user 消息,不混入其他会话上下文。
|
||||
|
||||
### `normalizeModel(model)`
|
||||
|
||||
合并请求体模型名和环境变量默认模型,并返回修剪后的字符串。
|
||||
|
||||
### `normalizeReasoningEffort(value)`
|
||||
|
||||
校验推理强度,只允许 `minimal`、`low`、`medium`、`high`、`xhigh`。非法值会回退到 `medium`。
|
||||
|
||||
### `createThreadOptions(body)`
|
||||
|
||||
创建 Codex SDK thread 运行选项:
|
||||
|
||||
- 使用只读沙箱。
|
||||
- 禁用审批。
|
||||
- 固定工作目录为 `CODEX_WORKSPACE`。
|
||||
- 跳过 Git 仓库检查。
|
||||
- 禁用网络和 Web 搜索。
|
||||
- 透传推理强度和可选模型。
|
||||
|
||||
这些设置降低本地聊天代理对用户文件和网络的影响范围。
|
||||
|
||||
### `createCodexClient()`
|
||||
|
||||
创建 Codex SDK 客户端,并关闭原始 agent reasoning 输出,让前端只展示面向用户的最终回答。
|
||||
|
||||
### `hasUsableAuth(status)`
|
||||
|
||||
判断 Codex 是否可用:既要安装 CLI,也要处于已登录状态。
|
||||
|
||||
### `getAuthStatus(options)`
|
||||
|
||||
检查 Codex 登录状态:
|
||||
|
||||
- 默认使用短期缓存,`force` 为真时强制刷新。
|
||||
- 执行 `codex login status`。
|
||||
- 根据退出码判断是否已登录。
|
||||
- 返回安装状态、登录状态、认证模式输出、提示消息、Codex home 和检查时间。
|
||||
|
||||
### `ensureLoggedIn()`
|
||||
|
||||
在执行聊天请求前校验 Codex 可用性。CLI 未安装时抛出 503 错误;未登录时抛出 401 错误。
|
||||
|
||||
### `startLogin(options)`
|
||||
|
||||
启动 Codex 登录流程:
|
||||
|
||||
- 如果已有登录进程在跑,直接返回当前登录状态。
|
||||
- 默认启动 `codex login`。
|
||||
- `deviceAuth` 为真时追加 `--device-auth`。
|
||||
- 保存进程 pid、开始时间、结束时间、退出码、信号和输出日志。
|
||||
- 登录进程结束后清空认证状态缓存。
|
||||
|
||||
### `getLoginState()`
|
||||
|
||||
返回当前登录状态快照,供前端轮询展示。
|
||||
|
||||
### `logoutCodex()`
|
||||
|
||||
执行 `codex logout`,清空认证状态缓存,并返回命令结果。
|
||||
|
||||
### `runCodexOnce(body, signal)`
|
||||
|
||||
执行一次性聊天请求:
|
||||
|
||||
- 先确认 Codex 已登录。
|
||||
- 用 `buildCodexPrompt` 生成 prompt。
|
||||
- prompt 为空时抛出 400 错误。
|
||||
- 创建 Codex client 和 thread。
|
||||
- 调用 `thread.run(prompt, { signal })`。
|
||||
- 返回最终文本、thread id 和 usage。
|
||||
|
||||
### `runCodexStream(body, signal, handlers)`
|
||||
|
||||
执行流式聊天请求:
|
||||
|
||||
- 校验登录和 prompt。
|
||||
- 调用 `thread.runStreamed(prompt, { signal })`。
|
||||
- 遍历 SDK 返回的事件流。
|
||||
- 对 agent message 计算增量文本,并调用 `handlers.onDelta(delta)`。
|
||||
- 记录 `turn.completed` 的 usage。
|
||||
- 遇到 SDK 错误事件时抛出后端错误。
|
||||
- 返回完整文本、thread id 和 usage。
|
||||
|
||||
## 导出接口
|
||||
|
||||
该文件导出:
|
||||
|
||||
- `getAuthStatus`
|
||||
- `getLoginState`
|
||||
- `logoutCodex`
|
||||
- `runCodexOnce`
|
||||
- `runCodexStream`
|
||||
- `startLogin`
|
||||
|
||||
这些函数由 `server/index.cjs` 的 API 路由调用。
|
||||
@@ -0,0 +1,159 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const {
|
||||
getAuthStatus,
|
||||
getLoginState,
|
||||
logoutCodex,
|
||||
runCodexOnce,
|
||||
runCodexStream,
|
||||
startLogin
|
||||
} = require('./codexClient.cjs');
|
||||
|
||||
const app = express();
|
||||
|
||||
const PORT = Number(process.env.PORT || 8787);
|
||||
const HOST = process.env.HOST || '127.0.0.1';
|
||||
|
||||
function isAllowedOrigin(origin) {
|
||||
if (!origin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
return ['localhost', '127.0.0.1', '::1'].includes(url.hostname);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
app.use(cors({
|
||||
origin: function (origin, callback) {
|
||||
callback(null, isAllowedOrigin(origin));
|
||||
}
|
||||
}));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
|
||||
function writeSse(res, eventName, data) {
|
||||
res.write('event: ' + eventName + '\n');
|
||||
res.write('data: ' + JSON.stringify(data) + '\n\n');
|
||||
}
|
||||
|
||||
function toHttpStatus(error) {
|
||||
const status = Number(error && (error.status || error.statusCode));
|
||||
if (Number.isFinite(status) && status >= 400 && status < 600) {
|
||||
return status;
|
||||
}
|
||||
return 500;
|
||||
}
|
||||
|
||||
function toErrorMessage(error) {
|
||||
if (!error) {
|
||||
return '未知错误';
|
||||
}
|
||||
|
||||
if (typeof error.message === 'string' && error.message.trim()) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
app.get('/api/health', function (_req, res) {
|
||||
res.json({ ok: true, provider: 'codex-sdk' });
|
||||
});
|
||||
|
||||
app.get('/api/codex/status', async function (_req, res) {
|
||||
try {
|
||||
const status = await getAuthStatus({ force: true });
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
res.status(toHttpStatus(error)).json({ error: toErrorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/codex/login-log', function (_req, res) {
|
||||
res.json(getLoginState());
|
||||
});
|
||||
|
||||
app.post('/api/codex/login', function (req, res) {
|
||||
const state = startLogin({
|
||||
deviceAuth: Boolean(req.body && req.body.deviceAuth)
|
||||
});
|
||||
|
||||
res.status(202).json(state);
|
||||
});
|
||||
|
||||
app.post('/api/codex/logout', async function (_req, res) {
|
||||
try {
|
||||
const result = await logoutCodex();
|
||||
res.status(result.ok ? 200 : 500).json(result);
|
||||
} catch (error) {
|
||||
res.status(toHttpStatus(error)).json({ error: toErrorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/chat', async function (req, res) {
|
||||
const controller = new AbortController();
|
||||
|
||||
// 只有客户端真正断开时才中止 Codex 进程,避免普通请求结束误判为取消。
|
||||
res.on('close', function () {
|
||||
if (!res.writableEnded) {
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runCodexOnce(req.body || {}, controller.signal);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(toHttpStatus(error)).json({ error: toErrorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/chat/stream', async function (req, res) {
|
||||
const controller = new AbortController();
|
||||
let closed = false;
|
||||
|
||||
// 流式响应中,浏览器停止请求时同步中止后端 Codex 运行。
|
||||
res.on('close', function () {
|
||||
if (!res.writableEnded) {
|
||||
closed = true;
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders && res.flushHeaders();
|
||||
|
||||
try {
|
||||
const result = await runCodexStream(req.body || {}, controller.signal, {
|
||||
onDelta: function (delta) {
|
||||
if (!closed) {
|
||||
writeSse(res, 'delta', { delta: delta });
|
||||
}
|
||||
},
|
||||
onError: function (message) {
|
||||
if (!closed) {
|
||||
writeSse(res, 'error', { error: message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!closed) {
|
||||
writeSse(res, 'done', result);
|
||||
res.end();
|
||||
}
|
||||
} catch (error) {
|
||||
if (!closed) {
|
||||
writeSse(res, 'error', { error: toErrorMessage(error) });
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, HOST, function () {
|
||||
console.log('Local Codex SDK proxy running at http://' + HOST + ':' + PORT);
|
||||
});
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
# index.cjs 说明
|
||||
|
||||
## 文件作用
|
||||
|
||||
这个文件启动本地 Express 服务,为前端提供 Codex 登录管理、普通聊天和流式聊天 API。它把 HTTP 请求转换成对 `codexClient.cjs` 的函数调用,并负责错误转换、CORS、JSON 解析和 SSE 输出。
|
||||
|
||||
## 服务配置
|
||||
|
||||
- `PORT`:默认 `8787`,可通过环境变量 `PORT` 覆盖。
|
||||
- `HOST`:默认 `127.0.0.1`,可通过环境变量 `HOST` 覆盖。
|
||||
- 使用 `cors` 只允许本地来源。
|
||||
- 使用 `express.json({ limit: '2mb' })` 解析 JSON 请求体。
|
||||
|
||||
## 主要函数
|
||||
|
||||
### `isAllowedOrigin(origin)`
|
||||
|
||||
判断请求来源是否允许:
|
||||
|
||||
- 没有 `origin` 时允许,方便非浏览器或同源请求。
|
||||
- 只允许 hostname 为 `localhost`、`127.0.0.1`、`::1` 的来源。
|
||||
- URL 解析失败时拒绝。
|
||||
|
||||
### `writeSse(res, eventName, data)`
|
||||
|
||||
向响应写入一条 SSE 事件:
|
||||
|
||||
- 写入 `event: 事件名`。
|
||||
- 写入 JSON 序列化后的 `data:`。
|
||||
- 用空行结束事件。
|
||||
|
||||
### `toHttpStatus(error)`
|
||||
|
||||
从错误对象中读取 `status` 或 `statusCode`。如果是合法 4xx/5xx 状态码就返回它,否则返回 500。
|
||||
|
||||
### `toErrorMessage(error)`
|
||||
|
||||
把错误对象转换为可返回给前端的字符串。优先使用 `error.message`,没有错误时返回未知错误文案。
|
||||
|
||||
## API 路由
|
||||
|
||||
### `GET /api/health`
|
||||
|
||||
健康检查接口,返回 `{ ok: true, provider: 'codex-sdk' }`。
|
||||
|
||||
### `GET /api/codex/status`
|
||||
|
||||
强制刷新 Codex 登录状态,调用 `getAuthStatus({ force: true })`。失败时返回标准错误 JSON。
|
||||
|
||||
### `GET /api/codex/login-log`
|
||||
|
||||
返回当前或最近一次 Codex 登录进程状态,数据来自 `getLoginState()`。
|
||||
|
||||
### `POST /api/codex/login`
|
||||
|
||||
启动 Codex 登录流程。请求体中的 `deviceAuth` 控制是否使用设备码登录。接口返回 202 和登录状态。
|
||||
|
||||
### `POST /api/codex/logout`
|
||||
|
||||
执行 Codex 退出登录。命令成功时返回 200,命令失败时返回 500。
|
||||
|
||||
### `POST /api/chat`
|
||||
|
||||
普通非流式聊天接口:
|
||||
|
||||
- 为请求创建 `AbortController`。
|
||||
- 监听响应 `close`,如果客户端提前断开则中止 Codex 运行。
|
||||
- 调用 `runCodexOnce(req.body, controller.signal)`。
|
||||
- 成功时返回 JSON 结果,失败时返回错误 JSON。
|
||||
|
||||
### `POST /api/chat/stream`
|
||||
|
||||
流式聊天接口:
|
||||
|
||||
- 创建 `AbortController` 并监听客户端断开。
|
||||
- 设置 `text/event-stream`、禁用缓存和保持连接。
|
||||
- 调用 `runCodexStream`。
|
||||
- `onDelta` 时写入 `delta` SSE 事件。
|
||||
- `onError` 时写入 `error` SSE 事件。
|
||||
- 正常完成时写入 `done` SSE 事件并结束响应。
|
||||
- 异常时写入 `error` 事件并结束响应。
|
||||
|
||||
## 依赖关系
|
||||
|
||||
该文件从 `codexClient.cjs` 引入 Codex 相关能力。前端 `src/utils/api.js` 会调用这里暴露的 API。
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "codex_backend",
|
||||
"version": "1.0.0",
|
||||
"description": "a backend server of codex api proxy",
|
||||
"main": "main.cjs",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "zhh",
|
||||
"license": "MIT"
|
||||
}
|
||||
Reference in New Issue
Block a user