diff --git a/components/agents/AgentDetailReferenceStitchDialog.tsx b/components/agents/AgentDetailReferenceStitchDialog.tsx new file mode 100644 index 000000000..cb93c0554 --- /dev/null +++ b/components/agents/AgentDetailReferenceStitchDialog.tsx @@ -0,0 +1,630 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { Loader2, Plus, RotateCcw, Scissors, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; +import { buildImageProxyUrl } from "@/lib/image-delivery"; +import { cn } from "@/lib/utils"; +import type { AgentDetailReplacementReferenceItem } from "@/types/agent"; + +const MERGED_IMAGE_MAX_WIDTH = 1600; +const MIN_SLICE_HEIGHT = 120; +const MERGED_IMAGE_MAX_PIXELS = 42_000_000; + +type LoadedReferenceImage = { + card: AgentDetailReplacementReferenceItem; + image: HTMLImageElement; + width: number; + height: number; + scaledHeight: number; +}; + +type MergedSnapshot = { + width: number; + height: number; + defaultCutLines: number[]; + loadedImages: LoadedReferenceImage[]; +}; + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function loadImage(url: string) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = "anonymous"; + image.onload = () => resolve(image); + image.onerror = () => reject(new Error("图片加载失败")); + image.src = buildImageProxyUrl(url, { + width: MERGED_IMAGE_MAX_WIDTH, + quality: 92, + format: "png", + }); + }); +} + +function buildMergedSnapshot(images: LoadedReferenceImage[]) { + const baseWidth = Math.max( + 1, + Math.min( + MERGED_IMAGE_MAX_WIDTH, + Math.max(...images.map((item) => item.width), 1) + ) + ); + const baseTotalHeight = images.reduce((total, item) => { + return total + Math.max(1, Math.round((baseWidth / Math.max(item.width, 1)) * item.height)); + }, 0); + const basePixels = baseWidth * baseTotalHeight; + const safeScale = + basePixels > MERGED_IMAGE_MAX_PIXELS + ? Math.sqrt(MERGED_IMAGE_MAX_PIXELS / Math.max(basePixels, 1)) + : 1; + const targetWidth = Math.max( + 1, + Math.min( + baseWidth, + Math.max(baseWidth > 720 ? 720 : baseWidth, Math.round(baseWidth * safeScale)) + ) + ); + + let totalHeight = 0; + const defaultCutLines: number[] = []; + const normalizedImages = images.map((item, index) => { + const scaledHeight = Math.max( + 1, + Math.round((targetWidth / Math.max(item.width, 1)) * item.height) + ); + totalHeight += scaledHeight; + if (index < images.length - 1) { + defaultCutLines.push(totalHeight); + } + return { + ...item, + scaledHeight, + }; + }); + + const canvas = document.createElement("canvas"); + canvas.width = targetWidth; + canvas.height = totalHeight; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("无法初始化拼合画布"); + } + + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, canvas.width, canvas.height); + + let offsetY = 0; + normalizedImages.forEach((item) => { + context.drawImage(item.image, 0, offsetY, targetWidth, item.scaledHeight); + offsetY += item.scaledHeight; + }); + + return { + canvas, + snapshot: { + width: targetWidth, + height: totalHeight, + defaultCutLines, + loadedImages: normalizedImages, + } satisfies MergedSnapshot, + }; +} + +interface AgentDetailReferenceStitchDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + cards: AgentDetailReplacementReferenceItem[]; + sourceLabel?: string; + onApply: (nextCards: AgentDetailReplacementReferenceItem[]) => void; +} + +export function AgentDetailReferenceStitchDialog({ + open, + onOpenChange, + cards, + sourceLabel = "详情参考图", + onApply, +}: AgentDetailReferenceStitchDialogProps) { + const previewCanvasRef = useRef(null); + const previewContainerRef = useRef(null); + const mergedCanvasRef = useRef(null); + const [snapshot, setSnapshot] = useState(null); + const [cutLines, setCutLines] = useState([]); + const [draggingLineIndex, setDraggingLineIndex] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isApplying, setIsApplying] = useState(false); + const [applyProgressText, setApplyProgressText] = useState(""); + const [errorText, setErrorText] = useState(null); + + const validCards = useMemo( + () => + cards.filter( + (card): card is AgentDetailReplacementReferenceItem & { imageUrl: string } => + Boolean(String(card.imageUrl || "").trim()) + ), + [cards] + ); + + const normalizedCutLines = useMemo( + () => + [...cutLines] + .filter((value) => Number.isFinite(value)) + .sort((left, right) => left - right), + [cutLines] + ); + + const sliceSegments = useMemo(() => { + if (!snapshot) return []; + const points = [0, ...normalizedCutLines, snapshot.height]; + return Array.from({ length: Math.max(points.length - 1, 0) }, (_, index) => ({ + index, + top: points[index], + height: Math.max(points[index + 1] - points[index], 1), + })); + }, [normalizedCutLines, snapshot]); + + const redrawPreviewCanvas = useCallback(() => { + const previewCanvas = previewCanvasRef.current; + const mergedCanvas = mergedCanvasRef.current; + if (!previewCanvas || !mergedCanvas || !snapshot) { + return; + } + + previewCanvas.width = snapshot.width; + previewCanvas.height = snapshot.height; + const context = previewCanvas.getContext("2d"); + if (!context) { + return; + } + context.clearRect(0, 0, snapshot.width, snapshot.height); + context.drawImage(mergedCanvas, 0, 0); + }, [snapshot]); + + useEffect(() => { + redrawPreviewCanvas(); + }, [redrawPreviewCanvas]); + + useEffect(() => { + if (!open) { + setDraggingLineIndex(null); + return; + } + + if (validCards.length === 0) { + setSnapshot(null); + setCutLines([]); + setErrorText(`当前没有可拼合的${sourceLabel}`); + return; + } + + let cancelled = false; + + const setup = async () => { + try { + setIsLoading(true); + setErrorText(null); + + const loadedImages = await Promise.all( + validCards.map(async (card) => { + const image = await loadImage(card.imageUrl); + return { + card, + image, + width: image.naturalWidth, + height: image.naturalHeight, + scaledHeight: image.naturalHeight, + } satisfies LoadedReferenceImage; + }) + ); + + if (cancelled) return; + + const next = buildMergedSnapshot(loadedImages); + mergedCanvasRef.current = next.canvas; + setSnapshot(next.snapshot); + setCutLines(next.snapshot.defaultCutLines); + } catch (error) { + if (cancelled) return; + console.error("[AgentDetailReferenceStitchDialog] Failed to prepare merged image:", error); + setSnapshot(null); + setCutLines([]); + setErrorText(error instanceof Error ? error.message : "拼合参考图失败"); + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + + void setup(); + + return () => { + cancelled = true; + }; + }, [open, sourceLabel, validCards]); + + useEffect(() => { + if (draggingLineIndex === null || !snapshot) { + return; + } + + const handlePointerMove = (event: PointerEvent) => { + const container = previewContainerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + if (rect.height <= 0) return; + + const rawY = ((event.clientY - rect.top) / rect.height) * snapshot.height; + setCutLines((current) => { + const next = [...current].sort((left, right) => left - right); + const minY = + draggingLineIndex === 0 + ? MIN_SLICE_HEIGHT + : next[draggingLineIndex - 1] + MIN_SLICE_HEIGHT; + const maxY = + draggingLineIndex === next.length - 1 + ? snapshot.height - MIN_SLICE_HEIGHT + : next[draggingLineIndex + 1] - MIN_SLICE_HEIGHT; + next[draggingLineIndex] = clamp(rawY, minY, maxY); + return next; + }); + }; + + const handlePointerUp = () => { + setDraggingLineIndex(null); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + + return () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + }; + }, [draggingLineIndex, snapshot]); + + const handleAddCutLine = useCallback(() => { + if (!snapshot) return; + const points = [0, ...normalizedCutLines, snapshot.height]; + let bestIndex = -1; + let bestHeight = 0; + + for (let index = 0; index < points.length - 1; index += 1) { + const currentHeight = points[index + 1] - points[index]; + if (currentHeight > bestHeight) { + bestHeight = currentHeight; + bestIndex = index; + } + } + + if (bestIndex < 0 || bestHeight < MIN_SLICE_HEIGHT * 2) { + toast.error("当前没有足够空间继续添加切线"); + return; + } + + const nextY = Math.round((points[bestIndex] + points[bestIndex + 1]) / 2); + setCutLines((current) => + [...current, nextY].sort((left, right) => left - right) + ); + }, [normalizedCutLines, snapshot]); + + const handleResetCutLines = useCallback(() => { + if (!snapshot) return; + setCutLines(snapshot.defaultCutLines); + }, [snapshot]); + + const handleRemoveCutLine = useCallback((index: number) => { + setCutLines((current) => current.filter((_, currentIndex) => currentIndex !== index)); + }, []); + + const handleLinePointerDown = useCallback( + (index: number) => (event: ReactPointerEvent) => { + event.preventDefault(); + setDraggingLineIndex(index); + }, + [] + ); + + const handleApply = useCallback(async () => { + if (!snapshot || !mergedCanvasRef.current) { + toast.error("当前还没有可用的拼合图"); + return; + } + + if (sliceSegments.length === 0) { + toast.error("当前没有可切割的参考图"); + return; + } + + setIsApplying(true); + setApplyProgressText(`正在上传切片 1/${sliceSegments.length}`); + + try { + const nextCards: AgentDetailReplacementReferenceItem[] = []; + const sourceCanvas = mergedCanvasRef.current; + const sourceCandidateId = validCards[0]?.candidateId || null; + const timestamp = Date.now(); + + for (const segment of sliceSegments) { + setApplyProgressText(`正在上传切片 ${segment.index + 1}/${sliceSegments.length}`); + + const sliceCanvas = document.createElement("canvas"); + sliceCanvas.width = snapshot.width; + sliceCanvas.height = segment.height; + const context = sliceCanvas.getContext("2d"); + if (!context) { + throw new Error("切片画布初始化失败"); + } + + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, sliceCanvas.width, sliceCanvas.height); + context.drawImage( + sourceCanvas, + 0, + segment.top, + snapshot.width, + segment.height, + 0, + 0, + snapshot.width, + segment.height + ); + + const blob = await new Promise((resolve, reject) => { + sliceCanvas.toBlob( + (value) => { + if (value) { + resolve(value); + return; + } + reject(new Error("切片导出失败")); + }, + "image/jpeg", + 0.92 + ); + }); + + const formData = new FormData(); + formData.append( + "file", + new File([blob], `detail-reference-slice-${timestamp}-${segment.index + 1}.jpg`, { + type: "image/jpeg", + }) + ); + + const response = await fetchUploadWithRetry(formData); + const result = await response.json().catch(() => ({})); + if (!response.ok || !result?.url) { + throw new Error(result?.error || `第 ${segment.index + 1} 张切片上传失败`); + } + + nextCards.push({ + id: `detail-stitch-slice-${timestamp}-${segment.index + 1}`, + label: `拼合切片 ${segment.index + 1}`, + imageUrl: String(result.url), + candidateId: sourceCandidateId, + sortOrder: segment.index, + }); + } + + onApply(nextCards); + onOpenChange(false); + toast.success(`已生成 ${nextCards.length} 张切片参考图`); + } catch (error) { + console.error("[AgentDetailReferenceStitchDialog] Failed to apply slices:", error); + toast.error(error instanceof Error ? error.message : "切割参考图失败"); + } finally { + setIsApplying(false); + setApplyProgressText(""); + } + }, [onApply, onOpenChange, sliceSegments, snapshot, validCards]); + + return ( + + + +
+
+ + + 拼合切割{sourceLabel} + + + 先把当前参考图按顺序纵向拼成一张长图,再通过横向切线重新切成多屏,继续用于详情替换逐屏生成。 + +
+
+
+ 原始 {validCards.length} 张 +
+
+ 当前将切成 {sliceSegments.length || 0} 张 +
+
+
+
+ +
+
+
+ + +
+ 默认切线已按原始详情页边界生成,可直接拖动横线微调。 +
+
+ +
+ {isLoading ? ( +
+ +
正在拼合参考图...
+
会按当前参考图顺序从上到下拼成一张长图
+
+ ) : errorText ? ( +
+
{errorText}
+
+ 请先确认当前已经成功加载参考详情图,再进入拼合切割。 +
+
+ ) : snapshot ? ( +
+
+ + +
+ {normalizedCutLines.map((lineY, index) => { + const topPercent = (lineY / snapshot.height) * 100; + return ( +
+
+
+
+
+ 切线 {index + 1} +
+ +
+
+
+ ); + })} +
+
+ +
+ 向下拖动可浏览整张拼合长图。每条横线代表一次切割边界,应用后会替换当前详情参考图列表。 +
+
+ ) : null} +
+
+ +
+
+
切割结果预估
+
+ 应用切割后,下方每一段都会回到详情替换参考图区,继续逐屏生成。 +
+
+ +
+ {sliceSegments.length > 0 ? ( + sliceSegments.map((segment) => ( +
+
+
+ 切片 {segment.index + 1} +
+
+ 高度 {segment.height}px +
+
+
+ 从 {Math.round(segment.top)}px 到 {Math.round(segment.top + segment.height)}px +
+
+ )) + ) : ( +
+ 当前还没有可切割的长图。 +
+ )} +
+ +
+ +
+
+
+ +
+ ); +} diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 37acf044d..c00d95b52 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -39,6 +39,7 @@ import { import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar"; import { ImageGenerationLoading } from "@/components/image-generation-loading"; import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner"; +import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog"; import { useCanvasStore } from "@/store/canvas"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { buildImageProxyUrl } from "@/lib/image-delivery"; @@ -551,6 +552,7 @@ export function AgentGenerationWorkspaceDarkV2({ isLoading: false, lastQuery: null, }); + const [detailReferenceStitchDialogOpen, setDetailReferenceStitchDialogOpen] = useState(false); const [activeDetailReferenceCardId, setActiveDetailReferenceCardId] = useState(null); const [skuReplacementSearchState, setSkuReplacementSearchState] = useState<{ isLoading: boolean; @@ -1756,6 +1758,15 @@ export function AgentGenerationWorkspaceDarkV2({ 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) { @@ -2381,6 +2392,16 @@ export function AgentGenerationWorkspaceDarkV2({ `查询${detailReferenceSourceLabel}` )} + {detailReplacementReferenceCards.length > 0 ? ( + + ) : null} {detailReplacementReferenceCards.length > 0 ? ( + ) : null} +
+ {detailReplacementReferenceCards.length} 张 +
diff --git a/components/agents/AgentWorkflowPanelDarkV2.tsx b/components/agents/AgentWorkflowPanelDarkV2.tsx index a0cf6eb7c..7d7d15d25 100644 --- a/components/agents/AgentWorkflowPanelDarkV2.tsx +++ b/components/agents/AgentWorkflowPanelDarkV2.tsx @@ -611,30 +611,6 @@ export function AgentWorkflowPanelDarkV2({ })} - -
-
- - {activeEmbeddedStepIndex + 2} - - {activeEmbeddedStepEntry.step.title} - {activeEmbeddedStepEntry.depth > 1 ? ( - - {activeEmbeddedStepEntry.depth === 2 ? "二级模块" : "三级模块"} - - ) : null} -
-
- {stepSelectionSummaryMap[activeEmbeddedStepEntry.step.key] - ? `已选:${stepSelectionSummaryMap[activeEmbeddedStepEntry.step.key]}` - : "待选择:完成当前卡片后会自动推进到下一步"} -
- {activeEmbeddedStepEntry.step.description ? ( -

- {activeEmbeddedStepEntry.step.description} -

- ) : null} -
{renderOptionLevels(