mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
refactor: 重构服务器部分并整理目录和注释
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @fileoverview Xvfb 和 VNC 显示参数处理模块(仅 Linux)
|
||||
* @description 处理 -xvfb 和 -vnc 命令行参数,启动虚拟显示器和 VNC 服务器
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import os from 'os';
|
||||
import net from 'net';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* 检查命令是否存在
|
||||
* @param {string} cmd - 命令名称
|
||||
* @returns {boolean} 命令是否存在
|
||||
*/
|
||||
function checkCommand(cmd) {
|
||||
const result = spawnSync('which', [cmd], { encoding: 'utf8' });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查端口是否可用
|
||||
* @param {number} port - 端口号
|
||||
* @returns {Promise<boolean>} 端口是否可用
|
||||
*/
|
||||
function isPortAvailable(port) {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
|
||||
server.once('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
server.once('listening', () => {
|
||||
server.close();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
server.listen(port);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找可用的 VNC 端口
|
||||
* @param {number} [startPort=5900] - 起始端口
|
||||
* @param {number} [maxAttempts=10] - 最大尝试次数
|
||||
* @returns {Promise<number|null>} 可用端口号,或 null 表示未找到
|
||||
*/
|
||||
async function findAvailableVncPort(startPort = 5900, maxAttempts = 10) {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const port = startPort + i;
|
||||
if (await isPortAvailable(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 VNC 服务器
|
||||
* @param {string} display - 显示器编号(如 ':99')
|
||||
* @returns {Promise<import('child_process').ChildProcess>} VNC 进程
|
||||
*/
|
||||
async function startVncServer(display) {
|
||||
if (!checkCommand('x11vnc')) {
|
||||
logger.error('服务器', '未找到 x11vnc 命令');
|
||||
logger.error('服务器', '请先安装 x11vnc:');
|
||||
logger.error('服务器', ' - Ubuntu/Debian: sudo apt install x11vnc');
|
||||
logger.error('服务器', ' - CentOS/RHEL: sudo dnf install x11vnc');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info('服务器', '正在查找可用的 VNC 端口...');
|
||||
const vncPort = await findAvailableVncPort(5900, 10);
|
||||
|
||||
if (!vncPort) {
|
||||
logger.error('服务器', '无法找到可用的 VNC 端口 (已尝试 5900-5909)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info('服务器', `正在启动 VNC 服务器 (端口 ${vncPort})...`);
|
||||
|
||||
const vncProcess = spawn('x11vnc', [
|
||||
'-display', display,
|
||||
'-rfbport', vncPort.toString(),
|
||||
'-localhost',
|
||||
'-nopw',
|
||||
'-once',
|
||||
'-noxdamage',
|
||||
'-ncache', '10',
|
||||
'-forever'
|
||||
], {
|
||||
stdio: 'ignore',
|
||||
detached: false
|
||||
});
|
||||
|
||||
vncProcess.on('error', (err) => {
|
||||
logger.error('服务器', 'VNC 启动失败', { error: err.message });
|
||||
});
|
||||
|
||||
logger.info('服务器', 'VNC 服务器已成功启动');
|
||||
logger.warn('服务器', `VNC 连接端口: ${vncPort}`);
|
||||
|
||||
return vncProcess;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Xvfb 中重启进程
|
||||
* @param {string[]} args - 当前命令行参数
|
||||
*/
|
||||
function restartInXvfb(args) {
|
||||
logger.info('服务器', '正在启动 Xvfb 虚拟显示器...');
|
||||
|
||||
// 构建新的参数列表(移除 -xvfb,保留其他参数)
|
||||
const newArgs = args.filter(arg => arg !== '-xvfb');
|
||||
|
||||
const xvfbArgs = [
|
||||
'--server-num=99',
|
||||
'--server-args=-ac -screen 0 1366x768x24',
|
||||
'env',
|
||||
'XVFB_RUNNING=true',
|
||||
'DISPLAY=:99',
|
||||
process.argv[0],
|
||||
process.argv[1],
|
||||
...newArgs
|
||||
];
|
||||
|
||||
const xvfbProcess = spawn('xvfb-run', xvfbArgs, {
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
xvfbProcess.on('error', (err) => {
|
||||
logger.error('服务器', 'Xvfb 启动失败', { error: err.message });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
xvfbProcess.on('exit', (code) => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
|
||||
// 处理父进程退出信号
|
||||
process.on('SIGINT', () => {
|
||||
xvfbProcess.kill('SIGTERM');
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
xvfbProcess.kill('SIGTERM');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Xvfb 和 VNC 启动参数
|
||||
* @returns {Promise<'XVFB_REDIRECT'|undefined>} 如果需要重定向到 Xvfb 则返回 'XVFB_REDIRECT'
|
||||
*/
|
||||
export async function handleDisplayParams() {
|
||||
const args = process.argv.slice(2);
|
||||
const hasXvfb = args.includes('-xvfb');
|
||||
const hasVnc = args.includes('-vnc');
|
||||
const isInXvfb = process.env.XVFB_RUNNING === 'true';
|
||||
|
||||
// -vnc 必须和 -xvfb 并用(但如果已在 Xvfb 中运行则允许)
|
||||
if (hasVnc && !hasXvfb && !isInXvfb) {
|
||||
logger.error('服务器', '-vnc 参数必须和 -xvfb 参数一起使用');
|
||||
logger.error('服务器', '正确用法: node server.js -xvfb -vnc');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 非 Linux 系统检查
|
||||
if ((hasXvfb || hasVnc) && os.platform() !== 'linux') {
|
||||
logger.warn('服务器', '忽略参数: -xvfb 和 -vnc 参数仅在 Linux 系统上支持');
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理 -xvfb 参数
|
||||
if ((hasXvfb || isInXvfb) && os.platform() === 'linux') {
|
||||
// 检查 xvfb-run 是否存在(仅在首次启动时需要)
|
||||
if (hasXvfb && !isInXvfb) {
|
||||
if (!checkCommand('xvfb-run')) {
|
||||
logger.error('服务器', '未找到 xvfb-run 命令');
|
||||
logger.error('服务器', '请先安装 Xvfb:');
|
||||
logger.error('服务器', ' - Ubuntu/Debian: sudo apt install xvfb');
|
||||
logger.error('服务器', ' - CentOS/RHEL: sudo dnf install xorg-x11-server-Xvfb');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 已在 Xvfb 中运行
|
||||
if (isInXvfb) {
|
||||
logger.info('服务器', '已在 Xvfb 虚拟显示器中运行', { display: process.env.DISPLAY || ':99' });
|
||||
|
||||
// 处理 VNC
|
||||
if (hasVnc) {
|
||||
const display = process.env.DISPLAY || ':99';
|
||||
const vncProcess = await startVncServer(display);
|
||||
|
||||
// 处理进程退出信号
|
||||
process.on('SIGINT', () => {
|
||||
vncProcess.kill('SIGTERM');
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
vncProcess.kill('SIGTERM');
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 需要在 Xvfb 中重启
|
||||
restartInXvfb(args);
|
||||
return 'XVFB_REDIRECT';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @fileoverview 错误码表与中文消息映射模块
|
||||
* @description 统一定义服务器错误码及其对应的中文消息和 HTTP 状态码
|
||||
*/
|
||||
|
||||
/**
|
||||
* 错误码枚举
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const ERROR_CODES = {
|
||||
/** 未授权(Token 无效或缺失) */
|
||||
UNAUTHORIZED: 'UNAUTHORIZED',
|
||||
/** 浏览器未初始化 */
|
||||
BROWSER_NOT_INITIALIZED: 'BROWSER_NOT_INITIALIZED',
|
||||
/** 服务器繁忙(队列已满) */
|
||||
SERVER_BUSY: 'SERVER_BUSY',
|
||||
/** 请求参数缺少 messages */
|
||||
NO_MESSAGES: 'NO_MESSAGES',
|
||||
/** messages 中缺少 role=user 的消息 */
|
||||
NO_USER_MESSAGES: 'NO_USER_MESSAGES',
|
||||
/** 图片数量超过限制 */
|
||||
TOO_MANY_IMAGES: 'TOO_MANY_IMAGES',
|
||||
/** 模型无效/后端不支持 */
|
||||
INVALID_MODEL: 'INVALID_MODEL',
|
||||
/** 该模型需要参考图 */
|
||||
IMAGE_REQUIRED: 'IMAGE_REQUIRED',
|
||||
/** 该模型不支持图片输入 */
|
||||
IMAGE_FORBIDDEN: 'IMAGE_FORBIDDEN',
|
||||
/** 触发人机验证(reCAPTCHA) */
|
||||
RECAPTCHA: 'RECAPTCHA',
|
||||
/** 服务器内部错误 */
|
||||
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
||||
};
|
||||
|
||||
/**
|
||||
* 错误详情映射表
|
||||
* @type {Record<string, {message: string, status: number}>}
|
||||
*/
|
||||
const ERROR_DETAILS = {
|
||||
[ERROR_CODES.UNAUTHORIZED]: {
|
||||
message: '未授权(Token 无效或缺失)',
|
||||
status: 401,
|
||||
},
|
||||
[ERROR_CODES.BROWSER_NOT_INITIALIZED]: {
|
||||
message: '浏览器未初始化',
|
||||
status: 503,
|
||||
},
|
||||
[ERROR_CODES.SERVER_BUSY]: {
|
||||
message: '服务器繁忙(队列已满)',
|
||||
status: 429,
|
||||
},
|
||||
[ERROR_CODES.NO_MESSAGES]: {
|
||||
message: '请求参数缺少 messages',
|
||||
status: 400,
|
||||
},
|
||||
[ERROR_CODES.NO_USER_MESSAGES]: {
|
||||
message: 'messages 中缺少 role=user 的消息',
|
||||
status: 400,
|
||||
},
|
||||
[ERROR_CODES.TOO_MANY_IMAGES]: {
|
||||
message: '图片数量超过限制',
|
||||
status: 400,
|
||||
},
|
||||
[ERROR_CODES.INVALID_MODEL]: {
|
||||
message: '模型无效/后端不支持',
|
||||
status: 400,
|
||||
},
|
||||
[ERROR_CODES.IMAGE_REQUIRED]: {
|
||||
message: '该模型需要参考图',
|
||||
status: 400,
|
||||
},
|
||||
[ERROR_CODES.IMAGE_FORBIDDEN]: {
|
||||
message: '该模型不支持图片输入',
|
||||
status: 400,
|
||||
},
|
||||
[ERROR_CODES.RECAPTCHA]: {
|
||||
message: '触发人机验证(reCAPTCHA)',
|
||||
status: 500,
|
||||
},
|
||||
[ERROR_CODES.INTERNAL_ERROR]: {
|
||||
message: '服务器内部错误',
|
||||
status: 500,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取错误消息
|
||||
* @param {string} code - 错误码
|
||||
* @returns {string} 中文错误消息
|
||||
*/
|
||||
export function getErrorMessage(code) {
|
||||
return ERROR_DETAILS[code]?.message || '未知错误';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误对应的 HTTP 状态码
|
||||
* @param {string} code - 错误码
|
||||
* @returns {number} HTTP 状态码
|
||||
*/
|
||||
export function getErrorStatus(code) {
|
||||
return ERROR_DETAILS[code]?.status || 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整的错误详情
|
||||
* @param {string} code - 错误码
|
||||
* @returns {{message: string, status: number}} 错误详情
|
||||
*/
|
||||
export function getErrorDetails(code) {
|
||||
return ERROR_DETAILS[code] || { message: '未知错误', status: 500 };
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @fileoverview 统一响应写出模块
|
||||
* @description 封装 JSON、SSE 响应和错误响应的统一处理函数
|
||||
*/
|
||||
|
||||
import { getErrorDetails } from '../errors.js';
|
||||
|
||||
/**
|
||||
* 发送 JSON 响应
|
||||
* @param {import('http').ServerResponse} res - HTTP 响应对象
|
||||
* @param {number} status - HTTP 状态码
|
||||
* @param {object} payload - 响应数据
|
||||
*/
|
||||
export function sendJson(res, status, payload) {
|
||||
if (res.writableEnded) return;
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 SSE 事件
|
||||
* @param {import('http').ServerResponse} res - HTTP 响应对象
|
||||
* @param {object} payload - 事件数据
|
||||
*/
|
||||
export function sendSse(res, payload) {
|
||||
if (res.writableEnded) return;
|
||||
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 SSE 结束标记
|
||||
* @param {import('http').ServerResponse} res - HTTP 响应对象
|
||||
*/
|
||||
export function sendSseDone(res) {
|
||||
if (res.writableEnded) return;
|
||||
res.write(`data: [DONE]\n\n`);
|
||||
res.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 SSE 心跳包
|
||||
* @param {import('http').ServerResponse} res - HTTP 响应对象
|
||||
* @param {string} mode - 心跳模式 ('comment' | 'content')
|
||||
* @param {string} [modelName] - 模型名称(content 模式需要)
|
||||
*/
|
||||
export function sendHeartbeat(res, mode, modelName) {
|
||||
if (res.writableEnded) return;
|
||||
|
||||
if (mode === 'comment') {
|
||||
res.write(`:keepalive\n\n`);
|
||||
} else {
|
||||
// content 模式:发送空 delta
|
||||
const chunk = {
|
||||
id: 'chatcmpl-' + Date.now(),
|
||||
object: 'chat.completion.chunk',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelName || 'default-model',
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: '' },
|
||||
finish_reason: null
|
||||
}]
|
||||
};
|
||||
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送统一 API 错误响应
|
||||
* @param {import('http').ServerResponse} res - HTTP 响应对象
|
||||
* @param {object} options - 错误选项
|
||||
* @param {string} [options.code] - 错误码(使用 ERROR_CODES 枚举)
|
||||
* @param {string} [options.error] - 自定义错误消息(如提供则覆盖 code 对应的消息)
|
||||
* @param {number} [options.status] - 自定义 HTTP 状态码
|
||||
* @param {boolean} [options.isStreaming=false] - 是否为流式响应
|
||||
* @param {string} [options.raw] - 原始错误信息(用于调试)
|
||||
*/
|
||||
export function sendApiError(res, options) {
|
||||
const { code, error, status, isStreaming = false, raw } = options;
|
||||
|
||||
// 获取错误详情
|
||||
const details = code ? getErrorDetails(code) : null;
|
||||
const errorMessage = error || (details ? details.message : '未知错误');
|
||||
const httpStatus = status || (details ? details.status : 500);
|
||||
|
||||
// 构造错误响应体
|
||||
const payload = {
|
||||
error: errorMessage,
|
||||
code: code || 'INTERNAL_ERROR',
|
||||
};
|
||||
if (raw) {
|
||||
payload.raw = raw;
|
||||
}
|
||||
|
||||
if (isStreaming) {
|
||||
// 流式响应
|
||||
sendSse(res, payload);
|
||||
sendSseDone(res);
|
||||
} else {
|
||||
// 非流式响应
|
||||
sendJson(res, httpStatus, payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 OpenAI 格式的聊天完成响应(非流式)
|
||||
* @param {string} content - 响应内容
|
||||
* @param {string} [modelName] - 模型名称
|
||||
* @returns {object} OpenAI 格式的响应对象
|
||||
*/
|
||||
export function buildChatCompletion(content, modelName) {
|
||||
return {
|
||||
id: 'chatcmpl-' + Date.now(),
|
||||
object: 'chat.completion',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelName || 'default-model',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: content
|
||||
},
|
||||
finish_reason: 'stop'
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 OpenAI 格式的流式聊天完成响应块
|
||||
* @param {string} content - 响应内容
|
||||
* @param {string} [modelName] - 模型名称
|
||||
* @param {string|null} [finishReason='stop'] - 完成原因
|
||||
* @returns {object} OpenAI 格式的流式响应块
|
||||
*/
|
||||
export function buildChatCompletionChunk(content, modelName, finishReason = 'stop') {
|
||||
return {
|
||||
id: 'chatcmpl-' + Date.now(),
|
||||
object: 'chat.completion.chunk',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelName || 'default-model',
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content },
|
||||
finish_reason: finishReason
|
||||
}]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* @fileoverview HTTP 路由分发模块
|
||||
* @description 处理 API 路由分发和请求鉴权
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { ERROR_CODES } from '../errors.js';
|
||||
import { sendJson, sendApiError } from './respond.js';
|
||||
import { parseRequest } from '../parseChat.js';
|
||||
|
||||
/**
|
||||
* 鉴权检查
|
||||
* @param {import('http').IncomingMessage} req - HTTP 请求
|
||||
* @param {string} authToken - 有效的认证令牌
|
||||
* @returns {boolean} 是否通过鉴权
|
||||
*/
|
||||
function checkAuth(req, authToken) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
return authHeader === `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建路由处理器
|
||||
* @param {object} context - 路由上下文
|
||||
* @param {string} context.authToken - 认证令牌
|
||||
* @param {string} context.backendName - 后端名称
|
||||
* @param {Function} context.getModels - 获取模型列表函数
|
||||
* @param {Function} context.resolveModelId - 解析模型 ID 函数
|
||||
* @param {Function} context.getImagePolicy - 获取图片策略函数
|
||||
* @param {string} context.tempDir - 临时目录
|
||||
* @param {number} context.imageLimit - 图片数量限制
|
||||
* @param {object} context.queueManager - 队列管理器
|
||||
* @returns {Function} 请求处理函数
|
||||
*/
|
||||
export function createRouter(context) {
|
||||
const {
|
||||
authToken,
|
||||
backendName,
|
||||
getModels,
|
||||
resolveModelId,
|
||||
getImagePolicy,
|
||||
tempDir,
|
||||
imageLimit,
|
||||
queueManager
|
||||
} = context;
|
||||
|
||||
/**
|
||||
* 处理 GET /v1/models
|
||||
* @param {import('http').ServerResponse} res - HTTP 响应
|
||||
*/
|
||||
function handleModels(res) {
|
||||
const models = getModels();
|
||||
sendJson(res, 200, models);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 GET /v1/cookies
|
||||
* @param {import('http').ServerResponse} res - HTTP 响应
|
||||
* @param {string} requestId - 请求 ID
|
||||
*/
|
||||
async function handleCookies(res, requestId) {
|
||||
const browserContext = queueManager.getBrowserContext();
|
||||
|
||||
if (!browserContext?.page) {
|
||||
sendApiError(res, { code: ERROR_CODES.BROWSER_NOT_INITIALIZED });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const context = browserContext.page.context();
|
||||
const cookies = await context.cookies();
|
||||
sendJson(res, 200, { cookies });
|
||||
} catch (err) {
|
||||
logger.error('服务器', '获取 Cookies 失败', { id: requestId, error: err.message });
|
||||
sendApiError(res, {
|
||||
code: ERROR_CODES.INTERNAL_ERROR,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 POST /v1/chat/completions
|
||||
* @param {import('http').IncomingMessage} req - HTTP 请求
|
||||
* @param {import('http').ServerResponse} res - HTTP 响应
|
||||
* @param {string} requestId - 请求 ID
|
||||
*/
|
||||
async function handleChatCompletions(req, res, requestId) {
|
||||
// 读取请求体
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = Buffer.concat(chunks).toString();
|
||||
const data = JSON.parse(body);
|
||||
const isStreaming = data.stream === true;
|
||||
|
||||
// 限流检查:非流式请求在队列满时拒绝
|
||||
if (!isStreaming && !queueManager.canAcceptNonStreaming()) {
|
||||
const status = queueManager.getStatus();
|
||||
logger.warn('服务器', '非流式请求被拒绝 (队列已满)', { id: requestId, queueSize: status.total });
|
||||
sendApiError(res, {
|
||||
code: ERROR_CODES.SERVER_BUSY,
|
||||
error: `服务器繁忙(队列: ${status.total}/${queueManager.maxQueueSize})。请使用流式模式 (stream: true) 或稍后重试。`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置 SSE 响应头(流式请求)
|
||||
if (isStreaming) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
});
|
||||
}
|
||||
|
||||
// 解析请求
|
||||
const parseResult = await parseRequest(data, {
|
||||
tempDir,
|
||||
imageLimit,
|
||||
backendName,
|
||||
resolveModelId,
|
||||
getImagePolicy,
|
||||
requestId,
|
||||
logger
|
||||
});
|
||||
|
||||
if (!parseResult.success) {
|
||||
sendApiError(res, {
|
||||
code: parseResult.error.code,
|
||||
error: parseResult.error.error,
|
||||
isStreaming
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { prompt, imagePaths, modelId, modelName } = parseResult.data;
|
||||
|
||||
logger.info('服务器', `[队列] 请求入队: ${prompt.slice(0, 10)}...`, { id: requestId, images: imagePaths.length });
|
||||
|
||||
// 将任务加入队列
|
||||
queueManager.addTask({
|
||||
req,
|
||||
res,
|
||||
prompt,
|
||||
imagePaths,
|
||||
modelId,
|
||||
modelName,
|
||||
id: requestId,
|
||||
isStreaming
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
logger.error('服务器', '请求处理失败', { id: requestId, error: err.message });
|
||||
sendApiError(res, {
|
||||
code: ERROR_CODES.INTERNAL_ERROR,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主路由处理函数
|
||||
* @param {import('http').IncomingMessage} req - HTTP 请求
|
||||
* @param {import('http').ServerResponse} res - HTTP 响应
|
||||
*/
|
||||
return async function handleRequest(req, res) {
|
||||
// 生成请求 ID
|
||||
const requestId = crypto.randomUUID().slice(0, 8);
|
||||
|
||||
// 鉴权检查
|
||||
if (!checkAuth(req, authToken)) {
|
||||
sendApiError(res, { code: ERROR_CODES.UNAUTHORIZED });
|
||||
return;
|
||||
}
|
||||
|
||||
// 路由分发
|
||||
if (req.method === 'GET' && req.url === '/v1/models') {
|
||||
handleModels(res);
|
||||
} else if (req.method === 'GET' && req.url === '/v1/cookies') {
|
||||
await handleCookies(res, requestId);
|
||||
} else if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
|
||||
await handleChatCompletions(req, res, requestId);
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @fileoverview src/server 模块入口
|
||||
* @description 导出服务器相关模块
|
||||
*/
|
||||
|
||||
export { ERROR_CODES, getErrorMessage, getErrorStatus, getErrorDetails } from './errors.js';
|
||||
export {
|
||||
sendJson,
|
||||
sendSse,
|
||||
sendSseDone,
|
||||
sendHeartbeat,
|
||||
sendApiError,
|
||||
buildChatCompletion,
|
||||
buildChatCompletionChunk
|
||||
} from './http/respond.js';
|
||||
export { handleDisplayParams } from './display.js';
|
||||
export { createQueueManager } from './queue.js';
|
||||
export { parseRequest } from './parseChat.js';
|
||||
export { createRouter } from './http/routes.js';
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* @fileoverview 请求解析模块
|
||||
* @description 负责解析聊天请求、提取提示词和处理图片
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import sharp from 'sharp';
|
||||
import { IMAGE_POLICY } from '../backend/models.js';
|
||||
import { ERROR_CODES, getErrorMessage } from './errors.js';
|
||||
|
||||
/**
|
||||
* 构造解析错误结果
|
||||
* @param {string} code - 错误码
|
||||
* @param {string} [customMessage] - 自定义消息(可选,用于包含动态参数)
|
||||
* @returns {{success: false, error: {code: string, error: string}}}
|
||||
*/
|
||||
function parseError(code, customMessage) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
error: customMessage || getErrorMessage(code)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} ParsedRequest
|
||||
* @property {string} prompt - 提取的提示词
|
||||
* @property {string[]} imagePaths - 图片临时文件路径
|
||||
* @property {string|null} modelId - 解析后的模型 ID
|
||||
* @property {string|null} modelName - 原始模型名称
|
||||
* @property {boolean} isStreaming - 是否流式请求
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ParseError
|
||||
* @property {string} code - 错误码
|
||||
* @property {string} error - 错误消息
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ParseResult
|
||||
* @property {boolean} success - 是否解析成功
|
||||
* @property {ParsedRequest} [data] - 解析结果(成功时)
|
||||
* @property {ParseError} [error] - 错误信息(失败时)
|
||||
*/
|
||||
|
||||
/**
|
||||
* 解析聊天请求
|
||||
* @param {object} data - 请求体数据
|
||||
* @param {object} options - 解析选项
|
||||
* @param {string} options.tempDir - 临时目录路径
|
||||
* @param {number} options.imageLimit - 图片数量限制
|
||||
* @param {string} options.backendName - 后端名称
|
||||
* @param {Function} options.resolveModelId - 模型 ID 解析函数
|
||||
* @param {Function} options.getImagePolicy - 获取图片策略函数
|
||||
* @param {string} options.requestId - 请求 ID
|
||||
* @param {Function} options.logger - 日志函数
|
||||
* @returns {Promise<ParseResult>} 解析结果
|
||||
*/
|
||||
export async function parseRequest(data, options) {
|
||||
const {
|
||||
tempDir,
|
||||
imageLimit,
|
||||
backendName,
|
||||
resolveModelId,
|
||||
getImagePolicy,
|
||||
requestId,
|
||||
logger
|
||||
} = options;
|
||||
|
||||
const messages = data.messages;
|
||||
const isStreaming = data.stream === true;
|
||||
|
||||
// 验证 messages
|
||||
if (!messages || messages.length === 0) {
|
||||
return parseError(ERROR_CODES.NO_MESSAGES);
|
||||
}
|
||||
|
||||
// 筛选用户消息
|
||||
const userMessages = messages.filter(m => m.role === 'user');
|
||||
if (userMessages.length === 0) {
|
||||
return parseError(ERROR_CODES.NO_USER_MESSAGES);
|
||||
}
|
||||
|
||||
const lastMessage = userMessages[userMessages.length - 1];
|
||||
|
||||
let prompt = '';
|
||||
const imagePaths = [];
|
||||
let imageCount = 0;
|
||||
|
||||
// 解析内容
|
||||
if (Array.isArray(lastMessage.content)) {
|
||||
for (const item of lastMessage.content) {
|
||||
if (item.type === 'text') {
|
||||
prompt += item.text + ' ';
|
||||
} else if (item.type === 'image_url' && item.image_url?.url) {
|
||||
imageCount++;
|
||||
|
||||
// 图片数量检查
|
||||
if (imageLimit <= 10) {
|
||||
if (imageCount > imageLimit) {
|
||||
return parseError(ERROR_CODES.TOO_MANY_IMAGES, `图片数量超过限制(最大 ${imageLimit} 张)`);
|
||||
}
|
||||
} else {
|
||||
// imageLimit > 10:超过浏览器硬限制时忽略
|
||||
if (imageCount > 10) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 data URL
|
||||
const url = item.image_url.url;
|
||||
if (url.startsWith('data:image')) {
|
||||
const imagePath = await saveBase64Image(url, tempDir);
|
||||
if (imagePath) {
|
||||
imagePaths.push(imagePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
prompt = lastMessage.content;
|
||||
}
|
||||
|
||||
prompt = prompt.trim();
|
||||
|
||||
// 解析模型参数
|
||||
let modelId = null;
|
||||
if (data.model) {
|
||||
modelId = resolveModelId(data.model);
|
||||
if (modelId) {
|
||||
logger.info('服务器', `触发模型: ${data.model} (${modelId})`, { id: requestId });
|
||||
} else {
|
||||
return parseError(ERROR_CODES.INVALID_MODEL, `模型无效/后端 ${backendName} 不支持: ${data.model}`);
|
||||
}
|
||||
} else {
|
||||
logger.info('服务器', '未指定模型,使用网页默认', { id: requestId });
|
||||
}
|
||||
|
||||
// 图片策略校验
|
||||
const hasImage = imagePaths.length > 0;
|
||||
const policy = data.model ? getImagePolicy(data.model) : IMAGE_POLICY.OPTIONAL;
|
||||
|
||||
if (policy === IMAGE_POLICY.REQUIRED && !hasImage) {
|
||||
return parseError(ERROR_CODES.IMAGE_REQUIRED, `模型 ${data.model} 需要参考图`);
|
||||
}
|
||||
|
||||
if (policy === IMAGE_POLICY.FORBIDDEN && hasImage) {
|
||||
return parseError(ERROR_CODES.IMAGE_FORBIDDEN, `模型 ${data.model} 不支持图片输入`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
prompt,
|
||||
imagePaths,
|
||||
modelId,
|
||||
modelName: data.model || null,
|
||||
isStreaming
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Base64 图片到临时文件
|
||||
* @param {string} dataUrl - data URL 格式的图片
|
||||
* @param {string} tempDir - 临时目录
|
||||
* @returns {Promise<string|null>} 保存的文件路径,失败返回 null
|
||||
*/
|
||||
async function saveBase64Image(dataUrl, tempDir) {
|
||||
const matches = dataUrl.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/);
|
||||
if (!matches || matches.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(matches[2], 'base64');
|
||||
// 压缩图片
|
||||
const processedBuffer = await sharp(buffer)
|
||||
.jpeg({ quality: 90 })
|
||||
.toBuffer();
|
||||
|
||||
const filename = `img_${Date.now()}_${Math.random().toString(36).substring(7)}.jpg`;
|
||||
const filePath = path.join(tempDir, filename);
|
||||
fs.writeFileSync(filePath, processedBuffer);
|
||||
return filePath;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* @fileoverview 任务队列管理模块
|
||||
* @description 负责请求队列、并发控制和心跳机制
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
import {
|
||||
sendJson,
|
||||
sendSse,
|
||||
sendSseDone,
|
||||
sendHeartbeat,
|
||||
sendApiError,
|
||||
buildChatCompletion,
|
||||
buildChatCompletionChunk
|
||||
} from './http/respond.js';
|
||||
|
||||
/**
|
||||
* @typedef {object} TaskContext
|
||||
* @property {import('http').IncomingMessage} req - HTTP 请求对象
|
||||
* @property {import('http').ServerResponse} res - HTTP 响应对象
|
||||
* @property {string} prompt - 用户提示词
|
||||
* @property {string[]} imagePaths - 图片路径列表
|
||||
* @property {string|null} modelId - 模型 ID
|
||||
* @property {string|null} modelName - 模型名称
|
||||
* @property {string} id - 请求唯一标识
|
||||
* @property {boolean} isStreaming - 是否流式请求
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} QueueConfig
|
||||
* @property {number} maxConcurrent - 最大并发数
|
||||
* @property {number} maxQueueSize - 最大队列大小
|
||||
* @property {string} keepaliveMode - 心跳模式 ('comment' | 'content')
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} BrowserContext
|
||||
* @property {object} browser - Playwright 浏览器实例
|
||||
* @property {object} page - Playwright 页面实例
|
||||
* @property {object} config - 配置对象
|
||||
*/
|
||||
|
||||
/**
|
||||
* 创建任务队列管理器
|
||||
* @param {QueueConfig} queueConfig - 队列配置
|
||||
* @param {object} callbacks - 回调函数
|
||||
* @param {Function} callbacks.initBrowser - 初始化浏览器函数
|
||||
* @param {Function} callbacks.generateImage - 生成图片函数
|
||||
* @param {object} callbacks.config - 配置对象
|
||||
* @returns {object} 队列管理器
|
||||
*/
|
||||
export function createQueueManager(queueConfig, callbacks) {
|
||||
const { maxConcurrent, maxQueueSize, keepaliveMode } = queueConfig;
|
||||
const { initBrowser, generateImage, config } = callbacks;
|
||||
|
||||
/** @type {TaskContext[]} */
|
||||
const queue = [];
|
||||
|
||||
/** @type {number} */
|
||||
let processingCount = 0;
|
||||
|
||||
/** @type {BrowserContext|null} */
|
||||
let browserContext = null;
|
||||
|
||||
/**
|
||||
* 清理任务临时文件
|
||||
* @param {TaskContext} task - 任务上下文
|
||||
*/
|
||||
async function cleanupTask(task) {
|
||||
if (task?.imagePaths) {
|
||||
const fs = await import('fs');
|
||||
for (const p of task.imagePaths) {
|
||||
try { fs.unlinkSync(p); } catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个任务
|
||||
* @param {TaskContext} task - 任务上下文
|
||||
*/
|
||||
async function processTask(task) {
|
||||
const { res, prompt, imagePaths, modelId, modelName, id, isStreaming } = task;
|
||||
|
||||
logger.info('服务器', '[队列] 开始处理任务', { id, remaining: queue.length });
|
||||
|
||||
// 启动心跳(流式请求)
|
||||
let heartbeatInterval = null;
|
||||
if (isStreaming) {
|
||||
heartbeatInterval = setInterval(() => {
|
||||
if (res.writableEnded) {
|
||||
clearInterval(heartbeatInterval);
|
||||
return;
|
||||
}
|
||||
sendHeartbeat(res, keepaliveMode, modelName);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
try {
|
||||
// 确保浏览器已初始化
|
||||
if (!browserContext) {
|
||||
browserContext = await initBrowser(config);
|
||||
}
|
||||
|
||||
// 调用核心生图逻辑
|
||||
const result = await generateImage(browserContext, prompt, imagePaths, modelId, { id });
|
||||
|
||||
// 清除心跳
|
||||
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
||||
|
||||
// 处理结果
|
||||
let finalContent = '';
|
||||
|
||||
if (result.error) {
|
||||
// 适配器层已归一化错误,直接构造错误响应
|
||||
finalContent = `[生成错误] ${result.error}`;
|
||||
} else if (result.image) {
|
||||
finalContent = ``;
|
||||
logger.info('服务器', '图片已准备就绪 (Base64)', { id });
|
||||
} else {
|
||||
finalContent = result.text || '生成失败';
|
||||
}
|
||||
|
||||
// 发送响应
|
||||
if (isStreaming) {
|
||||
const chunk = buildChatCompletionChunk(finalContent, modelName);
|
||||
sendSse(res, chunk);
|
||||
sendSseDone(res);
|
||||
} else {
|
||||
const response = buildChatCompletion(finalContent, modelName);
|
||||
sendJson(res, 200, response);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
// 清除心跳
|
||||
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
||||
|
||||
logger.error('服务器', '任务处理失败', { id, error: err.message });
|
||||
sendApiError(res, {
|
||||
code: ERROR_CODES.INTERNAL_ERROR,
|
||||
error: err.message,
|
||||
isStreaming
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理队列中的任务
|
||||
*/
|
||||
async function processQueue() {
|
||||
// 如果正在处理的任务已满,或队列为空,则停止
|
||||
if (processingCount >= maxConcurrent || queue.length === 0) return;
|
||||
|
||||
// 取出下一个任务
|
||||
const task = queue.shift();
|
||||
processingCount++;
|
||||
|
||||
try {
|
||||
await processTask(task);
|
||||
} finally {
|
||||
// 清理临时文件
|
||||
cleanupTask(task);
|
||||
processingCount--;
|
||||
// 递归处理下一个任务
|
||||
processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加任务到队列
|
||||
* @param {TaskContext} task - 任务上下文
|
||||
*/
|
||||
function addTask(task) {
|
||||
queue.push(task);
|
||||
processQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前队列状态
|
||||
* @returns {{queueLength: number, processing: number, total: number}}
|
||||
*/
|
||||
function getStatus() {
|
||||
return {
|
||||
queueLength: queue.length,
|
||||
processing: processingCount,
|
||||
total: processingCount + queue.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以接受新请求(非流式)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function canAcceptNonStreaming() {
|
||||
return processingCount + queue.length < maxQueueSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化浏览器
|
||||
* @returns {Promise<BrowserContext>}
|
||||
*/
|
||||
async function initializeBrowser() {
|
||||
browserContext = await initBrowser(config);
|
||||
return browserContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取浏览器上下文
|
||||
* @returns {BrowserContext|null}
|
||||
*/
|
||||
function getBrowserContext() {
|
||||
return browserContext;
|
||||
}
|
||||
|
||||
return {
|
||||
addTask,
|
||||
getStatus,
|
||||
canAcceptNonStreaming,
|
||||
initializeBrowser,
|
||||
getBrowserContext,
|
||||
maxQueueSize
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user