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
+183
View File
@@ -0,0 +1,183 @@
"""Saved-chat API — save and view chat history (phase 50, task 02).
Admin-only CRUD under ``/api/chats`` (the phase-16
:func:`app.core.auth.require_admin` gate, applied router-wide exactly
like :mod:`app.api.steering`): conversations the owner explicitly
**Saves** are stored in Postgres (``saved_chats``, migration 0008).
A10 extension (owner permission 2026-08-29, recorded per AGENTS.md
rule 3 — a recorded revision, not a silent deviation): ``/api/chat``
itself stays stateless; nothing is stored about a conversation that was
not saved, and phase 14's browser-local persistence is untouched
(saving is an additional, explicit action). The stored ``messages``
payload is 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.
Routes: ``GET`` (list, latest activity first — no payloads), ``POST``
(create — auto-title from the first question when no ``title`` is
supplied), ``GET /{chat_id}`` (full payload), ``PUT /{chat_id}``
(re-Save upsert — full ``messages`` replacement, ``title`` replaced
only when supplied), ``DELETE /{chat_id}``.
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.auth import require_admin
from app.db import get_db
from app.models import SavedChat
from app.schemas import (
ChatMessage,
SavedChatCreate,
SavedChatList,
SavedChatOut,
SavedChatRow,
SavedChatUpdate,
)
router = APIRouter(
prefix="/chats",
tags=["chats"],
dependencies=[Depends(require_admin)], # phase 16: save/history is admin-only
)
#: Auto-title cap (owner-locked convention, phase 50): the first user
#: message's text, whitespace-collapsed, truncated to 120 chars.
_AUTO_TITLE_MAX = 120
def _auto_title(messages: list[ChatMessage]) -> str:
"""The auto-title (owner-locked, phase 50): the **first user
message**'s text, whitespace-collapsed, truncated to 120 chars.
Returns "" when the conversation has no user message (defensive —
the UI cannot produce one; the route then falls back to
``"Chat <id-hex8>"``).
"""
first_user = next((m.text for m in messages if m.who == "user"), None)
if first_user is None:
return ""
return " ".join(first_user.split())[:_AUTO_TITLE_MAX]
def _to_out(row: SavedChat) -> SavedChatOut:
"""The full-payload response shape (create/get/put)."""
return SavedChatOut(
id=row.id,
title=row.title,
created_at=row.created_at,
updated_at=row.updated_at,
message_count=len(row.messages),
messages=[ChatMessage.model_validate(m) for m in row.messages],
)
def _to_row(row: SavedChat) -> SavedChatRow:
"""The list-page row shape (no payloads in the list)."""
return SavedChatRow(
id=row.id,
title=row.title,
updated_at=row.updated_at,
message_count=len(row.messages),
)
@router.get("", response_model=SavedChatList)
def list_chats(
db: Session = Depends(get_db), # noqa: B008
) -> SavedChatList:
"""All saved chats, latest activity first (``updated_at desc, id
desc``) — the History page's table order."""
rows = db.scalars(
select(SavedChat).order_by(SavedChat.updated_at.desc(), SavedChat.id.desc())
).all()
return SavedChatList(chats=[_to_row(row) for row in rows])
@router.post("", response_model=SavedChatOut, status_code=201)
def create_chat(
payload: SavedChatCreate,
db: Session = Depends(get_db), # noqa: B008
) -> SavedChatOut:
"""Store one explicitly saved conversation (201).
Auto-title when ``title`` is absent/blank: the first user message's
text, whitespace-collapsed, truncated to 120 chars (owner-locked
convention); a conversation with no user message (defensive) falls
back to ``"Chat <id-hex8>"``.
"""
title = (payload.title or "").strip() or _auto_title(payload.messages)
row = SavedChat(
title=title,
# Plain model_dump (no exclude_none): the stored JSONB keeps
# every bor.chat.v1 key, explicit null included — the phase-37
# tool records carry `argument: null` in localStorage, so this
# is what makes a real payload round-trip byte-identical (the
# restore path is null-safe for every optional key).
messages=[m.model_dump() for m in payload.messages],
)
db.add(row)
db.flush() # python-side uuid default lands the id before the fallback
if not row.title:
row.title = f"Chat {row.id.hex[:8]}"
db.commit()
db.refresh(row)
return _to_out(row)
@router.get("/{chat_id}", response_model=SavedChatOut)
def get_chat(
chat_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> SavedChatOut:
"""One saved chat, full payload (the ``?chat=<id>`` load); 404 when
the id is unknown."""
row = db.get(SavedChat, chat_id)
if row is None:
raise HTTPException(status_code=404, detail="unknown chat")
return _to_out(row)
@router.put("/{chat_id}", response_model=SavedChatOut)
def update_chat(
chat_id: uuid.UUID,
payload: SavedChatUpdate,
db: Session = Depends(get_db), # noqa: B008
) -> SavedChatOut:
"""Re-Save upsert: full ``messages`` replacement on the same row.
``title`` is replaced only when supplied (an absent/blank ``title``
keeps the current one); 404 when the id is unknown. ``updated_at``
bumps via the model's ``onupdate=func.now()`` — the attribute
assignment above is the ORM change that triggers it, so the History
page's "latest activity first" order follows re-Saves.
"""
row = db.get(SavedChat, chat_id)
if row is None:
raise HTTPException(status_code=404, detail="unknown chat")
row.messages = [m.model_dump() for m in payload.messages]
title = (payload.title or "").strip()
if title:
row.title = title
db.commit()
db.refresh(row)
return _to_out(row)
@router.delete("/{chat_id}", status_code=204)
def delete_chat(
chat_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> Response:
"""Remove a saved chat; 404 when the id is unknown."""
row = db.get(SavedChat, chat_id)
if row is None:
raise HTTPException(status_code=404, detail="unknown chat")
db.delete(row)
db.commit()
return Response(status_code=204)
+7 -5
View File
@@ -5,9 +5,10 @@ Two layers, one module:
* **Version token** (``asset_version()``) — the value the HTML pages
append to their asset URLs (``?v=<token>``).
* **Response middleware** (``CachingMiddleware`` / ``configure_caching``)
— applies the caching behavior at the transport layer: the five known
HTML pages are always revalidated (``no-cache``) and their local asset
references are rewritten to carry ``?v=<token>``; ``/assets/*`` is
— applies the caching behavior at the transport layer: the known
HTML pages (``HTML_PAGES``) are always revalidated (``no-cache``) and
their local asset references are rewritten to carry ``?v=<token>``;
``/assets/*`` is
served ``immutable`` for a year; everything else — all of ``/api/*``,
including the SSE chat stream — passes through byte-identical.
@@ -123,6 +124,7 @@ HTML_PAGES: tuple[str, ...] = (
"/login.html",
"/tuning.html",
"/git-sources.html", # phase 35: the admin git sources page
"/history.html", # phase 50: the admin saved-chats page
)
#: Prefix of the versioned static assets (header-only caching; the body is
@@ -192,8 +194,8 @@ class CachingMiddleware(BaseHTTPMiddleware):
* ``/assets/*`` — ``Cache-Control: public, max-age=31536000, immutable``
(header only — the body is never read).
* the five known HTML pages — ``Cache-Control: no-cache``, and (for
``text/html`` bodies) every local asset reference gains
* the known HTML pages (``HTML_PAGES``) — ``Cache-Control: no-cache``,
and (for ``text/html`` bodies) every local asset reference gains
``?v=<token>``.
Everything else — all of ``/api/*`` (including the SSE chat stream) —
+2
View File
@@ -21,6 +21,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app.api.auth import router as auth_router
from app.api.chat import router as chat_router
from app.api.chats import router as chats_router
from app.api.config import router as config_router
from app.api.docs import router as docs_router
from app.api.git_sources import router as git_sources_router
@@ -71,6 +72,7 @@ def create_app() -> FastAPI:
app.include_router(chat_router, prefix="/api")
app.include_router(steering_router, prefix="/api")
app.include_router(sync_router, prefix="/api")
app.include_router(chats_router, prefix="/api")
# Cache busting (phase 33): the five HTML pages revalidate (no-cache)
# with ?v=<token> asset refs; /assets/* becomes immutable for a year.
+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()
)
+101 -1
View File
@@ -5,7 +5,7 @@ import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator
class HealthResponse(BaseModel):
@@ -282,3 +282,103 @@ class UploadOut(BaseModel):
errors: int
chunks: int
overview: bool
class ToolCall(BaseModel):
"""One agent tool-call record (the phase-37 ``tools`` record shape).
Mirrors the ``{name, argument}`` pair the SSE ``tool`` frames carry
(PLAN §4 extension): ``argument`` is the read document's
``"source/path"`` for ``read_document`` and null otherwise. Stored
inside :class:`ChatMessage.tools` so a saved chat restores the
"calling tool" lines pixel-identical (phase 50).
"""
name: str
argument: str | None = None
class ChatMessage(BaseModel):
"""One conversation record in the ``bor.chat.v1`` localStorage shape
(phase 14) — the stored ``messages`` payload of a saved chat (phase 50).
``{who, text, sources?, deflected?, suggestions?, thinking?, tools?,
stopped?}`` — raw text, never HTML, so a saved chat restores
pixel-identical through the existing ``renderStoredMessage`` path.
``extra="forbid"`` rejects unknown keys (a corrupted or HTML-shaped
payload, e.g. a stray ``<b>``-ish extra key) at the boundary with a
422, so nothing outside this shape can poison a restored
conversation.
"""
model_config = ConfigDict(extra="forbid")
who: Literal["user", "brain"]
text: str = Field(min_length=1)
sources: list[SourceRef] | None = None
deflected: bool | None = None
suggestions: list[str] | None = None
thinking: str | None = None
tools: list[ToolCall] | None = None
stopped: bool | None = None
class SavedChatCreate(BaseModel):
"""``POST /api/chats`` body (phase 50, task 02).
``title`` is optional: when absent or blank the API auto-titles the
row (the first user message's text, whitespace-collapsed, truncated
to 120 chars — the owner-locked convention). ``messages`` must be
non-empty — a saved chat with nothing to restore is meaningless.
"""
title: str | None = Field(default=None, max_length=500)
messages: list[ChatMessage] = Field(min_length=1)
class SavedChatUpdate(BaseModel):
"""``PUT /api/chats/{chat_id}`` body (phase 50, task 02).
``messages`` is a full replacement (the re-Save upsert semantics —
re-Saving the same conversation updates the same row, never a new
one). ``title`` is replaced only when supplied — an absent (or
blank) ``title`` keeps the row's current title.
"""
title: str | None = Field(default=None, max_length=500)
messages: list[ChatMessage] = Field(min_length=1)
class SavedChatOut(BaseModel):
"""One saved chat, full payload (create/get/put response, phase 50).
``messages`` round-trips the ``bor.chat.v1`` record list losslessly
— the restore path is pixel-identical by construction.
"""
id: uuid.UUID
title: str
created_at: datetime
updated_at: datetime
message_count: int
messages: list[ChatMessage]
class SavedChatRow(BaseModel):
"""One row of ``GET /api/chats`` (the History page's list shape).
No payloads in the list — the row carries only what the table needs
(Title, Messages count, Updated).
"""
id: uuid.UUID
title: str
updated_at: datetime
message_count: int
class SavedChatList(BaseModel):
"""``GET /api/chats`` response: saved chats, latest activity first
(``updated_at desc, id desc``)."""
chats: list[SavedChatRow]