diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 5ed84baf2..26e6f4a86 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -68,6 +68,7 @@ from open_webui.socket.main import ( get_models_in_use, ) from open_webui.routers import ( + analytics, audio, images, ollama, @@ -1455,6 +1456,9 @@ app.include_router(functions.router, prefix="/api/v1/functions", tags=["function app.include_router( evaluations.router, prefix="/api/v1/evaluations", tags=["evaluations"] ) +app.include_router( + analytics.router, prefix="/api/v1/analytics", tags=["analytics"] +) app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"]) # SCIM 2.0 API for identity management diff --git a/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py b/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py new file mode 100644 index 000000000..5a139db3e --- /dev/null +++ b/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py @@ -0,0 +1,173 @@ +"""Add chat_message table + +Revision ID: 8452d01d26d7 +Revises: 374d2f66af06 +Create Date: 2026-02-01 04:00:00.000000 + +""" + +import time +import json +import logging +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +log = logging.getLogger(__name__) + +revision: str = "8452d01d26d7" +down_revision: Union[str, None] = "374d2f66af06" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Step 1: Create table + op.create_table( + "chat_message", + sa.Column("id", sa.Text(), primary_key=True), + sa.Column("chat_id", sa.Text(), nullable=False, index=True), + sa.Column("user_id", sa.Text(), index=True), + sa.Column("role", sa.Text(), nullable=False), + sa.Column("parent_id", sa.Text(), nullable=True), + sa.Column("content", sa.JSON(), nullable=True), + sa.Column("output", sa.JSON(), nullable=True), + sa.Column("model_id", sa.Text(), nullable=True, index=True), + sa.Column("files", sa.JSON(), nullable=True), + sa.Column("sources", sa.JSON(), nullable=True), + sa.Column("embeds", sa.JSON(), nullable=True), + sa.Column("done", sa.Boolean(), default=True), + sa.Column("status_history", sa.JSON(), nullable=True), + sa.Column("error", sa.JSON(), nullable=True), + sa.Column("usage", sa.JSON(), nullable=True), + sa.Column("created_at", sa.BigInteger(), index=True), + sa.Column("updated_at", sa.BigInteger()), + sa.ForeignKeyConstraint(["chat_id"], ["chat.id"], ondelete="CASCADE"), + ) + + # Create composite indexes + op.create_index( + "chat_message_chat_parent_idx", "chat_message", ["chat_id", "parent_id"] + ) + op.create_index( + "chat_message_model_created_idx", "chat_message", ["model_id", "created_at"] + ) + op.create_index( + "chat_message_user_created_idx", "chat_message", ["user_id", "created_at"] + ) + + # Step 2: Backfill from existing chats + conn = op.get_bind() + + chat_table = sa.table( + "chat", + sa.column("id", sa.Text()), + sa.column("user_id", sa.Text()), + sa.column("chat", sa.JSON()), + ) + + chat_message_table = sa.table( + "chat_message", + sa.column("id", sa.Text()), + sa.column("chat_id", sa.Text()), + sa.column("user_id", sa.Text()), + sa.column("role", sa.Text()), + sa.column("parent_id", sa.Text()), + sa.column("content", sa.JSON()), + sa.column("output", sa.JSON()), + sa.column("model_id", sa.Text()), + sa.column("files", sa.JSON()), + sa.column("sources", sa.JSON()), + sa.column("embeds", sa.JSON()), + sa.column("done", sa.Boolean()), + sa.column("status_history", sa.JSON()), + sa.column("error", sa.JSON()), + sa.column("usage", sa.JSON()), + sa.column("created_at", sa.BigInteger()), + sa.column("updated_at", sa.BigInteger()), + ) + + # Fetch all chats + chats = conn.execute( + sa.select(chat_table.c.id, chat_table.c.user_id, chat_table.c.chat) + ).fetchall() + + now = int(time.time()) + messages_inserted = 0 + messages_failed = 0 + + for chat_row in chats: + chat_id = chat_row[0] + user_id = chat_row[1] + chat_data = chat_row[2] + + if not chat_data: + continue + + # Handle both string and dict chat data + if isinstance(chat_data, str): + try: + chat_data = json.loads(chat_data) + except Exception: + continue + + history = chat_data.get("history", {}) + messages = history.get("messages", {}) + + for message_id, message in messages.items(): + if not isinstance(message, dict): + continue + + role = message.get("role") + if not role: + continue + + timestamp = message.get("timestamp", now) + + # Normalize timestamp: convert ms to seconds, validate range + if timestamp > 10_000_000_000: + timestamp = timestamp // 1000 + # Must be after 2020 and not too far in the future + if timestamp < 1577836800 or timestamp > now + 86400: + timestamp = now + + # Use savepoint to allow individual insert failures without aborting transaction + savepoint = conn.begin_nested() + try: + conn.execute( + sa.insert(chat_message_table).values( + id=f"{chat_id}-{message_id}", + chat_id=chat_id, + user_id=user_id, + role=role, + parent_id=message.get("parentId"), + content=message.get("content"), + output=message.get("output"), + model_id=message.get("model"), + files=message.get("files"), + sources=message.get("sources"), + embeds=message.get("embeds"), + done=message.get("done", True), + status_history=message.get("statusHistory"), + error=message.get("error"), + created_at=timestamp, + updated_at=timestamp, + ) + ) + savepoint.commit() + messages_inserted += 1 + except Exception as e: + savepoint.rollback() + messages_failed += 1 + log.warning(f"Failed to insert message {message_id}: {e}") + continue + + log.info(f"Backfilled {messages_inserted} messages into chat_message table ({messages_failed} failed)") + + +def downgrade() -> None: + op.drop_index("chat_message_user_created_idx", table_name="chat_message") + op.drop_index("chat_message_model_created_idx", table_name="chat_message") + op.drop_index("chat_message_chat_parent_idx", table_name="chat_message") + op.drop_table("chat_message") diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py new file mode 100644 index 000000000..9254baf5d --- /dev/null +++ b/backend/open_webui/models/chat_messages.py @@ -0,0 +1,545 @@ +import json +import time +import uuid +from typing import Any, Optional + +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, get_db_context + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import ( + BigInteger, + Boolean, + Column, + ForeignKey, + Text, + JSON, + Index, +) + +#################### +# Helpers +#################### + + +def _normalize_timestamp(timestamp: int) -> float: + """Normalize and validate timestamp. Returns current time if invalid.""" + now = time.time() + + # Convert milliseconds to seconds if needed + if timestamp > 10_000_000_000: + timestamp = timestamp / 1000 + + # Validate: must be after 2020 and not in the future (with 1 day tolerance) + min_valid = 1577836800 # 2020-01-01 00:00:00 UTC + max_valid = now + 86400 # 1 day in the future (clock skew tolerance) + + if timestamp < min_valid or timestamp > max_valid: + return now + + return timestamp + + +#################### +# ChatMessage DB Schema +#################### + + +class ChatMessage(Base): + __tablename__ = "chat_message" + + # Identity + id = Column(Text, primary_key=True) + chat_id = Column( + Text, ForeignKey("chat.id", ondelete="CASCADE"), nullable=False, index=True + ) + user_id = Column(Text, index=True) + + # Structure + role = Column(Text, nullable=False) # user, assistant, system + parent_id = Column(Text, nullable=True) + + # Content + content = Column(JSON, nullable=True) # Can be str or list of blocks + output = Column(JSON, nullable=True) + + # Model (for assistant messages) + model_id = Column(Text, nullable=True, index=True) + + # Attachments + files = Column(JSON, nullable=True) + sources = Column(JSON, nullable=True) + embeds = Column(JSON, nullable=True) + + # Status + done = Column(Boolean, default=True) + status_history = Column(JSON, nullable=True) + error = Column(JSON, nullable=True) + + # Usage (tokens, timing, etc.) + usage = Column(JSON, nullable=True) + + # Timestamps + created_at = Column(BigInteger, index=True) + updated_at = Column(BigInteger) + + __table_args__ = ( + Index("chat_message_chat_parent_idx", "chat_id", "parent_id"), + Index("chat_message_model_created_idx", "model_id", "created_at"), + Index("chat_message_user_created_idx", "user_id", "created_at"), + ) + + +#################### +# Pydantic Models +#################### + + +class ChatMessageModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + chat_id: str + user_id: str + role: str + parent_id: Optional[str] = None + content: Optional[Any] = None # str or list of blocks + output: Optional[list] = None + model_id: Optional[str] = None + files: Optional[list] = None + sources: Optional[list] = None + embeds: Optional[list] = None + done: bool = True + status_history: Optional[list] = None + error: Optional[dict] = None + usage: Optional[dict] = None + created_at: int + updated_at: int + + +#################### +# Table Operations +#################### + + +class ChatMessageTable: + def upsert_message( + self, + message_id: str, + chat_id: str, + user_id: str, + data: dict, + db: Optional[Session] = None, + ) -> Optional[ChatMessageModel]: + """Insert or update a chat message.""" + with get_db_context(db) as db: + now = int(time.time()) + timestamp = data.get("timestamp", now) + + # Use composite ID: {chat_id}-{message_id} + composite_id = f"{chat_id}-{message_id}" + + existing = db.get(ChatMessage, composite_id) + if existing: + # Update existing + if "role" in data: + existing.role = data["role"] + if "parent_id" in data: + existing.parent_id = data.get("parent_id") or data.get("parentId") + if "content" in data: + existing.content = data.get("content") + if "output" in data: + existing.output = data.get("output") + if "model_id" in data or "model" in data: + existing.model_id = data.get("model_id") or data.get("model") + if "files" in data: + existing.files = data.get("files") + if "sources" in data: + existing.sources = data.get("sources") + if "embeds" in data: + existing.embeds = data.get("embeds") + if "done" in data: + existing.done = data.get("done", True) + if "status_history" in data or "statusHistory" in data: + existing.status_history = data.get("status_history") or data.get( + "statusHistory" + ) + if "error" in data: + existing.error = data.get("error") + # Extract usage - check direct field first, then info.usage + usage = data.get("usage") + if not usage: + info = data.get("info", {}) + usage = info.get("usage") if info else None + if usage: + existing.usage = usage + existing.updated_at = now + db.commit() + db.refresh(existing) + return ChatMessageModel.model_validate(existing) + else: + # Insert new + # Extract usage - check direct field first, then info.usage + usage = data.get("usage") + if not usage: + info = data.get("info", {}) + usage = info.get("usage") if info else None + message = ChatMessage( + id=composite_id, + chat_id=chat_id, + user_id=user_id, + role=data.get("role", "user"), + parent_id=data.get("parent_id") or data.get("parentId"), + content=data.get("content"), + output=data.get("output"), + model_id=data.get("model_id") or data.get("model"), + files=data.get("files"), + sources=data.get("sources"), + embeds=data.get("embeds"), + done=data.get("done", True), + status_history=data.get("status_history") + or data.get("statusHistory"), + error=data.get("error"), + usage=usage, + created_at=timestamp, + updated_at=now, + ) + db.add(message) + db.commit() + db.refresh(message) + return ChatMessageModel.model_validate(message) + + def get_message_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[ChatMessageModel]: + with get_db_context(db) as db: + message = db.get(ChatMessage, id) + return ChatMessageModel.model_validate(message) if message else None + + def get_messages_by_chat_id( + self, chat_id: str, db: Optional[Session] = None + ) -> list[ChatMessageModel]: + with get_db_context(db) as db: + messages = ( + db.query(ChatMessage) + .filter_by(chat_id=chat_id) + .order_by(ChatMessage.created_at.asc()) + .all() + ) + return [ChatMessageModel.model_validate(message) for message in messages] + + def get_messages_by_user_id( + self, + user_id: str, + skip: int = 0, + limit: int = 50, + db: Optional[Session] = None, + ) -> list[ChatMessageModel]: + with get_db_context(db) as db: + messages = ( + db.query(ChatMessage) + .filter_by(user_id=user_id) + .order_by(ChatMessage.created_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + return [ChatMessageModel.model_validate(message) for message in messages] + + def get_messages_by_model_id( + self, + model_id: str, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + skip: int = 0, + limit: int = 100, + db: Optional[Session] = None, + ) -> list[ChatMessageModel]: + with get_db_context(db) as db: + query = db.query(ChatMessage).filter_by(model_id=model_id) + if start_date: + query = query.filter(ChatMessage.created_at >= start_date) + if end_date: + query = query.filter(ChatMessage.created_at <= end_date) + messages = ( + query.order_by(ChatMessage.created_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + return [ChatMessageModel.model_validate(message) for message in messages] + + def delete_messages_by_chat_id( + self, chat_id: str, db: Optional[Session] = None + ) -> bool: + with get_db_context(db) as db: + db.query(ChatMessage).filter_by(chat_id=chat_id).delete() + db.commit() + return True + + # Analytics methods + def get_message_count_by_model( + self, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + db: Optional[Session] = None, + ) -> dict[str, int]: + with get_db_context(db) as db: + from sqlalchemy import func + + query = db.query( + ChatMessage.model_id, func.count(ChatMessage.id).label("count") + ).filter(ChatMessage.role == "assistant", ChatMessage.model_id.isnot(None)) + + if start_date: + query = query.filter(ChatMessage.created_at >= start_date) + if end_date: + query = query.filter(ChatMessage.created_at <= end_date) + + results = query.group_by(ChatMessage.model_id).all() + return {row.model_id: row.count for row in results} + + def get_token_usage_by_model( + self, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + db: Optional[Session] = None, + ) -> dict[str, dict]: + """Aggregate token usage by model using database-level aggregation.""" + with get_db_context(db) as db: + from sqlalchemy import func, cast, Integer + + dialect = db.bind.dialect.name + + if dialect == "sqlite": + input_tokens = cast( + func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer + ) + output_tokens = cast( + func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer + ) + elif dialect == "postgresql": + # Use json_extract_path_text for PostgreSQL JSON columns + input_tokens = cast( + func.json_extract_path_text(ChatMessage.usage, "input_tokens"), Integer + ) + output_tokens = cast( + func.json_extract_path_text(ChatMessage.usage, "output_tokens"), Integer + ) + else: + raise NotImplementedError(f"Unsupported dialect: {dialect}") + + query = db.query( + ChatMessage.model_id, + func.coalesce(func.sum(input_tokens), 0).label("input_tokens"), + func.coalesce(func.sum(output_tokens), 0).label("output_tokens"), + func.count(ChatMessage.id).label("message_count"), + ).filter( + ChatMessage.role == "assistant", + ChatMessage.model_id.isnot(None), + ChatMessage.usage.isnot(None), + ) + + if start_date: + query = query.filter(ChatMessage.created_at >= start_date) + if end_date: + query = query.filter(ChatMessage.created_at <= end_date) + + results = query.group_by(ChatMessage.model_id).all() + + return { + row.model_id: { + "input_tokens": row.input_tokens, + "output_tokens": row.output_tokens, + "total_tokens": row.input_tokens + row.output_tokens, + "message_count": row.message_count, + } + for row in results + } + + def get_token_usage_by_user( + self, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + db: Optional[Session] = None, + ) -> dict[str, dict]: + """Aggregate token usage by user using database-level aggregation.""" + with get_db_context(db) as db: + from sqlalchemy import func, cast, Integer + + dialect = db.bind.dialect.name + + if dialect == "sqlite": + input_tokens = cast( + func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer + ) + output_tokens = cast( + func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer + ) + elif dialect == "postgresql": + # Use json_extract_path_text for PostgreSQL JSON columns + input_tokens = cast( + func.json_extract_path_text(ChatMessage.usage, "input_tokens"), Integer + ) + output_tokens = cast( + func.json_extract_path_text(ChatMessage.usage, "output_tokens"), Integer + ) + else: + raise NotImplementedError(f"Unsupported dialect: {dialect}") + + query = db.query( + ChatMessage.user_id, + func.coalesce(func.sum(input_tokens), 0).label("input_tokens"), + func.coalesce(func.sum(output_tokens), 0).label("output_tokens"), + func.count(ChatMessage.id).label("message_count"), + ).filter( + ChatMessage.role == "assistant", + ChatMessage.user_id.isnot(None), + ChatMessage.usage.isnot(None), + ) + + if start_date: + query = query.filter(ChatMessage.created_at >= start_date) + if end_date: + query = query.filter(ChatMessage.created_at <= end_date) + + results = query.group_by(ChatMessage.user_id).all() + + return { + row.user_id: { + "input_tokens": row.input_tokens, + "output_tokens": row.output_tokens, + "total_tokens": row.input_tokens + row.output_tokens, + "message_count": row.message_count, + } + for row in results + } + + def get_message_count_by_user( + self, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + db: Optional[Session] = None, + ) -> dict[str, int]: + with get_db_context(db) as db: + from sqlalchemy import func + + query = db.query( + ChatMessage.user_id, func.count(ChatMessage.id).label("count") + ) + + if start_date: + query = query.filter(ChatMessage.created_at >= start_date) + if end_date: + query = query.filter(ChatMessage.created_at <= end_date) + + results = query.group_by(ChatMessage.user_id).all() + return {row.user_id: row.count for row in results} + + def get_message_count_by_chat( + self, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + db: Optional[Session] = None, + ) -> dict[str, int]: + with get_db_context(db) as db: + from sqlalchemy import func + + query = db.query( + ChatMessage.chat_id, func.count(ChatMessage.id).label("count") + ) + + if start_date: + query = query.filter(ChatMessage.created_at >= start_date) + if end_date: + query = query.filter(ChatMessage.created_at <= end_date) + + results = query.group_by(ChatMessage.chat_id).all() + return {row.chat_id: row.count for row in results} + + def get_daily_message_counts_by_model( + self, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + db: Optional[Session] = None, + ) -> dict[str, dict[str, int]]: + """Get message counts grouped by day and model.""" + with get_db_context(db) as db: + from datetime import datetime, timedelta + + query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter( + ChatMessage.role == "assistant", + ChatMessage.model_id.isnot(None) + ) + + if start_date: + query = query.filter(ChatMessage.created_at >= start_date) + if end_date: + query = query.filter(ChatMessage.created_at <= end_date) + + results = query.all() + + # Group by date -> model -> count + daily_counts: dict[str, dict[str, int]] = {} + for timestamp, model_id in results: + date_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime("%Y-%m-%d") + if date_str not in daily_counts: + daily_counts[date_str] = {} + daily_counts[date_str][model_id] = daily_counts[date_str].get(model_id, 0) + 1 + + # Fill in missing days + if start_date and end_date: + current = datetime.fromtimestamp(_normalize_timestamp(start_date)) + end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date)) + while current <= end_dt: + date_str = current.strftime("%Y-%m-%d") + if date_str not in daily_counts: + daily_counts[date_str] = {} + current += timedelta(days=1) + + return daily_counts + + def get_hourly_message_counts_by_model( + self, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + db: Optional[Session] = None, + ) -> dict[str, dict[str, int]]: + """Get message counts grouped by hour and model.""" + with get_db_context(db) as db: + from datetime import datetime, timedelta + + query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter( + ChatMessage.role == "assistant", + ChatMessage.model_id.isnot(None) + ) + + if start_date: + query = query.filter(ChatMessage.created_at >= start_date) + if end_date: + query = query.filter(ChatMessage.created_at <= end_date) + + results = query.all() + + # Group by hour -> model -> count + hourly_counts: dict[str, dict[str, int]] = {} + for timestamp, model_id in results: + hour_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime("%Y-%m-%d %H:00") + if hour_str not in hourly_counts: + hourly_counts[hour_str] = {} + hourly_counts[hour_str][model_id] = hourly_counts[hour_str].get(model_id, 0) + 1 + + # Fill in missing hours + if start_date and end_date: + current = datetime.fromtimestamp(_normalize_timestamp(start_date)).replace(minute=0, second=0, microsecond=0) + end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date)) + while current <= end_dt: + hour_str = current.strftime("%Y-%m-%d %H:00") + if hour_str not in hourly_counts: + hourly_counts[hour_str] = {} + current += timedelta(hours=1) + + return hourly_counts + + +ChatMessages = ChatMessageTable() diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index eb0763048..51a714cea 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.models.tags import TagModel, Tag, Tags from open_webui.models.folders import Folders +from open_webui.models.chat_messages import ChatMessages from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db from pydantic import BaseModel, ConfigDict @@ -314,6 +315,22 @@ class ChatTable: db.add(chat_item) db.commit() db.refresh(chat_item) + + # Dual-write initial messages to chat_message table + try: + history = form_data.chat.get("history", {}) + messages = history.get("messages", {}) + for message_id, message in messages.items(): + if isinstance(message, dict) and message.get("role"): + ChatMessages.upsert_message( + message_id=message_id, + chat_id=id, + user_id=user_id, + data=message, + ) + except Exception as e: + log.warning(f"Failed to write initial messages to chat_message table: {e}") + return ChatModel.model_validate(chat_item) if chat_item else None def _chat_import_form_to_chat_model( @@ -356,6 +373,23 @@ class ChatTable: db.add_all(chats) db.commit() + + # Dual-write messages to chat_message table + try: + for form_data, chat_obj in zip(chat_import_forms, chats): + history = form_data.chat.get("history", {}) + messages = history.get("messages", {}) + for message_id, message in messages.items(): + if isinstance(message, dict) and message.get("role"): + ChatMessages.upsert_message( + message_id=message_id, + chat_id=chat_obj.id, + user_id=user_id, + data=message, + ) + except Exception as e: + log.warning(f"Failed to write imported messages to chat_message table: {e}") + return [ChatModel.model_validate(chat) for chat in chats] def update_chat_by_id( @@ -458,6 +492,18 @@ class ChatTable: history["currentId"] = message_id chat["history"] = history + + # Dual-write to chat_message table + try: + ChatMessages.upsert_message( + message_id=message_id, + chat_id=id, + user_id=self.get_chat_by_id(id).user_id, + data=history["messages"][message_id], + ) + except Exception as e: + log.warning(f"Failed to write to chat_message table: {e}") + return self.update_chat_by_id(id, chat) def add_message_status_to_chat_by_id_and_message_id( diff --git a/backend/open_webui/routers/analytics.py b/backend/open_webui/routers/analytics.py new file mode 100644 index 000000000..d8f6928ff --- /dev/null +++ b/backend/open_webui/routers/analytics.py @@ -0,0 +1,247 @@ +from typing import Optional +import logging +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel + +from open_webui.models.chat_messages import ChatMessages, ChatMessageModel +from open_webui.utils.auth import get_admin_user +from open_webui.internal.db import get_session +from sqlalchemy.orm import Session + +log = logging.getLogger(__name__) + + +router = APIRouter() + + +#################### +# Response Models +#################### + + +class ModelAnalyticsEntry(BaseModel): + model_id: str + count: int + + +class ModelAnalyticsResponse(BaseModel): + models: list[ModelAnalyticsEntry] + + +class UserAnalyticsEntry(BaseModel): + user_id: str + name: Optional[str] = None + email: Optional[str] = None + count: int + input_tokens: int = 0 + output_tokens: int = 0 + total_tokens: int = 0 + + +class UserAnalyticsResponse(BaseModel): + users: list[UserAnalyticsEntry] + + +#################### +# Endpoints +#################### + + +@router.get("/models", response_model=ModelAnalyticsResponse) +async def get_model_analytics( + start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"), + end_date: Optional[int] = Query(None, description="End timestamp (epoch)"), + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): + """Get message counts per model.""" + counts = ChatMessages.get_message_count_by_model( + start_date=start_date, end_date=end_date, db=db + ) + models = [ + ModelAnalyticsEntry(model_id=model_id, count=count) + for model_id, count in sorted(counts.items(), key=lambda x: -x[1]) + ] + return ModelAnalyticsResponse(models=models) + + +@router.get("/users", response_model=UserAnalyticsResponse) +async def get_user_analytics( + start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"), + end_date: Optional[int] = Query(None, description="End timestamp (epoch)"), + limit: int = Query(50, description="Max users to return"), + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): + """Get message counts and token usage per user with user info.""" + from open_webui.models.users import Users + + counts = ChatMessages.get_message_count_by_user( + start_date=start_date, end_date=end_date, db=db + ) + token_usage = ChatMessages.get_token_usage_by_user( + start_date=start_date, end_date=end_date, db=db + ) + + # Get user info for top users + top_user_ids = [uid for uid, _ in sorted(counts.items(), key=lambda x: -x[1])[:limit]] + user_info = {u.id: u for u in Users.get_users_by_user_ids(top_user_ids, db=db)} + + users = [] + for user_id in top_user_ids: + u = user_info.get(user_id) + tokens = token_usage.get(user_id, {}) + users.append(UserAnalyticsEntry( + user_id=user_id, + name=u.name if u else None, + email=u.email if u else None, + count=counts[user_id], + input_tokens=tokens.get("input_tokens", 0), + output_tokens=tokens.get("output_tokens", 0), + total_tokens=tokens.get("total_tokens", 0), + )) + + return UserAnalyticsResponse(users=users) + + +@router.get("/messages", response_model=list[ChatMessageModel]) +async def get_messages( + model_id: Optional[str] = Query(None, description="Filter by model ID"), + user_id: Optional[str] = Query(None, description="Filter by user ID"), + chat_id: Optional[str] = Query(None, description="Filter by chat ID"), + start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"), + end_date: Optional[int] = Query(None, description="End timestamp (epoch)"), + skip: int = Query(0), + limit: int = Query(50, le=100), + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): + """Query messages with filters.""" + if chat_id: + return ChatMessages.get_messages_by_chat_id(chat_id=chat_id, db=db) + elif model_id: + return ChatMessages.get_messages_by_model_id( + model_id=model_id, + start_date=start_date, + end_date=end_date, + skip=skip, + limit=limit, + db=db, + ) + elif user_id: + return ChatMessages.get_messages_by_user_id( + user_id=user_id, skip=skip, limit=limit, db=db + ) + else: + # Return empty if no filter specified + return [] + + +class SummaryResponse(BaseModel): + total_messages: int + total_chats: int + total_models: int + total_users: int + + +@router.get("/summary", response_model=SummaryResponse) +async def get_summary( + start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"), + end_date: Optional[int] = Query(None, description="End timestamp (epoch)"), + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): + """Get summary statistics for the dashboard.""" + model_counts = ChatMessages.get_message_count_by_model( + start_date=start_date, end_date=end_date, db=db + ) + user_counts = ChatMessages.get_message_count_by_user( + start_date=start_date, end_date=end_date, db=db + ) + chat_counts = ChatMessages.get_message_count_by_chat( + start_date=start_date, end_date=end_date, db=db + ) + + return SummaryResponse( + total_messages=sum(model_counts.values()), + total_chats=len(chat_counts), + total_models=len(model_counts), + total_users=len(user_counts), + ) + + +class DailyStatsEntry(BaseModel): + date: str + models: dict[str, int] + + +class DailyStatsResponse(BaseModel): + data: list[DailyStatsEntry] + + +@router.get("/daily", response_model=DailyStatsResponse) +async def get_daily_stats( + start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"), + end_date: Optional[int] = Query(None, description="End timestamp (epoch)"), + granularity: str = Query("daily", description="Granularity: 'hourly' or 'daily'"), + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): + """Get message counts grouped by model for time-series chart.""" + if granularity == "hourly": + counts = ChatMessages.get_hourly_message_counts_by_model( + start_date=start_date, end_date=end_date, db=db + ) + else: + counts = ChatMessages.get_daily_message_counts_by_model( + start_date=start_date, end_date=end_date, db=db + ) + return DailyStatsResponse( + data=[ + DailyStatsEntry(date=date, models=models) + for date, models in sorted(counts.items()) + ] + ) + + +class TokenUsageEntry(BaseModel): + model_id: str + input_tokens: int + output_tokens: int + total_tokens: int + message_count: int + + +class TokenUsageResponse(BaseModel): + models: list[TokenUsageEntry] + total_input_tokens: int + total_output_tokens: int + total_tokens: int + + +@router.get("/tokens", response_model=TokenUsageResponse) +async def get_token_usage( + start_date: Optional[int] = Query(None), + end_date: Optional[int] = Query(None), + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): + """Get token usage aggregated by model.""" + usage = ChatMessages.get_token_usage_by_model( + start_date=start_date, end_date=end_date, db=db + ) + + models = [ + TokenUsageEntry(model_id=model_id, **data) + for model_id, data in sorted(usage.items(), key=lambda x: -x[1]["total_tokens"]) + ] + + total_input = sum(m.input_tokens for m in models) + total_output = sum(m.output_tokens for m in models) + + return TokenUsageResponse( + models=models, + total_input_tokens=total_input, + total_output_tokens=total_output, + total_tokens=total_input + total_output, + ) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 385bc30a9..0f40ce594 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -107,6 +107,7 @@ from open_webui.utils.filter import ( ) from open_webui.utils.code_interpreter import execute_code_jupyter from open_webui.utils.payload import apply_system_prompt_to_body +from open_webui.utils.response import normalize_usage from open_webui.utils.mcp.client import MCPClient @@ -3362,6 +3363,7 @@ async def process_chat_response( "content": content, } ] + usage = None reasoning_tags_param = metadata.get("params", {}).get("reasoning_tags") DETECT_REASONING_TAGS = reasoning_tags_param is not False @@ -3402,6 +3404,7 @@ async def process_chat_response( async def stream_body_handler(response, form_data): nonlocal content nonlocal content_blocks + nonlocal usage nonlocal output response_tool_calls = [] @@ -3509,10 +3512,11 @@ async def process_chat_response( else: choices = data.get("choices", []) - # 17421 - usage = data.get("usage", {}) or {} - usage.update(data.get("timings", {})) # llama.cpp - if usage: + # Normalize usage data to standard format + raw_usage = data.get("usage", {}) or {} + raw_usage.update(data.get("timings", {})) # llama.cpp + if raw_usage: + usage = normalize_usage(raw_usage) await event_emitter( { "type": "chat:completion", @@ -4310,8 +4314,15 @@ async def process_chat_response( { "content": serialize_output(output), "output": output, + **({"usage": usage} if usage else {}), }, ) + elif usage: + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata["chat_id"], + metadata["message_id"], + {"usage": usage}, + ) # Send a webhook notification if the user is not active if not Users.is_user_active(user.id): diff --git a/backend/open_webui/utils/response.py b/backend/open_webui/utils/response.py index 2a54b9af6..52539860a 100644 --- a/backend/open_webui/utils/response.py +++ b/backend/open_webui/utils/response.py @@ -6,6 +6,47 @@ from open_webui.utils.misc import ( ) +def normalize_usage(usage: dict) -> dict: + """ + Normalize usage statistics to standard format. + Handles OpenAI, Ollama, and llama.cpp formats. + + Adds standardized token fields to the original data: + - input_tokens: Number of tokens in the prompt + - output_tokens: Number of tokens generated + - total_tokens: Sum of input and output tokens + """ + if not usage: + return {} + + # Map various field names to standard names + input_tokens = ( + usage.get("input_tokens") # Already standard + or usage.get("prompt_tokens") # OpenAI + or usage.get("prompt_eval_count") # Ollama + or usage.get("prompt_n") # llama.cpp + or 0 + ) + + output_tokens = ( + usage.get("output_tokens") # Already standard + or usage.get("completion_tokens") # OpenAI + or usage.get("eval_count") # Ollama + or usage.get("predicted_n") # llama.cpp + or 0 + ) + + total_tokens = usage.get("total_tokens") or (input_tokens + output_tokens) + + # Add standardized fields to original data + result = dict(usage) + result["input_tokens"] = int(input_tokens) + result["output_tokens"] = int(output_tokens) + result["total_tokens"] = int(total_tokens) + + return result + + def convert_ollama_tool_call_to_openai(tool_calls: list) -> list: openai_tool_calls = [] for tool_call in tool_calls: @@ -24,7 +65,19 @@ def convert_ollama_tool_call_to_openai(tool_calls: list) -> list: def convert_ollama_usage_to_openai(data: dict) -> dict: + input_tokens = int(data.get("prompt_eval_count", 0)) + output_tokens = int(data.get("eval_count", 0)) + total_tokens = input_tokens + output_tokens + return { + # Standardized fields + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": total_tokens, + # OpenAI-compatible fields (for backward compatibility) + "prompt_tokens": input_tokens, + "completion_tokens": output_tokens, + # Ollama-specific metrics "response_token/s": ( round( ( @@ -56,22 +109,13 @@ def convert_ollama_usage_to_openai(data: dict) -> dict: "total_duration": data.get("total_duration", 0), "load_duration": data.get("load_duration", 0), "prompt_eval_count": data.get("prompt_eval_count", 0), - "prompt_tokens": int( - data.get("prompt_eval_count", 0) - ), # This is the OpenAI compatible key "prompt_eval_duration": data.get("prompt_eval_duration", 0), "eval_count": data.get("eval_count", 0), - "completion_tokens": int( - data.get("eval_count", 0) - ), # This is the OpenAI compatible key "eval_duration": data.get("eval_duration", 0), "approximate_total": (lambda s: f"{s // 3600}h{(s % 3600) // 60}m{s % 60}s")( (data.get("total_duration", 0) or 0) // 1_000_000_000 ), - "total_tokens": int( # This is the OpenAI compatible key - data.get("prompt_eval_count", 0) + data.get("eval_count", 0) - ), - "completion_tokens_details": { # This is the OpenAI compatible key + "completion_tokens_details": { "reasoning_tokens": 0, "accepted_prediction_tokens": 0, "rejected_prediction_tokens": 0, diff --git a/src/lib/apis/analytics/index.ts b/src/lib/apis/analytics/index.ts new file mode 100644 index 000000000..19ef2a388 --- /dev/null +++ b/src/lib/apis/analytics/index.ts @@ -0,0 +1,231 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export const getModelAnalytics = async ( + token: string = '', + startDate: number | null = null, + endDate: number | null = null +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/models?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getUserAnalytics = async ( + token: string = '', + startDate: number | null = null, + endDate: number | null = null, + limit: number = 50 +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + if (limit) searchParams.append('limit', limit.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/users?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getMessages = async ( + token: string = '', + modelId: string | null = null, + userId: string | null = null, + chatId: string | null = null, + startDate: number | null = null, + endDate: number | null = null, + skip: number = 0, + limit: number = 50 +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (modelId) searchParams.append('model_id', modelId); + if (userId) searchParams.append('user_id', userId); + if (chatId) searchParams.append('chat_id', chatId); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + if (skip) searchParams.append('skip', skip.toString()); + if (limit) searchParams.append('limit', limit.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/messages?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getSummary = async ( + token: string = '', + startDate: number | null = null, + endDate: number | null = null +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/summary?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getDailyStats = async ( + token: string = '', + startDate: number | null = null, + endDate: number | null = null, + granularity: 'hourly' | 'daily' = 'daily' +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + searchParams.append('granularity', granularity); + + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/daily?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getTokenUsage = async ( + token: string = '', + startDate: number | null = null, + endDate: number | null = null +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (startDate) searchParams.append('start_date', startDate.toString()); + if (endDate) searchParams.append('end_date', endDate.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/tokens?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/components/admin/Analytics.svelte b/src/lib/components/admin/Analytics.svelte new file mode 100644 index 000000000..4aa6ae86f --- /dev/null +++ b/src/lib/components/admin/Analytics.svelte @@ -0,0 +1,24 @@ + + +{#if loaded} +
+ +
+{/if} diff --git a/src/lib/components/admin/Analytics/ChartLine.svelte b/src/lib/components/admin/Analytics/ChartLine.svelte new file mode 100644 index 000000000..5c3ffabdf --- /dev/null +++ b/src/lib/components/admin/Analytics/ChartLine.svelte @@ -0,0 +1,129 @@ + + +
+ (hoveredIdx = null)} + > + {#each models as m} + + {/each} + {#if hoveredIdx !== null} + + {#each models as m} + {@const v = hovered?.models?.[m] || 0} + {#if v > 0} + + {/if} + {/each} + {/if} + + + {#if data.length > 1} + {@const labelCount = Math.min(7, data.length)} + {@const step = labelCount > 1 ? Math.floor((data.length - 1) / (labelCount - 1)) || 1 : 1} + {@const isHourly = data[0]?.date?.includes(':')} + {@const dateFormat = isHourly ? 'h A' : period === 'year' || period === 'all' ? 'M/D/YY' : 'M/D'} +
+ {#each Array(labelCount) as _, i} + {@const idx = i === labelCount - 1 ? data.length - 1 : Math.min(i * step, data.length - 1)} + {#if data[idx]} + {dayjs(data[idx].date).format(dateFormat)} + {/if} + {/each} +
+ {/if} + {#if hovered} + {@const total = Object.values(hovered.models || {}).reduce((a, b) => a + b, 0)} +
+
+
+ {#if hovered.date?.includes(':')} + {dayjs(hovered.date).format('MMM D, h A')} + {:else} + {dayjs(hovered.date).format('MMM D, YYYY')} + {/if} +
+ {#each Object.entries(hovered.models || {}) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) as [n, c]} +
+ {n} + {c.toLocaleString()} + ({total > 0 ? ((c / total) * 100).toFixed(0) : 0}%) +
+ {/each} +
+
+ {/if} +
diff --git a/src/lib/components/admin/Analytics/Dashboard.svelte b/src/lib/components/admin/Analytics/Dashboard.svelte new file mode 100644 index 000000000..82eb5b581 --- /dev/null +++ b/src/lib/components/admin/Analytics/Dashboard.svelte @@ -0,0 +1,346 @@ + + + +
+
+ {$i18n.t('Analytics')} +
+ +
+ + +{#if !loading} +
+ {summary.total_messages.toLocaleString()} {$i18n.t('messages')} + {formatNumber(totalTokens.total)} {$i18n.t('tokens')} + {summary.total_chats.toLocaleString()} {$i18n.t('chats')} + {summary.total_users} {$i18n.t('users')} +
+ + + {#if dailyStats.length > 1} + {@const allModels = [...new Set(dailyStats.flatMap(d => Object.keys(d.models || {})))]} + {@const topModels = allModels.slice(0, 8)} + {@const chartColors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']} + {@const periodMap = { '24h': 'hour', '7d': 'week', '30d': 'month', '90d': 'year', 'all': 'all' }} +
+
+ {$i18n.t(selectedPeriod === '24h' ? 'Hourly Messages' : 'Daily Messages')} +
+ +
+ {/if} +{/if} + +{#if loading} +
+ +
+{:else} +
+ +
+
+ {$i18n.t('Model Usage')} +
+
+ + + + + + + + + + + + {#each sortedModels as model, idx (model.model_id)} + + + + + + + + {/each} + {#if sortedModels.length === 0} + + {/if} + +
# toggleModelSort('name')} + > +
+ {$i18n.t('Model')} + {#if modelOrderBy === 'name'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleModelSort('count')} + > +
+ {$i18n.t('Messages')} + {#if modelOrderBy === 'count'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
{$i18n.t('Tokens')}%
{idx + 1} +
+ {model.name} + {model.name} +
+
{model.count.toLocaleString()}{formatNumber(tokenStats[model.model_id]?.total_tokens ?? 0)} + {totalModelMessages > 0 ? ((model.count / totalModelMessages) * 100).toFixed(1) : 0}% +
{$i18n.t('No data')}
+
+
+ + +
+
+ {$i18n.t('User Activity')} +
+
+ + + + + + + + + + + {#each sortedUsers as user, idx (user.user_id)} + + + + + + + {/each} + {#if sortedUsers.length === 0} + + {/if} + +
# toggleUserSort('name')} + > +
+ {$i18n.t('User')} + {#if userOrderBy === 'name'} + + {#if userDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleUserSort('count')} + > +
+ {$i18n.t('Messages')} + {#if userOrderBy === 'count'} + + {#if userDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
{$i18n.t('Tokens')}
{idx + 1} +
+ {user.name + {user.name || user.email || user.user_id.substring(0, 8)} +
+
{user.count.toLocaleString()}{formatNumber(user.total_tokens ?? 0)}
{$i18n.t('No data')}
+
+
+
+ +
+ ⓘ {$i18n.t('Message counts are based on assistant responses.')} +
+{/if} diff --git a/src/lib/components/admin/Analytics/ModelUsage.svelte b/src/lib/components/admin/Analytics/ModelUsage.svelte new file mode 100644 index 000000000..d7212f721 --- /dev/null +++ b/src/lib/components/admin/Analytics/ModelUsage.svelte @@ -0,0 +1,141 @@ + + +
+
+ {$i18n.t('Model Usage')} + {totalMessages} {$i18n.t('messages')} +
+
+ +
+ {#if loading} +
+ +
+ {/if} + + {#if !modelStats.length && !loading} +
{$i18n.t('No data found')}
+ {:else if modelStats.length} + + + + + + + + + + + {#each sortedModels as model, idx (model.model_id)} + + + + + + + {/each} + +
# toggleSort('name')} + > +
+ {$i18n.t('Model')} + {#if orderBy === 'name'} + {#if direction === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
toggleSort('count')} + > +
+ {$i18n.t('Messages')} + {#if orderBy === 'count'} + {#if direction === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
{$i18n.t('Share')}
+ {idx + 1} + +
+ {model.name} + {model.name} +
+
+ {model.count.toLocaleString()} + + {((model.count / totalMessages) * 100).toFixed(1)}% +
+ {/if} +
+ +
+
+ ⓘ {$i18n.t('Message counts are based on assistant responses.')} +
+
diff --git a/src/lib/components/admin/Analytics/UserUsage.svelte b/src/lib/components/admin/Analytics/UserUsage.svelte new file mode 100644 index 000000000..015ba195d --- /dev/null +++ b/src/lib/components/admin/Analytics/UserUsage.svelte @@ -0,0 +1,129 @@ + + +
+
+ {$i18n.t('User Activity')} + {userStats.length} {$i18n.t('users')} +
+
+ +
+ {#if loading} +
+ +
+ {/if} + + {#if !userStats.length && !loading} +
{$i18n.t('No data found')}
+ {:else if userStats.length} + + + + + + + + + + + {#each sortedUsers as user, idx (user.user_id)} + + + + + + + {/each} + +
# toggleSort('user_id')} + > +
+ {$i18n.t('User')} + {#if orderBy === 'user_id'} + {#if direction === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
toggleSort('count')} + > +
+ {$i18n.t('Messages')} + {#if orderBy === 'count'} + {#if direction === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
{$i18n.t('Share')}
+ {idx + 1} + + + {user.user_id.substring(0, 8)}... + + + {user.count.toLocaleString()} + + {((user.count / totalMessages) * 100).toFixed(1)}% +
+ {/if} +
+ +
+
+ ⓘ {$i18n.t('Showing all messages (user + assistant) per user.')} +
+
diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 96f290a2b..3577eb004 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -28,6 +28,10 @@ import hljs from 'highlight.js'; export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +export const formatNumber = (num: number): string => { + return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(num); +}; + function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } diff --git a/src/routes/(app)/admin/+layout.svelte b/src/routes/(app)/admin/+layout.svelte index ec3d931b4..6577cdabe 100644 --- a/src/routes/(app)/admin/+layout.svelte +++ b/src/routes/(app)/admin/+layout.svelte @@ -66,12 +66,12 @@ href="/admin">{$i18n.t('Users')} - + >