refactor(ui): move shell, entry and public hub text onto i18n (#50110)

Move batch B1 of the admin dashboard onto react-i18next: the dashboard
shell (navbar, command palette, deployment health, header, layouts), the
entry pages (login, onboarding, change password, home, API reference,
UI theme, workflows), the public AI hub and the AI hub table, the shared
components and helpers in src/components/shared, common_components,
networking toasts, the toast titles and the shadcn primitive labels.

Pure text move into t() and Trans with 1,056 new en.json keys. The
English on screen is unchanged.
This commit is contained in:
yuneng-jiang 2026-11-04 16:59:42 -08:00 • committed by GitHub
parent 114ba4021a
commit a73e12de56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
121 changed files with 3756 additions and 1332 deletions

View file

@ -1,5 +1,6 @@
"use client";
import React from "react";
import { useTranslation } from "react-i18next";
import CodeBlock from "@/components/CodeBlock";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import DocLink from "./DocLink";
@ -12,6 +13,7 @@ interface ApiRefProps {
}
const APIReferenceView: React.FC<ApiRefProps> = ({ proxySettings }) => {
const { t } = useTranslation();
let base_url = "<your_proxy_base_url>";
const customDocBaseUrl = proxySettings?.LITELLM_UI_API_DOC_BASE_URL;
if (customDocBaseUrl && customDocBaseUrl.trim()) {
@ -25,14 +27,11 @@ const APIReferenceView: React.FC<ApiRefProps> = ({ proxySettings }) => {
<div className="mb-5">
{/* Header row with Docs link on the right */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">OpenAI Compatible Proxy: API Reference</h1>
<h1 className="text-2xl font-semibold text-foreground">{t("apiReference.view.title")}</h1>
<DocLink className="ml-3 shrink-0" href="https://docs.litellm.ai/docs/proxy/user_keys" />
</div>
<p className="mt-2 mb-2 text-sm text-muted-foreground">
LiteLLM is OpenAI Compatible. This means your API Key works with the OpenAI SDK. Just replace the base_url to
point to your litellm proxy. Example Below{" "}
</p>
<p className="mt-2 mb-2 text-sm text-muted-foreground">{t("apiReference.view.description")} </p>
<Tabs defaultValue="openai">
<TabsList variant="line" className="border-b rounded-none w-full justify-start h-auto p-0">

View file

@ -1,5 +1,6 @@
import React from "react";
import { ExternalLink } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cva.config";
@ -9,21 +10,22 @@ export type DocLinkProps = {
};
const DocLink = ({ href, className }: DocLinkProps) => {
const { t } = useTranslation();
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
title="Open documentation in a new tab"
title={t("apiReference.docLink.title")}
className={cn(
"inline-flex items-center gap-2 rounded-xl border border-border bg-card/80 px-3.5 py-2 text-sm font-medium text-foreground shadow-xs",
"hover:bg-card focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring active:translate-y-[0.5px]",
className,
)}
>
<span>API Reference Docs</span>
<span>{t("apiReference.docLink.label")}</span>
<ExternalLink aria-hidden className="h-4 w-4 opacity-80" />
<span className="sr-only">(opens in a new tab)</span>
<span className="sr-only">{t("apiReference.docLink.newTab")}</span>
</a>
);
};

View file

@ -1,17 +1,19 @@
"use client";
import { useTranslation } from "react-i18next";
import APIReferenceView from "./_components/APIReferenceView";
import { DeprecationBanner } from "@/components/DeprecationBanner";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";
const APIReferencePage = () => {
const { t } = useTranslation();
const { accessToken } = useAuthorized();
const proxySettings = useProxySettings(accessToken);
return (
<>
<DeprecationBanner featureName="The API Reference tab" />
<DeprecationBanner featureName={t("apiReference.page.featureName")} />
<APIReferenceView proxySettings={proxySettings} />
</>
);

View file

@ -1,6 +1,7 @@
"use client";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { CircleAlert } from "lucide-react";
import { z } from "zod";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
@ -17,21 +18,23 @@ import { useZodForm } from "@/lib/forms/useZodForm";
import { toast } from "@/lib/toast";
import { revokeSessionAndClearClientState } from "@/app/(dashboard)/hooks/useLogout";
import { getLoginUrl } from "@/utils/returnUrlUtils";
import i18n from "@/i18n/i18n";
const changePasswordSchema = z
.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z.string().min(1, "New password is required"),
confirmNewPassword: z.string().min(1, "Confirm your new password"),
currentPassword: z.string().min(1, { error: () => i18n.t("changePassword.schema.currentRequired") }),
newPassword: z.string().min(1, { error: () => i18n.t("changePassword.schema.newRequired") }),
confirmNewPassword: z.string().min(1, { error: () => i18n.t("changePassword.schema.confirmRequired") }),
})
.refine((values) => values.newPassword === values.confirmNewPassword, {
message: "New passwords do not match",
error: () => i18n.t("changePassword.schema.mismatch"),
path: ["confirmNewPassword"],
});
type ChangePasswordValues = z.infer<typeof changePasswordSchema>;
export function ChangePasswordForm() {
const { t } = useTranslation();
const { accessToken, passwordResetRequired } = useAuthorized();
const form = useZodForm(changePasswordSchema, {
defaultValues: { currentPassword: "", newPassword: "", confirmNewPassword: "" },
@ -49,12 +52,12 @@ export function ChangePasswordForm() {
// The session key was minted restricted; only a fresh login lifts it.
// Revoke it server-side too (best-effort) so it doesn't sit valid
// until the expiry reaper gets to it.
toast.success("Password updated. Please log in with your new password.");
toast.success(t("changePassword.form.toasts.updatedSignIn"));
await revokeSessionAndClearClientState(accessToken);
window.location.replace(getUiPageUrl(getLoginUrl(getProxyBaseUrl())));
return;
}
toast.success("Password updated");
toast.success(t("changePassword.form.toasts.updated"));
form.reset();
} catch (error) {
setSubmitError(extractProxyErrorMessage(error));
@ -67,34 +70,31 @@ export function ChangePasswordForm() {
<div className="mx-auto mt-10 w-full max-w-md">
<Card>
<CardContent>
<h3 className="text-2xl font-semibold text-foreground">Change Password</h3>
<p className="text-sm text-muted-foreground">
Enter your current password and choose a new one. The new password must meet this proxy&apos;s password
policy.
</p>
<h3 className="text-2xl font-semibold text-foreground">{t("changePassword.form.title")}</h3>
<p className="text-sm text-muted-foreground">{t("changePassword.form.description")}</p>
{passwordResetRequired && (
<Alert variant="warning" className="mt-4">
<CircleAlert />
<AlertTitle>
Your password must be changed before you can use the dashboard: it was either found in a known data
breach or set by an administrator as a temporary password. After updating it, you will be signed out to
log in again.
</AlertTitle>
<AlertTitle>{t("changePassword.form.resetRequired")}</AlertTitle>
</Alert>
)}
<form className="mb-2 mt-8" onSubmit={form.handleSubmit(handleSubmit)}>
<FieldGroup>
<FormField control={form.control} name="currentPassword" label="Current Password">
<FormField control={form.control} name="currentPassword" label={t("changePassword.form.currentPassword")}>
{({ ref, ...field }) => <PasswordInput {...field} ref={ref} autoComplete="current-password" />}
</FormField>
<FormField control={form.control} name="newPassword" label="New Password">
<FormField control={form.control} name="newPassword" label={t("changePassword.form.newPassword")}>
{({ ref, ...field }) => <PasswordInput {...field} ref={ref} autoComplete="new-password" />}
</FormField>
<FormField control={form.control} name="confirmNewPassword" label="Confirm New Password">
<FormField
control={form.control}
name="confirmNewPassword"
label={t("changePassword.form.confirmNewPassword")}
>
{({ ref, ...field }) => <PasswordInput {...field} ref={ref} autoComplete="new-password" />}
</FormField>
</FieldGroup>
@ -108,8 +108,10 @@ export function ChangePasswordForm() {
<div className="mt-8">
<Button type="submit" disabled={isPending}>
{isPending && <UiLoadingSpinner className="size-4" role="img" aria-label="loading" />}
Change Password
{isPending && (
<UiLoadingSpinner className="size-4" role="img" aria-label={t("changePassword.form.loading")} />
)}
{t("changePassword.form.submit")}
</Button>
</div>
</form>

View file

@ -1,6 +1,7 @@
"use client";
import { ArrowRight, Plus, Rocket, Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@ -20,6 +21,7 @@ import { HOME_USAGE_DAYS, useHomeUsage } from "./useHomeUsage";
const LEADERBOARD = { metric: "tokens", dimension: "model_groups" } as const;
export default function HomePage() {
const { t } = useTranslation();
const { isViewOnly } = useAuthorized();
const usage = useHomeUsage();
const blog = useBlogPosts();
@ -29,23 +31,23 @@ export default function HomePage() {
return (
<Page>
<PageHeader>
<PageHeaderTitle>Home</PageHeaderTitle>
<PageHeaderTitle>{t("home.page.title")}</PageHeaderTitle>
{!isViewOnly && (
<PageHeaderControls>
<Button render={<a href={uiHref("api-keys?create=true")} />}>
<Plus />
Create New Virtual Key
{t("home.page.createKey")}
</Button>
</PageHeaderControls>
)}
</PageHeader>
<section aria-label={`Usage, last ${HOME_USAGE_DAYS} days`}>
<section aria-label={t("home.page.usageAria", { days: HOME_USAGE_DAYS })}>
{usage.failed ? (
<div className="flex items-center justify-between gap-2 rounded-xl border bg-card px-4 py-3 text-sm">
<span className="text-destructive">Failed to load usage</span>
<span className="text-destructive">{t("home.page.usageFailed")}</span>
<Button variant="outline" size="sm" onClick={usage.retry}>
Retry
{t("home.page.retry")}
</Button>
</div>
) : (
@ -66,7 +68,7 @@ export default function HomePage() {
<div>
<h2 id="home-whats-new" className="flex items-center gap-2 text-lg font-semibold">
<Sparkles className="size-4 text-muted-foreground" />
What&apos;s new in LiteLLM?
{t("home.page.whatsNew")}
</h2>
</div>
<div className="grid gap-4 sm:grid-cols-3">
@ -97,8 +99,8 @@ export default function HomePage() {
)}
<Panel
title="Leaderboard"
subtitle={`Share of tokens over the last ${HOME_USAGE_DAYS} days, with the change between the first and second half of the period`}
title={t("home.page.leaderboard.title")}
subtitle={t("home.page.leaderboard.subtitle", { days: HOME_USAGE_DAYS })}
testId="home-leaderboard"
>
<Leaderboard

View file

@ -1,6 +1,7 @@
"use client";
import { ArrowUpRight, Newspaper } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts";
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
import { Button } from "@/components/ui/button";
@ -33,6 +34,7 @@ const Row = ({ children }: { children: React.ReactNode }) => (
/** Latest posts from the LiteLLM blog RSS feed, via /public/litellm_blog_posts, one row per post. */
export default function UpdatesFeed({ className }: { className?: string }) {
const { t } = useTranslation();
const disabled = useDisableBlogPosts();
const { data, isLoading, isError, refetch } = useBlogPosts();
if (disabled) return null;
@ -43,10 +45,10 @@ export default function UpdatesFeed({ className }: { className?: string }) {
<section aria-labelledby="home-updates-title" data-testid="home-updates" className={className}>
<div className="flex items-center justify-between gap-2">
<h2 id="home-updates-title" className="text-lg font-semibold">
Updates
{t("home.updatesFeed.title")}
</h2>
<Button variant="ghost" size="sm" render={<a href={BLOG_URL} target="_blank" rel="noopener noreferrer" />}>
View all
{t("home.updatesFeed.viewAll")}
<ArrowUpRight className="size-3.5" />
</Button>
</div>
@ -63,13 +65,15 @@ export default function UpdatesFeed({ className }: { className?: string }) {
)}
{isError && (
<div className="flex items-center justify-between gap-2 py-3 text-sm">
<span className="text-destructive">Failed to load posts</span>
<span className="text-destructive">{t("home.updatesFeed.failed")}</span>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
Retry
{t("home.updatesFeed.retry")}
</Button>
</div>
)}
{data && posts.length === 0 && <p className="py-3 text-sm text-muted-foreground">No posts yet</p>}
{data && posts.length === 0 && (
<p className="py-3 text-sm text-muted-foreground">{t("home.updatesFeed.empty")}</p>
)}
{posts.length > 0 && (
<ol className="m-0 list-none p-0">
{posts.map((post: BlogPost) => (

View file

@ -1,6 +1,7 @@
"use client";
import React, { Suspense, useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { DashboardHeader } from "@/components/DashboardHeader";
import Navbar from "@/components/navbar";
@ -44,6 +45,7 @@ function PluginModeProviderWithAuth({ children }: { children: React.ReactNode })
}
export function AgentControlPlaneView() {
const { t } = useTranslation();
const { activePlugin } = usePluginMode();
const activePluginName = activePlugin?.name;
const agentPlatformUrl = activePlugin?.url ?? "";
@ -88,8 +90,8 @@ export function AgentControlPlaneView() {
return (
<div className="flex flex-1 items-center justify-center text-muted-foreground">
<div className="text-center">
<p className="text-lg font-medium mb-2">Plugin</p>
<p className="text-sm">Configure the plugin URL in settings</p>
<p className="text-lg font-medium mb-2">{t("shell.agentControlPlane.plugin")}</p>
<p className="text-sm">{t("shell.agentControlPlane.configure")}</p>
</div>
</div>
);
@ -107,7 +109,7 @@ export function AgentControlPlaneView() {
flex: 1,
minHeight: "calc(100vh - 56px)",
}}
title={activePlugin?.display_name ?? "Plugin"}
title={activePlugin?.display_name ?? t("shell.agentControlPlane.plugin")}
allow="clipboard-write"
/>
);
@ -116,6 +118,7 @@ export function AgentControlPlaneView() {
const FULL_BLEED_SEGMENTS = new Set(["logs"]);
function DashboardShell({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
const { accessToken } = useAuth();
const { mode } = usePluginMode();
const pathname = usePathname();
@ -186,7 +189,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
if (event.target instanceof Element && event.target.closest("a[href]")) setMobileNavigationKey(null);
}}
>
<SheetTitle className="sr-only">Navigation</SheetTitle>
<SheetTitle className="sr-only">{t("shell.dashboardShell.navigation")}</SheetTitle>
<SidebarProvider sidebarCollapsed={false} onToggleCollapsed={() => setMobileNavigationKey(null)} />
</SheetContent>
</Sheet>
@ -198,7 +201,12 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
<SheetTrigger
handle={mobileNavigationHandle}
render={
<Button variant="ghost" size="icon" className="size-11 md:hidden" aria-label="Open navigation" />
<Button
variant="ghost"
size="icon"
className="size-11 md:hidden"
aria-label={t("shell.dashboardShell.openNavigation")}
/>
}
>
<Menu />

View file

@ -1,23 +1,21 @@
"use client";
import { useTranslation } from "react-i18next";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import type { TelemetrySettings } from "@/app/(dashboard)/hooks/telemetry/useTelemetrySettings";
import { useTelemetrySettings } from "@/app/(dashboard)/hooks/telemetry/useTelemetrySettings";
export function TelemetryEnvNotice({ settings }: { settings: TelemetrySettings }) {
const { t } = useTranslation();
if (settings.environment_variables.length === 0) return null;
return (
<Alert>
<AlertTitle>
{settings.vetoed
? "Telemetry is turned off by an environment variable"
: "Telemetry environment variables are set"}
{settings.vetoed ? t("shell.telemetryEnvNotice.vetoed") : t("shell.telemetryEnvNotice.set")}
</AlertTitle>
<AlertDescription>
Set on this proxy: {settings.environment_variables.join(", ")}.{" "}
{settings.set_by_environment
? "These override the settings on this page, which are read-only until they are removed."
: "The groups below can still be changed here."}
{t("shell.telemetryEnvNotice.setOnProxy", { variables: settings.environment_variables.join(", ") })}{" "}
{settings.set_by_environment ? t("shell.telemetryEnvNotice.readOnly") : t("shell.telemetryEnvNotice.editable")}
</AlertDescription>
</Alert>
);

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@ -15,6 +16,7 @@ interface UIThemeSettingsProps {
}
const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, accessToken }) => {
const { t } = useTranslation();
const { setLogoUrl, setLogoUrlDark, setFaviconUrl } = useTheme();
const [logoUrlInput, setLogoUrlInput] = useState<string>("");
const [logoUrlDarkInput, setLogoUrlDarkInput] = useState<string>("");
@ -71,7 +73,7 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
}),
});
if (response.ok) {
toast.success("Theme settings updated successfully!");
toast.success(t("uiTheme.settings.toasts.updated"));
setLogoUrl(logoUrlInput || null);
setLogoUrlDark(logoUrlDarkInput || null);
setFaviconUrl(faviconUrlInput || null);
@ -80,7 +82,7 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
}
} catch (error) {
console.error("Error updating theme settings:", error);
toast.fromError("Failed to update theme settings");
toast.fromError(t("uiTheme.settings.toasts.updateFailed"));
} finally {
setLoading(false);
}
@ -106,13 +108,13 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
body: JSON.stringify({ logo_url: null, logo_url_dark: null, favicon_url: null }),
});
if (response.ok) {
toast.success("Theme settings reset to default!");
toast.success(t("uiTheme.settings.toasts.reset"));
} else {
throw new Error("Failed to reset");
}
} catch (error) {
console.error("Error resetting theme settings:", error);
toast.fromError("Failed to reset theme settings");
toast.fromError(t("uiTheme.settings.toasts.resetFailed"));
} finally {
setLoading(false);
}
@ -125,16 +127,14 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
return (
<div className="w-full mx-auto max-w-4xl px-6 py-8">
<div className="mb-8">
<h1 className="mb-2 text-2xl font-bold">UI Theme Customization</h1>
<p className="text-sm text-muted-foreground">
Customize your LiteLLM admin dashboard with a custom logo and favicon.
</p>
<h1 className="mb-2 text-2xl font-bold">{t("uiTheme.settings.title")}</h1>
<p className="text-sm text-muted-foreground">{t("uiTheme.settings.description")}</p>
</div>
<Card>
<CardContent className="space-y-6">
<div>
<Label htmlFor="ui-theme-logo-url" className="mb-2">
Custom Logo URL
{t("uiTheme.settings.logoUrl.label")}
</Label>
<Input
id="ui-theme-logo-url"
@ -145,13 +145,11 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
setLogoUrl(event.target.value || null);
}}
/>
<p className="mt-1 text-xs text-muted-foreground">
Enter a URL for your custom logo or leave empty for default
</p>
<p className="mt-1 text-xs text-muted-foreground">{t("uiTheme.settings.logoUrl.hint")}</p>
</div>
<div>
<Label htmlFor="ui-theme-logo-url-dark" className="mb-2">
Custom Logo URL (dark mode)
{t("uiTheme.settings.logoUrlDark.label")}
</Label>
<Input
id="ui-theme-logo-url-dark"
@ -162,13 +160,11 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
setLogoUrlDark(event.target.value || null);
}}
/>
<p className="mt-1 text-xs text-muted-foreground">
Enter a URL for a logo suited to dark backgrounds, or leave empty to reuse the logo above
</p>
<p className="mt-1 text-xs text-muted-foreground">{t("uiTheme.settings.logoUrlDark.hint")}</p>
</div>
<div>
<Label htmlFor="ui-theme-favicon-url" className="mb-2">
Custom Favicon URL
{t("uiTheme.settings.faviconUrl.label")}
</Label>
<Input
id="ui-theme-favicon-url"
@ -179,18 +175,16 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
setFaviconUrl(event.target.value || null);
}}
/>
<p className="mt-1 text-xs text-muted-foreground">
Enter a URL for your custom favicon (.ico, .png, or .svg) or leave empty for default
</p>
<p className="mt-1 text-xs text-muted-foreground">{t("uiTheme.settings.faviconUrl.hint")}</p>
</div>
<div className="flex gap-3 pt-4">
<Button onClick={handleSave} disabled={loading}>
{loading && <UiLoadingSpinner className="size-4" />}
Save Changes
{t("uiTheme.settings.saveButton")}
</Button>
<Button variant="outline" onClick={handleReset} disabled={loading}>
{loading && <UiLoadingSpinner className="size-4" />}
Reset to Default
{t("uiTheme.settings.resetButton")}
</Button>
</div>
</CardContent>

View file

@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import WorkflowRuns from "./WorkflowRuns";
import { DeprecationBanner } from "@/components/DeprecationBanner";
import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice";
@ -7,16 +8,17 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useCan from "@/app/(dashboard)/hooks/useCan";
export default function Workflows() {
const { t } = useTranslation();
const { accessToken } = useAuthorized();
const canViewWorkflowRuns = useCan("viewWorkflowRuns");
if (!canViewWorkflowRuns) {
return <AdminOnlyNotice pageTitle="Workflow Runs" />;
return <AdminOnlyNotice pageTitle={t("workflows.page.title")} />;
}
return (
<>
<DeprecationBanner featureName="Workflows" />
<DeprecationBanner featureName={t("workflows.page.featureName")} />
<WorkflowRuns accessToken={accessToken} />
</>
);

View file

@ -27,17 +27,20 @@ import { consumeReturnUrl, getLoginUrl, getReturnUrl, isValidReturnUrl } from "@
import { CircleAlert, Info, TriangleAlert, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useId, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { z } from "zod";
import { useWorker } from "@/hooks/useWorker";
import i18n from "@/i18n/i18n";
const loginSchema = z.object({
username: z.string().min(1, "Please enter your username"),
password: z.string().min(1, "Please enter your password"),
username: z.string().min(1, { error: () => i18n.t("login.schema.usernameRequired") }),
password: z.string().min(1, { error: () => i18n.t("login.schema.passwordRequired") }),
});
type LoginFormValues = z.infer<typeof loginSchema>;
function SsoEnabledNotice() {
const { t } = useTranslation();
const [dismissed, setDismissed] = useState(false);
if (dismissed) {
@ -48,13 +51,22 @@ function SsoEnabledNotice() {
<Alert variant="info" className="mt-4">
<Info />
<AlertTitle>
Single Sign-On (SSO) is enabled. LiteLLM no longer automatically redirects to the SSO login flow upon loading
this page. To re-enable auto-redirect-to-SSO, set{" "}
<code className="bg-muted px-1 py-0.5 rounded-sm text-xs">AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</code> in your
environment configuration.
<Trans
i18nKey="login.ssoNotice.title"
components={{
setting: (
<code className="bg-muted px-1 py-0.5 rounded-sm text-xs">AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</code>
),
}}
/>
</AlertTitle>
<AlertAction>
<Button variant="ghost" size="icon-sm" aria-label="Close" onClick={() => setDismissed(true)}>
<Button
variant="ghost"
size="icon-sm"
aria-label={t("login.ssoNotice.close")}
onClick={() => setDismissed(true)}
>
<X className="size-4" />
</Button>
</AlertAction>
@ -63,6 +75,7 @@ function SsoEnabledNotice() {
}
function LoginPageContent() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(true);
const { data: uiConfig, isLoading: isConfigLoading } = useUIConfig();
const loginMutation = useLogin();
@ -203,12 +216,9 @@ function LoginPageContent() {
<Alert variant="warning">
<TriangleAlert />
<AlertTitle>Admin UI Disabled</AlertTitle>
<AlertTitle>{t("login.adminDisabled.title")}</AlertTitle>
<AlertDescription>
<p className="text-sm">
The Admin UI has been disabled by the administrator. To re-enable it, please update the following
environment variable:
</p>
<p className="text-sm">{t("login.adminDisabled.description")}</p>
<p className="mt-2 text-sm">
<code className="bg-muted px-1 py-0.5 rounded-sm text-xs">DISABLE_ADMIN_UI=False</code>
</p>
@ -232,26 +242,33 @@ function LoginPageContent() {
</div>
<div className="text-center">
<h3 className="text-2xl font-semibold text-foreground">Login</h3>
<p className="text-sm text-muted-foreground">Access your LiteLLM Admin UI.</p>
<h3 className="text-2xl font-semibold text-foreground">{t("login.form.title")}</h3>
<p className="text-sm text-muted-foreground">{t("login.form.subtitle")}</p>
</div>
{!uiConfig?.hide_default_credentials_hint && (
<Alert variant="info">
<Info />
<AlertTitle>Default Credentials</AlertTitle>
<AlertTitle>{t("login.form.defaultCredentials.title")}</AlertTitle>
<AlertDescription>
<p className="text-sm">
By default, Username is <code className="bg-muted px-1 py-0.5 rounded-sm text-xs">admin</code> and
Password is your set LiteLLM Proxy
<code className="bg-muted px-1 py-0.5 rounded-sm text-xs">MASTER_KEY</code>.
<Trans
i18nKey="login.form.defaultCredentials.body"
components={{
username: <code className="bg-muted px-1 py-0.5 rounded-sm text-xs">admin</code>,
masterkey: <code className="bg-muted px-1 py-0.5 rounded-sm text-xs">MASTER_KEY</code>,
}}
/>
</p>
<p className="mt-2 text-sm">
Need to set UI credentials or SSO?{" "}
<a href="https://docs.litellm.ai/docs/proxy/ui" target="_blank" rel="noopener noreferrer">
Check the documentation
</a>
.
<Trans
i18nKey="login.form.defaultCredentials.help"
components={{
docs: (
<a href="https://docs.litellm.ai/docs/proxy/ui" target="_blank" rel="noopener noreferrer" />
),
}}
/>
</p>
</AlertDescription>
</Alert>
@ -268,14 +285,14 @@ function LoginPageContent() {
<FieldGroup>
{uiConfig?.is_control_plane && workers.length > 0 && (
<Field>
<FieldLabel htmlFor={workerFieldId}>Worker</FieldLabel>
<FieldLabel htmlFor={workerFieldId}>{t("login.form.worker.label")}</FieldLabel>
<Select
items={workers.map((worker) => ({ label: worker.name, value: worker.worker_id }))}
value={selectedWorkerId}
onValueChange={(value: string | null) => setSelectedWorkerId(value)}
>
<SelectTrigger id={workerFieldId} className="h-10 w-full">
<SelectValue placeholder="Choose a worker to connect to" />
<SelectValue placeholder={t("login.form.worker.placeholder")} />
</SelectTrigger>
<SelectContent>
{workers.map((worker) => (
@ -288,12 +305,12 @@ function LoginPageContent() {
</Field>
)}
<FormField control={form.control} name="username" label="Username">
<FormField control={form.control} name="username" label={t("login.form.username.label")}>
{({ ref, ...field }) => (
<Input
{...field}
ref={ref}
placeholder="Enter your username"
placeholder={t("login.form.username.placeholder")}
autoComplete="username"
disabled={isLoginLoading}
className="h-10 rounded-md"
@ -301,12 +318,12 @@ function LoginPageContent() {
)}
</FormField>
<FormField control={form.control} name="password" label="Password">
<FormField control={form.control} name="password" label={t("login.form.password.label")}>
{({ ref, ...field }) => (
<PasswordInput
{...field}
ref={ref}
placeholder="Enter your password"
placeholder={t("login.form.password.placeholder")}
autoComplete="current-password"
disabled={isLoginLoading}
groupClassName="h-10"
@ -315,18 +332,20 @@ function LoginPageContent() {
</FormField>
<Button type="submit" size="lg" disabled={isLoginLoading} className="w-full">
{isLoginLoading && <UiLoadingSpinner className="size-4" role="img" aria-label="loading" />}
{isLoginLoading ? "Logging in..." : "Login"}
{isLoginLoading && (
<UiLoadingSpinner className="size-4" role="img" aria-label={t("login.form.loading")} />
)}
{isLoginLoading ? t("login.form.submitting") : t("login.form.submit")}
</Button>
{!uiConfig?.sso_configured ? (
<Tooltip>
<TooltipTrigger render={<span className="block w-full" />}>
<Button type="button" variant="outline" size="lg" disabled className="w-full">
Login with SSO
{t("login.form.sso")}
</Button>
</TooltipTrigger>
<TooltipContent>Please configure SSO to log in with SSO.</TooltipContent>
<TooltipContent>{t("login.form.ssoUnavailable")}</TooltipContent>
</Tooltip>
) : (
<Button
@ -349,7 +368,7 @@ function LoginPageContent() {
}}
className="w-full"
>
Login with SSO
{t("login.form.sso")}
</Button>
)}
</FieldGroup>

View file

@ -2,6 +2,7 @@
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import PublicAiHub from "@/components/publicModelHub/PublicAiHub";
@ -11,8 +12,13 @@ function PublicAiHubPage() {
}
export default function PublicModelHub() {
const { t } = useTranslation();
return (
<Suspense fallback={<div className="flex min-h-screen items-center justify-center">Loading...</div>}>
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">{t("publicModelHub.modelHubPage.loading")}</div>
}
>
<PublicAiHubPage />
</Suspense>
);

View file

@ -2,6 +2,7 @@
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import PublicAiHub from "@/components/publicModelHub/PublicAiHub";
@ -11,8 +12,15 @@ function PublicAiHubPage() {
}
export default function PublicModelHubTable() {
const { t } = useTranslation();
return (
<Suspense fallback={<div className="flex min-h-screen items-center justify-center">Loading...</div>}>
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
{t("publicModelHub.modelHubTablePage.loading")}
</div>
}
>
<PublicAiHubPage />
</Suspense>
);

View file

@ -1,20 +1,22 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { CircleAlert } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { buttonVariants } from "@/components/ui/button";
import { getLoginUrl } from "@/utils/returnUrlUtils";
export function OnboardingErrorView() {
const { t } = useTranslation();
return (
<div className="mx-auto w-full max-w-md mt-10">
<Alert variant="error">
<CircleAlert />
<AlertTitle>Failed to load invitation</AlertTitle>
<AlertDescription>The invitation link may be invalid or expired.</AlertDescription>
<AlertTitle>{t("onboarding.errorView.title")}</AlertTitle>
<AlertDescription>{t("onboarding.errorView.description")}</AlertDescription>
</Alert>
<div className="mt-4">
<a href={getLoginUrl()} className={buttonVariants({ variant: "outline" })}>
Back to Login
{t("onboarding.errorView.back")}
</a>
</div>
</div>

View file

@ -1,5 +1,6 @@
"use client";
import React from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "next/navigation";
import { jwtDecode } from "jwt-decode";
import { useOnboardingCredentials, useClaimOnboardingToken } from "@/app/(dashboard)/hooks/onboarding/useOnboarding";
@ -14,6 +15,7 @@ type OnboardingFormProps = {
};
export function OnboardingForm({ variant }: OnboardingFormProps) {
const { t } = useTranslation();
const searchParams = useSearchParams()!;
const inviteId = searchParams.get("invitation_id");
const [claimError, setClaimError] = React.useState<string | null>(null);
@ -41,7 +43,7 @@ export function OnboardingForm({ variant }: OnboardingFormProps) {
{
onSuccess: (data: { token?: string }) => {
if (!data?.token) {
setClaimError("Failed to start session. Please try again.");
setClaimError(t("onboarding.form.errors.sessionFailed"));
return;
}
// Invite signup is a principal-change boundary — the prior admin's
@ -56,7 +58,7 @@ export function OnboardingForm({ variant }: OnboardingFormProps) {
);
},
onError: (error: Error) => {
setClaimError(error.message || "Failed to submit. Please try again.");
setClaimError(error.message || t("onboarding.form.errors.submitFailed"));
},
},
);

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { CircleAlert, Info } from "lucide-react";
import { z } from "zod";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
@ -11,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { cn } from "@/lib/cva.config";
import { useZodForm } from "@/lib/forms/useZodForm";
import i18n from "@/i18n/i18n";
type OnboardingFormBodyProps = {
variant: "signup" | "reset_password";
@ -21,16 +23,17 @@ type OnboardingFormBodyProps = {
};
const onboardingSchema = z.object({
password: z.string().min(1, "password required to sign up"),
password: z.string().min(1, { error: () => i18n.t("onboarding.formBody.schema.passwordRequired") }),
});
type OnboardingFormValues = z.infer<typeof onboardingSchema>;
export function OnboardingFormBody({ variant, userEmail, isPending, claimError, onSubmit }: OnboardingFormBodyProps) {
const { t } = useTranslation();
const form = useZodForm(onboardingSchema, { defaultValues: { password: "" } });
const emailFieldId = React.useId();
const isResetPassword = variant === "reset_password";
const actionLabel = isResetPassword ? "Reset Password" : "Sign Up";
const actionLabel = isResetPassword ? t("onboarding.formBody.resetPassword") : t("onboarding.formBody.signUp");
const handleSubmit = (values: OnboardingFormValues) => onSubmit({ password: values.password });
@ -41,25 +44,23 @@ export function OnboardingFormBody({ variant, userEmail, isPending, claimError,
<h5 className="text-center mb-5 text-base font-semibold text-foreground">🚅 LiteLLM</h5>
<h3 className="text-2xl font-semibold text-foreground">{actionLabel}</h3>
<p className="text-sm text-foreground">
{isResetPassword
? "Reset your password to access Admin UI."
: "Claim your user account to login to Admin UI."}
{isResetPassword ? t("onboarding.formBody.resetDescription") : t("onboarding.formBody.signUpDescription")}
</p>
{variant === "signup" && (
<Alert className="mt-4" variant="info">
<Info />
<AlertTitle>SSO</AlertTitle>
<AlertTitle>{t("onboarding.formBody.sso.title")}</AlertTitle>
<AlertDescription>
<div className="flex justify-between items-center">
<span>SSO is under the Enterprise Tier.</span>
<span>{t("onboarding.formBody.sso.description")}</span>
<a
className={cn(buttonVariants({ size: "sm" }))}
href="https://forms.gle/W3U4PZpJGFHWtHyA9"
target="_blank"
rel="noopener noreferrer"
>
Get Free Trial
{t("onboarding.formBody.sso.trial")}
</a>
</div>
</AlertDescription>
@ -69,15 +70,19 @@ export function OnboardingFormBody({ variant, userEmail, isPending, claimError,
<form className="mt-10 mb-5" onSubmit={form.handleSubmit(handleSubmit)}>
<FieldGroup>
<Field>
<FieldLabel htmlFor={emailFieldId}>Email Address</FieldLabel>
<FieldLabel htmlFor={emailFieldId}>{t("onboarding.formBody.email")}</FieldLabel>
<Input id={emailFieldId} type="email" value={userEmail} readOnly disabled />
</Field>
<FormField
control={form.control}
name="password"
label="Password"
description={isResetPassword ? "Enter your new password" : "Create a password for your account"}
label={t("onboarding.formBody.password.label")}
description={
isResetPassword
? t("onboarding.formBody.password.resetHint")
: t("onboarding.formBody.password.signUpHint")
}
>
{({ ref, ...field }) => <PasswordInput {...field} ref={ref} />}
</FormField>
@ -92,7 +97,9 @@ export function OnboardingFormBody({ variant, userEmail, isPending, claimError,
<div className="mt-10">
<Button type="submit" variant="outline" disabled={isPending}>
{isPending && <UiLoadingSpinner className="size-4" role="img" aria-label="loading" />}
{isPending && (
<UiLoadingSpinner className="size-4" role="img" aria-label={t("onboarding.formBody.loading")} />
)}
{actionLabel}
</Button>
</div>

View file

@ -1,10 +1,16 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
export function OnboardingLoadingView() {
const { t } = useTranslation();
return (
<div className="mx-auto w-full max-w-md mt-10 flex justify-center">
<UiLoadingSpinner role="status" aria-label="Loading invitation" className="size-8 text-muted-foreground" />
<UiLoadingSpinner
role="status"
aria-label={t("onboarding.loadingView.label")}
className="size-8 text-muted-foreground"
/>
</div>
);
}

View file

@ -1,6 +1,7 @@
"use client";
import React, { Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import { OnboardingForm } from "./OnboardingForm";
function OnboardingContent() {
@ -11,8 +12,11 @@ function OnboardingContent() {
}
export default function Onboarding() {
const { t } = useTranslation();
return (
<Suspense fallback={<div className="flex items-center justify-center min-h-screen">Loading...</div>}>
<Suspense
fallback={<div className="flex items-center justify-center min-h-screen">{t("onboarding.page.loading")}</div>}
>
<OnboardingContent />
</Suspense>
);

View file

@ -2,6 +2,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { Copy, Info, MoreHorizontal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { StatusBadge } from "@/components/shared/table_cells";
@ -15,6 +16,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
import i18n from "@/i18n/i18n";
import { copyToClipboard } from "@/utils/dataUtils";
export interface AgentHubData {
@ -48,10 +50,11 @@ interface AgentHubRowActionsProps {
}
function AgentHubRowActions({ agent, onAgentClick }: AgentHubRowActionsProps) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open agent actions"
aria-label={t("aiHub.agentHubColumns.rowActions.openActions")}
data-testid={`agent-hub-actions-${agent.agent_id || agent.name}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
@ -60,14 +63,14 @@ function AgentHubRowActions({ agent, onAgentClick }: AgentHubRowActionsProps) {
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem data-testid="agent-hub-action-details" onClick={() => onAgentClick(agent)}>
<Info />
View details
{t("aiHub.agentHubColumns.rowActions.viewDetails")}
</DropdownMenuItem>
<DropdownMenuItem
data-testid="agent-hub-action-copy"
onClick={() => void copyToClipboard(agent.name, "Agent name copied")}
onClick={() => void copyToClipboard(agent.name, t("aiHub.agentHubColumns.rowActions.nameCopied"))}
>
<Copy />
Copy agent name
{t("aiHub.agentHubColumns.rowActions.copyName")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -82,8 +85,8 @@ export const getAgentHubTableColumns = ({ onAgentClick }: AgentHubTableColumnsDe
{
id: "name",
accessorKey: "name",
meta: { title: "Agent Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Agent Name" />,
meta: { title: i18n.t("aiHub.agentHubColumns.name") },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.agentHubColumns.name")} />,
size: 200,
enableSorting: true,
sortingFn: "alphanumeric",
@ -94,8 +97,8 @@ export const getAgentHubTableColumns = ({ onAgentClick }: AgentHubTableColumnsDe
{
id: "description",
accessorKey: "description",
meta: { title: "Description", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Description" />,
meta: { title: i18n.t("aiHub.agentHubColumns.description"), className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.agentHubColumns.description")} />,
size: 240,
enableSorting: true,
sortingFn: "alphanumeric",
@ -108,8 +111,8 @@ export const getAgentHubTableColumns = ({ onAgentClick }: AgentHubTableColumnsDe
{
id: "version",
accessorKey: "version",
meta: { title: "Version", skeleton: "badge", className: "hidden lg:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Version" />,
meta: { title: i18n.t("aiHub.agentHubColumns.version"), skeleton: "badge", className: "hidden lg:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.agentHubColumns.version")} />,
size: 100,
enableSorting: true,
sortingFn: "alphanumeric",
@ -122,8 +125,8 @@ export const getAgentHubTableColumns = ({ onAgentClick }: AgentHubTableColumnsDe
{
id: "protocolVersion",
accessorKey: "protocolVersion",
meta: { title: "Protocol", className: "hidden lg:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Protocol" />,
meta: { title: i18n.t("aiHub.agentHubColumns.protocol"), className: "hidden lg:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.agentHubColumns.protocol")} />,
size: 100,
enableSorting: true,
sortingFn: "alphanumeric",
@ -131,8 +134,8 @@ export const getAgentHubTableColumns = ({ onAgentClick }: AgentHubTableColumnsDe
},
{
id: "skills",
meta: { title: "Skills", skeleton: "chips" },
header: "Skills",
meta: { title: i18n.t("aiHub.agentHubColumns.skills"), skeleton: "chips" },
header: i18n.t("aiHub.agentHubColumns.skills"),
size: 180,
enableSorting: false,
cell: ({ row }) => {
@ -140,7 +143,7 @@ export const getAgentHubTableColumns = ({ onAgentClick }: AgentHubTableColumnsDe
return (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium">
{skills.length} skill{skills.length !== 1 ? "s" : ""}
{i18n.t("aiHub.agentHubColumns.skillCount", { count: skills.length })}
</span>
{skills.length > 0 && (
<div className="flex flex-wrap gap-1">
@ -158,8 +161,8 @@ export const getAgentHubTableColumns = ({ onAgentClick }: AgentHubTableColumnsDe
},
{
id: "capabilities",
meta: { title: "Capabilities", skeleton: "chips" },
header: "Capabilities",
meta: { title: i18n.t("aiHub.agentHubColumns.capabilities"), skeleton: "chips" },
header: i18n.t("aiHub.agentHubColumns.capabilities"),
size: 160,
enableSorting: false,
cell: ({ row }) => {
@ -182,8 +185,8 @@ export const getAgentHubTableColumns = ({ onAgentClick }: AgentHubTableColumnsDe
},
{
id: "io_modes",
meta: { title: "I/O Modes", skeleton: "twoLine", className: "hidden xl:table-cell" },
header: "I/O Modes",
meta: { title: i18n.t("aiHub.agentHubColumns.ioModes"), skeleton: "twoLine", className: "hidden xl:table-cell" },
header: i18n.t("aiHub.agentHubColumns.ioModes"),
size: 150,
enableSorting: false,
cell: ({ row }) => {
@ -192,10 +195,12 @@ export const getAgentHubTableColumns = ({ onAgentClick }: AgentHubTableColumnsDe
return (
<div className="flex flex-col gap-0.5 text-xs">
<span>
<span className="font-medium">In:</span> {inputModes.join(", ") || "-"}
<span className="font-medium">{i18n.t("aiHub.agentHubColumns.inputLabel")}</span>{" "}
{inputModes.join(", ") || "-"}
</span>
<span>
<span className="font-medium">Out:</span> {outputModes.join(", ") || "-"}
<span className="font-medium">{i18n.t("aiHub.agentHubColumns.outputLabel")}</span>{" "}
{outputModes.join(", ") || "-"}
</span>
</div>
);
@ -204,8 +209,8 @@ export const getAgentHubTableColumns = ({ onAgentClick }: AgentHubTableColumnsDe
{
id: "is_public",
accessorKey: "is_public",
meta: { title: "Public", skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Public" />,
meta: { title: i18n.t("aiHub.agentHubColumns.public"), skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.agentHubColumns.public")} />,
size: 100,
enableSorting: true,
sortingFn: (rowA, rowB) => {
@ -215,13 +220,18 @@ export const getAgentHubTableColumns = ({ onAgentClick }: AgentHubTableColumnsDe
},
cell: ({ row }) => {
const isPublic = row.original.is_public === true;
return <StatusBadge tone={isPublic ? "success" : "neutral"} label={isPublic ? "Yes" : "No"} />;
return (
<StatusBadge
tone={isPublic ? "success" : "neutral"}
label={isPublic ? i18n.t("aiHub.agentHubColumns.yes") : i18n.t("aiHub.agentHubColumns.no")}
/>
);
},
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
header: () => <span className="sr-only">{i18n.t("aiHub.agentHubColumns.actions")}</span>,
size: 64,
enableSorting: false,
enableHiding: false,

View file

@ -1,5 +1,7 @@
import React, { useMemo, useState } from "react";
import type { ParseKeys } from "i18next";
import { BookOpen, Plus, Sparkles } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProviderFields";
@ -20,17 +22,18 @@ import { uiHref } from "@/utils/uiHref";
const PREVIEW_COUNT = 8;
const plural = (count: number, one: string, many: string): string => (count === 1 ? one : many);
const ROUTE_LINKS: Readonly<Record<DecisionRoute, { readonly href: string; readonly label: string }>> = {
decisions: { href: DECISIONS_DOCS_URL, label: "/v1/decisions docs" },
"self-hosted": { href: DECISIONS_SELF_HOSTED_DOCS_URL, label: "Self-hosted, own route" },
"needs-upgrade": { href: DECISIONS_PROVIDERS_DOCS_URL, label: "Needs a newer LiteLLM" },
const ROUTE_LINKS: Readonly<Record<DecisionRoute, { readonly href: string; readonly label: ParseKeys }>> = {
decisions: { href: DECISIONS_DOCS_URL, label: "aiHub.decisionModelGarden.routes.decisions" },
"self-hosted": { href: DECISIONS_SELF_HOSTED_DOCS_URL, label: "aiHub.decisionModelGarden.routes.selfHosted" },
"needs-upgrade": { href: DECISIONS_PROVIDERS_DOCS_URL, label: "aiHub.decisionModelGarden.routes.needsUpgrade" },
};
const DEPLOYMENT_NAMED_PROVIDERS: ReadonlySet<string> = new Set(["azure", "azure_ai"]);
const OWN_DEPLOYMENT_LINK = { href: DECISIONS_PROVIDERS_DOCS_URL, label: "Deploy under your own name" } as const;
const OWN_DEPLOYMENT_LINK = {
href: DECISIONS_PROVIDERS_DOCS_URL,
label: "aiHub.decisionModelGarden.routes.ownDeployment",
} as const;
const needsOwnDeploymentName = (row: DecisionGardenModel): boolean =>
row.route === "decisions" && DEPLOYMENT_NAMED_PROVIDERS.has(row.provider);
@ -49,6 +52,7 @@ interface DecisionModelCardProps {
}
function DecisionModelCard({ row, canAddModels }: DecisionModelCardProps) {
const { t } = useTranslation();
const { displayName } = getProviderLogoAndName(row.provider);
const link = cardLink(row);
return (
@ -70,12 +74,12 @@ function DecisionModelCard({ row, canAddModels }: DecisionModelCardProps) {
rel="noopener noreferrer"
className="text-xs text-muted-foreground underline"
>
{link.label}
{t(link.label)}
</a>
{canAddModels && (
<Button size="xs" variant="outline" nativeButton={false} role="link" render={<a href={addModelHref(row)} />}>
<Plus />
Add model
{t("aiHub.decisionModelGarden.addModel")}
</Button>
)}
</div>
@ -89,6 +93,7 @@ interface DecisionModelGardenProps {
}
const DecisionModelGarden: React.FC<DecisionModelGardenProps> = ({ accessToken, canAddModels }) => {
const { t } = useTranslation();
const { data: costMap } = useModelCostMap(true, true);
const { data: providerFields } = useProviderFields();
const { data: proxyProviders, isLoading: proxyProvidersLoading } = useProxyDecisionProviders(accessToken);
@ -118,15 +123,16 @@ const DecisionModelGarden: React.FC<DecisionModelGardenProps> = ({ accessToken,
<Sparkles className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<h3 className="m-0 text-base font-semibold text-foreground">Decision models LiteLLM supports</h3>
<h3 className="m-0 text-base font-semibold text-foreground">{t("aiHub.decisionModelGarden.title")}</h3>
<p className="m-0 mt-0.5 text-sm text-muted-foreground">
{routedRows.length} decision {plural(routedRows.length, "model", "models")} across {routedProviderCount}{" "}
{plural(routedProviderCount, "provider", "providers")} {plural(routedRows.length, "answers", "answer")} on{" "}
<code>/v1/decisions</code>.
{selfHostedCount > 0 &&
` ${selfHostedCount} more ${plural(selfHostedCount, "is self-hosted and answers on its own route", "are self-hosted and answer on their own routes")}.`}
{needsUpgradeCount > 0 &&
` ${needsUpgradeCount} more ${plural(needsUpgradeCount, "needs", "need")} a newer LiteLLM version.`}
<Trans
i18nKey="aiHub.decisionModelGarden.summary"
count={routedRows.length}
values={{ providers: routedProviderCount }}
components={{ code: <code /> }}
/>
{selfHostedCount > 0 && ` ${t("aiHub.decisionModelGarden.selfHosted", { count: selfHostedCount })}`}
{needsUpgradeCount > 0 && ` ${t("aiHub.decisionModelGarden.needsUpgrade", { count: needsUpgradeCount })}`}
</p>
</div>
<Button
@ -137,7 +143,7 @@ const DecisionModelGarden: React.FC<DecisionModelGardenProps> = ({ accessToken,
render={<a href={DECISIONS_DOCS_URL} target="_blank" rel="noopener noreferrer" />}
>
<BookOpen />
Decisions docs
{t("aiHub.decisionModelGarden.docs")}
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
@ -148,7 +154,9 @@ const DecisionModelGarden: React.FC<DecisionModelGardenProps> = ({ accessToken,
{rows.length > PREVIEW_COUNT && (
<div className="mt-4 flex justify-center">
<Button variant="ghost" type="button" onClick={() => setShowAll((value) => !value)}>
{showAll ? "Show fewer" : `Show all ${rows.length}`}
{showAll
? t("aiHub.decisionModelGarden.showFewer")
: t("aiHub.decisionModelGarden.showAll", { total: rows.length })}
</Button>
</div>
)}

View file

@ -2,6 +2,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { Copy, Info, MoreHorizontal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { MCP_REACHABLE_DESCRIPTION } from "@/components/mcp_tools/types";
@ -15,6 +16,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
import i18n from "@/i18n/i18n";
import { copyToClipboard } from "@/utils/dataUtils";
export interface MCPServerData {
@ -60,10 +62,11 @@ interface MCPHubRowActionsProps {
}
function MCPHubRowActions({ server, onServerClick }: MCPHubRowActionsProps) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open MCP server actions"
aria-label={t("aiHub.mcpHubColumns.rowActions.openActions")}
data-testid={`mcp-hub-actions-${server.server_id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
@ -72,14 +75,14 @@ function MCPHubRowActions({ server, onServerClick }: MCPHubRowActionsProps) {
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem data-testid="mcp-hub-action-details" onClick={() => onServerClick(server)}>
<Info />
View details
{t("aiHub.mcpHubColumns.rowActions.viewDetails")}
</DropdownMenuItem>
<DropdownMenuItem
data-testid="mcp-hub-action-copy"
onClick={() => void copyToClipboard(server.server_name, "Server name copied")}
onClick={() => void copyToClipboard(server.server_name, t("aiHub.mcpHubColumns.rowActions.nameCopied"))}
>
<Copy />
Copy server name
{t("aiHub.mcpHubColumns.rowActions.copyName")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -94,8 +97,8 @@ export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps)
{
id: "server_name",
accessorKey: "server_name",
meta: { title: "Server Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Server Name" />,
meta: { title: i18n.t("aiHub.mcpHubColumns.serverName") },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.mcpHubColumns.serverName")} />,
size: 200,
enableSorting: true,
sortingFn: "alphanumeric",
@ -106,8 +109,8 @@ export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps)
{
id: "description",
accessorKey: "description",
meta: { title: "Description", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Description" />,
meta: { title: i18n.t("aiHub.mcpHubColumns.description"), className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.mcpHubColumns.description")} />,
size: 240,
enableSorting: true,
sortingFn: "alphanumeric",
@ -120,8 +123,8 @@ export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps)
{
id: "transport",
accessorKey: "transport",
meta: { title: "Transport", skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Transport" />,
meta: { title: i18n.t("aiHub.mcpHubColumns.transport"), skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.mcpHubColumns.transport")} />,
size: 110,
enableSorting: true,
sortingFn: "alphanumeric",
@ -134,8 +137,8 @@ export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps)
{
id: "auth_type",
accessorKey: "auth_type",
meta: { title: "Auth Type", skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Auth Type" />,
meta: { title: i18n.t("aiHub.mcpHubColumns.authType"), skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.mcpHubColumns.authType")} />,
size: 110,
enableSorting: true,
sortingFn: "alphanumeric",
@ -146,23 +149,23 @@ export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps)
{
id: "status",
accessorKey: "status",
meta: { title: "Status", skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title="Status" />,
meta: { title: i18n.t("aiHub.mcpHubColumns.status"), skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.mcpHubColumns.status")} />,
size: 110,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<StatusBadge
tone={STATUS_TONES[row.original.status] || "neutral"}
label={row.original.status || "unknown"}
label={row.original.status || i18n.t("aiHub.mcpHubColumns.statusUnknown")}
tooltip={row.original.status === "reachable" ? MCP_REACHABLE_DESCRIPTION : undefined}
/>
),
},
{
id: "allowed_tools",
meta: { title: "Tools", skeleton: "chips", className: "hidden lg:table-cell" },
header: "Tools",
meta: { title: i18n.t("aiHub.mcpHubColumns.tools"), skeleton: "chips", className: "hidden lg:table-cell" },
header: i18n.t("aiHub.mcpHubColumns.tools"),
size: 180,
enableSorting: false,
cell: ({ row }) => {
@ -170,7 +173,9 @@ export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps)
return (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium">
{tools.length > 0 ? `${tools.length} tool${tools.length !== 1 ? "s" : ""}` : "All tools"}
{tools.length > 0
? i18n.t("aiHub.mcpHubColumns.toolCount", { count: tools.length })
: i18n.t("aiHub.mcpHubColumns.allTools")}
</span>
{tools.length > 0 && (
<div className="flex flex-wrap gap-1">
@ -189,8 +194,8 @@ export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps)
{
id: "created_by",
accessorKey: "created_by",
meta: { title: "Created By", className: "hidden xl:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created By" />,
meta: { title: i18n.t("aiHub.mcpHubColumns.createdBy"), className: "hidden xl:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.mcpHubColumns.createdBy")} />,
size: 140,
enableSorting: true,
sortingFn: "alphanumeric",
@ -203,8 +208,8 @@ export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps)
{
id: "is_public",
accessorFn: (row) => row.mcp_info?.is_public === true,
meta: { title: "Hub listing", skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Hub listing" />,
meta: { title: i18n.t("aiHub.mcpHubColumns.hubListing"), skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.mcpHubColumns.hubListing")} />,
size: 100,
enableSorting: true,
sortingFn: (rowA, rowB) => {
@ -214,13 +219,18 @@ export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps)
},
cell: ({ row }) => {
const isPublic = row.original.mcp_info?.is_public === true;
return <StatusBadge tone={isPublic ? "success" : "neutral"} label={isPublic ? "Listed" : "Unlisted"} />;
return (
<StatusBadge
tone={isPublic ? "success" : "neutral"}
label={isPublic ? i18n.t("aiHub.mcpHubColumns.listed") : i18n.t("aiHub.mcpHubColumns.unlisted")}
/>
);
},
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
header: () => <span className="sr-only">{i18n.t("aiHub.mcpHubColumns.actions")}</span>,
size: 64,
enableSorting: false,
enableHiding: false,

View file

@ -1,3 +1,5 @@
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
export type ModelCustomMetadataValue = string | number | boolean;
@ -20,19 +22,20 @@ export function ModelCustomTags({ tags, className = "" }: { tags: string[]; clas
}
export function ModelCustomMetadata({ customTags, customMetadata }: ModelCustomMetadataProps) {
const { t } = useTranslation();
const tags = customTags ?? [];
const entries = Object.entries(customMetadata ?? {});
return (
<>
{tags.length > 0 && (
<div className="col-span-2">
<p className="font-medium">Tags:</p>
<p className="font-medium">{t("aiHub.modelCustomMetadata.tags")}</p>
<ModelCustomTags tags={tags} className="mt-1" />
</div>
)}
{entries.length > 0 && (
<div className="col-span-2">
<p className="font-medium">Custom Metadata:</p>
<p className="font-medium">{t("aiHub.modelCustomMetadata.customMetadata")}</p>
<dl className="mt-1 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1">
{entries.map(([key, value]) => (
<div key={key} className="contents">

View file

@ -2,6 +2,7 @@
import Image from "next/image";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -55,12 +56,13 @@ export default function ModelHubProviders({
onSelect,
loading = false,
}: ModelHubProvidersProps) {
const { t } = useTranslation();
const availableProviders = Array.from(new Set(providers)).filter(Boolean);
const selectedProvider = selectedProviders[0] ?? "";
const initialLoading = loading && availableProviders.length === 0;
const emptyLabel = initialLoading ? "Loading providers" : "All providers";
const emptyLabel = initialLoading ? t("aiHub.modelHubProviders.loading") : t("aiHub.modelHubProviders.allProviders");
const items = [
{ value: "", label: "All providers" },
{ value: "", label: t("aiHub.modelHubProviders.allProviders") },
...availableProviders.map((provider) => ({
value: provider,
label: getProviderLogoAndName(provider).displayName,
@ -75,7 +77,7 @@ export default function ModelHubProviders({
disabled={initialLoading}
>
<SelectTrigger
aria-label="Provider"
aria-label={t("aiHub.modelHubProviders.ariaLabel")}
aria-busy={loading}
className="chat-model-filter-select chat-model-provider-select"
>

View file

@ -1,4 +1,5 @@
import { useTerminology } from "@/contexts/TerminologyContext";
import i18n from "@/i18n/i18n";
import { AgentHubData, getAgentHubTableColumns } from "@/components/AIHub/AgentHubTableColumns";
import MakeAgentPublicForm from "@/components/AIHub/forms/MakeAgentPublicForm";
import MakeMCPPublicForm from "@/components/AIHub/forms/MakeMCPPublicForm";
@ -38,6 +39,7 @@ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Copy, Inbox, Search as SearchIcon, X } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { prism } from "react-syntax-highlighter/dist/esm/styles/prism";
@ -78,13 +80,19 @@ function modelTableEmptyState(
): { title: string; body: string } {
if (showsDecisionGarden && selectedMode === DECISIONS_MODE && !models.some((model) => isDecisionMode(model.mode))) {
return {
title: "No model on this proxy uses the decisions mode",
body: "The decision models LiteLLM supports are listed below.",
title: i18n.t("aiHub.modelHubTable.models.empty.decisionsTitle"),
body: i18n.t("aiHub.modelHubTable.models.empty.decisionsBody"),
};
}
return models.length > 0
? { title: "No matching models", body: "Adjust the filters to see more models." }
: { title: "No models yet", body: "Models added to this proxy will appear here." };
? {
title: i18n.t("aiHub.modelHubTable.models.empty.noMatchTitle"),
body: i18n.t("aiHub.modelHubTable.models.empty.noMatchBody"),
}
: {
title: i18n.t("aiHub.modelHubTable.models.empty.noneTitle"),
body: i18n.t("aiHub.modelHubTable.models.empty.noneBody"),
};
}
function HubEmptyState({ title, body }: { title: string; body: string }) {
@ -109,6 +117,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
variant = "dashboard",
}) => {
const term = useTerminology();
const { t } = useTranslation();
const syntaxTheme = useSyntaxTheme(prism);
const isChat = variant === "chat";
// Admin Viewer follows the read-parity rule: see the AI Hub catalog, but
@ -459,25 +468,21 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{!isChat && (
<div className="flex justify-between items-center mb-6">
<div className="flex flex-col items-start">
<h2 className="text-center text-xl font-semibold">{term("AI Hub")}</h2>
<h2 className="text-center text-xl font-semibold">{term(t("aiHub.modelHubTable.title"))}</h2>
{isAdminRole(userRole || "") ? (
<p className="text-sm text-muted-foreground">
Make models, agents, and MCP servers public for developers to know what&apos;s available.
</p>
<p className="text-sm text-muted-foreground">{t("aiHub.modelHubTable.adminDescription")}</p>
) : (
<p className="text-sm text-muted-foreground">
A list of all public model names personally available to you.
</p>
<p className="text-sm text-muted-foreground">{t("aiHub.modelHubTable.userDescription")}</p>
)}
</div>
<div className="flex items-center space-x-4">
<p>Model Hub URL:</p>
<p>{t("aiHub.modelHubTable.hubUrlLabel")}</p>
<div className="flex items-center bg-border px-2 py-1 rounded-sm">
<p className="mr-2">{getUiPageUrl(`${getProxyBaseUrl()}/ui/model_hub_table`)}</p>
<button
onClick={() => void copyToClipboard(getUiPageUrl(`${getProxyBaseUrl()}/ui/model_hub_table`))}
className="p-1 hover:bg-accent rounded-sm transition-colors"
title="Copy URL"
title={t("aiHub.modelHubTable.copyUrl")}
>
<Copy size={16} className="text-muted-foreground" />
</button>
@ -498,16 +503,16 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{!isChat && (
<TabsList variant="line" className="mb-4 h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="models" className="flex-none rounded-none px-4 py-2">
Model Hub
{t("aiHub.modelHubTable.tabs.models")}
</TabsTrigger>
<TabsTrigger value="agents" className="flex-none rounded-none px-4 py-2">
Agent Hub
{t("aiHub.modelHubTable.tabs.agents")}
</TabsTrigger>
<TabsTrigger value="mcp" className="flex-none rounded-none px-4 py-2">
MCP Hub
{t("aiHub.modelHubTable.tabs.mcp")}
</TabsTrigger>
<TabsTrigger value="skills" className="flex-none rounded-none px-4 py-2">
Skill Hub
{t("aiHub.modelHubTable.tabs.skills")}
</TabsTrigger>
</TabsList>
)}
@ -519,8 +524,11 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
<Card className={isChat ? "chat-model-hub-card" : "px-6"}>
{isChat && (
<div className="chat-model-section-heading">
<h2>Models</h2>
<span className="chat-model-section-count" aria-label={`${filteredData.length} models`}>
<h2>{t("aiHub.modelHubTable.models.chatTitle")}</h2>
<span
className="chat-model-section-count"
aria-label={t("aiHub.modelHubTable.models.chatCount", { shown: filteredData.length })}
>
{filteredData.length}
</span>
</div>
@ -528,7 +536,9 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Header with Make Public Button */}
{publicPage == false && canModify && (
<div className="flex justify-end mb-4">
<Button onClick={() => handleMakePublicPage()}>Select Models to Make Public</Button>
<Button onClick={() => handleMakePublicPage()}>
{t("aiHub.modelHubTable.models.makePublic")}
</Button>
</div>
)}
@ -555,7 +565,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
sorting={modelSorting}
onSortingChange={setModelSorting}
isLoading={loading}
loadingMessage="Loading models…"
loadingMessage={t("aiHub.modelHubTable.models.loading")}
noDataMessage={
<HubEmptyState {...modelTableEmptyState(modelHubData ?? [], showDecisionGarden, selectedMode)} />
}
@ -566,7 +576,10 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{!isChat && (
<div className="mt-4 text-center space-y-2">
<p className="text-sm text-muted-foreground">
Showing {filteredData.length} of {modelHubData?.length || 0} models
{t("aiHub.modelHubTable.models.showing", {
shown: filteredData.length,
total: modelHubData?.length || 0,
})}
</p>
</div>
)}
@ -580,18 +593,20 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Header with Make Public Button */}
{publicPage == false && canModify && (
<div className="flex justify-end mb-4">
<Button onClick={() => handleMakeAgentPublicPage()}>Select Agents to Make Public</Button>
<Button onClick={() => handleMakeAgentPublicPage()}>
{t("aiHub.modelHubTable.agents.makePublic")}
</Button>
</div>
)}
<div className="mb-4">
<p className="text-sm font-medium mb-2">Search Agents:</p>
<p className="text-sm font-medium mb-2">{t("aiHub.modelHubTable.agents.searchLabel")}</p>
<InputGroup className="max-w-sm">
<InputGroupAddon>
<SearchIcon className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search agent names or descriptions..."
placeholder={t("aiHub.modelHubTable.agents.searchPlaceholder")}
value={agentSearchTerm}
onChange={(e) => setAgentSearchTerm(e.target.value)}
/>
@ -599,7 +614,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label="Clear search"
aria-label={t("aiHub.modelHubTable.agents.clearSearch")}
onClick={() => setAgentSearchTerm("")}
>
<X />
@ -619,14 +634,18 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
sorting={agentSorting}
onSortingChange={setAgentSorting}
isLoading={agentLoading}
loadingMessage="Loading agents…"
loadingMessage={t("aiHub.modelHubTable.agents.loading")}
noDataMessage={
<HubEmptyState
title={agentHubData?.length ? "No matching agents" : "No agents yet"}
title={
agentHubData?.length
? t("aiHub.modelHubTable.agents.noMatchTitle")
: t("aiHub.modelHubTable.agents.noneTitle")
}
body={
agentHubData?.length
? "Adjust the search to see more agents."
: "Agents added to this proxy will appear here."
? t("aiHub.modelHubTable.agents.noMatchBody")
: t("aiHub.modelHubTable.agents.noneBody")
}
/>
}
@ -636,7 +655,10 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
<div className="mt-4 text-center space-y-2">
<p className="text-sm text-muted-foreground">
Showing {filteredAgentData.length} of {agentHubData?.length || 0} agents
{t("aiHub.modelHubTable.agents.showing", {
shown: filteredAgentData.length,
total: agentHubData?.length || 0,
})}
</p>
</div>
</TabsContent>
@ -651,7 +673,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
onClick={() => handleMakeMcpPublicPage()}
disabled={isMCPHubVisibilityDisabled(mcpLoading, mcpHubData)}
>
Manage MCP Hub Visibility
{t("aiHub.modelHubTable.mcp.manageVisibility")}
</Button>
</div>
)}
@ -666,11 +688,11 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
sorting={mcpSorting}
onSortingChange={setMcpSorting}
isLoading={mcpLoading}
loadingMessage="Loading MCP servers…"
loadingMessage={t("aiHub.modelHubTable.mcp.loading")}
noDataMessage={
<HubEmptyState
title="No MCP servers yet"
body="MCP servers added to this proxy will appear here."
title={t("aiHub.modelHubTable.mcp.noneTitle")}
body={t("aiHub.modelHubTable.mcp.noneBody")}
/>
}
size="compact"
@ -679,7 +701,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
<div className="mt-4 text-center space-y-2">
<p className="text-sm text-muted-foreground">
Showing {mcpHubData?.length || 0} MCP server{mcpHubData?.length !== 1 ? "s" : ""}
{t("aiHub.modelHubTable.mcp.showing", { count: mcpHubData?.length || 0 })}
</p>
</div>
</TabsContent>
@ -688,7 +710,9 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
<TabsContent value="skills" keepMounted>
{publicPage == false && canModify && (
<div className="flex justify-end mb-4">
<Button onClick={() => setIsMakeSkillPublicModalVisible(true)}>Select Skills to Make Public</Button>
<Button onClick={() => setIsMakeSkillPublicModalVisible(true)}>
{t("aiHub.modelHubTable.skills.makePublic")}
</Button>
</div>
)}
<SkillHubDashboard
@ -708,10 +732,8 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
</div>
) : (
<Card className="mx-auto max-w-xl mt-10 px-6">
<p className="text-xl text-center mb-2 text-foreground">Public Model Hub not enabled.</p>
<p className="text-base text-center text-foreground">
Ask your proxy admin to enable this on their Admin UI.
</p>
<p className="text-xl text-center mb-2 text-foreground">{t("aiHub.modelHubTable.publicNotEnabled.title")}</p>
<p className="text-base text-center text-foreground">{t("aiHub.modelHubTable.publicNotEnabled.body")}</p>
</Card>
)}
@ -721,24 +743,26 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
className={`${isChat ? "chat-theme chat-model-hub-dialog " : ""}max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1000px]`}
>
<DialogHeader>
<DialogTitle>{selectedModel?.model_group || "Model Details"}</DialogTitle>
<DialogTitle>
{selectedModel?.model_group || t("aiHub.modelHubTable.modelDialog.fallbackTitle")}
</DialogTitle>
</DialogHeader>
{selectedModel && (
<div className="space-y-6">
{/* Model Overview */}
<div>
<p className="text-lg font-semibold mb-4">Model Overview</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.modelHubTable.modelDialog.overview")}</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<p className="font-medium">Model Group:</p>
<p className="font-medium">{t("aiHub.modelHubTable.modelDialog.modelGroup")}</p>
<p>{selectedModel.model_group}</p>
</div>
<div>
<p className="font-medium">Mode:</p>
<p>{displayMode(selectedModel.mode) ?? "Not specified"}</p>
<p className="font-medium">{t("aiHub.modelHubTable.modelDialog.mode")}</p>
<p>{displayMode(selectedModel.mode) ?? t("aiHub.modelHubTable.modelDialog.notSpecified")}</p>
</div>
<div>
<p className="font-medium">Providers:</p>
<p className="font-medium">{t("aiHub.modelHubTable.modelDialog.providers")}</p>
<div className="flex flex-wrap gap-1 mt-1">
{selectedModel.providers.map((provider) => (
<Badge key={provider} variant="secondary">
@ -749,7 +773,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
</div>
{selectedModel.description && (
<div className="col-span-2">
<p className="font-medium">Description:</p>
<p className="font-medium">{t("aiHub.modelHubTable.modelDialog.description")}</p>
<p className="mt-1">{selectedModel.description}</p>
</div>
)}
@ -762,30 +786,36 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Token and Cost Information */}
<div>
<p className="text-lg font-semibold mb-4">Token & Cost Information</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.modelHubTable.modelDialog.tokenCost")}</p>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="font-medium">Max Input Tokens:</p>
<p>{selectedModel.max_input_tokens?.toLocaleString() || "Not specified"}</p>
</div>
<div>
<p className="font-medium">Max Output Tokens:</p>
<p>{selectedModel.max_output_tokens?.toLocaleString() || "Not specified"}</p>
</div>
<div>
<p className="font-medium">Input Cost per 1M Tokens:</p>
<p className="font-medium">{t("aiHub.modelHubTable.modelDialog.maxInputTokens")}</p>
<p>
{selectedModel.input_cost_per_token
? formatCost(selectedModel.input_cost_per_token)
: "Not specified"}
{selectedModel.max_input_tokens?.toLocaleString() ||
t("aiHub.modelHubTable.modelDialog.notSpecified")}
</p>
</div>
<div>
<p className="font-medium">Output Cost per 1M Tokens:</p>
<p className="font-medium">{t("aiHub.modelHubTable.modelDialog.maxOutputTokens")}</p>
<p>
{selectedModel.max_output_tokens?.toLocaleString() ||
t("aiHub.modelHubTable.modelDialog.notSpecified")}
</p>
</div>
<div>
<p className="font-medium">{t("aiHub.modelHubTable.modelDialog.inputCost")}</p>
<p>
{selectedModel.input_cost_per_token
? formatCost(selectedModel.input_cost_per_token)
: t("aiHub.modelHubTable.modelDialog.notSpecified")}
</p>
</div>
<div>
<p className="font-medium">{t("aiHub.modelHubTable.modelDialog.outputCost")}</p>
<p>
{selectedModel.output_cost_per_token
? formatCost(selectedModel.output_cost_per_token)
: "Not specified"}
: t("aiHub.modelHubTable.modelDialog.notSpecified")}
</p>
</div>
</div>
@ -793,14 +823,16 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Capabilities */}
<div>
<p className="text-lg font-semibold mb-4">Capabilities</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.modelHubTable.modelDialog.capabilities")}</p>
<div className="flex flex-wrap gap-2">
{(() => {
const capabilities = getModelCapabilities(selectedModel);
const colors = ["green", "blue", "purple", "orange", "red", "yellow"];
if (capabilities.length === 0) {
return <p className="text-muted-foreground">No special capabilities listed</p>;
return (
<p className="text-muted-foreground">{t("aiHub.modelHubTable.modelDialog.noCapabilities")}</p>
);
}
return capabilities.map((capability, index) => (
@ -815,17 +847,17 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Rate Limits */}
{(selectedModel.tpm || selectedModel.rpm) && (
<div>
<p className="text-lg font-semibold mb-4">Rate Limits</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.modelHubTable.modelDialog.rateLimits")}</p>
<div className="grid grid-cols-2 gap-4">
{selectedModel.tpm && (
<div>
<p className="font-medium">Tokens per Minute:</p>
<p className="font-medium">{t("aiHub.modelHubTable.modelDialog.tpm")}</p>
<p>{selectedModel.tpm.toLocaleString()}</p>
</div>
)}
{selectedModel.rpm && (
<div>
<p className="font-medium">Requests per Minute:</p>
<p className="font-medium">{t("aiHub.modelHubTable.modelDialog.rpm")}</p>
<p>{selectedModel.rpm.toLocaleString()}</p>
</div>
)}
@ -836,7 +868,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Supported OpenAI Parameters */}
{selectedModel.supported_openai_params && (
<div>
<p className="text-lg font-semibold mb-4">Supported OpenAI Parameters</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.modelHubTable.modelDialog.openaiParams")}</p>
<div className="flex flex-wrap gap-2">
{selectedModel.supported_openai_params.map((param) => (
<Badge key={param} variant="default">
@ -848,19 +880,18 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
)}
<div>
<p className="text-lg font-semibold mb-4">Usage Example</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.modelHubTable.modelDialog.usageExample")}</p>
{isDecisionMode(selectedModel.mode) && (
<p className="mb-2 text-sm text-muted-foreground">
Decision models answer on <code>/v1/decisions</code> and <code>/v1/systemone</code>, not{" "}
<code>/chat/completions</code>.{" "}
<Trans i18nKey="aiHub.modelHubTable.modelDialog.decisionNote" components={{ code: <code /> }} />{" "}
<a href={DECISIONS_DOCS_URL} target="_blank" rel="noopener noreferrer" className="underline">
How to call decision models
{t("aiHub.modelHubTable.modelDialog.howToCall")}
</a>
{canOpenPlayground && (
<>
{" · "}
<a href={uiHref(SYSTEM_ONE_PLAYGROUND_ROUTE)} className="underline">
Try it in the Playground
{t("aiHub.modelHubTable.modelDialog.tryPlayground")}
</a>
</>
)}
@ -884,28 +915,28 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
<Dialog open={isAgentModalVisible} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1000px]">
<DialogHeader>
<DialogTitle>{selectedAgent?.name || "Agent Details"}</DialogTitle>
<DialogTitle>{selectedAgent?.name || t("aiHub.modelHubTable.agentDialog.fallbackTitle")}</DialogTitle>
</DialogHeader>
{selectedAgent && (
<div className="space-y-6">
{/* Agent Overview */}
<div>
<p className="text-lg font-semibold mb-4">Agent Overview</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.modelHubTable.agentDialog.overview")}</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<p className="font-medium">Name:</p>
<p className="font-medium">{t("aiHub.modelHubTable.agentDialog.name")}</p>
<p>{selectedAgent.name}</p>
</div>
<div>
<p className="font-medium">Version:</p>
<p className="font-medium">{t("aiHub.modelHubTable.agentDialog.version")}</p>
<Badge variant="secondary">v{selectedAgent.version}</Badge>
</div>
<div>
<p className="font-medium">Protocol Version:</p>
<p className="font-medium">{t("aiHub.modelHubTable.agentDialog.protocolVersion")}</p>
<p>{selectedAgent.protocolVersion}</p>
</div>
<div>
<p className="font-medium">URL:</p>
<p className="font-medium">{t("aiHub.modelHubTable.agentDialog.url")}</p>
<div className="flex items-center space-x-2">
<p className="truncate min-w-0">{selectedAgent.url}</p>
<Copy
@ -916,7 +947,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
</div>
</div>
<div>
<p className="font-medium">Description:</p>
<p className="font-medium">{t("aiHub.modelHubTable.agentDialog.description")}</p>
<p className="mt-1">{selectedAgent.description}</p>
</div>
</div>
@ -924,7 +955,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Capabilities */}
{selectedAgent.capabilities && Object.keys(selectedAgent.capabilities).length > 0 && (
<div>
<p className="text-lg font-semibold mb-4">Capabilities</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.modelHubTable.agentDialog.capabilities")}</p>
<div className="flex flex-wrap gap-2">
{Object.entries(selectedAgent.capabilities)
.filter(([_, value]) => value === true)
@ -939,26 +970,26 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Input/Output Modes */}
<div>
<p className="text-lg font-semibold mb-4">Input/Output Modes</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.modelHubTable.agentDialog.ioModes")}</p>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="font-medium">Input Modes:</p>
<p className="font-medium">{t("aiHub.modelHubTable.agentDialog.inputModes")}</p>
<div className="flex flex-wrap gap-1 mt-1">
{selectedAgent.defaultInputModes?.map((mode) => (
<Badge key={mode} variant="secondary">
{mode}
</Badge>
)) || <p>Not specified</p>}
)) || <p>{t("aiHub.modelHubTable.agentDialog.notSpecified")}</p>}
</div>
</div>
<div>
<p className="font-medium">Output Modes:</p>
<p className="font-medium">{t("aiHub.modelHubTable.agentDialog.outputModes")}</p>
<div className="flex flex-wrap gap-1 mt-1">
{selectedAgent.defaultOutputModes?.map((mode) => (
<Badge key={mode} variant="outline">
{mode}
</Badge>
)) || <p>Not specified</p>}
)) || <p>{t("aiHub.modelHubTable.agentDialog.notSpecified")}</p>}
</div>
</div>
</div>
@ -967,14 +998,16 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Skills */}
{selectedAgent.skills && selectedAgent.skills.length > 0 && (
<div>
<p className="text-lg font-semibold mb-4">Skills</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.modelHubTable.agentDialog.skills")}</p>
<div className="space-y-4">
{selectedAgent.skills.map((skill) => (
<div key={skill.id} className="border border-border rounded-sm p-4">
<div className="flex justify-between items-start mb-2">
<div>
<p className="font-medium text-base">{skill.name}</p>
<p className="text-xs text-muted-foreground">ID: {skill.id}</p>
<p className="text-xs text-muted-foreground">
{t("aiHub.modelHubTable.agentDialog.skillId", { id: skill.id })}
</p>
</div>
{skill.tags && skill.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
@ -989,7 +1022,9 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
<p className="text-sm mb-2">{skill.description}</p>
{skill.examples && skill.examples.length > 0 && (
<div>
<p className="text-xs font-medium text-foreground">Examples:</p>
<p className="text-xs font-medium text-foreground">
{t("aiHub.modelHubTable.agentDialog.examples")}
</p>
<div className="flex flex-wrap gap-1 mt-1">
{skill.examples.map((example, idx) => (
<Badge key={idx} variant="outline">
@ -1008,8 +1043,10 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Additional Properties */}
{selectedAgent.supportsAuthenticatedExtendedCard && (
<div>
<p className="text-lg font-semibold mb-4">Additional Features</p>
<Badge variant="default">Supports Authenticated Extended Card</Badge>
<p className="text-lg font-semibold mb-4">
{t("aiHub.modelHubTable.agentDialog.additionalFeatures")}
</p>
<Badge variant="default">{t("aiHub.modelHubTable.agentDialog.extendedCard")}</Badge>
</div>
)}
</div>
@ -1021,20 +1058,22 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
<Dialog open={isMcpModalVisible} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1000px]">
<DialogHeader>
<DialogTitle>{selectedMcpServer?.server_name || "MCP Server Details"}</DialogTitle>
<DialogTitle>
{selectedMcpServer?.server_name || t("aiHub.modelHubTable.mcpDialog.fallbackTitle")}
</DialogTitle>
</DialogHeader>
{selectedMcpServer && (
<div className="space-y-6">
{/* Server Overview */}
<div>
<p className="text-lg font-semibold mb-4">Server Overview</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.modelHubTable.mcpDialog.overview")}</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<p className="font-medium">Server Name:</p>
<p className="font-medium">{t("aiHub.modelHubTable.mcpDialog.serverName")}</p>
<p>{selectedMcpServer.server_name}</p>
</div>
<div>
<p className="font-medium">Server ID:</p>
<p className="font-medium">{t("aiHub.modelHubTable.mcpDialog.serverId")}</p>
<div className="flex items-center space-x-2">
<p className="text-xs truncate min-w-0">{selectedMcpServer.server_id}</p>
<Copy
@ -1045,22 +1084,22 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
</div>
{selectedMcpServer.alias && (
<div>
<p className="font-medium">Alias:</p>
<p className="font-medium">{t("aiHub.modelHubTable.mcpDialog.alias")}</p>
<p>{selectedMcpServer.alias}</p>
</div>
)}
<div>
<p className="font-medium">Transport:</p>
<p className="font-medium">{t("aiHub.modelHubTable.mcpDialog.transport")}</p>
<Badge variant="secondary">{selectedMcpServer.transport}</Badge>
</div>
<div>
<p className="font-medium">Auth Type:</p>
<p className="font-medium">{t("aiHub.modelHubTable.mcpDialog.authType")}</p>
<Badge variant={selectedMcpServer.auth_type === "none" ? "outline" : "default"}>
{selectedMcpServer.auth_type}
</Badge>
</div>
<div>
<p className="font-medium">Status:</p>
<p className="font-medium">{t("aiHub.modelHubTable.mcpDialog.status")}</p>
<Badge
variant={
selectedMcpServer.status === "active" || selectedMcpServer.status === "healthy"
@ -1070,13 +1109,13 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
: "outline"
}
>
{selectedMcpServer.status || "unknown"}
{selectedMcpServer.status || t("aiHub.modelHubTable.mcpDialog.statusUnknown")}
</Badge>
</div>
</div>
{selectedMcpServer.description && (
<div className="mt-2">
<p className="font-medium">Description:</p>
<p className="font-medium">{t("aiHub.modelHubTable.mcpDialog.description")}</p>
<p className="mt-1">{selectedMcpServer.description}</p>
</div>
)}
@ -1084,11 +1123,11 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Connection Details */}
<div>
<p className="text-lg font-semibold mb-4">Connection Details</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.modelHubTable.mcpDialog.connectionDetails")}</p>
<div className="space-y-2">
{selectedMcpServer.command && (
<div>
<p className="font-medium">Command:</p>
<p className="font-medium">{t("aiHub.modelHubTable.mcpDialog.command")}</p>
<p className="text-sm bg-muted p-2 rounded-sm mt-1 font-mono">{selectedMcpServer.command}</p>
</div>
)}
@ -1098,7 +1137,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Tools */}
{selectedMcpServer.allowed_tools && selectedMcpServer.allowed_tools.length > 0 && (
<div>
<p className="text-lg font-semibold mb-4">Allowed Tools</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.modelHubTable.mcpDialog.allowedTools")}</p>
<div className="flex flex-wrap gap-2">
{selectedMcpServer.allowed_tools.map((tool, idx) => (
<Badge key={idx} variant="outline">
@ -1112,7 +1151,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Teams */}
{selectedMcpServer.teams && selectedMcpServer.teams.length > 0 && (
<div>
<p className="text-lg font-semibold mb-4">Teams</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.modelHubTable.mcpDialog.teams")}</p>
<div className="flex flex-wrap gap-2">
{selectedMcpServer.teams.map((team, idx) => (
<Badge key={idx} variant="secondary">
@ -1126,7 +1165,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Access Groups */}
{selectedMcpServer.mcp_access_groups && selectedMcpServer.mcp_access_groups.length > 0 && (
<div>
<p className="text-lg font-semibold mb-4">Access Groups</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.modelHubTable.mcpDialog.accessGroups")}</p>
<div className="flex flex-wrap gap-2">
{selectedMcpServer.mcp_access_groups.map((group, idx) => (
<Badge key={idx} variant="default">
@ -1139,34 +1178,36 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Metadata */}
<div>
<p className="text-lg font-semibold mb-4">Metadata</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.modelHubTable.mcpDialog.metadata")}</p>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="font-medium">Created By:</p>
<p className="font-medium">{t("aiHub.modelHubTable.mcpDialog.createdBy")}</p>
<p>{selectedMcpServer.created_by}</p>
</div>
<div>
<p className="font-medium">Updated By:</p>
<p className="font-medium">{t("aiHub.modelHubTable.mcpDialog.updatedBy")}</p>
<p>{selectedMcpServer.updated_by}</p>
</div>
<div>
<p className="font-medium">Created At:</p>
<p className="font-medium">{t("aiHub.modelHubTable.mcpDialog.createdAt")}</p>
<p className="text-sm">{new Date(selectedMcpServer.created_at).toLocaleString()}</p>
</div>
<div>
<p className="font-medium">Updated At:</p>
<p className="font-medium">{t("aiHub.modelHubTable.mcpDialog.updatedAt")}</p>
<p className="text-sm">{new Date(selectedMcpServer.updated_at).toLocaleString()}</p>
</div>
{selectedMcpServer.last_health_check && (
<div>
<p className="font-medium">Last Health Check:</p>
<p className="font-medium">{t("aiHub.modelHubTable.mcpDialog.lastHealthCheck")}</p>
<p className="text-sm">{new Date(selectedMcpServer.last_health_check).toLocaleString()}</p>
</div>
)}
</div>
{selectedMcpServer.health_check_error && (
<div className="mt-2 p-2 bg-destructive/10 rounded-sm">
<p className="font-medium text-destructive">Health Check Error:</p>
<p className="font-medium text-destructive">
{t("aiHub.modelHubTable.mcpDialog.healthCheckError")}
</p>
<p className="text-sm text-destructive mt-1">{selectedMcpServer.health_check_error}</p>
</div>
)}
@ -1174,7 +1215,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
{/* Usage Example */}
<div>
<p className="text-lg font-semibold mb-4">Usage Example</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.modelHubTable.mcpDialog.usageExample")}</p>
<SyntaxHighlighter language="python" className="text-sm" style={syntaxTheme}>
{`from fastmcp import Client
import asyncio

View file

@ -2,6 +2,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { Copy, Info, MoreHorizontal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { IdentityCell, StatusBadge } from "@/components/shared/table_cells";
@ -14,6 +15,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
import i18n from "@/i18n/i18n";
import { displayMode } from "@/lib/decisionModels";
import { copyToClipboard } from "@/utils/dataUtils";
@ -68,10 +70,11 @@ interface ModelHubRowActionsProps {
}
function ModelHubRowActions({ model, onModelClick }: ModelHubRowActionsProps) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open model actions"
aria-label={t("aiHub.modelHubColumns.rowActions.openActions")}
data-testid={`model-hub-actions-${model.model_group}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
@ -80,14 +83,14 @@ function ModelHubRowActions({ model, onModelClick }: ModelHubRowActionsProps) {
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem data-testid="model-hub-action-details" onClick={() => onModelClick(model)}>
<Info />
View details
{t("aiHub.modelHubColumns.rowActions.viewDetails")}
</DropdownMenuItem>
<DropdownMenuItem
data-testid="model-hub-action-copy"
onClick={() => void copyToClipboard(model.model_group, "Model name copied")}
onClick={() => void copyToClipboard(model.model_group, t("aiHub.modelHubColumns.rowActions.nameCopied"))}
>
<Copy />
Copy model name
{t("aiHub.modelHubColumns.rowActions.copyName")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -107,9 +110,20 @@ export const getModelHubTableColumns = ({
{
id: "model_group",
accessorKey: "model_group",
meta: { title: showProviderLogos ? "Model" : "Public Model Name" },
meta: {
title: showProviderLogos
? i18n.t("aiHub.modelHubColumns.modelChat")
: i18n.t("aiHub.modelHubColumns.publicModelName"),
},
header: ({ column }) => (
<DataTableSortHeader column={column} title={showProviderLogos ? "Model" : "Public Model Name"} />
<DataTableSortHeader
column={column}
title={
showProviderLogos
? i18n.t("aiHub.modelHubColumns.modelChat")
: i18n.t("aiHub.modelHubColumns.publicModelName")
}
/>
),
size: 220,
enableSorting: true,
@ -141,8 +155,8 @@ export const getModelHubTableColumns = ({
{
id: "providers",
accessorKey: "providers",
meta: { title: "Provider", skeleton: "chips", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Provider" />,
meta: { title: i18n.t("aiHub.modelHubColumns.provider"), skeleton: "chips", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.modelHubColumns.provider")} />,
size: 150,
enableSorting: true,
sortingFn: (rowA, rowB) => rowA.original.providers.join(", ").localeCompare(rowB.original.providers.join(", ")),
@ -163,8 +177,8 @@ export const getModelHubTableColumns = ({
{
id: "mode",
accessorFn: (row) => (row.mode ? displayMode(row.mode) : ""),
meta: { title: "Mode", className: "hidden lg:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Mode" />,
meta: { title: i18n.t("aiHub.modelHubColumns.mode"), className: "hidden lg:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.modelHubColumns.mode")} />,
size: 110,
enableSorting: true,
sortingFn: "alphanumeric",
@ -180,8 +194,8 @@ export const getModelHubTableColumns = ({
{
id: "max_input_tokens",
accessorKey: "max_input_tokens",
meta: { title: "Tokens", className: "hidden lg:table-cell", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Tokens" />,
meta: { title: i18n.t("aiHub.modelHubColumns.tokens"), className: "hidden lg:table-cell", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.modelHubColumns.tokens")} />,
size: 110,
enableSorting: true,
sortingFn: (rowA, rowB) => {
@ -202,8 +216,8 @@ export const getModelHubTableColumns = ({
{
id: "input_cost_per_token",
accessorKey: "input_cost_per_token",
meta: { title: "Cost/1M", skeleton: "twoLine", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Cost/1M" />,
meta: { title: i18n.t("aiHub.modelHubColumns.cost"), skeleton: "twoLine", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.modelHubColumns.cost")} />,
size: 110,
enableSorting: true,
sortingFn: (rowA, rowB) => {
@ -225,8 +239,12 @@ export const getModelHubTableColumns = ({
},
{
id: "capabilities",
meta: { title: "Features", skeleton: "chips", className: showProviderLogos ? "hidden md:table-cell" : undefined },
header: "Features",
meta: {
title: i18n.t("aiHub.modelHubColumns.features"),
skeleton: "chips",
className: showProviderLogos ? "hidden md:table-cell" : undefined,
},
header: i18n.t("aiHub.modelHubColumns.features"),
size: 220,
enableSorting: false,
cell: ({ row }) => {
@ -247,8 +265,8 @@ export const getModelHubTableColumns = ({
},
{
id: "custom_tags",
meta: { title: "Tags", skeleton: "chips" },
header: "Tags",
meta: { title: i18n.t("aiHub.modelHubColumns.tags"), skeleton: "chips" },
header: i18n.t("aiHub.modelHubColumns.tags"),
size: 160,
enableSorting: false,
cell: ({ row }) => {
@ -262,8 +280,8 @@ export const getModelHubTableColumns = ({
{
id: "is_public_model_group",
accessorKey: "is_public_model_group",
meta: { title: "Public", skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Public" />,
meta: { title: i18n.t("aiHub.modelHubColumns.public"), skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.modelHubColumns.public")} />,
size: 100,
enableSorting: true,
sortingFn: (rowA, rowB) => {
@ -273,15 +291,15 @@ export const getModelHubTableColumns = ({
},
cell: ({ row }) =>
row.original.is_public_model_group === true ? (
<StatusBadge tone="success" label="Yes" />
<StatusBadge tone="success" label={i18n.t("aiHub.modelHubColumns.yes")} />
) : (
<StatusBadge tone="neutral" label="No" />
<StatusBadge tone="neutral" label={i18n.t("aiHub.modelHubColumns.no")} />
),
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
header: () => <span className="sr-only">{i18n.t("aiHub.modelHubColumns.actions")}</span>,
size: 64,
enableSorting: false,
enableHiding: false,

View file

@ -1,6 +1,7 @@
import React, { useMemo, useState } from "react";
import { SortingState } from "@tanstack/react-table";
import { Inbox, Search, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Plugin } from "@/components/claude_code_plugins/types";
import { DataTable } from "@/components/shared/DataTable";
import { getSkillHubTableColumns } from "@/components/AIHub/SkillHubTableColumns";
@ -20,16 +21,21 @@ interface SkillHubDashboardProps {
}
function SkillsEmptyState({ filtered }: { filtered: boolean }) {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">{filtered ? "No matching skills" : "No skills yet"}</div>
<div className="text-sm font-medium text-foreground">
{filtered
? t("aiHub.skillHubDashboard.emptyState.noMatchTitle")
: t("aiHub.skillHubDashboard.emptyState.noneTitle")}
</div>
<div className="text-sm text-muted-foreground">
{filtered
? "Adjust the search or domain filter to see more skills."
: "Skills added here will appear for developers."}
? t("aiHub.skillHubDashboard.emptyState.noMatchBody")
: t("aiHub.skillHubDashboard.emptyState.noneBody")}
</div>
</div>
);
@ -43,6 +49,7 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
publicPage = false,
onPublishSuccess,
}) => {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [domainFilter, setDomainFilter] = useState<string | undefined>(undefined);
const [selectedSkill, setSelectedSkill] = useState<Plugin | null>(null);
@ -79,8 +86,11 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
const columns = useMemo(() => getSkillHubTableColumns({ onSkillClick: setSelectedSkill }), []);
const domainItems = useMemo(
() => [{ value: ALL_DOMAINS, label: "All Domains" }, ...domains.map((d) => ({ value: d, label: d }))],
[domains],
() => [
{ value: ALL_DOMAINS, label: t("aiHub.skillHubDashboard.allDomains") },
...domains.map((d) => ({ value: d, label: d })),
],
[domains, t],
);
const hasActiveFilter = search.trim().length > 0 || domainFilter != null;
@ -102,15 +112,15 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
{/* Stats row */}
<div className="grid grid-cols-3 gap-4">
<div className="border border-border rounded-lg p-4">
<div className="text-xs text-muted-foreground mb-1">Total Skills</div>
<div className="text-xs text-muted-foreground mb-1">{t("aiHub.skillHubDashboard.totalSkills")}</div>
<div className="text-2xl font-semibold text-foreground">{totalSkills}</div>
</div>
<div className="border border-border rounded-lg p-4">
<div className="text-xs text-muted-foreground mb-1">Namespaces</div>
<div className="text-xs text-muted-foreground mb-1">{t("aiHub.skillHubDashboard.namespaces")}</div>
<div className="text-2xl font-semibold text-foreground">{namespaces.length}</div>
</div>
<div className="border border-border rounded-lg p-4">
<div className="text-xs text-muted-foreground mb-1">Domains</div>
<div className="text-xs text-muted-foreground mb-1">{t("aiHub.skillHubDashboard.domains")}</div>
<div className="text-2xl font-semibold text-foreground">{domains.length}</div>
</div>
</div>
@ -118,7 +128,9 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
{/* Search + filters + table */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-foreground">All {publicPage ? "Public " : ""}Skills</h3>
<h3 className="text-sm font-semibold text-foreground">
{publicPage ? t("aiHub.skillHubDashboard.allPublicSkills") : t("aiHub.skillHubDashboard.allSkills")}
</h3>
<div className="flex items-center gap-2">
<Select
items={domainItems}
@ -141,7 +153,7 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search by name, namespace, or tag…"
placeholder={t("aiHub.skillHubDashboard.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@ -150,7 +162,7 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
<InputGroupButton
size="icon-xs"
variant="ghost"
aria-label="Clear search"
aria-label={t("aiHub.skillHubDashboard.clearSearch")}
onClick={() => setSearch("")}
>
<X className="size-3.5" />
@ -169,13 +181,13 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
sorting={sorting}
onSortingChange={setSorting}
isLoading={isLoading}
loadingMessage="Loading skills…"
loadingMessage={t("aiHub.skillHubDashboard.loading")}
noDataMessage={<SkillsEmptyState filtered={hasActiveFilter} />}
size="compact"
/>
<div className="mt-3 text-center">
<p className="text-sm text-muted-foreground">
Showing {filteredSkills.length} of {totalSkills} skill{totalSkills !== 1 ? "s" : ""}
{t("aiHub.skillHubDashboard.showing", { shown: filteredSkills.length, count: totalSkills })}
</p>
</div>
</div>

View file

@ -2,6 +2,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { Copy, ExternalLink, Info, MoreHorizontal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { IdentityCell, StatusBadge } from "@/components/shared/table_cells";
@ -16,6 +17,7 @@ import {
import { cn } from "@/lib/cva.config";
import { copyToClipboard } from "@/utils/dataUtils";
import { Plugin } from "@/components/claude_code_plugins/types";
import i18n from "@/i18n/i18n";
function getSkillSourceLink(skill: Plugin): { url: string; label: string } | null {
const src = skill.source;
@ -38,10 +40,11 @@ interface SkillHubRowActionsProps {
}
function SkillHubRowActions({ skill, onSkillClick }: SkillHubRowActionsProps) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open skill actions"
aria-label={t("aiHub.skillHubColumns.rowActions.openActions")}
data-testid={`skill-hub-actions-${skill.id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
@ -50,14 +53,14 @@ function SkillHubRowActions({ skill, onSkillClick }: SkillHubRowActionsProps) {
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem data-testid="skill-hub-action-details" onClick={() => onSkillClick(skill)}>
<Info />
View details
{t("aiHub.skillHubColumns.rowActions.viewDetails")}
</DropdownMenuItem>
<DropdownMenuItem
data-testid="skill-hub-action-copy"
onClick={() => void copyToClipboard(skill.name, "Skill name copied")}
onClick={() => void copyToClipboard(skill.name, t("aiHub.skillHubColumns.rowActions.nameCopied"))}
>
<Copy />
Copy skill name
{t("aiHub.skillHubColumns.rowActions.copyName")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -72,8 +75,8 @@ export const getSkillHubTableColumns = ({ onSkillClick }: SkillHubTableColumnsDe
{
id: "name",
accessorKey: "name",
meta: { title: "Skill Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Skill Name" />,
meta: { title: i18n.t("aiHub.skillHubColumns.name") },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.skillHubColumns.name")} />,
size: 200,
enableSorting: true,
sortingFn: "alphanumeric",
@ -84,8 +87,8 @@ export const getSkillHubTableColumns = ({ onSkillClick }: SkillHubTableColumnsDe
{
id: "description",
accessorKey: "description",
meta: { title: "Description" },
header: "Description",
meta: { title: i18n.t("aiHub.skillHubColumns.description") },
header: i18n.t("aiHub.skillHubColumns.description"),
size: 260,
enableSorting: false,
cell: ({ row }) => (
@ -97,8 +100,8 @@ export const getSkillHubTableColumns = ({ onSkillClick }: SkillHubTableColumnsDe
{
id: "category",
accessorKey: "category",
meta: { title: "Category", skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title="Category" />,
meta: { title: i18n.t("aiHub.skillHubColumns.category"), skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.skillHubColumns.category")} />,
size: 130,
enableSorting: true,
sortingFn: "alphanumeric",
@ -112,8 +115,8 @@ export const getSkillHubTableColumns = ({ onSkillClick }: SkillHubTableColumnsDe
{
id: "domain",
accessorKey: "domain",
meta: { title: "Domain" },
header: ({ column }) => <DataTableSortHeader column={column} title="Domain" />,
meta: { title: i18n.t("aiHub.skillHubColumns.domain") },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.skillHubColumns.domain")} />,
size: 130,
enableSorting: true,
sortingFn: "alphanumeric",
@ -121,8 +124,8 @@ export const getSkillHubTableColumns = ({ onSkillClick }: SkillHubTableColumnsDe
},
{
id: "source",
meta: { title: "Source" },
header: "Source",
meta: { title: i18n.t("aiHub.skillHubColumns.source") },
header: i18n.t("aiHub.skillHubColumns.source"),
size: 200,
enableSorting: false,
cell: ({ row }) => {
@ -145,21 +148,21 @@ export const getSkillHubTableColumns = ({ onSkillClick }: SkillHubTableColumnsDe
{
id: "enabled",
accessorKey: "enabled",
meta: { title: "Status", skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title="Status" />,
meta: { title: i18n.t("aiHub.skillHubColumns.status"), skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("aiHub.skillHubColumns.status")} />,
size: 100,
enableSorting: true,
cell: ({ row }) => (
<StatusBadge
tone={row.original.enabled ? "success" : "neutral"}
label={row.original.enabled ? "Public" : "Draft"}
label={row.original.enabled ? i18n.t("aiHub.skillHubColumns.public") : i18n.t("aiHub.skillHubColumns.draft")}
/>
),
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
header: () => <span className="sr-only">{i18n.t("aiHub.skillHubColumns.actions")}</span>,
size: 64,
enableSorting: false,
enableHiding: false,

View file

@ -6,6 +6,7 @@ import { Card } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import Link from "next/link";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getProxyBaseUrl, getPublicModelHubInfo, getUiPageUrl, updateUsefulLinksCall } from "../networking";
interface UsefulLinksManagementProps {
@ -21,6 +22,7 @@ interface Link {
}
const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessToken, userRole }) => {
const { t } = useTranslation();
const [links, setLinks] = useState<Link[]>([]);
const [newLink, setNewLink] = useState({ url: "", displayName: "" });
const [editingLink, setEditingLink] = useState<Link | null>(null);
@ -103,7 +105,7 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
return true;
} catch (error) {
console.error("Error saving links:", error);
toast.fromError(`Failed to save links - ${error}`);
toast.fromError(t("aiHub.usefulLinks.saveFailed", { error }));
return false;
}
};
@ -115,13 +117,13 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
try {
new URL(newLink.url);
} catch {
toast.fromError("Please enter a valid URL");
toast.fromError(t("aiHub.usefulLinks.invalidUrl"));
return;
}
// Check for duplicate display names
if (links.some((link) => link.displayName === newLink.displayName)) {
toast.fromError("A link with this display name already exists");
toast.fromError(t("aiHub.usefulLinks.duplicateName"));
return;
}
@ -136,7 +138,7 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
if (await saveLinksToBackend(updatedLinks)) {
setLinks(updatedLinks);
setNewLink({ url: "", displayName: "" });
toast.success("Link added successfully");
toast.success(t("aiHub.usefulLinks.added"));
}
};
@ -151,13 +153,13 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
try {
new URL(editingLink.url);
} catch {
toast.fromError("Please enter a valid URL");
toast.fromError(t("aiHub.usefulLinks.invalidUrl"));
return;
}
// Check for duplicate display names (excluding current link)
if (links.some((link) => link.id !== editingLink.id && link.displayName === editingLink.displayName)) {
toast.fromError("A link with this display name already exists");
toast.fromError(t("aiHub.usefulLinks.duplicateName"));
return;
}
@ -166,7 +168,7 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
if (await saveLinksToBackend(updatedLinks)) {
setLinks(updatedLinks);
setEditingLink(null);
toast.success("Link updated successfully");
toast.success(t("aiHub.usefulLinks.updated"));
}
};
@ -179,7 +181,7 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
if (await saveLinksToBackend(updatedLinks)) {
setLinks(updatedLinks);
toast.success("Link deleted successfully");
toast.success(t("aiHub.usefulLinks.deleted"));
}
};
@ -205,7 +207,7 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
if (await saveLinksToBackend(links)) {
setIsRearranging(false);
setOriginalLinksOrder([]);
toast.success("Link order saved successfully");
toast.success(t("aiHub.usefulLinks.orderSaved"));
}
};
@ -227,10 +229,8 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
<Card className="mb-6 px-6">
<div className="flex items-center justify-between cursor-pointer" onClick={() => setIsExpanded(!isExpanded)}>
<div className="flex flex-col">
<h3 className="mb-0 text-lg font-semibold">Link Management</h3>
<p className="text-sm text-muted-foreground">
Manage the links that are displayed under &apos;Useful Links&apos; on the public model hub.
</p>
<h3 className="mb-0 text-lg font-semibold">{t("aiHub.usefulLinks.title")}</h3>
<p className="text-sm text-muted-foreground">{t("aiHub.usefulLinks.description")}</p>
</div>
<div className="flex items-center">
{isExpanded ? (
@ -244,10 +244,12 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
{isExpanded && (
<div className="mt-4">
<div className="mb-6">
<p className="text-sm font-medium text-foreground mb-2">Add New Link</p>
<p className="text-sm font-medium text-foreground mb-2">{t("aiHub.usefulLinks.addForm.title")}</p>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs text-muted-foreground mb-1">Display Name</label>
<label className="block text-xs text-muted-foreground mb-1">
{t("aiHub.usefulLinks.addForm.displayName")}
</label>
<input
type="text"
value={newLink.displayName}
@ -257,12 +259,12 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
displayName: e.target.value,
})
}
placeholder="Friendly name"
placeholder={t("aiHub.usefulLinks.addForm.displayNamePlaceholder")}
className="w-full px-3 py-2 border border-border rounded-md text-sm"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">URL</label>
<label className="block text-xs text-muted-foreground mb-1">{t("aiHub.usefulLinks.addForm.url")}</label>
<input
type="text"
value={newLink.url}
@ -283,22 +285,22 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
className={`flex items-center px-4 py-2 rounded-md text-sm ${!newLink.url || !newLink.displayName ? "bg-border text-muted-foreground cursor-not-allowed" : "bg-success text-success-foreground hover:bg-success/80"}`}
>
<PlusCircleIcon className="w-4 h-4 mr-1" />
Add Link
{t("aiHub.usefulLinks.addForm.submit")}
</button>
</div>
</div>
</div>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-foreground">Manage Existing Links</p>
<p className="text-sm font-medium text-foreground">{t("aiHub.usefulLinks.manageExisting")}</p>
<div className="flex items-center space-x-2">
<Link
href={getUiPageUrl(`${getProxyBaseUrl()}/ui/model_hub_table`)}
target="_blank"
rel="noopener noreferrer"
className="text-xs bg-info/10 text-info px-3 py-1.5 rounded-sm hover:bg-info/15 flex items-center"
title="Open Public Model Hub"
title={t("aiHub.usefulLinks.openPublicHubTitle")}
>
Public Model Hub
{t("aiHub.usefulLinks.publicHub")}
<ExternalLinkIcon className="w-4 h-4 ml-1" />
</Link>
{!isRearranging ? (
@ -306,7 +308,7 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
onClick={handleStartRearranging}
className="text-xs bg-purple-50 text-purple-600 px-3 py-1.5 rounded-sm hover:bg-purple-100 flex items-center dark:bg-purple-950 dark:text-purple-300 dark:hover:bg-purple-900"
>
Rearrange Order
{t("aiHub.usefulLinks.rearrange")}
</button>
) : (
<div className="flex space-x-2">
@ -314,13 +316,13 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
onClick={handleSaveRearranging}
className="text-xs bg-success text-success-foreground px-3 py-1.5 rounded-sm hover:bg-success/80"
>
Save Order
{t("aiHub.usefulLinks.saveOrder")}
</button>
<button
onClick={handleCancelRearranging}
className="text-xs bg-muted text-muted-foreground px-3 py-1.5 rounded-sm hover:bg-accent"
>
Cancel
{t("aiHub.usefulLinks.cancel")}
</button>
</div>
)}
@ -331,9 +333,9 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHeader>
<TableRow>
<TableHead className="py-1 h-8">Display Name</TableHead>
<TableHead className="py-1 h-8">URL</TableHead>
<TableHead className="py-1 h-8">Actions</TableHead>
<TableHead className="py-1 h-8">{t("aiHub.usefulLinks.table.displayName")}</TableHead>
<TableHead className="py-1 h-8">{t("aiHub.usefulLinks.table.url")}</TableHead>
<TableHead className="py-1 h-8">{t("aiHub.usefulLinks.table.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -373,13 +375,13 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
onClick={handleUpdateLink}
className="text-xs bg-info/10 text-info px-2 py-1 rounded-sm hover:bg-info/15"
>
Save
{t("aiHub.usefulLinks.save")}
</button>
<button
onClick={handleCancelEdit}
className="text-xs bg-muted text-muted-foreground px-2 py-1 rounded-sm hover:bg-accent"
>
Cancel
{t("aiHub.usefulLinks.cancel")}
</button>
</div>
</TableCell>
@ -394,17 +396,17 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
<TableIconActionButton
variant="Up"
onClick={() => handleMoveUp(index)}
tooltipText="Move up"
tooltipText={t("aiHub.usefulLinks.moveUp")}
disabled={index === 0}
disabledTooltipText="Already at the top"
disabledTooltipText={t("aiHub.usefulLinks.alreadyTop")}
dataTestId={`move-up-${link.id}`}
/>
<TableIconActionButton
variant="Down"
onClick={() => handleMoveDown(index)}
tooltipText="Move down"
tooltipText={t("aiHub.usefulLinks.moveDown")}
disabled={index === links.length - 1}
disabledTooltipText="Already at the bottom"
disabledTooltipText={t("aiHub.usefulLinks.alreadyBottom")}
dataTestId={`move-down-${link.id}`}
/>
</div>
@ -413,19 +415,19 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
<TableIconActionButton
variant="Open"
onClick={() => setCurrentLink(link.url)}
tooltipText="Open link"
tooltipText={t("aiHub.usefulLinks.openLink")}
dataTestId={`open-link-${link.id}`}
/>
<TableIconActionButton
variant="Edit"
onClick={() => handleEditLink(link)}
tooltipText="Edit link"
tooltipText={t("aiHub.usefulLinks.editLink")}
dataTestId={`edit-link-${link.id}`}
/>
<TableIconActionButton
variant="Delete"
onClick={() => deleteLink(link.id)}
tooltipText="Delete link"
tooltipText={t("aiHub.usefulLinks.deleteLink")}
dataTestId={`delete-link-${link.id}`}
/>
</div>
@ -438,7 +440,7 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
{links.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="py-0.5 text-sm text-muted-foreground text-center">
No links added yet. Add a new link above.
{t("aiHub.usefulLinks.empty")}
</TableCell>
</TableRow>
)}

View file

@ -1,5 +1,7 @@
import React, { useState, useEffect } from "react";
import type { ParseKeys } from "i18next";
import { Loader2 } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@ -9,7 +11,7 @@ import { makeAgentsPublicCall } from "../../networking";
import { toast } from "@/lib/toast";
import { AgentHubData } from "@/components/AIHub/AgentHubTableColumns";
const STEP_TITLES = ["Select Agents", "Confirm"];
const STEP_TITLES: ParseKeys[] = ["aiHub.makeAgentPublic.steps.selectAgents", "aiHub.makeAgentPublic.steps.confirm"];
interface MakeAgentPublicFormProps {
visible: boolean;
@ -26,6 +28,7 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
agentHubData,
onSuccess,
}) => {
const { t } = useTranslation();
const [currentStep, setCurrentStep] = useState(0);
const [selectedAgents, setSelectedAgents] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
@ -39,7 +42,7 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
const handleNext = () => {
if (currentStep === 0) {
if (selectedAgents.size === 0) {
toast.fromError("Please select at least one agent to make public");
toast.fromError(t("aiHub.makeAgentPublic.selectAtLeastOne"));
return;
}
setCurrentStep(1);
@ -85,7 +88,7 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
const handleSubmit = async () => {
if (selectedAgents.size === 0) {
toast.fromError("Please select at least one agent to make public");
toast.fromError(t("aiHub.makeAgentPublic.selectAtLeastOne"));
return;
}
@ -96,12 +99,12 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
// Make batch API call for all agents
await makeAgentsPublicCall(accessToken, agentIdsToMakePublic);
toast.success(`Successfully made ${agentIdsToMakePublic.length} agent(s) public!`);
toast.success(t("aiHub.makeAgentPublic.success", { total: agentIdsToMakePublic.length }));
handleClose();
onSuccess();
} catch (error) {
console.error("Error making agents public:", error);
toast.fromError("Failed to make agents public. Please try again.");
toast.fromError(t("aiHub.makeAgentPublic.failed"));
} finally {
setLoading(false);
}
@ -115,7 +118,7 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Select Agents to Make Public</h3>
<h3 className="text-lg font-semibold">{t("aiHub.makeAgentPublic.step1Title")}</h3>
<div className="flex items-center space-x-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox
@ -124,21 +127,18 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
onCheckedChange={(checked) => handleSelectAll(checked === true)}
disabled={agentHubData.length === 0}
/>
Select All {agentHubData.length > 0 && `(${agentHubData.length})`}
{t("aiHub.makeAgentPublic.selectAll")} {agentHubData.length > 0 && `(${agentHubData.length})`}
</label>
</div>
</div>
<p className="text-sm text-muted-foreground">
Select the agents you want to be visible on the public model hub. Users will still require a valid Virtual Key
to use these agents.
</p>
<p className="text-sm text-muted-foreground">{t("aiHub.makeAgentPublic.description")}</p>
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
<div className="space-y-3">
{agentHubData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No agents available.</p>
<p>{t("aiHub.makeAgentPublic.noAgents")}</p>
</div>
) : (
agentHubData.map((agent) => {
@ -163,7 +163,9 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
</Badge>
))}
{agent.skills.length > 3 && (
<p className="text-xs text-muted-foreground">+{agent.skills.length - 3} more</p>
<p className="text-xs text-muted-foreground">
{t("aiHub.makeAgentPublic.moreSkills", { extra: agent.skills.length - 3 })}
</p>
)}
</div>
)}
@ -178,7 +180,11 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
{selectedAgents.size > 0 && (
<div className="bg-info/10 border border-info/20 rounded-lg p-3">
<p className="text-sm text-info">
<strong>{selectedAgents.size}</strong> agent{selectedAgents.size !== 1 ? "s" : ""} selected
<Trans
i18nKey="aiHub.makeAgentPublic.selectedCount"
count={selectedAgents.size}
components={{ strong: <strong /> }}
/>
</p>
</div>
)}
@ -189,17 +195,16 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
const renderStep2Content = () => {
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Confirm Making Agents Public</h3>
<h3 className="text-lg font-semibold">{t("aiHub.makeAgentPublic.step2Title")}</h3>
<div className="bg-warning/10 border border-warning/20 rounded-lg p-4">
<p className="text-sm text-warning">
<strong>Warning:</strong> Once you make these agents public, anyone who can go to the{" "}
<code>/ui/model_hub_table</code> will be able to know they exist on the proxy.
<Trans i18nKey="aiHub.makeAgentPublic.warning" components={{ strong: <strong />, code: <code /> }} />
</p>
</div>
<div className="space-y-3">
<p className="font-medium">Agents to be made public:</p>
<p className="font-medium">{t("aiHub.makeAgentPublic.toBeMadePublic")}</p>
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
<div className="space-y-2">
{Array.from(selectedAgents).map((agentId) => {
@ -224,8 +229,11 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
<div className="bg-info/10 border border-info/20 rounded-lg p-3">
<p className="text-sm text-info">
Total: <strong>{selectedAgents.size}</strong> agent{selectedAgents.size !== 1 ? "s" : ""} will be made
public
<Trans
i18nKey="aiHub.makeAgentPublic.total"
count={selectedAgents.size}
components={{ strong: <strong /> }}
/>
</p>
</div>
</div>
@ -247,20 +255,20 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
return (
<div className="flex justify-between mt-6">
<Button variant="outline" onClick={currentStep === 0 ? handleClose : handlePrevious}>
{currentStep === 0 ? "Cancel" : "Previous"}
{currentStep === 0 ? t("aiHub.makeAgentPublic.cancel") : t("aiHub.makeAgentPublic.previous")}
</Button>
<div className="flex space-x-2">
{currentStep === 0 && (
<Button onClick={handleNext} disabled={selectedAgents.size === 0}>
Next
{t("aiHub.makeAgentPublic.next")}
</Button>
)}
{currentStep === 1 && (
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="size-4 animate-spin" />}
Make Public
{t("aiHub.makeAgentPublic.makePublic")}
</Button>
)}
</div>
@ -272,7 +280,7 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
<Dialog open={visible} onOpenChange={(open) => !open && handleClose()} disablePointerDismissal>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1200px]">
<DialogHeader>
<DialogTitle>Make Agents Public</DialogTitle>
<DialogTitle>{t("aiHub.makeAgentPublic.title")}</DialogTitle>
</DialogHeader>
<div>
@ -294,7 +302,7 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
{index + 1}
</span>
<span className={cn("text-sm", currentStep === index ? "font-medium" : "text-muted-foreground")}>
{title}
{t(title)}
</span>
</li>
))}

View file

@ -1,5 +1,7 @@
import React, { useState, useEffect } from "react";
import type { ParseKeys } from "i18next";
import { Loader2 } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import CodeBlock from "@/components/CodeBlock";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -10,7 +12,7 @@ import { makeMCPPublicCall } from "../../networking";
import { toast } from "@/lib/toast";
import { MCPServerData } from "@/components/AIHub/MCPHubTableColumns";
const STEP_TITLES = ["Select Servers", "Confirm"];
const STEP_TITLES: ParseKeys[] = ["aiHub.makeMcpPublic.steps.selectServers", "aiHub.makeMcpPublic.steps.confirm"];
const statusVariant = (status?: string) => {
if (status === "active" || status === "healthy") {
@ -42,6 +44,7 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
mcpHubData,
onSuccess,
}) => {
const { t } = useTranslation();
const [currentStep, setCurrentStep] = useState(0);
const [selection, setSelection] = useState<PublicationSelection | null>(null);
const [loading, setLoading] = useState(false);
@ -115,7 +118,7 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
// Make batch API call for all servers
await makeMCPPublicCall(accessToken, serverIdsToMakePublic);
toast.success("MCP Hub publication list updated");
toast.success(t("aiHub.makeMcpPublic.updated"));
handleClose();
onSuccess();
} catch (error) {
@ -134,7 +137,7 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Select MCP Servers for the Hub</h3>
<h3 className="text-lg font-semibold">{t("aiHub.makeMcpPublic.step1Title")}</h3>
<div className="flex items-center space-x-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox
@ -143,26 +146,20 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
onCheckedChange={(checked) => handleSelectAll(checked === true)}
disabled={mcpHubData.length === 0}
/>
Select All {mcpHubData.length > 0 && `(${mcpHubData.length})`}
{t("aiHub.makeMcpPublic.selectAll")} {mcpHubData.length > 0 && `(${mcpHubData.length})`}
</label>
</div>
</div>
<p className="text-sm text-muted-foreground">
Select the complete list of MCP servers to publish on the public hub. Uncheck a server to remove it from this
list, or uncheck all to clear it. Authentication and access permissions still apply
</p>
<p className="text-sm text-muted-foreground">{t("aiHub.makeMcpPublic.description")}</p>
<p className="text-xs text-muted-foreground">
Legacy mode also lists servers with public IP access enabled. Set public_mcp_hub_strict_whitelist to true in
your configuration to use only the publication list
</p>
<p className="text-xs text-muted-foreground">{t("aiHub.makeMcpPublic.legacyNote")}</p>
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
<div className="space-y-3">
{mcpHubData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No MCP servers available.</p>
<p>{t("aiHub.makeMcpPublic.noServers")}</p>
</div>
) : (
mcpHubData.map((server) => {
@ -173,7 +170,7 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-accent"
>
<Checkbox
aria-label={`Publish ${server.server_name}`}
aria-label={t("aiHub.makeMcpPublic.publishServer", { name: server.server_name })}
checked={selectedServers.has(server.server_id)}
onCheckedChange={(checked) => handleServerSelection(server.server_id, checked === true)}
/>
@ -182,11 +179,15 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
<p className="font-medium break-words">{server.server_name}</p>
{isPublic && (
<Badge>
{server.mcp_info?.is_public_explicit === false ? "Listed by legacy mode" : "Listed"}
{server.mcp_info?.is_public_explicit === false
? t("aiHub.makeMcpPublic.listedLegacy")
: t("aiHub.makeMcpPublic.listed")}
</Badge>
)}
<Badge variant="secondary">{server.transport}</Badge>
<Badge variant={statusVariant(server.status)}>{server.status || "unknown"}</Badge>
<Badge variant={statusVariant(server.status)}>
{server.status || t("aiHub.makeMcpPublic.statusUnknown")}
</Badge>
</div>
<p className="text-xs font-mono text-muted-foreground mt-1 break-all">{server.server_id}</p>
<p className="text-xs text-muted-foreground mt-1 break-words">
@ -200,7 +201,9 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
</Badge>
))}
{server.allowed_tools.length > 3 && (
<p className="text-xs text-muted-foreground">+{server.allowed_tools.length - 3} more</p>
<p className="text-xs text-muted-foreground">
{t("aiHub.makeMcpPublic.moreTools", { extra: server.allowed_tools.length - 3 })}
</p>
)}
</div>
)}
@ -213,13 +216,9 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
</div>
<details className="rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">Configure in YAML</summary>
<summary className="cursor-pointer text-sm font-medium">{t("aiHub.makeMcpPublic.configureYaml")}</summary>
<div className="mt-3 space-y-3">
<p className="text-sm text-muted-foreground">
Merge these settings into your proxy configuration and reload it. Entries use the server IDs shown above,
not names or aliases. For servers defined in YAML, pin server_id in each existing mcp_servers entry so the
publication list stays stable
</p>
<p className="text-sm text-muted-foreground">{t("aiHub.makeMcpPublic.yamlHelp")}</p>
<CodeBlock code={publicationYaml} language="yaml" />
</div>
</details>
@ -227,7 +226,11 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
{selectedServers.size > 0 && (
<div className="bg-info/10 border border-info/20 rounded-lg p-3">
<p className="text-sm text-info">
<strong>{selectedServers.size}</strong> MCP server{selectedServers.size !== 1 ? "s" : ""} selected
<Trans
i18nKey="aiHub.makeMcpPublic.selectedCount"
count={selectedServers.size}
components={{ strong: <strong /> }}
/>
</p>
</div>
)}
@ -238,20 +241,19 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
const renderStep2Content = () => {
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Confirm MCP Hub Publication</h3>
<h3 className="text-lg font-semibold">{t("aiHub.makeMcpPublic.step2Title")}</h3>
<div className="bg-warning/10 border border-warning/20 rounded-lg p-4">
<p className="text-sm text-warning">
Anyone who can open <code>/ui/model_hub_table</code> can discover published servers. Explicitly published
server IDs also allow requests from public IPs. Authentication and access permissions still apply
<Trans i18nKey="aiHub.makeMcpPublic.publicWarning" components={{ code: <code /> }} />
</p>
</div>
<div className="space-y-3">
<p className="font-medium">MCP servers in the publication list:</p>
<p className="font-medium">{t("aiHub.makeMcpPublic.publicationList")}</p>
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
<div className="space-y-2">
{selectedServers.size === 0 && <p className="text-sm">No explicitly published servers</p>}
{selectedServers.size === 0 && <p className="text-sm">{t("aiHub.makeMcpPublic.noPublished")}</p>}
{Array.from(selectedServers).map((serverId) => {
const server = mcpHubData.find((s) => s.server_id === serverId);
return (
@ -262,7 +264,9 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
{server && (
<>
<Badge variant="secondary">{server.transport}</Badge>
<Badge variant={statusVariant(server.status)}>{server.status || "unknown"}</Badge>
<Badge variant={statusVariant(server.status)}>
{server.status || t("aiHub.makeMcpPublic.statusUnknown")}
</Badge>
</>
)}
</div>
@ -280,8 +284,11 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
<div className="bg-info/10 border border-info/20 rounded-lg p-3">
<p className="text-sm text-info">
Saving replaces the publication list with <strong>{selectedServers.size}</strong> MCP server
{selectedServers.size !== 1 ? "s" : ""}. Legacy mode may still list servers with public IP access enabled
<Trans
i18nKey="aiHub.makeMcpPublic.savingSummary"
count={selectedServers.size}
components={{ strong: <strong /> }}
/>
</p>
</div>
</div>
@ -292,12 +299,11 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
if (!hasPublicationMetadata) {
return (
<div role="alert" className="rounded-lg border border-warning/20 bg-warning/10 p-4 text-sm">
This proxy does not provide explicit publication status for every MCP server. Update the proxy to manage
visibility here, or edit litellm_settings.public_mcp_servers in its existing configuration
{t("aiHub.makeMcpPublic.noMetadata")}
</div>
);
}
if (!canManagePublication) return <p role="status">Loading publication settings</p>;
if (!canManagePublication) return <p role="status">{t("aiHub.makeMcpPublic.loadingSettings")}</p>;
switch (currentStep) {
case 0:
return renderStep1Content();
@ -312,20 +318,20 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
return (
<div className="flex justify-between mt-6">
<Button variant="outline" onClick={currentStep === 0 ? handleClose : handlePrevious}>
{currentStep === 0 ? "Cancel" : "Previous"}
{currentStep === 0 ? t("aiHub.makeMcpPublic.cancel") : t("aiHub.makeMcpPublic.previous")}
</Button>
<div className="flex space-x-2">
{currentStep === 0 && (
<Button onClick={handleNext} disabled={!canManagePublication}>
Next
{t("aiHub.makeMcpPublic.next")}
</Button>
)}
{currentStep === 1 && (
<Button onClick={handleSubmit} disabled={loading || !canManagePublication}>
{loading && <Loader2 className="size-4 animate-spin" />}
Save Publication List
{t("aiHub.makeMcpPublic.save")}
</Button>
)}
</div>
@ -337,7 +343,7 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
<Dialog open={visible} onOpenChange={(open) => !open && handleClose()} disablePointerDismissal>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1200px]">
<DialogHeader>
<DialogTitle>Manage MCP Hub Visibility</DialogTitle>
<DialogTitle>{t("aiHub.makeMcpPublic.title")}</DialogTitle>
</DialogHeader>
<div>
@ -359,7 +365,7 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
{index + 1}
</span>
<span className={cn("text-sm", currentStep === index ? "font-medium" : "text-muted-foreground")}>
{title}
{t(title)}
</span>
</li>
))}

View file

@ -1,5 +1,7 @@
import React, { useState, useCallback, useEffect } from "react";
import type { ParseKeys } from "i18next";
import { Loader2 } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@ -10,7 +12,7 @@ import ModelFilters from "../../model_filters";
import { toast } from "@/lib/toast";
import { displayMode } from "@/lib/decisionModels";
const STEP_TITLES = ["Select Models", "Confirm"];
const STEP_TITLES: ParseKeys[] = ["aiHub.makeModelPublic.steps.selectModels", "aiHub.makeModelPublic.steps.confirm"];
interface ModelGroupInfo {
model_group: string;
@ -45,6 +47,7 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
modelHubData,
onSuccess,
}) => {
const { t } = useTranslation();
const [currentStep, setCurrentStep] = useState(0);
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
const [filteredData, setFilteredData] = useState<ModelGroupInfo[]>([]);
@ -60,7 +63,7 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
const handleNext = () => {
if (currentStep === 0) {
if (selectedModels.size === 0) {
toast.fromError("Please select at least one model to make public");
toast.fromError(t("aiHub.makeModelPublic.selectAtLeastOne"));
return;
}
setCurrentStep(1);
@ -113,7 +116,7 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
const handleSubmit = async () => {
if (selectedModels.size === 0) {
toast.fromError("Please select at least one model to make public");
toast.fromError(t("aiHub.makeModelPublic.selectAtLeastOne"));
return;
}
@ -122,12 +125,12 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
const modelGroupsToMakePublic = Array.from(selectedModels);
await makeModelGroupPublic(accessToken, modelGroupsToMakePublic);
toast.success(`Successfully made ${modelGroupsToMakePublic.length} model group(s) public!`);
toast.success(t("aiHub.makeModelPublic.success", { total: modelGroupsToMakePublic.length }));
handleClose();
onSuccess();
} catch (error) {
console.error("Error making model groups public:", error);
toast.fromError("Failed to make model groups public. Please try again.");
toast.fromError(t("aiHub.makeModelPublic.failed"));
} finally {
setLoading(false);
}
@ -141,7 +144,7 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Select Models to Make Public</h3>
<h3 className="text-lg font-semibold">{t("aiHub.makeModelPublic.step1Title")}</h3>
<div className="flex items-center space-x-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox
@ -150,15 +153,12 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
onCheckedChange={(checked) => handleSelectAll(checked === true)}
disabled={filteredData.length === 0}
/>
Select All {filteredData.length > 0 && `(${filteredData.length})`}
{t("aiHub.makeModelPublic.selectAll")} {filteredData.length > 0 && `(${filteredData.length})`}
</label>
</div>
</div>
<p className="text-sm text-muted-foreground">
Select the models you want to be visible on the public model hub. Users will still require a valid Virtual Key
to use these models.
</p>
<p className="text-sm text-muted-foreground">{t("aiHub.makeModelPublic.description")}</p>
{/* Filters */}
<ModelFilters
@ -172,7 +172,7 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
<div className="space-y-3">
{filteredData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No models match the current filters.</p>
<p>{t("aiHub.makeModelPublic.noMatch")}</p>
</div>
) : (
filteredData.map((model) => (
@ -206,7 +206,11 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
{selectedModels.size > 0 && (
<div className="bg-info/10 border border-info/20 rounded-lg p-3">
<p className="text-sm text-info">
<strong>{selectedModels.size}</strong> model{selectedModels.size !== 1 ? "s" : ""} selected
<Trans
i18nKey="aiHub.makeModelPublic.selectedCount"
count={selectedModels.size}
components={{ strong: <strong /> }}
/>
</p>
</div>
)}
@ -217,17 +221,16 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
const renderStep2Content = () => {
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Confirm Making Models Public</h3>
<h3 className="text-lg font-semibold">{t("aiHub.makeModelPublic.step2Title")}</h3>
<div className="bg-warning/10 border border-warning/20 rounded-lg p-4">
<p className="text-sm text-warning">
<strong>Warning:</strong> Once you make these models public, anyone who can go to the{" "}
<code>/ui/model_hub_table</code> will be able to know they exist on the proxy.
<Trans i18nKey="aiHub.makeModelPublic.warning" components={{ strong: <strong />, code: <code /> }} />
</p>
</div>
<div className="space-y-3">
<p className="font-medium">Models to be made public:</p>
<p className="font-medium">{t("aiHub.makeModelPublic.toBeMadePublic")}</p>
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
<div className="space-y-2">
{Array.from(selectedModels).map((modelGroup) => {
@ -255,8 +258,11 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
<div className="bg-info/10 border border-info/20 rounded-lg p-3">
<p className="text-sm text-info">
Total: <strong>{selectedModels.size}</strong> model{selectedModels.size !== 1 ? "s" : ""} will be made
public
<Trans
i18nKey="aiHub.makeModelPublic.total"
count={selectedModels.size}
components={{ strong: <strong /> }}
/>
</p>
</div>
</div>
@ -278,20 +284,20 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
return (
<div className="flex justify-between mt-6">
<Button variant="outline" onClick={currentStep === 0 ? handleClose : handlePrevious}>
{currentStep === 0 ? "Cancel" : "Previous"}
{currentStep === 0 ? t("aiHub.makeModelPublic.cancel") : t("aiHub.makeModelPublic.previous")}
</Button>
<div className="flex space-x-2">
{currentStep === 0 && (
<Button onClick={handleNext} disabled={selectedModels.size === 0}>
Next
{t("aiHub.makeModelPublic.next")}
</Button>
)}
{currentStep === 1 && (
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="size-4 animate-spin" />}
Make Public
{t("aiHub.makeModelPublic.makePublic")}
</Button>
)}
</div>
@ -303,7 +309,7 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
<Dialog open={visible} onOpenChange={(open) => !open && handleClose()} disablePointerDismissal>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1200px]">
<DialogHeader>
<DialogTitle>Make Models Public</DialogTitle>
<DialogTitle>{t("aiHub.makeModelPublic.title")}</DialogTitle>
</DialogHeader>
<div>
@ -325,7 +331,7 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
{index + 1}
</span>
<span className={cn("text-sm", currentStep === index ? "font-medium" : "text-muted-foreground")}>
{title}
{t(title)}
</span>
</li>
))}

View file

@ -1,5 +1,6 @@
import { useState } from "react";
import { CheckIcon, ClipboardIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
@ -11,6 +12,7 @@ interface CodeBlockProps {
}
const CodeBlock = ({ code, language }: CodeBlockProps) => {
const { t } = useTranslation();
const syntaxTheme = useSyntaxTheme(oneLight);
const [copied, setCopied] = useState(false);
const copyToClipboard = () => {
@ -24,7 +26,7 @@ const CodeBlock = ({ code, language }: CodeBlockProps) => {
<button
onClick={copyToClipboard}
className="absolute top-3 right-3 p-2 rounded-md border border-border bg-background text-muted-foreground hover:bg-accent hover:text-foreground z-raised"
aria-label="Copy code"
aria-label={t("codeBlock.copyCode")}
>
{copied ? <CheckIcon size={16} /> : <ClipboardIcon size={16} />}
</button>

View file

@ -5,6 +5,7 @@ import { Command as CommandPrimitive } from "cmdk";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { FileText, KeyRound, LoaderCircle, Search } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Badge } from "@/components/ui/badge";
@ -20,6 +21,7 @@ import {
} from "@/components/ui/command";
import { InputGroupAddon } from "@/components/ui/input-group";
import { useTerminology } from "@/contexts/TerminologyContext";
import i18n from "@/i18n/i18n";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { keyDetailHref } from "@/utils/entityLinks";
import { routeSegmentForPathname, uiHref } from "@/utils/uiHref";
@ -32,7 +34,7 @@ type PaletteEntry = {
title: string;
subtitle?: ReactNode;
icon: ReactNode;
typeLabel: "Key" | "Page" | "Action";
typeLabel: "shell.commandPalette.types.key" | "shell.commandPalette.types.page" | "shell.commandPalette.types.action";
} & (
| { kind: "key"; key: KeyResponse }
| { kind: "filter" }
@ -46,9 +48,9 @@ interface PaletteGroup {
}
const emptyMessageFor = (scope: PaletteScope, query: string, hasKeyError: boolean): string => {
if (hasKeyError) return "Unable to search keys";
if (scope === "keys" && !query) return "No recent keys";
return `No results for “${query}”`;
if (hasKeyError) return i18n.t("shell.commandPalette.empty.keyError");
if (scope === "keys" && !query) return i18n.t("shell.commandPalette.empty.noRecentKeys");
return i18n.t("shell.commandPalette.empty.noResults", { query });
};
// TODO(https://github.com/dip/cmdk/issues/413): remove once cmdk keeps aria-activedescendant on the selected row
@ -75,12 +77,13 @@ function useActiveDescendantOnSelectedRow(inputRef: RefObject<HTMLInputElement |
}
export function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
const { t } = useTranslation();
return (
<CommandDialog
open={open}
onOpenChange={setOpen}
title="Command palette"
description="Search for a page, an action or a virtual key"
title={t("shell.commandPalette.title")}
description={t("shell.commandPalette.description")}
>
<CommandPaletteContent setOpen={setOpen} />
</CommandDialog>
@ -88,6 +91,7 @@ export function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (ope
}
function CommandPaletteContent({ setOpen }: { setOpen: (open: boolean) => void }) {
const { t } = useTranslation();
const pathname = usePathname();
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
@ -130,22 +134,27 @@ function CommandPaletteContent({ setOpen }: { setOpen: (open: boolean) => void }
title: page.label,
subtitle: page.section,
icon: page.icon ?? <FileText />,
typeLabel: "Page",
typeLabel: "shell.commandPalette.types.page",
page,
}));
const pagesGroups = pages.length > 0 ? [{ heading: "Pages", entries: pages }] : [];
const pagesGroups = pages.length > 0 ? [{ heading: t("shell.commandPalette.groups.pages"), entries: pages }] : [];
if (scope === "global") {
const showSearchKeysAction = !trimmedQuery || "search virtual keys".includes(trimmedQuery.toLowerCase());
const searchKeysAction: PaletteEntry = {
id: "action:search-keys",
kind: "search-keys",
title: "Search virtual keys",
subtitle: "Find a key by alias or ID",
title: t("shell.commandPalette.searchKeys.title"),
subtitle: t("shell.commandPalette.searchKeys.subtitle"),
icon: <KeyRound />,
typeLabel: "Action",
typeLabel: "shell.commandPalette.types.action",
};
return [...(showSearchKeysAction ? [{ heading: "Actions", entries: [searchKeysAction] }] : []), ...pagesGroups];
return [
...(showSearchKeysAction
? [{ heading: t("shell.commandPalette.groups.actions"), entries: [searchKeysAction] }]
: []),
...pagesGroups,
];
}
const keyEntries: PaletteEntry[] = (isLoadingKeys ? [] : keyResults.data?.keys ?? []).map((key) => {
@ -155,7 +164,7 @@ function CommandPaletteContent({ setOpen }: { setOpen: (open: boolean) => void }
return {
id: `key:${key.token}`,
kind: "key",
title: key.key_alias || "Unnamed key",
title: key.key_alias || t("shell.commandPalette.unnamedKey"),
subtitle: (
<>
<span className="font-mono">{key.key_name}</span>
@ -163,25 +172,38 @@ function CommandPaletteContent({ setOpen }: { setOpen: (open: boolean) => void }
</>
),
icon: <KeyRound />,
typeLabel: "Key",
typeLabel: "shell.commandPalette.types.key",
key,
};
});
const keysGroups =
keyEntries.length > 0 ? [{ heading: trimmedQuery ? "Keys" : "Recent keys", entries: keyEntries }] : [];
keyEntries.length > 0
? [
{
heading: trimmedQuery
? t("shell.commandPalette.groups.keys")
: t("shell.commandPalette.groups.recentKeys"),
entries: keyEntries,
},
]
: [];
if (!trimmedQuery) return keysGroups;
const filterAction: PaletteEntry = {
id: "action:filter-keys",
kind: "filter",
title: `Filter the keys table for “${trimmedQuery}”`,
subtitle: "Open Virtual Keys with this search applied",
title: t("shell.commandPalette.filterKeys.title", { query: trimmedQuery }),
subtitle: t("shell.commandPalette.filterKeys.subtitle"),
icon: <Search />,
typeLabel: "Action",
typeLabel: "shell.commandPalette.types.action",
};
return [...keysGroups, { heading: "Actions", entries: [filterAction] }, ...pagesGroups];
}, [hasKeyError, isLoadingKeys, keyResults.data?.keys, navMatches, scope, teamAliases, trimmedQuery]);
return [
...keysGroups,
{ heading: t("shell.commandPalette.groups.actions"), entries: [filterAction] },
...pagesGroups,
];
}, [hasKeyError, isLoadingKeys, keyResults.data?.keys, navMatches, scope, t, teamAliases, trimmedQuery]);
const resultSet = [scope, query, isLoadingKeys].join("\n");
const [highlight, setHighlight] = useState<{ value: string; resultSet: string } | null>(null);
@ -216,7 +238,7 @@ function CommandPaletteContent({ setOpen }: { setOpen: (open: boolean) => void }
return (
<Command
label="Search"
label={t("shell.commandPalette.label")}
shouldFilter={false}
loop
value={highlightedValue}
@ -228,23 +250,28 @@ function CommandPaletteContent({ setOpen }: { setOpen: (open: boolean) => void }
<InputGroupAddon>
<Badge variant="secondary">
<KeyRound />
Virtual Keys
{t("shell.commandPalette.virtualKeys")}
</Badge>
</InputGroupAddon>
)}
<CommandInput
ref={inputRef}
placeholder={scope === "keys" ? "Search keys by alias or ID…" : "Search pages and actions…"}
placeholder={
scope === "keys" ? t("shell.commandPalette.placeholder.keys") : t("shell.commandPalette.placeholder.global")
}
value={query}
onValueChange={setQuery}
/>
</div>
<CommandList onMouseDown={(event) => event.preventDefault()}>
{isLoadingKeys && !hasKeyError ? (
<CommandPrimitive.Loading label="Searching…" className="py-6 text-center text-sm text-muted-foreground">
<CommandPrimitive.Loading
label={t("shell.commandPalette.searching")}
className="py-6 text-center text-sm text-muted-foreground"
>
<span className="inline-flex items-center gap-2">
<LoaderCircle className="size-4 animate-spin" />
Searching…
{t("shell.commandPalette.searching")}
</span>
</CommandPrimitive.Loading>
) : (
@ -259,7 +286,7 @@ function CommandPaletteContent({ setOpen }: { setOpen: (open: boolean) => void }
<span className="max-w-full shrink-0 truncate">{entry.title}</span>
{entry.subtitle && <span className="truncate text-xs text-muted-foreground">{entry.subtitle}</span>}
</span>
<CommandShortcut>{entry.typeLabel}</CommandShortcut>
<CommandShortcut>{t(entry.typeLabel)}</CommandShortcut>
</CommandItem>
))}
</CommandGroup>

View file

@ -1,6 +1,7 @@
"use client";
import { Search, X } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { z } from "zod";
import { Kbd } from "@/components/ui/kbd";
import { storageKey } from "@/lib/storage";
@ -16,6 +17,7 @@ interface CommandPaletteHintProps {
}
export function CommandPaletteHint({ scope, onDismiss }: CommandPaletteHintProps) {
const { t } = useTranslation();
const { setOpen } = useCommandPalette();
const shortcut = useShortcutLabel();
const mounted = useClientMounted();
@ -26,14 +28,15 @@ export function CommandPaletteHint({ scope, onDismiss }: CommandPaletteHintProps
<div className="fixed bottom-4 left-1/2 z-overlay hidden -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-background/80 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur animate-in fade-in slide-in-from-bottom-2 duration-300 md:flex">
<button type="button" className="flex items-center gap-1.5 hover:text-foreground" onClick={() => setOpen(true)}>
<Search className="size-3.5" aria-hidden="true" />
<span>Press</span>
<Kbd>{shortcut}</Kbd>
<span>{scope === "keys" ? "to search keys" : "to search or jump to a page"}</span>
<Trans
i18nKey={scope === "keys" ? "shell.commandPaletteHint.keys" : "shell.commandPaletteHint.global"}
components={{ press: <span />, shortcut: <Kbd>{shortcut}</Kbd>, action: <span /> }}
/>
</button>
<button
type="button"
className="ml-0.5 flex size-5 items-center justify-center rounded-full hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Dismiss search hint"
aria-label={t("shell.commandPaletteHint.dismiss")}
onClick={onDismiss}
>
<X className="size-3" aria-hidden="true" />

View file

@ -3,6 +3,7 @@
import { cn } from "@/lib/cva.config";
import { Search } from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import { routeSegmentForPathname } from "@/utils/uiHref";
@ -11,11 +12,12 @@ import { useCommandPalette } from "./CommandPaletteProvider";
import { useShortcutLabel } from "./useShortcutLabel";
export function CommandPaletteTrigger({ mobile = false, className }: { mobile?: boolean; className?: string }) {
const { t } = useTranslation();
const pathname = usePathname();
const { open, setOpen } = useCommandPalette();
const shortcut = useShortcutLabel();
const isKeysRoute = paletteScopeForRoute(routeSegmentForPathname(pathname)) === "keys";
const label = isKeysRoute ? "Search keys…" : "Search or jump to…";
const label = isKeysRoute ? t("shell.commandPaletteTrigger.keys") : t("shell.commandPaletteTrigger.global");
if (mobile) {
return (
@ -23,7 +25,7 @@ export function CommandPaletteTrigger({ mobile = false, className }: { mobile?:
variant="ghost"
size="icon"
className={cn("text-muted-foreground", className ?? "size-11 md:hidden")}
aria-label="Search"
aria-label={t("shell.commandPaletteTrigger.search")}
aria-expanded={open}
onClick={() => setOpen(true)}
>

View file

@ -1,12 +1,15 @@
"use client";
import { useSyncExternalStore } from "react";
import i18n from "@/i18n/i18n";
import { shortcutLabel } from "./utils";
const subscribeToShortcut = () => () => {};
const getClientShortcut = () =>
typeof navigator === "undefined" ? "Ctrl K" : shortcutLabel(navigator.platform, navigator.userAgent);
const getServerShortcut = () => "Ctrl K";
typeof navigator === "undefined"
? i18n.t("shell.shortcutLabel.ctrlShortcut")
: shortcutLabel(navigator.platform, navigator.userAgent);
const getServerShortcut = () => i18n.t("shell.shortcutLabel.ctrlShortcut");
const subscribeToMount = () => () => {};
const getClientMounted = () => true;
const getServerMounted = () => false;

View file

@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { labelText, routeOf, sectionText, type MenuGroup } from "@/components/leftnav";
import { keepTerminology, type Term } from "@/lib/terminology";
import i18n from "@/i18n/i18n";
export type PaletteScope = "keys" | "global";
@ -57,4 +58,6 @@ export const matchNavItems = (items: readonly PaletteNavItem[], query: string):
};
export const shortcutLabel = (platform: string, userAgent: string): string =>
[platform, userAgent].some((value) => value.toLowerCase().includes("mac")) ? "⌘K" : "Ctrl K";
[platform, userAgent].some((value) => value.toLowerCase().includes("mac"))
? "⌘K"
: i18n.t("shell.commandPaletteUtils.ctrlShortcut");

View file

@ -2,6 +2,7 @@
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { CircleAlert, Shield, TriangleAlert } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
type DeploymentWarning,
useDeploymentHealthAccess,
@ -11,6 +12,7 @@ import { Alert, AlertTitle } from "@/components/shared/Alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { SheetTrigger } from "@/components/ui/sheet";
import i18n from "@/i18n/i18n";
import { deploymentWarningContent } from "./DeploymentWarningContent";
export type DeploymentHealthHandle = DialogPrimitive.Handle<unknown>;
@ -20,9 +22,10 @@ export const createDeploymentHealthHandle = (): DeploymentHealthHandle => Dialog
export const activeWarnings = (warnings: readonly DeploymentWarning[]): DeploymentWarning[] =>
warnings.filter((warning) => !warning.ignore);
const pluralWarnings = (count: number): string => `${count} active ${count === 1 ? "warning" : "warnings"}`;
const pluralWarnings = (count: number): string => i18n.t("shell.deploymentHealth.activeWarnings", { count });
export const DeploymentHealthIcon = ({ handle }: { handle: DeploymentHealthHandle }) => {
const { t } = useTranslation();
const { canView } = useDeploymentHealthAccess();
const { data } = useDeploymentWarnings();
if (!canView) return null;
@ -35,7 +38,11 @@ export const DeploymentHealthIcon = ({ handle }: { handle: DeploymentHealthHandl
<SheetTrigger
handle={handle}
render={<Button variant="ghost" size="icon" className="relative text-muted-foreground hover:text-foreground" />}
aria-label={active.length > 0 ? `Deployment health, ${pluralWarnings(active.length)}` : "Deployment health"}
aria-label={
active.length > 0
? t("shell.deploymentHealth.icon.withCount", { warnings: pluralWarnings(active.length) })
: t("shell.deploymentHealth.icon.label")
}
>
<Shield className="size-4" aria-hidden />
{active.length > 0 ? (
@ -52,6 +59,7 @@ export const DeploymentHealthIcon = ({ handle }: { handle: DeploymentHealthHandl
};
export const DeploymentHealthLink = ({ handle }: { handle: DeploymentHealthHandle }) => {
const { t } = useTranslation();
const { canView } = useDeploymentHealthAccess();
if (!canView) return null;
return (
@ -66,12 +74,13 @@ export const DeploymentHealthLink = ({ handle }: { handle: DeploymentHealthHandl
}
>
<Shield className="size-4" aria-hidden />
Deployment health
{t("shell.deploymentHealth.link.label")}
</SheetTrigger>
);
};
export const DeploymentHealthBanner = ({ handle }: { handle: DeploymentHealthHandle }) => {
const { t } = useTranslation();
const { canView } = useDeploymentHealthAccess();
const { data } = useDeploymentWarnings();
const active = activeWarnings(data?.warnings ?? []);
@ -84,7 +93,7 @@ export const DeploymentHealthBanner = ({ handle }: { handle: DeploymentHealthHan
<Alert
variant={critical ? "error" : "warning"}
role={critical ? "alert" : "status"}
aria-label="Deployment alert"
aria-label={t("shell.deploymentHealth.banner.label")}
className="min-h-10 shrink-0 items-center *:[svg]:text-warning data-[variant=error]:*:[svg]:text-destructive rounded-none border-x-0 border-t-0 border-b-warning/25 bg-warning/10 px-4 py-2 text-[13px] leading-5 text-foreground data-[variant=error]:border-b-destructive/25 data-[variant=error]:bg-destructive/10"
>
<Icon aria-hidden />
@ -96,7 +105,7 @@ export const DeploymentHealthBanner = ({ handle }: { handle: DeploymentHealthHan
<Button variant="link" className="h-auto p-0 text-[13px] leading-5 font-medium text-foreground underline" />
}
>
Review
{t("shell.deploymentHealth.banner.review")}
</SheetTrigger>
</AlertTitle>
</Alert>

View file

@ -10,6 +10,7 @@ import {
ShieldCheck,
TriangleAlert,
} from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import {
type DeploymentWarning,
useDeploymentHealthAccess,
@ -38,14 +39,14 @@ import { deploymentWarningContent } from "./DeploymentWarningContent";
const DAY_SECONDS = 24 * 60 * 60;
const SNOOZE_OPTIONS = [
{ label: "Snooze for 1 day", seconds: DAY_SECONDS },
{ label: "Snooze for 7 days", seconds: 7 * DAY_SECONDS },
{ label: "Snooze for 30 days", seconds: 30 * DAY_SECONDS },
{ label: "shell.deploymentHealthPanel.snooze.oneDay", seconds: DAY_SECONDS },
{ label: "shell.deploymentHealthPanel.snooze.sevenDays", seconds: 7 * DAY_SECONDS },
{ label: "shell.deploymentHealthPanel.snooze.thirtyDays", seconds: 30 * DAY_SECONDS },
] as const;
const SEVERITY_GROUPS = [
{ severity: "critical", label: "Critical" },
{ severity: "warning", label: "Warnings" },
{ severity: "critical", label: "shell.deploymentHealthPanel.severity.critical" },
{ severity: "warning", label: "shell.deploymentHealthPanel.severity.warning" },
] as const;
const LOCAL_DAY_FORMAT = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });
@ -64,6 +65,7 @@ interface ItemPermissions {
type IgnoreState = NonNullable<DeploymentWarning["ignore"]>;
const SnoozeMenu = ({ warningId, title }: { warningId: string; title: string }) => {
const { t } = useTranslation();
const update = useUpdateDeploymentWarningIgnore();
const ignore = (snoozeSeconds?: number) =>
update.mutate({ warningId, update: { ignored: true, snooze_seconds: snoozeSeconds } });
@ -76,24 +78,24 @@ const SnoozeMenu = ({ warningId, title }: { warningId: string; title: string })
size="sm"
className="h-7 text-[13px]"
disabled={update.isPending}
aria-label={`Snooze ${title}`}
aria-label={t("shell.deploymentHealthPanel.snooze.triggerAria", { title })}
/>
}
>
Snooze
{t("shell.deploymentHealthPanel.snooze.trigger")}
<ChevronDown data-icon="inline-end" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48 [&_[data-slot=dropdown-menu-item]]:whitespace-nowrap">
{SNOOZE_OPTIONS.map(({ label, seconds }) => (
<DropdownMenuItem key={seconds} onClick={() => ignore(seconds)}>
<Clock aria-hidden />
{label}
{t(label)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => ignore()}>
<BellOff aria-hidden />
Ignore forever
{t("shell.deploymentHealthPanel.snooze.forever")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -101,6 +103,7 @@ const SnoozeMenu = ({ warningId, title }: { warningId: string; title: string })
};
const RestoreButton = ({ warningId, title }: { warningId: string; title: string }) => {
const { t } = useTranslation();
const update = useUpdateDeploymentWarningIgnore();
return (
<Button
@ -108,10 +111,10 @@ const RestoreButton = ({ warningId, title }: { warningId: string; title: string
size="sm"
className="h-7 text-[13px]"
disabled={update.isPending}
aria-label={`Restore ${title}`}
aria-label={t("shell.deploymentHealthPanel.restore.aria", { title })}
onClick={() => update.mutate({ warningId, update: { ignored: false } })}
>
Restore
{t("shell.deploymentHealthPanel.restore.label")}
</Button>
);
};
@ -128,10 +131,14 @@ const ItemAction = ({
};
const IgnoreMeta = ({ ignore }: { ignore: IgnoreState }) => {
const { t } = useTranslation();
if (ignore.source === "env_var") {
return (
<span>
By env var <code className="font-mono wrap-anywhere">{ignore.env_var}</code>
<Trans
i18nKey="shell.deploymentHealthPanel.ignoreMeta.envVar"
components={{ envvar: <code className="font-mono wrap-anywhere">{ignore.env_var}</code> }}
/>
</span>
);
}
@ -140,28 +147,34 @@ const IgnoreMeta = ({ ignore }: { ignore: IgnoreState }) => {
<span>
<SimpleTooltip content={formatFullUtc(ignore.ignored_until)}>
<span className="underline decoration-dotted underline-offset-3">
Until {formatLocalMinute(ignore.ignored_until)}
{t("shell.deploymentHealthPanel.ignoreMeta.until", { time: formatLocalMinute(ignore.ignored_until) })}
</span>
</SimpleTooltip>{" "}
· by {ignore.ignored_by}
{t("shell.deploymentHealthPanel.ignoreMeta.byUser", { user: ignore.ignored_by })}
</span>
);
}
return (
<span>
Since {formatLocalDay(ignore.ignored_at)} · by {ignore.ignored_by}
{t("shell.deploymentHealthPanel.ignoreMeta.since", {
date: formatLocalDay(ignore.ignored_at),
user: ignore.ignored_by,
})}
</span>
);
};
const IgnoreStatus = ({ ignore }: { ignore: IgnoreState }) => {
const { t } = useTranslation();
const snoozed = ignore.source === "admin" && Boolean(ignore.ignored_until);
const Icon = snoozed ? Clock : BellOff;
return (
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[12px] leading-4 text-muted-foreground">
<Badge variant="outline" className="gap-1 bg-muted text-muted-foreground">
<Icon aria-hidden />
{snoozed ? "Snoozed" : "Ignored"}
{snoozed
? t("shell.deploymentHealthPanel.ignoreStatus.snoozed")
: t("shell.deploymentHealthPanel.ignoreStatus.ignored")}
</Badge>
<IgnoreMeta ignore={ignore} />
</div>
@ -169,6 +182,7 @@ const IgnoreStatus = ({ ignore }: { ignore: IgnoreState }) => {
};
const WarningItem = ({ warning, ...permissions }: ItemPermissions & { warning: DeploymentWarning }) => {
const { t } = useTranslation();
const content = deploymentWarningContent(warning.id);
const critical = warning.severity === "critical";
const SeverityIcon = critical ? CircleAlert : TriangleAlert;
@ -196,7 +210,7 @@ const WarningItem = ({ warning, ...permissions }: ItemPermissions & { warning: D
"h-auto gap-1 p-0 text-[13px] leading-5 font-medium text-foreground no-underline hover:underline",
)}
>
Read the docs
{t("shell.deploymentHealthPanel.item.docs")}
<ExternalLink className="size-3.5 text-muted-foreground" aria-hidden />
</a>
) : null}
@ -251,36 +265,42 @@ const PanelSkeleton = () => (
</div>
);
const NoActiveWarnings = ({ hasIgnored }: { hasIgnored: boolean }) => (
<Empty className="gap-2 p-0 py-6">
<EmptyHeader>
<EmptyMedia
variant="icon"
className="border border-border bg-transparent text-muted-foreground [&_svg:not([class*='size-'])]:size-4"
>
<ShieldCheck aria-hidden />
</EmptyMedia>
<EmptyTitle className="text-[15px] leading-[22px] font-semibold tracking-normal">No active warnings</EmptyTitle>
<EmptyDescription className="text-[13px] leading-5">
{hasIgnored
? "Snoozed and ignored warnings are listed below"
: "Deployment warnings show up here when this proxy\u2019s setup needs attention"}
</EmptyDescription>
</EmptyHeader>
</Empty>
);
const NoActiveWarnings = ({ hasIgnored }: { hasIgnored: boolean }) => {
const { t } = useTranslation();
return (
<Empty className="gap-2 p-0 py-6">
<EmptyHeader>
<EmptyMedia
variant="icon"
className="border border-border bg-transparent text-muted-foreground [&_svg:not([class*='size-'])]:size-4"
>
<ShieldCheck aria-hidden />
</EmptyMedia>
<EmptyTitle className="text-[15px] leading-[22px] font-semibold tracking-normal">
{t("shell.deploymentHealthPanel.empty.title")}
</EmptyTitle>
<EmptyDescription className="text-[13px] leading-5">
{hasIgnored
? t("shell.deploymentHealthPanel.empty.ignoredBelow")
: t("shell.deploymentHealthPanel.empty.description")}
</EmptyDescription>
</EmptyHeader>
</Empty>
);
};
const PanelBody = () => {
const { t } = useTranslation();
const { canManage } = useDeploymentHealthAccess();
const { data, isError } = useDeploymentWarnings({ refetchOnOpen: true });
if (!data) {
return (
<div className="flex flex-col gap-2 px-4 pb-4 text-[13px] leading-5">
{isError ? (
<p className="text-destructive">Could not load deployment warnings</p>
<p className="text-destructive">{t("shell.deploymentHealthPanel.body.loadError")}</p>
) : (
<>
<span className="sr-only">Loading deployment warnings</span>
<span className="sr-only">{t("shell.deploymentHealthPanel.body.loading")}</span>
<PanelSkeleton />
</>
)}
@ -298,24 +318,36 @@ const PanelBody = () => {
{SEVERITY_GROUPS.map(({ severity, label }) => {
const group = active.filter((warning) => warning.severity === severity);
return group.length === 0 ? null : (
<WarningGroup key={severity} label={label} warnings={group} defaultOpen permissions={permissions} />
<WarningGroup key={severity} label={t(label)} warnings={group} defaultOpen permissions={permissions} />
);
})}
{ignored.length > 0 ? (
<WarningGroup label="Ignored and snoozed" warnings={ignored} defaultOpen={false} permissions={permissions} />
<WarningGroup
label={t("shell.deploymentHealthPanel.body.ignoredGroup")}
warnings={ignored}
defaultOpen={false}
permissions={permissions}
/>
) : null}
</div>
);
};
export const DeploymentHealthPanel = ({ handle }: { handle: DeploymentHealthHandle }) => (
<Sheet handle={handle}>
<SheetContent side="right" className="gap-0 data-[side=right]:sm:max-w-md">
<SheetHeader className="gap-0.5 pr-12">
<SheetTitle className="text-[15px] leading-[22px] font-semibold">Deployment health</SheetTitle>
<SheetDescription className="text-[13px] leading-5">Warnings about how this proxy is deployed</SheetDescription>
</SheetHeader>
<PanelBody />
</SheetContent>
</Sheet>
);
export const DeploymentHealthPanel = ({ handle }: { handle: DeploymentHealthHandle }) => {
const { t } = useTranslation();
return (
<Sheet handle={handle}>
<SheetContent side="right" className="gap-0 data-[side=right]:sm:max-w-md">
<SheetHeader className="gap-0.5 pr-12">
<SheetTitle className="text-[15px] leading-[22px] font-semibold">
{t("shell.deploymentHealthPanel.sheet.title")}
</SheetTitle>
<SheetDescription className="text-[13px] leading-5">
{t("shell.deploymentHealthPanel.sheet.description")}
</SheetDescription>
</SheetHeader>
<PanelBody />
</SheetContent>
</Sheet>
);
};

View file

@ -1,4 +1,6 @@
import type { ReactNode } from "react";
import { Trans } from "react-i18next";
import i18n from "@/i18n/i18n";
import { formatExpiryDate, getDaysUntilExpiration } from "@/utils/licenseUtils";
const LICENSE_EXPIRY_PREFIX = "license_expiry:";
@ -14,41 +16,38 @@ const STATIC_CONTENT: Readonly<Record<string, DeploymentWarningContent>> = {
env_credential_login: {
title: "Environment-credential login is enabled",
fix: (
<>
Anyone with <code>UI_USERNAME</code>/<code>UI_PASSWORD</code> (or the master key, when <code>UI_PASSWORD</code>{" "}
is unset) can sign in as a proxy admin with a shared static secret. Create a regular admin account with its own
password, then set <code>general_settings.disable_env_credential_login: true</code>
</>
<Trans
i18nKey="shell.deploymentWarnings.envCredentialLogin.fix"
components={{
username: <code>UI_USERNAME</code>,
password: <code>UI_PASSWORD</code>,
setting: <code>general_settings.disable_env_credential_login: true</code>,
}}
/>
),
docsUrl: "https://docs.litellm.ai/docs/proxy/ui",
},
no_redis: {
title: "No Redis configured",
fix: (
<>
This proxy runs more than one worker (or the worker count could not be verified). Without Redis, rate limits,
budgets, router state and cache invalidation are per worker, so limits are enforced once per worker and spend
can overshoot
</>
),
fix: <Trans i18nKey="shell.deploymentWarnings.noRedis.fix" />,
docsUrl: "https://docs.litellm.ai/docs/proxy/redis_requirements",
},
detailed_debug: {
title: "Detailed debug logging is on",
fix: (
<>
<code>LITELLM_LOG=DEBUG</code> logs extensive diagnostic information and significantly slows the proxy. Use it
only while troubleshooting and turn it off in production
</>
<Trans
i18nKey="shell.deploymentWarnings.detailedDebug.fix"
components={{ loglevel: <code>LITELLM_LOG=DEBUG</code> }}
/>
),
docsUrl: "https://docs.litellm.ai/docs/proxy/prod",
},
};
const describeCountdown = (days: number): string => {
if (days <= 0) return "expires today";
if (days === 1) return "expires in 1 day";
return `expires in ${days} days`;
if (days <= 0) return i18n.t("shell.deploymentWarnings.license.expiresToday");
if (days === 1) return i18n.t("shell.deploymentWarnings.license.expiresIn", { count: 1 });
return i18n.t("shell.deploymentWarnings.license.expiresIn", { count: days });
};
const licenseExpiryContent = (expirationDate: string, now: Date): DeploymentWarningContent => {
@ -57,13 +56,16 @@ const licenseExpiryContent = (expirationDate: string, now: Date): DeploymentWarn
const salesLink = <a href={`mailto:${SALES_EMAIL}`}>{SALES_EMAIL}</a>;
return days < 0
? {
title: `Your LiteLLM Enterprise license expired on ${formattedDate}`,
fix: <>Enterprise features are now disabled. Reach out to {salesLink} to restore access</>,
title: i18n.t("shell.deploymentWarnings.license.expiredTitle", { date: formattedDate }),
fix: <Trans i18nKey="shell.deploymentWarnings.license.expiredFix" components={{ sales: salesLink }} />,
docsUrl: "https://docs.litellm.ai/docs/enterprise",
}
: {
title: `Your LiteLLM Enterprise license ${describeCountdown(days)} (${formattedDate})`,
fix: <>Renew before it lapses to keep enterprise features. Reach out to {salesLink}</>,
title: i18n.t("shell.deploymentWarnings.license.expiringTitle", {
countdown: describeCountdown(days),
date: formattedDate,
}),
fix: <Trans i18nKey="shell.deploymentWarnings.license.expiringFix" components={{ sales: salesLink }} />,
docsUrl: "https://docs.litellm.ai/docs/enterprise",
};
};

View file

@ -1,6 +1,7 @@
"use client";
import React from "react";
import { Trans, useTranslation } from "react-i18next";
import Link from "next/link";
import { Info } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@ -11,16 +12,21 @@ interface DeprecationBannerProps {
featureName: string;
}
export const DeprecationBanner: React.FC<DeprecationBannerProps> = ({ featureName }) => (
<Alert className="mb-4">
<Info />
<AlertTitle>{`${featureName} is deprecated`}</AlertTitle>
<AlertDescription>
{`${featureName} will be removed in the near future. See the `}
<Link href={DEPRECATION_DISCUSSION_URL} target="_blank" rel="noopener noreferrer">
deprecation discussion
</Link>
{" for details."}
</AlertDescription>
</Alert>
);
export const DeprecationBanner: React.FC<DeprecationBannerProps> = ({ featureName }) => {
const { t } = useTranslation();
return (
<Alert className="mb-4">
<Info />
<AlertTitle>{t("shell.deprecationBanner.title", { featureName })}</AlertTitle>
<AlertDescription>
<Trans
i18nKey="shell.deprecationBanner.description"
components={{
feature: <>{featureName}</>,
discussion: <Link href={DEPRECATION_DISCUSSION_URL} target="_blank" rel="noopener noreferrer" />,
}}
/>
</AlertDescription>
</Alert>
);
};

View file

@ -11,6 +11,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { ChevronDown, LoaderCircle } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
function formatDate(dateStr: string): string {
const date = new Date(dateStr + "T00:00:00");
@ -22,6 +23,7 @@ function formatDate(dateStr: string): string {
}
export const BlogDropdown: React.FC = () => {
const { t } = useTranslation();
const disableBlogPosts = useDisableBlogPosts();
const { data, isLoading, isError, refetch } = useBlogPosts();
@ -34,7 +36,7 @@ export const BlogDropdown: React.FC = () => {
if (isLoading) {
return (
<div className="flex items-center px-2 py-1.5 text-sm">
<LoaderCircle role="img" aria-label="loading" className="size-4 animate-spin" />
<LoaderCircle role="img" aria-label={t("shell.blogDropdown.loading")} className="size-4 animate-spin" />
</div>
);
}
@ -42,16 +44,16 @@ export const BlogDropdown: React.FC = () => {
if (isError) {
return (
<div className="flex items-center gap-2 px-2 py-1.5 text-sm">
<span className="text-destructive">Failed to load posts</span>
<span className="text-destructive">{t("shell.blogDropdown.failed")}</span>
<Button variant="outline" size="sm" onClick={() => refetch()}>
Retry
{t("shell.blogDropdown.retry")}
</Button>
</div>
);
}
if (!data || data.posts.length === 0) {
return <div className="px-2 py-1.5 text-sm text-muted-foreground">No posts available</div>;
return <div className="px-2 py-1.5 text-sm text-muted-foreground">{t("shell.blogDropdown.empty")}</div>;
}
return (
@ -72,7 +74,7 @@ export const BlogDropdown: React.FC = () => {
<DropdownMenuSeparator />
<DropdownMenuItem>
<a href="https://docs.litellm.ai/blog" target="_blank" rel="noopener noreferrer">
View all posts
{t("shell.blogDropdown.viewAll")}
</a>
</DropdownMenuItem>
</>
@ -87,7 +89,7 @@ export const BlogDropdown: React.FC = () => {
closeDelay={100}
render={<Button variant="ghost" className={`${NAV_PRODUCT_LINK_CLASS} border-0!`} />}
>
Blog
{t("shell.blogDropdown.trigger")}
<ChevronDown className="size-2.5 text-muted-foreground" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className="w-auto">

View file

@ -5,23 +5,25 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { cn } from "@/lib/cva.config";
import { Github, Slack } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
const COMMUNITY_LINKS = [
{
href: "https://www.litellm.ai/support",
label: "Join Slack",
tooltip: "LiteLLM Slack community",
label: "shell.communityButtons.slackLabel",
tooltip: "shell.communityButtons.slackTooltip",
Icon: Slack,
},
{
href: "https://github.com/BerriAI/litellm",
label: "LiteLLM on GitHub",
tooltip: "LiteLLM on GitHub",
label: "shell.communityButtons.githubLabel",
tooltip: "shell.communityButtons.githubTooltip",
Icon: Github,
},
] as const;
export const CommunityEngagementButtons: React.FC = () => {
const { t } = useTranslation();
const disableShowPrompts = useDisableShowPrompts();
if (disableShowPrompts) {
@ -30,7 +32,7 @@ export const CommunityEngagementButtons: React.FC = () => {
return (
<TooltipProvider>
<ButtonGroup aria-label="Community links">
<ButtonGroup aria-label={t("shell.communityButtons.label")}>
{COMMUNITY_LINKS.map(({ href, label, tooltip, Icon }) => (
<Tooltip key={href}>
<TooltipTrigger
@ -39,14 +41,14 @@ export const CommunityEngagementButtons: React.FC = () => {
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
aria-label={t(label)}
className={cn(buttonVariants({ variant: "outline", size: "icon" }), "text-muted-foreground")}
/>
}
>
<Icon />
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
<TooltipContent>{t(tooltip)}</TooltipContent>
</Tooltip>
))}
</ButtonGroup>

View file

@ -1,6 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { BookOpen, Github, Newspaper, ScrollText, Slack } from "lucide-react";
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
@ -19,10 +20,25 @@ function DiscordIcon() {
}
const COMMUNITY = [
{ href: "https://www.litellm.ai/support", label: "Slack", accessibleLabel: "Join Slack", Icon: Slack },
{ href: "https://discord.gg/wuPM9dRgDw", label: "Discord", accessibleLabel: "Join Discord", Icon: DiscordIcon },
{ href: "https://github.com/BerriAI/litellm", label: "GitHub", accessibleLabel: "LiteLLM on GitHub", Icon: Github },
];
{
href: "https://www.litellm.ai/support",
label: "Slack",
accessibleLabel: "shell.dashboardCommunityLinks.joinSlack",
Icon: Slack,
},
{
href: "https://discord.gg/wuPM9dRgDw",
label: "Discord",
accessibleLabel: "shell.dashboardCommunityLinks.joinDiscord",
Icon: DiscordIcon,
},
{
href: "https://github.com/BerriAI/litellm",
label: "GitHub",
accessibleLabel: "shell.dashboardCommunityLinks.github",
Icon: Github,
},
] as const;
const DOCS = [
{ href: "https://docs.litellm.ai/docs/", label: "Docs", Icon: BookOpen },
{ href: "https://docs.litellm.ai/release_notes", label: "Releases", Icon: ScrollText },
@ -30,10 +46,11 @@ const DOCS = [
const BLOG = { href: "https://docs.litellm.ai/blog", label: "Blog", Icon: Newspaper };
export function DashboardCommunityLinks({ className }: { className?: string }) {
const { t } = useTranslation();
const hidden = useDisableShowPrompts();
if (hidden) return null;
return (
<nav aria-label="Community links" className={cn("items-center gap-0.5", className)}>
<nav aria-label={t("shell.dashboardCommunityLinks.label")} className={cn("items-center gap-0.5", className)}>
<TooltipProvider>
{COMMUNITY.map(({ href, label, accessibleLabel, Icon }) => (
<Tooltip key={label}>
@ -43,7 +60,7 @@ export function DashboardCommunityLinks({ className }: { className?: string }) {
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={accessibleLabel}
aria-label={t(accessibleLabel)}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "text-muted-foreground")}
/>
}
@ -60,6 +77,7 @@ export function DashboardCommunityLinks({ className }: { className?: string }) {
}
export function DashboardResources({ children }: { children?: ReactNode }) {
const { t } = useTranslation();
const hideBlog = useDisableBlogPosts();
const hideCommunity = useDisableShowPrompts();
const { data, isPending, isError, refetch } = useBlogPosts();
@ -67,11 +85,13 @@ export function DashboardResources({ children }: { children?: ReactNode }) {
return (
<>
{!hideBlog && (
<section aria-label="Latest posts" className="border-t border-border px-2 py-2">
{isPending && <p className="px-2 py-3 text-xs text-muted-foreground">Loading posts…</p>}
<section aria-label={t("shell.dashboardResources.latestPosts")} className="border-t border-border px-2 py-2">
{isPending && (
<p className="px-2 py-3 text-xs text-muted-foreground">{t("shell.dashboardResources.loadingPosts")}</p>
)}
{isError && (
<Button variant="ghost" size="sm" onClick={() => void refetch()}>
Retry loading posts
{t("shell.dashboardResources.retryPosts")}
</Button>
)}
{data?.posts.slice(0, 3).map((post) => (
@ -91,7 +111,7 @@ export function DashboardResources({ children }: { children?: ReactNode }) {
</section>
)}
<div className="border-t border-border bg-muted/25 p-2">
<nav aria-label="Help and resources" className="grid grid-cols-3 gap-1">
<nav aria-label={t("shell.dashboardResources.helpAndResources")} className="grid grid-cols-3 gap-1">
{links.map(({ href, label, Icon }) => (
<a
key={label}

View file

@ -1,6 +1,7 @@
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
import { ChevronDown } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
export const DOCS_URL = "https://docs.litellm.ai/docs/";
@ -8,11 +9,14 @@ const ChevronWidthSpacer: React.FC = () => (
<ChevronDown className="pointer-events-none size-2.5 opacity-0" aria-hidden />
);
export const DocsLink: React.FC = () => (
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className={NAV_PRODUCT_LINK_CLASS}>
Docs
<ChevronWidthSpacer />
</a>
);
export const DocsLink: React.FC = () => {
const { t } = useTranslation();
return (
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className={NAV_PRODUCT_LINK_CLASS}>
{t("shell.docsLink.label")}
<ChevronWidthSpacer />
</a>
);
};
export default DocsLink;

View file

@ -19,6 +19,8 @@ import { cn } from "@/lib/cva.config";
import { isNewerVersion } from "@/utils/versionUtils";
import { ArrowUpRight, Bell } from "lucide-react";
import React, { Fragment } from "react";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n/i18n";
export const AUTO_ROUTER_DOCS_URL = "https://docs.litellm.ai/docs/proxy/auto_routing";
@ -38,14 +40,11 @@ const AUTO_ROUTER_ANNOUNCEMENT: Announcement = {
href: AUTO_ROUTER_DOCS_URL,
};
const plural = (count: number, singular: string, pluralForm: string): string =>
`${count} ${count === 1 ? singular : pluralForm}`;
const describeRelease = ({ new_features, bug_fixes, other_updates }: LatestReleaseInfo): string =>
[
plural(new_features, "new feature", "new features"),
plural(bug_fixes, "fix", "fixes"),
`and ${plural(other_updates, "other update", "other updates")}`,
i18n.t("shell.notifications.release.newFeatures", { count: new_features }),
i18n.t("shell.notifications.release.fixes", { count: bug_fixes }),
i18n.t("shell.notifications.release.otherUpdates", { count: other_updates }),
].join(", ");
const upgradeAnnouncement = (
@ -57,14 +56,18 @@ const upgradeAnnouncement = (
}
return {
readKey: `litellm:upgradeBannerDismissed:${latestRelease.version}`,
title: `LiteLLM v${latestRelease.version} is available`,
description: `${describeRelease(latestRelease)}. Your current version is v${currentVersion}`,
linkLabel: "View release notes",
title: i18n.t("shell.notifications.upgrade.title", { version: latestRelease.version }),
description: i18n.t("shell.notifications.upgrade.description", {
summary: describeRelease(latestRelease),
current: currentVersion,
}),
linkLabel: i18n.t("shell.notifications.upgrade.link"),
href: latestRelease.release_url,
};
};
const AnnouncementItem: React.FC<{ announcement: Announcement }> = ({ announcement }) => {
const { t } = useTranslation();
const read = useAnnouncementRead(announcement.readKey);
return (
<article aria-label={announcement.title} className="flex flex-col gap-2">
@ -90,7 +93,7 @@ const AnnouncementItem: React.FC<{ announcement: Announcement }> = ({ announceme
className="h-auto px-0! text-xs font-normal text-muted-foreground"
onClick={() => markAnnouncementRead(announcement.readKey)}
>
Mark as read
{t("shell.notifications.announcement.markAsRead")}
</Button>
)}
</div>
@ -109,6 +112,7 @@ export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({
latestRelease,
deploymentHealthLink,
}) => {
const { t } = useTranslation();
const upgrade = upgradeAnnouncement(currentVersion, latestRelease);
const announcements = upgrade ? [upgrade, AUTO_ROUTER_ANNOUNCEMENT] : [AUTO_ROUTER_ANNOUNCEMENT];
const unreadCount = useUnreadAnnouncementCount(announcements.map((announcement) => announcement.readKey));
@ -117,7 +121,11 @@ export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({
<Popover>
<PopoverTrigger
render={<Button variant="ghost" size="icon" className="text-muted-foreground" />}
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
aria-label={
unreadCount > 0
? t("shell.notifications.bell.triggerUnread", { unread: unreadCount })
: t("shell.notifications.bell.trigger")
}
>
<span className="relative inline-flex">
<Bell className="size-4" aria-hidden />
@ -126,10 +134,12 @@ export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({
</PopoverTrigger>
<PopoverContent
align="end"
aria-label="Notifications"
aria-label={t("shell.notifications.bell.trigger")}
className="w-96 max-w-[calc(100vw-1rem)] gap-0 overflow-hidden p-0"
>
<PopoverTitle className="border-b border-border px-4 py-3 text-sm font-semibold">What&apos;s new</PopoverTitle>
<PopoverTitle className="border-b border-border px-4 py-3 text-sm font-semibold">
{t("shell.notifications.bell.title")}
</PopoverTitle>
<div className="max-h-[min(32rem,calc(100dvh-10rem))] overflow-y-auto">
<div className="space-y-4 p-4">
{announcements.map((announcement, index) => (

View file

@ -24,6 +24,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import CopyButton from "@/components/shared/CopyButton";
import { cn } from "@/lib/cva.config";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
function hueFromString(seed: string): number {
let h = 0;
@ -68,6 +69,7 @@ interface UserDropdownProps {
}
const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar", collapsed = false }) => {
const { t } = useTranslation();
const {
userId,
userEmail,
@ -101,16 +103,16 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
{premiumUser ? (
<Badge>
<Crown className="size-3" />
Premium
{t("shell.userMenu.premium")}
</Badge>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<Badge variant="outline" />}>
<Crown className="size-3" />
Standard
{t("shell.userMenu.standard")}
</TooltipTrigger>
<TooltipContent side="left">Upgrade to Premium for advanced features</TooltipContent>
<TooltipContent side="left">{t("shell.userMenu.upgradeHint")}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
@ -119,25 +121,25 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center gap-2">
<User className="size-4" />
<span className="text-muted-foreground">User ID</span>
<span className="text-muted-foreground">{t("shell.userMenu.userId")}</span>
</div>
<div className="flex items-center gap-1">
<span className="max-w-[150px] truncate" title={userId || "-"}>
{userId || "-"}
</span>
<CopyButton value={userId} label="Copy User ID" />
<CopyButton value={userId} label={t("shell.userMenu.copyUserId")} />
</div>
</div>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ShieldCheck className="size-4" />
<span className="text-muted-foreground">Role</span>
<span className="text-muted-foreground">{t("shell.userMenu.role")}</span>
</div>
<span>{userRole}</span>
</div>
<Separator className="my-2" />
<div className="flex w-full items-center justify-between gap-2">
<span className="text-muted-foreground">Hide New Feature Indicators</span>
<span className="text-muted-foreground">{t("shell.userMenu.hideNewBadges")}</span>
<Switch
size="sm"
checked={disableShowNewBadge}
@ -151,11 +153,11 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
emitLocalStorageChange("disableShowNewBadge");
}
}}
aria-label="Toggle hide new feature indicators"
aria-label={t("shell.userMenu.toggleHideNewBadges")}
/>
</div>
<div className="flex w-full items-center justify-between gap-2">
<span className="text-muted-foreground">Hide All Prompts</span>
<span className="text-muted-foreground">{t("shell.userMenu.hidePrompts")}</span>
<Switch
size="sm"
checked={disableShowPrompts}
@ -168,11 +170,11 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
emitLocalStorageChange("disableShowPrompts");
}
}}
aria-label="Toggle hide all prompts"
aria-label={t("shell.userMenu.toggleHidePrompts")}
/>
</div>
<div className="flex w-full items-center justify-between gap-2">
<span className="text-muted-foreground">Hide Blog Posts</span>
<span className="text-muted-foreground">{t("shell.userMenu.hideBlogPosts")}</span>
<Switch
size="sm"
checked={disableBlogPosts}
@ -185,11 +187,11 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
emitLocalStorageChange("disableBlogPosts");
}
}}
aria-label="Toggle hide blog posts"
aria-label={t("shell.userMenu.toggleHideBlogPosts")}
/>
</div>
<div className="flex w-full items-center justify-between gap-2">
<span className="text-muted-foreground">Hide Bouncing Icon</span>
<span className="text-muted-foreground">{t("shell.userMenu.hideBouncingIcon")}</span>
<Switch
size="sm"
checked={disableBouncingIcon}
@ -202,17 +204,17 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
emitLocalStorageChange("disableBouncingIcon");
}
}}
aria-label="Toggle hide bouncing icon"
aria-label={t("shell.userMenu.toggleHideBouncingIcon")}
/>
</div>
{premiumUser === true && canUseLiteAdmin && (
<div className="flex w-full items-center justify-between gap-2">
<span className="text-muted-foreground">Hide {ARIA_AGENT_LABEL}</span>
<span className="text-muted-foreground">{t("shell.userMenu.hideAgent", { agent: ARIA_AGENT_LABEL })}</span>
<Switch
size="sm"
checked={disableLiteAdmin}
onCheckedChange={setDisableLiteAdmin}
aria-label={`Toggle hide ${ARIA_AGENT_LABEL}`}
aria-label={t("shell.userMenu.toggleHideAgent", { agent: ARIA_AGENT_LABEL })}
/>
</div>
)}
@ -235,7 +237,10 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
"flex w-full items-center rounded-lg border border-transparent transition-colors hover:bg-sidebar-accent",
collapsed ? "justify-center px-0 py-1" : "gap-2.5 px-2 py-1.5 text-left",
)}
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
aria-label={t("shell.userMenu.triggerAria", {
role: userRole ?? t("shell.userMenu.unknownRole"),
identity: userEmail || userId || t("shell.userMenu.unknownIdentity"),
})}
aria-haspopup="dialog"
title={collapsed ? displayName : undefined}
/>
@ -262,7 +267,10 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
<button
type="button"
className="flex! max-w-[min(200px,34vw)] items-center gap-2 rounded-md! py-0.5! pl-1! pr-2! transition-colors hover:bg-accent!"
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
aria-label={t("shell.userMenu.triggerAria", {
role: userRole ?? t("shell.userMenu.unknownRole"),
identity: userEmail || userId || t("shell.userMenu.unknownIdentity"),
})}
aria-haspopup="dialog"
/>
}
@ -296,7 +304,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
>
<KeyRound className="size-4" />
Change Password
{t("shell.userMenu.changePassword")}
</button>
)}
<button
@ -305,7 +313,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
>
<LogOut className="size-4" />
Logout
{t("shell.userMenu.logout")}
</button>
</PopoverContent>
</Popover>

View file

@ -1,5 +1,6 @@
import { useTerminology } from "@/contexts/TerminologyContext";
import React from "react";
import { useTranslation } from "react-i18next";
import { usePathname } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
@ -13,6 +14,7 @@ import { usePluginMode } from "@/contexts/PluginModeContext";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { uiHref } from "@/utils/uiHref";
import { cn } from "@/lib/cva.config";
import i18n from "@/i18n/i18n";
const GATEWAY = "ai-gateway";
const CHAT = "chat";
@ -30,7 +32,7 @@ function activeWorkspaceLabel(
pluginLabel: string | undefined,
gatewayLabel: string,
): string {
if (isChatRoute) return "Chat";
if (isChatRoute) return i18n.t("shell.viewSwitcher.chat");
if (isMoyaiRoute) return "Moyai";
return pluginLabel ?? gatewayLabel;
}
@ -48,6 +50,7 @@ interface ViewSwitcherProps {
}
export default function ViewSwitcher({ className, contentClassName }: ViewSwitcherProps) {
const { t } = useTranslation();
const term = useTerminology();
const { mode, setMode, plugins } = usePluginMode();
const { data: uiSettings } = useUISettings();
@ -61,7 +64,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
const isMoyaiRoute = isWorkspaceRoute(normalizedPathname, MOYAI);
const isStandaloneRoute = isChatRoute || isMoyaiRoute;
const gatewayLabel = term("AI Gateway");
const gatewayLabel = term(t("shell.viewSwitcher.aiGateway"));
const activeLabel = activeWorkspaceLabel(
isChatRoute,
isMoyaiRoute,
@ -87,11 +90,11 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
label: (
<div className="flex w-full items-center justify-between gap-6 py-0.5">
<span className="flex max-w-[220px] flex-col">
<span className="font-medium">Chat</span>{" "}
<span className="text-xs leading-snug text-muted-foreground">Chat UI for using LLMs, MCPs, Agents</span>{" "}
<span className="font-medium">{t("shell.viewSwitcher.chat")}</span>{" "}
<span className="text-xs leading-snug text-muted-foreground">{t("shell.viewSwitcher.chatDescription")}</span>{" "}
{!chatEnabled && (
<span className="whitespace-normal text-xs leading-snug text-muted-foreground">
Admins can enable in Settings
{t("shell.viewSwitcher.chatDisabled")}
</span>
)}
</span>
@ -119,7 +122,9 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
<div className="flex w-full items-center justify-between gap-6 py-0.5">
<span className="flex flex-col">
<span className="font-medium">Moyai</span>{" "}
<span className="text-xs leading-snug text-muted-foreground">Cloud Coding Agent</span>
<span className="text-xs leading-snug text-muted-foreground">
{t("shell.viewSwitcher.moyaiDescription")}
</span>
</span>
{isMoyaiRoute && <Check className="size-4 text-info" />}
</div>
@ -131,7 +136,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label={`Switch workspace, current: ${activeLabel}`}
aria-label={t("shell.viewSwitcher.trigger", { workspace: activeLabel })}
render={
<Button
type="button"

View file

@ -1,6 +1,7 @@
"use client";
import React from "react";
import { useTranslation } from "react-i18next";
import { Server } from "lucide-react";
import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox";
import { Button } from "@/components/ui/button";
@ -27,6 +28,7 @@ interface WorkerOption {
}
const WorkerDropdown: React.FC<WorkerDropdownProps> = ({ onWorkerSwitch, compact = false }) => {
const { t } = useTranslation();
const { isControlPlane, selectedWorker, workers } = useWorker();
if (!isControlPlane || !selectedWorker) return null;
@ -51,21 +53,27 @@ const WorkerDropdown: React.FC<WorkerDropdownProps> = ({ onWorkerSwitch, compact
{compact ? (
<ComboboxPrimitive.Trigger
render={<Button variant="ghost" size="icon" className="text-muted-foreground" />}
aria-label={`Switch worker, current: ${selectedWorker.name}`}
aria-label={t("shell.workerDropdown.trigger", { worker: selectedWorker.name })}
title={selectedWorker.name}
>
<Server className="size-4" aria-hidden />
</ComboboxPrimitive.Trigger>
) : (
<ComboboxInput className="min-w-[180px]" aria-label="Worker">
<ComboboxInput className="min-w-[180px]" aria-label={t("shell.workerDropdown.label")}>
<InputGroupAddon align="inline-start">
<Server className="size-4" />
</InputGroupAddon>
</ComboboxInput>
)}
<ComboboxContent align={compact ? "end" : "start"} className={compact ? "w-64" : undefined}>
{compact && <ComboboxInput aria-label="Search workers" placeholder="Search workers…" showTrigger={false} />}
<ComboboxEmpty>No matching workers</ComboboxEmpty>
{compact && (
<ComboboxInput
aria-label={t("shell.workerDropdown.searchLabel")}
placeholder={t("shell.workerDropdown.searchPlaceholder")}
showTrigger={false}
/>
)}
<ComboboxEmpty>{t("shell.workerDropdown.empty")}</ComboboxEmpty>
<ComboboxList>
{(option: WorkerOption) => (
<ComboboxItem key={option.value} value={option} disabled={option.disabled}>

View file

@ -1,3 +1,5 @@
import i18n from "@/i18n/i18n";
/** Primary label for the navbar account control — avoids raw placeholder JWT/user IDs in the UI. */
export function navAccountDisplayName(userEmail: string | null, userId: string | null): string {
const email = userEmail?.trim();
@ -6,10 +8,10 @@ export function navAccountDisplayName(userEmail: string | null, userId: string |
}
const id = userId?.trim();
if (!id) {
return "Account";
return i18n.t("shell.navDisplayName.account");
}
if (/^default[_\s-]?user[_\s-]?id$/i.test(id)) {
return "Account";
return i18n.t("shell.navDisplayName.account");
}
return id;
}

View file

@ -7,6 +7,7 @@ import { CellTooltip, IdentityCell, StatusBadge, type StatusTone } from "@/compo
import { Badge } from "@/components/ui/badge";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
import { PUBLIC_MODEL_HUB_SORTABLE_FIELDS } from "@/components/publicModelHub/publicModelHubFilters";
import i18n from "@/i18n/i18n";
import { displayMode } from "@/lib/decisionModels";
import type { ModelCustomMetadataValue } from "@/components/AIHub/ModelCustomMetadata";
import { formatModelModeLabel, ProviderLogo } from "@/components/AIHub/ModelHubProviders";
@ -88,14 +89,17 @@ const formatCapabilityName = (key: string) =>
const formatCost = (cost: number) => `$${(cost * 1_000_000).toFixed(4)}`;
const formatTokens = (tokens: number | undefined) => {
if (!tokens) return "N/A";
if (!tokens) return i18n.t("aiHub.publicModelHubColumns.notAvailable");
if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}K`;
return tokens.toString();
};
const formatLimits = (rpm?: number, tpm?: number) => {
const limits = [...(rpm ? [`RPM: ${rpm.toLocaleString()}`] : []), ...(tpm ? [`TPM: ${tpm.toLocaleString()}`] : [])];
return limits.length > 0 ? limits.join(", ") : "N/A";
const limits = [
...(rpm ? [i18n.t("aiHub.publicModelHubColumns.rpmLimit", { value: rpm.toLocaleString() })] : []),
...(tpm ? [i18n.t("aiHub.publicModelHubColumns.tpmLimit", { value: tpm.toLocaleString() })] : []),
];
return limits.length > 0 ? limits.join(", ") : i18n.t("aiHub.publicModelHubColumns.notAvailable");
};
const getModeIcon = (mode: string) => {
@ -179,9 +183,20 @@ export const getPublicModelHubColumns = ({
{
id: "model_group",
accessorKey: "model_group",
meta: { title: showProviderLogos ? "Model" : "Model Name" },
meta: {
title: showProviderLogos
? i18n.t("aiHub.publicModelHubColumns.models.modelChat")
: i18n.t("aiHub.publicModelHubColumns.models.modelName"),
},
header: ({ column }) => (
<DataTableSortHeader column={column} title={showProviderLogos ? "Model" : "Model Name"} />
<DataTableSortHeader
column={column}
title={
showProviderLogos
? i18n.t("aiHub.publicModelHubColumns.models.modelChat")
: i18n.t("aiHub.publicModelHubColumns.models.modelName")
}
/>
),
size: 200,
sortingFn: "alphanumeric",
@ -213,8 +228,10 @@ export const getPublicModelHubColumns = ({
{
id: "providers",
accessorKey: "providers",
meta: { title: "Providers", skeleton: "chips" },
header: ({ column }) => <DataTableSortHeader column={column} title="Providers" />,
meta: { title: i18n.t("aiHub.publicModelHubColumns.models.providers"), skeleton: "chips" },
header: ({ column }) => (
<DataTableSortHeader column={column} title={i18n.t("aiHub.publicModelHubColumns.models.providers")} />
),
size: 150,
sortingFn: (rowA, rowB) =>
(rowA.original.providers ?? []).join(", ").localeCompare((rowB.original.providers ?? []).join(", ")),
@ -223,15 +240,19 @@ export const getPublicModelHubColumns = ({
{
id: "mode",
accessorKey: "mode",
meta: { title: "Mode" },
header: ({ column }) => <DataTableSortHeader column={column} title="Mode" />,
meta: { title: i18n.t("aiHub.publicModelHubColumns.models.mode") },
header: ({ column }) => (
<DataTableSortHeader column={column} title={i18n.t("aiHub.publicModelHubColumns.models.mode")} />
),
size: 110,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<span className="flex items-center gap-2 text-sm">
{!showProviderLogos && <span>{getModeIcon(row.original.mode || "")}</span>}
<span>
{showProviderLogos ? formatModelModeLabel(row.original.mode) : displayMode(row.original.mode) ?? "Chat"}
{showProviderLogos
? formatModelModeLabel(row.original.mode)
: displayMode(row.original.mode) ?? i18n.t("aiHub.publicModelHubColumns.chatMode")}
</span>
</span>
),
@ -239,59 +260,89 @@ export const getPublicModelHubColumns = ({
{
id: "max_input_tokens",
accessorKey: "max_input_tokens",
meta: { title: "Max Input", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Max Input" />,
meta: { title: i18n.t("aiHub.publicModelHubColumns.models.maxInput"), numeric: true },
header: ({ column }) => (
<DataTableSortHeader column={column} title={i18n.t("aiHub.publicModelHubColumns.models.maxInput")} />
),
size: 100,
cell: ({ row }) => <span className="text-sm">{formatTokens(row.original.max_input_tokens)}</span>,
},
{
id: "max_output_tokens",
accessorKey: "max_output_tokens",
meta: { title: "Max Output", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Max Output" />,
meta: { title: i18n.t("aiHub.publicModelHubColumns.models.maxOutput"), numeric: true },
header: ({ column }) => (
<DataTableSortHeader column={column} title={i18n.t("aiHub.publicModelHubColumns.models.maxOutput")} />
),
size: 100,
cell: ({ row }) => <span className="text-sm">{formatTokens(row.original.max_output_tokens)}</span>,
},
{
id: "input_cost_per_token",
accessorKey: "input_cost_per_token",
meta: { title: showProviderLogos ? "Cost / 1M tokens" : "Input $/1M", numeric: true },
meta: {
title: showProviderLogos
? i18n.t("aiHub.publicModelHubColumns.models.costChat")
: i18n.t("aiHub.publicModelHubColumns.models.inputCost"),
numeric: true,
},
header: ({ column }) => (
<DataTableSortHeader column={column} title={showProviderLogos ? "Cost / 1M tokens" : "Input $/1M"} />
<DataTableSortHeader
column={column}
title={
showProviderLogos
? i18n.t("aiHub.publicModelHubColumns.models.costChat")
: i18n.t("aiHub.publicModelHubColumns.models.inputCost")
}
/>
),
size: 110,
cell: ({ row }) =>
showProviderLogos ? (
<div className="flex flex-col gap-0.5 text-xs tabular-nums">
<span>
Input {row.original.input_cost_per_token ? formatCost(row.original.input_cost_per_token) : "Free"}
{i18n.t("aiHub.publicModelHubColumns.inputCost", {
cost: row.original.input_cost_per_token
? formatCost(row.original.input_cost_per_token)
: i18n.t("aiHub.publicModelHubColumns.free"),
})}
</span>
<span className="text-muted-foreground">
Output {row.original.output_cost_per_token ? formatCost(row.original.output_cost_per_token) : "Free"}
{i18n.t("aiHub.publicModelHubColumns.outputCost", {
cost: row.original.output_cost_per_token
? formatCost(row.original.output_cost_per_token)
: i18n.t("aiHub.publicModelHubColumns.free"),
})}
</span>
</div>
) : (
<span className="text-sm">
{row.original.input_cost_per_token ? formatCost(row.original.input_cost_per_token) : "Free"}
{row.original.input_cost_per_token
? formatCost(row.original.input_cost_per_token)
: i18n.t("aiHub.publicModelHubColumns.free")}
</span>
),
},
{
id: "output_cost_per_token",
accessorKey: "output_cost_per_token",
meta: { title: "Output $/1M", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Output $/1M" />,
meta: { title: i18n.t("aiHub.publicModelHubColumns.models.outputCost"), numeric: true },
header: ({ column }) => (
<DataTableSortHeader column={column} title={i18n.t("aiHub.publicModelHubColumns.models.outputCost")} />
),
size: 110,
cell: ({ row }) => (
<span className="text-sm">
{row.original.output_cost_per_token ? formatCost(row.original.output_cost_per_token) : "Free"}
{row.original.output_cost_per_token
? formatCost(row.original.output_cost_per_token)
: i18n.t("aiHub.publicModelHubColumns.free")}
</span>
),
},
{
id: "features",
meta: { title: "Features", skeleton: "chips" },
header: "Features",
meta: { title: i18n.t("aiHub.publicModelHubColumns.models.features"), skeleton: "chips" },
header: i18n.t("aiHub.publicModelHubColumns.models.features"),
size: 140,
cell: ({ row }) => {
const features = Object.entries(row.original)
@ -302,25 +353,29 @@ export const getPublicModelHubColumns = ({
},
{
id: "custom_tags",
meta: { title: "Tags", skeleton: "chips" },
header: "Tags",
meta: { title: i18n.t("aiHub.publicModelHubColumns.models.tags"), skeleton: "chips" },
header: i18n.t("aiHub.publicModelHubColumns.models.tags"),
size: 140,
cell: ({ row }) => <OverflowChips items={row.original.custom_tags ?? []} />,
},
{
id: "health_status",
accessorKey: "health_status",
meta: { title: "Health Status", skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title="Health Status" />,
meta: { title: i18n.t("aiHub.publicModelHubColumns.models.healthStatus"), skeleton: "badge" },
header: ({ column }) => (
<DataTableSortHeader column={column} title={i18n.t("aiHub.publicModelHubColumns.models.healthStatus")} />
),
size: 130,
cell: ({ row }) => {
const model = row.original;
const responseTimeLabel = model.health_response_time
? `Response Time: ${Number(model.health_response_time).toFixed(2)}ms`
: "N/A";
? i18n.t("aiHub.publicModelHubColumns.responseTime", { ms: Number(model.health_response_time).toFixed(2) })
: i18n.t("aiHub.publicModelHubColumns.notAvailable");
const lastCheckedLabel = model.health_checked_at
? `Last Checked: ${new Date(model.health_checked_at).toLocaleString()}`
: "N/A";
? i18n.t("aiHub.publicModelHubColumns.lastChecked", {
date: new Date(model.health_checked_at).toLocaleString(),
})
: i18n.t("aiHub.publicModelHubColumns.notAvailable");
return (
<CellTooltip
content={
@ -333,7 +388,7 @@ export const getPublicModelHubColumns = ({
<span className="capitalize">
<StatusBadge
tone={HEALTH_TONES[model.health_status ?? ""] || "neutral"}
label={model.health_status ?? "Unknown"}
label={model.health_status ?? i18n.t("aiHub.publicModelHubColumns.healthUnknown")}
/>
</span>
}
@ -344,8 +399,10 @@ export const getPublicModelHubColumns = ({
{
id: "rpm",
accessorKey: "rpm",
meta: { title: "Limits" },
header: ({ column }) => <DataTableSortHeader column={column} title="Limits" />,
meta: { title: i18n.t("aiHub.publicModelHubColumns.models.limits") },
header: ({ column }) => (
<DataTableSortHeader column={column} title={i18n.t("aiHub.publicModelHubColumns.models.limits")} />
),
size: 150,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">{formatLimits(row.original.rpm, row.original.tpm)}</span>
@ -378,8 +435,10 @@ export const getPublicAgentHubColumns = ({ onAgentClick }: PublicAgentHubColumns
{
id: "name",
accessorKey: "name",
meta: { title: "Agent Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Agent Name" />,
meta: { title: i18n.t("aiHub.publicModelHubColumns.agents.name") },
header: ({ column }) => (
<DataTableSortHeader column={column} title={i18n.t("aiHub.publicModelHubColumns.agents.name")} />
),
size: 200,
enableSorting: true,
sortingFn: "alphanumeric",
@ -395,8 +454,8 @@ export const getPublicAgentHubColumns = ({ onAgentClick }: PublicAgentHubColumns
{
id: "description",
accessorKey: "description",
meta: { title: "Description" },
header: "Description",
meta: { title: i18n.t("aiHub.publicModelHubColumns.agents.description") },
header: i18n.t("aiHub.publicModelHubColumns.agents.description"),
size: 260,
enableSorting: false,
cell: ({ row }) => (
@ -408,8 +467,10 @@ export const getPublicAgentHubColumns = ({ onAgentClick }: PublicAgentHubColumns
{
id: "version",
accessorKey: "version",
meta: { title: "Version" },
header: ({ column }) => <DataTableSortHeader column={column} title="Version" />,
meta: { title: i18n.t("aiHub.publicModelHubColumns.agents.version") },
header: ({ column }) => (
<DataTableSortHeader column={column} title={i18n.t("aiHub.publicModelHubColumns.agents.version")} />
),
size: 90,
enableSorting: true,
sortingFn: "alphanumeric",
@ -417,8 +478,8 @@ export const getPublicAgentHubColumns = ({ onAgentClick }: PublicAgentHubColumns
},
{
id: "provider",
meta: { title: "Provider" },
header: "Provider",
meta: { title: i18n.t("aiHub.publicModelHubColumns.agents.provider") },
header: i18n.t("aiHub.publicModelHubColumns.agents.provider"),
size: 130,
enableSorting: false,
cell: ({ row }) =>
@ -430,16 +491,16 @@ export const getPublicAgentHubColumns = ({ onAgentClick }: PublicAgentHubColumns
},
{
id: "skills",
meta: { title: "Skills", skeleton: "chips" },
header: "Skills",
meta: { title: i18n.t("aiHub.publicModelHubColumns.agents.skills"), skeleton: "chips" },
header: i18n.t("aiHub.publicModelHubColumns.agents.skills"),
size: 160,
enableSorting: false,
cell: ({ row }) => <OverflowChips items={(row.original.skills || []).map((skill) => skill.name)} />,
},
{
id: "capabilities",
meta: { title: "Capabilities", skeleton: "chips" },
header: "Capabilities",
meta: { title: i18n.t("aiHub.publicModelHubColumns.agents.capabilities"), skeleton: "chips" },
header: i18n.t("aiHub.publicModelHubColumns.agents.capabilities"),
size: 160,
enableSorting: false,
cell: ({ row }) => {
@ -470,8 +531,10 @@ export const getPublicMCPHubColumns = ({ onServerClick }: PublicMCPHubColumnsDep
{
id: "server_name",
accessorKey: "server_name",
meta: { title: "Server Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Server Name" />,
meta: { title: i18n.t("aiHub.publicModelHubColumns.mcp.serverName") },
header: ({ column }) => (
<DataTableSortHeader column={column} title={i18n.t("aiHub.publicModelHubColumns.mcp.serverName")} />
),
size: 180,
enableSorting: true,
sortingFn: "alphanumeric",
@ -486,8 +549,8 @@ export const getPublicMCPHubColumns = ({ onServerClick }: PublicMCPHubColumnsDep
},
{
id: "description",
meta: { title: "Description" },
header: "Description",
meta: { title: i18n.t("aiHub.publicModelHubColumns.mcp.description") },
header: i18n.t("aiHub.publicModelHubColumns.mcp.description"),
size: 260,
enableSorting: false,
cell: ({ row }) => {
@ -502,8 +565,10 @@ export const getPublicMCPHubColumns = ({ onServerClick }: PublicMCPHubColumnsDep
{
id: "transport",
accessorKey: "transport",
meta: { title: "Transport", skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title="Transport" />,
meta: { title: i18n.t("aiHub.publicModelHubColumns.mcp.transport"), skeleton: "badge" },
header: ({ column }) => (
<DataTableSortHeader column={column} title={i18n.t("aiHub.publicModelHubColumns.mcp.transport")} />
),
size: 110,
enableSorting: true,
sortingFn: "alphanumeric",
@ -516,8 +581,10 @@ export const getPublicMCPHubColumns = ({ onServerClick }: PublicMCPHubColumnsDep
{
id: "auth_type",
accessorKey: "auth_type",
meta: { title: "Auth Type", skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title="Auth Type" />,
meta: { title: i18n.t("aiHub.publicModelHubColumns.mcp.authType"), skeleton: "badge" },
header: ({ column }) => (
<DataTableSortHeader column={column} title={i18n.t("aiHub.publicModelHubColumns.mcp.authType")} />
),
size: 110,
enableSorting: true,
sortingFn: "alphanumeric",

View file

@ -1,3 +1,5 @@
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { DEFAULT_PROXY_ADMIN_USER_ID } from "@/utils/sentinels";
@ -6,8 +8,9 @@ interface DefaultProxyAdminTagProps {
}
export default function DefaultProxyAdminTag({ userId }: DefaultProxyAdminTagProps) {
const { t } = useTranslation();
if (userId === DEFAULT_PROXY_ADMIN_USER_ID) {
return <Badge variant="secondary">Default Proxy Admin</Badge>;
return <Badge variant="secondary">{t("commonComponents.defaultProxyAdminTag.label")}</Badge>;
}
return <span>{userId}</span>;

View file

@ -1,5 +1,6 @@
import { CircleAlert } from "lucide-react";
import React, { useState, useEffect } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Alert, AlertTitle } from "@/components/shared/Alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -35,6 +36,7 @@ export default function DeleteResourceModal({
confirmLoading,
requiredConfirmation,
}: DeleteResourceModalProps) {
const { t } = useTranslation();
const [requiredConfirmationInput, setRequiredConfirmationInput] = useState("");
useEffect(() => {
@ -78,7 +80,12 @@ export default function DeleteResourceModal({
{requiredConfirmation && (
<div className="mb-6 mt-4 pt-4 border-t border-border">
<p className="block text-base font-medium text-foreground mb-2">
Type <span className="font-semibold text-destructive">{requiredConfirmation}</span> to confirm deletion:
<Trans
i18nKey="commonComponents.deleteResourceModal.typeToConfirm"
components={{
confirmation: <span className="font-semibold text-destructive">{requiredConfirmation}</span>,
}}
/>
</p>
<InputGroup className="rounded-md">
<InputGroupAddon>
@ -96,14 +103,16 @@ export default function DeleteResourceModal({
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={confirmLoading}>
Cancel
{t("commonComponents.deleteResourceModal.cancel")}
</Button>
<Button
variant="destructive"
onClick={onOk}
disabled={(!!requiredConfirmation && requiredConfirmationInput !== requiredConfirmation) || confirmLoading}
>
{confirmLoading ? "Deleting..." : "Delete"}
{confirmLoading
? t("commonComponents.deleteResourceModal.deleting")
: t("commonComponents.deleteResourceModal.delete")}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -1,14 +1,16 @@
import { useTranslation } from "react-i18next";
import { cx } from "@/lib/cva.config";
import { UiLoadingSpinner } from "../ui/ui-loading-spinner";
export default function LoadingScreen() {
const { t } = useTranslation();
return (
<div className={cx("h-screen", "flex items-center justify-center gap-4")}>
<div className="text-lg font-medium py-2 pr-4 border-r border-r-gray-200">🚅 LiteLLM</div>
<div className="flex items-center justify-center gap-2">
<UiLoadingSpinner className="size-4" />
<span className="text-muted-foreground text-sm">Loading...</span>
<span className="text-muted-foreground text-sm">{t("shell.loadingScreen.loading")}</span>
</div>
</div>
);

View file

@ -5,6 +5,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Info } from "lucide-react";
import { SimpleTooltip } from "@/components/ui/tooltip";
import type { UseFormSetValue } from "react-hook-form";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n/i18n";
import { getOpenAPISchema } from "../networking";
import { formatLabel } from "@/utils/textUtils";
import { MountedFormField, type MountedFormValues } from "./MountedFormField";
@ -71,28 +73,28 @@ const getFieldHelp = (key: string, property: SchemaProperty, type: string): stri
// Default help text based on type
const defaultHelp =
{
string: "Text input",
number: "Numeric input",
integer: "Whole number input",
boolean: "True/False value",
}[type] || "Text input";
string: i18n.t("commonComponents.checkOpenapiSchema.typeHelp.string"),
number: i18n.t("commonComponents.checkOpenapiSchema.typeHelp.number"),
integer: i18n.t("commonComponents.checkOpenapiSchema.typeHelp.integer"),
boolean: i18n.t("commonComponents.checkOpenapiSchema.typeHelp.boolean"),
}[type] || i18n.t("commonComponents.checkOpenapiSchema.typeHelp.fallback");
// Specific field help text
const specificHelp: { [key: string]: string } = {
max_budget: "Enter maximum budget in USD (e.g., 100.50)",
budget_duration: "Select a time period for budget reset",
tpm_limit: "Enter maximum tokens per minute (whole number)",
rpm_limit: "Enter maximum requests per minute (whole number)",
duration: "Enter duration (e.g., 30s, 24h, 7d)",
metadata: 'Enter JSON object with key-value pairs\nExample: {"team": "research", "project": "nlp"}',
config: 'Enter configuration as JSON object\nExample: {"setting": "value"}',
permissions: "Enter comma-separated permission strings",
enforced_params: 'Enter parameters as JSON object\nExample: {"param": "value"}',
blocked: "Enter true/false or specific block conditions",
aliases: 'Enter aliases as JSON object\nExample: {"alias1": "value1", "alias2": "value2"}',
models: "Select one or more model names",
key_alias: "Enter a unique identifier for this key",
tags: "Enter comma-separated tag strings",
max_budget: i18n.t("commonComponents.checkOpenapiSchema.fieldHelp.maxBudget"),
budget_duration: i18n.t("commonComponents.checkOpenapiSchema.fieldHelp.budgetDuration"),
tpm_limit: i18n.t("commonComponents.checkOpenapiSchema.fieldHelp.tpmLimit"),
rpm_limit: i18n.t("commonComponents.checkOpenapiSchema.fieldHelp.rpmLimit"),
duration: i18n.t("commonComponents.checkOpenapiSchema.fieldHelp.duration"),
metadata: i18n.t("commonComponents.checkOpenapiSchema.fieldHelp.metadata"),
config: i18n.t("commonComponents.checkOpenapiSchema.fieldHelp.config"),
permissions: i18n.t("commonComponents.checkOpenapiSchema.fieldHelp.permissions"),
enforced_params: i18n.t("commonComponents.checkOpenapiSchema.fieldHelp.enforcedParams"),
blocked: i18n.t("commonComponents.checkOpenapiSchema.fieldHelp.blocked"),
aliases: i18n.t("commonComponents.checkOpenapiSchema.fieldHelp.aliases"),
models: i18n.t("commonComponents.checkOpenapiSchema.fieldHelp.models"),
key_alias: i18n.t("commonComponents.checkOpenapiSchema.fieldHelp.keyAlias"),
tags: i18n.t("commonComponents.checkOpenapiSchema.fieldHelp.tags"),
};
// Get specific help text or use default based on type
@ -100,11 +102,11 @@ const getFieldHelp = (key: string, property: SchemaProperty, type: string): stri
// Add format requirements for special cases
if (isJSONField(key, property)) {
return `${helpText}\nMust be valid JSON format`;
return i18n.t("commonComponents.checkOpenapiSchema.jsonHelp", { helpText });
}
if (property.enum) {
return `Select from available options\nAllowed values: ${property.enum.join(", ")}`;
return i18n.t("commonComponents.checkOpenapiSchema.enumHelp", { values: property.enum.join(", ") });
}
return helpText;
@ -119,6 +121,7 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
customValidation = {},
defaultValues = {},
}) => {
const { t } = useTranslation();
const [schemaProperties, setSchemaProperties] = useState<OpenAPISchema | null>(null);
const [error, setError] = useState<string | null>(null);
@ -141,12 +144,12 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
});
} catch (error) {
console.error("Schema fetch error:", error);
setError(error instanceof Error ? error.message : "Failed to fetch schema");
setError(error instanceof Error ? error.message : t("commonComponents.checkOpenapiSchema.fetchFailed"));
}
};
fetchOpenAPISchema();
}, [schemaComponent, setValue, excludedFields]);
}, [schemaComponent, setValue, excludedFields, t]);
const getPropertyType = (property: SchemaProperty): string => {
if (property.type) {
@ -169,7 +172,8 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
const validate = {
...(isRequired && {
required: (value: unknown) => (isBlank(value) ? `${label} is required` : true),
required: (value: unknown) =>
isBlank(value) ? t("commonComponents.checkOpenapiSchema.required", { label }) : true,
}),
...(customValidation[key] && {
custom: async (value: unknown) => {
@ -183,7 +187,9 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
}),
...(isJSONField(key, property) && {
json: (value: unknown) =>
value && !validateJSON(value as string) ? "Please enter valid JSON" : (true as const),
value && !validateJSON(value as string)
? t("commonComponents.checkOpenapiSchema.invalidJson")
: (true as const),
}),
};
@ -216,7 +222,7 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
{...control}
value={control.value as string | undefined}
rows={4}
placeholder="Enter as JSON"
placeholder={t("commonComponents.checkOpenapiSchema.jsonPlaceholder")}
className="font-mono"
/>
);
@ -256,7 +262,11 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
}
if (key === "duration") {
return (
<Input {...control} value={(control.value as string | undefined) ?? ""} placeholder="eg: 30s, 30h, 30d" />
<Input
{...control}
value={(control.value as string | undefined) ?? ""}
placeholder={t("commonComponents.checkOpenapiSchema.durationPlaceholder")}
/>
);
}
return <Input {...control} value={(control.value as string | undefined) ?? ""} placeholder={tooltip || ""} />;
@ -266,7 +276,7 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
};
if (error) {
return <div className="text-destructive">Error: {error}</div>;
return <div className="text-destructive">{t("commonComponents.checkOpenapiSchema.error", { error })}</div>;
}
if (!schemaProperties?.properties) {

View file

@ -1,3 +1,4 @@
import i18n from "@/i18n/i18n";
import { modelAvailableCall } from "../networking";
export const fetchAvailableModelsForTeamOrKey = async (
@ -43,11 +44,11 @@ export const hasAllModelsSentinel = (models: string[]): boolean =>
export const getModelDisplayName = (model: string) => {
if (model === "all-proxy-models") {
return "All Proxy Models";
return i18n.t("keyTeamHelpers.fetchAvailableModelsTeamKey.allProxyModels");
}
if (model.endsWith("/*")) {
const provider = model.replace("/*", "");
return `All ${provider} models`;
return i18n.t("keyTeamHelpers.fetchAvailableModelsTeamKey.allProviderModels", { provider });
}
return model;
};

View file

@ -1,4 +1,5 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
import { resolveLogoSrc } from "@/lib/assetPaths";
import { cn } from "@/lib/cva.config";
@ -15,6 +16,7 @@ const DARK_TREATMENT_CLASS: Readonly<Record<LogoTreatment, string>> = {
};
export const Logo: React.FC<LogoProps> = ({ provider, src, label, className = "w-4 h-4" }) => {
const { t } = useTranslation();
const [erroredSrc, setErroredSrc] = useState<string | null>(null);
const resolvedSrc = provider !== undefined ? getProviderLogoAndName(provider).logo : resolveLogoSrc(src) ?? "";
const name = label ?? provider ?? "";
@ -32,7 +34,7 @@ export const Logo: React.FC<LogoProps> = ({ provider, src, label, className = "w
return (
<img
src={resolvedSrc}
alt={`${name || "-"} logo`}
alt={t("shell.logo.alt", { name: name || "-" })}
className={treatment === undefined ? className : cn(className, DARK_TREATMENT_CLASS[treatment])}
onError={() => {
console.warn(`Logo failed to load: ${resolvedSrc}`);

View file

@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import Link from "next/link";
import React from "react";
import { useTranslation } from "react-i18next";
import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown";
import { DocsLink } from "./Navbar/DocsLink/DocsLink";
import { CommunityEngagementButtons } from "./Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
@ -40,6 +41,7 @@ const Navbar: React.FC<NavbarProps> = ({
deploymentHealth,
deploymentHealthLink,
}) => {
const { t } = useTranslation();
const baseUrl = getProxyBaseUrl();
const { logoUrl, logoUrlDark } = useTheme();
const { data: healthData } = useHealthReadinessDetails(accessToken);
@ -69,7 +71,7 @@ const Navbar: React.FC<NavbarProps> = ({
<button
onClick={onToggleSidebar}
className="mr-2 flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
title={sidebarCollapsed ? t("shell.navbar.expandSidebar") : t("shell.navbar.collapseSidebar")}
>
<span className="text-lg">
{sidebarCollapsed ? (
@ -90,7 +92,11 @@ const Navbar: React.FC<NavbarProps> = ({
isPublicPage && "h-8 max-w-36 sm:h-10 sm:max-w-48",
)}
>
<img src={imageUrl} alt="LiteLLM Brand" className={cn(NAV_LOGO_CLASS_NAME, "dark:hidden")} />
<img
src={imageUrl}
alt={t("shell.navbar.logoAlt")}
className={cn(NAV_LOGO_CLASS_NAME, "dark:hidden")}
/>
<img
src={darkImageUrl}
alt=""
@ -106,7 +112,7 @@ const Navbar: React.FC<NavbarProps> = ({
<span
className="absolute -left-2 -top-1 animate-bounce text-lg"
style={{ animationDuration: "2s" }}
title="Thanks for using LiteLLM!"
title={t("shell.navbar.thanks")}
>
🌑
</span>
@ -140,7 +146,7 @@ const Navbar: React.FC<NavbarProps> = ({
)}
<nav
aria-label="Product documentation"
aria-label={t("shell.navbar.productDocs")}
className={`flex min-w-0 items-center gap-2 ${showWorkerSwitch ? "border-l border-border pl-4" : ""}`}
>
<DocsLink />

View file

@ -86,6 +86,7 @@ export const getAutoRouterAssembledPromptCall = async (
/**
* Helper file for calls being made to proxy
*/
import i18n from "@/i18n/i18n";
import { toast } from "@/lib/toast";
import { clearTokenCookies, getCookie, storeLoginToken } from "@/utils/cookieUtils";
import { decodeToken } from "@/utils/jwtUtils";
@ -436,7 +437,7 @@ export const handleError = async (errorData: string | any) => {
// Convert errorData to string if it isn't already
const errorString = typeof errorData === "string" ? errorData : JSON.stringify(errorData);
if (errorString.includes("Authentication Error - Expired Key")) {
toast.info("UI Session Expired. Logging out.");
toast.info(i18n.t("networking.sessionExpired"));
lastErrorTime = currentTime;
clearTokenCookies();
const browserLocation = getWindowLocation();
@ -748,7 +749,7 @@ export const modelCreateCall = async (accessToken: string, formValues: Model) =>
toast.dismiss();
// Sequential success messages
toast.success(`Model ${formValues.model_name} created successfully`);
toast.success(i18n.t("networking.modelCreated", { modelName: formValues.model_name }));
return data;
} catch (error) {
@ -1692,7 +1693,7 @@ export const modelInfoCall = async (
errorData += `error shown=${ModelListerrorShown}`;
if (!ModelListerrorShown) {
if (errorData.includes("No model list passed")) {
errorData = "No Models Exist. Click Add Model to get started.";
errorData = i18n.t("networking.noModelsExist");
}
toast.info(errorData);
ModelListerrorShown = true;
@ -2154,7 +2155,7 @@ export const keyInfoV1Call = async (accessToken: string, key: string) => {
if (!response.ok) {
const errorData = await response.text();
handleError(errorData);
toast.fromError("Failed to fetch key info - " + errorData);
toast.fromError(i18n.t("networking.keyInfoFetchFailed", { error: errorData }));
}
const data = await response.json();
@ -2556,7 +2557,7 @@ export const teamUpdateCall = async (
const errorData = await response.text();
handleError(errorData);
console.error("Error response from the server:", errorData);
toast.fromError("Failed to update team settings: " + unwrapProxyErrorMessage(errorData));
toast.fromError(i18n.t("networking.teamUpdateFailed", { error: unwrapProxyErrorMessage(errorData) }));
throw new Error(errorData);
}
const data = (await response.json()) as { data: Team; team_id: string };
@ -3209,7 +3210,7 @@ export const updateConfigFieldSetting = async (accessToken: string, fieldName: s
config_type: "general_settings",
};
const data = await apiClient.post(`/config/field/update`, { accessToken, body: formData });
toast.success("Successfully updated value!");
toast.success(i18n.t("networking.configValueUpdated"));
return data;
// Handle success - you might want to update some state or UI based on the created key
} catch (error) {
@ -3225,7 +3226,7 @@ export const deleteConfigFieldSetting = async (accessToken: string, fieldName: s
config_type: "general_settings",
};
const data = await apiClient.post(`/config/field/delete`, { accessToken, body: formData });
toast.success("Field reset on proxy");
toast.success(i18n.t("networking.configFieldReset"));
return data;
// Handle success - you might want to update some state or UI based on the created key
} catch (error) {
@ -6258,7 +6259,7 @@ export const updatePassThroughEndpoint = async (
}
const data = await response.json();
toast.success("Pass through endpoint updated successfully");
toast.success(i18n.t("networking.passThroughEndpointUpdated"));
return data;
} catch (error) {
console.error("Failed to update pass through endpoint:", error);

View file

@ -1,6 +1,7 @@
"use client";
import { ExternalLink } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { AgentCard } from "@/components/PublicModelHubTableColumns";
import { Button } from "@/components/ui/button";
@ -19,20 +20,32 @@ const modes = (agent: AgentCard) => {
};
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="Copy agent name" description={agent.description} />
<InspectorHeader
title={agent.name}
copyLabel={t("publicModelHub.agentInspector.copyName")}
description={agent.description}
/>
<Facts
groups={[
[
{ label: "Version", value: agent.version },
{ label: "Owner", value: agent.provider?.organization },
{ label: "Protocol", value: agent.protocolVersion && `A2A ${agent.protocolVersion}` },
{ label: 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: "Input / output", wide: true, value: modes(agent) },
{ label: "Capabilities", wide: true, value: capabilities.length > 0 && <Badges items={capabilities} /> },
{ 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} />,
},
],
]}
/>
@ -44,12 +57,12 @@ export function AgentInspector({ agent }: { agent: AgentCard }) {
className="h-auto self-start px-0"
render={<a href={agent.documentationUrl} target="_blank" rel="noopener noreferrer" />}
>
Read the documentation
{t("publicModelHub.agentInspector.documentation")}
<ExternalLink />
</Button>
)}
{(agent.skills ?? []).length > 0 && (
<PaneSection title="Skills">
<PaneSection title={t("publicModelHub.agentInspector.skills")}>
<ItemGroup className="gap-2">
{agent.skills.map((skill) => (
<Item key={skill.id} variant="outline" size="sm" role="listitem">
@ -65,11 +78,11 @@ export function AgentInspector({ agent }: { agent: AgentCard }) {
</ItemGroup>
</PaneSection>
)}
<PaneSection title="Call this agent">
<PaneSection title={t("publicModelHub.agentInspector.callAgent")}>
{agentSnippets(agent).map((snippet, index) => (
<Field key={snippet.title}>
<FieldDescription>
Step {index + 1}: {snippet.title}
{t("publicModelHub.agentInspector.step", { step: index + 1, title: snippet.title })}
</FieldDescription>
<CodeBlock code={snippet.code} />
</Field>

View file

@ -1,6 +1,7 @@
"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";
@ -37,6 +38,7 @@ export function FilterChip({
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;
@ -64,8 +66,8 @@ export function FilterChip({
{active && <span className="font-normal text-muted-foreground">{firstPlus(selected)}</span>}
</ComboboxTrigger>
<ComboboxContent className="w-auto min-w-60">
<ComboboxInput showTrigger={false} placeholder={`Search ${noun}`} />
<ComboboxEmpty>No {noun} found</ComboboxEmpty>
<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}>
@ -80,7 +82,7 @@ export function FilterChip({
<Button
variant="outline"
size="icon-sm"
aria-label={`Clear ${label.toLowerCase()} filter`}
aria-label={t("publicModelHub.filterChip.clear", { label: label.toLowerCase() })}
onClick={() => onChange([])}
>
<X />

View file

@ -2,15 +2,19 @@
import { LayoutGrid, SearchX, TriangleAlert } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import i18n from "@/i18n/i18n";
import { SECTION_TITLE } from "./hubFormat";
const noResultsTitle = (noun: string, query: string, filtered: boolean) => {
if (!filtered) return `No ${noun} published yet`;
return query ? `No ${noun} match "${query}"` : `No ${noun} match these filters`;
if (!filtered) return i18n.t("publicModelHub.noResults.noneYet", { noun });
return query
? i18n.t("publicModelHub.noResults.noMatchQuery", { noun, query })
: i18n.t("publicModelHub.noResults.noMatchFilters", { noun });
};
export function NoResults({
@ -24,19 +28,20 @@ export function NoResults({
query: string;
onClear?: () => void;
}) {
const { t } = useTranslation();
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">{icon}</EmptyMedia>
<EmptyTitle className={SECTION_TITLE}>{noResultsTitle(noun, query, Boolean(onClear))}</EmptyTitle>
<EmptyDescription>
{onClear ? "Try a shorter search or fewer filters" : "Your admin publishes them from the AI Hub page"}
{onClear ? t("publicModelHub.noResults.filteredHint") : t("publicModelHub.noResults.emptyHint")}
</EmptyDescription>
</EmptyHeader>
{onClear && (
<EmptyContent>
<Button variant="outline" size="sm" onClick={onClear}>
Clear filters
{t("publicModelHub.noResults.clearFilters")}
</Button>
</EmptyContent>
)}
@ -45,32 +50,34 @@ export function NoResults({
}
export function HubNotEnabled() {
const { t } = useTranslation();
return (
<Empty className="rounded-xl border border-solid border-border">
<EmptyHeader>
<EmptyMedia variant="icon">
<LayoutGrid />
</EmptyMedia>
<EmptyTitle className={SECTION_TITLE}>This AI Hub isn&apos;t available</EmptyTitle>
<EmptyDescription>Ask your proxy admin to make it public from their Admin UI</EmptyDescription>
<EmptyTitle className={SECTION_TITLE}>{t("publicModelHub.notEnabled.title")}</EmptyTitle>
<EmptyDescription>{t("publicModelHub.notEnabled.description")}</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
export function LoadFailed({ noun, onRetry }: { noun: string; onRetry: () => void }) {
const { t } = useTranslation();
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<TriangleAlert />
</EmptyMedia>
<EmptyTitle className={SECTION_TITLE}>Couldn&apos;t load {noun}</EmptyTitle>
<EmptyDescription>The gateway returned an error. Try again in a moment</EmptyDescription>
<EmptyTitle className={SECTION_TITLE}>{t("publicModelHub.loadFailed.title", { noun })}</EmptyTitle>
<EmptyDescription>{t("publicModelHub.loadFailed.description")}</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="outline" size="sm" onClick={onRetry}>
Try again
{t("publicModelHub.loadFailed.retry")}
</Button>
</EmptyContent>
</Empty>
@ -78,14 +85,15 @@ export function LoadFailed({ noun, onRetry }: { noun: string; onRetry: () => voi
}
export function NotListed({ name }: { name: string }) {
const { t } = useTranslation();
return (
<Empty className="h-full">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchX />
</EmptyMedia>
<EmptyTitle className={SECTION_TITLE}>&ldquo;{name}&rdquo; isn&apos;t listed here</EmptyTitle>
<EmptyDescription>It may have been renamed or unpublished</EmptyDescription>
<EmptyTitle className={SECTION_TITLE}>{t("publicModelHub.notListed.title", { name })}</EmptyTitle>
<EmptyDescription>{t("publicModelHub.notListed.description")}</EmptyDescription>
</EmptyHeader>
</Empty>
);

View file

@ -1,6 +1,7 @@
"use client";
import { ChevronDown, ExternalLink, LayoutGrid } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
@ -23,12 +24,13 @@ import type { HubInfo } from "./useHubData";
export type HubStatus = "loading" | "operational" | "degraded" | "unavailable";
function LinksMenu({ info }: { info: HubInfo | undefined }) {
const { t } = useTranslation();
const links = usefulLinkList(info?.useful_links);
if (links.length === 0) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="sm" />}>
Links
{t("publicModelHub.linksMenu.trigger")}
<ChevronDown className="text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-auto min-w-(--anchor-width)">
@ -42,7 +44,9 @@ function LinksMenu({ info }: { info: HubInfo | undefined }) {
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs font-normal">LiteLLM v{info.litellm_version}</DropdownMenuLabel>
<DropdownMenuLabel className="text-xs font-normal">
{t("publicModelHub.linksMenu.version", { version: info.litellm_version })}
</DropdownMenuLabel>
</DropdownMenuGroup>
</>
)}
@ -52,44 +56,46 @@ function LinksMenu({ info }: { info: HubInfo | undefined }) {
}
function BaseUrlField() {
const { t } = useTranslation();
const url = gatewayUrl();
return (
<Field className="w-full gap-1.5 sm:w-[420px]">
<InputGroup className="h-8">
<InputGroupAddon>
<InputGroupText className="text-xs font-medium">Base URL</InputGroupText>
<InputGroupText className="text-xs font-medium">{t("publicModelHub.baseUrlField.label")}</InputGroupText>
</InputGroupAddon>
<InputGroupInput
readOnly
value={url}
aria-label="Base URL"
aria-label={t("publicModelHub.baseUrlField.label")}
aria-describedby="hub-base-url-hint"
className="font-mono text-xs md:text-xs"
/>
<InputGroupAddon align="inline-end">
<CopyIconButton value={url} label="Copy base URL" className="size-6" />
<CopyIconButton value={url} label={t("publicModelHub.baseUrlField.copy")} className="size-6" />
</InputGroupAddon>
</InputGroup>
<FieldDescription id="hub-base-url-hint" className="text-xs text-balance">
Use your LiteLLM key as the API key in any OpenAI-compatible SDK
{t("publicModelHub.baseUrlField.hint")}
</FieldDescription>
</Field>
);
}
export function HubHeader({ info, status }: { info: HubInfo | undefined; status: HubStatus }) {
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" />
AI Hub
{t("publicModelHub.header.title")}
</h1>
{status === "operational" && <StatusBadge tone="success" label="Operational" />}
{status === "operational" && <StatusBadge tone="success" label={t("publicModelHub.header.operational")} />}
</div>
<p className="text-sm text-muted-foreground">
{info?.custom_docs_description || "Models, agents, MCP servers and skills you can call through this gateway"}
{info?.custom_docs_description || t("publicModelHub.header.description")}
</p>
</div>
{status !== "unavailable" && (

View file

@ -7,6 +7,7 @@ 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";
@ -121,7 +122,7 @@ function useSheet<T>(
const { row } = content;
return {
open: chosen !== undefined || missingKey !== undefined,
title: row ? getKey(row) : content.missingKey ?? "Details",
title: row ? getKey(row) : content.missingKey ?? i18n.t("publicModelHub.masterDetail.details"),
body: row ? renderDetail(row) : content.missingKey && <NotListed name={content.missingKey} />,
};
}

View file

@ -2,6 +2,7 @@
import { Copy, Search, X } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
@ -11,11 +12,13 @@ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "
import { ItemTitle } from "@/components/ui/item";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/cva.config";
import i18n from "@/i18n/i18n";
import { copyToClipboard } from "@/utils/dataUtils";
import { providerLogo, providerName, SECTION_TITLE } from "./hubFormat";
export function CopyIconButton({ value, label, className }: { value: string; label: string; className?: string }) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger
@ -25,7 +28,7 @@ export function CopyIconButton({ value, label, className }: { value: string; lab
size="icon-sm"
aria-label={label}
className={cn("text-muted-foreground", className)}
onClick={() => void copyToClipboard(value, "Copied")}
onClick={() => void copyToClipboard(value, t("publicModelHub.copyIconButton.copied"))}
/>
}
>
@ -36,7 +39,13 @@ export function CopyIconButton({ value, label, className }: { value: string; lab
);
}
export function CodeBlock({ code, label = "Copy code" }: { code: string; label?: string }) {
export function CodeBlock({
code,
label = i18n.t("publicModelHub.codeBlock.copyCode"),
}: {
code: string;
label?: string;
}) {
return (
<div className="relative min-w-0 rounded-lg border border-border bg-muted">
<CopyIconButton value={code} label={label} className="absolute top-1.5 right-1.5 bg-background" />
@ -125,13 +134,14 @@ export function Facts({ groups }: { groups: Fact[][] }) {
}
export function EndpointField({ id, value }: { id: string; value: string }) {
const { t } = useTranslation();
return (
<Field>
<FieldLabel htmlFor={id}>Endpoint</FieldLabel>
<FieldLabel htmlFor={id}>{t("publicModelHub.endpointField.label")}</FieldLabel>
<InputGroup>
<InputGroupInput id={id} readOnly value={value} className="font-mono text-xs md:text-xs" />
<InputGroupAddon align="inline-end">
<CopyIconButton value={value} label="Copy endpoint" className="size-6" />
<CopyIconButton value={value} label={t("publicModelHub.endpointField.copy")} className="size-6" />
</InputGroupAddon>
</InputGroup>
</Field>
@ -147,6 +157,7 @@ export function SearchInput({
onChange: (value: string) => void;
placeholder: string;
}) {
const { t } = useTranslation();
return (
<InputGroup className="h-8 min-w-0 flex-1">
<InputGroupAddon>
@ -160,7 +171,11 @@ export function SearchInput({
/>
{value && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => onChange("")}>
<InputGroupButton
size="icon-xs"
aria-label={t("publicModelHub.searchInput.clear")}
onClick={() => onChange("")}
>
<X />
</InputGroupButton>
</InputGroupAddon>

View file

@ -1,6 +1,7 @@
"use client";
import { CircleCheck, CircleDashed, CircleX } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
import { Badge } from "@/components/ui/badge";
@ -27,6 +28,7 @@ export function StatusBadge({ tone, label }: { tone: HealthTone; label: string }
}
export function HealthBadge({ model }: { model: ModelGroupInfo }) {
const { t } = useTranslation();
const badge = <StatusBadge {...healthOf(model)} />;
if (!model.health_status) return badge;
return (
@ -36,11 +38,13 @@ export function HealthBadge({ model }: { model: ModelGroupInfo }) {
</HoverCardTrigger>
<HoverCardContent align="end" className="w-56 p-3 text-xs">
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<dt className="text-muted-foreground">Response time</dt>
<dt className="text-muted-foreground">{t("publicModelHub.healthBadge.responseTime")}</dt>
<dd className="tabular-nums">
{model.health_response_time ? `${Math.round(model.health_response_time)} ms` : "-"}
{model.health_response_time
? t("publicModelHub.healthBadge.milliseconds", { value: Math.round(model.health_response_time) })
: "-"}
</dd>
<dt className="text-muted-foreground">Last checked</dt>
<dt className="text-muted-foreground">{t("publicModelHub.healthBadge.lastChecked")}</dt>
<dd>{relativeTime(model.health_checked_at)}</dd>
</dl>
</HoverCardContent>

View file

@ -2,6 +2,7 @@
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";
@ -25,6 +26,7 @@ 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">
@ -36,7 +38,14 @@ export function InspectorHeader({
{status && <span className="hidden sm:flex">{status}</span>}
{inSheet && (
<SheetClose
render={<Button variant="ghost" size="icon-sm" aria-label="Close" className="text-muted-foreground" />}
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={t("publicModelHub.inspectorHeader.close")}
className="text-muted-foreground"
/>
}
>
<X />
</SheetClose>

View file

@ -1,5 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import type { MCPServerData } from "@/components/PublicModelHubTableColumns";
import { mcpAuthLabel } from "./hubFormat";
@ -8,20 +10,25 @@ 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="Copy server name" description={server.mcp_info?.description} />
<InspectorHeader
title={server.name}
copyLabel={t("publicModelHub.mcpInspector.copyName")}
description={server.mcp_info?.description}
/>
<Facts
groups={[
[
{ label: "Transport", value: server.transport },
{ label: "Sign-in", value: mcpAuthLabel(server.auth_type) },
{ label: "Alias", value: server.alias },
{ 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="Connect a client">
<PaneSection title={t("publicModelHub.mcpInspector.connect")}>
<CodeBlock code={mcpSnippet(server)} />
</PaneSection>
</div>

View file

@ -2,12 +2,14 @@
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import i18n from "@/i18n/i18n";
import {
formatPerMillion,
@ -25,31 +27,43 @@ import { InspectorHeader } from "./InspectorHeader";
const summary = (model: ModelGroupInfo): string | undefined => {
if (model.description) return model.description;
const providers = (model.providers ?? []).map(providerName).join(", ");
return model.mode && providers ? `${modeLabel(model.mode)} model from ${providers}` : undefined;
return model.mode && providers
? i18n.t("publicModelHub.modelInspector.summary", { mode: modeLabel(model.mode), providers })
: undefined;
};
const tokens = (value: number | undefined) => (value ? `${formatTokens(value)} tokens` : null);
const tokens = (value: number | undefined) =>
value ? i18n.t("publicModelHub.modelInspector.tokens", { tokens: formatTokens(value) }) : null;
const modelFacts = (model: ModelGroupInfo): Fact[][] => {
const providers = model.providers ?? [];
const tags = model.custom_tags ?? [];
return [
[
{ label: "Mode", value: model.mode ? modeLabel(model.mode) : null },
{ label: "Context", value: tokens(model.max_input_tokens) },
{ label: "Max output", value: tokens(model.max_output_tokens) },
],
[
{ label: "Input / 1M tokens", value: formatPerMillion(model.input_cost_per_token) },
{ label: "Output / 1M tokens", value: formatPerMillion(model.output_cost_per_token) },
],
[
{ label: "Requests per minute", value: model.rpm },
{ label: "Tokens per minute", value: model.tpm ? formatTokens(model.tpm) : null },
{ label: i18n.t("publicModelHub.modelInspector.facts.mode"), value: model.mode ? modeLabel(model.mode) : null },
{ label: i18n.t("publicModelHub.modelInspector.facts.context"), value: tokens(model.max_input_tokens) },
{ label: i18n.t("publicModelHub.modelInspector.facts.maxOutput"), value: tokens(model.max_output_tokens) },
],
[
{
label: providers.length > 1 ? "Providers" : "Provider",
label: i18n.t("publicModelHub.modelInspector.facts.inputPrice"),
value: formatPerMillion(model.input_cost_per_token),
},
{
label: i18n.t("publicModelHub.modelInspector.facts.outputPrice"),
value: formatPerMillion(model.output_cost_per_token),
},
],
[
{ label: i18n.t("publicModelHub.modelInspector.facts.rpm"), value: model.rpm },
{ label: i18n.t("publicModelHub.modelInspector.facts.tpm"), value: model.tpm ? formatTokens(model.tpm) : null },
],
[
{
label:
providers.length > 1
? i18n.t("publicModelHub.modelInspector.facts.providers")
: i18n.t("publicModelHub.modelInspector.facts.provider"),
wide: true,
value: providers.length > 0 && (
<span className="flex flex-wrap gap-x-4 gap-y-1">
@ -59,22 +73,27 @@ const modelFacts = (model: ModelGroupInfo): Fact[][] => {
</span>
),
},
{ label: "Tags", wide: true, value: tags.length > 0 && <Badges items={tags} /> },
{
label: i18n.t("publicModelHub.modelInspector.facts.tags"),
wide: true,
value: tags.length > 0 && <Badges items={tags} />,
},
],
];
};
function CallThisModel({ model }: { model: ModelGroupInfo }) {
const { t } = useTranslation();
const curl = curlSnippet(model);
if (!curl) {
return (
<PaneSection title="Call this model">
<PaneSection title={t("publicModelHub.modelInspector.callThisModel.title")}>
<CodeBlock code={modelSnippet(model)} />
</PaneSection>
);
}
return (
<PaneSection title="Call this model">
<PaneSection title={t("publicModelHub.modelInspector.callThisModel.title")}>
<Tabs defaultValue="python" className="gap-3">
<TabsList>
<TabsTrigger value="python">Python</TabsTrigger>
@ -92,10 +111,11 @@ function CallThisModel({ model }: { model: ModelGroupInfo }) {
}
function CustomMetadata({ model }: { model: ModelGroupInfo }) {
const { t } = useTranslation();
const metadata = Object.entries(model.custom_metadata ?? {});
if (metadata.length === 0) return null;
return (
<PaneSection title="Custom metadata">
<PaneSection title={t("publicModelHub.modelInspector.customMetadata.title")}>
<dl className="grid grid-cols-[160px_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm">
{metadata.map(([key, value]) => (
<React.Fragment key={key}>
@ -109,14 +129,15 @@ function CustomMetadata({ model }: { model: ModelGroupInfo }) {
}
function SupportedParams({ params }: { params: string[] }) {
const { t } = useTranslation();
if (params.length === 0) return null;
return (
<Collapsible>
<PaneSection
title="Supported OpenAI parameters"
title={t("publicModelHub.modelInspector.supportedParams.title")}
aside={
<CollapsibleTrigger render={<Button variant="ghost" size="sm" />}>
Show {params.length}
{t("publicModelHub.modelInspector.supportedParams.show", { total: params.length })}
<ChevronDown className="text-muted-foreground" />
</CollapsibleTrigger>
}
@ -130,6 +151,7 @@ function SupportedParams({ params }: { params: string[] }) {
}
export function ModelInspector({ model }: { model: ModelGroupInfo }) {
const { t } = useTranslation();
const providers = model.providers ?? [];
const capabilities = modelCapabilities(model);
return (
@ -137,22 +159,24 @@ export function ModelInspector({ model }: { model: ModelGroupInfo }) {
<InspectorHeader
media={<ProviderAvatar provider={providers[0] ?? ""} className="size-6" />}
title={model.model_group}
copyLabel="Copy model name"
copyLabel={t("publicModelHub.modelInspector.copyName")}
status={<HealthBadge model={model} />}
description={summary(model)}
/>
{model.model_group.includes("*") && (
<Alert>
<AlertTitle>Wildcard model</AlertTitle>
<AlertTitle>{t("publicModelHub.modelInspector.wildcard.title")}</AlertTitle>
<AlertDescription>
Call it as <code className="font-mono">{wildcardExample(model.model_group)}</code>, with any model name this
provider offers
<Trans
i18nKey="publicModelHub.modelInspector.wildcard.description"
components={{ example: <code className="font-mono">{wildcardExample(model.model_group)}</code> }}
/>
</AlertDescription>
</Alert>
)}
<Facts groups={modelFacts(model)} />
{capabilities.length > 0 && (
<PaneSection title="Capabilities">
<PaneSection title={t("publicModelHub.modelInspector.capabilities")}>
<Badges items={capabilities} />
</PaneSection>
)}

View file

@ -1,6 +1,7 @@
"use client";
import { ArrowDownWideNarrow, ArrowUpNarrowWide, Boxes, ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
import { Button } from "@/components/ui/button";
@ -10,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
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";
@ -33,6 +35,7 @@ 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;
@ -40,14 +43,12 @@ function ModelsPager({ models }: { models: Models }) {
const go = (pageIndex: number) => onPaginationChange({ ...pagination, pageIndex });
return (
<div className="flex items-center justify-between gap-2 px-6 pb-3 text-xs text-muted-foreground tabular-nums">
<span>
{start}–{end} of {rowCount}
</span>
<span>{t("publicModelHub.modelsPager.range", { start, end, total: rowCount })}</span>
<ButtonGroup>
<Button
variant="outline"
size="icon-sm"
aria-label="Previous page"
aria-label={t("publicModelHub.modelsPager.previous")}
disabled={pagination.pageIndex === 0}
onClick={() => go(pagination.pageIndex - 1)}
>
@ -56,7 +57,7 @@ function ModelsPager({ models }: { models: Models }) {
<Button
variant="outline"
size="icon-sm"
aria-label="Next page"
aria-label={t("publicModelHub.modelsPager.next")}
disabled={start + pagination.pageSize > rowCount}
onClick={() => go(pagination.pageIndex + 1)}
>
@ -68,6 +69,7 @@ function ModelsPager({ models }: { models: Models }) {
}
function SortControl({ models }: { models: Models }) {
const { t } = useTranslation();
const field = models.sorting[0]?.id ?? "model_group";
const desc = models.sorting[0]?.desc ?? false;
const pick = (value: string | null) => {
@ -77,8 +79,8 @@ function SortControl({ models }: { models: Models }) {
return (
<ButtonGroup>
<Select items={SORTS} value={field} onValueChange={pick}>
<SelectTrigger size="sm" className="w-auto gap-1.5" aria-label="Sort by">
<span className="hidden text-muted-foreground sm:inline">Sort</span>
<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)">
@ -95,7 +97,7 @@ function SortControl({ models }: { models: Models }) {
<Toggle
variant="outline"
size="sm"
aria-label="Sort descending"
aria-label={t("publicModelHub.sortControl.descending")}
pressed={desc}
onPressedChange={(pressed) => models.onSortingChange([{ id: field, desc: pressed }])}
className="dark:bg-input/30 dark:hover:bg-input/50"
@ -104,31 +106,36 @@ function SortControl({ models }: { models: Models }) {
>
{desc ? <ArrowDownWideNarrow /> : <ArrowUpNarrowWide />}
</TooltipTrigger>
<TooltipContent>Sort descending</TooltipContent>
<TooltipContent>{t("publicModelHub.sortControl.descending")}</TooltipContent>
</Tooltip>
</ButtonGroup>
);
}
function ModelsToolbar({ hub }: { hub: HubData }) {
const { t } = useTranslation();
const { models, facets } = hub;
return (
<>
<div className="flex items-center gap-2">
<SearchInput value={models.searchValue} onChange={models.onSearchChange} placeholder="Search models" />
<SearchInput
value={models.searchValue}
onChange={models.onSearchChange}
placeholder={t("publicModelHub.modelsToolbar.search")}
/>
<SortControl models={models} />
</div>
<div className="flex flex-wrap items-center gap-2">
<FilterChip
label="Provider"
noun="providers"
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="Mode"
noun="modes"
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),
@ -137,8 +144,8 @@ function ModelsToolbar({ hub }: { hub: HubData }) {
onChange={models.onModesChange}
/>
<FilterChip
label="Features"
noun="features"
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}
@ -149,16 +156,23 @@ function ModelsToolbar({ hub }: { hub: HubData }) {
}
const price = (cost: number | null | undefined, direction: "input" | "output") => {
if (cost === 0) return `Free ${direction}`;
return cost ? `${formatPerMillion(cost)} / 1M ${direction === "input" ? "in" : "out"}` : null;
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
? ["Free", null]
? [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 }) {
const { t } = useTranslation();
const [input, output] = prices(model);
const priced = Boolean(input || output);
return (
@ -173,7 +187,9 @@ function ModelRow({ model }: { model: ModelGroupInfo }) {
parts={[
firstPlus((model.providers ?? []).map(providerName)),
model.mode ? modeLabel(model.mode) : null,
model.max_input_tokens ? `${formatTokens(model.max_input_tokens)} context` : null,
model.max_input_tokens
? t("publicModelHub.modelRow.context", { tokens: formatTokens(model.max_input_tokens) })
: null,
]}
/>
</ItemDescription>
@ -202,6 +218,7 @@ export function ModelsPane({
selected: string | null;
onSelect: (key: string | null) => void;
}) {
const { t } = useTranslation();
const { models } = hub;
const opened = useOpenedModel(selected, models.rows, !models.isLoading);
const clear = () => {
@ -219,11 +236,11 @@ export function ModelsPane({
onSelect={onSelect}
empty={
models.error ? (
<LoadFailed noun="models" onRetry={models.refetch} />
<LoadFailed noun={t("publicModelHub.modelsPane.noun")} onRetry={models.refetch} />
) : (
<NoResults
icon={<Boxes />}
noun="models"
noun={t("publicModelHub.modelsPane.noun")}
query={models.searchValue}
onClear={models.hasActiveQuery ? clear : undefined}
/>

View file

@ -1,6 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import Navbar from "@/components/navbar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -32,25 +33,26 @@ const hubStatus = (hub: HubData): HubStatus => {
};
function HubTabs({ hub }: { hub: HubData }) {
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}>Models</TabLabel>
<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}>Agents</TabLabel>
<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">MCP</span>
<span className="hidden sm:inline">MCP servers</span>
<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}>Skills</TabLabel>
<TabLabel count={hub.skills.data?.length}>{t("publicModelHub.tabs.skills")}</TabLabel>
</TabsTrigger>
</TabsList>
<TabsContent value="models">

View file

@ -2,6 +2,7 @@
import { Bot, Puzzle, Server } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { Plugin } from "@/components/claude_code_plugins/types";
import type { AgentCard, MCPServerData } from "@/components/PublicModelHubTableColumns";
@ -52,6 +53,7 @@ function ResourcePane<T>({
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();
@ -83,7 +85,11 @@ function ResourcePane<T>({
}
toolbar={
<div className="flex items-center gap-2">
<SearchInput value={query} onChange={setQuery} placeholder={`Search ${noun}`} />
<SearchInput
value={query}
onChange={setQuery}
placeholder={t("publicModelHub.resourcePane.search", { noun })}
/>
{options.length > 0 && (
<FilterChip
label={filter.label}
@ -122,16 +128,17 @@ interface PaneProps<T> {
}
export function AgentsPane({ rows, ...props }: PaneProps<AgentCard>) {
const { t } = useTranslation();
return (
<ResourcePane<AgentCard>
{...props}
rows={rows ?? []}
noun="agents"
noun={t("publicModelHub.agentsPane.noun")}
icon={<Bot />}
search={(agent) => [agent.name, agent.description]}
filter={{
label: "Skill tag",
noun: "skill tags",
label: t("publicModelHub.agentsPane.filterLabel"),
noun: t("publicModelHub.agentsPane.filterNoun"),
values: (agent) => (agent.skills ?? []).flatMap((s) => s.tags ?? []),
}}
getKey={(agent) => agent.name}
@ -144,16 +151,17 @@ export function AgentsPane({ rows, ...props }: PaneProps<AgentCard>) {
}
export function McpPane({ rows, ...props }: PaneProps<MCPServerData>) {
const { t } = useTranslation();
return (
<ResourcePane<MCPServerData>
{...props}
rows={rows ?? []}
noun="MCP servers"
noun={t("publicModelHub.mcpPane.noun")}
icon={<Server />}
search={(server) => [server.name, server.mcp_info?.description]}
filter={{
label: "Transport",
noun: "transports",
label: t("publicModelHub.mcpPane.filterLabel"),
noun: t("publicModelHub.mcpPane.filterNoun"),
values: (server) => (server.transport ? [server.transport] : []),
}}
getKey={(server) => server.name}
@ -166,14 +174,19 @@ export function McpPane({ rows, ...props }: PaneProps<MCPServerData>) {
}
export function SkillsPane({ rows, ...props }: PaneProps<Plugin>) {
const { t } = useTranslation();
return (
<ResourcePane<Plugin>
{...props}
rows={rows ?? []}
noun="skills"
noun={t("publicModelHub.skillsPane.noun")}
icon={<Puzzle />}
search={(skill) => [skill.name, skill.description, skill.namespace, skill.domain, ...(skill.keywords ?? [])]}
filter={{ label: "Domain", noun: "domains", values: (skill) => (skill.domain ? [skill.domain] : []) }}
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}

View file

@ -1,6 +1,7 @@
"use client";
import { ExternalLink } from "lucide-react";
import { useTranslation } from "react-i18next";
import { getSourceDisplayText, getSourceLink } from "@/components/claude_code_plugins/helpers";
import type { Plugin } from "@/components/claude_code_plugins/types";
@ -29,35 +30,46 @@ function sourceValue(source: Plugin["source"]) {
}
export function SkillInspector({ skill }: { skill: Plugin }) {
const { t } = useTranslation();
const keywords = skill.keywords ?? [];
const addedAt = skill.created_at ? new Date(skill.created_at) : null;
const added = addedAt && !Number.isNaN(addedAt.getTime()) ? addedAt.toLocaleDateString() : null;
return (
<div className="flex flex-col gap-6 p-6">
<InspectorHeader title={skill.name} copyLabel="Copy skill name" description={skill.description} />
<InspectorHeader
title={skill.name}
copyLabel={t("publicModelHub.skillInspector.copyName")}
description={skill.description}
/>
<Facts
groups={[
[
{ label: "Domain", value: skill.domain },
{ label: "Namespace", value: skill.namespace },
{ label: "Version", value: skill.version },
{ label: "Category", value: skill.category },
{ label: t("publicModelHub.skillInspector.facts.domain"), value: skill.domain },
{ label: t("publicModelHub.skillInspector.facts.namespace"), value: skill.namespace },
{ label: t("publicModelHub.skillInspector.facts.version"), value: skill.version },
{ label: t("publicModelHub.skillInspector.facts.category"), value: skill.category },
],
[
{ label: "Source", wide: true, value: sourceValue(skill.source) },
{ label: "Author", value: skill.author?.name },
{ label: "Added", value: added },
{ label: t("publicModelHub.skillInspector.facts.source"), wide: true, value: sourceValue(skill.source) },
{ label: t("publicModelHub.skillInspector.facts.author"), value: skill.author?.name },
{ label: t("publicModelHub.skillInspector.facts.added"), value: added },
],
[
{
label: t("publicModelHub.skillInspector.facts.keywords"),
wide: true,
value: keywords.length > 0 && <Badges items={keywords} />,
},
],
[{ label: "Keywords", wide: true, value: keywords.length > 0 && <Badges items={keywords} /> }],
]}
/>
<PaneSection title="Install in Claude Code">
<PaneSection title={t("publicModelHub.skillInspector.install.title")}>
<Field>
<FieldDescription>First time only: add this marketplace</FieldDescription>
<FieldDescription>{t("publicModelHub.skillInspector.install.marketplace")}</FieldDescription>
<CodeBlock code={marketplaceCommand()} />
</Field>
<Field>
<FieldDescription>Then install the skill</FieldDescription>
<FieldDescription>{t("publicModelHub.skillInspector.install.skill")}</FieldDescription>
<CodeBlock code={skillInstallCommand(skill)} />
</Field>
</PaneSection>

View file

@ -1,32 +1,35 @@
import type { ParseKeys } from "i18next";
import { getProxyBaseUrl } from "@/components/networking";
import type { AgentCard, ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
import { displayMode } from "@/lib/decisionModels";
import i18n from "@/i18n/i18n";
export const SECTION_TITLE = "text-[15px] leading-[22px] font-semibold";
const MODE_LABELS: Record<string, string> = {
chat: "Chat",
completion: "Completion",
embedding: "Embedding",
rerank: "Rerank",
image_generation: "Image generation",
image_edit: "Image edit",
audio_speech: "Text to speech",
audio_transcription: "Transcription",
video_generation: "Video generation",
realtime: "Realtime",
responses: "Responses",
batch: "Batch",
ocr: "OCR",
decisions: "Decisions",
const MODE_LABELS: Record<string, ParseKeys> = {
chat: "publicModelHub.format.modes.chat",
completion: "publicModelHub.format.modes.completion",
embedding: "publicModelHub.format.modes.embedding",
rerank: "publicModelHub.format.modes.rerank",
image_generation: "publicModelHub.format.modes.imageGeneration",
image_edit: "publicModelHub.format.modes.imageEdit",
audio_speech: "publicModelHub.format.modes.audioSpeech",
audio_transcription: "publicModelHub.format.modes.audioTranscription",
video_generation: "publicModelHub.format.modes.videoGeneration",
realtime: "publicModelHub.format.modes.realtime",
responses: "publicModelHub.format.modes.responses",
batch: "publicModelHub.format.modes.batch",
ocr: "publicModelHub.format.modes.ocr",
decisions: "publicModelHub.format.modes.decisions",
};
const sentenceCase = (text: string): string => text.replaceAll("_", " ").replace(/^./, (c) => c.toUpperCase());
export const modeLabel = (mode: string | null | undefined): string => {
const shown = displayMode(mode);
return shown ? MODE_LABELS[shown] ?? sentenceCase(shown) : "-";
return shown ? (Object.hasOwn(MODE_LABELS, shown) && i18n.t(MODE_LABELS[shown])) || sentenceCase(shown) : "-";
};
export const formatTokens = (tokens: number | null | undefined): string => {
@ -47,7 +50,7 @@ const PER_MILLION_FORMAT: Intl.NumberFormatOptions = {
const PER_MILLION = new Intl.NumberFormat("en-US", PER_MILLION_FORMAT);
export const formatPerMillion = (cost: number | null | undefined): string => {
if (cost === 0) return "Free";
if (cost === 0) return i18n.t("publicModelHub.format.free");
return cost ? PER_MILLION.format(cost * 1_000_000) : "-";
};
@ -56,14 +59,14 @@ export const providerName = (provider: string): string =>
export const providerLogo = (provider: string): string => getProviderLogoAndName(provider).logo;
const CAPABILITY_LABELS: Record<string, string> = {
supports_vision: "Vision",
supports_function_calling: "Function calling",
supports_reasoning: "Reasoning",
supports_web_search: "Web search",
supports_parallel_function_calling: "Parallel function calling",
supports_url_context: "URL context",
supports_fast_mode: "Fast mode",
const CAPABILITY_LABELS: Record<string, ParseKeys> = {
supports_vision: "publicModelHub.format.capabilities.vision",
supports_function_calling: "publicModelHub.format.capabilities.functionCalling",
supports_reasoning: "publicModelHub.format.capabilities.reasoning",
supports_web_search: "publicModelHub.format.capabilities.webSearch",
supports_parallel_function_calling: "publicModelHub.format.capabilities.parallelFunctionCalling",
supports_url_context: "publicModelHub.format.capabilities.urlContext",
supports_fast_mode: "publicModelHub.format.capabilities.fastMode",
};
const CAPABILITY_ORDER = Object.keys(CAPABILITY_LABELS);
@ -74,7 +77,7 @@ const capabilityRank = (key: string): number => {
};
export const capabilityLabel = (key: string): string =>
CAPABILITY_LABELS[key] ?? sentenceCase(key.replace(/^supports_/, ""));
Object.hasOwn(CAPABILITY_LABELS, key) ? i18n.t(CAPABILITY_LABELS[key]) : sentenceCase(key.replace(/^supports_/, ""));
export const featureFacetLabel = (feature: string): string => capabilityLabel(`supports_${feature}`);
@ -98,18 +101,18 @@ const HEALTH: Record<string, { tone: HealthTone; label: string }> = {
};
export const healthOf = (model: ModelGroupInfo): { tone: HealthTone; label: string } =>
HEALTH[model.health_status ?? ""] ?? { tone: "neutral", label: "Not checked" };
HEALTH[model.health_status ?? ""] ?? { tone: "neutral", label: i18n.t("publicModelHub.format.notChecked") };
export const relativeTime = (iso: string | null | undefined, now: number = Date.now()): string => {
const then = iso ? new Date(iso).getTime() : NaN;
if (Number.isNaN(then)) return "-";
const minutes = Math.max(0, Math.round((now - then) / 60_000));
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes} min ago`;
if (minutes < 1) return i18n.t("publicModelHub.format.relativeTime.justNow");
if (minutes < 60) return i18n.t("publicModelHub.format.relativeTime.minutes", { minutes });
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours} h ago`;
if (hours < 24) return i18n.t("publicModelHub.format.relativeTime.hours", { hours });
const days = Math.round(hours / 24);
return `${days} ${days === 1 ? "day" : "days"} ago`;
return i18n.t("publicModelHub.format.relativeTime.days", { count: days });
};
export const gatewayUrl = (): string => getProxyBaseUrl();
@ -119,15 +122,15 @@ export const wildcardExample = (modelGroup: string): string => modelGroup.replac
export const firstPlus = (items: string[]): string | undefined =>
items.length > 1 ? `${items[0]} +${items.length - 1}` : items[0];
const MCP_AUTH: Record<string, string> = {
none: "None",
oauth2: "OAuth",
bearer_token: "Bearer token",
api_key: "API key",
const MCP_AUTH: Record<string, ParseKeys> = {
none: "publicModelHub.format.mcpAuth.none",
oauth2: "publicModelHub.format.mcpAuth.oauth2",
bearer_token: "publicModelHub.format.mcpAuth.bearerToken",
api_key: "publicModelHub.format.mcpAuth.apiKey",
};
export const mcpAuthLabel = (authType: string | null | undefined): string =>
authType ? MCP_AUTH[authType] ?? authType : "-";
authType ? (Object.hasOwn(MCP_AUTH, authType) && i18n.t(MCP_AUTH[authType])) || authType : "-";
export const usefulLinkList = (
links: Record<string, string | { url: string; index?: number }> | undefined,

View file

@ -6,6 +6,7 @@ import { getEndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
import { getGlobalLitellmHeaderName } from "@/components/networking";
import type { AgentCard, MCPServerData, ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
import { isDecisionMode } from "@/lib/decisionModels";
import i18n from "@/i18n/i18n";
import { gatewayUrl, wildcardExample } from "./hubFormat";
@ -48,14 +49,14 @@ export const curlSnippet = (model: ModelGroupInfo): string | null => {
export const agentSnippets = (agent: AgentCard): { title: string; code: string }[] => [
{
title: "Retrieve the agent card",
title: i18n.t("publicModelHub.snippets.retrieveAgentCard"),
code: `from a2a.client import A2ACardResolver
resolver = A2ACardResolver(httpx_client=httpx_client, base_url="${agent.url}")
card = await resolver.get_agent_card()`,
},
{
title: "Call the agent",
title: i18n.t("publicModelHub.snippets.callAgent"),
code: `from a2a.client import A2AClient
from a2a.types import MessageSendParams, SendMessageRequest

View file

@ -3,6 +3,7 @@ import { ExternalLinkIcon, SearchIcon } from "@heroicons/react/outline";
import { SortingState } from "@tanstack/react-table";
import { Copy, Inbox, Info } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@ -95,6 +96,7 @@ function PublicHubEmptyState({ title, body }: { title: string; body: string }) {
}
const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded = false, variant = "dashboard" }) => {
const { t } = useTranslation();
const isChat = variant === "chat";
const portalClassName = isChat ? "chat-theme" : undefined;
const dashboardRootClassName = isEmbedded ? "w-full" : "min-h-screen bg-card";
@ -275,7 +277,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success("Copied to clipboard!");
toast.success(t("aiHub.publicModelHub.copiedToClipboard"));
};
const formatCapabilityName = (key: string) => {
@ -310,7 +312,9 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
() => modelFacets.features.map((feature) => ({ label: featureLabel(feature), value: feature })),
[modelFacets],
);
const serviceStatus = models.error ? "Service unavailable" : "I'm alive! ✓";
const serviceStatus = models.error
? t("aiHub.publicModelHub.serviceUnavailable")
: t("aiHub.publicModelHub.serviceAlive");
const [agentSorting, setAgentSorting] = useState<SortingState>([{ id: "name", desc: false }]);
const [mcpSorting, setMcpSorting] = useState<SortingState>([{ id: "server_name", desc: false }]);
@ -351,26 +355,21 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{/* Embedded Explainer - only shown when embedded in dashboard */}
{!isChat && isEmbedded && (
<div className="mb-6 p-4 bg-info/10 border border-info/20 rounded-lg">
<p className="text-sm text-foreground">
These are models, agents, and MCP servers your proxy admin has indicated are available in your
company.
</p>
<p className="text-sm text-foreground">{t("aiHub.publicModelHub.embeddedExplainer")}</p>
</div>
)}
{/* About Section - only shown when not embedded */}
{!isChat && !isEmbedded && (
<Card className="mb-10 p-8 bg-card border border-border rounded-lg shadow-xs">
<h2 className="text-2xl font-semibold mb-6 text-foreground">About</h2>
<h2 className="text-2xl font-semibold mb-6 text-foreground">{t("aiHub.publicModelHub.about.title")}</h2>
<p className="text-foreground mb-6 text-base leading-relaxed">
{customDocsDescription
? customDocsDescription
: "Proxy Server to call 100+ LLMs in the OpenAI format."}
{customDocsDescription ? customDocsDescription : t("aiHub.publicModelHub.about.defaultDescription")}
</p>
<div className="flex items-center space-x-3 text-sm text-muted-foreground">
<span className="flex items-center">
<span className="w-4 h-4 mr-2">🔧</span>
Built with litellm: v{litellmVersion}
{t("aiHub.publicModelHub.about.builtWith", { version: litellmVersion })}
</span>
</div>
</Card>
@ -379,7 +378,9 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{/* Useful Links - only shown when not embedded */}
{showUsefulLinks && (
<Card className="mb-10 p-8 bg-card border border-border rounded-lg shadow-xs">
<h2 className="text-2xl font-semibold mb-6 text-foreground">Useful Links</h2>
<h2 className="text-2xl font-semibold mb-6 text-foreground">
{t("aiHub.publicModelHub.usefulLinks.title")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Object.entries(usefulLinks || {})
.map(([title, value]) => {
@ -406,9 +407,13 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{/* Health and Endpoint Status - only shown when not embedded */}
{!isChat && !isEmbedded && (
<Card className="mb-10 p-8 bg-card border border-border rounded-lg shadow-xs">
<h2 className="text-2xl font-semibold mb-6 text-foreground">Health and Endpoint Status</h2>
<h2 className="text-2xl font-semibold mb-6 text-foreground">
{t("aiHub.publicModelHub.health.title")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<p className="text-success font-medium text-sm">Service status: {serviceStatus}</p>
<p className="text-success font-medium text-sm">
{t("aiHub.publicModelHub.health.serviceStatus", { status: serviceStatus })}
</p>
</div>
</Card>
)}
@ -424,10 +429,10 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<Tabs value={isChat ? "models" : activeTab} onValueChange={setActiveTab} className="public-hub-tabs">
{!isChat && (
<TabsList>
<TabsTrigger value="models">Model Hub</TabsTrigger>
{hasAgents && <TabsTrigger value="agents">Agent Hub</TabsTrigger>}
{hasMcpServers && <TabsTrigger value="mcp">MCP Hub</TabsTrigger>}
<TabsTrigger value="skills">Skill Hub</TabsTrigger>
<TabsTrigger value="models">{t("aiHub.publicModelHub.tabs.models")}</TabsTrigger>
{hasAgents && <TabsTrigger value="agents">{t("aiHub.publicModelHub.tabs.agents")}</TabsTrigger>}
{hasMcpServers && <TabsTrigger value="mcp">{t("aiHub.publicModelHub.tabs.mcp")}</TabsTrigger>}
<TabsTrigger value="skills">{t("aiHub.publicModelHub.tabs.skills")}</TabsTrigger>
</TabsList>
)}
@ -435,14 +440,19 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<TabsContent value="models" className="model-hub-models">
{!isChat && (
<div className="model-hub-heading flex justify-between items-center mb-8">
<h2 className="text-2xl font-semibold text-foreground">Available Models</h2>
<h2 className="text-2xl font-semibold text-foreground">
{t("aiHub.publicModelHub.models.title")}
</h2>
</div>
)}
{isChat && (
<div className="chat-model-section-heading">
<h2>Models</h2>
<span className="chat-model-section-count" aria-label={`${models.rowCount} models`}>
<h2>{t("aiHub.publicModelHub.models.chatTitle")}</h2>
<span
className="chat-model-section-count"
aria-label={t("aiHub.publicModelHub.models.chatCount", { rowCount: models.rowCount })}
>
{models.rowCount.toLocaleString()}
</span>
</div>
@ -459,12 +469,13 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<div className={isChat ? "chat-model-filter-search" : undefined}>
{!isChat && (
<div className="flex items-center space-x-2 mb-3">
<p className="text-sm font-medium text-foreground">Search Models:</p>
<p className="text-sm font-medium text-foreground">
{t("aiHub.publicModelHub.models.searchLabel")}
</p>
<Tooltip>
<TooltipTrigger render={<Info className="w-4 h-4 text-muted-foreground cursor-help" />} />
<TooltipContent side="top" className={portalClassName}>
Finds every published model whose name contains what you type, across all pages. Try
&apos;grok&apos;, &apos;claude&apos;, &apos;gpt-4&apos;, or &apos;sonnet&apos;
{t("aiHub.publicModelHub.models.searchTooltip")}
</TooltipContent>
</Tooltip>
</div>
@ -474,8 +485,8 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{isChat ? (
<Input
type="text"
placeholder="Search models"
aria-label="Search models"
placeholder={t("aiHub.publicModelHub.models.chatSearchPlaceholder")}
aria-label={t("aiHub.publicModelHub.models.chatSearchAriaLabel")}
value={models.searchValue}
onChange={(e) => models.onSearchChange(e.target.value)}
className="pl-9"
@ -483,8 +494,8 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
) : (
<input
type="text"
placeholder="Search model names..."
aria-label="Search model names"
placeholder={t("aiHub.publicModelHub.models.searchPlaceholder")}
aria-label={t("aiHub.publicModelHub.models.searchAriaLabel")}
value={models.searchValue}
onChange={(e) => models.onSearchChange(e.target.value)}
className="border border-border rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-hidden focus:ring-2 focus:ring-ring focus:border-transparent bg-card"
@ -502,7 +513,9 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
)}
{!isChat && (
<div className="min-w-0">
<p className="text-sm font-medium mb-3 text-foreground">Provider:</p>
<p className="text-sm font-medium mb-3 text-foreground">
{t("aiHub.publicModelHub.models.providerLabel")}
</p>
<Combobox
multiple
items={modelFacets.providers}
@ -520,13 +533,13 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
}
</ComboboxValue>
<ComboboxChipsInput
placeholder="Select providers"
aria-label="Select providers"
placeholder={t("aiHub.publicModelHub.models.providersPlaceholder")}
aria-label={t("aiHub.publicModelHub.models.providersAriaLabel")}
className="min-w-24"
/>
</ComboboxChips>
<ComboboxContent anchor={anchor} className={portalClassName}>
<ComboboxEmpty>No providers found</ComboboxEmpty>
<ComboboxEmpty>{t("aiHub.publicModelHub.models.noProvidersFound")}</ComboboxEmpty>
<ComboboxList>
{(provider: string) => {
const { logo } = getProviderLogoAndName(provider);
@ -554,24 +567,40 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
</div>
)}
<div className={isChat ? "chat-model-filter-select min-w-0" : "min-w-0"}>
{!isChat && <p className="text-sm font-medium mb-3 text-foreground">Mode:</p>}
{!isChat && (
<p className="text-sm font-medium mb-3 text-foreground">
{t("aiHub.publicModelHub.models.modeLabel")}
</p>
)}
<MultiSelect
options={modeOptions}
contentClassName={portalClassName}
value={models.modeValues}
onValueChange={models.onModesChange}
placeholder={isChat ? "Model type" : "Select modes"}
placeholder={
isChat
? t("aiHub.publicModelHub.models.chatModePlaceholder")
: t("aiHub.publicModelHub.models.modePlaceholder")
}
className="w-full"
/>
</div>
<div className={isChat ? "chat-model-filter-select min-w-0" : "min-w-0"}>
{!isChat && <p className="text-sm font-medium mb-3 text-foreground">Features:</p>}
{!isChat && (
<p className="text-sm font-medium mb-3 text-foreground">
{t("aiHub.publicModelHub.models.featuresLabel")}
</p>
)}
<MultiSelect
options={featureOptions}
contentClassName={portalClassName}
value={models.featureValues}
onValueChange={models.onFeaturesChange}
placeholder={isChat ? "Model capability" : "Select features"}
placeholder={
isChat
? t("aiHub.publicModelHub.models.chatFeaturesPlaceholder")
: t("aiHub.publicModelHub.models.featuresPlaceholder")
}
className="w-full"
/>
</div>
@ -590,14 +619,18 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
onPaginationChange={models.onPaginationChange}
rowCount={models.rowCount}
isLoading={models.isLoading}
loadingMessage="Loading models…"
loadingMessage={t("aiHub.publicModelHub.models.loading")}
noDataMessage={
<PublicHubEmptyState
title={models.hasActiveQuery ? "No matching models" : "No models available"}
title={
models.hasActiveQuery
? t("aiHub.publicModelHub.models.noMatchTitle")
: t("aiHub.publicModelHub.models.emptyTitle")
}
body={
models.hasActiveQuery
? "Adjust the search or filters to see more models."
: "Models made public by the proxy admin will appear here."
? t("aiHub.publicModelHub.models.noMatchBody")
: t("aiHub.publicModelHub.models.emptyBody")
}
/>
}
@ -610,18 +643,22 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{!isChat && hasAgents && (
<TabsContent value="agents">
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-semibold text-foreground">Available Agents</h2>
<h2 className="text-2xl font-semibold text-foreground">
{t("aiHub.publicModelHub.agents.title")}
</h2>
</div>
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8 p-6 bg-muted rounded-lg border border-border">
<div>
<div className="flex items-center space-x-2 mb-3">
<p className="text-sm font-medium text-foreground">Search Agents:</p>
<p className="text-sm font-medium text-foreground">
{t("aiHub.publicModelHub.agents.searchLabel")}
</p>
<Tooltip>
<TooltipTrigger render={<Info className="w-4 h-4 text-muted-foreground cursor-help" />} />
<TooltipContent side="top" className={portalClassName}>
Search agents by name or description
{t("aiHub.publicModelHub.agents.searchTooltip")}
</TooltipContent>
</Tooltip>
</div>
@ -629,7 +666,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<SearchIcon className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
type="text"
placeholder="Search agent names or descriptions..."
placeholder={t("aiHub.publicModelHub.agents.searchPlaceholder")}
value={agentSearchTerm}
onChange={(e) => setAgentSearchTerm(e.target.value)}
className="border border-border rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-hidden focus:ring-2 focus:ring-ring focus:border-transparent bg-card"
@ -637,12 +674,14 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
</div>
</div>
<div className="min-w-0">
<p className="text-sm font-medium mb-3 text-foreground">Skills:</p>
<p className="text-sm font-medium mb-3 text-foreground">
{t("aiHub.publicModelHub.agents.skillsLabel")}
</p>
<MultiSelect
options={agentSkillOptions}
value={selectedAgentSkills}
onValueChange={setSelectedAgentSkills}
placeholder="Select skills"
placeholder={t("aiHub.publicModelHub.agents.skillsPlaceholder")}
className="w-full"
/>
</div>
@ -657,11 +696,11 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
sorting={agentSorting}
onSortingChange={setAgentSorting}
isLoading={agentLoading}
loadingMessage="Loading agents…"
loadingMessage={t("aiHub.publicModelHub.agents.loading")}
noDataMessage={
<PublicHubEmptyState
title="No matching agents"
body="Adjust the search or skill filter to see more agents."
title={t("aiHub.publicModelHub.agents.noMatchTitle")}
body={t("aiHub.publicModelHub.agents.noMatchBody")}
/>
}
size="compact"
@ -669,7 +708,10 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<div className="mt-8 text-center">
<p className="text-sm text-muted-foreground">
Showing {filteredAgentData.length} of {agentHubData?.length || 0} agents
{t("aiHub.publicModelHub.agents.showing", {
shown: filteredAgentData.length,
total: agentHubData?.length || 0,
})}
</p>
</div>
</TabsContent>
@ -679,18 +721,20 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{!isChat && hasMcpServers && (
<TabsContent value="mcp">
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-semibold text-foreground">Available MCP Servers</h2>
<h2 className="text-2xl font-semibold text-foreground">{t("aiHub.publicModelHub.mcp.title")}</h2>
</div>
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8 p-6 bg-muted rounded-lg border border-border">
<div>
<div className="flex items-center space-x-2 mb-3">
<p className="text-sm font-medium text-foreground">Search MCP Servers:</p>
<p className="text-sm font-medium text-foreground">
{t("aiHub.publicModelHub.mcp.searchLabel")}
</p>
<Tooltip>
<TooltipTrigger render={<Info className="w-4 h-4 text-muted-foreground cursor-help" />} />
<TooltipContent side="top" className={portalClassName}>
Search MCP servers by name or description
{t("aiHub.publicModelHub.mcp.searchTooltip")}
</TooltipContent>
</Tooltip>
</div>
@ -698,7 +742,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<SearchIcon className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
type="text"
placeholder="Search MCP server names or descriptions..."
placeholder={t("aiHub.publicModelHub.mcp.searchPlaceholder")}
value={mcpSearchTerm}
onChange={(e) => setMcpSearchTerm(e.target.value)}
className="border border-border rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-hidden focus:ring-2 focus:ring-ring focus:border-transparent bg-card"
@ -706,12 +750,14 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
</div>
</div>
<div className="min-w-0">
<p className="text-sm font-medium mb-3 text-foreground">Transport:</p>
<p className="text-sm font-medium mb-3 text-foreground">
{t("aiHub.publicModelHub.mcp.transportLabel")}
</p>
<MultiSelect
options={mcpTransportOptions}
value={selectedMcpTransports}
onValueChange={setSelectedMcpTransports}
placeholder="Select transport types"
placeholder={t("aiHub.publicModelHub.mcp.transportPlaceholder")}
className="w-full"
/>
</div>
@ -726,11 +772,11 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
sorting={mcpSorting}
onSortingChange={setMcpSorting}
isLoading={mcpLoading}
loadingMessage="Loading MCP servers…"
loadingMessage={t("aiHub.publicModelHub.mcp.loading")}
noDataMessage={
<PublicHubEmptyState
title="No matching MCP servers"
body="Adjust the search or transport filter to see more servers."
title={t("aiHub.publicModelHub.mcp.noMatchTitle")}
body={t("aiHub.publicModelHub.mcp.noMatchBody")}
/>
}
size="compact"
@ -738,7 +784,10 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<div className="mt-8 text-center">
<p className="text-sm text-muted-foreground">
Showing {filteredMcpData.length} of {mcpHubData?.length || 0} MCP servers
{t("aiHub.publicModelHub.mcp.showing", {
shown: filteredMcpData.length,
total: mcpHubData?.length || 0,
})}
</p>
</div>
</TabsContent>
@ -761,7 +810,9 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
>
<DialogHeader>
<DialogTitle className="flex min-w-0 items-center space-x-2">
<span className="break-words">{selectedModel?.model_group || "Model Details"}</span>
<span className="break-words">
{selectedModel?.model_group || t("aiHub.publicModelHub.modelDialog.fallbackTitle")}
</span>
{selectedModel && (
<Tooltip>
<TooltipTrigger
@ -772,7 +823,9 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
/>
}
/>
<TooltipContent className={portalClassName}>Copy model name</TooltipContent>
<TooltipContent className={portalClassName}>
{t("aiHub.publicModelHub.modelDialog.copyName")}
</TooltipContent>
</Tooltip>
)}
</DialogTitle>
@ -781,18 +834,18 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<div className="space-y-6">
{/* Model Overview */}
<div>
<p className="text-lg font-semibold mb-4">Model Overview</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.publicModelHub.modelDialog.overview")}</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<p className="font-medium">Model Name:</p>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.modelName")}</p>
<p>{selectedModel.model_group}</p>
</div>
<div>
<p className="font-medium">Mode:</p>
<p>{displayMode(selectedModel.mode) ?? "Not specified"}</p>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.mode")}</p>
<p>{displayMode(selectedModel.mode) ?? t("aiHub.publicModelHub.modelDialog.notSpecified")}</p>
</div>
<div>
<p className="font-medium">Providers:</p>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.providers")}</p>
<div className="flex flex-wrap gap-1 mt-1">
{(selectedModel.providers ?? []).map((provider) => {
const { logo } = getProviderLogoAndName(provider);
@ -818,7 +871,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
</div>
{selectedModel.description && (
<div className="col-span-2">
<p className="font-medium">Description:</p>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.description")}</p>
<p className="mt-1">{selectedModel.description}</p>
</div>
)}
@ -834,21 +887,31 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<div className="flex items-start space-x-2">
<Info className="w-4 h-4 text-info mt-0.5 shrink-0" />
<div>
<p className="font-medium text-info mb-2">Wildcard Routing</p>
<p className="font-medium text-info mb-2">
{t("aiHub.publicModelHub.modelDialog.wildcardTitle")}
</p>
<p className="text-sm text-info mb-2">
This model uses wildcard routing. You can pass any value where you see the{" "}
<code className="bg-info/15 px-1 py-0.5 rounded-sm text-xs">*</code> symbol.
<Trans
i18nKey="aiHub.publicModelHub.modelDialog.wildcardIntro"
components={{ code: <code className="bg-info/15 px-1 py-0.5 rounded-sm text-xs" /> }}
/>
</p>
<p className="text-sm text-info">
For example, with{" "}
<code className="bg-info/15 px-1 py-0.5 rounded-sm text-xs">
{selectedModel.model_group}
</code>
, you can use any string (
<code className="bg-info/15 px-1 py-0.5 rounded-sm text-xs">
{selectedModel.model_group.replaceAll("*", "my-custom-value")}
</code>
) that matches this pattern.
<Trans
i18nKey="aiHub.publicModelHub.modelDialog.wildcardExample"
components={{
model: (
<code className="bg-info/15 px-1 py-0.5 rounded-sm text-xs">
{selectedModel.model_group}
</code>
),
example: (
<code className="bg-info/15 px-1 py-0.5 rounded-sm text-xs">
{selectedModel.model_group.replaceAll("*", "my-custom-value")}
</code>
),
}}
/>
</p>
</div>
</div>
@ -858,30 +921,36 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{/* Token and Cost Information */}
<div>
<p className="text-lg font-semibold mb-4">Token & Cost Information</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.publicModelHub.modelDialog.tokenCost")}</p>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="font-medium">Max Input Tokens:</p>
<p>{selectedModel.max_input_tokens?.toLocaleString() || "Not specified"}</p>
</div>
<div>
<p className="font-medium">Max Output Tokens:</p>
<p>{selectedModel.max_output_tokens?.toLocaleString() || "Not specified"}</p>
</div>
<div>
<p className="font-medium">Input Cost per 1M Tokens:</p>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.maxInputTokens")}</p>
<p>
{selectedModel.input_cost_per_token
? formatCost(selectedModel.input_cost_per_token)
: "Not specified"}
{selectedModel.max_input_tokens?.toLocaleString() ||
t("aiHub.publicModelHub.modelDialog.notSpecified")}
</p>
</div>
<div>
<p className="font-medium">Output Cost per 1M Tokens:</p>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.maxOutputTokens")}</p>
<p>
{selectedModel.max_output_tokens?.toLocaleString() ||
t("aiHub.publicModelHub.modelDialog.notSpecified")}
</p>
</div>
<div>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.inputCost")}</p>
<p>
{selectedModel.input_cost_per_token
? formatCost(selectedModel.input_cost_per_token)
: t("aiHub.publicModelHub.modelDialog.notSpecified")}
</p>
</div>
<div>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.outputCost")}</p>
<p>
{selectedModel.output_cost_per_token
? formatCost(selectedModel.output_cost_per_token)
: "Not specified"}
: t("aiHub.publicModelHub.modelDialog.notSpecified")}
</p>
</div>
</div>
@ -889,13 +958,17 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{/* Capabilities */}
<div>
<p className="text-lg font-semibold mb-4">Capabilities</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.publicModelHub.modelDialog.capabilities")}</p>
<div className="flex flex-wrap gap-2">
{(() => {
const capabilities = getModelCapabilities(selectedModel);
if (capabilities.length === 0) {
return <p className="text-muted-foreground">No special capabilities listed</p>;
return (
<p className="text-muted-foreground">
{t("aiHub.publicModelHub.modelDialog.noCapabilities")}
</p>
);
}
return capabilities.map((capability) => (
@ -910,17 +983,17 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{/* Rate Limits */}
{(selectedModel.tpm || selectedModel.rpm) && (
<div>
<p className="text-lg font-semibold mb-4">Rate Limits</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.publicModelHub.modelDialog.rateLimits")}</p>
<div className="grid grid-cols-2 gap-4">
{selectedModel.tpm && (
<div>
<p className="font-medium">Tokens per Minute:</p>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.tpm")}</p>
<p>{selectedModel.tpm.toLocaleString()}</p>
</div>
)}
{selectedModel.rpm && (
<div>
<p className="font-medium">Requests per Minute:</p>
<p className="font-medium">{t("aiHub.publicModelHub.modelDialog.rpm")}</p>
<p>{selectedModel.rpm.toLocaleString()}</p>
</div>
)}
@ -931,7 +1004,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{/* Supported OpenAI Parameters */}
{selectedModel.supported_openai_params && selectedModel.supported_openai_params.length > 0 && (
<div>
<p className="text-lg font-semibold mb-4">Supported OpenAI Parameters</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.publicModelHub.modelDialog.openaiParams")}</p>
<div className="flex flex-wrap gap-2">
{selectedModel.supported_openai_params.map((param) => (
<Badge key={param} variant="secondary">
@ -944,7 +1017,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{/* Usage Example */}
<div>
<p className="text-lg font-semibold mb-4">Usage Example</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.publicModelHub.modelDialog.usageExample")}</p>
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<pre className="text-sm">{usageExample(selectedModel)}</pre>
</div>
@ -953,7 +1026,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
onClick={() => copyToClipboard(usageExample(selectedModel))}
className="text-sm text-info hover:text-info/80 cursor-pointer"
>
Copy to clipboard
{t("aiHub.publicModelHub.modelDialog.copyToClipboard")}
</button>
</div>
</div>
@ -969,7 +1042,9 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
>
<DialogHeader>
<DialogTitle className="flex min-w-0 items-center space-x-2">
<span className="break-words">{selectedAgent?.name || "Agent Details"}</span>
<span className="break-words">
{selectedAgent?.name || t("aiHub.publicModelHub.agentDialog.fallbackTitle")}
</span>
{selectedAgent && (
<Tooltip>
<TooltipTrigger
@ -980,7 +1055,9 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
/>
}
/>
<TooltipContent className={portalClassName}>Copy agent name</TooltipContent>
<TooltipContent className={portalClassName}>
{t("aiHub.publicModelHub.agentDialog.copyName")}
</TooltipContent>
</Tooltip>
)}
</DialogTitle>
@ -989,23 +1066,23 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<div className="space-y-6">
{/* Agent Overview */}
<div>
<p className="text-lg font-semibold mb-4">Agent Overview</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.publicModelHub.agentDialog.overview")}</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<p className="font-medium">Name:</p>
<p className="font-medium">{t("aiHub.publicModelHub.agentDialog.name")}</p>
<p>{selectedAgent.name}</p>
</div>
<div>
<p className="font-medium">Version:</p>
<p className="font-medium">{t("aiHub.publicModelHub.agentDialog.version")}</p>
<p>{selectedAgent.version}</p>
</div>
<div className="col-span-2">
<p className="font-medium">Description:</p>
<p className="font-medium">{t("aiHub.publicModelHub.agentDialog.description")}</p>
<p>{selectedAgent.description}</p>
</div>
{selectedAgent.url && (
<div>
<p className="font-medium">URL:</p>
<p className="font-medium">{t("aiHub.publicModelHub.agentDialog.url")}</p>
<a
href={selectedAgent.url}
target="_blank"
@ -1022,7 +1099,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{/* Capabilities */}
{selectedAgent.capabilities && (
<div>
<p className="text-lg font-semibold mb-4">Capabilities</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.publicModelHub.agentDialog.capabilities")}</p>
<div className="flex flex-wrap gap-2">
{Object.entries(selectedAgent.capabilities)
.filter(([_, value]) => value === true)
@ -1038,7 +1115,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{/* Skills */}
{selectedAgent.skills && selectedAgent.skills.length > 0 && (
<div>
<p className="text-lg font-semibold mb-4">Skills</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.publicModelHub.agentDialog.skills")}</p>
<div className="space-y-4">
{selectedAgent.skills.map((skill, index) => (
<div key={index} className="border border-border rounded-lg p-4">
@ -1065,10 +1142,10 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{/* Input/Output Modes */}
<div>
<p className="text-lg font-semibold mb-4">Input/Output Modes</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.publicModelHub.agentDialog.ioModes")}</p>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="font-medium">Input Modes:</p>
<p className="font-medium">{t("aiHub.publicModelHub.agentDialog.inputModes")}</p>
<div className="flex flex-wrap gap-1 mt-1">
{(selectedAgent.defaultInputModes ?? []).map((mode) => (
<Badge key={mode} variant="secondary">
@ -1078,7 +1155,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
</div>
</div>
<div>
<p className="font-medium">Output Modes:</p>
<p className="font-medium">{t("aiHub.publicModelHub.agentDialog.outputModes")}</p>
<div className="flex flex-wrap gap-1 mt-1">
{(selectedAgent.defaultOutputModes ?? []).map((mode) => (
<Badge key={mode} variant="secondary">
@ -1093,7 +1170,9 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
{/* Documentation */}
{selectedAgent.documentationUrl && (
<div>
<p className="text-lg font-semibold mb-4">Documentation</p>
<p className="text-lg font-semibold mb-4">
{t("aiHub.publicModelHub.agentDialog.documentation")}
</p>
<a
href={selectedAgent.documentationUrl}
target="_blank"
@ -1101,18 +1180,20 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
className="text-info hover:text-info/80 flex items-center space-x-2"
>
<ExternalLinkIcon className="w-4 h-4" />
<span>View Documentation</span>
<span>{t("aiHub.publicModelHub.agentDialog.viewDocumentation")}</span>
</a>
</div>
)}
{/* A2A Usage Example */}
<div>
<p className="text-lg font-semibold mb-4">Usage Example (A2A Protocol)</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.publicModelHub.agentDialog.usageExample")}</p>
{/* Step 1: Retrieve Agent Card */}
<div className="mb-4">
<p className="text-sm font-medium mb-2 text-foreground">Step 1: Retrieve Agent Card</p>
<p className="text-sm font-medium mb-2 text-foreground">
{t("aiHub.publicModelHub.agentDialog.stepRetrieveCard")}
</p>
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<pre className="text-xs">
{`base_url = '${selectedAgent.url}'
@ -1200,14 +1281,16 @@ if _public_card.supports_authenticated_extended_card:
}}
className="text-sm text-info hover:text-info/80 cursor-pointer"
>
Copy to clipboard
{t("aiHub.publicModelHub.agentDialog.copyToClipboard")}
</button>
</div>
</div>
{/* Step 2: Call the Agent */}
<div>
<p className="text-sm font-medium mb-2 text-foreground">Step 2: Call the Agent</p>
<p className="text-sm font-medium mb-2 text-foreground">
{t("aiHub.publicModelHub.agentDialog.stepCallAgent")}
</p>
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<pre className="text-xs">
{`client = A2AClient(
@ -1257,7 +1340,7 @@ print(response.model_dump(mode='json', exclude_none=True))`;
}}
className="text-sm text-info hover:text-info/80 cursor-pointer"
>
Copy to clipboard
{t("aiHub.publicModelHub.agentDialog.copyToClipboard")}
</button>
</div>
</div>
@ -1274,7 +1357,9 @@ print(response.model_dump(mode='json', exclude_none=True))`;
>
<DialogHeader>
<DialogTitle className="flex min-w-0 items-center space-x-2">
<span className="break-words">{selectedMcpServer?.server_name || "MCP Server Details"}</span>
<span className="break-words">
{selectedMcpServer?.server_name || t("aiHub.publicModelHub.mcpDialog.fallbackTitle")}
</span>
{selectedMcpServer && (
<Tooltip>
<TooltipTrigger
@ -1285,7 +1370,9 @@ print(response.model_dump(mode='json', exclude_none=True))`;
/>
}
/>
<TooltipContent className={portalClassName}>Copy server name</TooltipContent>
<TooltipContent className={portalClassName}>
{t("aiHub.publicModelHub.mcpDialog.copyName")}
</TooltipContent>
</Tooltip>
)}
</DialogTitle>
@ -1294,30 +1381,30 @@ print(response.model_dump(mode='json', exclude_none=True))`;
<div className="space-y-6">
{/* Server Overview */}
<div>
<p className="text-lg font-semibold mb-4">Server Overview</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.publicModelHub.mcpDialog.overview")}</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<p className="font-medium">Server Name:</p>
<p className="font-medium">{t("aiHub.publicModelHub.mcpDialog.serverName")}</p>
<p>{selectedMcpServer.server_name}</p>
</div>
<div>
<p className="font-medium">Transport:</p>
<p className="font-medium">{t("aiHub.publicModelHub.mcpDialog.transport")}</p>
<Badge variant="secondary">{selectedMcpServer.transport}</Badge>
</div>
{selectedMcpServer.alias && (
<div>
<p className="font-medium">Alias:</p>
<p className="font-medium">{t("aiHub.publicModelHub.mcpDialog.alias")}</p>
<p>{selectedMcpServer.alias}</p>
</div>
)}
<div>
<p className="font-medium">Auth Type:</p>
<p className="font-medium">{t("aiHub.publicModelHub.mcpDialog.authType")}</p>
<Badge variant={selectedMcpServer.auth_type === "none" ? "outline" : "secondary"}>
{selectedMcpServer.auth_type}
</Badge>
</div>
<div className="col-span-2">
<p className="font-medium">Description:</p>
<p className="font-medium">{t("aiHub.publicModelHub.mcpDialog.description")}</p>
<p>{selectedMcpServer.mcp_info?.description || "-"}</p>
</div>
</div>
@ -1326,7 +1413,7 @@ print(response.model_dump(mode='json', exclude_none=True))`;
{/* Additional Info */}
{selectedMcpServer.mcp_info && Object.keys(selectedMcpServer.mcp_info).length > 0 && (
<div>
<p className="text-lg font-semibold mb-4">Additional Information</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.publicModelHub.mcpDialog.additionalInfo")}</p>
<div className="bg-muted p-4 rounded-lg">
<pre className="text-xs overflow-x-auto">
{JSON.stringify(selectedMcpServer.mcp_info, null, 2)}
@ -1337,7 +1424,7 @@ print(response.model_dump(mode='json', exclude_none=True))`;
{/* Usage Example */}
<div>
<p className="text-lg font-semibold mb-4">Usage Example</p>
<p className="text-lg font-semibold mb-4">{t("aiHub.publicModelHub.mcpDialog.usageExample")}</p>
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<pre className="text-sm">
{`# Using MCP Server with Python FastMCP
@ -1419,7 +1506,7 @@ if __name__ == "__main__":
}}
className="text-sm text-info hover:text-info/80 cursor-pointer"
>
Copy to clipboard
{t("aiHub.publicModelHub.mcpDialog.copyToClipboard")}
</button>
</div>
</div>

View file

@ -1,14 +1,18 @@
"use client";
import React from "react";
import { useTranslation } from "react-i18next";
interface AdminOnlyNoticeProps {
pageTitle: string;
}
export const AdminOnlyNotice: React.FC<AdminOnlyNoticeProps> = ({ pageTitle }) => (
<div className="p-6 w-full min-w-0 flex-1">
<h1 className="text-2xl font-semibold text-foreground mb-2">{pageTitle}</h1>
<p className="text-sm text-muted-foreground">{pageTitle} is only available to admin users.</p>
</div>
);
export const AdminOnlyNotice: React.FC<AdminOnlyNoticeProps> = ({ pageTitle }) => {
const { t } = useTranslation();
return (
<div className="p-6 w-full min-w-0 flex-1">
<h1 className="text-2xl font-semibold text-foreground mb-2">{pageTitle}</h1>
<p className="text-sm text-muted-foreground">{t("shell.adminOnlyNotice.description", { pageTitle })}</p>
</div>
);
};

View file

@ -4,6 +4,7 @@ import dayjs from "dayjs";
import { CircleHelp } from "lucide-react";
import { getBudgetDurationLabel } from "@/components/common_components/budget_duration_dropdown";
import i18n from "@/i18n/i18n";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { budgetPeriodRange } from "@/utils/budgetUtils";
import { formatNumberWithCommas } from "@/utils/dataUtils";
@ -21,10 +22,14 @@ const describePeriod = (budgetDuration: string, budgetResetAt: string | null | u
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const period = budgetPeriodRange(budgetDuration, budgetResetAt);
if (period) {
return `Budget period: ${formatPeriodDate(period.start)} to ${formatPeriodDate(period.end)} (${timeZone})`;
return i18n.t("shared.budgetPeriodTooltip.budgetPeriod", {
start: formatPeriodDate(period.start),
end: formatPeriodDate(period.end),
timeZone,
});
}
if (budgetResetAt && dayjs(budgetResetAt).isValid()) {
return `Next reset: ${formatPeriodDate(budgetResetAt)} (${timeZone})`;
return i18n.t("shared.budgetPeriodTooltip.nextReset", { date: formatPeriodDate(budgetResetAt), timeZone });
}
return null;
};
@ -35,15 +40,22 @@ export const budgetPeriodLines = ({
budgetDuration,
budgetResetAt,
}: BudgetPeriodTooltipProps): readonly string[] => {
const spendLine = `${budgetDuration ? "Spend this period" : "Spend so far"}: $${formatNumberWithCommas(spend ?? 0, 4)}`;
if (maxBudget == null) return ["No budget set", spendLine];
const maxBudgetLine = `Max budget: $${formatNumberWithCommas(maxBudget, 2)}`;
if (!budgetDuration) return ["Lifetime budget, never resets", maxBudgetLine, spendLine];
const spendLine = i18n.t("shared.budgetPeriodTooltip.spendLine", {
label: budgetDuration
? i18n.t("shared.budgetPeriodTooltip.spendThisPeriod")
: i18n.t("shared.budgetPeriodTooltip.spendSoFar"),
amount: formatNumberWithCommas(spend ?? 0, 4),
});
if (maxBudget == null) return [i18n.t("shared.budgetPeriodTooltip.noBudgetSet"), spendLine];
const maxBudgetLine = i18n.t("shared.budgetPeriodTooltip.maxBudget", {
amount: formatNumberWithCommas(maxBudget, 2),
});
if (!budgetDuration) return [i18n.t("shared.budgetPeriodTooltip.lifetimeBudget"), maxBudgetLine, spendLine];
const periodLine = describePeriod(budgetDuration, budgetResetAt);
return [
...(periodLine ? [periodLine] : []),
maxBudgetLine,
`Reset interval: ${getBudgetDurationLabel(budgetDuration)}`,
i18n.t("shared.budgetPeriodTooltip.resetInterval", { interval: getBudgetDurationLabel(budgetDuration) }),
spendLine,
];
};

View file

@ -2,6 +2,7 @@
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import i18n from "@/i18n/i18n";
import { cn } from "@/lib/cva.config";
import { copyToClipboard } from "@/utils/dataUtils";
import { Check, Copy } from "lucide-react";
@ -37,7 +38,7 @@ const CopyButton: React.FC<CopyButtonProps> = ({
iconClassName,
variant = "icon",
iconOnly = false,
copiedLabel = "Copied",
copiedLabel = i18n.t("shared.copyButton.copied"),
}) => {
const [copied, setCopied] = useState(false);
const isAction = variant === "action";

View file

@ -1,6 +1,7 @@
import React, { useState } from "react";
import { CopyToClipboard } from "react-copy-to-clipboard";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import anthropicLogo from "../../../public/assets/logos/anthropic.svg";
import openaiLogo from "../../../public/assets/logos/openai_small.svg";
import { getProxyBaseUrl } from "@/components/networking";
@ -41,6 +42,7 @@ export const buildAgentInstructions = (baseUrl: string, apiKey: string): string
* Used on the Virtual Keys page and in the Add Agent wizard.
*/
const CreatedKeyDisplay: React.FC<CreatedKeyDisplayProps> = ({ apiKey }) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const [instructionsCopied, setInstructionsCopied] = useState(false);
const [showInstructions, setShowInstructions] = useState(false);
@ -49,39 +51,42 @@ const CreatedKeyDisplay: React.FC<CreatedKeyDisplayProps> = ({ apiKey }) => {
const handleCopy = () => {
setCopied(true);
toast.success("Key copied to clipboard");
toast.success(t("shared.createdKeyDisplay.toasts.keyCopied"));
setTimeout(() => setCopied(false), 2000);
};
const handleCopyInstructions = () => {
setInstructionsCopied(true);
toast.success("Instructions copied");
toast.success(t("shared.createdKeyDisplay.toasts.instructionsCopied"));
setTimeout(() => setInstructionsCopied(false), 2000);
};
return (
<div>
<p className="mb-2">
Please save this secret key somewhere safe and accessible. For security reasons,{" "}
<b>you will not be able to view it again</b> through your LiteLLM account. If you lose this secret key, you will
need to generate a new one.
<Trans i18nKey="shared.createdKeyDisplay.saveWarning" components={{ bold: <b /> }} />
</p>
<p className="text-sm text-muted-foreground mt-3 mb-1">Virtual Key:</p>
<p className="text-sm text-muted-foreground mt-3 mb-1">{t("shared.createdKeyDisplay.virtualKey")}</p>
<div className="bg-muted rounded-md p-2.5 mb-2.5">
<pre className="m-0 whitespace-normal break-words text-foreground">{apiKey}</pre>
</div>
<CopyToClipboard text={apiKey} onCopy={handleCopy}>
<Button className="mt-3">{copied ? "Copied!" : "Copy Virtual Key"}</Button>
<Button className="mt-3">
{copied ? t("shared.createdKeyDisplay.copied") : t("shared.createdKeyDisplay.copyVirtualKey")}
</Button>
</CopyToClipboard>
<p className="text-sm text-muted-foreground mt-4 mb-1">Gateway URL:</p>
<p className="text-sm text-muted-foreground mt-4 mb-1">{t("shared.createdKeyDisplay.gatewayUrl")}</p>
<div className="bg-muted rounded-md p-2.5 mb-2.5 flex items-center justify-between gap-2">
<pre className="m-0 whitespace-normal break-words text-foreground">{baseUrl}</pre>
<CopyToClipboard text={baseUrl} onCopy={() => toast.success("Gateway URL copied to clipboard")}>
<CopyToClipboard
text={baseUrl}
onCopy={() => toast.success(t("shared.createdKeyDisplay.toasts.gatewayUrlCopied"))}
>
<Button type="button" variant="outline" className="shrink-0">
Copy URL
{t("shared.createdKeyDisplay.copyUrl")}
</Button>
</CopyToClipboard>
</div>
@ -89,13 +94,17 @@ const CreatedKeyDisplay: React.FC<CreatedKeyDisplayProps> = ({ apiKey }) => {
<div className="mt-4 rounded-md border border-border p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">Agent instructions</p>
<p className="text-sm font-medium">{t("shared.createdKeyDisplay.agentInstructions")}</p>
<img src={anthropicLogo.src} alt="Claude Code" title="Claude Code" className="size-5 object-contain" />
<img src={openaiLogo.src} alt="Codex" title="Codex" className="size-5 object-contain" />
</div>
<div className="flex items-center gap-2">
<CopyToClipboard text={instructions} onCopy={handleCopyInstructions}>
<Button type="button">{instructionsCopied ? "Copied!" : "Copy instructions"}</Button>
<Button type="button">
{instructionsCopied
? t("shared.createdKeyDisplay.copied")
: t("shared.createdKeyDisplay.copyInstructions")}
</Button>
</CopyToClipboard>
<Button
type="button"
@ -103,7 +112,7 @@ const CreatedKeyDisplay: React.FC<CreatedKeyDisplayProps> = ({ apiKey }) => {
aria-expanded={showInstructions}
onClick={() => setShowInstructions((expanded) => !expanded)}
>
{showInstructions ? "Hide" : "Show"}
{showInstructions ? t("shared.createdKeyDisplay.hide") : t("shared.createdKeyDisplay.show")}
{showInstructions ? (
<ChevronUp aria-hidden="true" className="size-4" />
) : (

View file

@ -29,6 +29,7 @@ import { SearchX } from "lucide-react";
import { ErrorState } from "@/components/shared/ErrorState";
import * as React from "react";
import { Fragment, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Skeleton } from "@/components/ui/skeleton";
import {
@ -353,13 +354,14 @@ function MessageRow({
}
function DefaultEmptyState() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<SearchX className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No results</div>
<div className="text-sm text-muted-foreground">No rows match your search or filters.</div>
<div className="text-sm font-medium text-foreground">{t("shared.dataTable.defaultEmptyState.title")}</div>
<div className="text-sm text-muted-foreground">{t("shared.dataTable.defaultEmptyState.description")}</div>
</div>
);
}
@ -596,6 +598,7 @@ function useSettledPageClamp<TData extends RowData>(table: Table<TData>, options
}
export function DataTable<TData extends RowData, TValue>(props: DataTableProps<TData, TValue>) {
const { t } = useTranslation();
const resolved: DataTableResolvedProps<TData, TValue> = props;
const {
@ -603,7 +606,7 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
isError = false,
errorMessage,
onRetry,
loadingMessage = "Loading…",
loadingMessage = t("shared.dataTable.loadingMessage"),
skeletonRowCount = 8,
noDataMessage,
paginationMode = "none",

View file

@ -6,6 +6,7 @@ import * as React from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import i18n from "@/i18n/i18n";
export interface FilterDraft {
get: (columnId: string) => unknown;
@ -46,10 +47,10 @@ export function DataTableFilterDrawer<TData>({
table,
open,
onOpenChange,
title = "Filters",
title = i18n.t("shared.dataTable.filterDrawer.title"),
description,
applyLabel = "Apply Filters",
resetLabel = "Reset",
applyLabel = i18n.t("shared.dataTable.filterDrawer.apply"),
resetLabel = i18n.t("shared.dataTable.filterDrawer.reset"),
onReset,
children,
}: DataTableFilterDrawerProps<TData>) {

View file

@ -1,6 +1,7 @@
"use client";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -29,6 +30,7 @@ export function DataTablePagination({
isLoading = false,
className,
}: DataTablePaginationProps) {
const { t } = useTranslation();
const pageCount = pageSize > 0 ? Math.ceil(rowCount / pageSize) : 0;
const start = rowCount === 0 ? 0 : page * pageSize + 1;
const end = Math.min((page + 1) * pageSize, rowCount);
@ -42,7 +44,7 @@ export function DataTablePagination({
className={cn("flex flex-wrap items-center justify-between gap-4 px-4 py-2.5", className)}
>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Rows per page</span>
<span>{t("shared.dataTable.pagination.rowsPerPage")}</span>
<Select
value={String(pageSize)}
onValueChange={(value) => {
@ -66,17 +68,19 @@ export function DataTablePagination({
<div data-slot="table-pagination-summary" className="flex items-center gap-4">
<span data-testid="pagination-range" className="text-sm text-muted-foreground tabular-nums">
{rowCount === 0 ? "No results" : `Showing ${start}-${end} of ${rowCount}`}
{rowCount === 0
? t("shared.dataTable.pagination.noResults")
: t("shared.dataTable.pagination.showing", { start, end, total: rowCount })}
</span>
<span data-testid="pagination-page" className="text-sm text-muted-foreground tabular-nums">
Page {page + 1} of {Math.max(pageCount, 1)}
{t("shared.dataTable.pagination.pageOf", { page: page + 1, total: Math.max(pageCount, 1) })}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
data-testid="pagination-first"
aria-label="Go to first page"
aria-label={t("shared.dataTable.pagination.firstPage")}
disabled={!canPrev}
onClick={() => onPageChange(0)}
>
@ -86,7 +90,7 @@ export function DataTablePagination({
variant="outline"
size="icon-sm"
data-testid="pagination-prev"
aria-label="Go to previous page"
aria-label={t("shared.dataTable.pagination.previousPage")}
disabled={!canPrev}
onClick={() => onPageChange(page - 1)}
>
@ -96,7 +100,7 @@ export function DataTablePagination({
variant="outline"
size="icon-sm"
data-testid="pagination-next"
aria-label="Go to next page"
aria-label={t("shared.dataTable.pagination.nextPage")}
disabled={!canNext}
onClick={() => onPageChange(page + 1)}
>
@ -106,7 +110,7 @@ export function DataTablePagination({
variant="outline"
size="icon-sm"
data-testid="pagination-last"
aria-label="Go to last page"
aria-label={t("shared.dataTable.pagination.lastPage")}
disabled={!canNext}
onClick={() => onPageChange(lastPage)}
>

View file

@ -1,20 +1,23 @@
"use client";
import type { ColumnDef, Row, RowData, Table } from "@tanstack/react-table";
import { useTranslation } from "react-i18next";
import { Checkbox } from "@/components/ui/checkbox";
import i18n from "@/i18n/i18n";
interface SelectionColumnOptions<TData> {
rowAriaLabel?: (row: Row<TData>) => string;
}
function SelectAllCheckbox<TData>({ table }: { table: Table<TData> }) {
const { t } = useTranslation();
const allSelected = table.getIsAllPageRowsSelected();
const someSelected = table.getIsSomePageRowsSelected();
return (
<Checkbox
aria-label="Select all rows"
aria-label={t("shared.dataTable.selectionColumn.selectAllRows")}
data-testid="datatable-select-all"
checked={allSelected}
indeterminate={someSelected && !allSelected}
@ -48,6 +51,11 @@ export function createSelectionColumn<TData extends RowData>(
enableResizing: false,
meta: { title: "Select", className: "w-11", headerClassName: "w-11" },
header: ({ table }) => <SelectAllCheckbox table={table} />,
cell: ({ row }) => <SelectRowCheckbox row={row} label={rowAriaLabel?.(row) ?? "Select row"} />,
cell: ({ row }) => (
<SelectRowCheckbox
row={row}
label={rowAriaLabel?.(row) ?? i18n.t("shared.dataTable.selectionColumn.selectRow")}
/>
),
};
}

View file

@ -4,6 +4,7 @@ import { Menu } from "@base-ui/react/menu";
import type { Column, SortDirection, Table } from "@tanstack/react-table";
import { Check, ChevronDown, ChevronsUpDown, ChevronUp, X } from "lucide-react";
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cva.config";
@ -35,6 +36,7 @@ export function DataTableSortHeader<TData, TValue>({
variant = "header-cycle",
className,
}: DataTableSortHeaderProps<TData, TValue>) {
const { t } = useTranslation();
const sorted = column.getIsSorted();
if (!column.getCanSort()) {
@ -51,7 +53,7 @@ export function DataTableSortHeader<TData, TValue>({
<button
type="button"
data-testid={`sort-trigger-${column.id}`}
aria-label={`Sort options for ${column.id}`}
aria-label={t("shared.dataTable.sortHeader.sortOptionsFor", { column: column.id })}
onClick={(event) => event.stopPropagation()}
className={cn(
"inline-flex size-6 items-center justify-center rounded-md hover:bg-muted",
@ -66,13 +68,13 @@ export function DataTableSortHeader<TData, TValue>({
<Menu.Positioner side="bottom" align="start" sideOffset={4} className="isolate z-popup">
<Menu.Popup className="min-w-[9rem] rounded-md bg-popover p-1 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden">
<Menu.Item className={MENU_ITEM_CLASS} onClick={() => column.toggleSorting(false)}>
<ChevronUp className="size-3.5" /> Ascending
<ChevronUp className="size-3.5" /> {t("shared.dataTable.sortHeader.ascending")}
</Menu.Item>
<Menu.Item className={MENU_ITEM_CLASS} onClick={() => column.toggleSorting(true)}>
<ChevronDown className="size-3.5" /> Descending
<ChevronDown className="size-3.5" /> {t("shared.dataTable.sortHeader.descending")}
</Menu.Item>
<Menu.Item className={MENU_ITEM_CLASS} onClick={() => column.clearSorting()}>
<X className="size-3.5" /> Reset
<X className="size-3.5" /> {t("shared.dataTable.sortHeader.reset")}
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
@ -114,14 +116,27 @@ interface DataTableMultiSortHeaderProps<TData> {
* without opening the menu. The chevron opens a menu offering each field in both directions.
*/
export function DataTableMultiSortHeader<TData>({ table, fields, className }: DataTableMultiSortHeaderProps<TData>) {
const { t } = useTranslation();
const active = table.getState().sorting[0];
const activeField = active !== undefined && fields.some((field) => field.id === active.id) ? active : undefined;
const activeDirection: SortDirection = activeField?.desc === true ? "desc" : "asc";
const sorted: false | SortDirection = activeField === undefined ? false : activeDirection;
const options = fields.flatMap((field) => [
{ key: `${field.id}-asc`, id: field.id, desc: false, label: `${field.label} ascending`, Icon: ChevronUp },
{ key: `${field.id}-desc`, id: field.id, desc: true, label: `${field.label} descending`, Icon: ChevronDown },
{
key: `${field.id}-asc`,
id: field.id,
desc: false,
label: t("shared.dataTable.multiSortHeader.fieldAscending", { field: field.label }),
Icon: ChevronUp,
},
{
key: `${field.id}-desc`,
id: field.id,
desc: true,
label: t("shared.dataTable.multiSortHeader.fieldDescending", { field: field.label }),
Icon: ChevronDown,
},
]);
const segmentClass = (isActive: boolean): string => {
@ -155,7 +170,9 @@ export function DataTableMultiSortHeader<TData>({ table, fields, className }: Da
<button
type="button"
data-testid={`sort-trigger-${fields[0]?.id ?? "field"}`}
aria-label={`Sort options for ${fields.map((field) => field.label).join(" or ")}`}
aria-label={t("shared.dataTable.multiSortHeader.sortOptionsFor", {
fields: fields.map((field) => field.label).join(t("shared.dataTable.multiSortHeader.fieldSeparator")),
})}
onClick={(event) => event.stopPropagation()}
className={cn(
"inline-flex size-6 items-center justify-center rounded-md hover:bg-muted",
@ -183,7 +200,7 @@ export function DataTableMultiSortHeader<TData>({ table, fields, className }: Da
);
})}
<Menu.Item className={MENU_ITEM_CLASS} onClick={() => table.setSorting([])}>
<X className="size-3.5" /> Reset
<X className="size-3.5" /> {t("shared.dataTable.multiSortHeader.reset")}
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>

View file

@ -3,10 +3,12 @@
import type { Table } from "@tanstack/react-table";
import { RefreshCw, Search, SlidersHorizontal, X } from "lucide-react";
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import i18n from "@/i18n/i18n";
import { cn } from "@/lib/cva.config";
import { DataTableViewOptions } from "./DataTableViewOptions";
@ -37,7 +39,7 @@ export function DataTableToolbar<TData>({
table,
searchValue,
onSearchChange,
searchPlaceholder = "Search",
searchPlaceholder = i18n.t("shared.dataTable.toolbar.search"),
onOpenFilters,
onRefresh,
isRefreshing = false,
@ -47,6 +49,7 @@ export function DataTableToolbar<TData>({
children,
className,
}: DataTableToolbarProps<TData>) {
const { t } = useTranslation();
const filters = table.getState().columnFilters;
const labelFor = (columnId: string): string =>
@ -75,7 +78,7 @@ export function DataTableToolbar<TData>({
{valueFor(filter.id, filter.value)}
<button
type="button"
aria-label={`Remove ${labelFor(filter.id)} filter`}
aria-label={t("shared.dataTable.toolbar.removeFilter", { label: labelFor(filter.id) })}
data-testid={`filter-chip-remove-${filter.id}`}
onClick={() => table.setColumnFilters((previous) => previous.filter((entry) => entry.id !== filter.id))}
className="ml-0.5 rounded-full text-muted-foreground hover:text-foreground"
@ -91,7 +94,7 @@ export function DataTableToolbar<TData>({
onClick={() => table.setColumnFilters([])}
data-testid="datatable-clear-filters"
>
Clear all
{t("shared.dataTable.toolbar.clearAll")}
</Button>
)}
</div>
@ -103,18 +106,18 @@ export function DataTableToolbar<TData>({
size="icon-sm"
onClick={onRefresh}
disabled={isRefreshing}
aria-label="Refresh"
title="Refresh"
aria-label={t("shared.dataTable.toolbar.refresh")}
title={t("shared.dataTable.toolbar.refresh")}
data-testid="datatable-refresh"
>
<RefreshCw className={isRefreshing ? "animate-spin" : ""} />
</Button>
)}
{showViewOptions && <DataTableViewOptions table={table} label="Columns" />}
{showViewOptions && <DataTableViewOptions table={table} label={t("shared.dataTable.toolbar.columns")} />}
{onOpenFilters !== undefined && (
<Button variant="outline" size="sm" onClick={onOpenFilters} data-testid="datatable-filters-trigger">
<SlidersHorizontal />
Filters
{t("shared.dataTable.toolbar.filters")}
{filters.length > 0 && (
<Badge className="ml-1 h-5 min-w-5 justify-center rounded-full px-1" data-testid="datatable-filter-count">
{filters.length}

View file

@ -5,6 +5,7 @@ import type { Table } from "@tanstack/react-table";
import { Check, Columns3 } from "lucide-react";
import { Button } from "@/components/ui/button";
import i18n from "@/i18n/i18n";
import { cn } from "@/lib/cva.config";
interface DataTableViewOptionsProps<TData> {
@ -16,7 +17,7 @@ interface DataTableViewOptionsProps<TData> {
export function DataTableViewOptions<TData>({
table,
label = "View",
label = i18n.t("shared.dataTable.viewOptions.view"),
iconOnly = false,
className,
}: DataTableViewOptionsProps<TData>) {

View file

@ -1,6 +1,8 @@
import { Button } from "@/components/ui/button";
import { TriangleAlert } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n/i18n";
interface ErrorStateProps {
title?: string;
@ -8,7 +10,8 @@ interface ErrorStateProps {
onRetry?: () => void;
}
export function ErrorState({ title = "Failed to load", message, onRetry }: ErrorStateProps) {
export function ErrorState({ title = i18n.t("shared.errorState.title"), message, onRetry }: ErrorStateProps) {
const { t } = useTranslation();
return (
<div role="alert" className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-destructive/10">
@ -18,7 +21,7 @@ export function ErrorState({ title = "Failed to load", message, onRetry }: Error
{message ? <div className="max-w-prose text-center text-sm text-muted-foreground">{message}</div> : null}
{onRetry ? (
<Button variant="outline" size="sm" className="mt-2" onClick={onRetry}>
Retry
{t("shared.errorState.retry")}
</Button>
) : null}
</div>

View file

@ -1,8 +1,11 @@
"use client";
import { useTranslation } from "react-i18next";
import { SimpleTooltip } from "@/components/ui/tooltip";
import type { Team } from "@/components/key_team_helpers/key_list";
import type { Organization } from "@/components/networking";
import i18n from "@/i18n/i18n";
import { formatNumberWithCommas } from "@/utils/dataUtils";
export interface InheritedBudgetGate {
@ -115,7 +118,10 @@ export const teamMemberBudgetGate = (
return {
scope: "Team member",
alias: `${memberLabel || userId} in ${teamInfo.team_info.team_alias || teamInfo.team_info.team_id}`,
alias: i18n.t("shared.inheritedBudgetHint.memberAlias", {
member: memberLabel || userId,
team: teamInfo.team_info.team_alias || teamInfo.team_info.team_id,
}),
maxBudget: budgetRow.max_budget + activeIncrease(membershipBudget, now),
budgetDuration: budgetRow.budget_duration ?? null,
spend: membership?.spend ?? 0,
@ -143,12 +149,13 @@ interface InheritedBudgetHintProps {
}
export function InheritedBudgetHint({ gates }: InheritedBudgetHintProps) {
const { t } = useTranslation();
if (gates.length === 0) return null;
return (
<SimpleTooltip
content={
<div data-testid="inherited-budget-hint" className="flex flex-col gap-1">
<span>This key has no budget of its own, but its spend still counts toward:</span>
<span>{t("shared.inheritedBudgetHint.intro")}</span>
{gates.map((gate) => (
<span key={gate.scope}>{formatGate(gate)}</span>
))}

View file

@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Combobox,
ComboboxChip,
@ -14,6 +15,7 @@ import {
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
import i18n from "@/i18n/i18n";
export interface MultiSelectOption {
label: string;
@ -57,14 +59,15 @@ export function MultiSelect({
options,
value = [],
onValueChange,
placeholder = "Select options",
emptyText = "No options found",
placeholder = i18n.t("shared.multiSelect.placeholder"),
emptyText = i18n.t("shared.multiSelect.empty"),
disabled = false,
loading = false,
allowCustomValues = false,
className,
contentClassName,
}: MultiSelectProps) {
const { t } = useTranslation();
const anchor = useComboboxAnchor();
const [query, setQuery] = useState("");
const safeOptions = options.filter(
@ -84,7 +87,7 @@ export function MultiSelect({
const customOptionExists = safeOptions.some((option) => option.value.toLowerCase() === customOption.toLowerCase());
const items =
allowCustomValues && customOption && !customOptionExists
? [...safeOptions, { label: `Create "${customOption}"`, value: customOption }]
? [...safeOptions, { label: t("shared.multiSelect.createOption", { value: customOption }), value: customOption }]
: safeOptions;
const canClear = (selected: MultiSelectOption[]) => selected.length > 0 && !disabled && !loading;
@ -121,11 +124,13 @@ export function MultiSelect({
))}
<ComboboxChipsInput
id={id}
placeholder={loading ? "Loading..." : placeholder}
placeholder={loading ? t("shared.multiSelect.loading") : placeholder}
className="min-w-24"
aria-label={placeholder || undefined}
/>
{canClear(selected) && <ComboboxClear className="ml-auto self-center" aria-label="Clear all" />}
{canClear(selected) && (
<ComboboxClear className="ml-auto self-center" aria-label={t("shared.multiSelect.clearAll")} />
)}
</>
)}
</ComboboxValue>

View file

@ -1,6 +1,7 @@
"use client";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cva.config";
@ -25,10 +26,11 @@ export function PageHeaderDescription({ className, ...props }: ComponentProps<"p
}
export function PageHeaderControls({ className, ...props }: ComponentProps<"div">) {
const { t } = useTranslation();
return (
<div
role="group"
aria-label="Page controls"
aria-label={t("shell.pageHeader.controls")}
className={cn("mt-5 flex min-h-9 items-center gap-2", className)}
{...props}
/>

View file

@ -3,6 +3,8 @@
import { Loader2 } from "lucide-react";
import { useMemo, useState } from "react";
import i18n from "@/i18n/i18n";
import {
Combobox,
ComboboxChip,
@ -50,10 +52,10 @@ export function PaginatedMultiSelect({
hasNextPage = false,
isLoading = false,
isFetchingNextPage = false,
placeholder = "Search…",
emptyText = "No results",
placeholder = i18n.t("shared.paginatedMultiSelect.placeholder"),
emptyText = i18n.t("shared.paginatedMultiSelect.empty"),
errorText,
loadingText = "Loading…",
loadingText = i18n.t("shared.paginatedMultiSelect.loading"),
clearAllLabel,
disabled = false,
className,

View file

@ -3,6 +3,8 @@
import { Loader2 } from "lucide-react";
import { useMemo, useRef, useState, type SyntheticEvent } from "react";
import i18n from "@/i18n/i18n";
import {
Combobox,
ComboboxContent,
@ -64,10 +66,10 @@ export function PaginatedSearchSelect({
hasNextPage = false,
isLoading = false,
isFetchingNextPage = false,
placeholder = "Search…",
emptyText = "No results",
placeholder = i18n.t("shared.paginatedSearchSelect.placeholder"),
emptyText = i18n.t("shared.paginatedSearchSelect.empty"),
errorText,
loadingText = "Loading…",
loadingText = i18n.t("shared.paginatedSearchSelect.loading"),
autoHighlight = false,
disabled = false,
className,

View file

@ -2,6 +2,7 @@
import { Eye, EyeOff } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
@ -11,6 +12,7 @@ export type PasswordInputProps = Omit<React.ComponentPropsWithoutRef<"input">, "
export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
({ className, groupClassName, disabled, ...props }, ref) => {
const { t } = useTranslation();
const [revealed, setRevealed] = React.useState(false);
return (
@ -26,7 +28,7 @@ export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputPro
<InputGroupButton
size="icon-xs"
disabled={disabled}
aria-label={revealed ? "Hide password" : "Show password"}
aria-label={revealed ? t("shared.passwordInput.hide") : t("shared.passwordInput.show")}
onClick={() => setRevealed((current) => !current)}
>
{revealed ? <EyeOff /> : <Eye />}

View file

@ -1,6 +1,7 @@
"use client";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import SummaryCard from "@/components/shared/SummaryCard";
import {
@ -32,34 +33,35 @@ const useSavingsTotals = (results: DailyData[]) =>
);
const SavingsTiles = ({ results, isLoading }: { results: DailyData[]; isLoading: boolean }) => {
const { t } = useTranslation();
const totals = useSavingsTotals(results);
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<SummaryCard
label="Total recorded savings"
label={t("shared.savingsTiles.total.label")}
value={usd(totals.total)}
hint={isLoading ? "Loading..." : "Compression + prompt caching + auto-router"}
info="The sum of recorded savings in the three tiles beside it. Auto-router requests without an estimate are excluded. Its caching term is the LiteLLM-injected share; caching supplied by clients or providers appears only in the caching tile's Total figure."
hint={isLoading ? t("shared.savingsTiles.total.loading") : t("shared.savingsTiles.total.hint")}
info={t("shared.savingsTiles.total.info")}
/>
<SummaryCard
label="Compression savings"
label={t("shared.savingsTiles.compression.label")}
value={usd(totals.compression)}
hint={`${formatNumberWithCommas(totals.savedTokens)} tokens compressed`}
info="Tokens Headroom removed before the call, priced at the model's input rate."
hint={t("shared.savingsTiles.compression.hint", { tokens: formatNumberWithCommas(totals.savedTokens) })}
info={t("shared.savingsTiles.compression.info")}
/>
<SummaryCard
label="Prompt caching savings"
label={t("shared.savingsTiles.caching.label")}
value={usd(totals.gatewayAttributedCaching)}
hint="LiteLLM injected"
secondary={{ label: "Total", value: usd(totals.caching) }}
info="What caching saved against paying the input rate for every token: the discount on tokens served from cache, less the premium providers charge to write a cache entry. The headline figure is the share LiteLLM earned by inserting the breakpoints itself, through configured injection points or auto prompt caching. The total beside it also counts requests that arrived with their own cache_control and providers that cache implicitly. Either can be negative on traffic that writes more cache than it reuses, which is why the headline is not always the smaller of the two."
hint={t("shared.savingsTiles.caching.hint")}
secondary={{ label: t("shared.savingsTiles.caching.total"), value: usd(totals.caching) }}
info={t("shared.savingsTiles.caching.info")}
/>
<SummaryCard
label="Auto-router savings"
label={t("shared.savingsTiles.autorouter.label")}
value={usd(totals.autorouter)}
hint="Recorded estimates subtotal"
info="Sum of available per-request savings estimates against each router's highest-tier baseline, net of classifier cost. Requests without an estimate contribute nothing to this subtotal; this does not mean they saved zero. Historical records retain the estimator used when they were written. The Auto-router usage tab shows coverage for current estimates."
hint={t("shared.savingsTiles.autorouter.hint")}
info={t("shared.savingsTiles.autorouter.info")}
/>
</div>
);

View file

@ -1,6 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AreaChart, BarChart, CustomLegend } from "@/components/shared/charts";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
@ -36,6 +37,7 @@ interface ScopedSavingsTabProps {
}
const ScopedSavingsTab = ({ accessToken, scope, activity, entityType, scopeNote }: ScopedSavingsTabProps) => {
const { t } = useTranslation();
const { dateValue, onDateChange, results, loading, failed } = useScopedDailyActivityRange(
accessToken,
scope,
@ -54,11 +56,13 @@ const ScopedSavingsTab = ({ accessToken, scope, activity, entityType, scopeNote
return withStartAnchor(toCumulative(perInterval), startLabel);
}, [accumulation, perInterval, startTime]);
const intervalLabel = "Per day";
const intervalLabel = t("shared.scopedSavingsTab.perDay");
const rangeLabel = formatRangeLabel(startTime, endTime);
const savingsSubtitle = [
accumulation === "cumulative" ? "Running total saved" : `Saved ${intervalLabel.toLowerCase()}`,
rangeLabel && `${rangeLabel} (UTC)`,
accumulation === "cumulative"
? t("shared.scopedSavingsTab.runningTotal")
: t("shared.scopedSavingsTab.savedInterval", { interval: intervalLabel.toLowerCase() }),
rangeLabel && t("shared.scopedSavingsTab.rangeUtc", { range: rangeLabel }),
]
.filter(Boolean)
.join(" · ");
@ -81,7 +85,7 @@ const ScopedSavingsTab = ({ accessToken, scope, activity, entityType, scopeNote
return (
<div className="w-full space-y-6">
<div className="flex flex-wrap items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">Spend is bucketed by UTC day</span>
<span className="text-sm text-muted-foreground">{t("shared.scopedSavingsTab.bucketedByUtc")}</span>
<AdvancedDatePicker value={dateValue} onValueChange={onDateChange} />
</div>
@ -93,20 +97,20 @@ const ScopedSavingsTab = ({ accessToken, scope, activity, entityType, scopeNote
{unavailable && (
<p role="alert" className="text-sm text-muted-foreground">
Savings are unavailable for this range. Try another date range or reopen this tab.
{t("shared.scopedSavingsTab.unavailable")}
</p>
)}
{showResults && <SavingsTiles results={results} isLoading={false} />}
<Card>
<CardHeader>
<CardTitle>Savings</CardTitle>
<CardTitle>{t("shared.scopedSavingsTab.title")}</CardTitle>
<CardDescription>{savingsSubtitle}</CardDescription>
<CardAction className="flex flex-wrap items-center justify-end gap-x-4 gap-y-2">
<CustomLegend categories={SAVINGS_SERIES} colors={SAVINGS_COLORS} />
<Tabs value={accumulation} onValueChange={(value) => setAccumulation(value as SavingsAccumulation)}>
<TabsList>
<TabsTrigger value="cumulative">Cumulative</TabsTrigger>
<TabsTrigger value="cumulative">{t("shared.scopedSavingsTab.cumulative")}</TabsTrigger>
<TabsTrigger value="per-interval">{intervalLabel}</TabsTrigger>
</TabsList>
</Tabs>
@ -115,7 +119,7 @@ const ScopedSavingsTab = ({ accessToken, scope, activity, entityType, scopeNote
<CardContent>
{showEmpty && (
<p className="py-12 text-center text-sm text-muted-foreground" data-testid={`${entityType}-savings-empty`}>
{isLoading ? "Loading savings..." : `No usage recorded for this ${entityType} in this range.`}
{isLoading ? t("shared.scopedSavingsTab.loading") : t("shared.scopedSavingsTab.noUsage", { entityType })}
</p>
)}
{showChart &&

View file

@ -9,6 +9,7 @@ import {
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import i18n from "@/i18n/i18n";
export interface SearchSelectOption {
label: string;
@ -50,8 +51,8 @@ export function SearchSelect({
options,
value,
onValueChange,
placeholder = "Select…",
emptyText = "No results",
placeholder = i18n.t("shared.searchSelect.placeholder"),
emptyText = i18n.t("shared.searchSelect.empty"),
disabled = false,
className,
inputId,

View file

@ -2,6 +2,7 @@
import React from "react";
import { Info } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -23,42 +24,45 @@ export interface SummaryCardProps {
*/
const slugOf = (label: string): string => label.toLowerCase().replace(/\s+/g, "-");
const SummaryCard = ({ label, value, hint, info, secondary }: SummaryCardProps) => (
<Card data-testid={`summary-card-${slugOf(label)}`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
{info && (
<Popover>
<PopoverTrigger
aria-label={`How ${label.toLowerCase()} is calculated`}
data-testid={`summary-card-info-${slugOf(label)}`}
className="cursor-pointer text-muted-foreground hover:text-foreground"
>
<Info className="size-3.5" />
</PopoverTrigger>
<PopoverContent align="end" className="w-64 text-sm text-muted-foreground">
{info}
</PopoverContent>
</Popover>
)}
</CardHeader>
<CardContent>
<div className="flex items-end gap-4">
<div>
<p className="text-2xl font-semibold text-foreground">{value}</p>
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
</div>
{secondary && (
<div className="self-stretch border-l pl-4">
<div className="flex h-full flex-col justify-end">
<p className="text-lg font-medium text-muted-foreground">{secondary.value}</p>
<p className="mt-1 text-xs text-muted-foreground">{secondary.label}</p>
</div>
</div>
const SummaryCard = ({ label, value, hint, info, secondary }: SummaryCardProps) => {
const { t } = useTranslation();
return (
<Card data-testid={`summary-card-${slugOf(label)}`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
{info && (
<Popover>
<PopoverTrigger
aria-label={t("shared.summaryCard.infoLabel", { label: label.toLowerCase() })}
data-testid={`summary-card-info-${slugOf(label)}`}
className="cursor-pointer text-muted-foreground hover:text-foreground"
>
<Info className="size-3.5" />
</PopoverTrigger>
<PopoverContent align="end" className="w-64 text-sm text-muted-foreground">
{info}
</PopoverContent>
</Popover>
)}
</div>
</CardContent>
</Card>
);
</CardHeader>
<CardContent>
<div className="flex items-end gap-4">
<div>
<p className="text-2xl font-semibold text-foreground">{value}</p>
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
</div>
{secondary && (
<div className="self-stretch border-l pl-4">
<div className="flex h-full flex-col justify-end">
<p className="text-lg font-medium text-muted-foreground">{secondary.value}</p>
<p className="mt-1 text-xs text-muted-foreground">{secondary.label}</p>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
};
export default SummaryCard;

View file

@ -4,6 +4,8 @@ import { cn } from "@/lib/cva.config";
import type { DateRangePickerValue } from "./date_picker_types";
import moment from "moment";
import React, { useCallback, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n/i18n";
import { relativeTimeOptions, type RelativeTimeOption } from "./date_range_presets";
export { relativeTimeOptions, type RelativeTimeOption } from "./date_range_presets";
@ -35,12 +37,13 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
value,
onValueChange,
presetShortLabel = null,
label = "Select Time Range",
label = i18n.t("shared.advancedDatePicker.selectTimeRange"),
className,
showTimeRange = true,
align = "right",
compact = false,
}) => {
const { t } = useTranslation();
const inputId = useId();
const [isOpen, setIsOpen] = useState(false);
const [tempValue, setTempValue] = useState<DateRangePickerValue>(value);
@ -74,15 +77,15 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
const end = moment(endDate, "YYYY-MM-DD");
if (!start.isValid() || !end.isValid()) {
return { isValid: false, error: "Invalid date format" };
return { isValid: false, error: t("shared.advancedDatePicker.invalidDateFormat") };
}
if (end.isBefore(start)) {
return { isValid: false, error: "End date cannot be before start date" };
return { isValid: false, error: t("shared.advancedDatePicker.endBeforeStart") };
}
return { isValid: true, error: "" };
}, [startDate, endDate]);
}, [startDate, endDate, t]);
const validation = validateDateRange();
@ -115,15 +118,18 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
};
}, [isOpen]);
const formatDisplayRange = useCallback((from: Date | undefined, to: Date | undefined) => {
if (!from || !to) return "Select date range";
const formatDisplayRange = useCallback(
(from: Date | undefined, to: Date | undefined) => {
if (!from || !to) return t("shared.advancedDatePicker.selectDateRange");
const formatDateTime = (date: Date) => {
return moment(date).format("D MMM, HH:mm");
};
const formatDateTime = (date: Date) => {
return moment(date).format("D MMM, HH:mm");
};
return `${formatDateTime(from)} - ${formatDateTime(to)}`;
}, []);
return `${formatDateTime(from)} - ${formatDateTime(to)}`;
},
[t],
);
// CRITICAL: Apply the same date adjustment logic as the original component
const adjustDateRange = useCallback((newValue: DateRangePickerValue): DateRangePickerValue => {
@ -285,7 +291,9 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
{/* Left side - Relative time options */}
<div className={cn("border-border", compact ? "w-full border-b" : "w-1/2 border-r")}>
<div className="p-3 border-b border-border">
<span className="text-sm font-semibold text-foreground">Relative time</span>
<span className="text-sm font-semibold text-foreground">
{t("shared.advancedDatePicker.relativeTime")}
</span>
</div>
<div className={cn("overflow-y-auto", compact ? "grid h-auto grid-cols-2" : "h-[350px]")}>
{relativeTimeOptions.map((option) => {
@ -324,7 +332,9 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
<div className="p-3.5 border-b border-border">
<div className="flex items-center gap-2">
<Calendar className="size-4 text-muted-foreground" />
<span className="text-sm font-semibold text-foreground">Start and end dates</span>
<span className="text-sm font-semibold text-foreground">
{t("shared.advancedDatePicker.startAndEndDates")}
</span>
</div>
</div>
@ -332,7 +342,7 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
{/* Start date */}
<div className={compact ? "min-w-0" : undefined}>
<label htmlFor={`${inputId}-start`} className="text-sm text-foreground mb-1 block">
Start date
{t("shared.advancedDatePicker.startDate")}
</label>
<input
id={`${inputId}-start`}
@ -355,7 +365,7 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
{/* End date */}
<div className={compact ? "min-w-0" : undefined}>
<label htmlFor={`${inputId}-end`} className="text-sm text-foreground mb-1 block">
End date
{t("shared.advancedDatePicker.endDate")}
</label>
<input
id={`${inputId}-end`}
@ -401,11 +411,11 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
{tempValue.from && tempValue.to && validation.isValid && (
<div className={cn("bg-info/10 p-3 rounded-md space-y-1", compact && "col-span-2")}>
<div className="text-xs text-info">
<span className="font-medium">From:</span>{" "}
<span className="font-medium">{t("shared.advancedDatePicker.from")}</span>{" "}
{moment(tempValue.from).format("MMM D, YYYY [at] HH:mm:ss")}
</div>
<div className="text-xs text-info">
<span className="font-medium">To:</span>{" "}
<span className="font-medium">{t("shared.advancedDatePicker.to")}</span>{" "}
{moment(tempValue.to).format("MMM D, YYYY [at] HH:mm:ss")}
</div>
</div>
@ -415,10 +425,10 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
<div className={compact ? "flex justify-end px-3 pb-3" : "absolute bottom-4 right-4"}>
<div className="flex gap-2">
<Button variant="secondary" onClick={handleCancel}>
Cancel
{t("shared.advancedDatePicker.cancel")}
</Button>
<Button onClick={handleApply} disabled={!tempValue.from || !tempValue.to || !validation.isValid}>
Apply
{t("shared.advancedDatePicker.apply")}
</Button>
</div>
</div>

View file

@ -3,6 +3,7 @@
import * as React from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import i18n from "@/i18n/i18n";
import { cn } from "@/lib/cva.config";
/** The Model Leaderboard palette; shared so the usage page stacks in the same colors. */
@ -77,7 +78,7 @@ export function StackedUsageChart({
xLabel = String,
format,
totalFor,
totalLabel = "Total",
totalLabel = i18n.t("shared.charts.stackedUsageChart.total"),
scale = "linear",
className,
}: StackedUsageChartProps) {

Some files were not shown because too many files have changed in this diff Show more