diff --git a/lib/backend/index.js b/lib/backend/index.js index cc7c6a5..6b59e2a 100644 --- a/lib/backend/index.js +++ b/lib/backend/index.js @@ -62,7 +62,7 @@ const SingleBackend = { // 2. 聚合后端模式实现 const MergedBackend = { - name: 'aggregated', + name: 'merge', _globalBrowser: null, _globalPage: null, diff --git a/lib/server/display.js b/lib/server/display.js new file mode 100644 index 0000000..4c6a0da --- /dev/null +++ b/lib/server/display.js @@ -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} 端口是否可用 + */ +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} 可用端口号,或 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} 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'; + } +} diff --git a/lib/server/errors.js b/lib/server/errors.js new file mode 100644 index 0000000..8134d91 --- /dev/null +++ b/lib/server/errors.js @@ -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} + */ +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 }; +} diff --git a/lib/server/index.js b/lib/server/index.js new file mode 100644 index 0000000..6622741 --- /dev/null +++ b/lib/server/index.js @@ -0,0 +1,19 @@ +/** + * @fileoverview lib/server 模块入口 + * @description 导出服务器相关模块 + */ + +export { ERROR_CODES, getErrorMessage, getErrorStatus, getErrorDetails } from './errors.js'; +export { + sendJson, + sendSse, + sendSseDone, + sendHeartbeat, + sendApiError, + buildChatCompletion, + buildChatCompletionChunk +} from './respond.js'; +export { handleDisplayParams } from './display.js'; +export { createQueueManager } from './queue.js'; +export { parseRequest } from './parseChat.js'; +export { createRouter } from './routes.js'; diff --git a/lib/server/parseChat.js b/lib/server/parseChat.js new file mode 100644 index 0000000..c004e00 --- /dev/null +++ b/lib/server/parseChat.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} 解析结果 + */ +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} 保存的文件路径,失败返回 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; + } +} diff --git a/lib/server/queue.js b/lib/server/queue.js new file mode 100644 index 0000000..70f3a9c --- /dev/null +++ b/lib/server/queue.js @@ -0,0 +1,223 @@ +/** + * @fileoverview 任务队列管理模块 + * @description 负责请求队列、并发控制和心跳机制 + */ + +import { logger } from '../utils/logger.js'; +import { + sendJson, + sendSse, + sendSseDone, + sendHeartbeat, + sendApiError, + buildChatCompletion, + buildChatCompletionChunk +} from './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 = `![generated](${result.image})`; + 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} + */ + async function initializeBrowser() { + browserContext = await initBrowser(config); + return browserContext; + } + + /** + * 获取浏览器上下文 + * @returns {BrowserContext|null} + */ + function getBrowserContext() { + return browserContext; + } + + return { + addTask, + getStatus, + canAcceptNonStreaming, + initializeBrowser, + getBrowserContext, + maxQueueSize + }; +} diff --git a/lib/server/respond.js b/lib/server/respond.js new file mode 100644 index 0000000..8b13606 --- /dev/null +++ b/lib/server/respond.js @@ -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 + }] + }; +} diff --git a/lib/server/routes.js b/lib/server/routes.js new file mode 100644 index 0000000..ce69bc5 --- /dev/null +++ b/lib/server/routes.js @@ -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(); + } + }; +} diff --git a/server.js b/server.js index 90f3f28..1b3aaef 100644 --- a/server.js +++ b/server.js @@ -1,224 +1,41 @@ +/** + * @fileoverview LMArena Image Automator 服务器入口 + * @description HTTP API 服务器,提供 OpenAI 兼容的图像生成接口 + * + * 支持的端点: + * - GET /v1/models - 获取可用模型列表 + * - GET /v1/cookies - 获取当前浏览器 Cookies + * - POST /v1/chat/completions - 生成图像(OpenAI 兼容格式) + * + * 命令行参数: + * - -xvfb 启用 Xvfb 虚拟显示器(仅 Linux) + * - -vnc 启用 VNC 服务器(需配合 -xvfb 使用) + * - -login 启动时打开登录页面 + */ + import http from 'http'; -import fs from 'fs'; -import path from 'path'; -import sharp from 'sharp'; import { getBackend } from './lib/backend/index.js'; -import { IMAGE_POLICY } from './lib/backend/models.js'; import { logger } from './lib/utils/logger.js'; -import crypto from 'crypto'; -import { spawn, spawnSync } from 'child_process'; -import os from 'os'; -import net from 'net'; +import { handleDisplayParams, createQueueManager, createRouter } from './lib/server/index.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} - */ -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} - */ -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; -} - -/** - * 处理 Xvfb 和 VNC 启动参数(仅 Linux) - */ -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 参数(或已在 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) { - // 已在 Xvfb 中,继续正常启动 - logger.info('服务器', '已在 Xvfb 虚拟显示器中运行', { display: process.env.DISPLAY || ':99' }); - - // 处理 VNC(如果需要) - if (hasVnc) { - 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); - } - - const display = process.env.DISPLAY || ':99'; - - // 自动查找可用端口 (从 5900 开始) - 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', // 忽略 VNC 的输出,避免混入日志 - detached: false - }); - - vncProcess.on('error', (err) => { - logger.error('服务器', 'VNC 启动失败', { error: err.message }); - }); - - // 处理进程退出信号 - process.on('SIGINT', () => { - vncProcess.kill('SIGTERM'); - process.exit(0); - }); - process.on('SIGTERM', () => { - vncProcess.kill('SIGTERM'); - process.exit(0); - }); - - logger.info('服务器', 'VNC 服务器已成功启动'); - logger.warn('服务器', `VNC 连接端口: ${vncPort}`); - } - - return; - } - - // 需要在 Xvfb 中重启 - logger.info('服务器', '正在启动 Xvfb 虚拟显示器...'); - - // 构建新的参数列表(移除 -xvfb,保留其他参数如 -vnc、-login 等) - const newArgs = args.filter(arg => arg !== '-xvfb'); - - // 使用 env 命令来确保环境变量被正确传递 - const xvfbArgs = [ - '--server-num=99', - '--server-args=-ac -screen 0 1366x768x24', - 'env', - 'XVFB_RUNNING=true', - 'DISPLAY=:99', - process.argv[0], // node 可执行文件 - process.argv[1], // server.js 路径 - ...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'); - }); - - // 不再继续执行后续代码 - return 'XVFB_REDIRECT'; - } -} - -// 执行参数处理 +// 处理 Xvfb/VNC 参数(仅 Linux) const displayResult = await handleDisplayParams(); if (displayResult === 'XVFB_REDIRECT') { - // 已经重定向到 Xvfb,不再继续执行 - // 这个进程将等待子进程退出 - // eslint-disable-next-line no-process-exit + // 已重定向到 Xvfb,阻止继续执行 process.on('exit', () => { }); - // 阻止继续执行 await new Promise(() => { }); } -// ==================== 服务器主逻辑 ==================== +// ==================== 初始化配置 ==================== -// 使用统一后端获取配置和函数 +/** + * 从统一后端获取配置和函数 + */ const { config, - name, + name: backendName, initBrowser, generateImage, TEMP_DIR, @@ -227,423 +44,80 @@ const { getImagePolicy } = getBackend(); -const PORT = config.server.port || 3000; -const AUTH_TOKEN = config.server.auth; -const KEEPALIVE_MODE = config.server.keepalive?.mode || 'comment'; +/** @type {number} 服务器端口 */ +const PORT = config.server?.port || 3000; -// --- 全局状态 --- -let browserContext = null; // 浏览器上下文 {browser, page, client, width, height} -const queue = []; // 请求队列 -let processingCount = 0; // 当前正在处理的任务数 -const MAX_CONCURRENT = config.queue?.maxConcurrent || 1; // 从配置读取 -const MAX_QUEUE_SIZE = config.queue?.maxQueueSize || 2; // 从配置读取 -const IMAGE_LIMIT = config.queue?.imageLimit || 5; // 图片数量上限 +/** @type {string} 认证令牌 */ +const AUTH_TOKEN = config.server?.auth; + +/** @type {string} 心跳模式 */ +const KEEPALIVE_MODE = config.server?.keepalive?.mode || 'comment'; + +/** @type {number} 最大并发数 */ +const MAX_CONCURRENT = config.queue?.maxConcurrent || 1; + +/** @type {number} 最大队列大小 */ +const MAX_QUEUE_SIZE = config.queue?.maxQueueSize || 2; + +/** @type {number} 图片数量限制 */ +const IMAGE_LIMIT = config.queue?.imageLimit || 5; + +// ==================== 创建服务组件 ==================== /** - * 处理队列中的任务 + * 队列管理器:负责任务队列、并发控制和心跳机制 */ -async function processQueue() { - // 如果正在处理的任务已满,或队列为空,则停止 - if (processingCount >= MAX_CONCURRENT || queue.length === 0) return; - - // 取出下一个任务 - const task = queue.shift(); - processingCount++; - - try { - const { req, 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; - } - // 发送心跳包 - if (KEEPALIVE_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`); - } - }, 3000); - } - - // 确保浏览器已初始化 - if (!browserContext) { - browserContext = await initBrowser(config); - } - - // 调用核心生图逻辑 - const result = await generateImage(browserContext, prompt, imagePaths, modelId, { id }); - - // 清除心跳 - if (heartbeatInterval) clearInterval(heartbeatInterval); - - // 处理结果 - let finalContent = ''; - - if (result.error) { - // 特殊错误处理:reCAPTCHA - if (result.error === 'recaptcha validation failed') { - if (isStreaming) { - res.write(`data: ${JSON.stringify({ error: 'recaptcha validation failed' })}\n\n`); - res.write(`data: [DONE]\n\n`); - res.end(); - } else { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'recaptcha validation failed' })); - } - return; - } - finalContent = `[生成错误] ${result.error}`; - } else if (result.image) { - try { - // result.image 已经是 "data:image/png;base64,..." 格式 - // 构造 Markdown 图片展示 (Data URI) - finalContent = `![generated](${result.image})`; - logger.info('服务器', '图片已准备就绪 (Base64)', { id }); - } catch (e) { - logger.error('服务器', '图片处理失败', { id, error: e.message }); - finalContent = `[图片处理失败] ${e.message}`; - } - } else { - finalContent = result.text || '生成失败'; - } - - // 发送响应 - if (isStreaming) { - // 流式响应 - 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: finalContent }, - finish_reason: 'stop' - }] - }; - res.write(`data: ${JSON.stringify(chunk)}\n\n`); - res.write(`data: [DONE]\n\n`); - res.end(); - } else { - // 非流式响应 - const response = { - id: 'chatcmpl-' + Date.now(), - object: 'chat.completion', - created: Math.floor(Date.now() / 1000), - model: modelName || 'default-model', - choices: [{ - index: 0, - message: { - role: 'assistant', - content: finalContent - }, - finish_reason: 'stop' - }] - }; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(response)); - } - - } catch (err) { - logger.error('服务器', '任务处理失败', { id: task.id, error: err.message }); - if (task.isStreaming) { - if (!task.res.writableEnded) { - task.res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`); - task.res.write(`data: [DONE]\n\n`); - task.res.end(); - } - } else { - if (!task.res.writableEnded) { - task.res.writeHead(500, { 'Content-Type': 'application/json' }); - task.res.end(JSON.stringify({ error: err.message })); - } - } - } finally { - // 无论成功失败,都尝试清理临时图片 - if (task && task.imagePaths) { - for (const p of task.imagePaths) { - try { fs.unlinkSync(p); } catch (e) { } - } - } - processingCount--; - // 递归处理下一个任务 - processQueue(); +const queueManager = createQueueManager( + { + maxConcurrent: MAX_CONCURRENT, + maxQueueSize: MAX_QUEUE_SIZE, + keepaliveMode: KEEPALIVE_MODE + }, + { + initBrowser, + generateImage, + config } -} +); + +/** + * 路由处理器:负责 API 路由分发和鉴权 + */ +const handleRequest = createRouter({ + authToken: AUTH_TOKEN, + backendName, + getModels, + resolveModelId, + getImagePolicy, + tempDir: TEMP_DIR, + imageLimit: IMAGE_LIMIT, + queueManager +}); + +// ==================== 启动服务器 ==================== /** * 启动 HTTP 服务器 + * @returns {Promise} */ async function startServer() { // 预先启动浏览器 try { - browserContext = await initBrowser(config); + await queueManager.initializeBrowser(); } catch (err) { logger.error('服务器', '浏览器初始化失败', { error: err.message }); process.exit(1); } - const server = http.createServer(async (req, res) => { - // 为每个请求生成唯一 ID - const id = crypto.randomUUID().slice(0, 8); - - // --- 鉴权中间件 --- - const authHeader = req.headers['authorization']; - if (!authHeader || authHeader !== `Bearer ${AUTH_TOKEN}`) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Unauthorized' })); - return; - } - - // --- 路由分发 --- - // 1. 模型列表接口 - if (req.method === 'GET' && req.url === '/v1/models') { - const models = getModels(); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(models)); - return; - } - - // 2. 获取 Cookies 接口 - if (req.method === 'GET' && req.url === '/v1/cookies') { - try { - if (!browserContext || !browserContext.page) { - res.writeHead(503, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Browser not initialized' })); - return; - } - const context = browserContext.page.context(); - const cookies = await context.cookies(); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ cookies })); - } catch (err) { - logger.error('服务器', '获取 Cookies 失败', { id, error: err.message }); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: err.message })); - } - return; - } - - // 3. 聊天补全接口 - if (req.method === 'POST' && req.url.startsWith('/v1/chat/completions')) { - const chunks = []; - req.on('data', chunk => chunks.push(chunk)); - req.on('end', async () => { - try { - const body = Buffer.concat(chunks).toString(); - const data = JSON.parse(body); - const messages = data.messages; - const isStreaming = data.stream === true; - - // 限流检查:非流式请求在队列满时拒绝 - const totalPending = processingCount + queue.length; - if (!isStreaming && totalPending >= MAX_QUEUE_SIZE) { - logger.warn('服务器', '非流式请求被拒绝 (队列已满)', { id, queueSize: totalPending }); - res.writeHead(429, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: `Server is busy (queue: ${totalPending}/${MAX_QUEUE_SIZE}). Please use streaming mode (stream: true) to wait in queue, or try again later.` - })); - return; - } - - // 如果是流式,设置 SSE 响应头 - if (isStreaming) { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive' - }); - } - - if (!messages || messages.length === 0) { - if (isStreaming) { - res.write(`data: ${JSON.stringify({ error: 'No messages' })}\n\n`); - res.write(`data: [DONE]\n\n`); - res.end(); - } else { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'No messages' })); - } - return; - } - - // 筛选用户消息 - const userMessages = messages.filter(m => m.role === 'user'); - if (userMessages.length === 0) { - if (isStreaming) { - res.write(`data: ${JSON.stringify({ error: 'No user messages' })}\n\n`); - res.write(`data: [DONE]\n\n`); - res.end(); - } else { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'No user messages' })); - } - return; - } - 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 && item.image_url.url) { - imageCount++; - - // 逻辑: - // 1. 如果配置限制 <= 10 (浏览器硬限制), 则严格执行, 超过报错 - // 2. 如果配置限制 > 10, 则视为用户想"尽力而为", 自动截断到 10 张, 忽略多余的 - - if (IMAGE_LIMIT <= 10) { - if (imageCount > IMAGE_LIMIT) { - const errorMsg = `Too many images. Maximum ${IMAGE_LIMIT} images allowed.`; - logger.warn('server', errorMsg, { id }); - if (isStreaming) { - res.write(`data: ${JSON.stringify({ error: errorMsg })}\n\n`); - res.write(`data: [DONE]\n\n`); - res.end(); - } else { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: errorMsg })); - } - return; - } - } else { - // IMAGE_LIMIT > 10 - if (imageCount > 10) { - // 超过浏览器硬限制, 忽略该图片 - continue; - } - } - - const url = item.image_url.url; - if (url.startsWith('data:image')) { - const matches = url.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/); - if (matches && matches.length === 3) { - 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(TEMP_DIR, filename); - fs.writeFileSync(filePath, processedBuffer); - imagePaths.push(filePath); - } - } - } - } - } 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 }); - } else { - const errorMsg = `Invalid model for backend ${name}: ${data.model}`; - logger.warn('服务器', errorMsg, { id }); - if (isStreaming) { - res.write(`data: ${JSON.stringify({ error: errorMsg })}\n\n`); - res.write(`data: [DONE]\n\n`); - res.end(); - } else { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: errorMsg })); - } - return; - } - } else { - logger.info('服务器', '未指定模型,使用网页默认', { id }); - } - - // 图片策略校验 - const hasImage = imagePaths.length > 0; - const policy = data.model ? getImagePolicy(data.model) : IMAGE_POLICY.OPTIONAL; - - if (policy === IMAGE_POLICY.REQUIRED && !hasImage) { - const errorMsg = `Model ${data.model} requires a reference image.`; - logger.warn('服务器', errorMsg, { id }); - if (isStreaming) { - res.write(`data: ${JSON.stringify({ error: errorMsg })}\n\n`); - res.write(`data: [DONE]\n\n`); - res.end(); - } else { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: errorMsg })); - } - return; - } - - if (policy === IMAGE_POLICY.FORBIDDEN && hasImage) { - const errorMsg = `Model ${data.model} does not accept images.`; - logger.warn('服务器', errorMsg, { id }); - if (isStreaming) { - res.write(`data: ${JSON.stringify({ error: errorMsg })}\n\n`); - res.write(`data: [DONE]\n\n`); - res.end(); - } else { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: errorMsg })); - } - return; - } - - logger.info('服务器', `[队列] 请求入队: ${prompt.slice(0, 10)}...`, { id, images: imagePaths.length }); - - // 将任务加入队列 - queue.push({ req, res, prompt, imagePaths, modelId, modelName: data.model || null, id, isStreaming }); - - // 触发队列处理 - processQueue(); - - } catch (err) { - logger.error('服务器', '服务器处理失败', { id, error: err.message }); - if (!res.writableEnded) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: err.message })); - } - } - }); - } else { - res.writeHead(404); - res.end(); - } - }); + // 创建并启动 HTTP 服务器 + const server = http.createServer(handleRequest); server.listen(PORT, () => { logger.info('服务器', `HTTP 服务器启动成功,监听端口 ${PORT}`); - logger.info('服务器', `流式心跳模式: ${KEEPALIVE_MODE}`); - logger.info('服务器', `最大队列: ${MAX_QUEUE_SIZE},最大图片数量: ${IMAGE_LIMIT}`); + logger.info('服务器', `后端: ${backendName},流式心跳模式: ${KEEPALIVE_MODE}`); + logger.info('服务器', `最大并发: ${MAX_CONCURRENT},最大队列: ${MAX_QUEUE_SIZE},最大图片数量: ${IMAGE_LIMIT}`); }); } +// 启动服务器 startServer();