AstrBot 4.9.1__py3-none-any.whl → 4.10.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 (45) hide show
  1. astrbot/cli/__init__.py +1 -1
  2. astrbot/core/agent/message.py +6 -4
  3. astrbot/core/agent/response.py +22 -1
  4. astrbot/core/agent/run_context.py +1 -1
  5. astrbot/core/agent/runners/tool_loop_agent_runner.py +99 -20
  6. astrbot/core/astr_agent_context.py +3 -1
  7. astrbot/core/astr_agent_run_util.py +42 -3
  8. astrbot/core/astr_agent_tool_exec.py +34 -4
  9. astrbot/core/config/default.py +127 -184
  10. astrbot/core/core_lifecycle.py +3 -0
  11. astrbot/core/db/__init__.py +72 -0
  12. astrbot/core/db/po.py +59 -0
  13. astrbot/core/db/sqlite.py +240 -0
  14. astrbot/core/message/components.py +4 -5
  15. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +6 -1
  16. astrbot/core/pipeline/respond/stage.py +1 -1
  17. astrbot/core/platform/sources/telegram/tg_event.py +9 -0
  18. astrbot/core/platform/sources/webchat/webchat_event.py +22 -18
  19. astrbot/core/provider/entities.py +41 -0
  20. astrbot/core/provider/manager.py +203 -93
  21. astrbot/core/provider/sources/anthropic_source.py +55 -11
  22. astrbot/core/provider/sources/gemini_source.py +84 -33
  23. astrbot/core/provider/sources/openai_source.py +21 -6
  24. astrbot/core/star/__init__.py +5 -1
  25. astrbot/core/star/command_management.py +449 -0
  26. astrbot/core/star/context.py +4 -0
  27. astrbot/core/star/filter/command.py +1 -0
  28. astrbot/core/star/filter/command_group.py +1 -0
  29. astrbot/core/star/star_handler.py +4 -0
  30. astrbot/core/star/star_manager.py +14 -0
  31. astrbot/core/utils/llm_metadata.py +63 -0
  32. astrbot/core/utils/migra_helper.py +93 -0
  33. astrbot/core/utils/plugin_kv_store.py +28 -0
  34. astrbot/dashboard/routes/__init__.py +2 -0
  35. astrbot/dashboard/routes/chat.py +56 -13
  36. astrbot/dashboard/routes/command.py +82 -0
  37. astrbot/dashboard/routes/config.py +291 -33
  38. astrbot/dashboard/routes/stat.py +96 -0
  39. astrbot/dashboard/routes/tools.py +20 -4
  40. astrbot/dashboard/server.py +1 -0
  41. {astrbot-4.9.1.dist-info → astrbot-4.10.0.dist-info}/METADATA +2 -2
  42. {astrbot-4.9.1.dist-info → astrbot-4.10.0.dist-info}/RECORD +45 -41
  43. {astrbot-4.9.1.dist-info → astrbot-4.10.0.dist-info}/WHEEL +0 -0
  44. {astrbot-4.9.1.dist-info → astrbot-4.10.0.dist-info}/entry_points.txt +0 -0
  45. {astrbot-4.9.1.dist-info → astrbot-4.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -32,6 +32,92 @@ def _migra_agent_runner_configs(conf: AstrBotConfig, ids_map: dict) -> None:
32
32
  logger.error(traceback.format_exc())
33
33
 
34
34
 
35
+ def _migra_provider_to_source_structure(conf: AstrBotConfig) -> None:
36
+ """
37
+ Migrate old provider structure to new provider-source separation.
38
+ Provider only keeps: id, provider_source_id, model, modalities, custom_extra_body
39
+ All other fields move to provider_sources.
40
+ """
41
+ providers = conf.get("provider", [])
42
+ provider_sources = conf.get("provider_sources", [])
43
+
44
+ # Track if any migration happened
45
+ migrated = False
46
+
47
+ # Provider-only fields that should stay in provider
48
+ provider_only_fields = {
49
+ "id",
50
+ "provider_source_id",
51
+ "model",
52
+ "modalities",
53
+ "custom_extra_body",
54
+ "enable",
55
+ }
56
+
57
+ # Fields that should not go to source
58
+ source_exclude_fields = provider_only_fields | {"model_config"}
59
+
60
+ for provider in providers:
61
+ # Skip if already has provider_source_id
62
+ if provider.get("provider_source_id"):
63
+ continue
64
+
65
+ # Skip non-chat-completion types (they don't need source separation)
66
+ provider_type = provider.get("provider_type", "")
67
+ if provider_type != "chat_completion":
68
+ # For old types without provider_type, check type field
69
+ old_type = provider.get("type", "")
70
+ if "chat_completion" not in old_type:
71
+ continue
72
+
73
+ migrated = True
74
+ logger.info(f"Migrating provider {provider.get('id')} to new structure")
75
+
76
+ # Extract source fields from provider
77
+ source_fields = {}
78
+ for key, value in list(provider.items()):
79
+ if key not in source_exclude_fields:
80
+ source_fields[key] = value
81
+
82
+ # Create new provider_source
83
+ source_id = provider.get("id", "") + "_source"
84
+ new_source = {"id": source_id, **source_fields}
85
+
86
+ # Update provider to only keep necessary fields
87
+ provider["provider_source_id"] = source_id
88
+
89
+ # Extract model from model_config if exists
90
+ if "model_config" in provider and isinstance(provider["model_config"], dict):
91
+ model_config = provider["model_config"]
92
+ provider["model"] = model_config.get("model", "")
93
+
94
+ # Put other model_config fields into custom_extra_body
95
+ extra_body_fields = {k: v for k, v in model_config.items() if k != "model"}
96
+ if extra_body_fields:
97
+ if "custom_extra_body" not in provider:
98
+ provider["custom_extra_body"] = {}
99
+ provider["custom_extra_body"].update(extra_body_fields)
100
+
101
+ # Initialize new fields if not present
102
+ if "modalities" not in provider:
103
+ provider["modalities"] = []
104
+ if "custom_extra_body" not in provider:
105
+ provider["custom_extra_body"] = {}
106
+
107
+ # Remove fields that should be in source
108
+ keys_to_remove = [k for k in provider.keys() if k not in provider_only_fields]
109
+ for key in keys_to_remove:
110
+ del provider[key]
111
+
112
+ # Add source to provider_sources
113
+ provider_sources.append(new_source)
114
+
115
+ if migrated:
116
+ conf["provider_sources"] = provider_sources
117
+ conf.save_config()
118
+ logger.info("Provider-source structure migration completed")
119
+
120
+
35
121
  async def migra(
36
122
  db, astrbot_config_mgr, umop_config_router, acm: AstrBotConfigManager
37
123
  ) -> None:
@@ -71,3 +157,10 @@ async def migra(
71
157
 
72
158
  for conf in acm.confs.values():
73
159
  _migra_agent_runner_configs(conf, ids_map)
160
+
161
+ # Migrate providers to new structure: extract source fields to provider_sources
162
+ try:
163
+ _migra_provider_to_source_structure(astrbot_config)
164
+ except Exception as e:
165
+ logger.error(f"Migration for provider-source structure failed: {e!s}")
166
+ logger.error(traceback.format_exc())
@@ -0,0 +1,28 @@
1
+ from typing import TypeVar
2
+
3
+ from astrbot.core import sp
4
+
5
+ SUPPORTED_VALUE_TYPES = int | float | str | bytes | bool | dict | list | None
6
+ _VT = TypeVar("_VT")
7
+
8
+
9
+ class PluginKVStoreMixin:
10
+ """为插件提供键值存储功能的 Mixin 类"""
11
+
12
+ plugin_id: str
13
+
14
+ async def put_kv_data(
15
+ self,
16
+ key: str,
17
+ value: SUPPORTED_VALUE_TYPES,
18
+ ) -> None:
19
+ """为指定插件存储一个键值对"""
20
+ await sp.put_async("plugin", self.plugin_id, key, value)
21
+
22
+ async def get_kv_data(self, key: str, default: _VT) -> _VT | None:
23
+ """获取指定插件存储的键值对"""
24
+ return await sp.get_async("plugin", self.plugin_id, key, default)
25
+
26
+ async def delete_kv_data(self, key: str) -> None:
27
+ """删除指定插件存储的键值对"""
28
+ await sp.remove_async("plugin", self.plugin_id, key)
@@ -1,5 +1,6 @@
1
1
  from .auth import AuthRoute
2
2
  from .chat import ChatRoute
3
+ from .command import CommandRoute
3
4
  from .config import ConfigRoute
4
5
  from .conversation import ConversationRoute
5
6
  from .file import FileRoute
@@ -17,6 +18,7 @@ from .update import UpdateRoute
17
18
  __all__ = [
18
19
  "AuthRoute",
19
20
  "ChatRoute",
21
+ "CommandRoute",
20
22
  "ConfigRoute",
21
23
  "ConversationRoute",
22
24
  "FileRoute",
@@ -227,16 +227,19 @@ class ChatRoute(Route):
227
227
  text: str,
228
228
  media_parts: list,
229
229
  reasoning: str,
230
+ agent_stats: dict,
230
231
  ):
231
232
  """保存 bot 消息到历史记录,返回保存的记录"""
232
233
  bot_message_parts = []
234
+ bot_message_parts.extend(media_parts)
233
235
  if text:
234
236
  bot_message_parts.append({"type": "plain", "text": text})
235
- bot_message_parts.extend(media_parts)
236
237
 
237
238
  new_his = {"type": "bot", "message": bot_message_parts}
238
239
  if reasoning:
239
240
  new_his["reasoning"] = reasoning
241
+ if agent_stats:
242
+ new_his["agent_stats"] = agent_stats
240
243
 
241
244
  record = await self.platform_history_mgr.insert(
242
245
  platform_id="webchat",
@@ -294,7 +297,8 @@ class ChatRoute(Route):
294
297
  accumulated_parts = []
295
298
  accumulated_text = ""
296
299
  accumulated_reasoning = ""
297
-
300
+ tool_calls = {}
301
+ agent_stats = {}
298
302
  try:
299
303
  async with track_conversation(self.running_convs, webchat_conv_id):
300
304
  while True:
@@ -314,6 +318,16 @@ class ChatRoute(Route):
314
318
  result_text = result["data"]
315
319
  msg_type = result.get("type")
316
320
  streaming = result.get("streaming", False)
321
+ chain_type = result.get("chain_type")
322
+
323
+ if chain_type == "agent_stats":
324
+ stats_info = {
325
+ "type": "agent_stats",
326
+ "data": json.loads(result_text),
327
+ }
328
+ yield f"data: {json.dumps(stats_info, ensure_ascii=False)}\n\n"
329
+ agent_stats = stats_info["data"]
330
+ continue
317
331
 
318
332
  # 发送 SSE 数据
319
333
  try:
@@ -335,11 +349,35 @@ class ChatRoute(Route):
335
349
 
336
350
  # 累积消息部分
337
351
  if msg_type == "plain":
338
- chain_type = result.get("chain_type", "normal")
339
- if chain_type == "reasoning":
352
+ chain_type = result.get("chain_type")
353
+ if chain_type == "tool_call":
354
+ tool_call = json.loads(result_text)
355
+ tool_calls[tool_call.get("id")] = tool_call
356
+ if accumulated_text:
357
+ # 如果累积了文本,则先保存文本
358
+ accumulated_parts.append(
359
+ {"type": "plain", "text": accumulated_text}
360
+ )
361
+ accumulated_text = ""
362
+ elif chain_type == "tool_call_result":
363
+ tcr = json.loads(result_text)
364
+ tc_id = tcr.get("id")
365
+ if tc_id in tool_calls:
366
+ tool_calls[tc_id]["result"] = tcr.get("result")
367
+ tool_calls[tc_id]["finished_ts"] = tcr.get("ts")
368
+ accumulated_parts.append(
369
+ {
370
+ "type": "tool_call",
371
+ "tool_calls": [tool_calls[tc_id]],
372
+ }
373
+ )
374
+ tool_calls.pop(tc_id, None)
375
+ elif chain_type == "reasoning":
340
376
  accumulated_reasoning += result_text
341
- else:
377
+ elif streaming:
342
378
  accumulated_text += result_text
379
+ else:
380
+ accumulated_text = result_text
343
381
  elif msg_type == "image":
344
382
  filename = result_text.replace("[IMAGE]", "")
345
383
  part = await self._create_attachment_from_file(
@@ -367,15 +405,20 @@ class ChatRoute(Route):
367
405
  if msg_type == "end":
368
406
  break
369
407
  elif (
370
- (streaming and msg_type == "complete")
371
- or not streaming
372
- or msg_type == "break"
408
+ (streaming and msg_type == "complete") or not streaming
409
+ # or msg_type == "break"
373
410
  ):
411
+ if (
412
+ chain_type == "tool_call"
413
+ or chain_type == "tool_call_result"
414
+ ):
415
+ continue
374
416
  saved_record = await self._save_bot_message(
375
417
  webchat_conv_id,
376
418
  accumulated_text,
377
419
  accumulated_parts,
378
420
  accumulated_reasoning,
421
+ agent_stats,
379
422
  )
380
423
  # 发送保存的消息信息给前端
381
424
  if saved_record and not client_disconnected:
@@ -390,11 +433,11 @@ class ChatRoute(Route):
390
433
  yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n"
391
434
  except Exception:
392
435
  pass
393
- # 重置累积变量 (对于 break 后的下一段消息)
394
- if msg_type == "break":
395
- accumulated_parts = []
396
- accumulated_text = ""
397
- accumulated_reasoning = ""
436
+ accumulated_parts = []
437
+ accumulated_text = ""
438
+ accumulated_reasoning = ""
439
+ # tool_calls = {}
440
+ agent_stats = {}
398
441
  except BaseException as e:
399
442
  logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
400
443
 
@@ -0,0 +1,82 @@
1
+ from quart import request
2
+
3
+ from astrbot.core.star.command_management import (
4
+ list_command_conflicts,
5
+ list_commands,
6
+ )
7
+ from astrbot.core.star.command_management import (
8
+ rename_command as rename_command_service,
9
+ )
10
+ from astrbot.core.star.command_management import (
11
+ toggle_command as toggle_command_service,
12
+ )
13
+
14
+ from .route import Response, Route, RouteContext
15
+
16
+
17
+ class CommandRoute(Route):
18
+ def __init__(self, context: RouteContext) -> None:
19
+ super().__init__(context)
20
+ self.routes = {
21
+ "/commands": ("GET", self.get_commands),
22
+ "/commands/conflicts": ("GET", self.get_conflicts),
23
+ "/commands/toggle": ("POST", self.toggle_command),
24
+ "/commands/rename": ("POST", self.rename_command),
25
+ }
26
+ self.register_routes()
27
+
28
+ async def get_commands(self):
29
+ commands = await list_commands()
30
+ summary = {
31
+ "total": len(commands),
32
+ "disabled": len([cmd for cmd in commands if not cmd["enabled"]]),
33
+ "conflicts": len([cmd for cmd in commands if cmd.get("has_conflict")]),
34
+ }
35
+ return Response().ok({"items": commands, "summary": summary}).__dict__
36
+
37
+ async def get_conflicts(self):
38
+ conflicts = await list_command_conflicts()
39
+ return Response().ok(conflicts).__dict__
40
+
41
+ async def toggle_command(self):
42
+ data = await request.get_json()
43
+ handler_full_name = data.get("handler_full_name")
44
+ enabled = data.get("enabled")
45
+
46
+ if handler_full_name is None or enabled is None:
47
+ return Response().error("handler_full_name 与 enabled 均为必填。").__dict__
48
+
49
+ if isinstance(enabled, str):
50
+ enabled = enabled.lower() in ("1", "true", "yes", "on")
51
+
52
+ try:
53
+ await toggle_command_service(handler_full_name, bool(enabled))
54
+ except ValueError as exc:
55
+ return Response().error(str(exc)).__dict__
56
+
57
+ payload = await _get_command_payload(handler_full_name)
58
+ return Response().ok(payload).__dict__
59
+
60
+ async def rename_command(self):
61
+ data = await request.get_json()
62
+ handler_full_name = data.get("handler_full_name")
63
+ new_name = data.get("new_name")
64
+
65
+ if not handler_full_name or not new_name:
66
+ return Response().error("handler_full_name 与 new_name 均为必填。").__dict__
67
+
68
+ try:
69
+ await rename_command_service(handler_full_name, new_name)
70
+ except ValueError as exc:
71
+ return Response().error(str(exc)).__dict__
72
+
73
+ payload = await _get_command_payload(handler_full_name)
74
+ return Response().ok(payload).__dict__
75
+
76
+
77
+ async def _get_command_payload(handler_full_name: str):
78
+ commands = await list_commands()
79
+ for cmd in commands:
80
+ if cmd["handler_full_name"] == handler_full_name:
81
+ return cmd
82
+ return {}