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