diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index 32a2722..37a8a56 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -2564,7 +2564,7 @@ FABLE_API_KEY=<your-api-key>
Voice (Speech-to-Speech)
Enable self-hosted voice using faster-whisper (STT) and Kokoro (TTS).
- Models load at startup — a server restart is required after changing these settings.
+ Save settings then click "Reload models" to apply without restarting the server.
Install dependencies first: pip install faster-whisper kokoro soundfile
diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py
index 1924188..3798017 100644
--- a/src/fabledassistant/app.py
+++ b/src/fabledassistant/app.py
@@ -212,10 +212,10 @@ def create_app() -> Quart:
async def _warm_user_models() -> None:
"""
- Warm whichever chat model(s) users have selected in Settings, then prime
- the KV cache with each user's system prompt so the first real message is fast.
+ Pull any user-configured models that are missing from Ollama, then warm
+ them and prime the KV cache with each user's system prompt.
- Only warms models that are already installed in Ollama — never auto-pulls.
+ Handles both default_model (chat) and background_model user overrides.
Falls back silently if no user preferences exist or Ollama is unreachable.
"""
from sqlalchemy import select as sa_select
@@ -223,50 +223,62 @@ def create_app() -> Quart:
from fabledassistant.models import async_session
from fabledassistant.models.setting import Setting
- # 1. Collect (user_id, model) pairs for all users with a saved default_model.
+ # 1. Collect all user model preferences (both chat and background).
try:
async with async_session() as session:
rows = await session.execute(
- sa_select(Setting.user_id, Setting.value).where(
- Setting.key == "default_model",
+ sa_select(Setting.user_id, Setting.key, Setting.value).where(
+ Setting.key.in_(["default_model", "background_model"]),
Setting.value.isnot(None),
Setting.value != "",
)
)
- user_model_pairs: list[tuple[int, str]] = list(rows)
+ settings_rows: list[tuple[int, str, str]] = list(rows)
except Exception:
logger.debug("Could not read user model preferences from DB", exc_info=True)
return
- if not user_model_pairs:
+ if not settings_rows:
logger.debug("No user model preferences found; skipping warm-up")
return
- # 2. Ask Ollama which models are currently installed.
+ # 2. Build the set of unique models to ensure, and the list of
+ # (user_id, chat_model) pairs for KV-cache priming.
+ all_models: set[str] = set()
+ user_chat_models: list[tuple[int, str]] = []
+ for user_id_val, key, model in settings_rows:
+ all_models.add(model)
+ if key == "default_model":
+ user_chat_models.append((user_id_val, model))
+
+ # 3. Ask Ollama which models are currently installed.
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
resp.raise_for_status()
- installed: set[str] = {m["name"] for m in resp.json().get("models", [])}
+ raw_installed: set[str] = {m["name"] for m in resp.json().get("models", [])}
+ installed: set[str] = raw_installed | {
+ n.removesuffix(":latest") for n in raw_installed if n.endswith(":latest")
+ }
except Exception:
logger.debug("Could not reach Ollama to check installed models", exc_info=True)
return
- # 3. Warm each unique model, then prime KV cache per user.
+ # 4. Pull any user-configured models that are missing.
+ for model in all_models:
+ if model not in installed:
+ logger.info("User-configured model '%s' not installed; pulling...", model)
+ await _pull_model(model)
+ installed.add(model)
+
+ # 5. Warm each unique chat model, then prime KV cache per user.
warmed: set[str] = set()
- for user_id_val, model in user_model_pairs:
- base = model.removesuffix(":latest")
- if model in installed or f"{base}:latest" in installed or base in installed:
+ for user_id_val, model in user_chat_models:
+ if model in installed:
if model not in warmed:
await _warm_model(model)
warmed.add(model)
await _prime_kv_cache(user_id_val, model)
- else:
- logger.info(
- "User-preferred model '%s' is not installed; skipping warm-up "
- "(install it via Settings → Models to enable auto-warm)",
- model,
- )
async def _pull_model(model: str, warm: bool = False) -> None:
try:
@@ -281,11 +293,11 @@ def create_app() -> Quart:
if warm:
await _warm_model(model)
- # Warm user-preferred chat models that are already installed.
- # Also ensure the embedding model is pulled (no warm needed).
- asyncio.create_task(_warm_user_models())
+ # Ensure system-default models are present, then pull/warm user-configured ones.
+ asyncio.create_task(_pull_model(Config.OLLAMA_MODEL, warm=True))
asyncio.create_task(_pull_model(Config.EMBEDDING_MODEL, warm=False))
asyncio.create_task(_pull_model(Config.OLLAMA_BACKGROUND_MODEL, warm=False))
+ asyncio.create_task(_warm_user_models())
# After models are pulled, backfill embeddings for existing notes.
# Runs in the background so it never blocks the server from accepting requests.
diff --git a/src/fabledassistant/routes/admin.py b/src/fabledassistant/routes/admin.py
index e891ae4..0b2df86 100644
--- a/src/fabledassistant/routes/admin.py
+++ b/src/fabledassistant/routes/admin.py
@@ -195,6 +195,10 @@ async def get_base_url_setting():
async def update_base_url():
data = await request.get_json()
url = (data.get("base_url") or "").strip().rstrip("/")
+ if url:
+ scheme = url.split("://")[0].lower() if "://" in url else ""
+ if scheme not in ("http", "https"):
+ return jsonify({"error": "Base URL must use http or https"}), 400
uid = get_current_user_id()
await set_setting(uid, "base_url", url)
await log_audit("base_url_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"base_url": url})
diff --git a/src/fabledassistant/routes/settings.py b/src/fabledassistant/routes/settings.py
index 979075e..b5e94f9 100644
--- a/src/fabledassistant/routes/settings.py
+++ b/src/fabledassistant/routes/settings.py
@@ -65,10 +65,10 @@ async def update_settings_route():
if installed and model not in installed:
return jsonify({"error": f"Model '{model}' is not installed"}), 400
- # Empty string for default_model means "reset to system default".
+ # Empty string for model keys means "reset to system default".
# Delete the DB row so get_setting() falls back to Config defaults
# rather than returning "" and breaking model resolution everywhere.
- _MODEL_KEYS = frozenset({"default_model"})
+ _MODEL_KEYS = frozenset({"default_model", "background_model"})
to_save = {}
for k, v in data.items():
str_v = str(v)