From ee5b5d146dc544ab67d4dab39e6e65cee7678ed0 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 5 Aug 2026 19:17:49 +0800 Subject: [PATCH] fix(canvas): dedupe Claw table images --- components/canvas/CanvasChatPanel.tsx | 6 +- .../canvas/chat-panel/canvas-chat-media.ts | 74 +++++++++++++++++++ tests/routes/canvas-chat-helpers.test.ts | 25 +++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index e9ae58b38..dd3a53b8c 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -281,6 +281,7 @@ import { extractCanvasModelName, extractImageUrls, extractVideoUrls, + filterStandaloneAssistantImageUrls, formatMessageDateLabel, getAssistantImageNodeIdentityKey, getCanvasAssistantMessagesAfterTargetUser, @@ -14925,9 +14926,12 @@ export function CanvasChatPanel({ const shouldDeferAssistantMedia = (isClawAssistantMessage || shouldTypeAgentAssistantMessage) && (shouldAnimateAnalysis || shouldAnimatePlainText); + const standaloneImageUrls = isClawAssistantMessage + ? filterStandaloneAssistantImageUrls(rawText, imageUrls) + : imageUrls; const visibleImageUrls = shouldDeferAssistantMedia ? [] - : imageUrls; + : standaloneImageUrls; const visibleVideoUrls = shouldDeferAssistantMedia ? [] : videoUrls; diff --git a/components/canvas/chat-panel/canvas-chat-media.ts b/components/canvas/chat-panel/canvas-chat-media.ts index a182e9783..3c935d4af 100644 --- a/components/canvas/chat-panel/canvas-chat-media.ts +++ b/components/canvas/chat-panel/canvas-chat-media.ts @@ -245,6 +245,80 @@ export function extractAssistantImageUrls( }); } +function isMarkdownTableRow(line: string): boolean { + const trimmed = String(line || "").trim(); + if (!trimmed || !trimmed.includes("|")) return false; + + const cells = trimmed + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|"); + return cells.length >= 2; +} + +function isMarkdownTableSeparator(line: string): boolean { + if (!isMarkdownTableRow(line)) return false; + + const cells = String(line || "") + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((cell) => cell.trim().replace(/\s+/g, "")); + return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell)); +} + +export function getMarkdownTableImageUrlKeys(text: string): Set { + const urlKeys = new Set(); + const lines = String(text || "").replace(/\r\n/g, "\n").split("\n"); + let inCodeFence = false; + + const addUrl = (url: string) => { + const key = normalizeImageIdentityUrl(url); + if (key) urlKeys.add(key); + }; + + for (let index = 0; index < lines.length - 1; index += 1) { + const line = lines[index] || ""; + if (/^\s*```/.test(line)) { + inCodeFence = !inCodeFence; + continue; + } + if ( + inCodeFence || + !isMarkdownTableRow(line) || + !isMarkdownTableSeparator(lines[index + 1] || "") + ) { + continue; + } + + let endIndex = index + 2; + while (endIndex < lines.length && isMarkdownTableRow(lines[endIndex] || "")) { + endIndex += 1; + } + + const tableText = lines.slice(index, endIndex).join("\n"); + extractAssistantImageUrls(tableText).forEach(addUrl); + extractPlainImageUrls(tableText).forEach(addUrl); + index = endIndex - 1; + } + + return urlKeys; +} + +export function filterStandaloneAssistantImageUrls( + text: string, + imageUrls: string[] +): string[] { + const tableImageUrlKeys = getMarkdownTableImageUrlKeys(text); + if (tableImageUrlKeys.size === 0) return imageUrls; + + return imageUrls.filter((url) => { + const key = normalizeImageIdentityUrl(url); + return !key || !tableImageUrlKeys.has(key); + }); +} + export function extractVideoUrls(text: string): string[] { if (!text) return []; const urls = new Set(); diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index c4b94d227..e25dcdff5 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -3,6 +3,8 @@ import { collectMessageImageUrls, extractAssistantImageUrls, extractVideoUrls, + filterStandaloneAssistantImageUrls, + getMarkdownTableImageUrlKeys, normalizeMediaUrl, sortCanvasMessagesByCreatedAt, stripMediaMarkup, @@ -5175,6 +5177,29 @@ test.describe("canvas chat pure helpers", () => { expect(urls).toEqual(["https://cdn.example.com/result.png?token=secret"]); }); + test("does not append Claw images that already render inside markdown tables", () => { + const markdown = [ + "| 商品 | 主图 |", + "| --- | --- |", + "| A | ![主图](https://cdn.example.com/table-image.png?token=secret) |", + "| B | [查看](https://cdn.example.com/table-link.webp?token=secret) |", + "", + "补充图片:", + "![补充](https://cdn.example.com/standalone-image.jpg?token=secret)", + ].join("\n"); + const imageUrls = extractAssistantImageUrls(markdown); + + expect(getMarkdownTableImageUrlKeys(markdown)).toEqual( + new Set([ + "https://cdn.example.com/table-image.png", + "https://cdn.example.com/table-link.webp", + ]) + ); + expect(filterStandaloneAssistantImageUrls(markdown, imageUrls)).toEqual([ + "https://cdn.example.com/standalone-image.jpg?token=secret", + ]); + }); + test("extracts and strips video markup without removing surrounding text", () => { const text = [ "生成完成",