mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
Merge pull request #55 from solar2ain/fix/doubao-image-sse-parsing
fix: 修复豆包适配器及错误处理相关问题
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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": "<json_string>", "event_type": 2001}
|
||||
* event_data 解析后: {"message": {"content_type": 2074, "content": "<json_string>"}}
|
||||
* 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:')) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user