From 39bcd7a8faa5d72e5d16b016404e0b23ead00339 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 10 Feb 2026 22:34:58 -0500 Subject: [PATCH] Stream model pull progress via SSE instead of fire-and-forget Replace the asyncio.create_task() fire-and-forget pattern for model downloads with SSE streaming that relays Ollama's pull progress in real time. The frontend now shows live download percentage instead of a static "Pulling..." label with blind polling. - routes/chat.py: SSE streaming endpoint with 1800s timeout - stores/settings.ts: pullModel uses apiStreamPost with onProgress callback - SettingsView.vue: live "Downloading 42%" display, removed polling/setTimeout - summary.md: updated to reflect SSE streaming for model pulls Co-Authored-By: Claude Opus 4.6 --- frontend/src/stores/settings.ts | 11 ++++-- frontend/src/views/SettingsView.vue | 54 +++++++++++++---------------- src/fabledassistant/routes/chat.py | 35 ++++++++++++++++--- summary.md | 15 ++++---- 4 files changed, 70 insertions(+), 45 deletions(-) diff --git a/frontend/src/stores/settings.ts b/frontend/src/stores/settings.ts index 530f35e..99889ad 100644 --- a/frontend/src/stores/settings.ts +++ b/frontend/src/stores/settings.ts @@ -1,6 +1,6 @@ import { ref, computed } from "vue"; import { defineStore } from "pinia"; -import { apiGet, apiPut, apiPost } from "@/api/client"; +import { apiGet, apiPut, apiPost, apiStreamPost } from "@/api/client"; import type { AppSettings } from "@/types/settings"; export const useSettingsStore = defineStore("settings", () => { @@ -45,8 +45,13 @@ export const useSettingsStore = defineStore("settings", () => { } } - async function pullModel(model: string) { - await apiPost("/api/chat/models/pull", { model }); + async function pullModel( + model: string, + onProgress?: (data: Record) => void, + ) { + await apiStreamPost("/api/chat/models/pull", { model }, (data) => { + if (onProgress) onProgress(data); + }); } async function deleteModel(model: string) { diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index adeb210..c6453d4 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -7,7 +7,7 @@ const store = useSettingsStore(); const assistantName = ref(""); const saving = ref(false); const saved = ref(false); -const pulling = ref(null); +const pullProgress = ref<{ model: string; percent: number } | null>(null); const deleting = ref(null); const confirmDelete = ref(null); @@ -34,13 +34,6 @@ const MODEL_CATALOG: ModelInfo[] = [ bestFor: "Fast responses, general tasks", category: "General Purpose", }, - { - name: "gemma2", - description: "Google's Gemma 2 9B — well-rounded model strong in reasoning and conversation.", - size: "5.4 GB", - bestFor: "Conversation, reasoning, summarization", - category: "General Purpose", - }, { name: "qwen2.5", description: "Alibaba's Qwen 2.5 7B — multilingual model with strong coding and math skills.", @@ -171,8 +164,9 @@ function modelsInCategory(category: string) { function isActiveModel(name: string): boolean { const current = selectedModel.value || store.defaultModel; if (!current) return false; - const clean = current.replace(/:latest$/, ""); - return clean === name || clean === name.replace(/:.*$/, ""); + const cleanCurrent = current.replace(/:latest$/, ""); + const cleanName = name.replace(/:latest$/, ""); + return cleanCurrent === cleanName; } onMounted(async () => { @@ -205,27 +199,27 @@ async function selectModel(name: string) { } async function pullModel(name: string) { - pulling.value = name; + pullProgress.value = { model: name, percent: 0 }; try { - await store.pullModel(name); - // Poll for completion - const poll = setInterval(async () => { - await store.fetchInstalledModels(); - const installed = new Set( - store.installedModels.map((m) => m.replace(/:latest$/, "")) - ); - if (installed.has(name) || installed.has(name.replace(/:.*$/, ""))) { - clearInterval(poll); - pulling.value = null; + await store.pullModel(name, (data) => { + if (data.error) { + pullProgress.value = null; + return; } - }, 5000); - // Stop polling after 10 minutes max - setTimeout(() => { - clearInterval(poll); - if (pulling.value === name) pulling.value = null; - }, 600_000); + const completed = data.completed as number | undefined; + const total = data.total as number | undefined; + if (completed != null && total && total > 0) { + pullProgress.value = { + model: name, + percent: Math.round((completed / total) * 100), + }; + } + }); + await store.fetchInstalledModels(); } catch { - pulling.value = null; + // error already handled + } finally { + pullProgress.value = null; } } @@ -342,9 +336,9 @@ function cancelDelete() { v-else class="btn-pull" @click="pullModel(model.name)" - :disabled="pulling !== null" + :disabled="pullProgress !== null" > - {{ pulling === model.name ? "Pulling..." : "Download" }} + {{ pullProgress?.model === model.name ? `Downloading ${pullProgress.percent}%` : "Download" }} diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py index 699d60a..e71ae53 100644 --- a/src/fabledassistant/routes/chat.py +++ b/src/fabledassistant/routes/chat.py @@ -15,7 +15,7 @@ from fabledassistant.services.chat import ( summarize_conversation_as_note, update_conversation_title, ) -from fabledassistant.services.llm import build_context, ensure_model, stream_chat +from fabledassistant.services.llm import build_context, stream_chat from fabledassistant.services.settings import get_setting logger = logging.getLogger(__name__) @@ -201,15 +201,40 @@ async def list_models_route(): @chat_bp.route("/models/pull", methods=["POST"]) async def pull_model_route(): - """Pull a model from Ollama. Runs in the background.""" + """Pull a model from Ollama, streaming progress via SSE.""" data = await request.get_json() model_name = data.get("model", "").strip() if not model_name: return jsonify({"error": "model is required"}), 400 - import asyncio - asyncio.create_task(ensure_model(model_name)) - return jsonify({"status": "pulling", "model": model_name}), 202 + async def generate(): + try: + async with httpx.AsyncClient(timeout=1800.0) as client: + async with client.stream( + "POST", + f"{Config.OLLAMA_URL}/api/pull", + json={"name": model_name}, + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line.strip(): + continue + progress = json.loads(line) + event_data = json.dumps(progress) + yield f"data: {event_data}\n\n" + yield f"data: {json.dumps({'status': 'success'})}\n\n" + except Exception as e: + logger.exception("Error pulling model %s", model_name) + yield f"data: {json.dumps({'error': str(e)})}\n\n" + + return Response( + generate(), + content_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) @chat_bp.route("/models/delete", methods=["POST"]) diff --git a/summary.md b/summary.md index 4f7469f..51ce3a1 100644 --- a/summary.md +++ b/summary.md @@ -66,9 +66,10 @@ for AI-assisted features. simple word splitting with stopword filtering (no embeddings). - **No blocking long-running operations:** Any potentially slow operation (model pulls, LLM calls, URL fetching, etc.) must never block app startup or freeze the - UI. Backend uses `asyncio.create_task()` for fire-and-forget work and SSE streaming - for incremental responses. Frontend keeps the UI responsive during async operations - (loading states, streaming indicators). This applies to all future features as well. + UI. Backend uses SSE streaming for incremental responses (chat messages and model + pull progress). Frontend keeps the UI responsive during async operations (loading + states, streaming indicators, progress percentages). This applies to all future + features as well. - **Auto-pull model on startup:** Non-blocking background task, logs warning on failure so the app still starts even if Ollama isn't ready. - **Backlinks:** `GET /api/notes/:id/backlinks` searches all note bodies for @@ -209,7 +210,7 @@ fabledassistant/ │ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags │ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (uses body not description) │ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), saveMessageAsNote, summarizeAsNote, fetchModels, status polling (ollamaStatus, modelStatus, chatReady) - │ │ ├── settings.ts # App settings: assistantName, defaultModel, fetchInstalledModels, pullModel, deleteModel + │ │ ├── settings.ts # App settings: assistantName, defaultModel, fetchInstalledModels, pullModel (SSE streaming with onProgress callback), deleteModel │ │ └── toast.ts # Toast notification state, 3s auto-dismiss │ ├── types/ │ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse @@ -279,7 +280,7 @@ fabledassistant/ | POST | `/api/chat/conversations/:id/summarize` | Summarize conversation via LLM, save as note | | GET | `/api/chat/status` | Check Ollama availability and model readiness (`{ollama, model, default_model}`) | | GET | `/api/chat/models` | List available Ollama models | -| POST | `/api/chat/models/pull` | Pull/download a model from Ollama (background, body: `{model}`) | +| POST | `/api/chat/models/pull` | Pull/download a model from Ollama via SSE streaming progress (body: `{model}`, response: SSE with `{status, completed, total}` events) | | POST | `/api/chat/models/delete` | Delete a model from Ollama (body: `{model}`) | | GET | `/api/settings` | Get all app settings as `{key: value}` dict | | PUT | `/api/settings` | Update settings (body: `{key: value, ...}`) | @@ -441,7 +442,7 @@ When adding a new migration, follow these conventions: - [x] **Configurable assistant name:** Default "Fable", editable in settings, injected into LLM system prompt and chat message labels - [x] **Settings page:** `/settings` route with assistant name form + model catalog - [x] **Model catalog:** 18 models across 3 categories (General Purpose, Coding, Uncensored / Creative Writing) with descriptions, sizes, and best-for labels -- [x] **Model management:** Download (pull), select (set as default), and remove (delete from Ollama) with confirmation UI +- [x] **Model management:** Download (pull with SSE progress streaming + live percentage in UI), select (set as default), and remove (delete from Ollama) with confirmation UI - [x] **Status indicator in nav bar:** Moved from per-component (ChatView/ChatPanel) to global AppHeader; green/yellow/red dot with label - [x] **Chat bubble layout:** User messages right-aligned (primary color bg), assistant messages left-aligned (card bg), speech bubble tails - [x] **Floating dark input bar:** `#1c1c1e` background, rounded corners, circular send button with arrow @@ -485,7 +486,7 @@ When adding a new migration, follow these conventions: - Dedicated `/chat` page with bubble-style messages (user right, assistant left), floating dark input bar - Slide-out chat panel accessible from any page, auto-includes note/task context - Settings page: configurable assistant name (default "Fable"), model catalog with 18 models in 3 categories -- Model management: download, select, and remove models from the settings page +- Model management: download (with live SSE progress percentage), select, and remove models from the settings page - Ollama status indicator in global nav bar (green/yellow/red dot) with 30s polling - Recent chats section on home page with quick "New Chat" button - HTML entity rendering fix (apostrophes in marked → DOMPurify pipeline)