mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-02 07:13:02 +00:00
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
This commit is contained in:
parent
bd546f1dac
commit
e980378056
20 changed files with 338 additions and 109 deletions
|
|
@ -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 } });
|
||||
|
|
|
|||
|
|
@ -1155,16 +1155,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
|
||||
|
|
|
|||
|
|
@ -12,8 +12,11 @@ import { Badges, CodeBlock, EndpointField, Facts, PaneSection } from "./HubParts
|
|||
import { agentSnippets } from "./hubSnippets";
|
||||
import { InspectorHeader } from "./InspectorHeader";
|
||||
|
||||
const modes = (agent: AgentCard) =>
|
||||
`${(agent.defaultInputModes ?? []).join(", ")} / ${(agent.defaultOutputModes ?? []).join(", ")}`;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { LayoutGrid, SearchX, TriangleAlert } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -57,3 +57,36 @@ export function HubNotEnabled() {
|
|||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadFailed({ noun, onRetry }: { noun: string; onRetry: () => void }) {
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<TriangleAlert />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle className={SECTION_TITLE}>Couldn'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}>“{name}” isn't listed here</EmptyTitle>
|
||||
<EmptyDescription>It may have been renamed or unpublished</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ 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;
|
||||
|
|
@ -75,13 +77,7 @@ function BaseUrlField() {
|
|||
);
|
||||
}
|
||||
|
||||
export function HubHeader({
|
||||
info,
|
||||
status,
|
||||
}: {
|
||||
info: HubInfo | undefined;
|
||||
status: "loading" | "operational" | "unavailable";
|
||||
}) {
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ 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";
|
||||
|
|
@ -104,6 +105,8 @@ export function HubMasterDetail<T>({
|
|||
rows,
|
||||
loading,
|
||||
selectedKey,
|
||||
opened,
|
||||
openPending = false,
|
||||
getKey,
|
||||
onSelect,
|
||||
renderItem,
|
||||
|
|
@ -116,6 +119,8 @@ export function HubMasterDetail<T>({
|
|||
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;
|
||||
|
|
@ -124,11 +129,16 @@ export function HubMasterDetail<T>({
|
|||
footer?: React.ReactNode;
|
||||
}) {
|
||||
const narrow = useIsNarrow();
|
||||
const chosen = rows.find((row) => getKey(row) === selectedKey);
|
||||
const selected = chosen ?? rows[0];
|
||||
const chosen = selectedKey === null ? undefined : opened ?? rows.find((row) => getKey(row) === selectedKey);
|
||||
const selected = selectedKey === null ? rows[0] : chosen;
|
||||
const [sheetRow, setSheetRow] = React.useState<T | undefined>(chosen);
|
||||
if (chosen !== undefined && chosen !== sheetRow) setSheetRow(chosen);
|
||||
const isEmpty = !loading && rows.length === 0;
|
||||
const detail = () => {
|
||||
if (loading || (selected === undefined && openPending)) return <DetailSkeleton />;
|
||||
if (selected === undefined) return selectedKey && <NotListed name={selectedKey} />;
|
||||
return renderDetail(selected);
|
||||
};
|
||||
const list = isEmpty ? (
|
||||
empty
|
||||
) : (
|
||||
|
|
@ -158,11 +168,7 @@ export function HubMasterDetail<T>({
|
|||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">{loading ? <ListSkeleton /> : list}</ScrollArea>
|
||||
</div>
|
||||
{!isEmpty && (
|
||||
<ScrollArea className="hidden min-h-0 lg:block">
|
||||
{loading ? <DetailSkeleton /> : selected && renderDetail(selected)}
|
||||
</ScrollArea>
|
||||
)}
|
||||
{!isEmpty && <ScrollArea className="hidden min-h-0 lg:block">{detail()}</ScrollArea>}
|
||||
<DetailSheet
|
||||
open={narrow && chosen !== undefined}
|
||||
title={sheetRow ? getKey(sheetRow) : "Details"}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,7 @@ import { InspectorHeader } from "./InspectorHeader";
|
|||
export function McpInspector({ server }: { server: MCPServerData }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<InspectorHeader
|
||||
title={server.server_name}
|
||||
copyLabel="Copy server name"
|
||||
description={server.mcp_info?.description}
|
||||
/>
|
||||
<InspectorHeader title={server.name} copyLabel="Copy server name" description={server.mcp_info?.description} />
|
||||
<Facts
|
||||
groups={[
|
||||
[
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ 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[][] => {
|
||||
|
|
@ -133,9 +139,7 @@ export function ModelInspector({ model }: { model: ModelGroupInfo }) {
|
|||
title={model.model_group}
|
||||
copyLabel="Copy model name"
|
||||
status={<HealthBadge model={model} />}
|
||||
description={
|
||||
model.description || `${modeLabel(model.mode)} model from ${providers.map(providerName).join(", ")}`
|
||||
}
|
||||
description={summary(model)}
|
||||
/>
|
||||
{model.model_group.includes("*") && (
|
||||
<Alert>
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ import { ItemActions, ItemContent, ItemDescription, ItemMedia } from "@/componen
|
|||
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 { NoResults } from "./HubEmpty";
|
||||
import { LoadFailed, NoResults } from "./HubEmpty";
|
||||
import { Joined, ProviderAvatar, RowTitle, SearchInput } from "./HubParts";
|
||||
import type { HubData } from "./useHubData";
|
||||
import { type HubData, useOpenedModel } from "./useHubData";
|
||||
import { ModelInspector } from "./ModelInspector";
|
||||
|
||||
const SORTS = [
|
||||
|
|
@ -128,7 +129,10 @@ function ModelsToolbar({ hub }: { hub: HubData }) {
|
|||
<FilterChip
|
||||
label="Mode"
|
||||
noun="modes"
|
||||
options={facets.modes.map((m) => ({ value: m, label: modeLabel(m) }))}
|
||||
options={[...new Set(facets.modes.map((m) => displayMode(m) ?? m))].map((m) => ({
|
||||
value: m,
|
||||
label: modeLabel(m),
|
||||
}))}
|
||||
value={models.modeValues}
|
||||
onChange={models.onModesChange}
|
||||
/>
|
||||
|
|
@ -194,6 +198,7 @@ export function ModelsPane({
|
|||
onSelect: (key: string | null) => void;
|
||||
}) {
|
||||
const { models } = hub;
|
||||
const opened = useOpenedModel(selected, models.rows, !models.isLoading);
|
||||
const clear = () => {
|
||||
models.onSearchChange("");
|
||||
models.onColumnFiltersChange([]);
|
||||
|
|
@ -203,15 +208,21 @@ export function ModelsPane({
|
|||
rows={models.rows}
|
||||
loading={models.isLoading}
|
||||
selectedKey={selected}
|
||||
opened={opened.model}
|
||||
openPending={opened.pending}
|
||||
getKey={(model) => model.model_group}
|
||||
onSelect={onSelect}
|
||||
empty={
|
||||
<NoResults
|
||||
icon={<Boxes />}
|
||||
noun="models"
|
||||
query={models.searchValue}
|
||||
onClear={models.hasActiveQuery ? clear : undefined}
|
||||
/>
|
||||
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} />}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ import { fireEvent } from "@testing-library/react";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { agentHubPublicModelsCall, mcpHubPublicServersCall, skillHubPublicCall } from "@/components/networking";
|
||||
|
||||
import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
|
||||
import PublicAiHub from "./PublicAiHub";
|
||||
|
||||
const { apiGetMock, uiSettings, replaceMock, hubInfo } = vi.hoisted(() => ({
|
||||
const { apiGetMock, navigation, uiSettings, replaceMock, hubInfo } = vi.hoisted(() => ({
|
||||
apiGetMock: vi.fn(),
|
||||
navigation: { params: new URLSearchParams() },
|
||||
uiSettings: { values: {} as Record<string, unknown> },
|
||||
replaceMock: vi.fn(),
|
||||
hubInfo: {
|
||||
|
|
@ -18,7 +21,7 @@ const { apiGetMock, uiSettings, replaceMock, hubInfo } = vi.hoisted(() => ({
|
|||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
useSearchParams: () => navigation.params,
|
||||
usePathname: () => "/model_hub_table",
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
|
||||
}));
|
||||
|
|
@ -75,10 +78,18 @@ const page = (data: unknown[]) => ({
|
|||
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" };
|
||||
|
||||
const respondWithModels = () =>
|
||||
apiGetMock.mockImplementation((path: string) =>
|
||||
Promise.resolve(path === MODEL_HUB_PATH ? page(MODELS) : { ...page([]), meta: { page: 1, page_size: 100 } }),
|
||||
);
|
||||
apiGetMock.mockImplementation((path: string, options?: { query?: Record<string, unknown> }) => {
|
||||
if (path !== MODEL_HUB_PATH) return Promise.resolve({ ...page([]), meta: { page: 1, page_size: 100 } });
|
||||
return Promise.resolve(options?.query?.q === "o1-pro" ? page([OFF_PAGE_MODEL]) : page(MODELS));
|
||||
});
|
||||
|
||||
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`;
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
|
|
@ -96,6 +107,8 @@ beforeEach(() => {
|
|||
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", {
|
||||
|
|
@ -152,18 +165,78 @@ describe("PublicAiHub", () => {
|
|||
expect(screen.getByText("LiteLLM v1.90.0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says the hub is unavailable when the public models call fails", async () => {
|
||||
apiGetMock.mockRejectedValue(new Error("Public model hub is not enabled"));
|
||||
it("keeps agents reachable when only the models call fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
apiGetMock.mockRejectedValue(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"));
|
||||
vi.mocked(agentHubPublicModelsCall).mockRejectedValueOnce(new Error("Not found"));
|
||||
vi.mocked(mcpHubPublicServersCall).mockRejectedValueOnce(new Error("Not found"));
|
||||
vi.mocked(skillHubPublicCall).mockRejectedValueOnce(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("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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
|
||||
import { HubHeader } from "./HubHeader";
|
||||
import { HubHeader, type HubStatus } from "./HubHeader";
|
||||
import { HubNotEnabled } from "./HubEmpty";
|
||||
import { ModelsPane } from "./ModelsPane";
|
||||
import { AgentsPane, McpPane, SkillsPane } from "./ResourcePanes";
|
||||
|
|
@ -24,9 +24,11 @@ function TabLabel({ children, count }: { children: ReactNode; count: number | un
|
|||
);
|
||||
}
|
||||
|
||||
const hubStatus = (hub: HubData) => {
|
||||
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 hub.models.error ? "unavailable" : "operational";
|
||||
return failures.length > 0 ? "degraded" : "operational";
|
||||
};
|
||||
|
||||
function HubTabs({ hub }: { hub: HubData }) {
|
||||
|
|
@ -55,13 +57,31 @@ function HubTabs({ hub }: { hub: HubData }) {
|
|||
<ModelsPane hub={hub} {...paneProps} />
|
||||
</TabsContent>
|
||||
<TabsContent value="agents">
|
||||
<AgentsPane rows={hub.agents.data} loading={hub.agents.isPending} {...paneProps} />
|
||||
<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} {...paneProps} />
|
||||
<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} {...paneProps} />
|
||||
<SkillsPane
|
||||
rows={hub.skills.data}
|
||||
loading={hub.skills.isPending}
|
||||
error={hub.skills.isError}
|
||||
onRetry={() => void hub.skills.refetch()}
|
||||
{...paneProps}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,24 +6,22 @@ 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 { NoResults } from "./HubEmpty";
|
||||
import { LoadFailed, NoResults } from "./HubEmpty";
|
||||
import { RowTitle, SearchInput } from "./HubParts";
|
||||
import { McpInspector } from "./McpInspector";
|
||||
import { SkillInspector } from "./SkillInspector";
|
||||
|
||||
const matches = (fields: (string | null | undefined)[], query: string) => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return !needle || fields.some((field) => field?.toLowerCase().includes(needle));
|
||||
};
|
||||
|
||||
interface ResourcePaneProps<T> {
|
||||
rows: T[];
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
onRetry: () => void;
|
||||
noun: string;
|
||||
icon: React.ReactNode;
|
||||
search: (row: T) => (string | null | undefined)[];
|
||||
|
|
@ -40,6 +38,8 @@ interface ResourcePaneProps<T> {
|
|||
function ResourcePane<T>({
|
||||
rows,
|
||||
loading,
|
||||
error,
|
||||
onRetry,
|
||||
noun,
|
||||
icon,
|
||||
search,
|
||||
|
|
@ -55,8 +55,11 @@ function ResourcePane<T>({
|
|||
const [query, setQuery] = React.useState("");
|
||||
const [chosen, setChosen] = React.useState<string[]>([]);
|
||||
const options = [...new Set(rows.flatMap(filter.values))].sort();
|
||||
const shown = rows.filter(
|
||||
(row) => matches(search(row), query) && (chosen.length === 0 || filter.values(row).some((v) => chosen.includes(v))),
|
||||
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 = () => {
|
||||
|
|
@ -68,9 +71,16 @@ function ResourcePane<T>({
|
|||
rows={shown}
|
||||
loading={loading}
|
||||
selectedKey={selected}
|
||||
opened={rows.find((row) => getKey(row) === selected)}
|
||||
getKey={getKey}
|
||||
onSelect={onSelect}
|
||||
empty={<NoResults icon={icon} noun={noun} query={query} onClear={filtered ? clear : undefined} />}
|
||||
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}`} />
|
||||
|
|
@ -105,6 +115,8 @@ function ResourcePane<T>({
|
|||
interface PaneProps<T> {
|
||||
rows: T[] | undefined;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
onRetry: () => void;
|
||||
selected: string | null;
|
||||
onSelect: (key: string | null) => void;
|
||||
}
|
||||
|
|
@ -138,16 +150,16 @@ export function McpPane({ rows, ...props }: PaneProps<MCPServerData>) {
|
|||
rows={rows ?? []}
|
||||
noun="MCP servers"
|
||||
icon={<Server />}
|
||||
search={(server) => [server.server_name, server.mcp_info?.description]}
|
||||
search={(server) => [server.name, server.mcp_info?.description]}
|
||||
filter={{
|
||||
label: "Transport",
|
||||
noun: "transports",
|
||||
values: (server) => (server.transport ? [server.transport] : []),
|
||||
}}
|
||||
getKey={(server) => server.server_name}
|
||||
title={(server) => server.server_name}
|
||||
getKey={(server) => server.name}
|
||||
title={(server) => server.name}
|
||||
description={(server) => server.mcp_info?.description}
|
||||
meta={(server) => mcpAuthLabel(server.auth_type)}
|
||||
meta={(server) => (server.auth_type ? mcpAuthLabel(server.auth_type) : undefined)}
|
||||
renderDetail={(server) => <McpInspector server={server} />}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,36 @@
|
|||
"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 added = skill.created_at ? new Date(skill.created_at).toLocaleDateString() : null;
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<InspectorHeader title={skill.name} copyLabel="Copy skill name" description={skill.description} />
|
||||
|
|
@ -20,6 +42,11 @@ export function SkillInspector({ skill }: { skill: Plugin }) {
|
|||
{ 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} /> }],
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ describe("modeLabel", () => {
|
|||
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("-");
|
||||
});
|
||||
});
|
||||
|
|
@ -50,10 +51,13 @@ describe("formatTokens", () => {
|
|||
});
|
||||
|
||||
describe("formatPerMillion", () => {
|
||||
it("prices per million tokens with two decimals", () => {
|
||||
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(0)).toBe("-");
|
||||
expect(formatPerMillion(3.75e-8)).toBe("$0.0375");
|
||||
expect(formatPerMillion(2e-9)).toBe("$0.002");
|
||||
expect(formatPerMillion(0)).toBe("Free");
|
||||
expect(formatPerMillion(undefined)).toBe("-");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -70,8 +74,14 @@ describe("capabilities", () => {
|
|||
});
|
||||
|
||||
it("lists an agent's enabled capabilities", () => {
|
||||
const agent = { capabilities: { streaming: true, pushNotifications: true, stateTransitionHistory: false } };
|
||||
expect(agentCapabilities(agent as AgentCard)).toEqual(["Streaming", "Push notifications"]);
|
||||
const agent = {
|
||||
capabilities: { streaming: true, pushNotifications: true, stateTransitionHistory: true, x: false },
|
||||
};
|
||||
expect(agentCapabilities(agent as AgentCard)).toEqual([
|
||||
"Streaming",
|
||||
"Push notifications",
|
||||
"State transition history",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -90,8 +100,10 @@ describe("relativeTime", () => {
|
|||
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("-");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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";
|
||||
|
||||
|
|
@ -23,8 +24,10 @@ const MODE_LABELS: Record<string, string> = {
|
|||
|
||||
const sentenceCase = (text: string): string => text.replaceAll("_", " ").replace(/^./, (c) => c.toUpperCase());
|
||||
|
||||
export const modeLabel = (mode: string | null | undefined): string =>
|
||||
mode ? MODE_LABELS[mode] ?? sentenceCase(mode) : "-";
|
||||
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 "-";
|
||||
|
|
@ -33,8 +36,20 @@ export const formatTokens = (tokens: number | null | undefined): string => {
|
|||
return String(tokens);
|
||||
};
|
||||
|
||||
export const formatPerMillion = (cost: number | null | undefined): string =>
|
||||
cost ? `$${(cost * 1_000_000).toFixed(2)}` : "-";
|
||||
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*\(.*\)$/, "");
|
||||
|
|
@ -73,7 +88,7 @@ export const modelCapabilities = (model: ModelGroupInfo): string[] =>
|
|||
export const agentCapabilities = (agent: AgentCard): string[] =>
|
||||
Object.entries(agent.capabilities ?? {})
|
||||
.filter(([, on]) => on === true)
|
||||
.map(([key]) => (key === "pushNotifications" ? "Push notifications" : sentenceCase(key)));
|
||||
.map(([key]) => sentenceCase(key.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()));
|
||||
|
||||
export type HealthTone = "success" | "error" | "neutral";
|
||||
|
||||
|
|
@ -86,13 +101,15 @@ export const healthOf = (model: ModelGroupInfo): { tone: HealthTone; label: stri
|
|||
HEALTH[model.health_status ?? ""] ?? { tone: "neutral", label: "Not checked" };
|
||||
|
||||
export const relativeTime = (iso: string | null | undefined, now: number = Date.now()): string => {
|
||||
if (!iso) return "-";
|
||||
const minutes = Math.max(0, Math.round((now - new Date(iso).getTime()) / 60_000));
|
||||
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`;
|
||||
return `${Math.round(hours / 24)} days ago`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days} ${days === 1 ? "day" : "days"} ago`;
|
||||
};
|
||||
|
||||
export const gatewayUrl = (): string => getProxyBaseUrl();
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const model = (overrides: Partial<ModelGroupInfo>): ModelGroupInfo => ({
|
|||
...overrides,
|
||||
});
|
||||
|
||||
const server = { server_id: "s1", server_name: "github" } as MCPServerData;
|
||||
const server = { server_id: "s1", name: "github", server_name: "github" } as MCPServerData;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
|
|
@ -46,6 +46,11 @@ describe("MCP and skill snippets", () => {
|
|||
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",
|
||||
|
|
|
|||
|
|
@ -72,15 +72,15 @@ response = await client.send_message(request)`,
|
|||
},
|
||||
];
|
||||
|
||||
export const mcpEndpoint = (server: MCPServerData): string => `${gatewayUrl()}/${server.server_name}/mcp`;
|
||||
export const mcpEndpoint = (server: MCPServerData): string => `${gatewayUrl()}/${server.name}/mcp`;
|
||||
|
||||
export const mcpSnippet = (server: MCPServerData): string => `from fastmcp import Client
|
||||
|
||||
client = Client({
|
||||
"mcpServers": {
|
||||
"${server.server_name}": {
|
||||
"${server.name}": {
|
||||
"url": "${mcpEndpoint(server)}",
|
||||
"headers": {"x-litellm-api-key": "Bearer $LITELLM_API_KEY"},
|
||||
"headers": {"x-litellm-api-key": "Bearer your_api_key"},
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,18 +3,20 @@
|
|||
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 {
|
||||
agentHubPublicModelsCall,
|
||||
apiClient,
|
||||
getPublicModelHubInfo,
|
||||
getUiConfig,
|
||||
mcpHubPublicServersCall,
|
||||
skillHubPublicCall,
|
||||
} from "@/components/networking";
|
||||
import type { AgentCard, MCPServerData } from "@/components/PublicModelHubTableColumns";
|
||||
import type { AgentCard, MCPServerData, ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
|
||||
|
||||
import { usePublicModelHubFacets } from "./usePublicModelHubFacets";
|
||||
import { usePublicModelHubList } from "./usePublicModelHubList";
|
||||
import { PUBLIC_MODEL_HUB_PAGE_SIZE, PUBLIC_MODEL_HUB_PATH, usePublicModelHubList } from "./usePublicModelHubList";
|
||||
|
||||
export interface HubInfo {
|
||||
docs_title: string;
|
||||
|
|
@ -25,8 +27,15 @@ export interface HubInfo {
|
|||
|
||||
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({ queryKey: ["publicAiHub", "uiConfig"], queryFn: () => getUiConfig(), staleTime: Infinity });
|
||||
const config = useQuery(UI_CONFIG_QUERY);
|
||||
const ready = config.isSuccess || config.isError;
|
||||
|
||||
const info = useQuery<HubInfo>({
|
||||
|
|
@ -51,13 +60,31 @@ export function useHubData() {
|
|||
});
|
||||
|
||||
const list = usePublicModelHubList(ready);
|
||||
const models = { ...list, isLoading: !ready || list.isLoading };
|
||||
const listLoading = !ready || list.isLoading;
|
||||
const [listSettled, setListSettled] = useState(false);
|
||||
if (!listLoading && !listSettled) setListSettled(true);
|
||||
const models = { ...list, isLoading: listLoading && !listSettled };
|
||||
const facets = usePublicModelHubFacets(ready);
|
||||
const [modelTotal, setModelTotal] = useState<number | undefined>(undefined);
|
||||
const settledTotal = !models.isLoading && !models.hasActiveQuery && !models.error ? models.rowCount : modelTotal;
|
||||
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>;
|
||||
|
||||
export function useOpenedModel(open: string | null, rows: ModelGroupInfo[], enabled: boolean) {
|
||||
const onPage = rows.find((model) => model.model_group === open);
|
||||
const lookup = useQuery({
|
||||
queryKey: ["publicAiHub", "openedModel", open],
|
||||
queryFn: ({ signal }) =>
|
||||
apiClient.get<ResourceListPage<ModelGroupInfo>>(PUBLIC_MODEL_HUB_PATH, {
|
||||
query: { q: open ?? "", page_size: PUBLIC_MODEL_HUB_PAGE_SIZE },
|
||||
signal,
|
||||
}),
|
||||
enabled: enabled && open !== null && onPage === undefined,
|
||||
});
|
||||
const found = lookup.data?.data.find((model) => model.model_group === open);
|
||||
return { model: onPage ?? found, pending: onPage === undefined && lookup.isLoading };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,5 +28,5 @@ export function useHubUrlState() {
|
|||
|
||||
export function useOpenHref() {
|
||||
const params = useSearchParams();
|
||||
return useCallback((key: string) => serializeOpen(params?.toString() ?? "", { open: key }), [params]);
|
||||
return useCallback((key: string) => serializeOpen(params ?? new URLSearchParams(), { open: key }), [params]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const NARROW = "(max-width: 1023px)";
|
||||
const NARROW = "(width < 64rem)";
|
||||
|
||||
const subscribe = (onChange: () => void) => {
|
||||
const query = window.matchMedia(NARROW);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue