mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
refactor: 代码解耦合,完善WebUI功能,添加网页VNC
This commit is contained in:
@@ -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 - 清理缓存
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* 构造解析错误结果
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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.html(SPA 模式)
|
||||
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
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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 封装 JSON、SSE 响应和错误响应的统一处理函数
|
||||
*/
|
||||
|
||||
import { getErrorDetails } from '../errors.js';
|
||||
import { getErrorDetails } from './errors.js';
|
||||
|
||||
/**
|
||||
* 发送 JSON 响应
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user