feat(chat): save and view chat history — admin-only saved_chats, History page, open-a-chat return
This commit is contained in:
@@ -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
@@ -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) —
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user