Files
ace-step-ui/components/TrainingPanel.tsx
T
fspecii 565faacb7b Gradio API migration, training pipeline, news page, and UI improvements
- Migrate backend from REST API to Gradio @gradio/client for generation
- Fix Gradio parameter alignment (positions 36-49) for reference/cover audio
- Add LoRA training pipeline with dataset upload, preprocessing, and export
- Add News page with dismiss/restore and GitHub star button
- Add localization info icon in Settings language section
- Fix upload audio URL prefix, add missing MIME types
- Add training API routes and Python preprocess script
- Update i18n with news keys for all languages
2026-02-09 22:30:15 +02:00

1172 lines
62 KiB
TypeScript

import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import {
Database, Play, Square, Download, FolderOpen, Save, Loader2, Music2,
Edit3, Upload, X, Volume2, FileAudio, ChevronRight, Zap, Search,
Cpu, Wand2, Settings, RefreshCw,
} from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { useI18n } from '../context/I18nContext';
import { trainingApi, getTrainingAudioUrl, TrainingSample, DatasetSettings } from '../services/api';
type TrainingTab = 'dataset' | 'train' | 'export';
interface DataframeRow {
[key: string]: unknown;
}
const LANGUAGES = [
{ value: 'instrumental', label: 'Instrumental' },
{ value: 'en', label: 'English' },
{ value: 'zh', label: 'Chinese' },
{ value: 'ja', label: 'Japanese' },
{ value: 'ko', label: 'Korean' },
{ value: 'es', label: 'Spanish' },
{ value: 'fr', label: 'French' },
{ value: 'de', label: 'German' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'ru', label: 'Russian' },
{ value: 'unknown', label: 'Unknown' },
];
const TIME_SIGS = ['', '2', '3', '4', '6', 'N/A'];
const DEVICES = ['auto', 'cuda', 'mps', 'xpu', 'cpu'];
const BACKENDS = ['pt', 'vllm', 'mlx'];
// Pipeline step definitions
const PIPELINE_STEPS = [
{ key: 'upload', label: 'Upload', icon: Upload },
{ key: 'edit', label: 'Edit', icon: Edit3 },
{ key: 'save', label: 'Save', icon: Save },
{ key: 'preprocess', label: 'Preprocess', icon: Zap },
{ key: 'train', label: 'Train', icon: Play },
{ key: 'export', label: 'Export', icon: Download },
] as const;
type PipelineStepKey = typeof PIPELINE_STEPS[number]['key'];
export const TrainingPanel: React.FC = () => {
const { token } = useAuth();
const { t } = useI18n();
const [activeTab, setActiveTab] = useState<TrainingTab>('dataset');
// Pipeline completion tracking
const [completedSteps, setCompletedSteps] = useState<Set<PipelineStepKey>>(new Set());
// Model / Service config state
const [showModelConfig, setShowModelConfig] = useState(false);
const [modelCheckpoints, setModelCheckpoints] = useState<string[]>([]);
const [modelConfigs, setModelConfigs] = useState<string[]>([]);
const [selectedCheckpoint, setSelectedCheckpoint] = useState('');
const [selectedConfig, setSelectedConfig] = useState('');
const [selectedDevice, setSelectedDevice] = useState('auto');
const [selectedBackend, setSelectedBackend] = useState('pt');
const [initLlm, setInitLlm] = useState(false);
const [lmModelPath, setLmModelPath] = useState('');
const [useFlashAttention, setUseFlashAttention] = useState(false);
const [offloadToCpu, setOffloadToCpu] = useState(false);
const [offloadDitToCpu, setOffloadDitToCpu] = useState(false);
const [compileModel, setCompileModel] = useState(false);
const [quantization, setQuantization] = useState(false);
const [modelInitStatus, setModelInitStatus] = useState('');
const [modelInitializing, setModelInitializing] = useState(false);
// Upload state
const [queuedFiles, setQueuedFiles] = useState<File[]>([]);
const [uploadDatasetName, setUploadDatasetName] = useState('my_lora_dataset');
const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Scan directory state
const [scanDir, setScanDir] = useState('');
const [scanning, setScanning] = useState(false);
const [scanStatus, setScanStatus] = useState('');
// Dataset state
const [datasetPath, setDatasetPath] = useState('./datasets/my_lora_dataset.json');
const [datasetLoaded, setDatasetLoaded] = useState(false);
const [datasetLoading, setDatasetLoading] = useState(false);
const [sampleCount, setSampleCount] = useState(0);
const [currentSampleIdx, setCurrentSampleIdx] = useState(0);
const [currentSample, setCurrentSample] = useState<TrainingSample | null>(null);
const [datasetSettings, setDatasetSettings] = useState<DatasetSettings>({
datasetName: 'my_lora_dataset',
customTag: '',
tagPosition: 'replace',
allInstrumental: true,
genreRatio: 0,
});
const [datasetStatus, setDatasetStatus] = useState('');
// Dataset table state
const [dataframeHeaders, setDataframeHeaders] = useState<string[]>([]);
const [dataframeRows, setDataframeRows] = useState<DataframeRow[]>([]);
// Auto-label state
const [autoLabeling, setAutoLabeling] = useState(false);
const [autoLabelStatus, setAutoLabelStatus] = useState('');
const [skipMetas, setSkipMetas] = useState(false);
const [formatLyrics, setFormatLyrics] = useState(false);
const [transcribeLyrics, setTranscribeLyrics] = useState(false);
const [onlyUnlabeled, setOnlyUnlabeled] = useState(false);
// Editing sample state
const [editCaption, setEditCaption] = useState('');
const [editGenre, setEditGenre] = useState('');
const [editPromptOverride, setEditPromptOverride] = useState('Use Global Ratio');
const [editLyrics, setEditLyrics] = useState('');
const [editBpm, setEditBpm] = useState(120);
const [editKey, setEditKey] = useState('');
const [editTimeSig, setEditTimeSig] = useState('');
const [editDuration, setEditDuration] = useState(0);
const [editLanguage, setEditLanguage] = useState('instrumental');
const [editInstrumental, setEditInstrumental] = useState(true);
const [editRawLyrics, setEditRawLyrics] = useState('');
// Dataset save state
const [savePath, setSavePath] = useState('./datasets/my_lora_dataset.json');
const [saveStatus, setSaveStatus] = useState('');
const [editSaveStatus, setEditSaveStatus] = useState('');
// Preprocess state — has its own load-existing-dataset sub-section (matches Gradio)
const [preprocessDatasetPath, setPreprocessDatasetPath] = useState('./datasets/my_lora_dataset.json');
const [preprocessDatasetLoading, setPreprocessDatasetLoading] = useState(false);
const [preprocessDatasetStatus, setPreprocessDatasetStatus] = useState('');
const [preprocessOutputDir, setPreprocessOutputDir] = useState('./datasets/preprocessed_tensors');
const [preprocessing, setPreprocessing] = useState(false);
const [preprocessStatus, setPreprocessStatus] = useState('');
// Training state
const [trainingParams, setTrainingParams] = useState({
tensorDir: './datasets/preprocessed_tensors',
rank: 64,
alpha: 128,
dropout: 0.1,
learningRate: 0.0003,
epochs: 1000,
batchSize: 1,
gradientAccumulation: 1,
saveEvery: 200,
shift: 3.0,
seed: 42,
outputDir: './lora_output',
resumeCheckpoint: '' as string,
});
const [isTraining, setIsTraining] = useState(false);
const [trainingProgress, setTrainingProgress] = useState('');
const [trainingLog, setTrainingLog] = useState('');
const [trainingMetrics, setTrainingMetrics] = useState<unknown>(null);
const [trainingDatasetInfo, setTrainingDatasetInfo] = useState('');
// Export state
const [exportPath, setExportPath] = useState('./lora_output/final_lora');
const [exportOutputDir, setExportOutputDir] = useState('./lora_output');
const [exportStatus, setExportStatus] = useState('');
// Loading states
const [saving, setSaving] = useState(false);
const [exporting, setExporting] = useState(false);
// Audio preview URL
const audioPreviewUrl = useMemo(() => {
if (!currentSample?.audio) return undefined;
return getTrainingAudioUrl(currentSample.audio);
}, [currentSample?.audio]);
const markStep = useCallback((step: PipelineStepKey) => {
setCompletedSteps(prev => new Set([...prev, step]));
}, []);
const populateSampleFields = (sample: TrainingSample) => {
setEditCaption(sample.caption || '');
setEditGenre(sample.genre || '');
setEditPromptOverride(sample.promptOverride || 'Use Global Ratio');
setEditLyrics(sample.lyrics || '');
setEditBpm(sample.bpm || 120);
setEditKey(sample.key || '');
setEditTimeSig(sample.timeSignature || '');
setEditDuration(sample.duration || 0);
setEditLanguage(sample.language || 'instrumental');
setEditInstrumental(sample.instrumental ?? true);
setEditRawLyrics(sample.rawLyrics || '');
};
// Parse dataframe from Gradio response
const parseDataframe = (df: unknown) => {
if (!df || typeof df !== 'object') return;
const dfObj = df as { headers?: string[]; data?: unknown[][] };
if (dfObj.headers && Array.isArray(dfObj.data)) {
setDataframeHeaders(dfObj.headers);
setDataframeRows(dfObj.data.map(row => {
const obj: DataframeRow = {};
dfObj.headers!.forEach((h, i) => { obj[h] = row[i]; });
return obj;
}));
}
};
// Load checkpoints on mount
useEffect(() => {
if (!token) return;
trainingApi.getCheckpoints(token).then(result => {
setModelCheckpoints(result.checkpoints);
setModelConfigs(result.configs);
if (result.checkpoints.length > 0 && !selectedCheckpoint) {
setSelectedCheckpoint(result.checkpoints[0]);
}
if (result.configs.length > 0 && !selectedConfig) {
setSelectedConfig(result.configs[0]);
}
}).catch(() => { /* ignore */ });
}, [token]); // eslint-disable-line react-hooks/exhaustive-deps
// === Model init ===
const handleRefreshCheckpoints = useCallback(async () => {
if (!token) return;
try {
const result = await trainingApi.getCheckpoints(token);
setModelCheckpoints(result.checkpoints);
setModelConfigs(result.configs);
} catch { /* ignore */ }
}, [token]);
const handleInitModel = useCallback(async () => {
if (!token) return;
setModelInitializing(true);
setModelInitStatus('Initializing model...');
try {
const result = await trainingApi.initModel({
checkpoint: selectedCheckpoint,
configPath: selectedConfig,
device: selectedDevice,
initLlm,
lmModelPath,
backend: selectedBackend,
useFlashAttention,
offloadToCpu,
offloadDitToCpu,
compileModel,
quantization,
}, token);
setModelInitStatus(result.status || result.error || '');
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed';
setModelInitStatus(msg.includes('501') ? 'Use Gradio UI to initialize model' : msg);
} finally {
setModelInitializing(false);
}
}, [token, selectedCheckpoint, selectedConfig, selectedDevice, initLlm, lmModelPath, selectedBackend, useFlashAttention, offloadToCpu, offloadDitToCpu, compileModel, quantization]);
// === Drop zone handlers ===
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files).filter((f: File) => {
const ext = f.name.toLowerCase().split('.').pop();
return ['wav', 'mp3', 'flac', 'ogg', 'opus'].includes(ext || '');
});
if (files.length > 0) {
setQueuedFiles(prev => [...prev, ...files]);
}
}, []);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setQueuedFiles(prev => [...prev, ...Array.from(e.target.files!)]);
}
if (fileInputRef.current) fileInputRef.current.value = '';
}, []);
const removeQueuedFile = useCallback((idx: number) => {
setQueuedFiles(prev => prev.filter((_, i) => i !== idx));
}, []);
// === Upload + Build Dataset ===
const handleUploadAndBuild = useCallback(async () => {
if (!token || queuedFiles.length === 0) return;
setUploading(true);
setUploadStatus('Uploading files...');
try {
await trainingApi.uploadAudio(queuedFiles, uploadDatasetName, token);
setUploadStatus(`Uploaded ${queuedFiles.length} files. Building dataset...`);
const result = await trainingApi.buildDataset({
datasetName: uploadDatasetName,
customTag: datasetSettings.customTag,
tagPosition: datasetSettings.tagPosition,
allInstrumental: datasetSettings.allInstrumental,
}, token);
setDatasetLoaded(true);
setSampleCount(result.sampleCount);
setCurrentSampleIdx(0);
if (result.sample) {
setCurrentSample(result.sample);
populateSampleFields(result.sample);
}
if (result.settings) setDatasetSettings(result.settings);
if (result.dataframe) parseDataframe(result.dataframe);
const dp = result.datasetPath || `./datasets/${uploadDatasetName}.json`;
setDatasetPath(dp);
setSavePath(dp);
setDatasetStatus(result.status as string);
setQueuedFiles([]);
markStep('upload');
setUploadStatus('');
} catch (error) {
setUploadStatus(`Error: ${error instanceof Error ? error.message : 'Upload failed'}`);
} finally {
setUploading(false);
}
}, [token, queuedFiles, uploadDatasetName, datasetSettings, markStep]);
// === Scan directory ===
const handleScanDirectory = useCallback(async () => {
if (!token || !scanDir) return;
setScanning(true);
setScanStatus('Scanning...');
try {
const result = await trainingApi.scanDirectory({
audioDir: scanDir,
datasetName: datasetSettings.datasetName,
customTag: datasetSettings.customTag,
tagPosition: datasetSettings.tagPosition,
allInstrumental: datasetSettings.allInstrumental,
}, token);
setScanStatus(result.status);
setSampleCount(result.sampleCount);
if (result.dataframe) parseDataframe(result.dataframe);
} catch (error) {
setScanStatus(`Error: ${error instanceof Error ? error.message : 'Scan failed'}`);
} finally {
setScanning(false);
}
}, [token, scanDir, datasetSettings]);
// === Load existing dataset ===
const handleLoadDataset = useCallback(async () => {
if (!token || !datasetPath) return;
setDatasetLoading(true);
setDatasetStatus(t('loadingDataset'));
try {
const result = await trainingApi.loadDataset(datasetPath, token);
setDatasetLoaded(true);
setSampleCount(result.sampleCount);
setCurrentSampleIdx(0);
setCurrentSample(result.sample);
populateSampleFields(result.sample);
setDatasetSettings(result.settings);
parseDataframe(result.dataframe);
setDatasetStatus(result.status as string);
setSavePath(datasetPath);
markStep('upload');
} catch (error) {
setDatasetStatus(`${t('error')}: ${error instanceof Error ? error.message : 'Failed'}`);
} finally {
setDatasetLoading(false);
}
}, [token, datasetPath, t, markStep]);
// === Auto-label ===
const handleAutoLabel = useCallback(async () => {
if (!token) return;
setAutoLabeling(true);
setAutoLabelStatus(t('autoLabeling'));
try {
const result = await trainingApi.autoLabel({
skipMetas,
formatLyrics,
transcribeLyrics,
onlyUnlabeled,
}, token);
if (result.dataframe) parseDataframe(result.dataframe);
setAutoLabelStatus(result.status || result.hint || '');
// Refresh current sample
if (token && sampleCount > 0) {
const sample = await trainingApi.getSamplePreview(currentSampleIdx, token);
setCurrentSample(sample);
populateSampleFields(sample);
}
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed';
setAutoLabelStatus(msg.includes('501') ? 'Auto-label requires model loaded in Gradio UI' : msg);
} finally {
setAutoLabeling(false);
}
}, [token, skipMetas, formatLyrics, transcribeLyrics, onlyUnlabeled, sampleCount, currentSampleIdx, t]);
// === Sample navigation ===
const handleSampleNavigate = useCallback(async (idx: number) => {
if (!token || idx < 0 || idx >= sampleCount) return;
setCurrentSampleIdx(idx);
try {
const sample = await trainingApi.getSamplePreview(idx, token);
setCurrentSample(sample);
populateSampleFields(sample);
} catch (error) {
console.error('Failed to load sample:', error);
}
}, [token, sampleCount]);
// === Save sample ===
const handleSaveSample = useCallback(async () => {
if (!token) return;
setSaving(true);
try {
const result = await trainingApi.saveSample({
sampleIdx: currentSampleIdx,
caption: editCaption,
genre: editGenre,
promptOverride: editPromptOverride,
lyrics: editLyrics,
bpm: editBpm,
key: editKey,
timeSignature: editTimeSig,
language: editLanguage,
instrumental: editInstrumental,
}, token);
if (result.dataframe) parseDataframe(result.dataframe);
setEditSaveStatus(result.status as string);
markStep('edit');
} catch (error) {
setEditSaveStatus(`${t('error')}: ${error instanceof Error ? error.message : 'Failed'}`);
} finally {
setSaving(false);
}
}, [token, currentSampleIdx, editCaption, editGenre, editPromptOverride, editLyrics, editBpm, editKey, editTimeSig, editLanguage, editInstrumental, t, markStep]);
// === Update settings ===
const handleUpdateSettings = useCallback(async () => {
if (!token) return;
try {
await trainingApi.updateSettings({
customTag: datasetSettings.customTag,
tagPosition: datasetSettings.tagPosition,
allInstrumental: datasetSettings.allInstrumental,
genreRatio: datasetSettings.genreRatio,
}, token);
setDatasetStatus('Settings updated');
} catch (error) {
setDatasetStatus(`${t('error')}: ${error instanceof Error ? error.message : 'Failed'}`);
}
}, [token, datasetSettings, t]);
// === Save dataset ===
const handleSaveDataset = useCallback(async () => {
if (!token) return;
setSaving(true);
setSaveStatus(t('savingDataset'));
try {
const result = await trainingApi.saveDataset({
savePath: savePath || `./datasets/${datasetSettings.datasetName}.json`,
datasetName: datasetSettings.datasetName,
}, token);
setSaveStatus(result.status as string);
if (result.path) setSavePath(result.path);
markStep('save');
} catch (error) {
setSaveStatus(`${t('error')}: ${error instanceof Error ? error.message : 'Failed'}`);
} finally {
setSaving(false);
}
}, [token, savePath, datasetSettings.datasetName, t, markStep]);
// === Load existing dataset for preprocessing (matches Gradio's load_existing_dataset_for_preprocess) ===
const handleLoadDatasetForPreprocess = useCallback(async () => {
if (!token) return;
setPreprocessDatasetLoading(true);
setPreprocessDatasetStatus('Loading dataset for preprocessing...');
try {
const result = await trainingApi.loadDataset(preprocessDatasetPath, token);
setPreprocessDatasetStatus(result.status || `Loaded ${result.sampleCount} samples`);
if (result.sampleCount) setSampleCount(result.sampleCount);
if (result.dataframe) parseDataframe(result.dataframe);
} catch (error) {
setPreprocessDatasetStatus(`Error: ${error instanceof Error ? error.message : 'Failed to load'}`);
} finally {
setPreprocessDatasetLoading(false);
}
}, [token, preprocessDatasetPath]);
// === Preprocess ===
const handlePreprocess = useCallback(async () => {
if (!token) return;
setPreprocessing(true);
setPreprocessStatus('Preprocessing...');
try {
const result = await trainingApi.preprocess({
datasetPath: preprocessDatasetPath || savePath || datasetPath,
outputDir: preprocessOutputDir,
}, token);
setPreprocessStatus(result.message || result.status);
markStep('preprocess');
} catch (error) {
setPreprocessStatus(`Error: ${error instanceof Error ? error.message : 'Preprocessing failed'}`);
} finally {
setPreprocessing(false);
}
}, [token, preprocessDatasetPath, savePath, datasetPath, preprocessOutputDir, markStep]);
// === Load tensors ===
const handleLoadTensors = useCallback(async () => {
if (!token) return;
try {
const result = await trainingApi.loadTensors(trainingParams.tensorDir, token);
setTrainingDatasetInfo(result.status);
} catch (error) {
setTrainingDatasetInfo(`Error: ${error instanceof Error ? error.message : 'Failed'}`);
}
}, [token, trainingParams.tensorDir]);
// === Training ===
const handleStartTraining = useCallback(async () => {
if (!token) return;
setIsTraining(true);
setTrainingProgress(t('startingTraining'));
setTrainingLog('');
setTrainingMetrics(null);
try {
const result = await trainingApi.startTraining({
...trainingParams,
resumeCheckpoint: trainingParams.resumeCheckpoint || null,
}, token);
setTrainingProgress(result.progress as string);
setTrainingLog(result.log as string);
setTrainingMetrics(result.metrics);
markStep('train');
} catch (error) {
setTrainingProgress(`${t('error')}: ${error instanceof Error ? error.message : 'Failed'}`);
} finally {
setIsTraining(false);
}
}, [token, trainingParams, t, markStep]);
const handleStopTraining = useCallback(async () => {
if (!token) return;
try {
const result = await trainingApi.stopTraining(token);
setTrainingProgress(result.status as string);
setIsTraining(false);
} catch (error) {
console.error('Failed to stop training:', error);
}
}, [token]);
// === Export ===
const handleExportLora = useCallback(async () => {
if (!token) return;
setExporting(true);
setExportStatus('Exporting...');
try {
const result = await trainingApi.exportLora({
exportPath,
loraOutputDir: exportOutputDir,
}, token);
setExportStatus(result.status as string);
markStep('export');
} catch (error) {
setExportStatus(`${t('error')}: ${error instanceof Error ? error.message : 'Failed'}`);
} finally {
setExporting(false);
}
}, [token, exportPath, exportOutputDir, t, markStep]);
// === Loss chart ===
const lossChartSvg = useMemo(() => {
if (!trainingMetrics) return null;
let points: { step: number; loss: number }[] = [];
const m = trainingMetrics as any;
if (m?.data && Array.isArray(m.data)) {
points = m.data.map((row: unknown[]) => ({ step: Number(row[0]) || 0, loss: Number(row[1]) || 0 })).filter((p: { loss: number }) => p.loss > 0);
} else if (Array.isArray(m)) {
points = m.map((item: any, i: number) => ({ step: item.step ?? item.x ?? i, loss: item.loss ?? item.y ?? 0 })).filter((p: { loss: number }) => p.loss > 0);
}
if (points.length < 2) return null;
const width = 280, height = 100, pad = 4;
const minStep = Math.min(...points.map(p => p.step));
const maxStep = Math.max(...points.map(p => p.step));
const minLoss = Math.min(...points.map(p => p.loss));
const maxLoss = Math.max(...points.map(p => p.loss));
const rangeStep = maxStep - minStep || 1;
const rangeLoss = maxLoss - minLoss || 1;
const polyPoints = points.map(p => {
const x = pad + ((p.step - minStep) / rangeStep) * (width - 2 * pad);
const y = pad + (1 - (p.loss - minLoss) / rangeLoss) * (height - 2 * pad);
return `${x},${y}`;
}).join(' ');
return (
<svg width={width} height={height} className="w-full" viewBox={`0 0 ${width} ${height}`}>
<polyline points={polyPoints} fill="none" stroke="rgb(236 72 153)" strokeWidth="1.5" strokeLinejoin="round" />
<text x={pad} y={height - 2} fontSize="8" fill="rgb(113 113 122)" fontFamily="monospace">{minStep}</text>
<text x={width - pad} y={height - 2} fontSize="8" fill="rgb(113 113 122)" fontFamily="monospace" textAnchor="end">{maxStep}</text>
<text x={pad} y={10} fontSize="8" fill="rgb(113 113 122)" fontFamily="monospace">{minLoss.toFixed(4)}</text>
</svg>
);
}, [trainingMetrics]);
// Mutual exclusion: formatLyrics / transcribeLyrics
useEffect(() => {
if (formatLyrics && transcribeLyrics) setTranscribeLyrics(false);
}, [formatLyrics]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (transcribeLyrics && formatLyrics) setFormatLyrics(false);
}, [transcribeLyrics]); // eslint-disable-line react-hooks/exhaustive-deps
const tabs: { id: TrainingTab; label: string; icon: React.ReactNode }[] = [
{ id: 'dataset', label: t('datasetBuilder'), icon: <Database size={16} /> },
{ id: 'train', label: t('trainLora'), icon: <Music2 size={16} /> },
{ id: 'export', label: 'Export', icon: <Download size={16} /> },
];
return (
<div className="h-full w-full flex flex-col bg-zinc-50 dark:bg-suno-panel overflow-hidden">
{/* Header */}
<div className="px-4 pt-4 pb-2 flex-shrink-0">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">{t('loraTraining')}</h2>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{t('trainingDescription')}</p>
</div>
{/* Pipeline Steps */}
<div className="flex items-center gap-0.5 px-4 pb-2 flex-shrink-0 overflow-x-auto scrollbar-hide">
{PIPELINE_STEPS.map((step, i) => {
const Icon = step.icon;
const done = completedSteps.has(step.key);
return (
<React.Fragment key={step.key}>
{i > 0 && <ChevronRight size={10} className="text-zinc-600 flex-shrink-0" />}
<div className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] flex-shrink-0 ${done ? 'bg-green-500/15 text-green-400' : 'bg-white/5 text-zinc-500'}`}>
<Icon size={10} />
{step.label}
</div>
</React.Fragment>
);
})}
</div>
{/* Tab Bar */}
<div className="flex px-4 gap-1 flex-shrink-0">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${activeTab === tab.id ? 'bg-pink-500/20 text-pink-400 border border-pink-500/30' : 'text-zinc-400 hover:text-zinc-200 hover:bg-white/5'}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3 scrollbar-hide">
{/* ===== MODEL CONFIGURATION (shown at top of all tabs) ===== */}
<Section title={
<button onClick={() => setShowModelConfig(!showModelConfig)} className="flex items-center gap-1.5 w-full text-left">
<Settings size={12} />
<span>Model Configuration</span>
<ChevronRight size={12} className={`ml-auto transition-transform ${showModelConfig ? 'rotate-90' : ''}`} />
</button>
}>
{showModelConfig && (
<div className="space-y-2 mt-2">
<div className="flex gap-2 items-center">
<FieldRow label="Checkpoint">
<select value={selectedCheckpoint} onChange={e => setSelectedCheckpoint(e.target.value)} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-2 py-1 text-xs text-zinc-200">
{modelCheckpoints.map(c => <option key={c} value={c}>{c}</option>)}
{modelCheckpoints.length === 0 && <option value="">No checkpoints found</option>}
</select>
</FieldRow>
<button onClick={handleRefreshCheckpoints} className="p-1.5 bg-white/5 hover:bg-white/10 rounded-lg text-zinc-400">
<RefreshCw size={12} />
</button>
</div>
<FieldRow label="Config">
<select value={selectedConfig} onChange={e => setSelectedConfig(e.target.value)} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-2 py-1 text-xs text-zinc-200">
{modelConfigs.map(c => <option key={c} value={c}>{c}</option>)}
{modelConfigs.length === 0 && <option value="">No configs found</option>}
</select>
</FieldRow>
<div className="grid grid-cols-2 gap-2">
<FieldRow label="Device">
<select value={selectedDevice} onChange={e => setSelectedDevice(e.target.value)} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-2 py-1 text-xs text-zinc-200">
{DEVICES.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</FieldRow>
<FieldRow label="Backend">
<select value={selectedBackend} onChange={e => setSelectedBackend(e.target.value)} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-2 py-1 text-xs text-zinc-200">
{BACKENDS.map(b => <option key={b} value={b}>{b}</option>)}
</select>
</FieldRow>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1">
<label className="flex items-center gap-1.5 text-[10px] text-zinc-400">
<input type="checkbox" checked={initLlm} onChange={e => setInitLlm(e.target.checked)} className="w-3 h-3 accent-pink-500" />
Init LLM
</label>
<label className="flex items-center gap-1.5 text-[10px] text-zinc-400">
<input type="checkbox" checked={useFlashAttention} onChange={e => setUseFlashAttention(e.target.checked)} className="w-3 h-3 accent-pink-500" />
Flash Attention
</label>
<label className="flex items-center gap-1.5 text-[10px] text-zinc-400">
<input type="checkbox" checked={offloadToCpu} onChange={e => setOffloadToCpu(e.target.checked)} className="w-3 h-3 accent-pink-500" />
Offload CPU
</label>
<label className="flex items-center gap-1.5 text-[10px] text-zinc-400">
<input type="checkbox" checked={offloadDitToCpu} onChange={e => setOffloadDitToCpu(e.target.checked)} className="w-3 h-3 accent-pink-500" />
Offload DiT CPU
</label>
<label className="flex items-center gap-1.5 text-[10px] text-zinc-400">
<input type="checkbox" checked={compileModel} onChange={e => setCompileModel(e.target.checked)} className="w-3 h-3 accent-pink-500" />
Compile
</label>
<label className="flex items-center gap-1.5 text-[10px] text-zinc-400">
<input type="checkbox" checked={quantization} onChange={e => setQuantization(e.target.checked)} className="w-3 h-3 accent-pink-500" />
Quantization
</label>
</div>
{initLlm && (
<FieldRow label="LM Model">
<input type="text" value={lmModelPath} onChange={e => setLmModelPath(e.target.value)} placeholder="LM model path" className="flex-1 bg-white/5 border border-white/10 rounded-lg px-2 py-1 text-xs text-zinc-200" />
</FieldRow>
)}
<button onClick={handleInitModel} disabled={modelInitializing} className="w-full py-1.5 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded-lg text-xs font-medium flex items-center justify-center gap-1.5 disabled:opacity-50">
{modelInitializing ? <Loader2 size={12} className="animate-spin" /> : <Cpu size={12} />}
Initialize Service
</button>
{modelInitStatus && <p className="text-[10px] text-zinc-400 break-words">{modelInitStatus}</p>}
</div>
)}
</Section>
{activeTab === 'dataset' && (
<>
{/* Drop Zone */}
<Section title={t('uploadAudio')}>
<div
onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-4 text-center cursor-pointer transition-all ${isDragOver ? 'border-pink-500 bg-pink-500/10' : 'border-white/10 hover:border-white/20 hover:bg-white/[0.02]'}`}
>
<Upload size={24} className={`mx-auto mb-2 ${isDragOver ? 'text-pink-400' : 'text-zinc-500'}`} />
<p className="text-xs text-zinc-400">Drop audio files here or click to browse</p>
<p className="text-[10px] text-zinc-600 mt-1">.wav, .mp3, .flac, .ogg, .opus</p>
<input ref={fileInputRef} type="file" multiple accept=".wav,.mp3,.flac,.ogg,.opus" onChange={handleFileSelect} className="hidden" />
</div>
{queuedFiles.length > 0 && (
<div className="mt-2 space-y-1 max-h-32 overflow-y-auto">
{queuedFiles.map((f, i) => (
<div key={`${f.name}-${i}`} className="flex items-center gap-2 bg-white/5 rounded-lg px-2 py-1">
<FileAudio size={12} className="text-zinc-400 flex-shrink-0" />
<span className="text-[11px] text-zinc-300 truncate flex-1">{f.name}</span>
<span className="text-[10px] text-zinc-500">{(f.size / 1024 / 1024).toFixed(1)}MB</span>
<button onClick={() => removeQueuedFile(i)} className="text-zinc-500 hover:text-red-400"><X size={12} /></button>
</div>
))}
</div>
)}
{queuedFiles.length > 0 && (
<div className="mt-2 space-y-2">
<FieldRow label={t('datasetName')}>
<input type="text" value={uploadDatasetName} onChange={e => setUploadDatasetName(e.target.value)} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" placeholder="my_lora_dataset" />
</FieldRow>
<button onClick={handleUploadAndBuild} disabled={uploading || !uploadDatasetName.trim()} className="w-full py-2 bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 text-white rounded-lg text-xs font-medium flex items-center justify-center gap-2 disabled:opacity-50">
{uploading ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
Upload & Create Dataset ({queuedFiles.length} files)
</button>
</div>
)}
{uploadStatus && <p className="text-xs text-zinc-400 mt-1.5 break-words">{uploadStatus}</p>}
</Section>
{/* Scan Directory */}
<Section title="Scan Directory">
<div className="flex gap-2">
<input type="text" value={scanDir} onChange={e => setScanDir(e.target.value)} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" placeholder="./path/to/audio/folder" />
<button onClick={handleScanDirectory} disabled={scanning || !scanDir} className="px-3 py-1.5 bg-white/5 hover:bg-white/10 text-zinc-300 rounded-lg text-xs font-medium flex items-center gap-1.5 disabled:opacity-50">
{scanning ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} />}
Scan
</button>
</div>
{scanStatus && <p className="text-xs text-zinc-400 mt-1.5 break-words">{scanStatus}</p>}
</Section>
{/* Load Existing Dataset */}
<Section title={t('loadExistingDataset')}>
<div className="flex gap-2">
<input type="text" value={datasetPath} onChange={e => setDatasetPath(e.target.value)} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" placeholder="./datasets/my_dataset.json" />
<button onClick={handleLoadDataset} disabled={datasetLoading} className="px-3 py-1.5 bg-pink-500/20 hover:bg-pink-500/30 text-pink-400 rounded-lg text-xs font-medium flex items-center gap-1.5 disabled:opacity-50">
{datasetLoading ? <Loader2 size={14} className="animate-spin" /> : <FolderOpen size={14} />}
{t('loadDataset')}
</button>
</div>
{datasetStatus && <p className="text-xs text-zinc-400 mt-1.5 break-words">{datasetStatus}</p>}
</Section>
{/* Dataset Table */}
{dataframeRows.length > 0 && (
<Section title={`Dataset (${dataframeRows.length} samples)`}>
<div className="overflow-x-auto max-h-48 overflow-y-auto rounded-lg border border-white/5">
<table className="w-full text-[10px]">
<thead>
<tr className="bg-white/5 sticky top-0">
<th className="text-left px-2 py-1 text-zinc-400 font-medium">#</th>
{dataframeHeaders.slice(0, 6).map(h => (
<th key={h} className="text-left px-2 py-1 text-zinc-400 font-medium truncate max-w-[80px]">{h}</th>
))}
</tr>
</thead>
<tbody>
{dataframeRows.map((row, i) => (
<tr key={i} onClick={() => handleSampleNavigate(i)} className={`cursor-pointer transition-colors ${i === currentSampleIdx ? 'bg-pink-500/10 text-pink-300' : 'hover:bg-white/5 text-zinc-300'}`}>
<td className="px-2 py-0.5 text-zinc-500">{i + 1}</td>
{dataframeHeaders.slice(0, 6).map(h => (
<td key={h} className="px-2 py-0.5 truncate max-w-[80px]">{String(row[h] ?? '')}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Section>
)}
{/* Dataset Settings */}
{datasetLoaded && (
<>
<Section title={t('datasetSettings')}>
<div className="space-y-2">
<FieldRow label={t('datasetName')}>
<input type="text" value={datasetSettings.datasetName} onChange={e => setDatasetSettings(s => ({ ...s, datasetName: e.target.value }))} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" />
</FieldRow>
<FieldRow label={t('customActivationTag')}>
<input type="text" value={datasetSettings.customTag} onChange={e => setDatasetSettings(s => ({ ...s, customTag: e.target.value }))} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" placeholder="e.g. my_style" />
</FieldRow>
<FieldRow label={t('tagPosition')}>
<select value={datasetSettings.tagPosition} onChange={e => setDatasetSettings(s => ({ ...s, tagPosition: e.target.value as DatasetSettings['tagPosition'] }))} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50">
<option value="prepend">{t('tagPrepend')}</option>
<option value="append">{t('tagAppend')}</option>
<option value="replace">{t('tagReplace')}</option>
</select>
</FieldRow>
<FieldRow label={t('allInstrumental')}>
<input type="checkbox" checked={datasetSettings.allInstrumental} onChange={e => setDatasetSettings(s => ({ ...s, allInstrumental: e.target.checked }))} className="w-4 h-4 accent-pink-500" />
</FieldRow>
<FieldRow label={`${t('genreRatio')} (${datasetSettings.genreRatio}%)`}>
<input type="range" min={0} max={100} value={datasetSettings.genreRatio} onChange={e => setDatasetSettings(s => ({ ...s, genreRatio: parseInt(e.target.value) }))} className="flex-1 accent-pink-500" />
</FieldRow>
<p className="text-[10px] text-zinc-500">{t('genreRatioHint')}</p>
<button onClick={handleUpdateSettings} className="w-full py-1.5 bg-white/5 hover:bg-white/10 text-zinc-300 rounded-lg text-xs font-medium">
Apply Settings
</button>
</div>
</Section>
{/* Auto-Label */}
<Section title={t('autoLabelWithAI')}>
<p className="text-[10px] text-zinc-500 mb-2">{t('autoLabelDescription')}</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 mb-2">
<label className="flex items-center gap-1.5 text-[10px] text-zinc-400">
<input type="checkbox" checked={skipMetas} onChange={e => setSkipMetas(e.target.checked)} className="w-3 h-3 accent-pink-500" />
{t('skipMetas')}
</label>
<label className="flex items-center gap-1.5 text-[10px] text-zinc-400">
<input type="checkbox" checked={formatLyrics} onChange={e => setFormatLyrics(e.target.checked)} className="w-3 h-3 accent-pink-500" />
{t('formatLyrics')}
</label>
<label className="flex items-center gap-1.5 text-[10px] text-zinc-400">
<input type="checkbox" checked={transcribeLyrics} onChange={e => setTranscribeLyrics(e.target.checked)} className="w-3 h-3 accent-pink-500" />
Transcribe Lyrics
</label>
<label className="flex items-center gap-1.5 text-[10px] text-zinc-400">
<input type="checkbox" checked={onlyUnlabeled} onChange={e => setOnlyUnlabeled(e.target.checked)} className="w-3 h-3 accent-pink-500" />
{t('onlyUnlabeled')}
</label>
</div>
<button onClick={handleAutoLabel} disabled={autoLabeling} className="w-full py-1.5 bg-purple-500/20 hover:bg-purple-500/30 text-purple-400 rounded-lg text-xs font-medium flex items-center justify-center gap-1.5 disabled:opacity-50">
{autoLabeling ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
{t('autoLabelAll')}
</button>
{autoLabelStatus && <p className="text-xs text-zinc-400 mt-1.5 break-words">{autoLabelStatus}</p>}
</Section>
{/* Sample Editor */}
<Section title={`${t('editSample')} (${currentSampleIdx + 1}/${sampleCount})`}>
{/* Sample Navigation */}
<div className="flex items-center gap-2 mb-2">
<button onClick={() => handleSampleNavigate(currentSampleIdx - 1)} disabled={currentSampleIdx <= 0} className="px-2 py-1 bg-white/5 hover:bg-white/10 text-zinc-300 rounded text-xs disabled:opacity-30">Prev</button>
<input type="number" min={1} max={sampleCount} value={currentSampleIdx + 1} onChange={e => { const v = parseInt(e.target.value) - 1; if (v >= 0 && v < sampleCount) handleSampleNavigate(v); }} className="w-16 bg-white/5 border border-white/10 rounded px-2 py-1 text-xs text-center text-zinc-200" />
<button onClick={() => handleSampleNavigate(currentSampleIdx + 1)} disabled={currentSampleIdx >= sampleCount - 1} className="px-2 py-1 bg-white/5 hover:bg-white/10 text-zinc-300 rounded text-xs disabled:opacity-30">Next</button>
<span className="text-[10px] text-zinc-500 ml-auto truncate max-w-[100px]">{currentSample?.filename || ''}</span>
</div>
{/* Audio Preview */}
{audioPreviewUrl && (
<div className="mb-2 flex items-center gap-2 bg-white/5 rounded-lg px-2 py-1.5">
<Volume2 size={14} className="text-pink-400 flex-shrink-0" />
<audio controls src={audioPreviewUrl} className="w-full h-7 [&::-webkit-media-controls-panel]:bg-transparent" preload="metadata" />
</div>
)}
<div className="space-y-2">
<FieldRow label={t('caption')}>
<input type="text" value={editCaption} onChange={e => setEditCaption(e.target.value)} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" placeholder={t('musicDescription')} />
</FieldRow>
<FieldRow label={t('genre')}>
<input type="text" value={editGenre} onChange={e => setEditGenre(e.target.value)} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" />
</FieldRow>
<FieldRow label={t('promptOverride')}>
<select value={editPromptOverride} onChange={e => setEditPromptOverride(e.target.value)} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50">
<option value="Use Global Ratio">{t('useGlobalRatio')}</option>
<option value="Caption">{t('caption')}</option>
<option value="Genre">{t('genre')}</option>
</select>
</FieldRow>
<div>
<label className="text-[11px] text-zinc-500 mb-0.5 block">Lyrics ({t('editableUsedForTraining')})</label>
<textarea value={editLyrics} onChange={e => setEditLyrics(e.target.value)} rows={3} className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50 resize-none" />
</div>
{editRawLyrics && (
<div>
<label className="text-[11px] text-zinc-500 mb-0.5 block">Raw Lyrics (read-only)</label>
<textarea value={editRawLyrics} readOnly rows={3} className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-400 resize-none opacity-60" />
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-[11px] text-zinc-500 mb-0.5 block">BPM</label>
<input type="number" value={editBpm} onChange={e => setEditBpm(parseInt(e.target.value) || 0)} className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" />
</div>
<div>
<label className="text-[11px] text-zinc-500 mb-0.5 block">Key</label>
<input type="text" value={editKey} onChange={e => setEditKey(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" placeholder="e.g. C major" />
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<label className="text-[11px] text-zinc-500 mb-0.5 block">Time Sig</label>
<select value={editTimeSig} onChange={e => setEditTimeSig(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50">
{TIME_SIGS.map(ts => <option key={ts} value={ts}>{ts || 'Auto'}</option>)}
</select>
</div>
<div>
<label className="text-[11px] text-zinc-500 mb-0.5 block">Duration</label>
<input type="number" value={editDuration} readOnly className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-400 opacity-60" />
</div>
<div>
<label className="text-[11px] text-zinc-500 mb-0.5 block">Language</label>
<select value={editLanguage} onChange={e => setEditLanguage(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50">
{LANGUAGES.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
</select>
</div>
</div>
<FieldRow label={t('allInstrumental')}>
<input type="checkbox" checked={editInstrumental} onChange={e => setEditInstrumental(e.target.checked)} className="w-4 h-4 accent-pink-500" />
</FieldRow>
<button onClick={handleSaveSample} disabled={saving} className="w-full py-1.5 bg-pink-500/20 hover:bg-pink-500/30 text-pink-400 rounded-lg text-xs font-medium flex items-center justify-center gap-1.5 disabled:opacity-50">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Edit3 size={14} />}
Save Sample
</button>
{editSaveStatus && <p className="text-xs text-zinc-400 mt-1.5 break-words">{editSaveStatus}</p>}
</div>
</Section>
{/* Save Dataset */}
<Section title={t('saveDataset')}>
<FieldRow label="Save Path">
<input type="text" value={savePath} onChange={e => setSavePath(e.target.value)} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" />
</FieldRow>
<button onClick={handleSaveDataset} disabled={saving} className="w-full mt-2 py-2 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg text-xs font-medium flex items-center justify-center gap-1.5 disabled:opacity-50">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{t('saveDataset')}
</button>
{saveStatus && <p className="text-xs text-zinc-400 mt-1.5 break-words">{saveStatus}</p>}
</Section>
{/* Preprocess to Tensors (matches Gradio's Step 5 with its own Load Existing Dataset) */}
<Section title="Preprocess to Tensors">
<p className="text-[10px] text-zinc-500 mb-2">{t('preprocessDescription')}</p>
{/* Load Existing Dataset for Preprocess (Gradio: load_existing_dataset_path/btn/status) */}
<div className="mb-3 p-2 bg-white/[0.02] border border-white/5 rounded-lg space-y-2">
<label className="text-[10px] text-zinc-500 font-medium">Load Existing Dataset</label>
<div className="flex gap-2">
<input type="text" value={preprocessDatasetPath} onChange={e => setPreprocessDatasetPath(e.target.value)} placeholder="./datasets/my_lora_dataset.json" className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" />
<button onClick={handleLoadDatasetForPreprocess} disabled={preprocessDatasetLoading} className="px-3 py-1.5 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded-lg text-xs font-medium flex items-center gap-1.5 disabled:opacity-50">
{preprocessDatasetLoading ? <Loader2 size={14} className="animate-spin" /> : <FolderOpen size={14} />}
Load
</button>
</div>
{preprocessDatasetStatus && <p className="text-[10px] text-zinc-400 break-words">{preprocessDatasetStatus}</p>}
</div>
<FieldRow label="Output Dir">
<input type="text" value={preprocessOutputDir} onChange={e => setPreprocessOutputDir(e.target.value)} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" />
</FieldRow>
<button onClick={handlePreprocess} disabled={preprocessing} className="w-full mt-2 py-2 bg-purple-500/20 hover:bg-purple-500/30 text-purple-400 rounded-lg text-xs font-medium flex items-center justify-center gap-1.5 disabled:opacity-50">
{preprocessing ? <Loader2 size={14} className="animate-spin" /> : <Zap size={14} />}
{preprocessing ? 'Preprocessing...' : 'Preprocess'}
</button>
{preprocessStatus && <p className="text-xs text-zinc-400 mt-1.5 break-words">{preprocessStatus}</p>}
</Section>
</>
)}
</>
)}
{activeTab === 'train' && (
<>
{/* Load Tensors */}
<Section title={t('preprocessedDataset')}>
<div className="flex gap-2">
<input type="text" value={trainingParams.tensorDir} onChange={e => setTrainingParams(p => ({ ...p, tensorDir: e.target.value }))} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" />
<button onClick={handleLoadTensors} className="px-3 py-1.5 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded-lg text-xs font-medium flex items-center gap-1.5">
<FolderOpen size={14} />
Load
</button>
</div>
{trainingDatasetInfo && <p className="text-xs text-zinc-400 mt-1.5 break-words whitespace-pre-wrap">{trainingDatasetInfo}</p>}
</Section>
{/* LoRA Settings */}
<Section title={t('loraSettings')}>
<div className="space-y-2">
<ParamSlider label={`${t('loraRank')} (r)`} value={trainingParams.rank} min={4} max={256} step={4} onChange={v => setTrainingParams(p => ({ ...p, rank: v }))} />
<ParamSlider label={`${t('loraAlpha')} (a)`} value={trainingParams.alpha} min={4} max={512} step={4} onChange={v => setTrainingParams(p => ({ ...p, alpha: v }))} />
<ParamSlider label={`${t('dropout')}`} value={trainingParams.dropout} min={0} max={0.5} step={0.05} onChange={v => setTrainingParams(p => ({ ...p, dropout: v }))} />
</div>
</Section>
{/* Training Parameters */}
<Section title={t('trainingParameters')}>
<div className="space-y-2">
<FieldRow label={t('learningRate')}>
<input type="number" value={trainingParams.learningRate} onChange={e => setTrainingParams(p => ({ ...p, learningRate: parseFloat(e.target.value) || 0.0003 }))} step={0.0001} className="w-28 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" />
</FieldRow>
<ParamSlider label={t('maxEpochs')} value={trainingParams.epochs} min={1} max={4000} step={1} onChange={v => setTrainingParams(p => ({ ...p, epochs: v }))} />
<ParamSlider label="Batch Size" value={trainingParams.batchSize} min={1} max={8} step={1} onChange={v => setTrainingParams(p => ({ ...p, batchSize: v }))} />
<ParamSlider label={t('gradientAccumulation')} value={trainingParams.gradientAccumulation} min={1} max={16} step={1} onChange={v => setTrainingParams(p => ({ ...p, gradientAccumulation: v }))} />
<ParamSlider label={`${t('saveEvery')} (${t('epochs')})`} value={trainingParams.saveEvery} min={50} max={1000} step={50} onChange={v => setTrainingParams(p => ({ ...p, saveEvery: v }))} />
<ParamSlider label="Shift" value={trainingParams.shift} min={1.0} max={5.0} step={0.5} onChange={v => setTrainingParams(p => ({ ...p, shift: v }))} />
<FieldRow label="Seed">
<input type="number" value={trainingParams.seed} onChange={e => setTrainingParams(p => ({ ...p, seed: parseInt(e.target.value) || 42 }))} className="w-24 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" />
</FieldRow>
<FieldRow label={t('outputDirectory')}>
<input type="text" value={trainingParams.outputDir} onChange={e => setTrainingParams(p => ({ ...p, outputDir: e.target.value }))} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" />
</FieldRow>
<FieldRow label="Resume Checkpoint">
<input type="text" value={trainingParams.resumeCheckpoint} onChange={e => setTrainingParams(p => ({ ...p, resumeCheckpoint: e.target.value }))} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" placeholder="./lora_output/checkpoints/epoch_200" />
</FieldRow>
</div>
</Section>
{/* Training Controls */}
<div className="flex gap-2">
{!isTraining ? (
<button onClick={handleStartTraining} className="flex-1 py-2.5 bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 text-white rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<Play size={16} />
{t('startTraining')}
</button>
) : (
<button onClick={handleStopTraining} className="flex-1 py-2.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<Square size={16} />
{t('stopTraining')}
</button>
)}
</div>
{/* Training Progress */}
{(trainingProgress || trainingLog) && (
<Section title={t('trainingProgress')}>
{trainingProgress && <p className="text-xs text-zinc-300 mb-2 break-words">{trainingProgress}</p>}
{trainingLog && (
<pre className="text-[10px] text-zinc-400 bg-black/20 rounded-lg p-2 max-h-40 overflow-y-auto whitespace-pre-wrap">{trainingLog}</pre>
)}
</Section>
)}
{/* Loss Chart */}
{lossChartSvg && (
<Section title="Training Loss">
<div className="bg-black/20 rounded-lg p-2">{lossChartSvg}</div>
</Section>
)}
</>
)}
{activeTab === 'export' && (
<>
<Section title="Export LoRA">
<div className="space-y-2">
<FieldRow label="Export Path">
<input type="text" value={exportPath} onChange={e => setExportPath(e.target.value)} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" />
</FieldRow>
<FieldRow label="LoRA Output Dir">
<input type="text" value={exportOutputDir} onChange={e => setExportOutputDir(e.target.value)} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-pink-500/50" />
</FieldRow>
</div>
<button onClick={handleExportLora} disabled={exporting} className="w-full mt-3 py-2.5 bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-white rounded-lg text-sm font-medium flex items-center justify-center gap-2 disabled:opacity-50">
{exporting ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />}
Export LoRA
</button>
{exportStatus && <p className="text-xs text-zinc-400 mt-2 break-words">{exportStatus}</p>}
</Section>
<Section title="Load LoRA for Inference">
<p className="text-xs text-zinc-500 mb-2">
After exporting, use the LoRA controls in the Create panel to load your trained adapter.
</p>
</Section>
</>
)}
</div>
</div>
);
};
// Reusable Section component (supports string or ReactNode title)
const Section: React.FC<{ title: string | React.ReactNode; children: React.ReactNode }> = ({ title, children }) => (
<div className="bg-white/[0.02] border border-white/5 rounded-xl p-3">
<h3 className="text-xs font-semibold text-zinc-300 mb-2">{title}</h3>
{children}
</div>
);
const FieldRow: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<div className="flex items-center gap-2">
<label className="text-[11px] text-zinc-500 w-28 flex-shrink-0">{label}</label>
{children}
</div>
);
const ParamSlider: React.FC<{
label: string;
value: number;
min: number;
max: number;
step: number;
onChange: (v: number) => void;
}> = ({ label, value, min, max, step, onChange }) => (
<div>
<div className="flex items-center justify-between mb-0.5">
<label className="text-[11px] text-zinc-500">{label}</label>
<span className="text-[11px] text-zinc-400 font-mono">{step < 1 ? value.toFixed(2) : value}</span>
</div>
<input type="range" min={min} max={max} step={step} value={value} onChange={e => onChange(parseFloat(e.target.value))} className="w-full accent-pink-500 h-1.5" />
</div>
);