1006 lines
30 KiB
Svelte
1006 lines
30 KiB
Svelte
<script lang="ts">
|
|
import { marked } from 'marked';
|
|
import Fuse from 'fuse.js';
|
|
|
|
import dayjs from '$lib/dayjs';
|
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
|
dayjs.extend(relativeTime);
|
|
|
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
|
import { flyAndScale } from '$lib/utils/transitions';
|
|
|
|
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
|
|
|
|
import { deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama';
|
|
import { unloadModel } from '$lib/apis';
|
|
|
|
import {
|
|
user,
|
|
MODEL_DOWNLOAD_POOL,
|
|
models,
|
|
temporaryChatEnabled,
|
|
settings,
|
|
config,
|
|
showSettings
|
|
} from '$lib/stores';
|
|
import { toast } from 'svelte-sonner';
|
|
import { capitalizeFirstLetter, sanitizeResponseContent, splitStream } from '$lib/utils';
|
|
import { getModels } from '$lib/apis';
|
|
|
|
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
|
import Check from '$lib/components/icons/Check.svelte';
|
|
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 ChatBubbleOval from '$lib/components/icons/ChatBubbleOval.svelte';
|
|
import Keyframes from '$lib/components/icons/Keyframes.svelte';
|
|
import TagSelector from '$lib/components/workspace/common/TagSelector.svelte';
|
|
|
|
import ModelItem from './ModelItem.svelte';
|
|
|
|
const i18n = getContext('i18n');
|
|
const dispatch = createEventDispatcher();
|
|
|
|
export let id = '';
|
|
export let value: string | null = '';
|
|
export let values: string[] | null = null;
|
|
export let compareEnabled = false;
|
|
export let multipleEnabled = false;
|
|
export let disabled = false;
|
|
export let placeholder = $i18n.t('Select a model');
|
|
export let searchEnabled = true;
|
|
export let searchPlaceholder = $i18n.t('Search a model');
|
|
export let selectionOnly = false;
|
|
export let includeHidden = false;
|
|
|
|
export let items: {
|
|
label: string;
|
|
value: string;
|
|
model: Model;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
[key: string]: any;
|
|
}[] = [];
|
|
|
|
export let className = 'w-[20rem]';
|
|
export let triggerClassName = 'text-lg';
|
|
export let placement: 'top' | 'bottom' | 'auto' = 'bottom';
|
|
export let align: 'start' | 'end' = 'start';
|
|
export let showSetDefault = false;
|
|
export let onSetDefault: () => Promise<void> | void = () => {};
|
|
|
|
export let pinModelHandler: (modelId: string) => void = () => {};
|
|
|
|
let show = false;
|
|
let triggerElement: HTMLElement | null = null;
|
|
let contentElement: HTMLElement | null = null;
|
|
let panelElement: HTMLElement | null = null;
|
|
let dropdownPosition = { top: 0, left: 0, maxHeight: undefined as number | undefined };
|
|
let positionFrame: number | undefined;
|
|
let settleTimers: number[] = [];
|
|
|
|
const portal = (node: HTMLElement) => {
|
|
document.body.appendChild(node);
|
|
return {
|
|
destroy() {
|
|
node.remove();
|
|
}
|
|
};
|
|
};
|
|
|
|
const measureContent = () => {
|
|
if (!contentElement) return { width: 0, height: 0 };
|
|
|
|
const previousMaxHeight = panelElement?.style.maxHeight;
|
|
if (panelElement) panelElement.style.maxHeight = '';
|
|
const rect = contentElement.getBoundingClientRect();
|
|
if (panelElement && previousMaxHeight !== undefined) {
|
|
panelElement.style.maxHeight = previousMaxHeight;
|
|
}
|
|
|
|
return { width: rect.width, height: rect.height };
|
|
};
|
|
|
|
const visualViewportRect = () => {
|
|
const viewport = window.visualViewport;
|
|
return {
|
|
left: viewport?.offsetLeft ?? 0,
|
|
top: viewport?.offsetTop ?? 0,
|
|
width: viewport?.width ?? window.innerWidth,
|
|
height: viewport?.height ?? window.innerHeight
|
|
};
|
|
};
|
|
|
|
const updatePosition = () => {
|
|
if (!show || !triggerElement) return;
|
|
const rect = triggerElement.getBoundingClientRect();
|
|
const { width: contentWidth, height: contentHeight } = measureContent();
|
|
const viewport = visualViewportRect();
|
|
const viewportRight = viewport.left + viewport.width;
|
|
const viewportBottom = viewport.top + viewport.height;
|
|
const pad = 8;
|
|
const gap = 2;
|
|
const spaceBelow = viewportBottom - rect.bottom - gap - pad;
|
|
const spaceAbove = rect.top - viewport.top - gap - pad;
|
|
const preferredLeft = align === 'end' && contentWidth ? rect.right - contentWidth : rect.left;
|
|
const maxLeft = contentWidth ? viewportRight - contentWidth - pad : preferredLeft;
|
|
const resolvedPlacement =
|
|
placement === 'auto'
|
|
? contentHeight && spaceBelow < contentHeight && spaceAbove > spaceBelow
|
|
? 'top'
|
|
: 'bottom'
|
|
: placement;
|
|
const availableHeight = resolvedPlacement === 'top' ? spaceAbove : spaceBelow;
|
|
const constrainedHeight =
|
|
contentHeight && availableHeight >= 0
|
|
? Math.min(contentHeight, availableHeight)
|
|
: contentHeight;
|
|
const top =
|
|
resolvedPlacement === 'top' && contentHeight
|
|
? rect.top - constrainedHeight - gap
|
|
: rect.bottom + gap;
|
|
|
|
dropdownPosition = {
|
|
top: Math.max(viewport.top + pad, Math.min(top, viewportBottom - pad - constrainedHeight)),
|
|
left: Math.max(viewport.left + pad, Math.min(preferredLeft, maxLeft)),
|
|
maxHeight:
|
|
contentHeight && availableHeight >= 0 && contentHeight > availableHeight
|
|
? Math.max(0, availableHeight)
|
|
: undefined
|
|
};
|
|
};
|
|
|
|
const schedulePositionUpdate = () => {
|
|
if (positionFrame != null) cancelAnimationFrame(positionFrame);
|
|
positionFrame = requestAnimationFrame(() => {
|
|
positionFrame = undefined;
|
|
updatePosition();
|
|
});
|
|
};
|
|
|
|
const scheduleSettledPositionUpdates = () => {
|
|
for (const timer of settleTimers) window.clearTimeout(timer);
|
|
settleTimers = [];
|
|
schedulePositionUpdate();
|
|
for (const delay of [50, 150, 300]) {
|
|
settleTimers.push(window.setTimeout(schedulePositionUpdate, delay));
|
|
}
|
|
};
|
|
|
|
const handleScroll = (event: Event) => {
|
|
if (event.target instanceof Node && contentElement?.contains(event.target)) return;
|
|
schedulePositionUpdate();
|
|
};
|
|
|
|
const toggleOpen = async () => {
|
|
show = !show;
|
|
if (show) {
|
|
searchValue = '';
|
|
listScrollTop = 0;
|
|
resetView();
|
|
updatePosition();
|
|
await tick();
|
|
updatePosition();
|
|
window.setTimeout(() => document.getElementById('model-search-input')?.focus(), 0);
|
|
} else {
|
|
document.getElementById(`model-selector-${id}-button`)?.blur();
|
|
}
|
|
};
|
|
|
|
const handlePointerDown = (e: PointerEvent) => {
|
|
if (!show) return;
|
|
const target = e.target as Node;
|
|
if (
|
|
(triggerElement && triggerElement.contains(target)) ||
|
|
(contentElement && contentElement.contains(target)) ||
|
|
((target as HTMLElement).closest?.('.model-selector-child-menu') ?? false)
|
|
) {
|
|
return;
|
|
}
|
|
show = false;
|
|
document.getElementById(`model-selector-${id}-button`)?.blur();
|
|
};
|
|
|
|
const handleKeydown = (e: KeyboardEvent) => {
|
|
if (show && e.key === 'Escape') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
show = false;
|
|
document.getElementById(`model-selector-${id}-button`)?.blur();
|
|
}
|
|
};
|
|
|
|
let tags = [];
|
|
|
|
let selectedModel = '';
|
|
$: selectedValues = values ?? (value ? [value] : []);
|
|
$: primaryValue = selectedValues[0] ?? value ?? '';
|
|
$: selectedModel = items.find((item) => item.value === primaryValue) ?? '';
|
|
$: selectedCount = selectedValues.filter(Boolean).length;
|
|
$: triggerLabel = selectedModel
|
|
? compareEnabled && selectedCount > 1
|
|
? `${selectedModel.label} +${selectedCount - 1}`
|
|
: selectedModel.label
|
|
: placeholder;
|
|
|
|
let searchValue = '';
|
|
|
|
let selectedTag = '';
|
|
let selectedConnectionType = '';
|
|
let selectedFilter = '';
|
|
let modelFilterItems = [];
|
|
|
|
let ollamaVersion = null;
|
|
let selectedModelIdx = 0;
|
|
|
|
const fuse = new Fuse(
|
|
items.map((item) => {
|
|
const _item = {
|
|
...item,
|
|
modelName: item.model?.name,
|
|
tags: (item.model?.tags ?? []).map((tag) => tag.name).join(' '),
|
|
desc: item.model?.info?.meta?.description
|
|
};
|
|
return _item;
|
|
}),
|
|
{
|
|
keys: ['value', 'tags', 'modelName'],
|
|
threshold: 0.4
|
|
}
|
|
);
|
|
|
|
const updateFuse = () => {
|
|
if (fuse) {
|
|
fuse.setCollection(
|
|
items.map((item) => {
|
|
const _item = {
|
|
...item,
|
|
modelName: item.model?.name,
|
|
tags: (item.model?.tags ?? []).map((tag) => tag.name).join(' '),
|
|
desc: item.model?.info?.meta?.description
|
|
};
|
|
return _item;
|
|
})
|
|
);
|
|
}
|
|
};
|
|
|
|
$: if (items) {
|
|
updateFuse();
|
|
}
|
|
|
|
$: filteredItems = (
|
|
searchValue
|
|
? fuse
|
|
.search(searchValue)
|
|
.map((e) => {
|
|
return e.item;
|
|
})
|
|
.filter((item) => {
|
|
if (selectedTag === '') {
|
|
return true;
|
|
}
|
|
|
|
return (item.model?.tags ?? [])
|
|
.map((tag) => tag.name.toLowerCase())
|
|
.includes(selectedTag.toLowerCase());
|
|
})
|
|
.filter((item) => {
|
|
if (selectedConnectionType === '') {
|
|
return true;
|
|
} else if (selectedConnectionType === 'local') {
|
|
return item.model?.connection_type === 'local';
|
|
} else if (selectedConnectionType === 'external') {
|
|
return item.model?.connection_type === 'external';
|
|
} else if (selectedConnectionType === 'direct') {
|
|
return item.model?.direct;
|
|
}
|
|
})
|
|
: items
|
|
.filter((item) => {
|
|
if (selectedTag === '') {
|
|
return true;
|
|
}
|
|
return (item.model?.tags ?? [])
|
|
.map((tag) => tag.name.toLowerCase())
|
|
.includes(selectedTag.toLowerCase());
|
|
})
|
|
.filter((item) => {
|
|
if (selectedConnectionType === '') {
|
|
return true;
|
|
} else if (selectedConnectionType === 'local') {
|
|
return item.model?.connection_type === 'local';
|
|
} else if (selectedConnectionType === 'external') {
|
|
return item.model?.connection_type === 'external';
|
|
} else if (selectedConnectionType === 'direct') {
|
|
return item.model?.direct;
|
|
}
|
|
})
|
|
).filter((item) => includeHidden || !(item.model?.info?.meta?.hidden ?? false));
|
|
|
|
$: if (
|
|
selectedTag !== undefined ||
|
|
selectedConnectionType !== undefined ||
|
|
searchValue !== undefined
|
|
) {
|
|
resetView();
|
|
}
|
|
|
|
$: modelFilterItems = [
|
|
...(items.find((item) => item.model?.connection_type === 'local')
|
|
? [{ value: 'connection:local', label: $i18n.t('Local') }]
|
|
: []),
|
|
...(items.find((item) => item.model?.connection_type === 'external')
|
|
? [{ value: 'connection:external', label: $i18n.t('External') }]
|
|
: []),
|
|
...(items.find((item) => item.model?.direct)
|
|
? [{ value: 'connection:direct', label: $i18n.t('Direct') }]
|
|
: []),
|
|
...tags.map((tag) => ({ value: `tag:${tag}`, label: tag }))
|
|
];
|
|
|
|
$: selectedFilter = selectedConnectionType
|
|
? `connection:${selectedConnectionType}`
|
|
: selectedTag
|
|
? `tag:${selectedTag}`
|
|
: '';
|
|
|
|
const setModelFilter = (filterValue: string) => {
|
|
if (!filterValue) {
|
|
selectedConnectionType = '';
|
|
selectedTag = '';
|
|
} else if (filterValue.startsWith('connection:')) {
|
|
selectedConnectionType = filterValue.replace('connection:', '');
|
|
selectedTag = '';
|
|
} else if (filterValue.startsWith('tag:')) {
|
|
selectedConnectionType = '';
|
|
selectedTag = filterValue.replace('tag:', '');
|
|
}
|
|
};
|
|
|
|
const resetView = async () => {
|
|
await tick();
|
|
|
|
const selectedInFiltered = filteredItems.findIndex((item) => item.value === primaryValue);
|
|
|
|
if (selectedInFiltered >= 0) {
|
|
// The selected model is visible in the current filter
|
|
selectedModelIdx = selectedInFiltered;
|
|
} else {
|
|
// The selected model is not visible, default to first item in filtered list
|
|
selectedModelIdx = 0;
|
|
}
|
|
|
|
// Set the virtual scroll position so the selected item is rendered and centered
|
|
const targetScrollTop = Math.max(0, selectedModelIdx * ITEM_HEIGHT - 128 + ITEM_HEIGHT / 2);
|
|
listScrollTop = targetScrollTop;
|
|
|
|
await tick();
|
|
|
|
if (listContainer) {
|
|
listContainer.scrollTop = targetScrollTop;
|
|
}
|
|
|
|
await tick();
|
|
const item = document.querySelector(`[data-arrow-selected="true"]`);
|
|
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
|
|
};
|
|
|
|
const setCompareEnabled = (enabled: boolean) => {
|
|
compareEnabled = enabled;
|
|
|
|
if (!enabled && values) {
|
|
values = [primaryValue || selectedValues[0] || ''];
|
|
value = values[0];
|
|
}
|
|
};
|
|
|
|
const selectItem = (item, index: number) => {
|
|
selectedModelIdx = index;
|
|
|
|
if (values) {
|
|
if (compareEnabled) {
|
|
const nextValues = selectedValues.includes(item.value)
|
|
? selectedValues.length > 1
|
|
? selectedValues.filter((selectedValue) => selectedValue !== item.value)
|
|
: selectedValues
|
|
: [...selectedValues.filter(Boolean), item.value];
|
|
|
|
values = nextValues.length ? nextValues : [item.value];
|
|
value = values[0];
|
|
return;
|
|
}
|
|
|
|
values = [item.value];
|
|
value = item.value;
|
|
show = false;
|
|
return;
|
|
}
|
|
|
|
value = item.value;
|
|
show = false;
|
|
};
|
|
|
|
const setDefaultHandler = async () => {
|
|
await onSetDefault();
|
|
};
|
|
|
|
const pullModelHandler = async () => {
|
|
const sanitizedModelTag = searchValue.trim().replace(/^ollama\s+(run|pull)\s+/, '');
|
|
|
|
console.log($MODEL_DOWNLOAD_POOL);
|
|
if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag]) {
|
|
toast.error(
|
|
$i18n.t(`Model '{{modelTag}}' is already in queue for downloading.`, {
|
|
modelTag: sanitizedModelTag
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
if (Object.keys($MODEL_DOWNLOAD_POOL).length === 3) {
|
|
toast.error(
|
|
$i18n.t('Maximum of 3 models can be downloaded simultaneously. Please try again later.')
|
|
);
|
|
return;
|
|
}
|
|
|
|
const [res, controller] = await pullModel(localStorage.token, sanitizedModelTag, '0').catch(
|
|
(error) => {
|
|
toast.error(`${error}`);
|
|
return null;
|
|
}
|
|
);
|
|
|
|
if (res) {
|
|
const reader = res.body
|
|
.pipeThrough(new TextDecoderStream())
|
|
.pipeThrough(splitStream('\n'))
|
|
.getReader();
|
|
|
|
MODEL_DOWNLOAD_POOL.set({
|
|
...$MODEL_DOWNLOAD_POOL,
|
|
[sanitizedModelTag]: {
|
|
...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
|
|
abortController: controller,
|
|
reader,
|
|
done: false
|
|
}
|
|
});
|
|
|
|
while (true) {
|
|
try {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
|
|
let lines = value.split('\n');
|
|
|
|
for (const line of lines) {
|
|
if (line !== '') {
|
|
let data = JSON.parse(line);
|
|
console.log(data);
|
|
if (data.error) {
|
|
throw data.error;
|
|
}
|
|
if (data.detail) {
|
|
throw data.detail;
|
|
}
|
|
|
|
if (data.status) {
|
|
if (data.digest) {
|
|
let downloadProgress = 0;
|
|
if (data.completed) {
|
|
downloadProgress = Math.round((data.completed / data.total) * 1000) / 10;
|
|
} else {
|
|
downloadProgress = 100;
|
|
}
|
|
|
|
MODEL_DOWNLOAD_POOL.set({
|
|
...$MODEL_DOWNLOAD_POOL,
|
|
[sanitizedModelTag]: {
|
|
...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
|
|
pullProgress: downloadProgress,
|
|
digest: data.digest
|
|
}
|
|
});
|
|
} else {
|
|
toast.success(data.status);
|
|
|
|
MODEL_DOWNLOAD_POOL.set({
|
|
...$MODEL_DOWNLOAD_POOL,
|
|
[sanitizedModelTag]: {
|
|
...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
|
|
done: data.status === 'success'
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log(error);
|
|
if (typeof error !== 'string') {
|
|
error = error.message;
|
|
}
|
|
|
|
toast.error(`${error}`);
|
|
// opts.callback({ success: false, error, modelName: opts.modelName });
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag].done) {
|
|
toast.success(
|
|
$i18n.t(`Model '{{modelName}}' has been successfully downloaded.`, {
|
|
modelName: sanitizedModelTag
|
|
})
|
|
);
|
|
|
|
models.set(
|
|
await getModels(
|
|
localStorage.token,
|
|
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
|
|
)
|
|
);
|
|
} else {
|
|
toast.error($i18n.t('Download canceled'));
|
|
}
|
|
|
|
delete $MODEL_DOWNLOAD_POOL[sanitizedModelTag];
|
|
|
|
MODEL_DOWNLOAD_POOL.set({
|
|
...$MODEL_DOWNLOAD_POOL
|
|
});
|
|
}
|
|
};
|
|
|
|
const setOllamaVersion = async () => {
|
|
ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false);
|
|
};
|
|
|
|
onMount(() => {
|
|
if (items) {
|
|
tags = items
|
|
.filter((item) => includeHidden || !(item.model?.info?.meta?.hidden ?? false))
|
|
.flatMap((item) => item.model?.tags ?? [])
|
|
.map((tag) => tag.name.toLowerCase());
|
|
// Remove duplicates and sort
|
|
tags = Array.from(new Set(tags)).sort((a, b) => a.localeCompare(b));
|
|
}
|
|
|
|
window.addEventListener('scroll', handleScroll, true);
|
|
window.visualViewport?.addEventListener('resize', scheduleSettledPositionUpdates);
|
|
window.visualViewport?.addEventListener('scroll', schedulePositionUpdate);
|
|
|
|
return () => {
|
|
if (positionFrame != null) cancelAnimationFrame(positionFrame);
|
|
for (const timer of settleTimers) window.clearTimeout(timer);
|
|
window.removeEventListener('scroll', handleScroll, true);
|
|
window.visualViewport?.removeEventListener('resize', scheduleSettledPositionUpdates);
|
|
window.visualViewport?.removeEventListener('scroll', schedulePositionUpdate);
|
|
};
|
|
});
|
|
|
|
$: if (show && !selectionOnly) {
|
|
setOllamaVersion();
|
|
}
|
|
|
|
const cancelModelPullHandler = async (model: string) => {
|
|
const { reader, abortController } = $MODEL_DOWNLOAD_POOL[model];
|
|
if (abortController) {
|
|
abortController.abort();
|
|
}
|
|
if (reader) {
|
|
await reader.cancel();
|
|
delete $MODEL_DOWNLOAD_POOL[model];
|
|
MODEL_DOWNLOAD_POOL.set({
|
|
...$MODEL_DOWNLOAD_POOL
|
|
});
|
|
await deleteModel(localStorage.token, model);
|
|
toast.success($i18n.t('{{model}} download has been canceled', { model: model }));
|
|
}
|
|
};
|
|
|
|
const unloadModelHandler = async (model: string) => {
|
|
const res = await unloadModel(localStorage.token, model).catch((error) => {
|
|
toast.error($i18n.t('Error unloading model: {{error}}', { error }));
|
|
});
|
|
|
|
if (res) {
|
|
toast.success($i18n.t('Model unloaded successfully'));
|
|
models.set(
|
|
await getModels(
|
|
localStorage.token,
|
|
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
|
|
)
|
|
);
|
|
}
|
|
};
|
|
|
|
let showDeleteConfirm = false;
|
|
let deleteModelTarget: any = null;
|
|
|
|
const deleteModelHandler = async (model: any) => {
|
|
deleteModelTarget = model;
|
|
showDeleteConfirm = true;
|
|
};
|
|
|
|
const confirmDeleteModel = async () => {
|
|
const model = deleteModelTarget;
|
|
if (!model) return;
|
|
|
|
const res = await deleteModel(localStorage.token, model.id).catch((error) => {
|
|
toast.error($i18n.t('Error deleting model: {{error}}', { error }));
|
|
});
|
|
|
|
if (res) {
|
|
// $i18n.t('Model {{modelId}} not found')
|
|
toast.success(
|
|
$i18n.t('Model {{modelName}} deleted successfully', { modelName: model.name ?? model.id })
|
|
);
|
|
|
|
// If the deleted model was selected, clear the selection
|
|
if (value === model.id) {
|
|
value = '';
|
|
}
|
|
|
|
models.set(
|
|
await getModels(
|
|
localStorage.token,
|
|
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
|
|
)
|
|
);
|
|
}
|
|
|
|
deleteModelTarget = null;
|
|
};
|
|
|
|
const ITEM_HEIGHT = 32;
|
|
const OVERSCAN = 10;
|
|
|
|
let listScrollTop = 0;
|
|
let listContainer;
|
|
let listViewportHeight = 288;
|
|
|
|
const trackListViewport = (node: HTMLElement) => {
|
|
const updateHeight = () => {
|
|
listViewportHeight = node.clientHeight || 288;
|
|
};
|
|
|
|
updateHeight();
|
|
|
|
if (!('ResizeObserver' in window)) {
|
|
return { destroy() {} };
|
|
}
|
|
|
|
const observer = new ResizeObserver(updateHeight);
|
|
observer.observe(node);
|
|
|
|
return {
|
|
destroy() {
|
|
observer.disconnect();
|
|
}
|
|
};
|
|
};
|
|
|
|
$: visibleStart = Math.max(0, Math.floor(listScrollTop / ITEM_HEIGHT) - OVERSCAN);
|
|
$: visibleEnd = Math.min(
|
|
filteredItems.length,
|
|
Math.ceil((listScrollTop + listViewportHeight) / ITEM_HEIGHT) + OVERSCAN
|
|
);
|
|
</script>
|
|
|
|
<ConfirmDialog
|
|
bind:show={showDeleteConfirm}
|
|
title={$i18n.t('Delete Model')}
|
|
message={$i18n.t('Are you sure you want to delete **{{modelName}}**?', {
|
|
modelName: deleteModelTarget?.name ?? deleteModelTarget?.id ?? ''
|
|
})}
|
|
on:confirm={() => {
|
|
confirmDeleteModel();
|
|
}}
|
|
/>
|
|
|
|
<svelte:window
|
|
on:pointerdown={handlePointerDown}
|
|
on:keydown={handleKeydown}
|
|
on:resize={scheduleSettledPositionUpdates}
|
|
/>
|
|
|
|
<div class="relative w-full">
|
|
<button
|
|
bind:this={triggerElement}
|
|
class="relative w-full {($settings?.highContrastMode ?? false)
|
|
? ''
|
|
: 'outline-hidden focus:outline-hidden'}"
|
|
aria-label={selectedModel
|
|
? $i18n.t('Selected model: {{modelName}}', { modelName: triggerLabel })
|
|
: placeholder}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={show}
|
|
id="model-selector-{id}-button"
|
|
type="button"
|
|
{disabled}
|
|
on:click={toggleOpen}
|
|
>
|
|
<div
|
|
class="flex w-full min-w-0 text-left px-0.5 bg-transparent {triggerClassName} justify-between {($settings?.highContrastMode ??
|
|
false)
|
|
? 'dark:placeholder-gray-100 placeholder-gray-800'
|
|
: 'placeholder-gray-400'}"
|
|
on:mouseenter={async () => {
|
|
models.set(
|
|
await getModels(
|
|
localStorage.token,
|
|
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
|
|
)
|
|
);
|
|
}}
|
|
>
|
|
<span class="min-w-0 flex-1 truncate">{triggerLabel}</span>
|
|
<ChevronDown className="ml-1 size-2.5 shrink-0 self-center" strokeWidth="2.5" />
|
|
</div>
|
|
</button>
|
|
|
|
{#if show}
|
|
<div
|
|
use:portal
|
|
bind:this={contentElement}
|
|
style="position: fixed; z-index: 9999; top: {dropdownPosition.top}px; left: {dropdownPosition.left}px;"
|
|
>
|
|
<div
|
|
bind:this={panelElement}
|
|
class="z-40 {className} max-w-[calc(100vw-1rem)] justify-start rounded-xl border border-gray-100 bg-white p-0.5 shadow-lg outline-hidden dark:border-gray-800 dark:bg-gray-850 dark:text-white flex flex-col overflow-hidden"
|
|
style={dropdownPosition.maxHeight ? `max-height: ${dropdownPosition.maxHeight}px;` : ''}
|
|
transition:flyAndScale
|
|
>
|
|
<slot>
|
|
{#if searchEnabled}
|
|
<div class="my-0.5 flex h-[1.6875rem] shrink-0 items-center gap-2">
|
|
<Search className="ml-2 size-3.5 shrink-0" strokeWidth="2" />
|
|
|
|
<input
|
|
id="model-search-input"
|
|
bind:value={searchValue}
|
|
class="w-full bg-transparent text-[13px] font-normal outline-hidden placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
|
placeholder={searchPlaceholder}
|
|
autocomplete="off"
|
|
aria-label={$i18n.t('Search In Models')}
|
|
on:keydown={(e) => {
|
|
if (e.code === 'Enter' && filteredItems.length > 0) {
|
|
selectItem(filteredItems[selectedModelIdx], selectedModelIdx);
|
|
return; // dont need to scroll on selection
|
|
} else if (e.code === 'ArrowDown') {
|
|
e.stopPropagation();
|
|
selectedModelIdx = Math.min(selectedModelIdx + 1, filteredItems.length - 1);
|
|
} else if (e.code === 'ArrowUp') {
|
|
e.stopPropagation();
|
|
selectedModelIdx = Math.max(selectedModelIdx - 1, 0);
|
|
} else {
|
|
// if the user types something, reset to the top selection.
|
|
selectedModelIdx = 0;
|
|
}
|
|
|
|
const item = document.querySelector(`[data-arrow-selected="true"]`);
|
|
item?.scrollIntoView({
|
|
block: 'center',
|
|
inline: 'nearest',
|
|
behavior: 'instant'
|
|
});
|
|
}}
|
|
/>
|
|
|
|
{#if modelFilterItems.length > 0 || (multipleEnabled && items.length > 0)}
|
|
<div class="flex min-w-0 shrink-0 items-center gap-0.5">
|
|
{#if multipleEnabled && items.length > 0}
|
|
<Tooltip content={$i18n.t('Compare')}>
|
|
<button
|
|
type="button"
|
|
class="flex size-[1.375rem] shrink-0 items-center justify-center rounded-lg transition-colors duration-100 {compareEnabled
|
|
? 'bg-gray-50 text-gray-700 hover:bg-gray-50 dark:bg-gray-800/60 dark:text-gray-200 dark:hover:bg-gray-800/60'
|
|
: 'text-gray-500 hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800/40 dark:hover:text-gray-200'}"
|
|
aria-label={$i18n.t('Compare')}
|
|
aria-pressed={compareEnabled}
|
|
on:click={() => {
|
|
setCompareEnabled(!compareEnabled);
|
|
}}
|
|
>
|
|
<Keyframes className="size-3" strokeWidth="2" />
|
|
</button>
|
|
</Tooltip>
|
|
{/if}
|
|
|
|
{#if modelFilterItems.length > 0}
|
|
<TagSelector
|
|
bind:value={selectedFilter}
|
|
placeholder={$i18n.t('All')}
|
|
align="end"
|
|
items={modelFilterItems}
|
|
triggerClass="relative flex h-[1.375rem] max-w-32 items-center gap-0.5 rounded-xl bg-transparent px-1.5 text-[11px] font-normal text-gray-400 transition-colors duration-100 hover:bg-gray-50/40 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-gray-800/40 dark:hover:text-gray-300"
|
|
itemClass="flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[13px] capitalize hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100"
|
|
contentClass="min-w-36 model-selector-child-menu"
|
|
onChange={setModelFilter}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="group relative flex min-h-0 flex-1 flex-col">
|
|
{#if filteredItems.length === 0}
|
|
{#if items.length === 0 && $user?.role === 'admin'}
|
|
<div
|
|
class="my-2 flex w-full flex-col items-start justify-center px-4 py-3 text-start"
|
|
>
|
|
<div
|
|
class="mb-0.5 text-xs font-normal leading-4 text-gray-800 dark:text-gray-100"
|
|
>
|
|
{$i18n.t('No models available')}
|
|
</div>
|
|
<div class="w-full text-[11px] leading-3.5 text-gray-500 dark:text-gray-400">
|
|
{$i18n.t('Connect to an AI provider to start chatting')}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="mt-3 rounded-lg px-0 py-1 text-[11px] font-normal leading-none text-gray-600 underline-offset-2 transition-colors duration-100 hover:text-gray-800 hover:underline focus:outline-hidden focus:underline dark:text-gray-300 dark:hover:text-gray-100"
|
|
on:click={() => {
|
|
show = false;
|
|
showSettings.set('admin:connections');
|
|
}}
|
|
>
|
|
{$i18n.t('Manage Connections')}
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<div class="">
|
|
<div class="block px-2 py-1 text-[13px] text-gray-700 dark:text-gray-100">
|
|
{$i18n.t('No results found')}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
<div
|
|
class="min-h-0 flex-1 overflow-y-auto"
|
|
style="max-height: 288px;"
|
|
role="listbox"
|
|
aria-label={$i18n.t('Available models')}
|
|
bind:this={listContainer}
|
|
use:trackListViewport
|
|
on:scroll={() => {
|
|
listScrollTop = listContainer.scrollTop;
|
|
}}
|
|
>
|
|
<div style="height: {visibleStart * ITEM_HEIGHT}px;" />
|
|
{#each filteredItems.slice(visibleStart, visibleEnd) as item, i (item.value)}
|
|
{@const index = visibleStart + i}
|
|
<ModelItem
|
|
{selectedModelIdx}
|
|
{item}
|
|
{index}
|
|
value={primaryValue}
|
|
{pinModelHandler}
|
|
{unloadModelHandler}
|
|
{deleteModelHandler}
|
|
{selectionOnly}
|
|
{compareEnabled}
|
|
{selectedValues}
|
|
onClick={() => {
|
|
selectItem(item, index);
|
|
}}
|
|
/>
|
|
{/each}
|
|
<div style="height: {(filteredItems.length - visibleEnd) * ITEM_HEIGHT}px;" />
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !selectionOnly && !(searchValue.trim() in $MODEL_DOWNLOAD_POOL) && searchValue && ollamaVersion && $user?.role === 'admin'}
|
|
<Tooltip
|
|
content={$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, {
|
|
searchValue: searchValue
|
|
})}
|
|
placement="top-start"
|
|
>
|
|
<button
|
|
class="flex h-[1.6875rem] w-full cursor-pointer select-none items-center rounded-xl px-2 text-[13px] font-normal text-gray-700 outline-hidden transition-colors duration-75 hover:bg-gray-50/40 dark:text-gray-100 dark:hover:bg-gray-800/40"
|
|
on:click={() => {
|
|
pullModelHandler();
|
|
}}
|
|
>
|
|
<div class=" truncate">
|
|
{$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, {
|
|
searchValue: searchValue
|
|
})}
|
|
</div>
|
|
</button>
|
|
</Tooltip>
|
|
{/if}
|
|
|
|
{#each selectionOnly ? [] : Object.keys($MODEL_DOWNLOAD_POOL) as model}
|
|
<div
|
|
class="flex min-h-[1.6875rem] w-full cursor-pointer select-none justify-between rounded-xl px-2 text-[13px] font-normal text-gray-700 outline-hidden transition-colors duration-75 dark:text-gray-100"
|
|
>
|
|
<div class="flex">
|
|
<div class="mr-2.5 translate-y-0.5">
|
|
<Spinner />
|
|
</div>
|
|
|
|
<div class="flex flex-col self-start">
|
|
<div class="flex gap-1">
|
|
<div class="line-clamp-1">
|
|
Downloading "{model}"
|
|
</div>
|
|
|
|
<div class="shrink-0">
|
|
{'pullProgress' in $MODEL_DOWNLOAD_POOL[model]
|
|
? `(${$MODEL_DOWNLOAD_POOL[model].pullProgress}%)`
|
|
: ''}
|
|
</div>
|
|
</div>
|
|
|
|
{#if 'digest' in $MODEL_DOWNLOAD_POOL[model] && $MODEL_DOWNLOAD_POOL[model].digest}
|
|
<div class="-mt-1 h-fit text-[0.7rem] dark:text-gray-500 line-clamp-1">
|
|
{$MODEL_DOWNLOAD_POOL[model].digest}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mr-2 ml-1 translate-y-0.5">
|
|
<Tooltip content={$i18n.t('Cancel')}>
|
|
<button
|
|
class="text-gray-800 dark:text-gray-100"
|
|
aria-label={$i18n.t('Cancel download of {{model}}', { model: model })}
|
|
on:click={() => {
|
|
cancelModelPullHandler(model);
|
|
}}
|
|
>
|
|
<svg
|
|
class="w-4 h-4 text-gray-800 dark:text-white"
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
fill="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke="currentColor"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18 17.94 6M18 18 6.06 6"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if showSetDefault}
|
|
<div class="flex shrink-0 items-center justify-end px-2 py-1 leading-none">
|
|
<button
|
|
type="button"
|
|
class="text-[0.65rem] font-normal leading-none text-gray-500 underline-offset-2 transition-colors duration-100 hover:text-gray-700 hover:underline dark:text-gray-500 dark:hover:text-gray-300"
|
|
on:click|stopPropagation={setDefaultHandler}
|
|
>
|
|
{$i18n.t('Set as default')}
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<div class="shrink-0 pb-1"></div>
|
|
{/if}
|
|
|
|
<div class="hidden w-[42rem]" />
|
|
<div class="hidden w-[28rem]" />
|
|
<div class="hidden w-[24rem]" />
|
|
<div class="hidden w-[22rem]" />
|
|
<div class="hidden w-[20rem]" />
|
|
</slot>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|