AstrBot 4.9.2__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 (43) 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/command_management.py +449 -0
  25. astrbot/core/star/context.py +4 -0
  26. astrbot/core/star/filter/command.py +1 -0
  27. astrbot/core/star/filter/command_group.py +1 -0
  28. astrbot/core/star/star_handler.py +4 -0
  29. astrbot/core/star/star_manager.py +2 -0
  30. astrbot/core/utils/llm_metadata.py +63 -0
  31. astrbot/core/utils/migra_helper.py +93 -0
  32. astrbot/dashboard/routes/__init__.py +2 -0
  33. astrbot/dashboard/routes/chat.py +56 -13
  34. astrbot/dashboard/routes/command.py +82 -0
  35. astrbot/dashboard/routes/config.py +291 -33
  36. astrbot/dashboard/routes/stat.py +96 -0
  37. astrbot/dashboard/routes/tools.py +20 -4
  38. astrbot/dashboard/server.py +1 -0
  39. {astrbot-4.9.2.dist-info → astrbot-4.10.0.dist-info}/METADATA +2 -2
  40. {astrbot-4.9.2.dist-info → astrbot-4.10.0.dist-info}/RECORD +43 -40
  41. {astrbot-4.9.2.dist-info → astrbot-4.10.0.dist-info}/WHEEL +0 -0
  42. {astrbot-4.9.2.dist-info → astrbot-4.10.0.dist-info}/entry_points.txt +0 -0
  43. {astrbot-4.9.2.dist-info → astrbot-4.10.0.dist-info}/licenses/LICENSE +0 -0
astrbot/core/db/po.py CHANGED
@@ -234,6 +234,65 @@ class Attachment(SQLModel, table=True):
234
234
  )
235
235
 
236
236
 
237
+ class CommandConfig(SQLModel, table=True):
238
+ """Per-command configuration overrides for dashboard management."""
239
+
240
+ __tablename__ = "command_configs" # type: ignore
241
+
242
+ handler_full_name: str = Field(
243
+ primary_key=True,
244
+ max_length=512,
245
+ )
246
+ plugin_name: str = Field(nullable=False, max_length=255)
247
+ module_path: str = Field(nullable=False, max_length=255)
248
+ original_command: str = Field(nullable=False, max_length=255)
249
+ resolved_command: str | None = Field(default=None, max_length=255)
250
+ enabled: bool = Field(default=True, nullable=False)
251
+ keep_original_alias: bool = Field(default=False, nullable=False)
252
+ conflict_key: str | None = Field(default=None, max_length=255)
253
+ resolution_strategy: str | None = Field(default=None, max_length=64)
254
+ note: str | None = Field(default=None, sa_type=Text)
255
+ extra_data: dict | None = Field(default=None, sa_type=JSON)
256
+ auto_managed: bool = Field(default=False, nullable=False)
257
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
258
+ updated_at: datetime = Field(
259
+ default_factory=lambda: datetime.now(timezone.utc),
260
+ sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
261
+ )
262
+
263
+
264
+ class CommandConflict(SQLModel, table=True):
265
+ """Conflict tracking for duplicated command names."""
266
+
267
+ __tablename__ = "command_conflicts" # type: ignore
268
+
269
+ id: int | None = Field(
270
+ default=None, primary_key=True, sa_column_kwargs={"autoincrement": True}
271
+ )
272
+ conflict_key: str = Field(nullable=False, max_length=255)
273
+ handler_full_name: str = Field(nullable=False, max_length=512)
274
+ plugin_name: str = Field(nullable=False, max_length=255)
275
+ status: str = Field(default="pending", max_length=32)
276
+ resolution: str | None = Field(default=None, max_length=64)
277
+ resolved_command: str | None = Field(default=None, max_length=255)
278
+ note: str | None = Field(default=None, sa_type=Text)
279
+ extra_data: dict | None = Field(default=None, sa_type=JSON)
280
+ auto_generated: bool = Field(default=False, nullable=False)
281
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
282
+ updated_at: datetime = Field(
283
+ default_factory=lambda: datetime.now(timezone.utc),
284
+ sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
285
+ )
286
+
287
+ __table_args__ = (
288
+ UniqueConstraint(
289
+ "conflict_key",
290
+ "handler_full_name",
291
+ name="uix_conflict_handler",
292
+ ),
293
+ )
294
+
295
+
237
296
  @dataclass
238
297
  class Conversation:
239
298
  """LLM 对话类
astrbot/core/db/sqlite.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  import threading
3
3
  import typing as T
4
+ from collections.abc import Awaitable, Callable
4
5
  from datetime import datetime, timedelta, timezone
5
6
 
6
7
  from sqlalchemy import CursorResult
@@ -10,6 +11,8 @@ from sqlmodel import col, delete, desc, func, or_, select, text, update
10
11
  from astrbot.core.db import BaseDatabase
11
12
  from astrbot.core.db.po import (
12
13
  Attachment,
14
+ CommandConfig,
15
+ CommandConflict,
13
16
  ConversationV2,
14
17
  Persona,
15
18
  PlatformMessageHistory,
@@ -26,6 +29,7 @@ from astrbot.core.db.po import (
26
29
  )
27
30
 
28
31
  NOT_GIVEN = T.TypeVar("NOT_GIVEN")
32
+ TxResult = T.TypeVar("TxResult")
29
33
 
30
34
 
31
35
  class SQLiteDatabase(BaseDatabase):
@@ -670,6 +674,242 @@ class SQLiteDatabase(BaseDatabase):
670
674
  )
671
675
  await session.commit()
672
676
 
677
+ # ====
678
+ # Command Configuration & Conflict Tracking
679
+ # ====
680
+
681
+ async def _run_in_tx(
682
+ self,
683
+ fn: Callable[[AsyncSession], Awaitable[TxResult]],
684
+ ) -> TxResult:
685
+ async with self.get_db() as session:
686
+ session: AsyncSession
687
+ async with session.begin():
688
+ return await fn(session)
689
+
690
+ @staticmethod
691
+ def _apply_updates(model, **updates) -> None:
692
+ for field, value in updates.items():
693
+ if value is not None:
694
+ setattr(model, field, value)
695
+
696
+ @staticmethod
697
+ def _new_command_config(
698
+ handler_full_name: str,
699
+ plugin_name: str,
700
+ module_path: str,
701
+ original_command: str,
702
+ *,
703
+ resolved_command: str | None = None,
704
+ enabled: bool | None = None,
705
+ keep_original_alias: bool | None = None,
706
+ conflict_key: str | None = None,
707
+ resolution_strategy: str | None = None,
708
+ note: str | None = None,
709
+ extra_data: dict | None = None,
710
+ auto_managed: bool | None = None,
711
+ ) -> CommandConfig:
712
+ return CommandConfig(
713
+ handler_full_name=handler_full_name,
714
+ plugin_name=plugin_name,
715
+ module_path=module_path,
716
+ original_command=original_command,
717
+ resolved_command=resolved_command,
718
+ enabled=True if enabled is None else enabled,
719
+ keep_original_alias=False
720
+ if keep_original_alias is None
721
+ else keep_original_alias,
722
+ conflict_key=conflict_key or original_command,
723
+ resolution_strategy=resolution_strategy,
724
+ note=note,
725
+ extra_data=extra_data,
726
+ auto_managed=bool(auto_managed),
727
+ )
728
+
729
+ @staticmethod
730
+ def _new_command_conflict(
731
+ conflict_key: str,
732
+ handler_full_name: str,
733
+ plugin_name: str,
734
+ *,
735
+ status: str | None = None,
736
+ resolution: str | None = None,
737
+ resolved_command: str | None = None,
738
+ note: str | None = None,
739
+ extra_data: dict | None = None,
740
+ auto_generated: bool | None = None,
741
+ ) -> CommandConflict:
742
+ return CommandConflict(
743
+ conflict_key=conflict_key,
744
+ handler_full_name=handler_full_name,
745
+ plugin_name=plugin_name,
746
+ status=status or "pending",
747
+ resolution=resolution,
748
+ resolved_command=resolved_command,
749
+ note=note,
750
+ extra_data=extra_data,
751
+ auto_generated=bool(auto_generated),
752
+ )
753
+
754
+ async def get_command_configs(self) -> list[CommandConfig]:
755
+ async with self.get_db() as session:
756
+ session: AsyncSession
757
+ result = await session.execute(select(CommandConfig))
758
+ return list(result.scalars().all())
759
+
760
+ async def get_command_config(
761
+ self,
762
+ handler_full_name: str,
763
+ ) -> CommandConfig | None:
764
+ async with self.get_db() as session:
765
+ session: AsyncSession
766
+ return await session.get(CommandConfig, handler_full_name)
767
+
768
+ async def upsert_command_config(
769
+ self,
770
+ handler_full_name: str,
771
+ plugin_name: str,
772
+ module_path: str,
773
+ original_command: str,
774
+ *,
775
+ resolved_command: str | None = None,
776
+ enabled: bool | None = None,
777
+ keep_original_alias: bool | None = None,
778
+ conflict_key: str | None = None,
779
+ resolution_strategy: str | None = None,
780
+ note: str | None = None,
781
+ extra_data: dict | None = None,
782
+ auto_managed: bool | None = None,
783
+ ) -> CommandConfig:
784
+ async def _op(session: AsyncSession) -> CommandConfig:
785
+ config = await session.get(CommandConfig, handler_full_name)
786
+ if not config:
787
+ config = self._new_command_config(
788
+ handler_full_name,
789
+ plugin_name,
790
+ module_path,
791
+ original_command,
792
+ resolved_command=resolved_command,
793
+ enabled=enabled,
794
+ keep_original_alias=keep_original_alias,
795
+ conflict_key=conflict_key,
796
+ resolution_strategy=resolution_strategy,
797
+ note=note,
798
+ extra_data=extra_data,
799
+ auto_managed=auto_managed,
800
+ )
801
+ session.add(config)
802
+ else:
803
+ self._apply_updates(
804
+ config,
805
+ plugin_name=plugin_name,
806
+ module_path=module_path,
807
+ original_command=original_command,
808
+ resolved_command=resolved_command,
809
+ enabled=enabled,
810
+ keep_original_alias=keep_original_alias,
811
+ conflict_key=conflict_key,
812
+ resolution_strategy=resolution_strategy,
813
+ note=note,
814
+ extra_data=extra_data,
815
+ auto_managed=auto_managed,
816
+ )
817
+ await session.flush()
818
+ await session.refresh(config)
819
+ return config
820
+
821
+ return await self._run_in_tx(_op)
822
+
823
+ async def delete_command_config(self, handler_full_name: str) -> None:
824
+ await self.delete_command_configs([handler_full_name])
825
+
826
+ async def delete_command_configs(self, handler_full_names: list[str]) -> None:
827
+ if not handler_full_names:
828
+ return
829
+
830
+ async def _op(session: AsyncSession) -> None:
831
+ await session.execute(
832
+ delete(CommandConfig).where(
833
+ col(CommandConfig.handler_full_name).in_(handler_full_names),
834
+ ),
835
+ )
836
+
837
+ await self._run_in_tx(_op)
838
+
839
+ async def list_command_conflicts(
840
+ self,
841
+ status: str | None = None,
842
+ ) -> list[CommandConflict]:
843
+ async with self.get_db() as session:
844
+ session: AsyncSession
845
+ query = select(CommandConflict)
846
+ if status:
847
+ query = query.where(CommandConflict.status == status)
848
+ result = await session.execute(query)
849
+ return list(result.scalars().all())
850
+
851
+ async def upsert_command_conflict(
852
+ self,
853
+ conflict_key: str,
854
+ handler_full_name: str,
855
+ plugin_name: str,
856
+ *,
857
+ status: str | None = None,
858
+ resolution: str | None = None,
859
+ resolved_command: str | None = None,
860
+ note: str | None = None,
861
+ extra_data: dict | None = None,
862
+ auto_generated: bool | None = None,
863
+ ) -> CommandConflict:
864
+ async def _op(session: AsyncSession) -> CommandConflict:
865
+ result = await session.execute(
866
+ select(CommandConflict).where(
867
+ CommandConflict.conflict_key == conflict_key,
868
+ CommandConflict.handler_full_name == handler_full_name,
869
+ ),
870
+ )
871
+ record = result.scalar_one_or_none()
872
+ if not record:
873
+ record = self._new_command_conflict(
874
+ conflict_key,
875
+ handler_full_name,
876
+ plugin_name,
877
+ status=status,
878
+ resolution=resolution,
879
+ resolved_command=resolved_command,
880
+ note=note,
881
+ extra_data=extra_data,
882
+ auto_generated=auto_generated,
883
+ )
884
+ session.add(record)
885
+ else:
886
+ self._apply_updates(
887
+ record,
888
+ plugin_name=plugin_name,
889
+ status=status,
890
+ resolution=resolution,
891
+ resolved_command=resolved_command,
892
+ note=note,
893
+ extra_data=extra_data,
894
+ auto_generated=auto_generated,
895
+ )
896
+ await session.flush()
897
+ await session.refresh(record)
898
+ return record
899
+
900
+ return await self._run_in_tx(_op)
901
+
902
+ async def delete_command_conflicts(self, ids: list[int]) -> None:
903
+ if not ids:
904
+ return
905
+
906
+ async def _op(session: AsyncSession) -> None:
907
+ await session.execute(
908
+ delete(CommandConflict).where(col(CommandConflict.id).in_(ids)),
909
+ )
910
+
911
+ await self._run_in_tx(_op)
912
+
673
913
  # ====
674
914
  # Deprecated Methods
675
915
  # ====
@@ -629,12 +629,11 @@ class Nodes(BaseMessageComponent):
629
629
 
630
630
  class Json(BaseMessageComponent):
631
631
  type = ComponentType.Json
632
- data: str | dict
633
- resid: int | None = 0
632
+ data: dict
634
633
 
635
- def __init__(self, data, **_):
636
- if isinstance(data, dict):
637
- data = json.dumps(data)
634
+ def __init__(self, data: str | dict, **_):
635
+ if isinstance(data, str):
636
+ data = json.loads(data)
638
637
  super().__init__(data=data, **_)
639
638
 
640
639
 
@@ -321,7 +321,12 @@ class InternalAgentSubStage(Stage):
321
321
  elif isinstance(req.tool_calls_result, list):
322
322
  for tcr in req.tool_calls_result:
323
323
  messages.extend(tcr.to_openai_messages())
324
- messages.append({"role": "assistant", "content": llm_response.completion_text})
324
+ messages.append(
325
+ {
326
+ "role": "assistant",
327
+ "content": llm_response.completion_text or "*No response*",
328
+ }
329
+ )
325
330
  messages = list(filter(lambda item: "_no_save" not in item, messages))
326
331
  await self.conv_manager.update_conversation(
327
332
  event.unified_msg_origin,
@@ -119,7 +119,7 @@ class RespondStage(Stage):
119
119
 
120
120
  if (result := event.get_result()) is None:
121
121
  return False
122
- if self.only_llm_result and result.is_llm_result():
122
+ if self.only_llm_result and not result.is_llm_result():
123
123
  return False
124
124
 
125
125
  if event.get_platform_name() in [
@@ -200,6 +200,15 @@ class TelegramPlatformEvent(AstrMessageEvent):
200
200
  if isinstance(chain, MessageChain):
201
201
  if chain.type == "break":
202
202
  # 分割符
203
+ if message_id:
204
+ try:
205
+ await self.client.edit_message_text(
206
+ text=delta,
207
+ chat_id=payload["chat_id"],
208
+ message_id=message_id,
209
+ )
210
+ except Exception as e:
211
+ logger.warning(f"编辑消息失败(streaming-break): {e!s}")
203
212
  message_id = None # 重置消息 ID
204
213
  delta = "" # 重置 delta
205
214
  continue
@@ -1,11 +1,12 @@
1
1
  import base64
2
+ import json
2
3
  import os
3
4
  import shutil
4
5
  import uuid
5
6
 
6
7
  from astrbot.api import logger
7
8
  from astrbot.api.event import AstrMessageEvent, MessageChain
8
- from astrbot.api.message_components import File, Image, Plain, Record
9
+ from astrbot.api.message_components import File, Image, Json, Plain, Record
9
10
  from astrbot.core.utils.astrbot_path import get_astrbot_data_path
10
11
 
11
12
  from .webchat_queue_mgr import webchat_queue_mgr
@@ -41,12 +42,20 @@ class WebChatMessageEvent(AstrMessageEvent):
41
42
  await web_chat_back_queue.put(
42
43
  {
43
44
  "type": "plain",
44
- "cid": cid,
45
45
  "data": data,
46
46
  "streaming": streaming,
47
47
  "chain_type": message.type,
48
48
  },
49
49
  )
50
+ elif isinstance(comp, Json):
51
+ await web_chat_back_queue.put(
52
+ {
53
+ "type": "plain",
54
+ "data": json.dumps(comp.data, ensure_ascii=False),
55
+ "streaming": streaming,
56
+ "chain_type": message.type,
57
+ },
58
+ )
50
59
  elif isinstance(comp, Image):
51
60
  # save image to local
52
61
  filename = f"{str(uuid.uuid4())}.jpg"
@@ -58,7 +67,6 @@ class WebChatMessageEvent(AstrMessageEvent):
58
67
  await web_chat_back_queue.put(
59
68
  {
60
69
  "type": "image",
61
- "cid": cid,
62
70
  "data": data,
63
71
  "streaming": streaming,
64
72
  },
@@ -74,7 +82,6 @@ class WebChatMessageEvent(AstrMessageEvent):
74
82
  await web_chat_back_queue.put(
75
83
  {
76
84
  "type": "record",
77
- "cid": cid,
78
85
  "data": data,
79
86
  "streaming": streaming,
80
87
  },
@@ -91,7 +98,6 @@ class WebChatMessageEvent(AstrMessageEvent):
91
98
  await web_chat_back_queue.put(
92
99
  {
93
100
  "type": "file",
94
- "cid": cid,
95
101
  "data": data,
96
102
  "streaming": streaming,
97
103
  },
@@ -111,18 +117,17 @@ class WebChatMessageEvent(AstrMessageEvent):
111
117
  cid = self.session_id.split("!")[-1]
112
118
  web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
113
119
  async for chain in generator:
114
- if chain.type == "break" and final_data:
115
- # 分割符
116
- await web_chat_back_queue.put(
117
- {
118
- "type": "break", # break means a segment end
119
- "data": final_data,
120
- "streaming": True,
121
- "cid": cid,
122
- },
123
- )
124
- final_data = ""
125
- continue
120
+ # if chain.type == "break" and final_data:
121
+ # # 分割符
122
+ # await web_chat_back_queue.put(
123
+ # {
124
+ # "type": "break", # break means a segment end
125
+ # "data": final_data,
126
+ # "streaming": True,
127
+ # },
128
+ # )
129
+ # final_data = ""
130
+ # continue
126
131
 
127
132
  r = await WebChatMessageEvent._send(
128
133
  chain,
@@ -142,7 +147,6 @@ class WebChatMessageEvent(AstrMessageEvent):
142
147
  "data": final_data,
143
148
  "reasoning": reasoning_content,
144
149
  "streaming": True,
145
- "cid": cid,
146
150
  },
147
151
  )
148
152
  await super().send_streaming(generator, use_fallback)
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import base64
2
4
  import enum
3
5
  import json
@@ -199,6 +201,38 @@ class ProviderRequest:
199
201
  return ""
200
202
 
201
203
 
204
+ @dataclass
205
+ class TokenUsage:
206
+ input_other: int = 0
207
+ """The number of input tokens, excluding cached tokens."""
208
+ input_cached: int = 0
209
+ """The number of input cached tokens."""
210
+ output: int = 0
211
+ """The number of output tokens."""
212
+
213
+ @property
214
+ def total(self) -> int:
215
+ return self.input_other + self.input_cached + self.output
216
+
217
+ @property
218
+ def input(self) -> int:
219
+ return self.input_other + self.input_cached
220
+
221
+ def __add__(self, other: TokenUsage) -> TokenUsage:
222
+ return TokenUsage(
223
+ input_other=self.input_other + other.input_other,
224
+ input_cached=self.input_cached + other.input_cached,
225
+ output=self.output + other.output,
226
+ )
227
+
228
+ def __sub__(self, other: TokenUsage) -> TokenUsage:
229
+ return TokenUsage(
230
+ input_other=self.input_other - other.input_other,
231
+ input_cached=self.input_cached - other.input_cached,
232
+ output=self.output - other.output,
233
+ )
234
+
235
+
202
236
  @dataclass
203
237
  class LLMResponse:
204
238
  role: str
@@ -227,6 +261,11 @@ class LLMResponse:
227
261
  is_chunk: bool = False
228
262
  """Indicates if the response is a chunked response."""
229
263
 
264
+ id: str | None = None
265
+ """The ID of the response. For chunked responses, it's the ID of the chunk; for non-chunked responses, it's the ID of the response."""
266
+ usage: TokenUsage | None = None
267
+ """The usage of the response. For chunked responses, it's the usage of the chunk; for non-chunked responses, it's the usage of the response."""
268
+
230
269
  def __init__(
231
270
  self,
232
271
  role: str,
@@ -241,6 +280,8 @@ class LLMResponse:
241
280
  | AnthropicMessage
242
281
  | None = None,
243
282
  is_chunk: bool = False,
283
+ id: str | None = None,
284
+ usage: TokenUsage | None = None,
244
285
  ):
245
286
  """初始化 LLMResponse
246
287