-
-
-
{{ tm('providerSources.title') }}
-
- {{ displayedProviderSources.length }}
-
-
+
+
{{ tm('providerSources.title') }}
-
-
-
- {{ tm('providerSources.add') }}
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
- mdi-shape-outline
+
+
+
+
+ mdi-creation
-
-
+ {{ item.raw.title }}
+
+
+
+
+
+
+
+
+ mdi-creation
+
+
+
+
+
+
+
+
+
+
+ {{ tm('providerSources.add') }}
+
-
-
+
+
+
-
-
-
-
-
-
- mdi-creation
-
-
- {{ getSourceDisplayName(source) }}
- {{ source.api_base || 'N/A' }}
-
-
-
-
-
-
-
+
+
-
-
mdi-api-off
-
{{ tm('providerSources.empty') }}
+
+
+
mdi-api-off
+
{{ tm('providerSources.empty') }}
-
+
-
-
diff --git a/dashboard/src/components/shared/ProviderSelector.vue b/dashboard/src/components/shared/ProviderSelector.vue
index 7ffbeb6ea..104de3f54 100644
--- a/dashboard/src/components/shared/ProviderSelector.vue
+++ b/dashboard/src/components/shared/ProviderSelector.vue
@@ -168,7 +168,10 @@
@@ -178,6 +181,7 @@
import { computed, ref, watch } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
+import ProviderChatCompletionPanel from '@/components/provider/ProviderChatCompletionPanel.vue'
import ProviderPage from '@/views/ProviderPage.vue'
const props = defineProps({
@@ -426,4 +430,49 @@ function closeProviderDrawer() {
height: 100%;
overflow: auto;
}
+
+@media (max-width: 960px) {
+ .provider-drawer-card {
+ width: calc(100dvw - 24px);
+ height: calc(100dvh - 24px);
+ margin: 12px;
+ }
+}
+
+@media (max-width: 600px) {
+ .provider-name-text {
+ max-width: 100%;
+ }
+
+ .provider-drawer-overlay {
+ align-items: stretch;
+ justify-content: stretch;
+ }
+
+ .provider-drawer-card {
+ width: 100dvw;
+ height: 100dvh;
+ margin: 0;
+ border-radius: 0;
+ }
+
+ .provider-drawer-header {
+ padding: 8px 12px;
+ }
+
+ .provider-drawer-content {
+ overflow: auto;
+ }
+
+ :deep(.v-overlay__content) {
+ width: 100dvw;
+ max-width: 100dvw;
+ margin: 0;
+ }
+
+ :deep(.v-dialog > .v-overlay__content) {
+ width: calc(100dvw - 24px);
+ max-width: calc(100dvw - 24px);
+ }
+}
diff --git a/dashboard/src/i18n/locales/en-US/features/chat.json b/dashboard/src/i18n/locales/en-US/features/chat.json
index dc6c66c4d..d7d319a6b 100644
--- a/dashboard/src/i18n/locales/en-US/features/chat.json
+++ b/dashboard/src/i18n/locales/en-US/features/chat.json
@@ -51,7 +51,7 @@
"fullscreen": "Fullscreen Mode",
"exitFullscreen": "Exit Fullscreen",
"reply": "Reply",
- "providerConfig": "AI Configuration",
+ "providerConfig": "Model Configuration",
"toolsUsed": "Tool Used",
"toolCallUsed": "Used {name} tool",
"pythonCodeAnalysis": "Python Code Analysis Used"
diff --git a/dashboard/src/i18n/locales/en-US/features/provider.json b/dashboard/src/i18n/locales/en-US/features/provider.json
index 1dca1a4a5..26e2b167f 100644
--- a/dashboard/src/i18n/locales/en-US/features/provider.json
+++ b/dashboard/src/i18n/locales/en-US/features/provider.json
@@ -123,6 +123,7 @@
}
},
"models": {
+ "title": "Models",
"available": "Available Models",
"configured": "Configured Models",
"empty": "No configured models yet. Click \"Fetch Models\" above to add.",
diff --git a/dashboard/src/i18n/locales/ru-RU/features/provider.json b/dashboard/src/i18n/locales/ru-RU/features/provider.json
index 9bc9f31ed..8a4311283 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/provider.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/provider.json
@@ -124,6 +124,7 @@
}
},
"models": {
+ "title": "Модели",
"available": "Доступные модели",
"configured": "Настроенные модели",
"empty": "Модели не настроены. Нажмите «Загрузить список моделей» выше.",
diff --git a/dashboard/src/i18n/locales/zh-CN/features/chat.json b/dashboard/src/i18n/locales/zh-CN/features/chat.json
index 2b11c9999..67328f0ee 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/chat.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/chat.json
@@ -51,7 +51,7 @@
"fullscreen": "全屏模式",
"exitFullscreen": "退出全屏",
"reply": "引用回复",
- "providerConfig": "AI 配置",
+ "providerConfig": "模型配置",
"toolsUsed": "已使用工具",
"toolCallUsed": "已使用 {name} 工具",
"pythonCodeAnalysis": "已使用 Python 代码分析"
diff --git a/dashboard/src/i18n/locales/zh-CN/features/provider.json b/dashboard/src/i18n/locales/zh-CN/features/provider.json
index 7a8a1a4d8..5ca6bf7f8 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/provider.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/provider.json
@@ -124,6 +124,7 @@
}
},
"models": {
+ "title": "模型",
"available": "可用模型",
"configured": "已配置的模型",
"empty": "暂无已配置的模型,点击上方的\"获取模型列表\"添加",
diff --git a/dashboard/src/layouts/full/FullLayout.vue b/dashboard/src/layouts/full/FullLayout.vue
index 408f1db6b..19772046d 100644
--- a/dashboard/src/layouts/full/FullLayout.vue
+++ b/dashboard/src/layouts/full/FullLayout.vue
@@ -1,6 +1,6 @@
From 5d79c999384952bd835c242a43def46edff7bc14 Mon Sep 17 00:00:00 2001
From: Soulter <37870767+Soulter@users.noreply.github.com>
Date: Sat, 25 Apr 2026 16:26:30 +0800
Subject: [PATCH 11/41] feat: add deduplication for WeChat kefu text messages
within 15 seconds (#7788)
---
.../platform/sources/wecom/wecom_adapter.py | 33 +++++++++++++++++++
1 file changed, 33 insertions(+)
diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py
index a30afcd8f..82c0a8f4f 100644
--- a/astrbot/core/platform/sources/wecom/wecom_adapter.py
+++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py
@@ -1,6 +1,7 @@
import asyncio
import os
import sys
+import time
import uuid
from collections.abc import Awaitable, Callable
from typing import Any, cast
@@ -140,6 +141,8 @@ class WecomServer:
@register_platform_adapter("wecom", "wecom 适配器", support_streaming_message=False)
class WecomPlatformAdapter(Platform):
+ WECHAT_KF_TEXT_CONTENT_DEDUP_TTL_SECONDS = 15
+
def __init__(
self,
platform_config: dict,
@@ -166,6 +169,7 @@ class WecomPlatformAdapter(Platform):
self.server = WecomServer(self._event_queue, self.config)
self.agent_id: str | None = None
+ self._wechat_kf_seen_text_messages: dict[str, float] = {}
self.client = WeChatClient(
self.config["corpid"].strip(),
@@ -210,6 +214,28 @@ class WecomPlatformAdapter(Platform):
self.server.callback = callback
+ def _is_duplicate_wechat_kf_text_message(self, session_id: str, text: str) -> bool:
+ normalized_text = text.strip()
+ if not normalized_text:
+ return False
+
+ now = time.monotonic()
+ expired_keys = [
+ key
+ for key, expires_at in self._wechat_kf_seen_text_messages.items()
+ if expires_at <= now
+ ]
+ for key in expired_keys:
+ self._wechat_kf_seen_text_messages.pop(key, None)
+
+ dedup_key = f"{session_id}:{normalized_text}"
+ if dedup_key in self._wechat_kf_seen_text_messages:
+ return True
+ self._wechat_kf_seen_text_messages[dedup_key] = (
+ now + self.WECHAT_KF_TEXT_CONTENT_DEDUP_TTL_SECONDS
+ )
+ return False
+
@override
async def send_by_session(
self,
@@ -390,6 +416,13 @@ class WecomPlatformAdapter(Platform):
abm.message_str = ""
if msgtype == "text":
text = msg.get("text", {}).get("content", "").strip()
+ if self._is_duplicate_wechat_kf_text_message(abm.session_id, text):
+ logger.debug(
+ "忽略 15 秒内重复微信客服文本消息 session_id=%s text=%s",
+ abm.session_id,
+ text,
+ )
+ return None
abm.message = [Plain(text=text)]
abm.message_str = text
elif msgtype == "image":
From 5ce02da6df906d044602f6d84c6afbeefd25e462 Mon Sep 17 00:00:00 2001
From: lingyun14
Date: Sat, 25 Apr 2026 16:34:50 +0800
Subject: [PATCH 12/41] fix: use certifi ssl context on Windows (#7778)
* fix: use certifi ssl context on Windows
* docs: update docstring to reflect hybrid SSL context
* chore: ruff
---------
Co-authored-by: Soulter <905617992@qq.com>
---
astrbot/core/utils/network_utils.py | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/astrbot/core/utils/network_utils.py b/astrbot/core/utils/network_utils.py
index 047529396..0bf6b820e 100644
--- a/astrbot/core/utils/network_utils.py
+++ b/astrbot/core/utils/network_utils.py
@@ -5,8 +5,9 @@ import ssl
import httpx
from astrbot import logger
+from astrbot.utils.http_ssl_common import build_ssl_context_with_certifi
-_SYSTEM_SSL_CTX = ssl.create_default_context()
+_SYSTEM_SSL_CTX = build_ssl_context_with_certifi()
def is_connection_error(exc: BaseException) -> bool:
@@ -92,9 +93,9 @@ def create_proxy_client(
) -> httpx.AsyncClient:
"""Create an httpx AsyncClient with proxy configuration if provided.
- Uses the system SSL certificate store instead of certifi, which avoids
- SSL verification failures for endpoints whose CA chain is not in certifi
- but is trusted by the operating system.
+ Uses a hybrid SSL context that combines the system SSL certificate store
+ with certifi as a fallback, ensuring compatibility across different
+ environments including Windows where the system store may be incomplete.
Note: The caller is responsible for closing the client when done.
Consider using the client as a context manager or calling aclose() explicitly.
@@ -103,11 +104,11 @@ def create_proxy_client(
provider_label: The provider name for log prefix (e.g., "OpenAI", "Gemini")
proxy: The proxy address (e.g., "http://127.0.0.1:7890"), or None/empty
headers: Optional custom headers to include in every request
- verify: Optional override for TLS verification. Defaults to the shared
- system SSL context when not provided.
+ verify: Optional override for TLS verification. Defaults to the hybrid
+ SSL context (system store + certifi) when not provided.
Returns:
- An httpx.AsyncClient created with the shared system SSL context; the proxy is applied only if one is provided.
+ An httpx.AsyncClient created with the hybrid SSL context (system store + certifi); the proxy is applied only if one is provided.
"""
resolved_verify = _SYSTEM_SSL_CTX if verify is None else verify
if proxy:
From d4cdeeae72490871e59a32a10a03f53df4d1d7ef Mon Sep 17 00:00:00 2001
From: Rhonin Wang <33801807+RhoninSeiei@users.noreply.github.com>
Date: Sat, 25 Apr 2026 16:44:08 +0800
Subject: [PATCH 13/41] fix(computer): send sandbox image downloads as images
(#7785)
---
astrbot/core/tools/computer_tools/fs.py | 20 +++++++++++++++++---
1 file changed, 17 insertions(+), 3 deletions(-)
diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py
index 60c21d949..f15f28dec 100644
--- a/astrbot/core/tools/computer_tools/fs.py
+++ b/astrbot/core/tools/computer_tools/fs.py
@@ -43,7 +43,7 @@ from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.computer_client import get_booter
from astrbot.core.computer.file_read_utils import read_file_tool_result
-from astrbot.core.message.components import File
+from astrbot.core.message.components import File, Image
from astrbot.core.utils.astrbot_path import (
get_astrbot_skills_path,
get_astrbot_system_tmp_path,
@@ -64,6 +64,7 @@ _COMPUTER_RUNTIME_TOOL_CONFIG = {
_SANDBOX_RUNTIME_TOOL_CONFIG = {
"provider_settings.computer_use_runtime": "sandbox",
}
+_IMAGE_FILE_SUFFIXES = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"}
def _restricted_env_path_labels(umo: str) -> list[str]:
@@ -729,11 +730,21 @@ class FileDownloadTool(FunctionTool):
if also_send_to_user:
try:
name = os.path.basename(local_path)
+ if Path(local_path).suffix.lower() in _IMAGE_FILE_SUFFIXES:
+ message_component = Image.fromFileSystem(local_path)
+ sent_as = "image"
+ else:
+ message_component = File(name=name, file=local_path)
+ sent_as = "file"
await context.context.event.send(
- MessageChain(chain=[File(name=name, file=local_path)])
+ MessageChain(chain=[message_component])
)
except Exception as e:
logger.error(f"Error sending file message: {e}")
+ return (
+ f"File downloaded successfully to {local_path} "
+ f"but sending to user failed: {e}"
+ )
# remove
# try:
@@ -741,7 +752,10 @@ class FileDownloadTool(FunctionTool):
# except Exception as e:
# logger.error(f"Error removing temp file {local_path}: {e}")
- return f"File downloaded successfully to {local_path} and sent to user."
+ return (
+ f"File downloaded successfully to {local_path} "
+ f"and sent to user as {sent_as}."
+ )
return f"File downloaded successfully to {local_path}"
except Exception as e:
From 17aea1aa2c771004e71d8504acde8f2730bf1f01 Mon Sep 17 00:00:00 2001
From: wjiajian <71640797+wjiajian@users.noreply.github.com>
Date: Sun, 26 Apr 2026 13:07:27 +0800
Subject: [PATCH 14/41] feat: add Firecrawl web search tools (#7764)
* feat: add Firecrawl web search and extract tools, update configuration and tests
* feat: implement Firecrawl API integration and error handling in web search tools
* feat: enhance Firecrawl web search with session management and payload validation
* feat: Firecrawl web search to use aiohttp.ClientSession directly for improved session management as it was
* feat: update Firecrawl search to handle grouped web data response and add corresponding tests
* feat: refactor Firecrawl web search to use aiohttp.ClientSession for improved error handling and session management
* feat: remove unused coercion function and update Firecrawl search to use default limit in payload
---
astrbot/core/astr_main_agent.py | 5 +
astrbot/core/config/default.py | 11 +
astrbot/core/tools/web_search_tools.py | 192 +++++++++
.../components/chat/MessageListDEPRECATED.vue | 2 +-
.../en-US/features/config-metadata.json | 4 +
.../ru-RU/features/config-metadata.json | 4 +
.../zh-CN/features/config-metadata.json | 4 +
tests/unit/test_astr_main_agent.py | 31 ++
tests/unit/test_func_tool_manager.py | 14 +
tests/unit/test_web_search_tools.py | 380 ++++++++++++++++++
10 files changed, 646 insertions(+), 1 deletion(-)
create mode 100644 tests/unit/test_web_search_tools.py
diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py
index a9ddb2e7b..87cb2db06 100644
--- a/astrbot/core/astr_main_agent.py
+++ b/astrbot/core/astr_main_agent.py
@@ -77,6 +77,8 @@ from astrbot.core.tools.web_search_tools import (
BaiduWebSearchTool,
BochaWebSearchTool,
BraveWebSearchTool,
+ FirecrawlExtractWebPageTool,
+ FirecrawlWebSearchTool,
TavilyExtractWebPageTool,
TavilyWebSearchTool,
normalize_legacy_web_search_config,
@@ -1047,6 +1049,9 @@ async def _apply_web_search_tools(
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BochaWebSearchTool))
elif provider == "brave":
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BraveWebSearchTool))
+ elif provider == "firecrawl":
+ req.func_tool.add_tool(tool_mgr.get_builtin_tool(FirecrawlWebSearchTool))
+ req.func_tool.add_tool(tool_mgr.get_builtin_tool(FirecrawlExtractWebPageTool))
elif provider == "baidu_ai_search":
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BaiduWebSearchTool))
diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index 903f6c445..cd1c81a88 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -3202,6 +3202,7 @@ CONFIG_METADATA_3 = {
"baidu_ai_search",
"bocha",
"brave",
+ "firecrawl",
],
"condition": {
"provider_settings.web_search": True,
@@ -3237,6 +3238,16 @@ CONFIG_METADATA_3 = {
"provider_settings.web_search": True,
},
},
+ "provider_settings.websearch_firecrawl_key": {
+ "description": "Firecrawl API Key",
+ "type": "list",
+ "items": {"type": "string"},
+ "hint": "可添加多个 Key 进行轮询。",
+ "condition": {
+ "provider_settings.websearch_provider": "firecrawl",
+ "provider_settings.web_search": True,
+ },
+ },
"provider_settings.websearch_baidu_app_builder_key": {
"description": "百度千帆智能云 APP Builder API Key",
"type": "string",
diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py
index ca89bc17d..ebd13d010 100644
--- a/astrbot/core/tools/web_search_tools.py
+++ b/astrbot/core/tools/web_search_tools.py
@@ -19,6 +19,8 @@ WEB_SEARCH_TOOL_NAMES = [
"tavily_extract_web_page",
"web_search_bocha",
"web_search_brave",
+ "web_search_firecrawl",
+ "firecrawl_extract_web_page",
]
_TAVILY_WEB_SEARCH_TOOL_CONFIG = {
"provider_settings.web_search": True,
@@ -32,6 +34,10 @@ _BRAVE_WEB_SEARCH_TOOL_CONFIG = {
"provider_settings.web_search": True,
"provider_settings.websearch_provider": "brave",
}
+_FIRECRAWL_WEB_SEARCH_TOOL_CONFIG = {
+ "provider_settings.web_search": True,
+ "provider_settings.websearch_provider": "firecrawl",
+}
_BAIDU_WEB_SEARCH_TOOL_CONFIG = {
"provider_settings.web_search": True,
"provider_settings.websearch_provider": "baidu_ai_search",
@@ -69,6 +75,7 @@ class _KeyRotator:
_TAVILY_KEY_ROTATOR = _KeyRotator("websearch_tavily_key", "Tavily")
_BOCHA_KEY_ROTATOR = _KeyRotator("websearch_bocha_key", "BoCha")
_BRAVE_KEY_ROTATOR = _KeyRotator("websearch_brave_key", "Brave")
+_FIRECRAWL_KEY_ROTATOR = _KeyRotator("websearch_firecrawl_key", "Firecrawl")
def normalize_legacy_web_search_config(cfg) -> None:
@@ -91,6 +98,7 @@ def normalize_legacy_web_search_config(cfg) -> None:
"websearch_tavily_key",
"websearch_bocha_key",
"websearch_brave_key",
+ "websearch_firecrawl_key",
):
value = provider_settings.get(setting_name)
if isinstance(value, str):
@@ -258,6 +266,72 @@ async def _brave_search(
]
+async def _firecrawl_search(
+ provider_settings: dict,
+ payload: dict,
+) -> list[SearchResult]:
+ firecrawl_key = await _FIRECRAWL_KEY_ROTATOR.get(provider_settings)
+ header = {
+ "Authorization": f"Bearer {firecrawl_key}",
+ "Content-Type": "application/json",
+ }
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.post(
+ "https://api.firecrawl.dev/v2/search",
+ json=payload,
+ headers=header,
+ ) as response:
+ if response.status != 200:
+ reason = await response.text()
+ raise Exception(
+ f"Firecrawl web search failed: {reason}, status: {response.status}",
+ )
+ data = await response.json()
+ rows = data.get("data", [])
+ if isinstance(rows, dict):
+ rows = rows.get("web", [])
+ return [
+ SearchResult(
+ title=item.get("title", ""),
+ url=item.get("url", ""),
+ snippet=(
+ item.get("description")
+ or item.get("snippet")
+ or item.get("markdown")
+ or ""
+ ),
+ )
+ for item in rows
+ if item.get("url")
+ ]
+
+
+async def _firecrawl_scrape(provider_settings: dict, payload: dict) -> dict:
+ firecrawl_key = await _FIRECRAWL_KEY_ROTATOR.get(provider_settings)
+ header = {
+ "Authorization": f"Bearer {firecrawl_key}",
+ "Content-Type": "application/json",
+ }
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.post(
+ "https://api.firecrawl.dev/v2/scrape",
+ json=payload,
+ headers=header,
+ ) as response:
+ if response.status != 200:
+ reason = await response.text()
+ raise Exception(
+ f"Firecrawl web scraper failed: {reason}, status: {response.status}",
+ )
+ data = await response.json()
+ result = data.get("data", {})
+ if not result:
+ raise ValueError(
+ "Error: Firecrawl web scraper does not return any results."
+ )
+ return result
+
+
async def _baidu_search(
provider_settings: dict,
payload: dict,
@@ -548,6 +622,124 @@ class BraveWebSearchTool(FunctionTool[AstrAgentContext]):
return _search_result_payload(results)
+@builtin_tool(config=_FIRECRAWL_WEB_SEARCH_TOOL_CONFIG)
+@pydantic_dataclass
+class FirecrawlWebSearchTool(FunctionTool[AstrAgentContext]):
+ name: str = "web_search_firecrawl"
+ description: str = (
+ "A web search tool based on Firecrawl Search API, used to retrieve web "
+ "pages related to the user's query."
+ )
+ parameters: dict = Field(
+ default_factory=lambda: {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "Required. Search query."},
+ "limit": {
+ "type": "integer",
+ "description": "Optional. Number of results to return. Range: 1-100. Default is 5.",
+ },
+ "location": {
+ "type": "string",
+ "description": "Optional. Geographic location for search results.",
+ },
+ "country": {
+ "type": "string",
+ "description": 'Optional. Country code for search results, for example "US" or "CN".',
+ },
+ "timeout": {
+ "type": "integer",
+ "description": "Optional. Request timeout in milliseconds.",
+ },
+ },
+ "required": ["query"],
+ }
+ )
+
+ async def call(self, context, **kwargs) -> ToolExecResult:
+ _, provider_settings, _ = _get_runtime(context)
+ if not provider_settings.get("websearch_firecrawl_key", []):
+ return "Error: Firecrawl API key is not configured in AstrBot."
+
+ payload = {
+ "query": kwargs["query"],
+ "limit": kwargs.get("limit", 5),
+ "sources": ["web"],
+ }
+ for key in ("location", "country", "timeout"):
+ if kwargs.get(key):
+ payload[key] = kwargs[key]
+
+ results = await _firecrawl_search(provider_settings, payload)
+ if not results:
+ return "Error: Firecrawl web searcher does not return any results."
+ return _search_result_payload(results)
+
+
+@builtin_tool(config=_FIRECRAWL_WEB_SEARCH_TOOL_CONFIG)
+@pydantic_dataclass
+class FirecrawlExtractWebPageTool(FunctionTool[AstrAgentContext]):
+ name: str = "firecrawl_extract_web_page"
+ description: str = "Extract the content of a web page using Firecrawl."
+ parameters: dict = Field(
+ default_factory=lambda: {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "Required. A URL to extract content from.",
+ },
+ "format": {
+ "type": "string",
+ "description": 'Optional. Output format, one of "markdown", "html", "rawHtml", "summary". Default is "markdown".',
+ },
+ "only_main_content": {
+ "type": "boolean",
+ "description": "Optional. Whether to extract only the main page content. Default is true.",
+ },
+ "timeout": {
+ "type": "integer",
+ "description": "Optional. Request timeout in milliseconds.",
+ },
+ "max_age": {
+ "type": "integer",
+ "description": "Optional. Maximum cache age in milliseconds.",
+ },
+ },
+ "required": ["url"],
+ }
+ )
+
+ async def call(self, context, **kwargs) -> ToolExecResult:
+ _, provider_settings, _ = _get_runtime(context)
+ if not provider_settings.get("websearch_firecrawl_key", []):
+ return "Error: Firecrawl API key is not configured in AstrBot."
+
+ url = str(kwargs.get("url", "")).strip()
+ if not url:
+ return "Error: url must be a non-empty string."
+
+ output_format = kwargs.get("format", "markdown")
+ if output_format not in ["markdown", "html", "rawHtml", "summary"]:
+ output_format = "markdown"
+
+ payload = {
+ "url": url,
+ "formats": [output_format],
+ "onlyMainContent": kwargs.get("only_main_content", True),
+ }
+ if kwargs.get("timeout"):
+ payload["timeout"] = kwargs["timeout"]
+ if kwargs.get("max_age"):
+ payload["maxAge"] = kwargs["max_age"]
+
+ result = await _firecrawl_scrape(provider_settings, payload)
+ content = result.get(output_format, "")
+ result_url = result.get("url") or url
+ ret = f"URL: {result_url}\nContent: {content}" if content else ""
+ return ret or "Error: Firecrawl web scraper does not return any results."
+
+
@builtin_tool(config=_BAIDU_WEB_SEARCH_TOOL_CONFIG)
@pydantic_dataclass
class BaiduWebSearchTool(FunctionTool[AstrAgentContext]):
diff --git a/dashboard/src/components/chat/MessageListDEPRECATED.vue b/dashboard/src/components/chat/MessageListDEPRECATED.vue
index 128d0d97b..1271042b3 100644
--- a/dashboard/src/components/chat/MessageListDEPRECATED.vue
+++ b/dashboard/src/components/chat/MessageListDEPRECATED.vue
@@ -303,7 +303,7 @@ export default {
part.tool_calls.forEach(toolCall => {
// 检查是否是支持引用解析的 web_search 工具调用
if (
- !['web_search_baidu', 'web_search_tavily', 'web_search_bocha', 'web_search_brave'].includes(toolCall.name) ||
+ !['web_search_baidu', 'web_search_tavily', 'web_search_bocha', 'web_search_brave', 'web_search_firecrawl'].includes(toolCall.name) ||
!toolCall.result
) {
return;
diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json
index 373182fc1..4f35dd285 100644
--- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json
+++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json
@@ -125,6 +125,10 @@
"description": "Brave Search API Key",
"hint": "Multiple keys can be added for rotation."
},
+ "websearch_firecrawl_key": {
+ "description": "Firecrawl API Key",
+ "hint": "Multiple keys can be added for rotation."
+ },
"websearch_baidu_app_builder_key": {
"description": "Baidu Qianfan Smart Cloud APP Builder API Key",
"hint": "Reference: [https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)"
diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json
index c578f79d1..08d11aed6 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json
@@ -125,6 +125,10 @@
"description": "API-ключ Brave Search",
"hint": "Можно добавить несколько ключей для ротации."
},
+ "websearch_firecrawl_key": {
+ "description": "API-ключ Firecrawl",
+ "hint": "Можно добавить несколько ключей для ротации."
+ },
"websearch_baidu_app_builder_key": {
"description": "API-ключ Baidu Qianfan APP Builder",
"hint": "Ссылка: [https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)"
diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
index dd8711345..8495f9ba1 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
@@ -127,6 +127,10 @@
"description": "Brave Search API Key",
"hint": "可添加多个 Key 进行轮询。"
},
+ "websearch_firecrawl_key": {
+ "description": "Firecrawl API Key",
+ "hint": "可添加多个 Key 进行轮询。"
+ },
"websearch_baidu_app_builder_key": {
"description": "百度千帆智能云 APP Builder API Key",
"hint": "参考:[https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)"
diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py
index 6ca1a3f2a..5a5bceae1 100644
--- a/tests/unit/test_astr_main_agent.py
+++ b/tests/unit/test_astr_main_agent.py
@@ -398,6 +398,37 @@ class TestBuiltinToolInjection:
assert req.func_tool is not None
assert req.func_tool.get_tool("web_search_baidu") is builtin_tool
+ @pytest.mark.asyncio
+ async def test_apply_web_search_tools_adds_firecrawl_search_and_extract_tools(
+ self, mock_event, mock_context
+ ):
+ """Test Firecrawl web search injects search and extract tools."""
+ module = ama
+ req = ProviderRequest()
+ mock_context.get_config.return_value = {
+ "provider_settings": {
+ "web_search": True,
+ "websearch_provider": "firecrawl",
+ }
+ }
+ search_tool = MagicMock(spec=FunctionTool)
+ search_tool.name = "web_search_firecrawl"
+ extract_tool = MagicMock(spec=FunctionTool)
+ extract_tool.name = "firecrawl_extract_web_page"
+ tool_mgr = MagicMock()
+ tool_mgr.get_builtin_tool.side_effect = [search_tool, extract_tool]
+ mock_context.get_llm_tool_manager.return_value = tool_mgr
+
+ await module._apply_web_search_tools(mock_event, req, mock_context)
+
+ assert tool_mgr.get_builtin_tool.call_args_list == [
+ ((module.FirecrawlWebSearchTool,),),
+ ((module.FirecrawlExtractWebPageTool,),),
+ ]
+ assert req.func_tool is not None
+ assert req.func_tool.get_tool("web_search_firecrawl") is search_tool
+ assert req.func_tool.get_tool("firecrawl_extract_web_page") is extract_tool
+
def test_proactive_cron_job_tools_uses_builtin_tool_manager(self, mock_context):
"""Test cron tool injection through the builtin tool manager."""
module = ama
diff --git a/tests/unit/test_func_tool_manager.py b/tests/unit/test_func_tool_manager.py
index 908810cdb..c87a2de08 100644
--- a/tests/unit/test_func_tool_manager.py
+++ b/tests/unit/test_func_tool_manager.py
@@ -2,6 +2,8 @@ from astrbot.core import sp
from astrbot.core.provider.func_tool_manager import FunctionToolManager
from astrbot.core.tools.computer_tools.shell import ExecuteShellTool
from astrbot.core.tools.message_tools import SendMessageToUserTool
+from astrbot.core.tools.web_search_tools import FirecrawlExtractWebPageTool
+from astrbot.core.tools.web_search_tools import FirecrawlWebSearchTool
def test_get_builtin_tool_by_class_returns_cached_instance():
@@ -38,3 +40,15 @@ def test_computer_tools_are_registered_as_builtin_tools():
assert tool.name == "astrbot_execute_shell"
assert manager.is_builtin_tool("astrbot_execute_shell") is True
+
+
+def test_firecrawl_tools_are_registered_as_builtin_tools():
+ manager = FunctionToolManager()
+
+ search_tool = manager.get_builtin_tool(FirecrawlWebSearchTool)
+ extract_tool = manager.get_builtin_tool(FirecrawlExtractWebPageTool)
+
+ assert search_tool.name == "web_search_firecrawl"
+ assert extract_tool.name == "firecrawl_extract_web_page"
+ assert manager.is_builtin_tool("web_search_firecrawl") is True
+ assert manager.is_builtin_tool("firecrawl_extract_web_page") is True
diff --git a/tests/unit/test_web_search_tools.py b/tests/unit/test_web_search_tools.py
new file mode 100644
index 000000000..c0ac3cf80
--- /dev/null
+++ b/tests/unit/test_web_search_tools.py
@@ -0,0 +1,380 @@
+import json
+from types import SimpleNamespace
+
+import pytest
+
+from astrbot.core.tools import web_search_tools as tools
+
+
+class _FakeConfig(dict):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.saved = False
+
+ def save_config(self):
+ self.saved = True
+
+
+def test_normalize_legacy_web_search_config_migrates_firecrawl_key():
+ config = _FakeConfig(
+ {"provider_settings": {"websearch_firecrawl_key": "firecrawl-key"}}
+ )
+
+ tools.normalize_legacy_web_search_config(config)
+
+ assert config["provider_settings"]["websearch_firecrawl_key"] == ["firecrawl-key"]
+ assert config.saved is True
+
+
+@pytest.mark.asyncio
+async def test_firecrawl_search_maps_web_results(monkeypatch):
+ async def fake_firecrawl_search(provider_settings, payload):
+ assert provider_settings["websearch_firecrawl_key"] == ["firecrawl-key"]
+ assert payload == {
+ "query": "AstrBot",
+ "limit": 3,
+ "sources": ["web"],
+ "country": "US",
+ }
+ return [
+ tools.SearchResult(
+ title="AstrBot",
+ url="https://example.com",
+ snippet="Search result",
+ )
+ ]
+
+ monkeypatch.setattr(tools, "_firecrawl_search", fake_firecrawl_search)
+ tool = tools.FirecrawlWebSearchTool()
+ context = _context_with_provider_settings(
+ {"websearch_firecrawl_key": ["firecrawl-key"]}
+ )
+
+ result = await tool.call(context, query="AstrBot", limit=3, country="US")
+
+ assert json.loads(result)["results"] == [
+ {
+ "title": "AstrBot",
+ "url": "https://example.com",
+ "snippet": "Search result",
+ "index": json.loads(result)["results"][0]["index"],
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_firecrawl_search_maps_v2_data_list(monkeypatch):
+ session = _FakeFirecrawlSession(
+ _FakeFirecrawlResponse(
+ status=200,
+ json_data={
+ "success": True,
+ "data": [
+ {
+ "title": "AstrBot",
+ "url": "https://example.com",
+ "description": "Search result",
+ }
+ ],
+ },
+ )
+ )
+
+ def fake_client_session(*, trust_env):
+ session.trust_env = trust_env
+ return session
+
+ monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
+
+ results = await tools._firecrawl_search(
+ {"websearch_firecrawl_key": ["firecrawl-key"]},
+ {"query": "AstrBot", "limit": 5, "sources": ["web"]},
+ )
+
+ assert session.posted == {
+ "url": "https://api.firecrawl.dev/v2/search",
+ "json": {"query": "AstrBot", "limit": 5, "sources": ["web"]},
+ "headers": {
+ "Authorization": "Bearer firecrawl-key",
+ "Content-Type": "application/json",
+ },
+ }
+ assert results == [
+ tools.SearchResult(
+ title="AstrBot", url="https://example.com", snippet="Search result"
+ )
+ ]
+
+
+@pytest.mark.asyncio
+async def test_firecrawl_search_maps_v2_grouped_web_data(monkeypatch):
+ session = _FakeFirecrawlSession(
+ _FakeFirecrawlResponse(
+ status=200,
+ json_data={
+ "success": True,
+ "data": {
+ "web": [
+ {
+ "title": "AstrBot",
+ "url": "https://example.com",
+ "description": "Search result",
+ }
+ ]
+ },
+ },
+ )
+ )
+
+ def fake_client_session(*, trust_env):
+ session.trust_env = trust_env
+ return session
+
+ monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
+
+ results = await tools._firecrawl_search(
+ {"websearch_firecrawl_key": ["firecrawl-key"]},
+ {"query": "AstrBot", "limit": 5, "sources": ["web"]},
+ )
+
+ assert results == [
+ tools.SearchResult(
+ title="AstrBot", url="https://example.com", snippet="Search result"
+ )
+ ]
+
+
+@pytest.mark.asyncio
+async def test_firecrawl_search_payload_omits_tbs_and_uses_default_limit(monkeypatch):
+ async def fake_firecrawl_search(provider_settings, payload):
+ assert payload == {
+ "query": "AstrBot",
+ "limit": 5,
+ "sources": ["web"],
+ "country": "US",
+ }
+ return [
+ tools.SearchResult(
+ title="AstrBot",
+ url="https://example.com",
+ snippet="Search result",
+ )
+ ]
+
+ monkeypatch.setattr(tools, "_firecrawl_search", fake_firecrawl_search)
+ tool = tools.FirecrawlWebSearchTool()
+ context = _context_with_provider_settings(
+ {"websearch_firecrawl_key": ["firecrawl-key"]}
+ )
+
+ result = await tool.call(
+ context,
+ query="AstrBot",
+ tbs="qdr:d",
+ country="US",
+ )
+
+ assert json.loads(result)["results"][0]["url"] == "https://example.com"
+ assert "tbs" not in tool.parameters["properties"]
+
+
+@pytest.mark.asyncio
+async def test_firecrawl_extract_returns_scraped_markdown(monkeypatch):
+ async def fake_firecrawl_scrape(provider_settings, payload):
+ assert provider_settings["websearch_firecrawl_key"] == ["firecrawl-key"]
+ assert payload == {
+ "url": "https://example.com",
+ "formats": ["markdown"],
+ "onlyMainContent": True,
+ }
+ return {"url": "https://example.com", "markdown": "# Example"}
+
+ monkeypatch.setattr(tools, "_firecrawl_scrape", fake_firecrawl_scrape)
+ tool = tools.FirecrawlExtractWebPageTool()
+ context = _context_with_provider_settings(
+ {"websearch_firecrawl_key": ["firecrawl-key"]}
+ )
+
+ result = await tool.call(context, url="https://example.com")
+
+ assert result == "URL: https://example.com\nContent: # Example"
+
+
+@pytest.mark.asyncio
+async def test_firecrawl_search_uses_session_context(monkeypatch):
+ session = _FakeFirecrawlSession(
+ _FakeFirecrawlResponse(
+ status=200,
+ json_data={
+ "success": True,
+ "data": [
+ {
+ "title": "AstrBot",
+ "url": "https://example.com",
+ "description": "Search result",
+ }
+ ],
+ },
+ )
+ )
+
+ def fake_client_session(*, trust_env):
+ session.trust_env = trust_env
+ return session
+
+ monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
+
+ await tools._firecrawl_search(
+ {"websearch_firecrawl_key": ["firecrawl-key"]},
+ {"query": "AstrBot"},
+ )
+
+ assert session.trust_env is True
+ assert session.entered is True
+ assert session.exited is True
+ assert session.posted == {
+ "url": "https://api.firecrawl.dev/v2/search",
+ "json": {"query": "AstrBot"},
+ "headers": {
+ "Authorization": "Bearer firecrawl-key",
+ "Content-Type": "application/json",
+ },
+ }
+
+
+@pytest.mark.asyncio
+async def test_firecrawl_search_raises_error_for_http_errors(monkeypatch):
+ session = _FakeFirecrawlSession(
+ _FakeFirecrawlResponse(status=401, text_data="Unauthorized")
+ )
+
+ def fake_client_session(*, trust_env):
+ session.trust_env = trust_env
+ return session
+
+ monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
+
+ with pytest.raises(
+ Exception,
+ match="Firecrawl web search failed: Unauthorized, status: 401",
+ ):
+ await tools._firecrawl_search(
+ {"websearch_firecrawl_key": ["firecrawl-key"]},
+ {"query": "AstrBot"},
+ )
+
+ assert session.trust_env is True
+ assert session.entered is True
+ assert session.exited is True
+
+
+@pytest.mark.asyncio
+async def test_firecrawl_scrape_uses_request_setup(monkeypatch):
+ session = _FakeFirecrawlSession(
+ _FakeFirecrawlResponse(
+ status=200,
+ json_data={
+ "success": True,
+ "data": {"url": "https://example.com", "markdown": "# Example"},
+ },
+ )
+ )
+
+ def fake_client_session(*, trust_env):
+ session.trust_env = trust_env
+ return session
+
+ monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
+
+ result = await tools._firecrawl_scrape(
+ {"websearch_firecrawl_key": ["firecrawl-key"]},
+ {"url": "https://example.com", "formats": ["markdown"]},
+ )
+
+ assert result == {"url": "https://example.com", "markdown": "# Example"}
+ assert session.trust_env is True
+ assert session.entered is True
+ assert session.exited is True
+ assert session.posted == {
+ "url": "https://api.firecrawl.dev/v2/scrape",
+ "json": {"url": "https://example.com", "formats": ["markdown"]},
+ "headers": {
+ "Authorization": "Bearer firecrawl-key",
+ "Content-Type": "application/json",
+ },
+ }
+
+
+@pytest.mark.asyncio
+async def test_firecrawl_scrape_raises_error_for_http_errors(monkeypatch):
+ session = _FakeFirecrawlSession(
+ _FakeFirecrawlResponse(status=401, text_data="Unauthorized")
+ )
+
+ def fake_client_session(*, trust_env):
+ session.trust_env = trust_env
+ return session
+
+ monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
+
+ with pytest.raises(
+ Exception,
+ match="Firecrawl web scraper failed: Unauthorized, status: 401",
+ ):
+ await tools._firecrawl_scrape(
+ {"websearch_firecrawl_key": ["firecrawl-key"]},
+ {"url": "https://example.com", "formats": ["markdown"]},
+ )
+
+ assert session.trust_env is True
+ assert session.entered is True
+ assert session.exited is True
+
+
+class _FakeFirecrawlResponse:
+ def __init__(self, status=200, json_data=None, text_data=""):
+ self.status = status
+ self.json_data = json_data or {}
+ self.text_data = text_data
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return None
+
+ async def json(self):
+ return self.json_data
+
+ async def text(self):
+ return self.text_data
+
+
+class _FakeFirecrawlSession:
+ def __init__(self, response):
+ self.response = response
+ self.trust_env = None
+ self.entered = False
+ self.exited = False
+ self.posted = None
+
+ async def __aenter__(self):
+ self.entered = True
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ self.exited = True
+ return None
+
+ def post(self, url, json, headers):
+ self.posted = {"url": url, "json": json, "headers": headers}
+ return self.response
+
+
+def _context_with_provider_settings(provider_settings):
+ config = {"provider_settings": provider_settings}
+ agent_context = SimpleNamespace(
+ context=SimpleNamespace(get_config=lambda umo: config),
+ event=SimpleNamespace(unified_msg_origin="test:private:session"),
+ )
+ return SimpleNamespace(context=agent_context)
From 55c15586868942516e89174340bae7bac58223c1 Mon Sep 17 00:00:00 2001
From: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Date: Sun, 26 Apr 2026 13:10:47 +0800
Subject: [PATCH 15/41] fix(openai): apply empty-assistant filter to streaming
path (fixes #7721) (#7758)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PR #7202 added empty-assistant filtering in `_query` so strict
providers (Moonshot, etc.) wouldn't 400 on history with blank
assistant entries. The streaming sibling `_query_stream` was
never updated, so DeepSeek Reasoner — which returns reasoning only
during tool calls, leaving serialized content as `""` — blew up with
`Invalid assistant message: content or tool_calls must be set` on
the next turn.
Hoisted the filter into a `_sanitize_assistant_messages` helper and
called it from both paths. Also widened the empty check to cover
`content == []`, which the original filter missed and which shows up
with providers that emit content as a list of parts.
---
.../core/provider/sources/openai_source.py | 59 ++++++----
tests/test_openai_source.py | 106 ++++++++++++++++++
2 files changed, 145 insertions(+), 20 deletions(-)
diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py
index f2d947490..eb01ff01d 100644
--- a/astrbot/core/provider/sources/openai_source.py
+++ b/astrbot/core/provider/sources/openai_source.py
@@ -519,6 +519,42 @@ class ProviderOpenAIOfficial(Provider):
except NotFoundError as e:
raise Exception(f"获取模型列表失败:{e}")
+ @staticmethod
+ def _sanitize_assistant_messages(payloads: dict) -> None:
+ """在请求发送前过滤/规范化空的 assistant 消息。
+
+ 严格 API(Moonshot、DeepSeek Reasoner 等)会在 assistant 消息同时缺少
+ ``content`` 和 ``tool_calls`` 时返回 400。把 ``""`` / ``None`` / ``[]``
+ 都视作空内容:无 tool_calls 时整条过滤掉;有 tool_calls 时将 content
+ 设为 ``None`` 以符合 OpenAI 规范。就地修改 ``payloads["messages"]``。
+ """
+ messages = payloads.get("messages")
+ if not isinstance(messages, list):
+ return
+
+ def _is_empty(content: Any) -> bool:
+ return content is None or content == "" or content == []
+
+ cleaned: list[Any] = []
+ for idx, msg in enumerate(messages):
+ if not isinstance(msg, dict) or msg.get("role") != "assistant":
+ cleaned.append(msg)
+ continue
+
+ content = msg.get("content")
+ tool_calls = msg.get("tool_calls")
+
+ if _is_empty(content) and not tool_calls:
+ logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)")
+ continue
+
+ if _is_empty(content) and tool_calls:
+ msg["content"] = None
+
+ cleaned.append(msg)
+
+ payloads["messages"] = cleaned
+
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
if tools:
model = payloads.get("model", "").lower()
@@ -548,26 +584,7 @@ class ProviderOpenAIOfficial(Provider):
model = payloads.get("model", "").lower()
- if "messages" in payloads and isinstance(payloads["messages"], list):
- cleaned_messages = []
- for idx, msg in enumerate(payloads["messages"]):
- # 过滤空的 assistant 消息,防止严格 API(如 Moonshot)返回 400 错误
- if msg.get("role") == "assistant":
- content = msg.get("content")
- tool_calls = msg.get("tool_calls")
-
- # 情况1: 空/null content 且无 tool_calls -> 过滤掉
- if not tool_calls and (content == "" or content is None):
- logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)")
- continue
-
- # 情况2: 空 content 但有 tool_calls -> 设为 None (符合 OpenAI 规范)
- if content == "" and tool_calls:
- msg["content"] = None
-
- cleaned_messages.append(msg)
-
- payloads["messages"] = cleaned_messages
+ self._sanitize_assistant_messages(payloads)
completion = await self.client.chat.completions.create(
**payloads,
@@ -619,6 +636,8 @@ class ProviderOpenAIOfficial(Provider):
del payloads[key]
self._apply_provider_specific_extra_body_overrides(extra_body)
+ self._sanitize_assistant_messages(payloads)
+
stream = await self.client.chat.completions.create(
**payloads,
stream=True,
diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py
index 83e18137c..ec5e79f49 100644
--- a/tests/test_openai_source.py
+++ b/tests/test_openai_source.py
@@ -1618,3 +1618,109 @@ async def test_query_does_not_filter_user_or_system_messages(monkeypatch):
assert messages[2] == {"role": "user", "content": "hello"}
finally:
await provider.terminate()
+
+
+@pytest.mark.asyncio
+async def test_query_stream_filters_empty_assistant_message(monkeypatch):
+ """Regression for #7721: streaming path must also filter empty assistant messages.
+
+ Previously only ``_query`` sanitized the payload; ``_query_stream`` forwarded
+ the raw history and strict providers (e.g. DeepSeek Reasoner) returned 400 on
+ the next turn after a tool call whose assistant entry had reasoning only.
+ """
+ provider = _make_provider()
+ try:
+ captured_kwargs = {}
+
+ async def fake_stream():
+ yield ChatCompletionChunk.model_validate(
+ {
+ "id": "chatcmpl-stream",
+ "object": "chat.completion.chunk",
+ "created": 0,
+ "model": "deepseek-reasoner",
+ "choices": [
+ {
+ "index": 0,
+ "delta": {"role": "assistant", "content": "ok"},
+ "finish_reason": "stop",
+ }
+ ],
+ }
+ )
+
+ async def fake_create(**kwargs):
+ captured_kwargs.update(kwargs)
+ return fake_stream()
+
+ monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
+
+ payloads = {
+ "model": "deepseek-reasoner",
+ "messages": [
+ {"role": "user", "content": "hello"},
+ {"role": "assistant", "content": ""}, # should be filtered
+ {"role": "user", "content": "world"},
+ ],
+ }
+
+ async for _ in provider._query_stream(payloads=payloads, tools=None):
+ pass
+
+ messages = captured_kwargs["messages"]
+ assert len(messages) == 2
+ assert messages[0] == {"role": "user", "content": "hello"}
+ assert messages[1] == {"role": "user", "content": "world"}
+ finally:
+ await provider.terminate()
+
+
+@pytest.mark.asyncio
+async def test_query_filters_empty_list_content_assistant_message(monkeypatch):
+ """Empty-list content (``content == []``) must also be filtered, not just ``""`` / ``None``."""
+ provider = _make_provider()
+ try:
+ captured_kwargs = {}
+
+ async def fake_create(**kwargs):
+ captured_kwargs.update(kwargs)
+ return ChatCompletion.model_validate(
+ {
+ "id": "chatcmpl-test",
+ "object": "chat.completion",
+ "created": 0,
+ "model": "gpt-4o-mini",
+ "choices": [
+ {
+ "index": 0,
+ "message": {"role": "assistant", "content": "ok"},
+ "finish_reason": "stop",
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 1,
+ "completion_tokens": 1,
+ "total_tokens": 2,
+ },
+ }
+ )
+
+ monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
+
+ payloads = {
+ "model": "gpt-4o-mini",
+ "messages": [
+ {"role": "user", "content": "hi"},
+ {"role": "assistant", "content": []}, # should be filtered
+ {"role": "user", "content": "again"},
+ ],
+ }
+
+ await provider._query(payloads=payloads, tools=None)
+
+ messages = captured_kwargs["messages"]
+ assert len(messages) == 2
+ assert messages[0] == {"role": "user", "content": "hi"}
+ assert messages[1] == {"role": "user", "content": "again"}
+ finally:
+ await provider.terminate()
From d16ed4e552f4b8f017cb2b9edd141dd1d9d81d32 Mon Sep 17 00:00:00 2001
From: Weilong Liao <37870767+Soulter@users.noreply.github.com>
Date: Sun, 26 Apr 2026 22:21:57 +0800
Subject: [PATCH 16/41] fix: revise reasoning_key attribute to OpenRouter
(#7821)
---
astrbot/core/provider/sources/openrouter_source.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/astrbot/core/provider/sources/openrouter_source.py b/astrbot/core/provider/sources/openrouter_source.py
index e49d0c929..a308ad309 100644
--- a/astrbot/core/provider/sources/openrouter_source.py
+++ b/astrbot/core/provider/sources/openrouter_source.py
@@ -20,3 +20,4 @@ class ProviderOpenRouter(ProviderOpenAIOfficial):
self.client._custom_headers["X-OpenRouter-Categories"] = (
"general-chat,personal-agent" # type: ignore
)
+ self.reasoning_key = "reasoning"
From 3c1d0cd2c287e2c4d64b69c5c57a7a374abf6daa Mon Sep 17 00:00:00 2001
From: EnemyWind
Date: Sun, 26 Apr 2026 23:06:54 +0800
Subject: [PATCH 17/41] =?UTF-8?q?[fix]=20=E5=B0=86Minimax=20TTS=E9=BB=98?=
=?UTF-8?q?=E8=AE=A4=E8=BE=93=E5=87=BA=E6=A0=BC=E5=BC=8F=E6=94=B9=E4=B8=BA?=
=?UTF-8?q?wav=E4=BB=A5=E8=A7=A3=E5=86=B3RIFF=E9=94=99=E8=AF=AF=20(#7797)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 问题
在 QQ 官方平台插件中,处理来自 Minimax TTS 的语音时,会抛出错误:`处理语音时出错: file does not start with RIFF id`。
## 原因
Minimax TTS 提供商 (`minimax_tts_api_source.py`) 默认配置的音频输出格式为 `mp3`,而 `qqofficial_message_event.py` 中的 `wav_to_tencent_silk` 函数要求输入为 WAV 格式(具有 RIFF 文件头)。
## 解决方案
将 `minimax_tts_api_source.py` 文件中 `ProviderMiniMaxTTSAPI` 类的 `audio_setting` 字典的 `format` 键值,从 `"mp3"` 修改为 `"wav"`。
## 结果
修改后,Minimax TTS 生成的音频文件将直接为 WAV 格式,从而被下游函数正确识别和处理,修复上述错误。
---
astrbot/core/provider/sources/minimax_tts_api_source.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/astrbot/core/provider/sources/minimax_tts_api_source.py b/astrbot/core/provider/sources/minimax_tts_api_source.py
index f40cb968a..7ca369708 100644
--- a/astrbot/core/provider/sources/minimax_tts_api_source.py
+++ b/astrbot/core/provider/sources/minimax_tts_api_source.py
@@ -65,7 +65,7 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
self.audio_setting: dict = {
"sample_rate": 32000,
"bitrate": 128000,
- "format": "mp3",
+ "format": "wav",
}
self.concat_base_url: str = f"{self.api_base}?GroupId={self.group_id}"
@@ -147,7 +147,7 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
async def get_audio(self, text: str) -> str:
temp_dir = get_astrbot_temp_path()
os.makedirs(temp_dir, exist_ok=True)
- path = os.path.join(temp_dir, f"minimax_tts_api_{uuid.uuid4()}.mp3")
+ path = os.path.join(temp_dir, f"minimax_tts_api_{uuid.uuid4()}.wav")
try:
# 直接将异步生成器传递给 _audio_play 方法
From bbda1e678f7cdf307c89637c17021f61251cc769 Mon Sep 17 00:00:00 2001
From: bugkeep <140229885+bugkeep@users.noreply.github.com>
Date: Sun, 26 Apr 2026 23:10:58 +0800
Subject: [PATCH 18/41] fix(core): downscale oversized images (#7807)
* fix(core): downscale oversized images
* refactor: share image max-size check helper
* Delete tests/unit/test_media_utils_compress_image.py
---------
Co-authored-by: Weilong Liao <37870767+Soulter@users.noreply.github.com>
---
astrbot/core/utils/media_utils.py | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/astrbot/core/utils/media_utils.py b/astrbot/core/utils/media_utils.py
index 40f1e6049..03d7912cb 100644
--- a/astrbot/core/utils/media_utils.py
+++ b/astrbot/core/utils/media_utils.py
@@ -436,19 +436,30 @@ async def compress_image(
optimize = IMAGE_COMPRESS_DEFAULT_OPTIMIZE
min_file_size_bytes = int(IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_MB * 1024 * 1024)
data = None
+
+ def _exceeds_max_size(source: bytes | Path) -> bool:
+ try:
+ fp = io.BytesIO(source) if isinstance(source, bytes) else source
+ with PILImage.open(fp) as opened_img:
+ return max(opened_img.size) > max_size
+ except Exception: # noqa: BLE001
+ return False
+
# Skip compression for remote images and return the original value.
if url_or_path.startswith("http"):
return url_or_path
elif url_or_path.startswith("data:image"):
_header, encoded = url_or_path.split(",", 1)
data = base64.b64decode(encoded)
- if len(data) < min_file_size_bytes:
+ if len(data) < min_file_size_bytes and not _exceeds_max_size(data):
return url_or_path
else:
local_path = Path(url_or_path)
if not local_path.exists():
return url_or_path
- if local_path.stat().st_size < min_file_size_bytes:
+ if local_path.stat().st_size < min_file_size_bytes and not _exceeds_max_size(
+ local_path
+ ):
return url_or_path
with local_path.open("rb") as f:
data = f.read()
From 07b37b98deb9c141410f13bd59b7b8374cfc7436 Mon Sep 17 00:00:00 2001
From: Weilong Liao <37870767+Soulter@users.noreply.github.com>
Date: Mon, 27 Apr 2026 02:19:40 +0800
Subject: [PATCH 19/41] fix: handle empty reasoning content for DeepSeek v4
models (#7823)
Co-authored-by: Copilot
---
astrbot/core/provider/sources/openai_source.py | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py
index eb01ff01d..c1191a2dd 100644
--- a/astrbot/core/provider/sources/openai_source.py
+++ b/astrbot/core/provider/sources/openai_source.py
@@ -982,6 +982,11 @@ class ProviderOpenAIOfficial(Provider):
"""Finally convert the payload. Such as think part conversion, tool inject."""
model = payloads.get("model", "").lower()
is_gemini = "gemini" in model
+ deepseek_reasoning_models = {"deepseek-v4-pro", "deepseek-v4-flash"}
+ is_deepseek_v4_reasoning = (
+ model in deepseek_reasoning_models
+ or "api.deepseek.com" in self.client.base_url.host
+ )
for message in payloads.get("messages", []):
if message.get("role") == "assistant" and isinstance(
@@ -997,7 +1002,14 @@ class ProviderOpenAIOfficial(Provider):
# Some providers (Grok, etc.) reject empty content lists.
# When all parts were think blocks, fall back to None.
message["content"] = new_content or None
- if reasoning_content:
+ if is_deepseek_v4_reasoning and not reasoning_content:
+ logger.info(
+ "Deepseek v4 model requires non-empty reasoning content, but got empty. Setting to 'none' to satisfy the requirement."
+ )
+ # Deepseek models require the field on assistant
+ # history messages, even when the reasoning content is empty.
+ message["reasoning_content"] = "none"
+ elif reasoning_content:
message["reasoning_content"] = reasoning_content
# Gemini 的 function_response 要求 google.protobuf.Struct(即 JSON 对象),
From 415da218f640b2cca31feb645c5d5978d53df9e6 Mon Sep 17 00:00:00 2001
From: Weilong Liao <37870767+Soulter@users.noreply.github.com>
Date: Mon, 27 Apr 2026 11:47:32 +0800
Subject: [PATCH 20/41] fix: update reasoning_content handling to support empty
string values (#7830)
* fix: update reasoning_content handling to support empty string values
* fix: add reasoning_content field for DeepSeek v4 models in assistant messages
---
.../agent/runners/tool_loop_agent_runner.py | 12 ++--
astrbot/core/provider/entities.py | 4 +-
.../core/provider/sources/anthropic_source.py | 2 +-
.../core/provider/sources/gemini_source.py | 2 +-
.../core/provider/sources/openai_source.py | 57 +++++++++++--------
5 files changed, 43 insertions(+), 34 deletions(-)
diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py
index cf70b4150..d96a4c92c 100644
--- a/astrbot/core/agent/runners/tool_loop_agent_runner.py
+++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py
@@ -183,10 +183,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self.stats.end_time = time.time()
parts = []
- if llm_resp.reasoning_content or llm_resp.reasoning_signature:
+ if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
- think=llm_resp.reasoning_content,
+ think=llm_resp.reasoning_content or "",
encrypted=llm_resp.reasoning_signature,
)
)
@@ -876,10 +876,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# 将结果添加到上下文中
parts = []
- if llm_resp.reasoning_content or llm_resp.reasoning_signature:
+ if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
- think=llm_resp.reasoning_content,
+ think=llm_resp.reasoning_content or "",
encrypted=llm_resp.reasoning_signature,
)
)
@@ -1361,10 +1361,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self.stats.end_time = time.time()
parts = []
- if llm_resp.reasoning_content or llm_resp.reasoning_signature:
+ if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
- think=llm_resp.reasoning_content,
+ think=llm_resp.reasoning_content or "",
encrypted=llm_resp.reasoning_signature,
)
)
diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py
index d4bf8814d..9b64196e7 100644
--- a/astrbot/core/provider/entities.py
+++ b/astrbot/core/provider/entities.py
@@ -353,7 +353,7 @@ class LLMResponse:
"""Tool call IDs."""
tools_call_extra_content: dict[str, dict[str, Any]] = field(default_factory=dict)
"""Tool call extra content. tool_call_id -> extra_content dict"""
- reasoning_content: str = ""
+ reasoning_content: str | None = None
"""The reasoning content extracted from the LLM, if any."""
reasoning_signature: str | None = None
"""The signature of the reasoning content, if any."""
@@ -404,8 +404,6 @@ class LLMResponse:
raw_completion (ChatCompletion, optional): 原始响应, OpenAI 格式. Defaults to None.
"""
- if reasoning_content is None:
- reasoning_content = ""
if tools_call_args is None:
tools_call_args = []
if tools_call_name is None:
diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py
index d2fce17de..87e1b0284 100644
--- a/astrbot/core/provider/sources/anthropic_source.py
+++ b/astrbot/core/provider/sources/anthropic_source.py
@@ -39,7 +39,7 @@ class ProviderAnthropic(Provider):
stop_reason: str | None = None,
) -> None:
has_text_output = bool((llm_response.completion_text or "").strip())
- has_reasoning_output = bool(llm_response.reasoning_content.strip())
+ has_reasoning_output = bool((llm_response.reasoning_content or "").strip())
has_tool_output = bool(llm_response.tools_call_args)
if has_text_output or has_reasoning_output or has_tool_output:
return
diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py
index 158170d51..a942c56e4 100644
--- a/astrbot/core/provider/sources/gemini_source.py
+++ b/astrbot/core/provider/sources/gemini_source.py
@@ -462,7 +462,7 @@ class ProviderGoogleGenAI(Provider):
finish_reason: str | None = None,
) -> None:
has_text_output = bool((llm_response.completion_text or "").strip())
- has_reasoning_output = bool(llm_response.reasoning_content.strip())
+ has_reasoning_output = bool((llm_response.reasoning_content or "").strip())
has_tool_output = bool(llm_response.tools_call_args)
if has_text_output or has_reasoning_output or has_tool_output:
return
diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py
index c1191a2dd..512e47233 100644
--- a/astrbot/core/provider/sources/openai_source.py
+++ b/astrbot/core/provider/sources/openai_source.py
@@ -671,9 +671,9 @@ class ProviderOpenAIOfficial(Provider):
reasoning = self._extract_reasoning_content(chunk)
_y = False
llm_response.id = chunk.id
- llm_response.reasoning_content = ""
+ llm_response.reasoning_content = None
llm_response.completion_text = ""
- if reasoning:
+ if reasoning is not None:
llm_response.reasoning_content = reasoning
_y = True
if delta and delta.content:
@@ -701,22 +701,28 @@ class ProviderOpenAIOfficial(Provider):
def _extract_reasoning_content(
self,
completion: ChatCompletion | ChatCompletionChunk,
- ) -> str:
+ ) -> str | None:
"""Extract reasoning content from OpenAI ChatCompletion if available."""
- reasoning_text = ""
+
+ def _get_reasoning_attr(obj: Any) -> str | None:
+ fields_set = getattr(obj, "model_fields_set", None)
+ if isinstance(fields_set, set) and self.reasoning_key in fields_set:
+ attr = getattr(obj, self.reasoning_key, "")
+ return "" if attr is None else str(attr)
+ attr = getattr(obj, self.reasoning_key, None)
+ return None if attr is None else str(attr)
+
if not completion.choices:
- return reasoning_text
+ return None
if isinstance(completion, ChatCompletion):
choice = completion.choices[0]
- reasoning_attr = getattr(choice.message, self.reasoning_key, None)
- if reasoning_attr:
- reasoning_text = str(reasoning_attr)
+ reasoning_attr = _get_reasoning_attr(choice.message)
elif isinstance(completion, ChatCompletionChunk):
delta = completion.choices[0].delta
- reasoning_attr = getattr(delta, self.reasoning_key, None)
- if reasoning_attr:
- reasoning_text = str(reasoning_attr)
- return reasoning_text
+ reasoning_attr = _get_reasoning_attr(delta)
+ else:
+ return None
+ return reasoning_attr
def _extract_usage(self, usage: CompletionUsage | dict) -> TokenUsage:
ptd = getattr(usage, "prompt_tokens_details", None)
@@ -859,7 +865,9 @@ class ProviderOpenAIOfficial(Provider):
# parse the reasoning content if any
# the priority is higher than the tag extraction
- llm_response.reasoning_content = self._extract_reasoning_content(completion)
+ reasoning_content = self._extract_reasoning_content(completion)
+ if reasoning_content is not None:
+ llm_response.reasoning_content = reasoning_content
# parse tool calls if any
if choice.message.tool_calls and tools is not None:
@@ -906,7 +914,7 @@ class ProviderOpenAIOfficial(Provider):
"API 返回的 completion 由于内容安全过滤被拒绝(非 AstrBot)。",
)
has_text_output = bool((llm_response.completion_text or "").strip())
- has_reasoning_output = bool(llm_response.reasoning_content.strip())
+ has_reasoning_output = bool((llm_response.reasoning_content or "").strip())
if (
not has_text_output
and not has_reasoning_output
@@ -987,31 +995,34 @@ class ProviderOpenAIOfficial(Provider):
model in deepseek_reasoning_models
or "api.deepseek.com" in self.client.base_url.host
)
-
for message in payloads.get("messages", []):
if message.get("role") == "assistant" and isinstance(
message.get("content"), list
):
reasoning_content = ""
+ reasoning_content_present = False
new_content = [] # not including think part
for part in message["content"]:
if part.get("type") == "think":
+ reasoning_content_present = True
reasoning_content += str(part.get("think"))
else:
new_content.append(part)
# Some providers (Grok, etc.) reject empty content lists.
# When all parts were think blocks, fall back to None.
message["content"] = new_content or None
- if is_deepseek_v4_reasoning and not reasoning_content:
- logger.info(
- "Deepseek v4 model requires non-empty reasoning content, but got empty. Setting to 'none' to satisfy the requirement."
- )
- # Deepseek models require the field on assistant
- # history messages, even when the reasoning content is empty.
- message["reasoning_content"] = "none"
- elif reasoning_content:
+ if reasoning_content_present:
message["reasoning_content"] = reasoning_content
+ if (
+ message.get("role") == "assistant"
+ and is_deepseek_v4_reasoning
+ and "reasoning_content" not in message
+ ):
+ # DeepSeek v4 reasoning models require the field on assistant
+ # history messages, even when the reasoning content is empty.
+ message["reasoning_content"] = ""
+
# Gemini 的 function_response 要求 google.protobuf.Struct(即 JSON 对象),
# 纯文本会触发 400 Invalid argument,需要包一层 JSON。
if is_gemini and message.get("role") == "tool":
From c5ab4f726374c4f0c9d9de6e9cf7d7569eef8001 Mon Sep 17 00:00:00 2001
From: Weilong Liao <37870767+Soulter@users.noreply.github.com>
Date: Mon, 27 Apr 2026 12:59:21 +0800
Subject: [PATCH 21/41] feat: add /stats command to view conversation token
usage (#7831)
* feat: add /stats command to view conversation token usage
- Add stats() method to ConversationCommands that queries ProviderStat
records by conversation_id and aggregates token breakdowns
- Register /stats command in main.py
* feat: reorder conversation stats output for better readability
Co-authored-by: Copilot
* feat: reorder token usage output for improved clarity
* feat: enhance stats command to aggregate conversation token usage
* feat: add cached input tokens display and update translations for clarity
* feat: update stats command to clarify conversation token usage display
---------
Co-authored-by: Copilot
---
.../builtin_commands/commands/conversation.py | 63 +++++++++++++++++++
.../builtin_stars/builtin_commands/main.py | 5 ++
.../src/components/chat/ChatMessageList.vue | 15 ++++-
dashboard/src/components/chat/MessageList.vue | 15 ++++-
.../src/i18n/locales/en-US/features/chat.json | 4 +-
.../src/i18n/locales/ru-RU/features/chat.json | 4 +-
.../src/i18n/locales/zh-CN/features/chat.json | 4 +-
7 files changed, 102 insertions(+), 8 deletions(-)
diff --git a/astrbot/builtin_stars/builtin_commands/commands/conversation.py b/astrbot/builtin_stars/builtin_commands/commands/conversation.py
index ddc5e99aa..9dcf36909 100644
--- a/astrbot/builtin_stars/builtin_commands/commands/conversation.py
+++ b/astrbot/builtin_stars/builtin_commands/commands/conversation.py
@@ -1,3 +1,6 @@
+from sqlalchemy import case, func, select
+from sqlmodel import col
+
from astrbot.api import sp, star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core import logger
@@ -7,6 +10,7 @@ from astrbot.core.agent.runners.deerflow.constants import (
DEERFLOW_THREAD_ID_KEY,
)
from astrbot.core.agent.runners.deerflow.deerflow_api_client import DeerFlowAPIClient
+from astrbot.core.db.po import ProviderStat
from astrbot.core.utils.active_event_registry import active_event_registry
from .utils.rst_scene import RstScene
@@ -246,3 +250,62 @@ class ConversationCommands:
f"✅ Switched to new conversation: {cid[:4]}。"
),
)
+
+ async def stats(self, message: AstrMessageEvent) -> None:
+ """Show token usage statistics for the current conversation."""
+ umo = message.unified_msg_origin
+ cid = await self.context.conversation_manager.get_curr_conversation_id(umo)
+
+ if not cid:
+ message.set_result(
+ MessageEventResult().message(
+ "❌ You are not in a conversation. Use /new to create one."
+ ),
+ )
+ return
+
+ db = self.context.get_db()
+ async with db.get_db() as session:
+ result = await session.execute(
+ select(
+ func.count(case((col(ProviderStat.id).is_not(None), 1))).label(
+ "record_count",
+ ),
+ func.coalesce(func.sum(ProviderStat.token_input_other), 0).label(
+ "total_input_other",
+ ),
+ func.coalesce(func.sum(ProviderStat.token_input_cached), 0).label(
+ "total_input_cached",
+ ),
+ func.coalesce(func.sum(ProviderStat.token_output), 0).label(
+ "total_output",
+ ),
+ ).where(
+ col(ProviderStat.agent_type) == "internal",
+ col(ProviderStat.conversation_id) == cid,
+ )
+ )
+ stats = result.one()
+
+ if stats.record_count == 0:
+ message.set_result(
+ MessageEventResult().message(
+ "📊 No stats available for this conversation yet."
+ ),
+ )
+ return
+
+ total_input_other = stats.total_input_other
+ total_input_cached = stats.total_input_cached
+ total_output = stats.total_output
+ total_tokens = total_input_other + total_input_cached + total_output
+
+ ret = (
+ f"📊 Conversation Token usage (ID: {cid[:8]}...)\n"
+ f"Total: {total_tokens:,}\n"
+ f"Input (cached): {total_input_cached:,}\n"
+ f"Input (other): {total_input_other:,}\n"
+ f"Output: {total_output:,}\n"
+ )
+
+ message.set_result(MessageEventResult().message(ret))
diff --git a/astrbot/builtin_stars/builtin_commands/main.py b/astrbot/builtin_stars/builtin_commands/main.py
index f2e5e26d5..4a0e78f81 100644
--- a/astrbot/builtin_stars/builtin_commands/main.py
+++ b/astrbot/builtin_stars/builtin_commands/main.py
@@ -47,6 +47,11 @@ class Main(star.Star):
"""Create new conversation"""
await self.conversation_c.new_conv(message)
+ @filter.command("stats")
+ async def stats(self, message: AstrMessageEvent) -> None:
+ """Show token usage statistics for the current conversation"""
+ await self.conversation_c.stats(message)
+
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("provider")
async def provider(
diff --git a/dashboard/src/components/chat/ChatMessageList.vue b/dashboard/src/components/chat/ChatMessageList.vue
index 630539a5c..f19e5d637 100644
--- a/dashboard/src/components/chat/ChatMessageList.vue
+++ b/dashboard/src/components/chat/ChatMessageList.vue
@@ -293,6 +293,15 @@
/>
+
+ {{ tm("stats.cachedTokens") }}
+ {{
+ cachedInputTokens(messageContent(msg).agentStats)
+ }}
+
{{ tm("stats.inputTokens") }}
{{ inputTokens(messageContent(msg).agentStats) }}
@@ -850,13 +859,17 @@ function formatTime(value: string) {
function inputTokens(stats: any) {
const usage = stats?.token_usage || {};
- return (usage.input_other || 0) + (usage.input_cached || 0);
+ return usage.input_other || 0;
}
function outputTokens(stats: any) {
return stats?.token_usage?.output || 0;
}
+function cachedInputTokens(stats: any) {
+ return stats?.token_usage?.input_cached || 0;
+}
+
function agentDuration(stats: any) {
const directDuration = readPositiveNumber(stats, [
"duration",
diff --git a/dashboard/src/components/chat/MessageList.vue b/dashboard/src/components/chat/MessageList.vue
index dbafab20f..4f44bf166 100644
--- a/dashboard/src/components/chat/MessageList.vue
+++ b/dashboard/src/components/chat/MessageList.vue
@@ -185,6 +185,15 @@
/>
+
+ {{ tm("stats.cachedTokens") }}
+ {{
+ cachedInputTokens(messageContent(msg).agentStats)
+ }}
+
{{ tm("stats.inputTokens") }}
{{
@@ -512,13 +521,17 @@ function formatTime(value: string) {
function inputTokens(stats: any) {
const usage = stats?.token_usage || {};
- return (usage.input_other || 0) + (usage.input_cached || 0);
+ return usage.input_other || 0;
}
function outputTokens(stats: any) {
return stats?.token_usage?.output || 0;
}
+function cachedInputTokens(stats: any) {
+ return stats?.token_usage?.input_cached || 0;
+}
+
function agentDuration(stats: any) {
const directDuration = readPositiveNumber(stats, [
"duration",
diff --git a/dashboard/src/i18n/locales/en-US/features/chat.json b/dashboard/src/i18n/locales/en-US/features/chat.json
index d7d319a6b..a026532c5 100644
--- a/dashboard/src/i18n/locales/en-US/features/chat.json
+++ b/dashboard/src/i18n/locales/en-US/features/chat.json
@@ -137,9 +137,9 @@
},
"stats": {
"tokens": "Tokens",
- "inputTokens": "Input Tokens",
+ "inputTokens": "Input (other)",
"outputTokens": "Output Tokens",
- "cachedTokens": "Cached Tokens",
+ "cachedTokens": "Input (cached)",
"duration": "Duration",
"ttft": "Time to First Token"
},
diff --git a/dashboard/src/i18n/locales/ru-RU/features/chat.json b/dashboard/src/i18n/locales/ru-RU/features/chat.json
index e2a72a437..b033cedae 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/chat.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/chat.json
@@ -137,9 +137,9 @@
},
"stats": {
"tokens": "Токены",
- "inputTokens": "Входящие",
+ "inputTokens": "Входящие (прочие)",
"outputTokens": "Исходящие",
- "cachedTokens": "Кэшированные",
+ "cachedTokens": "Входящие (кэш)",
"duration": "Время",
"ttft": "Время до первого токена"
},
diff --git a/dashboard/src/i18n/locales/zh-CN/features/chat.json b/dashboard/src/i18n/locales/zh-CN/features/chat.json
index 67328f0ee..9ce2c6b81 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/chat.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/chat.json
@@ -137,9 +137,9 @@
},
"stats": {
"tokens": "Token",
- "inputTokens": "输入 Token",
+ "inputTokens": "输入(其他)",
"outputTokens": "输出 Token",
- "cachedTokens": "缓存 Token",
+ "cachedTokens": "输入(缓存)",
"duration": "耗时",
"ttft": "首字时间"
},
From 1efe4fd60e60d642d421dab3d6b53a6c0ad611f3 Mon Sep 17 00:00:00 2001
From: Weilong Liao <37870767+Soulter@users.noreply.github.com>
Date: Mon, 27 Apr 2026 12:59:44 +0800
Subject: [PATCH 22/41] fix(stats): TPM now only counts output tokens (#7827)
* fix(stats): TPM now only counts output tokens
- Add range_total_output_tokens accumulation, separate from total tokens
- Change range_avg_tpm formula to use output tokens only
- Update i18n labels to reflect Output TPM
* fix(stats): range
---
astrbot/dashboard/routes/stat.py | 5 ++++-
dashboard/src/i18n/locales/en-US/features/stats.json | 2 +-
dashboard/src/i18n/locales/ru-RU/features/stats.json | 2 +-
dashboard/src/i18n/locales/zh-CN/features/stats.json | 2 +-
4 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/astrbot/dashboard/routes/stat.py b/astrbot/dashboard/routes/stat.py
index 2eb3cd400..b02091d5d 100644
--- a/astrbot/dashboard/routes/stat.py
+++ b/astrbot/dashboard/routes/stat.py
@@ -243,6 +243,7 @@ class StatRoute(Route):
total_by_umo: dict[str, int] = defaultdict(int)
total_by_bucket: dict[int, int] = defaultdict(int)
range_total_tokens = 0
+ range_total_output_tokens = 0
range_total_calls = 0
range_success_calls = 0
range_ttft_total_ms = 0.0
@@ -286,6 +287,7 @@ class StatRoute(Route):
record.end_time - record.start_time
) * 1000
range_duration_samples += 1
+ range_total_output_tokens += record.token_output
if created_at_local >= today_start_local:
today_total_calls += 1
@@ -371,7 +373,8 @@ class StatRoute(Route):
else 0
),
"range_avg_tpm": (
- range_total_tokens / (range_duration_total_ms / 1000 / 60)
+ range_total_output_tokens
+ / (range_duration_total_ms / 1000 / 60)
if range_duration_total_ms > 0
else 0
),
diff --git a/dashboard/src/i18n/locales/en-US/features/stats.json b/dashboard/src/i18n/locales/en-US/features/stats.json
index b6349011e..18c2e2813 100644
--- a/dashboard/src/i18n/locales/en-US/features/stats.json
+++ b/dashboard/src/i18n/locales/en-US/features/stats.json
@@ -67,7 +67,7 @@
"callCount": "{count} calls",
"avgTtft": "Average TTFT",
"avgDuration": "Average Response Time",
- "avgTpm": "Average TPM",
+ "avgTpm": "Average Output TPM",
"successRate": "Success Rate"
},
"modelRanking": {
diff --git a/dashboard/src/i18n/locales/ru-RU/features/stats.json b/dashboard/src/i18n/locales/ru-RU/features/stats.json
index 45cc3ed6c..572ac3585 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/stats.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/stats.json
@@ -67,7 +67,7 @@
"callCount": "{count} вызовов",
"avgTtft": "Средний TTFT",
"avgDuration": "Среднее время ответа",
- "avgTpm": "Средний TPM",
+ "avgTpm": "Средний Output TPM",
"successRate": "Доля успешных вызовов"
},
"modelRanking": {
diff --git a/dashboard/src/i18n/locales/zh-CN/features/stats.json b/dashboard/src/i18n/locales/zh-CN/features/stats.json
index 64c8bb84a..6dc717511 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/stats.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/stats.json
@@ -67,7 +67,7 @@
"callCount": "共 {count} 次调用",
"avgTtft": "平均首字延迟(TTFT)",
"avgDuration": "平均响应时间",
- "avgTpm": "平均每分钟词元数(TPM)",
+ "avgTpm": "平均每分钟输出(TPM)",
"successRate": "调用成功率"
},
"modelRanking": {
From 09ab45fcb545a692c9b27877efc84731c246bfd1 Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Mon, 27 Apr 2026 13:05:20 +0800
Subject: [PATCH 23/41] chore: bump version to 4.23.6
---
astrbot/cli/__init__.py | 2 +-
astrbot/core/config/default.py | 2 +-
changelogs/v4.23.6.md | 56 ++++++++++++++++++++++++++++++++++
pyproject.toml | 2 +-
4 files changed, 59 insertions(+), 3 deletions(-)
create mode 100644 changelogs/v4.23.6.md
diff --git a/astrbot/cli/__init__.py b/astrbot/cli/__init__.py
index d996423cd..aebc801bf 100644
--- a/astrbot/cli/__init__.py
+++ b/astrbot/cli/__init__.py
@@ -1 +1 @@
-__version__ = "4.23.5"
+__version__ = "4.23.6"
diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index cd1c81a88..9b27d482e 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
-VERSION = "4.23.5"
+VERSION = "4.23.6"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {
diff --git a/changelogs/v4.23.6.md b/changelogs/v4.23.6.md
new file mode 100644
index 000000000..f02e7e7af
--- /dev/null
+++ b/changelogs/v4.23.6.md
@@ -0,0 +1,56 @@
+- [更新日志(简体中文)](#chinese)
+- [Changelog(English)](#english)
+
+
+
+## What's Changed
+
+### 新增
+
+- 新增 `/stats` 命令,可查看当前会话的 Token 使用统计,并按总量、输入(缓存)、输入(其他)与输出拆分展示。([#7831](https://github.com/AstrBotDevs/AstrBot/pull/7831))
+- 新增 Firecrawl Web 搜索与网页提取工具,支持搜索结果处理、网页内容提取、会话管理、请求校验与相关测试。([#7764](https://github.com/AstrBotDevs/AstrBot/pull/7764))
+- 微信客服文本消息新增 15 秒内去重,减少重复消息处理。([#7788](https://github.com/AstrBotDevs/AstrBot/pull/7788))
+
+### 优化
+
+- 优化 Provider 配置界面性能与响应式显示,改善相关组件的字体和布局体验。([#7772](https://github.com/AstrBotDevs/AstrBot/pull/7772))
+- 优化统计页 TPM 计算逻辑,TPM 现在仅统计输出 Token,并更新相关文案。([#7827](https://github.com/AstrBotDevs/AstrBot/pull/7827))
+- 优化 OpenAI 兼容 Provider 的空 assistant 消息过滤逻辑,流式与非流式路径统一处理空字符串和空列表内容,避免严格 Provider 拒绝历史消息。([#7758](https://github.com/AstrBotDevs/AstrBot/pull/7758))
+
+### 修复
+
+- 修复 DeepSeek v4 与 reasoning content 相关处理,支持空字符串 reasoning 内容,并在 assistant 消息中保留 reasoning 字段。([#7823](https://github.com/AstrBotDevs/AstrBot/pull/7823), [#7830](https://github.com/AstrBotDevs/AstrBot/pull/7830))
+- 修复 OpenRouter reasoning 字段属性名不正确的问题。([#7821](https://github.com/AstrBotDevs/AstrBot/pull/7821))
+- 修复超大图片未压缩可能导致后续处理异常的问题,并复用图片最大尺寸检查工具。([#7807](https://github.com/AstrBotDevs/AstrBot/pull/7807))
+- 修复 MiniMax TTS 默认输出 MP3 导致 QQ 官方平台语音转换出现 RIFF 错误的问题,默认输出格式改为 WAV。([#7797](https://github.com/AstrBotDevs/AstrBot/pull/7797))
+- 修复 Computer 沙盒图片下载未按图片发送的问题。([#7785](https://github.com/AstrBotDevs/AstrBot/pull/7785))
+- 修复 Windows 环境下部分 HTTPS 请求证书校验失败的问题,使用 certifi SSL context 提升兼容性。([#7778](https://github.com/AstrBotDevs/AstrBot/pull/7778))
+- 修复非安全上下文或部分对话框中复制功能不可用的问题,抽取共享剪贴板工具并增加 fallback。([#7747](https://github.com/AstrBotDevs/AstrBot/pull/7747))
+- 修复文件上传可能存在路径穿越的问题,并清理上传文件名中的 NUL 字节。([#7751](https://github.com/AstrBotDevs/AstrBot/pull/7751))
+
+
+
+## What's Changed (EN)
+
+### New Features
+
+- Added a `/stats` command to show token usage for the current conversation, including total tokens, input cached tokens, input other tokens, and output tokens. ([#7831](https://github.com/AstrBotDevs/AstrBot/pull/7831))
+- Added Firecrawl web search and web extract tools with result handling, content extraction, session management, payload validation, and tests. ([#7764](https://github.com/AstrBotDevs/AstrBot/pull/7764))
+- Added 15-second deduplication for WeChat kefu text messages to reduce duplicate message handling. ([#7788](https://github.com/AstrBotDevs/AstrBot/pull/7788))
+
+### Improvements
+
+- Improved the Provider configuration UI performance and responsive layout, including font and component styling updates. ([#7772](https://github.com/AstrBotDevs/AstrBot/pull/7772))
+- Updated stats-page TPM calculation so TPM only counts output tokens, with matching label updates. ([#7827](https://github.com/AstrBotDevs/AstrBot/pull/7827))
+- Improved empty assistant message filtering for OpenAI-compatible providers by sharing the logic across streaming and non-streaming paths and handling empty string or empty list content. ([#7758](https://github.com/AstrBotDevs/AstrBot/pull/7758))
+
+### Bug Fixes
+
+- Fixed DeepSeek v4 and reasoning content handling by supporting empty-string reasoning content and preserving the reasoning field in assistant messages. ([#7823](https://github.com/AstrBotDevs/AstrBot/pull/7823), [#7830](https://github.com/AstrBotDevs/AstrBot/pull/7830))
+- Fixed the reasoning field attribute used for OpenRouter. ([#7821](https://github.com/AstrBotDevs/AstrBot/pull/7821))
+- Fixed oversized image handling by downscaling large images and sharing the image max-size check helper. ([#7807](https://github.com/AstrBotDevs/AstrBot/pull/7807))
+- Fixed MiniMax TTS output for QQ Official voice conversion by changing the default output format from MP3 to WAV. ([#7797](https://github.com/AstrBotDevs/AstrBot/pull/7797))
+- Fixed Computer sandbox image downloads so they are sent as images. ([#7785](https://github.com/AstrBotDevs/AstrBot/pull/7785))
+- Fixed HTTPS certificate verification issues on Windows by using a certifi SSL context. ([#7778](https://github.com/AstrBotDevs/AstrBot/pull/7778))
+- Fixed copy actions in insecure contexts and dialogs by extracting a shared clipboard utility with fallback behavior. ([#7747](https://github.com/AstrBotDevs/AstrBot/pull/7747))
+- Fixed path traversal risks in file uploads and removed embedded NUL bytes from upload filenames. ([#7751](https://github.com/AstrBotDevs/AstrBot/pull/7751))
diff --git a/pyproject.toml b/pyproject.toml
index 2cdfbc44e..8ee8ed619 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
-version = "4.23.5"
+version = "4.23.6"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
license = { text = "AGPL-3.0-or-later" }
From 72f4e748e8b9b5d50f1751f36d18f285b0837224 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=8B=A5=E6=9C=88=E5=8D=83=E9=B8=AE?=
<40380042+camera-2018@users.noreply.github.com>
Date: Mon, 27 Apr 2026 15:38:43 +0800
Subject: [PATCH 24/41] fix: restore T2I text template rendering (#7789)
* fix: restore T2I text template rendering
- keep using {{ text | safe }} instead of text_base64
- inject Shiki runtime by default for T2I templates
- update built-in templates to read markdown from a hidden textarea
- improve WebUI preview sample text and Shiki runtime serving
- add regression tests for template rendering and runtime injection
* fix: prevent injected Shiki runtime from breaking T2I templates
* fix(t2i): restore raw text template rendering
* test(t2i): remove test
* fix(t2i): restore previewText
---
astrbot/core/utils/t2i/network_strategy.py | 67 ++++++++++++++--
.../t2i/template/astrbot_powershell.html | 18 +----
.../utils/t2i/template/astrbot_vitepress.html | 19 +----
astrbot/core/utils/t2i/template/base.html | 18 +----
.../components/shared/T2ITemplateEditor.vue | 77 +++++++++++++++++--
.../src/i18n/locales/en-US/core/shared.json | 2 +-
.../src/i18n/locales/ru-RU/core/shared.json | 4 +-
.../src/i18n/locales/zh-CN/core/shared.json | 2 +-
dashboard/vite.config.ts | 41 +++++++++-
9 files changed, 182 insertions(+), 66 deletions(-)
diff --git a/astrbot/core/utils/t2i/network_strategy.py b/astrbot/core/utils/t2i/network_strategy.py
index 5f9385614..4fec12195 100644
--- a/astrbot/core/utils/t2i/network_strategy.py
+++ b/astrbot/core/utils/t2i/network_strategy.py
@@ -1,7 +1,7 @@
import asyncio
-import base64
import logging
import random
+import re
from functools import lru_cache
from pathlib import Path
@@ -15,6 +15,11 @@ from astrbot.core.utils.t2i.template_manager import TemplateManager
from . import RenderStrategy
ASTRBOT_T2I_DEFAULT_ENDPOINT = "https://t2i.soulter.top/text2img"
+SHIKI_RUNTIME_SCRIPT_ID = "astrbot-t2i-shiki-runtime"
+SHIKI_RUNTIME_TEMPLATE_PATTERN = re.compile(r"\{\{\s*shiki_runtime\s*\|\s*safe\s*\}\}")
+JINJA_SYNTAX_PATTERN = re.compile(r"\{[{%#]")
+JINJA_RAW_OPEN_PATTERN = re.compile(r"{%-?\s*raw\s*-?%}")
+JINJA_RAW_CLOSE_PATTERN = re.compile(r"{%-?\s*endraw\s*-?%}")
logger = logging.getLogger("astrbot")
@@ -41,7 +46,49 @@ def get_shiki_runtime() -> str:
)
return ""
- return runtime.replace(" bool:
+ raw_open_index = -1
+ for match in JINJA_RAW_OPEN_PATTERN.finditer(tmpl_str, 0, index):
+ raw_open_index = match.start()
+
+ raw_close_index = -1
+ for match in JINJA_RAW_CLOSE_PATTERN.finditer(tmpl_str, 0, index):
+ raw_close_index = match.start()
+
+ return raw_open_index > raw_close_index
+
+
+def _wrap_runtime_for_jinja(tmpl_str: str, script: str, index: int) -> str:
+ if not JINJA_SYNTAX_PATTERN.search(script) or _is_inside_jinja_raw_block(
+ tmpl_str,
+ index,
+ ):
+ return script
+
+ return f"{{% raw %}}{script}{{% endraw %}}"
+
+
+def inject_shiki_runtime(tmpl_str: str) -> str:
+ if SHIKI_RUNTIME_SCRIPT_ID in tmpl_str or SHIKI_RUNTIME_TEMPLATE_PATTERN.search(
+ tmpl_str,
+ ):
+ return tmpl_str
+
+ runtime = get_shiki_runtime()
+ if not runtime:
+ return tmpl_str
+
+ script = f''
+ head_close = re.search(r"", tmpl_str, flags=re.IGNORECASE)
+ if head_close:
+ script = _wrap_runtime_for_jinja(tmpl_str, script, head_close.start())
+ return f"{tmpl_str[: head_close.start()]} {script}\n{tmpl_str[head_close.start() :]}"
+
+ script = _wrap_runtime_for_jinja(tmpl_str, script, 0)
+ return f"{script}\n{tmpl_str}"
class NetworkRenderStrategy(RenderStrategy):
@@ -101,11 +148,17 @@ class NetworkRenderStrategy(RenderStrategy):
options: dict | None = None,
) -> str:
"""使用自定义文转图模板"""
- default_options = {"full_page": True, "type": "jpeg", "quality": 40}
+ default_options = {
+ "full_page": True,
+ "type": "jpeg",
+ "quality": 40,
+ }
if options:
default_options |= options
- tmpl_data = {"shiki_runtime": get_shiki_runtime()} | tmpl_data
+ if SHIKI_RUNTIME_TEMPLATE_PATTERN.search(tmpl_str):
+ tmpl_data = {"shiki_runtime": get_shiki_runtime()} | tmpl_data
+ tmpl_str = inject_shiki_runtime(tmpl_str)
post_data = {
"tmpl": tmpl_str,
"json": return_url,
@@ -158,9 +211,11 @@ class NetworkRenderStrategy(RenderStrategy):
if not template_name:
template_name = "base"
tmpl_str = await self.get_template(name=template_name)
- text_base64 = base64.b64encode(text.encode("utf-8")).decode("ascii")
return await self.render_custom_template(
tmpl_str,
- {"text_base64": text_base64, "version": f"v{VERSION}"},
+ {
+ "text": text,
+ "version": f"v{VERSION}",
+ },
return_url,
)
diff --git a/astrbot/core/utils/t2i/template/astrbot_powershell.html b/astrbot/core/utils/t2i/template/astrbot_powershell.html
index 746d06fe8..3bfa014c0 100644
--- a/astrbot/core/utils/t2i/template/astrbot_powershell.html
+++ b/astrbot/core/utils/t2i/template/astrbot_powershell.html
@@ -174,14 +174,14 @@
-
+