Add multi-user auth, background generation, and chat UX improvements

Phase 5: Multi-user authentication with session cookies, bcrypt passwords,
first-user-is-admin pattern, per-user data isolation, backup/restore,
Docker Swarm production stack with secrets and network isolation.

Phase 5.1: Chat UX improvements:
- Background generation architecture (GenerationBuffer + asyncio task)
  with SSE fan-out, reconnect support, and periodic DB flushes
- LLM-generated conversation titles (first exchange + every 10th message)
- Stop generation button with cancel_event and partial content preservation
- Relative timestamps in sidebar (5m ago, 3h ago, then dates)
- Empty chat auto-cleanup on navigation away
- Save-as-note uses LLM for title generation, tags notes with "chat"
- Summarize-as-note also tags with "chat"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 14:36:30 -05:00
parent db01106714
commit cbfdf5289e
49 changed files with 3105 additions and 369 deletions
+85
View File
@@ -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)