feat: 为项目添加 WebUI

This commit is contained in:
foxhui
2025-12-19 04:43:59 +08:00
Unverified
parent 1ccaa55608
commit 41e3e022a3
33 changed files with 1842 additions and 40 deletions
+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,