feat: organize agent results by generation mode
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 03:36:30 +08:00
parent d2b1fd22d0
commit 0f32383d7d
4 changed files with 345 additions and 162 deletions

View File

@ -108,6 +108,8 @@ import { normalizeAgentPresetModeConfig } from "@/lib/agents/preset-mode-config"
import { agentProjectService } from "@/services/agent-projects";
import type {
AgentPreset,
AgentGenerationProjectResultCollections,
AgentGenerationResultModeType,
AgentGenerationProjectWorkspaceState,
AgentModeWorkspaceState,
AgentWorkflowComposerContext,
@ -199,6 +201,47 @@ function areStringArraysEqual(left: string[], right: string[]) {
return true;
}
function normalizeResultCollectionUrls(urls: string[] | undefined | null) {
return Array.from(
new Set(
(urls || [])
.map((item) => String(item || "").trim())
.filter((item) => /^https?:\/\//i.test(item))
)
);
}
function areResultCollectionsEqual(
left: AgentGenerationProjectResultCollections,
right: AgentGenerationProjectResultCollections
) {
return (
areStringArraysEqual(
normalizeResultCollectionUrls(left.scene_generation),
normalizeResultCollectionUrls(right.scene_generation)
) &&
areStringArraysEqual(
normalizeResultCollectionUrls(left.detail_replacement),
normalizeResultCollectionUrls(right.detail_replacement)
) &&
areStringArraysEqual(
normalizeResultCollectionUrls(left.sku_replacement),
normalizeResultCollectionUrls(right.sku_replacement)
)
);
}
function getFirstResultUrlFromCollections(
collections: AgentGenerationProjectResultCollections
) {
return (
normalizeResultCollectionUrls(collections.scene_generation)[0] ||
normalizeResultCollectionUrls(collections.detail_replacement)[0] ||
normalizeResultCollectionUrls(collections.sku_replacement)[0] ||
null
);
}
export default function CanvasEditorPage({ params }: PageProps) {
// 使用 useParams 确保客户端导航时能正确获取更新的 id
const routeParams = useParams();
@ -276,7 +319,8 @@ export default function CanvasEditorPage({ params }: PageProps) {
);
const [agentProjectTitle, setAgentProjectTitle] = useState("未命名生图项目");
const [agentProjectThumbnail, setAgentProjectThumbnail] = useState<string | null>(null);
const [agentProjectResultUrls, setAgentProjectResultUrls] = useState<string[]>([]);
const [agentProjectResultCollections, setAgentProjectResultCollections] =
useState<AgentGenerationProjectResultCollections>({});
const [isAgentProjectLoading, setIsAgentProjectLoading] = useState(false);
const [isAgentProjectSaving, setIsAgentProjectSaving] = useState(false);
const [isAgentProjectSheetOpen, setIsAgentProjectSheetOpen] = useState(false);
@ -451,7 +495,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
setAgentWorkspaceState(createEmptyAgentGenerationProjectWorkspaceState());
setAgentProjectTitle("未命名生图项目");
setAgentProjectThumbnail(null);
setAgentProjectResultUrls([]);
setAgentProjectResultCollections({});
// 🆕 先清空当前画布状态,确保切换时不会显示旧数据
useCanvasStore.getState().clearCanvas();
@ -590,11 +634,28 @@ export default function CanvasEditorPage({ params }: PageProps) {
return {
workflowState,
workspaceState: agentWorkspaceState,
resultImageUrls: agentProjectResultUrls,
resultCollections: {
scene_generation: normalizeResultCollectionUrls(
agentProjectResultCollections.scene_generation
),
detail_replacement: normalizeResultCollectionUrls(
agentProjectResultCollections.detail_replacement
),
sku_replacement: normalizeResultCollectionUrls(
agentProjectResultCollections.sku_replacement
),
},
resultImageUrls: Array.from(
new Set([
...normalizeResultCollectionUrls(agentProjectResultCollections.scene_generation),
...normalizeResultCollectionUrls(agentProjectResultCollections.detail_replacement),
...normalizeResultCollectionUrls(agentProjectResultCollections.sku_replacement),
])
),
};
}, [
agentModeState,
agentProjectResultUrls,
agentProjectResultCollections,
agentWorkspaceState,
currentAgentStage,
selectedAgentPresetId,
@ -809,7 +870,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
);
setAgentProjectTitle(result.data.title || "未命名生图项目");
setAgentProjectThumbnail(result.data.thumbnail || null);
setAgentProjectResultUrls(persistedState.resultImageUrls || []);
setAgentProjectResultCollections(persistedState.resultCollections || {});
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载生图项目失败");
@ -843,7 +904,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
}, [
agentModeState,
agentProjectId,
agentProjectResultUrls,
agentProjectResultCollections,
agentProjectThumbnail,
agentProjectTitle,
agentWorkspaceState,
@ -996,15 +1057,28 @@ export default function CanvasEditorPage({ params }: PageProps) {
[agentProjectId, updateCanvasSearchParams]
);
const handleAgentResultPreviewUrlsChange = useCallback((urls: string[]) => {
setAgentProjectResultUrls((current) =>
areStringArraysEqual(current, urls) ? current : urls
);
setAgentProjectThumbnail((current) => {
const nextThumbnail = urls[0] || null;
return current === nextThumbnail ? current : nextThumbnail;
});
}, []);
const handleAgentResultPreviewUrlsChange = useCallback(
(modeType: AgentGenerationResultModeType, urls: string[]) => {
const normalizedUrls = normalizeResultCollectionUrls(urls);
setAgentProjectResultCollections((current) => {
const nextCollections: AgentGenerationProjectResultCollections = {
...current,
[modeType]: normalizedUrls,
};
return areResultCollectionsEqual(current, nextCollections) ? current : nextCollections;
});
setAgentProjectThumbnail((current) => {
const nextThumbnail =
normalizedUrls[0] ||
getFirstResultUrlFromCollections({
...agentProjectResultCollections,
[modeType]: normalizedUrls,
});
return current === nextThumbnail ? current : nextThumbnail;
});
},
[agentProjectResultCollections]
);
useEffect(() => {
const normalizedBrand = workflowInputs.brand.trim().toLowerCase();
@ -1648,6 +1722,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
agentModeState={agentModeState}
onAgentModeStateChange={setAgentModeState}
workspaceState={agentWorkspaceState}
resultCollections={agentProjectResultCollections}
onWorkspaceStateChange={setAgentWorkspaceState}
onResultPreviewUrlsChange={handleAgentResultPreviewUrlsChange}
siteName={siteSettingsData?.siteName}

View File

@ -37,7 +37,6 @@ import {
SelectValue,
} from "@/components/ui/select";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { ImageGenerationLoading } from "@/components/image-generation-loading";
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog";
import { useCanvasStore } from "@/store/canvas";
@ -53,6 +52,8 @@ import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils";
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
import type {
AgentGenerationProjectWorkspaceState,
AgentGenerationProjectResultCollections,
AgentGenerationResultModeType,
AgentModeReferenceCandidate,
AgentModeWorkspaceState,
AgentPreset,
@ -91,7 +92,11 @@ interface AgentGenerationWorkspaceDarkV2Props {
onOpenCanvasEditor?: () => void;
workspaceState?: AgentGenerationProjectWorkspaceState | null;
onWorkspaceStateChange?: (next: AgentGenerationProjectWorkspaceState) => void;
onResultPreviewUrlsChange?: (urls: string[]) => void;
resultCollections?: AgentGenerationProjectResultCollections | null;
onResultPreviewUrlsChange?: (
modeType: AgentGenerationResultModeType,
urls: string[]
) => void;
topNavRightSlot?: ReactNode;
showcaseConfig?: AgentWorkflowShowcaseConfig | null;
workflowStylePanel?: ReactNode;
@ -130,6 +135,24 @@ function getTaskDetailReferenceSourceLabel(source: AgentTaskDetailReferenceSourc
return source === "main" ? "主图" : source === "sku" ? "SKU图" : "详情图";
}
function normalizeResultCollectionUrls(urls?: string[] | null) {
return Array.from(
new Set(
(urls || [])
.map((item) => String(item || "").trim())
.filter((item) => /^https?:\/\//i.test(item))
)
);
}
function getResultModeLabel(modeType: AgentGenerationResultModeType) {
return modeType === "detail_replacement"
? "详情替换"
: modeType === "sku_replacement"
? "SKU替换"
: "场景图";
}
function areImageGenerationParamsEqual(
left: ImageGenerationParams,
right: ImageGenerationParams
@ -514,6 +537,7 @@ export function AgentGenerationWorkspaceDarkV2({
onAgentModeStateChange,
onOpenCanvasEditor,
workspaceState = null,
resultCollections = null,
onWorkspaceStateChange,
onResultPreviewUrlsChange,
topNavRightSlot,
@ -568,6 +592,8 @@ export function AgentGenerationWorkspaceDarkV2({
normalizedIncomingWorkspaceState.selectedModelId || "auto"
);
const [selectedResultUrl, setSelectedResultUrl] = useState<string | null>(null);
const [activeResultModeType, setActiveResultModeType] =
useState<AgentGenerationResultModeType>("scene_generation");
const [fullscreenPreview, setFullscreenPreview] = useState<string | null>(null);
const [batchDownloadState, setBatchDownloadState] = useState<null | "scene" | "detail" | "sku">(null);
const [detailReplacementSearchState, setDetailReplacementSearchState] = useState<{
@ -638,6 +664,11 @@ export function AgentGenerationWorkspaceDarkV2({
const isSkuReplacementPreset =
selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "sku_replacement";
const isReplacementPreset = isDetailReplacementPreset || isSkuReplacementPreset;
const currentResultModeType: AgentGenerationResultModeType = isDetailReplacementPreset
? "detail_replacement"
: isSkuReplacementPreset
? "sku_replacement"
: "scene_generation";
const productSlotCount =
selectedAgentPreset?.type === "image"
? isSceneGenerationPreset
@ -1069,10 +1100,29 @@ export function AgentGenerationWorkspaceDarkV2({
.filter((group) => group.imageUrls.length > 0);
}, [messages]);
const generatedGroupsAfterLastUser = useMemo(() => {
const lastUserIndex = [...messages]
.map((message) => message.role)
.lastIndexOf("user");
if (lastUserIndex < 0) {
return [] as GeneratedImageGroup[];
}
return messages
.slice(lastUserIndex + 1)
.filter((message) => message.role === "assistant")
.map((message) => generatedGroups.find((group) => group.messageId === message.id) || null)
.filter((group): group is GeneratedImageGroup => Boolean(group));
}, [generatedGroups, messages]);
const allGeneratedImages = useMemo(
() => Array.from(new Set(generatedGroups.flatMap((group) => group.imageUrls))),
[generatedGroups]
);
const currentSceneGeneratedUrls = useMemo(
() => Array.from(new Set(generatedGroupsAfterLastUser.flatMap((group) => group.imageUrls))),
[generatedGroupsAfterLastUser]
);
const replacementGeneratedGroupsAfterLastUser = useMemo(() => {
const lastUserIndex = [...messages]
.map((message) => message.role)
@ -1125,7 +1175,41 @@ export function AgentGenerationWorkspaceDarkV2({
.filter((url): url is string => Boolean(url)),
[latestReplacementResults]
);
const displayedResultUrl = selectedResultUrl || latestGeneratedGroup?.imageUrls[0] || null;
const currentLiveResultUrls = isReplacementPreset
? latestReplacementResultUrls
: currentSceneGeneratedUrls;
const mergedResultCollections = useMemo(
() => {
const baseCollections = {
scene_generation: normalizeResultCollectionUrls(resultCollections?.scene_generation),
detail_replacement: normalizeResultCollectionUrls(resultCollections?.detail_replacement),
sku_replacement: normalizeResultCollectionUrls(resultCollections?.sku_replacement),
};
return {
...baseCollections,
[currentResultModeType]: Array.from(
new Set([
...baseCollections[currentResultModeType],
...normalizeResultCollectionUrls(currentLiveResultUrls),
])
),
} as Record<AgentGenerationResultModeType, string[]>;
},
[currentLiveResultUrls, currentResultModeType, resultCollections]
);
const availableResultModeTypes = useMemo(
() =>
(["scene_generation", "detail_replacement", "sku_replacement"] as AgentGenerationResultModeType[])
.filter(
(modeType) =>
modeType === currentResultModeType ||
(mergedResultCollections[modeType] || []).length > 0
),
[currentResultModeType, mergedResultCollections]
);
const activeResultModeUrls = mergedResultCollections[activeResultModeType] || [];
const displayedResultUrl = selectedResultUrl || activeResultModeUrls[0] || null;
const isGenerating = status === "streaming" || status === "submitted";
const detailResultCards = useMemo(
() =>
@ -1183,20 +1267,28 @@ export function AgentGenerationWorkspaceDarkV2({
).length,
[skuResultCards]
);
const sceneRequestedImageCount = Math.max(1, imageParams.imageCount || 1);
const scenePendingCardCount =
activeResultModeType === currentResultModeType && !isReplacementPreset && isGenerating
? sceneRequestedImageCount
: 0;
const sceneResultCardCount = Math.max(
allGeneratedImages.length,
imageParams.imageCount || 1,
activeResultModeUrls.length + scenePendingCardCount,
activeResultModeType === currentResultModeType ? sceneRequestedImageCount : activeResultModeUrls.length,
1
);
const sceneResultCards = useMemo(
() =>
Array.from({ length: sceneResultCardCount }, (_, index) => ({
id: allGeneratedImages[index] || `scene-result-placeholder-${index}`,
id: activeResultModeUrls[index] || `scene-result-placeholder-${activeResultModeType}-${index}`,
index,
url: allGeneratedImages[index] || null,
url: activeResultModeUrls[index] || null,
})),
[allGeneratedImages, sceneResultCardCount]
[activeResultModeType, activeResultModeUrls, sceneResultCardCount]
);
const shouldShowSceneResultGrid =
activeResultModeUrls.length > 0 ||
(activeResultModeType === currentResultModeType && !isReplacementPreset && isGenerating);
const attachmentCount = attachments.filter(Boolean).length;
const hasBrandModelSelection = Boolean(
selectedBrandModel?.imageUrl || referenceImages.some((image) => image.source === "brandModel")
@ -1361,16 +1453,28 @@ export function AgentGenerationWorkspaceDarkV2({
]);
useEffect(() => {
onResultPreviewUrlsChange?.(
Array.from(new Set(isReplacementPreset ? latestReplacementResultUrls : allGeneratedImages))
);
}, [allGeneratedImages, isReplacementPreset, latestReplacementResultUrls, onResultPreviewUrlsChange]);
setActiveResultModeType(currentResultModeType);
}, [currentResultModeType]);
useEffect(() => {
if (!selectedResultUrl && latestGeneratedGroup?.imageUrls[0]) {
setSelectedResultUrl(latestGeneratedGroup.imageUrls[0]);
onResultPreviewUrlsChange?.(
currentResultModeType,
mergedResultCollections[currentResultModeType] || []
);
}, [currentResultModeType, mergedResultCollections, onResultPreviewUrlsChange]);
useEffect(() => {
if (activeResultModeUrls.length === 0) {
if (selectedResultUrl) {
setSelectedResultUrl(null);
}
return;
}
}, [latestGeneratedGroup?.imageUrls, selectedResultUrl]);
if (!selectedResultUrl || !activeResultModeUrls.includes(selectedResultUrl)) {
setSelectedResultUrl(activeResultModeUrls[0] || null);
}
}, [activeResultModeUrls, selectedResultUrl]);
useEffect(() => {
if (!selectedAgentPresetId || !hasAgentUploadSlots) {
@ -2654,16 +2758,37 @@ export function AgentGenerationWorkspaceDarkV2({
? "详情替换结果会按参考图顺序纵向展示,并保持在当前工作台预览,不会自动回写到无限画布。"
: isSkuReplacementPreset
? "SKU替换结果会按参考图顺序一对一展示,并保持在当前工作台预览,不会自动回写到无限画布。"
: "结果仅保留在当前生图工作台预览,不会自动回写到无限画布。"}
: "结果仅保留在当前生图工作台预览,不会自动回写到无限画布。"}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{isDetailReplacementPreset && latestReplacementResultUrls.length > 0 ? (
{availableResultModeTypes.map((modeType) => {
const isActive = activeResultModeType === modeType;
const count = (mergedResultCollections[modeType] || []).length;
return (
<button
key={modeType}
type="button"
onClick={() => setActiveResultModeType(modeType)}
className={cn(
"rounded-full border px-3 py-1 text-xs transition-colors",
isActive
? "border-emerald-400/30 bg-emerald-500/[0.10] text-emerald-100"
: "border-white/10 bg-white/[0.03] text-white/55 hover:bg-white/[0.06] hover:text-white/78"
)}
>
{getResultModeLabel(modeType)}
{count > 0 ? ` · ${count}` : ""}
</button>
);
})}
{activeResultModeType === "detail_replacement" &&
mergedResultCollections.detail_replacement.length > 0 ? (
<button
type="button"
onClick={() =>
void handleBatchDownloadImages({
imageUrls: latestReplacementResultUrls,
imageUrls: mergedResultCollections.detail_replacement,
zipName: `${selectedAgentPreset?.name || "detail-replacement"}-results`,
scope: "detail",
})
@ -2674,12 +2799,13 @@ export function AgentGenerationWorkspaceDarkV2({
{batchDownloadState === "detail" ? "打包中..." : "批量下载"}
</button>
) : null}
{isSkuReplacementPreset && latestReplacementResultUrls.length > 0 ? (
{activeResultModeType === "sku_replacement" &&
mergedResultCollections.sku_replacement.length > 0 ? (
<button
type="button"
onClick={() =>
void handleBatchDownloadImages({
imageUrls: latestReplacementResultUrls,
imageUrls: mergedResultCollections.sku_replacement,
zipName: `${selectedAgentPreset?.name || "sku-replacement"}-results`,
scope: "sku",
})
@ -2690,12 +2816,12 @@ export function AgentGenerationWorkspaceDarkV2({
{batchDownloadState === "sku" ? "打包中..." : "批量下载"}
</button>
) : null}
{!isReplacementPreset && allGeneratedImages.length > 1 ? (
{activeResultModeType === "scene_generation" && activeResultModeUrls.length > 1 ? (
<button
type="button"
onClick={() =>
void handleBatchDownloadImages({
imageUrls: allGeneratedImages,
imageUrls: activeResultModeUrls,
zipName: `${selectedAgentPreset?.name || "scene-generation"}-results`,
scope: "scene",
})
@ -2707,23 +2833,27 @@ export function AgentGenerationWorkspaceDarkV2({
</button>
) : null}
{isDetailReplacementPreset ? (
{activeResultModeType === "detail_replacement" &&
isDetailReplacementPreset &&
activeResultModeType === currentResultModeType ? (
<div className="rounded-full bg-white/8 px-3 py-1 text-xs text-white/60">
预估 {detailReplacementReferenceCards.length} 屏
</div>
) : isSkuReplacementPreset ? (
) : activeResultModeType === "sku_replacement" &&
isSkuReplacementPreset &&
activeResultModeType === currentResultModeType ? (
<div className="rounded-full bg-white/8 px-3 py-1 text-xs text-white/60">
预估 {skuReplacementReferenceCards.length} 组
</div>
) : allGeneratedImages.length > 0 ? (
) : activeResultModeUrls.length > 0 ? (
<div className="rounded-full bg-white/8 px-3 py-1 text-xs text-white/60">
共 {allGeneratedImages.length} 张
共 {activeResultModeUrls.length} 张
</div>
) : null}
</div>
</div>
{isDetailReplacementPreset ? (
{isDetailReplacementPreset && activeResultModeType === currentResultModeType ? (
<div className="grid flex-1 min-h-0 grid-cols-1 gap-0 xl:grid-cols-[320px_repeat(2,minmax(0,1fr))]">
<div className="border-b border-white/10 p-4 xl:border-b-0 xl:border-r">
<div className="mb-3 flex items-center justify-between">
@ -2997,7 +3127,7 @@ export function AgentGenerationWorkspaceDarkV2({
</div>
</div>
</div>
) : isSkuReplacementPreset ? (
) : isSkuReplacementPreset && activeResultModeType === currentResultModeType ? (
<div className="grid flex-1 min-h-0 grid-cols-1 gap-0 xl:grid-cols-[320px_minmax(0,1fr)_360px]">
<div className="border-b border-white/10 p-4 xl:border-b-0 xl:border-r">
<div className="mb-3 flex items-center justify-between">
@ -3446,51 +3576,17 @@ export function AgentGenerationWorkspaceDarkV2({
</div>
</div>
) : (
<>
<AnimatePresence mode="wait">
{isGenerating ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-1 items-center justify-center"
>
<ImageGenerationLoading
imageCount={imageParams.imageCount || 1}
maxImages={6}
providerType={currentModel?.providerType}
modelName={currentModel?.displayName}
phase="processing"
/>
</motion.div>
) : allGeneratedImages.length > 0 ? (
<motion.div
key="result"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="flex flex-1 flex-col p-5"
>
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-white/45">
单击卡片高亮,双击放大预览
</div>
{selectedResultUrl ? (
<button
type="button"
onClick={() => {
void handleDownloadOriginalImage(selectedResultUrl);
}}
className="flex h-9 items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 text-sm text-white transition-colors hover:border-white/20 hover:bg-white/[0.08]"
>
<Download className="h-4 w-4" />
下载当前
</button>
) : null}
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<AnimatePresence mode="wait">
{shouldShowSceneResultGrid ? (
<motion.div
key={`result-${activeResultModeType}`}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
className="flex flex-1 flex-col px-6 pb-6 pt-5 md:px-8"
>
<div className="mx-auto w-full max-w-[1200px]">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{sceneResultCards.map((card) => {
const isActive = card.url && displayedResultUrl === card.url;
@ -3500,29 +3596,23 @@ export function AgentGenerationWorkspaceDarkV2({
role={card.url ? "button" : undefined}
tabIndex={card.url ? 0 : -1}
onClick={() => {
if (!card.url) {
return;
}
if (!card.url) return;
setSelectedResultUrl(card.url);
}}
onDoubleClick={() => {
if (!card.url) {
return;
}
if (!card.url) return;
setSelectedResultUrl(card.url);
setFullscreenPreview(card.url);
}}
onKeyDown={(event) => {
if (!card.url) {
return;
}
if (!card.url) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedResultUrl(card.url);
}
}}
className={cn(
"group overflow-hidden rounded-[22px] border text-left transition-all",
"overflow-hidden rounded-[22px] border transition-all",
card.url ? "cursor-pointer" : "cursor-default",
isActive
? "border-emerald-400/55 bg-emerald-500/[0.08] ring-1 ring-emerald-400/35"
@ -3531,77 +3621,50 @@ export function AgentGenerationWorkspaceDarkV2({
>
<div className="relative aspect-[4/5] overflow-hidden bg-[linear-gradient(180deg,#090b0c_0%,#111417_100%)]">
{card.url ? (
<>
<img
src={buildPreviewImageUrl(card.url, 480)}
alt={`结果图 ${card.index + 1}`}
className="h-full w-full object-contain p-2"
/>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void handleDownloadOriginalImage(card.url!);
}}
className="absolute right-3 top-3 rounded-full border border-white/10 bg-black/55 px-2.5 py-1 text-[11px] text-white/78 transition-colors hover:border-white/20 hover:text-white"
>
下载
</button>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/35 to-transparent px-3 py-3">
<div className="text-sm font-medium text-white/88">
结果图 {card.index + 1}
</div>
<div className="text-[11px] text-white/45">
双击可放大查看
</div>
</div>
</>
<img
src={buildPreviewImageUrl(card.url, 480)}
alt={`${getResultModeLabel(activeResultModeType)} 结果图 ${card.index + 1}`}
className="h-full w-full object-contain p-2"
/>
) : (
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5">
<Wand2 className="h-6 w-6 text-white/20" />
</div>
<div className="text-sm font-medium text-white/60">
结果图 {card.index + 1}
</div>
<div className="mt-2 text-xs leading-5 text-white/32">
{isGenerating
? "正在生成中,结果会按卡片顺序依次填充。"
: "开始生成后,这里会显示固定尺寸预览卡片。"}
</div>
</div>
<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"
: ""
)}
/>
)}
</div>
</div>
);
})}
</div>
</motion.div>
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-1 flex-col items-center justify-center px-6 text-center"
>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
<Wand2 className="h-8 w-8 text-white/20" />
</div>
<p className="text-base font-medium text-white/60">
{selectedAgentPresetId ? "准备开始生成" : "请选择生图模式"}
</p>
<p className="mt-2 text-sm text-white/30">
{selectedAgentPresetId
? hasMaterialStep
? "在“选图与生成”页完成搜图、槽位确认和参数设置后,即可开始生成"
: "在当前页补充要求并确认参数后,即可开始生成"
: "请先从顶部流程线选择一个生图模式"}
</p>
</motion.div>
)}
</AnimatePresence>
</>
</div>
</motion.div>
) : (
<motion.div
key={`empty-${activeResultModeType}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-1 flex-col items-center justify-center px-6 text-center"
>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
<Wand2 className="h-8 w-8 text-white/20" />
</div>
<p className="text-base font-medium text-white/60">
{selectedAgentPresetId ? `${getResultModeLabel(activeResultModeType)}结果暂为空` : "请选择生图模式"}
</p>
<p className="mt-2 text-sm text-white/30">
{selectedAgentPresetId
? "切换到对应生图模式并开始生成后,结果会保留在当前项目的对应分类中。"
: "请先从顶部流程线选择一个生图模式"}
</p>
</motion.div>
)}
</AnimatePresence>
)}
</motion.section>
</div>

View File

@ -8,6 +8,7 @@ import {
} from "@/lib/agents/workflow-material-config";
import type {
AgentGenerationProjectPersistedState,
AgentGenerationProjectResultCollections,
AgentGenerationProjectWorkspaceState,
AgentWorkflowPersistedState,
} from "@/types/agent";
@ -48,6 +49,31 @@ export function createEmptyAgentGenerationProjectPersistedState(): AgentGenerati
workflowState: createEmptyAgentWorkflowPersistedState(),
workspaceState: createEmptyAgentGenerationProjectWorkspaceState(),
resultImageUrls: [],
resultCollections: {},
};
}
function normalizeResultCollections(value: unknown): AgentGenerationProjectResultCollections {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const normalizeUrlList = (input: unknown) =>
Array.isArray(input)
? Array.from(
new Set(
input
.map((item) => String(item || "").trim())
.filter((item) => /^https?:\/\//i.test(item))
)
).slice(0, 128)
: [];
return {
scene_generation: normalizeUrlList(raw.scene_generation),
detail_replacement: normalizeUrlList(raw.detail_replacement),
sku_replacement: normalizeUrlList(raw.sku_replacement),
};
}
@ -152,10 +178,17 @@ export function normalizeAgentGenerationProjectPersistedState(
)
).slice(0, 64)
: [];
const resultCollections = normalizeResultCollections(raw.resultCollections);
return {
workflowState: normalizeWorkflowState(raw.workflowState),
workspaceState: normalizeWorkspaceState(raw.workspaceState),
resultImageUrls,
resultCollections: {
...resultCollections,
scene_generation: resultCollections.scene_generation?.length
? resultCollections.scene_generation
: resultImageUrls,
},
};
}

View File

@ -310,6 +310,17 @@ export interface AgentGenerationProjectImageParams {
imageCount?: number;
}
export type AgentGenerationResultModeType =
| "scene_generation"
| "detail_replacement"
| "sku_replacement";
export interface AgentGenerationProjectResultCollections {
scene_generation?: string[];
detail_replacement?: string[];
sku_replacement?: string[];
}
export interface AgentGenerationProjectWorkspaceState {
input: string;
attachments: Array<AgentGenerationProjectWorkspaceAttachment | null>;
@ -337,6 +348,7 @@ export interface AgentGenerationProjectPersistedState {
workflowState: AgentWorkflowPersistedState;
workspaceState?: AgentGenerationProjectWorkspaceState;
resultImageUrls?: string[];
resultCollections?: AgentGenerationProjectResultCollections;
}
export interface AgentGenerationProject {