mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
Merge 7a856f9be8 into 0aa7079f04
This commit is contained in:
commit
a3ff144709
31 changed files with 1094 additions and 170 deletions
|
|
@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LoginPage from "./LoginPage";
|
||||
import LanguageProvider from "@/i18n/LanguageProvider";
|
||||
|
||||
const mockPush = vi.fn();
|
||||
const mockReplace = vi.fn();
|
||||
|
|
@ -60,7 +61,9 @@ const renderLoginPage = () => {
|
|||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LoginPage />
|
||||
<LanguageProvider>
|
||||
<LoginPage />
|
||||
</LanguageProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
|
@ -77,6 +80,7 @@ const originalLocation = window.location;
|
|||
describe("LoginPage submit payload", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.removeItem("litellm_ui_language");
|
||||
WORKERS = [];
|
||||
setSearch("");
|
||||
(useUIConfig as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
|
|
@ -91,6 +95,7 @@ describe("LoginPage submit payload", () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.removeItem("litellm_ui_language");
|
||||
Object.defineProperty(window, "location", { value: originalLocation, writable: true });
|
||||
});
|
||||
|
||||
|
|
@ -190,3 +195,36 @@ describe("LoginPage submit payload", () => {
|
|||
expect(switchToWorkerUrl).toHaveBeenCalledWith("http://worker-a:4000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("localized login", () => {
|
||||
afterEach(() => {
|
||||
window.localStorage.removeItem("litellm_ui_language");
|
||||
});
|
||||
|
||||
it("updates existing validation errors when switching language and keeps the submitted payload unchanged", async () => {
|
||||
vi.clearAllMocks();
|
||||
WORKERS = [];
|
||||
setSearch("");
|
||||
window.localStorage.removeItem("litellm_ui_language");
|
||||
(useUIConfig as ReturnType<typeof vi.fn>).mockReturnValue({ data: { sso_configured: false }, isLoading: false });
|
||||
const user = userEvent.setup();
|
||||
renderLoginPage();
|
||||
await user.click(await screen.findByRole("button", { name: "Login" }));
|
||||
expect(await screen.findByText("Please enter your username")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Language" }));
|
||||
await user.click(await screen.findByRole("menuitemradio", { name: "简体中文" }));
|
||||
expect(await screen.findByText("请输入用户名")).toBeInTheDocument();
|
||||
expect(screen.getByText("请输入密码")).toBeInTheDocument();
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
fireEvent.change(screen.getByLabelText("用户名"), { target: { value: "admin" } });
|
||||
fireEvent.change(screen.getByLabelText("密码"), { target: { value: "opaque-password" } });
|
||||
await user.click(screen.getByRole("button", { name: "登录" }));
|
||||
await waitFor(() => expect(mockMutate).toHaveBeenCalledTimes(1));
|
||||
expect(mockMutate.mock.calls[0][0]).toStrictEqual({ username: "admin", password: "opaque-password", useV3: false });
|
||||
expect(screen.getByText("MASTER_KEY")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "查看文档" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.litellm.ai/docs/proxy/ui",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import { Trans } from "react-i18next";
|
||||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import { useLogin } from "@/app/(dashboard)/hooks/login/useLogin";
|
||||
import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig";
|
||||
import LoadingScreen from "@/components/common_components/LoadingScreen";
|
||||
|
|
@ -30,14 +33,10 @@ import { useEffect, useId, useState } from "react";
|
|||
import { z } from "zod";
|
||||
import { useWorker } from "@/hooks/useWorker";
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, "Please enter your username"),
|
||||
password: z.string().min(1, "Please enter your password"),
|
||||
});
|
||||
|
||||
type LoginFormValues = z.infer<typeof loginSchema>;
|
||||
type LoginFormValues = { username: string; password: string };
|
||||
|
||||
function SsoEnabledNotice() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (dismissed) {
|
||||
|
|
@ -48,13 +47,15 @@ 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
|
||||
i18n={i18n}
|
||||
t={t}
|
||||
i18nKey="login.ssoNotice"
|
||||
components={{ code: <code className="bg-muted px-1 py-0.5 rounded-sm text-xs" /> }}
|
||||
/>
|
||||
</AlertTitle>
|
||||
<AlertAction>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Close" onClick={() => setDismissed(true)}>
|
||||
<Button variant="ghost" size="icon-sm" aria-label={t("Close")} onClick={() => setDismissed(true)}>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</AlertAction>
|
||||
|
|
@ -63,6 +64,11 @@ function SsoEnabledNotice() {
|
|||
}
|
||||
|
||||
function LoginPageContent() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, t("Please enter your username")),
|
||||
password: z.string().min(1, t("Please enter your password")),
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { data: uiConfig, isLoading: isConfigLoading } = useUIConfig();
|
||||
const loginMutation = useLogin();
|
||||
|
|
@ -72,6 +78,12 @@ function LoginPageContent() {
|
|||
const workerFieldId = useId();
|
||||
const form = useZodForm(loginSchema, { defaultValues: { username: "", password: "" } });
|
||||
|
||||
useEffect(() => {
|
||||
if (form.getFieldState("username").error || form.getFieldState("password").error) {
|
||||
void form.trigger();
|
||||
}
|
||||
}, [form, t]);
|
||||
|
||||
// Pre-select worker from URL param (e.g. /ui/login?worker=team-b)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
|
@ -196,6 +208,9 @@ function LoginPageContent() {
|
|||
<div className="min-h-screen flex items-center justify-center bg-muted">
|
||||
<Card className="w-full max-w-lg shadow-md">
|
||||
<CardContent>
|
||||
<div className="flex justify-end">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-semibold text-foreground">🚅 LiteLLM</h2>
|
||||
|
|
@ -203,11 +218,12 @@ function LoginPageContent() {
|
|||
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>Admin UI Disabled</AlertTitle>
|
||||
<AlertTitle>{t("Admin UI Disabled")}</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:
|
||||
{t(
|
||||
"The Admin UI has been disabled by the administrator. To re-enable it, please update the following environment variable:",
|
||||
)}{" "}
|
||||
</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>
|
||||
|
|
@ -225,6 +241,9 @@ function LoginPageContent() {
|
|||
<div className="min-h-screen flex items-center justify-center bg-muted">
|
||||
<Card className="w-full max-w-lg shadow-md">
|
||||
<CardContent>
|
||||
<div className="flex justify-end">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="text-center">
|
||||
|
|
@ -232,26 +251,34 @@ 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")}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("Access your LiteLLM Admin UI.")}</p>
|
||||
</div>
|
||||
|
||||
{!uiConfig?.hide_default_credentials_hint && (
|
||||
<Alert variant="info">
|
||||
<Info />
|
||||
<AlertTitle>Default Credentials</AlertTitle>
|
||||
<AlertTitle>{t("Default Credentials")}</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
|
||||
i18n={i18n}
|
||||
t={t}
|
||||
i18nKey="login.defaultCredentials"
|
||||
components={{ code: <code className="bg-muted px-1 py-0.5 rounded-sm text-xs" /> }}
|
||||
/>
|
||||
</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
|
||||
i18n={i18n}
|
||||
t={t}
|
||||
i18nKey="login.credentialsHelp"
|
||||
components={{
|
||||
docs: (
|
||||
<a href="https://docs.litellm.ai/docs/proxy/ui" target="_blank" rel="noopener noreferrer" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
@ -268,14 +295,14 @@ function LoginPageContent() {
|
|||
<FieldGroup>
|
||||
{uiConfig?.is_control_plane && workers.length > 0 && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={workerFieldId}>Worker</FieldLabel>
|
||||
<FieldLabel htmlFor={workerFieldId}>{t("Worker")}</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("Choose a worker to connect to")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{workers.map((worker) => (
|
||||
|
|
@ -288,12 +315,12 @@ function LoginPageContent() {
|
|||
</Field>
|
||||
)}
|
||||
|
||||
<FormField control={form.control} name="username" label="Username">
|
||||
<FormField control={form.control} name="username" label={t("Username")}>
|
||||
{({ ref, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder="Enter your username"
|
||||
placeholder={t("Enter your username")}
|
||||
autoComplete="username"
|
||||
disabled={isLoginLoading}
|
||||
className="h-10 rounded-md"
|
||||
|
|
@ -301,12 +328,12 @@ function LoginPageContent() {
|
|||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="password" label="Password">
|
||||
<FormField control={form.control} name="password" label={t("Password")}>
|
||||
{({ ref, ...field }) => (
|
||||
<PasswordInput
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder="Enter your password"
|
||||
placeholder={t("Enter your password")}
|
||||
autoComplete="current-password"
|
||||
disabled={isLoginLoading}
|
||||
groupClassName="h-10"
|
||||
|
|
@ -315,18 +342,18 @@ 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("loading")} />}
|
||||
{isLoginLoading ? t("Logging in...") : t("Login")}
|
||||
</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 with SSO")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Please configure SSO to log in with SSO.</TooltipContent>
|
||||
<TooltipContent>{t("Please configure SSO to log in with SSO.")}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -349,7 +376,7 @@ function LoginPageContent() {
|
|||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Login with SSO
|
||||
{t("Login with SSO")}
|
||||
</Button>
|
||||
)}
|
||||
</FieldGroup>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PluginModeProvider } from "@/contexts/PluginModeContext";
|
||||
import { CommandPaletteProvider } from "@/components/CommandPalette/CommandPaletteProvider";
|
||||
import LanguageProvider from "@/i18n/LanguageProvider";
|
||||
import { LANGUAGE_STORAGE_KEY } from "@/i18n";
|
||||
import { DashboardHeader } from "./DashboardHeader";
|
||||
|
||||
vi.mock("next/navigation", () => ({ usePathname: () => "/ui/logs" }));
|
||||
|
|
@ -56,13 +58,15 @@ async function openTools() {
|
|||
const user = userEvent.setup();
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<PluginModeProvider accessToken="test-session">
|
||||
<CommandPaletteProvider>
|
||||
<DashboardHeader accessToken={null} />
|
||||
</CommandPaletteProvider>
|
||||
</PluginModeProvider>
|
||||
</QueryClientProvider>,
|
||||
<LanguageProvider>
|
||||
<QueryClientProvider client={client}>
|
||||
<PluginModeProvider accessToken="test-session">
|
||||
<CommandPaletteProvider>
|
||||
<DashboardHeader accessToken={null} />
|
||||
</CommandPaletteProvider>
|
||||
</PluginModeProvider>
|
||||
</QueryClientProvider>
|
||||
</LanguageProvider>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "More options" }));
|
||||
const tools = await screen.findByRole("dialog", { name: "Gateway tools" });
|
||||
|
|
@ -70,6 +74,25 @@ async function openTools() {
|
|||
}
|
||||
|
||||
describe("mobile header nested controls", () => {
|
||||
it("switches language inside mobile tools without losing the open menu or preference", async () => {
|
||||
const { user, tools } = await openTools();
|
||||
await user.click(within(tools).getByRole("button", { name: "Language", exact: true }));
|
||||
await user.click(await screen.findByRole("menuitemradio", { name: "简体中文" }));
|
||||
|
||||
const translatedTools = await screen.findByRole("dialog", { name: "网关工具" });
|
||||
expect(within(translatedTools).getByRole("button", { name: "语言", exact: true })).toBeVisible();
|
||||
expect(within(translatedTools).getByRole("button", { name: "切换为深色模式" })).toBeVisible();
|
||||
expect(screen.getByText("请求日志")).toBeVisible();
|
||||
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("zh-CN");
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
|
||||
expect(screen.getByRole("dialog", { name: "网关工具" })).toBeVisible();
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "网关工具" })).not.toBeInTheDocument());
|
||||
await user.click(screen.getByRole("button", { name: "更多选项" }));
|
||||
expect(await screen.findByRole("dialog", { name: "网关工具" })).toBeVisible();
|
||||
});
|
||||
|
||||
it.each(["mouse", "touch"] as const)(
|
||||
"selects a portalled gateway item with %s without dismissing More",
|
||||
async (pointer) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import { useTerminology } from "@/contexts/TerminologyContext";
|
||||
import {
|
||||
Breadcrumb,
|
||||
|
|
@ -41,6 +43,7 @@ export function DashboardHeader({
|
|||
navigationTrigger?: ReactNode;
|
||||
deploymentHealth?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const term = useTerminology();
|
||||
const { title } = getBreadcrumb(usePathname());
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)", { initializeWithValue: false });
|
||||
|
|
@ -68,7 +71,7 @@ export function DashboardHeader({
|
|||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="max-md:hidden" />
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">{term(title)}</BreadcrumbPage>
|
||||
<BreadcrumbPage className="truncate">{term(t(title, { defaultValue: title }))}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
|
@ -88,6 +91,7 @@ export function DashboardHeader({
|
|||
<BlogDropdown />
|
||||
{!hideCommunityLinks && <CommunityEngagementButtons />}
|
||||
<ToolbarSeparator />
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
{deploymentHealth}
|
||||
<NotificationsBell accessToken={accessToken} />
|
||||
|
|
@ -98,15 +102,16 @@ export function DashboardHeader({
|
|||
<NotificationsBell accessToken={accessToken} />
|
||||
<Popover open={!isDesktop && mobileToolsOpen} onOpenChange={setMobileToolsOpen}>
|
||||
<PopoverTrigger
|
||||
render={<Button variant="ghost" size="icon" className="size-11" aria-label="More options" />}
|
||||
render={<Button variant="ghost" size="icon" className="size-11" aria-label={t("More options")} />}
|
||||
>
|
||||
<Ellipsis />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="max-w-[calc(100vw-2rem)]">
|
||||
<PopoverTitle>Gateway tools</PopoverTitle>
|
||||
<PopoverTitle>{t("Gateway tools")}</PopoverTitle>
|
||||
<ViewSwitcher />
|
||||
{showWorkerSwitch && <WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
<DocsLink />
|
||||
<BlogDropdown />
|
||||
|
|
|
|||
45
ui/litellm-dashboard/src/components/LanguageSwitcher.tsx
Normal file
45
ui/litellm-dashboard/src/components/LanguageSwitcher.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { Languages } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { isLanguage, LANGUAGE_STORAGE_KEY } from "@/i18n";
|
||||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
|
||||
export default function LanguageSwitcher() {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const selectLanguage = (language: string) => {
|
||||
if (!isLanguage(language)) return;
|
||||
void i18n.changeLanguage(language);
|
||||
try {
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="ghost" size="icon-sm" aria-label={t("Language")} title={t("Language")} />}
|
||||
>
|
||||
<Languages />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup value={i18n.language} onValueChange={selectLanguage}>
|
||||
<DropdownMenuRadioItem value="en">
|
||||
<span lang="en">English</span>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="zh-CN">
|
||||
<span lang="zh-CN">简体中文</span>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDashboardI18n } from "@/i18n";
|
||||
import { renderWithProviders, screen } from "../../../../tests/test-utils";
|
||||
import { BlogDropdown } from "./BlogDropdown";
|
||||
|
||||
const mockRefetch = vi.fn();
|
||||
let mockIsError = false;
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useDisableBlogPosts", () => ({
|
||||
useDisableBlogPosts: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/blogPosts/useBlogPosts", () => ({
|
||||
useBlogPosts: () => ({
|
||||
data: {
|
||||
posts: [{ title: "Post One", date: "2026-02-01", description: "Description one", url: "https://example.com/1" }],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: mockIsError,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
}));
|
||||
|
||||
async function openDropdown() {
|
||||
await userEvent.setup().hover(screen.getByRole("button", { name: "Blog" }));
|
||||
}
|
||||
|
||||
describe("blog dropdown language changes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsError = false;
|
||||
});
|
||||
|
||||
it("should translate Retry after switching languages and still refetch posts", async () => {
|
||||
mockIsError = true;
|
||||
const user = userEvent.setup();
|
||||
const i18n = createDashboardI18n();
|
||||
renderWithProviders(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<BlogDropdown />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
await openDropdown();
|
||||
expect(await screen.findByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
});
|
||||
expect(screen.getByText("加载文章失败")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "重试" }));
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should update visible dates when switching between English and Chinese", async () => {
|
||||
const i18n = createDashboardI18n();
|
||||
renderWithProviders(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<BlogDropdown />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
await openDropdown();
|
||||
expect(await screen.findByText("Feb 1, 2026")).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
});
|
||||
expect(screen.getByText("2026年2月1日")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Feb 1, 2026")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Post One")).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
expect(screen.getByText("Feb 1, 2026")).toBeInTheDocument();
|
||||
expect(screen.queryByText("2026年2月1日")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
|
||||
import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts";
|
||||
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
|
||||
|
|
@ -12,9 +13,9 @@ import {
|
|||
import { ChevronDown, LoaderCircle } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
function formatDate(dateStr: string, language: string): string {
|
||||
const date = new Date(dateStr + "T00:00:00");
|
||||
return date.toLocaleDateString("en-US", {
|
||||
return date.toLocaleDateString(language === "zh-CN" ? "zh-CN" : "en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
|
|
@ -22,6 +23,7 @@ function formatDate(dateStr: string): string {
|
|||
}
|
||||
|
||||
export const BlogDropdown: React.FC = () => {
|
||||
const { t, i18n } = 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("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("Failed to load posts")}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
Retry
|
||||
{t("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("No posts available")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -63,7 +65,7 @@ export const BlogDropdown: React.FC = () => {
|
|||
{post.title}
|
||||
</h5>
|
||||
<span className="text-muted-foreground" style={{ fontSize: 11 }}>
|
||||
{formatDate(post.date)}
|
||||
{formatDate(post.date, i18n.language)}
|
||||
</span>
|
||||
<p className="line-clamp-2">{post.description}</p>
|
||||
</a>
|
||||
|
|
@ -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("View all posts")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
|
|
@ -87,8 +89,7 @@ export const BlogDropdown: React.FC = () => {
|
|||
closeDelay={100}
|
||||
render={<Button variant="ghost" className={`${NAV_PRODUCT_LINK_CLASS} border-0!`} />}
|
||||
>
|
||||
Blog
|
||||
<ChevronDown className="size-2.5 text-muted-foreground" aria-hidden />
|
||||
{t("Blog")} <ChevronDown className="size-2.5 text-muted-foreground" aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom" className="w-auto">
|
||||
{renderMenuContent()}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import React from "react";
|
||||
|
|
@ -8,11 +9,13 @@ 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("Docs")} <ChevronWidthSpacer />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocsLink;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { act, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { createDashboardI18n } from "@/i18n";
|
||||
import { AUTO_ROUTER_DOCS_URL, NotificationsBellView } from "./NotificationsBell";
|
||||
|
||||
describe("localized announcements", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it("translates releases and unread counts while preserving independent read state and links", async () => {
|
||||
const user = userEvent.setup();
|
||||
const instance = createDashboardI18n("zh-CN");
|
||||
const releaseUrl = "https://github.com/BerriAI/litellm/releases/tag/v1.103.0";
|
||||
render(
|
||||
<I18nextProvider i18n={instance}>
|
||||
<NotificationsBellView
|
||||
currentVersion="1.102.0"
|
||||
latestRelease={{
|
||||
version: "1.103.0",
|
||||
new_features: 1,
|
||||
bug_fixes: 2,
|
||||
other_updates: 3,
|
||||
release_url: releaseUrl,
|
||||
}}
|
||||
/>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "通知,2 条未读" }));
|
||||
const release = screen.getByRole("article", { name: "LiteLLM v1.103.0 已发布" });
|
||||
expect(release).toHaveTextContent("1 项新功能、2 项修复和3 项其他更新。当前版本为 v1.102.0");
|
||||
expect(within(release).getByRole("link", { name: "查看发行说明" })).toHaveAttribute("href", releaseUrl);
|
||||
expect(screen.getByRole("link", { name: "查看文档" })).toHaveAttribute("href", AUTO_ROUTER_DOCS_URL);
|
||||
await user.click(within(release).getByRole("button", { name: "标记为已读" }));
|
||||
expect(screen.getByRole("button", { name: "通知,1 条未读" })).toBeInTheDocument();
|
||||
expect(localStorage.getItem("litellm:upgradeBannerDismissed:1.103.0")).toBe("true");
|
||||
expect(localStorage.getItem("litellmHideAutoRouterAnnouncement")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await instance.changeLanguage("en");
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Notifications, 1 unread" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("article", { name: "LiteLLM v1.103.0 is available" })).toHaveTextContent(
|
||||
"1 new feature, 2 fixes, and 3 other updates. Your current version is v1.102.0",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Mark as read" }));
|
||||
expect(screen.getByRole("button", { name: "Notifications", exact: true })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
|
||||
import {
|
||||
type LatestReleaseInfo,
|
||||
|
|
@ -37,38 +39,39 @@ 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")}`,
|
||||
].join(", ");
|
||||
const describeRelease = ({ new_features, bug_fixes, other_updates }: LatestReleaseInfo, t: TFunction): string =>
|
||||
t("releaseSummary", {
|
||||
features: t("releaseFeatures", { count: new_features }),
|
||||
fixes: t("releaseFixes", { count: bug_fixes }),
|
||||
updates: t("releaseUpdates", { count: other_updates }),
|
||||
});
|
||||
|
||||
const upgradeAnnouncement = (
|
||||
currentVersion: string | null | undefined,
|
||||
latestRelease: LatestReleaseInfo | null | undefined,
|
||||
t: TFunction,
|
||||
): Announcement | null => {
|
||||
if (!currentVersion || !latestRelease || !isNewerVersion(currentVersion, latestRelease.version)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
readKey: `litellm:upgradeBannerDismissed:${latestRelease.version}`,
|
||||
title: `LiteLLM v${latestRelease.version} is available`,
|
||||
description: `${describeRelease(latestRelease)}. Your current version is v${currentVersion}`,
|
||||
title: t("releaseAvailable", { version: latestRelease.version }),
|
||||
description: t("releaseDescription", { summary: describeRelease(latestRelease, t), version: currentVersion }),
|
||||
linkLabel: "View release notes",
|
||||
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">
|
||||
<p className="text-sm font-medium">{announcement.title}</p>
|
||||
<p className="text-sm leading-snug text-muted-foreground">{announcement.description}</p>
|
||||
<article aria-label={t(announcement.title, { defaultValue: announcement.title })} className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">{t(announcement.title, { defaultValue: announcement.title })}</p>
|
||||
<p className="text-sm leading-snug text-muted-foreground">
|
||||
{t(announcement.description, { defaultValue: announcement.description })}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
className={cn(buttonVariants({ size: "sm" }))}
|
||||
|
|
@ -76,11 +79,11 @@ const AnnouncementItem: React.FC<{ announcement: Announcement }> = ({ announceme
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{announcement.linkLabel}
|
||||
{t(announcement.linkLabel, { defaultValue: announcement.linkLabel })}
|
||||
</a>
|
||||
{read ? null : (
|
||||
<Button variant="link" size="sm" className="px-1!" onClick={() => markAnnouncementRead(announcement.readKey)}>
|
||||
Mark as read
|
||||
{t("Mark as read")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -94,7 +97,8 @@ interface NotificationsBellViewProps {
|
|||
}
|
||||
|
||||
export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({ currentVersion, latestRelease }) => {
|
||||
const upgrade = upgradeAnnouncement(currentVersion, latestRelease);
|
||||
const { t } = useTranslation();
|
||||
const upgrade = upgradeAnnouncement(currentVersion, latestRelease, t);
|
||||
const announcements = upgrade ? [upgrade, AUTO_ROUTER_ANNOUNCEMENT] : [AUTO_ROUTER_ANNOUNCEMENT];
|
||||
const unreadCount = useUnreadAnnouncementCount(announcements.map((announcement) => announcement.readKey));
|
||||
|
||||
|
|
@ -102,14 +106,14 @@ export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({ cu
|
|||
<Popover>
|
||||
<PopoverTrigger
|
||||
className="flex! h-9! w-9! items-center justify-center rounded-md! text-muted-foreground transition-colors hover:bg-accent! hover:text-foreground!"
|
||||
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
|
||||
aria-label={unreadCount > 0 ? t("notificationsUnread", { count: unreadCount }) : t("Notifications")}
|
||||
>
|
||||
<span className="relative inline-flex">
|
||||
<Bell className="size-4" aria-hidden />
|
||||
{unreadCount > 0 ? <Badge className="absolute -top-0.5 -right-1 size-1.5 p-0" aria-hidden /> : null}
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" aria-label="Notifications">
|
||||
<PopoverContent align="end" aria-label={t("Notifications")}>
|
||||
{announcements.map((announcement, index) => (
|
||||
<Fragment key={announcement.readKey}>
|
||||
{index > 0 ? <Separator /> : null}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
|
||||
import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon";
|
||||
|
|
@ -79,6 +80,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
} = useAuthorized();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const disableShowPrompts = useDisableShowPrompts();
|
||||
const disableBlogPosts = useDisableBlogPosts();
|
||||
const disableBouncingIcon = useDisableBouncingIcon();
|
||||
|
|
@ -101,16 +103,16 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
{premiumUser ? (
|
||||
<Badge>
|
||||
<Crown className="size-3" />
|
||||
Premium
|
||||
{t("Premium")}
|
||||
</Badge>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Badge variant="outline" />}>
|
||||
<Crown className="size-3" />
|
||||
Standard
|
||||
{t("Standard")}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Upgrade to Premium for advanced features</TooltipContent>
|
||||
<TooltipContent side="left">{t("Upgrade to Premium for advanced features")}</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("User ID")}</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("Copy User ID")} />
|
||||
</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("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("Hide New Feature Indicators")}</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("Toggle hide new feature indicators")}
|
||||
/>
|
||||
</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("Hide All Prompts")}</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("Toggle hide all prompts")}
|
||||
/>
|
||||
</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("Hide Blog Posts")}</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("Toggle hide blog posts")}
|
||||
/>
|
||||
</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("Hide Bouncing Icon")}</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={disableBouncingIcon}
|
||||
|
|
@ -202,7 +204,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
emitLocalStorageChange("disableBouncingIcon");
|
||||
}
|
||||
}}
|
||||
aria-label="Toggle hide bouncing icon"
|
||||
aria-label={t("Toggle hide bouncing icon")}
|
||||
/>
|
||||
</div>
|
||||
{premiumUser === true && canUseLiteAdmin && (
|
||||
|
|
@ -222,7 +224,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
const seed = userEmail || userId || "user";
|
||||
const initials = initialsFromIdentity(userEmail, userId);
|
||||
const hue = hueFromString(seed);
|
||||
const displayName = navAccountDisplayName(userEmail, userId);
|
||||
const displayName = navAccountDisplayName(userEmail, userId, t("Account"));
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
|
|
@ -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("accountMenu", {
|
||||
role: userRole ?? t("Unknown role"),
|
||||
identity: userEmail || userId || t("unknown"),
|
||||
})}
|
||||
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("accountMenu", {
|
||||
role: userRole ?? t("Unknown role"),
|
||||
identity: userEmail || userId || t("unknown"),
|
||||
})}
|
||||
aria-haspopup="dialog"
|
||||
/>
|
||||
}
|
||||
|
|
@ -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("Logout")}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { createDashboardI18n } from "@/i18n";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { renderWithProviders, screen, testQueryClient, within } from "@/../tests/test-utils";
|
||||
|
|
@ -21,13 +23,15 @@ vi.mock("@/components/networking", () => ({
|
|||
getUiConfig: getUiConfigMock,
|
||||
}));
|
||||
|
||||
function renderSwitcher() {
|
||||
function renderSwitcher(language: "en" | "zh-CN" = "en") {
|
||||
return renderWithProviders(
|
||||
<TerminologyProvider>
|
||||
<PluginModeProvider accessToken="test-session">
|
||||
<ViewSwitcher />
|
||||
</PluginModeProvider>
|
||||
</TerminologyProvider>,
|
||||
<I18nextProvider i18n={createDashboardI18n(language)}>
|
||||
<TerminologyProvider>
|
||||
<PluginModeProvider accessToken="test-session">
|
||||
<ViewSwitcher />
|
||||
</PluginModeProvider>
|
||||
</TerminologyProvider>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +69,29 @@ describe("ViewSwitcher", () => {
|
|||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/ui/chat", "聊天"],
|
||||
["/ui/moyai", "Moyai"],
|
||||
["/ui/", "AI 网关"],
|
||||
])("localizes the active workspace at %s while preserving product names", (pathname, label) => {
|
||||
state.pathname = pathname;
|
||||
renderSwitcher("zh-CN");
|
||||
expect(screen.getByRole("button", { name: `切换工作区,当前:${label}` })).toHaveTextContent(label);
|
||||
});
|
||||
|
||||
it("localizes workspace descriptions and disabled Chat without changing navigation", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSwitcher("zh-CN");
|
||||
await user.click(screen.getByRole("button", { name: "切换工作区,当前:AI 网关" }));
|
||||
const chat = await screen.findByRole("menuitem", {
|
||||
name: "聊天 使用 LLM、MCP 和智能体的聊天界面 管理员可在设置中启用",
|
||||
});
|
||||
expect(chat).toHaveAttribute("aria-disabled", "true");
|
||||
expect(assignSpy).not.toHaveBeenCalled();
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Moyai 云端编程智能体" }));
|
||||
expect(assignSpy).toHaveBeenCalledWith("/ui/moyai");
|
||||
});
|
||||
|
||||
it("should render the workspace selector and keep disabled Chat unavailable", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSwitcher();
|
||||
|
|
@ -88,6 +115,16 @@ describe("ViewSwitcher", () => {
|
|||
expect(screen.queryByRole("menuitem", { name: "AI Gateway" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves SI terminology when the workspace selector is in Chinese", async () => {
|
||||
getUiConfigMock.mockResolvedValue({ use_si_terminology: true });
|
||||
const user = userEvent.setup();
|
||||
renderSwitcher("zh-CN");
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "切换工作区,当前:SI 网关" }));
|
||||
expect(await screen.findByRole("menuitem", { name: "SI 网关" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: "AI 网关" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the active plugin and switch plugin modes within the gateway", async () => {
|
||||
state.plugins = [
|
||||
{ name: "metrics", display_name: "Metrics", url: "/metrics" },
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import { useTerminology } from "@/contexts/TerminologyContext";
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
|
@ -31,11 +32,11 @@ function activeWorkspaceFor(
|
|||
isChatRoute: boolean,
|
||||
isMoyaiRoute: boolean,
|
||||
pluginLabel: string | undefined,
|
||||
gatewayLabel: string,
|
||||
labels: { gateway: string; chat: string },
|
||||
): { label: string; icon: WorkspaceIconName } {
|
||||
if (isChatRoute) return { label: "Chat", icon: CHAT };
|
||||
if (isChatRoute) return { label: labels.chat, icon: CHAT };
|
||||
if (isMoyaiRoute) return { label: "Moyai", icon: MOYAI };
|
||||
return { label: pluginLabel ?? gatewayLabel, icon: GATEWAY };
|
||||
return { label: pluginLabel ?? labels.gateway, icon: GATEWAY };
|
||||
}
|
||||
|
||||
function WorkspaceIcon({ workspace }: { workspace: WorkspaceIconName }) {
|
||||
|
|
@ -81,6 +82,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();
|
||||
|
|
@ -94,12 +96,12 @@ 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("AI Gateway"));
|
||||
const { label: activeLabel, icon: activeIcon } = activeWorkspaceFor(
|
||||
isChatRoute,
|
||||
isMoyaiRoute,
|
||||
plugins.find((p) => p.name === mode)?.display_name,
|
||||
gatewayLabel,
|
||||
{ gateway: gatewayLabel, chat: t("Chat") },
|
||||
);
|
||||
|
||||
const modeEntries = [
|
||||
|
|
@ -122,11 +124,13 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
|
|||
<span className="flex items-center gap-2">
|
||||
<WorkspaceIcon workspace={CHAT} />
|
||||
<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("Chat")}</span>{" "}
|
||||
<span className="text-xs leading-snug text-muted-foreground">
|
||||
{t("Chat UI for using LLMs, MCPs, Agents")}
|
||||
</span>{" "}
|
||||
{!chatEnabled && (
|
||||
<span className="whitespace-normal text-xs leading-snug text-muted-foreground">
|
||||
Admins can enable in Settings
|
||||
{t("Admins can enable in Settings")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -160,7 +164,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
|
|||
<WorkspaceIcon workspace={MOYAI} />
|
||||
<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("Cloud Coding Agent")}</span>
|
||||
</span>
|
||||
</span>
|
||||
{isMoyaiRoute && <Check className="size-4 text-info" />}
|
||||
|
|
@ -173,7 +177,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
|
|||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={`Switch workspace, current: ${activeLabel}`}
|
||||
aria-label={t("Switch workspace, current: {{workspace}}", { workspace: activeLabel })}
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -15,3 +15,9 @@ describe("navAccountDisplayName", () => {
|
|||
expect(navAccountDisplayName(null, "user-uuid-123")).toBe("user-uuid-123");
|
||||
});
|
||||
});
|
||||
|
||||
it("localizes only the placeholder account label, preserving real account identities", () => {
|
||||
expect(navAccountDisplayName(null, "default_user_id", "账户")).toBe("账户");
|
||||
expect(navAccountDisplayName(null, "Account", "账户")).toBe("Account");
|
||||
expect(navAccountDisplayName("admin@example.com", null, "账户")).toBe("admin@example.com");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
/** 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 {
|
||||
export function navAccountDisplayName(userEmail: string | null, userId: string | null, fallback = "Account"): string {
|
||||
const email = userEmail?.trim();
|
||||
if (email) {
|
||||
return email;
|
||||
}
|
||||
const id = userId?.trim();
|
||||
if (!id) {
|
||||
return "Account";
|
||||
return fallback;
|
||||
}
|
||||
if (/^default[_\s-]?user[_\s-]?id$/i.test(id)) {
|
||||
return "Account";
|
||||
return fallback;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
|
||||
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
|
||||
|
|
@ -98,6 +99,7 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
} = useAuthorized();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { data: healthData } = useHealthReadinessDetails(accessToken);
|
||||
const version = healthData?.litellm_version;
|
||||
const disableShowPrompts = useDisableShowPrompts();
|
||||
|
|
@ -119,29 +121,29 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
const toggles = [
|
||||
{
|
||||
key: "disableShowNewBadge",
|
||||
label: "Hide New Feature Indicators",
|
||||
ariaLabel: "Toggle hide new feature indicators",
|
||||
label: t("Hide New Feature Indicators"),
|
||||
ariaLabel: t("Toggle hide new feature indicators"),
|
||||
checked: disableShowNewBadge,
|
||||
onCheckedChange: (checked: boolean) => setFlag("disableShowNewBadge", checked),
|
||||
},
|
||||
{
|
||||
key: "disableShowPrompts",
|
||||
label: "Hide All Prompts",
|
||||
ariaLabel: "Toggle hide all prompts",
|
||||
label: t("Hide All Prompts"),
|
||||
ariaLabel: t("Toggle hide all prompts"),
|
||||
checked: disableShowPrompts,
|
||||
onCheckedChange: (checked: boolean) => setFlag("disableShowPrompts", checked),
|
||||
},
|
||||
{
|
||||
key: "disableBlogPosts",
|
||||
label: "Hide Blog Posts",
|
||||
ariaLabel: "Toggle hide blog posts",
|
||||
label: t("Hide Blog Posts"),
|
||||
ariaLabel: t("Toggle hide blog posts"),
|
||||
checked: disableBlogPosts,
|
||||
onCheckedChange: (checked: boolean) => setFlag("disableBlogPosts", checked),
|
||||
},
|
||||
{
|
||||
key: "disableBouncingIcon",
|
||||
label: "Hide Bouncing Icon",
|
||||
ariaLabel: "Toggle hide bouncing icon",
|
||||
label: t("Hide Bouncing Icon"),
|
||||
ariaLabel: t("Toggle hide bouncing icon"),
|
||||
checked: disableBouncingIcon,
|
||||
onCheckedChange: (checked: boolean) => setFlag("disableBouncingIcon", checked),
|
||||
},
|
||||
|
|
@ -150,8 +152,11 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
const seed = userEmail || userId || "user";
|
||||
const initials = initialsFromIdentity(userEmail, userId);
|
||||
const hue = hueFromString(seed);
|
||||
const displayName = navAccountDisplayName(userEmail, userId);
|
||||
const triggerLabel = `Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`;
|
||||
const displayName = navAccountDisplayName(userEmail, userId, t("Account"));
|
||||
const triggerLabel = t("accountMenu", {
|
||||
role: userRole ?? t("Unknown role"),
|
||||
identity: userEmail || userId || t("unknown"),
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
|
|
@ -192,7 +197,7 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
<span
|
||||
className="animate-bounce text-lg leading-none"
|
||||
style={{ animationDuration: "2s" }}
|
||||
title="Thanks for using LiteLLM!"
|
||||
title={t("Thanks for using LiteLLM!")}
|
||||
aria-hidden
|
||||
>
|
||||
🌴
|
||||
|
|
@ -211,27 +216,27 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
</div>
|
||||
|
||||
<div className="flex flex-col px-3 py-2">
|
||||
<InfoRow icon={<Crown className="size-[17px]" />} label="Tier">
|
||||
<InfoRow icon={<Crown className="size-[17px]" />} label={t("Tier")}>
|
||||
{premiumUser ? (
|
||||
<Badge variant="outline" className="gap-1 border-warning/30 bg-warning/10 text-warning">
|
||||
<Crown />
|
||||
Premium
|
||||
{t("Premium")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="gap-1" title="Upgrade to Premium for advanced features">
|
||||
<Badge variant="secondary" className="gap-1" title={t("Upgrade to Premium for advanced features")}>
|
||||
<Crown />
|
||||
Standard
|
||||
{t("Standard")}
|
||||
</Badge>
|
||||
)}
|
||||
</InfoRow>
|
||||
<InfoRow icon={<ShieldCheck className="size-[17px]" />} label="Role">
|
||||
<InfoRow icon={<ShieldCheck className="size-[17px]" />} label={t("Role")}>
|
||||
<Badge variant="secondary">{userRole}</Badge>
|
||||
</InfoRow>
|
||||
<InfoRow icon={<Mail className="size-[17px]" />} label="Email">
|
||||
<MonoValue value={userEmail} copyLabel="Copy email" />
|
||||
<InfoRow icon={<Mail className="size-[17px]" />} label={t("Email")}>
|
||||
<MonoValue value={userEmail} copyLabel={t("Copy email")} />
|
||||
</InfoRow>
|
||||
<InfoRow icon={<IdCard className="size-[17px]" />} label="User ID">
|
||||
<MonoValue value={userId} copyLabel="Copy user ID" />
|
||||
<InfoRow icon={<IdCard className="size-[17px]" />} label={t("User ID")}>
|
||||
<MonoValue value={userId} copyLabel={t("Copy user ID")} />
|
||||
</InfoRow>
|
||||
</div>
|
||||
|
||||
|
|
@ -284,7 +289,7 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
className="h-[42px] w-full justify-start gap-2.5 rounded-none px-3 text-sm font-medium text-foreground"
|
||||
>
|
||||
<LogOut className="size-[19px] text-muted-foreground" />
|
||||
Logout
|
||||
{t("Logout")}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import React from "react";
|
||||
|
|
@ -7,9 +8,10 @@ import React from "react";
|
|||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const ThemeToggle: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme === "dark";
|
||||
const label = isDark ? "Switch to light mode" : "Switch to dark mode";
|
||||
const label = isDark ? t("Switch to light mode") : t("Switch to dark mode");
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
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("Loading...")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { createDashboardI18n } from "@/i18n";
|
||||
import { renderWithProviders } from "../../tests/test-utils";
|
||||
import { TerminologyProvider } from "@/contexts/TerminologyContext";
|
||||
import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav";
|
||||
|
|
@ -168,6 +170,21 @@ describe("Sidebar (leftnav)", () => {
|
|||
expect(screen.queryByText("AI Hub")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves SI terminology in Chinese labels and collapsed navigation titles", () => {
|
||||
uiConfig.data = { admin_ui_disabled: false, use_si_terminology: true };
|
||||
renderWithProviders(
|
||||
<I18nextProvider i18n={createDashboardI18n("zh-CN")}>
|
||||
<TerminologyProvider>
|
||||
<Sidebar collapsed />
|
||||
</TerminologyProvider>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("SI 网关")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "SI 资源中心" })).toHaveAttribute("title", "SI 资源中心");
|
||||
expect(screen.getByRole("link", { name: "SI 资源中心" })).toHaveAttribute("href", "/ui/model-hub-table");
|
||||
});
|
||||
|
||||
it("should link the logo to the UI home route rather than the proxy origin", () => {
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
|
|
@ -753,3 +770,57 @@ describe("getBreadcrumb", () => {
|
|||
expect(getBreadcrumb("/ui/some-unknown-page")).toEqual({ section: null, title: "Some Unknown Page" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("localized navigation", () => {
|
||||
it("translates new navigation labels while preserving their routes and Beta badges", async () => {
|
||||
mockUseAuthorized.mockReset();
|
||||
const instance = createDashboardI18n("zh-CN");
|
||||
renderWithProviders(
|
||||
<I18nextProvider i18n={instance}>
|
||||
<Sidebar collapsed />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
const leaderboard = screen.getByRole("link", { name: /模型排行榜/ });
|
||||
expect(leaderboard).toHaveAttribute("href", "/ui/model-insights");
|
||||
expect(leaderboard).toHaveAttribute("title", "模型排行榜");
|
||||
expect(within(leaderboard).getByText("Beta")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "成本优化", exact: true })).not.toHaveTextContent("Beta");
|
||||
|
||||
await act(async () => {
|
||||
await instance.changeLanguage("en");
|
||||
});
|
||||
expect(screen.getByRole("link", { name: /Model Leaderboard/ })).toHaveAttribute("title", "Model Leaderboard");
|
||||
});
|
||||
|
||||
it("changes visible and collapsed labels while retaining routes and permission filtering", async () => {
|
||||
const internalUser = {
|
||||
userId: "test-user-id",
|
||||
accessToken: "test-access-token",
|
||||
userRole: "internal",
|
||||
isViewOnly: false,
|
||||
token: "test-token",
|
||||
userEmail: "test@example.com",
|
||||
premiumUser: false,
|
||||
disabledPersonalKeyCreation: false,
|
||||
showSSOBanner: false,
|
||||
};
|
||||
mockUseAuthorized.mockReturnValue(internalUser);
|
||||
const instance = createDashboardI18n("zh-CN");
|
||||
renderWithProviders(
|
||||
<I18nextProvider i18n={instance}>
|
||||
<Sidebar collapsed />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "虚拟密钥" })).toHaveAttribute("href", "/ui/api-keys");
|
||||
expect(screen.getByRole("link", { name: "虚拟密钥" })).toHaveAttribute("title", "虚拟密钥");
|
||||
expect(screen.getByRole("link", { name: "模型与端点" })).toHaveAttribute("href", "/ui/models-and-endpoints");
|
||||
expect(screen.queryByRole("link", { name: "内部用户" })).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await instance.changeLanguage("en");
|
||||
});
|
||||
expect(screen.getByRole("link", { name: "Virtual Keys" })).toHaveAttribute("href", "/ui/api-keys");
|
||||
expect(screen.getByRole("link", { name: "Virtual Keys" })).toHaveAttribute("title", "Virtual Keys");
|
||||
expect(screen.queryByRole("link", { name: "Internal Users" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import { canViewProjectsPage, projectReaderRoles } from "@/app/(dashboard)/hooks/projects/projectAccess";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
|
@ -114,6 +115,7 @@ export interface MenuItem {
|
|||
roles?: string[];
|
||||
children?: MenuItem[];
|
||||
icon?: React.ReactNode;
|
||||
badge?: React.ReactNode;
|
||||
external_url?: string;
|
||||
}
|
||||
|
||||
|
|
@ -302,11 +304,8 @@ const menuGroups: MenuGroup[] = [
|
|||
page: "model-insights",
|
||||
icon: <Trophy {...ICON} />,
|
||||
roles: all_admin_roles,
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Model Leaderboard <BetaBadge />
|
||||
</span>
|
||||
),
|
||||
label: "Model Leaderboard",
|
||||
badge: <BetaBadge />,
|
||||
},
|
||||
{
|
||||
key: "cost-optimization",
|
||||
|
|
@ -342,11 +341,8 @@ const menuGroups: MenuGroup[] = [
|
|||
{
|
||||
key: "projects",
|
||||
page: "projects",
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Projects <BetaBadge />
|
||||
</span>
|
||||
),
|
||||
label: "Projects",
|
||||
badge: <BetaBadge />,
|
||||
icon: <Folder {...ICON} />,
|
||||
roles: [...projectReaderRoles],
|
||||
},
|
||||
|
|
@ -539,9 +535,9 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
disableVectorStoresForInternalUsers,
|
||||
allowVectorStoresForTeamAdmins,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { userId, accessToken, userRole, isViewOnly } = useAuthorized();
|
||||
const term = useTerminology();
|
||||
const labelOf = (item: MenuItem) => (typeof item.label === "string" ? term(item.label) : item.label);
|
||||
const isOrgAdmin = useIsOrgAdmin();
|
||||
const { data: teams } = useTeams();
|
||||
const { logoUrl, logoUrlDark } = useTheme();
|
||||
|
|
@ -614,10 +610,24 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
});
|
||||
};
|
||||
|
||||
const translatedLabel = (item: MenuItem) => {
|
||||
const label = typeof item.label === "string" ? term(t(item.label, { defaultValue: item.label })) : item.label;
|
||||
return item.badge ? (
|
||||
<span className="flex items-center gap-2">
|
||||
{label}
|
||||
{item.badge}
|
||||
</span>
|
||||
) : (
|
||||
label
|
||||
);
|
||||
};
|
||||
|
||||
const renderLeaf = (item: MenuItem, isChild: boolean) => {
|
||||
const active = selectedKey === item.key;
|
||||
const size = isChild ? "sub" : "default";
|
||||
const label = <span className="flex-1 truncate group-data-[collapsed=true]/sidebar:hidden">{labelOf(item)}</span>;
|
||||
const label = (
|
||||
<span className="flex-1 truncate group-data-[collapsed=true]/sidebar:hidden">{translatedLabel(item)}</span>
|
||||
);
|
||||
|
||||
if (item.external_url) {
|
||||
return (
|
||||
|
|
@ -626,7 +636,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
href={item.external_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={collapsed ? term(labelText(item)) : undefined}
|
||||
title={collapsed ? term(t(labelText(item), { defaultValue: labelText(item) })) : undefined}
|
||||
data-active={active || undefined}
|
||||
className={cn(sidebarMenuButtonVariants({ isActive: active, size }))}
|
||||
>
|
||||
|
|
@ -641,7 +651,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
<Link
|
||||
key={item.key}
|
||||
href={uiHref(routeOf(item))}
|
||||
title={collapsed ? term(labelText(item)) : undefined}
|
||||
title={collapsed ? term(t(labelText(item), { defaultValue: labelText(item) })) : undefined}
|
||||
data-active={active || undefined}
|
||||
className={cn(sidebarMenuButtonVariants({ isActive: active, size }))}
|
||||
>
|
||||
|
|
@ -665,10 +675,10 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
isActive={active}
|
||||
aria-expanded={open}
|
||||
onClick={() => toggleGroup(item.key)}
|
||||
title={collapsed ? term(labelText(item)) : undefined}
|
||||
title={collapsed ? term(t(labelText(item), { defaultValue: labelText(item) })) : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="flex-1 truncate group-data-[collapsed=true]/sidebar:hidden">{labelOf(item)}</span>
|
||||
<span className="flex-1 truncate group-data-[collapsed=true]/sidebar:hidden">{translatedLabel(item)}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 shrink-0 transition-transform group-data-[collapsed=true]/sidebar:hidden",
|
||||
|
|
@ -696,7 +706,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
<SidebarHeader className="h-14 border-b border-border group-data-[collapsed=true]/sidebar:h-auto">
|
||||
<div className="flex items-center justify-between gap-2 group-data-[collapsed=true]/sidebar:flex-col">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Link href={uiHref("")} className="flex min-w-0 items-center" aria-label="LiteLLM home">
|
||||
<Link href={uiHref("")} className="flex min-w-0 items-center" aria-label={t("LiteLLM home")}>
|
||||
<img src={logoSrc} alt="LiteLLM" className={cn(LOGO_CLASS_NAME, "dark:hidden")} />
|
||||
<img
|
||||
src={darkLogoSrc}
|
||||
|
|
@ -721,7 +731,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
aria-label={collapsed ? t("Expand sidebar") : t("Collapse sidebar")}
|
||||
className="flex-none text-muted-foreground"
|
||||
>
|
||||
{collapsed ? <PanelLeftOpen /> : <PanelLeftClose />}
|
||||
|
|
@ -735,7 +745,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
{visibleGroups.map((group, gi) => (
|
||||
<SidebarGroup key={group.groupLabel}>
|
||||
{gi > 0 && <SidebarSeparator className="hidden group-data-[collapsed=true]/sidebar:block" />}
|
||||
<SidebarGroupLabel>{term(group.groupLabel)}</SidebarGroupLabel>
|
||||
<SidebarGroupLabel>{term(t(group.groupLabel, { defaultValue: group.groupLabel }))}</SidebarGroupLabel>
|
||||
<SidebarMenu>{group.items.map((item) => renderItem(item))}</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
|
||||
import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon";
|
||||
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
|
||||
|
|
@ -39,6 +41,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
deploymentHealth,
|
||||
}) => {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
const { t } = useTranslation();
|
||||
const { logoUrl, logoUrlDark } = useTheme();
|
||||
const { data: healthData } = useHealthReadinessDetails(accessToken);
|
||||
const version = healthData?.litellm_version;
|
||||
|
|
@ -67,7 +70,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("Expand sidebar") : t("Collapse sidebar")}
|
||||
>
|
||||
<span className="text-lg">
|
||||
{sidebarCollapsed ? (
|
||||
|
|
@ -99,7 +102,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("Thanks for using LiteLLM!")}
|
||||
>
|
||||
🌑
|
||||
</span>
|
||||
|
|
@ -133,7 +136,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
)}
|
||||
|
||||
<nav
|
||||
aria-label="Product documentation"
|
||||
aria-label={t("Product documentation")}
|
||||
className={`flex min-w-0 items-center gap-2 ${showWorkerSwitch ? "border-l border-border pl-4" : ""}`}
|
||||
>
|
||||
<DocsLink />
|
||||
|
|
@ -149,6 +152,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
{!isPublicPage && (
|
||||
<div className="flex shrink-0 items-center border-l border-border pl-4">
|
||||
<div className="flex items-center gap-0.5 rounded-lg bg-muted px-1 py-0 transition-colors hover:bg-accent">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
<span className="mx-0.5 h-6 w-px shrink-0 bg-border" aria-hidden />
|
||||
{deploymentHealth}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
|
|
@ -12,6 +13,7 @@ export type PasswordInputProps = Omit<React.ComponentPropsWithoutRef<"input">, "
|
|||
export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
|
||||
({ className, groupClassName, disabled, ...props }, ref) => {
|
||||
const [revealed, setRevealed] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<InputGroup className={groupClassName}>
|
||||
|
|
@ -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("Hide password") : t("Show password")}
|
||||
onClick={() => setRevealed((current) => !current)}
|
||||
>
|
||||
{revealed ? <EyeOff /> : <Eye />}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import i18n from "./i18n";
|
||||
import LanguageProvider from "./LanguageProvider";
|
||||
|
||||
export default function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
|
||||
return <LanguageProvider>{children}</LanguageProvider>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { useTranslation as useReactTranslation } from "react-i18next";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import LoadingScreen from "@/components/common_components/LoadingScreen";
|
||||
import { PasswordInput } from "@/components/shared/PasswordInput";
|
||||
import I18nProvider from "./I18nProvider";
|
||||
import { LANGUAGE_STORAGE_KEY } from ".";
|
||||
|
||||
function WorkflowMessages() {
|
||||
const { t } = useReactTranslation();
|
||||
return (
|
||||
<section>
|
||||
<h1>{t("workflows.header.title")}</h1>
|
||||
<p>{t("workflows.drawer.eventCount", { count: 2 })}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LanguageExample() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<LanguageSwitcher />
|
||||
<LoadingScreen />
|
||||
<PasswordInput aria-label="API key" />
|
||||
<WorkflowMessages />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function selectChinese() {
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Language" }));
|
||||
await user.click(await screen.findByRole("menuitemradio", { name: "简体中文" }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe("dashboard language preference", () => {
|
||||
it("switches all consumers, preserves password fields, and restores the explicit preference after remount", async () => {
|
||||
const user = userEvent.setup();
|
||||
const first = render(<LanguageExample />);
|
||||
await user.type(screen.getByLabelText("API key"), "opaque-value");
|
||||
await selectChinese();
|
||||
expect(screen.getByText("加载中…")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "显示密码" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Workflow Runs" })).toBeInTheDocument();
|
||||
expect(screen.getByText("2 events")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("API key")).toHaveValue("opaque-value");
|
||||
expect(document.documentElement).toHaveAttribute("lang", "zh-CN");
|
||||
expect(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("zh-CN");
|
||||
first.unmount();
|
||||
render(<LanguageExample />);
|
||||
expect(await screen.findByRole("button", { name: "语言" })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "语言" }));
|
||||
expect(await screen.findByRole("menuitemradio", { name: "简体中文" })).toBeChecked();
|
||||
await user.click(await screen.findByRole("menuitemradio", { name: "English" }));
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
expect(document.documentElement).toHaveAttribute("lang", "en");
|
||||
expect(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("en");
|
||||
});
|
||||
|
||||
it("renders English on the server even when the browser has a Chinese preference", () => {
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
||||
expect(renderToString(<LanguageExample />)).toContain("Loading...");
|
||||
});
|
||||
|
||||
it("ignores invalid stored values without changing storage or the default UI", () => {
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "fr");
|
||||
render(<LanguageExample />);
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
expect(document.documentElement).toHaveAttribute("lang", "en");
|
||||
expect(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("fr");
|
||||
});
|
||||
|
||||
it("remains usable when browser storage throws on both reads and writes", async () => {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new DOMException("Blocked", "SecurityError");
|
||||
});
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new DOMException("Blocked", "SecurityError");
|
||||
});
|
||||
render(<LanguageExample />);
|
||||
await selectChinese();
|
||||
expect(screen.getByText("加载中…")).toBeInTheDocument();
|
||||
expect(document.documentElement).toHaveAttribute("lang", "zh-CN");
|
||||
});
|
||||
});
|
||||
26
ui/litellm-dashboard/src/i18n/LanguageProvider.tsx
Normal file
26
ui/litellm-dashboard/src/i18n/LanguageProvider.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { createDashboardI18n, isLanguage, LANGUAGE_STORAGE_KEY } from ".";
|
||||
|
||||
export default function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [instance] = useState(createDashboardI18n);
|
||||
|
||||
useEffect(() => {
|
||||
const updateDocumentLanguage = (language: string) => {
|
||||
document.documentElement.lang = language;
|
||||
};
|
||||
instance.on("languageChanged", updateDocumentLanguage);
|
||||
try {
|
||||
const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY);
|
||||
if (isLanguage(stored)) void instance.changeLanguage(stored);
|
||||
} catch {}
|
||||
updateDocumentLanguage(instance.language);
|
||||
return () => {
|
||||
instance.off("languageChanged", updateDocumentLanguage);
|
||||
};
|
||||
}, [instance]);
|
||||
|
||||
return <I18nextProvider i18n={instance}>{children}</I18nextProvider>;
|
||||
}
|
||||
13
ui/litellm-dashboard/src/i18n/README.md
Normal file
13
ui/litellm-dashboard/src/i18n/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Dashboard translations
|
||||
|
||||
The dashboard defaults to English. Select 简体中文 from the language menu on the login page or dashboard header to switch to Simplified Chinese. The explicit choice is saved in `localStorage.litellm_ui_language`. Unsupported stored values fall back to English. When browser storage is unavailable, the selection works until the page reloads
|
||||
|
||||
This initial coverage includes login and validation, the sidebar and collapsed navigation labels, breadcrumbs, view switching, account controls, and header controls. Dashboard page bodies, server error messages, worker and plugin names, and role labels are outside this change
|
||||
|
||||
Translations are bundled with the static export using i18next and react-i18next. The root `I18nProvider` delegates to `LanguageProvider`. Each mounted provider owns its instance, starts in English for server rendering and hydration, then restores the stored preference after mounting. Changing language updates the HTML `lang` attribute without changing URLs or remounting the form. No translation service or language-specific backend is required
|
||||
|
||||
Use `useTranslation` from `@/i18n/useTranslation` for React components. It uses the root provider, with an isolated English fallback for standalone components. Add the same navigation or login key to `locales/en.json` and `locales/zh-CN.json`. Plain display text serves as its own key, and longer sentences with markup use descriptive keys and `Trans`. Nested keys remain supported for upstream dashboard messages. i18next also looks up flat keys literally, preserving punctuation in display text. Namespace separators are disabled. Untranslated dashboard messages remain in English through the fallback catalog when Chinese is selected
|
||||
|
||||
Translate labels at render time. Keep identifiers, URLs, form field names, permission checks, plugin names, and API payloads unchanged. Use interpolation for user values and `Trans` for inline links or code, rather than joining translated fragments. React escapes interpolated display values
|
||||
|
||||
Chinese terminology adapts the MIT-licensed locale contributed by [2099383411 in PR #30499](https://github.com/BerriAI/litellm/pull/30499) at `e787b318a932c9c6b897830f6d01045f05bbd0a8`, with additions for the current Base UI dashboard. That PR's Ant Design integration is not included here
|
||||
56
ui/litellm-dashboard/src/i18n/index.test.ts
Normal file
56
ui/litellm-dashboard/src/i18n/index.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createDashboardI18n, isLanguage } from ".";
|
||||
import en from "./locales/en.json";
|
||||
import zhCN from "./locales/zh-CN.json";
|
||||
|
||||
describe("dashboard translations", () => {
|
||||
it("keeps complete navigation catalogs with matching interpolation fields and markup", () => {
|
||||
const englishLabels = Object.fromEntries(Object.entries(en).filter(([, value]) => typeof value === "string"));
|
||||
expect(Object.keys(zhCN).sort()).toEqual(Object.keys(englishLabels).sort());
|
||||
for (const key of Object.keys(zhCN) as (keyof typeof zhCN)[]) {
|
||||
expect(zhCN[key].trim(), key).not.toBe("");
|
||||
expect(zhCN[key].match(/{{\w+}}|<\/?\w+>/g) ?? [], key).toEqual(en[key].match(/{{\w+}}|<\/?\w+>/g) ?? []);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves punctuation in natural-language keys and falls back to English", () => {
|
||||
const instance = createDashboardI18n("zh-CN");
|
||||
expect(instance.t("Access your LiteLLM Admin UI.")).toBe("登录 LiteLLM 管理界面。");
|
||||
instance.addResource("en", "translation", "English fallback.", "English fallback.");
|
||||
expect(instance.t("English fallback.", { defaultValue: "Missing fallback" })).toBe("English fallback.");
|
||||
expect(instance.t("An untranslated label", { defaultValue: "An untranslated label" })).toBe(
|
||||
"An untranslated label",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps nested workflow messages and pluralization available alongside literal display keys", () => {
|
||||
const instance = createDashboardI18n("zh-CN");
|
||||
expect(instance.t("workflows.header.title")).toBe(en.workflows.header.title);
|
||||
expect(instance.t("workflows.drawer.eventCount", { count: 1 })).toBe("1 event");
|
||||
expect(instance.t("workflows.drawer.eventCount", { count: 2 })).toBe("2 events");
|
||||
expect(instance.t("login.credentialsHelp")).toBe(zhCN["login.credentialsHelp"]);
|
||||
});
|
||||
|
||||
it.each(["en", "zh-CN"] as const)("retains untranslated dashboard messages when the language is %s", (language) => {
|
||||
const instance = createDashboardI18n(language);
|
||||
expect(instance.t("agents.killSwitchFields.errors.tokenRequired")).toBe(
|
||||
en.agents.killSwitchFields.errors.tokenRequired,
|
||||
);
|
||||
expect(instance.t("modelSelector.placeholder")).toBe(en.modelSelector.placeholder);
|
||||
expect(instance.t("mcpOAuthCallback.authorizationComplete")).toBe(en.mcpOAuthCallback.authorizationComplete);
|
||||
expect(instance.t("agents.killSwitchFields.removeItem", { item: "Authorization" })).toBe("Remove Authorization");
|
||||
});
|
||||
|
||||
it("keeps server-rendered and standalone instances independent of another user's selection", async () => {
|
||||
const first = createDashboardI18n();
|
||||
const second = createDashboardI18n();
|
||||
await first.changeLanguage("zh-CN");
|
||||
expect(first.t("Login")).toBe("登录");
|
||||
expect(second.t("Login")).toBe("Login");
|
||||
});
|
||||
|
||||
it("only accepts the supported explicit preferences", () => {
|
||||
expect(["en", "zh-CN"].every(isLanguage)).toBe(true);
|
||||
expect([null, undefined, "", "fr", "zh-TW", "__proto__"].some(isLanguage)).toBe(false);
|
||||
});
|
||||
});
|
||||
26
ui/litellm-dashboard/src/i18n/index.ts
Normal file
26
ui/litellm-dashboard/src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { createInstance, type InitOptions } from "i18next";
|
||||
import en from "./locales/en.json";
|
||||
import zhCN from "./locales/zh-CN.json";
|
||||
|
||||
export type Language = "en" | "zh-CN";
|
||||
export const LANGUAGE_STORAGE_KEY = "litellm_ui_language";
|
||||
|
||||
export function isLanguage(value: unknown): value is Language {
|
||||
return value === "en" || value === "zh-CN";
|
||||
}
|
||||
|
||||
export function createDashboardI18n(language: Language = "en") {
|
||||
const instance = createInstance();
|
||||
const options: InitOptions = {
|
||||
lng: language,
|
||||
fallbackLng: "en",
|
||||
supportedLngs: ["en", "zh-CN"],
|
||||
resources: { en: { translation: { ...en } }, "zh-CN": { translation: { ...zhCN } } },
|
||||
initAsync: false,
|
||||
nsSeparator: false,
|
||||
interpolation: { escapeValue: false },
|
||||
react: { useSuspense: false },
|
||||
};
|
||||
void instance.init(options);
|
||||
return instance;
|
||||
}
|
||||
|
|
@ -1,4 +1,138 @@
|
|||
{
|
||||
"ACCESS CONTROL": "ACCESS CONTROL",
|
||||
"AI GATEWAY": "AI GATEWAY",
|
||||
"AI Gateway": "AI Gateway",
|
||||
"AI Hub": "AI Hub",
|
||||
"API Playground": "API Playground",
|
||||
"API Reference": "API Reference",
|
||||
"Access Groups": "Access Groups",
|
||||
"Access your LiteLLM Admin UI.": "Access your LiteLLM Admin UI.",
|
||||
"Account": "Account",
|
||||
"Admin Settings": "Admin Settings",
|
||||
"Admin UI Disabled": "Admin UI Disabled",
|
||||
"Admins can enable in Settings": "Admins can enable in Settings",
|
||||
"Agentic": "Agentic",
|
||||
"Agents": "Agents",
|
||||
"Blog": "Blog",
|
||||
"Budgets": "Budgets",
|
||||
"Chat": "Chat",
|
||||
"Chat UI for using LLMs, MCPs, Agents": "Chat UI for using LLMs, MCPs, Agents",
|
||||
"Choose a worker to connect to": "Choose a worker to connect to",
|
||||
"Close": "Close",
|
||||
"Cloud Coding Agent": "Cloud Coding Agent",
|
||||
"Collapse sidebar": "Collapse sidebar",
|
||||
"Copy User ID": "Copy User ID",
|
||||
"Copy email": "Copy email",
|
||||
"Copy user ID": "Copy user ID",
|
||||
"Cost Optimization": "Cost Optimization",
|
||||
"Cost Tracking": "Cost Tracking",
|
||||
"DEVELOPER TOOLS": "DEVELOPER TOOLS",
|
||||
"Default Credentials": "Default Credentials",
|
||||
"Docs": "Docs",
|
||||
"Email": "Email",
|
||||
"Enter your password": "Enter your password",
|
||||
"Enter your username": "Enter your username",
|
||||
"Expand sidebar": "Expand sidebar",
|
||||
"Experimental": "Experimental",
|
||||
"Failed to load posts": "Failed to load posts",
|
||||
"Gateway tools": "Gateway tools",
|
||||
"Guardrails": "Guardrails",
|
||||
"Guardrails Monitor": "Guardrails Monitor",
|
||||
"Hide All Prompts": "Hide All Prompts",
|
||||
"Hide Blog Posts": "Hide Blog Posts",
|
||||
"Hide Bouncing Icon": "Hide Bouncing Icon",
|
||||
"Hide New Feature Indicators": "Hide New Feature Indicators",
|
||||
"Hide password": "Hide password",
|
||||
"Home": "Home",
|
||||
"Internal Users": "Internal Users",
|
||||
"Language": "Language",
|
||||
"Learning Resources": "Learning Resources",
|
||||
"LiteLLM Auto Router": "LiteLLM Auto Router",
|
||||
"LiteLLM home": "LiteLLM home",
|
||||
"Loading...": "Loading...",
|
||||
"Logging & Alerts": "Logging & Alerts",
|
||||
"Logging in...": "Logging in...",
|
||||
"Login": "Login",
|
||||
"Login with SSO": "Login with SSO",
|
||||
"Logout": "Logout",
|
||||
"Logs": "Logs",
|
||||
"MCP Servers": "MCP Servers",
|
||||
"Mark as read": "Mark as read",
|
||||
"Memory": "Memory",
|
||||
"Model Leaderboard": "Model Leaderboard",
|
||||
"Models + Endpoints": "Models + Endpoints",
|
||||
"More options": "More options",
|
||||
"No posts available": "No posts available",
|
||||
"Notifications": "Notifications",
|
||||
"OBSERVABILITY": "OBSERVABILITY",
|
||||
"Old Usage": "Old Usage",
|
||||
"Organizations": "Organizations",
|
||||
"Password": "Password",
|
||||
"Playground": "Playground",
|
||||
"Please configure SSO to log in with SSO.": "Please configure SSO to log in with SSO.",
|
||||
"Please enter your password": "Please enter your password",
|
||||
"Please enter your username": "Please enter your username",
|
||||
"Policies": "Policies",
|
||||
"Premium": "Premium",
|
||||
"Product documentation": "Product documentation",
|
||||
"Projects": "Projects",
|
||||
"Prompts": "Prompts",
|
||||
"ROI Calculator": "ROI Calculator",
|
||||
"Read the docs": "Read the docs",
|
||||
"Response Cache": "Response Cache",
|
||||
"Retry": "Retry",
|
||||
"Role": "Role",
|
||||
"Route every request to the cheapest model that can handle it, no prompt changes needed.": "Route every request to the cheapest model that can handle it, no prompt changes needed.",
|
||||
"Router Settings": "Router Settings",
|
||||
"SETTINGS": "SETTINGS",
|
||||
"Search Tools": "Search Tools",
|
||||
"Settings": "Settings",
|
||||
"Show password": "Show password",
|
||||
"Skills": "Skills",
|
||||
"Standard": "Standard",
|
||||
"Switch to dark mode": "Switch to dark mode",
|
||||
"Switch to light mode": "Switch to light mode",
|
||||
"Switch workspace, current: {{workspace}}": "Switch workspace, current: {{workspace}}",
|
||||
"Tag Management": "Tag Management",
|
||||
"Teams": "Teams",
|
||||
"Telemetry": "Telemetry",
|
||||
"Thanks for using LiteLLM!": "Thanks for using LiteLLM!",
|
||||
"The Admin UI has been disabled by the administrator. To re-enable it, please update the following environment variable:": "The Admin UI has been disabled by the administrator. To re-enable it, please update the following environment variable:",
|
||||
"Tier": "Tier",
|
||||
"Toggle hide all prompts": "Toggle hide all prompts",
|
||||
"Toggle hide blog posts": "Toggle hide blog posts",
|
||||
"Toggle hide bouncing icon": "Toggle hide bouncing icon",
|
||||
"Toggle hide new feature indicators": "Toggle hide new feature indicators",
|
||||
"Tool Policies": "Tool Policies",
|
||||
"Tools": "Tools",
|
||||
"UI Theme": "UI Theme",
|
||||
"Unknown role": "Unknown role",
|
||||
"Upgrade to Premium for advanced features": "Upgrade to Premium for advanced features",
|
||||
"Usage": "Usage",
|
||||
"User ID": "User ID",
|
||||
"Username": "Username",
|
||||
"Vector Stores": "Vector Stores",
|
||||
"View all posts": "View all posts",
|
||||
"View release notes": "View release notes",
|
||||
"Virtual Keys": "Virtual Keys",
|
||||
"Worker": "Worker",
|
||||
"Workflow Runs": "Workflow Runs",
|
||||
"accountMenu": "Account menu — {{role}} — signed in as {{identity}}",
|
||||
"loading": "loading",
|
||||
"login.credentialsHelp": "Need to set UI credentials or SSO? <docs>Check the documentation</docs>.",
|
||||
"login.defaultCredentials": "By default, Username is <code>admin</code> and Password is your set LiteLLM Proxy <code>MASTER_KEY</code>.",
|
||||
"login.ssoNotice": "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>AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</code> in your environment configuration.",
|
||||
"notificationsUnread": "Notifications, {{count}} unread",
|
||||
"releaseAvailable": "LiteLLM v{{version}} is available",
|
||||
"releaseDescription": "{{summary}}. Your current version is v{{version}}",
|
||||
"releaseFeatures_one": "{{count}} new feature",
|
||||
"releaseFeatures_other": "{{count}} new features",
|
||||
"releaseFixes_one": "{{count}} fix",
|
||||
"releaseFixes_other": "{{count}} fixes",
|
||||
"releaseSummary": "{{features}}, {{fixes}}, and {{updates}}",
|
||||
"releaseUpdates_one": "{{count}} other update",
|
||||
"releaseUpdates_other": "{{count}} other updates",
|
||||
"unknown": "unknown",
|
||||
"agents": {
|
||||
"killSwitchConfig": {
|
||||
"authTypes": {
|
||||
|
|
|
|||
136
ui/litellm-dashboard/src/i18n/locales/zh-CN.json
Normal file
136
ui/litellm-dashboard/src/i18n/locales/zh-CN.json
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
{
|
||||
"ACCESS CONTROL": "访问控制",
|
||||
"AI GATEWAY": "AI 网关",
|
||||
"AI Gateway": "AI 网关",
|
||||
"AI Hub": "AI 资源中心",
|
||||
"API Playground": "API 试用",
|
||||
"API Reference": "API 参考",
|
||||
"Access Groups": "访问组",
|
||||
"Access your LiteLLM Admin UI.": "登录 LiteLLM 管理界面。",
|
||||
"Account": "账户",
|
||||
"Admin Settings": "管理设置",
|
||||
"Admin UI Disabled": "管理界面已禁用",
|
||||
"Admins can enable in Settings": "管理员可在设置中启用",
|
||||
"Agentic": "智能体应用",
|
||||
"Agents": "智能体",
|
||||
"Blog": "博客",
|
||||
"Budgets": "预算",
|
||||
"Chat": "聊天",
|
||||
"Chat UI for using LLMs, MCPs, Agents": "使用 LLM、MCP 和智能体的聊天界面",
|
||||
"Choose a worker to connect to": "选择要连接的工作节点",
|
||||
"Close": "关闭",
|
||||
"Cloud Coding Agent": "云端编程智能体",
|
||||
"Collapse sidebar": "收起侧栏",
|
||||
"Copy User ID": "复制用户 ID",
|
||||
"Copy email": "复制邮箱",
|
||||
"Copy user ID": "复制用户 ID",
|
||||
"Cost Optimization": "成本优化",
|
||||
"Cost Tracking": "成本追踪",
|
||||
"DEVELOPER TOOLS": "开发工具",
|
||||
"Default Credentials": "默认登录信息",
|
||||
"Docs": "文档",
|
||||
"Email": "邮箱",
|
||||
"Enter your password": "请输入密码",
|
||||
"Enter your username": "请输入用户名",
|
||||
"Expand sidebar": "展开侧栏",
|
||||
"Experimental": "实验功能",
|
||||
"Failed to load posts": "加载文章失败",
|
||||
"Gateway tools": "网关工具",
|
||||
"Guardrails": "安全护栏",
|
||||
"Guardrails Monitor": "护栏监控",
|
||||
"Hide All Prompts": "隐藏所有提示",
|
||||
"Hide Blog Posts": "隐藏博客文章",
|
||||
"Hide Bouncing Icon": "隐藏跳动图标",
|
||||
"Hide New Feature Indicators": "隐藏新功能提示",
|
||||
"Hide password": "隐藏密码",
|
||||
"Home": "首页",
|
||||
"Internal Users": "内部用户",
|
||||
"Language": "语言",
|
||||
"Learning Resources": "学习资料",
|
||||
"LiteLLM Auto Router": "LiteLLM 自动路由",
|
||||
"LiteLLM home": "LiteLLM 首页",
|
||||
"Loading...": "加载中…",
|
||||
"Logging & Alerts": "日志与告警",
|
||||
"Logging in...": "正在登录…",
|
||||
"Login": "登录",
|
||||
"Login with SSO": "使用单点登录",
|
||||
"Logout": "退出登录",
|
||||
"Logs": "请求日志",
|
||||
"MCP Servers": "MCP 服务",
|
||||
"Mark as read": "标记为已读",
|
||||
"Memory": "记忆",
|
||||
"Model Leaderboard": "模型排行榜",
|
||||
"Models + Endpoints": "模型与端点",
|
||||
"More options": "更多选项",
|
||||
"No posts available": "暂无文章",
|
||||
"Notifications": "通知",
|
||||
"OBSERVABILITY": "运行监控",
|
||||
"Old Usage": "旧版用量",
|
||||
"Organizations": "组织",
|
||||
"Password": "密码",
|
||||
"Playground": "模型试用",
|
||||
"Please configure SSO to log in with SSO.": "请先配置单点登录。",
|
||||
"Please enter your password": "请输入密码",
|
||||
"Please enter your username": "请输入用户名",
|
||||
"Policies": "策略",
|
||||
"Premium": "高级版",
|
||||
"Product documentation": "产品文档",
|
||||
"Projects": "项目",
|
||||
"Prompts": "提示词",
|
||||
"ROI Calculator": "投资回报计算器",
|
||||
"Read the docs": "查看文档",
|
||||
"Response Cache": "响应缓存",
|
||||
"Retry": "重试",
|
||||
"Role": "角色",
|
||||
"Route every request to the cheapest model that can handle it, no prompt changes needed.": "自动将每个请求分配给能够处理它的最低成本模型,无需修改提示词。",
|
||||
"Router Settings": "路由设置",
|
||||
"SETTINGS": "设置",
|
||||
"Search Tools": "搜索工具",
|
||||
"Settings": "设置",
|
||||
"Show password": "显示密码",
|
||||
"Skills": "技能",
|
||||
"Standard": "标准版",
|
||||
"Switch to dark mode": "切换为深色模式",
|
||||
"Switch to light mode": "切换到浅色模式",
|
||||
"Switch workspace, current: {{workspace}}": "切换工作区,当前:{{workspace}}",
|
||||
"Tag Management": "标签管理",
|
||||
"Teams": "团队",
|
||||
"Telemetry": "遥测",
|
||||
"Thanks for using LiteLLM!": "感谢使用 LiteLLM!",
|
||||
"The Admin UI has been disabled by the administrator. To re-enable it, please update the following environment variable:": "管理员已禁用管理界面。如需重新启用,请修改以下环境变量:",
|
||||
"Tier": "套餐",
|
||||
"Toggle hide all prompts": "切换所有提示显示",
|
||||
"Toggle hide blog posts": "切换博客文章显示",
|
||||
"Toggle hide bouncing icon": "切换跳动图标显示",
|
||||
"Toggle hide new feature indicators": "切换新功能提示显示",
|
||||
"Tool Policies": "工具策略",
|
||||
"Tools": "工具",
|
||||
"UI Theme": "界面主题",
|
||||
"Unknown role": "未知角色",
|
||||
"Upgrade to Premium for advanced features": "升级到高级版以使用更多功能",
|
||||
"Usage": "用量统计",
|
||||
"User ID": "用户 ID",
|
||||
"Username": "用户名",
|
||||
"Vector Stores": "向量存储",
|
||||
"View all posts": "查看全部文章",
|
||||
"View release notes": "查看发行说明",
|
||||
"Virtual Keys": "虚拟密钥",
|
||||
"Worker": "工作节点",
|
||||
"Workflow Runs": "工作流运行",
|
||||
"accountMenu": "账户菜单:{{role}},当前登录为 {{identity}}",
|
||||
"loading": "加载中",
|
||||
"login.credentialsHelp": "需要设置管理界面登录凭据或单点登录?<docs>查看文档</docs>。",
|
||||
"login.defaultCredentials": "默认用户名为 <code>admin</code>,密码为你设置的 LiteLLM 网关 <code>MASTER_KEY</code>。",
|
||||
"login.ssoNotice": "已启用单点登录(SSO)。打开此页面时,LiteLLM 不再自动跳转到单点登录流程。若要恢复自动跳转,请在环境配置中设置 <code>AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</code>。",
|
||||
"notificationsUnread": "通知,{{count}} 条未读",
|
||||
"releaseAvailable": "LiteLLM v{{version}} 已发布",
|
||||
"releaseDescription": "{{summary}}。当前版本为 v{{version}}",
|
||||
"releaseFeatures_one": "{{count}} 项新功能",
|
||||
"releaseFeatures_other": "{{count}} 项新功能",
|
||||
"releaseFixes_one": "{{count}} 项修复",
|
||||
"releaseFixes_other": "{{count}} 项修复",
|
||||
"releaseSummary": "{{features}}、{{fixes}}和{{updates}}",
|
||||
"releaseUpdates_one": "{{count}} 项其他更新",
|
||||
"releaseUpdates_other": "{{count}} 项其他更新",
|
||||
"unknown": "未知"
|
||||
}
|
||||
12
ui/litellm-dashboard/src/i18n/useTranslation.ts
Normal file
12
ui/litellm-dashboard/src/i18n/useTranslation.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
import { I18nContext, useTranslation as useReactTranslation } from "react-i18next";
|
||||
import { createDashboardI18n } from ".";
|
||||
|
||||
const englishFallback = createDashboardI18n();
|
||||
|
||||
export function useTranslation() {
|
||||
const context = useContext(I18nContext);
|
||||
return useReactTranslation(undefined, { i18n: context?.i18n ?? englishFallback });
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue