mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
feat(ui)!: redesign public AI Hub as a catalog (#50153)
This commit is contained in:
parent
a1ffb06e28
commit
2ffbdf0d7a
20 changed files with 1422 additions and 1103 deletions
|
|
@ -1,8 +1,8 @@
|
|||
import { test, expect, type APIRequestContext } from "@playwright/test";
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { masterKey } from "../../helpers/traffic";
|
||||
import { masterKey, uniqueSuffix } from "../../helpers/traffic";
|
||||
|
||||
test.describe("AI Hub (internal admin view)", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
|
@ -62,103 +62,93 @@ 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 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.
|
||||
await page.goto(`/ui/model_hub_table?key=${masterKey()}`);
|
||||
|
||||
// Dismiss the feedback popup before asserting on the tab, so a popup
|
||||
// race can't briefly mask the tab while we're evaluating visibility.
|
||||
test("shows the Model Hub and Skills Hub catalogs", async ({ page }) => {
|
||||
await page.goto("/ui/model_hub_table");
|
||||
await dismissFeedbackPopup(page);
|
||||
|
||||
await expect(page.getByRole("tab", { name: /^Models/ })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page.getByRole("tab", { name: "Model Hub", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Search models", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("tab")).toHaveCount(2);
|
||||
await page.getByRole("tab", { name: "Skills Hub", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Search skills", exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
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}`;
|
||||
test("finds a published skill and opens its installation instructions", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const skillName = `e2e-catalog-${uniqueSuffix()}`;
|
||||
const domain = `catalog-${uniqueSuffix()}`;
|
||||
const auth = { Authorization: `Bearer ${masterKey()}` };
|
||||
|
||||
const publicMcpServerIds = async (api: APIRequestContext): Promise<string[]> => {
|
||||
const res = await api.get("/public/mcp_hub");
|
||||
expect(res.ok(), `public mcp_hub read failed (${res.status()}): ${await res.text()}`).toBe(true);
|
||||
const servers: { server_id: string }[] = await res.json();
|
||||
return servers.map((server) => server.server_id);
|
||||
};
|
||||
|
||||
const seedPublicEntries = async (
|
||||
api: APIRequestContext,
|
||||
priorMcpIds: string[],
|
||||
): Promise<{ agentId: string; serverId: string }> => {
|
||||
const agentRes = await api.post("/v1/agents", {
|
||||
headers: auth,
|
||||
data: {
|
||||
agent_name: agentName,
|
||||
agent_card_params: {
|
||||
name: agentName,
|
||||
description: "E2E public agent",
|
||||
version: "1.0.0",
|
||||
url: "http://127.0.0.1:9999/",
|
||||
capabilities: {},
|
||||
skills: [],
|
||||
defaultInputModes: ["text"],
|
||||
defaultOutputModes: ["text"],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(agentRes.ok(), `agent create failed (${agentRes.status()}): ${await agentRes.text()}`).toBe(true);
|
||||
const agentId = (await agentRes.json()).agent_id as string;
|
||||
|
||||
const serverRes = await api.post("/v1/mcp/server", {
|
||||
headers: auth,
|
||||
data: {
|
||||
server_name: mcpServerName,
|
||||
url: "http://127.0.0.1:9999/mcp",
|
||||
transport: "http",
|
||||
description: "E2E public MCP server",
|
||||
},
|
||||
});
|
||||
expect(serverRes.ok(), `mcp server create failed (${serverRes.status()}): ${await serverRes.text()}`).toBe(true);
|
||||
const serverId = (await serverRes.json()).server_id as string;
|
||||
|
||||
const agentPublicRes = await api.post(`/v1/agents/${agentId}/make_public`, { headers: auth });
|
||||
expect(agentPublicRes.ok(), `agent make_public failed: ${await agentPublicRes.text()}`).toBe(true);
|
||||
const mcpPublicRes = await api.post("/v1/mcp/make_public", {
|
||||
headers: auth,
|
||||
data: { mcp_server_ids: [...priorMcpIds, serverId] },
|
||||
});
|
||||
expect(mcpPublicRes.ok(), `mcp make_public failed: ${await mcpPublicRes.text()}`).toBe(true);
|
||||
|
||||
return { agentId, serverId };
|
||||
};
|
||||
|
||||
const priorMcpIds = await publicMcpServerIds(request);
|
||||
const { agentId, serverId } = await seedPublicEntries(request, priorMcpIds);
|
||||
const created = await request.post("/claude-code/plugins", {
|
||||
headers: auth,
|
||||
data: {
|
||||
name: skillName,
|
||||
source: { source: "github", repo: "example/catalog-skills" },
|
||||
description: "Catalog browser test skill",
|
||||
domain,
|
||||
version: "1.0.0",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
created.ok(),
|
||||
`skill registration failed (${created.status()}): ${await created.text()}`,
|
||||
).toBe(true);
|
||||
try {
|
||||
await page.goto(`/ui/model_hub_table?key=${masterKey()}`);
|
||||
await page.goto("/ui/model_hub_table?tab=skills");
|
||||
await dismissFeedbackPopup(page);
|
||||
await page.getByRole("checkbox", { name: domain, exact: true }).check();
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search skills", exact: true })
|
||||
.fill(skillName);
|
||||
await page.getByRole("link").filter({ hasText: skillName }).click();
|
||||
|
||||
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 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();
|
||||
const details = page.getByRole("dialog");
|
||||
await expect(
|
||||
details.getByRole("heading", { name: skillName, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
details.getByText(`/plugin install ${skillName}@litellm`, {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(new RegExp(`open=${skillName}`));
|
||||
await page.reload();
|
||||
await expect(
|
||||
page
|
||||
.getByRole("dialog")
|
||||
.getByRole("heading", { name: skillName, exact: true }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await request.post("/v1/mcp/make_public", { headers: auth, data: { mcp_server_ids: priorMcpIds } });
|
||||
await request.delete(`/v1/agents/${agentId}`, { headers: auth });
|
||||
await request.delete(`/v1/mcp/server/${serverId}`, { headers: auth });
|
||||
const deleted = await request.delete(
|
||||
`/claude-code/plugins/${skillName}`,
|
||||
{ headers: auth },
|
||||
);
|
||||
expect(deleted.ok(), `skill cleanup failed (${deleted.status()})`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("retired catalog links return to Models and clear the old selection", async ({
|
||||
page,
|
||||
}) => {
|
||||
for (const tab of ["agents", "mcp"]) {
|
||||
await page.goto(
|
||||
`/ui/model_hub_table?tab=${tab}&open=retired-entry&key=preserved`,
|
||||
);
|
||||
await dismissFeedbackPopup(page);
|
||||
await expect(
|
||||
page.getByRole("tab", { name: "Model Hub", exact: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page).toHaveURL(/model_hub_table\?key=preserved$/);
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ interface NavbarProps {
|
|||
onToggleSidebar?: () => void;
|
||||
deploymentHealth?: React.ReactNode;
|
||||
deploymentHealthLink?: React.ReactNode;
|
||||
publicNavigation?: React.ReactNode;
|
||||
}
|
||||
|
||||
const NAV_LOGO_CLASS_NAME = "h-auto max-h-full w-auto max-w-full object-contain";
|
||||
|
|
@ -40,6 +41,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
onToggleSidebar,
|
||||
deploymentHealth,
|
||||
deploymentHealthLink,
|
||||
publicNavigation,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
|
|
@ -132,6 +134,8 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{publicNavigation}
|
||||
|
||||
{!isPublicPage && (
|
||||
<div className="ml-4 flex shrink-0 items-center border-l border-border pl-4">
|
||||
<ViewSwitcher />
|
||||
|
|
|
|||
|
|
@ -1,93 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
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 { t } = useTranslation();
|
||||
const capabilities = agentCapabilities(agent);
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<InspectorHeader
|
||||
title={agent.name}
|
||||
copyLabel={t("publicModelHub.agentInspector.copyName")}
|
||||
description={agent.description}
|
||||
/>
|
||||
<Facts
|
||||
groups={[
|
||||
[
|
||||
{ label: t("publicModelHub.agentInspector.facts.version"), value: agent.version },
|
||||
{ label: t("publicModelHub.agentInspector.facts.owner"), value: agent.provider?.organization },
|
||||
{
|
||||
label: t("publicModelHub.agentInspector.facts.protocol"),
|
||||
value: agent.protocolVersion && `A2A ${agent.protocolVersion}`,
|
||||
},
|
||||
],
|
||||
[
|
||||
{ label: t("publicModelHub.agentInspector.facts.inputOutput"), wide: true, value: modes(agent) },
|
||||
{
|
||||
label: t("publicModelHub.agentInspector.facts.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" />}
|
||||
>
|
||||
{t("publicModelHub.agentInspector.documentation")}
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
)}
|
||||
{(agent.skills ?? []).length > 0 && (
|
||||
<PaneSection title={t("publicModelHub.agentInspector.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={t("publicModelHub.agentInspector.callAgent")}>
|
||||
{agentSnippets(agent).map((snippet, index) => (
|
||||
<Field key={snippet.title}>
|
||||
<FieldDescription>
|
||||
{t("publicModelHub.agentInspector.step", { step: index + 1, title: snippet.title })}
|
||||
</FieldDescription>
|
||||
<CodeBlock code={snippet.code} />
|
||||
</Field>
|
||||
))}
|
||||
</PaneSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export function CatalogDetails({
|
||||
name,
|
||||
loading = false,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
name: string | null;
|
||||
loading?: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={name !== null} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="max-h-[90dvh] gap-0 overflow-y-auto p-0 pt-8 sm:max-w-3xl" aria-describedby={undefined}>
|
||||
<DialogTitle className="sr-only">{t("publicModelHub.masterDetail.details")}</DialogTitle>
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-6 p-6" aria-busy="true">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
|
||||
import { useIsNarrow } from "./useIsNarrow";
|
||||
|
||||
export function CatalogFilters({
|
||||
children,
|
||||
count,
|
||||
onClear,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
count: number;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const narrow = useIsNarrow();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return (
|
||||
<aside data-slot="catalog-filters">
|
||||
<Collapsible open={!narrow || expanded} onOpenChange={setExpanded}>
|
||||
<div className="mb-5 flex items-center justify-between gap-2">
|
||||
<h2 className="hidden text-sm font-semibold lg:block">{t("publicModelHub.catalog.filters")}</h2>
|
||||
<CollapsibleTrigger render={<Button variant="outline" className="lg:hidden" />}>
|
||||
{t("publicModelHub.catalog.filters")}
|
||||
{count > 0 && <span className="tabular-nums">({count})</span>}
|
||||
<ChevronDown />
|
||||
</CollapsibleTrigger>
|
||||
{count > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={onClear}>
|
||||
{t("publicModelHub.catalog.clearFilters")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CollapsibleContent className="flex flex-col gap-5">{children}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { CirclePlus, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
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 { t } = useTranslation();
|
||||
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={t("publicModelHub.filterChip.search", { noun })} />
|
||||
<ComboboxEmpty>{t("publicModelHub.filterChip.empty", { noun })}</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={t("publicModelHub.filterChip.clear", { label: label.toLowerCase() })}
|
||||
onClick={() => onChange([])}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ExternalLink, LayoutGrid } from "lucide-react";
|
||||
import { ChevronDown, ExternalLink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -18,7 +18,8 @@ import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/
|
|||
|
||||
import { gatewayUrl, usefulLinkList } from "./hubFormat";
|
||||
import { CopyIconButton } from "./HubParts";
|
||||
import { StatusBadge } from "./HubStatus";
|
||||
import type { HubTab } from "./useHubUrlState";
|
||||
import styles from "./catalog.module.css";
|
||||
import type { HubInfo } from "./useHubData";
|
||||
|
||||
export type HubStatus = "loading" | "operational" | "degraded" | "unavailable";
|
||||
|
|
@ -59,7 +60,7 @@ function BaseUrlField() {
|
|||
const { t } = useTranslation();
|
||||
const url = gatewayUrl();
|
||||
return (
|
||||
<Field className="w-full gap-1.5 sm:w-[420px]">
|
||||
<Field className="w-full gap-1.5 sm:w-[360px]">
|
||||
<InputGroup className="h-8">
|
||||
<InputGroupAddon>
|
||||
<InputGroupText className="text-xs font-medium">{t("publicModelHub.baseUrlField.label")}</InputGroupText>
|
||||
|
|
@ -75,31 +76,33 @@ function BaseUrlField() {
|
|||
<CopyIconButton value={url} label={t("publicModelHub.baseUrlField.copy")} className="size-6" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FieldDescription id="hub-base-url-hint" className="text-xs text-balance">
|
||||
<FieldDescription id="hub-base-url-hint" className="hidden text-xs text-balance sm:block">
|
||||
{t("publicModelHub.baseUrlField.hint")}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function HubHeader({ info, status }: { info: HubInfo | undefined; status: HubStatus }) {
|
||||
export function HubHeader({ info, status, tab }: { info: HubInfo | undefined; status: HubStatus; tab: HubTab }) {
|
||||
const { t } = useTranslation();
|
||||
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" />
|
||||
{t("publicModelHub.header.title")}
|
||||
</h1>
|
||||
{status === "operational" && <StatusBadge tone="success" label={t("publicModelHub.header.operational")} />}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{info?.custom_docs_description || t("publicModelHub.header.description")}
|
||||
<header className={styles.header}>
|
||||
<div>
|
||||
<p className={styles.eyebrow}>{info?.docs_title || t("publicModelHub.catalog.eyebrow")}</p>
|
||||
<h1 className={styles.title}>
|
||||
{t(tab === "skills" ? "publicModelHub.catalog.skillsHub" : "publicModelHub.catalog.modelHub")}
|
||||
</h1>
|
||||
<p className={styles.description}>
|
||||
{info?.custom_docs_description ||
|
||||
t(
|
||||
tab === "skills"
|
||||
? "publicModelHub.catalog.skillsDescription"
|
||||
: "publicModelHub.catalog.modelsDescription",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{status !== "unavailable" && (
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<div className={styles.connection}>
|
||||
<BaseUrlField />
|
||||
<LinksMenu info={info} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,235 +0,0 @@
|
|||
"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 i18n from "@/i18n/i18n";
|
||||
|
||||
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 ?? i18n.t("publicModelHub.masterDetail.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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
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,
|
||||
|
|
@ -26,8 +20,6 @@ export function InspectorHeader({
|
|||
status?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inSheet = React.useContext(InSheet);
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
|
|
@ -36,20 +28,6 @@ export function InspectorHeader({
|
|||
<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={t("publicModelHub.inspectorHeader.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>}
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
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 }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<InspectorHeader
|
||||
title={server.name}
|
||||
copyLabel={t("publicModelHub.mcpInspector.copyName")}
|
||||
description={server.mcp_info?.description}
|
||||
/>
|
||||
<Facts
|
||||
groups={[
|
||||
[
|
||||
{ label: t("publicModelHub.mcpInspector.facts.transport"), value: server.transport },
|
||||
{ label: t("publicModelHub.mcpInspector.facts.signIn"), value: mcpAuthLabel(server.auth_type) },
|
||||
{ label: t("publicModelHub.mcpInspector.facts.alias"), value: server.alias },
|
||||
],
|
||||
]}
|
||||
/>
|
||||
<EndpointField id={`mcp-endpoint-${server.server_id}`} value={mcpEndpoint(server)} />
|
||||
<PaneSection title={t("publicModelHub.mcpInspector.connect")}>
|
||||
<CodeBlock code={mcpSnippet(server)} />
|
||||
</PaneSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,24 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowDownWideNarrow, ArrowUpNarrowWide, Boxes, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowDownWideNarrow, ArrowUpNarrowWide, Boxes } from "lucide-react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataTable, DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { ButtonGroup } from "@/components/ui/button-group";
|
||||
import { ItemActions, ItemContent, ItemDescription, ItemMedia } from "@/components/ui/item";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
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 i18n from "@/i18n/i18n";
|
||||
|
||||
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 { CatalogDetails } from "./CatalogDetails";
|
||||
import { CatalogFilters } from "./CatalogFilters";
|
||||
import { featureFacetLabel, formatPerMillion, formatTokens, modeLabel, providerName } from "./hubFormat";
|
||||
import { LoadFailed, NoResults, NotListed } from "./HubEmpty";
|
||||
import { ProviderAvatar, SearchInput } from "./HubParts";
|
||||
import { type HubData, useOpenedModel } from "./useHubData";
|
||||
import { useOpenHref } from "./useHubUrlState";
|
||||
import { ModelInspector } from "./ModelInspector";
|
||||
|
||||
const SORTS = [
|
||||
|
|
@ -34,42 +36,12 @@ const SORTS = [
|
|||
|
||||
type Models = HubData["models"];
|
||||
|
||||
function ModelsPager({ models }: { models: Models }) {
|
||||
const { t } = useTranslation();
|
||||
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>{t("publicModelHub.modelsPager.range", { start, end, total: rowCount })}</span>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={t("publicModelHub.modelsPager.previous")}
|
||||
disabled={pagination.pageIndex === 0}
|
||||
onClick={() => go(pagination.pageIndex - 1)}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={t("publicModelHub.modelsPager.next")}
|
||||
disabled={start + pagination.pageSize > rowCount}
|
||||
onClick={() => go(pagination.pageIndex + 1)}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortControl({ models }: { models: Models }) {
|
||||
const { t } = useTranslation();
|
||||
const options = SORTS.map((sort) => ({
|
||||
...sort,
|
||||
label: t(`publicModelHub.catalog.sort.${sort.value}`, { defaultValue: sort.label }),
|
||||
}));
|
||||
const field = models.sorting[0]?.id ?? "model_group";
|
||||
const desc = models.sorting[0]?.desc ?? false;
|
||||
const pick = (value: string | null) => {
|
||||
|
|
@ -78,13 +50,13 @@ function SortControl({ models }: { models: Models }) {
|
|||
};
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Select items={SORTS} value={field} onValueChange={pick}>
|
||||
<Select items={options} value={field} onValueChange={pick}>
|
||||
<SelectTrigger size="sm" className="w-auto gap-1.5" aria-label={t("publicModelHub.sortControl.sortBy")}>
|
||||
<span className="hidden text-muted-foreground sm:inline">{t("publicModelHub.sortControl.sort")}</span>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" className="w-auto min-w-(--anchor-width)">
|
||||
{SORTS.map((sort) => (
|
||||
{options.map((sort) => (
|
||||
<SelectItem key={sort.value} value={sort.value}>
|
||||
{sort.label}
|
||||
</SelectItem>
|
||||
|
|
@ -112,100 +84,106 @@ function SortControl({ models }: { models: Models }) {
|
|||
);
|
||||
}
|
||||
|
||||
function ModelsToolbar({ hub }: { hub: HubData }) {
|
||||
const { t } = useTranslation();
|
||||
const { models, facets } = hub;
|
||||
interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
function CatalogFilter({
|
||||
label,
|
||||
options,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
options: FilterOption[];
|
||||
values: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchInput
|
||||
value={models.searchValue}
|
||||
onChange={models.onSearchChange}
|
||||
placeholder={t("publicModelHub.modelsToolbar.search")}
|
||||
/>
|
||||
<SortControl models={models} />
|
||||
<fieldset data-slot="catalog-filter-section" className="min-w-0">
|
||||
<legend className="mb-3 text-sm font-medium">{label}</legend>
|
||||
<div className="flex flex-col gap-3">
|
||||
{options.map((option) => (
|
||||
<label key={option.value} className="flex cursor-pointer items-center gap-2.5 text-sm">
|
||||
<Checkbox
|
||||
checked={values.includes(option.value)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange(checked ? [...values, option.value] : values.filter((value) => value !== option.value))
|
||||
}
|
||||
/>
|
||||
{option.provider && <ProviderAvatar provider={option.provider} />}
|
||||
<span className="min-w-0 break-words">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterChip
|
||||
label={t("publicModelHub.modelsToolbar.provider")}
|
||||
noun={t("publicModelHub.modelsToolbar.providerNoun")}
|
||||
options={facets.providers.map((p) => ({ value: p, label: providerName(p), provider: p }))}
|
||||
value={models.providerValues}
|
||||
onChange={models.onProvidersChange}
|
||||
/>
|
||||
<FilterChip
|
||||
label={t("publicModelHub.modelsToolbar.mode")}
|
||||
noun={t("publicModelHub.modelsToolbar.modeNoun")}
|
||||
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={t("publicModelHub.modelsToolbar.features")}
|
||||
noun={t("publicModelHub.modelsToolbar.featureNoun")}
|
||||
options={facets.features.map((f) => ({ value: f, label: featureFacetLabel(f) }))}
|
||||
value={models.featureValues}
|
||||
onChange={models.onFeaturesChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
const price = (cost: number | null | undefined, direction: "input" | "output") => {
|
||||
if (cost === 0) return i18n.t("publicModelHub.modelPrices.freeDirection", { direction });
|
||||
return cost
|
||||
? i18n.t("publicModelHub.modelPrices.perMillion", {
|
||||
price: formatPerMillion(cost),
|
||||
direction:
|
||||
direction === "input" ? i18n.t("publicModelHub.modelPrices.in") : i18n.t("publicModelHub.modelPrices.out"),
|
||||
})
|
||||
: null;
|
||||
};
|
||||
|
||||
const prices = (model: ModelGroupInfo) =>
|
||||
model.input_cost_per_token === 0 && model.output_cost_per_token === 0
|
||||
? [i18n.t("publicModelHub.modelPrices.free"), null]
|
||||
: [price(model.input_cost_per_token, "input"), price(model.output_cost_per_token, "output")];
|
||||
|
||||
function ModelRow({ model }: { model: ModelGroupInfo }) {
|
||||
function ModelFilters({ hub }: { hub: HubData }) {
|
||||
const { t } = useTranslation();
|
||||
const [input, output] = prices(model);
|
||||
const priced = Boolean(input || output);
|
||||
const { models, facets } = hub;
|
||||
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
|
||||
? t("publicModelHub.modelRow.context", { tokens: formatTokens(model.max_input_tokens) })
|
||||
: 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>
|
||||
)}
|
||||
</>
|
||||
<CatalogFilters count={models.columnFilters.length} onClear={() => models.onColumnFiltersChange([])}>
|
||||
<CatalogFilter
|
||||
label={t("publicModelHub.catalog.provider", { defaultValue: "Provider" })}
|
||||
options={facets.providers.map((provider) => ({ value: provider, label: providerName(provider), provider }))}
|
||||
values={models.providerValues}
|
||||
onChange={models.onProvidersChange}
|
||||
/>
|
||||
<CatalogFilter
|
||||
label={t("publicModelHub.catalog.capabilities", { defaultValue: "Capabilities" })}
|
||||
options={facets.features.map((feature) => ({ value: feature, label: featureFacetLabel(feature) }))}
|
||||
values={models.featureValues}
|
||||
onChange={models.onFeaturesChange}
|
||||
/>
|
||||
<CatalogFilter
|
||||
label={t("publicModelHub.catalog.mode", { defaultValue: "Mode" })}
|
||||
options={[...new Set(facets.modes.map((mode) => displayMode(mode) ?? mode))].map((mode) => ({
|
||||
value: mode,
|
||||
label: modeLabel(mode),
|
||||
}))}
|
||||
values={models.modeValues}
|
||||
onChange={models.onModesChange}
|
||||
/>
|
||||
</CatalogFilters>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelLink({
|
||||
model,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
model: ModelGroupInfo;
|
||||
selected: string | null;
|
||||
onSelect: (key: string | null) => void;
|
||||
}) {
|
||||
const hrefFor = useOpenHref();
|
||||
const follow = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.button !== 0 || event.defaultPrevented) return;
|
||||
if (event.metaKey || event.ctrlKey) return;
|
||||
if (event.shiftKey || event.altKey) return;
|
||||
event.preventDefault();
|
||||
onSelect(model.model_group);
|
||||
};
|
||||
return (
|
||||
<a
|
||||
href={hrefFor(model.model_group)}
|
||||
aria-current={selected === model.model_group ? "true" : undefined}
|
||||
className="flex min-w-0 items-center gap-3 rounded-sm py-2 outline-none hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={follow}
|
||||
>
|
||||
<ProviderAvatar provider={model.providers?.[0] ?? ""} className="size-6 shrink-0 sm:size-8" />
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<span className="font-medium whitespace-normal wrap-anywhere">{model.model_group}</span>
|
||||
<span className="text-xs whitespace-normal text-muted-foreground">
|
||||
{(model.providers ?? []).map(providerName).join(", ")}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -221,35 +199,122 @@ export function ModelsPane({
|
|||
const { t } = useTranslation();
|
||||
const { models } = hub;
|
||||
const opened = useOpenedModel(selected, models.rows, !models.isLoading);
|
||||
const detailsLoading = opened.model === undefined && (models.isLoading || opened.pending);
|
||||
const missingName = opened.model === undefined && !detailsLoading ? selected : null;
|
||||
const countOptions = {
|
||||
count: models.rowCount,
|
||||
defaultValue_one: "{{count}} model",
|
||||
defaultValue_other: "{{count}} models",
|
||||
defaultValue: "{{count}} models",
|
||||
};
|
||||
const columns: ColumnDef<ModelGroupInfo>[] = [
|
||||
{
|
||||
accessorKey: "model_group",
|
||||
meta: { skeleton: "twoLine" },
|
||||
sortDescFirst: false,
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader column={column} title={t("publicModelHub.catalog.model", { defaultValue: "Model" })} />
|
||||
),
|
||||
cell: ({ row }) => <ModelLink model={row.original} selected={selected} onSelect={onSelect} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "max_input_tokens",
|
||||
meta: { numeric: true, className: "hidden sm:table-cell" },
|
||||
size: 140,
|
||||
sortDescFirst: true,
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader
|
||||
column={column}
|
||||
title={t("publicModelHub.catalog.inputLimit", { defaultValue: "Input limit" })}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (row.original.max_input_tokens === 0 ? "0" : formatTokens(row.original.max_input_tokens)),
|
||||
},
|
||||
{
|
||||
accessorKey: "input_cost_per_token",
|
||||
meta: { numeric: true },
|
||||
size: 130,
|
||||
sortDescFirst: false,
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader
|
||||
column={column}
|
||||
title={t("publicModelHub.catalog.inputPrice", { defaultValue: "Input /1M" })}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => formatPerMillion(row.original.input_cost_per_token),
|
||||
},
|
||||
{
|
||||
accessorKey: "output_cost_per_token",
|
||||
meta: { numeric: true },
|
||||
size: 130,
|
||||
sortDescFirst: false,
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader
|
||||
column={column}
|
||||
title={t("publicModelHub.catalog.outputPrice", { defaultValue: "Output /1M" })}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => formatPerMillion(row.original.output_cost_per_token),
|
||||
},
|
||||
];
|
||||
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={t("publicModelHub.modelsPane.noun")} onRetry={models.refetch} />
|
||||
) : (
|
||||
<NoResults
|
||||
icon={<Boxes />}
|
||||
noun={t("publicModelHub.modelsPane.noun")}
|
||||
query={models.searchValue}
|
||||
onClear={models.hasActiveQuery ? clear : undefined}
|
||||
<div data-slot="catalog-layout" className="grid min-w-0 gap-8 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<ModelFilters hub={hub} />
|
||||
<div data-slot="catalog-results" className="flex min-w-0 flex-col gap-4">
|
||||
<div data-slot="catalog-toolbar" className="flex flex-wrap items-center gap-3">
|
||||
<SearchInput
|
||||
value={models.searchValue}
|
||||
onChange={models.onSearchChange}
|
||||
placeholder={t("publicModelHub.catalog.searchModels", { defaultValue: "Search models" })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
toolbar={<ModelsToolbar hub={hub} />}
|
||||
renderItem={(model) => <ModelRow model={model} />}
|
||||
renderDetail={(model) => <ModelInspector model={model} />}
|
||||
footer={<ModelsPager models={models} />}
|
||||
/>
|
||||
<SortControl models={models} />
|
||||
</div>
|
||||
<div data-slot="catalog-count" className="text-sm text-muted-foreground tabular-nums" aria-live="polite">
|
||||
{models.isFetching || models.isLoading
|
||||
? t("publicModelHub.catalog.loadingModels", { defaultValue: "Loading models…" })
|
||||
: !models.error && t("publicModelHub.catalog.modelCount", countOptions)}
|
||||
</div>
|
||||
{models.error && models.rows.length > 0 && (
|
||||
<LoadFailed noun={t("publicModelHub.modelsPane.noun")} onRetry={models.refetch} />
|
||||
)}
|
||||
<div data-slot="catalog-table" aria-busy={models.isFetching || models.isLoading}>
|
||||
<DataTable
|
||||
data={models.rows}
|
||||
columns={columns}
|
||||
getRowId={(model) => model.model_group}
|
||||
sortingMode="server"
|
||||
sorting={models.sorting}
|
||||
onSortingChange={models.onSortingChange}
|
||||
paginationMode="server"
|
||||
pagination={models.pagination}
|
||||
onPaginationChange={models.onPaginationChange}
|
||||
rowCount={models.rowCount}
|
||||
filterMode="server"
|
||||
columnFilters={models.columnFilters}
|
||||
onColumnFiltersChange={models.onColumnFiltersChange}
|
||||
isLoading={models.isLoading}
|
||||
isError={Boolean(models.error)}
|
||||
errorMessage={t("publicModelHub.loadFailed.title", { noun: t("publicModelHub.modelsPane.noun") })}
|
||||
onRetry={models.refetch}
|
||||
noDataMessage={
|
||||
<NoResults
|
||||
icon={<Boxes />}
|
||||
noun={t("publicModelHub.modelsPane.noun")}
|
||||
query={models.searchValue}
|
||||
onClear={models.hasActiveQuery ? clear : undefined}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CatalogDetails name={selected} loading={detailsLoading} onClose={() => onSelect(null)}>
|
||||
{opened.model && <ModelInspector model={opened.model} />}
|
||||
{missingName !== null && <NotListed name={missingName} />}
|
||||
</CatalogDetails>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import { fireEvent, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
|
||||
import type { Plugin } from "@/components/claude_code_plugins/types";
|
||||
import type { apiClient } from "@/components/networking";
|
||||
|
||||
import { chooseSelectOption, 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")) },
|
||||
realGet: {
|
||||
current: (..._args: Parameters<typeof apiClient.get>): Promise<unknown> => Promise.reject(new Error("unset")),
|
||||
},
|
||||
navigation: { params: new URLSearchParams() },
|
||||
uiSettings: { values: {} as Record<string, unknown> },
|
||||
replaceMock: vi.fn(),
|
||||
|
|
@ -25,7 +31,13 @@ vi.mock("next/navigation", () => ({
|
|||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/navbar", () => ({ default: () => <nav aria-label="Top bar" /> }));
|
||||
vi.mock("next/font/local", () => ({ default: () => ({ variable: "catalog-display-font" }) }));
|
||||
|
||||
vi.mock("@/components/navbar", () => ({
|
||||
default: ({ publicNavigation }: { publicNavigation?: ReactNode }) => (
|
||||
<nav aria-label="Top bar">{publicNavigation}</nav>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/telemetry/uiEvents", () => ({ recordUiEvent: vi.fn() }));
|
||||
|
||||
|
|
@ -46,6 +58,7 @@ vi.mock("@/components/networking", async (importOriginal) => {
|
|||
});
|
||||
|
||||
const MODEL_HUB_PATH = "/public/v1/model_hub";
|
||||
const SKILL_HUB_PATH = "/public/skill_hub";
|
||||
|
||||
const MODELS = [
|
||||
{
|
||||
|
|
@ -53,6 +66,7 @@ const MODELS = [
|
|||
providers: ["anthropic"],
|
||||
mode: "chat",
|
||||
input_cost_per_token: 0.000003,
|
||||
max_input_tokens: 200000,
|
||||
description: "Best for agentic coding",
|
||||
},
|
||||
{
|
||||
|
|
@ -61,27 +75,61 @@ const MODELS = [
|
|||
mode: "chat",
|
||||
input_cost_per_token: 0,
|
||||
output_cost_per_token: 0,
|
||||
max_input_tokens: 0,
|
||||
description: "General purpose",
|
||||
},
|
||||
];
|
||||
|
||||
const AGENTS = [
|
||||
const SKILLS: Plugin[] = [
|
||||
{
|
||||
id: "skill-research",
|
||||
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: [],
|
||||
source: { source: "github", repo: "example/research-tools" },
|
||||
namespace: "knowledge",
|
||||
domain: "Research",
|
||||
category: "Analysis",
|
||||
keywords: ["evidence"],
|
||||
author: { name: "Research maintainers" },
|
||||
enabled: true,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
},
|
||||
{
|
||||
id: "skill-review",
|
||||
name: "review-changes",
|
||||
description: "Checks pull requests",
|
||||
version: "2.0.0",
|
||||
source: { source: "github", repo: "example/review-tools" },
|
||||
namespace: "development",
|
||||
domain: "Engineering",
|
||||
category: "Code review",
|
||||
keywords: ["quality"],
|
||||
enabled: true,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
},
|
||||
{
|
||||
id: "skill-release",
|
||||
name: "release-notes",
|
||||
description: "Summarizes shipped changes",
|
||||
version: "1.0.0",
|
||||
source: { source: "github", repo: "example/release-tools" },
|
||||
namespace: "delivery",
|
||||
domain: "Engineering",
|
||||
category: "Documentation",
|
||||
keywords: ["changelog"],
|
||||
enabled: true,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
},
|
||||
];
|
||||
|
||||
const HUBS: Record<string, unknown> = {
|
||||
"/public/agent_hub": AGENTS,
|
||||
"/public/mcp_hub": [],
|
||||
"/public/skill_hub": { plugins: [] },
|
||||
const FACETS: Record<string, string[]> = {
|
||||
[`${MODEL_HUB_PATH}/providers`]: ["anthropic", "openai"],
|
||||
[`${MODEL_HUB_PATH}/modes`]: ["chat"],
|
||||
[`${MODEL_HUB_PATH}/features`]: ["vision", "function_calling"],
|
||||
};
|
||||
|
||||
const page = (data: unknown[]) => ({
|
||||
|
|
@ -92,11 +140,12 @@ const page = (data: unknown[]) => ({
|
|||
|
||||
const OFF_PAGE_MODEL = { model_group: "o1-pro", providers: ["openai"], mode: "chat", description: "Deep reasoning" };
|
||||
|
||||
type Query = { query?: Record<string, unknown> };
|
||||
type Query = NonNullable<Parameters<typeof apiClient.get>[1]>;
|
||||
|
||||
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 } });
|
||||
if (path === SKILL_HUB_PATH) return Promise.resolve({ plugins: SKILLS });
|
||||
if (path in FACETS) return Promise.resolve(page(FACETS[path]));
|
||||
if (path !== MODEL_HUB_PATH) return Promise.reject(new Error(`Unexpected API read: ${path}`));
|
||||
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))));
|
||||
|
|
@ -109,24 +158,12 @@ const failModelsWith = (next: (options?: Query) => Promise<unknown>) =>
|
|||
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 = {};
|
||||
|
|
@ -141,14 +178,26 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
describe("PublicAiHub", () => {
|
||||
it("counts every hub and shows the first model's details", async () => {
|
||||
it("shows only Models and Skills without selecting an item or reading retired hubs", async () => {
|
||||
const user = userEvent.setup();
|
||||
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(await screen.findByRole("link", { name: /claude-sonnet-4-5/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("2 models")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("tab")).toHaveLength(2);
|
||||
expect(screen.getByRole("tab", { name: "Model Hub" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tab", { name: "Skills Hub" })).toHaveAttribute("aria-selected", "false");
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Base URL" })).toHaveValue("https://gateway.example.com");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Skills Hub" }));
|
||||
|
||||
expect(await screen.findByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("3 skills")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(apiGetMock.mock.calls.map(([path]) => path)).not.toEqual(
|
||||
expect.arrayContaining([expect.stringMatching(/^\/public\/(?:agent|mcp)_hub$/)]),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a model from the list and records it in the URL", async () => {
|
||||
|
|
@ -158,21 +207,25 @@ describe("PublicAiHub", () => {
|
|||
|
||||
await user.click(await screen.findByRole("link", { name: /gpt-5/ }));
|
||||
|
||||
expect(await screen.findByRole("heading", { level: 2, name: "gpt-5" })).toBeInTheDocument();
|
||||
const dialog = await screen.findByRole("dialog", { name: "Details" });
|
||||
expect(within(dialog).getByRole("heading", { level: 2, name: "gpt-5" })).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("General purpose")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(onUrlUpdate).toHaveBeenCalledWith(expect.objectContaining({ queryString: "?open=gpt-5" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("explains an empty search and clears it", async () => {
|
||||
it("explains an empty model search and clears the server query", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { tab: "agents" } });
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: "Search agents" }), { target: { value: "billing" } });
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: "Search models" }), { target: { value: "billing" } });
|
||||
|
||||
expect(await screen.findByText('No agents match "billing"')).toBeInTheDocument();
|
||||
expect(await screen.findByText('No models match "billing"')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Clear filters" }));
|
||||
expect(await screen.findByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("link", { name: /gpt-5/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Search models" })).toHaveValue("");
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists the admin's links and the gateway version", async () => {
|
||||
|
|
@ -188,14 +241,14 @@ describe("PublicAiHub", () => {
|
|||
expect(screen.getByText("LiteLLM v1.90.0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps agents reachable when only the models call fails", async () => {
|
||||
it("keeps skills 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$/ }));
|
||||
await user.click(screen.getByRole("tab", { name: "Skills Hub" }));
|
||||
expect(await screen.findByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -204,24 +257,31 @@ describe("PublicAiHub", () => {
|
|||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
|
||||
expect(await screen.findByText("This AI Hub isn't available")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox", { name: "Search models" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a load error when a hub answers with an HTTP error status", async () => {
|
||||
it("shows an HTTP failure for Skills and retries without losing access to Models", 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),
|
||||
path === SKILL_HUB_PATH ? realGet.current(path, options) : respond(path, options),
|
||||
);
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
|
||||
await user.click(await screen.findByRole("tab", { name: /^Agents/ }));
|
||||
expect(await screen.findByRole("link", { name: /gpt-5/ })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "Skills Hub" }));
|
||||
|
||||
expect(await screen.findByText("Couldn't load agents")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No agents published yet")).not.toBeInTheDocument();
|
||||
expect(await screen.findByText("Couldn't load skills")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No skills published yet")).not.toBeInTheDocument();
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ plugins: SKILLS }), { status: 200 }));
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
expect(await screen.findByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Couldn't load skills")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows loading rather than an empty hub while Try again reloads models", async () => {
|
||||
it("shows loading rather than an empty hub while Retry reloads models", async () => {
|
||||
const user = userEvent.setup();
|
||||
let finishRetry: (value: unknown) => void = () => {};
|
||||
failModelsWith(() => Promise.reject(new Error("Gateway timeout")));
|
||||
|
|
@ -229,84 +289,122 @@ describe("PublicAiHub", () => {
|
|||
await screen.findByText("Couldn't load models");
|
||||
failModelsWith(() => new Promise((resolve) => (finishRetry = resolve)));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Try again" }));
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
expect(screen.getByText("Loading models…")).toBeInTheDocument();
|
||||
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();
|
||||
expect(await screen.findByRole("link", { name: /claude-sonnet-4-5/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the open model and its snippet tab while a search hides its row", async () => {
|
||||
it("keeps model details and the selected snippet when an earlier search finishes", async () => {
|
||||
const user = userEvent.setup();
|
||||
let finishSearch: (value: unknown) => void = () => {};
|
||||
failModelsWith((options) =>
|
||||
options?.query?.q === "gpt-5" ? new Promise(() => {}) : respond(MODEL_HUB_PATH, options),
|
||||
options?.query?.q === "claude"
|
||||
? new Promise((resolve) => (finishSearch = resolve))
|
||||
: respond(MODEL_HUB_PATH, options),
|
||||
);
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "gpt-5" } });
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
await screen.findByRole("link", { name: /gpt-5/ });
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search models" }), { target: { value: "claude" } });
|
||||
fireEvent.click(screen.getByRole("link", { name: /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(apiGetMock).toHaveBeenCalledWith(
|
||||
MODEL_HUB_PATH,
|
||||
expect.objectContaining({ query: expect.objectContaining({ q: "claude" }) }),
|
||||
),
|
||||
);
|
||||
finishSearch(page([MODELS[0]]));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("link", { name: /gpt-5/ })).not.toBeInTheDocument());
|
||||
expect(await screen.findByText("1 model")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("gpt-5")).toHaveLength(1);
|
||||
expect(screen.getByRole("heading", { level: 2, name: "gpt-5" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "cURL" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(within(screen.getByRole("dialog")).getByRole("tabpanel", { name: "cURL" })).toHaveTextContent(
|
||||
'"model": "gpt-5"',
|
||||
);
|
||||
expect(apiGetMock).not.toHaveBeenCalledWith(
|
||||
MODEL_HUB_PATH,
|
||||
expect.objectContaining({ query: expect.objectContaining({ q: "gpt-5" }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the open item's details when a search matches nothing", async () => {
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, {
|
||||
searchParams: { tab: "agents", open: "research-assistant" },
|
||||
});
|
||||
it("looks up shared model details even when the catalog list is empty", async () => {
|
||||
failModelsWith((options) => Promise.resolve(page(options?.query?.q === "gpt-5" ? [MODELS[1]] : [])));
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "gpt-5" } });
|
||||
|
||||
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();
|
||||
expect(await screen.findByRole("heading", { level: 2, name: "gpt-5" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("“gpt-5” isn't listed here")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks a free model as free in its row", async () => {
|
||||
it("distinguishes zero and missing prices in the model table", async () => {
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
|
||||
const row = await screen.findByRole("link", { name: /gpt-5/ });
|
||||
const freeRow = await screen.findByRole("row", { name: /gpt-5/ });
|
||||
const pricedRow = screen.getByRole("row", { name: /claude-sonnet-4-5/ });
|
||||
|
||||
expect(within(row).getAllByText("Free").length).toBeGreaterThan(0);
|
||||
expect(within(freeRow).getAllByRole("cell", { name: "Free" })).toHaveLength(2);
|
||||
expect(within(freeRow).getByRole("cell", { name: "0" })).toBeInTheDocument();
|
||||
expect(within(pricedRow).getByRole("cell", { name: "$3.00" })).toBeInTheDocument();
|
||||
expect(within(pricedRow).getByRole("cell", { name: "-" })).toBeInTheDocument();
|
||||
expect(within(pricedRow).queryByRole("cell", { name: "Free" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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();
|
||||
expect(await screen.findByRole("link", { name: /claude-sonnet-4-5/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.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" } });
|
||||
it.each(["models", "skills"])("reports a missing shared item in the %s dialog", async (tab) => {
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { tab, open: "retired-item" } });
|
||||
|
||||
const sheet = await screen.findByRole("dialog");
|
||||
const dialog = await screen.findByRole("dialog", { name: "Details" });
|
||||
|
||||
expect(within(sheet).getByText("\u201cretired-model\u201d isn't listed here")).toBeInTheDocument();
|
||||
expect(await within(dialog).findByText("“retired-item” isn't listed here")).toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).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" } });
|
||||
it.each([
|
||||
{ tab: "models", name: "gpt-5" },
|
||||
{ tab: "skills", name: "research-assistant" },
|
||||
])("preserves query params and native modified clicks on $tab links", async ({ tab, name }) => {
|
||||
const onUrlUpdate = vi.fn();
|
||||
const searchParams = { tab, key: "abc", ref: "shared catalog" };
|
||||
navigation.params = new URLSearchParams(searchParams);
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams, onUrlUpdate });
|
||||
|
||||
expect(await screen.findByRole("link", { name: /research-assistant/ })).toHaveAttribute(
|
||||
"href",
|
||||
"?tab=agents&key=abc&open=research-assistant",
|
||||
);
|
||||
const link = await screen.findByRole("link", { name: new RegExp(name) });
|
||||
|
||||
expect(link).toHaveAttribute("href", `?tab=${tab}&key=abc&ref=shared+catalog&open=${name}`);
|
||||
for (const modifier of ["ctrlKey", "metaKey", "shiftKey", "altKey"]) {
|
||||
expect(fireEvent.click(link, { [modifier]: true })).toBe(true);
|
||||
}
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the open item when switching tabs", async () => {
|
||||
it("clears the detail URL on close and opens Skills without the old model selection", 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 user.click(screen.getByRole("button", { name: "Close" }));
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString: "" })));
|
||||
await user.click(screen.getByRole("tab", { name: "Skills Hub" }));
|
||||
|
||||
expect(await screen.findByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString: "?tab=agents" })),
|
||||
expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString: "?tab=skills" })),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -315,17 +413,36 @@ describe("PublicAiHub", () => {
|
|||
|
||||
expect(await screen.findByRole("heading", { level: 2, name: "o1-pro" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Deep reasoning")).toBeInTheDocument();
|
||||
expect(apiGetMock).toHaveBeenCalledWith(
|
||||
MODEL_HUB_PATH,
|
||||
expect.objectContaining({ query: { q: "o1-pro", page_size: 50 } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("says when a shared link names something that is no longer listed", async () => {
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "retired-model" } });
|
||||
it.each(["agents", "mcp"])("immediately discards incompatible selection from a retired %s tab", async (tab) => {
|
||||
const onUrlUpdate = vi.fn();
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, {
|
||||
searchParams: { tab, open: "gpt-5", ref: "shared" },
|
||||
onUrlUpdate,
|
||||
resetUrlUpdateQueueOnMount: false,
|
||||
});
|
||||
|
||||
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();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("link", { name: /gpt-5/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Model Hub" })).toHaveAttribute("aria-selected", "true");
|
||||
await waitFor(() =>
|
||||
expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString: "?ref=shared" })),
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(apiGetMock).not.toHaveBeenCalledWith(
|
||||
MODEL_HUB_PATH,
|
||||
expect.objectContaining({ query: expect.objectContaining({ q: "gpt-5" }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends visitors without a session to login when the admin requires it", async () => {
|
||||
it.each(["missing", "expired"])("redirects a visitor with a %s session when sign-in is required", async (session) => {
|
||||
uiSettings.values = { require_auth_for_public_ai_hub: true };
|
||||
if (session === "expired") document.cookie = `token=${tokenExpiringIn(-3600)}; path=/`;
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
|
||||
await waitFor(() => expect(replaceMock).toHaveBeenCalledWith(expect.stringContaining("login")));
|
||||
|
|
@ -336,7 +453,136 @@ describe("PublicAiHub", () => {
|
|||
document.cookie = `token=${tokenExpiringIn(3600)}; path=/`;
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
|
||||
expect(await screen.findByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("link", { name: /claude-sonnet-4-5/ })).toBeInTheDocument();
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wires model search, provider, capability, mode, and sorting controls into the list API", async () => {
|
||||
const user = userEvent.setup();
|
||||
failModelsWith((options) =>
|
||||
options?.query?.["filter[providers][in]"] ? Promise.resolve(page([MODELS[1]])) : respond(MODEL_HUB_PATH, options),
|
||||
);
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
await screen.findByRole("link", { name: /claude-sonnet-4-5/ });
|
||||
|
||||
await user.click(screen.getByRole("checkbox", { name: /OpenAI/ }));
|
||||
await user.click(screen.getByRole("checkbox", { name: "Vision" }));
|
||||
await user.click(screen.getByRole("checkbox", { name: "Chat" }));
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search models" }), { target: { value: "gpt" } });
|
||||
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Sort by" }), "Input price");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(apiGetMock).toHaveBeenCalledWith(
|
||||
MODEL_HUB_PATH,
|
||||
expect.objectContaining({
|
||||
query: {
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
q: "gpt",
|
||||
sort: "input_cost_per_token",
|
||||
"filter[providers][in]": "openai",
|
||||
"filter[features][in]": "vision",
|
||||
"filter[mode][in]": "chat",
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText("1 model")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /gpt-5/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /claude-sonnet-4-5/ })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Sort descending" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(apiGetMock).toHaveBeenCalledWith(
|
||||
MODEL_HUB_PATH,
|
||||
expect.objectContaining({ query: expect.objectContaining({ sort: "-input_cost_per_token", q: "gpt" }) }),
|
||||
),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Clear filters" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(apiGetMock).toHaveBeenLastCalledWith(
|
||||
MODEL_HUB_PATH,
|
||||
expect.objectContaining({ query: { page: 1, page_size: 50, q: "gpt", sort: "-input_cost_per_token" } }),
|
||||
),
|
||||
);
|
||||
expect(screen.getByRole("checkbox", { name: /OpenAI/ })).not.toBeChecked();
|
||||
expect(screen.getByRole("checkbox", { name: "Vision" })).not.toBeChecked();
|
||||
expect(screen.getByRole("checkbox", { name: "Chat" })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("combines skill domain and search filters and clears an empty result", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { tab: "skills" } });
|
||||
await screen.findByRole("link", { name: /research-assistant/ });
|
||||
|
||||
await user.click(screen.getByRole("checkbox", { name: "Engineering" }));
|
||||
expect(screen.queryByRole("link", { name: /research-assistant/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /review-changes/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /release-notes/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("2 skills")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search skills" }), { target: { value: "changelog" } });
|
||||
expect(screen.getByRole("link", { name: /release-notes/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /review-changes/ })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search skills" }), { target: { value: "evidence" } });
|
||||
expect(screen.getByText('No skills match "evidence"')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /^All domains/ }));
|
||||
expect(screen.getByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /release-notes/ })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Clear all" }));
|
||||
expect(screen.getByText("3 skills")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Search skills" })).toHaveValue("");
|
||||
expect(screen.getByRole("checkbox", { name: "Engineering" })).not.toBeChecked();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("finds skills by name, description, namespace, domain, and keywords", async () => {
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { tab: "skills" } });
|
||||
await screen.findByRole("link", { name: /research-assistant/ });
|
||||
const search = screen.getByRole("textbox", { name: "Search skills" });
|
||||
|
||||
for (const value of ["research-assistant", "Answers research questions", "knowledge", "Research", "evidence"]) {
|
||||
fireEvent.change(search, { target: { value } });
|
||||
expect(screen.getByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /review-changes/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /release-notes/ })).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the chosen skill with its source and install commands and updates the URL", async () => {
|
||||
const onUrlUpdate = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { tab: "skills" }, onUrlUpdate });
|
||||
await user.click(await screen.findByRole("link", { name: /research-assistant/ }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Details" });
|
||||
expect(within(dialog).getByRole("heading", { name: "research-assistant", level: 2 })).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole("button", { name: "github.com/example/research-tools" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/example/research-tools",
|
||||
);
|
||||
expect(within(dialog).getByText("knowledge")).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("Research")).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByText("/plugin marketplace add https://gateway.example.com/claude-code/marketplace.json"),
|
||||
).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("/plugin install research-assistant@litellm")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(onUrlUpdate).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ queryString: "?tab=skills&open=research-assistant" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("selects the skill named by a shared link instead of the first skill", async () => {
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, {
|
||||
searchParams: { tab: "skills", open: "review-changes" },
|
||||
});
|
||||
|
||||
expect(await screen.findByRole("heading", { level: 2, name: "review-changes" })).toBeInTheDocument();
|
||||
expect(screen.getByText("/plugin install review-changes@litellm")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { level: 2, name: "release-notes" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import localFont from "next/font/local";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import Navbar from "@/components/navbar";
|
||||
|
|
@ -11,93 +11,69 @@ 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 { SkillsPane } from "./ResourcePanes";
|
||||
import { type HubData, useHubData } from "./useHubData";
|
||||
import { type HubTab, useHubUrlState } from "./useHubUrlState";
|
||||
import { useRequirePublicHubAuth } from "./useRequirePublicHubAuth";
|
||||
import styles from "./catalog.module.css";
|
||||
|
||||
function TabLabel({ children, count }: { children: ReactNode; count: number | undefined }) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{count ?? "-"}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const displayFont = localFont({
|
||||
src: "./fonts/barlow-condensed-latin-600.ttf",
|
||||
weight: "600",
|
||||
variable: "--font-catalog-display",
|
||||
});
|
||||
|
||||
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";
|
||||
if (hub.models.error && hub.skills.isError) return "unavailable";
|
||||
if (hub.models.isLoading || hub.skills.isPending) return "loading";
|
||||
return hub.models.error || hub.skills.isError ? "degraded" : "operational";
|
||||
};
|
||||
|
||||
function HubTabs({ hub }: { hub: HubData }) {
|
||||
function PublicAiHubContent({ accessToken }: { accessToken: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
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}>{t("publicModelHub.tabs.models")}</TabLabel>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agents" className="px-1.5 sm:px-2">
|
||||
<TabLabel count={hub.agents.data?.length}>{t("publicModelHub.tabs.agents")}</TabLabel>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp" className="px-1.5 sm:px-2">
|
||||
<TabLabel count={hub.mcp.data?.length}>
|
||||
<span className="sm:hidden">{t("publicModelHub.tabs.mcpShort")}</span>
|
||||
<span className="hidden sm:inline">{t("publicModelHub.tabs.mcpServers")}</span>
|
||||
</TabLabel>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="skills" className="px-1.5 sm:px-2">
|
||||
<TabLabel count={hub.skills.data?.length}>{t("publicModelHub.tabs.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);
|
||||
const { tab, setTab, open, setOpen } = useHubUrlState();
|
||||
const paneProps = { selected: open, onSelect: setOpen };
|
||||
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>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(next) => setTab(next as HubTab)}
|
||||
className={`${styles.catalog} ${displayFont.variable}`}
|
||||
>
|
||||
<Navbar
|
||||
accessToken={accessToken}
|
||||
isPublicPage
|
||||
publicNavigation={
|
||||
<TabsList variant="line" className={styles.navigation} aria-label={t("publicModelHub.header.title")}>
|
||||
<TabsTrigger value="models">{t("publicModelHub.catalog.modelHub")}</TabsTrigger>
|
||||
<TabsTrigger value="skills">{t("publicModelHub.catalog.skillsHub")}</TabsTrigger>
|
||||
</TabsList>
|
||||
}
|
||||
/>
|
||||
<main className={styles.main}>
|
||||
<HubHeader info={hub.info.data} status={status} tab={tab} />
|
||||
{status === "unavailable" ? (
|
||||
<HubNotEnabled />
|
||||
) : (
|
||||
<>
|
||||
<TabsContent value="models">
|
||||
<ModelsPane hub={hub} {...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>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -105,10 +81,7 @@ export default function PublicAiHub({ accessToken }: { accessToken: string | nul
|
|||
return (
|
||||
<ThemeProvider accessToken={accessToken}>
|
||||
<TooltipProvider>
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar accessToken={accessToken} isPublicPage />
|
||||
<PublicAiHubContent />
|
||||
</div>
|
||||
<PublicAiHubContent accessToken={accessToken} />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,197 +1,232 @@
|
|||
"use client";
|
||||
|
||||
import { Bot, Puzzle, Server } from "lucide-react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Puzzle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getSourceDisplayText } from "@/components/claude_code_plugins/helpers";
|
||||
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 { DataTable } from "@/components/shared/DataTable";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
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 { CatalogDetails } from "./CatalogDetails";
|
||||
import { CatalogFilters } from "./CatalogFilters";
|
||||
import { LoadFailed, NoResults, NotListed } from "./HubEmpty";
|
||||
import { SearchInput } from "./HubParts";
|
||||
import { SkillInspector } from "./SkillInspector";
|
||||
import { useOpenHref } from "./useHubUrlState";
|
||||
|
||||
interface ResourcePaneProps<T> {
|
||||
rows: T[];
|
||||
interface SkillsPaneProps {
|
||||
rows: Plugin[] | undefined;
|
||||
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,
|
||||
function SkillLink({
|
||||
skill,
|
||||
selected,
|
||||
onSelect,
|
||||
getKey,
|
||||
renderDetail,
|
||||
title,
|
||||
description,
|
||||
meta,
|
||||
}: ResourcePaneProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
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([]);
|
||||
}: {
|
||||
skill: Plugin;
|
||||
selected: string | null;
|
||||
onSelect: (key: string | null) => void;
|
||||
}) {
|
||||
const hrefFor = useOpenHref();
|
||||
const follow = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.button !== 0 || event.defaultPrevented) return;
|
||||
if (event.metaKey || event.ctrlKey) return;
|
||||
if (event.shiftKey || event.altKey) return;
|
||||
event.preventDefault();
|
||||
onSelect(skill.name);
|
||||
};
|
||||
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">
|
||||
<a
|
||||
href={hrefFor(skill.name)}
|
||||
aria-current={selected === skill.name ? "true" : undefined}
|
||||
className="flex min-w-0 flex-col gap-1 rounded-sm py-2 outline-none hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={follow}
|
||||
>
|
||||
<span className="font-mono text-sm font-medium whitespace-normal wrap-anywhere">{skill.name}</span>
|
||||
{skill.description && (
|
||||
<span className="line-clamp-2 text-xs whitespace-normal text-muted-foreground">{skill.description}</span>
|
||||
)}
|
||||
{skill.domain && <span className="text-xs whitespace-normal text-muted-foreground">{skill.domain}</span>}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillsPane({ rows, loading, error, onRetry, selected, onSelect }: SkillsPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [domains, setDomains] = React.useState<string[]>([]);
|
||||
const skills = React.useMemo(() => [...(rows ?? [])].sort((a, b) => a.name.localeCompare(b.name)), [rows]);
|
||||
const options = React.useMemo(
|
||||
() =>
|
||||
[...new Set(skills.flatMap((skill) => (skill.domain ? [skill.domain] : [])))].sort().map((domain) => ({
|
||||
domain,
|
||||
count: skills.filter((skill) => skill.domain === domain).length,
|
||||
})),
|
||||
[skills],
|
||||
);
|
||||
const shown = React.useMemo(
|
||||
() =>
|
||||
skills.filter((skill) => {
|
||||
const matchesQuery = matchesSearchTerm(query, [
|
||||
skill.name,
|
||||
skill.description,
|
||||
skill.domain,
|
||||
skill.namespace,
|
||||
...(skill.keywords ?? []),
|
||||
]);
|
||||
const matchesDomain = domains.length === 0 || domains.includes(skill.domain ?? "");
|
||||
return matchesQuery && matchesDomain;
|
||||
}),
|
||||
[skills, query, domains],
|
||||
);
|
||||
const opened = skills.find((skill) => skill.name === selected);
|
||||
const missingName = opened === undefined && !loading ? selected : null;
|
||||
const showRefreshError = error && !loading && shown.length > 0;
|
||||
const filtered = query.trim() !== "" || domains.length > 0;
|
||||
const clear = () => {
|
||||
setQuery("");
|
||||
setDomains([]);
|
||||
};
|
||||
const columns: ColumnDef<Plugin>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: t("publicModelHub.catalog.skill", { defaultValue: "Skill" }),
|
||||
meta: { skeleton: "twoLine", className: "min-w-48 whitespace-normal" },
|
||||
cell: ({ row }) => <SkillLink skill={row.original} selected={selected} onSelect={onSelect} />,
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
header: t("publicModelHub.catalog.source", { defaultValue: "Source" }),
|
||||
meta: {
|
||||
className: "hidden max-w-64 text-xs whitespace-normal wrap-anywhere text-muted-foreground lg:table-cell",
|
||||
},
|
||||
cell: ({ row }) => getSourceDisplayText(row.original.source),
|
||||
},
|
||||
{
|
||||
accessorKey: "namespace",
|
||||
header: t("publicModelHub.catalog.namespace", { defaultValue: "Namespace" }),
|
||||
meta: {
|
||||
className:
|
||||
"hidden max-w-40 font-mono text-xs whitespace-normal wrap-anywhere text-muted-foreground xl:table-cell",
|
||||
},
|
||||
cell: ({ row }) => row.original.namespace || "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "category",
|
||||
header: t("publicModelHub.catalog.category", { defaultValue: "Category" }),
|
||||
meta: { className: "max-w-40 text-xs whitespace-normal wrap-anywhere text-muted-foreground" },
|
||||
cell: ({ row }) => row.original.category || "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "version",
|
||||
header: t("publicModelHub.catalog.version", { defaultValue: "Version" }),
|
||||
meta: {
|
||||
className:
|
||||
"hidden max-w-32 font-mono text-xs whitespace-normal wrap-anywhere text-muted-foreground sm:table-cell",
|
||||
},
|
||||
cell: ({ row }) => row.original.version || "-",
|
||||
},
|
||||
];
|
||||
const countOptions = {
|
||||
count: shown.length,
|
||||
defaultValue_one: "{{count}} skill",
|
||||
defaultValue_other: "{{count}} skills",
|
||||
defaultValue: "{{count}} skills",
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-slot="catalog-layout" className="grid min-w-0 gap-8 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<CatalogFilters count={domains.length} onClear={() => setDomains([])}>
|
||||
<fieldset data-slot="catalog-filter-section" className="min-w-0">
|
||||
<legend className="mb-3 text-sm font-medium">
|
||||
{t("publicModelHub.catalog.domain", { defaultValue: "Domain" })}
|
||||
</legend>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-between"
|
||||
aria-pressed={domains.length === 0}
|
||||
onClick={() => setDomains([])}
|
||||
>
|
||||
{t("publicModelHub.catalog.allDomains", { defaultValue: "All domains" })}
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{loading ? "-" : skills.length}</span>
|
||||
</Button>
|
||||
{options.map(({ domain, count }) => (
|
||||
<label key={domain} className="flex cursor-pointer items-center gap-2.5 text-sm">
|
||||
<Checkbox
|
||||
checked={domains.includes(domain)}
|
||||
onCheckedChange={(checked) =>
|
||||
setDomains((current) =>
|
||||
checked ? [...current, domain] : current.filter((value) => value !== domain),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 wrap-anywhere">{domain}</span>
|
||||
<span aria-hidden="true" className="text-xs text-muted-foreground tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</CatalogFilters>
|
||||
<div data-slot="catalog-results" className="flex min-w-0 flex-col gap-4">
|
||||
<div data-slot="catalog-toolbar" className="flex flex-wrap items-center gap-3">
|
||||
<SearchInput
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t("publicModelHub.resourcePane.search", { noun })}
|
||||
placeholder={t("publicModelHub.catalog.searchSkills", { defaultValue: "Search skills" })}
|
||||
/>
|
||||
{options.length > 0 && (
|
||||
<FilterChip
|
||||
label={filter.label}
|
||||
noun={filter.noun}
|
||||
options={options.map((option) => ({ value: option, label: option }))}
|
||||
value={chosen}
|
||||
onChange={setChosen}
|
||||
/>
|
||||
{filtered && (
|
||||
<Button variant="ghost" size="sm" onClick={clear}>
|
||||
{t("publicModelHub.catalog.clearAll", { defaultValue: "Clear all" })}
|
||||
</Button>
|
||||
)}
|
||||
</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>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ResourcePane<AgentCard>
|
||||
{...props}
|
||||
rows={rows ?? []}
|
||||
noun={t("publicModelHub.agentsPane.noun")}
|
||||
icon={<Bot />}
|
||||
search={(agent) => [agent.name, agent.description]}
|
||||
filter={{
|
||||
label: t("publicModelHub.agentsPane.filterLabel"),
|
||||
noun: t("publicModelHub.agentsPane.filterNoun"),
|
||||
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>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ResourcePane<MCPServerData>
|
||||
{...props}
|
||||
rows={rows ?? []}
|
||||
noun={t("publicModelHub.mcpPane.noun")}
|
||||
icon={<Server />}
|
||||
search={(server) => [server.name, server.mcp_info?.description]}
|
||||
filter={{
|
||||
label: t("publicModelHub.mcpPane.filterLabel"),
|
||||
noun: t("publicModelHub.mcpPane.filterNoun"),
|
||||
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>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ResourcePane<Plugin>
|
||||
{...props}
|
||||
rows={rows ?? []}
|
||||
noun={t("publicModelHub.skillsPane.noun")}
|
||||
icon={<Puzzle />}
|
||||
search={(skill) => [skill.name, skill.description, skill.namespace, skill.domain, ...(skill.keywords ?? [])]}
|
||||
filter={{
|
||||
label: t("publicModelHub.skillsPane.filterLabel"),
|
||||
noun: t("publicModelHub.skillsPane.filterNoun"),
|
||||
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} />}
|
||||
/>
|
||||
<div data-slot="catalog-count" className="text-sm text-muted-foreground tabular-nums" aria-live="polite">
|
||||
{loading
|
||||
? t("publicModelHub.catalog.loadingSkills", { defaultValue: "Loading skills…" })
|
||||
: !error && t("publicModelHub.catalog.skillCount", countOptions)}
|
||||
</div>
|
||||
{showRefreshError && <LoadFailed noun={t("publicModelHub.skillsPane.noun")} onRetry={onRetry} />}
|
||||
<div data-slot="catalog-table" aria-busy={loading}>
|
||||
<DataTable
|
||||
data={shown}
|
||||
columns={columns}
|
||||
getRowId={(skill) => skill.name}
|
||||
isLoading={loading}
|
||||
loadingMessage={t("publicModelHub.catalog.loadingSkills", { defaultValue: "Loading skills…" })}
|
||||
isError={error}
|
||||
errorMessage={t("publicModelHub.loadFailed.title", { noun: t("publicModelHub.skillsPane.noun") })}
|
||||
onRetry={onRetry}
|
||||
rowClassName={(row) => (row.original.name === selected ? "bg-muted/50" : "")}
|
||||
noDataMessage={
|
||||
<NoResults
|
||||
icon={<Puzzle />}
|
||||
noun={t("publicModelHub.skillsPane.noun")}
|
||||
query={query}
|
||||
onClear={filtered ? clear : undefined}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CatalogDetails name={selected} loading={loading && opened === undefined} onClose={() => onSelect(null)}>
|
||||
{opened && <SkillInspector skill={opened} />}
|
||||
{missingName !== null && !error && <NotListed name={missingName} />}
|
||||
{missingName !== null && error && <LoadFailed noun={t("publicModelHub.skillsPane.noun")} onRetry={onRetry} />}
|
||||
</CatalogDetails>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,268 @@
|
|||
.catalog {
|
||||
--catalog-blue: #343bff;
|
||||
--catalog-rule: #dce1ed;
|
||||
--catalog-muted: #67728a;
|
||||
min-height: 100dvh;
|
||||
gap: 0;
|
||||
background: #f7f8fb;
|
||||
color: #1e2534;
|
||||
}
|
||||
|
||||
:global(.dark) .catalog {
|
||||
--catalog-blue: #9ca5ff;
|
||||
--catalog-rule: #303747;
|
||||
--catalog-muted: #a6afc1;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.catalog > nav {
|
||||
background: inherit;
|
||||
border-color: var(--catalog-rule);
|
||||
}
|
||||
|
||||
.catalog > nav > div {
|
||||
max-width: 1504px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.catalog > nav > div > div {
|
||||
height: 76px;
|
||||
padding-inline: 36px;
|
||||
gap: 36px;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
align-self: stretch;
|
||||
height: auto !important;
|
||||
gap: 24px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.navigation [role="tab"] {
|
||||
flex: none;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.navigation [role="tab"][data-active] {
|
||||
color: var(--catalog-blue);
|
||||
}
|
||||
|
||||
.navigation [role="tab"]::after {
|
||||
bottom: -1px;
|
||||
background: var(--catalog-blue);
|
||||
}
|
||||
|
||||
.main {
|
||||
width: 100%;
|
||||
max-width: 1504px;
|
||||
margin-inline: auto;
|
||||
padding: 44px 36px 48px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin-bottom: 16px;
|
||||
color: var(--catalog-muted);
|
||||
font-family: var(--font-mono), monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 10px;
|
||||
font-family: var(--font-catalog-display), sans-serif;
|
||||
font-size: clamp(48px, 6vw, 80px);
|
||||
font-weight: 600;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--catalog-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.connection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
max-width: 100%;
|
||||
padding-left: 24px;
|
||||
border-left: 1px solid var(--catalog-rule);
|
||||
}
|
||||
|
||||
.catalog [data-slot="catalog-layout"] {
|
||||
display: grid;
|
||||
grid-template-columns: 192px minmax(0, 1fr);
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.catalog [data-slot="catalog-filters"] {
|
||||
padding-right: 24px;
|
||||
border-right: 1px solid var(--catalog-rule);
|
||||
}
|
||||
|
||||
.catalog [data-slot="catalog-filter-section"] {
|
||||
padding-block: 20px;
|
||||
border-bottom: 1px solid var(--catalog-rule);
|
||||
}
|
||||
|
||||
.catalog [data-slot="catalog-filter-section"]:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.catalog [data-slot="catalog-results"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.catalog [data-slot="catalog-toolbar"] {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.catalog [data-slot="catalog-count"] {
|
||||
color: var(--catalog-muted);
|
||||
font-family: var(--font-mono), monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.catalog [data-slot="catalog-table"] {
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.catalog [data-slot="catalog-table"] table {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.catalog [data-slot="catalog-table"] th {
|
||||
height: 44px;
|
||||
color: var(--catalog-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.catalog [data-slot="catalog-table"] td {
|
||||
height: 76px;
|
||||
padding-block: 16px;
|
||||
}
|
||||
|
||||
.catalog [data-slot="catalog-table"] tr {
|
||||
border-color: var(--catalog-rule);
|
||||
}
|
||||
|
||||
.catalog [data-slot="catalog-table"] a:hover {
|
||||
color: var(--catalog-blue);
|
||||
}
|
||||
|
||||
.catalog [data-slot="catalog-results"] [data-slot="input-group"] {
|
||||
min-height: 44px;
|
||||
background: var(--card);
|
||||
border-color: var(--catalog-rule);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.catalog [data-slot="catalog-filters"] {
|
||||
padding-right: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
.catalog > nav > div > div {
|
||||
gap: 24px;
|
||||
padding-inline: 24px;
|
||||
}
|
||||
.main {
|
||||
padding-inline: 24px;
|
||||
}
|
||||
.header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.connection {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
.catalog [data-slot="catalog-layout"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.catalog > nav > div > div {
|
||||
height: auto;
|
||||
min-height: 68px;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 24px;
|
||||
padding: 14px 20px 0;
|
||||
}
|
||||
.catalog > nav > div > div > :last-child {
|
||||
display: none;
|
||||
}
|
||||
.navigation {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
height: 48px !important;
|
||||
justify-content: flex-start;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.main {
|
||||
padding: 28px 20px;
|
||||
}
|
||||
.header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.eyebrow {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.description {
|
||||
font-size: 13px;
|
||||
}
|
||||
.catalog [data-slot="catalog-layout"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.catalog [data-slot="catalog-filters"] {
|
||||
padding-right: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
.catalog [data-slot="catalog-filter-section"] {
|
||||
padding-block: 12px;
|
||||
}
|
||||
.catalog [data-slot="catalog-table"] td {
|
||||
height: 68px;
|
||||
padding-block: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.catalog [data-slot="catalog-filter-section"] legend {
|
||||
font-family: var(--font-mono), monospace;
|
||||
color: var(--catalog-muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.catalog [data-slot="catalog-filter-section"] [aria-pressed="true"] {
|
||||
color: var(--catalog-blue);
|
||||
background: color-mix(in srgb, var(--catalog-blue) 7%, transparent);
|
||||
}
|
||||
|
||||
.catalog [data-slot="catalog-table"] td:nth-child(n + 2) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
|
|
@ -6,7 +6,7 @@ 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 type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
|
||||
|
||||
import { usePublicModelHubFacets } from "./usePublicModelHubFacets";
|
||||
import { PUBLIC_MODEL_HUB_PAGE_SIZE, PUBLIC_MODEL_HUB_PATH, usePublicModelHubList } from "./usePublicModelHubList";
|
||||
|
|
@ -18,8 +18,6 @@ export interface HubInfo {
|
|||
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(),
|
||||
|
|
@ -36,16 +34,6 @@ export function useHubData() {
|
|||
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 }) =>
|
||||
|
|
@ -61,7 +49,7 @@ export function useHubData() {
|
|||
const settledTotal = !listLoading && !models.hasActiveQuery && !models.error ? models.rowCount : modelTotal;
|
||||
if (settledTotal !== modelTotal) setModelTotal(settledTotal);
|
||||
|
||||
return { info, agents, mcp, skills, models, facets, modelTotal: settledTotal };
|
||||
return { info, skills, models, facets, modelTotal: settledTotal };
|
||||
}
|
||||
|
||||
export type HubData = ReturnType<typeof useHubData>;
|
||||
|
|
|
|||
|
|
@ -1,29 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { createSerializer, parseAsString, useQueryState } from "nuqs";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { createSerializer, parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import { useUrlTab } from "@/hooks/useUrlTab";
|
||||
import { recordUiEvent } from "@/lib/telemetry/uiEvents";
|
||||
import { routeSegmentForPathname } from "@/utils/uiHref";
|
||||
|
||||
export const HUB_TABS = ["models", "agents", "mcp", "skills"] as const;
|
||||
export const HUB_TABS = ["models", "skills"] as const;
|
||||
export type HubTab = (typeof HUB_TABS)[number];
|
||||
|
||||
const OPEN_PARAM = { open: parseAsString };
|
||||
const HUB_PARAMS = {
|
||||
tab: parseAsStringLiteral([...HUB_TABS, "agents", "mcp"] as const).withDefault("models"),
|
||||
...OPEN_PARAM,
|
||||
};
|
||||
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 [state, setState] = useQueryStates(HUB_PARAMS);
|
||||
const page = routeSegmentForPathname(usePathname() ?? "") || "home";
|
||||
const retired = state.tab === "agents" || state.tab === "mcp";
|
||||
const tab: HubTab = state.tab === "skills" ? "skills" : "models";
|
||||
useEffect(() => {
|
||||
if (retired) void setState({ tab: null, open: null });
|
||||
}, [retired, setState]);
|
||||
const setOpen = useCallback((key: string | null) => void setState({ open: key }), [setState]);
|
||||
const setTab = useCallback(
|
||||
(next: HubTab) => {
|
||||
setUrlTab(next);
|
||||
setOpen(null);
|
||||
void recordUiEvent({ page, action: "click", target: `tab=${next}` });
|
||||
void setState({ tab: next, open: null });
|
||||
},
|
||||
[setUrlTab, setOpen],
|
||||
[page, setState],
|
||||
);
|
||||
return { tab, setTab, open: open || null, setOpen };
|
||||
return { tab, setTab, open: retired ? null : state.open || null, setOpen };
|
||||
}
|
||||
|
||||
export function useOpenHref() {
|
||||
|
|
|
|||
|
|
@ -6971,6 +6971,48 @@
|
|||
}
|
||||
},
|
||||
"publicModelHub": {
|
||||
"catalog": {
|
||||
"modelHub": "Model Hub",
|
||||
"skillsHub": "Skills Hub",
|
||||
"eyebrow": "The LiteLLM catalog",
|
||||
"modelsDescription": "Find pricing, context, and capabilities.",
|
||||
"skillsDescription": "Discover reusable skills for your agents.",
|
||||
"filters": "Filters",
|
||||
"clearFilters": "Clear filters",
|
||||
"provider": "Provider",
|
||||
"capabilities": "Capabilities",
|
||||
"mode": "Mode",
|
||||
"model": "Model",
|
||||
"inputLimit": "Input limit",
|
||||
"inputPrice": "Input /1M",
|
||||
"outputPrice": "Output /1M",
|
||||
"searchModels": "Search models",
|
||||
"loadingModels": "Loading models…",
|
||||
"modelCount_one": "{{count}} model",
|
||||
"modelCount_other": "{{count}} models",
|
||||
"sort": {
|
||||
"model_group": "Name",
|
||||
"providers": "Provider",
|
||||
"mode": "Mode",
|
||||
"input_cost_per_token": "Input price",
|
||||
"output_cost_per_token": "Output price",
|
||||
"max_input_tokens": "Context",
|
||||
"max_output_tokens": "Max output",
|
||||
"rpm": "Rate limit"
|
||||
},
|
||||
"skill": "Skill",
|
||||
"source": "Source",
|
||||
"namespace": "Namespace",
|
||||
"category": "Category",
|
||||
"version": "Version",
|
||||
"domain": "Domain",
|
||||
"allDomains": "All domains",
|
||||
"searchSkills": "Search skills",
|
||||
"clearAll": "Clear all",
|
||||
"loadingSkills": "Loading skills…",
|
||||
"skillCount_one": "{{count}} skill",
|
||||
"skillCount_other": "{{count}} skills"
|
||||
},
|
||||
"modelHubPage": {
|
||||
"loading": "Loading..."
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue