This commit is contained in:
Timothy Jaeryang Baek 2026-07-15 17:50:26 -04:00
parent db934a3b4f
commit ba067258de
39 changed files with 1540 additions and 1442 deletions

View file

@ -3,7 +3,6 @@
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { settings } from '$lib/stores';
import { verifyOpenAIConnection } from '$lib/apis/openai';
import { verifyOllamaConnection } from '$lib/apis/ollama';
@ -59,9 +58,9 @@
let loading = false;
let showDeleteConfirmDialog = false;
const highContrastInputClass =
const inputClass =
'rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
const highContrastSelectClass =
const selectClass =
'rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-gray-700 outline-hidden transition-colors focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:focus:border-blue-500';
const verifyOllamaHandler = async () => {
@ -311,14 +310,13 @@
<label
for="url-input"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('URL')}</label
`}>{$i18n.t('URL')}</label
>
<div class="flex-1">
<input
id="url-input"
class={`w-full text-sm ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={url}
placeholder={$i18n.t('API Base URL')}
@ -378,9 +376,7 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="select-bearer-or-session" class={`text-xs text-gray-500`}
>{$i18n.t('Auth')}</label
>
@ -388,7 +384,7 @@
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
class={`w-full text-sm pr-5 ${($settings?.highContrastMode ?? false) ? highContrastSelectClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 dark:bg-gray-900'}`}
class={`w-full text-sm pr-5 ${selectClass}`}
bind:value={auth_type}
>
<option value="none">{$i18n.t('None')}</option>
@ -412,27 +408,19 @@
required={false}
/>
{:else if auth_type === 'none'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'session'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{:else if auth_type === 'system_oauth'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('Forwards system user OAuth access token to authenticate')}
</div>
{:else if ['azure_ad', 'microsoft_entra_id'].includes(auth_type)}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('Uses DefaultAzureCredential to authenticate')}
</div>
{/if}
@ -447,8 +435,7 @@
<label
for="headers-input"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('Headers')}</label
`}>{$i18n.t('Headers')}</label
>
<div class="flex-1">
@ -475,8 +462,7 @@
<label
for="prefix-id-input"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('Prefix ID')}</label
`}>{$i18n.t('Prefix ID')}</label
>
<div class="flex-1">
@ -486,7 +472,7 @@
)}
>
<input
class={`w-full text-sm ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${inputClass}`}
type="text"
id="prefix-id-input"
bind:value={prefixId}
@ -503,8 +489,7 @@
<label
for="provider-select"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('Provider')}</label
`}>{$i18n.t('Provider')}</label
>
<div>
@ -527,14 +512,13 @@
<label
for="api-version-input"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('API Version')}</label
`}>{$i18n.t('API Version')}</label
>
<div class="flex-1">
<input
id="api-version-input"
class={`w-full text-sm ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={apiVersion}
placeholder={$i18n.t('API Version')}
@ -551,8 +535,7 @@
<label
for="api-type-toggle"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('API Type')}</label
`}>{$i18n.t('API Type')}</label
>
<div>
@ -571,7 +554,8 @@
'This feature is currently experimental and may not work as expected.'
)}
>
<span class=" text-gray-400 dark:text-gray-600"
<span
class="inline-flex items-center text-[0.625rem] font-normal uppercase leading-none text-gray-400 dark:text-gray-600"
>{$i18n.t('Experimental')}</span
>
@ -589,7 +573,7 @@
<div class="mb-1 flex justify-between">
<div
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
`}
>
{$i18n.t('Model IDs')}
</div>
@ -621,7 +605,7 @@
{:else}
<div
class={`text-gray-500 text-xs text-center py-2 px-10
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
`}
>
{#if ollama}
{$i18n.t('Leave empty to include all models from "{{url}}/api/tags" endpoint', {
@ -644,7 +628,7 @@
<div class="flex items-center">
<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
<input
class={`w-full text-sm ${($settings?.highContrastMode ?? false) ? highContrastInputClass : `rounded-lg bg-transparent py-1 placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden ${modelId ? '' : 'text-gray-500'}`}`}
class={`w-full text-sm ${inputClass} ${modelId ? '' : 'text-gray-500'}`}
bind:value={modelId}
id="add-model-id-input"
placeholder={$i18n.t('Add a model ID')}
@ -668,7 +652,7 @@
<div class="flex flex-col w-full">
<div
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
`}
>
{$i18n.t('Tags')}
</div>

View file

@ -3,8 +3,6 @@
import { getContext, onMount } from 'svelte';
const i18n = getContext<any>('i18n');
import { settings } from '$lib/stores';
import Modal from '$lib/components/common/Modal.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
@ -61,9 +59,9 @@
let loadingPolicy = false;
let policyLoadError = '';
const highContrastInputClass =
const inputClass =
'rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
const highContrastSelectClass =
const selectClass =
'rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-gray-700 outline-hidden transition-colors focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:focus:border-blue-500';
const stringifyJson = (value: object | null | undefined) => {
@ -399,9 +397,7 @@
<div class="flex gap-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label
for="terminal-name"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="terminal-name" class={`text-xs text-gray-500`}
>{$i18n.t('Name')}</label
>
</div>
@ -409,7 +405,7 @@
<div class="flex flex-1 items-center">
<input
id="terminal-name"
class={`w-full flex-1 text-sm ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm ${inputClass}`}
type="text"
bind:value={name}
placeholder={$i18n.t('My Terminal')}
@ -420,9 +416,7 @@
{#if !direct}
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label
for="terminal-id"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="terminal-id" class={`text-xs text-gray-500`}
>{$i18n.t('ID')}
<span class="opacity-50">({$i18n.t('optional')})</span></label
>
@ -430,7 +424,7 @@
<div class="flex flex-1 items-center">
<input
id="terminal-id"
class={`w-full flex-1 text-sm font-mono ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
type="text"
bind:value={id}
placeholder="auto"
@ -444,17 +438,13 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<label
for="terminal-url"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('URL')}</label
>
<label for="terminal-url" class={`text-xs text-gray-500`}>{$i18n.t('URL')}</label>
</div>
<div class="flex flex-1 items-center">
<input
id="terminal-url"
class={`w-full flex-1 text-sm ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm ${inputClass}`}
type="text"
bind:value={url}
placeholder="http://localhost:9900"
@ -519,16 +509,14 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('Policy ID')}
</div>
</div>
<div class="flex flex-1 items-center">
<input
id="policy-id"
class={`w-full flex-1 text-sm font-mono ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
type="text"
bind:value={policyId}
placeholder="python-ds"
@ -549,9 +537,7 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('Image')}
<span class="opacity-50">({$i18n.t('optional')})</span>
</div>
@ -559,7 +545,7 @@
<div class="flex flex-1 items-center">
<input
id="policy-image"
class={`w-full flex-1 text-sm font-mono ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
type="text"
bind:value={policyImage}
placeholder="ghcr.io/open-webui/open-terminal:latest"
@ -572,16 +558,14 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('CPU')}
</div>
</div>
<div class="flex flex-1 items-center">
<input
id="policy-cpu"
class={`w-full flex-1 text-sm font-mono ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
type="text"
bind:value={policyCpu}
placeholder="1"
@ -591,16 +575,14 @@
</div>
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('Memory')}
</div>
</div>
<div class="flex flex-1 items-center">
<input
id="policy-memory"
class={`w-full flex-1 text-sm font-mono ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
type="text"
bind:value={policyMemory}
placeholder="1Gi"
@ -613,16 +595,14 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('Storage')}
</div>
</div>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
class={`w-full text-sm pr-5 ${($settings?.highContrastMode ?? false) ? highContrastSelectClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 dark:bg-gray-900'}`}
class={`w-full text-sm pr-5 ${selectClass}`}
bind:value={policyStorage}
>
<option value="ephemeral">{$i18n.t('Ephemeral')}</option>
@ -633,7 +613,7 @@
<div class="flex flex-1 items-center">
<input
id="policy-storage-size"
class={`w-full flex-1 text-sm font-mono ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
type="text"
bind:value={policyStorageSize}
placeholder="5Gi"
@ -646,9 +626,7 @@
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('Idle Timeout')}
<span class="opacity-50">({$i18n.t('min')})</span>
</div>
@ -656,7 +634,7 @@
<div class="flex flex-1 items-center">
<input
id="idle-timeout"
class={`w-full flex-1 text-sm font-mono ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
type="number"
min="0"
bind:value={policyIdleTimeout}
@ -671,9 +649,7 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between items-center mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('Environment Variables')}
</div>
<button
@ -688,13 +664,13 @@
{#each policyEnvPairs as pair, idx}
<div class="flex gap-1.5 mb-1">
<input
class={`flex-1 text-sm font-mono ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`flex-1 text-sm font-mono ${inputClass}`}
type="text"
bind:value={pair.key}
placeholder="KEY"
/>
<input
class={`flex-[2] text-sm font-mono ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`flex-[2] text-sm font-mono ${inputClass}`}
type="text"
bind:value={pair.value}
placeholder="value"
@ -715,15 +691,13 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('Lifecycle JSON')}
</div>
</div>
<textarea
id="lifecycle-json"
class={`w-full min-h-24 resize-y text-xs font-mono ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'rounded-lg border border-gray-200 bg-transparent p-2 outline-hidden placeholder:text-gray-300 dark:border-gray-800 dark:placeholder:text-gray-700'}`}
class={`w-full min-h-24 resize-y text-xs font-mono ${inputClass}`}
bind:value={lifecycleJson}
spellcheck="false"
placeholder={`{\n "reset": {\n "schedule": "@weekly",\n "timezone": "UTC"\n }\n}`}
@ -794,9 +768,7 @@
<div class="flex flex-col w-full">
<div class="flex justify-between items-center mb-0.5">
<div class="flex gap-2 items-center">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('OpenAPI Spec')}
</div>
</div>
@ -809,7 +781,7 @@
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${inputClass}`}
type="text"
id="openapi-path"
bind:value={path}
@ -821,9 +793,7 @@
</div>
</div>
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs mt-1 text-gray-500`}>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://')
? path
@ -838,9 +808,7 @@
<div class="flex flex-col w-full">
<div class="flex justify-between items-center">
<div class="flex gap-2 items-center">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('Auth')}
</div>
</div>
@ -848,10 +816,7 @@
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
class={`w-full text-sm pr-5 ${($settings?.highContrastMode ?? false) ? highContrastSelectClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 dark:bg-gray-900'}`}
bind:value={auth_type}
>
<select class={`w-full text-sm pr-5 ${selectClass}`} bind:value={auth_type}>
<option value="none">{$i18n.t('None')}</option>
<option value="bearer">{$i18n.t('Bearer')}</option>
{#if !direct}
@ -869,21 +834,15 @@
required={false}
/>
{:else if auth_type === 'none'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'session'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{:else if auth_type === 'system_oauth'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('Forwards system user OAuth access token to authenticate')}
</div>
{/if}

View file

@ -8,7 +8,6 @@
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { settings } from '$lib/stores';
import Modal from '$lib/components/common/Modal.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
@ -70,9 +69,9 @@
let showAccessControlModal = false;
let showDeleteConfirmDialog = false;
const highContrastInputClass =
const inputClass =
'rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
const highContrastSelectClass =
const selectClass =
'rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-gray-700 outline-hidden transition-colors focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:focus:border-blue-500';
const registerOAuthClientHandler = async () => {
@ -538,17 +537,13 @@
<div class="flex gap-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label
for="enter-name"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Name')}</label
>
<label for="enter-name" class={`text-xs text-gray-500`}>{$i18n.t('Name')}</label>
</div>
<div class="flex flex-1 items-center">
<input
id="enter-name"
class={`w-full flex-1 text-sm ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm ${inputClass}`}
type="text"
bind:value={name}
placeholder={$i18n.t('Enter name')}
@ -559,9 +554,7 @@
{#if !direct}
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label
for="enter-id"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="enter-id" class={`text-xs text-gray-500`}
>{$i18n.t('ID')}
{#if type !== 'mcp'}<span class="opacity-50">({$i18n.t('optional')})</span
>{/if}</label
@ -570,7 +563,7 @@
<div class="flex flex-1 items-center">
<input
id="enter-id"
class={`w-full flex-1 text-sm font-mono ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
type="text"
bind:value={id}
placeholder="auto"
@ -583,16 +576,14 @@
</div>
<div class="flex flex-col w-full mt-1 mb-1.5">
<label
for="description"
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="description" class={`mb-0.5 text-xs text-gray-500`}
>{$i18n.t('Description')}</label
>
<div class="flex-1">
<input
id="description"
class={`w-full text-sm ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={description}
placeholder={$i18n.t('Enter description')}
@ -604,17 +595,13 @@
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<label
for="api-base-url"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('URL')}</label
>
<label for="api-base-url" class={`text-xs text-gray-500`}>{$i18n.t('URL')}</label>
</div>
<div class="flex flex-1 items-center">
<input
id="api-base-url"
class={`w-full flex-1 text-sm ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-sm ${inputClass}`}
type="text"
bind:value={url}
placeholder={$i18n.t('API Base URL')}
@ -661,10 +648,7 @@
<div class="flex flex-col w-full">
<div class="flex justify-between items-center">
<div class="flex gap-2 items-center">
<div
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div for="select-bearer-or-session" class={`text-xs text-gray-500`}>
{$i18n.t('Auth')}
</div>
</div>
@ -710,7 +694,7 @@
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
class={`w-full text-sm pr-5 ${($settings?.highContrastMode ?? false) ? highContrastSelectClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 dark:bg-gray-900'}`}
class={`w-full text-sm pr-5 ${selectClass}`}
bind:value={auth_type}
>
<option value="none">{$i18n.t('None')}</option>
@ -736,26 +720,20 @@
required={false}
/>
{:else if auth_type === 'none'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'session'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{:else if auth_type === 'system_oauth'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
{$i18n.t('Forwards system user OAuth access token to authenticate')}
</div>
{:else if auth_type === 'oauth_2.1'}
<div
class={`flex items-center text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
class={`flex items-center text-xs self-center translate-y-[1px] text-gray-500`}
>
{$i18n.t('Uses OAuth 2.1 Dynamic Client Registration')}
</div>
@ -773,7 +751,7 @@
/>
<div class="flex flex-1 items-center">
<input
class={`w-full text-sm ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={oauthServerUrl}
placeholder={$i18n.t('OAuth Server URL')}
@ -824,10 +802,7 @@
<div class="flex flex-col w-full">
<div class="flex justify-between items-center mb-0.5">
<div class="flex gap-2 items-center">
<div
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div for="select-bearer-or-session" class={`text-xs text-gray-500`}>
{$i18n.t('OpenAPI Spec')}
</div>
</div>
@ -837,7 +812,7 @@
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
class={`w-full text-sm pr-5 ${($settings?.highContrastMode ?? false) ? highContrastSelectClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 dark:bg-gray-900'}`}
class={`w-full text-sm pr-5 ${selectClass}`}
bind:value={spec_type}
>
<option value="url">{$i18n.t('URL')}</option>
@ -852,7 +827,7 @@
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${inputClass}`}
type="text"
id="url-or-path"
bind:value={path}
@ -862,12 +837,10 @@
/>
</div>
{:else if spec_type === 'json'}
<div
class={`text-xs w-full self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs w-full self-center translate-y-[1px] text-gray-500`}>
<label for="url-or-path" class="sr-only">{$i18n.t('JSON Spec')}</label>
<textarea
class={`w-full text-sm ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-black dark:text-white'}`}
class={`w-full text-sm ${inputClass}`}
bind:value={spec}
placeholder={$i18n.t('JSON Spec')}
autocomplete="off"
@ -880,9 +853,7 @@
</div>
{#if ['', 'url'].includes(spec_type)}
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs mt-1 text-gray-500`}>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://')
? path
@ -897,16 +868,14 @@
{#if type === 'mcp' && ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type)}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="oauth-scope"
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="oauth-scope" class={`mb-0.5 text-xs text-gray-500`}
>{$i18n.t('OAuth Scopes')}</label
>
<div class="flex flex-1 items-center">
<input
id="oauth-scope"
class={`w-full text-sm ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={oauthScope}
placeholder={$i18n.t('Use discovered scopes')}
@ -918,16 +887,14 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="oauth-resource-parameter"
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="oauth-resource-parameter" class={`mb-0.5 text-xs text-gray-500`}
>{$i18n.t('OAuth Resource Parameter')}</label
>
<div class="flex flex-1 items-center">
<select
id="oauth-resource-parameter"
class={`w-full text-sm pr-5 ${($settings?.highContrastMode ?? false) ? highContrastSelectClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 dark:bg-gray-900'}`}
class={`w-full text-sm pr-5 ${selectClass}`}
bind:value={oauthResourceParameter}
>
<option value="auto">{$i18n.t('Automatic')}</option>
@ -945,8 +912,7 @@
<label
for="headers-input"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('Headers')}</label
`}>{$i18n.t('Headers')}</label
>
<div class="flex-1">
@ -973,16 +939,14 @@
<hr class=" border-gray-100/50 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex flex-col w-full mt-2">
<label
for="function-name-filter-list"
class={`mb-1 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="function-name-filter-list" class={`mb-1 text-xs text-gray-500`}
>{$i18n.t('Function Name Filter List')}</label
>
<div class="flex-1">
<input
id="function-name-filter-list"
class={`w-full text-sm ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={functionNameFilterList}
placeholder={$i18n.t('Enter function name filter list (e.g. func1, !func2)')}

View file

@ -11,7 +11,7 @@
export let urlIdx: number | null = null;
</script>
<Modal size="sm" bind:show>
<Modal size="sm" bind:show className="bg-white dark:bg-gray-900 rounded-4xl">
<div>
<div class=" flex justify-between dark:text-gray-100 px-4 pt-3 pb-1">
<div class="flex w-full justify-between items-center text-sm font-medium self-center">

View file

@ -128,7 +128,7 @@
}}
/>
<Modal size="sm" bind:show>
<Modal size="sm" bind:show className="bg-white dark:bg-gray-900 rounded-4xl">
<div>
<div class=" flex justify-between dark:text-gray-100 px-4 pt-3 pb-1">
<div class=" text-sm font-medium self-center">

View file

@ -51,7 +51,7 @@
events: ['*'] as string[]
};
const highContrastInputClass =
const inputClass =
'rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
$: events = eventItems.map((item) => item.event);
@ -392,7 +392,7 @@
});
</script>
<Modal bind:show={showWebhookModal} size="sm">
<Modal bind:show={showWebhookModal} size="sm" className="bg-white dark:bg-gray-900 rounded-4xl">
<div>
<div class="flex justify-between dark:text-gray-100 px-4 pt-3 pb-1">
<h1 class="text-sm font-medium self-center">
@ -413,14 +413,12 @@
<form class="flex flex-col w-full" on:submit|preventDefault={saveWebhook}>
<div class="flex gap-3">
<div class="flex flex-col w-full">
<label
for="event-webhook-name"
class={`text-xs mb-0.5 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="event-webhook-name" class={`text-xs mb-0.5 text-gray-500`}
>{$i18n.t('Name')}</label
>
<input
id="event-webhook-name"
class={`w-full text-xs ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-xs ${inputClass}`}
type="text"
placeholder={$i18n.t('Identity audit')}
autocomplete="off"
@ -431,15 +429,13 @@
<div class="flex gap-3 mt-2">
<div class="flex flex-col w-full">
<label
for="event-webhook-url"
class={`text-xs mb-0.5 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="event-webhook-url" class={`text-xs mb-0.5 text-gray-500`}
>{$i18n.t('URL')}</label
>
<div class="flex items-center gap-2">
<input
id="event-webhook-url"
class={`w-full text-xs ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full text-xs ${inputClass}`}
type="url"
placeholder="https://example.com/events"
autocomplete="off"
@ -455,9 +451,7 @@
<div class="mt-3">
<div class="flex justify-between items-center gap-3">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<div class={`text-xs text-gray-500`}>
{$i18n.t('Send events for')}
</div>
<select
@ -524,7 +518,8 @@
<div class="relative">
<input
class={`w-full text-xs ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
data-settings-search
class={`w-full text-xs ${inputClass}`}
type="text"
placeholder={$i18n.t('Search users or groups')}
autocomplete="off"
@ -571,9 +566,7 @@
<div class="mt-3">
<div class="flex justify-between items-center">
<label
for="event-webhook-all-events"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
<label for="event-webhook-all-events" class={`text-xs text-gray-500`}
>{$i18n.t('Events')}</label
>
<label class="flex items-center gap-1.5 text-xs text-gray-700 dark:text-gray-300">
@ -610,7 +603,7 @@
<div class="flex gap-2">
<input
class={`w-full flex-1 text-xs font-mono ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
class={`w-full flex-1 text-xs font-mono ${inputClass}`}
type="text"
placeholder={$i18n.t('Search or add pattern')}
autocomplete="off"

View file

@ -408,7 +408,7 @@
onMount(refresh);
</script>
<Modal bind:show={showSourceModal} size="sm">
<Modal bind:show={showSourceModal} size="sm" className="bg-white dark:bg-gray-900 rounded-4xl">
<div>
<div class="flex justify-between dark:text-gray-100 px-4 pt-3 pb-1">
<h1 class="text-sm font-medium self-center">

View file

@ -535,6 +535,7 @@
<Search className="size-3.5" />
</div>
<input
data-settings-search
class=" w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={searchValue}
placeholder={$i18n.t('Search Models')}

View file

@ -37,7 +37,7 @@
});
</script>
<Modal size="sm" bind:show>
<Modal size="sm" bind:show className="bg-white dark:bg-gray-900 rounded-4xl">
<div>
<div class=" flex justify-between dark:text-gray-100 px-4 pt-3 pb-1">
<div class=" text-sm font-medium self-center">

View file

@ -163,7 +163,7 @@
}}
/>
<Modal size="lg" bind:show>
<Modal size="lg" bind:show className="bg-white dark:bg-gray-900 rounded-4xl">
<div>
<div class=" flex justify-between dark:text-gray-100 px-4 pt-3 pb-1">
<div class=" text-sm font-medium self-center">

View file

@ -7,6 +7,8 @@
import { onMount, getContext } from 'svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import UserSettingRow from './UserSettingRow.svelte';
import UserSettingSection from './UserSettingSection.svelte';
const i18n = getContext('i18n');
@ -17,6 +19,8 @@
current: '',
latest: ''
};
const actionButtonClass =
'text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white';
const checkForVersionUpdates = async () => {
updateAvailable = null;
@ -44,19 +48,15 @@
});
</script>
<div id="tab-about" class="flex flex-col h-full justify-between space-y-3 text-sm">
<div id="tab-about" class="flex flex-col h-full justify-between text-sm">
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-4">{$i18n.t('About')}</h2>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5 space-y-3">
<div>
<div class="text-xs text-gray-400 dark:text-gray-600 mb-2.5 flex space-x-2 items-center">
<div>
{$WEBUI_NAME}
{$i18n.t('Version')}
</div>
</div>
<div class="flex w-full justify-between items-center">
<div class="flex flex-col text-xs text-gray-700 dark:text-gray-200">
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5">
<UserSettingSection title={`${$WEBUI_NAME} ${$i18n.t('Version')}`} first>
<UserSettingRow
description={$i18n.t('View the installed version and check release updates.')}
>
<div slot="label" class="flex flex-col text-xs text-gray-600 dark:text-gray-400">
<div class="flex gap-1">
<Tooltip content={WEBUI_BUILD_HASH}>
v{WEBUI_VERSION}
@ -77,7 +77,7 @@
</div>
<button
class=" underline flex items-center space-x-1 text-xs text-gray-500 dark:text-gray-500"
class={actionButtonClass}
on:click={() => {
showChangelog.set(true);
}}
@ -88,7 +88,7 @@
{#if $config?.features?.enable_version_update_check}
<button
class=" text-xs px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-lg font-normal"
class={actionButtonClass}
on:click={() => {
checkForVersionUpdates();
}}
@ -96,85 +96,67 @@
{$i18n.t('Check for updates')}
</button>
{/if}
</div>
</div>
</UserSettingRow>
</UserSettingSection>
{#if ollamaVersion}
<hr class=" border-gray-100/20 dark:border-white/[0.025]" />
<div>
<div class="text-xs text-gray-400 dark:text-gray-600 mb-2.5">
{$i18n.t('Ollama Version')}
<UserSettingSection title={$i18n.t('Ollama Version')}>
<div class="text-xs text-gray-600 dark:text-gray-400">
{ollamaVersion ?? 'N/A'}
</div>
<div class="flex w-full">
<div class="flex-1 text-xs text-gray-700 dark:text-gray-200">
{ollamaVersion ?? 'N/A'}
</div>
</div>
</div>
</UserSettingSection>
{/if}
<hr class=" border-gray-100/20 dark:border-white/[0.025]" />
<UserSettingSection title={$i18n.t('Community')}>
{#if $config?.license_metadata}
<div class="text-xs text-gray-600 dark:text-gray-400">
{#if !$WEBUI_NAME.includes('Open WebUI')}
<span>{$WEBUI_NAME}</span> -
{/if}
{#if $config?.license_metadata}
<div class="mb-2 text-xs">
{#if !$WEBUI_NAME.includes('Open WebUI')}
<span class=" text-gray-500 dark:text-gray-300 font-normal">{$WEBUI_NAME}</span> -
{/if}
<span class="capitalize">{$config?.license_metadata?.type}</span> license purchased by
<span class="capitalize">{$config?.license_metadata?.organization_name}</span>
</div>
{:else}
<div class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-gray-400 dark:text-gray-600">
<a
class="hover:text-gray-700 dark:hover:text-gray-400"
href="https://discord.gg/5rJgQTnV4s"
target="_blank">Discord</a
>
<a
class="hover:text-gray-700 dark:hover:text-gray-400"
href="https://twitter.com/OpenWebUI"
target="_blank">X</a
>
<a
class="hover:text-gray-700 dark:hover:text-gray-400"
href="https://github.com/open-webui/open-webui"
target="_blank">GitHub</a
>
</div>
{/if}
<span class=" capitalize">{$config?.license_metadata?.type}</span> license purchased by
<span class=" capitalize">{$config?.license_metadata?.organization_name}</span>
<div class="text-xs text-gray-400 dark:text-gray-500">
Emoji graphics provided by
<a href="https://github.com/jdecked/twemoji" target="_blank">Twemoji</a>, licensed under
<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank">CC-BY 4.0</a>.
</div>
{:else}
<div class="flex space-x-1">
<a href="https://discord.gg/5rJgQTnV4s" target="_blank">
<img
alt="Discord"
src="https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white"
/>
</a>
<a href="https://twitter.com/OpenWebUI" target="_blank">
<img
alt="X (formerly Twitter) Follow"
src="https://img.shields.io/twitter/follow/OpenWebUI"
/>
</a>
<a href="https://github.com/open-webui/open-webui" target="_blank">
<img
alt="Github Repo"
src="https://img.shields.io/github/stars/open-webui/open-webui?style=social&label=Star us on Github"
/>
</a>
</div>
{/if}
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
Emoji graphics provided by
<a href="https://github.com/jdecked/twemoji" target="_blank">Twemoji</a>, licensed under
<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank">CC-BY 4.0</a>.
</div>
<div>
<pre
class="text-xs text-gray-400 dark:text-gray-500">Copyright (c) {new Date().getFullYear()} <a
href="https://openwebui.com"
target="_blank"
class="underline">Open WebUI Inc.</a
> <a href="https://github.com/open-webui/open-webui/blob/main/LICENSE" target="_blank"
<div class="text-xs text-gray-400 dark:text-gray-500">
Copyright (c) {new Date().getFullYear()}
<a href="https://openwebui.com" target="_blank" class="underline">Open WebUI Inc.</a>
<a href="https://github.com/open-webui/open-webui/blob/main/LICENSE" target="_blank"
>All rights reserved.</a
>
</pre>
</div>
</div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Created by')}
<a
class=" text-gray-500 dark:text-gray-300 font-normal"
href="https://github.com/tjbck"
target="_blank">Timothy J. Baek</a
>
</div>
<div class="text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Created by')}
<a class="text-gray-500 dark:text-gray-400" href="https://github.com/tjbck" target="_blank"
>Timothy J. Baek</a
>
</div>
</UserSettingSection>
</div>
</div>

View file

@ -14,16 +14,16 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import User from '$lib/components/icons/User.svelte';
import UserProfileImage from './Account/UserProfileImage.svelte';
import UserSettingField from './UserSettingField.svelte';
import UserSettingRow from './UserSettingRow.svelte';
import UserSettingSection from './UserSettingSection.svelte';
const i18n = getContext('i18n');
export let saveHandler: Function;
export let saveSettings: Function;
let loaded = false;
let profileImageUrl = '';
let name = '';
let bio = '';
@ -39,7 +39,13 @@
let APIKey = '';
let APIKeyCopied = false;
let profileImageInputElement: HTMLInputElement;
const textareaClass =
'w-full resize-y rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
const inputClass =
'h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
const actionButtonClass =
'text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white';
const submitHandler = async () => {
if (name !== $user?.name) {
@ -119,18 +125,14 @@
return '';
});
}
loaded = true;
});
</script>
<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 py-1">
<div class="flex-1 min-h-0 w-full overflow-y-auto scrollbar-hover pr-1.5">
<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> -->
<UserSettingSection title={$i18n.t('Profile')} first>
<UserProfileImage
bind:profileImageUrl
user={$user}
@ -138,38 +140,39 @@
displayName={$user?.name}
/>
<div class="flex flex-col w-full">
<div class="mb-1 text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Name')}</div>
<UserSettingField
label={$i18n.t('Name')}
description={$i18n.t('Set the display name shown across your account.')}
>
<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"
class={inputClass}
type="text"
bind:value={name}
aria-label={$i18n.t('Name')}
required
placeholder={$i18n.t('Enter your name')}
/>
</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>
</UserSettingField>
<UserSettingField
label={$i18n.t('Bio')}
description={$i18n.t('Add optional profile context visible where profiles are shown.')}
>
<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"
className={textareaClass}
minSize={60}
bind:value={bio}
ariaLabel={$i18n.t('Bio')}
placeholder={$i18n.t('Share your background and interests')}
/>
</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>
</UserSettingField>
<UserSettingField
label={$i18n.t('Gender')}
description={$i18n.t('Choose the gender value stored on your profile.')}
>
<select
class="w-full bg-transparent py-1 text-[0.8125rem] text-gray-700 outline-hidden dark:text-gray-300"
class={inputClass}
bind:value={_gender}
aria-label={$i18n.t('Gender')}
on:change={(e) => {
@ -191,7 +194,7 @@
{#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"
class="mt-1 {inputClass}"
type="text"
required
aria-label={$i18n.t('Custom Gender')}
@ -199,150 +202,81 @@
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>
</UserSettingField>
<UserSettingField
label={$i18n.t('Birth Date')}
description={$i18n.t('Set the birth date saved with your profile.')}
>
<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"
class={inputClass}
type="date"
aria-label={$i18n.t('Birth Date')}
bind:value={dateOfBirth}
required
/>
</div>
</div>
</UserSettingField>
</UserSettingSection>
{#if $config?.features?.enable_user_webhooks && ($user?.role === 'admin' || ($user?.permissions?.features?.webhooks ?? false))}
<div class="mt-3">
<div class="flex flex-col w-full">
<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 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')}
bind:value={webhookUrl}
required
/>
</div>
</div>
</div>
<UserSettingSection title={$i18n.t('Notifications')}>
<UserSettingField
label={$i18n.t('Notification Webhook')}
description={$i18n.t('Send account notifications to this webhook URL.')}
>
<input
class={inputClass}
type="url"
placeholder={$i18n.t('Enter your webhook URL')}
aria-label={$i18n.t('Notification Webhook')}
bind:value={webhookUrl}
required
/>
</UserSettingField>
</UserSettingSection>
{/if}
<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">
<UserSettingSection title={$i18n.t('Password')}>
<UpdatePassword />
</div>
</UserSettingSection>
{/if}
{#if ($config?.features?.enable_api_keys ?? true) && ($user?.role === 'admin' || ($user?.permissions?.features?.api_keys ?? false))}
<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 transition-colors hover:text-gray-700 dark:hover:text-gray-300"
type="button"
on:click={() => {
showAPIKeys = !showAPIKeys;
}}>{showAPIKeys ? $i18n.t('Hide') : $i18n.t('Show')}</button
>
</div>
<UserSettingSection title={$i18n.t('API keys')}>
<UserSettingRow description={$i18n.t('Show or hide sensitive account secrets.')}>
<span slot="label">{$i18n.t('Secrets')}</span>
<button
class={actionButtonClass}
type="button"
on:click={() => {
showAPIKeys = !showAPIKeys;
}}>{showAPIKeys ? $i18n.t('Hide') : $i18n.t('Show')}</button
>
</UserSettingRow>
{#if showAPIKeys}
<div class="flex flex-col">
{#if $user?.role === 'admin'}
<div class="justify-between w-full mt-2">
<div class="flex justify-between w-full">
<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 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);
JWTTokenCopied = true;
setTimeout(() => {
JWTTokenCopied = false;
}, 2000);
}}
>
{#if JWTTokenCopied}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M11.986 3H12a2 2 0 0 1 2 2v6a2 2 0 0 1-1.5 1.937V7A2.5 2.5 0 0 0 10 4.5H4.063A2 2 0 0 1 6 3h.014A2.25 2.25 0 0 1 8.25 1h1.5a2.25 2.25 0 0 1 2.236 2ZM10.5 4v-.75a.75.75 0 0 0-.75-.75h-1.5a.75.75 0 0 0-.75.75V4h3Z"
clip-rule="evenodd"
/>
<path
fill-rule="evenodd"
d="M3 6a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H3Zm1.75 2.5a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5ZM4 11.75a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1-.75-.75Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</div>
</div>
{/if}
{#if ($config?.features?.enable_api_keys ?? true) && ($user?.role === 'admin' || ($user?.permissions?.features?.api_keys ?? false))}
<div class="justify-between w-full mt-2">
{#if $user?.role === 'admin'}
<div class="flex justify-between w-full">
<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">
{#if APIKey}
<SensitiveInput value={APIKey} readOnly={true} />
{#if showAPIKeys}
<div class="flex flex-col gap-2.5">
{#if $user?.role === 'admin'}
<UserSettingField
label={$i18n.t('JWT Token')}
description={$i18n.t('Copy the current session token for authenticated requests.')}
>
<div class="flex">
<SensitiveInput value={localStorage.token} readOnly={true} />
<button
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')}
aria-label={$i18n.t('Copy Token')}
on:click={() => {
copyToClipboard(APIKey);
APIKeyCopied = true;
copyToClipboard(localStorage.token);
JWTTokenCopied = true;
setTimeout(() => {
APIKeyCopied = false;
JWTTokenCopied = false;
}, 2000);
}}
>
{#if APIKeyCopied}
{#if JWTTokenCopied}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
@ -375,48 +309,106 @@
</svg>
{/if}
</button>
</div>
</UserSettingField>
{/if}
{#if ($config?.features?.enable_api_keys ?? true) && ($user?.role === 'admin' || ($user?.permissions?.features?.api_keys ?? false))}
<UserSettingField
label={$i18n.t('API Key')}
description={$i18n.t('Create, copy, or rotate your API key.')}
>
<div class="flex">
{#if APIKey}
<SensitiveInput value={APIKey} readOnly={true} />
<Tooltip content={$i18n.t('Create new key')}>
<button
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')}
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);
APIKeyCopied = true;
setTimeout(() => {
APIKeyCopied = false;
}, 2000);
}}
>
{#if APIKeyCopied}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M11.986 3H12a2 2 0 0 1 2 2v6a2 2 0 0 1-1.5 1.937V7A2.5 2.5 0 0 0 10 4.5H4.063A2 2 0 0 1 6 3h.014A2.25 2.25 0 0 1 8.25 1h1.5a2.25 2.25 0 0 1 2.236 2ZM10.5 4v-.75a.75.75 0 0 0-.75-.75h-1.5a.75.75 0 0 0-.75.75V4h3Z"
clip-rule="evenodd"
/>
<path
fill-rule="evenodd"
d="M3 6a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H3Zm1.75 2.5a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5ZM4 11.75a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1-.75-.75Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
<Tooltip content={$i18n.t('Create new key')}>
<button
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();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</Tooltip>
{:else}
<button
class={actionButtonClass}
on:click={() => {
createAPIKeyHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</Tooltip>
{:else}
<button
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();
}}
>
<Plus strokeWidth="2" className=" size-3.5" />
<Plus strokeWidth="2" className=" size-3.5" />
{$i18n.t('Create new secret key')}</button
>
{/if}
</div>
</div>
{/if}
</div>
{/if}
{$i18n.t('Create new secret key')}</button
>
{/if}
</div>
</UserSettingField>
{/if}
</div>
{/if}
</UserSettingSection>
{/if}
</div>

View file

@ -10,6 +10,10 @@
let currentPassword = '';
let newPassword = '';
let newPasswordConfirm = '';
const inputClass =
'h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
const actionButtonClass =
'text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white';
const updatePasswordHandler = async () => {
if (newPassword === newPasswordConfirm) {
@ -43,25 +47,30 @@
updatePasswordHandler();
}}
>
<div class="flex justify-between items-center text-sm">
<div class="text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Change Password')}</div>
<div class="flex items-center justify-between gap-2.5">
<div class="text-xs text-gray-600 dark:text-gray-400">{$i18n.t('Change Password')}</div>
<button
class="text-xs font-normal text-gray-500 transition-colors hover:text-gray-700 dark:hover:text-gray-300"
class={actionButtonClass}
type="button"
on:click={() => {
show = !show;
}}>{show ? $i18n.t('Hide') : $i18n.t('Show')}</button
>
</div>
<p class="-mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Update the password used for email and password sign-in.')}
</p>
{#if show}
<div class=" py-2.5 space-y-1.5">
<div class="py-2.5 space-y-2.5">
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Current Password')}</div>
<div class="mb-1 text-xs text-gray-600 dark:text-gray-400">
{$i18n.t('Current Password')}
</div>
<div class="flex-1">
<SensitiveInput
class="w-full bg-transparent text-sm dark:text-gray-300 outline-hidden placeholder:opacity-30"
class={inputClass}
type="password"
bind:value={currentPassword}
placeholder={$i18n.t('Enter your current password')}
@ -72,11 +81,13 @@
</div>
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('New Password')}</div>
<div class="mb-1 text-xs text-gray-600 dark:text-gray-400">
{$i18n.t('New Password')}
</div>
<div class="flex-1">
<SensitiveInput
class="w-full bg-transparent text-sm dark:text-gray-300 outline-hidden placeholder:opacity-30"
class={inputClass}
type="password"
bind:value={newPassword}
placeholder={$i18n.t('Enter your new password')}
@ -87,11 +98,13 @@
</div>
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Confirm Password')}</div>
<div class="mb-1 text-xs text-gray-600 dark:text-gray-400">
{$i18n.t('Confirm Password')}
</div>
<div class="flex-1">
<SensitiveInput
class="w-full bg-transparent text-sm dark:text-gray-300 outline-hidden placeholder:opacity-30"
class={inputClass}
type="password"
bind:value={newPasswordConfirm}
placeholder={$i18n.t('Confirm your new password')}

View file

@ -82,7 +82,7 @@
/>
{#if variant === 'account'}
<div class="mb-6 flex items-center gap-4">
<div class="mb-2 flex items-center gap-4">
<button
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"
@ -122,12 +122,10 @@
</button>
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-gray-900 dark:text-white"
>{displayName || user?.name}</span
>
<span class="text-xs text-gray-600 dark:text-gray-400">{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"
class="text-[0.6875rem] text-gray-500 transition-colors duration-100 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300"
type="button"
on:click={() => {
profileImageInputElement.click();
@ -135,7 +133,7 @@
>
<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"
class="text-[0.6875rem] text-gray-500 transition-colors duration-100 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300"
type="button"
on:click={async () => {
profileImageUrl = `${WEBUI_BASE_URL}/user.png`;
@ -143,7 +141,7 @@
>
<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"
class="text-[0.6875rem] text-gray-500 transition-colors duration-100 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300"
type="button"
on:click={async () => {
if (canvasPixelTest()) {
@ -162,7 +160,7 @@
>
<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"
class="text-[0.6875rem] text-gray-500 transition-colors duration-100 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300"
type="button"
on:click={async () => {
const url = await getGravatarUrl(localStorage.token, user?.email);
@ -215,7 +213,7 @@
>
<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"
class="rounded-lg py-0.5 text-center text-xs text-gray-600 opacity-0 transition-all group-hover:opacity-100 dark:text-gray-400"
type="button"
on:click={async () => {
if (canvasPixelTest()) {
@ -234,7 +232,7 @@
>
<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"
class="rounded-lg py-0.5 text-center text-xs text-gray-600 opacity-0 transition-all group-hover:opacity-100 dark:text-gray-400"
type="button"
on:click={async () => {
const url = await getGravatarUrl(localStorage.token, user?.email);

View file

@ -57,8 +57,8 @@
<div
class={layout === 'grid'
? 'grid grid-cols-1 gap-x-5 gap-y-1 pb-safe-bottom text-xs sm:grid-cols-2 lg:grid-cols-3'
: 'space-y-1 text-xs pb-safe-bottom'}
? 'grid grid-cols-1 gap-x-5 gap-y-1 pb-safe-bottom text-xs text-gray-600 dark:text-gray-400 sm:grid-cols-2 lg:grid-cols-3'
: 'space-y-1 pb-safe-bottom text-xs text-gray-600 dark:text-gray-400'}
>
<div>
<Tooltip

View file

@ -181,7 +181,7 @@
/>
<div id="tab-archived-chats" class="flex flex-col h-full text-sm">
<div class="flex items-center justify-between mb-2">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-medium text-gray-900 dark:text-white">
{$i18n.t('Archived Chats')}
{#if chatCount !== null}
@ -192,22 +192,25 @@
</h2>
</div>
<div class="flex h-8 shrink-0 items-center w-full gap-2">
<div class="flex min-w-0 flex-1 items-center">
<div class=" self-center ml-1 mr-3">
<div class="flex h-7 shrink-0 items-center w-full gap-2">
<div
class="flex min-w-0 flex-1 items-center rounded-lg bg-gray-50/70 px-2 dark:bg-white/[0.03]"
>
<div class="mr-2 self-center text-gray-400 dark:text-gray-600">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
data-settings-search
class="w-full bg-transparent py-1 text-xs text-gray-700 outline-hidden placeholder:text-gray-300 dark:text-gray-300 dark:placeholder:text-gray-700"
bind:value={query}
on:input={searchHandler}
placeholder={$i18n.t('Search')}
maxlength="500"
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<div class="self-center pl-1.5">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
class="rounded-lg p-0.5 text-gray-400 transition-colors hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
aria-label={$i18n.t('Clear search')}
on:click={() => {
@ -224,7 +227,7 @@
<Dropdown align="end">
<Tooltip content={$i18n.t('Actions')}>
<button
class="flex h-8 items-center gap-1.5 rounded-xl bg-transparent px-1.5 text-[13px] font-normal text-gray-700 transition hover:text-gray-900 dark:text-gray-200 dark:hover:text-gray-100"
class="flex h-7 items-center gap-1.5 rounded-lg bg-transparent px-1.5 text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white"
type="button"
>
<span>{$i18n.t('Actions')}</span>
@ -235,7 +238,7 @@
<div slot="content">
<DropdownMenu className="w-[170px] shadow-sm">
<button
class="flex h-[1.6875rem] w-full cursor-pointer select-none items-center gap-2 rounded-xl bg-transparent px-2 text-[13px] hover:text-gray-900 disabled:cursor-default disabled:opacity-30 dark:hover:text-gray-100"
class="flex h-[1.6875rem] w-full cursor-pointer select-none items-center gap-2 rounded-lg bg-transparent px-2 text-xs hover:text-gray-900 disabled:cursor-default disabled:opacity-30 dark:hover:text-gray-100"
disabled={loading || chatCount === 0}
type="button"
on:click={() => {
@ -251,7 +254,7 @@
</button>
<button
class="flex h-[1.6875rem] w-full cursor-pointer select-none items-center gap-2 rounded-xl bg-transparent px-2 text-[13px] hover:text-gray-900 disabled:cursor-default disabled:opacity-30 dark:hover:text-gray-100"
class="flex h-[1.6875rem] w-full cursor-pointer select-none items-center gap-2 rounded-lg bg-transparent px-2 text-xs hover:text-gray-900 disabled:cursor-default disabled:opacity-30 dark:hover:text-gray-100"
disabled={loading || chatCount === 0}
type="button"
on:click={exportChatsHandler}
@ -264,7 +267,7 @@
</Dropdown>
</div>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover px-2 pr-1.5">
<div class="mt-3 flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5">
{#if chatList === null}
<div class="flex min-h-20 items-center justify-center">
<Spinner className="size-5" />
@ -276,7 +279,7 @@
{$i18n.t('You have no archived conversations.')}
</div>
{:else}
<div class="flex items-center text-xs text-gray-400 dark:text-gray-600">
<div class="flex items-center px-1 text-xs text-gray-400 dark:text-gray-600">
<button
class="flex flex-1 items-center gap-1 py-0.5 text-left"
type="button"
@ -309,7 +312,7 @@
<div>
{#each chatList as chat (chat.id)}
<div class="py-0.5 flex w-full justify-between gap-2 text-xs">
<div class="flex w-full justify-between gap-2 px-1 py-1 text-xs">
<a
class="min-w-0 flex-1 self-center truncate text-gray-600 hover:text-black dark:text-gray-400 dark:hover:text-white"
href={`/c/${chat.id}`}
@ -338,7 +341,7 @@
<div class="flex shrink-0 items-center justify-end text-gray-500 dark:text-gray-500">
<Tooltip content={$i18n.t('Unarchive Chat')}>
<button
class="rounded-sm p-1 hover:bg-gray-100 dark:hover:bg-gray-850"
class="rounded-lg p-1 text-gray-400 transition-colors hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
aria-label={$i18n.t('Unarchive Chat')}
on:click={() => {
@ -350,7 +353,7 @@
</Tooltip>
<Tooltip content={$i18n.t('Delete Chat')}>
<button
class="rounded-sm p-1 hover:bg-gray-100 dark:hover:bg-gray-850"
class="rounded-lg p-1 text-gray-400 transition-colors hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
aria-label={$i18n.t('Delete Chat')}
on:click={() => {

View file

@ -8,6 +8,9 @@
import Switch from '$lib/components/common/Switch.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import UserSettingField from './UserSettingField.svelte';
import UserSettingRow from './UserSettingRow.svelte';
import UserSettingSection from './UserSettingSection.svelte';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
@ -35,6 +38,12 @@
// Audio speed control
let playbackRate = 1;
const inputClass =
'h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
const selectClass =
'h-7 w-fit min-w-28 rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-right text-xs text-gray-700 outline-hidden transition-colors focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:focus:border-blue-500';
const actionButtonClass =
'text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white';
const getVoices = async () => {
if (TTSEngine === 'browser-kokoro') {
@ -154,7 +163,7 @@
<form
id="tab-audio"
class="flex flex-col h-full justify-between space-y-3 text-sm"
class="flex flex-col h-full justify-between text-sm"
on:submit|preventDefault={async () => {
saveSettings({
audio: {
@ -177,57 +186,55 @@
>
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-4">{$i18n.t('Audio')}</h2>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5 space-y-3">
<div>
<div class="text-xs text-gray-400 dark:text-gray-600 mb-2">
{$i18n.t('STT Settings')}
</div>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5">
<UserSettingSection title={$i18n.t('STT Settings')} first>
{#if $config.audio.stt.engine !== 'web'}
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-normal">{$i18n.t('Speech-to-Text Engine')}</div>
<div class="flex items-center relative">
<select
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={STTEngine}
aria-label={$i18n.t('Speech-to-Text Engine')}
placeholder={$i18n.t('Select an engine')}
>
<option value="">{$i18n.t('Default')}</option>
<option value="web">{$i18n.t('Web API')}</option>
</select>
</div>
</div>
<UserSettingRow
label={$i18n.t('Speech-to-Text Engine')}
description={$i18n.t('Choose the engine used to transcribe voice input.')}
>
<select
class={selectClass}
bind:value={STTEngine}
aria-label={$i18n.t('Speech-to-Text Engine')}
placeholder={$i18n.t('Select an engine')}
>
<option value="">{$i18n.t('Default')}</option>
<option value="web">{$i18n.t('Web API')}</option>
</select>
</UserSettingRow>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-normal">{$i18n.t('Language')}</div>
<div class="flex items-center relative text-xs px-3">
<Tooltip
content={$i18n.t(
'The language of the input audio. Supplying the input language in ISO-639-1 (e.g. en) format will improve accuracy and latency. Leave blank to automatically detect the language.'
)}
placement="top"
>
<input
type="text"
bind:value={STTLanguage}
aria-label={$i18n.t('Speech-to-Text Language')}
placeholder={$i18n.t('e.g. en')}
class=" text-sm text-right bg-transparent dark:text-gray-300 outline-hidden"
/>
</Tooltip>
</div>
</div>
<UserSettingRow
label={$i18n.t('Language')}
description={$i18n.t(
'Set a speech recognition language or leave it blank to detect automatically.'
)}
>
<Tooltip
content={$i18n.t(
'The language of the input audio. Supplying the input language in ISO-639-1 (e.g. en) format will improve accuracy and latency. Leave blank to automatically detect the language.'
)}
placement="top"
>
<input
type="text"
bind:value={STTLanguage}
aria-label={$i18n.t('Speech-to-Text Language')}
placeholder={$i18n.t('e.g. en')}
class="h-7 w-24 rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-right text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500"
/>
</Tooltip>
</UserSettingRow>
{/if}
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-normal">
{$i18n.t('Instant Auto-Send After Voice Transcription')}
</div>
<UserSettingRow
label={$i18n.t('Instant Auto-Send After Voice Transcription')}
description={$i18n.t(
'Send transcribed voice input immediately after speech recognition finishes.'
)}
>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
class={actionButtonClass}
on:click={() => {
toggleSpeechAutoSend();
}}
@ -235,60 +242,53 @@
role="switch"
aria-checked={speechAutoSend}
>
{#if speechAutoSend === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
{speechAutoSend === true ? $i18n.t('On') : $i18n.t('Off')}
</button>
</div>
</div>
</UserSettingRow>
</UserSettingSection>
<div>
<div class="text-xs text-gray-400 dark:text-gray-600 mb-2">
{$i18n.t('TTS Settings')}
</div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-normal">{$i18n.t('Text-to-Speech Engine')}</div>
<div class="flex items-center relative">
<select
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={TTSEngine}
aria-label={$i18n.t('Text-to-Speech Engine')}
placeholder={$i18n.t('Select an engine')}
>
<option value="">{$i18n.t('Default')}</option>
<option value="browser-kokoro">{$i18n.t('Kokoro.js (Browser)')}</option>
</select>
</div>
</div>
<UserSettingSection title={$i18n.t('TTS Settings')}>
<UserSettingRow
label={$i18n.t('Text-to-Speech Engine')}
description={$i18n.t('Choose the engine used to read assistant responses aloud.')}
>
<select
class={selectClass}
bind:value={TTSEngine}
aria-label={$i18n.t('Text-to-Speech Engine')}
placeholder={$i18n.t('Select an engine')}
>
<option value="">{$i18n.t('Default')}</option>
<option value="browser-kokoro">{$i18n.t('Kokoro.js (Browser)')}</option>
</select>
</UserSettingRow>
{#if TTSEngine === 'browser-kokoro'}
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-normal">{$i18n.t('Kokoro.js Dtype')}</div>
<div class="flex items-center relative">
<select
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={TTSEngineConfig.dtype}
aria-label={$i18n.t('Kokoro.js Dtype')}
placeholder={$i18n.t('Select dtype')}
>
<option value="" disabled selected>{$i18n.t('Select dtype')}</option>
<option value="fp32">fp32</option>
<option value="fp16">fp16</option>
<option value="q8">q8</option>
<option value="q4">q4</option>
</select>
</div>
</div>
<UserSettingRow
label={$i18n.t('Kokoro.js Dtype')}
description={$i18n.t('Select the local model precision used by Kokoro.js.')}
>
<select
class={selectClass}
bind:value={TTSEngineConfig.dtype}
aria-label={$i18n.t('Kokoro.js Dtype')}
placeholder={$i18n.t('Select dtype')}
>
<option value="" disabled selected>{$i18n.t('Select dtype')}</option>
<option value="fp32">fp32</option>
<option value="fp16">fp16</option>
<option value="q8">q8</option>
<option value="q4">q4</option>
</select>
</UserSettingRow>
{/if}
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-normal">{$i18n.t('Auto-Playback Response')}</div>
<UserSettingRow
label={$i18n.t('Auto-Playback Response')}
description={$i18n.t('Play assistant responses aloud automatically.')}
>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
class={actionButtonClass}
on:click={() => {
toggleResponseAutoPlayback();
}}
@ -296,114 +296,38 @@
role="switch"
aria-checked={responseAutoPlayback}
>
{#if responseAutoPlayback === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
{responseAutoPlayback === true ? $i18n.t('On') : $i18n.t('Off')}
</button>
</div>
</UserSettingRow>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-normal">{$i18n.t('Speech Playback Speed')}</div>
<div class="flex items-center relative text-xs px-3">
<UserSettingRow
label={$i18n.t('Speech Playback Speed')}
description={$i18n.t('Adjust how quickly spoken responses are played.')}
>
<div class="relative flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-600">
<input
type="number"
min="0"
step="0.01"
bind:value={playbackRate}
aria-label={$i18n.t('Speech Playback Speed')}
class=" text-sm text-right bg-transparent dark:text-gray-300 outline-hidden"
class="h-7 w-16 rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-right text-xs text-gray-700 outline-hidden transition-colors focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:focus:border-blue-500"
/>
x
</div>
</div>
</div>
<hr class=" border-gray-100/20 dark:border-white/[0.025]" />
</UserSettingRow>
</UserSettingSection>
{#if TTSEngine === 'browser-kokoro'}
{#if TTSModel}
<div>
<div class="text-xs text-gray-400 dark:text-gray-600 mb-2.5">
{$i18n.t('Set Voice')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
list="voice-list"
class="w-full text-sm bg-transparent dark:text-gray-300 outline-hidden"
bind:value={voice}
aria-label={$i18n.t('Voice')}
placeholder={$i18n.t('Select a voice')}
/>
<datalist id="voice-list">
{#each voices as voice}
<option value={voice.id}>{voice.name}</option>
{/each}
</datalist>
</div>
</div>
</div>
{:else}
<div>
<div class=" mb-2.5 text-sm font-normal flex gap-2 items-center">
<Spinner className="size-4" />
<div class=" text-sm font-normal shimmer">
{$i18n.t('Loading Kokoro.js...')}
{TTSModelProgress && TTSModelProgress.status === 'progress'
? `(${Math.round(TTSModelProgress.progress * 10) / 10}%)`
: ''}
</div>
</div>
<div class="text-xs text-gray-500">
{$i18n.t('Please do not close the settings page while loading the model.')}
</div>
</div>
{/if}
{:else if $config.audio.tts.engine === ''}
<div>
<div class=" mb-2.5 text-sm font-normal">{$i18n.t('Set Voice')}</div>
<div class="flex w-full">
<div class="flex-1">
<select
class="w-full text-sm bg-transparent dark:text-gray-300 outline-hidden"
bind:value={voice}
aria-label={$i18n.t('Voice')}
>
<option value="" selected={voice !== ''}>{$i18n.t('Default')}</option>
{#each voices.filter((v) => nonLocalVoices || v.localService === true) as _voice}
<option
value={_voice.name}
class="bg-gray-100 dark:bg-gray-700"
selected={voice === _voice.name}>{_voice.name}</option
>
{/each}
</select>
</div>
</div>
<div class="flex items-center justify-between my-1.5">
<div class="text-xs">
{$i18n.t('Allow non-local voices')}
</div>
<div class="mt-1">
<Switch bind:state={nonLocalVoices} />
</div>
</div>
</div>
{:else if $config.audio.tts.engine !== ''}
<div>
<div class=" mb-2.5 text-sm font-normal">{$i18n.t('Set Voice')}</div>
<div class="flex w-full">
<div class="flex-1">
<UserSettingSection title={$i18n.t('Voice')}>
<UserSettingField
label={$i18n.t('Set Voice')}
description={$i18n.t('Choose the Kokoro.js voice used for speech output.')}
>
<input
list="voice-list"
class="w-full text-sm bg-transparent dark:text-gray-300 outline-hidden"
class={inputClass}
bind:value={voice}
aria-label={$i18n.t('Voice')}
placeholder={$i18n.t('Select a voice')}
@ -414,9 +338,71 @@
<option value={voice.id}>{voice.name}</option>
{/each}
</datalist>
</UserSettingField>
</UserSettingSection>
{:else}
<UserSettingSection title={$i18n.t('Voice')}>
<div class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<Spinner className="size-4" />
<div class="shimmer">
{$i18n.t('Loading Kokoro.js...')}
{TTSModelProgress && TTSModelProgress.status === 'progress'
? `(${Math.round(TTSModelProgress.progress * 10) / 10}%)`
: ''}
</div>
</div>
</div>
</div>
<div class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Please do not close the settings page while loading the model.')}
</div>
</UserSettingSection>
{/if}
{:else if $config.audio.tts.engine === ''}
<UserSettingSection title={$i18n.t('Voice')}>
<UserSettingField
label={$i18n.t('Set Voice')}
description={$i18n.t('Choose the browser voice used for speech output.')}
>
<select class={inputClass} bind:value={voice} aria-label={$i18n.t('Voice')}>
<option value="" selected={voice !== ''}>{$i18n.t('Default')}</option>
{#each voices.filter((v) => nonLocalVoices || v.localService === true) as _voice}
<option
value={_voice.name}
class="bg-gray-100 dark:bg-gray-700"
selected={voice === _voice.name}>{_voice.name}</option
>
{/each}
</select>
</UserSettingField>
<UserSettingRow
label={$i18n.t('Allow non-local voices')}
description={$i18n.t('Include voices that are not provided by a local speech service.')}
>
<Switch bind:state={nonLocalVoices} />
</UserSettingRow>
</UserSettingSection>
{:else if $config.audio.tts.engine !== ''}
<UserSettingSection title={$i18n.t('Voice')}>
<UserSettingField
label={$i18n.t('Set Voice')}
description={$i18n.t('Choose the configured text-to-speech service voice.')}
>
<input
list="voice-list"
class={inputClass}
bind:value={voice}
aria-label={$i18n.t('Voice')}
placeholder={$i18n.t('Select a voice')}
/>
<datalist id="voice-list">
{#each voices as voice}
<option value={voice.id}>{voice.name}</option>
{/each}
</datalist>
</UserSettingField>
</UserSettingSection>
{/if}
</div>

View file

@ -1,18 +1,15 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
import { getModels as _getModels } from '$lib/apis';
import { onMount, getContext } from 'svelte';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
import { models, settings, user } from '$lib/stores';
import { settings } from '$lib/stores';
import Switch from '$lib/components/common/Switch.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Connection from './Connections/Connection.svelte';
import UserSettingSection from './UserSettingSection.svelte';
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
@ -80,72 +77,59 @@
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5">
{#if config !== null}
<div class="">
<div class="pr-1.5">
<div class="">
<div class="flex justify-between items-center mb-0.5">
<div class="text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('Manage Direct Connections')}
</div>
<Tooltip content={$i18n.t(`Add Connection`)}>
<button
class="px-1"
aria-label={$i18n.t('Add Connection')}
on:click={() => {
showConnectionModal = true;
}}
type="button"
>
<Plus />
</button>
</Tooltip>
</div>
<div class="flex flex-col gap-1.5">
{#each config?.OPENAI_API_BASE_URLS ?? [] as url, idx}
<Connection
bind:url
bind:key={config.OPENAI_API_KEYS[idx]}
bind:config={config.OPENAI_API_CONFIGS[idx]}
onSubmit={() => {
updateHandler();
}}
onDelete={() => {
config.OPENAI_API_BASE_URLS = config.OPENAI_API_BASE_URLS.filter(
(url, urlIdx) => idx !== urlIdx
);
config.OPENAI_API_KEYS = config.OPENAI_API_KEYS.filter(
(key, keyIdx) => idx !== keyIdx
);
let newConfig = {};
config.OPENAI_API_BASE_URLS.forEach((url, newIdx) => {
newConfig[newIdx] =
config.OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
});
config.OPENAI_API_CONFIGS = newConfig;
}}
/>
{/each}
</div>
<UserSettingSection title={$i18n.t('Manage Direct Connections')} first>
<div class="flex items-center justify-between gap-2.5">
<div class="min-w-0 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Connect to your own OpenAI compatible API endpoints.')}
</div>
<div class="my-1.5">
<div
class="text-xs {($settings?.highContrastMode ?? false)
? 'text-gray-800 dark:text-gray-100'
: 'text-gray-500'}"
<Tooltip content={$i18n.t(`Add Connection`)}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition-colors hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
aria-label={$i18n.t('Add Connection')}
on:click={() => {
showConnectionModal = true;
}}
type="button"
>
{$i18n.t('Connect to your own OpenAI compatible API endpoints.')}
<br />
{$i18n.t(
'CORS must be properly configured by the provider to allow requests from Open WebUI.'
)}
</div>
</div>
<Plus />
</button>
</Tooltip>
</div>
</div>
<div class="flex flex-col gap-2">
{#each config?.OPENAI_API_BASE_URLS ?? [] as url, idx}
<Connection
bind:url
bind:key={config.OPENAI_API_KEYS[idx]}
bind:config={config.OPENAI_API_CONFIGS[idx]}
onSubmit={() => {
updateHandler();
}}
onDelete={() => {
config.OPENAI_API_BASE_URLS = config.OPENAI_API_BASE_URLS.filter(
(url, urlIdx) => idx !== urlIdx
);
config.OPENAI_API_KEYS = config.OPENAI_API_KEYS.filter(
(key, keyIdx) => idx !== keyIdx
);
let newConfig = {};
config.OPENAI_API_BASE_URLS.forEach((url, newIdx) => {
newConfig[newIdx] = config.OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1];
});
config.OPENAI_API_CONFIGS = newConfig;
}}
/>
{/each}
</div>
<div class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t(
'CORS must be properly configured by the provider to allow requests from Open WebUI.'
)}
</div>
</UserSettingSection>
{:else}
<div class="flex h-full justify-center">
<div class="my-auto">

View file

@ -1,11 +1,9 @@
<script lang="ts">
import { getContext, tick } from 'svelte';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { settings } from '$lib/stores';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
@ -19,9 +17,6 @@
export let config = {};
let showConfigModal = false;
const highContrastInputClass =
'rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
</script>
<AddConnectionModal
@ -45,7 +40,7 @@
}}
/>
<div class="flex w-full gap-2 items-center">
<div class="flex w-full items-center gap-3">
<Tooltip
className="w-full relative"
content={$i18n.t(`WebUI will make requests to "{{url}}/chat/completions"`, {
@ -61,7 +56,7 @@
<div class="flex w-full gap-2">
<div class="flex-1 relative">
<input
class={`w-full ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent outline-hidden'} ${pipeline ? 'pr-8' : ''}`}
class={`h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500 ${pipeline ? 'pr-8' : ''}`}
placeholder={$i18n.t('API Base URL')}
bind:value={url}
autocomplete="off"
@ -70,11 +65,11 @@
</div>
</Tooltip>
<div class="flex gap-1 items-center">
<div class="flex shrink-0 items-center gap-1">
<Tooltip content={$i18n.t('Configure')} className="self-start">
<button
aria-label={$i18n.t('Open modal to configure connection')}
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition-colors hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
on:click={() => {
showConfigModal = true;
}}

View file

@ -13,6 +13,8 @@
import SharedChatsModal from '$lib/components/layout/SharedChatsModal.svelte';
import FilesModal from '$lib/components/layout/FilesModal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import UserSettingRow from './UserSettingRow.svelte';
import UserSettingSection from './UserSettingSection.svelte';
const i18n = getContext('i18n');
@ -27,6 +29,8 @@
let showFilesModal = false;
let chatImportInputElement: HTMLInputElement;
const actionButtonClass =
'text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white';
$: if (importFiles) {
console.log(importFiles);
@ -137,7 +141,7 @@
{$i18n.t('Data Controls')}
</h2>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5 space-y-3">
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5">
<input
id="chat-import-input"
bind:this={chatImportInputElement}
@ -147,106 +151,102 @@
hidden
/>
<div>
<div class="text-xs text-gray-400 dark:text-gray-600 mb-2">{$i18n.t('Chats')}</div>
<UserSettingSection title={$i18n.t('Chats')} first>
{#if $user?.role === 'admin' || ($user.permissions?.chat?.import ?? true)}
<div>
<div class="py-0.5 flex w-full justify-between">
<div class="self-center text-xs">{$i18n.t('Import Chats')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
chatImportInputElement.click();
}}
type="button"
>
<span class="self-center">{$i18n.t('Import')}</span>
</button>
</div>
</div>
<UserSettingRow
label={$i18n.t('Import Chats')}
description={$i18n.t('Import chat history from a JSON export file.')}
>
<button
class={actionButtonClass}
on:click={() => {
chatImportInputElement.click();
}}
type="button"
>
{$i18n.t('Import')}
</button>
</UserSettingRow>
{/if}
{#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)}
<div>
<div class="py-0.5 flex w-full justify-between">
<div class="self-center text-xs">{$i18n.t('Export Chats')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
exportChats();
}}
type="button"
>
<span class="self-center">{$i18n.t('Export')}</span>
</button>
</div>
</div>
<UserSettingRow
label={$i18n.t('Export Chats')}
description={$i18n.t('Download your chat history as a JSON export.')}
>
<button
class={actionButtonClass}
on:click={() => {
exportChats();
}}
type="button"
>
{$i18n.t('Export')}
</button>
</UserSettingRow>
{/if}
<div>
<div class="py-0.5 flex w-full justify-between">
<div class="self-center text-xs">{$i18n.t('Shared Chats')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
showSharedChatsModal = true;
}}
type="button"
>
<span class="self-center">{$i18n.t('Manage')}</span>
</button>
</div>
</div>
<UserSettingRow
label={$i18n.t('Shared Chats')}
description={$i18n.t('Review and manage chats you have shared.')}
>
<button
class={actionButtonClass}
on:click={() => {
showSharedChatsModal = true;
}}
type="button"
>
{$i18n.t('Manage')}
</button>
</UserSettingRow>
<div>
<div class="py-0.5 flex w-full justify-between">
<div class="self-center text-xs">{$i18n.t('Archive All Chats')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
showArchiveConfirmDialog = true;
}}
type="button"
>
<span class="self-center">{$i18n.t('Archive All')}</span>
</button>
</div>
</div>
<UserSettingRow
label={$i18n.t('Archive All Chats')}
description={$i18n.t('Move every chat into the archive after confirmation.')}
>
<button
class={actionButtonClass}
on:click={() => {
showArchiveConfirmDialog = true;
}}
type="button"
>
{$i18n.t('Archive All')}
</button>
</UserSettingRow>
<div>
<div class="py-0.5 flex w-full justify-between">
<div class="self-center text-xs">{$i18n.t('Delete All Chats')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
showDeleteConfirmDialog = true;
}}
type="button"
>
<span class="self-center">{$i18n.t('Delete All')}</span>
</button>
</div>
</div>
</div>
<UserSettingRow
label={$i18n.t('Delete All Chats')}
description={$i18n.t('Permanently delete every chat after confirmation.')}
>
<button
class={actionButtonClass}
on:click={() => {
showDeleteConfirmDialog = true;
}}
type="button"
>
{$i18n.t('Delete All')}
</button>
</UserSettingRow>
</UserSettingSection>
<div>
<div class="text-xs text-gray-400 dark:text-gray-600 mb-2">{$i18n.t('Files')}</div>
<div>
<div class="py-0.5 flex w-full justify-between">
<div class="self-center text-xs">{$i18n.t('Manage Files')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
showFilesModal = true;
}}
type="button"
>
<span class="self-center">{$i18n.t('Manage')}</span>
</button>
</div>
</div>
</div>
<UserSettingSection title={$i18n.t('Files')}>
<UserSettingRow
label={$i18n.t('Manage Files')}
description={$i18n.t('Open the file manager for uploaded files.')}
>
<button
class={actionButtonClass}
on:click={() => {
showFilesModal = true;
}}
type="button"
>
{$i18n.t('Manage')}
</button>
</UserSettingRow>
</UserSettingSection>
</div>
</div>

View file

@ -10,6 +10,9 @@
import AdvancedParams from './Advanced/AdvancedParams.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import UserSettingField from './UserSettingField.svelte';
import UserSettingRow from './UserSettingRow.svelte';
import UserSettingSection from './UserSettingSection.svelte';
export let saveSettings: Function;
export let getModels: Function;
@ -25,11 +28,11 @@
let showAdvanced = false;
const systemPromptTextareaClass =
'w-full resize-vertical rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
const compactSystemPromptTextareaClass =
'w-full resize-vertical appearance-none bg-transparent text-sm outline-hidden focus:bg-transparent disabled:bg-transparent dark:text-gray-300';
const highContrastSelectClass =
'rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-gray-700 outline-hidden transition-colors focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:focus:border-blue-500';
'w-full resize-y rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
const selectClass =
'h-7 w-fit min-w-28 rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-right text-xs text-gray-700 outline-hidden transition-colors focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:focus:border-blue-500';
const actionButtonClass =
'text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white';
const toggleNotification = async () => {
const permission = await Notification.requestPermission();
@ -216,59 +219,49 @@
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-4">{$i18n.t('General')}</h2>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5">
<div class="">
<div class="text-xs text-gray-400 dark:text-gray-600 mb-2">
{$i18n.t('WebUI Settings')}
</div>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-normal">{$i18n.t('Theme')}</div>
<div class="flex items-center relative">
<select
class={`w-fit pr-8 text-xs text-right ${$settings.highContrastMode ? highContrastSelectClass : 'rounded-sm bg-transparent px-2 py-2 outline-hidden'}`}
bind:value={selectedTheme}
placeholder={$i18n.t('Select a theme')}
on:change={() => themeChangeHandler(selectedTheme)}
>
<option value="system">⚙️ {$i18n.t('System')}</option>
<option value="dark">🌑 {$i18n.t('Dark')}</option>
<option value="oled-dark">🌃 {$i18n.t('OLED Dark')}</option>
<option value="light">☀️ {$i18n.t('Light')}</option>
{#if $config?.features?.enable_easter_eggs}
<option value="her">🌷 Her</option>
{/if}
</select>
</div>
</div>
<div class=" flex w-full justify-between">
<div class=" self-center text-xs font-normal">{$i18n.t('Language')}</div>
<div class="flex items-center relative">
<select
class={`w-fit pr-8 text-xs text-right ${$settings.highContrastMode ? highContrastSelectClass : 'rounded-sm bg-transparent px-2 py-2 outline-hidden'}`}
bind:value={lang}
placeholder={$i18n.t('Select a language')}
on:change={(e) => {
changeLanguage(lang);
}}
>
{#each languages as language}
<option value={language['code']}>{language['title']}</option>
{/each}
</select>
</div>
</div>
{#if $i18n.language === 'en-US' && !($config?.license_metadata ?? false)}
<div
class="mb-2 text-xs {($settings?.highContrastMode ?? false)
? 'text-gray-800 dark:text-gray-100'
: 'text-gray-400 dark:text-gray-500'}"
<UserSettingSection title={$i18n.t('WebUI Settings')} first>
<UserSettingRow
label={$i18n.t('Theme')}
description={$i18n.t('Choose the color theme used by the interface.')}
>
<select
class={selectClass}
bind:value={selectedTheme}
placeholder={$i18n.t('Select a theme')}
on:change={() => themeChangeHandler(selectedTheme)}
>
<option value="system">⚙️ {$i18n.t('System')}</option>
<option value="dark">🌑 {$i18n.t('Dark')}</option>
<option value="oled-dark">🌃 {$i18n.t('OLED Dark')}</option>
<option value="light">☀️ {$i18n.t('Light')}</option>
{#if $config?.features?.enable_easter_eggs}
<option value="her">🌷 Her</option>
{/if}
</select>
</UserSettingRow>
<UserSettingRow
label={$i18n.t('Language')}
description={$i18n.t('Choose the language used for interface text.')}
>
<select
class={selectClass}
bind:value={lang}
placeholder={$i18n.t('Select a language')}
on:change={(e) => {
changeLanguage(lang);
}}
>
{#each languages as language}
<option value={language['code']}>{language['title']}</option>
{/each}
</select>
</UserSettingRow>
{#if $i18n.language === 'en-US' && !($config?.license_metadata ?? false)}
<div class="-mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
Couldn't find your language?
<a
class="font-normal underline {($settings?.highContrastMode ?? false)
? 'text-gray-700 dark:text-gray-200'
: 'text-gray-300'}"
class="font-normal underline text-gray-400 dark:text-gray-600"
href="https://github.com/open-webui/open-webui/blob/main/docs/CONTRIBUTING.md#-translations-and-internationalization"
target="_blank"
>
@ -277,69 +270,55 @@
</div>
{/if}
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-normal">{$i18n.t('Notifications')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
toggleNotification();
}}
type="button"
role="switch"
aria-checked={notificationEnabled}
>
{#if notificationEnabled === true}
<span class="ml-2 self-center">{$i18n.t('On')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Off')}</span>
{/if}
</button>
</div>
</div>
</div>
<UserSettingRow
label={$i18n.t('Notifications')}
description={$i18n.t('Allow browser notifications for completed responses.')}
>
<button
class={actionButtonClass}
on:click={() => {
toggleNotification();
}}
type="button"
role="switch"
aria-checked={notificationEnabled}
>
{notificationEnabled === true ? $i18n.t('On') : $i18n.t('Off')}
</button>
</UserSettingRow>
</UserSettingSection>
{#if $user?.role === 'admin' || (($user?.permissions.chat?.controls ?? true) && ($user?.permissions.chat?.system_prompt ?? true))}
<hr class="border-gray-100/20 dark:border-white/[0.025] my-3" />
<div>
<div class="text-xs text-gray-400 dark:text-gray-600 my-2.5">
{$i18n.t('System Prompt')}
</div>
<Textarea
bind:value={system}
className={($settings?.highContrastMode ?? false)
? systemPromptTextareaClass
: compactSystemPromptTextareaClass}
rows="4"
placeholder={$i18n.t('Enter system prompt here')}
/>
</div>
<UserSettingSection title={$i18n.t('System Prompt')}>
<UserSettingField description={$i18n.t('Set the default system prompt for new chats.')}>
<Textarea
bind:value={system}
className={systemPromptTextareaClass}
rows="4"
placeholder={$i18n.t('Enter system prompt here')}
/>
</UserSettingField>
</UserSettingSection>
{/if}
{#if $user?.role === 'admin' || (($user?.permissions.chat?.controls ?? true) && ($user?.permissions.chat?.params ?? true))}
<div class="mt-2 space-y-3 pr-1.5">
<div class="flex justify-between items-center text-sm">
<div class="text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('Advanced Parameters')}
</div>
<UserSettingSection title={$i18n.t('Advanced Parameters')}>
<UserSettingRow description={$i18n.t('Show or hide custom generation parameters.')}>
<span slot="label">{$i18n.t('Model parameters')}</span>
<button
class=" text-xs font-normal {($settings?.highContrastMode ?? false)
? 'text-gray-800 dark:text-gray-100'
: 'text-gray-400 dark:text-gray-500'}"
class={actionButtonClass}
type="button"
aria-expanded={showAdvanced}
on:click={() => {
showAdvanced = !showAdvanced;
}}>{showAdvanced ? $i18n.t('Hide') : $i18n.t('Show')}</button
>
</div>
</UserSettingRow>
{#if showAdvanced}
<AdvancedParams admin={$user?.role === 'admin'} custom={true} bind:params />
{/if}
</div>
</UserSettingSection>
{/if}
</div>

View file

@ -1,10 +1,11 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { onMount, getContext } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import { getToolServersData } from '$lib/apis';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
const i18n = getContext<Writable<i18nType>>('i18n');
import { settings, toolServers, terminalServers } from '$lib/stores';
@ -13,17 +14,31 @@
import Plus from '$lib/components/icons/Plus.svelte';
import Connection from './Tools/Connection.svelte';
import Terminals from './Integrations/Terminals.svelte';
import UserSettingSection from './UserSettingSection.svelte';
import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
export let saveSettings: Function;
type TerminalServerConfig = {
url: string;
key?: string;
name?: string;
enabled: boolean;
auth_type?: string;
path?: string;
[key: string]: any;
};
let servers = null;
let terminalServerConfigs: { url: string; key: string; name?: string; enabled: boolean }[] = [];
type ToolServerConnection = any;
export let saveSettings: (settings: any) => void | Promise<void>;
let servers: ToolServerConnection[] | null = null;
let terminalServerConfigs: TerminalServerConfig[] = [];
let showConnectionModal = false;
const helpTextClass = 'text-[0.6875rem] text-gray-400 dark:text-gray-600';
const addConnectionHandler = async (server) => {
servers = [...servers, server];
const addConnectionHandler = async (server: ToolServerConnection) => {
servers = [...(servers ?? []), server];
await updateHandler();
};
@ -34,7 +49,7 @@
});
let toolServersData = await getToolServersData($settings?.toolServers ?? []);
toolServersData = toolServersData.filter((data) => {
toolServersData = toolServersData.filter((data: any) => {
if (data.error) {
toast.error(
$i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, { URL: data?.url })
@ -43,10 +58,10 @@
}
return true;
});
toolServers.set(toolServersData);
toolServers.set(toolServersData as any);
// Refresh terminal servers store (preserve system terminals)
const existingSystemTerminals = ($terminalServers ?? []).filter((t) => t.id);
const existingSystemTerminals = (($terminalServers ?? []) as any[]).filter((t) => t.id);
const activeTerminals = terminalServerConfigs.filter((s) => s.enabled);
if (activeTerminals.length > 0) {
let terminalServersData = await getToolServersData(
@ -58,16 +73,16 @@
config: { enable: true }
}))
);
terminalServersData = terminalServersData.filter((data) => data && !data.error);
terminalServers.set([...terminalServersData, ...existingSystemTerminals]);
terminalServersData = terminalServersData.filter((data: any) => data && !data.error);
terminalServers.set([...terminalServersData, ...existingSystemTerminals] as any);
} else {
terminalServers.set(existingSystemTerminals);
terminalServers.set(existingSystemTerminals as any);
}
};
onMount(async () => {
servers = $settings?.toolServers ?? [];
terminalServerConfigs = $settings?.terminalServers ?? [];
terminalServerConfigs = ($settings as any)?.terminalServers ?? [];
});
</script>
@ -84,86 +99,75 @@
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5">
{#if servers !== null}
<div>
<div class="pr-1.5">
<div class="">
<div class="flex justify-between items-center mb-0.5">
<div class="text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('Manage Tool Servers')}
</div>
<Tooltip content={$i18n.t('Add Connection')}>
<button
aria-label={$i18n.t('Add Connection')}
class="px-1"
on:click={() => (showConnectionModal = true)}
type="button"
>
<Plus />
</button>
</Tooltip>
<UserSettingSection title={$i18n.t('Tools')} first>
<div>
<div class="mb-2 flex items-center justify-between">
<div class="text-xs text-gray-600 dark:text-gray-400">
{$i18n.t('External Tool Servers')}
</div>
<div class="flex flex-col gap-1.5">
{#each servers as server, idx}
<Connection
bind:connection={server}
direct
onSubmit={() => updateHandler()}
onDelete={() => {
servers = servers.filter((_, i) => i !== idx);
updateHandler();
}}
/>
{/each}
</div>
<Tooltip content={$i18n.t('Add Connection')}>
<button
aria-label={$i18n.t('Add Connection')}
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-black/5 hover:text-gray-900 dark:text-gray-600 dark:hover:bg-white/5 dark:hover:text-white"
on:click={() => (showConnectionModal = true)}
type="button"
>
<Plus />
</button>
</Tooltip>
</div>
<div class="my-1.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Connect to your own OpenAPI compatible external tool servers.')}
<br />
{$i18n.t(
'CORS must be properly configured by the provider to allow requests from Open WebUI.'
)}
</div>
<div class="flex flex-col gap-1">
{#each servers as server, idx}
<Connection
bind:connection={server}
direct
onSubmit={() => updateHandler()}
onDelete={() => {
servers = (servers ?? []).filter((_, i) => i !== idx);
updateHandler();
}}
/>
{/each}
</div>
<div class="text-xs text-gray-600 dark:text-gray-300 mb-2">
{#if (servers ?? []).length === 0}
<div class={helpTextClass}>
{$i18n.t('No tool server connections configured.')}
</div>
{/if}
<div class="mt-1 {helpTextClass}">
{$i18n.t('Connect to your own OpenAPI compatible external tool servers.')}
</div>
<div class={helpTextClass}>
{$i18n.t(
'CORS must be properly configured by the provider to allow requests from Open WebUI.'
)}
<a
class="underline"
class="ml-1 text-gray-500 underline hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300"
href="https://github.com/open-webui/openapi-servers"
target="_blank">{$i18n.t('Learn more about OpenAPI tool servers.')}</a
>
</div>
</div>
</UserSettingSection>
<hr class="border-gray-100/50 dark:border-gray-850/50 my-4" />
<UserSettingSection title={$i18n.t('Terminal')}>
<Terminals bind:servers={terminalServerConfigs} onChange={() => updateHandler()} />
<div class="pr-1.5">
<Terminals bind:servers={terminalServerConfigs} onChange={() => updateHandler()} />
<div class="mt-1.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t(
'Connect to Open Terminal instances to browse files and use them as always-on tools. Only one can be active at a time.'
)}
</div>
<div class="text-xs text-gray-600 dark:text-gray-300 mt-1">
<a
class="underline"
href="https://github.com/open-webui/open-terminal"
target="_blank">{$i18n.t('Learn more about Open Terminal')}</a
>
</div>
</div>
<div class="mt-1 {helpTextClass}">
{$i18n.t(
'Connect to Open Terminal instances to browse files and use them as always-on tools. Only one can be active at a time.'
)}
</div>
</div>
<a
class="mt-0.5 block text-[0.6875rem] text-gray-500 underline hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300"
href="https://github.com/open-webui/open-terminal"
target="_blank">{$i18n.t('Learn more about Open Terminal')}</a
>
</UserSettingSection>
{:else}
<div class="flex h-full justify-center">
<div class="my-auto">

View file

@ -1,18 +1,30 @@
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
const i18n = getContext<Writable<i18nType>>('i18n');
import Plus from '$lib/components/icons/Plus.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Connection from './Terminals/Connection.svelte';
import AddTerminalServerModal from '$lib/components/AddTerminalServerModal.svelte';
export let servers = [];
export let onChange: (servers: typeof servers) => void = () => {};
type TerminalServerConfig = {
url: string;
key?: string;
name?: string;
path?: string;
enabled: boolean;
[key: string]: any;
};
export let servers: TerminalServerConfig[] = [];
export let onChange: (servers: TerminalServerConfig[]) => void = () => {};
let showAddModal = false;
const addServer = (server: (typeof servers)[0]) => {
const addServer = (server: TerminalServerConfig) => {
servers = [...servers, server];
onChange(servers);
};
@ -27,7 +39,7 @@
onChange(servers);
};
const updateServer = (idx: number, updated: (typeof servers)[0]) => {
const updateServer = (idx: number, updated: TerminalServerConfig) => {
servers = servers.map((s, i) => (i === idx ? updated : s));
onChange(servers);
};
@ -38,16 +50,20 @@
};
</script>
<AddTerminalServerModal direct bind:show={showAddModal} onSubmit={(server) => addServer(server)} />
<AddTerminalServerModal
direct
bind:show={showAddModal}
onSubmit={(server: TerminalServerConfig) => addServer(server)}
/>
<div>
<div class="flex justify-between items-center mb-1">
<div class="flex justify-between items-center mb-2">
<div class="flex items-center gap-2">
<div class="font-normal">{$i18n.t('Open Terminal')}</div>
<div class="text-xs text-gray-600 dark:text-gray-400">{$i18n.t('Open Terminal')}</div>
</div>
<Tooltip content={$i18n.t('Add Connection')}>
<button
class="px-1"
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-black/5 hover:text-gray-900 dark:text-gray-600 dark:hover:bg-white/5 dark:hover:text-white"
on:click={() => (showAddModal = true)}
type="button"
aria-label={$i18n.t('Add Connection')}
@ -70,16 +86,8 @@
</div>
{#if servers.length === 0}
<div class="text-xs text-gray-400 dark:text-gray-500">
<div class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('No terminal connections configured.')}
<a
href="https://github.com/open-webui/open-terminal"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-gray-700 dark:hover:text-gray-200"
>
{$i18n.t('Learn more')}
</a>
</div>
{/if}
</div>

View file

@ -1,6 +1,9 @@
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
const i18n = getContext<Writable<i18nType>>('i18n');
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
@ -8,8 +11,23 @@
import AddTerminalServerModal from '$lib/components/AddTerminalServerModal.svelte';
import Cloud from '$lib/components/icons/Cloud.svelte';
export let connection = { url: '', key: '', name: '', path: '/openapi.json', enabled: false };
export let onSubmit: (c: typeof connection) => void = () => {};
type TerminalServerConfig = {
url: string;
key?: string;
name?: string;
path?: string;
enabled: boolean;
[key: string]: any;
};
export let connection: TerminalServerConfig = {
url: '',
key: '',
name: '',
path: '/openapi.json',
enabled: false
};
export let onSubmit: (c: TerminalServerConfig) => void = () => {};
export let onDelete: () => void = () => {};
export let onEnable: () => void = () => {};
export let onDisable: () => void = () => {};
@ -26,7 +44,7 @@
onDelete();
showConfigModal = false;
}}
onSubmit={(c) => {
onSubmit={(c: TerminalServerConfig) => {
connection = c;
onSubmit(c);
}}
@ -42,7 +60,7 @@
<Cloud className="size-4" strokeWidth="1.5" />
</Tooltip>
<div class="outline-hidden w-full bg-transparent text-sm">
<div class="outline-hidden w-full bg-transparent text-xs text-gray-700 dark:text-gray-300">
{connection.name || connection.url || $i18n.t('New Terminal')}
</div>
</div>
@ -52,7 +70,7 @@
<div class="flex gap-1 items-center">
<Tooltip content={$i18n.t('Configure')}>
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
class="self-center p-1 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition"
on:click={() => {
showConfigModal = true;
}}

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@
import Textarea from '$lib/components/common/Textarea.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { settings } from '$lib/stores';
export let show = false;
export let onSave = () => {};
@ -27,9 +28,9 @@
onMount(() => {});
</script>
<Modal size="sm" bind:show>
<Modal size="sm" bind:show className="bg-white dark:bg-gray-900 rounded-4xl">
<div>
<div class=" flex justify-between dark:text-gray-100 px-4 pt-3 pb-1">
<div class=" flex justify-between text-gray-900 dark:text-white px-4 pt-3 pb-1">
<h1 class="text-sm font-medium self-center">
{$i18n.t('Quick Actions')}
</h1>
@ -44,7 +45,9 @@
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div
class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 text-gray-600 dark:text-gray-400"
>
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full px-1"
@ -57,7 +60,7 @@
<div class="text-xs flex items-center justify-between mb-2">
<div class="font-normal">{$i18n.t('Actions')}</div>
<div class="flex items-center gap-2 text-gray-700 dark:text-gray-300">
<div class="flex items-center gap-2 text-gray-500 dark:text-gray-500">
<button
type="button"
on:click={() => {

View file

@ -10,6 +10,7 @@
import Textarea from '$lib/components/common/Textarea.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { settings } from '$lib/stores';
export let show = false;
export let size = null;
@ -23,9 +24,9 @@
onMount(() => {});
</script>
<Modal size="sm" bind:show>
<Modal size="sm" bind:show className="bg-white dark:bg-gray-900 rounded-4xl">
<div>
<div class=" flex justify-between dark:text-gray-100 px-4 pt-3 pb-1">
<div class=" flex justify-between text-gray-900 dark:text-white px-4 pt-3 pb-1">
<h1 class="text-sm font-medium self-center">
{$i18n.t('Manage')}
</h1>
@ -40,7 +41,9 @@
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div
class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 text-gray-600 dark:text-gray-400"
>
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full px-1"
@ -52,7 +55,10 @@
<div>
<div>
<div class=" py-0.5 flex flex-col w-full text-sm">
<div id="image-compression-size-label" class=" text-xs mb-2">
<div
id="image-compression-size-label"
class="mb-2 text-xs text-gray-600 dark:text-gray-400"
>
{$i18n.t('Image Max Compression Size')}
</div>
@ -65,7 +71,7 @@
id="image-comp-width"
bind:value={size.width}
type="number"
class="w-full bg-transparent outline-hidden text-center"
class="h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-center text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500"
min="0"
placeholder={$i18n.t('Width')}
/>
@ -83,7 +89,7 @@
id="image-comp-height"
bind:value={size.height}
type="number"
class="w-full bg-transparent outline-hidden text-center"
class="h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-center text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500"
min="0"
placeholder={$i18n.t('Height')}
/>

View file

@ -2,18 +2,27 @@
import Switch from '$lib/components/common/Switch.svelte';
import { config, settings } from '$lib/stores';
import { createEventDispatcher, onMount, getContext } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import MemoryModal from './Personalization/MemoryModal.svelte';
import { deleteMemoriesByUserId, deleteMemoryById, getMemories } from '$lib/apis/memories';
import { toast } from 'svelte-sonner';
import UserSettingRow from './UserSettingRow.svelte';
import UserSettingSection from './UserSettingSection.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Trash from '$lib/components/icons/Trash.svelte';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
const i18n = getContext<Writable<i18nType>>('i18n');
export let saveSettings: Function;
export let saveSettings: (settings: any) => void | Promise<void>;
// Addons
let enableMemory = false;
@ -25,6 +34,10 @@
let showClearConfirmDialog = false;
let showDeleteConfirm = false;
let query = '';
const inputClass =
'h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
const actionButtonClass =
'shrink-0 text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white';
type Memory = {
id: string;
@ -90,7 +103,7 @@
<form
id="tab-personalization"
class="flex flex-col h-full justify-between space-y-3 text-sm"
class="flex flex-col h-full justify-between text-sm"
on:submit|preventDefault={() => {
dispatch('save');
}}
@ -99,148 +112,158 @@
{$i18n.t('Personalization')}
</h2>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5 py-1">
<div>
<div class="flex items-center justify-between mb-1">
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5">
<UserSettingSection title={$i18n.t('Memory')} first>
<UserSettingRow
description={$i18n
.t(
"You can personalize your interactions with LLMs by adding memories through the 'Manage' button below, making them more helpful and tailored to you."
)
.replace($i18n.t('Manage'), $i18n.t('Add Memory'))}
>
<Tooltip
slot="label"
content={$i18n.t(
'This is an experimental feature, it may not function as expected and is subject to change at any time.'
)}
>
<div class="flex items-center gap-2 text-xs text-gray-400 dark:text-gray-600">
<div class="flex items-center gap-2">
{$i18n.t('Memory')}
<span
class="inline-flex items-center text-[0.625rem] font-normal uppercase leading-none text-gray-400 dark:text-gray-600"
<span class="text-[0.625rem] uppercase text-gray-400 dark:text-gray-600"
>{$i18n.t('Experimental')}</span
>
</div>
</Tooltip>
<div class="">
<Switch
bind:state={enableMemory}
on:change={async () => {
saveSettings({ memory: enableMemory });
}}
/>
</div>
</div>
<p class="-mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n
.t(
"You can personalize your interactions with LLMs by adding memories through the 'Manage' button below, making them more helpful and tailored to you."
)
.replace($i18n.t('Manage'), $i18n.t('Add Memory'))}
</p>
</div>
<Switch
bind:state={enableMemory}
on:change={async () => {
saveSettings({ memory: enableMemory });
}}
/>
</UserSettingRow>
{#if enableMemory}
<div class="mt-5">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<h3 class="text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('Memory')}
</h3>
{#if !loadingMemories}
<div class="text-xs text-gray-400 dark:text-gray-600">{memories.length}</div>
{/if}
</div>
<button
type="button"
class="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={() => {
selectedMemory = null;
showMemoryModal = true;
}}
>
{$i18n.t('Add Memory')}
</button>
</div>
{#if loadingMemories}
<div class="w-full flex justify-center items-center min-h-16">
<Spinner className="size-4" />
</div>
{:else}
{#if memories.length > 0}
<div class="flex items-center gap-2 mb-3">
<input
class="w-full text-xs py-1 bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
bind:value={query}
placeholder={$i18n.t('Search Memories')}
maxlength="500"
/>
{#if query}
<button
type="button"
class="shrink-0 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
on:click={() => {
query = '';
}}
>
{$i18n.t('Clear')}
</button>
{#if enableMemory}
<div>
<div class="mb-2 flex items-center justify-between">
<div class="text-xs text-gray-600 dark:text-gray-400">
{$i18n.t('Saved Memories')}
{#if !loadingMemories}
<span class="ml-1 text-gray-400 dark:text-gray-600">{memories.length}</span>
{/if}
</div>
{/if}
{#if sortedMemories.length === 0}
<div class="text-xs text-gray-500 dark:text-gray-400 min-h-16 flex items-center">
{#if memories.length === 0}
{$i18n.t('Memories accessible by LLMs will be shown here.')}
{:else}
{$i18n.t('No results found')}
{/if}
<Dropdown align="end">
<Tooltip content={$i18n.t('Actions')}>
<button
class="flex h-7 items-center gap-1.5 rounded-lg bg-transparent px-1.5 text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white"
type="button"
>
<span>{$i18n.t('Actions')}</span>
<ChevronDown className="size-3" strokeWidth="2.5" />
</button>
</Tooltip>
<div slot="content">
<DropdownMenu className="w-[170px] shadow-sm">
<button
class="flex h-[1.6875rem] w-full cursor-pointer select-none items-center gap-2 rounded-lg bg-transparent px-2 text-xs hover:text-gray-900 disabled:cursor-default disabled:opacity-30 dark:hover:text-gray-100"
disabled={loadingMemories}
type="button"
on:click={() => {
selectedMemory = null;
showMemoryModal = true;
}}
>
<Plus className="size-3.5 shrink-0" strokeWidth="1.5" />
<div class="min-w-0 flex-1 truncate text-left">{$i18n.t('Add Memory')}</div>
</button>
<button
class="flex h-[1.6875rem] w-full cursor-pointer select-none items-center gap-2 rounded-lg bg-transparent px-2 text-xs hover:text-gray-900 disabled:cursor-default disabled:opacity-30 dark:hover:text-gray-100"
disabled={loadingMemories || memories.length === 0}
type="button"
on:click={() => {
showClearConfirmDialog = true;
}}
>
<Trash className="size-3.5 shrink-0" strokeWidth="1.5" />
<div class="min-w-0 flex-1 truncate text-left">{$i18n.t('Clear memory')}</div>
</button>
</DropdownMenu>
</div>
</Dropdown>
</div>
{#if loadingMemories}
<div class="flex min-h-16 w-full items-center justify-center">
<Spinner className="size-4" />
</div>
{:else}
<div class="flex flex-col gap-2.5">
{#each sortedMemories as memory (memory.id)}
<div class="flex items-start justify-between gap-3">
<div
class="min-w-0 whitespace-pre-wrap break-words text-xs text-gray-600 dark:text-gray-400"
>
{memory.content}
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
on:click={() => editMemory(memory)}
>
{$i18n.t('Edit')}
</button>
<button
type="button"
class="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
on:click={() => confirmDeleteMemory(memory)}
>
{$i18n.t('Remove')}
</button>
</div>
</div>
{/each}
</div>
{/if}
{#if memories.length > 0}
<div class="mb-2 flex items-center gap-2">
<input
class={inputClass}
bind:value={query}
placeholder={$i18n.t('Search Memories')}
maxlength="500"
/>
<button
type="button"
class="mt-3 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:underline transition"
on:click={() => {
if (memories.length > 0) {
showClearConfirmDialog = true;
} else {
toast.error($i18n.t('No memories to clear'));
}
}}
>
{$i18n.t('Clear memory')}
</button>
{/if}
</div>
{/if}
{#if query}
<button
type="button"
class={actionButtonClass}
on:click={() => {
query = '';
}}
>
{$i18n.t('Clear')}
</button>
{/if}
</div>
{/if}
{#if sortedMemories.length === 0}
<div class="min-h-16 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{#if memories.length === 0}
{$i18n.t('Memories accessible by LLMs will be shown here.')}
{:else}
{$i18n.t('No results found')}
{/if}
</div>
{:else}
<div class="flex flex-col gap-2">
{#each sortedMemories as memory (memory.id)}
<div class="flex items-start justify-between gap-3">
<div
class="min-w-0 whitespace-pre-wrap break-words text-xs text-gray-700 dark:text-gray-300"
>
{memory.content}
</div>
<div class="flex shrink-0 items-center justify-end gap-2">
<button
type="button"
class="{actionButtonClass} hover:underline"
on:click={() => editMemory(memory)}
>
{$i18n.t('Edit')}
</button>
<button
type="button"
class="{actionButtonClass} hover:underline"
on:click={() => confirmDeleteMemory(memory)}
>
{$i18n.t('Remove')}
</button>
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
{/if}
</UserSettingSection>
</div>
<div class="shrink-0 flex justify-end text-sm font-normal">
@ -287,7 +310,7 @@
<div class="text-sm text-gray-500 flex-1">
{$i18n.t('Are you sure you want to delete this memory? This action cannot be undone.')}
<div
class="mt-2 bg-gray-50 dark:bg-gray-900 p-3 rounded-xl border border-gray-100 dark:border-gray-800 text-black dark:text-white whitespace-pre-wrap break-words max-h-32 overflow-y-auto"
class="mt-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words rounded-lg border border-gray-100/50 bg-gray-50/40 p-2 text-xs text-gray-600 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-400"
>
{selectedMemory?.content}
</div>
@ -296,7 +319,7 @@
<MemoryModal
bind:show={showMemoryModal}
memory={selectedMemory}
memory={selectedMemory as any}
on:save={async () => {
await loadMemories();
}}

View file

@ -7,6 +7,7 @@
import Modal from '$lib/components/common/Modal.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import { settings } from '$lib/stores';
const dispatch = createEventDispatcher();
@ -19,6 +20,12 @@
let content = '';
let type = 'user';
let path = '';
const inputClass =
'h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
const textareaClass =
'w-full resize-y rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
const actionButtonClass =
'text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white';
$: edit = !!memory?.id;
$: if (show) {
@ -51,9 +58,9 @@
};
</script>
<Modal bind:show size="sm">
<Modal bind:show size="sm" className="bg-white dark:bg-gray-900 rounded-4xl">
<div>
<div class=" flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
<div class=" flex justify-between text-gray-900 dark:text-white px-4 pt-3 pb-1">
<div class=" text-sm font-medium self-center">
{#if edit}
{$i18n.t('Edit Memory')}
@ -69,16 +76,18 @@
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
<div
class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 text-gray-600 dark:text-gray-400"
>
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form class="flex flex-col w-full" on:submit|preventDefault={submitHandler}>
<div class="px-1">
<div class="flex w-full justify-between items-center mb-1.5">
<div class="text-xs text-gray-500">{$i18n.t('Type')}</div>
<div class="text-xs text-gray-600 dark:text-gray-400">{$i18n.t('Type')}</div>
<button
type="button"
class="text-xs text-gray-700 dark:text-gray-300"
class={actionButtonClass}
on:click={() => {
type = type === 'user' ? 'context' : 'user';
}}
@ -93,16 +102,15 @@
<textarea
bind:value={content}
class="bg-transparent w-full text-sm outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
class={textareaClass}
rows="6"
style="resize: vertical;"
placeholder={type === 'user'
? $i18n.t('Add a preference, fact, or instruction about you')
: $i18n.t('Add durable context for future chats')}
/>
<div class="flex flex-col w-full mt-1.5">
<label for="memory-path" class="mb-0.5 text-xs text-gray-500">
<label for="memory-path" class="mb-1 text-xs text-gray-600 dark:text-gray-400">
{$i18n.t('Path')}
<span class="opacity-50">({$i18n.t('optional')})</span>
</label>
@ -110,7 +118,7 @@
<input
id="memory-path"
bind:value={path}
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
class={inputClass}
placeholder={$i18n.t('Path')}
autocomplete="off"
/>

View file

@ -105,7 +105,7 @@
</h2>
<button
class="text-[0.625rem] text-gray-400 transition hover:text-gray-600 dark:hover:text-gray-300"
class="text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white"
on:click={resetBindings}
>
{$i18n.t('Reset Defaults')}
@ -114,21 +114,17 @@
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5">
<div class="flex items-center gap-2 px-1 pb-1">
<span class="flex-1 text-[0.625rem] font-medium text-gray-400 dark:text-gray-600">
<span class="flex-1 text-[0.625rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Command')}
</span>
<span
class="w-[9.5rem] shrink-0 text-right text-[0.625rem] font-medium text-gray-400 dark:text-gray-600"
>
<span class="w-[9.5rem] shrink-0 text-right text-[0.625rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Key')}
</span>
</div>
{#each Object.entries(categorizedShortcuts) as [category, items], categoryIndex}
<div class={categoryIndex > 0 ? 'mt-3' : ''}>
<div
class="px-1 pb-0.5 pt-1 text-[0.625rem] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-600"
>
<div class="px-1 pb-0.5 pt-1 text-xs text-gray-400 dark:text-gray-600">
{$i18n.t(category)}
</div>
<div class="divide-y divide-gray-100/70 dark:divide-white/[0.03]">

View file

@ -5,6 +5,7 @@
import { exportChatStats, exportSingleChatStats, downloadChatStats } from '$lib/apis/chats';
import { getVersion } from '$lib/apis';
import { settings } from '$lib/stores';
import Modal from '$lib/components/common/Modal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
@ -314,7 +315,7 @@
};
</script>
<Modal bind:show size="md">
<Modal bind:show size="md" className="bg-white dark:bg-gray-900 rounded-4xl">
<div class="w-full">
{#if completed}
<div class="px-5.5 py-5">
@ -380,7 +381,7 @@
</div>
<div class="mt-3 text-xs text-gray-500">
<div class="font-normal text-gray-900 dark:text-gray-100 mb-1">
<div class="mb-1 font-normal text-gray-600 dark:text-gray-400">
{$i18n.t('What is shared:')}
</div>
<ul class="list-disc list-inside space-y-0.5 ml-1 mb-2">
@ -391,7 +392,7 @@
<li>{$i18n.t('User ratings (thumbs up/down)')}</li>
</ul>
<div class="font-normal text-gray-900 dark:text-gray-100 mb-1">
<div class="mb-1 font-normal text-gray-600 dark:text-gray-400">
{$i18n.t('What is NOT shared:')}
</div>
<ul class="list-disc list-inside space-y-0.5 ml-1">
@ -452,7 +453,7 @@
</div>
{/if}
<div class="mt-5 flex justify-between items-center gap-2">
<div class="mt-4 flex items-center justify-between gap-2">
<div class="text-xs text-gray-400 text-center mr-auto">
<button
class="hover:underline px-2"
@ -465,7 +466,7 @@
</div>
<button
class="px-4 py-2 rounded-full text-sm font-normal bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100 transition disabled:cursor-not-allowed"
class="rounded-full bg-gray-100 px-4 py-2 text-sm font-normal text-gray-600 transition hover:bg-gray-200 disabled:cursor-not-allowed dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
on:click={() => {
if (syncing) {
cancelOperation();

View file

@ -33,7 +33,7 @@
}}
/>
<div class="flex w-full gap-2 items-center">
<div class="flex w-full items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
<Tooltip className="w-full relative" content={''} placement="top-start">
<div class="flex w-full">
<div
@ -46,7 +46,7 @@
</Tooltip>
{#if connection?.info?.name}
<div class=" capitalize outline-hidden w-full bg-transparent">
<div class="w-full bg-transparent capitalize outline-hidden">
{connection?.info?.name ?? connection?.url}
<span class="text-gray-500">{connection?.info?.id ?? ''}</span>
</div>
@ -59,10 +59,10 @@
</div>
</Tooltip>
<div class="flex gap-1 items-center">
<div class="flex shrink-0 items-center gap-1">
<Tooltip content={$i18n.t('Configure')} className="self-start">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition-colors hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
on:click={() => {
showConfigModal = true;
}}

View file

@ -0,0 +1,24 @@
<script lang="ts">
export let label = '';
export let description = '';
export let forId = '';
export let className = '';
</script>
<div class={className}>
{#if label}
<label class="text-xs text-gray-600 dark:text-gray-400" for={forId || undefined}>
{label}
</label>
{/if}
<div class={label ? 'mt-1' : ''}>
<slot />
</div>
{#if description}
<p class="mt-0.5 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{description}
</p>
{/if}
</div>

View file

@ -0,0 +1,22 @@
<script lang="ts">
export let label = '';
export let description = '';
export let className = '';
export let labelClassName = '';
</script>
<div class="flex items-center justify-between gap-2.5 {className}">
<div class="min-w-0 text-xs text-gray-600 dark:text-gray-400 {labelClassName}">
<slot name="label">{label}</slot>
</div>
<div class="shrink-0">
<slot />
</div>
</div>
{#if description}
<p class="-mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{description}
</p>
{/if}

View file

@ -0,0 +1,17 @@
<script lang="ts">
export let title = '';
export let first = false;
export let className = '';
</script>
<section class="w-full {first ? '' : 'mt-4'} {className}">
{#if title}
<h3 class="mb-2 text-xs text-gray-400 dark:text-gray-600">
{title}
</h3>
{/if}
<div class="flex flex-col gap-2.5">
<slot />
</div>
</section>

View file

@ -52,9 +52,6 @@
const i18n: Writable<any> = getContext('i18n');
const highContrastInputClass =
'rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
export let show: boolean | string | SettingsModalRequest = false;
let modalShow = false;
let lastShow: boolean | string | SettingsModalRequest = false;
@ -847,14 +844,12 @@
class="hidden md:flex items-center gap-1.5 h-7 px-2 mx-1 mt-1 mb-0.5 shrink-0 rounded-lg text-xs bg-gray-50/70 dark:bg-white/[0.03]"
>
<div class="self-center rounded-l-xl bg-transparent">
<Search
className="size-3.5"
strokeWidth={($settings?.highContrastMode ?? false) ? '3' : '1.5'}
/>
<Search className="size-3.5" strokeWidth="1.5" />
</div>
<label class="sr-only" for="search-input-settings-modal">{$i18n.t('Search')}</label>
<input
class={`w-full text-xs ${($settings?.highContrastMode ?? false) ? highContrastInputClass : 'bg-transparent py-1 outline-hidden dark:text-gray-300'}`}
data-settings-search
class="w-full text-xs bg-transparent py-1 outline-hidden dark:text-gray-300"
bind:value={search}
id="search-input-settings-modal"
on:input={searchDebounceHandler}
@ -863,7 +858,7 @@
</div>
<div
class="tabs flex min-w-0 flex-1 min-h-0 overflow-x-auto md:overflow-x-hidden md:overflow-y-auto md:flex-col p-1 pl-0 md:pl-1 gap-px"
class="tabs scrollbar-none flex min-w-0 flex-1 min-h-0 overflow-x-auto md:overflow-x-hidden md:overflow-y-auto md:flex-col p-1 pl-0 md:pl-1 gap-px"
>
<span
class="hidden md:block text-[0.625rem] text-gray-400 dark:text-gray-600 px-2 mt-1.5 mb-0.5"
@ -1191,26 +1186,3 @@
</div>
</div>
</Modal>
<style>
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
/* display: none; <- Crashes Chrome on hover */
-webkit-appearance: none;
margin: 0; /* <-- Apparently some margin are still there even though it's hidden */
}
.tabs::-webkit-scrollbar {
display: none; /* for Chrome, Safari and Opera */
}
.tabs {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
input[type='number'] {
appearance: textfield;
-moz-appearance: textfield; /* Firefox */
}
</style>

View file

@ -1,7 +1,6 @@
<script lang="ts">
const i18n = getContext('i18n');
import { getContext } from 'svelte';
import { settings } from '$lib/stores';
export let id = 'password-input';
export let value: string = '';
export let placeholder = '';
@ -17,7 +16,7 @@
let show = false;
const highContrastInputClass =
const inputChromeClass =
'rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
</script>
@ -27,7 +26,7 @@
{/if}
<input
{id}
class={`${inputClassName} ${show ? '' : 'password'} ${($settings?.highContrastMode ?? false) ? highContrastInputClass : ' outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-600'}`}
class={`${inputClassName} ${show ? '' : 'password'} ${inputChromeClass}`}
{placeholder}
type={type === 'password' && !show ? 'password' : 'text'}
bind:value

View file

@ -28,8 +28,8 @@
import { refreshChatList } from '$lib/stores/chat-list';
import Messages from '../chat/Messages.svelte';
import { goto } from '$app/navigation';
import PencilSquare from '../icons/PencilSquare.svelte';
import PageEdit from '../icons/PageEdit.svelte';
import EditPencilIcon from './Sidebar/icons/EditPencil.svelte';
import NotesIcon from './Sidebar/icons/Notes.svelte';
import ChatMenu from './Sidebar/ChatMenu.svelte';
import ShareChatModal from '../chat/ShareChatModal.svelte';
@ -251,7 +251,7 @@
show = false;
onClose();
},
icon: PencilSquare
icon: EditPencilIcon
}
];
@ -508,7 +508,7 @@
show = false;
onClose();
},
icon: PageEdit
icon: NotesIcon
}
]
: [])

View file

@ -355,7 +355,10 @@ Based on the user's instruction, update and enhance the existing notes or select
position="top"
className="inline-block"
>
<span class="text-gray-500 text-sm">({$i18n.t('Experimental')})</span>
<span
class="inline-flex items-center text-[0.625rem] font-normal uppercase leading-none text-gray-400 dark:text-gray-600"
>{$i18n.t('Experimental')}</span
>
</Tooltip>
</div>
</div>