fix: filter handled canvas media batches

This commit is contained in:
134 2026-06-23 19:17:13 +08:00
parent cb44b2237f
commit 6d5604a84b
3 changed files with 155 additions and 20 deletions

View File

@ -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) {

View File

@ -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<string>;
}): 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

View File

@ -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 = {