fix: keep latest canvas image highlighted

This commit is contained in:
134 2026-06-22 14:46:25 +08:00
parent b032774e8e
commit f7d18f20db
3 changed files with 237 additions and 73 deletions

View File

@ -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<string, any>;
const metadata = (data?.metadata || {}) as Record<string, any>;
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<string, unknown>;
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<string, unknown>;
return (
metadata.canvasGenerationPlaceholder === true &&
(!metadata.chatId || String(metadata.chatId) === activeChatId)
);
});
const newImageEntries = imageUrls.reduce<Array<{ url: string; sourceImageKey: string }>>(
(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,

View File

@ -553,6 +553,30 @@ export function CanvasWorkspace({
);
const clearLastAddedNodeId = useCanvasStore((state) => state.clearLastAddedNodeId);
const centerTimeoutRef = useRef<number | null>(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<string, any>;
const metadata = (data?.metadata || {}) as Record<string, any>;
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);
}}

View File

@ -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<string, unknown>).flipRotateOriginalSrc || "").trim()
@ -7823,10 +7829,13 @@ export function ImageNode({
ref={containerRef}
>
{isRecentlyGenerated ? (
<div
className="pointer-events-none absolute -inset-2 z-[3] rounded-[inherit] border border-[#8fb0ff]/55"
style={{ animation: "canvas-generated-pulse 1.35s ease-out 0s 4" }}
/>
<div className="pointer-events-none absolute inset-0 z-[3] rounded-[inherit]">
<div
className="absolute inset-0 rounded-[inherit] border border-[#8fb0ff]/75"
style={{ animation: "canvas-generated-pulse 1.35s ease-out infinite" }}
/>
<div className="absolute inset-1 rounded-[inherit] border border-white/70 shadow-[0_0_22px_rgba(124,156,255,0.34)] dark:border-white/35" />
</div>
) : null}
<div className="relative h-full w-full" style={scaledContentStyle}>
{/* 图片内容 - 使用 object-contain 保持原始比例 */}