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:
@@ -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())
|
||||
Reference in New Issue
Block a user