diff --git a/backend/open_webui/models/feedbacks.py b/backend/open_webui/models/feedbacks.py index 1e807ac8d..66037e486 100644 --- a/backend/open_webui/models/feedbacks.py +++ b/backend/open_webui/models/feedbacks.py @@ -127,6 +127,17 @@ class FeedbackListResponse(BaseModel): total: int +class ModelHistoryEntry(BaseModel): + date: str + won: int + lost: int + + +class ModelHistoryResponse(BaseModel): + model_id: str + history: list[ModelHistoryEntry] + + class FeedbackTable: def insert_new_feedback( self, user_id: str, form_data: FeedbackForm, db: Optional[Session] = None @@ -288,6 +299,73 @@ class FeedbackTable: for row in db.query(Feedback.id, Feedback.data).all() ] + def get_model_evaluation_history( + self, model_id: str, days: int = 30, db: Optional[Session] = None + ) -> list[ModelHistoryEntry]: + """ + Get daily wins/losses for a specific model over the past N days. + If days=0, returns all time data starting from first feedback. + Returns: [{"date": "2026-01-08", "won": 5, "lost": 2}, ...] + """ + from datetime import datetime, timedelta + from collections import defaultdict + + with get_db_context(db) as db: + if days == 0: + # All time - no cutoff + rows = db.query(Feedback.created_at, Feedback.data).all() + else: + cutoff = int(time.time()) - (days * 86400) + rows = ( + db.query(Feedback.created_at, Feedback.data) + .filter(Feedback.created_at >= cutoff) + .all() + ) + + daily_counts = defaultdict(lambda: {"won": 0, "lost": 0}) + first_date = None + + for created_at, data in rows: + if not data: + continue + if data.get("model_id") != model_id: + continue + + rating_str = str(data.get("rating", "")) + if rating_str not in ("1", "-1"): + continue + + date_str = datetime.fromtimestamp(created_at).strftime("%Y-%m-%d") + if rating_str == "1": + daily_counts[date_str]["won"] += 1 + else: + daily_counts[date_str]["lost"] += 1 + + # Track first date for this model + if first_date is None or date_str < first_date: + first_date = date_str + + # Generate date range + result = [] + today = datetime.now().date() + + if days == 0 and first_date: + # All time: start from first feedback date + start_date = datetime.strptime(first_date, "%Y-%m-%d").date() + num_days = (today - start_date).days + 1 + else: + # Fixed range + num_days = days + start_date = today - timedelta(days=days - 1) + + for i in range(num_days): + d = start_date + timedelta(days=i) + date_str = d.strftime("%Y-%m-%d") + counts = daily_counts.get(date_str, {"won": 0, "lost": 0}) + result.append(ModelHistoryEntry(date=date_str, won=counts["won"], lost=counts["lost"])) + + return result + def get_feedbacks_by_type( self, type: str, db: Optional[Session] = None ) -> list[FeedbackModel]: diff --git a/backend/open_webui/routers/evaluations.py b/backend/open_webui/routers/evaluations.py index 86fe02224..3beea54f3 100644 --- a/backend/open_webui/routers/evaluations.py +++ b/backend/open_webui/routers/evaluations.py @@ -13,6 +13,8 @@ from open_webui.models.feedbacks import ( FeedbackUserResponse, FeedbackListResponse, LeaderboardFeedbackData, + ModelHistoryEntry, + ModelHistoryResponse, Feedbacks, ) @@ -254,6 +256,22 @@ async def get_leaderboard( return LeaderboardResponse(entries=entries) + + +@router.get("/leaderboard/{model_id}/history", response_model=ModelHistoryResponse) +async def get_model_history( + model_id: str, + days: int = 30, + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): + """Get daily win/loss history for a specific model.""" + history = Feedbacks.get_model_evaluation_history( + model_id=model_id, days=days, db=db + ) + return ModelHistoryResponse(model_id=model_id, history=history) + + ############################ # GetConfig ############################ diff --git a/src/lib/apis/evaluations/index.ts b/src/lib/apis/evaluations/index.ts index 315315fb0..318e766e5 100644 --- a/src/lib/apis/evaluations/index.ts +++ b/src/lib/apis/evaluations/index.ts @@ -127,6 +127,44 @@ export const getLeaderboard = async (token: string = '', query: string = '') => return res; }; +export const getModelHistory = 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}/evaluations/leaderboard/${encodeURIComponent(modelId)}/history?${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 getFeedbackItems = async (token: string = '', orderBy, direction, page) => { let error = null; diff --git a/src/lib/components/admin/Evaluations/LeaderboardModal.svelte b/src/lib/components/admin/Evaluations/LeaderboardModal.svelte index 0b82f9a6f..2a19fd6af 100644 --- a/src/lib/components/admin/Evaluations/LeaderboardModal.svelte +++ b/src/lib/components/admin/Evaluations/LeaderboardModal.svelte @@ -1,22 +1,63 @@ - + {#if model} @@ -27,9 +68,40 @@ - + + + + + {$i18n.t('Activity')} + + + {#each TIME_RANGES as range} + selectRange(range.key)} + > + {range.label} + + {/each} + + + + + + + + {$i18n.t('Tags')} + {#if topTags.length} - + {#each topTags as tagInfo} {tagInfo.tag} {tagInfo.count} @@ -37,9 +109,10 @@ {/each} {:else} - - + - {/if} + + import { onMount, onDestroy } from 'svelte'; + import { getContext } from 'svelte'; + import Spinner from '$lib/components/common/Spinner.svelte'; + + const i18n = getContext('i18n'); + + export let history: Array<{ date: string; won: number; lost: number }> = []; + export let loading = false; + export let aggregateWeekly = false; + + let chartCanvas: HTMLCanvasElement; + let chartInstance: any = null; + let Chart: any = null; + + const createChart = async () => { + if (!chartCanvas || !history.length) return; + + // Dynamically import Chart.js + if (!Chart) { + const module = await import('chart.js/auto'); + Chart = module.default; + } + + // Destroy previous chart instance + if (chartInstance) { + chartInstance.destroy(); + } + + // For year/all views, aggregate by week + let chartData = history; + + if (aggregateWeekly && history.length > 7) { + // Aggregate daily data into weekly buckets + const weeklyData: { [key: string]: { won: number; lost: number; startDate: string } } = {}; + history.forEach((h) => { + const date = new Date(h.date); + // Get the Monday of this week as the bucket key + const day = date.getDay(); + const diff = date.getDate() - day + (day === 0 ? -6 : 1); + const monday = new Date(date); + monday.setDate(diff); + const weekKey = monday.toISOString().split('T')[0]; + + if (!weeklyData[weekKey]) { + weeklyData[weekKey] = { won: 0, lost: 0, startDate: weekKey }; + } + weeklyData[weekKey].won += h.won; + weeklyData[weekKey].lost += h.lost; + }); + + chartData = Object.values(weeklyData).sort( + (a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime() + ); + } + + const labels = chartData.map((h) => { + const date = new Date('startDate' in h ? h.startDate : h.date); + if (aggregateWeekly) { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }); + + // Diverging chart: wins go UP (positive), losses go DOWN (negative) + const wonData = chartData.map((h) => h.won); + const lostData = chartData.map((h) => -h.lost); // Negative for below-zero bars + + // Thicker bars for weekly aggregated data + const barPercentage = aggregateWeekly ? 0.95 : 0.9; + const categoryPercentage = aggregateWeekly ? 1.0 : 0.95; + + chartInstance = new Chart(chartCanvas, { + type: 'bar', + data: { + labels, + datasets: [ + { + label: 'Won', + data: wonData, + backgroundColor: '#5ba3c8', + borderRadius: 2, + barPercentage, + categoryPercentage + }, + { + label: 'Lost', + data: lostData, + backgroundColor: '#d97c5a', + borderRadius: 2, + barPercentage, + categoryPercentage + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + intersect: false, + mode: 'index' + }, + plugins: { + legend: { + display: false // Hide legend for cleaner look + }, + tooltip: { + backgroundColor: 'rgba(17, 24, 39, 0.9)', + titleColor: '#f3f4f6', + bodyColor: '#d1d5db', + borderColor: 'rgba(75, 85, 99, 0.3)', + borderWidth: 1, + padding: 8, + displayColors: true, + boxWidth: 8, + boxHeight: 8, + callbacks: { + label: function (context: any) { + const value = Math.abs(context.raw); + return `${context.dataset.label}: ${value}`; + } + } + } + }, + scales: { + x: { + stacked: true, + grid: { + display: false + }, + ticks: { + display: false // Hide x-axis labels for cleaner look + }, + border: { + display: false + } + }, + y: { + stacked: true, + grid: { + color: 'rgba(107, 114, 128, 0.1)', + drawTicks: false + }, + ticks: { + color: '#6b7280', + font: { + size: 10 + }, + padding: 8, + stepSize: 1, + precision: 0, + callback: function (value: number) { + return Math.abs(value); // Show absolute values on y-axis + } + }, + border: { + display: false + } + } + }, + animation: { + duration: 400, + easing: 'easeOutQuart' + } + } + }); + }; + + $: if (chartCanvas && history.length && !loading && aggregateWeekly !== undefined) { + createChart(); + } + + onDestroy(() => { + if (chartInstance) { + chartInstance.destroy(); + chartInstance = null; + } + }); + + + + {#if loading} + + + + {:else if !history.length || history.every((h) => h.won === 0 && h.lost === 0)} + + {$i18n.t('No activity data')} + + {:else} + + + + {/if} +