feat: streamline agent mode selection flow
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-21 21:03:55 +08:00
parent 0fab83251c
commit 043bbab84d
2 changed files with 293 additions and 102 deletions

View File

@ -8,6 +8,8 @@ import { motion, AnimatePresence } from "framer-motion";
import JSZip from "jszip";
import {
ArrowUp,
ChevronDown,
ChevronUp,
Download,
ImagePlus,
Loader2,
@ -20,6 +22,11 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
@ -411,6 +418,7 @@ export function AgentGenerationWorkspaceDarkV2({
: null
)
);
const [isAttachmentSlotExpanded, setIsAttachmentSlotExpanded] = useState(false);
const [slotUploading, setSlotUploading] = useState<number | null>(null);
const [selectedModelId, setSelectedModelId] = useState<string>(
normalizedIncomingWorkspaceState.selectedModelId || "auto"
@ -533,6 +541,16 @@ export function AgentGenerationWorkspaceDarkV2({
);
const selectedModelAttachment =
modelSlotIndex >= 0 && attachments[modelSlotIndex] ? attachments[modelSlotIndex] : null;
const filledAttachmentCount = attachments.filter(Boolean).length;
const missingAttachmentCount = Math.max(requiredUploadCount - filledAttachmentCount, 0);
const productAttachmentSummary = `${selectedProductAttachments.length}/${productSlotCount} 张产品图`;
const modelAttachmentSummary = modelSlotEnabled
? selectedModelAttachment
? "模特图已带入"
: "模特图待补充"
: null;
const attachmentSummaryReady =
hasAgentUploadSlots && (requiredUploadCount === 0 || filledAttachmentCount >= requiredUploadCount);
const resolvedAgentModeState = agentModeState || localAgentModeState;
const emitAgentModeState = onAgentModeStateChange || setLocalAgentModeState;
const detailReplacementState = resolvedAgentModeState.detailReplacement;
@ -869,6 +887,10 @@ export function AgentGenerationWorkspaceDarkV2({
(image) => image.source === "primary" || image.source === "secondary"
);
const hasSatisfiedSlotRequirement = !hasAgentUploadSlots || attachmentCount >= requiredUploadCount;
const slotRequirementMessage =
hasAgentUploadSlots && !hasSatisfiedSlotRequirement
? `当前还缺少 ${missingAttachmentCount} 张输入图,请先在“选择 SPU / SKU”步骤补齐后再继续。`
: null;
const mergedSettingsStepLabel = hasMaterialStep ? "选图与生成" : "生成参数";
const mergedSettingsStepDescription = hasMaterialStep
? "搜索素材、补充要求、确认槽位并开始生成"
@ -1077,6 +1099,26 @@ export function AgentGenerationWorkspaceDarkV2({
}
}, [latestGeneratedGroup?.imageUrls, selectedResultUrl]);
useEffect(() => {
if (!selectedAgentPresetId || !hasAgentUploadSlots) {
setIsAttachmentSlotExpanded(true);
return;
}
if (filledAttachmentCount < requiredUploadCount) {
setIsAttachmentSlotExpanded(true);
return;
}
if (hasMaterialStep) {
setIsAttachmentSlotExpanded(false);
}
}, [
filledAttachmentCount,
hasMaterialStep,
hasAgentUploadSlots,
requiredUploadCount,
selectedAgentPresetId,
]);
useEffect(() => {
if (!selectedAgentPresetId) {
setActiveToolStep("preset");
@ -1392,6 +1434,19 @@ export function AgentGenerationWorkspaceDarkV2({
[toolSteps]
);
const scrollToSection = useCallback((sectionId: string) => {
if (
(sectionId === "agent-anchor-generate" || sectionId === "agent-anchor-results") &&
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;
}
@ -1400,7 +1455,7 @@ export function AgentGenerationWorkspaceDarkV2({
return;
}
target.scrollIntoView({ behavior: "smooth", block: "start" });
}, []);
}, [hasAgentUploadSlots, hasMaterialStep, hasSatisfiedSlotRequirement, slotRequirementMessage]);
const updateDetailReplacementState = useCallback(
(
updater:
@ -1610,90 +1665,160 @@ export function AgentGenerationWorkspaceDarkV2({
}, [updateSkuReplacementState]);
const attachmentSlotPanel = (
<div className="rounded-[28px] border border-white/12 bg-white/[0.02] p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<ImagePlus className="h-4 w-4 text-emerald-300" />
输入图槽位
</h3>
<div className="mb-4 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>
{agentUploadSlots.length > 0 ? (
<div className="space-y-4">
<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"
/>
<div className="absolute inset-x-0 bottom-0 bg-black/70 px-3 py-2 text-xs font-medium text-white">
{slot.label}
<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-amber-400/25 bg-amber-500/[0.10] text-amber-100"
)}
>
{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"
/>
<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"
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)}
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"
>
<X className="h-3 w-3" />
{attachments[index] ? "更换" : "上传"}
</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>
);
})}
</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>
) : (
<div className="flex min-h-[132px] flex-col items-center justify-center rounded-[24px] border border-dashed border-white/12 bg-black/20 px-4 text-center text-sm text-white/45">
<ImagePlus className="mb-2 h-5 w-5 opacity-60" />
先选择一个生图模式,系统会按模板配置展示对应的产品图和模特图槽位。
</div>
)}
</div>
)}
</div>
</Collapsible>
);
return (
@ -1734,22 +1859,42 @@ export function AgentGenerationWorkspaceDarkV2({
>
<div className="mb-5 border-b border-white/10 pb-4">
<div className="agent-dark-scrollbar flex gap-2 overflow-x-auto pb-1">
{unifiedAnchorSteps.map((step) => (
<button
key={step.id}
type="button"
onClick={() => scrollToSection(step.id)}
className="flex min-w-max items-center gap-3 rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-left transition-colors hover:border-white/20 hover:bg-white/[0.06]"
>
<span className="rounded-full bg-white text-[10px] font-semibold text-black px-2 py-0.5">
{step.badge}
</span>
<span className="flex flex-col">
<span className="text-sm font-medium text-white">{step.label}</span>
<span className="text-[11px] text-white/45">{step.description}</span>
</span>
</button>
))}
{unifiedAnchorSteps.map((step) => {
const isGenerateAnchor = step.id === "agent-anchor-generate";
const isBlockedGenerateAnchor =
isGenerateAnchor && hasAgentUploadSlots && !hasSatisfiedSlotRequirement;
return (
<button
key={step.id}
type="button"
onClick={() => scrollToSection(step.id)}
className={cn(
"flex min-w-max items-center gap-3 rounded-full border px-4 py-2 text-left transition-colors",
isBlockedGenerateAnchor
? "border-amber-400/20 bg-amber-500/[0.08] text-amber-100/90"
: "border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06]"
)}
>
<span className="rounded-full bg-white px-2 py-0.5 text-[10px] font-semibold text-black">
{step.badge}
</span>
<span className="flex flex-col">
<span className="text-sm font-medium text-white">{step.label}</span>
<span
className={cn(
"text-[11px]",
isBlockedGenerateAnchor ? "text-amber-100/70" : "text-white/45"
)}
>
{isBlockedGenerateAnchor && slotRequirementMessage
? slotRequirementMessage
: step.description}
</span>
</span>
</button>
);
})}
</div>
</div>
@ -1890,7 +2035,30 @@ export function AgentGenerationWorkspaceDarkV2({
/>
</div>
{attachmentSlotPanel}
{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}
{(!hasMaterialStep || !hasSatisfiedSlotRequirement || 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">
@ -1945,7 +2113,11 @@ export function AgentGenerationWorkspaceDarkV2({
<Button
onClick={handleSend}
disabled={isGenerating || !selectedAgentPresetId}
disabled={
isGenerating ||
!selectedAgentPresetId ||
(hasAgentUploadSlots && !hasSatisfiedSlotRequirement)
}
className={cn(
"h-11 w-full gap-2 rounded-[24px]",
"bg-gradient-to-r from-emerald-500 to-teal-500",
@ -1967,6 +2139,11 @@ export function AgentGenerationWorkspaceDarkV2({
</>
)}
</Button>
{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}
</div>
</div>
</div>

View File

@ -42,6 +42,8 @@ interface AgentPresetPanelDarkV2Props {
embeddedEntityLabel?: string;
}
const AGENT_PRESET_LIBRARY_ALL_MODES_ID = "__all_modes__";
export function AgentPresetPanelDarkV2({
selectedPresetId,
onSelectPreset,
@ -50,7 +52,7 @@ export function AgentPresetPanelDarkV2({
embeddedEntityLabel = "智能体",
}: AgentPresetPanelDarkV2Props) {
const { data: session } = useSession();
const [activeTab, setActiveTab] = useState<string>(AGENT_PRESET_LIBRARY_DEFAULT_SECTION_ID);
const [activeTab, setActiveTab] = useState<string>(AGENT_PRESET_LIBRARY_ALL_MODES_ID);
const [keyword, setKeyword] = useState("");
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [editingPreset, setEditingPreset] = useState<AgentPreset | null>(null);
@ -87,24 +89,33 @@ export function AgentPresetPanelDarkV2({
[presetLibraryConfig]
);
const navTabs = useMemo(() => {
const allModesTab = {
id: AGENT_PRESET_LIBRARY_ALL_MODES_ID,
label: "所有模式",
icon: Grid3X3,
isMyWorks: false,
isAllModes: true,
};
const dynamicTabs = presetLibrarySections.map((section) => ({
id: section.id,
label: section.label,
icon: LayoutGrid,
isMyWorks: false,
isAllModes: false,
}));
const myWorksTab = {
id: AGENT_PRESET_LIBRARY_MY_WORKS_ID,
label: "我的模式",
icon: FolderOpen,
isMyWorks: true,
isAllModes: false,
};
if (dynamicTabs.length === 0) {
return [myWorksTab];
return [allModesTab, myWorksTab];
}
return [...dynamicTabs, myWorksTab];
return [allModesTab, ...dynamicTabs, myWorksTab];
}, [presetLibrarySections]);
const activeSection = useMemo(
() => presetLibrarySections.find((section) => section.id === activeTab) || null,
@ -130,6 +141,7 @@ export function AgentPresetPanelDarkV2({
() => getEnabledAgentPresetLibraryNodes(selectedLevel2?.children || []),
[selectedLevel2]
);
const isAllModesTab = activeTab === AGENT_PRESET_LIBRARY_ALL_MODES_ID;
const isMyWorksTab = activeTab === AGENT_PRESET_LIBRARY_MY_WORKS_ID;
const canManagePublicPresets =
session?.user?.type === "admin" || session?.user?.type === "agent_admin";
@ -168,7 +180,7 @@ export function AgentPresetPanelDarkV2({
const filteredPresets = useMemo(() => {
let presets: AgentPreset[] = isMyWorksTab ? data?.private || [] : data?.public || [];
if (!isMyWorksTab) {
if (!isMyWorksTab && !isAllModesTab) {
const fallbackSectionId =
presetLibrarySections.find((section) => section.id === AGENT_PRESET_LIBRARY_DEFAULT_SECTION_ID)?.id ||
presetLibrarySections[0]?.id ||
@ -210,6 +222,7 @@ export function AgentPresetPanelDarkV2({
}, [
activeTab,
data,
isAllModesTab,
isMyWorksTab,
keyword,
presetLibraryConfig,
@ -248,6 +261,7 @@ export function AgentPresetPanelDarkV2({
// 获取当前标签标题
const getCurrentTitle = () => {
if (isAllModesTab) return "所有模式";
if (isMyWorksTab) return "我的模式";
return activeSection?.label || "生图模式";
};
@ -303,7 +317,7 @@ export function AgentPresetPanelDarkV2({
))}
</div>
{!isMyWorksTab ? (
{!isMyWorksTab && !isAllModesTab ? (
<div className="mb-4 space-y-2 rounded-2xl border border-white/8 bg-white/[0.02] p-3">
<CategoryFilterRow
label="一级"
@ -515,7 +529,7 @@ export function AgentPresetPanelDarkV2({
</div>
</motion.div>
{!isMyWorksTab ? (
{!isMyWorksTab && !isAllModesTab ? (
<motion.div
className="mb-6 space-y-2 rounded-2xl border border-white/8 bg-white/[0.02] p-4"
initial={{ opacity: 0, y: 16 }}