feat: add XLSX support for file previews and rendering

This commit is contained in:
Vukasin 2025-08-25 16:20:21 +02:00
parent 87d25e3634
commit 90a789192a
7 changed files with 419 additions and 13 deletions

View File

@ -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",

View File

@ -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"
},

View File

@ -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'
);
};

View File

@ -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}

View File

@ -1,3 +1,4 @@
export * from './html-renderer';
export * from './markdown-renderer';
export * from './csv-renderer';
export * from './csv-renderer';
export * from './xlsx-renderer';

View File

@ -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>
);
}

View File

@ -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';