feat:实现统计图标

This commit is contained in:
sylarchen1389 2025-12-07 22:58:16 +08:00
parent 194f80b787
commit e78488e131
5 changed files with 353 additions and 194 deletions

View file

@ -87,6 +87,7 @@ class DailyStats(BaseModel):
date: str date: str
cost: float cost: float
by_model: dict[str, float] = {} # 按模型分组的消费
class ModelStats(BaseModel): class ModelStats(BaseModel):
@ -102,6 +103,7 @@ class StatsResponse(BaseModel):
daily: list[DailyStats] daily: list[DailyStats]
by_model: list[ModelStats] by_model: list[ModelStats]
models: list[str] = [] # 所有模型列表(用于前端生成堆叠图系列)
#################### ####################
@ -189,41 +191,101 @@ async def get_logs(
@router.get("/stats", response_model=StatsResponse) @router.get("/stats", response_model=StatsResponse)
async def get_stats(user=Depends(get_verified_user), days: int = 7): async def get_stats(
user=Depends(get_verified_user),
days: int = 7,
granularity: str = "day"
):
""" """
查询统计报表 查询统计报表
按日统计和按模型统计 Args:
days: 查询天数
granularity: 时间粒度 (hour/day/month)
需要登录 需要登录
""" """
try: try:
from sqlalchemy import func from sqlalchemy import func
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
with get_db() as db: with get_db() as db:
# cutoff: 纳秒级时间戳 now = datetime.now()
cutoff = int((time.time() - days * 86400) * 1000000000) cutoff = int((time.time() - days * 86400) * 1000000000)
# 按日统计 # 根据粒度选择分组方式和生成完整时间序列(包含当前时段)
daily_query = ( if granularity == "hour":
trunc_unit = "hour"
date_format = "%H:00"
# 生成过去24小时的完整序列包含当前小时
all_periods = []
for i in range(23, -1, -1):
dt = now - timedelta(hours=i)
all_periods.append(dt.replace(minute=0, second=0, microsecond=0))
elif granularity == "month":
trunc_unit = "month"
date_format = "%Y-%m"
# 生成过去12个月的完整序列包含当前月
all_periods = []
for i in range(11, -1, -1):
dt = now - relativedelta(months=i)
all_periods.append(dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0))
else:
# 默认按天分组
trunc_unit = "day"
date_format = "%m-%d"
# 生成过去N天的完整序列包含今天
all_periods = []
for i in range(days - 1, -1, -1):
dt = now - timedelta(days=i)
all_periods.append(dt.replace(hour=0, minute=0, second=0, microsecond=0))
# 按时间+模型分组统计(用于堆叠图)
daily_by_model_query = (
db.query( db.query(
func.date_trunc( func.date_trunc(
"day", trunc_unit,
# created_at 是纳秒级,需要除以 1000000000 转换为秒级
func.to_timestamp(BillingLog.created_at / 1000000000) func.to_timestamp(BillingLog.created_at / 1000000000)
).label("date"), ).label("date"),
BillingLog.model_id,
func.sum(BillingLog.total_cost).label("total"), func.sum(BillingLog.total_cost).label("total"),
) )
.filter( .filter(
BillingLog.user_id == user.id, BillingLog.user_id == user.id,
BillingLog.created_at >= cutoff, BillingLog.created_at >= cutoff,
BillingLog.log_type == "deduct", BillingLog.log_type.in_(["deduct", "settle"]),
) )
.group_by("date") .group_by("date", BillingLog.model_id)
.order_by("date") .order_by("date")
.all() .all()
) )
# 构建数据结构: {date_key: {model_id: cost, ...}, ...}
data_dict: dict[str, dict[str, float]] = {}
all_models: set[str] = set()
for d in daily_by_model_query:
if d[0] and d[1]:
date_key = d[0].strftime(date_format)
model_id = d[1]
cost = d[2] / 10000 if d[2] else 0
all_models.add(model_id)
if date_key not in data_dict:
data_dict[date_key] = {}
data_dict[date_key][model_id] = cost
log.debug(f"统计查询: granularity={granularity}, days={days}, 记录数={len(daily_by_model_query)}, 模型数={len(all_models)}")
# 填充完整时间序列
daily_stats = []
for period in all_periods:
key = period.strftime(date_format)
by_model = data_dict.get(key, {})
total_cost = sum(by_model.values())
daily_stats.append(DailyStats(date=key, cost=total_cost, by_model=by_model))
log.debug(f"生成时间序列: 数量={len(daily_stats)}, 模型列表={list(all_models)}")
# 按模型统计 # 按模型统计
by_model_query = ( by_model_query = (
db.query( db.query(
@ -234,23 +296,20 @@ async def get_stats(user=Depends(get_verified_user), days: int = 7):
.filter( .filter(
BillingLog.user_id == user.id, BillingLog.user_id == user.id,
BillingLog.created_at >= cutoff, BillingLog.created_at >= cutoff,
BillingLog.log_type == "deduct", BillingLog.log_type.in_(["deduct", "settle"]),
) )
.group_by(BillingLog.model_id) .group_by(BillingLog.model_id)
.order_by(func.sum(BillingLog.total_cost).desc()) .order_by(func.sum(BillingLog.total_cost).desc())
.all() .all()
) )
# cost 单位转换:毫 → 元(除以 10000
return StatsResponse( return StatsResponse(
daily=[ daily=daily_stats,
DailyStats(date=str(d[0].date()), cost=d[1] / 10000 if d[1] else 0)
for d in daily_query
],
by_model=[ by_model=[
ModelStats(model=m[0], cost=m[1] / 10000 if m[1] else 0, count=m[2]) ModelStats(model=m[0], cost=m[1] / 10000 if m[1] else 0, count=m[2])
for m in by_model_query for m in by_model_query
], ],
models=sorted(list(all_models)), # 按字母排序的模型列表
) )
except Exception as e: except Exception as e:
log.error(f"查询统计失败: {e}") log.error(f"查询统计失败: {e}")

View file

@ -91,6 +91,7 @@ from open_webui.utils.misc import (
prepend_to_first_user_message_content, prepend_to_first_user_message_content,
convert_logit_bias_input_to_json, convert_logit_bias_input_to_json,
get_content_from_message, get_content_from_message,
merge_consecutive_messages,
) )
from open_webui.utils.tools import get_tools from open_webui.utils.tools import get_tools
from open_webui.utils.plugin import load_function_module_by_id from open_webui.utils.plugin import load_function_module_by_id
@ -1183,6 +1184,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
ordered_messages = [summary_system_message, *ordered_messages] ordered_messages = [summary_system_message, *ordered_messages]
if ordered_messages: if ordered_messages:
# 合并连续的同角色消息,避免 LLM API 报错
ordered_messages = merge_consecutive_messages(ordered_messages)
form_data["messages"] = ordered_messages form_data["messages"] = ordered_messages
# === 2. 处理 System Prompt 变量替换 === # === 2. 处理 System Prompt 变量替换 ===

View file

@ -155,6 +155,69 @@ def remove_system_message(messages: list[dict]) -> list[dict]:
return [message for message in messages if message["role"] != "system"] return [message for message in messages if message["role"] != "system"]
def merge_consecutive_messages(messages: list[dict]) -> list[dict]:
"""
合并连续的同角色消息避免 LLM API 报错
某些 LLM API OpenAI不允许连续的 assistant user 消息
此函数将连续的同角色消息合并为一条并过滤空内容的非 system 消息
:param messages: 消息列表
:return: 合并后的消息列表
"""
if not messages:
return messages
# 先过滤掉空内容的非 system 消息
def is_valid_message(msg: dict) -> bool:
role = msg.get("role", "")
content = msg.get("content", "")
# system 消息保留
if role == "system":
return True
# 检查内容是否为空
if isinstance(content, list):
# 多模态消息:检查是否有非空内容
return any(
item.get("text", "").strip() or item.get("image_url")
for item in content
if isinstance(item, dict)
)
else:
# 文本消息:检查是否为空字符串
return bool(str(content).strip())
filtered = [msg for msg in messages if is_valid_message(msg)]
# 合并连续的同角色消息
merged = []
for msg in filtered:
if not merged:
merged.append({**msg})
continue
last = merged[-1]
# 如果角色相同(且不是 system合并内容
if last.get("role") == msg.get("role") and last.get("role") != "system":
# 获取内容
last_content = last.get("content", "")
msg_content = msg.get("content", "")
# 处理 content 为 list 的情况(多模态消息)
if isinstance(last_content, list) or isinstance(msg_content, list):
# 多模态消息不合并,保持原样
merged.append({**msg})
else:
# 文本消息合并
last["content"] = f"{last_content}\n\n{msg_content}".strip()
else:
merged.append({**msg})
return merged
def pop_system_message(messages: list[dict]) -> tuple[Optional[dict], list[dict]]: def pop_system_message(messages: list[dict]) -> tuple[Optional[dict], list[dict]]:
return get_system_message(messages), remove_system_message(messages) return get_system_message(messages), remove_system_message(messages)

View file

@ -23,6 +23,7 @@ export interface BillingLog {
export interface DailyStats { export interface DailyStats {
date: string; date: string;
cost: number; cost: number;
by_model: Record<string, number>; // 按模型分组的消费
} }
export interface ModelStats { export interface ModelStats {
@ -34,6 +35,7 @@ export interface ModelStats {
export interface BillingStats { export interface BillingStats {
daily: DailyStats[]; daily: DailyStats[];
by_model: ModelStats[]; by_model: ModelStats[];
models: string[]; // 所有模型列表(用于堆叠图)
} }
export interface ModelPricing { export interface ModelPricing {
@ -133,11 +135,12 @@ export const getBillingLogs = async (
*/ */
export const getBillingStats = async ( export const getBillingStats = async (
token: string, token: string,
days: number = 7 days: number = 7,
granularity: string = 'day'
): Promise<BillingStats> => { ): Promise<BillingStats> => {
let error = null; let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/billing/stats?days=${days}`, { const res = await fetch(`${WEBUI_API_BASE_URL}/billing/stats?days=${days}&granularity=${granularity}`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View file

@ -3,176 +3,215 @@
import { billingStats } from '$lib/stores'; import { billingStats } from '$lib/stores';
import { getBillingStats } from '$lib/apis/billing'; import { getBillingStats } from '$lib/apis/billing';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import * as echarts from 'echarts';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
let dailyChartContainer: HTMLDivElement; let chartContainer: HTMLDivElement;
let modelChartContainer: HTMLDivElement; let chart: any = null;
let dailyChart: echarts.ECharts | null = null; let period = '7d'; // 24h, 7d, 30d, 12m
let modelChart: echarts.ECharts | null = null;
let days = 7;
let loading = false; let loading = false;
let showLoading = false; // 延迟显示 loading
let loadingTimer: ReturnType<typeof setTimeout> | null = null;
let echarts: any = null;
// 时间选项配置
const periodOptions = [
{ value: '24h', label: '过去24小时', days: 1, granularity: 'hour' },
{ value: '7d', label: '过去7天', days: 7, granularity: 'day' },
{ value: '30d', label: '过去1个月', days: 30, granularity: 'day' },
{ value: '1y', label: '过去1年', days: 365, granularity: 'month' }
];
$: currentPeriod = periodOptions.find((p) => p.value === period) || periodOptions[1];
const loadStats = async () => { const loadStats = async () => {
loading = true; loading = true;
// 延迟 500ms 后才显示 loading避免快速加载时闪烁
loadingTimer = setTimeout(() => {
if (loading) {
showLoading = true;
}
}, 500);
try { try {
const stats = await getBillingStats(localStorage.token, days); const stats = await getBillingStats(localStorage.token, currentPeriod.days, currentPeriod.granularity);
billingStats.set(stats); billingStats.set(stats);
// 等待 DOM 更新后初始化图表
await tick();
await initCharts();
// 渲染图表
renderCharts();
} catch (error) { } catch (error) {
toast.error($i18n.t('查询统计失败: ') + error.message); toast.error($i18n.t('查询统计失败: ') + error.message);
} finally { } finally {
loading = false; loading = false;
// 清除定时器并隐藏 loading
if (loadingTimer) {
clearTimeout(loadingTimer);
loadingTimer = null;
}
showLoading = false;
// 等待 DOM 更新后渲染图表
await tick();
setTimeout(() => {
if (!chart && chartContainer) {
initChart();
}
renderChart();
}, 50);
} }
}; };
const renderCharts = () => { // 模型颜色配置
if (!$billingStats || !dailyChart || !modelChart) return; const MODEL_COLORS = [
'#4F46E5', // indigo
'#10B981', // emerald
'#F59E0B', // amber
'#EF4444', // red
'#8B5CF6', // violet
'#06B6D4', // cyan
'#EC4899', // pink
'#84CC16', // lime
'#F97316', // orange
'#6366F1', // indigo-light
];
// 按日统计图表 const getModelColor = (index: number) => MODEL_COLORS[index % MODEL_COLORS.length];
if (dailyChart && $billingStats.daily.length > 0) {
dailyChart.setOption({
title: {
text: $i18n.t('每日消费趋势'),
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 'normal'
}
},
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const data = params[0];
return `${data.name}<br/>费用: ¥${data.value.toFixed(4)}`;
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: $billingStats.daily.map((d) => d.date),
axisLabel: {
rotate: 45
}
},
yAxis: {
type: 'value',
name: '费用(元)',
axisLabel: {
formatter: '¥{value}'
}
},
series: [
{
data: $billingStats.daily.map((d) => d.cost),
type: 'line',
smooth: true,
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(59, 130, 246, 0.5)' },
{ offset: 1, color: 'rgba(59, 130, 246, 0.1)' }
])
},
lineStyle: {
color: '#3b82f6',
width: 2
},
itemStyle: {
color: '#3b82f6'
}
}
]
});
}
// 按模型统计图表 const renderChart = () => {
if (modelChart && $billingStats.by_model.length > 0) { if (!$billingStats || !echarts || !chart) return;
modelChart.setOption({
title: { const data = $billingStats.daily || [];
text: $i18n.t('模型消费分布'), const models = $billingStats.models || [];
left: 'center', if (data.length === 0) return;
textStyle: {
fontSize: 16, // 计算Y轴最大值确保至少为1当所有数据为0时
fontWeight: 'normal' const maxCost = Math.max(...data.map((d: any) => d.cost || 0));
const yAxisMax = maxCost > 0 ? undefined : 1;
// 为每个模型创建一个 series
const series = models.length > 0
? models.map((model: string, index: number) => ({
name: model,
type: 'bar',
stack: 'total',
barWidth: '50%',
data: data.map((d: any) => d.by_model?.[model] || 0),
itemStyle: {
color: getModelColor(index),
// 只有最后一个(堆叠顶部)才有圆角
borderRadius: index === models.length - 1 ? [4, 4, 0, 0] : [0, 0, 0, 0]
} }
}, }))
tooltip: { : [{
trigger: 'item', name: '消费',
formatter: (params: any) => { type: 'bar',
return `${params.name}<br/>费用: ¥${params.value.toFixed(4)}<br/>占比: ${params.percent}%`; barWidth: '50%',
data: data.map((d: any) => d.cost),
itemStyle: {
color: '#4F46E5',
borderRadius: [4, 4, 0, 0]
} }
}];
chart.setOption({
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
if (!params || params.length === 0) return '';
const date = params[0].name;
let total = 0;
let details = params
.filter((p: any) => p.value > 0)
.map((p: any) => {
total += p.value;
return `<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${p.color};margin-right:5px;"></span>${p.seriesName}: ¥${p.value.toFixed(4)}`;
})
.join('<br/>');
return `<strong>${date}</strong><br/>${details || '无消费'}<br/><strong>合计: ¥${total.toFixed(4)}</strong>`;
}
},
legend: models.length > 0 ? {
data: models,
bottom: 0,
type: 'scroll',
textStyle: { fontSize: 10, color: '#6b7280' }
} : undefined,
grid: {
left: '3%',
right: '4%',
bottom: models.length > 0 ? '18%' : '12%',
top: '8%',
containLabel: true
},
xAxis: {
type: 'category',
data: data.map((d: any) => d.date),
axisLabel: {
rotate: data.length > 12 ? 45 : 0,
fontSize: 11,
color: '#6b7280'
}, },
legend: { axisLine: {
orient: 'vertical', lineStyle: { color: '#e5e7eb' }
left: 'left',
top: 'middle'
}, },
series: [ axisTick: {
{ show: false
type: 'pie', }
radius: ['40%', '70%'], },
center: ['60%', '50%'], yAxis: {
data: $billingStats.by_model.map((m) => ({ type: 'value',
name: m.model, min: 0,
value: m.cost max: yAxisMax,
})), axisLabel: {
emphasis: { formatter: (value: number) => `¥${value.toFixed(2)}`,
itemStyle: { fontSize: 11,
shadowBlur: 10, color: '#6b7280'
shadowOffsetX: 0, },
shadowColor: 'rgba(0, 0, 0, 0.5)' splitLine: {
} lineStyle: { color: '#f3f4f6', type: 'dashed' }
}, },
label: { axisLine: {
formatter: '{b}: ¥{c}' show: false
} }
} },
] series
}); }, true); // true = 不合并,完全替换
}
}; };
const initCharts = async () => { const initChart = () => {
// 等待 DOM 更新 if (!echarts) return;
await tick();
// 确保 DOM 元素存在后再初始化 // 确保 DOM 元素存在且有尺寸后再初始化
if (dailyChartContainer && !dailyChart) { if (chartContainer && !chart) {
dailyChart = echarts.init(dailyChartContainer); const width = chartContainer.offsetWidth;
} const height = chartContainer.offsetHeight;
if (modelChartContainer && !modelChart) { if (width > 0 && height > 0) {
modelChart = echarts.init(modelChartContainer); chart = echarts.init(chartContainer);
}
} }
}; };
onMount(async () => { onMount(async () => {
// 动态导入 echarts避免 SSR 问题)
try {
echarts = await import('echarts');
} catch (e) {
console.error('Failed to load echarts:', e);
return;
}
// loadStats 会处理图表初始化和渲染 // loadStats 会处理图表初始化和渲染
await loadStats(); await loadStats();
// 响应式调整(等待图表初始化完成) // 响应式调整
await tick();
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
if (dailyChartContainer && modelChartContainer && dailyChart && modelChart) {
resizeObserver = new ResizeObserver(() => {
dailyChart?.resize();
modelChart?.resize();
});
resizeObserver.observe(dailyChartContainer); // 延迟设置 ResizeObserver确保图表已初始化
resizeObserver.observe(modelChartContainer); setTimeout(() => {
} if (chartContainer) {
resizeObserver = new ResizeObserver(() => {
chart?.resize();
});
resizeObserver.observe(chartContainer);
}
}, 200);
return () => { return () => {
resizeObserver?.disconnect(); resizeObserver?.disconnect();
@ -180,56 +219,48 @@
}); });
onDestroy(() => { onDestroy(() => {
dailyChart?.dispose(); chart?.dispose();
modelChart?.dispose(); chart = null;
if (loadingTimer) {
clearTimeout(loadingTimer);
loadingTimer = null;
}
}); });
</script> </script>
<div class="billing-stats bg-white dark:bg-gray-850 rounded-xl shadow-sm p-4"> <div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-inner">
<div class="stats-header flex justify-between items-center mb-4"> <!-- 标题和时间选择器 -->
<h2 class="text-lg font-semibold">{$i18n.t('消费统计')}</h2> <div class="flex justify-between items-center mb-4">
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">{$i18n.t('消费统计')}</p>
<select <select
bind:value={days} bind:value={period}
on:change={loadStats} on:change={loadStats}
class="px-3 py-1.5 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-sm" class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-500"
> >
<option value={7}>{$i18n.t('最近7天')}</option> {#each periodOptions as opt}
<option value={30}>{$i18n.t('最近30天')}</option> <option value={opt.value}>{opt.label}</option>
<option value={90}>{$i18n.t('最近90天')}</option> {/each}
</select> </select>
</div> </div>
{#if loading} <div class="relative h-56">
<div class="flex justify-center items-center py-12"> {#if showLoading}
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div> <div class="absolute inset-0 flex justify-center items-center bg-gray-50/80 dark:bg-gray-800/80 z-10">
</div> <div class="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-500"></div>
{:else if $billingStats}
<div class="charts-container grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="chart-wrapper">
<div bind:this={dailyChartContainer} class="chart h-80"></div>
</div>
<div class="chart-wrapper">
<div bind:this={modelChartContainer} class="chart h-80"></div>
</div>
</div>
{#if $billingStats.daily.length === 0 && $billingStats.by_model.length === 0}
<div class="flex flex-col items-center justify-center py-12 text-gray-500">
<svg
class="w-16 h-16 mb-4 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<p class="text-sm">暂无统计数据</p>
</div> </div>
{/if} {/if}
{/if}
{#if !$billingStats || !$billingStats.daily || $billingStats.daily.length === 0}
{#if !loading && !showLoading}
<div class="absolute inset-0 flex flex-col items-center justify-center text-gray-400 dark:text-gray-500">
<svg class="w-10 h-10 mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p class="text-xs">{$i18n.t('暂无消费记录')}</p>
</div>
{/if}
{/if}
<div bind:this={chartContainer} class="w-full h-full"></div>
</div>
</div> </div>