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,10 +1,13 @@
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Quart, jsonify, make_response, request, send_from_directory
|
||||
from quart import Quart, g, jsonify, make_response, request, send_from_directory
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.routes.admin import admin_bp
|
||||
from fabledassistant.routes.api import api
|
||||
from fabledassistant.routes.auth import auth_bp
|
||||
from fabledassistant.routes.chat import chat_bp
|
||||
from fabledassistant.routes.notes import notes_bp
|
||||
from fabledassistant.routes.settings import settings_bp
|
||||
@@ -15,21 +18,49 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_app() -> Quart:
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
level=getattr(logging, Config.LOG_LEVEL.upper(), logging.INFO),
|
||||
)
|
||||
|
||||
app = Quart(__name__, static_folder=None)
|
||||
app.secret_key = Config.SECRET_KEY
|
||||
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(api)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(chat_bp)
|
||||
app.register_blueprint(notes_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(tasks_bp)
|
||||
|
||||
@app.before_request
|
||||
async def before_request():
|
||||
g.request_start = time.monotonic()
|
||||
|
||||
@app.after_request
|
||||
async def after_request(response):
|
||||
duration = time.monotonic() - getattr(g, "request_start", time.monotonic())
|
||||
duration_ms = round(duration * 1000, 1)
|
||||
logger.info(
|
||||
"%s %s %s %.1fms",
|
||||
request.method,
|
||||
request.path,
|
||||
response.status_code,
|
||||
duration_ms,
|
||||
)
|
||||
return response
|
||||
|
||||
@app.before_serving
|
||||
async def startup():
|
||||
import asyncio
|
||||
|
||||
from fabledassistant.services.generation_buffer import start_cleanup_loop
|
||||
from fabledassistant.services.llm import ensure_model
|
||||
|
||||
start_cleanup_loop()
|
||||
|
||||
async def _pull_model():
|
||||
try:
|
||||
await ensure_model(Config.OLLAMA_MODEL)
|
||||
@@ -70,11 +101,9 @@ def create_app() -> Quart:
|
||||
|
||||
@app.errorhandler(500)
|
||||
async def handle_500(error):
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
logger.exception("Internal server error on %s %s", request.method, request.path)
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify({"error": str(error)}), 500
|
||||
return jsonify({"error": "Internal server error"}), 500
|
||||
return "Internal Server Error", 500
|
||||
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import functools
|
||||
|
||||
from quart import g, jsonify, session
|
||||
|
||||
from fabledassistant.services.auth import get_user_by_id
|
||||
|
||||
|
||||
def login_required(f):
|
||||
@functools.wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
user_id = session.get("user_id")
|
||||
if not user_id:
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
user = await get_user_by_id(user_id)
|
||||
if not user:
|
||||
session.clear()
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
g.user = user
|
||||
return await f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
@functools.wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
user_id = session.get("user_id")
|
||||
if not user_id:
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
user = await get_user_by_id(user_id)
|
||||
if not user:
|
||||
session.clear()
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
if user.role != "admin":
|
||||
return jsonify({"error": "Admin access required"}), 403
|
||||
g.user = user
|
||||
return await f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def get_current_user_id() -> int:
|
||||
return g.user.id
|
||||
@@ -1,11 +1,28 @@
|
||||
import os
|
||||
|
||||
|
||||
def _read_secret(env_var: str, secret_file_var: str, default: str) -> str:
|
||||
"""Read a config value from env var, or from a Docker secrets file."""
|
||||
value = os.environ.get(env_var)
|
||||
if value:
|
||||
return value
|
||||
secret_file = os.environ.get(secret_file_var)
|
||||
if secret_file:
|
||||
try:
|
||||
with open(secret_file) as f:
|
||||
return f.read().strip()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
class Config:
|
||||
DATABASE_URL: str = os.environ.get(
|
||||
DATABASE_URL: str = _read_secret(
|
||||
"DATABASE_URL",
|
||||
"DATABASE_URL_FILE",
|
||||
"postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant",
|
||||
)
|
||||
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "llama3.1")
|
||||
SECRET_KEY: str = os.environ.get("SECRET_KEY", "dev-secret-change-me")
|
||||
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
|
||||
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
|
||||
|
||||
@@ -14,3 +14,4 @@ class Base(DeclarativeBase):
|
||||
from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
|
||||
from fabledassistant.models.conversation import Conversation, Message # noqa: E402, F401
|
||||
from fabledassistant.models.setting import Setting # noqa: E402, F401
|
||||
from fabledassistant.models.user import User # noqa: E402, F401
|
||||
|
||||
@@ -11,6 +11,9 @@ class Conversation(Base):
|
||||
__tablename__ = "conversations"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
model: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
@@ -30,6 +33,7 @@ class Conversation(Base):
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_conversations_updated_at", "updated_at"),
|
||||
Index("ix_conversations_user_id", "user_id"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
@@ -57,6 +61,7 @@ class Message(Base):
|
||||
)
|
||||
role: Mapped[str] = mapped_column(Text)
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
status: Mapped[str] = mapped_column(Text, default="complete")
|
||||
context_note_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
@@ -76,6 +81,7 @@ class Message(Base):
|
||||
"conversation_id": self.conversation_id,
|
||||
"role": self.role,
|
||||
"content": self.content,
|
||||
"status": self.status,
|
||||
"context_note_id": self.context_note_id,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import enum
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import Date, DateTime, Index, Text
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -25,6 +25,9 @@ class Note(Base):
|
||||
__tablename__ = "notes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
|
||||
@@ -47,6 +50,7 @@ class Note(Base):
|
||||
Index("ix_notes_tags", "tags", postgresql_using="gin"),
|
||||
Index("ix_notes_status", "status"),
|
||||
Index("ix_notes_title", "title"),
|
||||
Index("ix_notes_user_id", "user_id"),
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Text
|
||||
from sqlalchemy import ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
@@ -7,6 +7,9 @@ from fabledassistant.models import Base
|
||||
class Setting(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
key: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, Index, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
email: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
role: Mapped[str] = mapped_column(Text, nullable=False, default="user")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_users_username", "username"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"username": self.username,
|
||||
"email": self.email,
|
||||
"role": self.role,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import logging
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import func, select, update
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Conversation
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.setting import Setting
|
||||
from fabledassistant.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
return bcrypt.checkpw(password.encode(), password_hash.encode())
|
||||
|
||||
|
||||
async def get_user_count() -> int:
|
||||
async with async_session() as session:
|
||||
return await session.scalar(select(func.count(User.id))) or 0
|
||||
|
||||
|
||||
async def create_user(
|
||||
username: str, password: str, email: str | None = None
|
||||
) -> User:
|
||||
user_count = await get_user_count()
|
||||
role = "admin" if user_count == 0 else "user"
|
||||
|
||||
async with async_session() as session:
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
# First user claims all pre-existing data (migration assigns user_id=1,
|
||||
# which matches SERIAL first insert; also handles any NULLs)
|
||||
if role == "admin":
|
||||
await session.execute(
|
||||
update(Note)
|
||||
.where((Note.user_id.is_(None)) | (Note.user_id == user.id))
|
||||
.values(user_id=user.id)
|
||||
)
|
||||
await session.execute(
|
||||
update(Conversation)
|
||||
.where(
|
||||
(Conversation.user_id.is_(None))
|
||||
| (Conversation.user_id == user.id)
|
||||
)
|
||||
.values(user_id=user.id)
|
||||
)
|
||||
await session.execute(
|
||||
update(Setting)
|
||||
.where(Setting.user_id == user.id)
|
||||
.values(user_id=user.id)
|
||||
)
|
||||
await session.commit()
|
||||
logger.info("First user '%s' created as admin, claimed orphaned data", username)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def authenticate(username: str, password: str) -> User | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
user = result.scalars().first()
|
||||
if user and verify_password(password, user.password_hash):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
async def get_user_by_id(user_id: int) -> User | None:
|
||||
async with async_session() as session:
|
||||
return await session.get(User, user_id)
|
||||
@@ -0,0 +1,255 @@
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.setting import Setting
|
||||
from fabledassistant.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def export_full_backup() -> dict:
|
||||
"""Export all users, notes, conversations+messages, and settings as JSON."""
|
||||
async with async_session() as session:
|
||||
users = (await session.execute(select(User))).scalars().all()
|
||||
notes = (await session.execute(select(Note))).scalars().all()
|
||||
conversations = (
|
||||
await session.execute(
|
||||
select(Conversation).options(selectinload(Conversation.messages))
|
||||
)
|
||||
).scalars().all()
|
||||
settings = (await session.execute(select(Setting))).scalars().all()
|
||||
|
||||
return {
|
||||
"version": 1,
|
||||
"scope": "full",
|
||||
"users": [
|
||||
{
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"password_hash": u.password_hash,
|
||||
"role": u.role,
|
||||
"created_at": u.created_at.isoformat(),
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
"notes": [
|
||||
{
|
||||
"id": n.id,
|
||||
"user_id": n.user_id,
|
||||
"title": n.title,
|
||||
"body": n.body,
|
||||
"tags": n.tags or [],
|
||||
"parent_id": n.parent_id,
|
||||
"status": n.status,
|
||||
"priority": n.priority,
|
||||
"due_date": n.due_date.isoformat() if n.due_date else None,
|
||||
"created_at": n.created_at.isoformat(),
|
||||
"updated_at": n.updated_at.isoformat(),
|
||||
}
|
||||
for n in notes
|
||||
],
|
||||
"conversations": [
|
||||
{
|
||||
"id": c.id,
|
||||
"user_id": c.user_id,
|
||||
"title": c.title,
|
||||
"model": c.model,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"updated_at": c.updated_at.isoformat(),
|
||||
"messages": [
|
||||
{
|
||||
"id": m.id,
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
"context_note_id": m.context_note_id,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
}
|
||||
for m in c.messages
|
||||
],
|
||||
}
|
||||
for c in conversations
|
||||
],
|
||||
"settings": [
|
||||
{
|
||||
"user_id": s.user_id,
|
||||
"key": s.key,
|
||||
"value": s.value,
|
||||
}
|
||||
for s in settings
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def export_user_backup(user_id: int) -> dict:
|
||||
"""Export a single user's data as JSON."""
|
||||
async with async_session() as session:
|
||||
user = await session.get(User, user_id)
|
||||
notes = (
|
||||
await session.execute(select(Note).where(Note.user_id == user_id))
|
||||
).scalars().all()
|
||||
conversations = (
|
||||
await session.execute(
|
||||
select(Conversation)
|
||||
.options(selectinload(Conversation.messages))
|
||||
.where(Conversation.user_id == user_id)
|
||||
)
|
||||
).scalars().all()
|
||||
settings = (
|
||||
await session.execute(
|
||||
select(Setting).where(Setting.user_id == user_id)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
return {
|
||||
"version": 1,
|
||||
"scope": "user",
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
} if user else None,
|
||||
"notes": [
|
||||
{
|
||||
"id": n.id,
|
||||
"title": n.title,
|
||||
"body": n.body,
|
||||
"tags": n.tags or [],
|
||||
"parent_id": n.parent_id,
|
||||
"status": n.status,
|
||||
"priority": n.priority,
|
||||
"due_date": n.due_date.isoformat() if n.due_date else None,
|
||||
"created_at": n.created_at.isoformat(),
|
||||
"updated_at": n.updated_at.isoformat(),
|
||||
}
|
||||
for n in notes
|
||||
],
|
||||
"conversations": [
|
||||
{
|
||||
"id": c.id,
|
||||
"title": c.title,
|
||||
"model": c.model,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"updated_at": c.updated_at.isoformat(),
|
||||
"messages": [
|
||||
{
|
||||
"id": m.id,
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
"context_note_id": m.context_note_id,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
}
|
||||
for m in c.messages
|
||||
],
|
||||
}
|
||||
for c in conversations
|
||||
],
|
||||
"settings": [
|
||||
{"key": s.key, "value": s.value}
|
||||
for s in settings
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def restore_full_backup(data: dict) -> dict:
|
||||
"""Restore from a full backup JSON. Returns stats about what was restored."""
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
stats = {"users": 0, "notes": 0, "conversations": 0, "messages": 0, "settings": 0}
|
||||
|
||||
async with async_session() as session:
|
||||
# Restore users
|
||||
user_id_map: dict[int, int] = {}
|
||||
for u_data in data.get("users", []):
|
||||
old_id = u_data["id"]
|
||||
user = User(
|
||||
username=u_data["username"],
|
||||
email=u_data.get("email"),
|
||||
password_hash=u_data["password_hash"],
|
||||
role=u_data.get("role", "user"),
|
||||
created_at=datetime.fromisoformat(u_data["created_at"]) if u_data.get("created_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
user_id_map[old_id] = user.id
|
||||
stats["users"] += 1
|
||||
|
||||
# Restore notes
|
||||
note_id_map: dict[int, int] = {}
|
||||
for n_data in data.get("notes", []):
|
||||
old_id = n_data.get("id")
|
||||
mapped_user_id = user_id_map.get(n_data.get("user_id", 0))
|
||||
if mapped_user_id is None:
|
||||
continue
|
||||
due = None
|
||||
if n_data.get("due_date"):
|
||||
due = date.fromisoformat(n_data["due_date"])
|
||||
note = Note(
|
||||
user_id=mapped_user_id,
|
||||
title=n_data.get("title", ""),
|
||||
body=n_data.get("body", ""),
|
||||
tags=n_data.get("tags", []),
|
||||
parent_id=n_data.get("parent_id"),
|
||||
status=n_data.get("status"),
|
||||
priority=n_data.get("priority"),
|
||||
due_date=due,
|
||||
created_at=datetime.fromisoformat(n_data["created_at"]) if n_data.get("created_at") else datetime.now(timezone.utc),
|
||||
updated_at=datetime.fromisoformat(n_data["updated_at"]) if n_data.get("updated_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(note)
|
||||
await session.flush()
|
||||
if old_id is not None:
|
||||
note_id_map[old_id] = note.id
|
||||
stats["notes"] += 1
|
||||
|
||||
# Restore conversations + messages
|
||||
for c_data in data.get("conversations", []):
|
||||
mapped_user_id = user_id_map.get(c_data.get("user_id", 0))
|
||||
if mapped_user_id is None:
|
||||
continue
|
||||
conv = Conversation(
|
||||
user_id=mapped_user_id,
|
||||
title=c_data.get("title", ""),
|
||||
model=c_data.get("model", ""),
|
||||
created_at=datetime.fromisoformat(c_data["created_at"]) if c_data.get("created_at") else datetime.now(timezone.utc),
|
||||
updated_at=datetime.fromisoformat(c_data["updated_at"]) if c_data.get("updated_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(conv)
|
||||
await session.flush()
|
||||
stats["conversations"] += 1
|
||||
|
||||
for m_data in c_data.get("messages", []):
|
||||
msg = Message(
|
||||
conversation_id=conv.id,
|
||||
role=m_data["role"],
|
||||
content=m_data.get("content", ""),
|
||||
context_note_id=note_id_map.get(m_data.get("context_note_id")) if m_data.get("context_note_id") else None,
|
||||
created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(msg)
|
||||
stats["messages"] += 1
|
||||
|
||||
# Restore settings
|
||||
for s_data in data.get("settings", []):
|
||||
mapped_user_id = user_id_map.get(s_data.get("user_id", 0))
|
||||
if mapped_user_id is None:
|
||||
continue
|
||||
setting = Setting(
|
||||
user_id=mapped_user_id,
|
||||
key=s_data["key"],
|
||||
value=s_data.get("value", ""),
|
||||
)
|
||||
session.add(setting)
|
||||
stats["settings"] += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info("Restored backup: %s", stats)
|
||||
return stats
|
||||
@@ -12,9 +12,11 @@ from fabledassistant.services.notes import create_note
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_conversation(title: str = "", model: str = "") -> Conversation:
|
||||
async def create_conversation(
|
||||
user_id: int, title: str = "", model: str = ""
|
||||
) -> Conversation:
|
||||
async with async_session() as session:
|
||||
conv = Conversation(title=title, model=model)
|
||||
conv = Conversation(user_id=user_id, title=title, model=model)
|
||||
session.add(conv)
|
||||
await session.commit()
|
||||
# Re-fetch with messages eagerly loaded to avoid lazy-load in async context
|
||||
@@ -26,22 +28,29 @@ async def create_conversation(title: str = "", model: str = "") -> Conversation:
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_conversation(conversation_id: int) -> Conversation | None:
|
||||
async def get_conversation(
|
||||
user_id: int, conversation_id: int
|
||||
) -> Conversation | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation)
|
||||
.options(selectinload(Conversation.messages))
|
||||
.where(Conversation.id == conversation_id)
|
||||
.where(
|
||||
Conversation.id == conversation_id,
|
||||
Conversation.user_id == user_id,
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def list_conversations(
|
||||
limit: int = 50, offset: int = 0
|
||||
user_id: int, limit: int = 50, offset: int = 0
|
||||
) -> tuple[list[dict], int]:
|
||||
async with async_session() as session:
|
||||
total = await session.scalar(
|
||||
select(func.count(Conversation.id))
|
||||
select(func.count(Conversation.id)).where(
|
||||
Conversation.user_id == user_id
|
||||
)
|
||||
) or 0
|
||||
|
||||
# Subquery for message count — avoids loading all messages
|
||||
@@ -54,6 +63,7 @@ async def list_conversations(
|
||||
|
||||
result = await session.execute(
|
||||
select(Conversation, msg_count.label("message_count"))
|
||||
.where(Conversation.user_id == user_id)
|
||||
.order_by(Conversation.updated_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
@@ -75,9 +85,15 @@ async def list_conversations(
|
||||
return conversations, total
|
||||
|
||||
|
||||
async def delete_conversation(conversation_id: int) -> bool:
|
||||
async def delete_conversation(user_id: int, conversation_id: int) -> bool:
|
||||
async with async_session() as session:
|
||||
conv = await session.get(Conversation, conversation_id)
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.id == conversation_id,
|
||||
Conversation.user_id == user_id,
|
||||
)
|
||||
)
|
||||
conv = result.scalars().first()
|
||||
if conv is None:
|
||||
return False
|
||||
await session.delete(conv)
|
||||
@@ -86,10 +102,16 @@ async def delete_conversation(conversation_id: int) -> bool:
|
||||
|
||||
|
||||
async def update_conversation_title(
|
||||
conversation_id: int, title: str
|
||||
user_id: int, conversation_id: int, title: str
|
||||
) -> Conversation | None:
|
||||
async with async_session() as session:
|
||||
conv = await session.get(Conversation, conversation_id)
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.id == conversation_id,
|
||||
Conversation.user_id == user_id,
|
||||
)
|
||||
)
|
||||
conv = result.scalars().first()
|
||||
if conv is None:
|
||||
return None
|
||||
conv.title = title
|
||||
@@ -104,14 +126,18 @@ async def add_message(
|
||||
role: str,
|
||||
content: str,
|
||||
context_note_id: int | None = None,
|
||||
status: str | None = None,
|
||||
) -> Message:
|
||||
async with async_session() as session:
|
||||
msg = Message(
|
||||
kwargs: dict = dict(
|
||||
conversation_id=conversation_id,
|
||||
role=role,
|
||||
content=content,
|
||||
context_note_id=context_note_id,
|
||||
)
|
||||
if status is not None:
|
||||
kwargs["status"] = status
|
||||
msg = Message(**kwargs)
|
||||
session.add(msg)
|
||||
# Touch conversation updated_at
|
||||
conv = await session.get(Conversation, conversation_id)
|
||||
@@ -127,7 +153,7 @@ async def get_message(message_id: int) -> Message | None:
|
||||
return await session.get(Message, message_id)
|
||||
|
||||
|
||||
async def save_response_as_note(message_id: int) -> dict:
|
||||
async def save_response_as_note(user_id: int, message_id: int) -> dict:
|
||||
"""Create a note from an assistant message. Returns the new note dict."""
|
||||
msg = await get_message(message_id)
|
||||
if msg is None:
|
||||
@@ -135,20 +161,42 @@ async def save_response_as_note(message_id: int) -> dict:
|
||||
if msg.role != "assistant":
|
||||
raise ValueError("Can only save assistant messages as notes")
|
||||
|
||||
# Use first line as title, rest as body
|
||||
lines = msg.content.strip().split("\n", 1)
|
||||
title = lines[0].strip().lstrip("# ")[:100]
|
||||
body = msg.content
|
||||
conv = await get_conversation(user_id, msg.conversation_id)
|
||||
|
||||
note = await create_note(title=title, body=body)
|
||||
# Generate title via LLM using the assistant message content
|
||||
title = ""
|
||||
if conv:
|
||||
model = conv.model or "llama3.2"
|
||||
try:
|
||||
prompt_messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Generate a concise 3-8 word title for a note based on "
|
||||
"this content. Reply with ONLY the title, no quotes or "
|
||||
"punctuation."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": msg.content[:2000]},
|
||||
]
|
||||
title = await generate_completion(prompt_messages, model)
|
||||
title = title.strip().strip('"\'').strip()[:100]
|
||||
except Exception:
|
||||
logger.warning("Failed to generate note title, using fallback", exc_info=True)
|
||||
|
||||
if not title:
|
||||
lines = msg.content.strip().split("\n", 1)
|
||||
title = lines[0].strip().lstrip("# ")[:100]
|
||||
|
||||
note = await create_note(user_id, title=title, body=msg.content, tags=["chat"])
|
||||
return note.to_dict()
|
||||
|
||||
|
||||
async def summarize_conversation_as_note(
|
||||
conversation_id: int, model: str
|
||||
user_id: int, conversation_id: int, model: str
|
||||
) -> dict:
|
||||
"""Summarize a conversation using the LLM and save as a note."""
|
||||
conv = await get_conversation(conversation_id)
|
||||
conv = await get_conversation(user_id, conversation_id)
|
||||
if conv is None:
|
||||
raise ValueError("Conversation not found")
|
||||
|
||||
@@ -178,5 +226,5 @@ async def summarize_conversation_as_note(
|
||||
title = conv.title or "Conversation Summary"
|
||||
title = f"Summary: {title}"
|
||||
|
||||
note = await create_note(title=title, body=summary)
|
||||
note = await create_note(user_id, title=title, body=summary, tags=["chat"])
|
||||
return note.to_dict()
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""In-memory generation event buffer for SSE fan-out.
|
||||
|
||||
Each active generation gets a GenerationBuffer keyed by conversation_id.
|
||||
SSE clients tail the buffer and can reconnect at any point without data loss.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GenerationState(str, Enum):
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
ERRORED = "errored"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSEEvent:
|
||||
index: int
|
||||
event_type: str # "context", "chunk", "done", "error"
|
||||
data: dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenerationBuffer:
|
||||
conversation_id: int
|
||||
assistant_message_id: int
|
||||
state: GenerationState = GenerationState.RUNNING
|
||||
events: list[SSEEvent] = field(default_factory=list)
|
||||
content_so_far: str = ""
|
||||
finished_at: float | None = None
|
||||
cancel_event: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
_notify: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
|
||||
def append_event(self, event_type: str, data: dict) -> SSEEvent:
|
||||
event = SSEEvent(index=len(self.events), event_type=event_type, data=data)
|
||||
self.events.append(event)
|
||||
# Wake all waiting SSE clients, then reset for next wait
|
||||
old = self._notify
|
||||
self._notify = asyncio.Event()
|
||||
old.set()
|
||||
return event
|
||||
|
||||
async def wait_for_event(self, after_index: int, timeout: float = 30.0) -> bool:
|
||||
"""Wait until there are events beyond after_index. Returns True if new events exist."""
|
||||
if after_index + 1 < len(self.events):
|
||||
return True
|
||||
try:
|
||||
await asyncio.wait_for(self._notify.wait(), timeout=timeout)
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
def events_after(self, index: int) -> list[SSEEvent]:
|
||||
"""Return events with index > given index (for replay on reconnect)."""
|
||||
start = index + 1
|
||||
if start >= len(self.events):
|
||||
return []
|
||||
return self.events[start:]
|
||||
|
||||
@staticmethod
|
||||
def format_sse(event: SSEEvent) -> str:
|
||||
return f"id: {event.index}\nevent: {event.event_type}\ndata: {json.dumps(event.data)}\n\n"
|
||||
|
||||
|
||||
# Module-level singleton registry
|
||||
_buffers: dict[int, GenerationBuffer] = {}
|
||||
_cleanup_task: asyncio.Task | None = None
|
||||
_GRACE_PERIOD = 60.0 # seconds to keep completed buffers
|
||||
|
||||
|
||||
def create_buffer(conv_id: int, msg_id: int) -> GenerationBuffer:
|
||||
existing = _buffers.get(conv_id)
|
||||
if existing and existing.state == GenerationState.RUNNING:
|
||||
raise RuntimeError(f"Generation already running for conversation {conv_id}")
|
||||
buf = GenerationBuffer(conversation_id=conv_id, assistant_message_id=msg_id)
|
||||
_buffers[conv_id] = buf
|
||||
return buf
|
||||
|
||||
|
||||
def get_buffer(conv_id: int) -> GenerationBuffer | None:
|
||||
return _buffers.get(conv_id)
|
||||
|
||||
|
||||
def remove_buffer(conv_id: int) -> None:
|
||||
_buffers.pop(conv_id, None)
|
||||
|
||||
|
||||
async def _cleanup_loop() -> None:
|
||||
"""Remove completed/errored buffers after grace period."""
|
||||
while True:
|
||||
await asyncio.sleep(15)
|
||||
now = time.monotonic()
|
||||
to_remove = [
|
||||
cid for cid, buf in _buffers.items()
|
||||
if buf.state != GenerationState.RUNNING
|
||||
and buf.finished_at is not None
|
||||
and now - buf.finished_at > _GRACE_PERIOD
|
||||
]
|
||||
for cid in to_remove:
|
||||
_buffers.pop(cid, None)
|
||||
logger.debug("Cleaned up generation buffer for conversation %d", cid)
|
||||
|
||||
|
||||
def start_cleanup_loop() -> None:
|
||||
global _cleanup_task
|
||||
if _cleanup_task is None or _cleanup_task.done():
|
||||
_cleanup_task = asyncio.create_task(_cleanup_loop())
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Background asyncio task for LLM generation.
|
||||
|
||||
Streams from Ollama into a GenerationBuffer, periodically flushing to DB.
|
||||
Runs independently of any HTTP connection.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from sqlalchemy import update
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Message
|
||||
from fabledassistant.services.generation_buffer import GenerationBuffer, GenerationState
|
||||
from fabledassistant.services.llm import generate_completion, stream_chat
|
||||
from fabledassistant.services.chat import update_conversation_title
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_FLUSH_INTERVAL = 5.0 # seconds between partial DB flushes
|
||||
|
||||
|
||||
async def _generate_title(messages: list[dict], model: str) -> str:
|
||||
"""Ask the LLM for a concise conversation title."""
|
||||
# Build conversation text like summarize_conversation_as_note
|
||||
conv_lines = []
|
||||
for m in messages:
|
||||
if m["role"] == "system":
|
||||
continue
|
||||
label = "User" if m["role"] == "user" else "Assistant"
|
||||
conv_lines.append(f"{label}: {m['content']}")
|
||||
# Keep only last 6 pairs worth of text
|
||||
conv_lines = conv_lines[-12:]
|
||||
|
||||
prompt_messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Generate a concise 3-8 word title for this conversation. "
|
||||
"Reply with ONLY the title, no quotes or punctuation."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": "\n\n".join(conv_lines)},
|
||||
]
|
||||
title = await generate_completion(prompt_messages, model)
|
||||
title = title.strip().strip('"\'').strip()
|
||||
return title[:100] if title else ""
|
||||
|
||||
|
||||
async def _update_message(message_id: int, content: str, status: str) -> None:
|
||||
async with async_session() as session:
|
||||
await session.execute(
|
||||
update(Message)
|
||||
.where(Message.id == message_id)
|
||||
.values(content=content, status=status)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def run_generation(
|
||||
buf: GenerationBuffer,
|
||||
messages: list[dict],
|
||||
model: str,
|
||||
context_meta: dict,
|
||||
user_id: int,
|
||||
conv_id: int,
|
||||
conv_title: str,
|
||||
user_content: str,
|
||||
) -> None:
|
||||
"""Stream LLM response into buffer with periodic DB flushes."""
|
||||
msg_id = buf.assistant_message_id
|
||||
|
||||
# Emit context event
|
||||
buf.append_event("context", {"context": context_meta})
|
||||
|
||||
last_flush = time.monotonic()
|
||||
|
||||
try:
|
||||
cancelled = False
|
||||
async for chunk in stream_chat(messages, model):
|
||||
if buf.cancel_event.is_set():
|
||||
cancelled = True
|
||||
break
|
||||
buf.content_so_far += chunk
|
||||
buf.append_event("chunk", {"chunk": chunk})
|
||||
|
||||
# Periodic DB flush
|
||||
now = time.monotonic()
|
||||
if now - last_flush >= DB_FLUSH_INTERVAL:
|
||||
try:
|
||||
await _update_message(msg_id, buf.content_so_far, "generating")
|
||||
except Exception:
|
||||
logger.warning("Failed periodic flush for message %d", msg_id, exc_info=True)
|
||||
last_flush = now
|
||||
|
||||
# Final save (partial content on cancel is still valid)
|
||||
await _update_message(msg_id, buf.content_so_far, "complete")
|
||||
|
||||
# Count non-system messages to decide on title generation
|
||||
non_system = [m for m in messages if m["role"] != "system"]
|
||||
msg_count = len(non_system)
|
||||
should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0)
|
||||
|
||||
if should_gen_title:
|
||||
# Include the just-generated assistant reply for context
|
||||
title_messages = messages + [
|
||||
{"role": "assistant", "content": buf.content_so_far}
|
||||
]
|
||||
try:
|
||||
title = await _generate_title(title_messages, model)
|
||||
if title:
|
||||
await update_conversation_title(user_id, conv_id, title)
|
||||
except Exception:
|
||||
logger.warning("Failed to generate title for conversation %d", conv_id, exc_info=True)
|
||||
# Fallback for first message only
|
||||
if not conv_title:
|
||||
fallback = user_content[:80]
|
||||
if len(user_content) > 80:
|
||||
fallback += "..."
|
||||
await update_conversation_title(user_id, conv_id, fallback)
|
||||
|
||||
buf.state = GenerationState.COMPLETED
|
||||
buf.finished_at = time.monotonic()
|
||||
buf.append_event("done", {"done": True, "message_id": msg_id})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error in generation task for conversation %d", conv_id)
|
||||
# Save partial content with error status
|
||||
try:
|
||||
await _update_message(msg_id, buf.content_so_far, "error")
|
||||
except Exception:
|
||||
logger.warning("Failed to save error state for message %d", msg_id, exc_info=True)
|
||||
|
||||
buf.state = GenerationState.ERRORED
|
||||
buf.finished_at = time.monotonic()
|
||||
buf.append_event("error", {"error": str(e)})
|
||||
@@ -149,6 +149,7 @@ def _find_urls(text: str) -> list[str]:
|
||||
|
||||
|
||||
async def build_context(
|
||||
user_id: int,
|
||||
history: list[dict],
|
||||
current_note_id: int | None,
|
||||
user_message: str,
|
||||
@@ -160,7 +161,7 @@ async def build_context(
|
||||
which notes were included as context.
|
||||
"""
|
||||
exclude_set = set(exclude_note_ids or [])
|
||||
assistant_name = await get_setting("assistant_name", "Fable")
|
||||
assistant_name = await get_setting(user_id, "assistant_name", "Fable")
|
||||
system_parts = [
|
||||
f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Assistant. "
|
||||
"Help users with their notes, tasks, and general questions. "
|
||||
@@ -175,7 +176,7 @@ async def build_context(
|
||||
|
||||
# Include current note context if provided
|
||||
if current_note_id:
|
||||
note = await get_note(current_note_id)
|
||||
note = await get_note(user_id, current_note_id)
|
||||
if note:
|
||||
context_meta["context_note_id"] = note.id
|
||||
context_meta["context_note_title"] = note.title
|
||||
@@ -194,7 +195,7 @@ async def build_context(
|
||||
search_exclude.add(current_note_id)
|
||||
try:
|
||||
notes = await search_notes_for_context(
|
||||
keywords, exclude_ids=search_exclude or None, limit=3
|
||||
user_id, keywords, exclude_ids=search_exclude or None, limit=3
|
||||
)
|
||||
snippets: list[str] = []
|
||||
for n in notes:
|
||||
|
||||
@@ -10,6 +10,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_note(
|
||||
user_id: int,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
tags: list[str] | None = None,
|
||||
@@ -20,6 +21,7 @@ async def create_note(
|
||||
) -> Note:
|
||||
async with async_session() as session:
|
||||
note = Note(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
body=body,
|
||||
tags=tags or [],
|
||||
@@ -34,12 +36,16 @@ async def create_note(
|
||||
return note
|
||||
|
||||
|
||||
async def get_note(note_id: int) -> Note | None:
|
||||
async def get_note(user_id: int, note_id: int) -> Note | None:
|
||||
async with async_session() as session:
|
||||
return await session.get(Note, note_id)
|
||||
result = await session.execute(
|
||||
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def list_notes(
|
||||
user_id: int,
|
||||
q: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
is_task: bool | None = None,
|
||||
@@ -51,8 +57,8 @@ async def list_notes(
|
||||
offset: int = 0,
|
||||
) -> tuple[list[Note], int]:
|
||||
async with async_session() as session:
|
||||
query = select(Note)
|
||||
count_query = select(func.count(Note.id))
|
||||
query = select(Note).where(Note.user_id == user_id)
|
||||
count_query = select(func.count(Note.id)).where(Note.user_id == user_id)
|
||||
|
||||
# Filter by task vs note
|
||||
if is_task is True:
|
||||
@@ -104,25 +110,31 @@ async def list_notes(
|
||||
return notes, total
|
||||
|
||||
|
||||
async def get_note_by_title(title: str) -> Note | None:
|
||||
async def get_note_by_title(user_id: int, title: str) -> Note | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Note).where(func.lower(Note.title) == func.lower(title.strip())).limit(1)
|
||||
select(Note).where(
|
||||
Note.user_id == user_id,
|
||||
func.lower(Note.title) == func.lower(title.strip()),
|
||||
).limit(1)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_or_create_note_by_title(title: str) -> Note:
|
||||
async def get_or_create_note_by_title(user_id: int, title: str) -> Note:
|
||||
title = title.strip()
|
||||
note = await get_note_by_title(title)
|
||||
note = await get_note_by_title(user_id, title)
|
||||
if note:
|
||||
return note
|
||||
return await create_note(title=title)
|
||||
return await create_note(user_id, title=title)
|
||||
|
||||
|
||||
async def update_note(note_id: int, **fields: object) -> Note | None:
|
||||
async def update_note(user_id: int, note_id: int, **fields: object) -> Note | None:
|
||||
async with async_session() as session:
|
||||
note = await session.get(Note, note_id)
|
||||
result = await session.execute(
|
||||
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
||||
)
|
||||
note = result.scalars().first()
|
||||
if note is None:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
@@ -139,9 +151,12 @@ async def update_note(note_id: int, **fields: object) -> Note | None:
|
||||
return note
|
||||
|
||||
|
||||
async def delete_note(note_id: int) -> bool:
|
||||
async def delete_note(user_id: int, note_id: int) -> bool:
|
||||
async with async_session() as session:
|
||||
note = await session.get(Note, note_id)
|
||||
result = await session.execute(
|
||||
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
||||
)
|
||||
note = result.scalars().first()
|
||||
if note is None:
|
||||
return False
|
||||
await session.delete(note)
|
||||
@@ -149,10 +164,13 @@ async def delete_note(note_id: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def get_all_tags(q: str | None = None) -> list[str]:
|
||||
async def get_all_tags(user_id: int, q: str | None = None) -> list[str]:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
text("SELECT DISTINCT unnest(tags) AS tag FROM notes WHERE tags != '{}'")
|
||||
text(
|
||||
"SELECT DISTINCT unnest(tags) AS tag FROM notes"
|
||||
" WHERE tags != '{}' AND user_id = :user_id"
|
||||
).bindparams(user_id=user_id)
|
||||
)
|
||||
all_tags = [row[0] for row in result.fetchall()]
|
||||
|
||||
@@ -163,9 +181,12 @@ async def get_all_tags(q: str | None = None) -> list[str]:
|
||||
return sorted(all_tags)
|
||||
|
||||
|
||||
async def convert_note_to_task(note_id: int) -> Note:
|
||||
async def convert_note_to_task(user_id: int, note_id: int) -> Note:
|
||||
async with async_session() as session:
|
||||
note = await session.get(Note, note_id)
|
||||
result = await session.execute(
|
||||
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
||||
)
|
||||
note = result.scalars().first()
|
||||
if note is None:
|
||||
logger.warning("convert_note_to_task: note %d not found", note_id)
|
||||
raise ValueError("Note not found")
|
||||
@@ -178,9 +199,12 @@ async def convert_note_to_task(note_id: int) -> Note:
|
||||
return note
|
||||
|
||||
|
||||
async def convert_task_to_note(note_id: int) -> Note:
|
||||
async def convert_task_to_note(user_id: int, note_id: int) -> Note:
|
||||
async with async_session() as session:
|
||||
note = await session.get(Note, note_id)
|
||||
result = await session.execute(
|
||||
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
||||
)
|
||||
note = result.scalars().first()
|
||||
if note is None:
|
||||
logger.warning("convert_task_to_note: note %d not found", note_id)
|
||||
raise ValueError("Note not found")
|
||||
@@ -195,6 +219,7 @@ async def convert_task_to_note(note_id: int) -> Note:
|
||||
|
||||
|
||||
async def search_notes_for_context(
|
||||
user_id: int,
|
||||
keywords: list[str],
|
||||
exclude_ids: set[int] | None = None,
|
||||
limit: int = 3,
|
||||
@@ -207,7 +232,7 @@ async def search_notes_for_context(
|
||||
pattern = f"%{escaped}%"
|
||||
keyword_filters.append(or_(Note.title.ilike(pattern), Note.body.ilike(pattern)))
|
||||
|
||||
query = select(Note).where(or_(*keyword_filters))
|
||||
query = select(Note).where(Note.user_id == user_id, or_(*keyword_filters))
|
||||
if exclude_ids:
|
||||
query = query.where(Note.id.notin_(exclude_ids))
|
||||
query = query.order_by(Note.updated_at.desc()).limit(limit)
|
||||
@@ -216,8 +241,8 @@ async def search_notes_for_context(
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_backlinks(note_id: int) -> list[dict]:
|
||||
note = await get_note(note_id)
|
||||
async def get_backlinks(user_id: int, note_id: int) -> list[dict]:
|
||||
note = await get_note(user_id, note_id)
|
||||
if note is None:
|
||||
return []
|
||||
|
||||
@@ -232,6 +257,7 @@ async def get_backlinks(note_id: int) -> list[dict]:
|
||||
|
||||
results = await session.execute(
|
||||
select(Note.id, Note.title, Note.status).where(
|
||||
Note.user_id == user_id,
|
||||
Note.id != note_id,
|
||||
or_(Note.body.like(pattern), Note.body.like(pattern_alias)),
|
||||
)
|
||||
|
||||
@@ -8,39 +8,47 @@ from fabledassistant.models.setting import Setting
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_setting(key: str, default: str = "") -> str:
|
||||
async def get_setting(user_id: int, key: str, default: str = "") -> str:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(Setting).where(Setting.key == key))
|
||||
result = await session.execute(
|
||||
select(Setting).where(Setting.user_id == user_id, Setting.key == key)
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
return setting.value if setting else default
|
||||
|
||||
|
||||
async def set_setting(key: str, value: str) -> None:
|
||||
async def set_setting(user_id: int, key: str, value: str) -> None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(Setting).where(Setting.key == key))
|
||||
result = await session.execute(
|
||||
select(Setting).where(Setting.user_id == user_id, Setting.key == key)
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
session.add(Setting(key=key, value=value))
|
||||
session.add(Setting(user_id=user_id, key=key, value=value))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def set_settings_batch(settings: dict[str, str]) -> None:
|
||||
async def set_settings_batch(user_id: int, settings: dict[str, str]) -> None:
|
||||
"""Update multiple settings in a single transaction."""
|
||||
async with async_session() as session:
|
||||
for key, value in settings.items():
|
||||
result = await session.execute(select(Setting).where(Setting.key == key))
|
||||
result = await session.execute(
|
||||
select(Setting).where(Setting.user_id == user_id, Setting.key == key)
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
session.add(Setting(key=key, value=value))
|
||||
session.add(Setting(user_id=user_id, key=key, value=value))
|
||||
await session.commit()
|
||||
logger.info("Batch-updated %d settings", len(settings))
|
||||
logger.info("Batch-updated %d settings for user %d", len(settings), user_id)
|
||||
|
||||
|
||||
async def get_all_settings() -> dict[str, str]:
|
||||
async def get_all_settings(user_id: int) -> dict[str, str]:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(Setting))
|
||||
result = await session.execute(
|
||||
select(Setting).where(Setting.user_id == user_id)
|
||||
)
|
||||
return {s.key: s.value for s in result.scalars().all()}
|
||||
|
||||
Reference in New Issue
Block a user