fix(canvas): deduplicate archived image result replays
This commit is contained in:
parent
3b9dbd93b1
commit
b9d3763730
@ -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,6 +7738,11 @@ export function CanvasChatPanel({
|
||||
latestGeneratedHighlight: true,
|
||||
},
|
||||
};
|
||||
const nodeId = commitCanvasImageResultIfMissing({
|
||||
getNodes: () => useCanvasStore.getState().nodes,
|
||||
url,
|
||||
aliases: imageAliases,
|
||||
commit: () => {
|
||||
const nodeId = placeholderNode
|
||||
? placeholderNode.id
|
||||
: addNode({
|
||||
@ -7728,6 +7760,10 @@ export function CanvasChatPanel({
|
||||
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,6 +9412,11 @@ export function CanvasChatPanel({
|
||||
: anchorNode?.id
|
||||
? [anchorNode.id]
|
||||
: [];
|
||||
const nodeId = commitCanvasImageResultIfMissing({
|
||||
getNodes: () => useCanvasStore.getState().nodes,
|
||||
url,
|
||||
aliases: imageAliases,
|
||||
commit: () => {
|
||||
const nodeId = placeholderNode
|
||||
? placeholderNode.id
|
||||
: addNode({
|
||||
@ -9381,6 +9434,10 @@ export function CanvasChatPanel({
|
||||
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,
|
||||
|
||||
70
components/canvas/chat-panel/canvas-image-result-sync.ts
Normal file
70
components/canvas/chat-panel/canvas-image-result-sync.ts
Normal 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();
|
||||
}
|
||||
101
tests/integration/canvas-image-result-sync.test.ts
Normal file
101
tests/integration/canvas-image-result-sync.test.ts
Normal 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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user