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,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)})
|
||||
Reference in New Issue
Block a user