This commit is contained in:
Timothy Jaeryang Baek
2026-07-15 00:14:01 -04:00
parent 254888cf15
commit 63ada24706
31 changed files with 2560 additions and 2754 deletions
+53
View File
@@ -70,6 +70,7 @@
import {
archiveChatById,
cloneChatById,
compactChatById,
createNewChat,
deleteChatById,
@@ -2265,6 +2266,53 @@
document.getElementById('chat-input')?.focus();
};
const handleForkChat = async () => {
if (!$chatId || !history?.currentId) {
toast.message($i18n.t('No chat to fork'));
return;
}
if (!($user?.role === 'admin' || ($user?.permissions?.chat?.import ?? true))) {
toast.error($i18n.t('Access prohibited'));
return;
}
const currentMessage = history.messages?.[history.currentId];
if (
generating ||
taskIds?.length ||
(currentMessage?.role === 'assistant' && !currentMessage.done)
) {
toast.warning($i18n.t('Wait for the current response to finish before forking.'));
return;
}
const toastId = toast.loading($i18n.t('Forking chat...'));
try {
const result = await cloneChatById(
localStorage.token,
$chatId,
$i18n.t('Clone of {{TITLE}}', {
TITLE: $chatTitle || 'Chat'
})
);
if (result?.id) {
await goto(`/c/${result.id}`);
await refreshChatList(localStorage.token, { refreshPinned: true });
toast.success($i18n.t('Chat forked'), { id: toastId });
} else {
toast.error($i18n.t('Failed to fork chat'), { id: toastId });
}
} catch (error) {
toast.error(`${error}`, { id: toastId });
} finally {
messageInput?.setText('');
prompt = '';
document.getElementById('chat-input')?.focus();
}
};
const submitHandler = async (userPrompt, { _raw = false } = {}) => {
console.log('submitHandler', userPrompt, $chatId);
@@ -2284,6 +2332,10 @@
handleStatusCommand();
return;
}
if (String(userPrompt).trim() === '/fork') {
await handleForkChat();
return;
}
if (pendingOAuthTools.length > 0) {
toast.warning($i18n.t('Please connect all required integrations before sending a message'));
@@ -3451,6 +3503,7 @@
{contextUsage}
compactHandler={handleManualCompact}
statusHandler={handleStatusCommand}
forkHandler={handleForkChat}
toolServers={$toolServers}
{generating}
{stopResponse}
@@ -116,6 +116,7 @@
export let stopResponse: Function;
export let compactHandler: Function = () => {};
export let statusHandler: Function = () => {};
export let forkHandler: Function = () => {};
export let chatId = '';
export let contextUsage = null;
@@ -1086,9 +1087,12 @@
canCompact: () => !!history?.currentId,
compactDisabled: () => isActive,
canStatus: () => !!history?.currentId,
canFork: () => !!history?.currentId,
forkDisabled: () => isActive,
contextUsage: () => contextUsage,
onCompact: compactHandler,
onStatus: statusHandler,
onFork: forkHandler,
onSelect: (e) => {
const { type, data } = e;
@@ -14,16 +14,21 @@
export let onUpload: (e: any) => void = () => {};
export let onCompact: () => void = () => {};
export let onStatus: () => void = () => {};
export let onFork: () => void = () => {};
export let insertTextHandler: (text: string) => void = () => {};
export let canCompact: boolean | (() => boolean) = false;
export let compactDisabled: boolean | (() => boolean) = false;
export let canStatus: boolean | (() => boolean) = false;
export let canFork: boolean | (() => boolean) = false;
export let forkDisabled: boolean | (() => boolean) = false;
export let contextUsage = null;
$: compactAvailable = typeof canCompact === 'function' ? canCompact() : canCompact;
$: isCompactDisabled =
typeof compactDisabled === 'function' ? compactDisabled() : compactDisabled;
$: statusAvailable = typeof canStatus === 'function' ? canStatus() : canStatus;
$: forkAvailable = typeof canFork === 'function' ? canFork() : canFork;
$: isForkDisabled = typeof forkDisabled === 'function' ? forkDisabled() : forkDisabled;
$: resolvedContextUsage = typeof contextUsage === 'function' ? contextUsage() : contextUsage;
$: contextPercent = Math.max(0, Math.round(resolvedContextUsage?.percent ?? 0));
@@ -77,6 +82,8 @@
canCompact={compactAvailable}
compactDisabled={isCompactDisabled}
canStatus={statusAvailable}
canFork={forkAvailable}
forkDisabled={isForkDisabled}
{contextPercent}
onSelect={(e) => {
const { type, data } = e;
@@ -89,6 +96,9 @@
} else if (type === 'command' && data.id === 'status') {
insertTextHandler('');
onStatus();
} else if (type === 'command' && data.id === 'fork') {
insertTextHandler('');
onFork();
} else if (type === 'skill') {
command({
id: `${data.id}|${data.name}`,
@@ -12,6 +12,8 @@
export let canCompact = false;
export let compactDisabled = false;
export let canStatus = false;
export let canFork = false;
export let forkDisabled = false;
export let contextPercent = 0;
let selectedIdx = 0;
@@ -28,6 +30,9 @@
...(canCompact && 'compact'.startsWith(query.toLowerCase())
? [{ type: 'command', data: { id: 'compact' } }]
: []),
...(canFork && 'fork'.startsWith(query.toLowerCase())
? [{ type: 'command', data: { id: 'fork' } }]
: []),
...(canStatus && 'status'.startsWith(query.toLowerCase())
? [{ type: 'command', data: { id: 'status' } }]
: [])
@@ -86,6 +91,9 @@
if (item?.type === 'command' && item.data?.id === 'compact' && compactDisabled) {
return;
}
if (item?.type === 'command' && item.data?.id === 'fork' && forkDisabled) {
return;
}
if (item) {
onSelect(item);
}
@@ -167,6 +175,50 @@
</span>
</button>
</Tooltip>
{:else if item.data.id === 'fork'}
<Tooltip content="Clone this chat into a new copy." placement="top">
<button
type="button"
aria-label="Fork: clone this chat into a new copy."
class="slash-command-row flex items-center gap-2 w-full h-6 px-2 rounded-xl text-xs text-left transition-colors duration-75
{commandIdx === selectedIdx ? 'app-interactive-active' : ''} disabled:opacity-50"
disabled={forkDisabled}
on:mousedown={(e) => e.preventDefault()}
on:click={() => {
if (!forkDisabled) {
onSelect(item);
}
}}
on:mouseenter={() => {
selectedIdx = commandIdx;
}}
on:focus={() => {}}
data-selected={commandIdx === selectedIdx}
>
<span class="app-icon-muted flex items-center justify-center w-4 shrink-0">
<svg
class="size-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M4 12H9" />
<path d="M9 12C12.5 12 12.5 7 16 7H20" />
<path d="M17 4L20 7L17 10" />
<path d="M9 12C12.5 12 12.5 17 16 17H20" />
<path d="M17 14L20 17L17 20" />
</svg>
</span>
<span class="flex-1 min-w-0 flex items-baseline gap-1.5 overflow-hidden">
<span class="truncate">Fork</span>
<span class="app-muted text-[0.625rem] truncate shrink-0">Clone chat</span>
</span>
</button>
</Tooltip>
{:else if item.data.id === 'status'}
<Tooltip content="Check what is running in this chat." placement="top">
<button
+100 -107
View File
@@ -124,119 +124,108 @@
});
</script>
<div id="tab-account" class="flex flex-col h-full justify-between text-sm">
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5 -mr-1.5">
<div class="space-y-1">
<div>
<div class="text-xs text-gray-400 dark:text-gray-600 mb-2">
{$i18n.t('Your Account')}
</div>
<div class="text-xs text-gray-500 mt-0.5">
{$i18n.t('Manage your account information.')}
</div>
</div>
<div id="tab-account" class="flex h-full flex-col text-sm">
<div class="flex-1 min-h-0 w-full overflow-y-auto scrollbar-hover pr-1.5 -mr-1.5 py-1">
<h2 class="mb-4 text-sm font-medium text-gray-900 dark:text-white">{$i18n.t('Account')}</h2>
<div class="space-y-3">
<!-- <div class=" text-sm font-normal">{$i18n.t('Account')}</div> -->
<div class="flex space-x-5 my-4">
<UserProfileImage bind:profileImageUrl user={$user} />
<UserProfileImage
bind:profileImageUrl
user={$user}
variant="account"
displayName={$user?.name}
/>
<div class="flex flex-1 flex-col">
<div class=" flex-1">
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs font-normal">{$i18n.t('Name')}</div>
<div class="flex flex-col w-full">
<div class="mb-1 text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Name')}</div>
<div class="flex-1">
<input
class="w-full text-sm dark:text-gray-300 bg-transparent outline-hidden"
type="text"
bind:value={name}
aria-label={$i18n.t('Name')}
required
placeholder={$i18n.t('Enter your name')}
/>
</div>
</div>
<input
class="w-full bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-hidden placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700"
type="text"
bind:value={name}
aria-label={$i18n.t('Name')}
required
placeholder={$i18n.t('Enter your name')}
/>
</div>
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs font-normal">{$i18n.t('Bio')}</div>
<div class="mt-2 flex flex-col w-full">
<div class="mb-1 text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Bio')}</div>
<div class="flex-1">
<Textarea
className="w-full text-sm dark:text-gray-300 bg-transparent outline-hidden"
minSize={60}
bind:value={bio}
ariaLabel={$i18n.t('Bio')}
placeholder={$i18n.t('Share your background and interests')}
/>
</div>
</div>
<Textarea
className="w-full bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-hidden placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700"
minSize={60}
bind:value={bio}
ariaLabel={$i18n.t('Bio')}
placeholder={$i18n.t('Share your background and interests')}
/>
</div>
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs font-normal">{$i18n.t('Gender')}</div>
<div class="flex-1">
<select
class="w-full text-sm dark:text-gray-300 bg-transparent outline-hidden"
bind:value={_gender}
aria-label={$i18n.t('Gender')}
on:change={(e) => {
console.log(_gender);
if (_gender === 'custom') {
// Handle custom gender input
gender = '';
} else {
gender = _gender;
}
}}
>
<option value="" selected>{$i18n.t('Prefer not to say')}</option>
<option value="male">{$i18n.t('Male')}</option>
<option value="female">{$i18n.t('Female')}</option>
<option value="custom">{$i18n.t('Custom')}</option>
</select>
</div>
{#if _gender === 'custom'}
<input
class="w-full text-sm dark:text-gray-300 bg-transparent outline-hidden mt-1"
type="text"
required
aria-label={$i18n.t('Custom Gender')}
placeholder={$i18n.t('Enter your gender')}
bind:value={gender}
/>
{/if}
</div>
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs font-normal">{$i18n.t('Birth Date')}</div>
<div class="flex-1">
<input
class="w-full text-sm dark:text-gray-300 dark:placeholder:text-gray-300 bg-transparent outline-hidden"
type="date"
aria-label={$i18n.t('Birth Date')}
bind:value={dateOfBirth}
required
/>
</div>
</div>
</div>
<div class="mt-2 flex flex-col w-full">
<div class="mb-1 text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('Gender')}
</div>
<select
class="w-full bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-hidden dark:text-gray-300"
bind:value={_gender}
aria-label={$i18n.t('Gender')}
on:change={(e) => {
console.log(_gender);
if (_gender === 'custom') {
// Handle custom gender input
gender = '';
} else {
gender = _gender;
}
}}
>
<option value="" selected>{$i18n.t('Prefer not to say')}</option>
<option value="male">{$i18n.t('Male')}</option>
<option value="female">{$i18n.t('Female')}</option>
<option value="custom">{$i18n.t('Custom')}</option>
</select>
{#if _gender === 'custom'}
<input
class="mt-1 w-full bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-hidden placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700"
type="text"
required
aria-label={$i18n.t('Custom Gender')}
placeholder={$i18n.t('Enter your gender')}
bind:value={gender}
/>
{/if}
</div>
<div class="mt-2 flex flex-col w-full">
<div class="mb-1 text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('Birth Date')}
</div>
<input
class="w-full bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-hidden dark:text-gray-300 dark:placeholder:text-gray-700"
type="date"
aria-label={$i18n.t('Birth Date')}
bind:value={dateOfBirth}
required
/>
</div>
</div>
{#if $config?.features?.enable_user_webhooks && ($user?.role === 'admin' || ($user?.permissions?.features?.webhooks ?? false))}
<div class="mt-2">
<div class="mt-3">
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs font-normal">{$i18n.t('Notification Webhook')}</div>
<div class="mb-1 text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('Notification Webhook')}
</div>
<div class="flex-1">
<input
class="w-full text-sm outline-hidden"
class="w-full bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-hidden placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700"
type="url"
placeholder={$i18n.t('Enter your webhook URL')}
aria-label={$i18n.t('Notification Webhook')}
@@ -248,7 +237,7 @@
</div>
{/if}
<hr class="border-gray-50 dark:border-gray-850/30 my-4" />
<hr class="my-4 border-gray-100/40 dark:border-white/[0.03]" />
{#if $config?.features.enable_login_form && $config?.features.enable_password_change_form}
<div class="mt-2">
@@ -257,10 +246,10 @@
{/if}
{#if ($config?.features?.enable_api_keys ?? true) && ($user?.role === 'admin' || ($user?.permissions?.features?.api_keys ?? false))}
<div class="flex justify-between items-center text-sm mt-2">
<div class=" font-normal">{$i18n.t('API keys')}</div>
<div class="mt-3 flex items-center justify-between text-sm">
<div class="text-xs text-gray-400 dark:text-gray-600">{$i18n.t('API keys')}</div>
<button
class=" text-xs font-normal text-gray-500"
class="text-xs font-normal text-gray-500 transition-colors hover:text-gray-700 dark:hover:text-gray-300"
type="button"
on:click={() => {
showAPIKeys = !showAPIKeys;
@@ -273,14 +262,16 @@
{#if $user?.role === 'admin'}
<div class="justify-between w-full mt-2">
<div class="flex justify-between w-full">
<div class="self-center text-xs font-normal mb-1">{$i18n.t('JWT Token')}</div>
<div class="mb-1 self-center text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('JWT Token')}
</div>
</div>
<div class="flex">
<SensitiveInput value={localStorage.token} readOnly={true} />
<button
class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
class="ml-1.5 rounded-sm px-1.5 py-1 text-gray-500 transition hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-850 dark:hover:text-gray-300"
aria-label={$i18n.t('Copy Token')}
on:click={() => {
copyToClipboard(localStorage.token);
@@ -331,7 +322,9 @@
<div class="justify-between w-full mt-2">
{#if $user?.role === 'admin'}
<div class="flex justify-between w-full">
<div class="self-center text-xs font-normal mb-1">{$i18n.t('API Key')}</div>
<div class="mb-1 self-center text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('API Key')}
</div>
</div>
{/if}
<div class="flex">
@@ -339,7 +332,7 @@
<SensitiveInput value={APIKey} readOnly={true} />
<button
class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
class="ml-1.5 rounded-sm px-1.5 py-1 text-gray-500 transition hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-850 dark:hover:text-gray-300"
aria-label={$i18n.t('Copy API Key')}
on:click={() => {
copyToClipboard(APIKey);
@@ -385,7 +378,7 @@
<Tooltip content={$i18n.t('Create new key')}>
<button
class=" px-1.5 py-1 dark:hover:bg-gray-850transition rounded-lg"
class="rounded-sm px-1.5 py-1 text-gray-500 transition hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-850 dark:hover:text-gray-300"
aria-label={$i18n.t('Create new key')}
on:click={() => {
createAPIKeyHandler();
@@ -409,7 +402,7 @@
</Tooltip>
{:else}
<button
class="flex gap-1.5 items-center font-normal px-3.5 py-1.5 rounded-lg bg-gray-100/70 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-850 transition"
class="flex items-center gap-1.5 text-[0.8125rem] font-normal text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
on:click={() => {
createAPIKeyHandler();
}}
@@ -427,7 +420,7 @@
{/if}
</div>
<div class="shrink-0 flex justify-end pt-3 text-sm font-normal">
<div class="shrink-0 flex w-full justify-end pt-3 text-sm font-normal">
<button
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
on:click={async () => {
@@ -44,9 +44,9 @@
}}
>
<div class="flex justify-between items-center text-sm">
<div class=" font-normal">{$i18n.t('Change Password')}</div>
<div class="text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Change Password')}</div>
<button
class=" text-xs font-normal text-gray-500"
class="text-xs font-normal text-gray-500 transition-colors hover:text-gray-700 dark:hover:text-gray-300"
type="button"
on:click={() => {
show = !show;
@@ -13,6 +13,8 @@
export let user = null;
export let imageClassName = 'size-14 md:size-18';
export let variant = 'default';
export let displayName = '';
let profileImageInputElement;
</script>
@@ -79,10 +81,11 @@
}}
/>
<div class="flex flex-col self-start group">
<div class="self-center flex">
{#if variant === 'account'}
<div class="mb-6 flex items-center gap-4">
<button
class="relative rounded-full dark:bg-gray-700"
class="group relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-gray-100
ring-1 ring-gray-200 transition-all duration-200 hover:ring-2 hover:ring-gray-300 dark:bg-white/8 dark:ring-white/10 dark:hover:ring-white/20"
type="button"
on:click={() => {
profileImageInputElement.click();
@@ -91,61 +94,154 @@
<img
src={profileImageUrl !== '' ? profileImageUrl : generateInitialsImage(user?.name)}
alt="profile"
class=" rounded-full {imageClassName} object-cover"
class="h-full w-full object-cover"
/>
<div class="absolute bottom-0 right-0 opacity-0 group-hover:opacity-100 transition">
<div class="p-1 rounded-full bg-white text-black border-gray-100 shadow">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3"
>
<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
class="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors duration-200 group-hover:bg-black/40"
>
<svg
class="h-4 w-4 text-white opacity-0 drop-shadow-sm transition-opacity duration-200 group-hover:opacity-100"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z"
/>
</svg>
</div>
</button>
</div>
<div class="flex flex-col w-full justify-center mt-2">
<button
class=" text-xs text-center text-gray-500 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
type="button"
on:click={async () => {
profileImageUrl = `${WEBUI_BASE_URL}/user.png`;
}}>{$i18n.t('Remove')}</button
>
<button
class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
type="button"
on:click={async () => {
if (canvasPixelTest()) {
profileImageUrl = generateInitialsImage(user?.name);
} else {
toast.info(
$i18n.t(
'Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.'
),
{
duration: 1000 * 10
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-gray-900 dark:text-white"
>{displayName || user?.name}</span
>
<div class="flex flex-wrap items-center gap-2">
<button
class="text-[0.6875rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
profileImageInputElement.click();
}}>{$i18n.t('Upload Photo')}</button
>
<span class="text-[0.6875rem] text-gray-300 dark:text-gray-700">·</span>
<button
class="text-[0.6875rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={async () => {
profileImageUrl = `${WEBUI_BASE_URL}/user.png`;
}}>{$i18n.t('Remove')}</button
>
<span class="text-[0.6875rem] text-gray-300 dark:text-gray-700">·</span>
<button
class="text-[0.6875rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={async () => {
if (canvasPixelTest()) {
profileImageUrl = generateInitialsImage(user?.name);
} else {
toast.info(
$i18n.t(
'Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.'
),
{
duration: 1000 * 10
}
);
}
);
}
}}>{$i18n.t('Initials')}</button
>
}}>{$i18n.t('Initials')}</button
>
<span class="text-[0.6875rem] text-gray-300 dark:text-gray-700">·</span>
<button
class="text-[0.6875rem] text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={async () => {
const url = await getGravatarUrl(localStorage.token, user?.email);
<button
class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
type="button"
on:click={async () => {
const url = await getGravatarUrl(localStorage.token, user?.email);
profileImageUrl = url;
}}>{$i18n.t('Gravatar')}</button
>
profileImageUrl = url;
}}>{$i18n.t('Gravatar')}</button
>
</div>
</div>
</div>
</div>
{:else}
<div class="flex flex-col self-start group">
<div class="self-center flex">
<button
class="relative rounded-full dark:bg-gray-700"
type="button"
on:click={() => {
profileImageInputElement.click();
}}
>
<img
src={profileImageUrl !== '' ? profileImageUrl : generateInitialsImage(user?.name)}
alt="profile"
class=" rounded-full {imageClassName} object-cover"
/>
<div class="absolute bottom-0 right-0 opacity-0 group-hover:opacity-100 transition">
<div class="p-1 rounded-full bg-white text-black border-gray-100 shadow">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3"
>
<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>
<div class="flex flex-col w-full justify-center mt-2">
<button
class=" text-xs text-center text-gray-500 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
type="button"
on:click={async () => {
profileImageUrl = `${WEBUI_BASE_URL}/user.png`;
}}>{$i18n.t('Remove')}</button
>
<button
class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
type="button"
on:click={async () => {
if (canvasPixelTest()) {
profileImageUrl = generateInitialsImage(user?.name);
} else {
toast.info(
$i18n.t(
'Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.'
),
{
duration: 1000 * 10
}
);
}
}}>{$i18n.t('Initials')}</button
>
<button
class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg py-0.5 opacity-0 group-hover:opacity-100 transition-all"
type="button"
on:click={async () => {
const url = await getGravatarUrl(localStorage.token, user?.email);
profileImageUrl = url;
}}>{$i18n.t('Gravatar')}</button
>
</div>
</div>
{/if}
+7 -4
View File
@@ -592,7 +592,8 @@
<Modal
size="full"
className="w-full max-w-3xl lg:max-w-4xl xl:max-w-5xl mx-4 md:mx-0 flex flex-col md:flex-row max-h-[85vh] lg:max-h-[90vh] md:h-[35rem] lg:h-[42rem] xl:h-[46rem] bg-white dark:bg-gray-900 rounded-4xl"
containerClassName="p-4 sm:p-6 lg:p-8"
className="w-full max-w-[88rem] h-[min(50rem,calc(100dvh-4rem))] max-h-[calc(100dvh-4rem)] flex flex-col md:flex-row bg-white dark:bg-gray-900 rounded-4xl"
bind:show
>
<nav
@@ -806,9 +807,11 @@
</nav>
<div class="flex-1 min-h-0 p-4 md:px-5 flex flex-col">
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-4">
{$i18n.t(selectedTabTitle)}
</h2>
{#if selectedTab !== 'account'}
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-4">
{$i18n.t(selectedTabTitle)}
</h2>
{/if}
<div class="flex-1 min-h-0 overflow-hidden">
{#if selectedTab === 'general'}