chainlit 1.1.0rc1__py3-none-any.whl → 1.1.101__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.

@@ -22,7 +22,7 @@
22
22
  <script>
23
23
  const global = globalThis;
24
24
  </script>
25
- <script type="module" crossorigin src="/assets/index-032fca02.js"></script>
25
+ <script type="module" crossorigin src="/assets/index-37c9a5a9.js"></script>
26
26
  <link rel="stylesheet" href="/assets/index-d088547c.css">
27
27
  </head>
28
28
  <body>
chainlit/server.py CHANGED
@@ -119,16 +119,27 @@ async def lifespan(app: FastAPI):
119
119
 
120
120
  watch_task = asyncio.create_task(watch_files_for_changes())
121
121
 
122
+ discord_task = None
123
+
124
+ if discord_bot_token := os.environ.get("DISCORD_BOT_TOKEN"):
125
+ from chainlit.discord.app import client
126
+
127
+ discord_task = asyncio.create_task(client.start(discord_bot_token))
128
+
122
129
  try:
123
130
  yield
124
131
  finally:
125
- if watch_task:
126
- try:
132
+ try:
133
+ if watch_task:
127
134
  stop_event.set()
128
135
  watch_task.cancel()
129
136
  await watch_task
130
- except asyncio.exceptions.CancelledError:
131
- pass
137
+
138
+ if discord_task:
139
+ discord_task.cancel()
140
+ await discord_task
141
+ except asyncio.exceptions.CancelledError:
142
+ pass
132
143
 
133
144
  if FILES_DIRECTORY.is_dir():
134
145
  shutil.rmtree(FILES_DIRECTORY)
@@ -195,6 +206,18 @@ socket = SocketManager(
195
206
  )
196
207
 
197
208
 
209
+ # -------------------------------------------------------------------------------
210
+ # SLACK HANDLER
211
+ # -------------------------------------------------------------------------------
212
+
213
+ if os.environ.get("SLACK_BOT_TOKEN") and os.environ.get("SLACK_SIGNING_SECRET"):
214
+ from chainlit.slack.app import slack_app_handler
215
+
216
+ @app.post("/slack/events")
217
+ async def endpoint(req: Request):
218
+ return await slack_app_handler.handle(req)
219
+
220
+
198
221
  # -------------------------------------------------------------------------------
199
222
  # HTTP HANDLERS
200
223
  # -------------------------------------------------------------------------------
chainlit/session.py CHANGED
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
24
24
  from chainlit.types import FileDict, FileReference
25
25
  from chainlit.user import PersistedUser, User
26
26
 
27
- ClientType = Literal["app", "copilot", "teams", "slack"]
27
+ ClientType = Literal["webapp", "copilot", "teams", "slack", "discord"]
28
28
 
29
29
 
30
30
  class JSONEncoderIgnoreNonSerializable(json.JSONEncoder):
@@ -35,11 +35,21 @@ class JSONEncoderIgnoreNonSerializable(json.JSONEncoder):
35
35
  return None
36
36
 
37
37
 
38
- def clean_metadata(metadata: Dict):
39
- return json.loads(
38
+
39
+ def clean_metadata(metadata: Dict, max_size: int = 1048576):
40
+ cleaned_metadata = json.loads(
40
41
  json.dumps(metadata, cls=JSONEncoderIgnoreNonSerializable, ensure_ascii=False)
41
42
  )
42
43
 
44
+ metadata_size = len(json.dumps(cleaned_metadata).encode('utf-8'))
45
+ if metadata_size > max_size:
46
+ # Redact the metadata if it exceeds the maximum size
47
+ cleaned_metadata = {
48
+ 'message': f'Metadata size exceeds the limit of {max_size} bytes. Redacted.'
49
+ }
50
+
51
+ return cleaned_metadata
52
+
43
53
 
44
54
  class BaseSession:
45
55
  """Base object."""
@@ -80,18 +90,66 @@ class BaseSession:
80
90
  self.chat_profile = chat_profile
81
91
  self.http_referer = http_referer
82
92
 
93
+ self.files = {} # type: Dict[str, "FileDict"]
94
+
83
95
  self.id = id
84
96
 
85
97
  self.chat_settings: Dict[str, Any] = {}
86
98
 
99
+ @property
100
+ def files_dir(self):
101
+ from chainlit.config import FILES_DIRECTORY
102
+
103
+ return FILES_DIRECTORY / self.id
104
+
87
105
  async def persist_file(
88
106
  self,
89
107
  name: str,
90
108
  mime: str,
91
109
  path: Optional[str] = None,
92
110
  content: Optional[Union[bytes, str]] = None,
93
- ):
94
- return None
111
+ ) -> "FileReference":
112
+ if not path and not content:
113
+ raise ValueError(
114
+ "Either path or content must be provided to persist a file"
115
+ )
116
+
117
+ self.files_dir.mkdir(exist_ok=True)
118
+
119
+ file_id = str(uuid.uuid4())
120
+
121
+ file_path = self.files_dir / file_id
122
+
123
+ file_extension = mimetypes.guess_extension(mime)
124
+
125
+ if file_extension:
126
+ file_path = file_path.with_suffix(file_extension)
127
+
128
+ if path:
129
+ # Copy the file from the given path
130
+ async with aiofiles.open(path, "rb") as src, aiofiles.open(
131
+ file_path, "wb"
132
+ ) as dst:
133
+ await dst.write(await src.read())
134
+ elif content:
135
+ # Write the provided content to the file
136
+ async with aiofiles.open(file_path, "wb") as buffer:
137
+ if isinstance(content, str):
138
+ content = content.encode("utf-8")
139
+ await buffer.write(content)
140
+
141
+ # Get the file size
142
+ file_size = file_path.stat().st_size
143
+ # Store the file content in memory
144
+ self.files[file_id] = {
145
+ "id": file_id,
146
+ "path": file_path,
147
+ "name": name,
148
+ "type": mime,
149
+ "size": file_size,
150
+ }
151
+
152
+ return {"id": file_id}
95
153
 
96
154
  def to_persistable(self) -> Dict:
97
155
  from chainlit.user_session import user_sessions
@@ -99,6 +157,8 @@ class BaseSession:
99
157
  user_session = user_sessions.get(self.id) or {} # type: Dict
100
158
  user_session["chat_settings"] = self.chat_settings
101
159
  user_session["chat_profile"] = self.chat_profile
160
+ user_session["http_referer"] = self.http_referer
161
+ user_session["client_type"] = self.client_type
102
162
  metadata = clean_metadata(user_session)
103
163
  return metadata
104
164
 
@@ -134,6 +194,11 @@ class HTTPSession(BaseSession):
134
194
  http_referer=http_referer,
135
195
  )
136
196
 
197
+ def delete(self):
198
+ """Delete the session."""
199
+ if self.files_dir.is_dir():
200
+ shutil.rmtree(self.files_dir)
201
+
137
202
 
138
203
  class WebsocketSession(BaseSession):
139
204
  """Internal web socket session object.
@@ -147,6 +212,8 @@ class WebsocketSession(BaseSession):
147
212
  socket id for convenience.
148
213
  """
149
214
 
215
+ to_clear: bool = False
216
+
150
217
  def __init__(
151
218
  self,
152
219
  # Id from the session cookie
@@ -194,68 +261,12 @@ class WebsocketSession(BaseSession):
194
261
  self.restored = False
195
262
 
196
263
  self.thread_queues = {} # type: Dict[str, Deque[Callable]]
197
- self.files = {} # type: Dict[str, "FileDict"]
198
264
 
199
265
  ws_sessions_id[self.id] = self
200
266
  ws_sessions_sid[socket_id] = self
201
267
 
202
268
  self.languages = languages
203
269
 
204
- @property
205
- def files_dir(self):
206
- from chainlit.config import FILES_DIRECTORY
207
-
208
- return FILES_DIRECTORY / self.id
209
-
210
- async def persist_file(
211
- self,
212
- name: str,
213
- mime: str,
214
- path: Optional[str] = None,
215
- content: Optional[Union[bytes, str]] = None,
216
- ) -> "FileReference":
217
- if not path and not content:
218
- raise ValueError(
219
- "Either path or content must be provided to persist a file"
220
- )
221
-
222
- self.files_dir.mkdir(exist_ok=True)
223
-
224
- file_id = str(uuid.uuid4())
225
-
226
- file_path = self.files_dir / file_id
227
-
228
- file_extension = mimetypes.guess_extension(mime)
229
-
230
- if file_extension:
231
- file_path = file_path.with_suffix(file_extension)
232
-
233
- if path:
234
- # Copy the file from the given path
235
- async with aiofiles.open(path, "rb") as src, aiofiles.open(
236
- file_path, "wb"
237
- ) as dst:
238
- await dst.write(await src.read())
239
- elif content:
240
- # Write the provided content to the file
241
- async with aiofiles.open(file_path, "wb") as buffer:
242
- if isinstance(content, str):
243
- content = content.encode("utf-8")
244
- await buffer.write(content)
245
-
246
- # Get the file size
247
- file_size = file_path.stat().st_size
248
- # Store the file content in memory
249
- self.files[file_id] = {
250
- "id": file_id,
251
- "path": file_path,
252
- "name": name,
253
- "type": mime,
254
- "size": file_size,
255
- }
256
-
257
- return {"id": file_id}
258
-
259
270
  def restore(self, new_socket_id: str):
260
271
  """Associate a new socket id to the session."""
261
272
  ws_sessions_sid.pop(self.socket_id, None)
@@ -0,0 +1,6 @@
1
+ try:
2
+ import slack_bolt
3
+ except ModuleNotFoundError:
4
+ raise ValueError(
5
+ "The slack_bolt package is required to integrate Chainlit with a Slack app. Run `pip install slack_bolt --upgrade`"
6
+ )
chainlit/slack/app.py ADDED
@@ -0,0 +1,379 @@
1
+ import asyncio
2
+ import os
3
+ import re
4
+ import uuid
5
+ from functools import partial
6
+ from typing import Dict, List, Optional, Union
7
+
8
+ import httpx
9
+ from chainlit.config import config
10
+ from chainlit.context import ChainlitContext, HTTPSession, context_var
11
+ from chainlit.data import get_data_layer
12
+ from chainlit.element import Element, ElementDict
13
+ from chainlit.emitter import BaseChainlitEmitter
14
+ from chainlit.message import Message, StepDict
15
+ from chainlit.types import Feedback
16
+ from chainlit.user import PersistedUser, User
17
+ from chainlit.user_session import user_session
18
+ from chainlit.logger import logger
19
+ from chainlit.telemetry import trace
20
+ from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler
21
+ from slack_bolt.async_app import AsyncApp
22
+
23
+
24
+ class SlackEmitter(BaseChainlitEmitter):
25
+ def __init__(
26
+ self,
27
+ session: HTTPSession,
28
+ app: AsyncApp,
29
+ channel_id: str,
30
+ say,
31
+ enabled=False,
32
+ thread_ts: Optional[str] = None,
33
+ ):
34
+ super().__init__(session)
35
+ self.app = app
36
+ self.channel_id = channel_id
37
+ self.say = say
38
+ self.enabled = enabled
39
+ self.thread_ts = thread_ts
40
+
41
+ async def send_element(self, element_dict: ElementDict):
42
+ if not self.enabled or element_dict.get("display") != "inline":
43
+ return
44
+
45
+ persisted_file = self.session.files.get(element_dict.get("chainlitKey") or "")
46
+ file: Optional[Union[bytes, str]] = None
47
+
48
+ if persisted_file:
49
+ file = str(persisted_file["path"])
50
+ elif file_url := element_dict.get("url"):
51
+ async with httpx.AsyncClient() as client:
52
+ response = await client.get(file_url)
53
+ if response.status_code == 200:
54
+ file = response.content
55
+
56
+ if not file:
57
+ return
58
+
59
+ await self.app.client.files_upload_v2(
60
+ channel=self.channel_id,
61
+ thread_ts=self.thread_ts,
62
+ file=file,
63
+ title=element_dict.get("name"),
64
+ )
65
+
66
+ async def send_step(self, step_dict: StepDict):
67
+ if not self.enabled:
68
+ return
69
+
70
+ is_chain_of_thought = bool(step_dict.get("parentId"))
71
+ is_empty_output = not step_dict.get("output")
72
+
73
+ if is_chain_of_thought or is_empty_output:
74
+ return
75
+
76
+ enable_feedback = not step_dict.get("disableFeedback") and get_data_layer()
77
+ blocks: List[Dict] = [
78
+ {
79
+ "type": "section",
80
+ "text": {"type": "mrkdwn", "text": step_dict["output"]},
81
+ }
82
+ ]
83
+ if enable_feedback:
84
+ blocks.append(
85
+ {
86
+ "type": "actions",
87
+ "elements": [
88
+ {
89
+ "action_id": "thumbdown",
90
+ "type": "button",
91
+ "text": {
92
+ "type": "plain_text",
93
+ "emoji": True,
94
+ "text": ":thumbsdown:",
95
+ },
96
+ "value": step_dict.get("id"),
97
+ },
98
+ {
99
+ "action_id": "thumbup",
100
+ "type": "button",
101
+ "text": {
102
+ "type": "plain_text",
103
+ "emoji": True,
104
+ "text": ":thumbsup:",
105
+ },
106
+ "value": step_dict.get("id"),
107
+ },
108
+ ],
109
+ }
110
+ )
111
+ await self.say(
112
+ text=step_dict["output"], blocks=blocks, thread_ts=self.thread_ts
113
+ )
114
+
115
+ async def update_step(self, step_dict: StepDict):
116
+ if not self.enabled:
117
+ return
118
+
119
+ await self.send_step(step_dict)
120
+
121
+
122
+ slack_app = AsyncApp(
123
+ token=os.environ.get("SLACK_BOT_TOKEN"),
124
+ signing_secret=os.environ.get("SLACK_SIGNING_SECRET"),
125
+ )
126
+
127
+ @trace
128
+ def init_slack_context(
129
+ session: HTTPSession,
130
+ slack_channel_id: str,
131
+ event,
132
+ say,
133
+ thread_ts: Optional[str] = None,
134
+ ) -> ChainlitContext:
135
+ emitter = SlackEmitter(
136
+ session=session,
137
+ app=slack_app,
138
+ channel_id=slack_channel_id,
139
+ say=say,
140
+ thread_ts=thread_ts,
141
+ )
142
+ context = ChainlitContext(session=session, emitter=emitter)
143
+ context_var.set(context)
144
+ user_session.set("slack_event", event)
145
+ user_session.set(
146
+ "fetch_slack_message_history",
147
+ partial(
148
+ fetch_message_history, channel_id=slack_channel_id, thread_ts=thread_ts
149
+ ),
150
+ )
151
+ return context
152
+
153
+
154
+ slack_app_handler = AsyncSlackRequestHandler(slack_app)
155
+
156
+ users_by_slack_id: Dict[str, Union[User, PersistedUser]] = {}
157
+
158
+ USER_PREFIX = "slack_"
159
+
160
+
161
+ def clean_content(message: str):
162
+ cleaned_text = re.sub(r"<@[\w]+>", "", message).strip()
163
+ return cleaned_text
164
+
165
+
166
+ async def get_user(slack_user_id: str):
167
+ if slack_user_id in users_by_slack_id:
168
+ return users_by_slack_id[slack_user_id]
169
+
170
+ slack_user = await slack_app.client.users_info(user=slack_user_id)
171
+ slack_user_profile = slack_user["user"]["profile"]
172
+
173
+ user_email = slack_user_profile.get("email")
174
+ user = User(identifier=USER_PREFIX + user_email, metadata=slack_user_profile)
175
+
176
+ users_by_slack_id[slack_user_id] = user
177
+
178
+ if data_layer := get_data_layer():
179
+ try:
180
+ persisted_user = await data_layer.create_user(user)
181
+ if persisted_user:
182
+ users_by_slack_id[slack_user_id] = persisted_user
183
+ except Exception as e:
184
+ logger.error(f"Error creating user: {e}")
185
+
186
+ return users_by_slack_id[slack_user_id]
187
+
188
+
189
+ async def fetch_message_history(
190
+ channel_id: str, thread_ts: Optional[str] = None, limit=30
191
+ ):
192
+ if not thread_ts:
193
+ result = await slack_app.client.conversations_history(
194
+ channel=channel_id, limit=limit
195
+ )
196
+ else:
197
+ result = await slack_app.client.conversations_replies(
198
+ channel=channel_id, ts=thread_ts, limit=limit
199
+ )
200
+ if result["ok"]:
201
+ messages = result["messages"]
202
+ return messages
203
+ else:
204
+ raise Exception(f"Failed to fetch messages: {result['error']}")
205
+
206
+
207
+ async def download_slack_file(url, token):
208
+ headers = {"Authorization": f"Bearer {token}"}
209
+ async with httpx.AsyncClient() as client:
210
+ response = await client.get(url, headers=headers)
211
+ if response.status_code == 200:
212
+ return response.content
213
+ else:
214
+ return None
215
+
216
+
217
+ async def download_slack_files(session: HTTPSession, files, token):
218
+ download_coros = [
219
+ download_slack_file(file.get("url_private"), token) for file in files
220
+ ]
221
+ file_bytes_list = await asyncio.gather(*download_coros)
222
+ file_refs = []
223
+ for idx, file_bytes in enumerate(file_bytes_list):
224
+ if file_bytes:
225
+ name = files[idx].get("name")
226
+ mime_type = files[idx].get("mimetype")
227
+ file_ref = await session.persist_file(
228
+ name=name, mime=mime_type, content=file_bytes
229
+ )
230
+ file_refs.append(file_ref)
231
+
232
+ files_dicts = [
233
+ session.files[file["id"]] for file in file_refs if file["id"] in session.files
234
+ ]
235
+
236
+ file_elements = [Element.from_dict(file_dict) for file_dict in files_dicts]
237
+
238
+ return file_elements
239
+
240
+
241
+ async def process_slack_message(
242
+ event,
243
+ say,
244
+ thread_name: Optional[str] = None,
245
+ bind_thread_to_user=False,
246
+ thread_ts: Optional[str] = None,
247
+ ):
248
+ user = await get_user(event["user"])
249
+
250
+ channel_id = event["channel"]
251
+ thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, thread_ts or channel_id))
252
+
253
+ text = event.get("text")
254
+ slack_files = event.get("files", [])
255
+
256
+ session_id = str(uuid.uuid4())
257
+ session = HTTPSession(
258
+ id=session_id,
259
+ thread_id=thread_id,
260
+ user=user,
261
+ client_type="slack",
262
+ )
263
+
264
+ ctx = init_slack_context(
265
+ session=session,
266
+ slack_channel_id=channel_id,
267
+ event=event,
268
+ say=say,
269
+ thread_ts=thread_ts,
270
+ )
271
+
272
+ file_elements = await download_slack_files(
273
+ session, slack_files, slack_app.client.token
274
+ )
275
+
276
+ msg = Message(
277
+ content=clean_content(text),
278
+ elements=file_elements,
279
+ type="user_message",
280
+ author=user.metadata.get("real_name"),
281
+ )
282
+
283
+ await msg.send()
284
+
285
+ ctx.emitter.enabled = True
286
+
287
+ if on_chat_start := config.code.on_chat_start:
288
+ await on_chat_start()
289
+
290
+ if on_message := config.code.on_message:
291
+ await on_message(msg)
292
+
293
+ if on_chat_end := config.code.on_chat_end:
294
+ await on_chat_end()
295
+
296
+ if data_layer := get_data_layer():
297
+ user_id = None
298
+ if isinstance(user, PersistedUser):
299
+ user_id = user.id if bind_thread_to_user else None
300
+
301
+ try:
302
+ await data_layer.update_thread(
303
+ thread_id=thread_id,
304
+ name=thread_name or msg.content,
305
+ metadata=ctx.session.to_persistable(),
306
+ user_id=user_id
307
+ )
308
+ except Exception as e:
309
+ logger.error(f"Error updating thread: {e}")
310
+
311
+ ctx.session.delete()
312
+
313
+
314
+ @slack_app.event("app_home_opened")
315
+ async def handle_app_home_opened(event, say):
316
+ pass
317
+
318
+
319
+ @slack_app.event("app_mention")
320
+ async def handle_app_mentions(event, say):
321
+ thread_ts = event.get("thread_ts", event["ts"])
322
+ await process_slack_message(event, say, thread_ts=thread_ts)
323
+
324
+
325
+ @slack_app.event("message")
326
+ async def handle_message(message, say):
327
+ user = await get_user(message["user"])
328
+ thread_name = f"{user.identifier} Slack DM"
329
+ await process_slack_message(message, say, thread_name)
330
+
331
+
332
+ @slack_app.block_action("thumbdown")
333
+ async def thumb_down(ack, context, body):
334
+ await ack()
335
+ step_id = body["actions"][0]["value"]
336
+
337
+ if data_layer := get_data_layer():
338
+ await data_layer.upsert_feedback(Feedback(forId=step_id, value=0))
339
+
340
+ text = body["message"]["text"]
341
+ blocks = body["message"]["blocks"]
342
+ updated_blocks = [block for block in blocks if block["type"] != "actions"]
343
+ updated_blocks.append(
344
+ {
345
+ "type": "section",
346
+ "text": {"type": "mrkdwn", "text": ":thumbsdown: Feedback received."},
347
+ }
348
+ )
349
+ await context.client.chat_update(
350
+ channel=body["channel"]["id"],
351
+ ts=body["container"]["message_ts"],
352
+ text=text,
353
+ blocks=updated_blocks,
354
+ )
355
+
356
+
357
+ @slack_app.block_action("thumbup")
358
+ async def thumb_up(ack, context, body):
359
+ await ack()
360
+ step_id = body["actions"][0]["value"]
361
+
362
+ if data_layer := get_data_layer():
363
+ await data_layer.upsert_feedback(Feedback(forId=step_id, value=1))
364
+
365
+ text = body["message"]["text"]
366
+ blocks = body["message"]["blocks"]
367
+ updated_blocks = [block for block in blocks if block["type"] != "actions"]
368
+ updated_blocks.append(
369
+ {
370
+ "type": "section",
371
+ "text": {"type": "mrkdwn", "text": ":thumbsup: Feedback received."},
372
+ }
373
+ )
374
+ await context.client.chat_update(
375
+ channel=body["channel"]["id"],
376
+ ts=body["container"]["message_ts"],
377
+ text=text,
378
+ blocks=updated_blocks,
379
+ )