mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
refactor(ui): migrate agents to shadcn (#34365)
* test(ui): make the agents route's tests markup-agnostic before migration Rewrites the two assertions that were coupled to antd's DOM and adds the missing characterisation test for agent_cost_view, so the suite describes behaviour rather than antd markup and can stay untouched across the shadcn migration. The skill selection test reached the checkbox with a querySelector on input[type=checkbox]; antd renders an input while Base UI renders a span[role=checkbox], so it now queries by role and accessible name, which both libraries derive from the wrapping label. The delete confirmation test queried role=dialog; antd Modal is a dialog while Base UI AlertDialog is an alertdialog, so it now anchors on the confirmation text and accepts either role. agent_cost_view had no test at all; it gets one covering the null render, the dollar-prefixed values, the omitted rows, and a zero cost that must not be mistaken for unset. All 55 tests pass against the current antd components. * refactor(ui): migrate agents to shadcn Replaces antd and Tremor with shadcn (base-vega) primitives across the five files the agents route exclusively owns. Markup only; no behaviour, data fetching or route structure changes. Modal becomes AlertDialog, with a plain destructive Button in the footer rather than AlertDialogAction, because that action is AlertDialog.Close and would dismiss the dialog before the delete request settles, losing the in-flight state. Alert, Tag, Spin, Space, Collapse, Descriptions, Typography and the antd icons map onto alert, badge, ui-loading-spinner, flex/grid utilities, collapsible, a definition list, semantic headings and lucide. The shadcn CLI emits alert.tsx importing cva from class-variance-authority, which this project does not depend on; it uses the cva object syntax from lib/cva.config. The generated file fails to typecheck, so the adapted copy lives in components/shared instead, per the convention that ui/ stays CLI-managed. Colour comes from tokens throughout, so the info callout is now the neutral card style rather than antd's blue, and nothing hardcodes a colour in the way of a later theme change. The 55 tests in the route pass unchanged from the previous commit. The visual gate re-baselined agents and all 34 other routes stayed pixel-identical.
This commit is contained in:
parent
43e7b96b83
commit
07726b4f60
10 changed files with 402 additions and 255 deletions
|
|
@ -37,16 +37,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/agents/_components/AgentsPanel.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/agents/_components/AgentsTable.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/agents/_components/add_agent_form.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
@ -71,9 +61,6 @@
|
|||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/refs": {
|
||||
"count": 3
|
||||
},
|
||||
|
|
@ -84,9 +71,6 @@
|
|||
"src/app/(dashboard)/agents/_components/agent_cost_view.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/agents/_components/agent_form_fields.tsx": {
|
||||
|
|
@ -123,9 +107,6 @@
|
|||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/agents/_components/cost_config_fields.tsx": {
|
||||
|
|
|
|||
|
|
@ -140,8 +140,9 @@ describe("AgentsPanel", () => {
|
|||
await user.click(await screen.findByTestId("agent-actions-agent-9"));
|
||||
await user.click(await screen.findByTestId("agent-action-delete"));
|
||||
|
||||
const modal = await screen.findByRole("dialog");
|
||||
await user.click(within(modal).getByRole("button", { name: /^delete$/i }));
|
||||
const confirmPrompt = await screen.findByText(/are you sure you want to delete agent: Doomed Agent\?/i);
|
||||
const confirmDialog = confirmPrompt.closest('[role="dialog"],[role="alertdialog"]') as HTMLElement;
|
||||
await user.click(within(confirmDialog).getByRole("button", { name: /^delete$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(networking.deleteAgentCall).toHaveBeenCalledWith("test-token", "agent-9");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Modal, Alert } from "antd";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Info, Plus } from "lucide-react";
|
||||
import { getAgentsList, deleteAgentCall } from "@/components/networking";
|
||||
import AddAgentForm from "./add_agent_form";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
|
|
@ -9,6 +8,16 @@ import AgentsTable from "./AgentsTable";
|
|||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { Agent } from "@/components/agents/types";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AgentsPanelProps {
|
||||
|
|
@ -130,17 +139,18 @@ const AgentsPanel: React.FC<AgentsPanelProps> = ({ accessToken, userRole, teams
|
|||
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<h1 className="text-2xl font-bold">Agents</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
List of A2A-spec agents that are available to be used in your organization. Go to AI Hub, to make agents
|
||||
public.
|
||||
</p>
|
||||
<Alert
|
||||
message="Why do agents need keys?"
|
||||
description="Keys scope access to an agent and allow it to call MCP tools. Assign a key when creating an agent or from the Virtual Keys page."
|
||||
type="info"
|
||||
showIcon
|
||||
className="mb-3"
|
||||
/>
|
||||
<Alert className="mb-3">
|
||||
<Info />
|
||||
<AlertTitle>Why do agents need keys?</AlertTitle>
|
||||
<AlertDescription>
|
||||
Keys scope access to an agent and allow it to call MCP tools. Assign a key when creating an agent or from
|
||||
the Virtual Keys page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{isAdmin && (
|
||||
<div className="mt-2 flex items-center gap-4">
|
||||
<Button onClick={handleAddAgent} disabled={!accessToken}>
|
||||
|
|
@ -180,18 +190,27 @@ const AgentsPanel: React.FC<AgentsPanelProps> = ({ accessToken, userRole, teams
|
|||
/>
|
||||
|
||||
{agentToDelete && (
|
||||
<Modal
|
||||
title="Delete Agent"
|
||||
open={agentToDelete !== null}
|
||||
onOk={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
confirmLoading={isDeleting}
|
||||
okText="Delete"
|
||||
okButtonProps={{ danger: true }}
|
||||
<AlertDialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleDeleteCancel();
|
||||
}}
|
||||
>
|
||||
<p>Are you sure you want to delete agent: {agentToDelete.name}?</p>
|
||||
<p>This action cannot be undone.</p>
|
||||
</Modal>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Agent</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete agent: {agentToDelete.name}? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<Button variant="destructive" onClick={handleDeleteConfirm} disabled={isDeleting}>
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Tooltip, Switch } from "antd";
|
||||
import { CheckCircleOutlined } from "@ant-design/icons";
|
||||
import { Bot } from "lucide-react";
|
||||
import { Bot, CircleCheck } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import { Agent } from "@/components/agents/types";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
import { getAgentsTableColumns } from "./AgentsTableColumns";
|
||||
|
||||
|
|
@ -67,18 +67,27 @@ const AgentsTable: React.FC<AgentsTableProps> = ({
|
|||
size="compact"
|
||||
toolbar={() => (
|
||||
<div className="flex items-center justify-end">
|
||||
<Tooltip title="When enabled, only agents with reachable URLs are shown">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleOutlined className={healthCheckEnabled ? "text-green-500" : "text-muted-foreground"} />
|
||||
<span className="text-sm text-muted-foreground">Health Check</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={healthCheckEnabled}
|
||||
onChange={onHealthCheckToggle}
|
||||
loading={isHealthCheckLoading}
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleCheck
|
||||
className={healthCheckEnabled ? "size-4 text-green-500" : "size-4 text-muted-foreground"}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Health Check</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={healthCheckEnabled}
|
||||
onCheckedChange={onHealthCheckToggle}
|
||||
disabled={isHealthCheckLoading}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<TooltipContent>When enabled, only agents with reachable URLs are shown</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -126,10 +126,7 @@ describe("AgentCardDiscovery", () => {
|
|||
expect(initialSelection.upstream_url).toBe("https://upstream.example.com");
|
||||
expect(initialSelection.selected_card.skills).toHaveLength(2);
|
||||
|
||||
const summarizeLabel = screen.getByText("Summarize").closest("label");
|
||||
expect(summarizeLabel).toBeTruthy();
|
||||
const summarizeCheckbox = summarizeLabel!.querySelector("input[type='checkbox']") as HTMLInputElement;
|
||||
await user.click(summarizeCheckbox);
|
||||
await user.click(screen.getByRole("checkbox", { name: /Summarize/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const latest = onApply.mock.calls.at(-1)?.[0];
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Button, Checkbox, Collapse, Empty, Input, Space, Spin, Switch, Tag, Tooltip, Typography } from "antd";
|
||||
// Empty is used in the skills panel below.
|
||||
import {
|
||||
CheckCircleTwoTone,
|
||||
InfoCircleOutlined,
|
||||
LinkOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { ChevronDown, CircleAlert, CircleCheck, Info, Link as LinkIcon, RotateCw, Search, X } from "lucide-react";
|
||||
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { DiscoveredAgentCard, discoverAgentCardCall } from "@/components/networking";
|
||||
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import {
|
||||
ALLOWED_CAPABILITY_KEYS,
|
||||
selectionsFromSavedAgentCard,
|
||||
|
|
@ -20,9 +22,6 @@ import {
|
|||
skillId,
|
||||
} from "./agent_discovery_utils";
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
const { Panel } = Collapse;
|
||||
|
||||
const DISCOVERY_DEBOUNCE_WAIT_MS = 400;
|
||||
|
||||
export interface DiscoveredAgentCardSelection {
|
||||
|
|
@ -243,102 +242,115 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
const skillCount = card?.skills?.length ?? 0;
|
||||
const selectedSkillCount = selectedSkillIds.size;
|
||||
|
||||
const renderDiscoverIcon = () => {
|
||||
if (loading) return <UiLoadingSpinner className="size-4" />;
|
||||
if (card) return <RotateCw />;
|
||||
return <Search />;
|
||||
};
|
||||
const discoverLabel = card ? "Re-discover" : "Discover";
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-4 bg-gray-50 mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<LinkOutlined className="text-indigo-600" />
|
||||
<Text strong>Discover from agent URL</Text>
|
||||
<Tooltip title="LiteLLM will fetch /.well-known/agent-card.json from this URL and let you pick which skills and capabilities to expose through the proxy.">
|
||||
<InfoCircleOutlined className="text-gray-400" />
|
||||
</Tooltip>
|
||||
<div className="mb-4 rounded-lg border border-border bg-muted/50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<LinkIcon className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium text-foreground">Discover from agent URL</span>
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="inline-flex text-muted-foreground">
|
||||
<Info className="size-4" />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
LiteLLM will fetch /.well-known/agent-card.json from this URL and let you pick which skills and
|
||||
capabilities to expose through the proxy.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
{isParentDriven ? (
|
||||
<>
|
||||
<Paragraph className="text-xs text-gray-500 mb-2">
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Using the connection details you entered above. We'll fetch:
|
||||
</Paragraph>
|
||||
<div className="bg-white border border-gray-200 rounded-sm px-3 py-2 mb-3 font-mono text-xs text-gray-700 break-all">
|
||||
</p>
|
||||
<div className="mb-3 rounded-sm border border-border bg-background px-3 py-2 font-mono text-xs break-all text-foreground">
|
||||
{discoveryRequest!.display_url || effectiveUrl || (
|
||||
<span className="text-gray-400 italic">Fill in the fields above first</span>
|
||||
<span className="text-muted-foreground italic">Fill in the fields above first</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={card ? <ReloadOutlined /> : <SearchOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleDiscover}
|
||||
disabled={!effectiveUrl.trim()}
|
||||
>
|
||||
{card ? "Re-discover" : "Discover"}
|
||||
<Button onClick={handleDiscover} disabled={loading || !effectiveUrl.trim()}>
|
||||
{renderDiscoverIcon()}
|
||||
{discoverLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Paragraph className="text-xs text-gray-500 mb-3">
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Paste the upstream agent's base URL. We'll try <code>/.well-known/agent-card.json</code>,{" "}
|
||||
<code>/.well-known/agent.json</code>, and <code>/agent.json</code> in order.
|
||||
</Paragraph>
|
||||
</p>
|
||||
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Input
|
||||
placeholder="https://upstream-agent.example.com"
|
||||
value={manualUrl}
|
||||
onChange={(e) => setManualUrl(e.target.value)}
|
||||
onPressEnter={handleDiscover}
|
||||
allowClear
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleDiscover();
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={card ? <ReloadOutlined /> : <SearchOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleDiscover}
|
||||
>
|
||||
{card ? "Re-discover" : "Discover"}
|
||||
<Button onClick={handleDiscover} disabled={loading}>
|
||||
{renderDiscoverIcon()}
|
||||
{discoverLabel}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
className="mt-3"
|
||||
type="error"
|
||||
message="Discovery failed"
|
||||
description={error}
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setError(null)}
|
||||
/>
|
||||
<Alert variant="destructive" className="mt-3">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Discovery failed</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button variant="ghost" size="icon-xs" aria-label="Dismiss error" onClick={() => setError(null)}>
|
||||
<X />
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading && !card && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spin />
|
||||
<UiLoadingSpinner className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{card && (
|
||||
<div className="mt-4 bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Space>
|
||||
<CheckCircleTwoTone twoToneColor="#52c41a" />
|
||||
<Text strong>Upstream card loaded</Text>
|
||||
{card.version && <Tag color="blue">v{card.version}</Tag>}
|
||||
{card.provider?.organization && <Tag color="purple">{card.provider.organization}</Tag>}
|
||||
</Space>
|
||||
<div className="mt-4 rounded-lg border border-border bg-background p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<CircleCheck className="size-4 text-green-600" />
|
||||
<span className="text-sm font-medium text-foreground">Upstream card loaded</span>
|
||||
{card.version && <Badge variant="secondary">v{card.version}</Badge>}
|
||||
{card.provider?.organization && <Badge variant="secondary">{card.provider.organization}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
|
||||
<div className="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Name (shown to API clients)</label>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||
Name (shown to API clients)
|
||||
</label>
|
||||
<Input value={editedName} onChange={(e) => setEditedName(e.target.value)} placeholder="Agent name" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Description</label>
|
||||
<Input.TextArea
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">Description</label>
|
||||
<Textarea
|
||||
className="field-sizing-fixed min-h-0"
|
||||
value={editedDescription}
|
||||
onChange={(e) => setEditedDescription(e.target.value)}
|
||||
rows={2}
|
||||
|
|
@ -347,103 +359,114 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Collapse defaultActiveKey={["skills", "capabilities"]} ghost className="bg-transparent">
|
||||
<Panel
|
||||
key="skills"
|
||||
header={
|
||||
<Space>
|
||||
<Text strong>Skills</Text>
|
||||
<Tag>
|
||||
{selectedSkillCount} / {skillCount} selected
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{skillCount === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Upstream card has no skills" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(card.skills ?? []).map((skill, idx) => {
|
||||
const id = skillId(skill, idx);
|
||||
const checked = selectedSkillIds.has(id);
|
||||
return (
|
||||
<label
|
||||
key={id}
|
||||
className={`flex items-start gap-3 p-3 border rounded cursor-pointer transition-colors ${
|
||||
checked ? "border-indigo-300 bg-indigo-50" : "border-gray-200 bg-white hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Checkbox checked={checked} onChange={(e) => toggleSkill(id, e.target.checked)} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Text strong>{skill.name || id}</Text>
|
||||
{skill.id && <Tag style={{ marginLeft: 0 }}>{skill.id}</Tag>}
|
||||
{(skill.tags ?? []).map((t: string) => (
|
||||
<Tag key={t} color="geekblue">
|
||||
{t}
|
||||
</Tag>
|
||||
))}
|
||||
<div className="flex flex-col gap-4">
|
||||
<Collapsible defaultOpen>
|
||||
<div className="flex items-center gap-2">
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<button type="button" className="group flex items-center gap-2">
|
||||
<ChevronDown className="size-4 text-muted-foreground transition-transform group-data-[panel-open]:rotate-180" />
|
||||
<span className="text-sm font-medium text-foreground">Skills</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Badge variant="secondary">
|
||||
{selectedSkillCount} / {skillCount} selected
|
||||
</Badge>
|
||||
</div>
|
||||
<CollapsibleContent className="pt-2">
|
||||
{skillCount === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">Upstream card has no skills</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(card.skills ?? []).map((skill, idx) => {
|
||||
const id = skillId(skill, idx);
|
||||
const checked = selectedSkillIds.has(id);
|
||||
return (
|
||||
<label
|
||||
key={id}
|
||||
className={`flex cursor-pointer items-start gap-3 rounded border p-3 transition-colors ${
|
||||
checked ? "border-primary/40 bg-primary/5" : "border-border bg-background hover:border-ring"
|
||||
}`}
|
||||
>
|
||||
<Checkbox checked={checked} onCheckedChange={(next) => toggleSkill(id, next)} />
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{skill.name || id}</span>
|
||||
{skill.id && <Badge variant="secondary">{skill.id}</Badge>}
|
||||
{(skill.tags ?? []).map((t: string) => (
|
||||
<Badge key={t} variant="outline">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{skill.description && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{skill.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{skill.description && (
|
||||
<Paragraph
|
||||
className="text-xs text-gray-500 mt-1 mb-0"
|
||||
ellipsis={{ rows: 2, expandable: true, symbol: "more" }}
|
||||
>
|
||||
{skill.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible defaultOpen>
|
||||
<div className="flex items-center gap-2">
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<button type="button" className="group flex items-center gap-2">
|
||||
<ChevronDown className="size-4 text-muted-foreground transition-transform group-data-[panel-open]:rotate-180" />
|
||||
<span className="text-sm font-medium text-foreground">Capabilities</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="inline-flex text-muted-foreground">
|
||||
<Info className="size-4" />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
Only capabilities LiteLLM can faithfully proxy today are listed. Others (push notifications,
|
||||
extensions) are coming soon.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<div className="space-y-2">
|
||||
{ALLOWED_CAPABILITY_KEYS.map((key) => {
|
||||
const upstreamHas = Boolean(card.capabilities?.[key]);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between rounded-sm border border-border bg-background p-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground capitalize">{key}</span>
|
||||
{!upstreamHas && <Badge variant="outline">not advertised upstream</Badge>}
|
||||
</div>
|
||||
</label>
|
||||
<Switch
|
||||
checked={Boolean(selectedCapabilities[key])}
|
||||
onCheckedChange={(checked) =>
|
||||
setSelectedCapabilities((prev) => ({
|
||||
...prev,
|
||||
[key]: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
key="capabilities"
|
||||
header={
|
||||
<Space>
|
||||
<Text strong>Capabilities</Text>
|
||||
<Tooltip title="Only capabilities LiteLLM can faithfully proxy today are listed. Others (push notifications, extensions) are coming soon.">
|
||||
<InfoCircleOutlined className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{ALLOWED_CAPABILITY_KEYS.map((key) => {
|
||||
const upstreamHas = Boolean(card.capabilities?.[key]);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between p-2 border border-gray-200 rounded-sm bg-white"
|
||||
>
|
||||
<div>
|
||||
<Text strong className="capitalize">
|
||||
{key}
|
||||
</Text>
|
||||
{!upstreamHas && (
|
||||
<Tag className="ml-2" color="default">
|
||||
not advertised upstream
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(selectedCapabilities[key])}
|
||||
onChange={(checked) =>
|
||||
setSelectedCapabilities((prev) => ({
|
||||
...prev,
|
||||
[key]: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import AgentCostView from "./agent_cost_view";
|
||||
import type { Agent } from "@/components/agents/types";
|
||||
|
||||
const makeAgent = (litellmParams: Agent["litellm_params"]): Agent => ({
|
||||
agent_id: "agent-1",
|
||||
agent_name: "Test Agent",
|
||||
litellm_params: litellmParams,
|
||||
});
|
||||
|
||||
describe("AgentCostView", () => {
|
||||
it("renders nothing when the agent has no cost configuration at all", () => {
|
||||
const { container } = renderWithProviders(<AgentCostView agent={makeAgent({ model: "gpt-4" })} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders every configured cost with a dollar-prefixed value", () => {
|
||||
const fullyPricedParams = {
|
||||
model: "gpt-4",
|
||||
cost_per_query: 0.05,
|
||||
input_cost_per_token: 0.000012,
|
||||
output_cost_per_token: 0.000034,
|
||||
};
|
||||
renderWithProviders(<AgentCostView agent={makeAgent(fullyPricedParams)} />);
|
||||
|
||||
expect(screen.getByText("Cost Configuration")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cost Per Query")).toBeInTheDocument();
|
||||
expect(screen.getByText("$0.05")).toBeInTheDocument();
|
||||
expect(screen.getByText("Input Cost Per Token")).toBeInTheDocument();
|
||||
expect(screen.getByText("$0.000012")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output Cost Per Token")).toBeInTheDocument();
|
||||
expect(screen.getByText("$0.000034")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the rows whose cost is not configured", () => {
|
||||
renderWithProviders(<AgentCostView agent={makeAgent({ model: "gpt-4", cost_per_query: 0.25 })} />);
|
||||
|
||||
expect(screen.getByText("Cost Per Query")).toBeInTheDocument();
|
||||
expect(screen.getByText("$0.25")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Input Cost Per Token")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Output Cost Per Token")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still renders a zero cost rather than treating it as unset", () => {
|
||||
renderWithProviders(<AgentCostView agent={makeAgent({ model: "gpt-4", cost_per_query: 0 })} />);
|
||||
|
||||
expect(screen.getByText("Cost Configuration")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cost Per Query")).toBeInTheDocument();
|
||||
expect(screen.getByText("$0")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import React from "react";
|
||||
import { Title } from "@tremor/react";
|
||||
import { Descriptions } from "antd";
|
||||
import { Agent } from "@/components/agents/types";
|
||||
|
||||
interface AgentCostViewProps {
|
||||
|
|
@ -18,20 +16,25 @@ const AgentCostView: React.FC<AgentCostViewProps> = ({ agent }) => {
|
|||
return null;
|
||||
}
|
||||
|
||||
const rows = (
|
||||
[
|
||||
["Cost Per Query", params.cost_per_query],
|
||||
["Input Cost Per Token", params.input_cost_per_token],
|
||||
["Output Cost Per Token", params.output_cost_per_token],
|
||||
] as const
|
||||
).filter(([, value]) => value !== undefined);
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Title>Cost Configuration</Title>
|
||||
<Descriptions bordered column={1} style={{ marginTop: 16 }}>
|
||||
{params.cost_per_query !== undefined && (
|
||||
<Descriptions.Item label="Cost Per Query">${params.cost_per_query}</Descriptions.Item>
|
||||
)}
|
||||
{params.input_cost_per_token !== undefined && (
|
||||
<Descriptions.Item label="Input Cost Per Token">${params.input_cost_per_token}</Descriptions.Item>
|
||||
)}
|
||||
{params.output_cost_per_token !== undefined && (
|
||||
<Descriptions.Item label="Output Cost Per Token">${params.output_cost_per_token}</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold text-foreground">Cost Configuration</h3>
|
||||
<dl className="mt-4 divide-y divide-border overflow-hidden rounded-lg border border-border">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="grid grid-cols-1 sm:grid-cols-3">
|
||||
<dt className="bg-muted/50 px-4 py-3 text-sm font-medium text-foreground">{label}</dt>
|
||||
<dd className="px-4 py-3 text-sm text-foreground sm:col-span-2">${value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import React from "react";
|
||||
import { Button, Tooltip, Typography } from "antd";
|
||||
import { KeyOutlined } from "@ant-design/icons";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { KeyResponse } from "@/components/key_team_helpers/key_list";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface AgentVirtualKeysProps {
|
||||
keys: KeyResponse[];
|
||||
|
|
@ -13,29 +12,31 @@ interface AgentVirtualKeysProps {
|
|||
|
||||
const AgentVirtualKeys: React.FC<AgentVirtualKeysProps> = ({ keys, isLoading, onKeyClick }) => {
|
||||
return (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Title level={4}>Virtual Keys</Title>
|
||||
<div className="mt-6">
|
||||
<h4 className="text-base font-semibold text-foreground">Virtual Keys</h4>
|
||||
{isLoading ? (
|
||||
<Text className="mt-2 block">Loading keys...</Text>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Loading keys...</p>
|
||||
) : keys.length === 0 ? (
|
||||
<Text className="mt-2 block text-gray-500">No virtual key assigned to this agent.</Text>
|
||||
<p className="mt-2 text-sm text-muted-foreground">No virtual key assigned to this agent.</p>
|
||||
) : (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{keys.map((key) => (
|
||||
<div key={key.token} className="flex items-center gap-3 border border-gray-100 rounded-sm px-3 py-2">
|
||||
<KeyOutlined className="text-gray-400" />
|
||||
<span className="font-medium">{key.key_alias || "Unnamed key"}</span>
|
||||
{key.key_name && <span className="font-mono text-xs text-gray-500">{key.key_name}</span>}
|
||||
<Tooltip title={key.token}>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
className="font-mono text-blue-500 ml-auto"
|
||||
onClick={() => onKeyClick(key)}
|
||||
>
|
||||
{key.token?.slice(0, 12)}...
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<div key={key.token} className="flex items-center gap-3 rounded-sm border border-border px-3 py-2">
|
||||
<KeyRound className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">{key.key_alias || "Unnamed key"}</span>
|
||||
{key.key_name && <span className="font-mono text-xs text-muted-foreground">{key.key_name}</span>}
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="link" size="sm" className="ml-auto font-mono" onClick={() => onKeyClick(key)}>
|
||||
{key.token?.slice(0, 12)}...
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{key.token}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
59
ui/litellm-dashboard/src/components/shared/Alert.tsx
Normal file
59
ui/litellm-dashboard/src/components/shared/Alert.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import * as React from "react";
|
||||
import { type VariantProps } from "cva";
|
||||
|
||||
import { cn, cva } from "@/lib/cva.config";
|
||||
|
||||
const alertVariants = cva({
|
||||
base: "group/alert relative grid w-full gap-0.5 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive: "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
type AlertProps = React.ComponentProps<"div"> & VariantProps<typeof alertVariants>;
|
||||
|
||||
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
const AlertAction = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} data-slot="alert-action" className={cn("absolute top-2.5 right-3", className)} {...props} />
|
||||
));
|
||||
AlertAction.displayName = "AlertAction";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction };
|
||||
Loading…
Add table
Reference in a new issue