ai-chatbot/components/agents/AgentGenerationWorkspaceDarkV2.tsx
Your Name aa885a3d48
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: persist PSD editor uploads and results
2026-04-10 20:17:51 +08:00

6167 lines
261 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,
type SyntheticEvent,
} from "react";
import useSWR from "swr";
import { motion, AnimatePresence } from "framer-motion";
import JSZip from "jszip";
import {
ArrowUp,
ChevronDown,
ChevronUp,
Download,
ImagePlus,
Loader2,
Maximize2,
Sparkles,
Upload,
UserRound,
Trash2,
X,
Wand2,
Settings2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ClientErrorBoundary } from "@/components/ui/client-error-boundary";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
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 { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog";
import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog";
import { ImageAnnotator } from "@/components/image-annotator";
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 {
AGENT_DETAIL_BUNDLE_SLOT_INDEXES,
AGENT_DETAIL_FIELD_EXAMPLES,
AGENT_DETAIL_MODEL_SLOT_INDEX,
AGENT_DETAIL_PRODUCT_SLOT_INDEXES,
AGENT_DETAIL_SLOT_CONFIGS,
AGENT_DETAIL_TARGET_HEIGHT,
AGENT_DETAIL_TARGET_WIDTH,
createEmptyAgentDetailBriefForm,
createEmptyAgentDetailSlots,
extractAgentDetailFrameCount,
normalizeAgentDetailGenerationMode,
sanitizeAgentDetailBrief,
sanitizeDetailImageModelPrompt,
} from "@/lib/agents/detail-generation-config";
import {
normalizeAgentPresetModeConfig,
} from "@/lib/agents/preset-mode-config";
import { buildAgentSceneImageRolePrompt } from "@/lib/agents/scene-image-role-prompt";
import type { AgentPsdTemplateMode } from "@/lib/agents/psd-template-config";
import type { Attachment, ChatMessage } from "@/lib/types";
import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils";
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
import type {
AgentDetailBriefForm,
AgentDetailGenerationProgressState,
AgentGenerationProjectWorkspaceState,
AgentGenerationProjectResultCollections,
AgentGenerationResultModeType,
AgentModeReferenceCandidate,
AgentModeWorkspaceState,
AgentPreset,
AgentTaskDetailReferenceSource,
AgentWorkflowShowcaseConfig,
AgentWorkflowComposerContext,
AgentWorkflowMaterialQueryType,
AgentWorkflowSelectedBrandModel,
} from "@/types/agent";
import {
createEmptyAgentModeWorkspaceState,
} from "@/lib/agents/mode-workspace-state";
import {
createEmptyAgentGenerationProjectWorkspaceState,
normalizeAgentGenerationProjectPersistedState,
} from "@/lib/agents/generation-project-state";
interface AgentGenerationWorkspaceDarkV2Props {
chatId: string;
projectId: string;
selectedAgentPresetId?: string | null;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
workflowContext?: AgentWorkflowComposerContext | null;
workflowPromptOptimizationEnabled?: boolean;
getLatestWorkflowContext?: () => AgentWorkflowComposerContext | null;
onBack?: () => void;
presetPanel?: ReactNode;
brandStorePanel?: 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;
workspaceState?: AgentGenerationProjectWorkspaceState | null;
onWorkspaceStateChange?: (next: AgentGenerationProjectWorkspaceState) => void;
resultCollections?: AgentGenerationProjectResultCollections | null;
deletedResultCollections?: AgentGenerationProjectResultCollections | null;
onResultPreviewUrlsChange?: (
modeType: AgentGenerationResultModeType,
urls: string[]
) => void;
onDeleteResultImage?: (
modeType: AgentGenerationResultModeType,
imageUrl: string
) => void;
topNavRightSlot?: ReactNode;
showcaseConfig?: AgentWorkflowShowcaseConfig | null;
workflowStylePanel?: ReactNode;
}
interface PickedAttachment extends Attachment {}
const MAX_CHAT_TEXT_PART_LENGTH = 2000;
const MAX_CHAT_HIDDEN_PROMPT_LENGTH = 4000;
const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100;
const MAX_AGENT_MODE_QUERY_LENGTH = 64;
const MAX_AGENT_MODE_REFERENCE_COUNT = 40;
const CHAT_UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const ALLOWED_CHAT_MEDIA_TYPES = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
"image/jpg",
"image/bmp",
"image/svg+xml",
]);
function isValidChatUuid(value?: string | null): value is string {
return Boolean(value && CHAT_UUID_REGEX.test(String(value).trim()));
}
function isValidChatHttpUrl(value?: string | null): value is string {
if (!value) return false;
try {
const parsed = new URL(String(value).trim());
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
function clampChatRequestText(value?: string | null, maxLength = MAX_CHAT_TEXT_PART_LENGTH): string {
return String(value || "").slice(0, maxLength);
}
function normalizeChatAttachmentName(value?: string | null): string {
const normalized = String(value || "").trim() || "参考图";
return normalized.slice(0, MAX_CHAT_ATTACHMENT_NAME_LENGTH);
}
function normalizeChatMediaType(value?: string | null): string {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "image/pjpeg") return "image/jpeg";
if (normalized === "image/x-png") return "image/png";
if (ALLOWED_CHAT_MEDIA_TYPES.has(normalized)) {
return normalized;
}
return "image/jpeg";
}
function getPsdTemplateModeByResultMode(modeType: AgentGenerationResultModeType): AgentPsdTemplateMode {
return modeType === "sku_replacement" ? "sku" : "main";
}
function sanitizeChatAttachment(
attachment?: PickedAttachment | null,
): PickedAttachment | null {
if (!attachment || !isValidChatHttpUrl(attachment.url)) {
return null;
}
return {
...attachment,
url: attachment.url,
name: normalizeChatAttachmentName(attachment.name),
contentType: normalizeChatMediaType(attachment.contentType),
assetId: isValidChatUuid(attachment.assetId) ? attachment.assetId : undefined,
};
}
interface GeneratedImageGroup {
messageId: string;
imageUrls: string[];
text: string;
}
interface AgentModeReferenceSearchResponse {
success?: boolean;
data?: {
query: string;
queryType: AgentWorkflowMaterialQueryType;
target: AgentTaskDetailReferenceSource;
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;
};
}
function getTaskDetailReferenceSourceLabel(source: AgentTaskDetailReferenceSource) {
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 excludeDeletedResultUrls(
urls: string[] | undefined | null,
deletedUrls: string[] | undefined | null
) {
const deletedUrlSet = new Set(normalizeResultCollectionUrls(deletedUrls));
return normalizeResultCollectionUrls(urls).filter((url) => !deletedUrlSet.has(url));
}
function getResultModeLabel(modeType: AgentGenerationResultModeType) {
return modeType === "detail_generation"
? "AI详情"
: modeType === "detail_replacement"
? "参考图替换"
: modeType === "sku_replacement"
? "SKU替换"
: "场景图";
}
function areImageGenerationParamsEqual(
left: ImageGenerationParams,
right: ImageGenerationParams
) {
return (
left.aspectRatio === right.aspectRatio &&
left.imageSize === right.imageSize &&
(left.imageCount || 1) === (right.imageCount || 1) &&
left.size === right.size &&
left.negativePrompt === right.negativePrompt &&
left.promptExtend === right.promptExtend &&
left.watermark === right.watermark &&
left.seed === right.seed
);
}
function arePickedAttachmentsEqual(
left: Array<PickedAttachment | null>,
right: Array<PickedAttachment | null>
) {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
const leftItem = left[index];
const rightItem = right[index];
if (!leftItem && !rightItem) continue;
if (!leftItem || !rightItem) return false;
if (
leftItem.url !== rightItem.url ||
leftItem.name !== rightItem.name ||
leftItem.contentType !== rightItem.contentType ||
leftItem.assetId !== rightItem.assetId
) {
return false;
}
}
return true;
}
function areAttachmentValuesEqual(
left:
| PickedAttachment
| {
url?: string | null;
name?: string | null;
contentType?: string | null;
assetId?: string | null;
}
| null
| undefined,
right:
| PickedAttachment
| {
url?: string | null;
name?: string | null;
contentType?: string | null;
assetId?: string | null;
}
| null
| undefined
) {
return (
String(left?.url || "").trim() === String(right?.url || "").trim() &&
String(left?.name || "").trim() === String(right?.name || "").trim() &&
String(left?.contentType || "").trim() === String(right?.contentType || "").trim() &&
String(left?.assetId || "").trim() === String(right?.assetId || "").trim()
);
}
function mergePersistedHistoryMessages(
currentMessages: ChatMessage[],
persistedMessages: ChatMessage[]
) {
if (persistedMessages.length === 0) {
return currentMessages;
}
if (currentMessages.length === 0) {
return persistedMessages;
}
const persistedIds = new Set(persistedMessages.map((message) => message.id));
const extraLocalMessages = currentMessages.filter(
(message) => !persistedIds.has(message.id)
);
const mergedMessages = [...persistedMessages, ...extraLocalMessages];
if (
mergedMessages.length === currentMessages.length &&
mergedMessages.every((message, index) => currentMessages[index]?.id === message.id)
) {
return currentMessages;
}
return mergedMessages;
}
function getAttachmentIdentityKey(
attachment:
| PickedAttachment
| {
url?: string | null;
assetId?: string | null;
}
| null
| undefined
) {
const assetId = String(attachment?.assetId || "").trim();
if (assetId) {
return `asset:${assetId}`;
}
const url = String(attachment?.url || "").trim();
return url ? `url:${url}` : null;
}
function toWorkflowReferenceAttachment(params: {
url?: string | null;
name?: string | null;
label?: string | null;
mimeType?: string | null;
assetId?: string | null;
}): PickedAttachment | null {
const url = String(params.url || "").trim();
if (!isValidChatHttpUrl(url)) {
return null;
}
return {
url,
name: normalizeChatAttachmentName(params.name || params.label || "参考图"),
contentType: normalizeChatMediaType(params.mimeType || "image/png"),
assetId: isValidChatUuid(params.assetId) ? params.assetId || undefined : undefined,
};
}
type ToolStepKey =
| "preset"
| "brand-model"
| "materials"
| "prompt"
| "settings";
function buildPreviewImageUrl(url: string, width: number) {
return buildImageProxyUrl(url, { width, quality: 86, format: "webp" });
}
function handlePreviewImageLoadError(
event: SyntheticEvent<HTMLImageElement, Event>,
originalUrl: string
) {
const fallbackUrl = String(originalUrl || "").trim();
if (!fallbackUrl) {
return;
}
const image = event.currentTarget;
if (image.dataset.previewFallbackApplied === "1") {
return;
}
image.dataset.previewFallbackApplied = "1";
image.src = fallbackUrl;
}
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 hasLikelyGenerationErrorText(value: string | null | undefined) {
const normalized = normalizeAssistantText(String(value || ""));
if (!normalized) {
return false;
}
return /失败|超时|重试|error|failed/i.test(normalized);
}
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;
});
}
type DirectDetailGenerationImage = {
originalUrl: string;
thumbnailUrl: string;
title?: string;
subtitle?: string;
copy?: string;
};
type DirectDetailReviewResult = {
reviewAction?: "pass" | "protocol_patch" | "regenerate_image";
revisedPrompt?: string;
issues?: string[];
uiValidation?: {
passed?: boolean;
issues?: string[];
};
};
const AGENT_IMAGE_TASK_POLL_INTERVAL_MS = 1600;
const AGENT_IMAGE_TASK_TIMEOUT_MS = 4 * 60 * 1000;
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function requestCanvasImageGeneration(
payload: {
prompt: string;
model?: string;
aspectRatio?: string;
imageSize?: string;
imageCount?: number;
negativePrompt?: string;
referenceImage?: string;
referenceImages?: string[];
}
): Promise<{ images: Array<{ originalUrl?: string; thumbnailUrl?: string }> }> {
const createResponse = await fetch("/api/canvas/generate/image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...payload,
async: true,
}),
});
const createResult = await createResponse.json().catch(() => ({}));
if (!createResponse.ok || !createResult?.success) {
throw new Error(createResult?.error || "创建生图任务失败");
}
if (Array.isArray(createResult?.data?.images) && createResult.data.images.length > 0) {
return { images: createResult.data.images };
}
const taskId = String(createResult?.taskId || "").trim();
if (!taskId) {
throw new Error("创建生图任务失败:未返回任务ID");
}
const deadline = Date.now() + AGENT_IMAGE_TASK_TIMEOUT_MS;
let lastError = "";
while (Date.now() < deadline) {
await sleep(AGENT_IMAGE_TASK_POLL_INTERVAL_MS);
const statusResponse = await fetch(
`/api/canvas/generate/image?taskId=${encodeURIComponent(taskId)}`,
{ cache: "no-store" }
);
const statusResult = await statusResponse.json().catch(() => ({}));
if (!statusResponse.ok || !statusResult?.success) {
lastError = statusResult?.error || `查询任务失败(${statusResponse.status})`;
continue;
}
const task = statusResult?.task;
const status = String(task?.status || "").trim();
if (status === "succeeded") {
const result = task?.result || {};
const images = Array.isArray(result?.images) ? result.images : [];
if (images.length === 0) {
throw new Error("任务完成但未返回图片");
}
return { images };
}
if (status === "failed") {
throw new Error(String(task?.error || "图片生成失败"));
}
}
throw new Error(lastError || "图片生成超时,请稍后重试");
}
function buildReferenceCardsFromCandidate(params: {
candidate: AgentModeReferenceCandidate | null | undefined;
target: AgentTaskDetailReferenceSource;
productCount?: number;
assignProductIndex?: boolean;
}) {
const candidate = params.candidate;
if (!candidate) return [] as AgentModeWorkspaceState["detailReplacement"]["references"];
const rawUrls =
params.target === "main"
? candidate.mainImageUrls || []
: params.target === "detail"
? candidate.detailImageUrls || []
: candidate.skuImageUrls || [];
const urls = rawUrls;
const targetLabel = `${getTaskDetailReferenceSourceLabel(params.target)}参考图`;
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.assignProductIndex === true && (params.productCount || 0) > 0
? Math.min(index, Math.max((params.productCount || 1) - 1, 0))
: null,
}));
}
function formatCandidateMeta(candidate: AgentModeReferenceCandidate, target: AgentTaskDetailReferenceSource) {
const rawImageCount =
target === "main"
? candidate.mainImageUrls.length
: target === "detail"
? candidate.detailImageUrls.length
: candidate.skuImageUrls.length;
const imageCount = rawImageCount;
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(" · ");
}
function getCandidatePreviewUrl(
candidate: AgentModeReferenceCandidate,
target: AgentTaskDetailReferenceSource
) {
const rawTargetUrls =
target === "main"
? candidate.mainImageUrls || []
: target === "detail"
? candidate.detailImageUrls || []
: candidate.skuImageUrls || [];
const targetUrls = rawTargetUrls;
return (
targetUrls[0] ||
candidate.mainImageUrls[0] ||
candidate.allImageUrls[0] ||
null
);
}
function getCandidatePreviewUrls(
candidate: AgentModeReferenceCandidate,
target: AgentTaskDetailReferenceSource,
maxCount = 4
) {
const rawTargetUrls =
target === "main"
? candidate.mainImageUrls || []
: target === "detail"
? candidate.detailImageUrls || []
: candidate.skuImageUrls || [];
const targetUrls = rawTargetUrls;
const fallbackUrls = [
...targetUrls,
...(candidate.mainImageUrls || []),
...(candidate.allImageUrls || []),
];
return Array.from(new Set(fallbackUrls.filter(Boolean))).slice(0, maxCount);
}
function getCandidateTargetCount(
candidate: AgentModeReferenceCandidate,
target: AgentTaskDetailReferenceSource
) {
const rawCount =
target === "main"
? candidate.mainImageUrls.length
: target === "detail"
? candidate.detailImageUrls.length
: candidate.skuImageUrls.length;
return rawCount;
}
type ReplacementCardStatus =
| "waiting"
| "queued"
| "generating"
| "completed"
| "passthrough"
| "fallback"
| "missing";
function inferReplacementCardStatusFromText(text: string | null | undefined): ReplacementCardStatus | null {
const normalized = normalizeAssistantText(String(text || ""))
.replace(/\s+/g, " ")
.trim();
if (!normalized) {
return null;
}
if (normalized.includes("生成失败,当前先沿用参考图")) {
return "fallback";
}
if (
normalized.includes("沿用参考图") ||
normalized.includes("未检测到可替换的家具主体") ||
normalized.includes("视觉判断失败")
) {
return "passthrough";
}
return "completed";
}
function getReplacementCardStatus(params: {
index: number;
resultUrl: string | null;
resultText?: string | null;
generatedCount: number;
isGenerating: boolean;
}) : ReplacementCardStatus {
if (params.resultUrl) {
return inferReplacementCardStatusFromText(params.resultText) || "completed";
}
if (params.isGenerating) {
return params.index === params.generatedCount ? "generating" : "queued";
}
if (params.generatedCount > 0 && params.index >= params.generatedCount) {
return "missing";
}
return "waiting";
}
function getReplacementCardStatusMeta(status: ReplacementCardStatus) {
switch (status) {
case "passthrough":
return {
label: "沿用参考图",
className: "border-cyan-400/30 bg-cyan-500/[0.10] text-cyan-200",
};
case "fallback":
return {
label: "失败已沿用",
className: "border-amber-400/30 bg-amber-500/[0.10] text-amber-200",
};
case "completed":
return {
label: "已生成",
className: "border-emerald-400/30 bg-emerald-500/[0.10] text-emerald-200",
};
case "generating":
return {
label: "生成中",
className: "border-sky-400/30 bg-sky-500/[0.10] text-sky-200",
};
case "queued":
return {
label: "排队中",
className: "border-white/12 bg-white/[0.05] text-white/60",
};
case "missing":
return {
label: "未回传",
className: "border-amber-400/30 bg-amber-500/[0.10] text-amber-200",
};
default:
return {
label: "待生成",
className: "border-white/12 bg-white/[0.05] text-white/60",
};
}
}
interface LocalGenerationPendingState {
chatId: string;
modeType: AgentGenerationResultModeType;
expectedCount: number;
userTurnCountAtStart: number;
timestamp: number;
}
const AGENT_PENDING_GENERATION_STORAGE_KEY = "ai-chatbot-agent-pending-generations";
const AGENT_PENDING_GENERATION_EXPIRY_MS = 30 * 60 * 1000;
const AGENT_SCENE_PENDING_FAILURE_GRACE_MS = 3 * 60 * 1000;
function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] {
if (typeof window === "undefined") {
return [];
}
try {
const raw = window.localStorage.getItem(AGENT_PENDING_GENERATION_STORAGE_KEY);
if (!raw) {
return [];
}
const now = Date.now();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((item) => {
if (!item || typeof item !== "object") {
return false;
}
const record = item as Record<string, unknown>;
const chatId = String(record.chatId || "").trim();
const modeType = String(record.modeType || "").trim();
const expectedCount = Number(record.expectedCount);
const userTurnCountAtStart = Number(record.userTurnCountAtStart);
const timestamp = Number(record.timestamp);
return (
Boolean(chatId) &&
(modeType === "scene_generation" ||
modeType === "detail_generation" ||
modeType === "detail_replacement" ||
modeType === "sku_replacement") &&
Number.isFinite(expectedCount) &&
expectedCount > 0 &&
Number.isFinite(userTurnCountAtStart) &&
Number.isFinite(timestamp) &&
now - timestamp < AGENT_PENDING_GENERATION_EXPIRY_MS
);
}) as LocalGenerationPendingState[];
} catch {
return [];
}
}
function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[]) {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(
AGENT_PENDING_GENERATION_STORAGE_KEY,
JSON.stringify(items)
);
} catch (error) {
console.warn(
"[AgentGenerationWorkspaceDarkV2] Failed to persist pending generation state:",
error
);
}
}
function AnimatedGenerationPlaceholderCard(params: {
title: string;
description: string;
badge: string;
badgeClassName?: string;
showSpinner?: boolean;
}) {
return (
<div className="relative h-full w-full overflow-hidden bg-[linear-gradient(180deg,#060809_0%,#101417_100%)]">
<motion.div
aria-hidden
className="absolute inset-[-42%] origin-center"
animate={{ rotate: 360 }}
transition={{ duration: 5.2, repeat: Infinity, ease: "linear" }}
>
<div className="h-full w-full bg-[conic-gradient(from_0deg,rgba(16,185,129,0)_0deg,rgba(16,185,129,0.96)_42deg,rgba(34,211,238,0.86)_120deg,rgba(251,191,36,0.72)_198deg,rgba(168,85,247,0.88)_272deg,rgba(16,185,129,0)_360deg)] opacity-95" />
</motion.div>
<div className="absolute inset-[1.5px] rounded-[20px] bg-[linear-gradient(180deg,#080a0c_0%,#12171a_100%)]" />
<motion.div
aria-hidden
className="absolute inset-[1.5px] rounded-[20px] bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.16),transparent_58%)]"
animate={{ opacity: [0.42, 0.72, 0.42] }}
transition={{ duration: 2.3, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.div
aria-hidden
className="absolute inset-[1.5px] rounded-[20px] opacity-70"
style={{
backgroundImage:
"linear-gradient(118deg, transparent 8%, rgba(255,255,255,0.08) 36%, transparent 62%)",
backgroundSize: "220% 100%",
}}
animate={{ backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"] }}
transition={{ duration: 3.6, repeat: Infinity, ease: "linear" }}
/>
<div className="relative z-10 flex h-full flex-col items-center justify-center gap-3 px-5 text-center">
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex rounded-full border px-3 py-1 text-[11px]",
params.badgeClassName || "border-white/12 bg-white/[0.05] text-white/65"
)}
>
{params.badge}
</span>
{params.showSpinner ? (
<Loader2 className="h-4 w-4 animate-spin text-emerald-200/80" />
) : null}
</div>
<div className="text-sm font-medium tracking-[0.02em] text-white/82">{params.title}</div>
<div className="max-w-[220px] text-xs leading-6 text-white/42">{params.description}</div>
</div>
</div>
);
}
export function AgentGenerationWorkspaceDarkV2({
chatId,
projectId,
selectedAgentPresetId,
selectedBrandModel = null,
workflowContext = null,
workflowPromptOptimizationEnabled = true,
getLatestWorkflowContext,
onBack,
presetPanel,
brandStorePanel,
materialSelectionPanel,
brandModelPanel,
onClearPreset,
siteName,
siteLogo,
siteLogoDark,
syncGeneratedResultsToCanvas = true,
agentModeState = null,
onAgentModeStateChange,
onOpenCanvasEditor,
workspaceState = null,
resultCollections = null,
deletedResultCollections = null,
onWorkspaceStateChange,
onResultPreviewUrlsChange,
onDeleteResultImage,
topNavRightSlot,
showcaseConfig,
workflowStylePanel,
}: AgentGenerationWorkspaceDarkV2Props) {
const darkSelectContentClass =
"border-white/10 bg-zinc-950 text-white shadow-2xl [--popover:hsl(240_10%_3.9%)] [--popover-foreground:hsl(0_0%_98%)] [--accent:rgba(255,255,255,0.08)] [--accent-foreground:hsl(0_0%_98%)] [--muted:rgba(255,255,255,0.04)] [--muted-foreground:rgba(255,255,255,0.56)]";
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 detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const hiddenPromptRef = useRef<string | null>(null);
const generatedImageKeysRef = useRef<Set<string>>(new Set());
const lastSyncedBrandModelUrlRef = useRef<string | null>(null);
const lastSyncedProductSlotKeysRef = useRef<Array<string | null>>([]);
const lastSyncedDetailProductSlotKeysRef = useRef<Array<string | null>>([]);
const lastSyncedDetailBundleSlotKeysRef = useRef<Array<string | null>>([]);
const lastSyncedDetailModelSlotKeyRef = useRef<string | null>(null);
const detailGenerationCancelRef = useRef(false);
const workspaceStateSignatureRef = useRef<string>("");
const generationRequestSnapshotRef = useRef<{
hiddenPrompt: string | null;
}>({
hiddenPrompt: null,
});
const [localAgentModeState, setLocalAgentModeState] = useState<AgentModeWorkspaceState>(
() => createEmptyAgentModeWorkspaceState()
);
const normalizedIncomingWorkspaceState = useMemo(
() =>
normalizeAgentGenerationProjectPersistedState({
workflowState: {},
workspaceState,
resultImageUrls: [],
}).workspaceState || createEmptyAgentGenerationProjectWorkspaceState(),
[workspaceState]
);
const [input, setInput] = useState(normalizedIncomingWorkspaceState.input);
const [attachments, setAttachments] = useState<Array<PickedAttachment | null>>(
normalizedIncomingWorkspaceState.attachments.map((item) =>
item
? {
url: item.url,
name: item.name || "参考图",
contentType: item.contentType || "image/png",
assetId: item.assetId || undefined,
}
: null
)
);
const [isAttachmentSlotExpanded, setIsAttachmentSlotExpanded] = useState(false);
const [isScenePromptExpanded, setIsScenePromptExpanded] = useState(false);
const [slotUploading, setSlotUploading] = useState<number | null>(null);
const [selectedModelId, setSelectedModelId] = useState<string>(
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 [sceneResizeEditorTargetUrl, setSceneResizeEditorTargetUrl] = useState<string | null>(null);
const [isPsdTemplateDialogOpen, setIsPsdTemplateDialogOpen] = useState(false);
const [psdTemplateDialogSourceUrl, setPsdTemplateDialogSourceUrl] = useState<string | null>(null);
const [psdTemplateDialogMode, setPsdTemplateDialogMode] = useState<AgentPsdTemplateMode>("main");
const [psdTemplateDialogResultModeType, setPsdTemplateDialogResultModeType] =
useState<AgentGenerationResultModeType>("scene_generation");
const [batchDownloadState, setBatchDownloadState] = useState<null | "scene" | "detail" | "sku">(null);
const [detailReplacementSearchState, setDetailReplacementSearchState] = useState<{
isLoading: boolean;
lastQuery: string | null;
}>({
isLoading: false,
lastQuery: null,
});
const [detailReferenceStitchDialogOpen, setDetailReferenceStitchDialogOpen] = useState(false);
const [activeDetailReferenceCardId, setActiveDetailReferenceCardId] = useState<string | null>(null);
const [skuReplacementSearchState, setSkuReplacementSearchState] = useState<{
isLoading: boolean;
lastQuery: string | null;
}>({
isLoading: false,
lastQuery: null,
});
const [activeSkuReferenceCardId, setActiveSkuReferenceCardId] = useState<string | null>(null);
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
aspectRatio: normalizedIncomingWorkspaceState.imageParams?.aspectRatio || "auto",
imageSize: normalizedIncomingWorkspaceState.imageParams?.imageSize || "2K",
imageCount: normalizedIncomingWorkspaceState.imageParams?.imageCount || 1,
});
const [pendingGeneration, setPendingGeneration] =
useState<LocalGenerationPendingState | null>(null);
const [detailGenerationRunResults, setDetailGenerationRunResults] = useState<
DirectDetailGenerationImage[]
>([]);
const closePsdTemplateDialog = useCallback(() => {
setIsPsdTemplateDialogOpen(false);
setPsdTemplateDialogSourceUrl(null);
setPsdTemplateDialogMode("main");
setPsdTemplateDialogResultModeType("scene_generation");
}, []);
const openPsdTemplateDialog = useCallback(
(params?: {
sourceImageUrl?: string | null;
mode?: AgentPsdTemplateMode | null;
resultModeType?: AgentGenerationResultModeType | null;
}) => {
setPsdTemplateDialogSourceUrl(
isValidChatHttpUrl(params?.sourceImageUrl)
? String(params?.sourceImageUrl).trim()
: null
);
setPsdTemplateDialogMode(
params?.mode === "sku" ? "sku" : params?.mode === "main" ? "main" : "main"
);
setPsdTemplateDialogResultModeType(
params?.resultModeType ||
activeResultModeType ||
"scene_generation"
);
setIsPsdTemplateDialogOpen(true);
},
[activeResultModeType]
);
const normalizedDeletedResultCollections = useMemo(
() => ({
scene_generation: normalizeResultCollectionUrls(
deletedResultCollections?.scene_generation
),
detail_generation: normalizeResultCollectionUrls(
deletedResultCollections?.detail_generation
),
detail_replacement: normalizeResultCollectionUrls(
deletedResultCollections?.detail_replacement
),
sku_replacement: normalizeResultCollectionUrls(
deletedResultCollections?.sku_replacement
),
}),
[deletedResultCollections]
);
useEffect(() => {
const restored = getStoredAgentPendingGenerations().find(
(item) => item.chatId === chatId
);
if (!restored) {
return;
}
setPendingGeneration((current) => current || restored);
}, [chatId]);
useEffect(() => {
const allItems = getStoredAgentPendingGenerations().filter(
(item) => item.chatId !== chatId
);
if (pendingGeneration) {
allItems.push(pendingGeneration);
}
saveStoredAgentPendingGenerations(allItems);
}, [chatId, pendingGeneration]);
const { data: historyData } = useSWR<{ messages: ChatMessage[] }>(
rawChatId ? `/api/messages?chatId=${rawChatId}` : null,
fetcher,
{
refreshInterval: selectedAgentPresetId ? 3000 : 0,
revalidateOnFocus: true,
}
);
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 resolvedAgentModeState = agentModeState || localAgentModeState;
const emitAgentModeState = onAgentModeStateChange || setLocalAgentModeState;
const detailReplacementState = resolvedAgentModeState.detailReplacement;
const detailReplacementMethod =
detailReplacementState.method === "ai_detail_generate"
? "ai_detail_generate"
: "reference_replace";
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 isDetailAiGenerationMethod =
isDetailReplacementPreset && detailReplacementMethod === "ai_detail_generate";
const isReplacementPreset =
(isDetailReplacementPreset && !isDetailAiGenerationMethod) || isSkuReplacementPreset;
const currentResultModeType: AgentGenerationResultModeType =
isDetailReplacementPreset && isDetailAiGenerationMethod
? "detail_generation"
: isDetailReplacementPreset
? "detail_replacement"
: isSkuReplacementPreset
? "sku_replacement"
: "scene_generation";
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 requiredProductUploadCount = hasAgentUploadSlots ? productSlotCount : 0;
const detailReferenceTarget = presetModeConfig.detail.referenceImageSource;
const skuReferenceTarget = presetModeConfig.sku.referenceImageSource;
const detailReferenceSourceLabel = getTaskDetailReferenceSourceLabel(detailReferenceTarget);
const skuReferenceSourceLabel = getTaskDetailReferenceSourceLabel(skuReferenceTarget);
const workflowStoreName = String(workflowContext?.store || "").trim();
const shouldAllowModelOverride = !isSceneGenerationPreset || presetModeConfig.scene.allowModelOverride;
const brandModelAttachmentKeys = useMemo(() => {
const keys = new Set<string>();
referenceImages
.filter((image) => image.source === "brandModel")
.forEach((image) => {
const key = getAttachmentIdentityKey(image);
if (key) {
keys.add(key);
}
});
const selectedBrandModelKey = getAttachmentIdentityKey({
url: selectedBrandModel?.imageUrl || null,
assetId: selectedBrandModel?.assetId || null,
});
if (selectedBrandModelKey) {
keys.add(selectedBrandModelKey);
}
const currentModelSlotKey =
modelSlotIndex >= 0 ? getAttachmentIdentityKey(attachments[modelSlotIndex]) : null;
if (currentModelSlotKey) {
keys.add(currentModelSlotKey);
}
return keys;
}, [
attachments,
modelSlotIndex,
referenceImages,
selectedBrandModel?.assetId,
selectedBrandModel?.imageUrl,
]);
const selectedProductAttachments = useMemo(
() =>
attachments
.slice(0, productSlotCount)
.filter((item): item is PickedAttachment => {
if (!item) return false;
const attachmentKey = getAttachmentIdentityKey(item);
return !attachmentKey || !brandModelAttachmentKeys.has(attachmentKey);
}),
[attachments, brandModelAttachmentKeys, productSlotCount]
);
const selectedModelAttachment =
modelSlotIndex >= 0 && attachments[modelSlotIndex] ? attachments[modelSlotIndex] : null;
const scenePrimaryReferenceCount = useMemo(
() => referenceImages.filter((image) => image.source === "primary").length,
[referenceImages]
);
const sceneSecondaryReferenceAttachments = useMemo(() => {
const seenKeys = new Set<string>();
return referenceImages
.filter((image) => image.source === "secondary")
.map((image) =>
toWorkflowReferenceAttachment({
url: image.url,
name: image.name,
label: image.label,
mimeType: image.mimeType,
assetId: image.assetId || null,
})
)
.filter((attachment): attachment is PickedAttachment => {
if (!attachment) return false;
const key = getAttachmentIdentityKey(attachment);
if (key && seenKeys.has(key)) {
return false;
}
if (key) {
seenKeys.add(key);
}
return true;
});
}, [referenceImages]);
const sceneSecondaryReferenceCount = sceneSecondaryReferenceAttachments.length;
const sceneMessageAttachments = useMemo(() => {
if (!isSceneGenerationPreset) {
return attachments
.map((attachment) => sanitizeChatAttachment(attachment))
.filter((attachment): attachment is PickedAttachment => Boolean(attachment));
}
const ordered: PickedAttachment[] = [];
const seenKeys = new Set<string>();
const pushUniqueAttachment = (
attachment:
| PickedAttachment
| {
url?: string | null;
name?: string | null;
contentType?: string | null;
assetId?: string | null;
}
| null
| undefined
) => {
const sanitized = sanitizeChatAttachment(attachment as PickedAttachment | null | undefined);
if (!sanitized) return;
const key = getAttachmentIdentityKey(sanitized);
if (key && seenKeys.has(key)) {
return;
}
if (key) {
seenKeys.add(key);
}
ordered.push(sanitized);
};
const primaryVisibleCount = Math.min(
Math.max(scenePrimaryReferenceCount, selectedProductAttachments.length > 0 ? 1 : 0),
selectedProductAttachments.length
);
selectedProductAttachments.slice(0, primaryVisibleCount).forEach((attachment) => {
pushUniqueAttachment(attachment);
});
selectedProductAttachments.slice(primaryVisibleCount).forEach((attachment) => {
pushUniqueAttachment(attachment);
});
sceneSecondaryReferenceAttachments.forEach((attachment) => {
pushUniqueAttachment(attachment);
});
pushUniqueAttachment(selectedModelAttachment);
attachments.forEach((attachment) => {
pushUniqueAttachment(attachment);
});
return ordered;
}, [
attachments,
isSceneGenerationPreset,
scenePrimaryReferenceCount,
sceneSecondaryReferenceAttachments,
selectedModelAttachment,
selectedProductAttachments,
]);
const filledProductAttachmentCount = selectedProductAttachments.length;
const missingAttachmentCount = Math.max(
requiredProductUploadCount - filledProductAttachmentCount,
0
);
const productAttachmentSummary = `${selectedProductAttachments.length}/${productSlotCount} 张产品图`;
const modelAttachmentSummary = modelSlotEnabled
? selectedModelAttachment
? "模特图已带入"
: "模特图可不选"
: null;
const attachmentSummaryReady =
hasAgentUploadSlots &&
(requiredProductUploadCount === 0 || filledProductAttachmentCount >= requiredProductUploadCount);
const sceneImageRolePromptPreview = useMemo(() => {
const sceneHasModelReference = Boolean(selectedModelAttachment);
if (
!isSceneGenerationPreset ||
scenePrimaryReferenceCount + sceneSecondaryReferenceCount + (sceneHasModelReference ? 1 : 0) <= 0
) {
return "";
}
return buildAgentSceneImageRolePrompt({
primaryProductImageCount: Math.max(scenePrimaryReferenceCount, selectedProductAttachments.length > 0 ? 1 : 0),
bundleProductImageCount: sceneSecondaryReferenceCount,
includeModelImage: sceneHasModelReference,
});
}, [
isSceneGenerationPreset,
scenePrimaryReferenceCount,
sceneSecondaryReferenceCount,
selectedModelAttachment,
selectedProductAttachments.length,
]);
const detailReplacementQueryInput = detailReplacementState.query;
const detailReplacementCandidates = detailReplacementState.candidates;
const detailReplacementSelectedCandidateId = detailReplacementState.selectedCandidateId;
const detailReplacementReferenceCards = detailReplacementState.references;
const detailGenerationMode = normalizeAgentDetailGenerationMode(
detailReplacementState.generationMode
);
const detailGenerationBrief = sanitizeAgentDetailBrief(detailReplacementState.brief);
const detailGenerationSlots =
Array.isArray(detailReplacementState.slots) &&
detailReplacementState.slots.length === AGENT_DETAIL_SLOT_CONFIGS.length
? detailReplacementState.slots
: createEmptyAgentDetailSlots();
const detailGenerationRequiredProductCount = Math.max(
1,
Math.min(
isDetailReplacementPreset
? presetModeConfig.detail.productImageSlotCount
: productSlotCount,
AGENT_DETAIL_PRODUCT_SLOT_INDEXES.length
)
);
const detailGenerationProductSlotIndexes = useMemo(
() =>
AGENT_DETAIL_PRODUCT_SLOT_INDEXES.slice(0, detailGenerationRequiredProductCount),
[detailGenerationRequiredProductCount]
);
const detailGenerationVisibleSlotIndexes = useMemo(
() => [
...detailGenerationProductSlotIndexes,
...(modelSlotEnabled && AGENT_DETAIL_MODEL_SLOT_INDEX >= 0
? [AGENT_DETAIL_MODEL_SLOT_INDEX]
: []),
...AGENT_DETAIL_BUNDLE_SLOT_INDEXES,
],
[detailGenerationProductSlotIndexes, modelSlotEnabled]
);
const detailGenerationProgress =
(detailReplacementState.progress || {
status: "idle",
stage: "",
current: 0,
total: 0,
startedAt: null,
finishedAt: null,
}) as AgentDetailGenerationProgressState;
const detailGenerationProductCount = detailGenerationProductSlotIndexes.filter(
(index) => Boolean(detailGenerationSlots[index]?.url)
).length;
const detailGenerationBundleCount = AGENT_DETAIL_BUNDLE_SLOT_INDEXES.filter(
(index) => Boolean(detailGenerationSlots[index]?.url)
).length;
const detailGenerationModelReady = Boolean(
modelSlotEnabled &&
AGENT_DETAIL_MODEL_SLOT_INDEX >= 0 &&
detailGenerationSlots[AGENT_DETAIL_MODEL_SLOT_INDEX]?.url
);
const detailGenerationValidationIssues = useMemo(() => {
const issues: string[] = [];
if (!detailGenerationBrief.targetAudience) issues.push("缺少主购买人群");
if (!detailGenerationBrief.usageScenario) issues.push("缺少需求场景");
if (!detailGenerationBrief.coreNeed) issues.push("缺少核心需求");
if (detailGenerationProductCount < detailGenerationRequiredProductCount) {
issues.push(
`产品图不足(${detailGenerationProductCount}/${detailGenerationRequiredProductCount})`
);
}
return issues;
}, [
detailGenerationBrief.coreNeed,
detailGenerationBrief.targetAudience,
detailGenerationBrief.usageScenario,
detailGenerationProductCount,
detailGenerationRequiredProductCount,
]);
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 transportStateRef = useRef<{
selectedAgentPresetId: string | null;
selectedAgentPresetType: AgentPreset["type"] | null;
selectedModelId: string;
imageParams: ImageGenerationParams;
isDetailReplacementPreset: boolean;
isSkuReplacementPreset: boolean;
detailReplacementQueryInput: string;
detailReplacementSelectedCandidateId: string | null;
detailReplacementReferenceCards: typeof detailReplacementReferenceCards;
skuReplacementQueryInput: string;
skuReplacementQueryType: typeof skuReplacementQueryType;
skuReplacementSelectedCandidateId: string | null;
skuReplacementReferenceCards: typeof skuReplacementReferenceCards;
selectedProductAttachmentCount: number;
}>({
selectedAgentPresetId: null,
selectedAgentPresetType: null,
selectedModelId: "auto",
imageParams: {
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
},
isDetailReplacementPreset: false,
isSkuReplacementPreset: false,
detailReplacementQueryInput: "",
detailReplacementSelectedCandidateId: null,
detailReplacementReferenceCards: [],
skuReplacementQueryInput: "",
skuReplacementQueryType: "sku",
skuReplacementSelectedCandidateId: null,
skuReplacementReferenceCards: [],
selectedProductAttachmentCount: 0,
});
transportStateRef.current = {
selectedAgentPresetId: selectedAgentPresetId || null,
selectedAgentPresetType: selectedAgentPreset?.type || null,
selectedModelId,
imageParams,
isDetailReplacementPreset,
isSkuReplacementPreset,
detailReplacementQueryInput,
detailReplacementSelectedCandidateId: detailReplacementSelectedCandidateId || null,
detailReplacementReferenceCards,
skuReplacementQueryInput,
skuReplacementQueryType,
skuReplacementSelectedCandidateId: skuReplacementSelectedCandidateId || null,
skuReplacementReferenceCards,
selectedProductAttachmentCount: selectedProductAttachments.length,
};
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 hasWorkflowStyleStep = Boolean(workflowStylePanel) || hasBrandModelStep;
const { messages, setMessages, sendMessage, status, stop } = useChat<ChatMessage>({
id: chatId,
messages: historyMessages,
experimental_throttle: 100,
generateId: generateUUID,
transport: new DefaultChatTransport({
api: "/api/chat",
fetch: fetchWithErrorHandlers,
prepareSendMessagesRequest(request) {
const transportState = transportStateRef.current;
const resolvedAgentPresetId =
transportState.selectedAgentPresetId || selectedAgentPresetId || undefined;
const resolvedSelectedModelId =
transportState.selectedModelId !== "auto"
? transportState.selectedModelId
: selectedAgentPreset?.defaultModelId || transportState.selectedModelId;
const replacementModePayload =
transportState.selectedAgentPresetType === "image" &&
(transportState.isDetailReplacementPreset || transportState.isSkuReplacementPreset)
? {
modeType: transportState.isDetailReplacementPreset
? ("detail_replacement" as const)
: ("sku_replacement" as const),
query: clampChatRequestText(
transportState.isDetailReplacementPreset
? transportState.detailReplacementQueryInput.trim()
: transportState.skuReplacementQueryInput.trim(),
MAX_AGENT_MODE_QUERY_LENGTH
),
queryType: transportState.isDetailReplacementPreset
? ("spu" as const)
: transportState.skuReplacementQueryType,
selectedCandidateId: transportState.isDetailReplacementPreset
? transportState.detailReplacementSelectedCandidateId || null
: transportState.skuReplacementSelectedCandidateId || null,
references: (
transportState.isDetailReplacementPreset
? transportState.detailReplacementReferenceCards
: transportState.skuReplacementReferenceCards
)
.slice(0, MAX_AGENT_MODE_REFERENCE_COUNT)
.filter((item) => Boolean(String(item.imageUrl || "").trim()))
.map((item) => ({
id: String(item.id || "").slice(0, 128),
label: String(item.label || "").slice(0, 255),
imageUrl: String(item.imageUrl || "").trim(),
candidateId: item.candidateId
? String(item.candidateId).slice(0, 128)
: null,
sortOrder: item.sortOrder ?? 0,
assignedProductIndex:
transportState.selectedProductAttachmentCount > 0
? Math.min(
item.assignedProductIndex ?? 0,
Math.max(transportState.selectedProductAttachmentCount - 1, 0)
)
: null,
})),
}
: undefined;
return {
body: {
id: rawChatId,
message: request.messages.at(-1),
hiddenPrompt:
clampChatRequestText(
generationRequestSnapshotRef.current.hiddenPrompt ||
hiddenPromptRef.current ||
"",
MAX_CHAT_HIDDEN_PROMPT_LENGTH
) || undefined,
workflowPromptOptimizationEnabled,
agentPresetId: isValidChatUuid(resolvedAgentPresetId)
? resolvedAgentPresetId
: undefined,
selectedChatModel: String(resolvedSelectedModelId || "auto").trim() || "auto",
composerMode:
selectedAgentPreset?.type === "image" ? ("image" as const) : ("agent" as const),
selectedVisibilityType: "private",
imageParams: transportState.imageParams,
contextPayload: {
source: "chat" as const,
projectId: isValidChatUuid(projectId) ? projectId : undefined,
},
agentModePayload: replacementModePayload,
},
};
},
}),
});
useEffect(() => {
if (!historyMessages.length) {
return;
}
setMessages((currentMessages) =>
mergePersistedHistoryMessages(currentMessages, historyMessages)
);
}, [historyMessages, setMessages]);
useEffect(() => {
if (normalizedIncomingWorkspaceState.selectedModelId) {
const nextModelId = normalizedIncomingWorkspaceState.selectedModelId;
setSelectedModelId((current) =>
current === nextModelId ? current : nextModelId
);
return;
}
if (selectedAgentPreset?.defaultModelId) {
const nextModelId = selectedAgentPreset.defaultModelId;
setSelectedModelId((current) =>
current === nextModelId ? current : nextModelId
);
return;
}
if (modelsData?.defaultModelId) {
const nextModelId = modelsData.defaultModelId;
setSelectedModelId((current) =>
current === nextModelId ? current : nextModelId
);
}
}, [
modelsData?.defaultModelId,
normalizedIncomingWorkspaceState.selectedModelId,
selectedAgentPreset?.defaultModelId,
]);
// 初始化附件
useEffect(() => {
workspaceStateSignatureRef.current = "";
lastSyncedProductSlotKeysRef.current = [];
lastSyncedBrandModelUrlRef.current = null;
lastSyncedDetailProductSlotKeysRef.current = [];
lastSyncedDetailBundleSlotKeysRef.current = [];
lastSyncedDetailModelSlotKeyRef.current = null;
setInput("");
setAttachments([]);
setSelectedResultUrl(null);
setFullscreenPreview(null);
setPendingGeneration(null);
setImageParams({
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
});
}, [projectId]);
useEffect(() => {
if (!hasAgentUploadSlots) {
setAttachments((current) => (current.length === 0 ? current : []));
lastSyncedProductSlotKeysRef.current = [];
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 < productSlotCount; index += 1) {
const nextAttachment = nextSlots[index];
const previousSyncedKey = lastSyncedProductSlotKeysRef.current[index] || null;
if (nextAttachment) {
result[index] = nextAttachment as PickedAttachment;
} else if (previousSyncedKey) {
result[index] = null;
}
}
return arePickedAttachmentsEqual(current, result) ? current : result;
});
lastSyncedProductSlotKeysRef.current = Array.from(
{ length: productSlotCount },
(_, index) => getAttachmentIdentityKey(nextSlots[index])
);
}, [
agentUploadSlots.length,
brandModelAttachmentKeys,
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) {
slots[modelSlotIndex] = nextAttachment;
} else if (modelSlotIndex >= 0 && previousSyncedUrl && currentSlot?.url === previousSyncedUrl) {
slots[modelSlotIndex] = null;
}
return arePickedAttachmentsEqual(current, slots) ? current : 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 userMessageCount = useMemo(
() => messages.filter((message) => message.role === "user").length,
[messages]
);
const assistantMessagesAfterLastUser = useMemo(() => {
const lastUserIndex = [...messages]
.map((message) => message.role)
.lastIndexOf("user");
if (lastUserIndex < 0) {
return [] as ChatMessage[];
}
return messages
.slice(lastUserIndex + 1)
.filter((message) => message.role === "assistant");
}, [messages]);
const hasAssistantFailureAfterLastUser = useMemo(
() =>
assistantMessagesAfterLastUser.some((message) => {
const text = (message.parts || [])
.filter((part: any) => part.type === "text" && part.text)
.map((part: any) => String(part.text))
.join("\n\n");
if (!hasLikelyGenerationErrorText(text)) {
return false;
}
const imageUrls = extractImageUrlsFromText(text);
return imageUrls.length === 0;
}),
[assistantMessagesAfterLastUser]
);
const generatedGroupsAfterLastUser = useMemo(() => {
return assistantMessagesAfterLastUser
.map((message) => generatedGroups.find((group) => group.messageId === message.id) || null)
.filter((group): group is GeneratedImageGroup => Boolean(group));
}, [assistantMessagesAfterLastUser, generatedGroups]);
const allGeneratedImages = useMemo(
() =>
Array.from(
new Set(
[
...generatedGroups.flatMap((group) => group.imageUrls),
...detailGenerationRunResults.map((item) => item.originalUrl),
...normalizeResultCollectionUrls(resultCollections?.scene_generation),
...normalizeResultCollectionUrls(resultCollections?.detail_generation),
...normalizeResultCollectionUrls(resultCollections?.detail_replacement),
...normalizeResultCollectionUrls(resultCollections?.sku_replacement),
].filter(
(url) =>
!normalizedDeletedResultCollections.scene_generation.includes(url) &&
!normalizedDeletedResultCollections.detail_generation.includes(url) &&
!normalizedDeletedResultCollections.detail_replacement.includes(url) &&
!normalizedDeletedResultCollections.sku_replacement.includes(url)
)
)
),
[detailGenerationRunResults, generatedGroups, normalizedDeletedResultCollections, resultCollections]
);
const currentSceneGeneratedUrls = useMemo(
() => Array.from(new Set(generatedGroupsAfterLastUser.flatMap((group) => group.imageUrls))),
[generatedGroupsAfterLastUser]
);
const replacementGeneratedGroupsAfterLastUser = useMemo(() => {
return assistantMessagesAfterLastUser
.map((message) => generatedGroups.find((group) => group.messageId === message.id) || null)
.filter((group): group is GeneratedImageGroup => Boolean(group));
}, [assistantMessagesAfterLastUser, 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 latestReplacementResults = useMemo(
() =>
replacementGeneratedGroupsAfterLastUser.map((group) => ({
url: group.imageUrls[0] || null,
text: group.text || "",
})),
[replacementGeneratedGroupsAfterLastUser]
);
const latestReplacementResultUrls = useMemo(
() =>
latestReplacementResults
.map((item) => item.url)
.filter((url): url is string => Boolean(url))
.filter(
(url) => !normalizedDeletedResultCollections[currentResultModeType].includes(url)
),
[currentResultModeType, latestReplacementResults, normalizedDeletedResultCollections]
);
const detailGenerationRunResultUrls = useMemo(
() =>
detailGenerationRunResults
.map((item) => item.originalUrl)
.filter(Boolean)
.filter(
(url) => !normalizedDeletedResultCollections.detail_generation.includes(url)
),
[detailGenerationRunResults, normalizedDeletedResultCollections.detail_generation]
);
const currentLiveResultUrls =
currentResultModeType === "detail_generation"
? detailGenerationRunResultUrls
: isReplacementPreset
? latestReplacementResultUrls
: currentSceneGeneratedUrls;
const transportIsGenerating = status === "streaming" || status === "submitted";
const pendingGenerationProgressCount = useMemo(() => {
if (!pendingGeneration) {
return 0;
}
if (
pendingGeneration.modeType !== "detail_generation" &&
userMessageCount <= pendingGeneration.userTurnCountAtStart
) {
return 0;
}
if (pendingGeneration.modeType === "scene_generation") {
return currentSceneGeneratedUrls.length;
}
if (pendingGeneration.modeType === "detail_generation") {
return detailGenerationRunResults.length;
}
return latestReplacementResults.length;
}, [
currentSceneGeneratedUrls.length,
detailGenerationRunResults.length,
latestReplacementResults.length,
pendingGeneration,
userMessageCount,
]);
const isAnyGenerationRunning = transportIsGenerating || Boolean(pendingGeneration);
const isSceneResultModeGenerating =
(activeResultModeType === "scene_generation" &&
pendingGeneration?.modeType === "scene_generation") ||
(activeResultModeType === currentResultModeType &&
currentResultModeType === "scene_generation" &&
transportIsGenerating);
const isDetailResultModeGenerating =
pendingGeneration?.modeType === "detail_replacement" ||
(currentResultModeType === "detail_replacement" && transportIsGenerating);
const isDetailGenerationResultModeGenerating =
pendingGeneration?.modeType === "detail_generation";
const isSkuResultModeGenerating =
pendingGeneration?.modeType === "sku_replacement" ||
(currentResultModeType === "sku_replacement" && transportIsGenerating);
const mergedResultCollections = useMemo(
() => {
const baseCollections = {
scene_generation: excludeDeletedResultUrls(
resultCollections?.scene_generation,
normalizedDeletedResultCollections.scene_generation
),
detail_generation: excludeDeletedResultUrls(
resultCollections?.detail_generation,
normalizedDeletedResultCollections.detail_generation
),
detail_replacement: excludeDeletedResultUrls(
resultCollections?.detail_replacement,
normalizedDeletedResultCollections.detail_replacement
),
sku_replacement: excludeDeletedResultUrls(
resultCollections?.sku_replacement,
normalizedDeletedResultCollections.sku_replacement
),
};
return {
...baseCollections,
[currentResultModeType]: Array.from(
new Set([
...baseCollections[currentResultModeType],
...excludeDeletedResultUrls(
currentLiveResultUrls,
normalizedDeletedResultCollections[currentResultModeType]
),
])
),
} as Record<AgentGenerationResultModeType, string[]>;
},
[
currentLiveResultUrls,
currentResultModeType,
normalizedDeletedResultCollections,
resultCollections,
]
);
const availableResultModeTypes = useMemo(
() =>
([
"scene_generation",
"detail_generation",
"detail_replacement",
"sku_replacement",
] as AgentGenerationResultModeType[])
.filter(
(modeType) =>
modeType === currentResultModeType ||
modeType === pendingGeneration?.modeType ||
(mergedResultCollections[modeType] || []).length > 0
),
[currentResultModeType, mergedResultCollections, pendingGeneration?.modeType]
);
const activeResultModeUrls = mergedResultCollections[activeResultModeType] || [];
const displayedResultUrl = selectedResultUrl || activeResultModeUrls[0] || null;
const detailResultCards = useMemo(
() =>
detailReplacementReferenceCards.map((card, index) => {
const resultMeta = latestReplacementResults[index];
const resultUrl =
resultMeta?.url &&
!normalizedDeletedResultCollections.detail_replacement.includes(resultMeta.url)
? resultMeta.url
: null;
return {
...card,
index,
resultUrl,
resultText: resultUrl ? resultMeta?.text || "" : "",
status: getReplacementCardStatus({
index,
resultUrl,
resultText: resultUrl ? resultMeta?.text || "" : "",
generatedCount: latestReplacementResults.length,
isGenerating: isDetailResultModeGenerating,
}),
};
}),
[
detailReplacementReferenceCards,
isDetailResultModeGenerating,
latestReplacementResults,
normalizedDeletedResultCollections.detail_replacement,
]
);
const skuResultCards = useMemo(
() =>
skuReplacementReferenceCards.map((card, index) => {
const resultMeta = latestReplacementResults[index];
const resultUrl =
resultMeta?.url &&
!normalizedDeletedResultCollections.sku_replacement.includes(resultMeta.url)
? resultMeta.url
: null;
return {
...card,
index,
resultUrl,
resultText: resultUrl ? resultMeta?.text || "" : "",
status: getReplacementCardStatus({
index,
resultUrl,
resultText: resultUrl ? resultMeta?.text || "" : "",
generatedCount: latestReplacementResults.length,
isGenerating: isSkuResultModeGenerating,
}),
};
}),
[
isSkuResultModeGenerating,
latestReplacementResults,
normalizedDeletedResultCollections.sku_replacement,
skuReplacementReferenceCards,
]
);
const detailProcessedCount = useMemo(
() =>
detailResultCards.filter((card) =>
["completed", "passthrough", "fallback"].includes(card.status)
).length,
[detailResultCards]
);
const skuProcessedCount = useMemo(
() =>
skuResultCards.filter((card) =>
["completed", "passthrough", "fallback"].includes(card.status)
).length,
[skuResultCards]
);
const detailGenerationExpectedCount = useMemo(() => {
if (
pendingGeneration?.modeType === "detail_generation" &&
Number.isFinite(pendingGeneration.expectedCount) &&
pendingGeneration.expectedCount > 0
) {
return pendingGeneration.expectedCount;
}
const progressTotal = Number(detailGenerationProgress.total || 0);
if (progressTotal > 1) {
return Math.max(
detailGenerationRunResults.length,
Math.round((progressTotal - 1) / 2)
);
}
return detailGenerationRunResults.length;
}, [
detailGenerationProgress.total,
detailGenerationRunResults.length,
pendingGeneration?.expectedCount,
pendingGeneration?.modeType,
]);
const hasDetailGenerationPlaceholderState =
currentResultModeType === "detail_generation" &&
detailGenerationExpectedCount > detailGenerationRunResults.length &&
(pendingGeneration?.modeType === "detail_generation" ||
detailGenerationProgress.status === "running" ||
detailGenerationProgress.status === "error");
const gridRequestedImageCount =
activeResultModeType === "detail_generation"
? Math.max(
1,
detailGenerationExpectedCount || activeResultModeUrls.length
)
: Math.max(1, imageParams.imageCount || 1);
const gridPendingRemainingCount =
activeResultModeType === "scene_generation" &&
pendingGeneration?.modeType === "scene_generation"
? Math.max(pendingGeneration.expectedCount - currentSceneGeneratedUrls.length, 0)
: activeResultModeType === "detail_generation" &&
hasDetailGenerationPlaceholderState
? Math.max(
detailGenerationExpectedCount - detailGenerationRunResults.length,
0
)
: 0;
const gridPendingCardCount =
activeResultModeType === "scene_generation" ||
activeResultModeType === "detail_generation"
? gridPendingRemainingCount
: 0;
const gridResultCardCount = Math.max(
activeResultModeUrls.length + gridPendingCardCount,
(activeResultModeType === "scene_generation" ||
activeResultModeType === "detail_generation") &&
pendingGeneration?.modeType === activeResultModeType
? activeResultModeUrls.length + gridPendingRemainingCount
: activeResultModeType === currentResultModeType
? gridRequestedImageCount
: activeResultModeUrls.length,
1
);
const gridResultCards = useMemo(
() => {
const placeholderStartIndex = activeResultModeUrls.length;
return Array.from({ length: gridResultCardCount }, (_, index) => {
const url = activeResultModeUrls[index] || null;
const placeholderOffset = Math.max(index - placeholderStartIndex, 0);
return {
id: url || `grid-result-placeholder-${activeResultModeType}-${index}`,
index,
url,
status: url
? ("completed" as const)
: activeResultModeType === "detail_generation" &&
detailGenerationProgress.status === "error" &&
gridPendingRemainingCount > 0
? ("missing" as const)
: gridPendingRemainingCount > 0
? placeholderOffset === 0
? ("generating" as const)
: ("queued" as const)
: ("waiting" as const),
};
});
},
[
activeResultModeType,
activeResultModeUrls,
detailGenerationProgress.status,
gridPendingRemainingCount,
gridResultCardCount,
]
);
const shouldShowGridResultSection =
activeResultModeUrls.length > 0 ||
((activeResultModeType === "scene_generation" && isSceneResultModeGenerating) ||
(activeResultModeType === "detail_generation" &&
(isDetailGenerationResultModeGenerating || hasDetailGenerationPlaceholderState)));
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 || filledProductAttachmentCount >= requiredProductUploadCount;
const slotRequirementMessage =
hasAgentUploadSlots && !hasSatisfiedSlotRequirement
? `当前还缺少 ${missingAttachmentCount} 张产品图,请先在“选择 SPU / SKU”步骤补齐后再继续。`
: null;
const mergedSettingsStepLabel = hasMaterialStep ? "选图与生成" : "生成参数";
const mergedSettingsStepDescription = hasMaterialStep
? "搜索素材、补充要求、确认槽位并开始生成"
: "补充要求并开始生成";
const styleStepBadge = hasMaterialStep ? "03" : "02";
const generateStepBadge =
hasMaterialStep && hasWorkflowStyleStep
? "04"
: hasMaterialStep || hasWorkflowStyleStep
? "03"
: "02";
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,
},
]
: []),
{
key: "settings" as const,
label: mergedSettingsStepLabel,
description: mergedSettingsStepDescription,
icon: Settings2,
disabled: !selectedAgentPresetId,
completed:
Boolean(selectedAgentPresetId) &&
(Boolean(input.trim()) || hasMaterialSelections || hasSatisfiedSlotRequirement),
optional: false,
},
],
[
hasBrandModelSelection,
hasBrandModelStep,
hasMaterialSelections,
hasSatisfiedSlotRequirement,
mergedSettingsStepDescription,
mergedSettingsStepLabel,
hasMaterialStep,
input,
selectedAgentPresetId,
]
);
useEffect(() => {
setActiveDetailReferenceCardId((current) =>
detailReplacementReferenceCards.some((card) => card.id === current)
? current
: detailReplacementReferenceCards[0]?.id || null
);
}, [detailReplacementReferenceCards]);
useEffect(() => {
setActiveSkuReferenceCardId((current) =>
skuReplacementReferenceCards.some((card) => card.id === current)
? current
: skuReplacementReferenceCards[0]?.id || null
);
}, [skuReplacementReferenceCards]);
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(() => {
const incomingSignature = JSON.stringify(normalizedIncomingWorkspaceState);
if (incomingSignature === workspaceStateSignatureRef.current) {
return;
}
workspaceStateSignatureRef.current = incomingSignature;
setInput(normalizedIncomingWorkspaceState.input);
setAttachments((current) => {
const nextAttachments = normalizedIncomingWorkspaceState.attachments.map((item) =>
item
? {
url: item.url,
name: item.name || "参考图",
contentType: item.contentType || "image/png",
assetId: item.assetId || undefined,
}
: null
);
return arePickedAttachmentsEqual(current, nextAttachments) ? current : nextAttachments;
});
setSelectedModelId((current) => {
const nextModelId = normalizedIncomingWorkspaceState.selectedModelId || "auto";
return current === nextModelId ? current : nextModelId;
});
setImageParams((current) => {
const nextParams = {
aspectRatio: normalizedIncomingWorkspaceState.imageParams?.aspectRatio || "auto",
imageSize: normalizedIncomingWorkspaceState.imageParams?.imageSize || "2K",
imageCount: normalizedIncomingWorkspaceState.imageParams?.imageCount || 1,
};
return areImageGenerationParamsEqual(current, nextParams) ? current : nextParams;
});
}, [normalizedIncomingWorkspaceState]);
useEffect(() => {
const nextWorkspaceState = {
input,
attachments: attachments.map((item) =>
item
? {
url: item.url,
name: item.name || null,
contentType: item.contentType || null,
assetId: item.assetId || null,
}
: null
),
selectedModelId,
imageParams: {
aspectRatio: imageParams.aspectRatio,
imageSize: imageParams.imageSize,
imageCount: imageParams.imageCount,
},
};
workspaceStateSignatureRef.current = JSON.stringify(nextWorkspaceState);
onWorkspaceStateChange?.(nextWorkspaceState);
}, [
attachments,
imageParams.aspectRatio,
imageParams.imageCount,
imageParams.imageSize,
input,
onWorkspaceStateChange,
selectedModelId,
]);
useEffect(() => {
setActiveResultModeType(currentResultModeType);
}, [currentResultModeType]);
useEffect(() => {
if (!pendingGeneration) {
return;
}
if (pendingGenerationProgressCount >= pendingGeneration.expectedCount) {
setPendingGeneration(null);
}
}, [pendingGeneration, pendingGenerationProgressCount]);
useEffect(() => {
if (!pendingGeneration || transportIsGenerating) {
return;
}
if (!hasAssistantFailureAfterLastUser) {
return;
}
const hasProgress =
pendingGeneration.modeType === "scene_generation"
? currentSceneGeneratedUrls.length > 0
: pendingGeneration.modeType === "detail_generation"
? detailGenerationRunResults.length > 0
: latestReplacementResults.length > 0;
if (
pendingGeneration.modeType === "scene_generation" &&
!hasProgress &&
Date.now() - pendingGeneration.timestamp < AGENT_SCENE_PENDING_FAILURE_GRACE_MS
) {
return;
}
if (!hasProgress) {
setPendingGeneration(null);
}
}, [
currentSceneGeneratedUrls.length,
detailGenerationRunResults.length,
hasAssistantFailureAfterLastUser,
latestReplacementResults.length,
pendingGeneration,
transportIsGenerating,
]);
useEffect(() => {
onResultPreviewUrlsChange?.(
currentResultModeType,
mergedResultCollections[currentResultModeType] || []
);
}, [currentResultModeType, mergedResultCollections, onResultPreviewUrlsChange]);
useEffect(() => {
if (activeResultModeUrls.length === 0) {
if (selectedResultUrl) {
setSelectedResultUrl(null);
}
return;
}
if (!selectedResultUrl || !activeResultModeUrls.includes(selectedResultUrl)) {
setSelectedResultUrl(activeResultModeUrls[0] || null);
}
}, [activeResultModeUrls, selectedResultUrl]);
useEffect(() => {
if (!selectedAgentPresetId || !hasAgentUploadSlots) {
setIsAttachmentSlotExpanded(false);
}
}, [hasAgentUploadSlots, selectedAgentPresetId]);
useEffect(() => {
if (!selectedAgentPresetId) {
setActiveToolStep("preset");
return;
}
setActiveToolStep((current) => {
if (
current !== "preset" &&
current !== "materials" &&
current !== "prompt"
) {
return current;
}
if (hasBrandModelStep) return "brand-model";
return "settings";
});
}, [
hasBrandModelStep,
selectedAgentPresetId,
]);
useEffect(() => {
if (!toolSteps.some((step) => step.key === activeToolStep)) {
setActiveToolStep(selectedAgentPresetId ? "settings" : "preset");
}
}, [activeToolStep, selectedAgentPresetId, 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 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 (isDetailReplacementPreset && isDetailAiGenerationMethod) {
void handleRunAiDetailGeneration();
return;
}
if (!selectedAgentPresetId) {
toast.error("请先选择生图模式");
return;
}
if (isDetailReplacementPreset && detailReplacementReferenceCards.length === 0) {
toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`);
return;
}
if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) {
toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`);
return;
}
const uploadedProductAttachmentCount = selectedProductAttachments.length;
if (hasAgentUploadSlots && uploadedProductAttachmentCount < requiredProductUploadCount) {
toast.error(`当前模式需要上传 ${requiredProductUploadCount} 张产品图`);
return;
}
const resolvedModelId =
selectedModelId !== "auto"
? selectedModelId
: selectedAgentPreset?.defaultModelId || selectedModelId;
if (!resolvedModelId) {
toast.error("当前智能体未配置模型");
return;
}
if (
resolvedModelId !== "auto" &&
!selectableModels.some((model) => model.id === resolvedModelId)
) {
toast.error("当前智能体绑定的模型不存在或已停用,请重新编辑智能体模型配置");
return;
}
const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = [];
const sanitizedAttachments = sceneMessageAttachments;
if (
hasAgentUploadSlots &&
selectedProductAttachments.filter((attachment) => Boolean(attachment?.url)).length <
requiredProductUploadCount
) {
toast.error(`当前模式需要上传 ${requiredProductUploadCount} 张有效产品图`);
return;
}
const primaryAttachmentCountForMessage = Math.min(
scenePrimaryReferenceCount,
sanitizedAttachments.length
);
const secondaryAttachmentCountForMessage = Math.min(
sceneSecondaryReferenceCount,
Math.max(
sanitizedAttachments.length -
primaryAttachmentCountForMessage -
(selectedModelAttachment ? 1 : 0),
0
)
);
sanitizedAttachments.forEach((attachment, index) => {
if (!attachment) return;
let attachmentName = normalizeChatAttachmentName(attachment.name);
if (isSceneGenerationPreset) {
if (index < primaryAttachmentCountForMessage) {
attachmentName = `主SKU参考图 ${index + 1}`;
} else if (index < primaryAttachmentCountForMessage + secondaryAttachmentCountForMessage) {
attachmentName = `搭配SKU参考图 ${index - primaryAttachmentCountForMessage + 1}`;
} else if (
selectedModelAttachment &&
index === primaryAttachmentCountForMessage + secondaryAttachmentCountForMessage
) {
attachmentName = "品牌模特图";
}
}
parts.push({
type: "file",
mediaType: normalizeChatMediaType(attachment.contentType),
name: attachmentName,
url: attachment.url,
assetId: isValidChatUuid(attachment.assetId) ? attachment.assetId : undefined,
});
});
const resolvedWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext;
let textContent = input.trim();
if (!textContent && selectedAgentPreset?.type === "text" && resolvedWorkflowContext?.summaryText) {
textContent = `请基于当前工作流设定生成方案:${resolvedWorkflowContext.summaryText}`;
}
if (!textContent && selectedAgentPreset?.type === "text" && selectedAgentPreset) {
textContent = `请按「${selectedAgentPreset.name}」输出内容`;
}
textContent = clampChatRequestText(textContent, MAX_CHAT_TEXT_PART_LENGTH);
if (textContent) {
parts.push({ type: "text", text: textContent });
}
if (parts.length === 0) {
toast.error("请至少补充要求或上传图片");
return;
}
const nextHiddenPrompt = resolvedWorkflowContext?.promptText
? clampChatRequestText(
`【工作流设定】\n${resolvedWorkflowContext.promptText}\n\n请严格遵循以上工作流设定输出内容,并保持整体结果一致。`,
MAX_CHAT_HIDDEN_PROMPT_LENGTH
)
: null;
hiddenPromptRef.current = nextHiddenPrompt;
generationRequestSnapshotRef.current = {
hiddenPrompt: nextHiddenPrompt,
};
setActiveResultModeType(currentResultModeType);
setSelectedResultUrl(null);
setPendingGeneration({
chatId,
modeType: currentResultModeType,
expectedCount:
currentResultModeType === "scene_generation"
? Math.max(1, imageParams.imageCount || 1)
: currentResultModeType === "detail_replacement"
? detailReplacementReferenceCards.length
: skuReplacementReferenceCards.length,
userTurnCountAtStart: userMessageCount,
timestamp: Date.now(),
});
try {
sendMessage({ role: "user", parts });
if (selectedAgentPreset?.type !== "image") {
setInput("");
}
} catch (error) {
generationRequestSnapshotRef.current = {
hiddenPrompt: null,
};
setPendingGeneration(null);
toast.error(error instanceof Error ? error.message : "发送生成请求失败");
}
}, [
attachments,
detailReplacementReferenceCards.length,
handleRunAiDetailGeneration,
currentResultModeType,
hasAgentUploadSlots,
imageParams.imageCount,
input,
isDetailAiGenerationMethod,
isDetailReplacementPreset,
isSkuReplacementPreset,
requiredProductUploadCount,
selectedAgentPreset,
selectedAgentPresetId,
selectableModels,
selectedModelId,
sendMessage,
setPendingGeneration,
skuReplacementReferenceCards.length,
userMessageCount,
getLatestWorkflowContext,
isSceneGenerationPreset,
workflowContext,
sceneMessageAttachments,
scenePrimaryReferenceCount,
sceneSecondaryReferenceCount,
selectedModelAttachment,
]);
const handleStopGeneration = useCallback(() => {
detailGenerationCancelRef.current = true;
stop();
setPendingGeneration(null);
setMessages((current) => current);
toast.info("已停止当前生成任务");
}, [setMessages, stop]);
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 uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {
const response = await fetch(base64Url);
const blob = await response.blob();
const formData = new FormData();
formData.append("file", blob, `agent-scene-resize-${Date.now()}.png`);
const uploadResponse = await fetchUploadWithRetry(formData);
if (!uploadResponse.ok) {
throw new Error("上传失败");
}
const result = await uploadResponse.json().catch(() => ({}));
const uploadedUrl = String(result?.url || "").trim();
if (!/^https?:\/\//i.test(uploadedUrl)) {
throw new Error("上传结果无效");
}
return uploadedUrl;
}, []);
const handleSceneResultResizeComplete = useCallback(
async (resizedImageUrl: string) => {
if (!onResultPreviewUrlsChange) {
toast.error("当前项目未接入结果保存能力");
return;
}
let finalUrl = String(resizedImageUrl || "").trim();
if (finalUrl.startsWith("data:")) {
try {
toast.info("正在上传尺寸调整结果...");
finalUrl = await uploadBase64ResultToStorage(finalUrl);
} catch (error) {
console.error("[AgentGenerationWorkspaceDarkV2] 上传尺寸调整结果失败:", error);
toast.error("尺寸调整结果上传失败");
return;
}
}
if (!/^https?:\/\//i.test(finalUrl)) {
toast.error("尺寸调整结果地址无效");
return;
}
const nextSceneUrls = Array.from(
new Set([
...normalizeResultCollectionUrls(mergedResultCollections.scene_generation),
finalUrl,
])
);
onResultPreviewUrlsChange("scene_generation", nextSceneUrls);
setActiveResultModeType("scene_generation");
setSelectedResultUrl(finalUrl);
setSceneResizeEditorTargetUrl(null);
toast.success("尺寸调整结果已加入场景结果区");
},
[mergedResultCollections.scene_generation, onResultPreviewUrlsChange, uploadBase64ResultToStorage]
);
const appendResultUrlToCollection = useCallback(
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
const normalizedUrl = String(imageUrl || "").trim();
if (!normalizedUrl || !onResultPreviewUrlsChange) {
return false;
}
const nextUrls = Array.from(
new Set([
...normalizeResultCollectionUrls(mergedResultCollections[modeType]),
normalizedUrl,
])
);
onResultPreviewUrlsChange(modeType, nextUrls);
if (activeResultModeType === modeType) {
setSelectedResultUrl(normalizedUrl);
}
return true;
},
[activeResultModeType, mergedResultCollections, onResultPreviewUrlsChange]
);
const handlePsdDerivedImageCreated = useCallback(
(imageUrl: string) => {
appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl);
},
[appendResultUrlToCollection, psdTemplateDialogResultModeType]
);
const handlePsdRenderedResultCreated = useCallback(
(imageUrl: string) => {
appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl);
},
[appendResultUrlToCollection, psdTemplateDialogResultModeType]
);
const handleDeleteResultImage = useCallback(
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
const normalizedUrl = String(imageUrl || "").trim();
if (!normalizedUrl || !onDeleteResultImage) {
return;
}
if (selectedResultUrl === normalizedUrl) {
setSelectedResultUrl(null);
}
if (fullscreenPreview === normalizedUrl) {
setFullscreenPreview(null);
}
if (sceneResizeEditorTargetUrl === normalizedUrl) {
setSceneResizeEditorTargetUrl(null);
}
onDeleteResultImage(modeType, normalizedUrl);
toast.success("结果图已删除");
},
[fullscreenPreview, onDeleteResultImage, sceneResizeEditorTargetUrl, selectedResultUrl]
);
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 scrollToSection = useCallback((sectionId: string) => {
if (
(sectionId === "agent-anchor-generate" || sectionId === "agent-anchor-results") &&
!isDetailAiGenerationMethod &&
hasAgentUploadSlots &&
!hasSatisfiedSlotRequirement
) {
toast.error(slotRequirementMessage || "请先补齐输入图后再继续");
const fallbackTarget = hasMaterialStep ? "agent-anchor-materials" : "agent-anchor-generate";
const fallbackElement = typeof document !== "undefined"
? document.getElementById(fallbackTarget)
: null;
fallbackElement?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
if (typeof document === "undefined") {
return;
}
const target = document.getElementById(sectionId);
if (!target) {
return;
}
target.scrollIntoView({ behavior: "smooth", block: "start" });
}, [
hasAgentUploadSlots,
hasMaterialStep,
hasSatisfiedSlotRequirement,
isDetailAiGenerationMethod,
slotRequirementMessage,
]);
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 };
if (nextDetailReplacement === current) {
return;
}
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 };
if (nextSkuReplacement === current) {
return;
}
emitAgentModeState({
...resolvedAgentModeState,
skuReplacement: nextSkuReplacement,
});
},
[emitAgentModeState, resolvedAgentModeState]
);
useEffect(() => {
if (!isDetailReplacementPreset) {
lastSyncedDetailProductSlotKeysRef.current = [];
lastSyncedDetailBundleSlotKeysRef.current = [];
lastSyncedDetailModelSlotKeyRef.current = null;
return;
}
const secondaryReferenceImages = referenceImages
.filter((image) => image.source === "secondary")
.slice(0, AGENT_DETAIL_BUNDLE_SLOT_INDEXES.length);
const nextDetailProductSourceKeys = detailGenerationProductSlotIndexes.map((_, index) =>
getAttachmentIdentityKey(selectedProductAttachments[index])
);
const nextDetailBundleSourceKeys = AGENT_DETAIL_BUNDLE_SLOT_INDEXES.map((_, index) =>
getAttachmentIdentityKey(
secondaryReferenceImages[index]
? {
url: secondaryReferenceImages[index]?.url,
assetId: secondaryReferenceImages[index]?.assetId || null,
}
: null
)
);
const nextDetailModelSourceKey =
modelSlotEnabled && AGENT_DETAIL_MODEL_SLOT_INDEX >= 0
? getAttachmentIdentityKey(selectedModelAttachment)
: null;
updateDetailReplacementState((current) => {
let changed = false;
const nextBrief = {
...(current.brief || createEmptyAgentDetailBriefForm()),
};
const workflowPrimarySku = String(workflowContext?.primarySku || "").trim();
const workflowPriceBand = String(workflowContext?.priceBand || "").trim();
if (!nextBrief.productSpu && workflowPrimarySku) {
nextBrief.productSpu = workflowPrimarySku;
changed = true;
}
if (!nextBrief.priceBand && workflowPriceBand) {
nextBrief.priceBand = workflowPriceBand;
changed = true;
}
const nextSlots = Array.from(
{ length: AGENT_DETAIL_SLOT_CONFIGS.length },
(_, index) => current.slots?.[index] || null
);
detailGenerationProductSlotIndexes.forEach((slotIndex, index) => {
const attachment = selectedProductAttachments[index];
const currentSlot = nextSlots[slotIndex];
const currentSlotKey = getAttachmentIdentityKey(currentSlot);
const previousSyncedKey = lastSyncedDetailProductSlotKeysRef.current[index] || null;
const currentIsPreviousAutoSelection =
Boolean(previousSyncedKey) && currentSlotKey === previousSyncedKey;
if (attachment) {
const nextAttachment = {
url: attachment.url,
name: attachment.name || AGENT_DETAIL_SLOT_CONFIGS[slotIndex]?.label || null,
contentType: attachment.contentType || "image/jpeg",
assetId: attachment.assetId || null,
};
if (
(!currentSlot || currentIsPreviousAutoSelection) &&
!areAttachmentValuesEqual(currentSlot, nextAttachment)
) {
nextSlots[slotIndex] = nextAttachment;
changed = true;
}
} else if (currentIsPreviousAutoSelection) {
nextSlots[slotIndex] = null;
changed = true;
}
});
if (
modelSlotEnabled &&
AGENT_DETAIL_MODEL_SLOT_INDEX >= 0 &&
selectedModelAttachment
) {
const currentSlot = nextSlots[AGENT_DETAIL_MODEL_SLOT_INDEX];
const currentSlotKey = getAttachmentIdentityKey(currentSlot);
const previousSyncedKey = lastSyncedDetailModelSlotKeyRef.current;
const currentIsPreviousAutoSelection =
Boolean(previousSyncedKey) && currentSlotKey === previousSyncedKey;
const nextAttachment = {
url: selectedModelAttachment.url,
name:
selectedModelAttachment.name ||
AGENT_DETAIL_SLOT_CONFIGS[AGENT_DETAIL_MODEL_SLOT_INDEX]?.label ||
null,
contentType: selectedModelAttachment.contentType || "image/jpeg",
assetId: selectedModelAttachment.assetId || null,
};
if (
(!currentSlot || currentIsPreviousAutoSelection) &&
!areAttachmentValuesEqual(currentSlot, nextAttachment)
) {
nextSlots[AGENT_DETAIL_MODEL_SLOT_INDEX] = nextAttachment;
changed = true;
}
} else if (modelSlotEnabled && AGENT_DETAIL_MODEL_SLOT_INDEX >= 0) {
const currentSlot = nextSlots[AGENT_DETAIL_MODEL_SLOT_INDEX];
const currentSlotKey = getAttachmentIdentityKey(currentSlot);
const previousSyncedKey = lastSyncedDetailModelSlotKeyRef.current;
const currentIsPreviousAutoSelection =
Boolean(previousSyncedKey) && currentSlotKey === previousSyncedKey;
if (currentIsPreviousAutoSelection) {
nextSlots[AGENT_DETAIL_MODEL_SLOT_INDEX] = null;
changed = true;
}
}
AGENT_DETAIL_BUNDLE_SLOT_INDEXES.forEach((slotIndex, index) => {
const image = secondaryReferenceImages[index];
const currentSlot = nextSlots[slotIndex];
const currentSlotKey = getAttachmentIdentityKey(currentSlot);
const previousSyncedKey = lastSyncedDetailBundleSlotKeysRef.current[index] || null;
const currentIsPreviousAutoSelection =
Boolean(previousSyncedKey) && currentSlotKey === previousSyncedKey;
if (image) {
const nextAttachment = {
url: image.url,
name:
image.name ||
image.label ||
AGENT_DETAIL_SLOT_CONFIGS[slotIndex]?.label ||
null,
contentType: image.mimeType || "image/jpeg",
assetId: image.assetId || null,
};
if (
(!currentSlot || currentIsPreviousAutoSelection) &&
!areAttachmentValuesEqual(currentSlot, nextAttachment)
) {
nextSlots[slotIndex] = nextAttachment;
changed = true;
}
} else if (currentIsPreviousAutoSelection) {
nextSlots[slotIndex] = null;
changed = true;
}
});
if (!changed) {
lastSyncedDetailProductSlotKeysRef.current = nextDetailProductSourceKeys;
lastSyncedDetailBundleSlotKeysRef.current = nextDetailBundleSourceKeys;
lastSyncedDetailModelSlotKeyRef.current = nextDetailModelSourceKey;
return current;
}
lastSyncedDetailProductSlotKeysRef.current = nextDetailProductSourceKeys;
lastSyncedDetailBundleSlotKeysRef.current = nextDetailBundleSourceKeys;
lastSyncedDetailModelSlotKeyRef.current = nextDetailModelSourceKey;
return {
...current,
brief: nextBrief,
slots: nextSlots,
};
});
}, [
detailGenerationProductSlotIndexes,
isDetailReplacementPreset,
modelSlotEnabled,
referenceImages,
selectedModelAttachment,
selectedProductAttachments,
updateDetailReplacementState,
workflowContext?.priceBand,
workflowContext?.primarySku,
]);
const updateDetailGenerationMethod = useCallback(
(method: "reference_replace" | "ai_detail_generate") => {
updateDetailReplacementState((current) => ({
...current,
method,
}));
},
[updateDetailReplacementState]
);
const updateDetailGenerationBriefField = useCallback(
(key: keyof AgentDetailBriefForm, value: string) => {
updateDetailReplacementState((current) => ({
...current,
brief: {
...(current.brief || createEmptyAgentDetailBriefForm()),
[key]: value,
},
}));
},
[updateDetailReplacementState]
);
const updateDetailGenerationSlot = useCallback(
(slotIndex: number, attachment: PickedAttachment | null) => {
updateDetailReplacementState((current) => {
const nextSlots = Array.from(
{ length: AGENT_DETAIL_SLOT_CONFIGS.length },
(_, index) => current.slots?.[index] || null
);
nextSlots[slotIndex] = attachment
? {
url: attachment.url,
name: attachment.name || null,
contentType: attachment.contentType || null,
assetId: attachment.assetId || null,
}
: null;
return {
...current,
slots: nextSlots,
};
});
},
[updateDetailReplacementState]
);
const updateDetailGenerationProgressState = useCallback(
(progress: AgentDetailGenerationProgressState) => {
updateDetailReplacementState((current) => ({
...current,
progress,
}));
},
[updateDetailReplacementState]
);
const handleDetailGenerationSlotFileChange = 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;
}
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 || "上传失败");
}
updateDetailGenerationSlot(slotIndex, {
url: data.url,
name: file.name,
contentType: file.type || "image/jpeg",
assetId: typeof data.assetId === "string" ? data.assetId : undefined,
});
toast.success(`${AGENT_DETAIL_SLOT_CONFIGS[slotIndex]?.label || "素材"}上传成功`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传失败");
} finally {
event.currentTarget.value = "";
}
},
[updateDetailGenerationSlot]
);
async function handleRunAiDetailGeneration() {
if (!selectedAgentPresetId) {
toast.error("请先选择生图模式");
return;
}
if (detailGenerationMode !== "classic") {
toast.error("AGENT排版模式将在共享画布文字层能力接入后开放,当前请先使用经典详情");
return;
}
if (detailGenerationValidationIssues.length > 0) {
toast.error(detailGenerationValidationIssues[0] || "请先补齐 AI详情生成参数");
return;
}
const productAttachments = detailGenerationProductSlotIndexes.map((index) =>
detailGenerationSlots[index]
).filter((item): item is NonNullable<(typeof detailGenerationSlots)[number]> => Boolean(item?.url));
const bundleAttachments = AGENT_DETAIL_BUNDLE_SLOT_INDEXES.map((index) =>
detailGenerationSlots[index]
).filter((item): item is NonNullable<(typeof detailGenerationSlots)[number]> => Boolean(item?.url));
const modelAttachment =
modelSlotEnabled && AGENT_DETAIL_MODEL_SLOT_INDEX >= 0
? detailGenerationSlots[AGENT_DETAIL_MODEL_SLOT_INDEX]
: null;
const basePrompt = detailGenerationBrief.productName
? `${detailGenerationBrief.productName}详情页生成`
: "电商详情页生成";
const demandHint = detailGenerationBrief.demandType
? `,需求类型:${detailGenerationBrief.demandType}`
: "";
const userSupplementPrompt = input.trim();
const detailPrompt =
userSupplementPrompt ||
`${basePrompt},面向${detailGenerationBrief.targetAudience},在${detailGenerationBrief.usageScenario}场景解决${detailGenerationBrief.coreNeed}${demandHint}`;
const workflowHint = workflowContext?.promptText
? `\n\n【工作流风格要求】\n${workflowContext.promptText}`
: "";
const detailPromptWithWorkflow = `${detailPrompt}${workflowHint}`.trim();
const detailCount = extractAgentDetailFrameCount(detailPrompt);
const referenceImageNotes = detailGenerationVisibleSlotIndexes.map((slotIndex, visibleIndex) => {
const config = AGENT_DETAIL_SLOT_CONFIGS[slotIndex];
const current = detailGenerationSlots[slotIndex];
return `${visibleIndex + 1}. ${config?.label || `素材${visibleIndex + 1}`}${current?.name ? `(${current.name})` : ""}`;
});
const outputLanguage: "zh" | "en" = /[a-zA-Z]/.test(detailPromptWithWorkflow)
? "en"
: "zh";
const resolvedModelId =
selectedModelId !== "auto"
? selectedModelId
: selectedAgentPreset?.defaultModelId || modelsData?.defaultModelId || undefined;
detailGenerationCancelRef.current = false;
setDetailGenerationRunResults([]);
setActiveResultModeType("detail_generation");
setSelectedResultUrl(null);
const startedAt = new Date().toISOString();
updateDetailGenerationProgressState({
status: "running",
stage: "Agent 正在解析需求并规划详情结构...",
current: 0,
total: Math.max(1, detailCount * 2 + 1),
startedAt,
finishedAt: null,
});
setPendingGeneration({
chatId,
modeType: "detail_generation",
expectedCount: detailCount,
userTurnCountAtStart: userMessageCount,
timestamp: Date.now(),
});
try {
const planResponse = await fetch("/api/canvas/detail-plan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: detailPromptWithWorkflow,
count: detailCount,
generationMode: "classic",
referenceImages: detailGenerationSlots
.filter((_, slotIndex) => detailGenerationVisibleSlotIndexes.includes(slotIndex))
.map((slot) => slot?.url || "")
.filter(Boolean)
.slice(0, 12),
referenceImageNotes,
modelImageUrl: modelAttachment?.url || undefined,
bundleImageUrls: bundleAttachments.map((item) => item.url),
outputLanguage,
brief: detailGenerationBrief,
}),
});
const planResult = await planResponse.json().catch(() => ({}));
if (!planResponse.ok || !planResult?.success) {
throw new Error(planResult?.error || "详情分镜规划失败");
}
const planData = (planResult.data || {}) as {
summary?: string;
prompts?: string[];
globalStyle?: Record<string, any>;
screens?: Array<Record<string, any>>;
};
const prompts = Array.isArray(planData.prompts)
? planData.prompts.map((item) => String(item || "").trim()).filter(Boolean)
: [];
if (prompts.length === 0) {
throw new Error("未获取到有效分镜提示词");
}
setPendingGeneration((current) =>
current && current.chatId === chatId && current.modeType === "detail_generation"
? {
...current,
expectedCount: prompts.length,
}
: current
);
updateDetailGenerationProgressState({
status: "running",
stage: "分镜规划完成,开始逐屏生成...",
current: 1,
total: Math.max(1, prompts.length * 2 + 1),
startedAt,
finishedAt: null,
});
const generatedImages: DirectDetailGenerationImage[] = [];
for (let index = 0; index < prompts.length; index += 1) {
if (detailGenerationCancelRef.current) {
throw new Error("已停止当前 AI详情生成任务");
}
const screen = Array.isArray(planData.screens) ? planData.screens[index] || {} : {};
const useModelForScene = Boolean(screen?.useModel);
const bundleShowcase = Boolean(screen?.bundleShowcase);
const bundleReason = String(screen?.bundleReason || "").trim();
const promptText = String(screen?.visualPrompt || prompts[index] || "").trim();
const generationStepCurrent = 2 + index * 2;
const reviewStepCurrent = generationStepCurrent + 1;
updateDetailGenerationProgressState({
status: "running",
stage: `正在生成第 ${index + 1}/${prompts.length} 屏详情图`,
current: generationStepCurrent,
total: Math.max(1, prompts.length * 2 + 1),
startedAt,
finishedAt: null,
});
const globalStyle = planData.globalStyle || {};
const styleDirective = [
globalStyle.designLanguage ? `全局风格:${globalStyle.designLanguage}` : "",
globalStyle.typography?.fontFamily
? `字体家族统一:${globalStyle.typography.fontFamily}`
: "",
globalStyle.typography?.title ? `标题规范:${globalStyle.typography.title}` : "",
globalStyle.typography?.subtitle
? `副标题规范:${globalStyle.typography.subtitle}`
: "",
globalStyle.typography?.body ? `正文规范:${globalStyle.typography.body}` : "",
globalStyle.layout?.grid ? `布局栅格:${globalStyle.layout.grid}` : "",
globalStyle.layout?.safeArea ? `安全区:${globalStyle.layout.safeArea}` : "",
globalStyle.layout?.rhythm ? `留白节奏:${globalStyle.layout.rhythm}` : "",
globalStyle.palette ? `配色:${globalStyle.palette}` : "",
]
.filter(Boolean)
.join(";");
const styleAnchorUrl =
index > 0 ? generatedImages[0]?.originalUrl || generatedImages[0]?.thumbnailUrl : null;
const generationReferenceImagesBase = [
...productAttachments.map((item) => item.url),
...(bundleShowcase ? bundleAttachments.map((item) => item.url) : []),
...(useModelForScene && modelAttachment?.url ? [modelAttachment.url] : []),
...(styleAnchorUrl ? [styleAnchorUrl] : []),
].filter(Boolean);
const generationPrompt = sanitizeDetailImageModelPrompt(
[
promptText,
styleDirective ? `全局样式约束:${styleDirective}` : "",
screen?.screenType
? `分镜类型:${screen.screenType}(A=技术公信,B=场景共鸣,C=细节佐证)`
: "",
index === 0
? "首屏封面规则:必须做成海报主视觉,单一主体强焦点,产品主体占画面55%-70%,顶部标题区清晰留白,禁止拼贴式说明图。"
: "",
useModelForScene
? "人物规则:本屏涉及人物场景,固定使用提供的模特图同一人物(面部、发型、体型、服装风格保持一致)。"
: "人物规则:本屏不要出现人物或人体部位,只展示产品与场景元素。",
screen?.modelReason ? `人物融入依据:${screen.modelReason}` : "",
bundleShowcase
? "搭配展示规则:本屏必须同场景展示主产品与搭配产品,主次层级清晰,突出组合购买价值与加购理由。"
: "",
bundleReason ? `搭配依据:${bundleReason}` : "",
screen?.title ? `本屏标题:${screen.title}` : "",
screen?.subtitle ? `本屏副标题:${screen.subtitle}` : "",
screen?.copy ? `本屏文案说明:${screen.copy}` : "",
screen?.layoutNote ? `本屏排版说明:${screen.layoutNote}` : "",
screen?.fabe?.b ? `B(利益):${screen.fabe.b}` : "",
screen?.fabe?.f ? `F(特征):${screen.fabe.f}` : "",
screen?.fabe?.a ? `A(优势):${screen.fabe.a}` : "",
screen?.fabe?.e ? `E(证据):${screen.fabe.e}` : "",
`输出为竖版电商详情图,尺寸固定 ${AGENT_DETAIL_TARGET_WIDTH}x${AGENT_DETAIL_TARGET_HEIGHT} 像素。`,
"要求:整套详情图风格统一,产品外观严格一致,主标题/副标题/正文字体与层级全套一致,商业摄影质感,排版区域清晰。",
]
.filter(Boolean)
.join("\n\n")
);
const firstPass = await requestCanvasImageGeneration({
prompt: generationPrompt,
model: resolvedModelId,
aspectRatio: "9:16",
imageSize: imageParams.imageSize,
imageCount: 1,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: generationReferenceImagesBase,
});
const firstPassImage = firstPass.images[0];
if (!firstPassImage?.originalUrl && !firstPassImage?.thumbnailUrl) {
throw new Error(`第 ${index + 1} 屏生成失败`);
}
let finalImage = {
originalUrl: String(
firstPassImage.originalUrl || firstPassImage.thumbnailUrl || ""
).trim(),
thumbnailUrl: String(
firstPassImage.thumbnailUrl || firstPassImage.originalUrl || ""
).trim(),
};
updateDetailGenerationProgressState({
status: "running",
stage: `第 ${index + 1}/${prompts.length} 屏首稿完成,正在审稿优化...`,
current: reviewStepCurrent,
total: Math.max(1, prompts.length * 2 + 1),
startedAt,
finishedAt: null,
});
try {
const reviewResponse = await fetch("/api/canvas/detail-review", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
outputLanguage,
generationMode: "classic",
imageUrl: finalImage.originalUrl || finalImage.thumbnailUrl,
referenceImages: generationReferenceImagesBase.slice(0, 6),
brief: {
productName: detailGenerationBrief.productName,
targetAudience: detailGenerationBrief.targetAudience,
usageScenario: detailGenerationBrief.usageScenario,
coreNeed: detailGenerationBrief.coreNeed,
coreSellingPoint: detailGenerationBrief.coreSellingPoint,
},
globalStyle: planData.globalStyle,
screen: {
index: index + 1,
title: String(screen?.title || "").trim(),
subtitle: String(screen?.subtitle || "").trim(),
copy: String(screen?.copy || "").trim(),
layoutNote: String(screen?.layoutNote || "").trim(),
visualPrompt: generationPrompt,
screenType: String(screen?.screenType || "").trim(),
},
}),
});
const reviewResult = (await reviewResponse.json().catch(() => ({}))) as {
success?: boolean;
data?: DirectDetailReviewResult;
};
if (
reviewResponse.ok &&
reviewResult?.success &&
reviewResult.data?.reviewAction === "regenerate_image" &&
typeof reviewResult.data?.revisedPrompt === "string" &&
reviewResult.data.revisedPrompt.trim().length > 20
) {
const regenerated = await requestCanvasImageGeneration({
prompt: sanitizeDetailImageModelPrompt(reviewResult.data.revisedPrompt.trim()),
model: resolvedModelId,
aspectRatio: "9:16",
imageSize: imageParams.imageSize,
imageCount: 1,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: [
...generationReferenceImagesBase,
finalImage.originalUrl || finalImage.thumbnailUrl,
].filter(Boolean),
});
const regeneratedImage = regenerated.images[0];
if (regeneratedImage?.originalUrl || regeneratedImage?.thumbnailUrl) {
finalImage = {
originalUrl: String(
regeneratedImage.originalUrl || regeneratedImage.thumbnailUrl || ""
).trim(),
thumbnailUrl: String(
regeneratedImage.thumbnailUrl || regeneratedImage.originalUrl || ""
).trim(),
};
}
}
} catch (reviewError) {
console.warn("[AgentGenerationWorkspaceDarkV2] AI detail review failed:", reviewError);
}
const nextImage: DirectDetailGenerationImage = {
originalUrl: finalImage.originalUrl,
thumbnailUrl: finalImage.thumbnailUrl,
title: String(screen?.title || "").trim() || `详情图 ${index + 1}`,
subtitle: String(screen?.subtitle || "").trim(),
copy: String(screen?.copy || "").trim(),
};
generatedImages.push(nextImage);
setDetailGenerationRunResults([...generatedImages]);
}
updateDetailGenerationProgressState({
status: "done",
stage: `AI详情生成完成,共 ${generatedImages.length}/${prompts.length} 屏`,
current: Math.max(1, prompts.length * 2 + 1),
total: Math.max(1, prompts.length * 2 + 1),
startedAt,
finishedAt: new Date().toISOString(),
});
toast.success(`AI详情生成完成,已输出 ${generatedImages.length} 屏详情图`);
} catch (error) {
const message =
error instanceof Error ? error.message : "AI详情生成失败";
updateDetailGenerationProgressState({
status: /已停止/.test(message) ? "idle" : "error",
stage: message,
current: detailGenerationRunResults.length,
total: pendingGeneration?.expectedCount || detailCount,
startedAt,
finishedAt: new Date().toISOString(),
});
toast.error(message);
} finally {
setPendingGeneration(null);
}
}
const handleDetailReplacementSearch = useCallback(async () => {
const normalized = detailReplacementQueryInput.trim().toUpperCase();
if (!workflowStoreName) {
toast.error("请先在第一步完成品牌和店铺选择后再查询参考图");
return;
}
if (!normalized) {
toast.error(`请输入 SPU 后再查询${detailReferenceSourceLabel}`);
return;
}
setDetailReplacementSearchState({
isLoading: true,
lastQuery: normalized,
});
try {
const searchParams = new URLSearchParams({
keyword: normalized,
queryType: "spu",
target: detailReferenceTarget,
});
if (workflowStoreName) {
searchParams.set("storeName", workflowStoreName);
}
const response = await fetch(
`/api/agent-workflow/reference-search?${searchParams.toString()}`,
{ cache: "no-store" }
);
const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse;
if (!response.ok || !result?.success || !result?.data) {
throw new Error(result?.error?.message || `${detailReferenceSourceLabel}参考图查询失败`);
}
updateDetailReplacementState({
query: normalized,
queryType: "spu",
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} 张${detailReferenceSourceLabel}参考图`);
} catch (error) {
setDetailReplacementSearchState({
isLoading: false,
lastQuery: normalized,
});
toast.error(error instanceof Error ? error.message : `${detailReferenceSourceLabel}参考图查询失败`);
}
}, [
detailReferenceSourceLabel,
detailReferenceTarget,
detailReplacementQueryInput,
updateDetailReplacementState,
workflowStoreName,
]);
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: detailReferenceTarget,
productCount: selectedProductAttachments.length,
}),
};
});
}, [detailReferenceTarget, 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 handleApplyStitchedDetailReferenceCards = useCallback(
(nextCards: AgentModeWorkspaceState["detailReplacement"]["references"]) => {
updateDetailReplacementState((current) => ({
...current,
references: nextCards,
}));
},
[updateDetailReplacementState]
);
const handleSkuReplacementSearch = useCallback(async () => {
const normalized = skuReplacementQueryInput.trim().toUpperCase();
if (!workflowStoreName) {
toast.error("请先在第一步完成品牌和店铺选择后再查询参考图");
return;
}
if (!normalized) {
toast.error(`请输入${skuReplacementQueryType === "spu" ? "SPU" : "SKU"}后再查询${skuReferenceSourceLabel}参考图`);
return;
}
setSkuReplacementSearchState({
isLoading: true,
lastQuery: normalized,
});
try {
const searchParams = new URLSearchParams({
keyword: normalized,
queryType: skuReplacementQueryType,
target: skuReferenceTarget,
});
if (workflowStoreName) {
searchParams.set("storeName", workflowStoreName);
}
const response = await fetch(
`/api/agent-workflow/reference-search?${searchParams.toString()}`,
{ cache: "no-store" }
);
const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse;
if (!response.ok || !result?.success || !result?.data) {
throw new Error(result?.error?.message || `${skuReferenceSourceLabel}参考图查询失败`);
}
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} 张${skuReferenceSourceLabel}参考图`);
} catch (error) {
setSkuReplacementSearchState({
isLoading: false,
lastQuery: normalized,
});
toast.error(error instanceof Error ? error.message : `${skuReferenceSourceLabel}参考图查询失败`);
}
}, [
selectedProductAttachments.length,
skuReferenceSourceLabel,
skuReferenceTarget,
skuReplacementQueryInput,
skuReplacementQueryType,
updateSkuReplacementState,
workflowStoreName,
]);
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: skuReferenceTarget,
productCount: selectedProductAttachments.length,
assignProductIndex: true,
}),
};
});
}, [selectedProductAttachments.length, skuReferenceTarget, 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]);
const aiDetailWorkbenchPanel = isDetailAiGenerationMethod ? (
<div className="rounded-[28px] border border-white/12 bg-white/[0.02] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Wand2 className="h-4 w-4 text-emerald-300" />
AI详情工作台
</h3>
<p className="mt-1 text-xs text-white/45">
复用无限画布中的 AI详情生成链路,直接完成分镜规划和逐屏详情生图。
</p>
</div>
<div className="flex items-center gap-2">
<div
className={cn(
"rounded-full border px-3 py-1 text-xs",
detailGenerationProgress.status === "running"
? "border-sky-400/25 bg-sky-500/[0.10] text-sky-100"
: detailGenerationProgress.status === "done"
? "border-emerald-400/25 bg-emerald-500/[0.10] text-emerald-100"
: detailGenerationProgress.status === "error"
? "border-rose-400/25 bg-rose-500/[0.10] text-rose-100"
: "border-white/10 bg-white/[0.03] text-white/55"
)}
>
{detailGenerationProgress.status === "running"
? "生成中"
: detailGenerationProgress.status === "done"
? "已完成"
: detailGenerationProgress.status === "error"
? "异常"
: "待启动"}
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/60">
经典详情
</div>
</div>
</div>
<div className="mt-4 rounded-[22px] border border-white/10 bg-black/25 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-xs font-medium text-white/82">生成进度</div>
<div className="text-[11px] text-white/45">
{detailGenerationProgress.current}/{detailGenerationProgress.total || 0}
</div>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/8">
<div
className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-teal-400 transition-all duration-500"
style={{
width: `${
detailGenerationProgress.total > 0
? Math.min(
100,
Math.round(
(detailGenerationProgress.current / detailGenerationProgress.total) * 100
)
)
: 0
}%`,
}}
/>
</div>
<div className="mt-2 text-[11px] text-white/48">
{detailGenerationProgress.stage || "请先补齐核心参数和素材后开始生成"}
</div>
{detailGenerationValidationIssues.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{detailGenerationValidationIssues.map((issue) => (
<span
key={issue}
className="rounded-full border border-amber-400/20 bg-amber-500/[0.10] px-2.5 py-1 text-[11px] text-amber-100"
>
{issue}
</span>
))}
</div>
) : null}
</div>
<div className="mt-4 grid gap-4 xl:grid-cols-[1.25fr_1fr]">
<div className="rounded-[22px] border border-white/10 bg-black/25 p-4">
<div className="mb-3 text-xs font-medium text-white/82">核心参数</div>
<div className="grid gap-3 sm:grid-cols-2">
{([
["productSpu", "商品SPU"],
["productName", "产品名称"],
["targetAudience", "主购买人群 *"],
["usageScenario", "需求场景 *"],
["coreNeed", "核心需求 *"],
["priceBand", "价格带"],
["demandType", "需求类型"],
["brandTone", "品牌语气"],
] as Array<[keyof AgentDetailBriefForm, string]>).map(([key, label]) => (
<div key={key} className="space-y-1">
<label className="text-[11px] text-white/58">{label}</label>
<Input
value={detailGenerationBrief[key]}
onChange={(event) =>
updateDetailGenerationBriefField(key, event.target.value)
}
placeholder={AGENT_DETAIL_FIELD_EXAMPLES[key]}
className="h-10 rounded-[18px] border-white/10 bg-black text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30"
/>
</div>
))}
<div className="space-y-1 sm:col-span-2">
<label className="text-[11px] text-white/58">核心卖点关键词</label>
<Input
value={detailGenerationBrief.coreSellingPoint}
onChange={(event) =>
updateDetailGenerationBriefField("coreSellingPoint", event.target.value)
}
placeholder={AGENT_DETAIL_FIELD_EXAMPLES.coreSellingPoint}
className="h-10 rounded-[18px] border-white/10 bg-black text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30"
/>
</div>
<div className="space-y-1 sm:col-span-2">
<label className="text-[11px] text-white/58">竞品对标</label>
<Input
value={detailGenerationBrief.competitorReference}
onChange={(event) =>
updateDetailGenerationBriefField("competitorReference", event.target.value)
}
placeholder={AGENT_DETAIL_FIELD_EXAMPLES.competitorReference}
className="h-10 rounded-[18px] border-white/10 bg-black text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30"
/>
</div>
<div className="space-y-1 sm:col-span-2">
<label className="text-[11px] text-white/58">用户原话样本</label>
<Textarea
value={detailGenerationBrief.userVoice}
onChange={(event) =>
updateDetailGenerationBriefField("userVoice", event.target.value)
}
placeholder={AGENT_DETAIL_FIELD_EXAMPLES.userVoice}
className="min-h-[88px] rounded-[18px] border-white/10 bg-black text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30"
/>
</div>
</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-black/25 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="text-xs font-medium text-white/82">素材中心</div>
<div className="text-[11px] text-white/45">
产品图 {detailGenerationProductCount}/{detailGenerationRequiredProductCount}
{modelSlotEnabled
? ` · 模特图 ${detailGenerationModelReady ? "已带入" : "可不选"}`
: ""}
{` · 搭配图 ${detailGenerationBundleCount}/3`}
</div>
</div>
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
{detailGenerationVisibleSlotIndexes.map((index) => {
const slot = AGENT_DETAIL_SLOT_CONFIGS[index];
const attachment = detailGenerationSlots[index];
return (
<div
key={slot.key}
className="group relative aspect-square overflow-hidden rounded-[18px] border border-white/10 bg-black/30"
>
<input
ref={(element) => {
detailSlotInputRefs.current[index] = element;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void handleDetailGenerationSlotFileChange(index, event);
}}
/>
{attachment ? (
<>
<img
src={buildPreviewImageUrl(attachment.url, 360)}
alt={attachment.name || slot.label}
className="h-full w-full object-cover"
onError={(event) => handlePreviewImageLoadError(event, attachment.url)}
/>
<div className="absolute inset-x-0 bottom-0 bg-black/70 px-2 py-1.5 text-[11px] text-white">
{slot.label}
</div>
<div className="absolute left-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
onClick={() => detailSlotInputRefs.current[index]?.click()}
className="rounded-full border border-white/10 bg-black/55 px-2 py-1 text-[10px] text-white"
>
更换
</button>
</div>
<button
type="button"
onClick={() => updateDetailGenerationSlot(index, null)}
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full border border-white/10 bg-black/55 text-white transition-colors hover:border-white/20"
>
<X className="h-3.5 w-3.5" />
</button>
</>
) : (
<button
type="button"
onClick={() => detailSlotInputRefs.current[index]?.click()}
className="flex h-full w-full flex-col items-center justify-center gap-2 text-white/55 transition-colors hover:text-white/78"
>
<Upload className="h-5 w-5" />
<span className="text-[11px]">{slot.label}</span>
</button>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
) : null;
const attachmentSlotPanel = (
<Collapsible
open={isAttachmentSlotExpanded}
onOpenChange={setIsAttachmentSlotExpanded}
>
<div className="rounded-[28px] border border-white/12 bg-white/[0.02] p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-white">
<ImagePlus className="h-4 w-4 text-emerald-300" />
输入图槽位
</div>
{agentUploadSlots.length > 0 ? (
<>
<div className="flex flex-wrap gap-2">
<span
className={cn(
"rounded-full border px-3 py-1 text-xs",
attachmentSummaryReady
? "border-emerald-400/25 bg-emerald-500/[0.10] text-emerald-100"
: "border-amber-400/25 bg-amber-500/[0.10] text-amber-100"
)}
>
{productAttachmentSummary}
</span>
{modelAttachmentSummary ? (
<span
className={cn(
"rounded-full border px-3 py-1 text-xs",
selectedModelAttachment
? "border-emerald-400/25 bg-emerald-500/[0.10] text-emerald-100"
: "border-white/10 bg-black/20 text-white/60"
)}
>
{modelAttachmentSummary}
</span>
) : null}
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-xs text-white/60">
共 {agentUploadSlots.length} 个输入槽位
</span>
</div>
<p className="text-xs leading-6 text-white/55">
{missingAttachmentCount > 0
? `当前还需补充 ${missingAttachmentCount} 张产品图,展开后可继续检查或替换。`
: "产品图已自动带入并准备完成;模特图槽位可按需补充,不选也可以继续生成。"}
</p>
</>
) : (
<p className="text-xs leading-6 text-white/45">
先选择一个生图模式,系统会按模板配置展示对应的产品图和模特图槽位。
</p>
)}
</div>
{agentUploadSlots.length > 0 ? (
<CollapsibleTrigger asChild>
<Button
type="button"
variant="outline"
className="border-white/12 bg-black/25 text-white/82 hover:border-white/22 hover:bg-white/[0.06] hover:text-white"
>
{isAttachmentSlotExpanded ? "收起素材槽位" : "检查/替换素材"}
{isAttachmentSlotExpanded ? (
<ChevronUp className="ml-2 h-4 w-4" />
) : (
<ChevronDown className="ml-2 h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
) : null}
</div>
{agentUploadSlots.length > 0 ? (
<CollapsibleContent className="mt-4 space-y-4">
<div className="rounded-[20px] border border-emerald-400/15 bg-emerald-500/[0.06] px-4 py-3 text-xs leading-6 text-emerald-100/80">
上一步已选的白底图和品牌模特图会自动带入对应槽位,这里只保留最终检查与单张上传替换,不再重复展示已选素材列表。
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{agentUploadSlots.map((slot, 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"
)}
>
<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"
onError={(event) =>
handlePreviewImageLoadError(event, attachments[index]!.url)
}
/>
<div className="absolute inset-x-0 bottom-0 bg-black/70 px-3 py-2 text-xs font-medium 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-2 text-white/55">
{slotUploading === index ? (
<Loader2 className="h-6 w-6 animate-spin text-emerald-400" />
) : (
<>
<Upload className="h-6 w-6" />
<span className="text-sm font-medium text-white/72">{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>
</div>
</motion.div>
);
})}
</div>
</CollapsibleContent>
) : (
<div className="mt-4 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>
</Collapsible>
);
return (
<div className="flex h-dvh flex-col overflow-hidden bg-black">
<AgentCanvasTopNavDark
siteName={siteName}
siteLogo={siteLogo}
siteLogoDark={siteLogoDark}
onBack={onBack}
activeKey="agent"
rightSlot={topNavRightSlot || <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">
{showcaseConfig ? (
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.04 }}
>
<AgentWorkflowShowcaseBanner
config={showcaseConfig}
actionLabel="开始选择模式"
secondaryActionLabel={allGeneratedImages.length > 0 ? "查看结果预览" : undefined}
onActionClick={() => scrollToSection("agent-anchor-mode")}
onSecondaryActionClick={() => scrollToSection("agent-anchor-results")}
/>
</motion.section>
) : null}
<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="space-y-6">
<section id="agent-anchor-mode" className="scroll-mt-24 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="mb-4 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="flex items-center gap-2">
<span className="rounded-full bg-emerald-500/18 px-2.5 py-1 text-[11px] font-semibold text-emerald-200">
01
</span>
<span className="text-sm font-semibold text-white">选择生图模式</span>
</div>
<p className="text-sm text-white/45">
先确定本次使用的模式,并完成品牌和店铺选择,后续的商品图、品牌模特、参考图和结果展示都会随之联动。
</p>
{isDetailReplacementPreset || isSkuReplacementPreset ? (
<p className="text-xs text-amber-300/80">
{isDetailReplacementPreset
? isDetailAiGenerationMethod
? "当前模板已切换到 AI详情生成,系统会直接复用智能规划详情链路生成整套详情图。"
: "当前模板属于参考图替换模式,结果区会按参考图顺序逐屏展示。"
: "当前模板属于 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}
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>
{presetPanel ? (
<div className="agent-dark-scrollbar max-h-[760px] 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>
)}
{brandStorePanel ? (
<div className="mt-4 border-t border-white/10 pt-4">
{brandStorePanel}
</div>
) : null}
</section>
{hasMaterialStep ? (
<section id="agent-anchor-materials" className="scroll-mt-24 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="mb-4 space-y-1 border-b border-white/10 pb-4">
<div className="flex items-center gap-2">
<span className="rounded-full bg-emerald-500/18 px-2.5 py-1 text-[11px] font-semibold text-emerald-200">
02
</span>
<span className="text-sm font-semibold text-white">选择 SPU / SKU</span>
</div>
<p className="text-sm text-white/45">
先搜索商品白底图与搭配图,再继续完成风格步骤和生成参数配置。
</p>
</div>
{materialSelectionPanel}
</section>
) : null}
{hasWorkflowStyleStep ? (
<section id="agent-anchor-style" className="scroll-mt-24 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="mb-4 space-y-1 border-b border-white/10 pb-4">
<div className="flex items-center gap-2">
<span className="rounded-full bg-emerald-500/18 px-2.5 py-1 text-[11px] font-semibold text-emerald-200">
{styleStepBadge}
</span>
<span className="text-sm font-semibold text-white">风格步骤卡片</span>
</div>
<p className="text-sm text-white/45">
先完成风格步骤卡片,再继续选择品牌模特,方便后续做人群联动,不再跳转第二个页面。
</p>
</div>
<div className="space-y-4">
{workflowStylePanel}
{hasBrandModelStep ? brandModelPanel : null}
</div>
</section>
) : null}
<section id="agent-anchor-generate" className="scroll-mt-24 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="mb-4 space-y-1 border-b border-white/10 pb-4">
<div className="flex items-center gap-2">
<span className="rounded-full bg-emerald-500/18 px-2.5 py-1 text-[11px] font-semibold text-emerald-200">
{generateStepBadge}
</span>
<span className="text-sm font-semibold text-white">生图与结果预览</span>
</div>
<p className="text-sm text-white/45">
确认补充要求、输入图槽位和生成参数后直接开始生成,结果会显示在当前页底部预览区。
</p>
</div>
<div className="space-y-4">
<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" />
{isDetailAiGenerationMethod ? "详情需求补充" : "补充要求"}
</h3>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={
isDetailAiGenerationMethod
? "补充详情页想强调的卖点、视觉氛围、风格要求或屏数,例如:生成12屏现代极简风详情页..."
: "描述你想要的风格、氛围、构图..."
}
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>
{hasAgentUploadSlots && hasMaterialStep && hasSatisfiedSlotRequirement ? (
<div className="flex flex-col gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium text-white">输入图已在上一步确认</div>
<div className="text-xs text-white/45">
{productAttachmentSummary}
{modelAttachmentSummary ? `,${modelAttachmentSummary}` : ""}
。如需微调,再手动展开编辑即可。
</div>
</div>
<Button
type="button"
variant="outline"
onClick={() => setIsAttachmentSlotExpanded(true)}
className="border-white/12 bg-black/25 text-white/82 hover:border-white/22 hover:bg-white/[0.06] hover:text-white"
>
手动调整输入图
</Button>
</div>
) : null}
{hasAgentUploadSlots && isAttachmentSlotExpanded ? attachmentSlotPanel : null}
<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">
{isDetailReplacementPreset ? (
<div className="rounded-[24px] border border-emerald-400/22 bg-emerald-500/[0.08] p-3 shadow-[0_0_0_1px_rgba(52,211,153,0.08),0_12px_36px_rgba(16,185,129,0.08)]">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<div className="text-sm font-semibold text-white">详情模式</div>
<div className="text-xs text-emerald-100/72">
顶部直接切换“参考图替换”或“AI详情生成”,生成结果会自动归档到对应分类。
</div>
</div>
<div className="grid w-full grid-cols-2 gap-2 lg:w-auto lg:min-w-[360px]">
<button
type="button"
onClick={() => updateDetailGenerationMethod("reference_replace")}
className={cn(
"flex h-11 items-center justify-center rounded-[18px] border text-sm font-medium transition-all",
!isDetailAiGenerationMethod
? "border-emerald-300/45 bg-emerald-400/18 text-white shadow-[0_0_0_1px_rgba(110,231,183,0.2),0_10px_28px_rgba(16,185,129,0.16)]"
: "border-white/12 bg-black/25 text-white/68 hover:border-white/22 hover:text-white"
)}
>
参考图替换
</button>
<button
type="button"
onClick={() => updateDetailGenerationMethod("ai_detail_generate")}
className={cn(
"flex h-11 items-center justify-center rounded-[18px] border text-sm font-medium transition-all",
isDetailAiGenerationMethod
? "border-emerald-300/45 bg-emerald-400/18 text-white shadow-[0_0_0_1px_rgba(110,231,183,0.2),0_10px_28px_rgba(16,185,129,0.16)]"
: "border-white/12 bg-black/25 text-white/68 hover:border-white/22 hover:text-white"
)}
>
AI详情生成
</button>
</div>
</div>
</div>
) : null}
{aiDetailWorkbenchPanel}
{isReplacementPreset || isDetailAiGenerationMethod ? (
<div className="rounded-[24px] border border-amber-400/20 bg-amber-500/[0.08] px-4 py-3 text-sm text-amber-100/90">
{isDetailAiGenerationMethod
? "AI详情生成会先自动规划整套详情分镜,再按顺序逐屏生成,结果会保留在“AI详情”分类中。"
: 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={darkSelectContentClass}>
{selectableModels.map((model) => (
<SelectItem
key={model.id}
value={model.id}
className="text-white focus:bg-white/10 focus: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}
/>
{isDetailAiGenerationMethod ? (
<div className="rounded-[18px] border border-white/10 bg-black/25 px-4 py-3 text-xs leading-6 text-white/52">
AI详情模式下,详情屏数会优先按你在需求里写的“X屏 / X张 / X页”来规划;这里的数量参数不会直接决定最终屏数。
</div>
) : null}
<div className="flex gap-3">
<Button
onClick={handleSend}
disabled={
isAnyGenerationRunning ||
!selectedAgentPresetId ||
(isDetailAiGenerationMethod
? detailGenerationValidationIssues.length > 0
: hasAgentUploadSlots && !hasSatisfiedSlotRequirement)
}
className={cn(
"h-11 flex-1 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"
)}
>
{isAnyGenerationRunning ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{pendingGeneration?.modeType === currentResultModeType
? `${isDetailAiGenerationMethod ? "详情生成中" : isReplacementPreset ? "批量处理中" : "生成中"} ${Math.min(
pendingGenerationProgressCount,
pendingGeneration.expectedCount
)}/${pendingGeneration.expectedCount}`
: "任务处理中..."}
</>
) : (
<>
<ArrowUp className="h-4 w-4" />
{isDetailAiGenerationMethod
? "开始AI详情生成"
: isReplacementPreset
? "开始批量替换"
: "开始生成"}
</>
)}
</Button>
{isAnyGenerationRunning ? (
<Button
type="button"
variant="outline"
onClick={handleStopGeneration}
className="h-11 shrink-0 rounded-[24px] border-white/12 bg-black/30 px-4 text-white hover:bg-white/[0.08]"
>
<X className="mr-2 h-4 w-4" />
停止生成
</Button>
) : null}
</div>
{slotRequirementMessage ? (
<div className="rounded-[18px] border border-amber-400/20 bg-amber-500/[0.08] px-4 py-3 text-xs leading-6 text-amber-100/90">
{slotRequirementMessage}
</div>
) : null}
{sceneImageRolePromptPreview ? (
<Collapsible
open={isScenePromptExpanded}
onOpenChange={setIsScenePromptExpanded}
className="rounded-[22px] border border-white/10 bg-black/25"
>
<div className="flex flex-wrap items-start justify-between gap-3 px-4 py-3">
<div>
<div className="text-sm font-medium text-white">系统预设约束</div>
<div className="mt-1 text-xs leading-6 text-white/55">
最终发送给模型时,会自动追加产品锁定约束
{modelSlotEnabled ? "和模特锁定约束" : ""}。
</div>
</div>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="outline"
className="h-9 rounded-[18px] border-white/12 bg-black/20 px-3 text-white/80 hover:border-white/22 hover:bg-white/[0.06] hover:text-white"
>
{isScenePromptExpanded ? "收起约束" : "查看约束"}
{isScenePromptExpanded ? (
<ChevronUp className="ml-2 h-4 w-4" />
) : (
<ChevronDown className="ml-2 h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className="border-t border-white/8 px-4 py-4">
<pre className="whitespace-pre-wrap text-xs leading-6 text-white/78">
{sceneImageRolePromptPreview}
</pre>
</div>
</CollapsibleContent>
</Collapsible>
) : null}
</div>
</div>
</div>
</section>
</div>
</motion.section>
{isDetailReplacementPreset && !isDetailAiGenerationMethod ? (
<motion.section
className="relative 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.08 }}
>
<div className="border-b border-white/12 px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">参考详情搜索</div>
<div className="text-xs text-white/40">
按 SPU 查询候选整套,并选择一套{detailReferenceSourceLabel}参考图进入下方结果区。
</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>
<div className="space-y-4 p-5">
<div className="inline-flex rounded-full border border-emerald-400/35 bg-emerald-500/[0.10] px-3 py-1.5 text-xs text-emerald-200">
按SPU查询
</div>
<div className="flex flex-col gap-2 lg:flex-row">
<Input
value={detailReplacementQueryInput}
onChange={(e) =>
updateDetailReplacementState({
query: e.target.value,
queryType: "spu",
})
}
placeholder={`输入 SPU,查询任务整套中的${detailReferenceSourceLabel}参考图`}
className="h-12 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-12 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" />
查询中
</>
) : (
`查询${detailReferenceSourceLabel}`
)}
</Button>
{detailReplacementReferenceCards.length > 0 ? (
<Button
type="button"
variant="outline"
onClick={() => setDetailReferenceStitchDialogOpen(true)}
className="h-12 rounded-[22px] border-emerald-400/20 bg-emerald-500/[0.08] px-5 text-sm text-emerald-100 hover:bg-emerald-500/[0.14] hover:text-emerald-50"
>
拼合切割参考图
</Button>
) : null}
{detailReplacementReferenceCards.length > 0 ? (
<Button
type="button"
variant="outline"
onClick={handleClearDetailReferenceCards}
className="h-12 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="rounded-[18px] border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-white/48">
当前参考图查询:
SPU /
{detailReplacementSearchState.lastQuery ||
detailReplacementQueryInput.trim().toUpperCase()}
{workflowStoreName ? (
<span className="ml-2 text-white/40">{`店铺 / ${workflowStoreName}`}</span>
) : null}
<span className="ml-2 text-emerald-200/70">
{`已接入真实${detailReferenceSourceLabel}参考图接口`}
</span>
</div>
) : null}
{detailReplacementCandidates.length > 0 ? (
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-xs text-white/42">
候选整套素材 {detailReplacementCandidates.length} 套
</div>
<div className="text-[11px] text-emerald-200/70">
默认载入最新一套,可随时切换
</div>
</div>
<div className="agent-dark-scrollbar grid max-h-[360px] grid-cols-1 gap-3 overflow-y-auto pr-1 md:grid-cols-2 xl:grid-cols-4">
{detailReplacementCandidates.map((candidate) => {
const isSelected = detailReplacementSelectedCandidateId === candidate.id;
const previewUrls = getCandidatePreviewUrls(
candidate,
detailReferenceTarget,
4
);
const primaryPreviewUrl =
previewUrls[0] || getCandidatePreviewUrl(candidate, detailReferenceTarget);
const uploadedAtText = (() => {
const raw = candidate.uploadedAt ? new Date(candidate.uploadedAt) : null;
if (!raw || Number.isNaN(raw.getTime())) return "未记录";
return raw.toLocaleString("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
})();
const summaryBadges = [
`${detailReferenceSourceLabel}`,
`${getCandidateTargetCount(candidate, detailReferenceTarget)}张`,
candidate.categoryName || null,
].filter((item): item is string => Boolean(item));
return (
<button
key={candidate.id}
type="button"
onClick={() => handleSelectDetailReplacementCandidate(candidate.id)}
className={cn(
"group relative min-w-0 overflow-hidden rounded-2xl border p-2 text-left transition-all duration-300",
isSelected
? "border-emerald-400/40 bg-emerald-500/[0.08] shadow-[0_0_24px_rgba(16,185,129,0.14)]"
: "border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06]"
)}
>
<div className="flex items-stretch gap-3">
<div className="relative w-[72px] min-h-[96px] shrink-0 overflow-hidden rounded-[18px] border border-white/10 bg-white">
<div className="h-full w-full">
{primaryPreviewUrl ? (
<img
src={buildPreviewImageUrl(primaryPreviewUrl, 360)}
alt={candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
className="h-full w-full object-contain object-top transition-transform duration-500 group-hover:scale-[1.02]"
onError={(event) =>
handlePreviewImageLoadError(event, primaryPreviewUrl)
}
/>
) : (
<div className="h-full w-full bg-[linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0.01))]" />
)}
</div>
<div className="absolute inset-0 bg-gradient-to-t from-black/35 to-transparent" />
<div className="absolute left-1.5 top-1.5 rounded-full border border-white/10 bg-black/45 px-1.5 py-0.5 text-[9px] text-white/78">
{getCandidateTargetCount(candidate, detailReferenceTarget)}张
</div>
</div>
<div className="flex min-h-[96px] min-w-0 flex-1 flex-col justify-between pr-5">
<div className="line-clamp-2 text-[12px] font-semibold leading-5 text-white">
{candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
</div>
<div className="mt-1.5 flex flex-wrap gap-1">
{summaryBadges.map((badge, badgeIndex) => (
<span
key={`${candidate.id}-badge-${badgeIndex}-${badge}`}
className={cn(
"rounded-full border px-1.5 py-0.5 text-[8px] leading-none",
badgeIndex === 0
? "border-emerald-400/25 bg-emerald-500/[0.12] text-emerald-100"
: "border-white/10 bg-white/[0.04] text-white/62"
)}
>
{badge}
</span>
))}
</div>
<div className="mt-1.5 text-[9px] leading-4 text-white/45">
<div className="truncate">
场景名称:{candidate.sceneName || candidate.productModel || "未命名整套"}
</div>
<div className="truncate">
任务编号:{candidate.taskNo || "未记录"}
</div>
</div>
<div className="mt-1.5 flex flex-col gap-0.5 text-[9px] text-white/50">
<div className="truncate">店铺:{candidate.shopName || "未记录"}</div>
<div className="truncate">更新时间:{uploadedAtText}</div>
</div>
</div>
{isSelected ? (
<div className="absolute right-3 top-3 h-2.5 w-2.5 rounded-full bg-emerald-400 shadow-[0_0_0_4px_rgba(16,185,129,0.18),0_0_18px_rgba(52,211,153,0.45)]" />
) : null}
</div>
</button>
);
})}
</div>
</div>
) : null}
</div>
</motion.section>
) : null}
{isDetailReplacementPreset && !isDetailAiGenerationMethod ? (
<AgentDetailReferenceStitchDialog
open={detailReferenceStitchDialogOpen}
onOpenChange={setDetailReferenceStitchDialogOpen}
cards={detailReplacementReferenceCards}
sourceLabel={`${detailReferenceSourceLabel}参考图`}
onApply={handleApplyStitchedDetailReferenceCards}
/>
) : null}
<motion.section
id="agent-anchor-results"
className={cn(
"relative flex min-h-[620px] flex-col rounded-[28px] border border-white/12 bg-white/[0.04] shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]",
isDetailReplacementPreset ? "overflow-visible" : "overflow-hidden"
)}
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
? isDetailAiGenerationMethod
? "AI详情生成结果会按生成顺序保留在当前项目中,并独立归档到“AI详情”分类。"
: "参考图替换结果会按参考图顺序纵向展示,并保持在当前工作台预览,不会自动回写到无限画布。"
: isSkuReplacementPreset
? "SKU替换结果会按参考图顺序一对一展示,并保持在当前工作台预览,不会自动回写到无限画布。"
: "结果仅保留在当前生图工作台预览,不会自动回写到无限画布。"}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{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: mergedResultCollections.detail_replacement,
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}
{activeResultModeType === "detail_generation" &&
mergedResultCollections.detail_generation.length > 0 ? (
<button
type="button"
onClick={() =>
void handleBatchDownloadImages({
imageUrls: mergedResultCollections.detail_generation,
zipName: `${selectedAgentPreset?.name || "ai-detail"}-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}
{activeResultModeType === "sku_replacement" &&
mergedResultCollections.sku_replacement.length > 0 ? (
<button
type="button"
onClick={() =>
void handleBatchDownloadImages({
imageUrls: mergedResultCollections.sku_replacement,
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}
{activeResultModeType === "scene_generation" && activeResultModeUrls.length > 1 ? (
<button
type="button"
onClick={() =>
void handleBatchDownloadImages({
imageUrls: activeResultModeUrls,
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}
{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>
) : activeResultModeType === "detail_generation" &&
isDetailAiGenerationMethod &&
activeResultModeType === currentResultModeType ? (
<div className="rounded-full bg-white/8 px-3 py-1 text-xs text-white/60">
预估 {detailGenerationExpectedCount || 0} 屏
</div>
) : 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>
) : activeResultModeUrls.length > 0 ? (
<div className="rounded-full bg-white/8 px-3 py-1 text-xs text-white/60">
共 {activeResultModeUrls.length} 张
</div>
) : null}
{pendingGeneration?.modeType === activeResultModeType ? (
<div className="rounded-full border border-sky-400/25 bg-sky-500/[0.10] px-3 py-1 text-xs text-sky-100">
处理中 {Math.min(pendingGenerationProgressCount, pendingGeneration.expectedCount)}/
{pendingGeneration.expectedCount}
</div>
) : activeResultModeType === "detail_generation" &&
detailGenerationProgress.status === "running" &&
detailGenerationExpectedCount > 0 ? (
<div className="rounded-full border border-sky-400/25 bg-sky-500/[0.10] px-3 py-1 text-xs text-sky-100">
处理中 {Math.min(detailGenerationRunResults.length, detailGenerationExpectedCount)}/
{detailGenerationExpectedCount}
</div>
) : null}
<button
type="button"
onClick={() =>
openPsdTemplateDialog({
mode: getPsdTemplateModeByResultMode(activeResultModeType),
})
}
className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/72 transition-colors hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
<ImagePlus className="h-3.5 w-3.5" />
<span>套PSD</span>
</button>
</div>
</div>
{isDetailReplacementPreset &&
!isDetailAiGenerationMethod &&
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">
<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"
onError={(event) =>
handlePreviewImageLoadError(event, attachment!.url)
}
/>
</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="overflow-hidden rounded-[22px] border border-emerald-400/20 bg-emerald-500/[0.06]">
<div className="relative aspect-[5/4] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.12),transparent_58%),linear-gradient(180deg,#08110d_0%,#101916_100%)]">
<img
src={buildPreviewImageUrl(selectedModelAttachment.url, 720)}
alt={selectedModelAttachment.name || "模特图"}
className="h-full w-full object-cover"
onError={(event) =>
handlePreviewImageLoadError(event, selectedModelAttachment.url)
}
/>
<div className="absolute left-3 top-3 rounded-full border border-emerald-300/25 bg-black/45 px-2.5 py-1 text-[11px] text-emerald-100">
模特图
</div>
</div>
<div className="border-t border-emerald-400/15 px-3 py-2">
<div className="truncate text-xs font-medium text-emerald-100">
{selectedModelAttachment.name || "已选择模特图"}
</div>
</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">{detailReferenceSourceLabel}参考图</div>
<div className="text-xs text-white/40">
仅支持按 SPU 查询任务整套候选,并切换某一套的{detailReferenceSourceLabel}参考图。
</div>
</div>
<div className="flex items-center gap-2">
{detailReplacementReferenceCards.length > 0 ? (
<button
type="button"
onClick={() => setDetailReferenceStitchDialogOpen(true)}
className="rounded-full border border-emerald-400/20 bg-emerald-500/[0.08] px-3 py-1 text-xs text-emerald-100 transition-colors hover:bg-emerald-500/[0.14]"
>
拼合切割
</button>
) : null}
<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>
<div className="grid grid-cols-1 gap-3">
{detailReplacementReferenceCards.length > 0 ? (
detailReplacementReferenceCards.map((card, index) => (
(() => {
const isActive = activeDetailReferenceCardId === card.id;
return (
<div
key={card.id}
className={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
isActive
? "border-emerald-400/40 shadow-[0_0_0_1px_rgba(52,211,153,0.15)]"
: "border-white/10"
)}
onClick={() => setActiveDetailReferenceCardId(card.id)}
>
<div className="relative min-h-[220px] 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="block h-auto w-full"
onError={(event) =>
handlePreviewImageLoadError(event, card.imageUrl || "")
}
/>
) : null}
<div className="absolute left-3 top-3 rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[11px] text-white/78">
参考图 {index + 1}
</div>
{isActive ? (
<div className="absolute left-3 top-12 rounded-full border border-emerald-300/35 bg-emerald-500/[0.14] px-2.5 py-1 text-[11px] text-emerald-100">
当前联动
</div>
) : 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} 张{detailReferenceSourceLabel}参考图
</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">等待{detailReferenceSourceLabel}参考图</div>
<div className="mt-2 text-xs leading-6 text-white/40">
输入 SPU 后,这里会展示任务整套里的真实{detailReferenceSourceLabel}参考图。
删除不需要的参考图后,列表会自动上移补位。
</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">
每张{detailReferenceSourceLabel}参考图将对应生成一张替换结果,并支持逐屏文字编辑。
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-emerald-200/80">
已处理 {detailProcessedCount}/{detailResultCards.length || 0}
</div>
</div>
<div className="grid grid-cols-1 gap-3">
{detailResultCards.length > 0 ? (
detailResultCards.map((card) => (
(() => {
const isActive = activeDetailReferenceCardId === card.id;
const statusMeta = getReplacementCardStatusMeta(card.status);
return (
<div
key={`${card.id}-result`}
className={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
isActive
? "border-emerald-400/40 shadow-[0_0_0_1px_rgba(52,211,153,0.15)]"
: "border-white/10"
)}
>
{card.resultUrl ? (
<div className="relative min-h-[220px] overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(card.resultUrl, 720)}
alt={`结果图 ${card.index + 1}`}
className="block h-auto w-full cursor-zoom-in"
onError={(event) =>
handlePreviewImageLoadError(event, card.resultUrl!)
}
onClick={() => {
setActiveDetailReferenceCardId(card.id);
setFullscreenPreview(card.resultUrl!);
}}
/>
<div className={cn("absolute left-3 top-3 rounded-full border px-2.5 py-1 text-[11px]", statusMeta.className)}>
{statusMeta.label}
</div>
{isActive ? (
<div className="absolute left-3 top-12 rounded-full border border-emerald-300/35 bg-emerald-500/[0.14] px-2.5 py-1 text-[11px] text-emerald-100">
当前联动
</div>
) : null}
<button
type="button"
onClick={() =>
handleDeleteResultImage("detail_replacement", card.resultUrl!)
}
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full border border-rose-400/25 bg-black/60 text-rose-100 transition-colors hover:border-rose-300/45 hover:bg-rose-500/15"
aria-label="删除结果图"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => {
void handleDownloadOriginalImage(card.resultUrl!);
}}
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: card.resultUrl!,
title: `${selectedAgentPreset?.name || "参考图替换"} 结果图 ${card.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="aspect-[5/3]"
onClick={() => setActiveDetailReferenceCardId(card.id)}
>
<AnimatedGenerationPlaceholderCard
badge={statusMeta.label}
badgeClassName={statusMeta.className}
showSpinner={card.status === "generating"}
title={`结果图 ${card.index + 1}`}
description={`将按“输入图组 + 第 ${card.index + 1} 张${detailReferenceSourceLabel}参考图”生成`}
/>
</div>
)}
<div className="border-t border-white/10 px-3 py-2 text-[11px] text-white/45">
{card.resultUrl
? card.status === "passthrough"
? "当前参考图未检测到可替换产品,本屏直接沿用参考图展示。"
: card.status === "fallback"
? "当前参考图替换失败,本屏先沿用参考图展示。"
: "已生成,可继续逐屏调整文字或重新执行。"
: card.status === "generating"
? "当前正在生成这一屏,请稍候。"
: card.status === "queued"
? "前面的屏生成完成后,会继续处理这一屏。"
: card.status === "missing"
? "当前这屏还没有回传结果,可重新执行整套生成。"
: "生成完成后,这里会接入现有无限画布的文字编辑能力。"}
</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">
{detailReferenceSourceLabel}参考图加载后,右侧会按相同顺序生成对应结果图,并逐屏支持文字编辑。
</div>
</div>
)}
</div>
</div>
</div>
) : 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">
<div>
<div className="text-sm font-semibold text-white">输入图</div>
<div className="text-xs text-white/40">
选中的白底图会和{skuReferenceSourceLabel}参考图建立一对一替换关系
</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"
onError={(event) =>
handlePreviewImageLoadError(event, attachment!.url)
}
/>
</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="overflow-hidden rounded-[22px] border border-emerald-400/20 bg-emerald-500/[0.06]">
<div className="relative aspect-[5/4] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.12),transparent_58%),linear-gradient(180deg,#08110d_0%,#101916_100%)]">
<img
src={buildPreviewImageUrl(selectedModelAttachment.url, 720)}
alt={selectedModelAttachment.name || "模特图"}
className="h-full w-full object-cover"
onError={(event) =>
handlePreviewImageLoadError(event, selectedModelAttachment.url)
}
/>
<div className="absolute left-3 top-3 rounded-full border border-emerald-300/25 bg-black/45 px-2.5 py-1 text-[11px] text-emerald-100">
模特图
</div>
</div>
<div className="border-t border-emerald-400/15 px-3 py-2">
<div className="truncate text-xs font-medium text-emerald-100">
{selectedModelAttachment.name || "已选择模特图"}
</div>
</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">{skuReferenceSourceLabel}参考图</div>
<div className="text-xs text-white/40">
支持按 SKU 或 SPU 查询任务整套候选,并切换对应的{skuReferenceSourceLabel}参考图。
</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">
<Input
value={skuReplacementQueryInput}
onChange={(e) =>
updateSkuReplacementState({
query: e.target.value,
})
}
placeholder={`输入${skuReplacementQueryType === "spu" ? "SPU" : "SKU"},查询任务整套中的${skuReferenceSourceLabel}参考图`}
className="h-12 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-12 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" />
查询中
</>
) : (
`查询${skuReferenceSourceLabel}`
)}
</Button>
{skuReplacementReferenceCards.length > 0 ? (
<Button
type="button"
variant="outline"
onClick={handleClearSkuReferenceCards}
className="h-12 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()}
{workflowStoreName ? (
<span className="ml-2 text-white/40">{`店铺 / ${workflowStoreName}`}</span>
) : null}
<span className="ml-2 text-emerald-200/70">
{`已接入真实${skuReferenceSourceLabel}参考图接口`}
</span>
</div>
) : null}
{skuReplacementCandidates.length > 0 ? (
<div className="mb-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-xs text-white/42">
候选整套素材 {skuReplacementCandidates.length} 套
</div>
<div className="text-[11px] text-emerald-200/70">
默认载入最新一套,可随时切换
</div>
</div>
<div className="agent-dark-scrollbar flex gap-2 overflow-x-auto pb-1">
{skuReplacementCandidates.map((candidate) => {
const isSelected = skuReplacementSelectedCandidateId === candidate.id;
const previewUrl = getCandidatePreviewUrl(candidate, skuReferenceTarget);
return (
<button
key={candidate.id}
type="button"
onClick={() => handleSelectSkuReplacementCandidate(candidate.id)}
className={cn(
"min-w-[280px] overflow-hidden rounded-[20px] border 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="relative aspect-[16/9] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.18),transparent_58%),linear-gradient(180deg,#0b0d0f_0%,#11161a_100%)]">
{previewUrl ? (
<img
src={buildPreviewImageUrl(previewUrl, 640)}
alt={candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
className="h-full w-full object-cover"
onError={(event) =>
handlePreviewImageLoadError(event, previewUrl)
}
/>
) : null}
<div className="absolute left-3 top-3 rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[11px] text-white/78">
{getCandidateTargetCount(candidate, skuReferenceTarget)} 张{skuReferenceSourceLabel}
</div>
{isSelected ? (
<div className="absolute right-3 top-3 rounded-full border border-emerald-300/35 bg-emerald-500/[0.14] px-2.5 py-1 text-[11px] text-emerald-100">
当前使用
</div>
) : null}
</div>
<div className="space-y-2 px-3 py-3">
<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, skuReferenceTarget) || `点击切换这套${skuReferenceSourceLabel}参考图`}
</div>
<div className="flex flex-wrap gap-1.5 text-[11px] text-white/48">
{candidate.shopName ? (
<span className="rounded-full bg-white/[0.05] px-2 py-1">
{candidate.shopName}
</span>
) : null}
{candidate.categoryName ? (
<span className="rounded-full bg-white/[0.05] px-2 py-1">
{candidate.categoryName}
</span>
) : null}
</div>
</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={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
activeSkuReferenceCardId === card.id
? "border-emerald-400/40 shadow-[0_0_0_1px_rgba(52,211,153,0.15)]"
: "border-white/10"
)}
onClick={() => setActiveSkuReferenceCardId(card.id)}
>
{(() => {
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"
onError={(event) =>
handlePreviewImageLoadError(event, card.imageUrl || "")
}
/>
) : null}
<div className="absolute left-3 top-3 rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[11px] text-white/78">
参考图 {index + 1}
</div>
{activeSkuReferenceCardId === card.id ? (
<div className="absolute left-3 top-12 rounded-full border border-emerald-300/35 bg-emerald-500/[0.14] px-2.5 py-1 text-[11px] text-emerald-100">
当前联动
</div>
) : 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} 张{skuReferenceSourceLabel}参考图
</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">等待{skuReferenceSourceLabel}参考图</div>
<div className="mt-2 text-xs leading-6 text-white/40">
输入 SKU 或 SPU 后,这里会展示任务整套里的真实{skuReferenceSourceLabel}参考图。
选择多少张图,就会按相同数量进入替换流程。
</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">
每张{skuReferenceSourceLabel}参考图会对应一张结果图,形成一对一批量替换结果。
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-emerald-200/80">
已处理 {skuProcessedCount}/{skuResultCards.length || 0}
</div>
</div>
<div className="agent-dark-scrollbar max-h-[720px] space-y-3 overflow-y-auto pr-1">
{skuResultCards.length > 0 ? (
skuResultCards.map((card) => {
const statusMeta = getReplacementCardStatusMeta(card.status);
return (
<div
key={`${card.id}-result`}
className={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
activeSkuReferenceCardId === card.id
? "border-emerald-400/40 shadow-[0_0_0_1px_rgba(52,211,153,0.15)]"
: "border-white/10"
)}
>
{card.resultUrl ? (
<div className="relative aspect-[5/3] overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(card.resultUrl, 720)}
alt={`结果图 ${card.index + 1}`}
className="h-full w-full cursor-zoom-in object-cover"
onError={(event) =>
handlePreviewImageLoadError(event, card.resultUrl!)
}
onClick={() => {
setActiveSkuReferenceCardId(card.id);
setFullscreenPreview(card.resultUrl!);
}}
/>
<div className={cn("absolute left-3 top-3 rounded-full border px-2.5 py-1 text-[11px]", statusMeta.className)}>
{statusMeta.label}
</div>
<button
type="button"
onClick={() =>
handleDeleteResultImage("sku_replacement", card.resultUrl!)
}
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full border border-rose-400/25 bg-black/60 text-rose-100 transition-colors hover:border-rose-300/45 hover:bg-rose-500/15"
aria-label="删除结果图"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => {
void handleDownloadOriginalImage(card.resultUrl!);
}}
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: card.resultUrl!,
title: `${selectedAgentPreset?.name || "SKU替换"} 结果图 ${card.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="aspect-[5/3]"
onClick={() => setActiveSkuReferenceCardId(card.id)}
>
<AnimatedGenerationPlaceholderCard
badge={statusMeta.label}
badgeClassName={statusMeta.className}
showSpinner={card.status === "generating"}
title={`结果图 ${card.index + 1}`}
description={`将按“输入图 + 第 ${card.index + 1} 张${skuReferenceSourceLabel}参考图”生成`}
/>
</div>
)}
<div className="border-t border-white/10 px-3 py-2 text-[11px] text-white/45">
{card.resultUrl
? card.status === "passthrough"
? "当前参考图未检测到可替换产品,这一张直接沿用参考图展示。"
: card.status === "fallback"
? "当前参考图替换失败,这一张先沿用参考图展示。"
: "已生成,可继续逐张检查并后续批量下载。"
: card.status === "generating"
? "当前正在生成这一张,请稍候。"
: card.status === "queued"
? "前面的图生成完成后,会继续处理这一张。"
: card.status === "missing"
? "当前这张还没有回传结果,可重新执行整套生成。"
: "生成完成后,这里会接入统一文字编辑和批量下载能力。"}
</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">
{skuReferenceSourceLabel}参考图加载后,右侧会按相同顺序生成一对一替换结果,并支持批量下载。
</div>
</div>
)}
</div>
</div>
</div>
) : (
<AnimatePresence mode="wait">
{shouldShowGridResultSection ? (
<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">
{gridResultCards.map((card) => {
const isActive = card.url && displayedResultUrl === card.url;
const isSceneResultCard = activeResultModeType === "scene_generation";
return (
<div
key={card.id}
role={card.url ? "button" : undefined}
tabIndex={card.url ? 0 : -1}
onClick={() => {
if (!card.url) return;
setSelectedResultUrl(card.url);
}}
onDoubleClick={() => {
if (!card.url) return;
setSelectedResultUrl(card.url);
setFullscreenPreview(card.url);
}}
onKeyDown={(event) => {
if (!card.url) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedResultUrl(card.url);
}
}}
className={cn(
"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"
: "border-white/10 bg-black/20 hover:border-white/18"
)}
>
<div className="relative aspect-[4/5] overflow-hidden bg-[linear-gradient(180deg,#090b0c_0%,#111417_100%)]">
{card.url ? (
<>
<img
src={card.url}
alt={`${getResultModeLabel(activeResultModeType)} 结果图 ${card.index + 1}`}
className="h-full w-full object-contain p-2"
loading="lazy"
/>
{isSceneResultCard ? (
<>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setSelectedResultUrl(card.url!);
setSceneResizeEditorTargetUrl(card.url!);
}}
className="absolute left-3 top-3 flex h-9 w-9 items-center justify-center rounded-full border border-emerald-400/28 bg-black/60 text-emerald-100 transition-colors hover:border-emerald-300/45 hover:bg-emerald-500/[0.16]"
aria-label="调整尺寸"
>
<Maximize2 className="h-4 w-4" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setSelectedResultUrl(card.url!);
openPsdTemplateDialog({
sourceImageUrl: card.url!,
mode: getPsdTemplateModeByResultMode(activeResultModeType),
});
}}
className="absolute left-14 top-3 flex items-center gap-1 rounded-full border border-white/10 bg-black/60 px-2.5 py-1 text-[11px] text-white transition-colors hover:border-white/20"
aria-label="套用 PSD 模板"
>
<ImagePlus className="h-3.5 w-3.5" />
<span>套PSD</span>
</button>
<div className="pointer-events-none absolute inset-x-3 bottom-3 rounded-2xl border border-white/10 bg-black/55 px-3 py-2 text-[11px] text-white/78 backdrop-blur">
双击查看大图
</div>
</>
) : null}
{!isSceneResultCard ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setSelectedResultUrl(card.url!);
openPsdTemplateDialog({
sourceImageUrl: card.url!,
mode: getPsdTemplateModeByResultMode(activeResultModeType),
});
}}
className="absolute left-3 top-3 flex items-center gap-1 rounded-full border border-white/10 bg-black/60 px-2.5 py-1 text-[11px] text-white transition-colors hover:border-white/20"
aria-label="套用 PSD 模板"
>
<ImagePlus className="h-3.5 w-3.5" />
<span>套PSD</span>
</button>
) : null}
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleDeleteResultImage(activeResultModeType, card.url!);
}}
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full border border-rose-400/25 bg-black/60 text-rose-100 transition-colors hover:border-rose-300/45 hover:bg-rose-500/15"
aria-label="删除结果图"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
{activeResultModeType === "detail_generation" ? (
<>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void handleDownloadOriginalImage(card.url!);
}}
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={(event) => {
event.stopPropagation();
handleSendResultToCanvasTextEdit({
imageUrl: card.url!,
title: `AI详情 ${card.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>
</>
) : null}
</>
) : (
<AnimatedGenerationPlaceholderCard
badge={card.status === "generating" ? "生成中" : card.status === "queued" ? "排队中" : "待生成"}
badgeClassName={
card.status === "generating"
? "border-sky-400/30 bg-sky-500/[0.10] text-sky-100"
: card.status === "queued"
? "border-white/12 bg-white/[0.05] text-white/62"
: "border-white/10 bg-white/[0.04] text-white/58"
}
showSpinner={card.status === "generating"}
title={`${getResultModeLabel(activeResultModeType)} 结果图 ${card.index + 1}`}
description={
card.status === "generating"
? "当前图片正在生成中,结果返回后会自动替换这张占位卡。"
: card.status === "queued"
? "前面的结果完成后会继续处理这一张,请保持当前页面即可。"
: "开始生成后,这里会出现对应数量的结果预览卡片。"
}
/>
)}
</div>
</div>
);
})}
</div>
</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>
<Button
type="button"
variant="outline"
onClick={() =>
openPsdTemplateDialog({
mode: getPsdTemplateModeByResultMode(activeResultModeType),
})
}
className="mt-5 rounded-full border-white/10 bg-white/[0.03] px-5 text-white hover:bg-white/[0.08]"
>
<ImagePlus className="mr-2 h-4 w-4" />
套PSD
</Button>
<p className="mt-2 text-xs text-white/35">
可直接进入模板页,并通过手动上传图片开始套版。
</p>
</motion.div>
)}
</AnimatePresence>
)}
</motion.section>
</div>
</main>
{sceneResizeEditorTargetUrl ? (
<ImageAnnotator
key={`agent-scene-resize-${sceneResizeEditorTargetUrl}`}
imageUrl={sceneResizeEditorTargetUrl}
initialResizeMode
confirmButtonText="保存处理结果"
appearance="dark"
onConfirm={handleSceneResultResizeComplete}
onCancel={() => setSceneResizeEditorTargetUrl(null)}
open={Boolean(sceneResizeEditorTargetUrl)}
onOpenChange={(open) => {
if (!open) {
setSceneResizeEditorTargetUrl(null);
}
}}
/>
) : null}
<ClientErrorBoundary
resetKeys={[
isPsdTemplateDialogOpen ? "open" : "closed",
psdTemplateDialogSourceUrl || "",
workflowContext?.brand || "",
workflowContext?.fifthCategory || "",
psdTemplateDialogMode,
]}
fallback={
<Dialog
open={isPsdTemplateDialogOpen}
onOpenChange={(open) => {
if (!open) {
closePsdTemplateDialog();
}
}}
>
<DialogContent
overlayClassName="bg-black/85 backdrop-blur-sm"
className="max-w-lg border border-white/10 bg-[#09090b] text-white"
>
<DialogHeader>
<DialogTitle>PSD 模板暂时无法打开</DialogTitle>
<DialogDescription>
已拦截本次客户端异常,避免整个智能体工作台被一起打断。
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm leading-6 text-white/72">
<p>请关闭弹窗后重试一次。</p>
<p>如果仍然出现,优先检查当前品牌下的 PSD 模板配置、图层数据或字体资源是否完整。</p>
</div>
</DialogContent>
</Dialog>
}
>
<AgentPsdTemplateEditorDialog
open={isPsdTemplateDialogOpen}
onOpenChange={(open) => {
if (!open) {
closePsdTemplateDialog();
}
}}
sourceImageUrl={psdTemplateDialogSourceUrl}
brandName={workflowContext?.brand || null}
selectedCategoryNames={[workflowContext?.fifthCategory || ""].filter(Boolean)}
sizeInfoText={workflowContext?.summaryText || null}
templateMode={psdTemplateDialogMode}
onDerivedImageCreated={
onResultPreviewUrlsChange ? handlePsdDerivedImageCreated : undefined
}
onRenderedResultCreated={
onResultPreviewUrlsChange ? handlePsdRenderedResultCreated : undefined
}
/>
</ClientErrorBoundary>
{/* 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>
);
}