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