mirror of
https://github.com/meta-llama/llama-stack.git
synced 2025-08-15 14:08:00 +00:00
feat(ui): Adding Files/contents to Admin portal to investigate File Contents
Signed-off-by: Francisco Javier Arceo <farceo@redhat.com>
This commit is contained in:
parent
e565b91182
commit
3e7b0e44b1
7 changed files with 1072 additions and 7 deletions
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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()}
|
||||
|
|
112
llama_stack/ui/lib/contents-api.ts
Normal file
112
llama_stack/ui/lib/contents-api.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
10
llama_stack/ui/package-lock.json
generated
10
llama_stack/ui/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.18.2",
|
||||
"llama-stack-client": ""0.2.17",
|
||||
"llama-stack-client": "0.2.17",
|
||||
"lucide-react": "^0.510.0",
|
||||
"next": "15.3.3",
|
||||
"next-auth": "^4.24.11",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue