Merge pull request #757 from CREDO23/sur-127-impr-public-chats-should-be-immutable

[Feat] Immutable public chat snapshots
This commit is contained in:
Rohan Verma 2026-02-01 17:38:07 -08:00 committed by GitHub
commit 3304a6cd78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1055 additions and 620 deletions

View file

@ -0,0 +1,179 @@
"""Add public_chat_snapshots table and remove deprecated columns from new_chat_threads
Revision ID: 85
Revises: 84
Create Date: 2026-01-29
Changes:
1. Create public_chat_snapshots table for immutable public chat sharing
2. Drop deprecated columns from new_chat_threads:
- public_share_token (moved to snapshots)
- public_share_enabled (replaced by snapshot existence)
- clone_pending (single-phase clone)
3. Drop related indexes
4. Add cloned_from_snapshot_id to new_chat_threads (tracks source snapshot for clones)
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "85"
down_revision: str | None = "84"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create public_chat_snapshots table and remove deprecated columns."""
# 1. Create public_chat_snapshots table
op.execute(
"""
CREATE TABLE IF NOT EXISTS public_chat_snapshots (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- Link to original thread (CASCADE DELETE)
thread_id INTEGER NOT NULL
REFERENCES new_chat_threads(id) ON DELETE CASCADE,
-- Public access token (unique URL identifier)
share_token VARCHAR(64) NOT NULL UNIQUE,
-- SHA-256 hash of message content for deduplication
content_hash VARCHAR(64) NOT NULL,
-- Immutable snapshot data (JSONB)
snapshot_data JSONB NOT NULL,
-- Array of message IDs for cascade delete on edit
message_ids INTEGER[] NOT NULL,
-- Who created this snapshot
created_by_user_id UUID REFERENCES "user"(id) ON DELETE SET NULL,
-- Prevent duplicate snapshots of same content for same thread
CONSTRAINT uq_snapshot_thread_content_hash UNIQUE (thread_id, content_hash)
);
"""
)
# 2. Create indexes for public_chat_snapshots
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_public_chat_snapshots_thread_id
ON public_chat_snapshots(thread_id);
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_public_chat_snapshots_share_token
ON public_chat_snapshots(share_token);
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_public_chat_snapshots_content_hash
ON public_chat_snapshots(content_hash);
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_public_chat_snapshots_created_by_user_id
ON public_chat_snapshots(created_by_user_id);
"""
)
# 3. Create GIN index for message_ids array (for fast overlap queries)
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_public_chat_snapshots_message_ids
ON public_chat_snapshots USING GIN(message_ids);
"""
)
# 4. Drop deprecated indexes from new_chat_threads
op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_public_share_enabled")
op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_public_share_token")
# 5. Drop deprecated columns from new_chat_threads
op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS clone_pending")
op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS public_share_enabled")
op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS public_share_token")
# 6. Add cloned_from_snapshot_id to new_chat_threads
op.execute(
"""
ALTER TABLE new_chat_threads
ADD COLUMN IF NOT EXISTS cloned_from_snapshot_id INTEGER
REFERENCES public_chat_snapshots(id) ON DELETE SET NULL;
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_new_chat_threads_cloned_from_snapshot_id
ON new_chat_threads(cloned_from_snapshot_id)
WHERE cloned_from_snapshot_id IS NOT NULL;
"""
)
def downgrade() -> None:
"""Restore deprecated columns and drop public_chat_snapshots table."""
# 1. Drop cloned_from_snapshot_id column and index
op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_cloned_from_snapshot_id")
op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS cloned_from_snapshot_id")
# 2. Restore deprecated columns on new_chat_threads
op.execute(
"""
ALTER TABLE new_chat_threads
ADD COLUMN IF NOT EXISTS public_share_token VARCHAR(64);
"""
)
op.execute(
"""
ALTER TABLE new_chat_threads
ADD COLUMN IF NOT EXISTS public_share_enabled BOOLEAN NOT NULL DEFAULT FALSE;
"""
)
op.execute(
"""
ALTER TABLE new_chat_threads
ADD COLUMN IF NOT EXISTS clone_pending BOOLEAN NOT NULL DEFAULT FALSE;
"""
)
# 2. Restore indexes
op.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS ix_new_chat_threads_public_share_token
ON new_chat_threads(public_share_token)
WHERE public_share_token IS NOT NULL;
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_new_chat_threads_public_share_enabled
ON new_chat_threads(public_share_enabled)
WHERE public_share_enabled = TRUE;
"""
)
# 3. Drop public_chat_snapshots table and its indexes
op.execute("DROP INDEX IF EXISTS ix_public_chat_snapshots_message_ids")
op.execute("DROP INDEX IF EXISTS ix_public_chat_snapshots_created_by_user_id")
op.execute("DROP INDEX IF EXISTS ix_public_chat_snapshots_content_hash")
op.execute("DROP INDEX IF EXISTS ix_public_chat_snapshots_share_token")
op.execute("DROP INDEX IF EXISTS ix_public_chat_snapshots_thread_id")
op.execute("DROP TABLE IF EXISTS public_chat_snapshots")

View file

@ -411,21 +411,6 @@ class NewChatThread(BaseModel, TimestampMixin):
index=True, index=True,
) )
# Public sharing - cryptographic token for public URL access
public_share_token = Column(
String(64),
nullable=True,
unique=True,
index=True,
)
# Whether public sharing is currently enabled for this thread
public_share_enabled = Column(
Boolean,
nullable=False,
default=False,
server_default="false",
)
# Clone tracking - for audit and history bootstrap # Clone tracking - for audit and history bootstrap
cloned_from_thread_id = Column( cloned_from_thread_id = Column(
Integer, Integer,
@ -433,6 +418,12 @@ class NewChatThread(BaseModel, TimestampMixin):
nullable=True, nullable=True,
index=True, index=True,
) )
cloned_from_snapshot_id = Column(
Integer,
ForeignKey("public_chat_snapshots.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
cloned_at = Column( cloned_at = Column(
TIMESTAMP(timezone=True), TIMESTAMP(timezone=True),
nullable=True, nullable=True,
@ -444,13 +435,6 @@ class NewChatThread(BaseModel, TimestampMixin):
default=False, default=False,
server_default="false", server_default="false",
) )
# Flag indicating content clone is pending (two-phase clone)
clone_pending = Column(
Boolean,
nullable=False,
default=False,
server_default="false",
)
# Relationships # Relationships
search_space = relationship("SearchSpace", back_populates="new_chat_threads") search_space = relationship("SearchSpace", back_populates="new_chat_threads")
@ -461,6 +445,12 @@ class NewChatThread(BaseModel, TimestampMixin):
order_by="NewChatMessage.created_at", order_by="NewChatMessage.created_at",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
snapshots = relationship(
"PublicChatSnapshot",
back_populates="thread",
cascade="all, delete-orphan",
foreign_keys="[PublicChatSnapshot.thread_id]",
)
class NewChatMessage(BaseModel, TimestampMixin): class NewChatMessage(BaseModel, TimestampMixin):
@ -501,6 +491,65 @@ class NewChatMessage(BaseModel, TimestampMixin):
) )
class PublicChatSnapshot(BaseModel, TimestampMixin):
"""
Immutable snapshot of a chat thread for public sharing.
Each snapshot is a frozen copy of the chat at a specific point in time.
The snapshot_data JSONB contains all messages and metadata needed to
render the public chat without querying the original thread.
"""
__tablename__ = "public_chat_snapshots"
# Link to original thread - CASCADE DELETE when thread is deleted
thread_id = Column(
Integer,
ForeignKey("new_chat_threads.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Public access token (unique URL identifier)
share_token = Column(
String(64),
nullable=False,
unique=True,
index=True,
)
content_hash = Column(
String(64),
nullable=False,
index=True,
)
snapshot_data = Column(JSONB, nullable=False)
message_ids = Column(ARRAY(Integer), nullable=False)
created_by_user_id = Column(
UUID(as_uuid=True),
ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# Relationships
thread = relationship(
"NewChatThread",
back_populates="snapshots",
foreign_keys="[PublicChatSnapshot.thread_id]",
)
created_by = relationship("User")
# Constraints
__table_args__ = (
# Prevent duplicate snapshots of the same content for the same thread
UniqueConstraint("thread_id", "content_hash", name="uq_snapshot_thread_content_hash"),
)
class ChatComment(BaseModel, TimestampMixin): class ChatComment(BaseModel, TimestampMixin):
""" """
Comment model for comments on AI chat responses. Comment model for comments on AI chat responses.

View file

@ -37,7 +37,6 @@ from app.db import (
get_async_session, get_async_session,
) )
from app.schemas.new_chat import ( from app.schemas.new_chat import (
CompleteCloneResponse,
NewChatMessageAppend, NewChatMessageAppend,
NewChatMessageRead, NewChatMessageRead,
NewChatRequest, NewChatRequest,
@ -46,14 +45,13 @@ from app.schemas.new_chat import (
NewChatThreadUpdate, NewChatThreadUpdate,
NewChatThreadVisibilityUpdate, NewChatThreadVisibilityUpdate,
NewChatThreadWithMessages, NewChatThreadWithMessages,
PublicShareToggleRequest,
PublicShareToggleResponse,
RegenerateRequest, RegenerateRequest,
SnapshotCreateResponse,
SnapshotListResponse,
ThreadHistoryLoadResponse, ThreadHistoryLoadResponse,
ThreadListItem, ThreadListItem,
ThreadListResponse, ThreadListResponse,
) )
from app.services.public_chat_service import toggle_public_share
from app.tasks.chat.stream_new_chat import stream_new_chat from app.tasks.chat.stream_new_chat import stream_new_chat
from app.users import current_active_user from app.users import current_active_user
from app.utils.rbac import check_permission from app.utils.rbac import check_permission
@ -219,7 +217,6 @@ async def list_threads(
visibility=thread.visibility, visibility=thread.visibility,
created_by_id=thread.created_by_id, created_by_id=thread.created_by_id,
is_own_thread=is_own_thread, is_own_thread=is_own_thread,
public_share_enabled=thread.public_share_enabled,
created_at=thread.created_at, created_at=thread.created_at,
updated_at=thread.updated_at, updated_at=thread.updated_at,
) )
@ -321,7 +318,6 @@ async def search_threads(
thread.created_by_id == user.id thread.created_by_id == user.id
or (thread.created_by_id is None and is_search_space_owner) or (thread.created_by_id is None and is_search_space_owner)
), ),
public_share_enabled=thread.public_share_enabled,
created_at=thread.created_at, created_at=thread.created_at,
updated_at=thread.updated_at, updated_at=thread.updated_at,
) )
@ -670,66 +666,6 @@ async def delete_thread(
) from None ) from None
@router.post(
"/threads/{thread_id}/complete-clone", response_model=CompleteCloneResponse
)
async def complete_clone(
thread_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Complete the cloning process for a thread.
Copies messages and podcasts from the source thread.
Sets clone_pending=False and needs_history_bootstrap=True when done.
Requires authentication and ownership of the thread.
"""
from app.services.public_chat_service import complete_clone_content
try:
result = await session.execute(
select(NewChatThread).filter(NewChatThread.id == thread_id)
)
thread = result.scalars().first()
if not thread:
raise HTTPException(status_code=404, detail="Thread not found")
if thread.created_by_id != user.id:
raise HTTPException(status_code=403, detail="Not authorized")
if not thread.clone_pending:
raise HTTPException(status_code=400, detail="Clone already completed")
if not thread.cloned_from_thread_id:
raise HTTPException(
status_code=400, detail="No source thread to clone from"
)
message_count = await complete_clone_content(
session=session,
target_thread=thread,
source_thread_id=thread.cloned_from_thread_id,
target_search_space_id=thread.search_space_id,
)
return CompleteCloneResponse(
status="success",
message_count=message_count,
)
except HTTPException:
raise
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500,
detail=f"An unexpected error occurred while completing clone: {e!s}",
) from None
@router.patch("/threads/{thread_id}/visibility", response_model=NewChatThreadRead) @router.patch("/threads/{thread_id}/visibility", response_model=NewChatThreadRead)
async def update_thread_visibility( async def update_thread_visibility(
thread_id: int, thread_id: int,
@ -795,32 +731,83 @@ async def update_thread_visibility(
) from None ) from None
@router.patch( # =============================================================================
"/threads/{thread_id}/public-share", response_model=PublicShareToggleResponse # Snapshot Endpoints
) # =============================================================================
async def update_thread_public_share(
@router.post("/threads/{thread_id}/snapshots", response_model=SnapshotCreateResponse)
async def create_thread_snapshot(
thread_id: int, thread_id: int,
request: Request, request: Request,
toggle_request: PublicShareToggleRequest,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
): ):
""" """
Enable or disable public sharing for a thread. Create a public snapshot of the thread.
Only the creator of the thread can manage public sharing. Returns existing snapshot URL if content unchanged (deduplication).
When enabled, returns a public URL that anyone can use to view the chat. Only the thread owner can create snapshots.
""" """
from app.services.public_chat_service import create_snapshot
base_url = str(request.base_url).rstrip("/") base_url = str(request.base_url).rstrip("/")
return await toggle_public_share( return await create_snapshot(
session=session, session=session,
thread_id=thread_id, thread_id=thread_id,
enabled=toggle_request.enabled,
user=user, user=user,
base_url=base_url, base_url=base_url,
) )
@router.get("/threads/{thread_id}/snapshots", response_model=SnapshotListResponse)
async def list_thread_snapshots(
thread_id: int,
request: Request,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
List all public snapshots for this thread.
Only the thread owner can view snapshots.
"""
from app.services.public_chat_service import list_snapshots_for_thread
base_url = str(request.base_url).rstrip("/")
return SnapshotListResponse(
snapshots=await list_snapshots_for_thread(
session=session,
thread_id=thread_id,
user=user,
base_url=base_url,
)
)
@router.delete("/threads/{thread_id}/snapshots/{snapshot_id}")
async def delete_thread_snapshot(
thread_id: int,
snapshot_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Delete a specific snapshot.
Only the thread owner can delete snapshots.
"""
from app.services.public_chat_service import delete_snapshot
await delete_snapshot(
session=session,
thread_id=thread_id,
snapshot_id=snapshot_id,
user=user,
)
return {"message": "Snapshot deleted successfully"}
# ============================================================================= # =============================================================================
# Message Endpoints # Message Endpoints
# ============================================================================= # =============================================================================
@ -1286,6 +1273,8 @@ async def regenerate_response(
.limit(2) .limit(2)
) )
messages_to_delete = list(last_messages_result.scalars().all()) messages_to_delete = list(last_messages_result.scalars().all())
message_ids_to_delete = [msg.id for msg in messages_to_delete]
# Get search space for LLM config # Get search space for LLM config
search_space_result = await session.execute( search_space_result = await session.execute(
@ -1329,6 +1318,15 @@ async def regenerate_response(
for msg in messages_to_delete: for msg in messages_to_delete:
await session.delete(msg) await session.delete(msg)
await session.commit() await session.commit()
# Delete any public snapshots that contain the modified messages
from app.services.public_chat_service import (
delete_affected_snapshots,
)
await delete_affected_snapshots(
session, thread_id, message_ids_to_delete
)
except Exception as cleanup_error: except Exception as cleanup_error:
# Log but don't fail - the new messages are already streamed # Log but don't fail - the new messages are already streamed
print( print(

View file

@ -23,7 +23,7 @@ from app.db import (
get_async_session, get_async_session,
) )
from app.schemas import PodcastRead from app.schemas import PodcastRead
from app.users import current_active_user, current_optional_user from app.users import current_active_user
from app.utils.rbac import check_permission from app.utils.rbac import check_permission
router = APIRouter() router = APIRouter()
@ -82,17 +82,14 @@ async def read_podcasts(
async def read_podcast( async def read_podcast(
podcast_id: int, podcast_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User | None = Depends(current_optional_user), user: User = Depends(current_active_user),
): ):
""" """
Get a specific podcast by ID. Get a specific podcast by ID.
Access is allowed if: Requires authentication with PODCASTS_READ permission.
- User is authenticated with PODCASTS_READ permission, OR For public podcast access, use /public/{share_token}/podcasts/{podcast_id}/stream
- Podcast belongs to a publicly shared thread
""" """
from app.services.public_chat_service import is_podcast_publicly_accessible
try: try:
result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id)) result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id))
podcast = result.scalars().first() podcast = result.scalars().first()
@ -103,18 +100,13 @@ async def read_podcast(
detail="Podcast not found", detail="Podcast not found",
) )
is_public = await is_podcast_publicly_accessible(session, podcast_id) await check_permission(
session,
if not is_public: user,
if not user: podcast.search_space_id,
raise HTTPException(status_code=401, detail="Authentication required") Permission.PODCASTS_READ.value,
await check_permission( "You don't have permission to read podcasts in this search space",
session, )
user,
podcast.search_space_id,
Permission.PODCASTS_READ.value,
"You don't have permission to read podcasts in this search space",
)
return PodcastRead.from_orm_with_entries(podcast) return PodcastRead.from_orm_with_entries(podcast)
except HTTPException as he: except HTTPException as he:
@ -168,19 +160,16 @@ async def delete_podcast(
async def stream_podcast( async def stream_podcast(
podcast_id: int, podcast_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User | None = Depends(current_optional_user), user: User = Depends(current_active_user),
): ):
""" """
Stream a podcast audio file. Stream a podcast audio file.
Access is allowed if: Requires authentication with PODCASTS_READ permission.
- User is authenticated with PODCASTS_READ permission, OR For public podcast access, use /public/{share_token}/podcasts/{podcast_id}/stream
- Podcast belongs to a publicly shared thread
Note: Both /stream and /audio endpoints are supported for compatibility. Note: Both /stream and /audio endpoints are supported for compatibility.
""" """
from app.services.public_chat_service import is_podcast_publicly_accessible
try: try:
result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id)) result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id))
podcast = result.scalars().first() podcast = result.scalars().first()
@ -188,19 +177,13 @@ async def stream_podcast(
if not podcast: if not podcast:
raise HTTPException(status_code=404, detail="Podcast not found") raise HTTPException(status_code=404, detail="Podcast not found")
is_public = await is_podcast_publicly_accessible(session, podcast_id) await check_permission(
session,
if not is_public: user,
if not user: podcast.search_space_id,
raise HTTPException(status_code=401, detail="Authentication required") Permission.PODCASTS_READ.value,
"You don't have permission to access podcasts in this search space",
await check_permission( )
session,
user,
podcast.search_space_id,
Permission.PODCASTS_READ.value,
"You don't have permission to access podcasts in this search space",
)
file_path = podcast.file_location file_path = podcast.file_location

View file

@ -1,21 +1,25 @@
""" """
Routes for public chat access (unauthenticated and mixed-auth endpoints). Routes for public chat access via immutable snapshots.
All public endpoints use share_token for access - no authentication required
for read operations. Clone requires authentication.
""" """
from datetime import UTC, datetime import os
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import ChatVisibility, NewChatThread, User, get_async_session from app.db import User, get_async_session
from app.schemas.new_chat import ( from app.schemas.new_chat import (
CloneInitResponse, CloneResponse,
PublicChatResponse, PublicChatResponse,
) )
from app.services.public_chat_service import ( from app.services.public_chat_service import (
clone_from_snapshot,
get_public_chat, get_public_chat,
get_thread_by_share_token, get_snapshot_podcast,
get_user_default_search_space,
) )
from app.users import current_active_user from app.users import current_active_user
@ -28,57 +32,85 @@ async def read_public_chat(
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
): ):
""" """
Get a public chat by share token. Get a public chat snapshot by share token.
No authentication required. No authentication required.
Returns sanitized content (citations stripped). Returns immutable snapshot data (sanitized, citations stripped).
""" """
return await get_public_chat(session, share_token) return await get_public_chat(session, share_token)
@router.post("/{share_token}/clone", response_model=CloneInitResponse) @router.post("/{share_token}/clone", response_model=CloneResponse)
async def clone_public_chat_endpoint( async def clone_public_chat(
share_token: str, share_token: str,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
): ):
""" """
Initialize cloning a public chat to the user's account. Clone a public chat snapshot to the user's account.
Creates an empty thread with clone_pending=True.
Frontend should redirect to the new thread and call /complete-clone.
Creates thread and copies messages.
Requires authentication. Requires authentication.
""" """
source_thread = await get_thread_by_share_token(session, share_token) return await clone_from_snapshot(session, share_token, user)
if not source_thread:
raise HTTPException(
status_code=404, detail="Chat not found or no longer public"
)
target_search_space_id = await get_user_default_search_space(session, user.id) @router.get("/{share_token}/podcasts/{podcast_id}")
async def get_public_podcast(
share_token: str,
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
):
"""
Get podcast details from a public chat snapshot.
if target_search_space_id is None: No authentication required - the share_token provides access.
raise HTTPException(status_code=400, detail="No search space found for user") Returns podcast info including transcript.
"""
podcast_info = await get_snapshot_podcast(session, share_token, podcast_id)
new_thread = NewChatThread( if not podcast_info:
title=source_thread.title, raise HTTPException(status_code=404, detail="Podcast not found")
archived=False,
visibility=ChatVisibility.PRIVATE, return {
search_space_id=target_search_space_id, "id": podcast_info.get("original_id"),
created_by_id=user.id, "title": podcast_info.get("title"),
public_share_enabled=False, "status": "ready",
cloned_from_thread_id=source_thread.id, "podcast_transcript": podcast_info.get("transcript"),
cloned_at=datetime.now(UTC), }
clone_pending=True,
)
session.add(new_thread) @router.get("/{share_token}/podcasts/{podcast_id}/stream")
await session.commit() async def stream_public_podcast(
await session.refresh(new_thread) share_token: str,
podcast_id: int,
return CloneInitResponse( session: AsyncSession = Depends(get_async_session),
thread_id=new_thread.id, ):
search_space_id=target_search_space_id, """
share_token=share_token, Stream a podcast from a public chat snapshot.
No authentication required - the share_token provides access.
Looks up podcast by original_id in the snapshot's podcasts array.
"""
podcast_info = await get_snapshot_podcast(session, share_token, podcast_id)
if not podcast_info:
raise HTTPException(status_code=404, detail="Podcast not found")
file_path = podcast_info.get("file_path")
if not file_path or not os.path.isfile(file_path):
raise HTTPException(status_code=404, detail="Podcast audio file not found")
def iterfile():
with open(file_path, mode="rb") as file_like:
yield from file_like
return StreamingResponse(
iterfile(),
media_type="audio/mpeg",
headers={
"Accept-Ranges": "bytes",
"Content-Disposition": f"inline; filename={os.path.basename(file_path)}",
},
) )

View file

@ -95,9 +95,6 @@ class NewChatThreadRead(NewChatThreadBase, IDModel):
search_space_id: int search_space_id: int
visibility: ChatVisibility visibility: ChatVisibility
created_by_id: UUID | None = None created_by_id: UUID | None = None
public_share_enabled: bool = False
public_share_token: str | None = None
clone_pending: bool = False
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@ -137,7 +134,6 @@ class ThreadListItem(BaseModel):
visibility: ChatVisibility visibility: ChatVisibility
created_by_id: UUID | None = None created_by_id: UUID | None = None
is_own_thread: bool = False is_own_thread: bool = False
public_share_enabled: bool = False
created_at: datetime = Field(alias="createdAt") created_at: datetime = Field(alias="createdAt")
updated_at: datetime = Field(alias="updatedAt") updated_at: datetime = Field(alias="updatedAt")
@ -211,22 +207,33 @@ class RegenerateRequest(BaseModel):
# ============================================================================= # =============================================================================
# Public Sharing Schemas # Public Chat Snapshot Schemas
# ============================================================================= # =============================================================================
class PublicShareToggleRequest(BaseModel): class SnapshotCreateResponse(BaseModel):
"""Request to enable/disable public sharing for a thread.""" """Response after creating a public snapshot."""
enabled: bool snapshot_id: int
share_token: str
public_url: str
is_new: bool # False if existing snapshot returned (same content)
class PublicShareToggleResponse(BaseModel): class SnapshotInfo(BaseModel):
"""Response after toggling public sharing.""" """Info about a single snapshot."""
enabled: bool id: int
public_url: str | None = None share_token: str
share_token: str | None = None public_url: str
created_at: datetime
message_count: int
class SnapshotListResponse(BaseModel):
"""List of snapshots for a thread."""
snapshots: list[SnapshotInfo]
# ============================================================================= # =============================================================================
@ -256,12 +263,8 @@ class PublicChatResponse(BaseModel):
messages: list[PublicChatMessage] messages: list[PublicChatMessage]
class CloneInitResponse(BaseModel): class CloneResponse(BaseModel):
"""Response after cloning a public snapshot."""
thread_id: int thread_id: int
search_space_id: int search_space_id: int
share_token: str
class CompleteCloneResponse(BaseModel):
status: str
message_count: int

View file

@ -1,17 +1,35 @@
""" """
Service layer for public chat sharing and cloning. Service layer for public chat sharing via immutable snapshots.
Key concepts:
- Snapshots are frozen copies of a chat at a specific point in time
- Content hash enables deduplication (same content = same URL)
- Podcasts are embedded in snapshot_data for self-contained public views
- Single-phase clone reads directly from snapshot_data
""" """
import hashlib
import json
import re import re
import secrets import secrets
from datetime import UTC, datetime
from uuid import UUID from uuid import UUID
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import select from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.db import NewChatThread, User from app.db import (
ChatVisibility,
NewChatMessage,
NewChatThread,
Podcast,
PodcastStatus,
PublicChatSnapshot,
SearchSpaceMembership,
User,
)
UI_TOOLS = { UI_TOOLS = {
"display_image", "display_image",
@ -100,20 +118,242 @@ async def get_author_display(
return user_cache[author_id] return user_cache[author_id]
async def toggle_public_share( # =============================================================================
# Content Hashing
# =============================================================================
def compute_content_hash(messages: list[dict]) -> str:
"""
Compute SHA-256 hash of message content for deduplication.
The hash is based on message IDs and content, ensuring that:
- Same messages = same hash = same URL (deduplication)
- Any change = different hash = new URL
"""
# Sort by message ID to ensure consistent ordering
sorted_messages = sorted(messages, key=lambda m: m.get("id", 0))
# Create normalized representation
normalized = []
for msg in sorted_messages:
normalized.append(
{
"id": msg.get("id"),
"role": msg.get("role"),
"content": msg.get("content"),
}
)
content_str = json.dumps(normalized, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(content_str.encode()).hexdigest()
# =============================================================================
# Snapshot Creation
# =============================================================================
async def create_snapshot(
session: AsyncSession, session: AsyncSession,
thread_id: int, thread_id: int,
enabled: bool,
user: User, user: User,
base_url: str, base_url: str,
) -> dict: ) -> dict:
""" """
Enable or disable public sharing for a thread. Create a public snapshot of a chat thread.
Only the thread owner can toggle public sharing. Returns existing snapshot if content unchanged (same hash).
When enabling, generates a new token if one doesn't exist. Returns new snapshot with unique URL if content changed.
When disabling, keeps the token for potential re-enable.
""" """
result = await session.execute(
select(NewChatThread)
.options(selectinload(NewChatThread.messages))
.filter(NewChatThread.id == thread_id)
)
thread = result.scalars().first()
if not thread:
raise HTTPException(status_code=404, detail="Thread not found")
if thread.created_by_id != user.id:
raise HTTPException(
status_code=403,
detail="Only the creator of this chat can create public snapshots",
)
# Build snapshot data
user_cache: dict[UUID, dict] = {}
messages_data = []
message_ids = []
podcasts_data = []
podcast_ids_seen: set[int] = set()
for msg in sorted(thread.messages, key=lambda m: m.created_at):
author = await get_author_display(session, msg.author_id, user_cache)
sanitized_content = sanitize_content_for_public(msg.content)
# Extract podcast references and update status to "ready" for completed podcasts
if isinstance(sanitized_content, list):
for part in sanitized_content:
if (
isinstance(part, dict)
and part.get("type") == "tool-call"
and part.get("toolName") == "generate_podcast"
):
result_data = part.get("result", {})
podcast_id = result_data.get("podcast_id")
if podcast_id and podcast_id not in podcast_ids_seen:
podcast_info = await _get_podcast_for_snapshot(
session, podcast_id
)
if podcast_info:
podcasts_data.append(podcast_info)
podcast_ids_seen.add(podcast_id)
# Update status to "ready" so frontend renders PodcastPlayer
part["result"] = {**result_data, "status": "ready"}
messages_data.append(
{
"id": msg.id,
"role": msg.role.value if hasattr(msg.role, "value") else str(msg.role),
"content": sanitized_content,
"author": author,
"author_id": str(msg.author_id) if msg.author_id else None,
"created_at": msg.created_at.isoformat() if msg.created_at else None,
}
)
message_ids.append(msg.id)
if not messages_data:
raise HTTPException(status_code=400, detail="Cannot share an empty chat")
# Compute content hash for deduplication
content_hash = compute_content_hash(messages_data)
# Check if identical snapshot already exists
existing_result = await session.execute(
select(PublicChatSnapshot).filter(
PublicChatSnapshot.thread_id == thread_id,
PublicChatSnapshot.content_hash == content_hash,
)
)
existing = existing_result.scalars().first()
if existing:
# Return existing snapshot URL
return {
"snapshot_id": existing.id,
"share_token": existing.share_token,
"public_url": f"{base_url}/public/{existing.share_token}",
"is_new": False,
}
# Get thread author info
thread_author = await get_author_display(session, thread.created_by_id, user_cache)
# Create snapshot data
snapshot_data = {
"title": thread.title,
"snapshot_at": datetime.now(UTC).isoformat(),
"author": thread_author,
"messages": messages_data,
"podcasts": podcasts_data,
}
# Create new snapshot
share_token = secrets.token_urlsafe(48)
snapshot = PublicChatSnapshot(
thread_id=thread_id,
share_token=share_token,
content_hash=content_hash,
snapshot_data=snapshot_data,
message_ids=message_ids,
created_by_user_id=user.id,
)
session.add(snapshot)
await session.commit()
await session.refresh(snapshot)
return {
"snapshot_id": snapshot.id,
"share_token": snapshot.share_token,
"public_url": f"{base_url}/public/{snapshot.share_token}",
"is_new": True,
}
async def _get_podcast_for_snapshot(
session: AsyncSession,
podcast_id: int,
) -> dict | None:
"""Get podcast info for embedding in snapshot_data."""
result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id))
podcast = result.scalars().first()
if not podcast or podcast.status != PodcastStatus.READY:
return None
return {
"original_id": podcast.id,
"title": podcast.title,
"transcript": podcast.podcast_transcript,
"file_path": podcast.file_location,
}
# =============================================================================
# Snapshot Retrieval
# =============================================================================
async def get_snapshot_by_token(
session: AsyncSession,
share_token: str,
) -> PublicChatSnapshot | None:
"""Get a snapshot by its share token."""
result = await session.execute(
select(PublicChatSnapshot).filter(
PublicChatSnapshot.share_token == share_token
)
)
return result.scalars().first()
async def get_public_chat(
session: AsyncSession,
share_token: str,
) -> dict:
"""
Get public chat data from a snapshot.
Returns sanitized content suitable for public viewing.
"""
snapshot = await get_snapshot_by_token(session, share_token)
if not snapshot:
raise HTTPException(status_code=404, detail="Not found")
data = snapshot.snapshot_data
return {
"thread": {
"title": data.get("title", "Untitled"),
"created_at": data.get("snapshot_at"),
},
"messages": data.get("messages", []),
}
async def list_snapshots_for_thread(
session: AsyncSession,
thread_id: int,
user: User,
base_url: str,
) -> list[dict]:
"""List all public snapshots for a thread."""
# Verify ownership
result = await session.execute( result = await session.execute(
select(NewChatThread).filter(NewChatThread.id == thread_id) select(NewChatThread).filter(NewChatThread.id == thread_id)
) )
@ -125,92 +365,101 @@ async def toggle_public_share(
if thread.created_by_id != user.id: if thread.created_by_id != user.id:
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,
detail="Only the creator of this chat can manage public sharing", detail="Only the creator can view snapshots",
) )
if enabled and not thread.public_share_token: # Get snapshots
thread.public_share_token = secrets.token_urlsafe(48) result = await session.execute(
select(PublicChatSnapshot)
.filter(PublicChatSnapshot.thread_id == thread_id)
.order_by(PublicChatSnapshot.created_at.desc())
)
snapshots = result.scalars().all()
thread.public_share_enabled = enabled return [
{
await session.commit() "id": s.id,
await session.refresh(thread) "share_token": s.share_token,
"public_url": f"{base_url}/public/{s.share_token}",
if enabled: "created_at": s.created_at.isoformat() if s.created_at else None,
return { "message_count": len(s.message_ids) if s.message_ids else 0,
"enabled": True,
"public_url": f"{base_url}/public/{thread.public_share_token}",
"share_token": thread.public_share_token,
} }
for s in snapshots
return { ]
"enabled": False,
"public_url": None,
"share_token": None,
}
async def get_public_chat( # =============================================================================
# Snapshot Deletion
# =============================================================================
async def delete_snapshot(
session: AsyncSession, session: AsyncSession,
share_token: str, thread_id: int,
) -> dict: snapshot_id: int,
""" user: User,
Get a public chat by share token. ) -> bool:
"""Delete a specific snapshot. Only thread owner can delete."""
Returns sanitized content suitable for public viewing. # Get snapshot with thread
"""
result = await session.execute( result = await session.execute(
select(NewChatThread) select(PublicChatSnapshot)
.options(selectinload(NewChatThread.messages)) .options(selectinload(PublicChatSnapshot.thread))
.filter( .filter(
NewChatThread.public_share_token == share_token, PublicChatSnapshot.id == snapshot_id,
NewChatThread.public_share_enabled.is_(True), PublicChatSnapshot.thread_id == thread_id,
) )
) )
thread = result.scalars().first() snapshot = result.scalars().first()
if not thread: if not snapshot:
raise HTTPException(status_code=404, detail="Not found") raise HTTPException(status_code=404, detail="Snapshot not found")
user_cache: dict[UUID, dict] = {} if snapshot.thread.created_by_id != user.id:
raise HTTPException(
messages = [] status_code=403,
for msg in sorted(thread.messages, key=lambda m: m.created_at): detail="Only the creator can delete snapshots",
author = await get_author_display(session, msg.author_id, user_cache)
sanitized_content = sanitize_content_for_public(msg.content)
messages.append(
{
"role": msg.role,
"content": sanitized_content,
"author": author,
"created_at": msg.created_at,
}
) )
return { await session.delete(snapshot)
"thread": { await session.commit()
"title": thread.title, return True
"created_at": thread.created_at,
},
"messages": messages,
}
async def get_thread_by_share_token( async def delete_affected_snapshots(
session: AsyncSession, session: AsyncSession, # noqa: ARG001 - kept for API compatibility
share_token: str, thread_id: int,
) -> NewChatThread | None: message_ids: list[int],
"""Get a thread by its public share token if sharing is enabled.""" ) -> int:
result = await session.execute( """
select(NewChatThread) Delete snapshots that contain any of the given message IDs.
.options(selectinload(NewChatThread.messages))
.filter( Called when messages are edited/deleted/regenerated.
NewChatThread.public_share_token == share_token, Uses independent session to work reliably in streaming response cleanup.
NewChatThread.public_share_enabled.is_(True), """
if not message_ids:
return 0
from sqlalchemy.dialects.postgresql import array
from app.db import async_session_maker
async with async_session_maker() as independent_session:
result = await independent_session.execute(
delete(PublicChatSnapshot)
.where(PublicChatSnapshot.thread_id == thread_id)
.where(PublicChatSnapshot.message_ids.op("&&")(array(message_ids)))
.returning(PublicChatSnapshot.id)
) )
)
return result.scalars().first() deleted_ids = result.scalars().all()
await independent_session.commit()
return len(deleted_ids)
# =============================================================================
# Cloning from Snapshot
# =============================================================================
async def get_user_default_search_space( async def get_user_default_search_space(
@ -222,8 +471,6 @@ async def get_user_default_search_space(
Returns the first search space where user is owner, or None if not found. Returns the first search space where user is owner, or None if not found.
""" """
from app.db import SearchSpaceMembership
result = await session.execute( result = await session.execute(
select(SearchSpaceMembership) select(SearchSpaceMembership)
.filter( .filter(
@ -240,140 +487,153 @@ async def get_user_default_search_space(
return None return None
async def complete_clone_content( async def clone_from_snapshot(
session: AsyncSession, session: AsyncSession,
target_thread: NewChatThread, share_token: str,
source_thread_id: int, user: User,
target_search_space_id: int, ) -> dict:
) -> int:
""" """
Copy messages and podcasts from source thread to target thread. Copy messages and podcasts from source thread to target thread.
Sets clone_pending=False and needs_history_bootstrap=True when done. Creates thread and copies messages from snapshot_data.
Returns the number of messages copied. When encountering generate_podcast tool-calls, creates cloned podcast records
and updates the podcast_id references inline.
Returns the new thread info.
""" """
from app.db import NewChatMessage import copy
result = await session.execute( snapshot = await get_snapshot_by_token(session, share_token)
select(NewChatThread)
.options(selectinload(NewChatThread.messages)) if not snapshot:
.filter(NewChatThread.id == source_thread_id) raise HTTPException(
status_code=404, detail="Chat not found or no longer public"
)
target_search_space_id = await get_user_default_search_space(session, user.id)
if target_search_space_id is None:
raise HTTPException(status_code=400, detail="No search space found for user")
data = snapshot.snapshot_data
messages_data = data.get("messages", [])
podcasts_lookup = {p.get("original_id"): p for p in data.get("podcasts", [])}
new_thread = NewChatThread(
title=data.get("title", "Cloned Chat"),
archived=False,
visibility=ChatVisibility.PRIVATE,
search_space_id=target_search_space_id,
created_by_id=user.id,
cloned_from_thread_id=snapshot.thread_id,
cloned_from_snapshot_id=snapshot.id,
cloned_at=datetime.now(UTC),
needs_history_bootstrap=True,
) )
source_thread = result.scalars().first() session.add(new_thread)
await session.flush()
if not source_thread: podcast_id_mapping: dict[int, int] = {}
raise ValueError("Source thread not found")
podcast_id_map: dict[int, int] = {} # Check which authors from snapshot still exist in DB
message_count = 0 author_ids_from_snapshot: set[UUID] = set()
for msg_data in messages_data:
if author_str := msg_data.get("author_id"):
try:
author_ids_from_snapshot.add(UUID(author_str))
except (ValueError, TypeError):
pass
for msg in sorted(source_thread.messages, key=lambda m: m.created_at): existing_authors: set[UUID] = set()
new_content = sanitize_content_for_public(msg.content) if author_ids_from_snapshot:
result = await session.execute(
select(User.id).where(User.id.in_(author_ids_from_snapshot))
)
existing_authors = {row[0] for row in result.fetchall()}
if isinstance(new_content, list): for msg_data in messages_data:
for part in new_content: role = msg_data.get("role", "user")
# Use original author if exists, otherwise None
author_id = None
if author_str := msg_data.get("author_id"):
try:
parsed_id = UUID(author_str)
if parsed_id in existing_authors:
author_id = parsed_id
except (ValueError, TypeError):
pass
content = copy.deepcopy(msg_data.get("content", []))
if isinstance(content, list):
for part in content:
if ( if (
isinstance(part, dict) isinstance(part, dict)
and part.get("type") == "tool-call" and part.get("type") == "tool-call"
and part.get("toolName") == "generate_podcast" and part.get("toolName") == "generate_podcast"
): ):
result_data = part.get("result", {}) result = part.get("result", {})
old_podcast_id = result_data.get("podcast_id") old_podcast_id = result.get("podcast_id")
if old_podcast_id and old_podcast_id not in podcast_id_map:
new_podcast_id = await _clone_podcast(
session,
old_podcast_id,
target_search_space_id,
target_thread.id,
)
if new_podcast_id:
podcast_id_map[old_podcast_id] = new_podcast_id
if old_podcast_id and old_podcast_id in podcast_id_map: if old_podcast_id and old_podcast_id not in podcast_id_mapping:
result_data["podcast_id"] = podcast_id_map[old_podcast_id] podcast_info = podcasts_lookup.get(old_podcast_id)
elif old_podcast_id: if podcast_info:
# Podcast couldn't be cloned (not ready), remove reference new_podcast = Podcast(
result_data.pop("podcast_id", None) title=podcast_info.get("title", "Cloned Podcast"),
podcast_transcript=podcast_info.get("transcript"),
file_location=podcast_info.get("file_path"),
status=PodcastStatus.READY,
search_space_id=target_search_space_id,
thread_id=new_thread.id,
)
session.add(new_podcast)
await session.flush()
podcast_id_mapping[old_podcast_id] = new_podcast.id
if old_podcast_id and old_podcast_id in podcast_id_mapping:
part["result"] = {
**result,
"podcast_id": podcast_id_mapping[old_podcast_id],
}
new_message = NewChatMessage( new_message = NewChatMessage(
thread_id=target_thread.id, thread_id=new_thread.id,
role=msg.role, role=role,
content=new_content, content=content,
author_id=msg.author_id, author_id=author_id,
created_at=msg.created_at,
) )
session.add(new_message) session.add(new_message)
message_count += 1
target_thread.clone_pending = False
target_thread.needs_history_bootstrap = True
await session.commit() await session.commit()
await session.refresh(new_thread)
return message_count return {
"thread_id": new_thread.id,
"search_space_id": target_search_space_id,
}
async def _clone_podcast( async def get_snapshot_podcast(
session: AsyncSession, session: AsyncSession,
share_token: str,
podcast_id: int, podcast_id: int,
target_search_space_id: int, ) -> dict | None:
target_thread_id: int, """
) -> int | None: Get podcast info from a snapshot by original podcast ID.
"""Clone a podcast record and its audio file. Only clones ready podcasts."""
import shutil
import uuid
from pathlib import Path
from app.db import Podcast, PodcastStatus Used for streaming podcast audio from public view.
Looks up the podcast by its original_id in the snapshot's podcasts array.
"""
snapshot = await get_snapshot_by_token(session, share_token)
result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id)) if not snapshot:
original = result.scalars().first()
if not original or original.status != PodcastStatus.READY:
return None return None
new_file_path = None podcasts = snapshot.snapshot_data.get("podcasts", [])
if original.file_location:
original_path = Path(original.file_location)
if original_path.exists():
new_filename = f"{uuid.uuid4()}_podcast.mp3"
new_dir = Path("podcasts")
new_dir.mkdir(parents=True, exist_ok=True)
new_file_path = str(new_dir / new_filename)
shutil.copy2(original.file_location, new_file_path)
new_podcast = Podcast( # Find podcast by original_id
title=original.title, for podcast in podcasts:
podcast_transcript=original.podcast_transcript, if podcast.get("original_id") == podcast_id:
file_location=new_file_path, return podcast
status=PodcastStatus.READY,
search_space_id=target_search_space_id,
thread_id=target_thread_id,
)
session.add(new_podcast)
await session.flush()
return new_podcast.id return None
async def is_podcast_publicly_accessible(
session: AsyncSession,
podcast_id: int,
) -> bool:
"""
Check if a podcast belongs to a publicly shared thread.
Uses the thread_id foreign key for efficient lookup.
"""
from app.db import Podcast
result = await session.execute(
select(Podcast)
.options(selectinload(Podcast.thread))
.filter(Podcast.id == podcast_id)
)
podcast = result.scalars().first()
if not podcast or not podcast.thread:
return False
return podcast.thread.public_share_enabled

View file

@ -42,7 +42,6 @@ import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
import { useMessagesElectric } from "@/hooks/use-messages-electric"; import { useMessagesElectric } from "@/hooks/use-messages-electric";
import { publicChatApiService } from "@/lib/apis/public-chat-api.service";
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos"; // import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
import { getBearerToken } from "@/lib/auth-utils"; import { getBearerToken } from "@/lib/auth-utils";
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter"; import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
@ -142,8 +141,6 @@ export default function NewChatPage() {
const params = useParams(); const params = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isInitializing, setIsInitializing] = useState(true); const [isInitializing, setIsInitializing] = useState(true);
const [isCompletingClone, setIsCompletingClone] = useState(false);
const [cloneError, setCloneError] = useState(false);
const [threadId, setThreadId] = useState<number | null>(null); const [threadId, setThreadId] = useState<number | null>(null);
const [currentThread, setCurrentThread] = useState<ThreadRecord | null>(null); const [currentThread, setCurrentThread] = useState<ThreadRecord | null>(null);
const [messages, setMessages] = useState<ThreadMessageLike[]>([]); const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
@ -332,42 +329,6 @@ export default function NewChatPage() {
initializeThread(); initializeThread();
}, [initializeThread]); }, [initializeThread]);
// Handle clone completion when thread has clone_pending flag
useEffect(() => {
if (!currentThread?.clone_pending || isCompletingClone || cloneError) return;
const completeClone = async () => {
setIsCompletingClone(true);
try {
await publicChatApiService.completeClone({ thread_id: currentThread.id });
// Re-initialize thread to fetch cloned content using existing logic
await initializeThread();
// Invalidate threads query to update sidebar
queryClient.invalidateQueries({
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads",
});
} catch (error) {
console.error("[NewChatPage] Failed to complete clone:", error);
toast.error("Failed to copy chat content. Please try again.");
setCloneError(true);
} finally {
setIsCompletingClone(false);
}
};
completeClone();
}, [
currentThread?.clone_pending,
currentThread?.id,
isCompletingClone,
cloneError,
initializeThread,
queryClient,
]);
// Handle scroll to comment from URL query params (e.g., from inbox item click) // Handle scroll to comment from URL query params (e.g., from inbox item click)
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const targetCommentIdParam = searchParams.get("commentId"); const targetCommentIdParam = searchParams.get("commentId");
@ -394,8 +355,6 @@ export default function NewChatPage() {
visibility: currentThread?.visibility ?? null, visibility: currentThread?.visibility ?? null,
hasComments: currentThread?.has_comments ?? false, hasComments: currentThread?.has_comments ?? false,
addingCommentToMessageId: null, addingCommentToMessageId: null,
publicShareEnabled: currentThread?.public_share_enabled ?? false,
publicShareToken: currentThread?.public_share_token ?? null,
})); }));
}, [currentThread, setCurrentThreadState]); }, [currentThread, setCurrentThreadState]);
@ -1420,16 +1379,6 @@ export default function NewChatPage() {
); );
} }
// Show loading state while completing clone
if (isCompletingClone) {
return (
<div className="flex h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
<Spinner size="lg" />
<div className="text-sm text-muted-foreground">Copying chat content...</div>
</div>
);
}
// Show error state only if we tried to load an existing thread but failed // Show error state only if we tried to load an existing thread but failed
// For new chats (urlChatId === 0), threadId being null is expected (lazy creation) // For new chats (urlChatId === 0), threadId being null is expected (lazy creation)
if (!threadId && urlChatId > 0) { if (!threadId && urlChatId > 0) {

View file

@ -1,28 +1,31 @@
import { atomWithMutation } from "jotai-tanstack-query"; import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner"; import { toast } from "sonner";
import type { import type {
TogglePublicShareRequest, CreateSnapshotRequest,
TogglePublicShareResponse, CreateSnapshotResponse,
} from "@/contracts/types/chat-threads.types"; } from "@/contracts/types/chat-threads.types";
import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service"; import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
export const togglePublicShareMutationAtom = atomWithMutation(() => ({ export const createSnapshotMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: TogglePublicShareRequest) => { mutationFn: async (request: CreateSnapshotRequest) => {
return chatThreadsApiService.togglePublicShare(request); return chatThreadsApiService.createSnapshot(request);
}, },
onSuccess: (response: TogglePublicShareResponse) => { onSuccess: (response: CreateSnapshotResponse) => {
if (response.enabled && response.share_token) { // Construct URL using frontend origin (backend returns its own URL which differs)
const publicUrl = `${window.location.origin}/public/${response.share_token}`; const publicUrl = `${window.location.origin}/public/${response.share_token}`;
navigator.clipboard.writeText(publicUrl); navigator.clipboard.writeText(publicUrl);
toast.success("Public link copied to clipboard", { if (response.is_new) {
description: "Anyone with this link can view the chat", toast.success("Public link created and copied to clipboard", {
description: "Anyone with this link can view a snapshot of this chat",
}); });
} else { } else {
toast.success("Public sharing disabled"); toast.success("Public link copied to clipboard", {
description: "This snapshot already exists",
});
} }
}, },
onError: (error: Error) => { onError: (error: Error) => {
console.error("Failed to toggle public share:", error); console.error("Failed to create snapshot:", error);
toast.error("Failed to update public sharing"); toast.error("Failed to create public link");
}, },
})); }));

View file

@ -19,8 +19,6 @@ interface CurrentThreadState {
addingCommentToMessageId: number | null; addingCommentToMessageId: number | null;
/** Whether the right-side comments panel is collapsed (desktop only) */ /** Whether the right-side comments panel is collapsed (desktop only) */
commentsCollapsed: boolean; commentsCollapsed: boolean;
publicShareEnabled: boolean;
publicShareToken: string | null;
} }
const initialState: CurrentThreadState = { const initialState: CurrentThreadState = {
@ -29,8 +27,6 @@ const initialState: CurrentThreadState = {
hasComments: false, hasComments: false,
addingCommentToMessageId: null, addingCommentToMessageId: null,
commentsCollapsed: false, commentsCollapsed: false,
publicShareEnabled: false,
publicShareToken: null,
}; };
export const currentThreadAtom = atom<CurrentThreadState>(initialState); export const currentThreadAtom = atom<CurrentThreadState>(initialState);

View file

@ -2,10 +2,10 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { Globe, Link2, User, Users } from "lucide-react"; import { Globe, User, Users } from "lucide-react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { togglePublicShareMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms"; import { createSnapshotMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms";
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom"; import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -49,19 +49,15 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
// Use Jotai atom for visibility (single source of truth) // Use Jotai atom for visibility (single source of truth)
const currentThreadState = useAtomValue(currentThreadAtom); const currentThreadState = useAtomValue(currentThreadAtom);
const setCurrentThreadState = useSetAtom(currentThreadAtom);
const setThreadVisibility = useSetAtom(setThreadVisibilityAtom); const setThreadVisibility = useSetAtom(setThreadVisibilityAtom);
// Public share mutation // Snapshot creation mutation
const { mutateAsync: togglePublicShare, isPending: isTogglingPublic } = useAtomValue( const { mutateAsync: createSnapshot, isPending: isCreatingSnapshot } = useAtomValue(
togglePublicShareMutationAtom createSnapshotMutationAtom
); );
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop // Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE"; const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
const isPublicEnabled =
currentThreadState.publicShareEnabled ?? thread?.public_share_enabled ?? false;
const publicShareToken = currentThreadState.publicShareToken ?? null;
const handleVisibilityChange = useCallback( const handleVisibilityChange = useCallback(
async (newVisibility: ChatVisibility) => { async (newVisibility: ChatVisibility) => {
@ -96,45 +92,24 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
[thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility] [thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility]
); );
const handlePublicShareToggle = useCallback(async () => { const handleCreatePublicLink = useCallback(async () => {
if (!thread) return; if (!thread) return;
try { try {
const response = await togglePublicShare({ await createSnapshot({ thread_id: thread.id });
thread_id: thread.id, setOpen(false);
enabled: !isPublicEnabled,
});
// Update atom state with response
setCurrentThreadState((prev) => ({
...prev,
publicShareEnabled: response.enabled,
publicShareToken: response.share_token,
}));
} catch (error) { } catch (error) {
console.error("Failed to toggle public share:", error); console.error("Failed to create public link:", error);
} }
}, [thread, isPublicEnabled, togglePublicShare, setCurrentThreadState]); }, [thread, createSnapshot]);
const handleCopyPublicLink = useCallback(async () => {
if (!publicShareToken) return;
const publicUrl = `${window.location.origin}/public/${publicShareToken}`;
await navigator.clipboard.writeText(publicUrl);
toast.success("Public link copied to clipboard");
}, [publicShareToken]);
// Don't show if no thread (new chat that hasn't been created yet) // Don't show if no thread (new chat that hasn't been created yet)
if (!thread) { if (!thread) {
return null; return null;
} }
const CurrentIcon = isPublicEnabled ? Globe : currentVisibility === "PRIVATE" ? User : Users; const CurrentIcon = currentVisibility === "PRIVATE" ? User : Users;
const buttonLabel = isPublicEnabled const buttonLabel = currentVisibility === "PRIVATE" ? "Private" : "Shared";
? "Public"
: currentVisibility === "PRIVATE"
? "Private"
: "Shared";
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
@ -211,67 +186,31 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
{/* Divider */} {/* Divider */}
<div className="border-t border-border my-1" /> <div className="border-t border-border my-1" />
{/* Public Share Option */} {/* Public Link Option */}
<button <button
type="button" type="button"
onClick={handlePublicShareToggle} onClick={handleCreatePublicLink}
disabled={isTogglingPublic} disabled={isCreatingSnapshot}
className={cn( className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all", "w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer", "hover:bg-accent/50 cursor-pointer",
"focus:outline-none", "focus:outline-none",
"disabled:opacity-50 disabled:cursor-not-allowed", "disabled:opacity-50 disabled:cursor-not-allowed"
isPublicEnabled && "bg-accent/80"
)} )}
> >
<div <div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted">
className={cn( <Globe className="size-4 block text-muted-foreground" />
"size-7 rounded-md shrink-0 grid place-items-center",
isPublicEnabled ? "bg-primary/10" : "bg-muted"
)}
>
<Globe
className={cn(
"size-4 block",
isPublicEnabled ? "text-primary" : "text-muted-foreground"
)}
/>
</div> </div>
<div className="flex-1 text-left min-w-0"> <div className="flex-1 text-left min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className={cn("text-sm font-medium", isPublicEnabled && "text-primary")}> <span className="text-sm font-medium">
Public {isCreatingSnapshot ? "Creating link..." : "Create public link"}
</span> </span>
{isPublicEnabled && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
ON
</span>
)}
</div> </div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug"> <p className="text-xs text-muted-foreground mt-0.5 leading-snug">
Anyone with the link can read Creates a shareable snapshot of this chat
</p> </p>
</div> </div>
{isPublicEnabled && publicShareToken && (
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
handleCopyPublicLink();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
handleCopyPublicLink();
}
}}
className="shrink-0 p-1.5 rounded-md hover:bg-muted transition-colors cursor-pointer"
title="Copy public link"
>
<Link2 className="size-4 text-muted-foreground" />
</div>
)}
</button> </button>
</div> </div>
</PopoverContent> </PopoverContent>

View file

@ -26,7 +26,7 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
share_token: shareToken, share_token: shareToken,
}); });
// Redirect to the new chat page (content will be loaded there) // Redirect to the new chat page with cloned content
router.push(`/dashboard/${response.search_space_id}/new-chat/${response.thread_id}`); router.push(`/dashboard/${response.search_space_id}/new-chat/${response.thread_id}`);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to copy chat"; const message = error instanceof Error ? error.message : "Failed to copy chat";

View file

@ -2,6 +2,7 @@
import { makeAssistantToolUI } from "@assistant-ui/react"; import { makeAssistantToolUI } from "@assistant-ui/react";
import { AlertCircleIcon, MicIcon } from "lucide-react"; import { AlertCircleIcon, MicIcon } from "lucide-react";
import { useParams, usePathname } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { z } from "zod"; import { z } from "zod";
import { Audio } from "@/components/tool-ui/audio"; import { Audio } from "@/components/tool-ui/audio";
@ -172,9 +173,6 @@ function AudioLoadingState({ title }: { title: string }) {
); );
} }
/**
* Podcast Player Component - Fetches audio and transcript with authentication
*/
function PodcastPlayer({ function PodcastPlayer({
podcastId, podcastId,
title, title,
@ -186,6 +184,11 @@ function PodcastPlayer({
description: string; description: string;
durationMs?: number; durationMs?: number;
}) { }) {
const params = useParams();
const pathname = usePathname();
const isPublicRoute = pathname?.startsWith("/public/");
const shareToken = isPublicRoute && typeof params?.token === "string" ? params.token : null;
const [audioSrc, setAudioSrc] = useState<string | null>(null); const [audioSrc, setAudioSrc] = useState<string | null>(null);
const [transcript, setTranscript] = useState<PodcastTranscriptEntry[] | null>(null); const [transcript, setTranscript] = useState<PodcastTranscriptEntry[] | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -217,30 +220,46 @@ function PodcastPlayer({
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60s timeout const timeoutId = setTimeout(() => controller.abort(), 60000); // 60s timeout
try { try {
// Fetch audio blob and podcast details in parallel let audioBlob: Blob;
const [audioResponse, rawPodcastDetails] = await Promise.all([ let rawPodcastDetails: unknown = null;
authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`,
{ method: "GET", signal: controller.signal }
),
baseApiService.get<unknown>(`/api/v1/podcasts/${podcastId}`),
]);
if (!audioResponse.ok) { if (shareToken) {
throw new Error(`Failed to load audio: ${audioResponse.status}`); // Public view - use public endpoints (baseApiService handles no-auth for /api/v1/public/)
const [blob, details] = await Promise.all([
baseApiService.getBlob(`/api/v1/public/${shareToken}/podcasts/${podcastId}/stream`),
baseApiService.get(`/api/v1/public/${shareToken}/podcasts/${podcastId}`),
]);
audioBlob = blob;
rawPodcastDetails = details;
} else {
// Authenticated view - fetch audio and details in parallel
const [audioResponse, details] = await Promise.all([
authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`,
{ method: "GET", signal: controller.signal }
),
baseApiService.get<unknown>(`/api/v1/podcasts/${podcastId}`),
]);
if (!audioResponse.ok) {
throw new Error(`Failed to load audio: ${audioResponse.status}`);
}
audioBlob = await audioResponse.blob();
rawPodcastDetails = details;
} }
const audioBlob = await audioResponse.blob();
// Create object URL from blob // Create object URL from blob
const objectUrl = URL.createObjectURL(audioBlob); const objectUrl = URL.createObjectURL(audioBlob);
objectUrlRef.current = objectUrl; objectUrlRef.current = objectUrl;
setAudioSrc(objectUrl); setAudioSrc(objectUrl);
// Parse and validate podcast details, then set transcript // Parse and validate podcast details, then set transcript
const podcastDetails = parsePodcastDetails(rawPodcastDetails); if (rawPodcastDetails) {
if (podcastDetails.podcast_transcript) { const podcastDetails = parsePodcastDetails(rawPodcastDetails);
setTranscript(podcastDetails.podcast_transcript); if (podcastDetails.podcast_transcript) {
setTranscript(podcastDetails.podcast_transcript);
}
} }
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
@ -255,7 +274,7 @@ function PodcastPlayer({
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [podcastId]); }, [podcastId, shareToken]);
// Load podcast when component mounts // Load podcast when component mounts
useEffect(() => { useEffect(() => {

View file

@ -1,19 +1,53 @@
import { z } from "zod"; import { z } from "zod";
/** /**
* Toggle public share * Snapshot info
*/ */
export const togglePublicShareRequest = z.object({ export const snapshotInfo = z.object({
thread_id: z.number(), id: z.number(),
enabled: z.boolean(), share_token: z.string(),
public_url: z.string(),
created_at: z.string(),
message_count: z.number(),
}); });
export const togglePublicShareResponse = z.object({ /**
enabled: z.boolean(), * Create snapshot
public_url: z.string().nullable(), */
share_token: z.string().nullable(), export const createSnapshotRequest = z.object({
thread_id: z.number(),
});
export const createSnapshotResponse = z.object({
snapshot_id: z.number(),
share_token: z.string(),
public_url: z.string(),
is_new: z.boolean(),
});
/**
* List snapshots
*/
export const listSnapshotsRequest = z.object({
thread_id: z.number(),
});
export const listSnapshotsResponse = z.object({
snapshots: z.array(snapshotInfo),
});
/**
* Delete snapshot
*/
export const deleteSnapshotRequest = z.object({
thread_id: z.number(),
snapshot_id: z.number(),
}); });
// Type exports // Type exports
export type TogglePublicShareRequest = z.infer<typeof togglePublicShareRequest>; export type SnapshotInfo = z.infer<typeof snapshotInfo>;
export type TogglePublicShareResponse = z.infer<typeof togglePublicShareResponse>; export type CreateSnapshotRequest = z.infer<typeof createSnapshotRequest>;
export type CreateSnapshotResponse = z.infer<typeof createSnapshotResponse>;
export type ListSnapshotsRequest = z.infer<typeof listSnapshotsRequest>;
export type ListSnapshotsResponse = z.infer<typeof listSnapshotsResponse>;
export type DeleteSnapshotRequest = z.infer<typeof deleteSnapshotRequest>;

View file

@ -39,7 +39,7 @@ export const getPublicChatResponse = z.object({
}); });
/** /**
* Clone public chat (init) * Clone public chat
*/ */
export const clonePublicChatRequest = z.object({ export const clonePublicChatRequest = z.object({
share_token: z.string(), share_token: z.string(),
@ -48,19 +48,6 @@ export const clonePublicChatRequest = z.object({
export const clonePublicChatResponse = z.object({ export const clonePublicChatResponse = z.object({
thread_id: z.number(), thread_id: z.number(),
search_space_id: z.number(), search_space_id: z.number(),
share_token: z.string(),
});
/**
* Complete clone
*/
export const completeCloneRequest = z.object({
thread_id: z.number(),
});
export const completeCloneResponse = z.object({
status: z.string(),
message_count: z.number(),
}); });
// Type exports // Type exports
@ -71,5 +58,3 @@ export type GetPublicChatRequest = z.infer<typeof getPublicChatRequest>;
export type GetPublicChatResponse = z.infer<typeof getPublicChatResponse>; export type GetPublicChatResponse = z.infer<typeof getPublicChatResponse>;
export type ClonePublicChatRequest = z.infer<typeof clonePublicChatRequest>; export type ClonePublicChatRequest = z.infer<typeof clonePublicChatRequest>;
export type ClonePublicChatResponse = z.infer<typeof clonePublicChatResponse>; export type ClonePublicChatResponse = z.infer<typeof clonePublicChatResponse>;
export type CompleteCloneRequest = z.infer<typeof completeCloneRequest>;
export type CompleteCloneResponse = z.infer<typeof completeCloneResponse>;

View file

@ -26,7 +26,7 @@ class BaseApiService {
noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"]; noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"];
// Prefixes that don't require auth (checked with startsWith) // Prefixes that don't require auth (checked with startsWith)
noAuthPrefixes: string[] = ["/api/v1/public/", "/api/v1/podcasts/"]; noAuthPrefixes: string[] = ["/api/v1/public/"];
// Use a getter to always read fresh token from localStorage // Use a getter to always read fresh token from localStorage
// This ensures the token is always up-to-date after login/logout // This ensures the token is always up-to-date after login/logout

View file

@ -1,31 +1,66 @@
import { import {
type TogglePublicShareRequest, type CreateSnapshotRequest,
type TogglePublicShareResponse, type CreateSnapshotResponse,
togglePublicShareRequest, createSnapshotRequest,
togglePublicShareResponse, createSnapshotResponse,
type DeleteSnapshotRequest,
deleteSnapshotRequest,
type ListSnapshotsRequest,
type ListSnapshotsResponse,
listSnapshotsRequest,
listSnapshotsResponse,
} from "@/contracts/types/chat-threads.types"; } from "@/contracts/types/chat-threads.types";
import { ValidationError } from "../error"; import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service"; import { baseApiService } from "./base-api.service";
class ChatThreadsApiService { class ChatThreadsApiService {
/** /**
* Toggle public sharing for a thread. * Create a public snapshot for a thread.
* Requires authentication.
*/ */
togglePublicShare = async ( createSnapshot = async (request: CreateSnapshotRequest): Promise<CreateSnapshotResponse> => {
request: TogglePublicShareRequest const parsed = createSnapshotRequest.safeParse(request);
): Promise<TogglePublicShareResponse> => {
const parsed = togglePublicShareRequest.safeParse(request);
if (!parsed.success) { if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`); throw new ValidationError(`Invalid request: ${errorMessage}`);
} }
return baseApiService.patch( return baseApiService.post(
`/api/v1/threads/${parsed.data.thread_id}/public-share`, `/api/v1/threads/${parsed.data.thread_id}/snapshots`,
togglePublicShareResponse, createSnapshotResponse
{ body: { enabled: parsed.data.enabled } } );
};
/**
* List all snapshots for a thread.
*/
listSnapshots = async (request: ListSnapshotsRequest): Promise<ListSnapshotsResponse> => {
const parsed = listSnapshotsRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.get(
`/api/v1/threads/${parsed.data.thread_id}/snapshots`,
listSnapshotsResponse
);
};
/**
* Delete a specific snapshot.
*/
deleteSnapshot = async (request: DeleteSnapshotRequest): Promise<void> => {
const parsed = deleteSnapshotRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
await baseApiService.delete(
`/api/v1/threads/${parsed.data.thread_id}/snapshots/${parsed.data.snapshot_id}`
); );
}; };
} }

View file

@ -1,12 +1,8 @@
import { import {
type ClonePublicChatRequest, type ClonePublicChatRequest,
type ClonePublicChatResponse, type ClonePublicChatResponse,
type CompleteCloneRequest,
type CompleteCloneResponse,
clonePublicChatRequest, clonePublicChatRequest,
clonePublicChatResponse, clonePublicChatResponse,
completeCloneRequest,
completeCloneResponse,
type GetPublicChatRequest, type GetPublicChatRequest,
type GetPublicChatResponse, type GetPublicChatResponse,
getPublicChatRequest, getPublicChatRequest,
@ -18,7 +14,6 @@ import { baseApiService } from "./base-api.service";
class PublicChatApiService { class PublicChatApiService {
/** /**
* Get a public chat by share token. * Get a public chat by share token.
* No authentication required.
*/ */
getPublicChat = async (request: GetPublicChatRequest): Promise<GetPublicChatResponse> => { getPublicChat = async (request: GetPublicChatRequest): Promise<GetPublicChatResponse> => {
const parsed = getPublicChatRequest.safeParse(request); const parsed = getPublicChatRequest.safeParse(request);
@ -33,8 +28,6 @@ class PublicChatApiService {
/** /**
* Clone a public chat to the user's account. * Clone a public chat to the user's account.
* Creates an empty thread and returns thread_id for redirect.
* Requires authentication.
*/ */
clonePublicChat = async (request: ClonePublicChatRequest): Promise<ClonePublicChatResponse> => { clonePublicChat = async (request: ClonePublicChatRequest): Promise<ClonePublicChatResponse> => {
const parsed = clonePublicChatRequest.safeParse(request); const parsed = clonePublicChatRequest.safeParse(request);
@ -49,25 +42,6 @@ class PublicChatApiService {
clonePublicChatResponse clonePublicChatResponse
); );
}; };
/**
* Complete the clone by copying messages and podcasts.
* Called from the chat page after redirect.
* Requires authentication.
*/
completeClone = async (request: CompleteCloneRequest): Promise<CompleteCloneResponse> => {
const parsed = completeCloneRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.post(
`/api/v1/threads/${parsed.data.thread_id}/complete-clone`,
completeCloneResponse
);
};
} }
export const publicChatApiService = new PublicChatApiService(); export const publicChatApiService = new PublicChatApiService();

View file

@ -24,9 +24,6 @@ export interface ThreadRecord {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
has_comments?: boolean; has_comments?: boolean;
public_share_enabled?: boolean;
public_share_token?: string | null;
clone_pending?: boolean;
} }
export interface MessageRecord { export interface MessageRecord {