From 68a1e87b66a7ec8831d5ed52940c4ef110e3e264 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 4 Feb 2026 23:42:46 -0600 Subject: [PATCH] enh: analytics model modal --- backend/open_webui/models/chat_messages.py | 50 +++- backend/open_webui/models/feedbacks.py | 17 ++ backend/open_webui/routers/analytics.py | 200 +++++++++++++ src/lib/apis/analytics/index.ts | 76 +++++ .../Analytics/AnalyticsModelModal.svelte | 248 ++++++++++++++++ .../admin/Analytics/Dashboard.svelte | 19 +- src/lib/components/common/ChatList.svelte | 121 ++++++++ src/lib/components/layout/ChatsModal.svelte | 269 ++++++++++-------- 8 files changed, 874 insertions(+), 126 deletions(-) create mode 100644 src/lib/components/admin/Analytics/AnalyticsModelModal.svelte create mode 100644 src/lib/components/common/ChatList.svelte 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 @@ + + + + {#if model} +
+ +
+ {model.name} +
+
+ +
+ + +
+
+ + {#if $config?.features?.enable_admin_chat_access} + + {/if} +
+
+ +
+ {#if selectedTab === 'overview'} + +
+
+ +
+ {$i18n.t('Feedback Activity')} +
+
+
+ {#each TIME_RANGES as range} + + {/each} +
+
+ +
+ + +
+
+ {$i18n.t('Tags')} +
+ {#if tags.length} +
+ {#each tags as tagInfo} + + {tagInfo.tag} {tagInfo.count} + + {/each} +
+ {:else} + - + {/if} +
+ {:else if selectedTab === 'chats'} +
+ (show = false)} + /> +
+ {/if} + +
+ +
+
+ {/if} +
diff --git a/src/lib/components/admin/Analytics/Dashboard.svelte b/src/lib/components/admin/Analytics/Dashboard.svelte index 82eb5b581..4e17ff027 100644 --- a/src/lib/components/admin/Analytics/Dashboard.svelte +++ b/src/lib/components/admin/Analytics/Dashboard.svelte @@ -6,8 +6,10 @@ import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; import ChartLine from './ChartLine.svelte'; + import AnalyticsModelModal from './AnalyticsModelModal.svelte'; import { WEBUI_API_BASE_URL } from '$lib/constants'; import { formatNumber } from '$lib/utils'; + import { goto } from '$app/navigation'; const i18n = getContext('i18n'); @@ -43,6 +45,10 @@ let loading = true; + // Selected model for drill-down + let selectedModel: { id: string; name: string } | null = null; + let showModelModal = false; + // Sorting let modelOrderBy = 'count'; let modelDirection: 'asc' | 'desc' = 'desc'; @@ -157,6 +163,14 @@ + + + {#if !loading}
@@ -241,7 +255,10 @@ {#each sortedModels as model, idx (model.model_id)} - + { selectedModel = { id: model.model_id, name: model.name }; showModelModal = true; }} + > {idx + 1}
diff --git a/src/lib/components/common/ChatList.svelte b/src/lib/components/common/ChatList.svelte new file mode 100644 index 000000000..18a324620 --- /dev/null +++ b/src/lib/components/common/ChatList.svelte @@ -0,0 +1,121 @@ + + +
+ {#if chatList && chatList.length > 0} +
+ {#if showUserInfo} +
+ {$i18n.t('User')} +
+ {/if} +
+ {$i18n.t('Title')} +
+ +
+ {/if} +
+ {#if loading && (!chatList || chatList.length === 0)} +
+ +
+ {:else if !chatList || chatList.length === 0} +
+ {$i18n.t(emptyMessage)} +
+ {:else} + {#each chatList as chat, idx (chat.id)} + {#if chat.time_range && (idx === 0 || chat.time_range !== chatList[idx - 1]?.time_range)} +
+ {$i18n.t(chat.time_range)} +
+ {/if} + +
+ {#if showUserInfo && chat.user_id} +
+ {chat.user_name + {chat.user_name || 'Unknown'} +
+ {/if} + onChatClick?.(chat.id)} + > +
+ {chat.title} +
+
+ +
+ +
+
+ {/each} + + {#if !allLoaded && onLoadMore} + { + if (!loading) { + onLoadMore(); + } + }} + > +
+ +
{$i18n.t('Loading...')}
+
+
+ {/if} + {/if} +
+
diff --git a/src/lib/components/layout/ChatsModal.svelte b/src/lib/components/layout/ChatsModal.svelte index 4df7ad21d..af136664d 100644 --- a/src/lib/components/layout/ChatsModal.svelte +++ b/src/lib/components/layout/ChatsModal.svelte @@ -10,6 +10,7 @@ dayjs.extend(calendar); import { deleteChatById } from '$lib/apis/chats'; + import { WEBUI_API_BASE_URL } from '$lib/constants'; import Modal from '$lib/components/common/Modal.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; @@ -31,6 +32,9 @@ export let title = 'Chats'; export let emptyPlaceholder = ''; export let shareUrl = false; + export let showUserInfo = false; + export let showSearch = true; + export let readOnly = false; export let query = ''; @@ -105,52 +109,61 @@
-
-
-
- - - -
- - - {#if query} -
- + +
- {/if} + + + {#if query} +
+ +
+ {/if} +
-
+ {/if}
{#if chatList}
{#if chatList.length > 0}
+ {#if showUserInfo} +
+ {$i18n.t('User')} +
+ {/if} - - {/if} + + + + + + {/if} - {#if unshareHandler && chat.share_id} - - - - {/if} + {#if unshareHandler && chat.share_id} + + + + {/if} - - - -
+ + +
+ {/if}
{/each}