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