ai-chatbot/components/agents/AgentGenerationWorkspaceDarkV2.tsx
Your Name 7ebfcfd4eb
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: refine agent workflow settings and studio ui
2026-03-19 00:34:09 +08:00

2581 lines
115 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import useSWR from "swr";
import { motion, AnimatePresence } from "framer-motion";
import JSZip from "jszip";
import {
ArrowUp,
Download,
ImagePlus,
Loader2,
Sparkles,
Upload,
UserRound,
X,
Wand2,
Settings2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { ImageGenerationLoading } from "@/components/image-generation-loading";
import { useCanvasStore } from "@/store/canvas";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { extractImageUrlsFromText } from "@/lib/image-url";
import {
getAgentRequiredUploadCount,
normalizeAgentPresetModeConfig,
} from "@/lib/agents/preset-mode-config";
import type { Attachment, ChatMessage } from "@/lib/types";
import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils";
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
import type {
AgentModeReferenceCandidate,
AgentModeWorkspaceState,
AgentPreset,
AgentWorkflowComposerContext,
AgentWorkflowMaterialQueryType,
AgentWorkflowSelectedBrandModel,
} from "@/types/agent";
import {
createEmptyAgentModeWorkspaceState,
} from "@/lib/agents/mode-workspace-state";
interface AgentGenerationWorkspaceDarkV2Props {
chatId: string;
projectId: string;
selectedAgentPresetId?: string | null;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
workflowContext?: AgentWorkflowComposerContext | null;
onBack?: () => void;
presetPanel?: ReactNode;
materialSelectionPanel?: ReactNode;
brandModelPanel?: ReactNode;
onClearPreset?: () => void;
siteName?: string | null;
siteLogo?: string | null;
siteLogoDark?: string | null;
syncGeneratedResultsToCanvas?: boolean;
agentModeState?: AgentModeWorkspaceState | null;
onAgentModeStateChange?: (next: AgentModeWorkspaceState) => void;
onOpenCanvasEditor?: () => void;
}
interface PickedAttachment extends Attachment {}
interface GeneratedImageGroup {
messageId: string;
imageUrls: string[];
text: string;
}
interface AgentModeReferenceSearchResponse {
success?: boolean;
data?: {
query: string;
queryType: AgentWorkflowMaterialQueryType;
target: "detail" | "sku";
matchedSpu?: string | null;
matchedSku?: string | null;
categoryName?: string | null;
shopName?: string | null;
candidateCount: number;
candidates: AgentModeReferenceCandidate[];
selectedCandidateId?: string | null;
references: AgentModeWorkspaceState["detailReplacement"]["references"];
};
error?: {
code?: string;
message?: string;
};
}
type ToolStepKey =
| "preset"
| "brand-model"
| "materials"
| "slots"
| "prompt"
| "settings";
function buildPreviewImageUrl(url: string, width: number) {
return buildImageProxyUrl(url, { width, quality: 86, format: "webp" });
}
function buildDownloadFilename(url: string): string {
const fallback = `canvas-image-${Date.now()}.png`;
const raw = String(url || "").trim();
if (!raw) return fallback;
try {
const pathname = new URL(raw).pathname || "";
const candidate = pathname.split("/").pop() || "";
return candidate || fallback;
} catch {
const candidate = raw.split("/").pop()?.split("?")[0] || "";
return candidate || fallback;
}
}
function normalizeAssistantText(value: string) {
return String(value || "")
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
.replace(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g, "")
.trim();
}
function getImageDimensions(url: string): Promise<{
width: number;
height: number;
originalWidth: number;
originalHeight: number;
}> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const maxSize = 500;
const originalWidth = img.naturalWidth;
const originalHeight = img.naturalHeight;
let width = originalWidth;
let height = originalHeight;
if (width > maxSize || height > maxSize) {
const scale = Math.min(maxSize / width, maxSize / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const minSize = 150;
if (width < minSize && height < minSize) {
const scale = minSize / Math.min(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
resolve({ width, height, originalWidth, originalHeight });
};
img.onerror = () => {
resolve({ width: 300, height: 300, originalWidth: 0, originalHeight: 0 });
};
img.src = url;
});
}
function buildReferenceCardsFromCandidate(params: {
candidate: AgentModeReferenceCandidate | null | undefined;
target: "detail" | "sku";
productCount?: number;
}) {
const candidate = params.candidate;
if (!candidate) return [] as AgentModeWorkspaceState["detailReplacement"]["references"];
const urls =
params.target === "detail" ? candidate.detailImageUrls || [] : candidate.skuImageUrls || [];
const targetLabel = params.target === "detail" ? "详情参考图" : "SKU参考图";
const prefix =
candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套";
return urls.map((url, index) => ({
id: `${candidate.id}-${params.target}-${index + 1}`,
label: `${prefix} ${targetLabel} ${String(index + 1).padStart(2, "0")}`,
imageUrl: url,
candidateId: candidate.id,
sortOrder: index,
assignedProductIndex:
params.target === "sku" && (params.productCount || 0) > 0
? Math.min(index, Math.max((params.productCount || 1) - 1, 0))
: null,
}));
}
function formatCandidateMeta(candidate: AgentModeReferenceCandidate, target: "detail" | "sku") {
const imageCount =
target === "detail"
? candidate.detailImageUrls.length
: candidate.skuImageUrls.length;
const uploadedAt = candidate.uploadedAt ? new Date(candidate.uploadedAt) : null;
const timeText =
uploadedAt && !Number.isNaN(uploadedAt.getTime())
? uploadedAt.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: null;
const parts = [
candidate.sceneName || null,
candidate.taskNo || null,
imageCount > 0 ? `${imageCount}张` : null,
timeText,
].filter(Boolean);
return parts.join(" · ");
}
export function AgentGenerationWorkspaceDarkV2({
chatId,
projectId,
selectedAgentPresetId,
selectedBrandModel = null,
workflowContext = null,
onBack,
presetPanel,
materialSelectionPanel,
brandModelPanel,
onClearPreset,
siteName,
siteLogo,
siteLogoDark,
syncGeneratedResultsToCanvas = true,
agentModeState = null,
onAgentModeStateChange,
onOpenCanvasEditor,
}: AgentGenerationWorkspaceDarkV2Props) {
const rawChatId = useMemo(
() => (chatId.startsWith("canvas-") ? chatId.replace("canvas-", "") : chatId),
[chatId]
);
const addNode = useCanvasStore((state) => state.addNode);
const nodes = useCanvasStore((state) => state.nodes);
const selectNodes = useCanvasStore((state) => state.selectNodes);
const requestImageNodeAction = useCanvasStore((state) => state.requestImageNodeAction);
const fileInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const hiddenPromptRef = useRef<string | null>(null);
const generatedImageKeysRef = useRef<Set<string>>(new Set());
const lastSyncedBrandModelUrlRef = useRef<string | null>(null);
const [localAgentModeState, setLocalAgentModeState] = useState<AgentModeWorkspaceState>(
() => createEmptyAgentModeWorkspaceState()
);
const [input, setInput] = useState("");
const [attachments, setAttachments] = useState<Array<PickedAttachment | null>>([]);
const [activeAssignmentSlotIndex, setActiveAssignmentSlotIndex] = useState<number | null>(null);
const [slotUploading, setSlotUploading] = useState<number | null>(null);
const [selectedModelId, setSelectedModelId] = useState<string>("auto");
const [selectedResultUrl, setSelectedResultUrl] = useState<string | null>(null);
const [fullscreenPreview, setFullscreenPreview] = useState<string | null>(null);
const [batchDownloadState, setBatchDownloadState] = useState<null | "scene" | "detail" | "sku">(null);
const [detailReplacementSearchState, setDetailReplacementSearchState] = useState<{
isLoading: boolean;
lastQuery: string | null;
}>({
isLoading: false,
lastQuery: null,
});
const [skuReplacementSearchState, setSkuReplacementSearchState] = useState<{
isLoading: boolean;
lastQuery: string | null;
}>({
isLoading: false,
lastQuery: null,
});
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
});
const { data: historyData } = useSWR<{ messages: ChatMessage[] }>(
rawChatId ? `/api/messages?chatId=${rawChatId}` : null,
fetcher
);
const { data: modelsData } = useSWR<{
models: Array<{
id: string;
displayName: string;
providerType: string;
supportsImageGen?: boolean;
isMultimodal?: boolean;
imageGenConfig?: any;
}>;
defaultModelId?: string | null;
agentModel?: any;
}>("/api/models", fetcher);
const { data: agentPresetResponse } = useSWR<{
success?: boolean;
data?: AgentPreset;
}>(selectedAgentPresetId ? `/api/agents/${selectedAgentPresetId}` : null, fetcher);
const selectedAgentPreset = useMemo(
() => (agentPresetResponse?.success && agentPresetResponse.data ? agentPresetResponse.data : null),
[agentPresetResponse]
);
const historyMessages = useMemo(() => historyData?.messages || [], [historyData?.messages]);
const referenceImages = workflowContext?.referenceImages || [];
const presetModeConfig = useMemo(
() => normalizeAgentPresetModeConfig(selectedAgentPreset?.defaultParams),
[selectedAgentPreset?.defaultParams]
);
const isSceneGenerationPreset =
selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "scene_generation";
const isDetailReplacementPreset =
selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement";
const isSkuReplacementPreset =
selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "sku_replacement";
const isReplacementPreset = isDetailReplacementPreset || isSkuReplacementPreset;
const productSlotCount =
selectedAgentPreset?.type === "image"
? isSceneGenerationPreset
? presetModeConfig.scene.productImageSlotCount
: isDetailReplacementPreset
? presetModeConfig.detail.productImageSlotCount
: isSkuReplacementPreset
? presetModeConfig.sku.productImageSlotCount
: 2
: 2;
const modelSlotEnabled =
selectedAgentPreset?.type === "image"
? isSceneGenerationPreset
? presetModeConfig.scene.includeModelSlot
: isDetailReplacementPreset
? presetModeConfig.detail.includeModelSlot
: isSkuReplacementPreset
? presetModeConfig.sku.includeModelSlot
: true
: false;
const modelSlotIndex = modelSlotEnabled ? productSlotCount : -1;
const hasAgentUploadSlots = Boolean(selectedAgentPreset && selectedAgentPreset.type !== "text");
const agentUploadSlots = useMemo(
() => {
if (!hasAgentUploadSlots) return [];
const nextSlots = Array.from({ length: productSlotCount }, (_, index) => ({
slotIndex: index,
label: `产品图${index + 1}`,
}));
if (modelSlotEnabled) {
nextSlots.push({
slotIndex: modelSlotIndex,
label: "模特图",
});
}
return nextSlots;
},
[hasAgentUploadSlots, modelSlotEnabled, modelSlotIndex, productSlotCount]
);
const requiredUploadCount = hasAgentUploadSlots
? getAgentRequiredUploadCount({
presetType: selectedAgentPreset?.type || "image",
defaultParams: selectedAgentPreset?.defaultParams || null,
fallback: modelSlotEnabled ? 3 : productSlotCount,
})
: 0;
const shouldAllowModelOverride = !isSceneGenerationPreset || presetModeConfig.scene.allowModelOverride;
const selectedProductAttachments = attachments.filter((item, index) =>
modelSlotIndex >= 0 ? index !== modelSlotIndex && Boolean(item) : Boolean(item)
);
const selectedModelAttachment =
modelSlotIndex >= 0 && attachments[modelSlotIndex] ? attachments[modelSlotIndex] : null;
const slotSourceLibrary = useMemo(
() =>
referenceImages.map((image, index) => ({
id: `${image.source}-${image.id || index}`,
source: image.source,
sourceLabel: image.sourceLabel,
label: image.label,
name: image.name,
url: image.url,
assetId: image.assetId || undefined,
mimeType: image.mimeType || undefined,
})),
[referenceImages]
);
const activeAssignmentIsModelSlot =
activeAssignmentSlotIndex !== null &&
modelSlotIndex >= 0 &&
activeAssignmentSlotIndex === modelSlotIndex;
const assignableSourceLibrary = useMemo(() => {
if (!slotSourceLibrary.length) return [];
if (activeAssignmentIsModelSlot) {
const brandModelOnly = slotSourceLibrary.filter((item) => item.source === "brandModel");
return brandModelOnly;
}
return slotSourceLibrary.filter((item) => item.source !== "brandModel");
}, [activeAssignmentIsModelSlot, slotSourceLibrary]);
const resolvedAgentModeState = agentModeState || localAgentModeState;
const emitAgentModeState = onAgentModeStateChange || setLocalAgentModeState;
const detailReplacementState = resolvedAgentModeState.detailReplacement;
const detailReplacementQueryInput = detailReplacementState.query;
const detailReplacementQueryType = detailReplacementState.queryType;
const detailReplacementCandidates = detailReplacementState.candidates;
const detailReplacementSelectedCandidateId = detailReplacementState.selectedCandidateId;
const detailReplacementReferenceCards = detailReplacementState.references;
const skuReplacementState = resolvedAgentModeState.skuReplacement;
const skuReplacementQueryInput = skuReplacementState.query;
const skuReplacementQueryType = skuReplacementState.queryType;
const skuReplacementCandidates = skuReplacementState.candidates;
const skuReplacementSelectedCandidateId = skuReplacementState.selectedCandidateId;
const skuReplacementReferenceCards = skuReplacementState.references;
const selectableModels = useMemo(() => {
const imageModels = (modelsData?.models || []).filter(
(model) => model.supportsImageGen || model.isMultimodal
);
const autoOption = {
id: "auto",
displayName: "自动选择",
providerType: "auto",
imageGenConfig: modelsData?.agentModel?.imageGenConfig || undefined,
};
return [autoOption, ...imageModels];
}, [modelsData?.agentModel?.imageGenConfig, modelsData?.models]);
const currentModel = useMemo(
() => selectableModels.find((model) => model.id === selectedModelId) || selectableModels[0],
[selectableModels, selectedModelId]
);
const [activeToolStep, setActiveToolStep] = useState<ToolStepKey>("preset");
const hasBrandModelStep = Boolean(brandModelPanel) && modelSlotEnabled;
const hasMaterialStep = Boolean(materialSelectionPanel);
const { messages, setMessages, sendMessage, status } = useChat<ChatMessage>({
id: chatId,
messages: historyMessages,
experimental_throttle: 100,
generateId: generateUUID,
transport: new DefaultChatTransport({
api: "/api/chat",
fetch: fetchWithErrorHandlers,
prepareSendMessagesRequest(request) {
const replacementModePayload =
selectedAgentPreset?.type === "image" &&
(isDetailReplacementPreset || isSkuReplacementPreset)
? {
modeType: isDetailReplacementPreset
? ("detail_replacement" as const)
: ("sku_replacement" as const),
query: isDetailReplacementPreset
? detailReplacementQueryInput.trim()
: skuReplacementQueryInput.trim(),
queryType: isDetailReplacementPreset
? detailReplacementQueryType
: skuReplacementQueryType,
selectedCandidateId: isDetailReplacementPreset
? detailReplacementSelectedCandidateId || null
: skuReplacementSelectedCandidateId || null,
references: (
isDetailReplacementPreset
? detailReplacementReferenceCards
: skuReplacementReferenceCards
)
.filter((item) => Boolean(String(item.imageUrl || "").trim()))
.map((item) => ({
id: item.id,
label: item.label,
imageUrl: String(item.imageUrl || "").trim(),
candidateId: item.candidateId || null,
sortOrder: item.sortOrder ?? 0,
assignedProductIndex:
selectedProductAttachments.length > 0
? Math.min(
item.assignedProductIndex ?? 0,
Math.max(selectedProductAttachments.length - 1, 0)
)
: null,
})),
}
: undefined;
return {
body: {
id: rawChatId,
message: request.messages.at(-1),
hiddenPrompt: hiddenPromptRef.current || undefined,
agentPresetId: selectedAgentPresetId || undefined,
selectedChatModel: selectedModelId,
selectedVisibilityType: "private",
imageParams,
agentModePayload: replacementModePayload,
},
};
},
}),
});
useEffect(() => {
if (!messages.length && historyMessages.length) {
setMessages(historyMessages);
}
}, [historyMessages, messages.length, setMessages]);
useEffect(() => {
if (selectedAgentPreset?.defaultModelId) {
setSelectedModelId(selectedAgentPreset.defaultModelId);
return;
}
if (modelsData?.defaultModelId) {
setSelectedModelId(modelsData.defaultModelId);
}
}, [modelsData?.defaultModelId, selectedAgentPreset?.defaultModelId]);
// 初始化附件
useEffect(() => {
if (!hasAgentUploadSlots) {
setAttachments([]);
return;
}
const nextSlots: Array<PickedAttachment | null> = Array.from(
{ length: agentUploadSlots.length },
() => null
);
const primaryImages = referenceImages.filter((image) => image.source === "primary");
const secondaryImages = referenceImages.filter((image) => image.source === "secondary");
const brandModelImages = referenceImages.filter((image) => image.source === "brandModel");
const toAttachment = (image: (typeof referenceImages)[number] | undefined) =>
image
? { url: image.url, name: image.name || image.label, contentType: image.mimeType || "image/png", assetId: image.assetId || undefined }
: null;
const orderedProductImages = [...primaryImages, ...secondaryImages].slice(0, productSlotCount);
orderedProductImages.forEach((image, index) => {
nextSlots[index] = toAttachment(image);
});
if (modelSlotEnabled && modelSlotIndex >= 0) {
nextSlots[modelSlotIndex] = toAttachment(brandModelImages[0]);
}
setAttachments((current) => {
const result: Array<PickedAttachment | null> = Array.from(
{ length: agentUploadSlots.length },
(_, index) => current[index] || null
);
for (let index = 0; index < agentUploadSlots.length; index += 1) {
if (!result[index] && nextSlots[index]) {
result[index] = nextSlots[index] as PickedAttachment;
}
}
return result;
});
}, [
agentUploadSlots.length,
hasAgentUploadSlots,
modelSlotEnabled,
modelSlotIndex,
productSlotCount,
referenceImages,
]);
useEffect(() => {
if (!hasAgentUploadSlots) {
lastSyncedBrandModelUrlRef.current = null;
return;
}
const nextAttachment = selectedBrandModel?.imageUrl
? {
url: selectedBrandModel.imageUrl,
name: selectedBrandModel.displayName || `${selectedBrandModel.brandName} 模特图`,
contentType: selectedBrandModel.mimeType || "image/png",
assetId: selectedBrandModel.assetId || undefined,
}
: null;
setAttachments((current) => {
const slots: Array<PickedAttachment | null> = Array.from(
{ length: Math.max(agentUploadSlots.length, Math.max(modelSlotIndex + 1, 1)) },
(_, index) => current[index] ?? null
);
const currentSlot = modelSlotIndex >= 0 ? slots[modelSlotIndex] ?? null : null;
const previousSyncedUrl = lastSyncedBrandModelUrlRef.current;
if (
modelSlotIndex >= 0 &&
nextAttachment &&
(!currentSlot || currentSlot.url === previousSyncedUrl)
) {
slots[modelSlotIndex] = nextAttachment;
} else if (modelSlotIndex >= 0 && previousSyncedUrl && currentSlot?.url === previousSyncedUrl) {
slots[modelSlotIndex] = null;
}
return slots;
});
lastSyncedBrandModelUrlRef.current = nextAttachment?.url || null;
}, [
agentUploadSlots.length,
hasAgentUploadSlots,
modelSlotIndex,
selectedBrandModel?.assetId,
selectedBrandModel?.brandName,
selectedBrandModel?.displayName,
selectedBrandModel?.id,
selectedBrandModel?.imageUrl,
selectedBrandModel?.mimeType,
]);
const generatedGroups = useMemo<GeneratedImageGroup[]>(() => {
return messages
.filter((message) => message.role === "assistant")
.map((message) => {
const textParts = (message.parts || [])
.filter((part: any) => part.type === "text" && part.text)
.map((part: any) => String(part.text));
const imageUrls = Array.from(
new Set(textParts.flatMap((text) => extractImageUrlsFromText(text)))
);
return {
messageId: message.id,
imageUrls,
text: normalizeAssistantText(textParts.join("\n\n")),
};
})
.filter((group) => group.imageUrls.length > 0);
}, [messages]);
const allGeneratedImages = useMemo(
() => Array.from(new Set(generatedGroups.flatMap((group) => group.imageUrls))),
[generatedGroups]
);
const latestGeneratedGroup = useMemo(() => {
const lastUserIndex = [...messages]
.map((message) => message.role)
.lastIndexOf("user");
if (lastUserIndex >= 0) {
const latestAssistantAfterLastUser = 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))
.at(-1);
if (latestAssistantAfterLastUser) {
return latestAssistantAfterLastUser;
}
const hasAssistantAfterLastUser = messages
.slice(lastUserIndex + 1)
.some((message) => message.role === "assistant");
if (hasAssistantAfterLastUser) {
return null;
}
}
return generatedGroups[generatedGroups.length - 1] || null;
}, [generatedGroups, messages]);
const latestReplacementResultUrls = latestGeneratedGroup?.imageUrls || [];
const displayedResultUrl = selectedResultUrl || latestGeneratedGroup?.imageUrls[0] || null;
const isGenerating = status === "streaming" || status === "submitted";
const attachmentCount = attachments.filter(Boolean).length;
const hasBrandModelSelection = Boolean(
selectedBrandModel?.imageUrl || referenceImages.some((image) => image.source === "brandModel")
);
const hasMaterialSelections = referenceImages.some(
(image) => image.source === "primary" || image.source === "secondary"
);
const hasSatisfiedSlotRequirement = !hasAgentUploadSlots || attachmentCount >= requiredUploadCount;
const toolSteps = useMemo(
() => [
{
key: "preset" as const,
label: "生图模式",
description: "先选择一个模式,再继续后续配置",
icon: Wand2,
disabled: false,
completed: Boolean(selectedAgentPresetId),
optional: false,
},
...(hasBrandModelStep
? [
{
key: "brand-model" as const,
label: "品牌模特",
description: "根据品牌选择模特图",
icon: UserRound,
disabled: !selectedAgentPresetId,
completed: hasBrandModelSelection,
optional: true,
},
]
: []),
...(hasMaterialStep
? [
{
key: "materials" as const,
label: "SKU / SPU 选图",
description: "搜索主图和搭配图",
icon: Upload,
disabled: !selectedAgentPresetId,
completed: hasMaterialSelections,
optional: true,
},
]
: []),
{
key: "slots" as const,
label: "图片槽位",
description: "检查和补齐参考图槽位",
icon: ImagePlus,
disabled: !selectedAgentPresetId,
completed: hasSatisfiedSlotRequirement,
optional: false,
},
{
key: "prompt" as const,
label: "补充要求",
description: "补充风格、构图和细节要求",
icon: Sparkles,
disabled: !selectedAgentPresetId,
completed: Boolean(input.trim()),
optional: true,
},
{
key: "settings" as const,
label: "生成参数",
description: "选择模型并开始生成",
icon: Settings2,
disabled: !selectedAgentPresetId,
completed: Boolean(selectedAgentPresetId),
optional: false,
},
],
[
hasBrandModelSelection,
hasBrandModelStep,
hasMaterialSelections,
hasMaterialStep,
hasSatisfiedSlotRequirement,
input,
selectedAgentPresetId,
]
);
const activeToolStepIndex = Math.max(
toolSteps.findIndex((step) => step.key === activeToolStep),
0
);
const activeToolStepMeta = toolSteps[activeToolStepIndex] || toolSteps[0];
const previousToolStep = activeToolStepIndex > 0 ? toolSteps[activeToolStepIndex - 1] : null;
const nextToolStep =
activeToolStepIndex < toolSteps.length - 1 ? toolSteps[activeToolStepIndex + 1] : null;
useEffect(() => {
if (!selectedResultUrl && latestGeneratedGroup?.imageUrls[0]) {
setSelectedResultUrl(latestGeneratedGroup.imageUrls[0]);
}
}, [latestGeneratedGroup?.imageUrls, selectedResultUrl]);
useEffect(() => {
if (agentUploadSlots.length === 0) {
setActiveAssignmentSlotIndex(null);
return;
}
setActiveAssignmentSlotIndex((current) => {
if (current !== null && current >= 0 && current < agentUploadSlots.length) {
return current;
}
return 0;
});
}, [agentUploadSlots.length]);
useEffect(() => {
if (!selectedAgentPresetId) {
setActiveToolStep("preset");
return;
}
setActiveToolStep((current) => {
if (current !== "preset") return current;
if (isDetailReplacementPreset || isSkuReplacementPreset) {
if (hasMaterialStep) return "materials";
return "slots";
}
if (hasBrandModelStep) return "brand-model";
if (hasMaterialStep) return "materials";
return "slots";
});
}, [
hasBrandModelStep,
hasMaterialStep,
isDetailReplacementPreset,
isSkuReplacementPreset,
selectedAgentPresetId,
]);
useEffect(() => {
if (!toolSteps.some((step) => step.key === activeToolStep)) {
setActiveToolStep("preset");
}
}, [activeToolStep, toolSteps]);
useEffect(() => {
if (!syncGeneratedResultsToCanvas) {
return;
}
const latestUrls = latestGeneratedGroup?.imageUrls || [];
if (latestUrls.length === 0) return;
latestUrls.forEach(async (url, index) => {
if (!url || generatedImageKeysRef.current.has(url)) return;
generatedImageKeysRef.current.add(url);
try {
const dimensions = await getImageDimensions(url);
const nodeIndex = nodes.length + index;
const spacing = 20;
const x = 120 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 120 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
addNode({
type: "image",
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: url,
status: "completed",
title: "生成结果",
prompt: latestGeneratedGroup?.text || "生成结果",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
},
},
connections: [],
});
} catch (error) {
console.warn("[AgentGenerationWorkspaceDarkV2] Failed to add generated image to canvas:", error);
}
});
}, [addNode, latestGeneratedGroup, nodes.length, syncGeneratedResultsToCanvas]);
const updateAttachmentAtSlot = useCallback((slotIndex: number, attachment: PickedAttachment | null) => {
const slotCount = Math.max(agentUploadSlots.length, 1);
setAttachments((current) => {
const slots: Array<PickedAttachment | null> = Array.from(
{ length: slotCount },
(_, index) => current[index] ?? null
);
slots[slotIndex] = attachment;
return slots;
});
}, [agentUploadSlots.length]);
const handleSlotFileChange = useCallback(
async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
event.currentTarget.value = "";
return;
}
setSlotUploading(slotIndex);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
const data = await response.json();
if (!response.ok || !data?.url) {
throw new Error(data?.error || "上传失败");
}
updateAttachmentAtSlot(slotIndex, {
url: data.url,
name: file.name,
contentType: file.type || "image/jpeg",
assetId: typeof data.assetId === "string" ? data.assetId : undefined,
});
toast.success(`${agentUploadSlots[slotIndex]?.label || `槽位${slotIndex + 1}`}上传成功`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传失败");
} finally {
setSlotUploading(null);
event.currentTarget.value = "";
}
},
[agentUploadSlots, updateAttachmentAtSlot]
);
const handleAssignSourceToSlot = useCallback(
(slotIndex: number, source: (typeof slotSourceLibrary)[number]) => {
updateAttachmentAtSlot(slotIndex, {
url: source.url,
name: source.name || source.label || `槽位${slotIndex + 1}`,
contentType: source.mimeType || "image/png",
assetId: source.assetId,
});
toast.success(`${source.label || source.name || "已选素材"} 已填入 ${agentUploadSlots[slotIndex]?.label || `槽位${slotIndex + 1}`}`);
},
[agentUploadSlots, slotSourceLibrary, updateAttachmentAtSlot]
);
const handleBatchDownloadImages = useCallback(async (params: {
imageUrls: string[];
zipName: string;
scope: "scene" | "detail" | "sku";
}) => {
const normalizedUrls = Array.from(
new Set(
(params.imageUrls || [])
.map((item) => String(item || "").trim())
.filter((item) => /^https?:\/\//i.test(item))
)
);
if (normalizedUrls.length === 0) {
toast.error("当前没有可下载的结果图");
return;
}
setBatchDownloadState(params.scope);
try {
const zip = new JSZip();
let successCount = 0;
for (const [index, imageUrl] of normalizedUrls.entries()) {
try {
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const fallbackName = `${params.zipName}-${String(index + 1).padStart(2, "0")}.png`;
zip.file(buildDownloadFilename(imageUrl) || fallbackName, blob);
successCount += 1;
} catch (error) {
console.warn("[AgentGenerationWorkspaceDarkV2] Batch download item failed:", {
imageUrl,
error,
});
}
}
if (successCount === 0) {
throw new Error("没有成功下载到任何图片");
}
const blob = await zip.generateAsync({ type: "blob" });
const link = document.createElement("a");
link.href = window.URL.createObjectURL(blob);
link.download = `${params.zipName}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(link.href);
toast.success(`已打包下载 ${successCount} 张图片`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "批量下载失败");
} finally {
setBatchDownloadState(null);
}
}, []);
const handleSendResultToCanvasTextEdit = useCallback((params: {
imageUrl: string;
title: string;
}) => {
const rawUrl = String(params.imageUrl || "").trim();
if (!rawUrl) {
toast.error("结果图地址无效");
return;
}
const previewTitle = String(params.title || "").trim() || "待改字结果";
const nodeIndex = nodes.length;
const nodeId = addNode({
type: "image",
position: {
x: 160 + (nodeIndex % 3) * 340,
y: 140 + Math.floor(nodeIndex / 3) * 340,
},
size: { width: 320, height: 320 },
data: {
src: rawUrl,
status: "completed",
title: previewTitle,
prompt: previewTitle,
},
connections: [],
});
selectNodes([nodeId]);
requestImageNodeAction(nodeId, "text-edit");
onOpenCanvasEditor?.();
toast.success("已送入画布,可继续改字");
}, [addNode, nodes.length, onOpenCanvasEditor, requestImageNodeAction, selectNodes]);
const handleSend = useCallback(() => {
if (!selectedAgentPresetId) {
toast.error("请先选择生图模式");
return;
}
if (isDetailReplacementPreset && detailReplacementReferenceCards.length === 0) {
toast.error("请先查询并选择详情参考图");
return;
}
if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) {
toast.error("请先查询并选择 SKU 参考图");
return;
}
const uploadedAttachmentCount = attachments.filter(Boolean).length;
if (hasAgentUploadSlots && uploadedAttachmentCount < requiredUploadCount) {
toast.error(`当前模式需要上传 ${requiredUploadCount} 张图片`);
return;
}
const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = [];
attachments.forEach((attachment) => {
if (!attachment) return;
parts.push({ type: "file", mediaType: attachment.contentType || "image/jpeg", name: attachment.name || "参考图", url: attachment.url, assetId: attachment.assetId });
});
let textContent = input.trim();
if (!textContent && selectedAgentPreset?.type === "text" && workflowContext?.summaryText) {
textContent = `请基于当前工作流设定生成方案:${workflowContext.summaryText}`;
}
if (!textContent && selectedAgentPreset?.type === "text" && selectedAgentPreset) {
textContent = `请按「${selectedAgentPreset.name}」输出内容`;
}
if (textContent) {
parts.push({ type: "text", text: textContent });
}
if (parts.length === 0) {
toast.error("请至少补充要求或上传图片");
return;
}
hiddenPromptRef.current = workflowContext?.promptText
? `【工作流设定】\n${workflowContext.promptText}\n\n请严格遵循以上工作流设定输出内容,并保持整体结果一致。`
: null;
sendMessage({ role: "user", parts });
setInput("");
}, [
attachments,
detailReplacementReferenceCards.length,
hasAgentUploadSlots,
input,
isDetailReplacementPreset,
isReplacementPreset,
isSkuReplacementPreset,
requiredUploadCount,
selectedAgentPreset,
selectedAgentPresetId,
sendMessage,
skuReplacementReferenceCards.length,
workflowContext?.promptText,
workflowContext?.summaryText,
]);
const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => {
const raw = String(imageUrl || "").trim();
if (!raw) {
toast.error("图片地址无效");
return;
}
const filename = buildDownloadFilename(raw);
try {
const response = await fetch(raw);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
}
}, []);
const handleToolStepChange = useCallback(
(nextKey: ToolStepKey) => {
const nextStep = toolSteps.find((step) => step.key === nextKey);
if (!nextStep) return;
if (nextStep.disabled) {
toast.error("请先选择生图模式");
return;
}
setActiveToolStep(nextKey);
},
[toolSteps]
);
const updateDetailReplacementState = useCallback(
(
updater:
| Partial<AgentModeWorkspaceState["detailReplacement"]>
| ((current: AgentModeWorkspaceState["detailReplacement"]) => AgentModeWorkspaceState["detailReplacement"])
) => {
const current = resolvedAgentModeState.detailReplacement;
const nextDetailReplacement =
typeof updater === "function" ? updater(current) : { ...current, ...updater };
emitAgentModeState({
...resolvedAgentModeState,
detailReplacement: nextDetailReplacement,
});
},
[emitAgentModeState, resolvedAgentModeState]
);
const updateSkuReplacementState = useCallback(
(
updater:
| Partial<AgentModeWorkspaceState["skuReplacement"]>
| ((current: AgentModeWorkspaceState["skuReplacement"]) => AgentModeWorkspaceState["skuReplacement"])
) => {
const current = resolvedAgentModeState.skuReplacement;
const nextSkuReplacement =
typeof updater === "function" ? updater(current) : { ...current, ...updater };
emitAgentModeState({
...resolvedAgentModeState,
skuReplacement: nextSkuReplacement,
});
},
[emitAgentModeState, resolvedAgentModeState]
);
const handleDetailReplacementSearch = useCallback(async () => {
const normalized = detailReplacementQueryInput.trim().toUpperCase();
if (!normalized) {
toast.error(`请输入${detailReplacementQueryType === "spu" ? "SPU" : "SKU"}后再查询详情图`);
return;
}
setDetailReplacementSearchState({
isLoading: true,
lastQuery: normalized,
});
try {
const response = await fetch(
`/api/agent-workflow/reference-search?keyword=${encodeURIComponent(
normalized
)}&queryType=${detailReplacementQueryType}&target=detail`,
{ cache: "no-store" }
);
const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse;
if (!response.ok || !result?.success || !result?.data) {
throw new Error(result?.error?.message || "详情参考图查询失败");
}
updateDetailReplacementState({
query: normalized,
queryType: result.data.queryType || detailReplacementQueryType,
candidates: result.data.candidates || [],
selectedCandidateId:
result.data.selectedCandidateId || result.data.candidates?.[0]?.id || null,
references: result.data.references || [],
});
setDetailReplacementSearchState({
isLoading: false,
lastQuery: normalized,
});
toast.success(`已载入 ${result.data.references?.length || 0} 张详情参考图`);
} catch (error) {
setDetailReplacementSearchState({
isLoading: false,
lastQuery: normalized,
});
toast.error(error instanceof Error ? error.message : "详情参考图查询失败");
}
}, [detailReplacementQueryInput, detailReplacementQueryType, updateDetailReplacementState]);
const handleSelectDetailReplacementCandidate = useCallback((candidateId: string) => {
updateDetailReplacementState((current) => {
const nextCandidate =
current.candidates.find((candidate) => candidate.id === candidateId) || null;
return {
...current,
selectedCandidateId: candidateId,
references: buildReferenceCardsFromCandidate({
candidate: nextCandidate,
target: "detail",
productCount: selectedProductAttachments.length,
}),
};
});
}, [selectedProductAttachments.length, updateDetailReplacementState]);
const handleRemoveDetailReferenceCard = useCallback((cardId: string) => {
updateDetailReplacementState((current) => ({
...current,
references: current.references.filter((card) => card.id !== cardId),
}));
}, [updateDetailReplacementState]);
const handleClearDetailReferenceCards = useCallback(() => {
updateDetailReplacementState((current) => ({
...current,
candidates: [],
selectedCandidateId: null,
references: [],
}));
}, [updateDetailReplacementState]);
const handleSkuReplacementSearch = useCallback(async () => {
const normalized = skuReplacementQueryInput.trim().toUpperCase();
if (!normalized) {
toast.error(`请输入${skuReplacementQueryType === "spu" ? "SPU" : "SKU"}后再查询 SKU 参考图`);
return;
}
setSkuReplacementSearchState({
isLoading: true,
lastQuery: normalized,
});
try {
const response = await fetch(
`/api/agent-workflow/reference-search?keyword=${encodeURIComponent(
normalized
)}&queryType=${skuReplacementQueryType}&target=sku`,
{ cache: "no-store" }
);
const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse;
if (!response.ok || !result?.success || !result?.data) {
throw new Error(result?.error?.message || "SKU 参考图查询失败");
}
updateSkuReplacementState({
query: normalized,
queryType: result.data.queryType || skuReplacementQueryType,
candidates: result.data.candidates || [],
selectedCandidateId:
result.data.selectedCandidateId || result.data.candidates?.[0]?.id || null,
references:
(result.data.references || []).map((item, index) => ({
...item,
assignedProductIndex:
selectedProductAttachments.length > 0
? Math.min(index, Math.max(selectedProductAttachments.length - 1, 0))
: null,
})) || [],
});
setSkuReplacementSearchState({
isLoading: false,
lastQuery: normalized,
});
toast.success(`已载入 ${result.data.references?.length || 0} 张 SKU 参考图`);
} catch (error) {
setSkuReplacementSearchState({
isLoading: false,
lastQuery: normalized,
});
toast.error(error instanceof Error ? error.message : "SKU 参考图查询失败");
}
}, [
selectedProductAttachments.length,
skuReplacementQueryInput,
skuReplacementQueryType,
updateSkuReplacementState,
]);
const handleSelectSkuReplacementCandidate = useCallback((candidateId: string) => {
updateSkuReplacementState((current) => {
const nextCandidate =
current.candidates.find((candidate) => candidate.id === candidateId) || null;
return {
...current,
selectedCandidateId: candidateId,
references: buildReferenceCardsFromCandidate({
candidate: nextCandidate,
target: "sku",
productCount: selectedProductAttachments.length,
}),
};
});
}, [selectedProductAttachments.length, updateSkuReplacementState]);
const handleAssignSkuReferenceProduct = useCallback((cardId: string, productIndex: number) => {
updateSkuReplacementState((current) => ({
...current,
references: current.references.map((card) =>
card.id === cardId
? {
...card,
assignedProductIndex: productIndex,
}
: card
),
}));
}, [updateSkuReplacementState]);
const handleRemoveSkuReferenceCard = useCallback((cardId: string) => {
updateSkuReplacementState((current) => ({
...current,
references: current.references.filter((card) => card.id !== cardId),
}));
}, [updateSkuReplacementState]);
const handleClearSkuReferenceCards = useCallback(() => {
updateSkuReplacementState((current) => ({
...current,
candidates: [],
selectedCandidateId: null,
references: [],
}));
}, [updateSkuReplacementState]);
return (
<div className="flex h-dvh flex-col overflow-hidden bg-black">
<AgentCanvasTopNavDark
siteName={siteName}
siteLogo={siteLogo}
siteLogoDark={siteLogoDark}
onBack={onBack}
activeKey="agent"
rightSlot={<div className="w-10" />}
/>
{/* Main Content */}
<main className="flex-1 overflow-y-auto overscroll-contain pt-16">
<div className="mx-auto flex w-full max-w-[1480px] flex-col gap-6 px-6 py-6 pb-24 lg:px-8 xl:px-10">
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.06 }}
className="rounded-[28px] border border-white/12 bg-white/[0.04] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
>
<div className="flex flex-col gap-4 border-b border-white/10 pb-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1">
<div className="text-xs uppercase tracking-[0.2em] text-white/35">Current Module</div>
<h3 className="text-lg font-semibold text-white">{activeToolStepMeta.label}</h3>
<p className="text-sm text-white/45">{activeToolStepMeta.description}</p>
{isReplacementPreset ? (
<p className="text-xs text-amber-300/80">
{isDetailReplacementPreset
? "当前模板属于详情替换模式,下一步将切入专用工作台进行逐屏替换。"
: "当前模板属于 SKU 替换模式,下一步将切入专用工作台进行一对一替换。"}
</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{selectedAgentPreset ? (
<div className="flex items-center gap-3 rounded-[20px] border border-white/10 bg-black/20 px-3 py-2">
{selectedAgentPreset.avatarUrl ? (
<img
src={selectedAgentPreset.avatarUrl}
alt={selectedAgentPreset.name}
className="h-10 w-10 rounded-xl object-cover"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500/18">
<Wand2 className="h-5 w-5 text-emerald-300" />
</div>
)}
<div>
<div className="text-sm font-medium text-white">{selectedAgentPreset.name}</div>
<div className="text-xs text-white/40">
{selectedAgentPreset.type === "image" ? "生图模式" : "视频模式"}
</div>
</div>
</div>
) : null}
{selectedAgentPreset && onClearPreset ? (
<button
type="button"
onClick={() => {
onClearPreset();
setActiveToolStep("preset");
}}
className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-emerald-300 transition-colors hover:border-emerald-300/35 hover:text-emerald-200"
>
更换生图模式
</button>
) : null}
</div>
</div>
<div className="mt-5 min-h-[260px]">
<div className={cn(activeToolStep === "preset" ? "block" : "hidden")}>
{presetPanel ? (
<div className="agent-dark-scrollbar max-h-[520px] overflow-y-auto pr-1">
{presetPanel}
</div>
) : (
<div className="flex min-h-[220px] items-center justify-center rounded-[24px] border border-dashed border-white/12 bg-black/20 text-sm text-white/45">
暂无可用的生图模式配置。
</div>
)}
</div>
{hasBrandModelStep ? (
<div className={cn(activeToolStep === "brand-model" ? "block" : "hidden")}>
{brandModelPanel}
</div>
) : null}
{hasMaterialStep ? (
<div className={cn(activeToolStep === "materials" ? "block" : "hidden")}>
{materialSelectionPanel}
</div>
) : null}
<div className={cn(activeToolStep === "slots" ? "block" : "hidden")}>
<div className="rounded-[28px] border border-white/12 bg-white/[0.02] p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<ImagePlus className="h-4 w-4 text-emerald-300" />
图片槽位
</h3>
{agentUploadSlots.length > 0 ? (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
{agentUploadSlots.map((slot, index) => {
const isActiveAssignmentSlot = activeAssignmentSlotIndex === index;
return (
<motion.div
key={slot.label}
whileHover={{ scale: 1.02 }}
className={cn(
"group relative aspect-square overflow-hidden rounded-[24px] transition-all",
attachments[index]
? "ring-2 ring-emerald-400/45"
: "border-2 border-dashed border-white/15 bg-black/25 hover:border-emerald-400/35",
isActiveAssignmentSlot && "ring-2 ring-sky-400/65 ring-offset-2 ring-offset-black"
)}
>
<input
ref={(el) => {
fileInputRefs.current[index] = el;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleSlotFileChange(index, e)}
/>
{attachments[index] ? (
<>
<img
src={buildPreviewImageUrl(attachments[index]!.url, 256)}
alt={attachments[index]!.name || slot.label}
className="h-full w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1 text-[10px] text-white">
{slot.label}
</div>
<button
type="button"
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-white transition-opacity hover:bg-white/30"
onClick={() => updateAttachmentAtSlot(index, null)}
>
<X className="h-3 w-3" />
</button>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-1 text-white/30">
{slotUploading === index ? (
<Loader2 className="h-5 w-5 animate-spin text-emerald-400" />
) : (
<>
<Upload className="h-5 w-5" />
<span className="text-[10px]">{slot.label}</span>
</>
)}
</div>
)}
<div className="absolute inset-x-0 top-0 flex items-center justify-between gap-2 p-2">
<button
type="button"
onClick={() => fileInputRefs.current[index]?.click()}
className="rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[11px] text-white/80 transition-colors hover:border-white/20 hover:text-white"
>
{attachments[index] ? "更换" : "上传"}
</button>
<button
type="button"
onClick={() => setActiveAssignmentSlotIndex(index)}
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] transition-colors",
isActiveAssignmentSlot
? "border-sky-400/45 bg-sky-500/[0.18] text-sky-100"
: "border-white/10 bg-black/45 text-white/70 hover:border-white/20 hover:text-white"
)}
>
{isActiveAssignmentSlot ? "当前目标" : "设为目标"}
</button>
</div>
</motion.div>
);
})}
</div>
{slotSourceLibrary.length > 0 ? (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-3">
<div className="mb-3 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-medium text-white">从第一步已选素材填入槽位</div>
<div className="text-xs text-white/42">
先点击上方槽位的“设为目标”,再点击下方素材图,将它放入对应槽位。
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/60">
当前目标:
{activeAssignmentSlotIndex !== null
? agentUploadSlots[activeAssignmentSlotIndex]?.label || "未选择"
: "未选择"}
</div>
</div>
{assignableSourceLibrary.length > 0 ? (
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
{assignableSourceLibrary.map((source) => {
const assignedToActiveSlot =
activeAssignmentSlotIndex !== null &&
attachments[activeAssignmentSlotIndex]?.url === source.url;
return (
<button
key={source.id}
type="button"
onClick={() => {
if (activeAssignmentSlotIndex === null) return;
handleAssignSourceToSlot(activeAssignmentSlotIndex, source);
}}
disabled={activeAssignmentSlotIndex === null}
className={cn(
"overflow-hidden rounded-[20px] border text-left transition-all",
assignedToActiveSlot
? "border-emerald-400/55 bg-emerald-500/[0.08]"
: "border-white/10 bg-white/[0.03] hover:border-white/18",
activeAssignmentSlotIndex === null && "cursor-not-allowed opacity-45"
)}
>
<div className="relative aspect-square overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(source.url, 320)}
alt={source.label}
className="h-full w-full object-cover"
/>
<div className="absolute left-2 top-2 rounded-full bg-black/60 px-2 py-1 text-[10px] text-white">
{source.sourceLabel}
</div>
</div>
<div className="space-y-1 px-3 py-2">
<div className="truncate text-xs font-medium text-white/88">
{source.label}
</div>
<div className="truncate text-[11px] text-white/42">
{source.name}
</div>
</div>
</button>
);
})}
</div>
) : (
<div className="flex min-h-[92px] items-center justify-center rounded-[20px] border border-dashed border-white/10 bg-white/[0.02] px-4 text-center text-xs text-white/40">
{activeAssignmentIsModelSlot
? "当前模特槽位还没有可分配的品牌模特图,请先在“品牌模特”步骤里选择。"
: "当前产品槽位还没有来自第一步的已选商品图,请先到“SKU / SPU 选图”里完成选择。"}
</div>
)}
</div>
) : null}
</div>
) : (
<div className="flex min-h-[132px] flex-col items-center justify-center rounded-[24px] border border-dashed border-white/12 bg-black/20 px-4 text-center text-sm text-white/45">
<ImagePlus className="mb-2 h-5 w-5 opacity-60" />
先选择一个生图模式,系统会按模板配置展示对应的产品图和模特图槽位。
</div>
)}
</div>
</div>
<div className={cn(activeToolStep === "prompt" ? "block" : "hidden")}>
<div className="rounded-[28px] border border-white/12 bg-white/[0.02] p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<Sparkles className="h-4 w-4 text-emerald-300" />
补充要求
</h3>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="描述你想要的风格、氛围、构图..."
className="min-h-[160px] resize-none rounded-[24px] border-white/12 bg-black text-sm text-white placeholder:text-white/20 focus:border-emerald-400/30"
/>
</div>
</div>
<div className={cn(activeToolStep === "settings" ? "block" : "hidden")}>
<div className="rounded-[28px] border border-white/12 bg-white/[0.02] p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<Settings2 className="h-4 w-4 text-emerald-300" />
生成参数
</h3>
<div className="space-y-3">
{isReplacementPreset ? (
<div className="rounded-[24px] border border-amber-400/20 bg-amber-500/[0.08] px-4 py-3 text-sm text-amber-100/90">
{isDetailReplacementPreset
? "详情替换会按当前参考图顺序逐张执行,结果将在下方结果区一一回显。"
: "SKU替换会按当前参考图顺序执行一对一替换,结果将在下方结果区一一回显。"}
</div>
) : null}
{shouldAllowModelOverride ? (
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger className="h-10 rounded-[24px] border-white/12 bg-black text-white">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent className="border-white/10 bg-zinc-900">
{selectableModels.map((model) => (
<SelectItem key={model.id} value={model.id} className="text-white">
{model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 text-sm text-white/70">
当前模板已锁定模型:
<span className="ml-2 font-medium text-white">
{selectedAgentPreset?.defaultModel?.displayName || currentModel?.displayName || "未配置"}
</span>
</div>
)}
<ImageParamsBar
modelName={currentModel?.displayName}
initialParams={imageParams}
onParamsChange={setImageParams}
inline
inlineTheme="dark"
showInlineDivider={false}
providerType={currentModel?.providerType}
imageGenConfig={currentModel?.imageGenConfig || undefined}
/>
<Button
onClick={handleSend}
disabled={isGenerating || !selectedAgentPresetId}
className={cn(
"h-11 w-full gap-2 rounded-[24px]",
"bg-gradient-to-r from-emerald-500 to-teal-500",
"text-white font-medium",
"hover:from-emerald-400 hover:to-teal-400",
"shadow-[0_0_24px_rgba(16,185,129,0.24)]",
"disabled:opacity-30"
)}
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{isReplacementPreset ? "批量替换中..." : "生成中..."}
</>
) : (
<>
<ArrowUp className="h-4 w-4" />
{isReplacementPreset ? "开始批量替换" : "开始生成"}
</>
)}
</Button>
</div>
</div>
</div>
</div>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-white/10 pt-4">
<div className="text-xs text-white/40">
完成当前模块后,可点击下一步继续,也可以直接点击顶部流程线跳转。
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => previousToolStep && handleToolStepChange(previousToolStep.key)}
disabled={!previousToolStep}
className={cn(
"rounded-full border px-4 py-2 text-sm transition-colors",
previousToolStep
? "border-white/12 bg-white/[0.03] text-white hover:border-white/20 hover:bg-white/[0.06]"
: "cursor-not-allowed border-white/8 bg-white/[0.02] text-white/25"
)}
>
上一步
</button>
<button
type="button"
onClick={() => nextToolStep && handleToolStepChange(nextToolStep.key)}
disabled={!nextToolStep}
className={cn(
"rounded-full border px-4 py-2 text-sm transition-colors",
nextToolStep
? "border-emerald-400/35 bg-emerald-500/[0.08] text-emerald-200 hover:border-emerald-300/50 hover:bg-emerald-500/[0.12]"
: "cursor-not-allowed border-white/8 bg-white/[0.02] text-white/25"
)}
>
下一步
</button>
</div>
</div>
</motion.section>
<motion.section
className="relative flex min-h-[620px] flex-col overflow-hidden rounded-[28px] border border-white/12 bg-white/[0.04] shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.12 }}
>
<div className="flex items-center justify-between border-b border-white/12 px-5 py-4">
<div>
<div className="text-sm font-semibold text-white">结果预览</div>
<div className="text-xs text-white/40">
{isDetailReplacementPreset
? "详情替换结果会按参考图顺序纵向展示,并保持在当前工作台预览,不会自动回写到无限画布。"
: isSkuReplacementPreset
? "SKU替换结果会按参考图顺序一对一展示,并保持在当前工作台预览,不会自动回写到无限画布。"
: "结果仅保留在当前生图工作台预览,不会自动回写到无限画布。"}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{isDetailReplacementPreset && latestReplacementResultUrls.length > 0 ? (
<button
type="button"
onClick={() =>
void handleBatchDownloadImages({
imageUrls: latestReplacementResultUrls,
zipName: `${selectedAgentPreset?.name || "detail-replacement"}-results`,
scope: "detail",
})
}
disabled={batchDownloadState === "detail"}
className="rounded-full border border-emerald-400/30 bg-emerald-500/[0.08] px-3 py-1 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/[0.14] disabled:opacity-50"
>
{batchDownloadState === "detail" ? "打包中..." : "批量下载"}
</button>
) : null}
{isSkuReplacementPreset && latestReplacementResultUrls.length > 0 ? (
<button
type="button"
onClick={() =>
void handleBatchDownloadImages({
imageUrls: latestReplacementResultUrls,
zipName: `${selectedAgentPreset?.name || "sku-replacement"}-results`,
scope: "sku",
})
}
disabled={batchDownloadState === "sku"}
className="rounded-full border border-emerald-400/30 bg-emerald-500/[0.08] px-3 py-1 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/[0.14] disabled:opacity-50"
>
{batchDownloadState === "sku" ? "打包中..." : "批量下载"}
</button>
) : null}
{!isReplacementPreset && allGeneratedImages.length > 1 ? (
<button
type="button"
onClick={() =>
void handleBatchDownloadImages({
imageUrls: allGeneratedImages,
zipName: `${selectedAgentPreset?.name || "scene-generation"}-results`,
scope: "scene",
})
}
disabled={batchDownloadState === "scene"}
className="rounded-full border border-emerald-400/30 bg-emerald-500/[0.08] px-3 py-1 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/[0.14] disabled:opacity-50"
>
{batchDownloadState === "scene" ? "打包中..." : "批量下载"}
</button>
) : null}
{isDetailReplacementPreset ? (
<div className="rounded-full bg-white/8 px-3 py-1 text-xs text-white/60">
预估 {detailReplacementReferenceCards.length} 屏
</div>
) : isSkuReplacementPreset ? (
<div className="rounded-full bg-white/8 px-3 py-1 text-xs text-white/60">
预估 {skuReplacementReferenceCards.length} 组
</div>
) : allGeneratedImages.length > 0 ? (
<div className="rounded-full bg-white/8 px-3 py-1 text-xs text-white/60">
共 {allGeneratedImages.length} 张
</div>
) : null}
</div>
</div>
{isDetailReplacementPreset ? (
<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">
<div>
<div className="text-sm font-semibold text-white">输入图</div>
<div className="text-xs text-white/40">
选中的白底图会作为详情替换的输入图组
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55">
{selectedProductAttachments.length} 张
</div>
</div>
<div className="space-y-3">
{selectedProductAttachments.length > 0 ? (
selectedProductAttachments.map((attachment, index) => (
<div
key={`${attachment?.url || "input"}-${index}`}
className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20"
>
<div className="aspect-square overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(attachment!.url, 400)}
alt={attachment!.name || `输入图 ${index + 1}`}
className="h-full w-full object-cover"
/>
</div>
<div className="border-t border-white/10 px-3 py-2">
<div className="text-xs font-medium text-white/80">
输入图 {index + 1}
</div>
<div className="truncate text-[11px] text-white/40">
{attachment?.name || "已选择白底图"}
</div>
</div>
</div>
))
) : (
<div className="flex min-h-[160px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-4 text-center text-sm text-white/42">
先在上方“图片槽位”中选择用于详情替换的白底输入图。
</div>
)}
{selectedModelAttachment ? (
<div className="rounded-[22px] border border-emerald-400/20 bg-emerald-500/[0.06] p-3">
<div className="text-xs font-medium text-emerald-200">模特图</div>
<div className="mt-1 truncate text-[11px] text-emerald-100/70">
{selectedModelAttachment.name || "已选择模特图"}
</div>
</div>
) : null}
</div>
</div>
<div className="min-h-0 border-b border-white/10 p-4 xl:border-b-0 xl:border-r">
<div className="mb-3 flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-white">详情参考图</div>
<div className="text-xs text-white/40">
支持按 SPU 或 SKU 查询任务-详情整套候选,并切换某一套的详情参考图。
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55">
{detailReplacementReferenceCards.length} 张
</div>
</div>
<div className="mb-3 flex flex-wrap gap-2">
{(["spu", "sku"] as const).map((queryType) => (
<button
key={queryType}
type="button"
onClick={() =>
updateDetailReplacementState((current) => ({
...current,
queryType,
candidates:
current.queryType === queryType ? current.candidates : [],
selectedCandidateId:
current.queryType === queryType ? current.selectedCandidateId : null,
references:
current.queryType === queryType ? current.references : [],
}))
}
className={cn(
"rounded-full border px-3 py-1.5 text-xs transition-colors",
detailReplacementQueryType === queryType
? "border-emerald-400/45 bg-emerald-500/[0.12] text-emerald-200"
: "border-white/10 bg-white/[0.03] text-white/58 hover:border-white/18 hover:text-white"
)}
>
按{queryType === "spu" ? "SPU" : "SKU"}查询
</button>
))}
</div>
<div className="mb-3 flex flex-col gap-2 sm:flex-row">
<Textarea
value={detailReplacementQueryInput}
onChange={(e) =>
updateDetailReplacementState({
query: e.target.value,
})
}
placeholder={`输入${detailReplacementQueryType === "spu" ? "SPU" : "SKU"},查询任务-详情整套中的详情参考图`}
className="min-h-[68px] resize-none rounded-[22px] border-white/10 bg-black/20 text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30"
/>
<Button
type="button"
onClick={handleDetailReplacementSearch}
disabled={detailReplacementSearchState.isLoading}
className="h-auto min-h-[68px] rounded-[22px] bg-white/[0.04] px-5 text-sm text-white hover:bg-white/[0.08]"
>
{detailReplacementSearchState.isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
查询中
</>
) : (
"查询详情图"
)}
</Button>
{detailReplacementReferenceCards.length > 0 ? (
<Button
type="button"
variant="outline"
onClick={handleClearDetailReferenceCards}
className="h-auto min-h-[68px] rounded-[22px] border-white/10 bg-white/[0.03] px-5 text-sm text-white hover:bg-white/[0.08]"
>
清空参考图
</Button>
) : null}
</div>
{(detailReplacementSearchState.lastQuery || detailReplacementQueryInput.trim()) ? (
<div className="mb-3 rounded-[18px] border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-white/48">
当前参考图查询:
{detailReplacementQueryType === "spu" ? "SPU" : "SKU"} /
{detailReplacementSearchState.lastQuery ||
detailReplacementQueryInput.trim().toUpperCase()}
<span className="ml-2 text-emerald-200/70">
已接入真实详情参考图接口
</span>
</div>
) : null}
{detailReplacementCandidates.length > 0 ? (
<div className="mb-3">
<div className="mb-2 text-xs text-white/42">
候选整套素材 {detailReplacementCandidates.length} 套
</div>
<div className="agent-dark-scrollbar flex gap-2 overflow-x-auto pb-1">
{detailReplacementCandidates.map((candidate) => {
const isSelected = detailReplacementSelectedCandidateId === candidate.id;
return (
<button
key={candidate.id}
type="button"
onClick={() => handleSelectDetailReplacementCandidate(candidate.id)}
className={cn(
"min-w-[220px] rounded-[20px] border px-3 py-2 text-left transition-colors",
isSelected
? "border-emerald-400/45 bg-emerald-500/[0.10]"
: "border-white/10 bg-white/[0.03] hover:border-white/18"
)}
>
<div className="truncate text-sm font-medium text-white">
{candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
</div>
<div className="mt-1 line-clamp-2 text-[11px] text-white/42">
{formatCandidateMeta(candidate, "detail") || "点击切换这套详情参考图"}
</div>
</button>
);
})}
</div>
</div>
) : null}
<div className="agent-dark-scrollbar max-h-[620px] space-y-3 overflow-y-auto pr-1">
{detailReplacementReferenceCards.length > 0 ? (
detailReplacementReferenceCards.map((card, index) => (
<div
key={card.id}
className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20"
>
<div className="relative aspect-[5/3] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.12),transparent_55%),linear-gradient(180deg,#0b0d0f_0%,#101316_100%)]">
<button
type="button"
onClick={() => handleRemoveDetailReferenceCard(card.id)}
className="absolute right-3 top-3 rounded-full border border-white/10 bg-black/40 px-2 py-1 text-[11px] text-white/70 transition-colors hover:border-white/20 hover:text-white"
>
删除
</button>
{card.imageUrl ? (
<img
src={buildPreviewImageUrl(card.imageUrl, 720)}
alt={card.label}
className="h-full w-full object-cover"
/>
) : null}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/35 to-transparent px-4 py-3">
<div className="text-sm font-medium text-white/88">{card.label}</div>
<div className="text-xs text-white/45">
第 {index + 1} 张详情参考图
</div>
</div>
</div>
<div className="border-t border-white/10 px-3 py-2 text-[11px] text-white/45">
删除某张后,后面的参考图会自动上移补位,不留空位。
</div>
</div>
))
) : (
<div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center">
<div className="text-sm font-medium text-white/72">等待详情参考图</div>
<div className="mt-2 text-xs leading-6 text-white/40">
输入 SPU 或 SKU 后,这里会展示任务-详情整套里的真实详情参考图。
删除不需要的参考图后,列表会自动上移补位。
</div>
</div>
)}
</div>
</div>
<div className="min-h-0 p-4">
<div className="mb-3 flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-white">替换结果</div>
<div className="text-xs text-white/40">
每张详情参考图将对应生成一张替换结果,并支持逐屏文字编辑。
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-emerald-200/80">
已接通
</div>
</div>
<div className="agent-dark-scrollbar max-h-[720px] space-y-3 overflow-y-auto pr-1">
{detailReplacementReferenceCards.length > 0 ? (
detailReplacementReferenceCards.map((card, index) => (
<div
key={`${card.id}-result`}
className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20"
>
{latestReplacementResultUrls[index] ? (
<div className="relative aspect-[5/3] overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(latestReplacementResultUrls[index]!, 720)}
alt={`结果图 ${index + 1}`}
className="h-full w-full cursor-zoom-in object-cover"
onClick={() => setFullscreenPreview(latestReplacementResultUrls[index]!)}
/>
<button
type="button"
onClick={() => {
void handleDownloadOriginalImage(latestReplacementResultUrls[index]!);
}}
className="absolute bottom-3 right-3 rounded-full border border-white/10 bg-black/55 px-3 py-1 text-[11px] text-white transition-colors hover:border-white/20"
>
下载
</button>
<button
type="button"
onClick={() =>
handleSendResultToCanvasTextEdit({
imageUrl: latestReplacementResultUrls[index]!,
title: `${selectedAgentPreset?.name || "详情替换"} 结果图 ${index + 1}`,
})
}
className="absolute bottom-3 left-3 rounded-full border border-white/10 bg-black/55 px-3 py-1 text-[11px] text-white transition-colors hover:border-white/20"
>
送入画布改字
</button>
</div>
) : (
<div className="flex aspect-[5/3] items-center justify-center bg-white/[0.02]">
<div className="space-y-2 px-4 text-center">
<div className="text-sm font-medium text-white/70">
结果图 {index + 1}
</div>
<div className="text-xs text-white/35">
将按“输入图组 + 第 {index + 1} 张详情参考图”生成
</div>
</div>
</div>
)}
<div className="border-t border-white/10 px-3 py-2 text-[11px] text-white/45">
{latestReplacementResultUrls[index]
? "已生成,可继续逐屏调整文字或重新执行。"
: "生成完成后,这里会接入现有无限画布的文字编辑能力。"}
</div>
</div>
))
) : (
<div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center">
<div className="text-sm font-medium text-white/72">等待替换结果</div>
<div className="mt-2 text-xs leading-6 text-white/40">
详情参考图加载后,右侧会按相同顺序生成对应结果图,并逐屏支持文字编辑。
</div>
</div>
)}
</div>
</div>
</div>
) : isSkuReplacementPreset ? (
<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">
<div>
<div className="text-sm font-semibold text-white">输入图</div>
<div className="text-xs text-white/40">
选中的白底图会和 SKU 参考图建立一对一替换关系
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55">
{selectedProductAttachments.length} 张
</div>
</div>
<div className="space-y-3">
{selectedProductAttachments.length > 0 ? (
selectedProductAttachments.map((attachment, index) => (
<div
key={`${attachment?.url || "sku-input"}-${index}`}
className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20"
>
<div className="aspect-square overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(attachment!.url, 400)}
alt={attachment!.name || `输入图 ${index + 1}`}
className="h-full w-full object-cover"
/>
</div>
<div className="border-t border-white/10 px-3 py-2">
<div className="text-xs font-medium text-white/80">
输入图 {index + 1}
</div>
<div className="truncate text-[11px] text-white/40">
{attachment?.name || "已选择白底图"}
</div>
</div>
</div>
))
) : (
<div className="flex min-h-[160px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-4 text-center text-sm text-white/42">
先在上方“图片槽位”中选择用于 SKU 替换的白底输入图。
</div>
)}
{selectedModelAttachment ? (
<div className="rounded-[22px] border border-emerald-400/20 bg-emerald-500/[0.06] p-3">
<div className="text-xs font-medium text-emerald-200">模特图</div>
<div className="mt-1 truncate text-[11px] text-emerald-100/70">
{selectedModelAttachment.name || "已选择模特图"}
</div>
</div>
) : null}
</div>
</div>
<div className="min-h-0 border-b border-white/10 p-4 xl:border-b-0 xl:border-r">
<div className="mb-3 flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-white">SKU 参考图</div>
<div className="text-xs text-white/40">
支持按 SKU 或 SPU 查询任务-详情整套候选,并切换对应的 SKU 参考图。
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55">
{skuReplacementReferenceCards.length} 张
</div>
</div>
<div className="mb-3 flex flex-wrap gap-2">
{(["sku", "spu"] as const).map((queryType) => (
<button
key={queryType}
type="button"
onClick={() =>
updateSkuReplacementState((current) => ({
...current,
queryType,
candidates:
current.queryType === queryType ? current.candidates : [],
selectedCandidateId:
current.queryType === queryType ? current.selectedCandidateId : null,
references:
current.queryType === queryType ? current.references : [],
}))
}
className={cn(
"rounded-full border px-3 py-1.5 text-xs transition-colors",
skuReplacementQueryType === queryType
? "border-emerald-400/45 bg-emerald-500/[0.12] text-emerald-200"
: "border-white/10 bg-white/[0.03] text-white/58 hover:border-white/18 hover:text-white"
)}
>
按{queryType === "spu" ? "SPU" : "SKU"}查询
</button>
))}
</div>
<div className="mb-3 flex flex-col gap-2 sm:flex-row">
<Textarea
value={skuReplacementQueryInput}
onChange={(e) =>
updateSkuReplacementState({
query: e.target.value,
})
}
placeholder={`输入${skuReplacementQueryType === "spu" ? "SPU" : "SKU"},查询任务-详情整套中的 SKU 参考图`}
className="min-h-[68px] resize-none rounded-[22px] border-white/10 bg-black/20 text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30"
/>
<Button
type="button"
onClick={handleSkuReplacementSearch}
disabled={skuReplacementSearchState.isLoading}
className="h-auto min-h-[68px] rounded-[22px] bg-white/[0.04] px-5 text-sm text-white hover:bg-white/[0.08]"
>
{skuReplacementSearchState.isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
查询中
</>
) : (
"查询 SKU 图"
)}
</Button>
{skuReplacementReferenceCards.length > 0 ? (
<Button
type="button"
variant="outline"
onClick={handleClearSkuReferenceCards}
className="h-auto min-h-[68px] rounded-[22px] border-white/10 bg-white/[0.03] px-5 text-sm text-white hover:bg-white/[0.08]"
>
清空参考图
</Button>
) : null}
</div>
{(skuReplacementSearchState.lastQuery || skuReplacementQueryInput.trim()) ? (
<div className="mb-3 rounded-[18px] border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-white/48">
当前参考图查询:
{skuReplacementQueryType === "spu" ? "SPU" : "SKU"} /
{skuReplacementSearchState.lastQuery ||
skuReplacementQueryInput.trim().toUpperCase()}
<span className="ml-2 text-emerald-200/70">
已接入真实 SKU 参考图接口
</span>
</div>
) : null}
{skuReplacementCandidates.length > 0 ? (
<div className="mb-3">
<div className="mb-2 text-xs text-white/42">
候选整套素材 {skuReplacementCandidates.length} 套
</div>
<div className="agent-dark-scrollbar flex gap-2 overflow-x-auto pb-1">
{skuReplacementCandidates.map((candidate) => {
const isSelected = skuReplacementSelectedCandidateId === candidate.id;
return (
<button
key={candidate.id}
type="button"
onClick={() => handleSelectSkuReplacementCandidate(candidate.id)}
className={cn(
"min-w-[220px] rounded-[20px] border px-3 py-2 text-left transition-colors",
isSelected
? "border-emerald-400/45 bg-emerald-500/[0.10]"
: "border-white/10 bg-white/[0.03] hover:border-white/18"
)}
>
<div className="truncate text-sm font-medium text-white">
{candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
</div>
<div className="mt-1 line-clamp-2 text-[11px] text-white/42">
{formatCandidateMeta(candidate, "sku") || "点击切换这套 SKU 参考图"}
</div>
</button>
);
})}
</div>
</div>
) : null}
<div className="agent-dark-scrollbar max-h-[620px] space-y-3 overflow-y-auto pr-1">
{skuReplacementReferenceCards.length > 0 ? (
skuReplacementReferenceCards.map((card, index) => (
<div
key={card.id}
className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20"
>
{(() => {
const resolvedAssignedProductIndex =
selectedProductAttachments.length > 0
? Math.min(
typeof card.assignedProductIndex === "number"
? card.assignedProductIndex
: index,
Math.max(selectedProductAttachments.length - 1, 0)
)
: null;
return (
<>
<div className="relative aspect-[5/3] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.12),transparent_55%),linear-gradient(180deg,#0b0d0f_0%,#101316_100%)]">
<button
type="button"
onClick={() => handleRemoveSkuReferenceCard(card.id)}
className="absolute right-3 top-3 rounded-full border border-white/10 bg-black/40 px-2 py-1 text-[11px] text-white/70 transition-colors hover:border-white/20 hover:text-white"
>
删除
</button>
{card.imageUrl ? (
<img
src={buildPreviewImageUrl(card.imageUrl, 720)}
alt={card.label}
className="h-full w-full object-cover"
/>
) : null}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/35 to-transparent px-4 py-3">
<div className="text-sm font-medium text-white/88">{card.label}</div>
<div className="text-xs text-white/45">
第 {index + 1} 张 SKU 参考图
</div>
</div>
</div>
<div className="border-t border-white/10 px-3 py-2">
{selectedProductAttachments.length > 0 ? (
<>
<div className="mb-2 text-[11px] text-white/45">
当前对应输入图:
{typeof resolvedAssignedProductIndex === "number"
? `输入图 ${resolvedAssignedProductIndex + 1}`
: "未指定"}
</div>
<div className="flex flex-wrap gap-2">
{selectedProductAttachments.map((_, productIndex) => (
<button
key={`${card.id}-product-${productIndex}`}
type="button"
onClick={() =>
handleAssignSkuReferenceProduct(card.id, productIndex)
}
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] transition-colors",
resolvedAssignedProductIndex === productIndex
? "border-emerald-400/45 bg-emerald-500/[0.12] text-emerald-200"
: "border-white/10 bg-white/[0.03] text-white/58 hover:border-white/20 hover:text-white"
)}
>
输入图 {productIndex + 1}
</button>
))}
</div>
</>
) : null}
<div className={cn("text-[11px] text-white/45", selectedProductAttachments.length > 0 ? "mt-2" : "")}>
删除某张后,后面的参考图会自动上移补位,不留空位。
</div>
</div>
</>
);
})()}
</div>
))
) : (
<div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center">
<div className="text-sm font-medium text-white/72">等待 SKU 参考图</div>
<div className="mt-2 text-xs leading-6 text-white/40">
输入 SKU 或 SPU 后,这里会展示任务-详情整套里的真实 SKU 参考图。
选择多少张图,就会按相同数量进入替换流程。
</div>
</div>
)}
</div>
</div>
<div className="min-h-0 p-4">
<div className="mb-3 flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-white">替换结果</div>
<div className="text-xs text-white/40">
每张 SKU 参考图会对应一张结果图,形成一对一批量替换结果。
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-emerald-200/80">
已接通
</div>
</div>
<div className="agent-dark-scrollbar max-h-[720px] space-y-3 overflow-y-auto pr-1">
{skuReplacementReferenceCards.length > 0 ? (
skuReplacementReferenceCards.map((card, index) => (
<div
key={`${card.id}-result`}
className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20"
>
{latestReplacementResultUrls[index] ? (
<div className="relative aspect-[5/3] overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(latestReplacementResultUrls[index]!, 720)}
alt={`结果图 ${index + 1}`}
className="h-full w-full cursor-zoom-in object-cover"
onClick={() => setFullscreenPreview(latestReplacementResultUrls[index]!)}
/>
<button
type="button"
onClick={() => {
void handleDownloadOriginalImage(latestReplacementResultUrls[index]!);
}}
className="absolute bottom-3 right-3 rounded-full border border-white/10 bg-black/55 px-3 py-1 text-[11px] text-white transition-colors hover:border-white/20"
>
下载
</button>
<button
type="button"
onClick={() =>
handleSendResultToCanvasTextEdit({
imageUrl: latestReplacementResultUrls[index]!,
title: `${selectedAgentPreset?.name || "SKU替换"} 结果图 ${index + 1}`,
})
}
className="absolute bottom-3 left-3 rounded-full border border-white/10 bg-black/55 px-3 py-1 text-[11px] text-white transition-colors hover:border-white/20"
>
送入画布改字
</button>
</div>
) : (
<div className="flex aspect-[5/3] items-center justify-center bg-white/[0.02]">
<div className="space-y-2 px-4 text-center">
<div className="text-sm font-medium text-white/70">
结果图 {index + 1}
</div>
<div className="text-xs text-white/35">
将按“输入图 + 第 {index + 1} 张 SKU 参考图”生成
</div>
</div>
</div>
)}
<div className="border-t border-white/10 px-3 py-2 text-[11px] text-white/45">
{latestReplacementResultUrls[index]
? "已生成,可继续逐张检查并后续批量下载。"
: "生成完成后,这里会接入统一文字编辑和批量下载能力。"}
</div>
</div>
))
) : (
<div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center">
<div className="text-sm font-medium text-white/72">等待替换结果</div>
<div className="mt-2 text-xs leading-6 text-white/40">
SKU 参考图加载后,右侧会按相同顺序生成一对一替换结果,并支持批量下载。
</div>
</div>
)}
</div>
</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>
) : displayedResultUrl ? (
<motion.div
key="result"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="relative flex flex-1 items-center justify-center p-6"
>
<img
src={buildPreviewImageUrl(displayedResultUrl, 1400)}
alt="生成结果"
className="max-h-full max-w-full cursor-zoom-in rounded-[24px] object-contain"
onClick={() => setFullscreenPreview(displayedResultUrl)}
/>
<button
onClick={() => {
void handleDownloadOriginalImage(displayedResultUrl);
}}
className="absolute bottom-6 right-6 flex h-10 items-center gap-2 rounded-full bg-white/10 px-4 text-sm text-white backdrop-blur-md transition-colors hover:bg-white/20"
>
<Download className="h-4 w-4" />
下载
</button>
</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
? "顶部流程线配置完成后,切到“生成参数”即可开始生成"
: "请先从顶部流程线选择一个生图模式"}
</p>
</motion.div>
)}
</AnimatePresence>
{allGeneratedImages.length > 0 ? (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
className="border-t border-white/12 bg-black/20 px-4 py-3"
>
<div className="flex gap-2 overflow-x-auto pb-1">
{allGeneratedImages.map((url) => (
<button
key={url}
onClick={() => setSelectedResultUrl(url)}
className={cn(
"relative flex-shrink-0 overflow-hidden rounded-[24px] transition-all",
displayedResultUrl === url
? "ring-2 ring-emerald-400"
: "ring-1 ring-white/12 hover:ring-white/30"
)}
>
<img
src={buildPreviewImageUrl(url, 240)}
alt="缩略图"
className="h-20 w-20 object-cover"
/>
</button>
))}
</div>
</motion.div>
) : null}
</>
)}
</motion.section>
</div>
</main>
{/* Fullscreen Preview */}
<AnimatePresence>
{fullscreenPreview && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/95 backdrop-blur-sm"
onClick={() => setFullscreenPreview(null)}
>
<img
src={fullscreenPreview}
alt="预览"
className="max-h-[95vh] max-w-[95vw] object-contain"
onClick={(e) => e.stopPropagation()}
/>
<button
onClick={() => setFullscreenPreview(null)}
className="absolute right-6 top-6 flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur-md transition-colors hover:bg-white/20"
>
<X className="h-6 w-6" />
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}