diff --git a/backend/api/routers/settings.py b/backend/api/routers/settings.py
index 2d11dd1b..3290cb3d 100644
--- a/backend/api/routers/settings.py
+++ b/backend/api/routers/settings.py
@@ -13,6 +13,7 @@ The state endpoint duplicates `/system/hf-token/state` (which lives on
from __future__ import annotations
import logging
+import os
from dataclasses import asdict
from typing import Optional
@@ -194,3 +195,93 @@ def get_license_acceptance(engine_id: str) -> dict:
logger.exception("get_license_accepted failed for %s", eid)
raise HTTPException(status_code=500, detail="Failed to read license acceptance")
return {"engine_id": eid, "accepted": bool(accepted)}
+
+
+# ── Storage: configurable models directory (#64) ──────────────────────────
+# Where HuggingFace / Torch download model weights. The user's choice is
+# persisted durably to the per-user env file as OMNIVOICE_CACHE_DIR, which
+# main.py maps to HF_HOME / HF_HUB_CACHE / TORCH_HOME at startup. That env file
+# is the *single source of truth*: PUT writes it, GET reads it back — there is
+# no second store to diverge from. Takes effect on the next backend restart
+# (a storage-location change can't safely move an in-use cache mid-process).
+_MODELS_DIR_ENV = "OMNIVOICE_CACHE_DIR"
+
+
+def _default_models_dir() -> str:
+ """huggingface_hub's default cache root, honoring XDG_CACHE_HOME on Linux
+ (matches HF so GET reports the *true* default the backend would use)."""
+ base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
+ return os.path.join(base, "huggingface")
+
+
+def _effective_models_dir() -> str:
+ return (
+ os.environ.get("HF_HUB_CACHE")
+ or os.environ.get("HUGGINGFACE_HUB_CACHE")
+ or os.environ.get("HF_HOME")
+ or _default_models_dir()
+ )
+
+
+class _ModelsDirBody(BaseModel):
+ path: str = Field(default="", description="Absolute directory; empty clears → default cache")
+
+
+@router.get("/storage/models-dir")
+def get_models_dir():
+ """Current models directory: the persisted choice (from the durable env
+ file — the same value main.py reads at startup), what's effective in this
+ process, and the platform default."""
+ from core import user_env
+
+ configured = user_env.get_user_env(_MODELS_DIR_ENV) or None
+ return {
+ "configured": configured,
+ "effective": _effective_models_dir(),
+ "default": _default_models_dir(),
+ "restart_required": False,
+ }
+
+
+@router.put("/storage/models-dir")
+def set_models_dir(body: _ModelsDirBody):
+ """Set (or clear, with an empty path) the models download directory.
+
+ Validates the directory is writable, then writes OMNIVOICE_CACHE_DIR to the
+ durable per-user env file so main.py applies it on the next launch. The env
+ file is the only persisted store, so GET can never diverge from what was
+ saved. Returns restart_required=True.
+ """
+ from core import user_env
+
+ raw = (body.path or "").strip()
+ if not raw:
+ user_env.unset_user_env(_MODELS_DIR_ENV)
+ return {"configured": None, "default": _default_models_dir(), "restart_required": True}
+
+ # Reject control characters / NUL before touching the filesystem: an
+ # embedded NUL makes os.makedirs raise ValueError (→ 500). This is also
+ # the input-validation barrier for the path before it reaches any fs call
+ # (the dir is user-chosen by design — this is a loopback-gated, same-user
+ # local file picker, not a cross-privilege boundary).
+ if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in raw):
+ raise HTTPException(status_code=400, detail="Path contains invalid control characters")
+
+ path = os.path.abspath(os.path.expanduser(raw))
+ try:
+ os.makedirs(path, exist_ok=True)
+ probe = os.path.join(path, ".omnivoice_write_test")
+ with open(probe, "w", encoding="utf-8") as f:
+ f.write("ok")
+ except OSError as e:
+ raise HTTPException(status_code=400, detail=f"Directory is not writable: {e}") from e
+ finally:
+ # Best-effort cleanup; a failed remove (concurrent process, perm change)
+ # must not leave the request hanging or mask the real error.
+ try:
+ os.remove(os.path.join(path, ".omnivoice_write_test"))
+ except OSError:
+ pass
+
+ user_env.set_user_env(_MODELS_DIR_ENV, path)
+ return {"configured": path, "effective": _effective_models_dir(), "restart_required": True}
diff --git a/backend/core/user_env.py b/backend/core/user_env.py
new file mode 100644
index 00000000..9e17e695
--- /dev/null
+++ b/backend/core/user_env.py
@@ -0,0 +1,84 @@
+"""Durable per-user environment file (`~/.config/omnivoice/env`).
+
+`main.py` loads this file at startup (via dotenv) before importing torch/HF, so
+values written here take effect on the next backend launch. Used by the
+configurable models directory (#64): the Settings endpoint upserts
+``OMNIVOICE_CACHE_DIR`` here, which main.py then maps to
+``HF_HOME`` / ``HF_HUB_CACHE`` / ``TORCH_HOME``.
+
+Format is dotenv-style ``KEY=value`` lines. Upsert preserves other keys (e.g. a
+persisted ``HF_TOKEN``) and writes the file ``0600`` (it can hold secrets).
+"""
+from __future__ import annotations
+
+import os
+from typing import Optional
+
+USER_ENV_PATH = os.path.expanduser("~/.config/omnivoice/env")
+
+
+def _read_lines(path: str) -> list[str]:
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ return f.read().splitlines()
+ except FileNotFoundError:
+ return []
+ # Any *other* OSError (permission, I/O error) propagates: collapsing it to
+ # an empty baseline would make a subsequent upsert silently drop existing
+ # keys (e.g. a persisted HF_TOKEN) when it rewrites the file.
+
+
+def _opener_0600(path: str, flags: int) -> int:
+ # Create the file with 0600 from the start (no world-readable window before
+ # a follow-up chmod) — it can hold secrets like HF_TOKEN. The mode is
+ # masked by umask but only ever *more* restrictive; non-POSIX platforms
+ # ignore the mode bits.
+ return os.open(path, flags, 0o600)
+
+
+def _write_lines(path: str, lines: list[str]) -> None:
+ parent = os.path.dirname(path)
+ if parent: # bare filename (e.g. an OMNIVOICE_ENV_FILE override) has no parent
+ os.makedirs(parent, exist_ok=True)
+ body = "\n".join(lines)
+ if body and not body.endswith("\n"):
+ body += "\n"
+ with open(path, "w", encoding="utf-8", opener=_opener_0600) as f:
+ f.write(body)
+ try:
+ os.chmod(path, 0o600) # tighten an existing file that predates the opener
+ except OSError:
+ pass # best-effort; some filesystems/Windows don't support chmod
+
+
+def get_user_env(key: str, path: Optional[str] = None) -> Optional[str]:
+ path = path or os.environ.get("OMNIVOICE_ENV_FILE") or USER_ENV_PATH # resolved at call time so tests can monkeypatch
+ prefix = f"{key}="
+ for line in _read_lines(path):
+ if line.startswith(prefix):
+ return line[len(prefix):]
+ return None
+
+
+def set_user_env(key: str, value: str, path: Optional[str] = None) -> None:
+ """Upsert ``KEY=value``, preserving all other lines."""
+ path = path or os.environ.get("OMNIVOICE_ENV_FILE") or USER_ENV_PATH
+ prefix = f"{key}="
+ lines = _read_lines(path)
+ replaced = False
+ for i, line in enumerate(lines):
+ if line.startswith(prefix):
+ lines[i] = f"{key}={value}"
+ replaced = True
+ break
+ if not replaced:
+ lines.append(f"{key}={value}")
+ _write_lines(path, lines)
+
+
+def unset_user_env(key: str, path: Optional[str] = None) -> None:
+ """Remove ``KEY=...`` if present, preserving all other lines."""
+ path = path or os.environ.get("OMNIVOICE_ENV_FILE") or USER_ENV_PATH
+ prefix = f"{key}="
+ lines = [ln for ln in _read_lines(path) if not ln.startswith(prefix)]
+ _write_lines(path, lines)
diff --git a/frontend/src/components/settings/StoragePanel.css b/frontend/src/components/settings/StoragePanel.css
new file mode 100644
index 00000000..21958c06
--- /dev/null
+++ b/frontend/src/components/settings/StoragePanel.css
@@ -0,0 +1,59 @@
+.storagepanel {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px 14px;
+ margin-bottom: 12px;
+ border: 1px solid var(--border, #3c3836);
+ border-radius: 8px;
+ background: var(--panel-bg, rgba(255, 255, 255, 0.02));
+}
+.storagepanel__title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 0;
+ font-size: 13px;
+ font-weight: 600;
+}
+.storagepanel__help {
+ margin: 0;
+ font-size: 12px;
+ opacity: 0.8;
+}
+.storagepanel__row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+.storagepanel__input {
+ flex: 1 1 280px;
+ min-width: 0;
+ padding: 6px 8px;
+ font-size: 12px;
+ font-family: var(--mono, ui-monospace, monospace);
+ border: 1px solid var(--border, #504945);
+ border-radius: 6px;
+ background: var(--input-bg, rgba(0, 0, 0, 0.2));
+ color: inherit;
+}
+.storagepanel__btn {
+ padding: 6px 12px;
+ font-size: 12px;
+ border: 1px solid var(--border, #504945);
+ border-radius: 6px;
+ background: var(--accent, #83a598);
+ color: #1d2021;
+ cursor: pointer;
+}
+.storagepanel__btn:disabled { opacity: 0.5; cursor: default; }
+.storagepanel__btn--ghost { background: transparent; color: inherit; }
+.storagepanel__meta { margin: 0; font-size: 11px; opacity: 0.7; }
+.storagepanel__meta code { font-size: 11px; }
+.storagepanel__restart { margin: 0; font-size: 12px; color: var(--warn, #fabd2f); }
+.storagepanel__error {
+ font-size: 12px;
+ color: var(--danger, #fb4934);
+ padding: 4px 0;
+}
diff --git a/frontend/src/components/settings/StoragePanel.jsx b/frontend/src/components/settings/StoragePanel.jsx
new file mode 100644
index 00000000..ee47ae2f
--- /dev/null
+++ b/frontend/src/components/settings/StoragePanel.jsx
@@ -0,0 +1,128 @@
+/**
+ * Settings → Models tab → Models directory panel (#64).
+ *
+ * Lets the user choose where model weights download (the HuggingFace / Torch
+ * cache). The backend persists it to the durable per-user env file as
+ * OMNIVOICE_CACHE_DIR, which main.py maps to HF_HOME / HF_HUB_CACHE / TORCH_HOME
+ * on the next launch — so changes apply after a restart.
+ *
+ * Endpoints:
+ * GET /api/settings/storage/models-dir
+ * → {configured, effective, default, restart_required}
+ * PUT /api/settings/storage/models-dir body {path} (empty path clears)
+ */
+import React, { useCallback, useEffect, useState } from 'react';
+import { HardDrive } from 'lucide-react';
+import toast from 'react-hot-toast';
+import { apiJson, apiFetch } from '../../api/client';
+import './StoragePanel.css';
+
+export default function StoragePanel() {
+ const [configured, setConfigured] = useState('');
+ const [effective, setEffective] = useState('');
+ const [def, setDef] = useState('');
+ const [input, setInput] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [restart, setRestart] = useState(false);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const d = await apiJson('/api/settings/storage/models-dir');
+ setConfigured(d?.configured || '');
+ setEffective(d?.effective || '');
+ setDef(d?.default || '');
+ setInput(d?.configured || '');
+ } catch (e) {
+ setError(e?.message || 'Failed to load storage settings');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => { refresh(); }, [refresh]);
+
+ const save = async (path) => {
+ setSaving(true);
+ setError(null);
+ try {
+ const res = await apiFetch('/api/settings/storage/models-dir', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ path }),
+ });
+ if (!res.ok) {
+ const b = await res.json().catch(() => ({}));
+ throw new Error(b?.detail || `HTTP ${res.status}`);
+ }
+ const b = await res.json();
+ setConfigured(b?.configured || '');
+ setRestart(Boolean(b?.restart_required));
+ toast.success(path ? 'Models directory saved — restart to apply' : 'Reverted to default — restart to apply');
+ refresh();
+ } catch (e) {
+ setError(e?.message || 'Failed to save models directory');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+ Models directory
+
+
+ {error &&
{error}
}
+
+
+ Where model weights download (the HuggingFace / Torch cache). Point this
+ at a larger or faster drive — useful when your system drive is small.
+ Changes apply on the next restart.
+