Files

832 lines
39 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Training — Nexus One AI</title>
<link rel="stylesheet" href="style.css?v=12">
<style>
/* ── Shared layout ────────────────────────────────────────── */
.tr-card { background:var(--navy2); border:1px solid var(--bdr); border-radius:14px; padding:28px; margin-bottom:28px; }
.tr-card-title { font-size:16px; font-weight:700; color:var(--ink); margin-bottom:4px; }
.tr-card-sub { font-size:13px; color:var(--lt); margin-bottom:20px; }
.tr-btn { padding:9px 18px; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; border:none; transition:all .15s; font-family:inherit; }
.tr-btn.primary { background:var(--purple); color:var(--ink); }
.tr-btn.primary:hover { background:#6D28D9; }
.tr-btn.ghost { background:rgba(255,255,255,.03); color:var(--med); border:1px solid var(--bdr); }
.tr-btn.ghost:hover { border-color:var(--purple); color:var(--purple); }
.tr-btn.danger { background:rgba(185,28,28,.08); color:#B91C1C; border:1px solid rgba(239,68,68,.25); }
.tr-btn.danger:hover { background:#DC2626; color:var(--ink); }
.tr-btn:disabled { opacity:.45; cursor:not-allowed; }
.tr-table-wrap { border:1px solid var(--bdr); border-radius:10px; overflow:hidden; }
table.tr-table { width:100%; border-collapse:collapse; }
.tr-table th { background:rgba(255,255,255,.03); padding:10px 14px; text-align:left; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; color:var(--lt); border-bottom:1px solid var(--bdr); }
.tr-table td { padding:12px 14px; border-bottom:1px solid var(--bdr); font-size:13px; color:var(--ink); vertical-align:middle; }
.tr-table tr:last-child td { border-bottom:none; }
.tr-table tr:hover td { background:rgba(255,255,255,.03); }
.tr-empty { text-align:center; color:var(--lt); padding:40px 0; font-size:13px; }
/* ── Dataset upload zone ──────────────────────────────────── */
.upload-zone { border:2px dashed var(--bdr); border-radius:10px; padding:32px; text-align:center; cursor:pointer; transition:.2s; background:rgba(255,255,255,.03); }
.upload-zone:hover, .upload-zone.drag { border-color:var(--purple); background:rgba(124,58,237,.12); }
.upload-zone input { display:none; }
.upload-zone .uz-icon { font-size:32px; margin-bottom:8px; }
.upload-zone .uz-label { font-size:14px; font-weight:600; color:var(--ink); margin-bottom:4px; }
.upload-zone .uz-hint { font-size:12px; color:var(--lt); }
.upload-progress { margin-top:10px; font-size:13px; color:var(--purple); display:none; }
/* ── Launch form ──────────────────────────────────────────── */
.launch-form { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
@media(max-width:640px){ .launch-form { grid-template-columns:1fr; } }
.launch-form .full { grid-column:1/-1; }
.lf-label { display:block; font-size:12px; font-weight:600; color:var(--med); margin-bottom:5px; }
.lf-input, .lf-select { width:100%; padding:9px 12px; border:1.5px solid var(--bdr); border-radius:8px; font-size:13px; font-family:inherit; outline:none; color:var(--ink); box-sizing:border-box; }
.lf-input:focus, .lf-select:focus { border-color:var(--purple); }
.lf-hint { font-size:11px; color:var(--lt); margin-top:3px; }
.param-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:14px; }
@media(max-width:640px){ .param-grid { grid-template-columns:1fr 1fr; } }
/* ── Status badges ────────────────────────────────────────── */
.badge-status { display:inline-block; font-size:10px; font-weight:700; padding:2px 9px; border-radius:10px; text-transform:uppercase; letter-spacing:.4px; }
.badge-status.running { background:rgba(34,197,94,.15); color:#15803D; }
.badge-status.completed { background:#DBEAFE; color:#1D4ED8; }
.badge-status.failed { background:#FEE2E2; color:#B91C1C; }
.badge-status.pending { background:rgba(234,179,8,.15); color:#92400E; }
.badge-status.cancelled { background:#F3F4F6; color:#6B7280; }
.badge-status.approved { background:rgba(34,197,94,.15); color:#15803D; }
.badge-status.rejected { background:#FEE2E2; color:#B91C1C; }
/* ── Log viewer ───────────────────────────────────────────── */
.log-wrap { background:#0F1117; border-radius:8px; padding:14px 16px; font-family:monospace; font-size:11px; line-height:1.6; color:#A3E635; max-height:220px; overflow-y:auto; margin-top:14px; display:none; white-space:pre-wrap; word-break:break-all; }
.log-toggle { font-size:12px; color:var(--purple); cursor:pointer; font-weight:600; margin-top:8px; display:inline-block; }
/* ── Loss chart ───────────────────────────────────────────── */
.chart-wrap { margin-top:16px; display:none; }
.chart-wrap canvas { max-width:100%; border-radius:8px; }
/* ── Job row expanded ─────────────────────────────────────── */
.job-detail { padding:14px 20px 18px; background:#F8FAFC; border-top:1px solid var(--bdr); display:none; }
.job-detail.open { display:block; }
.job-meta { display:flex; gap:24px; flex-wrap:wrap; font-size:12px; color:var(--lt); margin-bottom:10px; }
.job-meta span { display:flex; gap:4px; }
.job-meta strong { color:var(--ink); }
/* ── Toast ────────────────────────────────────────────────── */
#tr-toast { position:fixed; bottom:24px; right:24px; background:#1E3A5F; color:var(--ink); padding:12px 20px; border-radius:10px; font-size:13px; display:none; z-index:9999; box-shadow:0 8px 24px rgba(0,0,0,.2); max-width:340px; }
#tr-toast.err { background:#7F1D1D; }
/* ── Toolbar ──────────────────────────────────────────────── */
.tr-toolbar { display:flex; align-items:center; gap:10px; margin-bottom:16px; flex-wrap:wrap; }
.creator-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
.creator-grid .full { grid-column:1/-1; }
.creator-actions { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.sample-card { border:1px solid var(--bdr); border-radius:10px; padding:16px; margin-top:12px; background:rgba(255,255,255,.02); }
.sample-head { display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:12px; }
.sample-fields { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.sample-fields textarea { min-height:110px; resize:vertical; }
.source-preview { margin-top:10px; font-size:11px; color:var(--lt); max-height:70px; overflow:auto; white-space:pre-wrap; }
@media(max-width:720px){ .creator-grid,.sample-fields { grid-template-columns:1fr; } }
</style>
</head>
<body data-role="admin">
<header class="topnav">
<a href="index.html" class="brand">Nexus One <span>AI</span></a>
<nav>
<a href="index.html">Home</a>
<a href="quickstart.html">Quick Start</a>
<a href="prompts.html">Prompt Library</a>
<a href="usecases.html">Use Cases</a>
<span class="nav-sep"></span>
<div class="nav-dropdown">
<button class="nav-drop-btn">Help ▾</button>
<div class="nav-drop-menu">
<span class="nav-drop-cat">LEARN /</span>
<a href="quickstart.html">Quick Start</a>
<a href="models.html">Models</a>
<span class="nav-drop-cat">SUPPORT /</span>
<a href="troubleshooting.html">Troubleshoot</a>
<a href="faq.html">FAQ</a>
<span class="nav-drop-cat">MORE /</span>
<a href="glossary.html">Glossary</a>
<a href="whats-new.html">What's New</a>
</div>
</div>
<div class="nav-dropdown">
<button class="nav-drop-btn">Admin ▾</button>
<div class="nav-drop-menu nav-drop-menu-wide">
<span class="nav-drop-cat">DOCS /</span>
<a href="security.html">Security & Privacy</a>
<a href="admin.html">Admin Guide</a>
<span class="nav-drop-cat">MONITOR /</span>
<a href="dashboard.html">Dashboard</a>
<a href="audit.html">Governance & Audit</a>
<a href="health.html">Health Center</a>
<span class="nav-drop-cat">MANAGE /</span>
<a href="users.html">Users</a>
<a href="teams.html">Teams</a>
<a href="models-admin.html">Model Catalog</a>
<span class="nav-drop-cat">TOOLS /</span>
<a href="apikeys.html">API Keys</a>
<a href="model-compare.html">Response Comparison</a>
<a href="api-playground.html">API Playground</a>
<a href="guardrails.html">Guardrails</a>
<span class="nav-drop-cat">SYSTEM /</span>
<a href="console.html">Console</a>
<a href="settings.html">Settings</a>
</div>
</div>
<div class="nav-dropdown">
<button class="nav-drop-btn active">AI Tools ▾</button>
<div class="nav-drop-menu">
<span class="nav-drop-cat">MODEL BUILD /</span>
<a href="dataset-creation.html">Dataset Creation</a>
<a href="training.html" class="active">Model Training</a>
<span class="nav-drop-cat">INTELLIGENCE /</span>
<a href="documents.html">Document Intelligence</a>
<a href="chat-multi.html">AI Workspace</a>
<a href="prompt-studio.html">Prompt Studio</a>
<a href="meeting.html">Meeting Assistant</a>
<span class="nav-drop-cat">AUTOMATION /</span>
<a href="agents.html">Agent Studio</a>
<a href="schedules.html">Scheduled Jobs</a>
<a href="workflows.html">Workflow Designer</a>
<span class="nav-drop-cat">QUALITY /</span>
<a href="evals.html">AI Eval Suite</a>
<a href="chatrooms.html">Chat Rooms</a>
</div>
</div>
</nav>
<a href="notifications.html" class="nav-icon-btn" style="position:relative" aria-label="Notifications"><span class="icon-svg icon-sm" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M15 17h5l-1.4-1.4A2 2 0 0 1 18 14.2V11a6 6 0 1 0-12 0v3.2a2 2 0 0 1-.6 1.4L4 17h5"/><path d="M10 17a2 2 0 0 0 4 0"/></svg></span></a>
<span class="badge" data-brand="tier">Basic Tier</span>
<div id="nav-org-logo" class="nav-org-logo"></div>
</header>
<div class="page-hero">
<div class="label">Admin · Fine-Tuning</div>
<h1>Model Training</h1>
<p>Upload datasets, launch QLoRA fine-tuning jobs, and monitor training progress live.</p>
</div>
<div class="content">
<!-- ── 1. Dataset Manager ── -->
<div class="tr-card">
<div class="tr-card-title"><span class="icon-svg icon-sm" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></span> Dataset Manager</div>
<div class="tr-card-sub">Upload JSONL or CSV files. JSONL rows must have a <code>text</code> field, or <code>prompt</code>/<code>completion</code> pair, or <code>instruction</code>/<code>output</code> pair.</div>
<div class="upload-zone" id="upload-zone" onclick="document.getElementById('file-input').click()">
<input type="file" id="file-input" accept=".jsonl,.json,.csv" onchange="uploadDataset(this.files[0])">
<div class="uz-icon"><span class="icon-svg icon-xl" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg></span></div>
<div class="uz-label">Click to upload or drag &amp; drop</div>
<div class="uz-hint">.jsonl · .json · .csv — max 500 MB</div>
</div>
<div class="upload-progress" id="upload-progress">Uploading…</div>
<div style="margin-top:20px;">
<div class="tr-toolbar">
<button class="tr-btn ghost" onclick="loadDatasets()">Refresh</button>
</div>
<div class="tr-table-wrap">
<table class="tr-table">
<thead>
<tr><th>Filename</th><th>Rows</th><th>Size</th><th>Uploaded</th><th></th></tr>
</thead>
<tbody id="ds-tbody">
<tr><td colspan="5" class="tr-empty">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ── 2. Launch Fine-Tune ── -->
<div class="tr-card">
<div class="tr-card-title">Launch Fine-Tune Job</div>
<div class="tr-card-sub">Uses QLoRA (4-bit quantization + LoRA adapters). Requires a GPU with ≥ 8 GB VRAM.</div>
<div class="launch-form">
<div class="full">
<label class="lf-label">Job Name</label>
<input type="text" id="lf-name" class="lf-input" placeholder="e.g. mistral-govquery-v1">
</div>
<div>
<label class="lf-label">Base Model</label>
<select id="lf-model" class="lf-select">
<option value="">Loading models…</option>
</select>
<div class="lf-hint">Models currently in Ollama</div>
</div>
<div>
<label class="lf-label">Dataset</label>
<select id="lf-dataset" class="lf-select">
<option value="">Select a dataset…</option>
</select>
</div>
<div class="full">
<label class="lf-label" style="margin-bottom:10px;">QLoRA Parameters</label>
<div class="param-grid">
<div>
<label class="lf-label">Epochs</label>
<input type="number" id="lf-epochs" class="lf-input" value="3" min="1" max="50">
<div class="lf-hint">Training passes over dataset</div>
</div>
<div>
<label class="lf-label">Learning Rate</label>
<input type="number" id="lf-lr" class="lf-input" value="0.0002" step="0.00001" min="0.000001">
<div class="lf-hint">Recommended: 1e-4 to 3e-4</div>
</div>
<div>
<label class="lf-label">Batch Size</label>
<input type="number" id="lf-batch" class="lf-input" value="4" min="1" max="64">
<div class="lf-hint">Per-device, lower = less VRAM</div>
</div>
<div>
<label class="lf-label">LoRA Rank (r)</label>
<input type="number" id="lf-lora-r" class="lf-input" value="16" min="4" max="256" step="4">
<div class="lf-hint">Higher = more capacity</div>
</div>
<div>
<label class="lf-label">LoRA Alpha</label>
<input type="number" id="lf-lora-alpha" class="lf-input" value="32" min="4" max="512" step="4">
<div class="lf-hint">Usually 2× LoRA rank</div>
</div>
<div>
<label class="lf-label">Output Model Name</label>
<input type="text" id="lf-output" class="lf-input" placeholder="auto">
<div class="lf-hint">Name for Ollama registration</div>
</div>
</div>
</div>
<div class="full">
<button class="tr-btn primary" onclick="launchJob()" id="launch-btn">Launch Training</button>
</div>
</div>
</div>
<!-- ── 3. Job Monitor ── -->
<div class="tr-card">
<div class="tr-card-title">Training Jobs</div>
<div class="tr-card-sub">Click a job row to expand logs and the live loss curve. Running jobs auto-refresh every 10 seconds.</div>
<div class="tr-toolbar">
<button class="tr-btn ghost" onclick="loadJobs()">Refresh</button>
</div>
<div class="tr-table-wrap">
<table class="tr-table">
<thead>
<tr><th>Name</th><th>Base Model</th><th>Dataset</th><th>Status</th><th>Created</th><th></th></tr>
</thead>
<tbody id="jobs-tbody">
<tr><td colspan="6" class="tr-empty">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div><!-- /content -->
<footer>
<p data-brand="footer">Powered by Cezen</p>
</footer>
<div id="tr-toast"></div>
<script>
const _API = '/api';
const MOCK_DATASETS = [
{id:1, name:'hr_qa_pairs.jsonl', size_bytes:248000, row_count:412, created_at:'2026-05-20T10:00:00Z'},
{id:2, name:'finance_sops_qa.jsonl', size_bytes:189000, row_count:301, created_at:'2026-05-25T11:30:00Z'},
{id:3, name:'legal_clauses_qa.jsonl', size_bytes:542000, row_count:887, created_at:'2026-06-01T09:15:00Z'},
];
const MOCK_JOBS = [
{id:1, name:'HR Policy LoRA', base_model:'llama3:8b', dataset_name:'hr_qa_pairs.jsonl', status:'completed', epochs:3, loss_final:0.142, created_at:'2026-06-10T08:00:00Z', completed_at:'2026-06-10T14:22:00Z'},
{id:2, name:'Finance Expert', base_model:'mistral:7b', dataset_name:'finance_sops_qa.jsonl', status:'running', epochs:5, loss_final:null, created_at:'2026-06-28T06:00:00Z', completed_at:null},
{id:3, name:'Legal Classifier', base_model:'llama3:8b', dataset_name:'legal_clauses_qa.jsonl', status:'failed', epochs:2, loss_final:null, created_at:'2026-06-15T10:00:00Z', completed_at:null},
];
let datasetsCache = [];
let jobRefreshTimer = null;
let lossCharts = {};
let generationRefreshTimer = null;
// ── Helpers ──────────────────────────────────────────────────────────────────
function toast(msg, err=false) {
const t = document.getElementById('tr-toast');
t.textContent = msg;
t.className = err ? 'err' : '';
t.style.display = 'block';
setTimeout(() => t.style.display='none', 3500);
}
function fmt(iso) {
if (!iso) return '—';
const s = iso.replace(/\+00:00$/, 'Z').replace(/\.\d+Z$/, 'Z');
return new Date(s).toLocaleString([], {dateStyle:'short', timeStyle:'short'});
}
function fmtBytes(b) {
if (b < 1024) return b + ' B';
if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB';
return (b/(1024*1024)).toFixed(1) + ' MB';
}
function statusBadge(s) {
return `<span class="badge-status ${s}">${s}</span>`;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
}
async function startDatasetCreation() {
const name = document.getElementById('dc-name').value.trim();
const model = document.getElementById('dc-model').value;
const file = document.getElementById('dc-file').files[0];
if (!name || !model || !file) return toast('Dataset name, local model, and source document are required', true);
const button = document.getElementById('dc-start');
button.disabled = true;
const fd = new FormData();
fd.append('name', name); fd.append('model', model); fd.append('file', file);
fd.append('format', document.getElementById('dc-format').value);
fd.append('target_count', document.getElementById('dc-count').value);
try {
const res = await fetch(`${_API}/training/dataset-generation`, {method:'POST', credentials:'include', body:fd});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Could not start generation');
toast('Dataset generation started');
document.getElementById('dc-status').textContent = 'Generation is running locally. You can leave this page and return.';
loadGenerationJobs();
} catch (e) { toast(e.message, true); }
finally { button.disabled = false; }
}
async function loadGenerationJobs() {
const tbody = document.getElementById('dc-jobs');
try {
const res = await fetch(`${_API}/training/dataset-generation`, {credentials:'include'});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Could not load generation jobs');
const jobs = data.jobs || [];
tbody.innerHTML = jobs.length ? jobs.map(job => `<tr>
<td><strong>${escapeHtml(job.name)}</strong></td><td>${escapeHtml(job.source_name)}</td>
<td>${escapeHtml(job.model)}</td><td>${job.generated_count}/${job.target_count}</td>
<td>${statusBadge(job.status)}${job.error_msg ? `<div class="lf-hint">${escapeHtml(job.error_msg)}</div>` : ''}</td>
<td>${job.generated_count ? `<button class="tr-btn ghost" onclick="reviewGeneration(${job.id},'${escapeHtml(job.name)}')">Review</button>` : ''}</td>
</tr>`).join('') : '<tr><td colspan="6" class="tr-empty">No generation jobs yet.</td></tr>';
const running = jobs.some(job => ['pending','running'].includes(job.status));
if (running && !generationRefreshTimer) generationRefreshTimer = setInterval(loadGenerationJobs, 5000);
if (!running && generationRefreshTimer) { clearInterval(generationRefreshTimer); generationRefreshTimer = null; }
} catch (e) { tbody.innerHTML = `<tr><td colspan="6" class="tr-empty">${escapeHtml(e.message)}</td></tr>`; }
}
async function reviewGeneration(jobId, name) {
const review = document.getElementById('dc-review');
const res = await fetch(`${_API}/training/dataset-generation/${jobId}/samples`, {credentials:'include'});
const data = await res.json();
if (!res.ok) return toast(data.detail || 'Could not load samples', true);
const samples = data.samples || [];
review.innerHTML = `<div class="tr-toolbar" style="margin-top:22px"><strong>Review: ${escapeHtml(name)}</strong>
<button class="tr-btn ghost" onclick="setAllSamples(${jobId},'approved')">Approve all</button>
<button class="tr-btn primary" onclick="publishGeneration(${jobId})">Publish approved to Dataset Manager</button></div>` +
(samples.length ? samples.map(s => `<div class="sample-card" id="sample-${s.id}">
<div class="sample-head"><strong>Sample ${s.id}</strong>${statusBadge(s.status)}</div>
<div class="sample-fields"><div><label class="lf-label">Instruction</label><textarea class="lf-input" id="si-${s.id}">${escapeHtml(s.instruction)}</textarea></div>
<div><label class="lf-label">Output</label><textarea class="lf-input" id="so-${s.id}">${escapeHtml(s.output)}</textarea></div></div>
<details class="source-preview"><summary>Grounding source</summary>${escapeHtml(s.source_text)}</details>
<div class="creator-actions" style="margin-top:12px"><button class="tr-btn ghost" onclick="saveSample(${s.id},'approved')">Approve &amp; Save</button>
<button class="tr-btn danger" onclick="saveSample(${s.id},'rejected')">Reject</button></div></div>`).join('') : '<div class="tr-empty">No samples generated.</div>');
review.dataset.jobId = jobId; review.dataset.jobName = name;
}
async function saveSample(id, status) {
const body = {instruction:document.getElementById(`si-${id}`).value, output:document.getElementById(`so-${id}`).value, status};
const res = await fetch(`${_API}/training/dataset-generation/samples/${id}`, {method:'PATCH', credentials:'include', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
const data = await res.json().catch(()=>({}));
if (!res.ok) return toast(data.detail || 'Could not save sample', true);
toast(status === 'approved' ? 'Sample approved' : 'Sample rejected');
reviewGeneration(document.getElementById('dc-review').dataset.jobId, document.getElementById('dc-review').dataset.jobName);
}
async function setAllSamples(jobId, status) {
const cards = [...document.querySelectorAll('.sample-card')];
for (const card of cards) {
const id = Number(card.id.replace('sample-',''));
const body = {instruction:document.getElementById(`si-${id}`).value, output:document.getElementById(`so-${id}`).value, status};
const res = await fetch(`${_API}/training/dataset-generation/samples/${id}`, {method:'PATCH', credentials:'include', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
if (!res.ok) return toast('Could not update every sample', true);
}
toast('All samples approved');
reviewGeneration(jobId, document.getElementById('dc-review').dataset.jobName);
}
async function publishGeneration(jobId) {
const res = await fetch(`${_API}/training/dataset-generation/${jobId}/publish`, {method:'POST', credentials:'include'});
const data = await res.json().catch(()=>({}));
if (!res.ok) return toast(data.detail || 'Could not publish dataset', true);
toast(`Published ${data.filename} with ${data.row_count} rows`);
loadDatasets();
}
// ── Drag and drop ─────────────────────────────────────────────────────────────
const zone = document.getElementById('upload-zone');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag'); });
zone.addEventListener('dragleave', () => zone.classList.remove('drag'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('drag');
const f = e.dataTransfer.files[0];
if (f) uploadDataset(f);
});
// ── Dataset Manager ───────────────────────────────────────────────────────────
async function loadDatasets() {
const tbody = document.getElementById('ds-tbody');
let datasetsCache2 = [];
try {
const res = await fetch(`${_API}/training/datasets`,{credentials:'include'});
if(!res.ok) throw new Error();
const d=await res.json();
const rows = Array.isArray(d) ? d : (Array.isArray(d.datasets) ? d.datasets : []);
datasetsCache2 = rows.map((item, index) => ({
...item,
id: item.id ?? index,
orig_name: item.orig_name || item.name || item.filename || 'Untitled dataset',
row_count: Number(item.row_count ?? item.rows ?? 0),
size_bytes: Number(item.size_bytes ?? item.size ?? 0),
uploaded_at: item.uploaded_at || item.created_at || item.updated_at || null
}));
} catch(e){
datasetsCache2 = MOCK_DATASETS.map((item, index) => ({
...item,
id: item.id ?? index,
orig_name: item.orig_name || item.name || item.filename || 'Untitled dataset',
row_count: Number(item.row_count ?? item.rows ?? 0),
size_bytes: Number(item.size_bytes ?? item.size ?? 0),
uploaded_at: item.uploaded_at || item.created_at || item.updated_at || null
}));
}
datasetsCache = datasetsCache2;
// Update dataset selector in launch form
const sel = document.getElementById('lf-dataset');
const prev = sel.value;
sel.innerHTML = '<option value="">Select a dataset…</option>';
datasetsCache.forEach(d => {
const opt = document.createElement('option');
opt.value = d.id;
opt.textContent = `${d.orig_name} (${Number(d.row_count || 0).toLocaleString()} rows)`;
sel.appendChild(opt);
});
if (prev) sel.value = prev;
if (!datasetsCache.length) {
tbody.innerHTML = '<tr><td colspan="5" class="tr-empty">No datasets uploaded yet. Upload a JSONL or CSV file above.</td></tr>';
return;
}
tbody.innerHTML = datasetsCache.map(d => `
<tr>
<td><strong>${d.orig_name}</strong></td>
<td>${Number(d.row_count || 0).toLocaleString()}</td>
<td>${fmtBytes(Number(d.size_bytes || 0))}</td>
<td>${fmt(d.uploaded_at)}</td>
<td><button class="tr-btn danger" style="padding:5px 12px;font-size:12px;" onclick="deleteDataset(${d.id},'${d.orig_name.replace(/'/g,"\\'")}')">Delete</button></td>
</tr>
`).join('');
}
async function uploadDataset(file) {
if (!file) return;
const prog = document.getElementById('upload-progress');
prog.style.display = 'block';
prog.textContent = `Uploading ${file.name}…`;
const fd = new FormData();
fd.append('file', file);
try {
const res = await fetch(`${_API}/training/datasets`, {method:'POST', credentials:'include', body:fd});
const d = await res.json();
if (!res.ok) throw new Error(d.detail || 'Upload failed');
toast(`Uploaded ${file.name} - ${d.row_count.toLocaleString()} rows`);
loadDatasets();
} catch(e) {
toast(e.message, true);
} finally {
prog.style.display = 'none';
document.getElementById('file-input').value = '';
}
}
async function deleteDataset(id, name) {
if (!confirm(`Delete dataset "${name}"? This cannot be undone.`)) return;
const res = await fetch(`${_API}/training/datasets/${id}`, {method:'DELETE', credentials:'include'});
if (res.ok) {
toast('Dataset deleted');
loadDatasets();
} else {
const d = await res.json().catch(()=>({}));
toast(d.detail || 'Delete failed', true);
}
}
// ── Model selector ────────────────────────────────────────────────────────────
async function loadModels() {
const sel = document.getElementById('lf-model');
try {
const data = await fetch(`${_API}/models`, {credentials:'include'}).then(r=>r.json());
const models = data.models || data || [];
sel.innerHTML = '<option value="">Select base model…</option>';
(Array.isArray(models) ? models : []).forEach(m => {
const name = m.name || m;
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
sel.appendChild(opt);
});
if (!models.length) {
sel.innerHTML = '<option value="">No models available — pull one in Model Manager</option>';
}
} catch {
sel.innerHTML = '<option value="">Could not load models</option>';
}
}
// ── Launch job ────────────────────────────────────────────────────────────────
async function launchJob() {
const name = document.getElementById('lf-name').value.trim();
const base_model = document.getElementById('lf-model').value;
const dataset_id = parseInt(document.getElementById('lf-dataset').value);
const epochs = parseInt(document.getElementById('lf-epochs').value);
const lr = parseFloat(document.getElementById('lf-lr').value);
const batch_size = parseInt(document.getElementById('lf-batch').value);
const lora_r = parseInt(document.getElementById('lf-lora-r').value);
const lora_alpha = parseInt(document.getElementById('lf-lora-alpha').value);
const output_name = document.getElementById('lf-output').value.trim() || name.replace(/\s+/g,'_').toLowerCase();
if (!name) { toast('Please enter a job name', true); return; }
if (!base_model) { toast('Please select a base model', true); return; }
if (!dataset_id) { toast('Please select a dataset', true); return; }
const btn = document.getElementById('launch-btn');
btn.disabled = true;
btn.textContent = 'Launching…';
try {
const res = await fetch(`${_API}/training/jobs`, {
method:'POST', credentials:'include',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({name, base_model, dataset_id, epochs, lr, batch_size, lora_r, lora_alpha, output_name})
});
const d = await res.json();
if (!res.ok) throw new Error(d.detail || 'Launch failed');
toast(`Job "${name}" launched (ID: ${d.job_id})`);
loadJobs();
startAutoRefresh();
} catch(e) {
toast(e.message, true);
} finally {
btn.disabled = false;
btn.textContent = 'Launch Training';
}
}
// ── Job Monitor ───────────────────────────────────────────────────────────────
let openJobId = null;
async function loadJobs() {
const tbody = document.getElementById('jobs-tbody');
let jobs = [];
try { const res=await fetch(`${_API}/training/jobs`,{credentials:'include'}); if(!res.ok) throw new Error(); const d=await res.json(); jobs=d.jobs||[]; } catch(e){ jobs=MOCK_JOBS; }
if (!jobs.length) {
tbody.innerHTML = '<tr><td colspan="6" class="tr-empty">No training jobs yet. Configure and launch one above.</td></tr>';
return;
}
tbody.innerHTML = jobs.map(j => `
<tr class="job-row" onclick="toggleJobDetail(${j.id}, this)" style="cursor:pointer;">
<td><strong>${j.name}</strong></td>
<td>${j.base_model}</td>
<td>${j.dataset_name || '—'}</td>
<td>${statusBadge(j.status)}</td>
<td>${fmt(j.created_at)}</td>
<td>
${j.status === 'running'
? `<button class="tr-btn danger" style="padding:5px 12px;font-size:12px;" onclick="event.stopPropagation();cancelJob(${j.id},'${j.name.replace(/'/g,"\\'")}')">Cancel</button>`
: ''}
</td>
</tr>
<tr id="detail-${j.id}" style="display:none;">
<td colspan="6" style="padding:0;">
<div class="job-detail" id="detail-body-${j.id}">
<div class="job-meta">
<span><strong>Epochs:</strong> ${j.config?.epochs ?? '—'}</span>
<span><strong>LR:</strong> ${j.config?.lr ?? '—'}</span>
<span><strong>Batch:</strong> ${j.config?.batch_size ?? '—'}</span>
<span><strong>LoRA r:</strong> ${j.config?.lora_r ?? '—'} / alpha: ${j.config?.lora_alpha ?? '—'}</span>
<span><strong>Started:</strong> ${fmt(j.started_at)}</span>
<span><strong>Finished:</strong> ${fmt(j.finished_at)}</span>
<span><strong>Output:</strong> ${j.output_path || '—'}</span>
</div>
<div class="chart-wrap" id="chart-wrap-${j.id}">
<canvas id="chart-${j.id}" height="160"></canvas>
</div>
<span class="log-toggle" onclick="toggleLog(${j.id})">▼ Show Logs</span>
<div class="log-wrap" id="log-${j.id}">Loading logs…</div>
</div>
</td>
</tr>
`).join('');
// Restore open job
if (openJobId) {
const row = document.querySelector(`.job-row[onclick*="toggleJobDetail(${openJobId}"]`);
if (row) loadJobDetail(openJobId);
}
// Manage auto-refresh
const hasRunning = jobs.some(j => j.status === 'running' || j.status === 'pending');
if (hasRunning) startAutoRefresh(); else stopAutoRefresh();
}
function toggleJobDetail(id, row) {
const detailRow = document.getElementById(`detail-${id}`);
const isOpen = detailRow.style.display !== 'none';
// Close all
document.querySelectorAll('[id^="detail-"]').forEach(r => r.style.display = 'none');
if (!isOpen) {
detailRow.style.display = '';
openJobId = id;
loadJobDetail(id);
} else {
openJobId = null;
}
}
async function loadJobDetail(id) {
const data = await fetch(`${_API}/training/jobs/${id}`, {credentials:'include'}).then(r=>r.json()).catch(()=>null);
if (!data) return;
// Render log
const logEl = document.getElementById(`log-${id}`);
if (logEl) {
const msgs = (data.log_entries || []).map(e => {
if (e.type === 'loss') return `[step ${e.step}] loss=${e.loss} epoch=${e.epoch} lr=${e.lr}`;
if (e.type === 'error') return `ERROR: ${e.msg}${e.traceback ? '\n' + e.traceback : ''}`;
return `[${e.type}] ${e.msg || JSON.stringify(e)}`;
}).join('\n');
logEl.textContent = msgs || '(no log entries yet)';
logEl.scrollTop = logEl.scrollHeight;
}
// Render loss chart
renderLossChart(id, data.log_entries || []);
}
function renderLossChart(id, entries) {
const lossEntries = entries.filter(e => e.type === 'loss');
const wrap = document.getElementById(`chart-wrap-${id}`);
const canvas = document.getElementById(`chart-${id}`);
if (!wrap || !canvas || !lossEntries.length) return;
wrap.style.display = 'block';
const labels = lossEntries.map(e => `Step ${e.step}`);
const values = lossEntries.map(e => e.loss);
// Destroy previous chart instance if any
if (lossCharts[id]) { lossCharts[id].destroy(); }
// Minimal inline chart (no external deps)
const ctx = canvas.getContext('2d');
const W = canvas.parentElement.clientWidth || 600;
canvas.width = W;
canvas.height = 160;
const PAD = {top:20, right:20, bottom:36, left:56};
const cw = W - PAD.left - PAD.right;
const ch = 160 - PAD.top - PAD.bottom;
const minV = Math.min(...values);
const maxV = Math.max(...values);
const rangeV = maxV - minV || 1;
function xp(i) { return PAD.left + (i / (values.length - 1 || 1)) * cw; }
function yp(v) { return PAD.top + (1 - (v - minV) / rangeV) * ch; }
ctx.clearRect(0, 0, W, 160);
// Background
ctx.fillStyle = '#F8FAFC';
ctx.fillRect(0, 0, W, 160);
ctx.strokeStyle = '#E2E8F0';
ctx.lineWidth = 1;
ctx.strokeRect(PAD.left, PAD.top, cw, ch);
// Grid lines (5)
ctx.setLineDash([3,3]);
ctx.strokeStyle = '#E2E8F0';
for (let i=0; i<=4; i++) {
const y = PAD.top + (ch / 4) * i;
ctx.beginPath(); ctx.moveTo(PAD.left, y); ctx.lineTo(PAD.left+cw, y); ctx.stroke();
const v = (maxV - (rangeV/4)*i).toFixed(4);
ctx.fillStyle='#94A3B8'; ctx.font='10px sans-serif'; ctx.textAlign='right';
ctx.fillText(v, PAD.left-6, y+3);
}
ctx.setLineDash([]);
// X labels (max 6)
ctx.fillStyle='#94A3B8'; ctx.font='10px sans-serif'; ctx.textAlign='center';
const step = Math.max(1, Math.floor(values.length / 6));
for (let i=0; i<values.length; i+=step) {
ctx.fillText(labels[i], xp(i), PAD.top+ch+18);
}
// Loss line
if (values.length > 1) {
const grad = ctx.createLinearGradient(PAD.left, PAD.top, PAD.left, PAD.top+ch);
grad.addColorStop(0, 'rgba(124,58,237,.18)');
grad.addColorStop(1, 'rgba(124,58,237,.01)');
ctx.beginPath();
ctx.moveTo(xp(0), yp(values[0]));
for (let i=1; i<values.length; i++) ctx.lineTo(xp(i), yp(values[i]));
ctx.lineTo(xp(values.length-1), PAD.top+ch);
ctx.lineTo(xp(0), PAD.top+ch);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
ctx.beginPath();
ctx.moveTo(xp(0), yp(values[0]));
for (let i=1; i<values.length; i++) ctx.lineTo(xp(i), yp(values[i]));
ctx.strokeStyle = '#7C3AED'; ctx.lineWidth = 2;
ctx.stroke();
}
// Title
ctx.fillStyle='#1E293B'; ctx.font='bold 11px sans-serif'; ctx.textAlign='left';
ctx.fillText('Training Loss', PAD.left, PAD.top-6);
lossCharts[id] = { destroy: () => {} }; // simple sentinel
}
function toggleLog(id) {
const logEl = document.getElementById(`log-${id}`);
const isVisible = logEl.style.display === 'block';
logEl.style.display = isVisible ? 'none' : 'block';
const tog = logEl.previousElementSibling;
if (tog) tog.textContent = isVisible ? '▼ Show Logs' : '▲ Hide Logs';
}
async function cancelJob(id, name) {
if (!confirm(`Cancel job "${name}"?`)) return;
const res = await fetch(`${_API}/training/jobs/${id}`, {method:'DELETE', credentials:'include'});
if (res.ok) {
toast('Job cancelled');
loadJobs();
} else {
const d = await res.json().catch(()=>({}));
toast(d.detail || 'Cancel failed', true);
}
}
// ── Auto-refresh ──────────────────────────────────────────────────────────────
function startAutoRefresh() {
if (jobRefreshTimer) return;
jobRefreshTimer = setInterval(() => {
loadJobs();
if (openJobId) loadJobDetail(openJobId);
}, 10000);
}
function stopAutoRefresh() {
if (jobRefreshTimer) { clearInterval(jobRefreshTimer); jobRefreshTimer = null; }
}
// ── Init ──────────────────────────────────────────────────────────────────────
loadDatasets();
loadModels();
loadJobs();
</script>
<script src="auth.js?v=20260721-consolidated"></script>
<script src="branding.js?v=20260721-consolidated"></script>
</body>
</html>