mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 为项目添加 WebUI
This commit is contained in:
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 || '执行异常');
|
||||
|
||||
@@ -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--;
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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.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;
|
||||
}
|
||||
|
||||
// 路由分发
|
||||
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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user