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,114 @@
"""In-memory generation event buffer for SSE fan-out.
Each active generation gets a GenerationBuffer keyed by conversation_id.
SSE clients tail the buffer and can reconnect at any point without data loss.
"""
import asyncio
import json
import logging
import time
from dataclasses import dataclass, field
from enum import Enum
logger = logging.getLogger(__name__)
class GenerationState(str, Enum):
RUNNING = "running"
COMPLETED = "completed"
ERRORED = "errored"
@dataclass
class SSEEvent:
index: int
event_type: str # "context", "chunk", "done", "error"
data: dict
@dataclass
class GenerationBuffer:
conversation_id: int
assistant_message_id: int
state: GenerationState = GenerationState.RUNNING
events: list[SSEEvent] = field(default_factory=list)
content_so_far: str = ""
finished_at: float | None = None
cancel_event: asyncio.Event = field(default_factory=asyncio.Event)
_notify: asyncio.Event = field(default_factory=asyncio.Event)
def append_event(self, event_type: str, data: dict) -> SSEEvent:
event = SSEEvent(index=len(self.events), event_type=event_type, data=data)
self.events.append(event)
# Wake all waiting SSE clients, then reset for next wait
old = self._notify
self._notify = asyncio.Event()
old.set()
return event
async def wait_for_event(self, after_index: int, timeout: float = 30.0) -> bool:
"""Wait until there are events beyond after_index. Returns True if new events exist."""
if after_index + 1 < len(self.events):
return True
try:
await asyncio.wait_for(self._notify.wait(), timeout=timeout)
return True
except asyncio.TimeoutError:
return False
def events_after(self, index: int) -> list[SSEEvent]:
"""Return events with index > given index (for replay on reconnect)."""
start = index + 1
if start >= len(self.events):
return []
return self.events[start:]
@staticmethod
def format_sse(event: SSEEvent) -> str:
return f"id: {event.index}\nevent: {event.event_type}\ndata: {json.dumps(event.data)}\n\n"
# Module-level singleton registry
_buffers: dict[int, GenerationBuffer] = {}
_cleanup_task: asyncio.Task | None = None
_GRACE_PERIOD = 60.0 # seconds to keep completed buffers
def create_buffer(conv_id: int, msg_id: int) -> GenerationBuffer:
existing = _buffers.get(conv_id)
if existing and existing.state == GenerationState.RUNNING:
raise RuntimeError(f"Generation already running for conversation {conv_id}")
buf = GenerationBuffer(conversation_id=conv_id, assistant_message_id=msg_id)
_buffers[conv_id] = buf
return buf
def get_buffer(conv_id: int) -> GenerationBuffer | None:
return _buffers.get(conv_id)
def remove_buffer(conv_id: int) -> None:
_buffers.pop(conv_id, None)
async def _cleanup_loop() -> None:
"""Remove completed/errored buffers after grace period."""
while True:
await asyncio.sleep(15)
now = time.monotonic()
to_remove = [
cid for cid, buf in _buffers.items()
if buf.state != GenerationState.RUNNING
and buf.finished_at is not None
and now - buf.finished_at > _GRACE_PERIOD
]
for cid in to_remove:
_buffers.pop(cid, None)
logger.debug("Cleaned up generation buffer for conversation %d", cid)
def start_cleanup_loop() -> None:
global _cleanup_task
if _cleanup_task is None or _cleanup_task.done():
_cleanup_task = asyncio.create_task(_cleanup_loop())