From f4d95c36944d50be51b203d5ce9d5bba2ab37d0c Mon Sep 17 00:00:00 2001 From: foxhui Date: Wed, 24 Dec 2025 03:38:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20ChatGPT=E3=80=81zA?= =?UTF-8?q?I=E3=80=81DeepSeek=20=E6=96=87=E6=9C=AC=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 + README.md | 5 +- src/backend/adapter/chatgpt_text.js | 289 ++++++++++++++++++++ src/backend/adapter/deepseek_text.js | 296 ++++++++++++++++++++ src/backend/adapter/zai_is.js | 107 ++++++-- src/backend/adapter/zai_is_text.js | 393 +++++++++++++++++++++++++++ 6 files changed, 1071 insertions(+), 31 deletions(-) create mode 100644 src/backend/adapter/chatgpt_text.js create mode 100644 src/backend/adapter/deepseek_text.js create mode 100644 src/backend/adapter/zai_is_text.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f8c4554..1b5dfa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.4.0] - 2025-12-23 + +### ✨ Added +- **新增适配器** + - 支持 ChatGPT 文本生成适配器 + - 支持 zAI 文本生成适配器 + - 支持 DeepSeek 文本生成适配器 + +### 🔄 Changed +- **适配器实现更改** + - zAI 图片生成适配器不再使用拦截请求修改响应体的方式,改为UI选择模型列表,并且Nano Banana Pro 支持选择1K、2K、4K + ## [3.3.2] - 2025-12-22 ### 🔄 Changed diff --git a/README.md b/README.md index cf7fd69..953a9a9 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,11 @@ | [**LMArena**](https://lmarena.ai/) | ✅ | ✅ | 🚫 | | [**Gemini Enterprise Business**](https://business.gemini.google/) | ✅ | ✅ | ✅ | | [**Nano Banana Free**](https://nanobananafree.ai/) | 🚫 | ✅ | 🚫 | -| [**zAI**](https://zai.is/) | ❌ | ✅ | 🚫 | +| [**zAI**](https://zai.is/) | ✅ | ✅ | 🚫 | | [**Google Gemini**](https://gemini.google.com/) | ✅ | ✅ | ✅ | | [**ZenMux**](https://zenmux.ai/) | ✅ | ❌ | 🚫 | -| [**ChatGPT**](https://chatgpt.com/) | ❌ | ✅ | 🚫 | +| [**ChatGPT**](https://chatgpt.com/) | ✅ | ✅ | 🚫 | +| [**DeepSeek**](https://chat.deepseek.com/) | ✅ | 🚫 | 🚫 | | 待续... | - | - | - | > [!NOTE] diff --git a/src/backend/adapter/chatgpt_text.js b/src/backend/adapter/chatgpt_text.js new file mode 100644 index 0000000..620c7b0 --- /dev/null +++ b/src/backend/adapter/chatgpt_text.js @@ -0,0 +1,289 @@ +/** + * @fileoverview ChatGPT 文本生成适配器 + */ + +import { + sleep, + safeClick, + uploadFilesViaChooser +} from '../engine/utils.js'; +import { + fillPrompt, + normalizePageError, + moveMouseAway, + waitForInput, + gotoWithCheck, + waitApiResponse +} from '../utils/index.js'; +import { logger } from '../../utils/logger.js'; + +// --- 配置常量 --- +const TARGET_URL = 'https://chatgpt.com/'; +const INPUT_SELECTOR = '.ProseMirror'; + +/** + * 通过 UI 选择模型 + * @param {import('playwright-core').Page} page - 页面对象 + * @param {string} codeName - 模型 codeName + * @param {object} meta - 日志元数据 + * @returns {Promise} 是否成功选择了模型 + */ +async function selectModel(page, codeName, meta = {}) { + try { + // 1. 点击 Model selector 按钮 + const modelSelectorBtn = page.getByRole('button', { name: /^Model selector/ }); + const btnExists = await modelSelectorBtn.count(); + if (btnExists === 0) { + logger.debug('适配器', '未找到模型选择器按钮,跳过选择模型', meta); + return false; + } + + await modelSelectorBtn.waitFor({ timeout: 5000 }); + await sleep(300, 500); + await safeClick(page, modelSelectorBtn, { bias: 'button' }); + await sleep(500, 800); + + // 2. 检查是否有 Legacy models 选项 + const legacyMenuItem = page.getByRole('menuitem', { name: /^Legacy models/ }); + const legacyExists = await legacyMenuItem.count(); + if (legacyExists > 0) { + logger.debug('适配器', '发现 Legacy models 选项,正在点击...', meta); + await safeClick(page, legacyMenuItem, { bias: 'button' }); + await sleep(500, 800); + } + + // 3. 查找匹配 codeName 开头的 menuitem + const targetMenuItem = page.getByRole('menuitem', { name: new RegExp(`^${codeName}`) }); + const targetExists = await targetMenuItem.count(); + if (targetExists > 0) { + logger.info('适配器', `正在选择模型: ${codeName}`, meta); + await safeClick(page, targetMenuItem, { bias: 'button' }); + await sleep(500, 1000); + return true; + } else { + logger.debug('适配器', `未找到模型 ${codeName},使用默认模型`, meta); + // 点击空白区域关闭菜单 + await page.keyboard.press('Escape'); + await sleep(300, 500); + return false; + } + } catch (e) { + logger.warn('适配器', `选择模型失败: ${e.message}`, meta); + // 尝试关闭菜单 + await page.keyboard.press('Escape').catch(() => { }); + return false; + } +} + +/** + * 执行文本生成任务 + * @param {object} context - 浏览器上下文 { page, config } + * @param {string} prompt - 提示词 + * @param {string[]} imgPaths - 图片路径数组 + * @param {string} [modelId] - 模型 ID + * @param {object} [meta={}] - 日志元数据 + * @returns {Promise<{text?: string, error?: string}>} + */ +async function generate(context, prompt, imgPaths, modelId, meta = {}) { + const { page } = context; + const sendBtnLocator = page.getByRole('button', { name: 'Send prompt' }); + + try { + logger.info('适配器', '开启新会话...', meta); + await gotoWithCheck(page, TARGET_URL); + + // 1. 等待输入框加载 + await waitForInput(page, INPUT_SELECTOR, { click: false }); + await sleep(1500, 2500); + + // 2. 选择模型 + const modelConfig = manifest.models.find(m => m.id === modelId); + const targetModel = modelConfig?.codeName || modelId; + if (targetModel) { + await selectModel(page, targetModel, meta); + } + + // 3. 上传图片 (双击 Add files and more 按钮) + if (imgPaths && imgPaths.length > 0) { + const expectedUploads = imgPaths.length; + let uploadedCount = 0; + let processedCount = 0; + + logger.debug('适配器', '双击添加文件按钮...', meta); + const addFilesBtn = page.getByRole('button', { name: 'Add files and more' }); + + await uploadFilesViaChooser(page, addFilesBtn, imgPaths, { + clickAction: 'dblclick', // 使用双击 + uploadValidator: (response) => { + const url = response.url(); + if (response.status() === 200) { + // 上传请求 + if (url.includes('backend-api/files') && !url.includes('process_upload_stream')) { + uploadedCount++; + logger.debug('适配器', `图片上传进度: ${uploadedCount}/${expectedUploads}`, meta); + return false; + } + // 处理完成请求 + if (url.includes('backend-api/files/process_upload_stream')) { + processedCount++; + logger.info('适配器', `图片处理进度: ${processedCount}/${expectedUploads}`, meta); + + if (processedCount >= expectedUploads) { + return true; + } + } + } + return false; + } + }); + + await sleep(1000, 2000); + } + + // 4. 填写提示词 + await safeClick(page, INPUT_SELECTOR, { bias: 'input' }); + await fillPrompt(page, INPUT_SELECTOR, prompt, meta); + await sleep(500, 1000); + + // 5. 点击发送 + logger.debug('适配器', '点击发送...', meta); + await safeClick(page, sendBtnLocator, { bias: 'button' }); + + logger.info('适配器', '等待生成结果...', meta); + + // 6. 监听 conversation API 的 SSE 流,解析文本内容 + logger.info('适配器', '监听 SSE 流获取文本...', meta); + + let textContent = ''; + let isComplete = false; + let targetMessageId = null; // 追踪目标消息 ID + + try { + await page.waitForResponse(async (response) => { + const url = response.url(); + if (!url.includes('backend-api/f/conversation')) return false; + if (response.request().method() !== 'POST') return false; + if (response.status() !== 200) return false; + + try { + const body = await response.text(); + const lines = body.split('\n'); + + for (const line of lines) { + // 跳过空行和事件行 + if (!line.startsWith('data: ')) continue; + + const dataStr = line.slice(6).trim(); + if (dataStr === '[DONE]') { + isComplete = true; + continue; + } + + try { + const data = JSON.parse(dataStr); + + // 检测目标消息 (assistant 角色, channel: "final", content_type: "text") + if (data.v?.message?.author?.role === 'assistant' && + data.v?.message?.channel === 'final' && + data.v?.message?.content?.content_type === 'text') { + targetMessageId = data.v.message.id; + // 初始内容 + const parts = data.v.message.content.parts; + if (parts && parts[0]) { + textContent = parts[0]; + } + } + + // 累积 delta 内容 (append 操作) + if (data.o === 'append' && data.p === '/message/content/parts/0' && data.v) { + textContent += data.v; + } + + // 简单的 delta 追加 (没有 p/o,只有 v) + if (data.v && typeof data.v === 'string' && !data.o && !data.p && targetMessageId) { + textContent += data.v; + } + + // patch 操作中的 append + if (data.o === 'patch' && Array.isArray(data.v)) { + for (const patch of data.v) { + if (patch.o === 'append' && patch.p === '/message/content/parts/0' && patch.v) { + textContent += patch.v; + } + // 检查是否完成 + if (patch.p === '/message/status' && patch.v === 'finished_successfully') { + isComplete = true; + } + } + } + + // message_stream_complete 表示完成 + if (data.type === 'message_stream_complete') { + isComplete = true; + } + } catch { + // 忽略解析错误 + } + } + + return isComplete; + } catch { + return false; + } + }, { timeout: 180000 }); + } catch (e) { + const pageError = normalizePageError(e, meta); + if (pageError) return pageError; + throw e; + } + + if (!textContent || textContent.trim() === '') { + logger.warn('适配器', '回复内容为空', meta); + return { error: '回复内容为空' }; + } + + logger.info('适配器', `已获取文本内容 (${textContent.length} 字符)`, meta); + logger.info('适配器', '文本生成完成,任务完成', meta); + return { text: textContent.trim() }; + + } catch (err) { + // 顶层错误处理 + const pageError = normalizePageError(err, meta); + if (pageError) return pageError; + + logger.error('适配器', '生成任务失败', { ...meta, error: err.message }); + return { error: `生成任务失败: ${err.message}` }; + } finally { + // 任务结束,将鼠标移至安全区域 + await moveMouseAway(page); + } +} + +/** + * 适配器 manifest + */ +export const manifest = { + id: 'chatgpt_text', + displayName: 'ChatGPT (文本生成)', + + // 入口 URL + getTargetUrl(config, workerConfig) { + return TARGET_URL; + }, + + // 模型列表 + models: [ + { id: 'gpt-5.2', codeName: 'GPT-5.2 Instant', imagePolicy: 'optional' }, + { id: 'gpt-5.2-thinking', codeName: 'GPT-5.2 Thinking', imagePolicy: 'optional' }, + { id: 'gpt-5.1', codeName: 'GPT-5.1 Instant', imagePolicy: 'optional' }, + { id: 'gpt-5.1-thinking', codeName: 'GPT-5.1 Thinking', imagePolicy: 'optional' }, + { id: 'gpt-5', codeName: 'GPT-5 Instant', imagePolicy: 'optional' }, + { id: 'gpt-5-thinking', codeName: 'GPT-5 Thinking', imagePolicy: 'optional' }, + ], + + // 无需导航处理器 + navigationHandlers: [], + + // 核心文本生成方法 + generate +}; diff --git a/src/backend/adapter/deepseek_text.js b/src/backend/adapter/deepseek_text.js new file mode 100644 index 0000000..7aeed12 --- /dev/null +++ b/src/backend/adapter/deepseek_text.js @@ -0,0 +1,296 @@ +/** + * @fileoverview DeepSeek 文本生成适配器 + */ + +import { + sleep, + safeClick +} from '../engine/utils.js'; +import { + fillPrompt, + normalizePageError, + moveMouseAway, + waitForInput, + gotoWithCheck +} from '../utils/index.js'; +import { logger } from '../../utils/logger.js'; + +// --- 配置常量 --- +const TARGET_URL = 'https://chat.deepseek.com/'; +const INPUT_SELECTOR = 'textarea'; + +/** + * 切换功能按钮状态 + * @param {import('playwright-core').Page} page - 页面对象 + * @param {string} buttonName - 按钮名称 (DeepThink / Search) + * @param {boolean} targetState - 目标状态 (true=开启, false=关闭) + * @param {object} meta - 日志元数据 + * @returns {Promise} 是否成功切换 + */ +async function toggleButton(page, buttonName, targetState, meta = {}) { + try { + const btn = page.getByRole('button', { name: buttonName }); + const btnCount = await btn.count(); + if (btnCount === 0) { + logger.debug('适配器', `未找到 ${buttonName} 按钮`, meta); + return false; + } + + // 获取当前状态 (检查 class 是否包含 ds-toggle-button--selected) + const isSelected = await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected')); + + if (isSelected !== targetState) { + logger.info('适配器', `切换 ${buttonName}: ${isSelected} -> ${targetState}`, meta); + await safeClick(page, btn, { bias: 'button' }); + await sleep(300, 500); + return true; + } else { + logger.debug('适配器', `${buttonName} 已是目标状态: ${targetState}`, meta); + return true; + } + } catch (e) { + logger.warn('适配器', `切换 ${buttonName} 失败: ${e.message}`, meta); + return false; + } +} + +/** + * 配置模型功能 (thinking / search) + * @param {import('playwright-core').Page} page - 页面对象 + * @param {object} modelConfig - 模型配置 + * @param {object} meta - 日志元数据 + */ +async function configureModel(page, modelConfig, meta = {}) { + const thinking = modelConfig?.thinking || false; + const search = modelConfig?.search || false; + + // 切换 DeepThink 状态 + await toggleButton(page, 'DeepThink', thinking, meta); + await sleep(200, 400); + + // 切换 Search 状态 + await toggleButton(page, 'Search', search, meta); + await sleep(200, 400); +} + +/** + * 执行文本生成任务 + * @param {object} context - 浏览器上下文 { page, config } + * @param {string} prompt - 提示词 + * @param {string[]} imgPaths - 图片路径数组 (此适配器不支持) + * @param {string} [modelId] - 模型 ID + * @param {object} [meta={}] - 日志元数据 + * @returns {Promise<{text?: string, error?: string}>} + */ +async function generate(context, prompt, imgPaths, modelId, meta = {}) { + const { page } = context; + + try { + logger.info('适配器', '开启新会话...', meta); + await gotoWithCheck(page, TARGET_URL); + + // 1. 等待输入框加载 + await waitForInput(page, INPUT_SELECTOR, { click: false }); + await sleep(1500, 2500); + + // 2. 配置模型功能 (thinking / search) + const modelConfig = manifest.models.find(m => m.id === modelId); + if (modelConfig) { + await configureModel(page, modelConfig, meta); + } + + // 3. 填写提示词 + await safeClick(page, INPUT_SELECTOR, { bias: 'input' }); + await fillPrompt(page, INPUT_SELECTOR, prompt, meta); + await sleep(500, 1000); + + // 4. 按回车发送 + logger.debug('适配器', '按回车发送...', meta); + await page.keyboard.press('Enter'); + + logger.info('适配器', '等待生成结果...', meta); + + // 5. 监听 chat/completion SSE 流,解析文本内容 + logger.info('适配器', '监听 SSE 流获取文本...', meta); + + let textContent = ''; + let isComplete = false; + let responseFragmentIndex = -1; // RESPONSE 类型 fragment 的数组索引 + let currentFragmentIndex = -1; // 当前正在追加内容的 fragment 数组索引 + let fragmentCount = 0; // fragments 数组的当前长度 + + try { + await page.waitForResponse(async (response) => { + const url = response.url(); + if (!url.includes('chat/completion')) return false; + if (response.request().method() !== 'POST') return false; + if (response.status() !== 200) return false; + + try { + const body = await response.text(); + const lines = body.split('\n'); + + for (const line of lines) { + // 跳过事件行和空行 + if (line.startsWith('event:') || !line.startsWith('data:')) continue; + + const dataStr = line.slice(5).trim(); + if (!dataStr || dataStr === '{}') continue; + + try { + const data = JSON.parse(dataStr); + + // 初始响应中可能已有 fragments (如 SEARCH) + if (data.v?.response?.fragments && Array.isArray(data.v.response.fragments)) { + for (const fragment of data.v.response.fragments) { + const idx = fragmentCount++; + if (fragment.type === 'RESPONSE') { + responseFragmentIndex = idx; + currentFragmentIndex = idx; + if (fragment.content) { + textContent += fragment.content; + } + } else { + currentFragmentIndex = idx; + } + } + } + + // 简单的文本追加 (只有 v 字符串,没有 p 和 o) + // 只有当前活跃的 fragment 是 RESPONSE 类型时才收集 + if (data.v && typeof data.v === 'string' && !data.p && !data.o) { + if (currentFragmentIndex === responseFragmentIndex && responseFragmentIndex >= 0) { + textContent += data.v; + } + } + + // 带路径的 APPEND 操作 (如 response/fragments/1/content) + if (data.o === 'APPEND' && data.p && typeof data.v === 'string') { + const match = data.p.match(/response\/fragments\/(\d+)\/content/); + if (match) { + const fragIdx = parseInt(match[1], 10); + currentFragmentIndex = fragIdx; + if (fragIdx === responseFragmentIndex) { + textContent += data.v; + } + } + } + + // 不带操作符的路径设置 (如 {"v": "xxx", "p": "response/fragments/1/content"}) + if (data.p && typeof data.v === 'string' && !data.o) { + const match = data.p.match(/response\/fragments\/(\d+)\/content/); + if (match) { + const fragIdx = parseInt(match[1], 10); + currentFragmentIndex = fragIdx; + if (fragIdx === responseFragmentIndex) { + textContent += data.v; + } + } + } + + // fragments APPEND - 新增 fragment (非 BATCH) + if (data.p === 'response/fragments' && data.o === 'APPEND' && Array.isArray(data.v)) { + for (const fragment of data.v) { + const idx = fragmentCount++; + if (fragment.type === 'RESPONSE') { + responseFragmentIndex = idx; + currentFragmentIndex = idx; + if (fragment.content) { + textContent += fragment.content; + } + } else { + // THINK 或 SEARCH + currentFragmentIndex = idx; + } + } + } + + // BATCH 操作中的 fragments + if (data.o === 'BATCH' && data.p === 'response' && Array.isArray(data.v)) { + for (const item of data.v) { + // fragments 追加 + if (item.p === 'fragments' && item.o === 'APPEND' && Array.isArray(item.v)) { + for (const fragment of item.v) { + const idx = fragmentCount++; + if (fragment.type === 'RESPONSE') { + responseFragmentIndex = idx; + currentFragmentIndex = idx; + if (fragment.content) { + textContent += fragment.content; + } + } else { + // THINK 或 SEARCH + currentFragmentIndex = idx; + } + } + } + // 检查是否完成 + if (item.p === 'status' && item.v === 'FINISHED') { + isComplete = true; + } + } + } + } catch { + // 忽略解析错误 + } + } + + return isComplete; + } catch { + return false; + } + }, { timeout: 180000 }); + } catch (e) { + const pageError = normalizePageError(e, meta); + if (pageError) return pageError; + throw e; + } + + if (!textContent || textContent.trim() === '') { + logger.warn('适配器', '回复内容为空', meta); + return { error: '回复内容为空' }; + } + + logger.info('适配器', `已获取文本内容 (${textContent.length} 字符)`, meta); + logger.info('适配器', '文本生成完成,任务完成', meta); + return { text: textContent.trim() }; + + } catch (err) { + // 顶层错误处理 + const pageError = normalizePageError(err, meta); + if (pageError) return pageError; + + logger.error('适配器', '生成任务失败', { ...meta, error: err.message }); + return { error: `生成任务失败: ${err.message}` }; + } finally { + // 任务结束,将鼠标移至安全区域 + await moveMouseAway(page); + } +} + +/** + * 适配器 manifest + */ +export const manifest = { + id: 'deepseek_text', + displayName: 'DeepSeek (文本生成)', + + // 入口 URL + getTargetUrl(config, workerConfig) { + return TARGET_URL; + }, + + // 模型列表 + models: [ + { id: 'deepseek-v3.2', imagePolicy: 'forbidden' }, + { id: 'deepseek-v3.2-thinking', imagePolicy: 'forbidden', thinking: true }, + { id: 'deepseek-v3.2-search', imagePolicy: 'forbidden', search: true }, + { id: 'deepseek-v3.2-thinking-search', imagePolicy: 'forbidden', thinking: true, search: true }, + ], + + // 无需导航处理器 + navigationHandlers: [], + + // 核心文本生成方法 + generate +}; diff --git a/src/backend/adapter/zai_is.js b/src/backend/adapter/zai_is.js index df3eb2a..2b319ce 100644 --- a/src/backend/adapter/zai_is.js +++ b/src/backend/adapter/zai_is.js @@ -164,30 +164,79 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { await fillPrompt(page, INPUT_SELECTOR, prompt, meta); await sleep(500, 1000); - // 4. 设置请求拦截器 - 修改 chat/completions 请求中的 model 参数 - logger.debug('适配器', '已启用请求拦截', meta); - await page.unroute('**/*').catch(() => { }); + // 4. 通过 UI 交互选择模型 + const modelConfig = manifest.models.find(m => m.id === modelId); + const targetModel = modelConfig?.codeName || modelId; - await page.route(url => url.href.includes('chat/completions'), async (route) => { - const request = route.request(); - if (request.method() !== 'POST') return route.continue(); + logger.debug('适配器', `正在选择模型: ${targetModel}`, meta); - try { - const postData = request.postDataJSON(); - if (postData) { - logger.debug('适配器', '已拦截 chat/completions 请求,正在修改 model...', meta); - postData.model = modelId; - logger.info('适配器', `已拦截请求,使用模型: ${modelId}`, meta); - await route.continue({ postData: JSON.stringify(postData) }); - return; - } - } catch (e) { - logger.error('适配器', '请求拦截处理失败', { ...meta, error: e.message }); + // 点击 "Select a models" 按钮 + const selectModelBtn = page.getByRole('button', { name: 'Select a model' }); + await selectModelBtn.waitFor({ timeout: 5000 }); + await sleep(300, 500); + await safeClick(page, selectModelBtn, { bias: 'button' }); + await sleep(500, 800); + + // 在 "Search In Models" 文本框中输入模型名称 + const searchInput = page.getByRole('textbox', { name: 'Search In Models' }); + await searchInput.waitFor({ timeout: 5000 }); + await searchInput.fill(targetModel); + await sleep(300, 500); + + // 按回车确认选择 + await searchInput.press('Enter'); + await sleep(500, 1000); + + logger.info('适配器', `已选择模型: ${targetModel}`, meta); + + // 5. 检查 GIF Generation 按钮状态,确保为 OFF + const gifBtn = page.getByRole('button', { name: /^GIF Generation/ }); + const gifBtnExists = await gifBtn.count(); + if (gifBtnExists > 0) { + const gifState = await gifBtn.evaluate(el => { + const generic = el.querySelector('[role="generic"]') || el; + return generic.textContent?.trim() || ''; + }); + + if (!gifState.includes('OFF')) { + logger.debug('适配器', `GIF Generation 当前为 ${gifState},正在切换为 OFF`, meta); + await safeClick(page, gifBtn, { bias: 'button' }); + await sleep(300, 500); } - await route.continue(); - }); + } - // 5. 提交 + // 6. 设置图片大小 (如果模型配置了 imageSize) + if (modelConfig?.imageSize) { + const targetSize = modelConfig.imageSize; // 例如 "1K", "2K", "4K" + logger.debug('适配器', `正在设置图片大小: ${targetSize}`, meta); + + const imageSizeBtn = page.getByRole('button', { name: /^Image Size/ }); + const btnExists = await imageSizeBtn.count(); + + if (btnExists > 0) { + // 最多点击 4 次切换 + for (let i = 0; i < 4; i++) { + // 获取当前图片大小 (从按钮下的 generic 元素中的 text leaf 获取) + const currentSize = await imageSizeBtn.evaluate(el => { + const generic = el.querySelector('[role="generic"]') || el; + return generic.textContent?.trim() || ''; + }); + + if (currentSize.includes(targetSize)) { + logger.info('适配器', `图片大小已设置为: ${targetSize}`, meta); + break; + } + + // 点击切换 + await safeClick(page, imageSizeBtn, { bias: 'button' }); + await sleep(300, 500); + } + } else { + logger.debug('适配器', '未找到 Image Size 按钮', meta); + } + } + + // 7. 提交 logger.debug('适配器', '点击发送...', meta); await submit(page, { btnSelector: 'button[type="submit"]', @@ -197,7 +246,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { logger.info('适配器', '等待生成结果中...', meta); - // 6. 等待 v1/chats/new 响应 (状态码 200 且响应体中有 id) + // 8. 等待 v1/chats/new 响应 (状态码 200 且响应体中有 id) let chatsNewResponse; try { chatsNewResponse = await waitApiResponse(page, { @@ -231,7 +280,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { return { error: '解析对话响应失败' }; } - // 7. 等待 chat/completions 响应 (状态码 200 且 status: true) + // 9. 等待 chat/completions 响应 (状态码 200 且 status: true) let completionsResponse; try { completionsResponse = await waitApiResponse(page, { @@ -264,7 +313,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { return { error: '解析生成响应失败' }; } - // 8. 等待 chat/completed 响应,从中提取图片链接 + // 10. 等待 chat/completed 响应,从中提取图片链接 logger.debug('适配器', '正在等待完成响应...', meta); let completedResponse; @@ -309,8 +358,8 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { return { error: '回复内容为空可能触发违规/限流' }; } - // 从 content 中提取图片链接 (格式: ![image](https://zai.is/media/xxx.jpg)) - const imageUrlMatch = content.match(/!\[.*?\]\((https:\/\/zai\.is\/media\/[^)]+)\)/); + // 从 content 中提取图片链接 (格式: ![...](https://zai.is/...)) + const imageUrlMatch = content.match(/!\[.*?\]\((https:\/\/zai\.is\/[^)]+)\)/); if (!imageUrlMatch || !imageUrlMatch[1]) { logger.warn('适配器', '回复中未找到图片链接', meta); return { error: '回复中未找到图片链接' }; @@ -336,8 +385,6 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { logger.error('适配器', '生成任务失败', { ...meta, error: err.message }); return { error: `生成任务失败: ${err.message}` }; } finally { - // 清理拦截器 - await page.unroute('**/*').catch(() => { }); // 任务结束,将鼠标移至安全区域 await moveMouseAway(page); } @@ -357,8 +404,10 @@ export const manifest = { // 模型列表 models: [ - { id: 'gemini-3-pro-image-preview', imagePolicy: 'optional' }, - { id: 'gemini-2.5-flash-image', imagePolicy: 'optional' } + { id: 'gemini-3-pro-image-preview', codeName: 'Nano Banana Pro', imagePolicy: 'optional', imageSize: '1K' }, + { id: 'gemini-3-pro-image-preview-2k', codeName: 'Nano Banana Pro', imagePolicy: 'optional', imageSize: '2K' }, + { id: 'gemini-3-pro-image-preview-4k', codeName: 'Nano Banana Pro', imagePolicy: 'optional', imageSize: '4K' }, + { id: 'gemini-2.5-flash-image', codeName: 'Nano Banana', imagePolicy: 'optional' } ], // 导航处理器 diff --git a/src/backend/adapter/zai_is_text.js b/src/backend/adapter/zai_is_text.js new file mode 100644 index 0000000..5d4e9d6 --- /dev/null +++ b/src/backend/adapter/zai_is_text.js @@ -0,0 +1,393 @@ +/** + * @fileoverview zAI 文本生成适配器 + */ + +import { + sleep, + safeClick, + pasteImages +} from '../engine/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'; + +// zai.is 输入框选择器 +const INPUT_SELECTOR = '.tiptap.ProseMirror'; + +// 入口 URL +const TARGET_URL = 'https://zai.is/'; + +/** + * 处理 Discord OAuth2 登录流程 + * @param {import('playwright-core').Page} page + * @returns {Promise} 是否处理了登录 + */ +async function handleDiscordAuth(page) { + // 防止重复处理 + if (isPageAuthLocked(page)) return false; + + const currentUrl = page.url(); + + // 1. 检查是否在 zai.is/auth 页面 + if (currentUrl.includes('zai.is/auth')) { + lockPageAuth(page); + logger.info('适配器', '[登录器(zai)] 检测到登录页面,正在处理 Discord 登录...'); + + try { + // 等待页面加载完成,点击唯一的 button 标签 + await page.waitForSelector('button', { timeout: 30000 }); + await sleep(1000, 1500); + await safeClick(page, 'button', { bias: 'button' }); + logger.info('适配器', '[登录器(zai)] 已点击登录按钮,等待跳转到 Discord...'); + + // 2. 等待跳转到 Discord OAuth2 授权页面 + await page.waitForURL(url => url.href.includes('discord.com/oauth2/authorize'), { timeout: 60000 }); + logger.info('适配器', '[登录器(zai)] 已到达 Discord 授权页面'); + await sleep(2000, 3000); + + // 3. 使用鼠标滚轮滚动 main 元素,直到授权按钮可用 + // 授权按钮选择器: data-align="stretch" 的 div 中的最后一个按钮 (授权按钮在右边) + const authorizeBtnSelector = 'div[data-align="stretch"] button:last-child'; + + for (let i = 0; i < 15; i++) { + const authorizeBtn = await page.$(authorizeBtnSelector); + if (authorizeBtn) { + const isDisabled = await authorizeBtn.evaluate(el => el.disabled).catch(() => true); + if (!isDisabled) { + logger.info('适配器', '[登录器(zai)] 授权按钮已可用,正在点击...'); + await sleep(500, 1000); + await safeClick(page, authorizeBtn, { bias: 'button' }); + break; + } + } + // 使用鼠标滚轮在 main 元素中滚动 + const mainElement = await page.$('main'); + if (mainElement) { + const box = await mainElement.boundingBox(); + if (box) { + // 将鼠标移动到 main 元素中心并滚动 + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, 200); + } + } + await sleep(800, 1200); + } + + // 4. 等待跳转回 zai.is (不包含 auth 和 discord) + logger.info('适配器', '[登录器(zai)] 等待跳转回目标页面...'); + await page.waitForURL(url => { + const href = url.href; + return href.includes('zai.is') && + !href.includes('/auth') && + !href.includes('discord.com'); + }, { timeout: 60000 }); + + logger.info('适配器', '[登录器(zai)] Discord 登录完成'); + await sleep(2000, 3000); + unlockPageAuth(page); + return true; + } catch (err) { + logger.warn('适配器', `[登录器(zai)] Discord 登录处理失败: ${err.message}`); + unlockPageAuth(page); + } + } + + + return false; +} + +/** + * 从 content 中移除开头的
思考块 + * @param {string} content - 原始内容 + * @returns {string} 处理后的内容 + */ +function extractTextContent(content) { + if (!content) return ''; + + // 匹配开头的
...
块 + // 使用非贪婪匹配和 dotAll 模式 (s flag) + const detailsPattern = /^]*>[\s\S]*?<\/details>\s*/; + + // 移除开头的 details 块,返回剩余内容 + const result = content.replace(detailsPattern, '').trim(); + + return result; +} + + +/** + * 生成文本 + * @param {object} context - 浏览器上下文 { page, client, config } + * @param {string} prompt - 提示词 + * @param {string[]} imgPaths - 参考图片路径数组 + * @param {string} modelId - 模型 ID + * @param {object} meta - 日志元数据 + * @returns {Promise<{text?: string, error?: string}>} 生成结果 + */ +async function generate(context, prompt, imgPaths, modelId, meta = {}) { + const { page, config } = context; + + try { + // 开启新对话 - 先等待可能正在进行的登录处理完成 + await waitForPageAuth(page); + + logger.info('适配器', '开启新会话', meta); + await gotoWithCheck(page, TARGET_URL); + + // 如果触发了登录跳转,等待全局处理器完成 + await waitForPageAuth(page); + + // 1. 等待输入框加载 + logger.debug('适配器', '正在寻找输入框...', meta); + await waitForInput(page, INPUT_SELECTOR, { click: false }); + await sleep(1500, 2500); + + // 2. 上传图片 (如果有多张图片,会一张一张上传,每次都是 v1/files POST 请求) + if (imgPaths && imgPaths.length > 0) { + const expectedUploads = imgPaths.length; + let uploadedCount = 0; + + await pasteImages(page, INPUT_SELECTOR, imgPaths, { + uploadValidator: (response) => { + const url = response.url(); + if (response.status() === 200 && url.includes('v1/files')) { + uploadedCount++; + logger.info('适配器', `图片上传进度: ${uploadedCount}/${expectedUploads}`, meta); + if (uploadedCount >= expectedUploads) { + return true; + } + } + return false; + } + }); + + await sleep(1000, 2000); + } + + // 3. 填写提示词 + await safeClick(page, INPUT_SELECTOR, { bias: 'input' }); + await fillPrompt(page, INPUT_SELECTOR, prompt, meta); + await sleep(500, 1000); + + // 4. 通过 UI 交互选择模型 + const modelConfig = manifest.models.find(m => m.id === modelId); + const targetModel = modelConfig?.codeName || modelId; + + logger.debug('适配器', `正在选择模型: ${targetModel}`, meta); + + // 点击 "Select a models" 按钮 + const selectModelBtn = page.getByRole('button', { name: 'Select a model' }); + await selectModelBtn.waitFor({ timeout: 5000 }); + await sleep(300, 500); + await safeClick(page, selectModelBtn, { bias: 'button' }); + await sleep(500, 800); + + // 在 "Search In Models" 文本框中输入模型名称 + const searchInput = page.getByRole('textbox', { name: 'Search In Models' }); + await searchInput.waitFor({ timeout: 5000 }); + await searchInput.fill(targetModel); + await sleep(300, 500); + + // 按回车确认选择 + await searchInput.press('Enter'); + await sleep(500, 1000); + + logger.info('适配器', `已选择模型: ${targetModel}`, meta); + + // 5. 提交 + logger.debug('适配器', '点击发送...', meta); + await submit(page, { + btnSelector: 'button[type="submit"]', + inputTarget: INPUT_SELECTOR, + meta + }); + + logger.info('适配器', '等待生成结果中...', meta); + + // 6. 等待 v1/chats/new 响应 (状态码 200 且响应体中有 id) + let chatsNewResponse; + try { + chatsNewResponse = await waitApiResponse(page, { + urlMatch: 'v1/chats/new', + method: 'POST', + timeout: 60000, + meta + }); + } catch (e) { + const pageError = normalizePageError(e, meta); + if (pageError) return pageError; + throw e; + } + + // 检查 chats/new 响应 + const httpError = normalizeHttpError(chatsNewResponse); + if (httpError) { + logger.error('适配器', `创建对话失败: ${httpError.error}`, meta); + return { error: `创建对话失败: ${httpError.error}` }; + } + + try { + const chatsNewBody = await chatsNewResponse.json(); + if (!chatsNewBody.id) { + logger.error('适配器', '创建对话响应中没有无 id', meta); + return { error: '创建对话响应中没有 id' }; + } + logger.debug('适配器', `对话创建成功, id: ${chatsNewBody.id}`, meta); + } catch (e) { + logger.error('适配器', '解析 chats/new 响应失败', { ...meta, error: e.message }); + return { error: '解析对话响应失败' }; + } + + // 7. 等待 chat/completions 响应 (状态码 200 且 status: true) + let completionsResponse; + try { + completionsResponse = await waitApiResponse(page, { + urlMatch: 'chat/completions', + method: 'POST', + timeout: 120000, + meta + }); + } catch (e) { + const pageError = normalizePageError(e, meta); + if (pageError) return pageError; + throw e; + } + + const completionsHttpError = normalizeHttpError(completionsResponse); + if (completionsHttpError) { + logger.error('适配器', `生成请求失败: ${completionsHttpError.error}`, meta); + return { error: `生成请求失败: ${completionsHttpError.error}` }; + } + + try { + const completionsBody = await completionsResponse.json(); + if (!completionsBody.status) { + logger.error('适配器', '生成响应 status 不为 true', meta); + return { error: '生成失败,响应状态异常' }; + } + logger.debug('适配器', '生成请求成功', meta); + } catch (e) { + logger.error('适配器', '解析 completions 响应失败', { ...meta, error: e.message }); + return { error: '解析生成响应失败' }; + } + + // 8. 等待 chat/completed 响应,从中提取文本内容 + logger.debug('适配器', '正在等待完成响应...', meta); + + let completedResponse; + try { + completedResponse = await waitApiResponse(page, { + urlMatch: 'chat/completed', + method: 'POST', + timeout: 120000, + meta + }); + } catch (e) { + const pageError = normalizePageError(e, meta); + if (pageError) { + if (e.name === 'TimeoutError') { + return { error: '等待完成响应超时 (120秒)' }; + } + return pageError; + } + throw e; + } + + // 解析 chat/completed 响应 + let completedBody; + try { + completedBody = await completedResponse.json(); + } catch (e) { + logger.error('适配器', '解析 chat/completed 响应失败', { ...meta, error: e.message }); + return { error: '解析完成响应失败' }; + } + + // 在 messages 数组中查找匹配的消息 (id 与响应体的 id 相同) + const targetMessage = (completedBody.messages || []).find(msg => msg.id === completedBody.id); + if (!targetMessage) { + logger.error('适配器', `未找到匹配的消息`, meta); + return { error: '未找到匹配的消息' }; + } + + // 检查 content + const content = targetMessage.content; + if (!content || content.trim() === '') { + logger.warn('适配器', '回复内容为空可能触发违规/限流', meta); + return { error: '回复内容为空可能触发违规/限流' }; + } + + // 提取文本内容 (移除开头的
思考块) + const textContent = extractTextContent(content); + if (!textContent) { + logger.warn('适配器', '提取文本内容为空', meta); + return { error: '提取文本内容为空' }; + } + + logger.info('适配器', `已提取文本内容 (${textContent.length} 字符)`, meta); + logger.info('适配器', '文本生成完成,任务完成', meta); + return { text: textContent }; + + } catch (err) { + // 顶层错误处理 + const pageError = normalizePageError(err, meta); + if (pageError) return pageError; + + logger.error('适配器', '生成任务失败', { ...meta, error: err.message }); + return { error: `生成任务失败: ${err.message}` }; + } finally { + // 任务结束,将鼠标移至安全区域 + await moveMouseAway(page); + } +} + +/** + * 适配器 manifest + */ +export const manifest = { + id: 'zai_is_text', + displayName: 'zAI (文本生成)', + + // 入口 URL + getTargetUrl(config, workerConfig) { + return TARGET_URL; + }, + + // 模型列表 - 文本生成模型 + models: [ + { id: 'glm-4.6', codeName: 'GLM 4.6', imagePolicy: 'optional' }, + { id: 'gemini-3-pro-preview', codeName: 'Gemini 3 Pro Preview', imagePolicy: 'optional' }, + { id: 'gemini-2.5-pro', codeName: 'Gemini 2.5 Pro', imagePolicy: 'optional' }, + { id: 'gemini-3-flash-preview', codeName: 'Gemini 3 Flash Preview', imagePolicy: 'optional' }, + { id: 'claude-sonnet-4.5', codeName: 'Claude Sonnet 4.5', imagePolicy: 'optional' }, + { id: 'claude-sonnet-4', codeName: 'Claude Sonnet 4', imagePolicy: 'optional' }, + { id: 'claude-haiku-4.5', codeName: 'Claude Haiku 4.5', imagePolicy: 'optional' }, + { id: 'gpt-5.1', codeName: 'GPT-5.1', imagePolicy: 'optional' }, + { id: 'gpt-5', codeName: 'GPT-5', imagePolicy: 'optional' }, + { id: 'gpt-4.1', codeName: 'GPT-4.1', imagePolicy: 'optional' }, + { id: 'gpt-5.2', codeName: 'GPT-5.2 Chat', imagePolicy: 'optional' }, + { id: 'o3-high', codeName: 'o3-high', imagePolicy: 'optional' }, + { id: 'o3-mini', codeName: 'o3-mini', imagePolicy: 'optional' }, + { id: 'o4-mini', codeName: 'o4-mini', imagePolicy: 'optional' }, + { id: 'grok-4.1-fast', codeName: 'Grok 4.1 Fast', imagePolicy: 'optional' }, + { id: 'grok-4', codeName: 'Grok 4', imagePolicy: 'optional' }, + { id: 'kimi-k2-thinking', codeName: 'Kimi K2 Thinking', imagePolicy: 'optional' }, + ], + + // 导航处理器 + navigationHandlers: [handleDiscordAuth], + + // 核心文本生成方法 + generate +};