diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 591c75c48fe..d7f71a5840d 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -341,7 +341,7 @@ }, "src/app/(dashboard)/guardrails/_components/GuardrailTestResults.tsx": { "no-restricted-imports": { - "count": 2 + "count": 1 } }, "src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx": { @@ -1046,15 +1046,7 @@ "count": 1 } }, - "src/app/(dashboard)/playground/components/chat_ui/A2AMetrics.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx": { - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 2 } @@ -1070,11 +1062,6 @@ "count": 5 } }, - "src/app/(dashboard)/playground/components/chat_ui/ChatImageUpload.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/playground/components/chat_ui/ChatImageUtils.test.tsx": { "max-nested-callbacks": { "count": 1 @@ -1087,48 +1074,26 @@ }, "src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx": { "local/no-complex-jsx-arrow": { - "count": 2 + "count": 1 }, "max-lines": { "count": 1 }, "no-nested-ternary": { - "count": 7 - }, - "no-restricted-imports": { - "count": 2 - }, - "prefer-const": { - "count": 1 + "count": 6 }, "react-hooks/set-state-in-effect": { "count": 4 - }, - "unused-imports/no-unused-imports": { - "count": 13 } }, "src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterOutput.tsx": { "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "no-restricted-syntax": { "count": 2 } }, - "src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterTool.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, - "src/app/(dashboard)/playground/components/chat_ui/EndpointSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.tsx": { "no-nested-ternary": { "count": 2 @@ -1143,21 +1108,6 @@ "count": 1 } }, - "src/app/(dashboard)/playground/components/chat_ui/ResponsesImageUpload.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/playground/components/chat_ui/SearchResultsDisplay.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/playground/components/chat_ui/SessionManagement.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/playground/components/compareUI/CompareUI.tsx": { "max-lines": { "count": 1 @@ -1295,11 +1245,6 @@ "count": 1 } }, - "src/app/(dashboard)/playground/page.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/policies/_components/add_attachment_form.tsx": { "local/filename-pascal-case": { "count": 1 @@ -2468,9 +2413,6 @@ "src/components/agent_management/AgentSelector.test.tsx": { "react/display-name": { "count": 1 - }, - "unused-imports/no-unused-imports": { - "count": 1 } }, "src/components/agent_management/AgentSelector.tsx": { @@ -2544,21 +2486,6 @@ "count": 2 } }, - "src/components/chat_ui/MCPEventsDisplay.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/chat_ui/ReasoningContent.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/chat_ui/ResponseMetrics.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/chat_ui/mode_endpoint_mapping.tsx": { "local/filename-pascal-case": { "count": 1 @@ -2842,11 +2769,6 @@ "count": 1 } }, - "src/components/guardrails/GuardrailSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/key_info_utils.tsx": { "local/filename-pascal-case": { "count": 1 @@ -3064,9 +2986,6 @@ "src/components/navbar.test.tsx": { "prefer-const": { "count": 1 - }, - "unused-imports/no-unused-imports": { - "count": 1 } }, "src/components/navbar.tsx": { @@ -3199,9 +3118,6 @@ "src/components/policies/PolicySelector.tsx": { "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/price_data_reload.tsx": { @@ -3312,7 +3228,7 @@ "count": 3 }, "prefer-const": { - "count": 7 + "count": 4 } }, "src/components/shared/CreatedKeyDisplay.tsx": { @@ -3437,11 +3353,6 @@ "count": 1 } }, - "src/components/tag_management/TagSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/tag_management/types.tsx": { "local/filename-pascal-case": { "count": 1 @@ -3734,16 +3645,6 @@ "count": 1 } }, - "src/components/vector_store_management/VectorStoreSelector.test.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/vector_store_management/VectorStoreSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/vector_store_management/types.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/A2AMetrics.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/A2AMetrics.tsx index 004a513f061..6ddfe1442f5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/A2AMetrics.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/A2AMetrics.tsx @@ -1,17 +1,19 @@ import React, { useState } from "react"; -import { Tooltip, Button } from "antd"; import { - CheckCircleOutlined, - ClockCircleOutlined, - LoadingOutlined, - ExclamationCircleOutlined, - CopyOutlined, - DownOutlined, - RightOutlined, - LinkOutlined, - FileTextOutlined, - RobotOutlined, -} from "@ant-design/icons"; + Bot, + CheckCircle, + ChevronDown, + ChevronRight, + CircleAlert, + Clock, + Copy, + FileText, + Link, + LoaderCircle, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; export interface A2ATaskMetadata { taskId?: string; @@ -21,7 +23,7 @@ export interface A2ATaskMetadata { timestamp?: string; message?: string; }; - metadata?: Record; + metadata?: Record; } interface A2AMetricsProps { @@ -33,15 +35,15 @@ interface A2AMetricsProps { const getStatusIcon = (state?: string) => { switch (state) { case "completed": - return ; + return ; case "working": case "submitted": - return ; + return ; case "failed": case "canceled": - return ; + return ; default: - return ; + return ; } }; @@ -91,7 +93,7 @@ const A2AMetrics: React.FC = ({ a2aMetadata, timeToFirstToken,
{/* A2A Metadata Header */}
- + A2A Metadata
@@ -109,28 +111,33 @@ const A2AMetrics: React.FC = ({ a2aMetadata, timeToFirstToken, {/* Timestamp */} {formattedTime && ( - - - + + }> + {formattedTime} - + + {status?.timestamp} )} {/* Latency */} {totalLatency !== undefined && ( - - - + + }> + {(totalLatency / 1000).toFixed(2)}s - + + Total latency )} {/* Time to first token */} {timeToFirstToken !== undefined && ( - - TTFT: {(timeToFirstToken / 1000).toFixed(2)}s + + }> + TTFT: {(timeToFirstToken / 1000).toFixed(2)}s + + Time to first token )}
@@ -139,95 +146,133 @@ const A2AMetrics: React.FC = ({ a2aMetadata, timeToFirstToken,
{/* Task ID */} {taskId && ( - - copyToClipboard(taskId)} + + copyToClipboard(taskId)} + aria-label={`Copy task ID ${taskId}`} + /> + } > - + Task: {truncateId(taskId)} - - + + + Click to copy: {taskId} )} {/* Context/Session ID */} {contextId && ( - - copyToClipboard(contextId)} + + copyToClipboard(contextId)} + aria-label={`Copy session ID ${contextId}`} + /> + } > - + Session: {truncateId(contextId)} - - + + + Click to copy: {contextId} )} {/* Details toggle */} {(metadata || status?.message) && ( - + + + } + > + {showDetails ? : } + Details + + )}
{/* Expandable details panel */} - {showDetails && ( -
- {/* Status message */} - {status?.message && ( -
- Status Message: - {status.message} -
- )} + + +
+ {/* Status message */} + {status?.message && ( +
+ Status Message: + {status.message} +
+ )} - {/* Full IDs */} - {taskId && ( -
- Task ID: - - {taskId} - - copyToClipboard(taskId)} - /> -
- )} + {/* Full IDs */} + {taskId && ( +
+ Task ID: + + {taskId} + + +
+ )} - {contextId && ( -
- Session ID: - - {contextId} - - copyToClipboard(contextId)} - /> -
- )} + {contextId && ( +
+ Session ID: + + {contextId} + + +
+ )} - {/* Metadata fields */} - {metadata && Object.keys(metadata).length > 0 && ( -
- Custom Metadata: -
-                {JSON.stringify(metadata, null, 2)}
-              
-
- )} -
- )} + {/* Metadata fields */} + {metadata && Object.keys(metadata).length > 0 && ( +
+ Custom Metadata: +
+                  {JSON.stringify(metadata, null, 2)}
+                
+
+ )} +
+ + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.test.tsx index 1b443e98495..1c2cb426f23 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import AdditionalModelSettings from "./AdditionalModelSettings"; @@ -128,4 +128,47 @@ describe("AdditionalModelSettings", () => { expect(onMockTestFallbacksChange).toHaveBeenCalledWith(false); }); }); + + it("should keep a half-typed decimal temperature instead of rewriting it", async () => { + const onTemperatureChange = vi.fn(); + + render(); + + const temperatureField = screen.getByLabelText("Temperature value") as HTMLInputElement; + fireEvent.change(temperatureField, { target: { value: "0." } }); + + expect(temperatureField.value).toBe("0."); + + fireEvent.change(temperatureField, { target: { value: "0.5" } }); + + expect(temperatureField.value).toBe("0.5"); + expect(onTemperatureChange).toHaveBeenLastCalledWith(0.5); + }); + + it("should let the max tokens field be cleared instead of snapping to a value", async () => { + const user = userEvent.setup(); + const onMaxTokensChange = vi.fn(); + + render(); + + const maxTokensField = screen.getByLabelText("Max tokens value"); + await user.clear(maxTokensField); + + expect((maxTokensField as HTMLInputElement).value).toBe(""); + }); + + it("should clamp an out-of-range temperature once the field is left", async () => { + const user = userEvent.setup(); + const onTemperatureChange = vi.fn(); + + render(); + + const temperatureField = screen.getByLabelText("Temperature value"); + await user.clear(temperatureField); + await user.type(temperatureField, "9"); + await user.tab(); + + expect((temperatureField as HTMLInputElement).value).toBe("2"); + expect(onTemperatureChange).toHaveBeenLastCalledWith(2); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx index d4320110c4c..af306e0eb2e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx @@ -1,7 +1,10 @@ -import { InfoCircleOutlined } from "@ant-design/icons"; -import { Text } from "@tremor/react"; -import { Checkbox, InputNumber, Popover, Slider, Tooltip, Typography } from "antd"; -import React, { useEffect, useState } from "react"; +import { Info } from "lucide-react"; +import React, { useEffect, useId, useState } from "react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/cva.config"; interface AdditionalModelSettingsProps { temperature?: number; @@ -17,6 +20,10 @@ interface AdditionalModelSettingsProps { showAdvancedParams?: boolean; } +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + const AdditionalModelSettings: React.FC = ({ temperature = 1.0, maxTokens = 2048, @@ -35,30 +42,56 @@ const AdditionalModelSettings: React.FC = ({ externalUseAdvancedParams !== undefined ? externalUseAdvancedParams : internalUseAdvancedParams; const [localTemperature, setLocalTemperature] = useState(temperature); const [localMaxTokens, setLocalMaxTokens] = useState(maxTokens); + const [temperatureText, setTemperatureText] = useState(String(temperature)); + const [maxTokensText, setMaxTokensText] = useState(String(maxTokens)); + + const streamingId = useId(); + const advancedId = useId(); + const fallbacksId = useId(); + const temperatureId = useId(); + const maxTokensId = useId(); - // Sync local state with props when they change useEffect(() => { setLocalTemperature(temperature); + setTemperatureText(String(temperature)); }, [temperature]); useEffect(() => { setLocalMaxTokens(maxTokens); + setMaxTokensText(String(maxTokens)); }, [maxTokens]); - const handleTemperatureChange = (value: number | null) => { - const newValue = value ?? 1.0; + const handleTemperatureChange = (value: number) => { + const newValue = clamp(Number.isFinite(value) ? value : 1.0, 0, 2); setLocalTemperature(newValue); + setTemperatureText(String(newValue)); onTemperatureChange?.(newValue); }; - const handleMaxTokensChange = (value: number | null) => { - const newValue = value ?? 1000; + const handleMaxTokensChange = (value: number) => { + const newValue = clamp(Number.isFinite(value) ? Math.round(value) : 1000, 1, 32768); setLocalMaxTokens(newValue); + setMaxTokensText(String(newValue)); onMaxTokensChange?.(newValue); }; - const disabledOpacity = useAdvancedParams ? 1 : 0.4; - const disabledTextColor = useAdvancedParams ? "text-gray-700" : "text-gray-400"; + const handleTemperatureTyped = (raw: string) => { + setTemperatureText(raw); + const parsed = Number(raw); + if (raw.trim() !== "" && Number.isFinite(parsed) && parsed >= 0 && parsed <= 2) { + setLocalTemperature(parsed); + onTemperatureChange?.(parsed); + } + }; + + const handleMaxTokensTyped = (raw: string) => { + setMaxTokensText(raw); + const parsed = Number(raw); + if (raw.trim() !== "" && Number.isInteger(parsed) && parsed >= 1 && parsed <= 32768) { + setLocalMaxTokens(parsed); + onMaxTokensChange?.(parsed); + } + }; const handleUseAdvancedParamsChange = (checked: boolean) => { if (onUseAdvancedParamsChange) { @@ -68,129 +101,176 @@ const AdditionalModelSettings: React.FC = ({ } }; + const disabledTextColor = useAdvancedParams ? "text-gray-700" : "text-gray-400"; + return ( -
+
{onStreamingChange && ( -
- onStreamingChange(e.target.checked)}> - Stream responses - - - +
+ onStreamingChange(checked === true)} + aria-label="Stream responses" + /> + + + + + + + Streams the answer token by token. Uncheck to send a non-streaming request and render the full response at + once. +
)} {showAdvancedParams && ( - handleUseAdvancedParamsChange(e.target.checked)}> - Use Advanced Parameters - +
+ handleUseAdvancedParamsChange(checked === true)} + aria-label="Use Advanced Parameters" + /> + +
)} {onMockTestFallbacksChange && ( -
- onMockTestFallbacksChange(e.target.checked)}> - Simulate failure to test fallbacks - - - - Causes the first request to fail so the router tries fallbacks (if configured). Use this to verify - your fallback setup. - - - Behavior can differ when keys, teams, or router settings are configured.{" "} - - Learn more - - -
- } - > - +
+ onMockTestFallbacksChange(checked === true)} + aria-label="Simulate failure to test fallbacks" + /> + + + + + + +

+ Causes the first request to fail so the router tries fallbacks (if configured). Use this to verify your + fallback setup. +

+

+ Behavior can differ when keys, teams, or router settings are configured.{" "} + + Learn more + +

+
)} {showAdvancedParams && ( -
+
-
+
- Temperature - - + + + + + + + Controls randomness. Lower values make output more deterministic, higher values more creative. +
- handleTemperatureTyped(event.target.value)} + onBlur={() => handleTemperatureChange(Number(temperatureText))} />
- handleTemperatureChange(Number(event.target.value))} /> +
+ 0 + 1.0 + 2.0 +
-
+
- Max Tokens - - + + + + + + + Maximum number of tokens to generate in the response. +
- handleMaxTokensTyped(event.target.value)} + onBlur={() => handleMaxTokensChange(Number(maxTokensText))} />
- handleMaxTokensChange(Number(event.target.value))} /> +
+ 1 + 32768 +
)} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.test.tsx new file mode 100644 index 00000000000..f3efb0a9b0f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.test.tsx @@ -0,0 +1,114 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ChatComposer } from "./ChatComposer"; + +const renderComposer = (props: Partial> = {}) => + render(); + +const addonOf = (container: HTMLElement) => + container.querySelector("[data-slot=input-group-addon]") as HTMLElement; + +describe("ChatComposer", () => { + it("should submit on Enter", () => { + const onSubmit = vi.fn(); + renderComposer({ onSubmit }); + + fireEvent.keyDown(screen.getByTestId("chat-composer-input"), { key: "Enter" }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it("should not submit on Shift+Enter", () => { + const onSubmit = vi.fn(); + renderComposer({ onSubmit }); + + fireEvent.keyDown(screen.getByTestId("chat-composer-input"), { key: "Enter", shiftKey: true }); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("should not submit while an IME composition is active", () => { + const onSubmit = vi.fn(); + renderComposer({ onSubmit }); + + fireEvent.keyDown(screen.getByTestId("chat-composer-input"), { key: "Enter", isComposing: true }); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("should not submit on Enter or click when submitDisabled", () => { + const onSubmit = vi.fn(); + renderComposer({ onSubmit, submitDisabled: true }); + + fireEvent.keyDown(screen.getByTestId("chat-composer-input"), { key: "Enter" }); + fireEvent.click(screen.getByTestId("chat-send-button")); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("should submit when the send button is clicked", () => { + const onSubmit = vi.fn(); + renderComposer({ onSubmit }); + + fireEvent.click(screen.getByTestId("chat-send-button")); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it("should swap send for a stop button that cancels while loading", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + renderComposer({ onSubmit, onCancel, isLoading: true }); + + expect(screen.queryByTestId("chat-send-button")).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId("chat-stop-button")); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("should render suggestions only when asked and report the chosen one", () => { + const onSuggestionSelect = vi.fn(); + const { rerender } = renderComposer({ suggestions: ["Summarize this"], onSuggestionSelect }); + + expect(screen.queryByTestId("chat-suggested-actions")).not.toBeInTheDocument(); + + rerender( + , + ); + fireEvent.click(screen.getByText("Summarize this")); + + expect(onSuggestionSelect).toHaveBeenCalledWith("Summarize this"); + }); + + it("should not nest a form inside the composer when body renders one", () => { + const { container } = renderComposer({ + body: ( +
+ +
+ ), + }); + + expect(container.querySelectorAll("form")).toHaveLength(1); + expect(screen.getByTestId("body-form")).toBeInTheDocument(); + }); + + it("should focus the message box, not a tool input, when the toolbar gap is clicked", () => { + const { container } = renderComposer({ + tools: , + }); + + fireEvent.click(addonOf(container)); + + expect(document.activeElement).toBe(screen.getByTestId("chat-composer-input")); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx new file mode 100644 index 00000000000..cc541c939ad --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx @@ -0,0 +1,173 @@ +import React from "react"; +import { ArrowUp, Code2, Square } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/cva.config"; + +interface ChatComposerProps { + value: string; + onChange: (value: string) => void; + onSubmit: () => void; + onCancel?: () => void; + placeholder: string; + disabled?: boolean; + isLoading?: boolean; + submitDisabled?: boolean; + tools?: React.ReactNode; + body?: React.ReactNode; + suggestions?: string[]; + showSuggestions?: boolean; + onSuggestionSelect?: (suggestion: string) => void; + className?: string; +} + +export function ChatComposer({ + value, + onChange, + onSubmit, + onCancel, + placeholder, + disabled = false, + isLoading = false, + submitDisabled = false, + tools, + body, + suggestions = [], + showSuggestions = false, + onSuggestionSelect, + className, +}: ChatComposerProps) { + const submitIfAllowed = () => { + if (!submitDisabled && !isLoading) { + onSubmit(); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { + event.preventDefault(); + submitIfAllowed(); + } + }; + + return ( +
+ {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion) => ( + + ))} +
+ )} + +
+ + {body ? ( +
{body}
+ ) : ( + onChange(event.target.value)} + onKeyDown={handleKeyDown} + /> + )} + + +
{tools}
+ + {isLoading && onCancel ? ( + + + + ) : ( + + + + )} +
+
+
+
+ ); +} + +interface CodeInterpreterToggleProps { + enabled: boolean; + onToggle: () => void; +} + +export function CodeInterpreterToggle({ enabled, onToggle }: CodeInterpreterToggleProps) { + return ( + + + } + > + + + + {enabled ? "Code Interpreter enabled (click to disable)" : "Enable Code Interpreter"} + + + ); +} + +export default ChatComposer; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatImageRenderer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatImageRenderer.tsx index 0bd0a94a52c..0605a1ce139 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatImageRenderer.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatImageRenderer.tsx @@ -1,8 +1,8 @@ import React from "react"; import Image from "next/image"; +import { FileText } from "lucide-react"; import { MessageType } from "@/components/chat_ui/types"; import { shouldShowChatAttachedImage } from "./ChatImageUtils"; -import { FilePdfOutlined } from "@ant-design/icons"; interface ChatImageRendererProps { message: MessageType; @@ -18,8 +18,8 @@ const ChatImageRenderer: React.FC = ({ message }) => { return (
{isPdf ? ( -
- +
+
) : ( false; + onImageUpload: (file: File) => void; onRemoveImage: () => void; + disabled?: boolean; } -const ChatImageUpload: React.FC = ({ - chatUploadedImage, - chatImagePreviewUrl, - onImageUpload, - onRemoveImage, -}) => { +const ChatImageUpload: React.FC = ({ chatUploadedImage, onImageUpload, disabled = false }) => { + const inputRef = useRef(null); + const inputId = useId(); + + if (chatUploadedImage) { + return null; + } + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) { + return; + } + const result = validateChatAttachment(file); + if (!result.ok) { + NotificationsManager.error(result.error); + return; + } + onImageUpload(file); + }; + return ( <> - {/* Subtle upload button - only show when no image */} - {!chatUploadedImage && ( - - - - - - )} + variant="ghost" + size="icon-sm" + disabled={disabled} + aria-label="Attach image or PDF" + className="text-gray-400 hover:text-gray-600" + onClick={() => inputRef.current?.click()} + /> + } + > + + + Attach image or PDF + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatMessageBubble.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatMessageBubble.tsx index 8e71017a7b5..68348143003 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatMessageBubble.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatMessageBubble.tsx @@ -1,4 +1,4 @@ -import { RobotOutlined, UserOutlined } from "@ant-design/icons"; +import { Bot, User } from "lucide-react"; import React from "react"; import ReactMarkdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; @@ -41,9 +41,9 @@ function ChatMessageBubble({ const isUser = message.role === "user"; return ( -
+
{/* Header: role icon + name + model badge */} -
+
{isUser ? ( - +
{message.role} {message.role === "assistant" && message.model && ( - + {message.model} )} 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 0c487af213e..de079cdebc9 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,11 +1,11 @@ import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { renderWithProviders as render } from "@/../tests/test-utils"; 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(), })); @@ -14,15 +14,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 = () => {}; }); @@ -30,17 +33,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" }, @@ -48,7 +61,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 only endpoint-compatible models when chat endpoint is selected", 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" }, + { model_group: "RealtimeModel", mode: "realtime" }, + { model_group: "NoModeModel" }, ]); - 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.getAllByText("NoModeModel").length).toBeGreaterThan(0); expect(screen.queryByText("SpeechModel")).toBeNull(); expect(screen.queryByText("ImageModel")).toBeNull(); expect(screen.queryByText("ResponsesModel")).toBeNull(); + expect(screen.queryByText("RealtimeModel")).toBeNull(); }); }); - /** - * 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( + it("shows only realtime models when realtime endpoint is selected", async () => { + (fetchModelsModule.fetchAvailableModels as ReturnType).mockResolvedValueOnce([ + { model_group: "ChatModel", mode: "chat" }, + { model_group: "RealtimeModel", mode: "realtime" }, + { model_group: "NoModeModel" }, + ]); + + 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 selectComboboxOption("Select an endpoint", "/v1/realtime"); + 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.getAllByText("RealtimeModel").length).toBeGreaterThan(0); + expect(screen.getAllByText("NoModeModel").length).toBeGreaterThan(0); + expect(screen.queryByText("ChatModel")).toBeNull(); + }); + }); + + it("should show 'Enter custom model' option in model selector", async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Test Key")).toBeInTheDocument(); + }); + + await openComboboxByPlaceholder("Select a Model"); + + await waitFor(() => { + expect(screen.getByText("Enter custom model")).toBeInTheDocument(); }); }); @@ -242,44 +223,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(); @@ -334,9 +272,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(); @@ -344,6 +280,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(); @@ -447,7 +367,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" }, ]); @@ -465,37 +386,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(); @@ -544,6 +442,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(); - } - }); + await waitFor(() => { + expect(screen.getByText("All MCP Servers")).toBeInTheDocument(); + }); + }); - const searchInput = document.querySelector(".ant-select-selection-search-input"); - expect(searchInput).toBeInTheDocument(); - } - } + it("should keep the chosen endpoint when a model that endpoint can serve is picked", async () => { + (fetchModelsModule.fetchAvailableModels as ReturnType).mockResolvedValueOnce([ + { model_group: "ChatModel", mode: "chat" }, + ]); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Test Key")).toBeInTheDocument(); + }); + + await selectComboboxOption("Select an endpoint", "/v1/responses"); + await selectComboboxOption("Select a Model", "ChatModel"); + + expect(screen.getByPlaceholderText("Select an endpoint")).toHaveValue("/v1/responses"); + }); + + it("should not offer a model the selected endpoint cannot serve", async () => { + (fetchModelsModule.fetchAvailableModels as ReturnType).mockResolvedValueOnce([ + { model_group: "ChatModel", mode: "chat" }, + { model_group: "SpeechModel", mode: "audio_speech" }, + ]); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Test Key")).toBeInTheDocument(); + }); + + await selectComboboxOption("Select an endpoint", "/v1/responses"); + await openComboboxByPlaceholder("Select a Model"); + + await waitFor(() => { + expect(screen.getAllByText("ChatModel").length).toBeGreaterThan(0); + }); + expect(screen.queryByText("SpeechModel")).toBeNull(); + }); + + it("should attach an audio file dropped on the transcription upload area", async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Test Key")).toBeInTheDocument(); + }); + + await selectComboboxOption("Select an endpoint", "/v1/audio/transcriptions"); + + const dropZone = (await screen.findByText("Click or drag audio file to upload")).closest("label"); + const file = new File(["clip"], "clip.wav", { type: "audio/wav" }); + fireEvent.drop(dropZone as HTMLElement, { dataTransfer: { files: [file] } }); + + expect(await screen.findByText("clip.wav")).toBeInTheDocument(); + }); + + it("should name the virtual key source options instead of showing raw values", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Test Key")).toBeInTheDocument(); + }); + + const keySourceTrigger = screen.getByLabelText("Virtual Key Source"); + expect(keySourceTrigger).toHaveTextContent("Current UI Session"); + expect(keySourceTrigger).not.toHaveTextContent("session"); + + await user.click(keySourceTrigger); + await user.click(await screen.findByRole("option", { name: "Virtual Key" })); + + await waitFor(() => { + expect(screen.getByLabelText("Virtual Key Source")).toHaveTextContent("Virtual Key"); + }); + expect(screen.getByLabelText("Virtual Key Source")).not.toHaveTextContent("custom"); + }); + + it("should re-enable the model selector when the virtual key is cleared mid-load", async () => { + const user = userEvent.setup(); + (fetchModelsModule.fetchAvailableModels as ReturnType).mockImplementation( + () => new Promise(() => {}), + ); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Test Key")).toBeInTheDocument(); + }); + + await user.click(screen.getByLabelText("Virtual Key Source")); + await user.click(await screen.findByRole("option", { name: "Virtual Key" })); + + const keyField = await screen.findByPlaceholderText("Enter custom Virtual Key"); + await user.type(keyField, "sk-test"); + + await waitFor(() => { + expect(screen.getByPlaceholderText("Loading models...")).toBeInTheDocument(); + }); + + await user.clear(keyField); + + await waitFor(() => { + expect(screen.getByPlaceholderText("Select a Model")).not.toBeDisabled(); + }); }); }); 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 0241ef8a77e..e81b86d4b8b 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,24 @@ "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"; + 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"; @@ -33,7 +30,7 @@ import { MCPServer } from "@/components/mcp_tools/types"; import { ByokCredentialModal } from "@/components/mcp_tools/ByokCredentialModal"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { callMCPTool, fetchMCPServers, fetchMCPToolsets, listMCPTools } from "@/components/networking"; -import { MCPToolset } from "@/components/mcp_tools/types"; +import { MCPTool, MCPToolset } from "@/components/mcp_tools/types"; import TagSelector from "@/components/tag_management/TagSelector"; import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector"; import { makeA2ASendMessageRequest } from "../../llm_calls/a2a_send_message"; @@ -50,11 +47,13 @@ import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api import { makeInteractionsRequest } from "../../llm_calls/interactions_api"; import AdditionalModelSettings from "./AdditionalModelSettings"; import { OPEN_AI_VOICE_SELECT_OPTIONS, OpenAIVoice } from "./chatConstants"; +import ChatComposer, { CodeInterpreterToggle } from "./ChatComposer"; import ChatImageUpload from "./ChatImageUpload"; import { createChatDisplayMessage, createChatMultimodalMessage } from "./ChatImageUtils"; import CodeInterpreterTool from "./CodeInterpreterTool"; import { generateCodeSnippet } from "@/components/chat_ui/CodeSnippets"; import EndpointSelector from "./EndpointSelector"; +import { filterModelsForEndpoint, isModelCompatibleWithEndpoint } from "./EndpointUtils"; import FilePreviewCard from "./FilePreviewCard"; import ChatMessageBubble from "./ChatMessageBubble"; import MCPEventsDisplay from "@/components/chat_ui/MCPEventsDisplay"; @@ -67,10 +66,22 @@ 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; - -const { TextArea } = Input; -const { Dragger } = Upload; +import { + AUDIO_ACCEPT, + IMAGE_EDIT_ACCEPT, + validateAudioFile, + validateChatAttachment, + validateImageEditFile, +} from "./uploadValidation"; interface ChatUIProps { accessToken: string | null; @@ -179,6 +190,8 @@ const ChatUI: React.FC = ({ const [selectedModel, setSelectedModel] = useState(simplified ? fixedModel : undefined); const [showCustomModelInput, setShowCustomModelInput] = useState(false); const [modelInfo, setModelInfo] = useState([]); + const [isLoadingModels, setIsLoadingModels] = useState(false); + const [modelLoadError, setModelLoadError] = useState(false); const [agentInfo, setAgentInfo] = useState([]); const [selectedAgent, setSelectedAgent] = useState(undefined); const debouncedSetSelectedModel = useDebouncedCallback((value: string) => setSelectedModel(value), { @@ -390,38 +403,53 @@ const ChatUI: React.FC = ({ ]); useEffect(() => { - let userApiKey = apiKeySource === "session" ? accessToken : apiKey; - if (!userApiKey || !token || !userRole || !userID) { + const userApiKey = apiKeySource === "session" ? accessToken : apiKey.trim(); + if (!userApiKey) { + setModelInfo([]); + setModelLoadError(false); + setIsLoadingModels(false); return; } - // Fetch model info and set the default selected model (skip in simplified mode; we use fixedModel) + let cancelled = false; + const loadModels = async () => { + setIsLoadingModels(true); + setModelLoadError(false); try { - if (!userApiKey) { + const uniqueModels = await fetchAvailableModels(userApiKey); + if (cancelled) { return; } - const uniqueModels = await fetchAvailableModels(userApiKey); setModelInfo(uniqueModels); - // check for selection overlap or empty model list - const hasSelection = uniqueModels.some((m) => m.model_group === selectedModel); - if (!uniqueModels.length) { - setSelectedModel(undefined); - } else if (!hasSelection) { - setSelectedModel(undefined); - } + setSelectedModel((currentModel) => + uniqueModels.some((model) => model.model_group === currentModel) ? currentModel : undefined, + ); } catch (error) { + if (cancelled) { + return; + } console.error("Error fetching model info:", error); + setModelInfo([]); + setModelLoadError(true); + } finally { + if (!cancelled) { + setIsLoadingModels(false); + } } }; if (!simplified) { - loadModels(); + void loadModels(); } - loadMCPServers(); - }, [accessToken, userID, userRole, apiKeySource, apiKey, token, simplified]); + void loadMCPServers(); + + return () => { + cancelled = true; + }; + }, [accessToken, apiKeySource, apiKey, simplified]); // Load tools when MCP direct mode has a server (or toolset) selected useEffect(() => { @@ -479,14 +507,6 @@ const ChatUI: React.FC = ({ } }, [chatHistory]); - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); // Prevent default to avoid newline - handleSendMessage(); - } - // If Shift+Enter is pressed, the default behavior (inserting a newline) will occur - }; - const handleCancelRequest = () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); @@ -496,13 +516,34 @@ const ChatUI: React.FC = ({ } }; - const handleImageUpload = (file: File) => { - setUploadedImages((prev) => [...prev, file]); + const createBlobPreviewUrl = (file: File): string => { const rawPreviewUrl = URL.createObjectURL(file); - // Sanitize: only allow blob: URLs to prevent XSS via img src injection. - const previewUrl = rawPreviewUrl.startsWith("blob:") ? rawPreviewUrl : ""; - setImagePreviewUrls((prev) => [...prev, previewUrl]); - return false; // Prevent default upload behavior + return rawPreviewUrl.startsWith("blob:") ? rawPreviewUrl : ""; + }; + + const handleImageFiles = (files: File[]) => { + let nextCount = uploadedImages.length; + const accepted: File[] = []; + const previews: string[] = []; + for (const file of files) { + const result = validateImageEditFile(file, nextCount); + if (!result.ok) { + NotificationsManager.error(result.error); + continue; + } + accepted.push(file); + previews.push(createBlobPreviewUrl(file)); + nextCount += 1; + } + if (accepted.length === 0) { + return; + } + setUploadedImages((prev) => [...prev, ...accepted]); + setImagePreviewUrls((prev) => [...prev, ...previews]); + }; + + const handleImageUpload = (file: File): void => { + handleImageFiles([file]); }; const handleRemoveImage = (index: number) => { @@ -521,11 +562,14 @@ const ChatUI: React.FC = ({ setImagePreviewUrls([]); }; - const handleResponsesImageUpload = (file: File): false => { + const handleResponsesImageUpload = (file: File): void => { + const result = validateChatAttachment(file); + if (!result.ok) { + NotificationsManager.error(result.error); + return; + } setResponsesUploadedImage(file); - const previewUrl = URL.createObjectURL(file); - setResponsesImagePreviewUrl(previewUrl); - return false; // Prevent default upload behavior + setResponsesImagePreviewUrl(createBlobPreviewUrl(file)); }; const handleRemoveResponsesImage = () => { @@ -536,11 +580,14 @@ const ChatUI: React.FC = ({ setResponsesImagePreviewUrl(null); }; - const handleChatImageUpload = (file: File): false => { + const handleChatImageUpload = (file: File): void => { + const result = validateChatAttachment(file); + if (!result.ok) { + NotificationsManager.error(result.error); + return; + } setChatUploadedImage(file); - const previewUrl = URL.createObjectURL(file); - setChatImagePreviewUrl(previewUrl); - return false; // Prevent default upload behavior + setChatImagePreviewUrl(createBlobPreviewUrl(file)); }; const handleRemoveChatImage = () => { @@ -551,9 +598,102 @@ 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; + } setUploadedAudio(file); - return false; // Prevent default upload behavior + }; + + const handleEndpointChange = (value: string) => { + setEndpointType(value); + setSelectedModel(undefined); + setSelectedAgent(undefined); + setShowCustomModelInput(false); + setSelectedMCPDirectTool(undefined); + if (value === EndpointType.MCP) { + setSelectedMCPServers((prev) => (prev.length === 1 && prev[0] !== "__all__" ? prev : [])); + } + try { + sessionStorage.removeItem("selectedModel"); + sessionStorage.removeItem("selectedAgent"); + } catch {} + }; + + const handleVoiceChange = (value: OpenAIVoice | null) => { + if (value == null) { + return; + } + setSelectedVoice(value); + sessionStorage.setItem("selectedVoice", value); + }; + + const handleAudioFileInputChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + handleAudioUpload(file); + } + event.target.value = ""; + }; + + 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 ?? undefined, + }); + } + 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 = () => { @@ -1004,8 +1144,12 @@ const ChatUI: React.FC = ({ const onModelChange = (value: string) => { setSelectedModel(value); - setShowCustomModelInput(value === "custom"); + + const model = modelInfo.find((option) => option.model_group === value); + if (model?.mode && !isModelCompatibleWithEndpoint(model, endpointType as EndpointType)) { + setEndpointType(getEndpointType(model.mode)); + } }; // Check if the selected model is a chat model @@ -1022,141 +1166,169 @@ const ChatUI: React.FC = ({ }; const supportsStreamingToggle = endpointType === EndpointType.CHAT || endpointType === EndpointType.RESPONSES; + const modelsForEndpoint = useMemo( + () => filterModelsForEndpoint(modelInfo, endpointType as EndpointType), + [modelInfo, endpointType], + ); + let modelEmptyText = "No models available for this key"; + if (modelLoadError) { + modelEmptyText = "Unable to load models for this key"; + } else if (apiKeySource === "custom" && !apiKey.trim()) { + modelEmptyText = "Enter a Virtual Key to load models"; + } else if (modelInfo.length > 0 && modelsForEndpoint.length === 0) { + modelEmptyText = "No models available for this endpoint"; + } - 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 - - 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 : [])); - } - try { - sessionStorage.removeItem("selectedModel"); - sessionStorage.removeItem("selectedAgent"); - } catch {} - }} + onEndpointChange={handleEndpointChange} className="mb-4" /> - {/* Voice Selector for Speech Endpoint */} {endpointType === EndpointType.SPEECH && (
- - + - + { - if (!option.mode) { - //If no mode, show all models - return true; - } - const optionEndpoint = getEndpointType(option.mode); - // Show chat models for responses/anthropic_messages/interactions endpoints as they are compatible - if ( - endpointType === EndpointType.RESPONSES || - endpointType === EndpointType.ANTHROPIC_MESSAGES || - endpointType === EndpointType.INTERACTIONS - ) { - return optionEndpoint === endpointType || optionEndpoint === EndpointType.CHAT; - } - // Show image models for image_edits endpoint as they are compatible - if (endpointType === EndpointType.IMAGE_EDITS) { - return optionEndpoint === endpointType || optionEndpoint === EndpointType.IMAGE; - } - return optionEndpoint === endpointType; - }) - .map((option) => option.model_group), - ), - ).map((model_group, index) => ({ - value: model_group, - label: model_group, - key: index, + { value: "custom", label: "Enter custom model" }, + ...modelsForEndpoint.map((model) => ({ + value: model.model_group, + label: model.model_group, + sublabel: model.mode ? `Mode: ${model.mode}` : undefined, })), ]} - style={{ width: "100%" }} - showSearch={true} - className="rounded-md" /> {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) => { @@ -1572,28 +1594,31 @@ const ChatUI: React.FC = ({ return (
- {serverName} requires your API key +

{serverName} requires your API key

{server.has_user_credential ? (
- - Connected + + Connected
) : ( - + )}
); @@ -1603,23 +1628,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 - - . - - } - > - +
+
= ({ {canViewPolicies && (
- - 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 +
+
)} @@ -1753,29 +1764,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
@@ -1785,27 +1793,41 @@ const ChatUI: React.FC = ({ )} {isLoading && ( -
- +
+
)}
-
- {/* Image Upload Section for Image Edits */} +
{endpointType === EndpointType.IMAGE_EDITS && (
{uploadedImages.length === 0 ? ( - -

- +

Click or drag images to upload

+

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

-

Click or drag images to upload

-

- Support for PNG, JPG, JPEG formats. Multiple images supported. -

-
+ { + handleImageFiles(Array.from(event.target.files || [])); + event.target.value = ""; + }} + /> + ) : (
{uploadedImages.map((file, index) => ( @@ -1822,80 +1844,89 @@ 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 ? ( - event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + const file = event.dataTransfer.files[0]; + if (file) { + handleAudioUpload(file); + } + }} > -

- -

-

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.

-
+ + ) : ( -
-
- +
+
+
- + + 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 && (
{[ @@ -1946,7 +1976,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 + ( - - ))} -
- )} - -
-
- {/* Left: attachment and code interpreter icons */} -
+ } + onSuggestionSelect={setInputMessage} + tools={ + <> {endpointType === EndpointType.RESPONSES && !responsesUploadedImage && ( = ({ onRemoveImage={handleRemoveChatImage} /> )} - {/* Quick Code Interpreter toggle for Responses */} {endpointType === EndpointType.RESPONSES && ( - - - + { + codeInterpreter.toggle(); + if (!codeInterpreter.enabled) { + NotificationsManager.success("Code Interpreter enabled!"); + } + }} + /> )} -
- - {/* Middle: input field or MCP structured form */} - {endpointType === EndpointType.MCP && + + } + body={ + endpointType === EndpointType.MCP && selectedMCPServers.length === 1 && selectedMCPServers[0] !== "__all__" && - selectedMCPDirectTool ? ( -
- {(() => { + selectedMCPDirectTool + ? (() => { const rawSel = selectedMCPServers[0]; - let toolPool: any[] = []; + let toolPool: MCPTool[] = []; if (rawSel.startsWith("toolset:")) { const toolsetId = rawSel.slice("toolset:".length); const toolset = mcpToolsets.find((t) => t.toolset_id === toolsetId); if (toolset) { const uniqueServerIds = [...new Set(toolset.tools.map((t) => t.server_id))]; uniqueServerIds.forEach((sid) => { - toolPool = toolPool.concat(serverToolsMap[sid] || []); + toolPool = toolPool.concat((serverToolsMap[sid] || []) as MCPTool[]); }); } } else { - toolPool = serverToolsMap[rawSel] || []; + toolPool = (serverToolsMap[rawSel] || []) as MCPTool[]; } - const mcpTool = toolPool.find((t: any) => t.name === selectedMCPDirectTool); + const mcpTool = toolPool.find((t) => t.name === selectedMCPDirectTool); return mcpTool ? ( ) : ( -
+
Loading tool schema...
); - })()} -
- ) : ( -