diff --git a/backend/open_webui/routers/billing.py b/backend/open_webui/routers/billing.py index ca97b6fba0..0cfb72093f 100644 --- a/backend/open_webui/routers/billing.py +++ b/backend/open_webui/routers/billing.py @@ -87,6 +87,7 @@ class DailyStats(BaseModel): date: str cost: float + by_model: dict[str, float] = {} # 按模型分组的消费 class ModelStats(BaseModel): @@ -102,6 +103,7 @@ class StatsResponse(BaseModel): daily: list[DailyStats] by_model: list[ModelStats] + models: list[str] = [] # 所有模型列表(用于前端生成堆叠图系列) #################### @@ -189,41 +191,101 @@ async def get_logs( @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: from sqlalchemy import func + from datetime import datetime, timedelta + from dateutil.relativedelta import relativedelta with get_db() as db: - # cutoff: 纳秒级时间戳 + now = datetime.now() 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( func.date_trunc( - "day", - # created_at 是纳秒级,需要除以 1000000000 转换为秒级 + trunc_unit, func.to_timestamp(BillingLog.created_at / 1000000000) ).label("date"), + BillingLog.model_id, func.sum(BillingLog.total_cost).label("total"), ) .filter( BillingLog.user_id == user.id, 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") .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 = ( db.query( @@ -234,23 +296,20 @@ async def get_stats(user=Depends(get_verified_user), days: int = 7): .filter( BillingLog.user_id == user.id, BillingLog.created_at >= cutoff, - BillingLog.log_type == "deduct", + BillingLog.log_type.in_(["deduct", "settle"]), ) .group_by(BillingLog.model_id) .order_by(func.sum(BillingLog.total_cost).desc()) .all() ) - # cost 单位转换:毫 → 元(除以 10000) return StatsResponse( - daily=[ - DailyStats(date=str(d[0].date()), cost=d[1] / 10000 if d[1] else 0) - for d in daily_query - ], + daily=daily_stats, by_model=[ ModelStats(model=m[0], cost=m[1] / 10000 if m[1] else 0, count=m[2]) for m in by_model_query ], + models=sorted(list(all_models)), # 按字母排序的模型列表 ) except Exception as e: log.error(f"查询统计失败: {e}") diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index cac2a0842c..d72a5b6ece 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -91,6 +91,7 @@ from open_webui.utils.misc import ( prepend_to_first_user_message_content, convert_logit_bias_input_to_json, get_content_from_message, + merge_consecutive_messages, ) from open_webui.utils.tools import get_tools 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] if ordered_messages: + # 合并连续的同角色消息,避免 LLM API 报错 + ordered_messages = merge_consecutive_messages(ordered_messages) form_data["messages"] = ordered_messages # === 2. 处理 System Prompt 变量替换 === diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 1a7b2a04cd..607aa8b71e 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -155,6 +155,69 @@ def remove_system_message(messages: list[dict]) -> list[dict]: 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]]: return get_system_message(messages), remove_system_message(messages) diff --git a/src/lib/apis/billing/index.ts b/src/lib/apis/billing/index.ts index 08e17d0487..1efc6c0c9b 100644 --- a/src/lib/apis/billing/index.ts +++ b/src/lib/apis/billing/index.ts @@ -23,6 +23,7 @@ export interface BillingLog { export interface DailyStats { date: string; cost: number; + by_model: Record; // 按模型分组的消费 } export interface ModelStats { @@ -34,6 +35,7 @@ export interface ModelStats { export interface BillingStats { daily: DailyStats[]; by_model: ModelStats[]; + models: string[]; // 所有模型列表(用于堆叠图) } export interface ModelPricing { @@ -133,11 +135,12 @@ export const getBillingLogs = async ( */ export const getBillingStats = async ( token: string, - days: number = 7 + days: number = 7, + granularity: string = 'day' ): Promise => { 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', headers: { 'Content-Type': 'application/json', diff --git a/src/lib/components/billing/BillingStatsChart.svelte b/src/lib/components/billing/BillingStatsChart.svelte index 0b37174577..96a4a39575 100644 --- a/src/lib/components/billing/BillingStatsChart.svelte +++ b/src/lib/components/billing/BillingStatsChart.svelte @@ -3,176 +3,215 @@ import { billingStats } from '$lib/stores'; import { getBillingStats } from '$lib/apis/billing'; import { toast } from 'svelte-sonner'; - import * as echarts from 'echarts'; const i18n = getContext('i18n'); - let dailyChartContainer: HTMLDivElement; - let modelChartContainer: HTMLDivElement; - let dailyChart: echarts.ECharts | null = null; - let modelChart: echarts.ECharts | null = null; - let days = 7; + let chartContainer: HTMLDivElement; + let chart: any = null; + let period = '7d'; // 24h, 7d, 30d, 12m let loading = false; + let showLoading = false; // 延迟显示 loading + let loadingTimer: ReturnType | 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 () => { loading = true; + // 延迟 500ms 后才显示 loading,避免快速加载时闪烁 + loadingTimer = setTimeout(() => { + if (loading) { + showLoading = true; + } + }, 500); + try { - const stats = await getBillingStats(localStorage.token, days); + const stats = await getBillingStats(localStorage.token, currentPeriod.days, currentPeriod.granularity); billingStats.set(stats); - - // 等待 DOM 更新后初始化图表 - await tick(); - await initCharts(); - - // 渲染图表 - renderCharts(); } catch (error) { toast.error($i18n.t('查询统计失败: ') + error.message); } finally { 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 + ]; - // 按日统计图表 - 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}
费用: ¥${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 getModelColor = (index: number) => MODEL_COLORS[index % MODEL_COLORS.length]; - // 按模型统计图表 - if (modelChart && $billingStats.by_model.length > 0) { - modelChart.setOption({ - title: { - text: $i18n.t('模型消费分布'), - left: 'center', - textStyle: { - fontSize: 16, - fontWeight: 'normal' + const renderChart = () => { + if (!$billingStats || !echarts || !chart) return; + + const data = $billingStats.daily || []; + const models = $billingStats.models || []; + if (data.length === 0) return; + + // 计算Y轴最大值,确保至少为1(当所有数据为0时) + 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', - formatter: (params: any) => { - return `${params.name}
费用: ¥${params.value.toFixed(4)}
占比: ${params.percent}%`; + })) + : [{ + name: '消费', + type: 'bar', + 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 `${p.seriesName}: ¥${p.value.toFixed(4)}`; + }) + .join('
'); + return `${date}
${details || '无消费'}
合计: ¥${total.toFixed(4)}`; + } + }, + 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: { - orient: 'vertical', - left: 'left', - top: 'middle' + axisLine: { + lineStyle: { color: '#e5e7eb' } }, - series: [ - { - type: 'pie', - radius: ['40%', '70%'], - center: ['60%', '50%'], - data: $billingStats.by_model.map((m) => ({ - name: m.model, - value: m.cost - })), - emphasis: { - itemStyle: { - shadowBlur: 10, - shadowOffsetX: 0, - shadowColor: 'rgba(0, 0, 0, 0.5)' - } - }, - label: { - formatter: '{b}: ¥{c}' - } - } - ] - }); - } + axisTick: { + show: false + } + }, + yAxis: { + type: 'value', + min: 0, + max: yAxisMax, + axisLabel: { + formatter: (value: number) => `¥${value.toFixed(2)}`, + fontSize: 11, + color: '#6b7280' + }, + splitLine: { + lineStyle: { color: '#f3f4f6', type: 'dashed' } + }, + axisLine: { + show: false + } + }, + series + }, true); // true = 不合并,完全替换 }; - const initCharts = async () => { - // 等待 DOM 更新 - await tick(); + const initChart = () => { + if (!echarts) return; - // 确保 DOM 元素存在后再初始化 - if (dailyChartContainer && !dailyChart) { - dailyChart = echarts.init(dailyChartContainer); - } - if (modelChartContainer && !modelChart) { - modelChart = echarts.init(modelChartContainer); + // 确保 DOM 元素存在且有尺寸后再初始化 + if (chartContainer && !chart) { + const width = chartContainer.offsetWidth; + const height = chartContainer.offsetHeight; + if (width > 0 && height > 0) { + chart = echarts.init(chartContainer); + } } }; onMount(async () => { + // 动态导入 echarts(避免 SSR 问题) + try { + echarts = await import('echarts'); + } catch (e) { + console.error('Failed to load echarts:', e); + return; + } + // loadStats 会处理图表初始化和渲染 await loadStats(); - // 响应式调整(等待图表初始化完成) - await tick(); + // 响应式调整 let resizeObserver: ResizeObserver | null = null; - if (dailyChartContainer && modelChartContainer && dailyChart && modelChart) { - resizeObserver = new ResizeObserver(() => { - dailyChart?.resize(); - modelChart?.resize(); - }); - resizeObserver.observe(dailyChartContainer); - resizeObserver.observe(modelChartContainer); - } + // 延迟设置 ResizeObserver,确保图表已初始化 + setTimeout(() => { + if (chartContainer) { + resizeObserver = new ResizeObserver(() => { + chart?.resize(); + }); + resizeObserver.observe(chartContainer); + } + }, 200); return () => { resizeObserver?.disconnect(); @@ -180,56 +219,48 @@ }); onDestroy(() => { - dailyChart?.dispose(); - modelChart?.dispose(); + chart?.dispose(); + chart = null; + if (loadingTimer) { + clearTimeout(loadingTimer); + loadingTimer = null; + } }); -
-
-

{$i18n.t('消费统计')}

+
+ +
+

{$i18n.t('消费统计')}

- {#if loading} -
-
-
- {:else if $billingStats} -
-
-
-
-
-
-
-
- - {#if $billingStats.daily.length === 0 && $billingStats.by_model.length === 0} -
- - - -

暂无统计数据

+
+ {#if showLoading} +
+
{/if} - {/if} + + {#if !$billingStats || !$billingStats.daily || $billingStats.daily.length === 0} + {#if !loading && !showLoading} +
+ + + +

{$i18n.t('暂无消费记录')}

+
+ {/if} + {/if} + +
+