diff --git a/docker-compose.yml b/docker-compose.yml
index 5cf9bb6..7be1606 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -15,7 +15,7 @@ services:
environment:
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}"
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}"
# Uncomment and set to enable web research and image search via SearXNG:
# SEARXNG_URL: "http://searxng:8080"
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index 10df3ca..68059bc 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -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=<your-api-key>
Larger models are more accurate but use more RAM and take longer to load.
-
@@ -3793,4 +3832,26 @@ FABLE_API_KEY=<your-api-key>
.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; }
+}
diff --git a/src/fabledassistant/routes/admin.py b/src/fabledassistant/routes/admin.py
index abd20cd..e891ae4 100644
--- a/src/fabledassistant/routes/admin.py
+++ b/src/fabledassistant/routes/admin.py
@@ -228,6 +228,17 @@ async def update_voice_config():
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_required
async def create_invite():
diff --git a/src/fabledassistant/services/stt.py b/src/fabledassistant/services/stt.py
index 91969c9..2de2f41 100644
--- a/src/fabledassistant/services/stt.py
+++ b/src/fabledassistant/services/stt.py
@@ -42,6 +42,15 @@ async def load_stt_model() -> None:
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:
return _model is not None
diff --git a/src/fabledassistant/services/tts.py b/src/fabledassistant/services/tts.py
index 5f7b2af..cf5495c 100644
--- a/src/fabledassistant/services/tts.py
+++ b/src/fabledassistant/services/tts.py
@@ -56,6 +56,15 @@ async def load_tts_model() -> None:
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:
return _pipeline is not None