/** * Training Metrics Component * * Real-time visualization of training progress with loss curves and metrics. * Supports SSE (Server-Sent Events) for live updates during LoRA fine-tuning. * * Phase 3.3: Training Dashboard with Live Metrics */ 'use client' import { useState, useEffect, useCallback, useRef } from 'react' // Training data point for charts interface TrainingDataPoint { epoch: number step: number loss: number val_loss?: number learning_rate: number timestamp: number } // Training job status interface TrainingStatus { job_id: string status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled' progress: number // 0-100 current_epoch: number total_epochs: number current_step: number total_steps: number elapsed_time_ms: number estimated_remaining_ms: number metrics: { loss: number val_loss?: number accuracy?: number learning_rate: number } history: TrainingDataPoint[] error?: string } interface TrainingMetricsProps { /** API base URL */ apiBase: string /** Job ID to track (null for simulation mode) */ jobId?: string | null /** Simulate training progress for demo */ simulateMode?: boolean /** Custom class names */ className?: string /** Callback when training completes */ onComplete?: (status: TrainingStatus) => void /** Callback on error */ onError?: (error: string) => void } /** * Custom hook for SSE-based training metrics */ export function useTrainingMetricsSSE( apiBase: string, jobId: string | null, onUpdate?: (status: TrainingStatus) => void ) { const [status, setStatus] = useState(null) const [connected, setConnected] = useState(false) const [error, setError] = useState(null) const eventSourceRef = useRef(null) useEffect(() => { if (!jobId) return const url = `${apiBase}/api/klausur/trocr/training/metrics/stream?job_id=${jobId}` const eventSource = new EventSource(url) eventSourceRef.current = eventSource eventSource.onopen = () => { setConnected(true) setError(null) } eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data) as TrainingStatus setStatus(data) onUpdate?.(data) // Close connection when training is done if (data.status === 'completed' || data.status === 'failed' || data.status === 'cancelled') { eventSource.close() setConnected(false) } } catch (e) { console.error('Failed to parse SSE message:', e) } } eventSource.onerror = () => { setError('Verbindung zum Server verloren') setConnected(false) eventSource.close() } return () => { eventSource.close() setConnected(false) } }, [apiBase, jobId, onUpdate]) const disconnect = useCallback(() => { eventSourceRef.current?.close() setConnected(false) }, []) return { status, connected, error, disconnect } } /** * Simple line chart component for loss visualization */ function LossChart({ data, height = 200, className = '' }: { data: TrainingDataPoint[] height?: number className?: string }) { if (data.length === 0) { return (
Keine Daten
) } // Calculate bounds const losses = data.map(d => d.loss) const valLosses = data.filter(d => d.val_loss !== undefined).map(d => d.val_loss!) const allLosses = [...losses, ...valLosses] const minLoss = Math.min(...allLosses) * 0.9 const maxLoss = Math.max(...allLosses) * 1.1 const lossRange = maxLoss - minLoss || 1 // SVG dimensions const width = 400 const padding = { top: 20, right: 20, bottom: 30, left: 50 } const chartWidth = width - padding.left - padding.right const chartHeight = height - padding.top - padding.bottom // Generate path for loss line const generatePath = (values: number[]) => { if (values.length === 0) return '' return values .map((loss, idx) => { const x = padding.left + (idx / (values.length - 1 || 1)) * chartWidth const y = padding.top + chartHeight - ((loss - minLoss) / lossRange) * chartHeight return `${idx === 0 ? 'M' : 'L'} ${x} ${y}` }) .join(' ') } const lossPath = generatePath(losses) const valLossPath = valLosses.length > 0 ? generatePath(valLosses) : '' // Y-axis labels const yLabels = [maxLoss, (maxLoss + minLoss) / 2, minLoss].map(v => v.toFixed(3)) return ( {/* Grid lines */} {[0, 0.5, 1].map((ratio, idx) => ( ))} {/* Y-axis labels */} {yLabels.map((label, idx) => ( {label} ))} {/* X-axis label */} Epoche / Schritt {/* Y-axis label */} Loss {/* Training loss line */} {/* Validation loss line (dashed) */} {valLossPath && ( )} {/* Data points */} {data.map((point, idx) => { const x = padding.left + (idx / (data.length - 1 || 1)) * chartWidth const y = padding.top + chartHeight - ((point.loss - minLoss) / lossRange) * chartHeight return ( Epoch {point.epoch}, Step {point.step}: {point.loss.toFixed(4)} ) })} {/* Legend */} Training Loss {valLossPath && ( <> Val Loss )} ) } /** * Progress ring component */ function ProgressRing({ progress, size = 80, strokeWidth = 6, className = '' }: { progress: number size?: number strokeWidth?: number className?: string }) { const radius = (size - strokeWidth) / 2 const circumference = radius * 2 * Math.PI const offset = circumference - (progress / 100) * circumference return ( {/* Background circle */} {/* Progress circle */} {/* Center text */} {progress.toFixed(0)}% ) } /** * Format time duration */ function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms` const seconds = Math.floor(ms / 1000) if (seconds < 60) return `${seconds}s` const minutes = Math.floor(seconds / 60) const remainingSeconds = seconds % 60 if (minutes < 60) return `${minutes}m ${remainingSeconds}s` const hours = Math.floor(minutes / 60) const remainingMinutes = minutes % 60 return `${hours}h ${remainingMinutes}m` } /** * Training Metrics Component - Full Dashboard */ export function TrainingMetrics({ apiBase, jobId = null, simulateMode = false, className = '', onComplete, onError }: TrainingMetricsProps) { const [status, setStatus] = useState(null) const [simulationInterval, setSimulationInterval] = useState(null) // SSE hook for real connection const { status: sseStatus, connected, error } = useTrainingMetricsSSE( apiBase, simulateMode ? null : jobId, (newStatus) => { if (newStatus.status === 'completed') { onComplete?.(newStatus) } else if (newStatus.status === 'failed' && newStatus.error) { onError?.(newStatus.error) } } ) // Use SSE status if available useEffect(() => { if (sseStatus) { setStatus(sseStatus) } }, [sseStatus]) // Simulation mode for demo useEffect(() => { if (!simulateMode) return let step = 0 const totalSteps = 100 const history: TrainingDataPoint[] = [] const interval = setInterval(() => { step++ const epoch = Math.floor((step / totalSteps) * 3) + 1 const progress = (step / totalSteps) * 100 // Simulate decreasing loss with noise const baseLoss = 2.5 * Math.exp(-step / 30) + 0.1 const noise = (Math.random() - 0.5) * 0.1 const loss = Math.max(0.05, baseLoss + noise) const dataPoint: TrainingDataPoint = { epoch, step, loss, val_loss: step % 10 === 0 ? loss * (1 + Math.random() * 0.2) : undefined, learning_rate: 0.00005 * Math.pow(0.95, epoch - 1), timestamp: Date.now() } history.push(dataPoint) const newStatus: TrainingStatus = { job_id: 'simulation', status: step >= totalSteps ? 'completed' : 'running', progress, current_epoch: epoch, total_epochs: 3, current_step: step, total_steps: totalSteps, elapsed_time_ms: step * 500, estimated_remaining_ms: (totalSteps - step) * 500, metrics: { loss, val_loss: dataPoint.val_loss, accuracy: 0.7 + (step / totalSteps) * 0.25, learning_rate: dataPoint.learning_rate }, history: [...history] } setStatus(newStatus) if (step >= totalSteps) { clearInterval(interval) onComplete?.(newStatus) } }, 500) setSimulationInterval(interval) return () => { clearInterval(interval) } }, [simulateMode, onComplete]) // Stop simulation const stopSimulation = () => { if (simulationInterval) { clearInterval(simulationInterval) setSimulationInterval(null) } } if (error) { return (
{error}
) } if (!status) { return (
Warte auf Training-Daten...
) } return (
{/* Header */}

Training Dashboard

{status.status === 'running' && ( )} {status.status === 'running' ? 'Laeuft' : status.status === 'completed' ? 'Abgeschlossen' : status.status === 'failed' ? 'Fehlgeschlagen' : status.status}
{connected && ( Live )}
{simulateMode && status.status === 'running' && ( )}
{/* Main content */}
{/* Progress section */}
Epoche {status.current_epoch} / {status.total_epochs}
Schritt {status.current_step} / {status.total_steps}
{/* Loss chart */}

Loss-Verlauf

{/* Metrics grid */}
{status.metrics.loss.toFixed(4)}
Aktueller Loss
{status.metrics.val_loss !== undefined && (
{status.metrics.val_loss.toFixed(4)}
Validation Loss
)} {status.metrics.accuracy !== undefined && (
{(status.metrics.accuracy * 100).toFixed(1)}%
Genauigkeit
)}
{status.metrics.learning_rate.toExponential(1)}
Learning Rate
{/* Time info */}
Vergangen: {formatDuration(status.elapsed_time_ms)}
{status.status === 'running' && (
Geschaetzt: {formatDuration(status.estimated_remaining_ms)} verbleibend
)}
) } /** * Compact Training Metrics for inline display */ export function TrainingMetricsCompact({ progress, currentEpoch, totalEpochs, loss, status, className = '' }: { progress: number currentEpoch: number totalEpochs: number loss: number status: 'running' | 'completed' | 'failed' className?: string }) { return (
Epoche {currentEpoch}/{totalEpochs} {status === 'running' ? 'Laeuft' : status === 'completed' ? 'Fertig' : 'Fehler'}
Loss: {loss.toFixed(4)}
) } export default TrainingMetrics