Studio: add frontend i18n support (#5765)
* 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 <danielhanchen@gmail.com>
This commit is contained in:
parent
8b2b99be03
commit
4891118b5e
45 changed files with 2958 additions and 694 deletions
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<img
|
||||
|
|
@ -41,14 +44,14 @@ function DefaultNotFound() {
|
|||
/>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<h1 className="font-heading font-semibold text-2xl tracking-tight">
|
||||
Page not found
|
||||
{t("shell.notFound.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm break-all">
|
||||
{pathname} does not exist.
|
||||
{t("shell.notFound.description", { path: pathname })}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link to="/chat">Back to chat</Link>
|
||||
<Link to="/chat">{t("shell.notFound.backToChat")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
<div className="flex h-full min-h-0 flex-1 items-center justify-center text-muted-foreground text-sm">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
function RouteFallback() {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1 items-center justify-center text-muted-foreground text-sm">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
|||
<SettingsDialog />
|
||||
{hideNavbar ? (
|
||||
<main className="flex-1">
|
||||
<Suspense fallback={RouteFallback}>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
|
|
@ -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"}`}
|
||||
>
|
||||
<Suspense fallback={RouteFallback}>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<typeof useT>;
|
||||
|
||||
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(<em key={`emphasis-${index}`}>{emphasizedValue}</em>);
|
||||
}
|
||||
});
|
||||
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<HTMLDivElement | null>(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")}
|
||||
>
|
||||
<img
|
||||
src="/circle-logo-small.png"
|
||||
|
|
@ -377,7 +396,7 @@ export function AppSidebar() {
|
|||
unsloth
|
||||
</span>
|
||||
<span className="nav-badge ml-0.5 inline-flex items-center justify-center rounded-full border border-nav-beta-border px-[5px] pt-[3px] pb-[2px] text-[8px] font-medium leading-none tracking-[0.04em] text-nav-fg-muted antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]">
|
||||
BETA
|
||||
{t("shell.beta")}
|
||||
</span>
|
||||
</Link>
|
||||
{!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")}
|
||||
>
|
||||
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
|
||||
</button>
|
||||
|
|
@ -397,7 +416,7 @@ export function AppSidebar() {
|
|||
sideOffset={6}
|
||||
className="tooltip-compact"
|
||||
>
|
||||
Close sidebar
|
||||
{t("shell.aria.closeSidebar")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
|
@ -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")}
|
||||
>
|
||||
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
|
||||
</button>
|
||||
|
|
@ -422,7 +441,7 @@ export function AppSidebar() {
|
|||
sideOffset={8}
|
||||
className="tooltip-compact"
|
||||
>
|
||||
Open sidebar
|
||||
{t("shell.aria.openSidebar")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
@ -434,7 +453,7 @@ export function AppSidebar() {
|
|||
<SidebarMenu>
|
||||
<NavItem
|
||||
icon={PencilEdit02Icon}
|
||||
label="New Chat"
|
||||
label={t("shell.navigation.newChat")}
|
||||
active={false}
|
||||
disabled={chatDisabled}
|
||||
onClick={() => {
|
||||
|
|
@ -446,7 +465,7 @@ export function AppSidebar() {
|
|||
/>
|
||||
<NavItem
|
||||
icon={ColumnInsertIcon}
|
||||
label="Compare"
|
||||
label={t("shell.navigation.compare")}
|
||||
active={!!search.compare && !chatItems.some((i) => i.id === search.compare)}
|
||||
disabled={chatDisabled}
|
||||
dataTour="chat-compare"
|
||||
|
|
@ -459,7 +478,7 @@ export function AppSidebar() {
|
|||
/>
|
||||
<NavItem
|
||||
icon={Search01Icon}
|
||||
label="Search"
|
||||
label={t("shell.navigation.search")}
|
||||
active={false}
|
||||
disabled={chatDisabled}
|
||||
onClick={() => {
|
||||
|
|
@ -477,7 +496,7 @@ export function AppSidebar() {
|
|||
<SidebarMenu>
|
||||
<NavItem
|
||||
icon={TestTubeOutlineIcon}
|
||||
label="Train"
|
||||
label={t("shell.navigation.train")}
|
||||
active={pathname === "/studio" || pathname.startsWith("/studio/")}
|
||||
disabled={chatOnly}
|
||||
onClick={() => {
|
||||
|
|
@ -489,7 +508,7 @@ export function AppSidebar() {
|
|||
|
||||
<NavItem
|
||||
icon={ChefHatIcon}
|
||||
label="Recipes"
|
||||
label={t("shell.navigation.recipes")}
|
||||
active={isRecipesRoute}
|
||||
onClick={() => {
|
||||
navigate({ to: "/data-recipes" });
|
||||
|
|
@ -499,7 +518,7 @@ export function AppSidebar() {
|
|||
|
||||
<NavItem
|
||||
icon={DownloadSquare01Icon}
|
||||
label="Export"
|
||||
label={t("shell.navigation.export")}
|
||||
active={pathname === "/export" || pathname.startsWith("/export/")}
|
||||
disabled={chatOnly}
|
||||
onClick={() => {
|
||||
|
|
@ -513,13 +532,16 @@ export function AppSidebar() {
|
|||
</SidebarGroup>
|
||||
|
||||
<SidebarContent ref={scrollRef} className="gap-0 overflow-y-auto overscroll-contain min-h-0">
|
||||
{/* Recent Chats — hide on Studio only (Eyera fac13); chatOpen = ec695 clickability */}
|
||||
{!isStudioRoute && chatItems.length > 0 && (
|
||||
<Collapsible open={chatOpen} onOpenChange={setChatOpen} asChild>
|
||||
<Collapsible
|
||||
key={isChatRoute ? "chat-route" : "non-chat-route"}
|
||||
defaultOpen
|
||||
asChild
|
||||
>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
|
||||
<SidebarGroupLabel className={cn("sidebar-sticky-label", scrolled && "is-scrolled")} asChild>
|
||||
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
|
||||
Recents
|
||||
{t("shell.navigation.recents")}
|
||||
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
|
|
@ -552,7 +574,7 @@ export function AppSidebar() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Chat options"
|
||||
aria-label={t("shell.aria.chatOptions")}
|
||||
className="sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
|
||||
>
|
||||
<span className="sidebar-row-action-glyph">
|
||||
|
|
@ -568,14 +590,14 @@ export function AppSidebar() {
|
|||
>
|
||||
<DropdownMenuItem onSelect={() => openRenameChat(item)}>
|
||||
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Rename</span>
|
||||
<span>{t("common.rename")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => setConfirmingDelete({ kind: "chat", item })}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Delete</span>
|
||||
<span>{t("common.delete")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -588,13 +610,12 @@ export function AppSidebar() {
|
|||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Recent Runs */}
|
||||
{isStudioRoute && runItems.length > 0 && !chatOnly && (
|
||||
<Collapsible open={runsOpen} onOpenChange={setRunsOpen} asChild>
|
||||
<Collapsible key="studio-runs-route" defaultOpen asChild>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
|
||||
<SidebarGroupLabel className={cn("sidebar-sticky-label", scrolled && "is-scrolled")} asChild>
|
||||
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
|
||||
Recents
|
||||
{t("shell.navigation.recents")}
|
||||
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
|
|
@ -641,7 +662,7 @@ export function AppSidebar() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Run options"
|
||||
aria-label={t("shell.aria.runOptions")}
|
||||
className="sidebar-row-action group-hover/run-item:opacity-100 group-hover/run-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
|
||||
>
|
||||
<span className="sidebar-row-action-glyph">
|
||||
|
|
@ -657,7 +678,7 @@ export function AppSidebar() {
|
|||
>
|
||||
<DropdownMenuItem onSelect={() => openRenameRun(run)}>
|
||||
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Rename</span>
|
||||
<span>{t("common.rename")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
|
|
@ -667,7 +688,7 @@ export function AppSidebar() {
|
|||
}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Delete</span>
|
||||
<span>{t("common.delete")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -689,7 +710,7 @@ export function AppSidebar() {
|
|||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
aria-label={`${displayTitle} account menu`}
|
||||
aria-label={t("shell.accountMenu", { name: displayTitle })}
|
||||
className="sidebar-nav-btn !h-[50px] gap-[8px] px-2 py-[9px] rounded-[10px]"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
|
|
@ -717,16 +738,16 @@ export function AppSidebar() {
|
|||
onSelect={() => useSettingsDialogStore.getState().openDialog()}
|
||||
>
|
||||
<HugeiconsIcon icon={Settings02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Settings</span>
|
||||
<span>{t("shell.navigation.settings")}</span>
|
||||
<DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => useSettingsDialogStore.getState().openDialog("api-keys")}
|
||||
>
|
||||
<HugeiconsIcon icon={Globe02Icon} strokeWidth={1.75} className="size-[18px]" />
|
||||
<span>API</span>
|
||||
<span>{t("shell.navigation.api")}</span>
|
||||
<span className="ml-auto rounded-[6px] border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
New
|
||||
{t("common.new")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
|
|
@ -734,7 +755,11 @@ export function AppSidebar() {
|
|||
onSelect={(e) => { e.preventDefault(); toggleTheme(); }}
|
||||
>
|
||||
{isDark ? <Sun strokeWidth={1.75} className="size-icon" /> : <Moon strokeWidth={1.75} className="size-icon" />}
|
||||
<span>{isDark ? "Light Mode" : "Dark Mode"}</span>
|
||||
<span>
|
||||
{isDark
|
||||
? t("shell.navigation.lightMode")
|
||||
: t("shell.navigation.darkMode")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!getTourId(pathname)}
|
||||
|
|
@ -749,7 +774,7 @@ export function AppSidebar() {
|
|||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Guided Tour</span>
|
||||
<span>{t("shell.navigation.guidedTour")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator className="mx-2.5! my-2.5! h-0! border-t border-border/70 bg-transparent!" />
|
||||
|
|
@ -757,7 +782,7 @@ export function AppSidebar() {
|
|||
onSelect={() => useSettingsDialogStore.getState().openDialog("about")}
|
||||
>
|
||||
<HugeiconsIcon icon={HelpCircleIcon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Help</span>
|
||||
<span>{t("common.help")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={async () => {
|
||||
|
|
@ -772,11 +797,11 @@ export function AppSidebar() {
|
|||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={Logout01Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Log out</span>
|
||||
<span>{t("shell.navigation.logOut")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setShutdownOpen(true)}>
|
||||
<HugeiconsIcon icon={PowerIcon} strokeWidth={1.75} className="size-icon" />
|
||||
<span>Shutdown</span>
|
||||
<span>{t("common.shutdown")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -800,20 +825,23 @@ export function AppSidebar() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{confirmingDelete?.kind === "run"
|
||||
? "Delete training run"
|
||||
: "Delete chat"}
|
||||
? t("shell.dialog.deleteRun.title")
|
||||
: t("shell.dialog.deleteChat.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{confirmingDelete?.kind === "run" ? (
|
||||
<>
|
||||
Are you sure you want to delete this run{" "}
|
||||
<em>{confirmingDelete.run.display_name ?? confirmingDelete.run.model_name}</em>?
|
||||
</>
|
||||
renderEmphasizedTranslation(
|
||||
t,
|
||||
"shell.dialog.deleteRun.description",
|
||||
confirmingDelete.run.display_name ??
|
||||
confirmingDelete.run.model_name,
|
||||
)
|
||||
) : confirmingDelete?.kind === "chat" ? (
|
||||
<>
|
||||
Are you sure you want to delete this chat{" "}
|
||||
<em>{confirmingDelete.item.title}</em>?
|
||||
</>
|
||||
renderEmphasizedTranslation(
|
||||
t,
|
||||
"shell.dialog.deleteChat.description",
|
||||
confirmingDelete.item.title,
|
||||
)
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
@ -823,14 +851,14 @@ export function AppSidebar() {
|
|||
variant="ghost"
|
||||
onClick={() => setConfirmingDelete(null)}
|
||||
>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => void commitDelete()}
|
||||
>
|
||||
Delete
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
@ -844,7 +872,9 @@ export function AppSidebar() {
|
|||
<DialogContent className="corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{renamingTarget?.kind === "run" ? "Rename run" : "Rename chat"}
|
||||
{renamingTarget?.kind === "run"
|
||||
? t("shell.dialog.renameRun.title")
|
||||
: t("shell.dialog.renameChat.title")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
|
|
@ -858,8 +888,16 @@ export function AppSidebar() {
|
|||
}}
|
||||
autoFocus
|
||||
maxLength={120}
|
||||
placeholder={renamingTarget?.kind === "run" ? "Run name" : "Chat title"}
|
||||
aria-label={renamingTarget?.kind === "run" ? "Run name" : "Chat title"}
|
||||
placeholder={
|
||||
renamingTarget?.kind === "run"
|
||||
? t("shell.dialog.renameRun.placeholder")
|
||||
: t("shell.dialog.renameChat.placeholder")
|
||||
}
|
||||
aria-label={
|
||||
renamingTarget?.kind === "run"
|
||||
? t("shell.dialog.renameRun.placeholder")
|
||||
: t("shell.dialog.renameChat.placeholder")
|
||||
}
|
||||
className="focus-visible:border-input focus-visible:ring-0"
|
||||
/>
|
||||
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
|
||||
|
|
@ -868,14 +906,14 @@ export function AppSidebar() {
|
|||
variant="ghost"
|
||||
onClick={() => setRenamingTarget(null)}
|
||||
>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void commitRename()}
|
||||
disabled={!renameDirty}
|
||||
>
|
||||
Save
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
>
|
||||
<Camera className="size-3.5" strokeWidth={2} />
|
||||
</button>
|
||||
|
|
@ -123,7 +126,7 @@ export function ProfilePersonalizationPanel() {
|
|||
|
||||
<div className="flex w-full max-w-[560px] flex-col gap-2">
|
||||
<Label htmlFor="profile-display-name" className="text-xs font-medium text-muted-foreground">
|
||||
Display name
|
||||
{t("settings.profile.displayName")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
|
|
@ -142,7 +145,7 @@ export function ProfilePersonalizationPanel() {
|
|||
className="h-10 min-w-0 flex-1 rounded-full text-sm"
|
||||
/>
|
||||
<Button type="button" size="sm" className="h-10 px-5" onClick={saveName} disabled={!hasNameChanges}>
|
||||
Save
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<typeof useT>;
|
||||
|
||||
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 (
|
||||
<div className="group flex items-center gap-3 border-b border-border/60 px-1 py-3 last:border-b-0 transition-colors hover:bg-accent/40">
|
||||
|
|
@ -64,11 +74,23 @@ export function ApiKeyRow({
|
|||
</code>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-1.5 text-[11px] text-muted-foreground">
|
||||
<span>Created {relative(apiKey.created_at)}</span>
|
||||
<span>
|
||||
{t("settings.apiKeys.created", {
|
||||
value: relative(apiKey.created_at, t),
|
||||
})}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>Used {relative(apiKey.last_used_at)}</span>
|
||||
<span>
|
||||
{t("settings.apiKeys.used", {
|
||||
value: relative(apiKey.last_used_at, t),
|
||||
})}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>Expires {expiresText(apiKey.expires_at)}</span>
|
||||
<span>
|
||||
{t("settings.apiKeys.expires", {
|
||||
value: expiresText(apiKey.expires_at, t),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
|
|
@ -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 })}
|
||||
>
|
||||
<HugeiconsIcon icon={MoreHorizontalIcon} className="size-4" />
|
||||
</Button>
|
||||
|
|
@ -85,14 +107,14 @@ export function ApiKeyRow({
|
|||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={async () => { await copyToClipboard(prefix); }}>
|
||||
<HugeiconsIcon icon={Copy01Icon} className="size-3.5 mr-2" />
|
||||
Copy prefix
|
||||
{t("settings.apiKeys.copyPrefix")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onRevoke(apiKey)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-3.5 mr-2" />
|
||||
Revoke token
|
||||
{t("settings.apiKeys.revokeToken")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -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<number | null>(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({
|
|||
<Input
|
||||
value={name}
|
||||
onChange={(e) => 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")}
|
||||
/>
|
||||
<div className="inline-flex items-center rounded-md border border-border bg-background p-0.5">
|
||||
{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}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={loading || !name.trim()}>
|
||||
{loading ? "Creating…" : "Create token"}
|
||||
{loading
|
||||
? t("settings.apiKeys.creating")
|
||||
: t("settings.apiKeys.createToken")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
<span className="text-xs font-medium text-emerald-700 dark:text-emerald-500">
|
||||
New access token created
|
||||
{t("settings.apiKeys.newTokenCreated")}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -43,7 +45,11 @@ export function KeyRevealCard({
|
|||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
||||
copied && "border-emerald-500/40 bg-emerald-500/10",
|
||||
)}
|
||||
aria-label={copied ? "Access token copied" : "Copy access token"}
|
||||
aria-label={
|
||||
copied
|
||||
? t("settings.apiKeys.accessTokenCopied")
|
||||
: t("settings.apiKeys.copyAccessToken")
|
||||
}
|
||||
>
|
||||
<code className="min-w-0 flex-1 break-all text-left text-foreground">
|
||||
{rawKey}
|
||||
|
|
@ -55,7 +61,7 @@ export function KeyRevealCard({
|
|||
</button>
|
||||
<div className="flex items-center justify-between gap-3 pt-0.5">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Copy now — this won't be shown again.
|
||||
{t("settings.apiKeys.copyNow")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -63,7 +69,7 @@ export function KeyRevealCard({
|
|||
onClick={onDone}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
|
||||
>
|
||||
Done
|
||||
{t("common.done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Select
|
||||
value={locale}
|
||||
onValueChange={(value) => {
|
||||
if (isSupportedLocale(value)) setLocale(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("settings.appearance.language.label")}
|
||||
className="w-40"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(LOCALES).map(([value, metadata]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{metadata.nativeLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
|
@ -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() {
|
|||
/>
|
||||
)}
|
||||
<HugeiconsIcon icon={opt.icon} className="relative z-10 size-3.5" />
|
||||
<span className="relative z-10">{opt.label}</span>
|
||||
<span className="relative z-10">{t(opt.labelKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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<typeof useT>,
|
||||
): 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<ReturnType<typeof setTimeout> | 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,
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex shrink-0 items-center justify-center border-l border-border px-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={copied ? "Copied" : "Copy command"}
|
||||
aria-label={copied ? `${copyLabel} copied` : `Copy ${copyLabel}`}
|
||||
title={
|
||||
copied
|
||||
? t("settings.about.update.copied")
|
||||
: t("settings.about.update.copyCommand")
|
||||
}
|
||||
aria-label={
|
||||
copied
|
||||
? t("settings.about.update.commandCopied", { label: copyLabel })
|
||||
: t("settings.about.update.copyNamedCommand", {
|
||||
label: copyLabel,
|
||||
})
|
||||
}
|
||||
>
|
||||
{copied ? (
|
||||
<HugeiconsIcon
|
||||
|
|
@ -123,7 +140,9 @@ export function UpdateStudioInstructions({
|
|||
installSource?: UpdateInstallSourceState | null;
|
||||
showTitle?: boolean;
|
||||
}): ReactElement {
|
||||
const [shell, setShell] = useState<UpdateShell>(defaultShell);
|
||||
const t = useT();
|
||||
const [shellOverride, setShellOverride] = useState<UpdateShell | null>(null);
|
||||
const shell = shellOverride ?? defaultShell;
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const windows = shell === "windows";
|
||||
const localInstallSource = isLocalInstallSource(installSource);
|
||||
|
|
@ -144,10 +163,6 @@ export function UpdateStudioInstructions({
|
|||
? { opacity: 1 }
|
||||
: { opacity: 0, y: -2 };
|
||||
|
||||
useEffect(() => {
|
||||
setShell(defaultShell);
|
||||
}, [defaultShell]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3", className)}>
|
||||
<div
|
||||
|
|
@ -158,13 +173,13 @@ export function UpdateStudioInstructions({
|
|||
>
|
||||
{showTitle ? (
|
||||
<p className="shrink-0 whitespace-nowrap text-sm font-semibold font-heading">
|
||||
Update Unsloth Studio
|
||||
{t("settings.about.update.title")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex shrink-0 items-center gap-0.5 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShell("windows")}
|
||||
onClick={() => setShellOverride("windows")}
|
||||
className={cn(
|
||||
"px-0.5 py-0.5 font-medium transition-colors",
|
||||
windows
|
||||
|
|
@ -178,7 +193,7 @@ export function UpdateStudioInstructions({
|
|||
<span className="text-border">/</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShell("unix")}
|
||||
onClick={() => setShellOverride("unix")}
|
||||
className={cn(
|
||||
"px-0.5 py-0.5 font-medium transition-colors",
|
||||
windows
|
||||
|
|
@ -193,31 +208,28 @@ export function UpdateStudioInstructions({
|
|||
</div>
|
||||
{loadingInstallSource ? (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Checking how Studio was installed…
|
||||
{t("settings.about.update.checkingInstall")}
|
||||
</p>
|
||||
) : localInstallSource ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Source or local install detected. To avoid replacing it with PyPI,
|
||||
update from the checkout or source you originally installed from.
|
||||
{t("settings.about.update.localInstallDetected")}
|
||||
</p>
|
||||
{checkoutInstallSource ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Pull latest changes from your Unsloth repo checkout, then update
|
||||
Studio locally:
|
||||
{t("settings.about.update.pullThenUpdate")}
|
||||
</p>
|
||||
<CopyableCommand
|
||||
command={STUDIO_LOCAL_PULL_CMD}
|
||||
copyLabel="git pull command"
|
||||
copyLabel={t("settings.about.update.gitPullCommand")}
|
||||
/>
|
||||
<CopyableCommand
|
||||
command={STUDIO_LOCAL_UPDATE_CMD}
|
||||
copyLabel="local update command"
|
||||
copyLabel={t("settings.about.update.localUpdateCommand")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
If the Studio update command is unavailable, run the local
|
||||
installer from that checkout:
|
||||
{t("settings.about.update.localInstallerFallback")}
|
||||
</p>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
|
|
@ -233,7 +245,7 @@ export function UpdateStudioInstructions({
|
|||
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
|
||||
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
|
||||
}
|
||||
copyLabel="local installer command"
|
||||
copyLabel={t("settings.about.update.localInstallerCommand")}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
|
@ -242,12 +254,10 @@ export function UpdateStudioInstructions({
|
|||
{packagedSourceInstall ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
This looks like a source or VCS package install. Reinstall from
|
||||
the original local path or Git URL you used.
|
||||
{t("settings.about.update.sourceInstallDetected")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
If you still have the Unsloth repo checkout, run the local
|
||||
installer from that checkout:
|
||||
{t("settings.about.update.repoCheckoutFallback")}
|
||||
</p>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
|
|
@ -263,39 +273,37 @@ export function UpdateStudioInstructions({
|
|||
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
|
||||
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
|
||||
}
|
||||
copyLabel="local installer command"
|
||||
copyLabel={t("settings.about.update.localInstallerCommand")}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Restart Studio after updating for changes to take effect.
|
||||
{t("settings.about.update.restartAfterUpdate")}
|
||||
</p>
|
||||
</>
|
||||
) : unknownInstallSource ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Studio could not detect how it was installed. Check how you
|
||||
installed Studio first, then choose the matching update path.
|
||||
{t("settings.about.update.unknownInstall")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
For curl or PyPI installs, run:
|
||||
{t("settings.about.update.curlOrPypi")}
|
||||
</p>
|
||||
<CopyableCommand
|
||||
command={STUDIO_UPDATE_CMD}
|
||||
copyLabel="update command"
|
||||
copyLabel={t("settings.about.update.updateCommand")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
For local checkout installs, update from that checkout instead and
|
||||
use the local update command:
|
||||
{t("settings.about.update.localCheckout")}
|
||||
</p>
|
||||
<CopyableCommand
|
||||
command={STUDIO_LOCAL_UPDATE_CMD}
|
||||
copyLabel="local update command"
|
||||
copyLabel={t("settings.about.update.localUpdateCommand")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Restart Studio after updating for changes to take effect.
|
||||
{t("settings.about.update.restartAfterUpdate")}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -309,15 +317,15 @@ export function UpdateStudioInstructions({
|
|||
transition={fadeTransition}
|
||||
className="text-xs text-muted-foreground leading-relaxed"
|
||||
>
|
||||
{getStudioUpdateInstructionLine(shell)}
|
||||
{getStudioUpdateInstructionLine(shell, t)}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
<CopyableCommand
|
||||
command={STUDIO_UPDATE_CMD}
|
||||
copyLabel="update command"
|
||||
copyLabel={t("settings.about.update.updateCommand")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
If that fails or unsloth studio update is unavailable, run:
|
||||
{t("settings.about.update.fallbackInstruction")}
|
||||
</p>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
|
|
@ -333,12 +341,12 @@ export function UpdateStudioInstructions({
|
|||
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
|
||||
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
|
||||
}
|
||||
copyLabel="fallback command"
|
||||
copyLabel={t("settings.about.update.fallbackCommand")}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Restart Studio after updating for changes to take effect.
|
||||
{t("settings.about.update.restartAfterUpdate")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { useT } from "@/i18n";
|
||||
import {
|
||||
ArrowUpRight01Icon,
|
||||
Copy01Icon,
|
||||
|
|
@ -78,6 +79,7 @@ for chunk in response:
|
|||
}
|
||||
|
||||
export function UsageExamples() {
|
||||
const t = useT();
|
||||
const [lang, setLang] = useState<Lang>("curl");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const snippets = useMemo(
|
||||
|
|
@ -97,17 +99,19 @@ export function UsageExamples() {
|
|||
|
||||
return (
|
||||
<section className="flex min-w-0 max-w-full flex-col">
|
||||
<h2 className="mb-2 text-sm font-semibold text-foreground">Usage examples</h2>
|
||||
<h2 className="mb-2 text-sm font-semibold text-foreground">
|
||||
{t("settings.apiKeys.usageExamples")}
|
||||
</h2>
|
||||
<div className="min-w-0 max-w-full overflow-hidden rounded-lg border border-border bg-muted/20">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 border-b border-border px-2 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-0.5">
|
||||
{TABS.map((t) => {
|
||||
const active = lang === t.id;
|
||||
{TABS.map((tab) => {
|
||||
const active = lang === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setLang(t.id)}
|
||||
onClick={() => setLang(tab.id)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
|
|
@ -116,7 +120,9 @@ export function UsageExamples() {
|
|||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
{tab.id === "tools"
|
||||
? t("settings.apiKeys.usageTools")
|
||||
: tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -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")}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={copied ? Tick02Icon : Copy01Icon}
|
||||
className={cn("size-3.5", copied && "text-emerald-600")}
|
||||
/>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
{copied ? t("settings.apiKeys.copied") : t("settings.apiKeys.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="max-w-full overflow-x-auto whitespace-pre-wrap break-words p-3 font-mono text-[11px] leading-relaxed text-foreground">
|
||||
{snippets[lang]}
|
||||
</pre>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
|
||||
<span>Setup docs:</span>
|
||||
<span>{t("settings.apiKeys.setupDocs")}</span>
|
||||
{DOC_LINKS.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useT, type TranslationKey } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Cancel01Icon,
|
||||
|
|
@ -35,19 +36,33 @@ import { ProfileTab } from "./tabs/profile-tab";
|
|||
|
||||
interface TabDef {
|
||||
id: SettingsTab;
|
||||
label: string;
|
||||
labelKey: TranslationKey;
|
||||
icon: typeof Settings02Icon;
|
||||
badge?: string;
|
||||
badgeKey?: TranslationKey;
|
||||
}
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ id: "general", label: "General", icon: Settings02Icon },
|
||||
{ id: "profile", label: "Profile", icon: UserIcon },
|
||||
{ id: "appearance", label: "Appearance", icon: PaintBrush02Icon },
|
||||
{ id: "chat", label: "Chat", icon: Message01Icon },
|
||||
{ id: "connections", label: "Connections", icon: CloudIcon, badge: "New" },
|
||||
{ id: "api-keys", label: "API", icon: Globe02Icon, badge: "New" },
|
||||
{ id: "about", label: "Help", icon: HelpCircleIcon },
|
||||
{ id: "general", labelKey: "settings.tabs.general", icon: Settings02Icon },
|
||||
{ id: "profile", labelKey: "settings.tabs.profile", icon: UserIcon },
|
||||
{
|
||||
id: "appearance",
|
||||
labelKey: "settings.tabs.appearance",
|
||||
icon: PaintBrush02Icon,
|
||||
},
|
||||
{ id: "chat", labelKey: "settings.tabs.chat", icon: Message01Icon },
|
||||
{
|
||||
id: "connections",
|
||||
labelKey: "settings.tabs.connections",
|
||||
icon: CloudIcon,
|
||||
badgeKey: "common.new",
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
labelKey: "settings.tabs.apiKeys",
|
||||
icon: Globe02Icon,
|
||||
badgeKey: "common.new",
|
||||
},
|
||||
{ id: "about", labelKey: "settings.tabs.about", icon: HelpCircleIcon },
|
||||
];
|
||||
|
||||
function renderTab(tab: SettingsTab) {
|
||||
|
|
@ -70,6 +85,7 @@ function renderTab(tab: SettingsTab) {
|
|||
}
|
||||
|
||||
export function SettingsDialog() {
|
||||
const t = useT();
|
||||
const open = useSettingsDialogStore((s) => 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",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">Settings</DialogTitle>
|
||||
<DialogTitle className="sr-only">{t("settings.dialog.title")}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Manage your Unsloth Studio preferences.
|
||||
{t("settings.dialog.description")}
|
||||
</DialogDescription>
|
||||
<div className="flex h-full min-h-0 max-sm:flex-col">
|
||||
<aside className="font-heading flex w-[216px] shrink-0 flex-col border-r border-border bg-muted/20 p-2 max-sm:w-full max-sm:border-r-0 max-sm:border-b">
|
||||
|
|
@ -165,11 +181,11 @@ export function SettingsDialog() {
|
|||
className="relative z-10 size-icon"
|
||||
/>
|
||||
<span className="relative z-10 min-w-0 truncate">
|
||||
{tab.label}
|
||||
{t(tab.labelKey)}
|
||||
</span>
|
||||
{tab.badge ? (
|
||||
{tab.badgeKey ? (
|
||||
<span className="relative z-10 ml-auto rounded-[6px] border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
{tab.badge}
|
||||
{t(tab.badgeKey)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
|
@ -183,7 +199,7 @@ export function SettingsDialog() {
|
|||
type="button"
|
||||
onClick={closeDialog}
|
||||
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2d2f33] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Close settings"
|
||||
aria-label={t("settings.dialog.closeAriaLabel")}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { usePlatformStore } from "@/config/env";
|
||||
import { getAuthToken } from "@/features/auth";
|
||||
import { removeTrainingUnloadGuard } from "@/features/training";
|
||||
import { useT } from "@/i18n";
|
||||
import { apiUrl, isTauri } from "@/lib/api-base";
|
||||
import {
|
||||
ArrowUpRight01Icon,
|
||||
|
|
@ -94,6 +95,7 @@ async function fetchInstallSource(): Promise<UpdateInstallSource> {
|
|||
}
|
||||
|
||||
export function AboutTab() {
|
||||
const t = useT();
|
||||
const deviceType = usePlatformStore((s) => s.deviceType);
|
||||
const defaultShell = deviceType === "windows" ? "windows" : "unix";
|
||||
const [shutdownOpen, setShutdownOpen] = useState(false);
|
||||
|
|
@ -132,26 +134,28 @@ export function AboutTab() {
|
|||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">Help</h1>
|
||||
<h1 className="text-lg font-semibold font-heading">
|
||||
{t("settings.about.title")}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Documentation, release notes, feedback, and Studio build info.
|
||||
{t("settings.about.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SettingsSection title="Studio">
|
||||
<SettingsRow label="Studio Version">
|
||||
<SettingsRow label={t("settings.about.studioVersion")}>
|
||||
<code className="font-mono text-xs text-muted-foreground">
|
||||
{studioVersion}
|
||||
</code>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Package Version">
|
||||
<SettingsRow label={t("settings.about.packageVersion")}>
|
||||
<code className="font-mono text-xs text-muted-foreground">
|
||||
{packageVersion}
|
||||
</code>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Updates">
|
||||
<SettingsSection title={t("settings.about.updates")}>
|
||||
<div className="py-2">
|
||||
<UpdateStudioInstructions
|
||||
defaultShell={defaultShell}
|
||||
|
|
@ -161,8 +165,8 @@ export function AboutTab() {
|
|||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Help">
|
||||
<SettingsRow label="Documentation">
|
||||
<SettingsSection title={t("settings.about.help")}>
|
||||
<SettingsRow label={t("settings.about.documentation")}>
|
||||
<a
|
||||
href="https://unsloth.ai/docs"
|
||||
target="_blank"
|
||||
|
|
@ -174,7 +178,7 @@ export function AboutTab() {
|
|||
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
|
||||
</a>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Release notes">
|
||||
<SettingsRow label={t("settings.about.releaseNotes")}>
|
||||
<a
|
||||
href="https://unsloth.ai/docs/new/changelog"
|
||||
target="_blank"
|
||||
|
|
@ -182,11 +186,11 @@ export function AboutTab() {
|
|||
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={NewReleasesIcon} className="size-3.5" />
|
||||
What's new
|
||||
{t("settings.about.whatsNew")}
|
||||
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
|
||||
</a>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Feedback">
|
||||
<SettingsRow label={t("settings.about.feedback")}>
|
||||
<a
|
||||
href="https://github.com/unslothai/unsloth/issues"
|
||||
target="_blank"
|
||||
|
|
@ -197,17 +201,17 @@ export function AboutTab() {
|
|||
icon={MessageNotification01Icon}
|
||||
className="size-3.5"
|
||||
/>
|
||||
Report an issue
|
||||
{t("settings.about.reportIssue")}
|
||||
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
|
||||
</a>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Danger zone">
|
||||
<SettingsSection title={t("settings.about.dangerZone")}>
|
||||
<SettingsRow
|
||||
destructive={true}
|
||||
label="Shut down Unsloth Studio"
|
||||
description="Stops the Studio server process and ends your session."
|
||||
label={t("settings.about.shutDownStudio")}
|
||||
description={t("settings.about.shutDownStudioDescription")}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -216,7 +220,7 @@ export function AboutTab() {
|
|||
className="text-destructive hover:text-destructive hover:border-destructive/60"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-3.5 mr-1.5" />
|
||||
Shut down
|
||||
{t("settings.about.shutDown")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { translate, useT } from "@/i18n";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { fetchApiKeys, revokeApiKey, type ApiKey } from "../api/api-keys";
|
||||
|
|
@ -19,6 +20,7 @@ import { KeyRevealCard } from "../components/key-reveal-card";
|
|||
import { UsageExamples } from "../components/usage-examples";
|
||||
|
||||
export function ApiKeysTab() {
|
||||
const t = useT();
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -26,25 +28,47 @@ export function ApiKeysTab() {
|
|||
const [revoking, setRevoking] = useState(false);
|
||||
const [revealed, setRevealed] = useState<string | null>(null);
|
||||
const reduced = useReducedMotion();
|
||||
const t = reduced
|
||||
const transition = reduced
|
||||
? { duration: 0 }
|
||||
: { duration: 0.18, ease: [0.165, 0.84, 0.44, 1] as const };
|
||||
|
||||
// API helpers in ../api/api-keys.ts throw generic English Error messages
|
||||
// ("Failed to load API access", etc.). Always use the translated message
|
||||
// so zh-CN users do not see those English strings bleed through.
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setKeys(await fetchApiKeys());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn't load API access.");
|
||||
} catch {
|
||||
setError(translate("settings.apiKeys.loadError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
let cancelled = false;
|
||||
|
||||
async function loadInitialApiKeys() {
|
||||
try {
|
||||
const apiKeys = await fetchApiKeys();
|
||||
if (cancelled) return;
|
||||
setKeys(apiKeys);
|
||||
setError(null);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setError(translate("settings.apiKeys.loadError"));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void loadInitialApiKeys();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const confirmRevoke = async () => {
|
||||
if (!revokeTarget) return;
|
||||
|
|
@ -53,8 +77,8 @@ export function ApiKeysTab() {
|
|||
await revokeApiKey(revokeTarget.id);
|
||||
await load();
|
||||
setRevokeTarget(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn't revoke access token.");
|
||||
} catch {
|
||||
setError(translate("settings.apiKeys.revokeError"));
|
||||
} finally {
|
||||
setRevoking(false);
|
||||
}
|
||||
|
|
@ -63,16 +87,18 @@ export function ApiKeysTab() {
|
|||
return (
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-6">
|
||||
<header className="flex min-w-0 flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">API</h1>
|
||||
<h1 className="text-lg font-semibold font-heading">
|
||||
{t("settings.apiKeys.title")}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Access Unsloth programmatically via the OpenAI-compatible API.{" "}
|
||||
{t("settings.apiKeys.description")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/basics/api"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-foreground underline decoration-border underline-offset-2 transition-colors hover:decoration-foreground"
|
||||
>
|
||||
Read the API docs
|
||||
{t("settings.apiKeys.readDocs")}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
|
@ -85,7 +111,7 @@ export function ApiKeysTab() {
|
|||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={t}
|
||||
transition={transition}
|
||||
>
|
||||
<KeyRevealCard
|
||||
rawKey={revealed}
|
||||
|
|
@ -98,7 +124,7 @@ export function ApiKeysTab() {
|
|||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 4 }}
|
||||
transition={t}
|
||||
transition={transition}
|
||||
>
|
||||
<CreateKeyForm
|
||||
onCreated={(raw) => {
|
||||
|
|
@ -112,7 +138,9 @@ export function ApiKeysTab() {
|
|||
</AnimatePresence>
|
||||
|
||||
<section className="flex min-w-0 flex-col">
|
||||
<h2 className="mb-2 text-sm font-semibold text-foreground">Access tokens</h2>
|
||||
<h2 className="mb-2 text-sm font-semibold text-foreground">
|
||||
{t("settings.apiKeys.accessTokens")}
|
||||
</h2>
|
||||
{error ? (
|
||||
<div className="rounded-md border border-destructive/20 bg-destructive/5 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
|
|
@ -128,7 +156,7 @@ export function ApiKeysTab() {
|
|||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">
|
||||
No API access yet.
|
||||
{t("settings.apiKeys.noAccess")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
|
|
@ -144,21 +172,29 @@ export function ApiKeysTab() {
|
|||
<Dialog open={revokeTarget !== null} onOpenChange={(o) => !o && setRevokeTarget(null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Revoke access token “{revokeTarget?.name}”?</DialogTitle>
|
||||
<DialogTitle>
|
||||
{t("settings.apiKeys.revokeTitle", {
|
||||
name: revokeTarget?.name ?? "",
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Applications using this token will immediately lose access. This cannot be undone.
|
||||
{t("settings.apiKeys.revokeDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRevokeTarget(null)}>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmRevoke}
|
||||
disabled={revoking}
|
||||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
>
|
||||
{revoking ? "Revoking…" : `Revoke “${revokeTarget?.name}”`}
|
||||
{revoking
|
||||
? t("settings.apiKeys.revoking")
|
||||
: t("settings.apiKeys.revokeAction", {
|
||||
name: revokeTarget?.name ?? "",
|
||||
})}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -3,34 +3,48 @@
|
|||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useSidebarPin } from "@/hooks/use-sidebar-pin";
|
||||
import { useT } from "@/i18n";
|
||||
import { LanguageSelect } from "../components/language-select";
|
||||
import { SettingsRow } from "../components/settings-row";
|
||||
import { SettingsSection } from "../components/settings-section";
|
||||
import { ThemeSegmented } from "../components/theme-segmented";
|
||||
|
||||
export function AppearanceTab() {
|
||||
const t = useT();
|
||||
const { pinned, setPinned } = useSidebarPin();
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">Appearance</h1>
|
||||
<h1 className="text-lg font-semibold font-heading">
|
||||
{t("settings.appearance.title")}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How Unsloth Studio looks on this device.
|
||||
{t("settings.appearance.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SettingsSection title="Theme">
|
||||
<SettingsSection title={t("settings.appearance.theme.title")}>
|
||||
<SettingsRow
|
||||
label="Color scheme"
|
||||
description="Choose light, dark, or follow your system."
|
||||
label={t("settings.appearance.theme.label")}
|
||||
description={t("settings.appearance.theme.description")}
|
||||
>
|
||||
<ThemeSegmented />
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Layout">
|
||||
<SettingsSection title={t("settings.appearance.language.title")}>
|
||||
<SettingsRow
|
||||
label="Pin sidebar by default"
|
||||
description="Keep the sidebar expanded instead of collapsing to icons."
|
||||
label={t("settings.appearance.language.label")}
|
||||
description={t("settings.appearance.language.description")}
|
||||
>
|
||||
<LanguageSelect />
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings.appearance.layout.title")}>
|
||||
<SettingsRow
|
||||
label={t("settings.appearance.layout.compactSidebar")}
|
||||
description={t("settings.appearance.layout.compactSidebarDescription")}
|
||||
>
|
||||
<Switch checked={pinned} onCheckedChange={setPinned} />
|
||||
</SettingsRow>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
countAllChats,
|
||||
downloadChatExport,
|
||||
} from "@/features/chat";
|
||||
import { useT } from "@/i18n";
|
||||
import { Delete02Icon, Download02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
|
@ -23,6 +24,7 @@ import { SettingsRow } from "../components/settings-row";
|
|||
import { SettingsSection } from "../components/settings-section";
|
||||
|
||||
export function ChatTab() {
|
||||
const t = useT();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [count, setCount] = useState<number | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
|
@ -53,8 +55,10 @@ export function ChatTab() {
|
|||
setConfirmOpen(false);
|
||||
toast.success(
|
||||
clearedCount === 0
|
||||
? "Cleared all chats"
|
||||
: `Cleared ${clearedCount} chat${clearedCount === 1 ? "" : "s"}`,
|
||||
? t("settings.chat.clearedAllChats")
|
||||
: clearedCount === 1
|
||||
? t("settings.chat.clearedOneChat")
|
||||
: t("settings.chat.clearedChatCount", { count: clearedCount }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -66,20 +70,29 @@ export function ChatTab() {
|
|||
const remaining = await countAllChats().catch(() => fallbackRemaining);
|
||||
setCount(remaining);
|
||||
setConfirmOpen(false);
|
||||
toast.warning("Some chats could not be cleared", {
|
||||
toast.warning(t("settings.chat.someChatsCouldNotBeCleared"), {
|
||||
description:
|
||||
result.failedThreadIds.length > 0
|
||||
? `${clearedCount} chat${clearedCount === 1 ? "" : "s"} cleared; ${
|
||||
result.failedThreadIds.length
|
||||
} chat${result.failedThreadIds.length === 1 ? "" : "s"} remain. Please retry.`
|
||||
: `A storage clear failed; ${remaining} chat${
|
||||
remaining === 1 ? "" : "s"
|
||||
} may remain. Please retry.`,
|
||||
? clearedCount === 1 && result.failedThreadIds.length === 1
|
||||
? t("settings.chat.oneChatClearedRemainOne")
|
||||
: clearedCount === 1
|
||||
? t("settings.chat.oneChatClearedRemain", {
|
||||
remainingCount: result.failedThreadIds.length,
|
||||
})
|
||||
: result.failedThreadIds.length === 1
|
||||
? t("settings.chat.chatsClearedRemainOne", { clearedCount })
|
||||
: t("settings.chat.chatsClearedRemain", {
|
||||
clearedCount,
|
||||
remainingCount: result.failedThreadIds.length,
|
||||
})
|
||||
: remaining === 1
|
||||
? t("settings.chat.storageClearFailedOne")
|
||||
: t("settings.chat.storageClearFailed", { count: remaining }),
|
||||
});
|
||||
} catch (error) {
|
||||
const remaining = await countAllChats().catch(() => count);
|
||||
setCount(remaining);
|
||||
toast.error("Failed to clear chats", {
|
||||
toast.error(t("settings.chat.failedToClearChats"), {
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -90,16 +103,18 @@ export function ChatTab() {
|
|||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">Chat</h1>
|
||||
<h1 className="text-lg font-semibold font-heading">
|
||||
{t("settings.chat.title")}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Manage your chat history stored on this device.
|
||||
{t("settings.chat.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SettingsSection title="Data">
|
||||
<SettingsSection title={t("settings.chat.data")}>
|
||||
<SettingsRow
|
||||
label="Export chat history"
|
||||
description="Download all chats and messages as a JSON file."
|
||||
label={t("settings.chat.exportHistory")}
|
||||
description={t("settings.chat.exportHistoryDescription")}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -108,19 +123,23 @@ export function ChatTab() {
|
|||
disabled={exporting || count === 0}
|
||||
>
|
||||
<HugeiconsIcon icon={Download02Icon} className="size-3.5 mr-1.5" />
|
||||
{exporting ? "Exporting…" : "Export"}
|
||||
{exporting
|
||||
? t("settings.chat.exportingAction")
|
||||
: t("settings.chat.exportAction")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
destructive
|
||||
label="Clear all chats"
|
||||
label={t("settings.chat.clearAllChats")}
|
||||
description={
|
||||
count === null
|
||||
? "Permanently delete every chat on this device."
|
||||
? t("settings.chat.clearAllChatsDescription")
|
||||
: count === 0
|
||||
? "No chats to clear."
|
||||
: `Permanently delete all ${count} chat${count === 1 ? "" : "s"} on this device.`
|
||||
? t("settings.chat.noChatsToClear")
|
||||
: count === 1
|
||||
? t("settings.chat.clearOneChatDescription")
|
||||
: t("settings.chat.clearChatCountDescription", { count })
|
||||
}
|
||||
>
|
||||
<Button
|
||||
|
|
@ -131,7 +150,7 @@ export function ChatTab() {
|
|||
className="text-destructive hover:text-destructive hover:border-destructive/60"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-3.5 mr-1.5" />
|
||||
Clear chats
|
||||
{t("settings.chat.clearChatsAction")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
|
@ -140,16 +159,17 @@ export function ChatTab() {
|
|||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Clear {count ?? 0} chat{count === 1 ? "" : "s"}?
|
||||
{count === 1
|
||||
? t("settings.chat.clearOneChatTitle")
|
||||
: t("settings.chat.clearChatsTitle", { count: count ?? 0 })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This permanently deletes every chat and message stored on this
|
||||
device. This cannot be undone.
|
||||
{t("settings.chat.clearChatsConfirmDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClear}
|
||||
|
|
@ -157,8 +177,12 @@ export function ChatTab() {
|
|||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
>
|
||||
{clearing
|
||||
? "Clearing…"
|
||||
: `Clear ${count ?? 0} chat${count === 1 ? "" : "s"}`}
|
||||
? t("settings.chat.clearingAction")
|
||||
: count === 1
|
||||
? t("settings.chat.clearOneChatAction")
|
||||
: t("settings.chat.clearChatCountAction", {
|
||||
count: count ?? 0,
|
||||
})}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -14,8 +14,9 @@ import { Input } from "@/components/ui/input";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { resetOnboardingDone } from "@/features/auth";
|
||||
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
|
||||
import { useChatRuntimeStore } from "@/features/chat";
|
||||
import { useSettingsDialogStore } from "@/features/settings";
|
||||
import { LOCALE_STORAGE_KEY, useT } from "@/i18n";
|
||||
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
|
@ -35,6 +36,7 @@ import { SettingsSection } from "../components/settings-section";
|
|||
const PREFS_KEYS: string[] = [
|
||||
// Appearance
|
||||
"theme",
|
||||
LOCALE_STORAGE_KEY,
|
||||
// UI state
|
||||
"sidebar_pinned",
|
||||
"unsloth_sidebar_navigate_open",
|
||||
|
|
@ -81,6 +83,7 @@ function resetAllPrefs() {
|
|||
}
|
||||
|
||||
export function GeneralTab() {
|
||||
const t = useT();
|
||||
const navigate = useNavigate();
|
||||
const closeDialog = useSettingsDialogStore((s) => s.closeDialog);
|
||||
const { pathname, search } = useRouterState({
|
||||
|
|
@ -132,16 +135,18 @@ export function GeneralTab() {
|
|||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">General</h1>
|
||||
<h1 className="text-lg font-semibold font-heading">
|
||||
{t("settings.general.title")}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Global preferences for Unsloth Studio.
|
||||
{t("settings.general.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SettingsSection title="Account">
|
||||
<SettingsSection title={t("settings.general.account")}>
|
||||
<SettingsRow
|
||||
label="Hugging Face token"
|
||||
description="Used to load gated models and push artifacts."
|
||||
label={t("settings.general.huggingFaceToken")}
|
||||
description={t("settings.general.huggingFaceTokenDescription")}
|
||||
>
|
||||
<div className="relative w-[260px]">
|
||||
<Input
|
||||
|
|
@ -156,7 +161,11 @@ export function GeneralTab() {
|
|||
type="button"
|
||||
onClick={() => setShowToken((s) => !s)}
|
||||
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={showToken ? "Hide token" : "Show token"}
|
||||
aria-label={
|
||||
showToken
|
||||
? t("settings.general.hideToken")
|
||||
: t("settings.general.showToken")
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showToken ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
||||
|
|
@ -165,20 +174,20 @@ export function GeneralTab() {
|
|||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Chat defaults">
|
||||
<SettingsSection title={t("settings.general.chatDefaults")}>
|
||||
<SettingsRow
|
||||
label="Auto-title new chats"
|
||||
description="Generate a short title from the first message."
|
||||
label={t("settings.general.autoTitleNewChats")}
|
||||
description={t("settings.general.autoTitleNewChatsDescription")}
|
||||
>
|
||||
<Switch checked={autoTitle} onCheckedChange={setAutoTitle} />
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
{!chatOnly && (
|
||||
<SettingsSection title="Getting started">
|
||||
<SettingsSection title={t("settings.general.gettingStarted")}>
|
||||
<SettingsRow
|
||||
label="Start onboarding"
|
||||
description="Open the setup wizard again without changing your account."
|
||||
label={t("settings.general.startOnboarding")}
|
||||
description={t("settings.general.startOnboardingDescription")}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -189,17 +198,17 @@ export function GeneralTab() {
|
|||
navigate({ to: "/onboarding", search: { redirectTo } });
|
||||
}}
|
||||
>
|
||||
Start onboarding
|
||||
{t("settings.general.startOnboardingAction")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection title="Danger zone">
|
||||
<SettingsSection title={t("settings.general.resetPreferences.sectionTitle")}>
|
||||
<SettingsRow
|
||||
destructive
|
||||
label="Reset all local preferences"
|
||||
description="Clears local-only preferences. Chats, API access, and DB-backed chat settings are not affected."
|
||||
label={t("settings.general.resetPreferences.label")}
|
||||
description={t("settings.general.resetPreferences.description")}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -207,7 +216,7 @@ export function GeneralTab() {
|
|||
onClick={() => setConfirmOpen(true)}
|
||||
className="text-destructive hover:text-destructive hover:border-destructive/60"
|
||||
>
|
||||
Reset preferences
|
||||
{t("settings.general.resetPreferences.action")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
|
@ -215,21 +224,22 @@ export function GeneralTab() {
|
|||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset all local preferences?</DialogTitle>
|
||||
<DialogTitle>
|
||||
{t("settings.general.resetPreferences.confirmTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This clears local-only preferences, then reloads Studio. Chats,
|
||||
API access, and DB-backed chat settings are not affected.
|
||||
{t("settings.general.resetPreferences.confirmDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={resetAllPrefs}
|
||||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
>
|
||||
Reset and reload
|
||||
{t("settings.general.resetPreferences.confirmAction")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -2,14 +2,19 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { ProfilePersonalizationPanel } from "@/features/profile";
|
||||
import { useT } from "@/i18n";
|
||||
|
||||
export function ProfileTab() {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">Profile</h1>
|
||||
<h1 className="text-lg font-semibold font-heading">
|
||||
{t("settings.profile.title")}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Update how your profile appears in Studio.
|
||||
{t("settings.profile.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,18 @@ import { parseBackendTrainingMethod } from "@/features/training/lib/training-met
|
|||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import { ChartsSection } from "./sections/charts-section";
|
||||
import { ProgressSection } from "./sections/progress-section";
|
||||
import { translate, useT } from "@/i18n";
|
||||
|
||||
type StudioT = ReturnType<typeof useT>;
|
||||
|
||||
interface HistoricalTrainingViewProps {
|
||||
runId: string;
|
||||
}
|
||||
|
||||
function mapToViewData(detail: TrainingRunDetailResponse): TrainingViewData {
|
||||
function mapToViewData(
|
||||
detail: TrainingRunDetailResponse,
|
||||
t: StudioT,
|
||||
): TrainingViewData {
|
||||
const { run, metrics } = detail;
|
||||
|
||||
const lossHistory = metrics.loss_step_history
|
||||
|
|
@ -62,12 +68,12 @@ function mapToViewData(detail: TrainingRunDetailResponse): TrainingViewData {
|
|||
evalEnabled: evalLossHistory.length > 0,
|
||||
message:
|
||||
run.status === "completed"
|
||||
? "Training completed"
|
||||
? t("studio.history.message.completed")
|
||||
: run.status === "stopped"
|
||||
? "Training stopped"
|
||||
? t("studio.history.message.stopped")
|
||||
: run.status === "running"
|
||||
? "Training in progress"
|
||||
: run.error_message ?? "Training errored",
|
||||
? t("studio.history.message.running")
|
||||
: run.error_message ?? t("studio.history.message.errored"),
|
||||
error: run.status === "error" ? run.error_message : null,
|
||||
isTrainingRunning: false,
|
||||
modelName: run.display_name ?? run.model_name,
|
||||
|
|
@ -85,6 +91,7 @@ function mapToViewData(detail: TrainingRunDetailResponse): TrainingViewData {
|
|||
export function HistoricalTrainingView({
|
||||
runId,
|
||||
}: HistoricalTrainingViewProps): ReactElement {
|
||||
const t = useT();
|
||||
const [detail, setDetail] = useState<TrainingRunDetailResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -99,7 +106,11 @@ export function HistoricalTrainingView({
|
|||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load run");
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translate("studio.history.loadingRun"),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
controller.abort();
|
||||
|
|
@ -120,7 +131,7 @@ export function HistoricalTrainingView({
|
|||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-8 text-sm text-muted-foreground">
|
||||
Loading training run...
|
||||
{t("studio.history.loadingRun")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -128,12 +139,12 @@ export function HistoricalTrainingView({
|
|||
if (error || !detail) {
|
||||
return (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-8 text-sm text-red-500">
|
||||
{error ?? "Run not found"}
|
||||
{error ?? t("studio.history.runNotFound")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const viewData = mapToViewData(detail);
|
||||
const viewData = mapToViewData(detail, t);
|
||||
const configOverride = detail.config
|
||||
? {
|
||||
epochs: detail.config.num_epochs as number | undefined,
|
||||
|
|
|
|||
|
|
@ -29,40 +29,46 @@ import { Delete02Icon } from "@hugeicons/core-free-icons";
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { translate, useT } from "@/i18n";
|
||||
|
||||
type StudioT = ReturnType<typeof useT>;
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
const RUNNING_POLL_INTERVAL_MS = 5000;
|
||||
|
||||
const statusBadge: Record<
|
||||
string,
|
||||
{ label: string; className: string }
|
||||
{ className: string }
|
||||
> = {
|
||||
completed: {
|
||||
label: "Completed",
|
||||
className:
|
||||
"bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400",
|
||||
},
|
||||
stopped: {
|
||||
label: "Stopped",
|
||||
className:
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400",
|
||||
},
|
||||
error: {
|
||||
label: "Error",
|
||||
className: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
|
||||
},
|
||||
running: {
|
||||
label: "Running",
|
||||
className:
|
||||
"bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400",
|
||||
},
|
||||
resumed_later: {
|
||||
label: "Continued",
|
||||
className:
|
||||
"bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400",
|
||||
},
|
||||
};
|
||||
|
||||
function formatStatusLabel(status: string, t: StudioT): string {
|
||||
if (status === "completed") return t("studio.history.status.completed");
|
||||
if (status === "stopped") return t("studio.history.status.stopped");
|
||||
if (status === "running") return t("studio.history.status.running");
|
||||
if (status === "resumed_later") return t("studio.history.status.continued");
|
||||
return t("studio.history.status.error");
|
||||
}
|
||||
|
||||
function wasContinuedInVisibleRuns(
|
||||
run: TrainingRunSummary,
|
||||
runs: TrainingRunSummary[],
|
||||
|
|
@ -97,7 +103,15 @@ function catmullRomPath(points: { x: number; y: number }[]): string {
|
|||
return d.join(" ");
|
||||
}
|
||||
|
||||
function Sparkline({ values, id }: { values: number[]; id: string }): ReactElement | null {
|
||||
function Sparkline({
|
||||
values,
|
||||
id,
|
||||
ariaLabel,
|
||||
}: {
|
||||
values: number[];
|
||||
id: string;
|
||||
ariaLabel: string;
|
||||
}): ReactElement | null {
|
||||
if (!values || values.length < 2) return null;
|
||||
let min = values[0]!;
|
||||
let max = values[0]!;
|
||||
|
|
@ -123,7 +137,7 @@ function Sparkline({ values, id }: { values: number[]; id: string }): ReactEleme
|
|||
const fillPath = `${linePath} L${last.x.toFixed(1)},${h} L${first.x.toFixed(1)},${h} Z`;
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="h-8 w-full" preserveAspectRatio="none" role="img" aria-label="Loss trend sparkline">
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="h-8 w-full" preserveAspectRatio="none" role="img" aria-label={ariaLabel}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="0.12" />
|
||||
|
|
@ -148,15 +162,15 @@ function Sparkline({ values, id }: { values: number[]; id: string }): ReactEleme
|
|||
);
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoDate: string): string {
|
||||
function formatRelativeTime(isoDate: string, t: StudioT): string {
|
||||
const diff = Date.now() - new Date(isoDate).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
if (mins < 1) return t("studio.history.relativeJustNow");
|
||||
if (mins < 60) return t("studio.history.relativeMinutesAgo", { count: mins });
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
if (hrs < 24) return t("studio.history.relativeHoursAgo", { count: hrs });
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
return t("studio.history.relativeDaysAgo", { count: days });
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -169,6 +183,7 @@ export function HistoryCardGrid({
|
|||
onSelectRun,
|
||||
onResumeStarted,
|
||||
}: HistoryCardGridProps): ReactElement {
|
||||
const t = useT();
|
||||
const [runs, setRuns] = useState<TrainingRunSummary[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -209,7 +224,7 @@ export function HistoryCardGrid({
|
|||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
if (fetchIdRef.current !== id) return;
|
||||
if (!append) setError("Failed to load training runs");
|
||||
if (!append) setError(translate("studio.history.loadError"));
|
||||
} finally {
|
||||
if (fetchIdRef.current === id) {
|
||||
setLoading(false);
|
||||
|
|
@ -286,7 +301,7 @@ export function HistoryCardGrid({
|
|||
// Refresh failed — card is already removed, no stale display
|
||||
});
|
||||
} catch {
|
||||
setDeleteError("Failed to delete training run. Please try again.");
|
||||
setDeleteError(translate("studio.history.deleteError"));
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
|
@ -305,10 +320,13 @@ export function HistoryCardGrid({
|
|||
|
||||
if (!loading && error && runs.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-16 text-center">
|
||||
<div
|
||||
className="flex flex-col items-center gap-2 py-16 text-center"
|
||||
aria-label={t("studio.history.title")}
|
||||
>
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void fetchRuns(0)}>
|
||||
Retry
|
||||
{t("studio.history.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -316,17 +334,19 @@ export function HistoryCardGrid({
|
|||
|
||||
if (!loading && runs.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-16 text-center">
|
||||
<div
|
||||
className="flex flex-col items-center gap-2 py-16 text-center"
|
||||
aria-label={t("studio.history.title")}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No training runs yet. Start your first training run in the Configure
|
||||
tab.
|
||||
{t("studio.history.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="contents" aria-label={t("studio.history.title")}>
|
||||
{deleteError && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||
{deleteError}
|
||||
|
|
@ -370,10 +390,10 @@ export function HistoryCardGrid({
|
|||
)}
|
||||
>
|
||||
{isRunning && <Spinner className="size-2.5" />}
|
||||
{badge.label}
|
||||
{formatStatusLabel(wasContinued ? "resumed_later" : run.status, t)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatRelativeTime(run.started_at)}
|
||||
{formatRelativeTime(run.started_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
{canResume && (
|
||||
|
|
@ -388,7 +408,7 @@ export function HistoryCardGrid({
|
|||
void handleResume(run.id);
|
||||
}}
|
||||
>
|
||||
{isResuming ? "Resuming..." : "Resume training"}
|
||||
{isResuming ? t("studio.history.resuming") : t("studio.history.resumeTraining")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
|
|
@ -415,16 +435,20 @@ export function HistoryCardGrid({
|
|||
</div>
|
||||
{run.loss_sparkline && run.loss_sparkline.length >= 2 && (
|
||||
<div className={cn(canResume && "h-7 overflow-hidden")}>
|
||||
<Sparkline values={run.loss_sparkline} id={run.id} />
|
||||
<Sparkline
|
||||
values={run.loss_sparkline}
|
||||
id={run.id}
|
||||
ariaLabel={t("studio.history.lossTrendSparkline")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
Loss:{" "}
|
||||
{t("studio.history.loss")}:{" "}
|
||||
{run.final_loss != null ? run.final_loss.toFixed(4) : "--"}
|
||||
</span>
|
||||
<span>
|
||||
Steps: {run.final_step ?? 0}/{run.total_steps ?? "--"}
|
||||
{t("studio.history.steps")}: {run.final_step ?? 0}/{run.total_steps ?? "--"}
|
||||
</span>
|
||||
<span>{formatDuration(run.duration_seconds)}</span>
|
||||
</div>
|
||||
|
|
@ -432,7 +456,7 @@ export function HistoryCardGrid({
|
|||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-3 rounded-md p-1 text-muted-foreground/50 opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100 focus-visible:opacity-100"
|
||||
aria-label="Delete run"
|
||||
aria-label={t("studio.history.deleteRun")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(run.id);
|
||||
|
|
@ -453,7 +477,7 @@ export function HistoryCardGrid({
|
|||
onClick={() => void fetchRuns(runs.length, true)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Loading..." : "Load more"}
|
||||
{loading ? t("studio.history.loading") : t("studio.history.loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -475,23 +499,22 @@ export function HistoryCardGrid({
|
|||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete training run?</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("studio.history.deleteTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this training run and all its metrics.
|
||||
This action cannot be undone.
|
||||
{t("studio.history.deleteDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => void handleDelete()}
|
||||
>
|
||||
Delete
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "@/components/ui/sheet";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useT } from "@/i18n";
|
||||
import { Settings02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type ReactElement, useState } from "react";
|
||||
|
|
@ -82,25 +83,29 @@ function ScaleSection({
|
|||
outlierMode: OutlierMode;
|
||||
setOutlierMode: (value: OutlierMode) => void;
|
||||
}): ReactElement {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="text-xs text-muted-foreground">Scale and cleanup</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("studio.charts.scaleAndCleanup")}
|
||||
</p>
|
||||
</div>
|
||||
<ChoiceButtons
|
||||
options={[
|
||||
{ label: "Linear", value: "linear" },
|
||||
{ label: "Log", value: "log" },
|
||||
{ label: t("studio.charts.linear"), value: "linear" },
|
||||
{ label: t("studio.charts.log"), value: "log" },
|
||||
]}
|
||||
value={scale}
|
||||
onChange={setScale}
|
||||
/>
|
||||
<ChoiceButtons
|
||||
options={[
|
||||
{ label: "No clip", value: "none" },
|
||||
{ label: "Clip p99", value: "p99" },
|
||||
{ label: "Clip p95", value: "p95" },
|
||||
{ label: t("studio.charts.noClip"), value: "none" },
|
||||
{ label: t("studio.charts.clipP99"), value: "p99" },
|
||||
{ label: t("studio.charts.clipP95"), value: "p95" },
|
||||
]}
|
||||
value={outlierMode}
|
||||
onChange={setOutlierMode}
|
||||
|
|
@ -110,6 +115,7 @@ function ScaleSection({
|
|||
}
|
||||
|
||||
export function ChartSettingsSheet(): ReactElement {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const {
|
||||
availableSteps,
|
||||
|
|
@ -181,7 +187,7 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open chart settings"
|
||||
aria-label={t("studio.charts.openSettings")}
|
||||
>
|
||||
<HugeiconsIcon icon={Settings02Icon} className="size-4" />
|
||||
</Button>
|
||||
|
|
@ -191,24 +197,26 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
overlayClassName="bg-transparent backdrop-blur-0"
|
||||
>
|
||||
<SheetHeader className="pb-4">
|
||||
<SheetTitle>Chart Settings</SheetTitle>
|
||||
<SheetTitle>{t("studio.charts.settings")}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Tune chart presentation while training keeps running.
|
||||
{t("studio.charts.settingsDescription")}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 space-y-6 overflow-y-auto px-6 pb-6">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">View window</p>
|
||||
<p className="text-sm font-medium">
|
||||
{t("studio.charts.viewWindow")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Show latest steps only or the full history.
|
||||
{t("studio.charts.viewWindowDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Window</span>
|
||||
<span>{t("studio.charts.window")}</span>
|
||||
<span className="tabular-nums">
|
||||
{showingAll ? "All" : effectiveWindowSize}
|
||||
{showingAll ? t("studio.charts.all") : effectiveWindowSize}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
|
|
@ -224,14 +232,16 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Training loss</p>
|
||||
<p className="text-sm font-medium">
|
||||
{t("studio.charts.trainingLoss")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Control overlays and EMA smoothing.
|
||||
{t("studio.charts.trainingLossDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Smoothing</span>
|
||||
<span>{t("studio.charts.smoothing")}</span>
|
||||
<span className="tabular-nums">{smoothing.toFixed(2)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
|
|
@ -242,17 +252,17 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
step={0.01}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Move right for more smoothing. `0` = raw.
|
||||
{t("studio.charts.smoothingDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<SettingRow
|
||||
label="Show raw loss"
|
||||
label={t("studio.charts.showRawLoss")}
|
||||
control={
|
||||
<Switch checked={showRaw} onCheckedChange={setShowRaw} />
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="Show smoothed loss"
|
||||
label={t("studio.charts.showSmoothedLoss")}
|
||||
control={
|
||||
<Switch
|
||||
checked={showSmoothed}
|
||||
|
|
@ -261,7 +271,7 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="Show average line"
|
||||
label={t("studio.charts.showAverageLine")}
|
||||
control={
|
||||
<Switch
|
||||
checked={showAvgLine}
|
||||
|
|
@ -272,7 +282,7 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
</div>
|
||||
<Separator />
|
||||
<ScaleSection
|
||||
title="Loss axis"
|
||||
title={t("studio.charts.lossAxis")}
|
||||
scale={lossScale}
|
||||
setScale={setLossScale}
|
||||
outlierMode={lossOutlierMode}
|
||||
|
|
@ -280,7 +290,7 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
/>
|
||||
<Separator />
|
||||
<ScaleSection
|
||||
title="Gradient norm axis"
|
||||
title={t("studio.charts.gradientNormAxis")}
|
||||
scale={gradScale}
|
||||
setScale={setGradScale}
|
||||
outlierMode={gradOutlierMode}
|
||||
|
|
@ -288,7 +298,7 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
/>
|
||||
<Separator />
|
||||
<ScaleSection
|
||||
title="Learning rate axis"
|
||||
title={t("studio.charts.learningRateAxis")}
|
||||
scale={lrScale}
|
||||
setScale={setLrScale}
|
||||
outlierMode={lrOutlierMode}
|
||||
|
|
@ -302,10 +312,10 @@ export function ChartSettingsSheet(): ReactElement {
|
|||
size="sm"
|
||||
onClick={resetPreferences}
|
||||
>
|
||||
Reset defaults
|
||||
{t("studio.charts.resetDefaults")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => setOpen(false)}>
|
||||
Done
|
||||
{t("common.done")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import { useT } from "@/i18n";
|
||||
import { ChartAverageIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { ReactElement } from "react";
|
||||
|
|
@ -24,10 +25,6 @@ import {
|
|||
placeholderEvalData,
|
||||
} from "./utils";
|
||||
|
||||
const evalLossConfig = {
|
||||
loss: { label: "Eval Loss", color: "#ef4444" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function EvalLossChartCard({
|
||||
data,
|
||||
domain,
|
||||
|
|
@ -41,11 +38,16 @@ export function EvalLossChartCard({
|
|||
isTraining: boolean;
|
||||
evalEnabled: boolean;
|
||||
}): ReactElement {
|
||||
const t = useT();
|
||||
const evalLossConfig = {
|
||||
loss: { label: t("studio.charts.evalLoss"), color: "#ef4444" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
return (
|
||||
<Card data-tour="studio-eval-loss" size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className={`text-sm${data.length > 0 ? "" : " text-muted-foreground"}`}>
|
||||
Eval Loss
|
||||
{t("studio.charts.evalLoss")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -87,11 +89,13 @@ export function EvalLossChartCard({
|
|||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_value, payload) =>
|
||||
`Step ${payload?.[0]?.payload?.step ?? ""}`
|
||||
t("studio.charts.step", {
|
||||
step: payload?.[0]?.payload?.step ?? "",
|
||||
})
|
||||
}
|
||||
formatter={(_value, _name, item) => [
|
||||
formatMetric(Number(item?.payload?.loss)),
|
||||
"Eval Loss",
|
||||
t("studio.charts.evalLoss"),
|
||||
]}
|
||||
/>
|
||||
}
|
||||
|
|
@ -156,13 +160,13 @@ export function EvalLossChartCard({
|
|||
/>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{isTraining && evalEnabled
|
||||
? "Waiting for first evaluation step…"
|
||||
: "Evaluation not configured"}
|
||||
? t("studio.charts.waitingForFirstEvaluationStep")
|
||||
: t("studio.charts.evaluationNotConfigured")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
{isTraining && evalEnabled
|
||||
? "Chart will appear once eval_steps is reached"
|
||||
: "Set eval dataset & eval_steps to track eval loss"}
|
||||
? t("studio.charts.evalChartWillAppear")
|
||||
: t("studio.charts.setEvalDatasetAndSteps")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import { useT } from "@/i18n";
|
||||
import type { ReactElement } from "react";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import type { ScaleMode } from "./types";
|
||||
|
|
@ -24,10 +25,6 @@ import {
|
|||
fromLog1p,
|
||||
} from "./utils";
|
||||
|
||||
const gradNormConfig = {
|
||||
displayGradNorm: { label: "Grad Norm", color: "#f97316" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
interface GradNormPoint {
|
||||
step: number;
|
||||
gradNorm: number;
|
||||
|
|
@ -47,12 +44,18 @@ export function GradNormChartCard({
|
|||
xAxisTicks: number[];
|
||||
scale: ScaleMode;
|
||||
}): ReactElement {
|
||||
const t = useT();
|
||||
const gradNormConfig = {
|
||||
displayGradNorm: { label: t("studio.charts.gradNorm"), color: "#f97316" },
|
||||
} satisfies ChartConfig;
|
||||
const showPoint = data.length <= 1 ? { r: 3, strokeWidth: 0 } : false;
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Gradient Norm</CardTitle>
|
||||
<CardTitle className="text-sm">
|
||||
{t("studio.charts.gradientNorm")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={gradNormConfig} className={CHART_CONTAINER_CLASS}>
|
||||
|
|
@ -101,11 +104,13 @@ export function GradNormChartCard({
|
|||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_value, payload) =>
|
||||
`Step ${payload?.[0]?.payload?.step ?? ""}`
|
||||
t("studio.charts.step", {
|
||||
step: payload?.[0]?.payload?.step ?? "",
|
||||
})
|
||||
}
|
||||
formatter={(_value, _name, item) => {
|
||||
const raw = Number(item?.payload?.gradNorm);
|
||||
return [formatMetric(raw), "Grad Norm"];
|
||||
return [formatMetric(raw), t("studio.charts.gradNorm")];
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import { useT } from "@/i18n";
|
||||
import type { ReactElement } from "react";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import type { ScaleMode } from "./types";
|
||||
|
|
@ -22,10 +23,6 @@ import {
|
|||
fromLog1p,
|
||||
} from "./utils";
|
||||
|
||||
const lrConfig = {
|
||||
displayLr: { label: "LR", color: "#8b5cf6" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
interface LearningRatePoint {
|
||||
step: number;
|
||||
lr: number;
|
||||
|
|
@ -45,12 +42,18 @@ export function LearningRateChartCard({
|
|||
xAxisTicks: number[];
|
||||
scale: ScaleMode;
|
||||
}): ReactElement {
|
||||
const t = useT();
|
||||
const lrConfig = {
|
||||
displayLr: { label: t("studio.charts.lr"), color: "#8b5cf6" },
|
||||
} satisfies ChartConfig;
|
||||
const showPoint = data.length <= 1 ? { r: 3, strokeWidth: 0 } : false;
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Learning Rate</CardTitle>
|
||||
<CardTitle className="text-sm">
|
||||
{t("studio.charts.learningRate")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={lrConfig} className={CHART_CONTAINER_CLASS}>
|
||||
|
|
@ -99,13 +102,15 @@ export function LearningRateChartCard({
|
|||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_value, payload) =>
|
||||
`Step ${payload?.[0]?.payload?.step ?? ""}`
|
||||
t("studio.charts.step", {
|
||||
step: payload?.[0]?.payload?.step ?? "",
|
||||
})
|
||||
}
|
||||
formatter={(_value, _name, item) => {
|
||||
const raw = Number(item?.payload?.lr);
|
||||
return [
|
||||
Number.isFinite(raw) ? raw.toExponential(3) : "0e+0",
|
||||
"LR",
|
||||
t("studio.charts.lr"),
|
||||
];
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import { useT } from "@/i18n";
|
||||
import type { ReactElement } from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
|
|
@ -31,11 +32,6 @@ import {
|
|||
fromLog1p,
|
||||
} from "./utils";
|
||||
|
||||
const lossConfig = {
|
||||
displayLoss: { label: "Loss", color: "#3b82f6" },
|
||||
displaySmoothed: { label: "Smoothed", color: "#f59e0b" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
interface LossChartPoint {
|
||||
step: number;
|
||||
loss: number;
|
||||
|
|
@ -67,12 +63,17 @@ export function TrainingLossChartCard({
|
|||
showAvgLine: boolean;
|
||||
scale: ScaleMode;
|
||||
}): ReactElement {
|
||||
const t = useT();
|
||||
const lossConfig = {
|
||||
displayLoss: { label: t("studio.charts.loss"), color: "#3b82f6" },
|
||||
displaySmoothed: { label: t("studio.charts.smoothed"), color: "#f59e0b" },
|
||||
} satisfies ChartConfig;
|
||||
const showPoint = data.length <= 1 ? { r: 3, strokeWidth: 0 } : false;
|
||||
|
||||
return (
|
||||
<Card data-tour="studio-training-loss" size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Training Loss</CardTitle>
|
||||
<CardTitle className="text-sm">{t("studio.charts.trainingLoss")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={lossConfig} className={CHART_CONTAINER_CLASS}>
|
||||
|
|
@ -121,16 +122,21 @@ export function TrainingLossChartCard({
|
|||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_value, payload) =>
|
||||
`Step ${payload?.[0]?.payload?.step ?? ""}`
|
||||
t("studio.charts.step", {
|
||||
step: payload?.[0]?.payload?.step ?? "",
|
||||
})
|
||||
}
|
||||
formatter={(_value, name, item) => {
|
||||
if (name === "displaySmoothed") {
|
||||
return [
|
||||
formatMetric(Number(item?.payload?.smoothed)),
|
||||
"Smoothed",
|
||||
t("studio.charts.smoothed"),
|
||||
];
|
||||
}
|
||||
return [formatMetric(Number(item?.payload?.loss)), "Loss"];
|
||||
return [
|
||||
formatMetric(Number(item?.payload?.loss)),
|
||||
t("studio.charts.loss"),
|
||||
];
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
|
@ -142,7 +148,9 @@ export function TrainingLossChartCard({
|
|||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.5}
|
||||
label={{
|
||||
value: `avg ${formatMetric(avgRaw)}`,
|
||||
value: t("studio.charts.averageValue", {
|
||||
value: formatMetric(avgRaw),
|
||||
}),
|
||||
position: "insideTopRight",
|
||||
fontSize: 10,
|
||||
fill: "#3b82f6",
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ import {
|
|||
import { toast } from "@/lib/toast";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { DocumentUploadRedirectDialog } from "./document-upload-redirect-dialog";
|
||||
import { translate, useT } from "@/i18n";
|
||||
|
||||
const TRAINING_UPLOAD_EXTENSIONS = [
|
||||
".csv",
|
||||
|
|
@ -129,6 +130,7 @@ function normalizeSliceInput(value: string): string | null {
|
|||
}
|
||||
|
||||
export function DatasetSection() {
|
||||
const t = useT();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
dataset,
|
||||
|
|
@ -204,7 +206,9 @@ export function DatasetSection() {
|
|||
setLocalDatasets(response.datasets ?? []);
|
||||
} catch (error) {
|
||||
setLocalError(
|
||||
error instanceof Error ? error.message : "Failed to load local datasets.",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate("studio.dataset.failedToLoadLocalDatasets"),
|
||||
);
|
||||
} finally {
|
||||
setHasLoadedLocalDatasets(true);
|
||||
|
|
@ -398,8 +402,8 @@ export function DatasetSection() {
|
|||
onSuccess(uploaded.stored_path);
|
||||
toast.success(successMessage, { description: uploaded.filename });
|
||||
} catch (error) {
|
||||
toast.error("Upload failed", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
toast.error(t("studio.dataset.uploadFailed"), {
|
||||
description: error instanceof Error ? error.message : t("studio.dataset.unknownError"),
|
||||
});
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
|
|
@ -409,8 +413,10 @@ export function DatasetSection() {
|
|||
const handleDatasetFile = async (file: File) => {
|
||||
const extension = getFileExtension(file.name);
|
||||
if (!TRAINING_UPLOAD_EXTENSION_SET.has(extension)) {
|
||||
toast.error("Unsupported file type", {
|
||||
description: `Upload one ${TRAINING_UPLOAD_LABEL} file.`,
|
||||
toast.error(t("studio.dataset.unsupportedFileType"), {
|
||||
description: t("studio.dataset.uploadOneFileType", {
|
||||
types: TRAINING_UPLOAD_LABEL,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -421,7 +427,7 @@ export function DatasetSection() {
|
|||
return;
|
||||
}
|
||||
|
||||
await handleFileUpload(file, selectLocalDataset, "Dataset uploaded");
|
||||
await handleFileUpload(file, selectLocalDataset, t("studio.dataset.datasetUploaded"));
|
||||
};
|
||||
|
||||
const handleDatasetFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -441,8 +447,8 @@ export function DatasetSection() {
|
|||
if (files.length === 0) return;
|
||||
|
||||
if (files.length > 1) {
|
||||
toast.error("Upload one file at a time", {
|
||||
description: "Training dataset upload accepts a single file.",
|
||||
toast.error(t("studio.dataset.uploadOneFileAtATime"), {
|
||||
description: t("studio.dataset.uploadSingleFileDescription"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -467,7 +473,7 @@ export function DatasetSection() {
|
|||
event.target.value = "";
|
||||
if (!file) return;
|
||||
|
||||
await handleFileUpload(file, setUploadedEvalFile, "Eval dataset uploaded");
|
||||
await handleFileUpload(file, setUploadedEvalFile, t("studio.dataset.evalDatasetUploaded"));
|
||||
};
|
||||
|
||||
const handleOpenLearningRecipes = useCallback(() => {
|
||||
|
|
@ -480,8 +486,8 @@ export function DatasetSection() {
|
|||
<div data-tour="studio-dataset" className="min-w-0">
|
||||
<SectionCard
|
||||
icon={<HugeiconsIcon icon={Database02Icon} className="size-5" />}
|
||||
title="Dataset"
|
||||
description="Select or upload training data"
|
||||
title={t("studio.dataset.title")}
|
||||
description={t("studio.dataset.description")}
|
||||
accent="indigo"
|
||||
className={`dark:shadow-border ${
|
||||
advancedOpen || (datasetSource === "upload" && uploadedFile)
|
||||
|
|
@ -492,9 +498,9 @@ export function DatasetSection() {
|
|||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Choose dataset
|
||||
{t("studio.dataset.chooseDataset")}
|
||||
<span className="rounded-full border border-border/70 bg-muted/40 px-2 py-0.5 text-[10px] font-medium text-foreground/80">
|
||||
{datasetSource === "upload" ? "Local" : "Hugging Face"}
|
||||
{datasetSource === "upload" ? t("studio.dataset.localTab") : "Hugging Face"}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
|
|
@ -509,15 +515,14 @@ export function DatasetSection() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Use the popup tabs to switch between Hugging Face and local
|
||||
recipe outputs.{" "}
|
||||
{t("studio.dataset.chooseDatasetTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/datasets-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -590,8 +595,8 @@ export function DatasetSection() {
|
|||
<ComboboxInput
|
||||
placeholder={
|
||||
pickerTab === "huggingface"
|
||||
? "Search Hugging Face datasets..."
|
||||
: "Search local datasets..."
|
||||
? t("studio.dataset.searchHuggingFaceDatasets")
|
||||
: t("studio.dataset.searchLocalDatasets")
|
||||
}
|
||||
className="w-full min-w-0 overflow-hidden leading-5"
|
||||
showClear={true}
|
||||
|
|
@ -612,16 +617,16 @@ export function DatasetSection() {
|
|||
>
|
||||
<TabsList className=" w-full">
|
||||
<TabsTrigger value="huggingface">Hugging Face</TabsTrigger>
|
||||
<TabsTrigger value="local">Local</TabsTrigger>
|
||||
<TabsTrigger value="local">{t("studio.dataset.localTab")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="huggingface" className="m-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground">
|
||||
<Spinner className="size-4" /> Searching...
|
||||
<Spinner className="size-4" /> {t("studio.dataset.searching")}
|
||||
</div>
|
||||
) : (
|
||||
<ComboboxEmpty>No datasets found</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("studio.dataset.noDatasetsFound")}</ComboboxEmpty>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
|
|
@ -660,7 +665,7 @@ export function DatasetSection() {
|
|||
<TabsContent value="local" className="m-0">
|
||||
{localLoading ? (
|
||||
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground">
|
||||
<Spinner className="size-4" /> Loading local datasets...
|
||||
<Spinner className="size-4" /> {t("studio.dataset.loadingLocalDatasets")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -671,12 +676,12 @@ export function DatasetSection() {
|
|||
<div className="flex w-full flex-col items-center gap-2 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{localDatasets.length === 0
|
||||
? "No local datasets yet."
|
||||
: "No local datasets match search."}
|
||||
? t("studio.dataset.noLocalDatasetsYet")
|
||||
: t("studio.dataset.noLocalDatasetsMatchSearch")}
|
||||
</p>
|
||||
{localDatasets.length === 0 ? (
|
||||
<Button asChild={true} size="sm" variant="outline">
|
||||
<a href="/data-recipes">Open Data Recipes</a>
|
||||
<a href="/data-recipes">{t("studio.dataset.openDataRecipes")}</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -724,17 +729,27 @@ export function DatasetSection() {
|
|||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Get or update token
|
||||
{t("studio.dataset.getOrUpdateToken")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{isCheckingToken && (
|
||||
<p className="text-xs text-muted-foreground">Checking token…</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("studio.dataset.checkingToken")}
|
||||
</p>
|
||||
)}
|
||||
{pickerTab !== activeSourceTab && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Browsing {pickerTab === "local" ? "Local datasets" : "Hugging Face"}.
|
||||
Current selection stays {datasetSource === "upload" ? "Local" : "Hugging Face"}.
|
||||
{t("studio.dataset.browsingSource", {
|
||||
browsing:
|
||||
pickerTab === "local"
|
||||
? t("studio.dataset.localDatasets")
|
||||
: "Hugging Face",
|
||||
current:
|
||||
datasetSource === "upload"
|
||||
? t("studio.dataset.localTab")
|
||||
: "Hugging Face",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -770,10 +785,10 @@ export function DatasetSection() {
|
|||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Local dataset metadata
|
||||
{t("studio.dataset.localDatasetMetadata")}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground/80">
|
||||
Data Recipe output.
|
||||
{t("studio.dataset.dataRecipeOutput")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -781,7 +796,7 @@ export function DatasetSection() {
|
|||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
|
||||
<MetadataRow
|
||||
label="Rows"
|
||||
label={t("studio.dataset.rows")}
|
||||
value={
|
||||
typeof selectedLocalRows === "number"
|
||||
? selectedLocalRows.toLocaleString()
|
||||
|
|
@ -789,7 +804,7 @@ export function DatasetSection() {
|
|||
}
|
||||
/>
|
||||
<MetadataRow
|
||||
label="Columns"
|
||||
label={t("studio.dataset.columns")}
|
||||
value={
|
||||
selectedLocalColumns.length > 0
|
||||
? String(selectedLocalColumns.length)
|
||||
|
|
@ -797,7 +812,7 @@ export function DatasetSection() {
|
|||
}
|
||||
/>
|
||||
<MetadataRow
|
||||
label="Batches"
|
||||
label={t("studio.dataset.batches")}
|
||||
value={
|
||||
typeof selectedLocalMetadata?.num_completed_batches === "number" &&
|
||||
typeof selectedLocalMetadata?.total_num_batches === "number"
|
||||
|
|
@ -806,7 +821,7 @@ export function DatasetSection() {
|
|||
}
|
||||
/>
|
||||
<MetadataRow
|
||||
label="Updated"
|
||||
label={t("studio.dataset.updated")}
|
||||
value={formatUpdatedDate(selectedLocalUpdatedAt)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -817,7 +832,7 @@ export function DatasetSection() {
|
|||
{datasetSource === "upload" && uploadedFile && (
|
||||
<div className="rounded-lg border bg-muted/20 px-3.5 py-3">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Eval dataset
|
||||
{t("studio.dataset.evalDataset")}
|
||||
</p>
|
||||
{uploadedEvalFile ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
|
|
@ -850,10 +865,12 @@ export function DatasetSection() {
|
|||
) : (
|
||||
<HugeiconsIcon icon={CloudUploadIcon} className="size-3.5" />
|
||||
)}
|
||||
{isUploading ? "Uploading..." : "Upload eval file"}
|
||||
{isUploading
|
||||
? t("studio.dataset.uploading")
|
||||
: t("studio.dataset.uploadEvalFile")}
|
||||
</Button>
|
||||
<p className="text-[10px] text-muted-foreground/80">
|
||||
Optional. If not provided, a small portion will be split from the training data.
|
||||
{t("studio.dataset.evalDatasetDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -866,13 +883,13 @@ export function DatasetSection() {
|
|||
icon={ArrowDown01Icon}
|
||||
className={`size-3.5 transition-transform ${advancedOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
Advanced
|
||||
{t("studio.dataset.advanced")}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3 data-[state=open]:overflow-visible">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Target Format
|
||||
{t("studio.dataset.targetFormat")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -886,15 +903,14 @@ export function DatasetSection() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Format of your training data. Auto-detect works for most
|
||||
datasets.{" "}
|
||||
{t("studio.dataset.targetFormatTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/datasets-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -909,18 +925,18 @@ export function DatasetSection() {
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="auto">{t("studio.dataset.auto")}</SelectItem>
|
||||
<SelectItem value="alpaca">Alpaca</SelectItem>
|
||||
<SelectItem value="chatml">ChatML</SelectItem>
|
||||
<SelectItem value="sharegpt">ShareGPT</SelectItem>
|
||||
<SelectItem value="raw">Raw Text</SelectItem>
|
||||
<SelectItem value="raw">{t("studio.dataset.rawText")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Train Split Start
|
||||
{t("studio.dataset.trainSplitStart")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -934,9 +950,7 @@ export function DatasetSection() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Only train on a subset of your training split by
|
||||
specifying a start row index (inclusive, 0-based).
|
||||
Leave empty to start from the first row.
|
||||
{t("studio.dataset.trainSplitStartTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
|
|
@ -954,7 +968,7 @@ export function DatasetSection() {
|
|||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Train Split End
|
||||
{t("studio.dataset.trainSplitEnd")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -968,10 +982,7 @@ export function DatasetSection() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Last row index to include from the training split
|
||||
(inclusive, 0-based). For example, set Start to 0 and
|
||||
End to 99 to train on the first 100 rows. Leave empty
|
||||
to use all remaining rows.
|
||||
{t("studio.dataset.trainSplitEndTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
|
|
@ -980,7 +991,7 @@ export function DatasetSection() {
|
|||
inputMode="numeric"
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="End"
|
||||
placeholder={t("studio.dataset.endPlaceholder")}
|
||||
value={datasetSliceEnd ?? ""}
|
||||
onChange={(e) =>
|
||||
setDatasetSliceEnd(normalizeSliceInput(e.target.value))
|
||||
|
|
@ -1012,17 +1023,19 @@ export function DatasetSection() {
|
|||
{datasetSource === "upload" ? (
|
||||
uploadedFile ? (
|
||||
<>
|
||||
Local dataset
|
||||
{t("studio.dataset.localDataset")}
|
||||
{selectedLocalRows != null
|
||||
? ` / ${selectedLocalRows.toLocaleString()} rows`
|
||||
? t("studio.dataset.localDatasetRows", {
|
||||
count: selectedLocalRows.toLocaleString(),
|
||||
})
|
||||
: ""}
|
||||
</>
|
||||
) : (
|
||||
"Local dataset"
|
||||
t("studio.dataset.localDataset")
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
Hugging Face Dataset
|
||||
{t("studio.dataset.huggingFaceDataset")}
|
||||
{datasetSubset && ` / ${datasetSubset}`}
|
||||
{datasetSplit && ` / ${datasetSplit}`}
|
||||
</>
|
||||
|
|
@ -1035,7 +1048,7 @@ export function DatasetSection() {
|
|||
className="shrink-0 text-xs"
|
||||
onClick={() => clearSelectionForTab(activeSourceTab)}
|
||||
>
|
||||
Clear
|
||||
{t("studio.dataset.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -1058,7 +1071,7 @@ export function DatasetSection() {
|
|||
/>
|
||||
<span className="pointer-events-none min-w-0">
|
||||
<span className="block text-xs font-medium text-foreground">
|
||||
Drop 1 file here or click to upload
|
||||
{t("studio.dataset.dropFileOrClick")}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[10px] text-muted-foreground">
|
||||
{TRAINING_UPLOAD_LABEL}
|
||||
|
|
@ -1080,7 +1093,7 @@ export function DatasetSection() {
|
|||
) : (
|
||||
<HugeiconsIcon icon={CloudUploadIcon} className="size-3.5" />
|
||||
)}
|
||||
{isUploading ? "Uploading..." : "Upload"}
|
||||
{isUploading ? t("studio.dataset.uploading") : t("studio.dataset.upload")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -1090,7 +1103,7 @@ export function DatasetSection() {
|
|||
onClick={() => openPreview()}
|
||||
>
|
||||
<HugeiconsIcon icon={ViewIcon} className="size-3.5" />
|
||||
View dataset
|
||||
{t("studio.dataset.viewDataset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import {
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { translate, useT } from "@/i18n";
|
||||
|
||||
const METHOD_DOTS: Record<string, string> = {
|
||||
qlora: "bg-emerald-400",
|
||||
|
|
@ -85,6 +86,7 @@ function extractParamLabel(id: string): string | null {
|
|||
}
|
||||
|
||||
export function ModelSection() {
|
||||
const t = useT();
|
||||
const gpu = useGpuInfo();
|
||||
|
||||
const {
|
||||
|
|
@ -157,7 +159,7 @@ export function ModelSection() {
|
|||
setLocalModelsError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to load local models",
|
||||
: translate("studio.model.failedToLoadLocalModels"),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
|
|
@ -272,11 +274,11 @@ export function ModelSection() {
|
|||
<div data-tour="studio-model" className="w-full min-w-0">
|
||||
<SectionCard
|
||||
icon={<HugeiconsIcon icon={ChipIcon} className="size-5" />}
|
||||
title="Model"
|
||||
description="Select base model and training method"
|
||||
title={t("studio.model.title")}
|
||||
description={t("studio.model.description")}
|
||||
accent="emerald"
|
||||
featured={true}
|
||||
badge="2x Faster Training"
|
||||
badge={t("studio.model.fasterTrainingBadge")}
|
||||
className="shadow-border ring-border"
|
||||
>
|
||||
<div className="grid min-w-0 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
|
|
@ -285,7 +287,7 @@ export function ModelSection() {
|
|||
className="flex min-w-0 flex-col gap-2"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Local Model
|
||||
{t("studio.model.localModel")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -299,7 +301,7 @@ export function ModelSection() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Path to a locally downloaded model or a custom HF repo.
|
||||
{t("studio.model.localModelTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
|
|
@ -321,7 +323,7 @@ export function ModelSection() {
|
|||
<ComboboxInput
|
||||
placeholder={
|
||||
isLoadingLocalModels
|
||||
? "Scanning local and cached models..."
|
||||
? t("studio.model.scanningLocalAndCachedModels")
|
||||
: "./models/my-model"
|
||||
}
|
||||
className="w-full bg-foreground text-background [&_input]:text-background [&_input]:placeholder:text-background/40 [&_svg]:text-background/50 hover:bg-foreground/90"
|
||||
|
|
@ -342,26 +344,26 @@ export function ModelSection() {
|
|||
>
|
||||
{isLoadingLocalModels ? (
|
||||
<div className="flex items-center justify-center gap-2 py-4 text-xs text-muted-foreground">
|
||||
<Spinner className="size-4" /> Scanning...
|
||||
<Spinner className="size-4" /> {t("studio.model.scanning")}
|
||||
</div>
|
||||
) : localModelsError ? (
|
||||
<div className="px-3 py-2 text-xs text-red-500">
|
||||
{localModelsError}
|
||||
</div>
|
||||
) : (
|
||||
<ComboboxEmpty>No local models found</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("studio.model.noLocalModelsFound")}</ComboboxEmpty>
|
||||
)}
|
||||
<ComboboxList className="p-1">
|
||||
{(id: string) => {
|
||||
const model = localMetaById.get(id);
|
||||
const source =
|
||||
model?.source === "hf_cache"
|
||||
? "HF cache"
|
||||
? t("studio.model.hfCache")
|
||||
: model?.source === "lmstudio"
|
||||
? "LM Studio"
|
||||
: model?.source === "custom"
|
||||
? "Custom Folders"
|
||||
: "Local dir";
|
||||
? t("studio.model.customFolders")
|
||||
: t("studio.model.localDir");
|
||||
return (
|
||||
<ComboboxItem key={id} value={id} className="gap-2">
|
||||
<Tooltip>
|
||||
|
|
@ -389,15 +391,17 @@ export function ModelSection() {
|
|||
</div>
|
||||
{isLoadingLocalModels ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Scanning local models...
|
||||
{t("studio.model.scanningLocalModels")}
|
||||
</p>
|
||||
) : localModelsError ? (
|
||||
<p className="text-[10px] text-red-500">{localModelsError}</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{trainableLocalModels.length > 0
|
||||
? `${trainableLocalModels.length} local/cached models found`
|
||||
: "No local models found. Enter path manually."}
|
||||
? t("studio.model.localModelsFound", {
|
||||
count: trainableLocalModels.length,
|
||||
})
|
||||
: t("studio.model.noLocalModelsFoundManual")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -407,7 +411,7 @@ export function ModelSection() {
|
|||
className="flex min-w-0 flex-col gap-2"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Hugging Face Model
|
||||
{t("studio.model.huggingFaceModel")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -421,14 +425,14 @@ export function ModelSection() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Search Hugging Face models or pick from our recommended list.{" "}
|
||||
{t("studio.model.huggingFaceModelTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/what-model-should-i-use"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.model.readMore")}
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -459,7 +463,7 @@ export function ModelSection() {
|
|||
autoHighlight={true}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Search models..."
|
||||
placeholder={t("studio.model.searchModels")}
|
||||
className="w-full leading-5"
|
||||
>
|
||||
<InputGroupAddon>
|
||||
|
|
@ -469,10 +473,10 @@ export function ModelSection() {
|
|||
<ComboboxContent anchor={comboboxAnchorRef}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground">
|
||||
<Spinner className="size-4" /> Searching…
|
||||
<Spinner className="size-4" /> {t("studio.model.searching")}
|
||||
</div>
|
||||
) : (
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("studio.model.noModelsFound")}</ComboboxEmpty>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
|
|
@ -510,10 +514,18 @@ export function ModelSection() {
|
|||
gpu.available && (
|
||||
<span className="block text-[10px] mt-1">
|
||||
{exceeds
|
||||
? `Needs ~${vramEst}GB VRAM (GPU: ${gpu.memoryTotalGb}GB)`
|
||||
? t("studio.model.needsVram", {
|
||||
vram: vramEst,
|
||||
gpu: gpu.memoryTotalGb,
|
||||
})
|
||||
: fitStatus === "tight"
|
||||
? `~${vramEst}GB VRAM (tight fit on ${gpu.memoryTotalGb}GB)`
|
||||
: `~${vramEst}GB VRAM`}
|
||||
? t("studio.model.tightVram", {
|
||||
vram: vramEst,
|
||||
gpu: gpu.memoryTotalGb,
|
||||
})
|
||||
: t("studio.model.vramEstimate", {
|
||||
vram: vramEst,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</TooltipContent>
|
||||
|
|
@ -556,7 +568,7 @@ export function ModelSection() {
|
|||
className="flex min-w-0 flex-col gap-2"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Method
|
||||
{t("studio.model.method")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -570,17 +582,14 @@ export function ModelSection() {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
QLoRA uses 4-bit quantization for lowest VRAM. LoRA uses
|
||||
16-bit. Full updates all weights. CPT (Continued Pretraining)
|
||||
trains on raw text to adapt the model to a new domain without
|
||||
chat formatting.{" "}
|
||||
{t("studio.model.methodTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.model.readMore")}
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -617,7 +626,7 @@ export function ModelSection() {
|
|||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${METHOD_DOTS.full}`}
|
||||
/>
|
||||
Full Fine-tune
|
||||
{t("studio.model.fullFineTune")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="cpt">
|
||||
|
|
@ -625,7 +634,7 @@ export function ModelSection() {
|
|||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${METHOD_DOTS.cpt}`}
|
||||
/>
|
||||
Continued Pretraining
|
||||
{t("studio.model.continuedPretraining")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
|
|
@ -634,7 +643,7 @@ export function ModelSection() {
|
|||
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Hugging Face Token (Optional)
|
||||
{t("studio.model.huggingFaceTokenOptional")}
|
||||
</span>
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
|
|
@ -659,12 +668,14 @@ export function ModelSection() {
|
|||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Get or update token
|
||||
{t("studio.model.getOrUpdateToken")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{isCheckingToken && (
|
||||
<p className="text-xs text-muted-foreground">Checking token…</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("studio.model.checkingToken")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ import {
|
|||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type ReactElement, type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { useT } from "@/i18n";
|
||||
|
||||
type StudioT = ReturnType<typeof useT>;
|
||||
|
||||
function Row({
|
||||
label,
|
||||
|
|
@ -126,7 +129,46 @@ function SliderRow({
|
|||
);
|
||||
}
|
||||
|
||||
function formatOptimizerLabel(
|
||||
value: string,
|
||||
fallback: string,
|
||||
t: StudioT,
|
||||
): string {
|
||||
switch (value) {
|
||||
case "adamw_8bit":
|
||||
return t("studio.params.optimizerOptions.adamw8bit");
|
||||
case "paged_adamw_8bit":
|
||||
return t("studio.params.optimizerOptions.pagedAdamw8bit");
|
||||
case "adamw_bnb_8bit":
|
||||
return t("studio.params.optimizerOptions.adamwBnb8bit");
|
||||
case "paged_adamw_32bit":
|
||||
return t("studio.params.optimizerOptions.pagedAdamw32bit");
|
||||
case "adamw_torch":
|
||||
return t("studio.params.optimizerOptions.adamwTorch");
|
||||
case "adamw_torch_fused":
|
||||
return t("studio.params.optimizerOptions.adamwTorchFused");
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSchedulerLabel(
|
||||
value: string,
|
||||
fallback: string,
|
||||
t: StudioT,
|
||||
): string {
|
||||
switch (value) {
|
||||
case "linear":
|
||||
return t("studio.params.lrSchedulerOptions.linear");
|
||||
case "cosine":
|
||||
return t("studio.params.lrSchedulerOptions.cosine");
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function ParamsSection(): ReactElement {
|
||||
const t = useT();
|
||||
const store = useTrainingConfigStore();
|
||||
const platformDeviceType = usePlatformStore((s) => s.deviceType);
|
||||
const isLora = isAdapterMethod(store.trainingMethod);
|
||||
|
|
@ -171,8 +213,8 @@ export function ParamsSection(): ReactElement {
|
|||
<div data-tour="studio-params" className="min-w-0">
|
||||
<SectionCard
|
||||
icon={<HugeiconsIcon icon={Settings04Icon} className="size-5" />}
|
||||
title="Parameters"
|
||||
description="Configure training hyperparameters"
|
||||
title={t("studio.params.title")}
|
||||
description={t("studio.params.description")}
|
||||
accent="orange"
|
||||
className={`${needsExpandedHeight
|
||||
? "min-h-studio-config-column"
|
||||
|
|
@ -187,7 +229,7 @@ export function ParamsSection(): ReactElement {
|
|||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
{useEpochs ? "Epochs" : "Max Steps"}
|
||||
{useEpochs ? t("studio.params.epochs") : t("studio.params.maxSteps")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -202,15 +244,15 @@ export function ParamsSection(): ReactElement {
|
|||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{useEpochs
|
||||
? "Number of full passes over the dataset."
|
||||
: "Override total optimizer steps."}{" "}
|
||||
? t("studio.params.epochsTooltip")
|
||||
: t("studio.params.maxStepsTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -221,7 +263,7 @@ export function ParamsSection(): ReactElement {
|
|||
onClick={toggleUseEpochs}
|
||||
className="text-xs text-primary underline cursor-pointer"
|
||||
>
|
||||
{useEpochs ? "Use Max Steps" : "Use Epochs"}
|
||||
{useEpochs ? t("studio.params.useMaxSteps") : t("studio.params.useEpochs")}
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
|
|
@ -261,8 +303,8 @@ export function ParamsSection(): ReactElement {
|
|||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{useEpochs
|
||||
? "Each epoch is one full pass over your dataset."
|
||||
: "Limits training to a fixed number of optimizer steps."}
|
||||
? t("studio.params.epochsDescription")
|
||||
: t("studio.params.maxStepsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -270,7 +312,7 @@ export function ParamsSection(): ReactElement {
|
|||
{/* Context length */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Context Length
|
||||
{t("studio.params.contextLength")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -284,14 +326,14 @@ export function ParamsSection(): ReactElement {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Maximum number of tokens per training sample.{" "}
|
||||
{t("studio.params.contextLengthTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -330,7 +372,7 @@ export function ParamsSection(): ReactElement {
|
|||
}}
|
||||
/>
|
||||
<ComboboxContent anchor={ctxAnchorRef}>
|
||||
<ComboboxEmpty>Enter a custom value</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("studio.params.customContextLength")}</ComboboxEmpty>
|
||||
<ComboboxList className="p-1">
|
||||
{(id: string) => (
|
||||
<ComboboxItem key={id} value={id} className="font-mono">
|
||||
|
|
@ -342,14 +384,14 @@ export function ParamsSection(): ReactElement {
|
|||
</Combobox>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Max sequence length for training samples
|
||||
{t("studio.params.contextLengthDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Learning Rate */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Learning Rate
|
||||
{t("studio.params.learningRate")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -363,15 +405,14 @@ export function ParamsSection(): ReactElement {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Step size for weight updates. Lower values train slower but more
|
||||
stably.{" "}
|
||||
{t("studio.params.learningRateTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -384,7 +425,7 @@ export function ParamsSection(): ReactElement {
|
|||
className="w-full font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Recommended: 2e-4 for LoRA, 5e-5 for CPT, 2e-5 for full fine-tune
|
||||
{t("studio.params.learningRateDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -392,7 +433,7 @@ export function ParamsSection(): ReactElement {
|
|||
{isCpt && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Embedding Learning Rate
|
||||
{t("studio.params.embeddingLearningRate")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -406,12 +447,7 @@ export function ParamsSection(): ReactElement {
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Only used when CPT is training <code>embed_tokens</code>.
|
||||
Embeddings are easier to destabilize than LoRA weights, so
|
||||
they usually need a smaller LR. Leave blank to use
|
||||
<code>lr/10</code>; typical working range is 2x-10x smaller
|
||||
than the main LR. Increase it only if vocabulary or
|
||||
domain-token adaptation is too slow.
|
||||
{t("studio.params.embeddingLearningRateTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
|
|
@ -434,8 +470,7 @@ export function ParamsSection(): ReactElement {
|
|||
className="w-full font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Leave blank to use lr/10 (recommended). Typical range is
|
||||
2x-10x smaller than the main learning rate.
|
||||
{t("studio.params.embeddingLearningRateDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -448,22 +483,22 @@ export function ParamsSection(): ReactElement {
|
|||
icon={ArrowDown01Icon}
|
||||
className={`size-3.5 transition-transform ${loraOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
LoRA Settings
|
||||
{t("studio.params.loraSettings")}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3 data-[state=open]:overflow-visible">
|
||||
<div className="pt-1.5 flex flex-col gap-4">
|
||||
<SliderRow
|
||||
label="Rank"
|
||||
label={t("studio.params.rank")}
|
||||
tooltip={
|
||||
<>
|
||||
Dimension of the low-rank matrices. Higher = more capacity.{" "}
|
||||
{t("studio.params.rankTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -474,17 +509,17 @@ export function ParamsSection(): ReactElement {
|
|||
step={4}
|
||||
/>
|
||||
<SliderRow
|
||||
label="Alpha"
|
||||
label={t("studio.params.alpha")}
|
||||
tooltip={
|
||||
<>
|
||||
Scaling factor for LoRA updates. Usually 2x rank.{" "}
|
||||
{t("studio.params.alphaTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -495,17 +530,17 @@ export function ParamsSection(): ReactElement {
|
|||
step={4}
|
||||
/>
|
||||
<SliderRow
|
||||
label="Dropout"
|
||||
label={t("studio.params.dropout")}
|
||||
tooltip={
|
||||
<>
|
||||
Dropout probability for LoRA layers to reduce overfitting.{" "}
|
||||
{t("studio.params.dropoutTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -524,25 +559,25 @@ export function ParamsSection(): ReactElement {
|
|||
[
|
||||
[
|
||||
"finetuneVisionLayers",
|
||||
"Vision layers",
|
||||
t("studio.params.visionLayers"),
|
||||
store.finetuneVisionLayers,
|
||||
store.setFinetuneVisionLayers,
|
||||
],
|
||||
[
|
||||
"finetuneLanguageLayers",
|
||||
"Language layers",
|
||||
t("studio.params.languageLayers"),
|
||||
store.finetuneLanguageLayers,
|
||||
store.setFinetuneLanguageLayers,
|
||||
],
|
||||
[
|
||||
"finetuneAttentionModules",
|
||||
"Attention modules",
|
||||
t("studio.params.attentionModules"),
|
||||
store.finetuneAttentionModules,
|
||||
store.setFinetuneAttentionModules,
|
||||
],
|
||||
[
|
||||
"finetuneMLPModules",
|
||||
"MLP modules",
|
||||
t("studio.params.mlpModules"),
|
||||
store.finetuneMLPModules,
|
||||
store.setFinetuneMLPModules,
|
||||
],
|
||||
|
|
@ -571,7 +606,7 @@ export function ParamsSection(): ReactElement {
|
|||
{!showVisionLora && (
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Target Modules
|
||||
{t("studio.params.targetModules")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(isCpt ? CPT_TARGET_MODULES : TARGET_MODULES).map((mod) => {
|
||||
|
|
@ -606,14 +641,14 @@ export function ParamsSection(): ReactElement {
|
|||
[
|
||||
{
|
||||
value: "lora",
|
||||
label: "Enable LoRA",
|
||||
desc: "Train with LoRA",
|
||||
label: t("studio.params.enableLora"),
|
||||
desc: t("studio.params.trainWithLora"),
|
||||
},
|
||||
{ value: "rslora", label: "RS-LoRA", desc: "Stable Rank" },
|
||||
{ value: "rslora", label: "RS-LoRA", desc: t("studio.params.stableRank") },
|
||||
{
|
||||
value: "loftq",
|
||||
label: "LoftQ",
|
||||
desc: "Memory Efficient",
|
||||
desc: t("studio.params.memoryEfficient"),
|
||||
},
|
||||
] as const
|
||||
).map((opt) => (
|
||||
|
|
@ -645,7 +680,7 @@ export function ParamsSection(): ReactElement {
|
|||
icon={ArrowDown01Icon}
|
||||
className={`size-3.5 transition-transform ${hyperOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
Training Hyperparameters
|
||||
{t("studio.params.trainingHyperparameters")}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3 data-[state=open]:overflow-visible">
|
||||
<Tabs defaultValue="optimization" className="w-full">
|
||||
|
|
@ -654,19 +689,19 @@ export function ParamsSection(): ReactElement {
|
|||
value="optimization"
|
||||
className="flex-1 !corner-squircle text-xs cursor-pointer"
|
||||
>
|
||||
Optimization
|
||||
{t("studio.params.optimization")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="schedule"
|
||||
className="flex-1 text-xs cursor-pointer"
|
||||
>
|
||||
Schedule
|
||||
{t("studio.params.schedule")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="memory"
|
||||
className="flex-1 text-xs cursor-pointer"
|
||||
>
|
||||
Memory
|
||||
{t("studio.params.memory")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -675,18 +710,17 @@ export function ParamsSection(): ReactElement {
|
|||
className="mt-3 flex flex-col gap-3"
|
||||
>
|
||||
<Row
|
||||
label="Optimizer"
|
||||
label={t("studio.params.optimizer")}
|
||||
tooltip={
|
||||
<>
|
||||
Optimization algorithm. 8-bit variants reduce memory usage.
|
||||
Fused is recommended for vision models.{" "}
|
||||
{t("studio.params.optimizerTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -704,25 +738,24 @@ export function ParamsSection(): ReactElement {
|
|||
key={opt.value}
|
||||
value={opt.value}
|
||||
>
|
||||
{opt.label}
|
||||
{formatOptimizerLabel(opt.value, opt.label, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Row>
|
||||
<Row
|
||||
label="LR scheduler"
|
||||
label={t("studio.params.lrScheduler")}
|
||||
tooltip={
|
||||
<>
|
||||
How the learning rate changes over training. Linear decays
|
||||
steadily; cosine decays in a curve.{" "}
|
||||
{t("studio.params.lrSchedulerTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -740,24 +773,24 @@ export function ParamsSection(): ReactElement {
|
|||
key={opt.value}
|
||||
value={opt.value}
|
||||
>
|
||||
{opt.label}
|
||||
{formatSchedulerLabel(opt.value, opt.label, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Row>
|
||||
<SliderRow
|
||||
label="Batch Size"
|
||||
label={t("studio.params.batchSize")}
|
||||
tooltip={
|
||||
<>
|
||||
Samples processed per step. Higher uses more VRAM.{" "}
|
||||
{t("studio.params.batchSizeTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -768,17 +801,17 @@ export function ParamsSection(): ReactElement {
|
|||
step={1}
|
||||
/>
|
||||
<SliderRow
|
||||
label="Grad Accum"
|
||||
label={t("studio.params.gradAccum")}
|
||||
tooltip={
|
||||
<>
|
||||
Simulates larger batch sizes without extra VRAM.{" "}
|
||||
{t("studio.params.gradAccumTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -789,17 +822,17 @@ export function ParamsSection(): ReactElement {
|
|||
step={1}
|
||||
/>
|
||||
<Row
|
||||
label="Weight Decay"
|
||||
label={t("studio.params.weightDecay")}
|
||||
tooltip={
|
||||
<>
|
||||
L2 regularization to prevent overfitting.{" "}
|
||||
{t("studio.params.weightDecayTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -821,17 +854,17 @@ export function ParamsSection(): ReactElement {
|
|||
className="mt-3 flex flex-col gap-3"
|
||||
>
|
||||
<SliderRow
|
||||
label="Warmup Steps"
|
||||
label={t("studio.params.warmupSteps")}
|
||||
tooltip={
|
||||
<>
|
||||
Gradually increase LR at training start for stability.{" "}
|
||||
{t("studio.params.warmupStepsTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -843,18 +876,17 @@ export function ParamsSection(): ReactElement {
|
|||
/>
|
||||
{!useEpochs && (
|
||||
<SliderRow
|
||||
label="Epochs"
|
||||
label={t("studio.params.epochs")}
|
||||
tooltip={
|
||||
<>
|
||||
Number of full passes over the dataset. Set 0 to run by
|
||||
max steps.{" "}
|
||||
{t("studio.params.scheduleEpochsTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -866,17 +898,17 @@ export function ParamsSection(): ReactElement {
|
|||
/>
|
||||
)}
|
||||
<Row
|
||||
label="Save Steps"
|
||||
label={t("studio.params.saveSteps")}
|
||||
tooltip={
|
||||
<>
|
||||
Save a checkpoint every N steps. 0 to disable.{" "}
|
||||
{t("studio.params.saveStepsTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -889,8 +921,8 @@ export function ParamsSection(): ReactElement {
|
|||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label="Eval Steps"
|
||||
tooltip="Fraction of total training steps between evaluations (0-1). Set to 0 to disable evaluation. E.g. 0.01 = evaluate every 1% of steps."
|
||||
label={t("studio.params.evalSteps")}
|
||||
tooltip={t("studio.params.evalStepsTooltip")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
|
|
@ -902,7 +934,7 @@ export function ParamsSection(): ReactElement {
|
|||
className="w-28 font-mono"
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Seed" tooltip="Random seed for reproducibility.">
|
||||
<Row label={t("studio.params.seed")} tooltip={t("studio.params.seedTooltip")}>
|
||||
<Input
|
||||
type="number"
|
||||
value={store.randomSeed}
|
||||
|
|
@ -916,17 +948,17 @@ export function ParamsSection(): ReactElement {
|
|||
|
||||
<TabsContent value="memory" className="mt-3 flex flex-col gap-3">
|
||||
<Row
|
||||
label="Grad Checkpoint"
|
||||
label={t("studio.params.gradCheckpoint")}
|
||||
tooltip={
|
||||
<>
|
||||
Trade compute for memory by recomputing activations.{" "}
|
||||
{t("studio.params.gradCheckpointTooltip")}{" "}
|
||||
<a
|
||||
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Read more
|
||||
{t("studio.params.readMore")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
|
|
@ -941,8 +973,8 @@ export function ParamsSection(): ReactElement {
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="true">Standard</SelectItem>
|
||||
<SelectItem value="none">{t("studio.params.none")}</SelectItem>
|
||||
<SelectItem value="true">{t("studio.params.standard")}</SelectItem>
|
||||
{platformDeviceType === "mac" ? (
|
||||
<SelectItem value="mlx">MLX</SelectItem>
|
||||
) : (
|
||||
|
|
@ -962,7 +994,7 @@ export function ParamsSection(): ReactElement {
|
|||
htmlFor="packing"
|
||||
className="text-xs cursor-pointer text-muted-foreground"
|
||||
>
|
||||
Enable packing
|
||||
{t("studio.params.enablePacking")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -977,7 +1009,7 @@ export function ParamsSection(): ReactElement {
|
|||
htmlFor="trainOnCompletions"
|
||||
className="text-xs cursor-pointer text-muted-foreground"
|
||||
>
|
||||
Assistant completions only
|
||||
{t("studio.params.assistantCompletionsOnly")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,6 @@
|
|||
|
||||
import type { TrainingPhase } from "@/features/training";
|
||||
|
||||
export const phaseLabel: Record<TrainingPhase, string> = {
|
||||
idle: "Idle",
|
||||
downloading_model: "Downloading model",
|
||||
downloading_dataset: "Downloading dataset",
|
||||
loading_model: "Loading model",
|
||||
loading_dataset: "Loading dataset",
|
||||
configuring: "Configuring",
|
||||
training: "Training",
|
||||
completed: "Completed",
|
||||
error: "Error",
|
||||
stopped: "Stopped",
|
||||
};
|
||||
|
||||
export const phaseColors: Record<TrainingPhase, string> = {
|
||||
idle: "bg-muted text-muted-foreground",
|
||||
downloading_model:
|
||||
|
|
|
|||
|
|
@ -48,14 +48,27 @@ import {
|
|||
formatDuration,
|
||||
formatNumber,
|
||||
phaseColors,
|
||||
phaseLabel,
|
||||
} from "./progress-section-lib";
|
||||
import { useT, type TranslationKey } from "@/i18n";
|
||||
|
||||
type ConfigGroup = {
|
||||
section: string;
|
||||
rows: [string, string | number | null | undefined][];
|
||||
};
|
||||
|
||||
const phaseLabelKeys = {
|
||||
idle: "studio.progress.phase.idle",
|
||||
downloading_model: "studio.progress.phase.downloadingModel",
|
||||
downloading_dataset: "studio.progress.phase.downloadingDataset",
|
||||
loading_model: "studio.progress.phase.loadingModel",
|
||||
loading_dataset: "studio.progress.phase.loadingDataset",
|
||||
configuring: "studio.progress.phase.configuring",
|
||||
training: "studio.progress.phase.training",
|
||||
completed: "studio.progress.phase.completed",
|
||||
error: "studio.progress.phase.error",
|
||||
stopped: "studio.progress.phase.stopped",
|
||||
} satisfies Record<TrainingViewData["phase"], TranslationKey>;
|
||||
|
||||
function configRow(
|
||||
label: string,
|
||||
value: string | number | null | undefined,
|
||||
|
|
@ -86,6 +99,7 @@ export function ProgressSection({
|
|||
isHistorical = false,
|
||||
configOverride,
|
||||
}: ProgressSectionProps): ReactElement {
|
||||
const t = useT();
|
||||
const navigate = useNavigate();
|
||||
const trainingMethodLabel = getTrainingMethodLabel(data.trainingMethod);
|
||||
|
||||
|
|
@ -171,15 +185,15 @@ export function ProgressSection({
|
|||
|
||||
const configItems: ConfigGroup[] = [
|
||||
{
|
||||
section: "Hyperparams",
|
||||
section: t("studio.progress.hyperparams"),
|
||||
rows: [
|
||||
configRow("Epochs", cfgEpochs),
|
||||
configRow("Batch size", cfgBatchSize),
|
||||
configRow("Learning rate", cfgLearningRate),
|
||||
configRow("Optimizer", optimizerLabel),
|
||||
configRow("Max steps", cfgMaxSteps),
|
||||
configRow("Context length", cfgContextLength),
|
||||
configRow("Warmup steps", cfgWarmupSteps),
|
||||
configRow(t("studio.progress.epochs"), cfgEpochs),
|
||||
configRow(t("studio.progress.batchSize"), cfgBatchSize),
|
||||
configRow(t("studio.progress.learningRate"), cfgLearningRate),
|
||||
configRow(t("studio.progress.optimizer"), optimizerLabel),
|
||||
configRow(t("studio.progress.maxSteps"), cfgMaxSteps),
|
||||
configRow(t("studio.progress.contextLength"), cfgContextLength),
|
||||
configRow(t("studio.progress.warmupSteps"), cfgWarmupSteps),
|
||||
],
|
||||
},
|
||||
...(data.trainingMethod !== "full"
|
||||
|
|
@ -187,10 +201,10 @@ export function ProgressSection({
|
|||
{
|
||||
section: "LoRA",
|
||||
rows: [
|
||||
configRow("Rank", cfgLoraRank),
|
||||
configRow("Alpha", cfgLoraAlpha),
|
||||
configRow("Dropout", cfgLoraDropout),
|
||||
configRow("Variant", cfgLoraVariant),
|
||||
configRow(t("studio.progress.rank"), cfgLoraRank),
|
||||
configRow(t("studio.progress.alpha"), cfgLoraAlpha),
|
||||
configRow(t("studio.progress.dropout"), cfgLoraDropout),
|
||||
configRow(t("studio.progress.variant"), cfgLoraVariant),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
@ -200,8 +214,8 @@ export function ProgressSection({
|
|||
return (
|
||||
<SectionCard
|
||||
icon={<HugeiconsIcon icon={ChartAverageIcon} className="size-5" />}
|
||||
title="Training Progress"
|
||||
description={data.message || "Live training metrics"}
|
||||
title={t("studio.progress.title")}
|
||||
description={data.message || t("studio.progress.liveMetrics")}
|
||||
accent="emerald"
|
||||
className="shadow-border border border-border/60 bg-card/90 ring-0 backdrop-blur-sm"
|
||||
headerAction={
|
||||
|
|
@ -225,20 +239,25 @@ export function ProgressSection({
|
|||
<span
|
||||
className={`rounded-full px-2.5 py-1 text-[10px] font-semibold ${phaseColors[data.phase]}`}
|
||||
>
|
||||
{phaseLabel[data.phase]}
|
||||
{t(phaseLabelKeys[data.phase])}
|
||||
</span>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||
Epoch {formatNumber(data.currentEpoch, 2)}
|
||||
{t("studio.progress.epoch", {
|
||||
value: formatNumber(data.currentEpoch, 2),
|
||||
})}
|
||||
</span>
|
||||
<span className="rounded-full border border-border/60 px-2.5 py-1 text-[10px] font-medium tabular-nums text-muted-foreground">
|
||||
{pct}% complete
|
||||
{t("studio.progress.percentComplete", { percent: pct })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Step {data.currentStep} / {data.totalSteps || "--"}
|
||||
{t("studio.progress.stepProgress", {
|
||||
current: data.currentStep,
|
||||
total: data.totalSteps || "--",
|
||||
})}
|
||||
</span>
|
||||
<span>{pct}%</span>
|
||||
</div>
|
||||
|
|
@ -261,33 +280,37 @@ export function ProgressSection({
|
|||
|
||||
<div className="grid gap-x-4 gap-y-3 pt-1 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<MetricStat
|
||||
label="Loss"
|
||||
label={t("studio.progress.loss")}
|
||||
valueClassName="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{stoppedLoss != null ? stoppedLoss.toFixed(4) : "--"}
|
||||
</MetricStat>
|
||||
<MetricStat label="LR">{stoppedLr != null ? stoppedLr.toExponential(2) : "--"}</MetricStat>
|
||||
<MetricStat label="Grad Norm">
|
||||
<MetricStat label={t("studio.progress.lr")}>{stoppedLr != null ? stoppedLr.toExponential(2) : "--"}</MetricStat>
|
||||
<MetricStat label={t("studio.progress.gradNorm")}>
|
||||
{formatNumber(stoppedGradNorm, 3)}
|
||||
</MetricStat>
|
||||
<MetricStat label="Model" valueClassName="truncate">
|
||||
<MetricStat label={t("studio.progress.model")} valueClassName="truncate">
|
||||
{data.modelName || "--"}
|
||||
</MetricStat>
|
||||
<MetricStat label="Method">
|
||||
<MetricStat label={t("studio.progress.method")}>
|
||||
{trainingMethodLabel}
|
||||
</MetricStat>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>Elapsed: {formatDuration(elapsed)}</span>
|
||||
{!isHistorical && <span>ETA: {formatDuration(eta)}</span>}
|
||||
<span>{t("studio.progress.elapsed", { value: formatDuration(elapsed) })}</span>
|
||||
{!isHistorical && (
|
||||
<span>{t("studio.progress.eta", { value: formatDuration(eta) })}</span>
|
||||
)}
|
||||
<span>
|
||||
{stepsPerSecond == null
|
||||
? "-- steps/s"
|
||||
: `${stepsPerSecond.toFixed(2)} steps/s`}
|
||||
? t("studio.progress.noStepsPerSecond")
|
||||
: t("studio.progress.stepsPerSecond", {
|
||||
value: stepsPerSecond.toFixed(2),
|
||||
})}
|
||||
</span>
|
||||
{data.currentNumTokens != null && (
|
||||
<span>Tokens: {data.currentNumTokens}</span>
|
||||
<span>{t("studio.progress.tokens", { value: data.currentNumTokens })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -305,19 +328,22 @@ function LiveGpuPanel({
|
|||
}: {
|
||||
isTrainingRunning: boolean;
|
||||
}): ReactElement {
|
||||
const t = useT();
|
||||
const gpu = useGpuUtilization(isTrainingRunning);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
GPU Monitor
|
||||
{t("studio.progress.gpuMonitor")}
|
||||
</p>
|
||||
<span className="text-[11px] text-muted-foreground">Live</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("studio.progress.live")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<GpuStat
|
||||
label="Utilization"
|
||||
label={t("studio.progress.utilization")}
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={DashboardSpeed01Icon}
|
||||
|
|
@ -332,7 +358,7 @@ function LiveGpuPanel({
|
|||
pct={gpu.gpu_utilization_pct ?? 0}
|
||||
/>
|
||||
<GpuStat
|
||||
label="Temperature"
|
||||
label={t("studio.progress.temperature")}
|
||||
icon={
|
||||
<HugeiconsIcon icon={TemperatureIcon} className="size-3.5" />
|
||||
}
|
||||
|
|
@ -343,7 +369,7 @@ function LiveGpuPanel({
|
|||
max={100}
|
||||
/>
|
||||
<GpuStat
|
||||
label="VRAM"
|
||||
label={t("studio.progress.vram")}
|
||||
icon={<HugeiconsIcon icon={RamMemoryIcon} className="size-3.5" />}
|
||||
value={
|
||||
gpu.vram_used_gb != null && gpu.vram_total_gb != null
|
||||
|
|
@ -353,7 +379,7 @@ function LiveGpuPanel({
|
|||
pct={gpu.vram_utilization_pct ?? 0}
|
||||
/>
|
||||
<GpuStat
|
||||
label="Power"
|
||||
label={t("studio.progress.power")}
|
||||
icon={<HugeiconsIcon icon={ZapIcon} className="size-3.5" />}
|
||||
value={
|
||||
gpu.power_draw_w != null
|
||||
|
|
@ -417,6 +443,7 @@ function ConfigPopoverButton({
|
|||
}: {
|
||||
configItems: ConfigGroup[];
|
||||
}): ReactElement {
|
||||
const t = useT();
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild={true}>
|
||||
|
|
@ -425,14 +452,14 @@ function ConfigPopoverButton({
|
|||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Open training config"
|
||||
aria-label={t("studio.progress.openConfig")}
|
||||
>
|
||||
<HugeiconsIcon icon={Notebook01Icon} className="size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="end">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold">Training Config</p>
|
||||
<p className="text-xs font-semibold">{t("studio.progress.configLabel")}</p>
|
||||
{configItems.map((group) => (
|
||||
<div key={group.section} className="flex flex-col gap-1">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
|
|
@ -469,6 +496,7 @@ function TrainingHeaderActions({
|
|||
stopDialogOpen: boolean;
|
||||
stopRequested: boolean;
|
||||
}): ReactElement {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ConfigPopoverButton configItems={configItems} />
|
||||
|
|
@ -486,25 +514,25 @@ function TrainingHeaderActions({
|
|||
disabled={!isTrainingRunning || stopRequested}
|
||||
>
|
||||
<HugeiconsIcon icon={StopIcon} className="size-3" />
|
||||
{stopRequested ? "Stopping…" : "Stop"}
|
||||
{stopRequested ? t("studio.training.stopping") : t("studio.training.stopAction")}
|
||||
</Button>
|
||||
<AlertDialogContent overlayClassName="bg-background/40 supports-backdrop-filter:backdrop-blur-[1px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Stop Training</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("studio.training.stopTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Choose how you want to stop the current training run.
|
||||
{t("studio.training.stopDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Continue Training</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t("studio.training.continueAction")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => onRequestStop(false)}
|
||||
>
|
||||
Cancel Training
|
||||
{t("studio.training.cancelAction")}
|
||||
</AlertDialogAction>
|
||||
<AlertDialogAction onClick={() => onRequestStop(true)}>
|
||||
Stop and Save
|
||||
{t("studio.training.stopAndSave")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
@ -522,6 +550,7 @@ function MilestoneCallout({
|
|||
showHalfwayHint: boolean;
|
||||
onCompareInChat: () => Promise<void>;
|
||||
}): ReactElement | null {
|
||||
const t = useT();
|
||||
if (!(showHalfwayHint || showCompletedHint)) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -532,7 +561,7 @@ function MilestoneCallout({
|
|||
<div className="min-w-0">
|
||||
{!showCompletedHint && (
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Milestone
|
||||
{t("studio.training.milestone")}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
|
|
@ -542,8 +571,8 @@ function MilestoneCallout({
|
|||
)}
|
||||
>
|
||||
{showCompletedHint
|
||||
? "Training done. Next step: compare base vs fine-tuned outputs."
|
||||
: "Halfway done. Training is past 50%."}
|
||||
? t("studio.training.doneNextStep")
|
||||
: t("studio.training.halfwayDone")}
|
||||
</p>
|
||||
</div>
|
||||
{!showCompletedHint && (
|
||||
|
|
@ -555,10 +584,10 @@ function MilestoneCallout({
|
|||
{showCompletedHint && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button size="xs" onClick={onCompareInChat}>
|
||||
Compare in Chat
|
||||
{t("studio.training.compareInChat")}
|
||||
</Button>
|
||||
<Button asChild={true} size="xs" variant="outline">
|
||||
<Link to="/export">Export Model</Link>
|
||||
<Link to="/export">{t("studio.training.exportModel")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -28,10 +28,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
|
|||
import { useRef } from "react";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
|
||||
const chartConfig = {
|
||||
loss: { label: "Loss", color: "#3b82f6" },
|
||||
} satisfies ChartConfig;
|
||||
import { useT } from "@/i18n";
|
||||
|
||||
const placeholderData = [
|
||||
{ step: 0, loss: 2.5 },
|
||||
|
|
@ -43,6 +40,10 @@ const placeholderData = [
|
|||
];
|
||||
|
||||
export function TrainingSection() {
|
||||
const t = useT();
|
||||
const chartConfig = {
|
||||
loss: { label: t("studio.charts.loss"), color: "#3b82f6" },
|
||||
} satisfies ChartConfig;
|
||||
const store = useTrainingConfigStore();
|
||||
const { isStarting, startError, startTrainingRun } = useTrainingActions();
|
||||
const isLoadingModel = store.isLoadingModelDefaults || store.isCheckingVision;
|
||||
|
|
@ -65,16 +66,16 @@ export function TrainingSection() {
|
|||
try {
|
||||
const config = parseYamlConfig(reader.result as string);
|
||||
store.applyConfigPatch(config);
|
||||
toast.success("Config loaded", { description: file.name });
|
||||
toast.success(t("studio.training.configLoaded"), { description: file.name });
|
||||
} catch (err) {
|
||||
toast.error("Failed to load config", {
|
||||
toast.error(t("studio.training.failedToLoadConfig"), {
|
||||
description:
|
||||
err instanceof Error ? err.message : "Invalid YAML file",
|
||||
err instanceof Error ? err.message : t("studio.training.invalidYamlFile"),
|
||||
});
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to read file");
|
||||
toast.error(t("studio.training.failedToReadFile"));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
|
@ -98,15 +99,15 @@ export function TrainingSection() {
|
|||
|
||||
const handleResetConfig = () => {
|
||||
store.resetToModelDefaults();
|
||||
toast.success("Parameters reset to model defaults");
|
||||
toast.success(t("studio.training.parametersReset"));
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-tour="studio-training" className="min-w-0">
|
||||
<SectionCard
|
||||
icon={<HugeiconsIcon icon={ChartAverageIcon} className="size-5" />}
|
||||
title="Training"
|
||||
description="Monitor and control training"
|
||||
title={t("studio.training.title")}
|
||||
description={t("studio.training.description")}
|
||||
accent="blue"
|
||||
className={hasMessage ? "min-h-studio-config-column" : "h-studio-config-column"}
|
||||
>
|
||||
|
|
@ -147,10 +148,10 @@ export function TrainingSection() {
|
|||
className="size-5 text-muted-foreground/50"
|
||||
/>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
No training data yet
|
||||
{t("studio.training.chartNoDataTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Start training to see loss progress
|
||||
{t("studio.training.chartNoDataDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -163,7 +164,13 @@ export function TrainingSection() {
|
|||
disabled={isStarting || isIncompatible || store.isCheckingDataset || isLoadingModel || !configValidation.ok}
|
||||
>
|
||||
<HugeiconsIcon icon={Rocket01Icon} className="size-4" />
|
||||
{isStarting ? "Starting..." : isLoadingModel ? "Loading model..." : store.isCheckingDataset ? "Checking dataset..." : "Start Training"}
|
||||
{isStarting
|
||||
? t("studio.training.starting")
|
||||
: isLoadingModel
|
||||
? t("studio.training.loadingModel")
|
||||
: store.isCheckingDataset
|
||||
? t("studio.training.checkingDataset")
|
||||
: t("studio.training.startTraining")}
|
||||
</Button>
|
||||
{startError && (
|
||||
<p className="text-xs text-red-500 leading-relaxed">{startError}</p>
|
||||
|
|
@ -171,8 +178,8 @@ export function TrainingSection() {
|
|||
{isIncompatible && (
|
||||
<p className="text-xs text-red-500 leading-relaxed">
|
||||
{!store.isAudioModel && store.isDatasetAudio === true
|
||||
? "This model does not support audio. Switch to an audio-capable model or choose a non-audio dataset."
|
||||
: "Text model is not compatible with a multimodal dataset. Switch to a vision model or choose a text-only dataset."}
|
||||
? t("studio.training.audioIncompatible")
|
||||
: t("studio.training.visionIncompatible")}
|
||||
</p>
|
||||
)}
|
||||
{!configValidation.ok && configValidation.message && !isIncompatible && (
|
||||
|
|
@ -180,7 +187,7 @@ export function TrainingSection() {
|
|||
)}
|
||||
|
||||
{/* Upload / Save / Reset */}
|
||||
<p className="text-xs text-muted-foreground">Training Config</p>
|
||||
<p className="text-xs text-muted-foreground">{t("studio.training.configLabel")}</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -191,10 +198,10 @@ export function TrainingSection() {
|
|||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<HugeiconsIcon icon={CloudUploadIcon} className="size-3.5" />
|
||||
Upload
|
||||
{t("studio.training.upload")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Load a saved YAML config</TooltipContent>
|
||||
<TooltipContent>{t("studio.training.uploadConfigTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -206,10 +213,10 @@ export function TrainingSection() {
|
|||
onClick={handleSaveConfig}
|
||||
>
|
||||
<HugeiconsIcon icon={Archive04Icon} className="size-3.5" />
|
||||
Save
|
||||
{t("studio.training.save")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Download current config as YAML</TooltipContent>
|
||||
<TooltipContent>{t("studio.training.saveConfigTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -221,10 +228,10 @@ export function TrainingSection() {
|
|||
disabled={!store.selectedModel}
|
||||
>
|
||||
<HugeiconsIcon icon={CleanIcon} className="size-3.5" />
|
||||
Reset
|
||||
{t("studio.training.reset")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reset to model defaults</TooltipContent>
|
||||
<TooltipContent>{t("studio.training.resetConfigTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -24,8 +24,10 @@ import { TrainingSection } from "./sections/training-section";
|
|||
import { LiveTrainingView } from "./live-training-view";
|
||||
import { HistoricalTrainingView } from "./historical-training-view";
|
||||
import { HistoryCardGrid } from "./history-card-grid";
|
||||
import { useT } from "@/i18n";
|
||||
|
||||
export function StudioPage(): ReactElement {
|
||||
const t = useT();
|
||||
useTrainingRuntimeLifecycle();
|
||||
const showTrainingView = useTrainingRuntimeStore(shouldShowTrainingView);
|
||||
const isTrainingRunning = useTrainingRuntimeStore((state) => state.isTrainingRunning);
|
||||
|
|
@ -120,10 +122,13 @@ export function StudioPage(): ReactElement {
|
|||
}
|
||||
|
||||
const subtitle = (() => {
|
||||
if (activeTab === "current-run") return runtimeMessage || "Training in progress";
|
||||
if (activeTab === "current-run")
|
||||
return runtimeMessage || t("studio.subtitles.trainingInProgress");
|
||||
if (activeTab === "history")
|
||||
return selectedHistoryRunId ? "Viewing past run" : "View past training runs";
|
||||
return "Configure and start training";
|
||||
return selectedHistoryRunId
|
||||
? t("studio.subtitles.viewingPastRun")
|
||||
: t("studio.subtitles.viewPastRuns");
|
||||
return t("studio.subtitles.configure");
|
||||
})();
|
||||
|
||||
return (
|
||||
|
|
@ -150,14 +155,14 @@ export function StudioPage(): ReactElement {
|
|||
|
||||
<div className="mb-6 flex flex-col gap-0.5 sm:mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Fine-tuning Studio
|
||||
{t("studio.title")}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
|
||||
{!hasHydratedRuntime && isHydratingRuntime ? (
|
||||
<div className="rounded-xl border bg-card p-8 text-sm text-muted-foreground">
|
||||
Loading training runtime...
|
||||
{t("studio.loadingRuntime")}
|
||||
</div>
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
|
|
@ -168,19 +173,19 @@ export function StudioPage(): ReactElement {
|
|||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground"
|
||||
onClick={() => setSelectedHistoryRunId(null)}
|
||||
aria-label="Back to history"
|
||||
aria-label={t("studio.backToHistory")}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="configure" disabled={isTrainingRunning}>
|
||||
Configure
|
||||
{t("studio.tabs.configure")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="current-run" disabled={!showTrainingView}>
|
||||
Current Run
|
||||
{t("studio.tabs.currentRun")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history">History</TabsTrigger>
|
||||
<TabsTrigger value="history">{t("studio.tabs.history")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
import { Cancel01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useState, type ReactElement } from "react";
|
||||
import { useT } from "@/i18n";
|
||||
|
||||
const HF_REPO_REGEX = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
||||
|
||||
|
|
@ -171,6 +172,7 @@ type DownloadRowProps = {
|
|||
};
|
||||
|
||||
function DownloadRow({ label, state }: DownloadRowProps): ReactElement | null {
|
||||
const t = useT();
|
||||
// Compute a rolling-window rate + ETA from the same cumulative-byte
|
||||
// series the poll hook already produces, so we can show
|
||||
// "5.2 / 20.7 GB • 85.3 MB/s • 3m 12s left" instead of just the pair.
|
||||
|
|
@ -179,22 +181,25 @@ function DownloadRow({ label, state }: DownloadRowProps): ReactElement | null {
|
|||
if (state.downloadedBytes <= 0 && !state.cachePath) return null;
|
||||
const isComplete = state.totalBytes > 0 && state.percent >= 100;
|
||||
const statusLabel = isComplete
|
||||
? "Ready"
|
||||
? t("studio.trainingStart.ready")
|
||||
: state.totalBytes > 0
|
||||
? "Downloading"
|
||||
? t("studio.trainingStart.downloading")
|
||||
: state.downloadedBytes === 0
|
||||
? "Preparing"
|
||||
? t("studio.trainingStart.preparing")
|
||||
: null;
|
||||
const showRate = stats.stable && !isComplete;
|
||||
const rateSuffix = showRate ? ` • ${formatRate(stats.rateBytesPerSecond)}` : "";
|
||||
const etaStr =
|
||||
showRate && state.totalBytes > 0 ? formatEta(stats.etaSeconds) : "--";
|
||||
const etaSuffix = etaStr !== "--" ? ` • ${etaStr} left` : "";
|
||||
const etaSuffix =
|
||||
etaStr !== "--" ? ` • ${t("studio.trainingStart.left", { eta: etaStr })}` : "";
|
||||
const sizeLabel =
|
||||
state.totalBytes > 0
|
||||
? `${formatBytes(state.downloadedBytes)} / ${formatBytes(state.totalBytes)}${rateSuffix}${etaSuffix}`
|
||||
: state.downloadedBytes > 0
|
||||
? `${formatBytes(state.downloadedBytes)} downloaded${rateSuffix}`
|
||||
? `${t("studio.trainingStart.downloaded", {
|
||||
size: formatBytes(state.downloadedBytes),
|
||||
})}${rateSuffix}`
|
||||
: null;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 rounded-md border border-border/50 bg-muted/20 px-3 py-2">
|
||||
|
|
@ -245,6 +250,7 @@ export function TrainingStartOverlay({
|
|||
message,
|
||||
currentStep,
|
||||
}: TrainingStartOverlayProps): ReactElement {
|
||||
const t = useT();
|
||||
const { stopTrainingRun, dismissTrainingRun } = useTrainingActions();
|
||||
const isStarting = useTrainingRuntimeStore((s) => s.isStarting);
|
||||
const phase = useTrainingRuntimeStore((s) => s.phase);
|
||||
|
|
@ -273,8 +279,8 @@ export function TrainingStartOverlay({
|
|||
: null;
|
||||
const displayMessage =
|
||||
startFromResume && !isDownloadPhase && /^download/i.test(message)
|
||||
? "Resuming training..."
|
||||
: message || "starting training...";
|
||||
? t("studio.trainingStart.resumingTraining")
|
||||
: message || t("studio.trainingStart.startingTraining");
|
||||
const rawModelDownload = useModelDownloadProgress(modelName);
|
||||
const rawDatasetDownload = useDatasetDownloadProgress(datasetName);
|
||||
const modelDownload = isDownloadPhase
|
||||
|
|
@ -297,7 +303,7 @@ export function TrainingStartOverlay({
|
|||
<div className="pointer-events-auto relative flex w-[860px] max-w-[calc(100%-2rem)] flex-col items-center gap-4">
|
||||
<img
|
||||
src="/unsloth-gem.png"
|
||||
alt="Unsloth mascot"
|
||||
alt="Unsloth Studio"
|
||||
className="size-24 object-contain"
|
||||
/>
|
||||
<div className="relative w-full">
|
||||
|
|
@ -313,13 +319,13 @@ export function TrainingStartOverlay({
|
|||
</Button>
|
||||
<AlertDialogContent overlayClassName="bg-background/40 supports-backdrop-filter:backdrop-blur-[1px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel Training</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("studio.training.cancelTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Do you want to cancel the current training run?
|
||||
{t("studio.training.cancelDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Continue Training</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t("studio.training.continueAction")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
|
|
@ -335,7 +341,7 @@ export function TrainingStartOverlay({
|
|||
});
|
||||
}}
|
||||
>
|
||||
Cancel Training
|
||||
{t("studio.training.cancelAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
@ -348,24 +354,27 @@ export function TrainingStartOverlay({
|
|||
duration={36}
|
||||
className="bg-gradient-to-r from-emerald-300 via-lime-300 to-teal-300 bg-clip-text font-semibold text-transparent"
|
||||
>
|
||||
{"> unsloth training starts..."}
|
||||
{t("studio.trainingStart.terminalStart")}
|
||||
</TypingAnimation>
|
||||
<AnimatedSpan className="my-2">
|
||||
<pre className="whitespace-pre text-muted-foreground inline-block">{`==((====))==\n \\\\ /|\nO^O/ \\_/ \\\n\\ /\n "-____-"`}</pre>
|
||||
</AnimatedSpan>
|
||||
<TypingAnimation duration={44}>
|
||||
{"> Preparing model and dataset..."}
|
||||
{t("studio.trainingStart.preparingResources")}
|
||||
</TypingAnimation>
|
||||
<TypingAnimation duration={44}>
|
||||
{"> We are getting everything ready for your run..."}
|
||||
{t("studio.trainingStart.gettingReady")}
|
||||
</TypingAnimation>
|
||||
<AnimatedSpan className="mt-2 text-muted-foreground">
|
||||
{`> ${displayMessage} | waiting for first step... (${currentStep})`}
|
||||
{t("studio.trainingStart.waitingForFirstStep", {
|
||||
message: displayMessage,
|
||||
step: currentStep,
|
||||
})}
|
||||
</AnimatedSpan>
|
||||
{datasetDownload.downloadedBytes > 0 || datasetDownload.cachePath ? (
|
||||
<AnimatedSpan className="mt-3">
|
||||
<DownloadRow
|
||||
label="Dataset"
|
||||
label={t("studio.trainingStart.dataset")}
|
||||
state={datasetDownload}
|
||||
/>
|
||||
</AnimatedSpan>
|
||||
|
|
@ -373,7 +382,7 @@ export function TrainingStartOverlay({
|
|||
{modelDownload.downloadedBytes > 0 || modelDownload.cachePath ? (
|
||||
<AnimatedSpan className="mt-3">
|
||||
<DownloadRow
|
||||
label="Model weights"
|
||||
label={t("studio.trainingStart.modelWeights")}
|
||||
state={modelDownload}
|
||||
/>
|
||||
</AnimatedSpan>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ export {
|
|||
export { useTrainingActions } from "./hooks/use-training-actions";
|
||||
export { useTrainingHistorySidebarItems } from "./hooks/use-training-history-sidebar";
|
||||
export { useTrainingRuntimeLifecycle } from "./hooks/use-training-runtime-lifecycle";
|
||||
export { removeTrainingUnloadGuard } from "./hooks/use-training-unload-guard";
|
||||
export {
|
||||
removeTrainingUnloadGuard,
|
||||
useTrainingUnloadGuard,
|
||||
} from "./hooks/use-training-unload-guard";
|
||||
export { useMaxStepsEpochsToggle } from "./hooks/use-max-steps-epochs-toggle";
|
||||
export { HfDatasetSubsetSplitSelectors } from "./components/hf-dataset-subset-split-selectors";
|
||||
export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-store";
|
||||
|
|
|
|||
11
studio/frontend/src/i18n/AGENTS.md
Normal file
11
studio/frontend/src/i18n/AGENTS.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# i18n Contribution Instructions
|
||||
|
||||
- `locales/en.ts` is the complete baseline message file.
|
||||
- Non-English locale files may be partial. Missing keys must fall back to English at runtime.
|
||||
- Use BCP 47 locale tags for new languages, for example `zh-CN`, `ja-JP`, and `ko-KR`.
|
||||
- Do not change fallback logic to hide missing translations.
|
||||
- Do not add automatic DOM translation, MutationObserver text replacement, or runtime guess-based translation.
|
||||
- Preserve interpolation variables exactly, for example `{count}`, `{model}`, and `{provider}`.
|
||||
- Keep product and technical names unchanged unless there is an established localized name, for example `Unsloth Studio`, `LoRA`, `GGUF`, and `Hugging Face`.
|
||||
- Keep translation changes small and reviewable. Prefer separate commits for runtime changes, UI migration, and locale text.
|
||||
- When adding user-facing Studio UI text, add the English message key first and add non-English overrides only when the translation is clear.
|
||||
111
studio/frontend/src/i18n/check-parity.ts
Normal file
111
studio/frontend/src/i18n/check-parity.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
// Parity check between en.ts and every non-English locale.
|
||||
// - Locale files may be partial; missing keys must fall back to English.
|
||||
// - All zh-CN keys must exist in en (no extras).
|
||||
// - Placeholder set must match per leaf between en and the overlay.
|
||||
//
|
||||
// Run: npx tsx src/i18n/check-parity.ts
|
||||
|
||||
import { en } from "./locales/en.ts";
|
||||
import { zhCN } from "./locales/zh-CN.ts";
|
||||
|
||||
type Tree = { readonly [k: string]: string | Tree };
|
||||
|
||||
function isTree(v: unknown): v is Tree {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function placeholders(s: string): string[] {
|
||||
const out: string[] = [];
|
||||
const re = /\{([a-zA-Z0-9_]+)\}/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(s))) out.push(m[1]);
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
function checkOverlay(
|
||||
enNode: Tree,
|
||||
overlay: Tree | undefined,
|
||||
path: string,
|
||||
errors: string[],
|
||||
missing: string[],
|
||||
): void {
|
||||
for (const [k, v] of Object.entries(enNode)) {
|
||||
const subPath = path ? `${path}.${k}` : k;
|
||||
if (typeof v === "string") {
|
||||
if (overlay === undefined) {
|
||||
missing.push(subPath);
|
||||
continue;
|
||||
}
|
||||
const overlayV = overlay[k];
|
||||
if (overlayV === undefined) {
|
||||
missing.push(subPath);
|
||||
continue;
|
||||
}
|
||||
if (typeof overlayV !== "string") {
|
||||
errors.push(`${subPath} should be string, got ${typeof overlayV}`);
|
||||
continue;
|
||||
}
|
||||
const enP = placeholders(v);
|
||||
const ovP = placeholders(overlayV);
|
||||
if (JSON.stringify(enP) !== JSON.stringify(ovP)) {
|
||||
errors.push(
|
||||
`${subPath}: placeholder mismatch en={${enP.join(",")}} overlay={${ovP.join(",")}}`,
|
||||
);
|
||||
}
|
||||
} else if (isTree(v)) {
|
||||
const overlaySub = overlay === undefined ? undefined : overlay[k];
|
||||
if (overlaySub !== undefined && !isTree(overlaySub)) {
|
||||
errors.push(`${subPath} should be an object, got ${typeof overlaySub}`);
|
||||
continue;
|
||||
}
|
||||
checkOverlay(v, overlaySub, subPath, errors, missing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkExtras(
|
||||
overlay: Tree,
|
||||
enNode: Tree,
|
||||
path: string,
|
||||
errors: string[],
|
||||
): void {
|
||||
for (const [k, v] of Object.entries(overlay)) {
|
||||
const subPath = path ? `${path}.${k}` : k;
|
||||
if (!(k in enNode)) {
|
||||
errors.push(`${subPath} exists in overlay but not in en`);
|
||||
continue;
|
||||
}
|
||||
const enV = enNode[k];
|
||||
if (isTree(v) && isTree(enV)) {
|
||||
checkExtras(v, enV, subPath, errors);
|
||||
} else if (isTree(v) !== isTree(enV)) {
|
||||
errors.push(`${subPath}: shape mismatch (en=${typeof enV}, overlay=${typeof v})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const overlays: Record<string, Tree> = { "zh-CN": zhCN as unknown as Tree };
|
||||
let anyError = false;
|
||||
|
||||
for (const [locale, overlay] of Object.entries(overlays)) {
|
||||
const errors: string[] = [];
|
||||
const missing: string[] = [];
|
||||
checkOverlay(en as unknown as Tree, overlay, "", errors, missing);
|
||||
checkExtras(overlay, en as unknown as Tree, "", errors);
|
||||
|
||||
console.log(`\n=== ${locale} ===`);
|
||||
console.log(`Missing keys (will fall back to en): ${missing.length}`);
|
||||
if (errors.length) {
|
||||
anyError = true;
|
||||
console.error(`Errors (${errors.length}):`);
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
} else {
|
||||
console.log("No errors.");
|
||||
}
|
||||
}
|
||||
|
||||
if (anyError) process.exit(1);
|
||||
console.log("\nAll locale overlays pass parity.");
|
||||
44
studio/frontend/src/i18n/index.ts
Normal file
44
studio/frontend/src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// 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 { useCallback } from "react";
|
||||
import { useLocale } from "./locale-store";
|
||||
import { translate } from "./messages";
|
||||
import type { InterpolationValues } from "./types";
|
||||
import type { TranslationKey } from "./messages";
|
||||
|
||||
export {
|
||||
DEFAULT_LOCALE,
|
||||
LOCALE_STORAGE_KEY,
|
||||
getLocale,
|
||||
initializeLocale,
|
||||
setLocale,
|
||||
subscribeLocale,
|
||||
useLocale,
|
||||
} from "./locale-store";
|
||||
export {
|
||||
LOCALES,
|
||||
isSupportedLocale,
|
||||
messages,
|
||||
translate,
|
||||
} from "./messages";
|
||||
export type { Locale, TranslationKey } from "./messages";
|
||||
export type {
|
||||
DeepPartialMessageTree,
|
||||
InterpolationValues,
|
||||
MessageKey,
|
||||
MessageTree,
|
||||
} from "./types";
|
||||
|
||||
export function useT(): (
|
||||
key: TranslationKey,
|
||||
values?: InterpolationValues,
|
||||
) => string {
|
||||
const locale = useLocale();
|
||||
|
||||
return useCallback(
|
||||
(key: TranslationKey, values?: InterpolationValues) =>
|
||||
translate(key, values, locale),
|
||||
[locale],
|
||||
);
|
||||
}
|
||||
130
studio/frontend/src/i18n/locale-store.ts
Normal file
130
studio/frontend/src/i18n/locale-store.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// 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 { useSyncExternalStore } from "react";
|
||||
import { isSupportedLocale, type Locale } from "./messages";
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = "en";
|
||||
export const LOCALE_STORAGE_KEY = "unsloth_locale";
|
||||
|
||||
const subscribers = new Set<() => void>();
|
||||
|
||||
let currentLocale: Locale = DEFAULT_LOCALE;
|
||||
let isStorageListenerActive = false;
|
||||
|
||||
function normalizeLocale(value: unknown): Locale {
|
||||
return isSupportedLocale(value) ? value : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
function readStoredLocale(): Locale {
|
||||
try {
|
||||
const stored = globalThis.localStorage?.getItem(LOCALE_STORAGE_KEY) ?? null;
|
||||
return normalizeLocale(stored);
|
||||
} catch {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredLocale(locale: Locale): void {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(LOCALE_STORAGE_KEY, locale);
|
||||
} catch {
|
||||
// localStorage 可能被禁用;失败只影响持久化,不影响当前会话语言。
|
||||
}
|
||||
}
|
||||
|
||||
function syncDocumentLang(locale: Locale): void {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
|
||||
function notifySubscribers(): void {
|
||||
for (const subscriber of subscribers) subscriber();
|
||||
}
|
||||
|
||||
function updateCurrentLocale(locale: Locale): void {
|
||||
if (locale === currentLocale) return;
|
||||
currentLocale = locale;
|
||||
syncDocumentLang(locale);
|
||||
notifySubscribers();
|
||||
}
|
||||
|
||||
function isLocaleStorageEvent(event: StorageEvent): boolean {
|
||||
if (event.key !== LOCALE_STORAGE_KEY && event.key !== null) return false;
|
||||
if (!event.storageArea || typeof window === "undefined") return true;
|
||||
// Accessing window.localStorage can throw in privacy-restricted contexts
|
||||
// where storage is blocked; mirror the try/catch in readStoredLocale/
|
||||
// writeStoredLocale so storage-event handling is just as resilient.
|
||||
try {
|
||||
return event.storageArea === window.localStorage;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleStorageEvent(event: StorageEvent): void {
|
||||
if (!isLocaleStorageEvent(event)) return;
|
||||
const nextLocale =
|
||||
event.key === null ? DEFAULT_LOCALE : normalizeLocale(event.newValue);
|
||||
updateCurrentLocale(nextLocale);
|
||||
}
|
||||
|
||||
function startStorageListener(): void {
|
||||
if (isStorageListenerActive || typeof window === "undefined") return;
|
||||
window.addEventListener("storage", handleStorageEvent);
|
||||
isStorageListenerActive = true;
|
||||
}
|
||||
|
||||
function stopStorageListener(): void {
|
||||
if (!isStorageListenerActive || typeof window === "undefined") return;
|
||||
window.removeEventListener("storage", handleStorageEvent);
|
||||
isStorageListenerActive = false;
|
||||
}
|
||||
|
||||
function getLocaleSnapshot(): Locale {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
function getServerLocaleSnapshot(): Locale {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function subscribeLocale(listener: () => void): () => void {
|
||||
const shouldStartStorageListener = subscribers.size === 0;
|
||||
subscribers.add(listener);
|
||||
if (shouldStartStorageListener) startStorageListener();
|
||||
|
||||
return () => {
|
||||
subscribers.delete(listener);
|
||||
if (subscribers.size === 0) stopStorageListener();
|
||||
};
|
||||
}
|
||||
|
||||
export function initializeLocale(): Locale {
|
||||
const nextLocale = readStoredLocale();
|
||||
currentLocale = nextLocale;
|
||||
syncDocumentLang(nextLocale);
|
||||
notifySubscribers();
|
||||
return nextLocale;
|
||||
}
|
||||
|
||||
export function getLocale(): Locale {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
export function setLocale(locale: Locale): void {
|
||||
const requestedLocale = normalizeLocale(locale);
|
||||
writeStoredLocale(requestedLocale);
|
||||
|
||||
currentLocale = requestedLocale;
|
||||
syncDocumentLang(requestedLocale);
|
||||
notifySubscribers();
|
||||
}
|
||||
|
||||
export function useLocale(): Locale {
|
||||
return useSyncExternalStore(
|
||||
subscribeLocale,
|
||||
getLocaleSnapshot,
|
||||
getServerLocaleSnapshot,
|
||||
);
|
||||
}
|
||||
731
studio/frontend/src/i18n/locales/en.ts
Normal file
731
studio/frontend/src/i18n/locales/en.ts
Normal file
|
|
@ -0,0 +1,731 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export const en = {
|
||||
common: {
|
||||
cancel: "Cancel",
|
||||
close: "Close",
|
||||
delete: "Delete",
|
||||
done: "Done",
|
||||
error: "Error",
|
||||
export: "Export",
|
||||
help: "Help",
|
||||
loading: "Loading...",
|
||||
new: "New",
|
||||
rename: "Rename",
|
||||
save: "Save",
|
||||
search: "Search",
|
||||
shutdown: "Shutdown",
|
||||
},
|
||||
shell: {
|
||||
beta: "BETA",
|
||||
brand: "unsloth",
|
||||
product: "Unsloth Studio",
|
||||
accountMenu: "{name} account menu",
|
||||
aria: {
|
||||
home: "Unsloth home",
|
||||
closeSidebar: "Close sidebar",
|
||||
openSidebar: "Open sidebar",
|
||||
chatOptions: "Chat options",
|
||||
runOptions: "Run options",
|
||||
},
|
||||
navigation: {
|
||||
newChat: "New Chat",
|
||||
compare: "Compare",
|
||||
search: "Search",
|
||||
train: "Train",
|
||||
recipes: "Recipes",
|
||||
export: "Export",
|
||||
recents: "Recents",
|
||||
settings: "Settings",
|
||||
api: "API",
|
||||
lightMode: "Light Mode",
|
||||
darkMode: "Dark Mode",
|
||||
guidedTour: "Guided Tour",
|
||||
help: "Help",
|
||||
logOut: "Log out",
|
||||
shutdown: "Shutdown",
|
||||
},
|
||||
notFound: {
|
||||
title: "Page not found",
|
||||
description: "{path} does not exist.",
|
||||
backToChat: "Back to chat",
|
||||
},
|
||||
dialog: {
|
||||
deleteChat: {
|
||||
title: "Delete chat",
|
||||
description: "Are you sure you want to delete this chat \"{name}\"?",
|
||||
},
|
||||
deleteRun: {
|
||||
title: "Delete training run",
|
||||
description: "Are you sure you want to delete this run \"{name}\"?",
|
||||
},
|
||||
renameChat: {
|
||||
title: "Rename chat",
|
||||
placeholder: "Chat title",
|
||||
},
|
||||
renameRun: {
|
||||
title: "Rename run",
|
||||
placeholder: "Run name",
|
||||
},
|
||||
},
|
||||
toast: {
|
||||
cannotDeleteRunningRun: "Cannot delete a running training run",
|
||||
failedToDeleteChat: "Failed to delete chat",
|
||||
failedToDeleteRun: "Failed to delete run",
|
||||
failedToRenameChat: "Failed to rename chat",
|
||||
failedToRenameRun: "Failed to rename run",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "Settings",
|
||||
dialog: {
|
||||
title: "Settings",
|
||||
description: "Manage your Unsloth Studio preferences.",
|
||||
closeAriaLabel: "Close settings",
|
||||
},
|
||||
tabs: {
|
||||
general: "General",
|
||||
profile: "Profile",
|
||||
appearance: "Appearance",
|
||||
chat: "Chat",
|
||||
connections: "Connections",
|
||||
apiKeys: "API",
|
||||
about: "Help",
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
description: "Global preferences for Unsloth Studio.",
|
||||
account: "Account",
|
||||
huggingFaceToken: "Hugging Face token",
|
||||
huggingFaceTokenDescription:
|
||||
"Used to load gated models and push artifacts.",
|
||||
hideToken: "Hide token",
|
||||
showToken: "Show token",
|
||||
chatDefaults: "Chat defaults",
|
||||
autoTitleNewChats: "Auto-title new chats",
|
||||
autoTitleNewChatsDescription:
|
||||
"Generate a short title from the first message.",
|
||||
gettingStarted: "Getting started",
|
||||
startOnboarding: "Start onboarding",
|
||||
startOnboardingDescription:
|
||||
"Open the setup wizard again without changing your account.",
|
||||
startOnboardingAction: "Start onboarding",
|
||||
resetPreferences: {
|
||||
sectionTitle: "Danger zone",
|
||||
label: "Reset all local preferences",
|
||||
description:
|
||||
"Clears local-only preferences. Chats, API access, and DB-backed chat settings are not affected.",
|
||||
action: "Reset preferences",
|
||||
confirmTitle: "Reset all local preferences?",
|
||||
confirmDescription:
|
||||
"This clears local-only preferences, then reloads Studio. Chats, API access, and DB-backed chat settings are not affected.",
|
||||
confirmAction: "Reset and reload",
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
title: "Profile",
|
||||
description: "Update how your profile appears in Studio.",
|
||||
changePicture: "Change profile picture",
|
||||
displayName: "Display name",
|
||||
nameSaved: "Profile name saved",
|
||||
namePersistErrorTitle: "Could not persist profile name",
|
||||
namePersistErrorDescription:
|
||||
"Name updated for this session, but may not persist after reload.",
|
||||
photoUpdated: "Profile photo updated",
|
||||
photoPersistErrorTitle: "Could not persist profile photo",
|
||||
photoPersistErrorDescription:
|
||||
"Photo updated for this session, but may not persist after reload.",
|
||||
photoUpdateErrorTitle: "Could not update profile photo",
|
||||
imageUseError: "Could not use this image.",
|
||||
},
|
||||
appearance: {
|
||||
title: "Appearance",
|
||||
description: "How Unsloth Studio looks on this device.",
|
||||
theme: {
|
||||
title: "Theme",
|
||||
label: "Color scheme",
|
||||
description: "Choose light, dark, or follow your system.",
|
||||
system: "System",
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
},
|
||||
language: {
|
||||
title: "Language",
|
||||
label: "Display language",
|
||||
description: "Choose the language used by Studio.",
|
||||
},
|
||||
layout: {
|
||||
title: "Layout",
|
||||
compactSidebar: "Pin sidebar by default",
|
||||
compactSidebarDescription:
|
||||
"Keep the sidebar expanded instead of collapsing to icons.",
|
||||
},
|
||||
},
|
||||
chat: {
|
||||
title: "Chat",
|
||||
description: "Manage your chat history stored on this device.",
|
||||
data: "Data",
|
||||
exportHistory: "Export chat history",
|
||||
exportHistoryDescription:
|
||||
"Download all chats and messages as a JSON file.",
|
||||
exportAction: "Export",
|
||||
exportingAction: "Exporting...",
|
||||
clearHistory: "Clear chat history",
|
||||
clearHistoryDescription: "Delete local chat history from this device.",
|
||||
clearAction: "Clear",
|
||||
clearAllChats: "Clear all chats",
|
||||
clearAllChatsDescription:
|
||||
"Permanently delete every chat on this device.",
|
||||
noChatsToClear: "No chats to clear.",
|
||||
clearOneChatDescription:
|
||||
"Permanently delete the only chat on this device.",
|
||||
clearChatCountDescription:
|
||||
"Permanently delete all {count} chats on this device.",
|
||||
clearChatsAction: "Clear chats",
|
||||
clearOneChatTitle: "Clear 1 chat?",
|
||||
clearChatsTitle: "Clear {count} chats?",
|
||||
clearChatsConfirmDescription:
|
||||
"This permanently deletes every chat and message stored on this device. This cannot be undone.",
|
||||
clearingAction: "Clearing...",
|
||||
clearOneChatAction: "Clear 1 chat",
|
||||
clearChatCountAction: "Clear {count} chats",
|
||||
clearedAllChats: "Cleared all chats",
|
||||
clearedOneChat: "Cleared 1 chat",
|
||||
clearedChatCount: "Cleared {count} chats",
|
||||
someChatsCouldNotBeCleared: "Some chats could not be cleared",
|
||||
chatsClearedRemainOne:
|
||||
"{clearedCount} chats cleared; 1 chat remains. Please retry.",
|
||||
chatsClearedRemain:
|
||||
"{clearedCount} chats cleared; {remainingCount} chats remain. Please retry.",
|
||||
oneChatClearedRemain:
|
||||
"1 chat cleared; {remainingCount} chats remain. Please retry.",
|
||||
oneChatClearedRemainOne:
|
||||
"1 chat cleared; 1 chat remains. Please retry.",
|
||||
storageClearFailedOne:
|
||||
"A storage clear failed; 1 chat may remain. Please retry.",
|
||||
storageClearFailed:
|
||||
"A storage clear failed; {count} chats may remain. Please retry.",
|
||||
failedToClearChats: "Failed to clear chats",
|
||||
},
|
||||
connections: {
|
||||
title: "Connections",
|
||||
description: "Manage providers and external service connections.",
|
||||
},
|
||||
apiKeys: {
|
||||
title: "API",
|
||||
description: "Access Unsloth programmatically via the OpenAI-compatible API.",
|
||||
readDocs: "Read the API docs",
|
||||
noAccess: "No API access yet.",
|
||||
newBadge: "New",
|
||||
accessTokens: "Access tokens",
|
||||
loadError: "Couldn't load API access.",
|
||||
createError: "Couldn't create access token.",
|
||||
revokeError: "Couldn't revoke access token.",
|
||||
never: "Never",
|
||||
tokenNamePlaceholder: "Token name (e.g. production)",
|
||||
newAccessTokenName: "New access token name",
|
||||
createToken: "Create token",
|
||||
creating: "Creating...",
|
||||
newTokenCreated: "New access token created",
|
||||
accessTokenCopied: "Access token copied",
|
||||
copyAccessToken: "Copy access token",
|
||||
copyNow: "Copy now - this won't be shown again.",
|
||||
usageExamples: "Usage examples",
|
||||
usageTools: "Tools",
|
||||
copySnippet: "Copy snippet",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
setupDocs: "Setup docs:",
|
||||
relativeNever: "never",
|
||||
relativeJustNow: "just now",
|
||||
relativeHoursAgo: "{count}h ago",
|
||||
relativeDaysAgo: "{count}d ago",
|
||||
relativeMonthsAgo: "{count}mo ago",
|
||||
relativeYearsAgo: "{count}y ago",
|
||||
expired: "expired",
|
||||
today: "today",
|
||||
inDays: "in {count}d",
|
||||
created: "Created {value}",
|
||||
used: "Used {value}",
|
||||
expires: "Expires {value}",
|
||||
actionsFor: "Actions for {name}",
|
||||
copyPrefix: "Copy prefix",
|
||||
revokeToken: "Revoke token",
|
||||
revokeTitle: "Revoke access token \"{name}\"?",
|
||||
revokeDescription:
|
||||
"Applications using this token will immediately lose access. This cannot be undone.",
|
||||
revokeAction: "Revoke \"{name}\"",
|
||||
revoking: "Revoking...",
|
||||
},
|
||||
about: {
|
||||
title: "About",
|
||||
description:
|
||||
"Documentation, release notes, feedback, and Studio build info.",
|
||||
studioVersion: "Studio Version",
|
||||
packageVersion: "Package Version",
|
||||
updates: "Updates",
|
||||
help: "Help",
|
||||
documentation: "Documentation",
|
||||
releaseNotes: "Release notes",
|
||||
whatsNew: "What's new",
|
||||
feedback: "Feedback",
|
||||
reportIssue: "Report an issue",
|
||||
dangerZone: "Danger zone",
|
||||
shutDownStudio: "Shut down Unsloth Studio",
|
||||
shutDownStudioDescription:
|
||||
"Stops the Studio server process and ends your session.",
|
||||
shutDown: "Shut down",
|
||||
update: {
|
||||
title: "Update Unsloth Studio",
|
||||
openPowerShell: "Open PowerShell and run:",
|
||||
openTerminal: "Open Terminal and run:",
|
||||
commandText: "{label} text",
|
||||
copied: "Copied",
|
||||
copyCommand: "Copy command",
|
||||
commandCopied: "{label} copied",
|
||||
copyNamedCommand: "Copy {label}",
|
||||
checkingInstall: "Checking how Studio was installed...",
|
||||
localInstallDetected:
|
||||
"Source or local install detected. To avoid replacing it with PyPI, update from the checkout or source you originally installed from.",
|
||||
pullThenUpdate:
|
||||
"Pull latest changes from your Unsloth repo checkout, then update Studio locally:",
|
||||
gitPullCommand: "git pull command",
|
||||
localUpdateCommand: "local update command",
|
||||
localInstallerFallback:
|
||||
"If the Studio update command is unavailable, run the local installer from that checkout:",
|
||||
localInstallerCommand: "local installer command",
|
||||
sourceInstallDetected:
|
||||
"This looks like a source or VCS package install. Reinstall from the original local path or Git URL you used.",
|
||||
repoCheckoutFallback:
|
||||
"If you still have the Unsloth repo checkout, run the local installer from that checkout:",
|
||||
restartAfterUpdate:
|
||||
"Restart Studio after updating for changes to take effect.",
|
||||
unknownInstall:
|
||||
"Studio could not detect how it was installed. Check how you installed Studio first, then choose the matching update path.",
|
||||
curlOrPypi: "For curl or PyPI installs, run:",
|
||||
updateCommand: "update command",
|
||||
localCheckout:
|
||||
"For local checkout installs, update from that checkout instead and use the local update command:",
|
||||
fallbackInstruction:
|
||||
"If that fails or unsloth studio update is unavailable, run:",
|
||||
fallbackCommand: "fallback command",
|
||||
},
|
||||
},
|
||||
},
|
||||
studio: {
|
||||
routeTitle: "Train",
|
||||
title: "Fine-tuning Studio",
|
||||
subtitles: {
|
||||
configure: "Configure and start training",
|
||||
trainingInProgress: "Training in progress",
|
||||
viewPastRuns: "View past training runs",
|
||||
viewingPastRun: "Viewing past run",
|
||||
},
|
||||
tabs: {
|
||||
configure: "Configure",
|
||||
currentRun: "Current Run",
|
||||
history: "History",
|
||||
},
|
||||
loadingRuntime: "Loading training runtime...",
|
||||
backToHistory: "Back to history",
|
||||
sections: {
|
||||
model: "Model",
|
||||
dataset: "Dataset",
|
||||
params: "Parameters",
|
||||
training: "Training",
|
||||
charts: "Charts",
|
||||
progress: "Training Progress",
|
||||
},
|
||||
configure: {
|
||||
title: "Configure",
|
||||
description: "Choose a model, dataset, and training settings.",
|
||||
startTraining: "Start Training",
|
||||
starting: "Starting...",
|
||||
loadingModel: "Loading model...",
|
||||
checkingDataset: "Checking dataset...",
|
||||
trainingConfig: "Training Config",
|
||||
},
|
||||
model: {
|
||||
title: "Model",
|
||||
description: "Select base model and training method",
|
||||
fasterTrainingBadge: "2x Faster Training",
|
||||
baseModel: "Base model",
|
||||
localModel: "Local Model",
|
||||
localModelTooltip: "Path to a locally downloaded model or a custom HF repo.",
|
||||
scanningLocalAndCachedModels: "Scanning local and cached models...",
|
||||
scanning: "Scanning...",
|
||||
scanningLocalModels: "Scanning local models...",
|
||||
noLocalModelsFound: "No local models found",
|
||||
noLocalModelsFoundManual: "No local models found. Enter path manually.",
|
||||
failedToLoadLocalModels: "Failed to load local models",
|
||||
hfCache: "HF cache",
|
||||
customFolders: "Custom Folders",
|
||||
localDir: "Local dir",
|
||||
huggingFaceModel: "Hugging Face Model",
|
||||
huggingFaceModelTooltip:
|
||||
"Search Hugging Face models or pick from our recommended list.",
|
||||
searchModels: "Search models...",
|
||||
searching: "Searching...",
|
||||
noModelsFound: "No models found",
|
||||
needsVram: "Needs ~{vram}GB VRAM (GPU: {gpu}GB)",
|
||||
tightVram: "~{vram}GB VRAM (tight fit on {gpu}GB)",
|
||||
vramEstimate: "~{vram}GB VRAM",
|
||||
method: "Method",
|
||||
methodTooltip:
|
||||
"QLoRA uses 4-bit quantization for lowest VRAM. LoRA uses 16-bit. Full updates all weights. CPT (Continued Pretraining) trains on raw text to adapt the model to a new domain without chat formatting.",
|
||||
readMore: "Read more",
|
||||
fullFineTune: "Full Fine-tune",
|
||||
checkingToken: "Checking token...",
|
||||
getOrUpdateToken: "Get or update token",
|
||||
huggingFaceTokenOptional: "Hugging Face Token (Optional)",
|
||||
continuedPretraining: "Continued Pretraining",
|
||||
localModels: "Local models",
|
||||
localModelsFound: "{count} local/cached models found",
|
||||
loadingLocalModels: "Loading local models...",
|
||||
},
|
||||
dataset: {
|
||||
title: "Dataset",
|
||||
description: "Select or upload training data",
|
||||
source: "Dataset source",
|
||||
chooseDataset: "Choose dataset",
|
||||
chooseDatasetTooltip:
|
||||
"Use the popup tabs to switch between Hugging Face and local recipe outputs.",
|
||||
localTab: "Local",
|
||||
searchHuggingFaceDatasets: "Search Hugging Face datasets...",
|
||||
searchLocalDatasets: "Search local datasets...",
|
||||
searching: "Searching...",
|
||||
noDatasetsFound: "No datasets found",
|
||||
loadingLocalDatasets: "Loading local datasets...",
|
||||
failedToLoadLocalDatasets: "Failed to load local datasets.",
|
||||
noLocalDatasetsYet: "No local datasets yet.",
|
||||
noLocalDatasetsMatchSearch: "No local datasets match search.",
|
||||
openDataRecipes: "Open Data Recipes",
|
||||
browsingSource:
|
||||
"Browsing {browsing}. Current selection stays {current}.",
|
||||
localDatasets: "Local datasets",
|
||||
localDataset: "Local dataset",
|
||||
localDatasetRows: " / {count} rows",
|
||||
huggingFaceDataset: "Hugging Face Dataset",
|
||||
localDatasetMetadata: "Local dataset metadata",
|
||||
dataRecipeOutput: "Data Recipe output.",
|
||||
rows: "Rows",
|
||||
columns: "Columns",
|
||||
batches: "Batches",
|
||||
updated: "Updated",
|
||||
evalDataset: "Eval dataset",
|
||||
uploading: "Uploading...",
|
||||
upload: "Upload",
|
||||
uploadEvalFile: "Upload eval file",
|
||||
evalDatasetDescription:
|
||||
"Optional. If not provided, a small portion will be split from the training data.",
|
||||
advanced: "Advanced",
|
||||
targetFormat: "Target Format",
|
||||
targetFormatTooltip:
|
||||
"Format of your training data. Auto-detect works for most datasets.",
|
||||
auto: "Auto",
|
||||
rawText: "Raw Text",
|
||||
trainSplitStart: "Train Split Start",
|
||||
trainSplitStartTooltip:
|
||||
"Only train on a subset of your training split by specifying a start row index (inclusive, 0-based). Leave empty to start from the first row.",
|
||||
trainSplitEnd: "Train Split End",
|
||||
trainSplitEndTooltip:
|
||||
"Last row index to include from the training split (inclusive, 0-based). For example, set Start to 0 and End to 99 to train on the first 100 rows. Leave empty to use all remaining rows.",
|
||||
endPlaceholder: "End",
|
||||
clear: "Clear",
|
||||
dropFileOrClick: "Drop 1 file here or click to upload",
|
||||
viewDataset: "View dataset",
|
||||
uploadFailed: "Upload failed",
|
||||
unknownError: "Unknown error",
|
||||
unsupportedFileType: "Unsupported file type",
|
||||
uploadOneFileType: "Upload one {types} file.",
|
||||
datasetUploaded: "Dataset uploaded",
|
||||
evalDatasetUploaded: "Eval dataset uploaded",
|
||||
uploadOneFileAtATime: "Upload one file at a time",
|
||||
uploadSingleFileDescription:
|
||||
"Training dataset upload accepts a single file.",
|
||||
checkingToken: "Checking token...",
|
||||
getOrUpdateToken: "Get or update token",
|
||||
preview: "Preview dataset",
|
||||
split: "Split",
|
||||
subset: "Subset",
|
||||
},
|
||||
params: {
|
||||
title: "Parameters",
|
||||
description: "Configure training hyperparameters",
|
||||
loraSettings: "LoRA Settings",
|
||||
trainingHyperparameters: "Training Hyperparameters",
|
||||
maxSteps: "Max Steps",
|
||||
epochs: "Epochs",
|
||||
useMaxSteps: "Use Max Steps",
|
||||
useEpochs: "Use Epochs",
|
||||
maxStepsTooltip: "Override total optimizer steps.",
|
||||
epochsTooltip: "Number of full passes over the dataset.",
|
||||
epochsDescription: "Each epoch is one full pass over your dataset.",
|
||||
maxStepsDescription: "Limits training to a fixed number of optimizer steps.",
|
||||
contextLength: "Context Length",
|
||||
contextLengthTooltip: "Maximum number of tokens per training sample.",
|
||||
customContextLength: "Enter a custom value",
|
||||
contextLengthDescription: "Max sequence length for training samples",
|
||||
learningRate: "Learning Rate",
|
||||
learningRateTooltip:
|
||||
"Step size for weight updates. Lower values train slower but more stably.",
|
||||
learningRateDescription:
|
||||
"Recommended: 2e-4 for LoRA, 5e-5 for CPT, 2e-5 for full fine-tune",
|
||||
embeddingLearningRate: "Embedding Learning Rate",
|
||||
embeddingLearningRateTooltip:
|
||||
"Only used when CPT is training embed_tokens. Embeddings are easier to destabilize than LoRA weights, so they usually need a smaller LR. Leave blank to use lr/10; typical working range is 2x-10x smaller than the main LR. Increase it only if vocabulary or domain-token adaptation is too slow.",
|
||||
embeddingLearningRateDescription:
|
||||
"Leave blank to use lr/10 (recommended). Typical range is 2x-10x smaller than the main learning rate.",
|
||||
rank: "Rank",
|
||||
rankTooltip: "Dimension of the low-rank matrices. Higher = more capacity.",
|
||||
alpha: "Alpha",
|
||||
alphaTooltip: "Scaling factor for LoRA updates. Usually 2x rank.",
|
||||
dropout: "Dropout",
|
||||
dropoutTooltip: "Dropout probability for LoRA layers to reduce overfitting.",
|
||||
visionLayers: "Vision layers",
|
||||
languageLayers: "Language layers",
|
||||
attentionModules: "Attention modules",
|
||||
mlpModules: "MLP modules",
|
||||
targetModules: "Target Modules",
|
||||
enableLora: "Enable LoRA",
|
||||
trainWithLora: "Train with LoRA",
|
||||
stableRank: "Stable Rank",
|
||||
memoryEfficient: "Memory Efficient",
|
||||
optimization: "Optimization",
|
||||
schedule: "Schedule",
|
||||
memory: "Memory",
|
||||
optimizer: "Optimizer",
|
||||
optimizerTooltip:
|
||||
"Optimization algorithm. 8-bit variants reduce memory usage. Fused is recommended for vision models.",
|
||||
lrScheduler: "LR scheduler",
|
||||
lrSchedulerTooltip:
|
||||
"How the learning rate changes over training. Linear decays steadily; cosine decays in a curve.",
|
||||
optimizerOptions: {
|
||||
adamw8bit: "AdamW 8-bit",
|
||||
pagedAdamw8bit: "Paged AdamW 8-bit",
|
||||
adamwBnb8bit: "AdamW BNB 8-bit",
|
||||
pagedAdamw32bit: "Paged AdamW 32-bit",
|
||||
adamwTorch: "AdamW (PyTorch)",
|
||||
adamwTorchFused: "AdamW (PyTorch Fused)",
|
||||
},
|
||||
lrSchedulerOptions: {
|
||||
linear: "Linear",
|
||||
cosine: "Cosine",
|
||||
},
|
||||
batchSize: "Batch Size",
|
||||
batchSizeTooltip: "Samples processed per step. Higher uses more VRAM.",
|
||||
gradAccum: "Grad Accum",
|
||||
gradAccumTooltip: "Simulates larger batch sizes without extra VRAM.",
|
||||
weightDecay: "Weight Decay",
|
||||
weightDecayTooltip: "L2 regularization to prevent overfitting.",
|
||||
warmupSteps: "Warmup Steps",
|
||||
warmupStepsTooltip: "Gradually increase LR at training start for stability.",
|
||||
scheduleEpochsTooltip:
|
||||
"Number of full passes over the dataset. Set 0 to run by max steps.",
|
||||
saveSteps: "Save Steps",
|
||||
saveStepsTooltip: "Save a checkpoint every N steps. 0 to disable.",
|
||||
evalSteps: "Eval Steps",
|
||||
evalStepsTooltip:
|
||||
"Fraction of total training steps between evaluations (0-1). Set to 0 to disable evaluation. E.g. 0.01 = evaluate every 1% of steps.",
|
||||
seed: "Seed",
|
||||
seedTooltip: "Random seed for reproducibility.",
|
||||
gradCheckpoint: "Grad Checkpoint",
|
||||
gradCheckpointTooltip:
|
||||
"Trade compute for memory by recomputing activations.",
|
||||
none: "None",
|
||||
standard: "Standard",
|
||||
enablePacking: "Enable packing",
|
||||
assistantCompletionsOnly: "Assistant completions only",
|
||||
readMore: "Read more",
|
||||
},
|
||||
training: {
|
||||
title: "Training",
|
||||
description: "Monitor and control training",
|
||||
chartNoDataTitle: "No training data yet",
|
||||
chartNoDataDescription: "Start training to see loss progress",
|
||||
startTraining: "Start Training",
|
||||
starting: "Starting...",
|
||||
loadingModel: "Loading model...",
|
||||
checkingDataset: "Checking dataset...",
|
||||
configLabel: "Training Config",
|
||||
upload: "Upload",
|
||||
uploadConfigTooltip: "Load a saved YAML config",
|
||||
save: "Save",
|
||||
saveConfigTooltip: "Download current config as YAML",
|
||||
reset: "Reset",
|
||||
resetConfigTooltip: "Reset to model defaults",
|
||||
configLoaded: "Config loaded",
|
||||
failedToLoadConfig: "Failed to load config",
|
||||
invalidYamlFile: "Invalid YAML file",
|
||||
failedToReadFile: "Failed to read file",
|
||||
parametersReset: "Parameters reset to model defaults",
|
||||
audioIncompatible:
|
||||
"This model does not support audio. Switch to an audio-capable model or choose a non-audio dataset.",
|
||||
visionIncompatible:
|
||||
"Text model is not compatible with a multimodal dataset. Switch to a vision model or choose a text-only dataset.",
|
||||
cancelTitle: "Cancel Training",
|
||||
cancelDescription: "Do you want to cancel the current training run?",
|
||||
continueAction: "Continue Training",
|
||||
cancelAction: "Cancel Training",
|
||||
stopTitle: "Stop Training",
|
||||
stopDescription: "Choose how you want to stop the current training run.",
|
||||
stopAction: "Stop",
|
||||
stopping: "Stopping...",
|
||||
stopAndSave: "Stop and Save",
|
||||
compareInChat: "Compare in Chat",
|
||||
exportModel: "Export Model",
|
||||
milestone: "Milestone",
|
||||
halfwayDone: "Halfway done. Training is past 50%.",
|
||||
doneNextStep: "Training done. Next step: compare base vs fine-tuned outputs.",
|
||||
},
|
||||
history: {
|
||||
title: "History",
|
||||
emptyTitle: "No training runs yet",
|
||||
emptyDescription:
|
||||
"No training runs yet. Start your first training run in the Configure tab.",
|
||||
loadError: "Failed to load training runs",
|
||||
deleteError: "Failed to delete training run. Please try again.",
|
||||
retry: "Retry",
|
||||
loadMore: "Load more",
|
||||
loading: "Loading...",
|
||||
loadingRun: "Loading training run...",
|
||||
runNotFound: "Run not found",
|
||||
deleteTitle: "Delete training run?",
|
||||
deleteDescription:
|
||||
"This will permanently delete this training run and all its metrics. This action cannot be undone.",
|
||||
runCount: "{count} runs",
|
||||
oneRun: "1 run",
|
||||
resume: "Resume",
|
||||
resumeTraining: "Resume training",
|
||||
resuming: "Resuming...",
|
||||
deleteRun: "Delete run",
|
||||
loss: "Loss",
|
||||
steps: "Steps",
|
||||
lossTrendSparkline: "Loss trend sparkline",
|
||||
relativeJustNow: "just now",
|
||||
relativeMinutesAgo: "{count}m ago",
|
||||
relativeHoursAgo: "{count}h ago",
|
||||
relativeDaysAgo: "{count}d ago",
|
||||
status: {
|
||||
completed: "Completed",
|
||||
stopped: "Stopped",
|
||||
error: "Error",
|
||||
running: "Running",
|
||||
continued: "Continued",
|
||||
},
|
||||
message: {
|
||||
completed: "Training completed",
|
||||
stopped: "Training stopped",
|
||||
running: "Training in progress",
|
||||
errored: "Training errored",
|
||||
},
|
||||
},
|
||||
charts: {
|
||||
settings: "Chart Settings",
|
||||
settingsDescription: "Tune chart presentation while training keeps running.",
|
||||
openSettings: "Open chart settings",
|
||||
viewWindow: "View window",
|
||||
viewWindowDescription: "Show latest steps only or the full history.",
|
||||
window: "Window",
|
||||
all: "All",
|
||||
trainingLoss: "Training Loss",
|
||||
trainingLossDescription: "Control overlays and EMA smoothing.",
|
||||
smoothing: "Smoothing",
|
||||
smoothingDescription: "Move right for more smoothing. `0` = raw.",
|
||||
showRawLoss: "Show raw loss",
|
||||
showSmoothedLoss: "Show smoothed loss",
|
||||
showAverageLine: "Show average line",
|
||||
scaleAndCleanup: "Scale and cleanup",
|
||||
linear: "Linear",
|
||||
log: "Log",
|
||||
noClip: "No clip",
|
||||
clipP99: "Clip p99",
|
||||
clipP95: "Clip p95",
|
||||
lossAxis: "Loss axis",
|
||||
gradientNormAxis: "Gradient norm axis",
|
||||
learningRateAxis: "Learning rate axis",
|
||||
resetDefaults: "Reset defaults",
|
||||
loss: "Loss",
|
||||
smoothed: "Smoothed",
|
||||
evalLoss: "Eval Loss",
|
||||
learningRate: "Learning Rate",
|
||||
lr: "LR",
|
||||
gradNorm: "Grad Norm",
|
||||
gradientNorm: "Gradient Norm",
|
||||
step: "Step {step}",
|
||||
averageValue: "avg {value}",
|
||||
waitingForFirstEvaluationStep: "Waiting for first evaluation step...",
|
||||
evaluationNotConfigured: "Evaluation not configured",
|
||||
evalChartWillAppear: "Chart will appear once eval_steps is reached",
|
||||
setEvalDatasetAndSteps: "Set eval dataset & eval_steps to track eval loss",
|
||||
},
|
||||
progress: {
|
||||
title: "Training Progress",
|
||||
liveMetrics: "Live training metrics",
|
||||
openConfig: "Open training config",
|
||||
configLabel: "Training Config",
|
||||
hyperparams: "Hyperparams",
|
||||
epochs: "Epochs",
|
||||
batchSize: "Batch size",
|
||||
learningRate: "Learning rate",
|
||||
optimizer: "Optimizer",
|
||||
maxSteps: "Max steps",
|
||||
contextLength: "Context length",
|
||||
warmupSteps: "Warmup steps",
|
||||
rank: "Rank",
|
||||
alpha: "Alpha",
|
||||
dropout: "Dropout",
|
||||
variant: "Variant",
|
||||
epoch: "Epoch {value}",
|
||||
percentComplete: "{percent}% complete",
|
||||
stepProgress: "Step {current} / {total}",
|
||||
loss: "Loss",
|
||||
lr: "LR",
|
||||
gradNorm: "Grad Norm",
|
||||
model: "Model",
|
||||
method: "Method",
|
||||
elapsed: "Elapsed: {value}",
|
||||
eta: "ETA: {value}",
|
||||
stepsPerSecond: "{value} steps/s",
|
||||
noStepsPerSecond: "-- steps/s",
|
||||
tokens: "Tokens: {value}",
|
||||
gpuMonitor: "GPU Monitor",
|
||||
live: "Live",
|
||||
utilization: "Utilization",
|
||||
temperature: "Temperature",
|
||||
vram: "VRAM",
|
||||
power: "Power",
|
||||
phase: {
|
||||
idle: "Idle",
|
||||
downloadingModel: "Downloading model",
|
||||
downloadingDataset: "Downloading dataset",
|
||||
loadingModel: "Loading model",
|
||||
loadingDataset: "Loading dataset",
|
||||
configuring: "Configuring",
|
||||
training: "Training",
|
||||
completed: "Completed",
|
||||
error: "Error",
|
||||
stopped: "Stopped",
|
||||
},
|
||||
},
|
||||
trainingStart: {
|
||||
ready: "Ready",
|
||||
downloading: "Downloading",
|
||||
preparing: "Preparing",
|
||||
left: "{eta} left",
|
||||
downloaded: "{size} downloaded",
|
||||
terminalStart: "> unsloth training starts...",
|
||||
preparingResources: "> Preparing model and dataset...",
|
||||
gettingReady: "> We are getting everything ready for your run...",
|
||||
waitingForFirstStep: "> {message} | waiting for first step... ({step})",
|
||||
resumingTraining: "Resuming training...",
|
||||
startingTraining: "starting training...",
|
||||
dataset: "Dataset",
|
||||
modelWeights: "Model weights",
|
||||
},
|
||||
tour: {
|
||||
guidedTour: "Guided Tour",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
712
studio/frontend/src/i18n/locales/zh-CN.ts
Normal file
712
studio/frontend/src/i18n/locales/zh-CN.ts
Normal file
|
|
@ -0,0 +1,712 @@
|
|||
// 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 type { DeepPartialMessageTree } from "../types";
|
||||
import type { en } from "./en";
|
||||
|
||||
export const zhCN = {
|
||||
common: {
|
||||
cancel: "取消",
|
||||
close: "关闭",
|
||||
delete: "删除",
|
||||
done: "完成",
|
||||
error: "错误",
|
||||
export: "导出",
|
||||
help: "帮助",
|
||||
loading: "加载中...",
|
||||
new: "新增",
|
||||
rename: "重命名",
|
||||
save: "保存",
|
||||
search: "搜索",
|
||||
shutdown: "关闭服务",
|
||||
},
|
||||
shell: {
|
||||
accountMenu: "{name} 账号菜单",
|
||||
aria: {
|
||||
home: "Unsloth 首页",
|
||||
closeSidebar: "关闭侧边栏",
|
||||
openSidebar: "打开侧边栏",
|
||||
chatOptions: "聊天选项",
|
||||
runOptions: "训练选项",
|
||||
},
|
||||
navigation: {
|
||||
newChat: "新聊天",
|
||||
compare: "对比",
|
||||
search: "搜索",
|
||||
train: "训练",
|
||||
recipes: "配方",
|
||||
export: "导出",
|
||||
recents: "最近",
|
||||
settings: "设置",
|
||||
api: "API",
|
||||
lightMode: "浅色模式",
|
||||
darkMode: "深色模式",
|
||||
guidedTour: "引导教程",
|
||||
help: "帮助",
|
||||
logOut: "退出登录",
|
||||
shutdown: "关闭服务",
|
||||
},
|
||||
notFound: {
|
||||
title: "页面未找到",
|
||||
description: "{path} 不存在。",
|
||||
backToChat: "返回聊天",
|
||||
},
|
||||
dialog: {
|
||||
deleteChat: {
|
||||
title: "删除聊天",
|
||||
description: "确定要删除聊天“{name}”吗?",
|
||||
},
|
||||
deleteRun: {
|
||||
title: "删除训练运行",
|
||||
description: "确定要删除运行“{name}”吗?",
|
||||
},
|
||||
renameChat: {
|
||||
title: "重命名聊天",
|
||||
placeholder: "聊天标题",
|
||||
},
|
||||
renameRun: {
|
||||
title: "重命名运行",
|
||||
placeholder: "运行名称",
|
||||
},
|
||||
},
|
||||
toast: {
|
||||
cannotDeleteRunningRun: "不能删除正在运行的训练",
|
||||
failedToDeleteChat: "删除聊天失败",
|
||||
failedToDeleteRun: "删除运行失败",
|
||||
failedToRenameChat: "重命名聊天失败",
|
||||
failedToRenameRun: "重命名运行失败",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "设置",
|
||||
dialog: {
|
||||
title: "设置",
|
||||
description: "管理你的 Unsloth Studio 偏好设置。",
|
||||
closeAriaLabel: "关闭设置",
|
||||
},
|
||||
tabs: {
|
||||
general: "通用",
|
||||
profile: "个人资料",
|
||||
appearance: "外观",
|
||||
chat: "聊天",
|
||||
connections: "连接",
|
||||
apiKeys: "API",
|
||||
about: "帮助",
|
||||
},
|
||||
general: {
|
||||
title: "通用",
|
||||
description: "Unsloth Studio 的全局偏好设置。",
|
||||
account: "账号",
|
||||
huggingFaceToken: "Hugging Face token",
|
||||
huggingFaceTokenDescription: "用于加载受限模型和推送产物。",
|
||||
hideToken: "隐藏 token",
|
||||
showToken: "显示 token",
|
||||
chatDefaults: "聊天默认设置",
|
||||
autoTitleNewChats: "自动为新聊天命名",
|
||||
autoTitleNewChatsDescription: "根据第一条消息生成简短标题。",
|
||||
gettingStarted: "入门",
|
||||
startOnboarding: "开始引导",
|
||||
startOnboardingDescription: "重新打开设置向导,不会更改你的账号。",
|
||||
startOnboardingAction: "开始引导",
|
||||
resetPreferences: {
|
||||
sectionTitle: "危险区域",
|
||||
label: "重置所有本地偏好设置",
|
||||
description:
|
||||
"清除仅保存在本地的偏好设置。聊天、API 访问权限和数据库中的聊天设置不会受到影响。",
|
||||
action: "重置偏好设置",
|
||||
confirmTitle: "重置所有本地偏好设置?",
|
||||
confirmDescription:
|
||||
"这会清除仅保存在本地的偏好设置,然后重新加载 Studio。聊天、API 访问权限和数据库中的聊天设置不会受到影响。",
|
||||
confirmAction: "重置并重新加载",
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
title: "个人资料",
|
||||
description: "更新你在 Studio 中显示的个人资料。",
|
||||
changePicture: "更换头像",
|
||||
displayName: "显示名称",
|
||||
nameSaved: "个人资料名称已保存",
|
||||
namePersistErrorTitle: "无法持久保存个人资料名称",
|
||||
namePersistErrorDescription:
|
||||
"名称已在本次会话中更新,但重新加载后可能不会保留。",
|
||||
photoUpdated: "头像已更新",
|
||||
photoPersistErrorTitle: "无法持久保存头像",
|
||||
photoPersistErrorDescription:
|
||||
"头像已在本次会话中更新,但重新加载后可能不会保留。",
|
||||
photoUpdateErrorTitle: "无法更新头像",
|
||||
imageUseError: "无法使用这张图片。",
|
||||
},
|
||||
appearance: {
|
||||
title: "外观",
|
||||
description: "调整 Unsloth Studio 在此设备上的显示方式。",
|
||||
language: {
|
||||
title: "语言",
|
||||
label: "显示语言",
|
||||
description: "选择 Studio 使用的语言。",
|
||||
},
|
||||
theme: {
|
||||
title: "主题",
|
||||
label: "颜色主题",
|
||||
description: "选择浅色、深色,或跟随系统。",
|
||||
system: "跟随系统",
|
||||
light: "浅色",
|
||||
dark: "深色",
|
||||
},
|
||||
layout: {
|
||||
title: "布局",
|
||||
compactSidebar: "默认固定侧边栏",
|
||||
compactSidebarDescription: "保持侧边栏展开,而不是折叠为图标。",
|
||||
},
|
||||
},
|
||||
chat: {
|
||||
title: "聊天",
|
||||
description: "管理此设备上保存的聊天记录。",
|
||||
data: "数据",
|
||||
exportHistory: "导出聊天记录",
|
||||
exportHistoryDescription: "将所有聊天和消息下载为 JSON 文件。",
|
||||
exportAction: "导出",
|
||||
exportingAction: "导出中...",
|
||||
clearHistory: "清除聊天记录",
|
||||
clearHistoryDescription: "从此设备删除本地聊天记录。",
|
||||
clearAction: "清除",
|
||||
clearAllChats: "清除所有聊天",
|
||||
clearAllChatsDescription: "永久删除此设备上的每个聊天。",
|
||||
noChatsToClear: "没有可清除的聊天。",
|
||||
clearOneChatDescription: "永久删除此设备上的唯一一个聊天。",
|
||||
clearChatCountDescription: "永久删除此设备上的 {count} 个聊天。",
|
||||
clearChatsAction: "清除聊天",
|
||||
clearOneChatTitle: "清除 1 个聊天?",
|
||||
clearChatsTitle: "清除 {count} 个聊天?",
|
||||
clearChatsConfirmDescription:
|
||||
"这会永久删除此设备上保存的每个聊天和消息。此操作无法撤销。",
|
||||
clearingAction: "清除中...",
|
||||
clearOneChatAction: "清除 1 个聊天",
|
||||
clearChatCountAction: "清除 {count} 个聊天",
|
||||
clearedAllChats: "已清除所有聊天",
|
||||
clearedOneChat: "已清除 1 个聊天",
|
||||
clearedChatCount: "已清除 {count} 个聊天",
|
||||
someChatsCouldNotBeCleared: "部分聊天无法清除",
|
||||
chatsClearedRemainOne:
|
||||
"已清除 {clearedCount} 个聊天;仍有 1 个聊天保留。请重试。",
|
||||
chatsClearedRemain:
|
||||
"已清除 {clearedCount} 个聊天;仍有 {remainingCount} 个聊天保留。请重试。",
|
||||
oneChatClearedRemain:
|
||||
"已清除 1 个聊天;仍有 {remainingCount} 个聊天保留。请重试。",
|
||||
oneChatClearedRemainOne: "已清除 1 个聊天;仍有 1 个聊天保留。请重试。",
|
||||
storageClearFailedOne:
|
||||
"某个存储位置清除失败;可能仍有 1 个聊天保留。请重试。",
|
||||
storageClearFailed:
|
||||
"某个存储位置清除失败;可能仍有 {count} 个聊天保留。请重试。",
|
||||
failedToClearChats: "清除聊天失败",
|
||||
},
|
||||
connections: {
|
||||
title: "连接",
|
||||
description: "管理提供方和外部服务的连接。",
|
||||
},
|
||||
apiKeys: {
|
||||
title: "API",
|
||||
description: "通过兼容 OpenAI 的 API 以编程方式访问 Unsloth。",
|
||||
readDocs: "阅读 API 文档",
|
||||
noAccess: "还没有 API 访问权限。",
|
||||
newBadge: "新",
|
||||
accessTokens: "访问 token",
|
||||
loadError: "无法加载 API 访问权限。",
|
||||
createError: "无法创建访问 token。",
|
||||
revokeError: "无法撤销访问 token。",
|
||||
never: "永不过期",
|
||||
tokenNamePlaceholder: "Token 名称(例如 production)",
|
||||
newAccessTokenName: "新的访问 token 名称",
|
||||
createToken: "创建 token",
|
||||
creating: "创建中...",
|
||||
newTokenCreated: "新的访问 token 已创建",
|
||||
accessTokenCopied: "访问 token 已复制",
|
||||
copyAccessToken: "复制访问 token",
|
||||
copyNow: "现在复制 - 之后不会再次显示。",
|
||||
usageExamples: "使用示例",
|
||||
usageTools: "工具",
|
||||
copySnippet: "复制代码片段",
|
||||
copy: "复制",
|
||||
copied: "已复制",
|
||||
setupDocs: "设置文档:",
|
||||
relativeNever: "从未",
|
||||
relativeJustNow: "刚刚",
|
||||
relativeHoursAgo: "{count} 小时前",
|
||||
relativeDaysAgo: "{count} 天前",
|
||||
relativeMonthsAgo: "{count} 个月前",
|
||||
relativeYearsAgo: "{count} 年前",
|
||||
expired: "已过期",
|
||||
today: "今天",
|
||||
inDays: "{count} 天后",
|
||||
created: "创建于 {value}",
|
||||
used: "使用于 {value}",
|
||||
expires: "过期时间 {value}",
|
||||
actionsFor: "{name} 的操作",
|
||||
copyPrefix: "复制前缀",
|
||||
revokeToken: "撤销 token",
|
||||
revokeTitle: "撤销访问 token \"{name}\"?",
|
||||
revokeDescription:
|
||||
"使用此 token 的应用会立即失去访问权限。此操作无法撤销。",
|
||||
revokeAction: "撤销 \"{name}\"",
|
||||
revoking: "撤销中...",
|
||||
},
|
||||
about: {
|
||||
title: "帮助",
|
||||
description: "文档、发布说明、反馈和 Studio 构建信息。",
|
||||
studioVersion: "Studio 版本",
|
||||
packageVersion: "包版本",
|
||||
updates: "更新",
|
||||
help: "帮助",
|
||||
documentation: "文档",
|
||||
releaseNotes: "发布说明",
|
||||
whatsNew: "最新内容",
|
||||
feedback: "反馈",
|
||||
reportIssue: "报告问题",
|
||||
dangerZone: "危险区域",
|
||||
shutDownStudio: "关闭 Unsloth Studio",
|
||||
shutDownStudioDescription: "停止 Studio 服务进程并结束你的会话。",
|
||||
shutDown: "关闭",
|
||||
update: {
|
||||
title: "更新 Unsloth Studio",
|
||||
openPowerShell: "打开 PowerShell 并运行:",
|
||||
openTerminal: "打开终端并运行:",
|
||||
commandText: "{label} 文本",
|
||||
copied: "已复制",
|
||||
copyCommand: "复制命令",
|
||||
commandCopied: "{label} 已复制",
|
||||
copyNamedCommand: "复制 {label}",
|
||||
checkingInstall: "正在检查 Studio 的安装方式...",
|
||||
localInstallDetected:
|
||||
"检测到源码或本地安装。为避免替换为 PyPI 版本,请从最初安装时使用的 checkout 或源码位置更新。",
|
||||
pullThenUpdate:
|
||||
"从你的 Unsloth 仓库 checkout 拉取最新变更,然后本地更新 Studio:",
|
||||
gitPullCommand: "git pull 命令",
|
||||
localUpdateCommand: "本地更新命令",
|
||||
localInstallerFallback:
|
||||
"如果 Studio 更新命令不可用,请从该 checkout 运行本地安装器:",
|
||||
localInstallerCommand: "本地安装器命令",
|
||||
sourceInstallDetected:
|
||||
"这看起来是源码或 VCS 包安装。请从最初使用的本地路径或 Git URL 重新安装。",
|
||||
repoCheckoutFallback:
|
||||
"如果你仍保留 Unsloth 仓库 checkout,请从该 checkout 运行本地安装器:",
|
||||
restartAfterUpdate: "更新后重启 Studio,使变更生效。",
|
||||
unknownInstall:
|
||||
"Studio 无法检测安装方式。请先确认你如何安装 Studio,然后选择匹配的更新方式。",
|
||||
curlOrPypi: "对于 curl 或 PyPI 安装,请运行:",
|
||||
updateCommand: "更新命令",
|
||||
localCheckout:
|
||||
"对于本地 checkout 安装,请改为从该 checkout 更新并使用本地更新命令:",
|
||||
fallbackInstruction:
|
||||
"如果失败,或 unsloth studio update 不可用,请运行:",
|
||||
fallbackCommand: "备用命令",
|
||||
},
|
||||
},
|
||||
},
|
||||
studio: {
|
||||
routeTitle: "训练",
|
||||
title: "微调工作台",
|
||||
subtitles: {
|
||||
configure: "配置并开始训练",
|
||||
trainingInProgress: "训练进行中",
|
||||
viewPastRuns: "查看历史训练",
|
||||
viewingPastRun: "正在查看历史训练",
|
||||
},
|
||||
tabs: {
|
||||
configure: "配置",
|
||||
currentRun: "当前训练",
|
||||
history: "历史",
|
||||
},
|
||||
loadingRuntime: "正在加载训练运行时...",
|
||||
backToHistory: "返回历史",
|
||||
sections: {
|
||||
model: "模型",
|
||||
dataset: "数据集",
|
||||
params: "参数",
|
||||
training: "训练",
|
||||
charts: "图表",
|
||||
progress: "训练进度",
|
||||
},
|
||||
configure: {
|
||||
title: "配置",
|
||||
description: "选择模型、数据集和训练设置。",
|
||||
startTraining: "开始训练",
|
||||
starting: "启动中...",
|
||||
loadingModel: "正在加载模型...",
|
||||
checkingDataset: "正在检查数据集...",
|
||||
trainingConfig: "训练配置",
|
||||
},
|
||||
model: {
|
||||
title: "模型",
|
||||
description: "选择基础模型和训练方法",
|
||||
fasterTrainingBadge: "训练速度提升 2 倍",
|
||||
baseModel: "基础模型",
|
||||
localModel: "本地模型",
|
||||
localModelTooltip: "本地已下载模型的路径,或自定义 HF 仓库。",
|
||||
scanningLocalAndCachedModels: "正在扫描本地和缓存模型...",
|
||||
scanning: "正在扫描...",
|
||||
scanningLocalModels: "正在扫描本地模型...",
|
||||
noLocalModelsFound: "未找到本地模型",
|
||||
noLocalModelsFoundManual: "未找到本地模型。请手动输入路径。",
|
||||
failedToLoadLocalModels: "加载本地模型失败",
|
||||
hfCache: "HF 缓存",
|
||||
customFolders: "自定义文件夹",
|
||||
localDir: "本地目录",
|
||||
huggingFaceModel: "Hugging Face 模型",
|
||||
huggingFaceModelTooltip: "搜索 Hugging Face 模型,或从推荐列表中选择。",
|
||||
searchModels: "搜索模型...",
|
||||
searching: "搜索中...",
|
||||
noModelsFound: "未找到模型",
|
||||
needsVram: "约需 {vram}GB 显存(GPU:{gpu}GB)",
|
||||
tightVram: "约 {vram}GB 显存(在 {gpu}GB 上偏紧)",
|
||||
vramEstimate: "约 {vram}GB 显存",
|
||||
method: "方法",
|
||||
methodTooltip:
|
||||
"QLoRA 使用 4 位量化以最大限度降低显存。LoRA 使用 16 位。Full 会更新所有权重。CPT(持续预训练)在原始文本上训练,使模型适配新领域,不使用聊天格式。",
|
||||
readMore: "了解更多",
|
||||
fullFineTune: "全量微调",
|
||||
checkingToken: "正在检查 token...",
|
||||
getOrUpdateToken: "获取或更新 token",
|
||||
huggingFaceTokenOptional: "Hugging Face Token(可选)",
|
||||
continuedPretraining: "持续预训练",
|
||||
localModels: "本地模型",
|
||||
localModelsFound: "找到 {count} 个本地/缓存模型",
|
||||
loadingLocalModels: "正在加载本地模型...",
|
||||
},
|
||||
dataset: {
|
||||
title: "数据集",
|
||||
description: "选择或上传训练数据",
|
||||
source: "数据集来源",
|
||||
chooseDataset: "选择数据集",
|
||||
chooseDatasetTooltip:
|
||||
"通过弹出标签切换 Hugging Face 与本地数据配方输出。",
|
||||
localTab: "本地",
|
||||
searchHuggingFaceDatasets: "搜索 Hugging Face 数据集...",
|
||||
searchLocalDatasets: "搜索本地数据集...",
|
||||
searching: "搜索中...",
|
||||
noDatasetsFound: "未找到数据集",
|
||||
loadingLocalDatasets: "正在加载本地数据集...",
|
||||
failedToLoadLocalDatasets: "加载本地数据集失败。",
|
||||
noLocalDatasetsYet: "还没有本地数据集。",
|
||||
noLocalDatasetsMatchSearch: "没有本地数据集匹配搜索。",
|
||||
openDataRecipes: "打开数据配方",
|
||||
browsingSource: "正在浏览 {browsing}。当前选择仍保持为 {current}。",
|
||||
localDatasets: "本地数据集",
|
||||
localDataset: "本地数据集",
|
||||
localDatasetRows: " / {count} 行",
|
||||
huggingFaceDataset: "Hugging Face 数据集",
|
||||
localDatasetMetadata: "本地数据集元数据",
|
||||
dataRecipeOutput: "数据配方输出。",
|
||||
rows: "行",
|
||||
columns: "列",
|
||||
batches: "批次",
|
||||
updated: "更新时间",
|
||||
evalDataset: "评估数据集",
|
||||
uploading: "上传中...",
|
||||
upload: "上传",
|
||||
uploadEvalFile: "上传评估文件",
|
||||
evalDatasetDescription:
|
||||
"可选。如果未提供,将从训练数据中切分出一小部分。",
|
||||
advanced: "高级",
|
||||
targetFormat: "目标格式",
|
||||
targetFormatTooltip:
|
||||
"训练数据的格式。自动检测对大多数数据集都有效。",
|
||||
auto: "自动",
|
||||
rawText: "原始文本",
|
||||
trainSplitStart: "训练切分起始",
|
||||
trainSplitStartTooltip:
|
||||
"通过指定起始行索引(含,从 0 开始)仅在训练切分的子集上训练。留空则从第一行开始。",
|
||||
trainSplitEnd: "训练切分结束",
|
||||
trainSplitEndTooltip:
|
||||
"训练切分中包含的最后一行索引(含,从 0 开始)。例如将起始设为 0、结束设为 99,可在前 100 行上训练。留空则使用所有剩余行。",
|
||||
endPlaceholder: "结束",
|
||||
clear: "清除",
|
||||
dropFileOrClick: "拖放 1 个文件到此处,或点击上传",
|
||||
viewDataset: "查看数据集",
|
||||
uploadFailed: "上传失败",
|
||||
unknownError: "未知错误",
|
||||
unsupportedFileType: "不支持的文件类型",
|
||||
uploadOneFileType: "上传一个 {types} 文件。",
|
||||
datasetUploaded: "数据集已上传",
|
||||
evalDatasetUploaded: "评估数据集已上传",
|
||||
uploadOneFileAtATime: "一次只能上传一个文件",
|
||||
uploadSingleFileDescription: "训练数据集上传只接受单个文件。",
|
||||
checkingToken: "正在检查 token...",
|
||||
getOrUpdateToken: "获取或更新 token",
|
||||
preview: "预览数据集",
|
||||
split: "切分",
|
||||
subset: "子集",
|
||||
},
|
||||
params: {
|
||||
title: "参数",
|
||||
description: "配置训练超参数",
|
||||
loraSettings: "LoRA 设置",
|
||||
trainingHyperparameters: "训练超参数",
|
||||
maxSteps: "最大步数",
|
||||
epochs: "轮数",
|
||||
useMaxSteps: "使用最大步数",
|
||||
useEpochs: "使用轮数",
|
||||
maxStepsTooltip: "覆盖优化器总步数。",
|
||||
epochsTooltip: "完整遍历数据集的次数。",
|
||||
epochsDescription: "每个 epoch 是对数据集的一次完整遍历。",
|
||||
maxStepsDescription: "将训练限制为固定数量的优化器步数。",
|
||||
contextLength: "上下文长度",
|
||||
contextLengthTooltip: "每个训练样本的最大 token 数。",
|
||||
customContextLength: "输入自定义值",
|
||||
contextLengthDescription: "训练样本的最大序列长度",
|
||||
learningRate: "学习率",
|
||||
learningRateTooltip: "权重更新步长。较低的值训练更慢但更稳定。",
|
||||
learningRateDescription:
|
||||
"推荐值:LoRA 用 2e-4,CPT 用 5e-5,全量微调用 2e-5",
|
||||
embeddingLearningRate: "Embedding 学习率",
|
||||
embeddingLearningRateTooltip:
|
||||
"仅在 CPT 训练 embed_tokens 时使用。Embedding 比 LoRA 权重更易失稳,通常需要更小的学习率。留空则使用 lr/10;常用区间是比主学习率小 2 至 10 倍。仅在词表或领域 token 适配过慢时才提高。",
|
||||
embeddingLearningRateDescription:
|
||||
"留空使用 lr/10(推荐)。常用区间是比主学习率小 2 至 10 倍。",
|
||||
rank: "Rank",
|
||||
rankTooltip: "低秩矩阵的维度。越高容量越大。",
|
||||
alpha: "Alpha",
|
||||
alphaTooltip: "LoRA 更新的缩放因子。通常为 Rank 的 2 倍。",
|
||||
dropout: "Dropout",
|
||||
dropoutTooltip: "LoRA 层的 dropout 概率,用于减少过拟合。",
|
||||
visionLayers: "视觉层",
|
||||
languageLayers: "语言层",
|
||||
attentionModules: "注意力模块",
|
||||
mlpModules: "MLP 模块",
|
||||
targetModules: "目标模块",
|
||||
enableLora: "启用 LoRA",
|
||||
trainWithLora: "使用 LoRA 训练",
|
||||
stableRank: "稳定 Rank",
|
||||
memoryEfficient: "节省内存",
|
||||
optimization: "优化",
|
||||
schedule: "计划",
|
||||
memory: "内存",
|
||||
optimizer: "优化器",
|
||||
optimizerTooltip:
|
||||
"优化算法。8 位变体可降低内存占用。对视觉模型推荐 Fused。",
|
||||
lrScheduler: "LR 调度器",
|
||||
lrSchedulerTooltip:
|
||||
"学习率随训练变化的方式。Linear 平稳衰减;Cosine 曲线衰减。",
|
||||
optimizerOptions: {
|
||||
adamw8bit: "AdamW 8-bit",
|
||||
pagedAdamw8bit: "Paged AdamW 8-bit",
|
||||
adamwBnb8bit: "AdamW BNB 8-bit",
|
||||
pagedAdamw32bit: "Paged AdamW 32-bit",
|
||||
adamwTorch: "AdamW(PyTorch)",
|
||||
adamwTorchFused: "AdamW(PyTorch Fused)",
|
||||
},
|
||||
lrSchedulerOptions: {
|
||||
linear: "线性",
|
||||
cosine: "余弦",
|
||||
},
|
||||
batchSize: "批大小",
|
||||
batchSizeTooltip: "每步处理的样本数。越高占用越多显存。",
|
||||
gradAccum: "梯度累积",
|
||||
gradAccumTooltip: "在不增加显存的情况下模拟更大的批大小。",
|
||||
weightDecay: "权重衰减",
|
||||
weightDecayTooltip: "L2 正则化,用于防止过拟合。",
|
||||
warmupSteps: "预热步数",
|
||||
warmupStepsTooltip: "在训练开始时逐步提高学习率,提升稳定性。",
|
||||
scheduleEpochsTooltip:
|
||||
"完整遍历数据集的次数。设为 0 则按最大步数运行。",
|
||||
saveSteps: "保存步数",
|
||||
saveStepsTooltip: "每 N 步保存一次检查点。0 表示禁用。",
|
||||
evalSteps: "评估步数",
|
||||
evalStepsTooltip:
|
||||
"评估之间间隔占总训练步数的比例(0-1)。设为 0 则禁用评估。例如 0.01 = 每 1% 步评估一次。",
|
||||
seed: "随机种子",
|
||||
seedTooltip: "用于复现的随机种子。",
|
||||
gradCheckpoint: "梯度检查点",
|
||||
gradCheckpointTooltip: "通过重算激活以时间换显存。",
|
||||
none: "无",
|
||||
standard: "标准",
|
||||
enablePacking: "启用 packing",
|
||||
assistantCompletionsOnly: "仅助手回复",
|
||||
readMore: "了解更多",
|
||||
},
|
||||
training: {
|
||||
title: "训练",
|
||||
description: "监控和控制训练",
|
||||
chartNoDataTitle: "暂无训练数据",
|
||||
chartNoDataDescription: "开始训练后可查看 loss 进度",
|
||||
startTraining: "开始训练",
|
||||
starting: "启动中...",
|
||||
loadingModel: "正在加载模型...",
|
||||
checkingDataset: "正在检查数据集...",
|
||||
configLabel: "训练配置",
|
||||
upload: "上传",
|
||||
uploadConfigTooltip: "加载已保存的 YAML 配置",
|
||||
save: "保存",
|
||||
saveConfigTooltip: "将当前配置下载为 YAML",
|
||||
reset: "重置",
|
||||
resetConfigTooltip: "重置为模型默认值",
|
||||
configLoaded: "配置已加载",
|
||||
failedToLoadConfig: "加载配置失败",
|
||||
invalidYamlFile: "无效的 YAML 文件",
|
||||
failedToReadFile: "读取文件失败",
|
||||
parametersReset: "参数已重置为模型默认值",
|
||||
audioIncompatible:
|
||||
"该模型不支持音频。请切换到支持音频的模型,或选择非音频数据集。",
|
||||
visionIncompatible:
|
||||
"文本模型与多模态数据集不兼容。请切换到视觉模型,或选择纯文本数据集。",
|
||||
cancelTitle: "取消训练",
|
||||
cancelDescription: "要取消当前训练运行吗?",
|
||||
continueAction: "继续训练",
|
||||
cancelAction: "取消训练",
|
||||
stopTitle: "停止训练",
|
||||
stopDescription: "选择如何停止当前训练运行。",
|
||||
stopAction: "停止",
|
||||
stopping: "停止中...",
|
||||
stopAndSave: "停止并保存",
|
||||
compareInChat: "在聊天中对比",
|
||||
exportModel: "导出模型",
|
||||
milestone: "里程碑",
|
||||
halfwayDone: "已完成一半。训练进度超过 50%。",
|
||||
doneNextStep: "训练完成。下一步:对比基础模型和微调模型的输出。",
|
||||
},
|
||||
history: {
|
||||
title: "历史",
|
||||
emptyTitle: "还没有训练运行",
|
||||
emptyDescription: "还没有训练运行。请在配置标签页开始第一次训练。",
|
||||
loadError: "加载训练运行失败",
|
||||
deleteError: "删除训练运行失败。请重试。",
|
||||
retry: "重试",
|
||||
loadMore: "加载更多",
|
||||
loading: "加载中...",
|
||||
loadingRun: "正在加载训练运行...",
|
||||
runNotFound: "未找到运行",
|
||||
deleteTitle: "删除训练运行?",
|
||||
deleteDescription: "这会永久删除该训练运行及其所有指标。此操作无法撤销。",
|
||||
runCount: "{count} 次运行",
|
||||
oneRun: "1 次运行",
|
||||
resume: "继续",
|
||||
resumeTraining: "继续训练",
|
||||
resuming: "继续中...",
|
||||
deleteRun: "删除运行",
|
||||
loss: "Loss",
|
||||
steps: "步数",
|
||||
lossTrendSparkline: "Loss 趋势迷你图",
|
||||
relativeJustNow: "刚刚",
|
||||
relativeMinutesAgo: "{count} 分钟前",
|
||||
relativeHoursAgo: "{count} 小时前",
|
||||
relativeDaysAgo: "{count} 天前",
|
||||
status: {
|
||||
completed: "已完成",
|
||||
stopped: "已停止",
|
||||
error: "错误",
|
||||
running: "运行中",
|
||||
continued: "已继续",
|
||||
},
|
||||
message: {
|
||||
completed: "训练已完成",
|
||||
stopped: "训练已停止",
|
||||
running: "训练进行中",
|
||||
errored: "训练出错",
|
||||
},
|
||||
},
|
||||
charts: {
|
||||
settings: "图表设置",
|
||||
settingsDescription: "训练运行时调整图表显示。",
|
||||
openSettings: "打开图表设置",
|
||||
viewWindow: "查看窗口",
|
||||
viewWindowDescription: "只显示最新步数或完整历史。",
|
||||
window: "窗口",
|
||||
all: "全部",
|
||||
trainingLoss: "训练损失",
|
||||
trainingLossDescription: "控制覆盖线和 EMA 平滑。",
|
||||
smoothing: "平滑",
|
||||
smoothingDescription: "向右移动可增加平滑度。`0` = 原始值。",
|
||||
showRawLoss: "显示原始 loss",
|
||||
showSmoothedLoss: "显示平滑 loss",
|
||||
showAverageLine: "显示平均线",
|
||||
scaleAndCleanup: "比例和清理",
|
||||
linear: "线性",
|
||||
log: "对数",
|
||||
noClip: "不裁剪",
|
||||
clipP99: "裁剪 p99",
|
||||
clipP95: "裁剪 p95",
|
||||
lossAxis: "损失轴",
|
||||
gradientNormAxis: "梯度范数轴",
|
||||
learningRateAxis: "学习率轴",
|
||||
resetDefaults: "恢复默认值",
|
||||
loss: "Loss",
|
||||
smoothed: "平滑",
|
||||
evalLoss: "评估 Loss",
|
||||
learningRate: "学习率",
|
||||
lr: "LR",
|
||||
gradNorm: "梯度范数",
|
||||
gradientNorm: "梯度范数",
|
||||
step: "步数 {step}",
|
||||
averageValue: "平均 {value}",
|
||||
waitingForFirstEvaluationStep: "等待首次评估步...",
|
||||
evaluationNotConfigured: "未配置评估",
|
||||
evalChartWillAppear: "达到 eval_steps 后会显示图表",
|
||||
setEvalDatasetAndSteps: "设置评估数据集和 eval_steps 以追踪评估 loss",
|
||||
},
|
||||
progress: {
|
||||
title: "训练进度",
|
||||
liveMetrics: "实时训练指标",
|
||||
openConfig: "打开训练配置",
|
||||
configLabel: "训练配置",
|
||||
hyperparams: "超参数",
|
||||
epochs: "轮数",
|
||||
batchSize: "批大小",
|
||||
learningRate: "学习率",
|
||||
optimizer: "优化器",
|
||||
maxSteps: "最大步数",
|
||||
contextLength: "上下文长度",
|
||||
warmupSteps: "预热步数",
|
||||
rank: "Rank",
|
||||
alpha: "Alpha",
|
||||
dropout: "Dropout",
|
||||
variant: "变体",
|
||||
epoch: "Epoch {value}",
|
||||
percentComplete: "完成 {percent}%",
|
||||
stepProgress: "步数 {current} / {total}",
|
||||
loss: "Loss",
|
||||
lr: "LR",
|
||||
gradNorm: "梯度范数",
|
||||
model: "模型",
|
||||
method: "方法",
|
||||
elapsed: "已用时间:{value}",
|
||||
eta: "ETA:{value}",
|
||||
stepsPerSecond: "{value} 步/秒",
|
||||
noStepsPerSecond: "-- 步/秒",
|
||||
tokens: "Tokens:{value}",
|
||||
gpuMonitor: "GPU 监控",
|
||||
live: "实时",
|
||||
utilization: "利用率",
|
||||
temperature: "温度",
|
||||
vram: "VRAM",
|
||||
power: "功耗",
|
||||
phase: {
|
||||
idle: "空闲",
|
||||
downloadingModel: "正在下载模型",
|
||||
downloadingDataset: "正在下载数据集",
|
||||
loadingModel: "正在加载模型",
|
||||
loadingDataset: "正在加载数据集",
|
||||
configuring: "配置中",
|
||||
training: "训练中",
|
||||
completed: "已完成",
|
||||
error: "错误",
|
||||
stopped: "已停止",
|
||||
},
|
||||
},
|
||||
trainingStart: {
|
||||
ready: "就绪",
|
||||
downloading: "下载中",
|
||||
preparing: "准备中",
|
||||
left: "剩余 {eta}",
|
||||
downloaded: "已下载 {size}",
|
||||
terminalStart: "> Unsloth 训练开始...",
|
||||
preparingResources: "> 正在准备模型和数据集...",
|
||||
gettingReady: "> 正在为本次运行做好准备...",
|
||||
waitingForFirstStep: "> {message} | 等待第一步...({step})",
|
||||
resumingTraining: "正在继续训练...",
|
||||
startingTraining: "正在开始训练...",
|
||||
dataset: "数据集",
|
||||
modelWeights: "模型权重",
|
||||
},
|
||||
tour: {
|
||||
guidedTour: "引导教程",
|
||||
},
|
||||
},
|
||||
} satisfies DeepPartialMessageTree<typeof en>;
|
||||
76
studio/frontend/src/i18n/messages.ts
Normal file
76
studio/frontend/src/i18n/messages.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// 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 { getLocale } from "./locale-store";
|
||||
import { en } from "./locales/en";
|
||||
import { zhCN } from "./locales/zh-CN";
|
||||
import type { InterpolationValues, MessageKey } from "./types";
|
||||
|
||||
export const LOCALES = {
|
||||
en: { label: "English", nativeLabel: "English" },
|
||||
"zh-CN": { label: "Chinese (Simplified)", nativeLabel: "简体中文" },
|
||||
} as const;
|
||||
|
||||
export type Locale = keyof typeof LOCALES;
|
||||
export type TranslationKey = MessageKey<typeof en>;
|
||||
|
||||
export const messages = { en, "zh-CN": zhCN } as const;
|
||||
|
||||
const PLACEHOLDER_PATTERN = /\{([a-zA-Z0-9_]+)\}/g;
|
||||
|
||||
function readMessage(tree: unknown, key: string): string | undefined {
|
||||
let cursor = tree;
|
||||
for (const segment of key.split(".")) {
|
||||
if (
|
||||
cursor === null ||
|
||||
typeof cursor !== "object" ||
|
||||
!Object.prototype.hasOwnProperty.call(cursor, segment)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
cursor = (cursor as Record<string, unknown>)[segment];
|
||||
}
|
||||
return typeof cursor === "string" ? cursor : undefined;
|
||||
}
|
||||
|
||||
function interpolate(
|
||||
template: string,
|
||||
values: InterpolationValues | undefined,
|
||||
): string {
|
||||
if (!values) return template;
|
||||
|
||||
return template.replace(PLACEHOLDER_PATTERN, (match, name: string) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(values, name)) return match;
|
||||
const value = values[name];
|
||||
return value === null || value === undefined ? "" : String(value);
|
||||
});
|
||||
}
|
||||
|
||||
function warnMissingEnglishMessage(key: string): void {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`[i18n] Missing English translation for key "${key}".`);
|
||||
}
|
||||
}
|
||||
|
||||
export function translate(
|
||||
key: TranslationKey,
|
||||
values?: InterpolationValues,
|
||||
locale: Locale = getLocale(),
|
||||
): string {
|
||||
const localized = readMessage(messages[locale], key);
|
||||
const fallback = localized ?? readMessage(messages.en, key);
|
||||
|
||||
if (fallback === undefined) {
|
||||
warnMissingEnglishMessage(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
return interpolate(fallback, values);
|
||||
}
|
||||
|
||||
export function isSupportedLocale(value: unknown): value is Locale {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
Object.prototype.hasOwnProperty.call(LOCALES, value)
|
||||
);
|
||||
}
|
||||
30
studio/frontend/src/i18n/types.ts
Normal file
30
studio/frontend/src/i18n/types.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export type MessageTree = {
|
||||
readonly [key: string]: string | MessageTree;
|
||||
};
|
||||
|
||||
export type DeepPartialMessageTree<T> = {
|
||||
readonly [K in keyof T]?: T[K] extends string
|
||||
? string
|
||||
: T[K] extends MessageTree
|
||||
? DeepPartialMessageTree<T[K]>
|
||||
: never;
|
||||
};
|
||||
|
||||
type Join<Prefix extends string, Key extends string> =
|
||||
Prefix extends "" ? Key : `${Prefix}.${Key}`;
|
||||
|
||||
export type MessageKey<T, Prefix extends string = ""> = {
|
||||
[K in Extract<keyof T, string>]: T[K] extends string
|
||||
? Join<Prefix, K>
|
||||
: T[K] extends MessageTree
|
||||
? MessageKey<T[K], Join<Prefix, K>>
|
||||
: never;
|
||||
}[Extract<keyof T, string>];
|
||||
|
||||
export type InterpolationValues = Record<
|
||||
string,
|
||||
string | number | boolean | null | undefined
|
||||
>;
|
||||
|
|
@ -7,6 +7,7 @@ import { createRoot } from "react-dom/client";
|
|||
import "./index.css";
|
||||
import { fetchDeviceType } from "./config/env";
|
||||
import { App } from "./app/app";
|
||||
import { initializeLocale } from "./i18n";
|
||||
|
||||
const globalCrypto = globalThis.crypto as Crypto | undefined;
|
||||
|
||||
|
|
@ -33,6 +34,8 @@ if (!rootElement) {
|
|||
throw new Error("Root element not found");
|
||||
}
|
||||
|
||||
initializeLocale();
|
||||
|
||||
fetchDeviceType().then(() => {
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue