From d66bc2760b5b2e279f30f820ac7b8445989283b2 Mon Sep 17 00:00:00 2001 From: Shine1i Date: Mon, 9 Mar 2026 14:47:20 +0100 Subject: [PATCH] feat(studio): rework chart settings with a new preferences store and revamped settings UI --- .../components/assistant-ui/markdown-text.tsx | 111 ++-- studio/frontend/src/components/ui/chart.tsx | 153 +++--- .../studio/sections/charts-content.tsx | 198 ++++--- .../charts/chart-preferences-store.ts | 93 ++++ .../sections/charts/chart-settings-sheet.tsx | 312 +++++++++++ .../sections/charts/eval-loss-chart-card.tsx | 35 +- .../sections/charts/grad-norm-chart-card.tsx | 69 +-- .../charts/learning-rate-chart-card.tsx | 55 +- .../sections/charts/shared-chart-settings.tsx | 80 --- .../charts/training-loss-chart-card.tsx | 131 ++--- .../features/studio/sections/charts/types.ts | 7 - .../features/studio/sections/charts/utils.ts | 92 +++- .../studio/sections/progress-section.tsx | 503 +++++++++++------- 13 files changed, 1130 insertions(+), 709 deletions(-) create mode 100644 studio/frontend/src/features/studio/sections/charts/chart-preferences-store.ts create mode 100644 studio/frontend/src/features/studio/sections/charts/chart-settings-sheet.tsx delete mode 100644 studio/frontend/src/features/studio/sections/charts/shared-chart-settings.tsx diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index c967b33c27..ecc272815c 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -1,32 +1,42 @@ "use client"; -import { INTERNAL, useMessagePartText } from "@assistant-ui/react"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; +import { INTERNAL, useMessagePartText } from "@assistant-ui/react"; import { Copy02Icon, Tick02Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { code } from "@streamdown/code"; import { math } from "@streamdown/math"; import { mermaid } from "@streamdown/mermaid"; import { DownloadIcon } from "lucide-react"; -import { Block, type BlockProps, Streamdown } from "streamdown"; import { useEffect, useRef, useState } from "react"; +import { Block, type BlockProps, Streamdown } from "streamdown"; import "katex/dist/katex.min.css"; import { AudioPlayer } from "./audio-player"; const { withSmoothContextProvider } = INTERNAL; +const COPY_RESET_MS = 2000; +const MERMAID_SOURCE_RE = /```mermaid\s*([\s\S]*?)```/i; +const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/; +const ACTION_PANEL_CLASS = + "pointer-events-auto flex shrink-0 items-center gap-2 rounded-md border border-sidebar bg-sidebar/80 px-1.5 py-1 supports-[backdrop-filter]:bg-sidebar/70 supports-[backdrop-filter]:backdrop-blur"; +const ACTION_BUTTON_CLASS = + "cursor-pointer p-1 text-muted-foreground transition-all hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"; + +type CodeFence = { + language: string | null; + source: string; +}; function getMermaidSource(blockContent: string): string | null { - const source = blockContent.match(/```mermaid\s*([\s\S]*?)```/i)?.[1]?.trim(); + const source = blockContent.match(MERMAID_SOURCE_RE)?.[1]?.trim(); return source && source.length > 0 ? source : null; } -function getCodeFence( - blockContent: string, -): { language: string | null; source: string } | null { - const match = blockContent - .trimEnd() - .match(/^```([^\n`]*)\n([\s\S]*?)\n?```$/); - if (!match) return null; +function getCodeFence(blockContent: string): CodeFence | null { + const match = blockContent.trimEnd().match(CODE_FENCE_RE); + if (!match) { + return null; + } return { language: match[1]?.trim() || null, @@ -56,23 +66,26 @@ function getCodeFilename(language: string | null) { }; const normalized = language?.toLowerCase(); - const ext = normalized ? extByLanguage[normalized] || normalized : "txt"; + const fallbackExt = normalized?.replace(/[^a-z0-9]+/g, "-"); + const ext = normalized + ? extByLanguage[normalized] || fallbackExt || "txt" + : "txt"; return `snippet.${ext}`; } -function downloadTextFile(filename: string, text: string) { +function downloadTextFile(filename: string, text: string): void { const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; + document.body.appendChild(anchor); anchor.click(); - URL.revokeObjectURL(url); + document.body.removeChild(anchor); + window.setTimeout(() => URL.revokeObjectURL(url), 0); } -const COPY_RESET_MS = 2000; - -function MermaidCopyButton({ source }: { source: string }) { +function useCopiedState() { const [copied, setCopied] = useState(false); const resetTimeoutRef = useRef | null>(null); @@ -84,24 +97,39 @@ function MermaidCopyButton({ source }: { source: string }) { }; }, []); + const showCopied = () => { + setCopied(true); + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + resetTimeoutRef.current = setTimeout(() => { + setCopied(false); + resetTimeoutRef.current = null; + }, COPY_RESET_MS); + }; + + return { copied, showCopied }; +} + +function MermaidCopyButton({ source }: { source: string }) { + const { copied, showCopied } = useCopiedState(); + return ( ); } @@ -115,42 +143,31 @@ function CodeBlockActions({ language: string | null; source: string; }) { - const [copied, setCopied] = useState(false); - const resetTimeoutRef = useRef | null>(null); - - useEffect(() => { - return () => { - if (resetTimeoutRef.current) { - clearTimeout(resetTimeoutRef.current); - } - }; - }, []); + const { copied, showCopied } = useCopiedState(); return (
-
+
+ ))} +
+ ); +} + +function SettingRow({ + label, + description, + control, +}: { + label: string; + description?: string; + control: ReactElement; +}): ReactElement { + return ( +
+
+ + {description ? ( +

{description}

+ ) : null} +
+
{control}
+
+ ); +} + +function ScaleSection({ + title, + scale, + setScale, + outlierMode, + setOutlierMode, +}: { + title: string; + scale: ScaleMode; + setScale: (value: ScaleMode) => void; + outlierMode: OutlierMode; + setOutlierMode: (value: OutlierMode) => void; +}): ReactElement { + return ( +
+
+

{title}

+

Scale and cleanup

+
+ + +
+ ); +} + +export function ChartSettingsSheet(): ReactElement { + const [open, setOpen] = useState(false); + const { + availableSteps, + windowSize, + smoothing, + showRaw, + showSmoothed, + showAvgLine, + lossScale, + lrScale, + gradScale, + lossOutlierMode, + gradOutlierMode, + lrOutlierMode, + setWindowSize, + setSmoothing, + setShowRaw, + setShowSmoothed, + setShowAvgLine, + setLossScale, + setLrScale, + setGradScale, + setLossOutlierMode, + setGradOutlierMode, + setLrOutlierMode, + resetPreferences, + } = useChartPreferencesStore( + useShallow((state) => ({ + availableSteps: state.availableSteps, + windowSize: state.windowSize, + smoothing: state.smoothing, + showRaw: state.showRaw, + showSmoothed: state.showSmoothed, + showAvgLine: state.showAvgLine, + lossScale: state.lossScale, + lrScale: state.lrScale, + gradScale: state.gradScale, + lossOutlierMode: state.lossOutlierMode, + gradOutlierMode: state.gradOutlierMode, + lrOutlierMode: state.lrOutlierMode, + setWindowSize: state.setWindowSize, + setSmoothing: state.setSmoothing, + setShowRaw: state.setShowRaw, + setShowSmoothed: state.setShowSmoothed, + setShowAvgLine: state.setShowAvgLine, + setLossScale: state.setLossScale, + setLrScale: state.setLrScale, + setGradScale: state.setGradScale, + setLossOutlierMode: state.setLossOutlierMode, + setGradOutlierMode: state.setGradOutlierMode, + setLrOutlierMode: state.setLrOutlierMode, + resetPreferences: state.resetPreferences, + })), + ); + + const minWindow = Math.min(10, Math.max(1, availableSteps)); + const effectiveWindowSize = + windowSize == null ? Math.max(availableSteps, 1) : windowSize; + const showingAll = + availableSteps > 0 && + (windowSize == null || effectiveWindowSize >= availableSteps); + const sliderMax = Math.max(minWindow, availableSteps || 1); + + return ( + <> + + + + + Chart Settings + + Tune chart presentation while training keeps running. + + +
+
+
+

View window

+

+ Show latest steps only or the full history. +

+
+
+
+ Window + + {showingAll ? "All" : effectiveWindowSize} + +
+ setWindowSize(value)} + min={minWindow} + max={sliderMax} + step={1} + disabled={availableSteps <= 1} + /> +
+
+ +
+
+

Training loss

+

+ Control overlays and EMA smoothing. +

+
+
+
+ Smoothing + {smoothing.toFixed(2)} +
+ setSmoothing(value)} + min={0} + max={0.9} + step={0.01} + /> +

+ Move right for more smoothing. `0` = raw. +

+
+ + } + /> + + } + /> + + } + /> +
+ + + + + + +
+ + + + +
+
+ + ); +} diff --git a/studio/frontend/src/features/studio/sections/charts/eval-loss-chart-card.tsx b/studio/frontend/src/features/studio/sections/charts/eval-loss-chart-card.tsx index 844eb87b4e..a165f62d3a 100644 --- a/studio/frontend/src/features/studio/sections/charts/eval-loss-chart-card.tsx +++ b/studio/frontend/src/features/studio/sections/charts/eval-loss-chart-card.tsx @@ -11,7 +11,7 @@ import { ChartAverageIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import type { ReactElement } from "react"; import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; -import { formatStepTick, placeholderEvalData } from "./utils"; +import { formatMetric, formatStepTick, placeholderEvalData } from "./utils"; const evalLossConfig = { loss: { label: "Eval Loss", color: "#ef4444" }, @@ -33,14 +33,23 @@ export function EvalLossChartCard({ return ( - 0 ? "" : " text-muted-foreground"}`}> + 0 ? "" : " text-muted-foreground"}`} + > Eval Loss {data.length > 0 ? ( - - + + Number(value).toFixed(2)} + width={80} + tickFormatter={(value) => formatMetric(Number(value))} /> `Step ${payload?.[0]?.payload?.step ?? ""}` } + formatter={(_value, _name, item) => [ + formatMetric(Number(item?.payload?.loss)), + "Eval Loss", + ]} /> } /> @@ -91,7 +104,10 @@ export function EvalLossChartCard({ ) : (
- +
- +

{isTraining && evalEnabled ? "Waiting for first evaluation step…" diff --git a/studio/frontend/src/features/studio/sections/charts/grad-norm-chart-card.tsx b/studio/frontend/src/features/studio/sections/charts/grad-norm-chart-card.tsx index 0efa57c3fd..3866837ca4 100644 --- a/studio/frontend/src/features/studio/sections/charts/grad-norm-chart-card.tsx +++ b/studio/frontend/src/features/studio/sections/charts/grad-norm-chart-card.tsx @@ -1,4 +1,4 @@ -import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ChartContainer, ChartLegend, @@ -7,19 +7,15 @@ import { ChartTooltipContent, } from "@/components/ui/chart"; import type { ChartConfig } from "@/components/ui/chart"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Settings02Icon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; import type { ReactElement } from "react"; import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; -import { SharedChartSettings } from "./shared-chart-settings"; -import type { OutlierMode, ScaleMode, ViewSettingsState } from "./types"; -import { CHART_SYNC_ID, formatMetric, formatStepTick, fromLog1p } from "./utils"; +import type { ScaleMode } from "./types"; +import { + CHART_SYNC_ID, + formatMetric, + formatStepTick, + fromLog1p, +} from "./utils"; const gradNormConfig = { displayGradNorm: { label: "Grad Norm", color: "#f97316" }, @@ -37,50 +33,25 @@ export function GradNormChartCard({ visibleStepDomain, xAxisTicks, scale, - setScale, - outlierMode, - setOutlierMode, - viewSettings, }: { data: GradNormPoint[]; domain: [number, number]; visibleStepDomain: [number, number]; xAxisTicks: number[]; scale: ScaleMode; - setScale: (value: ScaleMode) => void; - outlierMode: OutlierMode; - setOutlierMode: (value: OutlierMode) => void; - viewSettings: ViewSettingsState; }): ReactElement { + const showPoint = data.length <= 1 ? { r: 3, strokeWidth: 0 } : false; + return ( - Gradient Norm - - - - - - - Chart Settings - - - - + Gradient Norm - + { const num = Number(value); - if (!Number.isFinite(num)) return "0"; + if (!Number.isFinite(num)) { + return "0"; + } const shown = scale === "log" ? fromLog1p(num) : num; return formatMetric(shown); }} @@ -133,11 +106,11 @@ export function GradNormChartCard({ } /> void; - outlierMode: OutlierMode; - setOutlierMode: (value: OutlierMode) => void; - viewSettings: ViewSettingsState; }): ReactElement { + const showPoint = data.length <= 1 ? { r: 3, strokeWidth: 0 } : false; + return ( - Learning Rate - - - - - - - Chart Settings - - - - + Learning Rate @@ -114,7 +77,9 @@ export function LearningRateChartCard({ width={52} tickFormatter={(value) => { const num = Number(value); - if (!Number.isFinite(num)) return "0e+0"; + if (!Number.isFinite(num)) { + return "0e+0"; + } const shown = scale === "log" ? fromLog1p(num) : num; return shown.toExponential(0); }} @@ -136,11 +101,11 @@ export function LearningRateChartCard({ } /> void; - outlierMode: OutlierMode; - setOutlierMode: (value: OutlierMode) => void; -}): ReactElement { - const showingAll = view.allStepsLength > 0 && view.effectiveWindowSize >= view.allStepsLength; - - return ( - <> - - View -

-
- - - {showingAll ? "All" : view.effectiveWindowSize} - -
- view.setWindowSize(Math.max(1, Math.round(v)))} - min={view.minWindow} - max={Math.max(view.minWindow, view.allStepsLength)} - step={1} - /> - - Always follows latest steps - -
- - Y Scale - checked && setScale("linear")} - > - Linear - - checked && setScale("log")} - > - Log (log1p) - - - Outliers - checked && setOutlierMode("none")} - > - No clipping - - checked && setOutlierMode("p99")} - > - Clip above p99 - - checked && setOutlierMode("p95")} - > - Clip above p95 - - - ); -} diff --git a/studio/frontend/src/features/studio/sections/charts/training-loss-chart-card.tsx b/studio/frontend/src/features/studio/sections/charts/training-loss-chart-card.tsx index bc548d0c11..1edf2bad55 100644 --- a/studio/frontend/src/features/studio/sections/charts/training-loss-chart-card.tsx +++ b/studio/frontend/src/features/studio/sections/charts/training-loss-chart-card.tsx @@ -1,4 +1,4 @@ -import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ChartContainer, ChartLegend, @@ -7,23 +7,22 @@ import { ChartTooltipContent, } from "@/components/ui/chart"; import type { ChartConfig } from "@/components/ui/chart"; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Label } from "@/components/ui/label"; -import { Slider } from "@/components/ui/slider"; -import { Settings02Icon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; import type { ReactElement } from "react"; -import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from "recharts"; -import { SharedChartSettings } from "./shared-chart-settings"; -import type { OutlierMode, ScaleMode, ViewSettingsState } from "./types"; -import { CHART_SYNC_ID, formatMetric, formatStepTick, fromLog1p } from "./utils"; +import { + CartesianGrid, + Line, + LineChart, + ReferenceLine, + XAxis, + YAxis, +} from "recharts"; +import type { ScaleMode } from "./types"; +import { + CHART_SYNC_ID, + formatMetric, + formatStepTick, + fromLog1p, +} from "./utils"; const lossConfig = { displayLoss: { label: "Loss", color: "#3b82f6" }, @@ -45,19 +44,10 @@ export function TrainingLossChartCard({ xAxisTicks, avgRaw, avgDisplay, - smoothing, - setSmoothing, showRaw, - setShowRaw, showSmoothed, - setShowSmoothed, showAvgLine, - setShowAvgLine, - viewSettings, scale, - setScale, - outlierMode, - setOutlierMode, }: { data: LossChartPoint[]; domain: [number, number]; @@ -65,81 +55,17 @@ export function TrainingLossChartCard({ xAxisTicks: number[]; avgRaw: number; avgDisplay: number; - smoothing: number; - setSmoothing: (value: number) => void; showRaw: boolean; - setShowRaw: (value: boolean) => void; showSmoothed: boolean; - setShowSmoothed: (value: boolean) => void; showAvgLine: boolean; - setShowAvgLine: (value: boolean) => void; - viewSettings: ViewSettingsState; scale: ScaleMode; - setScale: (value: ScaleMode) => void; - outlierMode: OutlierMode; - setOutlierMode: (value: OutlierMode) => void; }): ReactElement { + const showPoint = data.length <= 1 ? { r: 3, strokeWidth: 0 } : false; + return ( - Training Loss - - - - - - - Chart Settings - -
-
- - - {smoothing.toFixed(2)} - -
- setSmoothing(v)} - min={0} - max={0.99} - step={0.01} - /> -
- - setShowRaw(Boolean(value))} - > - Show raw loss - - setShowSmoothed(Boolean(value))} - > - Show smoothed loss - - setShowAvgLine(Boolean(value))} - > - Show average line - - -
-
-
+ Training Loss
@@ -173,10 +99,12 @@ export function TrainingLossChartCard({ axisLine={false} tickMargin={4} fontSize={10} - width={52} + width={80} tickFormatter={(value) => { const num = Number(value); - if (!Number.isFinite(num)) return "0"; + if (!Number.isFinite(num)) { + return "0"; + } const shown = scale === "log" ? fromLog1p(num) : num; return formatMetric(shown); }} @@ -189,7 +117,10 @@ export function TrainingLossChartCard({ } formatter={(_value, name, item) => { if (name === "displaySmoothed") { - return [formatMetric(Number(item?.payload?.smoothed)), "Smoothed"]; + return [ + formatMetric(Number(item?.payload?.smoothed)), + "Smoothed", + ]; } return [formatMetric(Number(item?.payload?.loss)), "Loss"]; }} @@ -212,12 +143,12 @@ export function TrainingLossChartCard({ )} {showRaw && ( void; -} diff --git a/studio/frontend/src/features/studio/sections/charts/utils.ts b/studio/frontend/src/features/studio/sections/charts/utils.ts index 482ca00f76..db5b53bd09 100644 --- a/studio/frontend/src/features/studio/sections/charts/utils.ts +++ b/studio/frontend/src/features/studio/sections/charts/utils.ts @@ -3,6 +3,8 @@ import type { LossHistoryItem, OutlierMode, SmoothedLossItem } from "./types"; export const CHART_SYNC_ID = "train-metrics-sync"; export const MAX_RENDER_POINTS = 800; export const DEFAULT_VISIBLE_POINTS = 160; +const TRAILING_ZEROES_RE = /\.?0+$/; +const NEGATIVE_ZERO_RE = /^-0$/; export const placeholderEvalData = [ { step: 0, loss: 2.8 }, @@ -22,11 +24,30 @@ export function fromLog1p(value: number): number { } export function formatMetric(value: number): string { - if (!Number.isFinite(value)) return "0"; - if (value === 0) return "0"; - if (value >= 1000) return value.toFixed(0); - if (value >= 1) return value.toFixed(2); - return value.toExponential(2); + if (!Number.isFinite(value)) { + return "0"; + } + const abs = Math.abs(value); + let decimals = 6; + + if (abs >= 1000) { + decimals = 0; + } else if (abs >= 100) { + decimals = 2; + } else if (abs >= 1) { + decimals = 4; + } else if (abs >= 0.01) { + decimals = 5; + } else if (abs >= 0.0001) { + decimals = 6; + } else { + decimals = 8; + } + + return value + .toFixed(decimals) + .replace(TRAILING_ZEROES_RE, "") + .replace(NEGATIVE_ZERO_RE, "0"); } export function formatStepTick(value: number): string { @@ -54,18 +75,12 @@ export function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } -export function getDefaultWindowSize(totalSteps: number): number { - if (totalSteps <= 1) { - return Math.max(totalSteps, 1); - } - if (totalSteps <= DEFAULT_VISIBLE_POINTS) { - return clamp(Math.floor(totalSteps * 0.6), 1, totalSteps); - } - return DEFAULT_VISIBLE_POINTS; -} - -export function buildStepTicks(min: number, max: number, targetCount = 6): number[] { - if (!Number.isFinite(min) || !Number.isFinite(max)) { +export function buildStepTicks( + min: number, + max: number, + targetCount = 6, +): number[] { + if (!(Number.isFinite(min) && Number.isFinite(max))) { return [0, 1]; } if (max <= min) { @@ -104,10 +119,17 @@ export function buildYDomain(values: number[]): [number, number] { return [min - pad, max + pad]; } -function getUpperPercentile(values: number[], mode: OutlierMode): number | null { - if (mode === "none") return null; +function getUpperPercentile( + values: number[], + mode: OutlierMode, +): number | null { + if (mode === "none") { + return null; + } const finiteValues = values.filter((value) => Number.isFinite(value)); - if (finiteValues.length < 3) return null; + if (finiteValues.length < 3) { + return null; + } const sorted = [...finiteValues].sort((a, b) => a - b); const q = mode === "p99" ? 0.99 : 0.95; @@ -120,18 +142,36 @@ function getUpperPercentile(values: number[], mode: OutlierMode): number | null export function applyOutlierCap(values: number[], mode: OutlierMode): number[] { const cap = getUpperPercentile(values, mode); - if (cap == null) return values; + if (cap == null) { + return values; + } return values.map((value) => Math.min(value, cap)); } -export function ema(data: LossHistoryItem[], alpha: number): SmoothedLossItem[] { +export function ema( + data: LossHistoryItem[], + alpha: number, +): SmoothedLossItem[] { if (data.length === 0) { return []; } - let s = data[0].loss; - return data.map((d) => { - s = alpha * d.loss + (1 - alpha) * s; - return { ...d, smoothed: +s.toFixed(4) }; + const values = data.map((point) => point.loss); + const isConstant = values.every((value) => value === values[0]); + + let last = 0; + let count = 0; + + return data.map((point) => { + const next = point.loss; + if (!Number.isFinite(next) || isConstant) { + return { ...point, smoothed: next }; + } + + last = last * alpha + (1 - alpha) * next; + count += 1; + + const debias = alpha === 1 ? 1 : 1 - alpha ** count; + return { ...point, smoothed: last / debias }; }); } diff --git a/studio/frontend/src/features/studio/sections/progress-section.tsx b/studio/frontend/src/features/studio/sections/progress-section.tsx index 10a547b82d..2125c95164 100644 --- a/studio/frontend/src/features/studio/sections/progress-section.tsx +++ b/studio/frontend/src/features/studio/sections/progress-section.tsx @@ -15,11 +15,16 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { Progress } from "@/components/ui/progress"; +import { OPTIMIZER_OPTIONS } from "@/config/training"; +import { setTrainingCompareHandoff } from "@/features/chat"; import { - useTrainingConfigStore, useTrainingActions, + useTrainingConfigStore, useTrainingRuntimeStore, } from "@/features/training"; +import { useGpuUtilization } from "@/hooks"; +import { cn } from "@/lib/utils"; import { ChartAverageIcon, DashboardSpeed01Icon, @@ -30,13 +35,28 @@ import { ZapIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { useEffect, useState, type ReactElement, type ReactNode } from "react"; import { Link, useNavigate } from "@tanstack/react-router"; +import { type ReactElement, type ReactNode, useEffect, useState } from "react"; import { useShallow } from "zustand/react/shallow"; -import { useGpuUtilization } from "@/hooks"; -import { setTrainingCompareHandoff } from "@/features/chat"; -import { OPTIMIZER_OPTIONS } from "@/config/training"; -import { formatDuration, formatNumber, phaseColors, phaseLabel } from "./progress-section-lib"; +import { ChartSettingsSheet } from "./charts/chart-settings-sheet"; +import { + formatDuration, + formatNumber, + phaseColors, + phaseLabel, +} from "./progress-section-lib"; + +type ConfigGroup = { + section: string; + rows: [string, string | number | null | undefined][]; +}; + +function configRow( + label: string, + value: string | number | null | undefined, +): [string, string | number | null | undefined] { + return [label, value]; +} export function ProgressSection(): ReactElement { const navigate = useNavigate(); @@ -94,12 +114,12 @@ export function ProgressSection(): ReactElement { const pct = runtime.totalSteps > 0 ? Math.min( - 100, - Math.max( - 0, - Math.round((runtime.currentStep / runtime.totalSteps) * 100), - ), - ) + 100, + Math.max( + 0, + Math.round((runtime.currentStep / runtime.totalSteps) * 100), + ), + ) : Math.round(runtime.progressPercent); const elapsed = runtime.elapsedSeconds; @@ -110,15 +130,26 @@ export function ProgressSection(): ReactElement { const eta = runtime.etaSeconds ?? derivedEta; const stepsPerSecond = - elapsed != null && elapsed > 0 - ? runtime.currentStep / elapsed - : null; + elapsed != null && elapsed > 0 ? runtime.currentStep / elapsed : null; const showHalfwayHint = runtime.phase === "training" && pct >= 50 && pct < 100; const showCompletedHint = runtime.phase === "completed"; - const handleCompareInChat = () => { + const handleCompareInChat = async () => { setTrainingCompareHandoff(config.selectedModel); - void navigate({ to: "/chat" }); + await navigate({ to: "/chat" }); + }; + const requestStop = async (saveCheckpoint: boolean) => { + setStopRequested(true); + setStopDialogOpen(false); + useTrainingRuntimeStore.getState().setStopRequested(true); + try { + const ok = await stopTrainingRun(saveCheckpoint); + if (!ok) { + setStopRequested(false); + } + } catch { + setStopRequested(false); + } }; const stoppedLoss = getDisplayMetric( @@ -133,37 +164,37 @@ export function ProgressSection(): ReactElement { ); const stoppedGradNorm = runtime.isTrainingRunning ? runtime.currentGradNorm - : lastNonZeroValue(runtime.gradNormHistory) ?? runtime.currentGradNorm; + : (lastNonZeroValue(runtime.gradNormHistory) ?? runtime.currentGradNorm); const optimizerLabel = OPTIMIZER_OPTIONS.find((o) => o.value === config.optimizerType)?.label ?? config.optimizerType; - const configItems = [ + const configItems: ConfigGroup[] = [ { section: "Hyperparams", rows: [ - ["Epochs", config.epochs], - ["Batch size", config.batchSize], - ["Learning rate", config.learningRate], - ["Optimizer", optimizerLabel], - ["Max steps", config.maxSteps], - ["Context length", config.contextLength], - ["Warmup steps", config.warmupSteps], + configRow("Epochs", config.epochs), + configRow("Batch size", config.batchSize), + configRow("Learning rate", config.learningRate), + configRow("Optimizer", optimizerLabel), + configRow("Max steps", config.maxSteps), + configRow("Context length", config.contextLength), + configRow("Warmup steps", config.warmupSteps), ], }, ...(config.trainingMethod !== "full" ? [ - { - section: "LoRA", - rows: [ - ["Rank", config.loraRank], - ["Alpha", config.loraAlpha], - ["Dropout", config.loraDropout], - ["Variant", config.loraVariant], - ], - }, - ] + { + section: "LoRA", + rows: [ + configRow("Rank", config.loraRank), + configRow("Alpha", config.loraAlpha), + configRow("Dropout", config.loraDropout), + configRow("Variant", config.loraVariant), + ], + }, + ] : []), ]; @@ -173,182 +204,76 @@ export function ProgressSection(): ReactElement { title="Training Progress" description={runtime.message || "Live training metrics"} accent="emerald" - className="shadow-border ring-1 ring-border" + className="shadow-border border border-border/60 bg-card/90 ring-0 backdrop-blur-sm" headerAction={ -
- - - - - -
-

Training Config

- {configItems.map((group) => ( -
-

- {group.section} -

- {group.rows.map(([label, value]) => ( -
- - {String(label)} - - - {String(value)} - -
- ))} -
- ))} -
-
-
- - - - - Stop Training - - Choose how you want to stop the current training run. - - - - Continue Training - { - setStopRequested(true); - setStopDialogOpen(false); - useTrainingRuntimeStore.getState().setStopRequested(true); - void stopTrainingRun(false).then((ok) => { - if (!ok) setStopRequested(false); - }); - }} - > - Cancel Training - - { - setStopRequested(true); - setStopDialogOpen(false); - useTrainingRuntimeStore.getState().setStopRequested(true); - void stopTrainingRun(true).then((ok) => { - if (!ok) setStopRequested(false); - }); - }} - > - Stop and Save - - - - -
+ } > -
+
-
+
{phaseLabel[runtime.phase]} Epoch {runtime.currentEpoch.toFixed(2)} + + {pct}% complete +
-
+
Step {runtime.currentStep} / {runtime.totalSteps || "--"} {pct}%
-
-
-
+
- {(showHalfwayHint || showCompletedHint) && ( -
-

- {showCompletedHint - ? "Training done. Next step: compare base vs fine-tuned outputs." - : "Halfway done. Training is past 50%."} -

- {showCompletedHint && ( -
- - -
- )} -
- )} + {runtime.error && ( -

{runtime.error}

+

+ {runtime.error} +

)} -
-
-

Loss

-

- {stoppedLoss.toFixed(4)} -

-
-
-

LR

-

- {stoppedLr.toExponential(2)} -

-
-
-

Grad Norm

-

- {formatNumber(stoppedGradNorm, 3)} -

-
-
-

Model

-

- {config.selectedModel ?? "--"} -

-
-
-

Method

-

- {config.trainingMethod.toUpperCase()} -

-
+
+ + {stoppedLoss.toFixed(4)} + + {stoppedLr.toExponential(2)} + + {formatNumber(stoppedGradNorm, 3)} + + + {config.selectedModel ?? "--"} + + + {config.trainingMethod.toUpperCase()} +
-
+
Elapsed: {formatDuration(elapsed)} ETA: {formatDuration(eta)} @@ -363,8 +288,13 @@ export function ProgressSection(): ReactElement {
-

GPU Monitor

-
+
+

+ GPU Monitor +

+ Live +
+
} - value={gpu.gpu_utilization_pct != null ? `${gpu.gpu_utilization_pct}%` : "--"} + value={ + gpu.gpu_utilization_pct != null + ? `${gpu.gpu_utilization_pct}%` + : "--" + } pct={gpu.gpu_utilization_pct ?? 0} /> } - value={gpu.temperature_c != null ? `${gpu.temperature_c}°C` : "--"} + icon={ + + } + value={ + gpu.temperature_c != null ? `${gpu.temperature_c}°C` : "--" + } pct={gpu.temperature_c ?? 0} max={100} /> } - value={gpu.vram_used_gb != null && gpu.vram_total_gb != null ? `${gpu.vram_used_gb} / ${gpu.vram_total_gb} GB` : "--"} + value={ + gpu.vram_used_gb != null && gpu.vram_total_gb != null + ? `${gpu.vram_used_gb} / ${gpu.vram_total_gb} GB` + : "--" + } pct={gpu.vram_utilization_pct ?? 0} /> } - value={gpu.power_draw_w != null ? (gpu.power_limit_w != null ? `${gpu.power_draw_w} / ${gpu.power_limit_w} W` : `${gpu.power_draw_w} W`) : "--"} + value={ + gpu.power_draw_w != null + ? gpu.power_limit_w != null + ? `${gpu.power_draw_w} / ${gpu.power_limit_w} W` + : `${gpu.power_draw_w} W` + : "--" + } pct={gpu.power_utilization_pct ?? 0} />
@@ -402,6 +350,171 @@ export function ProgressSection(): ReactElement { ); } +function TrainingHeaderActions({ + configItems, + isTrainingRunning, + onOpenStopDialog, + onRequestStop, + stopDialogOpen, + stopRequested, +}: { + configItems: ConfigGroup[]; + isTrainingRunning: boolean; + onOpenStopDialog: (open: boolean) => void; + onRequestStop: (saveCheckpoint: boolean) => Promise; + stopDialogOpen: boolean; + stopRequested: boolean; +}): ReactElement { + return ( +
+ + + + + +
+

Training Config

+ {configItems.map((group) => ( +
+

+ {group.section} +

+ {group.rows.map(([label, value]) => ( +
+ {label} + + {String(value)} + +
+ ))} +
+ ))} +
+
+
+ + + + + + Stop Training + + Choose how you want to stop the current training run. + + + + Continue Training + onRequestStop(false)} + > + Cancel Training + + onRequestStop(true)}> + Stop and Save + + + + +
+ ); +} + +function MilestoneCallout({ + showCompletedHint, + showHalfwayHint, + onCompareInChat, +}: { + showCompletedHint: boolean; + showHalfwayHint: boolean; + onCompareInChat: () => Promise; +}): ReactElement | null { + if (!(showHalfwayHint || showCompletedHint)) { + return null; + } + + return ( +
+
+
+ {!showCompletedHint && ( +

+ Milestone +

+ )} +

+ {showCompletedHint + ? "Training done. Next step: compare base vs fine-tuned outputs." + : "Halfway done. Training is past 50%."} +

+
+ {!showCompletedHint && ( + + 50%+ + + )} +
+ {showCompletedHint && ( +
+ + +
+ )} +
+ ); +} + +function MetricStat({ + label, + children, + valueClassName, +}: { + label: string; + children: ReactNode; + valueClassName?: string; +}): ReactElement { + return ( +
+

{label}

+

+ {children} +

+
+ ); +} + function lastNonZeroValue(points: { value: number }[]): number | null { for (let i = points.length - 1; i >= 0; i -= 1) { const value = points[i]?.value; @@ -436,7 +549,7 @@ function GpuStat({ pct: number; max?: number; }): ReactElement { - const clamped = Math.min(pct, max ?? 100); + const clamped = Math.max(0, Math.min(pct, max ?? 100)); let barColor = "bg-red-500"; if (clamped < 60) { barColor = "bg-emerald-500"; @@ -445,7 +558,7 @@ function GpuStat({ } return ( -
+
{icon} @@ -453,7 +566,7 @@ function GpuStat({ {value}
-
+