Add LLM chat integration with streaming responses via Ollama

Phase 4: Full chat system with SSE streaming, note-aware context, and
conversation persistence.

Backend:
- Migration 0005: conversations + messages tables with FKs and indexes
- Conversation/Message SQLAlchemy models with relationships
- LLM service: ensure_model (auto-pull on startup), stream_chat (NDJSON),
  generate_completion, fetch_url_content (HTML stripping), build_context
  (keyword extraction, related note search, URL content injection)
- Chat service: conversation CRUD, save_response_as_note,
  summarize_conversation_as_note
- Chat routes blueprint: 9 endpoints including SSE streaming for messages,
  save/summarize as note, Ollama model listing
- Auto-pull llama3.1 model on app startup (non-blocking)

Frontend:
- apiStreamPost: SSE client using fetch + ReadableStream
- Chat Pinia store with streaming state management
- ChatView: dedicated /chat page with conversation sidebar + message thread
- ChatPanel: slide-out panel with contextNoteId from current route
- ChatMessage: markdown-rendered message bubble with "Save as Note" action
- Updated AppHeader with Chat nav link + panel toggle button
- Updated App.vue to mount ChatPanel with route-derived context
- Added /chat and /chat/:id routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 18:45:22 -05:00
parent 807cde30be
commit d2b8ab8fe8
19 changed files with 1906 additions and 35 deletions
+175
View File
@@ -0,0 +1,175 @@
import json
import logging
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
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 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 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("/models", methods=["GET"])
async def list_models_route():
import httpx
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