Files
FabledScribe/src/fabledassistant/routes/chat.py
T
bvandeusen 39bcd7a8fa 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>
2026-02-10 22:34:58 -05:00

263 lines
9.3 KiB
Python

import json
import logging
import httpx
from quart import Blueprint, Response, jsonify, request
from fabledassistant.config import Config
from fabledassistant.services.chat import (
add_message,
create_conversation,
delete_conversation,
get_conversation,
list_conversations,
save_response_as_note,
summarize_conversation_as_note,
update_conversation_title,
)
from fabledassistant.services.llm import build_context, stream_chat
from fabledassistant.services.settings import get_setting
logger = logging.getLogger(__name__)
chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat")
@chat_bp.route("/conversations", methods=["GET"])
async def list_conversations_route():
limit = request.args.get("limit", 50, type=int)
offset = request.args.get("offset", 0, type=int)
conversations, total = await list_conversations(limit=limit, offset=offset)
return jsonify({
"conversations": [c.to_dict() for c in conversations],
"total": total,
})
@chat_bp.route("/conversations", methods=["POST"])
async def create_conversation_route():
data = await request.get_json(force=True, silent=True) or {}
title = data.get("title", "")
model = data.get("model", Config.OLLAMA_MODEL)
conv = await create_conversation(title=title, model=model)
return jsonify(conv.to_dict()), 201
@chat_bp.route("/conversations/<int:conv_id>", methods=["GET"])
async def get_conversation_route(conv_id: int):
conv = await get_conversation(conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
result = conv.to_dict()
result["messages"] = [m.to_dict() for m in conv.messages]
return jsonify(result)
@chat_bp.route("/conversations/<int:conv_id>", methods=["DELETE"])
async def delete_conversation_route(conv_id: int):
deleted = await delete_conversation(conv_id)
if not deleted:
return jsonify({"error": "Conversation not found"}), 404
return "", 204
@chat_bp.route("/conversations/<int:conv_id>", methods=["PATCH"])
async def update_conversation_route(conv_id: int):
data = await request.get_json()
title = data.get("title")
if title is None:
return jsonify({"error": "title is required"}), 400
conv = await update_conversation_title(conv_id, title)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return jsonify(conv.to_dict())
@chat_bp.route("/conversations/<int:conv_id>/messages", methods=["POST"])
async def send_message_route(conv_id: int):
"""Send a user message, stream the LLM response via SSE."""
conv = await get_conversation(conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
data = await request.get_json()
content = data.get("content", "").strip()
if not content:
return jsonify({"error": "content is required"}), 400
context_note_id = data.get("context_note_id")
# Save user message
await add_message(conv_id, "user", content, context_note_id=context_note_id)
# Build history from existing messages (excluding system)
history = []
for msg in conv.messages:
if msg.role != "system":
history.append({"role": msg.role, "content": msg.content})
# Build context with note search, URL fetching, etc.
messages = await build_context(history, context_note_id, content)
model = conv.model or await get_setting("default_model", Config.OLLAMA_MODEL)
async def generate():
full_response = []
try:
async for chunk in stream_chat(messages, model):
full_response.append(chunk)
event_data = json.dumps({"chunk": chunk})
yield f"data: {event_data}\n\n"
# Save complete assistant message
full_text = "".join(full_response)
msg = await add_message(conv_id, "assistant", full_text)
# Auto-generate title from first user message if conversation has no title
if not conv.title:
title = content[:80]
if len(content) > 80:
title += "..."
await update_conversation_title(conv_id, title)
done_data = json.dumps({"done": True, "message_id": msg.id})
yield f"data: {done_data}\n\n"
except Exception as e:
logger.exception("Error streaming LLM response")
error_data = json.dumps({"error": str(e)})
yield f"data: {error_data}\n\n"
return Response(
generate(),
content_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@chat_bp.route("/messages/<int:message_id>/save-as-note", methods=["POST"])
async def save_message_as_note_route(message_id: int):
try:
note = await save_response_as_note(message_id)
return jsonify(note), 201
except ValueError as e:
return jsonify({"error": str(e)}), 400
@chat_bp.route("/conversations/<int:conv_id>/summarize", methods=["POST"])
async def summarize_conversation_route(conv_id: int):
conv = await get_conversation(conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
model = conv.model or await get_setting("default_model", Config.OLLAMA_MODEL)
try:
note = await summarize_conversation_as_note(conv_id, model)
return jsonify(note), 201
except ValueError as e:
return jsonify({"error": str(e)}), 400
@chat_bp.route("/status", methods=["GET"])
async def chat_status_route():
"""Check Ollama availability and model readiness."""
default_model = await get_setting("default_model", Config.OLLAMA_MODEL)
result = {
"ollama": "unavailable",
"model": "not_found",
"default_model": default_model,
}
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
resp.raise_for_status()
result["ollama"] = "available"
data = resp.json()
model_names = [m["name"] for m in data.get("models", [])]
# Match with or without :latest tag
if default_model in model_names or f"{default_model}:latest" in model_names:
result["model"] = "ready"
except Exception:
logger.debug("Ollama status check failed", exc_info=True)
return jsonify(result)
@chat_bp.route("/models", methods=["GET"])
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})
except Exception as e:
logger.warning("Failed to list Ollama models: %s", e)
return jsonify({"models": [], "error": str(e)}), 200
@chat_bp.route("/models/pull", methods=["POST"])
async def pull_model_route():
"""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
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"])
async def delete_model_route():
"""Delete a model from Ollama."""
data = await request.get_json()
model_name = data.get("model", "").strip()
if not model_name:
return jsonify({"error": "model is required"}), 400
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.request(
"DELETE",
f"{Config.OLLAMA_URL}/api/delete",
json={"name": model_name},
)
resp.raise_for_status()
return jsonify({"status": "deleted", "model": model_name})
except httpx.HTTPStatusError as e:
logger.warning("Failed to delete model %s: %s", model_name, e)
return jsonify({"error": f"Failed to delete model: {e.response.status_code}"}), 400
except Exception as e:
logger.warning("Failed to delete model %s: %s", model_name, e)
return jsonify({"error": str(e)}), 500