From e2c60c6c85af90b4a94ce0292ab38dbfe6a06c4b Mon Sep 17 00:00:00 2001 From: 2099383411 <82358696+2099383411@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:49:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20add=20Chinese=20(=E4=B8=AD?= =?UTF-8?q?=E6=96=87)=20language=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add lightweight i18n system using React Context + localStorage - Zero external dependencies, integrates with existing Ant Design locale - Create locales/en.json and locales/zh.json with full translations - LanguageProvider wraps ConfigProvider for seamless antd locale switching - Add LanguageSwitcher component to navbar - Covers: nav, models, API keys, users, logs, settings, common actions Closes #28526 --- src/app/layout.tsx | 36 ++++++ src/components/LanguageSwitcher.tsx | 37 ++++++ src/components/navbar.tsx | 167 ++++++++++++++++++++++++++++ src/contexts/LanguageContext.tsx | 61 ++++++++++ src/i18n/index.ts | 54 +++++++++ src/i18n/locales/en.json | 130 ++++++++++++++++++++++ src/i18n/locales/zh.json | 130 ++++++++++++++++++++++ 7 files changed, 615 insertions(+) create mode 100644 src/app/layout.tsx create mode 100644 src/components/LanguageSwitcher.tsx create mode 100644 src/components/navbar.tsx create mode 100644 src/contexts/LanguageContext.tsx create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/locales/en.json create mode 100644 src/i18n/locales/zh.json diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 00000000000..1680678e9ff --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; + +import AntdGlobalProvider from "@/contexts/AntdGlobalProvider"; +import { AuthProvider } from "@/contexts/AuthContext"; +import { LanguageProvider } from "@/contexts/LanguageContext"; +import ReactQueryProvider from "@/contexts/ReactQueryProvider"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "LiteLLM Dashboard", + description: "LiteLLM Proxy Admin UI", + icons: { icon: "/get_favicon" }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + + {children} + + + + + + ); +} \ No newline at end of file diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx new file mode 100644 index 00000000000..893008e7423 --- /dev/null +++ b/src/components/LanguageSwitcher.tsx @@ -0,0 +1,37 @@ +"use client"; + +import React from "react"; +import { Button } from "antd"; +import { useLanguage } from "@/contexts/LanguageContext"; + +/** + * Inline language toggle used in navbar. + * Shows the opposite language (中文 ↔ English). + */ +export function LanguageSwitcher() { + const { locale, setLocale, t } = useLanguage(); + + const toggle = () => setLocale(locale === "en" ? "zh" : "en"); + + return ( + + ); +} + +/** + * Wraps children in LanguageProvider. + * This should be placed above ConfigProvider in the component tree. + */ +export function LanguageProviderWrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +export { LanguageProvider }; \ No newline at end of file diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx new file mode 100644 index 00000000000..d153616ae37 --- /dev/null +++ b/src/components/navbar.tsx @@ -0,0 +1,167 @@ +import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails"; +import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon"; +import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; +import { useWorker } from "@/hooks/useWorker"; +import { getProxyBaseUrl } from "@/components/networking"; +import { useTheme } from "@/contexts/ThemeContext"; +import { clearTokenCookies } from "@/utils/cookieUtils"; +import { clearStoredReturnUrl } from "@/utils/returnUrlUtils"; +import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings"; +import { DownOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons"; +import { Tag } from "antd"; +import Link from "next/link"; +import React from "react"; +import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown"; +import { CommunityEngagementButtons } from "./Navbar/CommunityEngagementButtons/CommunityEngagementButtons"; +import { NAV_PRODUCT_LINK_CLASS } from "./Navbar/navProductLinkClass"; +import { NotificationsBell } from "./Navbar/NotificationsBell/NotificationsBell"; +import UserDropdown from "./Navbar/UserDropdown/UserDropdown"; +import { LanguageSwitcher } from "./LanguageSwitcher"; +import WorkerDropdown from "./Navbar/WorkerDropdown/WorkerDropdown"; + +interface NavbarProps { + accessToken: string | null; + isPublicPage: boolean; + sidebarCollapsed?: boolean; + onToggleSidebar?: () => void; +} + +const Navbar: React.FC = ({ + accessToken, + isPublicPage = false, + sidebarCollapsed = false, + onToggleSidebar, +}) => { + const baseUrl = getProxyBaseUrl(); + const proxySettings = useProxySettings(accessToken); + const { logoUrl } = useTheme(); + const { data: healthData } = useHealthReadinessDetails(accessToken); + const version = healthData?.litellm_version; + const disableBouncingIcon = useDisableBouncingIcon(); + const hideCommunityLinks = useDisableShowPrompts(); + const { isControlPlane, selectedWorker } = useWorker(); + const showWorkerSwitch = isControlPlane && selectedWorker !== null; + + const imageUrl = logoUrl || `${baseUrl}/get_image`; + + const handleLogout = () => { + clearTokenCookies(); + localStorage.removeItem("litellm_selected_worker_id"); + localStorage.removeItem("litellm_worker_url"); + window.location.href = proxySettings.PROXY_LOGOUT_URL || ""; + }; + + const handleWorkerSwitch = (workerId: string) => { + clearTokenCookies(); + clearStoredReturnUrl(); + localStorage.removeItem("litellm_selected_worker_id"); + localStorage.removeItem("litellm_worker_url"); + window.location.href = `/ui/login?worker=${encodeURIComponent(workerId)}`; + }; + + return ( + + ); +}; + +export default Navbar; \ No newline at end of file diff --git a/src/contexts/LanguageContext.tsx b/src/contexts/LanguageContext.tsx new file mode 100644 index 00000000000..05e10ca8529 --- /dev/null +++ b/src/contexts/LanguageContext.tsx @@ -0,0 +1,61 @@ +"use client"; + +import React, { createContext, useCallback, useContext, useEffect, useState } from "react"; +import { ConfigProvider } from "antd"; +import enUS from "antd/locale/en_US"; +import zhCN from "antd/locale/zh_CN"; +import { Locale, DEFAULT_LOCALE, LOCALE_STORAGE_KEY, t as translate } from "@/i18n"; + +interface LanguageContextValue { + locale: Locale; + setLocale: (l: Locale) => void; + t: (key: string) => string; +} + +const LanguageContext = createContext({ + locale: DEFAULT_LOCALE, + setLocale: () => {}, + t: (key) => key, +}); + +export function useLanguage() { + return useContext(LanguageContext); +} + +const antdLocales: Record = { + en: enUS, + zh: zhCN, +}; + +export function LanguageProvider({ children }: { children: React.ReactNode }) { + const [locale, setLocaleState] = useState(DEFAULT_LOCALE); + + // Hydrate from localStorage on mount + useEffect(() => { + try { + const stored = localStorage.getItem(LOCALE_STORAGE_KEY); + if (stored === "zh" || stored === "en") { + setLocaleState(stored); + } + } catch { + // localStorage not available + } + }, []); + + const setLocale = useCallback((l: Locale) => { + setLocaleState(l); + try { + localStorage.setItem(LOCALE_STORAGE_KEY, l); + } catch { + // ignore + } + }, []); + + const t = useCallback((key: string) => translate(key, locale), [locale]); + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 00000000000..6560cd85d77 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,54 @@ +/** + * LiteLLM i18n - Lightweight translation system + * + * Supports English (default) and Chinese (中文). + * Uses React Context for state + localStorage for persistence. + * Zero external dependencies. + */ + +import en from "./locales/en.json"; +import zh from "./locales/zh.json"; + +export type Locale = "en" | "zh"; +export type TranslationMap = Record>; + +const translations: Record = { en, zh }; + +/** + * Resolve a dotted key like "nav.dashboard" against the current locale. + */ +export function t(key: string, locale: Locale = "en"): string { + const map = translations[locale] || translations.en; + const parts = key.split("."); + let current: any = map; + for (const part of parts) { + if (current == null) break; + current = current[part]; + } + if (typeof current === "string") return current; + + // Fallback to English + let fallback: any = translations.en; + for (const part of parts) { + if (fallback == null) break; + fallback = fallback[part]; + } + if (typeof fallback === "string") return fallback; + + // Ultimate fallback: return the key itself + return key; +} + +/** All supported locales (used by the switcher) */ +export const SUPPORTED_LOCALES: { value: Locale; label: string }[] = [ + { value: "en", label: "English" }, + { value: "zh", label: "中文" }, +]; + +/** LocalStorage key */ +export const LOCALE_STORAGE_KEY = "litellm_locale"; + +/** Default locale */ +export const DEFAULT_LOCALE: Locale = "en"; + +export { translations }; \ No newline at end of file diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json new file mode 100644 index 00000000000..aef5deb5719 --- /dev/null +++ b/src/i18n/locales/en.json @@ -0,0 +1,130 @@ +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "create": "Create", + "search": "Search", + "refresh": "Refresh", + "loading": "Loading...", + "no_data": "No data", + "confirm": "Confirm", + "back": "Back", + "next": "Next", + "submit": "Submit", + "reset": "Reset", + "copy": "Copy", + "copied": "Copied!", + "close": "Close", + "export": "Export", + "import": "Import", + "more": "More", + "actions": "Actions", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "active": "Active", + "inactive": "Inactive", + "yes": "Yes", + "no": "No", + "on": "On", + "off": "Off", + "success": "Success", + "error": "Error", + "warning": "Warning", + "info": "Info" + }, + "nav": { + "dashboard": "Dashboard", + "models": "Models & Endpoints", + "api_keys": "API Keys", + "users": "Users", + "teams": "Teams", + "logs": "Logs", + "usage": "Usage", + "spend": "Cost Tracking", + "settings": "Settings", + "admin": "Admin Panel", + "playground": "Playground", + "budgets": "Budgets", + "caching": "Caching", + "guardrails": "Guardrails", + "hooks": "Webhooks", + "model_hub": "Model Hub", + "router": "Router Settings", + "organizations": "Organizations", + "credentials": "Credentials", + "agents": "Agents", + "prompts": "Prompts", + "mcp": "MCP Servers", + "policies": "Policies", + "skills": "Skills", + "workflows": "Workflows", + "memory": "Memory", + "vector_stores": "Vector Stores", + "search_tools": "Search Tools", + "tag_management": "Tag Management", + "access_groups": "Access Groups", + "ui_theme": "UI Theme" + }, + "models": { + "title": "Models & Endpoints", + "add_model": "Add Model", + "model_name": "Model Name", + "provider": "Provider", + "api_base": "API Base URL", + "api_key": "API Key", + "rpm": "RPM", + "tpm": "TPM", + "max_retries": "Max Retries", + "timeout": "Timeout (s)", + "custom_llm_provider": "Provider", + "litellm_params": "LiteLLM Parameters", + "model_info": "Model Info", + "deployment_name": "Deployment Name", + "no_models": "No models configured", + "delete_confirm": "Are you sure you want to delete this model?" + }, + "keys": { + "title": "API Keys", + "create_key": "Create Key", + "key_name": "Key Name", + "key": "Key", + "models": "Models", + "max_budget": "Max Budget", + "budget_duration": "Budget Duration", + "expires_at": "Expires At", + "never": "Never", + "no_keys": "No API keys created", + "delete_confirm": "Are you sure you want to delete this key?" + }, + "users": { + "title": "Users", + "create_user": "Create User", + "user_email": "Email", + "user_role": "Role", + "user_id": "User ID", + "no_users": "No users found" + }, + "logs": { + "title": "Logs", + "request_id": "Request ID", + "model": "Model", + "status_code": "Status", + "tokens": "Tokens", + "cost": "Cost", + "duration": "Duration", + "timestamp": "Time" + }, + "settings": { + "title": "Settings", + "general": "General", + "proxy": "Proxy Settings", + "alerting": "Alerting" + }, + "language": { + "switch_to": "中文", + "label": "Language" + } +} \ No newline at end of file diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json new file mode 100644 index 00000000000..9026a8cd80b --- /dev/null +++ b/src/i18n/locales/zh.json @@ -0,0 +1,130 @@ +{ + "common": { + "save": "保存", + "cancel": "取消", + "delete": "删除", + "edit": "编辑", + "create": "创建", + "search": "搜索", + "refresh": "刷新", + "loading": "加载中...", + "no_data": "暂无数据", + "confirm": "确认", + "back": "返回", + "next": "下一步", + "submit": "提交", + "reset": "重置", + "copy": "复制", + "copied": "已复制!", + "close": "关闭", + "export": "导出", + "import": "导入", + "more": "更多", + "actions": "操作", + "status": "状态", + "enabled": "已启用", + "disabled": "已禁用", + "active": "活跃", + "inactive": "不活跃", + "yes": "是", + "no": "否", + "on": "开", + "off": "关", + "success": "成功", + "error": "错误", + "warning": "警告", + "info": "提示" + }, + "nav": { + "dashboard": "仪表盘", + "models": "模型与端点", + "api_keys": "API 密钥", + "users": "用户管理", + "teams": "团队管理", + "logs": "日志", + "usage": "用量统计", + "spend": "费用追踪", + "settings": "设置", + "admin": "管理面板", + "playground": "调试沙盒", + "budgets": "预算管理", + "caching": "缓存设置", + "guardrails": "安全护栏", + "hooks": "Webhooks", + "model_hub": "模型中心", + "router": "路由设置", + "organizations": "组织管理", + "credentials": "凭证管理", + "agents": "智能代理", + "prompts": "提示词", + "mcp": "MCP 服务", + "policies": "策略管理", + "skills": "技能管理", + "workflows": "工作流", + "memory": "记忆管理", + "vector_stores": "向量存储", + "search_tools": "搜索工具", + "tag_management": "标签管理", + "access_groups": "访问组", + "ui_theme": "界面主题" + }, + "models": { + "title": "模型与端点", + "add_model": "添加模型", + "model_name": "模型名称", + "provider": "提供商", + "api_base": "API 地址", + "api_key": "API 密钥", + "rpm": "每分钟请求数", + "tpm": "每分钟 Token 数", + "max_retries": "最大重试次数", + "timeout": "超时 (秒)", + "custom_llm_provider": "提供商", + "litellm_params": "LiteLLM 参数", + "model_info": "模型信息", + "deployment_name": "部署名称", + "no_models": "未配置模型", + "delete_confirm": "确定要删除此模型吗?" + }, + "keys": { + "title": "API 密钥", + "create_key": "创建密钥", + "key_name": "密钥名称", + "key": "密钥", + "models": "模型", + "max_budget": "最大预算", + "budget_duration": "预算周期", + "expires_at": "过期时间", + "never": "永不过期", + "no_keys": "暂无 API 密钥", + "delete_confirm": "确定要删除此密钥吗?" + }, + "users": { + "title": "用户管理", + "create_user": "创建用户", + "user_email": "邮箱", + "user_role": "角色", + "user_id": "用户 ID", + "no_users": "暂无用户" + }, + "logs": { + "title": "日志", + "request_id": "请求 ID", + "model": "模型", + "status_code": "状态码", + "tokens": "Token 数", + "cost": "费用", + "duration": "耗时", + "timestamp": "时间" + }, + "settings": { + "title": "设置", + "general": "常规设置", + "proxy": "代理设置", + "alerting": "告警设置" + }, + "language": { + "switch_to": "English", + "label": "语言" + } +} \ No newline at end of file