wip p1
This commit is contained in:
parent
cec51ad6d2
commit
4e0596c395
5 changed files with 715 additions and 273 deletions
227
studio/frontend/src/components/ui/terminal.tsx
Normal file
227
studio/frontend/src/components/ui/terminal.tsx
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import type { ElementType, ReactElement, ReactNode } from "react"
|
||||
|
||||
type TerminalProps = {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
sequence?: boolean
|
||||
startOnView?: boolean
|
||||
}
|
||||
|
||||
type InternalLineProps = {
|
||||
__isActive?: boolean
|
||||
__onDone?: () => void
|
||||
__sequence?: boolean
|
||||
}
|
||||
|
||||
function useStartOnView(enabled: boolean): {
|
||||
ref: React.RefObject<HTMLDivElement | null>
|
||||
started: boolean
|
||||
} {
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
const started = !enabled || isInView
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const node = ref.current
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
setIsInView(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.2 }
|
||||
)
|
||||
|
||||
observer.observe(node)
|
||||
return () => observer.disconnect()
|
||||
}, [enabled])
|
||||
|
||||
return { ref, started }
|
||||
}
|
||||
|
||||
export function Terminal({
|
||||
children,
|
||||
className,
|
||||
sequence = true,
|
||||
startOnView = true,
|
||||
}: TerminalProps): ReactElement {
|
||||
const { ref, started } = useStartOnView(startOnView)
|
||||
const childElements = Children.toArray(children).filter(isValidElement)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const visibleIndex = sequence
|
||||
? started
|
||||
? activeIndex
|
||||
: -1
|
||||
: Number.MAX_SAFE_INTEGER
|
||||
|
||||
function handleLineDone(index: number): void {
|
||||
if (!sequence) {
|
||||
return
|
||||
}
|
||||
|
||||
setActiveIndex((prev) => {
|
||||
if (prev !== index) {
|
||||
return prev
|
||||
}
|
||||
return Math.min(index + 1, childElements.length)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-full rounded-2xl border border-border bg-card px-6 py-5 font-mono text-sm text-foreground shadow-2xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{childElements.map((child, index) =>
|
||||
cloneElement(child, {
|
||||
__sequence: sequence,
|
||||
__isActive: !sequence || visibleIndex >= index,
|
||||
__onDone: () => handleLineDone(index),
|
||||
key: child.key ?? index,
|
||||
} as InternalLineProps)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type AnimatedSpanProps = InternalLineProps & {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
delay?: number
|
||||
startOnView?: boolean
|
||||
}
|
||||
|
||||
export function AnimatedSpan({
|
||||
children,
|
||||
className,
|
||||
delay = 0,
|
||||
startOnView = false,
|
||||
__isActive,
|
||||
__sequence,
|
||||
__onDone,
|
||||
}: AnimatedSpanProps): ReactElement {
|
||||
const { ref, started } = useStartOnView(startOnView)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const doneRef = useRef(false)
|
||||
const onDoneRef = useRef(__onDone)
|
||||
const shouldStart = __sequence ? __isActive : started
|
||||
|
||||
useEffect(() => {
|
||||
onDoneRef.current = __onDone
|
||||
}, [__onDone])
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldStart || doneRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setVisible(true)
|
||||
doneRef.current = true
|
||||
onDoneRef.current?.()
|
||||
}, delay)
|
||||
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [delay, shouldStart])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-5 transition-opacity duration-300",
|
||||
visible ? "opacity-100" : "opacity-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TypingAnimationProps = InternalLineProps & {
|
||||
children: string
|
||||
className?: string
|
||||
duration?: number
|
||||
delay?: number
|
||||
as?: ElementType
|
||||
startOnView?: boolean
|
||||
}
|
||||
|
||||
export function TypingAnimation({
|
||||
children,
|
||||
className,
|
||||
duration = 60,
|
||||
delay = 0,
|
||||
as: Component = "span",
|
||||
startOnView = true,
|
||||
__isActive,
|
||||
__sequence,
|
||||
__onDone,
|
||||
}: TypingAnimationProps): ReactElement {
|
||||
const { ref, started } = useStartOnView(startOnView)
|
||||
const [typed, setTyped] = useState("")
|
||||
const doneRef = useRef(false)
|
||||
const onDoneRef = useRef(__onDone)
|
||||
const shouldStart = __sequence ? __isActive : started
|
||||
|
||||
useEffect(() => {
|
||||
onDoneRef.current = __onDone
|
||||
}, [__onDone])
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldStart || doneRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
let index = 0
|
||||
let intervalId: number | null = null
|
||||
const startTimer = window.setTimeout(() => {
|
||||
intervalId = window.setInterval(() => {
|
||||
index += 1
|
||||
setTyped(children.slice(0, index))
|
||||
|
||||
if (index >= children.length) {
|
||||
if (intervalId) {
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
doneRef.current = true
|
||||
onDoneRef.current?.()
|
||||
}
|
||||
}, duration)
|
||||
}, delay)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(startTimer)
|
||||
if (intervalId) {
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
}
|
||||
}, [children, delay, duration, shouldStart])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="min-h-5">
|
||||
<Component className={cn("whitespace-pre-wrap", className)}>{typed}</Component>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -23,7 +23,6 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import type { TrainingMetrics } from "@/types/training";
|
||||
import { ChartAverageIcon, Settings02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type ReactElement, useMemo, useState } from "react";
|
||||
|
|
@ -44,9 +43,11 @@ const lossConfig = {
|
|||
const lrConfig = {
|
||||
lr: { label: "LR", color: "#8b5cf6" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const gradNormConfig = {
|
||||
gradNorm: { label: "Grad Norm", color: "#f97316" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const evalLossConfig = {
|
||||
loss: { label: "Eval Loss", color: "#ef4444" },
|
||||
} satisfies ChartConfig;
|
||||
|
|
@ -62,10 +63,81 @@ const placeholderEvalData = [
|
|||
type LossHistoryItem = { step: number; loss: number };
|
||||
type SmoothedLossItem = LossHistoryItem & { smoothed: number };
|
||||
|
||||
interface TrainingChartSeries {
|
||||
lossHistory: LossHistoryItem[];
|
||||
lrHistory: { step: number; lr: number }[];
|
||||
gradNormHistory: { step: number; gradNorm: number }[];
|
||||
}
|
||||
|
||||
const CHART_SYNC_ID = "train-metrics-sync";
|
||||
const MAX_RENDER_POINTS = 800;
|
||||
const DEFAULT_VISIBLE_POINTS = 160;
|
||||
|
||||
function formatStepTick(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(1)}k`;
|
||||
}
|
||||
return String(Math.round(value));
|
||||
}
|
||||
|
||||
function compressSeries<T>(data: T[], maxPoints: number): T[] {
|
||||
if (data.length <= maxPoints) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const stride = Math.ceil(data.length / maxPoints);
|
||||
return data.filter(
|
||||
(_item, index) => index % stride === 0 || index === data.length - 1,
|
||||
);
|
||||
}
|
||||
|
||||
function buildStepTicks(min: number, max: number, targetCount = 6): number[] {
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
||||
return [0, 1];
|
||||
}
|
||||
if (max <= min) {
|
||||
return [min, max];
|
||||
}
|
||||
|
||||
const stepSize = Math.max(1, Math.ceil((max - min) / (targetCount - 1)));
|
||||
const ticks: number[] = [];
|
||||
let current = min;
|
||||
|
||||
while (current < max) {
|
||||
ticks.push(current);
|
||||
current += stepSize;
|
||||
}
|
||||
|
||||
ticks.push(max);
|
||||
return Array.from(new Set(ticks));
|
||||
}
|
||||
|
||||
function buildYDomain(values: number[]): [number, number] {
|
||||
if (values.length === 0) {
|
||||
return [0, 1];
|
||||
}
|
||||
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
|
||||
if (min === max) {
|
||||
const base = Math.abs(min);
|
||||
const pad = base > 0 ? base * 0.08 : 0.1;
|
||||
return [min - pad, max + pad];
|
||||
}
|
||||
|
||||
const pad = (max - min) * 0.12;
|
||||
return [min - pad, max + pad];
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -75,18 +147,112 @@ function ema(data: LossHistoryItem[], alpha: number): SmoothedLossItem[] {
|
|||
|
||||
export function ChartsContent({
|
||||
metrics,
|
||||
}: { metrics: TrainingMetrics }): ReactElement {
|
||||
const [smoothing, setSmoothing] = useState(0.6);
|
||||
}: { metrics: TrainingChartSeries }): ReactElement {
|
||||
const [smoothing, setSmoothing] = useState(0.75);
|
||||
const [showRaw, setShowRaw] = useState(true);
|
||||
const [showSmoothed, setShowSmoothed] = useState(true);
|
||||
const [showAvgLine, setShowAvgLine] = useState(true);
|
||||
|
||||
const lossHistory = metrics.lossHistory;
|
||||
const smoothedData = useMemo(
|
||||
() => (lossHistory ? ema(lossHistory, 1 - smoothing) : []),
|
||||
() => (lossHistory.length > 0 ? ema(lossHistory, 1 - smoothing) : []),
|
||||
[lossHistory, smoothing],
|
||||
);
|
||||
|
||||
const reducedLossData = useMemo(
|
||||
() => compressSeries(smoothedData, MAX_RENDER_POINTS),
|
||||
[smoothedData],
|
||||
);
|
||||
|
||||
const reducedGradNormData = useMemo(
|
||||
() => compressSeries(metrics.gradNormHistory, MAX_RENDER_POINTS),
|
||||
[metrics.gradNormHistory],
|
||||
);
|
||||
|
||||
const reducedLrData = useMemo(
|
||||
() => compressSeries(metrics.lrHistory, MAX_RENDER_POINTS),
|
||||
[metrics.lrHistory],
|
||||
);
|
||||
|
||||
const visibleStepDomain = useMemo<[number, number]>(() => {
|
||||
const allSteps = [
|
||||
...reducedLossData.map((point) => point.step),
|
||||
...reducedGradNormData.map((point) => point.step),
|
||||
...reducedLrData.map((point) => point.step),
|
||||
].sort((a, b) => a - b);
|
||||
|
||||
if (allSteps.length === 0) {
|
||||
return [0, 1];
|
||||
}
|
||||
|
||||
const endStep = allSteps[allSteps.length - 1] ?? 1;
|
||||
const startIndex = Math.max(0, allSteps.length - DEFAULT_VISIBLE_POINTS);
|
||||
const startStep = allSteps[startIndex] ?? 0;
|
||||
if (startStep === endStep) {
|
||||
return [Math.max(0, startStep - 1), startStep + 4];
|
||||
}
|
||||
if (endStep - startStep < 6) {
|
||||
return [Math.max(0, endStep - 6), endStep];
|
||||
}
|
||||
return [startStep, endStep];
|
||||
}, [reducedGradNormData, reducedLossData, reducedLrData]);
|
||||
|
||||
const xAxisTicks = useMemo(
|
||||
() => buildStepTicks(visibleStepDomain[0], visibleStepDomain[1]),
|
||||
[visibleStepDomain],
|
||||
);
|
||||
|
||||
const visibleLossValues = useMemo(
|
||||
() =>
|
||||
reducedLossData
|
||||
.filter(
|
||||
(point) =>
|
||||
point.step >= visibleStepDomain[0] && point.step <= visibleStepDomain[1],
|
||||
)
|
||||
.map((point) => point.loss),
|
||||
[reducedLossData, visibleStepDomain],
|
||||
);
|
||||
|
||||
const visibleSmoothValues = useMemo(
|
||||
() =>
|
||||
reducedLossData
|
||||
.filter(
|
||||
(point) =>
|
||||
point.step >= visibleStepDomain[0] && point.step <= visibleStepDomain[1],
|
||||
)
|
||||
.map((point) => point.smoothed),
|
||||
[reducedLossData, visibleStepDomain],
|
||||
);
|
||||
|
||||
const visibleGradValues = useMemo(
|
||||
() =>
|
||||
reducedGradNormData
|
||||
.filter(
|
||||
(point) =>
|
||||
point.step >= visibleStepDomain[0] && point.step <= visibleStepDomain[1],
|
||||
)
|
||||
.map((point) => point.gradNorm),
|
||||
[reducedGradNormData, visibleStepDomain],
|
||||
);
|
||||
|
||||
const visibleLrValues = useMemo(
|
||||
() =>
|
||||
reducedLrData
|
||||
.filter(
|
||||
(point) =>
|
||||
point.step >= visibleStepDomain[0] && point.step <= visibleStepDomain[1],
|
||||
)
|
||||
.map((point) => point.lr),
|
||||
[reducedLrData, visibleStepDomain],
|
||||
);
|
||||
|
||||
const lossDomain = useMemo(
|
||||
() => buildYDomain([...visibleLossValues, ...visibleSmoothValues]),
|
||||
[visibleLossValues, visibleSmoothValues],
|
||||
);
|
||||
const gradDomain = useMemo(() => buildYDomain(visibleGradValues), [visibleGradValues]);
|
||||
const lrDomain = useMemo(() => buildYDomain(visibleLrValues), [visibleLrValues]);
|
||||
|
||||
const avg =
|
||||
metrics.lossHistory.length > 0
|
||||
? +(
|
||||
|
|
@ -96,8 +262,7 @@ export function ChartsContent({
|
|||
: 0;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Training Loss */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Training Loss</CardTitle>
|
||||
|
|
@ -106,7 +271,7 @@ export function ChartsContent({
|
|||
<DropdownMenuTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
|
||||
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>
|
||||
|
|
@ -155,12 +320,10 @@ export function ChartsContent({
|
|||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer
|
||||
config={lossConfig}
|
||||
className="h-[200px] w-full -ml-3"
|
||||
>
|
||||
<ChartContainer config={lossConfig} className="-ml-3 h-[220px] w-full">
|
||||
<LineChart
|
||||
data={smoothedData}
|
||||
data={reducedLossData}
|
||||
syncId={CHART_SYNC_ID}
|
||||
accessibilityLayer={true}
|
||||
margin={{ left: 0, right: 8 }}
|
||||
>
|
||||
|
|
@ -168,19 +331,27 @@ export function ChartsContent({
|
|||
<XAxis
|
||||
dataKey="step"
|
||||
type="number"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
domain={visibleStepDomain}
|
||||
ticks={xAxisTicks}
|
||||
allowDataOverflow={true}
|
||||
allowDecimals={false}
|
||||
minTickGap={28}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
fontSize={10}
|
||||
tickFormatter={(value) => formatStepTick(Number(value))}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
domain={lossDomain}
|
||||
allowDataOverflow={true}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={4}
|
||||
fontSize={10}
|
||||
width={40}
|
||||
width={52}
|
||||
tickFormatter={(value) => Number(value).toFixed(2)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
|
|
@ -207,22 +378,30 @@ export function ChartsContent({
|
|||
)}
|
||||
{showRaw && (
|
||||
<Line
|
||||
type="monotone"
|
||||
type="monotoneX"
|
||||
dataKey="loss"
|
||||
stroke="var(--color-loss)"
|
||||
strokeWidth={1.5}
|
||||
strokeOpacity={showSmoothed ? 0.3 : 1}
|
||||
strokeWidth={1.2}
|
||||
strokeOpacity={showSmoothed ? 0.35 : 1}
|
||||
dot={false}
|
||||
activeDot={{ r: 3, strokeWidth: 0 }}
|
||||
connectNulls={true}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
{showSmoothed && (
|
||||
<Line
|
||||
type="monotone"
|
||||
type="monotoneX"
|
||||
dataKey="smoothed"
|
||||
stroke="var(--color-smoothed)"
|
||||
strokeWidth={2}
|
||||
strokeWidth={2.2}
|
||||
dot={false}
|
||||
activeDot={{ r: 3, strokeWidth: 0 }}
|
||||
connectNulls={true}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -232,7 +411,6 @@ export function ChartsContent({
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Grad Norm */}
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Gradient Norm</CardTitle>
|
||||
|
|
@ -240,10 +418,11 @@ export function ChartsContent({
|
|||
<CardContent>
|
||||
<ChartContainer
|
||||
config={gradNormConfig}
|
||||
className="h-[200px] w-full -ml-3"
|
||||
className="-ml-3 h-[220px] w-full"
|
||||
>
|
||||
<LineChart
|
||||
data={metrics.gradNormHistory}
|
||||
data={reducedGradNormData}
|
||||
syncId={CHART_SYNC_ID}
|
||||
accessibilityLayer={true}
|
||||
margin={{ left: 0, right: 8 }}
|
||||
>
|
||||
|
|
@ -251,19 +430,27 @@ export function ChartsContent({
|
|||
<XAxis
|
||||
dataKey="step"
|
||||
type="number"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
domain={visibleStepDomain}
|
||||
ticks={xAxisTicks}
|
||||
allowDataOverflow={true}
|
||||
allowDecimals={false}
|
||||
minTickGap={28}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
fontSize={10}
|
||||
tickFormatter={(value) => formatStepTick(Number(value))}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
domain={gradDomain}
|
||||
allowDataOverflow={true}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={4}
|
||||
fontSize={10}
|
||||
width={40}
|
||||
width={52}
|
||||
tickFormatter={(value) => Number(value).toFixed(2)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
|
|
@ -275,11 +462,15 @@ export function ChartsContent({
|
|||
}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
type="monotoneX"
|
||||
dataKey="gradNorm"
|
||||
stroke="var(--color-gradNorm)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 3, strokeWidth: 0 }}
|
||||
connectNulls={true}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
|
|
@ -288,18 +479,15 @@ export function ChartsContent({
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Learning Rate */}
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Learning Rate</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer
|
||||
config={lrConfig}
|
||||
className="h-[200px] w-full -ml-1.5"
|
||||
>
|
||||
<ChartContainer config={lrConfig} className="-ml-1.5 h-[220px] w-full">
|
||||
<LineChart
|
||||
data={metrics.lrHistory}
|
||||
data={reducedLrData}
|
||||
syncId={CHART_SYNC_ID}
|
||||
accessibilityLayer={true}
|
||||
margin={{ left: 0, right: 8 }}
|
||||
>
|
||||
|
|
@ -307,20 +495,27 @@ export function ChartsContent({
|
|||
<XAxis
|
||||
dataKey="step"
|
||||
type="number"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
domain={visibleStepDomain}
|
||||
ticks={xAxisTicks}
|
||||
allowDataOverflow={true}
|
||||
allowDecimals={false}
|
||||
minTickGap={28}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
fontSize={10}
|
||||
tickFormatter={(value) => formatStepTick(Number(value))}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
domain={lrDomain}
|
||||
allowDataOverflow={true}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={4}
|
||||
fontSize={10}
|
||||
width={40}
|
||||
tickFormatter={(v) => v.toExponential(0)}
|
||||
width={52}
|
||||
tickFormatter={(value) => Number(value).toExponential(0)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
|
|
@ -328,19 +523,20 @@ export function ChartsContent({
|
|||
labelFormatter={(_value, payload) =>
|
||||
`Step ${payload?.[0]?.payload?.step ?? ""}`
|
||||
}
|
||||
formatter={(value) => [
|
||||
Number(value).toExponential(3),
|
||||
"LR",
|
||||
]}
|
||||
formatter={(value) => [Number(value).toExponential(3), "LR"]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
type="monotoneX"
|
||||
dataKey="lr"
|
||||
stroke="var(--color-lr)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 3, strokeWidth: 0 }}
|
||||
connectNulls={true}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
|
|
@ -349,7 +545,6 @@ export function ChartsContent({
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Eval Loss (disabled/blurred) */}
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-muted-foreground">
|
||||
|
|
@ -360,7 +555,7 @@ export function ChartsContent({
|
|||
<div className="relative">
|
||||
<ChartContainer
|
||||
config={evalLossConfig}
|
||||
className="h-[200px] w-full -ml-3 blur"
|
||||
className="-ml-3 h-[220px] w-full blur"
|
||||
>
|
||||
<LineChart
|
||||
data={placeholderEvalData}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@ import {
|
|||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useWizardStore } from "@/stores/training";
|
||||
import {
|
||||
useTrainingConfigStore,
|
||||
useTrainingActions,
|
||||
useTrainingRuntimeStore,
|
||||
type TrainingPhase,
|
||||
} from "@/features/training";
|
||||
import {
|
||||
ChartAverageIcon,
|
||||
DashboardSpeed01Icon,
|
||||
|
|
@ -17,65 +22,133 @@ import {
|
|||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
export function ProgressSection(): ReactElement | null {
|
||||
const store = useWizardStore();
|
||||
const metrics = store.trainingMetrics;
|
||||
if (!metrics) {
|
||||
return null;
|
||||
const phaseLabel: Record<TrainingPhase, string> = {
|
||||
idle: "Idle",
|
||||
loading_model: "Loading model",
|
||||
loading_dataset: "Loading dataset",
|
||||
configuring: "Configuring",
|
||||
training: "Training",
|
||||
completed: "Completed",
|
||||
error: "Error",
|
||||
stopped: "Stopped",
|
||||
};
|
||||
|
||||
const phaseColors: Record<TrainingPhase, string> = {
|
||||
idle: "bg-muted text-muted-foreground",
|
||||
loading_model: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
||||
loading_dataset:
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
||||
configuring: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
|
||||
training:
|
||||
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
|
||||
completed:
|
||||
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
|
||||
error: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
||||
stopped: "bg-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (seconds == null || seconds < 0) {
|
||||
return "--";
|
||||
}
|
||||
const total = Math.floor(seconds);
|
||||
const min = Math.floor(total / 60);
|
||||
const sec = total % 60;
|
||||
return `${min}m ${sec}s`;
|
||||
}
|
||||
|
||||
const pct = Math.round((metrics.currentStep / metrics.totalSteps) * 100);
|
||||
const etaSec =
|
||||
metrics.totalSteps > 0
|
||||
? Math.round(
|
||||
((metrics.totalSteps - metrics.currentStep) /
|
||||
Math.max(metrics.currentStep, 1)) *
|
||||
metrics.elapsed,
|
||||
function formatNumber(value: number | null | undefined, digits: number): string {
|
||||
if (value == null || !Number.isFinite(value)) {
|
||||
return "--";
|
||||
}
|
||||
return value.toFixed(digits);
|
||||
}
|
||||
|
||||
export function ProgressSection(): ReactElement {
|
||||
const runtime = useTrainingRuntimeStore(
|
||||
useShallow((state) => ({
|
||||
phase: state.phase,
|
||||
message: state.message,
|
||||
error: state.error,
|
||||
currentStep: state.currentStep,
|
||||
totalSteps: state.totalSteps,
|
||||
currentEpoch: state.currentEpoch,
|
||||
currentLoss: state.currentLoss,
|
||||
currentLearningRate: state.currentLearningRate,
|
||||
currentGradNorm: state.currentGradNorm,
|
||||
progressPercent: state.progressPercent,
|
||||
elapsedSeconds: state.elapsedSeconds,
|
||||
etaSeconds: state.etaSeconds,
|
||||
currentNumTokens: state.currentNumTokens,
|
||||
isTrainingRunning: state.isTrainingRunning,
|
||||
})),
|
||||
);
|
||||
|
||||
const config = useTrainingConfigStore(
|
||||
useShallow((state) => ({
|
||||
selectedModel: state.selectedModel,
|
||||
trainingMethod: state.trainingMethod,
|
||||
epochs: state.epochs,
|
||||
batchSize: state.batchSize,
|
||||
learningRate: state.learningRate,
|
||||
maxSteps: state.maxSteps,
|
||||
contextLength: state.contextLength,
|
||||
warmupSteps: state.warmupSteps,
|
||||
loraRank: state.loraRank,
|
||||
loraAlpha: state.loraAlpha,
|
||||
loraDropout: state.loraDropout,
|
||||
loraVariant: state.loraVariant,
|
||||
})),
|
||||
);
|
||||
|
||||
const { stopTrainingRun } = useTrainingActions();
|
||||
|
||||
const pct =
|
||||
runtime.totalSteps > 0
|
||||
? Math.min(
|
||||
100,
|
||||
Math.max(
|
||||
0,
|
||||
Math.round((runtime.currentStep / runtime.totalSteps) * 100),
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
const fmtTime = (s: number) => {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${m}m ${sec}s`;
|
||||
};
|
||||
: Math.round(runtime.progressPercent);
|
||||
|
||||
const statusColors = {
|
||||
training:
|
||||
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
|
||||
warmup: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
||||
saving:
|
||||
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
|
||||
};
|
||||
const statusLabels = {
|
||||
training: "Training",
|
||||
warmup: "Warming up",
|
||||
saving: "Saving checkpoint",
|
||||
};
|
||||
const elapsed = runtime.elapsedSeconds;
|
||||
const derivedEta =
|
||||
elapsed != null && pct > 0
|
||||
? Math.round((elapsed * (100 - pct)) / Math.max(pct, 1))
|
||||
: null;
|
||||
const eta = runtime.etaSeconds ?? derivedEta;
|
||||
|
||||
const modelName = store.selectedModel ?? "—";
|
||||
const stepsPerSecond =
|
||||
elapsed != null && elapsed > 0
|
||||
? runtime.currentStep / elapsed
|
||||
: null;
|
||||
|
||||
const configItems = [
|
||||
{
|
||||
section: "Hyperparams",
|
||||
rows: [
|
||||
["Epochs", store.epochs],
|
||||
["Batch size", store.batchSize],
|
||||
["Learning rate", store.learningRate],
|
||||
["Max steps", store.maxSteps],
|
||||
["Context length", store.contextLength],
|
||||
["Warmup steps", store.warmupSteps],
|
||||
["Epochs", config.epochs],
|
||||
["Batch size", config.batchSize],
|
||||
["Learning rate", config.learningRate],
|
||||
["Max steps", config.maxSteps],
|
||||
["Context length", config.contextLength],
|
||||
["Warmup steps", config.warmupSteps],
|
||||
],
|
||||
},
|
||||
...(store.trainingMethod !== "full"
|
||||
...(config.trainingMethod !== "full"
|
||||
? [
|
||||
{
|
||||
section: "LoRA",
|
||||
rows: [
|
||||
["Rank", store.loraRank],
|
||||
["Alpha", store.loraAlpha],
|
||||
["Dropout", store.loraDropout],
|
||||
["Variant", store.loraVariant],
|
||||
["Rank", config.loraRank],
|
||||
["Alpha", config.loraAlpha],
|
||||
["Dropout", config.loraDropout],
|
||||
["Variant", config.loraVariant],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
@ -86,7 +159,7 @@ export function ProgressSection(): ReactElement | null {
|
|||
<SectionCard
|
||||
icon={<HugeiconsIcon icon={ChartAverageIcon} className="size-5" />}
|
||||
title="Training Progress"
|
||||
description="Live training metrics"
|
||||
description={runtime.message || "Live training metrics"}
|
||||
accent="emerald"
|
||||
className="shadow-border ring-1 ring-border"
|
||||
headerAction={
|
||||
|
|
@ -130,7 +203,8 @@ export function ProgressSection(): ReactElement | null {
|
|||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 cursor-pointer px-3 text-xs"
|
||||
onClick={() => store.setIsTraining(false)}
|
||||
onClick={() => void stopTrainingRun()}
|
||||
disabled={!runtime.isTrainingRunning}
|
||||
>
|
||||
<HugeiconsIcon icon={StopIcon} className="size-3" /> Stop
|
||||
</Button>
|
||||
|
|
@ -138,24 +212,22 @@ export function ProgressSection(): ReactElement | null {
|
|||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* Left: Progress */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${statusColors[metrics.status]}`}
|
||||
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${phaseColors[runtime.phase]}`}
|
||||
>
|
||||
{statusLabels[metrics.status]}
|
||||
{phaseLabel[runtime.phase]}
|
||||
</span>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||
Epoch {metrics.currentEpoch.toFixed(2)} / {metrics.totalEpochs}
|
||||
Epoch {runtime.currentEpoch.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Step {metrics.currentStep} / {metrics.totalSteps}
|
||||
Step {runtime.currentStep} / {runtime.totalSteps || "--"}
|
||||
</span>
|
||||
<span>{pct}%</span>
|
||||
</div>
|
||||
|
|
@ -167,51 +239,59 @@ export function ProgressSection(): ReactElement | null {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="flex items-baseline gap-4">
|
||||
{runtime.error && (
|
||||
<p className="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">
|
||||
{metrics.currentLoss.toFixed(4)}
|
||||
{runtime.currentLoss.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">LR</p>
|
||||
<p className="text-lg font-semibold tabular-nums">
|
||||
{metrics.currentLR.toExponential(2)}
|
||||
{runtime.currentLearningRate.toExponential(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Grad Norm</p>
|
||||
<p className="text-lg font-semibold tabular-nums">
|
||||
{metrics.gradNorm.toFixed(3)}
|
||||
{formatNumber(runtime.currentGradNorm, 3)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Model</p>
|
||||
<p className="text-lg font-semibold truncate max-w-[140px]">
|
||||
{modelName}
|
||||
{config.selectedModel ?? "--"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Method</p>
|
||||
<p className="text-lg font-semibold">{store.trainingMethod}</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{config.trainingMethod.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timings */}
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
<span>Elapsed: {fmtTime(metrics.elapsed)}</span>
|
||||
<span>ETA: {fmtTime(etaSec)}</span>
|
||||
<span>{metrics.samplesPerSecond} samples/s</span>
|
||||
<div className="flex flex-wrap gap-4 text-xs text-muted-foreground">
|
||||
<span>Elapsed: {formatDuration(elapsed)}</span>
|
||||
<span>ETA: {formatDuration(eta)}</span>
|
||||
<span>
|
||||
{stepsPerSecond == null
|
||||
? "-- steps/s"
|
||||
: `${stepsPerSecond.toFixed(2)} steps/s`}
|
||||
</span>
|
||||
<span>
|
||||
Tokens: {runtime.currentNumTokens == null ? "--" : runtime.currentNumTokens}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: GPU */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
GPU Monitor
|
||||
</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">GPU Monitor</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<GpuStat
|
||||
label="Utilization"
|
||||
|
|
@ -221,29 +301,27 @@ export function ProgressSection(): ReactElement | null {
|
|||
className="size-3.5"
|
||||
/>
|
||||
}
|
||||
value={`${metrics.gpuUtil}%`}
|
||||
pct={metrics.gpuUtil}
|
||||
value="--"
|
||||
pct={0}
|
||||
/>
|
||||
<GpuStat
|
||||
label="Temperature"
|
||||
icon={
|
||||
<HugeiconsIcon icon={TemperatureIcon} className="size-3.5" />
|
||||
}
|
||||
value={`${metrics.gpuTemp}°C`}
|
||||
pct={metrics.gpuTemp}
|
||||
icon={<HugeiconsIcon icon={TemperatureIcon} className="size-3.5" />}
|
||||
value="--"
|
||||
pct={0}
|
||||
max={100}
|
||||
/>
|
||||
<GpuStat
|
||||
label="VRAM"
|
||||
icon={<HugeiconsIcon icon={RamMemoryIcon} className="size-3.5" />}
|
||||
value={`${metrics.gpuVramUsed.toFixed(1)} / ${metrics.gpuVramTotal}GB`}
|
||||
pct={(metrics.gpuVramUsed / metrics.gpuVramTotal) * 100}
|
||||
value="--"
|
||||
pct={0}
|
||||
/>
|
||||
<GpuStat
|
||||
label="Power"
|
||||
icon={<HugeiconsIcon icon={ZapIcon} className="size-3.5" />}
|
||||
value={`${metrics.gpuPower}W`}
|
||||
pct={(metrics.gpuPower / 350) * 100}
|
||||
value="--"
|
||||
pct={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -272,6 +350,7 @@ function GpuStat({
|
|||
} else if (clamped < 95) {
|
||||
barColor = "bg-amber-500";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 rounded-xl bg-muted/50 p-3">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import {
|
||||
AnimatedSpan,
|
||||
Terminal,
|
||||
TypingAnimation,
|
||||
} from "@/components/ui/terminal"
|
||||
import type { ReactElement } from "react"
|
||||
|
||||
type TrainingStartOverlayProps = {
|
||||
message: string
|
||||
currentStep: number
|
||||
}
|
||||
|
||||
export function TrainingStartOverlay({
|
||||
message,
|
||||
currentStep,
|
||||
}: TrainingStartOverlayProps): ReactElement {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center rounded-2xl bg-background/45 backdrop-blur-[1px]">
|
||||
<div className="flex w-[860px] max-w-[calc(100%-2rem)] flex-col items-center gap-4">
|
||||
<img
|
||||
src="/Sloth emojis/large sloth wave.png"
|
||||
alt="Unsloth mascot"
|
||||
className="size-24 animate-bounce object-contain"
|
||||
/>
|
||||
<Terminal
|
||||
className="w-full min-h-[390px] rounded-2xl px-7 py-6 text-left"
|
||||
startOnView={false}
|
||||
>
|
||||
<TypingAnimation
|
||||
duration={36}
|
||||
className="bg-gradient-to-r from-emerald-300 via-lime-300 to-teal-300 bg-clip-text font-semibold text-transparent"
|
||||
>
|
||||
{"> unsloth training starts..."}
|
||||
</TypingAnimation>
|
||||
<AnimatedSpan className="my-2">
|
||||
<pre className="whitespace-pre text-left text-muted-foreground">{`==((====))==
|
||||
\\\\ /|
|
||||
O^O/ \\_/ \\
|
||||
\\ /
|
||||
"-____-"`}</pre>
|
||||
</AnimatedSpan>
|
||||
<TypingAnimation duration={44}>
|
||||
{"> Preparing model and dataset..."}
|
||||
</TypingAnimation>
|
||||
<TypingAnimation duration={44}>
|
||||
{"> We are getting everything ready for your run..."}
|
||||
</TypingAnimation>
|
||||
<TypingAnimation duration={44}>
|
||||
{"> Did you know, Mugi is actually short for \"Mugiwara\" xd"}
|
||||
</TypingAnimation>
|
||||
<AnimatedSpan className="mt-2 text-muted-foreground">
|
||||
{`> ${message || "starting training..."} | waiting for first step... (${currentStep})`}
|
||||
</AnimatedSpan>
|
||||
</Terminal>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,164 +1,47 @@
|
|||
import { useWizardStore } from "@/stores/training";
|
||||
import type { TrainingMetrics } from "@/types/training";
|
||||
import { type ReactElement, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTrainingRuntimeStore } from "@/features/training";
|
||||
import type { ReactElement } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { ChartsSection } from "./sections/charts-section";
|
||||
import { ProgressSection } from "./sections/progress-section";
|
||||
|
||||
function createInitialMetrics(
|
||||
totalSteps: number,
|
||||
totalEpochs: number,
|
||||
lr: number,
|
||||
): TrainingMetrics {
|
||||
return {
|
||||
currentStep: 0,
|
||||
totalSteps,
|
||||
currentEpoch: 0,
|
||||
totalEpochs,
|
||||
currentLoss: 2.5,
|
||||
currentLR: lr * 0.1,
|
||||
gradNorm: 0,
|
||||
samplesPerSecond: 0,
|
||||
lossHistory: [],
|
||||
lrHistory: [],
|
||||
gradNormHistory: [],
|
||||
gpuUtil: 0,
|
||||
gpuTemp: 45,
|
||||
gpuVramUsed: 0,
|
||||
gpuVramTotal: 24,
|
||||
gpuPower: 50,
|
||||
elapsed: 0,
|
||||
status: "warmup",
|
||||
};
|
||||
}
|
||||
import { TrainingStartOverlay } from "./training-start-overlay";
|
||||
|
||||
export function TrainingView(): ReactElement {
|
||||
const { maxSteps, epochs, learningRate, warmupSteps, setTrainingMetrics } =
|
||||
useWizardStore();
|
||||
const metricsRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const chartsRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const runtime = useTrainingRuntimeStore(
|
||||
useShallow((state) => ({
|
||||
phase: state.phase,
|
||||
message: state.message,
|
||||
currentStep: state.currentStep,
|
||||
firstStepReceived: state.firstStepReceived,
|
||||
isStarting: state.isStarting,
|
||||
})),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const totalSteps = maxSteps || 500;
|
||||
const totalEpochs = epochs || 3;
|
||||
const peakLR = learningRate;
|
||||
const warmup = warmupSteps || 20;
|
||||
|
||||
setTrainingMetrics(createInitialMetrics(totalSteps, totalEpochs, peakLR));
|
||||
|
||||
let step = 0;
|
||||
let elapsed = 0;
|
||||
|
||||
const computeStep = () => {
|
||||
step++;
|
||||
if (step > totalSteps) {
|
||||
return null;
|
||||
}
|
||||
elapsed++;
|
||||
|
||||
let lr: number;
|
||||
if (step < warmup) {
|
||||
lr = peakLR * (step / warmup);
|
||||
} else {
|
||||
const progress = (step - warmup) / (totalSteps - warmup);
|
||||
lr = peakLR * 0.5 * (1 + Math.cos(Math.PI * progress));
|
||||
}
|
||||
|
||||
const baseLoss = 2.5 * Math.exp((-3 * step) / totalSteps) + 0.3;
|
||||
const noise = (Math.random() - 0.5) * 0.08;
|
||||
const loss = Math.max(0.1, baseLoss + noise);
|
||||
const status =
|
||||
step < warmup ? "warmup" : step % 100 === 0 ? "saving" : "training";
|
||||
const gradNorm = +(
|
||||
1.2 * Math.exp(-step / totalSteps) +
|
||||
0.1 +
|
||||
(Math.random() - 0.5) * 0.05
|
||||
).toFixed(3);
|
||||
|
||||
return {
|
||||
step,
|
||||
elapsed,
|
||||
lr,
|
||||
loss: +loss.toFixed(4),
|
||||
status: status as TrainingMetrics["status"],
|
||||
gradNorm,
|
||||
};
|
||||
};
|
||||
|
||||
// Top card values — update every 1s
|
||||
metricsRef.current = setInterval(() => {
|
||||
const s = computeStep();
|
||||
if (!s) {
|
||||
if (metricsRef.current) {
|
||||
clearInterval(metricsRef.current);
|
||||
}
|
||||
if (chartsRef.current) {
|
||||
clearInterval(chartsRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = useWizardStore.getState().trainingMetrics;
|
||||
setTrainingMetrics({
|
||||
currentStep: s.step,
|
||||
totalSteps,
|
||||
currentEpoch:
|
||||
Math.floor((s.step / totalSteps) * totalEpochs * 100) / 100,
|
||||
totalEpochs,
|
||||
currentLoss: s.loss,
|
||||
currentLR: s.lr,
|
||||
gradNorm: s.gradNorm,
|
||||
samplesPerSecond: +(12 + (Math.random() - 0.5) * 2).toFixed(1),
|
||||
lossHistory: prev?.lossHistory ?? [],
|
||||
lrHistory: prev?.lrHistory ?? [],
|
||||
gradNormHistory: prev?.gradNormHistory ?? [],
|
||||
gpuUtil: Math.min(99, 85 + Math.round((Math.random() - 0.5) * 10)),
|
||||
gpuTemp: Math.min(89, 68 + Math.round((Math.random() - 0.5) * 6)),
|
||||
gpuVramUsed: +(18.2 + (Math.random() - 0.5) * 0.4).toFixed(1),
|
||||
gpuVramTotal: 24,
|
||||
gpuPower: Math.round(280 + (Math.random() - 0.5) * 30),
|
||||
elapsed: s.elapsed,
|
||||
status: s.status,
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Chart history — update every 5s
|
||||
chartsRef.current = setInterval(() => {
|
||||
const prev = useWizardStore.getState().trainingMetrics;
|
||||
if (!prev || prev.currentStep === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTrainingMetrics({
|
||||
...prev,
|
||||
lossHistory: [
|
||||
...prev.lossHistory,
|
||||
{ step: prev.currentStep, loss: prev.currentLoss },
|
||||
],
|
||||
lrHistory: [
|
||||
...prev.lrHistory,
|
||||
{ step: prev.currentStep, lr: prev.currentLR },
|
||||
],
|
||||
gradNormHistory: [
|
||||
...prev.gradNormHistory,
|
||||
{ step: prev.currentStep, gradNorm: prev.gradNorm },
|
||||
],
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (metricsRef.current) {
|
||||
clearInterval(metricsRef.current);
|
||||
}
|
||||
if (chartsRef.current) {
|
||||
clearInterval(chartsRef.current);
|
||||
}
|
||||
};
|
||||
}, [epochs, learningRate, maxSteps, setTrainingMetrics, warmupSteps]);
|
||||
const isPreparingPhase =
|
||||
runtime.phase === "loading_model" ||
|
||||
runtime.phase === "loading_dataset" ||
|
||||
runtime.phase === "configuring";
|
||||
const isWaitingForFirstStep =
|
||||
runtime.phase === "training" && !runtime.firstStepReceived;
|
||||
const showOverlay =
|
||||
runtime.isStarting ||
|
||||
isPreparingPhase ||
|
||||
(isWaitingForFirstStep && runtime.currentStep <= 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ProgressSection />
|
||||
<ChartsSection />
|
||||
<div className={cn("relative", showOverlay && "min-h-[72vh]")}>
|
||||
<div
|
||||
className={cn("flex flex-col gap-6 transition-[filter]", showOverlay && "blur")}
|
||||
>
|
||||
<ProgressSection />
|
||||
<ChartsSection />
|
||||
</div>
|
||||
{showOverlay ? (
|
||||
<TrainingStartOverlay
|
||||
message={runtime.message}
|
||||
currentStep={runtime.currentStep}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue