fix(claw): restore spreadsheet attachment badges
This commit is contained in:
parent
f6f4f94245
commit
69705518d6
@ -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(
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
|
||||
@ -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 = [
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user