From c7ffc45bc6c300496ccd3806103403a25d20b1f6 Mon Sep 17 00:00:00 2001 From: zhh Date: Sun, 24 May 2026 22:53:05 +0900 Subject: [PATCH] Initial commit --- .gitignore | 143 ++++++++++++++ LICENSE | 18 ++ README.md | 3 + codexClient.cjs | 471 +++++++++++++++++++++++++++++++++++++++++++++ codexClient.cjs.md | 170 ++++++++++++++++ main.cjs | 159 +++++++++++++++ main.cjs.md | 85 ++++++++ package.json | 11 ++ 8 files changed, 1060 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 codexClient.cjs create mode 100644 codexClient.cjs.md create mode 100644 main.cjs create mode 100644 main.cjs.md create mode 100644 package.json 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" +}