Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
72 changes: 71 additions & 1 deletion backend/chainlit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from chainlit.cache import cache
from chainlit.chat_context import chat_context
from chainlit.chat_settings import ChatSettings
from chainlit.context import context
from chainlit.context import ChainlitContextException, context
from chainlit.element import (
Audio,
CustomElement,
Expand Down Expand Up @@ -115,6 +115,75 @@ def sleep(duration: int):
return asyncio.sleep(duration)


async def set_chat_profile(name: "str | None") -> bool:
"""
Programmatically hot-swap the chat profile of the current websocket session.

Requires ``features.hot_swap_chat_profile`` to be enabled in the project
configuration — otherwise this is a no-op and returns ``False`` so the
legacy reconnect flow is preserved.

The new profile name must match one of the profiles returned by
``@cl.set_chat_profiles``. Passing ``None`` clears the profile.

The new profile is persisted to the current thread's metadata (and, when
``features.auto_tag_thread`` is enabled, overwrites the single thread tag)
so that resuming the conversation later picks up the last used profile.

Returns ``True`` on success, ``False`` if the feature is disabled, no
session is available, or the profile name is invalid.
"""
from chainlit.config import config as global_config
from chainlit.data import get_data_layer
from chainlit.session import WebsocketSession

if not global_config.features.hot_swap_chat_profile:
return False

try:
session = context.session
except ChainlitContextException:
return False

if not isinstance(session, WebsocketSession):
return False

async with session._profile_lock:
ok = await session.set_chat_profile(name)
if not ok:
return False

data_layer = get_data_layer()
if data_layer and session.has_first_interaction and session.thread_id:
try:
await data_layer.update_thread(
thread_id=session.thread_id,
metadata=session.to_persistable(),
tags=(
[session.chat_profile]
if (
global_config.features.auto_tag_thread
and session.chat_profile
)
else []
if global_config.features.auto_tag_thread
else None
),
)
except Exception as e:
logger.warning(f"Failed to persist hot-swapped chat profile: {e}")

try:
await context.emitter.emit(
"chat_profile_updated",
{"chatProfile": session.chat_profile, "ok": True},
)
except Exception:
pass

return True


@dataclass()
class CopilotFunction:
name: str
Expand Down Expand Up @@ -212,6 +281,7 @@ def acall(self):
"password_auth_callback",
"run_sync",
"send_window_message",
"set_chat_profile",
"set_chat_profiles",
"set_starter_categories",
"set_starters",
Expand Down
1 change: 1 addition & 0 deletions backend/chainlit/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ class FeaturesSettings(BaseModel):
edit_message: bool = True
allow_thread_sharing: bool = False
favorites: bool = False
hot_swap_chat_profile: bool = False


class HeaderLink(BaseModel):
Expand Down
16 changes: 10 additions & 6 deletions backend/chainlit/data/chainlit_data_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,12 +594,6 @@ async def update_thread(
if metadata is None:
metadata = {}

thread_name = truncate(
name
if name is not None
else (metadata.get("name") if metadata and "name" in metadata else None)
)

existing = await self.execute_query(
'SELECT "metadata" FROM "Thread" WHERE id = $1',
{"thread_id": thread_id},
Expand All @@ -609,6 +603,16 @@ async def update_thread(
if thread_exists and not has_updates:
return

thread_name = truncate(
name
if name is not None
else (
metadata.get("name")
if not thread_exists and metadata and "name" in metadata
else None
)
)

base = {}
if thread_exists:
raw = existing[0].get("metadata") or {}
Expand Down
5 changes: 3 additions & 2 deletions backend/chainlit/data/sql_alchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,11 +264,12 @@ async def update_thread(
base = {k: v for k, v in base.items() if k not in to_delete}
metadata = {**base, **incoming}

is_new_thread = not thread_exists

name_value = name
if name_value is None and metadata:
if name_value is None and is_new_thread and metadata:
name_value = metadata.get("name")

is_new_thread = not thread_exists
created_at_value = await self.get_current_timestamp() if is_new_thread else None

data = {
Expand Down
59 changes: 58 additions & 1 deletion backend/chainlit/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,20 @@ def clean_metadata(metadata: Dict, max_size: int = 1048576):
metadata_size = len(json.dumps(cleaned_metadata).encode("utf-8"))
if metadata_size > max_size:
# Redact the metadata if it exceeds the maximum size
chat_settings = cleaned_metadata.get("chat_settings")
if chat_settings:
try:
settings_size = len(json.dumps(chat_settings).encode("utf-8"))
if settings_size > max_size // 2:
chat_settings = None
except Exception:
chat_settings = None

cleaned_metadata = {
"message": f"Metadata size exceeds the limit of {max_size} bytes. Redacted."
"message": f"Metadata size exceeds the limit of {max_size} bytes. Redacted.",
"chat_profile": cleaned_metadata.get("chat_profile"),
"chat_settings": chat_settings,
"client_type": cleaned_metadata.get("client_type"),
}

return cleaned_metadata
Expand Down Expand Up @@ -345,6 +357,7 @@ def __init__(
self.language = match.group(1) if match else "en-US"

self.config: ChainlitConfig = self.get_config()
self._profile_lock = asyncio.Lock()

ws_sessions_id[self.id] = self
ws_sessions_sid[socket_id] = self
Expand Down Expand Up @@ -382,6 +395,50 @@ def get_config(self) -> "ChainlitConfig":
self.config = cfg
return cfg

async def set_chat_profile(self, new_profile: Optional[str]) -> bool:
"""
Update the chat profile of this session in place (hot-swap).
"""
from chainlit.config import config as global_config
from chainlit.user_session import user_sessions

if new_profile == "":
return False

profiles = None
if new_profile is not None:
if not global_config.code.set_chat_profiles:
return False
try:
profiles = await global_config.code.set_chat_profiles(
self.user, self.language
)
except Exception as e:
logger.error(f"Error in set_chat_profiles callback: {e}")
return False
if profiles is None or not any(p.name == new_profile for p in profiles):
return False

if new_profile == self.chat_profile:
return True

self.chat_profile = new_profile
Comment thread
FosanzDev marked this conversation as resolved.

# Recompute the per-session config with the new profile's overrides (if any).
cfg = global_config
if new_profile and profiles:
current_profile = next((p for p in profiles if p.name == new_profile), None)
if current_profile and getattr(current_profile, "config_overrides", None):
cfg = global_config.with_overrides(current_profile.config_overrides)
self.config = cfg
Comment thread
FosanzDev marked this conversation as resolved.

# Keep the user_session view in sync for callbacks that read
# `cl.user_session["chat_profile"]`.
if self.id in user_sessions:
user_sessions[self.id]["chat_profile"] = new_profile

return True

def restore(self, new_socket_id: str):
"""Associate a new socket id to the session."""
ws_sessions_sid.pop(self.socket_id, None)
Expand Down
42 changes: 40 additions & 2 deletions backend/chainlit/socket.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ async def resume_thread(session: WebsocketSession):
if isinstance(metadata, str):
metadata = json.loads(metadata)
user_sessions[session.id] = metadata.copy()
if chat_profile := metadata.get("chat_profile"):
session.chat_profile = chat_profile
if "chat_profile" in metadata:
session.chat_profile = metadata["chat_profile"]
if chat_settings := metadata.get("chat_settings"):
session.chat_settings = chat_settings

Expand Down Expand Up @@ -252,6 +252,44 @@ async def clean_session(sid):
session.to_clear = True


@sio.on("set_chat_profile") # pyright: ignore [reportOptionalCall]
async def set_chat_profile(sid, payload: Dict[str, Any]):
"""Hot-swap the chat profile of an existing session.

Only active when `features.hot_swap_chat_profile` is enabled. When the
feature is disabled we simply ignore the event so a misconfigured client
cannot bypass the legacy reconnect flow. Invalid profile names trigger a
toast and leave the current profile unchanged.
"""
session = WebsocketSession.get(sid)
if not session:
return

context = init_ws_context(session)

if not config.features.hot_swap_chat_profile:
# Feature is opt-in; legacy clients that accidentally emit this event
# should not affect the session.
return

if not isinstance(payload, dict) or "chatProfile" not in payload:
return

new_profile: Optional[str] = payload["chatProfile"]
from chainlit import set_chat_profile as cl_set_chat_profile

ok = await cl_set_chat_profile(new_profile)
if not ok:
await context.emitter.send_toast(
f"Unknown chat profile: {new_profile}", type="error"
)
await context.emitter.emit(
"chat_profile_updated",
{"chatProfile": session.chat_profile, "ok": False},
)
return


@sio.on("disconnect") # pyright: ignore [reportOptionalCall]
async def disconnect(sid):
session = WebsocketSession.get(sid)
Expand Down
Loading
Loading