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
+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"])