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:
ehhuang 2025-05-22 22:05:54 -07:00 committed by GitHub
parent d8c6ab9bfc
commit 2708312168
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 6729 additions and 38 deletions

View file

@ -0,0 +1,3 @@
# Ignore artifacts:
build
coverage

View file

@ -0,0 +1 @@
{}

View file

@ -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.

View file

@ -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 (

View 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}
/>
);
}

View 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>
);
}

View file

@ -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}
/>
);
}

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 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();
});
});

View file

@ -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>
</>
);
}

View file

@ -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();
});
});
});

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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>

View 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>
);
}

View 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,
};

View 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,
};

View 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,
};

View 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);

View 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.",
);
});
});

View 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.
}
}

View 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})`;
}

View 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) + "...";
}

View 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[];
}

File diff suppressed because it is too large Load diff

View file

@ -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"
}