feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare
This commit is contained in:
+99
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user