From 01c7434e08bf21540bb2013758ad7a7c63ad75f3 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 17 Mar 2026 17:03:24 -0700 Subject: [PATCH] [Refactor] UI - Playground: Decompose ChatUI into focused modules Break down the 2565-line ChatUI monolith into smaller, focused components and hooks. ChatUI now delegates rendering to extracted display components and state management to custom hooks while maintaining identical behavior. New display components: - GetCodeModal: code generation modal - SuggestedPrompts: prompt chip buttons - ChatHeader: title and action buttons - ChatMessageBubble: per-message rendering with markdown and metrics - ChatMessageList: message list container with empty state and spinner New custom hooks: - useChatHistory: chat state, 15 updater functions, session persistence - useFileUploads: image/audio upload state and handlers for all endpoints Also adds 42 Playwright E2E tests for ChatUI to guard against regressions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../e2e_tests/tests/playground/chatUI.spec.ts | 507 +++++++++++ .../playground/chat_ui/ChatHeader.tsx | 38 + .../playground/chat_ui/ChatMessageBubble.tsx | 207 +++++ .../playground/chat_ui/ChatMessageList.tsx | 96 +++ .../components/playground/chat_ui/ChatUI.tsx | 816 +++--------------- .../playground/chat_ui/GetCodeModal.tsx | 69 ++ .../playground/chat_ui/SuggestedPrompts.tsx | 40 + .../playground/chat_ui/useChatHistory.ts | 317 +++++++ .../playground/chat_ui/useFileUploads.ts | 142 +++ 9 files changed, 1515 insertions(+), 717 deletions(-) create mode 100644 ui/litellm-dashboard/e2e_tests/tests/playground/chatUI.spec.ts create mode 100644 ui/litellm-dashboard/src/components/playground/chat_ui/ChatHeader.tsx create mode 100644 ui/litellm-dashboard/src/components/playground/chat_ui/ChatMessageBubble.tsx create mode 100644 ui/litellm-dashboard/src/components/playground/chat_ui/ChatMessageList.tsx create mode 100644 ui/litellm-dashboard/src/components/playground/chat_ui/GetCodeModal.tsx create mode 100644 ui/litellm-dashboard/src/components/playground/chat_ui/SuggestedPrompts.tsx create mode 100644 ui/litellm-dashboard/src/components/playground/chat_ui/useChatHistory.ts create mode 100644 ui/litellm-dashboard/src/components/playground/chat_ui/useFileUploads.ts diff --git a/ui/litellm-dashboard/e2e_tests/tests/playground/chatUI.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/playground/chatUI.spec.ts new file mode 100644 index 00000000000..14247e4ab07 --- /dev/null +++ b/ui/litellm-dashboard/e2e_tests/tests/playground/chatUI.spec.ts @@ -0,0 +1,507 @@ +import { test, expect, Page as PlaywrightPage } from "@playwright/test"; +import { ADMIN_STORAGE_PATH } from "../../constants"; +import { Page } from "../../fixtures/pages"; +import { navigateToPage } from "../../helpers/navigation"; + +/** + * ChatUI E2E Tests + * + * These tests verify the ChatUI playground component renders and behaves correctly. + * They are designed to run before AND after a refactor to ensure no regressions. + * + * The tests cover: + * 1. Page layout & structure (sidebar, chat area, controls) + * 2. Endpoint switching and conditional UI + * 3. Model selector behavior + * 4. Chat input and suggested prompts + * 5. Clear chat functionality + * 6. Get Code modal + * 7. API key source switching + * 8. Custom Proxy Base URL + * 9. Conditional sections (voice, agent, MCP, image upload, audio upload) + */ + +test.describe("ChatUI Playground", () => { + test.use({ storageState: ADMIN_STORAGE_PATH }); + + /** Navigate to the playground and wait for it to load */ + async function goToPlayground(page: PlaywrightPage) { + await navigateToPage(page, Page.LlmPlayground); + // Suppress popups/banners + await page.evaluate(() => { + window.localStorage.setItem("disableUsageIndicator", "true"); + window.localStorage.setItem("disableShowPrompts", "true"); + window.localStorage.setItem("disableShowNewBadge", "true"); + }); + // Wait for the sidebar to appear + await expect(page.getByText("Configurations", { exact: true })).toBeVisible({ timeout: 15000 }); + // Dismiss any overlays + for (const label of ["Dismiss", "Don't ask me again"]) { + const btn = page.getByRole("button", { name: label }); + if (await btn.isVisible({ timeout: 500 }).catch(() => false)) { + await btn.click(); + } + } + } + + /** + * Open the endpoint dropdown and select an option. + * Uses the Ant Design Select's wrapper div to click (avoids combobox interception issues). + */ + async function switchEndpoint(page: PlaywrightPage, endpointTitle: string) { + // Find the select that currently shows a /v1/ or /mcp- endpoint value + const endpointSelectItem = page.locator(".ant-select-selection-item").filter({ + hasText: /^\//, + }).first(); + await endpointSelectItem.click(); + await page.getByTitle(endpointTitle, { exact: true }).click(); + // Wait for the new endpoint to be reflected + await expect( + page.locator(".ant-select-selection-item").filter({ hasText: endpointTitle }).first(), + ).toBeVisible(); + } + + // ────────────────────────────────────────────── + // 1. Page Layout & Structure + // ────────────────────────────────────────────── + + test.describe("Layout & Structure", () => { + test("renders the playground page with sidebar and chat area", async ({ page }) => { + await goToPlayground(page); + + // Left sidebar elements + await expect(page.getByText("Configurations", { exact: true })).toBeVisible(); + await expect(page.getByText("Virtual Key Source").first()).toBeVisible(); + await expect(page.getByText("Custom Proxy Base URL").first()).toBeVisible(); + await expect(page.getByText("Endpoint Type").first()).toBeVisible(); + await expect(page.getByText("Select Model").first()).toBeVisible(); + + // Main chat area + await expect(page.getByText("Test Key").first()).toBeVisible(); + await expect(page.getByRole("button", { name: /Clear Chat/ })).toBeVisible(); + await expect(page.getByRole("button", { name: /Get Code/ })).toBeVisible(); + + // Empty state message + await expect( + page.getByText("Start a conversation, generate an image, or handle audio"), + ).toBeVisible(); + }); + + test("shows suggested prompts when chat is empty", async ({ page }) => { + await goToPlayground(page); + + await expect(page.getByRole("button", { name: "Write me a poem" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Explain quantum computing" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Draft a polite email requesting a meeting" }), + ).toBeVisible(); + }); + + test("has a text input area with correct placeholder", async ({ page }) => { + await goToPlayground(page); + + await expect( + page.getByRole("textbox", { name: /Type your message/ }), + ).toBeVisible(); + }); + + test("sidebar shows Tags section", async ({ page }) => { + await goToPlayground(page); + await expect(page.getByText("Tags", { exact: true }).first()).toBeVisible(); + }); + + test("sidebar shows MCP Servers section", async ({ page }) => { + await goToPlayground(page); + await expect(page.getByText("MCP Servers").first()).toBeVisible(); + }); + + test("sidebar shows Vector Store section", async ({ page }) => { + await goToPlayground(page); + await expect(page.getByText("Vector Store").first()).toBeVisible(); + }); + + test("sidebar shows Guardrails section", async ({ page }) => { + await goToPlayground(page); + await expect(page.getByText("Guardrails", { exact: true }).first()).toBeVisible(); + }); + + test("sidebar shows Policies section", async ({ page }) => { + await goToPlayground(page); + await expect(page.getByText("Policies", { exact: true }).first()).toBeVisible(); + }); + + test("shows Chat tab as selected by default", async ({ page }) => { + await goToPlayground(page); + const chatTab = page.getByRole("tab", { name: "Chat" }); + await expect(chatTab).toBeVisible(); + await expect(chatTab).toHaveAttribute("aria-selected", "true"); + }); + + test("shows Compare and Compliance tabs", async ({ page }) => { + await goToPlayground(page); + await expect(page.getByRole("tab", { name: "Compare" })).toBeVisible(); + await expect(page.getByRole("tab", { name: "Compliance" })).toBeVisible(); + }); + }); + + // ────────────────────────────────────────────── + // 2. Endpoint Switching + // ────────────────────────────────────────────── + + test.describe("Endpoint Switching", () => { + test("defaults to /v1/chat/completions endpoint", async ({ page }) => { + await goToPlayground(page); + + await expect( + page.locator(".ant-select-selection-item").filter({ hasText: "/v1/chat/completions" }).first(), + ).toBeVisible(); + }); + + test("can switch to /v1/responses endpoint", async ({ page }) => { + await goToPlayground(page); + await switchEndpoint(page, "/v1/responses"); + + await expect( + page.locator(".ant-select-selection-item").filter({ hasText: "/v1/responses" }).first(), + ).toBeVisible(); + }); + + test("shows voice selector only for audio/speech endpoint", async ({ page }) => { + await goToPlayground(page); + + // Voice should NOT be visible on default chat endpoint + await expect(page.getByText("Voice", { exact: true })).not.toBeVisible(); + + await switchEndpoint(page, "/v1/audio/speech"); + + // Now Voice selector should be visible + await expect(page.getByText("Voice", { exact: true })).toBeVisible(); + }); + + test("shows agent selector only for A2A endpoint", async ({ page }) => { + await goToPlayground(page); + await expect(page.getByText("Select Agent")).not.toBeVisible(); + + await switchEndpoint(page, "/v1/a2a/message/send"); + await expect(page.getByText("Select Agent")).toBeVisible(); + }); + + test("hides model selector for A2A endpoint", async ({ page }) => { + await goToPlayground(page); + await expect(page.getByText("Select Model").first()).toBeVisible(); + + await switchEndpoint(page, "/v1/a2a/message/send"); + await expect(page.getByText("Select Model")).not.toBeVisible(); + }); + + test("hides model selector for MCP direct endpoint", async ({ page }) => { + await goToPlayground(page); + await switchEndpoint(page, "/mcp-rest/tools/call"); + await expect(page.getByText("Select Model")).not.toBeVisible(); + }); + + test("shows image upload area for image edits endpoint", async ({ page }) => { + await goToPlayground(page); + await switchEndpoint(page, "/v1/images/edits"); + await expect(page.getByText("Click or drag images to upload")).toBeVisible(); + }); + + test("shows audio upload area for transcription endpoint", async ({ page }) => { + await goToPlayground(page); + await switchEndpoint(page, "/v1/audio/transcriptions"); + await expect(page.getByText("Click or drag audio file to upload")).toBeVisible(); + }); + + test("changes placeholder text for image generation endpoint", async ({ page }) => { + await goToPlayground(page); + await switchEndpoint(page, "/v1/images/generations"); + await expect( + page.getByRole("textbox", { name: /Describe the image you want to generate/ }), + ).toBeVisible(); + }); + + test("changes placeholder text for speech endpoint", async ({ page }) => { + await goToPlayground(page); + await switchEndpoint(page, "/v1/audio/speech"); + await expect( + page.getByRole("textbox", { name: /Enter text to convert to speech/ }), + ).toBeVisible(); + }); + + test("shows A2A suggested prompts for A2A endpoint", async ({ page }) => { + await goToPlayground(page); + await switchEndpoint(page, "/v1/a2a/message/send"); + + await expect(page.getByRole("button", { name: "What can you help me with?" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Tell me about yourself" })).toBeVisible(); + await expect(page.getByRole("button", { name: "What tasks can you perform?" })).toBeVisible(); + }); + + test("MCP label changes from 'MCP Servers' to 'MCP Server' for MCP endpoint", async ({ page }) => { + await goToPlayground(page); + await expect(page.getByText("MCP Servers").first()).toBeVisible(); + + await switchEndpoint(page, "/mcp-rest/tools/call"); + // The text should now be singular (with trailing space or exact match) + await expect(page.locator("text=MCP Server").first()).toBeVisible(); + }); + + test("can switch to each major endpoint type", async ({ page }) => { + await goToPlayground(page); + + // Verify we can successfully switch to several different endpoint types + // This implicitly validates they exist in the dropdown + const endpointsToTest = [ + "/v1/responses", + "/v1/images/generations", + "/v1/audio/speech", + "/v1/embeddings", + ]; + + for (const endpoint of endpointsToTest) { + await switchEndpoint(page, endpoint); + await expect( + page.locator(".ant-select-selection-item").filter({ hasText: endpoint }).first(), + ).toBeVisible(); + } + }); + }); + + // ────────────────────────────────────────────── + // 3. Model Selector + // ────────────────────────────────────────────── + + test.describe("Model Selector", () => { + test("shows 'Enter custom model' option in model dropdown", async ({ page }) => { + await goToPlayground(page); + + // Click the model selector's wrapper div (avoids combobox input intercepting) + const modelSelect = page.locator(".ant-select-selector").filter({ + has: page.locator(".ant-select-selection-placeholder", { hasText: "Select a Model" }), + }).first(); + await modelSelect.click({ force: true }); + + await expect(page.getByText("Enter custom model")).toBeVisible(); + }); + + test("shows custom model input when 'Enter custom model' is selected", async ({ page }) => { + await goToPlayground(page); + + const modelSelect = page.locator(".ant-select-selector").filter({ + has: page.locator(".ant-select-selection-placeholder", { hasText: "Select a Model" }), + }).first(); + await modelSelect.click({ force: true }); + await page.getByText("Enter custom model").click(); + + await expect(page.getByPlaceholder("Enter custom model name")).toBeVisible(); + }); + }); + + // ────────────────────────────────────────────── + // 4. Chat Input & Suggested Prompts + // ────────────────────────────────────────────── + + test.describe("Chat Input", () => { + test("clicking a suggested prompt fills the input", async ({ page }) => { + await goToPlayground(page); + + await page.getByRole("button", { name: "Write me a poem" }).click(); + + const textarea = page.getByRole("textbox", { name: /Type your message/ }); + await expect(textarea).toHaveValue("Write me a poem"); + }); + + test("send button is disabled when input is empty", async ({ page }) => { + await goToPlayground(page); + + const sendButton = page.getByRole("button", { name: "arrow-up" }); + await expect(sendButton).toBeDisabled(); + }); + + test("send button becomes enabled when input has text", async ({ page }) => { + await goToPlayground(page); + + const textarea = page.getByRole("textbox", { name: /Type your message/ }); + await textarea.fill("Hello world"); + + const sendButton = page.getByRole("button", { name: "arrow-up" }); + await expect(sendButton).toBeEnabled(); + }); + }); + + // ────────────────────────────────────────────── + // 5. Clear Chat + // ────────────────────────────────────────────── + + test.describe("Clear Chat", () => { + test("clear chat button is visible and clickable", async ({ page }) => { + await goToPlayground(page); + + const clearBtn = page.getByRole("button", { name: /Clear Chat/ }); + await expect(clearBtn).toBeVisible(); + await clearBtn.click(); + + // Should show success notification + await expect(page.getByText("Chat history cleared.")).toBeVisible({ timeout: 5000 }); + }); + }); + + // ────────────────────────────────────────────── + // 6. Get Code Modal + // ────────────────────────────────────────────── + + test.describe("Get Code Modal", () => { + test("opens code modal when Get Code is clicked", async ({ page }) => { + await goToPlayground(page); + await page.getByRole("button", { name: /Get Code/ }).click(); + + await expect(page.getByText("Generated Code")).toBeVisible(); + await expect(page.getByText("SDK Type")).toBeVisible(); + await expect(page.getByRole("button", { name: /Copy to Clipboard/ })).toBeVisible(); + }); + + test("code modal defaults to OpenAI SDK", async ({ page }) => { + await goToPlayground(page); + await page.getByRole("button", { name: /Get Code/ }).click(); + + await expect( + page.locator(".ant-select-selection-item").filter({ hasText: "OpenAI SDK" }), + ).toBeVisible(); + }); + + test("code modal can switch to Azure SDK", async ({ page }) => { + await goToPlayground(page); + await page.getByRole("button", { name: /Get Code/ }).click(); + await expect(page.getByText("Generated Code")).toBeVisible(); + + // Click the SDK type selector (shows "OpenAI SDK") + const sdkSelectItem = page.locator(".ant-select-selection-item").filter({ + hasText: "OpenAI SDK", + }); + await sdkSelectItem.click(); + + // Select Azure SDK from the dropdown + await page.getByTitle("Azure SDK", { exact: true }).click(); + + await expect( + page.locator(".ant-select-selection-item").filter({ hasText: "Azure SDK" }), + ).toBeVisible(); + }); + + test("code modal can be closed", async ({ page }) => { + await goToPlayground(page); + await page.getByRole("button", { name: /Get Code/ }).click(); + await expect(page.getByText("Generated Code")).toBeVisible(); + + // Close via the X button on the modal + const closeBtn = page.locator(".ant-modal-close").first(); + await closeBtn.click(); + await expect(page.getByText("Generated Code")).not.toBeVisible(); + }); + }); + + // ────────────────────────────────────────────── + // 7. API Key Source + // ────────────────────────────────────────────── + + test.describe("API Key Source", () => { + test("defaults to Current UI Session", async ({ page }) => { + await goToPlayground(page); + + await expect( + page.locator(".ant-select-selection-item").filter({ hasText: "Current UI Session" }).first(), + ).toBeVisible(); + }); + + test("switching to Virtual Key shows custom key input", async ({ page }) => { + await goToPlayground(page); + + // Click the "Current UI Session" text in the selector + const currentSessionItem = page.locator(".ant-select-selection-item").filter({ + hasText: "Current UI Session", + }).first(); + await currentSessionItem.click(); + await page.getByTitle("Virtual Key", { exact: true }).click(); + + await expect(page.getByPlaceholder("Enter custom Virtual Key")).toBeVisible(); + }); + }); + + // ────────────────────────────────────────────── + // 8. Custom Proxy Base URL + // ────────────────────────────────────────────── + + test.describe("Custom Proxy Base URL", () => { + test("shows custom proxy URL input", async ({ page }) => { + await goToPlayground(page); + await expect( + page.getByRole("textbox", { name: /Enter custom proxy URL/ }), + ).toBeVisible(); + }); + + test("entering custom proxy URL shows confirmation text", async ({ page }) => { + await goToPlayground(page); + + const proxyInput = page.getByRole("textbox", { name: /Enter custom proxy URL/ }); + await proxyInput.fill("http://my-proxy:8080"); + + await expect(page.getByText("API calls will be sent to: http://my-proxy:8080")).toBeVisible(); + }); + }); + + // ────────────────────────────────────────────── + // 9. Code Interpreter (Responses endpoint) + // ────────────────────────────────────────────── + + test.describe("Code Interpreter", () => { + test("code interpreter section is visible for responses endpoint", async ({ page }) => { + await goToPlayground(page); + await switchEndpoint(page, "/v1/responses"); + + await expect(page.getByText("Code Interpreter").first()).toBeVisible(); + }); + }); + + // ────────────────────────────────────────────── + // 10. Endpoint-specific Feature Combinations + // ────────────────────────────────────────────── + + test.describe("Endpoint Feature Combinations", () => { + test("switching endpoints resets model selection", async ({ page }) => { + await goToPlayground(page); + await switchEndpoint(page, "/v1/responses"); + + // The model select should show "Select a Model" placeholder (not a selected value) + const modelPlaceholder = page.locator(".ant-select-selection-placeholder").filter({ + hasText: "Select a Model", + }); + await expect(modelPlaceholder.first()).toBeVisible(); + }); + + test("embeddings endpoint shows correct placeholder", async ({ page }) => { + await goToPlayground(page); + await switchEndpoint(page, "/v1/embeddings"); + + await expect( + page.getByRole("textbox", { name: /Type your message/ }), + ).toBeVisible(); + }); + + test("image edits endpoint shows edit description placeholder", async ({ page }) => { + await goToPlayground(page); + await switchEndpoint(page, "/v1/images/edits"); + + await expect( + page.getByRole("textbox", { name: /Describe how you want to edit the image/ }), + ).toBeVisible(); + }); + + test("transcription endpoint has disabled send when no audio uploaded", async ({ page }) => { + await goToPlayground(page); + await switchEndpoint(page, "/v1/audio/transcriptions"); + + const sendButton = page.getByRole("button", { name: "arrow-up" }); + await expect(sendButton).toBeDisabled(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatHeader.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatHeader.tsx new file mode 100644 index 00000000000..e89062bd1a3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatHeader.tsx @@ -0,0 +1,38 @@ +import { ClearOutlined, CodeOutlined } from "@ant-design/icons"; +import { Title, Button as TremorButton } from "@tremor/react"; + +interface ChatHeaderProps { + simplified: boolean; + onClearChat: () => void; + onGetCode: () => void; +} + +function ChatHeader({ simplified, onClearChat, onGetCode }: ChatHeaderProps) { + return ( +
+ + {simplified ? "Chat" : "Test Key"} + +
+ + Clear Chat + + {!simplified && ( + + Get Code + + )} +
+
+ ); +} + +export default ChatHeader; diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatMessageBubble.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatMessageBubble.tsx new file mode 100644 index 00000000000..3feaf690599 --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatMessageBubble.tsx @@ -0,0 +1,207 @@ +import { RobotOutlined, UserOutlined } from "@ant-design/icons"; +import React from "react"; +import ReactMarkdown from "react-markdown"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; +import type { MCPEvent } from "../../mcp_tools/types"; +import type { CodeInterpreterResult } from "../llm_calls/code_interpreter_handler"; +import A2AMetrics from "./A2AMetrics"; +import AudioRenderer from "./AudioRenderer"; +import ChatImageRenderer from "./ChatImageRenderer"; +import CodeInterpreterOutput from "./CodeInterpreterOutput"; +import MCPEventsDisplay from "./MCPEventsDisplay"; +import { EndpointType } from "./mode_endpoint_mapping"; +import ReasoningContent from "./ReasoningContent"; +import ResponseMetrics from "./ResponseMetrics"; +import ResponsesImageRenderer from "./ResponsesImageRenderer"; +import { SearchResultsDisplay } from "./SearchResultsDisplay"; +import { MessageType } from "./types"; + +interface ChatMessageBubbleProps { + message: MessageType; + index: number; + isLastMessage: boolean; + endpointType: string; + mcpEvents: MCPEvent[]; + codeInterpreterResult: CodeInterpreterResult | null; + accessToken: string; +} + +function ChatMessageBubble({ + message, + index, + isLastMessage, + endpointType, + mcpEvents, + codeInterpreterResult, + accessToken, +}: ChatMessageBubbleProps) { + return ( +
+
+
+
+
+ {message.role === "user" ? ( + + ) : ( + + )} +
+ {message.role} + {message.role === "assistant" && message.model && ( + + {message.model} + + )} +
+ {message.reasoningContent && } + + {/* Show MCP events at the start of assistant messages */} + {message.role === "assistant" && + isLastMessage && + mcpEvents.length > 0 && + (endpointType === EndpointType.RESPONSES || endpointType === EndpointType.CHAT) && ( +
+ +
+ )} + + {/* Show search results at the start of assistant messages */} + {message.role === "assistant" && message.searchResults && ( + + )} + + {/* Show Code Interpreter output for the last assistant message */} + {message.role === "assistant" && + isLastMessage && + codeInterpreterResult && + endpointType === EndpointType.RESPONSES && ( + + )} + +
+ {message.isImage ? ( + Generated image + ) : message.isAudio ? ( + + ) : ( + <> + {/* Show attached image for user messages based on current endpoint */} + {endpointType === EndpointType.RESPONSES && } + {endpointType === EndpointType.CHAT && } + + & { + inline?: boolean; + node?: unknown; + }) { + const match = /language-(\w+)/.exec(className || ""); + return !inline && match ? ( + + {String(children).replace(/\n$/, "")} + + ) : ( + + {children} + + ); + }, + pre: ({ node, ...props }) => ( +
+                    ),
+                  }}
+                >
+                  {typeof message.content === "string" ? message.content : ""}
+                
+
+                {/* Show generated image from chat completions */}
+                {message.image && (
+                  
+ Generated image +
+ )} + + )} + + {message.role === "assistant" && + (message.timeToFirstToken || message.totalLatency || message.usage) && + !message.a2aMetadata && ( + + )} + + {/* A2A Metrics - show for A2A agent responses */} + {message.role === "assistant" && message.a2aMetadata && ( + + )} +
+
+
+
+ ); +} + +export default ChatMessageBubble; diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatMessageList.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatMessageList.tsx new file mode 100644 index 00000000000..9170a70fc8b --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatMessageList.tsx @@ -0,0 +1,96 @@ +import { LoadingOutlined, RobotOutlined } from "@ant-design/icons"; +import { Text } from "@tremor/react"; +import { Spin } from "antd"; +import React from "react"; +import type { MCPEvent } from "../../mcp_tools/types"; +import type { CodeInterpreterResult } from "../llm_calls/code_interpreter_handler"; +import ChatMessageBubble from "./ChatMessageBubble"; +import MCPEventsDisplay from "./MCPEventsDisplay"; +import { EndpointType } from "./mode_endpoint_mapping"; +import { MessageType } from "./types"; + +interface ChatMessageListProps { + chatHistory: MessageType[]; + endpointType: string; + mcpEvents: MCPEvent[]; + codeInterpreterResult: CodeInterpreterResult | null; + isLoading: boolean; + accessToken: string; + chatEndRef: React.RefObject; +} + +const antIcon = ; + +function ChatMessageList({ + chatHistory, + endpointType, + mcpEvents, + codeInterpreterResult, + isLoading, + accessToken, + chatEndRef, +}: ChatMessageListProps) { + return ( +
+ {chatHistory.length === 0 && ( +
+ + Start a conversation, generate an image, or handle audio +
+ )} + + {chatHistory.map((message, index) => ( + + ))} + + {/* 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 +
+ +
+
+ )} + + {isLoading && ( +
+ +
+ )} +
+
+ ); +} + +export default ChatMessageList; diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx index eba9d376699..ea2f0932057 100644 --- a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx @@ -3,7 +3,6 @@ import { ApiOutlined, ArrowUpOutlined, - ClearOutlined, CodeOutlined, DatabaseOutlined, DeleteOutlined, @@ -19,16 +18,11 @@ import { SoundOutlined, TagsOutlined, ToolOutlined, - UserOutlined, } from "@ant-design/icons"; import { Card, Text, TextInput, Title, Button as TremorButton } from "@tremor/react"; -import { Button, Input, Modal, Popover, Select, Spin, Tooltip, Typography, Upload } from "antd"; +import { Button, Input, Popover, Select, Spin, Tooltip, Typography, Upload } from "antd"; import React, { useEffect, useRef, useState } from "react"; -import ReactMarkdown from "react-markdown"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; import { v4 as uuidv4 } from "uuid"; -import { truncateString } from "../../../utils/textUtils"; import GuardrailSelector from "../../guardrails/GuardrailSelector"; import PolicySelector from "../../policies/PolicySelector"; import MCPToolArgumentsForm, { MCPToolArgumentsFormRef } from "../../mcp_tools/MCPToolArgumentsForm"; @@ -49,30 +43,27 @@ import { fetchAvailableModels, ModelGroup } from "../llm_calls/fetch_models"; import { makeOpenAIImageEditsRequest } from "../llm_calls/image_edits"; import { makeOpenAIImageGenerationRequest } from "../llm_calls/image_generation"; import { makeOpenAIResponsesRequest } from "../llm_calls/responses_api"; -import A2AMetrics from "./A2AMetrics"; import AdditionalModelSettings from "./AdditionalModelSettings"; -import AudioRenderer from "./AudioRenderer"; +import ChatHeader from "./ChatHeader"; import { OPEN_AI_VOICE_SELECT_OPTIONS, OpenAIVoice } from "./chatConstants"; -import ChatImageRenderer from "./ChatImageRenderer"; import ChatImageUpload from "./ChatImageUpload"; +import ChatMessageList from "./ChatMessageList"; import { createChatDisplayMessage, createChatMultimodalMessage } from "./ChatImageUtils"; -import CodeInterpreterOutput from "./CodeInterpreterOutput"; import CodeInterpreterTool from "./CodeInterpreterTool"; import { generateCodeSnippet } from "./CodeSnippets"; import EndpointSelector from "./EndpointSelector"; -import MCPEventsDisplay from "./MCPEventsDisplay"; +import GetCodeModal from "./GetCodeModal"; +import SuggestedPrompts from "./SuggestedPrompts"; import type { MCPEvent } from "../../mcp_tools/types"; import { EndpointType, getEndpointType } from "./mode_endpoint_mapping"; -import ReasoningContent from "./ReasoningContent"; -import ResponseMetrics, { TokenUsage } from "./ResponseMetrics"; -import ResponsesImageRenderer from "./ResponsesImageRenderer"; import ResponsesImageUpload from "./ResponsesImageUpload"; import { createDisplayMessage, createMultimodalMessage } from "./ResponsesImageUtils"; -import { SearchResultsDisplay } from "./SearchResultsDisplay"; import SessionManagement from "./SessionManagement"; import RealtimePlayground from "./RealtimePlayground"; -import { A2ATaskMetadata, MessageType } from "./types"; +import { MessageType } from "./types"; +import useChatHistory from "./useChatHistory"; import { useCodeInterpreter } from "./useCodeInterpreter"; +import useFileUploads from "./useFileUploads"; const { TextArea } = Input; const { Dragger } = Upload; @@ -148,17 +139,59 @@ const ChatUI: React.FC = ({ const [customProxyBaseUrl, setCustomProxyBaseUrl] = useState( () => sessionStorage.getItem("customProxyBaseUrl") || "", ); - const [inputMessage, setInputMessage] = useState(""); - const [chatHistory, setChatHistory] = useState(() => { - if (simplified) return []; - try { - const saved = sessionStorage.getItem("chatHistory"); - return saved ? JSON.parse(saved) : []; - } catch (error) { - console.error("Error parsing chatHistory from sessionStorage", error); - return []; - } + // File uploads hook (manages images/audio for all endpoint types) + const fileUploads = useFileUploads(); + const { + uploadedImages, + imagePreviewUrls, + handleImageUpload, + handleRemoveImage, + responsesUploadedImage, + responsesImagePreviewUrl, + handleResponsesImageUpload, + handleRemoveResponsesImage, + chatUploadedImage, + chatImagePreviewUrl, + handleChatImageUpload, + handleRemoveChatImage, + uploadedAudio, + handleAudioUpload, + handleRemoveAudio, + } = fileUploads; + + // Chat history hook (manages chatHistory, inputMessage, mcpEvents, session IDs, and all updater functions) + const chatHistoryHook = useChatHistory({ + simplified, + onClearUploads: fileUploads.clearAllUploads, }); + const { + chatHistory, + setChatHistory, + inputMessage, + setInputMessage, + messageTraceId, + setMessageTraceId, + responsesSessionId, + useApiSessionManagement, + mcpEvents, + chatEndRef, + updateTextUI, + updateReasoningContent, + updateTimingData, + updateUsageData, + updateA2AMetadata, + updateTotalLatency, + updateSearchResults, + updateImageUI, + updateEmbeddingsUI, + updateAudioUI, + updateChatImageUI, + handleResponseId, + handleToggleSessionManagement, + handleMCPEvent, + clearMCPEvents, + clearChatHistory, + } = chatHistoryHook; const [selectedModel, setSelectedModel] = useState(simplified ? fixedModel : undefined); const [showCustomModelInput, setShowCustomModelInput] = useState(false); const [modelInfo, setModelInfo] = useState([]); @@ -216,27 +249,12 @@ const ChatUI: React.FC = ({ return []; } }); - const [messageTraceId, setMessageTraceId] = useState( - () => sessionStorage.getItem("messageTraceId") || null, - ); - const [responsesSessionId, setResponsesSessionId] = useState( - () => sessionStorage.getItem("responsesSessionId") || null, - ); - const [useApiSessionManagement, setUseApiSessionManagement] = useState(() => { - const saved = sessionStorage.getItem("useApiSessionManagement"); - return saved ? JSON.parse(saved) : true; // Default to API session management - }); - const [uploadedImages, setUploadedImages] = useState([]); - const [imagePreviewUrls, setImagePreviewUrls] = useState([]); - const [responsesUploadedImage, setResponsesUploadedImage] = useState(null); - const [responsesImagePreviewUrl, setResponsesImagePreviewUrl] = useState(null); - const [chatUploadedImage, setChatUploadedImage] = useState(null); - const [chatImagePreviewUrl, setChatImagePreviewUrl] = useState(null); - const [uploadedAudio, setUploadedAudio] = useState(null); + // messageTraceId, responsesSessionId, useApiSessionManagement provided by useChatHistory + // File upload state provided by useFileUploads const [isGetCodeModalVisible, setIsGetCodeModalVisible] = useState(false); const [generatedCode, setGeneratedCode] = useState(""); const [selectedSdk, setSelectedSdk] = useState<"openai" | "azure">("openai"); - const [mcpEvents, setMCPEvents] = useState([]); + // mcpEvents provided by useChatHistory const [temperature, setTemperature] = useState(1.0); const [maxTokens, setMaxTokens] = useState(2048); const [useAdvancedParams, setUseAdvancedParams] = useState(false); @@ -245,7 +263,7 @@ const ChatUI: React.FC = ({ // Code Interpreter state (using custom hook) const codeInterpreter = useCodeInterpreter(); - const chatEndRef = useRef(null); + // chatEndRef provided by useChatHistory // Fetch MCP servers const loadMCPServers = async () => { @@ -330,16 +348,7 @@ const ChatUI: React.FC = ({ proxySettings, ]); - useEffect(() => { - if (simplified) return; // Do not persist chat history in simplified (embedded) mode - const handler = setTimeout(() => { - sessionStorage.setItem("chatHistory", JSON.stringify(chatHistory)); - }, 500); // Debounce by 500ms - - return () => { - clearTimeout(handler); - }; - }, [chatHistory, simplified]); + // chatHistory persistence is handled by useChatHistory useEffect(() => { sessionStorage.setItem("apiKeySource", JSON.stringify(apiKeySource)); @@ -361,18 +370,8 @@ const ChatUI: React.FC = ({ sessionStorage.removeItem("selectedModel"); } } - if (messageTraceId) { - sessionStorage.setItem("messageTraceId", messageTraceId); - } else { - sessionStorage.removeItem("messageTraceId"); - } - if (responsesSessionId) { - sessionStorage.setItem("responsesSessionId", responsesSessionId); - } else { - sessionStorage.removeItem("responsesSessionId"); - } - sessionStorage.setItem("useApiSessionManagement", JSON.stringify(useApiSessionManagement)); - // Note: codeInterpreterEnabled and selectedContainerId are persisted by useCodeInterpreter hook + // messageTraceId, responsesSessionId, useApiSessionManagement persistence handled by useChatHistory + // codeInterpreterEnabled and selectedContainerId are persisted by useCodeInterpreter hook }, [ simplified, apiKeySource, @@ -383,9 +382,6 @@ const ChatUI: React.FC = ({ selectedVectorStores, selectedGuardrails, selectedPolicies, - messageTraceId, - responsesSessionId, - useApiSessionManagement, selectedMCPServers, mcpServerToolRestrictions, selectedVoice, @@ -464,276 +460,11 @@ const ChatUI: React.FC = ({ loadAgents(); }, [accessToken, apiKeySource, apiKey, endpointType, customProxyBaseUrl, selectedAgent]); - useEffect(() => { - // Scroll to the bottom of the chat whenever chatHistory updates - if (chatEndRef.current) { - // Add a small delay to ensure content is rendered - setTimeout(() => { - chatEndRef.current?.scrollIntoView({ - behavior: "smooth", - block: "end", // Keep the scroll position at the end - }); - }, 100); - } - }, [chatHistory]); + // Auto-scroll on chat update is handled by useChatHistory - const updateTextUI = (role: string, chunk: string, model?: string) => { - console.log("updateTextUI called with:", role, chunk, model); - setChatHistory((prev) => { - const last = prev[prev.length - 1]; - // if the last message is already from this same role, append - if (last && last.role === role && !last.isImage && !last.isAudio) { - // build a new object, but only set `model` if it wasn't there already - const updated: MessageType = { - ...last, - content: last.content + chunk, - model: last.model ?? model, // ← only use the passed‐in model on the first chunk - }; - return [...prev.slice(0, -1), updated]; - } else { - // otherwise start a brand new assistant bubble - return [ - ...prev, - { - role, - content: chunk, - model, // model set exactly once here - }, - ]; - } - }); - }; - - const updateReasoningContent = (chunk: string) => { - setChatHistory((prevHistory) => { - const lastMessage = prevHistory[prevHistory.length - 1]; - - if (lastMessage && lastMessage.role === "assistant" && !lastMessage.isImage && !lastMessage.isAudio) { - return [ - ...prevHistory.slice(0, prevHistory.length - 1), - { - ...lastMessage, - reasoningContent: (lastMessage.reasoningContent || "") + chunk, - }, - ]; - } else { - // If there's no assistant message yet, we'll create one with empty content - // but with reasoning content - if (prevHistory.length > 0 && prevHistory[prevHistory.length - 1].role === "user") { - return [ - ...prevHistory, - { - role: "assistant", - content: "", - reasoningContent: chunk, - }, - ]; - } - - return prevHistory; - } - }); - }; - - const updateTimingData = (timeToFirstToken: number) => { - console.log("updateTimingData called with:", timeToFirstToken); - setChatHistory((prevHistory) => { - const lastMessage = prevHistory[prevHistory.length - 1]; - console.log("Current last message:", lastMessage); - - if (lastMessage && lastMessage.role === "assistant") { - console.log("Updating assistant message with timeToFirstToken:", timeToFirstToken); - const updatedHistory = [ - ...prevHistory.slice(0, prevHistory.length - 1), - { - ...lastMessage, - timeToFirstToken, - }, - ]; - console.log("Updated chat history:", updatedHistory); - return updatedHistory; - } - // If the last message is a user message and no assistant message exists yet, - // create a new assistant message with empty content - else if (lastMessage && lastMessage.role === "user") { - console.log("Creating new assistant message with timeToFirstToken:", timeToFirstToken); - return [ - ...prevHistory, - { - role: "assistant", - content: "", - timeToFirstToken, - }, - ]; - } - - console.log("No appropriate message found to update timing"); - return prevHistory; - }); - }; - - const updateUsageData = (usage: TokenUsage, toolName?: string) => { - console.log("Received usage data:", usage); - setChatHistory((prevHistory) => { - const lastMessage = prevHistory[prevHistory.length - 1]; - - if (lastMessage && lastMessage.role === "assistant") { - console.log("Updating message with usage data:", usage); - const updatedMessage = { - ...lastMessage, - usage, - toolName, - }; - console.log("Updated message:", updatedMessage); - - return [...prevHistory.slice(0, prevHistory.length - 1), updatedMessage]; - } - - return prevHistory; - }); - }; - - const updateA2AMetadata = (a2aMetadata: A2ATaskMetadata) => { - console.log("Received A2A metadata:", a2aMetadata); - setChatHistory((prevHistory) => { - const lastMessage = prevHistory[prevHistory.length - 1]; - - if (lastMessage && lastMessage.role === "assistant") { - const updatedMessage = { - ...lastMessage, - a2aMetadata, - }; - return [...prevHistory.slice(0, prevHistory.length - 1), updatedMessage]; - } - - return prevHistory; - }); - }; - - const updateTotalLatency = (totalLatency: number) => { - setChatHistory((prevHistory) => { - const lastMessage = prevHistory[prevHistory.length - 1]; - - if (lastMessage && lastMessage.role === "assistant") { - return [ - ...prevHistory.slice(0, prevHistory.length - 1), - { - ...lastMessage, - totalLatency, - }, - ]; - } - - return prevHistory; - }); - }; - - const updateSearchResults = (searchResults: any[]) => { - console.log("Received search results:", searchResults); - setChatHistory((prevHistory) => { - const lastMessage = prevHistory[prevHistory.length - 1]; - - if (lastMessage && lastMessage.role === "assistant") { - console.log("Updating message with search results"); - const updatedMessage = { - ...lastMessage, - searchResults, - }; - - return [...prevHistory.slice(0, prevHistory.length - 1), updatedMessage]; - } - - return prevHistory; - }); - }; - - const handleResponseId = (responseId: string) => { - console.log("Received response ID for session management:", responseId); - if (useApiSessionManagement) { - setResponsesSessionId(responseId); - } - }; - - const handleToggleSessionManagement = (useApi: boolean) => { - setUseApiSessionManagement(useApi); - if (!useApi) { - // Clear API session when switching to UI mode - setResponsesSessionId(null); - } - }; - - const handleMCPEvent = (event: MCPEvent) => { - console.log("ChatUI: Received MCP event:", event); - setMCPEvents((prev) => { - // Check if this is a duplicate event (same item_id and type) - // Only check for duplicates if item_id is defined (for mcp_list_tools, item_id is "mcp_list_tools") - const isDuplicate = event.item_id - ? prev.some( - (existingEvent) => - existingEvent.item_id === event.item_id && - existingEvent.type === event.type && - (existingEvent.sequence_number === event.sequence_number || - (existingEvent.sequence_number === undefined && event.sequence_number === undefined)), - ) - : false; - - if (isDuplicate) { - console.log("ChatUI: Duplicate MCP event, skipping"); - return prev; - } - - const newEvents = [...prev, event]; - console.log("ChatUI: Updated MCP events:", newEvents); - return newEvents; - }); - }; - - const updateImageUI = (imageUrl: string, model: string) => { - setChatHistory((prevHistory) => [...prevHistory, { role: "assistant", content: imageUrl, model, isImage: true }]); - }; - - const updateEmbeddingsUI = (embeddings: string, model?: string) => { - setChatHistory((prevHistory) => [ - ...prevHistory, - { role: "assistant", content: truncateString(embeddings, 100), model, isEmbeddings: true }, - ]); - }; - - const updateAudioUI = (audioUrl: string, model: string) => { - setChatHistory((prevHistory) => [...prevHistory, { role: "assistant", content: audioUrl, model, isAudio: true }]); - }; - - const updateChatImageUI = (imageUrl: string, model?: string) => { - setChatHistory((prev) => { - const last = prev[prev.length - 1]; - // If the last message is from assistant and has content, add image to it - if (last && last.role === "assistant" && !last.isImage && !last.isAudio) { - const updated = { - ...last, - image: { - url: imageUrl, - detail: "auto", - }, - model: last.model ?? model, - }; - return [...prev.slice(0, -1), updated]; - } else { - // Otherwise create a new assistant message with just the image - return [ - ...prev, - { - role: "assistant", - content: "", - model, - image: { - url: imageUrl, - detail: "auto", - }, - }, - ]; - } - }); - }; + // All chat history updater functions (updateTextUI, updateReasoningContent, etc.) + // handleResponseId, handleToggleSessionManagement, handleMCPEvent, and clearChatHistory + // are provided by useChatHistory above const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey) { @@ -752,67 +483,7 @@ const ChatUI: React.FC = ({ } }; - const handleImageUpload = (file: File) => { - setUploadedImages((prev) => [...prev, file]); - const previewUrl = URL.createObjectURL(file); - setImagePreviewUrls((prev) => [...prev, previewUrl]); - return false; // Prevent default upload behavior - }; - - const handleRemoveImage = (index: number) => { - if (imagePreviewUrls[index]) { - URL.revokeObjectURL(imagePreviewUrls[index]); - } - setUploadedImages((prev) => prev.filter((_, i) => i !== index)); - setImagePreviewUrls((prev) => prev.filter((_, i) => i !== index)); - }; - - const handleRemoveAllImages = () => { - imagePreviewUrls.forEach((url) => { - URL.revokeObjectURL(url); - }); - setUploadedImages([]); - setImagePreviewUrls([]); - }; - - const handleResponsesImageUpload = (file: File): false => { - setResponsesUploadedImage(file); - const previewUrl = URL.createObjectURL(file); - setResponsesImagePreviewUrl(previewUrl); - return false; // Prevent default upload behavior - }; - - const handleRemoveResponsesImage = () => { - if (responsesImagePreviewUrl) { - URL.revokeObjectURL(responsesImagePreviewUrl); - } - setResponsesUploadedImage(null); - setResponsesImagePreviewUrl(null); - }; - - const handleChatImageUpload = (file: File): false => { - setChatUploadedImage(file); - const previewUrl = URL.createObjectURL(file); - setChatImagePreviewUrl(previewUrl); - return false; // Prevent default upload behavior - }; - - const handleRemoveChatImage = () => { - if (chatImagePreviewUrl) { - URL.revokeObjectURL(chatImagePreviewUrl); - } - setChatUploadedImage(null); - setChatImagePreviewUrl(null); - }; - - const handleAudioUpload = (file: File): false => { - setUploadedAudio(file); - return false; // Prevent default upload behavior - }; - - const handleRemoveAudio = () => { - setUploadedAudio(null); - }; + // File upload handlers provided by useFileUploads const handleSendMessage = async () => { if ( @@ -965,7 +636,7 @@ const ChatUI: React.FC = ({ } setChatHistory([...chatHistory, displayMessage]); - setMCPEvents([]); // Clear previous MCP events for new conversation turn + clearMCPEvents(); // Clear previous MCP events for new conversation turn codeInterpreter.clearResult(); // Clear previous code interpreter results setIsLoading(true); @@ -1220,29 +891,7 @@ const ChatUI: React.FC = ({ setInputMessage(""); }; - const clearChatHistory = () => { - // Clean up audio object URLs before clearing history - chatHistory.forEach((message) => { - if (message.isAudio && typeof message.content === "string") { - URL.revokeObjectURL(message.content); - } - }); - - setChatHistory([]); - setMessageTraceId(null); - setResponsesSessionId(null); // Clear responses session ID - setMCPEvents([]); // Clear MCP events - handleRemoveAllImages(); // Clear any uploaded images for image edits - handleRemoveResponsesImage(); // Clear any uploaded images for responses - handleRemoveChatImage(); // Clear any uploaded images for chat completions - handleRemoveAudio(); // Clear any uploaded audio for transcription - if (!simplified) { - sessionStorage.removeItem("chatHistory"); - sessionStorage.removeItem("messageTraceId"); - sessionStorage.removeItem("responsesSessionId"); - } - NotificationsManager.success("Chat history cleared."); - }; + // clearChatHistory is provided by useChatHistory if (userRole && userRole === "Admin Viewer") { const { Title, Paragraph } = Typography; @@ -1900,240 +1549,20 @@ const ChatUI: React.FC = ({ /> ) : ( <> -
- {simplified ? "Chat" : "Test Key"} -
- - Clear Chat - - {!simplified && ( - setIsGetCodeModalVisible(true)} - className="bg-gray-100 hover:bg-gray-200 text-gray-700 border-gray-300" - icon={CodeOutlined} - > - Get Code - - )} -
-
-
- {chatHistory.length === 0 && ( -
- - Start a conversation, generate an image, or handle audio -
- )} - - {chatHistory.map((message, index) => ( -
-
-
-
-
- {message.role === "user" ? ( - - ) : ( - - )} -
- {message.role} - {message.role === "assistant" && message.model && ( - - {message.model} - - )} -
- {message.reasoningContent && } - - {/* Show MCP events at the start of assistant messages */} - {message.role === "assistant" && - index === chatHistory.length - 1 && - mcpEvents.length > 0 && - (endpointType === EndpointType.RESPONSES || endpointType === EndpointType.CHAT) && ( -
- -
- )} - - {/* Show search results at the start of assistant messages */} - {message.role === "assistant" && message.searchResults && ( - - )} - - {/* Show Code Interpreter output for the last assistant message */} - {message.role === "assistant" && - index === chatHistory.length - 1 && - codeInterpreter.result && - endpointType === EndpointType.RESPONSES && ( - - )} - -
- {message.isImage ? ( - Generated image - ) : message.isAudio ? ( - - ) : ( - <> - {/* Show attached image for user messages based on current endpoint */} - {endpointType === EndpointType.RESPONSES && } - {endpointType === EndpointType.CHAT && } - - & { - inline?: boolean; - node?: any; - }) { - const match = /language-(\w+)/.exec(className || ""); - return !inline && match ? ( - - {String(children).replace(/\n$/, "")} - - ) : ( - - {children} - - ); - }, - pre: ({ node, ...props }) => ( -
-                                ),
-                              }}
-                            >
-                              {typeof message.content === "string" ? message.content : ""}
-                            
-
-                            {/* Show generated image from chat completions */}
-                            {message.image && (
-                              
- Generated image -
- )} - - )} - - {message.role === "assistant" && - (message.timeToFirstToken || message.totalLatency || message.usage) && - !message.a2aMetadata && ( - - )} - - {/* A2A Metrics - show for A2A agent responses */} - {message.role === "assistant" && message.a2aMetadata && ( - - )} -
-
-
-
- ))} - - {/* 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 -
- -
-
- )} - - {isLoading && ( -
- -
- )} -
-
+ setIsGetCodeModalVisible(true)} + /> +
{/* Image Upload Section for Image Edits */} @@ -2341,21 +1770,10 @@ const ChatUI: React.FC = ({ {/* 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"] - ).map((prompt) => ( - - ))} -
+ )}
@@ -2501,49 +1919,13 @@ const ChatUI: React.FC = ({
- setIsGetCodeModalVisible(false)} - footer={null} - width={800} - > -
-
- SDK Type - onSdkChange(value as "openai" | "azure")} + style={{ width: 150 }} + options={[ + { value: "openai", label: "OpenAI SDK" }, + { value: "azure", label: "Azure SDK" }, + ]} + /> +
+ +
+ + {generatedCode} + +
+ ); +} + +export default GetCodeModal; diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/SuggestedPrompts.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/SuggestedPrompts.tsx new file mode 100644 index 00000000000..50cd6a9f320 --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/SuggestedPrompts.tsx @@ -0,0 +1,40 @@ +import { EndpointType } from "./mode_endpoint_mapping"; + +interface SuggestedPromptsProps { + endpointType: string; + onPromptClick: (prompt: string) => void; +} + +const A2A_PROMPTS = [ + "What can you help me with?", + "Tell me about yourself", + "What tasks can you perform?", +]; + +const DEFAULT_PROMPTS = [ + "Write me a poem", + "Explain quantum computing", + "Draft a polite email requesting a meeting", +]; + +function SuggestedPrompts({ endpointType, onPromptClick }: SuggestedPromptsProps) { + const prompts = + endpointType === EndpointType.A2A_AGENTS ? A2A_PROMPTS : DEFAULT_PROMPTS; + + return ( +
+ {prompts.map((prompt) => ( + + ))} +
+ ); +} + +export default SuggestedPrompts; diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/useChatHistory.ts b/ui/litellm-dashboard/src/components/playground/chat_ui/useChatHistory.ts new file mode 100644 index 00000000000..2113a63695d --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/useChatHistory.ts @@ -0,0 +1,317 @@ +import { useEffect, useRef, useState } from "react"; +import { truncateString } from "../../../utils/textUtils"; +import type { MCPEvent } from "../../mcp_tools/types"; +import NotificationsManager from "../../molecules/notifications_manager"; +import { TokenUsage } from "./ResponseMetrics"; +import { A2ATaskMetadata, MessageType } from "./types"; + +interface UseChatHistoryParams { + simplified: boolean; + /** Called during clearChatHistory to also clear file upload state */ + onClearUploads?: () => void; +} + +interface UseChatHistoryReturn { + chatHistory: MessageType[]; + setChatHistory: React.Dispatch>; + inputMessage: string; + setInputMessage: (msg: string) => void; + messageTraceId: string | null; + setMessageTraceId: (id: string | null) => void; + responsesSessionId: string | null; + useApiSessionManagement: boolean; + mcpEvents: MCPEvent[]; + chatEndRef: React.RefObject; + // Updaters + updateTextUI: (role: string, chunk: string, model?: string) => void; + updateReasoningContent: (chunk: string) => void; + updateTimingData: (timeToFirstToken: number) => void; + updateUsageData: (usage: TokenUsage, toolName?: string) => void; + updateA2AMetadata: (metadata: A2ATaskMetadata) => void; + updateTotalLatency: (latency: number) => void; + updateSearchResults: (searchResults: unknown[]) => void; + updateImageUI: (url: string, model: string) => void; + updateEmbeddingsUI: (embeddings: string, model?: string) => void; + updateAudioUI: (url: string, model: string) => void; + updateChatImageUI: (url: string, model?: string) => void; + handleResponseId: (responseId: string) => void; + handleToggleSessionManagement: (useApi: boolean) => void; + handleMCPEvent: (event: MCPEvent) => void; + clearMCPEvents: () => void; + clearChatHistory: () => void; +} + +function useChatHistory({ simplified, onClearUploads }: UseChatHistoryParams): UseChatHistoryReturn { + const [inputMessage, setInputMessage] = useState(""); + const [chatHistory, setChatHistory] = useState(() => { + if (simplified) return []; + try { + const saved = sessionStorage.getItem("chatHistory"); + return saved ? JSON.parse(saved) : []; + } catch (error) { + console.error("Error parsing chatHistory from sessionStorage", error); + return []; + } + }); + const [messageTraceId, setMessageTraceId] = useState( + () => sessionStorage.getItem("messageTraceId") || null, + ); + const [responsesSessionId, setResponsesSessionId] = useState( + () => sessionStorage.getItem("responsesSessionId") || null, + ); + const [useApiSessionManagement, setUseApiSessionManagement] = useState(() => { + const saved = sessionStorage.getItem("useApiSessionManagement"); + return saved ? JSON.parse(saved) : true; + }); + const [mcpEvents, setMCPEvents] = useState([]); + const chatEndRef = useRef(null); + + // Debounced persistence of chatHistory to sessionStorage + useEffect(() => { + if (simplified) return; + const handler = setTimeout(() => { + sessionStorage.setItem("chatHistory", JSON.stringify(chatHistory)); + }, 500); + return () => clearTimeout(handler); + }, [chatHistory, simplified]); + + // Persist session management state + useEffect(() => { + if (messageTraceId) { + sessionStorage.setItem("messageTraceId", messageTraceId); + } else { + sessionStorage.removeItem("messageTraceId"); + } + if (responsesSessionId) { + sessionStorage.setItem("responsesSessionId", responsesSessionId); + } else { + sessionStorage.removeItem("responsesSessionId"); + } + sessionStorage.setItem("useApiSessionManagement", JSON.stringify(useApiSessionManagement)); + }, [messageTraceId, responsesSessionId, useApiSessionManagement]); + + // Auto-scroll on chat update + useEffect(() => { + if (chatEndRef.current) { + setTimeout(() => { + chatEndRef.current?.scrollIntoView({ + behavior: "smooth", + block: "end", + }); + }, 100); + } + }, [chatHistory]); + + // --- Updater functions --- + + const updateTextUI = (role: string, chunk: string, model?: string) => { + console.log("updateTextUI called with:", role, chunk, model); + setChatHistory((prev) => { + const last = prev[prev.length - 1]; + if (last && last.role === role && !last.isImage && !last.isAudio) { + const updated: MessageType = { + ...last, + content: last.content + chunk, + model: last.model ?? model, + }; + return [...prev.slice(0, -1), updated]; + } else { + return [...prev, { role, content: chunk, model }]; + } + }); + }; + + const updateReasoningContent = (chunk: string) => { + setChatHistory((prevHistory) => { + const lastMessage = prevHistory[prevHistory.length - 1]; + if (lastMessage && lastMessage.role === "assistant" && !lastMessage.isImage && !lastMessage.isAudio) { + return [ + ...prevHistory.slice(0, -1), + { ...lastMessage, reasoningContent: (lastMessage.reasoningContent || "") + chunk }, + ]; + } else if (prevHistory.length > 0 && prevHistory[prevHistory.length - 1].role === "user") { + return [...prevHistory, { role: "assistant", content: "", reasoningContent: chunk }]; + } + return prevHistory; + }); + }; + + const updateTimingData = (timeToFirstToken: number) => { + console.log("updateTimingData called with:", timeToFirstToken); + setChatHistory((prevHistory) => { + const lastMessage = prevHistory[prevHistory.length - 1]; + if (lastMessage && lastMessage.role === "assistant") { + return [...prevHistory.slice(0, -1), { ...lastMessage, timeToFirstToken }]; + } else if (lastMessage && lastMessage.role === "user") { + return [...prevHistory, { role: "assistant", content: "", timeToFirstToken }]; + } + return prevHistory; + }); + }; + + const updateUsageData = (usage: TokenUsage, toolName?: string) => { + console.log("Received usage data:", usage); + setChatHistory((prevHistory) => { + const lastMessage = prevHistory[prevHistory.length - 1]; + if (lastMessage && lastMessage.role === "assistant") { + return [...prevHistory.slice(0, -1), { ...lastMessage, usage, toolName }]; + } + return prevHistory; + }); + }; + + const updateA2AMetadata = (a2aMetadata: A2ATaskMetadata) => { + setChatHistory((prevHistory) => { + const lastMessage = prevHistory[prevHistory.length - 1]; + if (lastMessage && lastMessage.role === "assistant") { + return [...prevHistory.slice(0, -1), { ...lastMessage, a2aMetadata }]; + } + return prevHistory; + }); + }; + + const updateTotalLatency = (totalLatency: number) => { + setChatHistory((prevHistory) => { + const lastMessage = prevHistory[prevHistory.length - 1]; + if (lastMessage && lastMessage.role === "assistant") { + return [...prevHistory.slice(0, -1), { ...lastMessage, totalLatency }]; + } + return prevHistory; + }); + }; + + const updateSearchResults = (searchResults: unknown[]) => { + setChatHistory((prevHistory) => { + const lastMessage = prevHistory[prevHistory.length - 1]; + if (lastMessage && lastMessage.role === "assistant") { + return [...prevHistory.slice(0, -1), { ...lastMessage, searchResults }]; + } + return prevHistory; + }); + }; + + const updateImageUI = (imageUrl: string, model: string) => { + setChatHistory((prev) => [...prev, { role: "assistant", content: imageUrl, model, isImage: true }]); + }; + + const updateEmbeddingsUI = (embeddings: string, model?: string) => { + setChatHistory((prev) => [ + ...prev, + { role: "assistant", content: truncateString(embeddings, 100), model, isEmbeddings: true }, + ]); + }; + + const updateAudioUI = (audioUrl: string, model: string) => { + setChatHistory((prev) => [...prev, { role: "assistant", content: audioUrl, model, isAudio: true }]); + }; + + const updateChatImageUI = (imageUrl: string, model?: string) => { + setChatHistory((prev) => { + const last = prev[prev.length - 1]; + if (last && last.role === "assistant" && !last.isImage && !last.isAudio) { + return [ + ...prev.slice(0, -1), + { ...last, image: { url: imageUrl, detail: "auto" }, model: last.model ?? model }, + ]; + } else { + return [ + ...prev, + { role: "assistant", content: "", model, image: { url: imageUrl, detail: "auto" } }, + ]; + } + }); + }; + + const handleResponseId = (responseId: string) => { + console.log("Received response ID for session management:", responseId); + if (useApiSessionManagement) { + setResponsesSessionId(responseId); + } + }; + + const handleToggleSessionManagement = (useApi: boolean) => { + setUseApiSessionManagement(useApi); + if (!useApi) { + setResponsesSessionId(null); + } + }; + + const handleMCPEvent = (event: MCPEvent) => { + console.log("ChatUI: Received MCP event:", event); + setMCPEvents((prev) => { + const isDuplicate = event.item_id + ? prev.some( + (existingEvent) => + existingEvent.item_id === event.item_id && + existingEvent.type === event.type && + (existingEvent.sequence_number === event.sequence_number || + (existingEvent.sequence_number === undefined && event.sequence_number === undefined)), + ) + : false; + + if (isDuplicate) { + console.log("ChatUI: Duplicate MCP event, skipping"); + return prev; + } + + return [...prev, event]; + }); + }; + + const clearMCPEvents = () => setMCPEvents([]); + + const clearChatHistory = () => { + // Revoke URLs to prevent memory leaks + chatHistory.forEach((message) => { + if (message.isImage && typeof message.content === "string" && message.content.startsWith("blob:")) { + URL.revokeObjectURL(message.content); + } + if (message.isAudio && typeof message.content === "string") { + URL.revokeObjectURL(message.content); + } + }); + + setChatHistory([]); + setMessageTraceId(null); + setResponsesSessionId(null); + setMCPEvents([]); + onClearUploads?.(); + if (!simplified) { + sessionStorage.removeItem("chatHistory"); + sessionStorage.removeItem("messageTraceId"); + sessionStorage.removeItem("responsesSessionId"); + } + NotificationsManager.success("Chat history cleared."); + }; + + return { + chatHistory, + setChatHistory, + inputMessage, + setInputMessage, + messageTraceId, + setMessageTraceId, + responsesSessionId, + useApiSessionManagement, + mcpEvents, + chatEndRef, + updateTextUI, + updateReasoningContent, + updateTimingData, + updateUsageData, + updateA2AMetadata, + updateTotalLatency, + updateSearchResults, + updateImageUI, + updateEmbeddingsUI, + updateAudioUI, + updateChatImageUI, + handleResponseId, + handleToggleSessionManagement, + handleMCPEvent, + clearMCPEvents, + clearChatHistory, + }; +} + +export default useChatHistory; diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/useFileUploads.ts b/ui/litellm-dashboard/src/components/playground/chat_ui/useFileUploads.ts new file mode 100644 index 00000000000..1432145582b --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/useFileUploads.ts @@ -0,0 +1,142 @@ +import { useState } from "react"; + +interface UseFileUploadsReturn { + // Image edits endpoint + uploadedImages: File[]; + imagePreviewUrls: string[]; + handleImageUpload: (file: File) => false; + handleRemoveImage: (index: number) => void; + handleRemoveAllImages: () => void; + // Responses endpoint + responsesUploadedImage: File | null; + responsesImagePreviewUrl: string | null; + handleResponsesImageUpload: (file: File) => false; + handleRemoveResponsesImage: () => void; + // Chat endpoint + chatUploadedImage: File | null; + chatImagePreviewUrl: string | null; + handleChatImageUpload: (file: File) => false; + handleRemoveChatImage: () => void; + // Audio transcription endpoint + uploadedAudio: File | null; + handleAudioUpload: (file: File) => false; + handleRemoveAudio: () => void; + /** Clears all file uploads across all endpoint types */ + clearAllUploads: () => void; +} + +function useFileUploads(): UseFileUploadsReturn { + // Image edits + const [uploadedImages, setUploadedImages] = useState([]); + const [imagePreviewUrls, setImagePreviewUrls] = useState([]); + + // Responses + const [responsesUploadedImage, setResponsesUploadedImage] = useState(null); + const [responsesImagePreviewUrl, setResponsesImagePreviewUrl] = useState(null); + + // Chat + const [chatUploadedImage, setChatUploadedImage] = useState(null); + const [chatImagePreviewUrl, setChatImagePreviewUrl] = useState(null); + + // Audio + const [uploadedAudio, setUploadedAudio] = useState(null); + + // --- Image edits handlers --- + + const handleImageUpload = (file: File): false => { + setUploadedImages((prev) => [...prev, file]); + const previewUrl = URL.createObjectURL(file); + setImagePreviewUrls((prev) => [...prev, previewUrl]); + return false; // Prevent default upload behavior + }; + + const handleRemoveImage = (index: number) => { + if (imagePreviewUrls[index]) { + URL.revokeObjectURL(imagePreviewUrls[index]); + } + setUploadedImages((prev) => prev.filter((_, i) => i !== index)); + setImagePreviewUrls((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleRemoveAllImages = () => { + imagePreviewUrls.forEach((url) => URL.revokeObjectURL(url)); + setUploadedImages([]); + setImagePreviewUrls([]); + }; + + // --- Responses handlers --- + + const handleResponsesImageUpload = (file: File): false => { + setResponsesUploadedImage(file); + const previewUrl = URL.createObjectURL(file); + setResponsesImagePreviewUrl(previewUrl); + return false; + }; + + const handleRemoveResponsesImage = () => { + if (responsesImagePreviewUrl) { + URL.revokeObjectURL(responsesImagePreviewUrl); + } + setResponsesUploadedImage(null); + setResponsesImagePreviewUrl(null); + }; + + // --- Chat handlers --- + + const handleChatImageUpload = (file: File): false => { + setChatUploadedImage(file); + const previewUrl = URL.createObjectURL(file); + setChatImagePreviewUrl(previewUrl); + return false; + }; + + const handleRemoveChatImage = () => { + if (chatImagePreviewUrl) { + URL.revokeObjectURL(chatImagePreviewUrl); + } + setChatUploadedImage(null); + setChatImagePreviewUrl(null); + }; + + // --- Audio handlers --- + + const handleAudioUpload = (file: File): false => { + setUploadedAudio(file); + return false; + }; + + const handleRemoveAudio = () => { + setUploadedAudio(null); + }; + + // --- Clear all --- + + const clearAllUploads = () => { + handleRemoveAllImages(); + handleRemoveResponsesImage(); + handleRemoveChatImage(); + handleRemoveAudio(); + }; + + return { + uploadedImages, + imagePreviewUrls, + handleImageUpload, + handleRemoveImage, + handleRemoveAllImages, + responsesUploadedImage, + responsesImagePreviewUrl, + handleResponsesImageUpload, + handleRemoveResponsesImage, + chatUploadedImage, + chatImagePreviewUrl, + handleChatImageUpload, + handleRemoveChatImage, + uploadedAudio, + handleAudioUpload, + handleRemoveAudio, + clearAllUploads, + }; +} + +export default useFileUploads;