From a906baa60cd550bbf2fd6bd8ac13c386aec81ff7 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Thu, 5 Mar 2026 16:56:51 -0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20add=20ChatPage=20=E2=80=94=20ChatGP?= =?UTF-8?q?T-like=20UI=20with=20scroll=20lock,=20MCP=20tools,=20streaming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/chat/ChatPage.tsx | 815 ++++++++++++++++++ 1 file changed, 815 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/chat/ChatPage.tsx 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..169c17d29d9 --- /dev/null +++ b/ui/litellm-dashboard/src/components/chat/ChatPage.tsx @@ -0,0 +1,815 @@ +"use client"; + +import React, { useCallback, useEffect, useRef, useState, useLayoutEffect } from "react"; +import { Select, Tooltip, Skeleton, Popover, message } from "antd"; +import { + SettingOutlined, + PlusOutlined, + EditOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + SearchOutlined, + MessageOutlined, + AppstoreOutlined, + ArrowLeftOutlined, + DownOutlined, +} 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 MCPAppsPanel from "./MCPAppsPanel"; +import { fetchAvailableModels } from "../playground/llm_calls/fetch_models"; +import { makeOpenAIChatCompletionRequest } from "../playground/llm_calls/chat_completion"; +import { serverRootPath } from "@/components/networking"; + +interface ChatPageProps { + accessToken: string; + userRole: string; + userId: string; + userEmail?: string; +} + +const SUGGESTIONS = ["Write", "Learn", "Code", "Brainstorm"]; + +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"; + +// Build the dashboard root URL +function getDashboardUrl(): string { + const base = process.env.NEXT_PUBLIC_BASE_URL ?? ""; + const trimmed = base.replace(/^\/+|\/+$/g, ""); + const uiPath = trimmed ? `/${trimmed}/` : "/"; + if (serverRootPath && serverRootPath !== "/") { + const cleanRoot = serverRootPath.replace(/\/+$/, ""); + const cleanUi = uiPath.replace(/^\/+/, ""); + return `${cleanRoot}/${cleanUi}`; + } + return uiPath; +} + +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 [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [sidebarView, setSidebarView] = useState<"chats" | "apps">("chats"); + const [storageBannerDismissed, setStorageBannerDismissed] = useState(false); + + const abortControllerRef = useRef(null); + const textareaRef = useRef(null); + const messagesScrollRef = useRef(null); + const [showScrollButton, setShowScrollButton] = useState(false); + const streamScrollLock = useRef(null); + + const { + conversations, + activeConversation, + storageUnavailable, + staleId, + createConversation, + appendMessage, + updateLastAssistantMessage, + truncateAfterMessage, + deleteConversation, + renameConversation, + } = useChatHistory(activeConversationId); + + // Load models + useEffect(() => { + if (!accessToken) return; + setIsLoadingModels(true); + fetchAvailableModels(accessToken) + .then((data) => { + const names = (data || []).map((m: { model_group?: string }) => m.model_group ?? "").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 }, + ]; + + let accumulatedContent = ""; + let accumulatedReasoning = ""; + + try { + await makeOpenAIChatCompletionRequest( + history, + (chunk: string) => { + accumulatedContent += chunk; + updateLastAssistantMessage(convId!, { content: accumulatedContent }); + }, + selectedModel, + accessToken, + undefined, + abortControllerRef.current.signal, + (rc: string) => { + accumulatedReasoning += rc; + updateLastAssistantMessage(convId!, { reasoningContent: accumulatedReasoning }); + }, + 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 handleEditAndResend = useCallback( + (messageId: string, newContent: string) => { + if (!activeConversationId || isStreaming) return; + // Remove the edited message and everything after it + truncateAfterMessage(activeConversationId, messageId); + // Re-send with the new content (handleSend appends user msg + starts completion) + handleSend(newContent); + }, + [activeConversationId, isStreaming, truncateAfterMessage, handleSend], + ); + + 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]); + + // Track scroll position to show/hide scroll-to-bottom button + useEffect(() => { + const el = messagesScrollRef.current; + if (!el) return; + const onScroll = () => { + const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + setShowScrollButton(distFromBottom > 120); + if (streamScrollLock.current !== null) { + streamScrollLock.current = el.scrollTop; // track user-initiated scroll + } + }; + el.addEventListener("scroll", onScroll, { passive: true }); + return () => el.removeEventListener("scroll", onScroll); + }, [activeConversation]); + + // Start/stop the scroll lock when streaming begins/ends + useEffect(() => { + const el = messagesScrollRef.current; + if (isStreaming) { + streamScrollLock.current = el?.scrollTop ?? 0; + } else { + streamScrollLock.current = null; + } + }, [isStreaming]); + + // After every render during streaming, restore the locked scroll position + useLayoutEffect(() => { + if (streamScrollLock.current === null) return; + const el = messagesScrollRef.current; + if (!el) return; + el.scrollTop = streamScrollLock.current; + }); + + // Scroll to bottom only when message COUNT increases (new message added) + const prevMsgCountRef = useRef(0); + useLayoutEffect(() => { + const count = activeConversation?.messages?.length ?? 0; + const prev = prevMsgCountRef.current; + prevMsgCountRef.current = count; + if (count > prev) { + const el = messagesScrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + } + }, [activeConversation?.messages]); + + const showBlankState = !activeConversation || activeConversation.messages.length === 0; + const displayName = userEmail?.split("@")[0] ?? userId ?? ""; + const greeting = displayName ? `${getGreeting()}, ${displayName}` : getGreeting(); + const dashboardUrl = getDashboardUrl(); + + // ---- Sidebar nav item renderer (inline, not a function-in-function) ---- + const sidebarNavItem = ( + icon: React.ReactNode, + label: string, + onClick: () => void, + active = false, + kbd?: string, + ) => ( + + + + ); + + return ( +
+ + {/* ===== LEFT SIDEBAR ===== */} +
+ + {/* Sidebar header: logo + collapse button */} +
+ {!sidebarCollapsed && ( +
+ LiteLLM + + LiteLLM + +
+ )} + + + +
+ + {/* Sidebar nav buttons */} +
+ {sidebarNavItem(, "New chat", () => router.push("/chat"))} + {sidebarNavItem(, "Search chats", () => {}, false, "⌘K")} +
+ +
+ + {/* Chats / Apps tabs + Back to console */} +
+ {sidebarNavItem(, "Chats", () => setSidebarView("chats"), sidebarView === "chats")} + {sidebarNavItem(, "Apps", () => setSidebarView("apps"), sidebarView === "apps")} + + { + (e.currentTarget as HTMLAnchorElement).style.background = "#f5f5f5"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLAnchorElement).style.background = "transparent"; + }} + > + + {!sidebarCollapsed && ( + Back to Developer Console UI + )} + + +
+ +
+ + {/* Sidebar content — only conversation list, only when in chats view and expanded */} + {!sidebarCollapsed && sidebarView === "chats" && ( +
+ router.push(`/chat?id=${id}`)} + onDelete={deleteConversation} + onNewChat={() => router.push("/chat")} + onRename={renameConversation} + /> +
+ )} + +
+ + {/* ===== MAIN AREA ===== */} +
+ + {/* Top bar — clean, minimal like ChatGPT */} +
+ {/* Left: model selector */} +
+ {isLoadingModels ? ( + + ) : ( +