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 { 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<string, unknown>) => void,
) {
await apiStreamPost("/api/chat/models/pull", { model }, (data) => {
if (onProgress) onProgress(data);
});
}
async function deleteModel(model: string) {
+24 -30
View File
@@ -7,7 +7,7 @@ const store = useSettingsStore();
const assistantName = ref("");
const saving = 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 confirmDelete = ref<string | null>(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" }}
</button>
</div>
</div>
+30 -5
View File
@@ -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"])
+8 -7
View File
@@ -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)