fix: keep generation placeholders active during processing
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-24 04:09:50 +08:00
parent bb86d1fb1a
commit 5b30c2616b

View File

@ -517,6 +517,69 @@ function getReplacementCardStatusMeta(status: ReplacementCardStatus) {
}
}
interface LocalGenerationPendingState {
modeType: AgentGenerationResultModeType;
expectedCount: number;
userTurnCountAtStart: number;
}
function AnimatedGenerationPlaceholderCard(params: {
title: string;
description: string;
badge: string;
badgeClassName?: string;
showSpinner?: boolean;
}) {
return (
<div className="relative h-full w-full overflow-hidden bg-[linear-gradient(180deg,#060809_0%,#101417_100%)]">
<motion.div
aria-hidden
className="absolute inset-[-42%] origin-center"
animate={{ rotate: 360 }}
transition={{ duration: 5.2, repeat: Infinity, ease: "linear" }}
>
<div className="h-full w-full bg-[conic-gradient(from_0deg,rgba(16,185,129,0)_0deg,rgba(16,185,129,0.96)_42deg,rgba(34,211,238,0.86)_120deg,rgba(251,191,36,0.72)_198deg,rgba(168,85,247,0.88)_272deg,rgba(16,185,129,0)_360deg)] opacity-95" />
</motion.div>
<div className="absolute inset-[1.5px] rounded-[20px] bg-[linear-gradient(180deg,#080a0c_0%,#12171a_100%)]" />
<motion.div
aria-hidden
className="absolute inset-[1.5px] rounded-[20px] bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.16),transparent_58%)]"
animate={{ opacity: [0.42, 0.72, 0.42] }}
transition={{ duration: 2.3, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.div
aria-hidden
className="absolute inset-[1.5px] rounded-[20px] opacity-70"
style={{
backgroundImage:
"linear-gradient(118deg, transparent 8%, rgba(255,255,255,0.08) 36%, transparent 62%)",
backgroundSize: "220% 100%",
}}
animate={{ backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"] }}
transition={{ duration: 3.6, repeat: Infinity, ease: "linear" }}
/>
<div className="relative z-10 flex h-full flex-col items-center justify-center gap-3 px-5 text-center">
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex rounded-full border px-3 py-1 text-[11px]",
params.badgeClassName || "border-white/12 bg-white/[0.05] text-white/65"
)}
>
{params.badge}
</span>
{params.showSpinner ? (
<Loader2 className="h-4 w-4 animate-spin text-emerald-200/80" />
) : null}
</div>
<div className="text-sm font-medium tracking-[0.02em] text-white/82">{params.title}</div>
<div className="max-w-[220px] text-xs leading-6 text-white/42">{params.description}</div>
</div>
</div>
);
}
export function AgentGenerationWorkspaceDarkV2({
chatId,
projectId,
@ -618,6 +681,8 @@ export function AgentGenerationWorkspaceDarkV2({
imageSize: normalizedIncomingWorkspaceState.imageParams?.imageSize || "2K",
imageCount: normalizedIncomingWorkspaceState.imageParams?.imageCount || 1,
});
const [pendingGeneration, setPendingGeneration] =
useState<LocalGenerationPendingState | null>(null);
const { data: historyData } = useSWR<{ messages: ChatMessage[] }>(
rawChatId ? `/api/messages?chatId=${rawChatId}` : null,
@ -869,6 +934,9 @@ export function AgentGenerationWorkspaceDarkV2({
messages: historyMessages,
experimental_throttle: 100,
generateId: generateUUID,
onError: () => {
setPendingGeneration(null);
},
transport: new DefaultChatTransport({
api: "/api/chat",
fetch: fetchWithErrorHandlers,
@ -1099,6 +1167,10 @@ export function AgentGenerationWorkspaceDarkV2({
})
.filter((group) => group.imageUrls.length > 0);
}, [messages]);
const userMessageCount = useMemo(
() => messages.filter((message) => message.role === "user").length,
[messages]
);
const generatedGroupsAfterLastUser = useMemo(() => {
const lastUserIndex = [...messages]
@ -1178,6 +1250,40 @@ export function AgentGenerationWorkspaceDarkV2({
const currentLiveResultUrls = isReplacementPreset
? latestReplacementResultUrls
: currentSceneGeneratedUrls;
const transportIsGenerating = status === "streaming" || status === "submitted";
const pendingGenerationProgressCount = useMemo(() => {
if (!pendingGeneration) {
return 0;
}
if (userMessageCount <= pendingGeneration.userTurnCountAtStart) {
return 0;
}
if (pendingGeneration.modeType === "scene_generation") {
return currentSceneGeneratedUrls.length;
}
return latestReplacementResults.length;
}, [
currentSceneGeneratedUrls.length,
latestReplacementResults.length,
pendingGeneration,
userMessageCount,
]);
const isAnyGenerationRunning = transportIsGenerating || Boolean(pendingGeneration);
const isSceneResultModeGenerating =
(activeResultModeType === "scene_generation" &&
pendingGeneration?.modeType === "scene_generation") ||
(activeResultModeType === currentResultModeType &&
currentResultModeType === "scene_generation" &&
transportIsGenerating);
const isDetailResultModeGenerating =
pendingGeneration?.modeType === "detail_replacement" ||
(currentResultModeType === "detail_replacement" && transportIsGenerating);
const isSkuResultModeGenerating =
pendingGeneration?.modeType === "sku_replacement" ||
(currentResultModeType === "sku_replacement" && transportIsGenerating);
const mergedResultCollections = useMemo(
() => {
const baseCollections = {
@ -1204,13 +1310,13 @@ export function AgentGenerationWorkspaceDarkV2({
.filter(
(modeType) =>
modeType === currentResultModeType ||
modeType === pendingGeneration?.modeType ||
(mergedResultCollections[modeType] || []).length > 0
),
[currentResultModeType, mergedResultCollections]
[currentResultModeType, mergedResultCollections, pendingGeneration?.modeType]
);
const activeResultModeUrls = mergedResultCollections[activeResultModeType] || [];
const displayedResultUrl = selectedResultUrl || activeResultModeUrls[0] || null;
const isGenerating = status === "streaming" || status === "submitted";
const detailResultCards = useMemo(
() =>
detailReplacementReferenceCards.map((card, index) => {
@ -1226,11 +1332,11 @@ export function AgentGenerationWorkspaceDarkV2({
resultUrl,
resultText: resultMeta?.text || "",
generatedCount: latestReplacementResults.length,
isGenerating,
isGenerating: isDetailResultModeGenerating,
}),
};
}),
[detailReplacementReferenceCards, isGenerating, latestReplacementResults]
[detailReplacementReferenceCards, isDetailResultModeGenerating, latestReplacementResults]
);
const skuResultCards = useMemo(
() =>
@ -1247,11 +1353,11 @@ export function AgentGenerationWorkspaceDarkV2({
resultUrl,
resultText: resultMeta?.text || "",
generatedCount: latestReplacementResults.length,
isGenerating,
isGenerating: isSkuResultModeGenerating,
}),
};
}),
[isGenerating, latestReplacementResults, skuReplacementReferenceCards]
[isSkuResultModeGenerating, latestReplacementResults, skuReplacementReferenceCards]
);
const detailProcessedCount = useMemo(
() =>
@ -1268,27 +1374,48 @@ export function AgentGenerationWorkspaceDarkV2({
[skuResultCards]
);
const sceneRequestedImageCount = Math.max(1, imageParams.imageCount || 1);
const scenePendingCardCount =
activeResultModeType === currentResultModeType && !isReplacementPreset && isGenerating
? sceneRequestedImageCount
const scenePendingRemainingCount =
activeResultModeType === "scene_generation" &&
pendingGeneration?.modeType === "scene_generation"
? Math.max(pendingGeneration.expectedCount - currentSceneGeneratedUrls.length, 0)
: 0;
const scenePendingCardCount =
activeResultModeType === "scene_generation" ? scenePendingRemainingCount : 0;
const sceneResultCardCount = Math.max(
activeResultModeUrls.length + scenePendingCardCount,
activeResultModeType === currentResultModeType ? sceneRequestedImageCount : activeResultModeUrls.length,
activeResultModeType === "scene_generation" &&
pendingGeneration?.modeType === "scene_generation"
? activeResultModeUrls.length + scenePendingRemainingCount
: activeResultModeType === currentResultModeType
? sceneRequestedImageCount
: activeResultModeUrls.length,
1
);
const sceneResultCards = useMemo(
() =>
Array.from({ length: sceneResultCardCount }, (_, index) => ({
id: activeResultModeUrls[index] || `scene-result-placeholder-${activeResultModeType}-${index}`,
index,
url: activeResultModeUrls[index] || null,
})),
[activeResultModeType, activeResultModeUrls, sceneResultCardCount]
() => {
const placeholderStartIndex = activeResultModeUrls.length;
return Array.from({ length: sceneResultCardCount }, (_, index) => {
const url = activeResultModeUrls[index] || null;
const placeholderOffset = Math.max(index - placeholderStartIndex, 0);
return {
id: url || `scene-result-placeholder-${activeResultModeType}-${index}`,
index,
url,
status: url
? ("completed" as const)
: scenePendingRemainingCount > 0
? placeholderOffset === 0
? ("generating" as const)
: ("queued" as const)
: ("waiting" as const),
};
});
},
[activeResultModeType, activeResultModeUrls, scenePendingRemainingCount, sceneResultCardCount]
);
const shouldShowSceneResultGrid =
activeResultModeUrls.length > 0 ||
(activeResultModeType === currentResultModeType && !isReplacementPreset && isGenerating);
(activeResultModeType === "scene_generation" && isSceneResultModeGenerating);
const attachmentCount = attachments.filter(Boolean).length;
const hasBrandModelSelection = Boolean(
selectedBrandModel?.imageUrl || referenceImages.some((image) => image.source === "brandModel")
@ -1456,6 +1583,16 @@ export function AgentGenerationWorkspaceDarkV2({
setActiveResultModeType(currentResultModeType);
}, [currentResultModeType]);
useEffect(() => {
if (!pendingGeneration) {
return;
}
if (pendingGenerationProgressCount >= pendingGeneration.expectedCount) {
setPendingGeneration(null);
}
}, [pendingGeneration, pendingGenerationProgressCount]);
useEffect(() => {
onResultPreviewUrlsChange?.(
currentResultModeType,
@ -1731,26 +1868,47 @@ export function AgentGenerationWorkspaceDarkV2({
hiddenPromptRef.current = workflowContext?.promptText
? `【工作流设定】\n${workflowContext.promptText}\n\n请严格遵循以上工作流设定输出内容,并保持整体结果一致。`
: null;
sendMessage({ role: "user", parts });
setInput("");
setActiveResultModeType(currentResultModeType);
setSelectedResultUrl(null);
setPendingGeneration({
modeType: currentResultModeType,
expectedCount:
currentResultModeType === "scene_generation"
? Math.max(1, imageParams.imageCount || 1)
: currentResultModeType === "detail_replacement"
? detailReplacementReferenceCards.length
: skuReplacementReferenceCards.length,
userTurnCountAtStart: userMessageCount,
});
try {
sendMessage({ role: "user", parts });
setInput("");
} catch (error) {
setPendingGeneration(null);
toast.error(error instanceof Error ? error.message : "发送生成请求失败");
}
}, [
attachments,
detailReplacementReferenceCards.length,
currentResultModeType,
hasAgentUploadSlots,
imageParams.imageCount,
input,
isDetailReplacementPreset,
isReplacementPreset,
isSkuReplacementPreset,
requiredUploadCount,
selectedAgentPreset,
selectedAgentPresetId,
sendMessage,
setPendingGeneration,
skuReplacementReferenceCards.length,
userMessageCount,
workflowContext?.promptText,
workflowContext?.summaryText,
]);
const handleStopGeneration = useCallback(() => {
stop();
setPendingGeneration(null);
setMessages((current) => current);
toast.info("已停止当前生成任务");
}, [setMessages, stop]);
@ -2479,7 +2637,7 @@ export function AgentGenerationWorkspaceDarkV2({
<Button
onClick={handleSend}
disabled={
isGenerating ||
isAnyGenerationRunning ||
!selectedAgentPresetId ||
(hasAgentUploadSlots && !hasSatisfiedSlotRequirement)
}
@ -2492,10 +2650,15 @@ export function AgentGenerationWorkspaceDarkV2({
"disabled:opacity-30"
)}
>
{isGenerating ? (
{isAnyGenerationRunning ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{isReplacementPreset ? "批量替换中..." : "生成中..."}
{pendingGeneration?.modeType === currentResultModeType
? `${isReplacementPreset ? "批量处理中" : "生成中"} ${Math.min(
pendingGenerationProgressCount,
pendingGeneration.expectedCount
)}/${pendingGeneration.expectedCount}`
: "任务处理中..."}
</>
) : (
<>
@ -2504,7 +2667,7 @@ export function AgentGenerationWorkspaceDarkV2({
</>
)}
</Button>
{isGenerating ? (
{isAnyGenerationRunning ? (
<Button
type="button"
variant="outline"
@ -2850,6 +3013,12 @@ export function AgentGenerationWorkspaceDarkV2({
共 {activeResultModeUrls.length} 张
</div>
) : null}
{pendingGeneration?.modeType === activeResultModeType ? (
<div className="rounded-full border border-sky-400/25 bg-sky-500/[0.10] px-3 py-1 text-xs text-sky-100">
处理中 {Math.min(pendingGenerationProgressCount, pendingGeneration.expectedCount)}/
{pendingGeneration.expectedCount}
</div>
) : null}
</div>
</div>
@ -3081,20 +3250,16 @@ export function AgentGenerationWorkspaceDarkV2({
</div>
) : (
<div
className="flex aspect-[5/3] items-center justify-center bg-white/[0.02]"
className="aspect-[5/3]"
onClick={() => setActiveDetailReferenceCardId(card.id)}
>
<div className="space-y-2 px-4 text-center">
<div className={cn("mx-auto inline-flex rounded-full border px-2.5 py-1 text-[11px]", statusMeta.className)}>
{statusMeta.label}
</div>
<div className="text-sm font-medium text-white/70">
结果图 {card.index + 1}
</div>
<div className="text-xs text-white/35">
将按“输入图组 + 第 {card.index + 1} 张{detailReferenceSourceLabel}参考图”生成
</div>
</div>
<AnimatedGenerationPlaceholderCard
badge={statusMeta.label}
badgeClassName={statusMeta.className}
showSpinner={card.status === "generating"}
title={`结果图 ${card.index + 1}`}
description={`将按“输入图组 + 第 ${card.index + 1} 张${detailReferenceSourceLabel}参考图”生成`}
/>
</div>
)}
<div className="border-t border-white/10 px-3 py-2 text-[11px] text-white/45">
@ -3530,20 +3695,16 @@ export function AgentGenerationWorkspaceDarkV2({
</div>
) : (
<div
className="flex aspect-[5/3] items-center justify-center bg-white/[0.02]"
className="aspect-[5/3]"
onClick={() => setActiveSkuReferenceCardId(card.id)}
>
<div className="space-y-2 px-4 text-center">
<div className={cn("mx-auto inline-flex rounded-full border px-2.5 py-1 text-[11px]", statusMeta.className)}>
{statusMeta.label}
</div>
<div className="text-sm font-medium text-white/70">
结果图 {card.index + 1}
</div>
<div className="text-xs text-white/35">
将按“输入图 + 第 {card.index + 1} 张{skuReferenceSourceLabel}参考图”生成
</div>
</div>
<AnimatedGenerationPlaceholderCard
badge={statusMeta.label}
badgeClassName={statusMeta.className}
showSpinner={card.status === "generating"}
title={`结果图 ${card.index + 1}`}
description={`将按“输入图 + 第 ${card.index + 1} 张${skuReferenceSourceLabel}参考图”生成`}
/>
</div>
)}
<div className="border-t border-white/10 px-3 py-2 text-[11px] text-white/45">
@ -3628,13 +3789,24 @@ export function AgentGenerationWorkspaceDarkV2({
loading="lazy"
/>
) : (
<div
className={cn(
"h-full w-full bg-[linear-gradient(135deg,rgba(255,255,255,0.03),rgba(255,255,255,0.01))]",
activeResultModeType === currentResultModeType && isGenerating
? "animate-pulse"
: ""
)}
<AnimatedGenerationPlaceholderCard
badge={card.status === "generating" ? "生成中" : card.status === "queued" ? "排队中" : "待生成"}
badgeClassName={
card.status === "generating"
? "border-sky-400/30 bg-sky-500/[0.10] text-sky-100"
: card.status === "queued"
? "border-white/12 bg-white/[0.05] text-white/62"
: "border-white/10 bg-white/[0.04] text-white/58"
}
showSpinner={card.status === "generating"}
title={`${getResultModeLabel(activeResultModeType)} 结果图 ${card.index + 1}`}
description={
card.status === "generating"
? "当前图片正在生成中,结果返回后会自动替换这张占位卡。"
: card.status === "queued"
? "前面的结果完成后会继续处理这一张,请保持当前页面即可。"
: "开始生成后,这里会出现对应数量的结果预览卡片。"
}
/>
)}
</div>