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