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 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 22:34:58 -05:00
parent de899ebc50
commit 39bcd7a8fa
4 changed files with 70 additions and 45 deletions
+8 -3
View File
@@ -1,6 +1,6 @@
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { defineStore } from "pinia"; 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"; import type { AppSettings } from "@/types/settings";
export const useSettingsStore = defineStore("settings", () => { export const useSettingsStore = defineStore("settings", () => {
@@ -45,8 +45,13 @@ export const useSettingsStore = defineStore("settings", () => {
} }
} }
async function pullModel(model: string) { async function pullModel(
await apiPost("/api/chat/models/pull", { model }); model: string,
onProgress?: (data: Record<string, unknown>) => void,
) {
await apiStreamPost("/api/chat/models/pull", { model }, (data) => {
if (onProgress) onProgress(data);
});
} }
async function deleteModel(model: string) { async function deleteModel(model: string) {
+24 -30
View File
@@ -7,7 +7,7 @@ const store = useSettingsStore();
const assistantName = ref(""); const assistantName = ref("");
const saving = ref(false); const saving = ref(false);
const saved = ref(false); const saved = ref(false);
const pulling = ref<string | null>(null); const pullProgress = ref<{ model: string; percent: number } | null>(null);
const deleting = ref<string | null>(null); const deleting = ref<string | null>(null);
const confirmDelete = ref<string | null>(null); const confirmDelete = ref<string | null>(null);
@@ -34,13 +34,6 @@ const MODEL_CATALOG: ModelInfo[] = [
bestFor: "Fast responses, general tasks", bestFor: "Fast responses, general tasks",
category: "General Purpose", 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", name: "qwen2.5",
description: "Alibaba's Qwen 2.5 7B — multilingual model with strong coding and math skills.", 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 { function isActiveModel(name: string): boolean {
const current = selectedModel.value || store.defaultModel; const current = selectedModel.value || store.defaultModel;
if (!current) return false; if (!current) return false;
const clean = current.replace(/:latest$/, ""); const cleanCurrent = current.replace(/:latest$/, "");
return clean === name || clean === name.replace(/:.*$/, ""); const cleanName = name.replace(/:latest$/, "");
return cleanCurrent === cleanName;
} }
onMounted(async () => { onMounted(async () => {
@@ -205,27 +199,27 @@ async function selectModel(name: string) {
} }
async function pullModel(name: string) { async function pullModel(name: string) {
pulling.value = name; pullProgress.value = { model: name, percent: 0 };
try { try {
await store.pullModel(name); await store.pullModel(name, (data) => {
// Poll for completion if (data.error) {
const poll = setInterval(async () => { pullProgress.value = null;
await store.fetchInstalledModels(); return;
const installed = new Set(
store.installedModels.map((m) => m.replace(/:latest$/, ""))
);
if (installed.has(name) || installed.has(name.replace(/:.*$/, ""))) {
clearInterval(poll);
pulling.value = null;
} }
}, 5000); const completed = data.completed as number | undefined;
// Stop polling after 10 minutes max const total = data.total as number | undefined;
setTimeout(() => { if (completed != null && total && total > 0) {
clearInterval(poll); pullProgress.value = {
if (pulling.value === name) pulling.value = null; model: name,
}, 600_000); percent: Math.round((completed / total) * 100),
};
}
});
await store.fetchInstalledModels();
} catch { } catch {
pulling.value = null; // error already handled
} finally {
pullProgress.value = null;
} }
} }
@@ -342,9 +336,9 @@ function cancelDelete() {
v-else v-else
class="btn-pull" class="btn-pull"
@click="pullModel(model.name)" @click="pullModel(model.name)"
:disabled="pulling !== null" :disabled="pullProgress !== null"
> >
{{ pulling === model.name ? "Pulling..." : "Download" }} {{ pullProgress?.model === model.name ? `Downloading ${pullProgress.percent}%` : "Download" }}
</button> </button>
</div> </div>
</div> </div>
+30 -5
View File
@@ -15,7 +15,7 @@ from fabledassistant.services.chat import (
summarize_conversation_as_note, summarize_conversation_as_note,
update_conversation_title, 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 from fabledassistant.services.settings import get_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -201,15 +201,40 @@ async def list_models_route():
@chat_bp.route("/models/pull", methods=["POST"]) @chat_bp.route("/models/pull", methods=["POST"])
async def pull_model_route(): 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() data = await request.get_json()
model_name = data.get("model", "").strip() model_name = data.get("model", "").strip()
if not model_name: if not model_name:
return jsonify({"error": "model is required"}), 400 return jsonify({"error": "model is required"}), 400
import asyncio async def generate():
asyncio.create_task(ensure_model(model_name)) try:
return jsonify({"status": "pulling", "model": model_name}), 202 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"]) @chat_bp.route("/models/delete", methods=["POST"])
+8 -7
View File
@@ -66,9 +66,10 @@ for AI-assisted features.
simple word splitting with stopword filtering (no embeddings). simple word splitting with stopword filtering (no embeddings).
- **No blocking long-running operations:** Any potentially slow operation (model - **No blocking long-running operations:** Any potentially slow operation (model
pulls, LLM calls, URL fetching, etc.) must never block app startup or freeze the 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 UI. Backend uses SSE streaming for incremental responses (chat messages and model
for incremental responses. Frontend keeps the UI responsive during async operations pull progress). Frontend keeps the UI responsive during async operations (loading
(loading states, streaming indicators). This applies to all future features as well. 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 - **Auto-pull model on startup:** Non-blocking background task, logs warning on
failure so the app still starts even if Ollama isn't ready. failure so the app still starts even if Ollama isn't ready.
- **Backlinks:** `GET /api/notes/:id/backlinks` searches all note bodies for - **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 │ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (uses body not description) │ │ ├── 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) │ │ ├── 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 │ │ └── toast.ts # Toast notification state, 3s auto-dismiss
│ ├── types/ │ ├── types/
│ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse │ │ ├── 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 | | 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/status` | Check Ollama availability and model readiness (`{ollama, model, default_model}`) |
| GET | `/api/chat/models` | List available Ollama models | | 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}`) | | POST | `/api/chat/models/delete` | Delete a model from Ollama (body: `{model}`) |
| GET | `/api/settings` | Get all app settings as `{key: value}` dict | | GET | `/api/settings` | Get all app settings as `{key: value}` dict |
| PUT | `/api/settings` | Update settings (body: `{key: value, ...}`) | | 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] **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] **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 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] **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] **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 - [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 - 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 - 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 - 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 - 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 - Recent chats section on home page with quick "New Chat" button
- HTML entity rendering fix (apostrophes in marked → DOMPurify pipeline) - HTML entity rendering fix (apostrophes in marked → DOMPurify pipeline)