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:
+1
-1
@@ -15,7 +15,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}"
|
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}"
|
||||||
OLLAMA_URL: "http://ollama:11434"
|
OLLAMA_URL: "http://ollama:11434"
|
||||||
OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}"
|
OLLAMA_MODEL: "${OLLAMA_MODEL:-qwen3:8B}"
|
||||||
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
|
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
|
||||||
# Uncomment and set to enable web research and image search via SearXNG:
|
# Uncomment and set to enable web research and image search via SearXNG:
|
||||||
# SEARXNG_URL: "http://searxng:8080"
|
# SEARXNG_URL: "http://searxng:8080"
|
||||||
|
|||||||
@@ -450,6 +450,7 @@ const adminVoiceEnabled = ref(false);
|
|||||||
const adminVoiceSttModel = ref("base.en");
|
const adminVoiceSttModel = ref("base.en");
|
||||||
const savingAdminVoice = ref(false);
|
const savingAdminVoice = ref(false);
|
||||||
const adminVoiceSaved = ref(false);
|
const adminVoiceSaved = ref(false);
|
||||||
|
const voiceLoadingModels = ref(false);
|
||||||
|
|
||||||
async function saveAdminVoice() {
|
async function saveAdminVoice() {
|
||||||
savingAdminVoice.value = true;
|
savingAdminVoice.value = true;
|
||||||
@@ -461,13 +462,39 @@ async function saveAdminVoice() {
|
|||||||
});
|
});
|
||||||
adminVoiceSaved.value = true;
|
adminVoiceSaved.value = true;
|
||||||
setTimeout(() => { adminVoiceSaved.value = false; }, 2000);
|
setTimeout(() => { adminVoiceSaved.value = false; }, 2000);
|
||||||
// Refresh the user-facing voice status after toggling
|
|
||||||
voiceTabLoaded.value = false;
|
voiceTabLoaded.value = false;
|
||||||
|
if (adminVoiceEnabled.value) {
|
||||||
|
await reloadVoiceModels();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
savingAdminVoice.value = false;
|
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)
|
// Search test (SearXNG)
|
||||||
const searxngConfigured = ref(false);
|
const searxngConfigured = ref(false);
|
||||||
const searxngUrl = ref("");
|
const searxngUrl = ref("");
|
||||||
@@ -2200,10 +2227,22 @@ FABLE_API_KEY=<your-api-key></pre>
|
|||||||
<p class="field-hint">Larger models are more accurate but use more RAM and take longer to load.</p>
|
<p class="field-hint">Larger models are more accurate but use more RAM and take longer to load.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions" style="margin-top:0.85rem;">
|
<div class="actions" style="margin-top:0.85rem;">
|
||||||
<button class="btn-save" @click="saveAdminVoice" :disabled="savingAdminVoice">
|
<button class="btn-save" @click="saveAdminVoice" :disabled="savingAdminVoice || voiceLoadingModels">
|
||||||
{{ adminVoiceSaved ? 'Saved ✓' : savingAdminVoice ? 'Saving…' : 'Save' }}
|
{{ savingAdminVoice ? 'Saving…' : adminVoiceSaved ? 'Saved ✓' : 'Save' }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -3793,4 +3832,26 @@ FABLE_API_KEY=<your-api-key></pre>
|
|||||||
.form-actions {
|
.form-actions {
|
||||||
margin-top: 1rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -228,6 +228,17 @@ async def update_voice_config():
|
|||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/voice/reload", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
async def reload_voice_models():
|
||||||
|
"""Reload STT and TTS models in the background without a server restart."""
|
||||||
|
from fabledassistant.services.stt import reload_stt_model
|
||||||
|
from fabledassistant.services.tts import reload_tts_model
|
||||||
|
asyncio.create_task(reload_stt_model())
|
||||||
|
asyncio.create_task(reload_tts_model())
|
||||||
|
return jsonify({"status": "loading"})
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/invitations", methods=["POST"])
|
@admin_bp.route("/invitations", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
async def create_invite():
|
async def create_invite():
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ async def load_stt_model() -> None:
|
|||||||
logger.exception(_load_error)
|
logger.exception(_load_error)
|
||||||
|
|
||||||
|
|
||||||
|
async def reload_stt_model() -> None:
|
||||||
|
"""Unload the current model and reload with the current config. Safe to call at runtime."""
|
||||||
|
global _model, _load_error
|
||||||
|
async with _model_lock:
|
||||||
|
_model = None
|
||||||
|
_load_error = None
|
||||||
|
await load_stt_model()
|
||||||
|
|
||||||
|
|
||||||
def stt_available() -> bool:
|
def stt_available() -> bool:
|
||||||
return _model is not None
|
return _model is not None
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ async def load_tts_model() -> None:
|
|||||||
logger.exception(_load_error)
|
logger.exception(_load_error)
|
||||||
|
|
||||||
|
|
||||||
|
async def reload_tts_model() -> None:
|
||||||
|
"""Unload and reload the TTS pipeline. Safe to call at runtime."""
|
||||||
|
global _pipeline, _load_error
|
||||||
|
async with _pipeline_lock:
|
||||||
|
_pipeline = None
|
||||||
|
_load_error = None
|
||||||
|
await load_tts_model()
|
||||||
|
|
||||||
|
|
||||||
def tts_available() -> bool:
|
def tts_available() -> bool:
|
||||||
return _pipeline is not None
|
return _pipeline is not None
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user