mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-13 01:02:25 -06:00
1234 lines
37 KiB
Svelte
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}
|