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>
256 lines
9.5 KiB
Python
256 lines
9.5 KiB
Python
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
|