Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions backend/chainlit/emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from chainlit.mode import Mode
from chainlit.session import BaseSession, WebsocketSession
from chainlit.step import StepDict
from chainlit.thread_metadata import public_thread
from chainlit.types import (
AskActionResponse,
AskElementResponse,
Expand Down Expand Up @@ -193,8 +194,14 @@ def emit_call(self):
return self._get_session_property("emit_call")

def resume_thread(self, thread_dict: ThreadDict):
"""Send a thread to the UI to resume it"""
return self.emit("resume_thread", thread_dict)
"""Send a thread to the UI with only public metadata.

Args:
thread_dict: Internal thread whose session metadata must be preserved.
"""
return self.emit(
"resume_thread", public_thread(thread_dict, include_settings=True)
)

def send_resume_thread_error(self, error: str):
"""Send a thread resume error to the UI"""
Expand Down
34 changes: 25 additions & 9 deletions backend/chainlit/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
from chainlit.markdown import get_markdown_str
from chainlit.oauth_providers import get_oauth_provider
from chainlit.secret import random_secret
from chainlit.thread_metadata import public_thread
from chainlit.types import (
AskFileSpec,
CallActionRequest,
Expand Down Expand Up @@ -970,7 +971,13 @@ async def get_user_threads(
payload: GetThreadsRequest,
current_user: UserParam,
):
"""Get the threads page by page."""
"""Get paginated threads with public metadata.

Args:
request: Incoming HTTP request.
payload: Pagination and thread filters.
current_user: Authenticated owner of the requested threads.
"""

data_layer = get_data_layer()

Expand All @@ -989,7 +996,9 @@ async def get_user_threads(
payload.filter.userId = current_user.id

res = await data_layer.list_threads(payload.pagination, payload.filter)
return JSONResponse(content=res.to_dict())
response = res.to_dict()
response["data"] = [public_thread(thread) for thread in response["data"]]
return JSONResponse(content=response)


@router.get("/project/thread/{thread_id}")
Expand All @@ -998,7 +1007,13 @@ async def get_thread(
thread_id: str,
current_user: UserParam,
):
"""Get a specific thread."""
"""Get an owner's thread with public metadata.

Args:
request: Incoming HTTP request.
thread_id: Identifier of the requested thread.
current_user: Authenticated thread owner.
"""
data_layer = get_data_layer()

if not data_layer:
Expand All @@ -1010,7 +1025,7 @@ async def get_thread(
await is_thread_author(current_user.identifier, thread_id)

res = await data_layer.get_thread(thread_id)
return JSONResponse(content=res)
return JSONResponse(content=public_thread(res, include_settings=True))


@router.get("/project/share/{thread_id}")
Expand All @@ -1024,6 +1039,11 @@ async def get_shared_thread(
This endpoint is separate from the resume endpoint and does not require the caller
to be the author of the thread. It only returns the thread if its metadata
contains is_shared=True. Otherwise, it returns 404 to avoid leaking existence.

Args:
request: Incoming HTTP request.
thread_id: Identifier of the requested shared thread.
current_user: Viewer passed to the optional access callback.
"""

data_layer = get_data_layer()
Expand Down Expand Up @@ -1061,11 +1081,7 @@ async def get_shared_thread(
if (not user_can_view) and (not is_shared):
raise HTTPException(status_code=404, detail="Thread not found")

metadata.pop("chat_profile", None)
metadata.pop("chat_settings", None)
metadata.pop("env", None)
thread["metadata"] = metadata
return JSONResponse(content=thread)
return JSONResponse(content=public_thread(thread))


@router.get("/project/thread/{thread_id}/element/{element_id}")
Expand Down
37 changes: 37 additions & 0 deletions backend/chainlit/thread_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Filter thread metadata at browser response boundaries."""

import json
from typing import Optional

from chainlit.types import ThreadDict


def public_thread(
thread: Optional[ThreadDict], *, include_settings: bool = False
) -> Optional[ThreadDict]:
"""Copy a thread with only browser-visible metadata, preserving stored state.

Args:
thread: Internal thread returned by the data layer, or None.
include_settings: Include profile and settings for an owner's chat view.
"""
if thread is None:
return None

metadata = thread.get("metadata") or {}
if isinstance(metadata, str):
try:
metadata = json.loads(metadata)
except (TypeError, ValueError):
metadata = {}
if not isinstance(metadata, dict):
metadata = {}

allowed = {"is_shared", "viewer_read_only"}
if include_settings:
allowed.update({"chat_profile", "chat_settings"})

return {
**thread,
"metadata": {key: value for key, value in metadata.items() if key in allowed},
}
Loading