From ae8b10cf8d00e6d41f8251b8639f2c1bc282237b Mon Sep 17 00:00:00 2001 From: cursor Date: Fri, 24 Apr 2026 07:57:27 +0000 Subject: [PATCH] feat(ui): migrate agents subtree to shadcn Co-authored-by: yuneng-jiang --- ui/litellm-dashboard/.eslintrc.json | 1 + .../src/components/agents/add_agent_form.tsx | 1442 +++++++++++------ .../components/agents/agent_form_fields.tsx | 785 ++++++--- .../src/components/agents/agent_info.tsx | 402 +++-- .../components/agents/cost_config_fields.tsx | 41 +- .../agents/dynamic_agent_form_fields.tsx | 219 ++- 6 files changed, 1923 insertions(+), 967 deletions(-) diff --git a/ui/litellm-dashboard/.eslintrc.json b/ui/litellm-dashboard/.eslintrc.json index e0d553ac384..d9efd3808ab 100644 --- a/ui/litellm-dashboard/.eslintrc.json +++ b/ui/litellm-dashboard/.eslintrc.json @@ -52,6 +52,7 @@ "src/components/common_components/AutoRotationView.tsx", "src/components/agents/agent_card.tsx", "src/components/agents/agent_table.tsx", + "src/components/agents/add_agent_form.tsx", "src/components/view_users/columns.tsx", "src/components/Projects/ProjectsPage.tsx", "src/components/pass_through_settings.tsx", diff --git a/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx b/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx index 891e9c574d8..826bd123a81 100644 --- a/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx @@ -1,9 +1,47 @@ import React, { useState, useEffect } from "react"; -import { Modal, Form, Select, Input, Steps, Radio, Tag, Divider, Switch, InputNumber, Collapse } from "antd"; -// eslint-disable-next-line litellm-ui/no-banned-ui-imports +import { + Controller, + FormProvider, + useForm, + UseFormReturn, +} from "react-hook-form"; import MessageManager from "@/components/molecules/message_manager"; -import { Button } from "@tremor/react"; -import { CheckCircle2 as CheckCircleFilled, Key as KeyOutlined, Bot as RobotOutlined, LayoutGrid as AppstoreOutlined, Info as InfoCircleOutlined } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Check, + CheckCircle2, + Info, + Key as KeyIcon, + LayoutGrid, + Bot, + X, +} from "lucide-react"; import CreatedKeyDisplay from "../shared/CreatedKeyDisplay"; import { createAgentCall, @@ -26,8 +64,6 @@ import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions"; import GuardrailSelector from "../guardrails/GuardrailSelector"; -const { Step } = Steps; - const CUSTOM_AGENT_TYPE = "custom"; interface AddAgentFormProps { @@ -38,45 +74,310 @@ interface AddAgentFormProps { teams?: Team[] | null; } +const STEP_LABELS = [ + "Configure", + "Entitlements", + "Governance", + "Agent Management", + "Ready", +]; + +const InfoTip: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + + + + + {children} + + +); + +function Stepper({ current, steps }: { current: number; steps: string[] }) { + return ( +
    + {steps.map((label, i) => { + const active = i === current; + const completed = i < current; + return ( +
  1. +
    + {completed ? : i + 1} +
    + + {label} + + {i < steps.length - 1 && ( +
    + )} +
  2. + ); + })} +
+ ); +} + +/** + * Shadcn-Select-backed multi-select with chip list under the trigger. Shares + * the pattern with AccessGroupBaseForm.tsx. + */ +function ChipMultiSelect({ + value, + onChange, + options, + placeholder, + emptyText = "No options", + loading = false, +}: { + value: string[]; + onChange: (next: string[]) => void; + options: { label: string; value: string }[]; + placeholder: string; + emptyText?: string; + loading?: boolean; +}) { + const selected = value ?? []; + const remaining = options.filter((o) => !selected.includes(o.value)); + return ( +
+ + {selected.length > 0 && ( +
+ {selected.map((v) => { + const opt = options.find((o) => o.value === v); + return ( + + {opt?.label ?? v} + + + ); + })} +
+ )} +
+ ); +} + +/** + * Free-form tag entry (models entitlement accepts arbitrary strings). + * Variant that also accepts an options list for suggestions. + */ +function FreeformTagsWithOptions({ + value, + onChange, + options, + placeholder, + loading = false, +}: { + value: string[]; + onChange: (next: string[]) => void; + options: { label: string; value: string }[]; + placeholder: string; + loading?: boolean; +}) { + const [draft, setDraft] = useState(""); + const selected = value ?? []; + const remaining = options.filter((o) => !selected.includes(o.value)); + return ( +
+
+ { + const v = e.target.value; + if (v.includes(",")) { + const parts = v + .split(",") + .map((s) => s.trim()) + .filter((s) => s && !selected.includes(s)); + if (parts.length > 0) onChange([...selected, ...parts]); + setDraft(""); + } else { + setDraft(v); + } + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + const v = draft.trim(); + if (v && !selected.includes(v)) { + onChange([...selected, v]); + } + setDraft(""); + } + }} + onBlur={() => { + const v = draft.trim(); + if (v && !selected.includes(v)) { + onChange([...selected, v]); + } + setDraft(""); + }} + /> + +
+ {selected.length > 0 && ( +
+ {selected.map((v) => { + const opt = options.find((o) => o.value === v); + return ( + + {opt?.label ?? v} + + + ); + })} +
+ )} +
+ ); +} + +type FormValues = Record; + +const getInitialFormValues = () => ({ + ...getDefaultFormValues(), + allowed_mcp_servers_and_groups: { servers: [], accessGroups: [] }, + mcp_tool_permissions: {}, + entitlement_models: [], + entitlement_agents: [], + guardrails: [], +}); + const AddAgentForm: React.FC = ({ visible, onClose, accessToken, onSuccess, - teams, }) => { const { userId, userRole } = useAuthorized(); - const [form] = Form.useForm(); + const form: UseFormReturn = useForm({ + defaultValues: getInitialFormValues(), + }); + const { register, control, watch, trigger, getValues, reset, formState } = + form; const [currentStep, setCurrentStep] = useState(0); const [isSubmitting, setIsSubmitting] = useState(false); const [agentType, setAgentType] = useState("a2a"); const [agentTypeMetadata, setAgentTypeMetadata] = useState([]); - const [loadingMetadata, setLoadingMetadata] = useState(false); + const [, setLoadingMetadata] = useState(false); - // Step 3: key assignment state const [keyAssignOption, setKeyAssignOption] = useState<"create_new" | "existing_key" | "skip">("create_new"); const [newKeyName, setNewKeyName] = useState(""); - const [newKeyModels, setNewKeyModels] = useState([]); + const [newKeyModels] = useState([]); const [existingKeys, setExistingKeys] = useState([]); const [selectedExistingKey, setSelectedExistingKey] = useState(null); const [loadingKeys, setLoadingKeys] = useState(false); const [availableModels, setAvailableModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); - const [availableAgents, setAvailableAgents] = useState<{agent_id: string; agent_name: string}[]>([]); + const [availableAgents, setAvailableAgents] = useState< + { agent_id: string; agent_name: string }[] + >([]); const [loadingAgents, setLoadingAgents] = useState(false); - // Step 4: results const [createdAgentName, setCreatedAgentName] = useState(""); const [createdKeyValue, setCreatedKeyValue] = useState(null); const [assignedKeyAlias, setAssignedKeyAlias] = useState(null); - // Tracing & guardrails state const [requireTraceIdInbound, setRequireTraceIdInbound] = useState(false); const [requireTraceIdOutbound, setRequireTraceIdOutbound] = useState(false); const [maxIterations, setMaxIterations] = useState(null); const [maxBudgetPerSession, setMaxBudgetPerSession] = useState(null); - // Fetch agent type metadata on mount useEffect(() => { const fetchMetadata = async () => { setLoadingMetadata(true); @@ -92,7 +393,6 @@ const AddAgentForm: React.FC = ({ fetchMetadata(); }, []); - // Fetch existing keys when Agent Management step becomes active (step 3) useEffect(() => { if (currentStep === 3 && accessToken && existingKeys.length === 0) { const fetchKeys = async () => { @@ -108,9 +408,8 @@ const AddAgentForm: React.FC = ({ }; fetchKeys(); } - }, [currentStep, accessToken]); + }, [currentStep, accessToken, existingKeys.length]); - // Fetch available models when Agent Management step is active (same list as key generation) useEffect(() => { if ((currentStep !== 1 && currentStep !== 3) || !accessToken || !userId || !userRole) return; let cancelled = false; @@ -155,29 +454,26 @@ const AddAgentForm: React.FC = ({ }, [currentStep, accessToken]); const selectedAgentTypeInfo = agentTypeMetadata.find( - (info) => info.agent_type === agentType + (info) => info.agent_type === agentType, ); const handleNext = async () => { - try { - if (currentStep === 0) { - await form.validateFields(["agent_name"]); - const agentName = form.getFieldValue("agent_name"); - if (agentName && !newKeyName) { - setNewKeyName(`${agentName}-key`); - } + if (currentStep === 0) { + const ok = await trigger(["agent_name"]); + if (!ok) return; + const agentName = getValues("agent_name"); + if (agentName && !newKeyName) { + setNewKeyName(`${agentName}-key`); } - setCurrentStep((s) => s + 1); - } catch { - // validation failed — stay on current step } + setCurrentStep((s) => s + 1); }; const handleBack = () => { setCurrentStep((s) => Math.max(0, s - 1)); }; - const buildAgentData = (values: any) => { + const buildAgentData = (values: FormValues): any => { if (agentType === CUSTOM_AGENT_TYPE) { return { agent_name: values.agent_name, @@ -206,6 +502,7 @@ const AddAgentForm: React.FC = ({ for (const field of selectedAgentTypeInfo.credential_fields) { const value = values[field.key]; if (value && field.include_in_litellm_params !== false) { + if (!agentData.litellm_params) agentData.litellm_params = {}; agentData.litellm_params[field.key] = value; } } @@ -224,8 +521,12 @@ const AddAgentForm: React.FC = ({ setIsSubmitting(true); try { - await form.validateFields(); - const values = { ...form.getFieldsValue(true) }; + const ok = await trigger(); + if (!ok) { + setIsSubmitting(false); + return; + } + const values = { ...getValues() }; const agentData = buildAgentData(values); if (!agentData) { MessageManager.error("Failed to build agent data"); @@ -233,7 +534,6 @@ const AddAgentForm: React.FC = ({ return; } - // Build object_permission from MCP Tools step (allowed_mcp_servers_and_groups, mcp_tool_permissions) const mcpServersAndGroups = values.allowed_mcp_servers_and_groups; const mcpToolPermissions = values.mcp_tool_permissions || {}; const entitlementModels = values.entitlement_models || []; @@ -262,7 +562,6 @@ const AddAgentForm: React.FC = ({ } } - // Wire trace-id flags and budget controls into agent litellm_params (before create call) if (requireTraceIdInbound || requireTraceIdOutbound) { if (!agentData.litellm_params) agentData.litellm_params = {}; if (requireTraceIdInbound) { @@ -327,12 +626,11 @@ const AddAgentForm: React.FC = ({ }; const handleClose = () => { - form.resetFields(); + reset(getInitialFormValues()); setAgentType("a2a"); setCurrentStep(0); setKeyAssignOption("create_new"); setNewKeyName(""); - setNewKeyModels([]); setSelectedExistingKey(null); setCreatedAgentName(""); setCreatedKeyValue(null); @@ -344,129 +642,319 @@ const AddAgentForm: React.FC = ({ onClose(); }; + const handleAgentTypeChange = (value: string) => { + setAgentType(value); + reset(getInitialFormValues()); + }; + + const isCustomAgent = agentType === CUSTOM_AGENT_TYPE; + const selectedLogo = isCustomAgent + ? null + : selectedAgentTypeInfo?.logo_url || + agentTypeMetadata.find((a) => a.agent_type === "a2a")?.logo_url; + + const agentNameError = (formState.errors as any)?.agent_name; + + const renderConfigureStep = () => ( + <> +
+ + +
+ +
+ {agentType === CUSTOM_AGENT_TYPE ? ( +
+
+ + + {agentNameError && ( +

+ {agentNameError.message as string} +

+ )} +
+
+ +