From 6d5604a84b7412e21a57a19b51429cdc8517cc6b Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 23 Jun 2026 19:17:13 +0800 Subject: [PATCH] fix: filter handled canvas media batches --- components/canvas/CanvasChatPanel.tsx | 49 ++++++----- .../canvas/chat-panel/canvas-agent-stream.ts | 43 ++++++++++ tests/routes/canvas-chat-helpers.test.ts | 83 +++++++++++++++++++ 3 files changed, 155 insertions(+), 20 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 398226bf9..101bd590e 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -133,6 +133,7 @@ import { type CanvasOperationStreamPayload, type CanvasViewOperationStreamPayload, type CanvasMediaStreamPayload, + filterCanvasMediaPayloadForUnhandledItems, getAgentProgressFallbackTitle, getAgentTargetLockTitle, getAgentTargetResolutionLabel, @@ -4461,36 +4462,44 @@ export function CanvasChatPanel({ if (dataPart.type === "data-canvasMedia") { try { - const payload: CanvasMediaStreamPayload = - typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data; - if (!payload || typeof payload !== "object") return; - const handledIds = getCanvasMediaPayloadHandledItemIds(payload); - handledIds.forEach((id) => handledCanvasMediaItemIdsRef.current.add(id)); - if ( - shouldSkipCanvasMediaPayloadForHandledItems({ - payload, - handledCanvasMediaItemIds: handledCanvasMediaItemIdsRef.current, - }) - ) { - clearStreamImageGenerationStateForChat(eventChatId); - return; - } - const imageUrls = Array.isArray(payload.imageUrls) - ? payload.imageUrls.filter( + const payload: CanvasMediaStreamPayload = + typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data; + if (!payload || typeof payload !== "object") return; + const handledIds = getCanvasMediaPayloadHandledItemIds(payload); + handledIds.forEach((id) => handledCanvasMediaItemIdsRef.current.add(id)); + const unhandledPayload = filterCanvasMediaPayloadForUnhandledItems({ + payload, + handledCanvasMediaItemIds: handledCanvasMediaItemIdsRef.current, + }); + if ( + shouldSkipCanvasMediaPayloadForHandledItems({ + payload: unhandledPayload, + handledCanvasMediaItemIds: handledCanvasMediaItemIdsRef.current, + }) + ) { + clearStreamImageGenerationStateForChat(eventChatId); + return; + } + const imageUrls = Array.isArray(unhandledPayload.imageUrls) + ? unhandledPayload.imageUrls.filter( (url): url is string => typeof url === "string" && /^https?:\/\//i.test(url) ) : []; - const videoUrls = Array.isArray(payload.videoUrls) - ? payload.videoUrls.filter( + const videoUrls = Array.isArray(unhandledPayload.videoUrls) + ? unhandledPayload.videoUrls.filter( (url): url is string => typeof url === "string" && /^https?:\/\//i.test(url) ) : []; + if (imageUrls.length === 0 && videoUrls.length === 0) { + clearStreamImageGenerationStateForChat(eventChatId); + return; + } if (isEventForActiveChat && (imageUrls.length > 0 || videoUrls.length > 0)) { const allUrls = [...imageUrls, ...videoUrls]; const fallbackMessageId = - String(payload.messageId || "").trim() || + String(unhandledPayload.messageId || "").trim() || buildCanvasMediaFallbackMessageId(eventChatId, allUrls); const mediaMarkdown = buildCanvasMediaMarkdown({ imageUrls, videoUrls }); const mediaUrlKeys = new Set( @@ -4546,7 +4555,7 @@ export function CanvasChatPanel({ ]); }); } - void appendStreamMediaToCanvas(payload).finally(() => { + void appendStreamMediaToCanvas(unhandledPayload).finally(() => { clearStreamImageGenerationStateForChat(eventChatId); }); } catch (e) { diff --git a/components/canvas/chat-panel/canvas-agent-stream.ts b/components/canvas/chat-panel/canvas-agent-stream.ts index 0caf7e19c..3e2480894 100644 --- a/components/canvas/chat-panel/canvas-agent-stream.ts +++ b/components/canvas/chat-panel/canvas-agent-stream.ts @@ -80,6 +80,49 @@ export function shouldSkipCanvasMediaPayloadForHandledItems(params: { ); } +function normalizeCanvasStreamMediaUrls(value: unknown) { + return Array.isArray(value) + ? value + .map((url) => String(url || "").trim()) + .filter((url) => /^https?:\/\//i.test(url)) + : []; +} + +export function filterCanvasMediaPayloadForUnhandledItems(params: { + payload: CanvasMediaStreamPayload; + handledCanvasMediaItemIds: ReadonlySet; +}): CanvasMediaStreamPayload { + const mediaItemIds = normalizeCanvasStreamIdList(params.payload.mediaItemIds); + if (mediaItemIds.length === 0 || params.handledCanvasMediaItemIds.size === 0) { + return params.payload; + } + + const imageUrls = normalizeCanvasStreamMediaUrls(params.payload.imageUrls); + const videoUrls = normalizeCanvasStreamMediaUrls(params.payload.videoUrls); + const mediaItems = [ + ...imageUrls.map((url) => ({ type: "image" as const, url })), + ...videoUrls.map((url) => ({ type: "video" as const, url })), + ]; + if (mediaItems.length === 0 || mediaItems.length !== mediaItemIds.length) { + return params.payload; + } + + const unhandledItems = mediaItems + .map((item, index) => ({ ...item, mediaItemId: mediaItemIds[index]! })) + .filter((item) => !params.handledCanvasMediaItemIds.has(item.mediaItemId)); + + return { + ...params.payload, + imageUrls: unhandledItems + .filter((item) => item.type === "image") + .map((item) => item.url), + videoUrls: unhandledItems + .filter((item) => item.type === "video") + .map((item) => item.url), + mediaItemIds: unhandledItems.map((item) => item.mediaItemId), + }; +} + export function shouldRetryCanvasViewOperationAfterRefresh( payload: CanvasViewOperationStreamPayload, currentProjectId?: string diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 30bf1ca18..817e41095 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -7,6 +7,7 @@ import { stripMediaMarkup, } from "@/components/canvas/chat-panel/canvas-chat-media"; import { + filterCanvasMediaPayloadForUnhandledItems, getCanvasMediaPayloadHandledItemIds, getCanvasOperationHandledMediaItemIds, isCanvasAgentViewOperationClientEnabled, @@ -1885,6 +1886,19 @@ test.describe("canvas chat pure helpers", () => { handledCanvasMediaItemIds: handled, }) ).toBe(false); + expect( + filterCanvasMediaPayloadForUnhandledItems({ + payload: { + mediaItemIds: ["media-2", "media-3"], + imageUrls: ["https://cdn.example.com/b.png", "https://cdn.example.com/c.png"], + }, + handledCanvasMediaItemIds: handled, + }) + ).toMatchObject({ + mediaItemIds: ["media-3"], + imageUrls: ["https://cdn.example.com/c.png"], + videoUrls: [], + }); const shadowHandledIds = getCanvasOperationHandledMediaItemIds({ status: "shadowed", @@ -1924,6 +1938,75 @@ test.describe("canvas chat pure helpers", () => { ).toEqual(["media-4", "media-5"]); }); + test("filters handled items out of partially applied canvasMedia batches", () => { + const handled = new Set(["media-2"]); + + expect( + filterCanvasMediaPayloadForUnhandledItems({ + payload: { + mediaItemIds: ["media-1", "media-2", "media-3"], + imageUrls: [ + "https://cdn.example.com/a.png", + "https://cdn.example.com/b.png", + "https://cdn.example.com/c.png", + ], + prompt: "keep prompt", + }, + handledCanvasMediaItemIds: handled, + }) + ).toMatchObject({ + mediaItemIds: ["media-1", "media-3"], + imageUrls: [ + "https://cdn.example.com/a.png", + "https://cdn.example.com/c.png", + ], + videoUrls: [], + prompt: "keep prompt", + }); + + expect( + filterCanvasMediaPayloadForUnhandledItems({ + payload: { + mediaItemIds: ["media-2"], + imageUrls: ["https://cdn.example.com/b.png"], + }, + handledCanvasMediaItemIds: handled, + }) + ).toMatchObject({ + mediaItemIds: [], + imageUrls: [], + videoUrls: [], + }); + + expect( + filterCanvasMediaPayloadForUnhandledItems({ + payload: { + mediaItemIds: ["media-image", "media-video"], + imageUrls: ["https://cdn.example.com/image.png"], + videoUrls: ["https://cdn.example.com/video.mp4"], + }, + handledCanvasMediaItemIds: new Set(["media-image"]), + }) + ).toMatchObject({ + mediaItemIds: ["media-video"], + imageUrls: [], + videoUrls: ["https://cdn.example.com/video.mp4"], + }); + + expect( + filterCanvasMediaPayloadForUnhandledItems({ + payload: { + mediaItemIds: ["media-1", "media-2"], + imageUrls: ["https://cdn.example.com/a.png"], + }, + handledCanvasMediaItemIds: handled, + }) + ).toMatchObject({ + mediaItemIds: ["media-1", "media-2"], + imageUrls: ["https://cdn.example.com/a.png"], + }); + }); + test("writes applied, failed, and shadowed canvas operation stream chunks", () => { const chunks: Array<{ type: string; data: string }> = []; const writer = {