fix: 修复生成结果超时客户端无通知的问题

This commit is contained in:
foxhui
2025-12-09 00:41:40 +08:00
Unverified
parent 94e89ad0c7
commit 3b42e29341
5 changed files with 331 additions and 180 deletions
+7 -1
View File
@@ -5,7 +5,13 @@ 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).
## [2.0.0] - 2025-12-06
## [2.0.1] - 2025-12-09
### Fixed
- **修复超时逻辑**
- 修复在等待生成结果时超时,但是客户端任务未终止且无任何通知的问题
## [2.0.0] - 2025-12-08
### Added
- **自动续登**
+104 -54
View File
@@ -10,7 +10,9 @@ import {
safeClick,
humanType,
pasteImages,
getHumanClickPoint
getHumanClickPoint,
isPageValid,
createPageCloseWatcher
} from '../browser/utils.js';
import { logger } from '../utils/logger.js';
@@ -327,70 +329,118 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
logger.info('适配器', '等待生成结果中...', meta);
// 6. 等待结果 (使用 Playwright waitForResponse)
// 我们需要等待两个响应:
// 1. widgetStreamAssist (API 响应,检查是否成功)
// 2. download/v1alpha/projects (图片下载请求)
// 6. 创建页面关闭监听器
const pageWatcher = createPageCloseWatcher(page);
const apiResponsePromise = page.waitForResponse(response =>
response.url().includes('global/widgetStreamAssist') &&
response.request().method() === 'POST' &&
(response.status() === 200 || response.status() >= 400),
{ timeout: 120000 }
).catch(e => e);
try {
// 检查页面状态
if (!isPageValid(page)) {
throw new Error('PAGE_INVALID');
}
const imageDownloadPromise = page.waitForResponse(response =>
response.url().includes('download/v1alpha/projects') &&
response.request().method() === 'GET' &&
response.status() === 200,
{ timeout: 120000 }
).catch(e => e);
// 7. 等待 API 响应 (使用 Promise.race 监听页面事件)
const apiResponsePromise = page.waitForResponse(response =>
response.url().includes('global/widgetStreamAssist') &&
response.request().method() === 'POST' &&
(response.status() === 200 || response.status() >= 400),
{ timeout: 120000 }
);
// 等待 API 响应
const apiResponse = await apiResponsePromise;
const apiResponse = await Promise.race([
apiResponsePromise,
pageWatcher.promise
]).catch(e => {
// 错误分类
if (e.message === 'PAGE_CLOSED') {
logger.error('适配器', '页面已关闭(API响应等待期间)', meta);
throw new Error('页面已关闭,请勿在生图过程中刷新页面');
}
if (e.message === 'PAGE_CRASHED') {
logger.error('适配器', '页面崩溃(API响应等待期间)', meta);
throw new Error('页面崩溃,请重试');
}
if (e.name === 'TimeoutError') {
logger.error('适配器', 'API 请求超时(120秒)', meta);
throw new Error('API 请求超时(120秒),请检查网络或稍后重试');
}
throw e;
});
if (apiResponse instanceof Error) {
throw apiResponse;
if (apiResponse.status() !== 200) {
logger.error('适配器', `API 返回错误状态码: ${apiResponse.status()}`, meta);
return { error: `API 错误: HTTP ${apiResponse.status()}` };
}
// 8. 等待图片下载响应
logger.info('适配器', 'API 请求成功,等待图片下载...', meta);
const imageDownloadPromise = page.waitForResponse(response =>
response.url().includes('download/v1alpha/projects') &&
response.request().method() === 'GET' &&
response.status() === 200,
{ timeout: 120000 }
);
const imageResponse = await Promise.race([
imageDownloadPromise,
pageWatcher.promise
]).catch(e => {
// 错误分类
if (e.message === 'PAGE_CLOSED') {
logger.error('适配器', '页面已关闭(图片下载期间)', meta);
throw new Error('页面已关闭,请勿在生图过程中刷新页面');
}
if (e.message === 'PAGE_CRASHED') {
logger.error('适配器', '页面崩溃(图片下载期间)', meta);
throw new Error('页面崩溃,请重试');
}
if (e.name === 'TimeoutError') {
logger.error('适配器', 'API 请求成功,但图片下载超时(120秒)', meta);
throw new Error('API 请求成功,但图片下载超时(120秒),请重试');
}
throw e;
});
logger.info('适配器', '捕获到图片下载请求', meta);
// 响应体本身就是 base64 字符串,直接获取文本即可,不需要再次 base64 编码
const base64 = await imageResponse.text();
const dataUri = `data:image/png;base64,${base64}`;
logger.info('适配器', '生图成功', meta);
// 任务结束,移开鼠标
if (page.cursor) {
const currentVp = await getRealViewport(page);
const relativeX = currentVp.safeWidth * random(0.85, 0.95);
const relativeY = currentVp.height * random(0.3, 0.7);
const finalX = clamp(relativeX, 0, currentVp.safeWidth);
const finalY = clamp(relativeY, 0, currentVp.safeHeight);
await page.cursor.moveTo({ x: finalX, y: finalY });
}
return { image: dataUri };
} finally {
// 清理页面事件监听器
pageWatcher.cleanup();
}
if (apiResponse.status() !== 200) {
logger.error('适配器', `请求返回错误状态码: ${apiResponse.status()}`, meta);
return { error: `API Error: ${apiResponse.status()}` };
}
// 等待图片下载响应
logger.info('适配器', 'API 请求成功,等待图片下载...', meta);
const imageResponse = await imageDownloadPromise;
if (imageResponse instanceof Error) {
throw imageResponse;
}
logger.info('适配器', '捕获到图片下载请求', meta);
// 响应体本身就是 base64 字符串,直接获取文本即可,不需要再次 base64 编码
const base64 = await imageResponse.text();
const dataUri = `data:image/png;base64,${base64}`;
logger.info('适配器', '生图成功', meta);
// 任务结束,移开鼠标
if (page.cursor) {
const currentVp = await getRealViewport(page);
const relativeX = currentVp.safeWidth * random(0.85, 0.95);
const relativeY = currentVp.height * random(0.3, 0.7);
const finalX = clamp(relativeX, 0, currentVp.safeWidth);
const finalY = clamp(relativeY, 0, currentVp.safeHeight);
await page.cursor.moveTo({ x: finalX, y: finalY });
}
return { image: dataUri };
} catch (err) {
// 详细的错误分类和日志
if (err.message === 'PAGE_INVALID') {
logger.error('适配器', '页面状态无效', meta);
return { error: '页面状态无效,请重新初始化' };
}
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
return { error: err.message };
} finally {
// 清理拦截器
await page.unroute('**/*').catch(() => { });
try {
await page.unroute('**/*').catch(() => { });
} catch (e) {
// 忽略清理错误
}
}
}
+104 -74
View File
@@ -10,7 +10,9 @@ import {
safeClick,
humanType,
pasteImages,
getHumanClickPoint
getHumanClickPoint,
isPageValid,
createPageCloseWatcher
} from '../browser/utils.js';
import { logger } from '../utils/logger.js';
import { loadConfig } from '../utils/config.js';
@@ -130,81 +132,109 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
});
}
// 4. 建立响应监听器
// 只要 URL 匹配且是 POST,无论状态码是 200 还是 429,都立即返回,防止超时死等
const responsePromise = page.waitForResponse(response =>
response.url().includes('/nextjs-api/stream') &&
response.request().method() === 'POST' &&
(response.status() === 200 || response.status() >= 400),
{ timeout: 120000 }
).catch(e => e);
// 4. 创建页面关闭监听器
const pageWatcher = createPageCloseWatcher(page);
logger.debug('适配器', '点击发送...', meta);
const btnSelector = 'button[type="submit"]';
await safeClick(page, btnSelector, { bias: 'button' });
logger.info('适配器', '等待生成结果...', meta);
// 5. 等待并处理响应
const response = await responsePromise;
if (response instanceof Error) {
throw response;
}
// 检查状态码
if (response.status() === 429) {
logger.warn('适配器', '触发限流/人机验证', meta);
return { error: 'Rate limit exceeded or CAPTCHA triggered (HTTP 429)' };
}
if (response.status() !== 200) {
logger.warn('适配器', `返回异常状态码: ${response.status()}`, meta);
return { error: `Server error: HTTP ${response.status()}` };
}
// 解析成功响应
const content = await response.text();
// 检查业务错误
if (content.includes('recaptcha validation failed')) {
return { error: 'recaptcha validation failed' };
}
const img = extractImage(content);
if (img) {
logger.info('适配器', '已获取生图结果,正在下载图片...', meta);
try {
// 获取代理配置
const config = loadConfig();
const proxyConfig = getProxyConfig(config);
const proxyUrl = await getHttpProxy(proxyConfig);
const options = {
url: img,
responseType: 'buffer',
http2: true,
headerGeneratorOptions: {
browsers: [{ name: 'firefox', minVersion: 100 }],
devices: ['desktop'],
locales: ['en-US'],
operatingSystems: ['windows'],
}
};
if (proxyUrl) {
options.proxyUrl = proxyUrl;
}
const imgRes = await gotScraping(options);
const base64 = imgRes.body.toString('base64');
return { image: `data:image/png;base64,${base64}` };
} catch (e) {
return { error: `Image download failed: ${e.message}` };
try {
// 检查页面状态
if (!isPageValid(page)) {
throw new Error('PAGE_INVALID');
}
} else {
logger.info('适配器', 'AI 返回文本回复', { ...meta, preview: content.substring(0, 150) });
return { text: content };
// 5. 点击发送按钮
logger.debug('适配器', '点击发送...', meta);
const btnSelector = 'button[type="submit"]';
await safeClick(page, btnSelector, { bias: 'button' });
logger.info('适配器', '等待生成结果...', meta);
// 6. 等待响应 (使用 Promise.race)
const responsePromise = page.waitForResponse(response =>
response.url().includes('/nextjs-api/stream') &&
response.request().method() === 'POST' &&
(response.status() === 200 || response.status() >= 400),
{ timeout: 120000 }
);
const response = await Promise.race([
responsePromise,
pageWatcher.promise
]).catch(e => {
// 错误分类
if (e.message === 'PAGE_CLOSED') {
logger.error('适配器', '页面已关闭(等待响应期间)', meta);
throw new Error('页面已关闭,请勿在生图过程中刷新页面');
}
if (e.message === 'PAGE_CRASHED') {
logger.error('适配器', '页面崩溃(等待响应期间)', meta);
throw new Error('页面崩溃,请重试');
}
if (e.name === 'TimeoutError') {
logger.error('适配器', 'API 请求超时(120秒)', meta);
throw new Error('API 请求超时(120秒),请检查网络或稍后重试');
}
throw e;
});
// 检查状态码
if (response.status() === 429 || content.includes('Too Many Requests')) {
logger.warn('适配器', '触发限流/上游繁忙', meta);
return { error: 'Rate limit exceeded or CAPTCHA triggered (HTTP 429)' };
}
if (response.status() !== 200) {
logger.warn('适配器', `返回异常状态码: ${response.status()}`, meta);
return { error: `Server error: HTTP ${response.status()}` };
}
// 解析成功响应
const content = await response.text();
// 检查业务错误
if (content.includes('recaptcha validation failed')) {
logger.warn('适配器', '触发人机验证', meta);
return { error: 'recaptcha validation failed' };
}
const img = extractImage(content);
if (img) {
logger.info('适配器', '已获取生图结果,正在下载图片...', meta);
try {
// 获取代理配置
const config = loadConfig();
const proxyConfig = getProxyConfig(config);
const proxyUrl = await getHttpProxy(proxyConfig);
const options = {
url: img,
responseType: 'buffer',
http2: true,
headerGeneratorOptions: {
browsers: [{ name: 'firefox', minVersion: 100 }],
devices: ['desktop'],
locales: ['en-US'],
operatingSystems: ['windows'],
}
};
if (proxyUrl) {
options.proxyUrl = proxyUrl;
}
const imgRes = await gotScraping(options);
const base64 = imgRes.body.toString('base64');
return { image: `data:image/png;base64,${base64}` };
} catch (e) {
return { error: `Image download failed: ${e.message}` };
}
} else {
logger.info('适配器', 'AI 返回文本回复', { ...meta, preview: content.substring(0, 150) });
return { text: content };
}
} finally {
// 清理页面事件监听器
pageWatcher.cleanup();
}
} catch (err) {
+79 -51
View File
@@ -9,7 +9,9 @@ import {
safeClick,
humanType,
pasteImages,
getHumanClickPoint
getHumanClickPoint,
isPageValid,
createPageCloseWatcher
} from '../browser/utils.js';
import { logger } from '../utils/logger.js';
@@ -88,61 +90,87 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
await humanType(page, textareaSelector, prompt);
await sleep(800, 1500);
// 3. 建立响应监听器
// 监听包含 v1/generateContent 路径的 POST 请求
const responsePromise = page.waitForResponse(response =>
response.url().includes('v1/generateContent') &&
response.request().method() === 'POST' &&
(response.status() === 200 || response.status() >= 400),
{ timeout: 120000 }
).catch(e => e);
// 3. 创建页面关闭监听器
const pageWatcher = createPageCloseWatcher(page);
// 4. 点击发送按钮 (匹配 class 包含 _sendButton_ 的 div)
logger.debug('适配器', '点击发送...', meta);
// 使用更通用的选择器匹配发送按钮
const sendBtnSelector = 'div[class*="_sendButton_"]';
await page.waitForSelector(sendBtnSelector, { timeout: 10000 });
await safeClick(page, sendBtnSelector, { bias: 'button' });
logger.info('适配器', '等待生成结果...', meta);
// 5. 等待并处理响应
const response = await responsePromise;
if (response instanceof Error) {
throw response;
}
// 检查状态码
if (response.status() !== 200) {
// 非200状态,尝试读取错误信息
try {
const body = await response.json();
const errMessage = body?.errMessage || body?.error?.message || `HTTP ${response.status()}`;
logger.warn('适配器', `请求返回错误: ${errMessage}`, meta);
return { error: errMessage };
} catch (e) {
logger.warn('适配器', `返回异常状态码: ${response.status()}`, meta);
return { error: `Server error: HTTP ${response.status()}` };
try {
// 检查页面状态
if (!isPageValid(page)) {
throw new Error('PAGE_INVALID');
}
}
// 解析成功响应
const body = await response.json();
// 4. 点击发送按钮 (匹配 class 包含 _sendButton_ 的 div)
logger.debug('适配器', '点击发送...', meta);
// 尝试从响应中提取 base64 图片
// 路径: data.candidates[0].content.parts[0].inlineData.data
const inlineData = body?.data?.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
// 使用更通用的选择器匹配发送按钮
const sendBtnSelector = 'div[class*="_sendButton_"]';
await page.waitForSelector(sendBtnSelector, { timeout: 10000 });
await safeClick(page, sendBtnSelector, { bias: 'button' });
if (inlineData) {
logger.info('适配器', '已获取生图结果', meta);
// 返回带有 data URI 前缀的 base64 图片
return { image: `data:image/png;base64,${inlineData}` };
} else {
// 没有找到图片数据,可能是文本回复或其他格式
logger.info('适配器', 'AI 返回非图片响应', { ...meta, preview: JSON.stringify(body).substring(0, 150) });
return { text: JSON.stringify(body) };
logger.info('适配器', '等待生成结果...', meta);
// 5. 等待响应 (使用 Promise.race)
const responsePromise = page.waitForResponse(response =>
response.url().includes('v1/generateContent') &&
response.request().method() === 'POST' &&
(response.status() === 200 || response.status() >= 400),
{ timeout: 120000 }
);
const response = await Promise.race([
responsePromise,
pageWatcher.promise
]).catch(e => {
// 错误分类
if (e.message === 'PAGE_CLOSED') {
logger.error('适配器', '页面已关闭(等待响应期间)', meta);
throw new Error('页面已关闭,请勿在生图过程中刷新页面');
}
if (e.message === 'PAGE_CRASHED') {
logger.error('适配器', '页面崩溃(等待响应期间)', meta);
throw new Error('页面崩溃,请重试');
}
if (e.name === 'TimeoutError') {
logger.error('适配器', 'API 请求超时(120秒)', meta);
throw new Error('API 请求超时(120秒),请检查网络或稍后重试');
}
throw e;
});
// 检查状态码
if (response.status() !== 200) {
// 非200状态,尝试读取错误信息
try {
const body = await response.json();
const errMessage = body?.errMessage || body?.error?.message || `HTTP ${response.status()}`;
logger.warn('适配器', `请求返回错误: ${errMessage}`, meta);
return { error: errMessage };
} catch (e) {
logger.warn('适配器', `返回异常状态码: ${response.status()}`, meta);
return { error: `Server error: HTTP ${response.status()}` };
}
}
// 解析成功响应
const body = await response.json();
// 尝试从响应中提取 base64 图片
// 路径: data.candidates[0].content.parts[0].inlineData.data
const inlineData = body?.data?.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
if (inlineData) {
logger.info('适配器', '已获取生图结果', meta);
// 返回带有 data URI 前缀的 base64 图片
return { image: `data:image/png;base64,${inlineData}` };
} else {
// 没有找到图片数据,可能是文本回复或其他格式
logger.info('适配器', 'AI 返回非图片响应', { ...meta, preview: JSON.stringify(body).substring(0, 150) });
return { text: JSON.stringify(body) };
}
} finally {
// 清理页面事件监听器
pageWatcher.cleanup();
}
} catch (err) {
+37
View File
@@ -405,3 +405,40 @@ export async function pasteImages(page, target, filePaths, options = {}) {
throw e;
}
}
/**
* 检查页面是否有效
* @param {import('playwright-core').Page} page
* @returns {boolean}
*/
export function isPageValid(page) {
try {
return page && !page.isClosed();
} catch {
return false;
}
}
/**
* 创建页面关闭/崩溃监听Promise
* @param {import('playwright-core').Page} page
* @returns {{promise: Promise, cleanup: Function}}
*/
export function createPageCloseWatcher(page) {
let closeHandler, crashHandler;
const promise = new Promise((_, reject) => {
closeHandler = () => reject(new Error('PAGE_CLOSED'));
crashHandler = () => reject(new Error('PAGE_CRASHED'));
page.once('close', closeHandler);
page.once('crash', crashHandler);
});
const cleanup = () => {
if (closeHandler) page.off('close', closeHandler);
if (crashHandler) page.off('crash', crashHandler);
};
return { promise, cleanup };
}