From befedf08523f2001408ba6de43fb3c7cde5beda1 Mon Sep 17 00:00:00 2001 From: Keysat Date: Thu, 14 May 2026 09:30:51 -0500 Subject: [PATCH] v0.8.1:2 - card button flips to blue "Download" when weights are absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a model's weights aren't on disk, the green "Switch to this" button on the card is replaced by a blue "Download" button that calls /api/download directly with the model's repo and the right mode (solo -> spark1, cluster -> both). One-click re-install of a previously-deleted model, no more pasting the repo into the manual download form. Also adds a confirmation dialog showing the model name, size, and target Spark(s) before kicking off the download — and disables the button when another download is already in flight. Co-Authored-By: Claude Opus 4.7 (1M context) --- image/app/static/app.js | 52 ++++++++++++++++++++++++++++-- image/app/static/style.css | 5 ++- package/startos/versions/v0_1_0.ts | 4 +-- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/image/app/static/app.js b/image/app/static/app.js index 97bfd17..d9f816c 100644 --- a/image/app/static/app.js +++ b/image/app/static/app.js @@ -82,6 +82,19 @@ function renderCards() { : 'Delete weights from disk'; trashBtn = ``; } + // Primary card action: "Switch to this" (green) when on disk; "Download" (blue) when not. + // Before disk-status loads we render the swap button as a sensible default. + const isOnDisk = !state.disk_status_loaded || (disk && disk.on_disk); + const dlInFlight = !!(typeof dlState !== 'undefined' && dlState && dlState.job_id); + let primaryBtn = ''; + if (isActive) { + primaryBtn = ``; + } else if (isOnDisk) { + primaryBtn = ``; + } else { + const tip = dlInFlight ? 'A download is already in progress' : 'Download weights to the Spark(s)'; + primaryBtn = ``; + } card.innerHTML = `
${escapeHtml(m.display_name)}
@@ -97,9 +110,7 @@ function renderCards() {
- + ${primaryBtn} ${trashBtn} @@ -111,6 +122,9 @@ function renderCards() { for (const btn of root.querySelectorAll('[data-swap-key]')) { btn.addEventListener('click', () => triggerSwap(btn.dataset.swapKey)); } + for (const btn of root.querySelectorAll('[data-download-key]')) { + btn.addEventListener('click', () => triggerDownloadForKey(btn.dataset.downloadKey)); + } for (const btn of root.querySelectorAll('[data-adv-key]')) { btn.addEventListener('click', () => openAdvanced(btn.dataset.advKey)); } @@ -857,6 +871,38 @@ async function triggerSwap(modelKey) { } } +async function triggerDownloadForKey(modelKey) { + const m = state.models[modelKey]; + if (!m) return; + if (dlState.job_id) { + alert('A download is already in progress; wait for it to finish.'); + return; + } + // Pick the download target from the model's mode: + // solo -> spark1 only + // cluster -> both Sparks (fetch on Spark 1, rsync to Spark 2 in parallel) + const dlMode = m.mode === 'cluster' ? 'cluster' : 'spark1'; + const sizeNote = m.size_gb ? ` (~${m.size_gb} GB)` : ''; + const target = m.mode === 'cluster' ? 'both Sparks' : 'Spark 1'; + if (!confirm(`Download "${m.display_name}"${sizeNote} to ${target}? Large models can take a while; you can watch progress in the download panel.`)) { + return; + } + dlState.last_repo = m.repo; + dlState.last_mode = dlMode; + try { + const r = await fetchJSON('/api/download', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ repo: m.repo, mode: dlMode }), + }); + // Open the download panel + attach to progress stream + openDownloadForm(); + attachToDownload(r.job_id); + } catch (e) { + alert('Failed to start download: ' + e.message); + } +} + async function attachToSwap(jobId, needsBackfill) { if (state.swap_eventsource) { state.swap_eventsource.close(); diff --git a/image/app/static/style.css b/image/app/static/style.css index d533874..67e1c96 100644 --- a/image/app/static/style.css +++ b/image/app/static/style.css @@ -711,9 +711,12 @@ main { .btn:disabled { opacity: 0.45; cursor: not-allowed; } .btn.danger { color: var(--error); border-color: rgba(239, 68, 68, 0.3); } .btn.danger:hover:not(:disabled) { background: rgba(239, 68, 68, 0.08); border-color: var(--error); } +.btn.info { background: var(--info); color: #0a1e3d; border-color: var(--info); } +.btn.info:hover:not(:disabled) { background: #82baff; border-color: #82baff; } .card.active .btn { background: rgba(74, 222, 128, 0.12); color: var(--accent); border-color: rgba(74, 222, 128, 0.4); } .card-actions { display: flex; gap: 6px; } -.card-actions .btn.primary { flex: 1; } +.card-actions .btn.primary, +.card-actions .btn.info { flex: 1; } .card .adv-btn, .card .test-btn { padding: 8px 12px; font-size: 12px; } .card .custom-pill { color: var(--info); border-color: rgba(96, 165, 250, 0.4); } diff --git a/package/startos/versions/v0_1_0.ts b/package/startos/versions/v0_1_0.ts index 0306cf0..707b872 100644 --- a/package/startos/versions/v0_1_0.ts +++ b/package/startos/versions/v0_1_0.ts @@ -1,10 +1,10 @@ import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' export const v0_1_0 = VersionInfo.of({ - version: '0.8.1:1', + version: '0.8.1:2', releaseNotes: { en_US: - 'v0.8.1:1 — fix: the disk-status probe shipped in 0.8.1:0 was wrapping $HOME in single quotes via shlex.quote, which prevented shell variable expansion. Result: every model reported as "not downloaded" even when weights were on disk, so no trash icons appeared. Rewritten to embed $HOME in double-quoted shell context and validate the cache dirname against a whitelist. The trash icons now show up correctly. v0.8.1:0 features: per-card disk-presence pills (on disk · GB / not downloaded), trash icon to rm -rf the HF cache directory via SSH with a confirmation dialog. Safety rails unchanged: refuses to delete the currently-loaded model or during an in-flight swap/download; catalog entry persists for re-download.', + 'v0.8.1:2 — the primary card button now adapts to whether the model is on disk. If weights are present: green "Switch to this" (unchanged). If weights are NOT on disk: blue "Download" instead, which calls /api/download directly with the model\'s repo and the right mode (solo→Spark 1, cluster→both Sparks) — no more pasting the repo into the manual download form to re-fetch a deleted model. Re-installing a previously-deleted model is now one click + a confirmation. Builds on the disk-status pills + trash icons from 0.8.1.', }, migrations: { up: async ({ effects }) => {},