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
+68 -20
View File
@@ -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()