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] );