39bcd7a8fa
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>
263 lines
9.3 KiB
Python
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
|