diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index a18c127ebf..087aa625bd 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -21,6 +21,7 @@ PlatformSession, PlatformStat, Preference, + ProviderStat, SessionProjectRelation, Stats, ) @@ -105,6 +106,21 @@ 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_provider_stat( + self, + *, + umo: str, + provider_id: str, + provider_model: str | None = None, + conversation_id: str | None = None, + status: str = "completed", + stats: dict | None = None, + agent_type: str = "internal", + ) -> ProviderStat: + """Insert a per-response provider stat record.""" + ... + @abc.abstractmethod async def get_conversations( self, diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index 451f054f62..cabc3432cd 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -38,6 +38,30 @@ class PlatformStat(SQLModel, table=True): ) +class ProviderStat(TimestampMixin, SQLModel, table=True): + """Per-response provider stats for internal agent runs.""" + + __tablename__: str = "provider_stats" + + id: int | None = Field( + default=None, + primary_key=True, + sa_column_kwargs={"autoincrement": True}, + ) + agent_type: str = Field(default="internal", nullable=False, index=True) + status: str = Field(default="completed", nullable=False, index=True) + umo: str = Field(nullable=False, index=True) + conversation_id: str | None = Field(default=None, index=True) + provider_id: str = Field(nullable=False, index=True) + provider_model: str | None = Field(default=None, index=True) + token_input_other: int = Field(default=0, nullable=False) + token_input_cached: int = Field(default=0, nullable=False) + token_output: int = Field(default=0, nullable=False) + start_time: float = Field(default=0.0, nullable=False) + end_time: float = Field(default=0.0, nullable=False) + time_to_first_token: float = Field(default=0.0, nullable=False) + + class ConversationV2(TimestampMixin, SQLModel, table=True): __tablename__: str = "conversations" diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index c8e50909d5..fd6668c0c7 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -23,6 +23,7 @@ PlatformSession, PlatformStat, Preference, + ProviderStat, SessionProjectRelation, SQLModel, ) @@ -169,6 +170,51 @@ async def get_platform_stats(self, offset_sec: int = 86400) -> list[PlatformStat ) return list(result.scalars().all()) + async def insert_provider_stat( + self, + *, + umo: str, + provider_id: str, + provider_model: str | None = None, + conversation_id: str | None = None, + status: str = "completed", + stats: dict | None = None, + agent_type: str = "internal", + ) -> ProviderStat: + """Insert a provider stat record for a single agent response.""" + stats = stats or {} + token_usage = stats.get("token_usage", {}) + + token_input_other = int(token_usage.get("input_other", 0) or 0) + token_input_cached = int(token_usage.get("input_cached", 0) or 0) + token_output = int(token_usage.get("output", 0) or 0) + + start_time = float(stats.get("start_time", 0.0) or 0.0) + end_time = float(stats.get("end_time", 0.0) or 0.0) + time_to_first_token = float(stats.get("time_to_first_token", 0.0) or 0.0) + + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + record = ProviderStat( + agent_type=agent_type, + status=status, + umo=umo, + conversation_id=conversation_id, + provider_id=provider_id, + provider_model=provider_model, + token_input_other=token_input_other, + token_input_cached=token_input_cached, + token_output=token_output, + start_time=start_time, + end_time=end_time, + time_to_first_token=time_to_first_token, + ) + session.add(record) + await session.flush() + await session.refresh(record) + return record + # ==== # Conversation Management # ==== diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index c7441d09f4..1a04e3a48e 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -5,7 +5,7 @@ from collections.abc import AsyncGenerator from dataclasses import replace -from astrbot.core import logger +from astrbot.core import db_helper, logger from astrbot.core.agent.message import Message from astrbot.core.agent.response import AgentStats from astrbot.core.astr_main_agent import ( @@ -350,6 +350,15 @@ async def process( resp=final_resp.completion_text if final_resp else None, ) + asyncio.create_task( + _record_internal_agent_stats( + event, + req, + agent_runner, + final_resp, + ) + ) + # 检查事件是否被停止,如果被停止则不保存历史记录 if not event.is_stopped() or agent_runner.was_aborted(): await self._save_to_history( @@ -462,3 +471,46 @@ async def _save_to_history( # these hosts are base64 encoded BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"} decoded_blocked = [base64.b64decode(b).decode("utf-8") for b in BLOCKED] + + +async def _record_internal_agent_stats( + event: AstrMessageEvent, + req: ProviderRequest | None, + agent_runner: AgentRunner | None, + final_resp: LLMResponse | None, +) -> None: + """Persist internal agent stats without affecting the user response flow.""" + if agent_runner is None: + return + + provider = agent_runner.provider + stats = agent_runner.stats + if provider is None or stats is None: + return + + try: + provider_config = getattr(provider, "provider_config", {}) or {} + conversation_id = ( + req.conversation.cid + if req is not None and req.conversation is not None + else None + ) + + if agent_runner.was_aborted(): + status = "aborted" + elif final_resp is not None and final_resp.role == "err": + status = "error" + else: + status = "completed" + + await db_helper.insert_provider_stat( + umo=event.unified_msg_origin, + conversation_id=conversation_id, + provider_id=provider_config.get("id", "") or provider.meta().id, + provider_model=provider.get_model(), + status=status, + stats=stats.to_dict(), + agent_type="internal", + ) + except Exception as e: + logger.warning("Persist provider stats failed: %s", e, exc_info=True) diff --git a/astrbot/dashboard/routes/stat.py b/astrbot/dashboard/routes/stat.py index a6f7ff7f2d..2eb3cd400e 100644 --- a/astrbot/dashboard/routes/stat.py +++ b/astrbot/dashboard/routes/stat.py @@ -4,18 +4,22 @@ import threading import time import traceback +from collections import defaultdict +from datetime import datetime, timedelta, timezone from functools import cmp_to_key from pathlib import Path import aiohttp import psutil from quart import request +from sqlmodel import select from astrbot.core import DEMO_MODE, logger from astrbot.core.config import VERSION from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db import BaseDatabase from astrbot.core.db.migration.helper import check_migration_needed_v4 +from astrbot.core.db.po import ProviderStat from astrbot.core.utils.astrbot_path import get_astrbot_path from astrbot.core.utils.io import get_dashboard_version from astrbot.core.utils.storage_cleaner import StorageCleaner @@ -34,6 +38,7 @@ def __init__( super().__init__(context) self.routes = { "/stat/get": ("GET", self.get_stat), + "/stat/provider-tokens": ("GET", self.get_provider_token_stats), "/stat/version": ("GET", self.get_version), "/stat/start-time": ("GET", self.get_start_time), "/stat/restart-core": ("POST", self.restart_core), @@ -188,6 +193,207 @@ async def get_stat(self): logger.error(traceback.format_exc()) return Response().error(e.__str__()).__dict__ + @staticmethod + def _ensure_aware_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + async def get_provider_token_stats(self): + try: + try: + days = int(request.args.get("days", 1)) + except (TypeError, ValueError): + days = 1 + if days not in (1, 3, 7): + days = 1 + + local_tz = datetime.now().astimezone().tzinfo or timezone.utc + now_local = datetime.now(local_tz) + range_start_local = (now_local - timedelta(days=days)).replace( + minute=0, second=0, microsecond=0 + ) + today_start_local = now_local.replace( + hour=0, minute=0, second=0, microsecond=0 + ) + query_start_local = min(range_start_local, today_start_local) + query_start_utc = query_start_local.astimezone(timezone.utc) + + async with self.db_helper.get_db() as session: + result = await session.execute( + select(ProviderStat) + .where( + ProviderStat.agent_type == "internal", + ProviderStat.created_at >= query_start_utc, + ) + .order_by(ProviderStat.created_at.asc()) + ) + records = result.scalars().all() + + bucket_timestamps: list[int] = [] + bucket_cursor = range_start_local + while bucket_cursor <= now_local: + bucket_timestamps.append(int(bucket_cursor.timestamp() * 1000)) + bucket_cursor += timedelta(hours=1) + + trend_by_provider: dict[str, dict[int, int]] = defaultdict( + lambda: defaultdict(int) + ) + total_by_provider: dict[str, int] = defaultdict(int) + total_by_umo: dict[str, int] = defaultdict(int) + total_by_bucket: dict[int, int] = defaultdict(int) + range_total_tokens = 0 + range_total_calls = 0 + range_success_calls = 0 + range_ttft_total_ms = 0.0 + range_ttft_samples = 0 + range_duration_total_ms = 0.0 + range_duration_samples = 0 + today_by_model: dict[str, int] = defaultdict(int) + today_by_provider: dict[str, int] = defaultdict(int) + today_total_tokens = 0 + today_total_calls = 0 + + for record in records: + created_at_utc = self._ensure_aware_utc(record.created_at) + created_at_local = created_at_utc.astimezone(local_tz) + token_total = ( + record.token_input_other + + record.token_input_cached + + record.token_output + ) + provider_id = record.provider_id or "unknown" + provider_model = record.provider_model or "Unknown" + + if created_at_local >= range_start_local: + bucket_local = created_at_local.replace( + minute=0, second=0, microsecond=0 + ) + bucket_ts = int(bucket_local.timestamp() * 1000) + trend_by_provider[provider_id][bucket_ts] += token_total + total_by_provider[provider_id] += token_total + total_by_umo[record.umo or "unknown"] += token_total + total_by_bucket[bucket_ts] += token_total + range_total_tokens += token_total + range_total_calls += 1 + if record.status != "error": + range_success_calls += 1 + if record.time_to_first_token > 0: + range_ttft_total_ms += record.time_to_first_token * 1000 + range_ttft_samples += 1 + if record.end_time > record.start_time: + range_duration_total_ms += ( + record.end_time - record.start_time + ) * 1000 + range_duration_samples += 1 + + if created_at_local >= today_start_local: + today_total_calls += 1 + today_total_tokens += token_total + today_by_model[provider_model] += token_total + today_by_provider[provider_id] += token_total + + sorted_provider_ids = sorted( + total_by_provider.keys(), + key=lambda item: total_by_provider[item], + reverse=True, + ) + + series = [ + { + "name": provider_id, + "data": [ + [bucket_ts, trend_by_provider[provider_id].get(bucket_ts, 0)] + for bucket_ts in bucket_timestamps + ], + "total_tokens": total_by_provider[provider_id], + } + for provider_id in sorted_provider_ids + ] + + total_series = [ + [bucket_ts, total_by_bucket.get(bucket_ts, 0)] + for bucket_ts in bucket_timestamps + ] + + today_by_model_data = [ + {"provider_model": model_name, "tokens": tokens} + for model_name, tokens in sorted( + today_by_model.items(), + key=lambda item: item[1], + reverse=True, + ) + ] + today_by_provider_data = [ + {"provider_id": provider_id, "tokens": tokens} + for provider_id, tokens in sorted( + today_by_provider.items(), + key=lambda item: item[1], + reverse=True, + ) + ] + range_by_provider_data = [ + {"provider_id": provider_id, "tokens": tokens} + for provider_id, tokens in sorted( + total_by_provider.items(), + key=lambda item: item[1], + reverse=True, + ) + ] + range_by_umo_data = [ + {"umo": umo, "tokens": tokens} + for umo, tokens in sorted( + total_by_umo.items(), + key=lambda item: item[1], + reverse=True, + ) + ] + + return ( + Response() + .ok( + { + "days": days, + "trend": { + "series": series, + "total_series": total_series, + }, + "range_total_tokens": range_total_tokens, + "range_total_calls": range_total_calls, + "range_avg_ttft_ms": ( + range_ttft_total_ms / range_ttft_samples + if range_ttft_samples + else 0 + ), + "range_avg_duration_ms": ( + range_duration_total_ms / range_duration_samples + if range_duration_samples + else 0 + ), + "range_avg_tpm": ( + range_total_tokens / (range_duration_total_ms / 1000 / 60) + if range_duration_total_ms > 0 + else 0 + ), + "range_success_rate": ( + range_success_calls / range_total_calls + if range_total_calls + else 0 + ), + "range_by_provider": range_by_provider_data, + "range_by_umo": range_by_umo_data, + "today_total_tokens": today_total_tokens, + "today_total_calls": today_total_calls, + "today_by_model": today_by_model_data, + "today_by_provider": today_by_provider_data, + } + ) + .__dict__ + ) + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"Error: {e!s}").__dict__ + async def test_ghproxy_connection(self): """测试 GitHub 代理连接是否可用。""" try: diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css index c7121fde42..bb882203a3 100644 --- a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css +++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css @@ -1,4 +1,4 @@ -/* Auto-generated MDI subset – 248 icons */ +/* Auto-generated MDI subset – 252 icons */ /* Do not edit manually. Run: pnpm run subset-icons */ @font-face { @@ -136,10 +136,6 @@ content: "\F00F3"; } -.mdi-calendar-range::before { - content: "\F0679"; -} - .mdi-chat::before { content: "\F0B79"; } @@ -204,6 +200,10 @@ content: "\F0143"; } +.mdi-chip::before { + content: "\F061A"; +} + .mdi-circle::before { content: "\F0765"; } @@ -288,6 +288,10 @@ content: "\F0674"; } +.mdi-creation-outline::before { + content: "\F1C2B"; +} + .mdi-cursor-default-click::before { content: "\F0CFD"; } @@ -640,6 +644,10 @@ content: "\F164E"; } +.mdi-message-outline::before { + content: "\F0365"; +} + .mdi-message-text::before { content: "\F0369"; } @@ -816,6 +824,10 @@ content: "\F16A7"; } +.mdi-robot-outline::before { + content: "\F167A"; +} + .mdi-send::before { content: "\F048A"; } @@ -932,6 +944,10 @@ content: "\F0BD4"; } +.mdi-timer-outline::before { + content: "\F051B"; +} + .mdi-tools::before { content: "\F1064"; } diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff index 415cd06e41..6131c0411f 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff differ diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 index 3364125197..2ac3e92281 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 differ diff --git a/dashboard/src/i18n/locales/en-US/features/stats.json b/dashboard/src/i18n/locales/en-US/features/stats.json new file mode 100644 index 0000000000..b6349011ee --- /dev/null +++ b/dashboard/src/i18n/locales/en-US/features/stats.json @@ -0,0 +1,104 @@ +{ + "header": { + "eyebrow": "Dashboard", + "title": "System Statistics", + "subtitle": "A unified view of platforms, messages, and model calls.", + "notUpdated": "Not updated yet" + }, + "ranges": { + "oneDay": "1 Day", + "threeDays": "3 Days", + "oneWeek": "1 Week" + }, + "rangeLabels": { + "oneDay": "Last 1 day", + "threeDays": "Last 3 days", + "oneWeek": "Last 1 week" + }, + "overviewCards": { + "platformCount": { + "label": "Platform Instances", + "note": "Configured and currently running platform instances" + }, + "messageCount": { + "label": "Messages", + "note": "Total accumulated messages" + }, + "todayModelCalls": { + "label": "Today's Model Calls", + "note": "Tokens" + }, + "cpu": { + "label": "CPU", + "note": "AstrBot CPU usage" + }, + "memory": { + "label": "Process Memory", + "note": "System memory {systemMemory}" + }, + "uptime": { + "label": "Uptime", + "note": "Started at {startTime}" + } + }, + "messageOverview": { + "title": "Message Overview", + "subtitle": "Robot message statistics" + }, + "messageTrend": { + "title": "Message Trend", + "subtitle": "{range}, aggregated by hour", + "totalMessages": "Total Messages" + }, + "platformRanking": { + "title": "Platform Message Ranking", + "subtitle": "{range}, sorted by aggregated message volume" + }, + "modelCalls": { + "title": "Model Calls", + "subtitle": "Model call statistics" + }, + "modelTrend": { + "title": "Model Call Trend", + "subtitle": "Token trend for model calls" + }, + "modelTotal": { + "title": "{range} total model calls", + "callCount": "{count} calls", + "avgTtft": "Average TTFT", + "avgDuration": "Average Response Time", + "avgTpm": "Average TPM", + "successRate": "Success Rate" + }, + "modelRanking": { + "title": "{range} model call ranking", + "subtitle": "Ranked by model call tokens" + }, + "sessionRanking": { + "title": "{range} session token top 10", + "subtitle": "Sessions ranked by token usage" + }, + "empty": { + "platformStats": "No platform message statistics available.", + "modelCalls": "No model call data for {range}.", + "sessionCalls": "No session call data for {range}." + }, + "chart": { + "messages": "Messages", + "others": "Others" + }, + "units": { + "tokens": "Tokens", + "tpm": "TPM", + "gb": "GB", + "mb": "MB", + "ms": "ms", + "hoursShort": "h", + "minutesShort": "m", + "secondsShort": "s" + }, + "errors": { + "loadFailed": "Failed to load statistics. Please try again later.", + "rangeFailed": "Failed to switch the statistics range. Please try again later." + } +} diff --git a/dashboard/src/i18n/locales/ru-RU/features/stats.json b/dashboard/src/i18n/locales/ru-RU/features/stats.json new file mode 100644 index 0000000000..45cc3ed6c4 --- /dev/null +++ b/dashboard/src/i18n/locales/ru-RU/features/stats.json @@ -0,0 +1,104 @@ +{ + "header": { + "eyebrow": "Dashboard", + "title": "Статистика системы", + "subtitle": "Единый обзор платформ, сообщений и вызовов моделей.", + "notUpdated": "Еще не обновлялось" + }, + "ranges": { + "oneDay": "1 день", + "threeDays": "3 дня", + "oneWeek": "1 неделя" + }, + "rangeLabels": { + "oneDay": "За 1 день", + "threeDays": "За 3 дня", + "oneWeek": "За 1 неделю" + }, + "overviewCards": { + "platformCount": { + "label": "Платформы", + "note": "Настроенные и запущенные экземпляры платформ" + }, + "messageCount": { + "label": "Сообщения", + "note": "Общее число сообщений" + }, + "todayModelCalls": { + "label": "Вызовы моделей сегодня", + "note": "Tokens" + }, + "cpu": { + "label": "CPU", + "note": "Использование CPU AstrBot" + }, + "memory": { + "label": "Память процесса", + "note": "Системная память {systemMemory}" + }, + "uptime": { + "label": "Время работы", + "note": "Запущен в {startTime}" + } + }, + "messageOverview": { + "title": "Обзор сообщений", + "subtitle": "Статистика сообщений бота" + }, + "messageTrend": { + "title": "Динамика сообщений", + "subtitle": "{range}, агрегация по часам", + "totalMessages": "Всего сообщений" + }, + "platformRanking": { + "title": "Рейтинг платформ по сообщениям", + "subtitle": "{range}, сортировка по общему числу сообщений" + }, + "modelCalls": { + "title": "Вызовы моделей", + "subtitle": "Статистика вызовов моделей" + }, + "modelTrend": { + "title": "Динамика вызовов моделей", + "subtitle": "Динамика токенов вызовов моделей" + }, + "modelTotal": { + "title": "{range} всего вызовов моделей", + "callCount": "{count} вызовов", + "avgTtft": "Средний TTFT", + "avgDuration": "Среднее время ответа", + "avgTpm": "Средний TPM", + "successRate": "Доля успешных вызовов" + }, + "modelRanking": { + "title": "{range} рейтинг вызовов моделей", + "subtitle": "Рейтинг по токенам вызовов моделей" + }, + "sessionRanking": { + "title": "{range} топ-10 сессий по токенам", + "subtitle": "Сессии с наибольшим расходом токенов" + }, + "empty": { + "platformStats": "Статистика сообщений по платформам отсутствует.", + "modelCalls": "Нет данных по вызовам моделей за период {range}.", + "sessionCalls": "Нет данных по сессиям за период {range}." + }, + "chart": { + "messages": "Сообщения", + "others": "Другое" + }, + "units": { + "tokens": "Tokens", + "tpm": "TPM", + "gb": "ГБ", + "mb": "МБ", + "ms": "мс", + "hoursShort": "ч", + "minutesShort": "м", + "secondsShort": "с" + }, + "errors": { + "loadFailed": "Не удалось загрузить статистику. Повторите попытку позже.", + "rangeFailed": "Не удалось переключить диапазон статистики. Повторите попытку позже." + } +} diff --git a/dashboard/src/i18n/locales/zh-CN/features/stats.json b/dashboard/src/i18n/locales/zh-CN/features/stats.json new file mode 100644 index 0000000000..64c8bb84a2 --- /dev/null +++ b/dashboard/src/i18n/locales/zh-CN/features/stats.json @@ -0,0 +1,104 @@ +{ + "header": { + "eyebrow": "Dashboard", + "title": "系统统计", + "subtitle": "平台、消息与模型调用的统一视图。", + "notUpdated": "尚未更新" + }, + "ranges": { + "oneDay": "1 天", + "threeDays": "3 天", + "oneWeek": "1 周" + }, + "rangeLabels": { + "oneDay": "最近 1 天", + "threeDays": "最近 3 天", + "oneWeek": "最近 1 周" + }, + "overviewCards": { + "platformCount": { + "label": "平台实例数", + "note": "当前配置并已运行的平台实例" + }, + "messageCount": { + "label": "消息总数", + "note": "累计消息量" + }, + "todayModelCalls": { + "label": "今日模型调用", + "note": "词元(Tokens)" + }, + "cpu": { + "label": "CPU", + "note": "程序 CPU 占用" + }, + "memory": { + "label": "进程内存", + "note": "系统内存 {systemMemory}" + }, + "uptime": { + "label": "运行时间", + "note": "启动于 {startTime}" + } + }, + "messageOverview": { + "title": "消息概览", + "subtitle": "机器人消息数据统计" + }, + "messageTrend": { + "title": "消息趋势", + "subtitle": "{range}按小时聚合", + "totalMessages": "总消息数" + }, + "platformRanking": { + "title": "平台消息排名", + "subtitle": "{range}按聚合消息总量排序" + }, + "modelCalls": { + "title": "模型调用", + "subtitle": "模型调用数据统计" + }, + "modelTrend": { + "title": "模型调用趋势", + "subtitle": "模型调用词元(Tokens)趋势统计" + }, + "modelTotal": { + "title": "{range}模型调用总量", + "callCount": "共 {count} 次调用", + "avgTtft": "平均首字延迟(TTFT)", + "avgDuration": "平均响应时间", + "avgTpm": "平均每分钟词元数(TPM)", + "successRate": "调用成功率" + }, + "modelRanking": { + "title": "{range}模型调用排名", + "subtitle": "模型调用词元(Tokens)排名" + }, + "sessionRanking": { + "title": "{range}会话词元(Tokens)Top 10", + "subtitle": "词元(Tokens)用量最大的会话排名" + }, + "empty": { + "platformStats": "当前没有平台消息统计数据。", + "modelCalls": "{range}还没有模型调用数据。", + "sessionCalls": "{range}还没有会话调用数据。" + }, + "chart": { + "messages": "消息", + "others": "其他" + }, + "units": { + "tokens": "词元(Tokens)", + "tpm": "TPM", + "gb": "GB", + "mb": "MB", + "ms": "ms", + "hoursShort": "h", + "minutesShort": "m", + "secondsShort": "s" + }, + "errors": { + "loadFailed": "统计数据加载失败,请稍后重试。", + "rangeFailed": "统计范围切换失败,请稍后重试。" + } +} diff --git a/dashboard/src/i18n/translations.ts b/dashboard/src/i18n/translations.ts index fa15e619ff..9d27b41d7f 100644 --- a/dashboard/src/i18n/translations.ts +++ b/dashboard/src/i18n/translations.ts @@ -26,6 +26,7 @@ import zhCNAuth from './locales/zh-CN/features/auth.json'; import zhCNChart from './locales/zh-CN/features/chart.json'; import zhCNDashboard from './locales/zh-CN/features/dashboard.json'; import zhCNCron from './locales/zh-CN/features/cron.json'; +import zhCNStats from './locales/zh-CN/features/stats.json'; import zhCNAlkaidIndex from './locales/zh-CN/features/alkaid/index.json'; import zhCNAlkaidKnowledgeBase from './locales/zh-CN/features/alkaid/knowledge-base.json'; import zhCNAlkaidMemory from './locales/zh-CN/features/alkaid/memory.json'; @@ -67,6 +68,7 @@ import enUSAuth from './locales/en-US/features/auth.json'; import enUSChart from './locales/en-US/features/chart.json'; import enUSDashboard from './locales/en-US/features/dashboard.json'; import enUSCron from './locales/en-US/features/cron.json'; +import enUSStats from './locales/en-US/features/stats.json'; import enUSAlkaidIndex from './locales/en-US/features/alkaid/index.json'; import enUSAlkaidKnowledgeBase from './locales/en-US/features/alkaid/knowledge-base.json'; import enUSAlkaidMemory from './locales/en-US/features/alkaid/memory.json'; @@ -108,6 +110,7 @@ import ruRUAuth from './locales/ru-RU/features/auth.json'; import ruRUChart from './locales/ru-RU/features/chart.json'; import ruRUDashboard from './locales/ru-RU/features/dashboard.json'; import ruRUCron from './locales/ru-RU/features/cron.json'; +import ruRUStats from './locales/ru-RU/features/stats.json'; import ruRUAlkaidIndex from './locales/ru-RU/features/alkaid/index.json'; import ruRUAlkaidKnowledgeBase from './locales/ru-RU/features/alkaid/knowledge-base.json'; import ruRUAlkaidMemory from './locales/ru-RU/features/alkaid/memory.json'; @@ -153,6 +156,7 @@ export const translations = { chart: zhCNChart, dashboard: zhCNDashboard, cron: zhCNCron, + stats: zhCNStats, alkaid: { index: zhCNAlkaidIndex, 'knowledge-base': zhCNAlkaidKnowledgeBase, @@ -202,6 +206,7 @@ export const translations = { chart: enUSChart, dashboard: enUSDashboard, cron: enUSCron, + stats: enUSStats, alkaid: { index: enUSAlkaidIndex, 'knowledge-base': enUSAlkaidKnowledgeBase, @@ -251,6 +256,7 @@ export const translations = { chart: ruRUChart, dashboard: ruRUDashboard, cron: ruRUCron, + stats: ruRUStats, alkaid: { index: ruRUAlkaidIndex, 'knowledge-base': ruRUAlkaidKnowledgeBase, diff --git a/dashboard/src/router/MainRoutes.ts b/dashboard/src/router/MainRoutes.ts index 109122dd86..526e316351 100644 --- a/dashboard/src/router/MainRoutes.ts +++ b/dashboard/src/router/MainRoutes.ts @@ -52,9 +52,9 @@ const MainRoutes = { redirect: '/config#system' }, { - name: 'Default', + name: 'Stats', path: '/dashboard/default', - component: () => import('@/views/dashboards/default/DefaultDashboard.vue') + component: () => import('@/views/stats/StatsPage.vue') }, { name: 'Conversation', diff --git a/dashboard/src/views/dashboards/default/DefaultDashboard.vue b/dashboard/src/views/dashboards/default/DefaultDashboard.vue deleted file mode 100644 index 4037a00458..0000000000 --- a/dashboard/src/views/dashboards/default/DefaultDashboard.vue +++ /dev/null @@ -1,223 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dashboard/src/views/dashboards/default/components/MemoryUsage.vue b/dashboard/src/views/dashboards/default/components/MemoryUsage.vue deleted file mode 100644 index 23c183b7ba..0000000000 --- a/dashboard/src/views/dashboards/default/components/MemoryUsage.vue +++ /dev/null @@ -1,147 +0,0 @@ - - - - - - - - - - {{ t('stats.memoryUsage.title') }} - - {{ stat.memory?.process || 0 }} MiB / {{ stat.memory?.system || 0 }} MiB - - {{ memoryStatus.label }} - - - - - - - - {{ t('stats.memoryUsage.cpuLoad') }} - {{ stat.cpu_percent || '0' }}% - - - - - - - - - diff --git a/dashboard/src/views/dashboards/default/components/MessageStat.vue b/dashboard/src/views/dashboards/default/components/MessageStat.vue deleted file mode 100644 index a41c664e45..0000000000 --- a/dashboard/src/views/dashboards/default/components/MessageStat.vue +++ /dev/null @@ -1,391 +0,0 @@ - - - - - - {{ t('charts.messageTrend.title') }} - {{ t('charts.messageTrend.subtitle') }} - - - - - - mdi-calendar-range - {{ item.raw.label }} - - - - - - - - {{ t('charts.messageTrend.totalMessages') }} - {{ totalMessages }} - - - - {{ t('charts.messageTrend.dailyAverage') }} - {{ dailyAverage }} - - - - {{ t('charts.messageTrend.growthRate') }} - - - {{ Math.abs(growthRate) }}% - - - - - - - - {{ t('status.loading') }} - - - - - - - - - - \ No newline at end of file diff --git a/dashboard/src/views/dashboards/default/components/OnlinePlatform.vue b/dashboard/src/views/dashboards/default/components/OnlinePlatform.vue deleted file mode 100644 index e634bc3082..0000000000 --- a/dashboard/src/views/dashboards/default/components/OnlinePlatform.vue +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - - - - {{ t('stats.onlinePlatform.title') }} - - {{ stat.platform_count || 0 }} - - {{ t('stats.onlinePlatform.subtitle') }} - - - - - - - - - \ No newline at end of file diff --git a/dashboard/src/views/dashboards/default/components/OnlineTime.vue b/dashboard/src/views/dashboards/default/components/OnlineTime.vue deleted file mode 100644 index 66f8b80121..0000000000 --- a/dashboard/src/views/dashboards/default/components/OnlineTime.vue +++ /dev/null @@ -1,196 +0,0 @@ - - - - - - - - - - - {{ tm('features.dashboard.status.uptime') }} - {{ stat.running || tm('features.dashboard.status.loading') }} - - - - - - - {{ tm('features.dashboard.status.online') }} - - - - - - - - - - - - - - {{ tm('features.dashboard.status.memoryUsage') }} - - {{ stat.memory?.process || 0 }} MiB - / - {{ stat.memory?.system || 0 }} MiB - - - - - {{ memoryPercentage }}% - - - - - - - - - - \ No newline at end of file diff --git a/dashboard/src/views/dashboards/default/components/PlatformStat.vue b/dashboard/src/views/dashboards/default/components/PlatformStat.vue deleted file mode 100644 index b61b1b298a..0000000000 --- a/dashboard/src/views/dashboards/default/components/PlatformStat.vue +++ /dev/null @@ -1,261 +0,0 @@ - - - - - - {{ t('charts.platformStat.title') }} - {{ t('charts.platformStat.subtitle') }} - - - - - - - - - - {{ i + 1 }} - - - {{ platform.name }} - - - - {{ platform.count }} - {{ t('charts.platformStat.messageUnit') }} - - - - - - - - {{ t('charts.platformStat.platformCount') }} - {{ platforms.length }} - - - - {{ t('charts.platformStat.mostActive') }} - {{ mostActivePlatform }} - - - - {{ t('charts.platformStat.totalPercentage') }} - {{ topPlatformPercentage }}% - - - - - - - - - - - {{ t('charts.platformStat.noData') }} - - - - - - - - \ No newline at end of file diff --git a/dashboard/src/views/dashboards/default/components/RunningTime.vue b/dashboard/src/views/dashboards/default/components/RunningTime.vue deleted file mode 100644 index df83bb30ff..0000000000 --- a/dashboard/src/views/dashboards/default/components/RunningTime.vue +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - {{ t('stats.runningTime.title') }} - - {{ formattedTime }} - - {{ t('stats.runningTime.subtitle') }} - - - - - - - - - diff --git a/dashboard/src/views/dashboards/default/components/TotalMessage.vue b/dashboard/src/views/dashboards/default/components/TotalMessage.vue deleted file mode 100644 index f9547c3943..0000000000 --- a/dashboard/src/views/dashboards/default/components/TotalMessage.vue +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - - - - - {{ t('stats.totalMessage.title') }} - - {{ formattedCount }} - - +{{ stat.daily_increase }} - - - {{ t('stats.totalMessage.subtitle') }} - - - - - - - - - \ No newline at end of file diff --git a/dashboard/src/views/stats/StatsPage.vue b/dashboard/src/views/stats/StatsPage.vue new file mode 100644 index 0000000000..ca6ae1ec97 --- /dev/null +++ b/dashboard/src/views/stats/StatsPage.vue @@ -0,0 +1,1170 @@ + + + + + + {{ t('header.eyebrow') }} + {{ t('header.title') }} + {{ t('header.subtitle') }} + + + + mdi-refresh + {{ lastUpdatedLabel }} + + + + + + {{ errorMessage }} + + + + + + + + + + + {{ card.icon }} + + {{ card.label }} + {{ card.value }} + {{ card.note }} + + + + + + {{ t('messageOverview.title') }} + {{ t('messageOverview.subtitle') }} + + + + {{ t(option.labelKey) }} + + + + + + + + + {{ t('messageTrend.title') }} + {{ t('messageTrend.subtitle', { range: rangeLabel }) }} + + + + {{ t('messageTrend.totalMessages') }} + {{ formatNumber(baseStats?.message_count ?? 0) }} + + + + + + + + + + {{ t('platformRanking.title') }} + {{ t('platformRanking.subtitle', { range: rangeLabel }) }} + + + + + {{ platform.name }} + {{ formatNumber(platform.count) }} + + + {{ t('empty.platformStats') }} + + + + + + {{ t('modelCalls.title') }} + {{ t('modelCalls.subtitle') }} + + + + + + + + {{ t('modelTrend.title') }} + {{ t('modelTrend.subtitle') }} + + + + + + + + {{ t('modelTotal.title', { range: rangeLabel }) }} + {{ formatNumber(providerStats?.range_total_tokens ?? 0) }} {{ t('units.tokens') }} + {{ t('modelTotal.callCount', { count: formatNumber(providerStats?.range_total_calls ?? 0) }) }} + + + {{ t('modelTotal.avgTtft') }} + {{ rangeAvgTtftLabel }} + + + {{ t('modelTotal.avgDuration') }} + {{ rangeAvgDurationLabel }} + + + {{ t('modelTotal.avgTpm') }} + {{ rangeAvgTpmLabel }} + + + {{ t('modelTotal.successRate') }} + {{ rangeSuccessRateLabel }} + + + + + + + + {{ t('modelRanking.title', { range: rangeLabel }) }} + {{ t('modelRanking.subtitle') }} + + + + + {{ provider.provider_id }} + {{ formatNumber(provider.tokens) }} + + + {{ t('empty.modelCalls', { range: rangeLabel }) }} + + + + + + + + {{ t('sessionRanking.title', { range: rangeLabel }) }} + {{ t('sessionRanking.subtitle') }} + + + + + {{ item.umo }} + {{ formatNumber(item.tokens) }} + + + {{ t('empty.sessionCalls', { range: rangeLabel }) }} + + + + + + + + + diff --git a/tests/unit/test_provider_stats.py b/tests/unit/test_provider_stats.py new file mode 100644 index 0000000000..c306c3f820 --- /dev/null +++ b/tests/unit/test_provider_stats.py @@ -0,0 +1,65 @@ +from types import SimpleNamespace + +import pytest +from sqlmodel import select + +from astrbot.core.agent.response import AgentStats +from astrbot.core.db.po import ProviderStat +from astrbot.core.pipeline.process_stage.method.agent_sub_stages import internal +from astrbot.core.provider.entities import ProviderRequest, TokenUsage + + +@pytest.mark.asyncio +async def test_record_internal_agent_stats_persists_provider_stat( + temp_db, + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setattr(internal, "db_helper", temp_db) + + event = SimpleNamespace(unified_msg_origin="webchat:FriendMessage:session-42") + req = ProviderRequest( + conversation=SimpleNamespace(cid="conv-123"), + ) + stats = AgentStats( + token_usage=TokenUsage(input_other=11, input_cached=3, output=7), + start_time=100.0, + end_time=108.5, + time_to_first_token=0.6, + ) + provider = SimpleNamespace( + provider_config={"id": "provider-1"}, + meta=lambda: SimpleNamespace(id="provider-1", type="openai"), + get_model=lambda: "gpt-4.1", + ) + agent_runner = SimpleNamespace( + provider=provider, + stats=stats, + was_aborted=lambda: False, + ) + final_resp = SimpleNamespace(role="assistant") + + await internal._record_internal_agent_stats( + event, + req, + agent_runner, + final_resp, + ) + + async with temp_db.get_db() as session: + result = await session.execute(select(ProviderStat)) + records = result.scalars().all() + + assert len(records) == 1 + record = records[0] + assert record.agent_type == "internal" + assert record.status == "completed" + assert record.umo == "webchat:FriendMessage:session-42" + assert record.conversation_id == "conv-123" + assert record.provider_id == "provider-1" + assert record.provider_model == "gpt-4.1" + assert record.token_input_other == 11 + assert record.token_input_cached == 3 + assert record.token_output == 7 + assert record.start_time == 100.0 + assert record.end_time == 108.5 + assert record.time_to_first_token == 0.6
{{ t('header.subtitle') }}