forked from phoenix-oss/llama-stack-mirror
feat(ui): add views for Responses (#2293)
# What does this PR do? * Add responses list and detail views * Refactored components to be shared as much as possible between chat completions and responses ## Test Plan <img width="2014" alt="image" src="https://github.com/user-attachments/assets/6dee12ea-8876-4351-a6eb-2338058466ef" /> <img width="2021" alt="image" src="https://github.com/user-attachments/assets/6c7c71b8-25b7-4199-9c57-6960be5580c8" /> added tests
This commit is contained in:
parent
6352078e4b
commit
56e5ddb39f
34 changed files with 3282 additions and 380 deletions
|
@ -75,7 +75,7 @@ describe("ChatCompletionDetailView", () => {
|
|||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("No details found for completion ID: notfound-id."),
|
||||
screen.getByText("No details found for ID: notfound-id."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
|
@ -3,45 +3,14 @@
|
|||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import {
|
||||
DetailLoadingView,
|
||||
DetailErrorView,
|
||||
DetailNotFoundView,
|
||||
DetailLayout,
|
||||
PropertiesCard,
|
||||
PropertyItem,
|
||||
} from "@/components/layout/detail-layout";
|
||||
|
||||
interface ChatCompletionDetailViewProps {
|
||||
completion: ChatCompletion | null;
|
||||
|
@ -56,143 +25,121 @@ export function ChatCompletionDetailView({
|
|||
error,
|
||||
id,
|
||||
}: ChatCompletionDetailViewProps) {
|
||||
const title = "Chat Completion Details";
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
return <DetailErrorView title={title} id={id} error={error} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <ChatCompletionDetailLoadingView />;
|
||||
return <DetailLoadingView title={title} />;
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
return <DetailNotFoundView title={title} id={id} />;
|
||||
}
|
||||
|
||||
// If no error, not loading, and completion exists, render the details:
|
||||
return (
|
||||
// Main content cards
|
||||
const mainContent = (
|
||||
<>
|
||||
<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>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: 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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)
|
||||
: 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'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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
// 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: any, 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} />
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
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
|
||||
import { ChatCompletionsTable } from "./chat-completions-table";
|
||||
import { ChatCompletion } from "@/lib/types";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = jest.fn();
|
||||
|
@ -13,21 +13,25 @@ jest.mock("next/navigation", () => ({
|
|||
}));
|
||||
|
||||
// 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");
|
||||
jest.mock("@/lib/format-message-content");
|
||||
|
||||
// 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";
|
||||
import {
|
||||
extractTextFromContentPart as originalExtractTextFromContentPart,
|
||||
extractDisplayableText as originalExtractDisplayableText,
|
||||
} from "@/lib/format-message-content";
|
||||
|
||||
// Cast to jest.Mock for typings
|
||||
const truncateText = originalTruncateText as jest.Mock;
|
||||
const formatToolCallToString = originalFormatToolCallToString as jest.Mock;
|
||||
const extractTextFromContentPart =
|
||||
originalExtractTextFromContentPart as jest.Mock;
|
||||
const extractDisplayableText = originalExtractDisplayableText as jest.Mock;
|
||||
|
||||
describe("ChatCompletionsTable", () => {
|
||||
const defaultProps = {
|
||||
completions: [] as ChatCompletion[],
|
||||
data: [] as ChatCompletion[],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
@ -36,28 +40,26 @@ describe("ChatCompletionsTable", () => {
|
|||
// Reset all mocks before each test
|
||||
mockPush.mockClear();
|
||||
truncateText.mockClear();
|
||||
formatToolCallToString.mockClear();
|
||||
extractTextFromContentPart.mockClear();
|
||||
extractDisplayableText.mockClear();
|
||||
|
||||
// Default pass-through implementation for tests not focusing on truncation/formatting
|
||||
// Default pass-through implementations
|
||||
truncateText.mockImplementation((text: string | undefined) => text);
|
||||
formatToolCallToString.mockImplementation((toolCall: any) =>
|
||||
toolCall && typeof toolCall === "object" && toolCall.name
|
||||
? `[DefaultToolCall:${toolCall.name}]`
|
||||
: "[InvalidToolCall]",
|
||||
extractTextFromContentPart.mockImplementation((content: unknown) =>
|
||||
typeof content === "string" ? content : "extracted text",
|
||||
);
|
||||
extractDisplayableText.mockImplementation(
|
||||
(message: unknown) =>
|
||||
(message as { content?: string })?.content || "extracted output",
|
||||
);
|
||||
});
|
||||
|
||||
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",
|
||||
|
@ -73,9 +75,12 @@ describe("ChatCompletionsTable", () => {
|
|||
input_messages: [{ role: "user", content: "Test input" }],
|
||||
};
|
||||
|
||||
rerender(
|
||||
<ChatCompletionsTable {...defaultProps} completions={[mockCompletion]} />,
|
||||
);
|
||||
// Set up mocks to return expected values
|
||||
extractTextFromContentPart.mockReturnValue("Test input");
|
||||
extractDisplayableText.mockReturnValue("Test output");
|
||||
|
||||
render(<ChatCompletionsTable {...defaultProps} data={[mockCompletion]} />);
|
||||
|
||||
const row = screen.getByText("Test input").closest("tr");
|
||||
if (row) {
|
||||
fireEvent.click(row);
|
||||
|
@ -91,14 +96,13 @@ describe("ChatCompletionsTable", () => {
|
|||
<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);
|
||||
const captionSkeleton = tableCaption.querySelector(
|
||||
'[data-slot="skeleton"]',
|
||||
);
|
||||
expect(captionSkeleton).toBeInTheDocument();
|
||||
}
|
||||
|
||||
|
@ -107,16 +111,10 @@ describe("ChatCompletionsTable", () => {
|
|||
expect(tableBody).toBeInTheDocument();
|
||||
if (tableBody) {
|
||||
const bodySkeletons = tableBody.querySelectorAll(
|
||||
`td ${skeletonSelector}`,
|
||||
'[data-slot="skeleton"]',
|
||||
);
|
||||
expect(bodySkeletons.length).toBeGreaterThan(0); // Ensure at least one skeleton cell exists
|
||||
expect(bodySkeletons.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// 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
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -140,14 +138,14 @@ describe("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
|
||||
render(<ChatCompletionsTable {...defaultProps} error={{} as Error} />);
|
||||
expect(
|
||||
screen.getByText("Error fetching data: An unknown error occurred"),
|
||||
).toBeInTheDocument();
|
||||
|
@ -155,14 +153,8 @@ describe("ChatCompletionsTable", () => {
|
|||
});
|
||||
|
||||
describe("Empty State", () => {
|
||||
test('renders "No chat completions found." and no table when completions array is empty', () => {
|
||||
render(
|
||||
<ChatCompletionsTable
|
||||
completions={[]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
/>,
|
||||
);
|
||||
test('renders "No chat completions found." and no table when data array is empty', () => {
|
||||
render(<ChatCompletionsTable data={[]} isLoading={false} error={null} />);
|
||||
expect(
|
||||
screen.getByText("No chat completions found."),
|
||||
).toBeInTheDocument();
|
||||
|
@ -179,7 +171,7 @@ describe("ChatCompletionsTable", () => {
|
|||
{
|
||||
id: "comp_1",
|
||||
object: "chat.completion",
|
||||
created: 1710000000, // Fixed timestamp for test
|
||||
created: 1710000000,
|
||||
model: "llama-test-model",
|
||||
choices: [
|
||||
{
|
||||
|
@ -206,9 +198,22 @@ describe("ChatCompletionsTable", () => {
|
|||
},
|
||||
];
|
||||
|
||||
// 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";
|
||||
});
|
||||
|
||||
render(
|
||||
<ChatCompletionsTable
|
||||
completions={mockCompletions}
|
||||
data={mockCompletions}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
/>,
|
||||
|
@ -242,7 +247,7 @@ describe("ChatCompletionsTable", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Text Truncation and Tool Call Formatting", () => {
|
||||
describe("Text Truncation and Content Extraction", () => {
|
||||
test("truncates long input and output text", () => {
|
||||
// Specific mock implementation for this test
|
||||
truncateText.mockImplementation(
|
||||
|
@ -259,6 +264,10 @@ describe("ChatCompletionsTable", () => {
|
|||
"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 = [
|
||||
{
|
||||
id: "comp_trunc",
|
||||
|
@ -278,7 +287,7 @@ describe("ChatCompletionsTable", () => {
|
|||
|
||||
render(
|
||||
<ChatCompletionsTable
|
||||
completions={mockCompletions}
|
||||
data={mockCompletions}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
/>,
|
||||
|
@ -289,52 +298,50 @@ describe("ChatCompletionsTable", () => {
|
|||
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);
|
||||
test("uses content extraction functions correctly", () => {
|
||||
const mockCompletion = {
|
||||
id: "comp_extract",
|
||||
object: "chat.completion",
|
||||
created: 1710003000,
|
||||
model: "llama-extract-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "Extracted output" },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
input_messages: [{ role: "user", content: "Extracted input" }],
|
||||
};
|
||||
|
||||
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" }],
|
||||
},
|
||||
];
|
||||
extractTextFromContentPart.mockReturnValue("Extracted input");
|
||||
extractDisplayableText.mockReturnValue("Extracted output");
|
||||
|
||||
render(
|
||||
<ChatCompletionsTable
|
||||
completions={mockCompletions}
|
||||
data={[mockCompletion]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The component concatenates message.content and the formatted tool call
|
||||
expect(screen.getByText("Tool output [TOOL:search]")).toBeInTheDocument();
|
||||
// Verify the extraction functions were called
|
||||
expect(extractTextFromContentPart).toHaveBeenCalledWith(
|
||||
"Extracted input",
|
||||
);
|
||||
expect(extractDisplayableText).toHaveBeenCalledWith({
|
||||
role: "assistant",
|
||||
content: "Extracted output",
|
||||
});
|
||||
|
||||
// Verify the extracted content is displayed
|
||||
expect(screen.getByText("Extracted input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Extracted output")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,120 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChatCompletion } from "@/lib/types";
|
||||
import { truncateText } from "@/lib/truncate-text";
|
||||
import {
|
||||
extractTextFromContentPart,
|
||||
extractDisplayableText,
|
||||
} from "@/lib/format-message-content";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface ChatCompletionsTableProps {
|
||||
completions: ChatCompletion[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function ChatCompletionsTable({
|
||||
completions,
|
||||
isLoading,
|
||||
error,
|
||||
}: ChatCompletionsTableProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const tableHeader = (
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Input</TableHead>
|
||||
<TableHead>Output</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead className="text-right">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Table>
|
||||
<TableCaption>
|
||||
<Skeleton className="h-4 w-[250px] mx-auto" />
|
||||
</TableCaption>
|
||||
{tableHeader}
|
||||
<TableBody>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<TableRow key={`skeleton-${i}`}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Skeleton className="h-4 w-1/2 ml-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p>Error fetching data: {error.message || "An unknown error occurred"}</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (completions.length === 0) {
|
||||
return <p>No chat completions found.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableCaption>A list of your recent chat completions.</TableCaption>
|
||||
{tableHeader}
|
||||
<TableBody>
|
||||
{completions.map((completion) => (
|
||||
<TableRow
|
||||
key={completion.id}
|
||||
onClick={() =>
|
||||
router.push(`/logs/chat-completions/${completion.id}`)
|
||||
}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<TableCell>
|
||||
{truncateText(
|
||||
extractTextFromContentPart(
|
||||
completion.input_messages?.[0]?.content,
|
||||
),
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
const message = completion.choices?.[0]?.message;
|
||||
const outputText = extractDisplayableText(message);
|
||||
return truncateText(outputText);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>{completion.model}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{new Date(completion.created * 1000).toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { ChatCompletion } from "@/lib/types";
|
||||
import { LogsTable, LogTableRow } from "@/components/logs/logs-table";
|
||||
import {
|
||||
extractTextFromContentPart,
|
||||
extractDisplayableText,
|
||||
} from "@/lib/format-message-content";
|
||||
|
||||
interface ChatCompletionsTableProps {
|
||||
data: ChatCompletion[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
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({
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
}: ChatCompletionsTableProps) {
|
||||
const formattedData = data.map(formatChatCompletionToRow);
|
||||
|
||||
return (
|
||||
<LogsTable
|
||||
data={formattedData}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
caption="A list of your recent chat completions."
|
||||
emptyMessage="No chat completions found."
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -4,45 +4,10 @@ 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>
|
||||
);
|
||||
};
|
||||
import {
|
||||
MessageBlock,
|
||||
ToolCallBlock,
|
||||
} from "@/components/ui/message-components";
|
||||
|
||||
interface ChatMessageItemProps {
|
||||
message: ChatMessage;
|
||||
|
@ -65,7 +30,11 @@ export function ChatMessageItem({ message }: ChatMessageItemProps) {
|
|||
);
|
||||
|
||||
case "assistant":
|
||||
if (message.tool_calls && message.tool_calls.length > 0) {
|
||||
if (
|
||||
message.tool_calls &&
|
||||
Array.isArray(message.tool_calls) &&
|
||||
message.tool_calls.length > 0
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{message.tool_calls.map((toolCall: any, index: number) => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue