diff --git a/src/backend/adapter/gemini.js b/src/backend/adapter/gemini.js index 5273a71..e0737e5 100644 --- a/src/backend/adapter/gemini.js +++ b/src/backend/adapter/gemini.js @@ -36,8 +36,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { try { logger.info('适配器', '开启新会话...', meta); - const gotoResult = await gotoWithCheck(page, TARGET_URL); - if (gotoResult.error) return gotoResult; + await gotoWithCheck(page, TARGET_URL); // 1. 等待输入框加载 await waitForInput(page, inputLocator, { click: false }); diff --git a/src/backend/adapter/gemini_biz.js b/src/backend/adapter/gemini_biz.js index e1715a2..45aa192 100644 --- a/src/backend/adapter/gemini_biz.js +++ b/src/backend/adapter/gemini_biz.js @@ -112,8 +112,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { await waitForPageAuth(page); logger.info('适配器', '开启新会话', meta); - const gotoResult = await gotoWithCheck(page, targetUrl); - if (gotoResult.error) return gotoResult; + await gotoWithCheck(page, targetUrl); // 如果触发了账户选择跳转,等待全局处理器完成 await waitForPageAuth(page); diff --git a/src/backend/adapter/gemini_biz_text.js b/src/backend/adapter/gemini_biz_text.js new file mode 100644 index 0000000..07a92f5 --- /dev/null +++ b/src/backend/adapter/gemini_biz_text.js @@ -0,0 +1,338 @@ +/** + * @fileoverview Gemini Business 适配器 + */ + +import { + sleep, + safeClick, + pasteImages +} from '../../browser/utils.js'; +import { + fillPrompt, + submit, + normalizePageError, + normalizeHttpError, + waitApiResponse, + moveMouseAway, + waitForPageAuth, + lockPageAuth, + unlockPageAuth, + isPageAuthLocked, + waitForInput, + gotoWithCheck +} from '../utils/index.js'; +import { logger } from '../../utils/logger.js'; + +// Gemini Biz 输入框选择器 +const INPUT_SELECTOR = 'ucs-prosemirror-editor .ProseMirror'; + +/** + * 处理账户选择页面跳转 + * @param {import('playwright-core').Page} page - Playwright 页面对象 + * @param {string} targetUrl - 目标 URL,用于判断跳转完成 + * @returns {Promise} 是否处理了跳转 + */ +async function handleAccountChooser(page) { + // 防止重复处理 + if (isPageAuthLocked(page)) return false; + + try { + const currentUrl = page.url(); + if (currentUrl.includes('auth.business.gemini.google/account-chooser')) { + lockPageAuth(page); + logger.info('适配器', '[登录器(gemini_biz)] 检测到账户选择页面,尝试自动确认...'); + + // 尝试查找提交按钮 (通常是标准的 button[type="submit"]) + const submitBtn = await page.$('button[type="submit"]'); + if (submitBtn) { + // 确保按钮在可视区域 + await submitBtn.scrollIntoViewIfNeeded(); + await sleep(300, 500); + + // 使用 safeClick 模拟人类点击行为 + logger.info('适配器', '[登录器(gemini_biz)] 正在点击确认按钮...'); + await safeClick(page, submitBtn, { bias: 'button' }); + + // 点击后等待跳转回目标页面 + logger.info('适配器', '[登录器(gemini_biz)] 等待跳转回目标页面...'); + try { + await page.waitForFunction(() => { + const href = window.location.href; + return !href.includes('accounts.google.com') && + !href.includes('auth.business.gemini.google') && + href.includes('business.gemini.google'); + }, { timeout: 60000, polling: 1000 }); + + logger.info('适配器', `[登录器(gemini_biz)] 已跳转回目标页面`); + } catch (timeoutErr) { + const finalUrl = page.url(); + logger.warn('适配器', `[登录器(gemini_biz)] 等待跳转回目标页面超时,尝试继续... 当前URL: ${finalUrl}`); + } + + // 额外缓冲时间,确保页面完全加载 + await sleep(2000, 3000); + unlockPageAuth(page); + return true; + } else { + // 按钮还没加载出来,保持锁,等待下次检查 + logger.debug('适配器', '[登录器(gemini_biz)] 按钮尚未加载,等待中...'); + await sleep(500, 1000); + unlockPageAuth(page); // 释放锁让下次尝试 + return true; // 返回 true 表示"仍在处理中" + } + } + } catch (err) { + logger.warn('适配器', `[登录器(gemini_biz)] 处理账户选择页面失败: ${err.message}`); + unlockPageAuth(page); + } + return false; +} + + +/** + * 生成图片 + * @param {object} context - 浏览器上下文 { page, client, config } + * @param {string} prompt - 提示词 + * @param {string[]} imgPaths - 参考图片路径数组 + * @param {string} modelId - 模型 ID (目前未使用,固定为 gemini-3-pro-preview) + * @returns {Promise<{image?: string, error?: string}>} 生成结果 + */ +async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { + const { page, config } = context; + + try { + // 支持新路径 adapter.gemini_biz.entryUrl,向下兼容旧路径 geminiBiz.entryUrl + const targetUrl = config.backend?.adapter?.gemini_biz?.entryUrl || config.backend?.geminiBiz?.entryUrl; + + if (!targetUrl) { + throw new Error('GeminiBiz backend missing entry URL'); + } + + // 开启新对话 - 先等待可能正在进行的登录处理完成 + await waitForPageAuth(page); + + logger.info('适配器', '开启新会话', meta); + await gotoWithCheck(page, targetUrl); + + // 如果触发了账户选择跳转,等待全局处理器完成 + await waitForPageAuth(page); + + // 1. 等待输入框加载 + logger.debug('适配器', '正在寻找输入框...', meta); + await waitForInput(page, INPUT_SELECTOR, { click: false }); + await sleep(1500, 2500); + + // 2. 上传图片 (uploadImages - 使用自定义验证器) + if (imgPaths && imgPaths.length > 0) { + const expectedUploads = imgPaths.length; + let uploadedCount = 0; + let metadataCount = 0; + + await pasteImages(page, INPUT_SELECTOR, imgPaths, { + uploadValidator: (response) => { + const url = response.url(); + if (response.status() === 200) { + if (url.includes('global/widgetAddContextFile')) { + uploadedCount++; + logger.debug('适配器', `图片上传进度 (Add): ${uploadedCount}/${expectedUploads}`, meta); + return false; + } else if (url.includes('global/widgetListSessionFileMetadata')) { + metadataCount++; + logger.info('适配器', `图片上传进度: ${metadataCount}/${expectedUploads}`, meta); + + if (uploadedCount >= expectedUploads && metadataCount >= expectedUploads) { + return true; + } + } + } + return false; + } + }); + + await sleep(1000, 2000); + } + + // 3. 填写提示词 (fillPrompt) + await safeClick(page, INPUT_SELECTOR, { bias: 'input' }); + await fillPrompt(page, INPUT_SELECTOR, prompt, meta); + await sleep(500, 1000); + + // 4. 设置请求拦截器(根据模型类型修改请求) + logger.debug('适配器', '已启用请求拦截', meta); + await page.unroute('**/*').catch(() => { }); + + // 判断是否为 grounding 模式 + const isGrounding = modelId.endsWith('-grounding'); + const actualModelId = isGrounding ? modelId.replace('-grounding', '') : modelId; + + await page.route(url => url.href.includes('global/widgetStreamAssist'), async (route) => { + const request = route.request(); + if (request.method() !== 'POST') return route.continue(); + + try { + const postData = request.postDataJSON(); + if (postData) { + logger.debug('适配器', '已拦截请求,正在修改...', meta); + if (!postData.streamAssistRequest) postData.streamAssistRequest = {}; + if (!postData.streamAssistRequest.assistGenerationConfig) postData.streamAssistRequest.assistGenerationConfig = {}; + + // 设置模型 ID + postData.streamAssistRequest.assistGenerationConfig.modelId = actualModelId; + + // 根据模式设置 toolsSpec + if (isGrounding) { + postData.streamAssistRequest.toolsSpec = { webGroundingSpec: {} }; + logger.info('适配器', `已拦截请求,使用 Grounding 模式 (模型: ${actualModelId})`, meta); + } else { + // 文本模式不需要额外工具 + postData.streamAssistRequest.toolsSpec = {}; + logger.info('适配器', `已拦截请求,使用文本模式 (模型: ${actualModelId})`, meta); + } + + await route.continue({ postData: JSON.stringify(postData) }); + return; + } + } catch (e) { + logger.error('适配器', '请求拦截处理失败', { ...meta, error: e.message }); + } + await route.continue(); + }); + + // 5. 提交 (submit - 使用公共函数) + logger.debug('适配器', '点击发送...', meta); + await submit(page, { + btnSelector: 'md-icon-button.send-button.submit, button[aria-label="提交"], button[aria-label="Send"], .send-button', + inputTarget: INPUT_SELECTOR, + meta + }); + + logger.info('适配器', '等待生成结果中...', meta); + + // 6. 等待 API 响应 + let apiResponse; + try { + apiResponse = await waitApiResponse(page, { + urlMatch: 'global/widgetStreamAssist', + method: 'POST', + timeout: 120000, + meta + }); + } catch (e) { + const pageError = normalizePageError(e, meta); + if (pageError) return pageError; + throw e; + } + + // 检查 API 响应状态 + const httpError = normalizeHttpError(apiResponse); + if (httpError) { + logger.error('适配器', `请求生成时返回错误: ${httpError.error}`, meta); + return { error: `请求生成时返回错误: ${httpError.error}` }; + } + + // 7. 解析文本响应 + const content = await apiResponse.text(); + logger.debug('适配器', `收到响应,长度: ${content.length}`, meta); + + // 解析 JSON 数组响应 + // 格式: [{uToken, streamAssistResponse: {answer: {replies: [...], state: "..."}}}, ...] + let fullText = ''; + try { + const parsed = JSON.parse(content); + + if (!Array.isArray(parsed)) { + logger.error('适配器', '响应不是数组格式', meta); + return { error: '响应格式错误:不是数组' }; + } + + for (const item of parsed) { + const response = item?.streamAssistResponse; + const answer = response?.answer; + const state = answer?.state; + + // 如果是 SUCCEEDED 状态,跳过(只是告知会话结束) + if (state === 'SUCCEEDED') { + continue; + } + + // 只处理 IN_PROGRESS 状态 + if (state === 'IN_PROGRESS') { + const replies = answer?.replies; + if (replies && replies.length > 0) { + const groundedContent = replies[0]?.groundedContent?.content; + + // 如果是思考过程,跳过 + if (groundedContent?.thought === true) { + continue; + } + + // 提取文本内容 + const text = groundedContent?.text; + if (text) { + fullText += text; + } + } + } + } + } catch (e) { + logger.error('适配器', '解析响应失败', { ...meta, error: e.message }); + return { error: `解析响应失败: ${e.message}` }; + } + + if (fullText) { + logger.info('适配器', `获取文本成功,长度: ${fullText.length}`, meta); + return { text: fullText }; + } else { + logger.warn('适配器', '未解析到有效文本内容', { ...meta, preview: content.substring(0, 200) }); + return { error: '未解析到有效文本内容' }; + } + + } catch (err) { + // 顶层错误处理 + const pageError = normalizePageError(err, meta); + if (pageError) return pageError; + + logger.error('适配器', '生成任务失败', { ...meta, error: err.message }); + return { error: `生成任务失败: ${err.message}` }; + } finally { + // 清理拦截器 + await page.unroute('**/*').catch(() => { }); + // 任务结束,将鼠标移至安全区域 + await moveMouseAway(page); + } +} + +/** + * 适配器 manifest + */ +export const manifest = { + id: 'gemini_biz_text', + displayName: 'Gemini Business (Text)', + + // 入口 URL (从配置读取,支持新旧路径) + getTargetUrl(config, workerConfig) { + return config?.backend?.adapter?.gemini_biz?.entryUrl || config?.backend?.geminiBiz?.entryUrl || null; + }, + + // 模型列表 + models: [ + { id: 'gemini-3-pro', imagePolicy: 'optional', type: 'text' }, + { id: 'gemini-2.5-pro', imagePolicy: 'optional', type: 'text' }, + { id: 'gemini-2.5-flash', imagePolicy: 'optional', type: 'text' }, + { id: 'gemini-3-pro-grounding', imagePolicy: 'optional', type: 'text' }, + { id: 'gemini-2.5-pro-grounding', imagePolicy: 'optional', type: 'text' }, + { id: 'gemini-2.5-flash-grounding', imagePolicy: 'optional', type: 'text' } + ], + + // 模型 ID 解析(直通) + resolveModelId(modelKey) { + const model = this.models.find(m => m.id === modelKey); + return model ? model.id : null; + }, + + // 导航处理器 + navigationHandlers: [handleAccountChooser], + + // 核心生图方法 + generateImage +}; diff --git a/src/backend/adapter/lmarena.js b/src/backend/adapter/lmarena.js index 23d0c28..57317fb 100644 --- a/src/backend/adapter/lmarena.js +++ b/src/backend/adapter/lmarena.js @@ -58,8 +58,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { try { logger.info('适配器', '开启新会话...', meta); - const gotoResult = await gotoWithCheck(page, TARGET_URL); - if (gotoResult.error) return gotoResult; + await gotoWithCheck(page, TARGET_URL); // 1. 等待输入框加载 await waitForInput(page, textareaSelector, { click: false }); diff --git a/src/backend/adapter/lmarena_text.js b/src/backend/adapter/lmarena_text.js index 7a10370..75b562e 100644 --- a/src/backend/adapter/lmarena_text.js +++ b/src/backend/adapter/lmarena_text.js @@ -43,8 +43,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { try { logger.info('适配器', `开启新会话... (搜索模式: ${!!modelConfig.search})`, meta); - const gotoResult = await gotoWithCheck(page, targetUrl); - if (gotoResult.error) return gotoResult; + await gotoWithCheck(page, targetUrl); // 1. 等待输入框加载 await waitForInput(page, textareaSelector, { click: false }); diff --git a/src/backend/adapter/nanobananafree_ai.js b/src/backend/adapter/nanobananafree_ai.js index 124f6d7..bea770e 100644 --- a/src/backend/adapter/nanobananafree_ai.js +++ b/src/backend/adapter/nanobananafree_ai.js @@ -38,8 +38,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { try { logger.info('适配器', '开启新会话', meta); - const gotoResult = await gotoWithCheck(page, TARGET_URL); - if (gotoResult.error) return gotoResult; + await gotoWithCheck(page, TARGET_URL); // 1. 等待输入框加载 await waitForInput(page, textareaSelector, { click: false }); diff --git a/src/backend/adapter/zai_is.js b/src/backend/adapter/zai_is.js index eebc559..ec58585 100644 --- a/src/backend/adapter/zai_is.js +++ b/src/backend/adapter/zai_is.js @@ -127,8 +127,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) { await waitForPageAuth(page); logger.info('适配器', '开启新会话', meta); - const gotoResult = await gotoWithCheck(page, TARGET_URL); - if (gotoResult.error) return gotoResult; + await gotoWithCheck(page, TARGET_URL); // 如果触发了登录跳转,等待全局处理器完成 await waitForPageAuth(page); diff --git a/src/backend/pool/PoolManager.js b/src/backend/pool/PoolManager.js index 818bac4..70e4a81 100644 --- a/src/backend/pool/PoolManager.js +++ b/src/backend/pool/PoolManager.js @@ -161,12 +161,28 @@ export class PoolManager { const failoverEnabled = failoverConfig.enabled !== false; const maxRetries = failoverConfig.maxRetries || 2; - const candidates = this.workers.filter(w => w.supports(modelId)); + let candidates = this.workers.filter(w => w.supports(modelId)); if (candidates.length === 0) { return { error: `没有 Worker 支持模型: ${modelId}` }; } + // 如果请求包含图片,优先选择 imagePolicy 为 optional 的 Worker + const hasImages = paths && paths.length > 0; + if (hasImages && candidates.length > 1) { + const optionalCandidates = candidates.filter(w => { + const policy = w.getImagePolicy(modelId); + return policy === 'optional' || policy === 'required'; + }); + + if (optionalCandidates.length > 0) { + logger.debug('工作池', `请求包含图片,优先选择支持图片的 Worker (${optionalCandidates.length}/${candidates.length} 个)`); + candidates = optionalCandidates; + } else { + logger.warn('工作池', `请求包含图片,但没有 Worker 的 imagePolicy 为 optional`); + } + } + const sortedCandidates = this.strategySelector.sort(candidates); if (!failoverEnabled) { @@ -238,14 +254,21 @@ export class PoolManager { } /** - * 获取图片策略 + * 获取图片策略(宽松策略:只要有一个 Worker 支持 optional 就返回 optional) */ getImagePolicy(modelKey) { + const policies = new Set(); + for (const worker of this.workers) { if (worker.supports(modelKey)) { - return worker.getImagePolicy(modelKey); + policies.add(worker.getImagePolicy(modelKey)); } } + + // 宽松策略:只要有一个 optional 就返回 optional + if (policies.has('optional')) return 'optional'; + if (policies.has('required')) return 'required'; + if (policies.has('forbidden')) return 'forbidden'; return 'optional'; } diff --git a/src/backend/pool/Worker.js b/src/backend/pool/Worker.js index 353dac7..8ddda94 100644 --- a/src/backend/pool/Worker.js +++ b/src/backend/pool/Worker.js @@ -7,7 +7,7 @@ import fs from 'fs'; import { logger } from '../../utils/logger.js'; import { initBrowserBase, createCursor } from '../../browser/launcher.js'; import { registry } from '../registry.js'; -import { gotoWithCheck } from '../utils/page.js'; +import { tryGotoWithCheck } from '../utils/page.js'; /** * Worker 类 - 封装单个浏览器实例 @@ -159,7 +159,7 @@ export class Worker { for (const type of this.mergeTypes) { const url = registry.getTargetUrl(type, this.globalConfig, this.workerConfig); if (!url) continue; - const gotoResult = await gotoWithCheck(this.page, url, { timeout: 30000 }); + const gotoResult = await tryGotoWithCheck(this.page, url, { timeout: 30000 }); if (!gotoResult.error) { gotoSuccess = true; logger.debug('工作池', `[${this.name}] 使用 ${type} 适配器初始化成功`); @@ -171,7 +171,7 @@ export class Worker { logger.warn('工作池', `[${this.name}] 所有适配器网站当前不可用,但 Worker 仍将初始化(请求时可能会失败)`); } } else { - const gotoResult = await gotoWithCheck(this.page, targetUrl, { timeout: 60000 }); + const gotoResult = await tryGotoWithCheck(this.page, targetUrl, { timeout: 60000 }); if (gotoResult.error) { logger.warn('工作池', `[${this.name}] 目标网站当前不可用: ${gotoResult.error},但 Worker 仍将初始化`); } @@ -401,9 +401,11 @@ export class Worker { } /** - * 获取图片策略 + * 获取图片策略(宽松策略:只要有一个适配器支持 optional 就返回 optional) */ getImagePolicy(modelKey) { + const policies = new Set(); + if (this.type === 'merge') { if (modelKey.includes('/')) { const [specifiedType, actualModel] = modelKey.split('/', 2); @@ -411,14 +413,22 @@ export class Worker { return registry.getImagePolicy(specifiedType, actualModel); } } + // 收集所有支持该模型的适配器的 imagePolicy for (const type of this.mergeTypes) { const realId = registry.resolveModelId(type, modelKey); - if (realId) return registry.getImagePolicy(type, modelKey); + if (realId) { + policies.add(registry.getImagePolicy(type, modelKey)); + } } - return 'optional'; } else { return registry.getImagePolicy(this.type, modelKey); } + + // 宽松策略:只要有一个 optional 就返回 optional + if (policies.has('optional')) return 'optional'; + if (policies.has('required')) return 'required'; + if (policies.has('forbidden')) return 'forbidden'; + return 'optional'; } /** diff --git a/src/backend/utils/index.js b/src/backend/utils/index.js index a514e77..09d15c2 100644 --- a/src/backend/utils/index.js +++ b/src/backend/utils/index.js @@ -32,6 +32,7 @@ export { fillPrompt, submit, gotoWithCheck, + tryGotoWithCheck, moveMouseAway, waitApiResponse, } from './page.js'; diff --git a/src/backend/utils/page.js b/src/backend/utils/page.js index 30fcb4e..a4ab091 100644 --- a/src/backend/utils/page.js +++ b/src/backend/utils/page.js @@ -59,7 +59,7 @@ export function isPageAuthLocked(page) { * @returns {Promise} */ export async function waitForInput(page, selectorOrLocator, options = {}) { - const { timeout = 60000, click = true } = options; + const { timeout = 20000, click = true } = options; const isLocator = typeof selectorOrLocator !== 'string'; const displayName = isLocator ? 'Locator' : selectorOrLocator; @@ -152,28 +152,47 @@ export async function submit(page, options = {}) { * @param {string} url - 目标 URL * @param {object} [options={}] - 选项 * @param {number} [options.timeout=30000] - 超时时间(毫秒) - * @returns {Promise<{success?: boolean, error?: string}>} + * @throws {Error} 导航失败时抛出错误 */ export async function gotoWithCheck(page, url, options = {}) { - const { timeout = 30000 } = options; + const { timeout = 20000 } = options; try { const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout }); if (!response) { - return { error: '页面加载失败: 无响应' }; + throw new Error('页面加载失败: 无响应'); } const status = response.status(); if (status >= 400) { - return { error: `网站无法访问 (HTTP ${status})` }; + throw new Error(`网站无法访问 (HTTP ${status})`); } - return { success: true }; } catch (e) { if (e.message.includes('Timeout')) { - return { error: '页面加载超时' }; + throw new Error('页面加载超时'); } - return { error: `页面加载失败: ${e.message}` }; + // 如果是我们自己抛出的错误,直接 re-throw + if (e.message.startsWith('页面') || e.message.startsWith('网站')) { + throw e; + } + throw new Error(`页面加载失败: ${e.message}`); + } +} + +/** + * 尝试导航到 URL(不抛异常版本,用于需要收集错误的场景) + * @param {import('playwright-core').Page} page - 页面对象 + * @param {string} url - 目标 URL + * @param {object} [options={}] - 选项 + * @returns {Promise<{success?: boolean, error?: string}>} + */ +export async function tryGotoWithCheck(page, url, options = {}) { + try { + await gotoWithCheck(page, url, options); + return { success: true }; + } catch (e) { + return { error: e.message }; } } diff --git a/src/browser/launcher.js b/src/browser/launcher.js index e96f5e2..939959c 100644 --- a/src/browser/launcher.js +++ b/src/browser/launcher.js @@ -226,7 +226,10 @@ export async function initBrowserBase(config, options = {}) { i_know_what_im_doing: true, block_webrtc: true, exclude_addons: ['UBO'], - geoip: true + geoip: true, + config: { + forceScopeAccess: true + } }; // 代理配置