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
@@ -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)})