From af9a315ac30b83241f3df5556d7a2abfcd5d25b0 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 20 Jul 2026 01:33:47 -0400 Subject: [PATCH] refac --- backend/open_webui/models/chat_messages.py | 275 ++++++++++ backend/open_webui/models/chats.py | 31 ++ backend/open_webui/routers/users.py | 209 +++++++- src/lib/apis/users/index.ts | 84 +++ src/lib/components/chat/Settings/Usage.svelte | 485 ++++++++++++++++++ src/lib/components/chat/SettingsModal.svelte | 36 ++ src/lib/components/icons/UsageIcon.svelte | 17 + 7 files changed, 1136 insertions(+), 1 deletion(-) create mode 100644 src/lib/components/chat/Settings/Usage.svelte create mode 100644 src/lib/components/icons/UsageIcon.svelte diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index 0988928fd2..048be9b270 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -1,7 +1,10 @@ import json import time import uuid +from collections import Counter +from datetime import datetime, timedelta from typing import Any, Optional +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from sqlalchemy import select, delete, func, cast, Integer, distinct from sqlalchemy.ext.asyncio import AsyncSession @@ -47,6 +50,17 @@ def _normalize_timestamp(timestamp: int) -> float: return timestamp +def _timezone(tz: Optional[str]) -> ZoneInfo: + try: + return ZoneInfo(tz or 'UTC') + except ZoneInfoNotFoundError: + return ZoneInfo('UTC') + + +def _date_key(timestamp: int, tz: ZoneInfo) -> str: + return datetime.fromtimestamp(_normalize_timestamp(timestamp), tz=tz).strftime('%Y-%m-%d') + + def get_usage(data: dict) -> Optional[dict]: """Extract and normalize usage from message data.""" usage = data.get('usage') or (data.get('info') or {}).get('usage') @@ -72,6 +86,40 @@ def _token_columns(dialect: str): ) +def _extract_tool_names(value: Any) -> list[str]: + names: list[str] = [] + + def add(name: Any): + if isinstance(name, str): + cleaned = name.strip() + if cleaned and len(cleaned) <= 128: + names.append(cleaned) + + def walk(item: Any): + if isinstance(item, list): + for child in item: + walk(child) + return + + if not isinstance(item, dict): + return + + item_type = str(item.get('type') or '') + looks_like_tool = 'tool' in item_type or item_type in {'function_call', 'function_call_output'} + if looks_like_tool: + add(item.get('name') or item.get('tool_name')) + function = item.get('function') + if isinstance(function, dict): + add(function.get('name')) + + for key in ('tool_calls', 'tools', 'output', 'meta'): + if key in item: + walk(item.get(key)) + + walk(value) + return names + + #################### # ChatMessage DB Schema #################### @@ -604,6 +652,233 @@ class ChatMessageTable: for row in result.all() } + async def get_user_usage_summary( + self, + user_id: str, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + include_active_days: bool = True, + timezone: Optional[str] = None, + db: Optional[AsyncSession] = None, + ) -> dict: + async with get_async_db_context(db) as db: + bind = await db.connection() + dialect = bind.dialect.name + input_tokens, output_tokens = _token_columns(dialect) + + messages_stmt = select(ChatMessage.role, func.count(ChatMessage.id).label('count')).filter( + ChatMessage.user_id == user_id, + ) + token_stmt = select( + func.coalesce(func.sum(input_tokens), 0).label('input_tokens'), + func.coalesce(func.sum(output_tokens), 0).label('output_tokens'), + ).filter( + ChatMessage.user_id == user_id, + ChatMessage.role == 'assistant', + ChatMessage.usage.isnot(None), + ) + models_stmt = select(func.count(distinct(ChatMessage.model_id)).label('models_used')).filter( + ChatMessage.user_id == user_id, + ChatMessage.role == 'assistant', + ChatMessage.model_id.isnot(None), + ) + if start_date: + messages_stmt = messages_stmt.filter(ChatMessage.created_at >= start_date) + token_stmt = token_stmt.filter(ChatMessage.created_at >= start_date) + models_stmt = models_stmt.filter(ChatMessage.created_at >= start_date) + if end_date: + messages_stmt = messages_stmt.filter(ChatMessage.created_at <= end_date) + token_stmt = token_stmt.filter(ChatMessage.created_at <= end_date) + models_stmt = models_stmt.filter(ChatMessage.created_at <= end_date) + + messages_result = await db.execute(messages_stmt.group_by(ChatMessage.role)) + message_counts = {row.role: row.count for row in messages_result.all()} + + token_result = (await db.execute(token_stmt)).one() + models_used = (await db.execute(models_stmt)).scalar() or 0 + + active_days = set() + if include_active_days: + tz = _timezone(timezone) + day_stmt = select(ChatMessage.created_at).filter(ChatMessage.user_id == user_id) + if start_date: + day_stmt = day_stmt.filter(ChatMessage.created_at >= start_date) + if end_date: + day_stmt = day_stmt.filter(ChatMessage.created_at <= end_date) + day_result = await db.execute(day_stmt) + active_days = {_date_key(row.created_at, tz) for row in day_result.all()} + + input_total = int(token_result.input_tokens or 0) + output_total = int(token_result.output_tokens or 0) + + return { + 'messages': sum(message_counts.values()), + 'user_messages': message_counts.get('user', 0), + 'assistant_messages': message_counts.get('assistant', 0), + 'input_tokens': input_total, + 'output_tokens': output_total, + 'total_tokens': input_total + output_total, + 'models_used': int(models_used), + 'active_days': len(active_days), + } + + async def get_user_first_message_created_at( + self, + user_id: str, + db: Optional[AsyncSession] = None, + ) -> Optional[int]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(func.min(ChatMessage.created_at)).filter( + ChatMessage.user_id == user_id, + ChatMessage.created_at.isnot(None), + ) + ) + value = result.scalar() + return int(value) if value else None + + async def get_user_daily_usage( + self, + user_id: str, + start_date: int, + end_date: int, + timezone: Optional[str] = None, + db: Optional[AsyncSession] = None, + ) -> list[dict]: + async with get_async_db_context(db) as db: + tz = _timezone(timezone) + bind = await db.connection() + dialect = bind.dialect.name + input_tokens, output_tokens = _token_columns(dialect) + + stmt = select( + ChatMessage.created_at, + ChatMessage.chat_id, + ChatMessage.role, + ChatMessage.model_id, + ChatMessage.usage, + input_tokens.label('input_tokens'), + output_tokens.label('output_tokens'), + ).filter( + ChatMessage.user_id == user_id, + ChatMessage.created_at >= start_date, + ChatMessage.created_at <= end_date, + ) + + result = await db.execute(stmt) + daily: dict[str, dict] = {} + for row in result.all(): + date = _date_key(row.created_at, tz) + entry = daily.setdefault( + date, + { + 'date': date, + 'messages': 0, + 'chat_ids': set(), + 'tokens': 0, + 'models': Counter(), + }, + ) + entry['messages'] += 1 + entry['chat_ids'].add(row.chat_id) + if row.role == 'assistant' and row.model_id: + entry['models'][row.model_id] += 1 + if row.usage: + entry['tokens'] += int(row.input_tokens or 0) + int(row.output_tokens or 0) + + current = datetime.fromtimestamp(_normalize_timestamp(start_date), tz=tz).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date), tz=tz).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + while current <= end_dt: + date = current.strftime('%Y-%m-%d') + daily.setdefault( + date, + {'date': date, 'messages': 0, 'chat_ids': set(), 'tokens': 0, 'models': Counter()}, + ) + current += timedelta(days=1) + + return [ + { + 'date': item['date'], + 'messages': item['messages'], + 'chats': len(item['chat_ids']), + 'tokens': item['tokens'], + 'models': dict(item['models']), + } + for item in sorted(daily.values(), key=lambda x: x['date']) + ] + + async def get_user_top_models( + self, + user_id: str, + start_date: int, + end_date: int, + limit: int = 5, + db: Optional[AsyncSession] = None, + ) -> list[dict]: + async with get_async_db_context(db) as db: + bind = await db.connection() + dialect = bind.dialect.name + input_tokens, output_tokens = _token_columns(dialect) + + stmt = ( + select( + ChatMessage.model_id, + func.count(ChatMessage.id).label('messages'), + func.coalesce(func.sum(input_tokens), 0).label('input_tokens'), + func.coalesce(func.sum(output_tokens), 0).label('output_tokens'), + ) + .filter( + ChatMessage.user_id == user_id, + ChatMessage.role == 'assistant', + ChatMessage.model_id.isnot(None), + ChatMessage.created_at >= start_date, + ChatMessage.created_at <= end_date, + ) + .group_by(ChatMessage.model_id) + .order_by(func.count(ChatMessage.id).desc()) + .limit(limit) + ) + result = await db.execute(stmt) + return [ + { + 'model_id': row.model_id, + 'messages': row.messages, + 'input_tokens': int(row.input_tokens or 0), + 'output_tokens': int(row.output_tokens or 0), + 'total_tokens': int(row.input_tokens or 0) + int(row.output_tokens or 0), + } + for row in result.all() + ] + + async def get_user_top_tools( + self, + user_id: str, + start_date: int, + end_date: int, + limit: int = 5, + db: Optional[AsyncSession] = None, + ) -> list[dict]: + async with get_async_db_context(db) as db: + stmt = select(ChatMessage.output, ChatMessage.meta).filter( + ChatMessage.user_id == user_id, + ChatMessage.created_at >= start_date, + ChatMessage.created_at <= end_date, + ) + result = await db.execute(stmt) + + counts: Counter[str] = Counter() + for output, meta in result.all(): + for name in _extract_tool_names(output): + counts[name] += 1 + for name in _extract_tool_names(meta): + counts[name] += 1 + + return [{'name': name, 'count': count} for name, count in counts.most_common(limit)] + async def get_message_count_by_user( self, start_date: Optional[int] = None, diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 049f1fe704..23af979eab 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -39,6 +39,7 @@ from sqlalchemy.sql import exists from sqlalchemy.sql.expression import bindparam log = logging.getLogger(__name__) +ACTIVE_CHAT_GAP_SECONDS = 30 * 60 class Chat(Base): # database table mapping for chat entity @@ -1371,6 +1372,36 @@ class ChatTable: all_chats = result.scalars().all() return [ChatModel.model_validate(chat) for chat in all_chats] + async def get_user_usage_chat_stats(self, user_id: str, db: AsyncSession | None = None) -> dict: + async with get_async_db_context(db) as session: + chat_filter = (Chat.user_id == user_id, Chat.meta['internal'].as_boolean().is_not(True)) + result = await session.execute(select(func.count(Chat.id).label('total_chats')).where(*chat_filter)) + total_chats = int(result.scalar() or 0) + + messages_stmt = ( + select(ChatMessage.chat_id, ChatMessage.created_at) + .join(Chat, Chat.id == ChatMessage.chat_id) + .where(*chat_filter, ChatMessage.created_at.isnot(None)) + .order_by(ChatMessage.chat_id, ChatMessage.created_at.asc()) + ) + messages_result = await session.execute(messages_stmt) + last_message_at_by_chat: dict[str, int] = {} + active_seconds_by_chat: dict[str, int] = {} + + for chat_id, created_at in messages_result.all(): + timestamp = int(created_at / 1000) if created_at > 10_000_000_000 else int(created_at) + last_message_at = last_message_at_by_chat.get(chat_id) + if last_message_at is not None: + delta = timestamp - last_message_at + if 0 < delta <= ACTIVE_CHAT_GAP_SECONDS: + active_seconds_by_chat[chat_id] = active_seconds_by_chat.get(chat_id, 0) + delta + last_message_at_by_chat[chat_id] = timestamp + + return { + 'total_chats': total_chats, + 'longest_chat_seconds': max(active_seconds_by_chat.values(), default=0), + } + # list user conversations async def get_chats_by_user_id( self, diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 378b682357..eaaa0092b2 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -4,9 +4,11 @@ import base64 import io import logging import time +from collections import Counter +from datetime import datetime, timedelta from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi.responses import FileResponse, Response, StreamingResponse from open_webui.constants import ERROR_MESSAGES from open_webui.events import EVENTS, publish_event @@ -14,6 +16,8 @@ from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_AL from open_webui.internal.db import get_async_session from open_webui.models.auths import Auths from open_webui.models.config import Config +from open_webui.models.chat_messages import ChatMessages +from open_webui.models.chats import Chats from open_webui.models.groups import Groups from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.users import ( @@ -259,6 +263,129 @@ class UserPermissions(BaseModel): settings: SettingsPermissions +class UserUsageTotals(BaseModel): + lifetime_tokens: int = 0 + input_tokens: int = 0 + output_tokens: int = 0 + peak_daily_tokens: int = 0 + longest_chat_seconds: int = 0 + current_streak: int = 0 + longest_streak: int = 0 + total_chats: int = 0 + active_days: int = 0 + models_used: int = 0 + messages: int = 0 + user_messages: int = 0 + assistant_messages: int = 0 + + +class UserUsageHeatmapEntry(BaseModel): + date: str + messages: int = 0 + chats: int = 0 + tokens: int = 0 + models: dict[str, int] = Field(default_factory=dict) + + +class UserUsageModelEntry(BaseModel): + model_id: str + messages: int = 0 + input_tokens: int = 0 + output_tokens: int = 0 + total_tokens: int = 0 + + +class UserUsageToolEntry(BaseModel): + name: str + count: int + + +class UserUsageInsights(BaseModel): + most_used_model: Optional[str] = None + average_tokens_per_chat: float = 0 + average_messages_per_active_day: float = 0 + user_message_share: float = 0 + assistant_message_share: float = 0 + + +class UserUsagePeriod(BaseModel): + start_date: int + end_date: int + days: int + + +class UserUsageResponse(BaseModel): + totals: UserUsageTotals + heatmap: list[UserUsageHeatmapEntry] + weekly_heatmap: list[UserUsageHeatmapEntry] + cumulative_heatmap: list[UserUsageHeatmapEntry] + insights: UserUsageInsights + top_models: list[UserUsageModelEntry] + top_tools: list[UserUsageToolEntry] = [] + period: UserUsagePeriod + + +def _week_start(date: datetime) -> datetime: + return date - timedelta(days=date.weekday()) + + +def _build_weekly_heatmap(heatmap: list[dict]) -> list[dict]: + weeks: dict[str, dict] = {} + for day in heatmap: + week = _week_start(datetime.strptime(day['date'], '%Y-%m-%d')).strftime('%Y-%m-%d') + entry = weeks.setdefault(week, {'date': week, 'messages': 0, 'chats': 0, 'tokens': 0, 'models': Counter()}) + entry['messages'] += day.get('messages', 0) + entry['chats'] += day.get('chats', 0) + entry['tokens'] += day.get('tokens', 0) + entry['models'].update(day.get('models', {})) + + return [ + { + **weeks[key], + 'models': dict(weeks[key]['models']), + } + for key in sorted(weeks) + ] + + +def _build_cumulative_heatmap(heatmap: list[dict]) -> list[dict]: + totals = {'messages': 0, 'chats': 0, 'tokens': 0} + models: Counter[str] = Counter() + cumulative = [] + for day in heatmap: + totals['messages'] += day.get('messages', 0) + totals['chats'] += day.get('chats', 0) + totals['tokens'] += day.get('tokens', 0) + models.update(day.get('models', {})) + cumulative.append( + { + 'date': day['date'], + **totals, + 'models': dict(models), + } + ) + return cumulative + + +def _calculate_streaks(heatmap: list[dict]) -> dict[str, int]: + longest = 0 + current_run = 0 + for day in heatmap: + if day.get('messages', 0) > 0: + current_run += 1 + longest = max(longest, current_run) + else: + current_run = 0 + + current = 0 + for day in reversed(heatmap): + if day.get('messages', 0) <= 0: + break + current += 1 + + return {'current': current, 'longest': longest} + + @router.get('/default/permissions', response_model=UserPermissions) async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)): user_permissions = await Config.get('user.permissions') @@ -455,6 +582,86 @@ async def update_user_info_by_session_user( # PATCH-style merge return updated.info +############################ +# GetUserUsageBySessionUser +############################ + + +@router.get('/usage', response_model=UserUsageResponse) +async def get_user_usage_by_session_user( + days: Optional[int] = Query(None, ge=7, le=732), + start_date: Optional[int] = Query(None), + end_date: Optional[int] = Query(None), + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + now = int(time.time()) + period_end = end_date or now + if start_date is not None: + period_start = start_date + elif days is not None: + period_start = period_end - ((days - 1) * 86400) + else: + period_start = max(user.created_at or (period_end - (364 * 86400)), period_end - (729 * 86400)) + + if period_start > period_end: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='start_date must be before end_date', + ) + + period_days = max(1, int((period_end - period_start) / 86400) + 1) + + lifetime_summary = await ChatMessages.get_user_usage_summary(user.id, include_active_days=False, db=db) + period_summary = await ChatMessages.get_user_usage_summary( + user.id, period_start, period_end, timezone=user.timezone, db=db + ) + chat_stats = await Chats.get_user_usage_chat_stats(user.id, db=db) + heatmap = await ChatMessages.get_user_daily_usage(user.id, period_start, period_end, timezone=user.timezone, db=db) + top_models = await ChatMessages.get_user_top_models(user.id, period_start, period_end, db=db) + top_tools = await ChatMessages.get_user_top_tools(user.id, period_start, period_end, db=db) + + streaks = _calculate_streaks(heatmap) + total_messages = period_summary.get('messages', 0) + total_chats = chat_stats.get('total_chats', 0) + active_days = period_summary.get('active_days', 0) + assistant_messages = period_summary.get('assistant_messages', 0) + user_messages = period_summary.get('user_messages', 0) + + return UserUsageResponse( + totals=UserUsageTotals( + lifetime_tokens=lifetime_summary.get('total_tokens', 0), + input_tokens=lifetime_summary.get('input_tokens', 0), + output_tokens=lifetime_summary.get('output_tokens', 0), + peak_daily_tokens=max((day.get('tokens', 0) for day in heatmap), default=0), + longest_chat_seconds=chat_stats.get('longest_chat_seconds', 0), + current_streak=streaks['current'], + longest_streak=streaks['longest'], + total_chats=total_chats, + active_days=active_days, + models_used=lifetime_summary.get('models_used', 0), + messages=total_messages, + user_messages=user_messages, + assistant_messages=assistant_messages, + ), + heatmap=heatmap, + weekly_heatmap=_build_weekly_heatmap(heatmap), + cumulative_heatmap=_build_cumulative_heatmap(heatmap), + insights=UserUsageInsights( + most_used_model=top_models[0]['model_id'] if top_models else None, + average_tokens_per_chat=( + round(lifetime_summary.get('total_tokens', 0) / total_chats, 1) if total_chats else 0 + ), + average_messages_per_active_day=round(total_messages / active_days, 1) if active_days else 0, + user_message_share=round((user_messages / total_messages) * 100, 1) if total_messages else 0, + assistant_message_share=round((assistant_messages / total_messages) * 100, 1) if total_messages else 0, + ), + top_models=top_models, + top_tools=top_tools, + period=UserUsagePeriod(start_date=period_start, end_date=period_end, days=period_days), + ) + + ############################ # GetUserById ############################ diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index fb68ab8f61..95c0ebac18 100644 --- a/src/lib/apis/users/index.ts +++ b/src/lib/apis/users/index.ts @@ -604,3 +604,87 @@ export const getUserPreview = async (token: string, userId: string) => { return res; }; + +export type UserUsageHeatmapEntry = { + date: string; + messages: number; + chats: number; + tokens: number; + models: Record; +}; + +export type UserUsageResponse = { + totals: { + lifetime_tokens: number; + input_tokens: number; + output_tokens: number; + peak_daily_tokens: number; + longest_chat_seconds: number; + current_streak: number; + longest_streak: number; + total_chats: number; + active_days: number; + models_used: number; + messages: number; + user_messages: number; + assistant_messages: number; + }; + heatmap: UserUsageHeatmapEntry[]; + weekly_heatmap: UserUsageHeatmapEntry[]; + cumulative_heatmap: UserUsageHeatmapEntry[]; + insights: { + most_used_model: string | null; + average_tokens_per_chat: number; + average_messages_per_active_day: number; + user_message_share: number; + assistant_message_share: number; + }; + top_models: Array<{ + model_id: string; + messages: number; + input_tokens: number; + output_tokens: number; + total_tokens: number; + }>; + top_tools: Array<{ name: string; count: number }>; + period: { + start_date: number; + end_date: number; + days: number; + }; +}; + +export const getUserUsage = async ( + token: string, + options: { days?: number; startDate?: number | null; endDate?: number | null } = {} +): Promise => { + let error = null; + const searchParams = new URLSearchParams(); + + if (options.days) searchParams.append('days', options.days.toString()); + if (options.startDate) searchParams.append('start_date', options.startDate.toString()); + if (options.endDate) searchParams.append('end_date', options.endDate.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/users/usage?${searchParams.toString()}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/components/chat/Settings/Usage.svelte b/src/lib/components/chat/Settings/Usage.svelte new file mode 100644 index 0000000000..5da2ca1a69 --- /dev/null +++ b/src/lib/components/chat/Settings/Usage.svelte @@ -0,0 +1,485 @@ + + +
+
+

{$i18n.t('Usage')}

+
+ + {#if loading} +
+ +
+ {:else if !usage} +
+ {$i18n.t('Failed to load usage')} +
+ {:else} +
+ +
+
+
+ {formatNumber(usage.totals.lifetime_tokens)} +
+
+ {$i18n.t('Lifetime tokens')} +
+
+
+
+ {formatNumber(usage.totals.peak_daily_tokens)} +
+
+ {$i18n.t('Peak tokens')} +
+
+
+
+ {formatDuration(usage.totals.longest_chat_seconds)} +
+
+ {$i18n.t('Longest active chat')} +
+
+
+
+ {usage.totals.current_streak.toLocaleString()} +
+
+ {$i18n.t('Current streak')} +
+
+
+
+ {usage.totals.longest_streak.toLocaleString()} +
+
+ {$i18n.t('Longest streak')} +
+
+
+
+ +
+
+

+ {$i18n.t('Token activity')} +

+
+ {#each heatmapModes as mode} + + {/each} +
+
+ +
+
+
+ {#each heatmapCells as entry} + {#if entry} + +
+
+ {:else} +
+ {/if} + {/each} +
+ +
+ {#each monthLabels as month} +
+ {month.label} +
+ {/each} +
+
+ + {#if usage.top_models.length > 0} +
+ {#each usage.top_models.slice(0, 6) as model} +
+ + {modelName(model.model_id)} +
+ {/each} +
+ {/if} +
+
+ + {#if !hasUsage} + +
+ {$i18n.t('No usage data found')} +
+
+ {:else} + + + + {usage.totals.models_used.toLocaleString()} + + + + + {formatNumber(usage.insights.average_tokens_per_chat)} + + + + + {usage.insights.average_messages_per_active_day.toLocaleString()} + + + + + {usage.totals.user_messages.toLocaleString()} · {usage.insights.user_message_share}% + + + + + {usage.totals.assistant_messages.toLocaleString()} · {usage.insights + .assistant_message_share}% + + + + + {usage.totals.total_chats.toLocaleString()} + + + + + + {#if usage.top_models.length === 0} +
+ {$i18n.t('No model usage found')} +
+ {:else} + {#each usage.top_models as model} + + + {model.messages.toLocaleString()} + {$i18n.t('messages')} · {formatNumber(model.total_tokens)} + + + {/each} + {/if} +
+ + {#if usage.top_tools.length > 0} + + {#each usage.top_tools as tool} + + + {tool.count.toLocaleString()} + {$i18n.t('runs')} + + + {/each} + + {/if} + {/if} + +
+ {$i18n.t('Token counts are estimates and may not reflect actual API usage')} +
+
+ {/if} +
diff --git a/src/lib/components/chat/SettingsModal.svelte b/src/lib/components/chat/SettingsModal.svelte index cd9bf74ec1..ddb6b06281 100644 --- a/src/lib/components/chat/SettingsModal.svelte +++ b/src/lib/components/chat/SettingsModal.svelte @@ -17,6 +17,7 @@ import Shortcuts from './Settings/Shortcuts.svelte'; import Audio from './Settings/Audio.svelte'; import DataControls from './Settings/DataControls.svelte'; + import Usage from './Settings/Usage.svelte'; import ArchivedChats from './Settings/ArchivedChats.svelte'; import Personalization from './Settings/Personalization.svelte'; import Search from '../icons/Search.svelte'; @@ -35,6 +36,7 @@ import ArchiveBox from '../icons/ArchiveBox.svelte'; import ChevronLeft from '../icons/ChevronLeft.svelte'; import Keyboard from '../icons/Keyboard.svelte'; + import UsageIcon from '../icons/UsageIcon.svelte'; import AdminTabIcon from '$lib/components/admin/Settings/AdminTabIcon.svelte'; import AdminGeneral from '$lib/components/admin/Settings/General.svelte'; import AdminAuthentication from '$lib/components/admin/Settings/Authentication.svelte'; @@ -108,6 +110,7 @@ personalization: 'Preferences', audio: 'Preferences', data_controls: 'Data', + usage: 'Data', archived_chats: 'Data', account: 'Profile', about: 'Profile' @@ -493,6 +496,24 @@ 'messagehistory' ] }, + { + id: 'usage', + title: 'Usage', + keywords: [ + 'activity', + 'activity heatmap', + 'analytics', + 'chat activity', + 'heatmap', + 'model usage', + 'stats', + 'streak', + 'token activity', + 'token usage', + 'tokens', + 'usage' + ] + }, { id: 'archived_chats', title: 'Archived Chats', @@ -1033,6 +1054,19 @@ {$i18n.t('Data Controls')} + {:else if tabId === 'usage'} + {:else if tabId === 'archived_chats'}