diff --git a/src/llama_stack_ui/app/api/v1/[...path]/route.ts b/src/llama_stack_ui/app/api/v1/[...path]/route.ts index d1aa31014..36b754fbe 100644 --- a/src/llama_stack_ui/app/api/v1/[...path]/route.ts +++ b/src/llama_stack_ui/app/api/v1/[...path]/route.ts @@ -37,28 +37,56 @@ async function proxyRequest(request: NextRequest, method: string) { // Add body for methods that support it if (["POST", "PUT", "PATCH"].includes(method) && request.body) { - requestOptions.body = await request.text(); + requestOptions.body = request.body; + // Required for ReadableStream bodies in newer Node.js versions + requestOptions.duplex = "half" as RequestDuplex; } // Make the request to FastAPI backend const response = await fetch(targetUrl, requestOptions); - // Get response data - const responseText = await response.text(); - console.log( `Response from FastAPI: ${response.status} ${response.statusText}` ); - // Create response with same status and headers // Handle 204 No Content responses specially - const proxyResponse = - response.status === 204 - ? new NextResponse(null, { status: 204 }) - : new NextResponse(responseText, { - status: response.status, - statusText: response.statusText, - }); + if (response.status === 204) { + const proxyResponse = new NextResponse(null, { status: 204 }); + // Copy response headers (except problematic ones) + response.headers.forEach((value, key) => { + if (!["connection", "transfer-encoding"].includes(key.toLowerCase())) { + proxyResponse.headers.set(key, value); + } + }); + return proxyResponse; + } + + // Check content type to handle binary vs text responses appropriately + const contentType = response.headers.get("content-type") || ""; + const isBinaryContent = + contentType.includes("application/pdf") || + contentType.includes("application/msword") || + contentType.includes("application/vnd.openxmlformats-officedocument") || + contentType.includes("application/octet-stream") || + contentType.includes("image/") || + contentType.includes("video/") || + contentType.includes("audio/"); + + let responseData: string | ArrayBuffer; + + if (isBinaryContent) { + // Handle binary content (PDFs, Word docs, images, etc.) + responseData = await response.arrayBuffer(); + } else { + // Handle text content (JSON, plain text, etc.) + responseData = await response.text(); + } + + // Create response with same status and headers + const proxyResponse = new NextResponse(responseData, { + status: response.status, + statusText: response.statusText, + }); // Copy response headers (except problematic ones) response.headers.forEach((value, key) => { diff --git a/src/llama_stack_ui/app/logs/files/[id]/page.tsx b/src/llama_stack_ui/app/logs/files/[id]/page.tsx new file mode 100644 index 000000000..026a2acbd --- /dev/null +++ b/src/llama_stack_ui/app/logs/files/[id]/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import React from "react"; +import { FileDetail } from "@/components/files/file-detail"; + +export default function FileDetailPage() { + return ; +} diff --git a/src/llama_stack_ui/app/logs/files/layout.tsx b/src/llama_stack_ui/app/logs/files/layout.tsx new file mode 100644 index 000000000..2fa163bf5 --- /dev/null +++ b/src/llama_stack_ui/app/logs/files/layout.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useParams, usePathname } from "next/navigation"; +import { + PageBreadcrumb, + BreadcrumbSegment, +} from "@/components/layout/page-breadcrumb"; + +export default function FileDetailLayout({ + children, +}: { + children: React.ReactNode; +}) { + const params = useParams(); + const pathname = usePathname(); + const fileId = params.id as string; + + const breadcrumbSegments: BreadcrumbSegment[] = [ + { label: "Files", href: "/logs/files" }, + { label: `Details (${fileId})` }, + ]; + + const isBaseDetailPage = pathname === `/logs/files/${fileId}`; + + return ( +
+ {isBaseDetailPage && } + {children} +
+ ); +} diff --git a/src/llama_stack_ui/app/logs/files/page.tsx b/src/llama_stack_ui/app/logs/files/page.tsx new file mode 100644 index 000000000..c55e52a21 --- /dev/null +++ b/src/llama_stack_ui/app/logs/files/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import React from "react"; +import { FilesManagement } from "@/components/files/files-management"; + +export default function FilesPage() { + return ; +} diff --git a/src/llama_stack_ui/components/files/csv-viewer.tsx b/src/llama_stack_ui/components/files/csv-viewer.tsx new file mode 100644 index 000000000..2ef95d11c --- /dev/null +++ b/src/llama_stack_ui/components/files/csv-viewer.tsx @@ -0,0 +1,290 @@ +"use client"; + +import React, { useMemo, useState } from "react"; +import Papa from "papaparse"; +import { + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Input } from "@/components/ui/input"; +import { Search, ChevronUp, ChevronDown } from "lucide-react"; + +interface CSVViewerProps { + content: string; + filename?: string; +} + +interface ParsedCSV { + headers: string[]; + rows: string[][]; + errors: Papa.ParseError[]; +} + +// Constants for content size management +const MAX_CSV_SIZE = 10 * 1024 * 1024; // 10MB +const WARN_CSV_SIZE = 5 * 1024 * 1024; // 5MB + +export function CSVViewer({ content }: CSVViewerProps) { + const [searchTerm, setSearchTerm] = useState(""); + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + + // Check content size + const contentSize = content.length; + const isLargeFile = contentSize > WARN_CSV_SIZE; + const isOversized = contentSize > MAX_CSV_SIZE; + + const parsedData: ParsedCSV = useMemo(() => { + if (!content) { + return { headers: [], rows: [], errors: [] }; + } + + const result = Papa.parse(content.trim(), { + header: false, + skipEmptyLines: true, + delimiter: "", // Auto-detect delimiter + quoteChar: '"', + escapeChar: '"', + }); + + if (result.errors.length > 0) { + console.warn("CSV parsing errors:", result.errors); + } + + const data = result.data as string[][]; + if (data.length === 0) { + return { headers: [], rows: [], errors: result.errors }; + } + + // First row as headers, rest as data + const headers = data[0] || []; + const rows = data.slice(1); + + return { + headers, + rows, + errors: result.errors, + }; + }, [content]); + + const filteredAndSortedRows = useMemo(() => { + let filtered = parsedData.rows; + + // Apply search filter + if (searchTerm) { + filtered = filtered.filter(row => + row.some(cell => cell?.toLowerCase().includes(searchTerm.toLowerCase())) + ); + } + + // Apply sorting + if (sortColumn !== null) { + filtered = [...filtered].sort((a, b) => { + const aVal = a[sortColumn] || ""; + const bVal = b[sortColumn] || ""; + + // Try to parse as numbers for numeric sorting + const aNum = parseFloat(aVal); + const bNum = parseFloat(bVal); + + if (!isNaN(aNum) && !isNaN(bNum)) { + return sortDirection === "asc" ? aNum - bNum : bNum - aNum; + } + + // String sorting + const comparison = aVal.localeCompare(bVal); + return sortDirection === "asc" ? comparison : -comparison; + }); + } + + return filtered; + }, [parsedData.rows, searchTerm, sortColumn, sortDirection]); + + // Handle oversized files after hooks + if (isOversized) { + return ( +
+

+ File Too Large +

+

+ CSV file is too large to display ( + {(contentSize / (1024 * 1024)).toFixed(2)}MB). Maximum supported size + is {MAX_CSV_SIZE / (1024 * 1024)}MB. +

+

+ Please download the file to view its contents. +

+
+ ); + } + + const handleSort = (columnIndex: number) => { + if (sortColumn === columnIndex) { + setSortDirection(sortDirection === "asc" ? "desc" : "asc"); + } else { + setSortColumn(columnIndex); + setSortDirection("asc"); + } + }; + + if (parsedData.errors.length > 0 && parsedData.headers.length === 0) { + return ( +
+

+ CSV Parsing Error +

+

+ Failed to parse CSV file. Please check the file format. +

+ {parsedData.errors.slice(0, 3).map((error, index) => ( +

+ Line {error.row}: {error.message} +

+ ))} +
+ ); + } + + if (parsedData.headers.length === 0) { + return ( +
+

+ No data found in CSV file. +

+
+ ); + } + + return ( +
+ {/* CSV Info & Search */} +
+
+ {parsedData.rows.length} rows × {parsedData.headers.length} columns + {filteredAndSortedRows.length !== parsedData.rows.length && ( + + (showing {filteredAndSortedRows.length} filtered) + + )} +
+
+ + setSearchTerm(e.target.value)} + className="pl-8" + /> +
+
+ + {/* Large File Warning */} + {isLargeFile && !isOversized && ( +
+

+ ⚠️ Large file detected ({(contentSize / (1024 * 1024)).toFixed(2)} + MB). Performance may be slower than usual. +

+
+ )} + + {/* Parsing Warnings */} + {parsedData.errors.length > 0 && ( +
+

+ ⚠️ {parsedData.errors.length} parsing warning(s) - data may be + incomplete +

+
+ )} + + {/* CSV Table */} +
+
+ + + + {parsedData.headers.map((header, index) => ( + handleSort(index)} + > +
+
+ {header || `Column ${index + 1}`} +
+ {sortColumn === index && ( +
+ {sortDirection === "asc" ? ( + + ) : ( + + )} +
+ )} +
+
+ ))} +
+
+ + {filteredAndSortedRows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + +
+ {cell || ""} +
+
+ ))} + {/* Fill empty cells if row is shorter than headers */} + {Array.from({ + length: Math.max(0, parsedData.headers.length - row.length), + }).map((_, emptyIndex) => ( + + — + + ))} +
+ ))} +
+
+
+
+ + {/* Table Stats */} +
+ {filteredAndSortedRows.length === 0 && searchTerm && ( +

No rows match your search criteria.

+ )} + {sortColumn !== null && ( +

+ Sorted by column " + {parsedData.headers[sortColumn] || `Column ${sortColumn + 1}`}" + ({sortDirection}ending) +

+ )} + {parsedData.headers.length > 4 && ( +

+ 💡 Scroll horizontally to view all {parsedData.headers.length}{" "} + columns +

+ )} +
+
+ ); +} diff --git a/src/llama_stack_ui/components/files/file-detail.tsx b/src/llama_stack_ui/components/files/file-detail.tsx new file mode 100644 index 000000000..84f99ac96 --- /dev/null +++ b/src/llama_stack_ui/components/files/file-detail.tsx @@ -0,0 +1,628 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Download, Trash2, ArrowLeft, FileText } from "lucide-react"; +import { useAuthClient } from "@/hooks/use-auth-client"; +import { FileResource } from "@/lib/types"; +import { + formatFileSize, + getFileTypeIcon, + formatTimestamp, + formatPurpose, + isTextFile, +} from "@/lib/file-utils"; +import { + DetailLoadingView, + DetailErrorView, + DetailNotFoundView, + DetailLayout, + PropertiesCard, + PropertyItem, +} from "@/components/layout/detail-layout"; +import { CopyButton } from "@/components/ui/copy-button"; +import { CSVViewer } from "./csv-viewer"; +import { JsonViewer } from "./json-viewer"; + +// Content size limits +const MAX_TEXT_PREVIEW_SIZE = 50 * 1024 * 1024; // 50MB for text files +const WARN_TEXT_PREVIEW_SIZE = 10 * 1024 * 1024; // 10MB warning threshold + +export function FileDetail() { + const params = useParams(); + const router = useRouter(); + const client = useAuthClient(); + const fileId = params.id as string; + + const [file, setFile] = useState(null); + const [fileContent, setFileContent] = useState(null); + const [fileContentUrl, setFileContentUrl] = useState(null); + const [loading, setLoading] = useState(true); + const [contentLoading, setContentLoading] = useState(false); + const [error, setError] = useState(null); + const [contentError, setContentError] = useState(null); + const [sizeWarning, setSizeWarning] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + useEffect(() => { + if (!fileId) return; + + const fetchFile = async () => { + try { + setLoading(true); + setError(null); + + const response = await client.files.retrieve(fileId); + setFile(response as FileResource); + } catch (err) { + console.error("Failed to fetch file:", err); + setError( + err instanceof Error ? err : new Error("Failed to fetch file") + ); + } finally { + setLoading(false); + } + }; + + fetchFile(); + }, [fileId, client]); + + // Cleanup blob URL when component unmounts or content changes + useEffect(() => { + return () => { + if (fileContentUrl) { + URL.revokeObjectURL(fileContentUrl); + } + }; + }, [fileContentUrl]); + + const handleLoadContent = async () => { + if (!file) return; + + try { + setContentLoading(true); + setContentError(null); // Clear any previous errors + setSizeWarning(null); // Clear any previous size warnings + + // Check file size before processing + if (file.bytes > MAX_TEXT_PREVIEW_SIZE) { + setContentError( + `File is too large to preview (${formatFileSize(file.bytes)}). Maximum supported size is ${formatFileSize(MAX_TEXT_PREVIEW_SIZE)}.` + ); + return; + } + + if (file.bytes > WARN_TEXT_PREVIEW_SIZE) { + setSizeWarning( + `Large file detected (${formatFileSize(file.bytes)}). Loading may take longer than usual.` + ); + } + + // Clean up existing blob URL + if (fileContentUrl) { + URL.revokeObjectURL(fileContentUrl); + setFileContentUrl(null); + } + + // Determine MIME type from file extension + const extension = file.filename.split(".").pop()?.toLowerCase(); + let mimeType = "application/octet-stream"; // Default + + switch (extension) { + case "pdf": + mimeType = "application/pdf"; + break; + case "txt": + mimeType = "text/plain"; + break; + case "md": + mimeType = "text/markdown"; + break; + case "html": + mimeType = "text/html"; + break; + case "csv": + mimeType = "text/csv"; + break; + case "json": + mimeType = "application/json"; + break; + case "docx": + mimeType = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + break; + case "doc": + mimeType = "application/msword"; + break; + } + + // For binary files (PDF, Word, images), fetch directly to avoid client parsing + const isBinaryFile = [ + "pdf", + "docx", + "doc", + "png", + "jpg", + "jpeg", + "gif", + "bmp", + "webp", + ].includes(extension || ""); + + let blob: Blob; + let textContent: string | null = null; + + // TODO: Use llama stack client consistently for all file types + // Currently using direct fetch for binary files to ensure proper rendering + if (isBinaryFile) { + // For binary files, use direct fetch to preserve binary integrity + const contentResponse = await fetch(`/api/v1/files/${fileId}/content`); + if (!contentResponse.ok) { + throw new Error( + `Failed to fetch content: ${contentResponse.status} ${contentResponse.statusText}` + ); + } + const arrayBuffer = await contentResponse.arrayBuffer(); + blob = new Blob([arrayBuffer], { type: mimeType }); + } else { + // Use llama stack client for text content + const response = await client.files.content(fileId); + + if (typeof response === "string") { + blob = new Blob([response], { type: mimeType }); + textContent = response; + } else if (response instanceof Blob) { + blob = response; + } else if (response instanceof ArrayBuffer) { + blob = new Blob([response], { type: mimeType }); + } else { + // Handle other response types (convert to JSON) + const jsonString = JSON.stringify(response, null, 2); + blob = new Blob([jsonString], { type: "application/json" }); + textContent = jsonString; + } + } + + const blobUrl = URL.createObjectURL(blob); + setFileContentUrl(blobUrl); + + // Keep text content for copy functionality and CSV/JSON viewers + if ( + textContent && + (isTextFile(mimeType) || extension === "csv" || extension === "json") + ) { + setFileContent(textContent); + } + } catch (err) { + console.error("Failed to load file content:", err); + + // Clean up any partially created blob URL on error + if (fileContentUrl) { + URL.revokeObjectURL(fileContentUrl); + setFileContentUrl(null); + } + + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + setContentError( + `Failed to load file content: ${errorMessage}. Please try again or check if the file still exists.` + ); + } finally { + setContentLoading(false); + } + }; + + const handleDownload = async () => { + if (!file) return; + + try { + // Determine MIME type from file extension + const extension = file.filename.split(".").pop()?.toLowerCase(); + let mimeType = "application/octet-stream"; + + switch (extension) { + case "pdf": + mimeType = "application/pdf"; + break; + case "txt": + mimeType = "text/plain"; + break; + case "md": + mimeType = "text/markdown"; + break; + case "html": + mimeType = "text/html"; + break; + case "csv": + mimeType = "text/csv"; + break; + case "json": + mimeType = "application/json"; + break; + case "docx": + mimeType = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + break; + case "doc": + mimeType = "application/msword"; + break; + } + + // For binary files (PDF, Word, images), detect to handle properly + const isBinaryFile = [ + "pdf", + "docx", + "doc", + "png", + "jpg", + "jpeg", + "gif", + "bmp", + "webp", + ].includes(extension || ""); + + let downloadUrl: string; + + // TODO: Use llama stack client consistently for all file types + // Currently using direct fetch for binary files to ensure proper downloading + if (isBinaryFile) { + // For binary files, use direct fetch to preserve binary integrity + const contentResponse = await fetch(`/api/v1/files/${fileId}/content`); + if (!contentResponse.ok) { + throw new Error( + `Failed to fetch content: ${contentResponse.status} ${contentResponse.statusText}` + ); + } + const arrayBuffer = await contentResponse.arrayBuffer(); + const blob = new Blob([arrayBuffer], { type: mimeType }); + downloadUrl = URL.createObjectURL(blob); + } else { + // Use llama stack client for text content + const response = await client.files.content(fileId); + + if (typeof response === "string") { + const blob = new Blob([response], { type: mimeType }); + downloadUrl = URL.createObjectURL(blob); + } else if (response instanceof Blob) { + downloadUrl = URL.createObjectURL(response); + } else { + const blob = new Blob([JSON.stringify(response, null, 2)], { + type: "application/json", + }); + downloadUrl = URL.createObjectURL(blob); + } + } + + // Trigger download + const link = document.createElement("a"); + link.href = downloadUrl; + link.download = file.filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(downloadUrl); + } catch (err) { + console.error("Failed to download file:", err); + setContentError("Failed to download file. Please try again."); + } + }; + + const handleDelete = async () => { + if (!file) return; + + if ( + !confirm( + `Are you sure you want to delete "${file.filename}"? This action cannot be undone.` + ) + ) { + return; + } + + try { + setIsDeleting(true); + await client.files.delete(fileId); + router.push("/logs/files"); + } catch (err) { + console.error("Failed to delete file:", err); + setContentError("Failed to delete file. Please try again."); + } finally { + setIsDeleting(false); + } + }; + + if (loading) { + return ; + } + + if (error) { + return ; + } + + if (!file) { + return ; + } + + const isExpired = file.expires_at && file.expires_at * 1000 < Date.now(); + const fileExtension = file.filename.split(".").pop()?.toLowerCase(); + const fileIcon = getFileTypeIcon(fileExtension); + const isCSVFile = fileExtension === "csv"; + const isJsonFile = fileExtension === "json"; + + // Security: File type whitelist for preview + // In local development, be permissive but still maintain reasonable security + const SAFE_PREVIEW_EXTENSIONS = [ + "txt", + "plain", + "csv", + "json", + "pdf", + "html", + "htm", + "docx", + "doc", + "md", + "markdown", + "xml", + "log", + ]; + const canPreview = + !fileExtension || SAFE_PREVIEW_EXTENSIONS.includes(fileExtension); + + const mainContent = ( +
+ {/* File Header */} + + +
+
+
{fileIcon}
+
+ {file.filename} +
+ {formatFileSize(file.bytes)} + + + {file.filename.split(".").pop()?.toUpperCase() || "Unknown"} + + + {formatPurpose(file.purpose)} +
+
+
+
+ + +
+
+
+
+ + {/* File Content Preview */} + {canPreview && ( + + +
+ + + Content Preview + + {!fileContentUrl && ( + + )} + {fileContentUrl && ( +
+ + +
+ )} +
+
+ {contentError && ( + +
+
+
+

+ Content Error +

+

+ {contentError} +

+
+ +
+
+
+ )} + {sizeWarning && ( + +
+
+
+

+ Large File Warning +

+

+ {sizeWarning} +

+
+ +
+
+
+ )} + {fileContentUrl && ( + +
+ {isCSVFile && fileContent ? ( + // CSV files: Use custom CSV viewer + + ) : isJsonFile && fileContent ? ( + // JSON files: Use custom JSON viewer + + ) : ( + // Other files: Use iframe preview +
+ {fileContent && ( +
+ +
+ )} +