Merge pull request #21106 from open-webui/chat-message-rebased
feat: analytics
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
545
backend/open_webui/models/chat_messages.py
Normal file
545
backend/open_webui/models/chat_messages.py
Normal file
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
247
backend/open_webui/routers/analytics.py
Normal file
247
backend/open_webui/routers/analytics.py
Normal file
@@ -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,
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
231
src/lib/apis/analytics/index.ts
Normal file
231
src/lib/apis/analytics/index.ts
Normal file
@@ -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;
|
||||
};
|
||||
24
src/lib/components/admin/Analytics.svelte
Normal file
24
src/lib/components/admin/Analytics.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script>
|
||||
import { onMount, getContext } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { user } from '$lib/stores';
|
||||
|
||||
import Dashboard from './Analytics/Dashboard.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
let loaded = false;
|
||||
|
||||
onMount(async () => {
|
||||
if ($user?.role !== 'admin') {
|
||||
await goto('/');
|
||||
}
|
||||
loaded = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loaded}
|
||||
<div class="w-full h-full pb-2 px-[16px]">
|
||||
<Dashboard />
|
||||
</div>
|
||||
{/if}
|
||||
129
src/lib/components/admin/Analytics/ChartLine.svelte
Normal file
129
src/lib/components/admin/Analytics/ChartLine.svelte
Normal file
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface Props {
|
||||
data: { date: string; models: Record<string, number> }[];
|
||||
models: string[];
|
||||
colors: string[];
|
||||
height?: number;
|
||||
period?: 'hour' | 'week' | 'month' | 'year' | 'all';
|
||||
}
|
||||
|
||||
let { data, models, colors, height = 300, period = 'week' }: Props = $props();
|
||||
|
||||
let hoveredIdx: number | null = $state(null);
|
||||
let mouseX = $state(0);
|
||||
|
||||
let colorMap = $derived(new Map(models.map((n, i) => [n, colors[i % colors.length]])));
|
||||
let maxCount = $derived(Math.max(...data.flatMap((d) => Object.values(d.models || {})), 1));
|
||||
|
||||
const pad = { t: 8, r: 0, b: 20, l: 0 };
|
||||
const w = 1000;
|
||||
let cw = $derived(w - pad.l - pad.r);
|
||||
let ch = $derived(height - pad.t - pad.b);
|
||||
|
||||
const getX = (i: number) =>
|
||||
data.length <= 1 ? pad.l + cw / 2 : pad.l + (i / (data.length - 1)) * cw;
|
||||
const getY = (v: number) => pad.t + ch - (v / maxCount) * ch;
|
||||
|
||||
const path = (m: string) => {
|
||||
const pts = data.map((d, i) => `${getX(i)},${getY(d.models?.[m] || 0)}`);
|
||||
return pts.length > 1 ? `M${pts.join('L')}` : '';
|
||||
};
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const svg = e.currentTarget as SVGSVGElement;
|
||||
const r = svg.getBoundingClientRect();
|
||||
mouseX = (e.clientX - r.left) * (w / r.width);
|
||||
hoveredIdx = Math.max(
|
||||
0,
|
||||
Math.min(data.length - 1, Math.round(((mouseX - pad.l) / cw) * (data.length - 1)))
|
||||
);
|
||||
};
|
||||
|
||||
let hovered = $derived(hoveredIdx !== null ? data[hoveredIdx] : null);
|
||||
</script>
|
||||
|
||||
<div class="relative w-full" style="height:{height}px">
|
||||
<svg
|
||||
viewBox="0 0 {w} {height - 20}"
|
||||
class="h-[calc(100%-20px)] w-full"
|
||||
preserveAspectRatio="none"
|
||||
onmousemove={onMove}
|
||||
onmouseleave={() => (hoveredIdx = null)}
|
||||
>
|
||||
{#each models as m}
|
||||
<path
|
||||
d={path(m)}
|
||||
fill="none"
|
||||
stroke={colorMap.get(m)}
|
||||
stroke-width="1.5"
|
||||
class={hovered && !hovered.models?.[m] ? 'opacity-20' : ''}
|
||||
/>
|
||||
{/each}
|
||||
{#if hoveredIdx !== null}
|
||||
<line
|
||||
x1={getX(hoveredIdx)}
|
||||
y1={pad.t}
|
||||
x2={getX(hoveredIdx)}
|
||||
y2={ch + pad.t}
|
||||
stroke="#ddd"
|
||||
stroke-width="1"
|
||||
/>
|
||||
{#each models as m}
|
||||
{@const v = hovered?.models?.[m] || 0}
|
||||
{#if v > 0}
|
||||
<circle cx={getX(hoveredIdx)} cy={getY(v)} r="3" fill={colorMap.get(m)} />
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</svg>
|
||||
<!-- X-axis labels as HTML -->
|
||||
{#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'}
|
||||
<div class="flex justify-between px-0.5 text-[10px] text-gray-400">
|
||||
{#each Array(labelCount) as _, i}
|
||||
{@const idx = i === labelCount - 1 ? data.length - 1 : Math.min(i * step, data.length - 1)}
|
||||
{#if data[idx]}
|
||||
<span class={i === 0 ? 'text-left' : i === labelCount - 1 ? 'text-right' : 'text-center'}
|
||||
>{dayjs(data[idx].date).format(dateFormat)}</span
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if hovered}
|
||||
{@const total = Object.values(hovered.models || {}).reduce((a, b) => a + b, 0)}
|
||||
<div
|
||||
class="pointer-events-none absolute top-1 text-[11px]"
|
||||
style="left:{Math.min(Math.max((mouseX / w) * 100, 8), 92)}%"
|
||||
>
|
||||
<div
|
||||
class="min-w-[140px] -translate-x-1/2 rounded border border-gray-100 bg-white px-2.5 py-1.5 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div class="mb-1.5 text-[10px] text-gray-400">
|
||||
{#if hovered.date?.includes(':')}
|
||||
{dayjs(hovered.date).format('MMM D, h A')}
|
||||
{:else}
|
||||
{dayjs(hovered.date).format('MMM D, YYYY')}
|
||||
{/if}
|
||||
</div>
|
||||
{#each Object.entries(hovered.models || {})
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5) as [n, c]}
|
||||
<div class="flex items-center justify-between gap-2 py-0.5">
|
||||
<span class="min-w-0 truncate text-gray-600 dark:text-gray-300">{n}</span>
|
||||
<span class="shrink-0 text-gray-900 tabular-nums dark:text-white"
|
||||
>{c.toLocaleString()}
|
||||
<span class="text-gray-400">({total > 0 ? ((c / total) * 100).toFixed(0) : 0}%)</span
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
346
src/lib/components/admin/Analytics/Dashboard.svelte
Normal file
346
src/lib/components/admin/Analytics/Dashboard.svelte
Normal file
@@ -0,0 +1,346 @@
|
||||
<script lang="ts">
|
||||
import { onMount, getContext } from 'svelte';
|
||||
import { models } from '$lib/stores';
|
||||
import { getSummary, getModelAnalytics, getUserAnalytics, getDailyStats, getTokenUsage } from '$lib/apis/analytics';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
import ChartLine from './ChartLine.svelte';
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
import { formatNumber } from '$lib/utils';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
// Time period
|
||||
let selectedPeriod = '7d';
|
||||
const periods = [
|
||||
{ value: '24h', label: 'Last 24 hours' },
|
||||
{ value: '7d', label: 'Last 7 days' },
|
||||
{ value: '30d', label: 'Last 30 days' },
|
||||
{ value: '90d', label: 'Last 90 days' },
|
||||
{ value: 'all', label: 'All time' }
|
||||
];
|
||||
|
||||
const getDateRange = (period: string): { start: number | null; end: number | null } => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const day = 86400;
|
||||
switch (period) {
|
||||
case '24h': return { start: now - day, end: now };
|
||||
case '7d': return { start: now - 7 * day, end: now };
|
||||
case '30d': return { start: now - 30 * day, end: now };
|
||||
case '90d': return { start: now - 90 * day, end: now };
|
||||
default: return { start: null, end: null };
|
||||
}
|
||||
};
|
||||
|
||||
// Data
|
||||
let summary = { total_messages: 0, total_chats: 0, total_models: 0, total_users: 0 };
|
||||
let modelStats: Array<{ model_id: string; count: number; name?: string }> = [];
|
||||
let userStats: Array<{ user_id: string; name?: string; email?: string; count: number }> = [];
|
||||
let dailyStats: Array<{ date: string; models: Record<string, number> }> = [];
|
||||
let tokenStats: Record<string, { input_tokens: number; output_tokens: number; total_tokens: number }> = {};
|
||||
let totalTokens = { input: 0, output: 0, total: 0 };
|
||||
|
||||
let loading = true;
|
||||
|
||||
// Sorting
|
||||
let modelOrderBy = 'count';
|
||||
let modelDirection: 'asc' | 'desc' = 'desc';
|
||||
let userOrderBy = 'count';
|
||||
let userDirection: 'asc' | 'desc' = 'desc';
|
||||
|
||||
const toggleModelSort = (key: string) => {
|
||||
if (modelOrderBy === key) {
|
||||
modelDirection = modelDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
modelOrderBy = key;
|
||||
modelDirection = key === 'name' ? 'asc' : 'desc';
|
||||
}
|
||||
};
|
||||
|
||||
const toggleUserSort = (key: string) => {
|
||||
if (userOrderBy === key) {
|
||||
userDirection = userDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
userOrderBy = key;
|
||||
userDirection = key === 'user_id' ? 'asc' : 'desc';
|
||||
}
|
||||
};
|
||||
|
||||
const loadDashboard = async () => {
|
||||
loading = true;
|
||||
try {
|
||||
const { start, end } = getDateRange(selectedPeriod);
|
||||
const granularity = selectedPeriod === '24h' ? 'hourly' : 'daily';
|
||||
const [summaryRes, modelsRes, usersRes, dailyRes, tokensRes] = await Promise.all([
|
||||
getSummary(localStorage.token, start, end),
|
||||
getModelAnalytics(localStorage.token, start, end),
|
||||
getUserAnalytics(localStorage.token, start, end, 50),
|
||||
getDailyStats(localStorage.token, start, end, granularity),
|
||||
getTokenUsage(localStorage.token, start, end)
|
||||
]);
|
||||
|
||||
summary = summaryRes ?? summary;
|
||||
|
||||
const modelsMap = new Map($models.map((m) => [m.id, m.name || m.id]));
|
||||
modelStats = (modelsRes?.models ?? []).map((entry) => ({
|
||||
...entry,
|
||||
name: modelsMap.get(entry.model_id) || entry.model_id
|
||||
}));
|
||||
|
||||
userStats = usersRes?.users ?? [];
|
||||
dailyStats = dailyRes?.data ?? [];
|
||||
|
||||
// Process token data
|
||||
if (tokensRes) {
|
||||
tokenStats = {};
|
||||
for (const m of tokensRes.models) {
|
||||
tokenStats[m.model_id] = {
|
||||
input_tokens: m.input_tokens,
|
||||
output_tokens: m.output_tokens,
|
||||
total_tokens: m.total_tokens
|
||||
};
|
||||
}
|
||||
totalTokens = {
|
||||
input: tokensRes.total_input_tokens,
|
||||
output: tokensRes.total_output_tokens,
|
||||
total: tokensRes.total_tokens
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Dashboard load failed:', err);
|
||||
}
|
||||
loading = false;
|
||||
};
|
||||
|
||||
$: if (selectedPeriod) {
|
||||
loadDashboard();
|
||||
}
|
||||
|
||||
$: sortedModels = [...modelStats].sort((a, b) => {
|
||||
if (modelOrderBy === 'name') {
|
||||
return modelDirection === 'asc'
|
||||
? a.name.localeCompare(b.name)
|
||||
: b.name.localeCompare(a.name);
|
||||
}
|
||||
return modelDirection === 'asc' ? a.count - b.count : b.count - a.count;
|
||||
});
|
||||
|
||||
$: sortedUsers = [...userStats].sort((a, b) => {
|
||||
if (userOrderBy === 'name') {
|
||||
const nameA = a.name || a.user_id;
|
||||
const nameB = b.name || b.user_id;
|
||||
return userDirection === 'asc'
|
||||
? nameA.localeCompare(nameB)
|
||||
: nameB.localeCompare(nameA);
|
||||
}
|
||||
return userDirection === 'asc' ? a.count - b.count : b.count - a.count;
|
||||
});
|
||||
|
||||
$: totalModelMessages = modelStats.reduce((sum, m) => sum + m.count, 0);
|
||||
|
||||
onMount(loadDashboard);
|
||||
</script>
|
||||
|
||||
<!-- Header with title and period selector -->
|
||||
<div class="pt-0.5 pb-1 gap-1 flex flex-row justify-between items-center sticky top-0 z-10 bg-white dark:bg-gray-900">
|
||||
<div class="text-lg font-medium px-0.5">
|
||||
{$i18n.t('Analytics')}
|
||||
</div>
|
||||
<select
|
||||
bind:value={selectedPeriod}
|
||||
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
|
||||
>
|
||||
{#each periods as period}
|
||||
<option value={period.value}>{$i18n.t(period.label)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
{#if !loading}
|
||||
<div class="flex gap-3 text-xs text-gray-500 dark:text-gray-400 px-0.5 pb-2">
|
||||
<span><span class="font-medium text-gray-900 dark:text-gray-300">{summary.total_messages.toLocaleString()}</span> {$i18n.t('messages')}</span>
|
||||
<span><span class="font-medium text-gray-900 dark:text-gray-300">{formatNumber(totalTokens.total)}</span> {$i18n.t('tokens')}</span>
|
||||
<span><span class="font-medium text-gray-900 dark:text-gray-300">{summary.total_chats.toLocaleString()}</span> {$i18n.t('chats')}</span>
|
||||
<span><span class="font-medium text-gray-900 dark:text-gray-300">{summary.total_users}</span> {$i18n.t('users')}</span>
|
||||
</div>
|
||||
|
||||
<!-- Daily usage chart -->
|
||||
{#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' }}
|
||||
<div class="mb-4">
|
||||
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-2 px-0.5">
|
||||
{$i18n.t(selectedPeriod === '24h' ? 'Hourly Messages' : 'Daily Messages')}
|
||||
</div>
|
||||
<ChartLine
|
||||
data={dailyStats}
|
||||
models={topModels}
|
||||
colors={chartColors}
|
||||
height={200}
|
||||
period={periodMap[selectedPeriod] || 'week'}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="my-10 flex justify-center">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<!-- Model Usage Table -->
|
||||
<div>
|
||||
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 px-0.5">
|
||||
{$i18n.t('Model Usage')}
|
||||
</div>
|
||||
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto">
|
||||
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
|
||||
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
|
||||
<th scope="col" class="px-2.5 py-2 w-8">#</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none"
|
||||
on:click={() => toggleModelSort('name')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
{$i18n.t('Model')}
|
||||
{#if modelOrderBy === 'name'}
|
||||
<span class="font-normal">
|
||||
{#if modelDirection === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown className="size-2" />{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="invisible"><ChevronUp className="size-2" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none text-right"
|
||||
on:click={() => toggleModelSort('count')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
{$i18n.t('Messages')}
|
||||
{#if modelOrderBy === 'count'}
|
||||
<span class="font-normal">
|
||||
{#if modelDirection === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown className="size-2" />{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="invisible"><ChevronUp className="size-2" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-2.5 py-2 text-right">{$i18n.t('Tokens')}</th>
|
||||
<th scope="col" class="px-2.5 py-2 text-right w-16">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedModels as model, idx (model.model_id)}
|
||||
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
|
||||
<td class="px-3 py-1 text-gray-400">{idx + 1}</td>
|
||||
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
|
||||
alt={model.name}
|
||||
class="size-5 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
<span class="truncate max-w-[150px]">{model.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-1 text-right">{model.count.toLocaleString()}</td>
|
||||
<td class="px-3 py-1 text-right">{formatNumber(tokenStats[model.model_id]?.total_tokens ?? 0)}</td>
|
||||
<td class="px-3 py-1 text-right text-gray-400">
|
||||
{totalModelMessages > 0 ? ((model.count / totalModelMessages) * 100).toFixed(1) : 0}%
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if sortedModels.length === 0}
|
||||
<tr><td colspan="5" class="px-3 py-2 text-center text-gray-400">{$i18n.t('No data')}</td></tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Activity Table -->
|
||||
<div>
|
||||
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 px-0.5">
|
||||
{$i18n.t('User Activity')}
|
||||
</div>
|
||||
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto">
|
||||
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
|
||||
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
|
||||
<th scope="col" class="px-2.5 py-2 w-8">#</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none"
|
||||
on:click={() => toggleUserSort('name')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
{$i18n.t('User')}
|
||||
{#if userOrderBy === 'name'}
|
||||
<span class="font-normal">
|
||||
{#if userDirection === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown className="size-2" />{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="invisible"><ChevronUp className="size-2" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none text-right"
|
||||
on:click={() => toggleUserSort('count')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
{$i18n.t('Messages')}
|
||||
{#if userOrderBy === 'count'}
|
||||
<span class="font-normal">
|
||||
{#if userDirection === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown className="size-2" />{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="invisible"><ChevronUp className="size-2" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-2.5 py-2 text-right">{$i18n.t('Tokens')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedUsers as user, idx (user.user_id)}
|
||||
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
|
||||
<td class="px-3 py-1 text-gray-400">{idx + 1}</td>
|
||||
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
src="{WEBUI_API_BASE_URL}/users/{user.user_id}/profile/image"
|
||||
alt={user.name || 'User'}
|
||||
class="size-5 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
<span class="truncate max-w-[150px]">{user.name || user.email || user.user_id.substring(0, 8)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-1 text-right">{user.count.toLocaleString()}</td>
|
||||
<td class="px-3 py-1 text-right">{formatNumber(user.total_tokens ?? 0)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if sortedUsers.length === 0}
|
||||
<tr><td colspan="4" class="px-3 py-2 text-center text-gray-400">{$i18n.t('No data')}</td></tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-500 text-xs mt-1.5 text-right">
|
||||
ⓘ {$i18n.t('Message counts are based on assistant responses.')}
|
||||
</div>
|
||||
{/if}
|
||||
141
src/lib/components/admin/Analytics/ModelUsage.svelte
Normal file
141
src/lib/components/admin/Analytics/ModelUsage.svelte
Normal file
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { onMount, getContext } from 'svelte';
|
||||
import { models } from '$lib/stores';
|
||||
import { getModelAnalytics } from '$lib/apis/analytics';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
let modelStats: Array<{ model_id: string; count: number; name?: string }> = [];
|
||||
let loading = true;
|
||||
let orderBy = 'count';
|
||||
let direction: 'asc' | 'desc' = 'desc';
|
||||
|
||||
const toggleSort = (key: string) => {
|
||||
if (orderBy === key) {
|
||||
direction = direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
orderBy = key;
|
||||
direction = key === 'name' ? 'asc' : 'desc';
|
||||
}
|
||||
};
|
||||
|
||||
const loadAnalytics = async () => {
|
||||
loading = true;
|
||||
try {
|
||||
const result = await getModelAnalytics(localStorage.token);
|
||||
const modelsMap = new Map($models.map((m) => [m.id, m.name || m.id]));
|
||||
|
||||
modelStats = (result?.models ?? []).map((entry) => ({
|
||||
...entry,
|
||||
name: modelsMap.get(entry.model_id) || entry.model_id
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Analytics load failed:', err);
|
||||
}
|
||||
loading = false;
|
||||
};
|
||||
|
||||
$: sortedModels = [...modelStats].sort((a, b) => {
|
||||
if (orderBy === 'name') {
|
||||
return direction === 'asc'
|
||||
? a.name.localeCompare(b.name)
|
||||
: b.name.localeCompare(a.name);
|
||||
}
|
||||
return direction === 'asc' ? a.count - b.count : b.count - a.count;
|
||||
});
|
||||
|
||||
$: totalMessages = modelStats.reduce((sum, m) => sum + m.count, 0);
|
||||
|
||||
onMount(loadAnalytics);
|
||||
</script>
|
||||
|
||||
<div class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900">
|
||||
<div class="flex items-center text-xl font-medium px-0.5 gap-2 shrink-0">
|
||||
{$i18n.t('Model Usage')}
|
||||
<span class="text-lg text-gray-500">{totalMessages} {$i18n.t('messages')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm min-h-[100px]">
|
||||
{#if loading}
|
||||
<div class="absolute inset-0 flex items-center justify-center z-10 bg-white/50 dark:bg-gray-900/50">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !modelStats.length && !loading}
|
||||
<div class="text-center text-xs text-gray-500 py-1">{$i18n.t('No data found')}</div>
|
||||
{:else if modelStats.length}
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 {loading ? 'opacity-20' : ''}">
|
||||
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
|
||||
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
|
||||
<th scope="col" class="px-2.5 py-2 w-8">#</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none"
|
||||
on:click={() => toggleSort('name')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
{$i18n.t('Model')}
|
||||
{#if orderBy === 'name'}
|
||||
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown className="size-2" />{/if}
|
||||
{:else}
|
||||
<span class="invisible"><ChevronUp className="size-2" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none text-right"
|
||||
on:click={() => toggleSort('count')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
{$i18n.t('Messages')}
|
||||
{#if orderBy === 'count'}
|
||||
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown className="size-2" />{/if}
|
||||
{:else}
|
||||
<span class="invisible"><ChevronUp className="size-2" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-2.5 py-2 text-right w-24">{$i18n.t('Share')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedModels as model, idx (model.model_id)}
|
||||
<tr class="bg-white dark:bg-gray-900 text-xs hover:bg-gray-50 dark:hover:bg-gray-850/50 transition">
|
||||
<td class="px-3 py-1.5 font-medium text-gray-900 dark:text-white">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td class="px-3 py-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
|
||||
alt={model.name}
|
||||
class="size-5 rounded-full object-cover"
|
||||
/>
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">{model.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white">
|
||||
{model.count.toLocaleString()}
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right font-medium text-blue-500">
|
||||
{((model.count / totalMessages) * 100).toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text-gray-500 text-xs mt-1.5 w-full flex justify-end">
|
||||
<div class="text-right">
|
||||
ⓘ {$i18n.t('Message counts are based on assistant responses.')}
|
||||
</div>
|
||||
</div>
|
||||
129
src/lib/components/admin/Analytics/UserUsage.svelte
Normal file
129
src/lib/components/admin/Analytics/UserUsage.svelte
Normal file
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { onMount, getContext } from 'svelte';
|
||||
import { getUserAnalytics } from '$lib/apis/analytics';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
let userStats: Array<{ user_id: string; count: number }> = [];
|
||||
let loading = true;
|
||||
let orderBy = 'count';
|
||||
let direction: 'asc' | 'desc' = 'desc';
|
||||
|
||||
const toggleSort = (key: string) => {
|
||||
if (orderBy === key) {
|
||||
direction = direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
orderBy = key;
|
||||
direction = key === 'user_id' ? 'asc' : 'desc';
|
||||
}
|
||||
};
|
||||
|
||||
const loadAnalytics = async () => {
|
||||
loading = true;
|
||||
try {
|
||||
const result = await getUserAnalytics(localStorage.token, null, null, 100);
|
||||
userStats = result?.users ?? [];
|
||||
} catch (err) {
|
||||
console.error('User analytics load failed:', err);
|
||||
}
|
||||
loading = false;
|
||||
};
|
||||
|
||||
$: sortedUsers = [...userStats].sort((a, b) => {
|
||||
if (orderBy === 'user_id') {
|
||||
return direction === 'asc'
|
||||
? a.user_id.localeCompare(b.user_id)
|
||||
: b.user_id.localeCompare(a.user_id);
|
||||
}
|
||||
return direction === 'asc' ? a.count - b.count : b.count - a.count;
|
||||
});
|
||||
|
||||
$: totalMessages = userStats.reduce((sum, u) => sum + u.count, 0);
|
||||
|
||||
onMount(loadAnalytics);
|
||||
</script>
|
||||
|
||||
<div class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900">
|
||||
<div class="flex items-center text-xl font-medium px-0.5 gap-2 shrink-0">
|
||||
{$i18n.t('User Activity')}
|
||||
<span class="text-lg text-gray-500">{userStats.length} {$i18n.t('users')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm min-h-[100px]">
|
||||
{#if loading}
|
||||
<div class="absolute inset-0 flex items-center justify-center z-10 bg-white/50 dark:bg-gray-900/50">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !userStats.length && !loading}
|
||||
<div class="text-center text-xs text-gray-500 py-1">{$i18n.t('No data found')}</div>
|
||||
{:else if userStats.length}
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 {loading ? 'opacity-20' : ''}">
|
||||
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
|
||||
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
|
||||
<th scope="col" class="px-2.5 py-2 w-8">#</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none"
|
||||
on:click={() => toggleSort('user_id')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
{$i18n.t('User')}
|
||||
{#if orderBy === 'user_id'}
|
||||
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown className="size-2" />{/if}
|
||||
{:else}
|
||||
<span class="invisible"><ChevronUp className="size-2" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none text-right"
|
||||
on:click={() => toggleSort('count')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
{$i18n.t('Messages')}
|
||||
{#if orderBy === 'count'}
|
||||
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown className="size-2" />{/if}
|
||||
{:else}
|
||||
<span class="invisible"><ChevronUp className="size-2" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-2.5 py-2 text-right w-24">{$i18n.t('Share')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedUsers as user, idx (user.user_id)}
|
||||
<tr class="bg-white dark:bg-gray-900 text-xs hover:bg-gray-50 dark:hover:bg-gray-850/50 transition">
|
||||
<td class="px-3 py-1.5 font-medium text-gray-900 dark:text-white">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td class="px-3 py-1.5">
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200 font-mono text-xs">
|
||||
{user.user_id.substring(0, 8)}...
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white">
|
||||
{user.count.toLocaleString()}
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right font-medium text-blue-500">
|
||||
{((user.count / totalMessages) * 100).toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text-gray-500 text-xs mt-1.5 w-full flex justify-end">
|
||||
<div class="text-right">
|
||||
ⓘ {$i18n.t('Showing all messages (user + assistant) per user.')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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, '\\$&');
|
||||
}
|
||||
|
||||
@@ -66,12 +66,12 @@
|
||||
href="/admin">{$i18n.t('Users')}</a
|
||||
>
|
||||
|
||||
<!-- <a
|
||||
<a
|
||||
class="min-w-fit p-1.5 {$page.url.pathname.includes('/admin/analytics')
|
||||
? ''
|
||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||
href="/admin/analytics">{$i18n.t('Analytics')}</a
|
||||
> -->
|
||||
>
|
||||
|
||||
<a
|
||||
class="min-w-fit p-1.5 {$page.url.pathname.includes('/admin/evaluations')
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import Evaluations from '$lib/components/admin/Evaluations.svelte';
|
||||
|
||||
onMount(() => {
|
||||
goto('/admin/evaluations/leaderboard');
|
||||
});
|
||||
import Analytics from '$lib/components/admin/Analytics.svelte';
|
||||
</script>
|
||||
|
||||
<Evaluations />
|
||||
<Analytics />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import Evaluations from '$lib/components/admin/Evaluations.svelte';
|
||||
import Analytics from '$lib/components/admin/Analytics.svelte';
|
||||
</script>
|
||||
|
||||
<Evaluations />
|
||||
<Analytics />
|
||||
|
||||
Reference in New Issue
Block a user