feat: limit workflow material selection by mode
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 19:18:15 +08:00
parent 2ae376bdfd
commit c608882f9f
3 changed files with 102 additions and 3 deletions

View File

@ -105,6 +105,7 @@ import {
} from "@/lib/agents/generation-project-state";
import { agentProjectService } from "@/services/agent-projects";
import type {
AgentPreset,
AgentGenerationProjectWorkspaceState,
AgentModeWorkspaceState,
AgentWorkflowComposerContext,
@ -1354,6 +1355,36 @@ export default function CanvasEditorPage({ params }: PageProps) {
workflowMaterials,
workflowSelections,
]);
const { data: selectedAgentPresetResponse } = useSWR<{
success?: boolean;
data?: AgentPreset;
}>(selectedAgentPresetId ? `/api/agents/${selectedAgentPresetId}` : null, fetcher);
const selectedAgentPresetData = useMemo(
() =>
selectedAgentPresetResponse?.success && selectedAgentPresetResponse.data
? selectedAgentPresetResponse.data
: null,
[selectedAgentPresetResponse]
);
const selectedAgentPresetModeConfig = useMemo(
() => normalizeAgentPresetModeConfig(selectedAgentPresetData?.defaultParams),
[selectedAgentPresetData?.defaultParams]
);
const materialProductSelectionLimit = useMemo(() => {
if (selectedAgentPresetData?.type !== "image") {
return null;
}
if (selectedAgentPresetModeConfig.modeType === "scene_generation") {
return selectedAgentPresetModeConfig.scene.productImageSlotCount;
}
if (selectedAgentPresetModeConfig.modeType === "detail_replacement") {
return selectedAgentPresetModeConfig.detail.productImageSlotCount;
}
if (selectedAgentPresetModeConfig.modeType === "sku_replacement") {
return selectedAgentPresetModeConfig.sku.productImageSlotCount;
}
return 2;
}, [selectedAgentPresetData?.type, selectedAgentPresetModeConfig]);
const workflowProgress = useMemo(
() =>
getAgentWorkflowProgress({
@ -1535,6 +1566,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
onInputsChange={setWorkflowInputs}
materials={workflowMaterials}
onMaterialsChange={setWorkflowMaterials}
productSelectionLimit={materialProductSelectionLimit}
disabled={!selectedAgentPresetId}
theme="dark"
/>

View File

@ -2,6 +2,7 @@
import { useMemo } from "react";
import { Check, ImageIcon, Loader2, RefreshCw, Search } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
@ -24,6 +25,8 @@ interface AgentWorkflowMaterialPickerProps {
errorText?: string | null;
result?: AgentWorkflowMaterialSearchResult | null;
selectedImageIds: string[];
selectionLimit?: number | null;
selectionLimitLabel?: string;
onValueChange: (value: string) => void;
onQueryModeChange: (value: AgentWorkflowMaterialQueryType) => void;
onSearch: () => void;
@ -46,6 +49,8 @@ export function AgentWorkflowMaterialPicker({
errorText,
result,
selectedImageIds,
selectionLimit = null,
selectionLimitLabel = "当前生图模式",
onValueChange,
onQueryModeChange,
onSearch,
@ -61,26 +66,41 @@ export function AgentWorkflowMaterialPicker({
const selectedCount = result?.images
? result.images.filter((item) => selectedSet.has(item.id)).length
: 0;
const resolvedSelectionLimit =
typeof selectionLimit === "number" && Number.isFinite(selectionLimit) && selectionLimit > 0
? Math.max(1, Math.round(selectionLimit))
: null;
const hasSelectableImages = Boolean(
result?.images?.some((item) => Boolean(String(item.imageUrl || "").trim()))
);
const remainingSelectableCount =
resolvedSelectionLimit === null ? null : Math.max(resolvedSelectionLimit - selectedCount, 0);
const handleToggleImage = (imageId: string) => {
const next = new Set(selectedImageIds);
if (next.has(imageId)) {
next.delete(imageId);
} else {
if (resolvedSelectionLimit !== null && next.size >= resolvedSelectionLimit) {
toast.error(`${selectionLimitLabel}最多可选择 ${resolvedSelectionLimit} 张图片`);
return;
}
next.add(imageId);
}
onSelectedImageIdsChange(Array.from(next));
};
const handleSelectAll = () => {
onSelectedImageIdsChange(
const allIds =
result?.images
?.filter((item) => Boolean(String(item.imageUrl || "").trim()))
.map((item) => item.id) || []
);
.map((item) => item.id) || [];
const nextIds =
resolvedSelectionLimit !== null ? allIds.slice(0, resolvedSelectionLimit) : allIds;
onSelectedImageIdsChange(nextIds);
if (resolvedSelectionLimit !== null && allIds.length > resolvedSelectionLimit) {
toast.message(`${selectionLimitLabel}最多可选择 ${resolvedSelectionLimit} 张图片,已按上限选取`);
}
};
return (
@ -168,6 +188,19 @@ export function AgentWorkflowMaterialPicker({
{searchNote}
</div>
) : null}
{resolvedSelectionLimit !== null ? (
<div
className={cn(
"rounded-[20px] border px-3 py-2 text-xs",
isDark
? "border-emerald-500/18 bg-emerald-500/[0.08] text-emerald-100"
: "border-emerald-200 bg-emerald-50 text-emerald-700"
)}
>
{selectionLimitLabel}最多可选择 {resolvedSelectionLimit} 张图片
{remainingSelectableCount !== null ? `,当前还可选择 ${remainingSelectableCount} 张。` : "。"}
</div>
) : null}
{disabled && disabledHint ? (
<div
className={cn(
@ -235,6 +268,16 @@ export function AgentWorkflowMaterialPicker({
>
已选 {selectedCount} 张
</span>
{resolvedSelectionLimit !== null ? (
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-emerald-500/[0.12] text-emerald-200" : "bg-emerald-50 text-emerald-700"
)}
>
最多 {resolvedSelectionLimit} 张
</span>
) : null}
</div>
<div className="flex items-center gap-2">
<Button

View File

@ -17,6 +17,7 @@ interface AgentWorkflowMaterialSelectionPanelProps {
onInputsChange: (next: AgentWorkflowInputs) => void;
materials: AgentWorkflowMaterialSelections;
onMaterialsChange: (next: AgentWorkflowMaterialSelections) => void;
productSelectionLimit?: number | null;
title?: string;
description?: string;
disabled?: boolean;
@ -29,6 +30,7 @@ export function AgentWorkflowMaterialSelectionPanel({
onInputsChange,
materials,
onMaterialsChange,
productSelectionLimit = null,
title,
description,
disabled = false,
@ -89,6 +91,16 @@ export function AgentWorkflowMaterialSelectionPanel({
const [isSecondaryMaterialVisible, setIsSecondaryMaterialVisible] = useState(
hasSecondaryMaterialContent
);
const primarySelectedCount = materials.primary.selectedImageIds.length;
const secondarySelectedCount = materials.secondary.selectedImageIds.length;
const primarySelectionLimit =
typeof productSelectionLimit === "number" && Number.isFinite(productSelectionLimit) && productSelectionLimit > 0
? Math.max(productSelectionLimit - secondarySelectedCount, primarySelectedCount, 0)
: null;
const secondarySelectionLimit =
typeof productSelectionLimit === "number" && Number.isFinite(productSelectionLimit) && productSelectionLimit > 0
? Math.max(productSelectionLimit - primarySelectedCount, secondarySelectedCount, 0)
: null;
useEffect(() => {
if (hasSecondaryMaterialContent) {
@ -226,6 +238,12 @@ export function AgentWorkflowMaterialSelectionPanel({
errorText={materialSearchState.primary.errorText}
result={materials.primary.result}
selectedImageIds={materials.primary.selectedImageIds}
selectionLimit={primarySelectionLimit}
selectionLimitLabel={
typeof productSelectionLimit === "number" && Number.isFinite(productSelectionLimit) && productSelectionLimit > 0
? `当前模式产品图总上限 ${productSelectionLimit} 张`
: undefined
}
onValueChange={(value) => handleMaterialInputChange("primary", value)}
onQueryModeChange={(nextMode) =>
setMaterialSearchMode((prev) => ({
@ -294,6 +312,12 @@ export function AgentWorkflowMaterialSelectionPanel({
errorText={materialSearchState.secondary.errorText}
result={materials.secondary.result}
selectedImageIds={materials.secondary.selectedImageIds}
selectionLimit={secondarySelectionLimit}
selectionLimitLabel={
typeof productSelectionLimit === "number" && Number.isFinite(productSelectionLimit) && productSelectionLimit > 0
? `当前模式产品图总上限 ${productSelectionLimit} 张`
: undefined
}
onValueChange={(value) => handleMaterialInputChange("secondary", value)}
onQueryModeChange={(nextMode) =>
setMaterialSearchMode((prev) => ({