AstrBot 4.7.4__py3-none-any.whl → 4.9.0__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.
Files changed (111) hide show
  1. astrbot/cli/__init__.py +1 -1
  2. astrbot/core/agent/runners/tool_loop_agent_runner.py +0 -1
  3. astrbot/core/agent/tool.py +7 -2
  4. astrbot/core/astr_agent_run_util.py +15 -1
  5. astrbot/core/astr_agent_tool_exec.py +5 -1
  6. astrbot/core/config/astrbot_config.py +4 -0
  7. astrbot/core/config/default.py +116 -1
  8. astrbot/core/core_lifecycle.py +1 -1
  9. astrbot/core/db/__init__.py +32 -4
  10. astrbot/core/db/migration/migra_3_to_4.py +2 -0
  11. astrbot/core/db/migration/sqlite_v3.py +6 -4
  12. astrbot/core/db/po.py +16 -15
  13. astrbot/core/db/sqlite.py +56 -1
  14. astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +2 -0
  15. astrbot/core/event_bus.py +6 -1
  16. astrbot/core/knowledge_base/retrieval/manager.py +5 -1
  17. astrbot/core/log.py +2 -1
  18. astrbot/core/message/components.py +9 -3
  19. astrbot/core/persona_mgr.py +2 -2
  20. astrbot/core/pipeline/content_safety_check/stage.py +1 -1
  21. astrbot/core/pipeline/context_utils.py +2 -1
  22. astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py +1 -1
  23. astrbot/core/pipeline/process_stage/method/star_request.py +1 -2
  24. astrbot/core/pipeline/process_stage/stage.py +1 -1
  25. astrbot/core/pipeline/respond/stage.py +4 -2
  26. astrbot/core/pipeline/result_decorate/stage.py +68 -21
  27. astrbot/core/pipeline/scheduler.py +5 -1
  28. astrbot/core/pipeline/waking_check/stage.py +10 -0
  29. astrbot/core/platform/astr_message_event.py +5 -3
  30. astrbot/core/platform/astrbot_message.py +2 -2
  31. astrbot/core/platform/manager.py +71 -9
  32. astrbot/core/platform/platform.py +109 -4
  33. astrbot/core/platform/platform_metadata.py +1 -1
  34. astrbot/core/platform/register.py +1 -0
  35. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +8 -6
  36. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +13 -8
  37. astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +28 -22
  38. astrbot/core/platform/sources/dingtalk/dingtalk_event.py +5 -2
  39. astrbot/core/platform/sources/discord/client.py +16 -4
  40. astrbot/core/platform/sources/discord/components.py +2 -2
  41. astrbot/core/platform/sources/discord/discord_platform_adapter.py +53 -26
  42. astrbot/core/platform/sources/discord/discord_platform_event.py +29 -8
  43. astrbot/core/platform/sources/lark/lark_adapter.py +178 -22
  44. astrbot/core/platform/sources/lark/lark_event.py +39 -4
  45. astrbot/core/platform/sources/lark/server.py +206 -0
  46. astrbot/core/platform/sources/misskey/misskey_adapter.py +3 -5
  47. astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +64 -18
  48. astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +14 -10
  49. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +36 -11
  50. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +15 -2
  51. astrbot/core/platform/sources/satori/satori_adapter.py +1 -2
  52. astrbot/core/platform/sources/slack/client.py +58 -40
  53. astrbot/core/platform/sources/slack/slack_adapter.py +36 -16
  54. astrbot/core/platform/sources/slack/slack_event.py +11 -10
  55. astrbot/core/platform/sources/telegram/tg_adapter.py +2 -3
  56. astrbot/core/platform/sources/telegram/tg_event.py +23 -27
  57. astrbot/core/platform/sources/webchat/webchat_adapter.py +97 -31
  58. astrbot/core/platform/sources/webchat/webchat_event.py +35 -35
  59. astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py +27 -11
  60. astrbot/core/platform/sources/wecom/wecom_adapter.py +75 -36
  61. astrbot/core/platform/sources/wecom/wecom_event.py +3 -3
  62. astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +26 -9
  63. astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py +3 -3
  64. astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py +27 -5
  65. astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py +81 -35
  66. astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py +11 -8
  67. astrbot/core/platform_message_history_mgr.py +3 -3
  68. astrbot/core/provider/func_tool_manager.py +3 -3
  69. astrbot/core/provider/manager.py +130 -74
  70. astrbot/core/provider/provider.py +12 -1
  71. astrbot/core/provider/sources/azure_tts_source.py +31 -9
  72. astrbot/core/provider/sources/bailian_rerank_source.py +4 -0
  73. astrbot/core/provider/sources/dashscope_tts.py +3 -2
  74. astrbot/core/provider/sources/edge_tts_source.py +1 -1
  75. astrbot/core/provider/sources/fishaudio_tts_api_source.py +5 -4
  76. astrbot/core/provider/sources/gemini_embedding_source.py +15 -5
  77. astrbot/core/provider/sources/gemini_source.py +12 -10
  78. astrbot/core/provider/sources/minimax_tts_api_source.py +4 -2
  79. astrbot/core/provider/sources/openai_embedding_source.py +2 -2
  80. astrbot/core/provider/sources/openai_source.py +4 -0
  81. astrbot/core/provider/sources/sensevoice_selfhosted_source.py +5 -2
  82. astrbot/core/provider/sources/vllm_rerank_source.py +1 -0
  83. astrbot/core/provider/sources/whisper_api_source.py +44 -12
  84. astrbot/core/provider/sources/whisper_selfhosted_source.py +6 -2
  85. astrbot/core/provider/sources/xinference_rerank_source.py +10 -2
  86. astrbot/core/star/context.py +2 -2
  87. astrbot/core/star/register/star_handler.py +22 -5
  88. astrbot/core/star/star_handler.py +85 -4
  89. astrbot/core/updator.py +3 -3
  90. astrbot/core/utils/io.py +1 -1
  91. astrbot/core/utils/session_waiter.py +17 -10
  92. astrbot/core/utils/shared_preferences.py +32 -0
  93. astrbot/core/utils/t2i/__init__.py +2 -2
  94. astrbot/core/utils/t2i/local_strategy.py +25 -31
  95. astrbot/core/utils/tencent_record_helper.py +2 -2
  96. astrbot/core/utils/version_comparator.py +6 -3
  97. astrbot/core/utils/webhook_utils.py +66 -0
  98. astrbot/dashboard/routes/__init__.py +2 -0
  99. astrbot/dashboard/routes/chat.py +311 -76
  100. astrbot/dashboard/routes/config.py +14 -5
  101. astrbot/dashboard/routes/knowledge_base.py +254 -79
  102. astrbot/dashboard/routes/log.py +13 -8
  103. astrbot/dashboard/routes/platform.py +100 -0
  104. astrbot/dashboard/routes/plugin.py +108 -51
  105. astrbot/dashboard/routes/route.py +2 -0
  106. astrbot/dashboard/server.py +9 -4
  107. {astrbot-4.7.4.dist-info → astrbot-4.9.0.dist-info}/METADATA +50 -37
  108. {astrbot-4.7.4.dist-info → astrbot-4.9.0.dist-info}/RECORD +111 -108
  109. {astrbot-4.7.4.dist-info → astrbot-4.9.0.dist-info}/WHEEL +0 -0
  110. {astrbot-4.7.4.dist-info → astrbot-4.9.0.dist-info}/entry_points.txt +0 -0
  111. {astrbot-4.7.4.dist-info → astrbot-4.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,11 +1,13 @@
1
1
  import asyncio
2
2
  import json
3
+ import mimetypes
3
4
  import os
4
5
  import uuid
5
6
  from contextlib import asynccontextmanager
7
+ from typing import cast
6
8
 
7
9
  from quart import Response as QuartResponse
8
- from quart import g, make_response, request
10
+ from quart import g, make_response, request, send_file
9
11
 
10
12
  from astrbot.core import logger
11
13
  from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -44,7 +46,7 @@ class ChatRoute(Route):
44
46
  self.update_session_display_name,
45
47
  ),
46
48
  "/chat/get_file": ("GET", self.get_file),
47
- "/chat/post_image": ("POST", self.post_image),
49
+ "/chat/get_attachment": ("GET", self.get_attachment),
48
50
  "/chat/post_file": ("POST", self.post_file),
49
51
  }
50
52
  self.core_lifecycle = core_lifecycle
@@ -73,52 +75,184 @@ class ChatRoute(Route):
73
75
  if not real_file_path.startswith(real_imgs_dir):
74
76
  return Response().error("Invalid file path").__dict__
75
77
 
76
- with open(real_file_path, "rb") as f:
77
- filename_ext = os.path.splitext(filename)[1].lower()
78
-
79
- if filename_ext == ".wav":
80
- return QuartResponse(f.read(), mimetype="audio/wav")
81
- if filename_ext[1:] in self.supported_imgs:
82
- return QuartResponse(f.read(), mimetype="image/jpeg")
83
- return QuartResponse(f.read())
78
+ filename_ext = os.path.splitext(filename)[1].lower()
79
+ if filename_ext == ".wav":
80
+ return await send_file(real_file_path, mimetype="audio/wav")
81
+ if filename_ext[1:] in self.supported_imgs:
82
+ return await send_file(real_file_path, mimetype="image/jpeg")
83
+ return await send_file(real_file_path)
84
84
 
85
85
  except (FileNotFoundError, OSError):
86
86
  return Response().error("File access error").__dict__
87
87
 
88
- async def post_image(self):
89
- post_data = await request.files
90
- if "file" not in post_data:
91
- return Response().error("Missing key: file").__dict__
88
+ async def get_attachment(self):
89
+ """Get attachment file by attachment_id."""
90
+ attachment_id = request.args.get("attachment_id")
91
+ if not attachment_id:
92
+ return Response().error("Missing key: attachment_id").__dict__
92
93
 
93
- file = post_data["file"]
94
- filename = str(uuid.uuid4()) + ".jpg"
95
- path = os.path.join(self.imgs_dir, filename)
96
- await file.save(path)
94
+ try:
95
+ attachment = await self.db.get_attachment_by_id(attachment_id)
96
+ if not attachment:
97
+ return Response().error("Attachment not found").__dict__
97
98
 
98
- return Response().ok(data={"filename": filename}).__dict__
99
+ file_path = attachment.path
100
+ real_file_path = os.path.realpath(file_path)
101
+
102
+ return await send_file(real_file_path, mimetype=attachment.mime_type)
103
+
104
+ except (FileNotFoundError, OSError):
105
+ return Response().error("File access error").__dict__
99
106
 
100
107
  async def post_file(self):
108
+ """Upload a file and create an attachment record, return attachment_id."""
101
109
  post_data = await request.files
102
110
  if "file" not in post_data:
103
111
  return Response().error("Missing key: file").__dict__
104
112
 
105
113
  file = post_data["file"]
106
- filename = f"{uuid.uuid4()!s}"
107
- # 通过文件格式判断文件类型
108
- if file.content_type.startswith("audio"):
109
- filename += ".wav"
114
+ filename = file.filename or f"{uuid.uuid4()!s}"
115
+ content_type = file.content_type or "application/octet-stream"
116
+
117
+ # 根据 content_type 判断文件类型并添加扩展名
118
+ if content_type.startswith("image"):
119
+ attach_type = "image"
120
+ elif content_type.startswith("audio"):
121
+ attach_type = "record"
122
+ elif content_type.startswith("video"):
123
+ attach_type = "video"
124
+ else:
125
+ attach_type = "file"
110
126
 
111
127
  path = os.path.join(self.imgs_dir, filename)
112
128
  await file.save(path)
113
129
 
114
- return Response().ok(data={"filename": filename}).__dict__
130
+ # 创建 attachment 记录
131
+ attachment = await self.db.insert_attachment(
132
+ path=path,
133
+ type=attach_type,
134
+ mime_type=content_type,
135
+ )
136
+
137
+ if not attachment:
138
+ return Response().error("Failed to create attachment").__dict__
139
+
140
+ filename = os.path.basename(attachment.path)
141
+
142
+ return (
143
+ Response()
144
+ .ok(
145
+ data={
146
+ "attachment_id": attachment.attachment_id,
147
+ "filename": filename,
148
+ "type": attach_type,
149
+ }
150
+ )
151
+ .__dict__
152
+ )
153
+
154
+ async def _build_user_message_parts(self, message: str | list) -> list[dict]:
155
+ """构建用户消息的部分列表
156
+
157
+ Args:
158
+ message: 文本消息 (str) 或消息段列表 (list)
159
+ """
160
+ parts = []
161
+
162
+ if isinstance(message, list):
163
+ for part in message:
164
+ part_type = part.get("type")
165
+ if part_type == "plain":
166
+ parts.append({"type": "plain", "text": part.get("text", "")})
167
+ elif part_type == "reply":
168
+ parts.append(
169
+ {"type": "reply", "message_id": part.get("message_id")}
170
+ )
171
+ elif attachment_id := part.get("attachment_id"):
172
+ attachment = await self.db.get_attachment_by_id(attachment_id)
173
+ if attachment:
174
+ parts.append(
175
+ {
176
+ "type": attachment.type,
177
+ "attachment_id": attachment.attachment_id,
178
+ "filename": os.path.basename(attachment.path),
179
+ "path": attachment.path, # will be deleted
180
+ }
181
+ )
182
+ return parts
183
+
184
+ if message:
185
+ parts.append({"type": "plain", "text": message})
186
+
187
+ return parts
188
+
189
+ async def _create_attachment_from_file(
190
+ self, filename: str, attach_type: str
191
+ ) -> dict | None:
192
+ """从本地文件创建 attachment 并返回消息部分
193
+
194
+ 用于处理 bot 回复中的媒体文件
195
+
196
+ Args:
197
+ filename: 存储的文件名
198
+ attach_type: 附件类型 (image, record, file, video)
199
+ """
200
+ file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
201
+ if not os.path.exists(file_path):
202
+ return None
203
+
204
+ # guess mime type
205
+ mime_type, _ = mimetypes.guess_type(filename)
206
+ if not mime_type:
207
+ mime_type = "application/octet-stream"
208
+
209
+ # insert attachment
210
+ attachment = await self.db.insert_attachment(
211
+ path=file_path,
212
+ type=attach_type,
213
+ mime_type=mime_type,
214
+ )
215
+ if not attachment:
216
+ return None
217
+
218
+ return {
219
+ "type": attach_type,
220
+ "attachment_id": attachment.attachment_id,
221
+ "filename": os.path.basename(file_path),
222
+ }
223
+
224
+ async def _save_bot_message(
225
+ self,
226
+ webchat_conv_id: str,
227
+ text: str,
228
+ media_parts: list,
229
+ reasoning: str,
230
+ ):
231
+ """保存 bot 消息到历史记录,返回保存的记录"""
232
+ bot_message_parts = []
233
+ if text:
234
+ bot_message_parts.append({"type": "plain", "text": text})
235
+ bot_message_parts.extend(media_parts)
236
+
237
+ new_his = {"type": "bot", "message": bot_message_parts}
238
+ if reasoning:
239
+ new_his["reasoning"] = reasoning
240
+
241
+ record = await self.platform_history_mgr.insert(
242
+ platform_id="webchat",
243
+ user_id=webchat_conv_id,
244
+ content=new_his,
245
+ sender_id="bot",
246
+ sender_name="bot",
247
+ )
248
+ return record
115
249
 
116
250
  async def chat(self):
117
251
  username = g.get("username", "guest")
118
252
 
119
253
  post_data = await request.json
120
- if "message" not in post_data and "image_url" not in post_data:
121
- return Response().error("Missing key: message or image_url").__dict__
254
+ if "message" not in post_data and "files" not in post_data:
255
+ return Response().error("Missing key: message or files").__dict__
122
256
 
123
257
  if "session_id" not in post_data and "conversation_id" not in post_data:
124
258
  return (
@@ -126,44 +260,40 @@ class ChatRoute(Route):
126
260
  )
127
261
 
128
262
  message = post_data["message"]
129
- # conversation_id = post_data["conversation_id"]
130
263
  session_id = post_data.get("session_id", post_data.get("conversation_id"))
131
- image_url = post_data.get("image_url")
132
- audio_url = post_data.get("audio_url")
133
264
  selected_provider = post_data.get("selected_provider")
134
265
  selected_model = post_data.get("selected_model")
135
- enable_streaming = post_data.get("enable_streaming", True) # 默认为 True
266
+ enable_streaming = post_data.get("enable_streaming", True)
136
267
 
137
- if not message and not image_url and not audio_url:
138
- return (
139
- Response()
140
- .error("Message and image_url and audio_url are empty")
141
- .__dict__
268
+ # 检查消息是否为空
269
+ if isinstance(message, list):
270
+ has_content = any(
271
+ part.get("type") in ("plain", "image", "record", "file", "video")
272
+ for part in message
142
273
  )
274
+ if not has_content:
275
+ return (
276
+ Response()
277
+ .error("Message content is empty (reply only is not allowed)")
278
+ .__dict__
279
+ )
280
+ elif not message:
281
+ return Response().error("Message are both empty").__dict__
282
+
143
283
  if not session_id:
144
284
  return Response().error("session_id is empty").__dict__
145
285
 
146
- # 追加用户消息
147
286
  webchat_conv_id = session_id
148
-
149
- # 获取会话特定的队列
150
287
  back_queue = webchat_queue_mgr.get_or_create_back_queue(webchat_conv_id)
151
288
 
152
- new_his = {"type": "user", "message": message}
153
- if image_url:
154
- new_his["image_url"] = image_url
155
- if audio_url:
156
- new_his["audio_url"] = audio_url
157
- await self.platform_history_mgr.insert(
158
- platform_id="webchat",
159
- user_id=webchat_conv_id,
160
- content=new_his,
161
- sender_id=username,
162
- sender_name=username,
163
- )
289
+ # 构建用户消息段(包含 path 用于传递给 adapter)
290
+ message_parts = await self._build_user_message_parts(message)
164
291
 
165
292
  async def stream():
166
293
  client_disconnected = False
294
+ accumulated_parts = []
295
+ accumulated_text = ""
296
+ accumulated_reasoning = ""
167
297
 
168
298
  try:
169
299
  async with track_conversation(self.running_convs, webchat_conv_id):
@@ -182,16 +312,17 @@ class ChatRoute(Route):
182
312
  continue
183
313
 
184
314
  result_text = result["data"]
185
- type = result.get("type")
315
+ msg_type = result.get("type")
186
316
  streaming = result.get("streaming", False)
187
317
 
318
+ # 发送 SSE 数据
188
319
  try:
189
320
  if not client_disconnected:
190
321
  yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
191
322
  except Exception as e:
192
323
  if not client_disconnected:
193
324
  logger.debug(
194
- f"[WebChat] 用户 {username} 断开聊天长连接。 {e}",
325
+ f"[WebChat] 用户 {username} 断开聊天长连接。 {e}"
195
326
  )
196
327
  client_disconnected = True
197
328
 
@@ -202,24 +333,68 @@ class ChatRoute(Route):
202
333
  logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
203
334
  client_disconnected = True
204
335
 
205
- if type == "end":
336
+ # 累积消息部分
337
+ if msg_type == "plain":
338
+ chain_type = result.get("chain_type", "normal")
339
+ if chain_type == "reasoning":
340
+ accumulated_reasoning += result_text
341
+ else:
342
+ accumulated_text += result_text
343
+ elif msg_type == "image":
344
+ filename = result_text.replace("[IMAGE]", "")
345
+ part = await self._create_attachment_from_file(
346
+ filename, "image"
347
+ )
348
+ if part:
349
+ accumulated_parts.append(part)
350
+ elif msg_type == "record":
351
+ filename = result_text.replace("[RECORD]", "")
352
+ part = await self._create_attachment_from_file(
353
+ filename, "record"
354
+ )
355
+ if part:
356
+ accumulated_parts.append(part)
357
+ elif msg_type == "file":
358
+ # 格式: [FILE]filename
359
+ filename = result_text.replace("[FILE]", "")
360
+ part = await self._create_attachment_from_file(
361
+ filename, "file"
362
+ )
363
+ if part:
364
+ accumulated_parts.append(part)
365
+
366
+ # 消息结束处理
367
+ if msg_type == "end":
206
368
  break
207
369
  elif (
208
- (streaming and type == "complete")
370
+ (streaming and msg_type == "complete")
209
371
  or not streaming
210
- or type == "break"
372
+ or msg_type == "break"
211
373
  ):
212
- # 追加机器人消息
213
- new_his = {"type": "bot", "message": result_text}
214
- if "reasoning" in result:
215
- new_his["reasoning"] = result["reasoning"]
216
- await self.platform_history_mgr.insert(
217
- platform_id="webchat",
218
- user_id=webchat_conv_id,
219
- content=new_his,
220
- sender_id="bot",
221
- sender_name="bot",
374
+ saved_record = await self._save_bot_message(
375
+ webchat_conv_id,
376
+ accumulated_text,
377
+ accumulated_parts,
378
+ accumulated_reasoning,
222
379
  )
380
+ # 发送保存的消息信息给前端
381
+ if saved_record and not client_disconnected:
382
+ saved_info = {
383
+ "type": "message_saved",
384
+ "data": {
385
+ "id": saved_record.id,
386
+ "created_at": saved_record.created_at.astimezone().isoformat(),
387
+ },
388
+ }
389
+ try:
390
+ yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n"
391
+ except Exception:
392
+ pass
393
+ # 重置累积变量 (对于 break 后的下一段消息)
394
+ if msg_type == "break":
395
+ accumulated_parts = []
396
+ accumulated_text = ""
397
+ accumulated_reasoning = ""
223
398
  except BaseException as e:
224
399
  logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
225
400
 
@@ -230,9 +405,7 @@ class ChatRoute(Route):
230
405
  username,
231
406
  webchat_conv_id,
232
407
  {
233
- "message": message,
234
- "image_url": image_url, # list
235
- "audio_url": audio_url,
408
+ "message": message_parts,
236
409
  "selected_provider": selected_provider,
237
410
  "selected_model": selected_model,
238
411
  "enable_streaming": enable_streaming,
@@ -240,14 +413,30 @@ class ChatRoute(Route):
240
413
  ),
241
414
  )
242
415
 
243
- response = await make_response(
244
- stream(),
245
- {
246
- "Content-Type": "text/event-stream",
247
- "Cache-Control": "no-cache",
248
- "Transfer-Encoding": "chunked",
249
- "Connection": "keep-alive",
250
- },
416
+ message_parts_for_storage = []
417
+ for part in message_parts:
418
+ part_copy = {k: v for k, v in part.items() if k != "path"}
419
+ message_parts_for_storage.append(part_copy)
420
+
421
+ await self.platform_history_mgr.insert(
422
+ platform_id="webchat",
423
+ user_id=webchat_conv_id,
424
+ content={"type": "user", "message": message_parts_for_storage},
425
+ sender_id=username,
426
+ sender_name=username,
427
+ )
428
+
429
+ response = cast(
430
+ QuartResponse,
431
+ await make_response(
432
+ stream(),
433
+ {
434
+ "Content-Type": "text/event-stream",
435
+ "Cache-Control": "no-cache",
436
+ "Transfer-Encoding": "chunked",
437
+ "Connection": "keep-alive",
438
+ },
439
+ ),
251
440
  )
252
441
  response.timeout = None # fix SSE auto disconnect issue
253
442
  return response
@@ -271,6 +460,17 @@ class ChatRoute(Route):
271
460
  unified_msg_origin = f"{session.platform_id}:{message_type}:{session.platform_id}!{username}!{session_id}"
272
461
  await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
273
462
 
463
+ # 获取消息历史中的所有附件 ID 并删除附件
464
+ history_list = await self.platform_history_mgr.get(
465
+ platform_id=session.platform_id,
466
+ user_id=session_id,
467
+ page=1,
468
+ page_size=100000, # 获取足够多的记录
469
+ )
470
+ attachment_ids = self._extract_attachment_ids(history_list)
471
+ if attachment_ids:
472
+ await self._delete_attachments(attachment_ids)
473
+
274
474
  # 删除消息历史
275
475
  await self.platform_history_mgr.delete(
276
476
  platform_id=session.platform_id,
@@ -297,6 +497,41 @@ class ChatRoute(Route):
297
497
 
298
498
  return Response().ok().__dict__
299
499
 
500
+ def _extract_attachment_ids(self, history_list) -> list[str]:
501
+ """从消息历史中提取所有 attachment_id"""
502
+ attachment_ids = []
503
+ for history in history_list:
504
+ content = history.content
505
+ if not content or "message" not in content:
506
+ continue
507
+ message_parts = content.get("message", [])
508
+ for part in message_parts:
509
+ if isinstance(part, dict) and "attachment_id" in part:
510
+ attachment_ids.append(part["attachment_id"])
511
+ return attachment_ids
512
+
513
+ async def _delete_attachments(self, attachment_ids: list[str]):
514
+ """删除附件(包括数据库记录和磁盘文件)"""
515
+ try:
516
+ attachments = await self.db.get_attachments(attachment_ids)
517
+ for attachment in attachments:
518
+ if not os.path.exists(attachment.path):
519
+ continue
520
+ try:
521
+ os.remove(attachment.path)
522
+ except OSError as e:
523
+ logger.warning(
524
+ f"Failed to delete attachment file {attachment.path}: {e}"
525
+ )
526
+ except Exception as e:
527
+ logger.warning(f"Failed to get attachments: {e}")
528
+
529
+ # 批量删除数据库记录
530
+ try:
531
+ await self.db.delete_attachments(attachment_ids)
532
+ except Exception as e:
533
+ logger.warning(f"Failed to delete attachments: {e}")
534
+
300
535
  async def new_session(self):
301
536
  """Create a new Platform session (default: webchat)."""
302
537
  username = g.get("username", "guest")
@@ -2,6 +2,7 @@ import asyncio
2
2
  import inspect
3
3
  import os
4
4
  import traceback
5
+ from typing import Any
5
6
 
6
7
  from quart import request
7
8
 
@@ -20,11 +21,12 @@ from astrbot.core.platform.register import platform_cls_map, platform_registry
20
21
  from astrbot.core.provider import Provider
21
22
  from astrbot.core.provider.register import provider_registry
22
23
  from astrbot.core.star.star import star_registry
24
+ from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
23
25
 
24
26
  from .route import Response, Route, RouteContext
25
27
 
26
28
 
27
- def try_cast(value: str, type_: str):
29
+ def try_cast(value: Any, type_: str):
28
30
  if type_ == "int":
29
31
  try:
30
32
  return int(value)
@@ -503,9 +505,9 @@ class ConfigRoute(Route):
503
505
  if not isinstance(inst, EmbeddingProvider):
504
506
  return Response().error("提供商不是 EmbeddingProvider 类型").__dict__
505
507
 
506
- # 初始化
507
- if getattr(inst, "initialize", None):
508
- await inst.initialize()
508
+ init_fn = getattr(inst, "initialize", None)
509
+ if inspect.iscoroutinefunction(init_fn):
510
+ await init_fn()
509
511
 
510
512
  # 获取嵌入向量维度
511
513
  vec = await inst.get_embedding("echo")
@@ -555,6 +557,10 @@ class ConfigRoute(Route):
555
557
 
556
558
  async def post_new_platform(self):
557
559
  new_platform_config = await request.json
560
+
561
+ # 如果是支持统一 webhook 模式的平台,生成 webhook_uuid
562
+ ensure_platform_webhook_config(new_platform_config)
563
+
558
564
  self.config["platform"].append(new_platform_config)
559
565
  try:
560
566
  save_config(self.config, self.config, is_core=True)
@@ -584,6 +590,9 @@ class ConfigRoute(Route):
584
590
  if not platform_id or not new_config:
585
591
  return Response().error("参数错误").__dict__
586
592
 
593
+ # 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid
594
+ ensure_platform_webhook_config(new_config)
595
+
587
596
  for i, platform in enumerate(self.config["platform"]):
588
597
  if platform["id"] == platform_id:
589
598
  self.config["platform"][i] = new_config
@@ -758,7 +767,7 @@ class ConfigRoute(Route):
758
767
  return {"metadata": CONFIG_METADATA_2, "config": config}
759
768
 
760
769
  async def _get_plugin_config(self, plugin_name: str):
761
- ret = {"metadata": None, "config": None}
770
+ ret: dict = {"metadata": None, "config": None}
762
771
 
763
772
  for plugin_md in star_registry:
764
773
  if plugin_md.name == plugin_name: