feat: support Clawd spreadsheet uploads

This commit is contained in:
134 2026-06-24 00:20:39 +08:00
parent deacac6e8f
commit 16f734e9c5
3 changed files with 731 additions and 37 deletions

View File

@ -12,15 +12,24 @@ import {
normalizeOpenClawChatConfig,
OPENCLAW_CHAT_CONFIG_KEY,
} from "@/lib/openclaw-chat/config";
import {
buildOpenClawDisplayMessage,
buildOpenClawMessageWithSpreadsheetMarkdown,
buildOpenClawSpreadsheetMarkdown,
} from "@/lib/openclaw-chat/spreadsheet-markdown";
import { generateUUID } from "@/lib/utils";
const requestSchema = z.object({
id: z.string().uuid(),
message: z.string().trim().min(1).max(8000),
message: z.string().trim().max(8000).optional().default(""),
userMessageId: z.string().uuid().optional(),
metadata: z.record(z.unknown()).optional(),
});
type ParsedClawdChatRequest = z.infer<typeof requestSchema> & {
tableFiles: File[];
};
function buildChatTitle(message: string) {
const normalized = message.replace(/\s+/g, " ").trim();
if (!normalized) return "Clawd 对话";
@ -31,6 +40,58 @@ function createConversationId(userId: string, chatId: string) {
return `site-user-${userId}-canvas-${chatId}`;
}
function isRequestFile(value: FormDataEntryValue): value is File {
return (
typeof value === "object" &&
value !== null &&
"arrayBuffer" in value &&
"name" in value &&
"size" in value
);
}
async function parseClawdChatRequest(
request: Request
): Promise<ParsedClawdChatRequest | null> {
const contentType = request.headers.get("content-type") || "";
if (!contentType.includes("multipart/form-data")) {
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
return parsed.success ? { ...parsed.data, tableFiles: [] } : null;
}
const formData = await request.formData().catch(() => null);
if (!formData) return null;
const metadataValue = formData.get("metadata");
let metadata: Record<string, unknown> | undefined;
if (typeof metadataValue === "string" && metadataValue.trim()) {
try {
const parsedMetadata = JSON.parse(metadataValue);
if (parsedMetadata && typeof parsedMetadata === "object") {
metadata = parsedMetadata as Record<string, unknown>;
} else {
return null;
}
} catch {
return null;
}
}
const parsed = requestSchema.safeParse({
id: formData.get("id"),
message: formData.get("message") || "",
userMessageId: formData.get("userMessageId") || undefined,
metadata,
});
if (!parsed.success) return null;
const tableFiles = formData
.getAll("tableFiles")
.filter((entry): entry is File => isRequestFile(entry) && entry.size > 0);
return { ...parsed.data, tableFiles };
}
async function readOpenClawConfig() {
const setting = await getSystemSetting({ key: OPENCLAW_CHAT_CONFIG_KEY }).catch(
() => null
@ -71,10 +132,13 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
const parsed = await parseClawdChatRequest(request);
if (!parsed) {
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
}
if (!parsed.message && parsed.tableFiles.length === 0) {
return NextResponse.json({ error: "请输入问题或上传表格" }, { status: 400 });
}
const config = await readOpenClawConfig();
if (!config.enabled) {
@ -87,23 +151,51 @@ export async function POST(request: Request) {
);
}
const existingChat = await getChatById({ id: parsed.data.id }).catch(() => null);
const existingChat = await getChatById({ id: parsed.id }).catch(() => null);
if (existingChat && existingChat.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
let spreadsheetMarkdownResult: Awaited<
ReturnType<typeof buildOpenClawSpreadsheetMarkdown>
> | null = null;
if (parsed.tableFiles.length > 0) {
try {
spreadsheetMarkdownResult = await buildOpenClawSpreadsheetMarkdown(
parsed.tableFiles
);
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "表格解析失败,请检查文件格式",
},
{ status: 400 }
);
}
}
const displayMessage = buildOpenClawDisplayMessage({
message: parsed.message,
files: spreadsheetMarkdownResult?.files || [],
});
const upstreamMessage = buildOpenClawMessageWithSpreadsheetMarkdown({
message: parsed.message,
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
});
if (!existingChat) {
await saveChat({
id: parsed.data.id,
id: parsed.id,
userId: session.user.id,
title: buildChatTitle(parsed.data.message),
title: buildChatTitle(displayMessage),
visibility: "private",
});
}
const userMessageId = parsed.data.userMessageId || generateUUID();
const userMessageId = parsed.userMessageId || generateUUID();
const assistantMessageId = generateUUID();
const conversationId = createConversationId(session.user.id, parsed.data.id);
const conversationId = createConversationId(session.user.id, parsed.id);
const endpoint = `${config.baseUrl}/api/chat`;
let upstreamPayload: unknown = null;
@ -122,13 +214,16 @@ export async function POST(request: Request) {
conversation_id: conversationId,
user_id: session.user.id,
user_name: session.user.name || session.user.email || "",
message: parsed.data.message,
message: upstreamMessage,
metadata: {
source: "ai_chatbot_canvas",
page: "/canvas",
tenant: "default",
chat_id: parsed.data.id,
...parsed.data.metadata,
chat_id: parsed.id,
spreadsheet_files: spreadsheetMarkdownResult?.files || undefined,
spreadsheet_markdown_truncated:
spreadsheetMarkdownResult?.truncated || undefined,
...parsed.metadata,
},
}),
signal: controller.signal,
@ -175,7 +270,7 @@ export async function POST(request: Request) {
);
}
const existingMessages = await getMessagesByChatId({ id: parsed.data.id }).catch(
const existingMessages = await getMessagesByChatId({ id: parsed.id }).catch(
() => []
);
const latestMessageTimestamp = existingMessages.reduce((latest, item) => {
@ -192,9 +287,9 @@ export async function POST(request: Request) {
const userMessage = {
id: userMessageId,
chatId: parsed.data.id,
chatId: parsed.id,
role: "user",
parts: [{ type: "text", text: parsed.data.message }],
parts: [{ type: "text", text: displayMessage }],
attachments: [],
createdAt: userCreatedAt,
videoStorageStatus: null,
@ -205,7 +300,7 @@ export async function POST(request: Request) {
};
const assistantMessage = {
id: assistantMessageId,
chatId: parsed.data.id,
chatId: parsed.id,
role: "assistant",
parts: [{ type: "text", text: answer }],
attachments: [],

View File

@ -25,7 +25,7 @@ import {
type UiSurfaceSnapshot,
} from "@/lib/ui-surface-protocol";
import useSWR, { useSWRConfig } from "swr";
import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot } from "lucide-react";
import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Collapsible,
@ -319,6 +319,48 @@ function getCanvasComposerModeSessionLabel(mode: CanvasComposerMode) {
return "Agent";
}
const CLAWD_TABLE_ACCEPT =
".xlsx,.csv,.tsv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv,text/tab-separated-values";
const CLAWD_TABLE_ALLOWED_EXTENSIONS = new Set(["xlsx", "csv", "tsv"]);
const CLAWD_TABLE_MAX_FILES = 3;
const CLAWD_TABLE_MAX_BYTES = 10 * 1024 * 1024;
function getClawdTableFileExtension(fileName: string) {
return String(fileName || "")
.split(".")
.pop()
?.toLowerCase()
.trim() || "";
}
function isClawdTableFile(file: File) {
return CLAWD_TABLE_ALLOWED_EXTENSIONS.has(
getClawdTableFileExtension(file.name)
);
}
function formatClawdTableFileSize(bytes: number) {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0KB";
}
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(bytes >= 10 * 1024 * 1024 ? 0 : 1)}MB`;
}
return `${Math.max(1, Math.round(bytes / 1024))}KB`;
}
function buildClawdTableDisplayText(message: string, files: File[]) {
const prompt = message.trim() || "请分析上传的表格内容";
if (files.length === 0) {
return prompt;
}
const lines = files.map(
(file) => `- ${file.name}(${formatClawdTableFileSize(file.size)})`
);
return `${prompt}\n\n已上传表格:\n${lines.join("\n")}`;
}
type VideoStoryboardStreamData =
| {
stage: "detecting";
@ -912,13 +954,16 @@ function CanvasAssistantTypingPlaceholder() {
<div className="flex w-full justify-start">
<div
aria-label="Clawd 正在处理"
className="inline-flex h-10 items-center gap-1.5 rounded-2xl bg-[#f4f5f7] px-4 text-[#9aa3b2] dark:bg-white/8 dark:text-zinc-400"
className="inline-flex h-7 items-center gap-1 rounded-[14px] bg-[#f4f5f7] px-3 text-[#9aa3b2] dark:bg-white/8 dark:text-zinc-400"
>
{[0, 1, 2].map((index) => (
<span
key={index}
className="h-2 w-2 rounded-full bg-current opacity-70 animate-pulse"
style={{ animationDelay: `${index * 140}ms` }}
className="h-1.5 w-1.5 animate-bounce rounded-full bg-current opacity-75"
style={{
animationDelay: `${index * 120}ms`,
animationDuration: "0.9s",
}}
/>
))}
</div>
@ -1339,9 +1384,11 @@ export function CanvasChatPanel({
const [messageViewportKey, setMessageViewportKey] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const clawdTableInputRef = useRef<HTMLInputElement>(null);
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const [clawdTableFiles, setClawdTableFiles] = useState<File[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const detailModeSwitchRef = useRef(false);
const hiddenPromptRef = useRef<string | null>(null);
@ -3522,10 +3569,11 @@ export function CanvasChatPanel({
Boolean(input.trim()) ||
Boolean(editorText) ||
attachments.length > 0 ||
clawdTableFiles.length > 0 ||
Boolean(selectedSkillTagId) ||
hasDetailSlotContent
);
}, [attachments.length, detailSlots, input, selectedSkillTagId]);
}, [attachments.length, clawdTableFiles.length, detailSlots, input, selectedSkillTagId]);
const getComposerModeMenuItemClass = useCallback(
(mode: CanvasComposerMode) =>
cn(
@ -5399,6 +5447,7 @@ export function CanvasChatPanel({
pendingLaunchRef.current = null;
setInput("");
setAttachments([]);
setClawdTableFiles([]);
clearSelection();
const editableDiv = textareaRef.current as HTMLDivElement | null;
@ -5536,6 +5585,7 @@ export function CanvasChatPanel({
setMessages([]);
setInput("");
setAttachments([]);
setClawdTableFiles([]);
setIsSessionMenuOpen(false);
setDetailSlotPickerIndex(null);
@ -7214,6 +7264,7 @@ export function CanvasChatPanel({
setInput("");
setAttachments([]);
setClawdTableFiles([]);
clearSelection();
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
@ -8741,19 +8792,29 @@ export function CanvasChatPanel({
.replace(/\u200b/g, " ")
.replace(/\s+/g, " ")
.trim();
const tableFilesSnapshot = [...clawdTableFiles];
const hasTableFiles = tableFilesSnapshot.length > 0;
if (!textContent) {
if (!textContent && !hasTableFiles) {
return;
}
if (attachments.length > 0) {
toast.error("Clawd 当前仅支持文本对话");
toast.error("Clawd 当前仅支持文本和表格文件");
return;
}
const requestMessage = textContent || "请分析上传的表格内容";
const optimisticMessageText = buildClawdTableDisplayText(
requestMessage,
tableFilesSnapshot
);
const sendSignature = JSON.stringify({
chatId: activeChatId,
mode: "clawd",
text: textContent,
text: requestMessage,
tableFiles: tableFilesSnapshot
.map((file) => `${file.name}:${file.size}:${file.lastModified}`)
.sort(),
});
if (shouldBlockDuplicateSend(activeChatId, sendSignature)) {
@ -8772,7 +8833,7 @@ export function CanvasChatPanel({
const optimisticUserMessage: ChatMessage = {
id: userMessageId,
role: "user",
parts: [{ type: "text", text: textContent }],
parts: [{ type: "text", text: optimisticMessageText }],
createdAt: optimisticCreatedAt,
metadata: { createdAt: optimisticCreatedAt.toISOString() },
} as ChatMessage;
@ -8791,6 +8852,7 @@ export function CanvasChatPanel({
);
setInput("");
setAttachments([]);
setClawdTableFiles([]);
clearSelection();
const editableDiv = textareaRef.current as HTMLDivElement | null;
@ -8799,18 +8861,24 @@ export function CanvasChatPanel({
}
try {
const formData = new FormData();
formData.append("id", rawChatId);
formData.append("userMessageId", userMessageId);
formData.append("message", requestMessage);
formData.append(
"metadata",
JSON.stringify({
composerMode: "clawd",
activeChatId,
})
);
tableFilesSnapshot.forEach((file) => {
formData.append("tableFiles", file, file.name);
});
const response = await fetch("/api/canvas/clawd-chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: rawChatId,
userMessageId,
message: textContent,
metadata: {
composerMode: "clawd",
activeChatId,
},
}),
body: formData,
});
const responseData = await response.json().catch(() => null);
if (!response.ok) {
@ -8842,6 +8910,7 @@ export function CanvasChatPanel({
previousMessages.filter((message) => message.id !== userMessageId)
);
toast.error(error instanceof Error ? error.message : "Clawd 请求失败");
setClawdTableFiles(tableFilesSnapshot);
const currentEditorText = (editableDiv?.textContent || "")
.replace(/\u200b/g, " ")
.replace(/\s+/g, " ")
@ -9151,6 +9220,7 @@ export function CanvasChatPanel({
rawChatId,
input,
attachments,
clawdTableFiles,
hasAgentUploadSlots,
hasWorkflowContext,
selectedSkillTag,
@ -9608,6 +9678,69 @@ export function CanvasChatPanel({
e.target.value = '';
};
const handleClawdTableFileSelect = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(event.currentTarget.files || []);
event.currentTarget.value = "";
if (selectedFiles.length === 0) return;
const remainingSlots = CLAWD_TABLE_MAX_FILES - clawdTableFiles.length;
if (remainingSlots <= 0) {
toast.error(`一次最多上传 ${CLAWD_TABLE_MAX_FILES} 个表格`);
return;
}
const acceptedFiles: File[] = [];
for (const file of selectedFiles) {
if (!isClawdTableFile(file)) {
toast.error(`${file.name} 格式不支持,请上传 .xlsx、.csv 或 .tsv`);
continue;
}
if (file.size <= 0) {
toast.error(`${file.name} 是空文件`);
continue;
}
if (file.size > CLAWD_TABLE_MAX_BYTES) {
toast.error(`${file.name} 超过 10MB,请先拆分或压缩`);
continue;
}
acceptedFiles.push(file);
}
if (acceptedFiles.length === 0) return;
setClawdTableFiles((currentFiles) => {
const existingSignatures = new Set(
currentFiles.map(
(file) => `${file.name}:${file.size}:${file.lastModified}`
)
);
const nextFiles = [...currentFiles];
for (const file of acceptedFiles) {
if (nextFiles.length >= CLAWD_TABLE_MAX_FILES) break;
const signature = `${file.name}:${file.size}:${file.lastModified}`;
if (existingSignatures.has(signature)) {
continue;
}
existingSignatures.add(signature);
nextFiles.push(file);
}
return nextFiles;
});
if (acceptedFiles.length > remainingSlots) {
toast.info(`一次最多上传 ${CLAWD_TABLE_MAX_FILES} 个表格,已自动保留前 ${remainingSlots} 个`);
}
},
[clawdTableFiles.length]
);
const removeClawdTableFile = useCallback((index: number) => {
setClawdTableFiles((currentFiles) =>
currentFiles.filter((_, fileIndex) => fileIndex !== index)
);
}, []);
// 移除附件(通过标签元素删除,支持同图多物品)
const removeAttachmentByTagElement = (tagEl: HTMLElement) => {
const url = tagEl.getAttribute('data-attachment-url') || '';
@ -9682,7 +9815,7 @@ export function CanvasChatPanel({
const hasEnoughAgentUploads = !hasAgentUploadSlots || attachments.length >= agentRequiredUploadCount;
const composerPlaceholder = isWorkspacePresentation
? isClawdMode
? "输入要交给 Clawd 的问题"
? "输入要交给 Clawd 的问题,可上传表格"
: isDetailGenerationMode
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
: isVideoWorkspace
@ -9693,7 +9826,7 @@ export function CanvasChatPanel({
? "可选补充本次生图要求,例如:高级感、暖灰色、近景特写、生成3张。"
: "可选补充本次生成要求,例如:更高级、更生活化、暖调灯光。"
: isClawdMode
? "输入要交给 Clawd 的问题"
? "输入要交给 Clawd 的问题,可上传表格"
: isDetailGenerationMode
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
: isVideoWorkspace
@ -9718,7 +9851,7 @@ export function CanvasChatPanel({
? Math.min(100, Math.round((detailProgress.current / detailProgress.total) * 100))
: 0;
const canSend = isClawdMode
? Boolean(input.trim()) && !isClawdSubmitting
? Boolean(input.trim() || clawdTableFiles.length > 0) && !isClawdSubmitting
: isDetailGenerationMode
? detailValidationIssues.length === 0 && !isDetailGenerating
: hasInputOrAttachments &&
@ -12343,6 +12476,34 @@ export function CanvasChatPanel({
)}
</button>
) : null}
{isClawdMode && clawdTableFiles.length > 0 ? (
<div className="mx-4 mt-3 flex flex-wrap gap-2">
{clawdTableFiles.map((file, index) => (
<div
key={`${file.name}-${file.size}-${file.lastModified}-${index}`}
className="flex max-w-full items-center gap-2 rounded-[12px] border border-[#d8dce3] bg-[#f4f5f7] px-2.5 py-1.5 text-[#20242a] dark:border-white/10 dark:bg-white/8 dark:text-zinc-100"
title={file.name}
>
<FileSpreadsheet className="h-4 w-4 shrink-0 text-emerald-600 dark:text-emerald-300" />
<span className="max-w-[180px] truncate text-[13px] font-medium">
{file.name}
</span>
<span className="shrink-0 text-[11px] text-[#7b8391] dark:text-zinc-400">
{formatClawdTableFileSize(file.size)}
</span>
<button
type="button"
className="rounded-full p-0.5 text-[#8b93a1] transition-colors hover:bg-black/5 hover:text-[#20242a] disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10 dark:hover:text-white"
onClick={() => removeClawdTableFile(index)}
disabled={isSubmitting}
aria-label={`移除 ${file.name}`}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
) : null}
<div
className={cn(
isWorkspacePresentation || isDetailGenerationMode || isVideoWorkspace
@ -12580,6 +12741,14 @@ export function CanvasChatPanel({
className="hidden"
onChange={handleFileSelect}
/>
<input
ref={clawdTableInputRef}
type="file"
accept={CLAWD_TABLE_ACCEPT}
multiple
className="hidden"
onChange={handleClawdTableFileSelect}
/>
{videoFrameSlots.map((_, index) => (
<input
key={`canvas-video-slot-input-${index}`}
@ -12629,6 +12798,21 @@ export function CanvasChatPanel({
/>
</Button>
)}
{!isWorkspacePresentation && !isDetailGenerationMode && isClawdMode ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 rounded-full border border-transparent bg-[#eeeeef] text-[#20242a] transition-colors hover:bg-[#e7e7e8] hover:text-[#111418] disabled:opacity-50 dark:bg-white/10 dark:text-zinc-100 dark:hover:bg-white/14 dark:hover:text-white"
onClick={() => clawdTableInputRef.current?.click()}
disabled={
isSubmitting ||
clawdTableFiles.length >= CLAWD_TABLE_MAX_FILES
}
title="上传表格"
>
<FileSpreadsheet className="h-4.5 w-4.5" />
</Button>
) : null}
{!isWorkspacePresentation ? (
<DropdownMenu
open={isComposerModeMenuOpen}

View File

@ -0,0 +1,415 @@
import Papa from "papaparse";
export const OPENCLAW_SPREADSHEET_MAX_FILES = 3;
export const OPENCLAW_SPREADSHEET_MAX_BYTES = 10 * 1024 * 1024;
export const OPENCLAW_SPREADSHEET_MAX_ROWS_PER_SHEET = 200;
export const OPENCLAW_SPREADSHEET_MAX_COLS_PER_SHEET = 30;
export const OPENCLAW_SPREADSHEET_MAX_SHEETS_PER_FILE = 5;
export const OPENCLAW_SPREADSHEET_MAX_MARKDOWN_CHARS = 60_000;
const SUPPORTED_EXTENSIONS = new Set(["csv", "tsv", "xlsx"]);
export type OpenClawSpreadsheetFileSummary = {
fileName: string;
sheetCount: number;
rowCount: number;
truncated: boolean;
};
export type OpenClawSpreadsheetMarkdownResult = {
markdown: string;
files: OpenClawSpreadsheetFileSummary[];
truncated: boolean;
};
type WorksheetMarkdown = {
markdown: string;
rowCount: number;
truncated: boolean;
};
async function createExcelWorkbook() {
const exceljs = await import("exceljs");
const WorkbookConstructor =
exceljs.Workbook ||
(exceljs as unknown as {
default?: { Workbook?: typeof exceljs.Workbook };
}).default?.Workbook;
if (!WorkbookConstructor) {
throw new Error("Excel 解析器加载失败");
}
return new WorkbookConstructor();
}
function getSpreadsheetExtension(fileName: string) {
return String(fileName || "")
.split(".")
.pop()
?.toLowerCase()
.trim() || "";
}
export function isOpenClawSpreadsheetFileName(fileName: string) {
return SUPPORTED_EXTENSIONS.has(getSpreadsheetExtension(fileName));
}
function getSafeFileName(fileName: string) {
return String(fileName || "未命名表格")
.replace(/[\\/\u0000-\u001f]/g, "_")
.trim()
.slice(0, 160) || "未命名表格";
}
function normalizeSpreadsheetCellValue(value: unknown): string {
if (value == null) {
return "";
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? "" : value.toISOString();
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (typeof value !== "object") {
return String(value);
}
const cellObject = value as {
error?: unknown;
formula?: unknown;
hyperlink?: unknown;
result?: unknown;
richText?: Array<{ text?: unknown }>;
text?: unknown;
};
if (Array.isArray(cellObject.richText)) {
return cellObject.richText
.map((part) => normalizeSpreadsheetCellValue(part.text))
.join("");
}
if (cellObject.result != null) {
return normalizeSpreadsheetCellValue(cellObject.result);
}
if (cellObject.text != null) {
return normalizeSpreadsheetCellValue(cellObject.text);
}
if (cellObject.hyperlink != null) {
return normalizeSpreadsheetCellValue(cellObject.hyperlink);
}
if (cellObject.error != null) {
return normalizeSpreadsheetCellValue(cellObject.error);
}
if (cellObject.formula != null) {
return `=${normalizeSpreadsheetCellValue(cellObject.formula)}`;
}
return "";
}
function normalizeMarkdownCell(value: unknown) {
const normalized = normalizeSpreadsheetCellValue(value)
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.replace(/\n+/g, "<br>")
.replace(/[ \t\u00a0]+/g, " ")
.trim();
return normalized.replace(/\|/g, "\\|");
}
function normalizeRows(rows: unknown[][]) {
const normalizedRows = rows
.map((row) => row.map((cell) => normalizeMarkdownCell(cell)))
.filter((row) => row.some((cell) => cell.length > 0));
let columnCount = 0;
for (const row of normalizedRows) {
for (let index = row.length - 1; index >= 0; index -= 1) {
if (row[index]) {
columnCount = Math.max(columnCount, index + 1);
break;
}
}
}
columnCount = Math.min(columnCount, OPENCLAW_SPREADSHEET_MAX_COLS_PER_SHEET);
return normalizedRows.map((row) =>
Array.from({ length: columnCount }, (_, index) => row[index] || "")
);
}
export function rowsToOpenClawMarkdownTable(rows: unknown[][]) {
const normalizedRows = normalizeRows(rows);
if (normalizedRows.length === 0 || normalizedRows[0].length === 0) {
return "";
}
const columnCount = normalizedRows[0].length;
const firstRow = normalizedRows[0];
const header = firstRow.map((cell, index) => cell || `列${index + 1}`);
const bodyRows = normalizedRows.length > 1 ? normalizedRows.slice(1) : [];
return [
`| ${header.join(" | ")} |`,
`| ${Array.from({ length: columnCount }, () => "---").join(" | ")} |`,
...bodyRows.map((row) => `| ${row.join(" | ")} |`),
].join("\n");
}
function buildWorksheetMarkdown(params: {
rows: unknown[][];
sheetName: string;
totalRows: number;
totalCols: number;
truncatedBySheetLimit?: boolean;
}): WorksheetMarkdown {
const limitedRows = params.rows.slice(0, OPENCLAW_SPREADSHEET_MAX_ROWS_PER_SHEET);
const markdownTable = rowsToOpenClawMarkdownTable(limitedRows);
if (!markdownTable) {
return { markdown: "", rowCount: 0, truncated: false };
}
const truncated =
params.truncatedBySheetLimit === true ||
params.totalRows > OPENCLAW_SPREADSHEET_MAX_ROWS_PER_SHEET ||
params.totalCols > OPENCLAW_SPREADSHEET_MAX_COLS_PER_SHEET;
const visibleRows = Math.min(
params.totalRows,
OPENCLAW_SPREADSHEET_MAX_ROWS_PER_SHEET
);
const visibleCols = Math.min(
params.totalCols,
OPENCLAW_SPREADSHEET_MAX_COLS_PER_SHEET
);
const suffix = truncated
? `(展示前 ${visibleRows} 行 x ${visibleCols} 列,内容已截断)`
: `(${params.totalRows} 行 x ${params.totalCols} 列)`;
return {
markdown: `### 工作表:${params.sheetName || "Sheet1"}${suffix}\n\n${markdownTable}`,
rowCount: params.totalRows,
truncated,
};
}
async function parseCsvOrTsvFile(file: File, delimiter: "," | "\t") {
const text = (await file.text()).replace(/^\uFEFF/, "");
const parsed = Papa.parse<string[]>(text, {
delimiter,
skipEmptyLines: "greedy",
});
if (parsed.errors.length > 0) {
throw new Error(
`${getSafeFileName(file.name)} 解析失败:${parsed.errors[0]?.message || "未知错误"}`
);
}
const rows = parsed.data;
const totalRows = rows.filter((row) =>
row.some((cell) => normalizeMarkdownCell(cell).length > 0)
).length;
const totalCols = rows.reduce((max, row) => Math.max(max, row.length), 0);
const sheet = buildWorksheetMarkdown({
rows,
sheetName: delimiter === "\t" ? "TSV" : "CSV",
totalRows,
totalCols,
});
if (!sheet.markdown) {
throw new Error(`${getSafeFileName(file.name)} 中没有可读取的表格内容`);
}
return {
markdown: `## 附件表格:${getSafeFileName(file.name)}\n\n${sheet.markdown}`.trim(),
summary: {
fileName: getSafeFileName(file.name),
rowCount: sheet.rowCount,
sheetCount: sheet.markdown ? 1 : 0,
truncated: sheet.truncated,
},
};
}
async function parseXlsxFile(file: File) {
const workbook = await createExcelWorkbook();
const buffer = await file.arrayBuffer();
await workbook.xlsx.load(buffer);
const worksheets = workbook.worksheets.filter((worksheet) => {
if (!worksheet) return false;
let hasValue = false;
worksheet.eachRow({ includeEmpty: false }, (row) => {
row.eachCell({ includeEmpty: false }, (cell) => {
if (normalizeMarkdownCell(cell.value).length > 0) {
hasValue = true;
}
});
});
return hasValue;
});
if (worksheets.length === 0) {
throw new Error(`${getSafeFileName(file.name)} 中没有可读取的表格内容`);
}
const parts: string[] = [];
let rowCount = 0;
let truncated = worksheets.length > OPENCLAW_SPREADSHEET_MAX_SHEETS_PER_FILE;
const visibleWorksheets = worksheets.slice(
0,
OPENCLAW_SPREADSHEET_MAX_SHEETS_PER_FILE
);
for (const worksheet of visibleWorksheets) {
let totalRows = 0;
let totalCols = 0;
worksheet.eachRow({ includeEmpty: false }, (row) => {
let rowHasValue = false;
row.eachCell({ includeEmpty: false }, (cell, colNumber) => {
if (normalizeMarkdownCell(cell.value).length > 0) {
rowHasValue = true;
totalCols = Math.max(totalCols, colNumber);
}
});
if (rowHasValue) {
totalRows += 1;
}
});
const rows: unknown[][] = [];
for (
let rowNumber = 1;
rowNumber <= worksheet.rowCount &&
rows.length < OPENCLAW_SPREADSHEET_MAX_ROWS_PER_SHEET;
rowNumber += 1
) {
const row = worksheet.getRow(rowNumber);
const values = Array.from(
{ length: Math.min(totalCols, OPENCLAW_SPREADSHEET_MAX_COLS_PER_SHEET) },
(_, index) => row.getCell(index + 1).value
);
if (values.some((value) => normalizeMarkdownCell(value).length > 0)) {
rows.push(values);
}
}
const sheet = buildWorksheetMarkdown({
rows,
sheetName: worksheet.name,
totalRows,
totalCols,
truncatedBySheetLimit:
worksheets.length > OPENCLAW_SPREADSHEET_MAX_SHEETS_PER_FILE,
});
if (sheet.markdown) {
parts.push(sheet.markdown);
rowCount += sheet.rowCount;
truncated = truncated || sheet.truncated;
}
}
return {
markdown: `## 附件表格:${getSafeFileName(file.name)}\n\n${parts.join("\n\n")}`.trim(),
summary: {
fileName: getSafeFileName(file.name),
rowCount,
sheetCount: visibleWorksheets.length,
truncated,
},
};
}
export async function spreadsheetFileToOpenClawMarkdown(file: File) {
if (file.size <= 0) {
throw new Error(`${getSafeFileName(file.name)} 是空文件`);
}
if (file.size > OPENCLAW_SPREADSHEET_MAX_BYTES) {
throw new Error(`${getSafeFileName(file.name)} 超过 10MB,请先拆分或压缩`);
}
const extension = getSpreadsheetExtension(file.name);
if (!SUPPORTED_EXTENSIONS.has(extension)) {
throw new Error("Clawd 目前支持 .xlsx、.csv、.tsv 表格文件");
}
if (extension === "csv") {
return parseCsvOrTsvFile(file, ",");
}
if (extension === "tsv") {
return parseCsvOrTsvFile(file, "\t");
}
return parseXlsxFile(file);
}
export async function buildOpenClawSpreadsheetMarkdown(
files: File[]
): Promise<OpenClawSpreadsheetMarkdownResult> {
const usableFiles = files.filter((file) => file.size > 0);
if (usableFiles.length === 0) {
return { markdown: "", files: [], truncated: false };
}
if (usableFiles.length > OPENCLAW_SPREADSHEET_MAX_FILES) {
throw new Error(`一次最多上传 ${OPENCLAW_SPREADSHEET_MAX_FILES} 个表格文件`);
}
const parts: string[] = [];
const summaries: OpenClawSpreadsheetFileSummary[] = [];
let truncated = false;
for (const file of usableFiles) {
const parsed = await spreadsheetFileToOpenClawMarkdown(file);
if (parsed.markdown) {
parts.push(parsed.markdown);
}
summaries.push(parsed.summary);
truncated = truncated || parsed.summary.truncated;
}
let markdown = parts.join("\n\n---\n\n").trim();
if (markdown.length > OPENCLAW_SPREADSHEET_MAX_MARKDOWN_CHARS) {
markdown = `${markdown.slice(0, OPENCLAW_SPREADSHEET_MAX_MARKDOWN_CHARS)}\n\n> 表格 Markdown 内容过长,已在本系统中截断。`;
truncated = true;
}
return { markdown, files: summaries, truncated };
}
export function buildOpenClawMessageWithSpreadsheetMarkdown(params: {
message: string;
spreadsheetMarkdown: string;
}) {
const prompt = params.message.trim() || "请分析上传的表格内容。";
if (!params.spreadsheetMarkdown.trim()) {
return prompt;
}
return [
prompt,
"以下是用户上传表格转换后的 Markdown 内容,请基于这些表格内容回答:",
params.spreadsheetMarkdown.trim(),
].join("\n\n");
}
export function buildOpenClawDisplayMessage(params: {
message: string;
files: OpenClawSpreadsheetFileSummary[];
}) {
const prompt = params.message.trim() || "请分析上传的表格内容";
if (params.files.length === 0) {
return prompt;
}
const fileLines = params.files.map((file) => {
const suffix = file.truncated ? ",已截断" : "";
return `- ${file.fileName}(${file.sheetCount} 个工作表,${file.rowCount} 行${suffix})`;
});
return `${prompt}\n\n已上传表格:\n${fileLines.join("\n")}`;
}