mirror of
https://github.com/meta-llama/llama-stack.git
synced 2025-06-27 18:50:41 +00:00
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
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import LlamaStackClient from "llama-stack-client";
|
|
||||||
import { ChatCompletion } from "@/lib/types";
|
import { ChatCompletion } from "@/lib/types";
|
||||||
import { ChatCompletionDetailView } from "@/components/chat-completions/chat-completion-detail";
|
import { ChatCompletionDetailView } from "@/components/chat-completions/chat-completion-detail";
|
||||||
|
import { client } from "@/lib/client";
|
||||||
|
|
||||||
export default function ChatCompletionDetailPage() {
|
export default function ChatCompletionDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
@ -22,10 +22,6 @@ export default function ChatCompletionDetailPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new LlamaStackClient({
|
|
||||||
baseURL: process.env.NEXT_PUBLIC_LLAMA_STACK_BASE_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchCompletionDetail = async () => {
|
const fetchCompletionDetail = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
|
@ -1,45 +1,19 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { usePathname, useParams } from "next/navigation";
|
import LogsLayout from "@/components/layout/logs-layout";
|
||||||
import {
|
|
||||||
PageBreadcrumb,
|
|
||||||
BreadcrumbSegment,
|
|
||||||
} from "@/components/layout/page-breadcrumb";
|
|
||||||
import { truncateText } from "@/lib/truncate-text";
|
|
||||||
|
|
||||||
export default function ChatCompletionsLayout({
|
export default function ChatCompletionsLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
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 (
|
return (
|
||||||
<div className="container mx-auto p-4">
|
<LogsLayout
|
||||||
<>
|
sectionLabel="Chat Completions"
|
||||||
{segments.length > 0 && (
|
basePath="/logs/chat-completions"
|
||||||
<PageBreadcrumb segments={segments} className="mb-4" />
|
>
|
||||||
)}
|
{children}
|
||||||
{children}
|
</LogsLayout>
|
||||||
</>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import LlamaStackClient from "llama-stack-client";
|
|
||||||
import { ChatCompletion } from "@/lib/types";
|
import { ChatCompletion } from "@/lib/types";
|
||||||
import { ChatCompletionsTable } from "@/components/chat-completions/chat-completion-table";
|
import { ChatCompletionsTable } from "@/components/chat-completions/chat-completions-table";
|
||||||
|
import { client } from "@/lib/client";
|
||||||
|
|
||||||
export default function ChatCompletionsPage() {
|
export default function ChatCompletionsPage() {
|
||||||
const [completions, setCompletions] = useState<ChatCompletion[]>([]);
|
const [completions, setCompletions] = useState<ChatCompletion[]>([]);
|
||||||
|
@ -11,9 +11,6 @@ export default function ChatCompletionsPage() {
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const client = new LlamaStackClient({
|
|
||||||
baseURL: process.env.NEXT_PUBLIC_LLAMA_STACK_BASE_URL,
|
|
||||||
});
|
|
||||||
const fetchCompletions = async () => {
|
const fetchCompletions = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
@ -21,7 +18,7 @@ export default function ChatCompletionsPage() {
|
||||||
const response = await client.chat.completions.list();
|
const response = await client.chat.completions.list();
|
||||||
const data = Array.isArray(response)
|
const data = Array.isArray(response)
|
||||||
? response
|
? response
|
||||||
: (response as any).data;
|
: (response as { data: ChatCompletion[] }).data;
|
||||||
|
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
setCompletions(data);
|
setCompletions(data);
|
||||||
|
@ -46,7 +43,7 @@ export default function ChatCompletionsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatCompletionsTable
|
<ChatCompletionsTable
|
||||||
completions={completions}
|
data={completions}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
|
|
125
llama_stack/ui/app/logs/responses/[id]/page.tsx
Normal file
125
llama_stack/ui/app/logs/responses/[id]/page.tsx
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import type { ResponseObject } from "llama-stack-client/resources/responses/responses";
|
||||||
|
import { OpenAIResponse, InputItemListResponse } from "@/lib/types";
|
||||||
|
import { ResponseDetailView } from "@/components/responses/responses-detail";
|
||||||
|
import { client } from "@/lib/client";
|
||||||
|
|
||||||
|
export default function ResponseDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const id = params.id as string;
|
||||||
|
|
||||||
|
const [responseDetail, setResponseDetail] = useState<OpenAIResponse | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [inputItems, setInputItems] = useState<InputItemListResponse | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [isLoadingInputItems, setIsLoadingInputItems] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const [inputItemsError, setInputItemsError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
// Helper function to convert ResponseObject to OpenAIResponse
|
||||||
|
const convertResponseObject = (
|
||||||
|
responseData: ResponseObject,
|
||||||
|
): OpenAIResponse => {
|
||||||
|
return {
|
||||||
|
id: responseData.id,
|
||||||
|
created_at: responseData.created_at,
|
||||||
|
model: responseData.model,
|
||||||
|
object: responseData.object,
|
||||||
|
status: responseData.status,
|
||||||
|
output: responseData.output as OpenAIResponse["output"],
|
||||||
|
input: [], // ResponseObject doesn't include input; component uses inputItems prop instead
|
||||||
|
error: responseData.error,
|
||||||
|
parallel_tool_calls: responseData.parallel_tool_calls,
|
||||||
|
previous_response_id: responseData.previous_response_id,
|
||||||
|
temperature: responseData.temperature,
|
||||||
|
top_p: responseData.top_p,
|
||||||
|
truncation: responseData.truncation,
|
||||||
|
user: responseData.user,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) {
|
||||||
|
setError(new Error("Response ID is missing."));
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchResponseDetail = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setIsLoadingInputItems(true);
|
||||||
|
setError(null);
|
||||||
|
setInputItemsError(null);
|
||||||
|
setResponseDetail(null);
|
||||||
|
setInputItems(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [responseResult, inputItemsResult] = await Promise.allSettled([
|
||||||
|
client.responses.retrieve(id),
|
||||||
|
client.responses.inputItems.list(id, { order: "asc" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Handle response detail result
|
||||||
|
if (responseResult.status === "fulfilled") {
|
||||||
|
const convertedResponse = convertResponseObject(responseResult.value);
|
||||||
|
setResponseDetail(convertedResponse);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`Error fetching response detail for ID ${id}:`,
|
||||||
|
responseResult.reason,
|
||||||
|
);
|
||||||
|
setError(
|
||||||
|
responseResult.reason instanceof Error
|
||||||
|
? responseResult.reason
|
||||||
|
: new Error("Failed to fetch response detail"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle input items result
|
||||||
|
if (inputItemsResult.status === "fulfilled") {
|
||||||
|
const inputItemsData =
|
||||||
|
inputItemsResult.value as unknown as InputItemListResponse;
|
||||||
|
setInputItems(inputItemsData);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`Error fetching input items for response ID ${id}:`,
|
||||||
|
inputItemsResult.reason,
|
||||||
|
);
|
||||||
|
setInputItemsError(
|
||||||
|
inputItemsResult.reason instanceof Error
|
||||||
|
? inputItemsResult.reason
|
||||||
|
: new Error("Failed to fetch input items"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Unexpected error fetching data for ID ${id}:`, err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err : new Error("Unexpected error occurred"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setIsLoadingInputItems(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchResponseDetail();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponseDetailView
|
||||||
|
response={responseDetail}
|
||||||
|
inputItems={inputItems}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isLoadingInputItems={isLoadingInputItems}
|
||||||
|
error={error}
|
||||||
|
inputItemsError={inputItemsError}
|
||||||
|
id={id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
16
llama_stack/ui/app/logs/responses/layout.tsx
Normal file
16
llama_stack/ui/app/logs/responses/layout.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import LogsLayout from "@/components/layout/logs-layout";
|
||||||
|
|
||||||
|
export default function ResponsesLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<LogsLayout sectionLabel="Responses" basePath="/logs/responses">
|
||||||
|
{children}
|
||||||
|
</LogsLayout>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,7 +1,66 @@
|
||||||
export default function Responses() {
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ResponseListResponse } from "llama-stack-client/resources/responses/responses";
|
||||||
|
import { OpenAIResponse } from "@/lib/types";
|
||||||
|
import { ResponsesTable } from "@/components/responses/responses-table";
|
||||||
|
import { client } from "@/lib/client";
|
||||||
|
|
||||||
|
export default function ResponsesPage() {
|
||||||
|
const [responses, setResponses] = useState<OpenAIResponse[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
// Helper function to convert ResponseListResponse.Data to OpenAIResponse
|
||||||
|
const convertResponseListData = (
|
||||||
|
responseData: ResponseListResponse.Data,
|
||||||
|
): OpenAIResponse => {
|
||||||
|
return {
|
||||||
|
id: responseData.id,
|
||||||
|
created_at: responseData.created_at,
|
||||||
|
model: responseData.model,
|
||||||
|
object: responseData.object,
|
||||||
|
status: responseData.status,
|
||||||
|
output: responseData.output as OpenAIResponse["output"],
|
||||||
|
input: responseData.input as OpenAIResponse["input"],
|
||||||
|
error: responseData.error,
|
||||||
|
parallel_tool_calls: responseData.parallel_tool_calls,
|
||||||
|
previous_response_id: responseData.previous_response_id,
|
||||||
|
temperature: responseData.temperature,
|
||||||
|
top_p: responseData.top_p,
|
||||||
|
truncation: responseData.truncation,
|
||||||
|
user: responseData.user,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchResponses = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await client.responses.list();
|
||||||
|
const responseListData = response as ResponseListResponse;
|
||||||
|
|
||||||
|
const convertedResponses: OpenAIResponse[] = responseListData.data.map(
|
||||||
|
convertResponseListData,
|
||||||
|
);
|
||||||
|
|
||||||
|
setResponses(convertedResponses);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching responses:", err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err : new Error("Failed to fetch responses"),
|
||||||
|
);
|
||||||
|
setResponses([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchResponses();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<ResponsesTable data={responses} isLoading={isLoading} error={error} />
|
||||||
<h1>Under Construction</h1>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -75,7 +75,7 @@ describe("ChatCompletionDetailView", () => {
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("No details found for completion ID: notfound-id."),
|
screen.getByText("No details found for ID: notfound-id."),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -3,45 +3,14 @@
|
||||||
import { ChatMessage, ChatCompletion } from "@/lib/types";
|
import { ChatMessage, ChatCompletion } from "@/lib/types";
|
||||||
import { ChatMessageItem } from "@/components/chat-completions/chat-messasge-item";
|
import { ChatMessageItem } from "@/components/chat-completions/chat-messasge-item";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import {
|
||||||
|
DetailLoadingView,
|
||||||
function ChatCompletionDetailLoadingView() {
|
DetailErrorView,
|
||||||
return (
|
DetailNotFoundView,
|
||||||
<>
|
DetailLayout,
|
||||||
<Skeleton className="h-8 w-3/4 mb-6" /> {/* Title Skeleton */}
|
PropertiesCard,
|
||||||
<div className="flex flex-col md:flex-row gap-6">
|
PropertyItem,
|
||||||
<div className="flex-grow md:w-2/3 space-y-6">
|
} from "@/components/layout/detail-layout";
|
||||||
{[...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 {
|
interface ChatCompletionDetailViewProps {
|
||||||
completion: ChatCompletion | null;
|
completion: ChatCompletion | null;
|
||||||
|
@ -56,143 +25,121 @@ export function ChatCompletionDetailView({
|
||||||
error,
|
error,
|
||||||
id,
|
id,
|
||||||
}: ChatCompletionDetailViewProps) {
|
}: ChatCompletionDetailViewProps) {
|
||||||
|
const title = "Chat Completion Details";
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <DetailErrorView title={title} id={id} error={error} />;
|
||||||
<>
|
|
||||||
{/* 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) {
|
if (isLoading) {
|
||||||
return <ChatCompletionDetailLoadingView />;
|
return <DetailLoadingView title={title} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!completion) {
|
if (!completion) {
|
||||||
// This state means: not loading, no error, but no completion data
|
return <DetailNotFoundView title={title} id={id} />;
|
||||||
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:
|
// Main content cards
|
||||||
return (
|
const mainContent = (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-2xl font-bold mb-6">Chat Completion Details</h1>
|
<Card>
|
||||||
<div className="flex flex-col md:flex-row gap-6">
|
<CardHeader>
|
||||||
<div className="flex-grow md:w-2/3 space-y-6">
|
<CardTitle>Input</CardTitle>
|
||||||
<Card>
|
</CardHeader>
|
||||||
<CardHeader>
|
<CardContent>
|
||||||
<CardTitle>Input</CardTitle>
|
{completion.input_messages?.map((msg, index) => (
|
||||||
</CardHeader>
|
<ChatMessageItem key={`input-msg-${index}`} message={msg} />
|
||||||
<CardContent>
|
))}
|
||||||
{completion.input_messages?.map((msg, index) => (
|
{completion.choices?.[0]?.message?.tool_calls &&
|
||||||
<ChatMessageItem key={`input-msg-${index}`} message={msg} />
|
Array.isArray(completion.choices[0].message.tool_calls) &&
|
||||||
))}
|
!completion.input_messages?.some(
|
||||||
{completion.choices?.[0]?.message?.tool_calls &&
|
(im) =>
|
||||||
!completion.input_messages?.some(
|
im.role === "assistant" &&
|
||||||
(im) =>
|
im.tool_calls &&
|
||||||
im.role === "assistant" &&
|
Array.isArray(im.tool_calls) &&
|
||||||
im.tool_calls &&
|
im.tool_calls.length > 0,
|
||||||
im.tool_calls.length > 0,
|
)
|
||||||
) &&
|
? completion.choices[0].message.tool_calls.map(
|
||||||
completion.choices[0].message.tool_calls.map(
|
(toolCall: any, index: number) => {
|
||||||
(toolCall: any, index: number) => {
|
const assistantToolCallMessage: ChatMessage = {
|
||||||
const assistantToolCallMessage: ChatMessage = {
|
role: "assistant",
|
||||||
role: "assistant",
|
tool_calls: [toolCall],
|
||||||
tool_calls: [toolCall],
|
content: "", // Ensure content is defined, even if empty
|
||||||
content: "", // Ensure content is defined, even if empty
|
};
|
||||||
};
|
return (
|
||||||
return (
|
<ChatMessageItem
|
||||||
<ChatMessageItem
|
key={`choice-tool-call-${index}`}
|
||||||
key={`choice-tool-call-${index}`}
|
message={assistantToolCallMessage}
|
||||||
message={assistantToolCallMessage}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
},
|
||||||
},
|
)
|
||||||
)}
|
: null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Output</CardTitle>
|
<CardTitle>Output</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{completion.choices?.[0]?.message ? (
|
{completion.choices?.[0]?.message ? (
|
||||||
<ChatMessageItem
|
<ChatMessageItem
|
||||||
message={completion.choices[0].message as ChatMessage}
|
message={completion.choices[0].message as ChatMessage}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-500 italic text-sm">
|
<p className="text-gray-500 italic text-sm">
|
||||||
No message found in assistant's choice.
|
No message found in assistant's choice.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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 React from "react";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { ChatCompletionsTable } from "./chat-completion-table";
|
import { ChatCompletionsTable } from "./chat-completions-table";
|
||||||
import { ChatCompletion } from "@/lib/types"; // Assuming this path is correct
|
import { ChatCompletion } from "@/lib/types";
|
||||||
|
|
||||||
// Mock next/navigation
|
// Mock next/navigation
|
||||||
const mockPush = jest.fn();
|
const mockPush = jest.fn();
|
||||||
|
@ -13,21 +13,25 @@ jest.mock("next/navigation", () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock helper functions
|
// Mock helper functions
|
||||||
// These are hoisted, so their mocks are available throughout the file
|
|
||||||
jest.mock("@/lib/truncate-text");
|
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 the mocked functions to set up default or specific implementations
|
||||||
import { truncateText as originalTruncateText } from "@/lib/truncate-text";
|
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
|
// Cast to jest.Mock for typings
|
||||||
const truncateText = originalTruncateText as jest.Mock;
|
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", () => {
|
describe("ChatCompletionsTable", () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
completions: [] as ChatCompletion[],
|
data: [] as ChatCompletion[],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
@ -36,28 +40,26 @@ describe("ChatCompletionsTable", () => {
|
||||||
// Reset all mocks before each test
|
// Reset all mocks before each test
|
||||||
mockPush.mockClear();
|
mockPush.mockClear();
|
||||||
truncateText.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);
|
truncateText.mockImplementation((text: string | undefined) => text);
|
||||||
formatToolCallToString.mockImplementation((toolCall: any) =>
|
extractTextFromContentPart.mockImplementation((content: unknown) =>
|
||||||
toolCall && typeof toolCall === "object" && toolCall.name
|
typeof content === "string" ? content : "extracted text",
|
||||||
? `[DefaultToolCall:${toolCall.name}]`
|
);
|
||||||
: "[InvalidToolCall]",
|
extractDisplayableText.mockImplementation(
|
||||||
|
(message: unknown) =>
|
||||||
|
(message as { content?: string })?.content || "extracted output",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders without crashing with default props", () => {
|
test("renders without crashing with default props", () => {
|
||||||
render(<ChatCompletionsTable {...defaultProps} />);
|
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();
|
expect(screen.getByText("No chat completions found.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("click on a row navigates to the correct URL", () => {
|
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 = {
|
const mockCompletion: ChatCompletion = {
|
||||||
id: "comp_123",
|
id: "comp_123",
|
||||||
object: "chat.completion",
|
object: "chat.completion",
|
||||||
|
@ -73,9 +75,12 @@ describe("ChatCompletionsTable", () => {
|
||||||
input_messages: [{ role: "user", content: "Test input" }],
|
input_messages: [{ role: "user", content: "Test input" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
rerender(
|
// Set up mocks to return expected values
|
||||||
<ChatCompletionsTable {...defaultProps} completions={[mockCompletion]} />,
|
extractTextFromContentPart.mockReturnValue("Test input");
|
||||||
);
|
extractDisplayableText.mockReturnValue("Test output");
|
||||||
|
|
||||||
|
render(<ChatCompletionsTable {...defaultProps} data={[mockCompletion]} />);
|
||||||
|
|
||||||
const row = screen.getByText("Test input").closest("tr");
|
const row = screen.getByText("Test input").closest("tr");
|
||||||
if (row) {
|
if (row) {
|
||||||
fireEvent.click(row);
|
fireEvent.click(row);
|
||||||
|
@ -91,14 +96,13 @@ describe("ChatCompletionsTable", () => {
|
||||||
<ChatCompletionsTable {...defaultProps} isLoading={true} />,
|
<ChatCompletionsTable {...defaultProps} isLoading={true} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
// The Skeleton component uses data-slot="skeleton"
|
|
||||||
const skeletonSelector = '[data-slot="skeleton"]';
|
|
||||||
|
|
||||||
// Check for skeleton in the table caption
|
// Check for skeleton in the table caption
|
||||||
const tableCaption = container.querySelector("caption");
|
const tableCaption = container.querySelector("caption");
|
||||||
expect(tableCaption).toBeInTheDocument();
|
expect(tableCaption).toBeInTheDocument();
|
||||||
if (tableCaption) {
|
if (tableCaption) {
|
||||||
const captionSkeleton = tableCaption.querySelector(skeletonSelector);
|
const captionSkeleton = tableCaption.querySelector(
|
||||||
|
'[data-slot="skeleton"]',
|
||||||
|
);
|
||||||
expect(captionSkeleton).toBeInTheDocument();
|
expect(captionSkeleton).toBeInTheDocument();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -107,16 +111,10 @@ describe("ChatCompletionsTable", () => {
|
||||||
expect(tableBody).toBeInTheDocument();
|
expect(tableBody).toBeInTheDocument();
|
||||||
if (tableBody) {
|
if (tableBody) {
|
||||||
const bodySkeletons = tableBody.querySelectorAll(
|
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}
|
{...defaultProps}
|
||||||
error={{ name: "Error", message: "" }}
|
error={{ name: "Error", message: "" }}
|
||||||
/>,
|
/>,
|
||||||
); // Error with empty message
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Error fetching data: An unknown error occurred"),
|
screen.getByText("Error fetching data: An unknown error occurred"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders default error message when error prop is an object without message", () => {
|
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(
|
expect(
|
||||||
screen.getByText("Error fetching data: An unknown error occurred"),
|
screen.getByText("Error fetching data: An unknown error occurred"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
@ -155,14 +153,8 @@ describe("ChatCompletionsTable", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Empty State", () => {
|
describe("Empty State", () => {
|
||||||
test('renders "No chat completions found." and no table when completions array is empty', () => {
|
test('renders "No chat completions found." and no table when data array is empty', () => {
|
||||||
render(
|
render(<ChatCompletionsTable data={[]} isLoading={false} error={null} />);
|
||||||
<ChatCompletionsTable
|
|
||||||
completions={[]}
|
|
||||||
isLoading={false}
|
|
||||||
error={null}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("No chat completions found."),
|
screen.getByText("No chat completions found."),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
@ -179,7 +171,7 @@ describe("ChatCompletionsTable", () => {
|
||||||
{
|
{
|
||||||
id: "comp_1",
|
id: "comp_1",
|
||||||
object: "chat.completion",
|
object: "chat.completion",
|
||||||
created: 1710000000, // Fixed timestamp for test
|
created: 1710000000,
|
||||||
model: "llama-test-model",
|
model: "llama-test-model",
|
||||||
choices: [
|
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(
|
render(
|
||||||
<ChatCompletionsTable
|
<ChatCompletionsTable
|
||||||
completions={mockCompletions}
|
data={mockCompletions}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
error={null}
|
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", () => {
|
test("truncates long input and output text", () => {
|
||||||
// Specific mock implementation for this test
|
// Specific mock implementation for this test
|
||||||
truncateText.mockImplementation(
|
truncateText.mockImplementation(
|
||||||
|
@ -259,6 +264,10 @@ describe("ChatCompletionsTable", () => {
|
||||||
"This is a very long input message that should be truncated.";
|
"This is a very long input message that should be truncated.";
|
||||||
const longOutput =
|
const longOutput =
|
||||||
"This is a very long output message that should also be truncated.";
|
"This is a very long output message that should also be truncated.";
|
||||||
|
|
||||||
|
extractTextFromContentPart.mockReturnValue(longInput);
|
||||||
|
extractDisplayableText.mockReturnValue(longOutput);
|
||||||
|
|
||||||
const mockCompletions = [
|
const mockCompletions = [
|
||||||
{
|
{
|
||||||
id: "comp_trunc",
|
id: "comp_trunc",
|
||||||
|
@ -278,7 +287,7 @@ describe("ChatCompletionsTable", () => {
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ChatCompletionsTable
|
<ChatCompletionsTable
|
||||||
completions={mockCompletions}
|
data={mockCompletions}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
error={null}
|
error={null}
|
||||||
/>,
|
/>,
|
||||||
|
@ -289,52 +298,50 @@ describe("ChatCompletionsTable", () => {
|
||||||
longInput.slice(0, 10) + "...",
|
longInput.slice(0, 10) + "...",
|
||||||
);
|
);
|
||||||
expect(truncatedTexts.length).toBe(2); // one for input, one for output
|
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) =>
|
truncatedTexts.forEach((textElement) =>
|
||||||
expect(textElement).toBeInTheDocument(),
|
expect(textElement).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("formats tool call output using formatToolCallToString", () => {
|
test("uses content extraction functions correctly", () => {
|
||||||
// Specific mock implementation for this test
|
const mockCompletion = {
|
||||||
formatToolCallToString.mockImplementation(
|
id: "comp_extract",
|
||||||
(toolCall: any) => `[TOOL:${toolCall.name}]`,
|
object: "chat.completion",
|
||||||
);
|
created: 1710003000,
|
||||||
// Ensure no truncation interferes for this specific test for clarity of tool call format
|
model: "llama-extract-model",
|
||||||
truncateText.mockImplementation((text: string | undefined) => text);
|
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" } };
|
extractTextFromContentPart.mockReturnValue("Extracted input");
|
||||||
const mockCompletions = [
|
extractDisplayableText.mockReturnValue("Extracted output");
|
||||||
{
|
|
||||||
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(
|
render(
|
||||||
<ChatCompletionsTable
|
<ChatCompletionsTable
|
||||||
completions={mockCompletions}
|
data={[mockCompletion]}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
error={null}
|
error={null}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// The component concatenates message.content and the formatted tool call
|
// Verify the extraction functions were called
|
||||||
expect(screen.getByText("Tool output [TOOL:search]")).toBeInTheDocument();
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 React from "react";
|
||||||
import { formatToolCallToString } from "@/lib/format-tool-call";
|
import { formatToolCallToString } from "@/lib/format-tool-call";
|
||||||
import { extractTextFromContentPart } from "@/lib/format-message-content";
|
import { extractTextFromContentPart } from "@/lib/format-message-content";
|
||||||
|
import {
|
||||||
// Sub-component or helper for the common label + content structure
|
MessageBlock,
|
||||||
const MessageBlock: React.FC<{
|
ToolCallBlock,
|
||||||
label: string;
|
} from "@/components/ui/message-components";
|
||||||
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 {
|
interface ChatMessageItemProps {
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
|
@ -65,7 +30,11 @@ export function ChatMessageItem({ message }: ChatMessageItemProps) {
|
||||||
);
|
);
|
||||||
|
|
||||||
case "assistant":
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{message.tool_calls.map((toolCall: any, index: number) => {
|
{message.tool_calls.map((toolCall: any, index: number) => {
|
||||||
|
|
141
llama_stack/ui/components/layout/detail-layout.tsx
Normal file
141
llama_stack/ui/components/layout/detail-layout.tsx
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
export function DetailLoadingView({ title }: { title: string }) {
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailErrorView({
|
||||||
|
title,
|
||||||
|
id,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
id: string;
|
||||||
|
error: Error;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl font-bold mb-6">{title}</h1>
|
||||||
|
<p>
|
||||||
|
Error loading details for ID {id}: {error.message}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailNotFoundView({
|
||||||
|
title,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
id: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl font-bold mb-6">{title}</h1>
|
||||||
|
<p>No details found for ID: {id}.</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PropertyItemProps {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
hasBorder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PropertyItem({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
className = "",
|
||||||
|
hasBorder = false,
|
||||||
|
}: PropertyItemProps) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={`${hasBorder ? "pt-1 mt-1 border-t border-gray-200" : ""} ${className}`}
|
||||||
|
>
|
||||||
|
<strong>{label}:</strong>{" "}
|
||||||
|
{typeof value === "string" || typeof value === "number" ? (
|
||||||
|
<span className="text-gray-900 font-medium">{value}</span>
|
||||||
|
) : (
|
||||||
|
value
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PropertiesCardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PropertiesCard({ children }: PropertiesCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Properties</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-600">{children}</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailLayoutProps {
|
||||||
|
title: string;
|
||||||
|
mainContent: React.ReactNode;
|
||||||
|
sidebar: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailLayout({
|
||||||
|
title,
|
||||||
|
mainContent,
|
||||||
|
sidebar,
|
||||||
|
}: DetailLayoutProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl font-bold mb-6">{title}</h1>
|
||||||
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
|
<div className="flex-grow md:w-2/3 space-y-6">{mainContent}</div>
|
||||||
|
<div className="md:w-1/3">{sidebar}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
49
llama_stack/ui/components/layout/logs-layout.tsx
Normal file
49
llama_stack/ui/components/layout/logs-layout.tsx
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
"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";
|
||||||
|
|
||||||
|
interface LogsLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
sectionLabel: string;
|
||||||
|
basePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogsLayout({
|
||||||
|
children,
|
||||||
|
sectionLabel,
|
||||||
|
basePath,
|
||||||
|
}: LogsLayoutProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
let segments: BreadcrumbSegment[] = [];
|
||||||
|
|
||||||
|
if (pathname === basePath) {
|
||||||
|
segments = [{ label: sectionLabel }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const idParam = params?.id;
|
||||||
|
if (idParam && typeof idParam === "string") {
|
||||||
|
segments = [
|
||||||
|
{ label: sectionLabel, href: basePath },
|
||||||
|
{ label: `Details (${truncateText(idParam, 20)})` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-4">
|
||||||
|
<>
|
||||||
|
{segments.length > 0 && (
|
||||||
|
<PageBreadcrumb segments={segments} className="mb-4" />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
350
llama_stack/ui/components/logs/logs-table.test.tsx
Normal file
350
llama_stack/ui/components/logs/logs-table.test.tsx
Normal file
|
@ -0,0 +1,350 @@
|
||||||
|
import React from "react";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { LogsTable, LogTableRow } from "./logs-table";
|
||||||
|
|
||||||
|
// Mock next/navigation
|
||||||
|
const mockPush = jest.fn();
|
||||||
|
jest.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
push: mockPush,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock helper functions
|
||||||
|
jest.mock("@/lib/truncate-text");
|
||||||
|
|
||||||
|
// Import the mocked functions
|
||||||
|
import { truncateText as originalTruncateText } from "@/lib/truncate-text";
|
||||||
|
|
||||||
|
// Cast to jest.Mock for typings
|
||||||
|
const truncateText = originalTruncateText as jest.Mock;
|
||||||
|
|
||||||
|
describe("LogsTable", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
data: [] as LogTableRow[],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
caption: "Test table caption",
|
||||||
|
emptyMessage: "No data found",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset all mocks before each test
|
||||||
|
mockPush.mockClear();
|
||||||
|
truncateText.mockClear();
|
||||||
|
|
||||||
|
// Default pass-through implementation
|
||||||
|
truncateText.mockImplementation((text: string | undefined) => text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders without crashing with default props", () => {
|
||||||
|
render(<LogsTable {...defaultProps} />);
|
||||||
|
expect(screen.getByText("No data found")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("click on a row navigates to the correct URL", () => {
|
||||||
|
const mockData: LogTableRow[] = [
|
||||||
|
{
|
||||||
|
id: "row_123",
|
||||||
|
input: "Test input",
|
||||||
|
output: "Test output",
|
||||||
|
model: "test-model",
|
||||||
|
createdTime: "2024-01-01 12:00:00",
|
||||||
|
detailPath: "/test/path/row_123",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<LogsTable {...defaultProps} data={mockData} />);
|
||||||
|
|
||||||
|
const row = screen.getByText("Test input").closest("tr");
|
||||||
|
if (row) {
|
||||||
|
fireEvent.click(row);
|
||||||
|
expect(mockPush).toHaveBeenCalledWith("/test/path/row_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(
|
||||||
|
<LogsTable {...defaultProps} isLoading={true} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for skeleton in the table caption
|
||||||
|
const tableCaption = container.querySelector("caption");
|
||||||
|
expect(tableCaption).toBeInTheDocument();
|
||||||
|
if (tableCaption) {
|
||||||
|
const captionSkeleton = tableCaption.querySelector(
|
||||||
|
'[data-slot="skeleton"]',
|
||||||
|
);
|
||||||
|
expect(captionSkeleton).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for skeletons in the table body cells
|
||||||
|
const tableBody = container.querySelector("tbody");
|
||||||
|
expect(tableBody).toBeInTheDocument();
|
||||||
|
if (tableBody) {
|
||||||
|
const bodySkeletons = tableBody.querySelectorAll(
|
||||||
|
'[data-slot="skeleton"]',
|
||||||
|
);
|
||||||
|
expect(bodySkeletons.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that table headers are still rendered
|
||||||
|
expect(screen.getByText("Input")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Output")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Model")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Created")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders correct number of skeleton rows", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<LogsTable {...defaultProps} isLoading={true} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const skeletonRows = container.querySelectorAll("tbody tr");
|
||||||
|
expect(skeletonRows.length).toBe(3); // Should render 3 skeleton rows
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error State", () => {
|
||||||
|
test("renders error message when error prop is provided", () => {
|
||||||
|
const errorMessage = "Network Error";
|
||||||
|
render(
|
||||||
|
<LogsTable
|
||||||
|
{...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(
|
||||||
|
<LogsTable {...defaultProps} error={{ name: "Error", 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(<LogsTable {...defaultProps} error={{} as Error} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText("Error fetching data: An unknown error occurred"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not render table when in error state", () => {
|
||||||
|
render(
|
||||||
|
<LogsTable
|
||||||
|
{...defaultProps}
|
||||||
|
error={{ name: "Error", message: "Test error" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const table = screen.queryByRole("table");
|
||||||
|
expect(table).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Empty State", () => {
|
||||||
|
test("renders custom empty message when data array is empty", () => {
|
||||||
|
render(
|
||||||
|
<LogsTable
|
||||||
|
{...defaultProps}
|
||||||
|
data={[]}
|
||||||
|
emptyMessage="Custom empty message"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Custom empty message")).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 data correctly", () => {
|
||||||
|
const mockData: LogTableRow[] = [
|
||||||
|
{
|
||||||
|
id: "row_1",
|
||||||
|
input: "First input",
|
||||||
|
output: "First output",
|
||||||
|
model: "model-1",
|
||||||
|
createdTime: "2024-01-01 12:00:00",
|
||||||
|
detailPath: "/path/1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "row_2",
|
||||||
|
input: "Second input",
|
||||||
|
output: "Second output",
|
||||||
|
model: "model-2",
|
||||||
|
createdTime: "2024-01-02 13:00:00",
|
||||||
|
detailPath: "/path/2",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LogsTable
|
||||||
|
{...defaultProps}
|
||||||
|
data={mockData}
|
||||||
|
caption="Custom table caption"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Table caption
|
||||||
|
expect(screen.getByText("Custom table caption")).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("First input")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("First output")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("model-1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("2024-01-01 12:00:00")).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.getByText("Second input")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Second output")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("model-2")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("2024-01-02 13:00:00")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applies correct CSS classes to table rows", () => {
|
||||||
|
const mockData: LogTableRow[] = [
|
||||||
|
{
|
||||||
|
id: "row_1",
|
||||||
|
input: "Test input",
|
||||||
|
output: "Test output",
|
||||||
|
model: "test-model",
|
||||||
|
createdTime: "2024-01-01 12:00:00",
|
||||||
|
detailPath: "/test/path",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<LogsTable {...defaultProps} data={mockData} />);
|
||||||
|
|
||||||
|
const row = screen.getByText("Test input").closest("tr");
|
||||||
|
expect(row).toHaveClass("cursor-pointer");
|
||||||
|
expect(row).toHaveClass("hover:bg-muted/50");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applies correct alignment to Created column", () => {
|
||||||
|
const mockData: LogTableRow[] = [
|
||||||
|
{
|
||||||
|
id: "row_1",
|
||||||
|
input: "Test input",
|
||||||
|
output: "Test output",
|
||||||
|
model: "test-model",
|
||||||
|
createdTime: "2024-01-01 12:00:00",
|
||||||
|
detailPath: "/test/path",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<LogsTable {...defaultProps} data={mockData} />);
|
||||||
|
|
||||||
|
const createdCell = screen.getByText("2024-01-01 12:00:00").closest("td");
|
||||||
|
expect(createdCell).toHaveClass("text-right");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Text Truncation", () => {
|
||||||
|
test("truncates input and output text using truncateText function", () => {
|
||||||
|
// Mock truncateText to return truncated versions
|
||||||
|
truncateText.mockImplementation((text: string | undefined) => {
|
||||||
|
if (typeof text === "string" && text.length > 10) {
|
||||||
|
return text.slice(0, 10) + "...";
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
|
||||||
|
const longInput =
|
||||||
|
"This is a very long input text that should be truncated";
|
||||||
|
const longOutput =
|
||||||
|
"This is a very long output text that should be truncated";
|
||||||
|
|
||||||
|
const mockData: LogTableRow[] = [
|
||||||
|
{
|
||||||
|
id: "row_1",
|
||||||
|
input: longInput,
|
||||||
|
output: longOutput,
|
||||||
|
model: "test-model",
|
||||||
|
createdTime: "2024-01-01 12:00:00",
|
||||||
|
detailPath: "/test/path",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<LogsTable {...defaultProps} data={mockData} />);
|
||||||
|
|
||||||
|
// Verify truncateText was called
|
||||||
|
expect(truncateText).toHaveBeenCalledWith(longInput);
|
||||||
|
expect(truncateText).toHaveBeenCalledWith(longOutput);
|
||||||
|
|
||||||
|
// Verify truncated text is displayed
|
||||||
|
const truncatedTexts = screen.getAllByText("This is a ...");
|
||||||
|
expect(truncatedTexts).toHaveLength(2); // one for input, one for output
|
||||||
|
truncatedTexts.forEach((textElement) =>
|
||||||
|
expect(textElement).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not truncate model names", () => {
|
||||||
|
const mockData: LogTableRow[] = [
|
||||||
|
{
|
||||||
|
id: "row_1",
|
||||||
|
input: "Test input",
|
||||||
|
output: "Test output",
|
||||||
|
model: "very-long-model-name-that-should-not-be-truncated",
|
||||||
|
createdTime: "2024-01-01 12:00:00",
|
||||||
|
detailPath: "/test/path",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<LogsTable {...defaultProps} data={mockData} />);
|
||||||
|
|
||||||
|
// Model name should not be passed to truncateText
|
||||||
|
expect(truncateText).not.toHaveBeenCalledWith(
|
||||||
|
"very-long-model-name-that-should-not-be-truncated",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Full model name should be displayed
|
||||||
|
expect(
|
||||||
|
screen.getByText("very-long-model-name-that-should-not-be-truncated"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Accessibility", () => {
|
||||||
|
test("table has proper role and structure", () => {
|
||||||
|
const mockData: LogTableRow[] = [
|
||||||
|
{
|
||||||
|
id: "row_1",
|
||||||
|
input: "Test input",
|
||||||
|
output: "Test output",
|
||||||
|
model: "test-model",
|
||||||
|
createdTime: "2024-01-01 12:00:00",
|
||||||
|
detailPath: "/test/path",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<LogsTable {...defaultProps} data={mockData} />);
|
||||||
|
|
||||||
|
const table = screen.getByRole("table");
|
||||||
|
expect(table).toBeInTheDocument();
|
||||||
|
|
||||||
|
const columnHeaders = screen.getAllByRole("columnheader");
|
||||||
|
expect(columnHeaders).toHaveLength(4);
|
||||||
|
|
||||||
|
const rows = screen.getAllByRole("row");
|
||||||
|
expect(rows).toHaveLength(2); // 1 header row + 1 data row
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,12 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ChatCompletion } from "@/lib/types";
|
|
||||||
import { truncateText } from "@/lib/truncate-text";
|
import { truncateText } from "@/lib/truncate-text";
|
||||||
import {
|
|
||||||
extractTextFromContentPart,
|
|
||||||
extractDisplayableText,
|
|
||||||
} from "@/lib/format-message-content";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
@ -18,17 +13,31 @@ import {
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
interface ChatCompletionsTableProps {
|
// Generic table row data interface
|
||||||
completions: ChatCompletion[];
|
export interface LogTableRow {
|
||||||
isLoading: boolean;
|
id: string;
|
||||||
error: Error | null;
|
input: string;
|
||||||
|
output: string;
|
||||||
|
model: string;
|
||||||
|
createdTime: string;
|
||||||
|
detailPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatCompletionsTable({
|
interface LogsTableProps {
|
||||||
completions,
|
data: LogTableRow[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
caption: string;
|
||||||
|
emptyMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogsTable({
|
||||||
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
}: ChatCompletionsTableProps) {
|
caption,
|
||||||
|
emptyMessage,
|
||||||
|
}: LogsTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const tableHeader = (
|
const tableHeader = (
|
||||||
|
@ -77,41 +86,25 @@ export function ChatCompletionsTable({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completions.length === 0) {
|
if (data.length === 0) {
|
||||||
return <p>No chat completions found.</p>;
|
return <p>{emptyMessage}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table>
|
<Table>
|
||||||
<TableCaption>A list of your recent chat completions.</TableCaption>
|
<TableCaption>{caption}</TableCaption>
|
||||||
{tableHeader}
|
{tableHeader}
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{completions.map((completion) => (
|
{data.map((row) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={completion.id}
|
key={row.id}
|
||||||
onClick={() =>
|
onClick={() => router.push(row.detailPath)}
|
||||||
router.push(`/logs/chat-completions/${completion.id}`)
|
|
||||||
}
|
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>{truncateText(row.input)}</TableCell>
|
||||||
{truncateText(
|
<TableCell>{truncateText(row.output)}</TableCell>
|
||||||
extractTextFromContentPart(
|
<TableCell>{row.model}</TableCell>
|
||||||
completion.input_messages?.[0]?.content,
|
<TableCell className="text-right">{row.createdTime}</TableCell>
|
||||||
),
|
|
||||||
)}
|
|
||||||
</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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { useFunctionCallGrouping } from "../hooks/function-call-grouping";
|
||||||
|
import { ItemRenderer } from "../items/item-renderer";
|
||||||
|
import { GroupedFunctionCallItemComponent } from "../items/grouped-function-call-item";
|
||||||
|
import {
|
||||||
|
isFunctionCallItem,
|
||||||
|
isFunctionCallOutputItem,
|
||||||
|
AnyResponseItem,
|
||||||
|
} from "../utils/item-types";
|
||||||
|
|
||||||
|
interface GroupedItemsDisplayProps {
|
||||||
|
items: AnyResponseItem[];
|
||||||
|
keyPrefix: string;
|
||||||
|
defaultRole?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupedItemsDisplay({
|
||||||
|
items,
|
||||||
|
keyPrefix,
|
||||||
|
defaultRole = "unknown",
|
||||||
|
}: GroupedItemsDisplayProps) {
|
||||||
|
const groupedItems = useFunctionCallGrouping(items);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{groupedItems.map((groupedItem) => {
|
||||||
|
// If this is a function call with an output, render the grouped component
|
||||||
|
if (
|
||||||
|
groupedItem.outputItem &&
|
||||||
|
isFunctionCallItem(groupedItem.item) &&
|
||||||
|
isFunctionCallOutputItem(groupedItem.outputItem)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<GroupedFunctionCallItemComponent
|
||||||
|
key={`${keyPrefix}-${groupedItem.index}`}
|
||||||
|
functionCall={groupedItem.item}
|
||||||
|
output={groupedItem.outputItem}
|
||||||
|
index={groupedItem.index}
|
||||||
|
keyPrefix={keyPrefix}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, render the individual item
|
||||||
|
return (
|
||||||
|
<ItemRenderer
|
||||||
|
key={`${keyPrefix}-${groupedItem.index}`}
|
||||||
|
item={groupedItem.item}
|
||||||
|
index={groupedItem.index}
|
||||||
|
keyPrefix={keyPrefix}
|
||||||
|
defaultRole={defaultRole}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
isFunctionCallOutputItem,
|
||||||
|
AnyResponseItem,
|
||||||
|
FunctionCallOutputItem,
|
||||||
|
} from "../utils/item-types";
|
||||||
|
|
||||||
|
export interface GroupedItem {
|
||||||
|
item: AnyResponseItem;
|
||||||
|
index: number;
|
||||||
|
outputItem?: AnyResponseItem;
|
||||||
|
outputIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to group function calls with their corresponding outputs
|
||||||
|
* @param items Array of items to group
|
||||||
|
* @returns Array of grouped items with their outputs
|
||||||
|
*/
|
||||||
|
export function useFunctionCallGrouping(
|
||||||
|
items: AnyResponseItem[],
|
||||||
|
): GroupedItem[] {
|
||||||
|
return useMemo(() => {
|
||||||
|
const groupedItems: GroupedItem[] = [];
|
||||||
|
const processedIndices = new Set<number>();
|
||||||
|
|
||||||
|
// Build a map of call_id to indices for function_call_output items
|
||||||
|
const callIdToIndices = new Map<string, number[]>();
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (isFunctionCallOutputItem(item)) {
|
||||||
|
if (!callIdToIndices.has(item.call_id)) {
|
||||||
|
callIdToIndices.set(item.call_id, []);
|
||||||
|
}
|
||||||
|
callIdToIndices.get(item.call_id)!.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process items and group function calls with their outputs
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (processedIndices.has(i)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentItem = items[i];
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentItem.type === "function_call" &&
|
||||||
|
"name" in currentItem &&
|
||||||
|
"call_id" in currentItem
|
||||||
|
) {
|
||||||
|
const functionCallId = currentItem.call_id as string;
|
||||||
|
let outputIndex = -1;
|
||||||
|
let outputItem: FunctionCallOutputItem | null = null;
|
||||||
|
|
||||||
|
const relatedIndices = callIdToIndices.get(functionCallId) || [];
|
||||||
|
for (const idx of relatedIndices) {
|
||||||
|
const potentialOutput = items[idx];
|
||||||
|
outputIndex = idx;
|
||||||
|
outputItem = potentialOutput as FunctionCallOutputItem;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outputItem && outputIndex !== -1) {
|
||||||
|
// Group function call with its function_call_output
|
||||||
|
groupedItems.push({
|
||||||
|
item: currentItem,
|
||||||
|
index: i,
|
||||||
|
outputItem,
|
||||||
|
outputIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark both items as processed
|
||||||
|
processedIndices.add(i);
|
||||||
|
processedIndices.add(outputIndex);
|
||||||
|
|
||||||
|
// Matching function call and output found, skip to next item
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// render normally
|
||||||
|
groupedItems.push({
|
||||||
|
item: currentItem,
|
||||||
|
index: i,
|
||||||
|
});
|
||||||
|
processedIndices.add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupedItems;
|
||||||
|
}, [items]);
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
import {
|
||||||
|
MessageBlock,
|
||||||
|
ToolCallBlock,
|
||||||
|
} from "@/components/ui/message-components";
|
||||||
|
import { FunctionCallItem } from "../utils/item-types";
|
||||||
|
|
||||||
|
interface FunctionCallItemProps {
|
||||||
|
item: FunctionCallItem;
|
||||||
|
index: number;
|
||||||
|
keyPrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FunctionCallItemComponent({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
keyPrefix,
|
||||||
|
}: FunctionCallItemProps) {
|
||||||
|
const name = item.name || "unknown";
|
||||||
|
const args = item.arguments || "{}";
|
||||||
|
const formattedFunctionCall = `${name}(${args})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageBlock
|
||||||
|
key={`${keyPrefix}-${index}`}
|
||||||
|
label="Function Call"
|
||||||
|
content={<ToolCallBlock>{formattedFunctionCall}</ToolCallBlock>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
37
llama_stack/ui/components/responses/items/generic-item.tsx
Normal file
37
llama_stack/ui/components/responses/items/generic-item.tsx
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import {
|
||||||
|
MessageBlock,
|
||||||
|
ToolCallBlock,
|
||||||
|
} from "@/components/ui/message-components";
|
||||||
|
import { BaseItem } from "../utils/item-types";
|
||||||
|
|
||||||
|
interface GenericItemProps {
|
||||||
|
item: BaseItem;
|
||||||
|
index: number;
|
||||||
|
keyPrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GenericItemComponent({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
keyPrefix,
|
||||||
|
}: GenericItemProps) {
|
||||||
|
// Handle other types like function calls, tool outputs, etc.
|
||||||
|
const itemData = item as Record<string, unknown>;
|
||||||
|
|
||||||
|
const content = itemData.content
|
||||||
|
? typeof itemData.content === "string"
|
||||||
|
? itemData.content
|
||||||
|
: JSON.stringify(itemData.content, null, 2)
|
||||||
|
: JSON.stringify(itemData, null, 2);
|
||||||
|
|
||||||
|
const label = keyPrefix === "input" ? "Input" : "Output";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageBlock
|
||||||
|
key={`${keyPrefix}-${index}`}
|
||||||
|
label={label}
|
||||||
|
labelDetail={`(${itemData.type})`}
|
||||||
|
content={<ToolCallBlock>{content}</ToolCallBlock>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
import {
|
||||||
|
MessageBlock,
|
||||||
|
ToolCallBlock,
|
||||||
|
} from "@/components/ui/message-components";
|
||||||
|
import { FunctionCallItem, FunctionCallOutputItem } from "../utils/item-types";
|
||||||
|
|
||||||
|
interface GroupedFunctionCallItemProps {
|
||||||
|
functionCall: FunctionCallItem;
|
||||||
|
output: FunctionCallOutputItem;
|
||||||
|
index: number;
|
||||||
|
keyPrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupedFunctionCallItemComponent({
|
||||||
|
functionCall,
|
||||||
|
output,
|
||||||
|
index,
|
||||||
|
keyPrefix,
|
||||||
|
}: GroupedFunctionCallItemProps) {
|
||||||
|
const name = functionCall.name || "unknown";
|
||||||
|
const args = functionCall.arguments || "{}";
|
||||||
|
|
||||||
|
// Extract the output content from function_call_output
|
||||||
|
let outputContent = "";
|
||||||
|
if (output.output) {
|
||||||
|
outputContent =
|
||||||
|
typeof output.output === "string"
|
||||||
|
? output.output
|
||||||
|
: JSON.stringify(output.output);
|
||||||
|
} else {
|
||||||
|
outputContent = JSON.stringify(output, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const functionCallContent = (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-sm text-gray-600">Arguments</span>
|
||||||
|
<ToolCallBlock>{`${name}(${args})`}</ToolCallBlock>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-600">Output</span>
|
||||||
|
<ToolCallBlock>{outputContent}</ToolCallBlock>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageBlock
|
||||||
|
key={`${keyPrefix}-${index}`}
|
||||||
|
label="Function Call"
|
||||||
|
content={functionCallContent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
6
llama_stack/ui/components/responses/items/index.ts
Normal file
6
llama_stack/ui/components/responses/items/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export { MessageItemComponent } from "./message-item";
|
||||||
|
export { FunctionCallItemComponent } from "./function-call-item";
|
||||||
|
export { WebSearchItemComponent } from "./web-search-item";
|
||||||
|
export { GenericItemComponent } from "./generic-item";
|
||||||
|
export { GroupedFunctionCallItemComponent } from "./grouped-function-call-item";
|
||||||
|
export { ItemRenderer } from "./item-renderer";
|
60
llama_stack/ui/components/responses/items/item-renderer.tsx
Normal file
60
llama_stack/ui/components/responses/items/item-renderer.tsx
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import {
|
||||||
|
isMessageItem,
|
||||||
|
isFunctionCallItem,
|
||||||
|
isWebSearchCallItem,
|
||||||
|
AnyResponseItem,
|
||||||
|
} from "../utils/item-types";
|
||||||
|
import { MessageItemComponent } from "./message-item";
|
||||||
|
import { FunctionCallItemComponent } from "./function-call-item";
|
||||||
|
import { WebSearchItemComponent } from "./web-search-item";
|
||||||
|
import { GenericItemComponent } from "./generic-item";
|
||||||
|
|
||||||
|
interface ItemRendererProps {
|
||||||
|
item: AnyResponseItem;
|
||||||
|
index: number;
|
||||||
|
keyPrefix: string;
|
||||||
|
defaultRole?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemRenderer({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
keyPrefix,
|
||||||
|
defaultRole = "unknown",
|
||||||
|
}: ItemRendererProps) {
|
||||||
|
if (isMessageItem(item)) {
|
||||||
|
return (
|
||||||
|
<MessageItemComponent
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
keyPrefix={keyPrefix}
|
||||||
|
defaultRole={defaultRole}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFunctionCallItem(item)) {
|
||||||
|
return (
|
||||||
|
<FunctionCallItemComponent
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
keyPrefix={keyPrefix}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWebSearchCallItem(item)) {
|
||||||
|
return (
|
||||||
|
<WebSearchItemComponent item={item} index={index} keyPrefix={keyPrefix} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to generic item for unknown types
|
||||||
|
return (
|
||||||
|
<GenericItemComponent
|
||||||
|
item={item as any}
|
||||||
|
index={index}
|
||||||
|
keyPrefix={keyPrefix}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
41
llama_stack/ui/components/responses/items/message-item.tsx
Normal file
41
llama_stack/ui/components/responses/items/message-item.tsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { MessageBlock } from "@/components/ui/message-components";
|
||||||
|
import { MessageItem } from "../utils/item-types";
|
||||||
|
|
||||||
|
interface MessageItemProps {
|
||||||
|
item: MessageItem;
|
||||||
|
index: number;
|
||||||
|
keyPrefix: string;
|
||||||
|
defaultRole?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageItemComponent({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
keyPrefix,
|
||||||
|
defaultRole = "unknown",
|
||||||
|
}: MessageItemProps) {
|
||||||
|
let content = "";
|
||||||
|
|
||||||
|
if (typeof item.content === "string") {
|
||||||
|
content = item.content;
|
||||||
|
} else if (Array.isArray(item.content)) {
|
||||||
|
content = item.content
|
||||||
|
.map((c) => {
|
||||||
|
return c.type === "input_text" || c.type === "output_text"
|
||||||
|
? c.text
|
||||||
|
: JSON.stringify(c);
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = item.role || defaultRole;
|
||||||
|
const label = role.charAt(0).toUpperCase() + role.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageBlock
|
||||||
|
key={`${keyPrefix}-${index}`}
|
||||||
|
label={label}
|
||||||
|
content={content}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
import {
|
||||||
|
MessageBlock,
|
||||||
|
ToolCallBlock,
|
||||||
|
} from "@/components/ui/message-components";
|
||||||
|
import { WebSearchCallItem } from "../utils/item-types";
|
||||||
|
|
||||||
|
interface WebSearchItemProps {
|
||||||
|
item: WebSearchCallItem;
|
||||||
|
index: number;
|
||||||
|
keyPrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebSearchItemComponent({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
keyPrefix,
|
||||||
|
}: WebSearchItemProps) {
|
||||||
|
const formattedWebSearch = `web_search_call(status: ${item.status})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageBlock
|
||||||
|
key={`${keyPrefix}-${index}`}
|
||||||
|
label="Function Call"
|
||||||
|
labelDetail="(Web Search)"
|
||||||
|
content={<ToolCallBlock>{formattedWebSearch}</ToolCallBlock>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
777
llama_stack/ui/components/responses/responses-detail.test.tsx
Normal file
777
llama_stack/ui/components/responses/responses-detail.test.tsx
Normal file
|
@ -0,0 +1,777 @@
|
||||||
|
import React from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { ResponseDetailView } from "./responses-detail";
|
||||||
|
import { OpenAIResponse, InputItemListResponse } from "@/lib/types";
|
||||||
|
|
||||||
|
describe("ResponseDetailView", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
response: null,
|
||||||
|
inputItems: null,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadingInputItems: false,
|
||||||
|
error: null,
|
||||||
|
inputItemsError: null,
|
||||||
|
id: "test_id",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Loading State", () => {
|
||||||
|
test("renders loading skeleton when isLoading is true", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ResponseDetailView {...defaultProps} isLoading={true} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for skeleton elements
|
||||||
|
const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
|
||||||
|
expect(skeletons.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The title is replaced by a skeleton when loading, so we shouldn't expect the text
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error State", () => {
|
||||||
|
test("renders error message when error prop is provided", () => {
|
||||||
|
const errorMessage = "Network Error";
|
||||||
|
render(
|
||||||
|
<ResponseDetailView
|
||||||
|
{...defaultProps}
|
||||||
|
error={{ name: "Error", message: errorMessage }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Responses Details")).toBeInTheDocument();
|
||||||
|
// The error message is split across elements, so we check for parts
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Error loading details for ID/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/test_id/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Network Error/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders default error message when error.message is not available", () => {
|
||||||
|
render(
|
||||||
|
<ResponseDetailView
|
||||||
|
{...defaultProps}
|
||||||
|
error={{ name: "Error", message: "" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Error loading details for ID/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/test_id/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Not Found State", () => {
|
||||||
|
test("renders not found message when response is null and not loading/error", () => {
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={null} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Responses Details")).toBeInTheDocument();
|
||||||
|
// The message is split across elements
|
||||||
|
expect(screen.getByText(/No details found for ID:/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/test_id/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Response Data Rendering", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "llama-test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Test response output",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: "Test input message",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0.7,
|
||||||
|
top_p: 0.9,
|
||||||
|
parallel_tool_calls: true,
|
||||||
|
previous_response_id: "prev_resp_456",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renders response data with input and output sections", () => {
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
// Check main sections
|
||||||
|
expect(screen.getByText("Responses Details")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Input")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Output")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check input content
|
||||||
|
expect(screen.getByText("Test input message")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("User")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check output content
|
||||||
|
expect(screen.getByText("Test response output")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders properties sidebar with all response metadata", () => {
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
// Check properties - use regex to handle text split across elements
|
||||||
|
expect(screen.getByText(/Created/)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(new Date(1710000000 * 1000).toLocaleString()),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for the specific ID label (not Previous Response ID)
|
||||||
|
expect(
|
||||||
|
screen.getByText((content, element) => {
|
||||||
|
return element?.tagName === "STRONG" && content === "ID:";
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("resp_123")).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.getByText(/Model/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("llama-test-model")).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.getByText(/Status/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("completed")).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.getByText(/Temperature/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("0.7")).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.getByText(/Top P/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("0.9")).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.getByText(/Parallel Tool Calls/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Yes")).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.getByText(/Previous Response ID/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("prev_resp_456")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles optional properties correctly", () => {
|
||||||
|
const minimalResponse: OpenAIResponse = {
|
||||||
|
id: "resp_minimal",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [],
|
||||||
|
input: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ResponseDetailView {...defaultProps} response={minimalResponse} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show required properties
|
||||||
|
expect(screen.getByText("resp_minimal")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("test-model")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("completed")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should not show optional properties
|
||||||
|
expect(screen.queryByText("Temperature")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Top P")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Parallel Tool Calls")).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Previous Response ID"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders error information when response has error", () => {
|
||||||
|
const errorResponse: OpenAIResponse = {
|
||||||
|
...mockResponse,
|
||||||
|
error: {
|
||||||
|
code: "invalid_request",
|
||||||
|
message: "The request was invalid",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={errorResponse} />);
|
||||||
|
|
||||||
|
// The error is shown in the properties sidebar, not as a separate "Error" label
|
||||||
|
expect(
|
||||||
|
screen.getByText("invalid_request: The request was invalid"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Input Items Handling", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [{ type: "message", role: "assistant", content: "output" }],
|
||||||
|
input: [{ type: "message", role: "user", content: "fallback input" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
test("shows loading state for input items", () => {
|
||||||
|
render(
|
||||||
|
<ResponseDetailView
|
||||||
|
{...defaultProps}
|
||||||
|
response={mockResponse}
|
||||||
|
isLoadingInputItems={true}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for skeleton loading in input items section
|
||||||
|
const { container } = render(
|
||||||
|
<ResponseDetailView
|
||||||
|
{...defaultProps}
|
||||||
|
response={mockResponse}
|
||||||
|
isLoadingInputItems={true}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
|
||||||
|
expect(skeletons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows error message for input items with fallback", () => {
|
||||||
|
render(
|
||||||
|
<ResponseDetailView
|
||||||
|
{...defaultProps}
|
||||||
|
response={mockResponse}
|
||||||
|
inputItemsError={{
|
||||||
|
name: "Error",
|
||||||
|
message: "Failed to load input items",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"Error loading input items: Failed to load input items",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Falling back to response input data."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should still show fallback input data
|
||||||
|
expect(screen.getByText("fallback input")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses input items data when available", () => {
|
||||||
|
const mockInputItems: InputItemListResponse = {
|
||||||
|
object: "list",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: "input from items API",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ResponseDetailView
|
||||||
|
{...defaultProps}
|
||||||
|
response={mockResponse}
|
||||||
|
inputItems={mockInputItems}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show input items data, not response.input
|
||||||
|
expect(screen.getByText("input from items API")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("fallback input")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to response.input when input items is empty", () => {
|
||||||
|
const emptyInputItems: InputItemListResponse = {
|
||||||
|
object: "list",
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ResponseDetailView
|
||||||
|
{...defaultProps}
|
||||||
|
response={mockResponse}
|
||||||
|
inputItems={emptyInputItems}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show fallback input data
|
||||||
|
expect(screen.getByText("fallback input")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows no input message when no data available", () => {
|
||||||
|
const responseWithoutInput: OpenAIResponse = {
|
||||||
|
...mockResponse,
|
||||||
|
input: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ResponseDetailView
|
||||||
|
{...defaultProps}
|
||||||
|
response={responseWithoutInput}
|
||||||
|
inputItems={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("No input data available.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Input Display Components", () => {
|
||||||
|
test("renders string content input correctly", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [],
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: "Simple string input",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Simple string input")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("User")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders array content input correctly", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [],
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "input_text", text: "First part" },
|
||||||
|
{ type: "output_text", text: "Second part" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("First part Second part")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("User")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders non-message input types correctly", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [],
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "function_call",
|
||||||
|
content: "function call content",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("function call content")).toBeInTheDocument();
|
||||||
|
// Use getAllByText to find the specific "Input" with the type detail
|
||||||
|
const inputElements = screen.getAllByText("Input");
|
||||||
|
expect(inputElements.length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText("(function_call)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles input with object content", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [],
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "custom_type",
|
||||||
|
content: JSON.stringify({ key: "value", nested: { data: "test" } }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
// Should show JSON stringified content (without quotes around keys in the rendered output)
|
||||||
|
expect(screen.getByText(/key.*value/)).toBeInTheDocument();
|
||||||
|
// Use getAllByText to find the specific "Input" with the type detail
|
||||||
|
const inputElements = screen.getAllByText("Input");
|
||||||
|
expect(inputElements.length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText("(custom_type)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders function call input correctly", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [],
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "function_call",
|
||||||
|
id: "call_456",
|
||||||
|
status: "completed",
|
||||||
|
name: "input_function",
|
||||||
|
arguments: '{"param": "value"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('input_function({"param": "value"})'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Function Call")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders web search call input correctly", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [],
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "web_search_call",
|
||||||
|
id: "search_789",
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("web_search_call(status: completed)"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Function Call")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("(Web Search)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Output Display Components", () => {
|
||||||
|
test("renders message output with string content", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Simple string output",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
input: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Simple string output")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders message output with array content", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "output_text", text: "First output" },
|
||||||
|
{ type: "input_text", text: "Second output" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
input: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("First output Second output"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders function call output correctly", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "function_call",
|
||||||
|
id: "call_123",
|
||||||
|
status: "completed",
|
||||||
|
name: "search_function",
|
||||||
|
arguments: '{"query": "test"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
input: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('search_function({"query": "test"})'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Function Call")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders function call output without arguments", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "function_call",
|
||||||
|
id: "call_123",
|
||||||
|
status: "completed",
|
||||||
|
name: "simple_function",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
input: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("simple_function({})")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Function Call/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders web search call output correctly", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "web_search_call",
|
||||||
|
id: "search_123",
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
input: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("web_search_call(status: completed)"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Function Call/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("(Web Search)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders unknown output types with JSON fallback", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "unknown_type",
|
||||||
|
custom_field: "custom_value",
|
||||||
|
data: { nested: "object" },
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
input: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
// Should show JSON stringified content
|
||||||
|
expect(
|
||||||
|
screen.getByText(/custom_field.*custom_value/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("(unknown_type)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows no output message when output array is empty", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [],
|
||||||
|
input: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("No output data available.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("groups function call with its output correctly", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "function_call",
|
||||||
|
id: "call_123",
|
||||||
|
status: "completed",
|
||||||
|
name: "get_weather",
|
||||||
|
arguments: '{"city": "Tokyo"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
call_id: "call_123",
|
||||||
|
content: "sunny and warm",
|
||||||
|
} as any, // Using any to bypass the type restriction for this test
|
||||||
|
],
|
||||||
|
input: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
// Should show the function call and message as separate items (not grouped)
|
||||||
|
expect(screen.getByText("Function Call")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('get_weather({"city": "Tokyo"})'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("sunny and warm")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should NOT have the grouped "Arguments" and "Output" labels
|
||||||
|
expect(screen.queryByText("Arguments")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("groups function call with function_call_output correctly", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "function_call",
|
||||||
|
call_id: "call_123",
|
||||||
|
status: "completed",
|
||||||
|
name: "get_weather",
|
||||||
|
arguments: '{"city": "Tokyo"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function_call_output",
|
||||||
|
id: "fc_68364957013081...",
|
||||||
|
status: "completed",
|
||||||
|
call_id: "call_123",
|
||||||
|
output: "sunny and warm",
|
||||||
|
} as any, // Using any to bypass the type restriction for this test
|
||||||
|
],
|
||||||
|
input: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
// Should show the function call grouped with its clean output
|
||||||
|
expect(screen.getByText("Function Call")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Arguments")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('get_weather({"city": "Tokyo"})'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// Use getAllByText since there are multiple "Output" elements (card title and output label)
|
||||||
|
const outputElements = screen.getAllByText("Output");
|
||||||
|
expect(outputElements.length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText("sunny and warm")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Edge Cases and Error Handling", () => {
|
||||||
|
test("handles missing role in message input", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [],
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
content: "Message without role",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Message without role")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Unknown")).toBeInTheDocument(); // Default role
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles missing name in function call output", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "function_call",
|
||||||
|
id: "call_123",
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
input: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||||
|
|
||||||
|
// When name is missing, it falls back to JSON.stringify of the entire output
|
||||||
|
const functionCallElements = screen.getAllByText(/function_call/);
|
||||||
|
expect(functionCallElements.length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText(/call_123/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
171
llama_stack/ui/components/responses/responses-detail.tsx
Normal file
171
llama_stack/ui/components/responses/responses-detail.tsx
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { OpenAIResponse, InputItemListResponse } from "@/lib/types";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
DetailLoadingView,
|
||||||
|
DetailErrorView,
|
||||||
|
DetailNotFoundView,
|
||||||
|
DetailLayout,
|
||||||
|
PropertiesCard,
|
||||||
|
PropertyItem,
|
||||||
|
} from "@/components/layout/detail-layout";
|
||||||
|
import { GroupedItemsDisplay } from "./grouping/grouped-items-display";
|
||||||
|
|
||||||
|
interface ResponseDetailViewProps {
|
||||||
|
response: OpenAIResponse | null;
|
||||||
|
inputItems: InputItemListResponse | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isLoadingInputItems: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
inputItemsError: Error | null;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponseDetailView({
|
||||||
|
response,
|
||||||
|
inputItems,
|
||||||
|
isLoading,
|
||||||
|
isLoadingInputItems,
|
||||||
|
error,
|
||||||
|
inputItemsError,
|
||||||
|
id,
|
||||||
|
}: ResponseDetailViewProps) {
|
||||||
|
const title = "Responses Details";
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <DetailErrorView title={title} id={id} error={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <DetailLoadingView title={title} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
return <DetailNotFoundView title={title} id={id} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main content cards
|
||||||
|
const mainContent = (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Input</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* Show loading state for input items */}
|
||||||
|
{isLoadingInputItems ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
) : inputItemsError ? (
|
||||||
|
<div className="text-red-500 text-sm">
|
||||||
|
Error loading input items: {inputItemsError.message}
|
||||||
|
<br />
|
||||||
|
<span className="text-gray-500 text-xs">
|
||||||
|
Falling back to response input data.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Display input items if available, otherwise fall back to response.input */}
|
||||||
|
{(() => {
|
||||||
|
const dataToDisplay =
|
||||||
|
inputItems?.data && inputItems.data.length > 0
|
||||||
|
? inputItems.data
|
||||||
|
: response.input;
|
||||||
|
|
||||||
|
if (dataToDisplay && dataToDisplay.length > 0) {
|
||||||
|
return (
|
||||||
|
<GroupedItemsDisplay
|
||||||
|
items={dataToDisplay}
|
||||||
|
keyPrefix="input"
|
||||||
|
defaultRole="unknown"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<p className="text-gray-500 italic text-sm">
|
||||||
|
No input data available.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Output</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{response.output?.length > 0 ? (
|
||||||
|
<GroupedItemsDisplay
|
||||||
|
items={response.output}
|
||||||
|
keyPrefix="output"
|
||||||
|
defaultRole="assistant"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 italic text-sm">
|
||||||
|
No output data available.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Properties sidebar
|
||||||
|
const sidebar = (
|
||||||
|
<PropertiesCard>
|
||||||
|
<PropertyItem
|
||||||
|
label="Created"
|
||||||
|
value={new Date(response.created_at * 1000).toLocaleString()}
|
||||||
|
/>
|
||||||
|
<PropertyItem label="ID" value={response.id} />
|
||||||
|
<PropertyItem label="Model" value={response.model} />
|
||||||
|
<PropertyItem label="Status" value={response.status} hasBorder />
|
||||||
|
{response.temperature && (
|
||||||
|
<PropertyItem
|
||||||
|
label="Temperature"
|
||||||
|
value={response.temperature}
|
||||||
|
hasBorder
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{response.top_p && <PropertyItem label="Top P" value={response.top_p} />}
|
||||||
|
{response.parallel_tool_calls && (
|
||||||
|
<PropertyItem
|
||||||
|
label="Parallel Tool Calls"
|
||||||
|
value={response.parallel_tool_calls ? "Yes" : "No"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{response.previous_response_id && (
|
||||||
|
<PropertyItem
|
||||||
|
label="Previous Response ID"
|
||||||
|
value={
|
||||||
|
<span className="text-xs">{response.previous_response_id}</span>
|
||||||
|
}
|
||||||
|
hasBorder
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{response.error && (
|
||||||
|
<PropertyItem
|
||||||
|
label="Error"
|
||||||
|
value={
|
||||||
|
<span className="text-red-900 font-medium">
|
||||||
|
{response.error.code}: {response.error.message}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
className="pt-1 mt-1 border-t border-red-200"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PropertiesCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailLayout title={title} mainContent={mainContent} sidebar={sidebar} />
|
||||||
|
);
|
||||||
|
}
|
537
llama_stack/ui/components/responses/responses-table.test.tsx
Normal file
537
llama_stack/ui/components/responses/responses-table.test.tsx
Normal file
|
@ -0,0 +1,537 @@
|
||||||
|
import React from "react";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { ResponsesTable } from "./responses-table";
|
||||||
|
import { OpenAIResponse } from "@/lib/types";
|
||||||
|
|
||||||
|
// Mock next/navigation
|
||||||
|
const mockPush = jest.fn();
|
||||||
|
jest.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
push: mockPush,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock helper functions
|
||||||
|
jest.mock("@/lib/truncate-text");
|
||||||
|
|
||||||
|
// Import the mocked functions
|
||||||
|
import { truncateText as originalTruncateText } from "@/lib/truncate-text";
|
||||||
|
|
||||||
|
// Cast to jest.Mock for typings
|
||||||
|
const truncateText = originalTruncateText as jest.Mock;
|
||||||
|
|
||||||
|
describe("ResponsesTable", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
data: [] as OpenAIResponse[],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset all mocks before each test
|
||||||
|
mockPush.mockClear();
|
||||||
|
truncateText.mockClear();
|
||||||
|
|
||||||
|
// Default pass-through implementation
|
||||||
|
truncateText.mockImplementation((text: string | undefined) => text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders without crashing with default props", () => {
|
||||||
|
render(<ResponsesTable {...defaultProps} />);
|
||||||
|
expect(screen.getByText("No responses found.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("click on a row navigates to the correct URL", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_123",
|
||||||
|
object: "response",
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
model: "llama-test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Test output",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: "Test input",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResponsesTable {...defaultProps} data={[mockResponse]} />);
|
||||||
|
|
||||||
|
const row = screen.getByText("Test input").closest("tr");
|
||||||
|
if (row) {
|
||||||
|
fireEvent.click(row);
|
||||||
|
expect(mockPush).toHaveBeenCalledWith("/logs/responses/resp_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(
|
||||||
|
<ResponsesTable {...defaultProps} isLoading={true} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for skeleton in the table caption
|
||||||
|
const tableCaption = container.querySelector("caption");
|
||||||
|
expect(tableCaption).toBeInTheDocument();
|
||||||
|
if (tableCaption) {
|
||||||
|
const captionSkeleton = tableCaption.querySelector(
|
||||||
|
'[data-slot="skeleton"]',
|
||||||
|
);
|
||||||
|
expect(captionSkeleton).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for skeletons in the table body cells
|
||||||
|
const tableBody = container.querySelector("tbody");
|
||||||
|
expect(tableBody).toBeInTheDocument();
|
||||||
|
if (tableBody) {
|
||||||
|
const bodySkeletons = tableBody.querySelectorAll(
|
||||||
|
'[data-slot="skeleton"]',
|
||||||
|
);
|
||||||
|
expect(bodySkeletons.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error State", () => {
|
||||||
|
test("renders error message when error prop is provided", () => {
|
||||||
|
const errorMessage = "Network Error";
|
||||||
|
render(
|
||||||
|
<ResponsesTable
|
||||||
|
{...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(
|
||||||
|
<ResponsesTable
|
||||||
|
{...defaultProps}
|
||||||
|
error={{ name: "Error", 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(<ResponsesTable {...defaultProps} error={{} as Error} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText("Error fetching data: An unknown error occurred"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Empty State", () => {
|
||||||
|
test('renders "No responses found." and no table when data array is empty', () => {
|
||||||
|
render(<ResponsesTable data={[]} isLoading={false} error={null} />);
|
||||||
|
expect(screen.getByText("No responses 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 response data correctly", () => {
|
||||||
|
const mockResponses = [
|
||||||
|
{
|
||||||
|
id: "resp_1",
|
||||||
|
object: "response" as const,
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "llama-test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "message" as const,
|
||||||
|
role: "assistant" as const,
|
||||||
|
content: "Test output",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: "Test input",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "resp_2",
|
||||||
|
object: "response" as const,
|
||||||
|
created_at: 1710001000,
|
||||||
|
model: "llama-another-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "message" as const,
|
||||||
|
role: "assistant" as const,
|
||||||
|
content: "Another output",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: "Another input",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ResponsesTable data={mockResponses} isLoading={false} error={null} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Table caption
|
||||||
|
expect(
|
||||||
|
screen.getByText("A list of your recent responses."),
|
||||||
|
).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("Input Text Extraction", () => {
|
||||||
|
test("extracts text from string content", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_string",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [{ type: "message", role: "assistant", content: "output" }],
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: "Simple string input",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Simple string input")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts text from array content with input_text type", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_array",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [{ type: "message", role: "assistant", content: "output" }],
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "input_text", text: "Array input text" },
|
||||||
|
{ type: "input_text", text: "Should not be used" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Array input text")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns empty string when no message input found", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_no_input",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [{ type: "message", role: "assistant", content: "output" }],
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "other_type",
|
||||||
|
content: "Not a message",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the input cell (first cell in the data row) and verify it's empty
|
||||||
|
const inputCell = container.querySelector("tbody tr td:first-child");
|
||||||
|
expect(inputCell).toBeInTheDocument();
|
||||||
|
expect(inputCell).toHaveTextContent("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Output Text Extraction", () => {
|
||||||
|
test("extracts text from string message content", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_string_output",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Simple string output",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
input: [{ type: "message", content: "input" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Simple string output")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts text from array message content with output_text type", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_array_output",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "output_text", text: "Array output text" },
|
||||||
|
{ type: "output_text", text: "Should not be used" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
input: [{ type: "message", content: "input" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Array output text")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats function call output", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_function_call",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "function_call",
|
||||||
|
id: "call_123",
|
||||||
|
status: "completed",
|
||||||
|
name: "search_function",
|
||||||
|
arguments: '{"query": "test"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
input: [{ type: "message", content: "input" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText('search_function({"query": "test"})'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats function call output without arguments", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_function_no_args",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "function_call",
|
||||||
|
id: "call_123",
|
||||||
|
status: "completed",
|
||||||
|
name: "simple_function",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
input: [{ type: "message", content: "input" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("simple_function({})")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats web search call output", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_web_search",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "web_search_call",
|
||||||
|
id: "search_123",
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
input: [{ type: "message", content: "input" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText("web_search_call(status: completed)"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to JSON.stringify for unknown tool call types", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_unknown_tool",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "unknown_call",
|
||||||
|
id: "unknown_123",
|
||||||
|
status: "completed",
|
||||||
|
custom_field: "custom_value",
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
input: [{ type: "message", content: "input" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||||
|
);
|
||||||
|
// Should contain the JSON stringified version
|
||||||
|
expect(screen.getByText(/unknown_call/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to JSON.stringify for entire output when no message or tool call found", () => {
|
||||||
|
const mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_fallback",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710000000,
|
||||||
|
model: "test-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "unknown_type",
|
||||||
|
data: "some data",
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
input: [{ type: "message", content: "input" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||||
|
);
|
||||||
|
// Should contain the JSON stringified version of the output array
|
||||||
|
expect(screen.getByText(/unknown_type/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Text Truncation", () => {
|
||||||
|
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 mockResponse: OpenAIResponse = {
|
||||||
|
id: "resp_trunc",
|
||||||
|
object: "response",
|
||||||
|
created_at: 1710002000,
|
||||||
|
model: "llama-trunc-model",
|
||||||
|
status: "completed",
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
content: longOutput,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: longInput,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ResponsesTable data={[mockResponse]} 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
|
||||||
|
truncatedTexts.forEach((textElement) =>
|
||||||
|
expect(textElement).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
117
llama_stack/ui/components/responses/responses-table.tsx
Normal file
117
llama_stack/ui/components/responses/responses-table.tsx
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
OpenAIResponse,
|
||||||
|
ResponseInput,
|
||||||
|
ResponseInputMessageContent,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import { LogsTable, LogTableRow } from "@/components/logs/logs-table";
|
||||||
|
import {
|
||||||
|
isMessageInput,
|
||||||
|
isMessageItem,
|
||||||
|
isFunctionCallItem,
|
||||||
|
isWebSearchCallItem,
|
||||||
|
MessageItem,
|
||||||
|
FunctionCallItem,
|
||||||
|
WebSearchCallItem,
|
||||||
|
} from "./utils/item-types";
|
||||||
|
|
||||||
|
interface ResponsesTableProps {
|
||||||
|
data: OpenAIResponse[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputText(response: OpenAIResponse): string {
|
||||||
|
const firstInput = response.input.find(isMessageInput);
|
||||||
|
if (firstInput) {
|
||||||
|
return extractContentFromItem(firstInput);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOutputText(response: OpenAIResponse): string {
|
||||||
|
const firstMessage = response.output.find((item) =>
|
||||||
|
isMessageItem(item as any),
|
||||||
|
);
|
||||||
|
if (firstMessage) {
|
||||||
|
const content = extractContentFromItem(firstMessage as MessageItem);
|
||||||
|
if (content) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const functionCall = response.output.find((item) =>
|
||||||
|
isFunctionCallItem(item as any),
|
||||||
|
);
|
||||||
|
if (functionCall) {
|
||||||
|
return formatFunctionCall(functionCall as FunctionCallItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
const webSearchCall = response.output.find((item) =>
|
||||||
|
isWebSearchCallItem(item as any),
|
||||||
|
);
|
||||||
|
if (webSearchCall) {
|
||||||
|
return formatWebSearchCall(webSearchCall as WebSearchCallItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(response.output);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractContentFromItem(item: {
|
||||||
|
content?: string | ResponseInputMessageContent[];
|
||||||
|
}): string {
|
||||||
|
if (!item.content) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item.content === "string") {
|
||||||
|
return item.content;
|
||||||
|
} else if (Array.isArray(item.content)) {
|
||||||
|
const textContent = item.content.find(
|
||||||
|
(c: ResponseInputMessageContent) =>
|
||||||
|
c.type === "input_text" || c.type === "output_text",
|
||||||
|
);
|
||||||
|
return textContent?.text || "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFunctionCall(functionCall: FunctionCallItem): string {
|
||||||
|
const args = functionCall.arguments || "{}";
|
||||||
|
const name = functionCall.name || "unknown";
|
||||||
|
return `${name}(${args})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWebSearchCall(webSearchCall: WebSearchCallItem): string {
|
||||||
|
return `web_search_call(status: ${webSearchCall.status})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResponseToRow(response: OpenAIResponse): LogTableRow {
|
||||||
|
return {
|
||||||
|
id: response.id,
|
||||||
|
input: getInputText(response),
|
||||||
|
output: getOutputText(response),
|
||||||
|
model: response.model,
|
||||||
|
createdTime: new Date(response.created_at * 1000).toLocaleString(),
|
||||||
|
detailPath: `/logs/responses/${response.id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponsesTable({
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
}: ResponsesTableProps) {
|
||||||
|
const formattedData = data.map(formatResponseToRow);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LogsTable
|
||||||
|
data={formattedData}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
caption="A list of your recent responses."
|
||||||
|
emptyMessage="No responses found."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
61
llama_stack/ui/components/responses/utils/item-types.ts
Normal file
61
llama_stack/ui/components/responses/utils/item-types.ts
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
/**
|
||||||
|
* Type guards for different item types in responses
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ResponseInput,
|
||||||
|
ResponseOutput,
|
||||||
|
ResponseMessage,
|
||||||
|
ResponseToolCall,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
export interface BaseItem {
|
||||||
|
type: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageItem = ResponseMessage;
|
||||||
|
export type FunctionCallItem = ResponseToolCall & { type: "function_call" };
|
||||||
|
export type WebSearchCallItem = ResponseToolCall & { type: "web_search_call" };
|
||||||
|
export type FunctionCallOutputItem = BaseItem & {
|
||||||
|
type: "function_call_output";
|
||||||
|
call_id: string;
|
||||||
|
output?: string | object;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnyResponseItem =
|
||||||
|
| ResponseInput
|
||||||
|
| ResponseOutput
|
||||||
|
| FunctionCallOutputItem;
|
||||||
|
|
||||||
|
export function isMessageInput(
|
||||||
|
item: ResponseInput,
|
||||||
|
): item is ResponseInput & { type: "message" } {
|
||||||
|
return item.type === "message";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMessageItem(item: AnyResponseItem): item is MessageItem {
|
||||||
|
return item.type === "message" && "content" in item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFunctionCallItem(
|
||||||
|
item: AnyResponseItem,
|
||||||
|
): item is FunctionCallItem {
|
||||||
|
return item.type === "function_call" && "name" in item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWebSearchCallItem(
|
||||||
|
item: AnyResponseItem,
|
||||||
|
): item is WebSearchCallItem {
|
||||||
|
return item.type === "web_search_call";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFunctionCallOutputItem(
|
||||||
|
item: AnyResponseItem,
|
||||||
|
): item is FunctionCallOutputItem {
|
||||||
|
return (
|
||||||
|
item.type === "function_call_output" &&
|
||||||
|
"call_id" in item &&
|
||||||
|
typeof (item as any).call_id === "string"
|
||||||
|
);
|
||||||
|
}
|
49
llama_stack/ui/components/ui/message-components.tsx
Normal file
49
llama_stack/ui/components/ui/message-components.tsx
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export interface MessageBlockProps {
|
||||||
|
label: string;
|
||||||
|
labelDetail?: string;
|
||||||
|
content: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MessageBlock: React.FC<MessageBlockProps> = ({
|
||||||
|
label,
|
||||||
|
labelDetail,
|
||||||
|
content,
|
||||||
|
className = "",
|
||||||
|
contentClassName = "",
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={`mb-4 ${className}`}>
|
||||||
|
<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 whitespace-pre-wrap ${contentClassName}`}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ToolCallBlockProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToolCallBlock = ({ children, className }: ToolCallBlockProps) => {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
12
llama_stack/ui/lib/client.ts
Normal file
12
llama_stack/ui/lib/client.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import LlamaStackClient from "llama-stack-client";
|
||||||
|
import OpenAI from "openai";
|
||||||
|
|
||||||
|
export const client =
|
||||||
|
process.env.NEXT_PUBLIC_USE_OPENAI_CLIENT === "true" // useful for testing
|
||||||
|
? new OpenAI({
|
||||||
|
apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY,
|
||||||
|
dangerouslyAllowBrowser: true,
|
||||||
|
})
|
||||||
|
: new LlamaStackClient({
|
||||||
|
baseURL: process.env.NEXT_PUBLIC_LLAMA_STACK_BASE_URL,
|
||||||
|
});
|
|
@ -43,10 +43,14 @@ export function extractDisplayableText(
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
let textPart = extractTextFromContentPart(message.content);
|
const textPart = extractTextFromContentPart(message.content);
|
||||||
let toolCallPart = "";
|
let toolCallPart = "";
|
||||||
|
|
||||||
if (message.tool_calls && message.tool_calls.length > 0) {
|
if (
|
||||||
|
message.tool_calls &&
|
||||||
|
Array.isArray(message.tool_calls) &&
|
||||||
|
message.tool_calls.length > 0
|
||||||
|
) {
|
||||||
// For summary, usually the first tool call is sufficient
|
// For summary, usually the first tool call is sufficient
|
||||||
toolCallPart = formatToolCallToString(message.tool_calls[0]);
|
toolCallPart = formatToolCallToString(message.tool_calls[0]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,20 +18,20 @@ export interface ImageUrlContentBlock {
|
||||||
export type ChatMessageContentPart =
|
export type ChatMessageContentPart =
|
||||||
| TextContentBlock
|
| TextContentBlock
|
||||||
| ImageUrlContentBlock
|
| ImageUrlContentBlock
|
||||||
| { type: string; [key: string]: any }; // Fallback for other potential types
|
| { type: string; [key: string]: unknown }; // Fallback for other potential types
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: string;
|
role: string;
|
||||||
content: string | ChatMessageContentPart[]; // Updated content type
|
content: string | ChatMessageContentPart[]; // Updated content type
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
tool_calls?: any | null; // This could also be refined to a more specific ToolCall[] type
|
tool_calls?: unknown | null; // This could also be refined to a more specific ToolCall[] type
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Choice {
|
export interface Choice {
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
finish_reason: string;
|
finish_reason: string;
|
||||||
index: number;
|
index: number;
|
||||||
logprobs?: any | null;
|
logprobs?: unknown | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatCompletion {
|
export interface ChatCompletion {
|
||||||
|
@ -42,3 +42,62 @@ export interface ChatCompletion {
|
||||||
model: string;
|
model: string;
|
||||||
input_messages: ChatMessage[];
|
input_messages: ChatMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Response types for OpenAI Responses API
|
||||||
|
export interface ResponseInputMessageContent {
|
||||||
|
text?: string;
|
||||||
|
type: "input_text" | "input_image" | "output_text";
|
||||||
|
image_url?: string;
|
||||||
|
detail?: "low" | "high" | "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseMessage {
|
||||||
|
content: string | ResponseInputMessageContent[];
|
||||||
|
role: "system" | "developer" | "user" | "assistant";
|
||||||
|
type: "message";
|
||||||
|
id?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseToolCall {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
type: "web_search_call" | "function_call";
|
||||||
|
arguments?: string;
|
||||||
|
call_id?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResponseOutput = ResponseMessage | ResponseToolCall;
|
||||||
|
|
||||||
|
export interface ResponseInput {
|
||||||
|
type: string;
|
||||||
|
content?: string | ResponseInputMessageContent[];
|
||||||
|
role?: string;
|
||||||
|
[key: string]: unknown; // Flexible for various input types
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenAIResponse {
|
||||||
|
id: string;
|
||||||
|
created_at: number;
|
||||||
|
model: string;
|
||||||
|
object: "response";
|
||||||
|
status: string;
|
||||||
|
output: ResponseOutput[];
|
||||||
|
input: ResponseInput[];
|
||||||
|
error?: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
parallel_tool_calls?: boolean;
|
||||||
|
previous_response_id?: string;
|
||||||
|
temperature?: number;
|
||||||
|
top_p?: number;
|
||||||
|
truncation?: string;
|
||||||
|
user?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputItemListResponse {
|
||||||
|
data: ResponseInput[];
|
||||||
|
object: "list";
|
||||||
|
}
|
||||||
|
|
52
llama_stack/ui/package-lock.json
generated
52
llama_stack/ui/package-lock.json
generated
|
@ -19,6 +19,7 @@
|
||||||
"lucide-react": "^0.510.0",
|
"lucide-react": "^0.510.0",
|
||||||
"next": "15.3.2",
|
"next": "15.3.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"openai": "^4.103.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"tailwind-merge": "^3.3.0"
|
"tailwind-merge": "^3.3.0"
|
||||||
|
@ -9092,7 +9093,7 @@
|
||||||
},
|
},
|
||||||
"node_modules/llama-stack-client": {
|
"node_modules/llama-stack-client": {
|
||||||
"version": "0.0.1-alpha.0",
|
"version": "0.0.1-alpha.0",
|
||||||
"resolved": "git+ssh://git@github.com/stainless-sdks/llama-stack-node.git#efa814980d44b3b2c92944377a086915137b2134",
|
"resolved": "git+ssh://git@github.com/stainless-sdks/llama-stack-node.git#5d34d229fb53b6dad02da0f19f4b310b529c6b15",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "^18.11.18",
|
"@types/node": "^18.11.18",
|
||||||
|
@ -9804,6 +9805,51 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openai": {
|
||||||
|
"version": "4.103.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/openai/-/openai-4.103.0.tgz",
|
||||||
|
"integrity": "sha512-eWcz9kdurkGOFDtd5ySS5y251H2uBgq9+1a2lTBnjMMzlexJ40Am5t6Mu76SSE87VvitPa0dkIAp75F+dZVC0g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^18.11.18",
|
||||||
|
"@types/node-fetch": "^2.6.4",
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"agentkeepalive": "^4.2.1",
|
||||||
|
"form-data-encoder": "1.7.2",
|
||||||
|
"formdata-node": "^4.3.2",
|
||||||
|
"node-fetch": "^2.6.7"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"openai": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"ws": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openai/node_modules/@types/node": {
|
||||||
|
"version": "18.19.103",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.103.tgz",
|
||||||
|
"integrity": "sha512-hHTHp+sEz6SxFsp+SA+Tqrua3AbmlAw+Y//aEwdHrdZkYVRWdvWD3y5uPZ0flYOkgskaFWqZ/YGFm3FaFQ0pRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~5.26.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openai/node_modules/undici-types": {
|
||||||
|
"version": "5.26.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||||
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
@ -12223,7 +12269,7 @@
|
||||||
"version": "8.18.2",
|
"version": "8.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",
|
||||||
"integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==",
|
"integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
@ -12334,7 +12380,7 @@
|
||||||
"version": "3.24.4",
|
"version": "3.24.4",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz",
|
||||||
"integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==",
|
"integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue