mirror of https://github.com/kortix-ai/suna.git
feat: add XLSX support for file previews and rendering
This commit is contained in:
parent
87d25e3634
commit
90a789192a
|
@ -47,6 +47,7 @@
|
|||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/xlsx": "^0.0.35",
|
||||
"@uiw/codemirror-extensions-langs": "^4.23.10",
|
||||
"@uiw/codemirror-theme-vscode": "^4.23.10",
|
||||
"@uiw/codemirror-theme-xcode": "^4.23.10",
|
||||
|
@ -113,6 +114,7 @@
|
|||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5",
|
||||
"vaul": "^1.1.2",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
|
@ -5896,6 +5898,12 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/xlsx": {
|
||||
"version": "0.0.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/xlsx/-/xlsx-0.0.35.tgz",
|
||||
"integrity": "sha512-s0x3DYHZzOkxtjqOk/Nv1ezGzpbN7I8WX+lzlV/nFfTDOv7x4d8ZwGHcnaiB8UCx89omPsftQhS5II3jeWePxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.0.tgz",
|
||||
|
@ -6709,6 +6717,15 @@
|
|||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
|
@ -7380,6 +7397,19 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/character-entities": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
|
||||
|
@ -7811,6 +7841,15 @@
|
|||
"@lezer/lr": "^1.3.10"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
|
@ -8380,6 +8419,18 @@
|
|||
"integrity": "sha512-D/ZkRyj+ywJC6b2IrAN3/tpbReMUqmuRLlcKFoY/o0+EPQN9Ev/e8tV+D3+9scvu/tarxwLErNwS73C3yzxs/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
|
@ -10310,6 +10361,15 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
|
@ -16152,6 +16212,18 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||
|
@ -17539,6 +17611,24 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
@ -17576,6 +17666,27 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
|
|
@ -50,6 +50,7 @@
|
|||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/xlsx": "^0.0.35",
|
||||
"@uiw/codemirror-extensions-langs": "^4.23.10",
|
||||
"@uiw/codemirror-theme-vscode": "^4.23.10",
|
||||
"@uiw/codemirror-theme-xcode": "^4.23.10",
|
||||
|
@ -116,6 +117,7 @@
|
|||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5",
|
||||
"vaul": "^1.1.2",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
|
|
|
@ -118,7 +118,7 @@ export function AttachmentGroup({
|
|||
return !sandboxId ? file.localUrl : undefined;
|
||||
};
|
||||
|
||||
// Check if a file is HTML, Markdown, CSV, or PDF (previewable types in grid)
|
||||
// Check if a file is HTML, Markdown, CSV, XLSX, or PDF (previewable types in grid)
|
||||
const isPreviewableFile = (file: string | UploadedFile): boolean => {
|
||||
const path = getFilePath(file);
|
||||
const ext = path.split('.').pop()?.toLowerCase() || '';
|
||||
|
@ -129,6 +129,8 @@ export function AttachmentGroup({
|
|||
ext === 'markdown' ||
|
||||
ext === 'csv' ||
|
||||
ext === 'tsv' ||
|
||||
ext === 'xlsx' ||
|
||||
ext === 'xls' ||
|
||||
ext === 'pdf'
|
||||
);
|
||||
};
|
||||
|
|
|
@ -2,14 +2,21 @@ import React from 'react';
|
|||
import {
|
||||
FileText, FileImage, FileCode, FileSpreadsheet, FileVideo,
|
||||
FileAudio, FileType, Database, Archive, File, ExternalLink,
|
||||
Loader2, Download
|
||||
Loader2, Download, ChevronDown
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AttachmentGroup } from './attachment-group';
|
||||
import { HtmlRenderer } from './preview-renderers/html-renderer';
|
||||
import { MarkdownRenderer } from './preview-renderers/markdown-renderer';
|
||||
import { CsvRenderer } from './preview-renderers/csv-renderer';
|
||||
import { XlsxRenderer } from './preview-renderers/xlsx-renderer';
|
||||
import { PdfRenderer as PdfPreviewRenderer } from './preview-renderers/pdf-renderer';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
import { useFileContent, useImageContent } from '@/hooks/react-query/files';
|
||||
import { useAuth } from '@/components/AuthProvider';
|
||||
|
@ -187,6 +194,10 @@ export function FileAttachment({
|
|||
// Simplified state management
|
||||
const [hasError, setHasError] = React.useState(false);
|
||||
|
||||
// XLSX sheet management
|
||||
const [xlsxSheetIndex, setXlsxSheetIndex] = React.useState(0);
|
||||
const [xlsxSheetNames, setXlsxSheetNames] = React.useState<string[]>([]);
|
||||
|
||||
// Basic file info
|
||||
const filename = filepath.split('/').pop() || 'file';
|
||||
const extension = filename.split('.').pop()?.toLowerCase() || '';
|
||||
|
@ -205,10 +216,11 @@ export function FileAttachment({
|
|||
const isGridLayout = customStyle?.gridColumn === '1 / -1' || Boolean(customStyle && ('--attachment-height' in customStyle));
|
||||
// Define isInlineMode early, before any hooks
|
||||
const isInlineMode = !isGridLayout;
|
||||
const shouldShowPreview = (isHtmlOrMd || isCsv || isPdf) && showPreview && collapsed === false;
|
||||
const shouldShowPreview = (isHtmlOrMd || isCsv || isXlsx || isPdf) && showPreview && collapsed === false;
|
||||
|
||||
// Use the React Query hook to fetch file content
|
||||
// For CSV files, always try to load content for better preview experience
|
||||
// For XLSX files, we need binary data which is handled by useImageContent
|
||||
const shouldLoadContent = (isHtmlOrMd || isCsv) && (shouldShowPreview || isCsv);
|
||||
const {
|
||||
data: fileContent,
|
||||
|
@ -239,12 +251,47 @@ export function FileAttachment({
|
|||
isPdf && shouldShowPreview ? filepath : undefined
|
||||
);
|
||||
|
||||
// For XLSX files we fetch binary data and convert to base64
|
||||
const {
|
||||
data: xlsxBlobUrl,
|
||||
isLoading: xlsxLoading,
|
||||
error: xlsxError
|
||||
} = useImageContent(
|
||||
isXlsx && shouldShowPreview && sandboxId ? sandboxId : undefined,
|
||||
isXlsx && shouldShowPreview ? filepath : undefined
|
||||
);
|
||||
|
||||
// Set error state based on query errors
|
||||
React.useEffect(() => {
|
||||
if (fileContentError || imageError || pdfError) {
|
||||
if (fileContentError || imageError || pdfError || xlsxError) {
|
||||
setHasError(true);
|
||||
}
|
||||
}, [fileContentError, imageError, pdfError]);
|
||||
}, [fileContentError, imageError, pdfError, xlsxError]);
|
||||
|
||||
// Parse XLSX to get sheet names when blob URL is available
|
||||
React.useEffect(() => {
|
||||
if (isXlsx && xlsxBlobUrl && shouldShowPreview) {
|
||||
const parseSheetNames = async () => {
|
||||
try {
|
||||
// Import XLSX dynamically to avoid bundle size issues
|
||||
const XLSX = await import('xlsx');
|
||||
|
||||
// Convert blob URL to binary data
|
||||
const response = await fetch(xlsxBlobUrl);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
// Read workbook
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
setXlsxSheetNames(workbook.SheetNames);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse XLSX sheet names:', error);
|
||||
setXlsxSheetNames([]);
|
||||
}
|
||||
};
|
||||
|
||||
parseSheetNames();
|
||||
}
|
||||
}, [isXlsx, xlsxBlobUrl, shouldShowPreview]);
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
|
@ -438,7 +485,9 @@ export function FileAttachment({
|
|||
'md': MarkdownRenderer,
|
||||
'markdown': MarkdownRenderer,
|
||||
'csv': CsvRenderer,
|
||||
'tsv': CsvRenderer
|
||||
'tsv': CsvRenderer,
|
||||
'xlsx': XlsxRenderer,
|
||||
'xls': XlsxRenderer
|
||||
};
|
||||
|
||||
// HTML/MD/CSV/PDF preview when not collapsed and in grid layout
|
||||
|
@ -453,7 +502,7 @@ export function FileAttachment({
|
|||
"rounded-xl border bg-card overflow-hidden pt-10", // Consistent card styling with header space
|
||||
isPdf ? "!min-h-[200px] sm:min-h-0 sm:h-[400px] max-h-[500px] sm:!min-w-[300px]" :
|
||||
isHtmlOrMd ? "!min-h-[200px] sm:min-h-0 sm:h-[400px] max-h-[600px] sm:!min-w-[300px]" :
|
||||
isCsv ? "min-h-[300px] h-full" : // Let CSV take full height
|
||||
(isCsv || isXlsx) ? "min-h-[300px] h-full" : // Let CSV and XLSX take full height
|
||||
standalone ? "min-h-[300px] h-auto" : "h-[300px]", // Better height handling for standalone
|
||||
className
|
||||
)}
|
||||
|
@ -475,7 +524,7 @@ export function FileAttachment({
|
|||
contain: (isPdf || isHtmlOrMd) ? 'layout size' : undefined
|
||||
}}
|
||||
>
|
||||
{/* Render PDF or text-based previews */}
|
||||
{/* Render PDF, XLSX, or text-based previews */}
|
||||
{!hasError && (
|
||||
<>
|
||||
{isPdf && (() => {
|
||||
|
@ -487,7 +536,18 @@ export function FileAttachment({
|
|||
/>
|
||||
) : null;
|
||||
})()}
|
||||
{!isPdf && fileContent && Renderer && (
|
||||
{isXlsx && (() => {
|
||||
const xlsxUrlForRender = localPreviewUrl || (sandboxId ? (xlsxBlobUrl ?? null) : fileUrl);
|
||||
return xlsxUrlForRender ? (
|
||||
<XlsxRenderer
|
||||
content={xlsxUrlForRender}
|
||||
className="h-full w-full"
|
||||
activeSheetIndex={xlsxSheetIndex}
|
||||
onSheetChange={(index) => setXlsxSheetIndex(index)}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
{!isPdf && !isXlsx && fileContent && Renderer && (
|
||||
<Renderer
|
||||
content={fileContent}
|
||||
previewUrl={fileUrl}
|
||||
|
@ -540,8 +600,14 @@ export function FileAttachment({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{isXlsx && xlsxLoading && !xlsxBlobUrl && (
|
||||
<div className="absolute inset-0 flex items-end justify-center bg-background/50 z-10 pb-8">
|
||||
<Loader2 className="h-8 w-8 text-primary animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty content state - show when not loading and no content yet */}
|
||||
{!isPdf && !fileContent && !fileContentLoading && !hasError && (
|
||||
{!isPdf && !isXlsx && !fileContent && !fileContentLoading && !hasError && (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center p-4 pointer-events-none">
|
||||
<div className="text-muted-foreground text-sm mb-2">
|
||||
Preview available
|
||||
|
@ -555,7 +621,34 @@ export function FileAttachment({
|
|||
|
||||
{/* Header with filename */}
|
||||
<div className="absolute top-0 left-0 right-0 bg-accent p-2 h-[40px] z-10 flex items-center justify-between">
|
||||
<div className="text-sm font-medium truncate">{filename}</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{filename}</div>
|
||||
{/* XLSX Sheet Selector */}
|
||||
{isXlsx && xlsxSheetNames.length > 1 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 px-2 py-1 rounded-xl hover:bg-background/70 text-xs font-medium transition-colors">
|
||||
<span className="truncate max-w-[100px]">{xlsxSheetNames[xlsxSheetIndex] || 'Sheet 1'}</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[120px]">
|
||||
{xlsxSheetNames.map((sheetName, index) => (
|
||||
<DropdownMenuItem
|
||||
key={index}
|
||||
onClick={() => setXlsxSheetIndex(index)}
|
||||
className={cn(
|
||||
"text-xs cursor-pointer",
|
||||
index === xlsxSheetIndex && "bg-accent"
|
||||
)}
|
||||
>
|
||||
{sheetName}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* <button
|
||||
onClick={handleDownload}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from './html-renderer';
|
||||
export * from './markdown-renderer';
|
||||
export * from './csv-renderer';
|
||||
export * from './csv-renderer';
|
||||
export * from './xlsx-renderer';
|
|
@ -0,0 +1,197 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { CsvTable } from '@/components/ui/csv-table';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
interface XlsxRendererProps {
|
||||
content: string; // Blob URL or base64 encoded XLSX content
|
||||
className?: string;
|
||||
onSheetChange?: (sheetIndex: number) => void; // Callback for sheet changes
|
||||
activeSheetIndex?: number; // Controlled sheet index
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert blob URL to base64 string
|
||||
*/
|
||||
async function blobUrlToBase64(blobUrl: string): Promise<string> {
|
||||
const response = await fetch(blobUrl);
|
||||
const blob = await response.blob();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
// Remove data:application/... prefix to get just base64
|
||||
const base64 = result.split(',')[1];
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse XLSX content into workbook with multiple sheets
|
||||
*/
|
||||
async function parseXLSX(content: string) {
|
||||
if (!content) return { sheets: [], sheetNames: [] };
|
||||
|
||||
try {
|
||||
let base64Content = content;
|
||||
|
||||
// If content is a blob URL, convert it to base64
|
||||
if (content.startsWith('blob:')) {
|
||||
base64Content = await blobUrlToBase64(content);
|
||||
}
|
||||
|
||||
// Convert base64 to binary
|
||||
const binaryString = atob(base64Content);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
// Read the workbook
|
||||
const workbook = XLSX.read(bytes, { type: 'array' });
|
||||
const sheetNames = workbook.SheetNames;
|
||||
|
||||
// Convert each sheet to data format
|
||||
const sheets = sheetNames.map(name => {
|
||||
const worksheet = workbook.Sheets[name];
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
|
||||
|
||||
if (jsonData.length === 0) return { headers: [], data: [] };
|
||||
|
||||
const headers = jsonData[0] as string[];
|
||||
const data = jsonData.slice(1).map(row => {
|
||||
const rowObj: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
rowObj[header] = (row as any[])[index] || '';
|
||||
});
|
||||
return rowObj;
|
||||
});
|
||||
|
||||
return { headers, data };
|
||||
});
|
||||
|
||||
return { sheets, sheetNames };
|
||||
} catch (error) {
|
||||
console.error("Error parsing XLSX:", error);
|
||||
return { sheets: [], sheetNames: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal XLSX renderer with sheet switching using the CsvTable component
|
||||
*/
|
||||
export function XlsxRenderer({
|
||||
content,
|
||||
className,
|
||||
onSheetChange,
|
||||
activeSheetIndex = 0
|
||||
}: XlsxRendererProps) {
|
||||
const [internalSheetIndex, setInternalSheetIndex] = useState(0);
|
||||
const [parsedData, setParsedData] = useState<{ sheets: { headers: string[]; data: any[]; }[]; sheetNames: string[]; }>({ sheets: [], sheetNames: [] });
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sortConfig, setSortConfig] = useState<{ column: string; direction: 'asc' | 'desc' | null }>({
|
||||
column: '',
|
||||
direction: null
|
||||
});
|
||||
|
||||
// Use controlled or internal sheet index
|
||||
const currentSheetIndex = onSheetChange ? activeSheetIndex : internalSheetIndex;
|
||||
|
||||
// Parse XLSX content on mount or content change
|
||||
React.useEffect(() => {
|
||||
if (!content) {
|
||||
setParsedData({ sheets: [], sheetNames: [] });
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
parseXLSX(content)
|
||||
.then(result => {
|
||||
setParsedData(result);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to parse XLSX:', error);
|
||||
setParsedData({ sheets: [], sheetNames: [] });
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [content]);
|
||||
|
||||
const { sheets, sheetNames } = parsedData;
|
||||
const currentSheet = sheets[currentSheetIndex] || { headers: [], data: [] };
|
||||
const isEmpty = sheets.length === 0 || currentSheet.data.length === 0;
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
setSortConfig(prev => {
|
||||
if (prev.column === column) {
|
||||
const newDirection = prev.direction === 'asc' ? 'desc' : prev.direction === 'desc' ? null : 'asc';
|
||||
return { column: newDirection ? column : '', direction: newDirection };
|
||||
} else {
|
||||
return { column, direction: 'asc' };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Sort the data based on sortConfig
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortConfig.column || !sortConfig.direction) {
|
||||
return currentSheet.data;
|
||||
}
|
||||
|
||||
return [...currentSheet.data].sort((a: any, b: any) => {
|
||||
const aVal = a[sortConfig.column];
|
||||
const bVal = b[sortConfig.column];
|
||||
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (bVal == null) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return sortConfig.direction === 'asc' ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
|
||||
const aStr = String(aVal).toLowerCase();
|
||||
const bStr = String(bVal).toLowerCase();
|
||||
|
||||
if (aStr < bStr) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aStr > bStr) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [currentSheet.data, sortConfig]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn('w-full h-full flex items-center justify-center', className)}>
|
||||
<div className="text-muted-foreground text-sm">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className={cn('w-full h-full flex items-center justify-center', className)}>
|
||||
<div className="text-muted-foreground text-sm">No data</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-full h-full', className)}>
|
||||
<CsvTable
|
||||
headers={currentSheet.headers}
|
||||
data={sortedData}
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
containerHeight={300} // Fixed height for thread preview
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -60,7 +60,7 @@ export function AskToolView({
|
|||
|
||||
const isPreviewableFile = (filePath: string): boolean => {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
return ext === 'html' || ext === 'htm' || ext === 'md' || ext === 'markdown' || ext === 'csv' || ext === 'tsv' || ext === 'pdf';
|
||||
return ext === 'html' || ext === 'htm' || ext === 'md' || ext === 'markdown' || ext === 'csv' || ext === 'tsv' || ext === 'pdf' || ext === 'xlsx' || ext === 'xls';
|
||||
};
|
||||
|
||||
const toolTitle = getToolTitle(name) || 'Ask User';
|
||||
|
|
Loading…
Reference in New Issue