mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
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:
parent
114ba4021a
commit
a73e12de56
121 changed files with 3756 additions and 1332 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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'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
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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'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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 'Useful Links' 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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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'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'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}>“{name}” isn'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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" && (
|
||||
|
|
|
|||
|
|
@ -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} />,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
'grok', 'claude', 'gpt-4', or 'sonnet'
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue