feat: hot-reload voice models without server restart

Voice enabled/STT model are now DB-backed (admin settings), not env
vars. Added reload_stt_model()/reload_tts_model() that clear singletons
under lock and re-trigger loading. POST /api/admin/voice/reload triggers
both in background tasks. Settings UI polls /api/voice/status every 2.5s
until models are ready, with spinner feedback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 20:52:07 -04:00
parent eaf70500b8
commit f146485df3
5 changed files with 95 additions and 5 deletions
+65 -4
View File
@@ -450,6 +450,7 @@ const adminVoiceEnabled = ref(false);
const adminVoiceSttModel = ref("base.en");
const savingAdminVoice = ref(false);
const adminVoiceSaved = ref(false);
const voiceLoadingModels = ref(false);
async function saveAdminVoice() {
savingAdminVoice.value = true;
@@ -461,13 +462,39 @@ async function saveAdminVoice() {
});
adminVoiceSaved.value = true;
setTimeout(() => { adminVoiceSaved.value = false; }, 2000);
// Refresh the user-facing voice status after toggling
voiceTabLoaded.value = false;
if (adminVoiceEnabled.value) {
await reloadVoiceModels();
}
} finally {
savingAdminVoice.value = false;
}
}
async function reloadVoiceModels() {
voiceLoadingModels.value = true;
try {
await apiPost("/api/admin/voice/reload", {});
// Poll /api/voice/status until both STT and TTS are ready (max 3 min)
const deadline = Date.now() + 3 * 60 * 1000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2500));
try {
const status = await apiGet<{ enabled: boolean; stt: boolean; tts: boolean }>("/api/voice/status");
if (status.stt && status.tts) {
voiceStatus.value = status;
voiceTabLoaded.value = true;
break;
}
} catch { /* keep polling */ }
}
} catch {
toastStore.show("Failed to trigger model reload", "error");
} finally {
voiceLoadingModels.value = false;
}
}
// Search test (SearXNG)
const searxngConfigured = ref(false);
const searxngUrl = ref("");
@@ -2200,10 +2227,22 @@ FABLE_API_KEY=&lt;your-api-key&gt;</pre>
<p class="field-hint">Larger models are more accurate but use more RAM and take longer to load.</p>
</div>
<div class="actions" style="margin-top:0.85rem;">
<button class="btn-save" @click="saveAdminVoice" :disabled="savingAdminVoice">
{{ adminVoiceSaved ? 'Saved ' : savingAdminVoice ? 'Saving' : 'Save' }}
<button class="btn-save" @click="saveAdminVoice" :disabled="savingAdminVoice || voiceLoadingModels">
{{ savingAdminVoice ? 'Saving' : adminVoiceSaved ? 'Saved ' : 'Save' }}
</button>
<span v-if="adminVoiceSaved" class="field-hint">Restart the server to apply model changes.</span>
<button
v-if="!voiceLoadingModels && adminVoiceEnabled"
class="btn-secondary"
@click="reloadVoiceModels"
:disabled="voiceLoadingModels"
title="Reload models without restarting the server"
>Reload models</button>
<span v-if="voiceLoadingModels" class="field-hint">
Loading models… this may take a minute.
<span class="voice-admin-spinner">
<span></span><span></span><span></span>
</span>
</span>
</div>
</section>
@@ -3793,4 +3832,26 @@ FABLE_API_KEY=&lt;your-api-key&gt;</pre>
.form-actions {
margin-top: 1rem;
}
.voice-admin-spinner {
display: inline-flex;
gap: 3px;
align-items: center;
margin-left: 0.4rem;
vertical-align: middle;
}
.voice-admin-spinner span {
display: inline-block;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--color-text-muted);
animation: va-dot-bounce 1.2s ease-in-out infinite;
}
.voice-admin-spinner span:nth-child(2) { animation-delay: 0.2s; }
.voice-admin-spinner span:nth-child(3) { animation-delay: 0.4s; }
@keyframes va-dot-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
</style>