From bb5d9a199ad6790cc8b3fd22bd97122fb9bcefc6 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Thu, 6 Aug 2026 14:33:39 -0700 Subject: [PATCH] feat(ui): migrate ChatUI off Ant Design and Tremor Replace ChatUI cards, inputs, dialogs, popovers, MCP selects, uploads, tooltips, and icons with shadcn/Base UI and Lucide. Update ChatUI tests to drive searchable combobox controls instead of Ant Design selectors --- .../components/chat_ui/ChatUI.test.tsx | 297 ++--- .../playground/components/chat_ui/ChatUI.tsx | 1141 ++++++++--------- .../src/components/shared/MultiSelect.tsx | 24 +- 3 files changed, 638 insertions(+), 824 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx index 3c94977f0dc..5407fa7e66c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx @@ -1,10 +1,10 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import ChatUI from "./ChatUI"; import * as fetchModelsModule from "@/components/llm_calls/fetch_models"; import { makeOpenAIChatCompletionRequest } from "@/components/llm_calls/chat_completion"; -// Mock the fetchAvailableModels function vi.mock("@/components/llm_calls/fetch_models", () => ({ fetchAvailableModels: vi.fn(), })); @@ -13,15 +13,18 @@ vi.mock("@/components/llm_calls/chat_completion", () => ({ makeOpenAIChatCompletionRequest: vi.fn().mockResolvedValue(undefined), })); -// Mock other networking functions that cause errors vi.mock("@/components/networking", () => ({ - tagListCall: vi.fn().mockResolvedValue({ data: [] }), + tagListCall: vi.fn().mockResolvedValue({}), vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }), getGuardrailsList: vi.fn().mockResolvedValue({ data: [] }), + getPoliciesList: vi.fn().mockResolvedValue({ data: [] }), modelHubCall: vi.fn().mockResolvedValue({ data: [] }), + fetchMCPServers: vi.fn().mockResolvedValue([]), + fetchMCPToolsets: vi.fn().mockResolvedValue([]), + listMCPTools: vi.fn().mockResolvedValue({ tools: [] }), + callMCPTool: vi.fn(), })); -// Mock scrollIntoView which is not available in jsdom beforeEach(() => { Element.prototype.scrollIntoView = () => {}; }); @@ -29,17 +32,27 @@ beforeEach(() => { const CHAT_REQUEST_ARG_COUNT = 26; const STREAMING_ENABLED_ARG_INDEX = 25; +async function openComboboxByPlaceholder(placeholder: string) { + const user = userEvent.setup(); + const combobox = screen.getByPlaceholderText(placeholder); + await user.click(combobox); + return combobox; +} + +async function selectComboboxOption(placeholder: string, optionLabel: string) { + const user = userEvent.setup(); + await openComboboxByPlaceholder(placeholder); + const option = await screen.findByText(optionLabel); + await user.click(option); +} + describe("ChatUI", () => { beforeEach(() => { - // Reset mocks before each test vi.clearAllMocks(); sessionStorage.clear(); - - // Mock scrollIntoView which is not available in JSDOM Element.prototype.scrollIntoView = vi.fn(); - // Mock the fetchAvailableModels to return test models - (fetchModelsModule.fetchAvailableModels as any).mockResolvedValue([ + (fetchModelsModule.fetchAvailableModels as ReturnType).mockResolvedValue([ { model_group: "Model 1", mode: "chat" }, { model_group: "Model 2", mode: "chat" }, { model_group: "Model 3", mode: "chat" }, @@ -47,7 +60,7 @@ describe("ChatUI", () => { }); it("should render the chat UI", async () => { - const { getByText } = render( + render( { disabledPersonalKeyCreation={false} />, ); - expect(getByText("Test Key")).toBeInTheDocument(); + expect(screen.getByText("Test Key")).toBeInTheDocument(); }); it("should show the voice selector when the endpoint type is audio_speech", async () => { - const { getByText } = render( + render( { />, ); - // Wait for the component to render await waitFor(() => { - expect(getByText("Test Key")).toBeInTheDocument(); + expect(screen.getByText("Test Key")).toBeInTheDocument(); }); - // Find the endpoint selector by looking for the "Endpoint Type:" text and its associated Select - const endpointTypeText = getByText("Endpoint Type"); - const selectContainer = endpointTypeText.parentElement; - const selectElement = selectContainer?.querySelector(".ant-select-selector"); + await selectComboboxOption("Select an endpoint", "/v1/audio/speech"); - expect(selectElement).toBeInTheDocument(); - - // Click on the select to open the dropdown - if (selectElement) { - fireEvent.mouseDown(selectElement); - } - - // Wait for the dropdown to appear and find the audio_speech option await waitFor(() => { - const audioSpeechOption = screen.getByText("/v1/audio/speech"); - expect(audioSpeechOption).toBeInTheDocument(); + expect(screen.getByText("Voice")).toBeInTheDocument(); + expect(screen.getByLabelText("Voice")).toBeInTheDocument(); }); - - // Click on the audio_speech option - const audioSpeechOption = screen.getByText("/v1/audio/speech"); - fireEvent.click(audioSpeechOption); - - // Verify the voice selector appears - await waitFor(() => { - expect(getByText("Voice")).toBeInTheDocument(); - }); - - // Verify the voice select component is present - const voiceText = getByText("Voice"); - const voiceSelectContainer = voiceText.parentElement; - const voiceSelectElement = voiceSelectContainer?.querySelector(".ant-select"); - expect(voiceSelectElement).toBeInTheDocument(); }); it("should allow the user to select a model", async () => { - const { getByText } = render( + render( { />, ); - // Wait for the component to render await waitFor(() => { - expect(getByText("Test Key")).toBeInTheDocument(); + expect(screen.getByText("Test Key")).toBeInTheDocument(); }); - // Open the "Select Model" dropdown (AntD renders options in a portal) - const selectModelLabel = getByText("Select Model"); - // The Select component is a sibling of the Text component, so we need to find it in the parent container - const modelSelectContainer = selectModelLabel.closest("div"); - const modelSelect = modelSelectContainer?.querySelector(".ant-select-selector"); - expect(modelSelect).toBeTruthy(); - - fireEvent.mouseDown(modelSelect!); + await openComboboxByPlaceholder("Select a Model"); await waitFor(() => { - const model1Label = screen.getAllByText("Model 1"); - expect(model1Label.length).toBeGreaterThan(0); + expect(screen.getAllByText("Model 1").length).toBeGreaterThan(0); }); }); - it("shows only chat-compatible models when chat endpoint is selected", async () => { - (fetchModelsModule.fetchAvailableModels as any).mockResolvedValueOnce([ + it("shows all models returned for the active key regardless of endpoint", async () => { + (fetchModelsModule.fetchAvailableModels as ReturnType).mockResolvedValueOnce([ { model_group: "ChatModel", mode: "chat" }, { model_group: "SpeechModel", mode: "audio_speech" }, { model_group: "ImageModel", mode: "image_generation" }, { model_group: "ResponsesModel", mode: "responses" }, ]); - const { getByText } = render( + render( { ); await waitFor(() => { - expect(getByText("Test Key")).toBeInTheDocument(); + expect(screen.getByText("Test Key")).toBeInTheDocument(); }); - // Open endpoint selector and explicitly select /v1/chat/completions - const endpointTypeText = getByText("Endpoint Type"); - const endpointSelect = endpointTypeText.parentElement?.querySelector(".ant-select-selector"); - expect(endpointSelect).toBeTruthy(); - act(() => { - fireEvent.mouseDown(endpointSelect!); - fireEvent.click(screen.getByText("/v1/chat/completions")); - }); - - // Open model selector - const selectModelLabel = getByText("Select Model"); - // The Select component is a sibling of the Text component, so we need to find it in the parent container - const modelSelectContainer = selectModelLabel.closest("div"); - const modelSelect = modelSelectContainer?.querySelector(".ant-select-selector"); - expect(modelSelect).toBeTruthy(); - act(() => { - fireEvent.mouseDown(modelSelect!); - }); + await selectComboboxOption("Select an endpoint", "/v1/chat/completions"); + await openComboboxByPlaceholder("Select a Model"); await waitFor(() => { - // Chat-compatible: ChatModel should be visible expect(screen.getAllByText("ChatModel").length).toBeGreaterThan(0); - expect(screen.queryByText("SpeechModel")).toBeNull(); - expect(screen.queryByText("ImageModel")).toBeNull(); - expect(screen.queryByText("ResponsesModel")).toBeNull(); + expect(screen.getAllByText("SpeechModel").length).toBeGreaterThan(0); + expect(screen.getAllByText("ImageModel").length).toBeGreaterThan(0); + expect(screen.getAllByText("ResponsesModel").length).toBeGreaterThan(0); }); }); - /** - * Tests that the 'Enter custom model' option is available in the model selector dropdown. - * This ensures users can manually enter a model name if it's not in the list. - */ it("should show 'Enter custom model' option in model selector", async () => { - const { getByText } = render( + render( { />, ); - // Wait for the component to render await waitFor(() => { - expect(getByText("Test Key")).toBeInTheDocument(); + expect(screen.getByText("Test Key")).toBeInTheDocument(); }); - // Open the "Select Model" dropdown - const selectModelLabel = getByText("Select Model"); - const modelSelectContainer = selectModelLabel.closest("div"); - const modelSelect = modelSelectContainer?.querySelector(".ant-select-selector"); - - fireEvent.mouseDown(modelSelect!); + await openComboboxByPlaceholder("Select a Model"); await waitFor(() => { - // Get all options in the dropdown (Ant Design renders these in a portal) - const options = document.querySelectorAll(".ant-select-item-option-content"); - expect(options.length).toBeGreaterThan(0); - // Check if the first option is 'Enter custom model' - expect(options[0]).toHaveTextContent("Enter custom model"); + expect(screen.getByText("Enter custom model")).toBeInTheDocument(); }); }); @@ -241,44 +187,23 @@ describe("ChatUI", () => { expect(screen.getByText("Test Key")).toBeInTheDocument(); }); - const endpointTypeText = screen.getByText("Endpoint Type"); - const endpointSelect = endpointTypeText.parentElement?.querySelector(".ant-select-selector") as HTMLElement | null; - expect(endpointSelect).not.toBeNull(); + const mcpInput = () => screen.getByLabelText("Select MCP servers"); - const selectEndpointOption = async (label: string) => { - act(() => { - fireEvent.mouseDown(endpointSelect!); - }); - - await waitFor(() => { - expect(screen.getByText(label)).toBeInTheDocument(); - }); - - act(() => { - fireEvent.click(screen.getByText(label)); - }); - }; - - const getMcpSelect = () => - screen.getByText("MCP Servers").closest("div")?.querySelector(".ant-select") as HTMLElement | null; - - await selectEndpointOption("/v1/embeddings"); - - const mcpSelect = getMcpSelect(); - expect(mcpSelect).not.toBeNull(); + await selectComboboxOption("Select an endpoint", "/v1/embeddings"); await waitFor(() => { - expect(mcpSelect).toHaveClass("ant-select-disabled"); + expect(mcpInput()).toBeDisabled(); }); - await selectEndpointOption("/v1/chat/completions"); + await selectComboboxOption("Select an endpoint", "/v1/chat/completions"); await waitFor(() => { - expect(mcpSelect).not.toHaveClass("ant-select-disabled"); + expect(mcpInput()).not.toBeDisabled(); }); }); it("should show Simulate failure to test fallbacks in Model Settings when chat endpoint is selected", async () => { + const user = userEvent.setup(); render( { expect(screen.getByText("Test Key")).toBeInTheDocument(); }); - // Model Settings button only appears when a chat model is selected; select "Model 1" first - const selectModelLabel = screen.getByText("Select Model"); - const modelSelectContainer = selectModelLabel.closest("div"); - const modelSelect = modelSelectContainer?.querySelector(".ant-select-selector"); - expect(modelSelect).toBeTruthy(); - - await act(async () => { - fireEvent.mouseDown(modelSelect!); - }); + await selectComboboxOption("Select a Model", "Model 1"); await waitFor(() => { - expect(screen.getAllByText("Model 1").length).toBeGreaterThan(0); + expect(screen.getByTestId("model-settings-button")).toBeInTheDocument(); }); - // Ant Design Select options may not have role="option"; click the dropdown option by text - const model1Options = screen.getAllByText("Model 1"); - await act(async () => { - fireEvent.click(model1Options[model1Options.length - 1]); - }); - - await waitFor(() => { - const modelSettingsButton = screen.getByTestId("model-settings-button"); - expect(modelSettingsButton).toBeInTheDocument(); - }); - - const modelSettingsButton = screen.getByTestId("model-settings-button"); - await act(async () => { - fireEvent.click(modelSettingsButton); - }); + await user.click(screen.getByTestId("model-settings-button")); await waitFor(() => { expect(screen.getByText("Model Settings")).toBeInTheDocument(); @@ -333,9 +236,7 @@ describe("ChatUI", () => { }); expect(fallbacksCheckbox).not.toBeChecked(); - await act(async () => { - fireEvent.click(fallbacksCheckbox); - }); + await user.click(fallbacksCheckbox); await waitFor(() => { expect(screen.getByRole("checkbox", { name: /Simulate failure to test fallbacks/i })).toBeChecked(); @@ -343,6 +244,7 @@ describe("ChatUI", () => { }); it("should send the chat request non-streaming after Stream responses is unchecked", async () => { + const user = userEvent.setup(); render( { expect(screen.getByText("Test Key")).toBeInTheDocument(); }); - const selectModelLabel = screen.getByText("Select Model"); - const modelSelect = selectModelLabel.closest("div")?.querySelector(".ant-select-selector"); - await act(async () => { - fireEvent.mouseDown(modelSelect!); - }); - - await waitFor(() => { - expect(screen.getAllByText("Model 1").length).toBeGreaterThan(0); - }); - - const model1Options = screen.getAllByText("Model 1"); - await act(async () => { - fireEvent.click(model1Options[model1Options.length - 1]); - }); + await selectComboboxOption("Select a Model", "Model 1"); await waitFor(() => { expect(screen.getByTestId("model-settings-button")).toBeInTheDocument(); }); - await act(async () => { - fireEvent.click(screen.getByTestId("model-settings-button")); - }); + await user.click(screen.getByTestId("model-settings-button")); const streamingCheckbox = await screen.findByRole("checkbox", { name: /Stream responses/i }); expect(streamingCheckbox).toBeChecked(); - await act(async () => { - fireEvent.click(streamingCheckbox); - }); + await user.click(streamingCheckbox); await waitFor(() => { expect(screen.getByRole("checkbox", { name: /Stream responses/i })).not.toBeChecked(); @@ -446,7 +331,8 @@ describe("ChatUI", () => { }); it("should offer the streaming toggle for a responses-only model without advanced params", async () => { - (fetchModelsModule.fetchAvailableModels as any).mockResolvedValue([ + const user = userEvent.setup(); + (fetchModelsModule.fetchAvailableModels as ReturnType).mockResolvedValue([ { model_group: "ResponsesModel", mode: "responses" }, ]); @@ -464,37 +350,14 @@ describe("ChatUI", () => { expect(screen.getByText("Test Key")).toBeInTheDocument(); }); - const endpointTypeText = screen.getByText("Endpoint Type"); - const endpointSelect = endpointTypeText.parentElement?.querySelector(".ant-select-selector"); - await act(async () => { - fireEvent.mouseDown(endpointSelect!); - }); - await act(async () => { - fireEvent.click(screen.getByText("/v1/responses")); - }); - - const selectModelLabel = screen.getByText("Select Model"); - const modelSelect = selectModelLabel.closest("div")?.querySelector(".ant-select-selector"); - await act(async () => { - fireEvent.mouseDown(modelSelect!); - }); - - await waitFor(() => { - expect(screen.getAllByText("ResponsesModel").length).toBeGreaterThan(0); - }); - - const modelOptions = screen.getAllByText("ResponsesModel"); - await act(async () => { - fireEvent.click(modelOptions[modelOptions.length - 1]); - }); + await selectComboboxOption("Select an endpoint", "/v1/responses"); + await selectComboboxOption("Select a Model", "ResponsesModel"); await waitFor(() => { expect(screen.getByTestId("model-settings-button")).toBeInTheDocument(); }); - await act(async () => { - fireEvent.click(screen.getByTestId("model-settings-button")); - }); + await user.click(screen.getByTestId("model-settings-button")); expect(await screen.findByRole("checkbox", { name: /Stream responses/i })).toBeChecked(); expect(screen.queryByText("Temperature")).not.toBeInTheDocument(); @@ -543,6 +406,7 @@ describe("ChatUI", () => { }); it("should enable search functionality for MCP server selector", async () => { + const user = userEvent.setup(); render( { expect(screen.getByText("Test Key")).toBeInTheDocument(); }); - const mcpServersText = screen.queryByText("MCP Servers"); - expect(mcpServersText).toBeInTheDocument(); + expect(screen.getByText("MCP Servers")).toBeInTheDocument(); - if (mcpServersText) { - const selectContainer = mcpServersText.parentElement?.nextElementSibling; - const selectElement = selectContainer?.querySelector(".ant-select-selector"); - expect(selectElement).toBeInTheDocument(); + const mcpInput = screen.getByLabelText("Select MCP servers"); + expect(mcpInput).toBeInTheDocument(); + expect(mcpInput).not.toBeDisabled(); - if (selectElement) { - fireEvent.mouseDown(selectElement); + await user.click(mcpInput); - await waitFor(() => { - const allServersOption = screen.queryByText("All MCP Servers"); - if (allServersOption) { - expect(allServersOption).toBeInTheDocument(); - } - }); - - const searchInput = document.querySelector(".ant-select-selection-search-input"); - expect(searchInput).toBeInTheDocument(); - } - } + await waitFor(() => { + expect(screen.getByText("All MCP Servers")).toBeInTheDocument(); + }); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx index 5edcbe84aa8..ce6880fbb30 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx @@ -1,27 +1,25 @@ "use client"; import { - ApiOutlined, - ArrowUpOutlined, - ClearOutlined, - CodeOutlined, - DatabaseOutlined, - DeleteOutlined, - InfoCircleOutlined, - KeyOutlined, - LinkOutlined, - LoadingOutlined, - PictureOutlined, - RobotOutlined, - SafetyOutlined, - SettingOutlined, - SoundOutlined, - TagsOutlined, - ToolOutlined, -} from "@ant-design/icons"; -import { Card, Text, TextInput, Title, Button as TremorButton } from "@tremor/react"; -import { Button, Input, Modal, Popover, Select, Spin, Tooltip, Upload } from "antd"; -import React, { useEffect, useRef, useState } from "react"; + ArrowUp, + Bot, + Code2, + Database, + Eraser, + Image as ImageIcon, + Info, + Key, + Link2, + Loader2, + Settings, + Shield, + Tags, + Trash2, + Volume2, + Wrench, + X, +} from "lucide-react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; import { v4 as uuidv4 } from "uuid"; @@ -66,8 +64,15 @@ import { MessageType } from "@/components/chat_ui/types"; import { useCodeInterpreter } from "../../hooks/useCodeInterpreter"; import { useChatHistory } from "../../hooks/useChatHistory"; import { getSecureItem, setSecureItem } from "@/utils/secureStorage"; +import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect"; import { SearchSelect } from "@/components/shared/SearchSelect"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select as ShadcnSelect, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { AUDIO_ACCEPT, @@ -77,9 +82,6 @@ import { validateImageEditFile, } from "./uploadValidation"; -const { TextArea } = Input; -const { Dragger } = Upload; - interface ChatUIProps { accessToken: string | null; token: string | null; @@ -535,9 +537,8 @@ const ChatUI: React.FC = ({ setImagePreviewUrls((prev) => [...prev, ...previews]); }; - const handleImageUpload = (file: File): false => { + const handleImageUpload = (file: File): void => { handleImageFiles([file]); - return false; }; const handleRemoveImage = (index: number) => { @@ -592,14 +593,71 @@ const ChatUI: React.FC = ({ setChatImagePreviewUrl(null); }; - const handleAudioUpload = (file: File): false => { + const handleAudioUpload = (file: File): void => { const result = validateAudioFile(file); if (!result.ok) { NotificationsManager.error(result.error); - return false; + return; } setUploadedAudio(file); - return false; + }; + + const mcpServerOptions = useMemo((): MultiSelectOption[] => { + const options: MultiSelectOption[] = []; + if (endpointType !== EndpointType.MCP) { + options.push({ + value: "__all__", + label: "All MCP Servers", + description: "Use all available MCP servers", + }); + } + for (const toolset of mcpToolsets) { + options.push({ + value: `toolset:${toolset.toolset_id}`, + label: toolset.toolset_name, + description: toolset.description || `Toolset (${toolset.tools.length} tools)`, + }); + } + for (const server of mcpServers) { + options.push({ + value: server.server_id, + label: server.alias || server.server_name || server.server_id, + description: server.description, + }); + } + return options; + }, [endpointType, mcpToolsets, mcpServers]); + + const handleMcpServersChange = (value: string[]) => { + if (endpointType === EndpointType.MCP) { + const serverId = value[0]; + setSelectedMCPServers(serverId ? [serverId] : []); + setSelectedMCPDirectTool(undefined); + if (serverId && !serverToolsMap[serverId]) { + loadServerTools(serverId); + } + return; + } + + if (value.includes("__all__")) { + setSelectedMCPServers(["__all__"]); + setMCPServerToolRestrictions({}); + return; + } + + setSelectedMCPServers(value); + setMCPServerToolRestrictions((prev) => { + const updated = { ...prev }; + Object.keys(updated).forEach((serverId) => { + if (!value.includes(serverId)) delete updated[serverId]; + }); + return updated; + }); + value.forEach((serverId) => { + if (!serverToolsMap[serverId]) { + loadServerTools(serverId); + } + }); }; const handleRemoveAudio = () => { @@ -1079,21 +1137,43 @@ const ChatUI: React.FC = ({ modelEmptyText = "Enter a Virtual Key to load models"; } - const antIcon = ; + const inputPlaceholder = + endpointType === EndpointType.CHAT || + endpointType === EndpointType.EMBEDDINGS || + endpointType === EndpointType.RESPONSES || + endpointType === EndpointType.ANTHROPIC_MESSAGES || + endpointType === EndpointType.INTERACTIONS + ? "Type your message... (Shift+Enter for new line)" + : endpointType === EndpointType.A2A_AGENTS + ? "Send a message to the A2A agent..." + : endpointType === EndpointType.IMAGE_EDITS + ? "Describe how you want to edit the image..." + : endpointType === EndpointType.SPEECH + ? "Enter text to convert to speech..." + : endpointType === EndpointType.TRANSCRIPTION + ? "Optional: Add context or prompt for transcription..." + : "Describe the image you want to generate..."; + + const sendDisabled = + isLoading || + (endpointType === EndpointType.MCP + ? !(selectedMCPServers.length === 1 && selectedMCPServers[0] !== "__all__" && selectedMCPDirectTool) + : endpointType === EndpointType.TRANSCRIPTION + ? !uploadedAudio + : !inputMessage.trim()); return (
- +
- {/* Left Sidebar with Controls - hidden in simplified mode */} {!simplified && (
- Configurations +

Configurations

- - Virtual Key Source - + = ({ {apiKeySource === "custom" && ( - +
+ + setApiKey(event.target.value)} + value={apiKey} + /> +
)}
-
- - Custom Proxy Base URL - +
+ {proxySettings?.LITELLM_UI_API_DOC_BASE_URL && !customProxyBaseUrl && ( )} {customProxyBaseUrl && ( )}
- { - setCustomProxyBaseUrl(value); - sessionStorage.setItem("customProxyBaseUrl", value); - }} - value={customProxyBaseUrl} - icon={ApiOutlined} - /> +
+ + { + setCustomProxyBaseUrl(event.target.value); + sessionStorage.setItem("customProxyBaseUrl", event.target.value); + }} + /> +
{customProxyBaseUrl && ( - API calls will be sent to: {customProxyBaseUrl} +

API calls will be sent to: {customProxyBaseUrl}

)}
- - Endpoint Type - + { setEndpointType(value); - // Clear model/agent selection when switching endpoint type setSelectedModel(undefined); setSelectedAgent(undefined); setShowCustomModelInput(false); setSelectedMCPDirectTool(undefined); - // For MCP direct mode, require single server (clear __all__ or multiple) if (value === EndpointType.MCP) { setSelectedMCPServers((prev) => (prev.length === 1 && prev[0] !== "__all__" ? prev : [])); } @@ -1194,13 +1279,12 @@ const ChatUI: React.FC = ({ className="mb-4" /> - {/* Voice Selector for Speech Endpoint */} {endpointType === EndpointType.SPEECH && (
- - + + { @@ -1222,7 +1306,6 @@ const ChatUI: React.FC = ({
)} - {/* Session Management Component */} = ({ />
- {/* Model Selector - shown when NOT using A2A Agents or MCP direct mode */} {endpointType !== EndpointType.A2A_AGENTS && endpointType !== EndpointType.MCP && (
- +
- Select Model + {isChatModel() || supportsStreamingToggle ? ( - + + } + > + + + +
Model Settings
= ({ streamingEnabled={streamingEnabled} onStreamingChange={supportsStreamingToggle ? setStreamingEnabled : undefined} /> - } - title="Model Settings" - trigger="click" - placement="right" - > -
= ({ ]} /> {showCustomModelInput && ( - debouncedSetSelectedModel(event.target.value)} /> )}
)} - {/* Agent Selector - shown ONLY for A2A Agents endpoint */} {endpointType === EndpointType.A2A_AGENTS && (
- - Select Agent - + = ({ }))} /> {agentInfo.length === 0 && ( - +

No agents found. Create agents via /v1/agents endpoint. - +

)}
)}
- - Tags - + = ({ />
- {/* MCP Server Selection */}
- - +
+
)} - {/* BYOK credential status for selected servers */} {selectedMCPServers.length > 0 && !selectedMCPServers.includes("__all__") && selectedMCPServers.some((serverId) => { @@ -1593,28 +1571,31 @@ const ChatUI: React.FC = ({ return (
- {serverName} requires your API key +

{serverName} requires your API key

{server.has_user_credential ? (
- - Connected + + Connected
) : ( - + )}
); @@ -1624,23 +1605,21 @@ const ChatUI: React.FC = ({
- - Vector Store - - Select vector store(s) to use for this LLM API call. You can set up your vector store{" "} - - here - - . - - } - > - +
+
= ({
- - Guardrails - - Select guardrail(s) to use for this LLM API call. You can set up your guardrails{" "} - - here - - . - - } - > - +
+
= ({
- - Policies - - Select policy/policies to apply to this LLM API call. Policies define which guardrails are - applied based on conditions. You can set up your policies{" "} - - here - - . - - } - > - +
+
= ({ />
- {/* Code Interpreter Toggle - Only for Responses endpoint */} {endpointType === EndpointType.RESPONSES && (
= ({
)} - {/* Main Chat Area */}
{endpointType === EndpointType.REALTIME ? ( = ({ ) : ( <>
- {simplified ? "Chat" : "Test Key"} +

{simplified ? "Chat" : "Test Key"}

- + {!simplified && ( - setIsGetCodeModalVisible(true)} - className="bg-gray-100 hover:bg-gray-200 text-gray-700 border-gray-300" - icon={CodeOutlined} - > + )}
{chatHistory.length === 0 && ( -
- - Start a conversation, generate an image, or handle audio +
+
)} @@ -1772,29 +1739,26 @@ const ChatUI: React.FC = ({
))} - {/* Show MCP events during loading if no assistant message exists yet */} {isLoading && mcpEvents.length > 0 && (endpointType === EndpointType.RESPONSES || endpointType === EndpointType.CHAT) && chatHistory.length > 0 && chatHistory[chatHistory.length - 1].role === "user" && ( -
+
-
+
- +
Assistant
@@ -1804,27 +1768,34 @@ const ChatUI: React.FC = ({ )} {isLoading && ( -
- +
+
)}
- {/* Image Upload Section for Image Edits */} {endpointType === EndpointType.IMAGE_EDITS && (
{uploadedImages.length === 0 ? ( - -

- -

-

Click or drag images to upload

-

+

Click or drag images to upload

+

Support for PNG, JPG, JPEG, GIF, WebP. Multiple images supported.

-
+ { + handleImageFiles(Array.from(event.target.files || [])); + event.target.value = ""; + }} + /> + ) : (
{uploadedImages.map((file, index) => ( @@ -1841,76 +1812,83 @@ const ChatUI: React.FC = ({ } })()} alt={`Upload preview ${index + 1}`} - className="max-w-32 max-h-32 rounded-md border border-gray-200 object-cover" + className="max-h-32 max-w-32 rounded-md border border-gray-200 object-cover" /> - + +
))} - {/* Add more images button */} -
document.getElementById("additional-image-upload")?.click()} - > -
- -

Add more

-
+
+
)}
)} - {/* Audio Upload Section for Transcriptions */} {endpointType === EndpointType.TRANSCRIPTION && (
{!uploadedAudio ? ( - -

- -

-

Click or drag audio file to upload

-

+

Click or drag audio file to upload

+

Support for MP3, MP4, MPEG, MPGA, M4A, WAV, WEBM formats. Max file size: 25 MB.

-
+ { + const file = event.target.files?.[0]; + if (file) handleAudioUpload(file); + event.target.value = ""; + }} + /> + ) : ( -
-
- +
+
+
- + + Remove +
)}
)} - {/* Show file previews above input when files are uploaded */} {endpointType === EndpointType.RESPONSES && responsesUploadedImage && ( = ({ /> )} - {/* Code Interpreter indicator and sample prompts when enabled */} {endpointType === EndpointType.RESPONSES && codeInterpreter.enabled && (
-
+
{isLoading ? ( <> - - Running Python code... +
- {/* Sample prompts - only show when not loading */} {!isLoading && (
{[ @@ -1961,7 +1938,8 @@ const ChatUI: React.FC = ({ ].map((prompt, idx) => (
)} - {/* Suggested prompts - show when chat is empty and not loading (skip for MCP - uses structured form) */} {chatHistory.length === 0 && !isLoading && endpointType !== EndpointType.MCP && ( -
+
{(endpointType === EndpointType.A2A_AGENTS ? ["What can you help me with?", "Tell me about yourself", "What tasks can you perform?"] : ["Write me a poem", "Explain quantum computing", "Draft a polite email requesting a meeting"] @@ -1982,7 +1959,7 @@ const ChatUI: React.FC = ({ + + + + {codeInterpreter.enabled + ? "Code Interpreter enabled (click to disable)" + : "Enable Code Interpreter"} + )}
- {/* Middle: input field or MCP structured form */} {endpointType === EndpointType.MCP && selectedMCPServers.length === 1 && selectedMCPServers[0] !== "__all__" && selectedMCPDirectTool ? ( -
+
{(() => { const rawSel = selectedMCPServers[0]; - let toolPool: any[] = []; + let toolPool: { name: string }[] = []; if (rawSel.startsWith("toolset:")) { const toolsetId = rawSel.slice("toolset:".length); const toolset = mcpToolsets.find((t) => t.toolset_id === toolsetId); @@ -2060,82 +2043,51 @@ const ChatUI: React.FC = ({ } else { toolPool = serverToolsMap[rawSel] || []; } - const mcpTool = toolPool.find((t: any) => t.name === selectedMCPDirectTool); + const mcpTool = toolPool.find((t) => t.name === selectedMCPDirectTool); return mcpTool ? ( ) : ( -
+
Loading tool schema...
); })()}
) : ( -