feat: 为项目添加 WebUI

This commit is contained in:
foxhui
2025-12-19 04:43:59 +08:00
parent 1ccaa55608
commit 41e3e022a3
33 changed files with 1842 additions and 40 deletions
+2 -2
View File
@@ -29,7 +29,7 @@ const TARGET_URL = 'https://gemini.google.com/app?hl=en';
* @param {object} [meta={}] - 日志元数据
* @returns {Promise<{image?: string, error?: string}>}
*/
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
const { page } = context;
const inputLocator = page.getByRole('textbox');
const sendBtnLocator = page.getByRole('button', { name: 'Send message' });
@@ -192,5 +192,5 @@ export const manifest = {
navigationHandlers: [],
// 核心生图方法
generateImage
generate
};
+13 -2
View File
@@ -97,7 +97,7 @@ async function handleAccountChooser(page) {
* @param {string} modelId - 模型 ID (目前未使用,固定为 gemini-3-pro-preview)
* @returns {Promise<{image?: string, error?: string}>} 生成结果
*/
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
const { page, config } = context;
try {
@@ -266,6 +266,17 @@ export const manifest = {
id: 'gemini_biz',
displayName: 'Gemini Business',
// 配置表单定义
configSchema: [
{
key: 'entryUrl',
label: '入口 URL',
type: 'string',
required: true,
placeholder: 'https://business.gemini.google/home/cid/8888a888-b6e0-88be-86e1-888cf3ee8cf4'
}
],
// 入口 URL (从配置读取,支持新旧路径)
getTargetUrl(config, workerConfig) {
return config?.backend?.adapter?.gemini_biz?.entryUrl || config?.backend?.geminiBiz?.entryUrl || null;
@@ -286,5 +297,5 @@ export const manifest = {
navigationHandlers: [handleAccountChooser],
// 核心生图方法
generateImage
generate
};
+15 -3
View File
@@ -97,7 +97,7 @@ async function handleAccountChooser(page) {
* @param {string} modelId - 模型 ID (目前未使用,固定为 gemini-3-pro-preview)
* @returns {Promise<{image?: string, error?: string}>} 生成结果
*/
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
const { page, config } = context;
try {
@@ -309,7 +309,19 @@ export const manifest = {
id: 'gemini_biz_text',
displayName: 'Gemini Business (Text)',
// 入口 URL (从配置读取,支持新旧路径)
// 配置表单定义(与 gemini_biz 共享配置)
configSchema: [
{
key: 'entryUrl',
label: '入口 URL',
type: 'string',
required: true,
placeholder: 'https://business.gemini.google/home/cid/8888a888-b6e0-88be-86e1-888cf3ee8cf4',
note: '与 gemini_biz 共享配置'
}
],
// 入口 URL (从配置读取,与 gemini_biz 共享)
getTargetUrl(config, workerConfig) {
return config?.backend?.adapter?.gemini_biz?.entryUrl || config?.backend?.geminiBiz?.entryUrl || null;
},
@@ -334,5 +346,5 @@ export const manifest = {
navigationHandlers: [handleAccountChooser],
// 核心生图方法
generateImage
generate
};
+2 -2
View File
@@ -52,7 +52,7 @@ function extractImage(text) {
* @param {object} [meta={}] - 日志元数据
* @returns {Promise<{image?: string, text?: string, error?: string}>} 生成结果
*/
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
const { page, config } = context;
const textareaSelector = 'textarea';
@@ -222,5 +222,5 @@ export const manifest = {
navigationHandlers: [],
// 核心生图方法
generateImage
generate
};
+2 -2
View File
@@ -33,7 +33,7 @@ const TARGET_URL_SEARCH = 'https://lmarena.ai/zh/c/new?mode=direct&chat-modality
* @param {object} [meta={}] - 日志元数据
* @returns {Promise<{image?: string, text?: string, error?: string}>} 生成结果
*/
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
const { page, config } = context;
const textareaSelector = 'textarea';
@@ -305,5 +305,5 @@ export const manifest = {
navigationHandlers: [],
// 核心生图方法
generateImage
generate
};
+2 -2
View File
@@ -32,7 +32,7 @@ const TARGET_URL = 'https://nanobananafree.ai/';
* @param {object} [meta={}] - 日志元数据
* @returns {Promise<{image?: string, text?: string, error?: string}>} 生成结果
*/
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
const { page } = context;
const textareaSelector = 'textarea';
@@ -157,5 +157,5 @@ export const manifest = {
navigationHandlers: [],
// 核心生图方法
generateImage
generate
};
+2 -2
View File
@@ -40,7 +40,7 @@ async function findElementWithShadowRoot(hostHandle) {
/**
* 执行 Turnstile 验证任务
*/
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
const { page } = context;
try {
@@ -212,5 +212,5 @@ export const manifest = {
},
navigationHandlers: [],
generateImage
generate
};
+2 -2
View File
@@ -119,7 +119,7 @@ async function handleDiscordAuth(page) {
* @param {object} meta - 日志元数据
* @returns {Promise<{image?: string, error?: string}>} 生成结果
*/
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
const { page, config } = context;
try {
@@ -371,5 +371,5 @@ export const manifest = {
navigationHandlers: [handleDiscordAuth],
// 核心生图方法
generateImage
generate
};
+3 -3
View File
@@ -4,7 +4,7 @@
*
* 对外统一能力:
* - `initBrowser(cfg)` → 初始化 Pool
* - `generateImage(ctx, prompt, imagePaths, modelId, meta)`
* - `generate(ctx, prompt, imagePaths, modelId, meta)`
* - `resolveModelId(modelKey)` / `getModels()` / `getImagePolicy(modelKey)`
* - `getCookies(workerName, domain)` - 获取指定 Worker 的 Cookies
*/
@@ -67,11 +67,11 @@ export function getBackend() {
* @param {string} modelId - 模型 ID
* @param {object} meta - 元信息
*/
generateImage: async (ctx, prompt, paths, modelId, meta) => {
generate: async (ctx, prompt, paths, modelId, meta) => {
if (!poolManager) {
return { error: 'Pool 未初始化' };
}
return await poolManager.generateImage(ctx, prompt, paths, modelId, meta);
return await poolManager.generate(ctx, prompt, paths, modelId, meta);
},
/**
+2 -2
View File
@@ -156,7 +156,7 @@ export class PoolManager {
/**
* 分发生图任务(支持故障转移)
*/
async generateImage(ctx, prompt, paths, modelId, meta) {
async generate(ctx, prompt, paths, modelId, meta) {
const failoverConfig = this.config.backend?.pool?.failover || {};
const failoverEnabled = failoverConfig.enabled !== false;
const maxRetries = failoverConfig.maxRetries || 2;
@@ -213,7 +213,7 @@ export class PoolManager {
*/
async _safeExecuteWorker(worker, ctx, prompt, paths, modelId, meta) {
try {
return await worker.generateImage(ctx, prompt, paths, modelId, meta);
return await worker.generate(ctx, prompt, paths, modelId, meta);
} catch (err) {
logger.error('工作池', `[${worker.name}] 执行异常`, { error: err.message, ...meta });
return normalizeError(err.message || '执行异常');
+4 -4
View File
@@ -240,12 +240,12 @@ export class Worker {
/**
* 生成图片
*/
async generateImage(ctx, prompt, paths, modelId, meta) {
async generate(ctx, prompt, paths, modelId, meta) {
const failoverConfig = this.globalConfig.backend?.pool?.failover || {};
const failoverEnabled = failoverConfig.enabled !== false;
if (this.type === 'merge' && failoverEnabled) {
return this._generateImageWithFailover(ctx, prompt, paths, modelId, meta, failoverConfig);
return this._generateWithFailover(ctx, prompt, paths, modelId, meta, failoverConfig);
}
const resolved = this.resolveModelId(modelId);
@@ -261,7 +261,7 @@ export class Worker {
* Merge 模式下的故障转移生成
* @private
*/
async _generateImageWithFailover(ctx, prompt, paths, modelId, meta, failoverConfig = {}) {
async _generateWithFailover(ctx, prompt, paths, modelId, meta, failoverConfig = {}) {
const maxRetries = failoverConfig.maxRetries || 2;
const candidateTypes = this._getCandidateTypes(modelId);
@@ -339,7 +339,7 @@ export class Worker {
this.busyCount++;
try {
return await adapter.generateImage(subContext, prompt, paths, realId, meta);
return await adapter.generate(subContext, prompt, paths, realId, meta);
} finally {
this.busyCount--;
}
+2 -2
View File
@@ -89,8 +89,8 @@ class AdapterRegistry {
errors.push('缺少 id 或类型不正确');
}
if (!manifest.generateImage || typeof manifest.generateImage !== 'function') {
errors.push('缺少 generateImage 函数');
if (!manifest.generate || typeof manifest.generate !== 'function') {
errors.push('缺少 generate 函数');
}
if (!manifest.models || !Array.isArray(manifest.models)) {
+356
View File
@@ -0,0 +1,356 @@
/**
* @fileoverview Admin API 路由模块
* @description 提供管理接口,包括系统状态、配置管理、适配器元数据等
*/
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';
import {
getServerConfig,
saveServerConfig,
getBrowserConfig,
saveBrowserConfig,
getQueueConfig,
saveQueueConfig,
getInstancesConfig,
saveInstancesConfig,
getAdaptersConfig,
saveAdaptersConfig,
getPoolConfig,
savePoolConfig
} from '../../utils/configManager.js';
import {
validateServerConfig,
validateBrowserConfig,
validateInstancesConfig,
validatePoolConfig,
validateAdaptersConfig
} from '../../utils/configValidator.js';
import { registry } from '../../backend/registry.js';
/**
* 读取请求体
* @param {import('http').IncomingMessage} req
* @returns {Promise<object>}
*/
async function readBody(req) {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const body = Buffer.concat(chunks).toString();
return body ? JSON.parse(body) : {};
}
/**
* 创建 Admin 路由处理器
* @param {object} context - 路由上下文
* @param {object} context.config - 完整配置对象
* @param {object} context.queueManager - 队列管理器
* @param {string} context.tempDir - 临时目录
* @returns {Function} Admin 路由处理函数
*/
export function createAdminRouter(context) {
const { config, queueManager, tempDir } = context;
/**
* Admin 路由处理函数
* @param {import('http').IncomingMessage} req
* @param {import('http').ServerResponse} res
* @param {string} pathname - 去除 /admin 前缀后的路径
*/
return async function handleAdminRequest(req, res, pathname) {
const method = req.method;
try {
// ==================== 系统管理 ====================
// GET /admin/status - 系统状态
if (method === 'GET' && pathname === '/status') {
const status = getSystemStatus();
sendJson(res, 200, status);
return;
}
// POST /admin/restart - 重启服务(使用子进程分离)
if (method === 'POST' && pathname === '/restart') {
const { spawn } = await import('child_process');
sendJson(res, 200, { success: true, message: '服务正在重启...' });
logger.info('管理器', '收到重启请求,将在 1 秒后重启');
setTimeout(() => {
// 启动新进程(完全独立)
const child = spawn(process.execPath, process.argv.slice(1), {
cwd: process.cwd(),
detached: true,
stdio: 'ignore',
env: process.env
});
child.unref();
// 退出当前进程
setTimeout(() => process.exit(0), 500);
}, 1000);
return;
}
// POST /admin/stop - 停止服务
if (method === 'POST' && pathname === '/stop') {
sendJson(res, 200, { success: true, message: '服务正在停止...' });
logger.info('管理器', '收到停止请求,将在 1 秒后退出');
setTimeout(() => process.exit(0), 1000);
return;
}
// ==================== 缓存与数据管理 ====================
// POST /admin/cache/clear - 清理缓存
if (method === 'POST' && pathname === '/cache/clear') {
const result = clearTempFiles(tempDir);
sendJson(res, 200, { success: true, cleaned: result.cleaned });
return;
}
// GET /admin/data-folders - 列出数据文件夹
if (method === 'GET' && pathname === '/data-folders') {
const workers = config.backend?.pool?.workers || [];
const folders = getDataFolders(workers);
sendJson(res, 200, folders);
return;
}
// POST /admin/data-folders/delete - 删除数据文件夹
if (method === 'POST' && pathname === '/data-folders/delete') {
const body = await readBody(req);
if (!body.folders || !Array.isArray(body.folders)) {
sendApiError(res, { code: ERROR_CODES.INVALID_REQUEST_BODY, message: '缺少 folders 数组' });
return;
}
const workers = config.backend?.pool?.workers || [];
const result = deleteDataFolders(body.folders, workers);
if (result.errors.length > 0) {
sendJson(res, 207, result); // 207 Multi-Status
} else {
sendJson(res, 200, result);
}
return;
}
// ==================== 配置管理 ====================
// GET/POST /admin/config/server
if (pathname === '/config/server') {
if (method === 'GET') {
const serverConfig = getServerConfig();
const queueConfig = getQueueConfig();
sendJson(res, 200, {
...serverConfig,
queueBuffer: queueConfig.queueBuffer,
imageLimit: queueConfig.imageLimit
});
} else if (method === 'POST') {
const body = await readBody(req);
// 校验配置
const validation = validateServerConfig(body);
if (!validation.valid) {
sendApiError(res, {
code: ERROR_CODES.INVALID_REQUEST_BODY,
message: `配置校验失败: ${validation.errors.join('; ')}`
});
return;
}
// 分别保存 server 和 queue 配置
saveServerConfig(body);
if (body.queueBuffer !== undefined || body.imageLimit !== undefined) {
saveQueueConfig(body);
}
sendJson(res, 200, { success: true, message: '配置已保存,请重启服务生效' });
} else {
res.writeHead(405);
res.end();
}
return;
}
// GET/POST /admin/config/browser
if (pathname === '/config/browser') {
if (method === 'GET') {
sendJson(res, 200, getBrowserConfig());
} else if (method === 'POST') {
const body = await readBody(req);
// 校验配置
const validation = validateBrowserConfig(body);
if (!validation.valid) {
sendApiError(res, {
code: ERROR_CODES.INVALID_REQUEST_BODY,
message: `配置校验失败: ${validation.errors.join('; ')}`
});
return;
}
saveBrowserConfig(body);
sendJson(res, 200, { success: true, message: '配置已保存,请重启服务生效' });
} else {
res.writeHead(405);
res.end();
}
return;
}
// GET/POST /admin/config/instances (对应原设计的 workers)
if (pathname === '/config/instances' || pathname === '/config/workers') {
if (method === 'GET') {
sendJson(res, 200, getInstancesConfig());
} else if (method === 'POST') {
const body = await readBody(req);
// 校验配置(包括 Instance/Worker 名称唯一性)
const validation = validateInstancesConfig(body);
if (!validation.valid) {
sendApiError(res, {
code: ERROR_CODES.INVALID_REQUEST_BODY,
message: `配置校验失败: ${validation.errors.join('; ')}`
});
return;
}
saveInstancesConfig(body);
sendJson(res, 200, { success: true, message: '配置已保存,请重启服务生效' });
} else {
res.writeHead(405);
res.end();
}
return;
}
// GET/POST /admin/config/adapters
if (pathname === '/config/adapters') {
if (method === 'GET') {
sendJson(res, 200, getAdaptersConfig());
} else if (method === 'POST') {
const body = await readBody(req);
// 校验配置
const validation = validateAdaptersConfig(body);
if (!validation.valid) {
sendApiError(res, {
code: ERROR_CODES.INVALID_REQUEST_BODY,
message: `配置校验失败: ${validation.errors.join('; ')}`
});
return;
}
saveAdaptersConfig(body);
sendJson(res, 200, { success: true, message: '配置已保存,请重启服务生效' });
} else {
res.writeHead(405);
res.end();
}
return;
}
// GET/POST /admin/config/pool - 负载均衡和故障转移配置
if (pathname === '/config/pool') {
if (method === 'GET') {
sendJson(res, 200, getPoolConfig());
} else if (method === 'POST') {
const body = await readBody(req);
// 校验配置
const validation = validatePoolConfig(body);
if (!validation.valid) {
sendApiError(res, {
code: ERROR_CODES.INVALID_REQUEST_BODY,
message: `配置校验失败: ${validation.errors.join('; ')}`
});
return;
}
savePoolConfig(body);
sendJson(res, 200, { success: true, message: '配置已保存,请重启服务生效' });
} else {
res.writeHead(405);
res.end();
}
return;
}
// ==================== 元数据 ====================
// GET /admin/adapters - 获取适配器列表(含 configSchema
if (method === 'GET' && pathname === '/adapters') {
const adapters = [];
const adapterIds = registry.getAdapterIds();
for (const id of adapterIds) {
const adapter = registry.getAdapter(id);
if (adapter) {
adapters.push({
id: adapter.id,
displayName: adapter.displayName || adapter.id,
modelCount: adapter.models?.length || 0,
models: (adapter.models || []).map(m => m.id),
configSchema: adapter.configSchema || []
});
}
}
sendJson(res, 200, adapters);
return;
}
// ==================== 统计与监控 ====================
// GET /admin/stats - 基本统计
if (method === 'GET' && pathname === '/stats') {
const instances = config.backend?.pool?.instances || [];
const workers = config.backend?.pool?.workers || [];
sendJson(res, 200, {
instances: instances.length,
workers: workers.length
});
return;
}
// GET /admin/queue - 任务队列状态
if (method === 'GET' && pathname === '/queue') {
const queueStatus = queueManager.getStatus();
const detailedStatus = queueManager.getDetailedStatus();
sendJson(res, 200, {
processing: queueStatus.processing,
waiting: queueStatus.queueLength,
total: queueStatus.total,
processingTasks: detailedStatus.processing,
waitingTasks: detailedStatus.waiting
});
return;
}
// 404
res.writeHead(404);
res.end(JSON.stringify({ error: 'Not Found' }));
} catch (err) {
logger.error('管理器', `请求处理失败: ${err.message}`);
sendApiError(res, {
code: ERROR_CODES.INTERNAL_ERROR,
message: err.message
});
}
};
}
+73 -5
View File
@@ -4,10 +4,33 @@
*/
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');
/**
* 鉴权检查
@@ -32,6 +55,7 @@ function checkAuth(req, authToken) {
* @param {string} context.tempDir - 临时目录
* @param {number} context.imageLimit - 图片数量限制
* @param {object} context.queueManager - 队列管理器
* @param {object} context.config - 完整配置对象(用于 Admin API)
* @returns {Function} 请求处理函数
*/
export function createRouter(context) {
@@ -44,9 +68,13 @@ export function createRouter(context) {
getModelType,
tempDir,
imageLimit,
queueManager
queueManager,
config
} = context;
// 创建 Admin 路由处理器
const handleAdminRequest = createAdminRouter({ config, queueManager, tempDir });
/**
* 处理 GET /v1/models
* @param {import('http').ServerResponse} res - HTTP 响应
@@ -188,15 +216,55 @@ export function createRouter(context) {
// 生成请求 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;
}
// 路由分发
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
const pathname = parsedUrl.pathname;
// 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);
+30 -3
View File
@@ -45,7 +45,7 @@ import { ERROR_CODES } from './errors.js';
* @param {QueueConfig} queueConfig - 队列配置
* @param {object} callbacks - 回调函数
* @param {Function} callbacks.initBrowser - 初始化 Pool 函数
* @param {Function} callbacks.generateImage - 生成图片函数
* @param {Function} callbacks.generate - 生成图片函数
* @param {object} callbacks.config - 配置对象
* @param {Function} [callbacks.navigateToMonitor] - 监控导航函数
* @param {Function} [callbacks.getCookies] - 获取 Cookies 函数
@@ -53,7 +53,7 @@ import { ERROR_CODES } from './errors.js';
*/
export function createQueueManager(queueConfig, callbacks) {
const { maxConcurrent, queueBuffer, keepaliveMode } = queueConfig;
const { initBrowser, generateImage, config, navigateToMonitor, getCookies } = callbacks;
const { initBrowser, generate, config, navigateToMonitor, getCookies } = callbacks;
// 计算有效队列大小:0 表示不限制,否则为 maxConcurrent + buffer
const effectiveQueueSize = queueBuffer === 0 ? Infinity : (maxConcurrent + queueBuffer);
@@ -61,6 +61,9 @@ export function createQueueManager(queueConfig, callbacks) {
/** @type {TaskContext[]} */
const queue = [];
/** @type {TaskContext[]} */
const processingTasks = []; // 跟踪正在处理的任务
/** @type {number} */
let processingCount = 0;
@@ -108,7 +111,7 @@ export function createQueueManager(queueConfig, callbacks) {
}
// 调用核心生图逻辑 (通过 Pool 分发)
const result = await generateImage(poolContext, prompt, imagePaths, modelId, { id });
const result = await generate(poolContext, prompt, imagePaths, modelId, { id });
// 清除心跳
if (heartbeatInterval) clearInterval(heartbeatInterval);
@@ -176,10 +179,14 @@ export function createQueueManager(queueConfig, callbacks) {
// 取出下一个任务
const task = queue.shift();
processingCount++;
processingTasks.push(task); // 添加到处理中列表
try {
await processTask(task);
} finally {
// 从处理中列表移除
const idx = processingTasks.indexOf(task);
if (idx !== -1) processingTasks.splice(idx, 1);
// 清理临时文件
cleanupTask(task);
processingCount--;
@@ -209,6 +216,25 @@ export function createQueueManager(queueConfig, callbacks) {
};
}
/**
* 获取详细队列状态(包含任务列表)
* @returns {{processing: object[], waiting: object[]}}
*/
function getDetailedStatus() {
return {
processing: processingTasks.map(t => ({
id: t.id,
model: t.modelName || t.modelId,
isStreaming: t.isStreaming
})),
waiting: queue.map(t => ({
id: t.id,
model: t.modelName || t.modelId,
isStreaming: t.isStreaming
}))
};
}
/**
* 检查是否可以接受新请求(非流式)
* @returns {boolean}
@@ -254,6 +280,7 @@ export function createQueueManager(queueConfig, callbacks) {
return {
addTask,
getStatus,
getDetailedStatus,
canAcceptNonStreaming,
initializePool,
getPoolContext,
+295
View File
@@ -0,0 +1,295 @@
/**
* @fileoverview 配置管理模块
* @description 提供配置读取和写入能力,支持分段更新
*/
import fs from 'fs';
import path from 'path';
import yaml from 'yaml';
import { logger } from './logger.js';
const CONFIG_PATH = path.join(process.cwd(), 'config.yaml');
/**
* 读取原始配置(不带缓存,直接从磁盘读取)
* @returns {object} 原始配置对象
*/
function readRawConfig() {
if (!fs.existsSync(CONFIG_PATH)) {
throw new Error('配置文件不存在');
}
const content = fs.readFileSync(CONFIG_PATH, 'utf8');
return yaml.parse(content);
}
/**
* 写入配置到文件
* @param {object} config - 完整配置对象
*/
function writeConfig(config) {
// 使用 yaml 库的默认序列化(会丢失注释,但结构正确)
const content = yaml.stringify(config, {
indent: 2,
lineWidth: 0 // 不自动换行
});
fs.writeFileSync(CONFIG_PATH, content, 'utf8');
logger.info('管理器', '配置已保存到 config.yaml');
}
/**
* 获取服务器配置
* @returns {object}
*/
export function getServerConfig() {
const config = readRawConfig();
return {
port: config.server?.port || 3000,
authToken: config.server?.auth || '',
keepaliveMode: config.server?.keepalive?.mode || 'comment'
};
}
/**
* 保存服务器配置
* @param {object} data - 服务器配置
*/
export function saveServerConfig(data) {
const config = readRawConfig();
if (!config.server) config.server = {};
if (data.port !== undefined) config.server.port = data.port;
if (data.authToken !== undefined) config.server.auth = data.authToken;
if (data.keepaliveMode !== undefined) {
if (!config.server.keepalive) config.server.keepalive = {};
config.server.keepalive.mode = data.keepaliveMode;
}
writeConfig(config);
}
/**
* 获取浏览器配置
* @returns {object}
*/
export function getBrowserConfig() {
const config = readRawConfig();
const browser = config.browser || {};
const proxy = browser.proxy || {};
return {
path: browser.path || '',
headless: browser.headless || false,
proxy: {
enable: proxy.enable || false,
type: proxy.type || 'http',
host: proxy.host || '',
port: proxy.port || 0,
auth: !!(proxy.user || proxy.passwd),
username: proxy.user || '',
password: proxy.passwd || ''
}
};
}
/**
* 保存浏览器配置
* @param {object} data - 浏览器配置
*/
export function saveBrowserConfig(data) {
const config = readRawConfig();
if (!config.browser) config.browser = {};
if (data.path !== undefined) config.browser.path = data.path;
if (data.headless !== undefined) config.browser.headless = data.headless;
if (data.proxy) {
if (!config.browser.proxy) config.browser.proxy = {};
const p = data.proxy;
if (p.enable !== undefined) config.browser.proxy.enable = p.enable;
if (p.type !== undefined) config.browser.proxy.type = p.type;
if (p.host !== undefined) config.browser.proxy.host = p.host;
if (p.port !== undefined) config.browser.proxy.port = p.port;
if (p.username !== undefined) config.browser.proxy.user = p.username;
if (p.password !== undefined) config.browser.proxy.passwd = p.password;
}
writeConfig(config);
}
/**
* 获取队列配置
* @returns {object}
*/
export function getQueueConfig() {
const config = readRawConfig();
return {
queueBuffer: config.queue?.queueBuffer ?? 2,
imageLimit: config.queue?.imageLimit ?? 5
};
}
/**
* 保存队列配置
* @param {object} data - 队列配置
*/
export function saveQueueConfig(data) {
const config = readRawConfig();
if (!config.queue) config.queue = {};
if (data.queueBuffer !== undefined) config.queue.queueBuffer = data.queueBuffer;
if (data.imageLimit !== undefined) config.queue.imageLimit = data.imageLimit;
writeConfig(config);
}
/**
* 获取实例配置
* @returns {object[]}
*/
export function getInstancesConfig() {
const config = readRawConfig();
const instances = config.backend?.pool?.instances || [];
return instances.map(inst => ({
name: inst.name,
userDataMark: inst.userDataMark || null,
proxy: inst.proxy ? {
enable: inst.proxy.enable || false,
type: inst.proxy.type || 'http',
host: inst.proxy.host || '',
port: inst.proxy.port || 0
} : null,
workers: (inst.workers || []).map(w => ({
name: w.name,
type: w.type,
mergeTypes: w.mergeTypes || [],
mergeMonitor: w.mergeMonitor || null
}))
}));
}
/**
* 保存实例配置
* @param {object[]} data - 实例配置列表
*/
export function saveInstancesConfig(data) {
const config = readRawConfig();
if (!config.backend) config.backend = {};
if (!config.backend.pool) config.backend.pool = {};
// 转换为 YAML 格式
config.backend.pool.instances = data.map(inst => {
const result = {
name: inst.name
};
if (inst.userDataMark) {
result.userDataMark = inst.userDataMark;
}
if (inst.proxy && inst.proxy.enable) {
result.proxy = {
enable: true,
type: inst.proxy.type || 'http',
host: inst.proxy.host,
port: inst.proxy.port
};
if (inst.proxy.username) result.proxy.user = inst.proxy.username;
if (inst.proxy.password) result.proxy.passwd = inst.proxy.password;
}
result.workers = (inst.workers || []).map(w => {
const worker = {
name: w.name,
type: w.type
};
if (w.type === 'merge' && w.mergeTypes) {
worker.mergeTypes = w.mergeTypes;
if (w.mergeMonitor) worker.mergeMonitor = w.mergeMonitor;
}
return worker;
});
return result;
});
writeConfig(config);
}
/**
* 获取适配器配置
* @returns {object}
*/
export function getAdaptersConfig() {
const config = readRawConfig();
return config.backend?.adapter || {};
}
/**
* 保存适配器配置
* @param {object} data - 适配器配置(键值对)
*/
export function saveAdaptersConfig(data) {
const config = readRawConfig();
if (!config.backend) config.backend = {};
// 合并而非覆盖,保留其他适配器配置
config.backend.adapter = {
...(config.backend.adapter || {}),
...data
};
writeConfig(config);
}
/**
* 获取 Pool 配置(负载均衡和故障转移)
* @returns {object}
*/
export function getPoolConfig() {
const config = readRawConfig();
const pool = config.backend?.pool || {};
const failover = pool.failover || {};
return {
strategy: pool.strategy || 'least_busy',
failover: {
enabled: failover.enabled !== false, // 默认 true
maxRetries: failover.maxRetries ?? 2
}
};
}
/**
* 保存 Pool 配置
* @param {object} data - Pool 配置
*/
export function savePoolConfig(data) {
const config = readRawConfig();
if (!config.backend) config.backend = {};
if (!config.backend.pool) config.backend.pool = {};
if (data.strategy !== undefined) {
config.backend.pool.strategy = data.strategy;
}
if (data.failover) {
if (!config.backend.pool.failover) config.backend.pool.failover = {};
if (data.failover.enabled !== undefined) {
config.backend.pool.failover.enabled = data.failover.enabled;
}
if (data.failover.maxRetries !== undefined) {
config.backend.pool.failover.maxRetries = data.failover.maxRetries;
}
}
writeConfig(config);
}
+270
View File
@@ -0,0 +1,270 @@
/**
* @fileoverview 配置校验模块
* @description 对前端提交的配置进行严格校验
*/
import { registry } from '../backend/registry.js';
/**
* 校验 Server 配置
* @param {object} data - 服务器配置
* @returns {{valid: boolean, errors: string[]}}
*/
export function validateServerConfig(data) {
const errors = [];
// 端口校验
if (data.port !== undefined) {
if (typeof data.port !== 'number' || !Number.isInteger(data.port)) {
errors.push('port 必须是整数');
} else if (data.port < 1 || data.port > 65535) {
errors.push('port 必须在 1-65535 范围内');
}
}
// Auth Token 校验
if (data.authToken !== undefined) {
if (typeof data.authToken !== 'string') {
errors.push('authToken 必须是字符串');
} else if (data.authToken.length < 10) {
errors.push('authToken 必须至少 10 个字符');
}
}
// Keepalive Mode 校验
if (data.keepaliveMode !== undefined) {
if (!['comment', 'content'].includes(data.keepaliveMode)) {
errors.push('keepaliveMode 必须是 comment 或 content');
}
}
// Queue Buffer 校验
if (data.queueBuffer !== undefined) {
if (typeof data.queueBuffer !== 'number' || !Number.isInteger(data.queueBuffer)) {
errors.push('queueBuffer 必须是整数');
} else if (data.queueBuffer < 0) {
errors.push('queueBuffer 不能为负数');
}
}
// Image Limit 校验
if (data.imageLimit !== undefined) {
if (typeof data.imageLimit !== 'number' || !Number.isInteger(data.imageLimit)) {
errors.push('imageLimit 必须是整数');
} else if (data.imageLimit < 1 || data.imageLimit > 10) {
errors.push('imageLimit 必须在 1-10 范围内');
}
}
return { valid: errors.length === 0, errors };
}
/**
* 校验 Browser 配置
* @param {object} data - 浏览器配置
* @returns {{valid: boolean, errors: string[]}}
*/
export function validateBrowserConfig(data) {
const errors = [];
// Path 校验(可选,字符串)
if (data.path !== undefined && typeof data.path !== 'string') {
errors.push('path 必须是字符串');
}
// Headless 校验
if (data.headless !== undefined && typeof data.headless !== 'boolean') {
errors.push('headless 必须是布尔值');
}
// Proxy 校验
if (data.proxy) {
if (data.proxy.enable !== undefined && typeof data.proxy.enable !== 'boolean') {
errors.push('proxy.enable 必须是布尔值');
}
if (data.proxy.type !== undefined && !['http', 'socks5'].includes(data.proxy.type)) {
errors.push('proxy.type 必须是 http 或 socks5');
}
if (data.proxy.port !== undefined) {
if (typeof data.proxy.port !== 'number' || !Number.isInteger(data.proxy.port)) {
errors.push('proxy.port 必须是整数');
} else if (data.proxy.port < 1 || data.proxy.port > 65535) {
errors.push('proxy.port 必须在 1-65535 范围内');
}
}
}
return { valid: errors.length === 0, errors };
}
/**
* 校验 Instances 配置
* @param {object[]} data - 实例配置列表
* @returns {{valid: boolean, errors: string[]}}
*/
export function validateInstancesConfig(data) {
const errors = [];
if (!Array.isArray(data)) {
return { valid: false, errors: ['instances 必须是数组'] };
}
if (data.length === 0) {
return { valid: false, errors: ['instances 不能为空'] };
}
const instanceNames = new Set();
const workerNames = new Set();
// 获取有效的适配器类型列表
const validAdapterTypes = new Set(registry.getAdapterIds());
validAdapterTypes.add('merge'); // merge 是特殊类型
for (let i = 0; i < data.length; i++) {
const inst = data[i];
const prefix = `instances[${i}]`;
// Instance 名称校验
if (!inst.name || typeof inst.name !== 'string') {
errors.push(`${prefix}: name 是必填字段且必须是字符串`);
} else if (inst.name.trim() === '') {
errors.push(`${prefix}: name 不能为空`);
} else if (instanceNames.has(inst.name)) {
errors.push(`${prefix}: Instance 名称 "${inst.name}" 重复`);
} else {
instanceNames.add(inst.name);
}
// userDataMark 校验(可选,可为空)
if (inst.userDataMark !== undefined && inst.userDataMark !== null && inst.userDataMark !== '') {
if (typeof inst.userDataMark !== 'string') {
errors.push(`${prefix}: userDataMark 必须是字符串`);
} else if (!/^[a-zA-Z0-9_-]+$/.test(inst.userDataMark)) {
errors.push(`${prefix}: userDataMark 只能包含字母、数字、下划线和连字符`);
}
}
// Proxy 校验(可选)
if (inst.proxy) {
if (inst.proxy.enable !== undefined && typeof inst.proxy.enable !== 'boolean') {
errors.push(`${prefix}.proxy: enable 必须是布尔值`);
}
if (inst.proxy.type !== undefined && !['http', 'socks5'].includes(inst.proxy.type)) {
errors.push(`${prefix}.proxy: type 必须是 http 或 socks5`);
}
if (inst.proxy.port !== undefined) {
if (typeof inst.proxy.port !== 'number') {
errors.push(`${prefix}.proxy: port 必须是数字`);
} else if (inst.proxy.port < 1 || inst.proxy.port > 65535) {
errors.push(`${prefix}.proxy: port 必须在 1-65535 范围内`);
}
}
}
// Workers 校验
if (!inst.workers || !Array.isArray(inst.workers)) {
errors.push(`${prefix}: workers 是必填字段且必须是数组`);
} else if (inst.workers.length === 0) {
errors.push(`${prefix}: workers 不能为空`);
} else {
for (let j = 0; j < inst.workers.length; j++) {
const w = inst.workers[j];
const wPrefix = `${prefix}.workers[${j}]`;
// Worker 名称校验
if (!w.name || typeof w.name !== 'string') {
errors.push(`${wPrefix}: name 是必填字段`);
} else if (w.name.trim() === '') {
errors.push(`${wPrefix}: name 不能为空`);
} else if (workerNames.has(w.name)) {
errors.push(`${wPrefix}: Worker 名称 "${w.name}" 全局重复(Worker 名称必须全局唯一)`);
} else {
workerNames.add(w.name);
}
// Worker type 校验
if (!w.type || typeof w.type !== 'string') {
errors.push(`${wPrefix}: type 是必填字段`);
} else if (!validAdapterTypes.has(w.type)) {
errors.push(`${wPrefix}: type "${w.type}" 不是有效的适配器类型`);
}
// merge 类型特殊校验
if (w.type === 'merge') {
if (!w.mergeTypes || !Array.isArray(w.mergeTypes) || w.mergeTypes.length === 0) {
errors.push(`${wPrefix}: merge 类型必须指定 mergeTypes 数组`);
} else {
for (const mt of w.mergeTypes) {
if (!validAdapterTypes.has(mt) || mt === 'merge') {
errors.push(`${wPrefix}: mergeTypes 中的 "${mt}" 不是有效的适配器类型`);
}
}
}
if (w.mergeMonitor && !w.mergeTypes?.includes(w.mergeMonitor)) {
errors.push(`${wPrefix}: mergeMonitor "${w.mergeMonitor}" 必须是 mergeTypes 中的一个`);
}
}
}
}
}
return { valid: errors.length === 0, errors };
}
/**
* 校验 Pool 配置
* @param {object} data - Pool 配置
* @returns {{valid: boolean, errors: string[]}}
*/
export function validatePoolConfig(data) {
const errors = [];
// Strategy 校验
if (data.strategy !== undefined) {
if (!['least_busy', 'round_robin', 'random'].includes(data.strategy)) {
errors.push('strategy 必须是 least_busy、round_robin 或 random');
}
}
// Failover 校验
if (data.failover) {
if (data.failover.enabled !== undefined && typeof data.failover.enabled !== 'boolean') {
errors.push('failover.enabled 必须是布尔值');
}
if (data.failover.maxRetries !== undefined) {
if (typeof data.failover.maxRetries !== 'number' || !Number.isInteger(data.failover.maxRetries)) {
errors.push('failover.maxRetries 必须是整数');
} else if (data.failover.maxRetries < 0) {
errors.push('failover.maxRetries 不能为负数');
}
}
}
return { valid: errors.length === 0, errors };
}
/**
* 校验 Adapters 配置
* @param {object} data - 适配器配置
* @returns {{valid: boolean, errors: string[]}}
*/
export function validateAdaptersConfig(data) {
const errors = [];
if (typeof data !== 'object' || data === null) {
return { valid: false, errors: ['adapters 配置必须是对象'] };
}
// gemini_biz 配置校验
if (data.gemini_biz) {
if (data.gemini_biz.entryUrl !== undefined) {
if (typeof data.gemini_biz.entryUrl !== 'string') {
errors.push('gemini_biz.entryUrl 必须是字符串');
} else if (data.gemini_biz.entryUrl && !data.gemini_biz.entryUrl.startsWith('https://')) {
errors.push('gemini_biz.entryUrl 必须以 https:// 开头');
}
}
}
return { valid: errors.length === 0, errors };
}
+256
View File
@@ -0,0 +1,256 @@
/**
* @fileoverview 系统信息工具模块
* @description 提供系统状态、数据文件夹管理等功能
*/
import os from 'os';
import fs from 'fs';
import path from 'path';
import { logger } from './logger.js';
// 服务启动时间
const startTime = Date.now();
// 版本信息(从 package.json 读取)
let version = '1.0.0';
try {
const pkgPath = path.join(process.cwd(), 'package.json');
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
version = pkg.version || '1.0.0';
}
} catch (e) { /* ignore */ }
// CPU 使用率采样数据
let lastCpuInfo = null;
/**
* 获取 CPU 使用率(跨平台)
* @returns {number} CPU 使用率百分比
*/
function getCpuUsage() {
const cpus = os.cpus();
if (cpus.length === 0) return 0;
let totalIdle = 0;
let totalTick = 0;
for (const cpu of cpus) {
for (const type in cpu.times) {
totalTick += cpu.times[type];
}
totalIdle += cpu.times.idle;
}
const currentInfo = { idle: totalIdle, total: totalTick };
if (!lastCpuInfo) {
lastCpuInfo = currentInfo;
return 0; // 第一次调用无法计算
}
const idleDiff = currentInfo.idle - lastCpuInfo.idle;
const totalDiff = currentInfo.total - lastCpuInfo.total;
lastCpuInfo = currentInfo;
if (totalDiff === 0) return 0;
const usage = 100 - (idleDiff / totalDiff * 100);
return Math.round(usage * 10) / 10;
}
/**
* 获取系统状态
* @returns {object} 系统状态信息
*/
export function getSystemStatus() {
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
// 检测运行模式
const isXvfb = !!process.env.XVFB_RUNNING;
const isHeadless = process.env.HEADLESS === 'true';
return {
status: isXvfb ? 'xvfb' : (isHeadless ? 'headless' : 'normal'),
version,
systemVersion: `${os.type()} ${os.release()}`,
uptime: Math.floor((Date.now() - startTime) / 1000),
cpuUsage: getCpuUsage(),
memoryUsage: {
total: Math.round(totalMem / 1024 / 1024),
used: Math.round(usedMem / 1024 / 1024),
free: Math.round(freeMem / 1024 / 1024)
}
};
}
/**
* 获取数据文件夹列表
* @param {object[]} workers - 当前 Worker 配置列表
* @returns {object[]} 数据文件夹信息
*/
export function getDataFolders(workers = []) {
const dataDir = path.join(process.cwd(), 'data');
if (!fs.existsSync(dataDir)) {
return [];
}
const folders = [];
const entries = fs.readdirSync(dataDir, { withFileTypes: true });
// 构建 userDataDir -> workerName 映射
const workerMap = new Map();
for (const w of workers) {
if (w.userDataDir) {
workerMap.set(path.basename(w.userDataDir), w.name);
}
}
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('camoufoxUserData')) {
const folderPath = path.join(dataDir, entry.name);
let size = 0;
// 计算文件夹大小(递归,但限制深度避免性能问题)
try {
size = getFolderSize(folderPath, 3);
} catch (e) { /* ignore */ }
folders.push({
name: entry.name,
path: `data/${entry.name}`,
size: formatSize(size),
sizeBytes: size,
instance: workerMap.get(entry.name) || null
});
}
}
return folders;
}
/**
* 删除指定的数据文件夹
* @param {string[]} folderNames - 要删除的文件夹名称列表
* @param {object[]} workers - 当前 Worker 配置列表(用于检查是否正在使用)
* @returns {{success: boolean, deleted: string[], errors: string[]}}
*/
export function deleteDataFolders(folderNames, workers = []) {
const dataDir = path.join(process.cwd(), 'data');
const deleted = [];
const errors = [];
// 构建正在使用的文件夹集合
const inUse = new Set();
for (const w of workers) {
if (w.userDataDir) {
inUse.add(path.basename(w.userDataDir));
}
}
for (const name of folderNames) {
// 安全检查:只允许删除 camoufoxUserData 开头的文件夹
if (!name.startsWith('camoufoxUserData')) {
errors.push(`${name}: 不允许删除非用户数据文件夹`);
continue;
}
// 检查是否正在使用
if (inUse.has(name)) {
errors.push(`${name}: 文件夹正在被 Worker 使用`);
continue;
}
const folderPath = path.join(dataDir, name);
// 检查是否存在
if (!fs.existsSync(folderPath)) {
errors.push(`${name}: 文件夹不存在`);
continue;
}
// 删除文件夹
try {
fs.rmSync(folderPath, { recursive: true, force: true });
deleted.push(name);
logger.info('系统', `已删除数据文件夹: ${name}`);
} catch (e) {
errors.push(`${name}: ${e.message}`);
}
}
return {
success: errors.length === 0,
deleted,
errors
};
}
/**
* 清理临时文件
* @param {string} tempDir - 临时目录路径
* @returns {{success: boolean, cleaned: number}}
*/
export function clearTempFiles(tempDir) {
if (!tempDir || !fs.existsSync(tempDir)) {
return { success: true, cleaned: 0 };
}
let cleaned = 0;
try {
const files = fs.readdirSync(tempDir);
for (const file of files) {
try {
fs.unlinkSync(path.join(tempDir, file));
cleaned++;
} catch (e) { /* ignore */ }
}
logger.info('系统', `已清理 ${cleaned} 个临时文件`);
} catch (e) {
logger.warn('系统', `清理临时文件失败: ${e.message}`);
}
return { success: true, cleaned };
}
// ==================== 辅助函数 ====================
/**
* 计算文件夹大小
* @param {string} dirPath - 目录路径
* @param {number} maxDepth - 最大深度
* @returns {number} 字节数
*/
function getFolderSize(dirPath, maxDepth) {
if (maxDepth <= 0) return 0;
let size = 0;
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isFile()) {
try {
size += fs.statSync(fullPath).size;
} catch (e) { /* ignore */ }
} else if (entry.isDirectory()) {
size += getFolderSize(fullPath, maxDepth - 1);
}
}
return size;
}
/**
* 格式化文件大小
* @param {number} bytes - 字节数
* @returns {string} 格式化后的字符串
*/
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
}