feat(ui): add Chat UI v0 — standalone LiteLLM-branded chat window

Adds a full chat UI accessible from the sidebar Chat link (opens in new tab).
- Standalone route at /chat (outside dashboard layout — no Navbar/Sidebar chrome)
- Claude.ai-style layout: model selector top-left, LiteLLM logo center, settings top-right
- Greeting with time-of-day, centered input card, suggestion chips (Write/Learn/Code/Brainstorm)
- Sliding conversation history sidebar with Cmd+K search, rename, delete, date grouping
- localStorage-backed conversation persistence (litellm_chat_history_v1)
- Streaming completions via makeOpenAIChatCompletionRequest with AbortController stop support
- MCP server picker (toggle servers on/off per conversation)
- LiteLLM aesthetic: white/light-gray background, Ant Design blue (#1677ff) primary, system font
- Sidebar2: Chat menu item opens in new tab via window.open
This commit is contained in:
Ishaan Jaffer 2026-03-05 15:05:57 -08:00
parent 3d027c0f7a
commit 7561cdc4cc
10 changed files with 2130 additions and 7 deletions

View file

@ -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: <KeyOutlined style={{ fontSize: 18 }} /> },
{ key: "29", page: "chat", label: "Chat", icon: <MessageOutlined style={{ fontSize: 18 }} />, newTab: true },
{
key: "3",
page: "llm-playground",
@ -371,19 +376,29 @@ const Sidebar2: React.FC<SidebarProps> = ({ 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 <a> 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 (
<a
href={href}
target={newTab ? "_blank" : undefined}
rel={newTab ? "noopener noreferrer" : undefined}
onClick={(e) => {
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<SidebarProps> = ({ 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,
}))}
/>
</ConfigProvider>

View file

@ -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 (
<ChatPage
accessToken={accessToken ?? ""}
userRole={userRole ?? ""}
userId={userId ?? ""}
userEmail={userEmail ?? ""}
/>
);
};
export default ChatPageRoute;

View file

@ -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<Props> = ({
onSend,
isStreaming,
onStop,
selectedMCPServers,
onMCPChange,
isLoadingModels,
accessToken,
}) => {
const [text, setText] = useState<string>("");
const [mcpPopoverOpen, setMcpPopoverOpen] = useState<boolean>(false);
const handleSend = () => {
if (text.trim() === "" || isStreaming || isLoadingModels) return;
onSend(text.trim());
setText("");
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const mcpButtonLabel =
selectedMCPServers.length > 0
? `MCP (${selectedMCPServers.length})`
: "MCP";
const mcpPopoverContent = (
<div style={{ minWidth: 200 }}>
{/* MCPConnectPicker - LIT-2170 */}
</div>
);
return (
<div
style={{
display: "flex",
alignItems: "flex-end",
gap: 8,
padding: "12px 16px",
borderTop: "1px solid #e5e7eb",
backgroundColor: "#ffffff",
}}
>
<Popover
content={mcpPopoverContent}
title="MCP Servers"
trigger="click"
open={mcpPopoverOpen}
onOpenChange={setMcpPopoverOpen}
placement="topLeft"
>
<Button icon={<ApiOutlined />}>
{mcpButtonLabel}
</Button>
</Popover>
<Tooltip title="Coming soon">
<Button icon={<PaperClipOutlined />} disabled />
</Tooltip>
<Input.TextArea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Message..."
autoSize={{ minRows: 1, maxRows: 5 }}
style={{ flex: 1 }}
/>
{isStreaming ? (
<Button
icon={<BorderOutlined />}
onClick={onStop}
type="primary"
danger
/>
) : (
<Button
icon={<SendOutlined />}
onClick={handleSend}
type="primary"
disabled={isStreaming || isLoadingModels || text.trim() === ""}
/>
)}
</div>
);
};
export default ChatInputBar;

View file

@ -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<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
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<string, unknown>);
} 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 ? (
<SyntaxHighlighter
style={coy as Record<string, React.CSSProperties>}
language={match[1]}
PreTag="div"
className="rounded-md my-2"
{...(props as Record<string, unknown>)}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code
className={`${className ?? ""} px-1.5 py-0.5 rounded bg-gray-100 text-sm font-mono`}
{...props}
>
{children}
</code>
);
}
// ------- Sub-components -------
interface UserBubbleProps {
message: ChatMessage;
}
function UserBubble({ message }: UserBubbleProps) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end" }}>
<div
style={{
maxWidth: "72%",
backgroundColor: "#f0f2f5",
borderRadius: 16,
padding: "10px 14px",
fontSize: 14,
lineHeight: "1.6",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
color: "#111827",
}}
>
{message.content}
</div>
<span
style={{
fontSize: 11,
color: "#9ca3af",
marginTop: 4,
}}
>
{formatTimestamp(message.timestamp)}
</span>
</div>
);
}
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<number>(0);
const prevStreamingRef = useRef<boolean>(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 (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
<div style={{ display: "flex", alignItems: "center", gap: 4, padding: "10px 4px" }}>
<TypingDots />
</div>
</div>
);
}
// 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 (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", maxWidth: "80%" }}>
{showReasoning && (
showReasoningPlaceholder ? (
<ThinkingPlaceholder />
) : (
<ReasoningContent
key={reasoningKeyRef.current}
reasoningContent={message.reasoningContent!}
/>
)
)}
<div
style={{
fontSize: 14,
lineHeight: "1.7",
color: "#111827",
wordBreak: "break-word",
}}
>
<ReactMarkdown
components={{
code: MarkdownCodeRenderer as React.ComponentType<React.ComponentPropsWithoutRef<"code">>,
}}
>
{mainContent}
</ReactMarkdown>
{stoppedSuffix && (
<span style={{ color: "#9ca3af", fontStyle: "italic" }}> [stopped]</span>
)}
</div>
<span style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
{formatTimestamp(message.timestamp)}
</span>
</div>
);
}
function ThinkingPlaceholder() {
return (
<>
<style>{`
@keyframes thinking-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.chat-thinking-text {
animation: thinking-pulse 1.4s ease-in-out infinite;
}
`}</style>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "4px 10px",
marginBottom: 8,
backgroundColor: "#f9fafb",
border: "1px solid #e5e7eb",
borderRadius: 8,
fontSize: 12,
color: "#6b7280",
}}
>
<span className="chat-thinking-text">Thinking...</span>
</div>
</>
);
}
function TypingDots() {
return (
<>
<style>{`
@keyframes chat-typing-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-4px); opacity: 1; }
}
.chat-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background-color: #9ca3af;
animation: chat-typing-bounce 1.2s ease-in-out infinite;
}
.chat-dot:nth-child(2) { animation-delay: 0.2s; }
.chat-dot:nth-child(3) { animation-delay: 0.4s; }
`}</style>
<div className="chat-dot" />
<div className="chat-dot" />
<div className="chat-dot" />
</>
);
}
interface ToolCardProps {
message: ChatMessage;
}
function ToolCard({ message }: ToolCardProps) {
const redactedArgs =
message.toolArgs ? redactSensitiveValues(message.toolArgs) : undefined;
return (
<div style={{ maxWidth: "80%" }}>
<Collapse
size="small"
style={{
backgroundColor: "#fafafa",
border: "1px solid #e5e7eb",
borderRadius: 8,
}}
>
<Panel
header={
<span style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13 }}>
<ToolOutlined style={{ color: "#6b7280" }} />
<span style={{ color: "#374151", fontWeight: 500 }}>
{message.toolName ?? "Tool call"}
</span>
</span>
}
key="tool"
>
{redactedArgs !== undefined && (
<div style={{ marginBottom: message.toolResult ? 12 : 0 }}>
<div
style={{
fontSize: 11,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "#9ca3af",
marginBottom: 4,
}}
>
Arguments
</div>
<pre
style={{
margin: 0,
padding: "8px 10px",
backgroundColor: "#f3f4f6",
borderRadius: 6,
fontSize: 12,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
whiteSpace: "pre-wrap",
wordBreak: "break-word",
color: "#374151",
}}
>
{JSON.stringify(redactedArgs, null, 2)}
</pre>
</div>
)}
{message.toolResult && (
<div>
<div
style={{
fontSize: 11,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "#9ca3af",
marginBottom: 4,
}}
>
Result
</div>
<div
style={{
fontSize: 13,
color: "#374151",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
}}
>
{message.toolResult}
</div>
</div>
)}
</Panel>
</Collapse>
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
{formatTimestamp(message.timestamp)}
</div>
</div>
);
}
// ------- Main component -------
interface Props {
messages: ChatMessage[];
isStreaming: boolean;
}
const ChatMessages: React.FC<Props> = ({ messages, isStreaming }) => {
const bottomRef = useRef<HTMLDivElement>(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 (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{messages.map((msg, idx) => {
const isLastMessage = idx === lastIndex;
if (msg.role === "user") {
return <UserBubble key={msg.id} message={msg} />;
}
if (msg.role === "tool") {
return <ToolCard key={msg.id} message={msg} />;
}
// assistant
return (
<AssistantBubble
key={msg.id}
message={msg}
isLastMessage={isLastMessage}
isStreaming={isStreaming}
isTypingIndicator={isLastMessage && isTypingIndicator}
/>
);
})}
{/* Bottom sentinel for auto-scroll */}
<div ref={bottomRef} />
</div>
);
};
export default ChatMessages;

View file

@ -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<ChatPageProps> = ({ accessToken, userRole, userId, userEmail }) => {
const router = useRouter();
const searchParams = useSearchParams();
const activeConversationId = searchParams.get("id");
const [selectedModel, setSelectedModel] = useState<string>("");
const [models, setModels] = useState<string[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(true);
const [selectedMCPServers, setSelectedMCPServers] = useState<string[]>([]);
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<AbortController | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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 (
<div style={{
display: "flex",
height: "100vh",
width: "100vw",
background: "#ffffff",
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
overflow: "hidden",
}}>
{/* Conversation sidebar — slides in */}
{sidebarOpen && (
<div style={{
width: 260,
flexShrink: 0,
background: "#fafafa",
borderRight: "1px solid #f0f0f0",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}>
<ConversationList
conversations={conversations}
activeConversationId={activeConversationId}
onSelect={(id) => router.push(`/chat?id=${id}`)}
onDelete={deleteConversation}
onNewChat={() => router.push("/chat")}
onRename={renameConversation}
/>
</div>
)}
{/* Main area */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Top bar */}
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 20px",
flexShrink: 0,
borderBottom: "1px solid #f0f0f0",
background: "#fff",
}}>
{/* Left: sidebar toggle + model selector */}
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<button
onClick={() => setSidebarOpen((v) => !v)}
style={{
background: "none", border: "none", cursor: "pointer",
padding: 6, borderRadius: 6, color: "#595959",
fontSize: 18, lineHeight: 1,
}}
title="Toggle chat history"
>
</button>
{isLoadingModels ? (
<Skeleton.Input active style={{ width: 160, height: 32 }} />
) : (
<Select
value={selectedModel || undefined}
onChange={handleModelChange}
showSearch
placeholder="Select model"
style={{ width: 220 }}
size="middle"
variant="filled"
options={models.map((m) => ({
value: m,
label: m.length > 35 ? m.slice(0, 35) + "…" : m,
}))}
/>
)}
</div>
{/* Center: LiteLLM logo */}
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<img
src="/assets/logos/litellm_logo.jpg"
alt="LiteLLM"
width={28}
height={28}
style={{ borderRadius: 6, objectFit: "cover" }}
/>
<span style={{ fontWeight: 600, fontSize: 15, color: "#1f2937", letterSpacing: "-0.01em" }}>
LiteLLM Chat
</span>
</div>
{/* Right: settings */}
<Tooltip title="Settings">
<button style={{
background: "none", border: "none", cursor: "pointer",
padding: 6, borderRadius: 6, color: "#595959", fontSize: 18,
}}>
<SettingOutlined />
</button>
</Tooltip>
</div>
{storageUnavailable && !storageBannerDismissed && (
<div style={{
background: "#fffbe6", borderBottom: "1px solid #ffe58f",
padding: "8px 20px", fontSize: 13, color: "#874d00",
display: "flex", justifyContent: "space-between", alignItems: "center",
}}>
<span>Chat history won&apos;t be saved in this browser session.</span>
<button onClick={() => setStorageBannerDismissed(true)}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: "#92400e" }}>
×
</button>
</div>
)}
{/* Content area */}
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column", background: "#f9fafb" }}>
{showBlankState ? (
/* ---- Blank state ---- */
<div style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "0 24px 60px",
}}>
{/* Greeting */}
<div style={{
display: "flex",
alignItems: "center",
gap: 14,
marginBottom: 40,
}}>
<img
src="/assets/logos/litellm_logo.jpg"
alt="LiteLLM"
width={40}
height={40}
style={{ borderRadius: 8, objectFit: "cover" }}
/>
<h1 style={{
margin: 0,
fontSize: 32,
fontWeight: 600,
color: "#1f2937",
fontFamily: "inherit",
letterSpacing: "-0.01em",
}}>
{greeting}
</h1>
</div>
{/* Input card */}
<div style={{
width: "100%",
maxWidth: 680,
background: "#fff",
borderRadius: 12,
border: "1px solid #e8e8e8",
boxShadow: "0 1px 4px rgba(0,0,0,0.06)",
overflow: "hidden",
}}>
<textarea
ref={textareaRef}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="How can I help you today?"
style={{
width: "100%",
minHeight: 80,
padding: "20px 20px 8px",
border: "none",
outline: "none",
resize: "none",
fontSize: 15,
color: "#1f2937",
background: "transparent",
fontFamily: "inherit",
boxSizing: "border-box",
}}
/>
{/* Card footer */}
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "8px 12px 12px",
borderTop: "1px solid #f5f5f5",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<Popover
open={mcpPopoverOpen}
onOpenChange={setMcpPopoverOpen}
content={
<MCPConnectPicker
accessToken={accessToken}
selectedServers={selectedMCPServers}
onChange={setSelectedMCPServers}
/>
}
trigger="click"
placement="topLeft"
>
<button style={{
background: "none", border: "1px solid #d9d9d9",
borderRadius: 6, padding: "5px 10px",
cursor: "pointer", fontSize: 16, color: "#595959",
display: "flex", alignItems: "center",
}}>
<PlusOutlined />
</button>
</Popover>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 12, color: "#8c8c8c", maxWidth: 140, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{selectedModel || "No model"}
</span>
{isStreaming ? (
<button
onClick={handleStop}
style={{
background: "#ff4d4f", border: "none", borderRadius: 6,
padding: "6px 8px", cursor: "pointer", color: "#fff",
display: "flex", alignItems: "center",
}}
>
<BorderOutlined />
</button>
) : (
<button
onClick={() => handleSend(inputText)}
disabled={!inputText.trim() || isLoadingModels || !selectedModel}
style={{
background: inputText.trim() && selectedModel ? "#1677ff" : "#f0f0f0",
border: "none", borderRadius: 6,
padding: "6px 14px", cursor: inputText.trim() && selectedModel ? "pointer" : "not-allowed",
color: inputText.trim() && selectedModel ? "#fff" : "#bfbfbf",
display: "flex", alignItems: "center",
transition: "background 0.15s",
fontSize: 14, fontWeight: 500,
}}
>
Send
</button>
)}
</div>
</div>
</div>
{/* Suggestion chips */}
<div style={{ display: "flex", gap: 10, marginTop: 16, flexWrap: "wrap", justifyContent: "center" }}>
{SUGGESTIONS.map((s) => (
<button
key={s.label}
onClick={() => setInputText(s.label + ": ")}
style={{
background: "#fff",
border: "1px solid #e8e8e8",
borderRadius: 8,
padding: "7px 16px",
fontSize: 14,
color: "#595959",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<span>{s.icon}</span> {s.label}
</button>
))}
</div>
</div>
) : (
/* ---- Active conversation ---- */
<div style={{ flex: 1, display: "flex", flexDirection: "column", maxWidth: 760, margin: "0 auto", width: "100%", padding: "0 24px" }}>
<div style={{ flex: 1, overflow: "auto", paddingTop: 24 }}>
<ChatMessages
messages={activeConversation.messages}
isStreaming={isStreaming}
/>
</div>
{/* Input bar (in conversation) */}
<div style={{ padding: "12px 0 24px" }}>
<div style={{
background: "#fff",
borderRadius: 12,
border: "1px solid #e8e8e8",
boxShadow: "0 1px 4px rgba(0,0,0,0.06)",
overflow: "hidden",
}}>
<textarea
ref={textareaRef}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Reply..."
style={{
width: "100%",
minHeight: 56,
padding: "16px 20px 8px",
border: "none",
outline: "none",
resize: "none",
fontSize: 15,
color: "#1f2937",
background: "transparent",
fontFamily: "inherit",
boxSizing: "border-box",
}}
/>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "4px 12px 10px",
borderTop: "1px solid #f5f5f5",
}}>
<Popover
open={mcpPopoverOpen}
onOpenChange={setMcpPopoverOpen}
content={
<MCPConnectPicker
accessToken={accessToken}
selectedServers={selectedMCPServers}
onChange={setSelectedMCPServers}
/>
}
trigger="click"
placement="topLeft"
>
<button style={{
background: "none", border: "1px solid #d9d9d9",
borderRadius: 6, padding: "5px 10px",
cursor: "pointer", fontSize: 14, color: "#595959",
display: "flex", alignItems: "center",
}}>
<PlusOutlined />
</button>
</Popover>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 12, color: "#8c8c8c" }}>
{selectedMCPServers.length > 0 ? `MCP (${selectedMCPServers.length})` : ""}
</span>
{isStreaming ? (
<button onClick={handleStop} style={{
background: "#ff4d4f", border: "none", borderRadius: 6,
padding: "6px 8px", cursor: "pointer", color: "#fff",
display: "flex", alignItems: "center",
}}>
<BorderOutlined />
</button>
) : (
<button
onClick={() => handleSend(inputText)}
disabled={!inputText.trim() || isLoadingModels || !selectedModel}
style={{
background: inputText.trim() && selectedModel ? "#1677ff" : "#f0f0f0",
border: "none", borderRadius: 6,
padding: "6px 14px", cursor: inputText.trim() && selectedModel ? "pointer" : "not-allowed",
color: inputText.trim() && selectedModel ? "#fff" : "#bfbfbf",
fontSize: 14, fontWeight: 500,
transition: "background 0.15s",
}}
>
Send
</button>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default ChatPage;

View file

@ -0,0 +1,483 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import {
Button,
Input,
Modal,
Popconfirm,
Tooltip,
Avatar,
Typography,
} from "antd";
import {
EditOutlined,
DeleteOutlined,
PlusOutlined,
SearchOutlined,
UserOutlined,
MessageOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import { Conversation } from "./types";
const { Text } = Typography;
interface Props {
conversations: Conversation[];
activeConversationId: string | null;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
onNewChat: () => void;
onRename: (id: string, newTitle: string) => void;
}
// ---- Date grouping helpers ----
type DateGroup = "Today" | "Yesterday" | "Last 7 Days" | "Older";
const getDateGroup = (timestamp: number): DateGroup => {
const now = dayjs();
const date = dayjs(timestamp);
if (date.isSame(now, "day")) return "Today";
if (date.isSame(now.subtract(1, "day"), "day")) return "Yesterday";
if (date.isAfter(now.subtract(7, "day"))) return "Last 7 Days";
return "Older";
};
const DATE_GROUP_ORDER: DateGroup[] = ["Today", "Yesterday", "Last 7 Days", "Older"];
interface GroupedConversations {
group: DateGroup;
items: Conversation[];
}
const groupConversations = (conversations: Conversation[]): GroupedConversations[] => {
const map = new Map<DateGroup, Conversation[]>();
for (const conv of conversations) {
const group = getDateGroup(conv.updatedAt);
if (!map.has(group)) map.set(group, []);
map.get(group)!.push(conv);
}
return DATE_GROUP_ORDER.filter((g) => map.has(g)).map((g) => ({
group: g,
items: map.get(g)!,
}));
};
// ---- Single conversation row ----
interface ConversationRowProps {
conv: Conversation;
isActive: boolean;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
onRename: (id: string, newTitle: string) => void;
}
const ConversationRow: React.FC<ConversationRowProps> = ({
conv,
isActive,
onSelect,
onDelete,
onRename,
}) => {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(conv.title);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
const startEditing = (e: React.MouseEvent) => {
e.stopPropagation();
setEditValue(conv.title);
setEditing(true);
};
const commitRename = () => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== conv.title) {
onRename(conv.id, trimmed);
}
setEditing(false);
};
const cancelEditing = () => {
setEditValue(conv.title);
setEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
commitRename();
} else if (e.key === "Escape") {
e.preventDefault();
cancelEditing();
}
};
const truncatedTitle =
conv.title.length > 40 ? conv.title.slice(0, 40) + "…" : conv.title;
return (
<div
onClick={() => !editing && onSelect(conv.id)}
className="conversation-row group"
style={{
display: "flex",
alignItems: "center",
padding: "6px 8px",
borderRadius: 6,
cursor: editing ? "default" : "pointer",
backgroundColor: isActive ? "#e6f4ff" : "transparent",
transition: "background-color 0.15s",
minHeight: 34,
position: "relative",
}}
onMouseEnter={(e) => {
if (!isActive) {
(e.currentTarget as HTMLDivElement).style.backgroundColor = "#f5f5f5";
}
}}
onMouseLeave={(e) => {
if (!isActive) {
(e.currentTarget as HTMLDivElement).style.backgroundColor = "transparent";
}
}}
>
{editing ? (
<Input
ref={(node) => {
inputRef.current = node?.input ?? null;
}}
size="small"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={commitRename}
onClick={(e) => e.stopPropagation()}
style={{ flex: 1, fontSize: 13 }}
/>
) : (
<>
<Text
style={{
flex: 1,
fontSize: 13,
color: isActive ? "#1677ff" : "#333",
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
fontWeight: isActive ? 500 : 400,
}}
title={conv.title}
>
{truncatedTitle}
</Text>
{/* Action icons — visible only on hover via CSS opacity */}
<div
className="conversation-actions"
style={{
display: "flex",
gap: 2,
opacity: 0,
transition: "opacity 0.15s",
flexShrink: 0,
}}
onClick={(e) => e.stopPropagation()}
>
<Tooltip title="Rename">
<Button
type="text"
size="small"
icon={<EditOutlined style={{ fontSize: 12 }} />}
onClick={startEditing}
style={{ width: 22, height: 22, padding: 0, minWidth: 22 }}
/>
</Tooltip>
<Popconfirm
title="Delete this conversation?"
onConfirm={() => onDelete(conv.id)}
okText="Delete"
cancelText="Cancel"
okButtonProps={{ danger: true }}
>
<Tooltip title="Delete">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined style={{ fontSize: 12 }} />}
style={{ width: 22, height: 22, padding: 0, minWidth: 22 }}
/>
</Tooltip>
</Popconfirm>
</div>
</>
)}
</div>
);
};
// ---- Cmd+K search modal ----
interface SearchModalProps {
open: boolean;
conversations: Conversation[];
onSelect: (id: string) => void;
onClose: () => void;
}
const SearchModal: React.FC<SearchModalProps> = ({
open,
conversations,
onSelect,
onClose,
}) => {
const [query, setQuery] = useState("");
useEffect(() => {
if (!open) setQuery("");
}, [open]);
const filtered = query.trim()
? conversations.filter((c) =>
c.title.toLowerCase().includes(query.trim().toLowerCase())
)
: conversations;
const handleSelect = (id: string) => {
onSelect(id);
onClose();
};
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
title={null}
width={480}
styles={{ body: { padding: "16px 16px 8px" } }}
>
<Input
autoFocus
prefix={<SearchOutlined style={{ color: "#bbb" }} />}
placeholder="Search conversations…"
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{ marginBottom: 12 }}
allowClear
/>
<div style={{ maxHeight: 320, overflowY: "auto" }}>
{filtered.length === 0 ? (
<div style={{ textAlign: "center", padding: "24px 0", color: "#999" }}>
No conversations found
</div>
) : (
filtered.map((conv) => {
const truncated =
conv.title.length > 55 ? conv.title.slice(0, 55) + "…" : conv.title;
return (
<div
key={conv.id}
onClick={() => handleSelect(conv.id)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
borderRadius: 6,
cursor: "pointer",
transition: "background-color 0.1s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLDivElement).style.backgroundColor = "#f0f5ff";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLDivElement).style.backgroundColor = "transparent";
}}
>
<MessageOutlined style={{ color: "#999", flexShrink: 0 }} />
<Text style={{ fontSize: 13 }}>{truncated}</Text>
<Text
type="secondary"
style={{ fontSize: 11, marginLeft: "auto", flexShrink: 0 }}
>
{dayjs(conv.updatedAt).format("MMM D")}
</Text>
</div>
);
})
)}
</div>
</Modal>
);
};
// ---- Main ConversationList component ----
const ConversationList: React.FC<Props> = ({
conversations,
activeConversationId,
onSelect,
onDelete,
onNewChat,
onRename,
}) => {
const [searchModalOpen, setSearchModalOpen] = useState(false);
// Cmd+K / Ctrl+K listener
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setSearchModalOpen((prev) => !prev);
}
}, []);
useEffect(() => {
document.addEventListener("keydown", handleGlobalKeyDown);
return () => document.removeEventListener("keydown", handleGlobalKeyDown);
}, [handleGlobalKeyDown]);
const grouped = groupConversations(conversations);
return (
<>
{/* Hover-reveal CSS for action icons */}
<style>{`
.conversation-row:hover .conversation-actions {
opacity: 1 !important;
}
`}</style>
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
width: "100%",
overflow: "hidden",
}}
>
{/* Top: New Chat button */}
<div style={{ padding: "12px 10px 8px" }}>
<Tooltip
title="Chats are saved locally in this browser. All requests are logged in Spend → Logs."
placement="right"
>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={onNewChat}
style={{ width: "100%" }}
>
New Chat
</Button>
</Tooltip>
</div>
{/* Conversation list (scrollable) */}
<div
style={{
flex: 1,
overflowY: "auto",
padding: "0 6px",
}}
>
{grouped.length === 0 ? (
<div
style={{
textAlign: "center",
color: "#bbb",
fontSize: 12,
marginTop: 32,
padding: "0 12px",
}}
>
No conversations yet.
<br />
Start a new chat above.
</div>
) : (
grouped.map(({ group, items }) => (
<div key={group} style={{ marginBottom: 8 }}>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "#999",
textTransform: "uppercase",
letterSpacing: "0.04em",
padding: "8px 8px 4px",
}}
>
{group}
</div>
{items.map((conv) => (
<ConversationRow
key={conv.id}
conv={conv}
isActive={conv.id === activeConversationId}
onSelect={onSelect}
onDelete={onDelete}
onRename={onRename}
/>
))}
</div>
))
)}
</div>
{/* Bottom: user avatar placeholder */}
<div
style={{
padding: "10px 12px",
borderTop: "1px solid #f0f0f0",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<Avatar
size={28}
icon={<UserOutlined />}
style={{ backgroundColor: "#e0e7ff", color: "#4f46e5", flexShrink: 0 }}
/>
<Text
style={{
fontSize: 13,
color: "#555",
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
}}
>
My Account
</Text>
</div>
</div>
{/* Cmd+K search modal */}
<SearchModal
open={searchModalOpen}
conversations={conversations}
onSelect={onSelect}
onClose={() => setSearchModalOpen(false)}
/>
</>
);
};
export default ConversationList;

View file

@ -0,0 +1,157 @@
import React, { useEffect, useState } from "react";
import { Switch, Spin, message } from "antd";
import { fetchMCPServers, listMCPTools } from "../networking";
import { MCPServer } from "../mcp_tools/types";
interface Props {
accessToken: string;
selectedServers: string[];
onChange: (servers: string[]) => void;
}
const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onChange }) => {
const [servers, setServers] = useState<MCPServer[]>([]);
const [loadingServers, setLoadingServers] = useState(true);
// Track which individual servers are being toggled on (verifying tools)
const [togglingOn, setTogglingOn] = useState<Set<string>>(new Set());
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoadingServers(true);
try {
const data = await fetchMCPServers(accessToken);
if (cancelled) return;
// API returns { data: MCPServer[] } or MCPServer[]
const list: MCPServer[] = Array.isArray(data) ? data : (data?.data ?? []);
setServers(list);
} catch {
if (!cancelled) {
setServers([]);
}
} finally {
if (!cancelled) {
setLoadingServers(false);
}
}
};
load();
return () => {
cancelled = true;
};
}, [accessToken]);
const handleToggle = async (serverName: string, checked: boolean) => {
if (!checked) {
// Toggle OFF — remove immediately, no tool fetch needed
onChange(selectedServers.filter((s) => s !== serverName));
return;
}
// Toggle ON — verify tools are reachable first
setTogglingOn((prev) => new Set(prev).add(serverName));
try {
const result = await listMCPTools(accessToken, serverName);
// listMCPTools never throws; it returns { tools, error, message } on failure
if (result?.error) {
message.warning(
`Could not load tools for ${serverName} — it will be excluded from this message.`
);
// Do not add to selectedServers
return;
}
onChange([...selectedServers, serverName]);
} catch {
message.warning(
`Could not load tools for ${serverName} — it will be excluded from this message.`
);
// Do not add to selectedServers
} finally {
setTogglingOn((prev) => {
const next = new Set(prev);
next.delete(serverName);
return next;
});
}
};
return (
<div
style={{
maxWidth: 320,
maxHeight: 400,
overflowY: "auto",
padding: "8px 0",
}}
>
{loadingServers ? (
<div style={{ display: "flex", justifyContent: "center", padding: "24px 0" }}>
<Spin />
</div>
) : servers.length === 0 ? (
<div style={{ padding: "16px 12px", color: "#8c8c8c", fontSize: 13, textAlign: "center" }}>
No MCP servers configured
</div>
) : (
servers.map((server) => {
const name = server.server_name ?? server.alias ?? server.server_id;
const isSelected = selectedServers.includes(name);
const isTogglingOn = togglingOn.has(name);
return (
<div
key={server.server_id}
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
padding: "8px 12px",
gap: 12,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 500,
fontSize: 13,
color: "#1f1f1f",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{name}
</div>
{server.description && (
<div
style={{
fontSize: 12,
color: "#8c8c8c",
marginTop: 2,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{server.description}
</div>
)}
</div>
<Switch
size="small"
checked={isSelected}
loading={isTogglingOn}
onChange={(checked) => handleToggle(name, checked)}
/>
</div>
);
})
)}
</div>
);
};
export default MCPConnectPicker;

View file

@ -0,0 +1,107 @@
import React, { useEffect, useState } from "react";
import { Select, Skeleton } from "antd";
import { fetchAvailableModels, ModelGroup } from "../playground/llm_calls/fetch_models";
const LOCALSTORAGE_KEY = "litellm_chat_selected_model";
const MAX_DISPLAY_LENGTH = 40;
interface Props {
accessToken: string;
selectedModel: string;
onChange: (model: string) => void;
onLoadingChange: (loading: boolean) => void;
}
const ModelSelector: React.FC<Props> = ({
accessToken,
selectedModel,
onChange,
onLoadingChange,
}) => {
const [models, setModels] = useState<ModelGroup[]>([]);
const [loading, setLoading] = useState(true);
const [fetchFailed, setFetchFailed] = useState(false);
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoading(true);
onLoadingChange(true);
try {
const fetched = await fetchAvailableModels(accessToken);
if (cancelled) return;
setModels(fetched);
if (fetched.length > 0) {
const persisted = localStorage.getItem(LOCALSTORAGE_KEY);
const modelNames = fetched.map((m) => m.model_group);
if (persisted && modelNames.includes(persisted)) {
onChange(persisted);
} else {
// Persisted model not in list — clear stale value and default to first
if (persisted) {
localStorage.removeItem(LOCALSTORAGE_KEY);
}
onChange(fetched[0].model_group);
}
}
} catch {
if (!cancelled) {
setFetchFailed(true);
}
} finally {
if (!cancelled) {
setLoading(false);
onLoadingChange(false);
}
}
};
load();
return () => {
cancelled = true;
};
}, [accessToken]); // eslint-disable-line react-hooks/exhaustive-deps
const handleChange = (value: string) => {
localStorage.setItem(LOCALSTORAGE_KEY, value);
onChange(value);
};
if (loading) {
return <Skeleton.Input active style={{ width: 220 }} />;
}
if (fetchFailed || models.length === 0) {
return (
<span style={{ color: "#8c8c8c", fontSize: 13 }}>
No models available check your proxy config
</span>
);
}
return (
<Select
value={selectedModel}
onChange={handleChange}
style={{ width: 220 }}
showSearch
filterOption={(input, option) =>
(option?.label as string ?? "").toLowerCase().includes(input.toLowerCase())
}
options={models.map((m) => ({
value: m.model_group,
label:
m.model_group.length > MAX_DISPLAY_LENGTH
? `${m.model_group.slice(0, MAX_DISPLAY_LENGTH)}`
: m.model_group,
}))}
/>
);
};
export default ModelSelector;

View file

@ -0,0 +1,20 @@
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "tool";
content: string;
reasoningContent?: string;
toolName?: string;
toolArgs?: Record<string, unknown>;
toolResult?: string;
timestamp: number;
}
export interface Conversation {
id: string;
title: string;
model: string;
messages: ChatMessage[];
mcpServerNames: string[];
createdAt: number;
updatedAt: number;
}

View file

@ -0,0 +1,246 @@
import { useCallback, useEffect, useState } from "react";
import { ChatMessage, Conversation } from "./types";
const STORAGE_KEY = "litellm_chat_history_v1";
const MAX_CONVERSATIONS = 100;
const TITLE_MAX_LENGTH = 40;
function generateTitle(firstUserMessage: string): string {
const trimmed = firstUserMessage.trim();
if (trimmed.length <= TITLE_MAX_LENGTH) {
return trimmed;
}
return trimmed.slice(0, TITLE_MAX_LENGTH);
}
function loadFromStorage(): { conversations: Conversation[]; storageUnavailable: boolean } {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return { conversations: [], storageUnavailable: false };
}
const parsed = JSON.parse(raw) as Conversation[];
return { conversations: parsed, storageUnavailable: false };
} catch {
return { conversations: [], storageUnavailable: true };
}
}
function saveToStorage(conversations: Conversation[]): boolean {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations));
return true;
} catch {
return false;
}
}
function trimConversations(conversations: Conversation[]): Conversation[] {
if (conversations.length <= MAX_CONVERSATIONS) {
return conversations;
}
return [...conversations]
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, MAX_CONVERSATIONS);
}
export function useChatHistory(activeConversationId: string | null): {
conversations: Conversation[];
activeConversation: Conversation | null;
storageUnavailable: boolean;
staleId: boolean;
createConversation: (model: string) => string;
appendMessage: (conversationId: string, message: Omit<ChatMessage, "id" | "timestamp">) => void;
updateLastAssistantMessage: (conversationId: string, updates: Partial<Pick<ChatMessage, "content" | "reasoningContent">>) => void;
deleteConversation: (id: string) => void;
renameConversation: (id: string, newTitle: string) => void;
setActiveConversationId: (id: string | null) => void;
} {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [storageUnavailable, setStorageUnavailable] = useState(false);
const [staleId, setStaleId] = useState(false);
const [currentActiveId, setCurrentActiveId] = useState<string | null>(activeConversationId);
useEffect(() => {
const { conversations: loaded, storageUnavailable: unavailable } = loadFromStorage();
setConversations(loaded);
setStorageUnavailable(unavailable);
if (activeConversationId !== null) {
const found = loaded.some((c) => c.id === activeConversationId);
if (!found) {
setStaleId(true);
}
}
}, []);
const persistConversations = useCallback(
(updated: Conversation[]) => {
const trimmed = trimConversations(updated);
setConversations(trimmed);
if (!storageUnavailable) {
const success = saveToStorage(trimmed);
if (!success) {
setStorageUnavailable(true);
}
}
},
[storageUnavailable],
);
const createConversation = useCallback(
(model: string): string => {
const id = crypto.randomUUID();
const now = Date.now();
const newConversation: Conversation = {
id,
title: "New conversation",
model,
messages: [],
mcpServerNames: [],
createdAt: now,
updatedAt: now,
};
persistConversations([newConversation, ...conversations]);
setCurrentActiveId(id);
return id;
},
[conversations, persistConversations],
);
const appendMessage = useCallback(
(conversationId: string, message: Omit<ChatMessage, "id" | "timestamp">) => {
const newMessage: ChatMessage = {
...message,
id: crypto.randomUUID(),
timestamp: Date.now(),
};
setConversations((prev) => {
const updated = prev.map((conv) => {
if (conv.id !== conversationId) {
return conv;
}
const updatedMessages = [...conv.messages, newMessage];
let title = conv.title;
if (
title === "New conversation" &&
newMessage.role === "user" &&
conv.messages.filter((m) => m.role === "user").length === 0
) {
title = generateTitle(newMessage.content);
}
return {
...conv,
title,
messages: updatedMessages,
updatedAt: Date.now(),
};
});
const trimmed = trimConversations(updated);
if (!storageUnavailable) {
const success = saveToStorage(trimmed);
if (!success) {
setStorageUnavailable(true);
}
}
return trimmed;
});
},
[storageUnavailable],
);
const updateLastAssistantMessage = useCallback(
(
conversationId: string,
updates: Partial<Pick<ChatMessage, "content" | "reasoningContent">>,
) => {
setConversations((prev) => {
const updated = prev.map((conv) => {
if (conv.id !== conversationId) {
return conv;
}
const messages = [...conv.messages];
const lastAssistantIndex = messages.reduceRight((found, msg, idx) => {
if (found !== -1) return found;
return msg.role === "assistant" ? idx : -1;
}, -1);
if (lastAssistantIndex === -1) {
return conv;
}
messages[lastAssistantIndex] = {
...messages[lastAssistantIndex],
...updates,
};
return {
...conv,
messages,
updatedAt: Date.now(),
};
});
const trimmed = trimConversations(updated);
if (!storageUnavailable) {
const success = saveToStorage(trimmed);
if (!success) {
setStorageUnavailable(true);
}
}
return trimmed;
});
},
[storageUnavailable],
);
const deleteConversation = useCallback(
(id: string) => {
const updated = conversations.filter((c) => c.id !== id);
persistConversations(updated);
if (currentActiveId === id) {
setCurrentActiveId(null);
}
},
[conversations, currentActiveId, persistConversations],
);
const renameConversation = useCallback(
(id: string, newTitle: string) => {
const updated = conversations.map((conv) =>
conv.id === id ? { ...conv, title: newTitle, updatedAt: Date.now() } : conv,
);
persistConversations(updated);
},
[conversations, persistConversations],
);
const setActiveConversationId = useCallback((id: string | null) => {
setCurrentActiveId(id);
setStaleId(false);
}, []);
const activeConversation =
currentActiveId !== null
? (conversations.find((c) => c.id === currentActiveId) ?? null)
: null;
return {
conversations,
activeConversation,
storageUnavailable,
staleId,
createConversation,
appendMessage,
updateLastAssistantMessage,
deleteConversation,
renameConversation,
setActiveConversationId,
};
}