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 }) => {},