160 lines
3.8 KiB
JavaScript
160 lines
3.8 KiB
JavaScript
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);
|
|
});
|