commit c7ffc45bc6c300496ccd3806103403a25d20b1f6 Author: zhh Date: Sun May 24 22:53:05 2026 +0900 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb594fd --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0283326 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..905e865 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# codex_backend + +这是一个codex反代后端 \ No newline at end of file diff --git a/codexClient.cjs b/codexClient.cjs new file mode 100644 index 0000000..d1bfc82 --- /dev/null +++ b/codexClient.cjs @@ -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 +}; diff --git a/codexClient.cjs.md b/codexClient.cjs.md new file mode 100644 index 0000000..f8ee235 --- /dev/null +++ b/codexClient.cjs.md @@ -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 路由调用。 diff --git a/main.cjs b/main.cjs new file mode 100644 index 0000000..8397f84 --- /dev/null +++ b/main.cjs @@ -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); +}); diff --git a/main.cjs.md b/main.cjs.md new file mode 100644 index 0000000..f6f2ba6 --- /dev/null +++ b/main.cjs.md @@ -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。 diff --git a/package.json b/package.json new file mode 100644 index 0000000..3e87818 --- /dev/null +++ b/package.json @@ -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" +}