feat(ui): rebuild the public AI Hub as a searchable list with a details pane (#49496)

* feat(ui): rebuild the public AI Hub as a searchable list with a details pane

The public AI Hub at /ui/model_hub_table (and the older /ui/model_hub) now
renders PublicAiHub, built from shadcn parts only: a header with the
gateway's base URL and the admin's links, segmented tabs for models, agents,
MCP servers and skills, and a list beside a details pane on desktop that
becomes a bottom sheet on phones. The open item and tab live in the URL.

It keeps every feature the old page had: server-side search, provider, mode
and feature filters, sorting, paging, health, rate limits, prices, custom
metadata, supported params, usage snippets, the require-auth redirect and
the not-enabled state. cURL shows only for chat models, since the old one
called chat completions for every mode.

The navbar hides the community buttons below the sm breakpoint on every page,
and the public navbar uses a smaller logo on phones, so Docs and Blog no
longer collide with them.

* fix(ui): harden the public AI Hub after adversarial review

- Show "This AI Hub isn't available" only when every hub call fails; a
  failed models call now shows an inline error with Try again and leaves
  agents, MCP servers and skills usable (a gateway with no models answers
  the models list with 400)
- Row links keep the page's other query params, so open-in-new-tab and
  copy link no longer 404
- A shared ?open= link resolves models beyond the first page, keeps the
  opened item when a search filters it out, and says when it is no
  longer listed instead of showing a different item
- Searching, sorting and paging no longer blank the details pane or drop
  the Operational badge
- Key MCP servers by their listed name, which is never null
- Keep sub-cent prices and show Free for zero cost, fold evaluation into
  Decisions, sort agents, MCP servers and skills by name, restore
  word-wise search, and show a skill's source, author and date added
- Drop the invented description for models without a mode, the empty
  agent modes row, the stray auth dash on MCP rows and NaN dates
- Do not retry the UI config call, and close the 1023-1024px gap between
  the sheet and the details pane
- Update the public hub Playwright spec for the new tabs and list, and
  prune the lint suppressions the old route pages needed

* fix(ui): surface hub load errors and keep the open item steady

Agents, MCP servers and skills now load through apiClient so an HTTP error
shows the inline error instead of an empty "published yet" state, and an
all-failing gateway reaches the unavailable page. Try again shows loading
instead of an empty hub, the open model stays in the details pane when a
search or page change hides its row, a zero-result search keeps the pane,
phones get the not-listed sheet for a stale link, an empty open param means
nothing opened, free models read Free in the list, and an invalid skill date
is hidden
This commit is contained in:
yuneng-jiang 2026-11-03 18:23:01 -08:00 • committed by GitHub
parent b9ad61d4f6
commit cbe361d970
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2645 additions and 70 deletions

View file

@ -64,7 +64,7 @@ test.describe("AI Hub (internal admin view)", () => {
test.describe("Public model hub (/ui/model_hub_table)", () => {
// No storageState — the public page is reached anonymously with a `key` query param.
test("Public model_hub_table loads and renders the Model Hub tab", async ({ page }) => {
test("Public model_hub_table loads and renders the Models tab", async ({ page }) => {
// The page expects the proxy key as the `key` query param. Use the master
// key the e2e runner already exports — this matches what the AI Hub copy
// button hands out.
@ -74,13 +74,10 @@ test.describe("Public model hub (/ui/model_hub_table)", () => {
// race can't briefly mask the tab while we're evaluating visibility.
await dismissFeedbackPopup(page);
// Page loads (no auth redirect) and the Model Hub tab is always present.
// Agent Hub and MCP Hub tabs are conditionally rendered only when public
// agents/MCP servers exist, so we don't assert on them in a fresh CI run.
await expect(page.getByRole("tab", { name: "Model Hub" })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("tab", { name: /^Models/ })).toBeVisible({ timeout: 10_000 });
});
test("Agent Hub and MCP Hub tabs render their public entries", async ({ page, request }) => {
test("Agents and MCP servers tabs render their public entries", async ({ page, request }) => {
const suffix = `${Date.now()}`;
const agentName = `e2e-public-agent-${suffix}`;
const mcpServerName = `e2e_public_mcp_${suffix}`;
@ -145,18 +142,18 @@ test.describe("Public model hub (/ui/model_hub_table)", () => {
await page.goto(`/ui/model_hub_table?key=${masterKey()}`);
await dismissFeedbackPopup(page);
const agentHubTab = page.getByRole("tab", { name: "Agent Hub" });
await expect(agentHubTab).toBeVisible({ timeout: 15_000 });
await agentHubTab.click();
await expect(page.getByText("Available Agents")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("row").filter({ hasText: agentName })).toHaveCount(1, { timeout: 10_000 });
const agentsTab = page.getByRole("tab", { name: /^Agents/ });
await expect(agentsTab).toBeVisible({ timeout: 15_000 });
await agentsTab.click();
await expect(page.getByRole("textbox", { name: "Search agents" })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("link").filter({ hasText: agentName })).toHaveCount(1, { timeout: 10_000 });
await expect(page.getByText("E2E public agent").first()).toBeVisible();
const mcpHubTab = page.getByRole("tab", { name: "MCP Hub" });
await expect(mcpHubTab).toBeVisible();
await mcpHubTab.click();
await expect(page.getByText("Available MCP Servers")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("row").filter({ hasText: mcpServerName })).toHaveCount(1, { timeout: 10_000 });
const mcpTab = page.getByRole("tab", { name: /^MCP/ });
await expect(mcpTab).toBeVisible();
await mcpTab.click();
await expect(page.getByRole("textbox", { name: "Search MCP servers" })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("link").filter({ hasText: mcpServerName })).toHaveCount(1, { timeout: 10_000 });
await expect(page.getByText("E2E public MCP server").first()).toBeVisible();
} finally {
await request.post("/v1/mcp/make_public", { headers: auth, data: { mcp_server_ids: priorMcpIds } });

View file

@ -1153,16 +1153,6 @@
"count": 2
}
},
"src/app/model_hub/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/model_hub_table/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/AIHub/ModelHubTable.test.tsx": {
"max-params": {
"count": 1

View file

@ -1,27 +1,19 @@
"use client";
import React, { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import PublicModelHubPage from "@/components/public_model_hub";
import { Suspense } from "react";
function PublicModelHubContent() {
const searchParams = useSearchParams()!;
const key = searchParams.get("key");
const [accessToken, setAccessToken] = useState<string | null>(null);
import PublicAiHub from "@/components/publicModelHub/PublicAiHub";
useEffect(() => {
if (!key) {
return;
}
setAccessToken(key);
}, [key]);
return <PublicModelHubPage accessToken={accessToken} />;
function PublicAiHubPage() {
const accessToken = useSearchParams()?.get("key") ?? null;
return <PublicAiHub accessToken={accessToken} />;
}
export default function PublicModelHub() {
return (
<Suspense fallback={<div className="flex items-center justify-center min-h-screen">Loading...</div>}>
<PublicModelHubContent />
<Suspense fallback={<div className="flex min-h-screen items-center justify-center">Loading...</div>}>
<PublicAiHubPage />
</Suspense>
);
}

View file

@ -1,36 +1,19 @@
"use client";
import React, { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import ModelHubTable from "@/components/AIHub/ModelHubTable";
import { Suspense } from "react";
function PublicModelHubTableContent() {
const searchParams = useSearchParams()!;
const key = searchParams.get("key");
const [accessToken, setAccessToken] = useState<string | null>(null);
import PublicAiHub from "@/components/publicModelHub/PublicAiHub";
useEffect(() => {
if (!key) {
return;
}
setAccessToken(key);
}, [key]);
return (
<ModelHubTable
accessToken={accessToken}
publicPage={true}
premiumUser={false}
userRole={null}
canOpenPlayground={false}
canAddModels={false}
/>
);
function PublicAiHubPage() {
const accessToken = useSearchParams()?.get("key") ?? null;
return <PublicAiHub accessToken={accessToken} />;
}
export default function PublicModelHubTable() {
return (
<Suspense fallback={<div className="flex items-center justify-center min-h-screen">Loading...</div>}>
<PublicModelHubTableContent />
<Suspense fallback={<div className="flex min-h-screen items-center justify-center">Loading...</div>}>
<PublicAiHubPage />
</Suspense>
);
}

View file

@ -61,7 +61,7 @@ const Navbar: React.FC<NavbarProps> = ({
return (
<nav className="sticky top-0 z-chrome border-b border-border bg-card">
<div className="w-full">
<div className="flex h-14 items-center px-4">
<div className={cn("flex h-14 items-center px-4", isPublicPage && "gap-4 sm:px-6")}>
<div className="flex shrink-0 items-center">
{onToggleSidebar && (
<button
@ -82,7 +82,12 @@ const Navbar: React.FC<NavbarProps> = ({
<div className="flex items-center gap-2">
<Link href={uiHref("")} className="flex items-center">
<div className="relative">
<div className="flex h-10 max-w-48 items-center justify-center overflow-hidden">
<div
className={cn(
"flex h-10 max-w-48 items-center justify-center overflow-hidden",
isPublicPage && "h-8 max-w-36 sm:h-10 sm:max-w-48",
)}
>
<img src={imageUrl} alt="LiteLLM Brand" className={cn(NAV_LOGO_CLASS_NAME, "dark:hidden")} />
<img
src={darkImageUrl}
@ -141,7 +146,7 @@ const Navbar: React.FC<NavbarProps> = ({
</nav>
{!hideCommunityLinks && (
<div className="flex shrink-0 items-center border-l border-border pl-4">
<div className="hidden shrink-0 items-center border-l border-border pl-4 sm:flex">
<CommunityEngagementButtons />
</div>
)}

View file

@ -0,0 +1,80 @@
"use client";
import { ExternalLink } from "lucide-react";
import type { AgentCard } from "@/components/PublicModelHubTableColumns";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription } from "@/components/ui/field";
import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemTitle } from "@/components/ui/item";
import { agentCapabilities } from "./hubFormat";
import { Badges, CodeBlock, EndpointField, Facts, PaneSection } from "./HubParts";
import { agentSnippets } from "./hubSnippets";
import { InspectorHeader } from "./InspectorHeader";
const modes = (agent: AgentCard) => {
const input = (agent.defaultInputModes ?? []).join(", ");
const output = (agent.defaultOutputModes ?? []).join(", ");
return input || output ? `${input || "-"} / ${output || "-"}` : null;
};
export function AgentInspector({ agent }: { agent: AgentCard }) {
const capabilities = agentCapabilities(agent);
return (
<div className="flex flex-col gap-6 p-6">
<InspectorHeader title={agent.name} copyLabel="Copy agent name" description={agent.description} />
<Facts
groups={[
[
{ label: "Version", value: agent.version },
{ label: "Owner", value: agent.provider?.organization },
{ label: "Protocol", value: agent.protocolVersion && `A2A ${agent.protocolVersion}` },
],
[
{ label: "Input / output", wide: true, value: modes(agent) },
{ label: "Capabilities", wide: true, value: capabilities.length > 0 && <Badges items={capabilities} /> },
],
]}
/>
<EndpointField id={`agent-endpoint-${agent.name}`} value={agent.url} />
{agent.documentationUrl && (
<Button
variant="link"
nativeButton={false}
className="h-auto self-start px-0"
render={<a href={agent.documentationUrl} target="_blank" rel="noopener noreferrer" />}
>
Read the documentation
<ExternalLink />
</Button>
)}
{(agent.skills ?? []).length > 0 && (
<PaneSection title="Skills">
<ItemGroup className="gap-2">
{agent.skills.map((skill) => (
<Item key={skill.id} variant="outline" size="sm" role="listitem">
<ItemContent>
<ItemTitle>{skill.name}</ItemTitle>
<ItemDescription>{skill.description}</ItemDescription>
</ItemContent>
<ItemActions>
<Badges items={skill.tags ?? []} />
</ItemActions>
</Item>
))}
</ItemGroup>
</PaneSection>
)}
<PaneSection title="Call this agent">
{agentSnippets(agent).map((snippet, index) => (
<Field key={snippet.title}>
<FieldDescription>
Step {index + 1}: {snippet.title}
</FieldDescription>
<CodeBlock code={snippet.code} />
</Field>
))}
</PaneSection>
</div>
);
}

View file

@ -0,0 +1,91 @@
"use client";
import { CirclePlus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from "@/components/ui/combobox";
import { cn } from "@/lib/cva.config";
import { firstPlus } from "./hubFormat";
import { ProviderAvatar } from "./HubParts";
interface FilterOption {
value: string;
label: string;
provider?: string;
}
export function FilterChip({
label,
noun,
options,
value,
onChange,
}: {
label: string;
noun: string;
options: FilterOption[];
value: string[];
onChange: (value: string[]) => void;
}) {
const byValue = new Map(options.map((option) => [option.value, option]));
const selected = value.map((v) => byValue.get(v)?.label ?? v);
const active = value.length > 0;
return (
<ButtonGroup>
<Combobox<FilterOption, true>
multiple
items={options}
value={options.filter((option) => value.includes(option.value))}
onValueChange={(next) => onChange(next.map((option) => option.value))}
itemToStringLabel={(option) => option.label}
isItemEqualToValue={(a, b) => a.value === b.value}
>
<ComboboxTrigger
render={
<Button
variant="outline"
size="sm"
className={cn("[&>svg:last-child]:hidden", !active && "border-dashed text-muted-foreground")}
/>
}
>
{!active && <CirclePlus />}
{label}
{active && <span className="font-normal text-muted-foreground">{firstPlus(selected)}</span>}
</ComboboxTrigger>
<ComboboxContent className="w-auto min-w-60">
<ComboboxInput showTrigger={false} placeholder={`Search ${noun}`} />
<ComboboxEmpty>No {noun} found</ComboboxEmpty>
<ComboboxList>
{(option: FilterOption) => (
<ComboboxItem key={option.value} value={option}>
{option.provider && <ProviderAvatar provider={option.provider} />}
<span className="whitespace-nowrap">{option.label}</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
{active && (
<Button
variant="outline"
size="icon-sm"
aria-label={`Clear ${label.toLowerCase()} filter`}
onClick={() => onChange([])}
>
<X />
</Button>
)}
</ButtonGroup>
);
}

View file

@ -0,0 +1,92 @@
"use client";
import { LayoutGrid, SearchX, TriangleAlert } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { SECTION_TITLE } from "./hubFormat";
const noResultsTitle = (noun: string, query: string, filtered: boolean) => {
if (!filtered) return `No ${noun} published yet`;
return query ? `No ${noun} match "${query}"` : `No ${noun} match these filters`;
};
export function NoResults({
icon,
noun,
query,
onClear,
}: {
icon: React.ReactNode;
noun: string;
query: string;
onClear?: () => void;
}) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">{icon}</EmptyMedia>
<EmptyTitle className={SECTION_TITLE}>{noResultsTitle(noun, query, Boolean(onClear))}</EmptyTitle>
<EmptyDescription>
{onClear ? "Try a shorter search or fewer filters" : "Your admin publishes them from the AI Hub page"}
</EmptyDescription>
</EmptyHeader>
{onClear && (
<EmptyContent>
<Button variant="outline" size="sm" onClick={onClear}>
Clear filters
</Button>
</EmptyContent>
)}
</Empty>
);
}
export function HubNotEnabled() {
return (
<Empty className="rounded-xl border border-solid border-border">
<EmptyHeader>
<EmptyMedia variant="icon">
<LayoutGrid />
</EmptyMedia>
<EmptyTitle className={SECTION_TITLE}>This AI Hub isn&apos;t available</EmptyTitle>
<EmptyDescription>Ask your proxy admin to make it public from their Admin UI</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
export function LoadFailed({ noun, onRetry }: { noun: string; onRetry: () => void }) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<TriangleAlert />
</EmptyMedia>
<EmptyTitle className={SECTION_TITLE}>Couldn&apos;t load {noun}</EmptyTitle>
<EmptyDescription>The gateway returned an error. Try again in a moment</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="outline" size="sm" onClick={onRetry}>
Try again
</Button>
</EmptyContent>
</Empty>
);
}
export function NotListed({ name }: { name: string }) {
return (
<Empty className="h-full">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchX />
</EmptyMedia>
<EmptyTitle className={SECTION_TITLE}>&ldquo;{name}&rdquo; isn&apos;t listed here</EmptyTitle>
<EmptyDescription>It may have been renamed or unpublished</EmptyDescription>
</EmptyHeader>
</Empty>
);
}

View file

@ -0,0 +1,103 @@
"use client";
import { ChevronDown, ExternalLink, LayoutGrid } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Field, FieldDescription } from "@/components/ui/field";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
import { gatewayUrl, usefulLinkList } from "./hubFormat";
import { CopyIconButton } from "./HubParts";
import { StatusBadge } from "./HubStatus";
import type { HubInfo } from "./useHubData";
export type HubStatus = "loading" | "operational" | "degraded" | "unavailable";
function LinksMenu({ info }: { info: HubInfo | undefined }) {
const links = usefulLinkList(info?.useful_links);
if (links.length === 0) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="sm" />}>
Links
<ChevronDown className="text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-auto min-w-(--anchor-width)">
{links.map((link) => (
<DropdownMenuItem key={link.title} render={<a href={link.url} target="_blank" rel="noopener noreferrer" />}>
<ExternalLink />
{link.title}
</DropdownMenuItem>
))}
{info?.litellm_version && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs font-normal">LiteLLM v{info.litellm_version}</DropdownMenuLabel>
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
function BaseUrlField() {
const url = gatewayUrl();
return (
<Field className="w-full gap-1.5 sm:w-[420px]">
<InputGroup className="h-8">
<InputGroupAddon>
<InputGroupText className="text-xs font-medium">Base URL</InputGroupText>
</InputGroupAddon>
<InputGroupInput
readOnly
value={url}
aria-label="Base URL"
aria-describedby="hub-base-url-hint"
className="font-mono text-xs md:text-xs"
/>
<InputGroupAddon align="inline-end">
<CopyIconButton value={url} label="Copy base URL" className="size-6" />
</InputGroupAddon>
</InputGroup>
<FieldDescription id="hub-base-url-hint" className="text-xs text-balance">
Use your LiteLLM key as the API key in any OpenAI-compatible SDK
</FieldDescription>
</Field>
);
}
export function HubHeader({ info, status }: { info: HubInfo | undefined; status: HubStatus }) {
return (
<header className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h1 className="flex items-center gap-2 text-xl leading-7 font-semibold">
<LayoutGrid className="size-5" />
AI Hub
</h1>
{status === "operational" && <StatusBadge tone="success" label="Operational" />}
</div>
<p className="text-sm text-muted-foreground">
{info?.custom_docs_description || "Models, agents, MCP servers and skills you can call through this gateway"}
</p>
</div>
{status !== "unavailable" && (
<div className="flex flex-wrap items-start gap-2">
<BaseUrlField />
<LinksMenu info={info} />
</div>
)}
</header>
);
}

View file

@ -0,0 +1,234 @@
"use client";
import * as React from "react";
import { Item, ItemContent, ItemGroup, ItemMedia } from "@/components/ui/item";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/cva.config";
import { NotListed } from "./HubEmpty";
import { InSheet } from "./InspectorHeader";
import { useOpenHref } from "./useHubUrlState";
import { useIsNarrow } from "./useIsNarrow";
function ListSkeleton() {
return (
<ItemGroup className="gap-0.5 p-3" aria-busy="true">
{[0, 1, 2].map((i) => (
<div key={i} role="listitem">
<Item size="sm">
<ItemMedia variant="icon" className="size-5">
<Skeleton className="size-5 rounded-full" />
</ItemMedia>
<ItemContent className="gap-1.5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</ItemContent>
</Item>
</div>
))}
</ItemGroup>
);
}
function DetailSkeleton() {
return (
<div className="flex flex-col gap-6 p-6" aria-busy="true">
<Skeleton className="h-5 w-48" />
<div className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="flex flex-col gap-1.5">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
<Skeleton className="h-40 w-full" />
</div>
);
}
const isPlainClick = (event: React.MouseEvent) => {
const opensElsewhere = event.metaKey || event.ctrlKey || event.shiftKey;
return event.button === 0 && !opensElsewhere;
};
function Rows<T>({
rows,
activeKey,
explicit,
getKey,
onSelect,
renderItem,
}: {
rows: T[];
activeKey: string | undefined;
explicit: boolean;
getKey: (row: T) => string;
onSelect: (key: string) => void;
renderItem: (row: T) => React.ReactNode;
}) {
const hrefFor = useOpenHref();
const follow = (key: string) => (event: React.MouseEvent) => {
if (!isPlainClick(event)) return;
event.preventDefault();
onSelect(key);
};
return (
<ItemGroup className="gap-0.5 p-3">
{rows.map((row) => {
const key = getKey(row);
const active = key === activeKey;
return (
<div key={key} role="listitem">
<Item
size="sm"
render={
<a href={hrefFor(key)} aria-current={active && explicit ? "true" : undefined} onClick={follow(key)} />
}
className={cn(active && (explicit ? "bg-muted" : "lg:bg-muted"))}
>
{renderItem(row)}
</Item>
</div>
);
})}
</ItemGroup>
);
}
const resolveSelection = <T,>(
rows: T[],
selectedKey: string | null,
opened: T | undefined,
getKey: (row: T) => string,
) => {
const chosen = selectedKey === null ? undefined : opened ?? rows.find((row) => getKey(row) === selectedKey);
return { chosen, selected: selectedKey === null ? rows[0] : chosen };
};
function useSheet<T>(
chosen: T | undefined,
missingKey: string | undefined,
getKey: (row: T) => string,
renderDetail: (row: T) => React.ReactNode,
) {
const [content, setContent] = React.useState<{ row?: T; missingKey?: string }>({ row: chosen });
if (chosen !== undefined && chosen !== content.row) setContent({ row: chosen });
if (missingKey !== undefined && missingKey !== content.missingKey) setContent({ missingKey });
const { row } = content;
return {
open: chosen !== undefined || missingKey !== undefined,
title: row ? getKey(row) : content.missingKey ?? "Details",
body: row ? renderDetail(row) : content.missingKey && <NotListed name={content.missingKey} />,
};
}
export function HubMasterDetail<T>({
switcher,
toolbar,
rows,
loading,
selectedKey,
opened,
openPending = false,
getKey,
onSelect,
renderItem,
renderDetail,
empty,
footer,
}: {
switcher?: React.ReactNode;
toolbar: React.ReactNode;
rows: T[];
loading: boolean;
selectedKey: string | null;
opened?: T;
openPending?: boolean;
getKey: (row: T) => string;
onSelect: (key: string | null) => void;
renderItem: (row: T) => React.ReactNode;
renderDetail: (row: T) => React.ReactNode;
empty: React.ReactNode;
footer?: React.ReactNode;
}) {
const narrow = useIsNarrow();
const { chosen, selected } = resolveSelection(rows, selectedKey, opened, getKey);
const isEmpty = !loading && rows.length === 0;
const settled = !loading && !openPending && !isEmpty;
const missingKey = selectedKey !== null && chosen === undefined && settled ? selectedKey : undefined;
const sheet = useSheet(chosen, missingKey, getKey, renderDetail);
const listOnly = isEmpty && chosen === undefined && !openPending;
const detail = () => {
if (loading || (selected === undefined && openPending)) return <DetailSkeleton />;
if (selected === undefined) return selectedKey && <NotListed name={selectedKey} />;
return renderDetail(selected);
};
const list = isEmpty ? (
empty
) : (
<>
<Rows
rows={rows}
activeKey={selected && getKey(selected)}
explicit={chosen !== undefined}
getKey={getKey}
onSelect={onSelect}
renderItem={renderItem}
/>
{footer}
</>
);
return (
<div
className={cn(
"grid min-h-0 grid-cols-1 overflow-hidden rounded-xl border border-border lg:h-[calc(100dvh-18.875rem)] lg:min-h-[560px]",
!listOnly && "lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)]",
)}
>
<div className={cn("flex min-h-0 flex-col", !listOnly && "lg:border-r lg:border-border")}>
<div className="flex flex-col gap-3 border-b border-border p-3">
{switcher}
<div className="flex flex-col gap-2">{toolbar}</div>
</div>
<ScrollArea className="min-h-0 flex-1">{loading ? <ListSkeleton /> : list}</ScrollArea>
</div>
{!listOnly && <ScrollArea className="hidden min-h-0 lg:block">{detail()}</ScrollArea>}
<DetailSheet open={narrow && sheet.open} title={sheet.title} onClose={() => onSelect(null)}>
{sheet.body}
</DetailSheet>
</div>
);
}
function DetailSheet({
open,
title,
onClose,
children,
}: {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
}) {
const body = React.useRef<HTMLDivElement>(null);
return (
<Sheet open={open} onOpenChange={(next) => !next && onClose()}>
<SheetContent
side="bottom"
showCloseButton={false}
initialFocus={body}
className="max-h-[85dvh] gap-0 rounded-t-xl p-0 lg:hidden"
>
<SheetTitle className="sr-only">{title}</SheetTitle>
<div ref={body} tabIndex={-1} className="min-h-0 overflow-y-auto outline-none">
<InSheet.Provider value>{children}</InSheet.Provider>
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,187 @@
"use client";
import { Copy, Search, X } from "lucide-react";
import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Field, FieldLabel } from "@/components/ui/field";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { ItemTitle } from "@/components/ui/item";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/cva.config";
import { copyToClipboard } from "@/utils/dataUtils";
import { providerLogo, providerName, SECTION_TITLE } from "./hubFormat";
export function CopyIconButton({ value, label, className }: { value: string; label: string; className?: string }) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={label}
className={cn("text-muted-foreground", className)}
onClick={() => void copyToClipboard(value, "Copied")}
/>
}
>
<Copy />
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
export function CodeBlock({ code, label = "Copy code" }: { code: string; label?: string }) {
return (
<div className="relative min-w-0 rounded-lg border border-border bg-muted">
<CopyIconButton value={code} label={label} className="absolute top-1.5 right-1.5 bg-background" />
<pre className="max-h-96 overflow-auto p-3 pr-12 font-mono text-xs leading-5 whitespace-pre text-foreground">
{code}
</pre>
</div>
);
}
export function ProviderAvatar({ provider, className }: { provider: string; className?: string }) {
const logo = providerLogo(provider);
return (
<Avatar className={cn("size-4 bg-white ring-1 ring-border", className)}>
{logo && <AvatarImage src={logo} alt="" className="object-contain p-0.5" />}
<AvatarFallback className="bg-muted text-xs">{providerName(provider).charAt(0)}</AvatarFallback>
</Avatar>
);
}
export function ProviderName({ provider }: { provider: string }) {
return (
<span className="flex min-w-0 items-center gap-1.5">
<ProviderAvatar provider={provider} />
<span className="truncate">{providerName(provider)}</span>
</span>
);
}
export function Badges({ items, mono }: { items: string[]; mono?: boolean }) {
if (items.length === 0) return null;
return (
<div className="flex flex-wrap gap-1">
{items.map((item) => (
<Badge key={item} variant="secondary" className={cn("font-medium", mono && "font-mono font-normal")}>
{item}
</Badge>
))}
</div>
);
}
export function PaneSection({
title,
aside,
children,
}: {
title: string;
aside?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<h3 className={SECTION_TITLE}>{title}</h3>
{aside}
</div>
{children}
</section>
);
}
export type Fact = { label: string; value: React.ReactNode; wide?: boolean };
const BLANK: React.ReactNode[] = [null, undefined, false, "", "-"];
const present = (value: React.ReactNode) => !BLANK.includes(value);
export function Facts({ groups }: { groups: Fact[][] }) {
const shown = groups.map((group) => group.filter((fact) => present(fact.value))).filter((group) => group.length);
if (shown.length === 0) return null;
return (
<div className="flex flex-col gap-4">
{shown.map((group) => (
<dl key={group[0].label} className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4">
{group.map((fact) => (
<div key={fact.label} className={cn("flex min-w-0 flex-col gap-1", fact.wide && "col-span-2")}>
<dt className="text-xs font-medium whitespace-nowrap text-muted-foreground">{fact.label}</dt>
<dd className="min-w-0 truncate text-sm tabular-nums">{fact.value}</dd>
</div>
))}
</dl>
))}
</div>
);
}
export function EndpointField({ id, value }: { id: string; value: string }) {
return (
<Field>
<FieldLabel htmlFor={id}>Endpoint</FieldLabel>
<InputGroup>
<InputGroupInput id={id} readOnly value={value} className="font-mono text-xs md:text-xs" />
<InputGroupAddon align="inline-end">
<CopyIconButton value={value} label="Copy endpoint" className="size-6" />
</InputGroupAddon>
</InputGroup>
</Field>
);
}
export function SearchInput({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (value: string) => void;
placeholder: string;
}) {
return (
<InputGroup className="h-8 min-w-0 flex-1">
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
aria-label={placeholder}
/>
{value && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => onChange("")}>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
);
}
export function RowTitle({ children }: { children: React.ReactNode }) {
return (
<ItemTitle className="w-full min-w-0">
<span className="line-clamp-2 wrap-anywhere">{children}</span>
</ItemTitle>
);
}
export function Joined({ parts }: { parts: (string | null | undefined)[] }) {
return parts.filter(Boolean).map((part, index) => (
<React.Fragment key={part}>
{index > 0 && " · "}
<span className="whitespace-nowrap">{part}</span>
</React.Fragment>
));
}

View file

@ -0,0 +1,49 @@
"use client";
import { CircleCheck, CircleDashed, CircleX } from "lucide-react";
import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
import { Badge } from "@/components/ui/badge";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { cn } from "@/lib/cva.config";
import { healthOf, type HealthTone, relativeTime } from "./hubFormat";
const TONE_ICON = { success: CircleCheck, error: CircleX, neutral: CircleDashed } as const;
const TONE_CLASS = {
success: "border-success/25 bg-success/10 text-success",
error: "border-destructive/25 bg-destructive/10 text-destructive",
neutral: "text-muted-foreground",
} as const;
export function StatusBadge({ tone, label }: { tone: HealthTone; label: string }) {
const Icon = TONE_ICON[tone];
return (
<Badge variant="outline" className={cn("font-medium", TONE_CLASS[tone])}>
<Icon data-icon="inline-start" />
{label}
</Badge>
);
}
export function HealthBadge({ model }: { model: ModelGroupInfo }) {
const badge = <StatusBadge {...healthOf(model)} />;
if (!model.health_status) return badge;
return (
<HoverCard>
<HoverCardTrigger delay={150} render={<span tabIndex={0} className="inline-flex" />}>
{badge}
</HoverCardTrigger>
<HoverCardContent align="end" className="w-56 p-3 text-xs">
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<dt className="text-muted-foreground">Response time</dt>
<dd className="tabular-nums">
{model.health_response_time ? `${Math.round(model.health_response_time)} ms` : "-"}
</dd>
<dt className="text-muted-foreground">Last checked</dt>
<dd>{relativeTime(model.health_checked_at)}</dd>
</dl>
</HoverCardContent>
</HoverCard>
);
}

View file

@ -0,0 +1,49 @@
"use client";
import { X } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { SheetClose } from "@/components/ui/sheet";
import { cn } from "@/lib/cva.config";
import { SECTION_TITLE } from "./hubFormat";
import { CopyIconButton } from "./HubParts";
export const InSheet = React.createContext(false);
export function InspectorHeader({
media,
title,
copyLabel,
status,
description,
}: {
media?: React.ReactNode;
title: string;
copyLabel: string;
status?: React.ReactNode;
description?: React.ReactNode;
}) {
const inSheet = React.useContext(InSheet);
return (
<div className="flex flex-col gap-1.5">
<div className="flex min-w-0 items-center gap-2">
{media}
<h2 className={cn("min-w-0 wrap-break-word", SECTION_TITLE)}>{title}</h2>
<CopyIconButton value={title} label={copyLabel} className="size-7" />
<span className="flex-1" />
{status && <span className="hidden sm:flex">{status}</span>}
{inSheet && (
<SheetClose
render={<Button variant="ghost" size="icon-sm" aria-label="Close" className="text-muted-foreground" />}
>
<X />
</SheetClose>
)}
</div>
{status && <span className="flex sm:hidden">{status}</span>}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
);
}

View file

@ -0,0 +1,29 @@
"use client";
import type { MCPServerData } from "@/components/PublicModelHubTableColumns";
import { mcpAuthLabel } from "./hubFormat";
import { CodeBlock, EndpointField, Facts, PaneSection } from "./HubParts";
import { mcpEndpoint, mcpSnippet } from "./hubSnippets";
import { InspectorHeader } from "./InspectorHeader";
export function McpInspector({ server }: { server: MCPServerData }) {
return (
<div className="flex flex-col gap-6 p-6">
<InspectorHeader title={server.name} copyLabel="Copy server name" description={server.mcp_info?.description} />
<Facts
groups={[
[
{ label: "Transport", value: server.transport },
{ label: "Sign-in", value: mcpAuthLabel(server.auth_type) },
{ label: "Alias", value: server.alias },
],
]}
/>
<EndpointField id={`mcp-endpoint-${server.server_id}`} value={mcpEndpoint(server)} />
<PaneSection title="Connect a client">
<CodeBlock code={mcpSnippet(server)} />
</PaneSection>
</div>
);
}

View file

@ -0,0 +1,164 @@
"use client";
import { ChevronDown } from "lucide-react";
import * as React from "react";
import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
formatPerMillion,
formatTokens,
modeLabel,
modelCapabilities,
providerName,
wildcardExample,
} from "./hubFormat";
import { Badges, CodeBlock, type Fact, Facts, PaneSection, ProviderAvatar, ProviderName } from "./HubParts";
import { curlSnippet, modelSnippet } from "./hubSnippets";
import { HealthBadge } from "./HubStatus";
import { InspectorHeader } from "./InspectorHeader";
const summary = (model: ModelGroupInfo): string | undefined => {
if (model.description) return model.description;
const providers = (model.providers ?? []).map(providerName).join(", ");
return model.mode && providers ? `${modeLabel(model.mode)} model from ${providers}` : undefined;
};
const tokens = (value: number | undefined) => (value ? `${formatTokens(value)} tokens` : null);
const modelFacts = (model: ModelGroupInfo): Fact[][] => {
const providers = model.providers ?? [];
const tags = model.custom_tags ?? [];
return [
[
{ label: "Mode", value: model.mode ? modeLabel(model.mode) : null },
{ label: "Context", value: tokens(model.max_input_tokens) },
{ label: "Max output", value: tokens(model.max_output_tokens) },
],
[
{ label: "Input / 1M tokens", value: formatPerMillion(model.input_cost_per_token) },
{ label: "Output / 1M tokens", value: formatPerMillion(model.output_cost_per_token) },
],
[
{ label: "Requests per minute", value: model.rpm },
{ label: "Tokens per minute", value: model.tpm ? formatTokens(model.tpm) : null },
],
[
{
label: providers.length > 1 ? "Providers" : "Provider",
wide: true,
value: providers.length > 0 && (
<span className="flex flex-wrap gap-x-4 gap-y-1">
{providers.map((provider) => (
<ProviderName key={provider} provider={provider} />
))}
</span>
),
},
{ label: "Tags", wide: true, value: tags.length > 0 && <Badges items={tags} /> },
],
];
};
function CallThisModel({ model }: { model: ModelGroupInfo }) {
const curl = curlSnippet(model);
if (!curl) {
return (
<PaneSection title="Call this model">
<CodeBlock code={modelSnippet(model)} />
</PaneSection>
);
}
return (
<PaneSection title="Call this model">
<Tabs defaultValue="python" className="gap-3">
<TabsList>
<TabsTrigger value="python">Python</TabsTrigger>
<TabsTrigger value="curl">cURL</TabsTrigger>
</TabsList>
<TabsContent value="python">
<CodeBlock code={modelSnippet(model)} />
</TabsContent>
<TabsContent value="curl">
<CodeBlock code={curl} />
</TabsContent>
</Tabs>
</PaneSection>
);
}
function CustomMetadata({ model }: { model: ModelGroupInfo }) {
const metadata = Object.entries(model.custom_metadata ?? {});
if (metadata.length === 0) return null;
return (
<PaneSection title="Custom metadata">
<dl className="grid grid-cols-[160px_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm">
{metadata.map(([key, value]) => (
<React.Fragment key={key}>
<dt className="text-xs font-medium text-muted-foreground">{key}</dt>
<dd>{String(value)}</dd>
</React.Fragment>
))}
</dl>
</PaneSection>
);
}
function SupportedParams({ params }: { params: string[] }) {
if (params.length === 0) return null;
return (
<Collapsible>
<PaneSection
title="Supported OpenAI parameters"
aside={
<CollapsibleTrigger render={<Button variant="ghost" size="sm" />}>
Show {params.length}
<ChevronDown className="text-muted-foreground" />
</CollapsibleTrigger>
}
>
<CollapsibleContent>
<Badges items={params} mono />
</CollapsibleContent>
</PaneSection>
</Collapsible>
);
}
export function ModelInspector({ model }: { model: ModelGroupInfo }) {
const providers = model.providers ?? [];
const capabilities = modelCapabilities(model);
return (
<div className="flex flex-col gap-6 p-6">
<InspectorHeader
media={<ProviderAvatar provider={providers[0] ?? ""} className="size-6" />}
title={model.model_group}
copyLabel="Copy model name"
status={<HealthBadge model={model} />}
description={summary(model)}
/>
{model.model_group.includes("*") && (
<Alert>
<AlertTitle>Wildcard model</AlertTitle>
<AlertDescription>
Call it as <code className="font-mono">{wildcardExample(model.model_group)}</code>, with any model name this
provider offers
</AlertDescription>
</Alert>
)}
<Facts groups={modelFacts(model)} />
{capabilities.length > 0 && (
<PaneSection title="Capabilities">
<Badges items={capabilities} />
</PaneSection>
)}
<CallThisModel model={model} />
<CustomMetadata model={model} />
<SupportedParams params={model.supported_openai_params ?? []} />
</div>
);
}

View file

@ -0,0 +1,238 @@
"use client";
import { ArrowDownWideNarrow, ArrowUpNarrowWide, Boxes, ChevronLeft, ChevronRight } from "lucide-react";
import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import { ItemActions, ItemContent, ItemDescription, ItemMedia } from "@/components/ui/item";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Toggle } from "@/components/ui/toggle";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { displayMode } from "@/lib/decisionModels";
import { FilterChip } from "./FilterChip";
import { featureFacetLabel, firstPlus, formatPerMillion, formatTokens, modeLabel, providerName } from "./hubFormat";
import { HubMasterDetail } from "./HubMasterDetail";
import { LoadFailed, NoResults } from "./HubEmpty";
import { Joined, ProviderAvatar, RowTitle, SearchInput } from "./HubParts";
import { type HubData, useOpenedModel } from "./useHubData";
import { ModelInspector } from "./ModelInspector";
const SORTS = [
{ value: "model_group", label: "Name", desc: false },
{ value: "providers", label: "Provider", desc: false },
{ value: "mode", label: "Mode", desc: false },
{ value: "input_cost_per_token", label: "Input price", desc: false },
{ value: "output_cost_per_token", label: "Output price", desc: false },
{ value: "max_input_tokens", label: "Context", desc: true },
{ value: "max_output_tokens", label: "Max output", desc: true },
{ value: "rpm", label: "Rate limit", desc: true },
];
type Models = HubData["models"];
function ModelsPager({ models }: { models: Models }) {
const { pagination, rowCount, rows, onPaginationChange } = models;
if (!rowCount) return null;
const start = pagination.pageIndex * pagination.pageSize + 1;
const end = start + rows.length - 1;
const go = (pageIndex: number) => onPaginationChange({ ...pagination, pageIndex });
return (
<div className="flex items-center justify-between gap-2 px-6 pb-3 text-xs text-muted-foreground tabular-nums">
<span>
{start}–{end} of {rowCount}
</span>
<ButtonGroup>
<Button
variant="outline"
size="icon-sm"
aria-label="Previous page"
disabled={pagination.pageIndex === 0}
onClick={() => go(pagination.pageIndex - 1)}
>
<ChevronLeft />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label="Next page"
disabled={start + pagination.pageSize > rowCount}
onClick={() => go(pagination.pageIndex + 1)}
>
<ChevronRight />
</Button>
</ButtonGroup>
</div>
);
}
function SortControl({ models }: { models: Models }) {
const field = models.sorting[0]?.id ?? "model_group";
const desc = models.sorting[0]?.desc ?? false;
const pick = (value: string | null) => {
const sort = SORTS.find((option) => option.value === value) ?? SORTS[0];
models.onSortingChange([{ id: sort.value, desc: sort.desc }]);
};
return (
<ButtonGroup>
<Select items={SORTS} value={field} onValueChange={pick}>
<SelectTrigger size="sm" className="w-auto gap-1.5" aria-label="Sort by">
<span className="hidden text-muted-foreground sm:inline">Sort</span>
<SelectValue />
</SelectTrigger>
<SelectContent align="end" className="w-auto min-w-(--anchor-width)">
{SORTS.map((sort) => (
<SelectItem key={sort.value} value={sort.value}>
{sort.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Tooltip>
<TooltipTrigger
render={
<Toggle
variant="outline"
size="sm"
aria-label="Sort descending"
pressed={desc}
onPressedChange={(pressed) => models.onSortingChange([{ id: field, desc: pressed }])}
className="dark:bg-input/30 dark:hover:bg-input/50"
/>
}
>
{desc ? <ArrowDownWideNarrow /> : <ArrowUpNarrowWide />}
</TooltipTrigger>
<TooltipContent>Sort descending</TooltipContent>
</Tooltip>
</ButtonGroup>
);
}
function ModelsToolbar({ hub }: { hub: HubData }) {
const { models, facets } = hub;
return (
<>
<div className="flex items-center gap-2">
<SearchInput value={models.searchValue} onChange={models.onSearchChange} placeholder="Search models" />
<SortControl models={models} />
</div>
<div className="flex flex-wrap items-center gap-2">
<FilterChip
label="Provider"
noun="providers"
options={facets.providers.map((p) => ({ value: p, label: providerName(p), provider: p }))}
value={models.providerValues}
onChange={models.onProvidersChange}
/>
<FilterChip
label="Mode"
noun="modes"
options={[...new Set(facets.modes.map((m) => displayMode(m) ?? m))].map((m) => ({
value: m,
label: modeLabel(m),
}))}
value={models.modeValues}
onChange={models.onModesChange}
/>
<FilterChip
label="Features"
noun="features"
options={facets.features.map((f) => ({ value: f, label: featureFacetLabel(f) }))}
value={models.featureValues}
onChange={models.onFeaturesChange}
/>
</div>
</>
);
}
const price = (cost: number | null | undefined, direction: "input" | "output") => {
if (cost === 0) return `Free ${direction}`;
return cost ? `${formatPerMillion(cost)} / 1M ${direction === "input" ? "in" : "out"}` : null;
};
const prices = (model: ModelGroupInfo) =>
model.input_cost_per_token === 0 && model.output_cost_per_token === 0
? ["Free", null]
: [price(model.input_cost_per_token, "input"), price(model.output_cost_per_token, "output")];
function ModelRow({ model }: { model: ModelGroupInfo }) {
const [input, output] = prices(model);
const priced = Boolean(input || output);
return (
<>
<ItemMedia variant="icon" className="size-5">
<ProviderAvatar provider={model.providers?.[0] ?? ""} className="size-5" />
</ItemMedia>
<ItemContent className="min-w-0 gap-0.5">
<RowTitle>{model.model_group}</RowTitle>
<ItemDescription>
<Joined
parts={[
firstPlus((model.providers ?? []).map(providerName)),
model.mode ? modeLabel(model.mode) : null,
model.max_input_tokens ? `${formatTokens(model.max_input_tokens)} context` : null,
]}
/>
</ItemDescription>
{priced && (
<ItemDescription className="tabular-nums sm:hidden">
<Joined parts={[input, output]} />
</ItemDescription>
)}
</ItemContent>
{priced && (
<ItemActions className="hidden flex-col items-end gap-0.5 text-xs whitespace-nowrap tabular-nums sm:flex">
{input && <span>{input}</span>}
{output && <span className="text-muted-foreground">{output}</span>}
</ItemActions>
)}
</>
);
}
export function ModelsPane({
hub,
selected,
onSelect,
}: {
hub: HubData;
selected: string | null;
onSelect: (key: string | null) => void;
}) {
const { models } = hub;
const opened = useOpenedModel(selected, models.rows, !models.isLoading);
const clear = () => {
models.onSearchChange("");
models.onColumnFiltersChange([]);
};
return (
<HubMasterDetail
rows={models.rows}
loading={models.isLoading}
selectedKey={selected}
opened={opened.model}
openPending={opened.pending}
getKey={(model) => model.model_group}
onSelect={onSelect}
empty={
models.error ? (
<LoadFailed noun="models" onRetry={models.refetch} />
) : (
<NoResults
icon={<Boxes />}
noun="models"
query={models.searchValue}
onClear={models.hasActiveQuery ? clear : undefined}
/>
)
}
toolbar={<ModelsToolbar hub={hub} />}
renderItem={(model) => <ModelRow model={model} />}
renderDetail={(model) => <ModelInspector model={model} />}
footer={<ModelsPager models={models} />}
/>
);
}

View file

@ -0,0 +1,342 @@
import { fireEvent, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
import PublicAiHub from "./PublicAiHub";
const { apiGetMock, realGet, navigation, uiSettings, replaceMock, hubInfo } = vi.hoisted(() => ({
apiGetMock: vi.fn(),
realGet: { current: (..._args: unknown[]): Promise<unknown> => Promise.reject(new Error("unset")) },
navigation: { params: new URLSearchParams() },
uiSettings: { values: {} as Record<string, unknown> },
replaceMock: vi.fn(),
hubInfo: {
docs_title: "",
custom_docs_description: null,
litellm_version: "1.90.0",
useful_links: { "Getting started": { url: "https://docs.example.com", index: 0 } },
},
}));
vi.mock("next/navigation", () => ({
useSearchParams: () => navigation.params,
usePathname: () => "/model_hub_table",
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
}));
vi.mock("@/components/navbar", () => ({ default: () => <nav aria-label="Top bar" /> }));
vi.mock("@/lib/telemetry/uiEvents", () => ({ recordUiEvent: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: () => ({ data: uiSettings, isLoading: false }),
}));
vi.mock("@/components/networking", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/components/networking")>();
realGet.current = actual.apiClient.get;
return {
...actual,
apiClient: { ...actual.apiClient, get: apiGetMock },
getUiConfig: vi.fn().mockResolvedValue({}),
getProxyBaseUrl: vi.fn(() => "https://gateway.example.com"),
getPublicModelHubInfo: vi.fn().mockResolvedValue(hubInfo),
};
});
const MODEL_HUB_PATH = "/public/v1/model_hub";
const MODELS = [
{
model_group: "claude-sonnet-4-5",
providers: ["anthropic"],
mode: "chat",
input_cost_per_token: 0.000003,
description: "Best for agentic coding",
},
{
model_group: "gpt-5",
providers: ["openai"],
mode: "chat",
input_cost_per_token: 0,
output_cost_per_token: 0,
description: "General purpose",
},
];
const AGENTS = [
{
name: "research-assistant",
description: "Answers research questions",
version: "1.4.0",
url: "https://gateway.example.com/a2a/research",
protocolVersion: "0.3.0",
defaultInputModes: ["text"],
defaultOutputModes: ["text"],
skills: [],
},
];
const HUBS: Record<string, unknown> = {
"/public/agent_hub": AGENTS,
"/public/mcp_hub": [],
"/public/skill_hub": { plugins: [] },
};
const page = (data: unknown[]) => ({
data,
meta: { total_count: data.length, page: 1, page_size: 50, total_pages: 1 },
links: { self: MODEL_HUB_PATH, first: MODEL_HUB_PATH, prev: null, next: null, last: MODEL_HUB_PATH },
});
const OFF_PAGE_MODEL = { model_group: "o1-pro", providers: ["openai"], mode: "chat", description: "Deep reasoning" };
type Query = { query?: Record<string, unknown> };
const respond = (path: string, options?: Query): Promise<unknown> => {
if (path in HUBS) return Promise.resolve(HUBS[path]);
if (path !== MODEL_HUB_PATH) return Promise.resolve({ ...page([]), meta: { page: 1, page_size: 100 } });
const q = String(options?.query?.q ?? "");
if (q === "o1-pro") return Promise.resolve(page([OFF_PAGE_MODEL]));
return Promise.resolve(page(MODELS.filter((model) => model.model_group.includes(q))));
};
const respondWithModels = () => apiGetMock.mockImplementation(respond);
const failModelsWith = (next: (options?: Query) => Promise<unknown>) =>
apiGetMock.mockImplementation((path: string, options?: Query) =>
path === MODEL_HUB_PATH ? next(options) : respond(path, options),
);
const narrowScreen = (narrow: boolean) =>
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: narrow,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
}),
});
const tokenExpiringIn = (seconds: number) => {
const encode = (value: object) => btoa(JSON.stringify(value)).replace(/=+$/, "");
return `${encode({ alg: "HS256" })}.${encode({ exp: Math.floor(Date.now() / 1000) + seconds })}.signature`;
};
beforeEach(() => {
narrowScreen(false);
vi.clearAllMocks();
testQueryClient.clear();
uiSettings.values = {};
navigation.params = new URLSearchParams();
document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
respondWithModels();
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}", { status: 404 }));
Object.defineProperty(window, "location", {
writable: true,
value: { ...window.location, replace: replaceMock },
});
});
describe("PublicAiHub", () => {
it("counts every hub and shows the first model's details", async () => {
renderWithProviders(<PublicAiHub accessToken={null} />);
expect(await screen.findByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /^Models\s*2$/ })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /^Agents\s*1$/ })).toBeInTheDocument();
expect(screen.getByText("Best for agentic coding")).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Base URL" })).toHaveValue("https://gateway.example.com");
});
it("opens a model from the list and records it in the URL", async () => {
const onUrlUpdate = vi.fn();
const user = userEvent.setup();
renderWithProviders(<PublicAiHub accessToken={null} />, { onUrlUpdate });
await user.click(await screen.findByRole("link", { name: /gpt-5/ }));
expect(await screen.findByRole("heading", { level: 2, name: "gpt-5" })).toBeInTheDocument();
await waitFor(() =>
expect(onUrlUpdate).toHaveBeenCalledWith(expect.objectContaining({ queryString: "?open=gpt-5" })),
);
});
it("explains an empty search and clears it", async () => {
const user = userEvent.setup();
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { tab: "agents" } });
fireEvent.change(await screen.findByRole("textbox", { name: "Search agents" }), { target: { value: "billing" } });
expect(await screen.findByText('No agents match "billing"')).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Clear filters" }));
expect(await screen.findByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
});
it("lists the admin's links and the gateway version", async () => {
const user = userEvent.setup();
renderWithProviders(<PublicAiHub accessToken={null} />);
await user.click(await screen.findByRole("button", { name: "Links" }));
expect(await screen.findByRole("menuitem", { name: "Getting started" })).toHaveAttribute(
"href",
"https://docs.example.com",
);
expect(screen.getByText("LiteLLM v1.90.0")).toBeInTheDocument();
});
it("keeps agents reachable when only the models call fails", async () => {
const user = userEvent.setup();
failModelsWith(() => Promise.reject(new Error("No models configured")));
renderWithProviders(<PublicAiHub accessToken={null} />);
expect(await screen.findByText("Couldn't load models")).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Search models" })).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: /^Agents\s*1$/ }));
expect(await screen.findByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
});
it("says the hub is unavailable when every hub call fails", async () => {
apiGetMock.mockRejectedValue(new Error("Not found"));
renderWithProviders(<PublicAiHub accessToken={null} />);
expect(await screen.findByText("This AI Hub isn't available")).toBeInTheDocument();
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
});
it("shows a load error when a hub answers with an HTTP error status", async () => {
const user = userEvent.setup();
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ detail: "Database error" }), { status: 500 }));
apiGetMock.mockImplementation((path: string, options?: Query) =>
path === "/public/agent_hub" ? realGet.current(path, options) : respond(path, options),
);
renderWithProviders(<PublicAiHub accessToken={null} />);
await user.click(await screen.findByRole("tab", { name: /^Agents/ }));
expect(await screen.findByText("Couldn't load agents")).toBeInTheDocument();
expect(screen.queryByText("No agents published yet")).not.toBeInTheDocument();
});
it("shows loading rather than an empty hub while Try again reloads models", async () => {
const user = userEvent.setup();
let finishRetry: (value: unknown) => void = () => {};
failModelsWith(() => Promise.reject(new Error("Gateway timeout")));
renderWithProviders(<PublicAiHub accessToken={null} />);
await screen.findByText("Couldn't load models");
failModelsWith(() => new Promise((resolve) => (finishRetry = resolve)));
await user.click(screen.getByRole("button", { name: "Try again" }));
expect(screen.queryByText("No models published yet")).not.toBeInTheDocument();
expect(screen.queryByText("Couldn't load models")).not.toBeInTheDocument();
finishRetry(page(MODELS));
expect(await screen.findByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).toBeInTheDocument();
});
it("keeps the open model and its snippet tab while a search hides its row", async () => {
const user = userEvent.setup();
failModelsWith((options) =>
options?.query?.q === "gpt-5" ? new Promise(() => {}) : respond(MODEL_HUB_PATH, options),
);
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "gpt-5" } });
await screen.findByRole("heading", { level: 2, name: "gpt-5" });
await user.click(screen.getByRole("tab", { name: "cURL" }));
fireEvent.change(screen.getByRole("textbox", { name: "Search models" }), { target: { value: "claude" } });
await waitFor(() => expect(screen.queryByRole("link", { name: /gpt-5/ })).not.toBeInTheDocument());
expect(screen.getByRole("heading", { level: 2, name: "gpt-5" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "cURL" })).toHaveAttribute("aria-selected", "true");
});
it("keeps the open item's details when a search matches nothing", async () => {
renderWithProviders(<PublicAiHub accessToken={null} />, {
searchParams: { tab: "agents", open: "research-assistant" },
});
fireEvent.change(await screen.findByRole("textbox", { name: "Search agents" }), { target: { value: "billing" } });
expect(await screen.findByText('No agents match "billing"')).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 2, name: "research-assistant" })).toBeInTheDocument();
});
it("marks a free model as free in its row", async () => {
renderWithProviders(<PublicAiHub accessToken={null} />);
const row = await screen.findByRole("link", { name: /gpt-5/ });
expect(within(row).getAllByText("Free").length).toBeGreaterThan(0);
});
it("treats an empty open param as nothing opened", async () => {
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "" } });
expect(await screen.findByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).toBeInTheDocument();
});
it("tells phone visitors when a shared link names something no longer listed", async () => {
narrowScreen(true);
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "retired-model" } });
const sheet = await screen.findByRole("dialog");
expect(within(sheet).getByText("\u201cretired-model\u201d isn't listed here")).toBeInTheDocument();
});
it("keeps the page's other query params in each row's link", async () => {
navigation.params = new URLSearchParams("tab=agents&key=abc");
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { tab: "agents", key: "abc" } });
expect(await screen.findByRole("link", { name: /research-assistant/ })).toHaveAttribute(
"href",
"?tab=agents&key=abc&open=research-assistant",
);
});
it("clears the open item when switching tabs", async () => {
const onUrlUpdate = vi.fn();
const user = userEvent.setup();
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "gpt-5" }, onUrlUpdate });
expect(await screen.findByRole("heading", { level: 2, name: "gpt-5" })).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: /^Agents/ }));
await waitFor(() =>
expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString: "?tab=agents" })),
);
});
it("opens a shared link to a model that is not on the first page", async () => {
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "o1-pro" } });
expect(await screen.findByRole("heading", { level: 2, name: "o1-pro" })).toBeInTheDocument();
expect(screen.getByText("Deep reasoning")).toBeInTheDocument();
});
it("says when a shared link names something that is no longer listed", async () => {
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "retired-model" } });
expect(await screen.findByText("\u201cretired-model\u201d isn't listed here")).toBeInTheDocument();
expect(screen.queryByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).not.toBeInTheDocument();
});
it("sends visitors without a session to login when the admin requires it", async () => {
uiSettings.values = { require_auth_for_public_ai_hub: true };
renderWithProviders(<PublicAiHub accessToken={null} />);
await waitFor(() => expect(replaceMock).toHaveBeenCalledWith(expect.stringContaining("login")));
});
it("keeps a signed-in visitor on the hub when the admin requires sign-in", async () => {
uiSettings.values = { require_auth_for_public_ai_hub: true };
document.cookie = `token=${tokenExpiringIn(3600)}; path=/`;
renderWithProviders(<PublicAiHub accessToken={null} />);
expect(await screen.findByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).toBeInTheDocument();
expect(replaceMock).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,113 @@
"use client";
import type { ReactNode } from "react";
import Navbar from "@/components/navbar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { HubHeader, type HubStatus } from "./HubHeader";
import { HubNotEnabled } from "./HubEmpty";
import { ModelsPane } from "./ModelsPane";
import { AgentsPane, McpPane, SkillsPane } from "./ResourcePanes";
import { type HubData, useHubData } from "./useHubData";
import { type HubTab, useHubUrlState } from "./useHubUrlState";
import { useRequirePublicHubAuth } from "./useRequirePublicHubAuth";
function TabLabel({ children, count }: { children: ReactNode; count: number | undefined }) {
return (
<>
{children}
<span className="text-xs text-muted-foreground tabular-nums">{count ?? "-"}</span>
</>
);
}
const hubStatus = (hub: HubData): HubStatus => {
const failures = [hub.models.error, hub.agents.error, hub.mcp.error, hub.skills.error].filter(Boolean);
if (failures.length === 4) return "unavailable";
if (hub.models.isLoading) return "loading";
return failures.length > 0 ? "degraded" : "operational";
};
function HubTabs({ hub }: { hub: HubData }) {
const { tab, setTab, open, setOpen } = useHubUrlState();
const paneProps = { selected: open, onSelect: setOpen };
return (
<Tabs value={tab} onValueChange={(next) => setTab(next as HubTab)} className="gap-4">
<TabsList className="w-full sm:w-fit">
<TabsTrigger value="models" className="px-1.5 sm:px-2">
<TabLabel count={hub.modelTotal}>Models</TabLabel>
</TabsTrigger>
<TabsTrigger value="agents" className="px-1.5 sm:px-2">
<TabLabel count={hub.agents.data?.length}>Agents</TabLabel>
</TabsTrigger>
<TabsTrigger value="mcp" className="px-1.5 sm:px-2">
<TabLabel count={hub.mcp.data?.length}>
<span className="sm:hidden">MCP</span>
<span className="hidden sm:inline">MCP servers</span>
</TabLabel>
</TabsTrigger>
<TabsTrigger value="skills" className="px-1.5 sm:px-2">
<TabLabel count={hub.skills.data?.length}>Skills</TabLabel>
</TabsTrigger>
</TabsList>
<TabsContent value="models">
<ModelsPane hub={hub} {...paneProps} />
</TabsContent>
<TabsContent value="agents">
<AgentsPane
rows={hub.agents.data}
loading={hub.agents.isPending}
error={hub.agents.isError}
onRetry={() => void hub.agents.refetch()}
{...paneProps}
/>
</TabsContent>
<TabsContent value="mcp">
<McpPane
rows={hub.mcp.data}
loading={hub.mcp.isPending}
error={hub.mcp.isError}
onRetry={() => void hub.mcp.refetch()}
{...paneProps}
/>
</TabsContent>
<TabsContent value="skills">
<SkillsPane
rows={hub.skills.data}
loading={hub.skills.isPending}
error={hub.skills.isError}
onRetry={() => void hub.skills.refetch()}
{...paneProps}
/>
</TabsContent>
</Tabs>
);
}
function PublicAiHubContent() {
useRequirePublicHubAuth();
const hub = useHubData();
const status = hubStatus(hub);
return (
<div className="flex w-full flex-col gap-6 p-4 sm:p-6">
<HubHeader info={hub.info.data} status={status} />
{status === "unavailable" ? <HubNotEnabled /> : <HubTabs hub={hub} />}
</div>
);
}
export default function PublicAiHub({ accessToken }: { accessToken: string | null }) {
return (
<ThemeProvider accessToken={accessToken}>
<TooltipProvider>
<div className="min-h-screen bg-background">
<Navbar accessToken={accessToken} isPublicPage />
<PublicAiHubContent />
</div>
</TooltipProvider>
</ThemeProvider>
);
}

View file

@ -0,0 +1,184 @@
"use client";
import { Bot, Puzzle, Server } from "lucide-react";
import * as React from "react";
import type { Plugin } from "@/components/claude_code_plugins/types";
import type { AgentCard, MCPServerData } from "@/components/PublicModelHubTableColumns";
import { ItemActions, ItemContent, ItemDescription, ItemMedia } from "@/components/ui/item";
import { matchesSearchTerm } from "@/utils/searchUtils";
import { AgentInspector } from "./AgentInspector";
import { FilterChip } from "./FilterChip";
import { mcpAuthLabel } from "./hubFormat";
import { HubMasterDetail } from "./HubMasterDetail";
import { LoadFailed, NoResults } from "./HubEmpty";
import { RowTitle, SearchInput } from "./HubParts";
import { McpInspector } from "./McpInspector";
import { SkillInspector } from "./SkillInspector";
interface ResourcePaneProps<T> {
rows: T[];
loading: boolean;
error: boolean;
onRetry: () => void;
noun: string;
icon: React.ReactNode;
search: (row: T) => (string | null | undefined)[];
filter: { label: string; noun: string; values: (row: T) => string[] };
selected: string | null;
onSelect: (key: string | null) => void;
getKey: (row: T) => string;
renderDetail: (row: T) => React.ReactNode;
title: (row: T) => string;
description: (row: T) => string | null | undefined;
meta: (row: T) => string | null | undefined;
}
function ResourcePane<T>({
rows,
loading,
error,
onRetry,
noun,
icon,
search,
filter,
selected,
onSelect,
getKey,
renderDetail,
title,
description,
meta,
}: ResourcePaneProps<T>) {
const [query, setQuery] = React.useState("");
const [chosen, setChosen] = React.useState<string[]>([]);
const options = [...new Set(rows.flatMap(filter.values))].sort();
const sorted = [...rows].sort((a, b) => title(a).localeCompare(title(b)));
const shown = sorted.filter(
(row) =>
matchesSearchTerm(query, search(row)) &&
(chosen.length === 0 || filter.values(row).some((v) => chosen.includes(v))),
);
const filtered = query.trim() !== "" || chosen.length > 0;
const clear = () => {
setQuery("");
setChosen([]);
};
return (
<HubMasterDetail
rows={shown}
loading={loading}
selectedKey={selected}
opened={rows.find((row) => getKey(row) === selected)}
getKey={getKey}
onSelect={onSelect}
empty={
error ? (
<LoadFailed noun={noun} onRetry={onRetry} />
) : (
<NoResults icon={icon} noun={noun} query={query} onClear={filtered ? clear : undefined} />
)
}
toolbar={
<div className="flex items-center gap-2">
<SearchInput value={query} onChange={setQuery} placeholder={`Search ${noun}`} />
{options.length > 0 && (
<FilterChip
label={filter.label}
noun={filter.noun}
options={options.map((option) => ({ value: option, label: option }))}
value={chosen}
onChange={setChosen}
/>
)}
</div>
}
renderItem={(row) => (
<>
<ItemMedia variant="icon" className="size-5 text-muted-foreground">
{icon}
</ItemMedia>
<ItemContent className="min-w-0 gap-0.5">
<RowTitle>{title(row)}</RowTitle>
{description(row) && <ItemDescription>{description(row)}</ItemDescription>}
</ItemContent>
{meta(row) && <ItemActions className="text-xs text-muted-foreground">{meta(row)}</ItemActions>}
</>
)}
renderDetail={renderDetail}
/>
);
}
interface PaneProps<T> {
rows: T[] | undefined;
loading: boolean;
error: boolean;
onRetry: () => void;
selected: string | null;
onSelect: (key: string | null) => void;
}
export function AgentsPane({ rows, ...props }: PaneProps<AgentCard>) {
return (
<ResourcePane<AgentCard>
{...props}
rows={rows ?? []}
noun="agents"
icon={<Bot />}
search={(agent) => [agent.name, agent.description]}
filter={{
label: "Skill tag",
noun: "skill tags",
values: (agent) => (agent.skills ?? []).flatMap((s) => s.tags ?? []),
}}
getKey={(agent) => agent.name}
title={(agent) => agent.name}
description={(agent) => agent.description}
meta={(agent) => (agent.version ? `v${agent.version}` : undefined)}
renderDetail={(agent) => <AgentInspector agent={agent} />}
/>
);
}
export function McpPane({ rows, ...props }: PaneProps<MCPServerData>) {
return (
<ResourcePane<MCPServerData>
{...props}
rows={rows ?? []}
noun="MCP servers"
icon={<Server />}
search={(server) => [server.name, server.mcp_info?.description]}
filter={{
label: "Transport",
noun: "transports",
values: (server) => (server.transport ? [server.transport] : []),
}}
getKey={(server) => server.name}
title={(server) => server.name}
description={(server) => server.mcp_info?.description}
meta={(server) => (server.auth_type ? mcpAuthLabel(server.auth_type) : undefined)}
renderDetail={(server) => <McpInspector server={server} />}
/>
);
}
export function SkillsPane({ rows, ...props }: PaneProps<Plugin>) {
return (
<ResourcePane<Plugin>
{...props}
rows={rows ?? []}
noun="skills"
icon={<Puzzle />}
search={(skill) => [skill.name, skill.description, skill.namespace, skill.domain, ...(skill.keywords ?? [])]}
filter={{ label: "Domain", noun: "domains", values: (skill) => (skill.domain ? [skill.domain] : []) }}
getKey={(skill) => skill.name}
title={(skill) => skill.name}
description={(skill) => skill.description}
meta={(skill) => skill.domain}
renderDetail={(skill) => <SkillInspector skill={skill} />}
/>
);
}

View file

@ -0,0 +1,66 @@
"use client";
import { ExternalLink } from "lucide-react";
import { getSourceDisplayText, getSourceLink } from "@/components/claude_code_plugins/helpers";
import type { Plugin } from "@/components/claude_code_plugins/types";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription } from "@/components/ui/field";
import { Badges, CodeBlock, Facts, PaneSection } from "./HubParts";
import { marketplaceCommand, skillInstallCommand } from "./hubSnippets";
import { InspectorHeader } from "./InspectorHeader";
function sourceValue(source: Plugin["source"]) {
const link = getSourceLink(source);
const href = link && source.source === "git-subdir" && source.path ? `${link}/tree/main/${source.path}` : link;
if (!href) return getSourceDisplayText(source) || null;
return (
<Button
variant="link"
nativeButton={false}
className="h-auto max-w-full justify-start px-0"
render={<a href={href} target="_blank" rel="noopener noreferrer" />}
>
<span className="truncate">{href.replace(/^https:\/\//, "")}</span>
<ExternalLink />
</Button>
);
}
export function SkillInspector({ skill }: { skill: Plugin }) {
const keywords = skill.keywords ?? [];
const addedAt = skill.created_at ? new Date(skill.created_at) : null;
const added = addedAt && !Number.isNaN(addedAt.getTime()) ? addedAt.toLocaleDateString() : null;
return (
<div className="flex flex-col gap-6 p-6">
<InspectorHeader title={skill.name} copyLabel="Copy skill name" description={skill.description} />
<Facts
groups={[
[
{ label: "Domain", value: skill.domain },
{ label: "Namespace", value: skill.namespace },
{ label: "Version", value: skill.version },
{ label: "Category", value: skill.category },
],
[
{ label: "Source", wide: true, value: sourceValue(skill.source) },
{ label: "Author", value: skill.author?.name },
{ label: "Added", value: added },
],
[{ label: "Keywords", wide: true, value: keywords.length > 0 && <Badges items={keywords} /> }],
]}
/>
<PaneSection title="Install in Claude Code">
<Field>
<FieldDescription>First time only: add this marketplace</FieldDescription>
<CodeBlock code={marketplaceCommand()} />
</Field>
<Field>
<FieldDescription>Then install the skill</FieldDescription>
<CodeBlock code={skillInstallCommand(skill)} />
</Field>
</PaneSection>
</div>
);
}

View file

@ -0,0 +1,138 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentCard, ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
import {
agentCapabilities,
capabilityLabel,
featureFacetLabel,
firstPlus,
formatPerMillion,
formatTokens,
healthOf,
mcpAuthLabel,
modeLabel,
modelCapabilities,
relativeTime,
usefulLinkList,
wildcardExample,
} from "./hubFormat";
vi.mock("@/components/networking", () => ({ getProxyBaseUrl: () => "https://gateway.example.com" }));
const model = (overrides: Partial<ModelGroupInfo>): ModelGroupInfo => ({
model_group: "gpt-5",
providers: ["openai"],
supports_parallel_function_calling: false,
supports_vision: false,
supports_function_calling: false,
...overrides,
});
describe("modeLabel", () => {
it("names known modes in sentence case and falls back for unknown ones", () => {
expect(modeLabel("image_generation")).toBe("Image generation");
expect(modeLabel("audio_speech")).toBe("Text to speech");
expect(modeLabel("custom_thing")).toBe("Custom thing");
expect(modeLabel("evaluation")).toBe("Decisions");
expect(modeLabel(undefined)).toBe("-");
});
});
describe("formatTokens", () => {
it("abbreviates thousands and millions", () => {
expect(formatTokens(512)).toBe("512");
expect(formatTokens(200_000)).toBe("200K");
expect(formatTokens(1_000_000)).toBe("1M");
expect(formatTokens(1_048_576)).toBe("1M");
expect(formatTokens(2_500_000)).toBe("2.5M");
expect(formatTokens(undefined)).toBe("-");
});
});
describe("formatPerMillion", () => {
it("prices per million tokens with at least two decimals and keeps sub-cent prices", () => {
expect(formatPerMillion(0.000003)).toBe("$3.00");
expect(formatPerMillion(0.0000165)).toBe("$16.50");
expect(formatPerMillion(3.75e-8)).toBe("$0.0375");
expect(formatPerMillion(2e-9)).toBe("$0.002");
expect(formatPerMillion(0)).toBe("Free");
expect(formatPerMillion(undefined)).toBe("-");
});
});
describe("capabilities", () => {
it("labels known flags and derives a label for new ones", () => {
expect(capabilityLabel("supports_function_calling")).toBe("Function calling");
expect(capabilityLabel("supports_pdf_input")).toBe("Pdf input");
expect(featureFacetLabel("web_search")).toBe("Web search");
});
it("lists only enabled flags, known ones first in a fixed order", () => {
const flags = model({ supports_pdf_input: true, supports_web_search: true, supports_vision: true });
expect(modelCapabilities(flags)).toEqual(["Vision", "Web search", "Pdf input"]);
});
it("lists an agent's enabled capabilities", () => {
const agent = {
capabilities: { streaming: true, pushNotifications: true, stateTransitionHistory: true, x: false },
};
expect(agentCapabilities(agent as AgentCard)).toEqual([
"Streaming",
"Push notifications",
"State transition history",
]);
});
});
describe("healthOf", () => {
it("maps the health check to a tone and label", () => {
expect(healthOf(model({ health_status: "healthy" }))).toEqual({ tone: "success", label: "Healthy" });
expect(healthOf(model({ health_status: "unhealthy" }))).toEqual({ tone: "error", label: "Unhealthy" });
expect(healthOf(model({}))).toEqual({ tone: "neutral", label: "Not checked" });
});
});
describe("relativeTime", () => {
const now = Date.parse("2026-10-31T12:00:00Z");
it("rounds to minutes, hours or days", () => {
expect(relativeTime("2026-10-31T11:59:45Z", now)).toBe("just now");
expect(relativeTime("2026-10-31T11:45:00Z", now)).toBe("15 min ago");
expect(relativeTime("2026-10-31T09:00:00Z", now)).toBe("3 h ago");
expect(relativeTime("2026-10-30T12:00:00Z", now)).toBe("1 day ago");
expect(relativeTime("2026-10-29T12:00:00Z", now)).toBe("2 days ago");
expect(relativeTime(undefined, now)).toBe("-");
expect(relativeTime("not-a-date", now)).toBe("-");
});
});
describe("small formatters", () => {
it("shows a wildcard model as a placeholder name", () => {
expect(wildcardExample("anthropic/*")).toBe("anthropic/<model-name>");
});
it("collapses a list to its first item and a count", () => {
expect(firstPlus(["Anthropic"])).toBe("Anthropic");
expect(firstPlus(["Anthropic", "Amazon Bedrock", "Vertex AI"])).toBe("Anthropic +2");
expect(firstPlus([])).toBeUndefined();
});
it("names MCP sign-in types", () => {
expect(mcpAuthLabel("oauth2")).toBe("OAuth");
expect(mcpAuthLabel("custom_header")).toBe("custom_header");
expect(mcpAuthLabel(null)).toBe("-");
});
});
describe("usefulLinkList", () => {
it("orders the admin's links by index and accepts the legacy string form", () => {
const links = {
"Status page": { url: "https://status.example.com", index: 2 },
"Getting started": { url: "https://docs.example.com", index: 0 },
Legacy: "https://legacy.example.com",
};
expect(usefulLinkList(links).map((link) => link.title)).toEqual(["Getting started", "Legacy", "Status page"]);
expect(usefulLinkList(undefined)).toEqual([]);
});
});

View file

@ -0,0 +1,142 @@
import { getProxyBaseUrl } from "@/components/networking";
import type { AgentCard, ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
import { displayMode } from "@/lib/decisionModels";
export const SECTION_TITLE = "text-[15px] leading-[22px] font-semibold";
const MODE_LABELS: Record<string, string> = {
chat: "Chat",
completion: "Completion",
embedding: "Embedding",
rerank: "Rerank",
image_generation: "Image generation",
image_edit: "Image edit",
audio_speech: "Text to speech",
audio_transcription: "Transcription",
video_generation: "Video generation",
realtime: "Realtime",
responses: "Responses",
batch: "Batch",
ocr: "OCR",
decisions: "Decisions",
};
const sentenceCase = (text: string): string => text.replaceAll("_", " ").replace(/^./, (c) => c.toUpperCase());
export const modeLabel = (mode: string | null | undefined): string => {
const shown = displayMode(mode);
return shown ? MODE_LABELS[shown] ?? sentenceCase(shown) : "-";
};
export const formatTokens = (tokens: number | null | undefined): string => {
if (!tokens) return "-";
if (tokens >= 1_000_000) return `${+(tokens / 1_000_000).toFixed(1)}M`;
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K`;
return String(tokens);
};
const PER_MILLION_FORMAT: Intl.NumberFormatOptions = {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 4,
useGrouping: false,
};
const PER_MILLION = new Intl.NumberFormat("en-US", PER_MILLION_FORMAT);
export const formatPerMillion = (cost: number | null | undefined): string => {
if (cost === 0) return "Free";
return cost ? PER_MILLION.format(cost * 1_000_000) : "-";
};
export const providerName = (provider: string): string =>
getProviderLogoAndName(provider).displayName.replace(/\s*\(.*\)$/, "");
export const providerLogo = (provider: string): string => getProviderLogoAndName(provider).logo;
const CAPABILITY_LABELS: Record<string, string> = {
supports_vision: "Vision",
supports_function_calling: "Function calling",
supports_reasoning: "Reasoning",
supports_web_search: "Web search",
supports_parallel_function_calling: "Parallel function calling",
supports_url_context: "URL context",
supports_fast_mode: "Fast mode",
};
const CAPABILITY_ORDER = Object.keys(CAPABILITY_LABELS);
const capabilityRank = (key: string): number => {
const index = CAPABILITY_ORDER.indexOf(key);
return index === -1 ? CAPABILITY_ORDER.length : index;
};
export const capabilityLabel = (key: string): string =>
CAPABILITY_LABELS[key] ?? sentenceCase(key.replace(/^supports_/, ""));
export const featureFacetLabel = (feature: string): string => capabilityLabel(`supports_${feature}`);
export const modelCapabilities = (model: ModelGroupInfo): string[] =>
Object.entries(model)
.filter(([key, value]) => key.startsWith("supports_") && value === true)
.map(([key]) => key)
.sort((a, b) => capabilityRank(a) - capabilityRank(b))
.map(capabilityLabel);
export const agentCapabilities = (agent: AgentCard): string[] =>
Object.entries(agent.capabilities ?? {})
.filter(([, on]) => on === true)
.map(([key]) => sentenceCase(key.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()));
export type HealthTone = "success" | "error" | "neutral";
const HEALTH: Record<string, { tone: HealthTone; label: string }> = {
healthy: { tone: "success", label: "Healthy" },
unhealthy: { tone: "error", label: "Unhealthy" },
};
export const healthOf = (model: ModelGroupInfo): { tone: HealthTone; label: string } =>
HEALTH[model.health_status ?? ""] ?? { tone: "neutral", label: "Not checked" };
export const relativeTime = (iso: string | null | undefined, now: number = Date.now()): string => {
const then = iso ? new Date(iso).getTime() : NaN;
if (Number.isNaN(then)) return "-";
const minutes = Math.max(0, Math.round((now - then) / 60_000));
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours} h ago`;
const days = Math.round(hours / 24);
return `${days} ${days === 1 ? "day" : "days"} ago`;
};
export const gatewayUrl = (): string => getProxyBaseUrl();
export const wildcardExample = (modelGroup: string): string => modelGroup.replaceAll("*", "<model-name>");
export const firstPlus = (items: string[]): string | undefined =>
items.length > 1 ? `${items[0]} +${items.length - 1}` : items[0];
const MCP_AUTH: Record<string, string> = {
none: "None",
oauth2: "OAuth",
bearer_token: "Bearer token",
api_key: "API key",
};
export const mcpAuthLabel = (authType: string | null | undefined): string =>
authType ? MCP_AUTH[authType] ?? authType : "-";
export const usefulLinkList = (
links: Record<string, string | { url: string; index?: number }> | undefined,
): { title: string; url: string }[] =>
Object.entries(links ?? {})
.map(([title, value]) => ({
title,
url: typeof value === "string" ? value : value.url,
index: typeof value === "string" ? 0 : value.index ?? 0,
}))
.sort((a, b) => a.index - b.index)
.map(({ title, url }) => ({ title, url }));

View file

@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ModelGroupInfo, MCPServerData } from "@/components/PublicModelHubTableColumns";
import { curlSnippet, marketplaceCommand, mcpEndpoint, mcpSnippet, modelSnippet } from "./hubSnippets";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: () => "https://gateway.example.com",
getGlobalLitellmHeaderName: () => "Authorization",
}));
const model = (overrides: Partial<ModelGroupInfo>): ModelGroupInfo => ({
model_group: "anthropic/*",
providers: ["anthropic"],
supports_parallel_function_calling: false,
supports_vision: false,
supports_function_calling: false,
...overrides,
});
const server = { server_id: "s1", name: "github", server_name: "github" } as MCPServerData;
afterEach(() => {
vi.unstubAllGlobals();
});
describe("model snippets", () => {
it("points the SDK snippet at the gateway and a concrete model name", () => {
vi.stubGlobal("window", { location: { origin: "http://ui.example.com" } });
const snippet = modelSnippet(model({ mode: "chat" }));
expect(snippet).toContain('base_url="https://gateway.example.com"');
expect(snippet).toContain("anthropic/<model-name>");
expect(snippet).not.toContain("ui.example.com");
});
it("offers cURL only for chat models", () => {
expect(curlSnippet(model({ mode: "chat" }))).toContain("curl https://gateway.example.com/v1/chat/completions");
expect(curlSnippet(model({ mode: undefined }))).not.toBeNull();
expect(curlSnippet(model({ mode: "embedding" }))).toBeNull();
});
});
describe("MCP and skill snippets", () => {
it("builds the MCP endpoint from the gateway and the server name", () => {
expect(mcpEndpoint(server)).toBe("https://gateway.example.com/github/mcp");
expect(mcpSnippet(server)).toContain('"url": "https://gateway.example.com/github/mcp"');
});
it("routes a server without a server name through its listed name", () => {
const aliased = { server_id: "s2", name: "my_alias", server_name: null } as unknown as MCPServerData;
expect(mcpEndpoint(aliased)).toBe("https://gateway.example.com/my_alias/mcp");
});
it("adds the gateway's marketplace", () => {
expect(marketplaceCommand()).toBe(
"/plugin marketplace add https://gateway.example.com/claude-code/marketplace.json",
);
});
});

View file

@ -0,0 +1,93 @@
import { modelUsageExample } from "@/components/AIHub/modelUsageExample";
import type { Plugin } from "@/components/claude_code_plugins/types";
import { formatInstallCommand } from "@/components/claude_code_plugins/helpers";
import { generateCodeSnippet } from "@/components/chat_ui/CodeSnippets";
import { getEndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
import { getGlobalLitellmHeaderName } from "@/components/networking";
import type { AgentCard, MCPServerData, ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
import { isDecisionMode } from "@/lib/decisionModels";
import { gatewayUrl, wildcardExample } from "./hubFormat";
const GREETING = "Hello, how are you?";
export const modelSnippet = (model: ModelGroupInfo): string => {
const name = wildcardExample(model.model_group);
if (isDecisionMode(model.mode)) {
return modelUsageExample(model.mode, gatewayUrl(), name, getGlobalLitellmHeaderName());
}
const params: Parameters<typeof generateCodeSnippet>[0] = {
apiKeySource: "custom",
accessToken: null,
apiKey: "your_api_key",
inputMessage: GREETING,
chatHistory: [{ role: "user", content: GREETING, isImage: false }],
selectedTags: [],
selectedVectorStores: [],
selectedGuardrails: [],
selectedPolicies: [],
selectedMCPServers: [],
endpointType: getEndpointType(model.mode || "chat"),
selectedModel: name,
selectedSdk: "openai",
proxySettings: { PROXY_BASE_URL: gatewayUrl() },
};
return generateCodeSnippet(params);
};
export const curlSnippet = (model: ModelGroupInfo): string | null => {
if (model.mode && model.mode !== "chat") return null;
return `curl ${gatewayUrl()}/v1/chat/completions \\
-H "Authorization: Bearer $LITELLM_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"model": "${wildcardExample(model.model_group)}",
"messages": [{"role": "user", "content": "Hello"}]
}'`;
};
export const agentSnippets = (agent: AgentCard): { title: string; code: string }[] => [
{
title: "Retrieve the agent card",
code: `from a2a.client import A2ACardResolver
resolver = A2ACardResolver(httpx_client=httpx_client, base_url="${agent.url}")
card = await resolver.get_agent_card()`,
},
{
title: "Call the agent",
code: `from a2a.client import A2AClient
from a2a.types import MessageSendParams, SendMessageRequest
client = A2AClient(httpx_client=httpx_client, agent_card=card)
request = SendMessageRequest(
id=str(uuid4()),
params=MessageSendParams(message={
"role": "user",
"parts": [{"kind": "text", "text": "how much is 10 USD in INR?"}],
"messageId": uuid4().hex,
}),
)
response = await client.send_message(request)`,
},
];
export const mcpEndpoint = (server: MCPServerData): string => `${gatewayUrl()}/${server.name}/mcp`;
export const mcpSnippet = (server: MCPServerData): string => `from fastmcp import Client
client = Client({
"mcpServers": {
"${server.name}": {
"url": "${mcpEndpoint(server)}",
"headers": {"x-litellm-api-key": "Bearer your_api_key"},
}
}
})
async with client:
tools = await client.list_tools()`;
export const marketplaceCommand = (): string => `/plugin marketplace add ${gatewayUrl()}/claude-code/marketplace.json`;
export const skillInstallCommand = (skill: Plugin): string => formatInstallCommand(skill);

View file

@ -0,0 +1,88 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import type { ResourceListPage } from "@/app/(dashboard)/hooks/common/useResourceList";
import type { Plugin } from "@/components/claude_code_plugins/types";
import { apiClient, getPublicModelHubInfo, getUiConfig } from "@/components/networking";
import type { AgentCard, MCPServerData, ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
import { usePublicModelHubFacets } from "./usePublicModelHubFacets";
import { PUBLIC_MODEL_HUB_PAGE_SIZE, PUBLIC_MODEL_HUB_PATH, usePublicModelHubList } from "./usePublicModelHubList";
export interface HubInfo {
docs_title: string;
custom_docs_description: string | null;
litellm_version: string;
useful_links: Record<string, string | { url: string; index?: number }>;
}
const asArray = <T>(data: unknown): T[] => (Array.isArray(data) ? (data as T[]) : []);
const UI_CONFIG_QUERY = {
queryKey: ["publicAiHub", "uiConfig"],
queryFn: () => getUiConfig(),
staleTime: Infinity,
retry: false,
};
export function useHubData() {
const config = useQuery(UI_CONFIG_QUERY);
const ready = config.isSuccess || config.isError;
const info = useQuery<HubInfo>({
queryKey: ["publicAiHub", "info"],
queryFn: () => getPublicModelHubInfo(),
enabled: ready,
});
const agents = useQuery<AgentCard[]>({
queryKey: ["publicAiHub", "agents"],
queryFn: async ({ signal }) => asArray<AgentCard>(await apiClient.get("/public/agent_hub", { signal })),
enabled: ready,
});
const mcp = useQuery<MCPServerData[]>({
queryKey: ["publicAiHub", "mcp"],
queryFn: async ({ signal }) => asArray<MCPServerData>(await apiClient.get("/public/mcp_hub", { signal })),
enabled: ready,
});
const skills = useQuery<Plugin[]>({
queryKey: ["publicAiHub", "skills"],
queryFn: async ({ signal }) =>
(await apiClient.get<{ plugins?: Plugin[] }>("/public/skill_hub", { signal })).plugins ?? [],
enabled: ready,
});
const list = usePublicModelHubList(ready);
const listLoading = !ready || list.isLoading;
const models = { ...list, isLoading: listLoading && list.rows.length === 0 };
const facets = usePublicModelHubFacets(ready);
const [modelTotal, setModelTotal] = useState<number | undefined>(undefined);
const settledTotal = !listLoading && !models.hasActiveQuery && !models.error ? models.rowCount : modelTotal;
if (settledTotal !== modelTotal) setModelTotal(settledTotal);
return { info, agents, mcp, skills, models, facets, modelTotal: settledTotal };
}
export type HubData = ReturnType<typeof useHubData>;
const openedModelQuery = (open: string | null, enabled: boolean) => ({
queryKey: ["publicAiHub", "openedModel", open],
queryFn: ({ signal }: { signal: AbortSignal }) =>
apiClient.get<ResourceListPage<ModelGroupInfo>>(PUBLIC_MODEL_HUB_PATH, {
query: { q: open ?? "", page_size: PUBLIC_MODEL_HUB_PAGE_SIZE },
signal,
}),
enabled,
retry: false,
});
export function useOpenedModel(open: string | null, rows: ModelGroupInfo[], enabled: boolean) {
const onPage = rows.find((model) => model.model_group === open);
const [kept, setKept] = useState<ModelGroupInfo | undefined>(undefined);
const known = onPage ?? (kept?.model_group === open ? kept : undefined);
const lookup = useQuery(openedModelQuery(open, enabled && open !== null && known === undefined));
const model = known ?? lookup.data?.data.find((found) => found.model_group === open);
if (model !== undefined && model !== kept) setKept(model);
return { model, pending: model === undefined && lookup.isLoading };
}

View file

@ -0,0 +1,32 @@
"use client";
import { createSerializer, parseAsString, useQueryState } from "nuqs";
import { useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { useUrlTab } from "@/hooks/useUrlTab";
export const HUB_TABS = ["models", "agents", "mcp", "skills"] as const;
export type HubTab = (typeof HUB_TABS)[number];
const OPEN_PARAM = { open: parseAsString };
const serializeOpen = createSerializer(OPEN_PARAM);
export function useHubUrlState() {
const [tab, setUrlTab] = useUrlTab(HUB_TABS, "models");
const [open, setOpenParam] = useQueryState("open", OPEN_PARAM.open);
const setOpen = useCallback((key: string | null) => void setOpenParam(key), [setOpenParam]);
const setTab = useCallback(
(next: HubTab) => {
setUrlTab(next);
setOpen(null);
},
[setUrlTab, setOpen],
);
return { tab, setTab, open: open || null, setOpen };
}
export function useOpenHref() {
const params = useSearchParams();
return useCallback((key: string) => serializeOpen(params ?? new URLSearchParams(), { open: key }), [params]);
}

View file

@ -0,0 +1,16 @@
import { useSyncExternalStore } from "react";
const NARROW = "(width < 64rem)";
const subscribe = (onChange: () => void) => {
const query = window.matchMedia(NARROW);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
export const useIsNarrow = (): boolean =>
useSyncExternalStore(
subscribe,
() => window.matchMedia(NARROW).matches,
() => false,
);

View file

@ -0,0 +1,19 @@
"use client";
import { useEffect } from "react";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { getProxyBaseUrl, getUiPageUrl } from "@/components/networking";
import { getCookie } from "@/utils/cookieUtils";
import { checkTokenValidity } from "@/utils/jwtUtils";
import { getLoginUrl } from "@/utils/returnUrlUtils";
export function useRequirePublicHubAuth() {
const { data: uiSettings, isLoading } = useUISettings();
const requireAuth = !isLoading && uiSettings?.values?.require_auth_for_public_ai_hub === true;
useEffect(() => {
if (requireAuth && !checkTokenValidity(getCookie("token"))) {
window.location.replace(getUiPageUrl(getLoginUrl(getProxyBaseUrl())));
}
}, [requireAuth]);
}