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

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


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

View file

@ -0,0 +1,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 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();
});
});

View file

@ -0,0 +1,150 @@
"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 {
DetailLoadingView,
DetailErrorView,
DetailNotFoundView,
DetailLayout,
PropertiesCard,
PropertyItem,
} from "@/components/layout/detail-layout";
interface ChatCompletionDetailViewProps {
completion: ChatCompletion | null;
isLoading: boolean;
error: Error | null;
id: string;
}
export function ChatCompletionDetailView({
completion,
isLoading,
error,
id,
}: ChatCompletionDetailViewProps) {
const title = "Chat Completion Details";
if (error) {
return <DetailErrorView title={title} id={id} error={error} />;
}
if (isLoading) {
return <DetailLoadingView title={title} />;
}
if (!completion) {
return <DetailNotFoundView title={title} id={id} />;
}
// Main content cards
const mainContent = (
<>
<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 &&
Array.isArray(completion.choices[0].message.tool_calls) &&
!completion.input_messages?.some(
im =>
im.role === "assistant" &&
im.tool_calls &&
Array.isArray(im.tool_calls) &&
im.tool_calls.length > 0
)
? completion.choices[0].message.tool_calls.map(
(toolCall: { function?: { name?: string } }, 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}
/>
);
}
)
: null}
</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&apos;s choice.
</p>
)}
</CardContent>
</Card>
</>
);
// Properties sidebar
const sidebar = (
<PropertiesCard>
<PropertyItem
label="Created"
value={new Date(completion.created * 1000).toLocaleString()}
/>
<PropertyItem label="ID" value={completion.id} />
<PropertyItem label="Model" value={completion.model} />
<PropertyItem
label="Finish Reason"
value={completion.choices?.[0]?.finish_reason || "N/A"}
hasBorder
/>
{(() => {
const toolCalls = completion.choices?.[0]?.message?.tool_calls;
if (toolCalls && Array.isArray(toolCalls) && toolCalls.length > 0) {
return (
<PropertyItem
label="Functions/Tools Called"
value={
<div>
<ul className="list-disc list-inside pl-4 mt-1">
{toolCalls.map(
(
toolCall: { function?: { name?: string } },
index: number
) => (
<li key={index}>
<span className="text-gray-900 font-medium">
{toolCall.function?.name || "N/A"}
</span>
</li>
)
)}
</ul>
</div>
}
hasBorder
/>
);
}
return null;
})()}
</PropertiesCard>
);
return (
<DetailLayout title={title} mainContent={mainContent} sidebar={sidebar} />
);
}

View file

@ -0,0 +1,427 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import { ChatCompletionsTable } from "./chat-completions-table";
import { ChatCompletion } from "@/lib/types";
// Mock next/navigation
const mockPush = jest.fn();
jest.mock("next/navigation", () => ({
useRouter: () => ({
push: mockPush,
}),
}));
// Mock next-auth
jest.mock("next-auth/react", () => ({
useSession: () => ({
status: "authenticated",
data: { accessToken: "mock-token" },
}),
}));
// Mock helper functions
jest.mock("@/lib/truncate-text");
jest.mock("@/lib/format-message-content");
// Mock the auth client hook
const mockClient = {
chat: {
completions: {
list: jest.fn(),
},
},
};
jest.mock("@/hooks/use-auth-client", () => ({
useAuthClient: () => mockClient,
}));
// Mock the usePagination hook
const mockLoadMore = jest.fn();
jest.mock("@/hooks/use-pagination", () => ({
usePagination: jest.fn(() => ({
data: [],
status: "idle",
hasMore: false,
error: null,
loadMore: mockLoadMore,
})),
}));
// Import the mocked functions to set up default or specific implementations
import { truncateText as originalTruncateText } from "@/lib/truncate-text";
import {
extractTextFromContentPart as originalExtractTextFromContentPart,
extractDisplayableText as originalExtractDisplayableText,
} from "@/lib/format-message-content";
// Import the mocked hook
import { usePagination } from "@/hooks/use-pagination";
const mockedUsePagination = usePagination as jest.MockedFunction<
typeof usePagination
>;
// Cast to jest.Mock for typings
const truncateText = originalTruncateText as jest.Mock;
const extractTextFromContentPart =
originalExtractTextFromContentPart as jest.Mock;
const extractDisplayableText = originalExtractDisplayableText as jest.Mock;
describe("ChatCompletionsTable", () => {
const defaultProps = {};
beforeEach(() => {
// Reset all mocks before each test
mockPush.mockClear();
truncateText.mockClear();
extractTextFromContentPart.mockClear();
extractDisplayableText.mockClear();
mockLoadMore.mockClear();
jest.clearAllMocks();
// Default pass-through implementations
truncateText.mockImplementation((text: string | undefined) => text);
extractTextFromContentPart.mockImplementation((content: unknown) =>
typeof content === "string" ? content : "extracted text"
);
extractDisplayableText.mockImplementation((message: unknown) => {
const msg = message as { content?: string };
return msg?.content || "extracted output";
});
// Default hook return value
mockedUsePagination.mockReturnValue({
data: [],
status: "idle",
hasMore: false,
error: null,
loadMore: mockLoadMore,
});
});
test("renders without crashing with default props", () => {
render(<ChatCompletionsTable {...defaultProps} />);
expect(screen.getByText("No chat completions found.")).toBeInTheDocument();
});
test("click on a row navigates to the correct URL", () => {
const mockData: ChatCompletion[] = [
{
id: "completion_123",
choices: [
{
message: { role: "assistant", content: "Test response" },
finish_reason: "stop",
index: 0,
},
],
object: "chat.completion",
created: 1234567890,
model: "test-model",
input_messages: [{ role: "user", content: "Test prompt" }],
},
];
// Configure the mock to return our test data
mockedUsePagination.mockReturnValue({
data: mockData,
status: "idle",
hasMore: false,
error: null,
loadMore: mockLoadMore,
});
render(<ChatCompletionsTable {...defaultProps} />);
const row = screen.getByText("Test prompt").closest("tr");
if (row) {
fireEvent.click(row);
expect(mockPush).toHaveBeenCalledWith(
"/logs/chat-completions/completion_123"
);
} else {
throw new Error('Row with "Test prompt" not found for router mock test.');
}
});
describe("Loading State", () => {
test("renders skeleton UI when isLoading is true", () => {
mockedUsePagination.mockReturnValue({
data: [],
status: "loading",
hasMore: false,
error: null,
loadMore: mockLoadMore,
});
const { container } = render(<ChatCompletionsTable {...defaultProps} />);
// Check for skeleton in the table caption
const tableCaption = container.querySelector("caption");
expect(tableCaption).toBeInTheDocument();
if (tableCaption) {
const captionSkeleton = tableCaption.querySelector(
'[data-slot="skeleton"]'
);
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(
'[data-slot="skeleton"]'
);
expect(bodySkeletons.length).toBeGreaterThan(0);
}
});
});
describe("Error State", () => {
test("renders error message when error prop is provided", () => {
const errorMessage = "Network Error";
mockedUsePagination.mockReturnValue({
data: [],
status: "error",
hasMore: false,
error: { name: "Error", message: errorMessage } as Error,
loadMore: mockLoadMore,
});
render(<ChatCompletionsTable {...defaultProps} />);
expect(
screen.getByText("Unable to load chat completions")
).toBeInTheDocument();
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
test.each([{ name: "Error", message: "" }, {}])(
"renders default error message when error has no message",
errorObject => {
mockedUsePagination.mockReturnValue({
data: [],
status: "error",
hasMore: false,
error: errorObject as Error,
loadMore: mockLoadMore,
});
render(<ChatCompletionsTable {...defaultProps} />);
expect(
screen.getByText("Unable to load chat completions")
).toBeInTheDocument();
expect(
screen.getByText(
"An unexpected error occurred while loading the data."
)
).toBeInTheDocument();
}
);
});
describe("Empty State", () => {
test('renders "No chat completions found." and no table when data array is empty', () => {
render(<ChatCompletionsTable {...defaultProps} />);
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: ChatCompletion[] = [
{
id: "comp_1",
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" }],
},
{
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" }],
},
];
// Set up mocks to return expected values
extractTextFromContentPart.mockImplementation((content: unknown) => {
if (content === "Test input") return "Test input";
if (content === "Another input") return "Another input";
return "extracted text";
});
extractDisplayableText.mockImplementation((message: unknown) => {
const msg = message as { content?: string };
if (msg?.content === "Test output") return "Test output";
if (msg?.content === "Another output") return "Another output";
return "extracted output";
});
mockedUsePagination.mockReturnValue({
data: mockCompletions,
status: "idle",
hasMore: false,
error: null,
loadMore: mockLoadMore,
});
render(<ChatCompletionsTable {...defaultProps} />);
// 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 Content Extraction", () => {
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.";
extractTextFromContentPart.mockReturnValue(longInput);
extractDisplayableText.mockReturnValue(longOutput);
const mockCompletions: ChatCompletion[] = [
{
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 }],
},
];
mockedUsePagination.mockReturnValue({
data: mockCompletions,
status: "idle",
hasMore: false,
error: null,
loadMore: mockLoadMore,
});
render(<ChatCompletionsTable {...defaultProps} />);
// 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
});
test("uses content extraction functions correctly", () => {
const complexMessage = [
{ type: "text", text: "Extracted input" },
{ type: "image", url: "http://example.com/image.png" },
];
const assistantMessage = {
role: "assistant",
content: "Extracted output from assistant",
};
const mockCompletions: ChatCompletion[] = [
{
id: "comp_extract",
object: "chat.completion",
created: 1710003000,
model: "llama-extract-model",
choices: [
{
index: 0,
message: assistantMessage,
finish_reason: "stop",
},
],
input_messages: [{ role: "user", content: complexMessage }],
},
];
extractTextFromContentPart.mockReturnValue("Extracted input");
extractDisplayableText.mockReturnValue("Extracted output from assistant");
mockedUsePagination.mockReturnValue({
data: mockCompletions,
status: "idle",
hasMore: false,
error: null,
loadMore: mockLoadMore,
});
render(<ChatCompletionsTable {...defaultProps} />);
// Verify the extraction functions were called
expect(extractTextFromContentPart).toHaveBeenCalledWith(complexMessage);
expect(extractDisplayableText).toHaveBeenCalledWith(assistantMessage);
// Verify the extracted text appears in the table
expect(screen.getByText("Extracted input")).toBeInTheDocument();
expect(
screen.getByText("Extracted output from assistant")
).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,73 @@
"use client";
import {
ChatCompletion,
UsePaginationOptions,
ListChatCompletionsResponse,
} from "@/lib/types";
import { ListChatCompletionsParams } from "@/lib/llama-stack-client";
import { LogsTable, LogTableRow } from "@/components/logs/logs-table";
import {
extractTextFromContentPart,
extractDisplayableText,
} from "@/lib/format-message-content";
import { usePagination } from "@/hooks/use-pagination";
interface ChatCompletionsTableProps {
/** Optional pagination configuration */
paginationOptions?: UsePaginationOptions;
}
function formatChatCompletionToRow(completion: ChatCompletion): LogTableRow {
return {
id: completion.id,
input: extractTextFromContentPart(completion.input_messages?.[0]?.content),
output: extractDisplayableText(completion.choices?.[0]?.message),
model: completion.model,
createdTime: new Date(completion.created * 1000).toLocaleString(),
detailPath: `/logs/chat-completions/${completion.id}`,
};
}
export function ChatCompletionsTable({
paginationOptions,
}: ChatCompletionsTableProps) {
const fetchFunction = async (
client: ReturnType<typeof import("@/hooks/use-auth-client").useAuthClient>,
params: {
after?: string;
limit: number;
model?: string;
order?: string;
}
) => {
const response = await client.chat.completions.list({
after: params.after,
limit: params.limit,
...(params.model && { model: params.model }),
...(params.order && { order: params.order }),
} as ListChatCompletionsParams);
return response as ListChatCompletionsResponse;
};
const { data, status, hasMore, error, loadMore } = usePagination({
...paginationOptions,
fetchFunction,
errorMessagePrefix: "chat completions",
});
const formattedData = data.map(formatChatCompletionToRow);
return (
<LogsTable
data={formattedData}
status={status}
hasMore={hasMore}
error={error}
onLoadMore={loadMore}
caption="A list of your recent chat completions."
emptyMessage="No chat completions found."
/>
);
}

View file

@ -0,0 +1,81 @@
"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";
import {
MessageBlock,
ToolCallBlock,
} from "@/components/chat-playground/message-components";
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 &&
Array.isArray(message.tool_calls) &&
message.tool_calls.length > 0
) {
return (
<>
{message.tool_calls.map(
(
toolCall: { function?: { name?: string; arguments?: unknown } },
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;
}