From 8039bea86cb4fd03be731a7bce1fe29227e9e210 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 5 Nov 2026 18:48:00 +0800 Subject: [PATCH] fix(canvas): restore Agent planning and settle image failures without duplicate generation --- .../api/chat/_lib/image-generation-failure.ts | 47 ++++ app/(chat)/api/chat/_lib/stream.ts | 2 + app/(chat)/api/chat/route.ts | 259 ++++-------------- components/canvas/CanvasChatPanel.tsx | 60 ++-- .../canvas/chat-panel/canvas-agent-stream.ts | 11 +- components/canvas/nodes/ImageNode.tsx | 23 +- .../nodes/image-node/canvas-preview-image.tsx | 32 +++ .../image-node/image-node-empty-state.tsx | 10 +- components/generation-dot-placeholder.tsx | 245 +---------------- components/image-generation-loading.tsx | 54 +--- lib/ai/runtime/agent-image-failover.ts | 1 + lib/ai/runtime/model-health.ts | 9 +- lib/canvas/generate-image.ts | 8 +- lib/canvas/image-generation-status.ts | 34 +++ lib/canvas/image-preview-width.ts | 5 + lib/request-error.ts | 2 +- playwright.helpers.config.ts | 1 + tests/e2e/canvas-generation-feedback.test.ts | 119 ++++++++ ...anvas-agent-runtime-image-failover.test.ts | 2 + tests/routes/image-generation-status.test.ts | 39 +++ ...mage-provider-error-classification.test.ts | 14 + 21 files changed, 451 insertions(+), 526 deletions(-) create mode 100644 app/(chat)/api/chat/_lib/image-generation-failure.ts create mode 100644 components/canvas/nodes/image-node/canvas-preview-image.tsx create mode 100644 lib/canvas/image-generation-status.ts create mode 100644 lib/canvas/image-preview-width.ts create mode 100644 tests/e2e/canvas-generation-feedback.test.ts create mode 100644 tests/routes/image-generation-status.test.ts diff --git a/app/(chat)/api/chat/_lib/image-generation-failure.ts b/app/(chat)/api/chat/_lib/image-generation-failure.ts new file mode 100644 index 000000000..3344b7852 --- /dev/null +++ b/app/(chat)/api/chat/_lib/image-generation-failure.ts @@ -0,0 +1,47 @@ +import { saveMessages } from "@/lib/db/queries"; +import type { ImageGenerationStatusStreamPayload } from "@/lib/canvas/image-generation-status"; +import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error"; +import { safeWriteStreamChunk, type StreamWriter, writeAssistantTextChunk, writeStructuredDataChunk } from "./stream"; + +export async function reportImageGenerationFailure(params: { + writer: StreamWriter; + chatId: string; + userMessageId: string; + messageId: string; + error: unknown; + label: string; +}) { + const failure = getRequestFailureDetails(params.error, "图片生成失败"); + const status: ImageGenerationStatusStreamPayload = { + phase: "failed", + userMessageId: params.userMessageId, + requestId: params.messageId, + failure, + }; + const text = formatRequestFailure(failure, params.messageId); + const parts = [ + { type: "text", text }, + { type: "data-imageGenerationStatus", data: status }, + ]; + writeStructuredDataChunk(params.writer, "data-imageGenerationStatus", status, `${params.label}:status`); + safeWriteStreamChunk(params.writer, { + type: "data-appendMessage", + data: JSON.stringify({ id: params.messageId, role: "assistant", parts }), + }, params.label); + writeAssistantTextChunk(params.writer, "", `${params.label}:end`); + // History carries the same terminal state when the browser loses the stream. + await saveMessages({ messages: [{ + id: params.messageId, + chatId: params.chatId, + role: "assistant", + parts, + attachments: [], + createdAt: new Date(), + videoStorageStatus: null, + temporaryVideoUrl: null, + permanentVideoUrl: null, + archivedAt: null, + videoMetadata: null, + }] }); + return text; +} diff --git a/app/(chat)/api/chat/_lib/stream.ts b/app/(chat)/api/chat/_lib/stream.ts index 65f44f5e2..95237cf83 100644 --- a/app/(chat)/api/chat/_lib/stream.ts +++ b/app/(chat)/api/chat/_lib/stream.ts @@ -208,6 +208,7 @@ export function writeImageGenerationStatusChunk( writer: StreamWriter, params: { phase: "queued" | "processing" | "finalizing" | "failed" | "completed"; + userMessageId?: string; providerType?: string | null; modelName?: string | null; imageCount?: number | null; @@ -223,6 +224,7 @@ export function writeImageGenerationStatusChunk( "data-imageGenerationStatus", { phase: params.phase, + userMessageId: params.userMessageId, providerType: normalizeImageLoadingProviderType(params.providerType), modelName: String(params.modelName || "").trim() || undefined, imageCount: diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index e95340dc8..c34f819d8 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -426,6 +426,8 @@ import { type VideoModeType, } from "./_lib/video-models"; +import { reportImageGenerationFailure } from "./_lib/image-generation-failure"; + const chatApiLogger = createLogger("chat.route"); const createUIMessageStream: typeof createAiUIMessageStream = (options) => createAiUIMessageStream({ ...options, execute: (writer) => trackRuntimeWork(() => options.execute(writer)) }); @@ -446,16 +448,6 @@ function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: stri throw new Error(`${modelLabel} 未返回可用图片:${reason.slice(0, 160)}`); } -function getPublicImageGenerationErrorText(error: unknown, requestId: string) { - const message = error instanceof Error ? error.message : String(error || ""); - const publicMessage = getUserFacingImageGenerationErrorMessage(error); - if (!message || (publicMessage === message && INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message))) { - return `图片生成失败,请稍后重试。若连续失败,请联系管理员。\n\n请求编号:${requestId}`; - } - return `${publicMessage}\n\n请求编号:${requestId}`; -} - - const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max( 1, Math.min(8, Number(process.env.AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT || 6) || 6), @@ -5688,6 +5680,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: "processing", modelName: resolvedModelRef, imageCount: replacementReferences.length, @@ -6030,6 +6023,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: queueStatus.status === "running" ? "processing" @@ -6185,6 +6179,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: successCount > 0 ? "completed" : "failed", modelName: resolvedModelDisplayName, imageCount: successCount, @@ -6417,6 +6412,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: "processing", modelName: selectedAgentPreset.defaultModel?.displayName || @@ -6968,6 +6964,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: queueStatus.status === "running" ? "processing" @@ -7293,6 +7290,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: queueStatus.status === "running" ? "processing" @@ -7521,6 +7519,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: queueStatus.status === "running" ? "processing" @@ -7839,6 +7838,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: successCount > 0 ? "completed" : "failed", modelName: selectedAgentPreset.defaultModel?.displayName || @@ -8048,6 +8048,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: "processing", providerType: requestedModelProviderType, modelName: finalModelId, @@ -8086,6 +8087,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: "processing", ...directImageStatusPayload, }, @@ -8178,6 +8180,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: "completed", providerType: requestedModelProviderType, modelName: directImageResult.model, @@ -8198,42 +8201,16 @@ export async function POST(request: Request) { ); } catch (directToolError) { chatApiLogger.error("[Chat API] Direct agent image generation failed:", directToolError); - const errorMessage = getDirectAgentToolErrorMessage( - "generate_image", - directToolError, - ); - await saveAssistantTextOnlyMessage({ - chatId: id, - messageId: assistantMessageId, - text: errorMessage, + const errorMessage = await reportImageGenerationFailure({ + writer, chatId: id, userMessageId: message.id, + messageId: assistantMessageId, error: directToolError, + label: "agent:generate_image:error", }); - writeAssistantTextChunk( - writer, - errorMessage, - "agent:generate_image:error", - assistantMessageId, - ); - if (contextPayload?.source === "canvas") { - writeImageGenerationStatusChunk( - writer, - { - phase: "failed", - providerType: requestedModelProviderType, - modelName: finalModelId, - }, - "agent:generate_image:status-failed", - ); - } writeAgentProgressChunk( writer, buildDirectAgentImageGenerateFailedProgress(errorMessage), "agent:generate_image:progress-failed", ); - writeAssistantTextChunk( - writer, - "", - "agent:generate_image:end-error", - ); } finally { stopDirectImageKeepalive(); if (stopDirectImageStatus) { @@ -9195,6 +9172,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: "processing", providerType: (languageModel as any).provider, modelName: currentModelConfig?.displayName || actualModelId, @@ -9282,6 +9260,7 @@ export async function POST(request: Request) { chatApiLogger.warn("[Chat API] Record Gemini fallback health failure failed:", healthError); }); chatApiLogger.error(`[Chat API] ❌ Fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); + if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError; } } @@ -9356,6 +9335,7 @@ export async function POST(request: Request) { chatApiLogger.warn("[Chat API] Record Gemini cross-provider health failure failed:", healthError); }); chatApiLogger.error(`[Chat API] ❌ Gemini cross-provider fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); + if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError; } } } @@ -9423,6 +9403,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: "completed", providerType: (languageModel as any).provider, modelName: usedModelDisplayName, @@ -9487,6 +9468,14 @@ export async function POST(request: Request) { } } catch (error) { chatApiLogger.error("[Chat API] Gemini generation error:", error); + let finalError = error; + if (!shouldFailoverAiModelError(error)) { + await reportImageGenerationFailure({ + writer, chatId: id, userMessageId: message.id, + messageId: generateUUID(), error, label: "gemini:error", + }); + return; + } chatApiLogger.info("[Chat API] Attempting failover to Volcano Vision..."); // 故障转移:尝试使用火山引擎模型 @@ -9564,6 +9553,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: "completed", providerType: "volcano-vision", modelName: volcanoModel.model.displayName || volcanoModel.model.modelId, @@ -9629,47 +9619,14 @@ export async function POST(request: Request) { chatApiLogger.info("[Chat API] No Volcano Vision model available for failover"); } catch (failoverError) { chatApiLogger.error("[Chat API] Failover to Volcano Vision also failed:", failoverError); + finalError = failoverError; } // 如果故障转移也失败,返回错误消息 - const errorMessageId = generateUUID(); - const errorText = getPublicImageGenerationErrorText(error, errorMessageId); - writeAssistantTextChunk( - writer, - errorText, - "gemini:error", - errorMessageId, - ); - - // 🆕 图片生成结束标记(失败) - writeAssistantTextChunk( - writer, - "", - "gemini:image-end-failed", - ); - - // 尝试保存错误消息 - try { - await saveMessages({ - messages: [ - { - id: errorMessageId, - chatId: id, - role: "assistant", - parts: [{ type: "text", text: errorText }], - attachments: [], - createdAt: new Date(), - videoStorageStatus: null, - temporaryVideoUrl: null, - permanentVideoUrl: null, - archivedAt: null, - videoMetadata: null, - }, - ], - }); - } catch (saveError) { - chatApiLogger.error("[Chat API] Failed to save error message:", saveError); - } + await reportImageGenerationFailure({ + writer, chatId: id, userMessageId: message.id, + messageId: generateUUID(), error: finalError, label: "gemini:error", + }); } finally { stopKeepalive?.(); } @@ -9867,6 +9824,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: "queued", providerType: "fal-image", modelName: modelDisplayName, @@ -9973,6 +9931,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: "processing", providerType: (languageModel as any).provider, modelName: alibabaModelConfig?.displayName || actualModelId, @@ -10070,6 +10029,7 @@ export async function POST(request: Request) { chatApiLogger.warn("[Chat API] Record image fallback health failure failed:", healthError); }); chatApiLogger.error(`[Chat API] ❌ Alibaba fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); + if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError; } } @@ -10204,6 +10164,7 @@ export async function POST(request: Request) { chatApiLogger.warn("[Chat API] Record image cross-provider health failure failed:", healthError); }); chatApiLogger.error(`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`, crossError); + if (!shouldFailoverAiModelError(crossError)) throw crossError; } } } @@ -10271,6 +10232,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: "completed", providerType: (languageModel as any).provider, modelName: usedModelDisplayName, @@ -10335,43 +10297,10 @@ export async function POST(request: Request) { chatApiLogger.error("[Chat API] Alibaba generation error:", error); // 返回错误消息 - const errorMessageId = generateUUID(); - const errorText = getPublicImageGenerationErrorText(error, errorMessageId); - writeAssistantTextChunk( - writer, - errorText, - "alibaba:error", - errorMessageId, - ); - - // 🆕 图片生成结束标记(失败) - writeAssistantTextChunk( - writer, - "", - "alibaba:image-end-failed", - ); - - try { - await saveMessages({ - messages: [ - { - id: errorMessageId, - chatId: id, - role: "assistant", - parts: [{ type: "text", text: errorText }], - attachments: [], - createdAt: new Date(), - videoStorageStatus: null, - temporaryVideoUrl: null, - permanentVideoUrl: null, - archivedAt: null, - videoMetadata: null, - }, - ], - }); - } catch (saveError) { - chatApiLogger.error("[Chat API] Failed to save error message:", saveError); - } + await reportImageGenerationFailure({ + writer, chatId: id, userMessageId: message.id, + messageId: generateUUID(), error, label: "alibaba:error", + }); } finally { stopKeepalive?.(); } @@ -10500,6 +10429,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: "processing", providerType: "volcano-vision", modelName: volcanoModelConfig?.displayName || actualModelId, @@ -10545,6 +10475,7 @@ export async function POST(request: Request) { setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage); } chatApiLogger.error(`[Chat API] ❌ Volcano fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); + if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError; } } if (!fallbackSuccess) { @@ -10711,6 +10642,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( writer, { + userMessageId: message.id, phase: "completed", providerType: "volcano-vision", modelName: usedModelDisplayName, @@ -10775,44 +10707,10 @@ export async function POST(request: Request) { } } catch (error) { chatApiLogger.error("[Chat API] Volcano Vision generation error:", error); - const errorMessageId = generateUUID(); - const errorText = getPublicImageGenerationErrorText(error, errorMessageId); - writeAssistantTextChunk( - writer, - errorText, - "volcano:error", - errorMessageId, - ); - - // 🆕 图片生成结束标记(失败) - writeAssistantTextChunk( - writer, - "", - "volcano:image-end-failed", - ); - - // 尝试保存错误消息 - try { - await saveMessages({ - messages: [ - { - id: errorMessageId, - chatId: id, - role: "assistant", - parts: [{ type: "text", text: errorText }], - attachments: [], // 必须提供 attachments 字段 - createdAt: new Date(), - videoStorageStatus: null, - temporaryVideoUrl: null, - permanentVideoUrl: null, - archivedAt: null, - videoMetadata: null, - }, - ], - }); - } catch (saveError) { - chatApiLogger.error("[Chat API] Failed to save error message:", saveError); - } + await reportImageGenerationFailure({ + writer, chatId: id, userMessageId: message.id, + messageId: generateUUID(), error, label: "volcano:error", + }); } finally { stopKeepalive?.(); } @@ -11385,6 +11283,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( dataStream as unknown as StreamWriter, { + userMessageId: message.id, phase: "processing", providerType: currentModelConfig?.providerId ? resolvedProviderType || providerType : providerType, modelName: currentModelConfig?.displayName || actualModelId, @@ -11476,6 +11375,7 @@ export async function POST(request: Request) { setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage); } chatApiLogger.error(`[Chat API] ❌ OpenAI-compatible fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); + if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError; } } @@ -11647,6 +11547,7 @@ export async function POST(request: Request) { writeImageGenerationStatusChunk( dataStream as unknown as StreamWriter, { + userMessageId: message.id, phase: "completed", providerType: resolvedProviderType || providerType, modelName: usedModelDisplayName, @@ -11706,54 +11607,10 @@ export async function POST(request: Request) { } catch (error) { chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error); - const errorMessageId = generateUUID(); - const errorText = getPublicImageGenerationErrorText(error, errorMessageId); - - dataStream.write({ - type: "data-appendMessage", - data: JSON.stringify({ - id: errorMessageId, - role: "assistant", - parts: [{ type: "text", text: errorText }], - }), - }); - dataStream.write({ - type: "data-appendMessage", - data: JSON.stringify({ - id: generateUUID(), - role: "assistant", - parts: [{ type: "text", text: "" }], - }), - }); - if (contextPayload?.source === "canvas") { - writeImageGenerationStatusChunk( - dataStream as unknown as StreamWriter, - { - phase: "failed", - providerType: resolvedProviderType || providerType, - modelName: currentModelConfig?.displayName || actualModelId, - imageCount: effectiveImageParams?.imageCount || 1, - }, - "openai-compat:image-status-failed", - ); - } - - await saveMessages({ - messages: [ - { - id: errorMessageId, - role: "assistant", - parts: [{ type: "text", text: errorText }], - createdAt: new Date(), - attachments: [], - chatId: id, - videoStorageStatus: null, - temporaryVideoUrl: null, - permanentVideoUrl: null, - archivedAt: null, - videoMetadata: null, - }, - ], + await reportImageGenerationFailure({ + writer: dataStream as unknown as StreamWriter, + chatId: id, userMessageId: message.id, + messageId: generateUUID(), error, label: "openai-compat:error", }); return; } diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index ce79f7b4f..12211f1b6 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -67,6 +67,7 @@ import { toast } from "sonner"; import { useCanvasStore } from "@/store/canvas"; import { cn } from "@/lib/utils"; import { getGatewayErrorPageMessage } from "@/lib/request-error"; +import { findImageGenerationFailure } from "@/lib/canvas/image-generation-status"; import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; import { fetchUploadWithRetry, @@ -3340,6 +3341,7 @@ export function CanvasChatPanel({ const activeChatIdRef = useRef(requestedChatSessionId || chatId); const activeMessagesRef = useRef([]); const inFlightChatIdRef = useRef(null); + const imageGenerationUserMessageIdsRef = useRef>({}); const inFlightRawChatIdRef = useRef(null); const [chatSessions, setChatSessions] = useState([]); const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false); @@ -3642,17 +3644,20 @@ export function CanvasChatPanel({ const setStreamImageGenerationStateForChat = useCallback( (chatSessionId: string, next: ImageGenerationStatusStreamPayload | null) => { + const userMessageId = imageGenerationUserMessageIdsRef.current[chatSessionId]; + if (next?.userMessageId && userMessageId && next.userMessageId !== userMessageId) return; setStreamImageGenerationStateByChatId((prev) => ({ ...prev, - [chatSessionId]: next, + [chatSessionId]: next ? { ...next, userMessageId: next.userMessageId || userMessageId } : null, })); }, [] ); - const clearStreamImageGenerationStateForChat = useCallback((chatSessionId: string) => { + const clearStreamImageGenerationStateForChat = useCallback((chatSessionId: string, preserveFailure = false) => { setStreamImageGenerationStateByChatId((prev) => { if (!(chatSessionId in prev)) return prev; + if (preserveFailure && prev[chatSessionId]?.phase === "failed") return prev; const next = { ...prev }; delete next[chatSessionId]; return next; @@ -6089,7 +6094,7 @@ export function CanvasChatPanel({ const activeComposerMode = unifiedImageAgent && (rawComposerMode === "image" || rawComposerMode === "video") ? "agent" : rawComposerMode; const isImageAgentComposer = unifiedImageAgent && activeComposerMode === "agent"; - const [imageAgentEnabled, setImageAgentEnabled] = useState(false); + const [imageAgentEnabled, setImageAgentEnabled] = useState(true); const imageAgentEnabledRef = useRef(imageAgentEnabled); imageAgentEnabledRef.current = imageAgentEnabled; const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video"; @@ -8018,6 +8023,8 @@ export function CanvasChatPanel({ api: "/api/chat", fetch: fetchWithErrorHandlers, async prepareSendMessagesRequest(request) { + const userMessageId = request.messages.findLast((item) => item.role === "user")?.id; + if (userMessageId) imageGenerationUserMessageIdsRef.current[request.id] = userMessageId; // 从 canvas-{uuid} 格式中提取纯 UUID,后端 schema 要求纯 UUID const rawId = request.id.startsWith('canvas-') ? request.id.replace('canvas-', '') @@ -8130,20 +8137,27 @@ export function CanvasChatPanel({ const payload: ImageGenerationStatusStreamPayload = typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data; if (!payload || typeof payload !== "object") return; + const userMessageId = imageGenerationUserMessageIdsRef.current[eventChatId]; + if (payload.userMessageId && userMessageId && payload.userMessageId !== userMessageId) return; if (payload.phase === "completed") { setStreamImageGenerationStateForChat(eventChatId, { phase: "finalizing", + userMessageId: payload.userMessageId, providerType: payload.providerType, modelName: getAgentSafeModelName(payload.modelName), imageCount: Number(payload.imageCount || 0) || undefined, }); window.setTimeout(() => { + if (payload.userMessageId && imageGenerationUserMessageIdsRef.current[eventChatId] !== payload.userMessageId) return; clearStreamImageGenerationStateForChat(eventChatId); }, 30_000); return; } setStreamImageGenerationStateForChat(eventChatId, { phase: payload.phase || "processing", + userMessageId: payload.userMessageId, + requestId: payload.requestId, + failure: payload.failure, providerType: payload.providerType, modelName: getAgentSafeModelName(payload.modelName), imageCount: Number(payload.imageCount || 0) || undefined, @@ -8411,6 +8425,11 @@ export function CanvasChatPanel({ ) : false; const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : []; + const failureStatus = findImageGenerationFailure( + [{ role: messageData.role, parts: normalizedParts }], + imageGenerationUserMessageIdsRef.current[eventChatId], + ); + if (failureStatus) setStreamImageGenerationStateForChat(eventChatId, failureStatus); const normalizedAttachments = Array.isArray(messageData.attachments) ? messageData.attachments : []; @@ -8479,7 +8498,7 @@ export function CanvasChatPanel({ (hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) && !shouldDeferClearForActiveMedia ) { - clearStreamImageGenerationStateForChat(eventChatId); + clearStreamImageGenerationStateForChat(eventChatId, true); } if (!isEventForActiveChat) { @@ -9202,7 +9221,7 @@ export function CanvasChatPanel({ useEffect(() => { const streamState = streamImageGenerationStateByChatId[activeChatId]; - if (!rawChatId || !streamState) return; + if (!rawChatId || !streamState || (streamState.phase === "failed" && streamState.failure?.outcome !== "unknown")) return; let cancelled = false; const poll = async () => { @@ -9226,6 +9245,13 @@ export function CanvasChatPanel({ const streamState = streamImageGenerationStateByChatId[activeChatId]; if (!streamState || messages.length === 0) return; + const failure = findImageGenerationFailure(messages, streamState.userMessageId || ""); + if (failure && streamState.phase !== "failed") { + setStreamImageGenerationStateForChat(activeChatId, failure); + return; + } + if (streamState.phase === "failed" && streamState.failure?.outcome !== "unknown") return; + const lastUserIndex = [...messages] .map((message) => message.role) .lastIndexOf("user"); @@ -9242,8 +9268,7 @@ export function CanvasChatPanel({ .join("\n"); return ( extractAssistantImageUrls(textContent).length > 0 || - extractVideoUrls(textContent).length > 0 || - /失败|超时|重试|error|failed/i.test(textContent) + extractVideoUrls(textContent).length > 0 ); }); @@ -9264,6 +9289,7 @@ export function CanvasChatPanel({ messages, streamImageGenerationStateByChatId, clearStreamImageGenerationStateForChat, + setStreamImageGenerationStateForChat, ]); useEffect(() => { @@ -13328,6 +13354,7 @@ export function CanvasChatPanel({ }); inFlightChatIdRef.current = activeChatId; inFlightRawChatIdRef.current = rawChatId; + imageGenerationUserMessageIdsRef.current[activeChatId] = userMessageId; clearStreamImageGenerationStateForChat(activeChatId); clearAgentProgressStateForChat(activeChatId); if (shouldShowOptimisticImageGeneration) { @@ -15097,7 +15124,7 @@ export function CanvasChatPanel({ !hasGenerationEnd && Boolean(currentModel || activeComposerMode === "agent")); const hasActiveTypewriter = Object.keys(activeTypingSections).length > 0; - const shouldRenderImageLoading = shouldShowImageLoading && !hasActiveTypewriter; + const shouldRenderImageLoading = shouldShowImageLoading && streamImageGenerationState?.phase !== "failed" && !hasActiveTypewriter; const loadingImageCount = streamImageGenerationState?.imageCount || imageParams.imageCount || 1; const loadingProviderType = streamImageGenerationState?.providerType || @@ -15160,9 +15187,9 @@ export function CanvasChatPanel({ phase === "queued" ? "等待开始生成" : phase === "finalizing" - ? "正在润饰细节" + ? "正在保存生成结果" : phase === "failed" - ? "生成失败" + ? streamState.failure?.message || "生成失败" : "正在创建图片"; const status = phase === "failed" ? "failed" : "generating"; const currentNodes = useCanvasStore.getState().nodes; @@ -15218,14 +15245,10 @@ export function CanvasChatPanel({ canvasGenerationPlaceholder: true, chatId: activeChatId, detailProgressLabel: label, - generationProgress: - phase === "queued" - ? 12 - : phase === "finalizing" - ? 96 - : phase === "failed" - ? 100 - : undefined, + generationProgress: undefined, + generationFailure: streamState.failure, + generationRequestId: streamState.requestId, + generationUserMessageId: streamState.userMessageId, modelName: streamState.modelName || currentModel?.displayName || undefined, providerType: streamState.providerType || currentModel?.providerType || undefined, placementMode: "anchored-generation", @@ -15319,6 +15342,7 @@ export function CanvasChatPanel({ if ( message.role === "assistant" && + !rawParts.some((part: any) => part.type === "data-imageGenerationStatus" && part.data?.failure) && isInternalCanvasImageGenerationFailureText(cleanedParts) ) { return acc; diff --git a/components/canvas/chat-panel/canvas-agent-stream.ts b/components/canvas/chat-panel/canvas-agent-stream.ts index 4a66d17f1..241d6e514 100644 --- a/components/canvas/chat-panel/canvas-agent-stream.ts +++ b/components/canvas/chat-panel/canvas-agent-stream.ts @@ -152,16 +152,7 @@ export function shouldRetryCanvasViewOperationAfterRefresh( ); } -export type ImageGenerationStatusStreamPayload = { - phase?: "queued" | "processing" | "finalizing" | "failed" | "completed"; - providerType?: string; - modelName?: string; - imageCount?: number; - queuePosition?: number; - sameModelRunningCount?: number; - concurrencyLimit?: number; - estimatedWaitMs?: number; -}; +export type { ImageGenerationStatusStreamPayload } from "@/lib/canvas/image-generation-status"; export type AgentTargetLockRefPreview = { url: string; diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index db71d9c7e..96c73e007 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -1,4 +1,6 @@ "use client"; +import { CanvasPreviewImage } from "./image-node/canvas-preview-image"; +import { canvasImagePreviewWidth } from "@/lib/canvas/image-preview-width"; import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; import { @@ -2502,13 +2504,13 @@ export function ImageNode({ const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05; const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640; const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600; - return Math.max( + return canvasImagePreviewWidth(Math.max( minWidth, Math.min( maxWidth, Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier) ) - ); + ), maxWidth); }, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]); const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88; const displayImageSrc = useMemo( @@ -8399,12 +8401,13 @@ export function ImageNode({ }} >
- {displayTitle} { if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) { event.currentTarget.src = buildImageFallbackUrl(data.src); @@ -8428,12 +8431,13 @@ export function ImageNode({ }} >
- {`${displayTitle}-原图`} { if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) { event.currentTarget.src = buildImageFallbackUrl(compareSource); @@ -8478,7 +8482,8 @@ export function ImageNode({
) : ( <> - {displayTitle}, "src"> & { src: string; sourceKey: string }; + +export const CanvasPreviewImage = forwardRef(function CanvasPreviewImage( + { src, sourceKey, onLoad, style, ...props }, ref, +) { + const [displayed, setDisplayed] = useState({ sourceKey, src }); + const visibleSrc = displayed.sourceKey === sourceKey ? displayed.src : src; + const sources = visibleSrc === src ? [src] : [visibleSrc, src]; + return <>{sources.map(candidate => { + const visible = candidate === visibleSrc; + return {visible { + const image = event.currentTarget; + onLoad?.(event); + void image.decode().then(() => { + if (image.isConnected && candidate === src) setDisplayed({ sourceKey, src: candidate }); + }).catch(() => undefined); + }} + />; + })}; +}); diff --git a/components/canvas/nodes/image-node/image-node-empty-state.tsx b/components/canvas/nodes/image-node/image-node-empty-state.tsx index b9423b460..29e9ce95e 100644 --- a/components/canvas/nodes/image-node/image-node-empty-state.tsx +++ b/components/canvas/nodes/image-node/image-node-empty-state.tsx @@ -3,11 +3,14 @@ import { AlertCircle, ImagePlus, Loader2 } from "lucide-react"; import type React from "react"; import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder"; +import type { RequestFailureDetails } from "@/lib/request-error"; interface ImageNodeEmptyStateProps { detailProgressLabel?: string; fileInputRef: React.Ref; generationProgress?: number | null; + failure?: RequestFailureDetails; + requestId?: string; onImageUpload: (event: React.ChangeEvent) => void; onSelectFile: (event: React.MouseEvent) => void; status?: string; @@ -52,6 +55,8 @@ export function ImageNodeEmptyState({ detailProgressLabel, fileInputRef, generationProgress, + failure, + requestId, onImageUpload, onSelectFile, status, @@ -89,7 +94,7 @@ export function ImageNodeEmptyState({ if (status === "failed") { const failedTitle = detailProgressLabel || "生成失败"; return ( -
+
@@ -97,8 +102,9 @@ export function ImageNodeEmptyState({ {failedTitle} - {failedTitle.includes("上传") ? "请重新选择文件" : "请重新上传或再试一次"} + {failure?.recommendation || (failedTitle.includes("上传") ? "请重新选择文件" : "请查看对话中的错误原因和处理建议")} + {requestId ? 请求编号:{requestId} : null}
); } diff --git a/components/generation-dot-placeholder.tsx b/components/generation-dot-placeholder.tsx index c5c416488..ade8212e7 100644 --- a/components/generation-dot-placeholder.tsx +++ b/components/generation-dot-placeholder.tsx @@ -1,6 +1,5 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; import { BloubAvatar } from "@/components/bloub-avatar"; import type { StateId } from "@/lib/bloub-official/states"; import { cn } from "@/lib/utils"; @@ -26,193 +25,17 @@ interface GenerationDotPlaceholderProps { const PHASE_TITLES: Record = { idle: "正在创建图片", queued: "等待开始生成", - processing: "生成初稿中", - finalizing: "正在润饰细节", + processing: "等待模型返回图片", + finalizing: "正在保存生成结果", failed: "生成失败", completed: "生成完成", }; -const GENERATION_STATUS_TITLES = [ - "正在理解画面关系", - "正在读取参考图片", - "正在分析主体轮廓", - "正在识别关键元素", - "正在整理构图线索", - "正在确认画面比例", - "正在匹配视觉风格", - "正在提取材质细节", - "正在梳理光影方向", - "正在构建初始画面", - "正在生成第一版构图", - "正在安排主体位置", - "正在校准画面重心", - "正在补全背景层次", - "正在保持主体一致", - "正在优化边缘结构", - "正在调整透视关系", - "正在细化空间层次", - "正在增强画面清晰度", - "正在统一色彩倾向", - "正在检查参考一致性", - "正在处理局部细节", - "正在补充纹理信息", - "正在修正不自然区域", - "正在平衡明暗对比", - "正在优化产品质感", - "正在调整阴影落点", - "正在整理背景干扰", - "正在增强真实感", - "正在重绘细小结构", - "正在合成高清结果", - "正在检查主体完整性", - "正在优化画面边缘", - "正在处理反光细节", - "正在提升材质表现", - "正在微调构图节奏", - "正在清理多余噪点", - "正在强化视觉焦点", - "正在校正颜色偏差", - "正在保留参考特征", - "正在生成高分辨率版本", - "正在压实画面层次", - "正在修饰局部瑕疵", - "正在检查文字与图形", - "正在稳定画面结构", - "正在优化细节密度", - "正在完善背景过渡", - "正在增强主体质感", - "正在统一整体光感", - "正在检查比例关系", - "正在修正边缘锯齿", - "正在提升清晰观感", - "正在处理最后细节", - "正在准备预览结果", - "正在同步生成进度", - "正在确认输出尺寸", - "正在整理结果文件", - "正在校验图片可用性", - "正在生成最终预览", - "正在完成高清渲染", - "正在保存生成结果", - "正在写入画布节点", - "正在同步到对话框", - "正在检查结果完整性", - "正在收尾生成任务", - "正在等待服务商返回", - "正在保持连接状态", - "正在确认生成结果", - "正在拉取图片地址", - "正在准备展示图片", - "正在完成最后处理", - "正在刷新画布内容", - "正在整理图片信息", - "正在完成本次生成", - "正在即将展示结果", -]; - -const LONG_WAIT_TITLE_THRESHOLD_MS = 5 * 60 * 1000; -const LONG_WAIT_STATUS_TITLES = [ - "服务繁忙,排队处理中", - "高峰期生成较慢,请耐心等候", - "仍在保持连接,等待模型返回", - "结果返回后会自动展示", -]; -const STATUS_TITLE_DURATIONS_MS = GENERATION_STATUS_TITLES.map((_, index) => { - const rhythm = [3600, 5200, 4100, 6100, 3300, 4700, 5700, 3900, 6800, 4400]; - return rhythm[index % rhythm.length]; -}); -const TYPEWRITER_START_DELAY_MS = 120; -const TYPEWRITER_BASE_STEP_MS = 46; - function clampProgress(value: number) { if (!Number.isFinite(value)) return 0; return Math.max(0, Math.min(100, Math.round(value))); } -function estimateProgress(phase: GenerationPhase, elapsedMs: number) { - if (phase === "completed" || phase === "failed") return 100; - if (phase === "finalizing") return 96; - if (phase === "queued") { - return clampProgress(6 + Math.min(12, elapsedMs / 3500)); - } - if (phase === "processing") { - const eased = 1 - Math.exp(-elapsedMs / 85_000); - return clampProgress(18 + eased * 78); - } - return clampProgress(Math.min(8, elapsedMs / 1000)); -} - -function getStatusTitleState(elapsedMs: number, initialTitle?: string) { - const titles = initialTitle - ? [ - initialTitle, - ...GENERATION_STATUS_TITLES.filter((item) => item !== initialTitle), - ] - : GENERATION_STATUS_TITLES; - const durations = initialTitle - ? [4200, ...STATUS_TITLE_DURATIONS_MS] - : STATUS_TITLE_DURATIONS_MS; - - let cursor = Math.max(0, elapsedMs); - for (let index = 0; index < titles.length; index += 1) { - const duration = durations[index] || 4600; - if (cursor < duration) { - return { - elapsedInTitle: cursor, - index, - title: - titles[index] || - titles[titles.length - 1] || - PHASE_TITLES.processing, - }; - } - cursor -= duration; - } - - return { - elapsedInTitle: durations[durations.length - 1] || 4600, - index: titles.length - 1, - title: titles[titles.length - 1] || PHASE_TITLES.processing, - }; -} - -function getLongWaitTitleState(elapsedMs: number, initialTitle?: string) { - const titles = initialTitle - ? [ - initialTitle, - ...LONG_WAIT_STATUS_TITLES.filter((item) => item !== initialTitle), - ] - : LONG_WAIT_STATUS_TITLES; - const elapsedAfterThreshold = Math.max( - 0, - elapsedMs - LONG_WAIT_TITLE_THRESHOLD_MS - ); - const duration = 7600; - const index = Math.floor(elapsedAfterThreshold / duration) % titles.length; - - return { - elapsedInTitle: elapsedAfterThreshold % duration, - index, - title: titles[index] || titles[0] || PHASE_TITLES.processing, - }; -} - -function getTypewriterText(text: string, elapsedInTitle: number, index: number) { - const chars = Array.from(text); - if (chars.length === 0) return ""; - const step = TYPEWRITER_BASE_STEP_MS + (index % 5) * 8; - const visibleCount = Math.max( - 1, - Math.min( - chars.length, - Math.floor( - Math.max(0, elapsedInTitle - TYPEWRITER_START_DELAY_MS) / step - ) + 1 - ) - ); - return chars.slice(0, visibleCount).join(""); -} - function getBloubState(phase: GenerationPhase): StateId { if (phase === "failed") return "alert"; if (phase === "completed") return "wink"; @@ -228,39 +51,10 @@ export function GenerationDotPlaceholder({ showProgress = true, title, }: GenerationDotPlaceholderProps) { - const [elapsedMs, setElapsedMs] = useState(0); - - useEffect(() => { - const startedAt = Date.now(); - const timer = window.setInterval(() => { - setElapsedMs(Date.now() - startedAt); - }, 90); - return () => window.clearInterval(timer); - }, [phase]); - - const resolvedProgress = clampProgress( - typeof progress === "number" ? progress : estimateProgress(phase, elapsedMs) - ); - const isLongWaitTitle = - (phase === "processing" || phase === "finalizing") && - elapsedMs >= LONG_WAIT_TITLE_THRESHOLD_MS; - const statusTitleState = isLongWaitTitle - ? getLongWaitTitleState(elapsedMs, title) - : getStatusTitleState(elapsedMs, title); - const rotatingTitle = getTypewriterText( - statusTitleState.title, - statusTitleState.elapsedInTitle, - statusTitleState.index - ); - const resolvedTitle = - phase === "processing" || phase === "finalizing" - ? rotatingTitle - : title || PHASE_TITLES[phase]; + const resolvedProgress = typeof progress === "number" && Number.isFinite(progress) + ? clampProgress(progress) : null; + const resolvedTitle = title || PHASE_TITLES[phase]; const playSequence = phase === "processing" || phase === "finalizing"; - const progressStyle = useMemo( - () => ({ width: `${resolvedProgress}%` }), - [resolvedProgress] - ); return (
{resolvedTitle} -
@@ -297,13 +87,13 @@ export function GenerationDotPlaceholder({ />
- {showProgress ? ( + {showProgress && phase !== "completed" ? (
- {phase === "failed" ? "已停止" : "生成进度"} - {resolvedProgress}% + {phase === "failed" ? "已停止" : resolvedProgress === null ? "等待结果,返回后自动展示" : "生成进度"} + {resolvedProgress !== null ? {resolvedProgress}% : null}
-
+ {resolvedProgress !== null ?
-
+
: null}
) : null}
- -
); } diff --git a/components/image-generation-loading.tsx b/components/image-generation-loading.tsx index 20ce806dc..4b6175549 100644 --- a/components/image-generation-loading.tsx +++ b/components/image-generation-loading.tsx @@ -41,45 +41,11 @@ const PHASE_HINT: Record, stri idle: "任务已创建,正在初始化生成参数。", queued: "任务已进入队列,请稍候。", processing: "模型正在生成图片,请稍候。", - finalizing: "图片已生成完成,正在同步到对话。", - failed: "生成失败,请重试。", + finalizing: "图片已返回,正在保存并同步到对话。", + failed: "本次处理已停止,请查看对话中的原因和建议。", }; -const LONG_PROCESSING_HINTS = [ - "正在读取参考图与文字需求。", - "正在分析主体、场景和构图关系。", - "正在提取画面中的关键视觉特征。", - "正在确认图片比例和输出尺寸。", - "正在匹配当前模型的最佳生成参数。", - "正在把参考图信息提交给模型。", - "正在等待模型完成第一阶段推理。", - "正在生成初始画面结构。", - "正在保持主体特征和参考一致。", - "正在补全画面中的空间层次。", - "正在调整光影方向和明暗关系。", - "正在优化主体边缘与背景融合。", - "正在细化材质、纹理和局部细节。", - "正在减少画面噪点和不自然区域。", - "正在提升清晰度与整体质感。", - "正在检查生成结果的完整性。", - "正在处理高分辨率输出。", - "正在等待服务商返回最终图片。", - "正在保存图片并同步到对话。", - "正在准备把结果显示到画布。", -]; const LONG_WAIT_SECONDS = 5 * 60; -const LONG_WAIT_HINT = - "服务繁忙,模型可能正在排队或自动尝试备用通道,请耐心等候,结果返回后会自动同步到画布。"; - -function getLongProcessingHint(phase: NonNullable, elapsedSeconds: number) { - if (phase !== "processing" && phase !== "finalizing") return PHASE_HINT[phase]; - if (elapsedSeconds >= LONG_WAIT_SECONDS) return LONG_WAIT_HINT; - const index = Math.min( - LONG_PROCESSING_HINTS.length - 1, - Math.floor(Math.max(0, elapsedSeconds) / 15) - ); - return LONG_PROCESSING_HINTS[index]; -} function formatImageQueueWaitLabel(estimatedWaitMs?: number) { const numeric = Number(estimatedWaitMs || 0); @@ -138,16 +104,8 @@ export const ImageGenerationLoading = memo( const isLongWait = resolvedElapsedSeconds >= LONG_WAIT_SECONDS && (phase === "processing" || phase === "finalizing"); - const estimateRangeLabel = isLongWait - ? "单次最长约10分钟" - : providerType === "grsai-gpt-image" - ? "2~5分钟" - : "60~120秒"; - const elapsedLabel = - resolvedElapsedSeconds > 0 - ? `已等待 ${resolvedElapsedSeconds}s` - : `预计 ${estimateRangeLabel}`; - const phaseHint = getLongProcessingHint(phase, resolvedElapsedSeconds); + const elapsedLabel = resolvedElapsedSeconds > 0 ? `已等待 ${resolvedElapsedSeconds}s` : ""; + const phaseHint = PHASE_HINT[phase]; const queueLine = phase === "queued" && Number.isFinite(queuePosition) ? Math.max(0, Number(queuePosition || 0)) > 0 @@ -189,12 +147,12 @@ export const ImageGenerationLoading = memo( } compact={count > 1} phase={phase} - title={isLongWait ? "服务繁忙,排队处理中" : undefined} + title={isLongWait && phase === "processing" ? "模型尚未返回结果,请勿重复提交" : undefined} />
- 使用 {resolvedModelName} 生成图片,预计 {estimateRangeLabel} + 使用 {resolvedModelName} 生成图片
{queueLine ?
{queueLine}
: null} {queueDetailLine ?
{queueDetailLine}
: null} diff --git a/lib/ai/runtime/agent-image-failover.ts b/lib/ai/runtime/agent-image-failover.ts index fcad92b3d..89e4085dc 100644 --- a/lib/ai/runtime/agent-image-failover.ts +++ b/lib/ai/runtime/agent-image-failover.ts @@ -3,6 +3,7 @@ import { agentModelFault, type AgentModelFault } from "./agent-model-failover"; export function agentImageFailurePolicy(error: unknown): { retry: boolean; pause: AgentModelFault | null } { const value = error as { message?: string; providerOutcomeUnknown?: boolean; status?: number; statusCode?: number } | null; const message = String(value?.message || "").toLowerCase(); + if (value?.providerOutcomeUnknown || ["AbortError", "TimeoutError"].includes(String((error as { name?: string })?.name || ""))) return { retry: false, pause: null }; // A submitted image job may still succeed. Retain its task and reconcile it; // never submit another paid job just because a poll/connection timed out. const unknown = value?.providerOutcomeUnknown || /request_outcome_unknown|may still be running|outcome.unknown|timed out|timeout|超时|fetch failed|econnreset|socket hang up/.test(message); diff --git a/lib/ai/runtime/model-health.ts b/lib/ai/runtime/model-health.ts index ebb71a8dd..f61f29e92 100644 --- a/lib/ai/runtime/model-health.ts +++ b/lib/ai/runtime/model-health.ts @@ -13,7 +13,7 @@ import { isMediaInputFailure, isNonProviderRequestStatus, } from "@/lib/ai/runtime/request-failure-classification"; -import { isGatewayResponseTimeout, isProviderBalanceError, isRateLimitError } from "@/lib/request-error"; +import { getRequestFailureDetails, isGatewayResponseTimeout, isProviderBalanceError, isRateLimitError } from "@/lib/request-error"; export type AiModelHealthEventType = | "success" @@ -344,7 +344,12 @@ export function classifyAiModelHealthFailure(error: unknown): { } export function shouldFailoverAiModelError(error: unknown) { - if (error instanceof ProviderTaskUncertainError || isGatewayResponseTimeout(error)) return false; + if ( + error instanceof ProviderTaskUncertainError || + isGatewayResponseTimeout(error) || + getRequestFailureDetails(error).outcome === "unknown" || + ["AbortError", "TimeoutError"].includes(String((error as { name?: string })?.name || "")) + ) return false; const classification = classifyAiModelHealthFailure(error); const normalized = getErrorMessage(error).toLowerCase(); const imageProviderTaskFailed = diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index bcee6aabd..50291970c 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -44,7 +44,7 @@ import { } from "@/lib/ai/runtime/model-health"; import { uploadFile } from "@/lib/storage/service"; import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance"; -import { isProviderBalanceError } from "@/lib/request-error"; +import { getRequestFailureDetails, isProviderBalanceError } from "@/lib/request-error"; import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage"; export interface CanvasLocalEditCompositeRegion { @@ -5663,7 +5663,11 @@ export async function generateCanvasImage( } // An accepted or uncertain request must not start another paid model/variant. - if (caughtError instanceof ProviderTaskUncertainError) throw caughtError; + if (caughtError && ( + caughtError instanceof ProviderTaskUncertainError || + getRequestFailureDetails(caughtError).outcome === "unknown" || + ["AbortError", "TimeoutError"].includes(String((caughtError as { name?: string })?.name || "")) + )) throw caughtError; const retryVariants = Array.isArray(request.retryVariants) ? request.retryVariants diff --git a/lib/canvas/image-generation-status.ts b/lib/canvas/image-generation-status.ts new file mode 100644 index 000000000..d31ad4474 --- /dev/null +++ b/lib/canvas/image-generation-status.ts @@ -0,0 +1,34 @@ +import type { RequestFailureDetails } from "@/lib/request-error"; + +export type ImageGenerationStatusStreamPayload = { + phase?: "queued" | "processing" | "finalizing" | "failed" | "completed"; + userMessageId?: string; + requestId?: string; + failure?: RequestFailureDetails; + providerType?: string; + modelName?: string; + imageCount?: number; + queuePosition?: number; + sameModelRunningCount?: number; + concurrencyLimit?: number; + estimatedWaitMs?: number; +}; + +export function findImageGenerationFailure( + messages: { role: string; parts?: unknown[] }[], + userMessageId: string, +): ImageGenerationStatusStreamPayload | null { + if (!userMessageId) return null; + for (const message of [...messages].reverse()) { + if (message.role !== "assistant") continue; + for (const part of message.parts || []) { + const candidate = part as { type?: string; data?: ImageGenerationStatusStreamPayload }; + if (candidate.type !== "data-imageGenerationStatus") continue; + const status = candidate.data; + if (status?.phase === "failed" && status.failure && status.userMessageId === userMessageId) { + return status; + } + } + } + return null; +} diff --git a/lib/canvas/image-preview-width.ts b/lib/canvas/image-preview-width.ts new file mode 100644 index 000000000..8a3cf170c --- /dev/null +++ b/lib/canvas/image-preview-width.ts @@ -0,0 +1,5 @@ +const PREVIEW_WIDTHS = [640, 960, 1280, 1600, 2048, 2600, 3200, 4096]; + +export function canvasImagePreviewWidth(width: number, maxWidth: number) { + return Math.min(maxWidth, PREVIEW_WIDTHS.find(value => value >= width) || 4096); +} diff --git a/lib/request-error.ts b/lib/request-error.ts index 9d517e45b..d78ee2fca 100644 --- a/lib/request-error.ts +++ b/lib/request-error.ts @@ -293,7 +293,7 @@ export function getRequestFailureDetails( if (/AOS结果下载超时/.test(text)) { return detail("RMBG_RESULT_DOWNLOAD_TIMEOUT", "抠图已完成,但结果图片下载超时。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown"); } - if ((isRecord(error) && error.providerOutcomeUnknown === true) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running/i.test(text)) { + if ((isRecord(error) && (error.providerOutcomeUnknown === true || ["AbortError", "TimeoutError"].includes(String(error.name)))) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running|operation was aborted/i.test(text)) { return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown"); } if (isProviderBalanceError(error)) { diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index 9de162c7f..8d8843141 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ /routes\/video-canvas-media-import\.test\.ts/, /routes\/video-canvas-toolbar-jobs\.test\.ts/, /routes\/image-provider-error-classification\.test\.ts/, + /routes\/image-generation-status\.test\.ts/, /routes\/canvas-agent-runtime-image-failover\.test\.ts/, /routes\/video-canvas-audio-remote\.test\.ts/, /routes\/video-canvas-groups-upscale\.test\.ts/, diff --git a/tests/e2e/canvas-generation-feedback.test.ts b/tests/e2e/canvas-generation-feedback.test.ts new file mode 100644 index 000000000..5c0dc144a --- /dev/null +++ b/tests/e2e/canvas-generation-feedback.test.ts @@ -0,0 +1,119 @@ +import { expect, test, type Page } from "@playwright/test"; + +test.setTimeout(30_000); +const browserErrors = new WeakMap(); +test.beforeEach(({ page }) => { + const errors: string[] = []; + browserErrors.set(page, errors); + page.on("pageerror", error => errors.push(error.message)); +}); +test.afterEach(({ page }) => expect(browserErrors.get(page)).toEqual([])); + +const projectId = "24cce650-7cfb-48bb-aa65-b04f490ac978"; +const imageOrigin = "https://scqnycdn.jiansun.vip"; +const failure = { + phase: "failed", requestId: "failed-request", userMessageId: "", + failure: { code: "SOURCE_IMAGE_UNAVAILABLE", outcome: "failed", message: "参考图片无法读取。", recommendation: "请重新上传原图后再提交。" }, +}; + +async function setup(page: Page, lostStream = false) { + const submissions: { path: string; body: any }[] = []; + let messages: any[] = []; + const canvasData = { + nodes: [{ id: "preview-image", type: "image", position: { x: 220, y: 160 }, size: { width: 640, height: 420 }, + data: { status: "completed", src: `${imageOrigin}/uploads/feedback-preview.png`, title: "参考图片" } }], + edges: [], viewport: { x: 0, y: 0, zoom: 1 }, + }; + await page.context().addInitScript(() => localStorage.setItem("theme", "light")); + await page.context().route("**/api/**", async route => { + const request = route.request(); + const path = new URL(request.url()).pathname; + let data: unknown = {}; + if (["/api/chat", "/api/canvas/agent-chat"].includes(path) && request.method() === "POST") { + const body = request.postDataJSON(); + submissions.push({ path, body }); + const terminal = { ...failure, userMessageId: body.message.id }; + const text = `${terminal.failure.message}${terminal.failure.recommendation}\n\n请求编号:${terminal.requestId}`; + const assistant = { id: "failed-assistant", role: "assistant", createdAt: new Date(Date.now() + 100).toISOString(), + parts: [{ type: "text", text }, { type: "data-imageGenerationStatus", data: terminal }] }; + messages = [{ ...body.message, createdAt: new Date().toISOString() }, assistant]; + const chunks: any[] = [{ type: "start", messageId: "stream-assistant" }, + { type: "data-imageGenerationStatus", data: JSON.stringify({ phase: "processing", userMessageId: body.message.id }) }]; + if (!lostStream) chunks.push( + { type: "data-imageGenerationStatus", data: JSON.stringify(terminal) }, + { type: "data-appendMessage", data: JSON.stringify(assistant) }, + { type: "data-appendMessage", data: JSON.stringify({ id: "end", role: "assistant", parts: [{ type: "text", text: "" }] }) }, + ); + chunks.push({ type: "finish" }); + await route.fulfill({ headers: { "content-type": "text/event-stream", "x-vercel-ai-ui-message-stream": "v1" }, + body: `${chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n` }); + return; + } + if (path.includes("validate-session")) data = { valid: true, success: true }; + else if (path === "/api/auth/session") data = { user: { id: "feedback-user", name: "测试", type: "admin" }, expires: "2099-01-01" }; + else if (path === "/api/site-settings") data = { canvasEnabled: true, agentEnabled: true, membershipEnabled: false }; + else if (path === "/api/vote") data = []; + else if (path === "/api/models") data = { models: [{ id: "image-test", modelId: "gpt-image-2", providerType: "openai", name: "GPT Image", type: "image", enabled: true, supportsImageGen: true, imageGenConfig: { creditCostPerImage: 15 } }] }; + else if (path === `/api/canvas/${projectId}` && request.method() === "GET") data = { success: true, data: { id: projectId, title: "反馈验收", canvasData } }; + else if (path.includes("messages")) data = { messages }; + else if (path.includes("sessions")) data = { sessions: [] }; + else if (path.startsWith("/api/canvas/")) data = { success: true, runs: [] }; + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(data) }); + }); + await page.context().route(`${imageOrigin}/**`, async route => { + await new Promise(resolve => setTimeout(resolve, 200)); + await route.fulfill({ contentType: "image/svg+xml", body: '' }); + }); + await page.goto(`/canvas/${projectId}`, { waitUntil: "domcontentloaded" }); + await expect(page.getByRole("button", { name: "关闭 Agent,直接使用生图模型", exact: true })).toBeVisible(); + return submissions; +} + +test("image workspaces start with Agent enabled and send requests to its planning runtime", async ({ page }) => { + const submissions = await setup(page); + await page.getByLabel("描述你的设计需求", { exact: true }).fill("请先分析参考图片,再规划处理步骤。"); + await page.getByLabel("描述你的设计需求", { exact: true }).press("Enter"); + await expect.poll(() => submissions.length).toBe(1); + expect(submissions[0].path).toBe("/api/canvas/agent-chat"); + expect(submissions[0].body.composerMode).toBe("agent"); +}); + +for (const lostStream of [false, true]) { + test(`image failure ends waiting and shows its cause${lostStream ? " after recovering history" : " from the stream"}`, async ({ page }) => { + const submissions = await setup(page, lostStream); + await page.getByRole("button", { name: "关闭 Agent,直接使用生图模型", exact: true }).click(); + await page.getByLabel("描述你的设计需求", { exact: true }).fill("将参考图改成白色背景。"); + await page.getByLabel("描述你的设计需求", { exact: true }).press("Enter"); + const failedNode = page.locator('[data-canvas-node-id] [role="alert"]'); + await expect(failedNode).toContainText("参考图片无法读取。"); + await expect(failedNode).toContainText("请重新上传原图后再提交。"); + await expect(failedNode).toContainText("请求编号:failed-request"); + await expect(page.getByTestId("message-assistant-image-loading")).toHaveCount(0); + expect(submissions).toHaveLength(1); + expect(submissions[0].path).toBe("/api/chat"); + await page.screenshot({ path: test.info().outputPath("terminal-failure.png") }); + }); +} + +test("zoom and hover keep a loaded canvas image visible while a new preview loads", async ({ page }) => { + await setup(page); + const node = page.locator('[data-canvas-node-id="preview-image"]'); + await expect.poll(() => node.locator("img").first().evaluate((image: HTMLImageElement) => image.naturalWidth)).toBeGreaterThan(0); + await page.evaluate(() => { + (window as any).blankPreviewFrames = 0; + const inspect = () => { + const node = document.querySelector('[data-canvas-node-id="preview-image"]'); + if (node && !Array.from(node.querySelectorAll("img")).some(image => Number(getComputedStyle(image).opacity) > 0 && image.naturalWidth > 0)) (window as any).blankPreviewFrames++; + requestAnimationFrame(inspect); + }; + requestAnimationFrame(inspect); + }); + await page.mouse.move(500, 350); + for (let i = 0; i < 18; i++) { + await page.mouse.wheel(0, i < 9 ? -100 : 100); + await page.waitForTimeout(50); + } + await page.mouse.move(100, 100); + await page.waitForTimeout(500); + expect(await page.evaluate(() => (window as any).blankPreviewFrames)).toBe(0); +}); diff --git a/tests/routes/canvas-agent-runtime-image-failover.test.ts b/tests/routes/canvas-agent-runtime-image-failover.test.ts index f17cd29a8..28c64c3cb 100644 --- a/tests/routes/canvas-agent-runtime-image-failover.test.ts +++ b/tests/routes/canvas-agent-runtime-image-failover.test.ts @@ -71,6 +71,8 @@ test("a submitted task with an unknown outcome is not submitted again through a await expect(h.generate(h.request)).rejects.toThrow(); expect(h.calls.map(c => c.id)).toEqual(["primary"]); expect(h.paused.size).toBe(0); expect(agentImageFailurePolicy(Object.assign(new Error("timeout"), { providerOutcomeUnknown: true })).retry).toBe(false); + expect(agentImageFailurePolicy(Object.assign(new Error("任务查询失败"), { providerOutcomeUnknown: true })).retry).toBe(false); + expect(agentImageFailurePolicy(Object.assign(new Error("This operation was aborted"), { name: "AbortError" })).retry).toBe(false); expect(agentImageFailurePolicy(new Error("VIP98K task failed: timeout")).retry).toBe(true); }); diff --git a/tests/routes/image-generation-status.test.ts b/tests/routes/image-generation-status.test.ts new file mode 100644 index 000000000..f1456c7bb --- /dev/null +++ b/tests/routes/image-generation-status.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "@playwright/test"; +import { findImageGenerationFailure } from "@/lib/canvas/image-generation-status"; +import { getRequestFailureDetails } from "@/lib/request-error"; +import { serverLoader } from "../helpers/canvas-download-server-loader"; + +test("a terminal image error reaches the stream and persists for disconnected clients", async () => { + const chunks: any[] = []; + const saved: any[] = []; + const load = serverLoader({ "@/lib/db/queries": { saveMessages: async ({ messages }: any) => saved.push(...messages) } }); + const { reportImageGenerationFailure } = load("app/(chat)/api/chat/_lib/image-generation-failure.ts"); + await reportImageGenerationFailure({ + writer: { write: (chunk: any) => chunks.push(chunk) }, + chatId: "chat", userMessageId: "original-user", messageId: "request-id", + error: new Error("get image url failed"), label: "test", + }); + const streamed = JSON.parse(chunks.find(chunk => chunk.type === "data-imageGenerationStatus").data); + const persisted = findImageGenerationFailure(saved, "original-user"); + expect(persisted).toEqual(streamed); + expect(persisted).toMatchObject({ phase: "failed", requestId: "request-id", failure: { code: "SOURCE_IMAGE_UNAVAILABLE", outcome: "failed" } }); + const text = saved[0].parts[0].text; + expect(text).toContain("重新上传原图"); + expect(text).toContain("请求编号:request-id"); + expect(JSON.parse(chunks.at(-1).data).parts[0].text).toContain("IMAGE_GENERATION_END"); + + await reportImageGenerationFailure({ + writer: { write: () => { throw new Error("browser disconnected"); } }, + chatId: "chat", userMessageId: "next-user", messageId: "next-request", + error: new Error("get image url failed"), label: "test", + }); + expect(findImageGenerationFailure(saved, "next-user")?.requestId).toBe("next-request"); +}); + +test("late failures remain associated with their original user request", () => { + const status = { phase: "failed" as const, userMessageId: "first", failure: getRequestFailureDetails(new Error("HTTP 524")) }; + const messages = [{ role: "user", parts: [] }, { role: "assistant", parts: [{ type: "data-imageGenerationStatus", data: status }] }]; + expect(findImageGenerationFailure(messages, "first")).toEqual(status); + expect(findImageGenerationFailure(messages, "second")).toBeNull(); + expect(findImageGenerationFailure([{ role: "assistant", parts: [{ type: "text", text: "请分析失败原因" }] }], "second")).toBeNull(); +}); diff --git a/tests/routes/image-provider-error-classification.test.ts b/tests/routes/image-provider-error-classification.test.ts index 21aa07be9..e793f1b2e 100644 --- a/tests/routes/image-provider-error-classification.test.ts +++ b/tests/routes/image-provider-error-classification.test.ts @@ -69,6 +69,20 @@ test("HTTP 524 does not start another model while the original result is unconfi expect(health.shouldFailoverAiModelError(new Error("Service unavailable (HTTP 503)"))).toBe(true); }); +test("aborts and unconfirmed fallback results cannot start another paid generation", () => { + const { health } = healthHarness(); + for (const error of [ + Object.assign(new Error("This operation was aborted"), { name: "AbortError" }), + Object.assign(new Error("request timed out"), { name: "TimeoutError" }), + Object.assign(new Error("任务查询失败"), { providerOutcomeUnknown: true }), + new Error("图片上传到存储桶失败"), + ]) { + expect(getRequestFailureDetails(error).outcome).toBe("unknown"); + expect(health.shouldFailoverAiModelError(error)).toBe(false); + } + expect(health.shouldFailoverAiModelError(new Error("Service unavailable (HTTP 503)"))).toBe(true); +}); + test("the provider submits once when an HTTP 524 request cannot be located", async () => { let submissions = 0; const provider = serverLoader({