Add multi-user auth, background generation, and chat UX improvements
Phase 5: Multi-user authentication with session cookies, bcrypt passwords, first-user-is-admin pattern, per-user data isolation, backup/restore, Docker Swarm production stack with secrets and network isolation. Phase 5.1: Chat UX improvements: - Background generation architecture (GenerationBuffer + asyncio task) with SSE fan-out, reconnect support, and periodic DB flushes - LLM-generated conversation titles (first exchange + every 10th message) - Stop generation button with cancel_event and partial content preservation - Relative timestamps in sidebar (5m ago, 3h ago, then dates) - Empty chat auto-cleanup on navigation away - Save-as-note uses LLM for title generation, tags notes with "chat" - Summarize-as-note also tags with "chat" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from quart import Blueprint, Response, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.chat import (
|
||||
add_message,
|
||||
@@ -15,7 +17,13 @@ from fabledassistant.services.chat import (
|
||||
summarize_conversation_as_note,
|
||||
update_conversation_title,
|
||||
)
|
||||
from fabledassistant.services.llm import build_context, stream_chat
|
||||
from fabledassistant.services.generation_buffer import (
|
||||
GenerationState,
|
||||
create_buffer,
|
||||
get_buffer,
|
||||
)
|
||||
from fabledassistant.services.generation_task import run_generation
|
||||
from fabledassistant.services.llm import build_context
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,10 +32,12 @@ chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat")
|
||||
|
||||
|
||||
@chat_bp.route("/conversations", methods=["GET"])
|
||||
@login_required
|
||||
async def list_conversations_route():
|
||||
uid = get_current_user_id()
|
||||
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)
|
||||
conversations, total = await list_conversations(uid, limit=limit, offset=offset)
|
||||
return jsonify({
|
||||
"conversations": conversations,
|
||||
"total": total,
|
||||
@@ -35,17 +45,21 @@ async def list_conversations_route():
|
||||
|
||||
|
||||
@chat_bp.route("/conversations", methods=["POST"])
|
||||
@login_required
|
||||
async def create_conversation_route():
|
||||
uid = get_current_user_id()
|
||||
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)
|
||||
conv = await create_conversation(uid, title=title, model=model)
|
||||
return jsonify(conv.to_dict()), 201
|
||||
|
||||
|
||||
@chat_bp.route("/conversations/<int:conv_id>", methods=["GET"])
|
||||
@login_required
|
||||
async def get_conversation_route(conv_id: int):
|
||||
conv = await get_conversation(conv_id)
|
||||
uid = get_current_user_id()
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
result = conv.to_dict()
|
||||
@@ -54,29 +68,35 @@ async def get_conversation_route(conv_id: int):
|
||||
|
||||
|
||||
@chat_bp.route("/conversations/<int:conv_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def delete_conversation_route(conv_id: int):
|
||||
deleted = await delete_conversation(conv_id)
|
||||
uid = get_current_user_id()
|
||||
deleted = await delete_conversation(uid, conv_id)
|
||||
if not deleted:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@chat_bp.route("/conversations/<int:conv_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_conversation_route(conv_id: int):
|
||||
uid = get_current_user_id()
|
||||
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)
|
||||
conv = await update_conversation_title(uid, 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"])
|
||||
@login_required
|
||||
async def send_message_route(conv_id: int):
|
||||
"""Send a user message, stream the LLM response via SSE."""
|
||||
conv = await get_conversation(conv_id)
|
||||
"""Start generation: save user message, launch background task, return 202."""
|
||||
uid = get_current_user_id()
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
|
||||
@@ -87,10 +107,20 @@ async def send_message_route(conv_id: int):
|
||||
context_note_id = data.get("context_note_id")
|
||||
exclude_note_ids = data.get("exclude_note_ids") or []
|
||||
|
||||
# Reject if generation already running for this conversation
|
||||
existing = get_buffer(conv_id)
|
||||
if existing and existing.state == GenerationState.RUNNING:
|
||||
return jsonify({"error": "Generation already in progress"}), 409
|
||||
|
||||
# Save user message
|
||||
await add_message(conv_id, "user", content, context_note_id=context_note_id)
|
||||
|
||||
# Build history from existing messages (excluding system)
|
||||
# Create placeholder assistant message
|
||||
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
|
||||
|
||||
buf = create_buffer(conv_id, assistant_msg.id)
|
||||
|
||||
# Build history from existing messages (excluding system and the placeholder)
|
||||
history = []
|
||||
for msg in conv.messages:
|
||||
if msg.role != "system":
|
||||
@@ -98,43 +128,60 @@ async def send_message_route(conv_id: int):
|
||||
|
||||
# Build context with note search, URL fetching, etc.
|
||||
messages, context_meta = await build_context(
|
||||
history, context_note_id, content, exclude_note_ids=exclude_note_ids
|
||||
uid, history, context_note_id, content, exclude_note_ids=exclude_note_ids
|
||||
)
|
||||
|
||||
model = conv.model or await get_setting("default_model", Config.OLLAMA_MODEL)
|
||||
model = conv.model or await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
async def generate():
|
||||
# Emit context metadata before streaming LLM response
|
||||
context_event = json.dumps({"context": context_meta})
|
||||
yield f"data: {context_event}\n\n"
|
||||
# Launch background generation task
|
||||
asyncio.create_task(run_generation(
|
||||
buf, messages, model, context_meta,
|
||||
uid, conv_id, conv.title, content,
|
||||
))
|
||||
|
||||
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"
|
||||
return jsonify({
|
||||
"assistant_message_id": assistant_msg.id,
|
||||
"status": "generating",
|
||||
}), 202
|
||||
|
||||
# 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)
|
||||
@chat_bp.route("/conversations/<int:conv_id>/generation/stream", methods=["GET"])
|
||||
@login_required
|
||||
async def generation_stream_route(conv_id: int):
|
||||
"""SSE endpoint that tails the generation buffer for a conversation."""
|
||||
uid = get_current_user_id()
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
|
||||
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"
|
||||
buf = get_buffer(conv_id)
|
||||
if buf is None:
|
||||
return jsonify({"error": "No active generation"}), 404
|
||||
|
||||
# Determine starting point from Last-Event-ID header or query param
|
||||
last_id_str = request.headers.get("Last-Event-ID") or request.args.get("last_event_id")
|
||||
last_id = int(last_id_str) if last_id_str is not None else -1
|
||||
|
||||
async def stream():
|
||||
cursor = last_id
|
||||
while True:
|
||||
# Replay any buffered events past the cursor
|
||||
pending = buf.events_after(cursor)
|
||||
for event in pending:
|
||||
yield buf.format_sse(event)
|
||||
cursor = event.index
|
||||
|
||||
# If generation is done and all events delivered, close stream
|
||||
if buf.state != GenerationState.RUNNING:
|
||||
break
|
||||
|
||||
# Wait for new events or send keepalive on timeout
|
||||
got_new = await buf.wait_for_event(cursor, timeout=15.0)
|
||||
if not got_new:
|
||||
yield ": keepalive\n\n"
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
stream(),
|
||||
content_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
@@ -143,32 +190,55 @@ async def send_message_route(conv_id: int):
|
||||
)
|
||||
|
||||
|
||||
@chat_bp.route("/conversations/<int:conv_id>/generation/cancel", methods=["POST"])
|
||||
@login_required
|
||||
async def cancel_generation_route(conv_id: int):
|
||||
"""Cancel an active generation for a conversation."""
|
||||
uid = get_current_user_id()
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
|
||||
buf = get_buffer(conv_id)
|
||||
if buf is None or buf.state != GenerationState.RUNNING:
|
||||
return jsonify({"error": "No active generation"}), 404
|
||||
|
||||
buf.cancel_event.set()
|
||||
return jsonify({"status": "cancelled"})
|
||||
|
||||
|
||||
@chat_bp.route("/messages/<int:message_id>/save-as-note", methods=["POST"])
|
||||
@login_required
|
||||
async def save_message_as_note_route(message_id: int):
|
||||
uid = get_current_user_id()
|
||||
try:
|
||||
note = await save_response_as_note(message_id)
|
||||
note = await save_response_as_note(uid, 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"])
|
||||
@login_required
|
||||
async def summarize_conversation_route(conv_id: int):
|
||||
conv = await get_conversation(conv_id)
|
||||
uid = get_current_user_id()
|
||||
conv = await get_conversation(uid, 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)
|
||||
model = conv.model or await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
|
||||
try:
|
||||
note = await summarize_conversation_as_note(conv_id, model)
|
||||
note = await summarize_conversation_as_note(uid, conv_id, model)
|
||||
return jsonify(note), 201
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@chat_bp.route("/status", methods=["GET"])
|
||||
@login_required
|
||||
async def chat_status_route():
|
||||
"""Check Ollama availability and model readiness."""
|
||||
default_model = await get_setting("default_model", Config.OLLAMA_MODEL)
|
||||
uid = get_current_user_id()
|
||||
default_model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
|
||||
result = {
|
||||
"ollama": "unavailable",
|
||||
"model": "not_found",
|
||||
@@ -190,6 +260,7 @@ async def chat_status_route():
|
||||
|
||||
|
||||
@chat_bp.route("/models", methods=["GET"])
|
||||
@login_required
|
||||
async def list_models_route():
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
@@ -207,6 +278,7 @@ async def list_models_route():
|
||||
|
||||
|
||||
@chat_bp.route("/models/pull", methods=["POST"])
|
||||
@login_required
|
||||
async def pull_model_route():
|
||||
"""Pull a model from Ollama, streaming progress via SSE."""
|
||||
data = await request.get_json()
|
||||
@@ -245,6 +317,7 @@ async def pull_model_route():
|
||||
|
||||
|
||||
@chat_bp.route("/models/delete", methods=["POST"])
|
||||
@login_required
|
||||
async def delete_model_route():
|
||||
"""Delete a model from Ollama."""
|
||||
data = await request.get_json()
|
||||
|
||||
Reference in New Issue
Block a user