From 5d8dc7c0a5d2d892b656c9a683fb91528c045e8d Mon Sep 17 00:00:00 2001 From: foxhui Date: Sat, 13 Dec 2025 03:37:51 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E9=83=A8=E5=88=86=E5=B9=B6=E6=95=B4=E7=90=86?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E5=92=8C=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 + lib/utils/config.js | 218 ------------------ package.json | 6 +- scripts/genkey.js | 21 ++ {lib/utils => scripts}/init.js | 13 +- {lib/utils => scripts}/test.js | 139 ++++------- server.js | 17 +- {lib => src}/backend/adapter/gemini.js | 5 + {lib => src}/backend/adapter/gemini_biz.js | 8 +- {lib => src}/backend/adapter/lmarena.js | 5 + .../backend/adapter/nanobananafree_ai.js | 6 +- {lib => src}/backend/adapter/zai_is.js | 5 + {lib => src}/backend/index.js | 71 +++--- {lib => src}/backend/models.js | 21 +- {lib => src}/backend/utils.js | 17 +- {lib => src}/browser/launcher.js | 11 +- {lib => src}/browser/utils.js | 16 +- {lib => src}/server/display.js | 0 {lib => src}/server/errors.js | 0 {lib/server => src/server/http}/respond.js | 2 +- {lib/server => src/server/http}/routes.js | 6 +- {lib => src}/server/index.js | 6 +- {lib => src}/server/parseChat.js | 0 {lib => src}/server/queue.js | 2 +- src/utils/config.js | 128 ++++++++++ {lib => src}/utils/logger.js | 8 + {lib => src}/utils/proxy.js | 7 +- 27 files changed, 367 insertions(+), 378 deletions(-) delete mode 100644 lib/utils/config.js create mode 100644 scripts/genkey.js rename {lib/utils => scripts}/init.js (97%) rename {lib/utils => scripts}/test.js (62%) rename {lib => src}/backend/adapter/gemini.js (97%) rename {lib => src}/backend/adapter/gemini_biz.js (98%) rename {lib => src}/backend/adapter/lmarena.js (97%) rename {lib => src}/backend/adapter/nanobananafree_ai.js (96%) rename {lib => src}/backend/adapter/zai_is.js (98%) rename {lib => src}/backend/index.js (80%) rename {lib => src}/backend/models.js (93%) rename {lib => src}/backend/utils.js (94%) rename {lib => src}/browser/launcher.js (96%) rename {lib => src}/browser/utils.js (96%) rename {lib => src}/server/display.js (100%) rename {lib => src}/server/errors.js (100%) rename {lib/server => src/server/http}/respond.js (98%) rename {lib/server => src/server/http}/routes.js (97%) rename {lib => src}/server/index.js (78%) rename {lib => src}/server/parseChat.js (100%) rename {lib => src}/server/queue.js (99%) create mode 100644 src/utils/config.js rename {lib => src}/utils/logger.js (90%) rename {lib => src}/utils/proxy.js (94%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 829615e..cc92fe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/utils/config.js b/lib/utils/config.js deleted file mode 100644 index 83dadd3..0000000 --- a/lib/utils/config.js +++ /dev/null @@ -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); -} diff --git a/package.json b/package.json index 3d15b55..a6f9626 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/genkey.js b/scripts/genkey.js new file mode 100644 index 0000000..f4169e7 --- /dev/null +++ b/scripts/genkey.js @@ -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 字段中。'); diff --git a/lib/utils/init.js b/scripts/init.js similarity index 97% rename from lib/utils/init.js rename to scripts/init.js index 2d457c2..2e4f5ca 100644 --- a/lib/utils/init.js +++ b/scripts/init.js @@ -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'); diff --git a/lib/utils/test.js b/scripts/test.js similarity index 62% rename from lib/utils/test.js rename to scripts/test.js index a34a71f..e0d475d 100644 --- a/lib/utils/test.js +++ b/scripts/test.js @@ -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); } -})(); \ No newline at end of file +})(); diff --git a/server.js b/server.js index 1b3aaef..a5952e7 100644 --- a/server.js +++ b/server.js @@ -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; diff --git a/lib/backend/adapter/gemini.js b/src/backend/adapter/gemini.js similarity index 97% rename from lib/backend/adapter/gemini.js rename to src/backend/adapter/gemini.js index 5b83eca..bd5c28a 100644 --- a/lib/backend/adapter/gemini.js +++ b/src/backend/adapter/gemini.js @@ -1,3 +1,8 @@ +/** + * @fileoverview Gemini(消费者版)适配器 + * @description 通过自动化方式驱动 Gemini 网页端生成图片,并将结果转换为统一的后端返回结构。 + */ + import { initBrowserBase } from '../../browser/launcher.js'; import { sleep, diff --git a/lib/backend/adapter/gemini_biz.js b/src/backend/adapter/gemini_biz.js similarity index 98% rename from lib/backend/adapter/gemini_biz.js rename to src/backend/adapter/gemini_biz.js index 2ca8951..e1f9e4b 100644 --- a/lib/backend/adapter/gemini_biz.js +++ b/src/backend/adapter/gemini_biz.js @@ -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} 是否处理了跳转 */ @@ -349,4 +354,3 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { } export { initBrowser, generateImage, handleAccountChooser }; - diff --git a/lib/backend/adapter/lmarena.js b/src/backend/adapter/lmarena.js similarity index 97% rename from lib/backend/adapter/lmarena.js rename to src/backend/adapter/lmarena.js index 822f472..62b5c4d 100644 --- a/lib/backend/adapter/lmarena.js +++ b/src/backend/adapter/lmarena.js @@ -1,3 +1,8 @@ +/** + * @fileoverview LMArena 适配器 + * @description 通过自动化方式驱动 LMArena 网页端生成图片(或解析文本),并将结果转换为统一的后端返回结构。 + */ + import { initBrowserBase } from '../../browser/launcher.js'; import { sleep, diff --git a/lib/backend/adapter/nanobananafree_ai.js b/src/backend/adapter/nanobananafree_ai.js similarity index 96% rename from lib/backend/adapter/nanobananafree_ai.js rename to src/backend/adapter/nanobananafree_ai.js index 2525300..1101adf 100644 --- a/lib/backend/adapter/nanobananafree_ai.js +++ b/src/backend/adapter/nanobananafree_ai.js @@ -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 }; - diff --git a/lib/backend/adapter/zai_is.js b/src/backend/adapter/zai_is.js similarity index 98% rename from lib/backend/adapter/zai_is.js rename to src/backend/adapter/zai_is.js index 5494dea..c5b713d 100644 --- a/lib/backend/adapter/zai_is.js +++ b/src/backend/adapter/zai_is.js @@ -1,3 +1,8 @@ +/** + * @fileoverview zAI(zai.is)适配器 + * @description 通过自动化方式驱动 zai.is 网页端生成图片,并将结果转换为统一的后端返回结构。 + */ + import { initBrowserBase } from '../../browser/launcher.js'; import { sleep, diff --git a/lib/backend/index.js b/src/backend/index.js similarity index 80% rename from lib/backend/index.js rename to src/backend/index.js index 6b59e2a..9f34d3d 100644 --- a/lib/backend/index.js +++ b/src/backend/index.js @@ -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; diff --git a/lib/backend/models.js b/src/backend/models.js similarity index 93% rename from lib/backend/models.js rename to src/backend/models.js index be5e6e5..6a3bde1 100644 --- a/lib/backend/models.js +++ b/src/backend/models.js @@ -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; } - diff --git a/lib/backend/utils.js b/src/backend/utils.js similarity index 94% rename from lib/backend/utils.js rename to src/backend/utils.js index 4912137..8104b2c 100644 --- a/lib/backend/utils.js +++ b/src/backend/utils.js @@ -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}` }; } } - diff --git a/lib/browser/launcher.js b/src/browser/launcher.js similarity index 96% rename from lib/browser/launcher.js rename to src/browser/launcher.js index 5eba273..7676843 100644 --- a/lib/browser/launcher.js +++ b/src/browser/launcher.js @@ -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 diff --git a/lib/browser/utils.js b/src/browser/utils.js similarity index 96% rename from lib/browser/utils.js rename to src/browser/utils.js index 9e0dad4..84aaf27 100644 --- a/lib/browser/utils.js +++ b/src/browser/utils.js @@ -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(); } - diff --git a/lib/server/display.js b/src/server/display.js similarity index 100% rename from lib/server/display.js rename to src/server/display.js diff --git a/lib/server/errors.js b/src/server/errors.js similarity index 100% rename from lib/server/errors.js rename to src/server/errors.js diff --git a/lib/server/respond.js b/src/server/http/respond.js similarity index 98% rename from lib/server/respond.js rename to src/server/http/respond.js index 8b13606..62a9126 100644 --- a/lib/server/respond.js +++ b/src/server/http/respond.js @@ -3,7 +3,7 @@ * @description 封装 JSON、SSE 响应和错误响应的统一处理函数 */ -import { getErrorDetails } from './errors.js'; +import { getErrorDetails } from '../errors.js'; /** * 发送 JSON 响应 diff --git a/lib/server/routes.js b/src/server/http/routes.js similarity index 97% rename from lib/server/routes.js rename to src/server/http/routes.js index ce69bc5..6906450 100644 --- a/lib/server/routes.js +++ b/src/server/http/routes.js @@ -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'; /** * 鉴权检查 diff --git a/lib/server/index.js b/src/server/index.js similarity index 78% rename from lib/server/index.js rename to src/server/index.js index 6622741..a44894a 100644 --- a/lib/server/index.js +++ b/src/server/index.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'; diff --git a/lib/server/parseChat.js b/src/server/parseChat.js similarity index 100% rename from lib/server/parseChat.js rename to src/server/parseChat.js diff --git a/lib/server/queue.js b/src/server/queue.js similarity index 99% rename from lib/server/queue.js rename to src/server/queue.js index 70f3a9c..805c8de 100644 --- a/lib/server/queue.js +++ b/src/server/queue.js @@ -12,7 +12,7 @@ import { sendApiError, buildChatCompletion, buildChatCompletionChunk -} from './respond.js'; +} from './http/respond.js'; /** * @typedef {object} TaskContext diff --git a/src/utils/config.js b/src/utils/config.js new file mode 100644 index 0000000..a20daad --- /dev/null +++ b/src/utils/config.js @@ -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; + + diff --git a/lib/utils/logger.js b/src/utils/logger.js similarity index 90% rename from lib/utils/logger.js rename to src/utils/logger.js index a51684d..a953351 100644 --- a/lib/utils/logger.js +++ b/src/utils/logger.js @@ -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']; diff --git a/lib/utils/proxy.js b/src/utils/proxy.js similarity index 94% rename from lib/utils/proxy.js rename to src/utils/proxy.js index 87f30c8..e97b769 100644 --- a/lib/utils/proxy.js +++ b/src/utils/proxy.js @@ -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 // 原始代理地址