feat(ui): Adding Vector Store Files to Admin UI (#3041)
Some checks failed
Integration Auth Tests / test-matrix (oauth2_token) (push) Failing after 4s
Integration Tests (Replay) / discover-tests (push) Successful in 3s
Test External Providers Installed via Module / test-external-providers-from-module (venv) (push) Has been skipped
Python Package Build Test / build (3.12) (push) Failing after 9s
Vector IO Integration Tests / test-matrix (3.12, remote::qdrant) (push) Failing after 15s
Vector IO Integration Tests / test-matrix (3.12, inline::sqlite-vec) (push) Failing after 16s
Unit Tests / unit-tests (3.13) (push) Failing after 12s
Test External API and Providers / test-external (venv) (push) Failing after 13s
SqlStore Integration Tests / test-postgres (3.12) (push) Failing after 20s
SqlStore Integration Tests / test-postgres (3.13) (push) Failing after 20s
Integration Tests (Replay) / Integration Tests (, , , client=, vision=) (push) Failing after 14s
Vector IO Integration Tests / test-matrix (3.12, inline::milvus) (push) Failing after 20s
Python Package Build Test / build (3.13) (push) Failing after 15s
Vector IO Integration Tests / test-matrix (3.12, remote::pgvector) (push) Failing after 18s
Vector IO Integration Tests / test-matrix (3.12, remote::weaviate) (push) Failing after 23s
Vector IO Integration Tests / test-matrix (3.13, remote::pgvector) (push) Failing after 23s
Vector IO Integration Tests / test-matrix (3.13, remote::weaviate) (push) Failing after 14s
Vector IO Integration Tests / test-matrix (3.12, inline::faiss) (push) Failing after 17s
Vector IO Integration Tests / test-matrix (3.13, remote::chromadb) (push) Failing after 15s
Vector IO Integration Tests / test-matrix (3.12, remote::chromadb) (push) Failing after 17s
Vector IO Integration Tests / test-matrix (3.13, inline::milvus) (push) Failing after 15s
Vector IO Integration Tests / test-matrix (3.13, inline::faiss) (push) Failing after 16s
Vector IO Integration Tests / test-matrix (3.13, remote::qdrant) (push) Failing after 17s
Vector IO Integration Tests / test-matrix (3.13, inline::sqlite-vec) (push) Failing after 57s
Unit Tests / unit-tests (3.12) (push) Failing after 55s
Pre-commit / pre-commit (push) Successful in 2m10s

# What does this PR do?
This PR updates the UI to create new:
1. `/files/{file_id}` 
2. `files/{file_id}/contents`
3. `files/{file_id}/contents/{content_id}` 

The list of files are clickable which brings the user to the FIles
Detail page
The File Details page shows all of the content
The content details page shows the individual chunk/content parsed 

These only use our existing OpenAI compatible APIs. I have a separate
branch where I expose the embedding and the portal is correctly
populated. I included the FE rendering code for that in this PR.

1. `vector-stores/{vector_store_id}/files/{file_id}` 
<img width="1913" height="1351" alt="Screenshot 2025-08-06 at 10 20
12 PM"
src="https://github.com/user-attachments/assets/08010d5e-60c8-4bd9-9f3e-a2731ed1ad55"
/>

2. `vector-stores/{vector_store_id}/files/{file_id}/contents`
<img width="1920" height="1272" alt="Screenshot 2025-08-06 at 10 21
23 PM"
src="https://github.com/user-attachments/assets/3b91e67b-5d64-4fe6-91b6-18f14587e850"
/>

3.
`vector-stores/{vector_store_id}/files/{file_id}/contents/{content_id}`
<img width="1916" height="1273" alt="Screenshot 2025-08-06 at 10 21
45 PM"
src="https://github.com/user-attachments/assets/d38ca996-e8d9-460c-9e39-7ff0cb5ec0dd"
/>

## Test Plan
I tested this locally and reviewed the code. I generated a significant
share of the code with Claude and some manual intervention. After this,
I'll begin adding tests to the UI.

---------

Signed-off-by: Francisco Javier Arceo <farceo@redhat.com>
This commit is contained in:
Francisco Arceo 2025-08-08 08:44:06 -06:00 committed by GitHub
parent 9e78f2da96
commit 9b70bb9d4b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1175 additions and 75 deletions

View file

@ -0,0 +1,383 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useAuthClient } from "@/hooks/use-auth-client";
import { ContentsAPI, VectorStoreContentItem } from "@/lib/contents-api";
import type { VectorStore } from "llama-stack-client/resources/vector-stores/vector-stores";
import type { VectorStoreFile } from "llama-stack-client/resources/vector-stores/files";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Edit, Save, X, Trash2 } from "lucide-react";
import {
DetailLoadingView,
DetailErrorView,
DetailNotFoundView,
DetailLayout,
PropertiesCard,
PropertyItem,
} from "@/components/layout/detail-layout";
import { PageBreadcrumb, BreadcrumbSegment } from "@/components/layout/page-breadcrumb";
export default function ContentDetailPage() {
const params = useParams();
const router = useRouter();
const vectorStoreId = params.id as string;
const fileId = params.fileId as string;
const contentId = params.contentId as string;
const client = useAuthClient();
const getTextFromContent = (content: any): string => {
if (typeof content === 'string') {
return content;
} else if (content && content.type === 'text') {
return content.text;
}
return '';
};
const [store, setStore] = useState<VectorStore | null>(null);
const [file, setFile] = useState<VectorStoreFile | null>(null);
const [content, setContent] = useState<VectorStoreContentItem | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [editedContent, setEditedContent] = useState("");
const [editedMetadata, setEditedMetadata] = useState<Record<string, any>>({});
const [isEditingEmbedding, setIsEditingEmbedding] = useState(false);
const [editedEmbedding, setEditedEmbedding] = useState<number[]>([]);
useEffect(() => {
if (!vectorStoreId || !fileId || !contentId) return;
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
const [storeResponse, fileResponse] = await Promise.all([
client.vectorStores.retrieve(vectorStoreId),
client.vectorStores.files.retrieve(vectorStoreId, fileId),
]);
setStore(storeResponse as VectorStore);
setFile(fileResponse as VectorStoreFile);
const contentsAPI = new ContentsAPI(client);
const contentsResponse = await contentsAPI.listContents(vectorStoreId, fileId);
const targetContent = contentsResponse.data.find(c => c.id === contentId);
if (targetContent) {
setContent(targetContent);
setEditedContent(getTextFromContent(targetContent.content));
setEditedMetadata({ ...targetContent.metadata });
setEditedEmbedding(targetContent.embedding || []);
} else {
throw new Error(`Content ${contentId} not found`);
}
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to load content."));
} finally {
setIsLoading(false);
}
};
fetchData();
}, [vectorStoreId, fileId, contentId, client]);
const handleSave = async () => {
if (!content) return;
try {
const updates: { content?: string; metadata?: Record<string, any> } = {};
if (editedContent !== getTextFromContent(content.content)) {
updates.content = editedContent;
}
if (JSON.stringify(editedMetadata) !== JSON.stringify(content.metadata)) {
updates.metadata = editedMetadata;
}
if (Object.keys(updates).length > 0) {
const contentsAPI = new ContentsAPI(client);
const updatedContent = await contentsAPI.updateContent(vectorStoreId, fileId, contentId, updates);
setContent(updatedContent);
}
setIsEditing(false);
} catch (err) {
console.error('Failed to update content:', err);
}
};
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this content?')) return;
try {
const contentsAPI = new ContentsAPI(client);
await contentsAPI.deleteContent(vectorStoreId, fileId, contentId);
router.push(`/logs/vector-stores/${vectorStoreId}/files/${fileId}/contents`);
} catch (err) {
console.error('Failed to delete content:', err);
}
};
const handleCancel = () => {
setEditedContent(content ? getTextFromContent(content.content) : "");
setEditedMetadata({ ...content?.metadata });
setEditedEmbedding(content?.embedding || []);
setIsEditing(false);
setIsEditingEmbedding(false);
};
const title = `Content: ${contentId}`;
const breadcrumbSegments: BreadcrumbSegment[] = [
{ label: "Vector Stores", href: "/logs/vector-stores" },
{ label: store?.name || vectorStoreId, href: `/logs/vector-stores/${vectorStoreId}` },
{ label: "Files", href: `/logs/vector-stores/${vectorStoreId}` },
{ label: fileId, href: `/logs/vector-stores/${vectorStoreId}/files/${fileId}` },
{ label: "Contents", href: `/logs/vector-stores/${vectorStoreId}/files/${fileId}/contents` },
{ label: contentId },
];
if (error) {
return <DetailErrorView title={title} id={contentId} error={error} />;
}
if (isLoading) {
return <DetailLoadingView title={title} />;
}
if (!content) {
return <DetailNotFoundView title={title} id={contentId} />;
}
const mainContent = (
<>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Content</CardTitle>
<div className="flex gap-2">
{isEditing ? (
<>
<Button size="sm" onClick={handleSave}>
<Save className="h-4 w-4 mr-1" />
Save
</Button>
<Button size="sm" variant="outline" onClick={handleCancel}>
<X className="h-4 w-4 mr-1" />
Cancel
</Button>
</>
) : (
<>
<Button size="sm" onClick={() => setIsEditing(true)}>
<Edit className="h-4 w-4 mr-1" />
Edit
</Button>
<Button size="sm" variant="destructive" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
</>
)}
</div>
</CardHeader>
<CardContent>
{isEditing ? (
<textarea
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
className="w-full h-64 p-3 border rounded-md resize-none font-mono text-sm"
placeholder="Enter content..."
/>
) : (
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
<pre className="whitespace-pre-wrap font-mono text-sm text-gray-900 dark:text-gray-100">
{getTextFromContent(content.content)}
</pre>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Content Embedding</CardTitle>
<div className="flex gap-2">
{isEditingEmbedding ? (
<>
<Button size="sm" onClick={() => {
setIsEditingEmbedding(false);
}}>
<Save className="h-4 w-4 mr-1" />
Save
</Button>
<Button size="sm" variant="outline" onClick={() => {
setEditedEmbedding(content?.embedding || []);
setIsEditingEmbedding(false);
}}>
<X className="h-4 w-4 mr-1" />
Cancel
</Button>
</>
) : (
<Button size="sm" onClick={() => setIsEditingEmbedding(true)}>
<Edit className="h-4 w-4 mr-1" />
Edit
</Button>
)}
</div>
</CardHeader>
<CardContent>
{content?.embedding && content.embedding.length > 0 ? (
isEditingEmbedding ? (
<div className="space-y-2">
<p className="text-sm text-gray-600 dark:text-gray-400">
Embedding ({editedEmbedding.length}D vector):
</p>
<textarea
value={JSON.stringify(editedEmbedding, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
if (Array.isArray(parsed) && parsed.every(v => typeof v === 'number')) {
setEditedEmbedding(parsed);
}
} catch {
}
}}
className="w-full h-32 p-3 border rounded-md resize-none font-mono text-xs"
placeholder="Enter embedding as JSON array..."
/>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="font-mono text-xs bg-gray-100 dark:bg-gray-800 rounded px-2 py-1">
{content.embedding.length}D vector
</span>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-md max-h-32 overflow-y-auto">
<pre className="whitespace-pre-wrap font-mono text-xs text-gray-900 dark:text-gray-100">
[{content.embedding.slice(0, 20).map(v => v.toFixed(6)).join(', ')}
{content.embedding.length > 20 ? `\n... and ${content.embedding.length - 20} more values` : ''}]
</pre>
</div>
</div>
)
) : (
<p className="text-gray-500 italic text-sm">
No embedding available for this content.
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Metadata</CardTitle>
</CardHeader>
<CardContent>
{isEditing ? (
<div className="space-y-2">
{Object.entries(editedMetadata).map(([key, value]) => (
<div key={key} className="flex gap-2">
<Input
value={key}
onChange={(e) => {
const newMetadata = { ...editedMetadata };
delete newMetadata[key];
newMetadata[e.target.value] = value;
setEditedMetadata(newMetadata);
}}
placeholder="Key"
className="flex-1"
/>
<Input
value={typeof value === 'string' ? value : JSON.stringify(value)}
onChange={(e) => {
setEditedMetadata({
...editedMetadata,
[key]: e.target.value
});
}}
placeholder="Value"
className="flex-1"
/>
</div>
))}
<Button
size="sm"
variant="outline"
onClick={() => {
setEditedMetadata({
...editedMetadata,
['']: ''
});
}}
>
Add Field
</Button>
</div>
) : (
<div className="space-y-2">
{Object.entries(content.metadata).map(([key, value]) => (
<div key={key} className="flex justify-between py-1">
<span className="font-medium text-gray-600">{key}:</span>
<span className="font-mono text-sm">
{typeof value === 'string' ? value : JSON.stringify(value)}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</>
);
const sidebar = (
<PropertiesCard>
<PropertyItem label="Content ID" value={contentId} />
<PropertyItem label="File ID" value={fileId} />
<PropertyItem label="Vector Store ID" value={vectorStoreId} />
<PropertyItem label="Object Type" value={content.object} />
<PropertyItem
label="Created"
value={new Date(content.created_timestamp * 1000).toLocaleString()}
/>
<PropertyItem
label="Content Length"
value={`${getTextFromContent(content.content).length} chars`}
/>
{content.metadata.chunk_window && (
<PropertyItem
label="Position"
value={content.metadata.chunk_window}
/>
)}
{file && (
<>
<PropertyItem label="File Status" value={file.status} />
<PropertyItem label="File Usage" value={`${file.usage_bytes} bytes`} />
</>
)}
{store && (
<>
<PropertyItem label="Store Name" value={store.name || ""} />
<PropertyItem
label="Provider ID"
value={(store.metadata.provider_id as string) || ""}
/>
</>
)}
</PropertiesCard>
);
return (
<>
<PageBreadcrumb segments={breadcrumbSegments} />
<DetailLayout title={title} mainContent={mainContent} sidebar={sidebar} />
</>
);
}

View file

@ -0,0 +1,297 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useAuthClient } from "@/hooks/use-auth-client";
import { ContentsAPI, VectorStoreContentItem } from "@/lib/contents-api";
import type { VectorStore } from "llama-stack-client/resources/vector-stores/vector-stores";
import type { VectorStoreFile } from "llama-stack-client/resources/vector-stores/files";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Edit, Trash2, Eye } from "lucide-react";
import {
DetailLoadingView,
DetailErrorView,
DetailNotFoundView,
DetailLayout,
PropertiesCard,
PropertyItem,
} from "@/components/layout/detail-layout";
import { PageBreadcrumb, BreadcrumbSegment } from "@/components/layout/page-breadcrumb";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export default function ContentsListPage() {
const params = useParams();
const router = useRouter();
const vectorStoreId = params.id as string;
const fileId = params.fileId as string;
const client = useAuthClient();
const getTextFromContent = (content: any): string => {
if (typeof content === 'string') {
return content;
} else if (content && content.type === 'text') {
return content.text;
}
return '';
};
const [store, setStore] = useState<VectorStore | null>(null);
const [file, setFile] = useState<VectorStoreFile | null>(null);
const [contents, setContents] = useState<VectorStoreContentItem[]>([]);
const [isLoadingStore, setIsLoadingStore] = useState(true);
const [isLoadingFile, setIsLoadingFile] = useState(true);
const [isLoadingContents, setIsLoadingContents] = useState(true);
const [errorStore, setErrorStore] = useState<Error | null>(null);
const [errorFile, setErrorFile] = useState<Error | null>(null);
const [errorContents, setErrorContents] = useState<Error | null>(null);
useEffect(() => {
if (!vectorStoreId) return;
const fetchStore = async () => {
setIsLoadingStore(true);
setErrorStore(null);
try {
const response = await client.vectorStores.retrieve(vectorStoreId);
setStore(response as VectorStore);
} catch (err) {
setErrorStore(err instanceof Error ? err : new Error("Failed to load vector store."));
} finally {
setIsLoadingStore(false);
}
};
fetchStore();
}, [vectorStoreId, client]);
useEffect(() => {
if (!vectorStoreId || !fileId) return;
const fetchFile = async () => {
setIsLoadingFile(true);
setErrorFile(null);
try {
const response = await client.vectorStores.files.retrieve(vectorStoreId, fileId);
setFile(response as VectorStoreFile);
} catch (err) {
setErrorFile(err instanceof Error ? err : new Error("Failed to load file."));
} finally {
setIsLoadingFile(false);
}
};
fetchFile();
}, [vectorStoreId, fileId, client]);
useEffect(() => {
if (!vectorStoreId || !fileId) return;
const fetchContents = async () => {
setIsLoadingContents(true);
setErrorContents(null);
try {
const contentsAPI = new ContentsAPI(client);
const contentsResponse = await contentsAPI.listContents(vectorStoreId, fileId, { limit: 100 });
setContents(contentsResponse.data);
} catch (err) {
setErrorContents(err instanceof Error ? err : new Error("Failed to load contents."));
} finally {
setIsLoadingContents(false);
}
};
fetchContents();
}, [vectorStoreId, fileId, client]);
const handleDeleteContent = async (contentId: string) => {
try {
const contentsAPI = new ContentsAPI(client);
await contentsAPI.deleteContent(vectorStoreId, fileId, contentId);
setContents(contents.filter(content => content.id !== contentId));
} catch (err) {
console.error('Failed to delete content:', err);
}
};
const handleViewContent = (contentId: string) => {
router.push(`/logs/vector-stores/${vectorStoreId}/files/${fileId}/contents/${contentId}`);
};
const title = `Contents in File: ${fileId}`;
const breadcrumbSegments: BreadcrumbSegment[] = [
{ label: "Vector Stores", href: "/logs/vector-stores" },
{ label: store?.name || vectorStoreId, href: `/logs/vector-stores/${vectorStoreId}` },
{ label: "Files", href: `/logs/vector-stores/${vectorStoreId}` },
{ label: fileId, href: `/logs/vector-stores/${vectorStoreId}/files/${fileId}` },
{ label: "Contents" },
];
if (errorStore) {
return <DetailErrorView title={title} id={vectorStoreId} error={errorStore} />;
}
if (isLoadingStore) {
return <DetailLoadingView title={title} />;
}
if (!store) {
return <DetailNotFoundView title={title} id={vectorStoreId} />;
}
const mainContent = (
<>
<Card>
<CardHeader>
<CardTitle>Content Chunks ({contents.length})</CardTitle>
</CardHeader>
<CardContent>
{isLoadingContents ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
) : errorContents ? (
<div className="text-destructive text-sm">
Error loading contents: {errorContents.message}
</div>
) : contents.length > 0 ? (
<Table>
<TableCaption>Contents in this file</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Content ID</TableHead>
<TableHead>Content Preview</TableHead>
<TableHead>Embedding</TableHead>
<TableHead>Position</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{contents.map((content) => (
<TableRow key={content.id}>
<TableCell className="font-mono text-xs">
<Button
variant="link"
className="p-0 h-auto font-mono text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
onClick={() => handleViewContent(content.id)}
title={content.id}
>
{content.id.substring(0, 10)}...
</Button>
</TableCell>
<TableCell>
<div className="max-w-md">
<p className="text-sm truncate" title={getTextFromContent(content.content)}>
{getTextFromContent(content.content)}
</p>
</div>
</TableCell>
<TableCell className="text-xs text-gray-500">
{content.embedding && content.embedding.length > 0 ? (
<div className="max-w-xs">
<span className="font-mono text-xs bg-gray-100 dark:bg-gray-800 rounded px-1 py-0.5" title={`${content.embedding.length}D vector: [${content.embedding.slice(0, 3).map(v => v.toFixed(3)).join(', ')}...]`}>
[{content.embedding.slice(0, 3).map(v => v.toFixed(3)).join(', ')}...] ({content.embedding.length}D)
</span>
</div>
) : (
<span className="text-gray-400 dark:text-gray-500 italic">No embedding</span>
)}
</TableCell>
<TableCell className="text-xs text-gray-500">
{content.metadata.chunk_window
? content.metadata.chunk_window
: `${content.metadata.content_length || 0} chars`}
</TableCell>
<TableCell className="text-xs">
{new Date(content.created_timestamp * 1000).toLocaleString()}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
title="View content details"
onClick={() => handleViewContent(content.id)}
>
<Eye className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
title="Edit content"
onClick={() => handleViewContent(content.id)}
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
title="Delete content"
onClick={() => handleDeleteContent(content.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-gray-500 italic text-sm">
No contents found for this file.
</p>
)}
</CardContent>
</Card>
</>
);
const sidebar = (
<PropertiesCard>
<PropertyItem label="File ID" value={fileId} />
<PropertyItem label="Vector Store ID" value={vectorStoreId} />
{file && (
<>
<PropertyItem label="Status" value={file.status} />
<PropertyItem
label="Created"
value={new Date(file.created_at * 1000).toLocaleString()}
/>
<PropertyItem label="Usage Bytes" value={file.usage_bytes} />
<PropertyItem
label="Chunking Strategy"
value={file.chunking_strategy.type}
/>
</>
)}
{store && (
<>
<PropertyItem label="Store Name" value={store.name || ""} />
<PropertyItem
label="Provider ID"
value={(store.metadata.provider_id as string) || ""}
/>
</>
)}
</PropertiesCard>
);
return (
<>
<PageBreadcrumb segments={breadcrumbSegments} />
<DetailLayout title={title} mainContent={mainContent} sidebar={sidebar} />
</>
);
}

View file

@ -0,0 +1,258 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useAuthClient } from "@/hooks/use-auth-client";
import type { VectorStore } from "llama-stack-client/resources/vector-stores/vector-stores";
import type { VectorStoreFile, FileContentResponse } from "llama-stack-client/resources/vector-stores/files";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from "@/components/ui/button";
import { List } from "lucide-react";
import {
DetailLoadingView,
DetailErrorView,
DetailNotFoundView,
DetailLayout,
PropertiesCard,
PropertyItem,
} from "@/components/layout/detail-layout";
import { PageBreadcrumb, BreadcrumbSegment } from "@/components/layout/page-breadcrumb";
export default function FileDetailPage() {
const params = useParams();
const router = useRouter();
const vectorStoreId = params.id as string;
const fileId = params.fileId as string;
const client = useAuthClient();
const [store, setStore] = useState<VectorStore | null>(null);
const [file, setFile] = useState<VectorStoreFile | null>(null);
const [contents, setContents] = useState<FileContentResponse | null>(null);
const [isLoadingStore, setIsLoadingStore] = useState(true);
const [isLoadingFile, setIsLoadingFile] = useState(true);
const [isLoadingContents, setIsLoadingContents] = useState(true);
const [errorStore, setErrorStore] = useState<Error | null>(null);
const [errorFile, setErrorFile] = useState<Error | null>(null);
const [errorContents, setErrorContents] = useState<Error | null>(null);
useEffect(() => {
if (!vectorStoreId) return;
const fetchStore = async () => {
setIsLoadingStore(true);
setErrorStore(null);
try {
const response = await client.vectorStores.retrieve(vectorStoreId);
setStore(response as VectorStore);
} catch (err) {
setErrorStore(err instanceof Error ? err : new Error("Failed to load vector store."));
} finally {
setIsLoadingStore(false);
}
};
fetchStore();
}, [vectorStoreId, client]);
useEffect(() => {
if (!vectorStoreId || !fileId) return;
const fetchFile = async () => {
setIsLoadingFile(true);
setErrorFile(null);
try {
const response = await client.vectorStores.files.retrieve(vectorStoreId, fileId);
setFile(response as VectorStoreFile);
} catch (err) {
setErrorFile(err instanceof Error ? err : new Error("Failed to load file."));
} finally {
setIsLoadingFile(false);
}
};
fetchFile();
}, [vectorStoreId, fileId, client]);
useEffect(() => {
if (!vectorStoreId || !fileId) return;
const fetchContents = async () => {
setIsLoadingContents(true);
setErrorContents(null);
try {
const response = await client.vectorStores.files.content(vectorStoreId, fileId);
setContents(response);
} catch (err) {
setErrorContents(err instanceof Error ? err : new Error("Failed to load contents."));
} finally {
setIsLoadingContents(false);
}
};
fetchContents();
}, [vectorStoreId, fileId, client]);
const handleViewContents = () => {
router.push(`/logs/vector-stores/${vectorStoreId}/files/${fileId}/contents`);
};
const title = `File: ${fileId}`;
const breadcrumbSegments: BreadcrumbSegment[] = [
{ label: "Vector Stores", href: "/logs/vector-stores" },
{ label: store?.name || vectorStoreId, href: `/logs/vector-stores/${vectorStoreId}` },
{ label: "Files", href: `/logs/vector-stores/${vectorStoreId}` },
{ label: fileId },
];
if (errorStore) {
return <DetailErrorView title={title} id={vectorStoreId} error={errorStore} />;
}
if (isLoadingStore) {
return <DetailLoadingView title={title} />;
}
if (!store) {
return <DetailNotFoundView title={title} id={vectorStoreId} />;
}
const mainContent = (
<>
<Card>
<CardHeader>
<CardTitle>File Information</CardTitle>
</CardHeader>
<CardContent>
{isLoadingFile ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
) : errorFile ? (
<div className="text-destructive text-sm">
Error loading file: {errorFile.message}
</div>
) : file ? (
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium mb-2">File Details</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-gray-600 dark:text-gray-400">Status:</span>
<span className="ml-2">{file.status}</span>
</div>
<div>
<span className="font-medium text-gray-600 dark:text-gray-400">Size:</span>
<span className="ml-2">{file.usage_bytes} bytes</span>
</div>
<div>
<span className="font-medium text-gray-600 dark:text-gray-400">Created:</span>
<span className="ml-2">{new Date(file.created_at * 1000).toLocaleString()}</span>
</div>
<div>
<span className="font-medium text-gray-600 dark:text-gray-400">Content Strategy:</span>
<span className="ml-2">{file.chunking_strategy.type}</span>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-lg font-medium mb-3">Actions</h3>
<Button
onClick={handleViewContents}
className="flex items-center gap-2 hover:bg-primary/90 dark:hover:bg-primary/80 hover:scale-105 transition-all duration-200"
>
<List className="h-4 w-4" />
View Contents
</Button>
</div>
</div>
) : (
<p className="text-gray-500 italic text-sm">
File not found.
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Content Summary</CardTitle>
</CardHeader>
<CardContent>
{isLoadingContents ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
) : errorContents ? (
<div className="text-destructive text-sm">
Error loading content summary: {errorContents.message}
</div>
) : contents && contents.content.length > 0 ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-gray-600 dark:text-gray-400">Content Items:</span>
<span className="ml-2">{contents.content.length}</span>
</div>
<div>
<span className="font-medium text-gray-600 dark:text-gray-400">Total Characters:</span>
<span className="ml-2">{contents.content.reduce((total, item) => total + item.text.length, 0)}</span>
</div>
</div>
<div className="pt-2">
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">Preview:</span>
<div className="mt-1 bg-gray-50 dark:bg-gray-800 rounded-md p-3">
<p className="text-sm text-gray-900 dark:text-gray-100 line-clamp-3">
{contents.content[0]?.text.substring(0, 200)}...
</p>
</div>
</div>
</div>
) : (
<p className="text-gray-500 italic text-sm">
No contents found for this file.
</p>
)}
</CardContent>
</Card>
</>
);
const sidebar = (
<PropertiesCard>
<PropertyItem label="File ID" value={fileId} />
<PropertyItem label="Vector Store ID" value={vectorStoreId} />
{file && (
<>
<PropertyItem label="Status" value={file.status} />
<PropertyItem
label="Created"
value={new Date(file.created_at * 1000).toLocaleString()}
/>
<PropertyItem label="Usage Bytes" value={file.usage_bytes} />
<PropertyItem
label="Content Strategy"
value={file.chunking_strategy.type}
/>
</>
)}
{store && (
<>
<PropertyItem label="Store Name" value={store.name || ""} />
<PropertyItem
label="Provider ID"
value={(store.metadata.provider_id as string) || ""}
/>
</>
)}
</PropertiesCard>
);
return (
<>
<PageBreadcrumb segments={breadcrumbSegments} />
<DetailLayout title={title} mainContent={mainContent} sidebar={sidebar} />
</>
);
}

View file

@ -1,16 +1,31 @@
"use client";
import React from "react";
import LogsLayout from "@/components/layout/logs-layout";
import { useParams, usePathname } from "next/navigation";
import {
PageBreadcrumb,
BreadcrumbSegment,
} from "@/components/layout/page-breadcrumb";
export default function VectorStoresLayout({
export default function VectorStoreDetailLayout({
children,
}: {
children: React.ReactNode;
}) {
const params = useParams();
const pathname = usePathname();
const vectorStoreId = params.id as string;
const breadcrumbSegments: BreadcrumbSegment[] = [
{ label: "Vector Stores", href: "/logs/vector-stores" },
{ label: `Details (${vectorStoreId})` },
];
const isBaseDetailPage = pathname === `/logs/vector-stores/${vectorStoreId}`;
return (
<LogsLayout sectionLabel="Vector Stores" basePath="/logs/vector-stores">
<div className="space-y-4">
{isBaseDetailPage && <PageBreadcrumb segments={breadcrumbSegments} />}
{children}
</LogsLayout>
</div>
);
}

View file

@ -8,6 +8,7 @@ import type {
} from "llama-stack-client/resources/vector-stores/vector-stores";
import { useRouter } from "next/navigation";
import { usePagination } from "@/hooks/use-pagination";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
@ -49,73 +50,92 @@ export default function VectorStoresPage() {
}
}, [status, hasMore, loadMore]);
if (status === "loading") {
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 (!stores || stores.length === 0) {
return <p>No vector stores found.</p>;
}
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>
<div className="overflow-auto flex-1 min-h-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Created</TableHead>
<TableHead>Completed</TableHead>
<TableHead>Cancelled</TableHead>
<TableHead>Failed</TableHead>
<TableHead>In Progress</TableHead>
<TableHead>Total</TableHead>
<TableHead>Usage Bytes</TableHead>
<TableHead>Provider ID</TableHead>
<TableHead>Provider Vector DB ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stores.map((store) => {
const fileCounts = store.file_counts;
const metadata = store.metadata || {};
const providerId = metadata.provider_id ?? "";
const providerDbId = metadata.provider_vector_db_id ?? "";
return (
<TableRow
key={store.id}
onClick={() => router.push(`/logs/vector-stores/${store.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/vector-stores/${store.id}`)
}
>
{store.id}
</Button>
</TableCell>
<TableCell>{store.name}</TableCell>
<TableCell>
{new Date(store.created_at * 1000).toLocaleString()}
</TableCell>
<TableCell>{fileCounts.completed}</TableCell>
<TableCell>{fileCounts.cancelled}</TableCell>
<TableCell>{fileCounts.failed}</TableCell>
<TableCell>{fileCounts.in_progress}</TableCell>
<TableCell>{fileCounts.total}</TableCell>
<TableCell>{store.usage_bytes}</TableCell>
<TableCell>{providerId}</TableCell>
<TableCell>{providerDbId}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
if (status === "error") {
return <div className="text-destructive">Error: {error?.message}</div>;
}
if (!stores || stores.length === 0) {
return <p>No vector stores found.</p>;
}
};
return (
<div className="overflow-auto flex-1 min-h-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Created</TableHead>
<TableHead>Completed</TableHead>
<TableHead>Cancelled</TableHead>
<TableHead>Failed</TableHead>
<TableHead>In Progress</TableHead>
<TableHead>Total</TableHead>
<TableHead>Usage Bytes</TableHead>
<TableHead>Provider ID</TableHead>
<TableHead>Provider Vector DB ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stores.map((store) => {
const fileCounts = store.file_counts;
const metadata = store.metadata || {};
const providerId = metadata.provider_id ?? "";
const providerDbId = metadata.provider_vector_db_id ?? "";
return (
<TableRow
key={store.id}
onClick={() => router.push(`/logs/vector-stores/${store.id}`)}
className="cursor-pointer hover:bg-muted/50"
>
<TableCell>{store.id}</TableCell>
<TableCell>{store.name}</TableCell>
<TableCell>
{new Date(store.created_at * 1000).toLocaleString()}
</TableCell>
<TableCell>{fileCounts.completed}</TableCell>
<TableCell>{fileCounts.cancelled}</TableCell>
<TableCell>{fileCounts.failed}</TableCell>
<TableCell>{fileCounts.in_progress}</TableCell>
<TableCell>{fileCounts.total}</TableCell>
<TableCell>{store.usage_bytes}</TableCell>
<TableCell>{providerId}</TableCell>
<TableCell>{providerDbId}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Vector Stores</h1>
{renderContent()}
</div>
);
}

View file

@ -1,9 +1,11 @@
"use client";
import { useRouter } from "next/navigation";
import type { VectorStore } from "llama-stack-client/resources/vector-stores/vector-stores";
import type { VectorStoreFile } from "llama-stack-client/resources/vector-stores/files";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import {
DetailLoadingView,
DetailErrorView,
@ -42,6 +44,11 @@ export function VectorStoreDetailView({
id,
}: VectorStoreDetailViewProps) {
const title = "Vector Store Details";
const router = useRouter();
const handleFileClick = (fileId: string) => {
router.push(`/logs/vector-stores/${id}/files/${fileId}`);
};
if (errorStore) {
return <DetailErrorView title={title} id={id} error={errorStore} />;
@ -80,7 +87,15 @@ export function VectorStoreDetailView({
<TableBody>
{files.map((file) => (
<TableRow key={file.id}>
<TableCell>{file.id}</TableCell>
<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={() => handleFileClick(file.id)}
>
{file.id}
</Button>
</TableCell>
<TableCell>{file.status}</TableCell>
<TableCell>
{new Date(file.created_at * 1000).toLocaleString()}

View file

@ -0,0 +1,112 @@
import type { FileContentResponse } from "llama-stack-client/resources/vector-stores/files";
import type { LlamaStackClient } from "llama-stack-client";
export type VectorStoreContent = FileContentResponse.Content;
export type VectorStoreContentsResponse = FileContentResponse;
export interface VectorStoreContentItem {
id: string;
object: string;
created_timestamp: number;
vector_store_id: string;
file_id: string;
content: VectorStoreContent;
metadata: Record<string, any>;
embedding?: number[];
}
export interface VectorStoreContentDeleteResponse {
id: string;
object: string;
deleted: boolean;
}
export interface VectorStoreListContentsResponse {
object: string;
data: VectorStoreContentItem[];
first_id?: string;
last_id?: string;
has_more: boolean;
}
export class ContentsAPI {
constructor(private client: LlamaStackClient) {}
async getFileContents(vectorStoreId: string, fileId: string): Promise<VectorStoreContentsResponse> {
return this.client.vectorStores.files.content(vectorStoreId, fileId);
}
async getContent(vectorStoreId: string, fileId: string, contentId: string): Promise<VectorStoreContentItem> {
const contentsResponse = await this.listContents(vectorStoreId, fileId);
const targetContent = contentsResponse.data.find(c => c.id === contentId);
if (!targetContent) {
throw new Error(`Content ${contentId} not found`);
}
return targetContent;
}
async updateContent(
vectorStoreId: string,
fileId: string,
contentId: string,
updates: { content?: string; metadata?: Record<string, any> }
): Promise<VectorStoreContentItem> {
throw new Error("Individual content updates not yet implemented in API");
}
async deleteContent(vectorStoreId: string, fileId: string, contentId: string): Promise<VectorStoreContentDeleteResponse> {
throw new Error("Individual content deletion not yet implemented in API");
}
async listContents(
vectorStoreId: string,
fileId: string,
options?: {
limit?: number;
order?: string;
after?: string;
before?: string;
}
): Promise<VectorStoreListContentsResponse> {
const fileContents = await this.client.vectorStores.files.content(vectorStoreId, fileId);
const contentItems: VectorStoreContentItem[] = [];
fileContents.content.forEach((content, contentIndex) => {
const rawContent = content as any;
// Extract actual fields from the API response
const embedding = rawContent.embedding || undefined;
const created_timestamp = rawContent.created_timestamp || rawContent.created_at || Date.now() / 1000;
const chunkMetadata = rawContent.chunk_metadata || {};
const contentId = rawContent.chunk_metadata?.chunk_id || rawContent.id || `content_${fileId}_${contentIndex}`;
const objectType = rawContent.object || 'vector_store.file.content';
contentItems.push({
id: contentId,
object: objectType,
created_timestamp: created_timestamp,
vector_store_id: vectorStoreId,
file_id: fileId,
content: content,
embedding: embedding,
metadata: {
...chunkMetadata, // chunk_metadata fields from API
content_length: content.type === 'text' ? content.text.length : 0,
},
});
});
// apply pagination if needed
let filteredItems = contentItems;
if (options?.limit) {
filteredItems = filteredItems.slice(0, options.limit);
}
return {
object: 'list',
data: filteredItems,
has_more: contentItems.length > (options?.limit || contentItems.length),
};
}
}

View file

@ -18,7 +18,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^11.18.2",
"llama-stack-client": "0.2.16",
"llama-stack-client": "0.2.17",
"lucide-react": "^0.510.0",
"next": "15.3.3",
"next-auth": "^4.24.11",
@ -9926,10 +9926,10 @@
"license": "MIT"
},
"node_modules/llama-stack-client": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/llama-stack-client/-/llama-stack-client-0.2.16.tgz",
"integrity": "sha512-jM7sh1CB5wVumutYb3qfmYJpoTe3IRAa5lm3Us4qO7zVP4tbo3eCE7BOFNWyChpjo9efafUItwogNh28pum9PQ==",
"license": "Apache-2.0",
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/llama-stack-client/-/llama-stack-client-0.2.17.tgz",
"integrity": "sha512-+/fEO8M7XPiVLjhH7ge18i1ijKp4+h3dOkE0C8g2cvGuDUtDYIJlf8NSyr9OMByjiWpCibWU7VOKL50LwGLS3Q==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",