feat: 增加token 使用统计

This commit is contained in:
ViperEkura 2026-03-24 16:55:07 +08:00
parent 8927681b6e
commit 87e56ed1c3
6 changed files with 584 additions and 5 deletions

View File

@ -26,7 +26,7 @@ def create_app():
db.init_app(app)
from .models import User, Conversation, Message
from .models import User, Conversation, Message, TokenUsage
from .routes import register_routes
register_routes(app)

View File

@ -44,3 +44,20 @@ class Message(db.Model):
token_count = db.Column(db.Integer, default=0)
thinking_content = db.Column(db.Text, default="")
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class TokenUsage(db.Model):
__tablename__ = "token_usage"
id = db.Column(db.BigInteger, primary_key=True, autoincrement=True)
user_id = db.Column(db.BigInteger, db.ForeignKey("users.id"), nullable=False)
date = db.Column(db.Date, nullable=False) # 使用日期
model = db.Column(db.String(64), nullable=False) # 模型名称
prompt_tokens = db.Column(db.Integer, default=0)
completion_tokens = db.Column(db.Integer, default=0)
total_tokens = db.Column(db.Integer, default=0)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
__table_args__ = (
db.UniqueConstraint("user_id", "date", "model", name="uq_user_date_model"),
)

View File

@ -5,7 +5,7 @@ import requests
from datetime import datetime
from flask import request, jsonify, Response, Blueprint, current_app
from . import db
from .models import Conversation, Message, User
from .models import Conversation, Message, User, TokenUsage
from . import load_config
bp = Blueprint("api", __name__)
@ -50,6 +50,30 @@ def to_dict(inst, **extra):
return d
def record_token_usage(user_id, model, prompt_tokens, completion_tokens):
"""记录 token 使用量"""
from datetime import date
today = date.today()
usage = TokenUsage.query.filter_by(
user_id=user_id, date=today, model=model
).first()
if usage:
usage.prompt_tokens += prompt_tokens
usage.completion_tokens += completion_tokens
usage.total_tokens += prompt_tokens + completion_tokens
else:
usage = TokenUsage(
user_id=user_id,
date=today,
model=model,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
)
db.session.add(usage)
db.session.commit()
def build_glm_messages(conv):
msgs = []
if conv.system_prompt:
@ -67,6 +91,102 @@ def list_models():
return ok(MODELS)
# -- Token Usage Statistics --------------------------------
@bp.route("/api/stats/tokens", methods=["GET"])
def token_stats():
"""获取 token 使用统计"""
from sqlalchemy import func
from datetime import date, timedelta
user = get_or_create_default_user()
period = request.args.get("period", "daily") # daily, weekly, monthly
today = date.today()
if period == "daily":
# 今日统计
stats = TokenUsage.query.filter_by(user_id=user.id, date=today).all()
result = {
"period": "daily",
"date": today.isoformat(),
"prompt_tokens": sum(s.prompt_tokens for s in stats),
"completion_tokens": sum(s.completion_tokens for s in stats),
"total_tokens": sum(s.total_tokens for s in stats),
"by_model": {s.model: {"prompt": s.prompt_tokens, "completion": s.completion_tokens, "total": s.total_tokens} for s in stats}
}
elif period == "weekly":
# 本周统计 (最近7天)
start_date = today - timedelta(days=6)
stats = TokenUsage.query.filter(
TokenUsage.user_id == user.id,
TokenUsage.date >= start_date,
TokenUsage.date <= today
).all()
daily_data = {}
for s in stats:
d = s.date.isoformat()
if d not in daily_data:
daily_data[d] = {"prompt": 0, "completion": 0, "total": 0}
daily_data[d]["prompt"] += s.prompt_tokens
daily_data[d]["completion"] += s.completion_tokens
daily_data[d]["total"] += s.total_tokens
# 填充没有数据的日期
for i in range(7):
d = (today - timedelta(days=6-i)).isoformat()
if d not in daily_data:
daily_data[d] = {"prompt": 0, "completion": 0, "total": 0}
result = {
"period": "weekly",
"start_date": start_date.isoformat(),
"end_date": today.isoformat(),
"prompt_tokens": sum(s.prompt_tokens for s in stats),
"completion_tokens": sum(s.completion_tokens for s in stats),
"total_tokens": sum(s.total_tokens for s in stats),
"daily": daily_data
}
elif period == "monthly":
# 本月统计 (最近30天)
start_date = today - timedelta(days=29)
stats = TokenUsage.query.filter(
TokenUsage.user_id == user.id,
TokenUsage.date >= start_date,
TokenUsage.date <= today
).all()
daily_data = {}
for s in stats:
d = s.date.isoformat()
if d not in daily_data:
daily_data[d] = {"prompt": 0, "completion": 0, "total": 0}
daily_data[d]["prompt"] += s.prompt_tokens
daily_data[d]["completion"] += s.completion_tokens
daily_data[d]["total"] += s.total_tokens
# 填充没有数据的日期
for i in range(30):
d = (today - timedelta(days=29-i)).isoformat()
if d not in daily_data:
daily_data[d] = {"prompt": 0, "completion": 0, "total": 0}
result = {
"period": "monthly",
"start_date": start_date.isoformat(),
"end_date": today.isoformat(),
"prompt_tokens": sum(s.prompt_tokens for s in stats),
"completion_tokens": sum(s.completion_tokens for s in stats),
"total_tokens": sum(s.total_tokens for s in stats),
"daily": daily_data
}
else:
return err(400, "invalid period")
return ok(result)
# -- Conversation CRUD ------------------------------------
@bp.route("/api/conversations", methods=["GET", "POST"])
@ -209,31 +329,40 @@ def _sync_response(conv):
choice = result["choices"][0]
usage = result.get("usage", {})
prompt_tokens = usage.get("prompt_tokens", 0)
completion_tokens = usage.get("completion_tokens", 0)
msg = Message(
id=str(uuid.uuid4()), conversation_id=conv.id, role="assistant",
content=choice["message"]["content"],
token_count=usage.get("completion_tokens", 0),
token_count=completion_tokens,
thinking_content=choice["message"].get("reasoning_content", ""),
)
db.session.add(msg)
db.session.commit()
# 记录 token 使用
user = get_or_create_default_user()
record_token_usage(user.id, conv.model, prompt_tokens, completion_tokens)
return ok({
"message": to_dict(msg, thinking_content=msg.thinking_content or None),
"usage": {"prompt_tokens": usage.get("prompt_tokens", 0),
"completion_tokens": usage.get("completion_tokens", 0),
"usage": {"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": usage.get("total_tokens", 0)},
})
def _stream_response(conv):
conv_id = conv.id
conv_model = conv.model
app = current_app._get_current_object()
def generate():
full_content = ""
full_thinking = ""
token_count = 0
prompt_tokens = 0
msg_id = str(uuid.uuid4())
try:
@ -267,6 +396,7 @@ def _stream_response(conv):
usage = chunk.get("usage", {})
if usage:
token_count = usage.get("completion_tokens", 0)
prompt_tokens = usage.get("prompt_tokens", 0)
except Exception as e:
yield f"event: error\ndata: {json.dumps({'content': str(e)}, ensure_ascii=False)}\n\n"
return
@ -280,6 +410,10 @@ def _stream_response(conv):
db.session.add(msg)
db.session.commit()
# 记录 token 使用
user = get_or_create_default_user()
record_token_usage(user.id, conv_model, prompt_tokens, token_count)
yield f"event: done\ndata: {json.dumps({'message_id': msg_id, 'token_count': token_count})}\n\n"
return Response(generate(), mimetype="text/event-stream",

View File

@ -19,6 +19,12 @@ export const modelApi = {
},
}
export const statsApi = {
getTokens(period = 'daily') {
return request(`/stats/tokens?period=${period}`)
},
}
export const conversationApi = {
list(cursor, limit = 20) {
const params = new URLSearchParams()

View File

@ -101,6 +101,10 @@
<button class="btn-cancel" @click="$emit('close')">取消</button>
<button class="btn-save" @click="save">保存</button>
</div>
<div class="settings-stats">
<StatsPanel />
</div>
</div>
</div>
</Transition>
@ -110,6 +114,7 @@
import { reactive, ref, watch, onMounted } from 'vue'
import { modelApi } from '../api'
import { useTheme } from '../composables/useTheme'
import StatsPanel from './StatsPanel.vue'
const props = defineProps({
visible: { type: Boolean, default: false },
@ -392,6 +397,12 @@ function save() {
background: var(--accent-primary-hover);
}
.settings-stats {
padding: 16px 24px 24px;
border-top: 1px solid var(--border-light);
background: var(--bg-secondary);
}
.slide-enter-active,
.slide-leave-active {
transition: transform 0.25s ease;

View File

@ -0,0 +1,411 @@
<template>
<div class="stats-panel">
<div class="stats-header">
<h4>Token 使用统计</h4>
<div class="period-tabs">
<button
v-for="p in periods"
:key="p.value"
:class="['tab', { active: period === p.value }]"
@click="changePeriod(p.value)"
>
{{ p.label }}
</button>
</div>
</div>
<div v-if="loading" class="stats-loading">加载中...</div>
<template v-else-if="stats">
<div class="stats-summary">
<div class="stat-card">
<div class="stat-label">输入 Token</div>
<div class="stat-value">{{ formatNumber(stats.prompt_tokens) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">输出 Token</div>
<div class="stat-value">{{ formatNumber(stats.completion_tokens) }}</div>
</div>
<div class="stat-card highlight">
<div class="stat-label">总计</div>
<div class="stat-value">{{ formatNumber(stats.total_tokens) }}</div>
</div>
</div>
<div v-if="period !== 'daily' && stats.daily" class="stats-chart">
<div class="chart-title">每日使用趋势</div>
<div class="chart-container">
<svg class="line-chart" :viewBox="`0 0 ${chartWidth} ${chartHeight}`">
<!-- 网格线 -->
<g class="grid-lines">
<line
v-for="i in 4"
:key="'grid-' + i"
:x1="padding"
:y1="padding + (chartHeight - 2 * padding) * (i - 1) / 4"
:x2="chartWidth - padding"
:y2="padding + (chartHeight - 2 * padding) * (i - 1) / 4"
stroke="var(--border-light)"
stroke-dasharray="4,4"
/>
</g>
<!-- 填充区域 -->
<path
:d="areaPath"
fill="url(#gradient)"
opacity="0.3"
/>
<!-- 折线 -->
<path
:d="linePath"
fill="none"
stroke="var(--accent-primary)"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- 数据点 -->
<g class="data-points">
<circle
v-for="(point, idx) in chartPoints"
:key="idx"
:cx="point.x"
:cy="point.y"
r="4"
fill="var(--accent-primary)"
stroke="var(--bg-primary)"
stroke-width="2"
class="data-point"
@mouseenter="hoveredPoint = idx"
@mouseleave="hoveredPoint = null"
/>
</g>
<!-- 渐变定义 -->
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="var(--accent-primary)" />
<stop offset="100%" stop-color="var(--accent-primary)" stop-opacity="0" />
</linearGradient>
</defs>
</svg>
<!-- X轴标签 -->
<div class="x-labels">
<span
v-for="(data, date) in sortedDaily"
:key="date"
class="x-label"
>
{{ formatDateLabel(date) }}
</span>
</div>
<!-- 悬浮提示 -->
<div
v-if="hoveredPoint !== null && chartPoints[hoveredPoint]"
class="tooltip"
:style="{ left: chartPoints[hoveredPoint].x + 'px', top: (chartPoints[hoveredPoint].y - 40) + 'px' }"
>
<div class="tooltip-date">{{ chartPoints[hoveredPoint].date }}</div>
<div class="tooltip-value">{{ formatNumber(chartPoints[hoveredPoint].value) }} tokens</div>
</div>
</div>
</div>
<div v-if="period === 'daily' && stats.by_model" class="stats-by-model">
<div class="model-title">按模型分布</div>
<div v-for="(data, model) in stats.by_model" :key="model" class="model-row">
<span class="model-name">{{ model }}</span>
<span class="model-value">{{ formatNumber(data.total) }}</span>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { statsApi } from '../api'
const periods = [
{ value: 'daily', label: '今日' },
{ value: 'weekly', label: '本周' },
{ value: 'monthly', label: '本月' },
]
const period = ref('daily')
const stats = ref(null)
const loading = ref(false)
const hoveredPoint = ref(null)
const chartWidth = 320
const chartHeight = 160
const padding = 20
const sortedDaily = computed(() => {
if (!stats.value?.daily) return {}
const entries = Object.entries(stats.value.daily)
entries.sort((a, b) => a[0].localeCompare(b[0]))
return Object.fromEntries(entries)
})
const chartData = computed(() => {
const data = sortedDaily.value
return Object.entries(data).map(([date, val]) => ({
date,
value: val.total,
prompt: val.prompt,
completion: val.completion,
}))
})
const maxValue = computed(() => {
if (chartData.value.length === 0) return 100
return Math.max(100, ...chartData.value.map(d => d.value))
})
const chartPoints = computed(() => {
const data = chartData.value
if (data.length === 0) return []
const xRange = chartWidth - 2 * padding
const yRange = chartHeight - 2 * padding
return data.map((d, i) => ({
x: padding + (i / Math.max(1, data.length - 1)) * xRange,
y: chartHeight - padding - (d.value / maxValue.value) * yRange,
date: d.date,
value: d.value,
}))
})
const linePath = computed(() => {
const points = chartPoints.value
if (points.length === 0) return ''
return points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
})
const areaPath = computed(() => {
const points = chartPoints.value
if (points.length === 0) return ''
const xRange = chartWidth - 2 * padding
const startX = padding
const endX = chartWidth - padding
const baseY = chartHeight - padding
let path = `M ${startX} ${baseY} `
path += points.map(p => `L ${p.x} ${p.y}`).join(' ')
path += ` L ${endX} ${baseY} Z`
return path
})
function formatDateLabel(dateStr) {
const d = new Date(dateStr)
return `${d.getMonth() + 1}/${d.getDate()}`
}
function formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
return num.toString()
}
async function loadStats() {
loading.value = true
try {
const res = await statsApi.getTokens(period.value)
stats.value = res.data
} catch (e) {
console.error('Failed to load stats:', e)
} finally {
loading.value = false
}
}
function changePeriod(p) {
period.value = p
loadStats()
}
onMounted(loadStats)
</script>
<style scoped>
.stats-panel {
padding: 16px 0;
}
.stats-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.stats-header h4 {
margin: 0;
font-size: 14px;
color: var(--text-primary);
}
.period-tabs {
display: flex;
gap: 4px;
background: var(--bg-input);
padding: 3px;
border-radius: 8px;
}
.tab {
padding: 4px 12px;
border: none;
background: none;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
border-radius: 6px;
transition: all 0.15s;
}
.tab.active {
background: var(--accent-primary);
color: white;
}
.stats-loading {
text-align: center;
padding: 20px;
color: var(--text-tertiary);
}
.stats-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat-card {
background: var(--bg-input);
border-radius: 8px;
padding: 12px;
text-align: center;
}
.stat-card.highlight {
background: var(--accent-primary-light);
}
.stat-label {
font-size: 11px;
color: var(--text-tertiary);
margin-bottom: 4px;
}
.stat-value {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.stat-card.highlight .stat-value {
color: var(--accent-primary);
}
.stats-chart {
margin-top: 16px;
}
.chart-title,
.model-title {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 12px;
}
.chart-container {
background: var(--bg-input);
border-radius: 8px;
padding: 16px;
position: relative;
}
.line-chart {
width: 100%;
height: 160px;
}
.data-point {
cursor: pointer;
transition: r 0.15s;
}
.data-point:hover {
r: 6;
}
.x-labels {
display: flex;
justify-content: space-between;
margin-top: 8px;
padding: 0 20px;
}
.x-label {
font-size: 10px;
color: var(--text-tertiary);
}
.tooltip {
position: absolute;
background: var(--bg-primary);
color: var(--text-primary);
padding: 6px 10px;
border-radius: 6px;
font-size: 11px;
pointer-events: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transform: translateX(-50%);
z-index: 10;
}
.tooltip-date {
color: var(--text-tertiary);
font-size: 10px;
}
.tooltip-value {
font-weight: 600;
color: var(--accent-primary);
}
.stats-by-model {
margin-top: 16px;
}
.model-row {
display: flex;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-input);
border-radius: 6px;
margin-bottom: 6px;
}
.model-name {
font-size: 12px;
color: var(--text-secondary);
}
.model-value {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
}
</style>