Skip to content

Commit aae1851

Browse files
committed
feat: 支持豆包,修复 LMA 模型选择
1 parent eb1d4ee commit aae1851

8 files changed

Lines changed: 642 additions & 94 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [3.4.4] - 2026-01-10
99

10+
### ✨ Added
11+
- **新增适配器**
12+
- 支持豆包图片生成与文本生成适配器
13+
1014
### 🐛 Fixed
1115
- **未捕获的超时错误**
1216
- 修复因未捕获的超时错误导致的程序崩溃
17+
- **模型选择**
18+
- 修复 LMArena 模型选择的问题并同步模型列表
1319

1420
## [3.4.3] - 2025-12-26
1521

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
| [**DeepSeek**](https://chat.deepseek.com/) || 🚫 | 🚫 |
4646
| [**Sora**](https://sora.chatgpt.com/) | 🚫 | 🚫 ||
4747
| [**Google Flow**](https://labs.google/fx/zh/tools/flow) | 🚫 |||
48+
| [**豆包**](https://www.doubao.com/) ||||
4849
| 待续... | - | - | - |
4950

5051
> [!NOTE]

src/backend/adapter/doubao.js

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/**
2+
* @fileoverview 豆包 (Doubao) 图片生成适配器
3+
*/
4+
5+
import {
6+
sleep,
7+
safeClick,
8+
uploadFilesViaChooser
9+
} from '../engine/utils.js';
10+
import {
11+
fillPrompt,
12+
normalizePageError,
13+
moveMouseAway,
14+
waitForInput,
15+
gotoWithCheck,
16+
useContextDownload
17+
} from '../utils/index.js';
18+
import { logger } from '../../utils/logger.js';
19+
20+
// --- 配置常量 ---
21+
const TARGET_URL = 'https://www.doubao.com/chat/';
22+
23+
/**
24+
* 执行图片生成任务
25+
* @param {object} context - 浏览器上下文 { page, config }
26+
* @param {string} prompt - 提示词
27+
* @param {string[]} imgPaths - 图片路径数组
28+
* @param {string} [modelId] - 模型 ID
29+
* @param {object} [meta={}] - 日志元数据
30+
* @returns {Promise<{image?: string, error?: string}>}
31+
*/
32+
async function generate(context, prompt, imgPaths, modelId, meta = {}) {
33+
const { page } = context;
34+
35+
// 获取模型配置
36+
const modelConfig = manifest.models.find(m => m.id === modelId) || manifest.models[0];
37+
const { codeName } = modelConfig;
38+
39+
try {
40+
logger.info('适配器', '开启新会话...', meta);
41+
await gotoWithCheck(page, TARGET_URL);
42+
await sleep(1500, 2500);
43+
44+
// 1. 点击进入图片生成模式
45+
logger.debug('适配器', '进入图片生成模式...', meta);
46+
const skillBtn = page.locator('button[data-testid="skill_bar_button_3"]');
47+
await skillBtn.waitFor({ state: 'visible', timeout: 30000 });
48+
await safeClick(page, skillBtn, { bias: 'button' });
49+
await sleep(1000, 1500);
50+
51+
// 2. 选择模型
52+
logger.debug('适配器', `选择模型: ${codeName}...`, meta);
53+
const modelBtn = page.locator('button[data-testid="image-creation-chat-input-picture-model-button"]');
54+
await modelBtn.waitFor({ state: 'visible', timeout: 10000 });
55+
await safeClick(page, modelBtn, { bias: 'button' });
56+
await sleep(500, 800);
57+
58+
const modelOption = page.getByRole('menuitem', { name: codeName });
59+
await modelOption.waitFor({ state: 'visible', timeout: 5000 });
60+
await safeClick(page, modelOption, { bias: 'button' });
61+
await sleep(500, 800);
62+
63+
// 3. 上传参考图片 (如果有)
64+
if (imgPaths && imgPaths.length > 0) {
65+
logger.info('适配器', `开始上传 ${imgPaths.length} 张参考图片...`, meta);
66+
67+
const uploadBtn = page.locator('button[data-testid="image-creation-chat-input-picture-reference-button"]');
68+
await uploadBtn.waitFor({ state: 'visible', timeout: 10000 });
69+
70+
await uploadFilesViaChooser(page, uploadBtn, imgPaths, {
71+
uploadValidator: (response) => {
72+
const url = response.url();
73+
return response.status() === 200 &&
74+
url.includes('bytedanceapi.com') &&
75+
url.includes('Action=CommitImageUpload');
76+
}
77+
});
78+
79+
logger.info('适配器', '参考图片上传完成', meta);
80+
await sleep(1000, 1500);
81+
}
82+
83+
// 4. 填写提示词
84+
const inputLocator = page.locator('div[data-testid="chat_input_input"][role="textbox"]');
85+
await waitForInput(page, inputLocator, { click: true });
86+
await fillPrompt(page, inputLocator, prompt, meta);
87+
await sleep(500, 1000);
88+
89+
// 5. 设置 SSE 监听
90+
logger.debug('适配器', '启动 SSE 监听...', meta);
91+
92+
let imageUrl = null;
93+
let isResolved = false;
94+
95+
const resultPromise = new Promise((resolve, reject) => {
96+
const timeout = setTimeout(() => {
97+
if (!isResolved) {
98+
isResolved = true;
99+
reject(new Error('API_TIMEOUT: 响应超时 (180秒)'));
100+
}
101+
}, 180000);
102+
103+
const handleResponse = async (response) => {
104+
try {
105+
const url = response.url();
106+
if (!url.includes('chat/completion')) return;
107+
108+
const contentType = response.headers()['content-type'] || '';
109+
if (!contentType.includes('text/event-stream')) return;
110+
111+
const body = await response.text();
112+
const extractedUrl = parseSSEForImage(body);
113+
114+
if (extractedUrl) {
115+
imageUrl = extractedUrl;
116+
if (!isResolved) {
117+
isResolved = true;
118+
clearTimeout(timeout);
119+
page.off('response', handleResponse);
120+
resolve();
121+
}
122+
}
123+
} catch (e) {
124+
// 忽略解析错误
125+
}
126+
};
127+
128+
page.on('response', handleResponse);
129+
});
130+
131+
// 6. 点击发送
132+
const sendBtn = page.locator('button[data-testid="chat_input_send_button"]');
133+
await sendBtn.waitFor({ state: 'visible', timeout: 10000 });
134+
logger.info('适配器', '点击发送...', meta);
135+
await safeClick(page, sendBtn, { bias: 'button' });
136+
137+
// 7. 等待响应
138+
logger.info('适配器', '等待图片生成...', meta);
139+
await resultPromise;
140+
141+
if (!imageUrl) {
142+
return { error: '未能从响应中提取图片链接' };
143+
}
144+
145+
logger.info('适配器', '已获取图片链接,开始下载...', meta);
146+
147+
// 8. 下载图片
148+
const downloadResult = await useContextDownload(imageUrl, page);
149+
if (downloadResult.error) {
150+
logger.error('适配器', downloadResult.error, meta);
151+
return downloadResult;
152+
}
153+
154+
logger.info('适配器', '图片生成完成', meta);
155+
return { image: downloadResult.image };
156+
157+
} catch (err) {
158+
const pageError = normalizePageError(err, meta);
159+
if (pageError) return pageError;
160+
161+
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
162+
return { error: `生成任务失败: ${err.message}` };
163+
} finally {
164+
await moveMouseAway(page);
165+
}
166+
}
167+
168+
/**
169+
* 解析 SSE 响应,提取图片链接
170+
* @param {string} body - SSE 响应体
171+
* @returns {string|null} 图片 URL
172+
*/
173+
function parseSSEForImage(body) {
174+
const lines = body.split('\n');
175+
176+
for (let i = 0; i < lines.length; i++) {
177+
const line = lines[i].trim();
178+
179+
if (line.startsWith('data:')) {
180+
const dataLine = line.substring(5).trim();
181+
if (!dataLine || dataLine === '{}') continue;
182+
183+
try {
184+
const data = JSON.parse(dataLine);
185+
const url = extractRawImage(data);
186+
if (url) return url;
187+
} catch (e) {
188+
// JSON 解析失败,跳过
189+
}
190+
}
191+
}
192+
193+
return null;
194+
}
195+
196+
/**
197+
* 从 SSE 消息数据中提取原图 Raw 链接
198+
* @param {Object} sseData - 解析后的 data JSON 对象
199+
* @returns {string|null} - 返回图片 URL 或 null
200+
*/
201+
function extractRawImage(sseData) {
202+
if (!sseData || !sseData.patch_op || !Array.isArray(sseData.patch_op)) {
203+
return null;
204+
}
205+
206+
for (const op of sseData.patch_op) {
207+
const contentBlocks = op.patch_value?.content_block;
208+
209+
if (Array.isArray(contentBlocks)) {
210+
for (const block of contentBlocks) {
211+
// block_type 2074 代表生成卡片
212+
if (block.block_type === 2074) {
213+
const creations = block.content?.creation_block?.creations;
214+
215+
if (Array.isArray(creations)) {
216+
for (const creation of creations) {
217+
// 提取 image_ori_raw,只有图片生成完成时才会出现
218+
const rawUrl = creation.image?.image_ori_raw?.url;
219+
if (rawUrl) {
220+
return rawUrl;
221+
}
222+
}
223+
}
224+
}
225+
}
226+
}
227+
}
228+
229+
return null;
230+
}
231+
232+
/**
233+
* 适配器 manifest
234+
*/
235+
export const manifest = {
236+
id: 'doubao',
237+
displayName: '豆包 (图片生成)',
238+
description: '使用字节跳动豆包生成图片,支持多种模型和参考图片上传。需要已登录的豆包账户。',
239+
240+
getTargetUrl(config, workerConfig) {
241+
return TARGET_URL;
242+
},
243+
244+
models: [
245+
{ id: 'seedream-4.5', codeName: 'Seedream 4.5', imagePolicy: 'optional' },
246+
{ id: 'seedream-4.0', codeName: 'Seedream 4.0', imagePolicy: 'optional' },
247+
{ id: 'seedream-3.0', codeName: 'Seedream 3.0', imagePolicy: 'optional' }
248+
],
249+
250+
navigationHandlers: [],
251+
252+
generate
253+
};

0 commit comments

Comments
 (0)