diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py
index 46795a537e..31a5513f6b 100644
--- a/astrbot/core/db/__init__.py
+++ b/astrbot/core/db/__init__.py
@@ -138,6 +138,31 @@ async def get_platform_stats(self, offset_sec: int = 86400) -> list[PlatformStat
"""Get platform statistics within the specified offset in seconds and group by platform_id."""
...
+ @abc.abstractmethod
+ async def insert_command_stats(
+ self,
+ command_name: str,
+ plugin_name: str = "",
+ count: int = 1,
+ timestamp: datetime.datetime | None = None,
+ ) -> None:
+ """Insert (or increment) a command trigger statistic record."""
+ ...
+
+ @abc.abstractmethod
+ async def get_top_commands(
+ self,
+ offset_sec: int = 86400,
+ limit: int = 20,
+ ) -> list[tuple[str, str, int]]:
+ """Get the most frequently triggered commands within the offset.
+
+ Each (plugin_name, command_name) pair is counted separately. Returns a
+ list of (command_name, plugin_name, total_count) ordered by total_count
+ descending.
+ """
+ ...
+
@abc.abstractmethod
async def insert_provider_stat(
self,
diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py
index 0d3b9822a3..8b3f0c7654 100644
--- a/astrbot/core/db/po.py
+++ b/astrbot/core/db/po.py
@@ -38,6 +38,33 @@ class PlatformStat(SQLModel, table=True):
)
+class CommandStat(SQLModel, table=True):
+ """This class represents the trigger statistics of plugin (Star) commands.
+
+ Counts are aggregated per hour, per owning plugin and per command name.
+ Aliases are merged into the canonical command name. The same command name
+ registered by different plugins is counted separately (the unique key is
+ timestamp + plugin_name + command_name), so plugin attribution is accurate.
+ """
+
+ __tablename__: str = "command_stats"
+
+ id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True})
+ timestamp: datetime = Field(nullable=False)
+ plugin_name: str = Field(default="", nullable=False) # owning plugin
+ command_name: str = Field(nullable=False)
+ count: int = Field(default=0, nullable=False)
+
+ __table_args__ = (
+ UniqueConstraint(
+ "timestamp",
+ "plugin_name",
+ "command_name",
+ name="uix_command_stats",
+ ),
+ )
+
+
class ProviderStat(TimestampMixin, SQLModel, table=True):
"""Per-response provider stats for internal agent runs."""
diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py
index d79ac9d703..b9b6fa9831 100644
--- a/astrbot/core/db/sqlite.py
+++ b/astrbot/core/db/sqlite.py
@@ -15,6 +15,7 @@
ChatUIProject,
CommandConfig,
CommandConflict,
+ CommandStat,
ConversationV2,
CronJob,
Persona,
@@ -163,6 +164,64 @@ async def insert_platform_stats(
},
)
+ async def insert_command_stats(
+ self,
+ command_name,
+ plugin_name="",
+ count=1,
+ timestamp=None,
+ ) -> None:
+ """Insert (or increment) a command trigger statistic record."""
+ async with self.get_db() as session:
+ session: AsyncSession
+ async with session.begin():
+ if timestamp is None:
+ timestamp = datetime.now().replace(
+ minute=0,
+ second=0,
+ microsecond=0,
+ )
+ await session.execute(
+ text("""
+ INSERT INTO command_stats (timestamp, plugin_name, command_name, count)
+ VALUES (:timestamp, :plugin_name, :command_name, :count)
+ ON CONFLICT(timestamp, plugin_name, command_name) DO UPDATE SET
+ count = command_stats.count + EXCLUDED.count
+ """),
+ {
+ "timestamp": timestamp,
+ "plugin_name": plugin_name,
+ "command_name": command_name,
+ "count": count,
+ },
+ )
+
+ async def get_top_commands(
+ self,
+ offset_sec=86400,
+ limit=20,
+ ) -> list[tuple[str, str, int]]:
+ """Get the most frequently triggered commands within the offset."""
+ async with self.get_db() as session:
+ session: AsyncSession
+ start_time = datetime.now() - timedelta(seconds=offset_sec)
+ total = func.sum(CommandStat.count).label("total")
+ result = await session.execute(
+ select(
+ CommandStat.command_name,
+ CommandStat.plugin_name,
+ total,
+ )
+ .where(CommandStat.timestamp >= start_time)
+ .group_by(CommandStat.plugin_name, CommandStat.command_name)
+ .order_by(desc(total))
+ .limit(limit),
+ )
+ return [
+ (command_name, plugin_name or "", int(count))
+ for command_name, plugin_name, count in result.all()
+ ]
+
async def count_platform_stats(self) -> int:
"""Count the number of platform statistics records."""
async with self.get_db() as session:
diff --git a/astrbot/core/pipeline/process_stage/method/star_request.py b/astrbot/core/pipeline/process_stage/method/star_request.py
index 3adcddc077..4627592485 100644
--- a/astrbot/core/pipeline/process_stage/method/star_request.py
+++ b/astrbot/core/pipeline/process_stage/method/star_request.py
@@ -1,12 +1,15 @@
"""本地 Agent 模式的 AstrBot 插件调用 Stage"""
+import asyncio
import traceback
from collections.abc import AsyncGenerator
from typing import Any
-from astrbot.core import logger
+from astrbot.core import db_helper, logger
from astrbot.core.message.message_event_result import MessageEventResult
from astrbot.core.platform.astr_message_event import AstrMessageEvent
+from astrbot.core.star.filter.command import CommandFilter
+from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.star import star_map
from astrbot.core.star.star_handler import EventType, StarHandlerMetadata
@@ -14,6 +17,17 @@
from ..stage import Stage
+async def _record_command_stat(command_name: str, plugin_name: str) -> None:
+ """异步记录指令触发统计,吞掉并记录异常,避免未捕获的后台任务错误。"""
+ try:
+ await db_helper.insert_command_stats(
+ command_name=command_name,
+ plugin_name=plugin_name,
+ )
+ except Exception as e:
+ logger.error(f"记录指令触发统计失败: {e}")
+
+
class StarRequestSubStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.prompt_prefix = ctx.astrbot_config["provider_settings"]["prompt_prefix"]
@@ -44,6 +58,20 @@ async def process(
)
continue
logger.debug(f"plugin -> {md.name} - {handler.handler_name}")
+
+ # 统计指令触发次数(仅统计带指令过滤器的 handler)
+ command_name = None
+ for f in handler.event_filters:
+ if isinstance(f, (CommandFilter, CommandGroupFilter)):
+ complete_names = f.get_complete_command_names()
+ if complete_names:
+ command_name = complete_names[0]
+ break
+ if command_name:
+ asyncio.create_task(
+ _record_command_stat(command_name, md.name or ""),
+ )
+
try:
wrapper = call_handler(event, handler.handler, **params)
async for ret in wrapper:
diff --git a/astrbot/dashboard/routes/stat.py b/astrbot/dashboard/routes/stat.py
index 060e4c4e27..bcabfdc303 100644
--- a/astrbot/dashboard/routes/stat.py
+++ b/astrbot/dashboard/routes/stat.py
@@ -47,6 +47,7 @@ def __init__(
super().__init__(context)
self.routes = {
"/stat/get": ("GET", self.get_stat),
+ "/stat/top-commands": ("GET", self.get_top_commands),
"/stat/provider-tokens": ("GET", self.get_provider_token_stats),
"/stat/version": ("GET", self.get_version),
"/stat/start-time": ("GET", self.get_start_time),
@@ -224,6 +225,25 @@ async def get_stat(self):
logger.error(traceback.format_exc())
return Response().error(e.__str__()).__dict__
+ async def get_top_commands(self):
+ """获取最常被触发的指令排行。"""
+ offset_sec = int(request.args.get("offset_sec", 86400))
+ limit = int(request.args.get("limit", 20))
+ try:
+ rows = await self.db_helper.get_top_commands(offset_sec, limit)
+ commands = [
+ {
+ "command_name": command_name,
+ "plugin_name": plugin_name,
+ "count": count,
+ }
+ for command_name, plugin_name, count in rows
+ ]
+ return Response().ok({"commands": commands}).__dict__
+ except Exception as e:
+ logger.error(traceback.format_exc())
+ return Response().error(e.__str__()).__dict__
+
@staticmethod
def _ensure_aware_utc(value: datetime) -> datetime:
if value.tzinfo is None:
diff --git a/dashboard/src/i18n/locales/en-US/features/stats.json b/dashboard/src/i18n/locales/en-US/features/stats.json
index 18c2e28132..5a54111370 100644
--- a/dashboard/src/i18n/locales/en-US/features/stats.json
+++ b/dashboard/src/i18n/locales/en-US/features/stats.json
@@ -54,6 +54,13 @@
"title": "Platform Message Ranking",
"subtitle": "{range}, sorted by aggregated message volume"
},
+ "commandRanking": {
+ "title": "Command Usage Ranking",
+ "subtitle": "{range}, sorted by trigger count",
+ "plugin": "Plugin",
+ "times": "times",
+ "total": "{count} total"
+ },
"modelCalls": {
"title": "Model Calls",
"subtitle": "Model call statistics"
@@ -81,7 +88,8 @@
"empty": {
"platformStats": "No platform message statistics available.",
"modelCalls": "No model call data for {range}.",
- "sessionCalls": "No session call data for {range}."
+ "sessionCalls": "No session call data for {range}.",
+ "commandStats": "No command trigger statistics available."
},
"chart": {
"messages": "Messages",
diff --git a/dashboard/src/i18n/locales/ru-RU/features/stats.json b/dashboard/src/i18n/locales/ru-RU/features/stats.json
index 572ac35859..9043d68f0d 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/stats.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/stats.json
@@ -54,6 +54,13 @@
"title": "Рейтинг платформ по сообщениям",
"subtitle": "{range}, сортировка по общему числу сообщений"
},
+ "commandRanking": {
+ "title": "Рейтинг использования команд",
+ "subtitle": "{range}, сортировка по числу вызовов",
+ "plugin": "Плагин",
+ "times": "раз",
+ "total": "всего {count}"
+ },
"modelCalls": {
"title": "Вызовы моделей",
"subtitle": "Статистика вызовов моделей"
@@ -81,7 +88,8 @@
"empty": {
"platformStats": "Статистика сообщений по платформам отсутствует.",
"modelCalls": "Нет данных по вызовам моделей за период {range}.",
- "sessionCalls": "Нет данных по сессиям за период {range}."
+ "sessionCalls": "Нет данных по сессиям за период {range}.",
+ "commandStats": "Статистика вызовов команд отсутствует."
},
"chart": {
"messages": "Сообщения",
diff --git a/dashboard/src/i18n/locales/zh-CN/features/stats.json b/dashboard/src/i18n/locales/zh-CN/features/stats.json
index 6dc717511b..f888117c07 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/stats.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/stats.json
@@ -54,6 +54,13 @@
"title": "平台消息排名",
"subtitle": "{range}按聚合消息总量排序"
},
+ "commandRanking": {
+ "title": "指令使用排名",
+ "subtitle": "{range}按指令触发次数排序",
+ "plugin": "插件",
+ "times": "次",
+ "total": "共 {count} 条"
+ },
"modelCalls": {
"title": "模型调用",
"subtitle": "模型调用数据统计"
@@ -81,7 +88,8 @@
"empty": {
"platformStats": "当前没有平台消息统计数据。",
"modelCalls": "{range}还没有模型调用数据。",
- "sessionCalls": "{range}还没有会话调用数据。"
+ "sessionCalls": "{range}还没有会话调用数据。",
+ "commandStats": "当前没有指令触发统计数据。"
},
"chart": {
"messages": "消息",
diff --git a/dashboard/src/views/stats/StatsPage.vue b/dashboard/src/views/stats/StatsPage.vue
index 251971baf2..52068dc626 100644
--- a/dashboard/src/views/stats/StatsPage.vue
+++ b/dashboard/src/views/stats/StatsPage.vue
@@ -105,6 +105,32 @@
+