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:
Francisco Javier Arceo 2025-12-09 16:28:05 -05:00 committed by GitHub
parent 6ad5fb5577
commit fcea9893a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 4034 additions and 23 deletions

View 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 &quot;
{parsedData.headers[sortColumn] || `Column ${sortColumn + 1}`}&quot;
({sortDirection}ending)
</p>
)}
{parsedData.headers.length > 4 && (
<p className="italic">
💡 Scroll horizontally to view all {parsedData.headers.length}{" "}
columns
</p>
)}
</div>
</div>
);
}

View 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}
/>
);
}

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

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

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

View 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();
});
});
});

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

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

View file

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

View file

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

View file

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