Skip to content

feat: add new StatsPage for enhanced statistics overview#7152

Merged
Soulter merged 2 commits intomasterfrom
feat/new-stastics
Mar 29, 2026
Merged

feat: add new StatsPage for enhanced statistics overview#7152
Soulter merged 2 commits intomasterfrom
feat/new-stastics

Conversation

@Soulter
Copy link
Copy Markdown
Member

@Soulter Soulter commented Mar 29, 2026

  • Introduced StatsPage.vue to provide a comprehensive overview of statistics with various metrics and charts.
  • Implemented fetching and displaying of base and model provider statistics.
  • Added unit tests for provider statistics persistence in the database.

Modifications / 改动点

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果


Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。

  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”

  • 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txtpyproject.toml 文件相应位置。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

Summary by Sourcery

Add backend provider token statistics collection and expose them via a new API, then replace the old default dashboard with a new Stats page that visualizes system and model usage metrics.

New Features:

  • Introduce a Stats page in the dashboard that provides an overview of system activity, message volume, and model usage across selectable time ranges.
  • Add an API endpoint to retrieve aggregated provider token statistics for internal agents, including trends and rankings by provider and session.
  • Persist per-response provider statistics, including token usage and timing data, for internal agent runs.

Enhancements:

  • Refine the dashboard routing to point the default dashboard route to the new Stats page.
  • Extend the icon subset used by the dashboard to support new statistics-related visuals.

Tests:

  • Add a unit test to verify that internal agent runs correctly persist provider statistics into the database.

- Introduced StatsPage.vue to provide a comprehensive overview of statistics with various metrics and charts.
- Implemented fetching and displaying of base and model provider statistics.
- Added unit tests for provider statistics persistence in the database.
@auto-assign auto-assign bot requested review from advent259141 and anka-afk March 29, 2026 14:30
@dosubot dosubot bot added size:XXL This PR changes 1000+ lines, ignoring generated files. area:webui The bug / feature is about webui(dashboard) of astrbot. labels Mar 29, 2026
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • The aggregateOverflowSeries helper in StatsPage.vue assumes all series have the same number of points at identical timestamps and sums by array index; consider aggregating by timestamp key instead to avoid subtle chart inaccuracies if the backend ever returns uneven series.
  • The new StatsPage.vue is quite large and mixes data fetching, chart configuration, and layout in a single file; consider extracting composables (for fetching/formatting logic) or smaller presentational components (e.g., overview cards, provider ranking list) to keep the view more maintainable.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `aggregateOverflowSeries` helper in `StatsPage.vue` assumes all series have the same number of points at identical timestamps and sums by array index; consider aggregating by timestamp key instead to avoid subtle chart inaccuracies if the backend ever returns uneven series.
- The new `StatsPage.vue` is quite large and mixes data fetching, chart configuration, and layout in a single file; consider extracting composables (for fetching/formatting logic) or smaller presentational components (e.g., overview cards, provider ranking list) to keep the view more maintainable.

## Individual Comments

### Comment 1
<location path="astrbot/core/db/sqlite.py" line_range="212-216" />
<code_context>
+                    end_time=end_time,
+                    time_to_first_token=time_to_first_token,
+                )
+                session.add(record)
+                await session.flush()
+                await session.refresh(record)
+                return record
+
</code_context>
<issue_to_address>
**suggestion (performance):** Refreshing the record after flush/begin is probably unnecessary overhead.

Here you `add(record)`, `flush()`, then `refresh(record)` before returning. For a simple insert where only the autoincrement PK is DB-generated, `flush()` already populates `record.id`. The extra `refresh()` adds an unnecessary DB round-trip. Unless you depend on DB-side defaults/triggers updating other fields, you can remove `await session.refresh(record)` to avoid that overhead.

```suggestion
                )
                session.add(record)
                await session.flush()
                return record
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +212 to +216
)
session.add(record)
await session.flush()
await session.refresh(record)
return record
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (performance): Refreshing the record after flush/begin is probably unnecessary overhead.

Here you add(record), flush(), then refresh(record) before returning. For a simple insert where only the autoincrement PK is DB-generated, flush() already populates record.id. The extra refresh() adds an unnecessary DB round-trip. Unless you depend on DB-side defaults/triggers updating other fields, you can remove await session.refresh(record) to avoid that overhead.

Suggested change
)
session.add(record)
await session.flush()
await session.refresh(record)
return record
)
session.add(record)
await session.flush()
return record

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive system statistics dashboard. It adds a new database model, ProviderStat, to track per-response LLM provider metrics such as token usage, time-to-first-token, and total duration. The backend now supports asynchronous recording of these statistics and provides a new API endpoint for time-based aggregation (1, 3, or 7 days). The frontend has been overhauled with a new StatsPage featuring interactive ApexCharts, overview cards, and detailed rankings for platforms and models, including full i18n support. Feedback focuses on refactoring the large backend aggregation logic and the monolithic Vue component into smaller, more maintainable modules to improve readability and testability.

Comment on lines +202 to +396
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__

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This method is quite long and handles multiple responsibilities: fetching data, complex time-based aggregation, and formatting the final response. This reduces readability and makes it harder to test and maintain.

Consider refactoring this logic into smaller, more focused components:

  • Data Fetching: A dedicated function to query and retrieve ProviderStat records.
  • Aggregation Logic: A separate class or function to process the raw records. This component would handle the bucketing, timezone conversions, and calculations for totals, averages, etc.
  • Response Formatting: A function to take the aggregated data and structure it into the final JSON response.

Additionally, for performance and scalability, it's worth noting that fetching all records and aggregating in Python can be memory-intensive. While the current approach is easier to implement given the timezone and bucketing complexity, consider moving some of the aggregation logic to the database using SQL GROUP BY and aggregate functions in the future if performance becomes a concern.

Comment on lines +1 to +1174
<template>
<div class="stats-page" :class="{ 'is-dark': isDark }">
<v-container fluid class="stats-shell pa-4 pa-md-6">
<div class="stats-header">
<div>
<div class="eyebrow">{{ t('header.eyebrow') }}</div>
<h1 class="stats-title">{{ t('header.title') }}</h1>
<p class="stats-subtitle">{{ t('header.subtitle') }}</p>
</div>
<div class="header-meta">
<div class="meta-pill">
<v-icon size="16">mdi-refresh</v-icon>
<span>{{ lastUpdatedLabel }}</span>
</div>
</div>
</div>

<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
>
{{ errorMessage }}
</v-alert>

<div v-if="loading && !baseStats" class="loading-wrap">
<v-progress-circular indeterminate color="grey-darken-1" />
</div>

<template v-else>
<div class="overview-grid">
<section
v-for="card in overviewCards"
:key="card.label"
class="stat-card overview-card"
>
<div class="card-icon">
<v-icon size="18">{{ card.icon }}</v-icon>
</div>
<div class="card-label">{{ card.label }}</div>
<div class="card-value">{{ card.value }}</div>
<div class="card-note">{{ card.note }}</div>
</section>
</div>

<div class="section-toolbar">
<div>
<div class="section-title">{{ t('messageOverview.title') }}</div>
<div class="section-subtitle">{{ t('messageOverview.subtitle') }}</div>
</div>
<div class="range-switch">
<button
v-for="option in rangeOptions"
:key="`toolbar-${option.value}`"
type="button"
class="range-chip"
:class="{ active: selectedRange === option.value }"
@click="selectedRange = option.value"
>
{{ t(option.labelKey) }}
</button>
</div>
</div>

<div class="panel-grid">
<section class="stat-card chart-card chart-card-wide">
<div class="card-head">
<div>
<div class="section-title">{{ t('messageTrend.title') }}</div>
<div class="section-subtitle">{{ t('messageTrend.subtitle', { range: rangeLabel }) }}</div>
</div>
<div class="card-head-actions">
<div class="section-metric">
<span class="metric-label">{{ t('messageTrend.totalMessages') }}</span>
<span class="metric-value">{{ formatNumber(baseStats?.message_count ?? 0) }}</span>
</div>
</div>
</div>
<apexchart
type="area"
height="320"
:options="messageChartOptions"
:series="messageChartSeries"
/>
</section>

<section class="stat-card provider-list-card">
<div class="card-head compact">
<div>
<div class="section-title">{{ t('platformRanking.title') }}</div>
<div class="section-subtitle">{{ t('platformRanking.subtitle', { range: rangeLabel }) }}</div>
</div>
</div>
<div v-if="platformRanking.length" class="provider-list">
<div
v-for="platform in platformRanking"
:key="platform.name"
class="provider-row"
>
<span class="provider-name">{{ platform.name }}</span>
<strong>{{ formatNumber(platform.count) }}</strong>
</div>
</div>
<div v-else class="empty-state">{{ t('empty.platformStats') }}</div>
</section>
</div>

<div class="token-section-head">
<div>
<div class="section-title">{{ t('modelCalls.title') }}</div>
<div class="section-subtitle">{{ t('modelCalls.subtitle') }}</div>
</div>
</div>

<div class="token-grid">
<section class="stat-card chart-card chart-card-wide provider-trend-card">
<div class="card-head">
<div>
<div class="section-title">{{ t('modelTrend.title') }}</div>
<div class="section-subtitle">{{ t('modelTrend.subtitle') }}</div>
</div>
</div>
<apexchart
type="bar"
height="420"
:options="providerChartOptions"
:series="providerTrendSeries"
/>
</section>

<section class="token-side-column">
<section class="stat-card token-total-card">
<div class="card-label">{{ t('modelTotal.title', { range: rangeLabel }) }}</div>
<div class="token-total-value">{{ formatNumber(providerStats?.range_total_tokens ?? 0) }} <span style="font-size: 18px;">{{ t('units.tokens') }}</span></div>
<div class="card-note">{{ t('modelTotal.callCount', { count: formatNumber(providerStats?.range_total_calls ?? 0) }) }}</div>
<div class="token-meta-list">
<div class="token-meta-item">
<span>{{ t('modelTotal.avgTtft') }}</span>
<strong>{{ rangeAvgTtftLabel }}</strong>
</div>
<div class="token-meta-item">
<span>{{ t('modelTotal.avgDuration') }}</span>
<strong>{{ rangeAvgDurationLabel }}</strong>
</div>
<div class="token-meta-item">
<span>{{ t('modelTotal.avgTpm') }}</span>
<strong>{{ rangeAvgTpmLabel }}</strong>
</div>
<div class="token-meta-item">
<span>{{ t('modelTotal.successRate') }}</span>
<strong>{{ rangeSuccessRateLabel }}</strong>
</div>
</div>
</section>

<section class="stat-card provider-list-card">
<div class="card-head compact">
<div>
<div class="section-title">{{ t('modelRanking.title', { range: rangeLabel }) }}</div>
<div class="section-subtitle">{{ t('modelRanking.subtitle') }}</div>
</div>
</div>
<div
v-if="rangeProviderRanking.length"
class="provider-list provider-list--scrollable"
>
<div
v-for="provider in rangeProviderRanking"
:key="provider.provider_id"
class="provider-row"
>
<span class="provider-name">{{ provider.provider_id }}</span>
<strong>{{ formatNumber(provider.tokens) }}</strong>
</div>
</div>
<div v-else class="empty-state">{{ t('empty.modelCalls', { range: rangeLabel }) }}</div>
</section>
</section>
</div>

<section class="stat-card provider-list-card">
<div class="card-head compact">
<div>
<div class="section-title">{{ t('sessionRanking.title', { range: rangeLabel }) }}</div>
<div class="section-subtitle">{{ t('sessionRanking.subtitle') }}</div>
</div>
</div>
<div v-if="rangeUmoRanking.length" class="provider-list">
<div
v-for="item in rangeUmoRanking"
:key="item.umo"
class="provider-row"
>
<span class="provider-name">{{ item.umo }}</span>
<strong>{{ formatNumber(item.tokens) }}</strong>
</div>
</div>
<div v-else class="empty-state">{{ t('empty.sessionCalls', { range: rangeLabel }) }}</div>
</section>
</template>
</v-container>
</div>
</template>

<script setup lang="ts">
import type { ApexOptions } from 'apexcharts'
import axios from 'axios'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useTheme } from 'vuetify'
import { useI18n, useModuleI18n } from '@/i18n/composables'

type TokenRange = 1 | 3 | 7
type ChartSeries = Array<{
name: string
data: unknown[]
}>

interface RunningStats {
hours: number
minutes: number
seconds: number
}

interface BaseStatsResponse {
message_count: number
platform_count: number
platform: Array<{
name: string
count: number
timestamp: number
}>
message_time_series: Array<[number, number]>
memory: {
process: number
system: number
}
cpu_percent: number
running: RunningStats
thread_count: number
start_time: number
}

interface ProviderTrendItem {
name: string
data: Array<[number, number]>
total_tokens: number
}

interface ProviderRankingItem {
provider_id: string
tokens: number
}

interface UmoRankingItem {
umo: string
tokens: number
}

interface ProviderTokenStatsResponse {
days: TokenRange
trend: {
series: ProviderTrendItem[]
total_series: Array<[number, number]>
}
range_total_tokens: number
range_total_calls: number
range_avg_ttft_ms: number
range_avg_duration_ms: number
range_avg_tpm: number
range_success_rate: number
range_by_provider: ProviderRankingItem[]
range_by_umo: UmoRankingItem[]
today_total_tokens: number
today_total_calls: number
today_by_provider: ProviderRankingItem[]
}

const { locale } = useI18n()
const { tm: t } = useModuleI18n('features/stats')
const theme = useTheme()
const loading = ref(true)
const errorMessage = ref('')
const baseStats = ref<BaseStatsResponse | null>(null)
const providerStats = ref<ProviderTokenStatsResponse | null>(null)
const selectedRange = ref<TokenRange>(1)
const lastUpdatedAt = ref<Date | null>(null)
const isDark = computed(() => theme.global.current.value.dark)
const themePalette = computed(() => {
const colors = theme.global.current.value.colors as Record<string, string>
return {
primary: colors.primary,
secondary: colors.secondary,
info: colors.info,
success: colors.success,
warning: colors.warning,
accent: colors.accent,
border: colors.border ?? colors.borderLight ?? colors.primary,
mutedText: colors.secondaryText ?? colors.primaryText ?? colors.primary,
lightPrimary: colors.lightprimary ?? colors.surface ?? colors.background,
lightSecondary: colors.lightsecondary ?? colors.surface ?? colors.background
}
})

let refreshTimer: number | null = null

function formatNumber(value: number): string {
return new Intl.NumberFormat(locale.value).format(value)
}

function formatCompactNumber(value: number): string {
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`
if (value >= 1_000) return `${(value / 1_000).toFixed(2)}K`
return formatNumber(value)
}

function formatMemory(memoryMb: number): string {
if (memoryMb >= 1024) {
return `${(memoryMb / 1024).toFixed(1)} ${t('units.gb')}`
}
return `${formatNumber(memoryMb)} ${t('units.mb')}`
}

function formatDurationMs(value: number): string {
if (!value || value <= 0) return '—'
if (value < 1000) return `${Math.round(value)} ${t('units.ms')}`
return `${(value / 1000).toFixed(2)} ${t('units.secondsShort')}`
}

function formatTpm(value: number): string {
if (!value || value <= 0) return '—'
return `${value.toFixed(0) } ${t('units.tpm')}`
}

function hexToRgba(color: string | undefined, alpha: number): string {
if (!color) return `rgba(0, 0, 0, ${alpha})`
if (!color.startsWith('#')) return color

let hex = color.slice(1)
if (hex.length === 3) {
hex = hex
.split('')
.map((char) => char + char)
.join('')
}

if (hex.length !== 6) return color

const red = Number.parseInt(hex.slice(0, 2), 16)
const green = Number.parseInt(hex.slice(2, 4), 16)
const blue = Number.parseInt(hex.slice(4, 6), 16)
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
}

function formatDateTime(timestampSec: number): string {
if (!timestampSec) return '—'
return new Date(timestampSec * 1000).toLocaleString(locale.value, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}

function formatRunningTime(running?: RunningStats | null): string {
if (!running) return '—'
const parts = [
running.hours > 0 ? `${running.hours}${t('units.hoursShort')}` : '',
running.minutes > 0 || running.hours > 0 ? `${running.minutes}${t('units.minutesShort')}` : '',
`${running.seconds}${t('units.secondsShort')}`
].filter(Boolean)
return parts.join(' ')
}

function aggregateOverflowSeries(series: ProviderTrendItem[]): ProviderTrendItem[] {
if (series.length <= 5) return series
const leading = series.slice(0, 4)
const overflow = series.slice(4)
const mergedPoints = overflow[0].data.map(([timestamp], index) => {
const total = overflow.reduce((sum, item) => sum + (item.data[index]?.[1] ?? 0), 0)
return [timestamp, total] as [number, number]
})
return [
...leading,
{
name: t('chart.others'),
data: mergedPoints,
total_tokens: overflow.reduce((sum, item) => sum + item.total_tokens, 0)
}
]
}

async function fetchBaseStats(): Promise<void> {
const response = await axios.get('/api/stat/get', {
params: {
offset_sec: selectedRange.value * 24 * 60 * 60
}
})
baseStats.value = response.data.data
}

async function fetchProviderStats(): Promise<void> {
const response = await axios.get('/api/stat/provider-tokens', {
params: {
days: selectedRange.value
}
})
providerStats.value = response.data.data
}

async function refreshStats(): Promise<void> {
try {
errorMessage.value = ''
await Promise.all([fetchBaseStats(), fetchProviderStats()])
lastUpdatedAt.value = new Date()
} catch (error) {
console.error('Failed to load stats page data:', error)
errorMessage.value = t('errors.loadFailed')
} finally {
loading.value = false
}
}

const rangeOptions = computed(() => [
{ labelKey: 'ranges.oneDay', value: 1 as TokenRange },
{ labelKey: 'ranges.threeDays', value: 3 as TokenRange },
{ labelKey: 'ranges.oneWeek', value: 7 as TokenRange }
])

const lastUpdatedLabel = computed(() => {
if (!lastUpdatedAt.value) return t('header.notUpdated')
return lastUpdatedAt.value.toLocaleTimeString(locale.value, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
})

const rangeLabel = computed(() => {
if (selectedRange.value === 3) return t('rangeLabels.threeDays')
if (selectedRange.value === 7) return t('rangeLabels.oneWeek')
return t('rangeLabels.oneDay')
})

const overviewCards = computed(() => [
{
label: t('overviewCards.platformCount.label'),
value: formatNumber(baseStats.value?.platform_count ?? 0),
note: t('overviewCards.platformCount.note'),
icon: 'mdi-robot-outline'
},
{
label: t('overviewCards.messageCount.label'),
value: formatNumber(baseStats.value?.message_count ?? 0),
note: t('overviewCards.messageCount.note'),
icon: 'mdi-message-outline'
},
{
label: t('overviewCards.todayModelCalls.label'),
value: formatCompactNumber(providerStats.value?.today_total_tokens ?? 0),
note: t('overviewCards.todayModelCalls.note'),
icon: 'mdi-creation-outline'
},
{
label: t('overviewCards.cpu.label'),
value: `${baseStats.value?.cpu_percent ?? 0}%`,
note: t('overviewCards.cpu.note'),
icon: 'mdi-chip'
},
{
label: t('overviewCards.memory.label'),
value: formatMemory(baseStats.value?.memory?.process ?? 0),
note: t('overviewCards.memory.note', {
systemMemory: formatMemory(baseStats.value?.memory?.system ?? 0)
}),
icon: 'mdi-memory'
},
{
label: t('overviewCards.uptime.label'),
value: formatRunningTime(baseStats.value?.running),
note: t('overviewCards.uptime.note', { startTime: startTimeLabel.value }),
icon: 'mdi-timer-outline'
}
])

const messageChartSeries = computed<ChartSeries>(() => [
{
name: t('chart.messages'),
data: (baseStats.value?.message_time_series ?? []).map(([timestamp, value]) => [
timestamp * 1000,
value
])
}
])

const providerTrendSeries = computed<ChartSeries>(() =>
aggregateOverflowSeries(providerStats.value?.trend.series ?? []).map((item) => ({
name: item.name,
data: item.data
}))
)

const rangeProviderRanking = computed(() => providerStats.value?.range_by_provider ?? [])

const rangeUmoRanking = computed(() =>
(providerStats.value?.range_by_umo ?? []).slice(0, 10)
)

const rangeAvgTtftLabel = computed(() =>
formatDurationMs(providerStats.value?.range_avg_ttft_ms ?? 0)
)

const rangeAvgDurationLabel = computed(() =>
formatDurationMs(providerStats.value?.range_avg_duration_ms ?? 0)
)

const rangeAvgTpmLabel = computed(() =>
formatTpm(providerStats.value?.range_avg_tpm ?? 0)
)

const rangeSuccessRateLabel = computed(() => {
if (!(providerStats.value?.range_total_calls ?? 0)) {
return '—'
}
const rate = providerStats.value?.range_success_rate ?? 0
return `${(rate * 100).toFixed(1)}%`
})

const platformRanking = computed(() =>
[...(baseStats.value?.platform ?? [])]
.sort((left, right) => right.count - left.count)
.slice(0, 6)
)

const startTimeLabel = computed(() =>
formatDateTime(baseStats.value?.start_time ?? 0)
)

const providerChartColors = computed(() =>
isDark.value
? [
'#6F8FAF',
'#7E9A73',
'#A78468',
'#8A78A8',
'#6B9995',
'#B07A87',
'#8C8F62',
'#7C8798'
]
: [
'#5F7E9B',
'#708865',
'#9A7557',
'#786696',
'#5D8985',
'#9C6674',
'#80844F',
'#69788D'
]
)

const messageChartOptions = computed<ApexOptions>(() => ({
chart: {
background: 'transparent',
toolbar: { show: false },
zoom: { enabled: false },
fontFamily: '"SF Pro Display", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
},
theme: {
mode: isDark.value ? 'dark' : 'light'
},
colors: [themePalette.value.primary],
stroke: {
curve: 'smooth',
width: 2.4
},
fill: {
type: 'solid',
opacity: 0.12
},
grid: {
borderColor: hexToRgba(themePalette.value.border, isDark.value ? 0.4 : 0.26),
strokeDashArray: 0
},
dataLabels: { enabled: false },
xaxis: {
type: 'datetime',
labels: {
datetimeUTC: false,
style: { colors: themePalette.value.mutedText }
},
axisBorder: { color: hexToRgba(themePalette.value.border, isDark.value ? 0.4 : 0.26) },
axisTicks: { color: hexToRgba(themePalette.value.border, isDark.value ? 0.4 : 0.26) }
},
yaxis: {
labels: {
formatter: (value) => formatCompactNumber(Number(value)),
style: { colors: themePalette.value.mutedText }
}
},
tooltip: {
theme: isDark.value ? 'dark' : 'light',
x: {
format: 'MM/dd HH:mm'
}
},
legend: { show: false }
}))

const providerChartOptions = computed<ApexOptions>(() => ({
chart: {
background: 'transparent',
toolbar: { show: false },
zoom: { enabled: false },
stacked: true,
fontFamily: '"SF Pro Display", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
},
theme: {
mode: isDark.value ? 'dark' : 'light'
},
plotOptions: {
bar: {
horizontal: false,
borderRadius: 4,
columnWidth: '58%'
}
},
colors: providerChartColors.value,
dataLabels: { enabled: false },
grid: {
borderColor: hexToRgba(themePalette.value.border, isDark.value ? 0.4 : 0.26)
},
xaxis: {
type: 'datetime',
labels: {
datetimeUTC: false,
style: { colors: themePalette.value.mutedText }
},
axisBorder: { color: hexToRgba(themePalette.value.border, isDark.value ? 0.4 : 0.26) },
axisTicks: { color: hexToRgba(themePalette.value.border, isDark.value ? 0.4 : 0.26) }
},
yaxis: {
labels: {
formatter: (value) => formatCompactNumber(Number(value)),
style: { colors: themePalette.value.mutedText }
}
},
tooltip: {
theme: isDark.value ? 'dark' : 'light',
x: {
format: 'MM/dd HH:mm'
}
},
legend: {
position: 'top',
horizontalAlign: 'left',
labels: {
colors: themePalette.value.mutedText
}
}
}))

watch(selectedRange, async () => {
try {
await Promise.all([fetchBaseStats(), fetchProviderStats()])
lastUpdatedAt.value = new Date()
} catch (error) {
console.error('Failed to refresh stats range:', error)
errorMessage.value = t('errors.rangeFailed')
}
})

onMounted(async () => {
await refreshStats()
refreshTimer = window.setInterval(() => {
void refreshStats()
}, 60_000)
})

onBeforeUnmount(() => {
if (refreshTimer !== null) {
window.clearInterval(refreshTimer)
}
})
</script>

<style scoped>
.stats-page {
--stats-bg: rgb(var(--v-theme-background));
--stats-surface: rgb(var(--v-theme-surface));
--stats-text: rgb(var(--v-theme-on-surface));
--stats-muted: rgba(var(--v-theme-on-surface), 0.68);
--stats-subtle: rgba(var(--v-theme-on-surface), 0.56);
--stats-border: rgba(var(--v-theme-on-surface), 0.1);
--stats-border-strong: rgba(var(--v-theme-on-surface), 0.14);
--stats-soft: rgba(var(--v-theme-primary), 0.08);
--stats-soft-strong: rgba(var(--v-theme-primary), 0.14);
--stats-shadow: 0 12px 40px rgba(var(--v-theme-on-surface), 0.04);
min-height: 100%;
background: var(--stats-bg);
}

.stats-page.is-dark {
--stats-border: rgba(var(--v-theme-on-surface), 0.14);
--stats-border-strong: rgba(var(--v-theme-on-surface), 0.18);
--stats-soft: rgba(var(--v-theme-primary), 0.12);
--stats-soft-strong: rgba(var(--v-theme-primary), 0.2);
--stats-shadow: none;
}

.stats-shell {
max-width: 1560px;
margin: 0 auto;
color: var(--stats-text);
font-family: "SF Pro Display", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

.stats-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 24px;
margin-bottom: 24px;
}

.eyebrow {
margin-bottom: 8px;
color: var(--stats-subtle);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}

.stats-title {
margin: 0;
font-size: clamp(34px, 4vw, 46px);
line-height: 1.04;
font-weight: 700;
letter-spacing: -0.04em;
}

.stats-subtitle {
margin: 10px 0 0;
color: var(--stats-muted);
font-size: 15px;
}

.stats-page.is-dark .eyebrow,
.stats-page.is-dark .stats-subtitle,
.stats-page.is-dark .metric-label,
.stats-page.is-dark .section-subtitle,
.stats-page.is-dark .card-note,
.stats-page.is-dark .empty-state {
color: var(--stats-muted);
}

.header-meta {
display: flex;
gap: 12px;
}

.meta-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border: 1px solid var(--stats-border);
border-radius: 999px;
background: var(--stats-surface);
color: var(--stats-muted);
font-size: 13px;
}

.stats-page.is-dark .meta-pill {
border-color: var(--stats-border-strong);
background: var(--stats-surface);
color: var(--stats-muted);
}

.loading-wrap {
display: flex;
justify-content: center;
padding: 80px 0;
}

.overview-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
}

.panel-grid,
.token-grid {
display: grid;
grid-template-columns: 1.6fr 0.9fr;
gap: 20px;
margin-bottom: 20px;
align-items: stretch;
}

.panel-grid > *,
.token-grid > * {
min-width: 0;
width: 100%;
}

.token-side-column {
display: grid;
grid-template-rows: auto 1fr;
gap: 20px;
min-width: 0;
width: 100%;
}

.stat-card {
border: 1px solid var(--stats-border);
border-radius: 28px;
background: var(--stats-surface);
box-shadow: var(--stats-shadow);
}

.stats-page.is-dark .stat-card {
border-color: var(--stats-border-strong);
background: var(--stats-surface);
box-shadow: var(--stats-shadow);
}

.overview-card {
padding: 20px 20px 18px;
}

.card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 12px;
background: var(--stats-soft);
color: rgb(var(--v-theme-primary));
}

.stats-page.is-dark .card-icon {
background: var(--stats-soft-strong);
color: rgb(var(--v-theme-primary));
}

.card-label {
margin-top: 8px;
color: var(--stats-muted);
font-size: 13px;
font-weight: 500;
}

.stats-page.is-dark .card-label,
.stats-page.is-dark .system-row,
.stats-page.is-dark .system-meta-item,
.stats-page.is-dark .provider-name {
color: var(--stats-muted);
}

.card-value {
margin-top: 8px;
font-size: clamp(24px, 2vw, 34px);
line-height: 1.1;
font-weight: 700;
letter-spacing: -0.03em;
}

.card-note {
margin-top: 8px;
color: var(--stats-subtle);
font-size: 12px;
line-height: 1.5;
}

.chart-card,
.system-card,
.provider-list-card,
.token-total-card {
padding: 22px;
}

.card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
margin-bottom: 18px;
}

.section-toolbar {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-end;
margin-bottom: 16px;
}

.section-toolbar .section-subtitle {
max-width: 680px;
}

.card-head-actions {
display: flex;
align-items: flex-start;
justify-content: flex-end;
gap: 14px;
flex-wrap: wrap;
}

.card-head.compact {
margin-bottom: 14px;
}

.section-title {
font-size: 19px;
font-weight: 650;
letter-spacing: -0.02em;
}

.section-subtitle {
margin-top: 6px;
color: var(--stats-muted);
font-size: 13px;
}

.section-metric {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}

.metric-label {
color: var(--stats-subtle);
font-size: 12px;
}

.metric-value {
font-size: 22px;
font-weight: 650;
}

.system-metric + .system-metric {
margin-top: 18px;
}

.system-row,
.system-meta-item,
.provider-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}

.system-row {
margin-bottom: 10px;
color: var(--stats-muted);
font-size: 14px;
}

.system-meta-list {
margin-top: 20px;
border-top: 1px solid var(--stats-border);
padding-top: 14px;
}

.stats-page.is-dark .system-meta-list {
border-top-color: var(--stats-border-strong);
}

.system-meta-item {
padding: 10px 0;
color: var(--stats-muted);
font-size: 14px;
}

.system-meta-item + .system-meta-item {
border-top: 1px solid var(--stats-border);
}

.stats-page.is-dark .system-meta-item + .system-meta-item,
.stats-page.is-dark .provider-row {
border-color: var(--stats-border-strong);
}

.token-section-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
margin-bottom: 16px;
}

.range-switch {
display: inline-flex;
gap: 8px;
padding: 6px;
border: 1px solid var(--stats-border);
border-radius: 999px;
background: var(--stats-surface);
}

.stats-page.is-dark .range-switch {
border-color: var(--stats-border-strong);
background: var(--stats-surface);
}

.range-chip {
border: 0;
border-radius: 999px;
background: transparent;
color: var(--stats-muted);
padding: 9px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.18s ease, color 0.18s ease;
}

.range-chip.active {
background: var(--stats-soft);
color: rgb(var(--v-theme-primary));
}

.stats-page.is-dark .range-chip {
color: var(--stats-muted);
}

.stats-page.is-dark .range-chip.active {
background: var(--stats-soft-strong);
color: rgb(var(--v-theme-primary));
}

.token-total-card {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 170px;
width: 100%;
}

.provider-trend-card {
min-height: 520px;
}

.provider-list-card {
width: 100%;
}

.token-total-value {
margin-top: 10px;
font-size: clamp(32px, 3vw, 44px);
line-height: 1.02;
font-weight: 700;
}

.token-meta-list {
margin-top: 18px;
border-top: 1px solid var(--stats-border);
padding-top: 14px;
display: grid;
gap: 10px;
}

.token-meta-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
color: var(--stats-muted);
font-size: 14px;
}

.provider-list {
display: grid;
gap: 12px;
}

.provider-list--scrollable {
max-height: 296px;
overflow-y: auto;
padding-right: 6px;
}

.provider-row {
padding: 12px 0;
border-bottom: 1px solid var(--stats-border);
font-size: 14px;
}

.provider-row:last-child {
border-bottom: 0;
}

.provider-name {
color: var(--stats-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.empty-state {
color: var(--stats-muted);
font-size: 14px;
}

.empty-state.large {
padding: 56px 0;
text-align: center;
}

@media (max-width: 1400px) {
.overview-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}

@media (max-width: 1080px) {
.panel-grid,
.token-grid {
grid-template-columns: 1fr;
}
}

@media (max-width: 900px) {
.overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}

.stats-header,
.token-section-head {
flex-direction: column;
align-items: flex-start;
}

.section-toolbar {
justify-content: flex-start;
align-items: flex-start;
flex-direction: column;
}

.card-head,
.card-head-actions {
flex-direction: column;
align-items: flex-start;
}
}

@media (max-width: 640px) {
.overview-grid {
grid-template-columns: 1fr;
}

.stats-shell {
padding-left: 12px !important;
padding-right: 12px !important;
}

.chart-card,
.system-card,
.provider-list-card,
.token-total-card {
padding: 18px;
border-radius: 22px;
}
}
</style>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This Vue component is very large, containing all the logic for data fetching, state management, formatting, and rendering for the entire page. This can make it difficult to maintain and test.

To improve maintainability, consider breaking it down into smaller, more focused child components. For example:

  • A component for the overview cards grid.
  • A component for the message trend chart.
  • A component for the provider token trend chart.
  • Reusable components for the ranking lists.

You could use a global state management solution (like Pinia) or provide/inject to manage the shared state (like baseStats, providerStats, selectedRange) and pass down only the necessary props to each child component.

This refactoring would make the code easier to navigate, understand, and test.

@Soulter Soulter merged commit 2b435e0 into master Mar 29, 2026
7 checks passed
@Soulter Soulter deleted the feat/new-stastics branch March 29, 2026 14:50
@dosubot
Copy link
Copy Markdown

dosubot bot commented Mar 29, 2026

Documentation Updates

1 document(s) were updated by changes in this PR:

pr4697的改动
View Changes
@@ -657,6 +657,63 @@
 - 后台任务需正确设置 `background_task: true` 参数
 - 后台任务执行通过 `HandoffExecutor.execute_queued_task()` 方法完成,该方法从数据库中恢复任务上下文并执行
 
+#### 提供商统计数据自动收集(PR #7152)
+
+[PR #7152](https://github.com/AstrBotDevs/AstrBot/pull/7152) 引入了提供商统计数据的自动收集机制,用于追踪内置 Agent 执行期间的令牌使用量、性能指标和 API 调用状态。
+
+**数据库模型(ProviderStat 表):**
+
+新增 `ProviderStat` 表,用于存储每次内置 Agent 执行的提供商统计数据,包含以下字段:
+
+- **`agent_type`**:Agent 类型(默认为 "internal"),索引字段
+- **`status`**:请求状态(如 "completed"、"error"、"aborted"),索引字段
+- **`umo`**:统一消息来源(Unified Message Origin)标识符,索引字段
+- **`conversation_id`**:关联的对话 ID,可选,索引字段
+- **`provider_id`**:提供商标识符(如 "openai"、"anthropic"),索引字段
+- **`provider_model`**:使用的模型名称(如 "gpt-4o"、"claude-3-opus"),可选,索引字段
+- **`token_input_other`**:非缓存输入令牌数(默认 0)
+- **`token_input_cached`**:缓存输入令牌数(默认 0)
+- **`token_output`**:输出令牌数(默认 0)
+- **`start_time`**:请求开始时间戳(秒,默认 0.0)
+- **`end_time`**:请求结束时间戳(秒,默认 0.0)
+- **`time_to_first_token`**:首个令牌响应时间(秒,默认 0.0)
+
+**自动统计收集:**
+
+在内置 Agent 执行完成后,系统会自动调用 `_record_internal_agent_stats()` 函数(位于 `astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py`),将统计数据持久化到数据库:
+
+- **采集时机**:Agent 执行完成后,通过 `asyncio.create_task()` 异步记录统计数据,不阻塞用户响应
+- **状态判断**:
+  - 任务被用户中止:`status="aborted"`
+  - LLM 返回错误:`status="error"`
+  - 正常完成:`status="completed"`
+- **数据来源**:从 `AgentRunner.stats` 对象中提取令牌使用量和性能指标
+- **错误容错**:统计记录失败时记录警告日志,不影响用户对话流程
+
+**数据库操作方法:**
+
+新增 `insert_provider_stat()` 方法(位于 `astrbot/core/db/sqlite.py`),用于插入提供商统计记录:
+
+```python
+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",
+)
+```
+
+该方法自动从 `stats` 字典中提取 `token_usage`、`start_time`、`end_time` 和 `time_to_first_token` 字段,并转换为数值类型存储。
+
+**数据用途:**
+
+- 支持统计数据页面(StatsPage.vue)展示令牌消耗趋势和模型使用统计
+- 通过 `/api/stat/provider-tokens` API 端点查询和聚合统计数据
+- 为用户提供详细的 API 调用成本分析和性能监控能力
+
 #### Neo 技能生命周期工具(PR #5028)
 
 > ⚠️ **REVERTED**: 本节描述的所有 Neo 技能生命周期管理工具和功能已在 [PR #5624](https://github.com/AstrBotDevs/AstrBot/pull/5624) 中被完全移除,以下内容仅供历史参考。
@@ -721,6 +778,27 @@
 
 主代理和子代理的工具分配逻辑在 UI 中有清晰展示,支持灵活配置。
 
+#### 统计数据页面(StatsPage.vue)
+
+[PR #7152](https://github.com/AstrBotDevs/AstrBot/pull/7152) 引入了全新的统计数据页面(StatsPage.vue),提供系统活动和模型使用的全面概览。该页面现已替代旧的 DefaultDashboard.vue,并在路由中作为默认仪表板页面(`/dashboard/default`)。
+
+**核心功能:**
+
+- **消息概览与趋势**:展示消息量的时间序列变化和统计数据
+- **模型调用统计与趋势**:显示不同 LLM 提供商的 API 调用次数和趋势
+- **令牌消耗趋势(按提供商)**:按提供商分类显示令牌使用量的时间序列数据
+- **会话排名**:列出最活跃的会话和用户
+- **平台统计**:展示不同平台(QQ、Telegram、Discord 等)的消息分布
+- **内存使用监控**:实时显示系统内存占用情况
+- **国际化支持**:完整支持中文(zh-CN)、英文(en-US)和俄语(ru-RU)
+
+**技术实现:**
+
+- 时间范围选择器支持 1 天、3 天和 7 天的数据查询
+- 交互式图表展示趋势数据(基于时间序列的折线图)
+- 多指标仪表板卡片展示关键性能指标(KPI)
+- 响应式设计适配不同屏幕尺寸
+
 #### 定时任务管理
 新增 Cron Job 管理页面(CronJobPage.vue),支持:
 
@@ -729,6 +807,86 @@
 
 #### API 支持
 新增 CronRoute 等 API 路由,支持通过 API 管理定时任务。
+
+##### 提供商令牌统计 API(`/stat/provider-tokens`)
+
+[PR #7152](https://github.com/AstrBotDevs/AstrBot/pull/7152) 新增了提供商令牌统计 API 端点,为统计数据页面提供详细的模型使用数据。
+
+**端点信息:**
+- **路径**:`GET /api/stat/provider-tokens`
+- **查询参数**:
+  - `days`:查询天数,可选值为 1、3 或 7,默认值为 1
+
+**返回数据结构:**
+
+```json
+{
+  "days": 1,
+  "trend": {
+    "series": [
+      {
+        "name": "provider_id",
+        "data": [[timestamp_ms, tokens], ...],
+        "total_tokens": 12345
+      }
+    ],
+    "total_series": [[timestamp_ms, total_tokens], ...]
+  },
+  "range_total_tokens": 12345,
+  "range_total_calls": 100,
+  "range_avg_ttft_ms": 123.45,
+  "range_avg_duration_ms": 456.78,
+  "range_avg_tpm": 789.12,
+  "range_success_rate": 0.95,
+  "range_by_provider": [
+    {"provider_id": "openai", "tokens": 5000},
+    {"provider_id": "anthropic", "tokens": 3000}
+  ],
+  "range_by_umo": [
+    {"umo": "user123", "tokens": 4000},
+    {"umo": "user456", "tokens": 2000}
+  ],
+  "today_total_tokens": 6789,
+  "today_total_calls": 50,
+  "today_by_model": [
+    {"provider_model": "gpt-4o", "tokens": 3000},
+    {"provider_model": "claude-3-opus", "tokens": 2000}
+  ],
+  "today_by_provider": [
+    {"provider_id": "openai", "tokens": 3500},
+    {"provider_id": "anthropic", "tokens": 2000}
+  ]
+}
+```
+
+**数据字段说明:**
+
+- **趋势数据(trend)**:
+  - `series`:按提供商分类的时间序列数据,每个数据点为 `[时间戳(毫秒), 令牌数]`
+  - `total_series`:所有提供商汇总的时间序列数据
+
+- **时间范围统计(range_*)**:
+  - `range_total_tokens`:选定时间范围内的总令牌消耗
+  - `range_total_calls`:选定时间范围内的总 API 调用次数
+  - `range_avg_ttft_ms`:首个令牌平均响应时间(毫秒)
+  - `range_avg_duration_ms`:请求平均持续时间(毫秒)
+  - `range_avg_tpm`:平均每分钟令牌数(TPM)
+  - `range_success_rate`:API 调用成功率(0-1 之间)
+  - `range_by_provider`:按提供商分类的令牌消耗排名
+  - `range_by_umo`:按统一消息来源(UMO)分类的令牌消耗排名
+
+- **今日统计(today_*)**:
+  - `today_total_tokens`:今日总令牌消耗
+  - `today_total_calls`:今日总 API 调用次数
+  - `today_by_model`:今日按模型分类的令牌消耗排名
+  - `today_by_provider`:今日按提供商分类的令牌消耗排名
+
+**技术实现要点:**
+
+- 数据按小时聚合(hourly buckets),提供精细的时间趋势
+- 自动处理时区转换,确保本地时间和 UTC 时间的正确映射
+- 支持空数据场景,当无数据时返回零值而非错误
+- 所有排名列表按令牌消耗降序排列
 
 #### Neo 技能管理 UI(PR #5028)
 

How did I do? Any feedback?  Join Discord

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:webui The bug / feature is about webui(dashboard) of astrbot. size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant