improve canvas generation placeholders
This commit is contained in:
parent
36654a6fa3
commit
1947aae685
@ -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;
|
||||
|
||||
@ -1036,6 +1036,7 @@ export function CanvasChatPanel({
|
||||
} | null>(null);
|
||||
const [streamImageGenerationStateByChatId, setStreamImageGenerationStateByChatId] =
|
||||
useState<Record<string, ImageGenerationStatusStreamPayload | null>>({});
|
||||
const canvasGenerationPlaceholderByChatIdRef = useRef<Record<string, string>>({});
|
||||
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<string, unknown>;
|
||||
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<string, unknown>),
|
||||
...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<ChatMessage[]>((acc, message) => {
|
||||
const rawParts = (message.parts || []) as any[];
|
||||
|
||||
@ -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<string, unknown>).flipRotateOriginalSrc || "").trim()
|
||||
@ -8301,6 +8306,7 @@ export function ImageNode({
|
||||
<ImageNodeEmptyState
|
||||
detailProgressLabel={detailProgressLabel}
|
||||
fileInputRef={fileInputRef}
|
||||
generationProgress={generationProgress}
|
||||
onImageUpload={handleImageUpload}
|
||||
onSelectFile={handleSelectFile}
|
||||
status={data.status}
|
||||
|
||||
@ -2,10 +2,12 @@
|
||||
|
||||
import { AlertCircle, ImagePlus, Loader2 } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder";
|
||||
|
||||
interface ImageNodeEmptyStateProps {
|
||||
detailProgressLabel?: string;
|
||||
fileInputRef: React.Ref<HTMLInputElement>;
|
||||
generationProgress?: number | null;
|
||||
onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
status?: string;
|
||||
@ -38,21 +40,20 @@ export function ImageNodeProcessingOverlay() {
|
||||
export function ImageNodeEmptyState({
|
||||
detailProgressLabel,
|
||||
fileInputRef,
|
||||
generationProgress,
|
||||
onImageUpload,
|
||||
onSelectFile,
|
||||
status,
|
||||
}: ImageNodeEmptyStateProps) {
|
||||
if (status === "generating") {
|
||||
return (
|
||||
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#f7f9fc] px-6 text-center dark:bg-zinc-900">
|
||||
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</span>
|
||||
<span className="select-none px-3 text-sm font-medium text-[#3f4b63]">
|
||||
{detailProgressLabel || "生成中..."}
|
||||
</span>
|
||||
<span className="mt-1 text-xs text-[#7a8395]">图片处理中</span>
|
||||
</div>
|
||||
<GenerationDotPlaceholder
|
||||
className="h-full w-full rounded-none"
|
||||
compact
|
||||
phase="processing"
|
||||
progress={generationProgress}
|
||||
title={detailProgressLabel || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
152
components/generation-dot-placeholder.tsx
Normal file
152
components/generation-dot-placeholder.tsx
Normal file
@ -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<GenerationPhase, string> = {
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"relative isolate overflow-hidden rounded-[32px] bg-[#f4f4f4] text-[#565b63]",
|
||||
compact ? "min-h-[190px] p-5" : "min-h-[360px] p-8",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="relative z-10 flex h-full min-h-[inherit] flex-col">
|
||||
<div className={cn("font-semibold tracking-tight", compact ? "text-[15px]" : "text-[22px]")}>
|
||||
{resolvedTitle}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{showProgress ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-[12px] font-medium text-[#7d838b]">
|
||||
<span>{phase === "failed" ? "已停止" : "生成进度"}</span>
|
||||
<span>{resolvedProgress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-white/80 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)]">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-[width] duration-700 ease-out",
|
||||
phase === "failed" ? "bg-[#c7ccd3]" : "bg-[#9fa4aa]"
|
||||
)}
|
||||
style={progressStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="generation-dot-field generation-dot-field-a absolute inset-0"
|
||||
style={{ opacity: dotOpacity }}
|
||||
/>
|
||||
<div
|
||||
className="generation-dot-field generation-dot-field-b absolute inset-0"
|
||||
style={{ opacity: dotOpacity * 0.9 }}
|
||||
/>
|
||||
<style jsx>{`
|
||||
.generation-dot-field {
|
||||
background-image: radial-gradient(circle, rgba(40, 44, 50, 0.42) 1.35px, transparent 1.55px);
|
||||
background-size: ${compact ? "14px 14px" : "18px 18px"};
|
||||
animation: generation-dot-drift 7.5s ease-in-out infinite alternate;
|
||||
mask-image:
|
||||
radial-gradient(circle at 32% 69%, black 0 19%, rgba(0, 0, 0, 0.72) 27%, transparent 48%),
|
||||
radial-gradient(circle at 68% 36%, black 0 15%, rgba(0, 0, 0, 0.58) 25%, transparent 45%);
|
||||
-webkit-mask-image:
|
||||
radial-gradient(circle at 32% 69%, black 0 19%, rgba(0, 0, 0, 0.72) 27%, transparent 48%),
|
||||
radial-gradient(circle at 68% 36%, black 0 15%, rgba(0, 0, 0, 0.58) 25%, transparent 45%);
|
||||
mask-composite: add;
|
||||
-webkit-mask-composite: source-over;
|
||||
}
|
||||
|
||||
.generation-dot-field-b {
|
||||
background-size: ${compact ? "18px 18px" : "23px 23px"};
|
||||
animation-duration: 9s;
|
||||
animation-direction: alternate-reverse;
|
||||
filter: blur(0.1px);
|
||||
transform: translate3d(6px, -4px, 0);
|
||||
}
|
||||
|
||||
@keyframes generation-dot-drift {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
transform: translate3d(-3px, 2px, 0) scale(0.985);
|
||||
}
|
||||
100% {
|
||||
background-position: 26px 18px;
|
||||
transform: translate3d(5px, -4px, 0) scale(1.015);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
>
|
||||
<div className="w-full max-w-[560px] space-y-2.5">
|
||||
<div className="w-full max-w-[520px] space-y-2.5">
|
||||
<div className="flex items-center gap-2 text-[13px] text-[#9aa3b2]">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<span className="font-medium">开始生成</span>
|
||||
@ -109,38 +108,11 @@ export const ImageGenerationLoading = memo(
|
||||
<span className="text-[14px]">{resolvedModelName}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-[22px] border border-[#e1e4ea] bg-[#eaedf2]",
|
||||
count === 1 ? "h-[420px] w-full max-w-[340px]" : "grid gap-3 p-3",
|
||||
count > 1 && "grid-cols-2"
|
||||
)}
|
||||
>
|
||||
{count === 1 ? (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#e9ecf1] via-[#dfe4eb] to-[#e9ecf1] opacity-90" />
|
||||
<div className="generation-skeleton-shimmer absolute inset-0 bg-[linear-gradient(105deg,transparent_0%,rgba(255,255,255,0.45)_48%,transparent_100%)]" />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: count }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative h-[180px] overflow-hidden rounded-2xl border border-[#dfe3e9] bg-[#e7ebf0]"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#e7ebf0] via-[#dce2ea] to-[#e7ebf0]" />
|
||||
<div
|
||||
className="generation-skeleton-shimmer absolute inset-0 bg-[linear-gradient(105deg,transparent_0%,rgba(255,255,255,0.42)_48%,transparent_100%)]"
|
||||
style={{ animationDelay: `${index * 0.2}s` }}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1 text-[13px] text-[#9aa3b2]">
|
||||
<Grid2x2 className="h-5 w-5" />
|
||||
<span className="font-medium">{isFailed ? "生成失败" : "设计图处理中"}</span>
|
||||
</div>
|
||||
<GenerationDotPlaceholder
|
||||
className={count === 1 ? "h-[420px] w-full max-w-[420px]" : "h-[360px] w-full"}
|
||||
compact={count > 1}
|
||||
phase={phase}
|
||||
/>
|
||||
|
||||
<div className="space-y-0.5 text-[#9aa3b2]">
|
||||
<div className="text-[13px] font-medium">
|
||||
@ -155,22 +127,6 @@ export const ImageGenerationLoading = memo(
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.generation-skeleton-shimmer {
|
||||
transform: translateX(-100%);
|
||||
animation: generation-shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes generation-shimmer {
|
||||
0% {
|
||||
transform: translateX(-110%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(110%);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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<string, any> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -310,6 +310,60 @@ type CanvasModelWithProvider = Awaited<
|
||||
ReturnType<typeof getEnabledAiModelsWithProvider>
|
||||
>[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(
|
||||
[
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user