fix(claw): restore spreadsheet attachment badges

This commit is contained in:
134 2026-08-11 21:09:38 +08:00
parent 92e0f49fbc
commit b4e3561510
3 changed files with 134 additions and 41 deletions

View File

@ -467,14 +467,16 @@ function isClawdChatMessage(message: unknown) {
function isClawdSpreadsheetAttachment( function isClawdSpreadsheetAttachment(
url: string, url: string,
contentType?: string | null contentType?: string | null,
name?: string | null
) { ) {
const normalizedType = String(contentType || "").toLowerCase(); const normalizedType = String(contentType || "").toLowerCase();
return ( return (
normalizedType.includes("spreadsheet") || normalizedType.includes("spreadsheet") ||
normalizedType === "text/csv" || normalizedType === "text/csv" ||
normalizedType === "text/tab-separated-values" || 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) .filter((part: any) => part?.type === "text" && part?.text)
.map((part: any) => String(part.text)) .map((part: any) => String(part.text))
.join("\n"); .join("\n");
const summaryMatch = text.match(
/(?:^|\n)已上传表格:\n((?:- [^\n]+(?:\n|$))+)/m
);
if (!summaryMatch?.[1]) {
return [];
}
const attachments: ClawdSpreadsheetAttachment[] = []; const attachments: ClawdSpreadsheetAttachment[] = [];
for (const line of summaryMatch[1].trim().split("\n")) { const sectionPattern =
const lineMatch = line.match(/^- (.+?)(?:((.+)))?$/); /(?:^|\n)(已上传表格|已上传文件):\n((?:- [^\n]+(?:\n|$))+)/gm;
const name = String(lineMatch?.[1] || "").trim(); for (const sectionMatch of text.matchAll(sectionPattern)) {
const details = String(lineMatch?.[2] || ""); const sectionType = String(sectionMatch[1] || "");
if (!name) continue; 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 sizeMatch = details.match(/([\d.]+)\s*(KB|MB)/i);
const sheetCountMatch = details.match(/(\d+)\s*个工作表/); const sheetCountMatch = details.match(/(\d+)\s*个工作表/);
const rowCountMatch = details.match(/(\d+)\s*行/); const rowCountMatch = details.match(/(\d+)\s*行/);
const sizeValue = Number(sizeMatch?.[1]); const sizeValue = Number(sizeMatch?.[1]);
const sizeUnit = String(sizeMatch?.[2] || "").toUpperCase(); const sizeUnit = String(sizeMatch?.[2] || "").toUpperCase();
attachments.push({ attachments.push({
name, name,
size: size:
Number.isFinite(sizeValue) && sizeValue > 0 Number.isFinite(sizeValue) && sizeValue > 0
? Math.round( ? Math.round(
sizeValue * (sizeUnit === "MB" ? 1024 * 1024 : 1024) sizeValue * (sizeUnit === "MB" ? 1024 * 1024 : 1024)
) )
: undefined, : undefined,
sheetCount: sheetCountMatch ? Number(sheetCountMatch[1]) : undefined, sheetCount: sheetCountMatch ? Number(sheetCountMatch[1]) : undefined,
rowCount: rowCountMatch ? Number(rowCountMatch[1]) : undefined, rowCount: rowCountMatch ? Number(rowCountMatch[1]) : undefined,
truncated: details.includes("已截断"), truncated: details.includes("已截断"),
}); });
}
} }
return attachments; return attachments;
} }
@ -1030,11 +1037,7 @@ function getClawdSpreadsheetAttachments(
: Array.isArray(videoMetadata.spreadsheetAttachments) : Array.isArray(videoMetadata.spreadsheetAttachments)
? videoMetadata.spreadsheetAttachments ? videoMetadata.spreadsheetAttachments
: []; : [];
if (rawAttachments.length === 0) { const normalizedAttachments = rawAttachments
return parseClawdSpreadsheetAttachmentsFromText(message);
}
return rawAttachments
.map((attachment: any) => { .map((attachment: any) => {
const name = String( const name = String(
attachment?.name || attachment?.fileName || "" attachment?.name || attachment?.fileName || ""
@ -1068,14 +1071,72 @@ function getClawdSpreadsheetAttachments(
attachment: ClawdSpreadsheetAttachment | null attachment: ClawdSpreadsheetAttachment | null
): attachment is ClawdSpreadsheetAttachment => Boolean(attachment) ): 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 return text
.replace( .replace(
/(?:^|\n{2})已上传表格:\n(?:- [^\n]*(?:\n|$))+/g, /(?:^|\n{2})已上传表格:\n(?:- [^\n]*(?:\n|$))+/g,
"\n" "\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") .replace(/\n{3,}/g, "\n\n")
.trim(); .trim();
} }
@ -12426,6 +12487,7 @@ export function CanvasChatPanel({
id: userMessageId, id: userMessageId,
role: "user", role: "user",
parts: [{ type: "text", text: optimisticMessageText }], parts: [{ type: "text", text: optimisticMessageText }],
attachments: sanitizedAttachmentsSnapshot,
createdAt: optimisticCreatedAt, createdAt: optimisticCreatedAt,
metadata: { metadata: {
createdAt: optimisticCreatedAt.toISOString(), createdAt: optimisticCreatedAt.toISOString(),
@ -16254,8 +16316,10 @@ export function CanvasChatPanel({
const isSpreadsheetAttachment = const isSpreadsheetAttachment =
isClawdSpreadsheetAttachment( isClawdSpreadsheetAttachment(
fileUrl, fileUrl,
attachmentContentType attachmentContentType,
part.name
); );
if (isSpreadsheetAttachment) return;
const attachmentLabel = const attachmentLabel =
part.name || part.name ||
(isVideoAttachment (isVideoAttachment
@ -16332,7 +16396,10 @@ export function CanvasChatPanel({
.trim(); .trim();
const text = const text =
spreadsheetAttachments.length > 0 spreadsheetAttachments.length > 0
? stripClawdSpreadsheetSummary(normalizedText) ? stripClawdSpreadsheetSummary(
normalizedText,
spreadsheetAttachments
)
: normalizedText; : normalizedText;
if (!text) return; if (!text) return;
renderedUserParts.push( renderedUserParts.push(
@ -16355,8 +16422,10 @@ export function CanvasChatPanel({
const isSpreadsheetAttachment = const isSpreadsheetAttachment =
isClawdSpreadsheetAttachment( isClawdSpreadsheetAttachment(
att.url, att.url,
att.contentType att.contentType,
att.name
); );
if (isSpreadsheetAttachment) return;
renderedUserParts.push( renderedUserParts.push(
isFileAttachment ? ( isFileAttachment ? (
<a <a

View File

@ -1,4 +1,5 @@
import type { Attachment, ChatMessage } from "@/lib/types"; import type { Attachment, ChatMessage } from "@/lib/types";
import { isOpenClawChatFileAttachment } from "@/lib/openclaw-chat/media-attachments";
export const SESSION_TITLE_MAX_LEN = 18; export const SESSION_TITLE_MAX_LEN = 18;
export const MAX_CHAT_TEXT_PART_LENGTH = 2000; export const MAX_CHAT_TEXT_PART_LENGTH = 2000;
@ -85,6 +86,9 @@ export function normalizeChatMediaType(value?: string | null): string {
if (ALLOWED_CHAT_MEDIA_TYPES.has(normalized)) { if (ALLOWED_CHAT_MEDIA_TYPES.has(normalized)) {
return normalized; return normalized;
} }
if (isOpenClawChatFileAttachment("", normalized)) {
return normalized;
}
return "image/jpeg"; return "image/jpeg";
} }

View File

@ -235,7 +235,11 @@ import {
normalizeUploadedAssetId, normalizeUploadedAssetId,
validateCanvasImageUploadFile, validateCanvasImageUploadFile,
} from "@/components/canvas/chat-panel/canvas-upload-helpers"; } 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 { CanvasAgentContext } from "@/types/canvas/agent-protocol";
import type { ChatMessage } from "@/lib/types"; import type { ChatMessage } from "@/lib/types";
import type { DBMessage } from "@/lib/db/schema"; import type { DBMessage } from "@/lib/db/schema";
@ -253,6 +257,22 @@ function dbTextMessage(
} }
test.describe("canvas chat pure helpers", () => { 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", () => { test("keeps optimistic messages without timestamps after persisted history", () => {
const messages = [ const messages = [
{ {