diff --git a/app/api/canvas/generate/image/route.ts b/app/api/canvas/generate/image/route.ts index a5d8aa784..b2d13dfbc 100644 --- a/app/api/canvas/generate/image/route.ts +++ b/app/api/canvas/generate/image/route.ts @@ -16,7 +16,7 @@ import { type ImageToolCreditFeature, } from "@/lib/points/config"; -export const maxDuration = 120; +export const maxDuration = 600; interface GenerateImageRequest extends CanvasGenerateImageRequest { async?: boolean; diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 5fd78fe5c..0b5d9cf4b 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -1036,6 +1036,7 @@ export function CanvasChatPanel({ } | null>(null); const [streamImageGenerationStateByChatId, setStreamImageGenerationStateByChatId] = useState>({}); + const canvasGenerationPlaceholderByChatIdRef = useRef>({}); const streamImageGenerationState = useMemo( () => streamImageGenerationStateByChatId[activeChatId] || null, [activeChatId, streamImageGenerationStateByChatId] @@ -3247,6 +3248,29 @@ export function CanvasChatPanel({ // 合并 initialMessages 和历史消息 const mergedInitialMessages = historyData?.messages ?? (activeChatId === chatId ? initialMessages : []); + const removeCanvasGenerationPlaceholders = useCallback( + (chatSessionId?: string) => { + const state = useCanvasStore.getState(); + const ids = state.nodes + .filter((node) => { + if (node.type !== "image") return false; + const metadata = ((node.data as any)?.metadata || {}) as Record; + if (metadata.canvasGenerationPlaceholder !== true) return false; + if (!chatSessionId) return true; + return String(metadata.chatId || "") === chatSessionId; + }) + .map((node) => node.id); + + ids.forEach((nodeId) => deleteNode(nodeId)); + if (chatSessionId) { + delete canvasGenerationPlaceholderByChatIdRef.current[chatSessionId]; + } else { + canvasGenerationPlaceholderByChatIdRef.current = {}; + } + }, + [deleteNode] + ); + const appendStreamMediaToCanvas = useCallback( async (payload: CanvasMediaStreamPayload) => { const imageUrls = Array.isArray(payload.imageUrls) @@ -3259,6 +3283,8 @@ export function CanvasChatPanel({ return; } + removeCanvasGenerationPlaceholders(); + const latestNodes = useCanvasStore.getState().nodes; const suppressedMediaKeys = new Set( (useCanvasStore.getState().getCanvasState().suppressedMediaKeys || []) @@ -3420,7 +3446,7 @@ export function CanvasChatPanel({ } }, - [addNode, autoNameGeneratedImageNode] + [addNode, autoNameGeneratedImageNode, removeCanvasGenerationPlaceholders] ); const { @@ -3545,6 +3571,7 @@ export function CanvasChatPanel({ typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data; if (!payload || typeof payload !== "object") return; void appendStreamMediaToCanvas(payload); + clearStreamImageGenerationStateForChat(eventChatId); } catch (e) { console.error("[CanvasChat] Failed to parse data-canvasMedia:", e); } @@ -7862,6 +7889,107 @@ export function CanvasChatPanel({ streamImageGenerationState?.sameModelRunningCount; const loadingConcurrencyLimit = streamImageGenerationState?.concurrencyLimit; const loadingEstimatedWaitMs = streamImageGenerationState?.estimatedWaitMs; + useEffect(() => { + const streamState = streamImageGenerationStateByChatId[activeChatId] || null; + const placeholderId = canvasGenerationPlaceholderByChatIdRef.current[activeChatId]; + + if (!streamState) { + removeCanvasGenerationPlaceholders(activeChatId); + return; + } + + const phase = streamState.phase || "processing"; + const label = + phase === "queued" + ? "等待开始生成" + : phase === "finalizing" + ? "正在润饰细节" + : phase === "failed" + ? "生成失败" + : "正在创建图片"; + const status = phase === "failed" ? "failed" : "generating"; + const imageParamSnapshot = imageParamsRef.current || imageParams; + const aspectRatio = + imageParamSnapshot.aspectRatio && imageParamSnapshot.aspectRatio !== "auto" + ? imageParamSnapshot.aspectRatio + : "1:1"; + const [ratioWidthRaw, ratioHeightRaw] = aspectRatio.split(":").map((item) => Number(item)); + const ratioWidth = Number.isFinite(ratioWidthRaw) && ratioWidthRaw > 0 ? ratioWidthRaw : 1; + const ratioHeight = Number.isFinite(ratioHeightRaw) && ratioHeightRaw > 0 ? ratioHeightRaw : 1; + const baseSide = 340; + const placeholderSize = + ratioWidth >= ratioHeight + ? { + width: baseSide, + height: Math.max(190, Math.round((baseSide * ratioHeight) / ratioWidth)), + } + : { + width: Math.max(190, Math.round((baseSide * ratioWidth) / ratioHeight)), + height: baseSide, + }; + const metadata = { + canvasGenerationPlaceholder: true, + chatId: activeChatId, + detailProgressLabel: label, + generationProgress: + phase === "queued" ? 12 : phase === "finalizing" ? 96 : phase === "failed" ? 100 : undefined, + modelName: streamState.modelName || currentModel?.displayName || undefined, + providerType: streamState.providerType || currentModel?.providerType || undefined, + }; + + if (placeholderId) { + const existingNode = useCanvasStore + .getState() + .nodes.find((node) => node.id === placeholderId); + if (existingNode) { + updateNode(placeholderId, { + data: { + ...((existingNode.data as any) || {}), + src: "", + status, + title: label, + prompt: label, + metadata: { + ...(((existingNode.data as any)?.metadata || {}) as Record), + ...metadata, + }, + }, + size: placeholderSize, + }); + return; + } + delete canvasGenerationPlaceholderByChatIdRef.current[activeChatId]; + } + + const currentNodes = useCanvasStore.getState().nodes; + const existingNodes = currentNodes.length; + const nodeId = addNode({ + type: "image", + position: { + x: 100 + (existingNodes % 3) * (placeholderSize.width + 20), + y: 100 + Math.floor(existingNodes / 3) * (placeholderSize.height + 20), + }, + size: placeholderSize, + data: { + src: "", + status, + title: label, + prompt: label, + metadata, + }, + connections: [], + }); + canvasGenerationPlaceholderByChatIdRef.current[activeChatId] = nodeId; + }, [ + activeChatId, + addNode, + currentModel?.displayName, + currentModel?.providerType, + imageParams, + removeCanvasGenerationPlaceholders, + streamImageGenerationStateByChatId, + updateNode, + ]); const renderableVisibleMessages = useMemo(() => { return visibleMessages.reduce((acc, message) => { const rawParts = (message.parts || []) as any[]; diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 01a2c998f..7de0d510d 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -1277,6 +1277,11 @@ export function ImageNode({ typeof data.metadata?.detailProgressLabel === "string" ? data.metadata.detailProgressLabel.trim() : ""; + const generationProgress = + typeof data.metadata?.generationProgress === "number" && + Number.isFinite(data.metadata.generationProgress) + ? Math.max(0, Math.min(100, Math.round(data.metadata.generationProgress))) + : null; const flipRotateOriginalSrc = data.metadata && typeof data.metadata === "object" ? String((data.metadata as Record).flipRotateOriginalSrc || "").trim() @@ -8301,6 +8306,7 @@ export function ImageNode({ ; + generationProgress?: number | null; onImageUpload: (event: React.ChangeEvent) => void; onSelectFile: (event: React.MouseEvent) => void; status?: string; @@ -38,21 +40,20 @@ export function ImageNodeProcessingOverlay() { export function ImageNodeEmptyState({ detailProgressLabel, fileInputRef, + generationProgress, onImageUpload, onSelectFile, status, }: ImageNodeEmptyStateProps) { if (status === "generating") { return ( -
- - - - - {detailProgressLabel || "生成中..."} - - 图片处理中 -
+ ); } diff --git a/components/generation-dot-placeholder.tsx b/components/generation-dot-placeholder.tsx new file mode 100644 index 000000000..9d3455830 --- /dev/null +++ b/components/generation-dot-placeholder.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { cn } from "@/lib/utils"; + +type GenerationPhase = "idle" | "queued" | "processing" | "finalizing" | "failed" | "completed"; + +interface GenerationDotPlaceholderProps { + className?: string; + title?: string; + phase?: GenerationPhase; + progress?: number | null; + compact?: boolean; + showProgress?: boolean; +} + +const PHASE_TITLES: Record = { + idle: "正在创建图片", + queued: "等待开始生成", + processing: "生成初稿中", + finalizing: "正在润饰细节", + failed: "生成失败", + completed: "生成完成", +}; + +const ROTATING_TITLES = ["正在创建图片", "生成初稿中", "正在润饰细节"]; + +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") return 100; + if (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)); +} + +export function GenerationDotPlaceholder({ + className, + title, + phase = "processing", + progress, + compact = false, + showProgress = true, +}: GenerationDotPlaceholderProps) { + const [elapsedMs, setElapsedMs] = useState(0); + + useEffect(() => { + const startedAt = Date.now(); + const timer = window.setInterval(() => { + setElapsedMs(Date.now() - startedAt); + }, 600); + return () => window.clearInterval(timer); + }, [phase]); + + const resolvedProgress = clampProgress( + typeof progress === "number" ? progress : estimateProgress(phase, elapsedMs) + ); + const rotatingTitle = ROTATING_TITLES[Math.floor(elapsedMs / 3200) % ROTATING_TITLES.length]; + const resolvedTitle = title || (phase === "processing" ? rotatingTitle : PHASE_TITLES[phase]); + const dotOpacity = phase === "failed" ? 0.22 : 0.34; + + const progressStyle = useMemo( + () => ({ width: `${resolvedProgress}%` }), + [resolvedProgress] + ); + + return ( +
+
+
+ {resolvedTitle} +
+
+ {showProgress ? ( +
+
+ {phase === "failed" ? "已停止" : "生成进度"} + {resolvedProgress}% +
+
+
+
+
+ ) : null} +
+
+
+ +
+ ); +} diff --git a/components/image-generation-loading.tsx b/components/image-generation-loading.tsx index 2e0baf0af..980314932 100644 --- a/components/image-generation-loading.tsx +++ b/components/image-generation-loading.tsx @@ -1,8 +1,8 @@ "use client"; import { memo } from "react"; -import { ChevronDown, Grid2x2, CircleDot } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { ChevronDown, CircleDot } from "lucide-react"; +import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder"; type ColorTheme = "purple" | "blue" | "green" | "orange" | "red"; @@ -72,10 +72,9 @@ export const ImageGenerationLoading = memo( const count = Math.min(Math.max(imageCount, 1), maxImages); const resolvedModelName = modelName?.trim() || PROVIDER_MODEL_LABEL[providerType || ""] || "AI 图片模型"; - const estimateRangeLabel = "60~120秒"; + const estimateRangeLabel = providerType === "grsai-gpt-image" ? "2~5分钟" : "60~120秒"; const elapsedLabel = elapsedSeconds > 0 ? `已等待 ${elapsedSeconds}s` : `预计 ${estimateRangeLabel}`; - const isFailed = phase === "failed"; const queueLine = phase === "queued" && Number.isFinite(queuePosition) ? Math.max(0, Number(queuePosition || 0)) > 0 @@ -98,7 +97,7 @@ export const ImageGenerationLoading = memo( data-role="assistant" data-testid="message-assistant-image-loading" > -
+
开始生成 @@ -109,38 +108,11 @@ export const ImageGenerationLoading = memo( {resolvedModelName}
-
1 && "grid-cols-2" - )} - > - {count === 1 ? ( - <> -
-
- - ) : ( - Array.from({ length: count }).map((_, index) => ( -
-
-
-
- )) - )} -
- -
- - {isFailed ? "生成失败" : "设计图处理中"} -
+ 1} + phase={phase} + />
@@ -155,22 +127,6 @@ export const ImageGenerationLoading = memo( )}
- -
); } diff --git a/components/image-params-bar.tsx b/components/image-params-bar.tsx index aaa78b204..5b3b80702 100644 --- a/components/image-params-bar.tsx +++ b/components/image-params-bar.tsx @@ -122,14 +122,14 @@ export function ImageParamsBar({ }; const getImageCountOptions = () => { - if (inlineTheme === "minimal") { - return Array.from({ length: 10 }, (_, i) => i + 1); - } - if (imageGenConfig?.maxImages) { return Array.from({ length: imageGenConfig.maxImages }, (_, i) => i + 1); } + if (inlineTheme === "minimal") { + return Array.from({ length: 10 }, (_, i) => i + 1); + } + if (providerType === "ruxa-image") { return [1]; } @@ -154,22 +154,25 @@ export function ImageParamsBar({ // Gemini:1~2 张 return [1, 2]; }; + const isImageSizeAllowed = (s: ImageSizeValue) => { + if (!imageGenConfig) return true; + if (s === "1K") return imageGenConfig.supports1K !== false; + if (s === "2K") return imageGenConfig.supports2K !== false; + if (s === "4K") return imageGenConfig.supports4K !== false; + return true; + }; + + const getFallbackImageSize = () => + (["2K", "1K", "4K"] as const).find(isImageSizeAllowed) || "2K"; + const clampParamsByProvider = (raw: ImageGenerationParams): ImageGenerationParams => { const options = getImageCountOptions(); const fallbackCount = options[0] ?? 1; const imageCount = options.includes(raw.imageCount || fallbackCount) ? (raw.imageCount || fallbackCount) : fallbackCount; // 根据模型配置校正分辨率:如果当前选择的档位被禁用,则降级到可用的档位 let imageSize = normalizeImageSizeValue(raw.imageSize); - if (imageGenConfig) { - const sizeAllowed = (s: ImageSizeValue) => { - if (s === "1K") return imageGenConfig.supports1K !== false; - if (s === "2K") return imageGenConfig.supports2K !== false; - if (s === "4K") return imageGenConfig.supports4K !== false; - return true; - }; - if (!sizeAllowed(imageSize)) { - imageSize = (["2K", "1K", "4K"] as const).find(sizeAllowed) || "2K"; - } + if (!isImageSizeAllowed(imageSize)) { + imageSize = getFallbackImageSize(); } return { ...raw, @@ -218,11 +221,7 @@ export function ImageParamsBar({ // 根据模型配置过滤可用的分辨率档位 const availableSizeOptions = IMAGE_SIZE_OPTIONS.filter((option) => { if (option.disabled) return false; - if (!imageGenConfig) return true; // 无配置时全部显示 - if (option.value === "1K" && imageGenConfig.supports1K === false) return false; - if (option.value === "2K" && imageGenConfig.supports2K === false) return false; - if (option.value === "4K" && imageGenConfig.supports4K === false) return false; - return true; + return isImageSizeAllowed(option.value); }); const getComputedOutputSize = (aspectRatio: string, imageSize: string) => { @@ -337,8 +336,10 @@ export function ImageParamsBar({ { label: "16:9(4K)", aspectRatio: "16:9", imageSize: "4K" }, { label: "9:16(4K)", aspectRatio: "9:16", imageSize: "4K" }, { label: "auto", aspectRatio: "auto", imageSize: params.imageSize || "1K" }, - ] as const; - const imageCountOptions = Array.from({ length: 10 }, (_, index) => index + 1); + ].filter((option) => + option.aspectRatio === "auto" || isImageSizeAllowed(normalizeImageSizeValue(option.imageSize)) + ); + const imageCountOptions = getImageCountOptions(); const selectedAspectLabel = params.aspectRatio === "auto" ? "auto" : params.aspectRatio; const selectedImageCount = params.imageCount || 1; const renderMinimalRatioGlyph = (ratioValue: string) => { diff --git a/lib/ai/providers/gemini-image.ts b/lib/ai/providers/gemini-image.ts index 1dc5c0e76..205e96945 100644 --- a/lib/ai/providers/gemini-image.ts +++ b/lib/ai/providers/gemini-image.ts @@ -533,8 +533,8 @@ function formatImageResponse( const GRSAI_NANO_BANANA_SUBMIT_TIMEOUT_MS = 30000; const GRSAI_NANO_BANANA_POLL_TIMEOUT_MS = 30000; const GRSAI_NANO_BANANA_POLL_INTERVAL_MS = 5000; -const GRSAI_GPT_IMAGE_TIMEOUT_MS = 300000; -const GRSAI_GPT_IMAGE_4K_TIMEOUT_MS = 420000; +const GRSAI_GPT_IMAGE_TIMEOUT_MS = 540000; +const GRSAI_GPT_IMAGE_4K_TIMEOUT_MS = 570000; function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); diff --git a/lib/ai/runtime/model-health.ts b/lib/ai/runtime/model-health.ts index 5b8585c69..4d19aea38 100644 --- a/lib/ai/runtime/model-health.ts +++ b/lib/ai/runtime/model-health.ts @@ -75,6 +75,8 @@ export function classifyAiModelHealthFailure(error: unknown): { normalized.includes("review result") || normalized.includes("content policy") || normalized.includes("safety") || + normalized.includes("generate image failed") || + normalized.includes("could not generate images with the given prompts and images") || normalized.includes("user abort") || normalized.includes("aborted") || normalized.includes("cancelled") diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index e05ec6726..2e10eb147 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -310,6 +310,60 @@ type CanvasModelWithProvider = Awaited< ReturnType >[number]; +type CanvasImageSizeValue = "1K" | "2K" | "4K"; + +interface CanvasImageGenConfig { + maxImages?: number; + supports1K?: boolean; + supports2K?: boolean; + supports4K?: boolean; +} + +function normalizeCanvasImageSizeValue( + value?: string, + fallback: CanvasImageSizeValue = "2K" +): CanvasImageSizeValue { + const normalized = String(value || "").trim().toUpperCase(); + if (normalized === "1K" || normalized === "2K" || normalized === "4K") { + return normalized; + } + return fallback; +} + +function isCanvasImageSizeSupported( + config: CanvasImageGenConfig | null | undefined, + size: CanvasImageSizeValue +) { + if (!config) return true; + if (size === "1K") return config.supports1K !== false; + if (size === "2K") return config.supports2K !== false; + if (size === "4K") return config.supports4K !== false; + return true; +} + +function normalizeCanvasImageParamsByModel(params: { + imageCount: number; + imageSize: string; + imageGenConfig: CanvasImageGenConfig | null | undefined; +}) { + const maxImages = + typeof params.imageGenConfig?.maxImages === "number" && + Number.isFinite(params.imageGenConfig.maxImages) + ? Math.max(1, Math.floor(params.imageGenConfig.maxImages)) + : 12; + const imageCount = Math.max(1, Math.min(maxImages, Math.floor(params.imageCount))); + + let imageSize = normalizeCanvasImageSizeValue(params.imageSize); + if (!isCanvasImageSizeSupported(params.imageGenConfig, imageSize)) { + imageSize = + (["2K", "1K", "4K"] as const).find((candidate) => + isCanvasImageSizeSupported(params.imageGenConfig, candidate) + ) || "2K"; + } + + return { imageCount, imageSize }; +} + function getCanvasModelRefKeys(item: CanvasModelWithProvider) { return [ String(item.model.id || "").trim(), @@ -376,11 +430,11 @@ export async function generateCanvasImage( typeof request.aspectRatio === "string" && request.aspectRatio.trim() ? request.aspectRatio.trim() : "auto"; - const imageSize = + const requestedImageSize = typeof request.imageSize === "string" && request.imageSize.trim() ? request.imageSize.trim() : "2K"; - const imageCount = + const requestedImageCount = typeof request.imageCount === "number" && Number.isFinite(request.imageCount) ? Math.max(1, Math.min(12, Math.floor(request.imageCount))) : 1; @@ -454,6 +508,26 @@ export async function generateCanvasImage( provider = fallbackProvider; } + const normalizedImageParams = normalizeCanvasImageParamsByModel({ + imageCount: requestedImageCount, + imageSize: requestedImageSize, + imageGenConfig: selectedModel.model.imageGenConfig as CanvasImageGenConfig | null | undefined, + }); + const imageSize = normalizedImageParams.imageSize; + const imageCount = normalizedImageParams.imageCount; + if (imageSize !== requestedImageSize || imageCount !== requestedImageCount) { + console.info("[Canvas Generate Image] Normalized model image params:", { + debugLabel, + requestedImageSize, + normalizedImageSize: imageSize, + requestedImageCount, + normalizedImageCount: imageCount, + selectedModelRef: selectedModel.model.id, + selectedModelId: selectedModel.model.modelId, + providerType: provider.type, + }); + } + const normalizedReferenceImages = Array.from( new Set( [