From b5c9a79dfcd77f1d3ec19769c19f2cddd2ebb1ad Mon Sep 17 00:00:00 2001 From: daidai Date: Tue, 7 Apr 2026 08:33:54 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B1=86=E5=8C=85?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E9=80=82=E9=85=8D=E5=99=A8=20SSE=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=A4=B1=E8=B4=A5=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 豆包图片生成的 SSE 使用 event_data 嵌套结构(三层 JSON 字符串), 而非 patch_op 扁平结构。parseSSEForImage 新增对 event_data 格式 的支持,extractRawImage 扩展支持 creations 直接结构以复用提取逻辑。 --- src/backend/adapter/doubao.js | 81 ++++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 30 deletions(-) diff --git a/src/backend/adapter/doubao.js b/src/backend/adapter/doubao.js index c418ade..27ac547 100644 --- a/src/backend/adapter/doubao.js +++ b/src/backend/adapter/doubao.js @@ -124,6 +124,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { const contentType = response.headers()['content-type'] || ''; if (!contentType.includes('text/event-stream')) return; + await response.finished(); const body = await response.text(); const extractedUrl = parseSSEForImage(body); @@ -184,26 +185,44 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { /** * 解析 SSE 响应,提取图片链接 + * SSE 格式: data: {"event_data": "", "event_type": 2001} + * event_data 解析后: {"message": {"content_type": 2074, "content": ""}} + * content 解析后: {"creations": [{"image": {"image_ori_raw": {"url": "..."}}}]} * @param {string} body - SSE 响应体 * @returns {string|null} 图片 URL */ function parseSSEForImage(body) { const lines = body.split('\n'); - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('data:')) continue; - if (line.startsWith('data:')) { - const dataLine = line.substring(5).trim(); - if (!dataLine || dataLine === '{}') continue; + const dataStr = trimmed.substring(5).trim(); + if (!dataStr || dataStr === '{}') continue; - try { - const data = JSON.parse(dataLine); - const url = extractRawImage(data); - if (url) return url; - } catch (e) { - // JSON 解析失败,跳过 + try { + const data = JSON.parse(dataStr); + + // 新格式: event_data 嵌套结构 + if (data.event_data) { + const eventData = typeof data.event_data === 'string' + ? JSON.parse(data.event_data) : data.event_data; + const message = eventData?.message; + if (message?.content_type === 2074 && message.content) { + const content = typeof message.content === 'string' + ? JSON.parse(message.content) : message.content; + const url = extractRawImage(content); + if (url) return url; + } + continue; } + + // 旧格式: patch_op 扁平结构 (兼容) + const url = extractRawImage(data); + if (url) return url; + } catch (e) { + // JSON 解析失败,跳过 } } @@ -212,37 +231,39 @@ function parseSSEForImage(body) { /** * 从 SSE 消息数据中提取原图 Raw 链接 + * 支持两种格式: + * - patch_op 格式: {patch_op: [{patch_value: {content_block: [{block_type: 2074, content: {creation_block: {creations: [...]}}}]}}]} + * - creations 格式: {creations: [{image: {image_ori_raw: {url: "..."}}}]} * @param {Object} sseData - 解析后的 data JSON 对象 * @returns {string|null} - 返回图片 URL 或 null */ function extractRawImage(sseData) { - if (!sseData || !sseData.patch_op || !Array.isArray(sseData.patch_op)) { - return null; - } + if (!sseData) return null; - for (const op of sseData.patch_op) { - const contentBlocks = op.patch_value?.content_block; + // 格式 1: patch_op 结构 + if (Array.isArray(sseData.patch_op)) { + for (const op of sseData.patch_op) { + const contentBlocks = op.patch_value?.content_block; - if (Array.isArray(contentBlocks)) { - for (const block of contentBlocks) { - // block_type 2074 代表生成卡片 - if (block.block_type === 2074) { - const creations = block.content?.creation_block?.creations; - - if (Array.isArray(creations)) { - for (const creation of creations) { - // 提取 image_ori_raw,只有图片生成完成时才会出现 - const rawUrl = creation.image?.image_ori_raw?.url; - if (rawUrl) { - return rawUrl; - } - } + if (Array.isArray(contentBlocks)) { + for (const block of contentBlocks) { + if (block.block_type === 2074) { + const url = extractRawImage(block.content?.creation_block); + if (url) return url; } } } } } + // 格式 2: creations 直接结构 + if (Array.isArray(sseData.creations)) { + for (const creation of sseData.creations) { + const rawUrl = creation.image?.image_ori_raw?.url; + if (rawUrl) return rawUrl; + } + } + return null; } From 04bb4a70d2b4bb241ca90543bf6aa08ce4d070ce Mon Sep 17 00:00:00 2001 From: daidai Date: Tue, 7 Apr 2026 16:32:55 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E3=80=81reasoning=5Fcontent=20=E9=80=8F?= =?UTF-8?q?=E4=BC=A0=E5=8F=8A=E8=B1=86=E5=8C=85=E6=A8=A1=E5=9E=8B=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正 Playwright TimeoutError 提示为页面操作超时(非网络问题) - 新增 CLICK_TIMEOUT 错误归一化处理,标记为 retryable - buildChatCompletion/buildChatCompletionChunk 支持 reasoningContent 参数 - doubao_text 模型选择菜单增加重试逻辑(最多 3 次),waitFor 超时从 2s 改为 5s --- src/backend/adapter/doubao_text.js | 18 +++++++++++++----- src/backend/utils/error.js | 11 ++++++++--- src/server/respond.js | 2 -- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/backend/adapter/doubao_text.js b/src/backend/adapter/doubao_text.js index 636a43c..be989bc 100644 --- a/src/backend/adapter/doubao_text.js +++ b/src/backend/adapter/doubao_text.js @@ -60,18 +60,26 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { .first(); let selectorExists = false; try { - await modelSelectorBtn.waitFor({ state: 'attached', timeout: 1000 }); + await modelSelectorBtn.waitFor({ state: 'attached', timeout: 5000 }); selectorExists = true; } catch (e) { selectorExists = false; } if (selectorExists) { - await safeClick(page, modelSelectorBtn, { bias: 'button' }); - await sleep(300, 500); - const menuItem = page.getByRole('menuitem', { name: modelMenuName }); - await menuItem.waitFor({ state: 'visible', timeout: 5000 }); + // 点击模型选择按钮,最多重试 3 次(菜单偶尔不弹出) + for (let attempt = 1; attempt <= 3; attempt++) { + await sleep(500, 1000); + await safeClick(page, modelSelectorBtn, { bias: 'button' }); + try { + await menuItem.waitFor({ state: 'visible', timeout: 3000 }); + break; // 菜单弹出,退出重试 + } catch { + logger.warn('适配器', `模型菜单未弹出,重试 ${attempt}/3`, meta); + if (attempt === 3) throw new Error('模型选择菜单未弹出'); + } + } await safeClick(page, menuItem, { bias: 'button' }); await sleep(200, 400); } diff --git a/src/backend/utils/error.js b/src/backend/utils/error.js index 823ad2c..d579dff 100644 --- a/src/backend/utils/error.js +++ b/src/backend/utils/error.js @@ -68,10 +68,15 @@ export function normalizePageError(err, meta = {}) { logger.error('适配器', err.message, meta); return { error: err.message, code: ADAPTER_ERRORS.TIMEOUT_ERROR, retryable: true }; } - // 兼容原生 TimeoutError (其他地方抛出的) + // CLICK_TIMEOUT: safeClick 内部超时 + if (err.message?.includes('CLICK_TIMEOUT')) { + logger.error('适配器', `点击操作超时: ${err.message}`, meta); + return { error: err.message, code: ADAPTER_ERRORS.TIMEOUT_ERROR, retryable: true }; + } + // 兼容原生 TimeoutError (Playwright 元素操作超时等) if (err.name === 'TimeoutError' || err.message?.includes('Timeout')) { - logger.error('适配器', '请求超时', meta); - return { error: '请求超时, 请检查网络或稍后重试', code: ADAPTER_ERRORS.TIMEOUT_ERROR, retryable: true }; + logger.error('适配器', `页面操作超时: ${err.message}`, meta); + return { error: '页面操作超时, 页面可能未正常加载或元素未找到', code: ADAPTER_ERRORS.TIMEOUT_ERROR, retryable: true }; } // PAGE_ERROR_DETECTED: waitApiResponse 页面 UI 中检测到的错误关键词 if (err.message?.startsWith('PAGE_ERROR_DETECTED:')) { diff --git a/src/server/respond.js b/src/server/respond.js index 618d40f..9eadd4d 100644 --- a/src/server/respond.js +++ b/src/server/respond.js @@ -114,7 +114,6 @@ export function buildChatCompletion(content, modelName, reasoningContent) { role: 'assistant', content: content }; - if (reasoningContent) { message.reasoning_content = reasoningContent; } @@ -142,7 +141,6 @@ export function buildChatCompletion(content, modelName, reasoningContent) { */ export function buildChatCompletionChunk(content, modelName, finishReason = 'stop', reasoningContent) { const delta = { content }; - if (reasoningContent) { delta.reasoning_content = reasoningContent; } From bc4936a44f5c6cf7bcb4b142d153b9975f98b437 Mon Sep 17 00:00:00 2001 From: daidai Date: Tue, 7 Apr 2026 16:39:43 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20CHANGELOG=20(v?= =?UTF-8?q?3.6.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a58165d..2db1777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ 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.6.4] - 2026-04-07 + +### ✨ Added +- **适配器** + - 支持 thinking/reasoning 提取与传递(豆包、Gemini 等) + - 智能错误检测与 retryable 标记传播 + - Gemini 图片生成返回全尺寸原图 +- **API** + - buildChatCompletion/buildChatCompletionChunk 支持 reasoning_content 透传 + +### 🐛 Fixed +- **适配器** + - 修复豆包图片适配器 SSE 解析失败的问题(适配三层嵌套 JSON 格式) + - 修复豆包文本适配器模型选择菜单偶尔不弹出的问题(增加重试逻辑) + - 修复 Playwright TimeoutError 错误提示不准确的问题(改为页面操作超时) + - 新增 CLICK_TIMEOUT 错误归一化处理 + - 修复 LMArena 域名更新为 arena.ai + ## [3.6.3] - 2025-04-05 ### 🐛 Fixed