diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatMessageBubble.test.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatMessageBubble.test.tsx new file mode 100644 index 00000000000..70c3fdd4f29 --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatMessageBubble.test.tsx @@ -0,0 +1,296 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import ChatMessageBubble from "./ChatMessageBubble"; +import { EndpointType } from "./mode_endpoint_mapping"; +import { MessageType } from "./types"; + +// Mock child components to isolate bubble rendering logic +vi.mock("react-markdown", () => ({ + default: ({ children }: { children: string }) =>
{children}
, +})); + +vi.mock("react-syntax-highlighter", () => ({ + Prism: ({ children }: { children: string }) =>
{children}
, +})); + +vi.mock("react-syntax-highlighter/dist/esm/styles/prism", () => ({ + coy: {}, +})); + +vi.mock("./ReasoningContent", () => ({ + default: ({ reasoningContent }: { reasoningContent: string }) => ( +
{reasoningContent}
+ ), +})); + +vi.mock("./MCPEventsDisplay", () => ({ + default: ({ events }: { events: unknown[] }) => ( +
{events.length} events
+ ), +})); + +vi.mock("./SearchResultsDisplay", () => ({ + SearchResultsDisplay: ({ searchResults }: { searchResults: unknown[] }) => ( +
{searchResults.length} results
+ ), +})); + +vi.mock("./ResponseMetrics", () => ({ + default: ({ timeToFirstToken }: { timeToFirstToken?: number }) => ( +
TTFT: {timeToFirstToken}
+ ), +})); + +vi.mock("./A2AMetrics", () => ({ + default: ({ a2aMetadata }: { a2aMetadata: unknown }) => ( +
A2A
+ ), +})); + +vi.mock("./CodeInterpreterOutput", () => ({ + default: ({ code }: { code: string }) =>
{code}
, +})); + +vi.mock("./AudioRenderer", () => ({ + default: ({ message }: { message: MessageType }) => ( +
{typeof message.content === "string" ? message.content : ""}
+ ), +})); + +vi.mock("./ResponsesImageRenderer", () => ({ + default: () =>
, +})); + +vi.mock("./ChatImageRenderer", () => ({ + default: () =>
, +})); + +const defaultProps = { + isLastMessage: false, + endpointType: EndpointType.CHAT, + mcpEvents: [], + codeInterpreterResult: null, + accessToken: "test-token", +}; + +describe("ChatMessageBubble", () => { + it("should render a user message with right-aligned text", () => { + render( + , + ); + + expect(screen.getByText("user")).toBeInTheDocument(); + expect(screen.getByText("Hello")).toBeInTheDocument(); + }); + + it("should render an assistant message with left-aligned text", () => { + render( + , + ); + + expect(screen.getByText("assistant")).toBeInTheDocument(); + expect(screen.getByText("Hi there")).toBeInTheDocument(); + }); + + it("should show model badge for assistant messages when model is provided", () => { + render( + , + ); + + expect(screen.getByText("gpt-4")).toBeInTheDocument(); + }); + + it("should not show model badge for user messages even when model is set", () => { + render( + , + ); + + expect(screen.queryByText("gpt-4")).not.toBeInTheDocument(); + }); + + it("should render markdown content via ReactMarkdown", () => { + render( + , + ); + + expect(screen.getByTestId("react-markdown")).toHaveTextContent("**bold text**"); + }); + + it("should render an image when isImage is true", () => { + render( + , + ); + + expect(screen.getByAltText("Generated image")).toHaveAttribute("src", "https://example.com/img.png"); + }); + + it("should render AudioRenderer when isAudio is true", () => { + render( + , + ); + + expect(screen.getByTestId("audio-renderer")).toBeInTheDocument(); + }); + + it("should show ReasoningContent when reasoningContent is present", () => { + render( + , + ); + + expect(screen.getByTestId("reasoning-content")).toHaveTextContent("thinking..."); + }); + + it("should show MCP events on the last assistant message for RESPONSES endpoint", () => { + const mcpEvents = [{ type: "tool_call", item_id: "1" }]; + + render( + , + ); + + expect(screen.getByTestId("mcp-events-display")).toHaveTextContent("1 events"); + }); + + it("should show MCP events on the last assistant message for CHAT endpoint", () => { + const mcpEvents = [{ type: "tool_call", item_id: "1" }]; + + render( + , + ); + + expect(screen.getByTestId("mcp-events-display")).toHaveTextContent("1 events"); + }); + + it("should not show MCP events when isLastMessage is false", () => { + const mcpEvents = [{ type: "tool_call", item_id: "1" }]; + + render( + , + ); + + expect(screen.queryByTestId("mcp-events-display")).not.toBeInTheDocument(); + }); + + it("should show SearchResultsDisplay when searchResults are present", () => { + render( + , + ); + + expect(screen.getByTestId("search-results-display")).toBeInTheDocument(); + }); + + it("should show ResponseMetrics when usage data is present and no a2aMetadata", () => { + render( + , + ); + + expect(screen.getByTestId("response-metrics")).toBeInTheDocument(); + }); + + it("should show A2AMetrics when a2aMetadata is present instead of ResponseMetrics", () => { + render( + , + ); + + expect(screen.getByTestId("a2a-metrics")).toBeInTheDocument(); + expect(screen.queryByTestId("response-metrics")).not.toBeInTheDocument(); + }); + + it("should show CodeInterpreterOutput on the last assistant message for RESPONSES endpoint", () => { + render( + , + ); + + expect(screen.getByTestId("code-interpreter-output")).toHaveTextContent("print('hello')"); + }); + + it("should render generated image from chat completions via message.image", () => { + render( + , + ); + + const images = screen.getAllByAltText("Generated image"); + expect(images.some((img) => img.getAttribute("src") === "https://example.com/generated.png")).toBe(true); + }); +}); 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..15978c17f7e --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatMessageBubble.tsx @@ -0,0 +1,214 @@ +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 { CodeInterpreterResult } from "../llm_calls/code_interpreter_handler"; +import A2AMetrics from "./A2AMetrics"; +import AudioRenderer from "./AudioRenderer"; +import ChatImageRenderer from "./ChatImageRenderer"; +import CodeInterpreterOutput from "./CodeInterpreterOutput"; +import { EndpointType } from "./mode_endpoint_mapping"; +import MCPEventsDisplay from "./MCPEventsDisplay"; +import type { MCPEvent } from "../../mcp_tools/types"; +import ReasoningContent from "./ReasoningContent"; +import ResponseMetrics from "./ResponseMetrics"; +import ResponsesImageRenderer from "./ResponsesImageRenderer"; +import { SearchResultsDisplay } from "./SearchResultsDisplay"; +import { MessageType } from "./types"; + +interface ChatMessageBubbleProps { + message: MessageType; + /** Whether this is the last message in the chat history. */ + isLastMessage: boolean; + endpointType: EndpointType; + /** MCP events to display on the last assistant message. */ + mcpEvents: MCPEvent[]; + /** Code interpreter result to display on the last assistant message. */ + codeInterpreterResult: CodeInterpreterResult | null; + /** API key used to fetch code interpreter file downloads. */ + accessToken: string; +} + +function ChatMessageBubble({ + message, + isLastMessage, + endpointType, + mcpEvents, + codeInterpreterResult, + accessToken, +}: ChatMessageBubbleProps) { + const isUser = message.role === "user"; + + return ( +
+
+ {/* Header: role icon + name + model badge */} +
+
+ {isUser ? ( + + ) : ( + + )} +
+ {message.role} + {message.role === "assistant" && message.model && ( + + {message.model} + + )} +
+ + {/* Reasoning content (chain-of-thought) */} + {message.reasoningContent && } + + {/* MCP events at the start of the last assistant message */} + {message.role === "assistant" && + isLastMessage && + mcpEvents.length > 0 && + (endpointType === EndpointType.RESPONSES || endpointType === EndpointType.CHAT) && ( +
+ +
+ )} + + {/* Search results */} + {message.role === "assistant" && message.searchResults && ( + + )} + + {/* Code Interpreter output for the last assistant message */} + {message.role === "assistant" && + isLastMessage && + codeInterpreterResult && + endpointType === EndpointType.RESPONSES && ( + + )} + + {/* Message body */} +
+ {message.isImage ? ( + Generated image + ) : message.isAudio ? ( + + ) : ( + <> + {/* Attached image for user messages based on 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 : ""}
+              
+
+              {/* Generated image from chat completions */}
+              {message.image && (
+                
+ Generated image +
+ )} + + )} + + {/* Response metrics */} + {message.role === "assistant" && + (message.timeToFirstToken || message.totalLatency || message.usage) && + !message.a2aMetadata && ( + + )} + + {/* A2A Metrics */} + {message.role === "assistant" && message.a2aMetadata && ( + + )} +
+
+
+ ); +} + +export default ChatMessageBubble; 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 bc0d52cf581..ef57a75062c 100644 --- a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx @@ -63,6 +63,7 @@ import EndpointSelector from "./EndpointSelector"; import FilePreviewCard from "./FilePreviewCard"; import MCPEventsDisplay from "./MCPEventsDisplay"; import type { MCPEvent } from "../../mcp_tools/types"; +import ChatMessageBubble from "./ChatMessageBubble"; import { EndpointType, getEndpointType } from "./mode_endpoint_mapping"; import ReasoningContent from "./ReasoningContent"; import ResponseMetrics, { TokenUsage } from "./ResponseMetrics"; @@ -1932,168 +1933,14 @@ const ChatUI: React.FC = ({ {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 && ( - - )} -
-
-
+
))}