mirror of
https://github.com/meta-llama/llama-stack.git
synced 2025-12-15 03:22:48 +00:00
chore(package): migrate to src/ layout (#3920)
Migrates package structure to src/ layout following Python packaging best practices. All code moved from `llama_stack/` to `src/llama_stack/`. Public API unchanged - imports remain `import llama_stack.*`. Updated build configs, pre-commit hooks, scripts, and GitHub workflows accordingly. All hooks pass, package builds cleanly. **Developer note**: Reinstall after pulling: `pip install -e .`
This commit is contained in:
parent
98a5047f9d
commit
471b1b248b
791 changed files with 2983 additions and 456 deletions
6
src/llama_stack/ui/app/api/auth/[...nextauth]/route.ts
Normal file
6
src/llama_stack/ui/app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
105
src/llama_stack/ui/app/api/v1/[...path]/route.ts
Normal file
105
src/llama_stack/ui/app/api/v1/[...path]/route.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
// Get backend URL from environment variable or default to localhost for development
|
||||
const BACKEND_URL =
|
||||
process.env.LLAMA_STACK_BACKEND_URL ||
|
||||
`http://localhost:${process.env.LLAMA_STACK_PORT || 8321}`;
|
||||
|
||||
async function proxyRequest(request: NextRequest, method: string) {
|
||||
try {
|
||||
// Extract the path from the request URL
|
||||
const url = new URL(request.url);
|
||||
const pathSegments = url.pathname.split("/");
|
||||
|
||||
// Remove /api from the path to get the actual API path
|
||||
// /api/v1/models/list -> /v1/models/list
|
||||
const apiPath = pathSegments.slice(2).join("/"); // Remove 'api' segment
|
||||
const targetUrl = `${BACKEND_URL}/${apiPath}${url.search}`;
|
||||
|
||||
console.log(`Proxying ${method} ${url.pathname} -> ${targetUrl}`);
|
||||
|
||||
// Prepare headers (exclude host and other problematic headers)
|
||||
const headers = new Headers();
|
||||
request.headers.forEach((value, key) => {
|
||||
// Skip headers that might cause issues in proxy
|
||||
if (
|
||||
!["host", "connection", "content-length"].includes(key.toLowerCase())
|
||||
) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Prepare the request options
|
||||
const requestOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
};
|
||||
|
||||
// Add body for methods that support it
|
||||
if (["POST", "PUT", "PATCH"].includes(method) && request.body) {
|
||||
requestOptions.body = await request.text();
|
||||
}
|
||||
|
||||
// Make the request to FastAPI backend
|
||||
const response = await fetch(targetUrl, requestOptions);
|
||||
|
||||
// Get response data
|
||||
const responseText = await response.text();
|
||||
|
||||
console.log(
|
||||
`Response from FastAPI: ${response.status} ${response.statusText}`
|
||||
);
|
||||
|
||||
// Create response with same status and headers
|
||||
const proxyResponse = new NextResponse(responseText, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
|
||||
// Copy response headers (except problematic ones)
|
||||
response.headers.forEach((value, key) => {
|
||||
if (!["connection", "transfer-encoding"].includes(key.toLowerCase())) {
|
||||
proxyResponse.headers.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return proxyResponse;
|
||||
} catch (error) {
|
||||
console.error("Proxy request failed:", error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Proxy request failed",
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
backend_url: BACKEND_URL,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP method handlers
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyRequest(request, "GET");
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return proxyRequest(request, "POST");
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return proxyRequest(request, "PUT");
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
return proxyRequest(request, "DELETE");
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
return proxyRequest(request, "PATCH");
|
||||
}
|
||||
|
||||
export async function OPTIONS(request: NextRequest) {
|
||||
return proxyRequest(request, "OPTIONS");
|
||||
}
|
||||
118
src/llama_stack/ui/app/auth/signin/page.tsx
Normal file
118
src/llama_stack/ui/app/auth/signin/page.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"use client";
|
||||
|
||||
import { signIn, signOut, useSession } from "next-auth/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Copy, Check, Home, Github } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function SignInPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleCopyToken = async () => {
|
||||
if (session?.accessToken) {
|
||||
await navigator.clipboard.writeText(session.accessToken);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Card className="w-[400px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Authentication</CardTitle>
|
||||
<CardDescription>
|
||||
{session
|
||||
? "You are successfully authenticated!"
|
||||
: "Sign in with GitHub to use your access token as an API key"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!session ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
console.log("Signing in with GitHub...");
|
||||
signIn("github", { callbackUrl: "/auth/signin" }).catch(
|
||||
error => {
|
||||
console.error("Sign in error:", error);
|
||||
}
|
||||
);
|
||||
}}
|
||||
className="w-full"
|
||||
variant="default"
|
||||
>
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
Sign in with GitHub
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Signed in as {session.user?.email}
|
||||
</div>
|
||||
|
||||
{session.accessToken && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
GitHub Access Token:
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<code className="flex-1 p-2 bg-muted rounded text-xs break-all">
|
||||
{session.accessToken}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCopyToken}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
This GitHub token will be used as your API key for
|
||||
authenticated Llama Stack requests.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => router.push("/")} className="flex-1">
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => signOut()}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
610
src/llama_stack/ui/app/chat-playground/chunk-processor.test.tsx
Normal file
610
src/llama_stack/ui/app/chat-playground/chunk-processor.test.tsx
Normal file
|
|
@ -0,0 +1,610 @@
|
|||
import { describe, test, expect } from "@jest/globals";
|
||||
|
||||
// Extract the exact processChunk function implementation for testing
|
||||
function createProcessChunk() {
|
||||
return (chunk: unknown): { text: string | null; isToolCall: boolean } => {
|
||||
const chunkObj = chunk as Record<string, unknown>;
|
||||
|
||||
// Helper function to check if content contains function call JSON
|
||||
const containsToolCall = (content: string): boolean => {
|
||||
return (
|
||||
content.includes('"type": "function"') ||
|
||||
content.includes('"name": "knowledge_search"') ||
|
||||
content.includes('"parameters":') ||
|
||||
!!content.match(/\{"type":\s*"function".*?\}/)
|
||||
);
|
||||
};
|
||||
|
||||
// Check if this chunk contains a tool call (function call)
|
||||
let isToolCall = false;
|
||||
|
||||
// Check direct chunk content if it's a string
|
||||
if (typeof chunk === "string") {
|
||||
isToolCall = containsToolCall(chunk);
|
||||
}
|
||||
|
||||
// Check delta structures
|
||||
if (
|
||||
chunkObj?.delta &&
|
||||
typeof chunkObj.delta === "object" &&
|
||||
chunkObj.delta !== null
|
||||
) {
|
||||
const delta = chunkObj.delta as Record<string, unknown>;
|
||||
if ("tool_calls" in delta) {
|
||||
isToolCall = true;
|
||||
}
|
||||
if (typeof delta.text === "string") {
|
||||
if (containsToolCall(delta.text)) {
|
||||
isToolCall = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check event structures
|
||||
if (
|
||||
chunkObj?.event &&
|
||||
typeof chunkObj.event === "object" &&
|
||||
chunkObj.event !== null
|
||||
) {
|
||||
const event = chunkObj.event as Record<string, unknown>;
|
||||
|
||||
// Check event payload
|
||||
if (
|
||||
event?.payload &&
|
||||
typeof event.payload === "object" &&
|
||||
event.payload !== null
|
||||
) {
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
if (typeof payload.content === "string") {
|
||||
if (containsToolCall(payload.content)) {
|
||||
isToolCall = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check payload delta
|
||||
if (
|
||||
payload?.delta &&
|
||||
typeof payload.delta === "object" &&
|
||||
payload.delta !== null
|
||||
) {
|
||||
const delta = payload.delta as Record<string, unknown>;
|
||||
if (typeof delta.text === "string") {
|
||||
if (containsToolCall(delta.text)) {
|
||||
isToolCall = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check event delta
|
||||
if (
|
||||
event?.delta &&
|
||||
typeof event.delta === "object" &&
|
||||
event.delta !== null
|
||||
) {
|
||||
const delta = event.delta as Record<string, unknown>;
|
||||
if (typeof delta.text === "string") {
|
||||
if (containsToolCall(delta.text)) {
|
||||
isToolCall = true;
|
||||
}
|
||||
}
|
||||
if (typeof delta.content === "string") {
|
||||
if (containsToolCall(delta.content)) {
|
||||
isToolCall = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if it's a tool call, skip it (don't display in chat)
|
||||
if (isToolCall) {
|
||||
return { text: null, isToolCall: true };
|
||||
}
|
||||
|
||||
// Extract text content from various chunk formats
|
||||
let text: string | null = null;
|
||||
|
||||
// Helper function to extract clean text content, filtering out function calls
|
||||
const extractCleanText = (content: string): string | null => {
|
||||
if (containsToolCall(content)) {
|
||||
try {
|
||||
// Try to parse and extract non-function call parts
|
||||
const jsonMatch = content.match(
|
||||
/\{"type":\s*"function"[^}]*\}[^}]*\}/
|
||||
);
|
||||
if (jsonMatch) {
|
||||
const jsonPart = jsonMatch[0];
|
||||
const parsedJson = JSON.parse(jsonPart);
|
||||
|
||||
// If it's a function call, extract text after JSON
|
||||
if (parsedJson.type === "function") {
|
||||
const textAfterJson = content
|
||||
.substring(content.indexOf(jsonPart) + jsonPart.length)
|
||||
.trim();
|
||||
return textAfterJson || null;
|
||||
}
|
||||
}
|
||||
// If we can't parse it properly, skip the whole thing
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
// Try direct delta text
|
||||
if (
|
||||
chunkObj?.delta &&
|
||||
typeof chunkObj.delta === "object" &&
|
||||
chunkObj.delta !== null
|
||||
) {
|
||||
const delta = chunkObj.delta as Record<string, unknown>;
|
||||
if (typeof delta.text === "string") {
|
||||
text = extractCleanText(delta.text);
|
||||
}
|
||||
}
|
||||
|
||||
// Try event structures
|
||||
if (
|
||||
!text &&
|
||||
chunkObj?.event &&
|
||||
typeof chunkObj.event === "object" &&
|
||||
chunkObj.event !== null
|
||||
) {
|
||||
const event = chunkObj.event as Record<string, unknown>;
|
||||
|
||||
// Try event payload content
|
||||
if (
|
||||
event?.payload &&
|
||||
typeof event.payload === "object" &&
|
||||
event.payload !== null
|
||||
) {
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
|
||||
// Try direct payload content
|
||||
if (typeof payload.content === "string") {
|
||||
text = extractCleanText(payload.content);
|
||||
}
|
||||
|
||||
// Try turn_complete event structure: payload.turn.output_message.content
|
||||
if (
|
||||
!text &&
|
||||
payload?.turn &&
|
||||
typeof payload.turn === "object" &&
|
||||
payload.turn !== null
|
||||
) {
|
||||
const turn = payload.turn as Record<string, unknown>;
|
||||
if (
|
||||
turn?.output_message &&
|
||||
typeof turn.output_message === "object" &&
|
||||
turn.output_message !== null
|
||||
) {
|
||||
const outputMessage = turn.output_message as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (typeof outputMessage.content === "string") {
|
||||
text = extractCleanText(outputMessage.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to model_response in steps if no output_message
|
||||
if (
|
||||
!text &&
|
||||
turn?.steps &&
|
||||
Array.isArray(turn.steps) &&
|
||||
turn.steps.length > 0
|
||||
) {
|
||||
for (const step of turn.steps) {
|
||||
if (step && typeof step === "object" && step !== null) {
|
||||
const stepObj = step as Record<string, unknown>;
|
||||
if (
|
||||
stepObj?.model_response &&
|
||||
typeof stepObj.model_response === "object" &&
|
||||
stepObj.model_response !== null
|
||||
) {
|
||||
const modelResponse = stepObj.model_response as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (typeof modelResponse.content === "string") {
|
||||
text = extractCleanText(modelResponse.content);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try payload delta
|
||||
if (
|
||||
!text &&
|
||||
payload?.delta &&
|
||||
typeof payload.delta === "object" &&
|
||||
payload.delta !== null
|
||||
) {
|
||||
const delta = payload.delta as Record<string, unknown>;
|
||||
if (typeof delta.text === "string") {
|
||||
text = extractCleanText(delta.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try event delta
|
||||
if (
|
||||
!text &&
|
||||
event?.delta &&
|
||||
typeof event.delta === "object" &&
|
||||
event.delta !== null
|
||||
) {
|
||||
const delta = event.delta as Record<string, unknown>;
|
||||
if (typeof delta.text === "string") {
|
||||
text = extractCleanText(delta.text);
|
||||
}
|
||||
if (!text && typeof delta.content === "string") {
|
||||
text = extractCleanText(delta.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try choices structure (ChatML format)
|
||||
if (
|
||||
!text &&
|
||||
chunkObj?.choices &&
|
||||
Array.isArray(chunkObj.choices) &&
|
||||
chunkObj.choices.length > 0
|
||||
) {
|
||||
const choice = chunkObj.choices[0] as Record<string, unknown>;
|
||||
if (
|
||||
choice?.delta &&
|
||||
typeof choice.delta === "object" &&
|
||||
choice.delta !== null
|
||||
) {
|
||||
const delta = choice.delta as Record<string, unknown>;
|
||||
if (typeof delta.content === "string") {
|
||||
text = extractCleanText(delta.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try direct string content
|
||||
if (!text && typeof chunk === "string") {
|
||||
text = extractCleanText(chunk);
|
||||
}
|
||||
|
||||
return { text, isToolCall: false };
|
||||
};
|
||||
}
|
||||
|
||||
describe("Chunk Processor", () => {
|
||||
const processChunk = createProcessChunk();
|
||||
|
||||
describe("Real Event Structures", () => {
|
||||
test("handles turn_complete event with cancellation policy response", () => {
|
||||
const chunk = {
|
||||
event: {
|
||||
payload: {
|
||||
event_type: "turn_complete",
|
||||
turn: {
|
||||
turn_id: "50a2d6b7-49ed-4d1e-b1c2-6d68b3f726db",
|
||||
session_id: "e7f62b8e-518c-4450-82df-e65fe49f27a3",
|
||||
input_messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "nice, what's the cancellation policy?",
|
||||
context: null,
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
turn_id: "50a2d6b7-49ed-4d1e-b1c2-6d68b3f726db",
|
||||
step_id: "54074310-af42-414c-9ffe-fba5b2ead0ad",
|
||||
started_at: "2025-08-27T18:15:25.870703Z",
|
||||
completed_at: "2025-08-27T18:15:51.288993Z",
|
||||
step_type: "inference",
|
||||
model_response: {
|
||||
role: "assistant",
|
||||
content:
|
||||
"According to the search results, the cancellation policy for Red Hat Summit is as follows:\n\n* Cancellations must be received by 5 PM EDT on April 18, 2025 for a 50% refund of the registration fee.\n* No refunds will be given for cancellations received after 5 PM EDT on April 18, 2025.\n* Cancellation of travel reservations and hotel reservations are the responsibility of the registrant.",
|
||||
stop_reason: "end_of_turn",
|
||||
tool_calls: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
output_message: {
|
||||
role: "assistant",
|
||||
content:
|
||||
"According to the search results, the cancellation policy for Red Hat Summit is as follows:\n\n* Cancellations must be received by 5 PM EDT on April 18, 2025 for a 50% refund of the registration fee.\n* No refunds will be given for cancellations received after 5 PM EDT on April 18, 2025.\n* Cancellation of travel reservations and hotel reservations are the responsibility of the registrant.",
|
||||
stop_reason: "end_of_turn",
|
||||
tool_calls: [],
|
||||
},
|
||||
output_attachments: [],
|
||||
started_at: "2025-08-27T18:15:25.868548Z",
|
||||
completed_at: "2025-08-27T18:15:51.289262Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = processChunk(chunk);
|
||||
expect(result.isToolCall).toBe(false);
|
||||
expect(result.text).toContain(
|
||||
"According to the search results, the cancellation policy for Red Hat Summit is as follows:"
|
||||
);
|
||||
expect(result.text).toContain("5 PM EDT on April 18, 2025");
|
||||
});
|
||||
|
||||
test("handles turn_complete event with address response", () => {
|
||||
const chunk = {
|
||||
event: {
|
||||
payload: {
|
||||
event_type: "turn_complete",
|
||||
turn: {
|
||||
turn_id: "2f4a1520-8ecc-4cb7-bb7b-886939e042b0",
|
||||
session_id: "e7f62b8e-518c-4450-82df-e65fe49f27a3",
|
||||
input_messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "what's francisco's address",
|
||||
context: null,
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
turn_id: "2f4a1520-8ecc-4cb7-bb7b-886939e042b0",
|
||||
step_id: "c13dd277-1acb-4419-8fbf-d5e2f45392ea",
|
||||
started_at: "2025-08-27T18:14:52.558761Z",
|
||||
completed_at: "2025-08-27T18:15:11.306032Z",
|
||||
step_type: "inference",
|
||||
model_response: {
|
||||
role: "assistant",
|
||||
content:
|
||||
"Francisco Arceo's address is:\n\nRed Hat\nUnited States\n17 Primrose Ln \nBasking Ridge New Jersey 07920",
|
||||
stop_reason: "end_of_turn",
|
||||
tool_calls: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
output_message: {
|
||||
role: "assistant",
|
||||
content:
|
||||
"Francisco Arceo's address is:\n\nRed Hat\nUnited States\n17 Primrose Ln \nBasking Ridge New Jersey 07920",
|
||||
stop_reason: "end_of_turn",
|
||||
tool_calls: [],
|
||||
},
|
||||
output_attachments: [],
|
||||
started_at: "2025-08-27T18:14:52.553707Z",
|
||||
completed_at: "2025-08-27T18:15:11.306729Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = processChunk(chunk);
|
||||
expect(result.isToolCall).toBe(false);
|
||||
expect(result.text).toContain("Francisco Arceo's address is:");
|
||||
expect(result.text).toContain("17 Primrose Ln");
|
||||
expect(result.text).toContain("Basking Ridge New Jersey 07920");
|
||||
});
|
||||
|
||||
test("handles turn_complete event with ticket cost response", () => {
|
||||
const chunk = {
|
||||
event: {
|
||||
payload: {
|
||||
event_type: "turn_complete",
|
||||
turn: {
|
||||
turn_id: "7ef244a3-efee-42ca-a9c8-942865251002",
|
||||
session_id: "e7f62b8e-518c-4450-82df-e65fe49f27a3",
|
||||
input_messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "what was the ticket cost for summit?",
|
||||
context: null,
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
turn_id: "7ef244a3-efee-42ca-a9c8-942865251002",
|
||||
step_id: "7651dda0-315a-472d-b1c1-3c2725f55bc5",
|
||||
started_at: "2025-08-27T18:14:21.710611Z",
|
||||
completed_at: "2025-08-27T18:14:39.706452Z",
|
||||
step_type: "inference",
|
||||
model_response: {
|
||||
role: "assistant",
|
||||
content:
|
||||
"The ticket cost for the Red Hat Summit was $999.00 for a conference pass.",
|
||||
stop_reason: "end_of_turn",
|
||||
tool_calls: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
output_message: {
|
||||
role: "assistant",
|
||||
content:
|
||||
"The ticket cost for the Red Hat Summit was $999.00 for a conference pass.",
|
||||
stop_reason: "end_of_turn",
|
||||
tool_calls: [],
|
||||
},
|
||||
output_attachments: [],
|
||||
started_at: "2025-08-27T18:14:21.705289Z",
|
||||
completed_at: "2025-08-27T18:14:39.706752Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = processChunk(chunk);
|
||||
expect(result.isToolCall).toBe(false);
|
||||
expect(result.text).toBe(
|
||||
"The ticket cost for the Red Hat Summit was $999.00 for a conference pass."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Function Call Detection", () => {
|
||||
test("detects function calls in direct string chunks", () => {
|
||||
const chunk =
|
||||
'{"type": "function", "name": "knowledge_search", "parameters": {"query": "test"}}';
|
||||
const result = processChunk(chunk);
|
||||
expect(result.isToolCall).toBe(true);
|
||||
expect(result.text).toBe(null);
|
||||
});
|
||||
|
||||
test("detects function calls in event payload content", () => {
|
||||
const chunk = {
|
||||
event: {
|
||||
payload: {
|
||||
content:
|
||||
'{"type": "function", "name": "knowledge_search", "parameters": {"query": "test"}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = processChunk(chunk);
|
||||
expect(result.isToolCall).toBe(true);
|
||||
expect(result.text).toBe(null);
|
||||
});
|
||||
|
||||
test("detects tool_calls in delta structure", () => {
|
||||
const chunk = {
|
||||
delta: {
|
||||
tool_calls: [{ function: { name: "knowledge_search" } }],
|
||||
},
|
||||
};
|
||||
const result = processChunk(chunk);
|
||||
expect(result.isToolCall).toBe(true);
|
||||
expect(result.text).toBe(null);
|
||||
});
|
||||
|
||||
test("detects function call in mixed content but skips it", () => {
|
||||
const chunk =
|
||||
'{"type": "function", "name": "knowledge_search", "parameters": {"query": "test"}} Based on the search results, here is your answer.';
|
||||
const result = processChunk(chunk);
|
||||
// This is detected as a tool call and skipped entirely - the implementation prioritizes safety
|
||||
expect(result.isToolCall).toBe(true);
|
||||
expect(result.text).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Text Extraction", () => {
|
||||
test("extracts text from direct string chunks", () => {
|
||||
const chunk = "Hello, this is a normal response.";
|
||||
const result = processChunk(chunk);
|
||||
expect(result.isToolCall).toBe(false);
|
||||
expect(result.text).toBe("Hello, this is a normal response.");
|
||||
});
|
||||
|
||||
test("extracts text from delta structure", () => {
|
||||
const chunk = {
|
||||
delta: {
|
||||
text: "Hello, this is a normal response.",
|
||||
},
|
||||
};
|
||||
const result = processChunk(chunk);
|
||||
expect(result.isToolCall).toBe(false);
|
||||
expect(result.text).toBe("Hello, this is a normal response.");
|
||||
});
|
||||
|
||||
test("extracts text from choices structure", () => {
|
||||
const chunk = {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
content: "Hello, this is a normal response.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = processChunk(chunk);
|
||||
expect(result.isToolCall).toBe(false);
|
||||
expect(result.text).toBe("Hello, this is a normal response.");
|
||||
});
|
||||
|
||||
test("prioritizes output_message over model_response in turn structure", () => {
|
||||
const chunk = {
|
||||
event: {
|
||||
payload: {
|
||||
turn: {
|
||||
steps: [
|
||||
{
|
||||
model_response: {
|
||||
content: "Model response content.",
|
||||
},
|
||||
},
|
||||
],
|
||||
output_message: {
|
||||
content: "Final output message content.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = processChunk(chunk);
|
||||
expect(result.isToolCall).toBe(false);
|
||||
expect(result.text).toBe("Final output message content.");
|
||||
});
|
||||
|
||||
test("falls back to model_response when no output_message", () => {
|
||||
const chunk = {
|
||||
event: {
|
||||
payload: {
|
||||
turn: {
|
||||
steps: [
|
||||
{
|
||||
model_response: {
|
||||
content: "This is from the model response.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = processChunk(chunk);
|
||||
expect(result.isToolCall).toBe(false);
|
||||
expect(result.text).toBe("This is from the model response.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
test("handles empty chunks", () => {
|
||||
const result = processChunk("");
|
||||
expect(result.isToolCall).toBe(false);
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
|
||||
test("handles null chunks", () => {
|
||||
const result = processChunk(null);
|
||||
expect(result.isToolCall).toBe(false);
|
||||
expect(result.text).toBe(null);
|
||||
});
|
||||
|
||||
test("handles undefined chunks", () => {
|
||||
const result = processChunk(undefined);
|
||||
expect(result.isToolCall).toBe(false);
|
||||
expect(result.text).toBe(null);
|
||||
});
|
||||
|
||||
test("handles chunks with no text content", () => {
|
||||
const chunk = {
|
||||
event: {
|
||||
metadata: {
|
||||
timestamp: "2024-01-01",
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = processChunk(chunk);
|
||||
expect(result.isToolCall).toBe(false);
|
||||
expect(result.text).toBe(null);
|
||||
});
|
||||
|
||||
test("handles malformed JSON in function calls gracefully", () => {
|
||||
const chunk =
|
||||
'{"type": "function", "name": "knowledge_search"} incomplete json';
|
||||
const result = processChunk(chunk);
|
||||
expect(result.isToolCall).toBe(true);
|
||||
expect(result.text).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
790
src/llama_stack/ui/app/chat-playground/page.test.tsx
Normal file
790
src/llama_stack/ui/app/chat-playground/page.test.tsx
Normal file
|
|
@ -0,0 +1,790 @@
|
|||
import React from "react";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
act,
|
||||
} from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import ChatPlaygroundPage from "./page";
|
||||
|
||||
const mockClient = {
|
||||
agents: {
|
||||
list: jest.fn(),
|
||||
create: jest.fn(),
|
||||
retrieve: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
session: {
|
||||
list: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
retrieve: jest.fn(),
|
||||
},
|
||||
turn: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
},
|
||||
models: {
|
||||
list: jest.fn(),
|
||||
},
|
||||
toolgroups: {
|
||||
list: jest.fn(),
|
||||
},
|
||||
vectorDBs: {
|
||||
list: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock("@/hooks/use-auth-client", () => ({
|
||||
useAuthClient: jest.fn(() => mockClient),
|
||||
}));
|
||||
|
||||
jest.mock("@/components/chat-playground/chat", () => ({
|
||||
Chat: jest.fn(
|
||||
({
|
||||
className,
|
||||
messages,
|
||||
handleSubmit,
|
||||
input,
|
||||
handleInputChange,
|
||||
isGenerating,
|
||||
append,
|
||||
suggestions,
|
||||
}) => (
|
||||
<div data-testid="chat-component" className={className}>
|
||||
<div data-testid="messages-count">{messages.length}</div>
|
||||
<input
|
||||
data-testid="chat-input"
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<button data-testid="submit-button" onClick={handleSubmit}>
|
||||
Submit
|
||||
</button>
|
||||
{suggestions?.map((suggestion: string, index: number) => (
|
||||
<button
|
||||
key={index}
|
||||
data-testid={`suggestion-${index}`}
|
||||
onClick={() => append({ role: "user", content: suggestion })}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("@/components/chat-playground/conversations", () => ({
|
||||
SessionManager: jest.fn(({ selectedAgentId, onNewSession }) => (
|
||||
<div data-testid="session-manager">
|
||||
{selectedAgentId && (
|
||||
<>
|
||||
<div data-testid="selected-agent">{selectedAgentId}</div>
|
||||
<button data-testid="new-session-button" onClick={onNewSession}>
|
||||
New Session
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)),
|
||||
SessionUtils: {
|
||||
saveCurrentSessionId: jest.fn(),
|
||||
loadCurrentSessionId: jest.fn(),
|
||||
loadCurrentAgentId: jest.fn(),
|
||||
saveCurrentAgentId: jest.fn(),
|
||||
clearCurrentSession: jest.fn(),
|
||||
saveSessionData: jest.fn(),
|
||||
loadSessionData: jest.fn(),
|
||||
saveAgentConfig: jest.fn(),
|
||||
loadAgentConfig: jest.fn(),
|
||||
clearAgentCache: jest.fn(),
|
||||
createDefaultSession: jest.fn(() => ({
|
||||
id: "test-session-123",
|
||||
name: "Default Session",
|
||||
messages: [],
|
||||
selectedModel: "",
|
||||
systemMessage: "You are a helpful assistant.",
|
||||
agentId: "test-agent-123",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockAgents = [
|
||||
{
|
||||
agent_id: "agent_123",
|
||||
agent_config: {
|
||||
name: "Test Agent",
|
||||
instructions: "You are a test assistant.",
|
||||
},
|
||||
},
|
||||
{
|
||||
agent_id: "agent_456",
|
||||
agent_config: {
|
||||
agent_name: "Another Agent",
|
||||
instructions: "You are another assistant.",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockModels = [
|
||||
{
|
||||
identifier: "test-model-1",
|
||||
model_type: "llm",
|
||||
},
|
||||
{
|
||||
identifier: "test-model-2",
|
||||
model_type: "llm",
|
||||
},
|
||||
];
|
||||
|
||||
const mockToolgroups = [
|
||||
{
|
||||
identifier: "builtin::rag",
|
||||
provider_id: "test-provider",
|
||||
type: "tool_group",
|
||||
provider_resource_id: "test-resource",
|
||||
},
|
||||
];
|
||||
|
||||
describe("ChatPlaygroundPage", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Element.prototype.scrollIntoView = jest.fn();
|
||||
mockClient.agents.list.mockResolvedValue({ data: mockAgents });
|
||||
mockClient.models.list.mockResolvedValue(mockModels);
|
||||
mockClient.toolgroups.list.mockResolvedValue(mockToolgroups);
|
||||
mockClient.agents.session.create.mockResolvedValue({
|
||||
session_id: "new-session-123",
|
||||
});
|
||||
mockClient.agents.session.list.mockResolvedValue({ data: [] });
|
||||
mockClient.agents.session.retrieve.mockResolvedValue({
|
||||
session_id: "test-session",
|
||||
session_name: "Test Session",
|
||||
started_at: new Date().toISOString(),
|
||||
turns: [],
|
||||
});
|
||||
mockClient.agents.retrieve.mockResolvedValue({
|
||||
agent_id: "test-agent",
|
||||
agent_config: {
|
||||
toolgroups: ["builtin::rag"],
|
||||
instructions: "Test instructions",
|
||||
model: "test-model",
|
||||
},
|
||||
});
|
||||
mockClient.agents.delete.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("Agent Selector Rendering", () => {
|
||||
test("shows agent selector when agents are available", async () => {
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Agent Session:")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("combobox")).toHaveLength(2);
|
||||
expect(screen.getByText("+ New Agent")).toBeInTheDocument();
|
||||
expect(screen.getByText("Clear Chat")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("does not show agent selector when no agents are available", async () => {
|
||||
mockClient.agents.list.mockResolvedValue({ data: [] });
|
||||
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Agent Session:")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole("combobox")).toHaveLength(1);
|
||||
expect(screen.getByText("+ New Agent")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Clear Chat")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("does not show agent selector while loading", async () => {
|
||||
mockClient.agents.list.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Agent Session:")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole("combobox")).toHaveLength(1);
|
||||
expect(screen.getByText("+ New Agent")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Clear Chat")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows agent options in selector", async () => {
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const agentCombobox = screen.getAllByRole("combobox").find(element => {
|
||||
return (
|
||||
element.textContent?.includes("Test Agent") ||
|
||||
element.textContent?.includes("Select Agent")
|
||||
);
|
||||
});
|
||||
expect(agentCombobox).toBeDefined();
|
||||
fireEvent.click(agentCombobox!);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Test Agent")).toHaveLength(2);
|
||||
expect(screen.getByText("Another Agent")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("displays agent ID when no name is available", async () => {
|
||||
const agentWithoutName = {
|
||||
agent_id: "agent_789",
|
||||
agent_config: {
|
||||
instructions: "You are an agent without a name.",
|
||||
},
|
||||
};
|
||||
|
||||
mockClient.agents.list.mockResolvedValue({ data: [agentWithoutName] });
|
||||
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const agentCombobox = screen.getAllByRole("combobox").find(element => {
|
||||
return (
|
||||
element.textContent?.includes("Agent agent_78") ||
|
||||
element.textContent?.includes("Select Agent")
|
||||
);
|
||||
});
|
||||
expect(agentCombobox).toBeDefined();
|
||||
fireEvent.click(agentCombobox!);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Agent agent_78...")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agent Creation Modal", () => {
|
||||
test("opens agent creation modal when + New Agent is clicked", async () => {
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
const newAgentButton = screen.getByText("+ New Agent");
|
||||
fireEvent.click(newAgentButton);
|
||||
|
||||
expect(screen.getByText("Create New Agent")).toBeInTheDocument();
|
||||
expect(screen.getByText("Agent Name (optional)")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Model")).toHaveLength(2);
|
||||
expect(screen.getByText("System Instructions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tools (optional)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("closes modal when Cancel is clicked", async () => {
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
const newAgentButton = screen.getByText("+ New Agent");
|
||||
fireEvent.click(newAgentButton);
|
||||
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(screen.queryByText("Create New Agent")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("creates agent when Create Agent is clicked", async () => {
|
||||
mockClient.agents.create.mockResolvedValue({ agent_id: "new-agent-123" });
|
||||
mockClient.agents.list
|
||||
.mockResolvedValueOnce({ data: mockAgents })
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
...mockAgents,
|
||||
{ agent_id: "new-agent-123", agent_config: { name: "New Agent" } },
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
const newAgentButton = screen.getByText("+ New Agent");
|
||||
await act(async () => {
|
||||
fireEvent.click(newAgentButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create New Agent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("My Custom Agent");
|
||||
await act(async () => {
|
||||
fireEvent.change(nameInput, { target: { value: "Test Agent Name" } });
|
||||
});
|
||||
|
||||
const instructionsTextarea = screen.getByDisplayValue(
|
||||
"You are a helpful assistant."
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.change(instructionsTextarea, {
|
||||
target: { value: "Custom instructions" },
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const modalModelSelectors = screen
|
||||
.getAllByRole("combobox")
|
||||
.filter(el => {
|
||||
return (
|
||||
el.textContent?.includes("Select Model") ||
|
||||
el.closest('[class*="modal"]') ||
|
||||
el.closest('[class*="card"]')
|
||||
);
|
||||
});
|
||||
expect(modalModelSelectors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const modalModelSelectors = screen.getAllByRole("combobox").filter(el => {
|
||||
return (
|
||||
el.textContent?.includes("Select Model") ||
|
||||
el.closest('[class*="modal"]') ||
|
||||
el.closest('[class*="card"]')
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(modalModelSelectors[0]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const modelOptions = screen.getAllByText("test-model-1");
|
||||
expect(modelOptions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const modelOptions = screen.getAllByText("test-model-1");
|
||||
const dropdownOption = modelOptions.find(
|
||||
option =>
|
||||
option.closest('[role="option"]') ||
|
||||
option.id?.includes("radix") ||
|
||||
option.getAttribute("aria-selected") !== null
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
dropdownOption || modelOptions[modelOptions.length - 1]
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const createButton = screen.getByText("Create Agent");
|
||||
expect(createButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
const createButton = screen.getByText("Create Agent");
|
||||
await act(async () => {
|
||||
fireEvent.click(createButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClient.agents.create).toHaveBeenCalledWith({
|
||||
agent_config: {
|
||||
model: expect.any(String),
|
||||
instructions: "Custom instructions",
|
||||
name: "Test Agent Name",
|
||||
enable_session_persistence: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Create New Agent")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agent Selection", () => {
|
||||
test("creates default session when agent is selected", async () => {
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClient.agents.session.create).toHaveBeenCalledWith(
|
||||
"agent_123",
|
||||
{ session_name: "Default Session" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("switches agent when different agent is selected", async () => {
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const agentCombobox = screen.getAllByRole("combobox").find(element => {
|
||||
return (
|
||||
element.textContent?.includes("Test Agent") ||
|
||||
element.textContent?.includes("Select Agent")
|
||||
);
|
||||
});
|
||||
expect(agentCombobox).toBeDefined();
|
||||
fireEvent.click(agentCombobox!);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const anotherAgentOption = screen.getByText("Another Agent");
|
||||
fireEvent.click(anotherAgentOption);
|
||||
});
|
||||
|
||||
expect(mockClient.agents.session.create).toHaveBeenCalledWith(
|
||||
"agent_456",
|
||||
{ session_name: "Default Session" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agent Deletion", () => {
|
||||
test("shows delete button when multiple agents exist", async () => {
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Delete current agent")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("shows delete button even when only one agent exists", async () => {
|
||||
mockClient.agents.list.mockResolvedValue({
|
||||
data: [mockAgents[0]],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Delete current agent")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("deletes agent and switches to another when confirmed", async () => {
|
||||
global.confirm = jest.fn(() => true);
|
||||
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Delete current agent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
mockClient.agents.delete.mockResolvedValue(undefined);
|
||||
mockClient.agents.list.mockResolvedValueOnce({ data: mockAgents });
|
||||
mockClient.agents.list.mockResolvedValueOnce({
|
||||
data: [mockAgents[1]],
|
||||
});
|
||||
|
||||
const deleteButton = screen.getByTitle("Delete current agent");
|
||||
await act(async () => {
|
||||
deleteButton.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClient.agents.delete).toHaveBeenCalledWith("agent_123");
|
||||
expect(global.confirm).toHaveBeenCalledWith(
|
||||
"Are you sure you want to delete this agent? This action cannot be undone and will delete the agent and all its sessions."
|
||||
);
|
||||
});
|
||||
|
||||
(global.confirm as jest.Mock).mockRestore();
|
||||
});
|
||||
|
||||
test("does not delete agent when cancelled", async () => {
|
||||
global.confirm = jest.fn(() => false);
|
||||
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Delete current agent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const deleteButton = screen.getByTitle("Delete current agent");
|
||||
await act(async () => {
|
||||
deleteButton.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.confirm).toHaveBeenCalled();
|
||||
expect(mockClient.agents.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
(global.confirm as jest.Mock).mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
test("handles agent loading errors gracefully", async () => {
|
||||
mockClient.agents.list.mockRejectedValue(
|
||||
new Error("Failed to load agents")
|
||||
);
|
||||
const consoleSpy = jest
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Error fetching agents:",
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText("+ New Agent")).toBeInTheDocument();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("handles model loading errors gracefully", async () => {
|
||||
mockClient.models.list.mockRejectedValue(
|
||||
new Error("Failed to load models")
|
||||
);
|
||||
const consoleSpy = jest
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Error fetching models:",
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RAG File Upload", () => {
|
||||
let mockFileReader: {
|
||||
readAsDataURL: jest.Mock;
|
||||
readAsText: jest.Mock;
|
||||
result: string | null;
|
||||
onload: (() => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
};
|
||||
let mockRAGTool: {
|
||||
insert: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockFileReader = {
|
||||
readAsDataURL: jest.fn(),
|
||||
readAsText: jest.fn(),
|
||||
result: null,
|
||||
onload: null,
|
||||
onerror: null,
|
||||
};
|
||||
global.FileReader = jest.fn(() => mockFileReader);
|
||||
|
||||
mockRAGTool = {
|
||||
insert: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
mockClient.toolRuntime = {
|
||||
ragTool: mockRAGTool,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("handles text file upload", async () => {
|
||||
new File(["Hello, world!"], "test.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
|
||||
mockClient.agents.retrieve.mockResolvedValue({
|
||||
agent_id: "test-agent",
|
||||
agent_config: {
|
||||
toolgroups: [
|
||||
{
|
||||
name: "builtin::rag/knowledge_search",
|
||||
args: { vector_db_ids: ["test-vector-db"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("chat-component")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const chatComponent = screen.getByTestId("chat-component");
|
||||
chatComponent.getAttribute("data-onragfileupload");
|
||||
|
||||
// this is a simplified test
|
||||
expect(mockRAGTool.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles PDF file upload with FileReader", async () => {
|
||||
new File([new ArrayBuffer(1000)], "test.pdf", {
|
||||
type: "application/pdf",
|
||||
});
|
||||
|
||||
const mockDataURL = "data:application/pdf;base64,JVBERi0xLjQK";
|
||||
mockFileReader.result = mockDataURL;
|
||||
|
||||
mockClient.agents.retrieve.mockResolvedValue({
|
||||
agent_id: "test-agent",
|
||||
agent_config: {
|
||||
toolgroups: [
|
||||
{
|
||||
name: "builtin::rag/knowledge_search",
|
||||
args: { vector_db_ids: ["test-vector-db"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(<ChatPlaygroundPage />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("chat-component")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(global.FileReader).toBeDefined();
|
||||
});
|
||||
|
||||
test("handles different file types correctly", () => {
|
||||
const getContentType = (filename: string): string => {
|
||||
const ext = filename.toLowerCase().split(".").pop();
|
||||
switch (ext) {
|
||||
case "pdf":
|
||||
return "application/pdf";
|
||||
case "txt":
|
||||
return "text/plain";
|
||||
case "md":
|
||||
return "text/markdown";
|
||||
case "html":
|
||||
return "text/html";
|
||||
case "csv":
|
||||
return "text/csv";
|
||||
case "json":
|
||||
return "application/json";
|
||||
case "docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
case "doc":
|
||||
return "application/msword";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
};
|
||||
|
||||
expect(getContentType("test.pdf")).toBe("application/pdf");
|
||||
expect(getContentType("test.txt")).toBe("text/plain");
|
||||
expect(getContentType("test.md")).toBe("text/markdown");
|
||||
expect(getContentType("test.html")).toBe("text/html");
|
||||
expect(getContentType("test.csv")).toBe("text/csv");
|
||||
expect(getContentType("test.json")).toBe("application/json");
|
||||
expect(getContentType("test.docx")).toBe(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
);
|
||||
expect(getContentType("test.doc")).toBe("application/msword");
|
||||
expect(getContentType("test.unknown")).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
test("determines text vs binary file types correctly", () => {
|
||||
const isTextFile = (mimeType: string): boolean => {
|
||||
return (
|
||||
mimeType.startsWith("text/") ||
|
||||
mimeType === "application/json" ||
|
||||
mimeType === "text/markdown" ||
|
||||
mimeType === "text/html" ||
|
||||
mimeType === "text/csv"
|
||||
);
|
||||
};
|
||||
|
||||
expect(isTextFile("text/plain")).toBe(true);
|
||||
expect(isTextFile("text/markdown")).toBe(true);
|
||||
expect(isTextFile("text/html")).toBe(true);
|
||||
expect(isTextFile("text/csv")).toBe(true);
|
||||
expect(isTextFile("application/json")).toBe(true);
|
||||
|
||||
expect(isTextFile("application/pdf")).toBe(false);
|
||||
expect(isTextFile("application/msword")).toBe(false);
|
||||
expect(
|
||||
isTextFile(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
)
|
||||
).toBe(false);
|
||||
expect(isTextFile("application/octet-stream")).toBe(false);
|
||||
});
|
||||
|
||||
test("handles FileReader error gracefully", async () => {
|
||||
const pdfFile = new File([new ArrayBuffer(1000)], "test.pdf", {
|
||||
type: "application/pdf",
|
||||
});
|
||||
|
||||
mockFileReader.onerror = jest.fn();
|
||||
const mockError = new Error("FileReader failed");
|
||||
|
||||
const fileReaderPromise = new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error || mockError);
|
||||
reader.readAsDataURL(pdfFile);
|
||||
|
||||
setTimeout(() => {
|
||||
reader.onerror?.(new ProgressEvent("error"));
|
||||
}, 0);
|
||||
});
|
||||
|
||||
await expect(fileReaderPromise).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
test("handles large file upload with FileReader approach", () => {
|
||||
// create a large file
|
||||
const largeFile = new File(
|
||||
[new ArrayBuffer(10 * 1024 * 1024)],
|
||||
"large.pdf",
|
||||
{
|
||||
type: "application/pdf",
|
||||
}
|
||||
);
|
||||
|
||||
expect(largeFile.size).toBe(10 * 1024 * 1024); // 10MB
|
||||
|
||||
expect(global.FileReader).toBeDefined();
|
||||
|
||||
const reader = new FileReader();
|
||||
expect(reader.readAsDataURL).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
1893
src/llama_stack/ui/app/chat-playground/page.tsx
Normal file
1893
src/llama_stack/ui/app/chat-playground/page.tsx
Normal file
File diff suppressed because it is too large
Load diff
163
src/llama_stack/ui/app/globals.css
Normal file
163
src/llama_stack/ui/app/globals.css
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.animate-typing-dot-1 {
|
||||
animation: typing-dot-bounce-1 0.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.animate-typing-dot-2 {
|
||||
animation: typing-dot-bounce-2 0.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.animate-typing-dot-3 {
|
||||
animation: typing-dot-bounce-3 0.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes typing-dot-bounce-1 {
|
||||
0%, 15%, 85%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
7.5% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing-dot-bounce-2 {
|
||||
0%, 15%, 35%, 85%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing-dot-bounce-3 {
|
||||
0%, 35%, 55%, 85%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
45% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/llama_stack/ui/app/layout.tsx
Normal file
63
src/llama_stack/ui/app/layout.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { Metadata } from "next";
|
||||
import { ThemeProvider } from "@/components/ui/theme-provider";
|
||||
import { SessionProvider } from "@/components/providers/session-provider";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { ModeToggle } from "@/components/ui/mode-toggle";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Llama Stack",
|
||||
description: "Llama Stack UI",
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
};
|
||||
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { SignInButton } from "@/components/ui/sign-in-button";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} font-sans`}>
|
||||
<SessionProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<main className="flex flex-col flex-1">
|
||||
{/* Header with aligned elements */}
|
||||
<div className="flex items-center p-4 border-b">
|
||||
<div className="flex-none">
|
||||
<SidebarTrigger />
|
||||
</div>
|
||||
<div className="flex-1 text-center"></div>
|
||||
<div className="flex-none flex items-center gap-2">
|
||||
<SignInButton />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 p-4">{children}</div>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
59
src/llama_stack/ui/app/logs/chat-completions/[id]/page.tsx
Normal file
59
src/llama_stack/ui/app/logs/chat-completions/[id]/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
src/llama_stack/ui/app/logs/chat-completions/layout.tsx
Normal file
19
src/llama_stack/ui/app/logs/chat-completions/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/llama_stack/ui/app/logs/chat-completions/page.tsx
Normal file
7
src/llama_stack/ui/app/logs/chat-completions/page.tsx
Normal 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 }} />;
|
||||
}
|
||||
125
src/llama_stack/ui/app/logs/responses/[id]/page.tsx
Normal file
125
src/llama_stack/ui/app/logs/responses/[id]/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
16
src/llama_stack/ui/app/logs/responses/layout.tsx
Normal file
16
src/llama_stack/ui/app/logs/responses/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/llama_stack/ui/app/logs/responses/page.tsx
Normal file
7
src/llama_stack/ui/app/logs/responses/page.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ResponsesTable } from "@/components/responses/responses-table";
|
||||
|
||||
export default function ResponsesPage() {
|
||||
return <ResponsesTable paginationOptions={{ limit: 20 }} />;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
79
src/llama_stack/ui/app/logs/vector-stores/[id]/page.tsx
Normal file
79
src/llama_stack/ui/app/logs/vector-stores/[id]/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
31
src/llama_stack/ui/app/logs/vector-stores/layout.tsx
Normal file
31
src/llama_stack/ui/app/logs/vector-stores/layout.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import {
|
||||
PageBreadcrumb,
|
||||
BreadcrumbSegment,
|
||||
} from "@/components/layout/page-breadcrumb";
|
||||
|
||||
export default function 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>
|
||||
);
|
||||
}
|
||||
138
src/llama_stack/ui/app/logs/vector-stores/page.tsx
Normal file
138
src/llama_stack/ui/app/logs/vector-stores/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/llama_stack/ui/app/page.tsx
Normal file
7
src/llama_stack/ui/app/page.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export default function Home() {
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h1>Welcome to Llama Stack!</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue