Evaluations.svelte 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  1. <script lang="ts">
  2. import { onMount, getContext } from 'svelte';
  3. import dayjs from 'dayjs';
  4. import relativeTime from 'dayjs/plugin/relativeTime';
  5. dayjs.extend(relativeTime);
  6. import * as ort from 'onnxruntime-web';
  7. import { AutoModel, AutoTokenizer } from '@huggingface/transformers';
  8. const EMBEDDING_MODEL = 'TaylorAI/bge-micro-v2';
  9. let tokenizer = null;
  10. let model = null;
  11. import { models } from '$lib/stores';
  12. import { deleteFeedbackById, getAllFeedbacks } from '$lib/apis/evaluations';
  13. import FeedbackMenu from './Evaluations/FeedbackMenu.svelte';
  14. import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
  15. import Tooltip from '../common/Tooltip.svelte';
  16. import Badge from '../common/Badge.svelte';
  17. import Pagination from '../common/Pagination.svelte';
  18. import MagnifyingGlass from '../icons/MagnifyingGlass.svelte';
  19. import Share from '../icons/Share.svelte';
  20. import CloudArrowUp from '../icons/CloudArrowUp.svelte';
  21. import { toast } from 'svelte-sonner';
  22. import Spinner from '../common/Spinner.svelte';
  23. const i18n = getContext('i18n');
  24. let rankedModels = [];
  25. let feedbacks = [];
  26. let query = '';
  27. let page = 1;
  28. let tagEmbeddings = new Map();
  29. let loaded = false;
  30. let loadingLeaderboard = true;
  31. let debounceTimer;
  32. $: paginatedFeedbacks = feedbacks.slice((page - 1) * 10, page * 10);
  33. type Feedback = {
  34. id: string;
  35. data: {
  36. rating: number;
  37. model_id: string;
  38. sibling_model_ids: string[] | null;
  39. reason: string;
  40. comment: string;
  41. tags: string[];
  42. };
  43. user: {
  44. name: string;
  45. profile_image_url: string;
  46. };
  47. updated_at: number;
  48. };
  49. type ModelStats = {
  50. rating: number;
  51. won: number;
  52. lost: number;
  53. };
  54. //////////////////////
  55. //
  56. // Rank models by Elo rating
  57. //
  58. //////////////////////
  59. const rankHandler = async (similarities: Map<string, number> = new Map()) => {
  60. const modelStats = calculateModelStats(feedbacks, similarities);
  61. rankedModels = $models
  62. .filter((m) => m?.owned_by !== 'arena' && (m?.info?.meta?.hidden ?? false) !== true)
  63. .map((model) => {
  64. const stats = modelStats.get(model.id);
  65. return {
  66. ...model,
  67. rating: stats ? Math.round(stats.rating) : '-',
  68. stats: {
  69. count: stats ? stats.won + stats.lost : 0,
  70. won: stats ? stats.won.toString() : '-',
  71. lost: stats ? stats.lost.toString() : '-'
  72. }
  73. };
  74. })
  75. .sort((a, b) => {
  76. if (a.rating === '-' && b.rating !== '-') return 1;
  77. if (b.rating === '-' && a.rating !== '-') return -1;
  78. if (a.rating !== '-' && b.rating !== '-') return b.rating - a.rating;
  79. return a.name.localeCompare(b.name);
  80. });
  81. loadingLeaderboard = false;
  82. };
  83. function calculateModelStats(
  84. feedbacks: Feedback[],
  85. similarities: Map<string, number>
  86. ): Map<string, ModelStats> {
  87. const stats = new Map<string, ModelStats>();
  88. const K = 32;
  89. function getOrDefaultStats(modelId: string): ModelStats {
  90. return stats.get(modelId) || { rating: 1000, won: 0, lost: 0 };
  91. }
  92. function updateStats(modelId: string, ratingChange: number, outcome: number) {
  93. const currentStats = getOrDefaultStats(modelId);
  94. currentStats.rating += ratingChange;
  95. if (outcome === 1) currentStats.won++;
  96. else if (outcome === 0) currentStats.lost++;
  97. stats.set(modelId, currentStats);
  98. }
  99. function calculateEloChange(
  100. ratingA: number,
  101. ratingB: number,
  102. outcome: number,
  103. similarity: number
  104. ): number {
  105. const expectedScore = 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400));
  106. return K * (outcome - expectedScore) * similarity;
  107. }
  108. feedbacks.forEach((feedback) => {
  109. const modelA = feedback.data.model_id;
  110. const statsA = getOrDefaultStats(modelA);
  111. let outcome: number;
  112. switch (feedback.data.rating.toString()) {
  113. case '1':
  114. outcome = 1;
  115. break;
  116. case '-1':
  117. outcome = 0;
  118. break;
  119. default:
  120. return; // Skip invalid ratings
  121. }
  122. // If the query is empty, set similarity to 1, else get the similarity from the map
  123. const similarity = query !== '' ? similarities.get(feedback.id) || 0 : 1;
  124. const opponents = feedback.data.sibling_model_ids || [];
  125. opponents.forEach((modelB) => {
  126. const statsB = getOrDefaultStats(modelB);
  127. const changeA = calculateEloChange(statsA.rating, statsB.rating, outcome, similarity);
  128. const changeB = calculateEloChange(statsB.rating, statsA.rating, 1 - outcome, similarity);
  129. updateStats(modelA, changeA, outcome);
  130. updateStats(modelB, changeB, 1 - outcome);
  131. });
  132. });
  133. return stats;
  134. }
  135. //////////////////////
  136. //
  137. // Calculate cosine similarity
  138. //
  139. //////////////////////
  140. const cosineSimilarity = (vecA, vecB) => {
  141. // Ensure the lengths of the vectors are the same
  142. if (vecA.length !== vecB.length) {
  143. throw new Error('Vectors must be the same length');
  144. }
  145. // Calculate the dot product
  146. let dotProduct = 0;
  147. let normA = 0;
  148. let normB = 0;
  149. for (let i = 0; i < vecA.length; i++) {
  150. dotProduct += vecA[i] * vecB[i];
  151. normA += vecA[i] ** 2;
  152. normB += vecB[i] ** 2;
  153. }
  154. // Calculate the magnitudes
  155. normA = Math.sqrt(normA);
  156. normB = Math.sqrt(normB);
  157. // Avoid division by zero
  158. if (normA === 0 || normB === 0) {
  159. return 0;
  160. }
  161. // Return the cosine similarity
  162. return dotProduct / (normA * normB);
  163. };
  164. const calculateMaxSimilarity = (queryEmbedding, tagEmbeddings: Map<string, number[]>) => {
  165. let maxSimilarity = 0;
  166. for (const tagEmbedding of tagEmbeddings.values()) {
  167. const similarity = cosineSimilarity(queryEmbedding, tagEmbedding);
  168. maxSimilarity = Math.max(maxSimilarity, similarity);
  169. }
  170. return maxSimilarity;
  171. };
  172. //////////////////////
  173. //
  174. // Embedding functions
  175. //
  176. //////////////////////
  177. const getEmbeddings = async (text: string) => {
  178. const tokens = await tokenizer(text);
  179. const output = await model(tokens);
  180. // Perform mean pooling on the last hidden states
  181. const embeddings = output.last_hidden_state.mean(1);
  182. return embeddings.ort_tensor.data;
  183. };
  184. const getTagEmbeddings = async (tags: string[]) => {
  185. const embeddings = new Map();
  186. for (const tag of tags) {
  187. if (!tagEmbeddings.has(tag)) {
  188. tagEmbeddings.set(tag, await getEmbeddings(tag));
  189. }
  190. embeddings.set(tag, tagEmbeddings.get(tag));
  191. }
  192. return embeddings;
  193. };
  194. const debouncedQueryHandler = async () => {
  195. loadingLeaderboard = true;
  196. if (query.trim() === '') {
  197. rankHandler();
  198. return;
  199. }
  200. clearTimeout(debounceTimer);
  201. debounceTimer = setTimeout(async () => {
  202. const queryEmbedding = await getEmbeddings(query);
  203. const similarities = new Map<string, number>();
  204. for (const feedback of feedbacks) {
  205. const feedbackTags = feedback.data.tags || [];
  206. const tagEmbeddings = await getTagEmbeddings(feedbackTags);
  207. const maxSimilarity = calculateMaxSimilarity(queryEmbedding, tagEmbeddings);
  208. similarities.set(feedback.id, maxSimilarity);
  209. }
  210. rankHandler(similarities);
  211. }, 1500); // Debounce for 1.5 seconds
  212. };
  213. $: query, debouncedQueryHandler();
  214. //////////////////////
  215. //
  216. // CRUD operations
  217. //
  218. //////////////////////
  219. const deleteFeedbackHandler = async (feedbackId: string) => {
  220. const response = await deleteFeedbackById(localStorage.token, feedbackId).catch((err) => {
  221. toast.error(err);
  222. return null;
  223. });
  224. if (response) {
  225. feedbacks = feedbacks.filter((f) => f.id !== feedbackId);
  226. }
  227. };
  228. const shareHandler = async () => {
  229. toast.success($i18n.t('Redirecting you to OpenWebUI Community'));
  230. // remove snapshot from feedbacks
  231. const feedbacksToShare = feedbacks.map((f) => {
  232. const { snapshot, user, ...rest } = f;
  233. return rest;
  234. });
  235. console.log(feedbacksToShare);
  236. const url = 'https://openwebui.com';
  237. const tab = await window.open(`${url}/leaderboard`, '_blank');
  238. // Define the event handler function
  239. const messageHandler = (event) => {
  240. if (event.origin !== url) return;
  241. if (event.data === 'loaded') {
  242. tab.postMessage(JSON.stringify(feedbacksToShare), '*');
  243. // Remove the event listener after handling the message
  244. window.removeEventListener('message', messageHandler);
  245. }
  246. };
  247. window.addEventListener('message', messageHandler, false);
  248. };
  249. const loadEmbeddingModel = async () => {
  250. // Check if the tokenizer and model are already loaded and stored in the window object
  251. if (!window.tokenizer) {
  252. window.tokenizer = await AutoTokenizer.from_pretrained(EMBEDDING_MODEL);
  253. }
  254. if (!window.model) {
  255. window.model = await AutoModel.from_pretrained(EMBEDDING_MODEL);
  256. }
  257. // Use the tokenizer and model from the window object
  258. tokenizer = window.tokenizer;
  259. model = window.model;
  260. // Pre-compute embeddings for all unique tags
  261. const allTags = new Set(feedbacks.flatMap((feedback) => feedback.data.tags || []));
  262. await getTagEmbeddings(Array.from(allTags));
  263. };
  264. onMount(async () => {
  265. feedbacks = await getAllFeedbacks(localStorage.token);
  266. loaded = true;
  267. rankHandler();
  268. });
  269. </script>
  270. {#if loaded}
  271. <div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
  272. <div class="flex md:self-center text-lg font-medium px-0.5 shrink-0 items-center">
  273. <div class=" gap-1">
  274. {$i18n.t('Leaderboard')}
  275. </div>
  276. <div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
  277. <span class="text-lg font-medium text-gray-500 dark:text-gray-300 mr-1.5"
  278. >{rankedModels.length}</span
  279. >
  280. </div>
  281. <div class=" flex space-x-2">
  282. <Tooltip content={$i18n.t('Re-rank models by topic similarity')}>
  283. <div class="flex flex-1">
  284. <div class=" self-center ml-1 mr-3">
  285. <MagnifyingGlass className="size-3" />
  286. </div>
  287. <input
  288. class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
  289. bind:value={query}
  290. placeholder={$i18n.t('Search')}
  291. on:focus={() => {
  292. loadEmbeddingModel();
  293. }}
  294. />
  295. </div>
  296. </Tooltip>
  297. </div>
  298. </div>
  299. <div
  300. class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded pt-0.5"
  301. >
  302. {#if loadingLeaderboard}
  303. <div class=" absolute top-0 bottom-0 left-0 right-0 flex">
  304. <div class="m-auto">
  305. <Spinner />
  306. </div>
  307. </div>
  308. {/if}
  309. {#if (rankedModels ?? []).length === 0}
  310. <div class="text-center text-xs text-gray-500 dark:text-gray-400 py-1">
  311. {$i18n.t('No models found')}
  312. </div>
  313. {:else}
  314. <table
  315. class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded {loadingLeaderboard
  316. ? 'opacity-20'
  317. : ''}"
  318. >
  319. <thead
  320. class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
  321. >
  322. <tr class="">
  323. <th scope="col" class="px-3 py-1.5 cursor-pointer select-none w-3">
  324. {$i18n.t('RK')}
  325. </th>
  326. <th scope="col" class="px-3 py-1.5 cursor-pointer select-none">
  327. {$i18n.t('Model')}
  328. </th>
  329. <th scope="col" class="px-3 py-1.5 text-right cursor-pointer select-none w-fit">
  330. {$i18n.t('Rating')}
  331. </th>
  332. <th scope="col" class="px-3 py-1.5 text-right cursor-pointer select-none w-5">
  333. {$i18n.t('Won')}
  334. </th>
  335. <th scope="col" class="px-3 py-1.5 text-right cursor-pointer select-none w-5">
  336. {$i18n.t('Lost')}
  337. </th>
  338. </tr>
  339. </thead>
  340. <tbody class="">
  341. {#each rankedModels as model, modelIdx (model.id)}
  342. <tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs group">
  343. <td class="px-3 py-1.5 text-left font-medium text-gray-900 dark:text-white w-fit">
  344. <div class=" line-clamp-1">
  345. {model?.rating !== '-' ? modelIdx + 1 : '-'}
  346. </div>
  347. </td>
  348. <td class="px-3 py-1.5 flex flex-col justify-center">
  349. <div class="flex items-center gap-2">
  350. <div class="flex-shrink-0">
  351. <img
  352. src={model?.info?.meta?.profile_image_url ?? '/favicon.png'}
  353. alt={model.name}
  354. class="size-5 rounded-full object-cover shrink-0"
  355. />
  356. </div>
  357. <div class="font-medium text-gray-800 dark:text-gray-200 pr-4">
  358. {model.name}
  359. </div>
  360. </div>
  361. </td>
  362. <td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white w-max">
  363. {model.rating}
  364. </td>
  365. <td class=" px-3 py-1.5 text-right font-semibold text-green-500">
  366. <div class=" w-10">
  367. {#if model.stats.won === '-'}
  368. -
  369. {:else}
  370. <span class="hidden group-hover:inline"
  371. >{((model.stats.won / model.stats.count) * 100).toFixed(1)}%</span
  372. >
  373. <span class=" group-hover:hidden">{model.stats.won}</span>
  374. {/if}
  375. </div>
  376. </td>
  377. <td class="px-3 py-1.5 text-right font-semibold text-red-500">
  378. <div class=" w-10">
  379. {#if model.stats.lost === '-'}
  380. -
  381. {:else}
  382. <span class="hidden group-hover:inline"
  383. >{((model.stats.lost / model.stats.count) * 100).toFixed(1)}%</span
  384. >
  385. <span class=" group-hover:hidden">{model.stats.lost}</span>
  386. {/if}
  387. </div>
  388. </td>
  389. </tr>
  390. {/each}
  391. </tbody>
  392. </table>
  393. {/if}
  394. </div>
  395. <div class=" text-gray-500 text-xs mt-1.5 w-full flex justify-end">
  396. <div class=" text-right">
  397. <div class="line-clamp-1">
  398. ⓘ {$i18n.t(
  399. 'The evaluation leaderboard is based on the Elo rating system and is updated in real-time.'
  400. )}
  401. </div>
  402. {$i18n.t(
  403. 'The leaderboard is currently in beta, and we may adjust the rating calculations as we refine the algorithm.'
  404. )}
  405. </div>
  406. </div>
  407. <div class="pb-4"></div>
  408. <div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
  409. <div class="flex md:self-center text-lg font-medium px-0.5">
  410. {$i18n.t('Feedback History')}
  411. <div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
  412. <span class="text-lg font-medium text-gray-500 dark:text-gray-300">{feedbacks.length}</span>
  413. </div>
  414. </div>
  415. <div
  416. class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded pt-0.5"
  417. >
  418. {#if (feedbacks ?? []).length === 0}
  419. <div class="text-center text-xs text-gray-500 dark:text-gray-400 py-1">
  420. {$i18n.t('No feedbacks found')}
  421. </div>
  422. {:else}
  423. <table
  424. class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded"
  425. >
  426. <thead
  427. class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
  428. >
  429. <tr class="">
  430. <th scope="col" class="px-3 text-right cursor-pointer select-none w-0">
  431. {$i18n.t('User')}
  432. </th>
  433. <th scope="col" class="px-3 pr-1.5 cursor-pointer select-none">
  434. {$i18n.t('Models')}
  435. </th>
  436. <th scope="col" class="px-3 py-1.5 text-right cursor-pointer select-none w-fit">
  437. {$i18n.t('Result')}
  438. </th>
  439. <th scope="col" class="px-3 py-1.5 text-right cursor-pointer select-none w-0">
  440. {$i18n.t('Updated At')}
  441. </th>
  442. <th scope="col" class="px-3 py-1.5 text-right cursor-pointer select-none w-0"> </th>
  443. </tr>
  444. </thead>
  445. <tbody class="">
  446. {#each paginatedFeedbacks as feedback (feedback.id)}
  447. <tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
  448. <td class=" py-0.5 text-right font-semibold">
  449. <div class="flex justify-center">
  450. <Tooltip content={feedback?.user?.name}>
  451. <div class="flex-shrink-0">
  452. <img
  453. src={feedback?.user?.profile_image_url ?? '/user.png'}
  454. alt={feedback?.user?.name}
  455. class="size-5 rounded-full object-cover shrink-0"
  456. />
  457. </div>
  458. </Tooltip>
  459. </div>
  460. </td>
  461. <td class=" py-1 pl-3 flex flex-col">
  462. <div class="flex flex-col items-start gap-0.5 h-full">
  463. <div class="flex flex-col h-full">
  464. {#if feedback.data?.sibling_model_ids}
  465. <div class="font-semibold text-gray-600 dark:text-gray-400 flex-1">
  466. {feedback.data?.model_id}
  467. </div>
  468. <Tooltip content={feedback.data.sibling_model_ids.join(', ')}>
  469. <div class=" text-[0.65rem] text-gray-600 dark:text-gray-400 line-clamp-1">
  470. {#if feedback.data.sibling_model_ids.length > 2}
  471. <!-- {$i18n.t('and {{COUNT}} more')} -->
  472. {feedback.data.sibling_model_ids.slice(0, 2).join(', ')}, {$i18n.t(
  473. 'and {{COUNT}} more',
  474. { COUNT: feedback.data.sibling_model_ids.length - 2 }
  475. )}
  476. {:else}
  477. {feedback.data.sibling_model_ids.join(', ')}
  478. {/if}
  479. </div>
  480. </Tooltip>
  481. {:else}
  482. <div
  483. class=" text-sm font-medium text-gray-600 dark:text-gray-400 flex-1 py-1.5"
  484. >
  485. {feedback.data?.model_id}
  486. </div>
  487. {/if}
  488. </div>
  489. </div>
  490. </td>
  491. <td class="px-3 py-1 text-right font-medium text-gray-900 dark:text-white w-max">
  492. <div class=" flex justify-end">
  493. {#if feedback.data.rating.toString() === '1'}
  494. <Badge type="info" content={$i18n.t('Won')} />
  495. {:else if feedback.data.rating.toString() === '0'}
  496. <Badge type="muted" content={$i18n.t('Draw')} />
  497. {:else if feedback.data.rating.toString() === '-1'}
  498. <Badge type="error" content={$i18n.t('Lost')} />
  499. {/if}
  500. </div>
  501. </td>
  502. <td class=" px-3 py-1 text-right font-medium">
  503. {dayjs(feedback.updated_at * 1000).fromNow()}
  504. </td>
  505. <td class=" px-3 py-1 text-right font-semibold">
  506. <FeedbackMenu
  507. on:delete={(e) => {
  508. deleteFeedbackHandler(feedback.id);
  509. }}
  510. >
  511. <button
  512. class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
  513. >
  514. <EllipsisHorizontal />
  515. </button>
  516. </FeedbackMenu>
  517. </td>
  518. </tr>
  519. {/each}
  520. </tbody>
  521. </table>
  522. {/if}
  523. </div>
  524. {#if feedbacks.length > 0}
  525. <div class=" flex flex-col justify-end w-full text-right gap-1">
  526. <div class="line-clamp-1 text-gray-500 text-xs">
  527. {$i18n.t('Help us create the best community leaderboard by sharing your feedback history!')}
  528. </div>
  529. <div class="flex space-x-1 ml-auto">
  530. <Tooltip
  531. content={$i18n.t(
  532. 'To protect your privacy, only ratings, model IDs, tags, and metadata are shared from your feedback—your chat logs remain private and are not included.'
  533. )}
  534. >
  535. <button
  536. class="flex text-xs items-center px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
  537. on:click={async () => {
  538. shareHandler();
  539. }}
  540. >
  541. <div class=" self-center mr-2 font-medium line-clamp-1">
  542. {$i18n.t('Share to OpenWebUI Community')}
  543. </div>
  544. <div class=" self-center">
  545. <svg
  546. xmlns="http://www.w3.org/2000/svg"
  547. viewBox="0 0 16 16"
  548. fill="currentColor"
  549. class="w-3.5 h-3.5"
  550. >
  551. <path
  552. fill-rule="evenodd"
  553. d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
  554. clip-rule="evenodd"
  555. />
  556. </svg>
  557. </div>
  558. </button>
  559. </Tooltip>
  560. </div>
  561. </div>
  562. {/if}
  563. {#if feedbacks.length > 10}
  564. <Pagination bind:page count={feedbacks.length} perPage={10} />
  565. {/if}
  566. <div class="pb-12"></div>
  567. {/if}