diff --git a/ainode/web/static/js/app.js b/ainode/web/static/js/app.js index cb11bd1..2f01e7d 100644 --- a/ainode/web/static/js/app.js +++ b/ainode/web/static/js/app.js @@ -343,9 +343,6 @@ const AINode = { this.renderDownloads(); } break; - case 'models': - this.renderModels(); - break; case 'training': this.renderTraining(); break; @@ -2042,9 +2039,17 @@ const AINode = { }).join('') + ''; - // Wire cancel buttons (event delegation) - container.querySelectorAll('.queue-item-cancel').forEach(function (btn) { - btn.addEventListener('click', function (e) { + // Wire cancel buttons via TRUE event delegation on the stable container. + // Rows redrawn in place (renderQueueItemInPlace → existing.outerHTML) mint + // brand-new button nodes; a per-button listener would not survive that + // (e.g. cancelDownload's revert() redraws an enabled, listener-less button + // that would then be a silent no-op). Bind once to the container — which + // innerHTML/outerHTML on its children never replaces — and match on click. + if (!container._cancelDelegated) { + container._cancelDelegated = true; + container.addEventListener('click', function (e) { + var btn = e.target.closest && e.target.closest('.queue-item-cancel'); + if (!btn || !container.contains(btn)) return; e.stopPropagation(); var jobId = btn.getAttribute('data-job-id'); if (!jobId) return; @@ -2052,17 +2057,35 @@ const AINode = { btn.textContent = '…'; self.cancelDownload(jobId); }); - }); + } }, cancelDownload(jobId) { var self = this; + // Revert any rows for this job back to 'downloading' so the poll resumes + // and the cancel button (currently disabled/…) is redrawn fresh. + var revert = function () { + Object.keys(self.state.activeDownloads || {}).forEach(function (repo) { + var dl = self.state.activeDownloads[repo]; + if (dl.jobId === jobId) { + dl.status = 'downloading'; + self.renderQueueItemInPlace(repo); + } + }); + }; fetch('/api/models/download-cancel', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ job_id: jobId }), }) - .then(function (r) { return r.json(); }) + .then(function (r) { + return r.json().catch(function () { return {}; }).then(function (data) { + if (!r.ok) { + throw new Error((data.error && data.error.message) || data.error || ('HTTP ' + r.status)); + } + return data; + }); + }) .then(function (data) { if (data.status === 'cancelling') { // Mark the local state so the UI updates immediately @@ -2073,9 +2096,15 @@ const AINode = { self.renderQueueItemInPlace(repo); } }); + } else { + // Unexpected response shape — don't leave the row stuck. + revert(); } }) - .catch(function () { /* server will clean up */ }); + .catch(function (err) { + self.toast('Cancel failed: ' + err.message, 'error'); + revert(); + }); }, renderQueueItem(dl) { @@ -2864,30 +2893,10 @@ const AINode = { }); }, - // ======================================================================== - // MODELS VIEW (secondary, accessible from downloads or direct) - // ======================================================================== - - renderModels() { - // Reuse downloads rendering for the models view - this.renderDownloads(); - }, - // ======================================================================== // TRAINING VIEW (overview / autodata / datasets / runs / templates) // ======================================================================== - trainingModels: [ - { id: 'meta-llama/Llama-3.2-3B-Instruct', name: 'Llama 3.2 3B Instruct', size: '~6 GB' }, - { id: 'meta-llama/Llama-3.1-8B-Instruct', name: 'Llama 3.1 8B Instruct', size: '~16 GB' }, - { id: 'meta-llama/Llama-3.1-70B-Instruct-AWQ', name: 'Llama 3.1 70B AWQ', size: '~38 GB' }, - { id: 'Qwen/Qwen2.5-72B-Instruct', name: 'Qwen 2.5 72B Instruct', size: '~145 GB' }, - { id: 'deepseek-ai/DeepSeek-R1-Distill-Qwen-7B', name: 'DeepSeek R1 7B', size: '~14 GB' }, - { id: 'mistralai/Mistral-7B-Instruct-v0.3', name: 'Mistral 7B v0.3', size: '~14 GB' }, - { id: 'microsoft/Phi-3-mini-4k-instruct', name: 'Phi-3 Mini 4K', size: '~7.5 GB' }, - { id: 'meta-llama/CodeLlama-34b-Instruct-hf', name: 'CodeLlama 34B', size: '~63 GB' }, - ], - // ---- Sidebar ------------------------------------------------------------ async renderTrainingSidebar() { var mount = document.getElementById('left-panel-training'); @@ -3555,12 +3564,15 @@ const AINode = { : (((await this.fetchJSON('/api/datasets')) || {}).datasets || []); this.state.datasets = dsets; - // Try to get downloaded models from /api/models; fall back to built-in list + // Only offer models that are actually on disk — a run against a + // not-downloaded model would fail. When none are downloaded we leave the + // list empty and show an empty-state hint (the free-text HF-id input in + // step 1 still works). The backend only ever sets `downloaded`. var modelsResp = await this.fetchJSON('/api/models'); var modelList = []; if (modelsResp && Array.isArray(modelsResp.models)) { modelList = modelsResp.models - .filter(function (m) { return m.downloaded || m.is_downloaded || m.status === 'downloaded'; }) + .filter(function (m) { return m.downloaded; }) // base_model = the canonical HF repo id (cards know it), NOT the on-disk // slug — the containerized runner hands base_model to // AutoTokenizer.from_pretrained, which rejects a '--' slug (HFValidationError). @@ -3569,7 +3581,6 @@ const AINode = { return { id: repo, name: m.name || repo, size: m.size || m.size_gb || '' }; }); } - if (!modelList.length) modelList = this.trainingModels.slice(); var initial = Object.assign({ base_model: modelList[0] ? modelList[0].id : '', @@ -3690,16 +3701,23 @@ const AINode = { _renderWizardStep1() { var s = this.state.wizardState; - var opts = (s.models || []).map(function (m) { - var active = m.id === s.base_model ? ' active' : ''; - return '
Downloaded models are shown. Your fine-tune will build on top of this one.
' + - '