feat: organize agent results by generation mode
This commit is contained in:
parent
d2b1fd22d0
commit
0f32383d7d
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user