This commit is contained in:
Timothy Jaeryang Baek
2026-07-15 00:40:20 -04:00
parent e05f9fa17e
commit c1460570b7
5 changed files with 300 additions and 53 deletions
+251 -47
View File
@@ -1,11 +1,10 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import { getContext, onMount, tick } from 'svelte';
import type { Writable } from 'svelte/store';
import { toast } from 'svelte-sonner';
import { config, models, settings, user } from '$lib/stores';
import { updateUserSettings } from '$lib/apis/users';
import { getModels as _getModels } from '$lib/apis';
import { goto } from '$app/navigation';
import { getBackendConfig, getModels as _getModels } from '$lib/apis';
import Modal from '../common/Modal.svelte';
import Account from './Settings/Account.svelte';
@@ -29,10 +28,25 @@
import WrenchAlt from '../icons/WrenchAlt.svelte';
import Face from '../icons/Face.svelte';
import AppNotification from '../icons/AppNotification.svelte';
import UserBadgeCheck from '../icons/UserBadgeCheck.svelte';
import ArchiveBox from '../icons/ArchiveBox.svelte';
import ChevronLeft from '../icons/ChevronLeft.svelte';
import Keyboard from '../icons/Keyboard.svelte';
import AdminTabIcon from '$lib/components/admin/Settings/AdminTabIcon.svelte';
import AdminGeneral from '$lib/components/admin/Settings/General.svelte';
import AdminAuthentication from '$lib/components/admin/Settings/Authentication.svelte';
import AdminConnections from '$lib/components/admin/Settings/Connections.svelte';
import AdminModels from '$lib/components/admin/Settings/Models.svelte';
import AdminSubagents from '$lib/components/admin/Settings/Subagents.svelte';
import AdminEvaluations from '$lib/components/admin/Settings/Evaluations.svelte';
import AdminIntegrations from '$lib/components/admin/Settings/Integrations.svelte';
import AdminDocuments from '$lib/components/admin/Settings/Documents.svelte';
import AdminWebSearch from '$lib/components/admin/Settings/WebSearch.svelte';
import AdminCodeExecution from '$lib/components/admin/Settings/CodeExecution.svelte';
import AdminInterface from '$lib/components/admin/Settings/Interface.svelte';
import AdminAudio from '$lib/components/admin/Settings/Audio.svelte';
import AdminImages from '$lib/components/admin/Settings/Images.svelte';
import AdminPipelines from '$lib/components/admin/Settings/Pipelines.svelte';
import AdminDatabase from '$lib/components/admin/Settings/Database.svelte';
const i18n: Writable<any> = getContext('i18n');
@@ -67,6 +81,10 @@
keywords: string[];
}
const isAdminTab = (tabId: string) => tabId.startsWith('admin:');
const adminTabSegment = (tabId: string) => tabId.replace('admin:', '');
const adminTabPanelId = (tabId: string) => `tab-${tabId.replace(':', '-')}`;
const allSettings: SettingsTab[] = [
{
id: 'general',
@@ -525,14 +543,123 @@
}
];
const adminSettings: SettingsTab[] = [
{
id: 'admin:general',
title: 'General',
keywords: ['general', 'admin', 'settings', 'version', 'update', 'community', 'channels']
},
{
id: 'admin:authentication',
title: 'Authentication',
keywords: [
'authentication',
'auth',
'login',
'signup',
'ldap',
'oauth',
'oidc',
'sso',
'roles'
]
},
{
id: 'admin:connections',
title: 'Connections',
keywords: [
'connections',
'ollama',
'openai',
'api',
'base url',
'direct connections',
'proxy'
]
},
{
id: 'admin:models',
title: 'Models',
keywords: [
'models',
'pull',
'delete',
'create',
'edit',
'modelfile',
'gguf',
'import',
'export'
]
},
{
id: 'admin:subagents',
title: 'Sub-agents',
keywords: ['sub-agents', 'subagents', 'delegation', 'background', 'agents']
},
{
id: 'admin:evaluations',
title: 'Evaluations',
keywords: ['evaluations', 'feedback', 'rating', 'arena', 'leaderboard', 'preference']
},
{
id: 'admin:integrations',
title: 'Integrations',
keywords: ['tools', 'integrations', 'plugins', 'extensions', 'functions', 'openapi', 'server']
},
{
id: 'admin:documents',
title: 'Documents',
keywords: ['documents', 'files', 'rag', 'knowledge', 'upload', 'embedding', 'vector db']
},
{
id: 'admin:web',
title: 'Web Search',
keywords: ['web search', 'google', 'bing', 'duckduckgo', 'serp', 'searxng', 'tavily', 'exa']
},
{
id: 'admin:code-execution',
title: 'Code Execution',
keywords: ['code execution', 'python', 'sandbox', 'compiler', 'jupyter', 'interpreter']
},
{
id: 'admin:interface',
title: 'Interface',
keywords: ['interface', 'ui', 'appearance', 'banners', 'tasks', 'prompt suggestions', 'tags']
},
{
id: 'admin:audio',
title: 'Audio',
keywords: ['audio', 'voice', 'speech', 'tts', 'stt', 'whisper', 'deepgram', 'azure']
},
{
id: 'admin:images',
title: 'Images',
keywords: ['images', 'generation', 'dalle', 'stable diffusion', 'comfyui', 'automatic1111']
},
{
id: 'admin:pipelines',
title: 'Pipelines',
keywords: ['pipelines', 'workflows', 'filters', 'valves', 'middleware']
},
{
id: 'admin:db',
title: 'Database',
keywords: ['database', 'export', 'import', 'backup', 'chats', 'users']
}
];
const allKnownSettings = [...allSettings, ...adminSettings];
let availableSettings: SettingsTab[] = [];
let filteredSettings: string[] = [];
let filteredPersonalSettings: string[] = [];
let filteredAdminSettings: string[] = [];
let search = '';
let searchDebounceTimeout: ReturnType<typeof setTimeout> | null = null;
const getAvailableSettings = () => {
return allSettings.filter((tab) => {
const personalSettings = allSettings.filter((tab) => {
if (tab.id === 'connections') {
return $config?.features?.enable_direct_connections;
}
@@ -557,6 +684,8 @@
return true;
});
return $user?.role === 'admin' ? [...personalSettings, ...adminSettings] : personalSettings;
};
const setFilteredSettings = () => {
@@ -570,8 +699,12 @@
);
})
.map((tab) => tab.id);
filteredPersonalSettings = filteredSettings.filter((tabId) => !isAdminTab(tabId));
filteredAdminSettings = filteredSettings.filter((tabId) => isAdminTab(tabId));
if (filteredSettings.length > 0 && !filteredSettings.includes(selectedTab)) {
if ($user?.role !== 'admin' && isAdminTab(selectedTab)) {
selectedTab = 'general';
} else if (filteredSettings.length > 0 && !filteredSettings.includes(selectedTab)) {
selectedTab = filteredSettings[0];
}
};
@@ -590,6 +723,12 @@
);
};
const adminConfigSaveHandler = async () => {
toast.success($i18n.t('Settings saved successfully!'));
await tick();
await config.set(await getBackendConfig());
};
const searchDebounceHandler = () => {
if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout);
@@ -608,7 +747,10 @@
}`;
let selectedTab = 'general';
$: selectedTabTitle = allSettings.find((tab) => tab.id === selectedTab)?.title ?? 'Settings';
$: if ($user?.role !== 'admin' && isAdminTab(selectedTab)) {
selectedTab = 'general';
}
$: selectedTabTitle = allKnownSettings.find((tab) => tab.id === selectedTab)?.title ?? 'Settings';
onMount(() => {
availableSettings = getAvailableSettings();
@@ -624,12 +766,12 @@
<Modal
size="full"
containerClassName="p-4 sm:p-6 lg:p-8"
className="!w-[calc(100vw-2rem)] sm:!w-[calc(100vw-3rem)] lg:!w-[calc(100vw-4rem)] !max-w-[76rem] h-[min(50rem,calc(100dvh-4rem))] max-h-[calc(100dvh-4rem)] flex flex-col md:flex-row bg-white dark:bg-gray-900 rounded-4xl"
className="!w-[calc(100vw-2rem)] sm:!w-[calc(100vw-3rem)] lg:!w-[calc(100vw-4rem)] !max-w-[80rem] h-[min(54rem,calc(100dvh-4rem))] max-h-[calc(100dvh-4rem)] flex flex-col md:flex-row bg-white dark:bg-gray-900 rounded-4xl"
bind:show={modalShow}
>
<nav
id="settings-tabs-container"
class="shrink-0 min-w-0 md:min-h-0 flex md:block border-b md:border-b-0 md:border-r border-gray-100/30 dark:border-white/[0.02] md:w-[15rem]"
class="shrink-0 min-w-0 md:min-h-0 flex md:flex-col border-b md:border-b-0 md:border-r border-gray-100/30 dark:border-white/[0.02] md:w-[15rem]"
>
<button
class="flex items-center gap-1.5 h-7 px-2 m-1 md:mb-0 md:w-[calc(100%-0.5rem)] shrink-0 rounded-lg text-xs text-gray-400 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-300 transition-colors duration-75"
@@ -643,36 +785,36 @@
</button>
<div
class="tabs flex min-w-0 flex-1 overflow-x-auto md:overflow-x-hidden md:overflow-y-auto md:flex-col p-1 pl-0 md:pl-1 gap-px"
class="hidden md:flex items-center gap-1.5 h-7 px-2 mx-1 mt-1 mb-2 shrink-0 rounded-lg text-xs bg-gray-50/70 dark:bg-white/[0.03]"
>
<div
class="hidden md:flex items-center gap-1.5 h-7 px-2 md:w-full shrink-0 rounded-lg text-xs bg-gray-50/70 dark:bg-white/[0.03] mb-2"
>
<div class="self-center rounded-l-xl bg-transparent">
<Search
className="size-3.5"
strokeWidth={($settings?.highContrastMode ?? false) ? '3' : '1.5'}
/>
</div>
<label class="sr-only" for="search-input-settings-modal">{$i18n.t('Search')}</label>
<input
class={`w-full py-1 text-xs bg-transparent dark:text-gray-300 outline-hidden
${($settings?.highContrastMode ?? false) ? 'placeholder-gray-800' : ''}`}
bind:value={search}
id="search-input-settings-modal"
on:input={searchDebounceHandler}
placeholder={$i18n.t('Search')}
<div class="self-center rounded-l-xl bg-transparent">
<Search
className="size-3.5"
strokeWidth={($settings?.highContrastMode ?? false) ? '3' : '1.5'}
/>
</div>
<label class="sr-only" for="search-input-settings-modal">{$i18n.t('Search')}</label>
<input
class={`w-full py-1 text-xs bg-transparent dark:text-gray-300 outline-hidden
${($settings?.highContrastMode ?? false) ? 'placeholder-gray-800' : ''}`}
bind:value={search}
id="search-input-settings-modal"
on:input={searchDebounceHandler}
placeholder={$i18n.t('Search')}
/>
</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"
>
<span
class="hidden md:block text-[0.625rem] text-gray-400 dark:text-gray-600 px-2 mt-1.5 mb-0.5"
>
{$i18n.t('Personal')}
</span>
{#if filteredSettings.length > 0}
{#each filteredSettings as tabId (tabId)}
{#if filteredPersonalSettings.length > 0}
{#each filteredPersonalSettings as tabId (tabId)}
{#if tabId === 'general'}
<button
role="tab"
@@ -822,30 +964,38 @@
</button>
{/if}
{/each}
{:else}
<div class="text-center text-gray-500 mt-4">
{$i18n.t('No results found')}
</div>
{/if}
{#if $user?.role === 'admin'}
{#if $user?.role === 'admin' && filteredAdminSettings.length > 0}
<span
class="hidden md:block text-[0.625rem] text-gray-400 dark:text-gray-600 px-2 mt-2 mb-0.5"
>
{$i18n.t('Admin')}
</span>
<a
href="/admin/settings"
draggable="false"
class="flex items-center gap-1.5 h-7 px-2 md:w-full shrink-0 rounded-lg text-xs text-left transition-colors duration-75 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 select-none"
on:click={async (e) => {
e.preventDefault();
await goto('/admin/settings');
show = false;
}}
>
<UserBadgeCheck className="size-3.5" strokeWidth="2" />
<span>{$i18n.t('Admin Settings')}</span>
</a>
{#each filteredAdminSettings as tabId (tabId)}
{@const tab = adminSettings.find((setting) => setting.id === tabId)}
{#if tab}
<button
role="tab"
aria-controls={adminTabPanelId(tab.id)}
aria-selected={selectedTab === tab.id}
class={tabButtonClass(selectedTab === tab.id)}
on:click={() => {
selectedTab = tab.id;
}}
>
<AdminTabIcon id={adminTabSegment(tab.id)} className="size-3.5" strokeWidth="2" />
<span>{$i18n.t(tab.title)}</span>
</button>
{/if}
{/each}
{/if}
{#if filteredSettings.length === 0}
<div class="px-2 py-1 text-xs text-gray-400 dark:text-gray-600">
{$i18n.t('No matches')}
</div>
{/if}
</div>
</nav>
@@ -916,6 +1066,60 @@
/>
{:else if selectedTab === 'about'}
<About />
{:else if selectedTab === 'admin:general'}
<AdminGeneral saveHandler={adminConfigSaveHandler} />
{:else if selectedTab === 'admin:authentication'}
<AdminAuthentication />
{:else if selectedTab === 'admin:connections'}
<AdminConnections
on:save={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'admin:models'}
<AdminModels />
{:else if selectedTab === 'admin:subagents'}
<AdminSubagents />
{:else if selectedTab === 'admin:evaluations'}
<AdminEvaluations />
{:else if selectedTab === 'admin:integrations'}
<AdminIntegrations {saveSettings} />
{:else if selectedTab === 'admin:documents'}
<AdminDocuments on:save={adminConfigSaveHandler} />
{:else if selectedTab === 'admin:web'}
<AdminWebSearch saveHandler={adminConfigSaveHandler} />
{:else if selectedTab === 'admin:code-execution'}
<AdminCodeExecution saveHandler={adminConfigSaveHandler} />
{:else if selectedTab === 'admin:interface'}
<AdminInterface
on:save={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'admin:audio'}
<AdminAudio
saveHandler={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'admin:images'}
<AdminImages
on:save={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'admin:db'}
<AdminDatabase
saveHandler={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{:else if selectedTab === 'admin:pipelines'}
<AdminPipelines
saveHandler={() => {
toast.success($i18n.t('Settings saved successfully!'));
}}
/>
{/if}
</div>
</div>
+33
View File
@@ -56,6 +56,7 @@
let localDBChats = [];
let version;
let handledSettingsUrl = '';
const clearChatInputStorage = () => {
const chatInputKeys = Object.keys(localStorage).filter((key) => key.startsWith('chat-input'));
@@ -194,6 +195,34 @@
tools.set(toolsData);
};
const openSettingsFromUrl = async () => {
const requestedSettings = $page.url.searchParams.get('settings');
if (!requestedSettings) {
return;
}
const urlKey = `${$page.url.pathname}${$page.url.search}${$page.url.hash}`;
if (handledSettingsUrl === urlKey) {
return;
}
handledSettingsUrl = urlKey;
showSettings.set(
requestedSettings.startsWith('admin:') && $user?.role !== 'admin'
? 'general'
: requestedSettings
);
const params = new URLSearchParams($page.url.searchParams);
params.delete('settings');
const query = params.toString();
await goto(`${$page.url.pathname}${query ? `?${query}` : ''}${$page.url.hash}`, {
replaceState: true,
noScroll: true,
keepFocus: true
});
};
onMount(async () => {
if ($user === undefined || $user === null) {
await goto('/auth');
@@ -367,6 +396,10 @@
loaded = true;
});
$: if (loaded) {
void openSettingsFromUrl();
}
const checkForVersionUpdates = async () => {
version = await getVersionUpdates(localStorage.token).catch((error) => {
return {
+6 -2
View File
@@ -2,7 +2,7 @@
import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation';
import { WEBUI_NAME, config, mobile, showSidebar, user } from '$lib/stores';
import { WEBUI_NAME, config, mobile, showSettings, showSidebar, user } from '$lib/stores';
import { page } from '$app/stores';
import Tooltip from '$lib/components/common/Tooltip.svelte';
@@ -105,7 +105,11 @@
class="min-w-fit p-1.5 {$page.url.pathname.includes('/admin/settings')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition select-none"
href="/admin/settings">{$i18n.t('Settings')}</a
href="/admin/settings"
on:click={(event) => {
event.preventDefault();
showSettings.set('admin:general');
}}>{$i18n.t('Settings')}</a
>
</div>
</div>
+1 -1
View File
@@ -3,6 +3,6 @@
import { onMount } from 'svelte';
onMount(() => {
goto('/admin/settings/general');
goto('/?settings=admin%3Ageneral', { replaceState: true });
});
</script>
@@ -1,5 +1,11 @@
<script>
import Settings from '$lib/components/admin/Settings.svelte';
</script>
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
<Settings />
onMount(() => {
const params = new URLSearchParams($page.url.searchParams);
params.set('settings', `admin:${$page.params.tab ?? 'general'}`);
goto(`/?${params.toString()}`, { replaceState: true });
});
</script>