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>
88 lines
2.9 KiB
Python
88 lines
2.9 KiB
Python
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
|
|
from sqlalchemy import inspect
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from fabledassistant.models import Base
|
|
|
|
|
|
class Conversation(Base):
|
|
__tablename__ = "conversations"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
|
)
|
|
title: Mapped[str] = mapped_column(Text, default="")
|
|
model: Mapped[str] = mapped_column(Text, default="")
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(timezone.utc),
|
|
onupdate=lambda: datetime.now(timezone.utc),
|
|
)
|
|
|
|
messages: Mapped[list["Message"]] = relationship(
|
|
back_populates="conversation",
|
|
cascade="all, delete-orphan",
|
|
order_by="Message.created_at",
|
|
)
|
|
|
|
__table_args__ = (
|
|
Index("ix_conversations_updated_at", "updated_at"),
|
|
Index("ix_conversations_user_id", "user_id"),
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
# Use loaded messages if available, otherwise 0
|
|
if "messages" in inspect(self).dict:
|
|
msg_count = len(self.messages) if self.messages else 0
|
|
else:
|
|
msg_count = 0
|
|
return {
|
|
"id": self.id,
|
|
"title": self.title,
|
|
"model": self.model,
|
|
"message_count": msg_count,
|
|
"created_at": self.created_at.isoformat(),
|
|
"updated_at": self.updated_at.isoformat(),
|
|
}
|
|
|
|
|
|
class Message(Base):
|
|
__tablename__ = "messages"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
conversation_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("conversations.id", ondelete="CASCADE")
|
|
)
|
|
role: Mapped[str] = mapped_column(Text)
|
|
content: Mapped[str] = mapped_column(Text, default="")
|
|
status: Mapped[str] = mapped_column(Text, default="complete")
|
|
context_note_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
|
)
|
|
|
|
conversation: Mapped["Conversation"] = relationship(back_populates="messages")
|
|
|
|
__table_args__ = (
|
|
Index("ix_messages_conversation_id", "conversation_id"),
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"conversation_id": self.conversation_id,
|
|
"role": self.role,
|
|
"content": self.content,
|
|
"status": self.status,
|
|
"context_note_id": self.context_note_id,
|
|
"created_at": self.created_at.isoformat(),
|
|
}
|