mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
fix(ui): design polish and per-tab routing for chat UI
Moves Chats/Integrations/Credentials/API Keys/Usage from client-side tab state to real nested routes (/chat, /chat/integrations, /chat/credentials, /chat/api-keys, /chat/usage) so each is bookmarkable and survives a hard reload. Extracts the chat sidebar into ChatShell and shared state (MCP server selection, conversation history) into ChatShellContext, both consumed via the new app/chat/layout.tsx. Along the way: fixes conversation URLs pointing at the wrong path (/ui/chat instead of /chat in dev, which 404'd after sending the first message) by reusing the existing migratedHref helper instead of a one-off uiConfig-based path; fixes the topnav view-switcher always showing "AI Gateway" as selected even while on the chat route; and cleans up several shadcn/tailwind styling bugs introduced by the antd migration (boxed tab outline instead of underline, model-selector dropdown overflowing its popover, sidebar nav labels centered instead of left-aligned, duplicate logo, dead non-interactive controls).
This commit is contained in:
parent
0b3e327d02
commit
afa739b423
18 changed files with 1538 additions and 1327 deletions
14
ui/litellm-dashboard/src/app/chat/api-keys/page.tsx
Normal file
14
ui/litellm-dashboard/src/app/chat/api-keys/page.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useChatShell } from "@/contexts/ChatShellContext";
|
||||
import KeysPanel from "@/components/chat/KeysPanel";
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
const { accessToken, userId, premiumUser } = useChatShell();
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto w-full py-8 px-8">
|
||||
<KeysPanel accessToken={accessToken} userId={userId} premiumUser={premiumUser} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
ui/litellm-dashboard/src/app/chat/credentials/page.tsx
Normal file
14
ui/litellm-dashboard/src/app/chat/credentials/page.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useChatShell } from "@/contexts/ChatShellContext";
|
||||
import MCPCredentialsTab from "@/components/chat/MCPCredentialsTab";
|
||||
|
||||
export default function CredentialsPage() {
|
||||
const { accessToken } = useChatShell();
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto w-full py-8 px-8">
|
||||
<MCPCredentialsTab accessToken={accessToken} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
ui/litellm-dashboard/src/app/chat/integrations/page.tsx
Normal file
38
ui/litellm-dashboard/src/app/chat/integrations/page.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useChatShell } from "@/contexts/ChatShellContext";
|
||||
import MCPAppsPanel from "@/components/chat/MCPAppsPanel";
|
||||
|
||||
// useSearchParams() requires a Suspense boundary for static export.
|
||||
function IntegrationsPageContent() {
|
||||
const { accessToken, selectedMCPServers, setSelectedMCPServers } = useChatShell();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const oauthReturn = searchParams.get("mcpOauthReturn");
|
||||
|
||||
// Clean up the OAuth return param after it's been consumed — real routing means
|
||||
// we no longer need it to pick a tab, but it should not linger in the address bar.
|
||||
useEffect(() => {
|
||||
if (oauthReturn) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("mcpOauthReturn");
|
||||
router.replace(url.pathname + url.search);
|
||||
}
|
||||
}, [oauthReturn, router]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto w-full py-8 px-8">
|
||||
<MCPAppsPanel accessToken={accessToken} selectedServers={selectedMCPServers} onChange={setSelectedMCPServers} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<IntegrationsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
86
ui/litellm-dashboard/src/app/chat/layout.test.tsx
Normal file
86
ui/litellm-dashboard/src/app/chat/layout.test.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import ChatLayout from "./layout";
|
||||
|
||||
const { mockUseAuthorized, mockUseUISettings, mockReplace, mockMigratedHref, state } = vi.hoisted(() => {
|
||||
const state = {
|
||||
enableChatUI: false,
|
||||
isUISettingsLoading: false,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
mockReplace: vi.fn(),
|
||||
mockMigratedHref: vi.fn((segment: string) => `/mocked-ui/${segment}`),
|
||||
mockUseAuthorized: vi.fn(() => ({
|
||||
accessToken: "token-123",
|
||||
userRole: "Internal User",
|
||||
userId: "user-1",
|
||||
userEmail: "user@example.com",
|
||||
premiumUser: false,
|
||||
})),
|
||||
mockUseUISettings: vi.fn(() => ({
|
||||
data: { values: { enable_chat_ui: state.enableChatUI } },
|
||||
isLoading: state.isUISettingsLoading,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
}));
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: mockUseAuthorized }));
|
||||
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings }));
|
||||
vi.mock("@/utils/migratedPages", () => ({ migratedHref: mockMigratedHref }));
|
||||
vi.mock("@/components/navbar", () => ({ default: () => <div data-testid="navbar" /> }));
|
||||
vi.mock("@/contexts/ThemeContext", () => ({
|
||||
ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock("@/contexts/ChatShellContext", () => ({
|
||||
ChatShellProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock("@/components/chat/ChatShell", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div data-testid="chat-shell">{children}</div>,
|
||||
}));
|
||||
|
||||
describe("ChatLayout", () => {
|
||||
afterEach(() => {
|
||||
state.enableChatUI = false;
|
||||
state.isUISettingsLoading = false;
|
||||
mockReplace.mockClear();
|
||||
mockMigratedHref.mockClear();
|
||||
});
|
||||
|
||||
it("renders the chat shell when enable_chat_ui is on", () => {
|
||||
state.enableChatUI = true;
|
||||
render(
|
||||
<ChatLayout>
|
||||
<div data-testid="page-content" />
|
||||
</ChatLayout>,
|
||||
);
|
||||
expect(screen.getByTestId("chat-shell")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-content")).toBeInTheDocument();
|
||||
expect(mockReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects to the dashboard when enable_chat_ui is off", () => {
|
||||
state.enableChatUI = false;
|
||||
render(
|
||||
<ChatLayout>
|
||||
<div data-testid="page-content" />
|
||||
</ChatLayout>,
|
||||
);
|
||||
expect(screen.queryByTestId("chat-shell")).not.toBeInTheDocument();
|
||||
expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/");
|
||||
});
|
||||
|
||||
it("renders nothing while UI settings are still loading", () => {
|
||||
state.isUISettingsLoading = true;
|
||||
render(
|
||||
<ChatLayout>
|
||||
<div data-testid="page-content" />
|
||||
</ChatLayout>,
|
||||
);
|
||||
expect(screen.queryByTestId("chat-shell")).not.toBeInTheDocument();
|
||||
expect(mockReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
54
ui/litellm-dashboard/src/app/chat/layout.tsx
Normal file
54
ui/litellm-dashboard/src/app/chat/layout.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"use client";
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import Navbar from "@/components/navbar";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import { ChatShellProvider } from "@/contexts/ChatShellContext";
|
||||
import ChatShell from "@/components/chat/ChatShell";
|
||||
import { migratedHref } from "@/utils/migratedPages";
|
||||
|
||||
// ChatShellProvider uses useSearchParams(), which requires a Suspense boundary for static export.
|
||||
function ChatLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
const { accessToken, userRole, userId, userEmail, premiumUser } = useAuthorized();
|
||||
const { data: uiSettings, isLoading: isUISettingsLoading } = useUISettings();
|
||||
const router = useRouter();
|
||||
|
||||
const chatEnabled = Boolean(uiSettings?.values?.enable_chat_ui);
|
||||
const blocked = !isUISettingsLoading && !chatEnabled;
|
||||
|
||||
useEffect(() => {
|
||||
if (blocked) router.replace(migratedHref(""));
|
||||
}, [blocked, router]);
|
||||
|
||||
if (isUISettingsLoading || blocked) return null;
|
||||
|
||||
return (
|
||||
<ThemeProvider accessToken={accessToken}>
|
||||
<div className="flex h-screen flex-col">
|
||||
<Navbar accessToken={accessToken} isPublicPage={false} />
|
||||
<div className="min-h-0 flex-1">
|
||||
<ChatShellProvider
|
||||
accessToken={accessToken ?? ""}
|
||||
userId={userId ?? ""}
|
||||
userEmail={userEmail ?? ""}
|
||||
userRole={userRole ?? ""}
|
||||
premiumUser={premiumUser ?? false}
|
||||
>
|
||||
<ChatShell>{children}</ChatShell>
|
||||
</ChatShellProvider>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense>
|
||||
<ChatLayoutContent>{children}</ChatLayoutContent>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import ChatPageRoute from "./page";
|
||||
|
||||
const { mockUseAuthorized, mockUseUISettings, mockUseUIConfig, mockReplace, state } = vi.hoisted(() => {
|
||||
const state = {
|
||||
userRole: "Internal User" as string,
|
||||
enableChatUI: false,
|
||||
isUISettingsLoading: false,
|
||||
serverRootPath: undefined as string | undefined,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
mockReplace: vi.fn(),
|
||||
mockUseAuthorized: vi.fn(() => ({
|
||||
accessToken: "token-123",
|
||||
userRole: state.userRole,
|
||||
userId: "user-1",
|
||||
userEmail: "user@example.com",
|
||||
premiumUser: false,
|
||||
})),
|
||||
mockUseUISettings: vi.fn(() => ({
|
||||
data: { values: { enable_chat_ui: state.enableChatUI } },
|
||||
isLoading: state.isUISettingsLoading,
|
||||
})),
|
||||
mockUseUIConfig: vi.fn(() => ({ data: { server_root_path: state.serverRootPath } })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
}));
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: mockUseAuthorized }));
|
||||
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings }));
|
||||
vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => ({ useUIConfig: mockUseUIConfig }));
|
||||
vi.mock("@/components/chat/ChatPage", () => ({ default: () => <div data-testid="chat-page" /> }));
|
||||
|
||||
describe("ChatPageRoute", () => {
|
||||
afterEach(() => {
|
||||
state.userRole = "Internal User";
|
||||
state.enableChatUI = false;
|
||||
state.isUISettingsLoading = false;
|
||||
state.serverRootPath = undefined;
|
||||
mockReplace.mockClear();
|
||||
});
|
||||
|
||||
it("renders the chat page when enable_chat_ui is on", () => {
|
||||
state.enableChatUI = true;
|
||||
render(<ChatPageRoute />);
|
||||
expect(screen.getByTestId("chat-page")).toBeInTheDocument();
|
||||
expect(mockReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects non-admins to the dashboard when enable_chat_ui is off", () => {
|
||||
state.enableChatUI = false;
|
||||
state.userRole = "Internal User";
|
||||
render(<ChatPageRoute />);
|
||||
expect(screen.queryByTestId("chat-page")).not.toBeInTheDocument();
|
||||
expect(mockReplace).toHaveBeenCalledWith("/ui/");
|
||||
});
|
||||
|
||||
it("redirects admins to the dashboard when enable_chat_ui is off", () => {
|
||||
state.enableChatUI = false;
|
||||
state.userRole = "Admin";
|
||||
render(<ChatPageRoute />);
|
||||
expect(screen.queryByTestId("chat-page")).not.toBeInTheDocument();
|
||||
expect(mockReplace).toHaveBeenCalledWith("/ui/");
|
||||
});
|
||||
|
||||
it("renders nothing while UI settings are still loading", () => {
|
||||
state.isUISettingsLoading = true;
|
||||
render(<ChatPageRoute />);
|
||||
expect(screen.queryByTestId("chat-page")).not.toBeInTheDocument();
|
||||
expect(mockReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects a custom server_root_path when redirecting", () => {
|
||||
state.enableChatUI = false;
|
||||
state.userRole = "Internal User";
|
||||
state.serverRootPath = "/api/v1";
|
||||
render(<ChatPageRoute />);
|
||||
expect(mockReplace).toHaveBeenCalledWith("/api/v1/ui/");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,47 +1,884 @@
|
|||
"use client";
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState, useLayoutEffect } from "react";
|
||||
import { Plus, ChevronDown, Check } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import ChatPage from "@/components/chat/ChatPage";
|
||||
import { useChatShell } from "@/contexts/ChatShellContext";
|
||||
import { CHAT_ROUTES } from "@/components/chat/ChatShell";
|
||||
import ChatMessages from "@/components/chat/ChatMessages";
|
||||
import MCPConnectPicker from "@/components/chat/MCPConnectPicker";
|
||||
import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
|
||||
import { makeOpenAIChatCompletionRequest } from "@/components/llm_calls/chat_completion";
|
||||
import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api";
|
||||
import type { MCPEvent } from "@/components/chat/types";
|
||||
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
|
||||
|
||||
// ChatPage uses useSearchParams() which requires a Suspense boundary for static export.
|
||||
const ChatPageContent = () => {
|
||||
const { accessToken, userRole, userId, userEmail, premiumUser } = useAuthorized();
|
||||
const { data: uiSettings, isLoading: isUISettingsLoading } = useUISettings();
|
||||
const { data: uiConfig } = useUIConfig();
|
||||
const SUGGESTIONS = ["Write", "Learn", "Code", "Brainstorm"];
|
||||
const MAX_COMPARISON_MODELS = 3;
|
||||
const LOCALSTORAGE_MODEL_KEY = "litellm_chat_selected_models";
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
// Extract provider from model name for logo lookup.
|
||||
// Handles prefixed models ("groq/llama-3"), and detects well-known providers by keyword.
|
||||
function getProviderFromModelName(modelName: string): string {
|
||||
if (!modelName) return "";
|
||||
const lower = modelName.toLowerCase();
|
||||
const slash = lower.indexOf("/");
|
||||
if (slash > 0) return lower.slice(0, slash);
|
||||
// Keyword matching — order matters (more specific first)
|
||||
if (lower.includes("claude")) return "anthropic";
|
||||
if (lower.includes("gemini")) return "gemini";
|
||||
if (lower.includes("gpt") || lower.includes("chatgpt") || /^o[0-9]/.test(lower)) return "openai";
|
||||
if (lower.includes("mistral") || lower.includes("codestral")) return "mistral";
|
||||
if (lower.includes("llama")) return "meta_llama";
|
||||
if (lower.includes("deepseek")) return "deepseek";
|
||||
if (lower.includes("grok")) return "xai";
|
||||
if (lower.includes("command")) return "cohere";
|
||||
if (lower.includes("nova") || lower.includes("titan")) return "bedrock";
|
||||
return "";
|
||||
}
|
||||
|
||||
interface ComparisonExchange {
|
||||
userMessage: string;
|
||||
responses: Record<string, string>; // model → accumulated response text
|
||||
}
|
||||
|
||||
interface StreamToModelArgs {
|
||||
model: string;
|
||||
messages: Array<{ role: "user" | "assistant"; content: string }>;
|
||||
accessToken: string;
|
||||
mcpServers: string[];
|
||||
signal: AbortSignal;
|
||||
onChunk: (model: string, chunk: string) => void;
|
||||
onDone: (model: string) => void;
|
||||
}
|
||||
|
||||
// Module-level async helper — each model gets its own independent Promise so they all run in parallel.
|
||||
async function streamToModel({
|
||||
model,
|
||||
messages,
|
||||
accessToken,
|
||||
mcpServers,
|
||||
signal,
|
||||
onChunk,
|
||||
onDone,
|
||||
}: StreamToModelArgs): Promise<void> {
|
||||
try {
|
||||
await makeOpenAIChatCompletionRequest(
|
||||
messages,
|
||||
(chunk: string) => onChunk(model, chunk),
|
||||
model,
|
||||
accessToken,
|
||||
undefined, // tags
|
||||
signal,
|
||||
undefined, // onReasoningContent
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined, // positions 8-13
|
||||
mcpServers.length > 0 ? mcpServers : undefined, // position 14: selectedMCPServers
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
// Surface real errors in the response card; ignore user-triggered aborts
|
||||
if (!(err instanceof Error && err.name === "AbortError")) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
onChunk(model, `\n\n_Error: ${msg}_`);
|
||||
}
|
||||
} finally {
|
||||
onDone(model);
|
||||
}
|
||||
}
|
||||
|
||||
export default function ChatConversationPage() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
accessToken,
|
||||
userId,
|
||||
userEmail,
|
||||
selectedMCPServers,
|
||||
setSelectedMCPServers,
|
||||
activeConversationId,
|
||||
activeConversation,
|
||||
storageUnavailable,
|
||||
staleId,
|
||||
createConversation,
|
||||
appendMessage,
|
||||
updateLastAssistantMessage,
|
||||
truncateFromMessage,
|
||||
} = useChatShell();
|
||||
const hadActiveConversationOnMountRef = useRef(activeConversationId !== null);
|
||||
|
||||
const uiRoot =
|
||||
uiConfig?.server_root_path && uiConfig.server_root_path !== "/"
|
||||
? uiConfig.server_root_path.replace(/\/+$/, "")
|
||||
: "";
|
||||
const chatEnabled = Boolean(uiSettings?.values?.enable_chat_ui);
|
||||
const blocked = !isUISettingsLoading && !chatEnabled;
|
||||
const [selectedModels, setSelectedModels] = useState<string[]>([]);
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(true);
|
||||
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
|
||||
const [modelSearchText, setModelSearchText] = useState("");
|
||||
|
||||
const [responsesSessionId, setResponsesSessionId] = useState<string | null>(null);
|
||||
const [prevConversationIdForSessionReset, setPrevConversationIdForSessionReset] = useState(activeConversationId);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false);
|
||||
const [storageBannerDismissed, setStorageBannerDismissed] = useState(false);
|
||||
|
||||
// Comparison mode state (active when selectedModels.length > 1)
|
||||
// Each exchange holds the user message + per-model responses so we can do multi-turn comparison.
|
||||
const [comparisonExchanges, setComparisonExchanges] = useState<ComparisonExchange[]>([]);
|
||||
const [comparisonStreamingSet, setComparisonStreamingSet] = useState<Set<string>>(new Set());
|
||||
const comparisonAbortControllersRef = useRef<Record<string, AbortController>>({});
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const messagesScrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const streamScrollLock = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocked) router.replace(`${uiRoot}/ui/`);
|
||||
}, [blocked, uiRoot, router]);
|
||||
if (staleId) router.replace(CHAT_ROUTES.chats);
|
||||
}, [staleId, router]);
|
||||
|
||||
if (isUISettingsLoading || blocked) return null;
|
||||
// Load models
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
fetchAvailableModels(accessToken)
|
||||
.then((data) => {
|
||||
const names = (data || []).map((m: { model_group?: string }) => m.model_group ?? "").filter(Boolean);
|
||||
setModels(names);
|
||||
try {
|
||||
const saved = localStorage.getItem(LOCALSTORAGE_MODEL_KEY);
|
||||
if (saved) {
|
||||
const parsed: unknown = JSON.parse(saved);
|
||||
if (Array.isArray(parsed)) {
|
||||
const valid = (parsed as string[]).filter((m) => names.includes(m));
|
||||
if (valid.length > 0) {
|
||||
setSelectedModels(hadActiveConversationOnMountRef.current ? [valid[0]] : valid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
if (names.length > 0) {
|
||||
setSelectedModels([names[0]]);
|
||||
localStorage.setItem(LOCALSTORAGE_MODEL_KEY, JSON.stringify([names[0]]));
|
||||
}
|
||||
})
|
||||
.catch(() => MessageManager.error("Could not load models"))
|
||||
.finally(() => setIsLoadingModels(false));
|
||||
}, [accessToken]);
|
||||
|
||||
// Reset the responses session when switching between conversations so that
|
||||
// previous_response_id from conversation A is never sent for conversation B.
|
||||
if (activeConversationId !== prevConversationIdForSessionReset) {
|
||||
setPrevConversationIdForSessionReset(activeConversationId);
|
||||
setResponsesSessionId(null);
|
||||
}
|
||||
|
||||
const toggleModel = useCallback((model: string) => {
|
||||
setSelectedModels((prev) => {
|
||||
let next: string[];
|
||||
if (prev.includes(model)) {
|
||||
next = prev.filter((m) => m !== model);
|
||||
} else if (prev.length >= MAX_COMPARISON_MODELS) {
|
||||
return prev;
|
||||
} else {
|
||||
next = [...prev, model];
|
||||
}
|
||||
localStorage.setItem(LOCALSTORAGE_MODEL_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isComparisonMode = selectedModels.length > 1;
|
||||
const isAnyStreaming = isStreaming || comparisonStreamingSet.size > 0;
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text: string, historyOverride?: Array<{ role: "user" | "assistant"; content: string }>) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || selectedModels.length === 0 || isStreaming) return;
|
||||
const model = selectedModels[0];
|
||||
setInputText("");
|
||||
|
||||
let convId = activeConversationId;
|
||||
if (!convId) {
|
||||
convId = createConversation(model);
|
||||
setResponsesSessionId(null); // new conversation starts a fresh session
|
||||
router.push(`${CHAT_ROUTES.chats}?id=${convId}`);
|
||||
}
|
||||
|
||||
appendMessage(convId, { role: "user", content: trimmed });
|
||||
appendMessage(convId, { role: "assistant", content: "" });
|
||||
|
||||
setIsStreaming(true);
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
// When historyOverride is set (edit / retry), the existing server-side
|
||||
// session chain covers messages that were just truncated and is no longer
|
||||
// valid for the rewritten history. Eagerly clear the session so that a
|
||||
// failed/aborted edit does not leave a stale session ID that contaminates
|
||||
// the next regular send.
|
||||
if (historyOverride) {
|
||||
setResponsesSessionId(null);
|
||||
}
|
||||
|
||||
// On a normal continuation turn with an active session, the Responses API
|
||||
// already holds the prior context server-side, so we only pass the new
|
||||
// user message (sending the full history would double-count it).
|
||||
//
|
||||
// On the very first turn (no session yet), we send the full history.
|
||||
const previousResponseId = historyOverride ? null : responsesSessionId;
|
||||
|
||||
const history: Array<{ role: "user" | "assistant"; content: string }> = historyOverride
|
||||
? [...historyOverride, { role: "user" as const, content: trimmed }]
|
||||
: previousResponseId
|
||||
? [{ role: "user" as const, content: trimmed }]
|
||||
: [
|
||||
// Explicitly filter to only user/assistant roles — tool messages
|
||||
// lack a required tool_call_id and would cause API errors.
|
||||
...(activeConversation?.messages ?? [])
|
||||
.filter(
|
||||
(m): m is typeof m & { role: "user" | "assistant" } => m.role === "user" || m.role === "assistant",
|
||||
)
|
||||
.map((m) => ({ role: m.role, content: m.content })),
|
||||
{ role: "user" as const, content: trimmed },
|
||||
];
|
||||
|
||||
let accumulatedContent = "";
|
||||
let accumulatedReasoning = "";
|
||||
// MCP events accumulated locally so we can persist them to the message
|
||||
// without relying on component state (which would cause stale closures).
|
||||
const accumulatedMCPEvents: MCPEvent[] = [];
|
||||
// Track clean completion so partial events are not shown on error/abort.
|
||||
let streamCompletedCleanly = false;
|
||||
|
||||
try {
|
||||
await makeOpenAIResponsesRequest(
|
||||
history,
|
||||
(_role: string, chunk: string) => {
|
||||
accumulatedContent += chunk;
|
||||
updateLastAssistantMessage(convId!, { content: accumulatedContent });
|
||||
},
|
||||
model,
|
||||
accessToken,
|
||||
undefined, // tags
|
||||
abortControllerRef.current.signal,
|
||||
(rc: string) => {
|
||||
accumulatedReasoning += rc;
|
||||
updateLastAssistantMessage(convId!, { reasoningContent: accumulatedReasoning });
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
selectedMCPServers.length > 0 ? selectedMCPServers : undefined,
|
||||
previousResponseId,
|
||||
(id: string) => setResponsesSessionId(id),
|
||||
(event: MCPEvent) => {
|
||||
// Accumulate locally only — persisted once in finally to avoid
|
||||
// one full localStorage write per MCP event during streaming.
|
||||
accumulatedMCPEvents.push(event);
|
||||
},
|
||||
);
|
||||
streamCompletedCleanly = true;
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
updateLastAssistantMessage(convId!, {
|
||||
content: accumulatedContent + " [stopped]",
|
||||
});
|
||||
} else {
|
||||
updateLastAssistantMessage(convId!, {
|
||||
content: "[Something went wrong. The partial response has been saved.]",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
// Only persist MCP events on clean completion — partial events from an
|
||||
// aborted or errored turn would show incomplete tool calls to the user.
|
||||
if (accumulatedMCPEvents.length > 0 && streamCompletedCleanly) {
|
||||
updateLastAssistantMessage(convId!, { mcpEvents: accumulatedMCPEvents });
|
||||
}
|
||||
setIsStreaming(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[
|
||||
activeConversationId,
|
||||
activeConversation,
|
||||
selectedModels,
|
||||
selectedMCPServers,
|
||||
accessToken,
|
||||
createConversation,
|
||||
appendMessage,
|
||||
updateLastAssistantMessage,
|
||||
router,
|
||||
isStreaming,
|
||||
responsesSessionId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSendComparison = useCallback(
|
||||
(text: string, currentExchanges: ComparisonExchange[]) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || selectedModels.length === 0 || isAnyStreaming) return;
|
||||
setInputText("");
|
||||
|
||||
// Append a new exchange with empty responses
|
||||
const newExchange: ComparisonExchange = { userMessage: trimmed, responses: {} };
|
||||
const newExchangeIdx = currentExchanges.length;
|
||||
setComparisonExchanges((prev) => [...prev, newExchange]);
|
||||
setComparisonStreamingSet(new Set(selectedModels));
|
||||
|
||||
const controllers: Record<string, AbortController> = {};
|
||||
selectedModels.forEach((m) => {
|
||||
controllers[m] = new AbortController();
|
||||
});
|
||||
comparisonAbortControllersRef.current = controllers;
|
||||
|
||||
// Launch all model streams simultaneously — Promise.allSettled ensures they run in parallel
|
||||
// (streamToModel handles its own errors internally so these promises won't reject)
|
||||
void Promise.allSettled(
|
||||
selectedModels.map((model) => {
|
||||
// Build per-model history: each model's past responses become its own context
|
||||
const history: Array<{ role: "user" | "assistant"; content: string }> = [];
|
||||
for (const ex of currentExchanges) {
|
||||
history.push({ role: "user", content: ex.userMessage });
|
||||
history.push({ role: "assistant", content: ex.responses[model] ?? "" });
|
||||
}
|
||||
history.push({ role: "user", content: trimmed });
|
||||
|
||||
return streamToModel({
|
||||
model,
|
||||
messages: history,
|
||||
accessToken,
|
||||
mcpServers: selectedMCPServers,
|
||||
signal: controllers[model].signal,
|
||||
onChunk: (m, chunk) =>
|
||||
setComparisonExchanges((prev) => {
|
||||
const updated = [...prev];
|
||||
const ex = { ...updated[newExchangeIdx] };
|
||||
ex.responses = { ...ex.responses, [m]: (ex.responses[m] ?? "") + chunk };
|
||||
updated[newExchangeIdx] = ex;
|
||||
return updated;
|
||||
}),
|
||||
onDone: (m) =>
|
||||
setComparisonStreamingSet((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(m);
|
||||
return next;
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
[selectedModels, accessToken, selectedMCPServers, isAnyStreaming],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
Object.values(comparisonAbortControllersRef.current).forEach((c) => c.abort());
|
||||
comparisonAbortControllersRef.current = {};
|
||||
}, []);
|
||||
|
||||
const handleEditAndResend = useCallback(
|
||||
(messageId: string, newContent: string) => {
|
||||
if (!activeConversationId || isStreaming) return;
|
||||
// Compute the truncated history synchronously before the async state update lands,
|
||||
// so handleSend receives the correct pre-edit context rather than the stale closure value.
|
||||
const msgs = activeConversation?.messages ?? [];
|
||||
const idx = msgs.findIndex((m) => m.id === messageId);
|
||||
const priorMessages = (idx === -1 ? msgs : msgs.slice(0, idx))
|
||||
.filter((m) => m.role === "user" || m.role === "assistant")
|
||||
.map((m) => ({ role: m.role as "user" | "assistant", content: m.content }));
|
||||
truncateFromMessage(activeConversationId, messageId);
|
||||
handleSend(newContent, priorMessages);
|
||||
},
|
||||
[activeConversationId, isStreaming, activeConversation, truncateFromMessage, handleSend],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(text: string) => {
|
||||
if (isComparisonMode) {
|
||||
handleSendComparison(text, comparisonExchanges);
|
||||
} else {
|
||||
handleSend(text);
|
||||
}
|
||||
},
|
||||
[isComparisonMode, handleSend, handleSendComparison, comparisonExchanges],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(inputText);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = `${Math.min(ta.scrollHeight, 180)}px`;
|
||||
}, [inputText]);
|
||||
|
||||
// Track scroll position to show/hide scroll-to-bottom button
|
||||
useEffect(() => {
|
||||
const el = messagesScrollRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
setShowScrollButton(distFromBottom > 120);
|
||||
if (streamScrollLock.current !== null) {
|
||||
streamScrollLock.current = el.scrollTop; // track user-initiated scroll
|
||||
}
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, [activeConversation]);
|
||||
|
||||
// Start/stop the scroll lock when streaming begins/ends
|
||||
useEffect(() => {
|
||||
const el = messagesScrollRef.current;
|
||||
if (isStreaming) {
|
||||
streamScrollLock.current = el?.scrollTop ?? 0;
|
||||
} else {
|
||||
streamScrollLock.current = null;
|
||||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
// After every render during streaming, restore the locked scroll position
|
||||
useLayoutEffect(() => {
|
||||
if (streamScrollLock.current === null) return;
|
||||
const el = messagesScrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = streamScrollLock.current;
|
||||
});
|
||||
|
||||
// Scroll to bottom only when message COUNT increases (new message added)
|
||||
const prevMsgCountRef = useRef(0);
|
||||
useLayoutEffect(() => {
|
||||
const count = activeConversation?.messages?.length ?? 0;
|
||||
const prev = prevMsgCountRef.current;
|
||||
prevMsgCountRef.current = count;
|
||||
if (count > prev) {
|
||||
const el = messagesScrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [activeConversation?.messages]);
|
||||
|
||||
const showBlankState = !isComparisonMode
|
||||
? !activeConversation || activeConversation.messages.length === 0
|
||||
: comparisonExchanges.length === 0;
|
||||
const displayName = userEmail?.split("@")[0] ?? userId ?? "";
|
||||
const greeting = displayName ? `${getGreeting()}, ${displayName}` : getGreeting();
|
||||
|
||||
// Filtered models: selected ones float to the top, then alphabetical
|
||||
const filteredModels = (
|
||||
modelSearchText ? models.filter((m) => m.toLowerCase().includes(modelSearchText.toLowerCase())) : models
|
||||
).sort((a, b) => {
|
||||
const aSelected = selectedModels.includes(a);
|
||||
const bSelected = selectedModels.includes(b);
|
||||
if (aSelected && !bSelected) return -1;
|
||||
if (!aSelected && bSelected) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const modelSelectorContent = (
|
||||
<div className="w-[280px] max-h-[400px] flex flex-col overflow-hidden">
|
||||
<div className="p-2 pb-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={modelSearchText}
|
||||
onChange={(e) => setModelSearchText(e.target.value)}
|
||||
placeholder="Search models..."
|
||||
className="w-full px-2.5 py-1.5 border rounded-md text-[13px] outline-none bg-background text-foreground"
|
||||
/>
|
||||
</div>
|
||||
{selectedModels.length >= MAX_COMPARISON_MODELS && (
|
||||
<div className="px-3 py-1 text-xs text-muted-foreground">
|
||||
Max {MAX_COMPARISON_MODELS} models selected; deselect one to change
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
{filteredModels.map((m) => {
|
||||
const checked = selectedModels.includes(m);
|
||||
const disabled = !checked && selectedModels.length >= MAX_COMPARISON_MODELS;
|
||||
const provider = getProviderFromModelName(m);
|
||||
const { logo } = provider ? getProviderLogoAndName(provider) : { logo: "" };
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
disabled={disabled}
|
||||
onClick={() => toggleModel(m)}
|
||||
className={`flex items-center gap-2 w-full px-3 py-[7px] border-none text-left rounded transition-colors ${
|
||||
checked ? "bg-accent" : "bg-transparent hover:bg-accent/50"
|
||||
} ${disabled ? "opacity-45 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
<span
|
||||
className={`w-4 h-4 rounded-[3px] flex items-center justify-center shrink-0 transition-all ${
|
||||
checked ? "bg-primary border-primary" : "bg-background border-border"
|
||||
}`}
|
||||
style={{ border: `1.5px solid ${checked ? "var(--color-primary)" : "var(--color-border)"}` }}
|
||||
>
|
||||
{checked && <Check className="h-2.5 w-2.5 text-primary-foreground" />}
|
||||
</span>
|
||||
{logo ? (
|
||||
<img
|
||||
src={logo}
|
||||
alt=""
|
||||
className="w-4 h-4 object-contain shrink-0"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="w-4 shrink-0" />
|
||||
)}
|
||||
<span className="text-[13px] text-foreground overflow-hidden text-ellipsis whitespace-nowrap">{m}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
|
||||
const modelSelectorTrigger = isLoadingModels ? (
|
||||
<Skeleton className="w-40 h-7" />
|
||||
) : (
|
||||
<Popover
|
||||
open={modelSelectorOpen}
|
||||
onOpenChange={(open) => {
|
||||
setModelSelectorOpen(open);
|
||||
if (!open) setModelSearchText("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="flex items-center gap-1.5 px-2.5 py-[5px] rounded-md border border-transparent cursor-pointer bg-transparent text-foreground text-sm font-medium max-w-[480px] overflow-hidden hover:bg-accent/50 transition-colors">
|
||||
{selectedModels.length === 0 ? (
|
||||
<span className="text-muted-foreground">Select model</span>
|
||||
) : selectedModels.length === 1 ? (
|
||||
<>
|
||||
{(() => {
|
||||
const provider = getProviderFromModelName(selectedModels[0]);
|
||||
const { logo } = provider ? getProviderLogoAndName(provider) : { logo: "" };
|
||||
return logo ? (
|
||||
<img
|
||||
src={logo}
|
||||
alt=""
|
||||
className="w-[18px] h-[18px] object-contain shrink-0"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[240px]">{selectedModels[0]}</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 flex-nowrap overflow-hidden">
|
||||
{selectedModels.map((m) => {
|
||||
const provider = getProviderFromModelName(m);
|
||||
const { logo } = provider ? getProviderLogoAndName(provider) : { logo: "" };
|
||||
return (
|
||||
<span
|
||||
key={m}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 bg-primary/10 rounded-[10px] text-xs text-primary font-medium shrink-0"
|
||||
>
|
||||
{logo && (
|
||||
<img
|
||||
src={logo}
|
||||
alt=""
|
||||
className="w-[13px] h-[13px] object-contain"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="max-w-[120px] overflow-hidden text-ellipsis whitespace-nowrap">{m}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className="h-2.5 w-2.5 text-muted-foreground shrink-0 ml-0.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="p-0 w-auto">
|
||||
{modelSelectorContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
const inputBar = (inConversation: boolean) => (
|
||||
<div className="bg-background rounded-xl border shadow-[0_1px_6px_rgba(0,0,0,0.06)] overflow-hidden">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={inConversation ? "Send a message..." : "How can I help you today?"}
|
||||
className="w-full border-none outline-none resize-none text-[15px] text-foreground bg-transparent font-[inherit] box-border"
|
||||
style={{
|
||||
minHeight: inConversation ? 52 : 80,
|
||||
padding: inConversation ? "16px 20px 8px" : "20px 20px 8px",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="flex items-center justify-between border-t"
|
||||
style={{ padding: inConversation ? "4px 12px 10px" : "8px 12px 12px" }}
|
||||
>
|
||||
<Popover open={mcpPopoverOpen} onOpenChange={setMcpPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="border rounded-md px-2.5 py-[5px] cursor-pointer text-sm text-muted-foreground flex items-center gap-1 bg-transparent hover:bg-accent/50 transition-colors">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{selectedMCPServers.length > 0 && (
|
||||
<span className="text-xs text-primary font-medium">{selectedMCPServers.length}</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="start" className="p-0 w-auto">
|
||||
<MCPConnectPicker
|
||||
accessToken={accessToken}
|
||||
selectedServers={selectedMCPServers}
|
||||
onChange={setSelectedMCPServers}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isComparisonMode && (
|
||||
<span className="text-xs text-muted-foreground max-w-[160px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{inConversation
|
||||
? selectedMCPServers.length > 0
|
||||
? `${selectedMCPServers.length} tool${selectedMCPServers.length > 1 ? "s" : ""} connected`
|
||||
: ""
|
||||
: selectedModels[0] || "No model"}
|
||||
</span>
|
||||
)}
|
||||
{isAnyStreaming ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="w-8 h-8 rounded-full border-[1.5px] flex items-center justify-center shrink-0 cursor-pointer transition-colors hover:border-muted-foreground bg-transparent text-foreground"
|
||||
>
|
||||
<div className="w-2.5 h-2.5 bg-foreground rounded-[2px]" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSubmit(inputText)}
|
||||
disabled={!inputText.trim() || isLoadingModels || selectedModels.length === 0}
|
||||
className={`border-none rounded-md px-4 py-[7px] text-sm font-medium transition-colors ${
|
||||
inputText.trim() && selectedModels.length > 0
|
||||
? "bg-primary text-primary-foreground cursor-pointer hover:bg-primary/90"
|
||||
: "bg-muted text-muted-foreground cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatPage
|
||||
accessToken={accessToken ?? ""}
|
||||
userRole={userRole ?? ""}
|
||||
userId={userId ?? ""}
|
||||
userEmail={userEmail ?? ""}
|
||||
premiumUser={premiumUser ?? false}
|
||||
/>
|
||||
<>
|
||||
<div className="flex items-center px-4 py-2 shrink-0 border-b bg-background h-12">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">{modelSelectorTrigger}</div>
|
||||
</div>
|
||||
|
||||
{storageUnavailable && !storageBannerDismissed && (
|
||||
<div className="bg-amber-50 border-b border-amber-200 px-5 py-1.5 text-[13px] text-amber-800 flex justify-between items-center">
|
||||
<span>Chat history won't be saved in this browser session</span>
|
||||
<button
|
||||
onClick={() => setStorageBannerDismissed(true)}
|
||||
className="text-amber-800 text-base cursor-pointer"
|
||||
style={{ background: "none", border: "none" }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col bg-background">
|
||||
{showBlankState ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-6 pb-20">
|
||||
<h1 className="m-0 mb-8 text-[28px] font-semibold text-foreground tracking-tight text-center">
|
||||
{isComparisonMode ? `Compare ${selectedModels.length} models` : greeting}
|
||||
</h1>
|
||||
|
||||
{isComparisonMode ? (
|
||||
<p className="-mt-4 mb-6 text-sm text-muted-foreground text-center">
|
||||
Send a message to see responses side-by-side
|
||||
</p>
|
||||
) : (
|
||||
<p className="-mt-4 mb-7 text-sm text-muted-foreground text-center max-w-[520px] leading-relaxed">
|
||||
Chat with 100+ LLMs + MCP tools; authenticate once, use them here.{" "}
|
||||
<button
|
||||
onClick={() => router.push(CHAT_ROUTES.integrations)}
|
||||
className="text-primary text-sm font-medium cursor-pointer hover:underline"
|
||||
style={{ background: "none", border: "none", padding: 0 }}
|
||||
>
|
||||
Open Integrations ->
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="w-full max-w-[680px]">{inputBar(false)}</div>
|
||||
|
||||
{!isComparisonMode && (
|
||||
<div className="flex gap-2 mt-3.5 flex-wrap justify-center">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setInputText(s + ": ")}
|
||||
className="bg-secondary border rounded-full px-4 py-[7px] text-sm text-foreground/70 cursor-pointer hover:bg-accent transition-colors"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex-1 min-h-0 flex flex-col mx-auto w-full px-6 relative"
|
||||
style={{ maxWidth: isComparisonMode ? (selectedModels.length >= 3 ? 1200 : 960) : 760 }}
|
||||
>
|
||||
<div
|
||||
ref={messagesScrollRef}
|
||||
className="flex-1 min-h-0 overflow-auto pt-6"
|
||||
style={{ overflowAnchor: "none" }}
|
||||
>
|
||||
{isComparisonMode ? (
|
||||
<div className="pb-2">
|
||||
{comparisonExchanges.map((exchange, exchangeIdx) => {
|
||||
const isLastExchange = exchangeIdx === comparisonExchanges.length - 1;
|
||||
return (
|
||||
<div key={exchangeIdx} className="mb-8">
|
||||
<div className="flex justify-end mb-5">
|
||||
<div className="bg-muted rounded-2xl px-4 py-2.5 max-w-[75%] text-sm text-foreground leading-relaxed">
|
||||
{exchange.userMessage}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3.5 items-start">
|
||||
{selectedModels.map((model, idx) => {
|
||||
const provider = getProviderFromModelName(model);
|
||||
const { logo } = provider ? getProviderLogoAndName(provider) : { logo: "" };
|
||||
const responseText = exchange.responses[model] ?? "";
|
||||
const isModelStreaming = isLastExchange && comparisonStreamingSet.has(model);
|
||||
return (
|
||||
<div key={model} className="flex-1 border rounded-xl overflow-hidden min-w-0">
|
||||
{exchangeIdx === 0 && (
|
||||
<div className="px-3.5 py-2.5 border-b flex items-center gap-2 bg-muted/50">
|
||||
{logo ? (
|
||||
<img
|
||||
src={logo}
|
||||
alt=""
|
||||
className="w-[18px] h-[18px] object-contain shrink-0"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[18px] h-[18px] rounded-full bg-border shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold text-xs text-foreground">Response {idx + 1}</span>
|
||||
<span className="text-[11px] text-muted-foreground overflow-hidden text-ellipsis whitespace-nowrap flex-1 min-w-0">
|
||||
{model}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-4 py-3.5 min-h-[60px] relative">
|
||||
{isModelStreaming && (
|
||||
<span className="absolute top-2.5 right-3 text-[9px] text-primary">●</span>
|
||||
)}
|
||||
{responseText ? (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => (
|
||||
<p className="mb-2.5 leading-relaxed text-sm text-foreground">{children}</p>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = /language-(\w+)/.exec(className || "");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre className="bg-muted px-3 py-2.5 rounded-md overflow-auto text-[13px] my-2">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-[13px]">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{responseText}
|
||||
</ReactMarkdown>
|
||||
) : isModelStreaming ? (
|
||||
<span className="text-muted-foreground text-sm">Generating…</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<ChatMessages
|
||||
messages={activeConversation!.messages}
|
||||
isStreaming={isStreaming}
|
||||
onEditMessage={handleEditAndResend}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showScrollButton && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const el = messagesScrollRef.current;
|
||||
if (el) {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
if (streamScrollLock.current !== null) {
|
||||
streamScrollLock.current = el.scrollHeight;
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="absolute bottom-[100px] left-1/2 -translate-x-1/2 w-[34px] h-[34px] rounded-full bg-background/75 backdrop-blur-md border border-black/10 shadow-sm cursor-pointer flex items-center justify-center text-muted-foreground z-10 transition-colors hover:bg-background/95"
|
||||
aria-label="Scroll to bottom"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="py-3 pb-6">{inputBar(true)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatPageRoute = () => (
|
||||
<Suspense>
|
||||
<ChatPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
export default ChatPageRoute;
|
||||
}
|
||||
|
|
|
|||
14
ui/litellm-dashboard/src/app/chat/usage/page.tsx
Normal file
14
ui/litellm-dashboard/src/app/chat/usage/page.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useChatShell } from "@/contexts/ChatShellContext";
|
||||
import UsagePanel from "@/components/chat/UsagePanel";
|
||||
|
||||
export default function UsagePage() {
|
||||
const { accessToken, userId } = useChatShell();
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto w-full py-8 px-8">
|
||||
<UsagePanel accessToken={accessToken} userId={userId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Dropdown } from "antd";
|
||||
import { AppstoreOutlined, CheckOutlined, DownOutlined } from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
|
|
@ -12,12 +13,17 @@ const CHAT = "chat";
|
|||
export default function ViewSwitcher() {
|
||||
const { mode, setMode, plugins } = usePluginMode();
|
||||
const { data: uiSettings } = useUISettings();
|
||||
const pathname = usePathname();
|
||||
|
||||
const chatEnabled = Boolean(uiSettings?.values?.enable_chat_ui);
|
||||
|
||||
if (plugins.length === 0 && !chatEnabled) return null;
|
||||
|
||||
const activeLabel = plugins.find((p) => p.name === mode)?.display_name ?? "AI Gateway";
|
||||
const chatHref = migratedHref(CHAT);
|
||||
const normalizedPathname = (pathname ?? "").replace(/\/+$/, "");
|
||||
const isChatRoute = chatEnabled && (normalizedPathname === chatHref || normalizedPathname.startsWith(`${chatHref}/`));
|
||||
|
||||
const activeLabel = isChatRoute ? "Chat" : plugins.find((p) => p.name === mode)?.display_name ?? "AI Gateway";
|
||||
|
||||
const modeEntries = [
|
||||
{ key: GATEWAY, label: "AI Gateway" },
|
||||
|
|
@ -30,7 +36,7 @@ export default function ViewSwitcher() {
|
|||
label: (
|
||||
<div className="flex items-center justify-between gap-6 py-0.5">
|
||||
<span className="font-medium">{e.label}</span>
|
||||
{e.key === mode && <CheckOutlined className="text-blue-600" />}
|
||||
{!isChatRoute && e.key === mode && <CheckOutlined className="text-blue-600" />}
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
|
|
@ -41,6 +47,7 @@ export default function ViewSwitcher() {
|
|||
label: (
|
||||
<div className="flex items-center justify-between gap-6 py-0.5">
|
||||
<span className="font-medium">Chat</span>
|
||||
{isChatRoute && <CheckOutlined className="text-blue-600" />}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
@ -57,7 +64,7 @@ export default function ViewSwitcher() {
|
|||
};
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items, onClick, selectedKeys: [mode] }} trigger={["click"]}>
|
||||
<Dropdown menu={{ items, onClick, selectedKeys: [isChatRoute ? CHAT : mode] }} trigger={["click"]}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-md border border-gray-200 px-2.5 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
76
ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx
Normal file
76
ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import ChatShell from "./ChatShell";
|
||||
|
||||
const { mockPush, mockUsePathname, mockUseChatShell } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockUsePathname: vi.fn(() => "/ui/chat"),
|
||||
mockUseChatShell: vi.fn(() => ({
|
||||
conversations: [],
|
||||
activeConversationId: null,
|
||||
deleteConversation: vi.fn(),
|
||||
renameConversation: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
usePathname: mockUsePathname,
|
||||
}));
|
||||
// Deterministic hrefs so navigation/active-state assertions don't depend on server_root_path.
|
||||
vi.mock("@/utils/migratedPages", () => ({ migratedHref: (seg: string) => `/ui/${seg}`.replace(/\/$/, "") || "/ui" }));
|
||||
vi.mock("@/contexts/ChatShellContext", () => ({ useChatShell: mockUseChatShell }));
|
||||
vi.mock("./ConversationList", () => ({ default: () => <div data-testid="conversation-list" /> }));
|
||||
|
||||
describe("ChatShell", () => {
|
||||
afterEach(() => {
|
||||
mockPush.mockClear();
|
||||
mockUsePathname.mockReturnValue("/ui/chat");
|
||||
});
|
||||
|
||||
it("marks Chats active and shows the conversation list on the base chat route", () => {
|
||||
render(
|
||||
<ChatShell>
|
||||
<div />
|
||||
</ChatShell>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Chats" })).toHaveAttribute("aria-current", "page");
|
||||
expect(screen.getByRole("button", { name: "API Keys" })).not.toHaveAttribute("aria-current");
|
||||
expect(screen.getByTestId("conversation-list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks API Keys active while still showing the conversation list", () => {
|
||||
mockUsePathname.mockReturnValue("/ui/chat/api-keys");
|
||||
render(
|
||||
<ChatShell>
|
||||
<div />
|
||||
</ChatShell>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "API Keys" })).toHaveAttribute("aria-current", "page");
|
||||
expect(screen.getByRole("button", { name: "Chats" })).not.toHaveAttribute("aria-current");
|
||||
expect(screen.getByTestId("conversation-list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates to the dedicated route for each nav item", () => {
|
||||
render(
|
||||
<ChatShell>
|
||||
<div />
|
||||
</ChatShell>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Integrations" }));
|
||||
expect(mockPush).toHaveBeenCalledWith("/ui/chat/integrations");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Usage" }));
|
||||
expect(mockPush).toHaveBeenCalledWith("/ui/chat/usage");
|
||||
});
|
||||
|
||||
it("tolerates a trailing slash on the current pathname when matching the active route", () => {
|
||||
mockUsePathname.mockReturnValue("/ui/chat/usage/");
|
||||
render(
|
||||
<ChatShell>
|
||||
<div />
|
||||
</ChatShell>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Usage" })).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
});
|
||||
186
ui/litellm-dashboard/src/components/chat/ChatShell.tsx
Normal file
186
ui/litellm-dashboard/src/components/chat/ChatShell.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
Pencil,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Search,
|
||||
MessageSquare,
|
||||
LayoutGrid,
|
||||
KeyRound,
|
||||
Lock,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { migratedHref } from "@/utils/migratedPages";
|
||||
import { useChatShell } from "@/contexts/ChatShellContext";
|
||||
import ConversationList from "./ConversationList";
|
||||
|
||||
const CHAT_BASE = migratedHref("chat");
|
||||
export const CHAT_ROUTES = {
|
||||
chats: CHAT_BASE,
|
||||
integrations: `${CHAT_BASE}/integrations`,
|
||||
credentials: `${CHAT_BASE}/credentials`,
|
||||
apiKeys: `${CHAT_BASE}/api-keys`,
|
||||
usage: `${CHAT_BASE}/usage`,
|
||||
};
|
||||
|
||||
function stripTrailingSlash(path: string): string {
|
||||
return path.length > 1 ? path.replace(/\/+$/, "") : path;
|
||||
}
|
||||
|
||||
interface NavItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
function NavItem({ icon, label, onClick, active = false, collapsed }: NavItemProps) {
|
||||
const btn = (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`flex items-center gap-2.5 px-2.5 py-2 w-full rounded-md border-none text-sm text-left transition-colors ${
|
||||
collapsed ? "justify-center" : "justify-start"
|
||||
} ${active ? "bg-accent text-accent-foreground" : "text-foreground/70 hover:bg-accent/50"}`}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<span className="shrink-0">{icon}</span>
|
||||
{!collapsed && <span className="flex-1 text-left font-medium">{label}</span>}
|
||||
</button>
|
||||
);
|
||||
if (!collapsed) return btn;
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{btn}</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatShellProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ChatShell: React.FC<ChatShellProps> = ({ children }) => {
|
||||
const router = useRouter();
|
||||
const pathname = stripTrailingSlash(usePathname() ?? "");
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const { conversations, activeConversationId, deleteConversation, renameConversation } = useChatShell();
|
||||
|
||||
const isChatsRoute = pathname === CHAT_ROUTES.chats;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full bg-background overflow-hidden">
|
||||
<div
|
||||
className="shrink-0 bg-secondary border-r flex flex-col overflow-hidden"
|
||||
style={{ width: sidebarCollapsed ? 56 : 260, transition: "width 0.2s cubic-bezier(0.4, 0, 0.2, 1)" }}
|
||||
>
|
||||
<div className="flex items-center justify-start px-2.5 py-3 shrink-0">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed((v) => !v)}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground flex items-center cursor-pointer transition-colors"
|
||||
style={{ background: "none", border: "none" }}
|
||||
>
|
||||
{sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div className="px-2 pb-1 shrink-0">
|
||||
<NavItem
|
||||
icon={<Pencil className="h-4 w-4" />}
|
||||
label="New chat"
|
||||
onClick={() => router.push(CHAT_ROUTES.chats)}
|
||||
collapsed={sidebarCollapsed}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Search className="h-4 w-4" />}
|
||||
label="Search chats"
|
||||
onClick={() => router.push(CHAT_ROUTES.chats)}
|
||||
collapsed={sidebarCollapsed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className="mx-2 shrink-0" />
|
||||
|
||||
<div className="px-2 py-1 shrink-0">
|
||||
<NavItem
|
||||
icon={<MessageSquare className="h-4 w-4" />}
|
||||
label="Chats"
|
||||
onClick={() => router.push(CHAT_ROUTES.chats)}
|
||||
active={isChatsRoute}
|
||||
collapsed={sidebarCollapsed}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<LayoutGrid className="h-4 w-4" />}
|
||||
label="Integrations"
|
||||
onClick={() => router.push(CHAT_ROUTES.integrations)}
|
||||
active={pathname === CHAT_ROUTES.integrations}
|
||||
collapsed={sidebarCollapsed}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<KeyRound className="h-4 w-4" />}
|
||||
label="Credentials"
|
||||
onClick={() => router.push(CHAT_ROUTES.credentials)}
|
||||
active={pathname === CHAT_ROUTES.credentials}
|
||||
collapsed={sidebarCollapsed}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Lock className="h-4 w-4" />}
|
||||
label="API Keys"
|
||||
onClick={() => router.push(CHAT_ROUTES.apiKeys)}
|
||||
active={pathname === CHAT_ROUTES.apiKeys}
|
||||
collapsed={sidebarCollapsed}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<BarChart3 className="h-4 w-4" />}
|
||||
label="Usage"
|
||||
onClick={() => router.push(CHAT_ROUTES.usage)}
|
||||
active={pathname === CHAT_ROUTES.usage}
|
||||
collapsed={sidebarCollapsed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className="mx-2 shrink-0" />
|
||||
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
activeConversationId={activeConversationId}
|
||||
onSelect={(id) => router.push(`${CHAT_ROUTES.chats}?id=${id}`)}
|
||||
onDelete={(id) => {
|
||||
deleteConversation(id);
|
||||
if (id === activeConversationId) router.push(CHAT_ROUTES.chats);
|
||||
}}
|
||||
onNewChat={() => router.push(CHAT_ROUTES.chats)}
|
||||
onRename={renameConversation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatShell;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Pencil, Trash2, Plus, Search, User, MessageSquare } from "lucide-react";
|
||||
import { Pencil, Trash2, Plus, Search, MessageSquare } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
|
|
@ -338,15 +338,6 @@ const ConversationList: React.FC<Props> = ({
|
|||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<div className="px-3 py-2.5 border-t flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-accent flex items-center justify-center shrink-0">
|
||||
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-[13px] text-muted-foreground overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
My Account
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchModal
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
|
|
@ -170,8 +171,43 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
|
|||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="text-xs font-semibold uppercase tracking-wide">Key</TableHead>
|
||||
<TableHead className="text-xs font-semibold uppercase tracking-wide">Spend</TableHead>
|
||||
<TableHead className="text-xs font-semibold uppercase tracking-wide">Expires</TableHead>
|
||||
<TableHead className="text-xs font-semibold uppercase tracking-wide">Created</TableHead>
|
||||
{premiumUser && (
|
||||
<TableHead className="text-xs font-semibold uppercase tracking-wide text-right w-[80px]" />
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
{premiumUser && (
|
||||
<TableCell className="text-right">
|
||||
<Skeleton className="h-4 w-16 ml-auto" />
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground text-sm py-12 border border-dashed rounded-lg">
|
||||
|
|
@ -221,15 +257,10 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
|
|||
</TableCell>
|
||||
{premiumUser && (
|
||||
<TableCell className="text-right">
|
||||
<button
|
||||
onClick={() => openRotateModal(record)}
|
||||
title="Rotate key"
|
||||
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
style={{ background: "none" }}
|
||||
>
|
||||
<Button variant="outline" size="xs" onClick={() => openRotateModal(record)} title="Rotate key">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Rotate
|
||||
</button>
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
|
|
|
|||
|
|
@ -421,17 +421,11 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
|
|||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabKey)} className="mb-4">
|
||||
<TabsList className="bg-transparent border-b rounded-none w-full justify-start gap-0 h-auto p-0">
|
||||
<TabsTrigger
|
||||
value="all"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-2 text-[13px]"
|
||||
>
|
||||
<TabsList variant="line" className="border-b rounded-none w-full justify-start h-auto p-0">
|
||||
<TabsTrigger value="all" className="rounded-none px-4 py-2 text-[13px]">
|
||||
All
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="connected"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-2 text-[13px]"
|
||||
>
|
||||
<TabsTrigger value="connected" className="rounded-none px-4 py-2 text-[13px]">
|
||||
Connected{connectedCount > 0 ? ` (${connectedCount})` : ""}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
|
|
|||
|
|
@ -98,8 +98,8 @@ const MCPCredentialsTab: React.FC<Props> = ({ accessToken }) => {
|
|||
<Link className="h-6 w-6 mb-3 mx-auto text-muted-foreground/50" />
|
||||
<p className="m-0">No connections yet</p>
|
||||
<p className="m-0 mt-1 text-xs">
|
||||
Go to <span className="font-medium">Apps</span> and click <span className="font-medium">Connect</span> to
|
||||
authorize an MCP server
|
||||
Go to <span className="font-medium">Integrations</span> and click{" "}
|
||||
<span className="font-medium">Connect</span> to authorize an MCP server
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Loader2, BarChart3 } from "lucide-react";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { userDailyActivityAggregatedCall } from "../networking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const USAGE_QUERY_KEY = "chat-user-usage";
|
||||
|
||||
|
|
@ -105,7 +107,7 @@ const UsagePanel: React.FC<Props> = ({ accessToken, userId }) => {
|
|||
const maxSpend = Math.max(...dailySpend, 0);
|
||||
const maxRequests = Math.max(...dailyRequests, 0);
|
||||
|
||||
const statCards: Array<{ label: string; value: string; sub?: string }> = meta
|
||||
const statCards: Array<{ label: string; value: string; sub?: string; subVariant?: "error" }> = meta
|
||||
? [
|
||||
{ label: "Total Spend", value: `$${meta.total_spend.toFixed(2)}` },
|
||||
{ label: "API Requests", value: formatNumber(meta.total_api_requests) },
|
||||
|
|
@ -121,6 +123,7 @@ const UsagePanel: React.FC<Props> = ({ accessToken, userId }) => {
|
|||
? `${((meta.total_successful_requests / meta.total_api_requests) * 100).toFixed(1)}%`
|
||||
: "N/A",
|
||||
sub: meta.total_failed_requests > 0 ? `${meta.total_failed_requests} failed` : undefined,
|
||||
subVariant: meta.total_failed_requests > 0 ? "error" : undefined,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
|
@ -132,27 +135,28 @@ const UsagePanel: React.FC<Props> = ({ accessToken, userId }) => {
|
|||
<h2 className="text-base font-semibold text-foreground mb-0.5">Your Usage</h2>
|
||||
<p className="text-sm text-muted-foreground m-0">Spend and request activity</p>
|
||||
</div>
|
||||
<div className="flex rounded-md border overflow-hidden">
|
||||
<div className="flex gap-1">
|
||||
{TIME_RANGE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
<Button
|
||||
key={opt.value}
|
||||
variant={timeRange === opt.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setTimeRange(opt.value)}
|
||||
className={`px-3 py-1.5 text-[13px] transition-colors ${
|
||||
timeRange === opt.value
|
||||
? "bg-primary text-primary-foreground font-medium"
|
||||
: "bg-background text-muted-foreground hover:bg-accent"
|
||||
}`}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="border rounded-lg px-4 py-3.5 flex flex-col gap-2">
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
<Skeleton className="h-5 w-2/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !meta || meta.total_api_requests === 0 ? (
|
||||
<div className="text-center text-muted-foreground text-sm py-12 border border-dashed rounded-lg">
|
||||
|
|
@ -166,7 +170,15 @@ const UsagePanel: React.FC<Props> = ({ accessToken, userId }) => {
|
|||
<div key={card.label} className="border rounded-lg px-4 py-3.5">
|
||||
<div className="text-xs text-muted-foreground mb-1">{card.label}</div>
|
||||
<div className="text-xl font-semibold text-foreground">{card.value}</div>
|
||||
{card.sub && <div className="text-xs text-muted-foreground mt-0.5">{card.sub}</div>}
|
||||
{card.sub && (
|
||||
<div
|
||||
className={`text-xs mt-0.5 ${
|
||||
card.subVariant === "error" ? "text-red-600 dark:text-red-400" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{card.sub}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
102
ui/litellm-dashboard/src/contexts/ChatShellContext.tsx
Normal file
102
ui/litellm-dashboard/src/contexts/ChatShellContext.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useChatHistory } from "@/components/chat/useChatHistory";
|
||||
import type { ChatMessage, Conversation } from "@/components/chat/types";
|
||||
|
||||
interface ChatShellContextValue {
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
userRole: string;
|
||||
premiumUser: boolean;
|
||||
selectedMCPServers: string[];
|
||||
setSelectedMCPServers: (servers: string[]) => void;
|
||||
conversations: Conversation[];
|
||||
activeConversation: Conversation | null;
|
||||
activeConversationId: string | 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" | "mcpEvents">>,
|
||||
) => void;
|
||||
truncateFromMessage: (conversationId: string, messageId: string) => void;
|
||||
deleteConversation: (id: string) => void;
|
||||
renameConversation: (id: string, newTitle: string) => void;
|
||||
}
|
||||
|
||||
const ChatShellContext = createContext<ChatShellContextValue | null>(null);
|
||||
|
||||
export function useChatShell(): ChatShellContextValue {
|
||||
const ctx = useContext(ChatShellContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useChatShell must be used within a ChatShellProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
interface ChatShellProviderProps {
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
userRole: string;
|
||||
premiumUser: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ChatShellProvider({
|
||||
accessToken,
|
||||
userId,
|
||||
userEmail,
|
||||
userRole,
|
||||
premiumUser,
|
||||
children,
|
||||
}: ChatShellProviderProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const activeConversationId = searchParams.get("id");
|
||||
const [selectedMCPServers, setSelectedMCPServers] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
conversations,
|
||||
activeConversation,
|
||||
storageUnavailable,
|
||||
staleId,
|
||||
createConversation,
|
||||
appendMessage,
|
||||
updateLastAssistantMessage,
|
||||
truncateFromMessage,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
} = useChatHistory(activeConversationId, userId);
|
||||
|
||||
return (
|
||||
<ChatShellContext.Provider
|
||||
value={{
|
||||
accessToken,
|
||||
userId,
|
||||
userEmail,
|
||||
userRole,
|
||||
premiumUser,
|
||||
selectedMCPServers,
|
||||
setSelectedMCPServers,
|
||||
conversations,
|
||||
activeConversation,
|
||||
activeConversationId,
|
||||
storageUnavailable,
|
||||
staleId,
|
||||
createConversation,
|
||||
appendMessage,
|
||||
updateLastAssistantMessage,
|
||||
truncateFromMessage,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChatShellContext.Provider>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue