feat(chat): save and view chat history — admin-only saved_chats, History page, open-a-chat return

This commit is contained in:
2026-08-29 21:22:25 -04:00
parent 6832957ab0
commit ece93a7c8f
29 changed files with 3099 additions and 20 deletions
+36 -1
View File
@@ -17,6 +17,11 @@ Data model — see ``.agent/PLAN.md`` §Data Model:
directories) the Sync button and import_docs
import (phase 35; ``kind`` discriminator added in
phase 38).
* ``saved_chats`` — owner-saved chat conversations: one row per
explicitly Saved conversation (auto-``title`` +
the ``bor.chat.v1`` message list as JSONB, phase
14 shape) — phase 50; ``/api/chat`` stays
stateless.
"""
from __future__ import annotations
@@ -35,7 +40,7 @@ from sqlalchemy import (
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.config import get_settings
@@ -162,3 +167,33 @@ class GitSource(Base):
#: Unique — Postgres treats NULLs as distinct under a unique index.
path: Mapped[str | None] = mapped_column(Text, unique=True)
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class SavedChat(Base):
"""One owner-saved chat conversation (phase 50).
Only conversations the owner explicitly **Saves** are stored —
``/api/chat`` itself stays stateless (A10, owner-locked extension
2026-08-29) and nothing is stored about a conversation that was not
saved. ``messages`` holds the exact ``bor.chat.v1`` localStorage
record shape (phase 14 — raw text, never HTML), so a saved chat
restores pixel-identical through the existing
``renderStoredMessage`` path. No share-related columns here —
phase 51 adds ``share_token`` in migration 0009.
"""
__tablename__ = "saved_chats"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
#: Auto-title (the first question, API-side). Plain column on purpose:
#: a future rename needs no migration.
title: Mapped[str] = mapped_column(String(500))
#: ``list[dict]`` in the ``bor.chat.v1`` record shape
#: (``{who, text, sources?, deflected?, suggestions?, thinking?,
#: tools?, stopped?}``); the API always supplies a list, so no
#: default is needed.
messages: Mapped[list] = mapped_column(JSONB)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)