diff --git a/src/backend/adapter/chatgpt.js b/src/backend/adapter/chatgpt.js index 5cc41fa..1cf78b3 100644 --- a/src/backend/adapter/chatgpt.js +++ b/src/backend/adapter/chatgpt.js @@ -110,12 +110,79 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { return { error: `API 返回错误: HTTP ${conversationResponse.status()}` }; } + // 5.5 解析 conversation 响应,检查是否是纯文本回复(拒绝/限流场景) + let conversationText = ''; + let isImageGenerationStarted = false; + let conversationBody = ''; + try { + conversationBody = await conversationResponse.text(); + + // 检查是否有图片生成相关的内容 (dalle 工具调用或 file_ 文件引用) + // 注意:不使用 'image' 关键词,因为拒绝消息也会包含这个词 + isImageGenerationStarted = conversationBody.includes('dalle') || conversationBody.includes('file_'); + logger.debug('适配器', `isImageGenerationStarted: ${isImageGenerationStarted}`, meta); + + // 提取文本内容 + const lines = conversationBody.split('\n'); + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const dataStr = line.slice(6).trim(); + if (dataStr === '[DONE]') continue; + try { + const data = JSON.parse(dataStr); + // 提取初始文本 (channel=final 的 assistant 消息) + if (data.v?.message?.channel === 'final' && + data.v?.message?.author?.role === 'assistant' && + data.v?.message?.content?.parts?.length > 0) { + const part = data.v.message.content.parts[0]; + if (typeof part === 'string') { + conversationText = part; + } + } + // patch 格式累加 (data.v 是 patch 操作数组) + if (Array.isArray(data.v)) { + for (const patch of data.v) { + if (patch.o === 'append' && patch.p === '/message/content/parts/0' && patch.v) { + conversationText += patch.v; + } + } + } + } catch { } + } + logger.debug('适配器', `提取到文本 (${conversationText.length} 字符): ${conversationText.substring(0, 200)}...`, meta); + } catch (e) { + logger.warn('适配器', `解析 conversation 响应失败: ${e.message}`, meta); + } + + // 早期检测:如果文本表明是拒绝/限流,立即返回,不等待图片超时 + if (conversationText) { + // 检查是否是速率限制错误 (不重试,同账号重试也没用) + const isRateLimit = conversationBody.includes('RateLimitException') || + conversationBody.includes('rate limit') || + /limit.*reset/i.test(conversationText); + + if (isRateLimit) { + logger.warn('适配器', `早期检测到速率限制: ${conversationText.substring(0, 200)}...`, meta); + return { error: `触发速率限制: ${conversationText.substring(0, 200)}`, retryable: false }; + } + + // 如果没有图片生成迹象,检查是否是内容被拒绝 + if (!isImageGenerationStarted) { + const isContentRejection = /cannot|can't|unable|sorry|policy|violat/i.test(conversationText); + if (isContentRejection) { + logger.warn('适配器', `早期检测到内容拒绝: ${conversationText.substring(0, 200)}...`, meta); + return { error: `内容被拒绝: ${conversationText.substring(0, 200)}`, retryable: false }; + } + } + } + logger.info('适配器', '生成中,等待图片就绪...', meta); // 6. 监听文件状态接口,等待图片生成完成 - // 通过 file_name 是否包含 .part 判断是否生成完成 + // 如果 conversation 响应中没有图片生成迹象,使用较短超时 let downloadUrl = null; let fileName = null; + const imageTimeout = isImageGenerationStarted ? 120000 : 30000; try { await page.waitForResponse(async (response) => { @@ -128,11 +195,6 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { const fn = json.file_name; const dl = json.download_url; - // 检查是否生成完成: - // 1. 必须有 file_name - // 2. file_name 开头必须是 user- (生成的图片) - // 3. file_name 不能包含 .part(表示中间状态) - // 4. 必须有 download_url if (fn && fn.startsWith('user-') && !fn.includes('.part') && dl) { fileName = fn; downloadUrl = dl; @@ -145,8 +207,34 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { } catch { return false; } - }, { timeout: waitTimeout }); + }, { timeout: imageTimeout }); } catch (e) { + logger.debug('适配器', `等待图片超时, conversationText长度: ${conversationText.length}, downloadUrl: ${downloadUrl}`, meta); + + // 超时时检查是否有 conversation 中的文本内容 + if (conversationText && !downloadUrl) { + const isRateLimit = conversationBody.includes('RateLimitException') || + conversationBody.includes('rate limit') || + /limit.*reset/i.test(conversationText); + + if (isRateLimit) { + logger.warn('适配器', `触发速率限制: ${conversationText.substring(0, 200)}...`, meta); + return { error: `触发速率限制: ${conversationText.substring(0, 200)}`, retryable: false }; + } + + logger.warn('适配器', `模型返回文本而非图片: ${conversationText.substring(0, 200)}...`, meta); + return { error: `模型返回文本而非图片: ${conversationText.substring(0, 200)}`, retryable: false }; + } + + // 如果没有提取到文本,但有原始响应体,尝试用简单方式提取 + if (!conversationText && conversationBody) { + const partsMatch = conversationBody.match(/"parts":\s*\["([^"]+)"\]/); + if (partsMatch && partsMatch[1]) { + logger.warn('适配器', `通过正则提取到文本: ${partsMatch[1].substring(0, 200)}...`, meta); + return { error: `模型返回文本而非图片: ${partsMatch[1].substring(0, 200)}`, retryable: false }; + } + } + const pageError = normalizePageError(e, meta); if (pageError) return pageError; throw e; diff --git a/src/backend/adapter/lmarena.js b/src/backend/adapter/lmarena.js index f9dba63..794efe2 100644 --- a/src/backend/adapter/lmarena.js +++ b/src/backend/adapter/lmarena.js @@ -40,6 +40,53 @@ function extractImage(text) { return null; } +/** + * 从响应文本中提取错误信息 + * SSE 错误格式: + * - a3: 模型提供方错误 (如 OpenAI moderation_blocked) + * - ae: Arena 平台错误 (如内容审核拦截) + * @param {string} text - 响应文本内容 + * @returns {string|null} 提取到的错误信息,如果未找到则返回 null + */ +function extractError(text) { + if (!text) return null; + const lines = text.split('\n'); + for (const line of lines) { + // a3: 模型提供方错误 + if (line.startsWith('a3:')) { + try { + const errorMsg = JSON.parse(line.substring(3)); + if (typeof errorMsg === 'string') { + // 尝试提取嵌套的 JSON 错误 + const jsonMatch = errorMsg.match(/\{[\s\S]*"error"[\s\S]*\}/); + if (jsonMatch) { + try { + const nested = JSON.parse(jsonMatch[0]); + if (nested.error?.message) { + return `[模型错误] ${nested.error.message} (code: ${nested.error.code || 'unknown'})`; + } + } catch { } + } + return `[模型错误] ${errorMsg}`; + } + } catch (e) { } + } + // ae: Arena 平台错误 + if (line.startsWith('ae:')) { + try { + const errorData = JSON.parse(line.substring(3)); + if (errorData?.message) { + return `[平台错误] ${errorData.message}`; + } + if (typeof errorData === 'string') { + return `[平台错误] ${errorData}`; + } + } catch (e) { } + } + } + return null; +} + /** * 执行生图任务 @@ -146,7 +193,14 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { const httpError = normalizeHttpError(response, content); if (httpError) { logger.error('适配器', `请求生成时返回错误: ${httpError.error}`, meta); - return { error: `请求生成时返回错误: ${httpError.error}` }; + return { error: `请求生成时返回错误: ${httpError.error}`, retryable: httpError.retryable }; + } + + // 8.5 检查 SSE 错误 (a3/ae 行) + const sseError = extractError(content); + if (sseError) { + logger.warn('适配器', `SSE 错误: ${sseError}`, meta); + return { error: sseError, retryable: false }; } // 9. 提取图片 URL @@ -170,7 +224,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { return result; } else { logger.warn('适配器', '未获得结果,响应中无图片数据', { ...meta, preview: content.substring(0, 150) }); - return { text: `未获得结果,响应中无图片数据: ${content}` }; + return { error: `未获得结果,响应中无图片数据: ${content.substring(0, 200)}` }; } } catch (err) { diff --git a/src/backend/pool/Worker.js b/src/backend/pool/Worker.js index db7c168..478f886 100644 --- a/src/backend/pool/Worker.js +++ b/src/backend/pool/Worker.js @@ -405,6 +405,7 @@ export class Worker { const maxAttempts = maxRetries === 0 ? candidateTypes.length : Math.min(maxRetries + 1, candidateTypes.length); let lastError = null; + let lastRetryable = undefined; for (let i = 0; i < maxAttempts; i++) { const { type, modelId: actualModelId } = candidateTypes[i]; @@ -415,12 +416,19 @@ export class Worker { } lastError = result.error; + lastRetryable = result.retryable; + + // 如果明确标记为不可重试(如内容安全问题),立即返回 + if (result.retryable === false) { + return { error: `所有支持该模型的适配器都无法使用: ${lastError}`, retryable: false }; + } + if (i < maxAttempts - 1) { logger.warn('工作池', `[${this.name}] ${type} 失败,尝试下一个适配器...`, { error: lastError, ...meta }); } } - return { error: `所有支持该模型的适配器都无法使用: ${lastError}` }; + return { error: `所有支持该模型的适配器都无法使用: ${lastError}`, retryable: lastRetryable }; } /** @@ -479,10 +487,13 @@ export class Worker { userDataDir: this.userDataDir }; + // 扩展 meta,添加 adapter 和 model 信息 + const enrichedMeta = { ...meta, adapter: type, model: modelId }; + this.busyCount++; try { // 传递原始 modelId,由适配器自己解析 - return await adapter.generate(subContext, prompt, paths, modelId, meta); + return await adapter.generate(subContext, prompt, paths, modelId, enrichedMeta); } finally { this.busyCount--; } diff --git a/src/backend/strategies/failover.js b/src/backend/strategies/failover.js index f3be28e..6d73fb3 100644 --- a/src/backend/strategies/failover.js +++ b/src/backend/strategies/failover.js @@ -66,11 +66,15 @@ export function createFailoverExecutor(options = {}) { // 记录错误 lastError = result.error; - // 检查是否可重试 - const normalized = normalizeError(lastError); - if (!normalized.retryable && i < maxAttempts - 1) { - // 不可重试的错误,但还有候选,继续尝试 - logger.debug('故障转移', `不可重试错误,跳过: ${lastError}`, meta); + // 优先使用 result 中的 retryable,否则通过 normalizeError 推断 + const retryable = result.retryable !== undefined + ? result.retryable + : normalizeError(lastError).retryable; + + // 不可重试的错误(如内容安全问题),直接返回,不尝试其他候选 + if (!retryable) { + logger.debug('故障转移', `不可重试错误,停止故障转移: ${lastError}`, meta); + return { error: lastError, code: 'NOT_RETRYABLE', retryable: false }; } // 触发重试回调 diff --git a/src/backend/utils/error.js b/src/backend/utils/error.js index 7a3fbfa..823ad2c 100644 --- a/src/backend/utils/error.js +++ b/src/backend/utils/error.js @@ -21,8 +21,8 @@ export function isRetryableError(errorMessage) { const retryablePatterns = [ // 网络错误 /network|net::|econnreset|econnrefused|etimedout/i, - // 超时 - /timeout|timed out/i, + // 超时(含中文) + /timeout|timed out|加载超时|请求超时/i, // 页面崩溃 /crashed|crash/i, // 5xx 服务端错误 @@ -63,6 +63,11 @@ export function normalizePageError(err, meta = {}) { logger.error('适配器', timeoutMsg, meta); return { error: timeoutMsg, code: ADAPTER_ERRORS.TIMEOUT_ERROR, retryable: true }; } + // 页面加载超时 (gotoWithCheck 抛出的中文超时错误) + if (err.message?.includes('页面加载超时') || err.message?.includes('页面加载失败')) { + logger.error('适配器', err.message, meta); + return { error: err.message, code: ADAPTER_ERRORS.TIMEOUT_ERROR, retryable: true }; + } // 兼容原生 TimeoutError (其他地方抛出的) if (err.name === 'TimeoutError' || err.message?.includes('Timeout')) { logger.error('适配器', '请求超时', meta);