feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare

This commit is contained in:
2026-08-30 01:34:44 -04:00
parent ece93a7c8f
commit 114b115034
28 changed files with 3442 additions and 54 deletions
+99 -6
View File
@@ -3,9 +3,16 @@ from __future__ import annotations
import uuid
from datetime import datetime
from typing import Literal
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import (
BaseModel,
ConfigDict,
Field,
SerializerFunctionWrapHandler,
field_validator,
model_serializer,
)
class HealthResponse(BaseModel):
@@ -324,16 +331,26 @@ class ChatMessage(BaseModel):
class SavedChatCreate(BaseModel):
"""``POST /api/chats`` body (phase 50, task 02).
"""``POST /api/chats`` body (phase 50, task 02; ``share``, phase 51
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.
``share`` (phase 51, owner-locked 2026-08-29): when true, the row is
shared in the SAME commit — ``share_token = uuid.uuid4()`` is set on
the fresh row before the INSERT, so one request saves AND shares
(the chat page's Share button on an unsaved conversation, the
save-then-share contract). The response then carries ``share_url``
(see :class:`SavedChatOut`). Default false — a plain Save is
unchanged by phase 51.
"""
title: str | None = Field(default=None, max_length=500)
messages: list[ChatMessage] = Field(min_length=1)
share: bool = False
class SavedChatUpdate(BaseModel):
@@ -349,11 +366,35 @@ class SavedChatUpdate(BaseModel):
messages: list[ChatMessage] = Field(min_length=1)
def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHandler) -> Any:
"""The ``share_url`` omission rule (phase 51, task 02): ``None`` →
ABSENT from the JSON (not ``"share_url": null``) — an unshared chat
exposes no share surface at all, and the History column renders the
unshared state from the key's absence.
A ``mode="wrap"`` model serializer: the default (recursive) dump runs
first, then only the TOP-LEVEL key is dropped when null. The
recursion matters — a route-level ``response_model_exclude_none``
would also drop the nested ``ChatMessage`` nulls (``sources: null``
and friends), which the byte-identical round-trip contract (phase
50) forbids.
"""
data = handler(model)
if data.get("share_url") is None:
data.pop("share_url", None)
return data
class SavedChatOut(BaseModel):
"""One saved chat, full payload (create/get/put response, phase 50).
"""One saved chat, full payload (create/get/put response, phase 50;
``share_url``, phase 51 task 02).
``messages`` round-trips the ``bor.chat.v1`` record list losslessly
— the restore path is pixel-identical by construction.
``share_url`` (phase 51): ``"/shared/<token>"`` while the chat is
shared, ABSENT from the JSON when unshared (``None`` → dropped by
:func:`_drop_absent_share_url` — no ``null`` in the wire shape).
"""
id: uuid.UUID
@@ -362,19 +403,33 @@ class SavedChatOut(BaseModel):
updated_at: datetime
message_count: int
messages: list[ChatMessage]
share_url: str | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
return _drop_absent_share_url(self, handler)
class SavedChatRow(BaseModel):
"""One row of ``GET /api/chats`` (the History page's list shape).
"""One row of ``GET /api/chats`` (the History page's list shape,
phase 50; ``share_url``, phase 51 task 02).
No payloads in the list — the row carries only what the table needs
(Title, Messages count, Updated).
(Title, Messages count, Updated). ``share_url`` is populated here so
the History page's Share column renders straight from ``GET
/api/chats`` — no second fetch per row (``None`` → absent, the same
omission rule as :class:`SavedChatOut`).
"""
id: uuid.UUID
title: str
updated_at: datetime
message_count: int
share_url: str | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
return _drop_absent_share_url(self, handler)
class SavedChatList(BaseModel):
@@ -382,3 +437,41 @@ class SavedChatList(BaseModel):
(``updated_at desc, id desc``)."""
chats: list[SavedChatRow]
class SharedChatOut(BaseModel):
"""``GET /api/shared/{token}`` body (phase 51, task 01) — the PUBLIC
read shape of a shared chat.
Deliberately minimal: ``title`` + ``messages`` only. No id, no
timestamps, no token, no ``message_count`` — a shared chat is a
content snapshot, not a handle: nothing in the body can be turned
back into an admin-surface request, and the token itself never
round-trips (it is the URL, not data).
"""
title: str
messages: list[ChatMessage]
class ShareOut(BaseModel):
"""``POST /api/chats/{chat_id}/share`` response (phase 51, task 01).
``share_url`` is the path (``/shared/<token>``) the UI copies into
the clipboard — the owner's own origin supplies the scheme/host.
Idempotent: a re-share returns the existing, unchanged token.
"""
chat_id: uuid.UUID
share_url: str
class UnshareOut(BaseModel):
"""``POST /api/chats/{chat_id}/unshare`` response (phase 51, task 01).
``shared: false`` is reported unconditionally — the endpoint is
idempotent, so an already-unshared chat unshares cleanly (200).
"""
chat_id: uuid.UUID
shared: bool