mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
feat(chat-ui): responses API + MCP tool execution display
- Switch /chat from chat completions to responses API (previous_response_id session chaining) - Add MCP server picker with search filter in chat input bar - Show MCP tool call events (list_tools + call_tool) inline in chat via MCPEventsDisplay - Add tool chip strip showing available tools when MCP servers are selected - Non-blocking MCP toggle: server added immediately, verification in background (works for no-auth MCPs like deepwiki) - Add truncateAfterMessage to useChatHistory for edit/retry - Sync activeConversationId on URL change (fixes stale conversation on new chat) - Add "Open Chat" shortcut button to sidebar
This commit is contained in:
parent
7561cdc4cc
commit
414ed03bd7
5 changed files with 885 additions and 191 deletions
|
|
@ -462,6 +462,42 @@ const Sidebar2: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelect
|
|||
/>
|
||||
</ConfigProvider>
|
||||
{isAdminRole(userRole) && !collapsed && <UsageIndicator accessToken={accessToken} width={220} />}
|
||||
|
||||
{/* Pinned "Open Chat" button at bottom */}
|
||||
<div style={{
|
||||
padding: collapsed ? "10px 8px" : "10px 12px",
|
||||
borderTop: "1px solid #f0f0f0",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<a
|
||||
href={toHref("chat")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: collapsed ? "center" : "flex-start",
|
||||
gap: 8,
|
||||
padding: collapsed ? "8px 0" : "8px 10px",
|
||||
borderRadius: 8,
|
||||
background: "#1677ff",
|
||||
color: "#fff",
|
||||
textDecoration: "none",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = "#0958d9";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = "#1677ff";
|
||||
}}
|
||||
>
|
||||
<MessageOutlined style={{ fontSize: 16, flexShrink: 0 }} />
|
||||
{!collapsed && <span>Open Chat</span>}
|
||||
</a>
|
||||
</div>
|
||||
</Sider>
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { ToolOutlined } from "@ant-design/icons";
|
||||
import { Collapse } from "antd";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { ToolOutlined, CopyOutlined, CheckOutlined, EditOutlined } from "@ant-design/icons";
|
||||
import { Collapse, Tooltip } from "antd";
|
||||
import React, { useEffect, useRef, useState } 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";
|
||||
|
|
@ -68,33 +68,157 @@ function MarkdownCodeRenderer({
|
|||
|
||||
interface UserBubbleProps {
|
||||
message: ChatMessage;
|
||||
onEdit?: (messageId: string, newContent: string) => void;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
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}
|
||||
function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(message.content);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.selectionStart = textareaRef.current.value.length;
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = `${ta.scrollHeight}px`;
|
||||
}, [editValue, editing]);
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed && trimmed !== message.content && onEdit) {
|
||||
onEdit(message.id, trimmed);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditValue(message.content);
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end" }}>
|
||||
<div style={{
|
||||
width: "72%",
|
||||
background: "#fff",
|
||||
border: "1.5px solid #1677ff",
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
boxShadow: "0 0 0 3px rgba(22,119,255,0.1)",
|
||||
}}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 14px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
resize: "none",
|
||||
fontSize: 14,
|
||||
lineHeight: "1.6",
|
||||
color: "#111827",
|
||||
fontFamily: "inherit",
|
||||
background: "transparent",
|
||||
boxSizing: "border-box",
|
||||
minHeight: 40,
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
padding: "6px 10px 8px",
|
||||
borderTop: "1px solid #f0f0f0",
|
||||
}}>
|
||||
<button
|
||||
onClick={() => { setEditValue(message.content); setEditing(false); }}
|
||||
style={{
|
||||
padding: "4px 12px", borderRadius: 6, border: "1px solid #d1d5db",
|
||||
background: "#fff", color: "#374151", fontSize: 13, cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!editValue.trim()}
|
||||
style={{
|
||||
padding: "4px 12px", borderRadius: 6, border: "none",
|
||||
background: editValue.trim() ? "#1677ff" : "#f3f4f6",
|
||||
color: editValue.trim() ? "#fff" : "#9ca3af",
|
||||
fontSize: 13, fontWeight: 500, cursor: editValue.trim() ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
Save & Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "#9ca3af",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", width: "100%" }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, maxWidth: "72%" }}>
|
||||
{/* Edit button — appears on hover, to the left of the bubble */}
|
||||
{hovered && !isStreaming && onEdit && (
|
||||
<Tooltip title="Edit message">
|
||||
<button
|
||||
onClick={() => { setEditValue(message.content); setEditing(true); }}
|
||||
style={{
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
padding: "4px 6px", borderRadius: 5,
|
||||
color: "#9ca3af", fontSize: 13, flexShrink: 0,
|
||||
display: "flex", alignItems: "center",
|
||||
transition: "color 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.color = "#6b7280"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.color = "#9ca3af"; }}
|
||||
>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#f0f2f5",
|
||||
borderRadius: 16,
|
||||
padding: "10px 14px",
|
||||
fontSize: 14,
|
||||
lineHeight: "1.6",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
color: "#111827",
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -188,9 +312,49 @@ function AssistantBubble({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</span>
|
||||
<CopyButton text={mainContent} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4, marginTop: 6 }}>
|
||||
<Tooltip title={copied ? "Copied!" : "Copy"}>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: "4px 6px",
|
||||
borderRadius: 5,
|
||||
color: copied ? "#52c41a" : "#9ca3af",
|
||||
fontSize: 13,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
transition: "color 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!copied) (e.currentTarget as HTMLButtonElement).style.color = "#6b7280";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!copied) (e.currentTarget as HTMLButtonElement).style.color = "#9ca3af";
|
||||
}}
|
||||
>
|
||||
{copied ? <CheckOutlined /> : <CopyOutlined />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -356,9 +520,10 @@ function ToolCard({ message }: ToolCardProps) {
|
|||
interface Props {
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
onEditMessage?: (messageId: string, newContent: string) => void;
|
||||
}
|
||||
|
||||
const ChatMessages: React.FC<Props> = ({ messages, isStreaming }) => {
|
||||
const ChatMessages: React.FC<Props> = ({ messages, isStreaming, onEditMessage }) => {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -379,7 +544,7 @@ const ChatMessages: React.FC<Props> = ({ messages, isStreaming }) => {
|
|||
const isLastMessage = idx === lastIndex;
|
||||
|
||||
if (msg.role === "user") {
|
||||
return <UserBubble key={msg.id} message={msg} />;
|
||||
return <UserBubble key={msg.id} message={msg} onEdit={onEditMessage} isStreaming={isStreaming} />;
|
||||
}
|
||||
|
||||
if (msg.role === "tool") {
|
||||
|
|
|
|||
|
|
@ -2,14 +2,27 @@
|
|||
|
||||
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 {
|
||||
SettingOutlined,
|
||||
PlusOutlined,
|
||||
BorderOutlined,
|
||||
EditOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
SearchOutlined,
|
||||
MessageOutlined,
|
||||
AppstoreOutlined,
|
||||
ArrowLeftOutlined,
|
||||
} 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;
|
||||
|
|
@ -34,6 +47,19 @@ function getGreeting(): string {
|
|||
|
||||
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<ChatPageProps> = ({ accessToken, userRole, userId, userEmail }) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
|
@ -46,7 +72,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [sidebarView, setSidebarView] = useState<"chats" | "apps">("chats");
|
||||
const [storageBannerDismissed, setStorageBannerDismissed] = useState(false);
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
|
@ -60,6 +87,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
createConversation,
|
||||
appendMessage,
|
||||
updateLastAssistantMessage,
|
||||
truncateAfterMessage,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
} = useChatHistory(activeConversationId);
|
||||
|
|
@ -70,7 +98,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
setIsLoadingModels(true);
|
||||
fetchAvailableModels(accessToken)
|
||||
.then((data) => {
|
||||
const names = (data || []).map((m: { model_name?: string }) => m.model_name ?? "").filter(Boolean);
|
||||
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)) {
|
||||
|
|
@ -80,9 +108,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
localStorage.setItem(LOCALSTORAGE_MODEL_KEY, names[0]);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
message.error("Could not load models");
|
||||
})
|
||||
.catch(() => message.error("Could not load models"))
|
||||
.finally(() => setIsLoadingModels(false));
|
||||
}, [accessToken]);
|
||||
|
||||
|
|
@ -121,15 +147,24 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
{ role: "user" as const, content: trimmed },
|
||||
];
|
||||
|
||||
let accumulatedContent = "";
|
||||
let accumulatedReasoning = "";
|
||||
|
||||
try {
|
||||
await makeOpenAIChatCompletionRequest(
|
||||
history,
|
||||
(chunk: string) => updateLastAssistantMessage(convId!, { content: chunk }),
|
||||
(chunk: string) => {
|
||||
accumulatedContent += chunk;
|
||||
updateLastAssistantMessage(convId!, { content: accumulatedContent });
|
||||
},
|
||||
selectedModel,
|
||||
accessToken,
|
||||
undefined,
|
||||
abortControllerRef.current.signal,
|
||||
(rc: string) => updateLastAssistantMessage(convId!, { reasoningContent: rc }),
|
||||
(rc: string) => {
|
||||
accumulatedReasoning += rc;
|
||||
updateLastAssistantMessage(convId!, { reasoningContent: accumulatedReasoning });
|
||||
},
|
||||
undefined, undefined, undefined, undefined, undefined, undefined,
|
||||
selectedMCPServers.length > 0 ? selectedMCPServers : undefined,
|
||||
);
|
||||
|
|
@ -156,6 +191,17 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
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<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
|
@ -172,8 +218,54 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
}, [inputText]);
|
||||
|
||||
const showBlankState = !activeConversation || activeConversation.messages.length === 0;
|
||||
const displayName = userEmail?.split("@")[0] ?? userId ?? "there";
|
||||
const greeting = `${getGreeting()}, ${displayName}`;
|
||||
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,
|
||||
) => (
|
||||
<Tooltip title={sidebarCollapsed ? label : undefined} placement="right" key={label}>
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
width: "100%",
|
||||
borderRadius: 7,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: active ? "#e8f4ff" : "transparent",
|
||||
color: active ? "#1677ff" : "#374151",
|
||||
textAlign: "left",
|
||||
fontSize: 14,
|
||||
justifyContent: sidebarCollapsed ? "center" : "flex-start",
|
||||
transition: "background 0.12s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active) (e.currentTarget as HTMLButtonElement).style.background = "#f5f5f5";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = active ? "#e8f4ff" : "transparent";
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16, flexShrink: 0 }}>{icon}</span>
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
<span style={{ flex: 1 }}>{label}</span>
|
||||
{kbd && <span style={{ fontSize: 11, color: "#9ca3af" }}>{kbd}</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
|
|
@ -185,56 +277,103 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
overflow: "hidden",
|
||||
}}>
|
||||
|
||||
{/* Conversation sidebar — slides in */}
|
||||
{sidebarOpen && (
|
||||
{/* ===== LEFT SIDEBAR ===== */}
|
||||
<div style={{
|
||||
width: sidebarCollapsed ? 56 : 260,
|
||||
flexShrink: 0,
|
||||
background: "#f9fafb",
|
||||
borderRight: "1px solid #e5e7eb",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
transition: "width 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
}}>
|
||||
|
||||
{/* Sidebar header: logo + collapse button */}
|
||||
<div style={{
|
||||
width: 260,
|
||||
flexShrink: 0,
|
||||
background: "#fafafa",
|
||||
borderRight: "1px solid #f0f0f0",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
alignItems: "center",
|
||||
padding: "12px 10px",
|
||||
justifyContent: sidebarCollapsed ? "center" : "space-between",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
activeConversationId={activeConversationId}
|
||||
onSelect={(id) => router.push(`/chat?id=${id}`)}
|
||||
onDelete={deleteConversation}
|
||||
onNewChat={() => router.push("/chat")}
|
||||
onRename={renameConversation}
|
||||
/>
|
||||
{!sidebarCollapsed && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<img
|
||||
src="/assets/logos/litellm_logo.jpg"
|
||||
alt="LiteLLM"
|
||||
width={26}
|
||||
height={26}
|
||||
style={{ borderRadius: 6, objectFit: "cover", flexShrink: 0 }}
|
||||
/>
|
||||
<span style={{ fontWeight: 700, fontSize: 15, color: "#111827", letterSpacing: "-0.01em" }}>
|
||||
LiteLLM
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Tooltip title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} placement="right">
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed((v) => !v)}
|
||||
style={{
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
padding: 6, borderRadius: 7, color: "#6b7280", fontSize: 16,
|
||||
display: "flex", alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main area */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
{/* Sidebar nav buttons */}
|
||||
<div style={{ padding: "0 8px 4px", flexShrink: 0 }}>
|
||||
{sidebarNavItem(<EditOutlined />, "New chat", () => router.push("/chat"))}
|
||||
{sidebarNavItem(<SearchOutlined />, "Search chats", () => {}, false, "⌘K")}
|
||||
</div>
|
||||
|
||||
{/* Top bar */}
|
||||
<div style={{ height: 1, background: "#e5e7eb", margin: "4px 8px", flexShrink: 0 }} />
|
||||
|
||||
{/* Chats / Apps tabs */}
|
||||
<div style={{ padding: "4px 8px", flexShrink: 0 }}>
|
||||
{sidebarNavItem(<MessageOutlined />, "Chats", () => setSidebarView("chats"), sidebarView === "chats")}
|
||||
{sidebarNavItem(<AppstoreOutlined />, "Apps", () => setSidebarView("apps"), sidebarView === "apps")}
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: "#e5e7eb", margin: "4px 8px", flexShrink: 0 }} />
|
||||
|
||||
{/* Sidebar content — only conversation list, only when in chats view and expanded */}
|
||||
{!sidebarCollapsed && sidebarView === "chats" && (
|
||||
<div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
activeConversationId={activeConversationId}
|
||||
onSelect={(id) => router.push(`/chat?id=${id}`)}
|
||||
onDelete={deleteConversation}
|
||||
onNewChat={() => router.push("/chat")}
|
||||
onRename={renameConversation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== MAIN AREA ===== */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 }}>
|
||||
|
||||
{/* Top bar — clean, minimal like ChatGPT */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 20px",
|
||||
padding: "8px 16px",
|
||||
flexShrink: 0,
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
background: "#fff",
|
||||
height: 48,
|
||||
}}>
|
||||
{/* 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>
|
||||
{/* Left: model selector */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{isLoadingModels ? (
|
||||
<Skeleton.Input active style={{ width: 160, height: 32 }} />
|
||||
<Skeleton.Input active style={{ width: 160, height: 28 }} />
|
||||
) : (
|
||||
<Select
|
||||
value={selectedModel || undefined}
|
||||
|
|
@ -243,7 +382,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
placeholder="Select model"
|
||||
style={{ width: 220 }}
|
||||
size="middle"
|
||||
variant="filled"
|
||||
variant="borderless"
|
||||
options={models.map((m) => ({
|
||||
value: m,
|
||||
label: m.length > 35 ? m.slice(0, 35) + "…" : m,
|
||||
|
|
@ -252,48 +391,80 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
)}
|
||||
</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>
|
||||
{/* Right: back to dashboard + settings */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<Tooltip title="Back to Dashboard">
|
||||
<a
|
||||
href={dashboardUrl}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "5px 12px",
|
||||
borderRadius: 7,
|
||||
border: "1px solid #e5e7eb",
|
||||
color: "#374151",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
textDecoration: "none",
|
||||
background: "#fff",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = "#f9fafb";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = "#fff";
|
||||
}}
|
||||
>
|
||||
<ArrowLeftOutlined style={{ fontSize: 12 }} />
|
||||
Dashboard
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Tooltip title="Settings">
|
||||
<button style={{
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
padding: 7, borderRadius: 7, color: "#6b7280", fontSize: 16,
|
||||
display: "flex", alignItems: "center",
|
||||
}}>
|
||||
<SettingOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</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>
|
||||
|
||||
{/* Storage warning banner */}
|
||||
{storageUnavailable && !storageBannerDismissed && (
|
||||
<div style={{
|
||||
background: "#fffbe6", borderBottom: "1px solid #ffe58f",
|
||||
padding: "8px 20px", fontSize: 13, color: "#874d00",
|
||||
padding: "6px 20px", fontSize: 13, color: "#874d00",
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
}}>
|
||||
<span>Chat history won't be saved in this browser session.</span>
|
||||
<button onClick={() => setStorageBannerDismissed(true)}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: "#92400e" }}>
|
||||
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: "#874d00" }}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content area */}
|
||||
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column", background: "#f9fafb" }}>
|
||||
{showBlankState ? (
|
||||
<div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column", background: "#fff" }}>
|
||||
{/* ---- Apps page view ---- */}
|
||||
{sidebarView === "apps" ? (
|
||||
<div style={{ flex: 1, maxWidth: 800, margin: "0 auto", width: "100%", padding: "32px 24px" }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 700, color: "#111827" }}>MCP Servers</h1>
|
||||
<p style={{ margin: "6px 0 0", fontSize: 14, color: "#6b7280" }}>
|
||||
Connect tools to your chat. Toggled servers are active in every new message.
|
||||
</p>
|
||||
</div>
|
||||
<MCPAppsPanel
|
||||
accessToken={accessToken}
|
||||
selectedServers={selectedMCPServers}
|
||||
onChange={setSelectedMCPServers}
|
||||
/>
|
||||
</div>
|
||||
) : showBlankState ? (
|
||||
/* ---- Blank state ---- */
|
||||
<div style={{
|
||||
flex: 1,
|
||||
|
|
@ -301,33 +472,20 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 24px 60px",
|
||||
padding: "0 24px 80px",
|
||||
}}>
|
||||
{/* Greeting */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
marginBottom: 40,
|
||||
<h1 style={{
|
||||
margin: "0 0 32px",
|
||||
fontSize: 28,
|
||||
fontWeight: 600,
|
||||
color: "#111827",
|
||||
fontFamily: "inherit",
|
||||
letterSpacing: "-0.01em",
|
||||
textAlign: "center",
|
||||
}}>
|
||||
<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>
|
||||
{greeting}
|
||||
</h1>
|
||||
|
||||
{/* Input card */}
|
||||
<div style={{
|
||||
|
|
@ -335,8 +493,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
maxWidth: 680,
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
border: "1px solid #e8e8e8",
|
||||
boxShadow: "0 1px 4px rgba(0,0,0,0.06)",
|
||||
border: "1px solid #e5e7eb",
|
||||
boxShadow: "0 1px 6px rgba(0,0,0,0.06)",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<textarea
|
||||
|
|
@ -353,71 +511,72 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
outline: "none",
|
||||
resize: "none",
|
||||
fontSize: 15,
|
||||
color: "#1f2937",
|
||||
color: "#111827",
|
||||
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",
|
||||
borderTop: "1px solid #f3f4f6",
|
||||
}}>
|
||||
<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"
|
||||
>
|
||||
<Popover
|
||||
open={mcpPopoverOpen}
|
||||
onOpenChange={setMcpPopoverOpen}
|
||||
content={
|
||||
<MCPConnectPicker
|
||||
accessToken={accessToken}
|
||||
selectedServers={selectedMCPServers}
|
||||
onChange={setSelectedMCPServers}
|
||||
/>
|
||||
}
|
||||
trigger="click"
|
||||
placement="topLeft"
|
||||
>
|
||||
<Tooltip title="Attach tools">
|
||||
<button style={{
|
||||
background: "none", border: "1px solid #d9d9d9",
|
||||
background: "none", border: "1px solid #d1d5db",
|
||||
borderRadius: 6, padding: "5px 10px",
|
||||
cursor: "pointer", fontSize: 16, color: "#595959",
|
||||
display: "flex", alignItems: "center",
|
||||
cursor: "pointer", fontSize: 14, color: "#6b7280",
|
||||
display: "flex", alignItems: "center", gap: 4,
|
||||
}}>
|
||||
<PlusOutlined />
|
||||
{selectedMCPServers.length > 0 && (
|
||||
<span style={{ fontSize: 12, color: "#1677ff", fontWeight: 500 }}>
|
||||
{selectedMCPServers.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Popover>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: "#8c8c8c", maxWidth: 140, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
<span style={{ fontSize: 12, color: "#9ca3af", maxWidth: 160, 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 onClick={handleStop} style={{
|
||||
background: "#111827", border: "none", borderRadius: 7,
|
||||
padding: "7px 10px", cursor: "pointer", color: "#fff",
|
||||
display: "flex", alignItems: "center",
|
||||
}}>
|
||||
<BorderOutlined style={{ fontSize: 14 }} />
|
||||
</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",
|
||||
background: inputText.trim() && selectedModel ? "#1677ff" : "#f3f4f6",
|
||||
border: "none", borderRadius: 7,
|
||||
padding: "7px 16px", cursor: inputText.trim() && selectedModel ? "pointer" : "not-allowed",
|
||||
color: inputText.trim() && selectedModel ? "#fff" : "#9ca3af",
|
||||
fontSize: 14, fontWeight: 500,
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
>
|
||||
Send
|
||||
|
|
@ -428,23 +587,29 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
</div>
|
||||
|
||||
{/* Suggestion chips */}
|
||||
<div style={{ display: "flex", gap: 10, marginTop: 16, flexWrap: "wrap", justifyContent: "center" }}>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 14, flexWrap: "wrap", justifyContent: "center" }}>
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
onClick={() => setInputText(s.label + ": ")}
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: "1px solid #e8e8e8",
|
||||
borderRadius: 8,
|
||||
background: "#f9fafb",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 20,
|
||||
padding: "7px 16px",
|
||||
fontSize: 14,
|
||||
color: "#595959",
|
||||
color: "#374151",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "#f3f4f6";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "#f9fafb";
|
||||
}}
|
||||
>
|
||||
<span>{s.icon}</span> {s.label}
|
||||
</button>
|
||||
|
|
@ -458,6 +623,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
<ChatMessages
|
||||
messages={activeConversation.messages}
|
||||
isStreaming={isStreaming}
|
||||
onEditMessage={handleEditAndResend}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -466,8 +632,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
<div style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
border: "1px solid #e8e8e8",
|
||||
boxShadow: "0 1px 4px rgba(0,0,0,0.06)",
|
||||
border: "1px solid #e5e7eb",
|
||||
boxShadow: "0 1px 6px rgba(0,0,0,0.06)",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<textarea
|
||||
|
|
@ -475,16 +641,16 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Reply..."
|
||||
placeholder="Send a message..."
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: 56,
|
||||
minHeight: 52,
|
||||
padding: "16px 20px 8px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
resize: "none",
|
||||
fontSize: 15,
|
||||
color: "#1f2937",
|
||||
color: "#111827",
|
||||
background: "transparent",
|
||||
fontFamily: "inherit",
|
||||
boxSizing: "border-box",
|
||||
|
|
@ -495,7 +661,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "4px 12px 10px",
|
||||
borderTop: "1px solid #f5f5f5",
|
||||
borderTop: "1px solid #f3f4f6",
|
||||
}}>
|
||||
<Popover
|
||||
open={mcpPopoverOpen}
|
||||
|
|
@ -511,35 +677,40 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
|||
placement="topLeft"
|
||||
>
|
||||
<button style={{
|
||||
background: "none", border: "1px solid #d9d9d9",
|
||||
background: "none", border: "1px solid #d1d5db",
|
||||
borderRadius: 6, padding: "5px 10px",
|
||||
cursor: "pointer", fontSize: 14, color: "#595959",
|
||||
display: "flex", alignItems: "center",
|
||||
cursor: "pointer", fontSize: 14, color: "#6b7280",
|
||||
display: "flex", alignItems: "center", gap: 4,
|
||||
}}>
|
||||
<PlusOutlined />
|
||||
{selectedMCPServers.length > 0 && (
|
||||
<span style={{ fontSize: 12, color: "#1677ff", fontWeight: 500 }}>
|
||||
{selectedMCPServers.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Popover>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: "#8c8c8c" }}>
|
||||
{selectedMCPServers.length > 0 ? `MCP (${selectedMCPServers.length})` : ""}
|
||||
<span style={{ fontSize: 12, color: "#9ca3af" }}>
|
||||
{selectedMCPServers.length > 0 ? `${selectedMCPServers.length} tool${selectedMCPServers.length > 1 ? "s" : ""} connected` : ""}
|
||||
</span>
|
||||
{isStreaming ? (
|
||||
<button onClick={handleStop} style={{
|
||||
background: "#ff4d4f", border: "none", borderRadius: 6,
|
||||
padding: "6px 8px", cursor: "pointer", color: "#fff",
|
||||
background: "#111827", border: "none", borderRadius: 7,
|
||||
padding: "7px 10px", cursor: "pointer", color: "#fff",
|
||||
display: "flex", alignItems: "center",
|
||||
}}>
|
||||
<BorderOutlined />
|
||||
<BorderOutlined style={{ fontSize: 14 }} />
|
||||
</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",
|
||||
background: inputText.trim() && selectedModel ? "#1677ff" : "#f3f4f6",
|
||||
border: "none", borderRadius: 7,
|
||||
padding: "7px 16px", cursor: inputText.trim() && selectedModel ? "pointer" : "not-allowed",
|
||||
color: inputText.trim() && selectedModel ? "#fff" : "#9ca3af",
|
||||
fontSize: 14, fontWeight: 500,
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
|
|
|
|||
296
ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx
Normal file
296
ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Switch, Spin, Input } from "antd";
|
||||
import { SearchOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import { fetchMCPServers, listMCPTools } from "../networking";
|
||||
import { MCPServer } from "../mcp_tools/types";
|
||||
import { message } from "antd";
|
||||
|
||||
interface Props {
|
||||
accessToken: string;
|
||||
selectedServers: string[];
|
||||
onChange: (servers: string[]) => void;
|
||||
}
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
"#1677ff", "#52c41a", "#fa8c16", "#eb2f96", "#722ed1",
|
||||
"#13c2c2", "#fa541c", "#2f54eb", "#a0d911", "#faad14",
|
||||
];
|
||||
|
||||
function getAvatarColor(name: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
type TabKey = "all" | "connected";
|
||||
|
||||
const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange }) => {
|
||||
const [servers, setServers] = useState<MCPServer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||
const [togglingOn, setTogglingOn] = useState<Set<string>>(new Set());
|
||||
const [expandedServer, setExpandedServer] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchMCPServers(accessToken)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
const list: MCPServer[] = Array.isArray(data) ? data : (data?.data ?? []);
|
||||
setServers(list);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setServers([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [accessToken]);
|
||||
|
||||
const handleToggle = async (serverName: string, checked: boolean) => {
|
||||
if (!checked) {
|
||||
onChange(selectedServers.filter((s) => s !== serverName));
|
||||
return;
|
||||
}
|
||||
setTogglingOn((prev) => new Set(prev).add(serverName));
|
||||
try {
|
||||
const result = await listMCPTools(accessToken, serverName);
|
||||
if (result?.error) {
|
||||
message.warning(`Could not load tools for ${serverName}`);
|
||||
return;
|
||||
}
|
||||
onChange([...selectedServers, serverName]);
|
||||
} catch {
|
||||
message.warning(`Could not load tools for ${serverName}`);
|
||||
} finally {
|
||||
setTogglingOn((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(serverName);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const nameOf = (s: MCPServer) => s.server_name ?? s.alias ?? s.server_id;
|
||||
|
||||
const filtered = servers.filter((s) => {
|
||||
const name = nameOf(s);
|
||||
const matchesQuery = !query.trim() ||
|
||||
name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
(s.description ?? "").toLowerCase().includes(query.toLowerCase());
|
||||
const matchesTab = activeTab === "all" || selectedServers.includes(name);
|
||||
return matchesQuery && matchesTab;
|
||||
});
|
||||
|
||||
const connectedCount = servers.filter((s) => selectedServers.includes(nameOf(s))).length;
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", maxWidth: 800, margin: "0 auto" }}>
|
||||
|
||||
{/* ── Page header ── */}
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 28, gap: 16, flexWrap: "wrap" }}>
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
|
||||
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 700, color: "#111827", letterSpacing: "-0.02em" }}>
|
||||
MCP Servers
|
||||
</h1>
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 600, color: "#1677ff",
|
||||
background: "#e8f4ff", borderRadius: 4, padding: "2px 7px",
|
||||
letterSpacing: "0.04em", textTransform: "uppercase",
|
||||
}}>
|
||||
BETA
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: 14, color: "#6b7280" }}>
|
||||
Connect tools to your chat. Toggle servers to use them in every message.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ width: 240, flexShrink: 0 }}>
|
||||
<Input
|
||||
prefix={<SearchOutlined style={{ color: "#9ca3af", fontSize: 14 }} />}
|
||||
placeholder="Search servers..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
allowClear
|
||||
style={{ borderRadius: 20, fontSize: 14, height: 38 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Hero banner ── */}
|
||||
{!query && (
|
||||
<div style={{
|
||||
borderRadius: 16,
|
||||
background: "linear-gradient(135deg, #1677ff 0%, #36cfc9 60%, #faad14 100%)",
|
||||
padding: "28px 32px",
|
||||
marginBottom: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 24,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}>
|
||||
<div style={{ zIndex: 1 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#fff", marginBottom: 6, letterSpacing: "-0.01em" }}>
|
||||
Supercharge your chat with MCP tools
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: "rgba(255,255,255,0.85)", maxWidth: 400 }}>
|
||||
MCP servers give Claude access to external data, APIs, and actions — right inside your conversation.
|
||||
</div>
|
||||
{connectedCount > 0 && (
|
||||
<div style={{
|
||||
marginTop: 14,
|
||||
display: "inline-flex", alignItems: "center", gap: 6,
|
||||
background: "rgba(255,255,255,0.2)", borderRadius: 20,
|
||||
padding: "5px 14px", fontSize: 13, color: "#fff", fontWeight: 500,
|
||||
}}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#52c41a", flexShrink: 0, display: "inline-block" }} />
|
||||
{connectedCount} server{connectedCount > 1 ? "s" : ""} connected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Decorative circles */}
|
||||
<div style={{
|
||||
position: "absolute", right: -20, top: -20,
|
||||
width: 160, height: 160, borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.08)",
|
||||
}} />
|
||||
<div style={{
|
||||
position: "absolute", right: 60, bottom: -40,
|
||||
width: 120, height: 120, borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tabs ── */}
|
||||
<div style={{ display: "flex", gap: 4, marginBottom: 16 }}>
|
||||
{(["all", "connected"] as TabKey[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
padding: "6px 16px",
|
||||
borderRadius: 20,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 14,
|
||||
fontWeight: activeTab === tab ? 600 : 400,
|
||||
background: activeTab === tab ? "#111827" : "transparent",
|
||||
color: activeTab === tab ? "#fff" : "#6b7280",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (activeTab !== tab) (e.currentTarget as HTMLButtonElement).style.background = "#f3f4f6";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (activeTab !== tab) (e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
{tab === "all" ? "All" : `Connected${connectedCount > 0 ? ` (${connectedCount})` : ""}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Server grid ── */}
|
||||
{loading ? (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: "60px 0" }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div style={{ textAlign: "center", color: "#9ca3af", fontSize: 14, padding: "60px 12px" }}>
|
||||
{servers.length === 0
|
||||
? "No MCP servers configured. Add servers in Tools → MCP Servers."
|
||||
: activeTab === "connected"
|
||||
? "No servers connected yet. Toggle a server below to connect it."
|
||||
: "No servers match your search."}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1, border: "1px solid #e5e7eb", borderRadius: 12, overflow: "hidden" }}>
|
||||
{filtered.map((server) => {
|
||||
const name = nameOf(server);
|
||||
const isConnected = selectedServers.includes(name);
|
||||
const isTogglingOn = togglingOn.has(name);
|
||||
const isExpanded = expandedServer === name;
|
||||
const color = getAvatarColor(name);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={server.server_id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
padding: "16px 20px",
|
||||
background: isExpanded ? "#f9fafb" : "#fff",
|
||||
cursor: "pointer",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
transition: "background 0.12s",
|
||||
}}
|
||||
onClick={() => setExpandedServer(isExpanded ? null : name)}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isExpanded) (e.currentTarget as HTMLDivElement).style.background = "#fafafa";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.background = isExpanded ? "#f9fafb" : "#fff";
|
||||
}}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10,
|
||||
background: color, display: "flex",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
color: "#fff", fontWeight: 700, fontSize: 16,
|
||||
flexShrink: 0, boxShadow: "0 1px 4px rgba(0,0,0,0.12)",
|
||||
}}>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 14, fontWeight: 600, color: "#111827",
|
||||
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||
}}>
|
||||
{name}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 12, color: "#6b7280", marginTop: 2,
|
||||
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||
}}>
|
||||
{server.description ?? "MCP server"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side: toggle (when expanded) or chevron */}
|
||||
{isExpanded ? (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={isConnected}
|
||||
loading={isTogglingOn}
|
||||
onChange={(checked) => handleToggle(name, checked)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<RightOutlined style={{ fontSize: 12, color: "#9ca3af", flexShrink: 0 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MCPAppsPanel;
|
||||
|
|
@ -52,6 +52,7 @@ export function useChatHistory(activeConversationId: string | null): {
|
|||
createConversation: (model: string) => string;
|
||||
appendMessage: (conversationId: string, message: Omit<ChatMessage, "id" | "timestamp">) => void;
|
||||
updateLastAssistantMessage: (conversationId: string, updates: Partial<Pick<ChatMessage, "content" | "reasoningContent">>) => void;
|
||||
truncateAfterMessage: (conversationId: string, messageId: string) => void;
|
||||
deleteConversation: (id: string) => void;
|
||||
renameConversation: (id: string, newTitle: string) => void;
|
||||
setActiveConversationId: (id: string | null) => void;
|
||||
|
|
@ -61,6 +62,12 @@ export function useChatHistory(activeConversationId: string | null): {
|
|||
const [staleId, setStaleId] = useState(false);
|
||||
const [currentActiveId, setCurrentActiveId] = useState<string | null>(activeConversationId);
|
||||
|
||||
// Sync internal active id whenever the URL-derived prop changes (e.g. "New chat" → null)
|
||||
useEffect(() => {
|
||||
setCurrentActiveId(activeConversationId);
|
||||
setStaleId(false);
|
||||
}, [activeConversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
const { conversations: loaded, storageUnavailable: unavailable } = loadFromStorage();
|
||||
setConversations(loaded);
|
||||
|
|
@ -200,6 +207,24 @@ export function useChatHistory(activeConversationId: string | null): {
|
|||
[storageUnavailable],
|
||||
);
|
||||
|
||||
const truncateAfterMessage = useCallback(
|
||||
(conversationId: string, messageId: string) => {
|
||||
setConversations((prev) => {
|
||||
const updated = prev.map((conv) => {
|
||||
if (conv.id !== conversationId) return conv;
|
||||
const idx = conv.messages.findIndex((m) => m.id === messageId);
|
||||
if (idx === -1) return conv;
|
||||
const messages = conv.messages.slice(0, idx);
|
||||
return { ...conv, messages, updatedAt: Date.now() };
|
||||
});
|
||||
const trimmed = trimConversations(updated);
|
||||
if (!storageUnavailable) saveToStorage(trimmed);
|
||||
return trimmed;
|
||||
});
|
||||
},
|
||||
[storageUnavailable],
|
||||
);
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
(id: string) => {
|
||||
const updated = conversations.filter((c) => c.id !== id);
|
||||
|
|
@ -239,6 +264,7 @@ export function useChatHistory(activeConversationId: string | null): {
|
|||
createConversation,
|
||||
appendMessage,
|
||||
updateLastAssistantMessage,
|
||||
truncateAfterMessage,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
setActiveConversationId,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue