Skip to content

Commit ffa3cbc

Browse files
authored
Merge branch 'AstrBotDevs:master' into master
2 parents e3ace42 + 67c7445 commit ffa3cbc

78 files changed

Lines changed: 3346 additions & 1219 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,26 @@ pnpm dev
1919

2020
Runs on `http://localhost:3000` by default.
2121

22+
## Pre-commit setup
23+
24+
AstrBot uses [pre-commit](https://pre-commit.com/) hooks to automatically format and lint Python code before each commit. The hooks run `ruff check`, `ruff format`, and `pyupgrade` (see [`.pre-commit-config.yaml`](.pre-commit-config.yaml) for details).
25+
26+
To set it up:
27+
28+
```bash
29+
pip install pre-commit
30+
pre-commit install
31+
```
32+
33+
After installation, the hooks will run automatically on `git commit`. You can also run them manually at any time:
34+
35+
```bash
36+
ruff format .
37+
ruff check .
38+
```
39+
40+
> **Note:** If you use VSCode, install the `Ruff` extension for real-time formatting and linting in the editor.
41+
2242
## Dev environment tips
2343

2444
1. When modifying the WebUI, be sure to maintain componentization and clean code. Avoid duplicate code.

astrbot/builtin_stars/builtin_commands/commands/conversation.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from sqlalchemy import case, func, select
2+
from sqlmodel import col
3+
14
from astrbot.api import sp, star
25
from astrbot.api.event import AstrMessageEvent, MessageEventResult
36
from astrbot.core import logger
@@ -7,6 +10,7 @@
710
DEERFLOW_THREAD_ID_KEY,
811
)
912
from astrbot.core.agent.runners.deerflow.deerflow_api_client import DeerFlowAPIClient
13+
from astrbot.core.db.po import ProviderStat
1014
from astrbot.core.utils.active_event_registry import active_event_registry
1115

1216
from .utils.rst_scene import RstScene
@@ -246,3 +250,62 @@ async def new_conv(self, message: AstrMessageEvent) -> None:
246250
f"✅ Switched to new conversation: {cid[:4]}。"
247251
),
248252
)
253+
254+
async def stats(self, message: AstrMessageEvent) -> None:
255+
"""Show token usage statistics for the current conversation."""
256+
umo = message.unified_msg_origin
257+
cid = await self.context.conversation_manager.get_curr_conversation_id(umo)
258+
259+
if not cid:
260+
message.set_result(
261+
MessageEventResult().message(
262+
"❌ You are not in a conversation. Use /new to create one."
263+
),
264+
)
265+
return
266+
267+
db = self.context.get_db()
268+
async with db.get_db() as session:
269+
result = await session.execute(
270+
select(
271+
func.count(case((col(ProviderStat.id).is_not(None), 1))).label(
272+
"record_count",
273+
),
274+
func.coalesce(func.sum(ProviderStat.token_input_other), 0).label(
275+
"total_input_other",
276+
),
277+
func.coalesce(func.sum(ProviderStat.token_input_cached), 0).label(
278+
"total_input_cached",
279+
),
280+
func.coalesce(func.sum(ProviderStat.token_output), 0).label(
281+
"total_output",
282+
),
283+
).where(
284+
col(ProviderStat.agent_type) == "internal",
285+
col(ProviderStat.conversation_id) == cid,
286+
)
287+
)
288+
stats = result.one()
289+
290+
if stats.record_count == 0:
291+
message.set_result(
292+
MessageEventResult().message(
293+
"📊 No stats available for this conversation yet."
294+
),
295+
)
296+
return
297+
298+
total_input_other = stats.total_input_other
299+
total_input_cached = stats.total_input_cached
300+
total_output = stats.total_output
301+
total_tokens = total_input_other + total_input_cached + total_output
302+
303+
ret = (
304+
f"📊 Conversation Token usage (ID: {cid[:8]}...)\n"
305+
f"Total: {total_tokens:,}\n"
306+
f"Input (cached): {total_input_cached:,}\n"
307+
f"Input (other): {total_input_other:,}\n"
308+
f"Output: {total_output:,}\n"
309+
)
310+
311+
message.set_result(MessageEventResult().message(ret))

astrbot/builtin_stars/builtin_commands/main.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ async def new_conv(self, message: AstrMessageEvent) -> None:
4747
"""Create new conversation"""
4848
await self.conversation_c.new_conv(message)
4949

50+
@filter.command("stats")
51+
async def stats(self, message: AstrMessageEvent) -> None:
52+
"""Show token usage statistics for the current conversation"""
53+
await self.conversation_c.stats(message)
54+
5055
@filter.permission_type(filter.PermissionType.ADMIN)
5156
@filter.command("provider")
5257
async def provider(

astrbot/cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "4.23.3"
1+
__version__ = "4.23.6"

astrbot/core/agent/runners/tool_loop_agent_runner.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,10 @@ async def _complete_with_assistant_response(self, llm_resp: LLMResponse) -> None
183183
self.stats.end_time = time.time()
184184

185185
parts = []
186-
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
186+
if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
187187
parts.append(
188188
ThinkPart(
189-
think=llm_resp.reasoning_content,
189+
think=llm_resp.reasoning_content or "",
190190
encrypted=llm_resp.reasoning_signature,
191191
)
192192
)
@@ -883,10 +883,10 @@ async def step(self):
883883

884884
# 将结果添加到上下文中
885885
parts = []
886-
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
886+
if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
887887
parts.append(
888888
ThinkPart(
889-
think=llm_resp.reasoning_content,
889+
think=llm_resp.reasoning_content or "",
890890
encrypted=llm_resp.reasoning_signature,
891891
)
892892
)
@@ -1422,10 +1422,10 @@ async def _finalize_aborted_step(
14221422
self.stats.end_time = time.time()
14231423

14241424
parts = []
1425-
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
1425+
if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
14261426
parts.append(
14271427
ThinkPart(
1428-
think=llm_resp.reasoning_content,
1428+
think=llm_resp.reasoning_content or "",
14291429
encrypted=llm_resp.reasoning_signature,
14301430
)
14311431
)

astrbot/core/astr_main_agent.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@
7777
BaiduWebSearchTool,
7878
BochaWebSearchTool,
7979
BraveWebSearchTool,
80+
FirecrawlExtractWebPageTool,
81+
FirecrawlWebSearchTool,
8082
TavilyExtractWebPageTool,
8183
TavilyWebSearchTool,
8284
normalize_legacy_web_search_config,
@@ -1146,6 +1148,9 @@ async def _apply_web_search_tools(
11461148
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BochaWebSearchTool))
11471149
elif provider == "brave":
11481150
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BraveWebSearchTool))
1151+
elif provider == "firecrawl":
1152+
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FirecrawlWebSearchTool))
1153+
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FirecrawlExtractWebPageTool))
11491154
elif provider == "baidu_ai_search":
11501155
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BaiduWebSearchTool))
11511156

astrbot/core/config/default.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
77

8-
VERSION = "4.23.3"
8+
VERSION = "4.23.6"
99
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
1010
PERSONAL_WECHAT_CONFIG_METADATA = {
1111
"weixin_oc_base_url": {
@@ -804,7 +804,7 @@ class ChatProviderTemplate(TypedDict):
804804
"appid": {
805805
"description": "appid",
806806
"type": "string",
807-
"hint": "必填项。QQ 官方机器人平台的 appid。如何获取请参考文档。",
807+
"hint": "必填项。当前消息平台的 AppID。如何获取请参考对应平台接入文档。",
808808
},
809809
"secret": {
810810
"description": "secret",
@@ -3223,6 +3223,7 @@ class ChatProviderTemplate(TypedDict):
32233223
"baidu_ai_search",
32243224
"bocha",
32253225
"brave",
3226+
"firecrawl",
32263227
],
32273228
"condition": {
32283229
"provider_settings.web_search": True,
@@ -3258,6 +3259,16 @@ class ChatProviderTemplate(TypedDict):
32583259
"provider_settings.web_search": True,
32593260
},
32603261
},
3262+
"provider_settings.websearch_firecrawl_key": {
3263+
"description": "Firecrawl API Key",
3264+
"type": "list",
3265+
"items": {"type": "string"},
3266+
"hint": "可添加多个 Key 进行轮询。",
3267+
"condition": {
3268+
"provider_settings.websearch_provider": "firecrawl",
3269+
"provider_settings.web_search": True,
3270+
},
3271+
},
32613272
"provider_settings.websearch_baidu_app_builder_key": {
32623273
"description": "百度千帆智能云 APP Builder API Key",
32633274
"type": "string",

astrbot/core/message/message_event_result.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,25 @@ class MessageChain:
2727

2828
chain: list[BaseMessageComponent] = field(default_factory=list)
2929
use_t2i_: bool | None = None # None 为跟随用户设置
30+
use_markdown_: bool | None = (
31+
None # 是否使用 Markdown 发送消息。None 跟随平台默认,True 强制 Markdown,False 强制纯文本。
32+
)
3033
type: str | None = None
3134
"""消息链承载的消息的类型。可选,用于让消息平台区分不同业务场景的消息链。"""
3235

36+
def derive(self, chain: list[BaseMessageComponent] | None = None) -> "MessageChain":
37+
"""基于当前消息链创建一个新的 MessageChain,继承元数据(use_t2i_、use_markdown_ 等)。
38+
39+
Args:
40+
chain: 新消息链的组件列表。如果为 None,则使用空列表。
41+
42+
"""
43+
new = MessageChain(chain=chain if chain is not None else [])
44+
new.use_t2i_ = self.use_t2i_
45+
new.use_markdown_ = self.use_markdown_
46+
new.type = self.type
47+
return new
48+
3349
def message(self, message: str):
3450
"""添加一条文本消息到消息链 `chain` 中。
3551
@@ -118,6 +134,18 @@ def use_t2i(self, use_t2i: bool):
118134
self.use_t2i_ = use_t2i
119135
return self
120136

137+
def use_markdown(self, use: bool | None = True):
138+
"""设置是否使用 Markdown 发送消息。
139+
140+
仅对支持 Markdown 的平台生效(如 QQ Official),不支持的平台会忽略此字段。
141+
142+
Args:
143+
use: True 强制使用 Markdown,False 强制纯文本,None 跟随平台默认行为。
144+
145+
"""
146+
self.use_markdown_ = use
147+
return self
148+
121149
def get_plain_text(self, with_other_comps_mark: bool = False) -> str:
122150
"""获取纯文本消息。这个方法将获取 chain 中所有 Plain 组件的文本并拼接成一条消息。空格分隔。
123151

astrbot/core/pipeline/respond/stage.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -246,9 +246,9 @@ async def process(
246246
await asyncio.sleep(i)
247247
try:
248248
if comp.type in need_separately:
249-
await event.send(MessageChain([comp]))
249+
await event.send(result.derive([comp]))
250250
else:
251-
await event.send(MessageChain([*header_comps, comp]))
251+
await event.send(result.derive([*header_comps, comp]))
252252
header_comps.clear()
253253
except Exception as e:
254254
logger.error(
@@ -271,15 +271,15 @@ async def process(
271271
modify_raw_chain=True,
272272
)
273273
for comp in sep_comps:
274-
chain = MessageChain([comp])
274+
chain = result.derive([comp])
275275
try:
276276
await event.send(chain)
277277
except Exception as e:
278278
logger.error(
279279
f"发送消息链失败: chain = {chain}, error = {e}",
280280
exc_info=True,
281281
)
282-
chain = MessageChain(result.chain)
282+
chain = result.derive(result.chain)
283283
if result.chain and len(result.chain) > 0:
284284
try:
285285
await event.send(chain)

astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -235,12 +235,20 @@ async def _post_send(self, stream: dict | None = None):
235235
):
236236
plain_text = plain_text + "\n"
237237

238-
payload: dict = {
239-
# "content": plain_text,
240-
"markdown": MarkdownPayload(content=plain_text) if plain_text else None,
241-
"msg_type": 2,
242-
"msg_id": self.message_obj.message_id,
243-
}
238+
# 根据消息链的 use_markdown_ 标记决定发送模式
239+
use_md = getattr(self.send_buffer, "use_markdown_", None)
240+
if use_md is False:
241+
payload: dict = {
242+
"content": plain_text,
243+
"msg_type": 0,
244+
"msg_id": self.message_obj.message_id,
245+
}
246+
else:
247+
payload = {
248+
"markdown": MarkdownPayload(content=plain_text) if plain_text else None,
249+
"msg_type": 2,
250+
"msg_id": self.message_obj.message_id,
251+
}
244252

245253
if not isinstance(source, botpy.message.Message | botpy.message.DirectMessage):
246254
payload["msg_seq"] = random.randint(1, 10000)

0 commit comments

Comments
 (0)