From 3e935fae3787a63c0ee9d2971b6c7c884d240f41 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 1 Apr 2026 23:36:05 +0800 Subject: [PATCH] feat: add workflow material upload cutout flow --- .../agent-workflow/material-upload/route.ts | 134 +++++++ .../agents/AgentWorkflowMaterialPicker.tsx | 373 +++++++++++++++-- .../AgentWorkflowMaterialSelectionPanel.tsx | 375 +++++++++++++++++- lib/agents/workflow-material-config.ts | 1 + types/agent.ts | 1 + 5 files changed, 834 insertions(+), 50 deletions(-) create mode 100644 app/api/agent-workflow/material-upload/route.ts diff --git a/app/api/agent-workflow/material-upload/route.ts b/app/api/agent-workflow/material-upload/route.ts new file mode 100644 index 000000000..eeb3b0b8f --- /dev/null +++ b/app/api/agent-workflow/material-upload/route.ts @@ -0,0 +1,134 @@ +import { randomUUID } from "node:crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/(auth)/auth"; +import { executeRmbgJob, RmbgServiceError } from "@/lib/ai/rmbg/service"; +import { upsertMediaAsset } from "@/lib/db/queries"; + +export const runtime = "nodejs"; +export const maxDuration = 180; + +const payloadSchema = z.object({ + source: z.enum(["primary", "secondary"]).default("primary"), + query: z.string().trim().max(64).optional(), + queryType: z.enum(["sku", "spu"]).optional(), + matchedSku: z.string().trim().max(64).optional(), + matchedSpu: z.string().trim().max(64).optional(), + productModel: z.string().trim().max(128).optional(), +}); + +function sanitizeFilenameStem(value: string) { + return String(value || "") + .trim() + .replace(/\.[^.]+$/, "") + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 80); +} + +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ error: "缺少图片文件" }, { status: 400 }); + } + + if (!String(file.type || "").startsWith("image/")) { + return NextResponse.json({ error: "请上传图片文件" }, { status: 400 }); + } + + const payload = payloadSchema.parse({ + source: formData.get("source"), + query: formData.get("query"), + queryType: formData.get("queryType"), + matchedSku: formData.get("matchedSku"), + matchedSpu: formData.get("matchedSpu"), + productModel: formData.get("productModel"), + }); + + const arrayBuffer = await file.arrayBuffer(); + const imageBuffer = Buffer.from(arrayBuffer); + const promptPrefix = payload.source === "secondary" ? "搭配SKU" : "主SPU"; + const prompt = `${promptPrefix}素材抠图`; + + const result = await executeRmbgJob({ + userId: session.user.id, + imageBuffer, + prompt, + skipGallerySave: true, + }); + + const originalStem = sanitizeFilenameStem(file.name) || `workflow-upload-${Date.now()}`; + const displayName = `${originalStem}-rmbg.png`; + const asset = await upsertMediaAsset({ + userId: session.user.id, + mediaType: "image", + sourceType: "agent_workflow_material_upload", + status: "ready", + originalUrl: result.original_url, + previewUrl: result.original_url, + thumbnailUrl: result.thumbnail_url, + mimeType: "image/png", + displayName, + prompt, + modelId: result.provider, + sourceTable: "agent_workflow_material_upload", + metadata: { + source: payload.source, + query: payload.query || null, + queryType: payload.queryType || "sku", + matchedSku: payload.matchedSku || null, + matchedSpu: payload.matchedSpu || null, + productModel: payload.productModel || null, + originalFileName: file.name, + provider: result.provider, + }, + }); + + const imageId = asset?.id || `upload-${randomUUID()}`; + + return NextResponse.json({ + success: true, + data: { + id: imageId, + key: imageId, + label: "用户上传", + fileName: displayName, + imageUrl: result.original_url, + assetId: asset?.id ?? null, + mimeType: "image/png", + sourceType: "upload", + group: "other", + sortOrder: 0, + }, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.issues[0]?.message || "请求参数不合法" }, + { status: 400 } + ); + } + + if (error instanceof RmbgServiceError) { + return NextResponse.json( + { error: error.message, detail: error.detail || error.message }, + { status: error.status } + ); + } + + console.error("[Agent Workflow Material Upload] Failed to process upload:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "上传失败" }, + { status: 500 } + ); + } +} diff --git a/components/agents/AgentWorkflowMaterialPicker.tsx b/components/agents/AgentWorkflowMaterialPicker.tsx index bceddcae0..0887a290d 100644 --- a/components/agents/AgentWorkflowMaterialPicker.tsx +++ b/components/agents/AgentWorkflowMaterialPicker.tsx @@ -1,12 +1,13 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Check, ImageIcon, Loader2, RefreshCw, Search } from "lucide-react"; +import { Check, ImageIcon, Loader2, RefreshCw, Search, Trash2, Upload } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import type { + AgentWorkflowMaterialImage, AgentWorkflowMaterialQueryType, AgentWorkflowMaterialSearchResult, } from "@/types/agent"; @@ -32,6 +33,15 @@ interface AgentWorkflowMaterialPickerProps { onSearch: () => void; onSkuOptionSelect?: (skuName: string) => void; onSelectedImageIdsChange: (next: string[]) => void; + onUploadImage?: ( + file: File, + options?: { + replaceImageId?: string; + } + ) => Promise; + onRemoveUploadedImage?: (imageId: string) => void; + isUploadingNewImage?: boolean; + uploadingImageId?: string | null; theme?: "light" | "dark"; } @@ -56,9 +66,14 @@ export function AgentWorkflowMaterialPicker({ onSearch, onSkuOptionSelect, onSelectedImageIdsChange, + onUploadImage, + onRemoveUploadedImage, + isUploadingNewImage = false, + uploadingImageId = null, theme = "light", }: AgentWorkflowMaterialPickerProps) { const isDark = theme === "dark"; + const hasUploadInFlight = isUploadingNewImage || Boolean(uploadingImageId); const [previewDisplayMode, setPreviewDisplayMode] = useState<"selected" | "all">("all"); const [hasManualSelectionInteraction, setHasManualSelectionInteraction] = useState(false); const selectedSet = useMemo( @@ -72,15 +87,27 @@ export function AgentWorkflowMaterialPicker({ : [], [result?.recommendedImageIds] ); - const selectedCount = result?.images - ? result.images.filter((item) => selectedSet.has(item.id)).length - : 0; + const allImages = result?.images || []; + const uploadedImages = useMemo( + () => + allImages + .filter((item) => item.sourceType === "upload") + .slice() + .sort((left, right) => left.sortOrder - right.sortOrder), + [allImages] + ); + const galleryImages = useMemo( + () => allImages.filter((item) => item.sourceType !== "upload"), + [allImages] + ); + const selectedCount = allImages.filter((item) => selectedSet.has(item.id)).length; + const selectedGalleryCount = galleryImages.filter((item) => selectedSet.has(item.id)).length; 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())) + allImages.some((item) => Boolean(String(item.imageUrl || "").trim())) ); const goodsInfoItems = [ { @@ -111,33 +138,41 @@ export function AgentWorkflowMaterialPicker({ ].filter((item) => Boolean(item.value)); const remainingSelectableCount = resolvedSelectionLimit === null ? null : Math.max(resolvedSelectionLimit - selectedCount, 0); - const totalImageCount = result?.images?.length || 0; + const remainingUploadSlotCount = + resolvedSelectionLimit === null + ? 0 + : Math.max( + resolvedSelectionLimit - selectedGalleryCount - uploadedImages.length, + 0 + ); + const totalImageCount = galleryImages.length; const hasSystemRecommendedSelection = result?.queryType === "sku" && recommendedImageIds.length > 0 && - selectedImageIds.length === recommendedImageIds.length && + selectedGalleryCount === recommendedImageIds.length && recommendedImageIds.every((id) => selectedSet.has(id)); const isAutoSelectedResult = Boolean(hasSystemRecommendedSelection) && !hasManualSelectionInteraction; - const canTogglePreviewMode = totalImageCount > 0 && selectedCount > 0 && selectedCount < totalImageCount; + const canTogglePreviewMode = + totalImageCount > 0 && selectedGalleryCount > 0 && selectedGalleryCount < totalImageCount; const visibleImages = useMemo(() => { - if (!result?.images?.length) { + if (!galleryImages.length) { return []; } - if (previewDisplayMode === "selected" && selectedCount > 0) { - return result.images.filter((image) => selectedSet.has(image.id)); + if (previewDisplayMode === "selected" && selectedGalleryCount > 0) { + return galleryImages.filter((image) => selectedSet.has(image.id)); } - return result.images; - }, [previewDisplayMode, result?.images, selectedCount, selectedSet]); + return galleryImages; + }, [galleryImages, previewDisplayMode, selectedGalleryCount, selectedSet]); const previewResetKey = useMemo( () => JSON.stringify({ queryType: result?.queryType || "", matchedSku: result?.matchedSku || "", - imageIds: result?.images?.map((image) => image.id) || [], + imageIds: galleryImages.map((image) => image.id), recommendedImageIds, }), - [recommendedImageIds, result?.images, result?.matchedSku, result?.queryType] + [galleryImages, recommendedImageIds, result?.matchedSku, result?.queryType] ); useEffect(() => { @@ -170,10 +205,9 @@ export function AgentWorkflowMaterialPicker({ const handleSelectAll = () => { setHasManualSelectionInteraction(true); - const allIds = - result?.images - ?.filter((item) => Boolean(String(item.imageUrl || "").trim())) - .map((item) => item.id) || []; + const allIds = allImages + .filter((item) => Boolean(String(item.imageUrl || "").trim())) + .map((item) => item.id); const nextIds = resolvedSelectionLimit !== null ? allIds.slice(0, resolvedSelectionLimit) : allIds; onSelectedImageIdsChange(nextIds); @@ -182,6 +216,96 @@ export function AgentWorkflowMaterialPicker({ } }; + const handleUploadInputChange = async ( + file: File | null, + options?: { + replaceImageId?: string; + } + ) => { + if (!file || !onUploadImage) return; + if (!String(file.type || "").startsWith("image/")) { + toast.error("请上传图片文件"); + return; + } + + try { + await onUploadImage(file, options); + } catch (error) { + toast.error(error instanceof Error ? error.message : "上传失败"); + } + }; + + const renderUploadPreview = (params: { + image?: AgentWorkflowMaterialImage | null; + selected?: boolean; + isUploading?: boolean; + }) => { + const { image, selected = false, isUploading = false } = params; + const hasImage = Boolean(image?.imageUrl); + + return ( +
+ {hasImage ? ( + {image?.label + ) : ( +
+ {isUploading ? ( + + ) : ( + + )} +
+ {isUploading ? "正在自动抠图" : "上传商品图"} +
+
+ {isUploading ? "系统会先抠图,再把透明底结果加入候选区。" : "支持 PNG / JPG / WEBP,上传后会自动抠图。"} +
+
+ )} + +
+ + {hasImage ? "用户上传" : "自动抠图上传"} + + {hasImage ? ( + + + + ) : null} +
+
+ {hasImage ? image?.fileName || image?.label : isUploading ? "抠图处理中..." : "没有搜索到时也可以直接上传"} +
+
+ ); + }; + return (
onQueryModeChange(mode)} - disabled={disabled} + disabled={disabled || hasUploadInFlight} className={cn( "rounded-full border px-3 py-1.5 text-xs transition-colors", queryMode === mode @@ -232,7 +356,7 @@ export function AgentWorkflowMaterialPicker({ value={value} onChange={(event) => onValueChange(event.target.value)} placeholder={placeholder} - disabled={disabled} + disabled={disabled || hasUploadInFlight} className={cn( hasFetchSuccess && "pr-11", isDark && @@ -241,7 +365,7 @@ export function AgentWorkflowMaterialPicker({ onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); - if (disabled) return; + if (disabled || hasUploadInFlight) return; onSearch(); } }} @@ -255,7 +379,7 @@ export function AgentWorkflowMaterialPicker({ +
+ + +
+ + ); + })} + + {Array.from({ length: remainingUploadSlotCount }, (_, index) => ( + + ))} + + ) : ( +
+ 当前上传槽位已满。若要改用用户上传图片,可先取消一张检索图,或删除上面已上传的图片后再补充。 +
+ )} + + ) : null} + {!disabled && result ? (
@@ -321,21 +610,31 @@ export function AgentWorkflowMaterialPicker({ 型号:{result.productModel} ) : null} - - 共 {result.totalCount} 张 + + 检索图 {totalImageCount} 张 + {uploadedImages.length > 0 ? ( - 已选 {selectedCount} 张 + 上传图 {uploadedImages.length} 张 + + ) : null} + + 已选 {selectedCount} 张 {resolvedSelectionLimit !== null ? ( setPreviewDisplayMode("selected")} - disabled={selectedCount === 0} + disabled={selectedGalleryCount === 0} className={cn( "rounded-full px-3 py-1.5 text-xs font-medium transition-colors", previewDisplayMode === "selected" @@ -372,7 +671,7 @@ export function AgentWorkflowMaterialPicker({ : "text-muted-foreground disabled:text-muted-foreground/50" )} > - 已选 {selectedCount} + 已选 {selectedGalleryCount}
diff --git a/components/agents/AgentWorkflowMaterialSelectionPanel.tsx b/components/agents/AgentWorkflowMaterialSelectionPanel.tsx index 980ef2891..b1ba1ca87 100644 --- a/components/agents/AgentWorkflowMaterialSelectionPanel.tsx +++ b/components/agents/AgentWorkflowMaterialSelectionPanel.tsx @@ -7,6 +7,7 @@ import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow import { cn } from "@/lib/utils"; import type { AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanel"; import type { + AgentWorkflowMaterialImage, AgentWorkflowMaterialSearchResult, AgentWorkflowMaterialQueryType, AgentWorkflowMaterialSelections, @@ -25,6 +26,110 @@ interface AgentWorkflowMaterialSelectionPanelProps { theme?: "light" | "dark"; } +function getUploadedWorkflowMaterialImages( + result?: AgentWorkflowMaterialSearchResult | null +): AgentWorkflowMaterialImage[] { + return Array.isArray(result?.images) + ? result.images + .filter((item) => item.sourceType === "upload") + .slice() + .sort((left, right) => left.sortOrder - right.sortOrder) + : []; +} + +function markSearchWorkflowMaterialImages( + images: AgentWorkflowMaterialImage[] +): AgentWorkflowMaterialImage[] { + return images.map((image) => ({ + ...image, + sourceType: image.sourceType === "upload" ? "upload" : "search", + })); +} + +function buildWorkflowMaterialResult(params: { + currentResult?: AgentWorkflowMaterialSearchResult | null; + images: AgentWorkflowMaterialImage[]; + fallbackQuery: string; + fallbackQueryType: AgentWorkflowMaterialQueryType; +}): AgentWorkflowMaterialSearchResult { + const normalizedImages: AgentWorkflowMaterialImage[] = params.images.map((image, index) => ({ + ...image, + sourceType: image.sourceType === "upload" ? "upload" : "search", + sortOrder: index, + })); + const validIds = new Set(normalizedImages.map((image) => image.id)); + const normalizedQuery = params.fallbackQuery.trim().toUpperCase(); + const currentResult = params.currentResult; + const queryType = currentResult?.queryType || params.fallbackQueryType; + const hasUploadedImages = normalizedImages.some((image) => image.sourceType === "upload"); + const fallbackMatchedSku = + queryType === "sku" + ? currentResult?.matchedSku || normalizedQuery || "手动上传素材" + : currentResult?.matchedSku || (hasUploadedImages ? "手动上传素材" : ""); + const fallbackMatchedSpu = + currentResult?.matchedSpu || + (queryType === "spu" ? normalizedQuery || (hasUploadedImages ? "手动上传素材" : "") : null); + + return { + query: currentResult?.query || normalizedQuery || "手动上传素材", + queryType, + matchedSku: currentResult?.matchedSku || fallbackMatchedSku, + matchedSpu: fallbackMatchedSpu, + materialId: currentResult?.materialId || null, + productModel: + currentResult?.productModel || + fallbackMatchedSpu || + currentResult?.matchedSku || + fallbackMatchedSku || + null, + zipUrl: currentResult?.zipUrl || null, + fixedCount: + normalizedImages.length > 0 + ? normalizedImages.filter((image) => image.group === "fixed").length + : currentResult?.fixedCount || 0, + otherCount: + normalizedImages.length > 0 + ? normalizedImages.filter((image) => image.group === "other").length + : currentResult?.otherCount || 0, + totalCount: + normalizedImages.length > 0 + ? normalizedImages.length + : currentResult?.totalCount || 0, + images: normalizedImages, + recommendedImageIds: Array.isArray(currentResult?.recommendedImageIds) + ? currentResult!.recommendedImageIds.filter((id) => validIds.has(id)) + : [], + skuOptions: currentResult?.skuOptions || [], + goodsInfo: currentResult?.goodsInfo || null, + }; +} + +function finalizeWorkflowMaterialResult( + result: AgentWorkflowMaterialSearchResult | null +): AgentWorkflowMaterialSearchResult | null { + if (!result) return null; + const hasImages = Array.isArray(result.images) && result.images.length > 0; + const hasSkuOptions = Array.isArray(result.skuOptions) && result.skuOptions.length > 0; + return hasImages || hasSkuOptions ? result : null; +} + +function mergeSearchResultWithUploadedImages(params: { + searchResult: AgentWorkflowMaterialSearchResult; + currentResult?: AgentWorkflowMaterialSearchResult | null; +}): AgentWorkflowMaterialSearchResult { + const uploadedImages = getUploadedWorkflowMaterialImages(params.currentResult); + const searchImages = markSearchWorkflowMaterialImages(params.searchResult.images || []); + return buildWorkflowMaterialResult({ + currentResult: { + ...params.searchResult, + images: [...uploadedImages, ...searchImages], + }, + images: [...uploadedImages, ...searchImages], + fallbackQuery: params.searchResult.query, + fallbackQueryType: params.searchResult.queryType === "spu" ? "spu" : "sku", + }); +} + export function AgentWorkflowMaterialSelectionPanel({ inputs, onInputsChange, @@ -69,6 +174,7 @@ export function AgentWorkflowMaterialSelectionPanel({ imageUrl: "", assetId: null, mimeType: null, + sourceType: "search", group: "fixed", sortOrder: index, })), @@ -82,6 +188,13 @@ export function AgentWorkflowMaterialSelectionPanel({ primary: { isLoading: false, errorText: null }, secondary: { isLoading: false, errorText: null }, }); + const [materialUploadState, setMaterialUploadState] = useState<{ + primary: { isUploadingNew: boolean; uploadingImageId: string | null }; + secondary: { isUploadingNew: boolean; uploadingImageId: string | null }; + }>({ + primary: { isUploadingNew: false, uploadingImageId: null }, + secondary: { isUploadingNew: false, uploadingImageId: null }, + }); const [materialSearchMode, setMaterialSearchMode] = useState<{ primary: AgentWorkflowMaterialQueryType; secondary: AgentWorkflowMaterialQueryType; @@ -122,6 +235,29 @@ export function AgentWorkflowMaterialSelectionPanel({ })); }; + const updateMaterialUploadState = ( + source: "primary" | "secondary", + updates: Partial<{ isUploadingNew: boolean; uploadingImageId: string | null }> + ) => { + setMaterialUploadState((prev) => ({ + ...prev, + [source]: { + ...prev[source], + ...updates, + }, + })); + }; + + const getFallbackMaterialQuery = (source: "primary" | "secondary") => { + const currentResult = materials[source].result; + const rawInput = String(source === "primary" ? inputs.primarySku : inputs.secondarySku).trim(); + return (currentResult?.query || rawInput || "手动上传素材").trim(); + }; + + const getFallbackMaterialQueryType = (source: "primary" | "secondary") => { + return materials[source].result?.queryType || materialSearchMode[source]; + }; + const handleMaterialInputChange = (source: "primary" | "secondary", value: string) => { const inputKey = source === "primary" ? "primarySku" : "secondarySku"; const trimmedValue = value.trim().toUpperCase(); @@ -189,13 +325,26 @@ export function AgentWorkflowMaterialSelectionPanel({ throw new Error(result?.error?.message || result?.error || "搜索失败"); } + const mergedResult = mergeSearchResultWithUploadedImages({ + searchResult: result.data, + currentResult: materials[source].result, + }); + const preservedUploadedIds = getUploadedWorkflowMaterialImages(materials[source].result) + .map((image) => image.id) + .filter((id) => materials[source].selectedImageIds.includes(id)); + onMaterialsChange({ ...materials, [source]: { - result: result.data, - selectedImageIds: Array.isArray(result.data?.recommendedImageIds) - ? result.data.recommendedImageIds - : [], + result: mergedResult, + selectedImageIds: Array.from( + new Set([ + ...(Array.isArray(result.data?.recommendedImageIds) + ? result.data.recommendedImageIds + : []), + ...preservedUploadedIds, + ]) + ).filter((id) => mergedResult.images.some((image) => image.id === id)), }, }); } catch (error) { @@ -206,6 +355,130 @@ export function AgentWorkflowMaterialSelectionPanel({ updateMaterialSearchState(source, { isLoading: false }); } }; + + const handleUploadMaterialImage = async ( + source: "primary" | "secondary", + file: File, + options?: { + replaceImageId?: string; + } + ) => { + const currentResult = materials[source].result; + const query = getFallbackMaterialQuery(source); + const queryType = getFallbackMaterialQueryType(source); + const matchedSku = + currentResult?.matchedSku || + (queryType === "sku" + ? String(source === "primary" ? inputs.primarySku : inputs.secondarySku).trim().toUpperCase() + : ""); + const matchedSpu = + currentResult?.matchedSpu || + (queryType === "spu" + ? String(source === "primary" ? inputs.primarySku : inputs.secondarySku).trim().toUpperCase() + : ""); + + updateMaterialUploadState(source, { + isUploadingNew: !options?.replaceImageId, + uploadingImageId: options?.replaceImageId || null, + }); + + try { + const formData = new FormData(); + formData.set("file", file); + formData.set("source", source); + formData.set("query", query); + formData.set("queryType", queryType); + if (matchedSku) { + formData.set("matchedSku", matchedSku); + } + if (matchedSpu) { + formData.set("matchedSpu", matchedSpu); + } + if (currentResult?.productModel) { + formData.set("productModel", currentResult.productModel); + } + + const response = await fetch("/api/agent-workflow/material-upload", { + method: "POST", + body: formData, + }); + const payload = await response.json().catch(() => ({})); + + if (!response.ok || !payload?.success || !payload?.data?.imageUrl) { + throw new Error(payload?.error || payload?.detail || "图片上传失败"); + } + + const uploadedImage = payload.data as AgentWorkflowMaterialImage; + const currentUploads = getUploadedWorkflowMaterialImages(currentResult).filter( + (image) => image.id !== options?.replaceImageId + ); + const searchImages = markSearchWorkflowMaterialImages( + (currentResult?.images || []).filter((image) => image.sourceType !== "upload") + ); + const nextResult = buildWorkflowMaterialResult({ + currentResult, + images: [...currentUploads, { ...uploadedImage, sourceType: "upload" }, ...searchImages], + fallbackQuery: query, + fallbackQueryType: queryType, + }); + const nextSelectedIds = Array.from( + new Set([ + ...materials[source].selectedImageIds.filter( + (id) => id !== options?.replaceImageId && nextResult.images.some((image) => image.id === id) + ), + uploadedImage.id, + ]) + ); + + onMaterialsChange({ + ...materials, + [source]: { + result: nextResult, + selectedImageIds: nextSelectedIds, + }, + }); + } finally { + updateMaterialUploadState(source, { + isUploadingNew: false, + uploadingImageId: null, + }); + } + }; + + const handleRemoveUploadedMaterialImage = ( + source: "primary" | "secondary", + imageId: string + ) => { + const currentResult = materials[source].result; + if (!currentResult) { + return; + } + + const nextUploads = getUploadedWorkflowMaterialImages(currentResult).filter( + (image) => image.id !== imageId + ); + const searchImages = markSearchWorkflowMaterialImages( + (currentResult.images || []).filter((image) => image.sourceType !== "upload") + ); + const nextResult = finalizeWorkflowMaterialResult( + buildWorkflowMaterialResult({ + currentResult, + images: [...nextUploads, ...searchImages], + fallbackQuery: getFallbackMaterialQuery(source), + fallbackQueryType: getFallbackMaterialQueryType(source), + }) + ); + + onMaterialsChange({ + ...materials, + [source]: { + result: nextResult, + selectedImageIds: materials[source].selectedImageIds.filter( + (id) => id !== imageId && (nextResult?.images || []).some((image) => image.id === id) + ), + }, + }); + }; const isDark = theme === "dark"; return ( @@ -273,11 +546,18 @@ export function AgentWorkflowMaterialSelectionPanel({ onSearch={() => { void handleSearchMaterials("primary"); }} + onUploadImage={(file, options) => handleUploadMaterialImage("primary", file, options)} + onRemoveUploadedImage={(imageId) => + handleRemoveUploadedMaterialImage("primary", imageId) + } + isUploadingNewImage={materialUploadState.primary.isUploadingNew} + uploadingImageId={materialUploadState.primary.uploadingImageId} onSkuOptionSelect={(skuName) => { const selectedOption = materials.primary.result?.skuOptions?.find( (option) => option.skuName === skuName ); const placeholderCount = selectedOption?.whiteBackgroundCount || 8; + const uploadedImages = getUploadedWorkflowMaterialImages(materials.primary.result); onInputsChange({ ...inputs, primarySku: skuName, @@ -285,12 +565,42 @@ export function AgentWorkflowMaterialSelectionPanel({ onMaterialsChange({ ...materials, primary: { - result: buildLoadingMaterialResult({ - keyword: skuName, - currentResult: materials.primary.result, - placeholderCount, + result: buildWorkflowMaterialResult({ + currentResult: { + ...buildLoadingMaterialResult({ + keyword: skuName, + currentResult: materials.primary.result, + placeholderCount, + }), + images: [ + ...uploadedImages, + ...buildLoadingMaterialResult({ + keyword: skuName, + currentResult: materials.primary.result, + placeholderCount, + }).images.map((image) => ({ + ...image, + sourceType: "search" as const, + })), + ], + }, + images: [ + ...uploadedImages, + ...buildLoadingMaterialResult({ + keyword: skuName, + currentResult: materials.primary.result, + placeholderCount, + }).images.map((image) => ({ + ...image, + sourceType: "search" as const, + })), + ], + fallbackQuery: skuName, + fallbackQueryType: "sku", }), - selectedImageIds: [], + selectedImageIds: materials.primary.selectedImageIds.filter((id) => + uploadedImages.some((image) => image.id === id) + ), }, }); setMaterialSearchMode((prev) => ({ @@ -347,11 +657,18 @@ export function AgentWorkflowMaterialSelectionPanel({ onSearch={() => { void handleSearchMaterials("secondary"); }} + onUploadImage={(file, options) => handleUploadMaterialImage("secondary", file, options)} + onRemoveUploadedImage={(imageId) => + handleRemoveUploadedMaterialImage("secondary", imageId) + } + isUploadingNewImage={materialUploadState.secondary.isUploadingNew} + uploadingImageId={materialUploadState.secondary.uploadingImageId} onSkuOptionSelect={(skuName) => { const selectedOption = materials.secondary.result?.skuOptions?.find( (option) => option.skuName === skuName ); const placeholderCount = selectedOption?.whiteBackgroundCount || 8; + const uploadedImages = getUploadedWorkflowMaterialImages(materials.secondary.result); onInputsChange({ ...inputs, secondarySku: skuName, @@ -359,12 +676,42 @@ export function AgentWorkflowMaterialSelectionPanel({ onMaterialsChange({ ...materials, secondary: { - result: buildLoadingMaterialResult({ - keyword: skuName, - currentResult: materials.secondary.result, - placeholderCount, + result: buildWorkflowMaterialResult({ + currentResult: { + ...buildLoadingMaterialResult({ + keyword: skuName, + currentResult: materials.secondary.result, + placeholderCount, + }), + images: [ + ...uploadedImages, + ...buildLoadingMaterialResult({ + keyword: skuName, + currentResult: materials.secondary.result, + placeholderCount, + }).images.map((image) => ({ + ...image, + sourceType: "search" as const, + })), + ], + }, + images: [ + ...uploadedImages, + ...buildLoadingMaterialResult({ + keyword: skuName, + currentResult: materials.secondary.result, + placeholderCount, + }).images.map((image) => ({ + ...image, + sourceType: "search" as const, + })), + ], + fallbackQuery: skuName, + fallbackQueryType: "sku", }), - selectedImageIds: [], + selectedImageIds: materials.secondary.selectedImageIds.filter((id) => + uploadedImages.some((image) => image.id === id) + ), }, }); setMaterialSearchMode((prev) => ({ diff --git a/lib/agents/workflow-material-config.ts b/lib/agents/workflow-material-config.ts index 5d2d946d9..a50351e03 100644 --- a/lib/agents/workflow-material-config.ts +++ b/lib/agents/workflow-material-config.ts @@ -42,6 +42,7 @@ function normalizeMaterialImage( imageUrl, assetId: sanitizeText(raw.assetId, 128) || null, mimeType: sanitizeText(raw.mimeType, 128) || null, + sourceType: raw.sourceType === "upload" ? "upload" : "search", group, sortOrder: Number.isFinite(Number(raw.sortOrder)) ? Math.max(0, Math.min(999, Math.round(Number(raw.sortOrder)))) diff --git a/types/agent.ts b/types/agent.ts index 70d40d87f..58ef233b3 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -175,6 +175,7 @@ export interface AgentWorkflowMaterialImage { imageUrl: string; assetId?: string | null; mimeType?: string | null; + sourceType?: "search" | "upload"; group: "fixed" | "other"; sortOrder: number; }