فهرست منبع

Merge pull request #584 from ollama-webui/profile-update

feat: profile image update support
Timothy Jaeryang Baek 1 سال پیش
والد
کامیت
5e672d9f79

+ 9 - 0
backend/apps/web/models/auths.py

@@ -63,6 +63,15 @@ class SigninForm(BaseModel):
     password: str
 
 
+class ProfileImageUrlForm(BaseModel):
+    profile_image_url: str
+
+
+class UpdateProfileForm(BaseModel):
+    profile_image_url: str
+    name: str
+
+
 class UpdatePasswordForm(BaseModel):
     password: str
     new_password: str

+ 15 - 1
backend/apps/web/models/users.py

@@ -65,7 +65,7 @@ class UsersTable:
                 "name": name,
                 "email": email,
                 "role": role,
-                "profile_image_url": get_gravatar_url(email),
+                "profile_image_url": "/user.png",
                 "timestamp": int(time.time()),
             }
         )
@@ -108,6 +108,20 @@ class UsersTable:
         except:
             return None
 
+    def update_user_profile_image_url_by_id(
+        self, id: str, profile_image_url: str
+    ) -> Optional[UserModel]:
+        try:
+            query = User.update(profile_image_url=profile_image_url).where(
+                User.id == id
+            )
+            query.execute()
+
+            user = User.get(User.id == id)
+            return UserModel(**model_to_dict(user))
+        except:
+            return None
+
     def update_user_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
         try:
             query = User.update(**updated).where(User.id == id)

+ 35 - 11
backend/apps/web/routers/auths.py

@@ -11,6 +11,7 @@ import uuid
 from apps.web.models.auths import (
     SigninForm,
     SignupForm,
+    UpdateProfileForm,
     UpdatePasswordForm,
     UserResponse,
     SigninResponse,
@@ -40,14 +41,37 @@ async def get_session_user(user=Depends(get_current_user)):
     }
 
 
+############################
+# Update Profile
+############################
+
+
+@router.post("/update/profile", response_model=UserResponse)
+async def update_profile(
+    form_data: UpdateProfileForm, session_user=Depends(get_current_user)
+):
+    if session_user:
+        user = Users.update_user_by_id(
+            session_user.id,
+            {"profile_image_url": form_data.profile_image_url, "name": form_data.name},
+        )
+        if user:
+            return user
+        else:
+            raise HTTPException(400, detail=ERROR_MESSAGES.DEFAULT())
+    else:
+        raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
+
+
 ############################
 # Update Password
 ############################
 
 
 @router.post("/update/password", response_model=bool)
-async def update_password(form_data: UpdatePasswordForm,
-                          session_user=Depends(get_current_user)):
+async def update_password(
+    form_data: UpdatePasswordForm, session_user=Depends(get_current_user)
+):
     if session_user:
         user = Auths.authenticate_user(session_user.email, form_data.password)
 
@@ -93,18 +117,19 @@ async def signin(form_data: SigninForm):
 async def signup(request: Request, form_data: SignupForm):
     if not request.app.state.ENABLE_SIGNUP:
         raise HTTPException(400, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
-        
+
     if not validate_email_format(form_data.email.lower()):
         raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT)
-        
+
     if Users.get_user_by_email(form_data.email.lower()):
         raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
-        
+
     try:
         role = "admin" if Users.get_num_users() == 0 else "pending"
         hashed = get_password_hash(form_data.password)
-        user = Auths.insert_new_auth(form_data.email.lower(),
-                                     hashed, form_data.name, role)
+        user = Auths.insert_new_auth(
+            form_data.email.lower(), hashed, form_data.name, role
+        )
 
         if user:
             token = create_token(data={"email": user.email})
@@ -120,11 +145,10 @@ async def signup(request: Request, form_data: SignupForm):
                 "profile_image_url": user.profile_image_url,
             }
         else:
-            raise HTTPException(
-                500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
+            raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
     except Exception as err:
-        raise HTTPException(500,
-                            detail=ERROR_MESSAGES.DEFAULT(err))
+        raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err))
+
 
 ############################
 # ToggleSignUp

+ 8 - 1
backend/apps/web/routers/utils.py

@@ -9,7 +9,7 @@ import os
 import aiohttp
 import json
 
-from utils.misc import calculate_sha256
+from utils.misc import calculate_sha256, get_gravatar_url
 
 from config import OLLAMA_API_BASE_URL, DATA_DIR, UPLOAD_DIR
 from constants import ERROR_MESSAGES
@@ -165,3 +165,10 @@ def upload(file: UploadFile = File(...)):
             yield f"data: {json.dumps(res)}\n\n"
 
     return StreamingResponse(file_process_stream(), media_type="text/event-stream")
+
+
+@router.get("/gravatar")
+async def get_gravatar(
+    email: str,
+):
+    return get_gravatar_url(email)

+ 31 - 0
src/lib/apis/auths/index.ts

@@ -89,6 +89,37 @@ export const userSignUp = async (name: string, email: string, password: string)
 	return res;
 };
 
+export const updateUserProfile = async (token: string, name: string, profileImageUrl: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/auths/update/profile`, {
+		method: 'POST',
+		headers: {
+			'Content-Type': 'application/json',
+			...(token && { authorization: `Bearer ${token}` })
+		},
+		body: JSON.stringify({
+			name: name,
+			profile_image_url: profileImageUrl
+		})
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			console.log(err);
+			error = err.detail;
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
 export const updateUserPassword = async (token: string, password: string, newPassword: string) => {
 	let error = null;
 

+ 23 - 0
src/lib/apis/utils/index.ts

@@ -0,0 +1,23 @@
+import { WEBUI_API_BASE_URL } from '$lib/constants';
+
+export const getGravatarUrl = async (email: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/utils/gravatar?email=${email}`, {
+		method: 'GET',
+		headers: {
+			'Content-Type': 'application/json'
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			console.log(err);
+			error = err;
+			return null;
+		});
+
+	return res;
+};

+ 179 - 0
src/lib/components/chat/Settings/Account.svelte

@@ -0,0 +1,179 @@
+<script lang="ts">
+	import toast from 'svelte-french-toast';
+	import { onMount } from 'svelte';
+
+	import { user } from '$lib/stores';
+	import { updateUserProfile } from '$lib/apis/auths';
+
+	import UpdatePassword from './Account/UpdatePassword.svelte';
+	import { getGravatarUrl } from '$lib/apis/utils';
+
+	export let saveHandler: Function;
+
+	let profileImageUrl = '';
+	let name = '';
+
+	const submitHandler = async () => {
+		const updatedUser = await updateUserProfile(localStorage.token, name, profileImageUrl).catch(
+			(error) => {
+				toast.error(error);
+			}
+		);
+
+		if (updatedUser) {
+			await user.set(updatedUser);
+			return true;
+		}
+		return false;
+	};
+
+	onMount(() => {
+		name = $user.name;
+		profileImageUrl = $user.profile_image_url;
+	});
+</script>
+
+<div class="flex flex-col h-full justify-between text-sm">
+	<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-80">
+		<input
+			id="profile-image-input"
+			type="file"
+			hidden
+			accept="image/*"
+			on:change={(e) => {
+				const files = e?.target?.files ?? [];
+				let reader = new FileReader();
+				reader.onload = (event) => {
+					let originalImageUrl = `${event.target.result}`;
+
+					const img = new Image();
+					img.src = originalImageUrl;
+
+					img.onload = function () {
+						const canvas = document.createElement('canvas');
+						const ctx = canvas.getContext('2d');
+
+						// Calculate the aspect ratio of the image
+						const aspectRatio = img.width / img.height;
+
+						// Calculate the new width and height to fit within 100x100
+						let newWidth, newHeight;
+						if (aspectRatio > 1) {
+							newWidth = 100 * aspectRatio;
+							newHeight = 100;
+						} else {
+							newWidth = 100;
+							newHeight = 100 / aspectRatio;
+						}
+
+						// Set the canvas size
+						canvas.width = 100;
+						canvas.height = 100;
+
+						// Calculate the position to center the image
+						const offsetX = (100 - newWidth) / 2;
+						const offsetY = (100 - newHeight) / 2;
+
+						// Draw the image on the canvas
+						ctx.drawImage(img, offsetX, offsetY, newWidth, newHeight);
+
+						// Get the base64 representation of the compressed image
+						const compressedSrc = canvas.toDataURL('image/jpeg');
+
+						// Display the compressed image
+						profileImageUrl = compressedSrc;
+
+						e.target.files = null;
+					};
+				};
+
+				if (
+					files.length > 0 &&
+					['image/gif', 'image/jpeg', 'image/png'].includes(files[0]['type'])
+				) {
+					reader.readAsDataURL(files[0]);
+				}
+			}}
+		/>
+
+		<div class=" mb-2.5 text-sm font-medium">Profile</div>
+
+		<div class="flex space-x-5">
+			<div class="flex flex-col">
+				<div class="self-center">
+					<button
+						class="relative rounded-full dark:bg-gray-700"
+						type="button"
+						on:click={() => {
+							document.getElementById('profile-image-input')?.click();
+						}}
+					>
+						<img
+							src={profileImageUrl !== '' ? profileImageUrl : '/user.png'}
+							alt="profile"
+							class=" rounded-full w-16 h-16 object-cover"
+						/>
+
+						<div
+							class="absolute flex justify-center rounded-full bottom-0 left-0 right-0 top-0 h-full w-full overflow-hidden bg-gray-700 bg-fixed opacity-0 transition duration-300 ease-in-out hover:opacity-50"
+						>
+							<div class="my-auto text-gray-100">
+								<svg
+									xmlns="http://www.w3.org/2000/svg"
+									viewBox="0 0 20 20"
+									fill="currentColor"
+									class="w-5 h-5"
+								>
+									<path
+										d="m2.695 14.762-1.262 3.155a.5.5 0 0 0 .65.65l3.155-1.262a4 4 0 0 0 1.343-.886L17.5 5.501a2.121 2.121 0 0 0-3-3L3.58 13.419a4 4 0 0 0-.885 1.343Z"
+									/>
+								</svg>
+							</div>
+						</div>
+					</button>
+				</div>
+				<button
+					class=" text-xs text-gray-600"
+					on:click={async () => {
+						const url = await getGravatarUrl($user.email);
+
+						profileImageUrl = url;
+					}}>Use Gravatar</button
+				>
+			</div>
+
+			<div class="flex-1">
+				<div class="flex flex-col w-full">
+					<div class=" mb-1 text-xs text-gray-500">Name</div>
+
+					<div class="flex-1">
+						<input
+							class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
+							type="text"
+							bind:value={name}
+							required
+						/>
+					</div>
+				</div>
+			</div>
+		</div>
+
+		<hr class=" dark:border-gray-700 my-4" />
+		<UpdatePassword />
+	</div>
+
+	<div class="flex justify-end pt-3 text-sm font-medium">
+		<button
+			class=" px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded"
+			on:click={async () => {
+				const res = await submitHandler();
+
+				if (res) {
+					saveHandler();
+				}
+			}}
+		>
+			Save
+		</button>
+	</div>
+</div>

+ 106 - 0
src/lib/components/chat/Settings/Account/UpdatePassword.svelte

@@ -0,0 +1,106 @@
+<script lang="ts">
+	import toast from 'svelte-french-toast';
+	import { updateUserPassword } from '$lib/apis/auths';
+
+	let show = false;
+	let currentPassword = '';
+	let newPassword = '';
+	let newPasswordConfirm = '';
+
+	const updatePasswordHandler = async () => {
+		if (newPassword === newPasswordConfirm) {
+			const res = await updateUserPassword(localStorage.token, currentPassword, newPassword).catch(
+				(error) => {
+					toast.error(error);
+					return null;
+				}
+			);
+
+			if (res) {
+				toast.success('Successfully updated.');
+			}
+
+			currentPassword = '';
+			newPassword = '';
+			newPasswordConfirm = '';
+		} else {
+			toast.error(
+				`The passwords you entered don't quite match. Please double-check and try again.`
+			);
+			newPassword = '';
+			newPasswordConfirm = '';
+		}
+	};
+</script>
+
+<form
+	class="flex flex-col text-sm"
+	on:submit|preventDefault={() => {
+		updatePasswordHandler();
+	}}
+>
+	<div class="flex justify-between mb-2.5 items-center text-sm">
+		<div class="  font-medium">Change Password</div>
+		<button
+			class=" text-xs font-medium text-gray-500"
+			type="button"
+			on:click={() => {
+				show = !show;
+			}}>{show ? 'Hide' : 'Show'}</button
+		>
+	</div>
+
+	{#if show}
+		<div class=" space-y-1.5">
+			<div class="flex flex-col w-full">
+				<div class=" mb-1 text-xs text-gray-500">Current Password</div>
+
+				<div class="flex-1">
+					<input
+						class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
+						type="password"
+						bind:value={currentPassword}
+						autocomplete="current-password"
+						required
+					/>
+				</div>
+			</div>
+
+			<div class="flex flex-col w-full">
+				<div class=" mb-1 text-xs text-gray-500">New Password</div>
+
+				<div class="flex-1">
+					<input
+						class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
+						type="password"
+						bind:value={newPassword}
+						autocomplete="new-password"
+						required
+					/>
+				</div>
+			</div>
+
+			<div class="flex flex-col w-full">
+				<div class=" mb-1 text-xs text-gray-500">Confirm Password</div>
+
+				<div class="flex-1">
+					<input
+						class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
+						type="password"
+						bind:value={newPasswordConfirm}
+						autocomplete="off"
+						required
+					/>
+				</div>
+			</div>
+		</div>
+
+		<div class="mt-3 flex justify-end">
+			<button
+				class=" px-4 py-2 text-xs bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-800 text-gray-100 transition rounded-md font-medium"
+			>
+				Update password
+			</button>
+		</div>
+	{/if}
+</form>

+ 7 - 208
src/lib/components/chat/SettingsModal.svelte

@@ -36,6 +36,8 @@
 	import { resetVectorDB } from '$lib/apis/rag';
 	import { setDefaultPromptSuggestions } from '$lib/apis/configs';
 	import { getBackendConfig } from '$lib/apis';
+	import UpdatePassword from './Settings/Account/UpdatePassword.svelte';
+	import Account from './Settings/Account.svelte';
 
 	export let show = false;
 
@@ -126,6 +128,7 @@
 	let authContent = '';
 
 	// Account
+	let profileImageUrl = '';
 	let currentPassword = '';
 	let newPassword = '';
 	let newPasswordConfirm = '';
@@ -559,31 +562,6 @@
 		return models;
 	};
 
-	const updatePasswordHandler = async () => {
-		if (newPassword === newPasswordConfirm) {
-			const res = await updateUserPassword(localStorage.token, currentPassword, newPassword).catch(
-				(error) => {
-					toast.error(error);
-					return null;
-				}
-			);
-
-			if (res) {
-				toast.success('Successfully updated.');
-			}
-
-			currentPassword = '';
-			newPassword = '';
-			newPasswordConfirm = '';
-		} else {
-			toast.error(
-				`The passwords you entered don't quite match. Please double-check and try again.`
-			);
-			newPassword = '';
-			newPasswordConfirm = '';
-		}
-	};
-
 	onMount(async () => {
 		console.log('settings', $user.role === 'admin');
 		if ($user.role === 'admin') {
@@ -616,7 +594,6 @@
 		responseAutoCopy = settings.responseAutoCopy ?? false;
 		titleAutoGenerateModel = settings.titleAutoGenerateModel ?? '';
 		gravatarEmail = settings.gravatarEmail ?? '';
-
 		speakVoice = settings.speakVoice ?? '';
 
 		const getVoicesLoop = setInterval(async () => {
@@ -631,12 +608,6 @@
 
 		saveChatHistory = settings.saveChatHistory ?? true;
 
-		authEnabled = settings.authHeader !== undefined ? true : false;
-		if (authEnabled) {
-			authType = settings.authHeader.split(' ')[0];
-			authContent = settings.authHeader.split(' ')[1];
-		}
-
 		ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => {
 			return '';
 		});
@@ -2040,184 +2011,12 @@
 							{/if}
 						</div>
 					</div>
-				{:else if selectedTab === 'auth'}
-					<form
-						class="flex flex-col h-full justify-between space-y-3 text-sm"
-						on:submit|preventDefault={() => {
-							console.log('auth save');
-							saveSettings({
-								authHeader: authEnabled ? `${authType} ${authContent}` : undefined
-							});
-							show = false;
-						}}
-					>
-						<div class=" space-y-3">
-							<div>
-								<div class=" py-1 flex w-full justify-between">
-									<div class=" self-center text-sm font-medium">Authorization Header</div>
-
-									<button
-										class="p-1 px-3 text-xs flex rounded transition"
-										type="button"
-										on:click={() => {
-											toggleAuthHeader();
-										}}
-									>
-										{#if authEnabled === true}
-											<svg
-												xmlns="http://www.w3.org/2000/svg"
-												viewBox="0 0 24 24"
-												fill="currentColor"
-												class="w-4 h-4"
-											>
-												<path
-													fill-rule="evenodd"
-													d="M12 1.5a5.25 5.25 0 00-5.25 5.25v3a3 3 0 00-3 3v6.75a3 3 0 003 3h10.5a3 3 0 003-3v-6.75a3 3 0 00-3-3v-3c0-2.9-2.35-5.25-5.25-5.25zm3.75 8.25v-3a3.75 3.75 0 10-7.5 0v3h7.5z"
-													clip-rule="evenodd"
-												/>
-											</svg>
-
-											<span class="ml-2 self-center"> On </span>
-										{:else}
-											<svg
-												xmlns="http://www.w3.org/2000/svg"
-												viewBox="0 0 24 24"
-												fill="currentColor"
-												class="w-4 h-4"
-											>
-												<path
-													d="M18 1.5c2.9 0 5.25 2.35 5.25 5.25v3.75a.75.75 0 01-1.5 0V6.75a3.75 3.75 0 10-7.5 0v3a3 3 0 013 3v6.75a3 3 0 01-3 3H3.75a3 3 0 01-3-3v-6.75a3 3 0 013-3h9v-3c0-2.9 2.35-5.25 5.25-5.25z"
-												/>
-											</svg>
-
-											<span class="ml-2 self-center">Off</span>
-										{/if}
-									</button>
-								</div>
-							</div>
-
-							{#if authEnabled}
-								<hr class=" dark:border-gray-700" />
-
-								<div class="mt-2">
-									<div class=" py-1 flex w-full space-x-2">
-										<button
-											class=" py-1 font-semibold flex rounded transition"
-											on:click={() => {
-												authType = authType === 'Basic' ? 'Bearer' : 'Basic';
-											}}
-											type="button"
-										>
-											{#if authType === 'Basic'}
-												<span class="self-center mr-2">Basic</span>
-											{:else if authType === 'Bearer'}
-												<span class="self-center mr-2">Bearer</span>
-											{/if}
-										</button>
-
-										<div class="flex-1">
-											<input
-												class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
-												placeholder="Enter Authorization Header Content"
-												bind:value={authContent}
-											/>
-										</div>
-									</div>
-									<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
-										Toggle between <span class=" text-gray-500 dark:text-gray-300 font-medium"
-											>'Basic'</span
-										>
-										and <span class=" text-gray-500 dark:text-gray-300 font-medium">'Bearer'</span> by
-										clicking on the label next to the input.
-									</div>
-								</div>
-
-								<hr class=" dark:border-gray-700" />
-
-								<div>
-									<div class=" mb-2.5 text-sm font-medium">Preview Authorization Header</div>
-									<textarea
-										value={JSON.stringify({
-											Authorization: `${authType} ${authContent}`
-										})}
-										class="w-full rounded p-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none resize-none"
-										rows="2"
-										disabled
-									/>
-								</div>
-							{/if}
-						</div>
-
-						<div class="flex justify-end pt-3 text-sm font-medium">
-							<button
-								class=" px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded"
-								type="submit"
-							>
-								Save
-							</button>
-						</div>
-					</form>
 				{:else if selectedTab === 'account'}
-					<form
-						class="flex flex-col h-full text-sm"
-						on:submit|preventDefault={() => {
-							updatePasswordHandler();
+					<Account
+						saveHandler={() => {
+							show = false;
 						}}
-					>
-						<div class=" mb-2.5 font-medium">Change Password</div>
-
-						<div class=" space-y-1.5">
-							<div class="flex flex-col w-full">
-								<div class=" mb-1 text-xs text-gray-500">Current Password</div>
-
-								<div class="flex-1">
-									<input
-										class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
-										type="password"
-										bind:value={currentPassword}
-										autocomplete="current-password"
-										required
-									/>
-								</div>
-							</div>
-
-							<div class="flex flex-col w-full">
-								<div class=" mb-1 text-xs text-gray-500">New Password</div>
-
-								<div class="flex-1">
-									<input
-										class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
-										type="password"
-										bind:value={newPassword}
-										autocomplete="new-password"
-										required
-									/>
-								</div>
-							</div>
-
-							<div class="flex flex-col w-full">
-								<div class=" mb-1 text-xs text-gray-500">Confirm Password</div>
-
-								<div class="flex-1">
-									<input
-										class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
-										type="password"
-										bind:value={newPasswordConfirm}
-										autocomplete="off"
-										required
-									/>
-								</div>
-							</div>
-						</div>
-
-						<div class="mt-3 flex justify-end">
-							<button
-								class=" px-4 py-2 text-xs bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-800 text-gray-100 transition rounded-md font-medium"
-							>
-								Update password
-							</button>
-						</div>
-					</form>
+					/>
 				{:else if selectedTab === 'about'}
 					<div class="flex flex-col h-full justify-between space-y-3 text-sm mb-6">
 						<div class=" space-y-3">