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
This commit is contained in:
Ishaan 2026-10-23 16:39:23 -07:00 • committed by GitHub
parent 23ccff69a1
commit ca64a3bef2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 6165 additions and 1382 deletions

View file

@ -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

View file

@ -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

View file

@ -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(
<ChatLayout>
<h1>Chat content</h1>
</ChatLayout>,
);
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(
<ChatLayout>
<h1>Chat content</h1>
</ChatLayout>,
);
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();
}
},
);
});

View file

@ -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: () => <div data-testid="navbar" /> }));
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 }) => <div data-testid="chat-shell">{children}</div>,
}));
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(
<ChatLayout>
@ -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(
<ChatLayout>
@ -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(
<ChatLayout>
@ -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(<ChatLayout>Chat content</ChatLayout>);
act(() => fireEvent.focus(window));
expect(recordChatQuery).not.toHaveBeenCalled();
visibility.mockReturnValue("visible");
act(() => fireEvent(document, new Event("visibilitychange")));
expect(recordChatQuery).not.toHaveBeenCalled();
view.rerender(<ChatLayout>Another Chat page</ChatLayout>);
act(() => fireEvent.focus(window));
view.unmount();
render(<ChatLayout>Chat revisited</ChatLayout>);
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(<ChatLayout>Chat content</ChatLayout>);
expect(recordChatQuery).not.toHaveBeenCalled();
state.userId = "user-2";
view.rerender(<ChatLayout>Chat content</ChatLayout>);
expect(screen.getByTestId("chat-shell")).toBeInTheDocument();
state.userId = null;
state.isAuthorized = false;
view.rerender(<ChatLayout>Chat content</ChatLayout>);
act(() => fireEvent.focus(window));
expect(recordChatQuery).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
});

View file

@ -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 (
<ThemeProvider accessToken={accessToken}>
<div className="flex h-screen flex-col">
<Navbar accessToken={accessToken} isPublicPage={false} />
<link rel="preconnect" href="https://api.fontshare.com" />
<link rel="preconnect" href="https://cdn.fontshare.com" crossOrigin="anonymous" />
<link rel="stylesheet" href="https://api.fontshare.com/v2/css?f[]=general-sans@1&display=swap" />
<div className="flex h-dvh flex-col">
<div className="min-h-0 flex-1">
<ChatShellProvider
accessToken={accessToken ?? ""}
@ -47,7 +51,9 @@ function ChatLayoutContent({ children }: { children: React.ReactNode }) {
userRole={userRole ?? ""}
premiumUser={premiumUser ?? false}
>
<ChatShell>{children}</ChatShell>
<PluginModeProvider accessToken={accessToken}>
<ChatShell>{children}</ChatShell>
</PluginModeProvider>
</ChatShellProvider>
</div>
</div>

View file

@ -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<typeof import("@/components/networking")>();
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<string, string[]> = {
[`${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(<ChatModelsPage />);
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(<ChatModelsPage />);
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();
});
});

View file

@ -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 (
<div className="moyai-models-page">
<div className="moyai-models-content">
<header className="moyai-models-header">
<p className="text-xs font-medium text-muted-foreground">Model Hub</p>
<h1>Models</h1>
<p className="text-sm text-muted-foreground">
Find the right model. Explore providers, compare pricing, and see what each model can do.
</p>
</header>
{isAdminRole(userRole) ? (
<ModelHubTable
accessToken={accessToken}
publicPage={false}
premiumUser={premiumUser}
userRole={userRole}
canOpenPlayground={false}
canAddModels={false}
variant="chat"
/>
) : (
<PublicModelHub accessToken={accessToken} isEmbedded variant="chat" />
)}
</div>
</div>
);
}

View file

@ -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<string, unknown> };
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(
<ChatShellProvider
accessToken="sk-media-test"
userId="media-test-user"
userEmail="researcher@example.com"
userRole="Internal Viewer"
premiumUser={false}
>
<ChatConversationPage />
</ChatShellProvider>,
);
}
async function uploadImage(user: ReturnType<typeof userEvent.setup>, 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<typeof userEvent.setup>, 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<Response>((_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<void>(() => {}));
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");
});
});

View file

@ -72,7 +72,7 @@ const ON_TOTAL_LATENCY_INDEX = 24;
async function sendOneMessage(onUrlUpdate?: OnUrlUpdateFunction): Promise<void> {
renderWithProviders(<ChatConversationPage />, { 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(<ChatConversationPage />);
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");

View file

@ -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<ChatMessage, "role" | "content" | "attachments" | "generatedImages">;
// 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<string | null>(null);
const [models, setModels] = useState<string[]>([]);
const [models, setModels] = useState<ModelGroup[]>([]);
const [mode, setMode] = useState<ChatMode>("chat");
const [attachments, setAttachments] = useState<ChatImageAttachment[]>([]);
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [isReadingImages, setIsReadingImages] = useState(false);
const imageInputRef = useRef<HTMLInputElement>(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<string | null>(null);
const [prevConversationIdForSessionReset, setPrevConversationIdForSessionReset] = useState(activeConversationId);
@ -83,7 +66,7 @@ export default function ChatConversationPage() {
const [storageBannerDismissed, setStorageBannerDismissed] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const requestConversationRef = useRef<string | null>(null);
const messagesScrollRef = useRef<HTMLDivElement>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const streamScrollLock = useRef<number | null>(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<HTMLInputElement>) => {
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<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend(inputText);
}
};
// Auto-resize textarea
useEffect(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.style.height = "auto";
ta.style.height = `${Math.min(ta.scrollHeight, 180)}px`;
}, [inputText]);
// 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 = (
<div className="w-[280px] 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="h-8 text-[13px]"
/>
</div>
<ScrollArea className="flex-1 h-0">
{filteredModels.map((m) => {
const checked = m === selectedModel;
const provider = getProviderFromModelName(m);
const { logo } = provider ? getProviderLogoAndName(provider) : { logo: "" };
return (
<Button
key={m}
variant="ghost"
onClick={() => selectModel(m)}
className={`h-auto w-full justify-start gap-2 rounded px-3 py-[7px] font-normal ${checked ? "bg-accent" : ""}`}
>
{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="flex-1 text-left text-[13px] text-foreground overflow-hidden text-ellipsis whitespace-nowrap">
{m}
</span>
{checked && <Check className="h-3.5 w-3.5 text-primary shrink-0" />}
</Button>
);
})}
</ScrollArea>
</div>
);
const modelSelectorTrigger = isLoadingModels ? (
<Skeleton className="w-40 h-8" />
) : (
<Popover
open={modelSelectorOpen}
onOpenChange={(open) => {
setModelSelectorOpen(open);
if (!open) setModelSearchText("");
}}
>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="max-w-[240px] justify-start gap-1.5 overflow-hidden">
{selectedModel ? (
const inputBar = (inConversation: boolean) => {
const preparing = isLoadingModels || isReadingImages;
const submitDisabled = !hasMessageContent(inputText, attachments, mode) || !selectedModel || preparing;
return (
<div
onDragOver={(event) => {
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));
}
}}
>
<MoyaiChatComposer
inConversation={inConversation}
value={inputText}
onChange={setInputText}
onSubmit={() => 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 ? (
<div className="flex w-full flex-wrap gap-3 px-4 pt-4" aria-label="Attached images">
{attachments.map((image) => (
<div key={image.id} className="relative w-20">
<img src={image.url} alt={image.name} className="h-20 w-20 rounded-xl border object-cover" />
<Button
type="button"
variant="secondary"
size="icon-xs"
aria-label={`Remove ${image.name}`}
className="absolute -right-2 -top-2 rounded-full"
disabled={isStreaming}
onClick={() => setAttachments((current) => current.filter((item) => item.id !== image.id))}
>
<X className="size-3" />
</Button>
<p className="mt-1 truncate text-[11px] text-muted-foreground">{image.name}</p>
</div>
))}
</div>
) : undefined
}
modelPicker={
<ChatModelPicker
models={models}
selectedModel={selectedModel}
loading={isLoadingModels}
disabled={isStreaming || isReadingImages}
onSelect={selectModel}
/>
}
tools={
mode === "chat" ? (
<>
{(() => {
const provider = getProviderFromModelName(selectedModel);
const { logo } = provider ? getProviderLogoAndName(provider) : { logo: "" };
return logo ? (
<img
src={logo}
alt=""
className="w-4 h-4 object-contain shrink-0"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
<input
ref={imageInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
multiple
aria-label="Upload images"
className="sr-only"
disabled={isStreaming || isReadingImages}
onChange={handleImageInput}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Attach images"
title="Attach images"
disabled={isStreaming || isReadingImages || attachments.length >= MAX_CHAT_IMAGES}
onClick={() => imageInputRef.current?.click()}
>
<Paperclip className="size-4" />
</Button>
<Popover open={mcpPopoverOpen} onOpenChange={setMcpPopoverOpen}>
<PopoverTrigger
render={
<Button
variant="ghost"
size="sm"
disabled={isStreaming}
aria-label="Connect tools"
className="gap-1.5 text-muted-foreground"
>
<Plus className="size-3.5" />
<span>Tools</span>
{selectedMCPServers.length > 0 && (
<span className="text-primary">{selectedMCPServers.length}</span>
)}
</Button>
}
/>
<PopoverContent side="top" align="start" className="p-0 w-auto">
<MCPConnectPicker
accessToken={accessToken}
selectedServers={selectedMCPServers}
onChange={setSelectedMCPServers}
/>
) : null;
})()}
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{selectedModel}</span>
</PopoverContent>
</Popover>
</>
) : (
<span className="text-muted-foreground">Select model</span>
)}
<ChevronDown className="h-3 w-3 text-muted-foreground shrink-0" />
</Button>
}
/>
<PopoverContent align="start" side="top" 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" }}
>
<div className="flex items-center gap-2 min-w-0">
{modelSelectorTrigger}
<Popover open={mcpPopoverOpen} onOpenChange={setMcpPopoverOpen}>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="gap-1 px-2.5 text-muted-foreground">
<Plus className="h-3.5 w-3.5" />
{selectedMCPServers.length > 0 && (
<span className="text-xs text-primary font-medium">{selectedMCPServers.length}</span>
)}
</Button>
}
/>
<PopoverContent side="top" align="start" className="p-0 w-auto">
<MCPConnectPicker
accessToken={accessToken}
selectedServers={selectedMCPServers}
onChange={setSelectedMCPServers}
/>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center gap-2">
{inConversation && selectedMCPServers.length > 0 && (
<span className="text-xs text-muted-foreground max-w-[160px] overflow-hidden text-ellipsis whitespace-nowrap">
{selectedMCPServers.length} tool{selectedMCPServers.length > 1 ? "s" : ""} connected
</span>
)}
{isStreaming ? (
<Button variant="outline" size="icon-sm" onClick={handleStop} className="rounded-full shrink-0">
<div className="w-2.5 h-2.5 bg-foreground rounded-[2px]" />
</Button>
) : (
<Button
size="sm"
onClick={() => handleSend(inputText)}
disabled={!inputText.trim() || isLoadingModels || !selectedModel}
>
Send
</Button>
)}
</div>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<ImageIcon className="size-4" />
Create an image from your description
</span>
)
}
/>
{isReadingImages && (
<p role="status" className="mt-2 text-xs text-muted-foreground">
Preparing images…
</p>
)}
{attachmentError && (
<p role="alert" className="mt-2 text-sm text-destructive">
{attachmentError}
</p>
)}
{!isLoadingModels && !selectedModel && (
<p className="mt-2 text-sm text-muted-foreground">
{mode === "image"
? "No image-generation models are available. Ask your admin to add one to Model Hub."
: "No chat models are available for your account."}
</p>
)}
<p className="moyai-chat-home-composer-hint">
{mode === "image" ? "Describe an image · Enter to generate" : "Drop files or paste images · Enter to send"}
</p>
</div>
</div>
);
);
};
return (
<>
{storageUnavailable && !storageBannerDismissed && (
<div className="bg-warning/10 border-b border-warning/20 px-5 py-1.5 text-[13px] text-warning flex justify-between items-center">
<span>Chat history won&apos;t be saved in this browser session</span>
<span>Some chat history or images could not be saved or restored.</span>
<Button
variant="ghost"
size="icon-xs"
@ -528,74 +518,55 @@ export default function ChatConversationPage() {
</div>
)}
<div className="flex-1 min-h-0 overflow-hidden flex flex-col bg-background">
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
{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">
{greeting}
</h1>
<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
variant="link"
onClick={() => router.push(getChatRoutes().integrations)}
className="h-auto p-0 text-sm font-medium"
>
Open Integrations -&gt;
</Button>
</p>
<div className="w-full max-w-[680px]">{inputBar(false)}</div>
<div className="flex gap-2 mt-3.5 flex-wrap justify-center">
{SUGGESTIONS.map((s) => (
<Button
key={s}
variant="outline"
size="sm"
onClick={() => setInputText(s + ": ")}
className="rounded-full px-4 text-muted-foreground"
>
{s}
</Button>
))}
</div>
</div>
<MoyaiChatHome
imageMode={mode === "image"}
draft={inputText}
busy={isStreaming}
conversations={conversations}
onSelectConversation={(id) => {
void setConversationIdInUrl(id);
}}
onPrompt={setInputText}
>
{inputBar(false)}
</MoyaiChatHome>
) : (
<div className="flex-1 min-h-0 flex flex-col mx-auto w-full px-6 relative" style={{ maxWidth: 760 }}>
<div
ref={messagesScrollRef}
className="flex-1 min-h-0 overflow-auto pt-6"
style={{ overflowAnchor: "none" }}
>
<ChatMessages
messages={activeConversation!.messages}
isStreaming={isStreaming}
onEditMessage={handleEditAndResend}
/>
</div>
{showScrollButton && (
<Button
variant="ghost"
size="icon"
onClick={() => {
const el = messagesScrollRef.current;
if (el) {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
if (streamScrollLock.current !== null) {
streamScrollLock.current = el.scrollHeight;
<div className="moyai-chat-conversation">
<header className="moyai-chat-conversation-header">
<h1>{activeConversation!.title}</h1>
</header>
<div className="moyai-chat-transcript-layout">
<div ref={messagesScrollRef} className="moyai-chat-transcript" style={{ overflowAnchor: "none" }}>
<ChatMessages
messages={activeConversation!.messages}
isStreaming={isStreaming}
onEditMessage={handleEditAndResend}
/>
</div>
{showScrollButton && (
<Button
variant="ghost"
size="icon"
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 z-chrome rounded-full border bg-background/75 text-muted-foreground shadow-sm backdrop-blur-md hover:bg-background/95"
aria-label="Scroll to bottom"
>
<ChevronDown className="h-3 w-3" />
</Button>
)}
}}
className="absolute bottom-[100px] left-1/2 -translate-x-1/2 z-chrome rounded-full border bg-background/75 text-muted-foreground shadow-sm backdrop-blur-md 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 className="moyai-chat-composer-wrap">{inputBar(true)}</div>
</div>
</div>
)}
</div>

View file

@ -43,8 +43,12 @@ describe("chat page URL state with ChatShellProvider", () => {
mockMakeOpenAIResponsesRequest.mockReset();
});
it("keeps the active conversation in sync with the URL across the first send and back navigation", async () => {
mockMakeOpenAIResponsesRequest.mockResolvedValue(undefined);
it("should keep the active conversation in sync with the URL across the first send and back navigation", async () => {
mockMakeOpenAIResponsesRequest.mockImplementation(
async (_messages, onChunk: (role: string, chunk: string) => void) => {
onChunk("assistant", "The estimated cost is one cent.");
},
);
render(
<NuqsAdapter>
@ -61,15 +65,20 @@ describe("chat page URL state with ChatShellProvider", () => {
);
expect(await screen.findByRole("button", { name: /gpt-5\.4-mini/ })).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("How can I help you today?"), {
target: { value: "How much did this cost?" },
act(() => {
fireEvent.change(screen.getByPlaceholderText("Describe a change, paste an error, or ask a question…"), {
target: { value: "How much did this cost?" },
});
});
act(() => {
fireEvent.click(screen.getByRole("button", { name: "Send" }));
});
fireEvent.click(screen.getByRole("button", { name: "Send" }));
await waitFor(() => expect(window.location.search).toMatch(/^\?id=/));
const conversationId = new URLSearchParams(window.location.search).get("id");
expect(conversationId).toBeTruthy();
expect(await screen.findByText("How much did this cost?")).toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 1, name: "How much did this cost?" })).toBeInTheDocument();
expect(await screen.findByText("The estimated cost is one cent.")).toBeInTheDocument();
await waitFor(() =>
expect(localStorage.getItem("litellm_chat_history_v1:url-test-user")).toContain(conversationId),
);
@ -77,7 +86,12 @@ describe("chat page URL state with ChatShellProvider", () => {
act(() => window.history.back());
await waitFor(() => expect(window.location.search).toBe(""));
expect(await screen.findByPlaceholderText("How can I help you today?")).toBeInTheDocument();
expect(screen.queryByText("How much did this cost?")).not.toBeInTheDocument();
expect(
await screen.findByPlaceholderText("Describe a change, paste an error, or ask a question…"),
).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 1, name: "What are we building?" })).toBeInTheDocument();
expect(screen.queryByText("The estimated cost is one cent.")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "How much did this cost?" })).toBeInTheDocument();
expect(window.location.pathname).toBe("/chat");
});
});

View file

@ -478,6 +478,381 @@
border: 1px solid var(--neutral-border);
}
/* Standalone Chat only. Moyai's semantic glass treatment (frontend/theme.css,
1118ab0) is adapted to the existing LiteLLM controls and theme switch. */
.chat-theme {
--background: #ffffff;
--foreground: #000000;
--card: var(--background);
--card-foreground: var(--foreground);
--popover: var(--background);
--popover-foreground: var(--foreground);
--primary: #7145b8;
--primary-foreground: #ffffff;
--secondary: #f0f0f2;
--secondary-foreground: var(--foreground);
--muted: var(--secondary);
--muted-foreground: #4b4b50;
--accent: var(--secondary);
--accent-foreground: var(--foreground);
--border: #e2e2e5;
--input: #d2d2d7;
--ring: #767680;
--sidebar: #f8f8f8;
--sidebar-foreground: var(--foreground);
--sidebar-accent: #eeeaf4;
--sidebar-accent-foreground: var(--foreground);
--sidebar-border: var(--border);
--chat-canvas: #ededf0;
--chat-shell: #ffffff;
--chat-message: #f5f5f6;
--chat-message-border: #e2e2e5;
--chat-home-editor-border: #d2d2d7;
--chat-focus-border: #767680;
--chat-prompt-muted: var(--muted-foreground);
--chat-reading: var(--background);
--chat-glass-surface: var(--background);
--chat-glass-chrome: var(--sidebar);
--chat-glass-elevated: var(--background);
--chat-glass-line: var(--border);
--chat-glass-sheen: none;
--chat-glass-blur: none;
--chat-glass-inset: inset 0 0 0 transparent;
--chat-glass-shadow: 0 8px 24px rgb(36 36 38 / 6%);
--chat-backdrop: none;
}
.dark .chat-theme {
--background: #181818;
--foreground: #ededed;
--primary: #b69af7;
--primary-foreground: #17131f;
--secondary: #222225;
--muted-foreground: #aeb0b5;
--border: #2b2b2f;
--input: #3c3c42;
--ring: #77777e;
--sidebar: #151515;
--sidebar-accent: #292430;
--chat-canvas: #0b0b0b;
--chat-shell: #111111;
--chat-message: #1c1c1e;
--chat-message-border: #303034;
--chat-home-editor-border: #3c3c42;
--chat-focus-border: #77777e;
--chat-prompt-muted: #a3a3a8;
--chat-glass-shadow: 0 8px 24px rgb(0 0 0 / 15%);
}
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
.chat-theme {
--chat-shell: rgb(255 255 255 / 38%);
--chat-reading: rgb(255 255 255 / 80%);
--chat-glass-surface: rgb(255 255 255 / 20%);
--chat-glass-chrome: rgb(255 255 255 / 40%);
--chat-glass-elevated: rgb(255 255 255 / 92%);
--chat-glass-line: rgb(73 77 89 / 14%);
--chat-glass-sheen: linear-gradient(125deg, rgb(255 255 255 / 24%), transparent 45%, rgb(255 255 255 / 4%));
--chat-glass-blur: blur(14px) saturate(110%);
--chat-glass-inset: inset 0 1px 0 rgb(255 255 255 / 75%), inset 1px 0 0 rgb(255 255 255 / 40%);
--chat-glass-shadow: var(--chat-glass-inset), 0 12px 32px rgb(41 45 58 / 10%);
--chat-backdrop: radial-gradient(ellipse at 3% 0%, #b3bac6 0%, transparent 56%),
radial-gradient(ellipse at 100% 100%, #c5cad4 0%, transparent 58%);
}
.dark .chat-theme {
--chat-shell: rgb(17 17 19 / 46%);
--chat-reading: rgb(17 17 19 / 90%);
--chat-glass-surface: rgb(28 28 31 / 32%);
--chat-glass-chrome: rgb(27 27 30 / 42%);
--chat-glass-elevated: rgb(27 27 30 / 92%);
--chat-glass-line: rgb(255 255 255 / 16%);
--chat-glass-sheen: linear-gradient(125deg, rgb(255 255 255 / 8%), transparent 45%, rgb(255 255 255 / 2%));
--chat-glass-inset: inset 0 1px 0 rgb(255 255 255 / 16%), inset 1px 0 0 rgb(255 255 255 / 4%);
--chat-glass-shadow: var(--chat-glass-inset), 0 12px 32px rgb(0 0 0 / 24%);
--chat-backdrop: radial-gradient(ellipse at 4% 0%, #1b1c1f 0%, transparent 52%),
radial-gradient(ellipse at 100% 100%, #151619 0%, transparent 58%);
}
}
.chat-workspace {
background: var(--chat-canvas);
background-image: linear-gradient(var(--chat-shell), var(--chat-shell)), var(--chat-backdrop);
font-family: var(--font-moyai-chat, ui-sans-serif, system-ui, sans-serif);
font-size: 13px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.chat-theme .chat-glass-pane:not(:has(.moyai-chat-home)) {
background: var(--chat-reading);
}
.chat-theme .chat-glass-sidebar,
.chat-theme.chat-mobile-sidebar {
background: var(--chat-glass-surface);
background-image: var(--chat-glass-sheen);
border-color: var(--chat-glass-line);
-webkit-backdrop-filter: var(--chat-glass-blur);
backdrop-filter: var(--chat-glass-blur);
box-shadow: var(--chat-glass-inset);
}
.chat-theme.chat-mobile-sidebar {
width: min(320px, calc(100vw - 32px));
background-color: var(--chat-glass-elevated);
}
.chat-theme .chat-nav-item,
.chat-theme .chat-new-conversation {
font-size: 13px;
border-radius: 8px;
}
/* Moyai main.tsx / brand.css / sidebar-layout.css at 1118ab0.
Keep the rail geometry local to standalone Chat. */
.moyai-chat-shell {
position: relative;
}
.chat-theme .moyai-chat-sidebar,
.chat-theme.moyai-chat-sidebar {
display: flex;
flex: 0 0 240px;
flex-direction: column;
width: 240px;
min-height: 0;
gap: 2px;
padding: 8px 10px;
overflow: hidden;
border-right: 1px solid var(--chat-glass-line);
}
.moyai-chat-brand-row {
display: flex;
align-items: center;
flex-shrink: 0;
min-height: 40px;
gap: 4px;
}
.chat-theme .moyai-chat-workspace-switcher {
min-width: 0;
height: 36px;
flex: 1;
justify-content: flex-start;
gap: 8px;
padding: 0 6px;
border-color: transparent;
background: transparent;
color: var(--sidebar-foreground);
box-shadow: none;
font-size: 15px;
line-height: 1.25;
font-weight: 550;
letter-spacing: -0.02em;
}
.chat-theme .moyai-chat-workspace-switcher:hover,
.chat-theme .moyai-chat-workspace-switcher[data-popup-open] {
background: var(--sidebar-accent);
color: var(--sidebar-accent-foreground);
}
.moyai-chat-workspace-switcher [data-slot="workspace-icon"] {
width: 28px;
height: 28px;
}
.moyai-chat-workspace-switcher [data-slot="workspace-label"] {
flex: 1;
text-align: left;
}
.moyai-chat-mobile-header .moyai-chat-workspace-switcher {
flex: none;
}
.moyai-chat-brand-row > [data-slot="button"]:not(.moyai-chat-workspace-switcher) {
width: 28px;
height: 28px;
color: var(--foreground);
}
.chat-theme .moyai-chat-rail-button {
height: 32px;
min-height: 32px;
flex-shrink: 0;
gap: 9px;
padding: 0 10px;
border-radius: 6px;
font-size: 13px;
font-weight: 400;
}
.moyai-chat-destinations {
display: flex;
flex-shrink: 0;
flex-direction: column;
gap: 2px;
margin-bottom: 6px;
}
.moyai-chat-destinations svg,
.moyai-chat-sidebar > .chat-new-conversation svg {
width: 15px;
height: 15px;
}
.moyai-chat-sidebar .moyai-chat-rail-button:hover,
.moyai-chat-history .chat-conversation-row:hover {
background: var(--secondary);
color: var(--foreground);
}
.moyai-chat-history .moyai-chat-history-heading {
height: 28px;
padding: 0 10px;
font-size: 12px;
font-weight: 500;
}
.moyai-chat-history .chat-conversation-row {
min-height: 48px;
border-radius: 6px;
}
.moyai-chat-history .chat-conversation-row > [data-slot="button"] {
height: auto;
min-height: 48px;
padding: 4px 10px;
border-radius: 6px;
font-weight: 400;
line-height: 20px;
}
.moyai-chat-empty-history {
padding: 2px 10px 6px 25px;
font-size: 12px;
line-height: 18px;
}
.moyai-chat-conversation-meta {
line-height: 18px;
}
.moyai-chat-account {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 4px;
padding: 8px 4px 0;
border-top: 1px solid var(--sidebar-border);
}
.moyai-chat-account > [data-slot="button"] {
flex: none;
width: 32px;
height: 32px;
min-height: 32px;
padding: 0;
color: var(--foreground);
}
.chat-theme.moyai-chat-collapsed .moyai-chat-sidebar {
flex-basis: 52px;
width: 52px;
padding: 12px 8px 8px;
}
.moyai-chat-collapsed .moyai-chat-sidebar > :not(.moyai-chat-brand-row),
.moyai-chat-collapsed [data-slot="workspace-label"],
.moyai-chat-collapsed [data-slot="workspace-chevron"],
.moyai-chat-collapsed .moyai-chat-search {
display: none;
}
.moyai-chat-collapsed .moyai-chat-brand-row {
flex-direction: column;
justify-content: center;
gap: 8px;
}
.moyai-chat-collapsed .moyai-chat-workspace-switcher {
flex: none;
width: 36px;
padding: 4px;
}
@media (min-width: 601px) and (max-width: 1250px) {
.chat-theme .moyai-chat-sidebar {
width: 230px;
flex-basis: 230px;
}
}
@media (max-width: 600px) {
.moyai-chat-shell {
flex-direction: column;
}
.chat-theme.moyai-chat-sidebar {
width: calc(100vw - 56px);
}
.chat-theme .moyai-chat-rail-button,
.chat-theme .moyai-chat-workspace-switcher,
.moyai-chat-brand-row > [data-slot="button"]:not(.moyai-chat-workspace-switcher),
.moyai-chat-account [data-slot="button"] {
min-height: 44px;
}
.moyai-chat-brand-row > [data-slot="button"]:not(.moyai-chat-workspace-switcher),
.moyai-chat-account [data-slot="button"] {
width: 44px;
}
}
@media (hover: none) {
.chat-theme .chat-conversation-actions {
opacity: 1;
}
}
@media (max-width: 600px) {
.chat-theme .chat-nav-item,
.chat-theme .chat-new-conversation,
.chat-theme .chat-conversation-row > [data-slot="button"] {
min-height: 44px;
}
}
@media (prefers-reduced-transparency: reduce), (forced-colors: active) {
.chat-theme,
.dark .chat-theme {
--chat-shell: var(--background);
--chat-reading: var(--background);
--chat-glass-surface: var(--background);
--chat-glass-chrome: var(--sidebar);
--chat-glass-elevated: var(--background);
--chat-glass-line: var(--border);
--chat-glass-sheen: none;
--chat-glass-blur: none;
--chat-glass-inset: inset 0 0 0 transparent;
--chat-glass-shadow: none;
--chat-backdrop: none;
}
}
@media (prefers-reduced-motion: reduce) {
.chat-theme [data-slot="button"],
.chat-theme .chat-conversation-row,
.chat-theme .chat-conversation-actions,
.chat-theme.chat-mobile-sidebar {
transition: none;
}
}
/* A dialog opened from inside another dialog reads as a drill-down, not a stack: base-ui stamps
this attribute on the parent while a child is open, so the parent steps aside instead of
showing its own edges around a differently sized child. */

View file

@ -0,0 +1,85 @@
import { useState } from "react";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { chooseSelectOption } from "@/../tests/test-utils";
import ModelHubProviders, { ProviderLogo } from "./ModelHubProviders";
function ProviderFilter({ initialProvider = "" }: { initialProvider?: string }) {
const [provider, setProvider] = useState<string | null>(initialProvider);
return (
<ModelHubProviders
providers={["openai", "anthropic"]}
selectedProviders={provider ? [provider] : []}
onSelect={setProvider}
/>
);
}
describe("ModelHubProviders", () => {
it("should render the selected provider and its decorative logo in the dropdown", () => {
render(<ProviderFilter initialProvider="anthropic" />);
const dropdown = screen.getByRole("combobox", { name: "Provider" });
expect(dropdown).toHaveTextContent("Anthropic");
expect(within(dropdown).getByAltText("")).toHaveAttribute("src", expect.stringContaining("anthropic.svg"));
});
it("should select a provider and reset the filter with All providers", async () => {
const user = userEvent.setup();
render(<ProviderFilter />);
const dropdown = screen.getByRole("combobox", { name: "Provider" });
expect(dropdown).toHaveTextContent("All providers");
await chooseSelectOption(user, dropdown, "OpenAI");
expect(dropdown).toHaveTextContent("OpenAI");
await chooseSelectOption(user, dropdown, "All providers");
expect(dropdown).toHaveTextContent("All providers");
expect(within(dropdown).queryByAltText("")).not.toBeInTheDocument();
});
it("should disable the dropdown until the initial provider list arrives", () => {
render(<ModelHubProviders providers={[]} selectedProviders={[]} onSelect={() => undefined} loading />);
const dropdown = screen.getByRole("combobox", { name: "Provider" });
expect(dropdown).toBeDisabled();
expect(dropdown).toHaveTextContent("Loading providers");
});
it("should let keyboard users open the dropdown and select a provider", async () => {
const user = userEvent.setup();
render(<ProviderFilter />);
const dropdown = screen.getByRole("combobox", { name: "Provider" });
await user.tab();
expect(dropdown).toHaveFocus();
await user.keyboard("{ArrowDown}");
expect(await screen.findByRole("option", { name: "Anthropic" })).toBeInTheDocument();
await user.keyboard("{End}{Enter}");
expect(dropdown).toHaveTextContent("Anthropic");
expect(dropdown).toHaveFocus();
});
});
describe("ProviderLogo", () => {
it("should show a provider initial when its logo is unknown", () => {
render(<ProviderLogo provider="local-models" />);
expect(screen.getByText("L")).toBeInTheDocument();
expect(screen.queryByAltText("")).not.toBeInTheDocument();
});
it("should fall back after a broken logo and load a different provider's logo", () => {
const { rerender } = render(<ProviderLogo provider="openai" />);
act(() => fireEvent.error(screen.getByAltText("")));
expect(screen.getByText("O")).toBeInTheDocument();
expect(screen.queryByAltText("")).not.toBeInTheDocument();
rerender(<ProviderLogo provider="anthropic" />);
expect(screen.getByAltText("")).toHaveAttribute("src", expect.stringContaining("anthropic.svg"));
expect(screen.queryByText("O")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,103 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/cva.config";
import { displayMode } from "@/lib/decisionModels";
export function formatModelModeLabel(mode: string | undefined): string {
const label = (displayMode(mode) ?? "chat").replaceAll("_", " ");
return label.charAt(0).toUpperCase() + label.slice(1);
}
interface ProviderLogoProps {
provider: string;
className?: string;
}
export function ProviderLogo({ provider, className }: ProviderLogoProps) {
const { logo, displayName } = getProviderLogoAndName(provider);
const [failedLogo, setFailedLogo] = useState<string | null>(null);
const showLogo = logo && failedLogo !== logo;
return (
<span aria-hidden="true" className={cn("chat-model-provider-logo", className)}>
{showLogo ? (
<Image
src={logo}
alt=""
width={32}
height={32}
unoptimized
className="h-full w-full object-contain"
onError={() => setFailedLogo(logo)}
/>
) : (
<span className="text-xs font-medium">{displayName.trim().charAt(0).toUpperCase() || "?"}</span>
)}
</span>
);
}
interface ModelHubProvidersProps {
providers: readonly string[];
selectedProviders: readonly string[];
onSelect: (provider: string | null) => void;
loading?: boolean;
}
export default function ModelHubProviders({
providers,
selectedProviders,
onSelect,
loading = false,
}: ModelHubProvidersProps) {
const availableProviders = Array.from(new Set(providers)).filter(Boolean);
const selectedProvider = selectedProviders[0] ?? "";
const initialLoading = loading && availableProviders.length === 0;
const emptyLabel = initialLoading ? "Loading providers" : "All providers";
const items = [
{ value: "", label: "All providers" },
...availableProviders.map((provider) => ({
value: provider,
label: getProviderLogoAndName(provider).displayName,
})),
];
return (
<Select
value={selectedProvider}
items={items}
onValueChange={(provider) => onSelect(provider || null)}
disabled={initialLoading}
>
<SelectTrigger
aria-label="Provider"
aria-busy={loading}
className="chat-model-filter-select chat-model-provider-select"
>
<SelectValue>
{selectedProvider ? (
<span className="flex min-w-0 items-center gap-2">
<ProviderLogo provider={selectedProvider} className="chat-model-provider-logo-option" />
<span className="truncate">{getProviderLogoAndName(selectedProvider).displayName}</span>
</span>
) : (
emptyLabel
)}
</SelectValue>
</SelectTrigger>
<SelectContent className="chat-theme" align="start">
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.value && <ProviderLogo provider={item.value} className="chat-model-provider-logo-option" />}
<span>{item.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View file

@ -229,6 +229,107 @@ describe("ModelHubTable", () => {
expect(networking.modelHubCall).not.toHaveBeenCalled();
});
it("should limit the chat catalog to browsing models even for an admin", async () => {
vi.mocked(networking.modelHubCall).mockResolvedValue({
data: [{ model_group: "chat-model", providers: ["openai"], mode: "chat" }],
});
mockUseUISettings.mockReturnValue({ data: { values: {} }, isLoading: false });
renderWithProviders(
<ModelHubTable
accessToken="test-token"
publicPage={false}
premiumUser
userRole="Admin"
canOpenPlayground
canAddModels
variant="chat"
/>,
);
expect(await screen.findByRole("button", { name: /^chat-model/ })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Search models" })).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "AI Hub" })).not.toBeInTheDocument();
expect(screen.queryByText("Model Hub URL:")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Select Models to Make Public" })).not.toBeInTheDocument();
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Add model" })).not.toBeInTheDocument();
expect(screen.queryByRole("option", { name: "decisions" })).not.toBeInTheDocument();
expect(screen.queryByTestId("decision-model-garden")).not.toBeInTheDocument();
expect(networking.modelHubCall).toHaveBeenCalledWith("test-token");
expect(networking.getConfigFieldSetting).not.toHaveBeenCalled();
expect(networking.getAgentsList).not.toHaveBeenCalled();
expect(networking.fetchMCPServers).not.toHaveBeenCalled();
expect(networking.getClaudeCodePluginsList).not.toHaveBeenCalled();
expect(networking.getPublicModelHubInfo).not.toHaveBeenCalled();
expect(networking.modelCostMap).not.toHaveBeenCalled();
});
it("should combine provider selection with other filters and clear only the requested filters", async () => {
vi.mocked(networking.modelHubCall).mockResolvedValue({
data: [
{ model_group: "openai-shared", providers: ["openai"], mode: "chat", supports_vision: true },
{ model_group: "anthropic-shared", providers: ["anthropic"], mode: "chat", supports_vision: true },
{ model_group: "openai-image", providers: ["openai"], mode: "image_generation" },
],
});
mockUseUISettings.mockReturnValue({ data: { values: {} }, isLoading: false });
const user = userEvent.setup();
renderWithProviders(
<ModelHubTable
accessToken="test-token"
publicPage={false}
premiumUser={false}
userRole="Admin"
canOpenPlayground={false}
canAddModels={false}
variant="chat"
/>,
);
await screen.findByRole("button", { name: /^openai-shared/ });
expect(screen.queryByDisplayValue("All Providers")).not.toBeInTheDocument();
const provider = screen.getByRole("combobox", { name: "Provider" });
const search = screen.getByRole("textbox", { name: "Search models" });
const mode = screen.getByRole("combobox", { name: "Model type" });
const feature = screen.getByRole("combobox", { name: "Model capability" });
await user.type(search, "shared");
await user.click(mode);
await user.click(await screen.findByRole("option", { name: "chat", exact: true }));
await waitFor(() => expect(screen.queryByRole("listbox")).not.toBeInTheDocument());
await user.click(feature);
await user.click(await screen.findByRole("option", { name: "Vision", exact: true }));
await waitFor(() => expect(screen.queryByRole("listbox")).not.toBeInTheDocument());
await user.click(provider);
await user.click(await screen.findByRole("option", { name: "OpenAI", exact: true }));
await waitFor(() => expect(screen.queryByRole("listbox")).not.toBeInTheDocument());
expect(screen.getByRole("button", { name: /^openai-shared/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^anthropic-shared/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^openai-image/ })).not.toBeInTheDocument();
expect(provider).toHaveTextContent("OpenAI");
await user.click(provider);
await user.click(await screen.findByRole("option", { name: "All providers", exact: true }));
await waitFor(() => expect(screen.queryByRole("listbox")).not.toBeInTheDocument());
expect(screen.getByRole("button", { name: /^anthropic-shared/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^openai-image/ })).not.toBeInTheDocument();
expect(search).toHaveValue("shared");
expect(mode).toHaveTextContent("chat");
expect(feature).toHaveTextContent("Vision");
await user.click(provider);
await user.click(await screen.findByRole("option", { name: "Anthropic", exact: true }));
await waitFor(() => expect(screen.queryByRole("listbox")).not.toBeInTheDocument());
await user.click(screen.getByRole("button", { name: "Clear Filters" }));
expect(provider).toHaveTextContent("All providers");
expect(search).toHaveValue("");
expect(mode).toHaveTextContent("All types");
expect(feature).toHaveTextContent("All capabilities");
expect(screen.getByRole("button", { name: /^openai-image/ })).toBeInTheDocument();
expect(screen.getByLabelText("3 models")).toBeInTheDocument();
});
it("should call getUiConfig before modelHubPublicModelsCall when publicPage is true", async () => {
const getUiConfigMock = vi.mocked(networking.getUiConfig);
const modelHubPublicModelsCallMock = vi.mocked(networking.modelHubPublicModelsCall);

View file

@ -63,6 +63,7 @@ interface ModelHubTableProps {
userRole: string | null;
canOpenPlayground: boolean;
canAddModels: boolean;
variant?: "dashboard" | "chat";
}
function isMCPHubVisibilityDisabled(isLoading: boolean, servers: readonly MCPServerData[] | null): boolean {
@ -104,11 +105,13 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
userRole,
canOpenPlayground,
canAddModels,
variant = "dashboard",
}) => {
const syntaxTheme = useSyntaxTheme(prism);
const isChat = variant === "chat";
// Admin Viewer follows the read-parity rule: see the AI Hub catalog, but
// cannot toggle public visibility (write).
const canModify = isProxyAdminRole(userRole || "");
const canModify = !isChat && isProxyAdminRole(userRole || "");
const [publicPageAllowed, setPublicPageAllowed] = useState<boolean>(false);
const [modelHubData, setModelHubData] = useState<ModelHubData[] | null>(null);
@ -117,7 +120,9 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
const [selectedModel, setSelectedModel] = useState<null | ModelHubData>(null);
const [filteredData, setFilteredData] = useState<ModelHubData[]>([]);
const [selectedMode, setSelectedMode] = useState<string>("");
const showDecisionGarden = !publicPage && (selectedMode === "" || selectedMode === DECISIONS_MODE);
const [selectedProvider, setSelectedProvider] = useState("");
const isDashboardCatalog = !isChat && !publicPage;
const showDecisionGarden = isDashboardCatalog && (selectedMode === "" || selectedMode === DECISIONS_MODE);
const [isMakePublicModalVisible, setIsMakePublicModalVisible] = useState(false);
// Agent Hub state
const [agentHubData, setAgentHubData] = useState<AgentHubData[] | null>(null);
@ -168,15 +173,17 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
const _modelHubData = await modelHubCall(accessToken);
setModelHubData(_modelHubData.data);
getConfigFieldSetting(accessToken, "enable_public_model_hub")
.then((data) => {
if (data.field_value == true) {
setPublicPageAllowed(true);
}
})
.catch((error) => {
// do nothing
});
if (!isChat) {
getConfigFieldSetting(accessToken, "enable_public_model_hub")
.then((data) => {
if (data.field_value == true) {
setPublicPageAllowed(true);
}
})
.catch((error) => {
// do nothing
});
}
} catch (error) {
console.error("There was an error fetching the model data", error);
} finally {
@ -208,7 +215,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
}
};
fetchModelData();
}, [accessToken, publicPage]);
}, [accessToken, publicPage, isChat]);
// Fetch Agent Hub data
useEffect(() => {
@ -235,10 +242,10 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
}
};
if (!publicPage) {
if (!publicPage && !isChat) {
fetchAgentData();
}
}, [publicPage, accessToken]);
}, [publicPage, accessToken, isChat]);
// Fetch MCP Hub data
useEffect(() => {
@ -259,10 +266,10 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
}
};
if (!publicPage) {
if (!publicPage && !isChat) {
fetchMcpData();
}
}, [publicPage, accessToken]);
}, [publicPage, accessToken, isChat]);
// Fetch Skill Hub data — all skills for admins, enabled-only for public page
useEffect(() => {
@ -280,8 +287,8 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
}
};
fetchSkillData();
}, [accessToken, publicPage]);
if (!isChat) fetchSkillData();
}, [accessToken, publicPage, isChat]);
const showModal = useCallback((model: ModelHubData) => {
setSelectedModel(model);
@ -426,7 +433,10 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
const [agentSorting, setAgentSorting] = useState<SortingState>([{ id: "name", desc: false }]);
const [mcpSorting, setMcpSorting] = useState<SortingState>([{ id: "server_name", desc: false }]);
const modelColumns = useMemo(() => getModelHubTableColumns({ onModelClick: showModal }), [showModal]);
const modelColumns = useMemo(
() => getModelHubTableColumns({ onModelClick: showModal, showProviderLogos: isChat }),
[showModal, isChat],
);
const agentColumns = useMemo(() => getAgentHubTableColumns({ onAgentClick: showAgentModal }), [showAgentModal]);
const filteredAgentData = useMemo(
() => filterBySearchTerm(agentHubData ?? [], agentSearchTerm, (agent) => [agent.name, agent.description]),
@ -440,37 +450,39 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
}
return (
<div className="mx-4">
<div data-slot="model-hub" className={isChat ? "chat-model-hub-catalog" : "mx-4"}>
{publicPage == false ? (
<div className="w-full m-2 mt-2 p-8">
<div className={isChat ? "chat-model-hub-content" : "w-full m-2 mt-2 p-8"}>
{/* Header with Title, Description and URL */}
<div className="flex justify-between items-center mb-6">
<div className="flex flex-col items-start">
<h2 className="text-center text-xl font-semibold">AI Hub</h2>
{isAdminRole(userRole || "") ? (
<p className="text-sm text-muted-foreground">
Make models, agents, and MCP servers public for developers to know what&apos;s available.
</p>
) : (
<p className="text-sm text-muted-foreground">
A list of all public model names personally available to you.
</p>
)}
</div>
<div className="flex items-center space-x-4">
<p>Model Hub URL:</p>
<div className="flex items-center bg-border px-2 py-1 rounded-sm">
<p className="mr-2">{getUiPageUrl(`${getProxyBaseUrl()}/ui/model_hub_table`)}</p>
<button
onClick={() => void copyToClipboard(getUiPageUrl(`${getProxyBaseUrl()}/ui/model_hub_table`))}
className="p-1 hover:bg-accent rounded-sm transition-colors"
title="Copy URL"
>
<Copy size={16} className="text-muted-foreground" />
</button>
{!isChat && (
<div className="flex justify-between items-center mb-6">
<div className="flex flex-col items-start">
<h2 className="text-center text-xl font-semibold">AI Hub</h2>
{isAdminRole(userRole || "") ? (
<p className="text-sm text-muted-foreground">
Make models, agents, and MCP servers public for developers to know what&apos;s available.
</p>
) : (
<p className="text-sm text-muted-foreground">
A list of all public model names personally available to you.
</p>
)}
</div>
<div className="flex items-center space-x-4">
<p>Model Hub URL:</p>
<div className="flex items-center bg-border px-2 py-1 rounded-sm">
<p className="mr-2">{getUiPageUrl(`${getProxyBaseUrl()}/ui/model_hub_table`)}</p>
<button
onClick={() => void copyToClipboard(getUiPageUrl(`${getProxyBaseUrl()}/ui/model_hub_table`))}
className="p-1 hover:bg-accent rounded-sm transition-colors"
title="Copy URL"
>
<Copy size={16} className="text-muted-foreground" />
</button>
</div>
</div>
</div>
</div>
)}
{/* Useful Links Management Section for Admins */}
{canModify && (
@ -481,26 +493,36 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Tab System for Model Hub, Agent Hub, MCP Hub, and Plugin Marketplace */}
<Tabs defaultValue="models">
<TabsList variant="line" className="mb-4 h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="models" className="flex-none rounded-none px-4 py-2">
Model Hub
</TabsTrigger>
<TabsTrigger value="agents" className="flex-none rounded-none px-4 py-2">
Agent Hub
</TabsTrigger>
<TabsTrigger value="mcp" className="flex-none rounded-none px-4 py-2">
MCP Hub
</TabsTrigger>
<TabsTrigger value="skills" className="flex-none rounded-none px-4 py-2">
Skill Hub
</TabsTrigger>
</TabsList>
{!isChat && (
<TabsList variant="line" className="mb-4 h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="models" className="flex-none rounded-none px-4 py-2">
Model Hub
</TabsTrigger>
<TabsTrigger value="agents" className="flex-none rounded-none px-4 py-2">
Agent Hub
</TabsTrigger>
<TabsTrigger value="mcp" className="flex-none rounded-none px-4 py-2">
MCP Hub
</TabsTrigger>
<TabsTrigger value="skills" className="flex-none rounded-none px-4 py-2">
Skill Hub
</TabsTrigger>
</TabsList>
)}
<div>
{/* Model Hub Tab */}
<TabsContent value="models" keepMounted>
{/* Model Filters and Table */}
<Card className="px-6">
<Card className={isChat ? "chat-model-hub-card" : "px-6"}>
{isChat && (
<div className="chat-model-section-heading">
<h2>Models</h2>
<span className="chat-model-section-count" aria-label={`${filteredData.length} models`}>
{filteredData.length}
</span>
</div>
)}
{/* Header with Make Public Button */}
{publicPage == false && canModify && (
<div className="flex justify-end mb-4">
@ -510,10 +532,15 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Filters */}
<ModelFilters
variant={variant}
modelHubData={modelHubData || []}
className={isChat ? "chat-model-hub-filters" : undefined}
showFiltersCard={!isChat}
onFilteredDataChange={handleFilteredDataChange}
extraModes={publicPage ? [] : [DECISIONS_MODE]}
extraModes={isDashboardCatalog ? [DECISIONS_MODE] : []}
onModeChange={setSelectedMode}
selectedProvider={isChat ? selectedProvider : undefined}
onProviderChange={isChat ? setSelectedProvider : undefined}
/>
{/* Model Table */}
@ -534,11 +561,13 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
/>
</Card>
<div className="mt-4 text-center space-y-2">
<p className="text-sm text-muted-foreground">
Showing {filteredData.length} of {modelHubData?.length || 0} models
</p>
</div>
{!isChat && (
<div className="mt-4 text-center space-y-2">
<p className="text-sm text-muted-foreground">
Showing {filteredData.length} of {modelHubData?.length || 0} models
</p>
</div>
)}
{showDecisionGarden && <DecisionModelGarden accessToken={accessToken} canAddModels={canAddModels} />}
</TabsContent>
@ -686,7 +715,9 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Model Details Modal */}
<Dialog open={isModalVisible} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1000px]">
<DialogContent
className={`${isChat ? "chat-theme chat-model-hub-dialog " : ""}max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1000px]`}
>
<DialogHeader>
<DialogTitle>{selectedModel?.model_group || "Model Details"}</DialogTitle>
</DialogHeader>

View file

@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DataTable } from "@/components/shared/DataTable";
@ -18,11 +18,11 @@ const mockModel: ModelHubData = {
is_public_model_group: true,
};
function renderTable(data: ModelHubData[], onModelClick = vi.fn()) {
function renderTable(data: ModelHubData[], onModelClick = vi.fn(), showProviderLogos = false) {
render(
<DataTable
data={data}
columns={getModelHubTableColumns({ onModelClick })}
columns={getModelHubTableColumns({ onModelClick, showProviderLogos })}
getRowId={(model, index) => model.model_group || String(index)}
sortingMode="client"
size="compact"
@ -35,6 +35,21 @@ describe("getModelHubTableColumns", () => {
it("renders the model row", () => {
renderTable([mockModel]);
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
expect(within(screen.getByRole("button", { name: "gpt-4o" })).queryByAltText("")).not.toBeInTheDocument();
});
it("should show the provider logo beside the model name only when opted in", async () => {
const user = userEvent.setup();
const onModelClick = renderTable([mockModel], vi.fn(), true);
const modelButton = screen.getByRole("button", { name: /^gpt-4o(?: |$)/ });
expect(within(modelButton).getByAltText("")).toBeInTheDocument();
expect(modelButton).toHaveTextContent("OpenAI");
expect(screen.getByRole("columnheader", { name: "Model" })).toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Provider" })).not.toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Public" })).not.toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Tags" })).not.toBeInTheDocument();
await user.click(modelButton);
expect(onModelClick).toHaveBeenCalledWith(mockModel);
});
it("shows the legacy evaluation mode as decisions", () => {

View file

@ -18,6 +18,8 @@ import { displayMode } from "@/lib/decisionModels";
import { copyToClipboard } from "@/utils/dataUtils";
import { ModelCustomTags, type ModelCustomMetadataValue } from "./ModelCustomMetadata";
import { formatModelModeLabel, ProviderLogo } from "./ModelHubProviders";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
export interface ModelHubData {
model_group: string;
@ -94,170 +96,204 @@ function ModelHubRowActions({ model, onModelClick }: ModelHubRowActionsProps) {
interface ModelHubTableColumnsDeps {
onModelClick: (model: ModelHubData) => void;
showProviderLogos?: boolean;
}
export const getModelHubTableColumns = ({ onModelClick }: ModelHubTableColumnsDeps): ColumnDef<ModelHubData>[] => [
{
id: "model_group",
accessorKey: "model_group",
meta: { title: "Public Model Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Public Model Name" />,
size: 220,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<IdentityCell title={row.original.model_group} className="max-w-72" onClick={() => onModelClick(row.original)} />
),
},
{
id: "providers",
accessorKey: "providers",
meta: { title: "Provider", skeleton: "chips", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Provider" />,
size: 150,
enableSorting: true,
sortingFn: (rowA, rowB) => rowA.original.providers.join(", ").localeCompare(rowB.original.providers.join(", ")),
cell: ({ row }) => {
const providers = row.original.providers;
return (
<div className="flex flex-wrap gap-1">
{providers.slice(0, 2).map((provider) => (
<Badge key={provider} variant="secondary">
{provider}
</Badge>
))}
{providers.length > 2 && <span className="text-xs text-muted-foreground">+{providers.length - 2}</span>}
</div>
);
},
},
{
id: "mode",
accessorFn: (row) => (row.mode ? displayMode(row.mode) : ""),
meta: { title: "Mode", className: "hidden lg:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Mode" />,
size: 110,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) =>
row.original.mode ? (
<span className="font-mono text-xs text-muted-foreground">{displayMode(row.original.mode)}</span>
) : (
<span className="text-xs text-muted-foreground">-</span>
export const getModelHubTableColumns = ({
onModelClick,
showProviderLogos = false,
}: ModelHubTableColumnsDeps): ColumnDef<ModelHubData>[] => {
const columns: ColumnDef<ModelHubData>[] = [
{
id: "model_group",
accessorKey: "model_group",
meta: { title: showProviderLogos ? "Model" : "Public Model Name" },
header: ({ column }) => (
<DataTableSortHeader column={column} title={showProviderLogos ? "Model" : "Public Model Name"} />
),
size: 220,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<IdentityCell
title={
showProviderLogos && row.original.providers?.[0] ? (
<span className="flex min-w-0 items-center gap-2">
<ProviderLogo provider={row.original.providers[0]} className="chat-model-provider-logo-small" />
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate">{row.original.model_group}</span>{" "}
<span className="truncate font-sans text-[11px] font-normal text-muted-foreground">
{(row.original.providers ?? [])
.map((provider) => getProviderLogoAndName(provider).displayName)
.join(", ")}
</span>
</span>
</span>
) : (
row.original.model_group
)
}
className="max-w-72"
onClick={() => onModelClick(row.original)}
/>
),
},
{
id: "max_input_tokens",
accessorKey: "max_input_tokens",
meta: { title: "Tokens", className: "hidden lg:table-cell", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Tokens" />,
size: 110,
enableSorting: true,
sortingFn: (rowA, rowB) => {
const tokensA = (rowA.original.max_input_tokens || 0) + (rowA.original.max_output_tokens || 0);
const tokensB = (rowB.original.max_input_tokens || 0) + (rowB.original.max_output_tokens || 0);
return tokensA - tokensB;
},
cell: ({ row }) => {
const model = row.original;
return (
<span className="text-xs tabular-nums">
{model.max_input_tokens ? formatTokens(model.max_input_tokens) : "-"} /{" "}
{model.max_output_tokens ? formatTokens(model.max_output_tokens) : "-"}
</span>
);
{
id: "providers",
accessorKey: "providers",
meta: { title: "Provider", skeleton: "chips", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Provider" />,
size: 150,
enableSorting: true,
sortingFn: (rowA, rowB) => rowA.original.providers.join(", ").localeCompare(rowB.original.providers.join(", ")),
cell: ({ row }) => {
const providers = row.original.providers;
return (
<div className="flex flex-wrap gap-1">
{providers.slice(0, 2).map((provider) => (
<Badge key={provider} variant="secondary">
{provider}
</Badge>
))}
{providers.length > 2 && <span className="text-xs text-muted-foreground">+{providers.length - 2}</span>}
</div>
);
},
},
},
{
id: "input_cost_per_token",
accessorKey: "input_cost_per_token",
meta: { title: "Cost/1M", skeleton: "twoLine", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Cost/1M" />,
size: 110,
enableSorting: true,
sortingFn: (rowA, rowB) => {
const costA = (rowA.original.input_cost_per_token || 0) + (rowA.original.output_cost_per_token || 0);
const costB = (rowB.original.input_cost_per_token || 0) + (rowB.original.output_cost_per_token || 0);
return costA - costB;
},
cell: ({ row }) => {
const model = row.original;
return (
<div className="flex flex-col gap-0.5 text-xs tabular-nums">
<span>{model.input_cost_per_token ? formatCost(model.input_cost_per_token) : "-"}</span>
<span className="text-muted-foreground">
{model.output_cost_per_token ? formatCost(model.output_cost_per_token) : "-"}
{
id: "mode",
accessorFn: (row) => (row.mode ? displayMode(row.mode) : ""),
meta: { title: "Mode", className: "hidden lg:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Mode" />,
size: 110,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) =>
row.original.mode ? (
<span className="font-mono text-xs text-muted-foreground">
{showProviderLogos ? formatModelModeLabel(row.original.mode) : displayMode(row.original.mode)}
</span>
) : (
<span className="text-xs text-muted-foreground">-</span>
),
},
{
id: "max_input_tokens",
accessorKey: "max_input_tokens",
meta: { title: "Tokens", className: "hidden lg:table-cell", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Tokens" />,
size: 110,
enableSorting: true,
sortingFn: (rowA, rowB) => {
const tokensA = (rowA.original.max_input_tokens || 0) + (rowA.original.max_output_tokens || 0);
const tokensB = (rowB.original.max_input_tokens || 0) + (rowB.original.max_output_tokens || 0);
return tokensA - tokensB;
},
cell: ({ row }) => {
const model = row.original;
return (
<span className="text-xs tabular-nums">
{model.max_input_tokens ? formatTokens(model.max_input_tokens) : "-"} /{" "}
{model.max_output_tokens ? formatTokens(model.max_output_tokens) : "-"}
</span>
);
},
},
{
id: "input_cost_per_token",
accessorKey: "input_cost_per_token",
meta: { title: "Cost/1M", skeleton: "twoLine", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Cost/1M" />,
size: 110,
enableSorting: true,
sortingFn: (rowA, rowB) => {
const costA = (rowA.original.input_cost_per_token || 0) + (rowA.original.output_cost_per_token || 0);
const costB = (rowB.original.input_cost_per_token || 0) + (rowB.original.output_cost_per_token || 0);
return costA - costB;
},
cell: ({ row }) => {
const model = row.original;
return (
<div className="flex flex-col gap-0.5 text-xs tabular-nums">
<span>{model.input_cost_per_token ? formatCost(model.input_cost_per_token) : "-"}</span>
<span className="text-muted-foreground">
{model.output_cost_per_token ? formatCost(model.output_cost_per_token) : "-"}
</span>
</div>
);
},
},
{
id: "capabilities",
meta: { title: "Features", skeleton: "chips", className: showProviderLogos ? "hidden md:table-cell" : undefined },
header: "Features",
size: 220,
enableSorting: false,
cell: ({ row }) => {
const capabilities = getModelCapabilities(row.original);
if (capabilities.length === 0) {
return <span className="text-xs text-muted-foreground">-</span>;
}
return (
<div className="flex flex-wrap gap-1">
{capabilities.map((capability) => (
<Badge key={capability} variant="outline">
{formatCapabilityName(capability)}
</Badge>
))}
</div>
);
},
},
{
id: "custom_tags",
meta: { title: "Tags", skeleton: "chips" },
header: "Tags",
size: 160,
enableSorting: false,
cell: ({ row }) => {
const tags = row.original.custom_tags ?? [];
if (tags.length === 0) {
return <span className="text-xs text-muted-foreground">-</span>;
}
return <ModelCustomTags tags={tags} />;
},
},
{
id: "is_public_model_group",
accessorKey: "is_public_model_group",
meta: { title: "Public", skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Public" />,
size: 100,
enableSorting: true,
sortingFn: (rowA, rowB) => {
const publicA = rowA.original.is_public_model_group === true ? 1 : 0;
const publicB = rowB.original.is_public_model_group === true ? 1 : 0;
return publicA - publicB;
},
cell: ({ row }) =>
row.original.is_public_model_group === true ? (
<StatusBadge tone="success" label="Yes" />
) : (
<StatusBadge tone="neutral" label="No" />
),
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<ModelHubRowActions model={row.original} onModelClick={onModelClick} />
</div>
);
},
},
{
id: "capabilities",
meta: { title: "Features", skeleton: "chips" },
header: "Features",
size: 220,
enableSorting: false,
cell: ({ row }) => {
const capabilities = getModelCapabilities(row.original);
if (capabilities.length === 0) {
return <span className="text-xs text-muted-foreground">-</span>;
}
return (
<div className="flex flex-wrap gap-1">
{capabilities.map((capability) => (
<Badge key={capability} variant="outline">
{formatCapabilityName(capability)}
</Badge>
))}
</div>
);
},
},
{
id: "custom_tags",
meta: { title: "Tags", skeleton: "chips" },
header: "Tags",
size: 160,
enableSorting: false,
cell: ({ row }) => {
const tags = row.original.custom_tags ?? [];
if (tags.length === 0) {
return <span className="text-xs text-muted-foreground">-</span>;
}
return <ModelCustomTags tags={tags} />;
},
},
{
id: "is_public_model_group",
accessorKey: "is_public_model_group",
meta: { title: "Public", skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Public" />,
size: 100,
enableSorting: true,
sortingFn: (rowA, rowB) => {
const publicA = rowA.original.is_public_model_group === true ? 1 : 0;
const publicB = rowB.original.is_public_model_group === true ? 1 : 0;
return publicA - publicB;
},
cell: ({ row }) =>
row.original.is_public_model_group === true ? (
<StatusBadge tone="success" label="Yes" />
) : (
<StatusBadge tone="neutral" label="No" />
),
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<ModelHubRowActions model={row.original} onModelClick={onModelClick} />
</div>
),
},
];
},
];
return columns.filter(
(column) =>
!showProviderLogos || !["providers", "is_public_model_group", "custom_tags"].includes(String(column.id)),
);
};

View file

@ -74,7 +74,7 @@ describe("mobile header nested controls", () => {
"selects a portalled gateway item with %s without dismissing More",
async (pointer) => {
const { user, tools } = await openTools();
await user.click(within(tools).getByRole("button", { name: "AI Gateway" }));
await user.click(within(tools).getByRole("button", { name: "Switch workspace, current: AI Gateway" }));
const item = await screen.findByRole("menuitem", { name: "Metrics" });
if (pointer === "touch") {
await user.pointer([
@ -84,7 +84,7 @@ describe("mobile header nested controls", () => {
} else {
await user.click(item);
}
expect(within(tools).getByRole("button", { name: "Metrics" })).toBeVisible();
expect(within(tools).getByRole("button", { name: "Switch workspace, current: Metrics" })).toBeVisible();
expect(screen.getByRole("dialog", { name: "Gateway tools" })).toBeVisible();
},
);

View file

@ -116,11 +116,13 @@ describe("DashboardHeader breadcrumb", () => {
);
});
it("keeps the gateway selector and tools available from the compact header menu", async () => {
it("should keep the gateway selector and tools available from the compact header menu", async () => {
renderDashboardHeader();
fireEvent.click(screen.getByRole("button", { name: "More options" }));
act(() => {
fireEvent.click(screen.getByRole("button", { name: "More options" }));
});
const tools = await screen.findByRole("dialog", { name: "Gateway tools" });
expect(within(tools).getByRole("button", { name: "AI Gateway" })).toBeInTheDocument();
expect(within(tools).getByRole("button", { name: "Switch workspace, current: AI Gateway" })).toBeInTheDocument();
expect(within(tools).getByRole("link", { name: "Docs" })).toBeInTheDocument();
expect(within(tools).getByRole("button", { name: "Aria Agent" })).toBeInTheDocument();
});

View file

@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen, testQueryClient, within } from "@/../tests/test-utils";
import { PluginModeProvider, type Plugin } from "@/contexts/PluginModeContext";
import { setServerRootPath } from "@/lib/serverRootPath";
import ViewSwitcher from "./ViewSwitcher";
const { state, getUiSettingsMock } = vi.hoisted(() => ({
state: { pathname: "/ui/", plugins: [] as Plugin[] },
getUiSettingsMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({ usePathname: () => state.pathname }));
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: () => "",
getUiSettings: getUiSettingsMock,
}));
function renderSwitcher() {
return renderWithProviders(
<PluginModeProvider accessToken="test-session">
<ViewSwitcher />
</PluginModeProvider>,
);
}
describe("ViewSwitcher", () => {
const originalLocation = window.location;
const assignSpy = vi.fn();
beforeEach(() => {
state.pathname = "/ui/";
state.plugins = [];
setServerRootPath("/");
localStorage.clear();
getUiSettingsMock.mockResolvedValue({ values: { enable_chat_ui: false } });
Object.defineProperty(window, "location", {
configurable: true,
value: { href: "http://localhost:3000/ui/", origin: "http://localhost:3000", assign: assignSpy },
});
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === "/api/plugins") return Response.json(state.plugins);
throw new Error(`Unexpected switcher request: ${String(input)}`);
}),
);
});
afterEach(() => {
Object.defineProperty(window, "location", { configurable: true, value: originalLocation });
testQueryClient.clear();
localStorage.clear();
setServerRootPath("/");
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it("should render the workspace selector and keep disabled Chat unavailable", async () => {
const user = userEvent.setup();
renderSwitcher();
await user.click(screen.getByRole("button", { name: "Switch workspace, current: AI Gateway" }));
const chat = await screen.findByRole("menuitem", {
name: "Chat Chat UI for using LLMs, MCPs, Agents Admins can enable in Settings",
});
expect(chat).toHaveAttribute("aria-disabled", "true");
expect(within(chat).queryByRole("img")).not.toBeInTheDocument();
expect(assignSpy).not.toHaveBeenCalled();
});
it("should show the active plugin and switch plugin modes within the gateway", async () => {
state.plugins = [
{ name: "metrics", display_name: "Metrics", url: "/metrics" },
{ name: "obs", display_name: "Observability", url: "/observability" },
];
localStorage.setItem("litellm_plugin_mode", "metrics");
const user = userEvent.setup();
renderSwitcher();
await user.click(await screen.findByRole("button", { name: "Switch workspace, current: Metrics" }));
await user.click(await screen.findByRole("menuitem", { name: "Observability" }));
expect(screen.getByRole("button", { name: "Switch workspace, current: Observability" })).toBeInTheDocument();
expect(assignSpy).not.toHaveBeenCalled();
});
it("should navigate from the gateway to enabled Chat without changing plugin mode", async () => {
getUiSettingsMock.mockResolvedValue({ values: { enable_chat_ui: true } });
const user = userEvent.setup();
renderSwitcher();
await user.click(screen.getByRole("button", { name: "Switch workspace, current: AI Gateway" }));
const chat = await screen.findByRole("menuitem", { name: "Chat Chat UI for using LLMs, MCPs, Agents" });
await user.click(chat);
expect(assignSpy).toHaveBeenCalledWith("/ui/chat");
expect(localStorage.getItem("litellm_plugin_mode")).toBeNull();
});
it.each(["/ui/chat", "/ui/chat/", "/ui/chat/models"])(
"should identify Chat at %s and navigate back while settings are loading",
async (pathname) => {
state.pathname = pathname;
getUiSettingsMock.mockImplementation(() => new Promise(() => {}));
const user = userEvent.setup();
renderSwitcher();
const trigger = screen.getByRole("button", { name: "Switch workspace, current: Chat" });
expect(trigger).toHaveTextContent("Chat");
expect(within(trigger).queryByRole("img")).not.toBeInTheDocument();
await user.click(trigger);
await user.click(await screen.findByRole("menuitem", { name: "AI Gateway" }));
expect(assignSpy).toHaveBeenCalledWith("/ui/");
expect(localStorage.getItem("litellm_plugin_mode")).toBe("ai-gateway");
},
);
it("should preserve a proxy mount path when switching from a Chat subpage to a plugin", async () => {
setServerRootPath("/gateway");
state.pathname = "/gateway/ui/chat/models/";
state.plugins = [{ name: "metrics", display_name: "Metrics", url: "/metrics" }];
getUiSettingsMock.mockImplementation(() => new Promise(() => {}));
const user = userEvent.setup();
renderSwitcher();
await user.click(screen.getByRole("button", { name: "Switch workspace, current: Chat" }));
await user.click(await screen.findByRole("menuitem", { name: "Metrics" }));
expect(assignSpy).toHaveBeenCalledWith("/gateway/ui/");
expect(localStorage.getItem("litellm_plugin_mode")).toBe("metrics");
});
it.each(["/ui/moyai", "/ui/moyai/sessions/one"])(
"should show Moyai's icon at %s and let users switch to Chat",
async (pathname) => {
state.pathname = pathname;
getUiSettingsMock.mockResolvedValue({ values: { enable_chat_ui: true } });
const user = userEvent.setup();
renderSwitcher();
const trigger = screen.getByRole("button", { name: "Switch workspace, current: Moyai" });
expect(within(trigger).getByAltText("")).toHaveAttribute("src", expect.stringContaining("moyai-head.svg"));
await user.click(trigger);
await user.click(await screen.findByRole("menuitem", { name: "Chat Chat UI for using LLMs, MCPs, Agents" }));
expect(assignSpy).toHaveBeenCalledWith("/ui/chat");
},
);
it("should switch from Chat to the connected Moyai deployment", async () => {
state.pathname = "/ui/chat";
const settings = { values: { enable_chat_ui: true, moyai_url: "https://moyai.example.com" } };
getUiSettingsMock.mockResolvedValue(settings);
const user = userEvent.setup();
renderSwitcher();
await user.click(screen.getByRole("button", { name: "Switch workspace, current: Chat" }));
await user.click(await screen.findByRole("menuitem", { name: "Moyai Cloud Coding Agent" }));
expect(assignSpy).toHaveBeenCalledWith("https://moyai.example.com");
});
it("should keep the local Moyai route available when Chat is disabled", async () => {
const user = userEvent.setup();
renderSwitcher();
await user.click(screen.getByRole("button", { name: "Switch workspace, current: AI Gateway" }));
await user.click(await screen.findByRole("menuitem", { name: "Moyai Cloud Coding Agent" }));
expect(assignSpy).toHaveBeenCalledWith("/ui/moyai");
});
it("should navigate from Moyai back to the gateway", async () => {
state.pathname = "/ui/moyai";
const user = userEvent.setup();
renderSwitcher();
await user.click(screen.getByRole("button", { name: "Switch workspace, current: Moyai" }));
await user.click(await screen.findByRole("menuitem", { name: "AI Gateway" }));
expect(assignSpy).toHaveBeenCalledWith("/ui/");
});
});

View file

@ -1,234 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import ViewSwitcher from "./ViewSwitcher";
const { mockUsePluginMode, mockUseUISettings, mockUsePathname, state } = vi.hoisted(() => {
const state = {
mode: "ai-gateway" as string,
setMode: vi.fn(),
plugins: [] as { name: string; display_name: string; url: string }[],
activePlugin: null as { name: string; display_name: string; url: string } | null,
enableChatUI: false,
moyaiUrl: undefined as string | undefined,
pathname: "/ui/",
};
return {
state,
mockUsePluginMode: vi.fn(() => ({
mode: state.mode,
setMode: state.setMode,
plugins: state.plugins,
activePlugin: state.activePlugin,
})),
mockUseUISettings: vi.fn(() => ({
data: { values: { enable_chat_ui: state.enableChatUI, moyai_url: state.moyaiUrl } },
})),
mockUsePathname: vi.fn(() => state.pathname),
};
});
vi.mock("@/contexts/PluginModeContext", () => ({ usePluginMode: mockUsePluginMode }));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings }));
vi.mock("next/navigation", () => ({ usePathname: mockUsePathname }));
// Deterministic hrefs so navigation assertions don't depend on server_root_path.
vi.mock("@/utils/uiHref", () => ({ uiHref: (seg: string) => `/ui/${seg}` }));
describe("ViewSwitcher", () => {
let assignSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
assignSpy = vi.fn();
Object.defineProperty(window, "location", {
configurable: true,
value: { assign: assignSpy },
});
});
afterEach(() => {
state.mode = "ai-gateway";
state.plugins = [];
state.enableChatUI = false;
state.moyaiUrl = undefined;
state.pathname = "/ui/";
state.setMode.mockClear();
});
it("still renders the selector with a disabled Chat hint when there are no plugins and chat is off", async () => {
render(<ViewSwitcher />);
const button = screen.getByRole("button");
expect(button).toHaveTextContent("AI Gateway");
act(() => {
fireEvent.click(button);
});
expect(await screen.findByText("Chat")).toBeInTheDocument();
expect(screen.getByText(/Admins can enable in Settings/i)).toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByText("Chat"));
});
expect(assignSpy).not.toHaveBeenCalled();
expect(state.setMode).not.toHaveBeenCalled();
});
it("labels the button from the active plugin and lists AI Gateway + each plugin", async () => {
state.plugins = [
{ name: "litellm-platform-plugin", display_name: "Chat UI", url: "http://localhost:3300" },
{ name: "obs", display_name: "Observability", url: "http://localhost:9000" },
];
state.mode = "litellm-platform-plugin";
render(<ViewSwitcher />);
expect(screen.getByRole("button")).toHaveTextContent("Chat UI");
act(() => {
fireEvent.click(screen.getByRole("button"));
});
expect(await screen.findByText("AI Gateway")).toBeInTheDocument();
expect(screen.getByText("Observability")).toBeInTheDocument();
});
it("switches plugin mode when a mode entry is picked", async () => {
state.plugins = [{ name: "litellm-platform-plugin", display_name: "Chat UI", url: "http://localhost:3300" }];
state.mode = "ai-gateway";
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
expect(await screen.findByText("Chat UI")).toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByText("Chat UI"));
});
expect(state.setMode).toHaveBeenCalledWith("litellm-platform-plugin");
expect(assignSpy).not.toHaveBeenCalled();
});
it("shows a clickable Chat entry that navigates to the chat app when enabled", async () => {
state.enableChatUI = true;
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
expect(await screen.findByText("Chat")).toBeInTheDocument();
expect(screen.queryByText(/Admins can enable in Settings/i)).not.toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByText("Chat"));
});
expect(assignSpy).toHaveBeenCalledWith("/ui/chat");
expect(state.setMode).not.toHaveBeenCalled();
});
it("navigates back to the dashboard when a mode entry is picked from the chat route", async () => {
state.enableChatUI = true;
state.pathname = "/ui/chat";
state.plugins = [{ name: "litellm-platform-plugin", display_name: "Chat UI", url: "http://localhost:3300" }];
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
expect(await screen.findByText("AI Gateway")).toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByText("AI Gateway"));
});
expect(state.setMode).toHaveBeenCalledWith("ai-gateway");
expect(assignSpy).toHaveBeenCalledWith("/ui/");
});
it("shows the Moyai entry with its description regardless of enable_chat_ui", async () => {
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
expect(await screen.findByText("Moyai")).toBeInTheDocument();
expect(screen.getByText("Cloud Coding Agent")).toBeInTheDocument();
});
it("navigates to the moyai route when the Moyai entry is picked", async () => {
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
act(() => {
fireEvent.click(screen.getByText("Moyai"));
});
expect(assignSpy).toHaveBeenCalledWith("/ui/moyai");
expect(state.setMode).not.toHaveBeenCalled();
});
it("labels the button Moyai on the moyai route and its subpaths", async () => {
state.pathname = "/ui/moyai";
const { unmount } = render(<ViewSwitcher />);
expect(screen.getByRole("button")).toHaveTextContent("Moyai");
unmount();
state.pathname = "/ui/moyai/anything";
render(<ViewSwitcher />);
expect(screen.getByRole("button")).toHaveTextContent("Moyai");
});
it("navigates back to the dashboard when AI Gateway is picked from the moyai route", async () => {
state.pathname = "/ui/moyai";
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
expect(await screen.findByText("AI Gateway")).toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByText("AI Gateway"));
});
expect(state.setMode).toHaveBeenCalledWith("ai-gateway");
expect(assignSpy).toHaveBeenCalledWith("/ui/");
});
it("lists Moyai before Chat in the menu", async () => {
state.enableChatUI = true;
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
expect(await screen.findByText("Moyai")).toBeInTheDocument();
expect(screen.getByText("Moyai").compareDocumentPosition(screen.getByText("Chat"))).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
});
it("navigates straight to the connected deployment when moyai_url is set", async () => {
state.moyaiUrl = "https://moyai.example.com";
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
act(() => {
fireEvent.click(screen.getByText("Moyai"));
});
expect(assignSpy).toHaveBeenCalledWith("https://moyai.example.com");
});
it("shows Chat as a disabled, non-navigating entry with an admin hint when disabled", async () => {
state.enableChatUI = false;
state.plugins = [{ name: "obs", display_name: "Observability", url: "http://localhost:9000" }];
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
expect(await screen.findByText("Observability")).toBeInTheDocument();
expect(screen.getByText("Chat")).toBeInTheDocument();
expect(screen.getByText(/Admins can enable in Settings/i)).toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByText("Chat"));
});
expect(assignSpy).not.toHaveBeenCalled();
});
});

View file

@ -1,29 +1,67 @@
import React from "react";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Check, ChevronsUpDown, LayoutGrid } from "lucide-react";
import { Check, ChevronsUpDown, LayoutGrid, MessageCircle } from "lucide-react";
import { usePluginMode } from "@/contexts/PluginModeContext";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { uiHref } from "@/utils/uiHref";
import { cn } from "@/lib/cva.config";
import moyaiHead from "../../../public/assets/moyai/moyai-head.svg";
const GATEWAY = "ai-gateway";
const CHAT = "chat";
const MOYAI = "moyai";
type WorkspaceIconName = typeof GATEWAY | typeof CHAT | typeof MOYAI;
function isRoute(pathname: string, href: string): boolean {
return pathname === href || pathname.startsWith(`${href}/`);
}
function activeLabelFor(isChatRoute: boolean, isMoyaiRoute: boolean, pluginLabel: string | undefined): string {
if (isChatRoute) return "Chat";
if (isMoyaiRoute) return "Moyai";
return pluginLabel ?? "AI Gateway";
function activeWorkspaceFor(
isChatRoute: boolean,
isMoyaiRoute: boolean,
pluginLabel: string | undefined,
): { label: string; icon: WorkspaceIconName } {
if (isChatRoute) return { label: "Chat", icon: CHAT };
if (isMoyaiRoute) return { label: "Moyai", icon: MOYAI };
return { label: pluginLabel ?? "AI Gateway", icon: GATEWAY };
}
function WorkspaceIcon({ workspace }: { workspace: WorkspaceIconName }) {
if (workspace === CHAT) {
return (
<span
data-slot="workspace-icon"
aria-hidden="true"
className="flex size-6 flex-none items-center justify-center rounded-md bg-primary/10 text-primary ring-1 ring-inset ring-primary/15"
>
<MessageCircle className="size-4" />
</span>
);
}
if (workspace === MOYAI) {
return (
<span data-slot="workspace-icon" aria-hidden="true" className="flex size-6 flex-none items-center justify-center">
<Image src={moyaiHead.src} alt="" width={20} height={20} unoptimized className="size-5 object-contain" />
</span>
);
}
return (
<span
data-slot="workspace-icon"
aria-hidden="true"
className="flex size-6 flex-none items-center justify-center rounded-md bg-muted text-muted-foreground"
>
<LayoutGrid className="size-4" />
</span>
);
}
interface ViewSwitcherItem {
@ -33,7 +71,12 @@ interface ViewSwitcherItem {
onClick?: () => void;
}
export default function ViewSwitcher() {
interface ViewSwitcherProps {
className?: string;
contentClassName?: string;
}
export default function ViewSwitcher({ className, contentClassName }: ViewSwitcherProps) {
const { mode, setMode, plugins } = usePluginMode();
const { data: uiSettings } = useUISettings();
const pathname = usePathname();
@ -42,11 +85,15 @@ export default function ViewSwitcher() {
const moyaiUrl = (uiSettings?.values?.moyai_url as string | undefined) ?? null;
const normalizedPathname = (pathname ?? "").replace(/\/+$/, "");
const isChatRoute = chatEnabled && isRoute(normalizedPathname, uiHref(CHAT));
const isChatRoute = isRoute(normalizedPathname, uiHref(CHAT));
const isMoyaiRoute = isRoute(normalizedPathname, uiHref(MOYAI));
const isStandaloneRoute = isChatRoute || isMoyaiRoute;
const activeLabel = activeLabelFor(isChatRoute, isMoyaiRoute, plugins.find((p) => p.name === mode)?.display_name);
const { label: activeLabel, icon: activeIcon } = activeWorkspaceFor(
isChatRoute,
isMoyaiRoute,
plugins.find((p) => p.name === mode)?.display_name,
);
const modeEntries = [
{ key: GATEWAY, label: "AI Gateway" },
@ -60,49 +107,52 @@ export default function ViewSwitcher() {
}
};
const chatItem: ViewSwitcherItem = chatEnabled
? {
key: CHAT,
label: (
<div className="flex items-center justify-between gap-6 py-0.5">
<span className="font-medium">Chat</span>
{isChatRoute && <Check className="size-4 text-info" />}
</div>
),
onClick: () => window.location.assign(uiHref(CHAT)),
}
: {
key: CHAT,
disabled: true,
label: (
<div className="flex max-w-[220px] flex-col py-0.5">
<span className="font-medium">Chat</span>
<span className="whitespace-normal text-xs leading-snug text-muted-foreground">
Admins can enable in Settings
</span>
</div>
),
};
const chatItem: ViewSwitcherItem = {
key: CHAT,
disabled: !chatEnabled,
label: (
<div className="flex w-full items-center justify-between gap-6 py-0.5">
<span className="flex items-center gap-2">
<WorkspaceIcon workspace={CHAT} />
<span className="flex max-w-[220px] flex-col">
<span className="font-medium">Chat</span>{" "}
<span className="text-xs leading-snug text-muted-foreground">Chat UI for using LLMs, MCPs, Agents</span>{" "}
{!chatEnabled && (
<span className="whitespace-normal text-xs leading-snug text-muted-foreground">
Admins can enable in Settings
</span>
)}
</span>
</span>
{isChatRoute && <Check className="size-4 text-info" />}
</div>
),
onClick: chatEnabled ? () => window.location.assign(uiHref(CHAT)) : undefined,
};
const items: ViewSwitcherItem[] = [
...modeEntries.map((e) => ({
key: e.key,
label: (
<div className="flex items-center justify-between gap-6 py-0.5">
<span className="font-medium">{e.label}</span>
<div className="flex w-full items-center justify-between gap-6 py-0.5">
<span className="flex items-center gap-2">
<WorkspaceIcon workspace={GATEWAY} />
<span className="font-medium">{e.label}</span>
</span>
{!isStandaloneRoute && e.key === mode && <Check className="size-4 text-info" />}
</div>
),
onClick: () => selectMode(e.key),
})),
chatItem,
{
key: MOYAI,
label: (
<div className="flex items-center justify-between gap-6 py-0.5">
<div className="flex w-full items-center justify-between gap-6 py-0.5">
<span className="flex items-center gap-2">
<img src={moyaiHead.src} alt="" className="h-4 w-auto" />
<WorkspaceIcon workspace={MOYAI} />
<span className="flex flex-col">
<span className="font-medium">Moyai</span>
<span className="font-medium">Moyai</span>{" "}
<span className="text-xs leading-snug text-muted-foreground">Cloud Coding Agent</span>
</span>
</span>
@ -111,28 +161,30 @@ export default function ViewSwitcher() {
),
onClick: () => window.location.assign(moyaiUrl ?? uiHref(MOYAI)),
},
chatItem,
];
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label={`Switch workspace, current: ${activeLabel}`}
render={
<button
<Button
type="button"
className="flex h-8 max-w-[220px] items-center gap-1.5 rounded-md border border-border bg-background pl-1.5 pr-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
variant="outline"
size="sm"
className={cn("max-w-[220px] gap-1.5 pl-1.5 pr-2", className)}
/>
}
>
<span className="flex size-5 flex-none items-center justify-center rounded bg-muted text-muted-foreground">
<LayoutGrid className="size-[13px]" />
<WorkspaceIcon workspace={activeIcon} />
<span data-slot="workspace-label" className="truncate">
{activeLabel}
</span>
<span className="truncate">{activeLabel}</span>
<ChevronsUpDown className="size-3.5 flex-none text-muted-foreground" />
<ChevronsUpDown data-slot="workspace-chevron" className="size-3.5 flex-none text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto">
<DropdownMenuContent className={cn("w-auto", contentClassName)}>
{items.map((item) => (
<DropdownMenuItem key={item.key} disabled={item.disabled} onClick={item.onClick}>
<DropdownMenuItem key={item.key} disabled={item.disabled} onClick={item.onClick} className="w-full">
{item.label}
</DropdownMenuItem>
))}

View file

@ -9,6 +9,7 @@ import { getProviderLogoAndName } from "@/components/provider_info_helpers";
import { PUBLIC_MODEL_HUB_SORTABLE_FIELDS } from "@/components/publicModelHub/publicModelHubFilters";
import { displayMode } from "@/lib/decisionModels";
import type { ModelCustomMetadataValue } from "@/components/AIHub/ModelCustomMetadata";
import { formatModelModeLabel, ProviderLogo } from "@/components/AIHub/ModelHubProviders";
export interface ModelGroupInfo {
model_group: string;
@ -167,20 +168,42 @@ function OverflowChips({ items }: { items: string[] }) {
interface PublicModelHubColumnsDeps {
onModelClick: (model: ModelGroupInfo) => void;
showProviderLogos?: boolean;
}
export const getPublicModelHubColumns = ({ onModelClick }: PublicModelHubColumnsDeps): ColumnDef<ModelGroupInfo>[] => {
export const getPublicModelHubColumns = ({
onModelClick,
showProviderLogos = false,
}: PublicModelHubColumnsDeps): ColumnDef<ModelGroupInfo>[] => {
const columns: ColumnDef<ModelGroupInfo>[] = [
{
id: "model_group",
accessorKey: "model_group",
meta: { title: "Model Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Model Name" />,
meta: { title: showProviderLogos ? "Model" : "Model Name" },
header: ({ column }) => (
<DataTableSortHeader column={column} title={showProviderLogos ? "Model" : "Model Name"} />
),
size: 200,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<IdentityCell
title={row.original.model_group}
title={
showProviderLogos && row.original.providers?.[0] ? (
<span className="flex min-w-0 items-center gap-2">
<ProviderLogo provider={row.original.providers[0]} className="chat-model-provider-logo-small" />
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate">{row.original.model_group}</span>{" "}
<span className="truncate font-sans text-[11px] font-normal text-muted-foreground">
{(row.original.providers ?? [])
.map((provider) => getProviderLogoAndName(provider).displayName)
.join(", ")}
</span>
</span>
</span>
) : (
row.original.model_group
)
}
titleClassName="font-mono text-xs font-normal"
className="max-w-72"
onClick={() => onModelClick(row.original)}
@ -206,8 +229,10 @@ export const getPublicModelHubColumns = ({ onModelClick }: PublicModelHubColumns
sortingFn: "alphanumeric",
cell: ({ row }) => (
<span className="flex items-center gap-2 text-sm">
<span>{getModeIcon(row.original.mode || "")}</span>
<span>{row.original.mode ? displayMode(row.original.mode) : "Chat"}</span>
{!showProviderLogos && <span>{getModeIcon(row.original.mode || "")}</span>}
<span>
{showProviderLogos ? formatModelModeLabel(row.original.mode) : displayMode(row.original.mode) ?? "Chat"}
</span>
</span>
),
},
@ -230,14 +255,26 @@ export const getPublicModelHubColumns = ({ onModelClick }: PublicModelHubColumns
{
id: "input_cost_per_token",
accessorKey: "input_cost_per_token",
meta: { title: "Input $/1M", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Input $/1M" />,
size: 110,
cell: ({ row }) => (
<span className="text-sm">
{row.original.input_cost_per_token ? formatCost(row.original.input_cost_per_token) : "Free"}
</span>
meta: { title: showProviderLogos ? "Cost / 1M tokens" : "Input $/1M", numeric: true },
header: ({ column }) => (
<DataTableSortHeader column={column} title={showProviderLogos ? "Cost / 1M tokens" : "Input $/1M"} />
),
size: 110,
cell: ({ row }) =>
showProviderLogos ? (
<div className="flex flex-col gap-0.5 text-xs tabular-nums">
<span>
Input {row.original.input_cost_per_token ? formatCost(row.original.input_cost_per_token) : "Free"}
</span>
<span className="text-muted-foreground">
Output {row.original.output_cost_per_token ? formatCost(row.original.output_cost_per_token) : "Free"}
</span>
</div>
) : (
<span className="text-sm">
{row.original.input_cost_per_token ? formatCost(row.original.input_cost_per_token) : "Free"}
</span>
),
},
{
id: "output_cost_per_token",
@ -315,10 +352,22 @@ export const getPublicModelHubColumns = ({ onModelClick }: PublicModelHubColumns
),
},
];
return columns.map((column) => ({
...column,
enableSorting: PUBLIC_MODEL_HUB_SORTABLE_FIELDS.includes(String(column.id)),
}));
return columns
.filter(
(column) =>
!showProviderLogos || !["providers", "custom_tags", "output_cost_per_token"].includes(String(column.id)),
)
.map((column) => ({
...column,
meta: {
...column.meta,
className:
showProviderLogos && !["model_group", "input_cost_per_token"].includes(String(column.id))
? "hidden md:table-cell"
: column.meta?.className,
},
enableSorting: PUBLIC_MODEL_HUB_SORTABLE_FIELDS.includes(String(column.id)),
}));
};
interface PublicAgentHubColumnsDeps {

View file

@ -0,0 +1,104 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ChatMessageContent } from "./ChatMessages";
import type { ChatMessage } from "./types";
vi.mock("@/components/networking", () => ({ getGatewayBaseUrl: () => "https://gateway.example/v1" }));
vi.mock("@/components/chat_ui/ReasoningContent", () => ({ default: () => null }));
vi.mock("@/components/chat_ui/MCPEventsDisplay", () => ({ default: () => null }));
vi.mock("@/components/chat_ui/ResponseMetrics", () => ({ default: () => null }));
vi.mock("@/hooks/useSyntaxTheme", () => ({ useSyntaxTheme: (theme: unknown) => theme }));
const image = { id: "image-1", name: "design.png", url: "data:image/png;base64,aW1hZ2U=" };
const message: ChatMessage = {
id: "message-1",
role: "user",
content: "Describe this design",
timestamp: 1,
attachments: [image],
};
describe("ChatMessageContent images", () => {
it("should render a user message and its image attachment", () => {
render(<ChatMessageContent message={message} />);
expect(screen.getByText("Describe this design")).toBeInTheDocument();
expect(screen.getByRole("img", { name: "design.png" })).toHaveAttribute("src", image.url);
expect(screen.getByRole("link", { name: "Open image" })).toHaveAttribute("href", image.url);
});
it("should display generated images even when assistant text is empty", () => {
render(
<ChatMessageContent
message={{ ...message, role: "assistant", content: "", attachments: undefined, generatedImages: [image] }}
isStreaming
/>,
);
expect(screen.getByRole("img", { name: "design.png" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Copy message" })).not.toBeInTheDocument();
});
it("should show image generation progress", () => {
render(
<ChatMessageContent
message={{
...message,
role: "assistant",
content: "",
imageGeneration: { prompt: "A design", model: "image-model" },
}}
isStreaming
/>,
);
expect(screen.getByRole("status")).toHaveTextContent("Creating your image");
expect(screen.queryByText("Thinking...")).not.toBeInTheDocument();
});
it("should respect image suppression for uploaded and generated images", () => {
render(
<>
<ChatMessageContent message={message} allowImages={false} />
<ChatMessageContent
message={{ ...message, id: "assistant-1", role: "assistant", content: "", generatedImages: [image] }}
allowImages={false}
/>
</>,
);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Open image" })).not.toBeInTheDocument();
expect(screen.getAllByText("design.png")).toHaveLength(2);
});
it("should retain image previews while editing the message text", () => {
const onEditMessage = vi.fn();
render(<ChatMessageContent message={message} onEditMessage={onEditMessage} />);
act(() => {
fireEvent.mouseEnter(screen.getByText("Describe this design"));
});
act(() => {
fireEvent.click(screen.getByRole("button", { name: "Edit message" }));
});
expect(screen.getByRole("img", { name: "design.png" })).toBeInTheDocument();
act(() => {
fireEvent.change(screen.getByRole("textbox", { name: "Edit message" }), { target: { value: "" } });
});
act(() => {
fireEvent.click(screen.getByRole("button", { name: "Save & Send" }));
});
expect(onEditMessage).toHaveBeenCalledWith(message.id, "");
});
it("should show a helpful message when saved image bytes are missing", () => {
render(<ChatMessageContent message={{ ...message, attachments: [{ ...image, url: "" }] }} />);
expect(screen.getByText(/design.png — image unavailable/)).toBeInTheDocument();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
it("should show a helpful message when a provider image URL expires", () => {
render(<ChatMessageContent message={message} />);
act(() => {
fireEvent.error(screen.getByRole("img", { name: "design.png" }));
});
expect(screen.getByText(/image unavailable/)).toBeInTheDocument();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
});

View file

@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Bubble, BubbleContent } from "@/components/ui/Bubble";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { Skeleton } from "@/components/ui/skeleton";
import React, { useEffect, useRef, useState } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
@ -17,7 +18,8 @@ import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import ReasoningContent from "@/components/chat_ui/ReasoningContent";
import MCPEventsDisplay from "@/components/chat_ui/MCPEventsDisplay";
import ResponseMetrics from "@/components/chat_ui/ResponseMetrics";
import { ChatMessage } from "./types";
import { ChatImageAttachment, ChatMessage } from "./types";
import { isChatImageUrl } from "./imageHelpers";
const REDACTED_KEY_PATTERNS = /token|key|secret|password|auth/i;
@ -90,18 +92,64 @@ const markdownWithoutImages: Components = {
img: ({ alt }) => <span>{alt || "Image omitted"}</span>,
};
function ChatImage({ image, allowImages }: { image: ChatImageAttachment; allowImages: boolean }) {
const [failedUrl, setFailedUrl] = useState<string | null>(null);
if (!allowImages) return <span className="text-sm text-muted-foreground">{image.name}</span>;
if (!isChatImageUrl(image.url) || failedUrl === image.url) {
return (
<div className="rounded-xl border bg-muted p-4 text-sm text-muted-foreground">
{image.name} — image unavailable. Start a new chat and attach a saved copy to use it again.
</div>
);
}
return (
<figure className="m-0 min-w-0 space-y-1.5">
{/* User-supplied and generated images may be data URLs or come from any configured provider. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image.url}
alt={image.name}
className="max-h-96 w-full rounded-xl border object-contain"
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailedUrl(image.url)}
/>
<Button
variant="ghost"
size="xs"
nativeButton={false}
role="link"
render={<a href={image.url} target="_blank" rel="noopener noreferrer" download={image.name} />}
>
Open image
</Button>
</figure>
);
}
function ChatImages({ images, allowImages }: { images?: ChatImageAttachment[]; allowImages: boolean }) {
if (!images?.length) return null;
return (
<div className={`grid gap-3 ${images.length > 1 ? "grid-cols-2" : "grid-cols-1"}`}>
{images.map((image) => (
<ChatImage key={image.id} image={image} allowImages={allowImages} />
))}
</div>
);
}
interface UserBubbleProps {
message: ChatMessage;
allowImages: boolean;
onEdit?: (messageId: string, newContent: string) => void;
isStreaming?: boolean;
}
function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) {
const [hovered, setHovered] = useState(false);
function UserBubble({ message, allowImages, onEdit, isStreaming }: UserBubbleProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(message.content);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const showEditAction = hovered && !isStreaming && Boolean(onEdit);
const showEditAction = !isStreaming && Boolean(onEdit);
useEffect(() => {
if (editing && textareaRef.current) {
@ -119,7 +167,8 @@ function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) {
const handleSave = () => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== message.content && onEdit) {
const hasContent = Boolean(trimmed || message.attachments?.length);
if (hasContent && trimmed !== message.content && onEdit) {
onEdit(message.id, trimmed);
}
setEditing(false);
@ -140,6 +189,11 @@ function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) {
return (
<div className="flex flex-col items-end">
<div className="w-[72%] bg-background border-2 border-primary rounded-xl overflow-hidden shadow-[0_0_0_3px_rgba(var(--primary)/0.1)]">
{message.attachments?.length ? (
<div className="p-3">
<ChatImages images={message.attachments} allowImages={allowImages} />
</div>
) : null}
<Textarea
ref={textareaRef}
aria-label="Edit message"
@ -159,7 +213,7 @@ function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) {
>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={!editValue.trim()}>
<Button size="sm" onClick={handleSave} disabled={!editValue.trim() && !message.attachments?.length}>
Save & Send
</Button>
</div>
@ -169,12 +223,8 @@ function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) {
}
return (
<div
className="flex flex-col items-end w-full"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div className="flex min-w-0 max-w-[80%] items-end gap-1.5">
<div className="group/user-message flex flex-col items-end w-full">
<div className="moyai-chat-user-row flex min-w-0 items-end gap-1.5">
{showEditAction && (
<TooltipProvider delay={300}>
<Tooltip>
@ -188,7 +238,7 @@ function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) {
setEditValue(message.content);
setEditing(true);
}}
className="text-muted-foreground hover:text-foreground shrink-0"
className="text-muted-foreground hover:text-foreground shrink-0 opacity-0 group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100 [@media(hover:none)]:opacity-100"
>
<Pencil className="size-3.5" />
</Button>
@ -200,8 +250,11 @@ function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) {
</Tooltip>
</TooltipProvider>
)}
<Bubble variant="muted" align="end" className="max-w-full">
<BubbleContent className="whitespace-pre-wrap">{message.content}</BubbleContent>
<Bubble variant="muted" align="end" className="moyai-chat-user-bubble max-w-full">
<BubbleContent className="space-y-3 whitespace-pre-wrap">
<ChatImages images={message.attachments} allowImages={allowImages} />
{message.content && <div>{message.content}</div>}
</BubbleContent>
</Bubble>
</div>
<span className="text-[11px] text-muted-foreground mt-1">{formatTimestamp(message.timestamp)}</span>
@ -238,10 +291,19 @@ function AssistantBubble({
prevStreamingRef.current = isStreaming;
}, [isStreaming]);
const showReasoningPlaceholder = isLastMessage && isStreaming && !message.reasoningContent;
const isReceivingResponse = isLastMessage && isStreaming;
const showReasoningPlaceholder = isReceivingResponse && !message.reasoningContent && !message.imageGeneration;
const showReasoning = !!message.reasoningContent || showReasoningPlaceholder;
if (isTypingIndicator) {
if (message.imageGeneration) {
return (
<div role="status" className="w-full max-w-sm space-y-3">
<Skeleton className="aspect-square w-full rounded-2xl" />
<p className="text-sm text-muted-foreground">Creating your image…</p>
</div>
);
}
return (
<div className="flex flex-col items-start">
<div className="flex items-center gap-1 px-1 py-2.5">
@ -259,7 +321,7 @@ function AssistantBubble({
}
return (
<Bubble variant="ghost" className="w-full items-start">
<Bubble variant="ghost" className="moyai-chat-assistant-bubble w-full items-start">
{showReasoning &&
(showReasoningPlaceholder ? (
<ThinkingPlaceholder />
@ -268,6 +330,7 @@ function AssistantBubble({
))}
<BubbleContent className="w-full text-foreground">
<ChatImages images={message.generatedImages} allowImages={allowImages} />
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={allowImages ? markdownComponents : markdownWithoutImages}
@ -277,7 +340,7 @@ function AssistantBubble({
{stoppedSuffix && <span className="text-muted-foreground italic"> [stopped]</span>}
</BubbleContent>
{actions ?? <CopyButton text={mainContent} />}
{actions ?? (mainContent.trim() && <CopyButton text={mainContent} />)}
{mcpEvents && mcpEvents.length > 0 && (
<div className="mt-2 max-w-full">
<MCPEventsDisplay events={mcpEvents} />
@ -443,20 +506,21 @@ export function ChatMessageContent({
actions,
}: ChatMessageContentProps) {
if (message.role === "user") {
return <UserBubble message={message} onEdit={onEditMessage} isStreaming={isStreaming} />;
return <UserBubble message={message} allowImages={allowImages} onEdit={onEditMessage} isStreaming={isStreaming} />;
}
if (message.role === "tool") {
return <ToolCard message={message} />;
}
const isEmptyResponse = message.content === "" && !message.generatedImages?.length;
return (
<AssistantBubble
message={message}
allowImages={allowImages}
isLastMessage={isLastMessage}
isStreaming={isStreaming}
isTypingIndicator={isLastMessage && isStreaming && message.content === ""}
isTypingIndicator={isLastMessage && isStreaming && isEmptyResponse}
mcpEvents={message.mcpEvents}
actions={actions}
/>

View file

@ -0,0 +1,122 @@
import { useState } from "react";
import { Check, ChevronDown, ImageIcon, MessageSquare, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
export type ChatMode = "chat" | "image";
export function modelChatMode(model: ModelGroup): ChatMode {
return model.mode === "image_generation" ? "image" : "chat";
}
export function isChatModel(model: ModelGroup): boolean {
return !model.mode || ["chat", "responses", "completion", "image_generation"].includes(model.mode);
}
export default function ChatModelPicker({
models,
selectedModel,
loading,
disabled,
onSelect,
}: {
models: ModelGroup[];
selectedModel: string | null;
loading: boolean;
disabled: boolean;
onSelect: (model: ModelGroup) => void;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<"all" | ChatMode>("all");
const selectModel = (model: ModelGroup) => {
onSelect(model);
setOpen(false);
setSearch("");
};
const visibleModels = models.filter(
(model) =>
(filter === "all" || modelChatMode(model) === filter) &&
`${model.model_group} ${(model.providers ?? []).join(" ")}`.toLowerCase().includes(search.toLowerCase()),
);
if (loading) return <Skeleton className="h-9 w-48" />;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button variant="ghost" disabled={disabled} className="moyai-chat-model-picker">
<span className="truncate">{selectedModel ?? "Choose a model"}</span>
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
</Button>
}
/>
<PopoverContent align="start" className="chat-theme w-[min(360px,calc(100vw-2rem))] p-0">
<div className="space-y-3 p-3">
<div>
<p className="text-sm font-medium">Model Hub</p>
<p className="text-xs text-muted-foreground">Choose a model to try it here.</p>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<Input
aria-label="Search models"
placeholder="Search models or providers"
value={search}
onChange={(event) => setSearch(event.target.value)}
className="pl-8"
/>
</div>
<div className="flex gap-1" aria-label="Model type">
{(
[
["all", "All"],
["chat", "Chat"],
["image", "Images"],
] as const
).map(([value, label]) => (
<Button
key={value}
size="sm"
variant={filter === value ? "secondary" : "ghost"}
aria-pressed={filter === value}
onClick={() => setFilter(value)}
>
{label}
</Button>
))}
</div>
</div>
<ScrollArea className="h-[min(320px,50dvh)] border-t p-1">
{visibleModels.length === 0 && <p className="p-4 text-sm text-muted-foreground">No models found.</p>}
{visibleModels.map((model) => {
const image = modelChatMode(model) === "image";
const Icon = image ? ImageIcon : MessageSquare;
return (
<Button
key={model.model_group}
variant="ghost"
className="h-auto w-full justify-start gap-3 py-3"
onClick={() => selectModel(model)}
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 text-left">
<span className="block truncate text-sm">{model.model_group}</span>
<span className="block truncate text-xs font-normal text-muted-foreground">
{[image ? "Image generation" : "Chat", ...(model.providers ?? [])].join(" · ")}
</span>
</span>
{selectedModel === model.model_group && <Check className="size-4 shrink-0 text-primary" />}
</Button>
);
})}
</ScrollArea>
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,173 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders as render } from "@/../tests/test-utils";
import ChatShell from "./ChatShell";
const { mockPush, mockUsePathname, mockUseChatShell, mockUseMediaQuery } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockUsePathname: vi.fn(() => "/ui/chat"),
mockUseMediaQuery: vi.fn(() => true),
mockUseChatShell: vi.fn(() => ({
accessToken: "test-token",
userEmail: "developer@example.com",
userId: "test-user",
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/uiHref", () => ({ uiHref: (seg: string) => `/ui/${seg}`.replace(/\/$/, "") || "/ui" }));
vi.mock("@/contexts/ChatShellContext", () => ({ useChatShell: mockUseChatShell }));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: () => ({ data: { values: { enable_chat_ui: true } } }),
}));
vi.mock("@/app/(dashboard)/hooks/useLogout", () => ({ useLogout: () => vi.fn() }));
vi.mock("usehooks-ts", () => ({ useMediaQuery: mockUseMediaQuery }));
vi.mock("./ConversationList", () => ({ default: () => <div data-testid="conversation-list" /> }));
describe("ChatShell", () => {
afterEach(() => {
mockPush.mockClear();
mockUsePathname.mockReturnValue("/ui/chat");
mockUseMediaQuery.mockReturnValue(true);
});
it("should render the workspace and mark Chats active on the base chat route", () => {
render(
<ChatShell>
<div />
</ChatShell>,
);
expect(screen.getByRole("button", { name: "Chats" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("button", { name: "Models" })).not.toHaveAttribute("aria-current");
expect(screen.getByRole("button", { name: "Workspace" })).not.toHaveAttribute("aria-current");
expect(screen.getByRole("button", { name: "Settings" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Switch workspace, current: Chat" })).toBeInTheDocument();
expect(screen.getByTestId("conversation-list")).toBeInTheDocument();
});
it("should mark Models active on its route and tolerate a trailing slash", () => {
mockUsePathname.mockReturnValue("/ui/chat/models/");
render(
<ChatShell>
<div />
</ChatShell>,
);
expect(screen.getByRole("button", { name: "Models" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("button", { name: "Chats" })).not.toHaveAttribute("aria-current");
expect(screen.getByRole("button", { name: "Workspace" })).not.toHaveAttribute("aria-current");
});
it("should mark API Keys active while still showing the conversation list", () => {
mockUsePathname.mockReturnValue("/ui/chat/api-keys");
render(
<ChatShell>
<div />
</ChatShell>,
);
expect(screen.getByRole("button", { name: "Workspace" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("button", { name: "Chats" })).not.toHaveAttribute("aria-current");
expect(screen.getByTestId("conversation-list")).toBeInTheDocument();
});
it("should navigate through the workspace menu and retain all chat destinations", async () => {
const user = userEvent.setup();
render(
<ChatShell>
<div />
</ChatShell>,
);
await user.click(screen.getByRole("button", { name: "Integrations" }));
expect(mockPush).toHaveBeenCalledWith("/ui/chat/integrations");
await user.click(screen.getByRole("button", { name: "Models" }));
expect(mockPush).toHaveBeenCalledWith("/ui/chat/models");
for (const [label, href] of [
["Credentials", "/ui/chat/credentials"],
["API Keys", "/ui/chat/api-keys"],
["Usage", "/ui/chat/usage"],
["Logs", "/ui/chat/logs"],
]) {
await user.click(screen.getByRole("button", { name: "Workspace" }));
await user.click(await screen.findByRole("menuitem", { name: label, exact: true }));
expect(mockPush).toHaveBeenCalledWith(href);
}
});
it("should mark the active workspace destination and tolerate a trailing slash", async () => {
mockUsePathname.mockReturnValue("/ui/chat/usage/");
const user = userEvent.setup();
render(
<ChatShell>
<div />
</ChatShell>,
);
expect(screen.getByRole("button", { name: "Workspace" })).toHaveAttribute("aria-current", "page");
await user.click(screen.getByRole("button", { name: "Workspace" }));
expect(await screen.findByRole("menuitem", { name: "Usage", exact: true })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("menuitem", { name: "Logs", exact: true })).not.toHaveAttribute("aria-current");
});
it("should collapse and reopen the desktop rail", async () => {
const user = userEvent.setup();
render(
<ChatShell>
<div />
</ChatShell>,
);
await user.click(screen.getByRole("button", { name: "Close sidebar" }));
expect(screen.getByRole("button", { name: "Open sidebar" })).toHaveAttribute("aria-expanded", "false");
await user.click(screen.getByRole("button", { name: "Switch workspace, current: Chat" }));
expect(await screen.findByRole("menuitem", { name: "AI Gateway", exact: true })).toBeInTheDocument();
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "Open sidebar" }));
expect(screen.getByRole("button", { name: "Close sidebar" })).toHaveAttribute("aria-expanded", "true");
});
it("should open mobile navigation and close it after selecting Models", async () => {
mockUseMediaQuery.mockReturnValue(false);
const user = userEvent.setup();
render(
<ChatShell>
<div>Conversation content</div>
</ChatShell>,
);
expect(screen.queryByRole("navigation", { name: "Chat workspace" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Switch workspace, current: Chat" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Open chat navigation" }));
expect(await screen.findByRole("dialog", { name: "LiteLLM Chat" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Models" }));
expect(mockPush).toHaveBeenCalledWith("/ui/chat/models");
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByText("Conversation content")).toBeInTheDocument();
});
it("should close mobile navigation with Escape and restore trigger focus", async () => {
mockUseMediaQuery.mockReturnValue(false);
const user = userEvent.setup();
render(
<ChatShell>
<div />
</ChatShell>,
);
const trigger = screen.getByRole("button", { name: "Open chat navigation" });
await user.click(trigger);
expect(await screen.findByRole("dialog", { name: "LiteLLM Chat" })).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
await waitFor(() => expect(trigger).toHaveFocus());
});
});

View file

@ -17,7 +17,7 @@ describe("getChatRoutes under server_root_path", () => {
vi.unstubAllEnvs();
});
it("reflects a server root path applied after the module is loaded", async () => {
it("should reflect a server root path applied after the module is loaded", async () => {
const { getChatRoutes } = await import("./ChatShell");
const { setServerRootPath } = await import("@/lib/serverRootPath");
@ -25,6 +25,7 @@ describe("getChatRoutes under server_root_path", () => {
const routes = getChatRoutes();
expect(routes.chats).toBe("/gw/ui/chat");
expect(routes.models).toBe("/gw/ui/chat/models");
expect(routes.integrations).toBe("/gw/ui/chat/integrations");
expect(routes.credentials).toBe("/gw/ui/chat/credentials");
expect(routes.apiKeys).toBe("/gw/ui/chat/api-keys");
@ -32,13 +33,14 @@ describe("getChatRoutes under server_root_path", () => {
expect(routes.usage).toBe("/gw/ui/chat/usage");
});
it("builds /ui-rooted paths when no server root path is set", async () => {
it("should build /ui-rooted paths when no server root path is set", async () => {
const { getChatRoutes } = await import("./ChatShell");
const { setServerRootPath } = await import("@/lib/serverRootPath");
setServerRootPath("/");
expect(getChatRoutes().chats).toBe("/ui/chat");
expect(getChatRoutes().models).toBe("/ui/chat/models");
expect(getChatRoutes().integrations).toBe("/ui/chat/integrations");
});
});

View file

@ -1,91 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import { renderWithProviders as render } from "@/../tests/test-utils";
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/uiHref", () => ({ uiHref: (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");
fireEvent.click(screen.getByRole("button", { name: "Logs" }));
expect(mockPush).toHaveBeenCalledWith("/ui/chat/logs");
});
it("marks Logs active on the logs route", () => {
mockUsePathname.mockReturnValue("/ui/chat/logs");
render(
<ChatShell>
<div />
</ChatShell>,
);
expect(screen.getByRole("button", { name: "Logs" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("button", { name: "Usage" })).not.toHaveAttribute("aria-current");
});
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");
});
});

View file

@ -1,18 +1,47 @@
"use client";
import React from "react";
import React, { useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { Plus, MessageSquare, LayoutGrid, KeyRound, Lock, BarChart3, ScrollText } from "lucide-react";
import {
Plus,
MessageSquare,
Network,
LayoutGrid,
KeyRound,
Lock,
BarChart3,
ScrollText,
PanelLeftClose,
PanelLeftOpen,
Search,
Settings2,
ChevronDown,
Info,
Settings,
LogOut,
} from "lucide-react";
import { useMediaQuery } from "usehooks-ts";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import ThemeToggle from "@/components/ThemeToggle/ThemeToggle";
import { useLogout } from "@/app/(dashboard)/hooks/useLogout";
import { uiHref } from "@/utils/uiHref";
import { useChatShell } from "@/contexts/ChatShellContext";
import ViewSwitcher from "@/components/Navbar/ViewSwitcher";
import ConversationList from "./ConversationList";
export function getChatRoutes() {
const base = uiHref("chat");
return {
chats: base,
models: `${base}/models`,
integrations: `${base}/integrations`,
credentials: `${base}/credentials`,
apiKeys: `${base}/api-keys`,
@ -38,110 +67,209 @@ function NavItem({ icon, label, onClick, active = false }: NavItemProps) {
onClick={onClick}
variant="ghost"
aria-current={active ? "page" : undefined}
className={`w-full justify-start gap-2.5 px-2.5 font-medium hover:bg-sidebar-accent ${
active ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-muted-foreground"
}`}
className={`chat-nav-item moyai-chat-rail-button w-full justify-start ${active ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-sidebar-foreground"}`}
>
<span className="shrink-0">{icon}</span>
{icon}
<span className="flex-1 text-left">{label}</span>
</Button>
);
}
interface ChatShellProps {
children: React.ReactNode;
}
const ChatShell: React.FC<ChatShellProps> = ({ children }) => {
const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const router = useRouter();
const pathname = stripTrailingSlash(usePathname() ?? "");
const { conversations, activeConversationId, deleteConversation, renameConversation } = useChatShell();
const {
accessToken,
userEmail,
userId,
conversations,
activeConversationId,
deleteConversation,
renameConversation,
} = useChatShell();
const handleLogout = useLogout(accessToken);
const isDesktop = useMediaQuery("(min-width: 601px)", { initializeWithValue: false });
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
if (isDesktop && sidebarOpen) setSidebarOpen(false);
const collapsed = isDesktop && sidebarCollapsed;
const routes = getChatRoutes();
const isChatsRoute = pathname === routes.chats;
const accountName = userEmail?.split("@")[0] || userId || "Account";
const workspaceItems = [
{ label: "Credentials", route: routes.credentials, Icon: KeyRound },
{ label: "API Keys", route: routes.apiKeys, Icon: Lock },
{ label: "Logs", route: routes.logs, Icon: ScrollText },
{ label: "Usage", route: routes.usage, Icon: BarChart3 },
];
const workspaceActive = workspaceItems.some((item) => item.route === pathname);
const navigate = (href: string) => {
setSidebarOpen(false);
setSearchOpen(false);
router.push(href);
};
const sidebar = (
<>
<div className="moyai-chat-brand-row">
<ViewSwitcher className="moyai-chat-workspace-switcher" contentClassName="chat-theme min-w-60" />
<Button
variant="ghost"
size="icon-sm"
className="moyai-chat-search"
aria-label="Search chats"
title="Search chats (⌘K)"
onClick={() => setSearchOpen(true)}
>
<Search className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={collapsed ? "Open sidebar" : "Close sidebar"}
aria-expanded={!collapsed}
onClick={() => (isDesktop ? setSidebarCollapsed(!sidebarCollapsed) : setSidebarOpen(false))}
>
{collapsed ? <PanelLeftOpen className="size-4" /> : <PanelLeftClose className="size-4" />}
</Button>
</div>
<Button
variant="ghost"
onClick={() => navigate(routes.chats)}
className="chat-new-conversation moyai-chat-rail-button w-full justify-start"
>
<Plus className="size-4" />
New Chat
</Button>
<nav aria-label="Chat workspace" className="moyai-chat-destinations">
<NavItem
icon={<MessageSquare className="size-4" />}
label="Chats"
onClick={() => navigate(routes.chats)}
active={isChatsRoute}
/>
<NavItem
icon={<Network className="size-4" />}
label="Models"
onClick={() => navigate(routes.models)}
active={pathname === routes.models}
/>
<NavItem
icon={<LayoutGrid className="size-4" />}
label="Integrations"
onClick={() => navigate(routes.integrations)}
active={pathname === routes.integrations}
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
className={`chat-nav-item moyai-chat-rail-button w-full justify-start ${workspaceActive ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-sidebar-foreground"}`}
aria-current={workspaceActive ? "page" : undefined}
/>
}
>
<Settings2 className="size-4" />
<span className="flex-1 text-left">Workspace</span>
<ChevronDown className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" className="chat-theme min-w-44">
{workspaceItems.map(({ label, route, Icon }) => (
<DropdownMenuItem
key={route}
aria-current={pathname === route ? "page" : undefined}
onClick={() => navigate(route)}
>
<Icon className="size-4" />
{label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<p className="px-2 py-1 text-xs text-muted-foreground">
Chat is a pre-v0 preview and may change unexpectedly. Do not use in production.
</p>
<DropdownMenuItem
render={
<a href="https://github.com/BerriAI/litellm/discussions/32085" target="_blank" rel="noreferrer" />
}
>
<Info className="size-4" />
Share feedback
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</nav>
<div className="moyai-chat-history flex min-h-0 flex-1 flex-col overflow-hidden">
<ConversationList
conversations={conversations}
activeConversationId={activeConversationId}
onSelect={(id) => navigate(`${routes.chats}?id=${encodeURIComponent(id)}`)}
onDelete={(id) => {
deleteConversation(id);
if (id === activeConversationId) navigate(routes.chats);
}}
onRename={renameConversation}
searchOpen={searchOpen}
onSearchOpenChange={setSearchOpen}
hideSearchButton
/>
</div>
<footer className="moyai-chat-account">
<span className="min-w-0 flex-1 truncate text-[13px]" title={userEmail || userId}>
{accountName}
</span>
<ThemeToggle />
<Button variant="ghost" size="icon-sm" aria-label="Settings" onClick={() => navigate(routes.credentials)}>
<Settings className="size-4" />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Sign out" onClick={handleLogout}>
<LogOut className="size-4" />
</Button>
</footer>
</>
);
return (
<div className="flex h-full w-full flex-col bg-background overflow-hidden">
<div className="shrink-0 border-b border-warning/20 bg-warning/10 px-4 py-1.5 text-center text-[13px] text-warning">
This is a pre-v0 feature. Do not use in production, it may change unexpectedly. Please share feedback{" "}
<a
href="https://github.com/BerriAI/litellm/discussions/32085"
target="_blank"
rel="noreferrer"
className="font-medium underline"
>
here
</a>
.
</div>
<div className="flex flex-1 min-h-0 overflow-hidden">
<div className="shrink-0 bg-sidebar border-sidebar-border border-r flex flex-col overflow-hidden w-[260px]">
<div className="px-2 pt-3 pb-1 shrink-0">
<Button onClick={() => router.push(routes.chats)} className="w-full justify-start gap-2.5">
<Plus className="h-4 w-4" />
New Chat
</Button>
<div
className={`chat-theme chat-workspace moyai-chat-shell flex h-full w-full overflow-hidden text-foreground ${collapsed ? "moyai-chat-collapsed" : ""}`}
>
{isDesktop ? (
<aside aria-label="Chat navigation" className="chat-glass-sidebar moyai-chat-sidebar">
{sidebar}
</aside>
) : (
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<div className="moyai-chat-mobile-header z-chrome flex h-16 shrink-0 items-center gap-2 px-3">
<SheetTrigger
render={
<Button
variant="ghost"
size="icon-lg"
className="moyai-chat-mobile-trigger"
aria-label="Open chat navigation"
/>
}
>
<PanelLeftOpen className="size-5" />
</SheetTrigger>
<ViewSwitcher className="moyai-chat-workspace-switcher" contentClassName="chat-theme min-w-60" />
</div>
<Separator className="mx-2 mt-2 shrink-0" />
<div className="px-2 py-1 shrink-0">
<NavItem
icon={<MessageSquare className="h-4 w-4" />}
label="Chats"
onClick={() => router.push(routes.chats)}
active={isChatsRoute}
/>
<NavItem
icon={<LayoutGrid className="h-4 w-4" />}
label="Integrations"
onClick={() => router.push(routes.integrations)}
active={pathname === routes.integrations}
/>
<NavItem
icon={<KeyRound className="h-4 w-4" />}
label="Credentials"
onClick={() => router.push(routes.credentials)}
active={pathname === routes.credentials}
/>
<NavItem
icon={<Lock className="h-4 w-4" />}
label="API Keys"
onClick={() => router.push(routes.apiKeys)}
active={pathname === routes.apiKeys}
/>
<NavItem
icon={<ScrollText className="h-4 w-4" />}
label="Logs"
onClick={() => router.push(routes.logs)}
active={pathname === routes.logs}
/>
<NavItem
icon={<BarChart3 className="h-4 w-4" />}
label="Usage"
onClick={() => router.push(routes.usage)}
active={pathname === routes.usage}
/>
</div>
<Separator className="mx-2 shrink-0" />
<div className="flex-1 overflow-hidden flex flex-col">
<ConversationList
conversations={conversations}
activeConversationId={activeConversationId}
onSelect={(id) => router.push(`${routes.chats}?id=${id}`)}
onDelete={(id) => {
deleteConversation(id);
if (id === activeConversationId) router.push(routes.chats);
}}
onRename={renameConversation}
/>
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden min-w-0">{children}</div>
</div>
<SheetContent
side="left"
showCloseButton={false}
className="chat-theme chat-mobile-sidebar moyai-chat-sidebar"
>
<SheetHeader className="sr-only">
<SheetTitle>LiteLLM Chat</SheetTitle>
<SheetDescription>Conversations and chat workspace navigation</SheetDescription>
</SheetHeader>
{sidebar}
</SheetContent>
</Sheet>
)}
<main className="chat-glass-pane relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">{children}</main>
</div>
);
};

View file

@ -0,0 +1,89 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import ConversationList from "./ConversationList";
import type { Conversation } from "./types";
const conversations: Conversation[] = [
{
id: "design",
title: "Design review",
model: "test-model",
messages: [],
mcpServerNames: [],
createdAt: 1,
updatedAt: 1,
},
{
id: "photos",
title: "Photo ideas",
model: "test-model",
messages: [],
mcpServerNames: [],
createdAt: 2,
updatedAt: 2,
},
];
describe("ConversationList navigation", () => {
it("should render named conversation actions and the active selection", () => {
render(
<ConversationList
conversations={conversations}
activeConversationId="design"
onSelect={vi.fn()}
onDelete={vi.fn()}
onRename={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "Design review", exact: true })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("button", { name: "Rename Design review" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Delete Design review" })).toBeInTheDocument();
});
it("should select a conversation with the keyboard", async () => {
const onSelect = vi.fn();
const user = userEvent.setup();
render(
<ConversationList
conversations={conversations}
activeConversationId={null}
onSelect={onSelect}
onDelete={vi.fn()}
onRename={vi.fn()}
/>,
);
await user.tab();
expect(screen.getByRole("button", { name: "Search chats" })).toHaveFocus();
await user.tab();
expect(screen.getByRole("button", { name: "Design review", exact: true })).toHaveFocus();
await user.keyboard("{Enter}");
expect(onSelect).toHaveBeenCalledWith("design");
});
it("should filter chats from visible search and select a result", async () => {
const onSelect = vi.fn();
const user = userEvent.setup();
render(
<ConversationList
conversations={conversations}
activeConversationId={null}
onSelect={onSelect}
onDelete={vi.fn()}
onRename={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: "Search chats" }));
const dialog = await screen.findByRole("dialog", { name: "Search conversations" });
act(() =>
fireEvent.change(screen.getByRole("textbox", { name: "Search conversations" }), { target: { value: "photo" } }),
);
expect(within(dialog).queryByRole("button", { name: /Design review/ })).not.toBeInTheDocument();
await user.click(within(dialog).getByRole("button", { name: /Photo ideas/ }));
expect(onSelect).toHaveBeenCalledWith("photos");
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
});

View file

@ -1,10 +1,10 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Pencil, Trash2, Search, MessageSquare } from "lucide-react";
import { Pencil, Trash2, Search, MessageSquare, Clock3 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
@ -27,6 +27,9 @@ interface Props {
onSelect: (id: string) => void;
onDelete: (id: string) => void;
onRename: (id: string, newTitle: string) => void;
searchOpen?: boolean;
onSearchOpenChange?: (open: boolean) => void;
hideSearchButton?: boolean;
}
type DateGroup = "Recents" | "Yesterday" | "Last 7 Days" | "Older";
@ -109,18 +112,16 @@ const ConversationRow: React.FC<ConversationRowProps> = ({ conv, isActive, onSel
}
};
const truncatedTitle = conv.title.length > 40 ? conv.title.slice(0, 40) + "\u2026" : conv.title;
return (
<div
onClick={() => !editing && onSelect(conv.id)}
className={`group flex items-center px-2 py-1.5 rounded-md cursor-pointer transition-colors min-h-[34px] relative ${
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
className={`chat-conversation-row group flex items-center rounded-lg transition-colors min-h-9 relative ${
isActive ? "bg-sidebar-accent text-sidebar-accent-foreground" : "hover:bg-sidebar-accent"
}`}
>
{editing ? (
<Input
ref={inputRef}
aria-label="Conversation title"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
@ -130,24 +131,39 @@ const ConversationRow: React.FC<ConversationRowProps> = ({ conv, isActive, onSel
/>
) : (
<>
<span
className={`flex-1 text-[13px] overflow-hidden whitespace-nowrap text-ellipsis ${
<Button
variant="ghost"
onClick={() => onSelect(conv.id)}
aria-label={conv.title}
aria-current={isActive ? "page" : undefined}
className={`min-w-0 flex-1 justify-start text-[13px] hover:bg-sidebar-accent ${
isActive ? "font-medium" : ""
}`}
title={conv.title}
>
{truncatedTitle}
</span>
<span className="min-w-0 flex-1 text-left">
<span className="block truncate">{conv.title}</span>
<span className="moyai-chat-conversation-meta block truncate text-xs font-normal text-muted-foreground">
{conv.model} · {dayjs(conv.updatedAt).format("MMM D")}
</span>
</span>
</Button>
<div
className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
className="chat-conversation-actions flex gap-0.5 pr-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity shrink-0"
onClick={(e) => e.stopPropagation()}
>
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger
render={
<Button onClick={startEditing} variant="ghost" size="icon-xs" className="text-muted-foreground">
<Button
onClick={startEditing}
variant="ghost"
size="icon-xs"
aria-label={`Rename ${conv.title}`}
className="text-muted-foreground"
>
<Pencil className="h-3 w-3" />
</Button>
}
@ -168,6 +184,7 @@ const ConversationRow: React.FC<ConversationRowProps> = ({ conv, isActive, onSel
<Button
variant="ghost"
size="icon-xs"
aria-label={`Delete ${conv.title}`}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
@ -181,7 +198,7 @@ const ConversationRow: React.FC<ConversationRowProps> = ({ conv, isActive, onSel
</TooltipContent>
</Tooltip>
</TooltipProvider>
<AlertDialogContent>
<AlertDialogContent className="chat-theme">
<AlertDialogHeader>
<AlertDialogTitle>Delete this conversation?</AlertDialogTitle>
<AlertDialogDescription>This action cannot be undone</AlertDialogDescription>
@ -231,11 +248,13 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, conversations, onSelect
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-[480px] p-4 gap-0">
<DialogContent className="chat-theme sm:max-w-[480px] p-4 gap-0">
<DialogTitle className="sr-only">Search conversations</DialogTitle>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
autoFocus
aria-label="Search conversations"
placeholder="Search conversations\u2026"
value={query}
onChange={(e) => setQuery(e.target.value)}
@ -243,24 +262,24 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, conversations, onSelect
/>
</div>
<ScrollArea className="max-h-[320px]">
<ScrollArea className="h-[320px]">
{filtered.length === 0 ? (
<div className="text-center py-6 text-muted-foreground text-sm">No conversations found</div>
) : (
filtered.map((conv) => {
const truncated = conv.title.length > 55 ? conv.title.slice(0, 55) + "\u2026" : conv.title;
return (
<div
<Button
key={conv.id}
variant="ghost"
onClick={() => handleSelect(conv.id)}
className="flex items-center gap-2 px-2.5 py-2 rounded-md cursor-pointer transition-colors hover:bg-accent/50"
className="w-full justify-start gap-2 px-2.5"
>
<MessageSquare className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-[13px] flex-1 truncate">{truncated}</span>
<span className="text-left text-[13px] flex-1 truncate">{conv.title}</span>
<span className="text-[11px] text-muted-foreground shrink-0 ml-auto">
{dayjs(conv.updatedAt).format("MMM D")}
</span>
</div>
</Button>
);
})
)}
@ -270,15 +289,29 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, conversations, onSelect
);
};
const ConversationList: React.FC<Props> = ({ conversations, activeConversationId, onSelect, onDelete, onRename }) => {
const [searchModalOpen, setSearchModalOpen] = useState(false);
const ConversationList: React.FC<Props> = ({
conversations,
activeConversationId,
onSelect,
onDelete,
onRename,
searchOpen,
onSearchOpenChange,
hideSearchButton = false,
}) => {
const [localSearchOpen, setLocalSearchOpen] = useState(false);
const searchModalOpen = searchOpen ?? localSearchOpen;
const setSearchModalOpen = onSearchOpenChange ?? setLocalSearchOpen;
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setSearchModalOpen((prev) => !prev);
}
}, []);
const handleGlobalKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setSearchModalOpen(!searchModalOpen);
}
},
[searchModalOpen, setSearchModalOpen],
);
useEffect(() => {
document.addEventListener("keydown", handleGlobalKeyDown);
@ -290,18 +323,34 @@ const ConversationList: React.FC<Props> = ({ conversations, activeConversationId
return (
<>
<div className="flex flex-col h-full w-full overflow-hidden">
{!hideSearchButton && (
<div className="px-2 pt-2">
<Button
variant="ghost"
onClick={() => setSearchModalOpen(true)}
className="chat-nav-item w-full justify-start gap-2.5 text-muted-foreground hover:bg-sidebar-accent"
>
<Search className="size-4" />
Search chats
</Button>
</div>
)}
<ScrollArea className="flex-1 h-0 px-1.5 pt-2">
{grouped.length === 0 ? (
<div className="text-center text-muted-foreground/60 text-xs mt-8 px-3">
No conversations yet
<br />
Start a new chat above
<div>
<div className="moyai-chat-history-heading flex items-center gap-2 text-xs text-sidebar-foreground">
<Clock3 className="size-3.5" aria-hidden="true" />
<span className="flex-1">Recent</span>
<span>0</span>
</div>
<p className="moyai-chat-empty-history text-xs text-muted-foreground">No chats here yet.</p>
</div>
) : (
grouped.map(({ group, items }) => (
<div key={group} className="mb-2">
<div className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider px-2 pt-2 pb-1">
{group}
<div className="moyai-chat-history-heading flex items-center gap-2 px-2 pt-2 pb-1 text-xs font-normal text-sidebar-foreground">
<Clock3 className="size-3.5" aria-hidden="true" />
{group === "Recents" ? "Recent" : group}
</div>
{items.map((conv) => (
<ConversationRow

View file

@ -0,0 +1,125 @@
import type { ChatImageAttachment, Conversation } from "./types";
const DATABASE_NAME = "litellm_chat_media_v1";
const STORE_NAME = "images";
let databasePromise: Promise<IDBDatabase> | undefined;
const imageWrites = new Map<string, Promise<void>>();
function openDatabase(): Promise<IDBDatabase> {
if (!databasePromise) {
databasePromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(STORE_NAME, { keyPath: ["userId", "id"] });
};
request.onsuccess = () => {
const database = request.result;
database.onversionchange = () => {
database.close();
databasePromise = undefined;
imageWrites.clear();
};
resolve(database);
};
request.onerror = () => reject(request.error ?? new Error("Image storage is unavailable."));
request.onblocked = () => reject(new Error("Image storage is blocked by another browser tab."));
}).catch((error: unknown) => {
databasePromise = undefined;
throw error;
});
}
return databasePromise;
}
export function getConversationImages(conversations: Conversation[]): ChatImageAttachment[] {
return conversations.flatMap((conversation) =>
conversation.messages.flatMap((message) => [...(message.attachments ?? []), ...(message.generatedImages ?? [])]),
);
}
export function persistChatImages(userId: string, images: ChatImageAttachment[]): Promise<void> {
return Promise.all(
images
.filter((image) => image.url.startsWith("data:"))
.map((image) => {
const key = JSON.stringify([userId, image.id]);
let write = imageWrites.get(key);
if (!write) {
write = openDatabase()
.then(
(database) =>
new Promise<void>((resolve, reject) => {
const transaction = database.transaction(STORE_NAME, "readwrite");
transaction.objectStore(STORE_NAME).put({ userId, id: image.id, url: image.url });
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error ?? new Error("Could not save the image."));
transaction.onabort = () => reject(transaction.error ?? new Error("Saving the image was canceled."));
}),
)
.catch((error: unknown) => {
imageWrites.delete(key);
throw error;
});
imageWrites.set(key, write);
}
return write;
}),
).then(() => undefined);
}
export async function loadChatImages(userId: string, ids: string[]): Promise<Map<string, string>> {
if (!ids.length) return new Map();
const database = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORE_NAME, "readonly");
const loaded = new Map<string, string>();
for (const id of new Set(ids)) {
const request = transaction.objectStore(STORE_NAME).get([userId, id]);
request.onsuccess = () => {
if (typeof request.result?.url === "string") loaded.set(id, request.result.url);
};
}
transaction.oncomplete = () => resolve(loaded);
transaction.onerror = () => reject(transaction.error ?? new Error("Could not restore saved images."));
transaction.onabort = () => reject(transaction.error ?? new Error("Restoring saved images was canceled."));
});
}
export async function deleteChatImages(userId: string, ids: string[]): Promise<void> {
if (!ids.length) return;
for (const id of ids) imageWrites.delete(JSON.stringify([userId, id]));
const database = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORE_NAME, "readwrite");
for (const id of ids) transaction.objectStore(STORE_NAME).delete([userId, id]);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error ?? new Error("Could not delete saved images."));
transaction.onabort = () => reject(transaction.error ?? new Error("Deleting saved images was canceled."));
});
}
export function withoutImageData(conversations: Conversation[]): Conversation[] {
const stripData = (images: ChatImageAttachment[] | undefined) =>
images?.map((image) => (image.url.startsWith("data:") ? { ...image, url: "" } : image));
return conversations.map((conversation) => ({
...conversation,
messages: conversation.messages.map((message) => ({
...message,
...(message.attachments ? { attachments: stripData(message.attachments) } : {}),
...(message.generatedImages ? { generatedImages: stripData(message.generatedImages) } : {}),
})),
}));
}
export function withRestoredImages(conversations: Conversation[], images: Map<string, string>): Conversation[] {
const restore = (attachments: ChatImageAttachment[]) =>
attachments.map((image) => (!image.url && images.has(image.id) ? { ...image, url: images.get(image.id)! } : image));
return conversations.map((conversation) => ({
...conversation,
messages: conversation.messages.map((message) => ({
...message,
...(message.attachments ? { attachments: restore(message.attachments) } : {}),
...(message.generatedImages ? { generatedImages: restore(message.generatedImages) } : {}),
})),
}));
}

View file

@ -2,6 +2,8 @@
Tactical guide for building chat UI features. Copy patterns exactly — don't improvise spacing, colors, or components.
The standalone `/ui/chat` workspace reuses Moyai's workspace structure and visual rules from `frontend/theme.css`, `native/web/style.css`, `brand.css`, `sidebar-layout.css`, and `home.css` at Moyai commit `1118ab0` (October 20, 2026). This is an intentional visual departure from the dashboard: neutral white/charcoal surfaces, restrained purple actions, and translucent navigation and composers. Shared controls and chat behavior remain owned by the existing LiteLLM primitives.
> **Agents:** Read `AGENTS.md` alongside this file. It contains decision trees, the pre-commit checklist, and the rules that govern when to use each pattern here. This file is the _what_; `AGENTS.md` is the _when and how_.
---
@ -9,10 +11,10 @@ Tactical guide for building chat UI features. Copy patterns exactly — don't im
## Stack
- **Next.js 16** App Router, TypeScript
- **Tailwind CSS v4** — utility classes only, no custom CSS except in `globals.css`
- **Tailwind CSS v4** — shared primitives and utilities, with the standalone Moyai port scoped in `globals.css` (theme and rail) and `moyai/moyai-chat.css` (home, composer and transcript)
- **shadcn/ui**: import from `@/components/ui/*`. Available today: `alert-dialog`, `badge`, `bubble`, `button`, `card`, `collapsible`, `dialog`, `input`, `input-group`, `label`, `popover`, `scroll-area`, `select`, `separator`, `sheet`, `skeleton`, `sonner`, `switch`, `table`, `tabs`, `textarea`, `tooltip`. Use these shared primitives when composing chat features
- **lucide-react** — only icon library, no emoji in UI
- System font stack (`-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`), inherited from `ChatShell`'s root
- Moyai's General Sans font from its original Fontshare stylesheet, with bundled Geist fallback; Geist Mono is bundled for model names
---
@ -20,6 +22,24 @@ Tactical guide for building chat UI features. Copy patterns exactly — don't im
Never hardcode hex values. Use these CSS variables via Tailwind classes — all verified present in `src/app/globals.css`.
`ChatShell` applies `.chat-theme`, which scopes the palette to standalone Chat. Its dialogs and mobile Sheet also carry `.chat-theme` because they render through portals. Do not apply these overrides globally or to the Playground. Light and dark modes follow LiteLLM's existing theme switch. Keep status colors semantic.
### Glass surfaces
- `.chat-workspace`: static neutral backdrop, with no blur on this layout ancestor.
- `.chat-glass-sidebar`: Moyai's frosted navigation rail with its original neutral sheen.
- `.chat-glass-pane`: transparent on the home view so the source glass backdrop remains visible; quiet reading fill on conversation and workspace pages, without nested backdrop filtering.
- `.moyai-chat-home-composer`: Moyai's 10px rounded glass editor, 132px minimum desktop entry height, 14px blur, original sheen/shadow and bordered lower action row. `MoyaiChatComposer` owns its layout. Model selection sits at the right beside send. Message and code surfaces stay solid, using `--chat-message` / `--chat-message-border`; editor focus uses `--chat-focus-border`.
- `.chat-mobile-sidebar`: elevated, nearly opaque Sheet surface so navigation remains legible over conversation content.
Glass progressively enhances solid fills inside `@supports`. Reduced transparency and forced colors disable filtering, sheen and decorative shadows. Reduced motion disables chrome transitions. Use the original static dot/grid backdrop and source home heading measurements. Do not add animated backgrounds or an outer rounded frame.
The standalone `ChatModelPicker` exposes the account's chat and image-generation models from the composer action row. Its portalled popover carries `.chat-theme`; keep model names, capabilities and selection states readable in both themes. Attachment and MCP tools stay beside the composer.
The **Models** sidebar destination opens `/ui/chat/models` inside the same shell. It reuses the existing Model Hub catalog, filters, pricing and details in a read-only `chat` variant. Admins see the authenticated catalog and other users see the published catalog, matching the dashboard's existing access rules. The page uses `.moyai-models-page` for scrolling and `.chat-model-hub-card` for the bordered Moyai surface. Portalled model details carry `.chat-theme`; catalog management and other hub tabs stay in the dashboard.
Show available providers in a single-select dropdown beside model search. “All providers” clears the provider filter while keeping the other filters. The dropdown includes provider logos and uses the Chat theme in its portal. Provider logos reuse the existing asset mapping; white logo plates preserve the colors and contrast of the original assets in both themes. Unknown or failed logos fall back to a provider initial. The model list uses compact search and capability controls, quiet separators, and provider identities beside model names. Keep these styles scoped to Chat.
### Colors
| Token | Tailwind class | Use |
@ -38,7 +58,7 @@ Never hardcode hex values. Use these CSS variables via Tailwind classes — all
| `--sidebar-accent-foreground` | `text-sidebar-accent-foreground` | Active nav item text |
| `--sidebar-border` | `border-sidebar-border` | Sidebar's own dividers/right border |
**Known gotcha — verified in `globals.css`:** `--accent`, `--secondary`, and `--muted` all resolve to the _identical_ OKLCH value in both light and dark themes. Using `bg-accent` for a "selected" state against a `bg-secondary` container is **invisible** — there is zero contrast. This bit us repeatedly in this exact sidebar. Rules:
**Known gotcha:** generic `--accent`, `--secondary`, and `--muted` values may coincide in the dashboard theme. The standalone palette deliberately separates its sidebar selection from the chrome. Always use the sidebar-specific tokens rather than relying on incidental contrast from a generic token. Rules:
- Sidebar container: `bg-sidebar`, never `bg-secondary` or `bg-accent`.
- Active/selected nav item: `bg-sidebar-accent text-sidebar-accent-foreground`, never `bg-accent` on its own inside the sidebar.
@ -59,7 +79,9 @@ Never hardcode hex values. Use these CSS variables via Tailwind classes — all
```tsx
// Page/greeting heading
<h1 className="text-[28px] font-semibold tracking-tight">Good afternoon</h1>
<header className="moyai-chat-home-header">
<h1>What are we building?</h1>
</header> // Moyai: 500 weight, clamp(32px, 3.4vw, 46px), 1.13 line-height
// Section heading (panel titles: "Your API Keys", "Your Usage")
<h2 className="text-base font-semibold tracking-tight">Your Usage</h2>
@ -91,7 +113,7 @@ Never hardcode hex values. Use these CSS variables via Tailwind classes — all
| Context | Value |
| ---------------------------------- | ------------------------------------------- |
| Sidebar outer padding | `p-2` (8px) |
| Sidebar outer padding | `8px 10px 8px` |
| Nav item padding | `px-2.5 py-2` |
| Inline icon-to-label gap | `gap-2.5` in nav items, `gap-1.5` elsewhere |
| Section divider margin | `mx-2` |
@ -104,11 +126,11 @@ Never hardcode hex values. Use these CSS variables via Tailwind classes — all
## Border Radius
```tsx
rounded-md // 8px — buttons, inputs, nav items, bordered divs
rounded-lg // 10px — panel cards, table containers
rounded-xl // 14px — chat input composer
rounded-2xl // 16px — chat message bubbles
rounded-full // pills, avatars, status dots
rounded - md; // 8px — buttons, inputs, nav items, bordered divs
rounded - lg; // 10px — panel cards, table containers
rounded - xl; // 12px — shared controls (Moyai composer uses its source 10px rule)
rounded - lg; // 8px — Moyai user message bubbles
rounded - full; // pills, avatars, status dots
```
---
@ -154,7 +176,11 @@ No dedicated shadcn `Sidebar` block is installed (see gaps). Build nav items fro
</Button>
```
There is no collapsible/icon-rail sidebar mode — it was removed (added complexity, no real use case, and its collapse toggle button had nowhere sensible to live). Don't reintroduce collapse state without a concrete reason.
The desktop sidebar follows Moyai: 240px wide, 230px below 1250px, and 52px when collapsed. Its top row uses the shared `ViewSwitcher`, search and collapse controls above a transparent 32px New Chat button and compact destinations. The switcher exposes AI Gateway, Moyai, Chat and registered plugins, with the active workspace marked. Chat uses the same rounded speech-bubble icon in the trigger and menu, including the dashboard entry point. The collapsed rail keeps the icon-only switcher above its expand button. Workspace keeps credentials, API keys, logs, usage and preview feedback reachable in one menu. Recent conversation rows show title, model and date at 48px height. The footer shows account identity, appearance, settings and sign out. The shared dashboard Navbar is absent from this standalone surface; only its product switcher is reused.
At 600px and below, a compact header keeps the workspace switcher beside the labelled menu button, visible even when the navigation Sheet is closed. The same navigation renders once inside the existing `Sheet`. Choosing a route or conversation closes the Sheet. Do not render two concurrent conversation lists: they each own a search shortcut listener. The collapsed desktop list remains mounted for keyboard search, but is visually hidden. The Chat layout owns an authenticated `PluginModeProvider` so returning to AI Gateway clears a previously selected plugin.
The landing page reuses Moyai's left-aligned 720px home content, dot/grid backdrop, 46px heading, 132px editor and compact starter rows. `ChatModelPicker` sits at the right of the composer action row, matching Moyai's model selector position. Selecting a model chooses chat or image generation. Preserve LiteLLM's existing model access and request behavior; do not copy Moyai product or backend logic.
### ScrollArea
@ -176,7 +202,7 @@ If you need "shrink to fit content, cap at N px" rather than "always N px," you
### Input / Textarea
Use `Input` and `Textarea` from `@/components/ui`. The shared `ChatComposer` uses `InputGroupTextarea` and owns submission, cancellation and IME handling. Reuse it for new chat surfaces
Use `Input` and `Textarea` from `@/components/ui`. Standalone Chat uses `MoyaiChatComposer` for the source editor and toolbar arrangement, preserving submission, cancellation, image attachments and IME handling. Other chat surfaces continue using the shared `ChatComposer` / `InputGroupTextarea`; this visual port is scoped to standalone Chat.
```tsx
import { Input } from "@/components/ui/input";
@ -315,16 +341,13 @@ import { Skeleton } from "@/components/ui/skeleton";
### Chat shell (sidebar + routed content)
```tsx
<div className="flex h-full w-full flex-col bg-background overflow-hidden">
<div className="chat-theme chat-workspace flex h-full w-full flex-col overflow-hidden text-foreground">
{/* optional full-width banner */}
<div className="flex flex-1 min-h-0 overflow-hidden">
<aside
className="shrink-0 bg-sidebar border-r flex flex-col overflow-hidden"
style={{ width: collapsed ? 56 : 260 }}
>
{/* nav items, conversation list */}
<aside className="chat-glass-sidebar w-[248px] shrink-0 border-r border-sidebar-border flex flex-col overflow-hidden">
{/* Desktop nav; on mobile use Sheet with the same content instead. */}
</aside>
<main className="flex-1 flex flex-col overflow-hidden min-w-0">{children}</main>
<main className="chat-glass-pane flex-1 flex flex-col overflow-hidden min-w-0">{children}</main>
</div>
</div>
```
@ -372,7 +395,7 @@ The shadcn Sidebar primitive block (`SidebarProvider`/`SidebarMenu`/etc) is stil
```
src/components/chat/
ChatShell.tsx # Sidebar chrome (nav, collapse, conversation list), shared across all /chat/* routes
ChatShell.tsx # Desktop navigation / mobile Sheet, shared across all /chat/* routes
ConversationList.tsx # Date-grouped chat list + Cmd+K search
ChatMessages.tsx # Shared list and single-message rendering (user, assistant, tool)
MCPAppsPanel.tsx # Integrations grid + detail view

View file

@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import OpenAI from "openai";
import {
generateChatImages,
isChatImageUrl,
MAX_CHAT_IMAGE_BYTES,
readChatImage,
toResponsesMessages,
} from "./imageHelpers";
const { generate } = vi.hoisted(() => ({ generate: vi.fn() }));
vi.mock("openai", () => ({
default: vi.fn(function () {
return { images: { generate } };
}),
}));
vi.mock("@/components/networking", () => ({ getGatewayBaseUrl: () => "https://gateway.example/v1" }));
const image = { id: "image-1", name: "design.png", url: "data:image/png;base64,aW1hZ2U=" };
describe("chat image helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should read supported images for preview and requests", async () => {
const result = await readChatImage(new File(["image"], "design.png", { type: "image/png" }));
const expected = { id: expect.any(String), name: "design.png", url: image.url, mimeType: "image/png" };
expect(result).toEqual(expected);
});
it("should reject unsupported, empty, and oversized uploads", async () => {
await expect(readChatImage(new File(["pdf"], "design.pdf", { type: "application/pdf" }))).rejects.toThrow(
"PNG, JPEG",
);
await expect(readChatImage(new File([], "empty.png", { type: "image/png" }))).rejects.toThrow("empty");
const large = new File(["image"], "large.png", { type: "image/png" });
Object.defineProperty(large, "size", { value: MAX_CHAT_IMAGE_BYTES + 1 });
await expect(readChatImage(large)).rejects.toThrow("10 MB");
});
it("should replay user image attachments using Responses API input parts", () => {
expect(
toResponsesMessages([
{ role: "user", content: "Describe this", attachments: [image] },
{ role: "assistant", content: "A design" },
{ role: "user", content: "", attachments: [image] },
{ role: "assistant", content: "", generatedImages: [image] },
{ role: "tool", content: "tool output" },
]),
).toEqual([
{
role: "user",
content: [
{ type: "input_text", text: "Describe this" },
{ type: "input_image", image_url: image.url, detail: "auto" },
],
},
{ role: "assistant", content: "A design" },
{ role: "user", content: [{ type: "input_image", image_url: image.url, detail: "auto" }] },
]);
});
it("should stop a replay if an image could not be restored", () => {
expect(() =>
toResponsesMessages([{ role: "user", content: "Look", attachments: [{ ...image, url: "" }] }]),
).toThrow("Start a new chat and attach it again");
});
it("should reject executable and non-image URLs", () => {
expect(isChatImageUrl("javascript:alert(1)")).toBe(false);
expect(isChatImageUrl("data:text/html;base64,aW1hZ2U=")).toBe(false);
expect(isChatImageUrl("data:image/svg+xml;base64,aW1hZ2U=")).toBe(false);
expect(isChatImageUrl("https://images.example/generated.png")).toBe(true);
});
it("should generate through the configured gateway and accept URLs and base64 results", async () => {
const signal = new AbortController().signal;
generate.mockResolvedValue({ data: [{ url: "https://images.example/generated.png" }, { b64_json: "aW1hZ2U=" }] });
const request = {
prompt: "A small boat",
model: "company-image-model",
accessToken: "test-token",
signal,
};
const result = await generateChatImages(request);
expect(OpenAI).toHaveBeenCalledWith({
apiKey: "test-token",
baseURL: "https://gateway.example/v1",
dangerouslyAllowBrowser: true,
});
expect(generate).toHaveBeenCalledWith({ model: "company-image-model", prompt: "A small boat" }, { signal });
expect(result.map((entry) => entry.url)).toEqual(["https://images.example/generated.png", image.url]);
});
it("should surface generation failures and aborts to the caller", async () => {
generate.mockResolvedValue({ data: [] });
await expect(generateChatImages({ prompt: "Boat", model: "image-model", accessToken: "token" })).rejects.toThrow(
"did not return an image",
);
const aborted = new DOMException("Stopped", "AbortError");
generate.mockRejectedValue(aborted);
await expect(generateChatImages({ prompt: "Boat", model: "image-model", accessToken: "token" })).rejects.toBe(
aborted,
);
});
it("should preserve the generated image format returned by the provider", async () => {
generate.mockResolvedValue({ output_format: "webp", data: [{ b64_json: "aW1hZ2U=" }] });
const images = await generateChatImages({ prompt: "Boat", model: "image-model", accessToken: "token" });
expect(images[0].url).toBe("data:image/webp;base64,aW1hZ2U=");
});
});

View file

@ -0,0 +1,83 @@
import OpenAI from "openai";
import { getGatewayBaseUrl } from "@/components/networking";
import type { MessageType, MultimodalContent } from "@/components/chat_ui/types";
import type { ChatImageAttachment, ChatMessage } from "./types";
export const MAX_CHAT_IMAGES = 4;
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
export const CHAT_IMAGE_ACCEPT = "image/png,image/jpeg,image/webp,image/gif";
const IMAGE_TYPES = new Set(CHAT_IMAGE_ACCEPT.split(","));
const DATA_IMAGE_PATTERN = /^data:image\/(png|jpeg|webp|gif);base64,[a-zA-Z0-9+/]/;
export function isChatImageUrl(url: string): boolean {
if (DATA_IMAGE_PATTERN.test(url)) return true;
try {
const parsed = new URL(url);
return parsed.protocol === "https:" || parsed.protocol === "http:";
} catch {
return false;
}
}
export async function readChatImage(file: File): Promise<ChatImageAttachment> {
if (!IMAGE_TYPES.has(file.type)) throw new Error("Choose a PNG, JPEG, WebP, or GIF image.");
if (file.size === 0) throw new Error(`${file.name} is empty. Choose another image.`);
if (file.size > MAX_CHAT_IMAGE_BYTES) {
throw new Error(`${file.name} is too large. Images must be 10 MB or smaller.`);
}
const url = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string" && DATA_IMAGE_PATTERN.test(reader.result)) resolve(reader.result);
else reject(new Error(`Could not read ${file.name}. Choose the image again.`));
};
reader.onerror = () => reject(new Error(`Could not read ${file.name}. Choose the image again.`));
reader.onabort = () => reject(new Error(`Reading ${file.name} was canceled.`));
reader.readAsDataURL(file);
});
return { id: crypto.randomUUID(), name: file.name, url, mimeType: file.type };
}
export function toResponsesMessages(
messages: Pick<ChatMessage, "role" | "content" | "attachments" | "generatedImages" | "imageGeneration">[],
): MessageType[] {
return messages.flatMap((message): MessageType[] => {
if (message.role === "tool") return [];
if (message.role === "assistant") {
return message.content.trim() ? [{ role: "assistant", content: message.content }] : [];
}
if (!message.attachments?.length) return [{ role: "user", content: message.content }];
const content: MultimodalContent[] = [];
if (message.content.trim()) content.push({ type: "input_text", text: message.content });
for (const attachment of message.attachments) {
if (!isChatImageUrl(attachment.url)) {
throw new Error(`A saved image (${attachment.name}) is unavailable. Start a new chat and attach it again.`);
}
content.push({ type: "input_image", image_url: attachment.url, detail: "auto" });
}
return [{ role: "user", content }];
});
}
export async function generateChatImages({
prompt,
model,
accessToken,
signal,
}: {
prompt: string;
model: string;
accessToken: string;
signal?: AbortSignal;
}): Promise<ChatImageAttachment[]> {
const client = new OpenAI({ apiKey: accessToken, baseURL: getGatewayBaseUrl(), dangerouslyAllowBrowser: true });
const response = await client.images.generate({ model, prompt }, { signal });
const mimeType = `image/${response.output_format ?? "png"}`;
const images = (response.data ?? []).flatMap((image, index): ChatImageAttachment[] => {
const url = image.b64_json ? `data:${mimeType};base64,${image.b64_json}` : image.url;
if (!url || !isChatImageUrl(url)) return [];
return [{ id: crypto.randomUUID(), name: `Generated image ${index + 1}`, url }];
});
if (!images.length) throw new Error("The model did not return an image. Try another prompt or image model.");
return images;
}

View file

@ -0,0 +1,93 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Berri AI
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,116 @@
import { act, createEvent, fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import MoyaiChatComposer from "./MoyaiChatComposer";
function composerProps(overrides: Partial<ComponentProps<typeof MoyaiChatComposer>> = {}) {
return {
value: "Explain this code",
onChange: vi.fn(),
onSubmit: vi.fn(),
onCancel: vi.fn(),
placeholder: "Describe a change, paste an error, or ask a question…",
isLoading: false,
submitDisabled: false,
submitLabel: "Send",
...overrides,
};
}
describe("MoyaiChatComposer", () => {
it("should render the message input with attachment previews and tools", () => {
const props = composerProps({
attachmentPreview: <span role="img" aria-label="Attached design" />,
tools: <span>Attach an image</span>,
modelPicker: <span>Choose a model</span>,
});
render(<MoyaiChatComposer {...props} />);
expect(screen.getByRole("textbox", { name: "Message" })).toHaveValue("Explain this code");
expect(screen.getByRole("img", { name: "Attached design" })).toBeInTheDocument();
expect(screen.getByText("Attach an image")).toBeInTheDocument();
expect(screen.getByText("Choose a model")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
});
it("should forward draft changes and submit on Enter", () => {
const props = composerProps();
render(<MoyaiChatComposer {...props} />);
const editor = screen.getByRole("textbox", { name: "Message" });
act(() => {
fireEvent.change(editor, { target: { value: "New prompt" } });
});
expect(props.onChange).toHaveBeenCalledWith("New prompt");
const enter = createEvent.keyDown(editor, { key: "Enter", code: "Enter" });
act(() => {
fireEvent(editor, enter);
});
expect(enter.defaultPrevented).toBe(true);
expect(props.onSubmit).toHaveBeenCalledOnce();
});
it("should keep Shift+Enter available for a new line", () => {
const props = composerProps();
render(<MoyaiChatComposer {...props} />);
const editor = screen.getByRole("textbox", { name: "Message" });
const enter = createEvent.keyDown(editor, { key: "Enter", code: "Enter", shiftKey: true });
act(() => {
fireEvent(editor, enter);
});
expect(enter.defaultPrevented).toBe(false);
expect(props.onSubmit).not.toHaveBeenCalled();
});
it("should let IME composition consume Enter without submitting", () => {
const props = composerProps();
render(<MoyaiChatComposer {...props} />);
const editor = screen.getByRole("textbox", { name: "Message" });
const enter = createEvent.keyDown(editor, { key: "Enter", code: "Enter", isComposing: true });
act(() => {
fireEvent(editor, enter);
});
expect(enter.defaultPrevented).toBe(false);
expect(props.onSubmit).not.toHaveBeenCalled();
});
it("should block both click and Enter submission while submission is disabled", () => {
const props = composerProps({ submitDisabled: true });
render(<MoyaiChatComposer {...props} />);
const send = screen.getByRole("button", { name: "Send" });
expect(send).toBeDisabled();
act(() => {
fireEvent.click(send);
});
act(() => {
fireEvent.keyDown(screen.getByRole("textbox", { name: "Message" }), { key: "Enter" });
});
expect(props.onSubmit).not.toHaveBeenCalled();
});
it("should stop an active request and prevent another Enter submission", () => {
const props = composerProps({ isLoading: true });
render(<MoyaiChatComposer {...props} />);
expect(screen.queryByRole("button", { name: "Send" })).not.toBeInTheDocument();
act(() => {
fireEvent.keyDown(screen.getByRole("textbox", { name: "Message" }), { key: "Enter" });
});
expect(props.onSubmit).not.toHaveBeenCalled();
act(() => {
fireEvent.click(screen.getByRole("button", { name: "Stop request" }));
});
expect(props.onCancel).toHaveBeenCalledOnce();
});
it("should forward pasted files to the attachment handler", () => {
const onPaste = vi.fn();
const props = composerProps({ onPaste });
render(<MoyaiChatComposer {...props} />);
const image = new File(["image"], "design.png", { type: "image/png" });
const clipboardData = { files: [image], items: [{ kind: "file", type: "image/png", getAsFile: () => image }] };
act(() => {
fireEvent.paste(screen.getByRole("textbox", { name: "Message" }), { clipboardData });
});
expect(onPaste).toHaveBeenCalledOnce();
expect(onPaste.mock.calls[0][0].clipboardData).toBe(clipboardData);
expect(props.onSubmit).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,81 @@
import type { ClipboardEventHandler, ReactNode } from "react";
import { Send, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import "./moyai-chat.css";
interface MoyaiChatComposerProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onCancel: () => void;
placeholder: string;
isLoading: boolean;
submitDisabled: boolean;
submitLabel: string;
onPaste?: ClipboardEventHandler<HTMLTextAreaElement>;
attachmentPreview?: ReactNode;
tools?: ReactNode;
modelPicker?: ReactNode;
inConversation?: boolean;
}
export default function MoyaiChatComposer({
value,
onChange,
onSubmit,
onCancel,
placeholder,
isLoading,
submitDisabled,
submitLabel,
onPaste,
attachmentPreview,
tools,
modelPicker,
inConversation = false,
}: MoyaiChatComposerProps) {
const send = () => {
if (!submitDisabled && !isLoading) onSubmit();
};
const sendButton = isLoading ? (
<Button type="button" size="icon" aria-label="Stop request" onClick={onCancel}>
<Square className="size-4" />
</Button>
) : (
<Button type="button" size="icon" aria-label={submitLabel} disabled={submitDisabled} onClick={send}>
<Send className="size-4" />
</Button>
);
return (
<div className={inConversation ? "moyai-chat-conversation-composer" : "moyai-chat-home-composer"}>
<div className="moyai-chat-composer-entry">
{attachmentPreview}
<div className="moyai-chat-home-editor-input">
<Textarea
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
aria-label="Message"
onPaste={onPaste}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
send();
}
}}
className="moyai-chat-editor"
/>
</div>
{inConversation && <div className="moyai-chat-entry-send">{sendButton}</div>}
</div>
<div className="moyai-chat-composer-actions">
<div className="moyai-chat-home-composer-tools">{tools}</div>
<div className="moyai-chat-send-actions">
{modelPicker}
{!inConversation && sendButton}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,86 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import type { ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import MoyaiChatHome from "./MoyaiChatHome";
import type { Conversation } from "../types";
function homeProps(overrides: Partial<ComponentProps<typeof MoyaiChatHome>> = {}) {
return {
children: <span>Chat composer</span>,
imageMode: false,
draft: "",
busy: false,
conversations: [],
onSelectConversation: vi.fn(),
onPrompt: vi.fn(),
...overrides,
};
}
function conversation(id: string, updatedAt: number): Conversation {
return { id, title: `Chat ${id}`, model: "chat-model", messages: [], mcpServerNames: [], createdAt: 1, updatedAt };
}
describe("MoyaiChatHome", () => {
it("should render the Moyai welcome and composer", () => {
render(<MoyaiChatHome {...homeProps()} />);
expect(screen.getByRole("heading", { level: 1, name: "What are we building?" })).toBeInTheDocument();
expect(screen.getByText("Chat composer")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Recent chats" })).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("No recent chats in this browser yet.");
});
it("should show the three most recently updated chats and open the selected one", () => {
const conversations = [
conversation("first", 1),
conversation("second", 4),
conversation("third", 3),
conversation("fourth", 2),
];
const props = homeProps({ conversations });
render(<MoyaiChatHome {...props} />);
const recent = screen.getByRole("region", { name: "Recent chats" });
const buttons = within(recent).getAllByRole("button");
expect(buttons.map((button) => button.textContent)).toEqual(["Chat second", "Chat third", "Chat fourth"]);
expect(screen.queryByRole("button", { name: "Chat first" })).not.toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByRole("button", { name: "Chat third" }));
});
expect(props.onSelectConversation).toHaveBeenCalledWith("third");
expect(conversations.map((item) => item.id)).toEqual(["first", "second", "third", "fourth"]);
});
it("should pass a starter prompt to the composer without submitting it", () => {
const props = homeProps();
render(<MoyaiChatHome {...props} />);
act(() => {
fireEvent.click(screen.getByRole("button", { name: /Review code/ }));
});
expect(props.onPrompt).toHaveBeenCalledWith("Review my changes for bugs and improvements.");
expect(props.onSelectConversation).not.toHaveBeenCalled();
});
it("should disable starters during a request", () => {
const props = homeProps({ busy: true });
render(<MoyaiChatHome {...props} />);
const starter = screen.getByRole("button", { name: /Review code/ });
expect(starter).toBeDisabled();
act(() => {
fireEvent.click(starter);
});
expect(props.onPrompt).not.toHaveBeenCalled();
});
it("should hide starter choices from assistive technology once a draft is present", () => {
render(<MoyaiChatHome {...homeProps({ draft: "My current prompt" })} />);
expect(screen.queryByRole("group", { name: "Start with a prompt" })).not.toBeInTheDocument();
for (const button of screen.getAllByRole("button", { hidden: true })) expect(button).toBeDisabled();
});
it("should show the image welcome without code starter prompts in image mode", () => {
render(<MoyaiChatHome {...homeProps({ imageMode: true })} />);
expect(screen.getByRole("heading", { level: 1, name: "What are we creating?" })).toBeInTheDocument();
expect(screen.queryByRole("group", { name: "Start with a prompt" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Review code/ })).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,108 @@
import type { ReactNode } from "react";
import { ArrowUpRight, Bug, Clock3, Code2, ListChecks } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { Conversation } from "../types";
import "./moyai-chat.css";
const starters = [
{
label: "Review code",
detail: "Find the edge cases",
prompt: "Review my changes for bugs and improvements.",
Icon: Code2,
},
{ label: "Fix a bug", detail: "Trace it to the source", prompt: "Help me investigate and fix this bug: ", Icon: Bug },
{
label: "Plan a feature",
detail: "Make the next change clear",
prompt: "Help me plan and implement this feature: ",
Icon: ListChecks,
},
];
export default function MoyaiChatHome({
children,
imageMode,
draft,
busy,
conversations,
onSelectConversation,
onPrompt,
}: {
children: ReactNode;
imageMode: boolean;
draft: string;
busy: boolean;
conversations: Conversation[];
onSelectConversation: (id: string) => void;
onPrompt: (prompt: string) => void;
}) {
const recent = [...conversations].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 3);
return (
<section className="moyai-chat-home">
<div className="moyai-chat-space-backdrop" aria-hidden="true" />
<div className="moyai-chat-home-content">
<header className="moyai-chat-home-header">
<h1>
{imageMode ? (
"What are we creating?"
) : (
<>
What are we <span>building?</span>
</>
)}
</h1>
</header>
{children}
<section className="moyai-chat-home-recent" aria-labelledby="recent-chats-heading">
<div className="moyai-chat-home-recent-header">
<h2 id="recent-chats-heading">Recent chats</h2>
</div>
{recent.length === 0 && (
<p className="text-muted-foreground" role="status">
No recent chats in this browser yet.
</p>
)}
<ul>
{recent.map((conversation) => (
<li key={conversation.id}>
<Button
variant="ghost"
className="moyai-chat-home-recent-link"
onClick={() => onSelectConversation(conversation.id)}
>
<Clock3 className="size-3.5" />
<span>{conversation.title}</span>
<ArrowUpRight className="size-3.5" />
</Button>
</li>
))}
</ul>
</section>
{!imageMode && (
<div
className="moyai-chat-home-starters"
role="group"
aria-label="Start with a prompt"
aria-hidden={Boolean(draft.trim())}
>
<p className="moyai-chat-home-starters-heading">Start with a prompt</p>
{starters.map(({ label, detail, prompt, Icon }) => (
<Button
key={label}
variant="ghost"
disabled={busy || Boolean(draft.trim())}
onClick={() => onPrompt(prompt)}
>
<Icon />
<span className="moyai-chat-home-starter-label">{label}</span>
<span className="moyai-chat-home-starter-detail">{detail}</span>
<ArrowUpRight className="moyai-chat-home-starter-arrow" />
</Button>
))}
</div>
)}
</div>
</section>
);
}

View file

@ -0,0 +1,729 @@
/* Adapted from BerriAI/moyai 1118ab0, native/web/home.css. MIT license in LICENSE.txt. */
@font-face {
font-family: "Moyai Geist";
src: url("./geist-variable.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
.chat-theme {
--font-moyai-chat: "General Sans", "Moyai Geist", ui-sans-serif, system-ui, sans-serif;
font-family: var(--font-moyai-chat);
}
.moyai-models-page {
height: 100%;
min-height: 0;
overflow: auto;
padding: 52px 48px;
}
.moyai-models-content {
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
.moyai-models-header {
display: grid;
gap: 10px;
margin-bottom: 36px;
}
.moyai-models-header h1 {
font: 500 clamp(28px, 3vw, 38px) / 1.15 var(--font-moyai-chat, sans-serif);
letter-spacing: -0.04em;
}
.chat-model-hub-catalog {
min-width: 0;
}
.chat-model-hub-card {
min-width: 0;
gap: 20px;
padding: 24px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--chat-glass-surface);
background-image: var(--chat-glass-sheen);
box-shadow: var(--chat-glass-shadow);
}
.chat-model-hub-catalog .chat-model-hub-filters {
gap: 12px;
margin-bottom: 0;
padding: 0;
border: 0;
background: transparent;
}
.chat-model-hub-card .model-hub-models {
display: flex;
flex-direction: column;
gap: 20px;
}
.chat-model-provider-logo {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 10px;
background: white;
color: black;
}
.chat-model-provider-logo-small {
width: 32px;
height: 32px;
padding: 6px;
border-radius: 8px;
}
.chat-model-provider-logo-option {
width: 24px;
height: 24px;
padding: 4px;
border-radius: 6px;
}
.chat-model-filter-select.chat-model-provider-select {
min-width: 180px;
max-width: 280px;
}
.chat-model-provider-select [data-slot="select-value"] {
min-width: 0;
}
.chat-model-section-heading {
display: flex;
align-items: center;
gap: 8px;
}
.chat-model-section-heading h2 {
font-size: 15px;
font-weight: 500;
letter-spacing: -0.02em;
}
.chat-model-section-heading > span {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 22px;
padding: 0 7px;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--muted-foreground);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.chat-model-filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.chat-model-filter-search {
flex: 1 1 240px;
min-width: 180px;
}
.chat-model-filter-select {
width: auto;
min-width: 150px;
}
.chat-model-filter-bar [data-slot="input-group"],
.chat-model-filter-bar [data-slot="input"],
.chat-model-filter-bar [data-slot="select-trigger"],
.chat-model-filter-bar [data-slot="combobox-trigger"] {
height: 38px;
border-color: var(--border);
border-radius: 8px;
background: var(--background);
box-shadow: none;
font-size: 12px;
}
.chat-model-hub-card [data-slot="table-head"] {
height: 42px;
font-size: 11px;
color: var(--muted-foreground);
}
.chat-model-hub-card [data-slot="table-cell"] {
padding-top: 16px;
padding-bottom: 16px;
}
.chat-model-hub-card [data-slot="table-cell"] [data-slot="badge"] {
border-color: var(--border);
background: var(--secondary);
color: var(--muted-foreground);
font-size: 10px;
font-weight: 400;
}
.chat-model-hub-card [data-slot="table-pagination"] {
gap: 12px;
}
.chat-model-hub-card [data-slot="table-pagination-summary"] {
flex-wrap: wrap;
gap: 12px;
}
.chat-model-hub-card [data-slot="table-pagination-summary"] > span {
font-size: 11px;
white-space: nowrap;
}
.chat-model-hub-dialog {
border-color: var(--chat-home-editor-border);
border-radius: 12px;
}
@media (max-width: 767px) {
.moyai-models-page {
padding: 64px 16px 28px;
}
.moyai-models-header {
margin-bottom: 24px;
}
.chat-model-hub-card {
padding: 16px;
}
.chat-model-filter-search,
.chat-model-filter-select.chat-model-provider-select {
flex-basis: 100%;
max-width: none;
}
.chat-model-filter-select {
flex: 1;
min-width: 0;
}
.chat-model-filter-clear {
flex-basis: 100%;
justify-content: flex-start;
}
}
.moyai-chat-home {
height: 100%;
overflow: auto;
position: relative;
isolation: isolate;
display: flex;
padding: 88px 48px 56px;
}
.moyai-chat-home-content {
position: relative;
width: 100%;
max-width: 720px;
margin: auto;
text-align: left;
}
.moyai-chat-home-header {
margin-bottom: 28px;
}
.moyai-chat-home-header h1 {
margin: 0;
font: 500 clamp(32px, 3.4vw, 46px) / 1.13 var(--font-moyai-chat, sans-serif);
letter-spacing: -0.045em;
text-wrap: balance;
}
.moyai-chat-home-form {
text-align: left;
}
.moyai-chat-home-connection-error {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 12px;
}
.moyai-chat-home-composer {
background: var(--chat-glass-surface);
background-image: var(--chat-glass-sheen);
-webkit-backdrop-filter: var(--chat-glass-blur);
backdrop-filter: var(--chat-glass-blur);
border: 1px solid var(--chat-home-editor-border);
border-radius: 10px;
box-shadow: var(--chat-glass-shadow);
overflow: hidden;
}
.moyai-chat-home-composer:focus-within {
border-color: var(--chat-focus-border);
outline: none;
}
.moyai-chat-home-editor-input {
position: relative;
}
.moyai-chat-home .moyai-chat-home-composer .skill-editor {
min-height: 132px;
padding: 21px 22px;
font: 14px/1.8 var(--font-moyai-chat, sans-serif);
}
.moyai-chat-home .moyai-chat-home-composer .skill-editor-placeholder {
top: 21px;
left: 22px;
right: 22px;
font: 14px/1.8 var(--font-moyai-chat, sans-serif);
color: var(--muted-foreground);
}
.moyai-chat-home-composer textarea {
min-height: 132px;
padding: 22px;
font-size: 15px;
resize: none;
}
.moyai-chat-home-composer .moyai-chat-composer-actions {
padding: 8px 12px;
border-top: 1px solid var(--border);
background: var(--chat-glass-chrome);
}
.moyai-chat-home-composer-tools {
display: flex;
align-items: center;
gap: 6px;
}
.moyai-chat-home-privacy-toggle {
color: var(--foreground);
font-size: 11px;
}
.moyai-chat-home-privacy-toggle[aria-pressed="true"] {
color: var(--primary);
background: var(--secondary);
}
.moyai-chat-home-composer .moyai-chat-send-actions {
margin-left: auto;
}
.moyai-chat-home-composer .moyai-chat-send-actions > button {
color: var(--primary);
background: var(--sidebar-accent);
}
.moyai-chat-home-composer .moyai-chat-send-actions > button:hover:not(:disabled) {
background: var(--secondary);
}
.moyai-chat-home-composer .moyai-chat-send-actions > button:disabled {
color: var(--muted-foreground);
background: transparent;
}
.moyai-chat-home-composer .moyai-chat-send-actions [data-slot="select-trigger"] {
width: auto;
max-width: 230px;
background: transparent;
border: 0;
font: 10px/1.5 var(--ui-font-mono);
color: var(--foreground);
}
.moyai-chat-home-composer-hint {
display: flex;
justify-content: flex-end;
gap: 12px;
color: var(--muted-foreground);
font-size: 11px;
line-height: 1.5;
margin: 10px 0 0;
}
.moyai-chat-home-composer-hint [role="status"] {
margin-right: auto;
}
.moyai-chat-home-recent {
margin-top: 24px;
}
.moyai-chat-home-recent-header {
min-height: 26px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.moyai-chat-home-recent h2 {
margin: 0;
font-size: 12px;
font-weight: 500;
}
.moyai-chat-home-recent-header button,
.moyai-chat-home-recent > p {
font-size: 11px;
}
.moyai-chat-home-recent ul {
list-style: none;
padding: 0;
margin: 4px 0 0;
}
.moyai-chat-home-recent .moyai-chat-home-recent-link {
display: flex;
justify-content: flex-start;
width: 100%;
min-width: 0;
height: 36px;
gap: 12px;
padding: 6px 10px;
font-size: 12px;
font-weight: 400;
}
.moyai-chat-home-recent-link span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.moyai-chat-home-recent-link svg:last-child {
margin-left: auto;
}
.moyai-chat-home-starters {
display: grid;
gap: 2px;
margin-top: 30px;
}
.moyai-chat-home-starters[aria-hidden="true"] {
visibility: hidden;
}
.moyai-chat-home-starters-heading {
margin: 0 0 8px;
color: var(--foreground);
font-size: 11px;
line-height: 1.5;
}
.moyai-chat-home-starters > button {
display: flex;
justify-content: flex-start;
height: 36px;
min-height: 36px;
padding: 6px 10px;
gap: 12px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--foreground);
font-size: 12px;
font-weight: 400;
}
.moyai-chat-home-starters > button:hover {
background: var(--muted);
border-color: var(--border);
color: var(--foreground);
}
.moyai-chat-home-starters > button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
.moyai-chat-home-starters > button > svg {
width: 14px;
height: 14px;
color: var(--foreground);
}
.moyai-chat-home-starter-label {
color: var(--foreground);
}
.moyai-chat-home-starter-detail {
margin-left: auto;
font-size: 11px;
line-height: 1.5;
}
.moyai-chat-home-starters > button > .moyai-chat-home-starter-arrow {
color: var(--foreground);
width: 12px;
}
@media (max-width: 1000px) {
.moyai-chat-home {
padding-inline: 28px;
}
.moyai-chat-home-header h1 {
font-size: 36px;
}
}
@media (max-width: 600px) {
.moyai-models-page {
padding-top: 16px;
}
.moyai-chat-home {
padding: 28px 20px 36px;
}
.moyai-chat-home-header {
margin-bottom: 24px;
}
.moyai-chat-home-header h1 {
font-size: 36px;
}
.moyai-chat-home .moyai-chat-home-composer .skill-editor {
min-height: 148px;
padding: 18px;
font-size: 16px;
}
.moyai-chat-home .moyai-chat-home-composer .skill-editor-placeholder {
top: 18px;
left: 18px;
right: 18px;
font-size: 16px;
}
.moyai-chat-home-composer .moyai-chat-send-actions {
width: auto;
max-width: 100%;
}
.moyai-chat-home-composer .moyai-chat-send-actions > button {
width: 44px;
height: 44px;
}
.moyai-chat-home-composer .moyai-chat-send-actions [data-slot="select-trigger"] {
max-width: 160px;
font-size: 12px !important;
}
.moyai-chat-home-composer-hint {
flex-wrap: wrap;
justify-content: flex-start;
}
.moyai-chat-home-recent .moyai-chat-home-recent-link {
min-height: 44px;
}
.moyai-chat-home-starters {
margin-top: 24px;
}
.moyai-chat-home-starters > button {
min-height: 44px;
gap: 8px;
}
.moyai-chat-home-starter-detail {
display: none;
}
.moyai-chat-home-starter-arrow {
margin-left: auto;
}
}
.moyai-chat-composer-actions {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.moyai-chat-send-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.chat-theme .moyai-chat-editor {
min-height: 132px;
max-height: 240px;
width: 100%;
border: 0;
border-radius: 0;
box-shadow: none;
outline: none;
resize: none;
background: transparent;
font: 14px/1.8 var(--font-moyai-chat, sans-serif);
padding: 21px 22px;
}
.chat-theme .moyai-chat-editor:focus-visible {
box-shadow: none;
outline: none;
}
.moyai-chat-space-backdrop {
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
background-image: radial-gradient(var(--moyai-home-dot) 1px, transparent 1px),
linear-gradient(var(--moyai-home-grid) 1px, transparent 1px),
linear-gradient(90deg, var(--moyai-home-grid) 1px, transparent 1px);
background-size:
48px 48px,
192px 192px,
192px 192px;
background-position:
0 0,
24px 24px,
24px 24px;
mask-image: linear-gradient(#000, #0009);
}
.moyai-chat-home {
--moyai-home-dot: #5b60602b;
--moyai-home-grid: #5b606008;
min-height: 0;
flex: 1;
}
.dark .moyai-chat-home {
--moyai-home-dot: #aeb0b52e;
--moyai-home-grid: #aeb0b508;
}
@media (max-width: 600px) {
.chat-theme .moyai-chat-editor {
min-height: 148px;
padding: 18px;
font-size: 16px;
}
}
.chat-theme .moyai-chat-send-actions .moyai-chat-model-picker {
width: auto;
max-width: 230px;
min-width: 0;
height: 32px;
padding: 0 12px;
gap: 8px;
background: transparent;
border: 0;
box-shadow: none;
font: 10px/1.5 var(--font-moyai-mono, ui-monospace, monospace);
color: var(--foreground);
}
.chat-theme .moyai-chat-editor::placeholder {
color: var(--chat-prompt-muted);
}
.chat-theme .moyai-chat-home-composer-tools button {
font-size: 11px;
font-weight: 400;
}
@media (max-width: 600px) {
.chat-theme .moyai-chat-send-actions .moyai-chat-model-picker {
max-width: 160px;
padding-inline: 4px;
font-size: 12px;
}
}
@font-face {
font-family: "Moyai Geist Mono";
src: url("./geist-mono-variable.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
.chat-theme {
--font-moyai-mono: "Moyai Geist Mono", ui-monospace, monospace;
}
.moyai-chat-home-composer .moyai-chat-send-actions > button {
width: 28px;
height: 28px;
}
.chat-theme .moyai-chat-home-composer-tools button {
height: 28px;
}
.moyai-chat-conversation {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.moyai-chat-conversation-header {
padding: 16px 28px;
min-height: 76px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
}
.moyai-chat-conversation-header h1 {
font-size: 18px;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.moyai-chat-transcript-layout {
--chat-gutter: clamp(20px, 2.2vw, 32px);
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
position: relative;
}
.moyai-chat-transcript {
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-gutter: stable;
padding: 28px var(--chat-gutter);
}
.moyai-chat-transcript > *,
.moyai-chat-composer-wrap > * {
max-width: 820px;
margin-inline: auto;
}
.moyai-chat-composer-wrap {
padding: 12px var(--chat-gutter) 16px;
overflow: hidden;
scrollbar-gutter: stable;
}
.moyai-chat-conversation-composer .moyai-chat-composer-entry {
position: relative;
border: 1px solid var(--chat-home-editor-border);
border-radius: 8px;
background: var(--chat-glass-surface);
background-image: var(--chat-glass-sheen);
backdrop-filter: var(--chat-glass-blur);
box-shadow: var(--chat-glass-shadow);
}
.moyai-chat-conversation-composer .moyai-chat-composer-entry:focus-within {
border-color: var(--chat-focus-border);
}
.chat-theme .moyai-chat-conversation-composer .moyai-chat-editor {
min-height: 52px;
padding: 14px 52px 14px 16px;
font-size: 13px;
line-height: 1.7;
}
.moyai-chat-entry-send {
position: absolute;
right: 10px;
bottom: 12px;
}
.moyai-chat-entry-send > button {
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--primary);
background: transparent;
}
.moyai-chat-entry-send > button:hover:not(:disabled) {
background: var(--sidebar-accent);
}
.moyai-chat-entry-send > button:disabled {
color: var(--muted-foreground);
}
.moyai-chat-conversation-composer .moyai-chat-composer-actions {
min-height: 38px;
padding: 4px 0 0;
gap: 6px;
}
.chat-theme .moyai-chat-conversation-composer .moyai-chat-model-picker {
height: 30px;
padding-inline: 8px;
}
.chat-theme .moyai-chat-user-row {
max-width: min(90%, 680px);
}
.chat-theme .moyai-chat-user-bubble > [data-slot="bubble-content"] {
background: var(--chat-message);
border: 1px solid var(--chat-message-border);
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
line-height: 1.7;
}
.chat-theme .moyai-chat-assistant-bubble > [data-slot="bubble-content"] {
font-size: 13px;
line-height: 1.7;
}
@media (max-width: 600px) {
.moyai-chat-conversation-header {
min-height: 52px;
padding: 12px 20px;
}
.moyai-chat-transcript-layout {
--chat-gutter: 16px;
}
.moyai-chat-transcript {
padding: 24px var(--chat-gutter);
}
.moyai-chat-composer-wrap {
padding: 8px var(--chat-gutter) 12px;
}
.chat-theme .moyai-chat-user-row {
max-width: 100%;
}
.chat-theme .moyai-chat-conversation-composer .moyai-chat-editor {
min-height: 58px;
font-size: 16px;
padding-block: 15px;
}
.moyai-chat-entry-send {
right: 5px;
bottom: 6px;
}
.moyai-chat-entry-send > button,
.moyai-chat-home-composer .moyai-chat-send-actions > button {
width: 44px;
height: 44px;
}
.chat-theme .moyai-chat-home-composer-tools button {
height: 44px;
}
}

View file

@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api";
import { generateChatImages, toResponsesMessages } from "./imageHelpers";
import { runChatRequest } from "./runChatRequest";
import type { MCPEvent } from "./types";
vi.mock("@/components/llm_calls/responses_api", () => ({ makeOpenAIResponsesRequest: vi.fn() }));
vi.mock("./imageHelpers", () => ({
generateChatImages: vi.fn(),
toResponsesMessages: vi.fn((messages) => messages),
}));
function requestOptions(overrides: Partial<Parameters<typeof runChatRequest>[0]> = {}) {
return {
model: "chat-model",
mode: "chat" as const,
prompt: "Hello",
accessToken: "test-token",
signal: new AbortController().signal,
messages: [{ role: "user" as const, content: "Hello" }],
previousResponseId: "previous-response",
mcpServers: ["search"],
onResponseId: vi.fn(),
onUpdate: vi.fn(),
...overrides,
};
}
const event: MCPEvent = { type: "response.mcp_call.completed", item_id: "tool-call-1" };
describe("runChatRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(toResponsesMessages).mockImplementation((messages) => messages);
});
it("should accumulate chat content, reasoning, metrics, and completed tool events", async () => {
const options = requestOptions();
const usage = { promptTokens: 4, completionTokens: 7 };
vi.mocked(makeOpenAIResponsesRequest).mockImplementation(async (...args) => {
args[1]("assistant", "Hello");
args[1]("assistant", " there");
args[6]?.("First");
args[6]?.(" thought");
args[7]?.(120);
args[8]?.(usage);
args[15]?.("new-response");
args[16]?.(event);
args[24]?.(350);
expect(options.onUpdate).not.toHaveBeenCalledWith({ mcpEvents: [event] });
});
await runChatRequest(options);
expect(options.onUpdate).toHaveBeenCalledWith({ content: "Hello there" });
expect(options.onUpdate).toHaveBeenCalledWith({ reasoningContent: "First thought" });
expect(options.onUpdate).toHaveBeenCalledWith({ timeToFirstToken: 120 });
expect(options.onUpdate).toHaveBeenCalledWith({ totalLatency: 350 });
expect(options.onUpdate).toHaveBeenCalledWith({ usage });
expect(options.onUpdate).toHaveBeenLastCalledWith({ mcpEvents: [event] });
expect(options.onResponseId).toHaveBeenCalledWith("new-response");
const args = vi.mocked(makeOpenAIResponsesRequest).mock.calls[0];
expect(args[5]).toBe(options.signal);
expect(args[13]).toEqual(["search"]);
expect(args[14]).toBe("previous-response");
});
it("should preserve partial text on abort without persisting partial tools or late response ids", async () => {
const controller = new AbortController();
const options = requestOptions({ signal: controller.signal });
vi.mocked(makeOpenAIResponsesRequest).mockImplementation(async (...args) => {
args[1]("assistant", "Partial answer");
args[16]?.(event);
controller.abort();
args[15]?.("late-response");
throw new DOMException("Stopped", "AbortError");
});
await runChatRequest(options);
expect(options.onUpdate).toHaveBeenLastCalledWith({ content: "Partial answer [stopped]" });
expect(options.onUpdate).not.toHaveBeenCalledWith({ mcpEvents: [event] });
expect(options.onResponseId).toHaveBeenCalledExactlyOnceWith(null);
});
it("should preserve partial output and reset the session after a request error", async () => {
const options = requestOptions();
vi.mocked(makeOpenAIResponsesRequest).mockImplementation(async (...args) => {
args[1]("assistant", "Partial answer");
args[16]?.(event);
throw new Error("Provider unavailable");
});
await runChatRequest(options);
expect(options.onUpdate).toHaveBeenLastCalledWith({ content: "Partial answer" });
expect(options.onUpdate).not.toHaveBeenCalledWith({ mcpEvents: [event] });
expect(options.onResponseId).toHaveBeenCalledWith(null);
});
it("should show an error when no response text has arrived", async () => {
const options = requestOptions();
vi.mocked(makeOpenAIResponsesRequest).mockRejectedValue(new Error("Provider unavailable"));
await runChatRequest(options);
expect(options.onUpdate).toHaveBeenLastCalledWith({ content: "Provider unavailable" });
expect(options.onResponseId).toHaveBeenCalledWith(null);
});
it("should return generated images through the same message update callback", async () => {
const options = requestOptions({ mode: "image", model: "image-model" });
const images = [{ id: "image-1", name: "Generated image 1", url: "https://images.example/image.png" }];
vi.mocked(generateChatImages).mockResolvedValue(images);
await runChatRequest(options);
expect(options.onUpdate).toHaveBeenCalledExactlyOnceWith({ generatedImages: images });
expect(makeOpenAIResponsesRequest).not.toHaveBeenCalled();
const expectedRequest = {
prompt: "Hello",
model: "image-model",
accessToken: "test-token",
signal: options.signal,
};
expect(generateChatImages).toHaveBeenCalledWith(expectedRequest);
});
it("should discard image results that arrive after the request was stopped", async () => {
const controller = new AbortController();
const options = requestOptions({ mode: "image", signal: controller.signal });
vi.mocked(generateChatImages).mockImplementation(async () => {
controller.abort();
return [{ id: "image-1", name: "Generated image 1", url: "https://images.example/image.png" }];
});
await runChatRequest(options);
expect(options.onUpdate).toHaveBeenCalledExactlyOnceWith({ content: " [stopped]" });
expect(options.onResponseId).toHaveBeenCalledWith(null);
});
});

View file

@ -0,0 +1,86 @@
import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api";
import { generateChatImages, toResponsesMessages } from "./imageHelpers";
import type { AssistantMessageUpdate, ChatMessage, MCPEvent } from "./types";
interface ChatRequestOptions {
model: string;
mode: "chat" | "image";
prompt: string;
accessToken: string;
signal: AbortSignal;
messages: Pick<ChatMessage, "role" | "content" | "attachments" | "generatedImages" | "imageGeneration">[];
previousResponseId?: string | null;
mcpServers?: string[];
onResponseId: (id: string | null) => void;
onUpdate: (update: AssistantMessageUpdate) => void;
}
export async function runChatRequest({
model,
mode,
prompt,
accessToken,
signal,
messages,
previousResponseId,
mcpServers,
onResponseId,
onUpdate,
}: ChatRequestOptions): Promise<void> {
let content = "";
let reasoningContent = "";
const mcpEvents: MCPEvent[] = [];
try {
signal.throwIfAborted();
if (mode === "image") {
const imageRequest = { prompt, model, accessToken, signal };
const generatedImages = await generateChatImages(imageRequest);
signal.throwIfAborted();
onUpdate({ generatedImages });
return;
}
await makeOpenAIResponsesRequest(
toResponsesMessages(messages),
(_role, chunk) => {
content += chunk;
onUpdate({ content });
},
model,
accessToken,
undefined,
signal,
(chunk) => {
reasoningContent += chunk;
onUpdate({ reasoningContent });
},
(timeToFirstToken) => onUpdate({ timeToFirstToken }),
(usage) => onUpdate({ usage }),
undefined,
undefined,
undefined,
undefined,
mcpServers?.length ? mcpServers : undefined,
previousResponseId,
(id) => {
if (!signal.aborted) onResponseId(id);
},
(event) => mcpEvents.push(event),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
true,
(totalLatency) => onUpdate({ totalLatency }),
);
signal.throwIfAborted();
if (mcpEvents.length) onUpdate({ mcpEvents });
} catch (error: unknown) {
onResponseId(null);
const stopped = signal.aborted || (error instanceof Error && error.name === "AbortError");
const errorMessage = error instanceof Error ? error.message : "Something went wrong. Please try again.";
onUpdate({ content: stopped ? content + " [stopped]" : content || errorMessage });
}
}

View file

@ -2,10 +2,25 @@ import type { MCPEvent } from "../mcp_tools/types";
import type { TokenUsage } from "../chat_ui/ResponseMetrics";
export type { MCPEvent };
export interface ChatImageAttachment {
id: string;
name: string;
url: string;
mimeType?: string;
}
export interface ChatImageGeneration {
model: string;
prompt: string;
}
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "tool";
content: string;
attachments?: ChatImageAttachment[];
generatedImages?: ChatImageAttachment[];
imageGeneration?: ChatImageGeneration;
reasoningContent?: string;
/** MCP tool events that occurred during this assistant turn, in order. */
mcpEvents?: MCPEvent[];
@ -19,7 +34,17 @@ export interface ChatMessage {
}
export type AssistantMessageUpdate = Partial<
Pick<ChatMessage, "content" | "reasoningContent" | "mcpEvents" | "timeToFirstToken" | "totalLatency" | "usage">
Pick<
ChatMessage,
| "content"
| "reasoningContent"
| "mcpEvents"
| "timeToFirstToken"
| "totalLatency"
| "usage"
| "generatedImages"
| "imageGeneration"
>
>;
export interface Conversation {

View file

@ -1,13 +1,42 @@
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { useChatHistory } from "./useChatHistory";
import { deleteChatImages, loadChatImages, persistChatImages } from "./chatImageStorage";
import type { Conversation } from "./types";
vi.mock("./chatImageStorage", async (importOriginal) => ({
...(await importOriginal<typeof import("./chatImageStorage")>()),
persistChatImages: vi.fn(),
loadChatImages: vi.fn(),
deleteChatImages: vi.fn(),
}));
const aliceStorageKey = "litellm_chat_history_v1:alice";
const image = { id: "image-1", name: "design.png", url: "data:image/png;base64,aW1hZ2U=", mimeType: "image/png" };
const savedConversation: Conversation = {
id: "conversation-1",
title: "Design",
model: "vision-model",
mcpServerNames: [],
createdAt: 1,
updatedAt: 1,
messages: [
{ id: "message-1", role: "user", content: "Describe this", timestamp: 1, attachments: [{ ...image, url: "" }] },
],
};
describe("useChatHistory", () => {
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
vi.mocked(persistChatImages).mockReset().mockResolvedValue(undefined);
vi.mocked(loadChatImages)
.mockReset()
.mockResolvedValue(new Map([[image.id, image.url]]));
vi.mocked(deleteChatImages).mockReset().mockResolvedValue(undefined);
});
it("does not leak conversations between different users on the same browser", () => {
it("should render without leaking conversations between different users on the same browser", () => {
const { result: alice } = renderHook(({ userId }) => useChatHistory(null, userId), {
initialProps: { userId: "alice" },
});
@ -22,7 +51,7 @@ describe("useChatHistory", () => {
expect(bob.current.conversations).toHaveLength(0);
});
it("reloads conversations scoped to the new user when userId changes", () => {
it("should reload conversations scoped to the new user when userId changes", () => {
const { result, rerender } = renderHook(({ userId }) => useChatHistory(null, userId), {
initialProps: { userId: "alice" },
});
@ -37,4 +66,185 @@ describe("useChatHistory", () => {
rerender({ userId: "alice" });
expect(result.current.conversations).toHaveLength(1);
});
it("should save image bytes before committing lightweight history, keeping the latest streamed text", async () => {
let finishSaving!: () => void;
vi.mocked(persistChatImages).mockReturnValue(
new Promise<void>((resolve) => {
finishSaving = resolve;
}),
);
const { result } = renderHook(() => useChatHistory(null, "alice"));
let id!: string;
act(() => {
id = result.current.createConversation("vision-model");
});
act(() => {
result.current.appendMessage(id, { role: "user", content: "", attachments: [image] });
});
act(() => {
result.current.appendMessage(id, { role: "assistant", content: "A blue design" });
});
expect(localStorage.getItem(aliceStorageKey)).not.toContain(image.id);
await act(async () => {
finishSaving();
});
await waitFor(() => expect(localStorage.getItem(aliceStorageKey)).toContain("A blue design"));
const stored = JSON.parse(localStorage.getItem(aliceStorageKey)!) as Conversation[];
expect(stored[0].messages[0].attachments).toEqual([{ ...image, url: "" }]);
expect(stored[0].title).toBe("design.png");
expect(result.current.conversations[0].messages[0].attachments?.[0].url).toBe(image.url);
});
it("should restore images from the current user's media store", async () => {
localStorage.setItem(aliceStorageKey, JSON.stringify([savedConversation]));
const { result } = renderHook(() => useChatHistory(savedConversation.id, "alice"));
await waitFor(() => expect(result.current.activeConversation?.messages[0].attachments?.[0].url).toBe(image.url));
expect(loadChatImages).toHaveBeenCalledWith("alice", [image.id]);
const { result: bob } = renderHook(() => useChatHistory(null, "bob"));
expect(bob.current.conversations).toEqual([]);
expect(loadChatImages).not.toHaveBeenCalledWith("bob", expect.anything());
});
it("should retain the previous saved history if storing an image fails", async () => {
vi.mocked(persistChatImages).mockRejectedValue(new Error("Quota exceeded"));
const { result } = renderHook(() => useChatHistory(null, "alice"));
let id!: string;
act(() => {
id = result.current.createConversation("vision-model");
});
const previousHistory = localStorage.getItem(aliceStorageKey);
act(() => {
result.current.appendMessage(id, { role: "user", content: "Describe", attachments: [image] });
});
await waitFor(() => expect(result.current.storageUnavailable).toBe(true));
expect(localStorage.getItem(aliceStorageKey)).toBe(previousHistory);
expect(result.current.conversations[0].messages[0].attachments?.[0].url).toBe(image.url);
});
it("should report missing saved images without discarding their history", async () => {
localStorage.setItem(aliceStorageKey, JSON.stringify([savedConversation]));
vi.mocked(loadChatImages).mockResolvedValue(new Map());
const { result } = renderHook(() => useChatHistory(savedConversation.id, "alice"));
await waitFor(() => expect(result.current.storageUnavailable).toBe(true));
expect(result.current.activeConversation?.messages[0].content).toBe("Describe this");
expect(result.current.activeConversation?.messages[0].attachments?.[0].id).toBe(image.id);
});
it("should remove media only after the conversation deletion has been saved", async () => {
localStorage.setItem(aliceStorageKey, JSON.stringify([savedConversation]));
const { result } = renderHook(() => useChatHistory(savedConversation.id, "alice"));
await waitFor(() => expect(result.current.activeConversation?.messages[0].attachments?.[0].url).toBe(image.url));
vi.mocked(deleteChatImages).mockImplementation(async () => {
expect(JSON.parse(localStorage.getItem(aliceStorageKey)!)).toEqual([]);
});
act(() => {
result.current.deleteConversation(savedConversation.id);
});
await waitFor(() => expect(deleteChatImages).toHaveBeenCalledWith("alice", [image.id]));
});
it("should keep saved media when the localStorage deletion cannot be committed", async () => {
localStorage.setItem(aliceStorageKey, JSON.stringify([savedConversation]));
const { result } = renderHook(() => useChatHistory(savedConversation.id, "alice"));
await waitFor(() => expect(result.current.activeConversation?.messages[0].attachments?.[0].url).toBe(image.url));
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("Storage blocked");
});
act(() => {
result.current.deleteConversation(savedConversation.id);
});
await waitFor(() => expect(result.current.storageUnavailable).toBe(true));
expect(deleteChatImages).not.toHaveBeenCalled();
expect(localStorage.getItem(aliceStorageKey)).toContain(image.id);
});
it("should clean up an image deleted before its first media write completes", async () => {
let finishSaving!: () => void;
vi.mocked(persistChatImages).mockReturnValue(
new Promise<void>((resolve) => {
finishSaving = resolve;
}),
);
const { result } = renderHook(() => useChatHistory(null, "alice"));
let id!: string;
act(() => {
id = result.current.createConversation("vision-model");
});
act(() => {
result.current.appendMessage(id, { role: "user", content: "Describe", attachments: [image] });
});
act(() => {
result.current.deleteConversation(id);
});
expect(deleteChatImages).not.toHaveBeenCalled();
await act(async () => {
finishSaving();
});
await waitFor(() => expect(deleteChatImages).toHaveBeenCalledWith("alice", [image.id]));
expect(JSON.parse(localStorage.getItem(aliceStorageKey)!)).toEqual([]);
});
it("should allow deleting a conversation whose image can no longer be restored", async () => {
localStorage.setItem(aliceStorageKey, JSON.stringify([savedConversation]));
vi.mocked(loadChatImages).mockResolvedValue(new Map());
const { result } = renderHook(() => useChatHistory(savedConversation.id, "alice"));
await waitFor(() => expect(result.current.storageUnavailable).toBe(true));
act(() => {
result.current.deleteConversation(savedConversation.id);
});
await waitFor(() => expect(result.current.storageUnavailable).toBe(false));
expect(JSON.parse(localStorage.getItem(aliceStorageKey)!)).toEqual([]);
act(() => {
result.current.createConversation("text-model");
});
expect(localStorage.getItem(aliceStorageKey)).toContain("text-model");
});
it("should resume saving when an image that exceeded storage quota is removed", async () => {
vi.mocked(persistChatImages).mockRejectedValue(new Error("Quota exceeded"));
const { result } = renderHook(() => useChatHistory(null, "alice"));
let id!: string;
act(() => {
id = result.current.createConversation("vision-model");
});
act(() => {
result.current.appendMessage(id, { role: "user", content: "Describe", attachments: [image] });
});
await waitFor(() => expect(result.current.storageUnavailable).toBe(true));
act(() => {
result.current.deleteConversation(id);
});
await waitFor(() => expect(result.current.storageUnavailable).toBe(false));
expect(JSON.parse(localStorage.getItem(aliceStorageKey)!)).toEqual([]);
});
it("should not resurrect a deleted conversation when an older hook mount finishes saving", async () => {
let finishSaving!: () => void;
vi.mocked(persistChatImages).mockReturnValue(
new Promise<void>((resolve) => {
finishSaving = resolve;
}),
);
const first = renderHook(() => useChatHistory(null, "alice"));
let id!: string;
act(() => {
id = first.result.current.createConversation("vision-model");
});
act(() => {
first.result.current.appendMessage(id, { role: "user", content: "Describe", attachments: [image] });
});
first.unmount();
const second = renderHook(() => useChatHistory(id, "alice"));
act(() => {
second.result.current.deleteConversation(id);
});
await act(async () => {
finishSaving();
});
expect(JSON.parse(localStorage.getItem(aliceStorageKey)!)).toEqual([]);
expect(second.result.current.conversations).toEqual([]);
expect(deleteChatImages).toHaveBeenCalledWith("alice", [image.id]);
});
});

View file

@ -1,9 +1,19 @@
import { useCallback, useEffect, useState } from "react";
import { AssistantMessageUpdate, ChatMessage, Conversation } from "./types";
import {
deleteChatImages,
getConversationImages,
loadChatImages,
persistChatImages,
withoutImageData,
withRestoredImages,
} from "./chatImageStorage";
const STORAGE_KEY_PREFIX = "litellm_chat_history_v1";
const MAX_CONVERSATIONS = 100;
const TITLE_MAX_LENGTH = 40;
const saveVersions = new Map<string, number>();
const latestImageIds = new Map<string, Set<string>>();
function generateTitle(firstUserMessage: string): string {
const trimmed = firstUserMessage.trim();
@ -70,6 +80,9 @@ export function useChatHistory(
const [storageUnavailable, setStorageUnavailable] = useState<boolean>(
() => loadFromStorage(storageKeyFor(userId)).storageUnavailable,
);
const [historyLoadFailed, setHistoryLoadFailed] = useState<boolean>(
() => loadFromStorage(storageKeyFor(userId)).storageUnavailable,
);
const [staleId, setStaleId] = useState(false);
const [currentActiveId, setCurrentActiveId] = useState<string | null>(activeConversationId);
@ -88,22 +101,77 @@ export function useChatHistory(
const { conversations: loaded, storageUnavailable: unavailable } = loadFromStorage(storageKeyFor(userId));
setConversations(loaded);
setStorageUnavailable(unavailable);
setHistoryLoadFailed(unavailable);
if (activeConversationId !== null && !loaded.some((c) => c.id === activeConversationId)) {
setStaleId(true);
}
}
// Persist to localStorage after every conversations change
useEffect(() => {
if (storageUnavailable) return;
const success = saveToStorage(storageKeyFor(userId), conversations);
if (!success) {
// Defer: a setState call directly in an effect body causes a synchronous
// cascading render; queuing it as a microtask callback avoids that.
queueMicrotask(() => setStorageUnavailable(true));
let canceled = false;
const stored = loadFromStorage(storageKeyFor(userId)).conversations;
const missingIds = getConversationImages(stored)
.filter((image) => !image.url)
.map((image) => image.id);
if (missingIds.length) {
loadChatImages(userId, missingIds)
.then((images) => {
if (canceled) return;
setConversations((current) => withRestoredImages(current, images));
if (new Set(missingIds).size !== images.size) setStorageUnavailable(true);
})
.catch(() => {
if (!canceled) setStorageUnavailable(true);
});
}
}, [conversations, userId, storageUnavailable]);
return () => {
canceled = true;
};
}, [userId]);
// Commit image bytes before replacing the lightweight conversation history.
useEffect(() => {
const images = getConversationImages(conversations);
latestImageIds.set(userId, new Set(images.map((image) => image.id)));
if (historyLoadFailed) return;
let canceled = false;
const version = (saveVersions.get(userId) ?? 0) + 1;
saveVersions.set(userId, version);
const reportFailure = () => {
if (!canceled) setStorageUnavailable(true);
};
const removeUnreferencedImages = (ids: string[]) => {
const savedImages = getConversationImages(loadFromStorage(storageKeyFor(userId)).conversations);
const retainedIds = new Set([...savedImages.map((image) => image.id), ...(latestImageIds.get(userId) ?? [])]);
const removedIds = [...new Set(ids)].filter((id) => !retainedIds.has(id));
if (removedIds.length) void deleteChatImages(userId, removedIds).catch(reportFailure);
};
const save = () => {
if (saveVersions.get(userId) !== version) {
removeUnreferencedImages(images.map((image) => image.id));
return;
}
const storageKey = storageKeyFor(userId);
const previous = loadFromStorage(storageKey).conversations;
if (!saveToStorage(storageKey, withoutImageData(conversations))) {
queueMicrotask(reportFailure);
return;
}
if (!images.some((image) => !image.url)) {
queueMicrotask(() => {
if (!canceled) setStorageUnavailable(false);
});
}
removeUnreferencedImages(getConversationImages(previous).map((image) => image.id));
};
if (images.some((image) => image.url.startsWith("data:"))) {
void persistChatImages(userId, images).then(save).catch(reportFailure);
} else save();
return () => {
canceled = true;
};
}, [conversations, userId, historyLoadFailed]);
const createConversation = useCallback((model: string): string => {
const id = crypto.randomUUID();
@ -139,7 +207,7 @@ export function useChatHistory(
newMessage.role === "user" &&
conv.messages.filter((m) => m.role === "user").length === 0
) {
title = generateTitle(newMessage.content);
title = generateTitle(newMessage.content || newMessage.attachments?.[0]?.name || "Image conversation");
}
return { ...conv, title, messages: updatedMessages, updatedAt: Date.now() };
});

View file

@ -1,5 +1,10 @@
import React, { useState, useEffect, useMemo, useRef } from "react";
import { Search } from "lucide-react";
import ModelHubProviders from "@/components/AIHub/ModelHubProviders";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { displayMode } from "@/lib/decisionModels";
import { matchesSearchTerm } from "@/utils/searchUtils";
@ -28,6 +33,36 @@ interface ModelFiltersProps {
className?: string;
extraModes?: readonly string[];
onModeChange?: (mode: string) => void;
selectedProvider?: string;
onProviderChange?: (provider: string) => void;
showProviderFilter?: boolean;
variant?: "dashboard" | "chat";
}
interface ChatFilterSelectProps {
label: string;
emptyLabel: string;
options: string[];
value: string;
onValueChange: (value: string) => void;
}
function ChatFilterSelect({ label, emptyLabel, options, value, onValueChange }: ChatFilterSelectProps) {
const items = [{ value: "", label: emptyLabel }, ...options.map((option) => ({ value: option, label: option }))];
return (
<Select value={value} items={items} onValueChange={(next) => onValueChange(next ?? "")}>
<SelectTrigger aria-label={label} className="chat-model-filter-select">
<SelectValue />
</SelectTrigger>
<SelectContent className="chat-theme">
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
const ModelFilters: React.FC<ModelFiltersProps> = ({
@ -37,9 +72,14 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
className = "",
extraModes = [],
onModeChange,
selectedProvider: controlledProvider,
onProviderChange,
showProviderFilter = true,
variant = "dashboard",
}) => {
const [searchTerm, setSearchTerm] = useState<string>("");
const [selectedProvider, setSelectedProvider] = useState<string>("");
const [internalProvider, setInternalProvider] = useState<string>("");
const selectedProvider = controlledProvider ?? internalProvider;
const [selectedMode, setSelectedMode] = useState<string>("");
const [selectedFeature, setSelectedFeature] = useState<string>("");
const previousFilteredDataRef = useRef<ModelGroupInfo[]>([]);
@ -67,6 +107,11 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
onModeChange?.(mode);
};
const changeProvider = (provider: string) => {
if (controlledProvider === undefined) setInternalProvider(provider);
onProviderChange?.(provider);
};
const getUniqueFeatures = (data: ModelGroupInfo[]) => {
const features = new Set<string>();
data.forEach((model) => {
@ -127,12 +172,56 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
// Reset filters function
const resetFilters = () => {
setSearchTerm("");
setSelectedProvider("");
changeProvider("");
changeMode("");
setSelectedFeature("");
};
// Expose filter values and reset function
const hasActiveFilters = Boolean(searchTerm || selectedProvider || selectedMode || selectedFeature);
if (variant === "chat") {
return (
<div className={`${className} chat-model-filter-bar`}>
<InputGroup className="chat-model-filter-search">
<InputGroupAddon aria-hidden="true">
<Search className="size-4" />
</InputGroupAddon>
<InputGroupInput
aria-label="Search models"
placeholder="Search model names..."
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
</InputGroup>
{showProviderFilter && (
<ModelHubProviders
providers={getUniqueProviders(modelHubData)}
selectedProviders={selectedProvider ? [selectedProvider] : []}
onSelect={(provider) => changeProvider(provider ?? "")}
/>
)}
<ChatFilterSelect
label="Model type"
emptyLabel="All types"
options={getUniqueModes(modelHubData)}
value={selectedMode}
onValueChange={changeMode}
/>
<ChatFilterSelect
label="Model capability"
emptyLabel="All capabilities"
options={getUniqueFeatures(modelHubData)}
value={selectedFeature}
onValueChange={setSelectedFeature}
/>
{hasActiveFilters && (
<Button variant="ghost" onClick={resetFilters} className="chat-model-filter-clear">
Clear Filters
</Button>
)}
</div>
);
}
const filtersContent = (
<div className="flex flex-wrap gap-4 items-center">
@ -146,24 +235,26 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
className="border rounded-sm px-3 py-2 w-64 h-10 text-sm"
/>
</div>
<div>
<p className="text-sm font-medium mb-2">Provider:</p>
<select
value={selectedProvider}
onChange={(e) => setSelectedProvider(e.target.value)}
className="border rounded-sm px-3 py-2 text-sm text-muted-foreground w-40 h-10"
>
<option value="" className="text-sm text-muted-foreground">
All Providers
</option>
{modelHubData &&
getUniqueProviders(modelHubData).map((provider) => (
<option key={provider} value={provider} className="text-sm text-foreground">
{provider}
</option>
))}
</select>
</div>
{showProviderFilter && (
<div>
<p className="text-sm font-medium mb-2">Provider:</p>
<select
value={selectedProvider}
onChange={(e) => changeProvider(e.target.value)}
className="border rounded-sm px-3 py-2 text-sm text-muted-foreground w-40 h-10"
>
<option value="" className="text-sm text-muted-foreground">
All Providers
</option>
{modelHubData &&
getUniqueProviders(modelHubData).map((provider) => (
<option key={provider} value={provider} className="text-sm text-foreground">
{provider}
</option>
))}
</select>
</div>
)}
<div>
<p className="text-sm font-medium mb-2">Mode:</p>
<select
@ -202,7 +293,7 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
</div>
{/* Clear filters button */}
{(searchTerm || selectedProvider || selectedMode || selectedFeature) && (
{hasActiveFilters && (
<div className="flex items-end">
<button
onClick={resetFilters}

View file

@ -99,11 +99,11 @@ const facetPaths = (): string[] =>
const modelQueries = (): QueryRecord[] => modelCalls().map((call) => (call[1] as { query: QueryRecord }).query);
const lastModelQuery = (): QueryRecord => modelQueries()[modelQueries().length - 1];
const renderHub = () => {
const renderHub = (props: React.ComponentProps<typeof PublicModelHub> = {}) => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return render(
<QueryClientProvider client={client}>
<PublicModelHub />
<PublicModelHub {...props} />
</QueryClientProvider>,
);
};
@ -134,6 +134,7 @@ beforeEach(() => {
value: {
pathname: "/",
origin: "http://localhost:3000",
href: "http://localhost:3000/",
},
});
});
@ -148,6 +149,9 @@ describe("PublicModelHub", () => {
renderHub();
expect(await screen.findByText("gpt-4")).toBeInTheDocument();
expect(
within(screen.getByRole("button", { name: "gpt-4", exact: true })).queryByAltText(""),
).not.toBeInTheDocument();
expect(modelCalls()[0][0]).toBe(MODEL_HUB_PATH);
expect(modelQueries()[0]).toEqual({ page: 1, page_size: 50, sort: "model_group" });
});
@ -442,6 +446,83 @@ describe("PublicModelHub", () => {
});
});
describe("PublicModelHub chat variant", () => {
it("should render published models without dashboard chrome or unrelated hub requests", async () => {
const networkingModule = await import("./networking");
renderHub({ isEmbedded: true, variant: "chat" });
expect(await screen.findByRole("button", { name: /^gpt-4(?: |$)/ })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Search models" })).toBeInTheDocument();
expect(screen.queryByTestId("navbar")).not.toBeInTheDocument();
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Available Models" })).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "About" })).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Useful Links" })).not.toBeInTheDocument();
expect(screen.queryByText(/These are models, agents, and MCP servers/)).not.toBeInTheDocument();
expect(networkingModule.getPublicModelHubInfo).not.toHaveBeenCalled();
expect(networkingModule.agentHubPublicModelsCall).not.toHaveBeenCalled();
expect(networkingModule.mcpHubPublicServersCall).not.toHaveBeenCalled();
expect(networkingModule.skillHubPublicCall).not.toHaveBeenCalled();
});
it("should filter all published pages from the provider dropdown and clear the selection", async () => {
const user = userEvent.setup();
respondWith(DEFAULT_MODELS, 300);
renderHub({ isEmbedded: true, variant: "chat" });
const modelButton = await screen.findByRole("button", { name: /^gpt-4(?: |$)/ });
expect(within(modelButton).getByAltText("")).toBeInTheDocument();
expect(modelButton).toHaveTextContent("OpenAI");
expect(screen.getByRole("columnheader", { name: "Model" })).toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Providers" })).not.toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Tags" })).not.toBeInTheDocument();
expect(screen.queryByRole("combobox", { name: "Select providers" })).not.toBeInTheDocument();
await user.click(screen.getByTestId("pagination-next"));
await waitFor(() => expect(lastModelQuery().page).toBe(2));
await user.click(screen.getByRole("combobox", { name: "Provider", exact: true }));
await user.click(await screen.findByRole("option", { name: "Anthropic", exact: true }));
await waitFor(() => expect(lastModelQuery()["filter[providers][in]"]).toBe("anthropic"));
expect(lastModelQuery().page).toBe(1);
expect(screen.getByRole("combobox", { name: "Provider", exact: true })).toHaveTextContent("Anthropic");
await user.click(screen.getByRole("combobox", { name: "Provider", exact: true }));
await user.click(await screen.findByRole("option", { name: "OpenAI", exact: true }));
await waitFor(() => expect(lastModelQuery()["filter[providers][in]"]).toBe("openai"));
expect(screen.getByRole("combobox", { name: "Provider", exact: true })).toHaveTextContent("OpenAI");
await user.click(screen.getByRole("combobox", { name: "Provider", exact: true }));
await user.click(await screen.findByRole("option", { name: "All providers", exact: true }));
await waitFor(() => expect(lastModelQuery()).not.toHaveProperty("filter[providers][in]"));
});
it("should preserve published model search and pagination in chat", async () => {
const user = userEvent.setup();
respondWith(DEFAULT_MODELS, 300);
renderHub({ isEmbedded: true, variant: "chat" });
await screen.findByRole("button", { name: /^gpt-4(?: |$)/ });
await user.type(screen.getByRole("textbox", { name: "Search models" }), "gpt");
await waitFor(() => expect(lastModelQuery().q).toBe("gpt"));
await user.click(screen.getByTestId("pagination-next"));
await waitFor(() => expect(lastModelQuery().page).toBe(2));
expect(lastModelQuery().q).toBe("gpt");
});
it("should retain costs and usage examples in a chat-themed model dialog", async () => {
const user = userEvent.setup();
respondWith([model({ model_group: "gpt-4", input_cost_per_token: 0.000001, output_cost_per_token: 0.000003 })]);
renderHub({ isEmbedded: true, variant: "chat" });
await user.click(await screen.findByRole("button", { name: /^gpt-4(?: |$)/ }));
const dialog = await screen.findByRole("dialog", { name: "gpt-4" });
expect(dialog).toHaveClass("chat-theme", "chat-model-hub-dialog");
expect(within(dialog).getByText("$1.0000")).toBeInTheDocument();
expect(within(dialog).getByText("$3.0000")).toBeInTheDocument();
expect(within(dialog).getByText("Usage Example")).toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: "Copy to clipboard" })).toBeInTheDocument();
await user.click(within(dialog).getByRole("button", { name: /close/i }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
});
const PUBLIC_SERVER_URL = "https://mcp.exa.ai/mcp";
const mockMcpServer: MCPServerData = {

View file

@ -5,6 +5,7 @@ import { Copy, Inbox, Info } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Combobox,
ComboboxChip,
@ -54,10 +55,12 @@ import { displayMode, isDecisionMode } from "@/lib/decisionModels";
import { modelUsageExample } from "@/components/AIHub/modelUsageExample";
import { filterBySearchTerm, rankBySearchRelevance } from "@/utils/searchUtils";
import { ModelCustomMetadata } from "@/components/AIHub/ModelCustomMetadata";
import ModelHubProviders, { formatModelModeLabel } from "@/components/AIHub/ModelHubProviders";
interface PublicModelHubProps {
accessToken?: string | null;
isEmbedded?: boolean; // When true, hides navbar and adjusts layout for embedding in dashboard
variant?: "dashboard" | "chat";
}
const usageExample = (model: ModelGroupInfo): string =>
@ -91,7 +94,11 @@ function PublicHubEmptyState({ title, body }: { title: string; body: string }) {
);
}
const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded = false }) => {
const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded = false, variant = "dashboard" }) => {
const isChat = variant === "chat";
const portalClassName = isChat ? "chat-theme" : undefined;
const dashboardRootClassName = isEmbedded ? "w-full" : "min-h-screen bg-card";
const dashboardContentClassName = isEmbedded ? "w-full p-6" : "w-full px-8 py-12";
const anchor = useComboboxAnchor();
const [proxyConfigured, setProxyConfigured] = useState<boolean>(false);
const [agentHubData, setAgentHubData] = useState<AgentCard[] | null>(null);
@ -126,6 +133,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
// Continue anyway - might work with default proxyBaseUrl
}
setProxyConfigured(true);
if (isChat) return;
const fetchAgentData = async () => {
try {
@ -179,7 +187,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
};
initializeAndFetch();
}, []);
}, [isChat]);
const getUniqueAgentSkills = (data: AgentCard[]) => {
const skills = new Set<string>();
@ -293,10 +301,10 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
const modeOptions = useMemo(
() =>
[...new Set(modelFacets.modes.flatMap((mode) => displayMode(mode) ?? []))].map((mode) => ({
label: mode,
label: isChat ? formatModelModeLabel(mode) : mode,
value: mode,
})),
[modelFacets],
[modelFacets, isChat],
);
const featureOptions = useMemo(
() => modelFacets.features.map((feature) => ({ label: featureLabel(feature), value: feature })),
@ -306,10 +314,14 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
const [agentSorting, setAgentSorting] = useState<SortingState>([{ id: "name", desc: false }]);
const [mcpSorting, setMcpSorting] = useState<SortingState>([{ id: "server_name", desc: false }]);
const modelColumns = useMemo(() => getPublicModelHubColumns({ onModelClick: showModal }), [showModal]);
const modelColumns = useMemo(
() => getPublicModelHubColumns({ onModelClick: showModal, showProviderLogos: isChat }),
[showModal, isChat],
);
const agentColumns = useMemo(() => getPublicAgentHubColumns({ onAgentClick: showAgentModal }), [showAgentModal]);
const mcpColumns = useMemo(() => getPublicMCPHubColumns({ onServerClick: showMcpModal }), [showMcpModal]);
const showUsefulLinks = !isChat && Object.keys(usefulLinks).length > 0;
const hasAgents = Array.isArray(agentHubData) && agentHubData.length > 0;
const hasMcpServers = Array.isArray(mcpHubData) && mcpHubData.length > 0;
@ -331,13 +343,13 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
return (
<ThemeProvider accessToken={accessToken}>
<TooltipProvider>
<div className={isEmbedded ? "w-full" : "min-h-screen bg-card"}>
<div className={isChat ? "chat-model-hub-catalog w-full" : dashboardRootClassName}>
{/* Navigation - only show when not embedded */}
{!isEmbedded && <Navbar accessToken={accessToken || null} isPublicPage={true} />}
{!isChat && !isEmbedded && <Navbar accessToken={accessToken || null} isPublicPage={true} />}
<div className={isEmbedded ? "w-full p-6" : "w-full px-8 py-12"}>
<div className={isChat ? "chat-model-hub-content w-full" : dashboardContentClassName}>
{/* Embedded Explainer - only shown when embedded in dashboard */}
{isEmbedded && (
{!isChat && isEmbedded && (
<div className="mb-6 p-4 bg-info/10 border border-info/20 rounded-lg">
<p className="text-sm text-foreground">
These are models, agents, and MCP servers your proxy admin has indicated are available in your
@ -347,7 +359,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
)}
{/* About Section - only shown when not embedded */}
{!isEmbedded && (
{!isChat && !isEmbedded && (
<Card className="mb-10 p-8 bg-card border border-border rounded-lg shadow-xs">
<h2 className="text-2xl font-semibold mb-6 text-foreground">About</h2>
<p className="text-foreground mb-6 text-base leading-relaxed">
@ -365,7 +377,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
)}
{/* Useful Links - only shown when not embedded */}
{usefulLinks && Object.keys(usefulLinks).length > 0 && (
{showUsefulLinks && (
<Card className="mb-10 p-8 bg-card border border-border rounded-lg shadow-xs">
<h2 className="text-2xl font-semibold mb-6 text-foreground">Useful Links</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@ -392,7 +404,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
)}
{/* Health and Endpoint Status - only shown when not embedded */}
{!isEmbedded && (
{!isChat && !isEmbedded && (
<Card className="mb-10 p-8 bg-card border border-border rounded-lg shadow-xs">
<h2 className="text-2xl font-semibold mb-6 text-foreground">Health and Endpoint Status</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@ -402,148 +414,200 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
)}
{/* Tabs for Models and Agents */}
<Card className="p-8 bg-card border border-border rounded-lg shadow-xs">
<Tabs value={activeTab} onValueChange={setActiveTab} className="public-hub-tabs">
<TabsList>
<TabsTrigger value="models">Model Hub</TabsTrigger>
{hasAgents && <TabsTrigger value="agents">Agent Hub</TabsTrigger>}
{hasMcpServers && <TabsTrigger value="mcp">MCP Hub</TabsTrigger>}
<TabsTrigger value="skills">Skill Hub</TabsTrigger>
</TabsList>
<Card
className={
isChat
? "chat-model-hub-card border-0 bg-transparent p-0 shadow-none"
: "p-8 bg-card border border-border rounded-lg shadow-xs"
}
>
<Tabs value={isChat ? "models" : activeTab} onValueChange={setActiveTab} className="public-hub-tabs">
{!isChat && (
<TabsList>
<TabsTrigger value="models">Model Hub</TabsTrigger>
{hasAgents && <TabsTrigger value="agents">Agent Hub</TabsTrigger>}
{hasMcpServers && <TabsTrigger value="mcp">MCP Hub</TabsTrigger>}
<TabsTrigger value="skills">Skill Hub</TabsTrigger>
</TabsList>
)}
{/* Models Tab */}
<TabsContent value="models">
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-semibold text-foreground">Available Models</h2>
</div>
<TabsContent value="models" className="model-hub-models">
{!isChat && (
<div className="model-hub-heading flex justify-between items-center mb-8">
<h2 className="text-2xl font-semibold text-foreground">Available Models</h2>
</div>
)}
{isChat && (
<div className="chat-model-section-heading">
<h2>Models</h2>
<span className="chat-model-section-count" aria-label={`${models.rowCount} models`}>
{models.rowCount.toLocaleString()}
</span>
</div>
)}
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 p-6 bg-muted rounded-lg border border-border">
<div>
<div className="flex items-center space-x-2 mb-3">
<p className="text-sm font-medium text-foreground">Search Models:</p>
<Tooltip>
<TooltipTrigger render={<Info className="w-4 h-4 text-muted-foreground cursor-help" />} />
<TooltipContent side="top">
Finds every published model whose name contains what you type, across all pages. Try
&apos;grok&apos;, &apos;claude&apos;, &apos;gpt-4&apos;, or &apos;sonnet&apos;
</TooltipContent>
</Tooltip>
</div>
<div
className={
isChat
? "chat-model-hub-filters chat-model-filter-bar"
: "chat-model-hub-filters grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 p-6 bg-muted rounded-lg border border-border"
}
>
<div className={isChat ? "chat-model-filter-search" : undefined}>
{!isChat && (
<div className="flex items-center space-x-2 mb-3">
<p className="text-sm font-medium text-foreground">Search Models:</p>
<Tooltip>
<TooltipTrigger render={<Info className="w-4 h-4 text-muted-foreground cursor-help" />} />
<TooltipContent side="top" className={portalClassName}>
Finds every published model whose name contains what you type, across all pages. Try
&apos;grok&apos;, &apos;claude&apos;, &apos;gpt-4&apos;, or &apos;sonnet&apos;
</TooltipContent>
</Tooltip>
</div>
)}
<div className="relative">
<SearchIcon className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
type="text"
placeholder="Search model names..."
aria-label="Search model names"
value={models.searchValue}
onChange={(e) => models.onSearchChange(e.target.value)}
className="border border-border rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-hidden focus:ring-2 focus:ring-ring focus:border-transparent bg-card"
/>
{isChat ? (
<Input
type="text"
placeholder="Search models"
aria-label="Search models"
value={models.searchValue}
onChange={(e) => models.onSearchChange(e.target.value)}
className="pl-9"
/>
) : (
<input
type="text"
placeholder="Search model names..."
aria-label="Search model names"
value={models.searchValue}
onChange={(e) => models.onSearchChange(e.target.value)}
className="border border-border rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-hidden focus:ring-2 focus:ring-ring focus:border-transparent bg-card"
/>
)}
</div>
</div>
<div className="min-w-0">
<p className="text-sm font-medium mb-3 text-foreground">Provider:</p>
<Combobox
multiple
items={modelFacets.providers}
value={models.providerValues}
onValueChange={models.onProvidersChange}
>
<ComboboxChips render={<div ref={anchor} />} className="min-h-8 w-full py-1 text-sm">
<ComboboxValue>
{(values: string[]) =>
values.map((provider) => (
<ComboboxChip key={provider} aria-label={provider}>
{provider}
</ComboboxChip>
))
}
</ComboboxValue>
<ComboboxChipsInput
placeholder="Select providers"
aria-label="Select providers"
className="min-w-24"
/>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No providers found</ComboboxEmpty>
<ComboboxList>
{(provider: string) => {
const { logo } = getProviderLogoAndName(provider);
return (
<ComboboxItem key={provider} value={provider}>
<span className="flex min-w-0 items-center space-x-2">
{logo && (
<img
src={logo}
alt={provider}
className="w-5 h-5 shrink-0 object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<span className="capitalize break-words">{provider}</span>
</span>
</ComboboxItem>
);
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<div className="min-w-0">
<p className="text-sm font-medium mb-3 text-foreground">Mode:</p>
{isChat && (
<ModelHubProviders
providers={modelFacets.providers}
selectedProviders={models.providerValues}
onSelect={(provider) => models.onProvidersChange(provider ? [provider] : [])}
loading={models.isLoading && modelFacets.providers.length === 0}
/>
)}
{!isChat && (
<div className="min-w-0">
<p className="text-sm font-medium mb-3 text-foreground">Provider:</p>
<Combobox
multiple
items={modelFacets.providers}
value={models.providerValues}
onValueChange={models.onProvidersChange}
>
<ComboboxChips render={<div ref={anchor} />} className="min-h-8 w-full py-1 text-sm">
<ComboboxValue>
{(values: string[]) =>
values.map((provider) => (
<ComboboxChip key={provider} aria-label={provider}>
{provider}
</ComboboxChip>
))
}
</ComboboxValue>
<ComboboxChipsInput
placeholder="Select providers"
aria-label="Select providers"
className="min-w-24"
/>
</ComboboxChips>
<ComboboxContent anchor={anchor} className={portalClassName}>
<ComboboxEmpty>No providers found</ComboboxEmpty>
<ComboboxList>
{(provider: string) => {
const { logo } = getProviderLogoAndName(provider);
return (
<ComboboxItem key={provider} value={provider}>
<span className="flex min-w-0 items-center space-x-2">
{logo && (
<img
src={logo}
alt={provider}
className="w-5 h-5 shrink-0 object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<span className="capitalize break-words">{provider}</span>
</span>
</ComboboxItem>
);
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
)}
<div className={isChat ? "chat-model-filter-select min-w-0" : "min-w-0"}>
{!isChat && <p className="text-sm font-medium mb-3 text-foreground">Mode:</p>}
<MultiSelect
options={modeOptions}
contentClassName={portalClassName}
value={models.modeValues}
onValueChange={models.onModesChange}
placeholder="Select modes"
placeholder={isChat ? "Model type" : "Select modes"}
className="w-full"
/>
</div>
<div className="min-w-0">
<p className="text-sm font-medium mb-3 text-foreground">Features:</p>
<div className={isChat ? "chat-model-filter-select min-w-0" : "min-w-0"}>
{!isChat && <p className="text-sm font-medium mb-3 text-foreground">Features:</p>}
<MultiSelect
options={featureOptions}
contentClassName={portalClassName}
value={models.featureValues}
onValueChange={models.onFeaturesChange}
placeholder="Select features"
placeholder={isChat ? "Model capability" : "Select features"}
className="w-full"
/>
</div>
</div>
<DataTable
data={models.rows}
columns={modelColumns}
getRowId={(model, index) => model.model_group || String(index)}
sortingMode="server"
sorting={models.sorting}
onSortingChange={models.onSortingChange}
paginationMode="server"
pagination={models.pagination}
onPaginationChange={models.onPaginationChange}
rowCount={models.rowCount}
isLoading={models.isLoading}
loadingMessage="Loading models…"
noDataMessage={
<PublicHubEmptyState
title={models.hasActiveQuery ? "No matching models" : "No models available"}
body={
models.hasActiveQuery
? "Adjust the search or filters to see more models."
: "Models made public by the proxy admin will appear here."
}
/>
}
size="compact"
/>
<div className="model-hub-table">
<DataTable
data={models.rows}
columns={modelColumns}
getRowId={(model, index) => model.model_group || String(index)}
sortingMode="server"
sorting={models.sorting}
onSortingChange={models.onSortingChange}
paginationMode="server"
pagination={models.pagination}
onPaginationChange={models.onPaginationChange}
rowCount={models.rowCount}
isLoading={models.isLoading}
loadingMessage="Loading models…"
noDataMessage={
<PublicHubEmptyState
title={models.hasActiveQuery ? "No matching models" : "No models available"}
body={
models.hasActiveQuery
? "Adjust the search or filters to see more models."
: "Models made public by the proxy admin will appear here."
}
/>
}
size="compact"
/>
</div>
</TabsContent>
{/* Agents Tab */}
{hasAgents && (
{!isChat && hasAgents && (
<TabsContent value="agents">
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-semibold text-foreground">Available Agents</h2>
@ -556,7 +620,9 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<p className="text-sm font-medium text-foreground">Search Agents:</p>
<Tooltip>
<TooltipTrigger render={<Info className="w-4 h-4 text-muted-foreground cursor-help" />} />
<TooltipContent side="top">Search agents by name or description</TooltipContent>
<TooltipContent side="top" className={portalClassName}>
Search agents by name or description
</TooltipContent>
</Tooltip>
</div>
<div className="relative">
@ -610,7 +676,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
)}
{/* MCP Servers Tab */}
{hasMcpServers && (
{!isChat && hasMcpServers && (
<TabsContent value="mcp">
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-semibold text-foreground">Available MCP Servers</h2>
@ -623,7 +689,9 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<p className="text-sm font-medium text-foreground">Search MCP Servers:</p>
<Tooltip>
<TooltipTrigger render={<Info className="w-4 h-4 text-muted-foreground cursor-help" />} />
<TooltipContent side="top">Search MCP servers by name or description</TooltipContent>
<TooltipContent side="top" className={portalClassName}>
Search MCP servers by name or description
</TooltipContent>
</Tooltip>
</div>
<div className="relative">
@ -677,16 +745,20 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
)}
{/* Skill Hub Tab */}
<TabsContent value="skills">
<SkillHubDashboard skills={skillHubData} isLoading={skillLoading} publicPage={true} />
</TabsContent>
{!isChat && (
<TabsContent value="skills">
<SkillHubDashboard skills={skillHubData} isLoading={skillLoading} publicPage={true} />
</TabsContent>
)}
</Tabs>
</Card>
</div>
{/* Model Details Modal */}
<Dialog open={isModalVisible} onOpenChange={(open) => !open && handleModalCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1000px]">
<DialogContent
className={`${isChat ? "chat-theme chat-model-hub-dialog " : ""}max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1000px]`}
>
<DialogHeader>
<DialogTitle className="flex min-w-0 items-center space-x-2">
<span className="break-words">{selectedModel?.model_group || "Model Details"}</span>
@ -700,7 +772,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
/>
}
/>
<TooltipContent>Copy model name</TooltipContent>
<TooltipContent className={portalClassName}>Copy model name</TooltipContent>
</Tooltip>
)}
</DialogTitle>
@ -892,7 +964,9 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{/* Agent Details Modal */}
<Dialog open={isAgentModalVisible} onOpenChange={(open) => !open && handleAgentModalCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1000px]">
<DialogContent
className={`${isChat ? "chat-theme chat-model-hub-dialog " : ""}max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1000px]`}
>
<DialogHeader>
<DialogTitle className="flex min-w-0 items-center space-x-2">
<span className="break-words">{selectedAgent?.name || "Agent Details"}</span>
@ -906,7 +980,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
/>
}
/>
<TooltipContent>Copy agent name</TooltipContent>
<TooltipContent className={portalClassName}>Copy agent name</TooltipContent>
</Tooltip>
)}
</DialogTitle>
@ -1195,7 +1269,9 @@ print(response.model_dump(mode='json', exclude_none=True))`;
{/* MCP Server Details Modal */}
<Dialog open={isMcpModalVisible} onOpenChange={(open) => !open && handleMcpModalCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1000px]">
<DialogContent
className={`${isChat ? "chat-theme chat-model-hub-dialog " : ""}max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1000px]`}
>
<DialogHeader>
<DialogTitle className="flex min-w-0 items-center space-x-2">
<span className="break-words">{selectedMcpServer?.server_name || "MCP Server Details"}</span>
@ -1209,7 +1285,7 @@ print(response.model_dump(mode='json', exclude_none=True))`;
/>
}
/>
<TooltipContent>Copy server name</TooltipContent>
<TooltipContent className={portalClassName}>Copy server name</TooltipContent>
</Tooltip>
)}
</DialogTitle>

View file

@ -37,7 +37,10 @@ export function DataTablePagination({
const lastPage = Math.max(pageCount - 1, 0);
return (
<div className={cn("flex flex-wrap items-center justify-between gap-4 px-4 py-2.5", className)}>
<div
data-slot="table-pagination"
className={cn("flex flex-wrap items-center justify-between gap-4 px-4 py-2.5", className)}
>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Rows per page</span>
<Select
@ -61,7 +64,7 @@ export function DataTablePagination({
</Select>
</div>
<div className="flex items-center gap-4">
<div data-slot="table-pagination-summary" className="flex items-center gap-4">
<span data-testid="pagination-range" className="text-sm text-muted-foreground tabular-nums">
{rowCount === 0 ? "No results" : `Showing ${start}-${end} of ${rowCount}`}
</span>

View file

@ -33,6 +33,7 @@ interface MultiSelectProps {
loading?: boolean;
allowCustomValues?: boolean;
className?: string;
contentClassName?: string;
}
const splitOnCommas = (raw: string): string[] =>
@ -62,6 +63,7 @@ export function MultiSelect({
loading = false,
allowCustomValues = false,
className,
contentClassName,
}: MultiSelectProps) {
const anchor = useComboboxAnchor();
const [query, setQuery] = useState("");
@ -128,7 +130,7 @@ export function MultiSelect({
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxContent anchor={anchor} className={contentClassName}>
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
<ComboboxList>
{(option: MultiSelectOption) => (

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, render, renderHook, screen, waitFor } from "@testing-library/react";
import { PluginModeProvider, usePluginMode } from "./PluginModeContext";
import type { Plugin } from "./PluginModeContext";
@ -35,7 +35,12 @@ describe("PluginModeProvider effectiveMode fallback", () => {
localStorage.setItem("litellm_plugin_mode", "my-plugin");
});
it("falls back to ai-gateway once an empty plugins list loads", async () => {
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
it("should fall back to ai-gateway once an empty plugins list loads", async () => {
renderWithPlugins([]);
await waitFor(() => expect(getMock).toHaveBeenCalled());
@ -43,14 +48,14 @@ describe("PluginModeProvider effectiveMode fallback", () => {
expect(screen.getByTestId("active")).toHaveTextContent("none");
});
it("keeps the stored mode when it is still registered", async () => {
it("should keep the stored mode when it is still registered", async () => {
renderWithPlugins([{ name: "my-plugin", display_name: "My Plugin", url: "https://p.example.com" }]);
await waitFor(() => expect(screen.getByTestId("active")).toHaveTextContent("my-plugin"));
expect(screen.getByTestId("mode")).toHaveTextContent("my-plugin");
});
it("falls back to ai-gateway when the plugins fetch fails, never stranding the user", async () => {
it("should fall back to ai-gateway when the plugins fetch fails, never stranding the user", async () => {
getMock.mockRejectedValueOnce(new Error("network down"));
render(
<PluginModeProvider accessToken="sk-test">
@ -61,4 +66,32 @@ describe("PluginModeProvider effectiveMode fallback", () => {
await waitFor(() => expect(getMock).toHaveBeenCalled());
await waitFor(() => expect(screen.getByTestId("mode")).toHaveTextContent("ai-gateway"));
});
it("should render the gateway when browser storage cannot be read", async () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new DOMException("Storage is blocked", "SecurityError");
});
renderWithPlugins([]);
expect(screen.getByTestId("mode")).toHaveTextContent("ai-gateway");
await waitFor(() => expect(getMock).toHaveBeenCalled());
});
it("should retain the selected workspace in memory when browser storage is full", async () => {
getMock.mockResolvedValueOnce([{ name: "my-plugin", display_name: "My Plugin", url: "https://p.example.com" }]);
const { result } = renderHook(usePluginMode, {
wrapper: ({ children }) => <PluginModeProvider accessToken="sk-test">{children}</PluginModeProvider>,
});
await waitFor(() => expect(result.current.activePlugin?.name).toBe("my-plugin"));
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new DOMException("Storage is full", "QuotaExceededError");
});
act(() => result.current.setMode("ai-gateway"));
expect(result.current.mode).toBe("ai-gateway");
expect(result.current.activePlugin).toBeNull();
expect(localStorage.getItem("litellm_plugin_mode")).toBeNull();
});
});

View file

@ -42,7 +42,11 @@ const pluginApiClient = createApiClient({ getBaseUrl: () => getProxyBaseUrl() ??
function readStoredMode(): PluginMode {
if (typeof window === "undefined") return "ai-gateway";
return localStorage.getItem(STORAGE_KEY) ?? "ai-gateway";
try {
return localStorage.getItem(STORAGE_KEY) ?? "ai-gateway";
} catch {
return "ai-gateway";
}
}
interface PluginModeProviderProps {
@ -84,7 +88,15 @@ export function PluginModeProvider({ children, accessToken }: PluginModeProvider
const setMode = (m: PluginMode) => {
setModeState(m);
localStorage.setItem(STORAGE_KEY, m);
try {
localStorage.setItem(STORAGE_KEY, m);
} catch {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Switching workspaces still works when browser storage is unavailable.
}
}
};
const activePlugin = plugins.find((p) => p.name === effectiveMode) ?? null;

View file

@ -0,0 +1,43 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { captureChatRequest } from "./chatTelemetry";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("Chat telemetry transport", () => {
it("should send only pseudonymous user and request IDs without gateway credentials or referrer", async () => {
const send = vi.fn().mockResolvedValue(new Response("{}"));
const timeout = vi.spyOn(AbortSignal, "timeout");
vi.stubGlobal("fetch", send);
await captureChatRequest("opaque-user", "unique-request");
expect(send).toHaveBeenCalledOnce();
const [url, request] = send.mock.calls[0];
expect(url).toBe("https://chat-telemetry.litellm.ai/v1/chat/events");
const expectedRequest = {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "omit",
referrerPolicy: "no-referrer",
keepalive: true,
signal: expect.any(AbortSignal),
body: expect.any(String),
};
expect(request).toEqual(expectedRequest);
expect(timeout).toHaveBeenCalledExactlyOnceWith(5000);
expect(JSON.parse(request.body)).toEqual({
user_id: "opaque-user",
request_id: "unique-request",
});
});
it("should reject an unsuccessful ingestion response for the caller to handle", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 503 })));
await expect(captureChatRequest("opaque-user", "unique-request")).rejects.toThrow(
"Chat telemetry was not accepted",
);
expect(fetch).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,16 @@
export async function captureChatRequest(userId: string, requestId: string): Promise<void> {
const request: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "omit",
referrerPolicy: "no-referrer",
keepalive: true,
signal: AbortSignal.timeout(5000),
body: JSON.stringify({
user_id: userId,
request_id: requestId,
}),
};
const response = await fetch("https://chat-telemetry.litellm.ai/v1/chat/events", request);
if (!response.ok) throw new Error("Chat telemetry was not accepted");
}

View file

@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ capture: vi.fn(), fetch: vi.fn() }));
vi.mock("@/lib/http/chatTelemetry", () => ({ captureChatRequest: mocks.capture }));
let chat: typeof import("./chat");
beforeEach(async () => {
vi.resetModules();
vi.resetAllMocks();
vi.stubEnv("NODE_ENV", "production");
vi.stubGlobal("window", { location: { origin: "https://gateway.example.com" } });
vi.stubGlobal("fetch", mocks.fetch);
mocks.capture.mockResolvedValue(undefined);
chat = await import("./chat");
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe("Chat request metrics", () => {
it("should do nothing when imported without a submission", () => {
expect(mocks.fetch).not.toHaveBeenCalled();
expect(mocks.capture).not.toHaveBeenCalled();
});
it("should emit by default with a stable user ID, fresh request IDs, and no backend settings request", async () => {
await chat.recordChatQuery("alice@example.com");
await chat.recordChatQuery("alice@example.com");
expect(mocks.capture).toHaveBeenCalledTimes(2);
const ids = mocks.capture.mock.calls.map(([id]) => id);
expect(ids[0]).toMatch(/^[\da-f]{8}-[\da-f]{4}-5[\da-f]{3}-[\da-f]{4}-[\da-f]{12}$/);
expect(ids[0]).toBe(ids[1]);
const requestIds = mocks.capture.mock.calls.map(([, requestId]) => requestId);
for (const requestId of requestIds) {
expect(requestId).toMatch(/^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[\da-f]{4}-[\da-f]{12}$/);
}
expect(new Set(requestIds).size).toBe(2);
expect(mocks.capture.mock.calls.every((args) => args.length === 2)).toBe(true);
expect(JSON.stringify(mocks.capture.mock.calls)).not.toContain("alice@example.com");
expect(JSON.stringify(mocks.capture.mock.calls)).not.toContain("gateway.example.com");
expect(mocks.fetch).not.toHaveBeenCalled();
});
it("should use the same user ID across reloads and browsers without stored identifiers", async () => {
await chat.recordChatQuery("alice");
vi.resetModules();
chat = await import("./chat");
vi.stubGlobal("window", { location: { origin: "https://gateway.example.com" } });
await chat.recordChatQuery("alice");
expect(mocks.capture.mock.calls[0][0]).toBe(mocks.capture.mock.calls[1][0]);
});
it("should distinguish accounts and deployments", async () => {
await chat.recordChatQuery("alice");
await chat.recordChatQuery("bob");
vi.stubGlobal("window", { location: { origin: "https://another.example.com" } });
await chat.recordChatQuery("alice");
const ids = mocks.capture.mock.calls.map(([id]) => id);
expect(new Set(ids).size).toBe(3);
});
it("should preserve user identity across days while leaving event timestamps to the collector", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-10-23T23:59:00Z"));
await chat.recordChatQuery("alice");
vi.setSystemTime(new Date("2026-10-24T00:01:00Z"));
await chat.recordChatQuery("alice");
expect(mocks.capture.mock.calls[0][0]).toBe(mocks.capture.mock.calls[1][0]);
expect(mocks.capture.mock.calls[0][1]).not.toBe(mocks.capture.mock.calls[1][1]);
expect(JSON.stringify(mocks.capture.mock.calls)).not.toContain("2026-10-");
});
it("should not collect from development, unauthenticated, or server rendering", async () => {
vi.stubEnv("NODE_ENV", "development");
await chat.recordChatQuery("alice");
vi.stubEnv("NODE_ENV", "production");
await chat.recordChatQuery("");
vi.stubGlobal("window", undefined);
await chat.recordChatQuery("alice");
expect(mocks.fetch).not.toHaveBeenCalled();
expect(mocks.capture).not.toHaveBeenCalled();
});
it("should count requests on HTTP deployments without secure-context APIs or browser storage", async () => {
vi.stubGlobal("window", { location: { origin: "http://gateway.internal:4000" } });
vi.stubGlobal("crypto", { getRandomValues: crypto.getRandomValues.bind(crypto) });
vi.stubGlobal("localStorage", undefined);
await chat.recordChatQuery("alice");
expect(mocks.capture).toHaveBeenCalledOnce();
});
it("should separate deployments mounted under different root paths", async () => {
const { setServerRootPath } = await import("@/lib/serverRootPath");
setServerRootPath("/team-a/");
await chat.recordChatQuery("alice");
setServerRootPath("team-a");
await chat.recordChatQuery("alice");
setServerRootPath("/team-b");
await chat.recordChatQuery("alice");
const ids = mocks.capture.mock.calls.map(([id]) => id);
expect(ids[0]).toBe(ids[1]);
expect(ids[0]).not.toBe(ids[2]);
});
it("should swallow transport failures without retrying requests", async () => {
mocks.capture.mockRejectedValueOnce(new Error("blocked"));
await expect(chat.recordChatQuery("alice")).resolves.toBeUndefined();
expect(mocks.capture).toHaveBeenCalledTimes(1);
await chat.recordChatQuery("alice");
expect(mocks.capture).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,20 @@
import { v4 as uuidv4, v5 as uuidv5 } from "uuid";
import { serverRootPath } from "@/lib/serverRootPath";
import { captureChatRequest } from "@/lib/http/chatTelemetry";
// Called only by Chat's accepted-submit handler. Daily unique users = DAU;
// event count = total requests. Page visits never count as activity.
export async function recordChatQuery(userId: string): Promise<void> {
// Match docs: development previews never contribute to production metrics.
if (process.env.NODE_ENV !== "production" || typeof window === "undefined" || !userId) return;
try {
// Stable across browsers for an account on this deployment. The payload
// excludes its raw account ID, email, and gateway origin.
const root = serverRootPath.replace(/^\/+|\/+$/g, "");
const identity = JSON.stringify([window.location.origin, root, userId]);
const distinctId = uuidv5(identity, uuidv5.URL);
await captureChatRequest(distinctId, uuidv4());
} catch {
// Analytics failures must never interrupt a request or trigger a retry.
}
}