Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions astrbot/core/db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions astrbot/core/db/po.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
59 changes: 59 additions & 0 deletions astrbot/core/db/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
ChatUIProject,
CommandConfig,
CommandConflict,
CommandStat,
ConversationV2,
CronJob,
Persona,
Expand Down Expand Up @@ -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
Comment thread
KBVsent marked this conversation as resolved.
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:
Expand Down
30 changes: 29 additions & 1 deletion astrbot/core/pipeline/process_stage/method/star_request.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
"""本地 Agent 模式的 AstrBot 插件调用 Stage"""

import asyncio
Comment thread
KBVsent marked this conversation as resolved.
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

from ...context import PipelineContext, call_event_hook, call_handler
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"]
Expand Down Expand Up @@ -44,6 +58,20 @@ async def process(
)
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
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 ""),
)
Comment thread
KBVsent marked this conversation as resolved.

try:
wrapper = call_handler(event, handler.handler, **params)
async for ret in wrapper:
Expand Down
20 changes: 20 additions & 0 deletions astrbot/dashboard/routes/stat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Comment thread
KBVsent marked this conversation as resolved.
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:
Expand Down
10 changes: 9 additions & 1 deletion dashboard/src/i18n/locales/en-US/features/stats.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion dashboard/src/i18n/locales/ru-RU/features/stats.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@
"title": "Рейтинг платформ по сообщениям",
"subtitle": "{range}, сортировка по общему числу сообщений"
},
"commandRanking": {
"title": "Рейтинг использования команд",
"subtitle": "{range}, сортировка по числу вызовов",
"plugin": "Плагин",
"times": "раз",
"total": "всего {count}"
},
"modelCalls": {
"title": "Вызовы моделей",
"subtitle": "Статистика вызовов моделей"
Expand Down Expand Up @@ -81,7 +88,8 @@
"empty": {
"platformStats": "Статистика сообщений по платформам отсутствует.",
"modelCalls": "Нет данных по вызовам моделей за период {range}.",
"sessionCalls": "Нет данных по сессиям за период {range}."
"sessionCalls": "Нет данных по сессиям за период {range}.",
"commandStats": "Статистика вызовов команд отсутствует."
},
"chart": {
"messages": "Сообщения",
Expand Down
10 changes: 9 additions & 1 deletion dashboard/src/i18n/locales/zh-CN/features/stats.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@
"title": "平台消息排名",
"subtitle": "{range}按聚合消息总量排序"
},
"commandRanking": {
"title": "指令使用排名",
"subtitle": "{range}按指令触发次数排序",
"plugin": "插件",
"times": "次",
"total": "共 {count} 条"
},
"modelCalls": {
"title": "模型调用",
"subtitle": "模型调用数据统计"
Expand Down Expand Up @@ -81,7 +88,8 @@
"empty": {
"platformStats": "当前没有平台消息统计数据。",
"modelCalls": "{range}还没有模型调用数据。",
"sessionCalls": "{range}还没有会话调用数据。"
"sessionCalls": "{range}还没有会话调用数据。",
"commandStats": "当前没有指令触发统计数据。"
},
"chart": {
"messages": "消息",
Expand Down
Loading
Loading