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:
@@ -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
|
||||
Reference in New Issue
Block a user