This commit is contained in:
Timothy Jaeryang Baek
2026-07-20 01:33:47 -04:00
parent 6e5efc1f75
commit af9a315ac3
7 changed files with 1136 additions and 1 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
############################

View File

@@ -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<string, number>;
};
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<UserUsageResponse | null> => {
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;
};

View File

@@ -0,0 +1,485 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import type { Writable } from 'svelte/store';
import {
getUserUsage,
type UserUsageHeatmapEntry,
type UserUsageResponse
} from '$lib/apis/users';
import { models } from '$lib/stores';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { formatNumber } from '$lib/utils';
import UserSettingSection from './UserSettingSection.svelte';
import UserSettingRow from './UserSettingRow.svelte';
const i18n: Writable<any> = getContext('i18n');
type HeatmapMode = 'daily' | 'weekly' | 'cumulative';
type HeatmapCell = UserUsageHeatmapEntry | null;
type MonthLabel = { label: string; column: number; span: number };
let usage: UserUsageResponse | null = null;
let loading = true;
let heatmapMode: HeatmapMode = 'daily';
let heatmapContainerWidth = 0;
const HEATMAP_MIN_CELL_PX = 8;
const HEATMAP_MAX_CELL_PX = 10;
const HEATMAP_GAP_PX = 4;
const MIN_HEATMAP_COLUMNS = 26;
const DEFAULT_HEATMAP_COLUMNS = 26;
const MIN_MONTH_LABEL_GAP = 6;
$: modelNames = new Map($models.map((model) => [model.id, model.name || model.id]));
$: dailyHeatmap = usage?.heatmap ?? [];
$: weeklyHeatmap = new Map((usage?.weekly_heatmap ?? []).map((entry) => [entry.date, entry]));
$: cumulativeHeatmap = new Map(
(usage?.cumulative_heatmap ?? []).map((entry) => [entry.date, entry])
);
$: heatmapData = buildDisplayHeatmap(dailyHeatmap, heatmapMode, weeklyHeatmap, cumulativeHeatmap);
$: totalHeatmapColumns = Math.max(Math.ceil(heatmapData.length / 7), 1);
$: maxHeatmapColumns = heatmapContainerWidth
? Math.max(
Math.min(totalHeatmapColumns, MIN_HEATMAP_COLUMNS),
Math.floor(
(heatmapContainerWidth + HEATMAP_GAP_PX) / (HEATMAP_MIN_CELL_PX + HEATMAP_GAP_PX)
)
)
: Math.min(totalHeatmapColumns, DEFAULT_HEATMAP_COLUMNS);
$: visibleHeatmapColumns = Math.min(totalHeatmapColumns, maxHeatmapColumns);
$: heatmapCells = buildHeatmapCells(heatmapData, visibleHeatmapColumns);
$: heatmapColumns = Math.max(Math.ceil(heatmapCells.length / 7), 1);
$: heatmapGridWidth = `${heatmapColumns * HEATMAP_MAX_CELL_PX + (heatmapColumns - 1) * HEATMAP_GAP_PX}px`;
$: monthLabels = buildMonthLabels(heatmapCells, heatmapColumns);
$: hasUsage = (usage?.totals.messages ?? 0) > 0 || (usage?.totals.lifetime_tokens ?? 0) > 0;
const palettes = [
[
'bg-gray-100 dark:bg-gray-800',
'bg-green-100 dark:bg-green-900/40',
'bg-green-300 dark:bg-green-700/60',
'bg-green-500 dark:bg-green-600/80',
'bg-green-700 dark:bg-green-500'
],
[
'bg-gray-100 dark:bg-gray-800',
'bg-blue-100 dark:bg-blue-900/40',
'bg-blue-300 dark:bg-blue-700/60',
'bg-blue-500 dark:bg-blue-600/80',
'bg-blue-700 dark:bg-blue-500'
],
[
'bg-gray-100 dark:bg-gray-800',
'bg-purple-100 dark:bg-purple-900/40',
'bg-purple-300 dark:bg-purple-700/60',
'bg-purple-500 dark:bg-purple-600/80',
'bg-purple-700 dark:bg-purple-500'
],
[
'bg-gray-100 dark:bg-gray-800',
'bg-orange-100 dark:bg-orange-900/40',
'bg-orange-300 dark:bg-orange-700/60',
'bg-orange-500 dark:bg-orange-600/80',
'bg-orange-700 dark:bg-orange-500'
],
[
'bg-gray-100 dark:bg-gray-800',
'bg-rose-100 dark:bg-rose-900/40',
'bg-rose-300 dark:bg-rose-700/60',
'bg-rose-500 dark:bg-rose-600/80',
'bg-rose-700 dark:bg-rose-500'
],
[
'bg-gray-100 dark:bg-gray-800',
'bg-yellow-100 dark:bg-yellow-900/40',
'bg-yellow-300 dark:bg-yellow-700/60',
'bg-yellow-500 dark:bg-yellow-600/80',
'bg-yellow-700 dark:bg-yellow-500'
],
[
'bg-gray-100 dark:bg-gray-800',
'bg-cyan-100 dark:bg-cyan-900/40',
'bg-cyan-300 dark:bg-cyan-700/60',
'bg-cyan-500 dark:bg-cyan-600/80',
'bg-cyan-700 dark:bg-cyan-500'
],
[
'bg-gray-100 dark:bg-gray-800',
'bg-red-100 dark:bg-red-900/40',
'bg-red-300 dark:bg-red-700/60',
'bg-red-500 dark:bg-red-600/80',
'bg-red-700 dark:bg-red-500'
],
[
'bg-gray-100 dark:bg-gray-800',
'bg-neutral-200 dark:bg-neutral-800',
'bg-neutral-400 dark:bg-neutral-600',
'bg-neutral-600 dark:bg-neutral-400',
'bg-neutral-800 dark:bg-neutral-200'
]
];
const heatmapModes: Array<{ value: HeatmapMode; label: string }> = [
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'cumulative', label: 'Cumulative' }
];
const loadUsage = async () => {
loading = true;
try {
usage = await getUserUsage(localStorage.token);
} catch (error) {
console.error('Usage load failed:', error);
usage = null;
}
loading = false;
};
const weekStart = (dateString: string) => {
const date = new Date(`${dateString}T00:00:00`);
const daysSinceMonday = (date.getDay() + 6) % 7;
date.setDate(date.getDate() - daysSinceMonday);
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
return `${date.getFullYear()}-${month}-${day}`;
};
const buildDisplayHeatmap = (
data: UserUsageHeatmapEntry[],
mode: HeatmapMode,
weekly: Map<string, UserUsageHeatmapEntry>,
cumulative: Map<string, UserUsageHeatmapEntry>
) => {
if (mode === 'daily') {
return data;
}
return data.map((day) => {
const aggregate =
mode === 'weekly' ? weekly.get(weekStart(day.date)) : cumulative.get(day.date);
return aggregate ? { ...aggregate, date: day.date } : day;
});
};
const buildHeatmapCells = (data: UserUsageHeatmapEntry[], visibleColumns: number) => {
if (data.length === 0) {
return [];
}
const cells = data.slice(-Math.max(7, visibleColumns * 7));
const trailingBlanks = Array.from({ length: (7 - (cells.length % 7)) % 7 }, () => null);
return [...cells, ...trailingBlanks];
};
const buildMonthLabels = (cells: HeatmapCell[], columns: number): MonthLabel[] => {
const labels: MonthLabel[] = [];
let currentMonth = '';
let lastLabelColumn = -MIN_MONTH_LABEL_GAP;
cells.forEach((entry, index) => {
if (!entry) {
return;
}
const month = entry.date.slice(0, 7);
if (month === currentMonth) {
return;
}
const column = Math.floor(index / 7) + 1;
if (column - lastLabelColumn < MIN_MONTH_LABEL_GAP) {
currentMonth = month;
return;
}
labels.push({
label: new Date(`${entry.date}T00:00:00`).toLocaleString(undefined, { month: 'short' }),
column,
span: Math.min(MIN_MONTH_LABEL_GAP, columns - column + 1)
});
lastLabelColumn = column;
currentMonth = month;
});
return labels;
};
const topItem = (entry: UserUsageHeatmapEntry) => {
const models = Object.entries(entry.models ?? {}).sort((a, b) => b[1] - a[1]);
return models[0]?.[0] ?? null;
};
const modelPalette = (modelId: string | null) => {
if (!modelId) {
return palettes[0];
}
const charCode = modelId.toLowerCase().charCodeAt(0);
const index = Math.max(0, charCode - 97) % palettes.length;
return palettes[index];
};
const intensity = (entry: UserUsageHeatmapEntry) => {
const count = entry.messages;
if (count >= 20) return 4;
if (count >= 10) return 3;
if (count >= 5) return 2;
if (count > 0) return 1;
return 0;
};
const tooltipFor = (entry: UserUsageHeatmapEntry) => {
const label =
heatmapMode === 'weekly'
? `${$i18n.t('Week of')} ${weekStart(entry.date)}`
: heatmapMode === 'cumulative'
? `${$i18n.t('Through')} ${entry.date}`
: entry.date;
const model = topItem(entry);
const models = Object.entries(entry.models ?? {}).sort((a, b) => b[1] - a[1]);
const modelSummary =
models.length > 0
? models
.slice(0, 3)
.map(([id, count]) => `${modelName(id)} ${count}`)
.join(', ')
: $i18n.t('No model data');
return `${label}: ${formatNumber(entry.tokens)} ${$i18n.t('tokens')}, ${entry.messages.toLocaleString()} ${$i18n.t('messages')}, ${entry.chats.toLocaleString()} ${$i18n.t('chats')}${model ? ` (${modelName(model)})` : ''}. ${modelSummary}`;
};
const formatDuration = (seconds: number) => {
if (!seconds) return '0m';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
const modelName = (id: string | null) => (id ? (modelNames.get(id) ?? id) : '-');
onMount(loadUsage);
</script>
<div class="flex h-full min-h-0 flex-col">
<div class="mb-4">
<h2 class="text-sm font-medium text-gray-900 dark:text-white">{$i18n.t('Usage')}</h2>
</div>
{#if loading}
<div class="flex flex-1 items-center justify-center">
<Spinner className="size-5" />
</div>
{:else if !usage}
<div class="flex flex-1 items-center justify-center text-xs text-gray-500">
{$i18n.t('Failed to load usage')}
</div>
{:else}
<div class="scrollbar-hover min-h-0 flex-1 overflow-y-auto pr-1.5">
<UserSettingSection title={$i18n.t('Overview')} first>
<div class="grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-5">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{formatNumber(usage.totals.lifetime_tokens)}
</div>
<div class="mt-0.5 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Lifetime tokens')}
</div>
</div>
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{formatNumber(usage.totals.peak_daily_tokens)}
</div>
<div class="mt-0.5 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Peak tokens')}
</div>
</div>
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{formatDuration(usage.totals.longest_chat_seconds)}
</div>
<div class="mt-0.5 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Longest active chat')}
</div>
</div>
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{usage.totals.current_streak.toLocaleString()}
</div>
<div class="mt-0.5 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Current streak')}
</div>
</div>
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{usage.totals.longest_streak.toLocaleString()}
</div>
<div class="mt-0.5 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Longest streak')}
</div>
</div>
</div>
</UserSettingSection>
<section class="mt-4 w-full">
<div class="mb-2 flex min-w-0 items-center justify-between gap-3">
<h3 class="min-w-0 shrink truncate text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('Token activity')}
</h3>
<div
class="flex min-w-0 max-w-[70%] shrink items-center gap-3 overflow-hidden whitespace-nowrap"
>
{#each heatmapModes as mode}
<button
type="button"
class="min-w-0 shrink truncate whitespace-nowrap text-xs transition-colors {heatmapMode ===
mode.value
? 'text-gray-900 dark:text-white'
: 'text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}
"
on:click={() => {
heatmapMode = mode.value;
}}
>
{$i18n.t(mode.label)}
</button>
{/each}
</div>
</div>
<div class="pb-1">
<div class="w-full min-w-0 overflow-hidden" bind:clientWidth={heatmapContainerWidth}>
<div
class="mx-auto grid grid-flow-col"
style="width: min(100%, {heatmapGridWidth}); gap: {HEATMAP_GAP_PX}px; aspect-ratio: {heatmapColumns} / 7; grid-template-columns: repeat({heatmapColumns}, minmax(0, 1fr)); grid-template-rows: repeat(7, minmax(0, 1fr));"
>
{#each heatmapCells as entry}
{#if entry}
<Tooltip content={tooltipFor(entry)}>
<div
class="h-full w-full rounded-[2px] {modelPalette(topItem(entry))[
intensity(entry)
]}"
aria-label={tooltipFor(entry)}
></div>
</Tooltip>
{:else}
<div class="h-full w-full"></div>
{/if}
{/each}
</div>
<div
class="mx-auto mt-2 grid text-[0.6875rem] leading-none text-gray-400 dark:text-gray-600"
style="width: min(100%, {heatmapGridWidth}); column-gap: {HEATMAP_GAP_PX}px; grid-template-columns: repeat({heatmapColumns}, minmax(0, 1fr));"
>
{#each monthLabels as month}
<div class="truncate" style="grid-column: {month.column} / span {month.span};">
{month.label}
</div>
{/each}
</div>
</div>
{#if usage.top_models.length > 0}
<div
class="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[0.6875rem] text-gray-400 dark:text-gray-600"
>
{#each usage.top_models.slice(0, 6) as model}
<div class="flex min-w-0 items-center gap-1.5">
<span class="size-2 shrink-0 rounded-[2px] {modelPalette(model.model_id)[3]}"
></span>
<span class="max-w-28 truncate">{modelName(model.model_id)}</span>
</div>
{/each}
</div>
{/if}
</div>
</section>
{#if !hasUsage}
<UserSettingSection title={$i18n.t('Activity')}>
<div class="text-xs text-gray-500 dark:text-gray-400">
{$i18n.t('No usage data found')}
</div>
</UserSettingSection>
{:else}
<UserSettingSection title={$i18n.t('Activity insights')}>
<UserSettingRow label={$i18n.t('Models')}>
<span class="text-xs text-gray-900 dark:text-white">
{usage.totals.models_used.toLocaleString()}
</span>
</UserSettingRow>
<UserSettingRow label={$i18n.t('Average tokens per chat')}>
<span class="text-xs text-gray-900 dark:text-white">
{formatNumber(usage.insights.average_tokens_per_chat)}
</span>
</UserSettingRow>
<UserSettingRow label={$i18n.t('Average messages per active day')}>
<span class="text-xs text-gray-900 dark:text-white">
{usage.insights.average_messages_per_active_day.toLocaleString()}
</span>
</UserSettingRow>
<UserSettingRow label={$i18n.t('User messages')}>
<span class="text-xs text-gray-900 dark:text-white">
{usage.totals.user_messages.toLocaleString()} · {usage.insights.user_message_share}%
</span>
</UserSettingRow>
<UserSettingRow label={$i18n.t('Assistant messages')}>
<span class="text-xs text-gray-900 dark:text-white">
{usage.totals.assistant_messages.toLocaleString()} · {usage.insights
.assistant_message_share}%
</span>
</UserSettingRow>
<UserSettingRow label={$i18n.t('Total chats')}>
<span class="text-xs text-gray-900 dark:text-white">
{usage.totals.total_chats.toLocaleString()}
</span>
</UserSettingRow>
</UserSettingSection>
<UserSettingSection title={$i18n.t('Top models')}>
{#if usage.top_models.length === 0}
<div class="text-xs text-gray-500 dark:text-gray-400">
{$i18n.t('No model usage found')}
</div>
{:else}
{#each usage.top_models as model}
<UserSettingRow label={modelName(model.model_id)}>
<span class="text-xs text-gray-500 dark:text-gray-400">
{model.messages.toLocaleString()}
{$i18n.t('messages')} · {formatNumber(model.total_tokens)}
</span>
</UserSettingRow>
{/each}
{/if}
</UserSettingSection>
{#if usage.top_tools.length > 0}
<UserSettingSection title={$i18n.t('Most used tools')}>
{#each usage.top_tools as tool}
<UserSettingRow label={tool.name}>
<span class="text-xs text-gray-500 dark:text-gray-400">
{tool.count.toLocaleString()}
{$i18n.t('runs')}
</span>
</UserSettingRow>
{/each}
</UserSettingSection>
{/if}
{/if}
<div class="mt-4 text-right text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Token counts are estimates and may not reflect actual API usage')}
</div>
</div>
{/if}
</div>

View File

@@ -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 @@
<DatabaseSettings className="size-3.5" strokeWidth="2" />
<span>{$i18n.t('Data Controls')}</span>
</button>
{:else if tabId === 'usage'}
<button
role="tab"
aria-controls="tab-usage"
aria-selected={selectedTab === 'usage'}
class={tabButtonClass(selectedTab === 'usage')}
on:click={() => {
selectedTab = 'usage';
}}
>
<UsageIcon className="size-3.5" strokeWidth="2" />
<span>{$i18n.t('Usage')}</span>
</button>
{:else if tabId === 'archived_chats'}
<button
role="tab"
@@ -1168,6 +1202,8 @@
/>
{:else if selectedTab === 'data_controls'}
<DataControls {saveSettings} />
{:else if selectedTab === 'usage'}
<Usage />
{:else if selectedTab === 'archived_chats'}
<ArchivedChats />
{:else if selectedTab === 'account'}

View File

@@ -0,0 +1,17 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width={strokeWidth}
class={className}
>
<path d="M20 20H4V4" stroke-linecap="round" stroke-linejoin="round" />
<path d="M4 16.5L12 9L15 12L19.5 7.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>