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 @@ +
+
+
+
{{ t('commandRanking.title') }}
+
{{ t('commandRanking.subtitle', { range: rangeLabel }) }}
+
+
+ {{ t('commandRanking.total', { count: formatNumber(commandStats.length) }) }} +
+
+
+
+ + {{ command.command_name }} + {{ command.plugin_name }} + + {{ formatNumber(command.count) }} {{ t('commandRanking.times') }} +
+
+
{{ t('empty.commandStats') }}
+
+
{{ t('modelCalls.title') }}
@@ -275,6 +301,12 @@ interface ProviderTokenStatsResponse { today_by_provider: ProviderRankingItem[] } +interface CommandRankingItem { + command_name: string + plugin_name: string + count: number +} + const { locale } = useI18n() const { tm: t } = useModuleI18n('features/stats') const theme = useTheme() @@ -282,6 +314,7 @@ const loading = ref(true) const errorMessage = ref('') const baseStats = ref(null) const providerStats = ref(null) +const commandStats = ref([]) const selectedRange = ref(1) const lastUpdatedAt = ref(null) const isDark = computed(() => theme.global.current.value.dark) @@ -408,10 +441,20 @@ async function fetchProviderStats(): Promise { providerStats.value = response.data.data } +async function fetchCommandStats(): Promise { + const response = await axios.get('/api/stat/top-commands', { + params: { + offset_sec: selectedRange.value * 24 * 60 * 60, + limit: 100 + } + }) + commandStats.value = response.data.data?.commands ?? [] +} + async function refreshStats(): Promise { try { errorMessage.value = '' - await Promise.all([fetchBaseStats(), fetchProviderStats()]) + await Promise.all([fetchBaseStats(), fetchProviderStats(), fetchCommandStats()]) lastUpdatedAt.value = new Date() } catch (error) { console.error('Failed to load stats page data:', error) @@ -663,7 +706,7 @@ const providerChartOptions = computed(() => ({ watch(selectedRange, async () => { try { - await Promise.all([fetchBaseStats(), fetchProviderStats()]) + await Promise.all([fetchBaseStats(), fetchProviderStats(), fetchCommandStats()]) lastUpdatedAt.value = new Date() } catch (error) { console.error('Failed to refresh stats range:', error) @@ -1085,6 +1128,37 @@ onBeforeUnmount(() => { padding-right: 6px; } +.provider-list--grid { + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + column-gap: 28px; + gap: 0 28px; +} + +.provider-list--grid .provider-row { + padding: 10px 0; +} + +.provider-list--grid .provider-name { + flex: 1; + min-width: 0; +} + +.section-badge { + flex-shrink: 0; + align-self: center; + padding: 2px 12px; + border-radius: 12px; + font-size: 13px; + color: var(--stats-text-secondary, #888); + background: var(--stats-chip-bg, rgba(128, 128, 128, 0.12)); + white-space: nowrap; +} + +.command-count { + flex-shrink: 0; + white-space: nowrap; +} + .provider-row { padding: 12px 0; border-bottom: 1px solid var(--stats-border); @@ -1102,6 +1176,15 @@ onBeforeUnmount(() => { white-space: nowrap; } +.command-plugin { + margin-left: 8px; + padding: 1px 8px; + border-radius: 10px; + font-size: 12px; + color: var(--stats-text-secondary, #888); + background: var(--stats-chip-bg, rgba(128, 128, 128, 0.12)); +} + .token-total-card .card-label, .token-total-card .card-note, .token-side-column .section-subtitle {