mirror of
https://github.com/meta-llama/llama-stack.git
synced 2025-12-17 02:52:36 +00:00
feat(UI): Adding Files API to Admin UI (#4319)
# What does this PR do? ## Files Admin Page <img width="1919" height="1238" alt="Screenshot 2025-12-09 at 10 33 06 AM" src="https://github.com/user-attachments/assets/3dd545f0-32bc-45be-af2b-1823800015f2" /> ## Files Upload Modal <img width="1919" height="1287" alt="Screenshot 2025-12-09 at 10 33 38 AM" src="https://github.com/user-attachments/assets/776bb372-75d3-4ccd-b6b5-c9dfb3fcb350" /> ## Files Detail <img width="1918" height="1099" alt="Screenshot 2025-12-09 at 10 34 26 AM" src="https://github.com/user-attachments/assets/f256dbf8-4047-4d79-923d-404161b05f36" /> Note, content preview has some handling for JSON, CSV, and PDF to enable nicer rendering. Pure text rendering is trivial. ### Files Detail File Content Preview (TXT) <img width="1918" height="1341" alt="Screenshot 2025-12-09 at 10 41 20 AM" src="https://github.com/user-attachments/assets/4fa0ddb7-ffff-424b-b764-0bd4af6ed976" /> ### Files Detail File Content Preview (JSON) <img width="1909" height="1233" alt="Screenshot 2025-12-09 at 10 39 57 AM" src="https://github.com/user-attachments/assets/b912f07a-2dff-483b-b73c-2f69dd0d87ad" /> ### Files Detail File Content Preview (HTML) <img width="1916" height="1348" alt="Screenshot 2025-12-09 at 10 40 27 AM" src="https://github.com/user-attachments/assets/17ebec0a-8754-4552-977d-d3c44f7f6973" /> ### Files Detail File Content Preview (CSV) <img width="1919" height="1177" alt="Screenshot 2025-12-09 at 10 34 50 AM" src="https://github.com/user-attachments/assets/20bd0755-1757-4a3a-99d2-fbd072f81f49" /> ### Files Detail File Content Preview (PDF) <img width="1917" height="1154" alt="Screenshot 2025-12-09 at 10 36 48 AM" src="https://github.com/user-attachments/assets/2873e6fe-4da3-4cbd-941b-7d903270b749" /> Closes https://github.com/llamastack/llama-stack/issues/4144 ## Test Plan Added Tests Signed-off-by: Francisco Javier Arceo <farceo@redhat.com>
This commit is contained in:
parent
6ad5fb5577
commit
fcea9893a4
23 changed files with 4034 additions and 23 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
8
src/llama_stack_ui/app/logs/files/[id]/page.tsx
Normal file
8
src/llama_stack_ui/app/logs/files/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { FileDetail } from "@/components/files/file-detail";
|
||||
|
||||
export default function FileDetailPage() {
|
||||
return <FileDetail />;
|
||||
}
|
||||
31
src/llama_stack_ui/app/logs/files/layout.tsx
Normal file
31
src/llama_stack_ui/app/logs/files/layout.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="space-y-4">
|
||||
{isBaseDetailPage && <PageBreadcrumb segments={breadcrumbSegments} />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
src/llama_stack_ui/app/logs/files/page.tsx
Normal file
8
src/llama_stack_ui/app/logs/files/page.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { FilesManagement } from "@/components/files/files-management";
|
||||
|
||||
export default function FilesPage() {
|
||||
return <FilesManagement />;
|
||||
}
|
||||
290
src/llama_stack_ui/components/files/csv-viewer.tsx
Normal file
290
src/llama_stack_ui/components/files/csv-viewer.tsx
Normal file
|
|
@ -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<number | null>(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 (
|
||||
<div className="p-4 border border-red-200 rounded-lg bg-red-50 dark:bg-red-900/20 dark:border-red-800">
|
||||
<h3 className="font-semibold text-red-800 dark:text-red-300 mb-2">
|
||||
File Too Large
|
||||
</h3>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
CSV file is too large to display (
|
||||
{(contentSize / (1024 * 1024)).toFixed(2)}MB). Maximum supported size
|
||||
is {MAX_CSV_SIZE / (1024 * 1024)}MB.
|
||||
</p>
|
||||
<p className="text-xs text-red-500 dark:text-red-400 mt-2">
|
||||
Please download the file to view its contents.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="p-4 border border-red-200 rounded-lg bg-red-50 dark:bg-red-900/20 dark:border-red-800">
|
||||
<h3 className="font-semibold text-red-800 dark:text-red-300 mb-2">
|
||||
CSV Parsing Error
|
||||
</h3>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
Failed to parse CSV file. Please check the file format.
|
||||
</p>
|
||||
{parsedData.errors.slice(0, 3).map((error, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className="text-xs text-red-500 dark:text-red-400 mt-1"
|
||||
>
|
||||
Line {error.row}: {error.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (parsedData.headers.length === 0) {
|
||||
return (
|
||||
<div className="p-4 border rounded-lg bg-muted/50">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No data found in CSV file.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 w-full overflow-x-auto">
|
||||
{/* CSV Info & Search */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{parsedData.rows.length} rows × {parsedData.headers.length} columns
|
||||
{filteredAndSortedRows.length !== parsedData.rows.length && (
|
||||
<span className="ml-2">
|
||||
(showing {filteredAndSortedRows.length} filtered)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search CSV data..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Large File Warning */}
|
||||
{isLargeFile && !isOversized && (
|
||||
<div className="p-3 border border-orange-200 rounded-lg bg-orange-50 dark:bg-orange-900/20 dark:border-orange-800">
|
||||
<p className="text-sm text-orange-800 dark:text-orange-300">
|
||||
⚠️ Large file detected ({(contentSize / (1024 * 1024)).toFixed(2)}
|
||||
MB). Performance may be slower than usual.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parsing Warnings */}
|
||||
{parsedData.errors.length > 0 && (
|
||||
<div className="p-3 border border-yellow-200 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 dark:border-yellow-800">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-300">
|
||||
⚠️ {parsedData.errors.length} parsing warning(s) - data may be
|
||||
incomplete
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSV Table */}
|
||||
<div className="border rounded-lg">
|
||||
<div className="max-h-[500px] overflow-y-auto scrollbar-thin scrollbar-track-muted scrollbar-thumb-muted-foreground">
|
||||
<table className="w-full caption-bottom text-sm table-auto">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{parsedData.headers.map((header, index) => (
|
||||
<TableHead
|
||||
key={index}
|
||||
className="cursor-pointer hover:bg-muted/50 select-none px-3 whitespace-nowrap"
|
||||
onClick={() => handleSort(index)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold">
|
||||
{header || `Column ${index + 1}`}
|
||||
</div>
|
||||
{sortColumn === index && (
|
||||
<div className="ml-1 flex-shrink-0">
|
||||
{sortDirection === "asc" ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedRows.map((row, rowIndex) => (
|
||||
<TableRow key={rowIndex}>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<TableCell
|
||||
key={cellIndex}
|
||||
className="font-mono text-sm overflow-hidden px-3 max-w-xs"
|
||||
>
|
||||
<div className="truncate" title={cell || ""}>
|
||||
{cell || ""}
|
||||
</div>
|
||||
</TableCell>
|
||||
))}
|
||||
{/* Fill empty cells if row is shorter than headers */}
|
||||
{Array.from({
|
||||
length: Math.max(0, parsedData.headers.length - row.length),
|
||||
}).map((_, emptyIndex) => (
|
||||
<TableCell
|
||||
key={`empty-${emptyIndex}`}
|
||||
className="text-muted-foreground px-3 max-w-xs"
|
||||
>
|
||||
—
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Stats */}
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
{filteredAndSortedRows.length === 0 && searchTerm && (
|
||||
<p>No rows match your search criteria.</p>
|
||||
)}
|
||||
{sortColumn !== null && (
|
||||
<p>
|
||||
Sorted by column "
|
||||
{parsedData.headers[sortColumn] || `Column ${sortColumn + 1}`}"
|
||||
({sortDirection}ending)
|
||||
</p>
|
||||
)}
|
||||
{parsedData.headers.length > 4 && (
|
||||
<p className="italic">
|
||||
💡 Scroll horizontally to view all {parsedData.headers.length}{" "}
|
||||
columns
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
628
src/llama_stack_ui/components/files/file-detail.tsx
Normal file
628
src/llama_stack_ui/components/files/file-detail.tsx
Normal file
|
|
@ -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<FileResource | null>(null);
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
const [fileContentUrl, setFileContentUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [contentLoading, setContentLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [contentError, setContentError] = useState<string | null>(null);
|
||||
const [sizeWarning, setSizeWarning] = useState<string | null>(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 <DetailLoadingView />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <DetailErrorView title="File Details" id={fileId} error={error} />;
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
return <DetailNotFoundView title="File Details" id={fileId} />;
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div className="space-y-6" data-main-content>
|
||||
{/* File Header */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-2xl">{fileIcon}</div>
|
||||
<div>
|
||||
<CardTitle className="text-xl">{file.filename}</CardTitle>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
|
||||
<span>{formatFileSize(file.bytes)}</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{file.filename.split(".").pop()?.toUpperCase() || "Unknown"}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{formatPurpose(file.purpose)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleDownload}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* File Content Preview */}
|
||||
{canPreview && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Content Preview
|
||||
</CardTitle>
|
||||
{!fileContentUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLoadContent}
|
||||
disabled={contentLoading}
|
||||
>
|
||||
{contentLoading ? "Loading..." : "Load Content"}
|
||||
</Button>
|
||||
)}
|
||||
{fileContentUrl && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(fileContentUrl, "_blank")}
|
||||
>
|
||||
Open in New Tab
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const link = document.createElement("a");
|
||||
link.href = fileContentUrl;
|
||||
link.download = file.filename;
|
||||
link.click();
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
{contentError && (
|
||||
<CardContent>
|
||||
<div className="p-4 border border-red-200 rounded-lg bg-red-50 dark:bg-red-900/20 dark:border-red-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-red-800 dark:text-red-300 mb-1">
|
||||
Content Error
|
||||
</h4>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{contentError}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setContentError(null)}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
{sizeWarning && (
|
||||
<CardContent>
|
||||
<div className="p-4 border border-orange-200 rounded-lg bg-orange-50 dark:bg-orange-900/20 dark:border-orange-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-orange-800 dark:text-orange-300 mb-1">
|
||||
Large File Warning
|
||||
</h4>
|
||||
<p className="text-sm text-orange-600 dark:text-orange-400">
|
||||
{sizeWarning}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSizeWarning(null)}
|
||||
className="text-orange-600 hover:text-orange-800 dark:text-orange-400 dark:hover:text-orange-300"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
{fileContentUrl && (
|
||||
<CardContent>
|
||||
<div className="w-full">
|
||||
{isCSVFile && fileContent ? (
|
||||
// CSV files: Use custom CSV viewer
|
||||
<CSVViewer content={fileContent} />
|
||||
) : isJsonFile && fileContent ? (
|
||||
// JSON files: Use custom JSON viewer
|
||||
<JsonViewer content={fileContent} />
|
||||
) : (
|
||||
// Other files: Use iframe preview
|
||||
<div className="relative">
|
||||
{fileContent && (
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<CopyButton
|
||||
content={fileContent}
|
||||
copyMessage="Copied file content to clipboard!"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
key={fileContentUrl} // Force iframe reload when URL changes
|
||||
src={fileContentUrl}
|
||||
className="w-full h-[600px] border rounded-lg bg-white dark:bg-gray-900"
|
||||
title="File Preview"
|
||||
onError={() => {
|
||||
console.warn(
|
||||
"Iframe failed to load content, this may be a browser security restriction"
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Additional Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>File Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div>
|
||||
<span className="font-medium">File ID:</span>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<code className="bg-muted px-2 py-1 rounded text-sm font-mono">
|
||||
{file.id}
|
||||
</code>
|
||||
<CopyButton
|
||||
content={file.id}
|
||||
copyMessage="Copied file ID to clipboard!"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{file.expires_at && (
|
||||
<div>
|
||||
<span className="font-medium">Status:</span>
|
||||
<div className="mt-1">
|
||||
<span
|
||||
className={`inline-block px-2 py-1 rounded text-sm ${
|
||||
isExpired
|
||||
? "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-300"
|
||||
: "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300"
|
||||
}`}
|
||||
>
|
||||
{isExpired ? "Expired" : "Active"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const sidebar = (
|
||||
<div className="space-y-4">
|
||||
{/* Navigation */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => router.push("/logs/files")}
|
||||
className="w-full justify-start p-0"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Files
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Properties */}
|
||||
<PropertiesCard>
|
||||
<PropertyItem label="ID" value={file.id} />
|
||||
<PropertyItem label="Filename" value={file.filename} />
|
||||
<PropertyItem label="Size" value={formatFileSize(file.bytes)} />
|
||||
<PropertyItem label="Purpose" value={formatPurpose(file.purpose)} />
|
||||
<PropertyItem
|
||||
label="Created"
|
||||
value={formatTimestamp(file.created_at)}
|
||||
/>
|
||||
{file.expires_at && (
|
||||
<PropertyItem
|
||||
label="Expires"
|
||||
value={
|
||||
<span className={isExpired ? "text-destructive" : ""}>
|
||||
{formatTimestamp(file.expires_at)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</PropertiesCard>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailLayout
|
||||
title="File Details"
|
||||
mainContent={mainContent}
|
||||
sidebar={sidebar}
|
||||
/>
|
||||
);
|
||||
}
|
||||
293
src/llama_stack_ui/components/files/file-editor.tsx
Normal file
293
src/llama_stack_ui/components/files/file-editor.tsx
Normal file
|
|
@ -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<File[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<Record<string, number>>(
|
||||
{}
|
||||
);
|
||||
|
||||
const [formData, setFormData] = useState<FileUploadFormData>({
|
||||
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<ReturnType<typeof toFile>>;
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div
|
||||
className={`p-4 rounded-lg ${
|
||||
showSuccessState
|
||||
? "bg-green-50 dark:bg-green-950/20 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800"
|
||||
: "bg-red-50 dark:bg-red-950/20 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800"
|
||||
}`}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showSuccessState && (
|
||||
<>
|
||||
{/* File Upload Zone */}
|
||||
<div>
|
||||
<Label className="text-base font-medium mb-3 block">
|
||||
Select Files
|
||||
</Label>
|
||||
<FileUploadZone
|
||||
onFilesSelected={handleFilesSelected}
|
||||
selectedFiles={selectedFiles}
|
||||
onRemoveFile={handleRemoveFile}
|
||||
disabled={isUploading}
|
||||
maxFiles={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Upload Configuration */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Purpose Selection */}
|
||||
<div>
|
||||
<Label htmlFor="purpose" className="text-sm font-medium">
|
||||
Purpose
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.purpose}
|
||||
onValueChange={(
|
||||
value:
|
||||
| "fine-tune"
|
||||
| "assistants"
|
||||
| "user_data"
|
||||
| "batch"
|
||||
| "vision"
|
||||
| "evals"
|
||||
) => setFormData(prev => ({ ...prev, purpose: value }))}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select purpose" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fine-tune">Fine-tuning</SelectItem>
|
||||
<SelectItem value="assistants">Assistants</SelectItem>
|
||||
<SelectItem value="user_data">User Data</SelectItem>
|
||||
<SelectItem value="batch">Batch Processing</SelectItem>
|
||||
<SelectItem value="vision">Vision</SelectItem>
|
||||
<SelectItem value="evals">Evaluations</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{getPurposeDescription(formData.purpose)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Expiration */}
|
||||
<div>
|
||||
<Label htmlFor="expires" className="text-sm font-medium">
|
||||
Expires After
|
||||
</Label>
|
||||
<Select
|
||||
value={String(formData.expiresAfter || 0)}
|
||||
onValueChange={value =>
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
expiresAfter: parseInt(value) || undefined,
|
||||
}))
|
||||
}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select expiration" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">Never</SelectItem>
|
||||
<SelectItem value="3600">1 hour</SelectItem>
|
||||
<SelectItem value="86400">1 day</SelectItem>
|
||||
<SelectItem value="604800">7 days</SelectItem>
|
||||
<SelectItem value="2592000">30 days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formData.expiresAfter && formData.expiresAfter > 0
|
||||
? `Files will be automatically deleted after ${formatExpiresAfter(formData.expiresAfter)}`
|
||||
: "Files will not expire automatically"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Progress */}
|
||||
{isUploading && Object.keys(uploadProgress).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Upload Progress</Label>
|
||||
{selectedFiles.map((file, index) => {
|
||||
const progressKey = `${file.name}-${index}`;
|
||||
const progress = uploadProgress[progressKey] || 0;
|
||||
|
||||
return (
|
||||
<div key={progressKey} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{file.name}</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={onCancel} disabled={isUploading}>
|
||||
{showSuccessState ? "Close" : "Cancel"}
|
||||
</Button>
|
||||
{!showSuccessState && (
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!canUpload}
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
{isUploading
|
||||
? "Uploading..."
|
||||
: `Upload ${selectedFiles.length} File${selectedFiles.length !== 1 ? "s" : ""}`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
288
src/llama_stack_ui/components/files/file-upload-zone.test.tsx
Normal file
288
src/llama_stack_ui/components/files/file-upload-zone.test.tsx
Normal file
|
|
@ -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(<FileUploadZone {...defaultProps} />);
|
||||
|
||||
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(<FileUploadZone {...defaultProps} />);
|
||||
|
||||
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(<FileUploadZone {...defaultProps} disabled={true} />);
|
||||
|
||||
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(<FileUploadZone {...defaultProps} />);
|
||||
|
||||
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(<FileUploadZone {...defaultProps} />);
|
||||
|
||||
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(
|
||||
<FileUploadZone {...defaultProps} selectedFiles={[existingFile]} />
|
||||
);
|
||||
|
||||
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(
|
||||
<FileUploadZone
|
||||
{...defaultProps}
|
||||
selectedFiles={existingFiles}
|
||||
maxFiles={10}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<FileUploadZone {...defaultProps} selectedFiles={selectedFiles} />
|
||||
);
|
||||
|
||||
expect(screen.getByText("test.txt")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls onRemoveFile when remove button clicked", () => {
|
||||
render(
|
||||
<FileUploadZone {...defaultProps} selectedFiles={selectedFiles} />
|
||||
);
|
||||
|
||||
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(<FileUploadZone {...defaultProps} />);
|
||||
|
||||
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(<FileUploadZone {...defaultProps} disabled={true} />);
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
280
src/llama_stack_ui/components/files/file-upload-zone.tsx
Normal file
280
src/llama_stack_ui/components/files/file-upload-zone.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* Upload Zone */}
|
||||
<div
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer",
|
||||
isDragOver
|
||||
? "border-blue-500 bg-blue-50 dark:bg-blue-950/20"
|
||||
: "border-gray-300 dark:border-gray-600",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
onClick={handleFileSelect}
|
||||
>
|
||||
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-medium">
|
||||
{isDragOver
|
||||
? "Drop files here"
|
||||
: "Click to upload or drag and drop"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Supported formats: {getSupportedFormats()}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Max file size: {formatFileSize(100 * 1024 * 1024)} • Max {maxFiles}{" "}
|
||||
files
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Files List */}
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">
|
||||
Selected Files ({selectedFiles.length})
|
||||
</h4>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{selectedFiles.map((file, index) => {
|
||||
const icon = getFileTypeIcon(file.name.split(".").pop());
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${file.name}-${index}`}
|
||||
className="flex items-center gap-3 p-3 border rounded-lg bg-muted/50"
|
||||
>
|
||||
<div className="text-lg">{icon}</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-xs bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded">
|
||||
{file.name.split(".").pop()?.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatFileSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onRemoveFile(index);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
265
src/llama_stack_ui/components/files/files-management.test.tsx
Normal file
265
src/llama_stack_ui/components/files/files-management.test.tsx
Normal file
|
|
@ -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(<FilesManagement />);
|
||||
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(<FilesManagement />);
|
||||
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(<FilesManagement />);
|
||||
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(<FilesManagement />);
|
||||
|
||||
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(<FilesManagement />);
|
||||
|
||||
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(<FilesManagement />);
|
||||
|
||||
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(<FilesManagement />);
|
||||
|
||||
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(<FilesManagement />);
|
||||
|
||||
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(<FilesManagement />);
|
||||
|
||||
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(<FilesManagement />);
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
417
src/llama_stack_ui/components/files/files-management.tsx
Normal file
417
src/llama_stack_ui/components/files/files-management.tsx
Normal file
|
|
@ -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<Set<string>>(new Set());
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [modalError, setModalError] = useState<string | null>(null);
|
||||
const [showSuccessState, setShowSuccessState] = useState(false);
|
||||
|
||||
const {
|
||||
data: files,
|
||||
status,
|
||||
hasMore,
|
||||
error,
|
||||
loadMore,
|
||||
refetch,
|
||||
} = usePagination<FileResource>({
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return <div className="text-destructive">Error: {error?.message}</div>;
|
||||
}
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground mb-4">No files found.</p>
|
||||
<Button onClick={() => setShowUploadModal(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Upload Your First File
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="space-y-4">
|
||||
{/* Search Bar */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Search files..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto flex-1 min-h-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>File Name</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Size</TableHead>
|
||||
<TableHead>Purpose</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredFiles.map(file => {
|
||||
const fileIcon = getFileTypeIcon(
|
||||
file.filename.split(".").pop()
|
||||
);
|
||||
const isExpired =
|
||||
file.expires_at && file.expires_at * 1000 < Date.now();
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={file.id}
|
||||
onClick={() => router.push(`/logs/files/${file.id}`)}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto font-mono text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
onClick={() => router.push(`/logs/files/${file.id}`)}
|
||||
>
|
||||
{file.id}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{fileIcon}</span>
|
||||
<span title={file.filename}>
|
||||
{truncateFilename(file.filename)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{file.filename.split(".").pop()?.toUpperCase() ||
|
||||
"Unknown"}
|
||||
</TableCell>
|
||||
<TableCell>{formatFileSize(file.bytes)}</TableCell>
|
||||
<TableCell>{formatPurpose(file.purpose)}</TableCell>
|
||||
<TableCell>{formatTimestamp(file.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
{file.expires_at ? (
|
||||
<span className={isExpired ? "text-destructive" : ""}>
|
||||
{formatTimestamp(file.expires_at)}
|
||||
</span>
|
||||
) : (
|
||||
"Never"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleDownloadFile(file.id, file.filename);
|
||||
}}
|
||||
title="Download file"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleDeleteFile(file.id);
|
||||
}}
|
||||
disabled={deletingFiles.has(file.id)}
|
||||
title="Delete file"
|
||||
>
|
||||
{deletingFiles.has(file.id) ? (
|
||||
"Deleting..."
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Files</h1>
|
||||
<Button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
disabled={status === "loading"}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Upload File
|
||||
</Button>
|
||||
</div>
|
||||
{renderContent()}
|
||||
|
||||
{/* Upload File Modal */}
|
||||
{showUploadModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-background border rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden">
|
||||
<div className="p-6 border-b flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">Upload File</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
className="p-1 h-auto"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||
<FileEditor
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
onCancel={handleCancel}
|
||||
error={modalError}
|
||||
showSuccessState={showSuccessState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
273
src/llama_stack_ui/components/files/json-viewer.tsx
Normal file
273
src/llama_stack_ui/components/files/json-viewer.tsx
Normal file
|
|
@ -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<Set<string>>(
|
||||
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 (
|
||||
<div key={path}>
|
||||
<div
|
||||
className="flex items-center cursor-pointer hover:bg-muted/50 py-1 px-2 rounded"
|
||||
style={{ marginLeft: indent }}
|
||||
onClick={() => toggleExpanded(path)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
)}
|
||||
{node.key && (
|
||||
<span className="font-medium text-blue-700 dark:text-blue-300 mr-2">
|
||||
"{node.key}":
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground">
|
||||
{`{${entries.length} ${entries.length === 1 ? "property" : "properties"}}`}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{entries.map(([key, value]) =>
|
||||
renderValue(
|
||||
{
|
||||
key,
|
||||
value,
|
||||
type: getValueType(value),
|
||||
},
|
||||
`${path}.${key}`,
|
||||
depth + 1
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === "array") {
|
||||
return (
|
||||
<div key={path}>
|
||||
<div
|
||||
className="flex items-center cursor-pointer hover:bg-muted/50 py-1 px-2 rounded"
|
||||
style={{ marginLeft: indent }}
|
||||
onClick={() => toggleExpanded(path)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
)}
|
||||
{node.key && (
|
||||
<span className="font-medium text-blue-700 dark:text-blue-300 mr-2">
|
||||
"{node.key}":
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground">
|
||||
[{node.value.length} {node.value.length === 1 ? "item" : "items"}]
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{node.value.map((item: JsonValue, index: number) =>
|
||||
renderValue(
|
||||
{
|
||||
key: index.toString(),
|
||||
value: item,
|
||||
type: getValueType(item),
|
||||
},
|
||||
`${path}[${index}]`,
|
||||
depth + 1
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Primitive values
|
||||
return (
|
||||
<div
|
||||
key={path}
|
||||
className="flex items-center py-1 px-2 hover:bg-muted/50 rounded"
|
||||
style={{ marginLeft: indent }}
|
||||
>
|
||||
{node.key && (
|
||||
<span className="font-medium text-blue-700 dark:text-blue-300 mr-2">
|
||||
"{node.key}":
|
||||
</span>
|
||||
)}
|
||||
<span className={getValueColor(node.type)}>
|
||||
{formatPrimitiveValue(node.value, node.type)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-red-200 rounded-lg bg-red-50 dark:bg-red-900/20 dark:border-red-800">
|
||||
<h3 className="font-semibold text-red-800 dark:text-red-300 mb-2">
|
||||
JSON Parsing Error
|
||||
</h3>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
Failed to parse JSON file. Please check the file format.
|
||||
</p>
|
||||
<p className="text-xs text-red-500 dark:text-red-400 mt-1 font-mono">
|
||||
{parsedData.error}
|
||||
</p>
|
||||
</div>
|
||||
{/* Show raw content as fallback */}
|
||||
<div className="border rounded-lg p-4 bg-muted/50">
|
||||
<h4 className="font-medium mb-2">Raw Content:</h4>
|
||||
<pre className="text-sm font-mono whitespace-pre-wrap overflow-auto max-h-96 text-muted-foreground">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* JSON Info */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
JSON Document • {Object.keys(parsedData.data || {}).length} root{" "}
|
||||
{Object.keys(parsedData.data || {}).length === 1
|
||||
? "property"
|
||||
: "properties"}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExpandedKeys(new Set(["root"]))}
|
||||
>
|
||||
Collapse All
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Expand all nodes (this is a simple version - could be more sophisticated)
|
||||
const allKeys = new Set<string>();
|
||||
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
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON Tree */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="max-h-[500px] overflow-auto scrollbar-thin scrollbar-track-muted scrollbar-thumb-muted-foreground p-4 bg-muted/20">
|
||||
{renderValue({
|
||||
value: parsedData.data,
|
||||
type: getValueType(parsedData.data),
|
||||
isRoot: true,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export function DetailLoadingView() {
|
|||
<>
|
||||
<Skeleton className="h-8 w-3/4 mb-6" /> {/* Title Skeleton */}
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="flex-grow md:w-2/3 space-y-6">
|
||||
<div className="flex-1 min-w-0 space-y-6">
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<Card key={`main-skeleton-card-${i}`}>
|
||||
<CardHeader>
|
||||
|
|
@ -23,7 +23,7 @@ export function DetailLoadingView() {
|
|||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="md:w-1/3">
|
||||
<div className="w-80 flex-shrink-0">
|
||||
<div className="p-4 border rounded-lg shadow-sm bg-white space-y-3">
|
||||
<Skeleton className="h-6 w-1/3 mb-3" />{" "}
|
||||
{/* Properties Title Skeleton */}
|
||||
|
|
@ -137,8 +137,10 @@ export function DetailLayout({
|
|||
<>
|
||||
<h1 className="text-2xl font-bold mb-6">{title}</h1>
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="flex-grow md:w-2/3 space-y-6">{mainContent}</div>
|
||||
<div className="md:w-1/3">{sidebar}</div>
|
||||
<div className="flex-1 min-w-0 space-y-6 overflow-x-hidden">
|
||||
{mainContent}
|
||||
</div>
|
||||
<div className="w-80 flex-shrink-0">{sidebar}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<SelectValue placeholder="Select Embedding Model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{embeddingModels.map((model, index) => (
|
||||
{embeddingModels.map(model => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.id}
|
||||
</SelectItem>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export interface PaginationReturn<T> {
|
|||
hasMore: boolean;
|
||||
error: Error | null;
|
||||
loadMore: () => void;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
interface UsePaginationParams<T> extends UsePaginationOptions {
|
||||
|
|
@ -144,6 +145,13 @@ export function usePagination<T>({
|
|||
}
|
||||
}, [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<T>({
|
|||
hasMore: state.hasMore,
|
||||
error: state.error,
|
||||
loadMore,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
205
src/llama_stack_ui/lib/file-utils.test.ts
Normal file
205
src/llama_stack_ui/lib/file-utils.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
293
src/llama_stack_ui/lib/file-utils.ts
Normal file
293
src/llama_stack_ui/lib/file-utils.ts
Normal file
|
|
@ -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}`;
|
||||
}
|
||||
295
src/llama_stack_ui/lib/file-validation.test.ts
Normal file
295
src/llama_stack_ui/lib/file-validation.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
280
src/llama_stack_ui/lib/file-validation.ts
Normal file
280
src/llama_stack_ui/lib/file-validation.ts
Normal file
|
|
@ -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<string>();
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
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")}`;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
25
src/llama_stack_ui/package-lock.json
generated
25
src/llama_stack_ui/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue