diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx index dbc1c4d10e2..b2926e7f608 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx @@ -20,6 +20,7 @@ import { ToolOutlined, TagsOutlined, AuditOutlined, + MessageOutlined, } from "@ant-design/icons"; // import { // all_admin_roles, @@ -47,6 +48,7 @@ interface SidebarProps { interface MenuItemCfg { key: string; + newTab?: boolean; page: string; // legacy id; we map this to a path below label: string; roles?: string[]; @@ -105,6 +107,8 @@ const routeFor = (slug: string): string => { return "guardrails"; case "policies": return "policies"; + case "chat": + return "chat"; // tools case "mcp-servers": @@ -156,6 +160,7 @@ const toHref = (slugOrPath: string) => { // ----- Menu config (unchanged labels/icons; same appearance) ----- const menuItems: MenuItemCfg[] = [ { key: "1", page: "api-keys", label: "Virtual Keys", icon: }, + { key: "29", page: "chat", label: "Chat", icon: , newTab: true }, { key: "3", page: "llm-playground", @@ -371,19 +376,29 @@ const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelect }, [pathname, filteredMenuItems, defaultSelectedKey]); // ----- Navigation ----- - const goTo = (slug: string) => { + const goTo = (slug: string, newTab?: boolean) => { const href = toHref(slug); - router.push(href); + if (newTab) { + window.open(href, "_blank"); + } else { + router.push(href); + } }; // Wrap label in so every nav item supports right-click β†’ "Open in new tab" // and Ctrl/Cmd+click to open in a new tab, while preserving SPA navigation for normal clicks. - const renderNavLink = (label: string, page: string): React.ReactNode => { + const renderNavLink = (label: string, page: string, newTab?: boolean): React.ReactNode => { const href = toHref(page); return ( { + if (newTab) { + e.stopPropagation(); + return; + } if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { e.stopPropagation(); return; @@ -435,14 +450,14 @@ const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelect items={filteredMenuItems.map((item) => ({ key: item.key, icon: item.icon, - label: renderNavLink(item.label, item.page), + label: renderNavLink(item.label, item.page, item.newTab), children: item.children?.map((child) => ({ key: child.key, icon: child.icon, - label: renderNavLink(child.label, child.page), - onClick: () => goTo(child.page), + label: renderNavLink(child.label, child.page, child.newTab), + onClick: () => goTo(child.page, child.newTab), })), - onClick: !item.children ? () => goTo(item.page) : undefined, + onClick: !item.children ? () => goTo(item.page, item.newTab) : undefined, }))} /> diff --git a/ui/litellm-dashboard/src/app/chat/page.tsx b/ui/litellm-dashboard/src/app/chat/page.tsx new file mode 100644 index 00000000000..18fc02e7f73 --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import ChatPage from "@/components/chat/ChatPage"; + +const ChatPageRoute = () => { + const { accessToken, userRole, userId, userEmail } = useAuthorized(); + + return ( + + ); +}; + +export default ChatPageRoute; diff --git a/ui/litellm-dashboard/src/components/chat/ChatInputBar.tsx b/ui/litellm-dashboard/src/components/chat/ChatInputBar.tsx new file mode 100644 index 00000000000..ae6cd608604 --- /dev/null +++ b/ui/litellm-dashboard/src/components/chat/ChatInputBar.tsx @@ -0,0 +1,107 @@ +import React, { useState } from "react"; +import { Button, Input, Popover, Tooltip } from "antd"; +import { ApiOutlined, BorderOutlined, PaperClipOutlined, SendOutlined } from "@ant-design/icons"; + +interface Props { + onSend: (text: string) => void; + isStreaming: boolean; + onStop: () => void; + selectedMCPServers: string[]; + onMCPChange: (servers: string[]) => void; + isLoadingModels: boolean; + accessToken: string; +} + +const ChatInputBar: React.FC = ({ + onSend, + isStreaming, + onStop, + selectedMCPServers, + onMCPChange, + isLoadingModels, + accessToken, +}) => { + const [text, setText] = useState(""); + const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false); + + const handleSend = () => { + if (text.trim() === "" || isStreaming || isLoadingModels) return; + onSend(text.trim()); + setText(""); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const mcpButtonLabel = + selectedMCPServers.length > 0 + ? `MCP (${selectedMCPServers.length})` + : "MCP"; + + const mcpPopoverContent = ( +
+ {/* MCPConnectPicker - LIT-2170 */} +
+ ); + + return ( +
+ + + + + +
+ ); +}; + +export default ChatInputBar; diff --git a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx new file mode 100644 index 00000000000..b3114564000 --- /dev/null +++ b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx @@ -0,0 +1,407 @@ +"use client"; + +import { ToolOutlined } from "@ant-design/icons"; +import { Collapse } from "antd"; +import React, { useEffect, useRef } 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 ReasoningContent from "../playground/chat_ui/ReasoningContent"; +import { ChatMessage } from "./types"; + +const { Panel } = Collapse; + +// Keys whose values must be redacted in tool args display +const REDACTED_KEY_PATTERNS = /token|key|secret|password|auth/i; + +function redactSensitiveValues(obj: Record): Record { + const result: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (REDACTED_KEY_PATTERNS.test(k)) { + result[k] = "[redacted]"; + } else if (v !== null && typeof v === "object" && !Array.isArray(v)) { + result[k] = redactSensitiveValues(v as Record); + } else { + result[k] = v; + } + } + return result; +} + +function formatTimestamp(ts: number): string { + const d = new Date(ts); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${hh}:${mm}`; +} + +// Shared markdown code renderer matching ReasoningContent style +function MarkdownCodeRenderer({ + node, + inline, + className, + children, + ...props +}: React.ComponentPropsWithoutRef<"code"> & { inline?: boolean; node?: unknown }) { + const match = /language-(\w+)/.exec(className || ""); + return !inline && match ? ( + } + language={match[1]} + PreTag="div" + className="rounded-md my-2" + {...(props as Record)} + > + {String(children).replace(/\n$/, "")} + + ) : ( + + {children} + + ); +} + +// ------- Sub-components ------- + +interface UserBubbleProps { + message: ChatMessage; +} + +function UserBubble({ message }: UserBubbleProps) { + return ( +
+
+ {message.content} +
+ + {formatTimestamp(message.timestamp)} + +
+ ); +} + +interface AssistantBubbleProps { + message: ChatMessage; + isLastMessage: boolean; + isStreaming: boolean; + isTypingIndicator: boolean; +} + +function AssistantBubble({ + message, + isLastMessage, + isStreaming, + isTypingIndicator, +}: AssistantBubbleProps) { + // Ref to control ReasoningContent collapse on streaming end. + // ReasoningContent manages its own expanded state; we use a key to + // remount it (collapsed by default) when streaming finishes. + const reasoningKeyRef = useRef(0); + const prevStreamingRef = useRef(isStreaming); + + useEffect(() => { + if (prevStreamingRef.current && !isStreaming) { + // Streaming just stopped β€” bump the key to remount ReasoningContent + // with isExpanded default (false won't work since it starts expanded). + // ReasoningContent always starts expanded on mount; we accept that + // behaviour and leave collapse-on-finish as a best-effort remount. + reasoningKeyRef.current += 1; + } + prevStreamingRef.current = isStreaming; + }, [isStreaming]); + + const showReasoningPlaceholder = + isLastMessage && isStreaming && !message.reasoningContent; + + const showReasoning = + !!message.reasoningContent || showReasoningPlaceholder; + + if (isTypingIndicator) { + return ( +
+
+ +
+
+ ); + } + + // Split content at trailing "[stopped]" + let mainContent = message.content; + let stoppedSuffix = false; + if (mainContent.endsWith("[stopped]")) { + mainContent = mainContent.slice(0, -"[stopped]".length); + stoppedSuffix = true; + } + + return ( +
+ {showReasoning && ( + showReasoningPlaceholder ? ( + + ) : ( + + ) + )} + +
+ >, + }} + > + {mainContent} + + {stoppedSuffix && ( + [stopped] + )} +
+ + + {formatTimestamp(message.timestamp)} + +
+ ); +} + +function ThinkingPlaceholder() { + return ( + <> + +
+ Thinking... +
+ + ); +} + +function TypingDots() { + return ( + <> + +
+
+
+ + ); +} + +interface ToolCardProps { + message: ChatMessage; +} + +function ToolCard({ message }: ToolCardProps) { + const redactedArgs = + message.toolArgs ? redactSensitiveValues(message.toolArgs) : undefined; + + return ( +
+ + + + + {message.toolName ?? "Tool call"} + + + } + key="tool" + > + {redactedArgs !== undefined && ( +
+
+ Arguments +
+
+                {JSON.stringify(redactedArgs, null, 2)}
+              
+
+ )} + + {message.toolResult && ( +
+
+ Result +
+
+ {message.toolResult} +
+
+ )} +
+
+
+ {formatTimestamp(message.timestamp)} +
+
+ ); +} + +// ------- Main component ------- + +interface Props { + messages: ChatMessage[]; + isStreaming: boolean; +} + +const ChatMessages: React.FC = ({ messages, isStreaming }) => { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const lastIndex = messages.length - 1; + const lastMsg = messages[lastIndex] ?? null; + const isTypingIndicator = + isStreaming && + lastMsg !== null && + lastMsg.role === "assistant" && + lastMsg.content === ""; + + return ( +
+ {messages.map((msg, idx) => { + const isLastMessage = idx === lastIndex; + + if (msg.role === "user") { + return ; + } + + if (msg.role === "tool") { + return ; + } + + // assistant + return ( + + ); + })} + + {/* Bottom sentinel for auto-scroll */} +
+
+ ); +}; + +export default ChatMessages; diff --git a/ui/litellm-dashboard/src/components/chat/ChatPage.tsx b/ui/litellm-dashboard/src/components/chat/ChatPage.tsx new file mode 100644 index 00000000000..8aabb0a62da --- /dev/null +++ b/ui/litellm-dashboard/src/components/chat/ChatPage.tsx @@ -0,0 +1,562 @@ +"use client"; + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Select, Tooltip, Skeleton, Popover, message } from "antd"; +import { SettingOutlined, PlusOutlined, BorderOutlined } from "@ant-design/icons"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useChatHistory } from "./useChatHistory"; +import ConversationList from "./ConversationList"; +import ChatMessages from "./ChatMessages"; +import MCPConnectPicker from "./MCPConnectPicker"; +import { fetchAvailableModels } from "../playground/llm_calls/fetch_models"; +import { makeOpenAIChatCompletionRequest } from "../playground/llm_calls/chat_completion"; + +interface ChatPageProps { + accessToken: string; + userRole: string; + userId: string; + userEmail?: string; +} + +const SUGGESTIONS = [ + { label: "Write", icon: "✏️" }, + { label: "Learn", icon: "πŸŽ“" }, + { label: "Code", icon: "" }, + { label: "Brainstorm", icon: "πŸ’‘" }, +]; + +function getGreeting(): string { + const h = new Date().getHours(); + if (h >= 5 && h < 12) return "Good morning"; + if (h >= 12 && h < 17) return "Good afternoon"; + return "Good evening"; +} + +const LOCALSTORAGE_MODEL_KEY = "litellm_chat_selected_model"; + +const ChatPage: React.FC = ({ accessToken, userRole, userId, userEmail }) => { + const router = useRouter(); + const searchParams = useSearchParams(); + const activeConversationId = searchParams.get("id"); + + const [selectedModel, setSelectedModel] = useState(""); + const [models, setModels] = useState([]); + const [isLoadingModels, setIsLoadingModels] = useState(true); + const [selectedMCPServers, setSelectedMCPServers] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const [inputText, setInputText] = useState(""); + const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [storageBannerDismissed, setStorageBannerDismissed] = useState(false); + + const abortControllerRef = useRef(null); + const textareaRef = useRef(null); + + const { + conversations, + activeConversation, + storageUnavailable, + staleId, + createConversation, + appendMessage, + updateLastAssistantMessage, + deleteConversation, + renameConversation, + } = useChatHistory(activeConversationId); + + // Load models + useEffect(() => { + if (!accessToken) return; + setIsLoadingModels(true); + fetchAvailableModels(accessToken) + .then((data) => { + const names = (data || []).map((m: { model_name?: string }) => m.model_name ?? "").filter(Boolean); + setModels(names); + const saved = localStorage.getItem(LOCALSTORAGE_MODEL_KEY); + if (saved && names.includes(saved)) { + setSelectedModel(saved); + } else if (names.length > 0) { + setSelectedModel(names[0]); + localStorage.setItem(LOCALSTORAGE_MODEL_KEY, names[0]); + } + }) + .catch(() => { + message.error("Could not load models"); + }) + .finally(() => setIsLoadingModels(false)); + }, [accessToken]); + + useEffect(() => { + if (staleId) router.replace("/chat"); + }, [staleId, router]); + + const handleModelChange = (val: string) => { + setSelectedModel(val); + localStorage.setItem(LOCALSTORAGE_MODEL_KEY, val); + }; + + const handleSend = useCallback( + async (text: string) => { + const trimmed = text.trim(); + if (!trimmed || !selectedModel || isStreaming) return; + setInputText(""); + + let convId = activeConversationId; + if (!convId) { + convId = createConversation(selectedModel); + router.push(`/chat?id=${convId}`); + } + + appendMessage(convId, { role: "user", content: trimmed }); + appendMessage(convId, { role: "assistant", content: "" }); + + setIsStreaming(true); + abortControllerRef.current = new AbortController(); + + const history = [ + ...(activeConversation?.messages ?? []).map((m) => ({ + role: m.role as "user" | "assistant", + content: m.content, + })), + { role: "user" as const, content: trimmed }, + ]; + + try { + await makeOpenAIChatCompletionRequest( + history, + (chunk: string) => updateLastAssistantMessage(convId!, { content: chunk }), + selectedModel, + accessToken, + undefined, + abortControllerRef.current.signal, + (rc: string) => updateLastAssistantMessage(convId!, { reasoningContent: rc }), + undefined, undefined, undefined, undefined, undefined, undefined, + selectedMCPServers.length > 0 ? selectedMCPServers : undefined, + ); + } catch (err: unknown) { + if (err instanceof Error && err.name === "AbortError") { + updateLastAssistantMessage(convId!, { + content: (activeConversation?.messages.at(-1)?.content ?? "") + " [stopped]", + }); + } else { + updateLastAssistantMessage(convId!, { + content: "[Something went wrong. The partial response has been saved.]", + }); + } + } finally { + setIsStreaming(false); + abortControllerRef.current = null; + } + }, + [activeConversationId, activeConversation, selectedModel, selectedMCPServers, accessToken, + createConversation, appendMessage, updateLastAssistantMessage, router, isStreaming], + ); + + const handleStop = useCallback(() => { + abortControllerRef.current?.abort(); + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(inputText); + } + }; + + // Auto-resize textarea + useEffect(() => { + const ta = textareaRef.current; + if (!ta) return; + ta.style.height = "auto"; + ta.style.height = `${Math.min(ta.scrollHeight, 180)}px`; + }, [inputText]); + + const showBlankState = !activeConversation || activeConversation.messages.length === 0; + const displayName = userEmail?.split("@")[0] ?? userId ?? "there"; + const greeting = `${getGreeting()}, ${displayName}`; + + return ( +
+ + {/* Conversation sidebar β€” slides in */} + {sidebarOpen && ( +
+ router.push(`/chat?id=${id}`)} + onDelete={deleteConversation} + onNewChat={() => router.push("/chat")} + onRename={renameConversation} + /> +
+ )} + + {/* Main area */} +
+ + {/* Top bar */} +
+ {/* Left: sidebar toggle + model selector */} +
+ + {isLoadingModels ? ( + + ) : ( +