Browse Source

Merge branch 'dev' into feat/customizable-title-prompt-length

Timothy Jaeryang Baek 1 year ago
parent
commit
a938ffb586

+ 19 - 6
backend/apps/audio/main.py

@@ -28,6 +28,7 @@ from config import (
     UPLOAD_DIR,
     UPLOAD_DIR,
     WHISPER_MODEL,
     WHISPER_MODEL,
     WHISPER_MODEL_DIR,
     WHISPER_MODEL_DIR,
+    WHISPER_MODEL_AUTO_UPDATE,
     DEVICE_TYPE,
     DEVICE_TYPE,
 )
 )
 
 
@@ -69,12 +70,24 @@ def transcribe(
             f.write(contents)
             f.write(contents)
             f.close()
             f.close()
 
 
-        model = WhisperModel(
-            WHISPER_MODEL,
-            device=whisper_device_type,
-            compute_type="int8",
-            download_root=WHISPER_MODEL_DIR,
-        )
+        whisper_kwargs = {
+            "model_size_or_path": WHISPER_MODEL,
+            "device": whisper_device_type,
+            "compute_type": "int8",
+            "download_root": WHISPER_MODEL_DIR,
+            "local_files_only": not WHISPER_MODEL_AUTO_UPDATE,
+        }
+
+        log.debug(f"whisper_kwargs: {whisper_kwargs}")
+
+        try:
+            model = WhisperModel(**whisper_kwargs)
+        except:
+            log.warning(
+                "WhisperModel initialization failed, attempting download with local_files_only=False"
+            )
+            whisper_kwargs["local_files_only"] = False
+            model = WhisperModel(**whisper_kwargs)
 
 
         segments, info = model.transcribe(file_path, beam_size=5)
         segments, info = model.transcribe(file_path, beam_size=5)
         log.info(
         log.info(

+ 8 - 2
backend/apps/images/main.py

@@ -29,7 +29,13 @@ import base64
 import json
 import json
 import logging
 import logging
 
 
-from config import SRC_LOG_LEVELS, CACHE_DIR, AUTOMATIC1111_BASE_URL, COMFYUI_BASE_URL
+from config import (
+    SRC_LOG_LEVELS,
+    CACHE_DIR,
+    ENABLE_IMAGE_GENERATION,
+    AUTOMATIC1111_BASE_URL,
+    COMFYUI_BASE_URL,
+)
 
 
 
 
 log = logging.getLogger(__name__)
 log = logging.getLogger(__name__)
@@ -48,7 +54,7 @@ app.add_middleware(
 )
 )
 
 
 app.state.ENGINE = ""
 app.state.ENGINE = ""
-app.state.ENABLED = False
+app.state.ENABLED = ENABLE_IMAGE_GENERATION
 
 
 app.state.OPENAI_API_KEY = ""
 app.state.OPENAI_API_KEY = ""
 app.state.MODEL = ""
 app.state.MODEL = ""

+ 28 - 8
backend/apps/ollama/main.py

@@ -612,8 +612,13 @@ async def generate_embeddings(
     user=Depends(get_current_user),
     user=Depends(get_current_user),
 ):
 ):
     if url_idx == None:
     if url_idx == None:
-        if form_data.model in app.state.MODELS:
-            url_idx = random.choice(app.state.MODELS[form_data.model]["urls"])
+        model = form_data.model
+
+        if ":" not in model:
+            model = f"{model}:latest"
+
+        if model in app.state.MODELS:
+            url_idx = random.choice(app.state.MODELS[model]["urls"])
         else:
         else:
             raise HTTPException(
             raise HTTPException(
                 status_code=400,
                 status_code=400,
@@ -672,8 +677,13 @@ async def generate_completion(
 ):
 ):
 
 
     if url_idx == None:
     if url_idx == None:
-        if form_data.model in app.state.MODELS:
-            url_idx = random.choice(app.state.MODELS[form_data.model]["urls"])
+        model = form_data.model
+
+        if ":" not in model:
+            model = f"{model}:latest"
+
+        if model in app.state.MODELS:
+            url_idx = random.choice(app.state.MODELS[model]["urls"])
         else:
         else:
             raise HTTPException(
             raise HTTPException(
                 status_code=400,
                 status_code=400,
@@ -770,8 +780,13 @@ async def generate_chat_completion(
 ):
 ):
 
 
     if url_idx == None:
     if url_idx == None:
-        if form_data.model in app.state.MODELS:
-            url_idx = random.choice(app.state.MODELS[form_data.model]["urls"])
+        model = form_data.model
+
+        if ":" not in model:
+            model = f"{model}:latest"
+
+        if model in app.state.MODELS:
+            url_idx = random.choice(app.state.MODELS[model]["urls"])
         else:
         else:
             raise HTTPException(
             raise HTTPException(
                 status_code=400,
                 status_code=400,
@@ -874,8 +889,13 @@ async def generate_openai_chat_completion(
 ):
 ):
 
 
     if url_idx == None:
     if url_idx == None:
-        if form_data.model in app.state.MODELS:
-            url_idx = random.choice(app.state.MODELS[form_data.model]["urls"])
+        model = form_data.model
+
+        if ":" not in model:
+            model = f"{model}:latest"
+
+        if model in app.state.MODELS:
+            url_idx = random.choice(app.state.MODELS[model]["urls"])
         else:
         else:
             raise HTTPException(
             raise HTTPException(
                 status_code=400,
                 status_code=400,

+ 7 - 1
backend/config.py

@@ -413,7 +413,7 @@ RAG_EMBEDDING_MODEL_AUTO_UPDATE = (
 )
 )
 
 
 
 
-# device type embbeding models - "cpu" (default), "cuda" (nvidia gpu required) or "mps" (apple silicon) - choosing this right can lead to better performance
+# device type embedding models - "cpu" (default), "cuda" (nvidia gpu required) or "mps" (apple silicon) - choosing this right can lead to better performance
 USE_CUDA = os.environ.get("USE_CUDA_DOCKER", "false")
 USE_CUDA = os.environ.get("USE_CUDA_DOCKER", "false")
 
 
 if USE_CUDA.lower() == "true":
 if USE_CUDA.lower() == "true":
@@ -450,11 +450,17 @@ Query: [query]"""
 
 
 WHISPER_MODEL = os.getenv("WHISPER_MODEL", "base")
 WHISPER_MODEL = os.getenv("WHISPER_MODEL", "base")
 WHISPER_MODEL_DIR = os.getenv("WHISPER_MODEL_DIR", f"{CACHE_DIR}/whisper/models")
 WHISPER_MODEL_DIR = os.getenv("WHISPER_MODEL_DIR", f"{CACHE_DIR}/whisper/models")
+WHISPER_MODEL_AUTO_UPDATE = (
+    os.environ.get("WHISPER_MODEL_AUTO_UPDATE", "").lower() == "true"
+)
 
 
 
 
 ####################################
 ####################################
 # Images
 # Images
 ####################################
 ####################################
 
 
+ENABLE_IMAGE_GENERATION = (
+    os.environ.get("ENABLE_IMAGE_GENERATION", "").lower() == "true"
+)
 AUTOMATIC1111_BASE_URL = os.getenv("AUTOMATIC1111_BASE_URL", "")
 AUTOMATIC1111_BASE_URL = os.getenv("AUTOMATIC1111_BASE_URL", "")
 COMFYUI_BASE_URL = os.getenv("COMFYUI_BASE_URL", "")
 COMFYUI_BASE_URL = os.getenv("COMFYUI_BASE_URL", "")

+ 1 - 1
src/app.html

@@ -3,7 +3,7 @@
 	<head>
 	<head>
 		<meta charset="utf-8" />
 		<meta charset="utf-8" />
 		<link rel="icon" href="%sveltekit.assets%/favicon.png" />
 		<link rel="icon" href="%sveltekit.assets%/favicon.png" />
-		<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
+		<link rel="manifest" href="%sveltekit.assets%/manifest.json" crossorigin="use-credentials" />
 		<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
 		<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
 		<meta name="robots" content="noindex,nofollow" />
 		<meta name="robots" content="noindex,nofollow" />
 		<script>
 		<script>

+ 5 - 1
src/lib/components/chat/Messages/ResponseMessage.svelte

@@ -18,6 +18,7 @@
 	import { synthesizeOpenAISpeech } from '$lib/apis/openai';
 	import { synthesizeOpenAISpeech } from '$lib/apis/openai';
 	import { imageGenerations } from '$lib/apis/images';
 	import { imageGenerations } from '$lib/apis/images';
 	import {
 	import {
+		approximateToHumanReadable,
 		extractSentences,
 		extractSentences,
 		revertSanitizedResponseContent,
 		revertSanitizedResponseContent,
 		sanitizeResponseContent
 		sanitizeResponseContent
@@ -122,7 +123,10 @@
                     eval_count: ${message.info.eval_count ?? 'N/A'}<br/>
                     eval_count: ${message.info.eval_count ?? 'N/A'}<br/>
                     eval_duration: ${
                     eval_duration: ${
 											Math.round(((message.info.eval_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A'
 											Math.round(((message.info.eval_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A'
-										}ms</span>`,
+										}ms<br/>
+                    approximate_total: ${approximateToHumanReadable(
+											message.info.total_duration
+										)}</span>`,
 				allowHTML: true
 				allowHTML: true
 			});
 			});
 		}
 		}

+ 1 - 1
src/lib/components/chat/ModelSelector/Selector.svelte

@@ -33,7 +33,7 @@
 		: items;
 		: items;
 
 
 	const pullModelHandler = async () => {
 	const pullModelHandler = async () => {
-		const sanitizedModelTag = searchValue.trim();
+		const sanitizedModelTag = searchValue.trim().replace(/^ollama\s+(run|pull)\s+/, '');
 
 
 		console.log($MODEL_DOWNLOAD_POOL);
 		console.log($MODEL_DOWNLOAD_POOL);
 		if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag]) {
 		if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag]) {

+ 1 - 1
src/lib/components/chat/Settings/Models.svelte

@@ -139,7 +139,7 @@
 	};
 	};
 
 
 	const pullModelHandler = async () => {
 	const pullModelHandler = async () => {
-		const sanitizedModelTag = modelTag.trim();
+		const sanitizedModelTag = modelTag.trim().replace(/^ollama\s+(run|pull)\s+/, '');
 		if (modelDownloadStatus[sanitizedModelTag]) {
 		if (modelDownloadStatus[sanitizedModelTag]) {
 			toast.error(
 			toast.error(
 				$i18n.t(`Model '{{modelTag}}' is already in queue for downloading.`, {
 				$i18n.t(`Model '{{modelTag}}' is already in queue for downloading.`, {

+ 42 - 0
src/lib/components/common/Pagination.svelte

@@ -0,0 +1,42 @@
+<script lang="ts">
+	import { Pagination } from 'bits-ui';
+	import { createEventDispatcher } from 'svelte';
+
+	import ChevronLeft from '../icons/ChevronLeft.svelte';
+	import ChevronRight from '../icons/ChevronRight.svelte';
+
+	export let page = 0;
+	export let count = 0;
+	export let perPage = 20;
+</script>
+
+<div class="flex justify-center">
+	<Pagination.Root bind:page {count} {perPage} let:pages>
+		<div class="my-2 flex items-center">
+			<Pagination.PrevButton
+				class="mr-[25px] inline-flex size-8 items-center justify-center rounded-[9px] bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 active:scale-98 disabled:cursor-not-allowed disabled:text-gray-400 dark:disabled:text-gray-700 hover:disabled:bg-transparent dark:hover:disabled:bg-transparent"
+			>
+				<ChevronLeft className="size-4" strokeWidth="2" />
+			</Pagination.PrevButton>
+			<div class="flex items-center gap-2.5">
+				{#each pages as page (page.key)}
+					{#if page.type === 'ellipsis'}
+						<div class="text-sm font-medium text-foreground-alt">...</div>
+					{:else}
+						<Pagination.Page
+							{page}
+							class="inline-flex size-8 items-center justify-center rounded-[9px] bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-sm font-medium hover:bg-dark-10 active:scale-98 disabled:cursor-not-allowed disabled:opacity-50 hover:disabled:bg-transparent data-[selected]:bg-black data-[selected]:text-gray-100 data-[selected]:hover:bg-black dark:data-[selected]:bg-white dark:data-[selected]:text-gray-900 dark:data-[selected]:hover:bg-white"
+						>
+							{page.value}
+						</Pagination.Page>
+					{/if}
+				{/each}
+			</div>
+			<Pagination.NextButton
+				class="ml-[25px]  inline-flex size-8 items-center justify-center rounded-[9px] bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 active:scale-98 disabled:cursor-not-allowed disabled:text-gray-400 dark:disabled:text-gray-700 hover:disabled:bg-transparent dark:hover:disabled:bg-transparent"
+			>
+				<ChevronRight className="size-4" strokeWidth="2" />
+			</Pagination.NextButton>
+		</div>
+	</Pagination.Root>
+</div>

+ 15 - 0
src/lib/components/icons/ChevronLeft.svelte

@@ -0,0 +1,15 @@
+<script lang="ts">
+	export let className = 'w-4 h-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
+</svg>

+ 15 - 0
src/lib/components/icons/ChevronRight.svelte

@@ -0,0 +1,15 @@
+<script lang="ts">
+	export let className = 'w-4 h-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
+</svg>

+ 3 - 3
src/lib/components/layout/Sidebar/ChatMenu.svelte

@@ -26,14 +26,14 @@
 
 
 	<div slot="content">
 	<div slot="content">
 		<DropdownMenu.Content
 		<DropdownMenu.Content
-			class="w-full max-w-[130px] rounded-lg px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow"
+			class="w-full max-w-[150px] rounded-lg px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-900 dark:text-white shadow"
 			sideOffset={-2}
 			sideOffset={-2}
 			side="bottom"
 			side="bottom"
 			align="start"
 			align="start"
 			transition={flyAndScale}
 			transition={flyAndScale}
 		>
 		>
 			<DropdownMenu.Item
 			<DropdownMenu.Item
-				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer"
+				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer dark:hover:bg-gray-850 rounded-md"
 				on:click={() => {
 				on:click={() => {
 					renameHandler();
 					renameHandler();
 				}}
 				}}
@@ -43,7 +43,7 @@
 			</DropdownMenu.Item>
 			</DropdownMenu.Item>
 
 
 			<DropdownMenu.Item
 			<DropdownMenu.Item
-				class="flex  gap-2  items-center px-3 py-2 text-sm  font-medium cursor-pointer"
+				class="flex  gap-2  items-center px-3 py-2 text-sm  font-medium cursor-pointer dark:hover:bg-gray-850 rounded-md"
 				on:click={() => {
 				on:click={() => {
 					deleteHandler();
 					deleteHandler();
 				}}
 				}}

+ 5 - 5
src/lib/i18n/locales/vi-VN/translation.json

@@ -55,9 +55,9 @@
 	"Check for updates": "Kiểm tra cập nhật",
 	"Check for updates": "Kiểm tra cập nhật",
 	"Checking for updates...": "Đang kiểm tra cập nhật...",
 	"Checking for updates...": "Đang kiểm tra cập nhật...",
 	"Choose a model before saving...": "Chọn mô hình trước khi lưu...",
 	"Choose a model before saving...": "Chọn mô hình trước khi lưu...",
-	"Chunk Overlap": "Kích thước chồng lấn (overlap)",
+	"Chunk Overlap": "Chồng lấn (overlap)",
 	"Chunk Params": "Cài đặt số lượng ký tự cho khối ký tự (chunk)",
 	"Chunk Params": "Cài đặt số lượng ký tự cho khối ký tự (chunk)",
-	"Chunk Size": "Kích thc khối (size)",
+	"Chunk Size": "Kích thước khối (size)",
 	"Click here for help.": "Bấm vào đây để được trợ giúp.",
 	"Click here for help.": "Bấm vào đây để được trợ giúp.",
 	"Click here to check other modelfiles.": "Bấm vào đây để kiểm tra các tệp mô tả mô hình (modelfiles) khác.",
 	"Click here to check other modelfiles.": "Bấm vào đây để kiểm tra các tệp mô tả mô hình (modelfiles) khác.",
 	"Click here to select": "Bấm vào đây để chọn",
 	"Click here to select": "Bấm vào đây để chọn",
@@ -65,7 +65,7 @@
 	"click here.": "bấm vào đây.",
 	"click here.": "bấm vào đây.",
 	"Click on the user role button to change a user's role.": "Bấm vào nút trong cột VAI TRÒ để thay đổi quyền của người sử dụng.",
 	"Click on the user role button to change a user's role.": "Bấm vào nút trong cột VAI TRÒ để thay đổi quyền của người sử dụng.",
 	"Close": "Đóng",
 	"Close": "Đóng",
-	"Collection": "Bộ sưu tập",
+	"Collection": "Tổng hợp mọi tài liệu",
 	"Command": "Lệnh",
 	"Command": "Lệnh",
 	"Confirm Password": "Xác nhận Mật khẩu",
 	"Confirm Password": "Xác nhận Mật khẩu",
 	"Connections": "Kết nối",
 	"Connections": "Kết nối",
@@ -76,7 +76,7 @@
 	"Copy last response": "Sao chép phản hồi cuối cùng",
 	"Copy last response": "Sao chép phản hồi cuối cùng",
 	"Copying to clipboard was successful!": "Sao chép vào clipboard thành công!",
 	"Copying to clipboard was successful!": "Sao chép vào clipboard thành công!",
 	"Create a concise, 3-5 word phrase as a header for the following query, strictly adhering to the 3-5 word limit and avoiding the use of the word 'title':": "Tạo một cụm từ súc tích, 3-5 từ làm tiêu đề cho truy vấn sau, tuân thủ nghiêm ngặt giới hạn 3-5 từ và tránh sử dụng từ 'tiêu đề':",
 	"Create a concise, 3-5 word phrase as a header for the following query, strictly adhering to the 3-5 word limit and avoiding the use of the word 'title':": "Tạo một cụm từ súc tích, 3-5 từ làm tiêu đề cho truy vấn sau, tuân thủ nghiêm ngặt giới hạn 3-5 từ và tránh sử dụng từ 'tiêu đề':",
-	"Create a modelfile": "Tạo tệp mô tả mô hình",
+	"Create a modelfile": "Tạo tệp mô tả cho mô hình",
 	"Create Account": "Tạo Tài khoản",
 	"Create Account": "Tạo Tài khoản",
 	"Created at": "Được tạo vào lúc",
 	"Created at": "Được tạo vào lúc",
 	"Created by": "Được tạo bởi",
 	"Created by": "Được tạo bởi",
@@ -347,7 +347,7 @@
 	"Valid time units:": "Đơn vị thời gian hợp lệ:",
 	"Valid time units:": "Đơn vị thời gian hợp lệ:",
 	"variable": "biến",
 	"variable": "biến",
 	"variable to have them replaced with clipboard content.": "biến để có chúng được thay thế bằng nội dung clipboard.",
 	"variable to have them replaced with clipboard content.": "biến để có chúng được thay thế bằng nội dung clipboard.",
-	"Version": "Phiên bản",
+	"Version": "Version",
 	"Web": "Web",
 	"Web": "Web",
 	"WebUI Add-ons": "Tiện ích WebUI",
 	"WebUI Add-ons": "Tiện ích WebUI",
 	"WebUI Settings": "Cài đặt WebUI",
 	"WebUI Settings": "Cài đặt WebUI",

+ 21 - 0
src/lib/utils/index.ts

@@ -493,4 +493,25 @@ export const templatePrompt = (template: string, prompt: string) => {
 	}
 	}
 
 
 	return template;
 	return template;
+  
+export const approximateToHumanReadable = (nanoseconds: number) => {
+	const seconds = Math.floor((nanoseconds / 1e9) % 60);
+	const minutes = Math.floor((nanoseconds / 6e10) % 60);
+	const hours = Math.floor((nanoseconds / 3.6e12) % 24);
+
+	const results: string[] = [];
+
+	if (seconds >= 0) {
+		results.push(`${seconds}s`);
+	}
+
+	if (minutes > 0) {
+		results.push(`${minutes}m`);
+	}
+
+	if (hours > 0) {
+		results.push(`${hours}h`);
+	}
+
+	return results.reverse().join(' ');
 };
 };

+ 0 - 5
src/routes/(app)/+layout.svelte

@@ -106,11 +106,6 @@
 				// IndexedDB Not Found
 				// IndexedDB Not Found
 			}
 			}
 
 
-			console.log();
-
-			await models.set(await getModels());
-			await tick();
-
 			await settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}'));
 			await settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}'));
 
 
 			await modelfiles.set(await getModelfiles(localStorage.token));
 			await modelfiles.set(await getModelfiles(localStorage.token));

+ 16 - 9
src/routes/(app)/admin/+page.svelte

@@ -12,6 +12,7 @@
 	import { getSignUpEnabledStatus, toggleSignUpEnabledStatus } from '$lib/apis/auths';
 	import { getSignUpEnabledStatus, toggleSignUpEnabledStatus } from '$lib/apis/auths';
 	import EditUserModal from '$lib/components/admin/EditUserModal.svelte';
 	import EditUserModal from '$lib/components/admin/EditUserModal.svelte';
 	import SettingsModal from '$lib/components/admin/SettingsModal.svelte';
 	import SettingsModal from '$lib/components/admin/SettingsModal.svelte';
+	import Pagination from '$lib/components/common/Pagination.svelte';
 
 
 	const i18n = getContext('i18n');
 	const i18n = getContext('i18n');
 
 
@@ -21,6 +22,8 @@
 	let search = '';
 	let search = '';
 	let selectedUser = null;
 	let selectedUser = null;
 
 
+	let page = 1;
+
 	let showSettingsModal = false;
 	let showSettingsModal = false;
 	let showEditUserModal = false;
 	let showEditUserModal = false;
 
 
@@ -159,15 +162,17 @@
 										</tr>
 										</tr>
 									</thead>
 									</thead>
 									<tbody>
 									<tbody>
-										{#each users.filter((user) => {
-											if (search === '') {
-												return true;
-											} else {
-												let name = user.name.toLowerCase();
-												const query = search.toLowerCase();
-												return name.includes(query);
-											}
-										}) as user}
+										{#each users
+											.filter((user) => {
+												if (search === '') {
+													return true;
+												} else {
+													let name = user.name.toLowerCase();
+													const query = search.toLowerCase();
+													return name.includes(query);
+												}
+											})
+											.slice((page - 1) * 20, page * 20) as user}
 											<tr class="bg-white border-b dark:bg-gray-900 dark:border-gray-700 text-xs">
 											<tr class="bg-white border-b dark:bg-gray-900 dark:border-gray-700 text-xs">
 												<td class="px-3 py-2 min-w-[7rem] w-28">
 												<td class="px-3 py-2 min-w-[7rem] w-28">
 													<button
 													<button
@@ -270,6 +275,8 @@
 							<div class=" text-gray-500 text-xs mt-2 text-right">
 							<div class=" text-gray-500 text-xs mt-2 text-right">
 								ⓘ {$i18n.t("Click on the user role button to change a user's role.")}
 								ⓘ {$i18n.t("Click on the user role button to change a user's role.")}
 							</div>
 							</div>
+
+							<Pagination bind:page count={users.length} />
 						</div>
 						</div>
 					</div>
 					</div>
 				</div>
 				</div>