mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 为项目添加 WebUI
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user