diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py
index 9254baf5d..5074b3042 100644
--- a/backend/open_webui/models/chat_messages.py
+++ b/backend/open_webui/models/chat_messages.py
@@ -111,7 +111,7 @@ class ChatMessageModel(BaseModel):
embeds: Optional[list] = None
done: bool = True
status_history: Optional[list] = None
- error: Optional[dict] = None
+ error: Optional[dict | str] = None
usage: Optional[dict] = None
created_at: int
updated_at: int
@@ -269,6 +269,36 @@ class ChatMessageTable:
)
return [ChatMessageModel.model_validate(message) for message in messages]
+ def get_chat_ids_by_model_id(
+ self,
+ model_id: str,
+ start_date: Optional[int] = None,
+ end_date: Optional[int] = None,
+ skip: int = 0,
+ limit: int = 50,
+ db: Optional[Session] = None,
+ ) -> list[str]:
+ """Get distinct chat_ids that used a specific model."""
+ from sqlalchemy import distinct
+
+ with get_db_context(db) as db:
+ query = db.query(distinct(ChatMessage.chat_id)).filter(
+ ChatMessage.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)
+
+ # Order by most recent message in each chat
+ chat_ids = (
+ query.order_by(ChatMessage.created_at.desc())
+ .offset(skip)
+ .limit(limit)
+ .all()
+ )
+ return [chat_id for (chat_id,) in chat_ids]
+
def delete_messages_by_chat_id(
self, chat_id: str, db: Optional[Session] = None
) -> bool:
@@ -289,7 +319,11 @@ class ChatMessageTable:
query = db.query(
ChatMessage.model_id, func.count(ChatMessage.id).label("count")
- ).filter(ChatMessage.role == "assistant", ChatMessage.model_id.isnot(None))
+ ).filter(
+ ChatMessage.role == "assistant",
+ ChatMessage.model_id.isnot(None),
+ ~ChatMessage.user_id.like("shared-%"),
+ )
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
@@ -338,6 +372,7 @@ class ChatMessageTable:
ChatMessage.role == "assistant",
ChatMessage.model_id.isnot(None),
ChatMessage.usage.isnot(None),
+ ~ChatMessage.user_id.like("shared-%"),
)
if start_date:
@@ -396,6 +431,7 @@ class ChatMessageTable:
ChatMessage.role == "assistant",
ChatMessage.user_id.isnot(None),
ChatMessage.usage.isnot(None),
+ ~ChatMessage.user_id.like("shared-%"),
)
if start_date:
@@ -426,7 +462,7 @@ class ChatMessageTable:
query = db.query(
ChatMessage.user_id, func.count(ChatMessage.id).label("count")
- )
+ ).filter(~ChatMessage.user_id.like("shared-%"))
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
@@ -447,7 +483,7 @@ class ChatMessageTable:
query = db.query(
ChatMessage.chat_id, func.count(ChatMessage.id).label("count")
- )
+ ).filter(~ChatMessage.user_id.like("shared-%"))
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
@@ -469,7 +505,8 @@ class ChatMessageTable:
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == "assistant",
- ChatMessage.model_id.isnot(None)
+ ChatMessage.model_id.isnot(None),
+ ~ChatMessage.user_id.like("shared-%"),
)
if start_date:
@@ -511,7 +548,8 @@ class ChatMessageTable:
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == "assistant",
- ChatMessage.model_id.isnot(None)
+ ChatMessage.model_id.isnot(None),
+ ~ChatMessage.user_id.like("shared-%"),
)
if start_date:
diff --git a/backend/open_webui/models/feedbacks.py b/backend/open_webui/models/feedbacks.py
index 2a4d752f5..406adb255 100644
--- a/backend/open_webui/models/feedbacks.py
+++ b/backend/open_webui/models/feedbacks.py
@@ -191,6 +191,23 @@ class FeedbackTable:
except Exception:
return None
+ def get_feedbacks_by_chat_id(
+ self, chat_id: str, db: Optional[Session] = None
+ ) -> list[FeedbackModel]:
+ """Get all feedbacks for a specific chat."""
+ try:
+ with get_db_context(db) as db:
+ # meta.chat_id stores the chat reference
+ feedbacks = (
+ db.query(Feedback)
+ .filter(Feedback.meta["chat_id"].as_string() == chat_id)
+ .order_by(Feedback.created_at.desc())
+ .all()
+ )
+ return [FeedbackModel.model_validate(fb) for fb in feedbacks]
+ except Exception:
+ return []
+
def get_feedback_items(
self,
filter: dict = {},
diff --git a/backend/open_webui/routers/analytics.py b/backend/open_webui/routers/analytics.py
index d8f6928ff..35e4aead2 100644
--- a/backend/open_webui/routers/analytics.py
+++ b/backend/open_webui/routers/analytics.py
@@ -1,9 +1,14 @@
from typing import Optional
+from datetime import datetime, timedelta
+from collections import defaultdict
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from open_webui.models.chat_messages import ChatMessages, ChatMessageModel
+from open_webui.models.chats import Chats
+from open_webui.models.users import Users
+from open_webui.models.feedbacks import Feedbacks
from open_webui.utils.auth import get_admin_user
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
@@ -245,3 +250,198 @@ async def get_token_usage(
total_output_tokens=total_output,
total_tokens=total_input + total_output,
)
+
+
+####################
+# Model Chats Browser
+####################
+
+
+class ModelChatEntry(BaseModel):
+ chat_id: str
+ user_id: Optional[str] = None
+ user_name: Optional[str] = None
+ first_message: Optional[str] = None
+ updated_at: int
+
+
+class ModelChatsResponse(BaseModel):
+ chats: list[ModelChatEntry]
+ total: int
+
+
+@router.get("/models/{model_id}/chats", response_model=ModelChatsResponse)
+async def get_model_chats(
+ model_id: str,
+ start_date: Optional[int] = Query(None),
+ end_date: Optional[int] = Query(None),
+ skip: int = Query(0),
+ limit: int = Query(50, le=100),
+ user=Depends(get_admin_user),
+ db: Session = Depends(get_session),
+):
+ """Get chats that used a specific model, with preview and feedback info."""
+
+ # Get chat IDs that used this model
+ chat_ids = ChatMessages.get_chat_ids_by_model_id(
+ model_id=model_id,
+ start_date=start_date,
+ end_date=end_date,
+ skip=skip,
+ limit=limit,
+ db=db,
+ )
+
+ if not chat_ids:
+ return ModelChatsResponse(chats=[], total=0)
+
+ # Get chat details from messages only
+ chats_data = []
+ for chat_id in chat_ids:
+ messages = ChatMessages.get_messages_by_chat_id(chat_id, db=db)
+ if not messages:
+ continue
+
+ # Get user_id from first user message
+ first_user_msg = next((m for m in messages if m.role == "user"), None)
+ user_id = first_user_msg.user_id if first_user_msg else None
+
+ # Extract first message content as preview
+ first_message = None
+ if first_user_msg and first_user_msg.content:
+ content = first_user_msg.content
+ if isinstance(content, str):
+ first_message = content[:200]
+ elif isinstance(content, list):
+ text_parts = [
+ b.get("text", "") for b in content if isinstance(b, dict)
+ ]
+ first_message = " ".join(text_parts)[:200]
+
+ # Get user info
+ user_name = None
+ if user_id:
+ user_info = Users.get_user_by_id(user_id, db=db)
+ user_name = user_info.name if user_info else None
+
+ # Timestamps from messages
+ updated_at = max(m.created_at for m in messages) if messages else 0
+
+
+ chats_data.append(
+ ModelChatEntry(
+ chat_id=chat_id,
+ user_id=user_id,
+ user_name=user_name,
+ first_message=first_message,
+ updated_at=updated_at,
+ )
+ )
+
+ return ModelChatsResponse(chats=chats_data, total=len(chats_data))
+
+
+####################
+# Model Overview
+####################
+
+
+class HistoryEntry(BaseModel):
+ date: str
+ won: int = 0
+ lost: int = 0
+
+
+class TagEntry(BaseModel):
+ tag: str
+ count: int
+
+
+class ModelOverviewResponse(BaseModel):
+ history: list[HistoryEntry]
+ tags: list[TagEntry]
+
+
+@router.get("/models/{model_id}/overview", response_model=ModelOverviewResponse)
+async def get_model_overview(
+ model_id: str,
+ days: int = Query(30, description="Number of days of history (0 for all)"),
+ user=Depends(get_admin_user),
+ db: Session = Depends(get_session),
+):
+ """Get model overview with feedback history and chat tags."""
+
+ # Get chat IDs that used this model
+ chat_ids = ChatMessages.get_chat_ids_by_model_id(
+ model_id=model_id,
+ start_date=None,
+ end_date=None,
+ skip=0,
+ limit=10000, # Get all chats
+ db=db,
+ )
+
+ # Get feedback history per day
+ history_counts: dict[str, dict] = defaultdict(lambda: {"won": 0, "lost": 0})
+
+ # Calculate start date for history
+ now = datetime.now()
+ start_dt = None
+ if days > 0:
+ start_dt = now - timedelta(days=days)
+
+ for chat_id in chat_ids:
+ feedbacks = Feedbacks.get_feedbacks_by_chat_id(chat_id, db=db)
+ for fb in feedbacks:
+ if fb.data and "rating" in fb.data:
+ rating = fb.data["rating"]
+ fb_date = datetime.fromtimestamp(fb.created_at)
+
+ # Filter by date range
+ if start_dt and fb_date < start_dt:
+ continue
+
+ date_str = fb_date.strftime("%Y-%m-%d")
+ if rating == 1:
+ history_counts[date_str]["won"] += 1
+ elif rating == -1:
+ history_counts[date_str]["lost"] += 1
+
+ # Fill in missing days
+ history = []
+ if history_counts or days > 0:
+ end_dt = now
+ if days > 0:
+ current = start_dt
+ elif history_counts:
+ # Find earliest date
+ min_date = min(history_counts.keys())
+ current = datetime.strptime(min_date, "%Y-%m-%d")
+ else:
+ current = now
+
+ while current <= end_dt:
+ date_str = current.strftime("%Y-%m-%d")
+ counts = history_counts.get(date_str, {"won": 0, "lost": 0})
+ history.append(HistoryEntry(
+ date=date_str,
+ won=counts["won"],
+ lost=counts["lost"],
+ ))
+ current += timedelta(days=1)
+
+ # Get chat tags
+ tag_counts: dict[str, int] = defaultdict(int)
+ for chat_id in chat_ids:
+ chat = Chats.get_chat_by_id(chat_id, db=db)
+ if chat and chat.meta:
+ for tag in chat.meta.get("tags", []):
+ tag_counts[tag] += 1
+
+ # Sort by count and take top 10
+ tags = [
+ TagEntry(tag=tag, count=count)
+ for tag, count in sorted(tag_counts.items(), key=lambda x: -x[1])[:10]
+ ]
+
+ return ModelOverviewResponse(history=history, tags=tags)
diff --git a/src/lib/apis/analytics/index.ts b/src/lib/apis/analytics/index.ts
index 19ef2a388..c678d54e7 100644
--- a/src/lib/apis/analytics/index.ts
+++ b/src/lib/apis/analytics/index.ts
@@ -229,3 +229,79 @@ export const getTokenUsage = async (
return res;
};
+
+export const getModelChats = async (
+ token: string = '',
+ modelId: string,
+ startDate: number | null = null,
+ endDate: number | null = null,
+ skip: number = 0,
+ 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 (skip) searchParams.append('skip', skip.toString());
+ if (limit) searchParams.append('limit', limit.toString());
+
+ const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/models/${encodeURIComponent(modelId)}/chats?${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 getModelOverview = async (
+ token: string = '',
+ modelId: string,
+ days: number = 30
+) => {
+ let error = null;
+
+ const searchParams = new URLSearchParams();
+ searchParams.append('days', days.toString());
+
+ const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/models/${encodeURIComponent(modelId)}/overview?${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/AnalyticsModelModal.svelte b/src/lib/components/admin/Analytics/AnalyticsModelModal.svelte
new file mode 100644
index 000000000..642ac49a6
--- /dev/null
+++ b/src/lib/components/admin/Analytics/AnalyticsModelModal.svelte
@@ -0,0 +1,248 @@
+
+
+