Add complete Mail Fine-Tuning Web-App for macOS Apple Silicon
Implemented a full-stack web application for fine-tuning LLMs on email data, optimized for Apple Silicon (M4 Pro with 24GB RAM). Features: - Mail import with drag & drop support (.mbox, .eml, .txt) - Automated mail cleaning and preprocessing - Interactive labeling interface with keyboard shortcuts - Training data export to JSONL format - MLX-based LoRA fine-tuning with live updates - Model evaluation and comparison interface - Server-Sent Events for real-time training progress - Dark theme UI optimized for extended use Technical Stack: - Backend: FastAPI with SQLite database - Frontend: Vanilla HTML/CSS/JavaScript (no external dependencies) - ML Framework: MLX for Apple Silicon optimization - Models: Support for Mistral 7B and Llama 3 8B via MLX Components: - data_manager.py: SQLite operations for mail storage and labeling - mail_parser.py: Parser for multiple mail formats with cleaning - training.py: MLX training wrapper with LoRA support - inference.py: Model loading and inference for evaluation - main.py: FastAPI backend with REST API and SSE - Frontend: Complete UI with all features Documentation: - Comprehensive README with installation and usage guide - Quick-start guide for rapid setup - Example mails for testing - Troubleshooting and best practices Ready for local deployment and fine-tuning workflows.
This commit is contained in:
@@ -0,0 +1,756 @@
|
||||
// Mail Fine-Tuning App - Frontend Logic
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
// State
|
||||
let currentMails = [];
|
||||
let currentLabelingIndex = 0;
|
||||
let stats = {};
|
||||
let trainingEventSource = null;
|
||||
|
||||
// ======================
|
||||
// Utility Functions
|
||||
// ======================
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
async function apiCall(endpoint, options = {}) {
|
||||
try {
|
||||
const response = await fetch(API_BASE + endpoint, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'API Error');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
showToast(error.message, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ======================
|
||||
// Navigation
|
||||
// ======================
|
||||
|
||||
function initNavigation() {
|
||||
const navLinks = document.querySelectorAll('.nav-link');
|
||||
const views = document.querySelectorAll('.view');
|
||||
|
||||
navLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const targetView = link.dataset.view;
|
||||
|
||||
// Update active states
|
||||
navLinks.forEach(l => l.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
|
||||
views.forEach(v => v.classList.remove('active'));
|
||||
document.getElementById(`${targetView}-view`).classList.add('active');
|
||||
|
||||
// Load data for view
|
||||
if (targetView === 'labeling') {
|
||||
loadLabelingView();
|
||||
} else if (targetView === 'export') {
|
||||
loadStats();
|
||||
} else if (targetView === 'models') {
|
||||
loadModels();
|
||||
} else if (targetView === 'training') {
|
||||
loadTrainingView();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ======================
|
||||
// Mail Import
|
||||
// ======================
|
||||
|
||||
function initImport() {
|
||||
const dropzone = document.getElementById('dropzone');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
|
||||
dropzone.addEventListener('click', () => fileInput.click());
|
||||
|
||||
dropzone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('dragover');
|
||||
});
|
||||
|
||||
dropzone.addEventListener('dragleave', () => {
|
||||
dropzone.classList.remove('dragover');
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove('dragover');
|
||||
handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
handleFiles(e.target.files);
|
||||
});
|
||||
|
||||
document.getElementById('refresh-mails').addEventListener('click', loadMails);
|
||||
|
||||
// Initial load
|
||||
loadMails();
|
||||
}
|
||||
|
||||
async function handleFiles(files) {
|
||||
const formData = new FormData();
|
||||
|
||||
for (let file of files) {
|
||||
formData.append('files', file);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(API_BASE + '/api/mails/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
const successCount = result.success.reduce((sum, r) => sum + r.count, 0);
|
||||
showToast(`${successCount} Mails erfolgreich importiert`, 'success');
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
showToast(`${result.errors.length} Fehler beim Import`, 'error');
|
||||
}
|
||||
|
||||
loadMails();
|
||||
|
||||
} catch (error) {
|
||||
showToast('Fehler beim Upload', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMails() {
|
||||
try {
|
||||
const data = await apiCall('/api/mails');
|
||||
currentMails = data.mails;
|
||||
|
||||
document.getElementById('mail-count').textContent = currentMails.length;
|
||||
|
||||
renderMailList(currentMails);
|
||||
} catch (error) {
|
||||
console.error('Error loading mails:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMailList(mails) {
|
||||
const container = document.getElementById('mail-list');
|
||||
|
||||
if (mails.length === 0) {
|
||||
container.innerHTML = '<p style="text-align:center; padding: 2rem;">Keine Mails vorhanden</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = mails.map(mail => `
|
||||
<div class="mail-item ${mail.status}">
|
||||
<div class="mail-header">
|
||||
<div class="mail-subject">${escapeHtml(mail.subject)}</div>
|
||||
<div class="mail-meta">${mail.status}</div>
|
||||
</div>
|
||||
<div class="mail-meta">Von: ${escapeHtml(mail.sender)}</div>
|
||||
<div class="mail-body">${escapeHtml(mail.body)}</div>
|
||||
<div class="mail-actions">
|
||||
<button class="btn btn-secondary" onclick="viewMail(${mail.id})">👁️ Ansehen</button>
|
||||
<button class="btn btn-danger" onclick="deleteMail(${mail.id})">🗑️ Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function deleteMail(id) {
|
||||
if (!confirm('Mail wirklich löschen?')) return;
|
||||
|
||||
try {
|
||||
await apiCall(`/api/mails/${id}`, { method: 'DELETE' });
|
||||
showToast('Mail gelöscht', 'success');
|
||||
loadMails();
|
||||
} catch (error) {
|
||||
console.error('Error deleting mail:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function viewMail(id) {
|
||||
const mail = currentMails.find(m => m.id === id);
|
||||
if (!mail) return;
|
||||
|
||||
alert(`Betreff: ${mail.subject}\n\nVon: ${mail.sender}\n\n${mail.body}`);
|
||||
}
|
||||
|
||||
// ======================
|
||||
// Labeling
|
||||
// ======================
|
||||
|
||||
function initLabeling() {
|
||||
const statusFilter = document.getElementById('status-filter');
|
||||
statusFilter.addEventListener('change', loadLabelingView);
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const activeView = document.querySelector('.view.active');
|
||||
if (activeView.id !== 'labeling-view') return;
|
||||
|
||||
if (e.key.toLowerCase() === 'n') {
|
||||
nextMail();
|
||||
} else if (e.key.toLowerCase() === 's') {
|
||||
saveLabelingMail();
|
||||
} else if (e.key.toLowerCase() === 'k') {
|
||||
skipMail();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLabelingView() {
|
||||
const statusFilter = document.getElementById('status-filter').value;
|
||||
|
||||
try {
|
||||
const data = await apiCall(`/api/mails?status=${statusFilter || ''}`);
|
||||
currentMails = data.mails;
|
||||
currentLabelingIndex = 0;
|
||||
|
||||
updateLabelingProgress();
|
||||
renderCurrentMail();
|
||||
} catch (error) {
|
||||
console.error('Error loading labeling view:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateLabelingProgress() {
|
||||
const labeled = currentMails.filter(m => m.status === 'labeled').length;
|
||||
const total = currentMails.length;
|
||||
|
||||
const percent = total > 0 ? (labeled / total) * 100 : 0;
|
||||
|
||||
document.getElementById('labeling-progress').style.width = `${percent}%`;
|
||||
document.getElementById('progress-text').textContent = `${labeled} / ${total} gelabelt`;
|
||||
}
|
||||
|
||||
function renderCurrentMail() {
|
||||
const container = document.getElementById('labeling-container');
|
||||
|
||||
if (currentMails.length === 0) {
|
||||
container.innerHTML = '<p>Keine Mails zum Labeln vorhanden</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const mail = currentMails[currentLabelingIndex];
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="current-mail">
|
||||
<h4>${escapeHtml(mail.subject)}</h4>
|
||||
<p><strong>Von:</strong> ${escapeHtml(mail.sender)}</p>
|
||||
<p><strong>An:</strong> ${escapeHtml(mail.recipient)}</p>
|
||||
<hr style="margin: 1rem 0; border-color: var(--border-color)">
|
||||
<div style="white-space: pre-wrap;">${escapeHtml(mail.body)}</div>
|
||||
</div>
|
||||
|
||||
<form id="labeling-form">
|
||||
<div class="form-group">
|
||||
<label>Aufgabentyp:</label>
|
||||
<select id="task-type" required>
|
||||
<option value="">-- Wählen --</option>
|
||||
<option value="Zusammenfassen" ${mail.task_type === 'Zusammenfassen' ? 'selected' : ''}>Zusammenfassen</option>
|
||||
<option value="Antwort schreiben" ${mail.task_type === 'Antwort schreiben' ? 'selected' : ''}>Antwort schreiben</option>
|
||||
<option value="Kategorisieren" ${mail.task_type === 'Kategorisieren' ? 'selected' : ''}>Kategorisieren</option>
|
||||
<option value="Action Items" ${mail.task_type === 'Action Items' ? 'selected' : ''}>Action Items</option>
|
||||
<option value="Custom" ${mail.task_type === 'Custom' ? 'selected' : ''}>Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Erwarteter Output:</label>
|
||||
<textarea id="expected-output" rows="6" required>${mail.expected_output || ''}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-primary" onclick="saveLabelingMail()">💾 Speichern (S)</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="skipMail()">⏭️ Überspringen (K)</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="nextMail()">➡️ Nächste (N)</button>
|
||||
<span style="margin-left: auto; color: var(--text-secondary);">
|
||||
${currentLabelingIndex + 1} / ${currentMails.length}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
async function saveLabelingMail() {
|
||||
const mail = currentMails[currentLabelingIndex];
|
||||
const taskType = document.getElementById('task-type').value;
|
||||
const expectedOutput = document.getElementById('expected-output').value;
|
||||
|
||||
if (!taskType || !expectedOutput) {
|
||||
showToast('Bitte alle Felder ausfüllen', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiCall(`/api/mails/${mail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
task_type: taskType,
|
||||
expected_output: expectedOutput,
|
||||
status: 'labeled'
|
||||
})
|
||||
});
|
||||
|
||||
showToast('Gespeichert', 'success');
|
||||
mail.status = 'labeled';
|
||||
updateLabelingProgress();
|
||||
nextMail();
|
||||
} catch (error) {
|
||||
console.error('Error saving mail:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function skipMail() {
|
||||
const mail = currentMails[currentLabelingIndex];
|
||||
|
||||
try {
|
||||
await apiCall(`/api/mails/${mail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
status: 'skip'
|
||||
})
|
||||
});
|
||||
|
||||
mail.status = 'skip';
|
||||
updateLabelingProgress();
|
||||
nextMail();
|
||||
} catch (error) {
|
||||
console.error('Error skipping mail:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function nextMail() {
|
||||
if (currentLabelingIndex < currentMails.length - 1) {
|
||||
currentLabelingIndex++;
|
||||
} else {
|
||||
currentLabelingIndex = 0;
|
||||
}
|
||||
renderCurrentMail();
|
||||
}
|
||||
|
||||
// ======================
|
||||
// Export & Stats
|
||||
// ======================
|
||||
|
||||
function initExport() {
|
||||
document.getElementById('export-jsonl').addEventListener('click', exportJSONL);
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
stats = await apiCall('/api/export/stats');
|
||||
renderStats();
|
||||
} catch (error) {
|
||||
console.error('Error loading stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const container = document.getElementById('stats-grid');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.total || 0}</div>
|
||||
<div class="stat-label">Gesamt Mails</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.labeled || 0}</div>
|
||||
<div class="stat-label">Gelabelt</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.unlabeled || 0}</div>
|
||||
<div class="stat-label">Unlabeled</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.avg_input_length || 0}</div>
|
||||
<div class="stat-label">Avg Input Length</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.avg_output_length || 0}</div>
|
||||
<div class="stat-label">Avg Output Length</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.sufficient_data ? '✅' : '❌'}</div>
|
||||
<div class="stat-label">Genug Daten (>50)</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function exportJSONL() {
|
||||
const trainSplit = document.getElementById('train-split').value / 100;
|
||||
|
||||
try {
|
||||
const result = await apiCall('/api/export/jsonl', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ train_split: trainSplit })
|
||||
});
|
||||
|
||||
const resultDiv = document.getElementById('export-result');
|
||||
resultDiv.innerHTML = `
|
||||
<p>✅ Export erfolgreich!</p>
|
||||
<p>Training Samples: ${result.train_samples}</p>
|
||||
<p>Validation Samples: ${result.val_samples}</p>
|
||||
<p>
|
||||
<a href="/api/export/download/train" class="btn btn-primary" download>📥 train.jsonl</a>
|
||||
<a href="/api/export/download/val" class="btn btn-primary" download>📥 val.jsonl</a>
|
||||
</p>
|
||||
`;
|
||||
resultDiv.classList.add('show');
|
||||
|
||||
showToast('JSONL Dateien generiert', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error exporting JSONL:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ======================
|
||||
// Models
|
||||
// ======================
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
const data = await apiCall('/api/models');
|
||||
renderModels(data.models);
|
||||
} catch (error) {
|
||||
console.error('Error loading models:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderModels(models) {
|
||||
const container = document.getElementById('models-list');
|
||||
|
||||
if (models.length === 0) {
|
||||
container.innerHTML = '<p>Keine Modelle vorhanden</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = models.map(model => `
|
||||
<div class="model-item">
|
||||
<span>📦 ${model}</span>
|
||||
<span style="color: var(--accent-success);">✓ Verfügbar</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ======================
|
||||
// Training
|
||||
// ======================
|
||||
|
||||
function initTraining() {
|
||||
const lrSlider = document.getElementById('learning-rate');
|
||||
const epochsSlider = document.getElementById('epochs');
|
||||
|
||||
lrSlider.addEventListener('input', (e) => {
|
||||
const value = Math.pow(10, parseFloat(e.target.value));
|
||||
document.getElementById('lr-value').textContent = value.toExponential(0);
|
||||
});
|
||||
|
||||
epochsSlider.addEventListener('input', (e) => {
|
||||
document.getElementById('epochs-value').textContent = e.target.value;
|
||||
});
|
||||
|
||||
document.getElementById('training-form').addEventListener('submit', startTraining);
|
||||
document.getElementById('stop-training').addEventListener('click', stopTraining);
|
||||
}
|
||||
|
||||
async function loadTrainingView() {
|
||||
// Load available models
|
||||
try {
|
||||
const data = await apiCall('/api/models');
|
||||
const select = document.getElementById('training-model');
|
||||
|
||||
select.innerHTML = '<option value="">-- Modell wählen --</option>' +
|
||||
data.models.map(m => `<option value="${m}">${m}</option>`).join('');
|
||||
} catch (error) {
|
||||
console.error('Error loading models:', error);
|
||||
}
|
||||
|
||||
// Get current status
|
||||
updateTrainingStatus();
|
||||
}
|
||||
|
||||
async function startTraining(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const modelName = document.getElementById('training-model').value;
|
||||
const learningRate = Math.pow(10, parseFloat(document.getElementById('learning-rate').value));
|
||||
const epochs = parseInt(document.getElementById('epochs').value);
|
||||
const batchSize = parseInt(document.getElementById('batch-size').value);
|
||||
const loraRank = parseInt(document.getElementById('lora-rank').value);
|
||||
|
||||
if (!modelName) {
|
||||
showToast('Bitte Modell wählen', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiCall('/api/training/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
model_name: modelName,
|
||||
learning_rate: learningRate,
|
||||
epochs: epochs,
|
||||
batch_size: batchSize,
|
||||
lora_rank: loraRank
|
||||
})
|
||||
});
|
||||
|
||||
showToast('Training gestartet', 'success');
|
||||
|
||||
document.getElementById('start-training').disabled = true;
|
||||
document.getElementById('stop-training').disabled = false;
|
||||
|
||||
// Start SSE stream
|
||||
startTrainingStream();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error starting training:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopTraining() {
|
||||
try {
|
||||
await apiCall('/api/training/stop', { method: 'POST' });
|
||||
showToast('Training gestoppt', 'warning');
|
||||
|
||||
document.getElementById('start-training').disabled = false;
|
||||
document.getElementById('stop-training').disabled = true;
|
||||
|
||||
if (trainingEventSource) {
|
||||
trainingEventSource.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping training:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function startTrainingStream() {
|
||||
if (trainingEventSource) {
|
||||
trainingEventSource.close();
|
||||
}
|
||||
|
||||
trainingEventSource = new EventSource('/api/training/stream');
|
||||
|
||||
trainingEventSource.onmessage = (event) => {
|
||||
const status = JSON.parse(event.data);
|
||||
updateTrainingStatusUI(status);
|
||||
|
||||
if (!status.is_training && status.current_step > 0) {
|
||||
trainingEventSource.close();
|
||||
document.getElementById('start-training').disabled = false;
|
||||
document.getElementById('stop-training').disabled = true;
|
||||
showToast('Training abgeschlossen', 'success');
|
||||
}
|
||||
};
|
||||
|
||||
trainingEventSource.onerror = () => {
|
||||
trainingEventSource.close();
|
||||
};
|
||||
}
|
||||
|
||||
async function updateTrainingStatus() {
|
||||
try {
|
||||
const status = await apiCall('/api/training/status');
|
||||
updateTrainingStatusUI(status);
|
||||
|
||||
if (status.is_training) {
|
||||
document.getElementById('start-training').disabled = true;
|
||||
document.getElementById('stop-training').disabled = false;
|
||||
startTrainingStream();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateTrainingStatusUI(status) {
|
||||
const container = document.getElementById('training-status');
|
||||
|
||||
if (!status.is_training && status.current_step === 0) {
|
||||
container.innerHTML = '<p>Kein Training aktiv</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const eta = status.eta_seconds ? `${Math.floor(status.eta_seconds / 60)}m ${status.eta_seconds % 60}s` : 'N/A';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="status-grid">
|
||||
<div class="status-item">
|
||||
<label>Status</label>
|
||||
<div class="value">${status.is_training ? '🟢 Running' : '⏸️ Stopped'}</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>Step</label>
|
||||
<div class="value">${status.current_step} / ${status.total_steps}</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>Epoch</label>
|
||||
<div class="value">${status.current_epoch}</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>Train Loss</label>
|
||||
<div class="value">${status.train_loss || 'N/A'}</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>Val Loss</label>
|
||||
<div class="value">${status.val_loss || 'N/A'}</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>ETA</label>
|
||||
<div class="value">${eta}</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>Memory</label>
|
||||
<div class="value">${status.memory_usage_percent}%</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Update charts (simple implementation without chart library)
|
||||
updateChart('train-loss-chart', status.train_loss_history);
|
||||
updateChart('val-loss-chart', status.val_loss_history);
|
||||
}
|
||||
|
||||
function updateChart(canvasId, data) {
|
||||
// Simplified chart rendering (without external library)
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = 200;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (!data || data.length === 0) return;
|
||||
|
||||
const padding = 20;
|
||||
const width = canvas.width - 2 * padding;
|
||||
const height = canvas.height - 2 * padding;
|
||||
|
||||
const maxVal = Math.max(...data);
|
||||
const minVal = Math.min(...data);
|
||||
const range = maxVal - minVal || 1;
|
||||
|
||||
ctx.strokeStyle = '#4a9eff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
|
||||
data.forEach((val, i) => {
|
||||
const x = padding + (i / (data.length - 1)) * width;
|
||||
const y = padding + height - ((val - minVal) / range) * height;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ======================
|
||||
// Evaluation
|
||||
// ======================
|
||||
|
||||
function initEvaluation() {
|
||||
document.getElementById('load-test-prompt').addEventListener('click', loadTestPrompt);
|
||||
document.getElementById('run-comparison').addEventListener('click', runComparison);
|
||||
}
|
||||
|
||||
async function loadTestPrompt() {
|
||||
const taskType = document.getElementById('eval-task-type').value;
|
||||
|
||||
try {
|
||||
const prompts = await apiCall('/api/inference/test-prompts');
|
||||
const prompt = prompts[taskType];
|
||||
|
||||
if (prompt) {
|
||||
// Extract mail body from prompt
|
||||
const parts = prompt.split('\n\n');
|
||||
document.getElementById('eval-mail-text').value = parts.slice(1).join('\n\n');
|
||||
showToast('Test-Beispiel geladen', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading test prompt:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function runComparison() {
|
||||
const taskType = document.getElementById('eval-task-type').value;
|
||||
const mailBody = document.getElementById('eval-mail-text').value;
|
||||
|
||||
if (!mailBody) {
|
||||
showToast('Bitte Mail-Text eingeben', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('base-result').textContent = 'Generiere...';
|
||||
document.getElementById('finetuned-result').textContent = 'Generiere...';
|
||||
|
||||
try {
|
||||
const result = await apiCall('/api/inference/compare', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
task_type: taskType,
|
||||
mail_body: mailBody
|
||||
})
|
||||
});
|
||||
|
||||
document.getElementById('base-result').textContent = result.base || 'Modell nicht geladen';
|
||||
document.getElementById('finetuned-result').textContent = result.finetuned || 'Modell nicht geladen';
|
||||
|
||||
showToast('Vergleich abgeschlossen', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error running comparison:', error);
|
||||
document.getElementById('base-result').textContent = 'Fehler';
|
||||
document.getElementById('finetuned-result').textContent = 'Fehler';
|
||||
}
|
||||
}
|
||||
|
||||
// ======================
|
||||
// Init
|
||||
// ======================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initNavigation();
|
||||
initImport();
|
||||
initLabeling();
|
||||
initExport();
|
||||
initTraining();
|
||||
initEvaluation();
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mail Fine-Tuning App</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Sidebar Navigation -->
|
||||
<nav class="sidebar">
|
||||
<h1>Mail Fine-Tuning</h1>
|
||||
<ul class="nav-menu">
|
||||
<li><a href="#" data-view="import" class="nav-link active">📥 Mail Import</a></li>
|
||||
<li><a href="#" data-view="labeling" class="nav-link">🏷️ Labeling</a></li>
|
||||
<li><a href="#" data-view="export" class="nav-link">📊 Export & Stats</a></li>
|
||||
<li><a href="#" data-view="models" class="nav-link">🤖 Modelle</a></li>
|
||||
<li><a href="#" data-view="training" class="nav-link">🎯 Training</a></li>
|
||||
<li><a href="#" data-view="evaluation" class="nav-link">🧪 Evaluation</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
|
||||
<!-- Import View -->
|
||||
<div id="import-view" class="view active">
|
||||
<h2>Mail Import</h2>
|
||||
|
||||
<div class="upload-section">
|
||||
<div class="dropzone" id="dropzone">
|
||||
<p>📂 Dateien hier ablegen oder klicken</p>
|
||||
<p class="hint">Unterstützt: .eml, .mbox, .txt</p>
|
||||
<input type="file" id="file-input" multiple accept=".eml,.mbox,.txt" hidden>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mail-list-section">
|
||||
<div class="section-header">
|
||||
<h3>Importierte Mails (<span id="mail-count">0</span>)</h3>
|
||||
<button id="refresh-mails" class="btn btn-secondary">🔄 Aktualisieren</button>
|
||||
</div>
|
||||
<div id="mail-list" class="mail-list">
|
||||
<!-- Mails werden hier eingefügt -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Labeling View -->
|
||||
<div id="labeling-view" class="view">
|
||||
<div class="section-header">
|
||||
<h2>Mail Labeling</h2>
|
||||
<div class="filter-controls">
|
||||
<select id="status-filter">
|
||||
<option value="">Alle anzeigen</option>
|
||||
<option value="unlabeled" selected>Nur Unlabeled</option>
|
||||
<option value="labeled">Nur Labeled</option>
|
||||
<option value="skip">Übersprungen</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="labeling-progress"></div>
|
||||
<span class="progress-text" id="progress-text">0 / 0 gelabelt</span>
|
||||
</div>
|
||||
|
||||
<div class="keyboard-hints">
|
||||
Shortcuts: <kbd>N</kbd> Nächste | <kbd>S</kbd> Speichern | <kbd>K</kbd> Skip
|
||||
</div>
|
||||
|
||||
<div id="labeling-container">
|
||||
<!-- Labeling Interface wird hier geladen -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export View -->
|
||||
<div id="export-view" class="view">
|
||||
<h2>Daten Export & Statistiken</h2>
|
||||
|
||||
<div class="stats-grid" id="stats-grid">
|
||||
<!-- Stats werden hier eingefügt -->
|
||||
</div>
|
||||
|
||||
<div class="export-section">
|
||||
<h3>Training-Daten exportieren</h3>
|
||||
<div class="export-controls">
|
||||
<label>
|
||||
Train/Val Split:
|
||||
<input type="number" id="train-split" value="90" min="50" max="95" step="5">%
|
||||
</label>
|
||||
<button id="export-jsonl" class="btn btn-primary">📦 JSONL generieren</button>
|
||||
</div>
|
||||
<div id="export-result"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Models View -->
|
||||
<div id="models-view" class="view">
|
||||
<h2>Modell-Verwaltung</h2>
|
||||
|
||||
<div class="model-section">
|
||||
<h3>Verfügbare Modelle</h3>
|
||||
<div id="models-list" class="models-list">
|
||||
<!-- Modelle werden hier geladen -->
|
||||
</div>
|
||||
|
||||
<div class="model-download">
|
||||
<h3>Modell herunterladen</h3>
|
||||
<p class="info-text">
|
||||
Modelle müssen manuell heruntergeladen werden. Empfohlen:
|
||||
</p>
|
||||
<ul>
|
||||
<li>mlx-community/Mistral-7B-Instruct-v0.3-4bit</li>
|
||||
<li>mlx-community/Meta-Llama-3-8B-Instruct-4bit</li>
|
||||
</ul>
|
||||
<p class="code-example">
|
||||
huggingface-cli download [model-name] --local-dir models/[model-name]
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Training View -->
|
||||
<div id="training-view" class="view">
|
||||
<h2>Training</h2>
|
||||
|
||||
<div class="training-config">
|
||||
<h3>Konfiguration</h3>
|
||||
<form id="training-form">
|
||||
<div class="form-group">
|
||||
<label>Modell:</label>
|
||||
<select id="training-model" required>
|
||||
<option value="">-- Modell wählen --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Learning Rate: <span id="lr-value">1e-5</span>
|
||||
</label>
|
||||
<input type="range" id="learning-rate"
|
||||
min="-6" max="-4" step="0.1" value="-5">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Epochs: <span id="epochs-value">3</span>
|
||||
</label>
|
||||
<input type="range" id="epochs"
|
||||
min="1" max="10" value="3">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Batch Size:</label>
|
||||
<select id="batch-size">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="4" selected>4</option>
|
||||
<option value="8">8</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>LoRA Rank:</label>
|
||||
<select id="lora-rank">
|
||||
<option value="4">4</option>
|
||||
<option value="8" selected>8</option>
|
||||
<option value="16">16</option>
|
||||
<option value="32">32</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" id="start-training">
|
||||
▶️ Training starten
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" id="stop-training" disabled>
|
||||
⏹️ Training stoppen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="training-status" id="training-status">
|
||||
<!-- Training Status wird hier angezeigt -->
|
||||
</div>
|
||||
|
||||
<div class="training-charts">
|
||||
<div class="chart-container">
|
||||
<h4>Training Loss</h4>
|
||||
<canvas id="train-loss-chart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<h4>Validation Loss</h4>
|
||||
<canvas id="val-loss-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Evaluation View -->
|
||||
<div id="evaluation-view" class="view">
|
||||
<h2>Modell Evaluation</h2>
|
||||
|
||||
<div class="eval-controls">
|
||||
<h3>Chat Interface</h3>
|
||||
<div class="form-group">
|
||||
<label>Task Type:</label>
|
||||
<select id="eval-task-type">
|
||||
<option value="Zusammenfassen">Zusammenfassen</option>
|
||||
<option value="Antwort schreiben">Antwort schreiben</option>
|
||||
<option value="Kategorisieren">Kategorisieren</option>
|
||||
<option value="Action Items">Action Items</option>
|
||||
<option value="Custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Mail-Text:</label>
|
||||
<textarea id="eval-mail-text" rows="6" placeholder="Mail-Text hier eingeben..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button id="load-test-prompt" class="btn btn-secondary">📝 Test-Beispiel laden</button>
|
||||
<button id="run-comparison" class="btn btn-primary">🔍 Vergleich starten</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comparison-results">
|
||||
<div class="result-box">
|
||||
<h4>Base Model</h4>
|
||||
<div id="base-result" class="result-content">
|
||||
Noch kein Ergebnis
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-box">
|
||||
<h4>Fine-tuned Model</h4>
|
||||
<div id="finetuned-result" class="result-content">
|
||||
Noch kein Ergebnis
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,600 @@
|
||||
/* Mail Fine-Tuning App Styles */
|
||||
|
||||
:root {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--bg-tertiary: #3a3a3a;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #b0b0b0;
|
||||
--accent-primary: #4a9eff;
|
||||
--accent-success: #4caf50;
|
||||
--accent-warning: #ff9800;
|
||||
--accent-danger: #f44336;
|
||||
--border-color: #444;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 2rem 1rem;
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #3a8eef;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--accent-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--accent-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Upload Section */
|
||||
.dropzone {
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.dropzone:hover {
|
||||
border-color: var(--accent-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.dropzone.dragover {
|
||||
border-color: var(--accent-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Section Header */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Mail List */
|
||||
.mail-list {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mail-item {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.mail-item.labeled {
|
||||
border-left-color: var(--accent-success);
|
||||
}
|
||||
|
||||
.mail-item.unlabeled {
|
||||
border-left-color: var(--accent-warning);
|
||||
}
|
||||
|
||||
.mail-item.skip {
|
||||
border-left-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mail-subject {
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mail-meta {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mail-body {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.mail-actions {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mail-actions button {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Labeling Interface */
|
||||
#labeling-container {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.current-mail {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.progress-bar {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
height: 30px;
|
||||
position: relative;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
background: var(--accent-primary);
|
||||
height: 100%;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Keyboard Hints */
|
||||
.keyboard-hints {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
kbd {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border-color);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Export Section */
|
||||
.export-section {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.export-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.export-controls input {
|
||||
width: 80px;
|
||||
padding: 0.4rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#export-result {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#export-result.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Models List */
|
||||
.models-list {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.model-item {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.model-download {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.code-example {
|
||||
background: var(--bg-primary);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
color: var(--accent-primary);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Training Status */
|
||||
.training-status {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-item label {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.status-item .value {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Training Charts */
|
||||
.training-charts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.chart-container h4 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100% !important;
|
||||
height: 200px !important;
|
||||
}
|
||||
|
||||
/* Evaluation */
|
||||
.comparison-results {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.result-box {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
background: var(--bg-primary);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
min-height: 150px;
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Filter Controls */
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.filter-controls select {
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Toast Notifications */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: 4px solid var(--accent-primary);
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
min-width: 300px;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-left-color: var(--accent-success);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-left-color: var(--accent-danger);
|
||||
}
|
||||
|
||||
.toast.warning {
|
||||
border-left-color: var(--accent-warning);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.comparison-results,
|
||||
.training-charts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user