mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-01 19:50:41 +02:00
refac
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
############################
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
485
src/lib/components/chat/Settings/Usage.svelte
Normal file
485
src/lib/components/chat/Settings/Usage.svelte
Normal 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>
|
||||
@@ -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'}
|
||||
|
||||
17
src/lib/components/icons/UsageIcon.svelte
Normal file
17
src/lib/components/icons/UsageIcon.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user