Scaffold project, data model and artwork

Establish the LLeMbas foundation: FastAPI/Jinja/SQLite layout, the ORM
schema, and the original SVG identity.

Notable decisions, all recorded in comments at the point they matter:

- No Alembic. SQLite only, schema created at startup, so models carry a
  few columns nothing reads yet (Message.parent_id for branching,
  content_parts_json for multimodal turns). Adding them later to a live
  database without migrations is the painful path.
- Sessions are server-side rows keyed by a SHA-256 of the cookie value,
  not JWTs, so logout and bans revoke access immediately.
- Upstream API keys are Fernet-encrypted with a key derived from
  LEMBAS_SECRET_KEY. decrypt() fails soft to "" so rotating the secret
  degrades to re-entering keys rather than crashing the admin UI.
- Artwork is generated by scripts/build_artwork.py rather than hand-drawn
  per file: the mallorn leaf appears in the icon, favicon, lockup and
  banner, and one source is the only way those stay in sync. The wordmark
  is Source Serif 4 (OFL) converted to outlines, because a README banner
  cannot load a webfont and <text> would render in whatever serif the
  viewer happens to have.
- Icons live in a template partial, not assets/, because same-document
  <use href="#id"> is universally supported and the cross-document form
  is not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-21 10:34:48 +02:00
parent 0665027bc6
commit 5ef2af6a9f
32 changed files with 2041 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
"""Folders, chats and messages."""
from __future__ import annotations
from typing import Any
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.types import JSONDict, JSONList
ROLE_SYSTEM = "system"
ROLE_USER = "user"
ROLE_ASSISTANT = "assistant"
ROLE_TOOL = "tool"
class Folder(UUIDPrimaryKey, Timestamps, Base):
"""A user-owned, arbitrarily nested container for chats."""
__tablename__ = "folders"
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
parent_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("folders.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
collapsed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
children: Mapped[list[Folder]] = relationship(
back_populates="parent",
cascade="all, delete-orphan",
order_by="Folder.position, Folder.name",
)
parent: Mapped[Folder | None] = relationship(back_populates="children", remote_side="Folder.id")
chats: Mapped[list[Chat]] = relationship(back_populates="folder")
def __repr__(self) -> str:
return f"<Folder {self.name}>"
class Chat(UUIDPrimaryKey, Timestamps, Base):
__tablename__ = "chats"
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# Deleting a folder keeps its chats; they fall back to the unfiled list.
folder_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("folders.id", ondelete="SET NULL"), index=True
)
title: Mapped[str] = mapped_column(String(300), default="New chat")
# Set once the model writes the first reply, so auto-titling only runs once.
title_generated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Denormalised rather than a foreign key: chat history must survive an admin
# deleting a connection or a model disappearing upstream.
model_id: Mapped[str] = mapped_column(String(300), default="")
connection_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("connections.id", ondelete="SET NULL")
)
system_prompt: Mapped[str] = mapped_column(Text, default="")
params_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
folder: Mapped[Folder | None] = relationship(back_populates="chats")
messages: Mapped[list[Message]] = relationship(
back_populates="chat",
cascade="all, delete-orphan",
order_by="Message.created_at",
)
def __repr__(self) -> str:
return f"<Chat {self.title!r}>"
class Message(UUIDPrimaryKey, Timestamps, Base):
__tablename__ = "messages"
chat_id: Mapped[str] = mapped_column(
String(32), ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True
)
# Reserved for conversation branching (edit a message, regenerate a reply
# and keep both). Nothing reads it yet; it exists now because retrofitting a
# column onto a live SQLite database without migrations is painful.
parent_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("messages.id"))
role: Mapped[str] = mapped_column(String(16), nullable=False)
content: Mapped[str] = mapped_column(Text, default="")
# Reserved for multimodal turns: [{"type": "image_url", ...}, ...].
# Plain-text messages leave this empty and use `content`.
content_parts_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
model_id: Mapped[str] = mapped_column(String(300), default="")
tool_calls_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
usage_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
# Non-empty when generation failed. Rendered as a styled error in the
# thread so a failed turn is never an unexplained blank bubble.
error: Mapped[str] = mapped_column(Text, default="")
# False while a reply is still streaming; flipped when the stream ends.
complete: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
chat: Mapped[Chat] = relationship(back_populates="messages")
def __repr__(self) -> str:
return f"<Message {self.role} {self.content[:40]!r}>"