fix(canvas): deduplicate archived image result replays

This commit is contained in:
134 2026-10-19 17:04:19 +08:00
parent 3b9dbd93b1
commit b9d3763730
3 changed files with 277 additions and 47 deletions

View File

@ -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,

View File

@ -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<string>();
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<ArchivedImageUrlMap>;
}): Promise<ArchivedImageUrlMap> {
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<T>(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();
}

View File

@ -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<typeof image>[] = [];
let release!: () => void;
const loading = new Promise<void>((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);
});