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:
2026-02-11 14:36:30 -05:00
parent db01106714
commit cbfdf5289e
49 changed files with 3105 additions and 369 deletions
+47
View File
@@ -0,0 +1,47 @@
import json
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import admin_required, login_required, get_current_user_id
from fabledassistant.services.backup import (
export_full_backup,
export_user_backup,
restore_full_backup,
)
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
@admin_bp.route("/backup", methods=["GET"])
@login_required
async def backup():
uid = get_current_user_id()
scope = request.args.get("scope", "user")
if scope == "full":
# Full backup requires admin
from quart import g
if g.user.role != "admin":
return jsonify({"error": "Admin access required for full backup"}), 403
data = await export_full_backup()
else:
data = await export_user_backup(uid)
return Response(
json.dumps(data, indent=2, default=str),
content_type="application/json",
headers={
"Content-Disposition": f'attachment; filename="fabled-backup-{scope}.json"',
},
)
@admin_bp.route("/restore", methods=["POST"])
@admin_required
async def restore():
data = await request.get_json()
if not data:
return jsonify({"error": "No backup data provided"}), 400
stats = await restore_full_backup(data)
return jsonify({"status": "ok", "stats": stats})
+70
View File
@@ -0,0 +1,70 @@
from quart import Blueprint, jsonify, request, session
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.auth import (
authenticate,
create_user,
get_user_by_id,
get_user_count,
)
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
@auth_bp.route("/register", methods=["POST"])
async def register():
data = await request.get_json()
username = (data.get("username") or "").strip()
password = data.get("password") or ""
email = (data.get("email") or "").strip() or None
if not username:
return jsonify({"error": "Username is required"}), 400
if len(password) < 8:
return jsonify({"error": "Password must be at least 8 characters"}), 400
try:
user = await create_user(username, password, email)
except Exception:
return jsonify({"error": "Username already taken"}), 409
session["user_id"] = user.id
return jsonify(user.to_dict()), 201
@auth_bp.route("/login", methods=["POST"])
async def login():
data = await request.get_json()
username = (data.get("username") or "").strip()
password = data.get("password") or ""
if not username or not password:
return jsonify({"error": "Username and password are required"}), 400
user = await authenticate(username, password)
if not user:
return jsonify({"error": "Invalid username or password"}), 401
session["user_id"] = user.id
return jsonify(user.to_dict())
@auth_bp.route("/logout", methods=["POST"])
async def logout():
session.clear()
return jsonify({"status": "ok"})
@auth_bp.route("/me", methods=["GET"])
@login_required
async def me():
user = await get_user_by_id(get_current_user_id())
if not user:
return jsonify({"error": "User not found"}), 404
return jsonify(user.to_dict())
@auth_bp.route("/status", methods=["GET"])
async def status():
count = await get_user_count()
return jsonify({"has_users": count > 0})
+115 -42
View File
@@ -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()
+34 -10
View File
@@ -2,6 +2,7 @@ from datetime import date
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.notes import (
convert_note_to_task,
convert_task_to_note,
@@ -21,7 +22,9 @@ notes_bp = Blueprint("notes", __name__, url_prefix="/api/notes")
@notes_bp.route("", methods=["GET"])
@login_required
async def list_notes_route():
uid = get_current_user_id()
q = request.args.get("q")
tag = request.args.getlist("tag")
sort = request.args.get("sort", "updated_at")
@@ -37,13 +40,15 @@ async def list_notes_route():
is_task = True
notes, total = await list_notes(
q=q, tags=tag or None, is_task=is_task, sort=sort, order=order, limit=limit, offset=offset
uid, q=q, tags=tag or None, is_task=is_task, sort=sort, order=order, limit=limit, offset=offset
)
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
@notes_bp.route("", methods=["POST"])
@login_required
async def create_note_route():
uid = get_current_user_id()
data = await request.get_json()
body = data.get("body", "")
tags = extract_tags(body)
@@ -56,6 +61,7 @@ async def create_note_route():
due_date = date.fromisoformat(data["due_date"])
note = await create_note(
uid,
title=data.get("title", ""),
body=body,
tags=tags,
@@ -68,43 +74,53 @@ async def create_note_route():
@notes_bp.route("/tags", methods=["GET"])
@login_required
async def list_tags_route():
uid = get_current_user_id()
q = request.args.get("q")
tags = await get_all_tags(q=q)
tags = await get_all_tags(uid, q=q)
return jsonify({"tags": tags})
@notes_bp.route("/by-title", methods=["GET"])
@login_required
async def get_note_by_title_route():
uid = get_current_user_id()
title = request.args.get("title", "")
if not title:
return jsonify({"error": "title parameter is required"}), 400
note = await get_note_by_title(title)
note = await get_note_by_title(uid, title)
if note is None:
return jsonify({"error": "Note not found"}), 404
return jsonify(note.to_dict())
@notes_bp.route("/resolve-title", methods=["POST"])
@login_required
async def resolve_title_route():
uid = get_current_user_id()
data = await request.get_json()
title = data.get("title", "").strip()
if not title:
return jsonify({"error": "title is required"}), 400
note = await get_or_create_note_by_title(title)
note = await get_or_create_note_by_title(uid, title)
return jsonify(note.to_dict())
@notes_bp.route("/<int:note_id>", methods=["GET"])
@login_required
async def get_note_route(note_id: int):
note = await get_note(note_id)
uid = get_current_user_id()
note = await get_note(uid, note_id)
if note is None:
return jsonify({"error": "Note not found"}), 404
return jsonify(note.to_dict())
@notes_bp.route("/<int:note_id>", methods=["PUT"])
@login_required
async def update_note_route(note_id: int):
uid = get_current_user_id()
data = await request.get_json()
fields = {}
for key in ("title", "body", "parent_id", "status", "priority"):
@@ -118,39 +134,47 @@ async def update_note_route(note_id: int):
if "body" in fields:
fields["tags"] = extract_tags(fields["body"])
note = await update_note(note_id, **fields)
note = await update_note(uid, note_id, **fields)
if note is None:
return jsonify({"error": "Note not found"}), 404
return jsonify(note.to_dict())
@notes_bp.route("/<int:note_id>", methods=["DELETE"])
@login_required
async def delete_note_route(note_id: int):
deleted = await delete_note(note_id)
uid = get_current_user_id()
deleted = await delete_note(uid, note_id)
if not deleted:
return jsonify({"error": "Note not found"}), 404
return "", 204
@notes_bp.route("/<int:note_id>/convert-to-task", methods=["POST"])
@login_required
async def convert_note_to_task_route(note_id: int):
uid = get_current_user_id()
try:
note = await convert_note_to_task(note_id)
note = await convert_note_to_task(uid, note_id)
return jsonify(note.to_dict()), 200
except ValueError as e:
return jsonify({"error": str(e)}), 404
@notes_bp.route("/<int:note_id>/convert-to-note", methods=["POST"])
@login_required
async def convert_task_to_note_route(note_id: int):
uid = get_current_user_id()
try:
note = await convert_task_to_note(note_id)
note = await convert_task_to_note(uid, note_id)
return jsonify(note.to_dict()), 200
except ValueError as e:
return jsonify({"error": str(e)}), 404
@notes_bp.route("/<int:note_id>/backlinks", methods=["GET"])
@login_required
async def get_backlinks_route(note_id: int):
links = await get_backlinks(note_id)
uid = get_current_user_id()
links = await get_backlinks(uid, note_id)
return jsonify({"backlinks": links})
+8 -3
View File
@@ -1,5 +1,6 @@
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.llm import get_installed_models
from fabledassistant.services.settings import get_all_settings, set_settings_batch
@@ -7,13 +8,17 @@ settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
@settings_bp.route("", methods=["GET"])
@login_required
async def get_settings_route():
settings = await get_all_settings()
uid = get_current_user_id()
settings = await get_all_settings(uid)
return jsonify(settings)
@settings_bp.route("", methods=["PUT"])
@login_required
async def update_settings_route():
uid = get_current_user_id()
data = await request.get_json()
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
@@ -24,6 +29,6 @@ async def update_settings_route():
if installed and model not in installed:
return jsonify({"error": f"Model '{model}' is not installed"}), 400
await set_settings_batch({k: str(v) for k, v in data.items()})
settings = await get_all_settings()
await set_settings_batch(uid, {k: str(v) for k, v in data.items()})
settings = await get_all_settings(uid)
return jsonify(settings)
+19 -4
View File
@@ -2,6 +2,7 @@ from datetime import date
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.models.note import TaskPriority, TaskStatus
from fabledassistant.services.notes import (
create_note,
@@ -16,7 +17,9 @@ tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
@tasks_bp.route("", methods=["GET"])
@login_required
async def list_tasks_route():
uid = get_current_user_id()
q = request.args.get("q")
tag = request.args.getlist("tag")
status = request.args.get("status")
@@ -27,6 +30,7 @@ async def list_tasks_route():
offset = request.args.get("offset", 0, type=int)
tasks, total = await list_notes(
uid,
q=q,
tags=tag or None,
is_task=True,
@@ -41,7 +45,9 @@ async def list_tasks_route():
@tasks_bp.route("", methods=["POST"])
@login_required
async def create_task_route():
uid = get_current_user_id()
data = await request.get_json()
body = data.get("body", "") or data.get("description", "")
tags = extract_tags(body)
@@ -56,6 +62,7 @@ async def create_task_route():
)
task = await create_note(
uid,
title=data.get("title", ""),
body=body,
status=status,
@@ -67,15 +74,19 @@ async def create_task_route():
@tasks_bp.route("/<int:task_id>", methods=["GET"])
@login_required
async def get_task_route(task_id: int):
task = await get_note(task_id)
uid = get_current_user_id()
task = await get_note(uid, task_id)
if task is None:
return jsonify({"error": "Task not found"}), 404
return jsonify(task.to_dict())
@tasks_bp.route("/<int:task_id>", methods=["PUT"])
@login_required
async def update_task_route(task_id: int):
uid = get_current_user_id()
data = await request.get_json()
fields = {}
for key in ("title", "status", "priority"):
@@ -96,14 +107,16 @@ async def update_task_route(task_id: int):
if "body" in fields:
fields["tags"] = extract_tags(fields["body"])
task = await update_note(task_id, **fields)
task = await update_note(uid, task_id, **fields)
if task is None:
return jsonify({"error": "Task not found"}), 404
return jsonify(task.to_dict())
@tasks_bp.route("/<int:task_id>/status", methods=["PATCH"])
@login_required
async def patch_task_status(task_id: int):
uid = get_current_user_id()
data = await request.get_json()
status_val = data.get("status")
if not status_val:
@@ -114,15 +127,17 @@ async def patch_task_status(task_id: int):
except ValueError:
return jsonify({"error": f"Invalid status: {status_val}"}), 400
task = await update_note(task_id, status=status_val)
task = await update_note(uid, task_id, status=status_val)
if task is None:
return jsonify({"error": "Task not found"}), 404
return jsonify(task.to_dict())
@tasks_bp.route("/<int:task_id>", methods=["DELETE"])
@login_required
async def delete_task_route(task_id: int):
deleted = await delete_note(task_id)
uid = get_current_user_id()
deleted = await delete_note(uid, task_id)
if not deleted:
return jsonify({"error": "Task not found"}), 404
return "", 204