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
+19 -11
View File
@@ -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()}