mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 为项目添加 WebUI
This commit is contained in:
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.2.0] - 2025-12-19
|
||||
|
||||
### ✨ Added
|
||||
- **WebUI**
|
||||
- 为项目添加了网页版管理工具,便于修改配置文件(可能会有问题,可随时反馈)
|
||||
|
||||
## [3.1.0] - 2025-12-17
|
||||
|
||||
### ✨ Added
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
ignoredBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- better-sqlite3
|
||||
@@ -54,7 +54,7 @@ const {
|
||||
config,
|
||||
name: backendName,
|
||||
initBrowser,
|
||||
generateImage,
|
||||
generate,
|
||||
TEMP_DIR,
|
||||
resolveModelId,
|
||||
getModels,
|
||||
@@ -93,7 +93,7 @@ const queueManager = createQueueManager(
|
||||
},
|
||||
{
|
||||
initBrowser,
|
||||
generateImage,
|
||||
generate,
|
||||
config,
|
||||
navigateToMonitor: backend.navigateToMonitor
|
||||
? () => backend.navigateToMonitor()
|
||||
@@ -112,7 +112,8 @@ const handleRequest = createRouter({
|
||||
getModelType,
|
||||
tempDir: TEMP_DIR,
|
||||
imageLimit: IMAGE_LIMIT,
|
||||
queueManager
|
||||
queueManager,
|
||||
config
|
||||
});
|
||||
|
||||
// ==================== 启动服务器 ====================
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import{c as l,I as q,j as I,r as x,k as D,o as F,l as T,b as _,d as i,w as s,i as y,e as c,f as v,u as g,t as S,S as L,g as h,h as f,m as G,F as J}from"./index-CdkAYAR1.js";var Q={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M464 144H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H212V212h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H612V212h200v200zM464 544H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H212V612h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H612V612h200v200z"}}]},name:"appstore",theme:"outlined"};function w(p){for(var n=1;n<arguments.length;n++){var a=arguments[n]!=null?Object(arguments[n]):{},o=Object.keys(a);typeof Object.getOwnPropertySymbols=="function"&&(o=o.concat(Object.getOwnPropertySymbols(a).filter(function(t){return Object.getOwnPropertyDescriptor(a,t).enumerable}))),o.forEach(function(t){R(p,t,a[t])})}return p}function R(p,n,a){return n in p?Object.defineProperty(p,n,{value:a,enumerable:!0,configurable:!0,writable:!0}):p[n]=a,p}var k=function(n,a){var o=w({},n,a.attrs);return l(q,w({},o,{icon:Q}),null)};k.displayName="AppstoreOutlined";k.inheritAttrs=!1;const W={style:{display:"flex","align-items":"center","justify-content":"space-between"}},X={style:{display:"flex","align-items":"center"}},Y={style:{"font-weight":"600","font-size":"15px"}},Z={key:0},K={key:2},ee={key:4,style:{"font-size":"12px",color:"#8c8c8c","margin-top":"4px"}},te={style:{"text-align":"right"}},ae={__name:"adapters",setup(p){const n=I(),a=x(!1),o=x(null),t=D({});F(async()=>{await Promise.all([n.fetchAdaptersMeta(),n.fetchAdapterConfig()])});const O=T(()=>n.adaptersMeta),C=m=>{o.value=m;const r=n.adapterConfig[m.id]||{};Object.keys(t).forEach(u=>delete t[u]),m.configSchema&&m.configSchema.forEach(u=>{r[u.key]!==void 0?t[u.key]=r[u.key]:t[u.key]=u.default}),a.value=!0},V=async()=>{if(!o.value)return;const m={[o.value.id]:{...t}};await n.saveAdapterConfig(m)&&(a.value=!1)};return(m,r)=>{const u=c("a-button"),b=c("a-card"),z=c("a-list-item"),A=c("a-list"),U=c("a-empty"),H=c("a-input"),j=c("a-input-number"),M=c("a-switch"),P=c("a-select"),B=c("a-form-item"),E=c("a-form"),N=c("a-drawer"),$=c("a-layout");return i(),_($,{style:{background:"transparent"}},{default:s(()=>[l(b,{title:"适配器管理",bordered:!1},{extra:s(()=>[l(u,{type:"link",onClick:g(n).fetchAdaptersMeta},{default:s(()=>[...r[2]||(r[2]=[h("刷新列表",-1)])]),_:1},8,["onClick"])]),default:s(()=>[l(A,{grid:{gutter:16,xs:1,sm:2,md:3,lg:3,xl:4,xxl:4},"data-source":O.value},{renderItem:s(({item:e})=>[l(z,null,{default:s(()=>[l(b,{hoverable:"",onClick:d=>C(e),bodyStyle:{padding:"16px"}},{default:s(()=>[v("div",W,[v("div",X,[l(g(k),{style:{"font-size":"20px",color:"#1890ff","margin-right":"12px"}}),v("span",Y,S(e.id),1)]),l(g(L),{style:{"font-size":"16px",color:"#8c8c8c"}})])]),_:2},1032,["onClick"])]),_:2},1024)]),_:1},8,["data-source"])]),_:1}),o.value?(i(),_(N,{key:0,open:a.value,"onUpdate:open":r[1]||(r[1]=e=>a.value=e),title:`配置适配器 - ${o.value.name}`,width:"500",placement:"right"},{footer:s(()=>[v("div",te,[l(u,{style:{"margin-right":"8px"},onClick:r[0]||(r[0]=e=>a.value=!1)},{default:s(()=>[...r[3]||(r[3]=[h("取消",-1)])]),_:1}),l(u,{type:"primary",onClick:V},{default:s(()=>[...r[4]||(r[4]=[h("保存配置",-1)])]),_:1})])]),default:s(()=>[!o.value.configSchema||o.value.configSchema.length===0?(i(),f("div",Z,[l(U,{description:"该适配器没有可配置项"})])):(i(),_(E,{key:1,layout:"vertical"},{default:s(()=>[(i(!0),f(J,null,G(o.value.configSchema,e=>(i(),_(B,{key:e.key,label:e.label,required:e.required},{default:s(()=>[e.type==="string"?(i(),_(H,{key:0,value:t[e.key],"onUpdate:value":d=>t[e.key]=d,placeholder:e.placeholder},null,8,["value","onUpdate:value","placeholder"])):y("",!0),e.type==="number"?(i(),_(j,{key:1,value:t[e.key],"onUpdate:value":d=>t[e.key]=d,min:e.min,max:e.max,style:{width:"100%"}},null,8,["value","onUpdate:value","min","max"])):y("",!0),e.type==="boolean"?(i(),f("div",K,[l(M,{checked:t[e.key],"onUpdate:checked":d=>t[e.key]=d},null,8,["checked","onUpdate:checked"])])):y("",!0),e.type==="select"?(i(),_(P,{key:3,value:t[e.key],"onUpdate:value":d=>t[e.key]=d,options:e.options},null,8,["value","onUpdate:value","options"])):y("",!0),e.note?(i(),f("div",ee,S(e.note),1)):y("",!0)]),_:2},1032,["label","required"]))),128))]),_:1}))]),_:1},8,["open","title"])):y("",!0)]),_:1})}}};export{ae as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{_ as o,b as a,d as r,w as s,g as n,e as c}from"./index-CdkAYAR1.js";const d={};function _(l,e){const t=c("a-card");return r(),a(t,{title:"VNC转发",bordered:!1,style:{height:"100%"}},{default:s(()=>[...e[0]||(e[0]=[n(" 开发中... ",-1)])]),_:1})}const i=o(d,[["render",_]]);export{i as default};
|
||||
@@ -0,0 +1 @@
|
||||
[data-v-7bbb1a7b]::-webkit-scrollbar{width:6px;height:6px}[data-v-7bbb1a7b]::-webkit-scrollbar-thumb{background:#ccc;border-radius:3px}[data-v-7bbb1a7b]::-webkit-scrollbar-track{background:#f1f1f1}html,body{width:100%;height:100%}input::-ms-clear,input::-ms-reveal{display:none}*,*:before,*:after{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:rgba(0,0,0,0)}@-ms-viewport{width:device-width}body{margin:0}[tabindex="-1"]:focus{outline:none}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5em;font-weight:500}p{margin-top:0;margin-bottom:1em}abbr[title],abbr[data-original-title]{-webkit-text-decoration:underline dotted;text-decoration:underline;text-decoration:underline dotted;border-bottom:0;cursor:help}address{margin-bottom:1em;font-style:normal;line-height:inherit}input[type=text],input[type=password],input[type=number],textarea{-webkit-appearance:none}ol,ul,dl{margin-top:0;margin-bottom:1em}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:500}dd{margin-bottom:.5em;margin-left:0}blockquote{margin:0 0 1em}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}pre,code,kbd,samp{font-size:1em;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace}pre{margin-top:0;margin-bottom:1em;overflow:auto}figure{margin:0 0 1em}img{vertical-align:middle;border-style:none}a,area,button,[role=button],input:not([type=range]),label,select,summary,textarea{touch-action:manipulation}table{border-collapse:collapse}caption{padding-top:.75em;padding-bottom:.3em;text-align:left;caption-side:bottom}input,button,select,optgroup,textarea{margin:0;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}button,html [type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{padding:0;border-style:none}input[type=radio],input[type=checkbox]{box-sizing:border-box;padding:0}input[type=date],input[type=time],input[type=datetime-local],input[type=month]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;margin:0;padding:0;border:0}legend{display:block;width:100%;max-width:100%;margin-bottom:.5em;padding:0;color:inherit;font-size:1.5em;line-height:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item}template{display:none}[hidden]{display:none!important}mark{padding:.2em;background-color:#feffe6}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{_ as b,j as w,k,o as c,b as C,d as S,w as n,c as o,f as e,e as a,g as i}from"./index-CdkAYAR1.js";const B={style:{"margin-bottom":"8px"}},T={style:{"margin-bottom":"8px"}},z={style:{"margin-bottom":"8px"}},U={style:{display:"flex","justify-content":"flex-end","margin-top":"24px"}},j={style:{"margin-bottom":"8px"}},M={style:{"margin-bottom":"8px"}},q={style:{display:"flex","justify-content":"flex-end","margin-top":"24px"}},L={__name:"server",setup(N){const d=w(),s=k({port:5173,authToken:"",keepaliveMode:"comment",queueBuffer:2,imageLimit:5});c(async()=>{await d.fetchServerConfig(),Object.assign(s,d.serverConfig)});const m=async()=>{await d.saveServerConfig(s)};return(V,t)=>{const p=a("a-input-number"),r=a("a-col"),y=a("a-input-password"),u=a("a-select-option"),g=a("a-select"),f=a("a-row"),v=a("a-button"),x=a("a-card"),_=a("a-layout");return S(),C(_,{style:{background:"transparent"}},{default:n(()=>[o(x,{title:"服务器设置",bordered:!1,style:{width:"100%"}},{default:n(()=>[o(f,{gutter:[16,16]},{default:n(()=>[o(r,{xs:24,md:12},{default:n(()=>[e("div",B,[t[5]||(t[5]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"监听端口",-1)),t[6]||(t[6]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}}," 设置服务器监听的端口号,默认为 5173 ",-1)),o(p,{value:s.port,"onUpdate:value":t[0]||(t[0]=l=>s.port=l),min:1,max:65535,placeholder:"请输入端口号",style:{width:"100%"}},null,8,["value"])])]),_:1}),o(r,{xs:24,md:12},{default:n(()=>[e("div",T,[t[7]||(t[7]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"鉴权 Token",-1)),t[8]||(t[8]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}}," 用于 API 请求鉴权的密钥,留空则不启用鉴权 ",-1)),o(y,{value:s.authToken,"onUpdate:value":t[1]||(t[1]=l=>s.authToken=l),placeholder:"请输入 Token",type:"password"},null,8,["value"])])]),_:1}),o(r,{xs:24,md:12},{default:n(()=>[e("div",z,[t[11]||(t[11]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"心跳包类型",-1)),t[12]||(t[12]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}}," 选择 SSE 流式响应的心跳包格式 ",-1)),o(g,{value:s.keepaliveMode,"onUpdate:value":t[2]||(t[2]=l=>s.keepaliveMode=l),style:{width:"100%"},placeholder:"请选择心跳包类型"},{default:n(()=>[o(u,{value:"comment"},{default:n(()=>[...t[9]||(t[9]=[i("Comment - 注释格式",-1)])]),_:1}),o(u,{value:"content"},{default:n(()=>[...t[10]||(t[10]=[i("Content - 内容格式",-1)])]),_:1})]),_:1},8,["value"])])]),_:1})]),_:1}),e("div",U,[o(v,{type:"primary",onClick:m},{default:n(()=>[...t[13]||(t[13]=[i(" 保存设置 ",-1)])]),_:1})])]),_:1}),o(x,{title:"队列设置",bordered:!1,style:{width:"100%","margin-top":"10px"}},{default:n(()=>[o(f,{gutter:[16,16]},{default:n(()=>[o(r,{xs:24,md:12},{default:n(()=>[e("div",j,[t[14]||(t[14]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"队列缓冲区大小",-1)),t[15]||(t[15]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}},[i(" 非流式请求的额外排队数(设为 0 则不限制非流式请求数量)"),e("br"),i(" 实际队列上限 = Workers数量 + 缓冲区大小 ")],-1)),o(p,{value:s.queueBuffer,"onUpdate:value":t[3]||(t[3]=l=>s.queueBuffer=l),min:0,max:100,placeholder:"默认为 2",style:{width:"100%"}},null,8,["value"])])]),_:1}),o(r,{xs:24,md:12},{default:n(()=>[e("div",M,[t[16]||(t[16]=e("div",{style:{"font-weight":"600","margin-bottom":"4px"}},"图片数量上限",-1)),t[17]||(t[17]=e("div",{style:{"font-size":"12px",color:"#8c8c8c","margin-bottom":"8px"}},[i(" 单次请求最多支持的图片附件数量"),e("br"),i(" 网页最多支持10个附件,超出会被丢弃 ")],-1)),o(p,{value:s.imageLimit,"onUpdate:value":t[4]||(t[4]=l=>s.imageLimit=l),min:1,max:10,placeholder:"默认为 5",style:{width:"100%"}},null,8,["value"])])]),_:1})]),_:1}),e("div",q,[o(v,{type:"primary",onClick:m},{default:n(()=>[...t[18]||(t[18]=[i(" 保存设置 ",-1)])]),_:1})])]),_:1})]),_:1})}}},A=b(L,[["__scopeId","data-v-bd32923f"]]);export{A as default};
|
||||
@@ -0,0 +1 @@
|
||||
.ant-input-number[data-v-bd32923f]{width:100%}
|
||||
@@ -0,0 +1 @@
|
||||
import{v as o,j as a,q as r}from"./index-CdkAYAR1.js";const n=o("system",{state:()=>({status:"",version:"1.0.0",systemVersion:"",uptime:0,cpuUsage:0,memoryUsage:{total:0,used:0,free:0},stats:{totalRequests:0,successRate:0,activeWorkers:0,totalWorkers:0,avgResponseTime:0}}),actions:{async fetchStatus(){const t=a();try{const e=await fetch("/admin/status",{headers:t.getHeaders()});if(e.ok){const s=await e.json();this.$patch(s)}}catch(e){console.error("Failed to fetch system status:",e)}},async fetchStats(){const t=a();try{const e=await fetch("/admin/stats",{headers:t.getHeaders()});if(e.ok){const s=await e.json();this.stats=s}}catch(e){console.error("Failed to fetch stats:",e)}},async restartService(){const t=a();try{const s=await(await fetch("/admin/restart",{method:"POST",headers:t.getHeaders()})).json();return s.success?(r.success(s.message||"Service restarting..."),!0):(r.error("Restart failed"),!1)}catch{return r.error("Restart request failed"),!1}},async stopService(){const t=a();try{const s=await(await fetch("/admin/stop",{method:"POST",headers:t.getHeaders()})).json();return s.success?(r.success(s.message||"Service stopping..."),!0):(r.error("Stop failed"),!1)}catch{return r.error("Stop request failed"),!1}}}});export{n as u};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebAI2API</title>
|
||||
<script type="module" crossorigin src="/assets/index-CdkAYAR1.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BxnGFPqi.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user