diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index bad9dad..2083e3b 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -16,6 +16,13 @@ const assistantName = ref(""); const defaultModel = ref(""); const installedModels = ref([]); const defaultChatModel = ref(""); + +interface OllamaModel { name: string; size: number; loaded: boolean; modified_at: string; } +const ollamaModels = ref([]); +const pullModelName = ref(""); +const pullProgress = ref<{ status: string; pct: number | null } | null>(null); +const pulling = ref(false); +const deletingModel = ref(null); const newEmail = ref(""); const emailPassword = ref(""); const changingEmail = ref(false); @@ -415,6 +422,7 @@ onMounted(async () => { } catch { // Ollama unreachable — dropdowns will be empty } + await loadOllamaModels(); // Load notification preferences from user settings const allSettings = await apiGet>("/api/settings"); @@ -527,6 +535,89 @@ async function changePassword() { } } +function formatBytes(bytes: number): string { + if (bytes === 0) return "—"; + const gb = bytes / 1e9; + if (gb >= 1) return `${gb.toFixed(1)} GB`; + return `${(bytes / 1e6).toFixed(0)} MB`; +} + +async function loadOllamaModels() { + try { + const data = await apiGet<{ models: OllamaModel[] }>("/api/chat/models"); + ollamaModels.value = data.models ?? []; + installedModels.value = ollamaModels.value.map((m) => m.name); + } catch { + // Ollama unreachable + } +} + +async function pullModel() { + const name = pullModelName.value.trim(); + if (!name || pulling.value) return; + pulling.value = true; + pullProgress.value = { status: "Connecting…", pct: null }; + try { + const resp = await fetch("/api/chat/models/pull", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: name }), + credentials: "include", + }); + if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`); + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split("\n"); + buf = lines.pop() ?? ""; + for (const line of lines) { + const trimmed = line.replace(/^data: /, "").trim(); + if (!trimmed) continue; + try { + const msg = JSON.parse(trimmed); + if (msg.error) throw new Error(msg.error); + if (msg.status === "success") { + pullProgress.value = { status: "Complete", pct: 100 }; + pullModelName.value = ""; + } else if (msg.total && msg.completed) { + const pct = Math.round((msg.completed / msg.total) * 100); + pullProgress.value = { status: msg.status || "Downloading…", pct }; + } else { + pullProgress.value = { status: msg.status || "Working…", pct: null }; + } + } catch (parseErr: any) { + if (parseErr.message !== "JSON parse error") throw parseErr; + } + } + } + await loadOllamaModels(); + toastStore.show(`Model ${name} ready`); + } catch (err: any) { + toastStore.show(`Pull failed: ${err.message}`, "error"); + } finally { + pulling.value = false; + setTimeout(() => { pullProgress.value = null; }, 3000); + } +} + +async function deleteModel(name: string) { + deletingModel.value = name; + try { + await apiPost("/api/chat/models/delete", { model: name }); + await loadOllamaModels(); + if (defaultModel.value === name) defaultModel.value = ""; + toastStore.show(`Deleted ${name}`); + } catch { + toastStore.show(`Failed to delete ${name}`, "error"); + } finally { + deletingModel.value = null; + } +} + async function saveAssistant() { saving.value = true; saved.value = false; @@ -1050,6 +1141,69 @@ function formatUserDate(iso: string): string { Saved! + + +
+
+
+

Model Management

+

Install and remove Ollama models without leaving the app. Search ollama.com/library for available models.

+
+ +
+ + +
+
+
+ {{ m.name }} + in VRAM + default +
+
+ {{ formatBytes(m.size) }} + +
+
+
+

No models installed, or Ollama is unreachable.

+ + +
+ + +
+
+ Suggestions: + +
+ + +
+
{{ pullProgress.status }}
+
+
+
+
+
+
@@ -2136,6 +2290,70 @@ function formatUserDate(iso: string): string { line-height: 1.5; } +/* ── Model Management ───────────────────────────────────────── */ +.model-mgmt-header { + display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.75rem; +} +.model-mgmt-header h2 { margin: 0; } +.model-mgmt-header .section-desc { margin: 0.25rem 0 0; } +.model-list { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.75rem; } +.model-row { + display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; + padding: 0.45rem 0.6rem; + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: 6px; +} +.model-row-info { display: flex; align-items: center; gap: 0.4rem; min-width: 0; flex: 1; } +.model-name { font-size: 0.88rem; font-weight: 500; font-family: monospace; } +.model-badge { + font-size: 0.68rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-weight: 600; white-space: nowrap; +} +.model-badge--loaded { background: color-mix(in srgb, #22c55e 18%, transparent); color: #22c55e; } +.model-badge--default { background: color-mix(in srgb, var(--color-primary) 18%, transparent); color: var(--color-primary); } +.model-row-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; } +.model-size { font-size: 0.78rem; color: var(--color-text-muted); } +.model-delete-btn { + background: none; border: none; cursor: pointer; color: var(--color-text-muted); + font-size: 0.8rem; padding: 0.2rem 0.35rem; border-radius: 3px; line-height: 1; + transition: color 0.15s, background 0.15s; +} +.model-delete-btn:hover:not(:disabled) { color: var(--color-danger, #ef4444); background: color-mix(in srgb, var(--color-danger, #ef4444) 10%, transparent); } +.model-delete-btn:disabled { opacity: 0.4; cursor: default; } +.model-pull-form { display: flex; gap: 0.5rem; margin-top: 0.5rem; } +.model-pull-form .input { flex: 1; } +.model-suggestions { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.4rem; } +.suggestions-label { font-size: 0.75rem; color: var(--color-text-muted); white-space: nowrap; } +.suggestion-chip { + font-size: 0.72rem; padding: 0.15rem 0.5rem; border-radius: 4px; + border: 1px solid var(--color-border); background: var(--color-bg-card); + cursor: pointer; font-family: monospace; color: var(--color-text); + transition: border-color 0.12s, background 0.12s; +} +.suggestion-chip:hover:not(:disabled) { border-color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card)); } +.suggestion-chip:disabled { opacity: 0.4; cursor: default; } +.model-pull-progress { margin-top: 0.6rem; } +.pull-status { font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.25rem; } +.pull-bar-track { + height: 4px; background: var(--color-border); border-radius: 2px; overflow: hidden; +} +.pull-bar-fill { + height: 100%; background: var(--color-primary); border-radius: 2px; + transition: width 0.3s ease; +} +.pull-bar-indeterminate { + height: 4px; background: var(--color-border); border-radius: 2px; + position: relative; overflow: hidden; +} +.pull-bar-indeterminate::after { + content: ""; position: absolute; top: 0; left: -40%; + width: 40%; height: 100%; background: var(--color-primary); border-radius: 2px; + animation: indeterminate 1.2s ease-in-out infinite; +} +@keyframes indeterminate { + 0% { left: -40%; } 100% { left: 100%; } +} + /* Assistant — 2-col internal grid */ .assistant-grid { display: grid; diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py index d3d5652..a1e2366 100644 --- a/src/fabledassistant/routes/chat.py +++ b/src/fabledassistant/routes/chat.py @@ -405,14 +405,29 @@ async def chat_status_route(): async def list_models_route(): try: async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.get(f"{Config.OLLAMA_URL}/api/tags") - resp.raise_for_status() - data = resp.json() - models = [ - {"name": m["name"], "size": m.get("size", 0)} - for m in data.get("models", []) - ] - return jsonify({"models": models}) + tags_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/tags")) + ps_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/ps")) + tags_resp, ps_resp = await asyncio.gather(tags_task, ps_task, return_exceptions=True) + + loaded_names: set[str] = set() + if not isinstance(ps_resp, Exception): + try: + ps_resp.raise_for_status() + loaded_names = {m["name"] for m in ps_resp.json().get("models", [])} + except Exception: + pass + + models = [] + if not isinstance(tags_resp, Exception): + tags_resp.raise_for_status() + for m in tags_resp.json().get("models", []): + models.append({ + "name": m["name"], + "size": m.get("size", 0), + "modified_at": m.get("modified_at", ""), + "loaded": m["name"] in loaded_names, + }) + return jsonify({"models": models}) except Exception as e: logger.warning("Failed to list Ollama models: %s", e) return jsonify({"models": [], "error": str(e)}), 200