diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index ffcc7e5d9..b8c4fbfef 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -467,14 +467,16 @@ function isClawdChatMessage(message: unknown) { function isClawdSpreadsheetAttachment( url: string, - contentType?: string | null + 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(url || "")) || + /\.(xlsx|csv|tsv)$/i.test(String(name || "").trim()) ); } @@ -976,38 +978,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; } @@ -1030,11 +1037,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 || "" @@ -1068,14 +1071,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(); } @@ -12426,6 +12487,7 @@ export function CanvasChatPanel({ id: userMessageId, role: "user", parts: [{ type: "text", text: optimisticMessageText }], + attachments: sanitizedAttachmentsSnapshot, createdAt: optimisticCreatedAt, metadata: { createdAt: optimisticCreatedAt.toISOString(), @@ -16254,8 +16316,10 @@ export function CanvasChatPanel({ const isSpreadsheetAttachment = isClawdSpreadsheetAttachment( fileUrl, - attachmentContentType + attachmentContentType, + part.name ); + if (isSpreadsheetAttachment) return; const attachmentLabel = part.name || (isVideoAttachment @@ -16332,7 +16396,10 @@ export function CanvasChatPanel({ .trim(); const text = spreadsheetAttachments.length > 0 - ? stripClawdSpreadsheetSummary(normalizedText) + ? stripClawdSpreadsheetSummary( + normalizedText, + spreadsheetAttachments + ) : normalizedText; if (!text) return; renderedUserParts.push( @@ -16355,8 +16422,10 @@ export function CanvasChatPanel({ const isSpreadsheetAttachment = isClawdSpreadsheetAttachment( att.url, - att.contentType + att.contentType, + att.name ); + if (isSpreadsheetAttachment) return; renderedUserParts.push( isFileAttachment ? ( { + 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 = [ {