Files
open-webui/src/lib/components/admin/Settings/Models.svelte
T
Timothy Jaeryang Baek 89caa7c849 refac
2026-07-27 01:19:34 -04:00

1234 lines
37 KiB
Svelte

<script lang="ts">
import { marked } from 'marked';
import Sortable from 'sortablejs';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { onMount, onDestroy, getContext, tick } from 'svelte';
const i18n = getContext('i18n');
import { config, models as _models, settings, showSettings, user } from '$lib/stores';
import {
createNewModel,
deleteAllModels,
getBaseModelTags,
getBaseModels,
getModelById,
toggleModelById,
updateModelById,
updateModelAccessGrants,
importModels
} from '$lib/apis/models';
import { copyToClipboard } from '$lib/utils';
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';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import ModelEditor from '$lib/components/workspace/Models/ModelEditor.svelte';
import { toast } from 'svelte-sonner';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import ManageModelsModal from './Models/ManageModelsModal.svelte';
import ModelDefaultsPanel from './Models/ModelDefaultsPanel.svelte';
import ModelMenu from '$lib/components/admin/Settings/Models/ModelMenu.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
import Eye from '$lib/components/icons/Eye.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Check from '$lib/components/icons/Check.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 Pin from '$lib/components/icons/Pin.svelte';
import PinSlash from '$lib/components/icons/PinSlash.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte';
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';
type ModelListItem = { id: string; name?: string };
let shiftKey = false;
export let tabState: Record<string, unknown> | null = null;
let modelsImportInProgress = false;
let importFiles;
let modelsImportInputElement: HTMLInputElement;
let tagsContainerElement: HTMLDivElement;
let modelListElement: HTMLDivElement;
let sortable = null;
let models = null;
let modelsConfig = null;
let modelOrderList: string[] = [];
let defaultModelIds: string[] = [];
let defaultPinnedModelIds: string[] = [];
let defaultModelIdSet = new Set<string>();
let defaultPinnedModelIdSet = new Set<string>();
let workspaceModels: ModelListItem[] = [];
let baseModels: ModelListItem[] = [];
let filteredModels = [];
let selectedModelId = null;
let showManageModal = false;
let showResetModal = false;
let savingModelOrder = false;
let savingModelsSettings = false;
let modelOrderDirty = false;
let modelDefaultsPanel = null;
let modelDefaultsDirty = false;
let viewOption = ''; // '' = All, 'enabled', 'disabled', 'visible', 'hidden'
let tags: string[] = [];
let selectedTag = '';
$: if (typeof tabState?.id === 'string' && tabState.id) {
selectedModelId = tabState.id;
tabState = null;
}
const isPublicModel = (model) => {
return (model?.access_grants ?? []).some(
(g) => g.principal_type === 'user' && g.principal_id === '*' && g.permission === 'read'
);
};
const isSharedModel = (model) => (model?.access_grants ?? []).length > 0 && !isPublicModel(model);
const modelAccessLabel = (model) => {
if (isPublicModel(model)) {
return $i18n.t('Public');
}
if (isSharedModel(model)) {
return $i18n.t('Shared');
}
return $i18n.t('Private');
};
const modelAccessClass = (model) => {
if (isPublicModel(model)) {
return 'text-[#4f7a5a] dark:text-[#8db395]';
}
if (isSharedModel(model)) {
return 'text-[#4f6f93] dark:text-[#8ba6c6]';
}
return 'text-gray-500 dark:text-gray-400';
};
$: defaultModelIdSet = new Set(defaultModelIds);
$: defaultPinnedModelIdSet = new Set(defaultPinnedModelIds);
$: 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) => {
if (viewOption === 'enabled') return m?.is_active ?? true;
if (viewOption === 'disabled') return !(m?.is_active ?? true);
if (viewOption === 'visible') return !(m?.meta?.hidden ?? false);
if (viewOption === 'hidden') return m?.meta?.hidden === true;
if (viewOption === 'public') return isPublicModel(m);
if (viewOption === 'private') return !isPublicModel(m);
if (viewOption === 'selected') return defaultModelIdSet.has(m.id);
if (viewOption === 'pinned') return defaultPinnedModelIdSet.has(m.id);
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;
$: canReorderModels = searchValue === '' && viewOption === '' && selectedTag === '';
const enableAllHandler = async () => {
const modelsToEnable = filteredModels.filter((m) => !(m.is_active ?? true));
// Optimistic UI update
modelsToEnable.forEach((m) => (m.is_active = true));
models = models;
// Sync with server
await Promise.all(
modelsToEnable.map((model) => upsertModelHandler(model, { is_active: true }, false))
);
await tick();
await init();
};
const disableAllHandler = async () => {
const modelsToDisable = filteredModels.filter((m) => m.is_active ?? true);
// Optimistic UI update
modelsToDisable.forEach((m) => (m.is_active = false));
models = models;
// Sync with server
await Promise.all(
modelsToDisable.map((model) => upsertModelHandler(model, { is_active: false }, false))
);
await tick();
await init();
};
const showAllHandler = async () => {
const modelsToShow = filteredModels.filter((m) => m?.meta?.hidden === true);
// Optimistic UI update
modelsToShow.forEach((m) => {
m.meta = { ...m.meta, hidden: false };
});
models = models;
// Sync with server
await Promise.all(
modelsToShow.map((model) =>
upsertModelHandler(model, { meta: { ...model.meta, hidden: false } }, false)
)
);
toast.success($i18n.t('All models are now visible'));
await tick();
await init();
};
const hideAllHandler = async () => {
const modelsToHide = filteredModels.filter((m) => !(m?.meta?.hidden ?? false));
// Optimistic UI update
modelsToHide.forEach((m) => {
m.meta = { ...m.meta, hidden: true };
});
models = models;
// Sync with server
await Promise.all(
modelsToHide.map((model) =>
upsertModelHandler(model, { meta: { ...model.meta, hidden: true } }, false)
)
);
toast.success($i18n.t('All models are now hidden'));
await tick();
await init();
};
const downloadModels = async (models) => {
models = await Promise.all(models.map(getFullModel));
let blob = new Blob([JSON.stringify(models)], {
type: 'application/json'
});
saveAs(blob, `models-export-${Date.now()}.json`);
};
const init = async () => {
models = null;
modelsConfig = await getModelsConfig(localStorage.token);
modelOrderList = modelsConfig?.MODEL_ORDER_LIST ?? [];
defaultModelIds = (modelsConfig?.DEFAULT_MODELS ?? '').split(',').filter((id) => id);
defaultPinnedModelIds = (modelsConfig?.DEFAULT_PINNED_MODELS ?? '')
.split(',')
.filter((id) => id);
tags = await getBaseModelTags(localStorage.token);
if (selectedTag && !tags.includes(selectedTag)) {
selectedTag = '';
}
workspaceModels = await getBaseModels(localStorage.token, selectedTag);
baseModels = await getModels(localStorage.token, null, true);
const workspaceModelIds = new Set<string>(workspaceModels.map((wm: ModelListItem) => wm.id));
models = baseModels
.filter((m: ModelListItem) => !selectedTag || workspaceModelIds.has(m.id))
.map((m: ModelListItem) => {
const workspaceModel = workspaceModels.find((wm: ModelListItem) => wm.id === m.id);
if (workspaceModel) {
return {
...m,
...workspaceModel
};
} else {
return {
...m,
id: m.id,
name: m.name,
is_active: true
};
}
});
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,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
};
const saveModelOrder = async (orderedModelIds: string[]) => {
savingModelOrder = true;
const res = await setModelsConfig(localStorage.token, {
DEFAULT_MODELS: defaultModelIds.join(','),
DEFAULT_PINNED_MODELS: defaultPinnedModelIds.join(','),
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 saveModelsSettings = async () => {
savingModelsSettings = true;
if (modelOrderDirty) {
await saveModelOrder(modelOrderList);
}
if (modelDefaultsDirty) {
await modelDefaultsPanel?.save();
}
savingModelsSettings = false;
};
const saveModelDefaults = async (
nextDefaultModelIds: string[],
nextDefaultPinnedModelIds: string[],
successMessage: string
) => {
const previousDefaultModelIds = defaultModelIds;
const previousDefaultPinnedModelIds = defaultPinnedModelIds;
defaultModelIds = nextDefaultModelIds;
defaultPinnedModelIds = nextDefaultPinnedModelIds;
const res = await setModelsConfig(localStorage.token, {
DEFAULT_MODELS: nextDefaultModelIds.join(','),
DEFAULT_PINNED_MODELS: nextDefaultPinnedModelIds.join(','),
MODEL_ORDER_LIST: modelsConfig?.MODEL_ORDER_LIST ?? [],
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;
toast.success(successMessage);
} else {
defaultModelIds = previousDefaultModelIds;
defaultPinnedModelIds = previousDefaultPinnedModelIds;
}
};
const toggleDefaultModelHandler = async (model) => {
const isSelected = defaultModelIdSet.has(model.id);
const nextDefaultModelIds = isSelected
? defaultModelIds.filter((id) => id !== model.id)
: [...new Set([...defaultModelIds, model.id])];
await saveModelDefaults(
nextDefaultModelIds,
defaultPinnedModelIds,
isSelected
? $i18n.t('Model removed from selected models')
: $i18n.t('Model added to selected models')
);
};
const toggleDefaultPinnedModelHandler = async (model) => {
const isPinned = defaultPinnedModelIdSet.has(model.id);
const nextDefaultPinnedModelIds = isPinned
? defaultPinnedModelIds.filter((id) => id !== model.id)
: [...new Set([...defaultPinnedModelIds, model.id])];
await saveModelDefaults(
defaultModelIds,
nextDefaultPinnedModelIds,
isPinned
? $i18n.t('Model removed from pinned models')
: $i18n.t('Model added to pinned models')
);
};
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 };
if (workspaceModels.find((m) => m.id === model.id)) {
const res = await updateModelById(localStorage.token, model.id, model).catch((error) => {
return null;
});
if (res && showToast) {
toast.success($i18n.t('Model updated successfully'));
}
} else {
const res = await createNewModel(localStorage.token, {
meta: {},
id: model.id,
name: model.name,
base_model_id: null,
params: {},
access_grants: [],
...model
}).catch((error) => {
return null;
});
if (res && showToast) {
toast.success($i18n.t('Model updated successfully'));
await init();
}
}
};
const toggleModelHandler = async (model) => {
if (!Object.keys(model).includes('base_model_id')) {
await createNewModel(localStorage.token, {
id: model.id,
name: model.name,
base_model_id: null,
meta: {},
params: {},
access_grants: [],
is_active: model.is_active
}).catch((error) => {
return null;
});
} else {
await toggleModelById(localStorage.token, model.id);
}
// await init();
_models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
};
const hideModelHandler = async (model) => {
const updatedModel = {
...model,
meta: {
...model.meta,
hidden: !(model?.meta?.hidden ?? false)
}
};
await upsertModelHandler(updatedModel, { meta: updatedModel.meta }, false);
models = models.map((model) => (model.id === updatedModel.id ? updatedModel : model));
_models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
toast.success(
updatedModel.meta.hidden
? $i18n.t(`Model {{name}} is now hidden`, {
name: updatedModel.id
})
: $i18n.t(`Model {{name}} is now visible`, {
name: updatedModel.id
})
);
};
const toggleModelPrivacyHandler = async (model) => {
const nextAccessGrants = isPublicModel(model)
? []
: [
...(model?.access_grants ?? []),
{
principal_type: 'user',
principal_id: '*',
permission: 'read'
}
];
const res = await updateModelAccessGrants(
localStorage.token,
model.id,
model.name,
nextAccessGrants
).catch(() => null);
if (res) {
models = models.map((m) =>
m.id === model.id ? { ...m, access_grants: res.access_grants ?? nextAccessGrants } : m
);
_models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
toast.success(
isPublicModel({ access_grants: nextAccessGrants })
? $i18n.t('Model is now public')
: $i18n.t('Model is now private')
);
}
};
const copyLinkHandler = async (model) => {
const baseUrl = window.location.origin;
const res = await copyToClipboard(`${baseUrl}/?model=${encodeURIComponent(model.id)}`);
if (res) {
toast.success($i18n.t('Copied link to clipboard'));
} else {
toast.error($i18n.t('Failed to copy link'));
}
};
const getFullModel = async (model: any) =>
workspaceModels.some((workspaceModel) => workspaceModel.id === model.id)
? ((await getModelById(localStorage.token, model.id).catch(() => null)) ?? model)
: model;
const cloneHandler = async (model) => {
model = await getFullModel(model);
sessionStorage.model = JSON.stringify({
...model,
base_model_id: model.id,
id: `${model.id}-clone`,
name: `${model.name} (Clone)`
});
showSettings.set(false);
await goto('/workspace/models/create');
};
const exportModelHandler = async (model) => {
model = await getFullModel(model);
let blob = new Blob([JSON.stringify([model])], {
type: 'application/json'
});
saveAs(blob, `${model.id}-${Date.now()}.json`);
};
const pinModelHandler = async (modelId) => {
let pinnedModels = $settings?.pinnedModels ?? [];
if (pinnedModels.includes(modelId)) {
pinnedModels = pinnedModels.filter((id) => id !== modelId);
} else {
pinnedModels = [...new Set([...pinnedModels, modelId])];
}
settings.set({ ...$settings, pinnedModels: pinnedModels });
await updateUserSettings(localStorage.token, { ui: $settings });
};
onMount(async () => {
await init();
const onKeyDown = (event) => {
if (event.key === 'Shift') {
shiftKey = true;
}
};
const onKeyUp = (event) => {
if (event.key === 'Shift') {
shiftKey = false;
}
};
const onBlur = () => {
shiftKey = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
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();
}
}}
/>
<ManageModelsModal bind:show={showManageModal} />
{#if models !== null}
{#if selectedModelId === null}
<div class="flex h-full min-h-0 flex-col text-sm">
<div class="mb-2 flex items-center justify-between">
<h2 class="text-sm font-medium text-gray-900 dark:text-white">
{$i18n.t('Models')}
<span class="ml-2 font-normal text-gray-500 dark:text-gray-500">
{filteredModels.length}
</span>
</h2>
</div>
{#if $user?.role === 'admin'}
<input
id="models-import-input"
bind:this={modelsImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
if (importFiles.length > 0) {
const reader = new FileReader();
reader.onload = async (event) => {
modelsImportInProgress = true;
try {
const models = JSON.parse(String(event.target.result));
const res = await importModels(localStorage.token, models);
if (res) {
toast.success($i18n.t('Models imported successfully'));
await init();
} else {
toast.error($i18n.t('Failed to import models'));
}
} catch (e) {
toast.error(e?.detail ?? $i18n.t('Invalid JSON file'));
console.error(e);
}
modelsImportInProgress = false;
};
reader.readAsText(importFiles[0]);
}
}}
/>
{/if}
<div class="flex min-h-0 flex-1 flex-col space-y-1">
<ModelDefaultsPanel
bind:this={modelDefaultsPanel}
bind:dirty={modelDefaultsDirty}
initHandler={init}
/>
<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">
<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')}
/>
{#if searchValue}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
aria-label={$i18n.t('Clear search')}
on:click={() => {
searchValue = '';
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
<div
class="flex max-w-[60%] shrink-0 items-center gap-1 overflow-x-auto scrollbar-none"
bind:this={tagsContainerElement}
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex w-fit gap-0.5 text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<AdminViewSelector bind:value={viewOption} align="end" />
{#if (tags ?? []).length > 0}
<TagSelector
bind:value={selectedTag}
align="end"
items={tags.map((tag) => {
return { value: tag, label: tag };
})}
onChange={async () => {
await init();
}}
/>
{/if}
</div>
<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"
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">
{#if $user?.role === 'admin'}
<button
class="flex h-[1.6875rem] w-full cursor-pointer select-none items-center gap-2 rounded-xl bg-transparent px-2 text-[13px] disabled:pointer-events-none disabled:opacity-40 hover:text-gray-900 dark:hover:text-gray-100"
type="button"
disabled={modelsImportInProgress}
on:click={() => {
modelsImportInputElement?.click();
}}
>
<DocumentArrowUp className="size-3.5" />
<div class="flex items-center">{$i18n.t('Import')}</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={() => {
downloadModels(models ?? []);
}}
>
<Download className="size-3.5" />
<div class="flex items-center">{$i18n.t('Export')}</div>
</button>
{/if}
<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={() => {
showManageModal = true;
}}
>
<Wrench className="size-3.5" />
<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
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={() => {
enableAllHandler();
}}
>
<CheckCircle className="size-3.5" />
<div class="flex items-center">{$i18n.t('Enable All')}</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={() => {
disableAllHandler();
}}
>
<Minus className="size-3.5" />
<div class="flex items-center">{$i18n.t('Disable All')}</div>
</button>
<hr class="mx-1 my-0.5 border-gray-100 dark:border-gray-800" />
<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={() => {
showAllHandler();
}}
>
<Eye className="size-3.5" />
<div class="flex items-center">{$i18n.t('Show All')}</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={() => {
hideAllHandler();
}}
>
<EyeSlash className="size-3.5" />
<div class="flex items-center">{$i18n.t('Hide All')}</div>
</button>
</DropdownMenu>
</div>
</Dropdown>
</div>
</div>
<div
class="my-0.5 min-h-0 flex-1 space-y-px {filteredModels.length > 0
? 'overflow-y-auto scrollbar-hover pr-1.5'
: 'overflow-hidden'}"
id="model-list"
bind:this={modelListElement}
>
{#if filteredModels.length > 0}
{#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 -ml-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"
on:click={() => {
selectedModelId = model.id;
}}
>
<div class="self-center">
<div class="flex bg-white rounded-xl">
<div
class="{(model?.is_active ?? true)
? ''
: 'opacity-50 dark:opacity-50'} bg-transparent rounded-xl"
>
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}&lang=${$i18n.language}`}
alt="modelfile profile"
class=" rounded-xl size-7 object-cover"
loading="lazy"
decoding="async"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
</div>
</div>
</div>
<div
class="flex min-w-0 flex-1 pr-1 self-center {(model?.is_active ?? true)
? ''
: 'text-gray-500'}"
>
<Tooltip
content={marked.parse(
!!model?.meta?.description
? model?.meta?.description
: model?.ollama?.digest
? `${model?.ollama?.digest} **(${model?.ollama?.modified_at})**`
: model.id
)}
className="min-w-0 flex-1"
placement="top-start"
>
<div
class="flex min-w-0 items-center gap-1.5 text-[13px] font-normal leading-4"
>
<span class="min-w-0 truncate">{model.name}</span>
<span
class="shrink-0 text-[11px] font-normal leading-4 {modelAccessClass(
model
)}"
>
{modelAccessLabel(model)}
</span>
{#if defaultModelIdSet.has(model.id)}
<span
class="shrink-0 text-[11px] font-normal leading-4 text-gray-500 dark:text-gray-400"
>
{$i18n.t('Selected')}
</span>
{/if}
{#if defaultPinnedModelIdSet.has(model.id)}
<span
class="shrink-0 text-[11px] font-normal leading-4 text-gray-500 dark:text-gray-400"
>
{$i18n.t('Pinned')}
</span>
{/if}
</div>
</Tooltip>
</div>
</button>
<div class="flex shrink-0 flex-row gap-0.5 items-center self-center">
{#if shiftKey}
<Tooltip content={model?.meta?.hidden ? $i18n.t('Show') : $i18n.t('Hide')}>
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
hideModelHandler(model);
}}
>
{#if model?.meta?.hidden}
<EyeSlash />
{:else}
<Eye />
{/if}
</button>
</Tooltip>
<Tooltip
content={defaultModelIdSet.has(model.id)
? $i18n.t('Remove Selected Model')
: $i18n.t('Set as Selected Model')}
>
<button
class="self-center w-fit text-sm p-1.5 rounded-xl hover:bg-black/5 dark:hover:bg-white/5 {defaultModelIdSet.has(
model.id
)
? 'text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 dark:hover:text-white'}"
type="button"
aria-label={defaultModelIdSet.has(model.id)
? $i18n.t('Remove Selected Model')
: $i18n.t('Set as Selected Model')}
on:click={() => {
toggleDefaultModelHandler(model);
}}
>
<Check className="size-3.5" />
</button>
</Tooltip>
<Tooltip
content={defaultPinnedModelIdSet.has(model.id)
? $i18n.t('Remove Pinned Model')
: $i18n.t('Set as Pinned Model')}
>
<button
class="self-center w-fit text-sm p-1.5 rounded-xl hover:bg-black/5 dark:hover:bg-white/5 {defaultPinnedModelIdSet.has(
model.id
)
? 'text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 dark:hover:text-white'}"
type="button"
aria-label={defaultPinnedModelIdSet.has(model.id)
? $i18n.t('Remove Pinned Model')
: $i18n.t('Set as Pinned Model')}
on:click={() => {
toggleDefaultPinnedModelHandler(model);
}}
>
{#if defaultPinnedModelIdSet.has(model.id)}
<PinSlash className="size-3.5" />
{:else}
<Pin className="size-3.5" />
{/if}
</button>
</Tooltip>
<Tooltip
content={isPublicModel(model)
? $i18n.t('Make Private')
: $i18n.t('Make Public')}
>
<button
class="self-center w-fit text-sm p-1.5 rounded-xl text-gray-500 hover:bg-black/5 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white"
type="button"
aria-label={isPublicModel(model)
? $i18n.t('Make Private')
: $i18n.t('Make Public')}
on:click={() => {
toggleModelPrivacyHandler(model);
}}
>
{#if isPublicModel(model)}
<LockClosed className="size-3.5" />
{:else}
<GlobeAlt className="size-3.5" />
{/if}
</button>
</Tooltip>
{/if}
<button
class="hidden sm:flex self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
aria-label={$i18n.t('Edit')}
on:click={() => {
selectedModelId = model.id;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>
</button>
<ModelMenu
user={$user}
{model}
exportHandler={() => {
exportModelHandler(model);
}}
hideHandler={() => {
hideModelHandler(model);
}}
privacyHandler={() => {
toggleModelPrivacyHandler(model);
}}
isDefaultSelected={defaultModelIdSet.has(model.id)}
isDefaultPinned={defaultPinnedModelIdSet.has(model.id)}
defaultSelectedHandler={() => {
toggleDefaultModelHandler(model);
}}
defaultPinnedHandler={() => {
toggleDefaultPinnedModelHandler(model);
}}
pinModelHandler={() => {
pinModelHandler(model.id);
}}
copyLinkHandler={() => {
copyLinkHandler(model);
}}
cloneHandler={() => {
cloneHandler(model);
}}
onClose={() => {}}
>
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
>
<EllipsisHorizontal className="size-3.5" />
</button>
</ModelMenu>
<div class="ml-1">
<Tooltip
content={(model?.is_active ?? true)
? $i18n.t('Enabled')
: $i18n.t('Disabled')}
>
<Switch
bind:state={model.is_active}
on:change={async () => {
toggleModelHandler(model);
}}
/>
</Tooltip>
</div>
</div>
</div>
{/each}
{:else}
<div class="flex h-full w-full items-center justify-center py-10">
<div class="max-w-md text-center">
<div class="mb-2 text-xl">😕</div>
<div class="mb-1 text-sm text-gray-700 dark:text-gray-300">
{$i18n.t('No models found')}
</div>
<div class=" text-gray-500 text-center text-xs">
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
</div>
</div>
</div>
{/if}
</div>
<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 && !modelDefaultsDirty) ||
savingModelOrder ||
savingModelsSettings}
on:click={async () => {
await saveModelsSettings();
}}
>
{$i18n.t('Save')}
{#if savingModelOrder || savingModelsSettings}
<span class="shrink-0">
<Spinner />
</span>
{/if}
</button>
</div>
</div>
</div>
{:else}
<ModelEditor
edit
model={models.find((m) => m.id === selectedModelId)}
preset={false}
onSubmit={async (model) => {
console.log(model);
await upsertModelHandler(model);
selectedModelId = null;
await init();
}}
onBack={async () => {
selectedModelId = null;
await init();
}}
/>
{/if}
{:else}
<div class=" h-full w-full flex justify-center items-center">
<Spinner className="size-5" />
</div>
{/if}