diff --git a/app/api/canvas/clawd-chat/route.ts b/app/api/canvas/clawd-chat/route.ts index 7c4238eae..3bff5448c 100644 --- a/app/api/canvas/clawd-chat/route.ts +++ b/app/api/canvas/clawd-chat/route.ts @@ -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 & { + 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 { + 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 | undefined; + if (typeof metadataValue === "string" && metadataValue.trim()) { + try { + const parsedMetadata = JSON.parse(metadataValue); + if (parsedMetadata && typeof parsedMetadata === "object") { + metadata = parsedMetadata as Record; + } 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 + > | 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: [], diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 1494e029d..76c2a5597 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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() {
{[0, 1, 2].map((index) => ( ))}
@@ -1339,9 +1384,11 @@ export function CanvasChatPanel({ const [messageViewportKey, setMessageViewportKey] = useState(0); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); + const clawdTableInputRef = useRef(null); const videoSlotInputRefs = useRef>([]); const agentSlotInputRefs = useRef>([]); const detailSlotInputRefs = useRef>([]); + const [clawdTableFiles, setClawdTableFiles] = useState([]); const textareaRef = useRef(null); const detailModeSwitchRef = useRef(false); const hiddenPromptRef = useRef(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) => { + 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({ )} ) : null} + {isClawdMode && clawdTableFiles.length > 0 ? ( +
+ {clawdTableFiles.map((file, index) => ( +
+ + + {file.name} + + + {formatClawdTableFileSize(file.size)} + + +
+ ))} +
+ ) : null}
+ {videoFrameSlots.map((_, index) => ( )} + {!isWorkspacePresentation && !isDetailGenerationMode && isClawdMode ? ( + + ) : null} {!isWorkspacePresentation ? ( ; + 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, "
") + .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(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 { + 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")}`; +}