diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b5f91..791e3c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). +## [2.2.3] - 2025-12-12 + +### Added +- **后端聚合** + - 可根据请求的模型id自动选择可用的适配器且遵循配置文件中的优先级 + ## [2.2.2] - 2025-12-12 ### Added diff --git a/config.example.yaml b/config.example.yaml index e7ab45c..40b83cb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -25,7 +25,16 @@ backend: # - nanobananafree_ai (Nano Banana Free) # - zai_is (zAI) type: lmarena - + + # 聚合模式设置 (优先级高于 type) + # 启用后,系统将复用单个浏览器标签页处理所有后端任务 + merge: + enable: false + # 聚合列表 (按优先级排序,模型匹配时优先使用靠前的适配器) + type: + - zai_is + - lmarena + # Gemini Business 设置 geminiBiz: # 入口链接 diff --git a/lib/backend/adapter/gemini_biz.js b/lib/backend/adapter/gemini_biz.js index be4ba4d..2ca8951 100644 --- a/lib/backend/adapter/gemini_biz.js +++ b/lib/backend/adapter/gemini_biz.js @@ -348,5 +348,5 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { } } -export { initBrowser, generateImage }; +export { initBrowser, generateImage, handleAccountChooser }; diff --git a/lib/backend/adapter/zai_is.js b/lib/backend/adapter/zai_is.js index cbaa66c..5494dea 100644 --- a/lib/backend/adapter/zai_is.js +++ b/lib/backend/adapter/zai_is.js @@ -413,4 +413,4 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { } } -export { initBrowser, generateImage }; +export { initBrowser, generateImage, handleDiscordAuth }; diff --git a/lib/backend/index.js b/lib/backend/index.js index 35e356c..dcf3969 100644 --- a/lib/backend/index.js +++ b/lib/backend/index.js @@ -1,6 +1,11 @@ import fs from 'fs'; import path from 'path'; import { loadConfig } from '../utils/config.js'; +import { initBrowserBase } from '../browser/launcher.js'; +import * as modelsModule from './models.js'; +import { logger } from '../utils/logger.js'; + +// 导入适配器 import * as lmarenaBackend from './adapter/lmarena.js'; import * as geminiBackend from './adapter/gemini_biz.js'; import * as nanobananafreeBackend from './adapter/nanobananafree_ai.js'; @@ -26,40 +31,183 @@ config.paths = { tempDir: TEMP_DIR }; -let activeBackend; +// 适配器映射表 +const ADAPTER_MAP = { + 'gemini_biz': geminiBackend, + 'nanobananafree_ai': nanobananafreeBackend, + 'zai_is': zaiIsBackend, + 'lmarena': lmarenaBackend +}; -switch (config.backend?.type) { - case 'gemini_biz': - activeBackend = { - name: 'gemini_biz', - initBrowser: (cfg) => geminiBackend.initBrowser(cfg), - generateImage: (ctx, prompt, paths, model, meta) => geminiBackend.generateImage(ctx, prompt, paths, model, meta) +// 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. 聚合后端模式实现 +const MergedBackend = { + name: 'aggregated', + _globalBrowser: null, + _globalPage: null, + + initBrowser: async (cfg) => { + if (MergedBackend._globalPage && !MergedBackend._globalPage.isClosed()) { + return { browser: MergedBackend._globalBrowser, page: MergedBackend._globalPage, config: cfg }; + } + + const activeTypes = cfg.backend.merge.type || []; + const handlers = []; + + // 收集导航处理器 + for (const type of activeTypes) { + const adapter = ADAPTER_MAP[type]; + if (type === 'gemini_biz' && adapter.handleAccountChooser) handlers.push(adapter.handleAccountChooser); + if (type === 'zai_is' && adapter.handleDiscordAuth) handlers.push(adapter.handleDiscordAuth); + } + + // 聚合处理器 + const aggregatedHandler = async (page) => { + for (const handler of handlers) { + try { await handler(page); } catch (e) { } + } }; - break; - case 'nanobananafree_ai': - activeBackend = { - name: 'nanobananafree_ai', - initBrowser: (cfg) => nanobananafreeBackend.initBrowser(cfg), - generateImage: (ctx, prompt, paths, model, meta) => nanobananafreeBackend.generateImage(ctx, prompt, paths, model, meta) + + logger.info('适配器', `[后端聚合] 启动全局浏览器,聚合: ${activeTypes.join(', ')}`); + + const base = await initBrowserBase(cfg, { + userDataDir: cfg.paths.userDataDir, + targetUrl: 'about:blank', + productName: '聚合模式', + navigationHandler: aggregatedHandler, + waitInputValidator: async () => { } + }); + + MergedBackend._globalBrowser = base.browser; + MergedBackend._globalPage = base.page; + return { ...base, config: cfg }; + }, + + generateImage: async (ctx, prompt, paths, modelId, meta) => { + if (!modelId || !modelId.includes('|')) return { error: 'Invalid aggregated model ID' }; + + const [adapterType, realId] = modelId.split('|'); + const adapter = ADAPTER_MAP[adapterType]; + + if (!adapter) return { error: `Adapter not found: ${adapterType}` }; + + logger.info('适配器', `[后端聚合] 路由至: ${adapterType}, Model: ${realId}`, meta); + + // 构造子上下文:复用全局 Page,但传入全局 Config + // 适配器会从 config.backend.geminiBiz 等字段读取所需配置 + const subContext = { + ...ctx, + page: MergedBackend._globalPage, + config: config }; - break; - case 'zai_is': - activeBackend = { - name: 'zai_is', - initBrowser: (cfg) => zaiIsBackend.initBrowser(cfg), - generateImage: (ctx, prompt, paths, model, meta) => zaiIsBackend.generateImage(ctx, prompt, paths, model, meta) - }; - break; - case 'lmarena': - default: - activeBackend = { - name: 'lmarena', - initBrowser: (cfg) => lmarenaBackend.initBrowser(cfg), - generateImage: (ctx, prompt, paths, model, meta) => lmarenaBackend.generateImage(ctx, prompt, paths, model, meta) - }; - break; -} + + return adapter.generateImage(subContext, prompt, paths, realId, meta); + }, + + resolveModelId: (modelKey) => { + const types = config.backend.merge.type; + + // 支持 backend/model 格式指定后端 (如 lmarena/seedream-4.5) + if (modelKey.includes('/')) { + const [specifiedType, actualModel] = modelKey.split('/', 2); + if (ADAPTER_MAP[specifiedType] && types.includes(specifiedType)) { + const realId = modelsModule.resolveModelId(specifiedType, actualModel); + if (realId) return `${specifiedType}|${realId}`; + } + return null; // 指定的后端不存在或不在聚合列表中 + } + + // 按优先级自动匹配 + for (const type of types) { + const realId = modelsModule.resolveModelId(type, modelKey); + if (realId) return `${type}|${realId}`; + } + return null; + }, + + getModels: () => { + const types = config.backend.merge.type; + const allModels = []; + const seenIds = new Set(); + + // 1. 添加按优先级自动选择的模型 (去重) + for (const type of types) { + const result = modelsModule.getModelsForBackend(type); + if (result?.data) { + for (const m of result.data) { + if (!seenIds.has(m.id)) { + seenIds.add(m.id); + allModels.push({ + ...m, + owned_by: type // 标记优先匹配的后端 + }); + } + } + } + } + + // 2. 添加带前缀的模型 (backend/model 格式,用于指定后端) + for (const type of types) { + const result = modelsModule.getModelsForBackend(type); + if (result?.data) { + for (const m of result.data) { + const prefixedId = `${type}/${m.id}`; + allModels.push({ + ...m, + id: prefixedId, + owned_by: type + }); + } + } + } + + return { object: 'list', data: allModels }; + }, + + getImagePolicy: (modelKey) => { + const types = config.backend.merge.type; + + // 支持 backend/model 格式 + if (modelKey.includes('/')) { + const [specifiedType, actualModel] = modelKey.split('/', 2); + if (ADAPTER_MAP[specifiedType] && types.includes(specifiedType)) { + return modelsModule.getImagePolicy(specifiedType, actualModel); + } + return 'optional'; + } + + // 按优先级查找 + for (const type of types) { + const realId = modelsModule.resolveModelId(type, modelKey); + if (realId) return modelsModule.getImagePolicy(type, modelKey); + } + return 'optional'; + } +}; export function getBackend() { + const isMerge = config.backend.merge && config.backend.merge.enable; + const activeBackend = isMerge ? MergedBackend : SingleBackend; + + logger.info('适配器', `后端模式: ${isMerge ? '聚合' : '独立'}`); + return { config, TEMP_DIR, ...activeBackend }; } diff --git a/lib/utils/config.js b/lib/utils/config.js index 69e08fc..9648a74 100644 --- a/lib/utils/config.js +++ b/lib/utils/config.js @@ -51,6 +51,15 @@ backend: # - nanobananafree_ai (Nano Banana Free) # - zai_is (zAI) type: lmarena + + # 聚合模式设置 (优先级高于 type) + # 启用后,系统将复用单个浏览器标签页处理所有后端任务 + merge: + enable: false + # 聚合列表 (按优先级排序,模型匹配时优先使用靠前的适配器) + type: + - zai_is + - lmarena # Gemini Business 设置 geminiBiz: @@ -167,6 +176,14 @@ export function loadConfig() { }; } + // 新增: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) { diff --git a/server.js b/server.js index 9624350..3f5e90c 100644 --- a/server.js +++ b/server.js @@ -3,7 +3,7 @@ import fs from 'fs'; import path from 'path'; import sharp from 'sharp'; import { getBackend } from './lib/backend/index.js'; -import { getModelsForBackend, resolveModelId, getImagePolicy, IMAGE_POLICY } from './lib/backend/models.js'; +import { IMAGE_POLICY } from './lib/backend/models.js'; import { logger } from './lib/utils/logger.js'; import crypto from 'crypto'; import { spawn, spawnSync } from 'child_process'; @@ -216,7 +216,16 @@ if (displayResult === 'XVFB_REDIRECT') { // ==================== 服务器主逻辑 ==================== // 使用统一后端获取配置和函数 -const { config, name, initBrowser, generateImage, TEMP_DIR } = getBackend(); +const { + config, + name, + initBrowser, + generateImage, + TEMP_DIR, + resolveModelId, + getModels, + getImagePolicy +} = getBackend(); const PORT = config.server.port || 3000; const AUTH_TOKEN = config.server.auth; @@ -408,7 +417,7 @@ async function startServer() { // --- 路由分发 --- // 1. 模型列表接口 if (req.method === 'GET' && req.url === '/v1/models') { - const models = getModelsForBackend(name); + const models = getModels(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(models)); return; @@ -561,7 +570,7 @@ async function startServer() { // 解析模型参数 let modelId = null; if (data.model) { - modelId = resolveModelId(name, data.model); + modelId = resolveModelId(data.model); if (modelId) { logger.info('服务器', `触发模型: ${data.model} (${modelId})`, { id }); } else { @@ -583,7 +592,7 @@ async function startServer() { // 图片策略校验 const hasImage = imagePaths.length > 0; - const policy = data.model ? getImagePolicy(name, data.model) : IMAGE_POLICY.OPTIONAL; + const policy = data.model ? getImagePolicy(data.model) : IMAGE_POLICY.OPTIONAL; if (policy === IMAGE_POLICY.REQUIRED && !hasImage) { const errorMsg = `Model ${data.model} requires a reference image.`;