/* Soup Web UI — Frontend Application */
const API = ''; // same origin
// --- State ---
let currentPage = 'dashboard';
let runsData = [];
let systemInfo = null;
let chatMessages = [];
let chatEndpoint = null;
// --- Navigation ---
function navigate(page) {
currentPage = page;
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById('page-' + page).classList.add('active');
document.querySelector(`[data-page="${page}"]`).classList.add('active');
if (page === 'dashboard') loadDashboard();
else if (page === 'training') loadTrainingPage();
else if (page === 'data') { /* loaded on demand */ }
else if (page === 'chat') loadChatPage();
}
// --- API Helpers ---
async function api(path, opts = {}) {
const resp = await fetch(API + path, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || 'API error');
}
return resp.json();
}
function formatDuration(secs) {
if (!secs) return '-';
if (secs < 60) return `${Math.round(secs)}s`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ${Math.round(secs % 60)}s`;
return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
}
function formatDate(iso) {
if (!iso) return '-';
const d = new Date(iso);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function statusBadge(status) {
const map = {
completed: 'badge-success',
failed: 'badge-danger',
running: 'badge-warning',
};
return `${status} `;
}
function truncate(str, len = 30) {
if (!str) return '-';
return str.length > len ? str.substring(0, len) + '...' : str;
}
// --- Dashboard ---
async function loadDashboard() {
try {
const [runsResp, sysResp] = await Promise.all([
api('/api/runs?limit=100'),
api('/api/system'),
]);
runsData = runsResp.runs;
systemInfo = sysResp;
renderDashboard();
} catch (err) {
document.getElementById('dashboard-content').innerHTML =
`
Error loading dashboard: ${err.message}
`;
}
}
function renderDashboard() {
const completed = runsData.filter(r => r.status === 'completed');
const failed = runsData.filter(r => r.status === 'failed');
const running = runsData.filter(r => r.status === 'running');
const bestLoss = completed.length
? Math.min(...completed.map(r => r.final_loss).filter(Boolean)).toFixed(4)
: '-';
document.getElementById('dashboard-content').innerHTML = `
${runsData.length}
Total Runs
${completed.length}
Completed
${running.length}
Running
System
Device: ${systemInfo.device_name} |
GPU Memory: ${systemInfo.gpu_info.memory_total} |
Python: ${systemInfo.python_version} |
Soup: v${systemInfo.version}
Recent Runs
${runsData.length === 0
? '
No runs yet
Start training with "soup train" or use the New Training page
'
: renderRunsTable(runsData.slice(0, 20))
}
`;
}
function renderRunsTable(runs) {
return `
Run ID
Name
Model
Task
Status
Loss
Duration
Date
${runs.map(r => `
${r.run_id.substring(0, 20)}...
${r.experiment_name || '-'}
${truncate(r.base_model)}
${r.task || 'sft'}
${statusBadge(r.status)}
${r.final_loss ? r.final_loss.toFixed(4) : '-'}
${formatDuration(r.duration_secs)}
${formatDate(r.created_at)}
Delete
`).join('')}
`;
}
async function deleteRun(runId) {
if (!confirm('Delete this run and all its metrics?')) return;
try {
await api(`/api/runs/${runId}`, { method: 'DELETE' });
loadDashboard();
} catch (err) {
alert('Error: ' + err.message);
}
}
// --- Run Detail Modal ---
let lossChart = null;
async function showRunDetail(runId) {
const modal = document.getElementById('run-modal');
const body = document.getElementById('run-modal-body');
modal.classList.add('active');
body.innerHTML = 'Loading...
';
try {
const [run, metricsResp] = await Promise.all([
api(`/api/runs/${runId}`),
api(`/api/runs/${runId}/metrics`),
]);
const config = run.config_json ? JSON.parse(run.config_json) : {};
const metrics = metricsResp.metrics;
body.innerHTML = `
Status
${statusBadge(run.status)}
Model
${run.base_model || '-'}
Task
${run.task || 'sft'}
Device
${run.device_name || run.device || '-'}
Duration
${formatDuration(run.duration_secs)}
Initial Loss
${run.initial_loss ? run.initial_loss.toFixed(4) : '-'}
Final Loss
${run.final_loss ? run.final_loss.toFixed(4) : '-'}
${metrics.length > 0 ? `
` : ''}
Config
${JSON.stringify(config, null, 2)}
`;
if (metrics.length > 0) {
renderCharts(metrics);
}
} catch (err) {
body.innerHTML = ``;
}
}
function renderCharts(metrics) {
const steps = metrics.map(m => m.step);
const losses = metrics.map(m => m.loss);
const lrs = metrics.map(m => m.lr);
// Loss chart
const lossCtx = document.getElementById('loss-chart');
if (lossCtx) {
if (lossChart) lossChart.destroy();
lossChart = new Chart(lossCtx, {
type: 'line',
data: {
labels: steps,
datasets: [{
label: 'Loss',
data: losses,
borderColor: '#c0512d',
backgroundColor: 'rgba(192, 81, 45, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 0,
borderWidth: 2,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { title: { display: true, text: 'Step', color: '#a09088' }, ticks: { color: '#a09088' }, grid: { color: 'rgba(58,48,64,0.5)' } },
y: { title: { display: true, text: 'Loss', color: '#a09088' }, ticks: { color: '#a09088' }, grid: { color: 'rgba(58,48,64,0.5)' } },
},
},
});
}
// LR chart
const lrCtx = document.getElementById('lr-chart');
if (lrCtx) {
new Chart(lrCtx, {
type: 'line',
data: {
labels: steps,
datasets: [{
label: 'Learning Rate',
data: lrs,
borderColor: '#e8975a',
backgroundColor: 'rgba(232, 151, 90, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 0,
borderWidth: 2,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { title: { display: true, text: 'Step', color: '#a09088' }, ticks: { color: '#a09088' }, grid: { color: 'rgba(58,48,64,0.5)' } },
y: { title: { display: true, text: 'LR', color: '#a09088' }, ticks: { color: '#a09088' }, grid: { color: 'rgba(58,48,64,0.5)' } },
},
},
});
}
}
function closeModal() {
document.getElementById('run-modal').classList.remove('active');
}
// --- New Training Page ---
async function loadTrainingPage() {
try {
const [templatesResp, statusResp] = await Promise.all([
api('/api/templates'),
api('/api/train/status'),
]);
renderTrainingPage(templatesResp.templates, statusResp);
} catch (err) {
document.getElementById('training-content').innerHTML =
``;
}
}
function renderTrainingPage(templates, status) {
const templateNames = Object.keys(templates);
const editorId = 'config-editor';
document.getElementById('training-content').innerHTML = `
Template
-- Select a template --
${templateNames.map(t => `${t} `).join('')}
Validate
Start Training
Training Status
${status.running
? `
Running PID: ${status.pid}
Stop Training `
: '
No training in progress
'
}
Quick Reference
Tasks: sft, dpo, grpo
Backends: transformers, unsloth
Modalities: text, vision
Quantization: 4bit, 8bit, none
Formats: alpaca, sharegpt, chatml, dpo, llava, sharegpt4v
`;
// Store templates globally
window._templates = templates;
}
function loadTemplate() {
const sel = document.getElementById('template-select');
const editor = document.getElementById('config-editor');
if (sel.value && window._templates[sel.value]) {
editor.value = window._templates[sel.value];
}
}
async function validateConfig() {
const yaml = document.getElementById('config-editor').value;
const statusEl = document.getElementById('config-status');
try {
const result = await api('/api/config/validate', {
method: 'POST',
body: JSON.stringify({ yaml }),
});
if (result.valid) {
statusEl.innerHTML = 'Config is valid! ';
} else {
statusEl.innerHTML = `Invalid: ${result.error} `;
}
} catch (err) {
statusEl.innerHTML = `Error: ${err.message} `;
}
}
async function startTraining() {
const yaml = document.getElementById('config-editor').value;
if (!yaml.trim()) {
alert('Please enter a config');
return;
}
if (!confirm('Start training with this config?')) return;
try {
const result = await api('/api/train/start', {
method: 'POST',
body: JSON.stringify({ config_yaml: yaml }),
});
document.getElementById('config-status').innerHTML =
`Training started! PID: ${result.pid} `;
// Refresh status
loadTrainingPage();
} catch (err) {
document.getElementById('config-status').innerHTML =
`Error: ${err.message} `;
}
}
async function stopTraining() {
if (!confirm('Stop the current training run?')) return;
try {
await api('/api/train/stop', { method: 'POST' });
loadTrainingPage();
} catch (err) {
alert('Error: ' + err.message);
}
}
// --- Data Explorer ---
async function inspectData() {
const path = document.getElementById('data-path').value;
if (!path.trim()) { alert('Enter a file path'); return; }
const limit = parseInt(document.getElementById('data-limit').value) || 50;
const content = document.getElementById('data-content');
content.innerHTML = 'Loading...
';
try {
const result = await api('/api/data/inspect', {
method: 'POST',
body: JSON.stringify({ path, limit }),
});
renderDataResults(result);
} catch (err) {
content.innerHTML = ``;
}
}
function renderDataResults(data) {
const content = document.getElementById('data-content');
content.innerHTML = `
${data.total}
Total Entries
${data.format}
Detected Format
${data.keys.length}
Fields
Fields: ${data.keys.join(', ')}
Sample Data (${data.sample.length} of ${data.total})
${data.sample.map((entry, idx) => `
#${idx + 1}
${Object.entries(entry).map(([key, val]) => `
${key}:
${typeof val === 'object' ? JSON.stringify(val).substring(0, 200) : String(val).substring(0, 200)}
`).join('')}
`).join('')}
`;
}
// --- Model Chat ---
function loadChatPage() {
// Just ensure the page renders with current messages
renderChatMessages();
}
function renderChatMessages() {
const container = document.getElementById('chat-messages');
if (!container) return;
if (chatMessages.length === 0) {
container.innerHTML = `
No messages yet
Enter a server URL and start chatting
`;
return;
}
container.innerHTML = chatMessages.map(msg => `
${msg.role}
${escapeHtml(msg.content)}
`).join('');
container.scrollTop = container.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function sendChatMessage() {
const input = document.getElementById('chat-input');
const serverUrl = document.getElementById('chat-server').value.trim();
const msg = input.value.trim();
if (!msg) return;
if (!serverUrl) { alert('Enter a server URL (e.g., http://localhost:8000)'); return; }
chatMessages.push({ role: 'user', content: msg });
input.value = '';
renderChatMessages();
try {
const resp = await fetch(serverUrl + '/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: chatMessages.map(m => ({ role: m.role, content: m.content })),
max_tokens: 512,
temperature: 0.7,
}),
});
const data = await resp.json();
const reply = data.choices[0].message.content;
chatMessages.push({ role: 'assistant', content: reply });
renderChatMessages();
} catch (err) {
chatMessages.push({ role: 'assistant', content: `[Error: ${err.message}]` });
renderChatMessages();
}
}
function clearChat() {
chatMessages = [];
renderChatMessages();
}
function handleChatKey(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendChatMessage();
}
}
// --- Init ---
document.addEventListener('DOMContentLoaded', () => {
navigate('dashboard');
});