refac: feedback list optimisation

This commit is contained in:
Timothy Jaeryang Baek 2025-11-20 17:10:12 -05:00
parent 485896753d
commit 6083960655
5 changed files with 202 additions and 99 deletions

View file

@ -4,7 +4,7 @@ import uuid
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.models.chats import Chats from open_webui.models.users import User
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@ -92,6 +92,28 @@ class FeedbackForm(BaseModel):
model_config = ConfigDict(extra="allow") model_config = ConfigDict(extra="allow")
class UserResponse(BaseModel):
id: str
name: str
email: str
role: str = "pending"
last_active_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True)
class FeedbackUserResponse(FeedbackResponse):
user: Optional[UserResponse] = None
class FeedbackListResponse(BaseModel):
items: list[FeedbackUserResponse]
total: int
class FeedbackTable: class FeedbackTable:
def insert_new_feedback( def insert_new_feedback(
self, user_id: str, form_data: FeedbackForm self, user_id: str, form_data: FeedbackForm
@ -143,6 +165,70 @@ class FeedbackTable:
except Exception: except Exception:
return None return None
def get_feedback_items(
self, filter: dict = {}, skip: int = 0, limit: int = 30
) -> FeedbackListResponse:
with get_db() as db:
query = db.query(Feedback, User).join(User, Feedback.user_id == User.id)
if filter:
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by == "username":
if direction == "asc":
query = query.order_by(User.name.asc())
else:
query = query.order_by(User.name.desc())
elif order_by == "model_id":
# it's stored in feedback.data['model_id']
if direction == "asc":
query = query.order_by(
Feedback.data["model_id"].as_string().asc()
)
else:
query = query.order_by(
Feedback.data["model_id"].as_string().desc()
)
elif order_by == "rating":
# it's stored in feedback.data['rating']
if direction == "asc":
query = query.order_by(
Feedback.data["rating"].as_string().asc()
)
else:
query = query.order_by(
Feedback.data["rating"].as_string().desc()
)
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(Feedback.updated_at.asc())
else:
query = query.order_by(Feedback.updated_at.desc())
else:
query = query.order_by(Feedback.created_at.desc())
# Count BEFORE pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
feedbacks = []
for feedback, user in items:
feedback_model = FeedbackModel.model_validate(feedback)
user_model = UserResponse.model_validate(user)
feedbacks.append(
FeedbackUserResponse(**feedback_model.model_dump(), user=user_model)
)
return FeedbackListResponse(items=feedbacks, total=total)
def get_all_feedbacks(self) -> list[FeedbackModel]: def get_all_feedbacks(self) -> list[FeedbackModel]:
with get_db() as db: with get_db() as db:
return [ return [

View file

@ -7,6 +7,8 @@ from open_webui.models.feedbacks import (
FeedbackModel, FeedbackModel,
FeedbackResponse, FeedbackResponse,
FeedbackForm, FeedbackForm,
FeedbackUserResponse,
FeedbackListResponse,
Feedbacks, Feedbacks,
) )
@ -56,35 +58,10 @@ async def update_config(
} }
class UserResponse(BaseModel): @router.get("/feedbacks/all", response_model=list[FeedbackResponse])
id: str
name: str
email: str
role: str = "pending"
last_active_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
class FeedbackUserResponse(FeedbackResponse):
user: Optional[UserResponse] = None
@router.get("/feedbacks/all", response_model=list[FeedbackUserResponse])
async def get_all_feedbacks(user=Depends(get_admin_user)): async def get_all_feedbacks(user=Depends(get_admin_user)):
feedbacks = Feedbacks.get_all_feedbacks() feedbacks = Feedbacks.get_all_feedbacks()
return feedbacks
feedback_list = []
for feedback in feedbacks:
user = Users.get_user_by_id(feedback.user_id)
feedback_list.append(
FeedbackUserResponse(
**feedback.model_dump(),
user=UserResponse(**user.model_dump()) if user else None,
)
)
return feedback_list
@router.delete("/feedbacks/all") @router.delete("/feedbacks/all")
@ -111,6 +88,31 @@ async def delete_feedbacks(user=Depends(get_verified_user)):
return success return success
PAGE_ITEM_COUNT = 30
@router.get("/feedbacks/list", response_model=FeedbackListResponse)
async def get_feedbacks(
order_by: Optional[str] = None,
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_admin_user),
):
limit = PAGE_ITEM_COUNT
page = max(1, page)
skip = (page - 1) * limit
filter = {}
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
result = Feedbacks.get_feedback_items(filter=filter, skip=skip, limit=limit)
return result
@router.post("/feedback", response_model=FeedbackModel) @router.post("/feedback", response_model=FeedbackModel)
async def create_feedback( async def create_feedback(
request: Request, request: Request,

View file

@ -93,6 +93,45 @@ export const getAllFeedbacks = async (token: string = '') => {
return res; return res;
}; };
export const getFeedbackItems = async (token: string = '', orderBy, direction, page) => {
let error = null;
const searchParams = new URLSearchParams();
if (orderBy) searchParams.append('order_by', orderBy);
if (direction) searchParams.append('direction', direction);
if (page) searchParams.append('page', page.toString());
const res = await fetch(
`${WEBUI_API_BASE_URL}/evaluations/feedbacks/list?${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();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const exportAllFeedbacks = async (token: string = '') => { export const exportAllFeedbacks = async (token: string = '') => {
let error = null; let error = null;

View file

@ -33,7 +33,9 @@
let feedbacks = []; let feedbacks = [];
onMount(async () => { onMount(async () => {
// TODO: feedbacks elo rating calculation should be done in the backend; remove below line later
feedbacks = await getAllFeedbacks(localStorage.token); feedbacks = await getAllFeedbacks(localStorage.token);
loaded = true; loaded = true;
const containerElement = document.getElementById('users-tabs-container'); const containerElement = document.getElementById('users-tabs-container');
@ -117,7 +119,7 @@
{#if selectedTab === 'leaderboard'} {#if selectedTab === 'leaderboard'}
<Leaderboard {feedbacks} /> <Leaderboard {feedbacks} />
{:else if selectedTab === 'feedbacks'} {:else if selectedTab === 'feedbacks'}
<Feedbacks {feedbacks} /> <Feedbacks />
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -10,7 +10,7 @@
import { onMount, getContext } from 'svelte'; import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import { deleteFeedbackById, exportAllFeedbacks, getAllFeedbacks } from '$lib/apis/evaluations'; import { deleteFeedbackById, exportAllFeedbacks, getFeedbackItems } from '$lib/apis/evaluations';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Download from '$lib/components/icons/Download.svelte'; import Download from '$lib/components/icons/Download.svelte';
@ -23,78 +23,24 @@
import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import { WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { config } from '$lib/stores'; import { config } from '$lib/stores';
export let feedbacks = [];
let page = 1; let page = 1;
$: paginatedFeedbacks = sortedFeedbacks.slice((page - 1) * 10, page * 10); let items = null;
let total = null;
let orderBy: string = 'updated_at'; let orderBy: string = 'updated_at';
let direction: 'asc' | 'desc' = 'desc'; let direction: 'asc' | 'desc' = 'desc';
type Feedback = { const setSortKey = (key) => {
id: string;
data: {
rating: number;
model_id: string;
sibling_model_ids: string[] | null;
reason: string;
comment: string;
tags: string[];
};
user: {
name: string;
profile_image_url: string;
};
updated_at: number;
};
type ModelStats = {
rating: number;
won: number;
lost: number;
};
function setSortKey(key: string) {
if (orderBy === key) { if (orderBy === key) {
direction = direction === 'asc' ? 'desc' : 'asc'; direction = direction === 'asc' ? 'desc' : 'asc';
} else { } else {
orderBy = key; orderBy = key;
if (key === 'user' || key === 'model_id') {
direction = 'asc'; direction = 'asc';
} else {
direction = 'desc';
} }
} };
page = 1;
}
$: sortedFeedbacks = [...feedbacks].sort((a, b) => {
let aVal, bVal;
switch (orderBy) {
case 'user':
aVal = a.user?.name || '';
bVal = b.user?.name || '';
return direction === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
case 'model_id':
aVal = a.data.model_id || '';
bVal = b.data.model_id || '';
return direction === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
case 'rating':
aVal = a.data.rating;
bVal = b.data.rating;
return direction === 'asc' ? aVal - bVal : bVal - aVal;
case 'updated_at':
aVal = a.updated_at;
bVal = b.updated_at;
return direction === 'asc' ? aVal - bVal : bVal - aVal;
default:
return 0;
}
});
let showFeedbackModal = false; let showFeedbackModal = false;
let selectedFeedback = null; let selectedFeedback = null;
@ -115,13 +61,41 @@
// //
////////////////////// //////////////////////
const getFeedbacks = async () => {
try {
const res = await getFeedbackItems(localStorage.token, orderBy, direction, page).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
if (res) {
items = res.items;
total = res.total;
}
} catch (err) {
console.error(err);
}
};
$: if (page) {
getFeedbacks();
}
$: if (orderBy && direction) {
getFeedbacks();
}
const deleteFeedbackHandler = async (feedbackId: string) => { const deleteFeedbackHandler = async (feedbackId: string) => {
const response = await deleteFeedbackById(localStorage.token, feedbackId).catch((err) => { const response = await deleteFeedbackById(localStorage.token, feedbackId).catch((err) => {
toast.error(err); toast.error(err);
return null; return null;
}); });
if (response) { if (response) {
feedbacks = feedbacks.filter((f) => f.id !== feedbackId); toast.success($i18n.t('Feedback deleted successfully'));
page = 1;
getFeedbacks();
} }
}; };
@ -175,10 +149,10 @@
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" /> <div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{feedbacks.length}</span> <span class="text-lg font-medium text-gray-500 dark:text-gray-300">{total}</span>
</div> </div>
{#if feedbacks.length > 0} {#if total > 0}
<div> <div>
<Tooltip content={$i18n.t('Export')}> <Tooltip content={$i18n.t('Export')}>
<button <button
@ -195,7 +169,7 @@
</div> </div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full"> <div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
{#if (feedbacks ?? []).length === 0} {#if (items ?? []).length === 0}
<div class="text-center text-xs text-gray-500 dark:text-gray-400 py-1"> <div class="text-center text-xs text-gray-500 dark:text-gray-400 py-1">
{$i18n.t('No feedbacks found')} {$i18n.t('No feedbacks found')}
</div> </div>
@ -299,7 +273,7 @@
</tr> </tr>
</thead> </thead>
<tbody class=""> <tbody class="">
{#each paginatedFeedbacks as feedback (feedback.id)} {#each items as feedback (feedback.id)}
<tr <tr
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition" class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
on:click={() => openFeedbackModal(feedback)} on:click={() => openFeedbackModal(feedback)}
@ -309,7 +283,7 @@
<Tooltip content={feedback?.user?.name}> <Tooltip content={feedback?.user?.name}>
<div class="shrink-0"> <div class="shrink-0">
<img <img
src={feedback?.user?.profile_image_url ?? `${WEBUI_BASE_URL}/user.png`} src={`${WEBUI_API_BASE_URL}/users/${feedback.user.id}/profile/image`}
alt={feedback?.user?.name} alt={feedback?.user?.name}
class="size-5 rounded-full object-cover shrink-0" class="size-5 rounded-full object-cover shrink-0"
/> />
@ -388,7 +362,7 @@
{/if} {/if}
</div> </div>
{#if feedbacks.length > 0 && $config?.features?.enable_community_sharing} {#if total > 0 && $config?.features?.enable_community_sharing}
<div class=" flex flex-col justify-end w-full text-right gap-1"> <div class=" flex flex-col justify-end w-full text-right gap-1">
<div class="line-clamp-1 text-gray-500 text-xs"> <div class="line-clamp-1 text-gray-500 text-xs">
{$i18n.t('Help us create the best community leaderboard by sharing your feedback history!')} {$i18n.t('Help us create the best community leaderboard by sharing your feedback history!')}
@ -419,6 +393,6 @@
</div> </div>
{/if} {/if}
{#if feedbacks.length > 10} {#if total > 30}
<Pagination bind:page count={feedbacks.length} perPage={10} /> <Pagination bind:page count={total} perPage={30} />
{/if} {/if}