From ca64a3bef2ee0c6430f338c6967288d998ed7f7e Mon Sep 17 00:00:00 2001 From: Ishaan Date: Fri, 23 Oct 2026 16:39:23 -0700 Subject: [PATCH] feat(chat): add Moyai UI, model hub, images, and usage metrics (#40557) (#48108) * feat(chat): port Moyai UI with Model Hub and images * feat(chat): add Models sidebar with embedded Model Hub * feat(chat): polish Model Hub with provider cards and logos * refactor(chat): use a provider dropdown in Model Hub * feat(chat): keep workspace switching visible with a Chat identity * Add minimal opt-in Chat usage metrics and prioritize Chat in workspace selector * fix(chat): enable usage metrics by default only when chat is used * fix(chat): count requesting users with frontend-only metrics * feat(chat): send default-on request metrics to litellm-tel --- tests/unit/proxy/telemetry/test_endpoints.py | 5 +- tests/unit/proxy/telemetry/test_settings.py | 1 - .../src/app/chat/layout.integration.test.tsx | 142 ++++ .../src/app/chat/layout.test.tsx | 79 +- ui/litellm-dashboard/src/app/chat/layout.tsx | 16 +- .../app/chat/models/page.integration.test.tsx | 172 +++++ .../src/app/chat/models/page.tsx | 37 + .../app/chat/page.images.integration.test.tsx | 355 +++++++++ .../src/app/chat/page.integration.test.tsx | 4 +- ui/litellm-dashboard/src/app/chat/page.tsx | 719 +++++++++-------- .../chat/page.url-state.integration.test.tsx | 30 +- ui/litellm-dashboard/src/app/globals.css | 375 +++++++++ .../ModelHubProviders.integration.test.tsx | 85 ++ .../components/AIHub/ModelHubProviders.tsx | 103 +++ .../components/AIHub/ModelHubTable.test.tsx | 101 +++ .../src/components/AIHub/ModelHubTable.tsx | 167 ++-- .../AIHub/ModelHubTableColumns.test.tsx | 21 +- .../components/AIHub/ModelHubTableColumns.tsx | 354 +++++---- .../DashboardHeader.integration.test.tsx | 4 +- .../src/components/DashboardHeader.test.tsx | 8 +- .../Navbar/ViewSwitcher.integration.test.tsx | 179 +++++ .../components/Navbar/ViewSwitcher.test.tsx | 234 ------ .../src/components/Navbar/ViewSwitcher.tsx | 142 ++-- .../components/PublicModelHubTableColumns.tsx | 83 +- .../src/components/chat/ChatMessages.test.tsx | 104 +++ .../src/components/chat/ChatMessages.tsx | 104 ++- .../src/components/chat/ChatModelPicker.tsx | 122 +++ .../chat/ChatShell.integration.test.tsx | 173 +++++ .../chat/ChatShell.serverRootPath.test.ts | 6 +- .../src/components/chat/ChatShell.test.tsx | 91 --- .../src/components/chat/ChatShell.tsx | 316 +++++--- .../ConversationList.integration.test.tsx | 89 +++ .../src/components/chat/ConversationList.tsx | 119 ++- .../src/components/chat/chatImageStorage.ts | 125 +++ .../src/components/chat/design.md | 63 +- .../src/components/chat/imageHelpers.test.tsx | 113 +++ .../src/components/chat/imageHelpers.ts | 83 ++ .../components/chat/moyai/FONT-LICENSE.txt | 93 +++ .../src/components/chat/moyai/LICENSE.txt | 21 + .../chat/moyai/MoyaiChatComposer.test.tsx | 116 +++ .../chat/moyai/MoyaiChatComposer.tsx | 81 ++ .../chat/moyai/MoyaiChatHome.test.tsx | 86 +++ .../components/chat/moyai/MoyaiChatHome.tsx | 108 +++ .../chat/moyai/geist-mono-variable.woff2 | Bin 0 -> 23108 bytes .../chat/moyai/geist-variable.woff2 | Bin 0 -> 29288 bytes .../src/components/chat/moyai/moyai-chat.css | 729 ++++++++++++++++++ .../components/chat/runChatRequest.test.ts | 132 ++++ .../src/components/chat/runChatRequest.ts | 86 +++ .../src/components/chat/types.ts | 27 +- .../components/chat/useChatHistory.test.ts | 218 +++++- .../src/components/chat/useChatHistory.ts | 86 ++- .../src/components/model_filters.tsx | 135 +++- .../src/components/public_model_hub.test.tsx | 85 +- .../src/components/public_model_hub.tsx | 354 +++++---- .../shared/DataTable/DataTablePagination.tsx | 7 +- .../src/components/shared/MultiSelect.tsx | 4 +- .../src/contexts/PluginModeContext.test.tsx | 43 +- .../src/contexts/PluginModeContext.tsx | 16 +- .../src/lib/http/chatTelemetry.test.ts | 43 ++ .../src/lib/http/chatTelemetry.ts | 16 + .../src/lib/telemetry/chat.test.ts | 117 +++ .../src/lib/telemetry/chat.ts | 20 + 62 files changed, 6165 insertions(+), 1382 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/chat/layout.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/chat/models/page.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/chat/models/page.tsx create mode 100644 ui/litellm-dashboard/src/app/chat/page.images.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/AIHub/ModelHubProviders.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/AIHub/ModelHubProviders.tsx create mode 100644 ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/ChatMessages.test.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/ChatModelPicker.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/ChatShell.integration.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/ConversationList.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/chatImageStorage.ts create mode 100644 ui/litellm-dashboard/src/components/chat/imageHelpers.test.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/imageHelpers.ts create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/FONT-LICENSE.txt create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/LICENSE.txt create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/MoyaiChatComposer.test.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/MoyaiChatComposer.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/MoyaiChatHome.test.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/MoyaiChatHome.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/geist-mono-variable.woff2 create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/geist-variable.woff2 create mode 100644 ui/litellm-dashboard/src/components/chat/moyai/moyai-chat.css create mode 100644 ui/litellm-dashboard/src/components/chat/runChatRequest.test.ts create mode 100644 ui/litellm-dashboard/src/components/chat/runChatRequest.ts create mode 100644 ui/litellm-dashboard/src/lib/http/chatTelemetry.test.ts create mode 100644 ui/litellm-dashboard/src/lib/http/chatTelemetry.ts create mode 100644 ui/litellm-dashboard/src/lib/telemetry/chat.test.ts create mode 100644 ui/litellm-dashboard/src/lib/telemetry/chat.ts diff --git a/tests/unit/proxy/telemetry/test_endpoints.py b/tests/unit/proxy/telemetry/test_endpoints.py index 3b5f66da0ed..c962c8df6a5 100644 --- a/tests/unit/proxy/telemetry/test_endpoints.py +++ b/tests/unit/proxy/telemetry/test_endpoints.py @@ -1,13 +1,12 @@ -from dataclasses import dataclass, field from collections.abc import Mapping +from dataclasses import dataclass, field from typing import Final -from typing_extensions import LiteralString - import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from httpx import ASGITransport, AsyncClient, Response +from typing_extensions import LiteralString from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth diff --git a/tests/unit/proxy/telemetry/test_settings.py b/tests/unit/proxy/telemetry/test_settings.py index 0e9c180da4f..b88e6811113 100644 --- a/tests/unit/proxy/telemetry/test_settings.py +++ b/tests/unit/proxy/telemetry/test_settings.py @@ -1,7 +1,6 @@ from typing import Final import pytest - from pydantic import ValidationError from litellm.proxy.telemetry.settings import EnvPolicy, TelemetrySettings, describe_errors, env_policy, load_settings diff --git a/ui/litellm-dashboard/src/app/chat/layout.integration.test.tsx b/ui/litellm-dashboard/src/app/chat/layout.integration.test.tsx new file mode 100644 index 00000000000..12acf9192aa --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/layout.integration.test.tsx @@ -0,0 +1,142 @@ +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders, screen, testQueryClient } from "@/../tests/test-utils"; +import { setServerRootPath } from "@/lib/serverRootPath"; +import { recordChatQuery } from "@/lib/telemetry/chat"; +import ChatLayout from "./layout"; + +vi.mock("@/lib/telemetry/chat", () => ({ recordChatQuery: vi.fn(async () => {}) })); + +const { router, route } = vi.hoisted(() => ({ + router: { push: vi.fn(), replace: vi.fn() }, + route: { pathname: "/ui/chat" }, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => router, + usePathname: () => route.pathname, +})); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ + accessToken: "chat-layout-test-token", + userId: "chat-layout-user", + userEmail: "developer@example.com", + userRole: "internal_user", + premiumUser: false, + isLoading: false, + isAuthorized: true, + }), +})); + +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: () => ({ data: { values: { enable_chat_ui: true } }, isLoading: false }), +})); + +const plugin = { name: "observability", display_name: "Observability", url: "https://plugin.example.com" }; +const originalLocation = Object.getOwnPropertyDescriptor(window, "location")!; +const assign = vi.fn(); + +describe("Chat layout product navigation", () => { + beforeEach(() => { + route.pathname = "/ui/chat"; + localStorage.clear(); + localStorage.setItem("litellm_plugin_mode", plugin.name); + testQueryClient.clear(); + assign.mockReset(); + Object.defineProperty(window, "location", { + configurable: true, + value: { ...window.location, assign }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + const path = new URL(url, "http://localhost").pathname; + switch (path) { + case "/api/plugins": + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer chat-layout-test-token"); + return Response.json([plugin]); + case "/get/ui_theme_settings": + return Response.json({ values: {} }); + case "/sso/get/ui_settings": + return Response.json({}); + default: + throw new Error(`Unexpected request: ${path}`); + } + }), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + testQueryClient.clear(); + localStorage.clear(); + setServerRootPath("/"); + Object.defineProperty(window, "location", originalLocation); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it.each([ + { pathname: "/ui/chat", root: "/", gateway: "/ui/" }, + { pathname: "/gateway/ui/chat/models/", root: "/gateway", gateway: "/gateway/ui/" }, + ])( + "should render Chat at $pathname and clear a saved plugin when returning to AI Gateway", + async ({ pathname, root, gateway }) => { + route.pathname = pathname; + setServerRootPath(root); + const user = userEvent.setup(); + renderWithProviders( + +

Chat content

+
, + ); + + expect(screen.getByRole("heading", { name: "Chat content" })).toBeVisible(); + expect(recordChatQuery).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Switch workspace, current: Chat" })); + expect(await screen.findByRole("menuitem", { name: "Observability" })).toBeInTheDocument(); + expect(localStorage.getItem("litellm_plugin_mode")).toBe(plugin.name); + + await user.click(screen.getByRole("menuitem", { name: "AI Gateway", exact: true })); + + expect(localStorage.getItem("litellm_plugin_mode")).toBe("ai-gateway"); + expect(assign).toHaveBeenCalledWith(gateway); + expect(recordChatQuery).not.toHaveBeenCalled(); + }, + ); + + it.each(["SecurityError", "QuotaExceededError"])( + "should still navigate to AI Gateway when browser storage throws %s", + async (errorName) => { + if (errorName === "SecurityError") { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new DOMException("Storage is unavailable", errorName); + }); + vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => { + throw new DOMException("Storage is unavailable", errorName); + }); + } + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("Storage is unavailable", errorName); + }); + const user = userEvent.setup(); + renderWithProviders( + +

Chat content

+
, + ); + + expect(screen.getByRole("heading", { name: "Chat content" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Switch workspace, current: Chat" })); + expect(await screen.findByRole("menuitem", { name: "Observability" })).toBeInTheDocument(); + await user.click(screen.getByRole("menuitem", { name: "AI Gateway", exact: true })); + + expect(assign).toHaveBeenCalledWith("/ui/"); + if (errorName === "QuotaExceededError") { + expect(localStorage.getItem("litellm_plugin_mode")).toBeNull(); + } + }, + ); +}); diff --git a/ui/litellm-dashboard/src/app/chat/layout.test.tsx b/ui/litellm-dashboard/src/app/chat/layout.test.tsx index d455b84afa3..0b0cb96ddc9 100644 --- a/ui/litellm-dashboard/src/app/chat/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/chat/layout.test.tsx @@ -1,23 +1,25 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, screen } from "@testing-library/react"; import { renderWithProviders as render } from "@/../tests/test-utils"; import ChatLayout from "./layout"; -const { mockUseAuthorized, mockUseUISettings, mockReplace, mockUiHref, state } = vi.hoisted(() => { +const { mockUseAuthorized, mockUseUISettings, mockReplace, mockUiHref, recordChatQuery, state } = vi.hoisted(() => { const state = { enableChatUI: false, isUISettingsLoading: false, isIdentityLoading: false, isAuthorized: true, + userId: "user-1" as string | null, }; return { state, mockReplace: vi.fn(), + recordChatQuery: vi.fn(async () => {}), mockUiHref: vi.fn((segment: string) => `/mocked-ui/${segment}`), mockUseAuthorized: vi.fn(() => ({ accessToken: "token-123", userRole: "Internal User", - userId: "user-1", + userId: state.userId, userEmail: "user@example.com", premiumUser: false, isLoading: state.isIdentityLoading, @@ -36,6 +38,7 @@ vi.mock("next/navigation", () => ({ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: mockUseAuthorized })); vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings })); vi.mock("@/utils/uiHref", () => ({ uiHref: mockUiHref })); +vi.mock("@/lib/telemetry/chat", () => ({ recordChatQuery })); vi.mock("@/components/navbar", () => ({ default: () =>
})); vi.mock("@/contexts/ThemeContext", () => ({ ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, @@ -43,21 +46,32 @@ vi.mock("@/contexts/ThemeContext", () => ({ vi.mock("@/contexts/ChatShellContext", () => ({ ChatShellProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); +vi.mock("@/contexts/PluginModeContext", () => ({ + PluginModeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); vi.mock("@/components/chat/ChatShell", () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, })); describe("ChatLayout", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + afterEach(() => { state.enableChatUI = false; state.isUISettingsLoading = false; state.isIdentityLoading = false; state.isAuthorized = true; + state.userId = "user-1"; mockReplace.mockClear(); mockUiHref.mockClear(); + recordChatQuery.mockClear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); - it("renders the chat shell when enable_chat_ui is on", () => { + it("should render the chat shell without recording a query when enable_chat_ui is on", () => { state.enableChatUI = true; render( @@ -67,9 +81,10 @@ describe("ChatLayout", () => { expect(screen.getByTestId("chat-shell")).toBeInTheDocument(); expect(screen.getByTestId("page-content")).toBeInTheDocument(); expect(mockReplace).not.toHaveBeenCalled(); + expect(recordChatQuery).not.toHaveBeenCalled(); }); - it("redirects to the dashboard when enable_chat_ui is off", () => { + it("should redirect without recording a query when enable_chat_ui is off", () => { state.enableChatUI = false; render( @@ -78,9 +93,10 @@ describe("ChatLayout", () => { ); expect(screen.queryByTestId("chat-shell")).not.toBeInTheDocument(); expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/"); + expect(recordChatQuery).not.toHaveBeenCalled(); }); - it("renders nothing while UI settings are still loading", () => { + it("should wait for UI settings without recording a query", () => { state.isUISettingsLoading = true; render( @@ -89,9 +105,10 @@ describe("ChatLayout", () => { ); expect(screen.queryByTestId("chat-shell")).not.toBeInTheDocument(); expect(mockReplace).not.toHaveBeenCalled(); + expect(recordChatQuery).not.toHaveBeenCalled(); }); - it("renders nothing until the signed-in user's identity has loaded", () => { + it("should wait for the signed-in user's identity without recording a query", () => { state.enableChatUI = true; state.isIdentityLoading = true; render( @@ -101,9 +118,10 @@ describe("ChatLayout", () => { ); expect(screen.queryByTestId("chat-shell")).not.toBeInTheDocument(); expect(screen.queryByTestId("page-content")).not.toBeInTheDocument(); + expect(recordChatQuery).not.toHaveBeenCalled(); }); - it("renders nothing when the signed-in user's identity could not be confirmed", () => { + it("should hide Chat without recording a query when the signed-in user's identity cannot be confirmed", () => { state.enableChatUI = true; state.isAuthorized = false; render( @@ -113,5 +131,48 @@ describe("ChatLayout", () => { ); expect(screen.queryByTestId("chat-shell")).not.toBeInTheDocument(); expect(screen.queryByTestId("page-content")).not.toBeInTheDocument(); + expect(recordChatQuery).not.toHaveBeenCalled(); + }); + + it("should not record a query or fetch telemetry when opening, focusing, or revisiting Chat", () => { + state.enableChatUI = true; + const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden"); + const view = render(Chat content); + + act(() => fireEvent.focus(window)); + expect(recordChatQuery).not.toHaveBeenCalled(); + + visibility.mockReturnValue("visible"); + act(() => fireEvent(document, new Event("visibilitychange"))); + expect(recordChatQuery).not.toHaveBeenCalled(); + + view.rerender(Another Chat page); + act(() => fireEvent.focus(window)); + + view.unmount(); + render(Chat revisited); + act(() => { + fireEvent.focus(window); + fireEvent(document, new Event("visibilitychange")); + }); + expect(recordChatQuery).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("should not record a query or fetch telemetry when the signed-in account changes", () => { + state.enableChatUI = true; + const view = render(Chat content); + expect(recordChatQuery).not.toHaveBeenCalled(); + + state.userId = "user-2"; + view.rerender(Chat content); + expect(screen.getByTestId("chat-shell")).toBeInTheDocument(); + + state.userId = null; + state.isAuthorized = false; + view.rerender(Chat content); + act(() => fireEvent.focus(window)); + expect(recordChatQuery).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); }); }); diff --git a/ui/litellm-dashboard/src/app/chat/layout.tsx b/ui/litellm-dashboard/src/app/chat/layout.tsx index bfe494d2234..1da33d80ec5 100644 --- a/ui/litellm-dashboard/src/app/chat/layout.tsx +++ b/ui/litellm-dashboard/src/app/chat/layout.tsx @@ -4,11 +4,12 @@ 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 { PluginModeProvider } from "@/contexts/PluginModeContext"; import ChatShell from "@/components/chat/ChatShell"; import { uiHref } from "@/utils/uiHref"; +import "@/components/chat/moyai/moyai-chat.css"; // The nuqs Next adapter uses useSearchParams, so keep the chat tree behind Suspense. function ChatLayoutContent({ children }: { children: React.ReactNode }) { @@ -32,13 +33,16 @@ function ChatLayoutContent({ children }: { children: React.ReactNode }) { }, [blocked, router]); const identityReady = !isIdentityLoading && isAuthorized; + const chatReady = identityReady && !isUISettingsLoading && chatEnabled; - if (!identityReady || isUISettingsLoading || blocked) return null; + if (!chatReady) return null; return ( -
- + + + +
- {children} + + {children} +
diff --git a/ui/litellm-dashboard/src/app/chat/models/page.integration.test.tsx b/ui/litellm-dashboard/src/app/chat/models/page.integration.test.tsx new file mode 100644 index 00000000000..d1868558626 --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/models/page.integration.test.tsx @@ -0,0 +1,172 @@ +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders, screen, testQueryClient, waitFor, within } from "@/../tests/test-utils"; +import type { ModelHubData } from "@/components/AIHub/ModelHubTableColumns"; + +import ChatModelsPage from "./page"; + +const { apiGetMock, modelHubCallMock, shellState } = vi.hoisted(() => ({ + apiGetMock: vi.fn(), + modelHubCallMock: vi.fn(), + shellState: { accessToken: "chat-session-token", userRole: "proxy_admin", premiumUser: false }, +})); + +vi.mock("@/contexts/ChatShellContext", () => ({ + useChatShell: () => shellState, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), +})); + +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: () => ({ data: { values: {} }, isLoading: false }), +})); + +vi.mock("@/components/networking", async (importOriginal) => { + const actual = await importOriginal(); + const hubInfo = { + docs_title: "Gateway models", + custom_docs_description: null, + litellm_version: "1.0.0", + useful_links: {}, + }; + return { + ...actual, + apiClient: { ...actual.apiClient, get: apiGetMock }, + modelHubCall: modelHubCallMock, + getConfigFieldSetting: vi.fn().mockResolvedValue({ field_value: true }), + getUiConfig: vi.fn().mockResolvedValue({}), + getProxyBaseUrl: () => "https://gateway.example.com", + getGatewayBaseUrl: () => "https://gateway.example.com", + getPublicModelHubInfo: vi.fn().mockResolvedValue(hubInfo), + getAgentsList: vi.fn().mockResolvedValue({ agents: [] }), + fetchMCPServers: vi.fn().mockResolvedValue([]), + getClaudeCodePluginsList: vi.fn().mockResolvedValue({ plugins: [] }), + agentHubPublicModelsCall: vi.fn().mockResolvedValue([]), + mcpHubPublicServersCall: vi.fn().mockResolvedValue([]), + skillHubPublicCall: vi.fn().mockResolvedValue({ plugins: [] }), + }; +}); + +const PRIVATE_MODEL = { + model_group: "research-private", + description: "An internal model for reviewing research reports.", + providers: ["openai"], + mode: "chat", + max_input_tokens: 128_000, + max_output_tokens: 4_096, + input_cost_per_token: 0.000002, + output_cost_per_token: 0.000008, + supports_function_calling: true, + supports_parallel_function_calling: false, + supports_vision: true, + is_public_model_group: false, +} satisfies ModelHubData; + +const IMAGE_MODEL: ModelHubData = { + ...PRIVATE_MODEL, + model_group: "image-studio", + description: "Create illustrations for presentations.", + mode: "image_generation", +}; + +const PUBLISHED_MODEL = { + ...PRIVATE_MODEL, + model_group: "published-chat", + description: "The shared model published for gateway users.", + is_public_model_group: true, +} satisfies ModelHubData; + +const PUBLIC_MODELS_PATH = "/public/v1/model_hub"; +const FACETS: Record = { + [`${PUBLIC_MODELS_PATH}/providers`]: ["openai"], + [`${PUBLIC_MODELS_PATH}/modes`]: ["chat"], + [`${PUBLIC_MODELS_PATH}/features`]: ["function_calling", "vision"], +}; + +describe("Chat Models page", () => { + beforeEach(() => { + shellState.userRole = "proxy_admin"; + modelHubCallMock.mockResolvedValue({ data: [PRIVATE_MODEL, IMAGE_MODEL] }); + apiGetMock.mockImplementation(async (path: string) => { + if (path in FACETS) { + return { + data: FACETS[path], + meta: { page: 1, page_size: 100, has_more: false }, + links: { self: path, prev: null, next: null }, + }; + } + if (path === PUBLIC_MODELS_PATH) { + return { + data: [PUBLISHED_MODEL], + meta: { total_count: 1, page: 1, page_size: 50, total_pages: 1 }, + links: { + self: path, + first: path, + prev: null, + next: null, + last: path, + }, + }; + } + throw new Error(`Unexpected catalog request: ${path}`); + }); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + if (String(input) !== "https://gateway.example.com/get/ui_theme_settings") { + throw new Error(`Unexpected fetch: ${String(input)}`); + } + return new Response(JSON.stringify({ values: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }), + ); + }); + + afterEach(() => { + testQueryClient.clear(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should render gateway models for admins and let them search and inspect model details", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + expect(screen.getByRole("heading", { name: "Models", level: 1 })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /^research-private/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^image-studio/ })).toBeInTheDocument(); + + await user.type(screen.getByPlaceholderText("Search model names..."), "research"); + await waitFor(() => expect(screen.queryByRole("button", { name: /^image-studio/ })).not.toBeInTheDocument()); + await user.click(screen.getByRole("button", { name: /^research-private/ })); + + const details = await screen.findByRole("dialog", { name: PRIVATE_MODEL.model_group }); + expect(within(details).getByText(PRIVATE_MODEL.description)).toBeInTheDocument(); + expect(within(details).getByText("128,000")).toBeInTheDocument(); + expect(within(details).getByText("$2.00")).toBeInTheDocument(); + expect(within(details).getByText("Vision")).toBeInTheDocument(); + expect(within(details).getByText("Function Calling")).toBeInTheDocument(); + }); + + it("should show regular users the published catalog and its model capabilities", async () => { + shellState.userRole = "internal_user"; + const user = userEvent.setup(); + renderWithProviders(); + + const publishedModel = await screen.findByRole("button", { name: /^published-chat/ }); + expect(screen.queryByRole("button", { name: /^research-private/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^image-studio/ })).not.toBeInTheDocument(); + await user.click(publishedModel); + + const details = await screen.findByRole("dialog", { name: PUBLISHED_MODEL.model_group }); + expect(within(details).getByText(PUBLISHED_MODEL.description)).toBeInTheDocument(); + expect(within(details).getByText("128,000")).toBeInTheDocument(); + expect(within(details).getByText("$2.0000")).toBeInTheDocument(); + expect(within(details).getByText("Vision")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/chat/models/page.tsx b/ui/litellm-dashboard/src/app/chat/models/page.tsx new file mode 100644 index 00000000000..8011235821e --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/models/page.tsx @@ -0,0 +1,37 @@ +"use client"; + +import ModelHubTable from "@/components/AIHub/ModelHubTable"; +import PublicModelHub from "@/components/public_model_hub"; +import { useChatShell } from "@/contexts/ChatShellContext"; +import { isAdminRole } from "@/utils/roles"; + +export default function ChatModelsPage() { + const { accessToken, userRole, premiumUser } = useChatShell(); + + return ( +
+
+
+

Model Hub

+

Models

+

+ Find the right model. Explore providers, compare pricing, and see what each model can do. +

+
+ {isAdminRole(userRole) ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/chat/page.images.integration.test.tsx b/ui/litellm-dashboard/src/app/chat/page.images.integration.test.tsx new file mode 100644 index 00000000000..4f34fe22e58 --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/page.images.integration.test.tsx @@ -0,0 +1,355 @@ +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders, testQueryClient } from "@/../tests/test-utils"; +import { ChatShellProvider } from "@/contexts/ChatShellContext"; +import { recordChatQuery } from "@/lib/telemetry/chat"; +import ChatConversationPage from "./page"; + +vi.mock("@/lib/telemetry/chat", () => ({ recordChatQuery: vi.fn(async () => {}) })); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +type RecordedRequest = { pathname: string; body: Record }; +const requests: RecordedRequest[] = []; +const pixel = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aRZQAAAAASUVORK5CYII="; +let models: { model_group: string; mode: string; providers: string[] }[]; +let imageResponse: () => Response; +let pendingGeneration: boolean; +let generationSignal: AbortSignal | null; +let includeMCPEvents: boolean; + +function responseStream() { + return new Response( + [ + { type: "response.output_text.delta", delta: "I can see " }, + ...(includeMCPEvents + ? ["search_notes", "read_note"].map((name) => ({ + type: "response.output_item.done", + item: { id: name, type: "mcp_call", name, server_label: "research-tools", output: "Found a note" }, + })) + : []), + { type: "response.output_text.delta", delta: "the uploaded image." }, + { type: "response.completed", response: { id: `response-${requests.length}`, output: [] } }, + ] + .map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`) + .join(""), + { headers: { "Content-Type": "text/event-stream" } }, + ); +} + +function renderChat() { + return renderWithProviders( + + + , + ); +} + +async function uploadImage(user: ReturnType, name = "diagram.png") { + const bytes = Uint8Array.from(atob(pixel), (character) => character.charCodeAt(0)); + await user.upload(screen.getByLabelText("Upload images"), new File([bytes], name, { type: "image/png" })); + await screen.findByRole("img", { name }); +} + +async function chooseModel(user: ReturnType, current: string, next: string) { + await user.click(screen.getByRole("button", { name: current, exact: true })); + await user.click(await screen.findByRole("button", { name: new RegExp(`^${next}`) })); + await waitFor(() => expect(screen.queryByRole("button", { name: "All", exact: true })).not.toBeInTheDocument()); +} + +beforeEach(() => { + localStorage.clear(); + testQueryClient.clear(); + requests.length = 0; + vi.mocked(recordChatQuery).mockClear(); + models = [ + { model_group: "research-chat", mode: "chat", providers: ["openai"] }, + { model_group: "studio-image", mode: "image_generation", providers: ["openai"] }, + ]; + imageResponse = () => Response.json({ created: 1, data: [{ b64_json: pixel }] }); + pendingGeneration = false; + generationSignal = null; + includeMCPEvents = false; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = + input instanceof Request ? input : new Request(new URL(String(input), window.location.origin), init); + const pathname = new URL(request.url).pathname; + if (pathname.endsWith("/model_group/info")) return Response.json({ data: models }); + if (pathname.endsWith("/v1/mcp/server")) { + return Response.json([{ server_id: "research-tools", server_name: "research-tools" }]); + } + if (pathname.endsWith("/mcp-rest/tools/list")) return Response.json({ tools: [] }); + if (pathname.endsWith("/responses") || pathname.endsWith("/images/generations")) { + requests.push({ pathname, body: JSON.parse(await request.text()) }); + if (pathname.endsWith("/responses")) return responseStream(); + if (pendingGeneration) { + generationSignal = request.signal; + return new Promise((_resolve, reject) => { + request.signal.addEventListener("abort", () => reject(new DOMException("Stopped", "AbortError")), { + once: true, + }); + }); + } + return imageResponse(); + } + return Response.json({}, { status: 404 }); + }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); +}); + +describe("chat image workflows", () => { + it("should render the permitted models and image controls for a research team member", async () => { + renderChat(); + expect(await screen.findByRole("button", { name: "research-chat" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Attach images" })).toBeInTheDocument(); + await userEvent.setup().click(screen.getByRole("button", { name: "research-chat", exact: true })); + expect(await screen.findByRole("button", { name: /^studio-image/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send" })).toBeDisabled(); + expect(recordChatQuery).not.toHaveBeenCalled(); + }); + + it("should restore the selected chat model after switching modes and reopening the page", async () => { + const user = userEvent.setup(); + const view = renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await chooseModel(user, "research-chat", "studio-image"); + expect(screen.getByRole("button", { name: "studio-image" })).toBeInTheDocument(); + await chooseModel(user, "studio-image", "research-chat"); + view.unmount(); + renderChat(); + expect(await screen.findByRole("button", { name: "research-chat" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Message", exact: true })).toBeInTheDocument(); + }); + + it("should preview and remove an uploaded image before sending", async () => { + const user = userEvent.setup(); + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await uploadImage(user); + expect(screen.getByRole("button", { name: "Send" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Remove diagram.png" })); + expect(screen.queryByRole("img", { name: "diagram.png" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send" })).toBeDisabled(); + expect(requests).toHaveLength(0); + expect(recordChatQuery).not.toHaveBeenCalled(); + }); + + it("should send the image and preserve it when editing and resending the message", async () => { + const user = userEvent.setup(); + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await uploadImage(user); + await user.type(screen.getByRole("textbox", { name: "Message", exact: true }), "Explain this diagram"); + await user.click(screen.getByRole("button", { name: "Send" })); + await screen.findByText("I can see the uploaded image."); + expect(recordChatQuery).toHaveBeenCalledExactlyOnceWith("media-test-user"); + expect(requests[0].body).toMatchObject({ + model: "research-chat", + input: [ + { + role: "user", + content: [ + { type: "input_text", text: "Explain this diagram" }, + { type: "input_image", image_url: `data:image/png;base64,${pixel}` }, + ], + }, + ], + }); + expect(screen.getByRole("img", { name: "diagram.png" })).toBeInTheDocument(); + await user.hover(screen.getAllByText("Explain this diagram").at(-1)!); + const editButton = await screen.findByRole("button", { name: "Edit message" }); + act(() => fireEvent.click(editButton)); + await user.clear(screen.getByRole("textbox", { name: "Edit message" })); + await user.type(screen.getByRole("textbox", { name: "Edit message" }), "Explain the arrows"); + await user.click(screen.getByRole("button", { name: "Save & Send" })); + await waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[1].body).toMatchObject({ + input: [ + { + role: "user", + content: [ + { type: "input_text", text: "Explain the arrows" }, + { type: "input_image", image_url: `data:image/png;base64,${pixel}` }, + ], + }, + ], + }); + expect(requests[1].body).not.toHaveProperty("previous_response_id"); + expect(screen.queryByText("Explain this diagram", { selector: "p" })).not.toBeInTheDocument(); + expect(recordChatQuery).toHaveBeenCalledTimes(2); + }); + + it("should preserve a chat turn when editing it while an image model is selected", async () => { + const user = userEvent.setup(); + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await user.type(screen.getByRole("textbox", { name: "Message", exact: true }), "Explain the experiment"); + await user.click(screen.getByRole("button", { name: "Send" })); + await screen.findByText("I can see the uploaded image."); + await chooseModel(user, "research-chat", "studio-image"); + await user.hover(screen.getAllByText("Explain the experiment").at(-1)!); + const editButton = await screen.findByRole("button", { name: "Edit message" }); + act(() => fireEvent.click(editButton)); + await user.clear(screen.getByRole("textbox", { name: "Edit message" })); + await user.type(screen.getByRole("textbox", { name: "Edit message" }), "Explain the findings"); + await user.click(screen.getByRole("button", { name: "Save & Send" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Choose a chat model before resending this message."); + expect(screen.getByRole("heading", { name: "Explain the experiment" })).toBeInTheDocument(); + expect(screen.getByText("I can see the uploaded image.")).toBeInTheDocument(); + expect(requests).toHaveLength(1); + expect(recordChatQuery).toHaveBeenCalledExactlyOnceWith("media-test-user"); + }); + + it("should continue an image conversation using the completed response context", async () => { + const user = userEvent.setup(); + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await uploadImage(user); + await user.click(screen.getByRole("button", { name: "Send" })); + await screen.findByText("I can see the uploaded image."); + await user.type(screen.getByRole("textbox", { name: "Message", exact: true }), "What is the main color?"); + await user.click(screen.getByRole("button", { name: "Send" })); + await waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[1].body).toMatchObject({ + previous_response_id: "response-1", + input: [{ role: "user", content: "What is the main color?" }], + }); + expect(screen.getByRole("img", { name: "diagram.png" })).toBeInTheDocument(); + }); + + it("should generate and render an image through the selected Model Hub model", async () => { + vi.mocked(recordChatQuery).mockReturnValueOnce(new Promise(() => {})); + const user = userEvent.setup(); + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await chooseModel(user, "research-chat", "studio-image"); + expect(screen.getByRole("button", { name: "studio-image" })).toBeInTheDocument(); + await user.type(screen.getByRole("textbox", { name: "Message", exact: true }), "A blue research lab"); + await user.click(screen.getByRole("button", { name: "Generate image" })); + const result = await screen.findByRole("img", { name: "Generated image 1" }); + expect(result).toHaveAttribute("src", `data:image/png;base64,${pixel}`); + expect(requests).toEqual([ + { pathname: "/images/generations", body: { model: "studio-image", prompt: "A blue research lab" } }, + ]); + expect(recordChatQuery).toHaveBeenCalledExactlyOnceWith("media-test-user"); + }); + + it("should list no image models when the account has none permitted", async () => { + models = models.filter((model) => model.mode === "chat"); + const user = userEvent.setup(); + renderChat(); + await user.click(await screen.findByRole("button", { name: "research-chat" })); + await user.click(screen.getByRole("button", { name: "Images", exact: true })); + expect(screen.getByText("No models found.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^studio-image/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Generate image" })).not.toBeInTheDocument(); + expect(requests).toHaveLength(0); + }); + + it("should show the provider error when image generation fails", async () => { + imageResponse = () => + Response.json({ error: { message: "Image access is disabled for this key" } }, { status: 403 }); + const user = userEvent.setup(); + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await chooseModel(user, "research-chat", "studio-image"); + await user.type(screen.getByRole("textbox", { name: "Message", exact: true }), "A blue lab"); + await user.click(screen.getByRole("button", { name: "Generate image" })); + expect(await screen.findByText(/Image access is disabled for this key/)).toBeInTheDocument(); + expect(screen.queryByRole("img", { name: "Generated image 1" })).not.toBeInTheDocument(); + expect(recordChatQuery).toHaveBeenCalledExactlyOnceWith("media-test-user"); + }); + + it("should abort image generation when the user stops the request", async () => { + pendingGeneration = true; + const user = userEvent.setup(); + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await chooseModel(user, "research-chat", "studio-image"); + await user.type(screen.getByRole("textbox", { name: "Message", exact: true }), "A blue lab"); + await user.click(screen.getByRole("button", { name: "Generate image" })); + await waitFor(() => expect(generationSignal).not.toBeNull()); + act(() => { + fireEvent.change(screen.getByRole("textbox", { name: "Message", exact: true }), { + target: { value: "Another image" }, + }); + fireEvent.keyDown(screen.getByRole("textbox", { name: "Message", exact: true }), { key: "Enter" }); + }); + expect(requests).toHaveLength(1); + expect(recordChatQuery).toHaveBeenCalledExactlyOnceWith("media-test-user"); + await user.click(screen.getByRole("button", { name: "Stop request" })); + expect(generationSignal?.aborted).toBe(true); + expect(await screen.findByText("[stopped]")).toBeInTheDocument(); + expect(screen.queryByRole("img", { name: "Generated image 1" })).not.toBeInTheDocument(); + expect(recordChatQuery).toHaveBeenCalledTimes(1); + }); + + it("should reject unsupported pasted files without issuing a request", async () => { + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + act(() => { + fireEvent.paste(screen.getByRole("textbox", { name: "Message", exact: true }), { + clipboardData: { files: [new File(["svg"], "diagram.svg", { type: "image/svg+xml" })] }, + }); + }); + expect(await screen.findByRole("alert")).toHaveTextContent("Choose a PNG, JPEG, WebP, or GIF image."); + expect(requests).toHaveLength(0); + expect(recordChatQuery).not.toHaveBeenCalled(); + }); + + it("should not count a blank submission as a query", async () => { + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + act(() => { + fireEvent.change(screen.getByRole("textbox", { name: "Message", exact: true }), { + target: { value: " " }, + }); + fireEvent.keyDown(screen.getByRole("textbox", { name: "Message", exact: true }), { key: "Enter" }); + }); + expect(requests).toHaveLength(0); + expect(recordChatQuery).not.toHaveBeenCalled(); + }); + + it("should count one query for a message that streams text and executes multiple MCP tools", async () => { + includeMCPEvents = true; + const user = userEvent.setup(); + renderChat(); + await screen.findByRole("button", { name: "research-chat" }); + await user.click(screen.getByRole("button", { name: "Connect tools" })); + const serverSwitch = await screen.findByRole("switch"); + await user.click(serverSwitch); + await waitFor(() => expect(screen.getByRole("switch")).toBeChecked()); + await user.click(screen.getByRole("button", { name: "Connect tools" })); + expect(recordChatQuery).not.toHaveBeenCalled(); + + act(() => { + fireEvent.change(screen.getByRole("textbox", { name: "Message", exact: true }), { + target: { value: "Search the research notes" }, + }); + }); + await user.click(screen.getByRole("button", { name: "Send" })); + await screen.findByText("I can see the uploaded image."); + + expect(requests).toHaveLength(1); + expect(requests[0].body).toMatchObject({ + tools: [{ type: "mcp", server_label: "research-tools", require_approval: "never" }], + }); + expect(recordChatQuery).toHaveBeenCalledExactlyOnceWith("media-test-user"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx b/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx index 2020a66bd1c..e49f3a17094 100644 --- a/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx @@ -72,7 +72,7 @@ const ON_TOTAL_LATENCY_INDEX = 24; async function sendOneMessage(onUrlUpdate?: OnUrlUpdateFunction): Promise { renderWithProviders(, { onUrlUpdate }); expect(await screen.findByRole("button", { name: /gpt-5\.4-mini/ })).toBeInTheDocument(); - fireEvent.change(screen.getByPlaceholderText("How can I help you today?"), { + fireEvent.change(screen.getByPlaceholderText("Describe a change, paste an error, or ask a question…"), { target: { value: "How much did this cost?" }, }); fireEvent.click(screen.getByRole("button", { name: "Send" })); @@ -157,7 +157,7 @@ describe("/ui/chat storage banner", () => { it("keeps the dismiss control amber on hover instead of the ghost variant's foreground", async () => { renderWithProviders(); - const banner = await screen.findByText("Chat history won't be saved in this browser session"); + const banner = await screen.findByText("Some chat history or images could not be saved or restored."); const dismiss = within(banner.parentElement!).getByRole("button"); expect(dismiss).toHaveClass("hover:text-warning/80"); expect(dismiss).not.toHaveClass("hover:text-foreground"); diff --git a/ui/litellm-dashboard/src/app/chat/page.tsx b/ui/litellm-dashboard/src/app/chat/page.tsx index fdf6a1e02a3..dbb578a0e3d 100644 --- a/ui/litellm-dashboard/src/app/chat/page.tsx +++ b/ui/litellm-dashboard/src/app/chat/page.tsx @@ -1,11 +1,8 @@ "use client"; import React, { useCallback, useEffect, useRef, useState, useLayoutEffect } from "react"; -import { Plus, ChevronDown, Check, X } from "lucide-react"; +import { Plus, ChevronDown, Paperclip, ImageIcon, X } 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 { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { toast } from "@/lib/toast"; import { useRouter } from "next/navigation"; @@ -13,41 +10,22 @@ import { useChatShell } from "@/contexts/ChatShellContext"; import { getChatRoutes } 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 { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api"; -import type { TokenUsage } from "@/components/chat_ui/ResponseMetrics"; -import type { MCPEvent } from "@/components/chat/types"; -import { getProviderLogoAndName } from "@/components/provider_info_helpers"; +import { fetchAvailableModels, type ModelGroup } from "@/components/llm_calls/fetch_models"; +import type { AssistantMessageUpdate, ChatImageAttachment, ChatMessage } from "@/components/chat/types"; +import ChatModelPicker, { isChatModel, modelChatMode, type ChatMode } from "@/components/chat/ChatModelPicker"; +import MoyaiChatComposer from "@/components/chat/moyai/MoyaiChatComposer"; +import MoyaiChatHome from "@/components/chat/moyai/MoyaiChatHome"; +import { runChatRequest } from "@/components/chat/runChatRequest"; +import { readChatImage, MAX_CHAT_IMAGES } from "@/components/chat/imageHelpers"; import { useQueryState } from "nuqs"; +import { recordChatQuery } from "@/lib/telemetry/chat"; -const SUGGESTIONS = ["Write", "Learn", "Code", "Brainstorm"]; const LOCALSTORAGE_MODEL_KEY = "litellm_chat_selected_model"; -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"; -} +type ReplayMessage = Pick; -// 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 ""; +function hasMessageContent(text: string, images: ChatImageAttachment[], mode: ChatMode): boolean { + return Boolean(text.trim()) || (mode === "chat" && images.length > 0); } export default function ChatConversationPage() { @@ -56,7 +34,7 @@ export default function ChatConversationPage() { const { accessToken, userId, - userEmail, + conversations, selectedMCPServers, setSelectedMCPServers, activeConversationId, @@ -70,10 +48,15 @@ export default function ChatConversationPage() { } = useChatShell(); const [selectedModel, setSelectedModel] = useState(null); - const [models, setModels] = useState([]); + const [models, setModels] = useState([]); + const [mode, setMode] = useState("chat"); + const [attachments, setAttachments] = useState([]); + const [attachmentError, setAttachmentError] = useState(null); + const [isReadingImages, setIsReadingImages] = useState(false); + const imageInputRef = useRef(null); + const readImagesRef = useRef(false); + const draftVersionRef = useRef(0); const [isLoadingModels, setIsLoadingModels] = useState(true); - const [modelSelectorOpen, setModelSelectorOpen] = useState(false); - const [modelSearchText, setModelSearchText] = useState(""); const [responsesSessionId, setResponsesSessionId] = useState(null); const [prevConversationIdForSessionReset, setPrevConversationIdForSessionReset] = useState(activeConversationId); @@ -83,7 +66,7 @@ export default function ChatConversationPage() { const [storageBannerDismissed, setStorageBannerDismissed] = useState(false); const abortControllerRef = useRef(null); - const textareaRef = useRef(null); + const requestConversationRef = useRef(null); const messagesScrollRef = useRef(null); const [showScrollButton, setShowScrollButton] = useState(false); const streamScrollLock = useRef(null); @@ -97,12 +80,14 @@ export default function ChatConversationPage() { if (!accessToken) return; fetchAvailableModels(accessToken) .then((data) => { - const names = (data || []).map((m: { model_group?: string }) => m.model_group ?? "").filter(Boolean); - setModels(names); + const available = (data || []).filter(isChatModel); + const names = available.map((model) => model.model_group); + setModels(available); try { const saved = localStorage.getItem(LOCALSTORAGE_MODEL_KEY); if (saved && names.includes(saved)) { setSelectedModel(saved); + setMode(modelChatMode(available.find((model) => model.model_group === saved)!)); return; } } catch { @@ -110,7 +95,7 @@ export default function ChatConversationPage() { } if (names.length > 0) { setSelectedModel(names[0]); - localStorage.setItem(LOCALSTORAGE_MODEL_KEY, names[0]); + setMode(modelChatMode(available[0])); } }) .catch(() => toast.error("Could not load models")) @@ -122,20 +107,87 @@ export default function ChatConversationPage() { if (activeConversationId !== prevConversationIdForSessionReset) { setPrevConversationIdForSessionReset(activeConversationId); setResponsesSessionId(null); + setAttachments([]); + setAttachmentError(null); + setInputText(""); } - const selectModel = useCallback((model: string) => { - setSelectedModel(model); - localStorage.setItem(LOCALSTORAGE_MODEL_KEY, model); - setModelSelectorOpen(false); - setModelSearchText(""); - }, []); + useEffect(() => { + draftVersionRef.current += 1; + if (abortControllerRef.current && requestConversationRef.current !== activeConversationId) { + abortControllerRef.current.abort(); + } + }, [activeConversationId]); + + const selectModel = useCallback( + (model: ModelGroup) => { + if (modelChatMode(model) === "image" && attachments.length > 0) { + setAttachmentError("Remove the attached images before switching to image generation."); + return; + } + setSelectedModel(model.model_group); + setMode(modelChatMode(model)); + setResponsesSessionId(null); + setAttachmentError(null); + try { + localStorage.setItem(LOCALSTORAGE_MODEL_KEY, model.model_group); + } catch { + return; + } + }, + [attachments.length], + ); + + const addImages = async (files: File[]) => { + if (mode !== "chat" || files.length === 0) return; + if (isStreaming || readImagesRef.current) return; + if (attachments.length + files.length > MAX_CHAT_IMAGES) { + setAttachmentError(`Attach up to ${MAX_CHAT_IMAGES} images per message.`); + return; + } + const draftVersion = draftVersionRef.current; + readImagesRef.current = true; + setIsReadingImages(true); + setAttachmentError(null); + try { + const images = await Promise.all(files.map(readChatImage)); + if (draftVersion === draftVersionRef.current) setAttachments((current) => [...current, ...images]); + } catch (error) { + if (draftVersion === draftVersionRef.current) + setAttachmentError(error instanceof Error ? error.message : "Could not read these images."); + } finally { + readImagesRef.current = false; + setIsReadingImages(false); + } + }; + + const handleImageInput = (event: React.ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + event.target.value = ""; + void addImages(files); + }; + + useEffect( + () => () => { + abortControllerRef.current?.abort(); + draftVersionRef.current += 1; + }, + [], + ); const handleSend = useCallback( - async (text: string, historyOverride?: Array<{ role: "user" | "assistant"; content: string }>) => { + async ( + text: string, + historyOverride?: ReplayMessage[], + attachmentOverride?: ChatImageAttachment[], + generationModel?: string, + ) => { const trimmed = text.trim(); - if (!trimmed || !selectedModel || isStreaming) return; - const model = selectedModel; + const images = attachmentOverride ?? attachments; + const requestMode = generationModel ? "image" : mode; + const model = generationModel ?? selectedModel; + if (!hasMessageContent(trimmed, images, requestMode) || !model) return; + if (isStreaming || abortControllerRef.current || readImagesRef.current) return; setInputText(""); let convId = activeConversationId; @@ -145,11 +197,21 @@ export default function ChatConversationPage() { setConversationIdInUrl(convId); } - appendMessage(convId, { role: "user", content: trimmed }); - appendMessage(convId, { role: "assistant", content: "" }); + const imageGeneration = requestMode === "image" ? { model, prompt: trimmed } : undefined; + const userMessage = { + role: "user" as const, + content: trimmed, + attachments: images.length > 0 ? images : undefined, + imageGeneration, + }; + appendMessage(convId, userMessage); + appendMessage(convId, { role: "assistant", content: "", imageGeneration }); + setAttachments([]); + setAttachmentError(null); setIsStreaming(true); abortControllerRef.current = new AbortController(); + requestConversationRef.current = convId; // When historyOverride is set (edit / retry), the existing server-side // session chain covers messages that were just truncated and is no longer @@ -167,86 +229,33 @@ export default function ChatConversationPage() { // 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; + const history: ReplayMessage[] = [ + ...(historyOverride ?? (previousResponseId ? [] : activeConversation?.messages ?? [])), + userMessage, + ]; + const requestController = abortControllerRef.current; + const request = { + model, + mode: requestMode, + prompt: trimmed, + accessToken, + signal: requestController.signal, + messages: history, + previousResponseId, + mcpServers: selectedMCPServers, + onResponseId: setResponsesSessionId, + onUpdate: (updates: AssistantMessageUpdate) => updateLastAssistantMessage(convId, updates), + }; 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 }); - }, - (timeToFirstToken: number) => updateLastAssistantMessage(convId!, { timeToFirstToken }), - (usage: TokenUsage) => updateLastAssistantMessage(convId!, { usage }), - 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); - }, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - true, - (totalLatency: number) => updateLastAssistantMessage(convId!, { totalLatency }), - ); - 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.]", - }); - } + void recordChatQuery(userId); + await runChatRequest(request); } 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 }); + if (abortControllerRef.current === requestController) { + setIsStreaming(false); + abortControllerRef.current = null; + requestConversationRef.current = null; } - setIsStreaming(false); - abortControllerRef.current = null; } }, [ @@ -254,7 +263,10 @@ export default function ChatConversationPage() { activeConversation, selectedModel, selectedMCPServers, + mode, + attachments, accessToken, + userId, createConversation, appendMessage, updateLastAssistantMessage, @@ -271,34 +283,25 @@ export default function ChatConversationPage() { const handleEditAndResend = useCallback( (messageId: string, newContent: string) => { if (!activeConversationId || isStreaming) return; + if (abortControllerRef.current || readImagesRef.current) 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 })); + if (idx < 0) return; + const original = msgs[idx]; + if (!original.imageGeneration && (mode !== "chat" || !selectedModel)) { + setAttachmentError("Choose a chat model before resending this message."); + return; + } + if (!newContent.trim() && !original.attachments?.length) return; + const priorMessages = msgs.slice(0, idx); truncateFromMessage(activeConversationId, messageId); - handleSend(newContent, priorMessages); + handleSend(newContent, priorMessages, original.attachments ?? [], original.imageGeneration?.model); }, - [activeConversationId, isStreaming, activeConversation, truncateFromMessage, handleSend], + [activeConversationId, isStreaming, activeConversation, truncateFromMessage, handleSend, mode, selectedModel], ); - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSend(inputText); - } - }; - - // Auto-resize textarea - useEffect(() => { - const ta = textareaRef.current; - if (!ta) return; - ta.style.height = "auto"; - ta.style.height = `${Math.min(ta.scrollHeight, 180)}px`; - }, [inputText]); - // Track scroll position to show/hide scroll-to-bottom button useEffect(() => { const el = messagesScrollRef.current; @@ -345,178 +348,165 @@ export default function ChatConversationPage() { }, [activeConversation?.messages]); const showBlankState = !activeConversation || activeConversation.messages.length === 0; - const displayName = userEmail?.split("@")[0] ?? userId ?? ""; - const greeting = displayName ? `${getGreeting()}, ${displayName}` : getGreeting(); - // Filtered models: selected one floats to the top, then alphabetical - const filteredModels = ( - modelSearchText ? models.filter((m) => m.toLowerCase().includes(modelSearchText.toLowerCase())) : models - ).sort((a, b) => { - if (a === selectedModel) return -1; - if (b === selectedModel) return 1; - return 0; - }); - - const modelSelectorContent = ( -
-
- setModelSearchText(e.target.value)} - placeholder="Search models..." - className="h-8 text-[13px]" - /> -
- - {filteredModels.map((m) => { - const checked = m === selectedModel; - const provider = getProviderFromModelName(m); - const { logo } = provider ? getProviderLogoAndName(provider) : { logo: "" }; - return ( - - ); - })} - -
- ); - - const modelSelectorTrigger = isLoadingModels ? ( - - ) : ( - { - setModelSelectorOpen(open); - if (!open) setModelSearchText(""); - }} - > - - {selectedModel ? ( + const inputBar = (inConversation: boolean) => { + const preparing = isLoadingModels || isReadingImages; + const submitDisabled = !hasMessageContent(inputText, attachments, mode) || !selectedModel || preparing; + return ( +
{ + if (mode === "chat" && event.dataTransfer.types.includes("Files")) event.preventDefault(); + }} + onDrop={(event) => { + if (mode === "chat" && event.dataTransfer.files.length) { + event.preventDefault(); + void addImages(Array.from(event.dataTransfer.files)); + } + }} + > + void handleSend(inputText)} + onCancel={handleStop} + placeholder={ + mode === "image" + ? "Describe the image you want to create…" + : inConversation + ? "Send a message..." + : "Describe a change, paste an error, or ask a question…" + } + isLoading={isStreaming} + submitLabel={mode === "image" ? "Generate image" : "Send"} + submitDisabled={submitDisabled} + onPaste={(event) => { + const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/")); + if (mode === "chat" && images.length > 0) { + event.preventDefault(); + void addImages(images); + } + }} + attachmentPreview={ + attachments.length > 0 ? ( +
+ {attachments.map((image) => ( +
+ {image.name} + +

{image.name}

+
+ ))} +
+ ) : undefined + } + modelPicker={ + + } + tools={ + mode === "chat" ? ( <> - {(() => { - const provider = getProviderFromModelName(selectedModel); - const { logo } = provider ? getProviderLogoAndName(provider) : { logo: "" }; - return logo ? ( - { - (e.currentTarget as HTMLImageElement).style.display = "none"; - }} + + + + + + Tools + {selectedMCPServers.length > 0 && ( + {selectedMCPServers.length} + )} + + } + /> + + - ) : null; - })()} - {selectedModel} + + ) : ( - Select model - )} - - - } - /> - - {modelSelectorContent} - - - ); - - const inputBar = (inConversation: boolean) => ( -
-