mirror of
https://github.com/meta-llama/llama-stack.git
synced 2025-06-27 18:50:41 +00:00
feat(ui): implement chat completion views (#2201)
# What does this PR do? Implements table and detail views for chat completions <img width="1548" alt="image" src="https://github.com/user-attachments/assets/01061b7f-0d47-4b3b-b5ac-2df8f9035ef6" /> <img width="1549" alt="image" src="https://github.com/user-attachments/assets/738d8612-8258-4c2c-858b-bee39030649f" /> ## Test Plan npm run test
This commit is contained in:
parent
d8c6ab9bfc
commit
2708312168
27 changed files with 6729 additions and 38 deletions
|
@ -23,6 +23,7 @@ import yaml
|
|||
from fastapi import Body, FastAPI, HTTPException, Request
|
||||
from fastapi import Path as FastapiPath
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from openai import BadRequestError
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
@ -465,6 +466,17 @@ def main(args: argparse.Namespace | None = None):
|
|||
window_seconds=window_seconds,
|
||||
)
|
||||
|
||||
# --- CORS middleware for local development ---
|
||||
# TODO: move to reverse proxy
|
||||
ui_port = os.environ.get("LLAMA_STACK_UI_PORT", 8322)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[f"http://localhost:{ui_port}"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
try:
|
||||
impls = asyncio.run(construct_stack(config))
|
||||
except InvalidProviderError as e:
|
||||
|
|
3
llama_stack/ui/.prettierignore
Normal file
3
llama_stack/ui/.prettierignore
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Ignore artifacts:
|
||||
build
|
||||
coverage
|
1
llama_stack/ui/.prettierrc
Normal file
1
llama_stack/ui/.prettierrc
Normal file
|
@ -0,0 +1 @@
|
|||
{}
|
|
@ -1,6 +1,5 @@
|
|||
## This is WIP.
|
||||
|
||||
|
||||
We use shadcdn/ui [Shadcn UI](https://ui.shadcn.com/) for the UI components.
|
||||
|
||||
## Getting Started
|
||||
|
@ -23,4 +22,4 @@ pnpm dev
|
|||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
Open [http://localhost:8322](http://localhost:8322) with your browser to see the result.
|
||||
|
|
|
@ -20,7 +20,7 @@ export const metadata: Metadata = {
|
|||
};
|
||||
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
|
62
llama_stack/ui/app/logs/chat-completions/[id]/page.tsx
Normal file
62
llama_stack/ui/app/logs/chat-completions/[id]/page.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import LlamaStackClient from "llama-stack-client";
|
||||
import { ChatCompletion } from "@/lib/types";
|
||||
import { ChatCompletionDetailView } from "@/components/chat-completions/chat-completion-detail";
|
||||
|
||||
export default function ChatCompletionDetailPage() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
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 client = new LlamaStackClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_LLAMA_STACK_BASE_URL,
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<ChatCompletionDetailView
|
||||
completion={completionDetail}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
}
|
45
llama_stack/ui/app/logs/chat-completions/layout.tsx
Normal file
45
llama_stack/ui/app/logs/chat-completions/layout.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { usePathname, useParams } from "next/navigation";
|
||||
import {
|
||||
PageBreadcrumb,
|
||||
BreadcrumbSegment,
|
||||
} from "@/components/layout/page-breadcrumb";
|
||||
import { truncateText } from "@/lib/truncate-text";
|
||||
|
||||
export default function ChatCompletionsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
|
||||
let segments: BreadcrumbSegment[] = [];
|
||||
|
||||
// Default for /logs/chat-completions
|
||||
if (pathname === "/logs/chat-completions") {
|
||||
segments = [{ label: "Chat Completions" }];
|
||||
}
|
||||
|
||||
// For /logs/chat-completions/[id]
|
||||
const idParam = params?.id;
|
||||
if (idParam && typeof idParam === "string") {
|
||||
segments = [
|
||||
{ label: "Chat Completions", href: "/logs/chat-completions" },
|
||||
{ label: `Details (${truncateText(idParam, 20)})` },
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<>
|
||||
{segments.length > 0 && (
|
||||
<PageBreadcrumb segments={segments} className="mb-4" />
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,7 +1,54 @@
|
|||
export default function ChatCompletions() {
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import LlamaStackClient from "llama-stack-client";
|
||||
import { ChatCompletion } from "@/lib/types";
|
||||
import { ChatCompletionsTable } from "@/components/chat-completions/chat-completion-table";
|
||||
|
||||
export default function ChatCompletionsPage() {
|
||||
const [completions, setCompletions] = useState<ChatCompletion[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const client = new LlamaStackClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_LLAMA_STACK_BASE_URL,
|
||||
});
|
||||
const fetchCompletions = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await client.chat.completions.list();
|
||||
const data = Array.isArray(response)
|
||||
? response
|
||||
: (response as any).data;
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
setCompletions(data);
|
||||
} else {
|
||||
console.error("Unexpected response structure:", response);
|
||||
setError(new Error("Unexpected response structure"));
|
||||
setCompletions([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching chat completions:", err);
|
||||
setError(
|
||||
err instanceof Error ? err : new Error("Failed to fetch completions"),
|
||||
);
|
||||
setCompletions([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCompletions();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Under Construction</h1>
|
||||
</div>
|
||||
<ChatCompletionsTable
|
||||
completions={completions}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,193 @@
|
|||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { ChatCompletionDetailView } from "./chat-completion-detail";
|
||||
import { ChatCompletion } from "@/lib/types";
|
||||
|
||||
// Initial test file setup for ChatCompletionDetailView
|
||||
|
||||
describe("ChatCompletionDetailView", () => {
|
||||
test("renders skeleton UI when isLoading is true", () => {
|
||||
const { container } = render(
|
||||
<ChatCompletionDetailView
|
||||
completion={null}
|
||||
isLoading={true}
|
||||
error={null}
|
||||
id="test-id"
|
||||
/>,
|
||||
);
|
||||
// Use the data-slot attribute for Skeletons
|
||||
const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("renders error message when error prop is provided", () => {
|
||||
render(
|
||||
<ChatCompletionDetailView
|
||||
completion={null}
|
||||
isLoading={false}
|
||||
error={{ name: "Error", message: "Network Error" }}
|
||||
id="err-id"
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/Error loading details for ID err-id: Network Error/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders default error message when error.message is empty", () => {
|
||||
render(
|
||||
<ChatCompletionDetailView
|
||||
completion={null}
|
||||
isLoading={false}
|
||||
error={{ name: "Error", message: "" }}
|
||||
id="err-id"
|
||||
/>,
|
||||
);
|
||||
// Use regex to match the error message regardless of whitespace
|
||||
expect(
|
||||
screen.getByText(/Error loading details for ID\s*err-id\s*:/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders error message when error prop is an object without message", () => {
|
||||
render(
|
||||
<ChatCompletionDetailView
|
||||
completion={null}
|
||||
isLoading={false}
|
||||
error={{} as Error}
|
||||
id="err-id"
|
||||
/>,
|
||||
);
|
||||
// Use regex to match the error message regardless of whitespace
|
||||
expect(
|
||||
screen.getByText(/Error loading details for ID\s*err-id\s*:/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders not found message when completion is null and not loading/error", () => {
|
||||
render(
|
||||
<ChatCompletionDetailView
|
||||
completion={null}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
id="notfound-id"
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("No details found for completion ID: notfound-id."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders input, output, and properties for valid completion", () => {
|
||||
const mockCompletion: ChatCompletion = {
|
||||
id: "comp_123",
|
||||
object: "chat.completion",
|
||||
created: 1710000000,
|
||||
model: "llama-test-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "Test output" },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
input_messages: [{ role: "user", content: "Test input" }],
|
||||
};
|
||||
render(
|
||||
<ChatCompletionDetailView
|
||||
completion={mockCompletion}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
id={mockCompletion.id}
|
||||
/>,
|
||||
);
|
||||
// Input
|
||||
expect(screen.getByText("Input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test input")).toBeInTheDocument();
|
||||
// Output
|
||||
expect(screen.getByText("Output")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test output")).toBeInTheDocument();
|
||||
// Properties
|
||||
expect(screen.getByText("Properties")).toBeInTheDocument();
|
||||
expect(screen.getByText("Created:")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(new Date(1710000000 * 1000).toLocaleString()),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("ID:")).toBeInTheDocument();
|
||||
expect(screen.getByText("comp_123")).toBeInTheDocument();
|
||||
expect(screen.getByText("Model:")).toBeInTheDocument();
|
||||
expect(screen.getByText("llama-test-model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Finish Reason:")).toBeInTheDocument();
|
||||
expect(screen.getByText("stop")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders tool call in output and properties when present", () => {
|
||||
const toolCall = {
|
||||
function: { name: "search", arguments: '{"query":"llama"}' },
|
||||
};
|
||||
const mockCompletion: ChatCompletion = {
|
||||
id: "comp_tool",
|
||||
object: "chat.completion",
|
||||
created: 1710001000,
|
||||
model: "llama-tool-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Tool output",
|
||||
tool_calls: [toolCall],
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
input_messages: [{ role: "user", content: "Tool input" }],
|
||||
};
|
||||
render(
|
||||
<ChatCompletionDetailView
|
||||
completion={mockCompletion}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
id={mockCompletion.id}
|
||||
/>,
|
||||
);
|
||||
// Output should include the tool call block (should be present twice: input and output)
|
||||
const toolCallLabels = screen.getAllByText("Tool Call");
|
||||
expect(toolCallLabels.length).toBeGreaterThanOrEqual(1); // At least one, but could be two
|
||||
// The tool call block should contain the formatted tool call string in both input and output
|
||||
const toolCallBlocks = screen.getAllByText('search({"query":"llama"})');
|
||||
expect(toolCallBlocks.length).toBe(2);
|
||||
// Properties should include the tool call name
|
||||
expect(screen.getByText("Functions/Tools Called:")).toBeInTheDocument();
|
||||
expect(screen.getByText("search")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles missing/empty fields gracefully", () => {
|
||||
const mockCompletion: ChatCompletion = {
|
||||
id: "comp_edge",
|
||||
object: "chat.completion",
|
||||
created: 1710002000,
|
||||
model: "llama-edge-model",
|
||||
choices: [], // No choices
|
||||
input_messages: [], // No input messages
|
||||
};
|
||||
render(
|
||||
<ChatCompletionDetailView
|
||||
completion={mockCompletion}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
id={mockCompletion.id}
|
||||
/>,
|
||||
);
|
||||
// Input section should be present but empty
|
||||
expect(screen.getByText("Input")).toBeInTheDocument();
|
||||
// Output section should show fallback message
|
||||
expect(
|
||||
screen.getByText("No message found in assistant's choice."),
|
||||
).toBeInTheDocument();
|
||||
// Properties should show N/A for finish reason
|
||||
expect(screen.getByText("Finish Reason:")).toBeInTheDocument();
|
||||
expect(screen.getByText("N/A")).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,198 @@
|
|||
"use client";
|
||||
|
||||
import { ChatMessage, ChatCompletion } from "@/lib/types";
|
||||
import { ChatMessageItem } from "@/components/chat-completions/chat-messasge-item";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
function ChatCompletionDetailLoadingView() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className="h-8 w-3/4 mb-6" /> {/* Title Skeleton */}
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="flex-grow md:w-2/3 space-y-6">
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<Card key={`main-skeleton-card-${i}`}>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Skeleton className="h-6 w-1/2" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="md:w-1/3">
|
||||
<div className="p-4 border rounded-lg shadow-sm bg-white space-y-3">
|
||||
<Skeleton className="h-6 w-1/3 mb-3" />{" "}
|
||||
{/* Properties Title Skeleton */}
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={`prop-skeleton-${i}`} className="space-y-1">
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatCompletionDetailViewProps {
|
||||
completion: ChatCompletion | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function ChatCompletionDetailView({
|
||||
completion,
|
||||
isLoading,
|
||||
error,
|
||||
id,
|
||||
}: ChatCompletionDetailViewProps) {
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
{/* We still want a title for consistency on error pages */}
|
||||
<h1 className="text-2xl font-bold mb-6">Chat Completion Details</h1>
|
||||
<p>
|
||||
Error loading details for ID {id}: {error.message}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <ChatCompletionDetailLoadingView />;
|
||||
}
|
||||
|
||||
if (!completion) {
|
||||
// This state means: not loading, no error, but no completion data
|
||||
return (
|
||||
<>
|
||||
{/* We still want a title for consistency on not-found pages */}
|
||||
<h1 className="text-2xl font-bold mb-6">Chat Completion Details</h1>
|
||||
<p>No details found for completion ID: {id}.</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// If no error, not loading, and completion exists, render the details:
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold mb-6">Chat Completion Details</h1>
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="flex-grow md:w-2/3 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Input</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{completion.input_messages?.map((msg, index) => (
|
||||
<ChatMessageItem key={`input-msg-${index}`} message={msg} />
|
||||
))}
|
||||
{completion.choices?.[0]?.message?.tool_calls &&
|
||||
!completion.input_messages?.some(
|
||||
(im) =>
|
||||
im.role === "assistant" &&
|
||||
im.tool_calls &&
|
||||
im.tool_calls.length > 0,
|
||||
) &&
|
||||
completion.choices[0].message.tool_calls.map(
|
||||
(toolCall: any, index: number) => {
|
||||
const assistantToolCallMessage: ChatMessage = {
|
||||
role: "assistant",
|
||||
tool_calls: [toolCall],
|
||||
content: "", // Ensure content is defined, even if empty
|
||||
};
|
||||
return (
|
||||
<ChatMessageItem
|
||||
key={`choice-tool-call-${index}`}
|
||||
message={assistantToolCallMessage}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Output</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{completion.choices?.[0]?.message ? (
|
||||
<ChatMessageItem
|
||||
message={completion.choices[0].message as ChatMessage}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-500 italic text-sm">
|
||||
No message found in assistant's choice.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="md:w-1/3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Properties</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<li>
|
||||
<strong>Created:</strong>{" "}
|
||||
<span className="text-gray-900 font-medium">
|
||||
{new Date(completion.created * 1000).toLocaleString()}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>ID:</strong>{" "}
|
||||
<span className="text-gray-900 font-medium">
|
||||
{completion.id}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Model:</strong>{" "}
|
||||
<span className="text-gray-900 font-medium">
|
||||
{completion.model}
|
||||
</span>
|
||||
</li>
|
||||
<li className="pt-1 mt-1 border-t border-gray-200">
|
||||
<strong>Finish Reason:</strong>{" "}
|
||||
<span className="text-gray-900 font-medium">
|
||||
{completion.choices?.[0]?.finish_reason || "N/A"}
|
||||
</span>
|
||||
</li>
|
||||
{completion.choices?.[0]?.message?.tool_calls &&
|
||||
completion.choices[0].message.tool_calls.length > 0 && (
|
||||
<li className="pt-1 mt-1 border-t border-gray-200">
|
||||
<strong>Functions/Tools Called:</strong>
|
||||
<ul className="list-disc list-inside pl-4 mt-1">
|
||||
{completion.choices[0].message.tool_calls.map(
|
||||
(toolCall: any, index: number) => (
|
||||
<li key={index}>
|
||||
<span className="text-gray-900 font-medium">
|
||||
{toolCall.function?.name || "N/A"}
|
||||
</span>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,340 @@
|
|||
import React from "react";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { ChatCompletionsTable } from "./chat-completion-table";
|
||||
import { ChatCompletion } from "@/lib/types"; // Assuming this path is correct
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = jest.fn();
|
||||
jest.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock helper functions
|
||||
// These are hoisted, so their mocks are available throughout the file
|
||||
jest.mock("@/lib/truncate-text");
|
||||
jest.mock("@/lib/format-tool-call");
|
||||
|
||||
// Import the mocked functions to set up default or specific implementations
|
||||
import { truncateText as originalTruncateText } from "@/lib/truncate-text";
|
||||
import { formatToolCallToString as originalFormatToolCallToString } from "@/lib/format-tool-call";
|
||||
|
||||
// Cast to jest.Mock for typings
|
||||
const truncateText = originalTruncateText as jest.Mock;
|
||||
const formatToolCallToString = originalFormatToolCallToString as jest.Mock;
|
||||
|
||||
describe("ChatCompletionsTable", () => {
|
||||
const defaultProps = {
|
||||
completions: [] as ChatCompletion[],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks before each test
|
||||
mockPush.mockClear();
|
||||
truncateText.mockClear();
|
||||
formatToolCallToString.mockClear();
|
||||
|
||||
// Default pass-through implementation for tests not focusing on truncation/formatting
|
||||
truncateText.mockImplementation((text: string | undefined) => text);
|
||||
formatToolCallToString.mockImplementation((toolCall: any) =>
|
||||
toolCall && typeof toolCall === "object" && toolCall.name
|
||||
? `[DefaultToolCall:${toolCall.name}]`
|
||||
: "[InvalidToolCall]",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders without crashing with default props", () => {
|
||||
render(<ChatCompletionsTable {...defaultProps} />);
|
||||
// Check for a unique element that should be present in the non-empty, non-loading, non-error state
|
||||
// For now, as per Task 1, we will test the empty state message
|
||||
expect(screen.getByText("No chat completions found.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("click on a row navigates to the correct URL", () => {
|
||||
const { rerender } = render(<ChatCompletionsTable {...defaultProps} />);
|
||||
|
||||
// Simulate a scenario where a completion exists and is clicked
|
||||
const mockCompletion: ChatCompletion = {
|
||||
id: "comp_123",
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: "llama-test-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "Test output" },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
input_messages: [{ role: "user", content: "Test input" }],
|
||||
};
|
||||
|
||||
rerender(
|
||||
<ChatCompletionsTable {...defaultProps} completions={[mockCompletion]} />,
|
||||
);
|
||||
const row = screen.getByText("Test input").closest("tr");
|
||||
if (row) {
|
||||
fireEvent.click(row);
|
||||
expect(mockPush).toHaveBeenCalledWith("/logs/chat-completions/comp_123");
|
||||
} else {
|
||||
throw new Error('Row with "Test input" not found for router mock test.');
|
||||
}
|
||||
});
|
||||
|
||||
describe("Loading State", () => {
|
||||
test("renders skeleton UI when isLoading is true", () => {
|
||||
const { container } = render(
|
||||
<ChatCompletionsTable {...defaultProps} isLoading={true} />,
|
||||
);
|
||||
|
||||
// The Skeleton component uses data-slot="skeleton"
|
||||
const skeletonSelector = '[data-slot="skeleton"]';
|
||||
|
||||
// Check for skeleton in the table caption
|
||||
const tableCaption = container.querySelector("caption");
|
||||
expect(tableCaption).toBeInTheDocument();
|
||||
if (tableCaption) {
|
||||
const captionSkeleton = tableCaption.querySelector(skeletonSelector);
|
||||
expect(captionSkeleton).toBeInTheDocument();
|
||||
}
|
||||
|
||||
// Check for skeletons in the table body cells
|
||||
const tableBody = container.querySelector("tbody");
|
||||
expect(tableBody).toBeInTheDocument();
|
||||
if (tableBody) {
|
||||
const bodySkeletons = tableBody.querySelectorAll(
|
||||
`td ${skeletonSelector}`,
|
||||
);
|
||||
expect(bodySkeletons.length).toBeGreaterThan(0); // Ensure at least one skeleton cell exists
|
||||
}
|
||||
|
||||
// General check: ensure multiple skeleton elements are present in the table overall
|
||||
const allSkeletonsInTable = container.querySelectorAll(
|
||||
`table ${skeletonSelector}`,
|
||||
);
|
||||
expect(allSkeletonsInTable.length).toBeGreaterThan(3); // e.g., caption + at least one row of 3 cells, or just a few
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error State", () => {
|
||||
test("renders error message when error prop is provided", () => {
|
||||
const errorMessage = "Network Error";
|
||||
render(
|
||||
<ChatCompletionsTable
|
||||
{...defaultProps}
|
||||
error={{ name: "Error", message: errorMessage }}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(`Error fetching data: ${errorMessage}`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders default error message when error.message is not available", () => {
|
||||
render(
|
||||
<ChatCompletionsTable
|
||||
{...defaultProps}
|
||||
error={{ name: "Error", message: "" }}
|
||||
/>,
|
||||
); // Error with empty message
|
||||
expect(
|
||||
screen.getByText("Error fetching data: An unknown error occurred"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders default error message when error prop is an object without message", () => {
|
||||
render(<ChatCompletionsTable {...defaultProps} error={{} as Error} />); // Empty error object
|
||||
expect(
|
||||
screen.getByText("Error fetching data: An unknown error occurred"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Empty State", () => {
|
||||
test('renders "No chat completions found." and no table when completions array is empty', () => {
|
||||
render(
|
||||
<ChatCompletionsTable
|
||||
completions={[]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("No chat completions found."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Ensure that the table structure is NOT rendered in the empty state
|
||||
const table = screen.queryByRole("table");
|
||||
expect(table).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data Rendering", () => {
|
||||
test("renders table caption, headers, and completion data correctly", () => {
|
||||
const mockCompletions = [
|
||||
{
|
||||
id: "comp_1",
|
||||
object: "chat.completion",
|
||||
created: 1710000000, // Fixed timestamp for test
|
||||
model: "llama-test-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "Test output" },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
input_messages: [{ role: "user", content: "Test input" }],
|
||||
},
|
||||
{
|
||||
id: "comp_2",
|
||||
object: "chat.completion",
|
||||
created: 1710001000,
|
||||
model: "llama-another-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "Another output" },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
input_messages: [{ role: "user", content: "Another input" }],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<ChatCompletionsTable
|
||||
completions={mockCompletions}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Table caption
|
||||
expect(
|
||||
screen.getByText("A list of your recent chat completions."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Table headers
|
||||
expect(screen.getByText("Input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output")).toBeInTheDocument();
|
||||
expect(screen.getByText("Model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Created")).toBeInTheDocument();
|
||||
|
||||
// Data rows
|
||||
expect(screen.getByText("Test input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test output")).toBeInTheDocument();
|
||||
expect(screen.getByText("llama-test-model")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(new Date(1710000000 * 1000).toLocaleString()),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Another input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Another output")).toBeInTheDocument();
|
||||
expect(screen.getByText("llama-another-model")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(new Date(1710001000 * 1000).toLocaleString()),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Text Truncation and Tool Call Formatting", () => {
|
||||
test("truncates long input and output text", () => {
|
||||
// Specific mock implementation for this test
|
||||
truncateText.mockImplementation(
|
||||
(text: string | undefined, maxLength?: number) => {
|
||||
const defaultTestMaxLength = 10;
|
||||
const effectiveMaxLength = maxLength ?? defaultTestMaxLength;
|
||||
return typeof text === "string" && text.length > effectiveMaxLength
|
||||
? text.slice(0, effectiveMaxLength) + "..."
|
||||
: text;
|
||||
},
|
||||
);
|
||||
|
||||
const longInput =
|
||||
"This is a very long input message that should be truncated.";
|
||||
const longOutput =
|
||||
"This is a very long output message that should also be truncated.";
|
||||
const mockCompletions = [
|
||||
{
|
||||
id: "comp_trunc",
|
||||
object: "chat.completion",
|
||||
created: 1710002000,
|
||||
model: "llama-trunc-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: longOutput },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
input_messages: [{ role: "user", content: longInput }],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<ChatCompletionsTable
|
||||
completions={mockCompletions}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The truncated text should be present for both input and output
|
||||
const truncatedTexts = screen.getAllByText(
|
||||
longInput.slice(0, 10) + "...",
|
||||
);
|
||||
expect(truncatedTexts.length).toBe(2); // one for input, one for output
|
||||
// Optionally, verify each one is in the document if getAllByText doesn't throw on not found
|
||||
truncatedTexts.forEach((textElement) =>
|
||||
expect(textElement).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
test("formats tool call output using formatToolCallToString", () => {
|
||||
// Specific mock implementation for this test
|
||||
formatToolCallToString.mockImplementation(
|
||||
(toolCall: any) => `[TOOL:${toolCall.name}]`,
|
||||
);
|
||||
// Ensure no truncation interferes for this specific test for clarity of tool call format
|
||||
truncateText.mockImplementation((text: string | undefined) => text);
|
||||
|
||||
const toolCall = { name: "search", args: { query: "llama" } };
|
||||
const mockCompletions = [
|
||||
{
|
||||
id: "comp_tool",
|
||||
object: "chat.completion",
|
||||
created: 1710003000,
|
||||
model: "llama-tool-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Tool output", // Content that will be prepended
|
||||
tool_calls: [toolCall],
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
input_messages: [{ role: "user", content: "Tool input" }],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<ChatCompletionsTable
|
||||
completions={mockCompletions}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The component concatenates message.content and the formatted tool call
|
||||
expect(screen.getByText("Tool output [TOOL:search]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,120 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChatCompletion } from "@/lib/types";
|
||||
import { truncateText } from "@/lib/truncate-text";
|
||||
import {
|
||||
extractTextFromContentPart,
|
||||
extractDisplayableText,
|
||||
} from "@/lib/format-message-content";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface ChatCompletionsTableProps {
|
||||
completions: ChatCompletion[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function ChatCompletionsTable({
|
||||
completions,
|
||||
isLoading,
|
||||
error,
|
||||
}: ChatCompletionsTableProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const tableHeader = (
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Input</TableHead>
|
||||
<TableHead>Output</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead className="text-right">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Table>
|
||||
<TableCaption>
|
||||
<Skeleton className="h-4 w-[250px] mx-auto" />
|
||||
</TableCaption>
|
||||
{tableHeader}
|
||||
<TableBody>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<TableRow key={`skeleton-${i}`}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Skeleton className="h-4 w-1/2 ml-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p>Error fetching data: {error.message || "An unknown error occurred"}</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (completions.length === 0) {
|
||||
return <p>No chat completions found.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableCaption>A list of your recent chat completions.</TableCaption>
|
||||
{tableHeader}
|
||||
<TableBody>
|
||||
{completions.map((completion) => (
|
||||
<TableRow
|
||||
key={completion.id}
|
||||
onClick={() =>
|
||||
router.push(`/logs/chat-completions/${completion.id}`)
|
||||
}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<TableCell>
|
||||
{truncateText(
|
||||
extractTextFromContentPart(
|
||||
completion.input_messages?.[0]?.content,
|
||||
),
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
const message = completion.choices?.[0]?.message;
|
||||
const outputText = extractDisplayableText(message);
|
||||
return truncateText(outputText);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>{completion.model}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{new Date(completion.created * 1000).toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
import { ChatMessage } from "@/lib/types";
|
||||
import React from "react";
|
||||
import { formatToolCallToString } from "@/lib/format-tool-call";
|
||||
import { extractTextFromContentPart } from "@/lib/format-message-content";
|
||||
|
||||
// Sub-component or helper for the common label + content structure
|
||||
const MessageBlock: React.FC<{
|
||||
label: string;
|
||||
labelDetail?: string;
|
||||
content: React.ReactNode;
|
||||
}> = ({ label, labelDetail, content }) => {
|
||||
return (
|
||||
<div>
|
||||
<p className="py-1 font-semibold text-gray-800 mb-1">
|
||||
{label}
|
||||
{labelDetail && (
|
||||
<span className="text-xs text-gray-500 font-normal ml-1">
|
||||
{labelDetail}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="py-1">{content}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ToolCallBlockProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ToolCallBlock = ({ children, className }: ToolCallBlockProps) => {
|
||||
// Common styling for both function call arguments and tool output blocks
|
||||
// Let's use slate-50 background as it's good for code-like content.
|
||||
const baseClassName =
|
||||
"p-3 bg-slate-50 border border-slate-200 rounded-md text-sm";
|
||||
|
||||
return (
|
||||
<div className={`${baseClassName} ${className || ""}`}>
|
||||
<pre className="whitespace-pre-wrap text-xs">{children}</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ChatMessageItemProps {
|
||||
message: ChatMessage;
|
||||
}
|
||||
export function ChatMessageItem({ message }: ChatMessageItemProps) {
|
||||
switch (message.role) {
|
||||
case "system":
|
||||
return (
|
||||
<MessageBlock
|
||||
label="System"
|
||||
content={extractTextFromContentPart(message.content)}
|
||||
/>
|
||||
);
|
||||
case "user":
|
||||
return (
|
||||
<MessageBlock
|
||||
label="User"
|
||||
content={extractTextFromContentPart(message.content)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "assistant":
|
||||
if (message.tool_calls && message.tool_calls.length > 0) {
|
||||
return (
|
||||
<>
|
||||
{message.tool_calls.map((toolCall: any, index: number) => {
|
||||
const formattedToolCall = formatToolCallToString(toolCall);
|
||||
const toolCallContent = (
|
||||
<ToolCallBlock>
|
||||
{formattedToolCall || "Error: Could not display tool call"}
|
||||
</ToolCallBlock>
|
||||
);
|
||||
return (
|
||||
<MessageBlock
|
||||
key={index}
|
||||
label="Tool Call"
|
||||
content={toolCallContent}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<MessageBlock
|
||||
label="Assistant"
|
||||
content={extractTextFromContentPart(message.content)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "tool":
|
||||
const toolOutputContent = (
|
||||
<ToolCallBlock>
|
||||
{extractTextFromContentPart(message.content)}
|
||||
</ToolCallBlock>
|
||||
);
|
||||
return (
|
||||
<MessageBlock label="Tool Call Output" content={toolOutputContent} />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
|
@ -1,5 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquareText, MessagesSquare, MoveUpRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
|
@ -32,6 +36,8 @@ const logItems = [
|
|||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
|
@ -42,16 +48,31 @@ export function AppSidebar() {
|
|||
<SidebarGroupLabel>Logs</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{logItems.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{logItems.map((item) => {
|
||||
const isActive = pathname.startsWith(item.url);
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className={cn(
|
||||
"justify-start",
|
||||
isActive &&
|
||||
"bg-gray-200 hover:bg-gray-200 text-primary hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<item.icon
|
||||
className={cn(
|
||||
isActive && "text-primary",
|
||||
"mr-2 h-4 w-4",
|
||||
)}
|
||||
/>
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
49
llama_stack/ui/components/layout/page-breadcrumb.tsx
Normal file
49
llama_stack/ui/components/layout/page-breadcrumb.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
|
||||
export interface BreadcrumbSegment {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface PageBreadcrumbProps {
|
||||
segments: BreadcrumbSegment[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageBreadcrumb({ segments, className }: PageBreadcrumbProps) {
|
||||
if (!segments || segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumb className={className}>
|
||||
<BreadcrumbList>
|
||||
{segments.map((segment, index) => (
|
||||
<React.Fragment key={segment.label + index}>
|
||||
<BreadcrumbItem>
|
||||
{segment.href ? (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={segment.href}>{segment.label}</Link>
|
||||
</BreadcrumbLink>
|
||||
) : (
|
||||
<BreadcrumbPage>{segment.label}</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{index < segments.length - 1 && <BreadcrumbSeparator />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
109
llama_stack/ui/components/ui/breadcrumb.tsx
Normal file
109
llama_stack/ui/components/ui/breadcrumb.tsx
Normal file
|
@ -0,0 +1,109 @@
|
|||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
92
llama_stack/ui/components/ui/card.tsx
Normal file
92
llama_stack/ui/components/ui/card.tsx
Normal file
|
@ -0,0 +1,92 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
116
llama_stack/ui/components/ui/table.tsx
Normal file
116
llama_stack/ui/components/ui/table.tsx
Normal file
|
@ -0,0 +1,116 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
210
llama_stack/ui/jest.config.ts
Normal file
210
llama_stack/ui/jest.config.ts
Normal file
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* For a detailed explanation regarding each configuration property, visit:
|
||||
* https://jestjs.io/docs/configuration
|
||||
*/
|
||||
|
||||
import type { Config } from "jest";
|
||||
import nextJest from "next/jest.js";
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
|
||||
dir: "./",
|
||||
});
|
||||
const config: Config = {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "/private/var/folders/cz/vyh7y1d11xg881lsxsshnc5c0000gn/T/jest_dx",
|
||||
|
||||
// Automatically clear mock calls, instances, contexts and results before every test
|
||||
// clearMocks: false,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
// collectCoverage: false,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// collectCoverageFrom: undefined,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
// coverageDirectory: undefined,
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// Indicates which provider should be used to instrument code for coverage
|
||||
coverageProvider: "v8",
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: undefined,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: undefined,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// The default configuration for fake timers
|
||||
// fakeTimers: {
|
||||
// "enableGlobally": false
|
||||
// },
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: undefined,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: undefined,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// globals: {},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "mjs",
|
||||
// "cjs",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "json",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
moduleNameMapper: {
|
||||
// Handle module aliases (this will be automatically configured by Next.js)
|
||||
// However, for mocks, sometimes explicit mapping is needed.
|
||||
"^@/lib/(.*)$": "<rootDir>/lib/$1",
|
||||
"^@/components/(.*)$": "<rootDir>/components/$1",
|
||||
// Add other aliases here if needed
|
||||
},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: undefined,
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: undefined,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state before every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: undefined,
|
||||
|
||||
// Automatically restore mock state and implementation before every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// rootDir: undefined,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
// roots: [
|
||||
// "<rootDir>"
|
||||
// ],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// slowTestThreshold: 5,
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: "jsdom",
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
// testMatch: [
|
||||
// "**/__tests__/**/*.[jt]s?(x)",
|
||||
// "**/?(*.)+(spec|test).[tj]s?(x)"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// testPathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: undefined,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jest-circus/runner",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
// transform: undefined,
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "/node_modules/",
|
||||
// "\\.pnp\\.[^\\/]+$"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: undefined,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
};
|
||||
|
||||
export default createJestConfig(config);
|
193
llama_stack/ui/lib/format-message-content.test.ts
Normal file
193
llama_stack/ui/lib/format-message-content.test.ts
Normal file
|
@ -0,0 +1,193 @@
|
|||
import {
|
||||
extractTextFromContentPart,
|
||||
extractDisplayableText,
|
||||
} from "./format-message-content";
|
||||
import { ChatMessage } from "@/lib/types";
|
||||
|
||||
describe("extractTextFromContentPart", () => {
|
||||
it("should return an empty string for null or undefined input", () => {
|
||||
expect(extractTextFromContentPart(null)).toBe("");
|
||||
expect(extractTextFromContentPart(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("should return the string itself if input is a string", () => {
|
||||
expect(extractTextFromContentPart("Hello, world!")).toBe("Hello, world!");
|
||||
expect(extractTextFromContentPart("")).toBe("");
|
||||
});
|
||||
|
||||
it("should extract text from an array of text content objects", () => {
|
||||
const content = [{ type: "text", text: "Which planet do humans live on?" }];
|
||||
expect(extractTextFromContentPart(content)).toBe(
|
||||
"Which planet do humans live on?",
|
||||
);
|
||||
});
|
||||
|
||||
it("should join text from multiple text content objects in an array", () => {
|
||||
const content = [
|
||||
{ type: "text", text: "Hello," },
|
||||
{ type: "text", text: "world!" },
|
||||
];
|
||||
expect(extractTextFromContentPart(content)).toBe("Hello, world!");
|
||||
});
|
||||
|
||||
it("should handle mixed text and image_url types in an array", () => {
|
||||
const content = [
|
||||
{ type: "text", text: "Look at this:" },
|
||||
{ type: "image_url", image_url: { url: "http://example.com/image.png" } },
|
||||
{ type: "text", text: "It's an image." },
|
||||
];
|
||||
expect(extractTextFromContentPart(content)).toBe(
|
||||
"Look at this: [Image] It's an image.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return '[Image]' for an array with only an image_url object", () => {
|
||||
const content = [
|
||||
{ type: "image_url", image_url: { url: "http://example.com/image.png" } },
|
||||
];
|
||||
expect(extractTextFromContentPart(content)).toBe("[Image]");
|
||||
});
|
||||
|
||||
it("should return an empty string for an empty array", () => {
|
||||
expect(extractTextFromContentPart([])).toBe("");
|
||||
});
|
||||
|
||||
it("should handle arrays with plain strings", () => {
|
||||
const content = ["This is", " a test."] as any;
|
||||
expect(extractTextFromContentPart(content)).toBe("This is a test.");
|
||||
});
|
||||
|
||||
it("should filter out malformed or unrecognized objects in an array", () => {
|
||||
const content = [
|
||||
{ type: "text", text: "Valid" },
|
||||
{ type: "unknown" },
|
||||
{ text: "Missing type" },
|
||||
null,
|
||||
undefined,
|
||||
{ type: "text", noTextProperty: true },
|
||||
] as any;
|
||||
expect(extractTextFromContentPart(content)).toBe("Valid");
|
||||
});
|
||||
|
||||
it("should handle an array of mixed valid items and plain strings", () => {
|
||||
const content = [
|
||||
{ type: "text", text: "First part." },
|
||||
"Just a string.",
|
||||
{ type: "image_url", image_url: { url: "http://example.com/image.png" } },
|
||||
{ type: "text", text: "Last part." },
|
||||
] as any;
|
||||
expect(extractTextFromContentPart(content)).toBe(
|
||||
"First part. Just a string. [Image] Last part.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractDisplayableText (composite function)", () => {
|
||||
const mockFormatToolCallToString = (toolCall: any) => {
|
||||
if (!toolCall || !toolCall.function || !toolCall.function.name) return "";
|
||||
const args = toolCall.function.arguments
|
||||
? JSON.stringify(toolCall.function.arguments)
|
||||
: "";
|
||||
return `${toolCall.function.name}(${args})`;
|
||||
};
|
||||
|
||||
it("should return empty string for null or undefined message", () => {
|
||||
expect(extractDisplayableText(null)).toBe("");
|
||||
expect(extractDisplayableText(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("should return only content part if no tool calls", () => {
|
||||
const message: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: "Hello there!",
|
||||
};
|
||||
expect(extractDisplayableText(message)).toBe("Hello there!");
|
||||
});
|
||||
|
||||
it("should return only content part for complex content if no tool calls", () => {
|
||||
const message: ChatMessage = {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Part 1" },
|
||||
{ type: "text", text: "Part 2" },
|
||||
],
|
||||
};
|
||||
expect(extractDisplayableText(message)).toBe("Part 1 Part 2");
|
||||
});
|
||||
|
||||
it("should return only formatted tool call if content is empty or null", () => {
|
||||
const toolCall = {
|
||||
function: { name: "search", arguments: { query: "cats" } },
|
||||
};
|
||||
const messageWithEffectivelyEmptyContent: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [toolCall],
|
||||
};
|
||||
expect(extractDisplayableText(messageWithEffectivelyEmptyContent)).toBe(
|
||||
mockFormatToolCallToString(toolCall),
|
||||
);
|
||||
|
||||
const messageWithEmptyContent: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [toolCall],
|
||||
};
|
||||
expect(extractDisplayableText(messageWithEmptyContent)).toBe(
|
||||
mockFormatToolCallToString(toolCall),
|
||||
);
|
||||
});
|
||||
|
||||
it("should combine content and formatted tool call", () => {
|
||||
const toolCall = {
|
||||
function: { name: "calculator", arguments: { expr: "2+2" } },
|
||||
};
|
||||
const message: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: "The result is:",
|
||||
tool_calls: [toolCall],
|
||||
};
|
||||
const expectedToolCallStr = mockFormatToolCallToString(toolCall);
|
||||
expect(extractDisplayableText(message)).toBe(
|
||||
`The result is: ${expectedToolCallStr}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle message with content an array and a tool call", () => {
|
||||
const toolCall = {
|
||||
function: { name: "get_weather", arguments: { city: "London" } },
|
||||
};
|
||||
const message: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Okay, checking weather for" },
|
||||
{ type: "text", text: "London." },
|
||||
],
|
||||
tool_calls: [toolCall],
|
||||
};
|
||||
const expectedToolCallStr = mockFormatToolCallToString(toolCall);
|
||||
expect(extractDisplayableText(message)).toBe(
|
||||
`Okay, checking weather for London. ${expectedToolCallStr}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return only content if tool_calls array is empty or undefined", () => {
|
||||
const messageEmptyToolCalls: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: "No tools here.",
|
||||
tool_calls: [],
|
||||
};
|
||||
expect(extractDisplayableText(messageEmptyToolCalls)).toBe(
|
||||
"No tools here.",
|
||||
);
|
||||
|
||||
const messageUndefinedToolCalls: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: "Still no tools.",
|
||||
tool_calls: undefined,
|
||||
};
|
||||
expect(extractDisplayableText(messageUndefinedToolCalls)).toBe(
|
||||
"Still no tools.",
|
||||
);
|
||||
});
|
||||
});
|
61
llama_stack/ui/lib/format-message-content.ts
Normal file
61
llama_stack/ui/lib/format-message-content.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { ChatMessage, ChatMessageContentPart } from "@/lib/types";
|
||||
import { formatToolCallToString } from "@/lib/format-tool-call";
|
||||
|
||||
export function extractTextFromContentPart(
|
||||
content: string | ChatMessageContentPart[] | null | undefined,
|
||||
): string {
|
||||
if (content === null || content === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
} else if (Array.isArray(content)) {
|
||||
const parts: string[] = [];
|
||||
for (const item of content) {
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
item.type === "text" &&
|
||||
typeof item.text === "string"
|
||||
) {
|
||||
parts.push(item.text);
|
||||
} else if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
item.type === "image_url"
|
||||
) {
|
||||
parts.push("[Image]"); // Placeholder for images
|
||||
} else if (typeof item === "string") {
|
||||
// Handle cases where an array might contain plain strings
|
||||
parts.push(item);
|
||||
}
|
||||
}
|
||||
return parts.join(" ");
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractDisplayableText(
|
||||
message: ChatMessage | undefined | null,
|
||||
): string {
|
||||
if (!message) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let textPart = extractTextFromContentPart(message.content);
|
||||
let toolCallPart = "";
|
||||
|
||||
if (message.tool_calls && message.tool_calls.length > 0) {
|
||||
// For summary, usually the first tool call is sufficient
|
||||
toolCallPart = formatToolCallToString(message.tool_calls[0]);
|
||||
}
|
||||
|
||||
if (textPart && toolCallPart) {
|
||||
return `${textPart} ${toolCallPart}`;
|
||||
} else if (toolCallPart) {
|
||||
return toolCallPart;
|
||||
} else {
|
||||
return textPart; // textPart will be "" if message.content was initially null/undefined/empty array etc.
|
||||
}
|
||||
}
|
33
llama_stack/ui/lib/format-tool-call.tsx
Normal file
33
llama_stack/ui/lib/format-tool-call.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Formats a tool_call object into a string representation.
|
||||
* Example: "functionName(argumentsString)"
|
||||
* @param toolCall The tool_call object, expected to have a `function` property
|
||||
* with `name` and `arguments`.
|
||||
* @returns A formatted string or an empty string if data is malformed.
|
||||
*/
|
||||
export function formatToolCallToString(toolCall: any): string {
|
||||
if (
|
||||
!toolCall ||
|
||||
!toolCall.function ||
|
||||
typeof toolCall.function.name !== "string" ||
|
||||
toolCall.function.arguments === undefined
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const name = toolCall.function.name;
|
||||
const args = toolCall.function.arguments;
|
||||
let argsString: string;
|
||||
|
||||
if (typeof args === "string") {
|
||||
argsString = args;
|
||||
} else {
|
||||
try {
|
||||
argsString = JSON.stringify(args);
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
return `${name}(${argsString})`;
|
||||
}
|
8
llama_stack/ui/lib/truncate-text.ts
Normal file
8
llama_stack/ui/lib/truncate-text.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
export function truncateText(
|
||||
text: string | null | undefined,
|
||||
maxLength: number = 50,
|
||||
): string {
|
||||
if (!text) return "N/A";
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + "...";
|
||||
}
|
44
llama_stack/ui/lib/types.ts
Normal file
44
llama_stack/ui/lib/types.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
export interface TextContentBlock {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ImageUrlDetail {
|
||||
url: string;
|
||||
detail?: "low" | "high" | "auto";
|
||||
}
|
||||
|
||||
export interface ImageUrlContentBlock {
|
||||
type: "image_url";
|
||||
// Support both simple URL string and detailed object, though our parser currently just looks for type: "image_url"
|
||||
image_url: string | ImageUrlDetail;
|
||||
}
|
||||
|
||||
// Union of known content part types. Add more specific types as needed.
|
||||
export type ChatMessageContentPart =
|
||||
| TextContentBlock
|
||||
| ImageUrlContentBlock
|
||||
| { type: string; [key: string]: any }; // Fallback for other potential types
|
||||
|
||||
export interface ChatMessage {
|
||||
role: string;
|
||||
content: string | ChatMessageContentPart[]; // Updated content type
|
||||
name?: string | null;
|
||||
tool_calls?: any | null; // This could also be refined to a more specific ToolCall[] type
|
||||
}
|
||||
|
||||
export interface Choice {
|
||||
message: ChatMessage;
|
||||
finish_reason: string;
|
||||
index: number;
|
||||
logprobs?: any | null;
|
||||
}
|
||||
|
||||
export interface ChatCompletion {
|
||||
id: string;
|
||||
choices: Choice[];
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
input_messages: ChatMessage[];
|
||||
}
|
4655
llama_stack/ui/package-lock.json
generated
4655
llama_stack/ui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -3,12 +3,13 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "next dev --turbopack --port 8322",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write \"./**/*.{ts,tsx}\"",
|
||||
"format:check": "prettier --check \"./**/*.{ts,tsx}\""
|
||||
"format:check": "prettier --check \"./**/*.{ts,tsx}\"",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.13",
|
||||
|
@ -18,6 +19,7 @@
|
|||
"@radix-ui/react-tooltip": "^1.2.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"llama-stack-client": "github:stainless-sdks/llama-stack-node#ehhuang/dev",
|
||||
"lucide-react": "^0.510.0",
|
||||
"next": "15.3.2",
|
||||
"next-themes": "^0.4.6",
|
||||
|
@ -28,6 +30,10 @@
|
|||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
@ -35,8 +41,11 @@
|
|||
"eslint-config-next": "15.3.2",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-prettier": "^5.4.0",
|
||||
"prettier": "^3.5.3",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"prettier": "3.5.3",
|
||||
"tailwindcss": "^4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tw-animate-css": "^1.2.9",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue