From f7d18f20db75a6baa96c348a0a399f3530149915 Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 22 Jun 2026 14:46:25 +0800 Subject: [PATCH] fix: keep latest canvas image highlighted --- components/canvas/CanvasChatPanel.tsx | 248 +++++++++++++++++++------- components/canvas/CanvasWorkspace.tsx | 43 ++++- components/canvas/nodes/ImageNode.tsx | 19 +- 3 files changed, 237 insertions(+), 73 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index ded1af6d0..82d18ea60 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -660,6 +660,30 @@ function getAnchoredGenerationPosition(params: { return fallbackBase; } +function getCenteredReplacementPosition( + placeholderNode: CanvasNode, + size: { width: number; height: number } +) { + const width = Math.max(1, Number(size.width || 300)); + const height = Math.max(1, Number(size.height || 300)); + const placeholderWidth = Math.max( + 1, + Number(placeholderNode.size?.width || width) + ); + const placeholderHeight = Math.max( + 1, + Number(placeholderNode.size?.height || height) + ); + + return { + x: Number(placeholderNode.position?.x || 0) + placeholderWidth / 2 - width / 2, + y: + Number(placeholderNode.position?.y || 0) + + placeholderHeight / 2 - + height / 2, + }; +} + type CanvasImageGenerationRequest = { prompt: string; model?: string; @@ -1603,6 +1627,29 @@ export function CanvasChatPanel({ // 🆕 物品拾取 const pendingPickedObjects = useCanvasStore((state) => state.pendingPickedObjects); const clearPendingPickedObjects = useCanvasStore((state) => state.clearPendingPickedObjects); + + const clearLatestGeneratedImageHighlights = useCallback(() => { + const currentNodes = useCanvasStore.getState().nodes; + const clearedAt = Date.now(); + + currentNodes.forEach((node) => { + if (node.type !== "image") return; + const data = node.data as Record; + const metadata = (data?.metadata || {}) as Record; + if (metadata.latestGeneratedHighlight !== true) return; + + updateNode(node.id, { + data: { + ...data, + metadata: { + ...metadata, + latestGeneratedHighlight: false, + latestGeneratedClearedAt: clearedAt, + }, + } as any, + }); + }); + }, [updateNode]); // 获取纯 UUID(去掉 canvas- 前缀)用于 API 调用 const rawChatId = toRawCanvasChatId(activeChatId); @@ -3659,17 +3706,21 @@ export function CanvasChatPanel({ : selectedImageNodesRef.current, sourceImageUrls, }); - const placeholderNode = placeholderNodes[index]; + const reservedPlaceholderNode = placeholderNodes[index]; + const placeholderNode = reservedPlaceholderNode + ? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null + : null; const size = { width: dimensions.width, height: dimensions.height }; - const position = getAnchoredGenerationPosition({ - anchorNode, - existingNodes: currentNodes, - excludeNodeIds: placeholderNode ? [placeholderNode.id] : [], - fallbackNodeCount: currentNodes.length + addedImageCount + addedVideoCount, - index, - size, - viewport: useCanvasStore.getState().viewport, - }); + const position = placeholderNode + ? getCenteredReplacementPosition(placeholderNode, size) + : getAnchoredGenerationPosition({ + anchorNode, + existingNodes: currentNodes, + fallbackNodeCount: currentNodes.length + addedImageCount + addedVideoCount, + index, + size, + viewport: useCanvasStore.getState().viewport, + }); const data = { src: url, status: "completed" as const, @@ -3691,6 +3742,7 @@ export function CanvasChatPanel({ sourceNodeId: sourceNodeId || anchorNode?.id || undefined, placementMode: "anchored-generation", recentlyGeneratedAt: completedAt, + latestGeneratedHighlight: true, }, }; const nodeId = placeholderNode @@ -3857,7 +3909,15 @@ export function CanvasChatPanel({ typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data; if (!payload || typeof payload !== "object") return; if (payload.phase === "completed") { - clearStreamImageGenerationStateForChat(eventChatId); + setStreamImageGenerationStateForChat(eventChatId, { + phase: "finalizing", + providerType: payload.providerType, + modelName: getAgentSafeModelName(payload.modelName), + imageCount: Number(payload.imageCount || 0) || undefined, + }); + window.setTimeout(() => { + clearStreamImageGenerationStateForChat(eventChatId); + }, 30_000); return; } setStreamImageGenerationStateForChat(eventChatId, { @@ -3968,8 +4028,9 @@ export function CanvasChatPanel({ ]); }); } - void appendStreamMediaToCanvas(payload); - clearStreamImageGenerationStateForChat(eventChatId); + void appendStreamMediaToCanvas(payload).finally(() => { + clearStreamImageGenerationStateForChat(eventChatId); + }); } catch (e) { console.error("[CanvasChat] Failed to parse data-canvasMedia:", e); } @@ -4082,7 +4143,13 @@ export function CanvasChatPanel({ Math.max(1, Number(imageParamsRef.current?.imageCount || 1)), }); } - if (hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) { + const shouldDeferClearForActiveMedia = + isEventForActiveChat && + (incomingImageUrls.length > 0 || incomingVideoUrls.length > 0); + if ( + (hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) && + !shouldDeferClearForActiveMedia + ) { clearStreamImageGenerationStateForChat(eventChatId); } @@ -4651,6 +4718,15 @@ export function CanvasChatPanel({ }); if (hasCompletedMedia) { + const hasActivePlaceholder = useCanvasStore.getState().nodes.some((node) => { + if (node.type !== "image") return false; + const metadata = ((node.data as any)?.metadata || {}) as Record; + return ( + metadata.canvasGenerationPlaceholder === true && + (!metadata.chatId || String(metadata.chatId) === activeChatId) + ); + }); + if (hasActivePlaceholder) return; clearStreamImageGenerationStateForChat(activeChatId); } }, [ @@ -4856,6 +4932,14 @@ export function CanvasChatPanel({ let addedVideoCount = 0; const insertedNodeIds: string[] = []; const completedAt = Date.now(); + const messagePlaceholderNodes = useCanvasStore.getState().nodes.filter((node) => { + if (node.type !== "image") return false; + const metadata = ((node.data as any)?.metadata || {}) as Record; + return ( + metadata.canvasGenerationPlaceholder === true && + (!metadata.chatId || String(metadata.chatId) === activeChatId) + ); + }); const newImageEntries = imageUrls.reduce>( (acc, url) => { @@ -4881,6 +4965,11 @@ export function CanvasChatPanel({ [] ); + if (newImageEntries.length === 0 && imageUrls.length > 0 && messagePlaceholderNodes.length > 0) { + removeCanvasGenerationPlaceholders(activeChatId); + clearStreamImageGenerationStateForChat(activeChatId); + } + for (let index = 0; index < newImageEntries.length; index += 1) { if (cancelled) return; const imageEntry = newImageEntries[index]; @@ -4937,7 +5026,13 @@ export function CanvasChatPanel({ : selectedImageNodesRef.current, sourceImageUrls: nearestUserImageUrls, }); - const size = anchorNode + const reservedPlaceholderNode = messagePlaceholderNodes[index]; + const placeholderNode = reservedPlaceholderNode + ? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null + : null; + const size = placeholderNode + ? { width: dimensions.width, height: dimensions.height } + : anchorNode ? { width: Math.max( 1, @@ -4949,39 +5044,58 @@ export function CanvasChatPanel({ ), } : { width: dimensions.width, height: dimensions.height }; - const position = getAnchoredGenerationPosition({ - anchorNode, - existingNodes: currentNodes, - fallbackNodeCount: currentNodes.length + addedImageCount + addedVideoCount, - index, - size, - viewport: useCanvasStore.getState().viewport, - }); + const position = placeholderNode + ? getCenteredReplacementPosition(placeholderNode, size) + : getAnchoredGenerationPosition({ + anchorNode, + existingNodes: currentNodes, + fallbackNodeCount: currentNodes.length + addedImageCount + addedVideoCount, + index, + size, + viewport: useCanvasStore.getState().viewport, + }); - const nodeId = addNode({ - type: "image", - position, - size, - data: { - src: url, - status: "completed", - title: "图片", - prompt: generationPrompt || "对话生成", - originalSrc: canUseCompareSource ? compareSourceUrl : undefined, - metadata: { - originalWidth: dimensions.originalWidth, - originalHeight: dimensions.originalHeight, - generationPrompt: generationPrompt || undefined, - sourceMessageId: message.id, - sourceImageKey: imageEntry.sourceImageKey, - compareSourceUrl: canUseCompareSource ? compareSourceUrl : undefined, - sourceNodeId: sourceNodeId || anchorNode?.id || undefined, - placementMode: "anchored-generation", - recentlyGeneratedAt: completedAt, - }, + const data = { + src: url, + status: "completed" as const, + title: "图片", + prompt: generationPrompt || "对话生成", + originalSrc: canUseCompareSource ? compareSourceUrl : undefined, + metadata: { + originalWidth: dimensions.originalWidth, + originalHeight: dimensions.originalHeight, + generationPrompt: generationPrompt || undefined, + sourceMessageId: message.id, + sourceImageKey: imageEntry.sourceImageKey, + compareSourceUrl: canUseCompareSource ? compareSourceUrl : undefined, + sourceNodeId: sourceNodeId || anchorNode?.id || undefined, + placementMode: "anchored-generation", + recentlyGeneratedAt: completedAt, + latestGeneratedHighlight: true, }, - connections: sourceNodeId ? [sourceNodeId] : anchorNode?.id ? [anchorNode.id] : [], - }); + }; + const connections = sourceNodeId + ? [sourceNodeId] + : anchorNode?.id + ? [anchorNode.id] + : []; + const nodeId = placeholderNode + ? placeholderNode.id + : addNode({ + type: "image", + position, + size, + data, + connections, + }); + if (placeholderNode) { + updateNode(nodeId, { + position, + size, + data, + connections, + }); + } insertedNodeIds.push(nodeId); void autoNameGeneratedImageNode(nodeId, url); addedImageCount += 1; @@ -4995,6 +5109,7 @@ export function CanvasChatPanel({ if (focusNode) { focusCanvasOnNode(focusNode); } + clearStreamImageGenerationStateForChat(activeChatId); } const newVideoUrls = videoUrls.filter((url) => { @@ -5037,8 +5152,10 @@ export function CanvasChatPanel({ addNode, nodes, autoNameGeneratedImageNode, + clearStreamImageGenerationStateForChat, focusCanvasOnNode, selectNodes, + updateNode, ]); useEffect(() => { @@ -7503,6 +7620,7 @@ export function CanvasChatPanel({ } if (isDetailGenerationMode && canUseDetailGeneration) { + clearLatestGeneratedImageHighlights(); setSendGuard(activeChatId, { inFlight: true, signature: `detail:${activeChatId}:${Date.now()}`, @@ -7723,25 +7841,26 @@ export function CanvasChatPanel({ return; } - setSendGuard(activeChatId, { - inFlight: true, - signature: sendSignature, - timestamp: Date.now(), + clearLatestGeneratedImageHighlights(); + setSendGuard(activeChatId, { + inFlight: true, + signature: sendSignature, + timestamp: Date.now(), + }); + inFlightChatIdRef.current = activeChatId; + inFlightRawChatIdRef.current = rawChatId; + clearStreamImageGenerationStateForChat(activeChatId); + clearAgentProgressStateForChat(activeChatId); + if (shouldShowOptimisticImageGeneration) { + const imageParamSnapshot = imageParamsRef.current || imageParams; + setStreamImageGenerationStateForChat(activeChatId, { + phase: "processing", + providerType: requestModel?.providerType, + modelName: getAgentSafeModelName(requestModel?.displayName), + imageCount: + Math.max(1, Math.round(Number(imageParamSnapshot?.imageCount || 1))) || 1, }); - inFlightChatIdRef.current = activeChatId; - inFlightRawChatIdRef.current = rawChatId; - clearStreamImageGenerationStateForChat(activeChatId); - clearAgentProgressStateForChat(activeChatId); - if (shouldShowOptimisticImageGeneration) { - const imageParamSnapshot = imageParamsRef.current || imageParams; - setStreamImageGenerationStateForChat(activeChatId, { - phase: "processing", - providerType: requestModel?.providerType, - modelName: getAgentSafeModelName(requestModel?.displayName), - imageCount: - Math.max(1, Math.round(Number(imageParamSnapshot?.imageCount || 1))) || 1, - }); - } + } // 使用与 multimodal-input 相同的格式发送消息 try { @@ -7793,6 +7912,7 @@ export function CanvasChatPanel({ selectedModelId, sendMessage, clearSelection, + clearLatestGeneratedImageHighlights, buildAutoContext, buildContextPayload, allModels, diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index 4a1122553..57a3cdcc1 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -553,6 +553,30 @@ export function CanvasWorkspace({ ); const clearLastAddedNodeId = useCanvasStore((state) => state.clearLastAddedNodeId); const centerTimeoutRef = useRef(null); + + const clearLatestGeneratedImageHighlights = useCallback(() => { + const currentNodes = useCanvasStore.getState().nodes; + const clearedAt = Date.now(); + + currentNodes.forEach((node) => { + if (node.type !== "image") return; + const data = node.data as Record; + const metadata = (data?.metadata || {}) as Record; + if (metadata.latestGeneratedHighlight !== true) return; + + updateNode(node.id, { + data: { + ...data, + metadata: { + ...metadata, + latestGeneratedHighlight: false, + latestGeneratedClearedAt: clearedAt, + }, + } as any, + }); + }); + }, [updateNode]); + const isMacPlatform = React.useMemo(() => { if (typeof navigator === "undefined") return false; return /Mac|iPhone|iPad|iPod/i.test(navigator.platform); @@ -746,6 +770,7 @@ export function CanvasWorkspace({ y: event.clientY - rect.top, }; + clearLatestGeneratedImageHighlights(); onBlankCanvasDoubleClick({ screenPosition: { x: event.clientX, @@ -766,6 +791,7 @@ export function CanvasWorkspace({ viewport.x, viewport.y, viewport.zoom, + clearLatestGeneratedImageHighlights, ] ); @@ -925,6 +951,7 @@ export function CanvasWorkspace({ suppressCanvasClickRef.current = false; return; } + clearLatestGeneratedImageHighlights(); setSelectedEdgeId(null); if (!isPointOnNodeElement(e.target) && !isMarqueeSelecting) { const now = Date.now(); @@ -935,7 +962,7 @@ export function CanvasWorkspace({ fitAllImageNodesIntoViewport(); } } - }, [clearSelection, fitAllImageNodesIntoViewport, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]); + }, [clearLatestGeneratedImageHighlights, clearSelection, fitAllImageNodesIntoViewport, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]); // 处理画布平移开始 const handleMouseDown = useCallback((e: React.MouseEvent) => { @@ -944,6 +971,7 @@ export function CanvasWorkspace({ if (connectionDraft) { return; } + clearLatestGeneratedImageHighlights(); const shouldPanBlankWorkflowCanvas = isWorkflowCanvas && tool === "select" && @@ -983,7 +1011,7 @@ export function CanvasWorkspace({ setMarqueeStart(start); setMarqueeEnd(start); } - }, [cancelPanPreview, clearBrowserTextSelection, connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, isWorkflowCanvas, toCanvasPosition, tool]); + }, [cancelPanPreview, clearBrowserTextSelection, clearLatestGeneratedImageHighlights, connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, isWorkflowCanvas, toCanvasPosition, tool]); // 处理画布平移 const handleMouseMove = useCallback((e: React.MouseEvent) => { @@ -1152,6 +1180,7 @@ export function CanvasWorkspace({ const handleWheel = useCallback((e: React.WheelEvent) => { if (hasBlockingDialogOpen()) return; if (!isEventFromCanvasContainer(e.target)) return; + clearLatestGeneratedImageHighlights(); if (e.cancelable) { e.preventDefault(); } @@ -1180,7 +1209,7 @@ export function CanvasWorkspace({ const newX = mouseX - (mouseX - currentViewport.x) * scaleChange; const newY = mouseY - (mouseY - currentViewport.y) * scaleChange; scheduleViewport({ ...currentViewport, x: newX, y: newY, zoom: newZoom }); - }, [hasBlockingDialogOpen, isEventFromCanvasContainer, isMacPlatform, scheduleViewport]); + }, [clearLatestGeneratedImageHighlights, hasBlockingDialogOpen, isEventFromCanvasContainer, isMacPlatform, scheduleViewport]); const getImageDimensionsFromFile = useCallback((file: File): Promise<{ width: number; @@ -1221,6 +1250,7 @@ export function CanvasWorkspace({ const processDroppedFiles = useCallback(async (files: File[], dropClientX: number, dropClientY: number) => { if (files.length === 0) return; + clearLatestGeneratedImageHighlights(); const rect = containerRef.current?.getBoundingClientRect(); const baseX = rect ? (dropClientX - rect.left - viewport.x) / viewport.zoom : 100; @@ -1312,7 +1342,7 @@ export function CanvasWorkspace({ toast.error("图片上传失败"); }); } - }, [addNode, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]); + }, [addNode, clearLatestGeneratedImageHighlights, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]); React.useEffect(() => { const handleWindowDrop = async (e: DragEvent) => { @@ -1574,6 +1604,7 @@ export function CanvasWorkspace({ }} scale={1} onDragStart={() => { + clearLatestGeneratedImageHighlights(); setSnapGuides(null); if (!isSelected) { selectNode(node.id); @@ -1682,6 +1713,9 @@ export function CanvasWorkspace({ groupDragRef.current = null; pushHistory(); }} + onResizeStart={() => { + clearLatestGeneratedImageHighlights(); + }} onResizeStop={(e, direction, ref, delta, position) => { const newWidth = parseInt(ref.style.width) / viewport.zoom; const newHeight = parseInt(ref.style.height) / viewport.zoom; @@ -1705,6 +1739,7 @@ export function CanvasWorkspace({ style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部 onClick={(e: React.MouseEvent) => { e.stopPropagation(); + clearLatestGeneratedImageHighlights(); setSelectedEdgeId(null); selectNode(node.id, e.shiftKey); }} diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 0a258be1d..8193a7e4a 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -1412,10 +1412,16 @@ export function ImageNode({ Number.isFinite(data.metadata.recentlyGeneratedAt) ? data.metadata.recentlyGeneratedAt : 0; - const isRecentlyGenerated = + const hasPersistentGeneratedHighlight = + data.metadata?.latestGeneratedHighlight === true; + const hasTransientGeneratedHighlight = + data.metadata?.latestGeneratedHighlight !== false && Boolean(data.src) && recentlyGeneratedAt > 0 && Date.now() - recentlyGeneratedAt < 20_000; + const isRecentlyGenerated = + Boolean(data.src) && + (hasPersistentGeneratedHighlight || hasTransientGeneratedHighlight); const flipRotateOriginalSrc = data.metadata && typeof data.metadata === "object" ? String((data.metadata as Record).flipRotateOriginalSrc || "").trim() @@ -7823,10 +7829,13 @@ export function ImageNode({ ref={containerRef} > {isRecentlyGenerated ? ( -
+
+
+
+
) : null}
{/* 图片内容 - 使用 object-contain 保持原始比例 */}