feat: add canvas material image search

This commit is contained in:
134 2026-06-23 16:55:22 +08:00
parent 3a2a7f2490
commit b78c77a790
2 changed files with 573 additions and 9 deletions

View File

@ -80,6 +80,10 @@ import {
AgentWorkflowPanelDarkV2,
} from "@/components/agents/AgentWorkflowPanelDarkV2";
import { type AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanelNew";
import {
CanvasAddImageDialog,
type CanvasAddImageDialogImage,
} from "@/components/canvas/CanvasAddImageDialog";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -1716,6 +1720,48 @@ function getAllResultUrlsFromCollections(
);
}
function resolveCanvasImageDisplaySize(url: string) {
return new Promise<{
height: number;
originalHeight: number;
originalWidth: number;
width: number;
}>((resolve) => {
if (typeof window === "undefined" || !url) {
resolve({
height: 320,
originalHeight: 0,
originalWidth: 0,
width: 320,
});
return;
}
const image = new window.Image();
image.onload = () => {
const originalWidth = image.naturalWidth || 320;
const originalHeight = image.naturalHeight || 320;
const maxSide = 360;
const scale = Math.min(1, maxSide / Math.max(originalWidth, originalHeight));
resolve({
height: Math.max(120, Math.round(originalHeight * scale)),
originalHeight,
originalWidth,
width: Math.max(120, Math.round(originalWidth * scale)),
});
};
image.onerror = () => {
resolve({
height: 320,
originalHeight: 0,
originalWidth: 0,
width: 320,
});
};
image.src = url;
});
}
export default function CanvasEditorLegacyPage({ params }: PageProps) {
// 使用 useParams 确保客户端导航时能正确获取更新的 id
const routeParams = useParams();
@ -1795,6 +1841,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [isAddImageDialogOpen, setIsAddImageDialogOpen] = useState(false);
const [editTitle, setEditTitle] = useState("");
const safariGestureScaleRef = useRef(1);
@ -2113,6 +2160,8 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
);
const isWorkflowMaterialStepEnabled =
workflowPageFeatureSettings.materialStepEnabled !== false;
const isCanvasAddImageMaterialSearchEnabled =
isAgentWorkspaceEnabled && isWorkflowMaterialStepEnabled;
const isBatchProcessingEnabled =
workflowPageFeatureSettings.batchProcessingEnabled !== false &&
sceneFusionConfigData?.enabled !== false;
@ -6759,16 +6808,14 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
});
}, [createCanvasNode, handleAddWorkflowNode, isVideoMode]);
// 添加图片节点
const handleAddImageNode = useCallback(() => {
if (isVideoMode) {
handleAddWorkflowNode("input-image");
return;
}
const handleCreateBlankImageNode = useCallback(() => {
const origin = getVisibleCanvasOrigin();
createCanvasNode({
type: "image",
position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
position: {
x: origin.x + 40 + Math.random() * 80,
y: origin.y + 40 + Math.random() * 80,
},
size: { width: 300, height: 300 },
data: {
src: "",
@ -6778,7 +6825,77 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
},
connections: [],
});
}, [createCanvasNode, handleAddWorkflowNode, isVideoMode]);
}, [createCanvasNode, getVisibleCanvasOrigin]);
const handleAddMaterialImagesToCanvas = useCallback(
async (images: CanvasAddImageDialogImage[]) => {
const validImages = images.filter((image) =>
String(image.imageUrl || "").trim()
);
if (!validImages.length) return;
const origin = getVisibleCanvasOrigin();
const columns = Math.min(3, Math.max(1, validImages.length));
const cellWidth = 388;
const cellHeight = 388;
const createdNodeIds: string[] = [];
for (const [index, image] of validImages.entries()) {
const dimensions = await resolveCanvasImageDisplaySize(image.imageUrl);
const column = index % columns;
const row = Math.floor(index / columns);
const nodeId = createCanvasNode(
{
type: "image",
position: {
x: origin.x + 40 + column * cellWidth,
y: origin.y + 40 + row * cellHeight,
},
size: {
width: dimensions.width,
height: dimensions.height,
},
data: {
src: image.imageUrl,
assetId: image.assetId || undefined,
status: "completed",
title: image.label || image.fileName || "白底图",
prompt: image.fileName || image.label || "素材库白底图",
metadata: {
materialFileName: image.fileName,
materialGroup: image.group,
materialImageId: image.id,
materialKey: image.key,
materialMimeType: image.mimeType || undefined,
originalHeight: dimensions.originalHeight,
originalWidth: dimensions.originalWidth,
source: "agent-workflow-material-search",
},
},
connections: [],
},
{ selectNewNode: false }
);
createdNodeIds.push(nodeId);
}
if (createdNodeIds.length > 0) {
selectNodes(createdNodeIds);
toast.success(`已添加 ${createdNodeIds.length} 张白底图到画布`);
}
},
[createCanvasNode, getVisibleCanvasOrigin, selectNodes]
);
// 添加图片节点
const handleAddImageNode = useCallback(() => {
if (isVideoMode) {
handleAddWorkflowNode("input-image");
return;
}
setIsAddImageDialogOpen(true);
}, [handleAddWorkflowNode, isVideoMode]);
const handleAddAgentSlotNode = useCallback((slotType: AgentSlotNodeData["slotType"]) => {
if (isVideoMode) return;
@ -11287,6 +11404,14 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
</div>
</div>
<CanvasAddImageDialog
materialSearchEnabled={isCanvasAddImageMaterialSearchEnabled}
onAddImages={handleAddMaterialImagesToCanvas}
onCreateBlankImage={handleCreateBlankImageNode}
onOpenChange={setIsAddImageDialogOpen}
open={isAddImageDialogOpen}
/>
{loginDialogElement}
{projectLoadingBadge}
</div>

View File

@ -0,0 +1,439 @@
"use client";
import { Check, ImageIcon, ImagePlus, Loader2, Search, X } from "lucide-react";
import { useMemo, useState } from "react";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type {
AgentWorkflowMaterialImage,
AgentWorkflowMaterialQueryType,
AgentWorkflowMaterialSearchResult,
AgentWorkflowMaterialSkuOption,
} from "@/types/agent";
export type CanvasAddImageDialogImage = AgentWorkflowMaterialImage;
interface CanvasAddImageDialogProps {
materialSearchEnabled: boolean;
onAddImages: (images: CanvasAddImageDialogImage[]) => Promise<void> | void;
onCreateBlankImage: () => void;
onOpenChange: (open: boolean) => void;
open: boolean;
}
function getMaterialSearchError(payload: any, fallback = "搜索失败") {
const error = payload?.error;
if (typeof error === "string") return error;
if (error?.message) return String(error.message);
return fallback;
}
function normalizeImages(result?: AgentWorkflowMaterialSearchResult | null) {
return Array.isArray(result?.images)
? result.images.filter((image) => String(image.imageUrl || "").trim())
: [];
}
function MaterialImageCard({
image,
selected,
onToggle,
}: {
image: AgentWorkflowMaterialImage;
selected: boolean;
onToggle: (id: string) => void;
}) {
return (
<button
className={cn(
"group overflow-hidden rounded-xl border bg-background text-left transition-all",
selected
? "border-primary shadow-[0_0_0_1px_hsl(var(--primary)/0.25)]"
: "border-border hover:border-primary/45"
)}
onClick={() => onToggle(image.id)}
type="button"
>
<div className="relative aspect-square bg-muted/30">
<AgentPreviewImage
alt={image.label || image.fileName || "白底图"}
className="h-full w-full object-contain"
delivery="proxy"
previewQuality={76}
previewWidth={520}
src={image.previewUrl || image.imageUrl}
/>
<div
className={cn(
"absolute top-2 right-2 flex h-6 w-6 items-center justify-center rounded-full border text-xs shadow-sm transition-colors",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-white/70 bg-black/35 text-white opacity-80 group-hover:opacity-100"
)}
>
{selected ? <Check className="h-3.5 w-3.5" /> : null}
</div>
</div>
<div className="space-y-1 px-3 py-2.5">
<div className="truncate text-sm font-medium">
{image.label || image.fileName || "白底图"}
</div>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{image.group === "fixed" ? "固定角度" : "补充图"}</span>
<span className="truncate">{image.fileName || image.key}</span>
</div>
</div>
</button>
);
}
function SkuOptionCard({
option,
onSelect,
}: {
option: AgentWorkflowMaterialSkuOption;
onSelect: (skuName: string) => void;
}) {
return (
<button
className={cn(
"flex items-center gap-3 rounded-xl border bg-background p-3 text-left transition-colors",
option.hasWhiteBackground
? "hover:border-primary/55 hover:bg-muted/30"
: "cursor-not-allowed opacity-55"
)}
disabled={!option.hasWhiteBackground}
onClick={() => onSelect(option.skuName)}
type="button"
>
<div className="h-14 w-14 overflow-hidden rounded-lg border bg-muted/40">
{option.previewImageUrl ? (
<AgentPreviewImage
alt={option.previewFileName || option.skuName}
className="h-full w-full object-cover"
previewQuality={72}
previewWidth={180}
src={option.previewImageUrl}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<ImageIcon className="h-4 w-4" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{option.skuName}</div>
<div className="mt-1 text-xs text-muted-foreground">
{option.hasWhiteBackground
? `${option.whiteBackgroundCount} 张白底图`
: "暂无白底图"}
</div>
</div>
</button>
);
}
export function CanvasAddImageDialog({
materialSearchEnabled,
onAddImages,
onCreateBlankImage,
onOpenChange,
open,
}: CanvasAddImageDialogProps) {
const [query, setQuery] = useState("");
const [queryMode, setQueryMode] = useState<AgentWorkflowMaterialQueryType>("spu");
const [result, setResult] = useState<AgentWorkflowMaterialSearchResult | null>(null);
const [selectedImageIds, setSelectedImageIds] = useState<string[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const [errorText, setErrorText] = useState<string | null>(null);
const images = useMemo(() => normalizeImages(result), [result]);
const skuOptions = Array.isArray(result?.skuOptions) ? result.skuOptions : [];
const selectedImages = useMemo(
() => images.filter((image) => selectedImageIds.includes(image.id)),
[images, selectedImageIds]
);
const handleSearch = async (nextQuery?: string, nextMode?: AgentWorkflowMaterialQueryType) => {
if (!materialSearchEnabled) return;
const keyword = String(nextQuery ?? query).trim();
const mode = nextMode || queryMode;
if (!keyword) {
setErrorText("请输入 SPU 或 SKU");
return;
}
try {
setIsSearching(true);
setErrorText(null);
const searchParams = new URLSearchParams({
keyword,
source: "primary",
queryType: mode,
appearanceRecognitionEnabled: "false",
});
const response = await fetch(
`/api/agent-workflow/material-search?${searchParams.toString()}`,
{ cache: "no-store" }
);
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true || !payload?.data) {
throw new Error(getMaterialSearchError(payload));
}
setResult(payload.data);
setSelectedImageIds(
Array.isArray(payload.data?.recommendedImageIds)
? payload.data.recommendedImageIds.filter((id: string) =>
payload.data.images?.some((image: AgentWorkflowMaterialImage) => image.id === id)
)
: []
);
} catch (error) {
setResult(null);
setSelectedImageIds([]);
setErrorText(error instanceof Error ? error.message : "搜索失败");
} finally {
setIsSearching(false);
}
};
const handleSelectSku = (skuName: string) => {
setQuery(skuName);
setQueryMode("sku");
void handleSearch(skuName, "sku");
};
const handleToggleImage = (imageId: string) => {
setSelectedImageIds((current) =>
current.includes(imageId)
? current.filter((id) => id !== imageId)
: [...current, imageId]
);
};
const handleAddSelected = async () => {
if (!selectedImages.length) return;
try {
setIsAdding(true);
await onAddImages(selectedImages);
onOpenChange(false);
} finally {
setIsAdding(false);
}
};
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0">
<DialogHeader className="border-b px-6 pt-5 pb-4">
<DialogTitle className="flex items-center gap-2">
<ImagePlus className="h-5 w-5" />
添加图片
</DialogTitle>
<DialogDescription>
添加空白图片节点,或从风格化素材库检索 SPU / SKU 白底图。
</DialogDescription>
</DialogHeader>
<div className="max-h-[62vh] overflow-y-auto px-6 py-5">
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="flex flex-col gap-3 sm:flex-row">
<Button
className="h-11 justify-start gap-2 rounded-xl"
onClick={() => {
onCreateBlankImage();
onOpenChange(false);
}}
type="button"
variant="secondary"
>
<ImageIcon className="h-4 w-4" />
空白图片节点
</Button>
{materialSearchEnabled ? (
<div className="flex min-w-0 flex-1 flex-col gap-3 sm:flex-row">
<Select
onValueChange={(value) =>
setQueryMode(value === "sku" ? "sku" : "spu")
}
value={queryMode}
>
<SelectTrigger className="h-11 w-full rounded-xl sm:w-[118px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="spu">SPU</SelectItem>
<SelectItem value="sku">SKU</SelectItem>
</SelectContent>
</Select>
<div className="relative min-w-0 flex-1">
<Input
className="h-11 rounded-xl pr-10"
onChange={(event) => {
setQuery(event.target.value);
setErrorText(null);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
void handleSearch();
}
}}
placeholder={queryMode === "spu" ? "输入 SPU 查询 SKU" : "输入 SKU 查询白底图"}
value={query}
/>
{query ? (
<button
className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground hover:text-foreground"
onClick={() => {
setQuery("");
setResult(null);
setSelectedImageIds([]);
}}
type="button"
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
<Button
className="h-11 gap-2 rounded-xl"
disabled={isSearching}
onClick={() => void handleSearch()}
type="button"
>
{isSearching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
搜索
</Button>
</div>
) : null}
</div>
{!materialSearchEnabled ? (
<div className="mt-3 rounded-xl border border-dashed bg-background/70 px-3 py-2 text-muted-foreground text-xs">
风格化功能或素材搜索步骤未开启,当前只显示空白图片入口。
</div>
) : null}
</div>
{errorText ? (
<div className="mt-4 rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive text-sm">
{errorText}
</div>
) : null}
{skuOptions.length > 0 ? (
<div className="mt-5 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium text-sm">选择 SKU</div>
<div className="text-muted-foreground text-xs">
命中 {skuOptions.length} 个 SKU,选择有白底图的 SKU 继续拉取图片。
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{skuOptions.map((option) => (
<SkuOptionCard
key={`${option.skuName}-${option.skuId || "sku"}`}
onSelect={handleSelectSku}
option={option}
/>
))}
</div>
</div>
) : null}
{images.length > 0 ? (
<div className="mt-5 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-medium text-sm">
白底图素材
{result?.matchedSku ? ` · ${result.matchedSku}` : ""}
</div>
<div className="text-muted-foreground text-xs">
共 {images.length} 张,已选 {selectedImages.length} 张。
</div>
</div>
<div className="flex gap-2">
<Button
onClick={() => setSelectedImageIds(images.map((image) => image.id))}
size="sm"
type="button"
variant="outline"
>
全选
</Button>
<Button
disabled={!selectedImageIds.length}
onClick={() => setSelectedImageIds([])}
size="sm"
type="button"
variant="ghost"
>
清空
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-4">
{images.map((image) => (
<MaterialImageCard
image={image}
key={image.id}
onToggle={handleToggleImage}
selected={selectedImageIds.includes(image.id)}
/>
))}
</div>
</div>
) : materialSearchEnabled && !isSearching && !skuOptions.length && result ? (
<div className="mt-5 flex min-h-[160px] items-center justify-center rounded-2xl border border-dashed text-muted-foreground text-sm">
当前没有可添加的白底图。
</div>
) : null}
</div>
<DialogFooter className="border-t px-6 py-4">
<Button
onClick={() => onOpenChange(false)}
type="button"
variant="outline"
>
取消
</Button>
<Button
className="gap-2"
disabled={!selectedImages.length || isAdding}
onClick={() => void handleAddSelected()}
type="button"
>
{isAdding ? <Loader2 className="h-4 w-4 animate-spin" /> : <ImagePlus className="h-4 w-4" />}
添加所选{selectedImages.length ? ` (${selectedImages.length})` : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}