AstrBot 4.12.3__py3-none-any.whl → 4.13.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 (72) hide show
  1. astrbot/builtin_stars/astrbot/process_llm_request.py +42 -1
  2. astrbot/builtin_stars/builtin_commands/commands/__init__.py +0 -2
  3. astrbot/builtin_stars/builtin_commands/commands/persona.py +68 -6
  4. astrbot/builtin_stars/builtin_commands/main.py +0 -26
  5. astrbot/cli/__init__.py +1 -1
  6. astrbot/core/agent/runners/tool_loop_agent_runner.py +91 -1
  7. astrbot/core/agent/tool.py +61 -20
  8. astrbot/core/astr_agent_hooks.py +3 -1
  9. astrbot/core/astr_agent_run_util.py +243 -1
  10. astrbot/core/astr_agent_tool_exec.py +2 -2
  11. astrbot/core/{sandbox → computer}/booters/base.py +4 -4
  12. astrbot/core/{sandbox → computer}/booters/boxlite.py +2 -2
  13. astrbot/core/computer/booters/local.py +234 -0
  14. astrbot/core/{sandbox → computer}/booters/shipyard.py +2 -2
  15. astrbot/core/computer/computer_client.py +102 -0
  16. astrbot/core/{sandbox → computer}/tools/__init__.py +2 -1
  17. astrbot/core/{sandbox → computer}/tools/fs.py +1 -1
  18. astrbot/core/computer/tools/python.py +94 -0
  19. astrbot/core/{sandbox → computer}/tools/shell.py +13 -5
  20. astrbot/core/config/default.py +90 -9
  21. astrbot/core/db/__init__.py +94 -1
  22. astrbot/core/db/po.py +46 -0
  23. astrbot/core/db/sqlite.py +248 -0
  24. astrbot/core/message/components.py +2 -2
  25. astrbot/core/persona_mgr.py +162 -2
  26. astrbot/core/pipeline/context_utils.py +2 -2
  27. astrbot/core/pipeline/preprocess_stage/stage.py +1 -1
  28. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +73 -6
  29. astrbot/core/pipeline/process_stage/utils.py +31 -4
  30. astrbot/core/pipeline/scheduler.py +1 -1
  31. astrbot/core/pipeline/waking_check/stage.py +0 -1
  32. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +3 -3
  33. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +32 -14
  34. astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +61 -2
  35. astrbot/core/platform/sources/dingtalk/dingtalk_event.py +57 -11
  36. astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +5 -7
  37. astrbot/core/platform/sources/webchat/webchat_adapter.py +1 -0
  38. astrbot/core/platform/sources/webchat/webchat_event.py +24 -0
  39. astrbot/core/provider/manager.py +38 -0
  40. astrbot/core/provider/provider.py +54 -0
  41. astrbot/core/provider/sources/gemini_embedding_source.py +1 -1
  42. astrbot/core/provider/sources/gemini_source.py +12 -9
  43. astrbot/core/provider/sources/genie_tts.py +128 -0
  44. astrbot/core/provider/sources/openai_embedding_source.py +1 -1
  45. astrbot/core/skills/__init__.py +3 -0
  46. astrbot/core/skills/skill_manager.py +237 -0
  47. astrbot/core/star/command_management.py +1 -1
  48. astrbot/core/star/config.py +1 -1
  49. astrbot/core/star/context.py +9 -8
  50. astrbot/core/star/filter/command.py +1 -1
  51. astrbot/core/star/filter/custom_filter.py +2 -2
  52. astrbot/core/star/register/star_handler.py +2 -4
  53. astrbot/core/utils/astrbot_path.py +6 -0
  54. astrbot/dashboard/routes/__init__.py +2 -0
  55. astrbot/dashboard/routes/config.py +236 -2
  56. astrbot/dashboard/routes/live_chat.py +423 -0
  57. astrbot/dashboard/routes/persona.py +265 -1
  58. astrbot/dashboard/routes/skills.py +148 -0
  59. astrbot/dashboard/routes/util.py +102 -0
  60. astrbot/dashboard/server.py +21 -5
  61. {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/METADATA +1 -1
  62. {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/RECORD +69 -63
  63. astrbot/builtin_stars/builtin_commands/commands/tool.py +0 -31
  64. astrbot/core/sandbox/sandbox_client.py +0 -52
  65. astrbot/core/sandbox/tools/python.py +0 -74
  66. /astrbot/core/{sandbox → computer}/olayer/__init__.py +0 -0
  67. /astrbot/core/{sandbox → computer}/olayer/filesystem.py +0 -0
  68. /astrbot/core/{sandbox → computer}/olayer/python.py +0 -0
  69. /astrbot/core/{sandbox → computer}/olayer/shell.py +0 -0
  70. {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/WHEEL +0 -0
  71. {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/entry_points.txt +0 -0
  72. {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,423 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import time
5
+ import uuid
6
+ import wave
7
+ from typing import Any
8
+
9
+ import jwt
10
+ from quart import websocket
11
+
12
+ from astrbot import logger
13
+ from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
14
+ from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
15
+ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
16
+
17
+ from .route import Route, RouteContext
18
+
19
+
20
+ class LiveChatSession:
21
+ """Live Chat 会话管理器"""
22
+
23
+ def __init__(self, session_id: str, username: str):
24
+ self.session_id = session_id
25
+ self.username = username
26
+ self.conversation_id = str(uuid.uuid4())
27
+ self.is_speaking = False
28
+ self.is_processing = False
29
+ self.should_interrupt = False
30
+ self.audio_frames: list[bytes] = []
31
+ self.current_stamp: str | None = None
32
+ self.temp_audio_path: str | None = None
33
+
34
+ def start_speaking(self, stamp: str):
35
+ """开始说话"""
36
+ self.is_speaking = True
37
+ self.current_stamp = stamp
38
+ self.audio_frames = []
39
+ logger.debug(f"[Live Chat] {self.username} 开始说话 stamp={stamp}")
40
+
41
+ def add_audio_frame(self, data: bytes):
42
+ """添加音频帧"""
43
+ if self.is_speaking:
44
+ self.audio_frames.append(data)
45
+
46
+ async def end_speaking(self, stamp: str) -> tuple[str | None, float]:
47
+ """结束说话,返回组装的 WAV 文件路径和耗时"""
48
+ start_time = time.time()
49
+ if not self.is_speaking or stamp != self.current_stamp:
50
+ logger.warning(
51
+ f"[Live Chat] stamp 不匹配或未在说话状态: {stamp} vs {self.current_stamp}"
52
+ )
53
+ return None, 0.0
54
+
55
+ self.is_speaking = False
56
+
57
+ if not self.audio_frames:
58
+ logger.warning("[Live Chat] 没有音频帧数据")
59
+ return None, 0.0
60
+
61
+ # 组装 WAV 文件
62
+ try:
63
+ temp_dir = os.path.join(get_astrbot_data_path(), "temp")
64
+ os.makedirs(temp_dir, exist_ok=True)
65
+ audio_path = os.path.join(temp_dir, f"live_audio_{uuid.uuid4()}.wav")
66
+
67
+ # 假设前端发送的是 PCM 数据,采样率 16000Hz,单声道,16位
68
+ with wave.open(audio_path, "wb") as wav_file:
69
+ wav_file.setnchannels(1) # 单声道
70
+ wav_file.setsampwidth(2) # 16位 = 2字节
71
+ wav_file.setframerate(16000) # 采样率 16000Hz
72
+ for frame in self.audio_frames:
73
+ wav_file.writeframes(frame)
74
+
75
+ self.temp_audio_path = audio_path
76
+ logger.info(
77
+ f"[Live Chat] 音频文件已保存: {audio_path}, 大小: {os.path.getsize(audio_path)} bytes"
78
+ )
79
+ return audio_path, time.time() - start_time
80
+
81
+ except Exception as e:
82
+ logger.error(f"[Live Chat] 组装 WAV 文件失败: {e}", exc_info=True)
83
+ return None, 0.0
84
+
85
+ def cleanup(self):
86
+ """清理临时文件"""
87
+ if self.temp_audio_path and os.path.exists(self.temp_audio_path):
88
+ try:
89
+ os.remove(self.temp_audio_path)
90
+ logger.debug(f"[Live Chat] 已删除临时文件: {self.temp_audio_path}")
91
+ except Exception as e:
92
+ logger.warning(f"[Live Chat] 删除临时文件失败: {e}")
93
+ self.temp_audio_path = None
94
+
95
+
96
+ class LiveChatRoute(Route):
97
+ """Live Chat WebSocket 路由"""
98
+
99
+ def __init__(
100
+ self,
101
+ context: RouteContext,
102
+ db: Any,
103
+ core_lifecycle: AstrBotCoreLifecycle,
104
+ ) -> None:
105
+ super().__init__(context)
106
+ self.core_lifecycle = core_lifecycle
107
+ self.db = db
108
+ self.plugin_manager = core_lifecycle.plugin_manager
109
+ self.sessions: dict[str, LiveChatSession] = {}
110
+
111
+ # 注册 WebSocket 路由
112
+ self.app.websocket("/api/live_chat/ws")(self.live_chat_ws)
113
+
114
+ async def live_chat_ws(self):
115
+ """Live Chat WebSocket 处理器"""
116
+ # WebSocket 不能通过 header 传递 token,需要从 query 参数获取
117
+ # 注意:WebSocket 上下文使用 websocket.args 而不是 request.args
118
+ token = websocket.args.get("token")
119
+ if not token:
120
+ await websocket.close(1008, "Missing authentication token")
121
+ return
122
+
123
+ try:
124
+ jwt_secret = self.config["dashboard"].get("jwt_secret")
125
+ payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
126
+ username = payload["username"]
127
+ except jwt.ExpiredSignatureError:
128
+ await websocket.close(1008, "Token expired")
129
+ return
130
+ except jwt.InvalidTokenError:
131
+ await websocket.close(1008, "Invalid token")
132
+ return
133
+
134
+ session_id = f"webchat_live!{username}!{uuid.uuid4()}"
135
+ live_session = LiveChatSession(session_id, username)
136
+ self.sessions[session_id] = live_session
137
+
138
+ logger.info(f"[Live Chat] WebSocket 连接建立: {username}")
139
+
140
+ try:
141
+ while True:
142
+ message = await websocket.receive_json()
143
+ await self._handle_message(live_session, message)
144
+
145
+ except Exception as e:
146
+ logger.error(f"[Live Chat] WebSocket 错误: {e}", exc_info=True)
147
+
148
+ finally:
149
+ # 清理会话
150
+ if session_id in self.sessions:
151
+ live_session.cleanup()
152
+ del self.sessions[session_id]
153
+ logger.info(f"[Live Chat] WebSocket 连接关闭: {username}")
154
+
155
+ async def _handle_message(self, session: LiveChatSession, message: dict):
156
+ """处理 WebSocket 消息"""
157
+ msg_type = message.get("t") # 使用 t 代替 type
158
+
159
+ if msg_type == "start_speaking":
160
+ # 开始说话
161
+ stamp = message.get("stamp")
162
+ if not stamp:
163
+ logger.warning("[Live Chat] start_speaking 缺少 stamp")
164
+ return
165
+ session.start_speaking(stamp)
166
+
167
+ elif msg_type == "speaking_part":
168
+ # 音频片段
169
+ audio_data_b64 = message.get("data")
170
+ if not audio_data_b64:
171
+ return
172
+
173
+ # 解码 base64
174
+ import base64
175
+
176
+ try:
177
+ audio_data = base64.b64decode(audio_data_b64)
178
+ session.add_audio_frame(audio_data)
179
+ except Exception as e:
180
+ logger.error(f"[Live Chat] 解码音频数据失败: {e}")
181
+
182
+ elif msg_type == "end_speaking":
183
+ # 结束说话
184
+ stamp = message.get("stamp")
185
+ if not stamp:
186
+ logger.warning("[Live Chat] end_speaking 缺少 stamp")
187
+ return
188
+
189
+ audio_path, assemble_duration = await session.end_speaking(stamp)
190
+ if not audio_path:
191
+ await websocket.send_json({"t": "error", "data": "音频组装失败"})
192
+ return
193
+
194
+ # 处理音频:STT -> LLM -> TTS
195
+ await self._process_audio(session, audio_path, assemble_duration)
196
+
197
+ elif msg_type == "interrupt":
198
+ # 用户打断
199
+ session.should_interrupt = True
200
+ logger.info(f"[Live Chat] 用户打断: {session.username}")
201
+
202
+ async def _process_audio(
203
+ self, session: LiveChatSession, audio_path: str, assemble_duration: float
204
+ ):
205
+ """处理音频:STT -> LLM -> 流式 TTS"""
206
+ try:
207
+ # 发送 WAV 组装耗时
208
+ await websocket.send_json(
209
+ {"t": "metrics", "data": {"wav_assemble_time": assemble_duration}}
210
+ )
211
+ wav_assembly_finish_time = time.time()
212
+
213
+ session.is_processing = True
214
+ session.should_interrupt = False
215
+
216
+ # 1. STT - 语音转文字
217
+ ctx = self.plugin_manager.context
218
+ stt_provider = ctx.provider_manager.stt_provider_insts[0]
219
+
220
+ if not stt_provider:
221
+ logger.error("[Live Chat] STT Provider 未配置")
222
+ await websocket.send_json({"t": "error", "data": "语音识别服务未配置"})
223
+ return
224
+
225
+ await websocket.send_json(
226
+ {"t": "metrics", "data": {"stt": stt_provider.meta().type}}
227
+ )
228
+
229
+ user_text = await stt_provider.get_text(audio_path)
230
+ if not user_text:
231
+ logger.warning("[Live Chat] STT 识别结果为空")
232
+ return
233
+
234
+ logger.info(f"[Live Chat] STT 结果: {user_text}")
235
+
236
+ await websocket.send_json(
237
+ {
238
+ "t": "user_msg",
239
+ "data": {"text": user_text, "ts": int(time.time() * 1000)},
240
+ }
241
+ )
242
+
243
+ # 2. 构造消息事件并发送到 pipeline
244
+ # 使用 webchat queue 机制
245
+ cid = session.conversation_id
246
+ queue = webchat_queue_mgr.get_or_create_queue(cid)
247
+
248
+ message_id = str(uuid.uuid4())
249
+ payload = {
250
+ "message_id": message_id,
251
+ "message": [{"type": "plain", "text": user_text}], # 直接发送文本
252
+ "action_type": "live", # 标记为 live mode
253
+ }
254
+
255
+ # 将消息放入队列
256
+ await queue.put((session.username, cid, payload))
257
+
258
+ # 3. 等待响应并流式发送 TTS 音频
259
+ back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
260
+
261
+ bot_text = ""
262
+ audio_playing = False
263
+
264
+ while True:
265
+ if session.should_interrupt:
266
+ # 用户打断,停止处理
267
+ logger.info("[Live Chat] 检测到用户打断")
268
+ await websocket.send_json({"t": "stop_play"})
269
+ # 保存消息并标记为被打断
270
+ await self._save_interrupted_message(session, user_text, bot_text)
271
+ # 清空队列中未处理的消息
272
+ while not back_queue.empty():
273
+ try:
274
+ back_queue.get_nowait()
275
+ except asyncio.QueueEmpty:
276
+ break
277
+ break
278
+
279
+ try:
280
+ result = await asyncio.wait_for(back_queue.get(), timeout=0.5)
281
+ except asyncio.TimeoutError:
282
+ continue
283
+
284
+ if not result:
285
+ continue
286
+
287
+ result_message_id = result.get("message_id")
288
+ if result_message_id != message_id:
289
+ logger.warning(
290
+ f"[Live Chat] 消息 ID 不匹配: {result_message_id} != {message_id}"
291
+ )
292
+ continue
293
+
294
+ result_type = result.get("type")
295
+ result_chain_type = result.get("chain_type")
296
+ data = result.get("data", "")
297
+
298
+ if result_chain_type == "agent_stats":
299
+ try:
300
+ stats = json.loads(data)
301
+ await websocket.send_json(
302
+ {
303
+ "t": "metrics",
304
+ "data": {
305
+ "llm_ttft": stats.get("time_to_first_token", 0),
306
+ "llm_total_time": stats.get("end_time", 0)
307
+ - stats.get("start_time", 0),
308
+ },
309
+ }
310
+ )
311
+ except Exception as e:
312
+ logger.error(f"[Live Chat] 解析 AgentStats 失败: {e}")
313
+ continue
314
+
315
+ if result_chain_type == "tts_stats":
316
+ try:
317
+ stats = json.loads(data)
318
+ await websocket.send_json(
319
+ {
320
+ "t": "metrics",
321
+ "data": stats,
322
+ }
323
+ )
324
+ except Exception as e:
325
+ logger.error(f"[Live Chat] 解析 TTSStats 失败: {e}")
326
+ continue
327
+
328
+ if result_type == "plain":
329
+ # 普通文本消息
330
+ bot_text += data
331
+
332
+ elif result_type == "audio_chunk":
333
+ # 流式音频数据
334
+ if not audio_playing:
335
+ audio_playing = True
336
+ logger.debug("[Live Chat] 开始播放音频流")
337
+
338
+ # Calculate latency from wav assembly finish to first audio chunk
339
+ speak_to_first_frame_latency = (
340
+ time.time() - wav_assembly_finish_time
341
+ )
342
+ await websocket.send_json(
343
+ {
344
+ "t": "metrics",
345
+ "data": {
346
+ "speak_to_first_frame": speak_to_first_frame_latency
347
+ },
348
+ }
349
+ )
350
+
351
+ text = result.get("text")
352
+ if text:
353
+ await websocket.send_json(
354
+ {
355
+ "t": "bot_text_chunk",
356
+ "data": {"text": text},
357
+ }
358
+ )
359
+
360
+ # 发送音频数据给前端
361
+ await websocket.send_json(
362
+ {
363
+ "t": "response",
364
+ "data": data, # base64 编码的音频数据
365
+ }
366
+ )
367
+
368
+ elif result_type in ["complete", "end"]:
369
+ # 处理完成
370
+ logger.info(f"[Live Chat] Bot 回复完成: {bot_text}")
371
+
372
+ # 如果没有音频流,发送 bot 消息文本
373
+ if not audio_playing:
374
+ await websocket.send_json(
375
+ {
376
+ "t": "bot_msg",
377
+ "data": {
378
+ "text": bot_text,
379
+ "ts": int(time.time() * 1000),
380
+ },
381
+ }
382
+ )
383
+
384
+ # 发送结束标记
385
+ await websocket.send_json({"t": "end"})
386
+
387
+ # 发送总耗时
388
+ wav_to_tts_duration = time.time() - wav_assembly_finish_time
389
+ await websocket.send_json(
390
+ {
391
+ "t": "metrics",
392
+ "data": {"wav_to_tts_total_time": wav_to_tts_duration},
393
+ }
394
+ )
395
+ break
396
+
397
+ except Exception as e:
398
+ logger.error(f"[Live Chat] 处理音频失败: {e}", exc_info=True)
399
+ await websocket.send_json({"t": "error", "data": f"处理失败: {str(e)}"})
400
+
401
+ finally:
402
+ session.is_processing = False
403
+ session.should_interrupt = False
404
+
405
+ async def _save_interrupted_message(
406
+ self, session: LiveChatSession, user_text: str, bot_text: str
407
+ ):
408
+ """保存被打断的消息"""
409
+ interrupted_text = bot_text + " [用户打断]"
410
+ logger.info(f"[Live Chat] 保存打断消息: {interrupted_text}")
411
+
412
+ # 简单记录到日志,实际保存逻辑可以后续完善
413
+ try:
414
+ timestamp = int(time.time() * 1000)
415
+ logger.info(
416
+ f"[Live Chat] 用户消息: {user_text} (session: {session.session_id}, ts: {timestamp})"
417
+ )
418
+ if bot_text:
419
+ logger.info(
420
+ f"[Live Chat] Bot 消息(打断): {interrupted_text} (session: {session.session_id}, ts: {timestamp})"
421
+ )
422
+ except Exception as e:
423
+ logger.error(f"[Live Chat] 记录消息失败: {e}", exc_info=True)