From 69705518d65cdcd7ef11a77a731d8b37c620f3cc Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 11 Aug 2026 21:09:38 +0800 Subject: [PATCH] fix(claw): restore spreadsheet attachment badges --- components/canvas/CanvasChatPanel.tsx | 150 +++++++++++++----- .../canvas/chat-panel/canvas-chat-session.ts | 4 + tests/routes/canvas-chat-helpers.test.ts | 22 ++- 3 files changed, 139 insertions(+), 37 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 12c8a647d..712ec6834 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -504,6 +504,21 @@ const CLAWD_IMAGE_ALLOWED_EXTENSIONS = new Set([ ]); const CLAWD_TABLE_MAX_FILES = 3; const CLAWD_TABLE_MAX_BYTES = 10 * 1024 * 1024; + +function isClawdSpreadsheetAttachment( + url: string, + contentType?: string | null, + name?: string | null +) { + const normalizedType = String(contentType || "").toLowerCase(); + return ( + normalizedType.includes("spreadsheet") || + normalizedType === "text/csv" || + normalizedType === "text/tab-separated-values" || + /\.(xlsx|csv|tsv)(?:$|[?#])/i.test(String(url || "")) || + /\.(xlsx|csv|tsv)$/i.test(String(name || "").trim()) + ); +} const CLAWD_PROCESSING_HINT = "已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。"; const CLAWD_SUBMITTED_COMPOSER_RECOVERY_TTL_MS = 30 * 60 * 1000; @@ -923,38 +938,43 @@ function parseClawdSpreadsheetAttachmentsFromText( .filter((part: any) => part?.type === "text" && part?.text) .map((part: any) => String(part.text)) .join("\n"); - const summaryMatch = text.match( - /(?:^|\n)已上传表格:\n((?:- [^\n]+(?:\n|$))+)/m - ); - if (!summaryMatch?.[1]) { - return []; - } - const attachments: ClawdSpreadsheetAttachment[] = []; - for (const line of summaryMatch[1].trim().split("\n")) { - const lineMatch = line.match(/^- (.+?)(?:((.+)))?$/); - const name = String(lineMatch?.[1] || "").trim(); - const details = String(lineMatch?.[2] || ""); - if (!name) continue; + const sectionPattern = + /(?:^|\n)(已上传表格|已上传文件):\n((?:- [^\n]+(?:\n|$))+)/gm; + for (const sectionMatch of text.matchAll(sectionPattern)) { + const sectionType = String(sectionMatch[1] || ""); + const sectionLines = String(sectionMatch[2] || ""); + for (const line of sectionLines.trim().split("\n")) { + const lineMatch = line.match(/^- (.+?)(?:((.+)))?$/); + const name = String(lineMatch?.[1] || "").trim(); + const details = String(lineMatch?.[2] || ""); + if ( + !name || + (sectionType === "已上传文件" && + !isClawdSpreadsheetAttachment("", "", name)) + ) { + continue; + } - const sizeMatch = details.match(/([\d.]+)\s*(KB|MB)/i); - const sheetCountMatch = details.match(/(\d+)\s*个工作表/); - const rowCountMatch = details.match(/(\d+)\s*行/); - const sizeValue = Number(sizeMatch?.[1]); - const sizeUnit = String(sizeMatch?.[2] || "").toUpperCase(); + const sizeMatch = details.match(/([\d.]+)\s*(KB|MB)/i); + const sheetCountMatch = details.match(/(\d+)\s*个工作表/); + const rowCountMatch = details.match(/(\d+)\s*行/); + const sizeValue = Number(sizeMatch?.[1]); + const sizeUnit = String(sizeMatch?.[2] || "").toUpperCase(); - attachments.push({ - name, - size: - Number.isFinite(sizeValue) && sizeValue > 0 - ? Math.round( - sizeValue * (sizeUnit === "MB" ? 1024 * 1024 : 1024) - ) - : undefined, - sheetCount: sheetCountMatch ? Number(sheetCountMatch[1]) : undefined, - rowCount: rowCountMatch ? Number(rowCountMatch[1]) : undefined, - truncated: details.includes("已截断"), - }); + attachments.push({ + name, + size: + Number.isFinite(sizeValue) && sizeValue > 0 + ? Math.round( + sizeValue * (sizeUnit === "MB" ? 1024 * 1024 : 1024) + ) + : undefined, + sheetCount: sheetCountMatch ? Number(sheetCountMatch[1]) : undefined, + rowCount: rowCountMatch ? Number(rowCountMatch[1]) : undefined, + truncated: details.includes("已截断"), + }); + } } return attachments; } @@ -977,11 +997,7 @@ function getClawdSpreadsheetAttachments( : Array.isArray(videoMetadata.spreadsheetAttachments) ? videoMetadata.spreadsheetAttachments : []; - if (rawAttachments.length === 0) { - return parseClawdSpreadsheetAttachmentsFromText(message); - } - - return rawAttachments + const normalizedAttachments = rawAttachments .map((attachment: any) => { const name = String( attachment?.name || attachment?.fileName || "" @@ -1015,14 +1031,72 @@ function getClawdSpreadsheetAttachments( attachment: ClawdSpreadsheetAttachment | null ): attachment is ClawdSpreadsheetAttachment => Boolean(attachment) ); + + const knownNames = new Set( + normalizedAttachments.map((attachment: ClawdSpreadsheetAttachment) => + attachment.name.toLowerCase() + ) + ); + const inferredAttachments = [ + ...(((message as any).attachments || []) as any[]), + ...((message.parts || []).filter((part: any) => part?.type === "file") as any[]), + ]; + for (const attachment of inferredAttachments) { + const name = String(attachment?.name || attachment?.fileName || "").trim(); + const url = String(attachment?.url || attachment?.data || "").trim(); + const contentType = String( + attachment?.contentType || + attachment?.content_type || + attachment?.mediaType || + "" + ).trim(); + if ( + !name || + knownNames.has(name.toLowerCase()) || + !isClawdSpreadsheetAttachment(url, contentType, name) + ) { + continue; + } + normalizedAttachments.push({ name, contentType: contentType || undefined }); + knownNames.add(name.toLowerCase()); + } + + for (const attachment of parseClawdSpreadsheetAttachmentsFromText(message)) { + if (knownNames.has(attachment.name.toLowerCase())) continue; + normalizedAttachments.push(attachment); + knownNames.add(attachment.name.toLowerCase()); + } + + return normalizedAttachments; } -function stripClawdSpreadsheetSummary(text: string) { +function stripClawdSpreadsheetSummary( + text: string, + attachments: ClawdSpreadsheetAttachment[] +) { + const spreadsheetNames = new Set( + attachments.map((attachment) => attachment.name.toLowerCase()) + ); return text .replace( /(?:^|\n{2})已上传表格:\n(?:- [^\n]*(?:\n|$))+/g, "\n" ) + .replace( + /(?:^|\n{2})已上传文件:\n((?:- [^\n]*(?:\n|$))+)/g, + (_section, lines: string) => { + const remainingLines = String(lines) + .trim() + .split("\n") + .filter((line) => { + const name = line.replace(/^- /, "").trim().toLowerCase(); + return !spreadsheetNames.has(name); + }); + return remainingLines.length > 0 + ? `\n\n已上传文件:\n${remainingLines.join("\n")}` + : "\n"; + } + ) .replace(/\n{3,}/g, "\n\n") .trim(); } @@ -12233,6 +12307,7 @@ export function CanvasChatPanel({ id: userMessageId, role: "user", parts: [{ type: "text", text: optimisticMessageText }], + attachments: sanitizedAttachmentsSnapshot, createdAt: optimisticCreatedAt, metadata: { createdAt: optimisticCreatedAt.toISOString(), @@ -15913,7 +15988,10 @@ export function CanvasChatPanel({ .trim(); const text = spreadsheetAttachments.length > 0 - ? stripClawdSpreadsheetSummary(normalizedText) + ? stripClawdSpreadsheetSummary( + normalizedText, + spreadsheetAttachments + ) : normalizedText; if (!text) return; renderedUserParts.push( diff --git a/components/canvas/chat-panel/canvas-chat-session.ts b/components/canvas/chat-panel/canvas-chat-session.ts index fd0879b8c..24b945b2c 100644 --- a/components/canvas/chat-panel/canvas-chat-session.ts +++ b/components/canvas/chat-panel/canvas-chat-session.ts @@ -1,4 +1,5 @@ import type { Attachment, ChatMessage } from "@/lib/types"; +import { isOpenClawChatFileAttachment } from "@/lib/openclaw-chat/media-attachments"; export const SESSION_TITLE_MAX_LEN = 18; export const MAX_CHAT_TEXT_PART_LENGTH = 2000; @@ -80,6 +81,9 @@ export function normalizeChatMediaType(value?: string | null): string { if (ALLOWED_CHAT_MEDIA_TYPES.has(normalized)) { return normalized; } + if (isOpenClawChatFileAttachment("", normalized)) { + return normalized; + } return "image/jpeg"; } diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 5c7144e44..dec96e15e 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -235,7 +235,11 @@ import { normalizeUploadedAssetId, validateCanvasImageUploadFile, } from "@/components/canvas/chat-panel/canvas-upload-helpers"; -import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session"; +import { + normalizeChatMediaType, + sanitizeChatAttachment, + type PickedObjectAttachment, +} from "@/components/canvas/chat-panel/canvas-chat-session"; import type { CanvasAgentContext } from "@/types/canvas/agent-protocol"; import type { ChatMessage } from "@/lib/types"; import type { DBMessage } from "@/lib/db/schema"; @@ -253,6 +257,22 @@ function dbTextMessage( } test.describe("canvas chat pure helpers", () => { + test("preserves spreadsheet attachment content types", () => { + const spreadsheetContentType = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + expect(normalizeChatMediaType(spreadsheetContentType)).toBe( + spreadsheetContentType + ); + expect( + sanitizeChatAttachment({ + name: "测试用.xlsx", + url: "https://cdn.example.com/uploads/test.xlsx", + contentType: spreadsheetContentType, + })?.contentType + ).toBe(spreadsheetContentType); + }); + test("keeps optimistic messages without timestamps after persisted history", () => { const messages = [ {