import Papa from "papaparse"; import { OPENCLAW_CHAT_FILE_MAX_BYTES } from "@/lib/openclaw-chat/media-attachments"; export const OPENCLAW_SPREADSHEET_MAX_FILES = 3; export const OPENCLAW_SPREADSHEET_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES; export const OPENCLAW_SPREADSHEET_INLINE_PARSE_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, "
") .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_INLINE_PARSE_MAX_BYTES) { throw new Error( `${getSafeFileName(file.name)} 超过 10MB,请使用 Claw 文件附件直传(最高 2GB)` ); } const extension = getSpreadsheetExtension(file.name); if (!SUPPORTED_EXTENSIONS.has(extension)) { throw new Error("Claw 目前支持 .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")}`; }