diff --git a/backend/chainlit/emitter.py b/backend/chainlit/emitter.py
index 12698d3d9f..42b21b58cb 100644
--- a/backend/chainlit/emitter.py
+++ b/backend/chainlit/emitter.py
@@ -1,6 +1,6 @@
import asyncio
import uuid
-from typing import Any, Dict, List, Literal, Optional, Union, cast
+from typing import Any, Dict, List, Literal, Optional, Union, cast, get_args
from literalai.helper import utc_now
from socketio.exceptions import TimeoutError
@@ -22,6 +22,7 @@
MessagePayload,
OutputAudioChunk,
ThreadDict,
+ ToastType,
)
from chainlit.user import PersistedUser
@@ -141,6 +142,10 @@ async def send_window_message(self, data: Any):
"""Stub method to send custom data to the host window."""
pass
+ def send_toast(self, message: str, type: Optional[ToastType] = "info"):
+ """Stub method to send a toast message to the UI."""
+ pass
+
class ChainlitEmitter(BaseChainlitEmitter):
"""
@@ -423,3 +428,10 @@ def set_commands(self, commands: List[CommandDict]):
def send_window_message(self, data: Any):
"""Send custom data to the host window."""
return self.emit("window_message", data)
+
+ def send_toast(self, message: str, type: Optional[ToastType] = "info"):
+ """Send a toast message to the UI."""
+ # check that the type is valid using ToastType
+ if type not in get_args(ToastType):
+ raise ValueError(f"Invalid toast type: {type}")
+ return self.emit("toast", {"message": message, "type": type})
diff --git a/backend/chainlit/types.py b/backend/chainlit/types.py
index 8a0141b39f..c08e8a7beb 100644
--- a/backend/chainlit/types.py
+++ b/backend/chainlit/types.py
@@ -25,6 +25,7 @@
InputWidgetType = Literal[
"switch", "slider", "select", "textinput", "tags", "numberinput"
]
+ToastType = Literal["info", "success", "warning", "error"]
class ThreadDict(TypedDict):
diff --git a/backend/tests/test_emitter.py b/backend/tests/test_emitter.py
index eff73c51d6..9a8290583c 100644
--- a/backend/tests/test_emitter.py
+++ b/backend/tests/test_emitter.py
@@ -137,3 +137,29 @@ async def test_stream_start(
}
await emitter.stream_start(step_dict)
mock_websocket_session.emit.assert_called_once_with("stream_start", step_dict)
+
+
+async def test_send_toast(
+ emitter: ChainlitEmitter, mock_websocket_session: MagicMock
+) -> None:
+ message = "This is a test message"
+ await emitter.send_toast(message)
+ mock_websocket_session.emit.assert_called_once_with(
+ "toast", {"message": message, "type": "info"}
+ )
+
+
+async def test_send_toast_with_type(
+ emitter: ChainlitEmitter, mock_websocket_session: MagicMock
+) -> None:
+ message = "This is a test message"
+ await emitter.send_toast(message, type="error")
+ mock_websocket_session.emit.assert_called_once_with(
+ "toast", {"message": message, "type": "error"}
+ )
+
+
+async def test_send_toast_invalid_type(emitter: ChainlitEmitter) -> None:
+ message = "This is a test message"
+ with pytest.raises(ValueError, match="Invalid toast type: invalid"):
+ await emitter.send_toast(message, type="invalid") # type: ignore[arg-type]
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b63e2650f3..f24996fb05 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -69,7 +69,7 @@ function App() {
storageKey="vite-ui-theme"
defaultTheme={data?.default_theme}
>
-
+
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 6d296d51b5..57733d1871 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -12,10 +12,11 @@ export default defineConfig({
plugins: [react(), tsconfigPaths(), svgr()],
resolve: {
alias: {
- "@": path.resolve(__dirname, "./src"),
+ '@': path.resolve(__dirname, './src'),
// To prevent conflicts with packages in @chainlit/react-client, we need to specify the resolution paths for these dependencies.
react: path.resolve(__dirname, './node_modules/react'),
'usehooks-ts': path.resolve(__dirname, './node_modules/usehooks-ts'),
+ sonner: path.resolve(__dirname, './node_modules/sonner'),
lodash: path.resolve(__dirname, './node_modules/lodash'),
recoil: path.resolve(__dirname, './node_modules/recoil')
}
diff --git a/libs/react-client/package.json b/libs/react-client/package.json
index 0845b73d14..0778a6a460 100644
--- a/libs/react-client/package.json
+++ b/libs/react-client/package.json
@@ -55,6 +55,7 @@
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"socket.io-client": "^4.7.2",
+ "sonner": "^1.7.1",
"swr": "^2.2.2",
"uuid": "^9.0.0"
},
diff --git a/libs/react-client/pnpm-lock.yaml b/libs/react-client/pnpm-lock.yaml
index 56a990e442..32fe45885c 100644
--- a/libs/react-client/pnpm-lock.yaml
+++ b/libs/react-client/pnpm-lock.yaml
@@ -43,6 +43,9 @@ importers:
socket.io-client:
specifier: ^4.7.2
version: 4.7.2
+ sonner:
+ specifier: ^1.7.1
+ version: 1.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
swr:
specifier: ^2.2.2
version: 2.2.2(react@18.3.1)
@@ -1648,6 +1651,12 @@ packages:
resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==}
engines: {node: '>=10.0.0'}
+ sonner@1.7.1:
+ resolution: {integrity: sha512-b6LHBfH32SoVasRFECrdY8p8s7hXPDn3OHUFbZZbiB1ctLS9Gdh6rpX2dVrpQA0kiL5jcRzDDldwwLkSKk3+QQ==}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -3488,6 +3497,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ sonner@1.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
source-map-js@1.2.1: {}
source-map@0.8.0-beta.0:
diff --git a/libs/react-client/src/useChatSession.ts b/libs/react-client/src/useChatSession.ts
index cc7b4a240d..4ecbc2b37a 100644
--- a/libs/react-client/src/useChatSession.ts
+++ b/libs/react-client/src/useChatSession.ts
@@ -7,6 +7,7 @@ import {
useSetRecoilState
} from 'recoil';
import io from 'socket.io-client';
+import { toast } from 'sonner';
import {
actionState,
askUserState,
@@ -365,6 +366,31 @@ const useChatSession = () => {
window.parent.postMessage(data, '*');
}
});
+
+ socket.on('toast', (data: { message: string; type: string }) => {
+ if (!data.message) {
+ console.warn('No message received for toast.');
+ return;
+ }
+
+ switch (data.type) {
+ case 'info':
+ toast.info(data.message);
+ break;
+ case 'error':
+ toast.error(data.message);
+ break;
+ case 'success':
+ toast.success(data.message);
+ break;
+ case 'warning':
+ toast.warning(data.message);
+ break;
+ default:
+ toast(data.message);
+ break;
+ }
+ });
},
[setSession, sessionId, idToResume, chatProfile]
);