chore: move src/llama_stack/ui to src/llama_stack_ui (#4068)

# What does this PR do?
This better separates UI from backend code, which was a point of
confusion often for our beloved AI friends.


## Test Plan
CI
This commit is contained in:
ehhuang 2025-11-04 15:21:49 -08:00 committed by GitHub
parent 5850e3473f
commit 95b0493fae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
156 changed files with 20 additions and 20 deletions

View file

@ -0,0 +1,59 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { ChatCompletion } from "@/lib/types";
import { ChatCompletionDetailView } from "@/components/chat-completions/chat-completion-detail";
import { useAuthClient } from "@/hooks/use-auth-client";
export default function ChatCompletionDetailPage() {
const params = useParams();
const id = params.id as string;
const client = useAuthClient();
const [completionDetail, setCompletionDetail] =
useState<ChatCompletion | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!id) {
setError(new Error("Completion ID is missing."));
setIsLoading(false);
return;
}
const fetchCompletionDetail = async () => {
setIsLoading(true);
setError(null);
setCompletionDetail(null);
try {
const response = await client.chat.completions.retrieve(id);
setCompletionDetail(response as ChatCompletion);
} catch (err) {
console.error(
`Error fetching chat completion detail for ID ${id}:`,
err
);
setError(
err instanceof Error
? err
: new Error("Failed to fetch completion detail")
);
} finally {
setIsLoading(false);
}
};
fetchCompletionDetail();
}, [id, client]);
return (
<ChatCompletionDetailView
completion={completionDetail}
isLoading={isLoading}
error={error}
id={id}
/>
);
}

View file

@ -0,0 +1,19 @@
"use client";
import React from "react";
import LogsLayout from "@/components/layout/logs-layout";
export default function ChatCompletionsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<LogsLayout
sectionLabel="Chat Completions"
basePath="/logs/chat-completions"
>
{children}
</LogsLayout>
);
}

View file

@ -0,0 +1,7 @@
"use client";
import { ChatCompletionsTable } from "@/components/chat-completions/chat-completions-table";
export default function ChatCompletionsPage() {
return <ChatCompletionsTable paginationOptions={{ limit: 20 }} />;
}

View file

@ -0,0 +1,125 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import type { ResponseObject } from "llama-stack-client/resources/responses/responses";
import { OpenAIResponse, InputItemListResponse } from "@/lib/types";
import { ResponseDetailView } from "@/components/responses/responses-detail";
import { useAuthClient } from "@/hooks/use-auth-client";
export default function ResponseDetailPage() {
const params = useParams();
const id = params.id as string;
const client = useAuthClient();
const [responseDetail, setResponseDetail] = useState<OpenAIResponse | null>(
null
);
const [inputItems, setInputItems] = useState<InputItemListResponse | null>(
null
);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isLoadingInputItems, setIsLoadingInputItems] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
const [inputItemsError, setInputItemsError] = useState<Error | null>(null);
// Helper function to convert ResponseObject to OpenAIResponse
const convertResponseObject = (
responseData: ResponseObject
): OpenAIResponse => {
return {
id: responseData.id,
created_at: responseData.created_at,
model: responseData.model,
object: responseData.object,
status: responseData.status,
output: responseData.output as OpenAIResponse["output"],
input: [], // ResponseObject doesn't include input; component uses inputItems prop instead
error: responseData.error,
parallel_tool_calls: responseData.parallel_tool_calls,
previous_response_id: responseData.previous_response_id,
temperature: responseData.temperature,
top_p: responseData.top_p,
truncation: responseData.truncation,
};
};
useEffect(() => {
if (!id) {
setError(new Error("Response ID is missing."));
setIsLoading(false);
return;
}
const fetchResponseDetail = async () => {
setIsLoading(true);
setIsLoadingInputItems(true);
setError(null);
setInputItemsError(null);
setResponseDetail(null);
setInputItems(null);
try {
const [responseResult, inputItemsResult] = await Promise.allSettled([
client.responses.retrieve(id),
client.responses.inputItems.list(id, { order: "asc" }),
]);
// Handle response detail result
if (responseResult.status === "fulfilled") {
const convertedResponse = convertResponseObject(responseResult.value);
setResponseDetail(convertedResponse);
} else {
console.error(
`Error fetching response detail for ID ${id}:`,
responseResult.reason
);
setError(
responseResult.reason instanceof Error
? responseResult.reason
: new Error("Failed to fetch response detail")
);
}
// Handle input items result
if (inputItemsResult.status === "fulfilled") {
const inputItemsData =
inputItemsResult.value as unknown as InputItemListResponse;
setInputItems(inputItemsData);
} else {
console.error(
`Error fetching input items for response ID ${id}:`,
inputItemsResult.reason
);
setInputItemsError(
inputItemsResult.reason instanceof Error
? inputItemsResult.reason
: new Error("Failed to fetch input items")
);
}
} catch (err) {
console.error(`Unexpected error fetching data for ID ${id}:`, err);
setError(
err instanceof Error ? err : new Error("Unexpected error occurred")
);
} finally {
setIsLoading(false);
setIsLoadingInputItems(false);
}
};
fetchResponseDetail();
}, [id, client]);
return (
<ResponseDetailView
response={responseDetail}
inputItems={inputItems}
isLoading={isLoading}
isLoadingInputItems={isLoadingInputItems}
error={error}
inputItemsError={inputItemsError}
id={id}
/>
);
}

View file

@ -0,0 +1,16 @@
"use client";
import React from "react";
import LogsLayout from "@/components/layout/logs-layout";
export default function ResponsesLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<LogsLayout sectionLabel="Responses" basePath="/logs/responses">
{children}
</LogsLayout>
);
}

View file

@ -0,0 +1,7 @@
"use client";
import { ResponsesTable } from "@/components/responses/responses-table";
export default function ResponsesPage() {
return <ResponsesTable paginationOptions={{ limit: 20 }} />;
}

View file

@ -0,0 +1,425 @@
import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import ContentDetailPage from "./page";
import { 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";
const mockPush = jest.fn();
const mockParams = {
id: "vs_123",
fileId: "file_456",
contentId: "content_789",
};
jest.mock("next/navigation", () => ({
useParams: () => mockParams,
useRouter: () => ({
push: mockPush,
}),
}));
const mockClient = {
vectorStores: {
retrieve: jest.fn(),
files: {
retrieve: jest.fn(),
},
},
};
jest.mock("@/hooks/use-auth-client", () => ({
useAuthClient: () => mockClient,
}));
const mockContentsAPI = {
listContents: jest.fn(),
updateContent: jest.fn(),
deleteContent: jest.fn(),
};
jest.mock("@/lib/contents-api", () => ({
ContentsAPI: jest.fn(() => mockContentsAPI),
}));
const originalConfirm = window.confirm;
describe("ContentDetailPage", () => {
const mockStore: VectorStore = {
id: "vs_123",
name: "Test Vector Store",
created_at: 1710000000,
status: "ready",
file_counts: { total: 5 },
usage_bytes: 1024,
metadata: {
provider_id: "test_provider",
},
};
const mockFile: VectorStoreFile = {
id: "file_456",
status: "completed",
created_at: 1710001000,
usage_bytes: 512,
chunking_strategy: { type: "fixed_size" },
};
const mockContent: VectorStoreContentItem = {
id: "content_789",
object: "vector_store.content",
content: "This is test content for the vector store.",
embedding: [0.1, 0.2, 0.3, 0.4, 0.5],
metadata: {
chunk_window: "0-45",
content_length: 45,
custom_field: "custom_value",
},
created_timestamp: 1710002000,
};
beforeEach(() => {
jest.clearAllMocks();
window.confirm = jest.fn();
mockClient.vectorStores.retrieve.mockResolvedValue(mockStore);
mockClient.vectorStores.files.retrieve.mockResolvedValue(mockFile);
mockContentsAPI.listContents.mockResolvedValue({
data: [mockContent],
});
});
afterEach(() => {
window.confirm = originalConfirm;
});
describe("Loading and Error States", () => {
test("renders loading skeleton while fetching data", () => {
mockClient.vectorStores.retrieve.mockImplementation(
() => new Promise(() => {})
);
const { container } = render(<ContentDetailPage />);
const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
expect(skeletons.length).toBeGreaterThan(0);
});
test("renders error message when API calls fail", async () => {
const error = new Error("Network error");
mockClient.vectorStores.retrieve.mockRejectedValue(error);
render(<ContentDetailPage />);
await waitFor(() => {
expect(
screen.getByText(/Error loading details for ID content_789/)
).toBeInTheDocument();
expect(screen.getByText(/Network error/)).toBeInTheDocument();
});
});
test("renders not found when content doesn't exist", async () => {
mockContentsAPI.listContents.mockResolvedValue({
data: [],
});
render(<ContentDetailPage />);
await waitFor(() => {
expect(
screen.getByText(/Content content_789 not found/)
).toBeInTheDocument();
});
});
});
describe("Content Display", () => {
test("renders content details correctly", async () => {
render(<ContentDetailPage />);
await waitFor(() => {
expect(screen.getByText("Content: content_789")).toBeInTheDocument();
expect(
screen.getByText("This is test content for the vector store.")
).toBeInTheDocument();
});
const contentIdTexts = screen.getAllByText("content_789");
expect(contentIdTexts.length).toBeGreaterThan(0);
const fileIdTexts = screen.getAllByText("file_456");
expect(fileIdTexts.length).toBeGreaterThan(0);
const storeIdTexts = screen.getAllByText("vs_123");
expect(storeIdTexts.length).toBeGreaterThan(0);
expect(screen.getByText("vector_store.content")).toBeInTheDocument();
const positionTexts = screen.getAllByText("0-45");
expect(positionTexts.length).toBeGreaterThan(0);
});
test("renders embedding information when available", async () => {
render(<ContentDetailPage />);
await waitFor(() => {
expect(
screen.getByText(/0.100000, 0.200000, 0.300000/)
).toBeInTheDocument();
});
});
test("handles content without embedding", async () => {
const contentWithoutEmbedding = {
...mockContent,
embedding: undefined,
};
mockContentsAPI.listContents.mockResolvedValue({
data: [contentWithoutEmbedding],
});
render(<ContentDetailPage />);
await waitFor(() => {
expect(
screen.getByText("No embedding available for this content.")
).toBeInTheDocument();
});
});
test("renders metadata correctly", async () => {
render(<ContentDetailPage />);
await waitFor(() => {
expect(screen.getByText("chunk_window:")).toBeInTheDocument();
const positionTexts = screen.getAllByText("0-45");
expect(positionTexts.length).toBeGreaterThan(0);
expect(screen.getByText("content_length:")).toBeInTheDocument();
expect(screen.getByText("custom_field:")).toBeInTheDocument();
expect(screen.getByText("custom_value")).toBeInTheDocument();
});
});
});
describe("Edit Functionality", () => {
test("enables edit mode when edit button is clicked", async () => {
render(<ContentDetailPage />);
await waitFor(() => {
expect(
screen.getByText("This is test content for the vector store.")
).toBeInTheDocument();
});
const editButtons = screen.getAllByRole("button", { name: /Edit/ });
const editButton = editButtons[0];
fireEvent.click(editButton);
expect(
screen.getByDisplayValue("This is test content for the vector store.")
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Save/ })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Cancel/ })
).toBeInTheDocument();
});
test("cancels edit mode and resets content", async () => {
render(<ContentDetailPage />);
await waitFor(() => {
expect(
screen.getByText("This is test content for the vector store.")
).toBeInTheDocument();
});
const editButtons = screen.getAllByRole("button", { name: /Edit/ });
const editButton = editButtons[0];
fireEvent.click(editButton);
const textarea = screen.getByDisplayValue(
"This is test content for the vector store."
);
fireEvent.change(textarea, { target: { value: "Modified content" } });
const cancelButton = screen.getByRole("button", { name: /Cancel/ });
fireEvent.click(cancelButton);
expect(
screen.getByText("This is test content for the vector store.")
).toBeInTheDocument();
expect(
screen.queryByDisplayValue("Modified content")
).not.toBeInTheDocument();
});
test("saves content changes", async () => {
const updatedContent = { ...mockContent, content: "Updated content" };
mockContentsAPI.updateContent.mockResolvedValue(updatedContent);
render(<ContentDetailPage />);
await waitFor(() => {
expect(
screen.getByText("This is test content for the vector store.")
).toBeInTheDocument();
});
const editButtons = screen.getAllByRole("button", { name: /Edit/ });
const editButton = editButtons[0];
fireEvent.click(editButton);
const textarea = screen.getByDisplayValue(
"This is test content for the vector store."
);
fireEvent.change(textarea, { target: { value: "Updated content" } });
const saveButton = screen.getByRole("button", { name: /Save/ });
fireEvent.click(saveButton);
await waitFor(() => {
expect(mockContentsAPI.updateContent).toHaveBeenCalledWith(
"vs_123",
"file_456",
"content_789",
{ content: "Updated content" }
);
});
});
});
describe("Delete Functionality", () => {
test("shows confirmation dialog before deleting", async () => {
window.confirm = jest.fn().mockReturnValue(false);
render(<ContentDetailPage />);
await waitFor(() => {
expect(
screen.getByText("This is test content for the vector store.")
).toBeInTheDocument();
});
const deleteButton = screen.getByRole("button", { name: /Delete/ });
fireEvent.click(deleteButton);
expect(window.confirm).toHaveBeenCalledWith(
"Are you sure you want to delete this content?"
);
expect(mockContentsAPI.deleteContent).not.toHaveBeenCalled();
});
test("deletes content when confirmed", async () => {
window.confirm = jest.fn().mockReturnValue(true);
render(<ContentDetailPage />);
await waitFor(() => {
expect(
screen.getByText("This is test content for the vector store.")
).toBeInTheDocument();
});
const deleteButton = screen.getByRole("button", { name: /Delete/ });
fireEvent.click(deleteButton);
await waitFor(() => {
expect(mockContentsAPI.deleteContent).toHaveBeenCalledWith(
"vs_123",
"file_456",
"content_789"
);
expect(mockPush).toHaveBeenCalledWith(
"/logs/vector-stores/vs_123/files/file_456/contents"
);
});
});
});
describe("Embedding Edit Functionality", () => {
test("enables embedding edit mode", async () => {
render(<ContentDetailPage />);
await waitFor(() => {
expect(
screen.getByText("This is test content for the vector store.")
).toBeInTheDocument();
});
const embeddingEditButtons = screen.getAllByRole("button", {
name: /Edit/,
});
expect(embeddingEditButtons.length).toBeGreaterThanOrEqual(1);
});
test.skip("cancels embedding edit mode", async () => {
render(<ContentDetailPage />);
await waitFor(() => {
// skip vector text check, just verify test completes
});
const embeddingEditButtons = screen.getAllByRole("button", {
name: /Edit/,
});
const embeddingEditButton = embeddingEditButtons[1];
fireEvent.click(embeddingEditButton);
const cancelButtons = screen.getAllByRole("button", { name: /Cancel/ });
expect(cancelButtons.length).toBeGreaterThan(0);
expect(
screen.queryByDisplayValue(/0.1,0.2,0.3,0.4,0.5/)
).not.toBeInTheDocument();
});
});
describe("Breadcrumb Navigation", () => {
test("renders correct breadcrumb structure", async () => {
render(<ContentDetailPage />);
await waitFor(() => {
const vectorStoreTexts = screen.getAllByText("Vector Stores");
expect(vectorStoreTexts.length).toBeGreaterThan(0);
const storeNameTexts = screen.getAllByText("Test Vector Store");
expect(storeNameTexts.length).toBeGreaterThan(0);
const contentsTexts = screen.getAllByText("Contents");
expect(contentsTexts.length).toBeGreaterThan(0);
});
});
});
describe("Content Utilities", () => {
test("handles different content types correctly", async () => {
const contentWithObjectType = {
...mockContent,
content: { type: "text", text: "Text object content" },
};
mockContentsAPI.listContents.mockResolvedValue({
data: [contentWithObjectType],
});
render(<ContentDetailPage />);
await waitFor(() => {
expect(screen.getByText("Text object content")).toBeInTheDocument();
});
});
test("handles string content type", async () => {
const contentWithStringType = {
...mockContent,
content: "Simple string content",
};
mockContentsAPI.listContents.mockResolvedValue({
data: [contentWithStringType],
});
render(<ContentDetailPage />);
await waitFor(() => {
expect(screen.getByText("Simple string content")).toBeInTheDocument();
});
});
});
});

View file

@ -0,0 +1,430 @@
"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: unknown): 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, unknown>>(
{}
);
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, unknown> } =
{};
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,481 @@
import React from "react";
import {
render,
screen,
fireEvent,
waitFor,
act,
} from "@testing-library/react";
import "@testing-library/jest-dom";
import ContentsListPage from "./page";
import { 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";
const mockPush = jest.fn();
const mockParams = {
id: "vs_123",
fileId: "file_456",
};
jest.mock("next/navigation", () => ({
useParams: () => mockParams,
useRouter: () => ({
push: mockPush,
}),
}));
const mockClient = {
vectorStores: {
retrieve: jest.fn(),
files: {
retrieve: jest.fn(),
},
},
};
jest.mock("@/hooks/use-auth-client", () => ({
useAuthClient: () => mockClient,
}));
const mockContentsAPI = {
listContents: jest.fn(),
deleteContent: jest.fn(),
};
jest.mock("@/lib/contents-api", () => ({
ContentsAPI: jest.fn(() => mockContentsAPI),
}));
describe("ContentsListPage", () => {
const mockStore: VectorStore = {
id: "vs_123",
name: "Test Vector Store",
created_at: 1710000000,
status: "ready",
file_counts: { total: 5 },
usage_bytes: 1024,
metadata: {
provider_id: "test_provider",
},
};
const mockFile: VectorStoreFile = {
id: "file_456",
status: "completed",
created_at: 1710001000,
usage_bytes: 512,
chunking_strategy: { type: "fixed_size" },
};
const mockContents: VectorStoreContentItem[] = [
{
id: "content_1",
object: "vector_store.content",
content: "First piece of content for testing.",
embedding: [0.1, 0.2, 0.3, 0.4, 0.5],
metadata: {
chunk_window: "0-35",
content_length: 35,
},
created_timestamp: 1710002000,
},
{
id: "content_2",
object: "vector_store.content",
content:
"Second piece of content with longer text for testing truncation and display.",
embedding: [0.6, 0.7, 0.8],
metadata: {
chunk_window: "36-95",
content_length: 85,
},
created_timestamp: 1710003000,
},
{
id: "content_3",
object: "vector_store.content",
content: "Third content without embedding.",
embedding: undefined,
metadata: {
content_length: 33,
},
created_timestamp: 1710004000,
},
];
beforeEach(() => {
jest.clearAllMocks();
mockClient.vectorStores.retrieve.mockResolvedValue(mockStore);
mockClient.vectorStores.files.retrieve.mockResolvedValue(mockFile);
mockContentsAPI.listContents.mockResolvedValue({
data: mockContents,
});
});
describe("Loading and Error States", () => {
test("renders loading skeleton while fetching store data", async () => {
mockClient.vectorStores.retrieve.mockImplementation(
() => new Promise(() => {})
);
await act(async () => {
render(<ContentsListPage />);
});
const skeletons = document.querySelectorAll('[data-slot="skeleton"]');
expect(skeletons.length).toBeGreaterThan(0);
});
test("renders error message when store API call fails", async () => {
const error = new Error("Failed to load store");
mockClient.vectorStores.retrieve.mockRejectedValue(error);
await act(async () => {
render(<ContentsListPage />);
});
await waitFor(() => {
expect(
screen.getByText(/Error loading details for ID vs_123/)
).toBeInTheDocument();
expect(screen.getByText(/Failed to load store/)).toBeInTheDocument();
});
});
test("renders not found when store doesn't exist", async () => {
mockClient.vectorStores.retrieve.mockResolvedValue(null);
await act(async () => {
render(<ContentsListPage />);
});
await waitFor(() => {
expect(
screen.getByText(/No details found for ID: vs_123/)
).toBeInTheDocument();
});
});
test("renders contents loading skeleton", async () => {
mockContentsAPI.listContents.mockImplementation(
() => new Promise(() => {})
);
const { container } = render(<ContentsListPage />);
await waitFor(() => {
expect(
screen.getByText("Contents in File: file_456")
).toBeInTheDocument();
});
const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
expect(skeletons.length).toBeGreaterThan(0);
});
test("renders contents error message", async () => {
const error = new Error("Failed to load contents");
mockContentsAPI.listContents.mockRejectedValue(error);
render(<ContentsListPage />);
await waitFor(() => {
expect(
screen.getByText("Error loading contents: Failed to load contents")
).toBeInTheDocument();
});
});
});
describe("Contents Table Display", () => {
test("renders contents table with correct headers", async () => {
render(<ContentsListPage />);
await waitFor(() => {
expect(screen.getByText("Content Chunks (3)")).toBeInTheDocument();
expect(screen.getByText("Contents in this file")).toBeInTheDocument();
});
// Check table headers
expect(screen.getByText("Content ID")).toBeInTheDocument();
expect(screen.getByText("Content Preview")).toBeInTheDocument();
expect(screen.getByText("Embedding")).toBeInTheDocument();
expect(screen.getByText("Position")).toBeInTheDocument();
expect(screen.getByText("Created")).toBeInTheDocument();
expect(screen.getByText("Actions")).toBeInTheDocument();
});
test("renders content data correctly", async () => {
render(<ContentsListPage />);
await waitFor(() => {
// Check first content row
expect(screen.getByText("content_1...")).toBeInTheDocument();
expect(
screen.getByText("First piece of content for testing.")
).toBeInTheDocument();
expect(
screen.getByText("[0.100, 0.200, 0.300...] (5D)")
).toBeInTheDocument();
expect(screen.getByText("0-35")).toBeInTheDocument();
expect(
screen.getByText(new Date(1710002000 * 1000).toLocaleString())
).toBeInTheDocument();
expect(screen.getByText("content_2...")).toBeInTheDocument();
expect(
screen.getByText(/Second piece of content with longer text/)
).toBeInTheDocument();
expect(
screen.getByText("[0.600, 0.700, 0.800...] (3D)")
).toBeInTheDocument();
expect(screen.getByText("36-95")).toBeInTheDocument();
expect(screen.getByText("content_3...")).toBeInTheDocument();
expect(
screen.getByText("Third content without embedding.")
).toBeInTheDocument();
expect(screen.getByText("No embedding")).toBeInTheDocument();
expect(screen.getByText("33 chars")).toBeInTheDocument();
});
});
test("handles empty contents list", async () => {
mockContentsAPI.listContents.mockResolvedValue({
data: [],
});
render(<ContentsListPage />);
await waitFor(() => {
expect(screen.getByText("Content Chunks (0)")).toBeInTheDocument();
expect(
screen.getByText("No contents found for this file.")
).toBeInTheDocument();
});
});
test("truncates long content IDs", async () => {
const longIdContent = {
...mockContents[0],
id: "very_long_content_id_that_should_be_truncated_123456789",
};
mockContentsAPI.listContents.mockResolvedValue({
data: [longIdContent],
});
render(<ContentsListPage />);
await waitFor(() => {
expect(screen.getByText("very_long_...")).toBeInTheDocument();
});
});
});
describe("Content Navigation", () => {
test("navigates to content detail when content ID is clicked", async () => {
render(<ContentsListPage />);
await waitFor(() => {
expect(screen.getByText("content_1...")).toBeInTheDocument();
});
const contentLink = screen.getByRole("button", { name: "content_1..." });
fireEvent.click(contentLink);
expect(mockPush).toHaveBeenCalledWith(
"/logs/vector-stores/vs_123/files/file_456/contents/content_1"
);
});
test("navigates to content detail when view button is clicked", async () => {
render(<ContentsListPage />);
await waitFor(() => {
expect(screen.getByText("Content Chunks (3)")).toBeInTheDocument();
});
const viewButtons = screen.getAllByTitle("View content details");
fireEvent.click(viewButtons[0]);
expect(mockPush).toHaveBeenCalledWith(
"/logs/vector-stores/vs_123/files/file_456/contents/content_1"
);
});
test("navigates to content detail when edit button is clicked", async () => {
render(<ContentsListPage />);
await waitFor(() => {
expect(screen.getByText("Content Chunks (3)")).toBeInTheDocument();
});
const editButtons = screen.getAllByTitle("Edit content");
fireEvent.click(editButtons[0]);
expect(mockPush).toHaveBeenCalledWith(
"/logs/vector-stores/vs_123/files/file_456/contents/content_1"
);
});
});
describe("Content Deletion", () => {
test("deletes content when delete button is clicked", async () => {
mockContentsAPI.deleteContent.mockResolvedValue(undefined);
render(<ContentsListPage />);
await waitFor(() => {
expect(screen.getByText("Content Chunks (3)")).toBeInTheDocument();
});
const deleteButtons = screen.getAllByTitle("Delete content");
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(mockContentsAPI.deleteContent).toHaveBeenCalledWith(
"vs_123",
"file_456",
"content_1"
);
});
await waitFor(() => {
expect(screen.getByText("Content Chunks (2)")).toBeInTheDocument();
});
expect(screen.queryByText("content_1...")).not.toBeInTheDocument();
});
test("handles delete error gracefully", async () => {
const consoleError = jest
.spyOn(console, "error")
.mockImplementation(() => {});
mockContentsAPI.deleteContent.mockRejectedValue(
new Error("Delete failed")
);
render(<ContentsListPage />);
await waitFor(() => {
expect(screen.getByText("Content Chunks (3)")).toBeInTheDocument();
});
const deleteButtons = screen.getAllByTitle("Delete content");
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
"Failed to delete content:",
expect.any(Error)
);
});
expect(screen.getByText("Content Chunks (3)")).toBeInTheDocument();
expect(screen.getByText("content_1...")).toBeInTheDocument();
consoleError.mockRestore();
});
});
describe("Breadcrumb Navigation", () => {
test("renders correct breadcrumb structure", async () => {
render(<ContentsListPage />);
await waitFor(() => {
const vectorStoreTexts = screen.getAllByText("Vector Stores");
expect(vectorStoreTexts.length).toBeGreaterThan(0);
const storeNameTexts = screen.getAllByText("Test Vector Store");
expect(storeNameTexts.length).toBeGreaterThan(0);
const filesTexts = screen.getAllByText("Files");
expect(filesTexts.length).toBeGreaterThan(0);
const fileIdTexts = screen.getAllByText("file_456");
expect(fileIdTexts.length).toBeGreaterThan(0);
const contentsTexts = screen.getAllByText("Contents");
expect(contentsTexts.length).toBeGreaterThan(0);
});
});
});
describe("Sidebar Properties", () => {
test("renders file and store properties", async () => {
render(<ContentsListPage />);
await waitFor(() => {
const fileIdTexts = screen.getAllByText("file_456");
expect(fileIdTexts.length).toBeGreaterThan(0);
const storeIdTexts = screen.getAllByText("vs_123");
expect(storeIdTexts.length).toBeGreaterThan(0);
const storeNameTexts = screen.getAllByText("Test Vector Store");
expect(storeNameTexts.length).toBeGreaterThan(0);
expect(screen.getByText("completed")).toBeInTheDocument();
expect(screen.getByText("512")).toBeInTheDocument();
expect(screen.getByText("fixed_size")).toBeInTheDocument();
expect(screen.getByText("test_provider")).toBeInTheDocument();
});
});
});
describe("Content Text Utilities", () => {
test("handles different content formats correctly", async () => {
const contentWithObject = {
...mockContents[0],
content: { type: "text", text: "Object format content" },
};
mockContentsAPI.listContents.mockResolvedValue({
data: [contentWithObject],
});
render(<ContentsListPage />);
await waitFor(() => {
expect(screen.getByText("Object format content")).toBeInTheDocument();
});
});
test("handles string content format", async () => {
const contentWithString = {
...mockContents[0],
content: "String format content",
};
mockContentsAPI.listContents.mockResolvedValue({
data: [contentWithString],
});
render(<ContentsListPage />);
await waitFor(() => {
expect(screen.getByText("String format content")).toBeInTheDocument();
});
});
test("handles unknown content format", async () => {
const contentWithUnknown = {
...mockContents[0],
content: { unknown: "format" },
};
mockContentsAPI.listContents.mockResolvedValue({
data: [contentWithUnknown],
});
render(<ContentsListPage />);
await waitFor(() => {
expect(screen.getByText("Content Chunks (1)")).toBeInTheDocument();
});
const contentCells = screen.getAllByRole("cell");
const contentPreviewCell = contentCells.find(cell =>
cell.querySelector("p[title]")
);
expect(contentPreviewCell?.querySelector("p")?.textContent).toBe("");
});
});
});

View file

@ -0,0 +1,347 @@
"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: unknown): 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>
{isLoadingFile ? (
<Skeleton className="h-4 w-full" />
) : errorFile ? (
<div className="text-destructive text-sm">
Error loading file: {errorFile.message}
</div>
) : 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,458 @@
import React from "react";
import {
render,
screen,
fireEvent,
waitFor,
act,
} from "@testing-library/react";
import "@testing-library/jest-dom";
import FileDetailPage from "./page";
import type { VectorStore } from "llama-stack-client/resources/vector-stores/vector-stores";
import type {
VectorStoreFile,
FileContentResponse,
} from "llama-stack-client/resources/vector-stores/files";
const mockPush = jest.fn();
const mockParams = {
id: "vs_123",
fileId: "file_456",
};
jest.mock("next/navigation", () => ({
useParams: () => mockParams,
useRouter: () => ({
push: mockPush,
}),
}));
const mockClient = {
vectorStores: {
retrieve: jest.fn(),
files: {
retrieve: jest.fn(),
content: jest.fn(),
},
},
};
jest.mock("@/hooks/use-auth-client", () => ({
useAuthClient: () => mockClient,
}));
describe("FileDetailPage", () => {
const mockStore: VectorStore = {
id: "vs_123",
name: "Test Vector Store",
created_at: 1710000000,
status: "ready",
file_counts: { total: 5 },
usage_bytes: 1024,
metadata: {
provider_id: "test_provider",
},
};
const mockFile: VectorStoreFile = {
id: "file_456",
status: "completed",
created_at: 1710001000,
usage_bytes: 2048,
chunking_strategy: { type: "fixed_size" },
};
const mockFileContent: FileContentResponse = {
content: [
{ text: "First chunk of file content." },
{
text: "Second chunk with more detailed information about the content.",
},
{ text: "Third and final chunk of the file." },
],
};
beforeEach(() => {
jest.clearAllMocks();
mockClient.vectorStores.retrieve.mockResolvedValue(mockStore);
mockClient.vectorStores.files.retrieve.mockResolvedValue(mockFile);
mockClient.vectorStores.files.content.mockResolvedValue(mockFileContent);
});
describe("Loading and Error States", () => {
test("renders loading skeleton while fetching store data", async () => {
mockClient.vectorStores.retrieve.mockImplementation(
() => new Promise(() => {})
);
await act(async () => {
await act(async () => {
render(<FileDetailPage />);
});
});
const skeletons = document.querySelectorAll('[data-slot="skeleton"]');
expect(skeletons.length).toBeGreaterThan(0);
});
test("renders error message when store API call fails", async () => {
const error = new Error("Failed to load store");
mockClient.vectorStores.retrieve.mockRejectedValue(error);
await act(async () => {
await act(async () => {
render(<FileDetailPage />);
});
});
await waitFor(() => {
expect(
screen.getByText(/Error loading details for ID vs_123/)
).toBeInTheDocument();
expect(screen.getByText(/Failed to load store/)).toBeInTheDocument();
});
});
test("renders not found when store doesn't exist", async () => {
mockClient.vectorStores.retrieve.mockResolvedValue(null);
await act(async () => {
render(<FileDetailPage />);
});
await waitFor(() => {
expect(
screen.getByText(/No details found for ID: vs_123/)
).toBeInTheDocument();
});
});
test("renders file loading skeleton", async () => {
mockClient.vectorStores.files.retrieve.mockImplementation(
() => new Promise(() => {})
);
const { container } = render(<FileDetailPage />);
await waitFor(() => {
expect(screen.getByText("File: file_456")).toBeInTheDocument();
});
const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
expect(skeletons.length).toBeGreaterThan(0);
});
test("renders file error message", async () => {
const error = new Error("Failed to load file");
mockClient.vectorStores.files.retrieve.mockRejectedValue(error);
await act(async () => {
render(<FileDetailPage />);
});
await waitFor(() => {
expect(
screen.getByText("Error loading file: Failed to load file")
).toBeInTheDocument();
});
});
test("renders content error message", async () => {
const error = new Error("Failed to load contents");
mockClient.vectorStores.files.content.mockRejectedValue(error);
await act(async () => {
render(<FileDetailPage />);
});
await waitFor(() => {
expect(
screen.getByText(
"Error loading content summary: Failed to load contents"
)
).toBeInTheDocument();
});
});
});
describe("File Information Display", () => {
test("renders file details correctly", async () => {
await act(async () => {
await act(async () => {
render(<FileDetailPage />);
});
});
await waitFor(() => {
expect(screen.getByText("File: file_456")).toBeInTheDocument();
expect(screen.getByText("File Information")).toBeInTheDocument();
expect(screen.getByText("File Details")).toBeInTheDocument();
});
const statusTexts = screen.getAllByText("Status:");
expect(statusTexts.length).toBeGreaterThan(0);
const completedTexts = screen.getAllByText("completed");
expect(completedTexts.length).toBeGreaterThan(0);
expect(screen.getByText("Size:")).toBeInTheDocument();
expect(screen.getByText("2048 bytes")).toBeInTheDocument();
const createdTexts = screen.getAllByText("Created:");
expect(createdTexts.length).toBeGreaterThan(0);
const dateTexts = screen.getAllByText(
new Date(1710001000 * 1000).toLocaleString()
);
expect(dateTexts.length).toBeGreaterThan(0);
const strategyTexts = screen.getAllByText("Content Strategy:");
expect(strategyTexts.length).toBeGreaterThan(0);
const fixedSizeTexts = screen.getAllByText("fixed_size");
expect(fixedSizeTexts.length).toBeGreaterThan(0);
});
test("handles missing file data", async () => {
mockClient.vectorStores.files.retrieve.mockResolvedValue(null);
await act(async () => {
render(<FileDetailPage />);
});
await waitFor(() => {
expect(screen.getByText("File not found.")).toBeInTheDocument();
});
});
});
describe("Content Summary Display", () => {
test("renders content summary correctly", async () => {
await act(async () => {
render(<FileDetailPage />);
});
await waitFor(() => {
expect(screen.getByText("Content Summary")).toBeInTheDocument();
expect(screen.getByText("Content Items:")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
expect(screen.getByText("Total Characters:")).toBeInTheDocument();
const totalChars = mockFileContent.content.reduce(
(total, item) => total + item.text.length,
0
);
expect(screen.getByText(totalChars.toString())).toBeInTheDocument();
expect(screen.getByText("Preview:")).toBeInTheDocument();
expect(
screen.getByText(/First chunk of file content\./)
).toBeInTheDocument();
});
});
test("handles empty content", async () => {
mockClient.vectorStores.files.content.mockResolvedValue({
content: [],
});
await act(async () => {
render(<FileDetailPage />);
});
await waitFor(() => {
expect(
screen.getByText("No contents found for this file.")
).toBeInTheDocument();
});
});
test("truncates long content preview", async () => {
const longContent = {
content: [
{
text: "This is a very long piece of content that should be truncated after 200 characters to ensure the preview doesn't take up too much space in the UI and remains readable and manageable for users viewing the file details page.",
},
],
};
mockClient.vectorStores.files.content.mockResolvedValue(longContent);
await act(async () => {
render(<FileDetailPage />);
});
await waitFor(() => {
expect(
screen.getByText(/This is a very long piece of content/)
).toBeInTheDocument();
expect(screen.getByText(/\.\.\.$/)).toBeInTheDocument();
});
});
});
describe("Navigation and Actions", () => {
test("navigates to contents list when View Contents button is clicked", async () => {
await act(async () => {
render(<FileDetailPage />);
});
await waitFor(() => {
expect(screen.getByText("Actions")).toBeInTheDocument();
});
const viewContentsButton = screen.getByRole("button", {
name: /View Contents/,
});
fireEvent.click(viewContentsButton);
expect(mockPush).toHaveBeenCalledWith(
"/logs/vector-stores/vs_123/files/file_456/contents"
);
});
test("View Contents button is styled correctly", async () => {
await act(async () => {
render(<FileDetailPage />);
});
await waitFor(() => {
const button = screen.getByRole("button", { name: /View Contents/ });
expect(button).toHaveClass("flex", "items-center", "gap-2");
});
});
});
describe("Breadcrumb Navigation", () => {
test("renders correct breadcrumb structure", async () => {
await act(async () => {
render(<FileDetailPage />);
});
await waitFor(() => {
const vectorStoresTexts = screen.getAllByText("Vector Stores");
expect(vectorStoresTexts.length).toBeGreaterThan(0);
const storeNameTexts = screen.getAllByText("Test Vector Store");
expect(storeNameTexts.length).toBeGreaterThan(0);
const filesTexts = screen.getAllByText("Files");
expect(filesTexts.length).toBeGreaterThan(0);
const fileIdTexts = screen.getAllByText("file_456");
expect(fileIdTexts.length).toBeGreaterThan(0);
});
});
test("uses store ID when store name is not available", async () => {
const storeWithoutName = { ...mockStore, name: "" };
mockClient.vectorStores.retrieve.mockResolvedValue(storeWithoutName);
await act(async () => {
render(<FileDetailPage />);
});
await waitFor(() => {
const storeIdTexts = screen.getAllByText("vs_123");
expect(storeIdTexts.length).toBeGreaterThan(0);
});
});
});
describe("Sidebar Properties", () => {
test.skip("renders file and store properties correctly", async () => {
await act(async () => {
render(<FileDetailPage />);
});
await waitFor(() => {
expect(screen.getByText("File ID")).toBeInTheDocument();
const fileIdTexts = screen.getAllByText("file_456");
expect(fileIdTexts.length).toBeGreaterThan(0);
expect(screen.getByText("Vector Store ID")).toBeInTheDocument();
const storeIdTexts = screen.getAllByText("vs_123");
expect(storeIdTexts.length).toBeGreaterThan(0);
expect(screen.getByText("Status")).toBeInTheDocument();
const completedTexts = screen.getAllByText("completed");
expect(completedTexts.length).toBeGreaterThan(0);
expect(screen.getByText("Usage Bytes")).toBeInTheDocument();
const usageTexts = screen.getAllByText("2048");
expect(usageTexts.length).toBeGreaterThan(0);
expect(screen.getByText("Content Strategy")).toBeInTheDocument();
const fixedSizeTexts = screen.getAllByText("fixed_size");
expect(fixedSizeTexts.length).toBeGreaterThan(0);
expect(screen.getByText("Store Name")).toBeInTheDocument();
const storeNameTexts = screen.getAllByText("Test Vector Store");
expect(storeNameTexts.length).toBeGreaterThan(0);
expect(screen.getByText("Provider ID")).toBeInTheDocument();
expect(screen.getByText("test_provider")).toBeInTheDocument();
});
});
test("handles missing optional properties", async () => {
const minimalFile = {
id: "file_456",
status: "completed",
created_at: 1710001000,
usage_bytes: 2048,
chunking_strategy: { type: "fixed_size" },
};
const minimalStore = {
...mockStore,
name: "",
metadata: {},
};
mockClient.vectorStores.files.retrieve.mockResolvedValue(minimalFile);
mockClient.vectorStores.retrieve.mockResolvedValue(minimalStore);
await act(async () => {
render(<FileDetailPage />);
});
await waitFor(() => {
const fileIdTexts = screen.getAllByText("file_456");
expect(fileIdTexts.length).toBeGreaterThan(0);
const storeIdTexts = screen.getAllByText("vs_123");
expect(storeIdTexts.length).toBeGreaterThan(0);
});
expect(screen.getByText("File: file_456")).toBeInTheDocument();
});
});
describe("Loading States for Individual Sections", () => {
test("shows loading skeleton for content while file loads", async () => {
mockClient.vectorStores.files.content.mockImplementation(
() => new Promise(() => {})
);
const { container } = render(<FileDetailPage />);
await waitFor(() => {
expect(screen.getByText("Content Summary")).toBeInTheDocument();
});
const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
expect(skeletons.length).toBeGreaterThan(0);
});
});
describe("Error Handling", () => {
test("handles multiple simultaneous errors gracefully", async () => {
mockClient.vectorStores.files.retrieve.mockRejectedValue(
new Error("File error")
);
mockClient.vectorStores.files.content.mockRejectedValue(
new Error("Content error")
);
await act(async () => {
render(<FileDetailPage />);
});
await waitFor(() => {
expect(
screen.getByText("Error loading file: File error")
).toBeInTheDocument();
expect(
screen.getByText("Error loading content summary: Content error")
).toBeInTheDocument();
});
});
});
});

View file

@ -0,0 +1,302 @@
"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

@ -0,0 +1,79 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } 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 } from "llama-stack-client/resources/vector-stores/files";
import { VectorStoreDetailView } from "@/components/vector-stores/vector-store-detail";
export default function VectorStoreDetailPage() {
const params = useParams();
const id = params.id as string;
const client = useAuthClient();
const [store, setStore] = useState<VectorStore | null>(null);
const [files, setFiles] = useState<VectorStoreFile[]>([]);
const [isLoadingStore, setIsLoadingStore] = useState(true);
const [isLoadingFiles, setIsLoadingFiles] = useState(true);
const [errorStore, setErrorStore] = useState<Error | null>(null);
const [errorFiles, setErrorFiles] = useState<Error | null>(null);
useEffect(() => {
if (!id) {
setErrorStore(new Error("Vector Store ID is missing."));
setIsLoadingStore(false);
return;
}
const fetchStore = async () => {
setIsLoadingStore(true);
setErrorStore(null);
try {
const response = await client.vectorStores.retrieve(id);
setStore(response as VectorStore);
} catch (err) {
setErrorStore(
err instanceof Error ? err : new Error("Failed to load vector store.")
);
} finally {
setIsLoadingStore(false);
}
};
fetchStore();
}, [id, client]);
useEffect(() => {
if (!id) {
setErrorFiles(new Error("Vector Store ID is missing."));
setIsLoadingFiles(false);
return;
}
const fetchFiles = async () => {
setIsLoadingFiles(true);
setErrorFiles(null);
try {
const result = await client.vectorStores.files.list(id);
setFiles((result as { data: VectorStoreFile[] }).data);
} catch (err) {
setErrorFiles(
err instanceof Error ? err : new Error("Failed to load files.")
);
} finally {
setIsLoadingFiles(false);
}
};
fetchFiles();
}, [id, client.vectorStores.files]);
return (
<VectorStoreDetailView
store={store}
files={files}
isLoadingStore={isLoadingStore}
isLoadingFiles={isLoadingFiles}
errorStore={errorStore}
errorFiles={errorFiles}
id={id}
/>
);
}

View file

@ -0,0 +1,31 @@
"use client";
import { useParams, usePathname } from "next/navigation";
import {
PageBreadcrumb,
BreadcrumbSegment,
} from "@/components/layout/page-breadcrumb";
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 (
<div className="space-y-4">
{isBaseDetailPage && <PageBreadcrumb segments={breadcrumbSegments} />}
{children}
</div>
);
}

View file

@ -0,0 +1,138 @@
"use client";
import React from "react";
import type {
ListVectorStoresResponse,
VectorStore,
} 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,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
export default function VectorStoresPage() {
const router = useRouter();
const {
data: stores,
status,
hasMore,
error,
loadMore,
} = usePagination<VectorStore>({
limit: 20,
order: "desc",
fetchFunction: async (client, params) => {
const response = await client.vectorStores.list({
after: params.after,
limit: params.limit,
order: params.order,
} as Parameters<typeof client.vectorStores.list>[0]);
return response as ListVectorStoresResponse;
},
errorMessagePrefix: "vector stores",
});
// Auto-load all pages for infinite scroll behavior (like Responses)
React.useEffect(() => {
if (status === "idle" && hasMore) {
loadMore();
}
}, [status, hasMore, loadMore]);
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="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>
);
};
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Vector Stores</h1>
{renderContent()}
</div>
);
}