From 4891118b5e31194e79c361de5b6b49cefe9b4112 Mon Sep 17 00:00:00 2001 From: wuwuwu <1498968550@qq.com> Date: Wed, 27 May 2026 19:52:16 +0800 Subject: [PATCH] Studio: add frontend i18n support (#5765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Studio: add frontend i18n support * Studio i18n: guard storage events, restore plurals, fill zh-CN, add parity check - locale-store.ts: wrap window.localStorage access in handleStorageEvent with try/catch. readStoredLocale and writeStoredLocale already guard the same API; the storage-event path can throw the same way in privacy/ restricted contexts and was the only unguarded localStorage call. Refactor the storageArea + key match into isLocaleStorageEvent for clarity. - chat-tab.tsx + en.ts/zh-CN.ts: restore singular handling for chat-clear copy that the i18n migration dropped. Pre-PR code rendered "1 chat" but the new template strings always said "chats", so a user with exactly one chat saw "Cleared 1 chats", "Clear 1 chats?", and "1 chats cleared; 1 chats remain". Add clearOneChat*, clearedOneChat, oneChatClearedRemain*, chatsClearedRemainOne, and storageClearFailedOne keys and pick them in chat-tab.tsx when count === 1. - zh-CN.ts: fill ~50 previously English-fallback keys across studio.configure, studio.model VRAM helpers, studio.dataset (source, browsing, tooltips, preview/split/subset), studio.params tooltips and learningRateDescription, studio.training (audio/vision incompatible), studio.trainingStart.terminalStart, studio.tour.guidedTour, settings.chat.clear*, settings.connections, settings.apiKeys.newBadge. shell.{beta,brand,product} kept as brand strings. - src/i18n/check-parity.ts + npm i18n:check: small script that verifies every locale overlay against the English baseline. Catches placeholder mismatches, shape mismatches, and unintended extra keys; runs via node --experimental- strip-types with no new devDependencies. Verified locally: npm run typecheck, lint, build, biome:check, i18n:check all pass. 24 vitest unit tests cover locale resolution, persistence failures, storage-event sync (including window.localStorage throwing), interpolation, and fallback. 33 Playwright e2e tests pass across Chromium, Firefox, and WebKit covering default load, switch + reload persistence, unsupported/garbage locale fallback, storage-event cross-tab sync, and storage clear. * Studio i18n: use translated API-key error copy instead of raw err.message The API helpers in src/features/settings/api/api-keys.ts throw generic English Error objects ("Failed to load API access", "Failed to create access token", "Failed to revoke access token"). ApiKeysTab and CreateKeyForm caught those and preferred err.message over the translated "settings.apiKeys.loadError" / .createError / .revokeError keys, so in zh-CN mode failed load/create/revoke requests still surfaced the English strings instead of the translated copy. Switched the four call-sites to always render the translated message and left the helper throws unchanged (they are still useful for diagnostics but should not be treated as user-facing localized copy). * Studio i18n: polish two zh-CN embedding LR tooltips Translation-pass review surfaced two awkward phrasings I introduced earlier: "常用区间是主学习率的 2 至 10 倍小" -> "常用区间是比主学习率小 2 至 10 倍" Both versions are grammatical, but the new "比 X 小 N 倍" phrasing is the standard idiomatic comparative for "N times smaller than X" in technical Chinese writing. The earlier "X 的 N 倍小" reads as a non-native construction. Applies to: studio.params.embeddingLearningRateTooltip studio.params.embeddingLearningRateDescription --------- Co-authored-by: Daniel Han --- studio/frontend/package.json | 1 + studio/frontend/src/app/router.tsx | 9 +- studio/frontend/src/app/routes/__root.tsx | 39 +- studio/frontend/src/app/routes/studio.tsx | 2 +- .../frontend/src/components/app-sidebar.tsx | 162 ++-- .../profile-personalization-panel.tsx | 25 +- .../settings/components/api-key-row.tsx | 58 +- .../settings/components/create-key-form.tsx | 19 +- .../settings/components/key-reveal-card.tsx | 14 +- .../settings/components/language-select.tsx | 46 ++ .../settings/components/theme-segmented.tsx | 16 +- .../components/update-studio-instructions.tsx | 94 +-- .../settings/components/usage-examples.tsx | 24 +- .../src/features/settings/settings-dialog.tsx | 46 +- .../src/features/settings/tabs/about-tab.tsx | 34 +- .../features/settings/tabs/api-keys-tab.tsx | 72 +- .../features/settings/tabs/appearance-tab.tsx | 30 +- .../src/features/settings/tabs/chat-tab.tsx | 78 +- .../features/settings/tabs/general-tab.tsx | 56 +- .../features/settings/tabs/profile-tab.tsx | 9 +- .../studio/historical-training-view.tsx | 29 +- .../src/features/studio/history-card-grid.tsx | 93 ++- .../sections/charts/chart-settings-sheet.tsx | 60 +- .../sections/charts/eval-loss-chart-card.tsx | 26 +- .../sections/charts/grad-norm-chart-card.tsx | 19 +- .../charts/learning-rate-chart-card.tsx | 19 +- .../charts/training-loss-chart-card.tsx | 28 +- .../studio/sections/dataset-section.tsx | 139 ++-- .../studio/sections/model-section.tsx | 81 +- .../studio/sections/params-section.tsx | 212 ++--- .../studio/sections/progress-section-lib.ts | 13 - .../studio/sections/progress-section.tsx | 125 +-- .../studio/sections/training-section.tsx | 53 +- .../src/features/studio/studio-page.tsx | 23 +- .../studio/training-start-overlay.tsx | 45 +- .../frontend/src/features/training/index.ts | 5 +- studio/frontend/src/i18n/AGENTS.md | 11 + studio/frontend/src/i18n/check-parity.ts | 111 +++ studio/frontend/src/i18n/index.ts | 44 ++ studio/frontend/src/i18n/locale-store.ts | 130 ++++ studio/frontend/src/i18n/locales/en.ts | 731 ++++++++++++++++++ studio/frontend/src/i18n/locales/zh-CN.ts | 712 +++++++++++++++++ studio/frontend/src/i18n/messages.ts | 76 ++ studio/frontend/src/i18n/types.ts | 30 + studio/frontend/src/main.tsx | 3 + 45 files changed, 2958 insertions(+), 694 deletions(-) create mode 100644 studio/frontend/src/features/settings/components/language-select.tsx create mode 100644 studio/frontend/src/i18n/AGENTS.md create mode 100644 studio/frontend/src/i18n/check-parity.ts create mode 100644 studio/frontend/src/i18n/index.ts create mode 100644 studio/frontend/src/i18n/locale-store.ts create mode 100644 studio/frontend/src/i18n/locales/en.ts create mode 100644 studio/frontend/src/i18n/locales/zh-CN.ts create mode 100644 studio/frontend/src/i18n/messages.ts create mode 100644 studio/frontend/src/i18n/types.ts diff --git a/studio/frontend/package.json b/studio/frontend/package.json index 83b1fd96f9..b43e174889 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -12,6 +12,7 @@ "lint": "eslint .", "preview": "vite preview", "typecheck": "tsc -b --pretty false", + "i18n:check": "node --experimental-strip-types --no-warnings src/i18n/check-parity.ts", "biome:check": "biome check", "biome:fix": "biome check --write" }, diff --git a/studio/frontend/src/app/router.tsx b/studio/frontend/src/app/router.tsx index c7bc0440bd..f0a417638d 100644 --- a/studio/frontend/src/app/router.tsx +++ b/studio/frontend/src/app/router.tsx @@ -3,6 +3,7 @@ import { Link, createRouter, useRouterState } from "@tanstack/react-router"; import { Button } from "@/components/ui/button"; +import { useT } from "@/i18n"; import { Route as rootRoute } from "./routes/__root"; import { Route as dataRecipesRoute } from "./routes/data-recipes"; import { Route as dataRecipeRoute } from "./routes/data-recipes.$recipeId"; @@ -31,7 +32,9 @@ const routeTree = rootRoute.addChildren([ ]); function DefaultNotFound() { + const t = useT(); const pathname = useRouterState({ select: (s) => s.location.pathname }); + return (

- Page not found + {t("shell.notFound.title")}

- {pathname} does not exist. + {t("shell.notFound.description", { path: pathname })}

); diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index 47bff815e6..57ed233d51 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -6,8 +6,9 @@ import { Navbar } from "@/components/navbar"; import { fetchDeviceType, usePlatformStore } from "@/config/env"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SettingsDialog, useSettingsDialogStore } from "@/features/settings"; -import { useTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard"; +import { useTrainingUnloadGuard } from "@/features/training"; import { useSidebarPin } from "@/hooks/use-sidebar-pin"; +import { useT, type TranslationKey } from "@/i18n"; import { Outlet, createRootRoute, @@ -16,24 +17,25 @@ import { useRouterState, } from "@tanstack/react-router"; import { AnimatePresence, motion } from "motion/react"; -import { Suspense, useEffect, useLayoutEffect, type ReactNode } from "react"; +import { Suspense, useEffect, useLayoutEffect } from "react"; import { AppProvider } from "../provider"; -// Type `staticData.title` on every route so the matched-title selector -// below stays type-safe without an inline cast. declare module "@tanstack/react-router" { interface StaticDataRouteOption { title?: string; + titleKey?: TranslationKey; } } -// Fallback while a lazy route bundle (Train/Recipes/Export) loads. -// /chat is synchronous and never hits this. -const RouteFallback: ReactNode = ( -
- Loading... -
-); +function RouteFallback() { + const t = useT(); + + return ( +
+ {t("common.loading")} +
+ ); +} const CHAT_ONLY_ALLOWED = new Set([ "/", @@ -68,6 +70,7 @@ const HIDDEN_NAVBAR_ROUTES = ["/onboarding", "/login", "/change-password"]; const DEFAULT_DOCUMENT_TITLE = "Unsloth Studio"; function RootLayout() { + const t = useT(); const pathname = useRouterState({ select: (s) => s.location.pathname }); const hideNavbar = HIDDEN_NAVBAR_ROUTES.includes(pathname); const isChatRoute = pathname.startsWith("/chat"); @@ -75,24 +78,20 @@ function RootLayout() { useTrainingUnloadGuard(); - // Walk matches deepest-first; each route declares its own title. const matchedTitle = useMatches({ select: (matches) => { for (let i = matches.length - 1; i >= 0; i--) { - const title = matches[i].staticData.title; + const { title, titleKey } = matches[i].staticData; + if (titleKey) return t(titleKey); if (title) return title; } return null; }, }); - // `/settings` redirects in `beforeLoad`, so its route never stays - // matched; surface the modal's title via the store instead. const settingsDialogOpen = useSettingsDialogStore((s) => s.open); - const documentTitle = settingsDialogOpen ? "Settings" : matchedTitle; + const documentTitle = settingsDialogOpen ? t("settings.title") : matchedTitle; - // useLayoutEffect updates the tab title before paint, avoiding a - // one-frame flash of the previous route's title on navigation. useLayoutEffect(() => { document.title = documentTitle ? `${documentTitle} - ${DEFAULT_DOCUMENT_TITLE}` @@ -116,7 +115,7 @@ function RootLayout() { {hideNavbar ? (
- + }>
@@ -142,7 +141,7 @@ function RootLayout() { transition={{ duration: 0.15 }} className={`flex min-h-0 min-w-0 flex-1 basis-0 flex-col ${isChatRoute ? "overflow-hidden" : "overflow-visible"}`} > - + }> diff --git a/studio/frontend/src/app/routes/studio.tsx b/studio/frontend/src/app/routes/studio.tsx index 75f1a1b937..ae7f445e94 100644 --- a/studio/frontend/src/app/routes/studio.tsx +++ b/studio/frontend/src/app/routes/studio.tsx @@ -15,7 +15,7 @@ const StudioPage = lazy(() => export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/studio", - staticData: { title: "Train" }, + staticData: { titleKey: "studio.routeTitle" }, beforeLoad: () => requireAuth(), component: StudioPage, }); diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index aac5f8f8a8..849e017ea8 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -90,9 +90,33 @@ import { useTrainingRuntimeStore, } from "@/features/training"; import type { TrainingRunSummary } from "@/features/training"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { toast } from "@/lib/toast"; import { ShutdownDialog } from "@/components/shutdown-dialog"; +import { translate, useT, type TranslationKey } from "@/i18n"; + +const EMPHASIS_MARKER = "__UNSLOTH_I18N_EMPHASIS_MARKER__"; + +type AppT = ReturnType; + +function renderEmphasizedTranslation( + t: AppT, + key: TranslationKey, + emphasizedValue: string, +): ReactNode { + const translated = t(key, { name: EMPHASIS_MARKER }); + const parts = translated.split(EMPHASIS_MARKER); + if (parts.length === 1) return translated; + + const nodes: ReactNode[] = []; + parts.forEach((part, index) => { + if (part.length > 0) nodes.push(part); + if (index < parts.length - 1) { + nodes.push({emphasizedValue}); + } + }); + return nodes; +} function getTourId(pathname: string): string | null { if (pathname.startsWith("/studio")) return "studio"; @@ -185,6 +209,7 @@ function NavItem({ } export function AppSidebar() { + const t = useT(); const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle(); const { pathname, search } = useRouterState({ select: (s) => ({ @@ -204,14 +229,8 @@ export function AppSidebar() { const chatOnly = usePlatformStore((s) => s.isChatOnly()); const [shutdownOpen, setShutdownOpen] = useState(false); - // Chat collapsible state — open by default, auto-expand on route entry const isChatRoute = pathname.startsWith("/chat"); const isStudioRoute = pathname === "/studio" || pathname.startsWith("/studio/"); - const [chatOpen, setChatOpen] = useState(true); - const [runsOpen, setRunsOpen] = useState(true); - - useEffect(() => { if (isChatRoute) setChatOpen(true); }, [isChatRoute]); - useEffect(() => { if (isStudioRoute) setRunsOpen(true); }, [isStudioRoute]); const scrollRef = useRef(null); const [scrolled, setScrolled] = useState(false); @@ -290,7 +309,7 @@ export function AppSidebar() { try { await renameChatItem(target.item, renameTrimmed); } catch (err) { - toast.error("Failed to rename chat", { + toast.error(translate("shell.toast.failedToRenameChat"), { description: err instanceof Error ? err.message : undefined, }); } @@ -300,7 +319,7 @@ export function AppSidebar() { const updated = await renameTrainingRun(target.run.id, nextRunDisplayName); emitTrainingRunUpdated(updated); } catch (err) { - toast.error("Failed to rename run", { + toast.error(translate("shell.toast.failedToRenameRun"), { description: err instanceof Error ? err.message : undefined, }); } @@ -320,14 +339,14 @@ export function AppSidebar() { try { await handleDeleteThread(target.item); } catch (err) { - toast.error("Failed to delete chat", { + toast.error(translate("shell.toast.failedToDeleteChat"), { description: err instanceof Error ? err.message : undefined, }); } return; } if (target.run.status === "running") { - toast.error("Cannot delete a running training run"); + toast.error(t("shell.toast.cannotDeleteRunningRun")); return; } try { @@ -337,7 +356,7 @@ export function AppSidebar() { } emitTrainingRunDeleted(target.run.id); } catch (err) { - toast.error("Failed to delete run", { + toast.error(translate("shell.toast.failedToDeleteRun"), { description: err instanceof Error ? err.message : undefined, }); } @@ -366,7 +385,7 @@ export function AppSidebar() { }); }} className="flex items-center gap-[6px] select-none" - aria-label="Unsloth home" + aria-label={t("shell.aria.home")} > - BETA + {t("shell.beta")} {!isMobile && ( @@ -387,7 +406,7 @@ export function AppSidebar() { type="button" onClick={togglePinned} className="inline-flex h-[35px] w-[32px] items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - aria-label="Close sidebar" + aria-label={t("shell.aria.closeSidebar")} > @@ -397,7 +416,7 @@ export function AppSidebar() { sideOffset={6} className="tooltip-compact" > - Close sidebar + {t("shell.aria.closeSidebar")} )} @@ -412,7 +431,7 @@ export function AppSidebar() { type="button" onClick={togglePinned} className="inline-flex h-[35px] w-[32px] items-center justify-center rounded-[10px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - aria-label="Open sidebar" + aria-label={t("shell.aria.openSidebar")} > @@ -422,7 +441,7 @@ export function AppSidebar() { sideOffset={8} className="tooltip-compact" > - Open sidebar + {t("shell.aria.openSidebar")} @@ -434,7 +453,7 @@ export function AppSidebar() { { @@ -446,7 +465,7 @@ export function AppSidebar() { /> i.id === search.compare)} disabled={chatDisabled} dataTour="chat-compare" @@ -459,7 +478,7 @@ export function AppSidebar() { /> { @@ -477,7 +496,7 @@ export function AppSidebar() { { @@ -489,7 +508,7 @@ export function AppSidebar() { { navigate({ to: "/data-recipes" }); @@ -499,7 +518,7 @@ export function AppSidebar() { { @@ -513,13 +532,16 @@ export function AppSidebar() { - {/* Recent Chats — hide on Studio only (Eyera fac13); chatOpen = ec695 clickability */} {!isStudioRoute && chatItems.length > 0 && ( - + - Recents + {t("shell.navigation.recents")} @@ -552,7 +574,7 @@ export function AppSidebar() { @@ -844,7 +872,9 @@ export function AppSidebar() { - {renamingTarget?.kind === "run" ? "Rename run" : "Rename chat"} + {renamingTarget?.kind === "run" + ? t("shell.dialog.renameRun.title") + : t("shell.dialog.renameChat.title")} @@ -868,14 +906,14 @@ export function AppSidebar() { variant="ghost" onClick={() => setRenamingTarget(null)} > - Cancel + {t("common.cancel")} diff --git a/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx index f30af7fcf9..ed590f226e 100644 --- a/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx +++ b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { getAuthToken } from "@/features/auth"; +import { useT } from "@/i18n"; import { toastError, toastSuccess } from "@/shared/toast"; import { Camera } from "lucide-react"; import { useMemo, useRef, useState } from "react"; @@ -37,6 +38,7 @@ function readPersistedProfile(): { displayName: string; avatarDataUrl: string | } export function ProfilePersonalizationPanel() { + const t = useT(); const displayName = useUserProfileStore((s) => s.displayName); const avatarDataUrl = useUserProfileStore((s) => s.avatarDataUrl); const setDisplayName = useUserProfileStore((s) => s.setDisplayName); @@ -60,11 +62,11 @@ export function ProfilePersonalizationPanel() { setDisplayName(trimmed); const persisted = readPersistedProfile(); if (persisted && persisted.displayName === trimmed) { - toastSuccess("Profile name saved"); + toastSuccess(t("settings.profile.nameSaved")); } else { toastError( - "Could not persist profile name", - "Name updated for this session, but may not persist after reload.", + t("settings.profile.namePersistErrorTitle"), + t("settings.profile.namePersistErrorDescription"), ); } } @@ -78,17 +80,18 @@ export function ProfilePersonalizationPanel() { setAvatarDataUrl(dataUrl); const persisted = readPersistedProfile(); if (persisted && persisted.avatarDataUrl === dataUrl) { - toastSuccess("Profile photo updated"); + toastSuccess(t("settings.profile.photoUpdated")); } else { toastError( - "Could not persist profile photo", - "Photo updated for this session, but may not persist after reload.", + t("settings.profile.photoPersistErrorTitle"), + t("settings.profile.photoPersistErrorDescription"), ); } } catch (e) { - const message = e instanceof Error ? e.message : "Could not use this image."; + const message = + e instanceof Error ? e.message : t("settings.profile.imageUseError"); setImageError(message); - toastError("Could not update profile photo", message); + toastError(t("settings.profile.photoUpdateErrorTitle"), message); } }; @@ -115,7 +118,7 @@ export function ProfilePersonalizationPanel() { type="button" onClick={() => fileInputRef.current?.click()} className="absolute right-0 bottom-0 -translate-x-[15.625%] -translate-y-[15.625%] flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" - aria-label="Change profile picture" + aria-label={t("settings.profile.changePicture")} > @@ -123,7 +126,7 @@ export function ProfilePersonalizationPanel() {
diff --git a/studio/frontend/src/features/settings/components/api-key-row.tsx b/studio/frontend/src/features/settings/components/api-key-row.tsx index ced6de17d1..9a6e1ee207 100644 --- a/studio/frontend/src/features/settings/components/api-key-row.tsx +++ b/studio/frontend/src/features/settings/components/api-key-row.tsx @@ -14,30 +14,39 @@ import { MoreHorizontalIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; +import { useT } from "@/i18n"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; import type { ApiKey } from "../api/api-keys"; -function relative(iso: string | null): string { - if (!iso) return "never"; +type SettingsT = ReturnType; + +function relative(iso: string | null, t: SettingsT): string { + if (!iso) return t("settings.apiKeys.relativeNever"); const diff = Date.now() - new Date(iso).getTime(); const days = Math.floor(diff / 86400000); if (days < 1) { const hours = Math.floor(diff / 3600000); - if (hours < 1) return "just now"; - return `${hours}h ago`; + if (hours < 1) return t("settings.apiKeys.relativeJustNow"); + return t("settings.apiKeys.relativeHoursAgo", { count: hours }); } - if (days < 30) return `${days}d ago`; - if (days < 365) return `${Math.floor(days / 30)}mo ago`; - return `${Math.floor(days / 365)}y ago`; + if (days < 30) return t("settings.apiKeys.relativeDaysAgo", { count: days }); + if (days < 365) { + return t("settings.apiKeys.relativeMonthsAgo", { + count: Math.floor(days / 30), + }); + } + return t("settings.apiKeys.relativeYearsAgo", { + count: Math.floor(days / 365), + }); } -function expiresText(iso: string | null): string { - if (!iso) return "never"; +function expiresText(iso: string | null, t: SettingsT): string { + if (!iso) return t("settings.apiKeys.relativeNever"); const diff = new Date(iso).getTime() - Date.now(); - if (diff < 0) return "expired"; + if (diff < 0) return t("settings.apiKeys.expired"); const days = Math.floor(diff / 86400000); - if (days < 1) return "today"; - return `in ${days}d`; + if (days < 1) return t("settings.apiKeys.today"); + return t("settings.apiKeys.inDays", { count: days }); } export function ApiKeyRow({ @@ -47,6 +56,7 @@ export function ApiKeyRow({ apiKey: ApiKey; onRevoke: (key: ApiKey) => void; }) { + const t = useT(); const prefix = `sk-unsloth-${apiKey.key_prefix}…`; return (
@@ -64,11 +74,23 @@ export function ApiKeyRow({
- Created {relative(apiKey.created_at)} + + {t("settings.apiKeys.created", { + value: relative(apiKey.created_at, t), + })} + · - Used {relative(apiKey.last_used_at)} + + {t("settings.apiKeys.used", { + value: relative(apiKey.last_used_at, t), + })} + · - Expires {expiresText(apiKey.expires_at)} + + {t("settings.apiKeys.expires", { + value: expiresText(apiKey.expires_at, t), + })} +
@@ -77,7 +99,7 @@ export function ApiKeyRow({ variant="ghost" size="sm" className="size-7 p-0 opacity-0 transition-opacity group-hover:opacity-100 data-[state=open]:opacity-100 max-sm:!opacity-100 max-sm:size-9" - aria-label={`Actions for ${apiKey.name}`} + aria-label={t("settings.apiKeys.actionsFor", { name: apiKey.name })} > @@ -85,14 +107,14 @@ export function ApiKeyRow({ { await copyToClipboard(prefix); }}> - Copy prefix + {t("settings.apiKeys.copyPrefix")} onRevoke(apiKey)} className="text-destructive focus:text-destructive" > - Revoke token + {t("settings.apiKeys.revokeToken")} diff --git a/studio/frontend/src/features/settings/components/create-key-form.tsx b/studio/frontend/src/features/settings/components/create-key-form.tsx index a0f2d7f82d..93802bfad8 100644 --- a/studio/frontend/src/features/settings/components/create-key-form.tsx +++ b/studio/frontend/src/features/settings/components/create-key-form.tsx @@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { useT } from "@/i18n"; import { cn } from "@/lib/utils"; import { useState } from "react"; import { createApiKey } from "../api/api-keys"; @@ -21,6 +22,7 @@ export function CreateKeyForm({ onCreated: (rawKey: string) => void; onError: (message: string) => void; }) { + const t = useT(); const [name, setName] = useState(""); const [expiry, setExpiry] = useState(null); const [loading, setLoading] = useState(false); @@ -33,8 +35,11 @@ export function CreateKeyForm({ const result = await createApiKey(name.trim(), expiry); onCreated(result.key); setName(""); - } catch (err) { - onError(err instanceof Error ? err.message : "Couldn't create access token."); + } catch { + // API helpers in ../api/api-keys.ts throw generic English Error + // messages; always use the translated message so zh-CN users do not + // see English text bleed through from internal exceptions. + onError(t("settings.apiKeys.createError")); } finally { setLoading(false); } @@ -49,9 +54,9 @@ export function CreateKeyForm({ setName(e.target.value)} - placeholder="Token name (e.g. production)" + placeholder={t("settings.apiKeys.tokenNamePlaceholder")} className="h-8 min-w-[180px] flex-1 text-sm" - aria-label="New access token name" + aria-label={t("settings.apiKeys.newAccessTokenName")} />
{EXPIRY_PRESETS.map((p) => { @@ -69,13 +74,15 @@ export function CreateKeyForm({ : "text-muted-foreground hover:text-foreground", )} > - {p.label} + {p.value === null ? t("settings.apiKeys.never") : p.label} ); })}
diff --git a/studio/frontend/src/features/settings/components/key-reveal-card.tsx b/studio/frontend/src/features/settings/components/key-reveal-card.tsx index 2b589e88fe..bdcb861c1d 100644 --- a/studio/frontend/src/features/settings/components/key-reveal-card.tsx +++ b/studio/frontend/src/features/settings/components/key-reveal-card.tsx @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { Button } from "@/components/ui/button"; +import { useT } from "@/i18n"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; import { cn } from "@/lib/utils"; import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons"; @@ -15,6 +16,7 @@ export function KeyRevealCard({ rawKey: string; onDone: () => void; }) { + const t = useT(); const [copied, setCopied] = useState(false); const handleCopy = async () => { @@ -32,7 +34,7 @@ export function KeyRevealCard({ className="size-3.5 text-emerald-600 dark:text-emerald-500" /> - New access token created + {t("settings.apiKeys.newTokenCreated")}

- Copy now — this won't be shown again. + {t("settings.apiKeys.copyNow")}

diff --git a/studio/frontend/src/features/settings/components/language-select.tsx b/studio/frontend/src/features/settings/components/language-select.tsx new file mode 100644 index 0000000000..9d30e06147 --- /dev/null +++ b/studio/frontend/src/features/settings/components/language-select.tsx @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + LOCALES, + isSupportedLocale, + setLocale, + useT, + useLocale, +} from "@/i18n"; + +export function LanguageSelect() { + const t = useT(); + const locale = useLocale(); + + return ( + + ); +} diff --git a/studio/frontend/src/features/settings/components/theme-segmented.tsx b/studio/frontend/src/features/settings/components/theme-segmented.tsx index 1061995346..36e7062d8f 100644 --- a/studio/frontend/src/features/settings/components/theme-segmented.tsx +++ b/studio/frontend/src/features/settings/components/theme-segmented.tsx @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { cn } from "@/lib/utils"; +import { useT, type TranslationKey } from "@/i18n"; import { LaptopIcon, Moon02Icon, @@ -11,13 +12,18 @@ import { HugeiconsIcon } from "@hugeicons/react"; import { motion, useReducedMotion } from "motion/react"; import { useTheme, type Theme } from "../stores/theme-store"; -const OPTIONS: { value: Theme; label: string; icon: typeof Sun02Icon }[] = [ - { value: "light", label: "Light", icon: Sun02Icon }, - { value: "dark", label: "Dark", icon: Moon02Icon }, - { value: "system", label: "System", icon: LaptopIcon }, +const OPTIONS: { + value: Theme; + labelKey: TranslationKey; + icon: typeof Sun02Icon; +}[] = [ + { value: "light", labelKey: "settings.appearance.theme.light", icon: Sun02Icon }, + { value: "dark", labelKey: "settings.appearance.theme.dark", icon: Moon02Icon }, + { value: "system", labelKey: "settings.appearance.theme.system", icon: LaptopIcon }, ]; export function ThemeSegmented() { + const t = useT(); const { theme, setTheme } = useTheme(); const reduced = useReducedMotion(); return ( @@ -49,7 +55,7 @@ export function ThemeSegmented() { /> )} - {opt.label} + {t(opt.labelKey)} ); })} diff --git a/studio/frontend/src/features/settings/components/update-studio-instructions.tsx b/studio/frontend/src/features/settings/components/update-studio-instructions.tsx index e4cdccd2d7..90f66483b7 100644 --- a/studio/frontend/src/features/settings/components/update-studio-instructions.tsx +++ b/studio/frontend/src/features/settings/components/update-studio-instructions.tsx @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { copyToClipboard } from "@/lib/copy-to-clipboard"; +import { useT } from "@/i18n"; import { cn } from "@/lib/utils"; import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; @@ -29,10 +30,13 @@ export type UpdateInstallSource = | "unknown"; type UpdateInstallSourceState = UpdateInstallSource | "loading"; -function getStudioUpdateInstructionLine(shell: UpdateShell): string { +function getStudioUpdateInstructionLine( + shell: UpdateShell, + t: ReturnType, +): string { return shell === "windows" - ? "Open PowerShell and run:" - : "Open Terminal and run:"; + ? t("settings.about.update.openPowerShell") + : t("settings.about.update.openTerminal"); } function isLocalInstallSource( @@ -59,6 +63,7 @@ function CopyableCommand({ command: string; copyLabel: string; }): ReactElement { + const t = useT(); const [copied, setCopied] = useState(false); const timerRef = useRef | null>(null); @@ -89,14 +94,26 @@ function CopyableCommand({ value={command} className="min-w-0 flex-1 bg-transparent px-2 py-1.5 font-mono text-[11px] text-foreground outline-none" title={command} - aria-label={`${copyLabel} text`} + aria-label={t("settings.about.update.commandText", { + label: copyLabel, + })} /> ); })} @@ -125,20 +131,20 @@ export function UsageExamples() { type="button" onClick={handleCopy} className="flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - aria-label="Copy snippet" + aria-label={t("settings.apiKeys.copySnippet")} > - {copied ? "Copied" : "Copy"} + {copied ? t("settings.apiKeys.copied") : t("settings.apiKeys.copy")}
           {snippets[lang]}
         
- Setup docs: + {t("settings.apiKeys.setupDocs")} {DOC_LINKS.map((link) => ( s.open); const activeTab = useSettingsDialogStore((s) => s.activeTab); const setActiveTab = useSettingsDialogStore((s) => s.setActiveTab); @@ -117,9 +133,9 @@ export function SettingsDialog() { "max-sm:h-dvh max-sm:w-dvw max-sm:!max-w-none max-sm:rounded-none", )} > - Settings + {t("settings.dialog.title")} - Manage your Unsloth Studio preferences. + {t("settings.dialog.description")}