feat(studio): rework chart settings with a new preferences store and revamped settings UI

This commit is contained in:
Shine1i 2026-03-09 14:47:20 +01:00
commit d66bc2760b
13 changed files with 1130 additions and 709 deletions

View file

@ -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<ReturnType<typeof setTimeout> | 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 (
<button
type="button"
className="absolute top-3.5 right-20 z-20 cursor-pointer text-muted-foreground transition-all hover:text-foreground"
title="Copy Mermaid source"
onClick={() => {
if (!copyToClipboard(source)) return;
setCopied(true);
if (resetTimeoutRef.current) {
clearTimeout(resetTimeoutRef.current);
if (!copyToClipboard(source)) {
return;
}
resetTimeoutRef.current = setTimeout(() => {
setCopied(false);
resetTimeoutRef.current = null;
}, COPY_RESET_MS);
showCopied();
}}
>
<HugeiconsIcon icon={copied ? Tick02Icon : Copy02Icon} className="size-5" />
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy02Icon}
className="size-5"
/>
</button>
);
}
@ -115,42 +143,31 @@ function CodeBlockActions({
language: string | null;
source: string;
}) {
const [copied, setCopied] = useState(false);
const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (resetTimeoutRef.current) {
clearTimeout(resetTimeoutRef.current);
}
};
}, []);
const { copied, showCopied } = useCopiedState();
return (
<div className="pointer-events-none absolute top-3.5 right-3 z-20 flex items-center justify-end">
<div className="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">
<div className={ACTION_PANEL_CLASS}>
<button
type="button"
className="cursor-pointer p-1 text-muted-foreground transition-all hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
className={ACTION_BUTTON_CLASS}
title="Copy code"
disabled={disabled}
onClick={() => {
if (!copyToClipboard(source)) return;
setCopied(true);
if (resetTimeoutRef.current) {
clearTimeout(resetTimeoutRef.current);
if (!copyToClipboard(source)) {
return;
}
resetTimeoutRef.current = setTimeout(() => {
setCopied(false);
resetTimeoutRef.current = null;
}, COPY_RESET_MS);
showCopied();
}}
>
<HugeiconsIcon icon={copied ? Tick02Icon : Copy02Icon} className="size-3.5" />
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy02Icon}
className="size-3.5"
/>
</button>
<button
type="button"
className="cursor-pointer p-1 text-muted-foreground transition-all hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
className={ACTION_BUTTON_CLASS}
title="Download file"
disabled={disabled}
onClick={() => {

View file

@ -249,73 +249,92 @@ function ChartTooltipContent({
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
let customContent: React.ReactNode = null;
let formattedValue: React.ReactNode =
item.value != null && typeof item.value !== "object"
? String(item.value)
: item.value;
let formattedLabel: React.ReactNode = itemConfig?.label || item.name;
if (formatter && item?.value !== undefined && item.name) {
const result = formatter(
item.value,
item.name,
item,
index,
item.payload,
);
if (Array.isArray(result)) {
formattedValue = result[0];
formattedLabel = result[1];
} else {
customContent = result;
}
}
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{customContent ?? (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between gap-3 leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{formattedLabel}</span>
</div>
{formattedValue != null && (
<span className="text-foreground font-mono font-medium tabular-nums">
{formattedValue}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}

View file

@ -1,11 +1,12 @@
import { type ReactElement, useMemo, useState } from "react";
import { type ReactElement, useEffect, useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
import { useChartPreferencesStore } from "./charts/chart-preferences-store";
import { EvalLossChartCard } from "./charts/eval-loss-chart-card";
import { GradNormChartCard } from "./charts/grad-norm-chart-card";
import { LearningRateChartCard } from "./charts/learning-rate-chart-card";
import { TrainingLossChartCard } from "./charts/training-loss-chart-card";
import type { OutlierMode, ScaleMode, TrainingChartSeries, ViewSettingsState } from "./charts/types";
import type { TrainingChartSeries } from "./charts/types";
import {
DEFAULT_VISIBLE_POINTS,
MAX_RENDER_POINTS,
applyOutlierCap,
buildStepTicks,
@ -16,29 +17,82 @@ import {
toLog1p,
} from "./charts/utils";
type LossDisplayPoint = {
step: number;
displayLoss: number;
displaySmoothed: number;
};
function isStepVisible(step: number, domain: [number, number]): boolean {
return step >= domain[0] && step <= domain[1];
}
function collectLossValues(
data: LossDisplayPoint[],
domain: [number, number],
options: { includeRaw: boolean; includeSmoothed: boolean },
): number[] {
const values: number[] = [];
for (const point of data) {
if (!isStepVisible(point.step, domain)) {
continue;
}
if (options.includeRaw && Number.isFinite(point.displayLoss)) {
values.push(point.displayLoss);
}
if (options.includeSmoothed && Number.isFinite(point.displaySmoothed)) {
values.push(point.displaySmoothed);
}
}
return values;
}
export function ChartsContent({
metrics,
isTraining,
evalEnabled,
}: { metrics: TrainingChartSeries; isTraining: boolean; evalEnabled: boolean }): ReactElement {
const [smoothing, setSmoothing] = useState(0.75);
const [showRaw, setShowRaw] = useState(true);
const [showSmoothed, setShowSmoothed] = useState(true);
const [showAvgLine, setShowAvgLine] = useState(true);
const [windowSize, setWindowSize] = useState<number | null>(
Math.max(24, Math.floor(DEFAULT_VISIBLE_POINTS / 2)),
}: {
metrics: TrainingChartSeries;
isTraining: boolean;
evalEnabled: boolean;
}): ReactElement {
const {
windowSize,
smoothing,
showRaw,
showSmoothed,
showAvgLine,
lossScale,
lrScale,
gradScale,
lossOutlierMode,
gradOutlierMode,
lrOutlierMode,
setAvailableSteps,
} = useChartPreferencesStore(
useShallow((state) => ({
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,
setAvailableSteps: state.setAvailableSteps,
})),
);
const [lossScale, setLossScale] = useState<ScaleMode>("linear");
const [lrScale, setLrScale] = useState<ScaleMode>("linear");
const [gradScale, setGradScale] = useState<ScaleMode>("linear");
const [lossOutlierMode, setLossOutlierMode] = useState<OutlierMode>("none");
const [gradOutlierMode, setGradOutlierMode] = useState<OutlierMode>("none");
const [lrOutlierMode, setLrOutlierMode] = useState<OutlierMode>("none");
const smoothedData = useMemo(
() => (metrics.lossHistory.length > 0 ? ema(metrics.lossHistory, 1 - smoothing) : []),
() =>
metrics.lossHistory.length > 0 ? ema(metrics.lossHistory, smoothing) : [],
[metrics.lossHistory, smoothing],
);
@ -61,11 +115,21 @@ export function ChartsContent({
const allSteps = useMemo(() => {
const set = new Set<number>();
for (const point of reducedLossData) set.add(point.step);
for (const point of reducedGradNormData) set.add(point.step);
for (const point of reducedLrData) set.add(point.step);
for (const point of metrics.lossHistory) {
set.add(point.step);
}
for (const point of metrics.gradNormHistory) {
set.add(point.step);
}
for (const point of metrics.lrHistory) {
set.add(point.step);
}
return Array.from(set).sort((a, b) => a - b);
}, [reducedGradNormData, reducedLossData, reducedLrData]);
}, [metrics.gradNormHistory, metrics.lossHistory, metrics.lrHistory]);
useEffect(() => {
setAvailableSteps(allSteps.length);
}, [allSteps.length, setAvailableSteps]);
const stepCount = Math.max(1, allSteps.length);
const effectiveWindowSize =
@ -129,35 +193,23 @@ export function ChartsContent({
);
const visibleLossDisplayValues = useMemo(() => {
const values: number[] = [];
const visibleValues = collectLossValues(
displayLossData,
visibleStepDomain,
{
includeRaw: showRaw,
includeSmoothed: showSmoothed,
},
);
for (const point of displayLossData) {
if (point.step < visibleStepDomain[0] || point.step > visibleStepDomain[1]) {
continue;
}
if (showRaw && Number.isFinite(point.displayLoss)) {
values.push(point.displayLoss);
}
if (showSmoothed && Number.isFinite(point.displaySmoothed)) {
values.push(point.displaySmoothed);
}
if (visibleValues.length > 0) {
return visibleValues;
}
if (values.length === 0) {
for (const point of displayLossData) {
if (point.step < visibleStepDomain[0] || point.step > visibleStepDomain[1]) {
continue;
}
if (Number.isFinite(point.displayLoss)) {
values.push(point.displayLoss);
}
if (Number.isFinite(point.displaySmoothed)) {
values.push(point.displaySmoothed);
}
}
}
return values;
return collectLossValues(displayLossData, visibleStepDomain, {
includeRaw: true,
includeSmoothed: true,
});
}, [displayLossData, showRaw, showSmoothed, visibleStepDomain]);
const visibleGradDisplayValues = useMemo(
@ -165,7 +217,8 @@ export function ChartsContent({
displayGradData
.filter(
(point) =>
point.step >= visibleStepDomain[0] && point.step <= visibleStepDomain[1],
point.step >= visibleStepDomain[0] &&
point.step <= visibleStepDomain[1],
)
.map((point) => point.displayGradNorm)
.filter((value) => Number.isFinite(value)),
@ -177,7 +230,8 @@ export function ChartsContent({
displayLrData
.filter(
(point) =>
point.step >= visibleStepDomain[0] && point.step <= visibleStepDomain[1],
point.step >= visibleStepDomain[0] &&
point.step <= visibleStepDomain[1],
)
.map((point) => point.displayLr)
.filter((value) => Number.isFinite(value)),
@ -185,11 +239,13 @@ export function ChartsContent({
);
const lossDomain = useMemo(
() => buildYDomain(applyOutlierCap(visibleLossDisplayValues, lossOutlierMode)),
() =>
buildYDomain(applyOutlierCap(visibleLossDisplayValues, lossOutlierMode)),
[lossOutlierMode, visibleLossDisplayValues],
);
const gradDomain = useMemo(
() => buildYDomain(applyOutlierCap(visibleGradDisplayValues, gradOutlierMode)),
() =>
buildYDomain(applyOutlierCap(visibleGradDisplayValues, gradOutlierMode)),
[gradOutlierMode, visibleGradDisplayValues],
);
const lrDomain = useMemo(
@ -203,7 +259,9 @@ export function ChartsContent({
}, [reducedEvalLossData]);
const evalLossStepTicks = useMemo(() => {
if (reducedEvalLossData.length < 2) return undefined;
if (reducedEvalLossData.length < 2) {
return undefined;
}
const min = reducedEvalLossData[0].step;
const max = reducedEvalLossData[reducedEvalLossData.length - 1].step;
return buildStepTicks(min, max);
@ -218,21 +276,6 @@ export function ChartsContent({
: 0;
const avgDisplay = lossScale === "log" ? toLog1p(avgRaw) : avgRaw;
const minWindow = Math.min(10, Math.max(1, allSteps.length));
const viewSettings: ViewSettingsState = {
effectiveWindowSize,
minWindow,
allStepsLength: allSteps.length,
setWindowSize: (value) => {
const clampedWindow = clamp(Math.round(value), 1, Math.max(1, allSteps.length));
if (clampedWindow >= allSteps.length) {
setWindowSize(null);
return;
}
setWindowSize(clampedWindow);
},
};
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<TrainingLossChartCard
@ -242,19 +285,10 @@ export function ChartsContent({
xAxisTicks={xAxisTicks}
avgRaw={avgRaw}
avgDisplay={avgDisplay}
smoothing={smoothing}
setSmoothing={setSmoothing}
showRaw={showRaw}
setShowRaw={setShowRaw}
showSmoothed={showSmoothed}
setShowSmoothed={setShowSmoothed}
showAvgLine={showAvgLine}
setShowAvgLine={setShowAvgLine}
viewSettings={viewSettings}
scale={lossScale}
setScale={setLossScale}
outlierMode={lossOutlierMode}
setOutlierMode={setLossOutlierMode}
/>
<GradNormChartCard
data={displayGradData}
@ -262,10 +296,6 @@ export function ChartsContent({
visibleStepDomain={visibleStepDomain}
xAxisTicks={xAxisTicks}
scale={gradScale}
setScale={setGradScale}
outlierMode={gradOutlierMode}
setOutlierMode={setGradOutlierMode}
viewSettings={viewSettings}
/>
<LearningRateChartCard
data={displayLrData}
@ -273,10 +303,6 @@ export function ChartsContent({
visibleStepDomain={visibleStepDomain}
xAxisTicks={xAxisTicks}
scale={lrScale}
setScale={setLrScale}
outlierMode={lrOutlierMode}
setOutlierMode={setLrOutlierMode}
viewSettings={viewSettings}
/>
<EvalLossChartCard
data={reducedEvalLossData}

View file

@ -0,0 +1,93 @@
import { create } from "zustand";
import type { OutlierMode, ScaleMode } from "./types";
import { DEFAULT_VISIBLE_POINTS, clamp } from "./utils";
const DEFAULT_WINDOW_SIZE = Math.max(
24,
Math.floor(DEFAULT_VISIBLE_POINTS / 2),
);
type ChartPreferencesState = {
availableSteps: number;
windowSize: number | null;
smoothing: number;
showRaw: boolean;
showSmoothed: boolean;
showAvgLine: boolean;
lossScale: ScaleMode;
lrScale: ScaleMode;
gradScale: ScaleMode;
lossOutlierMode: OutlierMode;
gradOutlierMode: OutlierMode;
lrOutlierMode: OutlierMode;
setAvailableSteps: (value: number) => void;
setWindowSize: (value: number | null) => void;
setSmoothing: (value: number) => void;
setShowRaw: (value: boolean) => void;
setShowSmoothed: (value: boolean) => void;
setShowAvgLine: (value: boolean) => void;
setLossScale: (value: ScaleMode) => void;
setLrScale: (value: ScaleMode) => void;
setGradScale: (value: ScaleMode) => void;
setLossOutlierMode: (value: OutlierMode) => void;
setGradOutlierMode: (value: OutlierMode) => void;
setLrOutlierMode: (value: OutlierMode) => void;
resetPreferences: () => void;
};
const defaultPreferences = {
windowSize: DEFAULT_WINDOW_SIZE as number | null,
smoothing: 0.6,
showRaw: true,
showSmoothed: true,
showAvgLine: true,
lossScale: "linear" as ScaleMode,
lrScale: "linear" as ScaleMode,
gradScale: "linear" as ScaleMode,
lossOutlierMode: "none" as OutlierMode,
gradOutlierMode: "none" as OutlierMode,
lrOutlierMode: "none" as OutlierMode,
};
export const useChartPreferencesStore = create<ChartPreferencesState>(
(set) => ({
availableSteps: 0,
...defaultPreferences,
setAvailableSteps: (value) =>
set((state) => {
const availableSteps = Math.max(0, Math.round(value));
if (state.windowSize == null || availableSteps <= 0) {
return { availableSteps };
}
if (state.windowSize >= availableSteps) {
return { availableSteps, windowSize: null };
}
return {
availableSteps,
windowSize: clamp(Math.round(state.windowSize), 1, availableSteps),
};
}),
setWindowSize: (value) =>
set((state) => {
if (value == null || state.availableSteps <= 0) {
return { windowSize: null };
}
const next = clamp(Math.round(value), 1, state.availableSteps);
return { windowSize: next >= state.availableSteps ? null : next };
}),
setSmoothing: (value) => set({ smoothing: clamp(value, 0, 0.9) }),
setShowRaw: (value) => set({ showRaw: value }),
setShowSmoothed: (value) => set({ showSmoothed: value }),
setShowAvgLine: (value) => set({ showAvgLine: value }),
setLossScale: (value) => set({ lossScale: value }),
setLrScale: (value) => set({ lrScale: value }),
setGradScale: (value) => set({ gradScale: value }),
setLossOutlierMode: (value) => set({ lossOutlierMode: value }),
setGradOutlierMode: (value) => set({ gradOutlierMode: value }),
setLrOutlierMode: (value) => set({ lrOutlierMode: value }),
resetPreferences: () => set({ ...defaultPreferences }),
}),
);

View file

@ -0,0 +1,312 @@
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Settings02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactElement, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useChartPreferencesStore } from "./chart-preferences-store";
import type { OutlierMode, ScaleMode } from "./types";
function ChoiceButtons<T extends string>({
options,
value,
onChange,
}: {
options: { label: string; value: T }[];
value: T;
onChange: (value: T) => void;
}): ReactElement {
return (
<div className="flex flex-wrap gap-2">
{options.map((option) => (
<Button
key={option.value}
type="button"
size="xs"
variant={value === option.value ? "secondary" : "outline"}
onClick={() => onChange(option.value)}
>
{option.label}
</Button>
))}
</div>
);
}
function SettingRow({
label,
description,
control,
}: {
label: string;
description?: string;
control: ReactElement;
}): ReactElement {
return (
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<Label className="text-sm">{label}</Label>
{description ? (
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
) : null}
</div>
<div className="shrink-0">{control}</div>
</div>
);
}
function ScaleSection({
title,
scale,
setScale,
outlierMode,
setOutlierMode,
}: {
title: string;
scale: ScaleMode;
setScale: (value: ScaleMode) => void;
outlierMode: OutlierMode;
setOutlierMode: (value: OutlierMode) => void;
}): ReactElement {
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>
</div>
<ChoiceButtons
options={[
{ label: "Linear", value: "linear" },
{ label: "Log", value: "log" },
]}
value={scale}
onChange={setScale}
/>
<ChoiceButtons
options={[
{ label: "No clip", value: "none" },
{ label: "Clip p99", value: "p99" },
{ label: "Clip p95", value: "p95" },
]}
value={outlierMode}
onChange={setOutlierMode}
/>
</div>
);
}
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 (
<>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={() => setOpen(true)}
aria-label="Open chart settings"
>
<HugeiconsIcon icon={Settings02Icon} className="size-4" />
</Button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
className="w-full sm:max-w-md"
overlayClassName="bg-transparent backdrop-blur-0"
>
<SheetHeader className="pb-4">
<SheetTitle>Chart Settings</SheetTitle>
<SheetDescription>
Tune chart presentation while training keeps running.
</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-xs text-muted-foreground">
Show latest steps only or the full history.
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Window</span>
<span className="tabular-nums">
{showingAll ? "All" : effectiveWindowSize}
</span>
</div>
<Slider
value={[effectiveWindowSize]}
onValueChange={([value]) => setWindowSize(value)}
min={minWindow}
max={sliderMax}
step={1}
disabled={availableSteps <= 1}
/>
</div>
</div>
<Separator />
<div className="space-y-4">
<div>
<p className="text-sm font-medium">Training loss</p>
<p className="text-xs text-muted-foreground">
Control overlays and EMA smoothing.
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Smoothing</span>
<span className="tabular-nums">{smoothing.toFixed(2)}</span>
</div>
<Slider
value={[smoothing]}
onValueChange={([value]) => setSmoothing(value)}
min={0}
max={0.9}
step={0.01}
/>
<p className="text-[11px] text-muted-foreground">
Move right for more smoothing. `0` = raw.
</p>
</div>
<SettingRow
label="Show raw loss"
control={
<Switch checked={showRaw} onCheckedChange={setShowRaw} />
}
/>
<SettingRow
label="Show smoothed loss"
control={
<Switch
checked={showSmoothed}
onCheckedChange={setShowSmoothed}
/>
}
/>
<SettingRow
label="Show average line"
control={
<Switch
checked={showAvgLine}
onCheckedChange={setShowAvgLine}
/>
}
/>
</div>
<Separator />
<ScaleSection
title="Loss axis"
scale={lossScale}
setScale={setLossScale}
outlierMode={lossOutlierMode}
setOutlierMode={setLossOutlierMode}
/>
<Separator />
<ScaleSection
title="Gradient norm axis"
scale={gradScale}
setScale={setGradScale}
outlierMode={gradOutlierMode}
setOutlierMode={setGradOutlierMode}
/>
<Separator />
<ScaleSection
title="Learning rate axis"
scale={lrScale}
setScale={setLrScale}
outlierMode={lrOutlierMode}
setOutlierMode={setLrOutlierMode}
/>
</div>
<SheetFooter className="mt-0 border-t border-border/60 bg-background/70 sm:flex-row sm:justify-between">
<Button
type="button"
variant="outline"
size="sm"
onClick={resetPreferences}
>
Reset defaults
</Button>
<Button type="button" size="sm" onClick={() => setOpen(false)}>
Done
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</>
);
}

View file

@ -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 (
<Card data-tour="studio-eval-loss" size="sm">
<CardHeader>
<CardTitle className={`text-sm pl-2${data.length > 0 ? "" : " text-muted-foreground"}`}>
<CardTitle
className={`text-sm pl-1${data.length > 0 ? "" : " text-muted-foreground"}`}
>
Eval Loss
</CardTitle>
</CardHeader>
<CardContent>
{data.length > 0 ? (
<ChartContainer config={evalLossConfig} className="-ml-3 h-[220px] w-full">
<LineChart data={data} accessibilityLayer={true} margin={{ left: 0, right: 8 }}>
<ChartContainer
config={evalLossConfig}
className="-ml-3 h-[220px] w-full"
>
<LineChart
data={data}
accessibilityLayer={true}
margin={{ left: 0, right: 8 }}
>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="step"
@ -64,8 +73,8 @@ export function EvalLossChartCard({
axisLine={false}
tickMargin={4}
fontSize={10}
width={52}
tickFormatter={(value) => Number(value).toFixed(2)}
width={80}
tickFormatter={(value) => formatMetric(Number(value))}
/>
<ChartTooltip
content={
@ -73,6 +82,10 @@ export function EvalLossChartCard({
labelFormatter={(_value, payload) =>
`Step ${payload?.[0]?.payload?.step ?? ""}`
}
formatter={(_value, _name, item) => [
formatMetric(Number(item?.payload?.loss)),
"Eval Loss",
]}
/>
}
/>
@ -91,7 +104,10 @@ export function EvalLossChartCard({
</ChartContainer>
) : (
<div className="relative">
<ChartContainer config={evalLossConfig} className="-ml-3 h-[220px] w-full blur">
<ChartContainer
config={evalLossConfig}
className="-ml-3 h-[220px] w-full blur"
>
<LineChart
data={placeholderEvalData}
accessibilityLayer={true}
@ -126,7 +142,10 @@ export function EvalLossChartCard({
</LineChart>
</ChartContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1">
<HugeiconsIcon icon={ChartAverageIcon} className="size-5 text-muted-foreground/50" />
<HugeiconsIcon
icon={ChartAverageIcon}
className="size-5 text-muted-foreground/50"
/>
<p className="text-sm font-medium text-muted-foreground">
{isTraining && evalEnabled
? "Waiting for first evaluation step…"

View file

@ -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 (
<Card size="sm">
<CardHeader>
<CardTitle className="text-sm pl-2">Gradient Norm</CardTitle>
<CardAction>
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
className="cursor-pointer rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<HugeiconsIcon icon={Settings02Icon} className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs">Chart Settings</DropdownMenuLabel>
<SharedChartSettings
view={viewSettings}
scale={scale}
setScale={setScale}
outlierMode={outlierMode}
setOutlierMode={setOutlierMode}
/>
</DropdownMenuContent>
</DropdownMenu>
</CardAction>
<CardTitle className="text-sm pl-1">Gradient Norm</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={gradNormConfig} className="-ml-3 h-[220px] w-full">
<ChartContainer
config={gradNormConfig}
className="-ml-3 h-[220px] w-full"
>
<LineChart
data={data}
syncId={CHART_SYNC_ID}
@ -111,10 +82,12 @@ export function GradNormChartCard({
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);
}}
@ -133,11 +106,11 @@ export function GradNormChartCard({
}
/>
<Line
type="monotoneX"
type="linear"
dataKey="displayGradNorm"
stroke="var(--color-displayGradNorm)"
strokeWidth={2}
dot={false}
dot={showPoint}
activeDot={{ r: 3, strokeWidth: 0 }}
connectNulls={true}
strokeLinecap="round"

View file

@ -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,18 +7,9 @@ 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 type { ScaleMode } from "./types";
import { CHART_SYNC_ID, formatStepTick, fromLog1p } from "./utils";
const lrConfig = {
@ -37,47 +28,19 @@ export function LearningRateChartCard({
visibleStepDomain,
xAxisTicks,
scale,
setScale,
outlierMode,
setOutlierMode,
viewSettings,
}: {
data: LearningRatePoint[];
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 (
<Card size="sm">
<CardHeader>
<CardTitle className="text-sm pl-2">Learning Rate</CardTitle>
<CardAction>
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
className="cursor-pointer rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<HugeiconsIcon icon={Settings02Icon} className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs">Chart Settings</DropdownMenuLabel>
<SharedChartSettings
view={viewSettings}
scale={scale}
setScale={setScale}
outlierMode={outlierMode}
setOutlierMode={setOutlierMode}
/>
</DropdownMenuContent>
</DropdownMenu>
</CardAction>
<CardTitle className="text-sm pl-1">Learning Rate</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={lrConfig} className="-ml-1.5 h-[220px] w-full">
@ -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({
}
/>
<Line
type="monotoneX"
type="linear"
dataKey="displayLr"
stroke="var(--color-displayLr)"
strokeWidth={2}
dot={false}
dot={showPoint}
activeDot={{ r: 3, strokeWidth: 0 }}
connectNulls={true}
strokeLinecap="round"

View file

@ -1,80 +0,0 @@
import { DropdownMenuCheckboxItem, DropdownMenuLabel, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import type { ReactElement } from "react";
import type { OutlierMode, ScaleMode, ViewSettingsState } from "./types";
export function SharedChartSettings({
view,
scale,
setScale,
outlierMode,
setOutlierMode,
}: {
view: ViewSettingsState;
scale: ScaleMode;
setScale: (value: ScaleMode) => void;
outlierMode: OutlierMode;
setOutlierMode: (value: OutlierMode) => void;
}): ReactElement {
const showingAll = view.allStepsLength > 0 && view.effectiveWindowSize >= view.allStepsLength;
return (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs">View</DropdownMenuLabel>
<div className="flex flex-col gap-1.5 px-2 py-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">Window (steps)</Label>
<span className="text-xs tabular-nums text-muted-foreground">
{showingAll ? "All" : view.effectiveWindowSize}
</span>
</div>
<Slider
value={[view.effectiveWindowSize]}
onValueChange={([v]) => view.setWindowSize(Math.max(1, Math.round(v)))}
min={view.minWindow}
max={Math.max(view.minWindow, view.allStepsLength)}
step={1}
/>
<span className="text-[10px] text-muted-foreground">
Always follows latest steps
</span>
</div>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs">Y Scale</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={scale === "linear"}
onCheckedChange={(checked) => checked && setScale("linear")}
>
Linear
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={scale === "log"}
onCheckedChange={(checked) => checked && setScale("log")}
>
Log (log1p)
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs">Outliers</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={outlierMode === "none"}
onCheckedChange={(checked) => checked && setOutlierMode("none")}
>
No clipping
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={outlierMode === "p99"}
onCheckedChange={(checked) => checked && setOutlierMode("p99")}
>
Clip above p99
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={outlierMode === "p95"}
onCheckedChange={(checked) => checked && setOutlierMode("p95")}
>
Clip above p95
</DropdownMenuCheckboxItem>
</>
);
}

View file

@ -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 (
<Card data-tour="studio-training-loss" size="sm">
<CardHeader>
<CardTitle className="text-sm pl-2">Training Loss</CardTitle>
<CardAction>
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
className="cursor-pointer rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<HugeiconsIcon icon={Settings02Icon} className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs">Chart Settings</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="flex flex-col gap-1.5 px-2 py-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">Smoothing</Label>
<span className="text-xs tabular-nums text-muted-foreground">
{smoothing.toFixed(2)}
</span>
</div>
<Slider
value={[smoothing]}
onValueChange={([v]) => setSmoothing(v)}
min={0}
max={0.99}
step={0.01}
/>
</div>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={showRaw}
onCheckedChange={(value) => setShowRaw(Boolean(value))}
>
Show raw loss
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={showSmoothed}
onCheckedChange={(value) => setShowSmoothed(Boolean(value))}
>
Show smoothed loss
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={showAvgLine}
onCheckedChange={(value) => setShowAvgLine(Boolean(value))}
>
Show average line
</DropdownMenuCheckboxItem>
<SharedChartSettings
view={viewSettings}
scale={scale}
setScale={setScale}
outlierMode={outlierMode}
setOutlierMode={setOutlierMode}
/>
</DropdownMenuContent>
</DropdownMenu>
</CardAction>
<CardTitle className="text-sm pl-1">Training Loss</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={lossConfig} className="-ml-3 h-[220px] w-full">
@ -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 && (
<Line
type="monotoneX"
type="linear"
dataKey="displayLoss"
stroke="var(--color-displayLoss)"
strokeWidth={1.2}
strokeOpacity={showSmoothed ? 0.35 : 1}
dot={false}
dot={showPoint}
activeDot={{ r: 3, strokeWidth: 0 }}
connectNulls={true}
strokeLinecap="round"
@ -227,11 +158,11 @@ export function TrainingLossChartCard({
)}
{showSmoothed && (
<Line
type="monotoneX"
type="linear"
dataKey="displaySmoothed"
stroke="var(--color-displaySmoothed)"
strokeWidth={2.2}
dot={false}
dot={showPoint}
activeDot={{ r: 3, strokeWidth: 0 }}
connectNulls={true}
strokeLinecap="round"

View file

@ -10,10 +10,3 @@ export interface TrainingChartSeries {
gradNormHistory: { step: number; gradNorm: number }[];
evalLossHistory: { step: number; loss: number }[];
}
export interface ViewSettingsState {
effectiveWindowSize: number;
minWindow: number;
allStepsLength: number;
setWindowSize: (value: number) => void;
}

View file

@ -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 };
});
}

View file

@ -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={
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild={true}>
<button
type="button"
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<HugeiconsIcon icon={Notebook01Icon} className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="flex flex-col gap-3">
<p className="text-xs font-semibold">Training Config</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">
{group.section}
</p>
{group.rows.map(([label, value]) => (
<div
key={String(label)}
className="flex justify-between text-xs"
>
<span className="text-muted-foreground">
{String(label)}
</span>
<span className="font-medium tabular-nums">
{String(value)}
</span>
</div>
))}
</div>
))}
</div>
</PopoverContent>
</Popover>
<AlertDialog open={stopDialogOpen} onOpenChange={setStopDialogOpen}>
<Button
data-tour="studio-training-stop"
variant="destructive"
size="sm"
className={`h-7 px-3 text-xs ${stopRequested ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
onClick={() => setStopDialogOpen(true)}
disabled={!runtime.isTrainingRunning || stopRequested}
>
<HugeiconsIcon icon={StopIcon} className="size-3" />
{stopRequested ? "Stopping…" : "Stop"}
</Button>
<AlertDialogContent overlayClassName="bg-background/40 supports-backdrop-filter:backdrop-blur-[1px]">
<AlertDialogHeader>
<AlertDialogTitle>Stop Training</AlertDialogTitle>
<AlertDialogDescription>
Choose how you want to stop the current training run.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Continue Training</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setStopRequested(true);
setStopDialogOpen(false);
useTrainingRuntimeStore.getState().setStopRequested(true);
void stopTrainingRun(false).then((ok) => {
if (!ok) setStopRequested(false);
});
}}
>
Cancel Training
</AlertDialogAction>
<AlertDialogAction
onClick={() => {
setStopRequested(true);
setStopDialogOpen(false);
useTrainingRuntimeStore.getState().setStopRequested(true);
void stopTrainingRun(true).then((ok) => {
if (!ok) setStopRequested(false);
});
}}
>
Stop and Save
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<TrainingHeaderActions
configItems={configItems}
isTrainingRunning={runtime.isTrainingRunning}
onOpenStopDialog={setStopDialogOpen}
onRequestStop={requestStop}
stopDialogOpen={stopDialogOpen}
stopRequested={stopRequested}
/>
}
>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1.2fr)_minmax(18rem,0.8fr)]">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<span
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${phaseColors[runtime.phase]}`}
className={`rounded-full px-2.5 py-1 text-[10px] font-semibold ${phaseColors[runtime.phase]}`}
>
{phaseLabel[runtime.phase]}
</span>
<span className="text-[10px] tabular-nums text-muted-foreground">
Epoch {runtime.currentEpoch.toFixed(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
</span>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex flex-col gap-2">
<div className="flex justify-between text-xs text-muted-foreground">
<span>
Step {runtime.currentStep} / {runtime.totalSteps || "--"}
</span>
<span>{pct}%</span>
</div>
<div className="h-2.5 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-teal-400 transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
<Progress value={pct} className="h-2 bg-foreground/[0.05]" />
</div>
{(showHalfwayHint || showCompletedHint) && (
<div className="rounded-xl border border-emerald-500/25 bg-emerald-500/8 p-3">
<p className="text-xs font-medium text-emerald-900 dark:text-emerald-200">
{showCompletedHint
? "Training done. Next step: compare base vs fine-tuned outputs."
: "Halfway done. Training is past 50%."}
</p>
{showCompletedHint && (
<div className="mt-2 flex flex-wrap gap-2">
<Button size="xs" onClick={handleCompareInChat}>
Compare in Chat
</Button>
<Button asChild={true} size="xs" variant="outline">
<Link to="/export">Export Model</Link>
</Button>
</div>
)}
</div>
)}
<MilestoneCallout
showCompletedHint={showCompletedHint}
showHalfwayHint={showHalfwayHint}
onCompareInChat={handleCompareInChat}
/>
{runtime.error && (
<p className="text-xs text-red-500 leading-relaxed">{runtime.error}</p>
<p className="rounded-2xl border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-red-500 leading-relaxed">
{runtime.error}
</p>
)}
<div className="flex flex-wrap items-baseline gap-4">
<div>
<p className="text-xs text-muted-foreground">Loss</p>
<p className="text-3xl font-bold tabular-nums tracking-tight">
{stoppedLoss.toFixed(4)}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">LR</p>
<p className="text-lg font-semibold tabular-nums">
{stoppedLr.toExponential(2)}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Grad Norm</p>
<p className="text-lg font-semibold tabular-nums">
{formatNumber(stoppedGradNorm, 3)}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Model</p>
<p className="text-lg font-semibold truncate max-w-[140px]">
{config.selectedModel ?? "--"}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Method</p>
<p className="text-lg font-semibold">
{config.trainingMethod.toUpperCase()}
</p>
</div>
<div className="grid gap-x-4 gap-y-3 pt-1 sm:grid-cols-2 xl:grid-cols-5">
<MetricStat
label="Loss"
valueClassName="text-2xl font-bold tracking-tight"
>
{stoppedLoss.toFixed(4)}
</MetricStat>
<MetricStat label="LR">{stoppedLr.toExponential(2)}</MetricStat>
<MetricStat label="Grad Norm">
{formatNumber(stoppedGradNorm, 3)}
</MetricStat>
<MetricStat label="Model" valueClassName="truncate">
{config.selectedModel ?? "--"}
</MetricStat>
<MetricStat label="Method">
{config.trainingMethod.toUpperCase()}
</MetricStat>
</div>
<div className="flex flex-wrap gap-4 text-xs text-muted-foreground">
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>Elapsed: {formatDuration(elapsed)}</span>
<span>ETA: {formatDuration(eta)}</span>
<span>
@ -363,8 +288,13 @@ export function ProgressSection(): ReactElement {
</div>
<div className="flex flex-col gap-3">
<p className="text-xs font-medium text-muted-foreground">GPU Monitor</p>
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
GPU Monitor
</p>
<span className="text-[11px] text-muted-foreground">Live</span>
</div>
<div className="grid grid-cols-2 gap-2.5">
<GpuStat
label="Utilization"
icon={
@ -373,26 +303,44 @@ export function ProgressSection(): ReactElement {
className="size-3.5"
/>
}
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}
/>
<GpuStat
label="Temperature"
icon={<HugeiconsIcon icon={TemperatureIcon} className="size-3.5" />}
value={gpu.temperature_c != null ? `${gpu.temperature_c}°C` : "--"}
icon={
<HugeiconsIcon icon={TemperatureIcon} className="size-3.5" />
}
value={
gpu.temperature_c != null ? `${gpu.temperature_c}°C` : "--"
}
pct={gpu.temperature_c ?? 0}
max={100}
/>
<GpuStat
label="VRAM"
icon={<HugeiconsIcon icon={RamMemoryIcon} className="size-3.5" />}
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}
/>
<GpuStat
label="Power"
icon={<HugeiconsIcon icon={ZapIcon} className="size-3.5" />}
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}
/>
</div>
@ -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<void>;
stopDialogOpen: boolean;
stopRequested: boolean;
}): ReactElement {
return (
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild={true}>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Open training config"
>
<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>
{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">
{group.section}
</p>
{group.rows.map(([label, value]) => (
<div key={label} className="flex justify-between text-xs">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">
{String(value)}
</span>
</div>
))}
</div>
))}
</div>
</PopoverContent>
</Popover>
<ChartSettingsSheet />
<AlertDialog open={stopDialogOpen} onOpenChange={onOpenStopDialog}>
<Button
data-tour="studio-training-stop"
variant="destructive"
size="sm"
className={cn(
"h-8 rounded-full px-3.5 text-xs shadow-sm",
stopRequested ? "cursor-not-allowed opacity-60" : "cursor-pointer",
)}
onClick={() => onOpenStopDialog(true)}
disabled={!isTrainingRunning || stopRequested}
>
<HugeiconsIcon icon={StopIcon} className="size-3" />
{stopRequested ? "Stopping…" : "Stop"}
</Button>
<AlertDialogContent overlayClassName="bg-background/40 supports-backdrop-filter:backdrop-blur-[1px]">
<AlertDialogHeader>
<AlertDialogTitle>Stop Training</AlertDialogTitle>
<AlertDialogDescription>
Choose how you want to stop the current training run.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Continue Training</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => onRequestStop(false)}
>
Cancel Training
</AlertDialogAction>
<AlertDialogAction onClick={() => onRequestStop(true)}>
Stop and Save
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function MilestoneCallout({
showCompletedHint,
showHalfwayHint,
onCompareInChat,
}: {
showCompletedHint: boolean;
showHalfwayHint: boolean;
onCompareInChat: () => Promise<void>;
}): ReactElement | null {
if (!(showHalfwayHint || showCompletedHint)) {
return null;
}
return (
<div className="corner-squircle rounded-2xl border border-border/60 bg-muted/30 px-3 py-2.5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
{!showCompletedHint && (
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
Milestone
</p>
)}
<p
className={cn(
"text-xs text-foreground/85",
!showCompletedHint && "mt-1",
)}
>
{showCompletedHint
? "Training done. Next step: compare base vs fine-tuned outputs."
: "Halfway done. Training is past 50%."}
</p>
</div>
{!showCompletedHint && (
<span className="rounded-full border border-border/60 bg-background/80 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
50%+
</span>
)}
</div>
{showCompletedHint && (
<div className="mt-2 flex flex-wrap gap-2">
<Button size="xs" onClick={onCompareInChat}>
Compare in Chat
</Button>
<Button asChild={true} size="xs" variant="outline">
<Link to="/export">Export Model</Link>
</Button>
</div>
)}
</div>
);
}
function MetricStat({
label,
children,
valueClassName,
}: {
label: string;
children: ReactNode;
valueClassName?: string;
}): ReactElement {
return (
<div className="min-w-0">
<p className="text-[11px] text-muted-foreground">{label}</p>
<p
className={`mt-1 text-base font-semibold tabular-nums ${valueClassName ?? ""}`}
>
{children}
</p>
</div>
);
}
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 (
<div className="flex flex-col gap-1.5 rounded-xl bg-muted/50 p-3">
<div className="corner-squircle flex flex-col gap-2 rounded-2xl border border-border/50 bg-background/60 p-3">
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5 text-muted-foreground">
{icon}
@ -453,7 +566,7 @@ function GpuStat({
</span>
<span className="font-medium tabular-nums">{value}</span>
</div>
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div className="h-2 w-full overflow-hidden rounded-full bg-muted/80">
<div
className={`h-full rounded-full ${barColor} transition-all duration-300`}
style={{ width: `${clamped}%` }}