diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index 17f899be9..fe1de3029 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -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(null); - const [agentProjectResultUrls, setAgentProjectResultUrls] = useState([]); + const [agentProjectResultCollections, setAgentProjectResultCollections] = + useState({}); 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} diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 659d4b40d..13fa8efd3 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -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(null); + const [activeResultModeType, setActiveResultModeType] = + useState("scene_generation"); const [fullscreenPreview, setFullscreenPreview] = useState(null); const [batchDownloadState, setBatchDownloadState] = useState(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; + }, + [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替换结果会按参考图顺序一对一展示,并保持在当前工作台预览,不会自动回写到无限画布。" - : "结果仅保留在当前生图工作台预览,不会自动回写到无限画布。"} + : "结果仅保留在当前生图工作台预览,不会自动回写到无限画布。"}
- {isDetailReplacementPreset && latestReplacementResultUrls.length > 0 ? ( + {availableResultModeTypes.map((modeType) => { + const isActive = activeResultModeType === modeType; + const count = (mergedResultCollections[modeType] || []).length; + return ( + + ); + })} + {activeResultModeType === "detail_replacement" && + mergedResultCollections.detail_replacement.length > 0 ? ( ) : null} - {isSkuReplacementPreset && latestReplacementResultUrls.length > 0 ? ( + {activeResultModeType === "sku_replacement" && + mergedResultCollections.sku_replacement.length > 0 ? ( ) : null} - {!isReplacementPreset && allGeneratedImages.length > 1 ? ( + {activeResultModeType === "scene_generation" && activeResultModeUrls.length > 1 ? ( ) : null} - {isDetailReplacementPreset ? ( + {activeResultModeType === "detail_replacement" && + isDetailReplacementPreset && + activeResultModeType === currentResultModeType ? (
预估 {detailReplacementReferenceCards.length} 屏
- ) : isSkuReplacementPreset ? ( + ) : activeResultModeType === "sku_replacement" && + isSkuReplacementPreset && + activeResultModeType === currentResultModeType ? (
预估 {skuReplacementReferenceCards.length} 组
- ) : allGeneratedImages.length > 0 ? ( + ) : activeResultModeUrls.length > 0 ? (
- 共 {allGeneratedImages.length} 张 + 共 {activeResultModeUrls.length} 张
) : null}
- {isDetailReplacementPreset ? ( + {isDetailReplacementPreset && activeResultModeType === currentResultModeType ? (
@@ -2997,7 +3127,7 @@ export function AgentGenerationWorkspaceDarkV2({
- ) : isSkuReplacementPreset ? ( + ) : isSkuReplacementPreset && activeResultModeType === currentResultModeType ? (
@@ -3446,51 +3576,17 @@ export function AgentGenerationWorkspaceDarkV2({
) : ( - <> - - {isGenerating ? ( - - - - ) : allGeneratedImages.length > 0 ? ( - -
-
- 单击卡片高亮,双击放大预览 -
- {selectedResultUrl ? ( - - ) : null} -
- -
+ + {shouldShowSceneResultGrid ? ( + +
+
{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({ >
{card.url ? ( - <> - {`结果图 - -
-
- 结果图 {card.index + 1} -
-
- 双击可放大查看 -
-
- + {`${getResultModeLabel(activeResultModeType)} ) : ( -
-
- -
-
- 结果图 {card.index + 1} -
-
- {isGenerating - ? "正在生成中,结果会按卡片顺序依次填充。" - : "开始生成后,这里会显示固定尺寸预览卡片。"} -
-
+
)}
); })}
- - ) : ( - -
- -
-

- {selectedAgentPresetId ? "准备开始生成" : "请选择生图模式"} -

-

- {selectedAgentPresetId - ? hasMaterialStep - ? "在“选图与生成”页完成搜图、槽位确认和参数设置后,即可开始生成" - : "在当前页补充要求并确认参数后,即可开始生成" - : "请先从顶部流程线选择一个生图模式"} -

-
- )} - - +
+
+ ) : ( + +
+ +
+

+ {selectedAgentPresetId ? `${getResultModeLabel(activeResultModeType)}结果暂为空` : "请选择生图模式"} +

+

+ {selectedAgentPresetId + ? "切换到对应生图模式并开始生成后,结果会保留在当前项目的对应分类中。" + : "请先从顶部流程线选择一个生图模式"} +

+
+ )} +
)}
diff --git a/lib/agents/generation-project-state.ts b/lib/agents/generation-project-state.ts index f15f40345..ff418f0e1 100644 --- a/lib/agents/generation-project-state.ts +++ b/lib/agents/generation-project-state.ts @@ -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) + : {}; + + 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, + }, }; } diff --git a/types/agent.ts b/types/agent.ts index 2fcefa5c1..0d07b31c1 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -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; @@ -337,6 +348,7 @@ export interface AgentGenerationProjectPersistedState { workflowState: AgentWorkflowPersistedState; workspaceState?: AgentGenerationProjectWorkspaceState; resultImageUrls?: string[]; + resultCollections?: AgentGenerationProjectResultCollections; } export interface AgentGenerationProject {