This commit is contained in:
Timothy Jaeryang Baek
2026-07-17 05:31:32 -04:00
parent 421da67446
commit d0bea60581
7 changed files with 558 additions and 618 deletions
+173 -15
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { marked } from 'marked';
import Sortable from 'sortablejs';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { onMount, getContext, tick } from 'svelte';
import { onMount, onDestroy, getContext, tick } from 'svelte';
const i18n = getContext('i18n');
import { config, models as _models, settings, user } from '$lib/stores';
@@ -21,6 +22,7 @@
import { updateUserSettings } from '$lib/apis/users';
import { getModels } from '$lib/apis';
import { getModelsConfig, setModelsConfig } from '$lib/apis/configs';
import Search from '$lib/components/icons/Search.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
@@ -38,19 +40,20 @@
import Eye from '$lib/components/icons/Eye.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import CheckCircle from '$lib/components/icons/CheckCircle.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
import DocumentArrowUp from '$lib/components/icons/DocumentArrowUp.svelte';
import Download from '$lib/components/icons/Download.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte';
import SettingsIcon from '$lib/components/icons/Settings.svelte';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { goto } from '$app/navigation';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import AdminViewSelector from './Models/AdminViewSelector.svelte';
import TagSelector from '$lib/components/workspace/common/TagSelector.svelte';
import Pagination from '$lib/components/common/Pagination.svelte';
type ModelListItem = { id: string; name?: string };
@@ -62,8 +65,12 @@
let importFiles;
let modelsImportInputElement: HTMLInputElement;
let tagsContainerElement: HTMLDivElement;
let modelListElement: HTMLDivElement;
let sortable = null;
let models = null;
let modelsConfig = null;
let modelOrderList: string[] = [];
let workspaceModels: ModelListItem[] = [];
let baseModels: ModelListItem[] = [];
@@ -73,6 +80,9 @@
let showConfigModal = false;
let showManageModal = false;
let showResetModal = false;
let savingModelOrder = false;
let modelOrderDirty = false;
let viewOption = ''; // '' = All, 'enabled', 'disabled', 'visible', 'hidden'
let tags: string[] = [];
@@ -83,9 +93,6 @@
tabState = null;
}
const perPage = 30;
let currentPage = 1;
const isPublicModel = (model) => {
return (model?.access_grants ?? []).some(
(g) => g.principal_type === 'user' && g.principal_id === '*' && g.permission === 'read'
@@ -115,6 +122,8 @@
};
$: if (models) {
const modelOrder = new Map(modelOrderList.map((id, idx) => [id, idx]));
filteredModels = models
.filter((m) => searchValue === '' || m.name.toLowerCase().includes(searchValue.toLowerCase()))
.filter((m) => {
@@ -127,15 +136,21 @@
return true; // All
})
.sort((a, b) => {
const orderA = modelOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
const orderB = modelOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) {
return orderA - orderB;
}
return (a?.name ?? a?.id ?? '').localeCompare(b?.name ?? b?.id ?? '');
});
}
let searchValue = '';
let canReorderModels = false;
$: if (searchValue || viewOption !== undefined) {
currentPage = 1;
}
$: canReorderModels = searchValue === '' && viewOption === '' && selectedTag === '';
const enableAllHandler = async () => {
const modelsToEnable = filteredModels.filter((m) => !(m.is_active ?? true));
@@ -213,6 +228,9 @@
const init = async () => {
models = null;
modelsConfig = await getModelsConfig(localStorage.token);
modelOrderList = modelsConfig?.MODEL_ORDER_LIST ?? [];
tags = await getBaseModelTags(localStorage.token);
if (selectedTag && !tags.includes(selectedTag)) {
selectedTag = '';
@@ -243,6 +261,15 @@
}
});
modelOrderList = [
...modelOrderList.filter((id) => models.some((model) => model.id === id)),
...models
.map((model) => model.id)
.filter((id) => !modelOrderList.includes(id))
.sort((a, b) => a.localeCompare(b))
];
modelOrderDirty = false;
_models.set(
await getModels(
localStorage.token,
@@ -251,6 +278,79 @@
);
};
const saveModelOrder = async (orderedModelIds: string[]) => {
savingModelOrder = true;
const res = await setModelsConfig(localStorage.token, {
DEFAULT_MODELS: modelsConfig?.DEFAULT_MODELS ?? null,
DEFAULT_PINNED_MODELS: modelsConfig?.DEFAULT_PINNED_MODELS ?? null,
MODEL_ORDER_LIST: orderedModelIds,
DEFAULT_MODEL_METADATA: modelsConfig?.DEFAULT_MODEL_METADATA ?? null,
DEFAULT_MODEL_PARAMS: modelsConfig?.DEFAULT_MODEL_PARAMS ?? null
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
modelsConfig = res;
modelOrderDirty = false;
toast.success($i18n.t('Model order saved successfully'));
_models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
}
savingModelOrder = false;
};
const positionChangeHandler = async (event) => {
const { oldIndex, newIndex, item } = event;
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) {
return;
}
const parent = item.parentNode;
const target = parent.children[oldIndex < newIndex ? oldIndex : oldIndex + 1];
parent.insertBefore(item, target);
const updatedModels = [...filteredModels];
const [movedModel] = updatedModels.splice(oldIndex, 1);
updatedModels.splice(newIndex, 0, movedModel);
const orderedIds = updatedModels.map((model) => model.id);
const orderedSet = new Set(orderedIds);
models = [...updatedModels, ...models.filter((model) => !orderedSet.has(model.id))];
modelOrderList = models.map((model) => model.id);
modelOrderDirty = true;
};
const initSortable = () => {
if (sortable) {
sortable.destroy();
sortable = null;
}
if (modelListElement && filteredModels.length > 0 && canReorderModels) {
sortable = new Sortable(modelListElement, {
animation: 150,
handle: '.model-item-handle',
onUpdate: positionChangeHandler
});
}
};
$: if (modelListElement && filteredModels) {
tick().then(() => {
initSortable();
});
}
const upsertModelHandler = async (model, overrides = {}, showToast = true) => {
model = { ...model, base_model_id: null, ...overrides };
@@ -436,8 +536,27 @@
window.removeEventListener('blur', onBlur);
};
});
onDestroy(() => {
if (sortable) {
sortable.destroy();
}
});
</script>
<ConfirmDialog
title={$i18n.t('Reset All Models')}
message={$i18n.t('This will delete all models including custom models and cannot be undone.')}
bind:show={showResetModal}
onConfirm={async () => {
const res = await deleteAllModels(localStorage.token);
if (res) {
toast.success($i18n.t('All models deleted successfully'));
await init();
}
}}
/>
<ModelSettingsModal bind:show={showConfigModal} initHandler={init} />
<ManageModelsModal bind:show={showManageModal} />
@@ -552,7 +671,6 @@
return { value: tag, label: tag };
})}
onChange={async () => {
currentPage = 1;
await init();
}}
/>
@@ -608,6 +726,17 @@
<div class="flex items-center">{$i18n.t('Manage')}</div>
</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 dark:hover:text-gray-100"
type="button"
on:click={() => {
showResetModal = true;
}}
>
<GarbageBin className="size-3.5" />
<div class="flex items-center">{$i18n.t('Reset')}</div>
</button>
<hr class="mx-1 my-0.5 border-gray-100 dark:border-gray-800" />
<button
@@ -666,16 +795,31 @@
? 'overflow-y-auto scrollbar-hover pr-1.5'
: 'overflow-hidden'}"
id="model-list"
bind:this={modelListElement}
>
{#if filteredModels.length > 0}
{#each filteredModels.slice((currentPage - 1) * perPage, currentPage * perPage) as model, modelIdx (`${model.id}-${modelIdx}`)}
{#each filteredModels as model, modelIdx (`${model.id}-${modelIdx}`)}
<div
class="flex cursor-pointer transition w-full px-2 py-1 rounded-xl hover:bg-gray-50/70 dark:hover:bg-gray-850/50 {model
?.meta?.hidden
? 'opacity-50 dark:opacity-50'
: ''}"
: ''}"
id="model-item-{model.id}"
>
<div class="self-center pr-1 text-gray-400 dark:text-gray-600">
<Tooltip
content={canReorderModels
? $i18n.t('Drag to reorder')
: $i18n.t('Clear filters to reorder')}
>
<EllipsisVertical
className="size-4 {canReorderModels
? 'cursor-move model-item-handle'
: 'opacity-40'}"
/>
</Tooltip>
</div>
<button
class="flex group/item gap-2.5 w-full min-w-0 flex-1 text-left cursor-pointer"
type="button"
@@ -841,9 +985,23 @@
{/if}
</div>
{#if filteredModels.length > perPage}
<Pagination bind:page={currentPage} count={filteredModels.length} {perPage} />
{/if}
<div class="flex justify-end pt-6 text-sm font-normal">
<button
class="flex items-center gap-2 px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full disabled:cursor-not-allowed disabled:opacity-40"
type="button"
disabled={!modelOrderDirty || savingModelOrder}
on:click={async () => {
await saveModelOrder(modelOrderList);
}}
>
{$i18n.t('Save')}
{#if savingModelOrder}
<span class="shrink-0">
<Spinner />
</span>
{/if}
</button>
</div>
</div>
</div>
{:else}
@@ -1,92 +0,0 @@
<script lang="ts">
import Sortable from 'sortablejs';
import { createEventDispatcher, getContext, onMount, onDestroy, tick } from 'svelte';
const i18n = getContext('i18n');
import { models } from '$lib/stores';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
export let modelIds = [];
let sortable = null;
let modelListElement = null;
const positionChangeHandler = (event) => {
const { oldIndex, newIndex, item } = event;
// Revert SortableJS's DOM manipulation so Svelte doesn't get out of sync.
// Svelte expects the DOM to match its virtual DOM before it applies state updates.
const parent = item.parentNode;
const target = parent.children[oldIndex < newIndex ? oldIndex : oldIndex + 1];
parent.insertBefore(item, target);
// Now apply the logical state update, letting Svelte handle the real DOM move.
const updatedIds = [...modelIds];
const [movedId] = updatedIds.splice(oldIndex, 1);
updatedIds.splice(newIndex, 0, movedId);
modelIds = updatedIds;
};
const initSortable = () => {
if (sortable) {
sortable.destroy();
sortable = null;
}
if (modelListElement) {
sortable = new Sortable(modelListElement, {
animation: 150,
handle: '.model-item-handle',
onUpdate: (event) => {
positionChangeHandler(event);
}
});
}
};
$: if (modelIds && modelListElement) {
tick().then(() => {
initSortable();
});
}
onMount(() => {
tick().then(() => {
initSortable();
});
});
onDestroy(() => {
if (sortable) {
sortable.destroy();
}
});
</script>
{#if modelIds.length > 0}
<div class="flex flex-col -translate-x-1" bind:this={modelListElement}>
{#each modelIds as modelId (modelId)}
<div class=" flex gap-2 w-full justify-between items-center" id="model-item-{modelId}">
<Tooltip content={modelId} placement="top-start">
<div class="flex items-center gap-1">
<EllipsisVertical className="size-4 cursor-move model-item-handle" />
<div class=" text-sm flex-1 py-1 rounded-lg line-clamp-1">
{#if $models.find((model) => model.id === modelId)}
{$models.find((model) => model.id === modelId).name}
{:else}
{modelId}
{/if}
</div>
</div>
</Tooltip>
</div>
{/each}
</div>
{:else}
<div class="text-gray-500 text-xs text-center py-2">
{$i18n.t('No models found')}
</div>
{/if}
@@ -1,28 +1,20 @@
<script>
import { toast } from 'svelte-sonner';
import { createEventDispatcher, getContext, onMount } from 'svelte';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import { models, config as _config } from '$lib/stores';
import { DEFAULT_CAPABILITIES } from '$lib/constants';
import { deleteAllModels } from '$lib/apis/models';
import { getModelsConfig, setModelsConfig, setDefaultPromptSuggestions } from '$lib/apis/configs';
import { getBackendConfig } from '$lib/apis';
import Modal from '$lib/components/common/Modal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ModelList from './ModelList.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import ModelSelector from './ModelSelector.svelte';
import Model from '../Evaluations/Model.svelte';
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import Capabilities from '$lib/components/workspace/Models/Capabilities.svelte';
@@ -31,28 +23,19 @@
import PromptSuggestions from '$lib/components/workspace/Models/PromptSuggestions.svelte';
import AdjustmentsHorizontal from '$lib/components/icons/AdjustmentsHorizontal.svelte';
import Eye from '$lib/components/icons/Eye.svelte';
export let show = false;
export let initHandler = () => {};
let config = null;
let selectedTab = 'defaults';
let selectedModelId = '';
let defaultModelIds = [];
let selectedPinnedModelId = '';
let defaultPinnedModelIds = [];
let modelIds = [];
let sortKey = '';
let sortOrder = '';
let loading = false;
let showResetModal = false;
let showDefaultCapabilities = false;
let showDefaultParams = false;
let showDefaultPromptSuggestions = false;
@@ -94,9 +77,6 @@
...allModelIds.filter((id) => !orderedSet.has(id)).sort((a, b) => a.localeCompare(b))
];
sortKey = '';
sortOrder = '';
const savedMeta = config?.DEFAULT_MODEL_METADATA;
if (savedMeta && Object.keys(savedMeta).length > 0) {
defaultCapabilities = savedMeta.capabilities ?? { ...DEFAULT_CAPABILITIES };
@@ -150,19 +130,6 @@
});
</script>
<ConfirmDialog
title={$i18n.t('Reset All Models')}
message={$i18n.t('This will delete all models including custom models and cannot be undone.')}
bind:show={showResetModal}
onConfirm={async () => {
const res = deleteAllModels(localStorage.token);
if (res) {
toast.success($i18n.t('All models deleted successfully'));
initHandler();
}
}}
/>
<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">
@@ -194,13 +161,7 @@
class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-normal text-left scrollbar-none"
>
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
'defaults'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'defaults';
}}
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition"
type="button"
>
<div class=" self-center mr-2">
@@ -208,35 +169,18 @@
</div>
<div class=" self-center">{$i18n.t('Defaults')}</div>
</button>
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
'display'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'display';
}}
type="button"
>
<div class=" self-center mr-2">
<Eye />
</div>
<div class=" self-center">{$i18n.t('Display')}</div>
</button>
</div>
<div class="flex-1 mt-1 lg:mt-1 lg:h-[30rem] lg:max-h-[30rem] flex flex-col min-w-0">
<div class="w-full h-full overflow-y-auto overflow-x-hidden scrollbar-hidden">
{#if selectedTab === 'defaults'}
<ModelSelector
title={$i18n.t('Selected Models')}
tooltip={$i18n.t(
'Set the default models that are automatically selected for all users when a new chat is created.'
)}
models={$models.filter((model) => !(model?.info?.meta?.hidden ?? false))}
bind:modelIds={defaultModelIds}
/>
<ModelSelector
title={$i18n.t('Selected Models')}
tooltip={$i18n.t(
'Set the default models that are automatically selected for all users when a new chat is created.'
)}
models={$models.filter((model) => !(model?.info?.meta?.hidden ?? false))}
bind:modelIds={defaultModelIds}
/>
<hr class=" border-gray-50 dark:border-gray-800/10 my-2.5 w-full" />
@@ -370,71 +314,9 @@
</div>
{/if}
</div>
{:else if selectedTab === 'display'}
<div>
<div class="flex flex-col w-full">
<button
class="mb-1 flex gap-2"
type="button"
on:click={() => {
sortKey = 'model';
if (sortOrder === 'asc') {
sortOrder = 'desc';
} else {
sortOrder = 'asc';
}
modelIds = modelIds
.filter((id) => id !== '')
.sort((a, b) => {
const nameA = $models.find((model) => model.id === a)?.name || a;
const nameB = $models.find((model) => model.id === b)?.name || b;
return sortOrder === 'desc'
? nameA.localeCompare(nameB)
: nameB.localeCompare(nameA);
});
}}
>
<div class="text-xs text-gray-500">{$i18n.t('Reorder Models')}</div>
{#if sortKey === 'model'}
<span class="font-normal self-center">
{#if sortOrder === 'asc'}
<ChevronUp className="size-3" />
{:else}
<ChevronDown className="size-3" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-3" />
</span>
{/if}
</button>
<ModelList bind:modelIds />
</div>
</div>
{/if}
</div>
<div class="flex justify-between items-center pt-3 text-sm font-normal gap-1.5">
<div>
<Tooltip
content={$i18n.t('This will delete all models including custom models')}
>
<button
class="text-sm font-normal text-gray-500 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 transition hover:underline"
type="button"
on:click={() => {
showResetModal = true;
}}
>
{$i18n.t('Reset')}
</button>
</Tooltip>
</div>
<div class="flex justify-end items-center pt-3 text-sm font-normal gap-1.5">
<button
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 whitespace-nowrap {loading
? ' cursor-not-allowed'