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)}
+
+
+
+
+
+
+ Download
+
+
+
+ {isDeleting ? "Deleting..." : "Delete"}
+
+
+
+
+
+
+ {/* File Content Preview */}
+ {canPreview && (
+
+
+
+
+
+ Content Preview
+
+ {!fileContentUrl && (
+
+ {contentLoading ? "Loading..." : "Load Content"}
+
+ )}
+ {fileContentUrl && (
+
+ window.open(fileContentUrl, "_blank")}
+ >
+ Open in New Tab
+
+ {
+ const link = document.createElement("a");
+ link.href = fileContentUrl;
+ link.download = file.filename;
+ link.click();
+ }}
+ >
+ Download
+
+
+ )}
+
+
+ {contentError && (
+
+
+
+
+
+ Content Error
+
+
+ {contentError}
+
+
+
setContentError(null)}
+ className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
+ >
+ Dismiss
+
+
+
+
+ )}
+ {sizeWarning && (
+
+
+
+
+
+ Large File Warning
+
+
+ {sizeWarning}
+
+
+
setSizeWarning(null)}
+ className="text-orange-600 hover:text-orange-800 dark:text-orange-400 dark:hover:text-orange-300"
+ >
+ Dismiss
+
+
+
+
+ )}
+ {fileContentUrl && (
+
+
+ {isCSVFile && fileContent ? (
+ // CSV files: Use custom CSV viewer
+
+ ) : isJsonFile && fileContent ? (
+ // JSON files: Use custom JSON viewer
+
+ ) : (
+ // Other files: Use iframe preview
+
+ {fileContent && (
+
+
+
+ )}
+
+ )}
+
+
+ )}
+
+ )}
+
+ {/* Additional Information */}
+
+
+ File Information
+
+
+
+
File ID:
+
+
+ {file.id}
+
+
+
+
+
+ {file.expires_at && (
+
+
Status:
+
+
+ {isExpired ? "Expired" : "Active"}
+
+
+
+ )}
+
+
+
+ );
+
+ const sidebar = (
+
+ {/* Navigation */}
+
+
+ router.push("/logs/files")}
+ className="w-full justify-start p-0"
+ >
+
+ Back to Files
+
+
+
+
+ {/* Properties */}
+
+
+
+
+
+
+ {file.expires_at && (
+
+ {formatTimestamp(file.expires_at)}
+
+ }
+ />
+ )}
+
+
+ );
+
+ return (
+
+ );
+}
diff --git a/src/llama_stack_ui/components/files/file-editor.tsx b/src/llama_stack_ui/components/files/file-editor.tsx
new file mode 100644
index 000000000..24eb2fe44
--- /dev/null
+++ b/src/llama_stack_ui/components/files/file-editor.tsx
@@ -0,0 +1,293 @@
+"use client";
+
+import React, { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useAuthClient } from "@/hooks/use-auth-client";
+import { FileUploadZone } from "./file-upload-zone";
+import { FileUploadFormData, DEFAULT_EXPIRES_AFTER } from "@/lib/types";
+import {
+ validateUploadParams,
+ formatValidationErrors,
+ formatValidationWarnings,
+} from "@/lib/file-validation";
+import { getPurposeDescription } from "@/lib/file-utils";
+import { toFile } from "llama-stack-client";
+
+interface FileEditorProps {
+ onUploadSuccess: () => void;
+ onCancel: () => void;
+ error?: string | null;
+ showSuccessState?: boolean;
+}
+
+export function FileEditor({
+ onUploadSuccess,
+ onCancel,
+ error,
+ showSuccessState,
+}: FileEditorProps) {
+ const client = useAuthClient();
+ const [selectedFiles, setSelectedFiles] = useState([]);
+ const [isUploading, setIsUploading] = useState(false);
+ const [uploadProgress, setUploadProgress] = useState>(
+ {}
+ );
+
+ const [formData, setFormData] = useState({
+ purpose: "assistants",
+ expiresAfter: DEFAULT_EXPIRES_AFTER,
+ });
+
+ const handleFilesSelected = (files: File[]) => {
+ setSelectedFiles(prev => [...prev, ...files]);
+ };
+
+ const handleRemoveFile = (index: number) => {
+ setSelectedFiles(prev => prev.filter((_, i) => i !== index));
+ };
+
+ const handleUpload = async () => {
+ // Comprehensive validation
+ const validation = validateUploadParams(
+ selectedFiles,
+ formData.purpose,
+ formData.expiresAfter
+ );
+
+ if (!validation.isValid) {
+ alert(formatValidationErrors(validation.errors));
+ return;
+ }
+
+ // Show warnings if any
+ if (validation.warnings.length > 0) {
+ const proceed = confirm(
+ `${formatValidationWarnings(validation.warnings)}\n\nDo you want to continue with the upload?`
+ );
+ if (!proceed) return;
+ }
+
+ setIsUploading(true);
+ setUploadProgress({});
+
+ try {
+ // Upload files sequentially to show individual progress
+ for (let i = 0; i < selectedFiles.length; i++) {
+ const file = selectedFiles[i];
+ const progressKey = `${file.name}-${i}`;
+
+ setUploadProgress(prev => ({ ...prev, [progressKey]: 0 }));
+
+ // Prepare upload parameters
+ const uploadParams: {
+ file: Awaited>;
+ purpose: typeof formData.purpose;
+ expires_after?: { anchor: "created_at"; seconds: number };
+ } = {
+ file: await toFile(file, file.name),
+ purpose: formData.purpose,
+ };
+
+ // Add expiration if specified
+ if (formData.expiresAfter && formData.expiresAfter > 0) {
+ uploadParams.expires_after = {
+ anchor: "created_at",
+ seconds: formData.expiresAfter,
+ };
+ }
+
+ // Simulate progress (since we don't have real progress from the API)
+ const progressInterval = setInterval(() => {
+ setUploadProgress(prev => {
+ const current = prev[progressKey] || 0;
+ if (current < 90) {
+ return { ...prev, [progressKey]: current + 10 };
+ }
+ return prev;
+ });
+ }, 100);
+
+ // Perform upload
+ await client.files.create(uploadParams);
+
+ // Complete progress
+ clearInterval(progressInterval);
+ setUploadProgress(prev => ({ ...prev, [progressKey]: 100 }));
+ }
+
+ onUploadSuccess();
+ } catch (err: unknown) {
+ console.error("Failed to upload files:", err);
+ const errorMessage =
+ err instanceof Error ? err.message : "Failed to upload files";
+ alert(`Upload failed: ${errorMessage}`);
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ const formatExpiresAfter = (seconds: number): string => {
+ if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes`;
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours`;
+ return `${Math.floor(seconds / 86400)} days`;
+ };
+
+ const canUpload = selectedFiles.length > 0 && !isUploading;
+
+ return (
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {!showSuccessState && (
+ <>
+ {/* File Upload Zone */}
+
+
+ Select Files
+
+
+
+
+ {/* Upload Configuration */}
+
+ {/* Purpose Selection */}
+
+
+ Purpose
+
+
setFormData(prev => ({ ...prev, purpose: value }))}
+ disabled={isUploading}
+ >
+
+
+
+
+ Fine-tuning
+ Assistants
+ User Data
+ Batch Processing
+ Vision
+ Evaluations
+
+
+
+ {getPurposeDescription(formData.purpose)}
+
+
+
+ {/* Expiration */}
+
+
+ Expires After
+
+
+ setFormData(prev => ({
+ ...prev,
+ expiresAfter: parseInt(value) || undefined,
+ }))
+ }
+ disabled={isUploading}
+ >
+
+
+
+
+ Never
+ 1 hour
+ 1 day
+ 7 days
+ 30 days
+
+
+
+ {formData.expiresAfter && formData.expiresAfter > 0
+ ? `Files will be automatically deleted after ${formatExpiresAfter(formData.expiresAfter)}`
+ : "Files will not expire automatically"}
+
+
+
+
+ {/* Upload Progress */}
+ {isUploading && Object.keys(uploadProgress).length > 0 && (
+
+
Upload Progress
+ {selectedFiles.map((file, index) => {
+ const progressKey = `${file.name}-${index}`;
+ const progress = uploadProgress[progressKey] || 0;
+
+ return (
+
+
+ {file.name}
+ {progress}%
+
+
+
+ );
+ })}
+
+ )}
+ >
+ )}
+
+ {/* Action Buttons */}
+
+
+ {showSuccessState ? "Close" : "Cancel"}
+
+ {!showSuccessState && (
+
+ {isUploading
+ ? "Uploading..."
+ : `Upload ${selectedFiles.length} File${selectedFiles.length !== 1 ? "s" : ""}`}
+
+ )}
+
+
+ );
+}
diff --git a/src/llama_stack_ui/components/files/file-upload-zone.test.tsx b/src/llama_stack_ui/components/files/file-upload-zone.test.tsx
new file mode 100644
index 000000000..8a1f17858
--- /dev/null
+++ b/src/llama_stack_ui/components/files/file-upload-zone.test.tsx
@@ -0,0 +1,288 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { FileUploadZone } from "./file-upload-zone";
+import {
+ validateFileForUpload,
+ detectPotentialCorruption,
+ formatValidationErrors,
+} from "@/lib/file-validation";
+
+// Mock file utils
+jest.mock("@/lib/file-utils", () => ({
+ formatFileSize: jest.fn(bytes => `${bytes} B`),
+ getFileTypeIcon: jest.fn(() => "📄"),
+}));
+
+// Mock file validation
+jest.mock("@/lib/file-validation", () => ({
+ validateFileForUpload: jest.fn(),
+ detectPotentialCorruption: jest.fn(),
+ formatValidationErrors: jest.fn(),
+}));
+
+// Mock window.alert and confirm
+const originalAlert = window.alert;
+const originalConfirm = window.confirm;
+
+describe("FileUploadZone", () => {
+ const mockOnFilesSelected = jest.fn();
+ const mockOnRemoveFile = jest.fn();
+ const selectedFiles = [
+ new File(["content"], "test.txt", { type: "text/plain" }),
+ ];
+
+ const defaultProps = {
+ onFilesSelected: mockOnFilesSelected,
+ selectedFiles: [],
+ onRemoveFile: mockOnRemoveFile,
+ maxFiles: 10,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ window.alert = jest.fn();
+ window.confirm = jest.fn(() => true);
+ (validateFileForUpload as jest.Mock).mockReturnValue({
+ isValid: true,
+ errors: [],
+ warnings: [],
+ });
+ (detectPotentialCorruption as jest.Mock).mockReturnValue([]);
+ (formatValidationErrors as jest.Mock).mockReturnValue("");
+ });
+
+ afterEach(() => {
+ window.alert = originalAlert;
+ window.confirm = originalConfirm;
+ });
+
+ describe("Drag and Drop", () => {
+ test("updates drag state on drag enter", () => {
+ render( );
+
+ const dropzone = screen.getByText("Click to upload or drag and drop");
+ const dragZoneDiv = dropzone.closest('[class*="border-2"]');
+
+ fireEvent.dragEnter(dragZoneDiv!, {
+ dataTransfer: { files: [] },
+ });
+
+ // Should show drag over state
+ expect(dragZoneDiv).toHaveClass("border-blue-500");
+ });
+
+ test("handles file drop correctly", () => {
+ const files = [
+ new File(["content"], "test.pdf", { type: "application/pdf" }),
+ ];
+
+ render( );
+
+ const dropzone = screen.getByText("Click to upload or drag and drop");
+
+ fireEvent.drop(dropzone, {
+ dataTransfer: { files },
+ });
+
+ expect(mockOnFilesSelected).toHaveBeenCalledWith([files[0]]);
+ });
+
+ test("prevents drop when disabled", () => {
+ render( );
+
+ const dropzone = screen.getByText("Click to upload or drag and drop");
+
+ fireEvent.drop(dropzone, {
+ dataTransfer: {
+ files: [new File(["content"], "test.txt", { type: "text/plain" })],
+ },
+ });
+
+ expect(mockOnFilesSelected).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("File Validation", () => {
+ test("shows validation errors for invalid files", () => {
+ (validateFileForUpload as jest.Mock).mockReturnValue({
+ isValid: false,
+ errors: [{ field: "size", message: "File too large" }],
+ warnings: [],
+ });
+ (formatValidationErrors as jest.Mock).mockReturnValue("File too large");
+
+ const file = new File(["content"], "large.pdf", {
+ type: "application/pdf",
+ });
+ // Mock large size
+ Object.defineProperty(file, "size", {
+ value: 200 * 1024 * 1024,
+ writable: false,
+ });
+ const files = [file];
+
+ render( );
+
+ const dropzone = screen.getByText("Click to upload or drag and drop");
+
+ fireEvent.drop(dropzone, {
+ dataTransfer: { files },
+ });
+
+ expect(window.alert).toHaveBeenCalledWith(
+ expect.stringContaining("File too large")
+ );
+ expect(mockOnFilesSelected).not.toHaveBeenCalled();
+ });
+
+ test("shows warnings but allows upload", () => {
+ (validateFileForUpload as jest.Mock).mockReturnValue({
+ isValid: true,
+ errors: [],
+ warnings: ["Large file warning"],
+ });
+
+ const files = [
+ new File(["content"], "test.pdf", { type: "application/pdf" }),
+ ];
+
+ render( );
+
+ const dropzone = screen.getByText("Click to upload or drag and drop");
+
+ fireEvent.drop(dropzone, {
+ dataTransfer: { files },
+ });
+
+ expect(window.confirm).toHaveBeenCalled();
+ expect(mockOnFilesSelected).toHaveBeenCalledWith([files[0]]);
+ });
+
+ test("prevents duplicate files", () => {
+ const existingFile = new File(["content"], "test.txt", {
+ type: "text/plain",
+ });
+ const duplicateFile = new File(["content"], "test.txt", {
+ type: "text/plain",
+ });
+
+ render(
+
+ );
+
+ const dropzone = screen.getByText("Click to upload or drag and drop");
+
+ fireEvent.drop(dropzone, {
+ dataTransfer: { files: [duplicateFile] },
+ });
+
+ expect(window.alert).toHaveBeenCalledWith(
+ expect.stringContaining('"test.txt" is already selected')
+ );
+ expect(mockOnFilesSelected).not.toHaveBeenCalled();
+ });
+
+ test("enforces max files limit", () => {
+ const existingFiles = Array.from(
+ { length: 9 },
+ (_, i) => new File(["content"], `file${i}.txt`, { type: "text/plain" })
+ );
+
+ const newFiles = [
+ new File(["content"], "file10.txt", { type: "text/plain" }),
+ new File(["content"], "file11.txt", { type: "text/plain" }),
+ ];
+
+ render(
+
+ );
+
+ const dropzone = screen.getByText("Click to upload or drag and drop");
+
+ fireEvent.drop(dropzone, {
+ dataTransfer: { files: newFiles },
+ });
+
+ expect(window.alert).toHaveBeenCalledWith(
+ expect.stringContaining("You can only upload 1 more file(s)")
+ );
+ expect(mockOnFilesSelected).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("Selected Files Display", () => {
+ test("displays selected files with remove buttons", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("test.txt")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "" })).toBeInTheDocument();
+ });
+
+ test("calls onRemoveFile when remove button clicked", () => {
+ render(
+
+ );
+
+ const removeButton = screen.getByRole("button", { name: "" });
+ fireEvent.click(removeButton);
+
+ expect(mockOnRemoveFile).toHaveBeenCalledWith(0);
+ });
+ });
+
+ describe("File Selection via Click", () => {
+ test("creates file input when zone clicked", () => {
+ // Mock document.createElement to track input creation
+ const mockInput = {
+ type: "",
+ multiple: false,
+ accept: "",
+ onchange: null as ((event: Event) => void) | null,
+ click: jest.fn(),
+ };
+
+ const originalCreateElement = document.createElement;
+ document.createElement = jest.fn((tagName: string) => {
+ if (tagName === "input") {
+ return mockInput as unknown as HTMLInputElement;
+ }
+ return originalCreateElement.call(document, tagName);
+ });
+
+ render( );
+
+ const clickArea = screen.getByText("Click to upload or drag and drop");
+ fireEvent.click(clickArea);
+
+ expect(document.createElement).toHaveBeenCalledWith("input");
+ expect(mockInput.type).toBe("file");
+ expect(mockInput.click).toHaveBeenCalled();
+
+ document.createElement = originalCreateElement;
+ });
+ });
+
+ describe("Disabled State", () => {
+ test("prevents interaction when disabled", () => {
+ render( );
+
+ const clickArea = screen.getByText("Click to upload or drag and drop");
+ const dragZoneDiv = clickArea.closest('[class*="border-2"]');
+
+ // Should not be clickable when disabled
+ expect(dragZoneDiv).toHaveClass("cursor-not-allowed");
+ expect(dragZoneDiv).toHaveClass("opacity-50");
+
+ fireEvent.click(clickArea);
+ // File selection should not work when disabled - hard to test the preventDefault
+ // but the component should be visually disabled
+ });
+ });
+});
diff --git a/src/llama_stack_ui/components/files/file-upload-zone.tsx b/src/llama_stack_ui/components/files/file-upload-zone.tsx
new file mode 100644
index 000000000..2cb816c08
--- /dev/null
+++ b/src/llama_stack_ui/components/files/file-upload-zone.tsx
@@ -0,0 +1,280 @@
+"use client";
+
+import React, { useState, useCallback } from "react";
+import { Button } from "@/components/ui/button";
+import { Upload, X } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { formatFileSize, getFileTypeIcon } from "@/lib/file-utils";
+import { SUPPORTED_FILE_TYPES } from "@/lib/types";
+import {
+ validateFileForUpload,
+ formatValidationErrors,
+ detectPotentialCorruption,
+} from "@/lib/file-validation";
+
+interface FileUploadZoneProps {
+ onFilesSelected: (files: File[]) => void;
+ selectedFiles: File[];
+ onRemoveFile: (index: number) => void;
+ disabled?: boolean;
+ maxFiles?: number;
+ className?: string;
+}
+
+export function FileUploadZone({
+ onFilesSelected,
+ selectedFiles,
+ onRemoveFile,
+ disabled = false,
+ maxFiles = 10,
+ className,
+}: FileUploadZoneProps) {
+ const [isDragOver, setIsDragOver] = useState(false);
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const [dragCounter, setDragCounter] = useState(0);
+
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDragCounter(prev => prev + 1);
+ setIsDragOver(true);
+ }, []);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDragCounter(prev => {
+ const newCount = prev - 1;
+ if (newCount === 0) {
+ setIsDragOver(false);
+ }
+ return newCount;
+ });
+ }, []);
+
+ const handleFiles = useCallback(
+ (files: File[]) => {
+ if (disabled) return;
+
+ // Check file limit
+ const availableSlots = maxFiles - selectedFiles.length;
+ if (files.length > availableSlots) {
+ alert(
+ `You can only upload ${availableSlots} more file(s). Maximum ${maxFiles} files allowed.`
+ );
+ return;
+ }
+
+ // Validate all files first
+ const validFiles: File[] = [];
+ const allErrors: string[] = [];
+ const allWarnings: string[] = [];
+
+ files.forEach(file => {
+ const validation = validateFileForUpload(file);
+ const corruptionWarnings = detectPotentialCorruption(file);
+
+ if (validation.isValid) {
+ // Check for duplicates
+ const isDuplicate = selectedFiles.some(
+ selected =>
+ selected.name === file.name && selected.size === file.size
+ );
+
+ if (isDuplicate) {
+ allErrors.push(`"${file.name}" is already selected`);
+ } else {
+ validFiles.push(file);
+
+ // Collect warnings
+ if (
+ validation.warnings.length > 0 ||
+ corruptionWarnings.length > 0
+ ) {
+ const fileWarnings = [
+ ...validation.warnings,
+ ...corruptionWarnings,
+ ];
+ allWarnings.push(`"${file.name}": ${fileWarnings.join(", ")}`);
+ }
+ }
+ } else {
+ allErrors.push(
+ `"${file.name}": ${formatValidationErrors(validation.errors)}`
+ );
+ }
+ });
+
+ // Show errors if any
+ if (allErrors.length > 0) {
+ alert(`Some files could not be added:\n\n${allErrors.join("\n")}`);
+ }
+
+ // Show warnings if any valid files have warnings
+ if (allWarnings.length > 0 && validFiles.length > 0) {
+ const proceed = confirm(
+ `Warning(s) for some files:\n\n${allWarnings.join("\n")}\n\nDo you want to continue adding these files?`
+ );
+ if (!proceed) return;
+ }
+
+ // Add valid files
+ if (validFiles.length > 0) {
+ onFilesSelected(validFiles);
+ }
+ },
+ [disabled, maxFiles, selectedFiles, onFilesSelected]
+ );
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }, []);
+
+ const handleDrop = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ setIsDragOver(false);
+ setDragCounter(0);
+
+ if (disabled) return;
+
+ const files = Array.from(e.dataTransfer.files);
+ handleFiles(files);
+ },
+ [disabled, handleFiles]
+ );
+
+ const handleFileSelect = useCallback(() => {
+ if (disabled) return;
+
+ const input = document.createElement("input");
+ input.type = "file";
+ input.multiple = maxFiles > 1;
+ input.accept = SUPPORTED_FILE_TYPES.join(",");
+
+ input.onchange = e => {
+ const target = e.target as HTMLInputElement;
+ if (target.files) {
+ const files = Array.from(target.files);
+ handleFiles(files);
+ }
+ };
+
+ input.click();
+ }, [disabled, maxFiles, handleFiles]);
+
+ const getSupportedFormats = () => {
+ return SUPPORTED_FILE_TYPES.map(type => {
+ switch (type) {
+ case "application/pdf":
+ return "PDF";
+ case "text/plain":
+ return "TXT";
+ case "text/markdown":
+ return "MD";
+ case "text/html":
+ return "HTML";
+ case "text/csv":
+ return "CSV";
+ case "application/json":
+ return "JSON";
+ case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
+ return "DOCX";
+ case "application/msword":
+ return "DOC";
+ default:
+ return type;
+ }
+ }).join(", ");
+ };
+
+ return (
+
+ {/* Upload Zone */}
+
+
+
+
+
+ {isDragOver
+ ? "Drop files here"
+ : "Click to upload or drag and drop"}
+
+
+ Supported formats: {getSupportedFormats()}
+
+
+ Max file size: {formatFileSize(100 * 1024 * 1024)} • Max {maxFiles}{" "}
+ files
+
+
+
+
+ {/* Selected Files List */}
+ {selectedFiles.length > 0 && (
+
+
+ Selected Files ({selectedFiles.length})
+
+
+ {selectedFiles.map((file, index) => {
+ const icon = getFileTypeIcon(file.name.split(".").pop());
+
+ return (
+
+
{icon}
+
+
+
+
+ {file.name}
+
+
+ {file.name.split(".").pop()?.toUpperCase()}
+
+
+
+ {formatFileSize(file.size)}
+
+
+
+
{
+ e.stopPropagation();
+ onRemoveFile(index);
+ }}
+ disabled={disabled}
+ className="h-8 w-8 p-0"
+ >
+
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/src/llama_stack_ui/components/files/files-management.test.tsx b/src/llama_stack_ui/components/files/files-management.test.tsx
new file mode 100644
index 000000000..85698feea
--- /dev/null
+++ b/src/llama_stack_ui/components/files/files-management.test.tsx
@@ -0,0 +1,265 @@
+import React from "react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { FilesManagement } from "./files-management";
+import type { FileResource } from "@/lib/types";
+
+// Mock the auth client
+const mockFilesClient = {
+ list: jest.fn(),
+ delete: jest.fn(),
+ content: jest.fn(),
+};
+
+jest.mock("@/hooks/use-auth-client", () => ({
+ useAuthClient: () => ({
+ files: mockFilesClient,
+ }),
+}));
+
+// Mock router
+const mockPush = jest.fn();
+jest.mock("next/navigation", () => ({
+ useRouter: () => ({
+ push: mockPush,
+ }),
+}));
+
+// Mock pagination hook
+const mockLoadMore = jest.fn();
+const mockRefetch = jest.fn();
+jest.mock("@/hooks/use-pagination", () => ({
+ usePagination: jest.fn(),
+}));
+
+import { usePagination } from "@/hooks/use-pagination";
+const mockedUsePagination = usePagination as jest.MockedFunction<
+ typeof usePagination
+>;
+
+// Mock file utils
+jest.mock("@/lib/file-utils", () => ({
+ formatFileSize: jest.fn(bytes => `${bytes} B`),
+ getFileTypeIcon: jest.fn(() => "📄"),
+ formatTimestamp: jest.fn(ts => `2024-01-01 ${ts}`),
+ formatPurpose: jest.fn(purpose => purpose),
+ truncateFilename: jest.fn(name => name),
+ getPurposeDescription: jest.fn(purpose => `Description for ${purpose}`),
+}));
+
+// Mock window.confirm
+const originalConfirm = window.confirm;
+
+describe("FilesManagement", () => {
+ const mockFiles: FileResource[] = [
+ {
+ id: "file_123",
+ filename: "test.pdf",
+ bytes: 1024,
+ created_at: 1640995200,
+ expires_at: 1640995200 + 86400,
+ purpose: "assistants",
+ },
+ {
+ id: "file_456",
+ filename: "document.txt",
+ bytes: 2048,
+ created_at: 1640995200,
+ expires_at: 0,
+ purpose: "user_data",
+ },
+ ];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockPush.mockClear();
+ mockLoadMore.mockClear();
+ mockRefetch.mockClear();
+ window.confirm = originalConfirm;
+ });
+
+ describe("Loading State", () => {
+ test("renders loading skeleton when status is loading", () => {
+ mockedUsePagination.mockReturnValue({
+ data: [],
+ status: "loading",
+ hasMore: false,
+ error: null,
+ loadMore: mockLoadMore,
+ refetch: mockRefetch,
+ });
+
+ const { container } = render( );
+ const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
+ expect(skeletons.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe("Empty State", () => {
+ test("renders empty state when no files", () => {
+ mockedUsePagination.mockReturnValue({
+ data: [],
+ status: "idle",
+ hasMore: false,
+ error: null,
+ loadMore: mockLoadMore,
+ refetch: mockRefetch,
+ });
+
+ render( );
+ expect(screen.getByText("No files found.")).toBeInTheDocument();
+ expect(screen.getByText("Upload Your First File")).toBeInTheDocument();
+ });
+ });
+
+ describe("Error State", () => {
+ test("renders error message when API fails", () => {
+ const error = new Error("API Error");
+ mockedUsePagination.mockReturnValue({
+ data: [],
+ status: "error",
+ hasMore: false,
+ error,
+ loadMore: mockLoadMore,
+ refetch: mockRefetch,
+ });
+
+ render( );
+ expect(screen.getByText(/Error: API Error/)).toBeInTheDocument();
+ });
+ });
+
+ describe("Files List", () => {
+ beforeEach(() => {
+ mockedUsePagination.mockReturnValue({
+ data: mockFiles,
+ status: "idle",
+ hasMore: false,
+ error: null,
+ loadMore: mockLoadMore,
+ refetch: mockRefetch,
+ });
+ });
+
+ test("renders files table with correct data", () => {
+ render( );
+
+ expect(screen.getByText("file_123")).toBeInTheDocument();
+ expect(screen.getByText("test.pdf")).toBeInTheDocument();
+ expect(screen.getByText("file_456")).toBeInTheDocument();
+ expect(screen.getByText("document.txt")).toBeInTheDocument();
+ });
+
+ test("filters files by search term", () => {
+ render( );
+
+ const searchInput = screen.getByPlaceholderText("Search files...");
+ fireEvent.change(searchInput, { target: { value: "test" } });
+
+ expect(screen.getByText("test.pdf")).toBeInTheDocument();
+ expect(screen.queryByText("document.txt")).not.toBeInTheDocument();
+ });
+
+ test("navigates to file detail on row click", () => {
+ render( );
+
+ const row = screen.getByText("test.pdf").closest("tr");
+ fireEvent.click(row!);
+
+ expect(mockPush).toHaveBeenCalledWith("/logs/files/file_123");
+ });
+ });
+
+ describe("File Operations", () => {
+ beforeEach(() => {
+ mockedUsePagination.mockReturnValue({
+ data: mockFiles,
+ status: "idle",
+ hasMore: false,
+ error: null,
+ loadMore: mockLoadMore,
+ refetch: mockRefetch,
+ });
+ });
+
+ test("deletes file with confirmation", async () => {
+ window.confirm = jest.fn(() => true);
+ mockFilesClient.delete.mockResolvedValue({ deleted: true });
+
+ render( );
+
+ const deleteButtons = screen.getAllByTitle("Delete file");
+ fireEvent.click(deleteButtons[0]);
+
+ expect(window.confirm).toHaveBeenCalledWith(
+ "Are you sure you want to delete this file? This action cannot be undone."
+ );
+
+ await waitFor(() => {
+ expect(mockFilesClient.delete).toHaveBeenCalledWith("file_123");
+ });
+
+ await waitFor(() => {
+ expect(mockRefetch).toHaveBeenCalled();
+ });
+ });
+
+ test("cancels delete when user declines confirmation", () => {
+ window.confirm = jest.fn(() => false);
+
+ render( );
+
+ const deleteButtons = screen.getAllByTitle("Delete file");
+ fireEvent.click(deleteButtons[0]);
+
+ expect(window.confirm).toHaveBeenCalled();
+ expect(mockFilesClient.delete).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("Upload Modal", () => {
+ test("opens upload modal when upload button clicked", () => {
+ mockedUsePagination.mockReturnValue({
+ data: mockFiles,
+ status: "idle",
+ hasMore: false,
+ error: null,
+ loadMore: mockLoadMore,
+ refetch: mockRefetch,
+ });
+
+ render( );
+
+ const uploadButton = screen.getByText("Upload File");
+ fireEvent.click(uploadButton);
+
+ expect(
+ screen.getByRole("heading", { name: "Upload File" })
+ ).toBeInTheDocument();
+ expect(screen.getByText("Select Files")).toBeInTheDocument();
+ });
+
+ test("closes modal on ESC key", () => {
+ mockedUsePagination.mockReturnValue({
+ data: [],
+ status: "idle",
+ hasMore: false,
+ error: null,
+ loadMore: mockLoadMore,
+ refetch: mockRefetch,
+ });
+
+ render( );
+
+ // Open modal first
+ const uploadButton = screen.getByText("Upload Your First File");
+ fireEvent.click(uploadButton);
+
+ // Press ESC key
+ fireEvent.keyDown(document, { key: "Escape" });
+
+ // Modal should be closed - the upload form shouldn't be visible
+ expect(screen.queryByText("Select Files")).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/llama_stack_ui/components/files/files-management.tsx b/src/llama_stack_ui/components/files/files-management.tsx
new file mode 100644
index 000000000..6e47e7a87
--- /dev/null
+++ b/src/llama_stack_ui/components/files/files-management.tsx
@@ -0,0 +1,417 @@
+"use client";
+
+import React from "react";
+import type { ListFilesResponse, FileResource } from "@/lib/types";
+import { useRouter } from "next/navigation";
+import { usePagination } from "@/hooks/use-pagination";
+import { Button } from "@/components/ui/button";
+import { Plus, Trash2, Search, Download, X } from "lucide-react";
+import { useState } from "react";
+import { Input } from "@/components/ui/input";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useAuthClient } from "@/hooks/use-auth-client";
+import { FileEditor } from "./file-editor";
+import {
+ formatFileSize,
+ getFileTypeIcon,
+ formatTimestamp,
+ formatPurpose,
+ truncateFilename,
+} from "@/lib/file-utils";
+
+export function FilesManagement() {
+ const router = useRouter();
+ const client = useAuthClient();
+ const [deletingFiles, setDeletingFiles] = useState>(new Set());
+ const [searchTerm, setSearchTerm] = useState("");
+ const [showUploadModal, setShowUploadModal] = useState(false);
+ const [modalError, setModalError] = useState(null);
+ const [showSuccessState, setShowSuccessState] = useState(false);
+
+ const {
+ data: files,
+ status,
+ hasMore,
+ error,
+ loadMore,
+ refetch,
+ } = usePagination({
+ limit: 20,
+ order: "desc",
+ fetchFunction: async (client, params) => {
+ const response = await client.files.list({
+ after: params.after,
+ limit: params.limit,
+ order: params.order,
+ });
+ return response as ListFilesResponse;
+ },
+ errorMessagePrefix: "files",
+ });
+
+ // Auto-load all pages for infinite scroll behavior (like other features)
+ React.useEffect(() => {
+ if (status === "idle" && hasMore) {
+ loadMore();
+ }
+ }, [status, hasMore, loadMore]);
+
+ // Handle ESC key to close modal
+ React.useEffect(() => {
+ const handleEscape = (event: KeyboardEvent) => {
+ if (event.key === "Escape" && showUploadModal) {
+ handleCancel();
+ }
+ };
+
+ document.addEventListener("keydown", handleEscape);
+ return () => document.removeEventListener("keydown", handleEscape);
+ }, [showUploadModal]);
+
+ const handleDeleteFile = async (fileId: string) => {
+ if (
+ !confirm(
+ "Are you sure you want to delete this file? This action cannot be undone."
+ )
+ ) {
+ return;
+ }
+
+ setDeletingFiles(prev => new Set([...prev, fileId]));
+
+ try {
+ await client.files.delete(fileId);
+ // Refresh the data to reflect the deletion
+ refetch();
+ } catch (err: unknown) {
+ console.error("Failed to delete file:", err);
+ const errorMessage = err instanceof Error ? err.message : "Unknown error";
+ alert(`Failed to delete file: ${errorMessage}`);
+ } finally {
+ setDeletingFiles(prev => {
+ const newSet = new Set(prev);
+ newSet.delete(fileId);
+ return newSet;
+ });
+ }
+ };
+
+ const handleDownloadFile = async (fileId: string, filename: string) => {
+ try {
+ // Show loading state (could be expanded with UI feedback)
+ console.log(`Starting download for file: ${filename}`);
+
+ const response = await client.files.content(fileId);
+
+ // Create download link
+ let downloadUrl: string;
+ let mimeType = "application/octet-stream";
+
+ // Determine MIME type from file extension
+ const extension = filename.split(".").pop()?.toLowerCase();
+ 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;
+ }
+
+ 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 {
+ // Handle other response types by converting to JSON string
+ 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 = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ // Clean up
+ setTimeout(() => {
+ URL.revokeObjectURL(downloadUrl);
+ }, 1000);
+
+ console.log(`Download completed for file: ${filename}`);
+ } catch (err: unknown) {
+ console.error("Failed to download file:", err);
+ let errorMessage = "Unknown error occurred";
+
+ if (err instanceof Error) {
+ errorMessage = err.message;
+
+ // Provide more specific error messages
+ if (err.message.includes("404") || err.message.includes("not found")) {
+ errorMessage = "File not found. It may have been deleted or moved.";
+ } else if (
+ err.message.includes("403") ||
+ err.message.includes("forbidden")
+ ) {
+ errorMessage =
+ "Access denied. You may not have permission to download this file.";
+ } else if (
+ err.message.includes("network") ||
+ err.message.includes("fetch")
+ ) {
+ errorMessage =
+ "Network error. Please check your connection and try again.";
+ }
+ }
+
+ alert(`Failed to download "${filename}": ${errorMessage}`);
+ }
+ };
+
+ const handleUploadSuccess = () => {
+ // Show success message and refresh data
+ setShowSuccessState(true);
+ setModalError(
+ "✅ Upload successful! File list updated. You can now close this modal."
+ );
+ refetch();
+ };
+
+ const handleCancel = () => {
+ setShowUploadModal(false);
+ setModalError(null);
+ setShowSuccessState(false);
+ };
+
+ const renderContent = () => {
+ if (status === "loading") {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (status === "error") {
+ return Error: {error?.message}
;
+ }
+
+ if (!files || files.length === 0) {
+ return (
+
+
No files found.
+
setShowUploadModal(true)}>
+
+ Upload Your First File
+
+
+ );
+ }
+
+ // Filter files based on search term
+ const filteredFiles = files.filter(file => {
+ if (!searchTerm) return true;
+
+ const searchLower = searchTerm.toLowerCase();
+ return (
+ file.id.toLowerCase().includes(searchLower) ||
+ file.filename.toLowerCase().includes(searchLower) ||
+ file.purpose.toLowerCase().includes(searchLower)
+ );
+ });
+
+ return (
+
+ {/* Search Bar */}
+
+
+ setSearchTerm(e.target.value)}
+ className="pl-10"
+ />
+
+
+
+
+
+
+ ID
+ File Name
+ Type
+ Size
+ Purpose
+ Created
+ Expires
+ Actions
+
+
+
+ {filteredFiles.map(file => {
+ const fileIcon = getFileTypeIcon(
+ file.filename.split(".").pop()
+ );
+ const isExpired =
+ file.expires_at && file.expires_at * 1000 < Date.now();
+
+ return (
+ router.push(`/logs/files/${file.id}`)}
+ className="cursor-pointer hover:bg-muted/50"
+ >
+
+ router.push(`/logs/files/${file.id}`)}
+ >
+ {file.id}
+
+
+
+
+ {fileIcon}
+
+ {truncateFilename(file.filename)}
+
+
+
+
+ {file.filename.split(".").pop()?.toUpperCase() ||
+ "Unknown"}
+
+ {formatFileSize(file.bytes)}
+ {formatPurpose(file.purpose)}
+ {formatTimestamp(file.created_at)}
+
+ {file.expires_at ? (
+
+ {formatTimestamp(file.expires_at)}
+
+ ) : (
+ "Never"
+ )}
+
+
+
+ {
+ e.stopPropagation();
+ handleDownloadFile(file.id, file.filename);
+ }}
+ title="Download file"
+ >
+
+
+ {
+ e.stopPropagation();
+ handleDeleteFile(file.id);
+ }}
+ disabled={deletingFiles.has(file.id)}
+ title="Delete file"
+ >
+ {deletingFiles.has(file.id) ? (
+ "Deleting..."
+ ) : (
+ <>
+
+ >
+ )}
+
+
+
+
+ );
+ })}
+
+
+
+
+ );
+ };
+
+ return (
+
+
+
Files
+
setShowUploadModal(true)}
+ disabled={status === "loading"}
+ >
+
+ Upload File
+
+
+ {renderContent()}
+
+ {/* Upload File Modal */}
+ {showUploadModal && (
+
+
+
+
Upload File
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/llama_stack_ui/components/files/json-viewer.tsx b/src/llama_stack_ui/components/files/json-viewer.tsx
new file mode 100644
index 000000000..692d72fb9
--- /dev/null
+++ b/src/llama_stack_ui/components/files/json-viewer.tsx
@@ -0,0 +1,273 @@
+"use client";
+
+/* eslint-disable react/no-unescaped-entities */
+import React, { useMemo, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { ChevronDown, ChevronRight } from "lucide-react";
+
+interface JsonViewerProps {
+ content: string;
+ filename?: string;
+}
+
+type JsonValue =
+ | string
+ | number
+ | boolean
+ | null
+ | JsonValue[]
+ | { [key: string]: JsonValue };
+
+interface JsonNode {
+ key?: string;
+ value: JsonValue;
+ type: "object" | "array" | "string" | "number" | "boolean" | "null";
+ isRoot?: boolean;
+}
+
+export function JsonViewer({ content }: JsonViewerProps) {
+ const [expandedKeys, setExpandedKeys] = useState>(
+ new Set(["root"])
+ );
+
+ const parsedData = useMemo(() => {
+ try {
+ const parsed = JSON.parse(content.trim());
+ return { data: parsed, error: null };
+ } catch (error) {
+ return {
+ data: null,
+ error: error instanceof Error ? error.message : "Invalid JSON",
+ };
+ }
+ }, [content]);
+
+ const toggleExpanded = (key: string) => {
+ const newExpanded = new Set(expandedKeys);
+ if (newExpanded.has(key)) {
+ newExpanded.delete(key);
+ } else {
+ newExpanded.add(key);
+ }
+ setExpandedKeys(newExpanded);
+ };
+
+ const renderValue = (
+ node: JsonNode,
+ path: string = "root",
+ depth: number = 0
+ ): React.ReactNode => {
+ const isExpanded = expandedKeys.has(path);
+ const indent = depth * 20;
+
+ if (node.type === "object" && node.value !== null) {
+ const entries = Object.entries(node.value);
+ return (
+
+
toggleExpanded(path)}
+ >
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+ {node.key && (
+
+ "{node.key}":
+
+ )}
+
+ {`{${entries.length} ${entries.length === 1 ? "property" : "properties"}}`}
+
+
+ {isExpanded && (
+
+ {entries.map(([key, value]) =>
+ renderValue(
+ {
+ key,
+ value,
+ type: getValueType(value),
+ },
+ `${path}.${key}`,
+ depth + 1
+ )
+ )}
+
+ )}
+
+ );
+ }
+
+ if (node.type === "array") {
+ return (
+
+
toggleExpanded(path)}
+ >
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+ {node.key && (
+
+ "{node.key}":
+
+ )}
+
+ [{node.value.length} {node.value.length === 1 ? "item" : "items"}]
+
+
+ {isExpanded && (
+
+ {node.value.map((item: JsonValue, index: number) =>
+ renderValue(
+ {
+ key: index.toString(),
+ value: item,
+ type: getValueType(item),
+ },
+ `${path}[${index}]`,
+ depth + 1
+ )
+ )}
+
+ )}
+
+ );
+ }
+
+ // Primitive values
+ return (
+
+ {node.key && (
+
+ "{node.key}":
+
+ )}
+
+ {formatPrimitiveValue(node.value, node.type)}
+
+
+ );
+ };
+
+ const getValueType = (value: JsonValue): JsonNode["type"] => {
+ if (value === null) return "null";
+ if (Array.isArray(value)) return "array";
+ return typeof value as JsonNode["type"];
+ };
+
+ const getValueColor = (type: JsonNode["type"]): string => {
+ switch (type) {
+ case "string":
+ return "text-green-700 dark:text-green-300";
+ case "number":
+ return "text-purple-700 dark:text-purple-300";
+ case "boolean":
+ return "text-orange-700 dark:text-orange-300";
+ case "null":
+ return "text-gray-500 dark:text-gray-400";
+ default:
+ return "text-foreground";
+ }
+ };
+
+ const formatPrimitiveValue = (
+ value: JsonValue,
+ type: JsonNode["type"]
+ ): string => {
+ if (type === "string") return `"${value}"`;
+ if (type === "null") return "null";
+ return String(value);
+ };
+
+ if (parsedData.error) {
+ return (
+
+
+
+ JSON Parsing Error
+
+
+ Failed to parse JSON file. Please check the file format.
+
+
+ {parsedData.error}
+
+
+ {/* Show raw content as fallback */}
+
+
Raw Content:
+
+ {content}
+
+
+
+ );
+ }
+
+ return (
+
+ {/* JSON Info */}
+
+
+ JSON Document • {Object.keys(parsedData.data || {}).length} root{" "}
+ {Object.keys(parsedData.data || {}).length === 1
+ ? "property"
+ : "properties"}
+
+
+ setExpandedKeys(new Set(["root"]))}
+ >
+ Collapse All
+
+ {
+ // Expand all nodes (this is a simple version - could be more sophisticated)
+ const allKeys = new Set();
+ const addKeys = (obj: JsonValue, prefix: string = "root") => {
+ allKeys.add(prefix);
+ if (obj && typeof obj === "object") {
+ Object.keys(obj).forEach(key => {
+ addKeys(obj[key], `${prefix}.${key}`);
+ });
+ }
+ };
+ addKeys(parsedData.data);
+ setExpandedKeys(allKeys);
+ }}
+ >
+ Expand All
+
+
+
+
+ {/* JSON Tree */}
+
+
+ {renderValue({
+ value: parsedData.data,
+ type: getValueType(parsedData.data),
+ isRoot: true,
+ })}
+
+
+
+ );
+}
diff --git a/src/llama_stack_ui/components/layout/app-sidebar.tsx b/src/llama_stack_ui/components/layout/app-sidebar.tsx
index a5df60aef..579fccf1b 100644
--- a/src/llama_stack_ui/components/layout/app-sidebar.tsx
+++ b/src/llama_stack_ui/components/layout/app-sidebar.tsx
@@ -9,6 +9,7 @@ import {
Settings2,
Compass,
FileText,
+ File,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -51,6 +52,11 @@ const manageItems = [
url: "/logs/vector-stores",
icon: Database,
},
+ {
+ title: "Files",
+ url: "/logs/files",
+ icon: File,
+ },
{
title: "Prompts",
url: "/prompts",
diff --git a/src/llama_stack_ui/components/layout/detail-layout.tsx b/src/llama_stack_ui/components/layout/detail-layout.tsx
index ed5edd127..d06971645 100644
--- a/src/llama_stack_ui/components/layout/detail-layout.tsx
+++ b/src/llama_stack_ui/components/layout/detail-layout.tsx
@@ -7,7 +7,7 @@ export function DetailLoadingView() {
<>
{/* Title Skeleton */}
-
+
{[...Array(2)].map((_, i) => (
@@ -23,7 +23,7 @@ export function DetailLoadingView() {
))}
-
+
{" "}
{/* Properties Title Skeleton */}
@@ -137,8 +137,10 @@ export function DetailLayout({
<>
{title}
-
{mainContent}
-
{sidebar}
+
+ {mainContent}
+
+
{sidebar}
>
);
diff --git a/src/llama_stack_ui/components/vector-stores/vector-store-editor.tsx b/src/llama_stack_ui/components/vector-stores/vector-store-editor.tsx
index 719a2a9fd..10a84e582 100644
--- a/src/llama_stack_ui/components/vector-stores/vector-store-editor.tsx
+++ b/src/llama_stack_ui/components/vector-stores/vector-store-editor.tsx
@@ -86,7 +86,7 @@ export function VectorStoreEditor({
};
fetchModels();
- }, [client]);
+ }, [client, formData.embedding_model]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -139,7 +139,7 @@ export function VectorStoreEditor({
- {embeddingModels.map((model, index) => (
+ {embeddingModels.map(model => (
{model.id}
diff --git a/src/llama_stack_ui/hooks/use-pagination.ts b/src/llama_stack_ui/hooks/use-pagination.ts
index 9fa4fa338..0af5f62b1 100644
--- a/src/llama_stack_ui/hooks/use-pagination.ts
+++ b/src/llama_stack_ui/hooks/use-pagination.ts
@@ -28,6 +28,7 @@ export interface PaginationReturn {
hasMore: boolean;
error: Error | null;
loadMore: () => void;
+ refetch: () => void;
}
interface UsePaginationParams extends UsePaginationOptions {
@@ -144,6 +145,13 @@ export function usePagination({
}
}, [fetchData]);
+ /**
+ * Refetches data from the beginning (resets pagination)
+ */
+ const refetch = useCallback(() => {
+ fetchData();
+ }, [fetchData]);
+
// Auto-load initial data on mount when enabled
useEffect(() => {
// If using auth, wait for session to load
@@ -169,5 +177,6 @@ export function usePagination({
hasMore: state.hasMore,
error: state.error,
loadMore,
+ refetch,
};
}
diff --git a/src/llama_stack_ui/lib/file-utils.test.ts b/src/llama_stack_ui/lib/file-utils.test.ts
new file mode 100644
index 000000000..ad0873130
--- /dev/null
+++ b/src/llama_stack_ui/lib/file-utils.test.ts
@@ -0,0 +1,205 @@
+import {
+ formatFileSize,
+ getFileTypeIcon,
+ formatPurpose,
+ getPurposeDescription,
+ formatTimestamp,
+ truncateFilename,
+ isTextFile,
+ createDownloadUrl,
+} from "./file-utils";
+
+describe("file-utils", () => {
+ describe("formatFileSize", () => {
+ test("formats bytes correctly", () => {
+ expect(formatFileSize(0)).toBe("0 B");
+ expect(formatFileSize(500)).toBe("500 B");
+ expect(formatFileSize(1023)).toBe("1023 B");
+ });
+
+ test("formats kilobytes correctly", () => {
+ expect(formatFileSize(1024)).toBe("1.0 KB");
+ expect(formatFileSize(1536)).toBe("1.5 KB");
+ expect(formatFileSize(1024 * 1023)).toBe("1023.0 KB");
+ });
+
+ test("formats megabytes correctly", () => {
+ expect(formatFileSize(1024 * 1024)).toBe("1.0 MB");
+ expect(formatFileSize(1024 * 1024 * 2.5)).toBe("2.5 MB");
+ });
+
+ test("formats gigabytes correctly", () => {
+ expect(formatFileSize(1024 * 1024 * 1024)).toBe("1.0 GB");
+ expect(formatFileSize(1024 * 1024 * 1024 * 1.8)).toBe("1.8 GB");
+ });
+ });
+
+ describe("getFileTypeIcon", () => {
+ test("returns correct icons for common file types", () => {
+ expect(getFileTypeIcon("pdf")).toBe("📕");
+ expect(getFileTypeIcon(".pdf")).toBe("📕");
+ expect(getFileTypeIcon("txt")).toBe("📄");
+ expect(getFileTypeIcon("html")).toBe("🌐");
+ expect(getFileTypeIcon("md")).toBe("📝");
+ expect(getFileTypeIcon("csv")).toBe("📊");
+ expect(getFileTypeIcon("json")).toBe("⚙️");
+ expect(getFileTypeIcon("docx")).toBe("📘");
+ expect(getFileTypeIcon("js")).toBe("⚡");
+ expect(getFileTypeIcon("py")).toBe("🐍");
+ });
+
+ test("handles MIME types", () => {
+ expect(getFileTypeIcon("application/pdf")).toBe("📕");
+ expect(getFileTypeIcon("text/plain")).toBe("📄");
+ expect(getFileTypeIcon("text/markdown")).toBe("📝");
+ expect(getFileTypeIcon("application/json")).toBe("⚙️");
+ expect(getFileTypeIcon("text/html")).toBe("🌐");
+ });
+
+ test("returns default icon for unknown types", () => {
+ expect(getFileTypeIcon("unknown")).toBe("📄");
+ expect(getFileTypeIcon("")).toBe("📄");
+ expect(getFileTypeIcon(undefined)).toBe("📄");
+ });
+
+ test("handles complex MIME types", () => {
+ expect(
+ getFileTypeIcon(
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ )
+ ).toBe("📘");
+ });
+ });
+
+ describe("formatPurpose", () => {
+ test("formats purpose labels correctly", () => {
+ expect(formatPurpose("fine-tune")).toBe("Fine-tuning");
+ expect(formatPurpose("assistants")).toBe("Assistants");
+ expect(formatPurpose("user_data")).toBe("User Data");
+ expect(formatPurpose("batch")).toBe("Batch Processing");
+ expect(formatPurpose("vision")).toBe("Vision");
+ expect(formatPurpose("evals")).toBe("Evaluations");
+ });
+ });
+
+ describe("getPurposeDescription", () => {
+ test("returns correct descriptions", () => {
+ expect(getPurposeDescription("fine-tune")).toBe(
+ "For training and fine-tuning language models"
+ );
+ expect(getPurposeDescription("assistants")).toBe(
+ "For use with AI assistants and chat completions"
+ );
+ expect(getPurposeDescription("user_data")).toBe(
+ "General user data and documents"
+ );
+ expect(getPurposeDescription("batch")).toBe(
+ "For batch processing and bulk operations"
+ );
+ expect(getPurposeDescription("vision")).toBe(
+ "For computer vision and image processing tasks"
+ );
+ expect(getPurposeDescription("evals")).toBe(
+ "For model evaluation and testing"
+ );
+ });
+ });
+
+ describe("formatTimestamp", () => {
+ test("formats Unix timestamp to readable date", () => {
+ const timestamp = 1640995200; // Jan 1, 2022 00:00:00 UTC
+ const result = formatTimestamp(timestamp);
+
+ // Should return a valid date string
+ expect(typeof result).toBe("string");
+ expect(result.length).toBeGreaterThan(0);
+
+ // Test that it's calling Date correctly by using a known timestamp
+ const testDate = new Date(timestamp * 1000);
+ expect(result).toBe(testDate.toLocaleString());
+ });
+ });
+
+ describe("truncateFilename", () => {
+ test("returns filename unchanged if under max length", () => {
+ expect(truncateFilename("short.txt", 30)).toBe("short.txt");
+ });
+
+ test("truncates long filenames while preserving extension", () => {
+ const longFilename =
+ "this_is_a_very_long_filename_that_should_be_truncated.pdf";
+ const result = truncateFilename(longFilename, 30);
+
+ expect(result).toContain("...");
+ expect(result).toContain(".pdf");
+ expect(result.length).toBeLessThanOrEqual(30);
+ });
+
+ test("handles files without extension", () => {
+ const longFilename = "this_is_a_very_long_filename_without_extension";
+ const result = truncateFilename(longFilename, 20);
+
+ expect(result).toContain("...");
+ expect(result.length).toBeLessThanOrEqual(20);
+ });
+
+ test("uses default max length", () => {
+ const longFilename = "a".repeat(50) + ".txt";
+ const result = truncateFilename(longFilename);
+
+ expect(result).toContain("...");
+ expect(result).toContain(".txt");
+ expect(result.length).toBeLessThanOrEqual(30); // Default max length
+ });
+ });
+
+ describe("isTextFile", () => {
+ test("identifies text files correctly", () => {
+ expect(isTextFile("text/plain")).toBe(true);
+ expect(isTextFile("text/html")).toBe(true);
+ expect(isTextFile("text/markdown")).toBe(true);
+ expect(isTextFile("application/json")).toBe(true);
+ });
+
+ test("identifies non-text files correctly", () => {
+ expect(isTextFile("application/pdf")).toBe(false);
+ expect(isTextFile("image/png")).toBe(false);
+ expect(isTextFile("application/octet-stream")).toBe(false);
+ });
+ });
+
+ describe("createDownloadUrl", () => {
+ // Mock URL.createObjectURL and revokeObjectURL for Node environment
+ const mockCreateObjectURL = jest.fn(() => "blob:mock-url");
+ const mockRevokeObjectURL = jest.fn();
+
+ beforeAll(() => {
+ global.URL = {
+ ...global.URL,
+ createObjectURL: mockCreateObjectURL,
+ revokeObjectURL: mockRevokeObjectURL,
+ } as typeof URL;
+ });
+
+ beforeEach(() => {
+ mockCreateObjectURL.mockClear();
+ mockRevokeObjectURL.mockClear();
+ });
+
+ test("creates download URL from string content", () => {
+ const content = "Hello, world!";
+ const url = createDownloadUrl(content);
+
+ expect(url).toBe("blob:mock-url");
+ expect(mockCreateObjectURL).toHaveBeenCalledWith(expect.any(Blob));
+ });
+
+ test("creates download URL from Blob", () => {
+ const blob = new Blob(["Hello, world!"], { type: "text/plain" });
+ const url = createDownloadUrl(blob);
+
+ expect(url).toBe("blob:mock-url");
+ expect(mockCreateObjectURL).toHaveBeenCalledWith(blob);
+ });
+ });
+});
diff --git a/src/llama_stack_ui/lib/file-utils.ts b/src/llama_stack_ui/lib/file-utils.ts
new file mode 100644
index 000000000..98efacc7b
--- /dev/null
+++ b/src/llama_stack_ui/lib/file-utils.ts
@@ -0,0 +1,293 @@
+import {
+ SupportedFileType,
+ SUPPORTED_FILE_TYPES,
+ FILE_TYPE_EXTENSIONS,
+ MAX_FILE_SIZE,
+} from "./types";
+
+/**
+ * Format file size in human readable format
+ */
+export function formatFileSize(bytes: number): string {
+ const units = ["B", "KB", "MB", "GB"];
+ let size = bytes;
+ let unitIndex = 0;
+
+ while (size >= 1024 && unitIndex < units.length - 1) {
+ size /= 1024;
+ unitIndex++;
+ }
+
+ return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
+}
+
+/**
+ * Get file type icon based on file extension or MIME type
+ */
+export function getFileTypeIcon(
+ fileExtensionOrMimeType: string | undefined
+): string {
+ if (!fileExtensionOrMimeType) return "📄";
+
+ // If it's a file extension (starts with . or is just the extension)
+ let extension = fileExtensionOrMimeType.toLowerCase();
+ if (extension.startsWith(".")) {
+ extension = extension.substring(1);
+ }
+
+ // Check by file extension first (more reliable)
+ switch (extension) {
+ case "pdf":
+ return "📕"; // Red book for PDF
+ case "html":
+ case "htm":
+ return "🌐"; // Globe for HTML
+ case "txt":
+ return "📄"; // Generic document for text
+ case "md":
+ case "markdown":
+ return "📝"; // Memo for Markdown
+ case "csv":
+ return "📊"; // Bar chart for CSV
+ case "json":
+ return "⚙️"; // Gear for JSON
+ case "docx":
+ case "doc":
+ return "📘"; // Blue book for Word documents
+ case "xlsx":
+ case "xls":
+ return "📗"; // Green book for Excel
+ case "pptx":
+ case "ppt":
+ return "📙"; // Orange book for PowerPoint
+ case "zip":
+ case "rar":
+ case "7z":
+ return "🗜️"; // Compression for archives
+ case "jpg":
+ case "jpeg":
+ case "png":
+ case "gif":
+ case "svg":
+ return "🖼️"; // Framed picture for images
+ case "mp4":
+ case "avi":
+ case "mov":
+ return "🎬"; // Movie camera for videos
+ case "mp3":
+ case "wav":
+ case "flac":
+ return "🎵"; // Musical note for audio
+ case "js":
+ case "ts":
+ return "⚡"; // Lightning for JavaScript/TypeScript
+ case "py":
+ return "🐍"; // Snake for Python
+ case "java":
+ return "☕"; // Coffee for Java
+ case "cpp":
+ case "c":
+ return "🔧"; // Wrench for C/C++
+ case "xml":
+ return "🏷️"; // Label for XML
+ case "css":
+ return "🎨"; // Palette for CSS
+ default:
+ break;
+ }
+
+ // Fallback to MIME type checking for unknown extensions
+ const mimeType = fileExtensionOrMimeType.toLowerCase();
+
+ if (mimeType.startsWith("text/")) {
+ if (mimeType === "text/markdown") return "📝";
+ if (mimeType === "text/html") return "🌐";
+ if (mimeType === "text/csv") return "📊";
+ if (mimeType === "text/plain") return "📄";
+ return "📄";
+ }
+
+ if (mimeType === "application/pdf") return "📕";
+ if (mimeType === "application/json") return "⚙️";
+ if (mimeType.includes("document") || mimeType.includes("word")) return "📘";
+ if (mimeType.includes("spreadsheet") || mimeType.includes("excel"))
+ return "📗";
+ if (mimeType.includes("presentation") || mimeType.includes("powerpoint"))
+ return "📙";
+ if (mimeType.startsWith("image/")) return "🖼️";
+ if (mimeType.startsWith("video/")) return "🎬";
+ if (mimeType.startsWith("audio/")) return "🎵";
+
+ return "📄";
+}
+
+/**
+ * Validate file before upload
+ */
+export function validateFile(file: File): {
+ isValid: boolean;
+ errors: string[];
+} {
+ const errors: string[] = [];
+
+ // Check file size
+ if (file.size > MAX_FILE_SIZE) {
+ errors.push(
+ `File size exceeds maximum limit of ${formatFileSize(MAX_FILE_SIZE)}`
+ );
+ }
+
+ // Check file type
+ if (!SUPPORTED_FILE_TYPES.includes(file.type as SupportedFileType)) {
+ const supportedExtensions = Object.values(FILE_TYPE_EXTENSIONS).join(", ");
+ errors.push(
+ `Unsupported file type. Supported types: ${supportedExtensions}`
+ );
+ }
+
+ // Check for empty file
+ if (file.size === 0) {
+ errors.push("File appears to be empty");
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors,
+ };
+}
+
+/**
+ * Get file extension from filename
+ */
+export function getFileExtension(filename: string): string {
+ const lastDot = filename.lastIndexOf(".");
+ return lastDot === -1 ? "" : filename.substring(lastDot);
+}
+
+/**
+ * Generate a human-readable purpose label
+ */
+export function formatPurpose(
+ purpose:
+ | "fine-tune"
+ | "assistants"
+ | "user_data"
+ | "batch"
+ | "vision"
+ | "evals"
+): string {
+ switch (purpose) {
+ case "fine-tune":
+ return "Fine-tuning";
+ case "assistants":
+ return "Assistants";
+ case "user_data":
+ return "User Data";
+ case "batch":
+ return "Batch Processing";
+ case "vision":
+ return "Vision";
+ case "evals":
+ return "Evaluations";
+ default:
+ return purpose;
+ }
+}
+
+/**
+ * Get purpose description for UI help text
+ */
+export function getPurposeDescription(
+ purpose:
+ | "fine-tune"
+ | "assistants"
+ | "user_data"
+ | "batch"
+ | "vision"
+ | "evals"
+): string {
+ switch (purpose) {
+ case "fine-tune":
+ return "For training and fine-tuning language models";
+ case "assistants":
+ return "For use with AI assistants and chat completions";
+ case "user_data":
+ return "General user data and documents";
+ case "batch":
+ return "For batch processing and bulk operations";
+ case "vision":
+ return "For computer vision and image processing tasks";
+ case "evals":
+ return "For model evaluation and testing";
+ default:
+ return "General purpose file";
+ }
+}
+
+/**
+ * Format timestamp to human readable date
+ */
+export function formatTimestamp(timestamp: number): string {
+ return new Date(timestamp * 1000).toLocaleString();
+}
+
+/**
+ * Check if file is text-based for preview
+ */
+export function isTextFile(mimeType: string): boolean {
+ return (
+ mimeType.startsWith("text/") ||
+ mimeType === "application/json" ||
+ mimeType === "text/markdown"
+ );
+}
+
+/**
+ * Create a download link for file content
+ */
+export function createDownloadUrl(content: string | Blob): string {
+ let blob: Blob;
+
+ if (typeof content === "string") {
+ blob = new Blob([content], { type: "text/plain" });
+ } else {
+ blob = content;
+ }
+
+ return URL.createObjectURL(blob);
+}
+
+/**
+ * Trigger file download
+ */
+export function downloadFile(url: string, filename: string): void {
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+}
+
+/**
+ * Truncate filename for display
+ */
+export function truncateFilename(
+ filename: string,
+ maxLength: number = 30
+): string {
+ if (filename.length <= maxLength) return filename;
+
+ const extension = getFileExtension(filename);
+ const nameWithoutExt = filename.substring(
+ 0,
+ filename.length - extension.length
+ );
+ const truncatedName = nameWithoutExt.substring(
+ 0,
+ maxLength - extension.length - 3
+ );
+
+ return `${truncatedName}...${extension}`;
+}
diff --git a/src/llama_stack_ui/lib/file-validation.test.ts b/src/llama_stack_ui/lib/file-validation.test.ts
new file mode 100644
index 000000000..479699757
--- /dev/null
+++ b/src/llama_stack_ui/lib/file-validation.test.ts
@@ -0,0 +1,295 @@
+import {
+ validateFileForUpload,
+ validateUploadParams,
+ formatValidationErrors,
+ formatValidationWarnings,
+ detectPotentialCorruption,
+} from "./file-validation";
+import { MAX_FILE_SIZE } from "./types";
+
+describe("file-validation", () => {
+ describe("validateFileForUpload", () => {
+ test("validates a correct file", () => {
+ const file = new File(["content"], "test.pdf", {
+ type: "application/pdf",
+ });
+ const result = validateFileForUpload(file);
+
+ expect(result.isValid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ test("rejects file that is too large", () => {
+ // Create a mock file that appears large without actually creating huge content
+ const file = new File(["small content"], "large.pdf", {
+ type: "application/pdf",
+ });
+ // Override size property
+ Object.defineProperty(file, "size", {
+ value: MAX_FILE_SIZE + 1,
+ writable: false,
+ });
+
+ const result = validateFileForUpload(file);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ field: "size",
+ message: expect.stringContaining("exceeds maximum limit"),
+ }),
+ ])
+ );
+ });
+
+ test("rejects empty file", () => {
+ const file = new File([], "empty.pdf", { type: "application/pdf" });
+ const result = validateFileForUpload(file);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ field: "size",
+ message: "File is empty",
+ }),
+ ])
+ );
+ });
+
+ test("rejects unsupported file type", () => {
+ const file = new File(["content"], "test.exe", {
+ type: "application/exe",
+ });
+ const result = validateFileForUpload(file);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ field: "type",
+ message: expect.stringContaining("Unsupported file type"),
+ }),
+ ])
+ );
+ });
+
+ test("warns about special characters in filename", () => {
+ const file = new File(["content"], "test<>file.pdf", {
+ type: "application/pdf",
+ });
+ const result = validateFileForUpload(file);
+
+ expect(result.isValid).toBe(true);
+ expect(result.warnings).toEqual(
+ expect.arrayContaining([expect.stringContaining("special characters")])
+ );
+ });
+
+ test("rejects file with very long name", () => {
+ const longName = "a".repeat(256) + ".pdf";
+ const file = new File(["content"], longName, { type: "application/pdf" });
+ const result = validateFileForUpload(file);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ field: "name",
+ message: expect.stringContaining("too long"),
+ }),
+ ])
+ );
+ });
+ });
+
+ describe("validateUploadParams", () => {
+ const validFile = new File(["content"], "test.pdf", {
+ type: "application/pdf",
+ });
+
+ test("validates correct upload parameters", () => {
+ const result = validateUploadParams([validFile], "assistants", 3600);
+
+ expect(result.isValid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ test("rejects when no files provided", () => {
+ const result = validateUploadParams([], "assistants", 3600);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ field: "files",
+ message: "At least one file is required",
+ }),
+ ])
+ );
+ });
+
+ test("rejects too many files", () => {
+ const files = Array.from(
+ { length: 11 },
+ (_, i) => new File(["content"], `file${i}.txt`, { type: "text/plain" })
+ );
+ const result = validateUploadParams(files, "assistants");
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ field: "files",
+ message: expect.stringContaining("Maximum 10 files"),
+ }),
+ ])
+ );
+ });
+
+ test("rejects invalid purpose", () => {
+ const result = validateUploadParams(
+ [validFile],
+ "invalid" as
+ | "fine-tune"
+ | "assistants"
+ | "user_data"
+ | "batch"
+ | "vision"
+ | "evals"
+ );
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ field: "purpose",
+ message: expect.stringContaining("Purpose must be one of"),
+ }),
+ ])
+ );
+ });
+
+ test("rejects expiration time too short", () => {
+ const result = validateUploadParams([validFile], "assistants", 1800); // 30 minutes
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ field: "expiresAfter",
+ message: expect.stringContaining(
+ "Minimum expiration time is 1 hour"
+ ),
+ }),
+ ])
+ );
+ });
+
+ test("rejects expiration time too long", () => {
+ const result = validateUploadParams([validFile], "assistants", 2592001); // > 30 days
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ field: "expiresAfter",
+ message: expect.stringContaining(
+ "Maximum expiration time is 30 days"
+ ),
+ }),
+ ])
+ );
+ });
+
+ test("warns about duplicate filenames", () => {
+ const files = [
+ new File(["content1"], "test.txt", { type: "text/plain" }),
+ new File(["content2"], "test.txt", { type: "text/plain" }),
+ ];
+ const result = validateUploadParams(files, "assistants");
+
+ expect(result.isValid).toBe(true);
+ expect(result.warnings).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining('Multiple files with name "test.txt"'),
+ ])
+ );
+ });
+ });
+
+ describe("detectPotentialCorruption", () => {
+ test("warns about suspiciously small PDF", () => {
+ const file = new File(["x"], "small.pdf", { type: "application/pdf" });
+ const warnings = detectPotentialCorruption(file);
+
+ expect(warnings).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining("PDF file appears unusually small"),
+ ])
+ );
+ });
+
+ test("warns about very large text file", () => {
+ const file = new File(["content"], "huge.txt", { type: "text/plain" });
+ // Override size to appear very large
+ Object.defineProperty(file, "size", {
+ value: 101 * 1024 * 1024,
+ writable: false,
+ });
+
+ const warnings = detectPotentialCorruption(file);
+
+ expect(warnings).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining("Very large text file"),
+ ])
+ );
+ });
+ });
+
+ describe("formatValidationErrors", () => {
+ test("returns empty string for no errors", () => {
+ expect(formatValidationErrors([])).toBe("");
+ });
+
+ test("returns single error message", () => {
+ const errors = [{ field: "size", message: "File too large" }];
+ expect(formatValidationErrors(errors)).toBe("File too large");
+ });
+
+ test("formats multiple errors", () => {
+ const errors = [
+ { field: "size", message: "File too large" },
+ { field: "type", message: "Unsupported type" },
+ ];
+ const result = formatValidationErrors(errors);
+
+ expect(result).toContain("Multiple issues found:");
+ expect(result).toContain("• File too large");
+ expect(result).toContain("• Unsupported type");
+ });
+ });
+
+ describe("formatValidationWarnings", () => {
+ test("returns empty string for no warnings", () => {
+ expect(formatValidationWarnings([])).toBe("");
+ });
+
+ test("returns single warning", () => {
+ expect(formatValidationWarnings(["Single warning"])).toBe(
+ "Single warning"
+ );
+ });
+
+ test("formats multiple warnings", () => {
+ const warnings = ["Warning 1", "Warning 2"];
+ const result = formatValidationWarnings(warnings);
+
+ expect(result).toContain("2 warnings:");
+ expect(result).toContain("• Warning 1");
+ expect(result).toContain("• Warning 2");
+ });
+ });
+});
diff --git a/src/llama_stack_ui/lib/file-validation.ts b/src/llama_stack_ui/lib/file-validation.ts
new file mode 100644
index 000000000..974db0b74
--- /dev/null
+++ b/src/llama_stack_ui/lib/file-validation.ts
@@ -0,0 +1,280 @@
+import {
+ SupportedFileType,
+ SUPPORTED_FILE_TYPES,
+ MAX_FILE_SIZE,
+ FileValidationError,
+} from "./types";
+
+export interface FileValidationResult {
+ isValid: boolean;
+ errors: FileValidationError[];
+ warnings: string[];
+}
+
+/**
+ * Comprehensive file validation
+ */
+export function validateFileForUpload(file: File): FileValidationResult {
+ const errors: FileValidationError[] = [];
+ const warnings: string[] = [];
+
+ // Check if file exists
+ if (!file) {
+ errors.push({
+ field: "file",
+ message: "No file provided",
+ });
+ return { isValid: false, errors, warnings };
+ }
+
+ // Check file size
+ if (file.size === 0) {
+ errors.push({
+ field: "size",
+ message: "File is empty",
+ });
+ } else if (file.size > MAX_FILE_SIZE) {
+ errors.push({
+ field: "size",
+ message: `File size (${formatFileSize(file.size)}) exceeds maximum limit of ${formatFileSize(MAX_FILE_SIZE)}`,
+ });
+ }
+
+ // Check file type
+ if (!file.type) {
+ warnings.push("File type could not be determined");
+ } else if (!SUPPORTED_FILE_TYPES.includes(file.type as SupportedFileType)) {
+ errors.push({
+ field: "type",
+ message: `Unsupported file type: ${file.type}`,
+ });
+ }
+
+ // Check filename
+ if (!file.name) {
+ errors.push({
+ field: "name",
+ message: "File must have a name",
+ });
+ } else {
+ // Check for potentially problematic characters
+ const problematicChars = /[<>:"/\\|?*]/g;
+ if (problematicChars.test(file.name)) {
+ warnings.push(
+ "File name contains special characters that may cause issues"
+ );
+ }
+
+ // Check filename length
+ if (file.name.length > 255) {
+ errors.push({
+ field: "name",
+ message: "File name is too long (maximum 255 characters)",
+ });
+ }
+
+ // Check for extension
+ const hasExtension = file.name.includes(".");
+ if (!hasExtension) {
+ warnings.push("File has no extension");
+ }
+ }
+
+ // File-specific validations
+ if (file.type === "application/pdf" && file.size > 50 * 1024 * 1024) {
+ warnings.push("Large PDF files may take longer to process");
+ }
+
+ if (file.type?.startsWith("text/") && file.size > 10 * 1024 * 1024) {
+ warnings.push("Large text files may have limited preview capabilities");
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors,
+ warnings,
+ };
+}
+
+/**
+ * Validate file upload parameters
+ */
+export function validateUploadParams(
+ files: File[],
+ purpose:
+ | "fine-tune"
+ | "assistants"
+ | "user_data"
+ | "batch"
+ | "vision"
+ | "evals",
+ expiresAfter?: number
+): FileValidationResult {
+ const errors: FileValidationError[] = [];
+ const warnings: string[] = [];
+
+ // Check files array
+ if (!files || files.length === 0) {
+ errors.push({
+ field: "files",
+ message: "At least one file is required",
+ });
+ return { isValid: false, errors, warnings };
+ }
+
+ // Check file limit
+ if (files.length > 10) {
+ errors.push({
+ field: "files",
+ message: "Maximum 10 files can be uploaded at once",
+ });
+ }
+
+ // Check total size
+ const totalSize = files.reduce((sum, file) => sum + file.size, 0);
+ const maxTotalSize = MAX_FILE_SIZE * files.length; // Per-file limit applies
+
+ if (totalSize > maxTotalSize) {
+ warnings.push(
+ `Total upload size is ${formatFileSize(totalSize)}. Consider uploading in smaller batches.`
+ );
+ }
+
+ // Validate each file
+ const duplicateNames = new Set();
+ const seenNames = new Set();
+
+ files.forEach((file, index) => {
+ const validation = validateFileForUpload(file);
+
+ // Add file-specific errors with context
+ validation.errors.forEach(error => {
+ errors.push({
+ ...error,
+ message: `File ${index + 1} (${file.name}): ${error.message}`,
+ });
+ });
+
+ // Check for duplicate names
+ if (seenNames.has(file.name)) {
+ duplicateNames.add(file.name);
+ } else {
+ seenNames.add(file.name);
+ }
+ });
+
+ // Report duplicate names
+ duplicateNames.forEach(name => {
+ warnings.push(`Multiple files with name "${name}" detected`);
+ });
+
+ // Validate purpose
+ const validPurposes = [
+ "fine-tune",
+ "assistants",
+ "user_data",
+ "batch",
+ "vision",
+ "evals",
+ ];
+ if (!purpose || !validPurposes.includes(purpose)) {
+ errors.push({
+ field: "purpose",
+ message: `Purpose must be one of: ${validPurposes.join(", ")}`,
+ });
+ }
+
+ // Validate expiration
+ if (expiresAfter !== undefined) {
+ if (expiresAfter < 0) {
+ errors.push({
+ field: "expiresAfter",
+ message: "Expiration time cannot be negative",
+ });
+ } else if (expiresAfter > 0 && expiresAfter < 3600) {
+ errors.push({
+ field: "expiresAfter",
+ message: "Minimum expiration time is 1 hour (3600 seconds)",
+ });
+ } else if (expiresAfter > 2592000) {
+ errors.push({
+ field: "expiresAfter",
+ message: "Maximum expiration time is 30 days (2592000 seconds)",
+ });
+ }
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors,
+ warnings,
+ };
+}
+
+/**
+ * Format file size helper (duplicated here for independence)
+ */
+function formatFileSize(bytes: number): string {
+ const units = ["B", "KB", "MB", "GB"];
+ let size = bytes;
+ let unitIndex = 0;
+
+ while (size >= 1024 && unitIndex < units.length - 1) {
+ size /= 1024;
+ unitIndex++;
+ }
+
+ return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
+}
+
+/**
+ * Check if file is likely corrupted based on size/type mismatch
+ */
+export function detectPotentialCorruption(file: File): string[] {
+ const warnings: string[] = [];
+
+ // PDF files should be at least a few KB
+ if (file.type === "application/pdf" && file.size < 1024) {
+ warnings.push("PDF file appears unusually small and may be corrupted");
+ }
+
+ // Word documents should be at least a few KB
+ if (file.type.includes("word") && file.size < 2048) {
+ warnings.push("Word document appears unusually small and may be corrupted");
+ }
+
+ // Large files with text/* MIME type might be incorrect
+ if (file.type?.startsWith("text/") && file.size > 100 * 1024 * 1024) {
+ warnings.push(
+ "Very large text file - file type might be incorrectly detected"
+ );
+ }
+
+ return warnings;
+}
+
+/**
+ * Get user-friendly error messages
+ */
+export function formatValidationErrors(errors: FileValidationError[]): string {
+ if (errors.length === 0) return "";
+
+ if (errors.length === 1) {
+ return errors[0].message;
+ }
+
+ return `Multiple issues found:\n${errors.map(e => `• ${e.message}`).join("\n")}`;
+}
+
+/**
+ * Get user-friendly warning messages
+ */
+export function formatValidationWarnings(warnings: string[]): string {
+ if (warnings.length === 0) return "";
+
+ if (warnings.length === 1) {
+ return warnings[0];
+ }
+
+ return `${warnings.length} warnings:\n${warnings.map(w => `• ${w}`).join("\n")}`;
+}
diff --git a/src/llama_stack_ui/lib/types.ts b/src/llama_stack_ui/lib/types.ts
index cfd9190af..744c26feb 100644
--- a/src/llama_stack_ui/lib/types.ts
+++ b/src/llama_stack_ui/lib/types.ts
@@ -128,3 +128,96 @@ export interface InputItemListResponse {
data: ResponseInput[];
object: "list";
}
+
+// Files API Types (based on LlamaStack Files API)
+export interface FileResource {
+ id: string;
+ bytes: number;
+ created_at: number;
+ expires_at: number;
+ filename: string;
+ purpose:
+ | "fine-tune"
+ | "assistants"
+ | "user_data"
+ | "batch"
+ | "vision"
+ | "evals";
+ object?: "file";
+}
+
+export interface ListFilesResponse {
+ data: FileResource[];
+ first_id: string;
+ has_more: boolean;
+ last_id: string;
+ object: "list";
+}
+
+export interface DeleteFileResponse {
+ id: string;
+ deleted: boolean;
+ object?: "file";
+}
+
+export interface FileCreateParams {
+ file: File; // Browser File object
+ purpose:
+ | "fine-tune"
+ | "assistants"
+ | "user_data"
+ | "batch"
+ | "vision"
+ | "evals";
+ expires_after?: {
+ anchor: "created_at";
+ seconds: number;
+ } | null;
+}
+
+export interface FileUploadFormData {
+ purpose:
+ | "fine-tune"
+ | "assistants"
+ | "user_data"
+ | "batch"
+ | "vision"
+ | "evals";
+ expiresAfter?: number; // seconds
+ fileName?: string;
+}
+
+export interface FileValidationError {
+ field: string;
+ message: string;
+}
+
+// File type mappings for UI
+export const SUPPORTED_FILE_TYPES = [
+ "application/pdf",
+ "text/plain",
+ "text/markdown",
+ "text/html",
+ "text/csv",
+ "application/json",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/msword",
+] as const;
+
+export const FILE_TYPE_EXTENSIONS = {
+ "application/pdf": ".pdf",
+ "text/plain": ".txt",
+ "text/markdown": ".md",
+ "text/html": ".html",
+ "text/csv": ".csv",
+ "application/json": ".json",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
+ ".docx",
+ "application/msword": ".doc",
+} as const;
+
+export type SupportedFileType = (typeof SUPPORTED_FILE_TYPES)[number];
+
+// Constants for file upload
+export const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
+export const DEFAULT_EXPIRES_AFTER = 24 * 60 * 60; // 24 hours
diff --git a/src/llama_stack_ui/package-lock.json b/src/llama_stack_ui/package-lock.json
index 9650ccb36..661ee5cb4 100644
--- a/src/llama_stack_ui/package-lock.json
+++ b/src/llama_stack_ui/package-lock.json
@@ -18,14 +18,16 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
+ "@types/papaparse": "^5.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.24",
- "llama-stack-client": "^0.3.1",
+ "llama-stack-client": "^0.4.0-rc1",
"lucide-react": "^0.545.0",
"next": "15.5.7",
"next-auth": "^4.24.11",
"next-themes": "^0.4.6",
+ "papaparse": "^5.5.3",
"react": "^19.0.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
@@ -3892,6 +3894,15 @@
"form-data": "^4.0.0"
}
},
+ "node_modules/@types/papaparse": {
+ "version": "5.5.1",
+ "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.1.tgz",
+ "integrity": "sha512-esEO+VISsLIyE+JZBmb89NzsYYbpwV8lmv2rPo6oX5y9KhBaIP7hhHgjuTut54qjdKVMufTEcrh5fUl9+58huw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/react": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
@@ -9688,9 +9699,9 @@
"license": "MIT"
},
"node_modules/llama-stack-client": {
- "version": "0.3.1",
- "resolved": "https://registry.npmjs.org/llama-stack-client/-/llama-stack-client-0.3.1.tgz",
- "integrity": "sha512-4aYoF2aAQiBSfxyZEtczeQmJn8q9T22ePDqGhR+ej5RG6a8wvl5B3v7ZoKuFkft+vcP/kbJ58GQZEPLekxekZA==",
+ "version": "0.4.0-rc1",
+ "resolved": "https://registry.npmjs.org/llama-stack-client/-/llama-stack-client-0.4.0-rc1.tgz",
+ "integrity": "sha512-kT8fClY6pjUhvyxrUgnc+kFDWwH+XjXSaIT2mZH8W7gjXFhotGS2zSPdaQiAptKLgiUiZ8kkpkm/sbC5sHikng==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18",
@@ -11366,6 +11377,12 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
+ "node_modules/papaparse": {
+ "version": "5.5.3",
+ "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
+ "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
+ "license": "MIT"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
diff --git a/src/llama_stack_ui/package.json b/src/llama_stack_ui/package.json
index 22427802f..62fbc95c8 100644
--- a/src/llama_stack_ui/package.json
+++ b/src/llama_stack_ui/package.json
@@ -42,14 +42,16 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
+ "@types/papaparse": "^5.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.24",
- "llama-stack-client": "^0.3.1",
+ "llama-stack-client": "^0.4.0-rc1",
"lucide-react": "^0.545.0",
"next": "15.5.7",
"next-auth": "^4.24.11",
"next-themes": "^0.4.6",
+ "papaparse": "^5.5.3",
"react": "^19.0.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",