diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index a60e97d74..518fde207 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -311,6 +311,11 @@ import { stripCanvasModelRoutingLines, stripMediaMarkup, } from "@/components/canvas/chat-panel/canvas-chat-media"; +import { + commitCanvasImageResultIfMissing, + getCanvasImageResultUrlKeys, + resolveCanvasImageResultAliases, +} from "@/components/canvas/chat-panel/canvas-image-result-sync"; import { clearClawdRequestState, startClawdRequestState, @@ -7552,6 +7557,18 @@ export function CanvasChatPanel({ }; }, [activeChatId, clearStreamImageGenerationStateForChat, deleteNode]); + const resolveImageArchiveAliases = useCallback(async (urls: string[]) => { + const response = await fetch(`/api/canvas/${encodeURIComponent(canvasId)}/image-archives`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ urls }), + signal: AbortSignal.timeout(12_000), + }); + if (!response.ok) throw new Error("Failed to resolve canvas image result aliases"); + const result = await response.json(); + return result.replacements || {}; + }, [canvasId]); + const appendStreamMediaToCanvas = useCallback( async (payload: CanvasMediaStreamPayload) => { const imageUrls = Array.isArray(payload.imageUrls) @@ -7564,6 +7581,20 @@ export function CanvasChatPanel({ return; } + let imageAliases; + try { + imageAliases = await resolveCanvasImageResultAliases({ + nodes: useCanvasStore.getState().nodes, + messageId: payload.messageId, + imageUrls, + resolveAliases: resolveImageArchiveAliases, + }); + } catch (error) { + // Preserve the existing result until aliases can be checked again. + console.warn("[CanvasChat] Deferred image replay during archive lookup:", error); + return; + } + if (useCanvasStore.getState().projectId !== canvasId || activeChatIdRef.current !== activeChatId) return; const latestNodes = useCanvasStore.getState().nodes; const placeholderNodes = latestNodes.filter((node) => { if (node.type !== "image") return false; @@ -7578,12 +7609,7 @@ export function CanvasChatPanel({ .map((item) => String(item || "").trim()) .filter(Boolean) ); - const existingImageUrlKeys = new Set( - latestNodes - .filter((node) => node.type === "image" && (node.data as any)?.src) - .map((node) => normalizeImageIdentityUrl(String((node.data as any)?.src || ""))) - .filter(Boolean) - ); + const existingImageUrlKeys = getCanvasImageResultUrlKeys(latestNodes, imageAliases); const existingMessageImageKeys = new Set( latestNodes .filter((node) => node.type === "image") @@ -7645,6 +7671,7 @@ export function CanvasChatPanel({ } const dimensions = await getImageDimensions(url); + if (useCanvasStore.getState().projectId !== canvasId || activeChatIdRef.current !== activeChatId) return; const compareSourceUrlRaw = sourceImageUrls[Math.min(index, Math.max(0, sourceImageUrls.length - 1))] || sourceImageUrls[0] || @@ -7711,23 +7738,32 @@ export function CanvasChatPanel({ latestGeneratedHighlight: true, }, }; - const nodeId = placeholderNode - ? placeholderNode.id - : addNode({ - type: "image", - position, - size, - data, - connections: sourceNodeId ? [sourceNodeId] : anchorNode?.id ? [anchorNode.id] : [], - }); - if (placeholderNode) { - updateNode(nodeId, { - position, - size, - data, - connections: sourceNodeId ? [sourceNodeId] : anchorNode?.id ? [anchorNode.id] : [], - }); - } + const nodeId = commitCanvasImageResultIfMissing({ + getNodes: () => useCanvasStore.getState().nodes, + url, + aliases: imageAliases, + commit: () => { + const nodeId = placeholderNode + ? placeholderNode.id + : addNode({ + type: "image", + position, + size, + data, + connections: sourceNodeId ? [sourceNodeId] : anchorNode?.id ? [anchorNode.id] : [], + }); + if (placeholderNode) { + updateNode(nodeId, { + position, + size, + data, + connections: sourceNodeId ? [sourceNodeId] : anchorNode?.id ? [anchorNode.id] : [], + }); + } + return nodeId; + }, + }); + if (!nodeId) continue; insertedNodeIds.push(nodeId); void autoNameGeneratedImageNode(nodeId, url); addedImageCount += 1; @@ -7784,6 +7820,8 @@ export function CanvasChatPanel({ }, [ activeChatId, + canvasId, + resolveImageArchiveAliases, addNode, autoNameGeneratedImageNode, deleteNode, @@ -9098,12 +9136,7 @@ export function CanvasChatPanel({ .map((item) => String(item || "").trim()) .filter(Boolean) ); - const existingImageUrlKeys = new Set( - nodes - .filter((n) => n.type === "image" && (n.data as any)?.src) - .map((n) => normalizeImageIdentityUrl((n.data as any).src)) - .filter(Boolean) - ); + const existingImageUrlKeys = getCanvasImageResultUrlKeys(useCanvasStore.getState().nodes); const existingMessageImageKeys = new Set( nodes .filter((n) => n.type === "image") @@ -9210,7 +9243,21 @@ export function CanvasChatPanel({ const videoUrls = Array.from(videoUrlSet); if (imageUrls.length === 0 && videoUrls.length === 0) continue; - processedMessageIds.current.add(message.id); + let imageAliases; + try { + imageAliases = await resolveCanvasImageResultAliases({ + nodes: useCanvasStore.getState().nodes, + messageId: message.id, + imageUrls, + resolveAliases: resolveImageArchiveAliases, + }); + } catch (error) { + console.warn("[CanvasChat] Deferred history image replay during archive lookup:", error); + return; + } + if (cancelled) return; + getCanvasImageResultUrlKeys(useCanvasStore.getState().nodes, imageAliases) + .forEach((key) => existingImageUrlKeys.add(key)); const generationPrompt = findNearestUserPrompt(messages, messageIndex); let addedImageCount = 0; @@ -9284,6 +9331,7 @@ export function CanvasChatPanel({ } const dimensions = await getImageDimensions(url); + if (cancelled) return; const compareSourceUrlRaw = nearestUserImageUrls[Math.min(index, Math.max(0, nearestUserImageUrls.length - 1))] || nearestUserImageUrls[0] || @@ -9364,23 +9412,32 @@ export function CanvasChatPanel({ : anchorNode?.id ? [anchorNode.id] : []; - const nodeId = placeholderNode - ? placeholderNode.id - : addNode({ - type: "image", - position, - size, - data, - connections, - }); - if (placeholderNode) { - updateNode(nodeId, { - position, - size, - data, - connections, - }); - } + const nodeId = commitCanvasImageResultIfMissing({ + getNodes: () => useCanvasStore.getState().nodes, + url, + aliases: imageAliases, + commit: () => { + const nodeId = placeholderNode + ? placeholderNode.id + : addNode({ + type: "image", + position, + size, + data, + connections, + }); + if (placeholderNode) { + updateNode(nodeId, { + position, + size, + data, + connections, + }); + } + return nodeId; + }, + }); + if (!nodeId) continue; insertedNodeIds.push(nodeId); void autoNameGeneratedImageNode(nodeId, url); addedImageCount += 1; @@ -9425,6 +9482,7 @@ export function CanvasChatPanel({ addedVideoCount += 1; } + processedMessageIds.current.add(message.id); } }; @@ -9435,6 +9493,7 @@ export function CanvasChatPanel({ }, [ activeChatId, activeChatSessionMode, + resolveImageArchiveAliases, messages, addNode, nodes, diff --git a/components/canvas/chat-panel/canvas-image-result-sync.ts b/components/canvas/chat-panel/canvas-image-result-sync.ts new file mode 100644 index 000000000..f1cda366a --- /dev/null +++ b/components/canvas/chat-panel/canvas-image-result-sync.ts @@ -0,0 +1,70 @@ +import { normalizeImageIdentityUrl } from "./canvas-chat-media"; +import type { ArchivedImageUrlMap } from "@/lib/storage/image-archive-urls"; + +type ImageResultNode = { + type: string; + data?: unknown; +}; + +function imageUrls(node: ImageResultNode): string[] { + if (node.type !== "image") return []; + const data = node.data as { src?: string; metadata?: { sourceImageKey?: string } } | undefined; + return [data?.src, data?.metadata?.sourceImageKey].filter( + (url): url is string => typeof url === "string" && Boolean(url.trim()), + ); +} + +export function getCanvasImageResultUrlKeys( + nodes: readonly ImageResultNode[], + aliases: ArchivedImageUrlMap = {}, +) { + const keys = new Set(); + for (const node of nodes) { + for (const url of imageUrls(node)) { + keys.add(normalizeImageIdentityUrl(url)); + if (aliases[url]) keys.add(normalizeImageIdentityUrl(aliases[url])); + } + } + keys.delete(""); + return keys; +} + +// A history refresh may already contain bucket URLs while this tab still shows +// provider previews. Resolve aliases only for an existing result of this message; +// a new generation never waits for storage before appearing on the canvas. +export async function resolveCanvasImageResultAliases(params: { + nodes: readonly ImageResultNode[]; + messageId?: string; + imageUrls: string[]; + resolveAliases: (urls: string[]) => Promise; +}): Promise { + const keys = getCanvasImageResultUrlKeys(params.nodes); + if (!params.messageId || params.imageUrls.every((url) => keys.has(normalizeImageIdentityUrl(url)))) { + return {}; + } + const urls = [...new Set(params.nodes.flatMap((node) => { + const data = node.data as { metadata?: { sourceMessageId?: string } } | undefined; + return data?.metadata?.sourceMessageId === params.messageId ? imageUrls(node) : []; + }))].filter((url) => /^https?:\/\//i.test(url)); + if (!urls.length) return {}; + const aliases: ArchivedImageUrlMap = {}; + for (let index = 0; index < urls.length; index += 100) { + Object.assign(aliases, await params.resolveAliases(urls.slice(index, index + 100))); + } + return aliases; +} + +// Call synchronously immediately before changing the store, after all image +// loading/network awaits. Both stream and history writers share this check. +export function commitCanvasImageResultIfMissing(params: { + getNodes: () => readonly ImageResultNode[]; + url: string; + aliases: ArchivedImageUrlMap; + commit: () => T; +}): T | undefined { + const key = normalizeImageIdentityUrl(params.url); + if (!key || getCanvasImageResultUrlKeys(params.getNodes(), params.aliases).has(key)) { + return undefined; + } + return params.commit(); +} diff --git a/tests/integration/canvas-image-result-sync.test.ts b/tests/integration/canvas-image-result-sync.test.ts new file mode 100644 index 000000000..8e43976e1 --- /dev/null +++ b/tests/integration/canvas-image-result-sync.test.ts @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + commitCanvasImageResultIfMissing, + getCanvasImageResultUrlKeys, + resolveCanvasImageResultAliases, +} from "@/components/canvas/chat-panel/canvas-image-result-sync"; + +const preview = "https://provider.example/result.png"; +const archived = "https://bucket.example/archived.png"; +const other = "https://bucket.example/second.png"; +const image = (src: string, sourceImageKey = src, sourceMessageId = "message-1") => ({ + type: "image", + data: { src, metadata: { sourceImageKey, sourceMessageId } }, +}); + +test("archived history replay recognizes the provider preview already on canvas", async () => { + const nodes = [image(preview)]; + const aliases = await resolveCanvasImageResultAliases({ + nodes, messageId: "message-1", imageUrls: [archived], + resolveAliases: async (urls) => { + assert.deepEqual(urls, [preview]); + return { [preview]: archived }; + }, + }); + let writes = 0; + const result = commitCanvasImageResultIfMissing({ getNodes: () => nodes, url: archived, aliases, commit: () => ++writes }); + assert.equal(result, undefined); + assert.equal(writes, 0); + assert.equal(nodes.length, 1); +}); + +test("current src and previous source identity both prevent replay", () => { + const keys = getCanvasImageResultUrlKeys([image(archived, preview)]); + assert(keys.has(preview)); + assert(keys.has(archived)); +}); + +test("simultaneous stream and history image loads commit a single node", async () => { + const nodes: ReturnType[] = []; + let release!: () => void; + const loading = new Promise((resolve) => { release = resolve; }); + const insert = async () => { + // Both paths have already observed an empty canvas before image loading. + assert.equal(nodes.length, 0); + await loading; + return commitCanvasImageResultIfMissing({ + getNodes: () => nodes, url: preview, aliases: {}, + commit: () => { nodes.push(image(preview)); return "inserted"; }, + }); + }; + const stream = insert(); + const history = insert(); + release(); + assert.deepEqual(await Promise.all([stream, history]), ["inserted", undefined]); + assert.equal(nodes.length, 1); +}); + +test("multiple distinct results in one message remain distinct", async () => { + const nodes = [image(preview)]; + const aliases = await resolveCanvasImageResultAliases({ + nodes, messageId: "message-1", imageUrls: [archived, other], + resolveAliases: async () => ({ [preview]: archived }), + }); + for (const url of [archived, other]) { + commitCanvasImageResultIfMissing({ getNodes: () => nodes, url, aliases, commit: () => nodes.push(image(url)) }); + } + assert.equal(nodes.length, 2); + assert.equal(nodes[1].data.src, other); +}); + +test("new generations display immediately without an archive lookup", async () => { + const aliases = await resolveCanvasImageResultAliases({ + nodes: [image(preview, preview, "previous-message")], messageId: "message-2", imageUrls: [other], + resolveAliases: async () => { throw new Error("Must not wait for archiving"); }, + }); + assert.deepEqual(aliases, {}); +}); + +test("an archive lookup failure never treats the URL change as a new result", async () => { + await assert.rejects(resolveCanvasImageResultAliases({ + nodes: [image(preview)], messageId: "message-1", imageUrls: [archived], + resolveAliases: async () => { throw new Error("Temporary network failure"); }, + }), /Temporary network failure/); +}); + +test("same-message image batches respect the archive endpoint limit", async () => { + const nodes = Array.from({ length: 205 }, (_, i) => image(`https://provider.example/${i}.png`)); + const batches: number[] = []; + await resolveCanvasImageResultAliases({ + nodes, messageId: "message-1", imageUrls: [archived], + resolveAliases: async (urls) => { batches.push(urls.length); return {}; }, + }); + assert.deepEqual(batches, [100, 100, 5]); +}); + +test("synchronization leaves existing manual copies untouched", () => { + const nodes = [image(archived, preview), image(archived, preview)]; + commitCanvasImageResultIfMissing({ getNodes: () => nodes, url: archived, aliases: {}, commit: () => { throw new Error("Unexpected insertion"); } }); + assert.equal(nodes.length, 2); +});