refactor: 代码解耦合,完善WebUI功能,添加网页VNC

This commit is contained in:
foxhui
2025-12-20 04:40:05 +08:00
Unverified
parent 41e3e022a3
commit c8c7aec0e1
74 changed files with 4984 additions and 644 deletions
@@ -3,15 +3,15 @@
* @description 提供管理接口包括系统状态配置管理适配器元数据等
*/
import { sendJson, sendApiError } from './respond.js';
import { ERROR_CODES } from '../errors.js';
import { logger } from '../../utils/logger.js';
import { sendJson, sendApiError } from '../../respond.js';
import { ERROR_CODES } from '../../errors.js';
import { logger } from '../../../utils/logger.js';
import {
getSystemStatus,
getDataFolders,
deleteDataFolders,
clearTempFiles
} from '../../utils/systemInfo.js';
} from '../../../utils/systemInfo.js';
import {
getServerConfig,
saveServerConfig,
@@ -25,15 +25,16 @@ import {
saveAdaptersConfig,
getPoolConfig,
savePoolConfig
} from '../../utils/configManager.js';
} from '../../../config/manager.js';
import {
validateServerConfig,
validateBrowserConfig,
validateInstancesConfig,
validatePoolConfig,
validateAdaptersConfig
} from '../../utils/configValidator.js';
import { registry } from '../../backend/registry.js';
} from '../../../config/validator.js';
import { registry } from '../../../backend/registry.js';
import { sendRestartSignal, sendStopSignal, isUnderSupervisor, getVncInfo } from '../../../utils/ipc.js';
/**
* 读取请求体
@@ -79,26 +80,63 @@ export function createAdminRouter(context) {
return;
}
// POST /admin/restart - 重启服务(使用子进程分离)
// POST /admin/restart - 重启服务
if (method === 'POST' && pathname === '/restart') {
const { spawn } = await import('child_process');
// 解析请求体获取重启参数
let loginMode = null;
let workerName = null;
try {
const body = await readBody(req);
loginMode = body.loginMode || null;
workerName = body.workerName || null;
} catch { /* 无请求体时使用默认值 */ }
sendJson(res, 200, { success: true, message: '服务正在重启...' });
logger.info('管理器', '收到重启请求,将在 1 秒后重启');
const modeDesc = loginMode
? (workerName ? `登录模式 (${workerName})` : '登录模式')
: '普通模式';
sendJson(res, 200, { success: true, message: `服务正在以${modeDesc}重启...` });
logger.info('管理器', `收到重启请求: ${modeDesc}`);
setTimeout(() => {
// 启动新进程(完全独立
const child = spawn(process.execPath, process.argv.slice(1), {
cwd: process.cwd(),
detached: true,
stdio: 'ignore',
env: process.env
});
child.unref();
setTimeout(async () => {
// 构建启动参数(仅登录模式相关
const extraArgs = [];
if (loginMode) {
extraArgs.push(workerName ? `-login=${workerName}` : '-login');
}
// 退出当前进程
setTimeout(() => process.exit(0), 500);
}, 1000);
if (isUnderSupervisor()) {
// Supervisor 模式:通过 IPC 发送带参数的重启信号
const sent = await sendRestartSignal(extraArgs);
if (!sent) {
logger.warn('管理器', 'IPC 重启信号发送失败,尝试自重启');
// 降级到自重启
const { spawn } = await import('child_process');
const newArgs = process.argv.slice(1).filter(arg => !arg.startsWith('-login'));
newArgs.push(...extraArgs);
const child = spawn(process.execPath, newArgs, {
cwd: process.cwd(),
detached: true,
stdio: 'ignore',
env: process.env
});
child.unref();
setTimeout(() => process.exit(0), 500);
}
} else {
// 独立模式:使用子进程自重启
const { spawn } = await import('child_process');
const newArgs = process.argv.slice(1).filter(arg => !arg.startsWith('-login'));
newArgs.push(...extraArgs);
const child = spawn(process.execPath, newArgs, {
cwd: process.cwd(),
detached: true,
stdio: 'ignore',
env: process.env
});
child.unref();
setTimeout(() => process.exit(0), 500);
}
}, 500);
return;
}
@@ -111,6 +149,23 @@ export function createAdminRouter(context) {
return;
}
// GET /admin/vnc/status - VNC 状态
if (method === 'GET' && pathname === '/vnc/status') {
const vncInfo = await getVncInfo();
if (vncInfo) {
sendJson(res, 200, vncInfo);
} else {
// 非 Supervisor 模式或无法获取信息
sendJson(res, 200, {
enabled: false,
port: 0,
display: '',
xvfbMode: false
});
}
return;
}
// ==================== 缓存与数据管理 ====================
// POST /admin/cache/clear - 清理缓存
+195
View File
@@ -0,0 +1,195 @@
/**
* @fileoverview VNC WebSocket 代理模块
* @description 将 VNC TCP 连接转发到 WebSocket
*/
import net from 'net';
import { getVncInfo } from '../../../utils/ipc.js';
/**
* 处理 VNC WebSocket 升级请求
* @param {import('http').IncomingMessage} req - HTTP 请求
* @param {import('net').Socket} socket - 原始 TCP socket
* @param {Buffer} head - 升级请求的头部数据
* @param {string} authToken - 有效的认证令牌
*/
export async function handleVncUpgrade(req, socket, head, authToken) {
const url = new URL(req.url, `http://${req.headers.host}`);
// 验证 token
const token = url.searchParams.get('token');
if (token !== authToken) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
// 获取 VNC 信息
const vncInfo = await getVncInfo();
if (!vncInfo || !vncInfo.enabled) {
socket.write('HTTP/1.1 503 Service Unavailable\r\n\r\n');
socket.destroy();
return;
}
// 手动完成 WebSocket 握手
const key = req.headers['sec-websocket-key'];
if (!key) {
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
socket.destroy();
return;
}
const crypto = await import('crypto');
const acceptKey = crypto.createHash('sha1')
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
.digest('base64');
// 发送 WebSocket 握手响应
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${acceptKey}\r\n` +
'Sec-WebSocket-Protocol: binary\r\n' +
'\r\n'
);
// 连接到 VNC 服务器
const vncSocket = net.createConnection({
host: '127.0.0.1',
port: vncInfo.port
});
vncSocket.on('error', (err) => {
console.error('[VNC Proxy] VNC 连接错误:', err.message);
socket.destroy();
});
vncSocket.on('connect', () => {
// 发送升级请求时可能附带的数据
if (head && head.length > 0) {
const data = decodeWebSocketFrame(head);
if (data) vncSocket.write(data);
}
});
// VNC -> WebSocket
vncSocket.on('data', (data) => {
try {
const frame = encodeWebSocketFrame(data);
socket.write(frame);
} catch {
socket.destroy();
}
});
// WebSocket -> VNC
let buffer = Buffer.alloc(0);
socket.on('data', (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length >= 2) {
const result = decodeWebSocketFrame(buffer);
if (!result) break;
const { data, bytesConsumed, opcode } = result;
// 关闭帧
if (opcode === 0x08) {
vncSocket.destroy();
socket.destroy();
return;
}
// 二进制数据或文本
if (data && data.length > 0) {
vncSocket.write(data);
}
buffer = buffer.slice(bytesConsumed);
}
});
socket.on('close', () => vncSocket.destroy());
socket.on('error', () => vncSocket.destroy());
vncSocket.on('close', () => socket.destroy());
}
/**
* 编码 WebSocket 帧(服务端发送,无掩码)
* @param {Buffer} data - 要发送的数据
* @returns {Buffer} WebSocket 帧
*/
function encodeWebSocketFrame(data) {
const length = data.length;
let header;
if (length <= 125) {
header = Buffer.alloc(2);
header[0] = 0x82; // FIN + Binary
header[1] = length;
} else if (length <= 65535) {
header = Buffer.alloc(4);
header[0] = 0x82;
header[1] = 126;
header.writeUInt16BE(length, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x82;
header[1] = 127;
header.writeBigUInt64BE(BigInt(length), 2);
}
return Buffer.concat([header, data]);
}
/**
* 解码 WebSocket 帧(客户端发送,有掩码)
* @param {Buffer} buffer - 接收到的数据
* @returns {{data: Buffer, bytesConsumed: number, opcode: number} | null}
*/
function decodeWebSocketFrame(buffer) {
if (buffer.length < 2) return null;
const firstByte = buffer[0];
const secondByte = buffer[1];
const opcode = firstByte & 0x0F;
const masked = (secondByte & 0x80) !== 0;
let payloadLength = secondByte & 0x7F;
let offset = 2;
if (payloadLength === 126) {
if (buffer.length < 4) return null;
payloadLength = buffer.readUInt16BE(2);
offset = 4;
} else if (payloadLength === 127) {
if (buffer.length < 10) return null;
payloadLength = Number(buffer.readBigUInt64BE(2));
offset = 10;
}
let maskKey = null;
if (masked) {
if (buffer.length < offset + 4) return null;
maskKey = buffer.slice(offset, offset + 4);
offset += 4;
}
if (buffer.length < offset + payloadLength) return null;
let data = buffer.slice(offset, offset + payloadLength);
if (masked && maskKey) {
data = Buffer.from(data);
for (let i = 0; i < data.length; i++) {
data[i] ^= maskKey[i % 4];
}
}
return {
data,
bytesConsumed: offset + payloadLength,
opcode
};
}
+120
View File
@@ -0,0 +1,120 @@
/**
* @fileoverview API 路由总装配
* @description 统一挂载 /v1 和 /admin 路由
*/
import fs from 'fs';
import path from 'path';
import { createOpenAIRouter } from './openai/routes.js';
import { createAdminRouter } from './admin/routes.js';
import { createAuthMiddleware } from '../middlewares/auth.js';
// MIME 类型映射
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf'
};
// WebUI 静态文件目录
const WEBUI_DIR = path.join(process.cwd(), 'webui', 'dist');
/**
* 创建全局路由处理器
* @param {object} context - 路由上下文
* @param {boolean} [context.loginMode] - 登录模式(禁用 OpenAI API
* @returns {Function} 请求处理函数
*/
export function createGlobalRouter(context) {
const { authToken, config, queueManager, tempDir, loginMode } = context;
// 创建鉴权中间件
const checkAuth = createAuthMiddleware(authToken);
// 创建子路由处理器
const handleOpenAIRequest = loginMode ? null : createOpenAIRouter(context);
const handleAdminRequest = createAdminRouter({ config, queueManager, tempDir });
/**
* 主路由处理函数
*/
return async function handleRequest(req, res) {
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
const pathname = parsedUrl.pathname;
// ==================== 静态文件服务 ====================
if (req.method === 'GET' && !pathname.startsWith('/v1') && !pathname.startsWith('/admin')) {
let filePath = pathname === '/' ? '/index.html' : pathname;
filePath = path.join(WEBUI_DIR, filePath);
// 安全检查
if (!filePath.startsWith(WEBUI_DIR)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
// 检查文件是否存在
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
const content = fs.readFileSync(filePath);
res.writeHead(200, { 'Content-Type': contentType });
res.end(content);
return;
}
// SPA 模式 fallback
const indexPath = path.join(WEBUI_DIR, 'index.html');
if (fs.existsSync(indexPath)) {
const content = fs.readFileSync(indexPath);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(content);
return;
}
}
// ==================== 鉴权检查 ====================
if (!checkAuth(req, res)) {
return; // 鉴权失败,已发送错误响应
}
// ==================== API 路由分发 ====================
// Admin API (/admin)
if (pathname.startsWith('/admin')) {
const adminPath = pathname.slice(6); // 去除 /admin 前缀
await handleAdminRequest(req, res, adminPath);
return;
}
// OpenAI API (/v1)
if (pathname.startsWith('/v1')) {
// 登录模式下禁用 OpenAI API
if (!handleOpenAIRequest) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: { message: '服务运行在登录模式,OpenAI API 不可用', type: 'service_unavailable' }
}));
return;
}
const v1Path = pathname.slice(3); // 去除 /v1 前缀
await handleOpenAIRequest(req, res, v1Path, parsedUrl);
return;
}
// 404
res.writeHead(404);
res.end();
};
}
@@ -6,8 +6,8 @@
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
import { IMAGE_POLICY } from '../backend/registry.js';
import { ERROR_CODES, getErrorMessage } from './errors.js';
import { IMAGE_POLICY } from '../../../backend/registry.js';
import { ERROR_CODES, getErrorMessage } from '../../errors.js';
/**
* 构造解析错误结果
+174
View File
@@ -0,0 +1,174 @@
/**
* @fileoverview OpenAI 兼容 API 路由
* @description 处理 /v1 路径下的所有 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 './parse.js';
/**
* 创建 OpenAI API 路由处理器
* @param {object} context - 路由上下文
* @returns {Function} 路由处理函数
*/
export function createOpenAIRouter(context) {
const {
backendName,
getModels,
resolveModelId,
getImagePolicy,
getModelType,
tempDir,
imageLimit,
queueManager
} = context;
/**
* 处理 GET /v1/models
*/
function handleModels(res) {
const models = getModels();
sendJson(res, 200, models);
}
/**
* 处理 GET /v1/cookies
*/
async function handleCookies(res, requestId, workerName, domain) {
const poolContext = queueManager.getPoolContext();
if (!poolContext?.poolManager) {
sendApiError(res, { code: ERROR_CODES.BROWSER_NOT_INITIALIZED });
return;
}
try {
const result = await queueManager.getWorkerCookies(workerName, domain);
sendJson(res, 200, {
worker: result.worker,
cookies: result.cookies
});
} catch (err) {
logger.error('服务器', '获取 Cookies 失败', { id: requestId, error: err.message });
if (err.message.includes('Worker 不存在') || err.message.includes('Worker not found')) {
sendApiError(res, {
code: ERROR_CODES.INVALID_MODEL,
message: err.message
});
} else {
sendApiError(res, {
code: ERROR_CODES.INTERNAL_ERROR,
message: err.message
});
}
}
}
/**
* 处理 POST /v1/chat/completions
*/
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,
message: `服务器繁忙(队列: ${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,
getModelType,
requestId,
logger
});
if (!parseResult.success) {
sendApiError(res, {
code: parseResult.error.code,
message: 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,
message: err.message
});
}
}
/**
* OpenAI API 路由处理函数
* @param {import('http').IncomingMessage} req
* @param {import('http').ServerResponse} res
* @param {string} pathname - 去除 /v1 前缀后的路径
* @param {URL} parsedUrl - 解析后的 URL 对象
*/
return async function handleOpenAIRequest(req, res, pathname, parsedUrl) {
const requestId = crypto.randomUUID().slice(0, 8);
if (req.method === 'GET' && pathname === '/models') {
handleModels(res);
} else if (req.method === 'GET' && pathname === '/cookies') {
const workerName = parsedUrl.searchParams.get('name');
const domain = parsedUrl.searchParams.get('domain');
await handleCookies(res, requestId, workerName, domain);
} else if (req.method === 'POST' && pathname.startsWith('/chat/completions')) {
await handleChatCompletions(req, res, requestId);
} else {
res.writeHead(404);
res.end();
}
};
}
-213
View File
@@ -1,213 +0,0 @@
/**
* @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';
}
}
-282
View File
@@ -1,282 +0,0 @@
/**
* @fileoverview HTTP 路由分发模块
* @description 处理 API 路由分发和请求鉴权
*/
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { logger } from '../../utils/logger.js';
import { ERROR_CODES } from '../errors.js';
import { sendJson, sendApiError } from './respond.js';
import { parseRequest } from '../parseChat.js';
import { createAdminRouter } from './adminRoutes.js';
// MIME 类型映射
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf'
};
// WebUI 静态文件目录
const WEBUI_DIR = path.join(process.cwd(), 'webui');
/**
* 鉴权检查
* @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 {Function} context.getModelType - 获取模型类型函数
* @param {string} context.tempDir - 临时目录
* @param {number} context.imageLimit - 图片数量限制
* @param {object} context.queueManager - 队列管理器
* @param {object} context.config - 完整配置对象(用于 Admin API)
* @returns {Function} 请求处理函数
*/
export function createRouter(context) {
const {
authToken,
backendName,
getModels,
resolveModelId,
getImagePolicy,
getModelType,
tempDir,
imageLimit,
queueManager,
config
} = context;
// 创建 Admin 路由处理器
const handleAdminRequest = createAdminRouter({ config, queueManager, tempDir });
/**
* 处理 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
* @param {string} [workerName] - 可选,指定 Worker 名称
* @param {string} [domain] - 可选,指定获取某个域名的 Cookies
*/
async function handleCookies(res, requestId, workerName, domain) {
const poolContext = queueManager.getPoolContext();
if (!poolContext?.poolManager) {
sendApiError(res, { code: ERROR_CODES.BROWSER_NOT_INITIALIZED });
return;
}
try {
const result = await queueManager.getWorkerCookies(workerName, domain);
sendJson(res, 200, {
worker: result.worker,
cookies: result.cookies
});
} catch (err) {
logger.error('服务器', '获取 Cookies 失败', { id: requestId, error: err.message });
// 区分错误类型
if (err.message.includes('Worker 不存在') || err.message.includes('Worker not found')) {
sendApiError(res, {
code: ERROR_CODES.INVALID_MODEL,
message: err.message
});
} else {
sendApiError(res, {
code: ERROR_CODES.INTERNAL_ERROR,
message: 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,
message: `服务器繁忙(队列: ${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,
getModelType,
requestId,
logger
});
if (!parseResult.success) {
sendApiError(res, {
code: parseResult.error.code,
message: 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,
message: 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);
// 路由分发
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
const pathname = parsedUrl.pathname;
// WebUI 静态文件服务(无需鉴权)
if (req.method === 'GET' && !pathname.startsWith('/v1') && !pathname.startsWith('/admin')) {
// 处理根路径
let filePath = pathname === '/' ? '/index.html' : pathname;
filePath = path.join(WEBUI_DIR, filePath);
// 安全检查:确保不越级访问
if (!filePath.startsWith(WEBUI_DIR)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
// 检查文件是否存在
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
const content = fs.readFileSync(filePath);
res.writeHead(200, { 'Content-Type': contentType });
res.end(content);
return;
}
// 文件不存在,返回 index.htmlSPA 模式)
const indexPath = path.join(WEBUI_DIR, 'index.html');
if (fs.existsSync(indexPath)) {
const content = fs.readFileSync(indexPath);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(content);
return;
}
}
// 鉴权检查(API 请求)
if (!checkAuth(req, authToken)) {
sendApiError(res, { code: ERROR_CODES.UNAUTHORIZED });
return;
}
// Admin API 路由
if (pathname.startsWith('/admin')) {
const adminPath = pathname.slice(6); // 去除 /admin 前缀
await handleAdminRequest(req, res, adminPath);
return;
}
if (req.method === 'GET' && pathname === '/v1/models') {
handleModels(res);
} else if (req.method === 'GET' && pathname === '/v1/cookies') {
const workerName = parsedUrl.searchParams.get('name');
const domain = parsedUrl.searchParams.get('domain');
await handleCookies(res, requestId, workerName, domain);
} else if (req.method === 'POST' && pathname.startsWith('/v1/chat/completions')) {
await handleChatCompletions(req, res, requestId);
} else {
res.writeHead(404);
res.end();
}
};
}
+6 -4
View File
@@ -12,8 +12,10 @@ export {
sendApiError,
buildChatCompletion,
buildChatCompletionChunk
} from './http/respond.js';
export { handleDisplayParams } from './display.js';
} from './respond.js';
export { createQueueManager } from './queue.js';
export { parseRequest } from './parseChat.js';
export { createRouter } from './http/routes.js';
export { parseRequest } from './api/openai/parse.js';
export { createGlobalRouter } from './api/index.js';
export { createAuthMiddleware } from './middlewares/auth.js';
+39
View File
@@ -0,0 +1,39 @@
/**
* @fileoverview 鉴权中间件
* @description 提取自 routes.js 的鉴权逻辑
*/
import { sendApiError } from '../respond.js';
import { ERROR_CODES } from '../errors.js';
/**
* 鉴权检查
* @param {import('http').IncomingMessage} req - HTTP 请求
* @param {string} authToken - 有效的认证令牌
* @returns {boolean} 是否通过鉴权
*/
export function checkAuth(req, authToken) {
const authHeader = req.headers['authorization'];
return authHeader === `Bearer ${authToken}`;
}
/**
* 创建鉴权中间件
* @param {string} authToken - 认证令牌
* @returns {Function} 中间件函数
*/
export function createAuthMiddleware(authToken) {
/**
* 鉴权中间件
* @param {import('http').IncomingMessage} req
* @param {import('http').ServerResponse} res
* @returns {boolean} 是否通过鉴权
*/
return function authMiddleware(req, res) {
if (!checkAuth(req, authToken)) {
sendApiError(res, { code: ERROR_CODES.UNAUTHORIZED });
return false;
}
return true;
};
}
+1 -1
View File
@@ -12,7 +12,7 @@ import {
sendApiError,
buildChatCompletion,
buildChatCompletionChunk
} from './http/respond.js';
} from './respond.js';
import { ERROR_CODES } from './errors.js';
/**
@@ -3,7 +3,7 @@
* @description 封装 JSONSSE 响应和错误响应的统一处理函数
*/
import { getErrorDetails } from '../errors.js';
import { getErrorDetails } from './errors.js';
/**
* 发送 JSON 响应
+168
View File
@@ -0,0 +1,168 @@
/**
* @fileoverview LMArena Image Automator 服务器入口
* @description HTTP API 服务器,提供 OpenAI 兼容的图像生成接口
*
* 支持的端点:
* - GET /v1/models - 获取可用模型列表
* - GET /v1/cookies - 获取当前浏览器 Cookies
* - POST /v1/chat/completions - 生成图像(OpenAI 兼容格式)
*
* 启动方式:
* - 通过 supervisor.js 启动(推荐,支持自动重启和 Xvfb 管理)
* - 直接运行 node server.js
*
* 命令行参数:
* - -login 启动时打开登录页面
*/
import http from 'http';
// ==================== 启动前自检 ====================
import { runPreflight } from '../utils/preflight.js';
runPreflight();
// ==================== 加载其他依赖 ====================
const { getBackend } = await import('../backend/index.js');
const { logger } = await import('../utils/logger.js');
const { createQueueManager, createGlobalRouter } = await import('./index.js');
const { isUnderSupervisor } = await import('../utils/ipc.js');
// ==================== 初始化配置 ====================
/**
* 从统一后端获取配置和函数
*/
let backend;
try {
backend = getBackend();
} catch (err) {
logger.error('服务器', '配置加载失败', { error: err.message });
logger.error('服务器', '请先初始化配置:复制 config.example.yaml 为 config.yaml');
process.exit(1);
}
const {
config,
name: backendName,
initBrowser,
generate,
TEMP_DIR,
resolveModelId,
getModels,
getImagePolicy,
getModelType
} = backend;
/** @type {number} 服务器端口 */
const PORT = config.server?.port || 3000;
/** @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} 队列缓冲区(0 表示不限制非流式) */
const QUEUE_BUFFER = config.queue?.queueBuffer ?? 2;
/** @type {number} 图片数量限制 */
const IMAGE_LIMIT = config.queue?.imageLimit || 5;
// ==================== 创建服务组件 ====================
/**
* 队列管理器:负责任务队列、并发控制和心跳机制
*/
const queueManager = createQueueManager(
{
maxConcurrent: MAX_CONCURRENT,
queueBuffer: QUEUE_BUFFER,
keepaliveMode: KEEPALIVE_MODE
},
{
initBrowser,
generate,
config,
navigateToMonitor: backend.navigateToMonitor
? () => backend.navigateToMonitor()
: null,
getCookies: backend.getCookies
? (workerName, domain) => backend.getCookies(workerName, domain)
: null
}
);
// ==================== 创建路由 ====================
/**
* 检测是否为登录模式
*/
const isLoginMode = process.argv.some(arg => arg.startsWith('-login'));
const handleRequest = createGlobalRouter({
authToken: AUTH_TOKEN,
backendName,
getModels,
resolveModelId,
getImagePolicy,
getModelType,
tempDir: TEMP_DIR,
imageLimit: IMAGE_LIMIT,
queueManager,
config,
loginMode: isLoginMode
});
// ==================== 启动服务器 ====================
/**
* 启动 HTTP 服务器
* @returns {Promise<void>}
*/
async function startServer() {
// 登录模式提示
if (isLoginMode) {
logger.info('服务器', '登录模式已就绪,请在浏览器中完成登录操作');
logger.info('服务器', '完成后可直接关闭浏览器窗口或按 Ctrl+C 退出');
}
// 预先启动 Pool
try {
await queueManager.initializePool();
} catch (err) {
logger.error('服务器', 'Pool 初始化失败', { error: err.message });
process.exit(1);
}
// 创建并启动 HTTP 服务器
const server = http.createServer(handleRequest);
// 处理 WebSocket 升级请求(VNC 代理)
server.on('upgrade', async (req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
// 只处理 /admin/vnc 路径
if (url.pathname === '/admin/vnc') {
const { handleVncUpgrade } = await import('./api/admin/vncProxy.js');
await handleVncUpgrade(req, socket, head, AUTH_TOKEN);
} else {
socket.destroy();
}
});
server.listen(PORT, () => {
const mode = isUnderSupervisor() ? 'Supervisor 托管' : '独立运行';
const modeExtra = isLoginMode ? ' (登录模式)' : '';
logger.info('服务器', `HTTP 服务器已启动,端口: ${PORT}${modeExtra}`);
logger.info('服务器', `运行模式: ${mode}`);
if (!isLoginMode) {
logger.info('服务器', `流式心跳模式: ${KEEPALIVE_MODE}`);
logger.info('服务器', `最大并发: ${MAX_CONCURRENT},队列缓冲: ${QUEUE_BUFFER},最大图片数量: ${IMAGE_LIMIT}`);
}
});
}
// 启动服务器
startServer();