mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
refactor: 重构服务器部分并整理目录和注释
This commit is contained in:
@@ -5,6 +5,13 @@ 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).
|
||||
|
||||
## [2.4.0] - 2025-12-13
|
||||
|
||||
### Changed
|
||||
- **重构部分**
|
||||
- 重构服务器部分,并整理项目目录
|
||||
- 加强注释,便于后期维护
|
||||
|
||||
## [2.3.0] - 2025-12-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import yaml from 'yaml';
|
||||
import crypto from 'crypto';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
const CONFIG_PATH = path.join(process.cwd(), 'config.yaml');
|
||||
|
||||
// 模块级缓存,确保配置只从磁盘读取一次
|
||||
let cachedConfig = null;
|
||||
|
||||
/**
|
||||
* 生成随机 API Key
|
||||
* 格式: sk-{48位十六进制字符}
|
||||
* @returns {string} API Key
|
||||
*/
|
||||
function generateApiKey() {
|
||||
return 'sk-' + crypto.randomBytes(24).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认配置模板
|
||||
*/
|
||||
function getDefaultConfig() {
|
||||
return `# 自动生成于 ${new Date().toLocaleString()}
|
||||
|
||||
# 日志等级: debug | info | warn | error
|
||||
logLevel: info
|
||||
|
||||
server:
|
||||
# 监听端口
|
||||
port: 3000
|
||||
# 鉴权 Token (Bearer Token) (可使用 npm run genkey 生成)
|
||||
auth: ${generateApiKey()}
|
||||
# 流式请求心跳设置 (自动对 stream: true 的请求发送心跳防止超时)
|
||||
keepalive:
|
||||
# 心跳模式
|
||||
# "comment": (推荐) 发送 :keepalive 注释,不污染数据
|
||||
# "content": (备用) 发送空 delta,仅当客户端必须收到 JSON 包才重置超时时使用
|
||||
mode: "comment"
|
||||
|
||||
backend:
|
||||
# 适配器设置
|
||||
# - lmarena (LMArena)
|
||||
# - gemini (Gemini 网页版)
|
||||
# - gemini_biz (Gemini Enterprise Business)
|
||||
# - nanobananafree_ai (Nano Banana Free)
|
||||
# - zai_is (zAI)
|
||||
type: lmarena
|
||||
|
||||
# 聚合模式设置 (优先级高于 type)
|
||||
# 启用后,系统将复用单个浏览器标签页处理所有后端任务
|
||||
merge:
|
||||
enable: false
|
||||
# 聚合列表 (按优先级排序,模型匹配时优先使用靠前的适配器)
|
||||
type:
|
||||
- zai_is
|
||||
- lmarena
|
||||
|
||||
# Gemini Business 设置
|
||||
geminiBiz:
|
||||
# 入口链接
|
||||
# 示例: "https://business.gemini.google/home/cid/8888a888-b6e0-88be-86e1-888cf3ee8cf4"
|
||||
entryUrl: ""
|
||||
|
||||
queue:
|
||||
# 最大排队数
|
||||
# 仅对未开启流式保活模式时做出限制,非必要不建议更改
|
||||
# 因客户端可能有超时保护,队列大于2是一定会触发超时保护的
|
||||
maxQueueSize: 2
|
||||
# 图片数量上限
|
||||
# 网页最多支持10个附件,如果设置大于10则直接丢弃超出10的图片
|
||||
imageLimit: 5
|
||||
|
||||
browser:
|
||||
# 浏览器可执行文件路径 (留空则使用 Camoufox 默认下载路径)
|
||||
# Windows系统示例 "C:\\camoufox\\camoufox.exe"
|
||||
# Linux系统示例 "/opt/camoufox/camoufox"
|
||||
path: ""
|
||||
|
||||
# 是否启用无头模式
|
||||
headless: false
|
||||
|
||||
# 代理设置
|
||||
proxy:
|
||||
# 是否启用代理
|
||||
enable: false
|
||||
# 代理类型: http | socks5
|
||||
type: http
|
||||
# 代理主机
|
||||
host: 127.0.0.1
|
||||
# 代理端口
|
||||
port: 7890
|
||||
# 代理认证 (可选)
|
||||
# user: username
|
||||
# passwd: password
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载配置,如果不存在则自动创建
|
||||
* @returns {object} 配置对象
|
||||
*/
|
||||
export function loadConfig() {
|
||||
// 如果已有缓存,直接返回
|
||||
if (cachedConfig) return cachedConfig;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
logger.warn('配置器', '配置文件不存在,正在生成默认配置...');
|
||||
const defaultConfig = getDefaultConfig();
|
||||
fs.writeFileSync(CONFIG_PATH, defaultConfig, 'utf8');
|
||||
logger.info('配置器', `已生成默认配置文件: ${CONFIG_PATH}`);
|
||||
logger.warn('配置器', '请注意查看生成的随机 API Key');
|
||||
}
|
||||
|
||||
const configFile = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const config = yaml.parse(configFile);
|
||||
|
||||
// Docker 路径兼容处理
|
||||
if ((!config.browser?.path || !fs.existsSync(config.browser.path)) &&
|
||||
fs.existsSync('/app/camoufox/camoufox')) {
|
||||
logger.info('配置器', '检测到容器环境,自动修正浏览器路径为 /app/camoufox/camoufox');
|
||||
if (!config.browser) config.browser = {};
|
||||
config.browser.path = '/app/camoufox/camoufox';
|
||||
}
|
||||
|
||||
// 基础配置校验
|
||||
if (!config.server || !config.server.port) {
|
||||
throw new Error('配置文件缺少必需字段: server.port');
|
||||
}
|
||||
if (!config.server.auth) {
|
||||
throw new Error('配置文件缺少必需字段: server.auth');
|
||||
}
|
||||
|
||||
// 设置队列配置默认值
|
||||
if (!config.queue) {
|
||||
config.queue = {
|
||||
maxConcurrent: 1,
|
||||
maxQueueSize: 2,
|
||||
imageLimit: 5
|
||||
};
|
||||
} else {
|
||||
// 强制 maxConcurrent 为 1
|
||||
config.queue.maxConcurrent = 1;
|
||||
if (config.queue.maxQueueSize === undefined) config.queue.maxQueueSize = 2;
|
||||
if (config.queue.imageLimit === undefined) config.queue.imageLimit = 5;
|
||||
}
|
||||
|
||||
// 设置 keepalive 配置默认值
|
||||
if (!config.server.keepalive) {
|
||||
config.server.keepalive = {
|
||||
mode: 'comment'
|
||||
};
|
||||
} else {
|
||||
if (config.server.keepalive.mode === undefined) config.server.keepalive.mode = 'comment';
|
||||
// 验证 mode 值
|
||||
if (!['comment', 'content'].includes(config.server.keepalive.mode)) {
|
||||
logger.warn('配置器', `无效的 keepalive.mode: ${config.server.keepalive.mode},使用默认值 comment`);
|
||||
config.server.keepalive.mode = 'comment';
|
||||
}
|
||||
}
|
||||
|
||||
// 设置 backend 配置默认值
|
||||
if (!config.backend) {
|
||||
config.backend = {
|
||||
type: 'lmarena',
|
||||
geminiBiz: { entryUrl: '' }
|
||||
};
|
||||
}
|
||||
|
||||
// 新增:Merge 配置初始化
|
||||
if (!config.backend.merge) {
|
||||
config.backend.merge = {
|
||||
enable: false,
|
||||
type: ['zai_is', 'lmarena']
|
||||
};
|
||||
}
|
||||
|
||||
// 校验 GeminiBiz 配置
|
||||
if (config.backend.type === 'gemini_biz') {
|
||||
if (!config.backend.geminiBiz || !config.backend.geminiBiz.entryUrl) {
|
||||
throw new Error('backend.type = gemini_biz requires backend.geminiBiz.entryUrl');
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('配置器', '已加载 config.yaml');
|
||||
logger.debug('配置器', '后端类型:', config.backend.type);
|
||||
logger.debug('配置器', '流式心跳模式:', config.server.keepalive.mode);
|
||||
if (config.backend.type === 'gemini_biz') {
|
||||
logger.debug('配置器', `GeminiBiz 入口: ${config.backend.geminiBiz.entryUrl}`);
|
||||
}
|
||||
|
||||
// 设置日志级别
|
||||
if (config.logLevel) {
|
||||
logger.setLevel(config.logLevel);
|
||||
}
|
||||
|
||||
// 缓存配置
|
||||
cachedConfig = config;
|
||||
return config;
|
||||
} catch (e) {
|
||||
logger.error('配置器', '无法加载或生成配置文件', { error: e.message });
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 默认导出为函数
|
||||
export default loadConfig;
|
||||
|
||||
// 生成 API Key
|
||||
if (process.argv.includes('-genkey')) {
|
||||
console.log('>>> [GenAPIKey] 生成新的 API Key:');
|
||||
console.log(generateApiKey());
|
||||
console.log('\n>>> 请将此 Key 复制到 config.yaml 文件的 server.auth 字段中。');
|
||||
process.exit(0);
|
||||
}
|
||||
+3
-3
@@ -7,9 +7,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "node lib/utils/test.js",
|
||||
"genkey": "node lib/utils/config.js -genkey",
|
||||
"init": "node lib/utils/init.js"
|
||||
"test": "node scripts/test.js",
|
||||
"genkey": "node scripts/genkey.js",
|
||||
"init": "node scripts/init.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inquirer/prompts": "^8.0.1",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @fileoverview 生成 API Key(CLI)
|
||||
* @description 输出一个新的 `server.auth` Key,供写入 `config.yaml` 使用。
|
||||
*
|
||||
* 用法:`npm run genkey`
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* 生成随机 API Key(用于 `config.yaml` 的 `server.auth`)
|
||||
* 格式:sk-{48位十六进制字符}
|
||||
* @returns {string} API Key
|
||||
*/
|
||||
function generateApiKey() {
|
||||
return 'sk-' + crypto.randomBytes(24).toString('hex');
|
||||
}
|
||||
|
||||
console.log('>>> [GenAPIKey] 生成新的 API Key:');
|
||||
console.log(generateApiKey());
|
||||
console.log('\n>>> 请将此 Key 复制到 config.yaml 文件的 server.auth 字段中。');
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* @fileoverview 运行环境初始化脚本(CLI)
|
||||
* @description 用于下载/准备运行所需依赖(如 Camoufox、better-sqlite3 等),并按需更新 `config.yaml`。
|
||||
*
|
||||
* 用法:`npm run init`
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
@@ -6,11 +13,11 @@ import { fileURLToPath } from 'url';
|
||||
import { gotScraping } from 'got-scraping';
|
||||
import compressing from 'compressing';
|
||||
import yaml from 'yaml';
|
||||
import { logger } from './logger.js';
|
||||
import { getHttpProxy, getProxyConfig } from './proxy.js';
|
||||
import { logger } from '../src/utils/logger.js';
|
||||
import { getHttpProxy, getProxyConfig } from '../src/utils/proxy.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT_ROOT = path.join(__dirname, '..', '..');
|
||||
const PROJECT_ROOT = path.join(__dirname, '..');
|
||||
const TEMP_DIR = path.join(PROJECT_ROOT, 'data', 'temp');
|
||||
const CONFIG_PATH = path.join(PROJECT_ROOT, 'config.yaml');
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
import { getBackend } from '../backend/index.js';
|
||||
/**
|
||||
* @fileoverview 本地 HTTP 调用测试(CLI)
|
||||
* @description 用交互式方式构造请求并调用本地服务的 `/v1/chat/completions`,用于快速验证服务可用性与流式输出。
|
||||
*
|
||||
* 用法:`npm run test`
|
||||
*/
|
||||
|
||||
import { select, input } from '@inquirer/prompts';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import { logger } from './logger.js';
|
||||
import yaml from 'yaml';
|
||||
|
||||
// 使用统一后端获取配置和函数
|
||||
const { config, name, TEMP_DIR, getModels } = getBackend();
|
||||
// 简易日志:脚本内部使用,避免引入服务端 logger 造成格式混淆
|
||||
const logger = {
|
||||
info: (tag, msg) => console.log(`[${new Date().toLocaleTimeString()}] [INFO] [${tag}] ${msg}`),
|
||||
warn: (tag, msg) => console.log(`[${new Date().toLocaleTimeString()}] [WARN] [${tag}] ${msg}`),
|
||||
error: (tag, msg, meta) => console.error(`[${new Date().toLocaleTimeString()}] [ERROR] [${tag}] ${msg}`, meta || '')
|
||||
};
|
||||
|
||||
logger.info('CLI/Test', `测试工具启动 (后端适配器: ${name})`);
|
||||
|
||||
/**
|
||||
* 选择模型
|
||||
*/
|
||||
async function selectModel() {
|
||||
const models = getModels(); // 使用后端统一接口,支持聚合模式
|
||||
const choices = [
|
||||
{ name: 'Skip(使用默认模型)', value: null },
|
||||
...models.data.map(m => ({ name: m.id, value: m.id }))
|
||||
];
|
||||
|
||||
const modelId = await select({
|
||||
message: '选择模型',
|
||||
choices,
|
||||
pageSize: 15
|
||||
});
|
||||
|
||||
return modelId;
|
||||
// 读取本地配置:用于获取端口与鉴权 Token(读取失败时使用默认值)
|
||||
let config = { server: { port: 3000, auth: '' } };
|
||||
try {
|
||||
if (fs.existsSync('config.yaml')) {
|
||||
const file = fs.readFileSync('config.yaml', 'utf8');
|
||||
const parsed = yaml.parse(file);
|
||||
if (parsed && parsed.server) {
|
||||
config.server.port = parsed.server.port || 3000;
|
||||
config.server.auth = parsed.server.auth || '';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Test', '无法读取 config.yaml,将使用默认设置');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +60,7 @@ async function promptForImages() {
|
||||
if (fs.existsSync(cleanPath)) {
|
||||
imagePaths.push(cleanPath);
|
||||
} else {
|
||||
logger.warn('CLI/Test', `图片不存在: ${cleanPath}`);
|
||||
logger.warn('Test', `图片不存在: ${cleanPath}`);
|
||||
}
|
||||
}
|
||||
return imagePaths;
|
||||
@@ -64,16 +68,16 @@ async function promptForImages() {
|
||||
|
||||
/**
|
||||
* HTTP 测试模式 - OpenAI 格式
|
||||
* @param {string} prompt - 提示词
|
||||
* @param {string|null} modelId - 模型 ID
|
||||
* @param {string[]} imagePaths - 图片路径
|
||||
* @param {boolean} isStreaming - 是否使用流式模式
|
||||
*/
|
||||
async function testViaHttpOpenAI(prompt, modelId, imagePaths, isStreaming) {
|
||||
const PORT = config.server.port || 3000;
|
||||
const PORT = config.server.port;
|
||||
const AUTH_TOKEN = config.server.auth;
|
||||
|
||||
logger.info('CLI/Test', `HTTP 测试 - ${isStreaming ? '流式模式' : '非流式模式'}`);
|
||||
if (!AUTH_TOKEN) {
|
||||
logger.warn('Test', '警告: 未配置 API Key (server.auth)');
|
||||
}
|
||||
|
||||
logger.info('Test', `HTTP 测试 - ${isStreaming ? '流式' : '非流式'} - 端口: ${PORT}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 构造请求体
|
||||
@@ -95,7 +99,7 @@ async function testViaHttpOpenAI(prompt, modelId, imagePaths, isStreaming) {
|
||||
image_url: { url: `data:image/${mimeType};base64,${base64}` }
|
||||
});
|
||||
} else {
|
||||
logger.warn('CLI/Test', `图片不存在,已跳过: ${imgPath}`);
|
||||
logger.warn('Test', `图片不存在,已跳过: ${imgPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +108,7 @@ async function testViaHttpOpenAI(prompt, modelId, imagePaths, isStreaming) {
|
||||
const body = {
|
||||
messages,
|
||||
stream: isStreaming,
|
||||
...(modelId && { model: modelId })
|
||||
model: modelId || 'default'
|
||||
};
|
||||
|
||||
const bodyStr = JSON.stringify(body);
|
||||
@@ -194,84 +198,37 @@ async function testViaHttpOpenAI(prompt, modelId, imagePaths, isStreaming) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存图片
|
||||
*/
|
||||
function saveImage(base64Data) {
|
||||
const testSaveDir = path.join(TEMP_DIR, 'testSave');
|
||||
if (!fs.existsSync(testSaveDir)) {
|
||||
fs.mkdirSync(testSaveDir, { recursive: true });
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const savePath = path.join(testSaveDir, `test_${timestamp}.png`);
|
||||
|
||||
// 移除 Data URI 前缀(如果有)
|
||||
const cleanBase64 = base64Data.replace(/^data:image\/\w+;base64,/, '');
|
||||
fs.writeFileSync(savePath, Buffer.from(cleanBase64, 'base64'));
|
||||
|
||||
logger.info('CLI/Test', `图片已保存: ${savePath}`);
|
||||
return savePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主流程
|
||||
*/
|
||||
(async () => {
|
||||
try {
|
||||
logger.info('CLI/Test', '=== HTTP 服务器测试 ===');
|
||||
logger.info('CLI/Test', '请确保服务器已启动 (npm start)');
|
||||
logger.info('Test', '=== API 独立测试脚本 ===');
|
||||
|
||||
// 1. 选择模型
|
||||
const modelId = await selectModel();
|
||||
if (modelId) {
|
||||
logger.info('CLI/Test', `选择模型: ${modelId}`);
|
||||
} else {
|
||||
logger.info('CLI/Test', '跳过模型选择,使用默认');
|
||||
}
|
||||
|
||||
// 2. 输入提示词
|
||||
// 1. 输入提示词
|
||||
const prompt = await promptForInput();
|
||||
logger.info('CLI/Test', `提示词: ${prompt}`);
|
||||
|
||||
// 3. 输入图片路径
|
||||
// 2. 输入图片路径
|
||||
const imagePaths = await promptForImages();
|
||||
if (imagePaths.length > 0) {
|
||||
logger.info('CLI/Test', `参考图片: ${imagePaths.join(', ')}`);
|
||||
}
|
||||
|
||||
// 4. 选择流式模式
|
||||
// 3. 选择流式模式
|
||||
const isStreaming = await select({
|
||||
message: '选择请求模式',
|
||||
choices: [
|
||||
{ name: '流式 (stream: true) - 实时输出,支持心跳保活', value: true },
|
||||
{ name: '非流式 (stream: false) - 等待完整响应', value: false }
|
||||
{ name: '流式 (stream: true)', value: true },
|
||||
{ name: '非流式 (stream: false)', value: false }
|
||||
]
|
||||
});
|
||||
|
||||
// 5. 执行测试
|
||||
logger.info('CLI/Test', '正在发送请求...');
|
||||
const result = await testViaHttpOpenAI(prompt, modelId, imagePaths, isStreaming);
|
||||
// 4. 执行测试
|
||||
logger.info('Test', '正在发送请求...');
|
||||
await testViaHttpOpenAI(prompt, null, imagePaths, isStreaming);
|
||||
|
||||
// 5. 处理响应
|
||||
if (result.choices) {
|
||||
const content = result.choices[0].message.content;
|
||||
logger.info('CLI/Test', `响应内容: ${content.slice(0, 100)}...`);
|
||||
|
||||
// 提取图片(如果有)
|
||||
const match = content.match(/!\[.*?\]\((data:image\/[^)]+)\)/);
|
||||
if (match) {
|
||||
saveImage(match[1]);
|
||||
} else {
|
||||
logger.info('CLI/Test', `文本回复: ${content}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('CLI/Test', '测试完成');
|
||||
logger.info('Test', '测试完成');
|
||||
process.exit(0);
|
||||
|
||||
} catch (err) {
|
||||
logger.error('CLI/Test', '测试失败', { error: err.message });
|
||||
logger.error('Test', '测试失败', { error: err.message });
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
})();
|
||||
@@ -14,9 +14,9 @@
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { getBackend } from './lib/backend/index.js';
|
||||
import { logger } from './lib/utils/logger.js';
|
||||
import { handleDisplayParams, createQueueManager, createRouter } from './lib/server/index.js';
|
||||
import { getBackend } from './src/backend/index.js';
|
||||
import { logger } from './src/utils/logger.js';
|
||||
import { handleDisplayParams, createQueueManager, createRouter } from './src/server/index.js';
|
||||
|
||||
// ==================== 命令行参数处理 ====================
|
||||
|
||||
@@ -33,6 +33,15 @@ if (displayResult === 'XVFB_REDIRECT') {
|
||||
/**
|
||||
* 从统一后端获取配置和函数
|
||||
*/
|
||||
let backend;
|
||||
try {
|
||||
backend = getBackend();
|
||||
} catch (err) {
|
||||
logger.error('服务器', '配置加载失败', { error: err.message });
|
||||
logger.error('服务器', '请先初始化配置:复制 config.example.yaml 为 config.yaml');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const {
|
||||
config,
|
||||
name: backendName,
|
||||
@@ -42,7 +51,7 @@ const {
|
||||
resolveModelId,
|
||||
getModels,
|
||||
getImagePolicy
|
||||
} = getBackend();
|
||||
} = backend;
|
||||
|
||||
/** @type {number} 服务器端口 */
|
||||
const PORT = config.server?.port || 3000;
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @fileoverview Gemini(消费者版)适配器
|
||||
* @description 通过自动化方式驱动 Gemini 网页端生成图片,并将结果转换为统一的后端返回结构。
|
||||
*/
|
||||
|
||||
import { initBrowserBase } from '../../browser/launcher.js';
|
||||
import {
|
||||
sleep,
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @fileoverview Gemini Business 适配器
|
||||
* @description 通过自动化方式驱动 Gemini Business 网页端生成图片,并将结果转换为统一的后端返回结构。
|
||||
*/
|
||||
|
||||
import { initBrowserBase } from '../../browser/launcher.js';
|
||||
import {
|
||||
sleep,
|
||||
@@ -19,7 +24,7 @@ const INPUT_SELECTOR = 'ucs-prosemirror-editor .ProseMirror';
|
||||
|
||||
/**
|
||||
* 处理账户选择页面跳转
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
||||
* @param {string} targetUrl - 目标 URL,用于判断跳转完成
|
||||
* @returns {Promise<boolean>} 是否处理了跳转
|
||||
*/
|
||||
@@ -349,4 +354,3 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
}
|
||||
|
||||
export { initBrowser, generateImage, handleAccountChooser };
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @fileoverview LMArena 适配器
|
||||
* @description 通过自动化方式驱动 LMArena 网页端生成图片(或解析文本),并将结果转换为统一的后端返回结构。
|
||||
*/
|
||||
|
||||
import { initBrowserBase } from '../../browser/launcher.js';
|
||||
import {
|
||||
sleep,
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @fileoverview NanoBananaFree AI 适配器
|
||||
* @description 通过自动化方式驱动 nanobananafree.ai 网页端生成图片,并将结果转换为统一的后端返回结构。
|
||||
*/
|
||||
|
||||
import { initBrowserBase } from '../../browser/launcher.js';
|
||||
import {
|
||||
sleep,
|
||||
@@ -148,4 +153,3 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
}
|
||||
|
||||
export { initBrowser, generateImage };
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @fileoverview zAI(zai.is)适配器
|
||||
* @description 通过自动化方式驱动 zai.is 网页端生成图片,并将结果转换为统一的后端返回结构。
|
||||
*/
|
||||
|
||||
import { initBrowserBase } from '../../browser/launcher.js';
|
||||
import {
|
||||
sleep,
|
||||
@@ -1,3 +1,13 @@
|
||||
/**
|
||||
* @fileoverview 后端适配器入口
|
||||
* @description 负责加载配置、准备运行目录(用户数据/临时目录),并根据配置返回“单后端”或“聚合后端”的统一接口。
|
||||
*
|
||||
* 对外统一能力:
|
||||
* - `initBrowser(cfg)`
|
||||
* - `generateImage(ctx, prompt, imagePaths, modelId, meta)`
|
||||
* - `resolveModelId(modelKey)` / `getModels()` / `getImagePolicy(modelKey)`
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { loadConfig } from '../utils/config.js';
|
||||
@@ -24,14 +34,6 @@ if (!fs.existsSync(TEMP_DIR)) {
|
||||
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
// 将路径常量注入 config 对象
|
||||
config.paths = {
|
||||
userDataDir: USER_DATA_DIR,
|
||||
tempDir: TEMP_DIR
|
||||
};
|
||||
|
||||
// 适配器映射表
|
||||
const ADAPTER_MAP = {
|
||||
'gemini_biz': geminiBackend,
|
||||
@@ -41,26 +43,7 @@ const ADAPTER_MAP = {
|
||||
'lmarena': lmarenaBackend
|
||||
};
|
||||
|
||||
// 1. 单一后端模式实现
|
||||
const SingleBackend = {
|
||||
name: config.backend.type,
|
||||
|
||||
initBrowser: async (cfg) => {
|
||||
const adapter = ADAPTER_MAP[cfg.backend.type] || lmarenaBackend;
|
||||
return adapter.initBrowser(cfg);
|
||||
},
|
||||
|
||||
generateImage: async (ctx, prompt, paths, modelId, meta) => {
|
||||
const adapter = ADAPTER_MAP[config.backend.type] || lmarenaBackend;
|
||||
return adapter.generateImage(ctx, prompt, paths, modelId, meta);
|
||||
},
|
||||
|
||||
resolveModelId: (modelKey) => modelsModule.resolveModelId(config.backend.type, modelKey),
|
||||
getModels: () => modelsModule.getModelsForBackend(config.backend.type),
|
||||
getImagePolicy: (modelKey) => modelsModule.getImagePolicy(config.backend.type, modelKey)
|
||||
};
|
||||
|
||||
// 2. 聚合后端模式实现
|
||||
// 2. 聚合后端模式实现(跨调用复用全局 Page)
|
||||
const MergedBackend = {
|
||||
name: 'merge',
|
||||
_globalBrowser: null,
|
||||
@@ -113,12 +96,11 @@ const MergedBackend = {
|
||||
|
||||
logger.info('适配器', `[后端聚合] 路由至: ${adapterType}, Model: ${realId}`, meta);
|
||||
|
||||
// 构造子上下文:复用全局 Page,但传入全局 Config
|
||||
// 适配器会从 config.backend.geminiBiz 等字段读取所需配置
|
||||
// 构造子上下文:复用全局 Page,但传入当前 config(适配器会读取 config.backend.geminiBiz 等字段)
|
||||
const subContext = {
|
||||
...ctx,
|
||||
page: MergedBackend._globalPage,
|
||||
config: config
|
||||
config: cfg
|
||||
};
|
||||
|
||||
return adapter.generateImage(subContext, prompt, paths, realId, meta);
|
||||
@@ -206,6 +188,33 @@ const MergedBackend = {
|
||||
};
|
||||
|
||||
export function getBackend() {
|
||||
const config = loadConfig();
|
||||
|
||||
// 将路径常量注入 config 对象
|
||||
config.paths = {
|
||||
userDataDir: USER_DATA_DIR,
|
||||
tempDir: TEMP_DIR
|
||||
};
|
||||
|
||||
// 单一后端模式实现(基于当前配置构建)
|
||||
const SingleBackend = {
|
||||
name: config.backend.type,
|
||||
|
||||
initBrowser: async (cfg) => {
|
||||
const adapter = ADAPTER_MAP[cfg.backend.type] || lmarenaBackend;
|
||||
return adapter.initBrowser(cfg);
|
||||
},
|
||||
|
||||
generateImage: async (ctx, prompt, paths, modelId, meta) => {
|
||||
const adapter = ADAPTER_MAP[config.backend.type] || lmarenaBackend;
|
||||
return adapter.generateImage(ctx, prompt, paths, modelId, meta);
|
||||
},
|
||||
|
||||
resolveModelId: (modelKey) => modelsModule.resolveModelId(config.backend.type, modelKey),
|
||||
getModels: () => modelsModule.getModelsForBackend(config.backend.type),
|
||||
getImagePolicy: (modelKey) => modelsModule.getImagePolicy(config.backend.type, modelKey)
|
||||
};
|
||||
|
||||
const isMerge = config.backend.merge && config.backend.merge.enable;
|
||||
const activeBackend = isMerge ? MergedBackend : SingleBackend;
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
// 图片策略枚举
|
||||
/**
|
||||
* @fileoverview 模型与图片策略映射
|
||||
* @description 维护各后端的模型列表/别名与图片输入策略,并提供统一的解析与查询方法供服务器层使用。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 图片输入策略枚举
|
||||
* - optional:可带可不带(默认)
|
||||
* - required:必须有参考图
|
||||
* - forbidden:禁止带图
|
||||
*/
|
||||
export const IMAGE_POLICY = {
|
||||
OPTIONAL: 'optional', // 可带可不带(默认)
|
||||
REQUIRED: 'required', // 必须有参考图
|
||||
FORBIDDEN: 'forbidden' // 禁止带图
|
||||
};
|
||||
|
||||
// LMArena 后端模型配置
|
||||
/** LMArena 后端模型配置(modelKey -> 上游 codeName + 图片策略) */
|
||||
export const LMARENA_MODELS = {
|
||||
"gemini-3-pro-image-preview-2k": {
|
||||
codeName: "019abc10-e78d-7932-b725-7f1563ed8a12",
|
||||
@@ -141,21 +151,21 @@ export const LMARENA_MODELS = {
|
||||
}
|
||||
};
|
||||
|
||||
// Gemini Biz 后端模型配置
|
||||
/** Gemini Business 后端模型配置 */
|
||||
export const GEMINI_BIZ_MODELS = {
|
||||
"gemini-3-pro-image-preview": {
|
||||
imagePolicy: IMAGE_POLICY.OPTIONAL
|
||||
}
|
||||
};
|
||||
|
||||
// NanoBananaFree AI 后端模型配置
|
||||
/** NanoBananaFree AI 后端模型配置 */
|
||||
export const NANOBANANAFREE_AI_MODELS = {
|
||||
"gemini-2.5-flash-image": {
|
||||
imagePolicy: IMAGE_POLICY.OPTIONAL
|
||||
}
|
||||
};
|
||||
|
||||
// zai.is 后端模型配置
|
||||
/** zai.is 后端模型配置 */
|
||||
export const ZAI_IS_MODELS = {
|
||||
"gemini-3-pro-image-preview": {
|
||||
imagePolicy: IMAGE_POLICY.OPTIONAL
|
||||
@@ -249,4 +259,3 @@ export function getImagePolicy(backendName, modelKey) {
|
||||
|
||||
return model.imagePolicy;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* 适配器公共流程函数
|
||||
*
|
||||
* - fillPrompt: 填写提示词
|
||||
* - submit: 提交表单(带回退逻辑)
|
||||
* - waitApiResponse: 等待 API 响应(含页面监听)
|
||||
* - normalizePageError: 页面错误处理
|
||||
* - normalizeHttpError: HTTP 错误处理
|
||||
* @fileoverview 后端适配器公共流程
|
||||
* @description 提供各适配器复用的通用页面操作与错误归一化能力。
|
||||
*
|
||||
* 主要函数:
|
||||
* - `fillPrompt`:拟人化输入提示词
|
||||
* - `submit`:提交表单(点击按钮失败则回退为回车)
|
||||
* - `waitApiResponse`:等待匹配的 API 响应(包含页面关闭/崩溃监听)
|
||||
* - `normalizePageError`:将页面级异常归一化为可返回给服务器层的错误
|
||||
* - `normalizeHttpError`:将 HTTP 响应错误(含限流/人机验证)归一化
|
||||
*/
|
||||
|
||||
import { sleep, humanType, safeClick, isPageValid, createPageCloseWatcher, getRealViewport, clamp, random } from '../browser/utils.js';
|
||||
@@ -267,4 +269,3 @@ export async function downloadImage(url, config) {
|
||||
return { error: `已获取结果,但图片下载时遇到错误: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* @fileoverview 浏览器启动与生命周期管理
|
||||
* @description 负责启动 Camoufox(Playwright 内核)、注入指纹与代理、创建/复用页面,并在进程退出时做资源清理。
|
||||
*
|
||||
* 约定:
|
||||
* - 登录模式会尽量保留 Profile(用户数据目录)
|
||||
* - 清理采用三级退出:Playwright close -> SIGTERM -> SIGKILL
|
||||
*/
|
||||
|
||||
import { Camoufox } from 'camoufox-js';
|
||||
import { FingerprintGenerator } from 'fingerprint-generator';
|
||||
import fs from 'fs';
|
||||
@@ -8,7 +17,7 @@ import { getRealViewport, clamp, random, sleep } from './utils.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { getBrowserProxy, cleanupProxy } from '../utils/proxy.js';
|
||||
|
||||
// 全局状态跟踪
|
||||
// 全局状态:用于在登录模式下管理残留进程与复用上下文
|
||||
let globalBrowserProcess = null;
|
||||
let globalContext = null; // 替代 globalBrowser
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
/**
|
||||
* @fileoverview 浏览器自动化工具函数
|
||||
* @description 封装 Playwright 页面常用操作(等待、查找、点击、上传、拟人化行为等),供后端适配器复用。
|
||||
*
|
||||
* 主要函数:
|
||||
* - `random` / `sleep`:随机与延迟工具
|
||||
* - `getMimeType`:根据文件扩展名推断 MIME
|
||||
* - `getRealViewport` / `clamp`:视口与坐标工具(防止越界)
|
||||
* - `queryDeep`:深层查询(包含 Shadow DOM / iframe)
|
||||
* - `safeClick` / `humanType`:拟人化点击与输入
|
||||
* - `pasteImages` / `uploadFilesViaChooser`:图片粘贴/上传辅助
|
||||
* - `isPageValid` / `createPageCloseWatcher`:页面有效性与关闭/崩溃监听
|
||||
* - `getCookies`:读取 Cookies(JSON 格式)
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
@@ -520,4 +535,3 @@ export async function getCookies(page) {
|
||||
const context = page.context();
|
||||
return await context.cookies();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @description 封装 JSON、SSE 响应和错误响应的统一处理函数
|
||||
*/
|
||||
|
||||
import { getErrorDetails } from './errors.js';
|
||||
import { getErrorDetails } from '../errors.js';
|
||||
|
||||
/**
|
||||
* 发送 JSON 响应
|
||||
@@ -4,10 +4,10 @@
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { ERROR_CODES } from './errors.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { ERROR_CODES } from '../errors.js';
|
||||
import { sendJson, sendApiError } from './respond.js';
|
||||
import { parseRequest } from './parseChat.js';
|
||||
import { parseRequest } from '../parseChat.js';
|
||||
|
||||
/**
|
||||
* 鉴权检查
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview lib/server 模块入口
|
||||
* @fileoverview src/server 模块入口
|
||||
* @description 导出服务器相关模块
|
||||
*/
|
||||
|
||||
@@ -12,8 +12,8 @@ export {
|
||||
sendApiError,
|
||||
buildChatCompletion,
|
||||
buildChatCompletionChunk
|
||||
} from './respond.js';
|
||||
} from './http/respond.js';
|
||||
export { handleDisplayParams } from './display.js';
|
||||
export { createQueueManager } from './queue.js';
|
||||
export { parseRequest } from './parseChat.js';
|
||||
export { createRouter } from './routes.js';
|
||||
export { createRouter } from './http/routes.js';
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
sendApiError,
|
||||
buildChatCompletion,
|
||||
buildChatCompletionChunk
|
||||
} from './respond.js';
|
||||
} from './http/respond.js';
|
||||
|
||||
/**
|
||||
* @typedef {object} TaskContext
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @fileoverview 配置加载模块
|
||||
* @description 负责读取/解析 `config.yaml`,并提供 API Key 生成能力(供脚本使用)。
|
||||
*
|
||||
* 约定:
|
||||
* - 该模块只负责“读取 + 校验 + 默认值补全”,不负责创建/写入配置文件。
|
||||
* - 初始化/拷贝配置请使用 `config.example.yaml` + `scripts/config-init.js`。
|
||||
*/
|
||||
|
||||
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');
|
||||
const EXAMPLE_CONFIG_PATH = path.join(process.cwd(), 'config.example.yaml');
|
||||
|
||||
// 模块级缓存:确保配置只从磁盘读取一次
|
||||
let cachedConfig = null;
|
||||
|
||||
/**
|
||||
* 加载并校验配置(只读)
|
||||
* @returns {object} 配置对象
|
||||
*/
|
||||
export function loadConfig() {
|
||||
// 如果已有缓存,直接返回
|
||||
if (cachedConfig) return cachedConfig;
|
||||
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
const hint = fs.existsSync(EXAMPLE_CONFIG_PATH)
|
||||
? `请复制 ${EXAMPLE_CONFIG_PATH} 为 ${CONFIG_PATH}`
|
||||
: `请创建 ${CONFIG_PATH}(仓库根目录通常提供 config.example.yaml 作为模板)`;
|
||||
throw new Error(`未找到配置文件: ${CONFIG_PATH}。${hint}`);
|
||||
}
|
||||
|
||||
const configFile = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const config = yaml.parse(configFile);
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error(`配置文件解析失败: ${CONFIG_PATH}`);
|
||||
}
|
||||
|
||||
// Docker 路径兼容处理
|
||||
if ((!config.browser?.path || !fs.existsSync(config.browser.path)) &&
|
||||
fs.existsSync('/app/camoufox/camoufox')) {
|
||||
logger.info('配置器', '检测到容器环境,自动修正浏览器路径为 /app/camoufox/camoufox');
|
||||
if (!config.browser) config.browser = {};
|
||||
config.browser.path = '/app/camoufox/camoufox';
|
||||
}
|
||||
|
||||
// 基础配置校验
|
||||
if (!config.server || !config.server.port) {
|
||||
throw new Error('配置文件缺少必需字段: server.port');
|
||||
}
|
||||
if (!config.server.auth) {
|
||||
throw new Error('配置文件缺少必需字段: server.auth');
|
||||
}
|
||||
|
||||
// 设置队列配置默认值
|
||||
if (!config.queue) {
|
||||
config.queue = {
|
||||
maxConcurrent: 1,
|
||||
maxQueueSize: 2,
|
||||
imageLimit: 5
|
||||
};
|
||||
} else {
|
||||
// 强制 maxConcurrent 为 1
|
||||
config.queue.maxConcurrent = 1;
|
||||
if (config.queue.maxQueueSize === undefined) config.queue.maxQueueSize = 2;
|
||||
if (config.queue.imageLimit === undefined) config.queue.imageLimit = 5;
|
||||
}
|
||||
|
||||
// 设置 keepalive 配置默认值(兼容旧字段:keepalive.enable)
|
||||
if (!config.server.keepalive) {
|
||||
config.server.keepalive = { mode: 'comment' };
|
||||
} else {
|
||||
if (config.server.keepalive.mode === undefined) config.server.keepalive.mode = 'comment';
|
||||
// 验证 mode 值
|
||||
if (!['comment', 'content'].includes(config.server.keepalive.mode)) {
|
||||
logger.warn('配置器', `无效的 keepalive.mode: ${config.server.keepalive.mode},使用默认值 comment`);
|
||||
config.server.keepalive.mode = 'comment';
|
||||
}
|
||||
}
|
||||
|
||||
// 设置 backend 配置默认值
|
||||
if (!config.backend) {
|
||||
config.backend = {
|
||||
type: 'lmarena',
|
||||
geminiBiz: { entryUrl: '' }
|
||||
};
|
||||
}
|
||||
|
||||
// 新增:Merge 配置初始化
|
||||
if (!config.backend.merge) {
|
||||
config.backend.merge = {
|
||||
enable: false,
|
||||
type: ['zai_is', 'lmarena']
|
||||
};
|
||||
}
|
||||
|
||||
// 校验 GeminiBiz 配置
|
||||
if (config.backend.type === 'gemini_biz') {
|
||||
if (!config.backend.geminiBiz || !config.backend.geminiBiz.entryUrl) {
|
||||
throw new Error('backend.type = gemini_biz requires backend.geminiBiz.entryUrl');
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('配置器', '已加载 config.yaml');
|
||||
logger.debug('配置器', '后端类型:', config.backend.type);
|
||||
logger.debug('配置器', '流式心跳模式:', config.server.keepalive.mode);
|
||||
if (config.backend.type === 'gemini_biz') {
|
||||
logger.debug('配置器', `GeminiBiz 入口: ${config.backend.geminiBiz.entryUrl}`);
|
||||
}
|
||||
|
||||
// 设置日志级别
|
||||
if (config.logLevel) {
|
||||
logger.setLevel(config.logLevel);
|
||||
}
|
||||
|
||||
// 缓存配置
|
||||
cachedConfig = config;
|
||||
return config;
|
||||
}
|
||||
|
||||
// 默认导出为函数
|
||||
export default loadConfig;
|
||||
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* @fileoverview 控制台日志模块
|
||||
* @description 提供带时间戳/级别/模块名的彩色日志输出,并支持通过环境变量控制日志等级。
|
||||
*
|
||||
* - 环境变量:LOG_LEVEL=debug|info|warn|error
|
||||
* - 输出格式:YYYY-MM-DD HH:mm:ss.SSS [LEVEL] [模块] 消息 | k=v ...
|
||||
*/
|
||||
|
||||
import process from 'process';
|
||||
|
||||
const LEVELS = ['debug', 'info', 'warn', 'error'];
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* @fileoverview 代理适配模块
|
||||
* @description 将配置中的 HTTP/SOCKS5 代理转换为 Playwright 可用的代理配置,并在需要时通过 proxy-chain 搭建本地 HTTP 代理桥接。
|
||||
*/
|
||||
|
||||
import { anonymizeProxy, closeAnonymizedProxy } from 'proxy-chain';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
// 全局代理状态追踪
|
||||
// 全局代理状态:用于清理 proxy-chain 创建的本地代理资源
|
||||
const proxyState = {
|
||||
anonymizedProxyUrl: null, // 转换后的 HTTP 代理地址
|
||||
originalProxyUrl: null // 原始代理地址
|
||||
Reference in New Issue
Block a user