v0.11.0:0 - Speech model patches panel (lifecycle for v0.10.0 overlays)
Folds the image/parakeet_patches/apply.sh script into a one-click
dashboard action and adds drift detection so you can see at a glance
whether the parakeet-asr container has the latest Sortformer overlays
that spark-control ships.
Backend:
* image/app/speech_models.py - SpeechModelsManager: reads /health from
Parakeet, sha256s the local overlay files inside spark-control's
Docker image (/app/parakeet_patches), sha256s the same files inside
the parakeet-asr container via `docker exec ... sha256sum`, surfaces
in_sync / drift / missing status per file.
* GET /api/speech-models - status payload
* POST /api/speech-models/reapply - copies overlays into container,
verifies python syntax, restarts,
polls /health for ~120s, returns
step-by-step result
* POST /api/speech-models/restart - plain `docker restart parakeet-asr`
Dockerfile: now COPY parakeet_patches into the image at /app/parakeet_patches
so the runtime can read them. Future spark-control releases auto-carry
newer overlay versions; the panel surfaces drift after upgrade.
Frontend: new "Speech model patches" section on the dashboard with
* Status pill (in sync / drift / missing)
* Per-file SHA comparison (local vs container)
* Loaded-models pills (ASR + diarizer)
* Reapply + Restart buttons (both with confirmation modals)
* Live progress display during reapply with per-step ✓/✗
Verified post-install against the running cluster:
GET /api/speech-models shows both files in_sync (SHAs match) and both
models loaded ready on Spark 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -532,6 +532,138 @@ async function onDeepHealthRun(name, btn) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== speech-model patches (v0.11) =====================
|
||||
|
||||
async function renderSpeechModels() {
|
||||
const panel = el('#speech-models-panel');
|
||||
const card = el('#speech-models-card');
|
||||
if (!panel || !card) return;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await fetchJSON('/api/speech-models');
|
||||
} catch (e) {
|
||||
// If parakeet host isn't even configured, hide the section entirely
|
||||
panel.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
if (!data || !data.patches) { panel.classList.add('hidden'); return; }
|
||||
panel.classList.remove('hidden');
|
||||
|
||||
const patches = data.patches || {};
|
||||
const health = data.container_health || {};
|
||||
const status = patches.status || 'unknown';
|
||||
|
||||
let statusPill;
|
||||
if (status === 'in_sync') {
|
||||
statusPill = `<span class="sm-pill ok">patches in sync</span>`;
|
||||
} else if (status === 'drift') {
|
||||
statusPill = `<span class="sm-pill warn">spark-control has newer patches</span>`;
|
||||
} else if (status === 'missing') {
|
||||
statusPill = `<span class="sm-pill bad">patches missing in container</span>`;
|
||||
} else {
|
||||
statusPill = `<span class="sm-pill warn">unknown</span>`;
|
||||
}
|
||||
|
||||
const asrLoaded = !!health.asr_loaded;
|
||||
const diarLoaded = !!health.diarizer_loaded;
|
||||
const asrModel = escapeHtml(health.model || '—');
|
||||
const diarModel = escapeHtml(health.diarizer_model || '—');
|
||||
|
||||
const fileRows = (patches.files || []).map((f) => {
|
||||
const sync = f.in_sync
|
||||
? '<span class="sm-file-ok">✓ in sync</span>'
|
||||
: f.remote_sha == null
|
||||
? '<span class="sm-file-bad">✗ missing</span>'
|
||||
: '<span class="sm-file-warn">⚠ drift</span>';
|
||||
const local = f.local_sha ? `<code>${escapeHtml(f.local_sha)}</code>` : '<span class="muted">—</span>';
|
||||
const remote = f.remote_sha ? `<code>${escapeHtml(f.remote_sha)}</code>` : '<span class="muted">—</span>';
|
||||
return `
|
||||
<div class="sm-file-row">
|
||||
<span class="sm-file-name"><code>${escapeHtml(f.name)}</code></span>
|
||||
<span class="sm-file-sync">${sync}</span>
|
||||
<span class="sm-file-sha muted small">local ${local} → remote ${remote}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const lastReapply = patches.last_reapply_at ? new Date(patches.last_reapply_at).toLocaleString() : 'never (since spark-control boot)';
|
||||
const lastRestart = patches.last_restart_at ? new Date(patches.last_restart_at).toLocaleString() : 'never (since spark-control boot)';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="sm-header">
|
||||
<div class="sm-title">parakeet-asr container</div>
|
||||
${statusPill}
|
||||
</div>
|
||||
<div class="sm-models">
|
||||
<div class="sm-model-row">
|
||||
<span class="sm-model-kind">Parakeet ASR</span>
|
||||
<span class="sm-model-name">${asrModel}</span>
|
||||
<span class="sm-model-loaded">${asrLoaded ? '<span class="sm-pill ok">loaded</span>' : '<span class="sm-pill bad">not loaded</span>'}</span>
|
||||
</div>
|
||||
<div class="sm-model-row">
|
||||
<span class="sm-model-kind">Sortformer diarizer</span>
|
||||
<span class="sm-model-name">${diarModel}</span>
|
||||
<span class="sm-model-loaded">${diarLoaded ? '<span class="sm-pill ok">loaded</span>' : '<span class="sm-pill bad">not loaded</span>'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm-files">${fileRows}</div>
|
||||
<div class="sm-meta muted small">
|
||||
Last reapply: ${escapeHtml(lastReapply)} · Last manual restart: ${escapeHtml(lastRestart)}
|
||||
</div>
|
||||
<div class="sm-actions">
|
||||
<button class="btn primary" id="sm-reapply">Reapply patches</button>
|
||||
<button class="btn" id="sm-restart">Restart container</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
el('#sm-reapply').addEventListener('click', onSpeechModelsReapply);
|
||||
el('#sm-restart').addEventListener('click', onSpeechModelsRestart);
|
||||
}
|
||||
|
||||
async function onSpeechModelsReapply() {
|
||||
if (!confirm('Reapply Sortformer patches to the parakeet-asr container? The container will restart and both ASR + diarizer will be unavailable for ~60–120 seconds.')) return;
|
||||
const dlg = el('#speech-models-progress-dialog');
|
||||
const steps = el('#sm-prog-steps');
|
||||
const closeBtn = el('#sm-prog-close');
|
||||
steps.innerHTML = '<div class="muted small">Starting…</div>';
|
||||
closeBtn.disabled = true;
|
||||
closeBtn.onclick = () => dlg.close();
|
||||
dlg.showModal();
|
||||
try {
|
||||
const r = await fetchJSON('/api/speech-models/reapply', { method: 'POST' });
|
||||
steps.innerHTML = (r.steps || []).map((s) => {
|
||||
const mark = s.ok ? '<span class="sm-file-ok">✓</span>' : '<span class="sm-file-bad">✗</span>';
|
||||
const extra = s.error ? `<div class="muted small">${escapeHtml(s.error)}</div>` : '';
|
||||
return `<div class="sm-prog-step">${mark} <strong>${escapeHtml(s.step)}</strong>${s.name ? ` (${escapeHtml(s.name)})` : ''}${extra}</div>`;
|
||||
}).join('') + `<div class="sm-prog-done sm-file-ok">Done — both models reloaded.</div>`;
|
||||
} catch (e) {
|
||||
let parsed = null;
|
||||
try { parsed = JSON.parse(e.message.split(':').slice(2).join(':').trim()); } catch {}
|
||||
const stepHtml = parsed && parsed.result && parsed.result.steps
|
||||
? parsed.result.steps.map((s) => {
|
||||
const mark = s.ok ? '<span class="sm-file-ok">✓</span>' : '<span class="sm-file-bad">✗</span>';
|
||||
return `<div class="sm-prog-step">${mark} <strong>${escapeHtml(s.step)}</strong>${s.name ? ` (${escapeHtml(s.name)})` : ''}${s.error ? `<div class="muted small">${escapeHtml(s.error)}</div>` : ''}</div>`;
|
||||
}).join('')
|
||||
: `<div class="sm-file-bad">${escapeHtml(e.message)}</div>`;
|
||||
steps.innerHTML = stepHtml + `<div class="sm-prog-done sm-file-bad">Failed.</div>`;
|
||||
} finally {
|
||||
closeBtn.disabled = false;
|
||||
try { await renderSpeechModels(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function onSpeechModelsRestart() {
|
||||
if (!confirm('Restart parakeet-asr container? STT + diarization will be unavailable for ~30 seconds.')) return;
|
||||
try {
|
||||
await fetchJSON('/api/speech-models/restart', { method: 'POST' });
|
||||
} catch (e) {
|
||||
alert('Restart failed: ' + e.message);
|
||||
} finally {
|
||||
try { await renderSpeechModels(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function onServiceAction(key) {
|
||||
if (state.service_action_in_flight) return;
|
||||
const [name, action] = key.split(':');
|
||||
@@ -1675,10 +1807,13 @@ async function init() {
|
||||
pollUpdates();
|
||||
// Disk-status probe runs after first paint — slow over SSH and not blocking.
|
||||
loadDiskStatus();
|
||||
// Speech-model patches panel — slow over SSH, runs after first paint.
|
||||
renderSpeechModels();
|
||||
setInterval(pollStatus, 5000);
|
||||
setInterval(pollHardware, 8000); // every 8s
|
||||
setInterval(pollUpdates, 300000); // every 5 min
|
||||
setInterval(loadDiskStatus, 60000); // every 60s — disk state changes rarely
|
||||
setInterval(renderSpeechModels, 120000); // every 2 min — patches change rarely
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
@@ -152,6 +152,30 @@
|
||||
</dialog>
|
||||
</section>
|
||||
|
||||
<section id="speech-models-panel" class="speech-models hidden">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Speech model patches</h2>
|
||||
</div>
|
||||
<p class="muted small sm-blurb">
|
||||
Spark Control adds Sortformer speaker diarization to the third-party Parakeet ASR
|
||||
container via two Python overlays (<code>diarizer.py</code> + a patched <code>main.py</code>).
|
||||
Overlays survive container restart but not a fresh redeploy — if the parakeet container is
|
||||
ever rebuilt, click <strong>Reapply patches</strong> below to restore them.
|
||||
</p>
|
||||
<div id="speech-models-card" class="speech-models-card"></div>
|
||||
|
||||
<dialog id="speech-models-progress-dialog" class="modal">
|
||||
<form method="dialog" class="modal-form">
|
||||
<h3>Reapplying speech-model patches…</h3>
|
||||
<p class="muted small">Copying overlays into the parakeet container, verifying syntax, restarting, waiting for both models to load. Takes ~60–120 s.</p>
|
||||
<div id="sm-prog-steps" class="sm-prog-steps"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="sm-prog-close" class="btn" disabled>Close</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</section>
|
||||
|
||||
<section id="models-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">LLM swap</h2>
|
||||
|
||||
@@ -764,3 +764,97 @@ main {
|
||||
main { padding: 16px 14px 80px; }
|
||||
.cards { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ===== Speech model patches (v0.11) ===== */
|
||||
.speech-models { margin-top: 28px; }
|
||||
.sm-blurb { max-width: 880px; margin-bottom: 14px; }
|
||||
.sm-blurb code {
|
||||
background: var(--surface-2);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.speech-models-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.sm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.sm-title {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.sm-pill {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.sm-pill.ok { color: var(--accent); border-color: rgba(74, 222, 128, 0.4); }
|
||||
.sm-pill.warn { color: var(--warn); border-color: rgba(245, 158, 11, 0.4); }
|
||||
.sm-pill.bad { color: var(--error); border-color: rgba(239, 68, 68, 0.4); }
|
||||
|
||||
.sm-models { display: flex; flex-direction: column; gap: 6px; }
|
||||
.sm-model-row {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.sm-model-row:first-child { border-top: none; }
|
||||
.sm-model-kind { color: var(--muted); font-size: 13px; }
|
||||
.sm-model-name { font-family: ui-monospace, monospace; font-size: 12px; word-break: break-all; }
|
||||
|
||||
.sm-files { display: flex; flex-direction: column; gap: 4px; }
|
||||
.sm-file-row {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 100px 1fr;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.sm-file-name code {
|
||||
background: var(--surface-2);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.sm-file-ok { color: var(--accent); }
|
||||
.sm-file-warn { color: var(--warn); }
|
||||
.sm-file-bad { color: var(--error); }
|
||||
.sm-file-sha code {
|
||||
background: var(--surface-2);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sm-meta { margin-top: 4px; }
|
||||
.sm-actions { display: flex; gap: 10px; }
|
||||
|
||||
.sm-prog-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 12px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sm-prog-step {
|
||||
padding: 6px 10px;
|
||||
background: var(--surface-2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.sm-prog-done {
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user