mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
Merge pull request #36793 from BerriAI/litellm_shadcn_logs_drawer_header_0813
refactor(ui): migrate SectionHeader and ToolsSection to shadcn
This commit is contained in:
commit
69b0296ca3
7 changed files with 228 additions and 129 deletions
|
|
@ -3828,11 +3828,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 2
|
||||
|
|
@ -3853,11 +3848,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/ToolsSection/ToolsSection.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/VectorStoreViewer.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -111,16 +111,12 @@ describe("InputCard", () => {
|
|||
|
||||
render(<InputCard messages={messages} />);
|
||||
|
||||
const copyButtons = screen.getAllByRole("button");
|
||||
const copyButton = copyButtons.find((button) => {
|
||||
const icon = button.querySelector('[aria-label="copy"]');
|
||||
return icon !== null;
|
||||
});
|
||||
const copyButton = screen.getByRole("button", { name: /copy/i });
|
||||
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(copyButton!);
|
||||
fireEvent.click(copyButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -209,11 +205,7 @@ describe("InputCard", () => {
|
|||
},
|
||||
];
|
||||
render(<InputCard messages={messages} />);
|
||||
const copyButtons = screen.getAllByRole("button");
|
||||
const copyButton = copyButtons.find((button) => {
|
||||
const icon = button.querySelector('[aria-label="copy"]');
|
||||
return icon !== null;
|
||||
});
|
||||
const copyButton = screen.getByRole("button", { name: /copy/i });
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -66,16 +66,12 @@ describe("OutputCard", () => {
|
|||
it("should copy message content when copy button is clicked", async () => {
|
||||
render(<OutputCard message={mockMessage} />);
|
||||
|
||||
const copyButtons = screen.getAllByRole("button");
|
||||
const copyButton = copyButtons.find((button) => {
|
||||
const icon = button.querySelector('[aria-label="copy"]');
|
||||
return icon !== null;
|
||||
});
|
||||
const copyButton = screen.getByRole("button", { name: /copy/i });
|
||||
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(copyButton!);
|
||||
fireEvent.click(copyButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -88,14 +84,10 @@ describe("OutputCard", () => {
|
|||
|
||||
render(<OutputCard message={null} />);
|
||||
|
||||
const copyButtons = screen.getAllByRole("button");
|
||||
const copyButton = copyButtons.find((button) => {
|
||||
const icon = button.querySelector('[aria-label="copy"]');
|
||||
return icon !== null;
|
||||
});
|
||||
const copyButton = screen.getByRole("button", { name: /copy/i });
|
||||
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
await user.click(copyButton!);
|
||||
await user.click(copyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SectionHeader } from "./SectionHeader";
|
||||
|
||||
describe("SectionHeader", () => {
|
||||
it("renders the input label with token, cost and turn metrics", () => {
|
||||
render(<SectionHeader type="input" tokens={1234} cost={0.000123} turnCount={3} onCopy={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("Input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tokens: 1,234")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cost: $0.000123")).toBeInTheDocument();
|
||||
expect(screen.getByText("Turns: 3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the output label", () => {
|
||||
render(<SectionHeader type="output" onCopy={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("Output")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits metrics that were not provided", () => {
|
||||
render(<SectionHeader type="input" onCopy={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByText(/^Tokens:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Cost:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Turns:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the turn count when there are no turns", () => {
|
||||
render(<SectionHeader type="input" turnCount={0} onCopy={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByText(/^Turns:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copies without toggling the section", async () => {
|
||||
const onCopy = vi.fn();
|
||||
const onToggleCollapse = vi.fn();
|
||||
render(<SectionHeader type="input" onCopy={onCopy} onToggleCollapse={onToggleCollapse} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /copy/i }));
|
||||
|
||||
expect(onCopy).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleCollapse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toggles the section when the header is clicked", async () => {
|
||||
const onToggleCollapse = vi.fn();
|
||||
render(<SectionHeader type="input" onCopy={vi.fn()} onToggleCollapse={onToggleCollapse} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Input"));
|
||||
|
||||
expect(onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stays inert when no toggle handler is given", async () => {
|
||||
const onCopy = vi.fn();
|
||||
render(<SectionHeader type="input" onCopy={onCopy} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Input"));
|
||||
|
||||
expect(onCopy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,10 +2,10 @@
|
|||
* SectionHeader - Datadog-style header with icon, label, metrics, and copy
|
||||
*/
|
||||
|
||||
import { Typography, Button, Tooltip } from "antd";
|
||||
import { MessageOutlined, CopyOutlined, DownOutlined, UpOutlined } from "@ant-design/icons";
|
||||
|
||||
const { Text } = Typography;
|
||||
import { ChevronDown, ChevronUp, Copy, MessageSquare } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
interface SectionHeaderProps {
|
||||
type: "input" | "output";
|
||||
|
|
@ -29,80 +29,57 @@ export function SectionHeader({
|
|||
return (
|
||||
<div
|
||||
onClick={onToggleCollapse}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 16px",
|
||||
borderBottom: isCollapsed ? "none" : "1px solid #f0f0f0",
|
||||
background: "#fafafa",
|
||||
cursor: onToggleCollapse ? "pointer" : "default",
|
||||
transition: "background 0.15s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (onToggleCollapse) {
|
||||
e.currentTarget.style.background = "#f5f5f5";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "#fafafa";
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-between bg-muted px-4 py-2.5 transition-colors",
|
||||
isCollapsed ? "border-b-0" : "border-b border-border",
|
||||
onToggleCollapse ? "cursor-pointer hover:bg-accent" : "cursor-default",
|
||||
)}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
{/* Collapse Arrow */}
|
||||
{onToggleCollapse && (
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
{isCollapsed ? (
|
||||
<DownOutlined style={{ fontSize: 10, color: "#8c8c8c" }} />
|
||||
) : (
|
||||
<UpOutlined style={{ fontSize: 10, color: "#8c8c8c" }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Icon + Label */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{type === "input" ? (
|
||||
<MessageOutlined style={{ color: "#8c8c8c", fontSize: 14 }} />
|
||||
<div className="flex items-center gap-4">
|
||||
{onToggleCollapse &&
|
||||
(isCollapsed ? (
|
||||
<ChevronDown className="size-2.5 text-muted-foreground" />
|
||||
) : (
|
||||
<span style={{ fontSize: 14, filter: "grayscale(1)", opacity: 0.6 }}>✨</span>
|
||||
<ChevronUp className="size-2.5 text-muted-foreground" />
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{type === "input" ? (
|
||||
<MessageSquare className="size-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<span className="text-sm opacity-60 grayscale">✨</span>
|
||||
)}
|
||||
<Text style={{ fontWeight: 500, fontSize: 14 }}>{type === "input" ? "Input" : "Output"}</Text>
|
||||
<span className="text-sm font-medium">{type === "input" ? "Input" : "Output"}</span>
|
||||
</div>
|
||||
|
||||
{/* Tokens */}
|
||||
{tokens !== undefined && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Tokens: {tokens.toLocaleString()}
|
||||
</Text>
|
||||
<span className="text-xs text-muted-foreground">Tokens: {tokens.toLocaleString()}</span>
|
||||
)}
|
||||
|
||||
{/* Cost */}
|
||||
{cost !== undefined && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Cost: ${cost.toFixed(6)}
|
||||
</Text>
|
||||
)}
|
||||
{cost !== undefined && <span className="text-xs text-muted-foreground">Cost: ${cost.toFixed(6)}</span>}
|
||||
|
||||
{/* Turn count */}
|
||||
{turnCount !== undefined && turnCount > 0 && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Turns: {turnCount}
|
||||
</Text>
|
||||
<span className="text-xs text-muted-foreground">Turns: {turnCount}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Copy Button */}
|
||||
<Tooltip title="Copy">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent triggering collapse
|
||||
onCopy();
|
||||
}}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Copy"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Copy />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,10 +2,46 @@
|
|||
* Core tests for Tools section
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseToolsFromLog } from "./utils";
|
||||
import { ToolsSection } from "./ToolsSection";
|
||||
import { LogEntry } from "../columns";
|
||||
|
||||
const logWithTools = (toolNames: string[], calledName?: string): LogEntry => ({
|
||||
request_id: "render-1",
|
||||
api_key: "key",
|
||||
team_id: "team",
|
||||
model: "gpt-4",
|
||||
model_id: "gpt-4",
|
||||
call_type: "completion",
|
||||
spend: 0.01,
|
||||
total_tokens: 100,
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 50,
|
||||
startTime: "2024-01-01T00:00:00Z",
|
||||
endTime: "2024-01-01T00:00:01Z",
|
||||
cache_hit: "none",
|
||||
messages: JSON.stringify({
|
||||
model: "gpt-4",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: toolNames.map((name) => ({
|
||||
type: "function",
|
||||
function: { name, description: `${name} description`, parameters: { type: "object", properties: {} } },
|
||||
})),
|
||||
}),
|
||||
response: JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: calledName
|
||||
? { tool_calls: [{ id: "call_1", type: "function", function: { name: calledName, arguments: "{}" } }] }
|
||||
: { content: "done" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
describe("ToolsSection", () => {
|
||||
it("should parse tools from request and match with response tool calls", () => {
|
||||
const mockLog: LogEntry = {
|
||||
|
|
@ -115,3 +151,51 @@ describe("ToolsSection", () => {
|
|||
expect(tools).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
const isShown = (text: string) => screen.queryAllByText(text).some((el) => el.closest("[hidden]") === null);
|
||||
|
||||
describe("ToolsSection rendering", () => {
|
||||
it("summarises how many tools were provided and called", () => {
|
||||
render(<ToolsSection log={logWithTools(["get_weather", "search_web"], "get_weather")} />);
|
||||
|
||||
expect(screen.getByText("Tools")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 provided, 1 called")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("previews the first two tool names", () => {
|
||||
render(<ToolsSection log={logWithTools(["alpha", "beta", "gamma"])} />);
|
||||
|
||||
expect(screen.getByText(/alpha, beta/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when the log has no tools", () => {
|
||||
const { container } = render(<ToolsSection log={logWithTools([])} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("reveals the tool list only after the section is expanded", async () => {
|
||||
render(<ToolsSection log={logWithTools(["get_weather", "search_web"], "get_weather")} />);
|
||||
|
||||
expect(isShown("called")).toBe(false);
|
||||
expect(isShown("not called")).toBe(false);
|
||||
|
||||
await userEvent.click(screen.getByText("Tools"));
|
||||
|
||||
await waitFor(() => expect(isShown("called")).toBe(true));
|
||||
expect(isShown("not called")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a tool's expanded detail across a close and reopen", async () => {
|
||||
render(<ToolsSection log={logWithTools(["get_weather", "search_web"], "get_weather")} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Tools"));
|
||||
await userEvent.click(await screen.findByText(/1\. get_weather/));
|
||||
expect(isShown("Description")).toBe(true);
|
||||
|
||||
await userEvent.click(screen.getByText("Tools"));
|
||||
await userEvent.click(screen.getByText("Tools"));
|
||||
|
||||
await waitFor(() => expect(isShown("Description")).toBe(true));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,18 +3,19 @@
|
|||
* and indicates which ones were actually called in the response
|
||||
*/
|
||||
|
||||
import { Collapse, Typography } from "antd";
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { LogEntry } from "../columns";
|
||||
import { parseToolsFromLog } from "./utils";
|
||||
import { ToolItem } from "./ToolItem";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ToolsSectionProps {
|
||||
log: LogEntry;
|
||||
}
|
||||
|
||||
export function ToolsSection({ log }: ToolsSectionProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const tools = parseToolsFromLog(log);
|
||||
|
||||
// Don't render if no tools
|
||||
|
|
@ -32,34 +33,33 @@ export function ToolsSection({ log }: ToolsSectionProps) {
|
|||
const hasMoreTools = tools.length > 2;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
||||
<h3 className="text-lg font-medium text-gray-900">Tools</h3>
|
||||
<Text type="secondary" style={{ fontSize: 14 }}>
|
||||
{totalTools} provided, {calledTools} called
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 14 }}>
|
||||
• {toolNamePreview}
|
||||
{hasMoreTools && "..."}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{tools.map((tool) => (
|
||||
<ToolItem key={tool.name} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="mb-6 w-full max-w-full overflow-hidden rounded-lg bg-background shadow-sm">
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted">
|
||||
{open ? (
|
||||
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h3 className="text-lg font-medium text-foreground">Tools</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{totalTools} provided, {calledTools} called
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
• {toolNamePreview}
|
||||
{hasMoreTools && "..."}
|
||||
</span>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent keepMounted>
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
{tools.map((tool) => (
|
||||
<ToolItem key={tool.name} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue