chainlit 1.0.401__py3-none-any.whl → 2.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of chainlit might be problematic. Click here for more details.

Files changed (112) hide show
  1. chainlit/__init__.py +98 -279
  2. chainlit/_utils.py +8 -0
  3. chainlit/action.py +12 -10
  4. chainlit/{auth.py → auth/__init__.py} +28 -36
  5. chainlit/auth/cookie.py +122 -0
  6. chainlit/auth/jwt.py +39 -0
  7. chainlit/cache.py +4 -6
  8. chainlit/callbacks.py +362 -0
  9. chainlit/chat_context.py +64 -0
  10. chainlit/chat_settings.py +3 -1
  11. chainlit/cli/__init__.py +77 -8
  12. chainlit/config.py +181 -101
  13. chainlit/context.py +42 -13
  14. chainlit/copilot/dist/index.js +8750 -903
  15. chainlit/data/__init__.py +101 -416
  16. chainlit/data/acl.py +6 -2
  17. chainlit/data/base.py +107 -0
  18. chainlit/data/chainlit_data_layer.py +608 -0
  19. chainlit/data/dynamodb.py +590 -0
  20. chainlit/data/literalai.py +500 -0
  21. chainlit/data/sql_alchemy.py +721 -0
  22. chainlit/data/storage_clients/__init__.py +0 -0
  23. chainlit/data/storage_clients/azure.py +81 -0
  24. chainlit/data/storage_clients/azure_blob.py +89 -0
  25. chainlit/data/storage_clients/base.py +26 -0
  26. chainlit/data/storage_clients/gcs.py +88 -0
  27. chainlit/data/storage_clients/s3.py +75 -0
  28. chainlit/data/utils.py +29 -0
  29. chainlit/discord/__init__.py +6 -0
  30. chainlit/discord/app.py +354 -0
  31. chainlit/element.py +91 -33
  32. chainlit/emitter.py +80 -29
  33. chainlit/frontend/dist/assets/DailyMotion-C_XC7xJI.js +1 -0
  34. chainlit/frontend/dist/assets/Dataframe-Cs4l4hA1.js +22 -0
  35. chainlit/frontend/dist/assets/Facebook-CUeCH7hk.js +1 -0
  36. chainlit/frontend/dist/assets/FilePlayer-CB-fYkx8.js +1 -0
  37. chainlit/frontend/dist/assets/Kaltura-YX6qaq72.js +1 -0
  38. chainlit/frontend/dist/assets/Mixcloud-DGV0ldjP.js +1 -0
  39. chainlit/frontend/dist/assets/Mux-CmRss5oc.js +1 -0
  40. chainlit/frontend/dist/assets/Preview-DBVJn7-H.js +1 -0
  41. chainlit/frontend/dist/assets/SoundCloud-qLUb18oY.js +1 -0
  42. chainlit/frontend/dist/assets/Streamable-BvYP7bFp.js +1 -0
  43. chainlit/frontend/dist/assets/Twitch-CTHt-sGZ.js +1 -0
  44. chainlit/frontend/dist/assets/Vidyard-B-0mCJbm.js +1 -0
  45. chainlit/frontend/dist/assets/Vimeo-Dnp7ri8q.js +1 -0
  46. chainlit/frontend/dist/assets/Wistia-DW0x_UBn.js +1 -0
  47. chainlit/frontend/dist/assets/YouTube--98FipvA.js +1 -0
  48. chainlit/frontend/dist/assets/index-D71nZ46o.js +8665 -0
  49. chainlit/frontend/dist/assets/index-g8LTJwwr.css +1 -0
  50. chainlit/frontend/dist/assets/react-plotly-Cn_BQTQw.js +3484 -0
  51. chainlit/frontend/dist/index.html +2 -4
  52. chainlit/haystack/callbacks.py +4 -7
  53. chainlit/input_widget.py +8 -4
  54. chainlit/langchain/callbacks.py +103 -68
  55. chainlit/langflow/__init__.py +1 -0
  56. chainlit/llama_index/callbacks.py +65 -40
  57. chainlit/markdown.py +22 -6
  58. chainlit/message.py +54 -56
  59. chainlit/mistralai/__init__.py +50 -0
  60. chainlit/oauth_providers.py +266 -8
  61. chainlit/openai/__init__.py +10 -18
  62. chainlit/secret.py +1 -1
  63. chainlit/server.py +789 -228
  64. chainlit/session.py +108 -90
  65. chainlit/slack/__init__.py +6 -0
  66. chainlit/slack/app.py +397 -0
  67. chainlit/socket.py +199 -116
  68. chainlit/step.py +141 -89
  69. chainlit/sync.py +2 -1
  70. chainlit/teams/__init__.py +6 -0
  71. chainlit/teams/app.py +338 -0
  72. chainlit/translations/bn.json +235 -0
  73. chainlit/translations/en-US.json +83 -4
  74. chainlit/translations/gu.json +235 -0
  75. chainlit/translations/he-IL.json +235 -0
  76. chainlit/translations/hi.json +235 -0
  77. chainlit/translations/kn.json +235 -0
  78. chainlit/translations/ml.json +235 -0
  79. chainlit/translations/mr.json +235 -0
  80. chainlit/translations/nl-NL.json +233 -0
  81. chainlit/translations/ta.json +235 -0
  82. chainlit/translations/te.json +235 -0
  83. chainlit/translations/zh-CN.json +233 -0
  84. chainlit/translations.py +60 -0
  85. chainlit/types.py +133 -28
  86. chainlit/user.py +14 -3
  87. chainlit/user_session.py +6 -3
  88. chainlit/utils.py +52 -5
  89. chainlit/version.py +3 -2
  90. {chainlit-1.0.401.dist-info → chainlit-2.0.3.dist-info}/METADATA +48 -50
  91. chainlit-2.0.3.dist-info/RECORD +106 -0
  92. chainlit/cli/utils.py +0 -24
  93. chainlit/frontend/dist/assets/index-9711593e.js +0 -723
  94. chainlit/frontend/dist/assets/index-d088547c.css +0 -1
  95. chainlit/frontend/dist/assets/react-plotly-d8762cc2.js +0 -3602
  96. chainlit/playground/__init__.py +0 -2
  97. chainlit/playground/config.py +0 -40
  98. chainlit/playground/provider.py +0 -108
  99. chainlit/playground/providers/__init__.py +0 -13
  100. chainlit/playground/providers/anthropic.py +0 -118
  101. chainlit/playground/providers/huggingface.py +0 -75
  102. chainlit/playground/providers/langchain.py +0 -89
  103. chainlit/playground/providers/openai.py +0 -408
  104. chainlit/playground/providers/vertexai.py +0 -171
  105. chainlit/translations/pt-BR.json +0 -155
  106. chainlit-1.0.401.dist-info/RECORD +0 -66
  107. /chainlit/copilot/dist/assets/{logo_dark-2a3cf740.svg → logo_dark-IkGJ_IwC.svg} +0 -0
  108. /chainlit/copilot/dist/assets/{logo_light-b078e7bc.svg → logo_light-Bb_IPh6r.svg} +0 -0
  109. /chainlit/frontend/dist/assets/{logo_dark-2a3cf740.svg → logo_dark-IkGJ_IwC.svg} +0 -0
  110. /chainlit/frontend/dist/assets/{logo_light-b078e7bc.svg → logo_light-Bb_IPh6r.svg} +0 -0
  111. {chainlit-1.0.401.dist-info → chainlit-2.0.3.dist-info}/WHEEL +0 -0
  112. {chainlit-1.0.401.dist-info → chainlit-2.0.3.dist-info}/entry_points.txt +0 -0
chainlit/socket.py CHANGED
@@ -1,22 +1,27 @@
1
1
  import asyncio
2
2
  import json
3
- import time
4
- import uuid
5
- from typing import Any, Dict, Literal
3
+ from typing import Any, Dict, Literal, Optional, Tuple, Union
4
+ from urllib.parse import unquote
5
+
6
+ from starlette.requests import cookie_parser
7
+ from typing_extensions import TypeAlias
6
8
 
7
- from chainlit.action import Action
8
9
  from chainlit.auth import get_current_user, require_login
10
+ from chainlit.chat_context import chat_context
9
11
  from chainlit.config import config
10
12
  from chainlit.context import init_ws_context
11
13
  from chainlit.data import get_data_layer
12
14
  from chainlit.logger import logger
13
15
  from chainlit.message import ErrorMessage, Message
14
- from chainlit.server import socket
16
+ from chainlit.server import sio
15
17
  from chainlit.session import WebsocketSession
16
18
  from chainlit.telemetry import trace_event
17
- from chainlit.types import UIMessagePayload
19
+ from chainlit.types import InputAudioChunk, InputAudioChunkPayload, MessagePayload
20
+ from chainlit.user import PersistedUser, User
18
21
  from chainlit.user_session import user_sessions
19
22
 
23
+ WSGIEnvironment: TypeAlias = dict[str, Any]
24
+
20
25
 
21
26
  def restore_existing_session(sid, session_id, emit_fn, emit_call_fn):
22
27
  """Restore a session from the sessionId provided by the client."""
@@ -42,11 +47,11 @@ async def resume_thread(session: WebsocketSession):
42
47
  if not thread:
43
48
  return
44
49
 
45
- author = thread.get("user").get("identifier") if thread["user"] else None
50
+ author = thread.get("userIdentifier")
46
51
  user_is_author = author == session.user.identifier
47
52
 
48
53
  if user_is_author:
49
- metadata = thread.get("metadata", {})
54
+ metadata = thread.get("metadata") or {}
50
55
  user_sessions[session.id] = metadata.copy()
51
56
  if chat_profile := metadata.get("chat_profile"):
52
57
  session.chat_profile = chat_profile
@@ -75,68 +80,69 @@ def load_user_env(user_env):
75
80
  return user_env
76
81
 
77
82
 
78
- def build_anon_user_identifier(environ):
79
- scope = environ.get("asgi.scope", {})
80
- client_ip, _ = scope.get("client")
81
- ip = environ.get("HTTP_X_FORWARDED_FOR", client_ip)
83
+ def _get_token_from_cookie(environ: WSGIEnvironment) -> Optional[str]:
84
+ if cookie_header := environ.get("HTTP_COOKIE", None):
85
+ cookies = cookie_parser(cookie_header)
86
+ return cookies.get("access_token", None)
82
87
 
83
- try:
84
- headers = scope.get("headers", {})
85
- user_agent = next(
86
- (v.decode("utf-8") for k, v in headers if k.decode("utf-8") == "user-agent")
87
- )
88
- return str(uuid.uuid5(uuid.NAMESPACE_DNS, user_agent + ip))
88
+ return None
89
+
90
+
91
+ def _get_token(environ: WSGIEnvironment, auth: dict) -> Optional[str]:
92
+ """Take WSGI environ, return access token."""
93
+ return _get_token_from_cookie(environ)
89
94
 
90
- except StopIteration:
91
- return str(uuid.uuid5(uuid.NAMESPACE_DNS, ip))
92
95
 
96
+ async def _authenticate_connection(
97
+ environ,
98
+ auth,
99
+ ) -> Union[Tuple[Union[User, PersistedUser], str], Tuple[None, None]]:
100
+ if token := _get_token(environ, auth):
101
+ user = await get_current_user(token=token)
102
+ if user:
103
+ return user, token
93
104
 
94
- @socket.on("connect")
105
+ return None, None
106
+
107
+
108
+ @sio.on("connect") # pyright: ignore [reportOptionalCall]
95
109
  async def connect(sid, environ, auth):
96
- if not config.code.on_chat_start and not config.code.on_message:
97
- logger.warning(
98
- "You need to configure at least an on_chat_start or an on_message callback"
99
- )
100
- return False
101
- user = None
102
- token = None
103
- login_required = require_login()
104
- try:
105
- # Check if the authentication is required
106
- if login_required:
107
- authorization_header = environ.get("HTTP_AUTHORIZATION")
108
- token = authorization_header.split(" ")[1] if authorization_header else None
109
- user = await get_current_user(token=token)
110
- except Exception as e:
111
- logger.info("Authentication failed")
112
- return False
110
+ user = token = None
111
+
112
+ if require_login():
113
+ try:
114
+ user, token = await _authenticate_connection(environ, auth)
115
+ except Exception as e:
116
+ logger.exception("Exception authenticating connection: %s", e)
117
+
118
+ if not user:
119
+ logger.error("Authentication failed in websocket connect.")
120
+ raise ConnectionRefusedError("authentication failed")
113
121
 
114
122
  # Session scoped function to emit to the client
115
123
  def emit_fn(event, data):
116
- if session := WebsocketSession.get(sid):
117
- if session.should_stop:
118
- session.should_stop = False
119
- raise InterruptedError("Task stopped by user")
120
- return socket.emit(event, data, to=sid)
124
+ return sio.emit(event, data, to=sid)
121
125
 
122
126
  # Session scoped function to emit to the client and wait for a response
123
127
  def emit_call_fn(event: Literal["ask", "call_fn"], data, timeout):
124
- if session := WebsocketSession.get(sid):
125
- if session.should_stop:
126
- session.should_stop = False
127
- raise InterruptedError("Task stopped by user")
128
- return socket.call(event, data, timeout=timeout, to=sid)
128
+ return sio.call(event, data, timeout=timeout, to=sid)
129
129
 
130
- session_id = environ.get("HTTP_X_CHAINLIT_SESSION_ID")
130
+ session_id = auth.get("sessionId")
131
131
  if restore_existing_session(sid, session_id, emit_fn, emit_call_fn):
132
132
  return True
133
133
 
134
- user_env_string = environ.get("HTTP_USER_ENV")
134
+ user_env_string = auth.get("userEnv")
135
135
  user_env = load_user_env(user_env_string)
136
136
 
137
- client_type = environ.get("HTTP_X_CHAINLIT_CLIENT_TYPE")
137
+ client_type = auth.get("clientType")
138
+ http_referer = environ.get("HTTP_REFERER")
139
+ http_cookie = environ.get("HTTP_COOKIE")
140
+ url_encoded_chat_profile = auth.get("chatProfile")
141
+ chat_profile = (
142
+ unquote(url_encoded_chat_profile) if url_encoded_chat_profile else None
143
+ )
138
144
 
139
- ws_session = WebsocketSession(
145
+ WebsocketSession(
140
146
  id=session_id,
141
147
  socket_id=sid,
142
148
  emit=emit_fn,
@@ -145,101 +151,119 @@ async def connect(sid, environ, auth):
145
151
  user_env=user_env,
146
152
  user=user,
147
153
  token=token,
148
- chat_profile=environ.get("HTTP_X_CHAINLIT_CHAT_PROFILE"),
149
- thread_id=environ.get("HTTP_X_CHAINLIT_THREAD_ID"),
154
+ chat_profile=chat_profile,
155
+ thread_id=auth.get("threadId"),
156
+ languages=environ.get("HTTP_ACCEPT_LANGUAGE"),
157
+ http_referer=http_referer,
158
+ http_cookie=http_cookie,
150
159
  )
151
160
 
152
161
  trace_event("connection_successful")
153
162
  return True
154
163
 
155
164
 
156
- @socket.on("connection_successful")
165
+ @sio.on("connection_successful") # pyright: ignore [reportOptionalCall]
157
166
  async def connection_successful(sid):
158
167
  context = init_ws_context(sid)
159
168
 
160
- if context.session.restored:
161
- return
162
-
163
169
  await context.emitter.task_end()
164
170
  await context.emitter.clear("clear_ask")
165
171
  await context.emitter.clear("clear_call_fn")
166
172
 
173
+ if context.session.restored:
174
+ return
175
+
167
176
  if context.session.thread_id_to_resume and config.code.on_chat_resume:
168
177
  thread = await resume_thread(context.session)
169
178
  if thread:
170
179
  context.session.has_first_interaction = True
171
- await context.emitter.emit("first_interaction", "resume")
172
- await context.emitter.resume_thread(thread)
180
+ await context.emitter.emit(
181
+ "first_interaction",
182
+ {"interaction": "resume", "thread_id": thread.get("id")},
183
+ )
173
184
  await config.code.on_chat_resume(thread)
185
+
186
+ for step in thread.get("steps", []):
187
+ if "message" in step["type"]:
188
+ chat_context.add(Message.from_dict(step))
189
+
190
+ await context.emitter.resume_thread(thread)
174
191
  return
192
+ else:
193
+ await context.emitter.send_resume_thread_error("Thread not found.")
175
194
 
176
195
  if config.code.on_chat_start:
177
- await config.code.on_chat_start()
196
+ task = asyncio.create_task(config.code.on_chat_start())
197
+ context.session.current_task = task
178
198
 
179
199
 
180
- @socket.on("clear_session")
200
+ @sio.on("clear_session") # pyright: ignore [reportOptionalCall]
181
201
  async def clean_session(sid):
182
- await disconnect(sid, force_clear=True)
202
+ session = WebsocketSession.get(sid)
203
+ if session:
204
+ session.to_clear = True
183
205
 
184
206
 
185
- @socket.on("disconnect")
186
- async def disconnect(sid, force_clear=False):
207
+ @sio.on("disconnect") # pyright: ignore [reportOptionalCall]
208
+ async def disconnect(sid):
187
209
  session = WebsocketSession.get(sid)
188
- if session:
189
- init_ws_context(session)
190
210
 
191
- if config.code.on_chat_end and session:
211
+ if not session:
212
+ return
213
+
214
+ init_ws_context(session)
215
+
216
+ if config.code.on_chat_end:
192
217
  await config.code.on_chat_end()
193
218
 
194
- if session and session.thread_id and session.has_first_interaction:
219
+ if session.thread_id and session.has_first_interaction:
195
220
  await persist_user_session(session.thread_id, session.to_persistable())
196
221
 
197
- def clear():
198
- if session := WebsocketSession.get(sid):
222
+ def clear(_sid):
223
+ if session := WebsocketSession.get(_sid):
199
224
  # Clean up the user session
200
225
  if session.id in user_sessions:
201
226
  user_sessions.pop(session.id)
202
227
  # Clean up the session
203
228
  session.delete()
204
229
 
205
- async def clear_on_timeout(sid):
206
- await asyncio.sleep(config.project.session_timeout)
207
- clear()
208
-
209
- if force_clear:
210
- clear()
230
+ if session.to_clear:
231
+ clear(sid)
211
232
  else:
233
+
234
+ async def clear_on_timeout(_sid):
235
+ await asyncio.sleep(config.project.session_timeout)
236
+ clear(_sid)
237
+
212
238
  asyncio.ensure_future(clear_on_timeout(sid))
213
239
 
214
240
 
215
- @socket.on("stop")
241
+ @sio.on("stop") # pyright: ignore [reportOptionalCall]
216
242
  async def stop(sid):
217
243
  if session := WebsocketSession.get(sid):
218
244
  trace_event("stop_task")
219
245
 
220
246
  init_ws_context(session)
221
- await Message(
222
- author="System", content="Task stopped by the user.", disable_feedback=True
223
- ).send()
247
+ await Message(content="Task manually stopped.").send()
224
248
 
225
- session.should_stop = True
249
+ if session.current_task:
250
+ session.current_task.cancel()
226
251
 
227
252
  if config.code.on_stop:
228
253
  await config.code.on_stop()
229
254
 
230
255
 
231
- async def process_message(session: WebsocketSession, payload: UIMessagePayload):
256
+ async def process_message(session: WebsocketSession, payload: MessagePayload):
232
257
  """Process a message from the user."""
233
258
  try:
234
259
  context = init_ws_context(session)
235
260
  await context.emitter.task_start()
236
- message = await context.emitter.process_user_message(payload)
261
+ message = await context.emitter.process_message(payload)
237
262
 
238
263
  if config.code.on_message:
239
- # Sleep 1ms to make sure any children step starts after the message step start
240
- time.sleep(0.001)
264
+ await asyncio.sleep(0.001)
241
265
  await config.code.on_message(message)
242
- except InterruptedError:
266
+ except asyncio.CancelledError:
243
267
  pass
244
268
  except Exception as e:
245
269
  logger.exception(e)
@@ -250,49 +274,108 @@ async def process_message(session: WebsocketSession, payload: UIMessagePayload):
250
274
  await context.emitter.task_end()
251
275
 
252
276
 
253
- @socket.on("ui_message")
254
- async def message(sid, payload: UIMessagePayload):
277
+ @sio.on("edit_message") # pyright: ignore [reportOptionalCall]
278
+ async def edit_message(sid, payload: MessagePayload):
255
279
  """Handle a message sent by the User."""
256
280
  session = WebsocketSession.require(sid)
257
- session.should_stop = False
281
+ context = init_ws_context(session)
258
282
 
259
- await process_message(session, payload)
283
+ messages = chat_context.get()
260
284
 
285
+ orig_message = None
261
286
 
262
- async def process_action(action: Action):
263
- callback = config.code.action_callbacks.get(action.name)
264
- if callback:
265
- res = await callback(action)
266
- return res
267
- else:
268
- logger.warning("No callback found for action %s", action.name)
287
+ for message in messages:
288
+ if orig_message:
289
+ await message.remove()
269
290
 
291
+ if message.id == payload["message"]["id"]:
292
+ message.content = payload["message"]["output"]
293
+ await message.update()
294
+ orig_message = message
270
295
 
271
- @socket.on("action_call")
272
- async def call_action(sid, action):
273
- """Handle an action call from the UI."""
274
- context = init_ws_context(sid)
296
+ await context.emitter.task_start()
297
+
298
+ if config.code.on_message:
299
+ try:
300
+ await config.code.on_message(orig_message)
301
+ except asyncio.CancelledError:
302
+ pass
303
+ finally:
304
+ await context.emitter.task_end()
275
305
 
276
- action = Action(**action)
277
306
 
307
+ @sio.on("client_message") # pyright: ignore [reportOptionalCall]
308
+ async def message(sid, payload: MessagePayload):
309
+ """Handle a message sent by the User."""
310
+ session = WebsocketSession.require(sid)
311
+
312
+ task = asyncio.create_task(process_message(session, payload))
313
+ session.current_task = task
314
+
315
+
316
+ @sio.on("window_message") # pyright: ignore [reportOptionalCall]
317
+ async def window_message(sid, data):
318
+ """Handle a message send by the host window."""
319
+ session = WebsocketSession.require(sid)
320
+ init_ws_context(session)
321
+
322
+ if config.code.on_window_message:
323
+ try:
324
+ await config.code.on_window_message(data)
325
+ except asyncio.CancelledError:
326
+ pass
327
+
328
+
329
+ @sio.on("audio_start") # pyright: ignore [reportOptionalCall]
330
+ async def audio_start(sid):
331
+ """Handle audio init."""
332
+ session = WebsocketSession.require(sid)
333
+
334
+ context = init_ws_context(session)
335
+ if config.code.on_audio_start:
336
+ connected = bool(await config.code.on_audio_start())
337
+ connection_state = "on" if connected else "off"
338
+ await context.emitter.update_audio_connection(connection_state)
339
+
340
+
341
+ @sio.on("audio_chunk")
342
+ async def audio_chunk(sid, payload: InputAudioChunkPayload):
343
+ """Handle an audio chunk sent by the user."""
344
+ session = WebsocketSession.require(sid)
345
+
346
+ init_ws_context(session)
347
+
348
+ if config.code.on_audio_chunk:
349
+ asyncio.create_task(config.code.on_audio_chunk(InputAudioChunk(**payload)))
350
+
351
+
352
+ @sio.on("audio_end")
353
+ async def audio_end(sid):
354
+ """Handle the end of the audio stream."""
355
+ session = WebsocketSession.require(sid)
278
356
  try:
279
- res = await process_action(action)
280
- await context.emitter.send_action_response(
281
- id=action.id, status=True, response=res if isinstance(res, str) else None
282
- )
283
-
284
- except InterruptedError:
285
- await context.emitter.send_action_response(
286
- id=action.id, status=False, response="Action interrupted by the user"
287
- )
357
+ context = init_ws_context(session)
358
+ await context.emitter.task_start()
359
+
360
+ if not session.has_first_interaction:
361
+ session.has_first_interaction = True
362
+ asyncio.create_task(context.emitter.init_thread("audio"))
363
+
364
+ if config.code.on_audio_end:
365
+ await config.code.on_audio_end()
366
+
367
+ except asyncio.CancelledError:
368
+ pass
288
369
  except Exception as e:
289
370
  logger.exception(e)
290
- await context.emitter.send_action_response(
291
- id=action.id, status=False, response="An error occured"
292
- )
371
+ await ErrorMessage(
372
+ author="Error", content=str(e) or e.__class__.__name__
373
+ ).send()
374
+ finally:
375
+ await context.emitter.task_end()
293
376
 
294
377
 
295
- @socket.on("chat_settings_change")
378
+ @sio.on("chat_settings_change")
296
379
  async def change_settings(sid, settings: Dict[str, Any]):
297
380
  """Handle change settings submit from the UI."""
298
381
  context = init_ws_context(sid)