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)