This commit is contained in:
Timothy Jaeryang Baek
2026-08-13 00:34:10 -06:00
parent 8260d527ee
commit 25802c048e
3 changed files with 378 additions and 106 deletions

View File

@@ -26,7 +26,7 @@
export let selectedValues: string[] = [];
export let compareEnabled = false;
export let unloadModelHandler: (modelValue: string) => void = () => {};
export let unloadModelHandler: (model: any) => void = () => {};
export let pinModelHandler: (modelId: string) => void = () => {};
export let deleteModelHandler: (model: any) => void = () => {};
export let selectionOnly = false;
@@ -44,6 +44,8 @@
}
};
const formatSize = (size?: number) => (size ? `(${(size / 1024 ** 3).toFixed(1)}GB)` : '');
let showMenu = false;
$: isSelected = compareEnabled ? selectedValues.includes(item.value) : value === item.value;
</script>
@@ -128,6 +130,23 @@
</Tooltip>
</div>
{/if}
{:else if item.model.provider === 'lmstudio' || item.model.provider === 'llama.cpp'}
{@const parameterSize = item.model.params_string ?? item.model.details?.parameter_size ?? ''}
{@const quantization =
item.model.quantization?.name ?? item.model.details?.quantization_level ?? ''}
{@const size = item.model.size_bytes ?? item.model.size}
{#if parameterSize || quantization || size}
<div class="flex items-center translate-y-[0.5px]">
<Tooltip
content={`${quantization ? `${quantization} ` : ''}${formatSize(size)}`}
className="self-end"
>
<span class="line-clamp-1 text-[0.6875rem] font-normal text-gray-500 dark:text-gray-400">
{parameterSize || quantization || formatSize(size)}
</span>
</Tooltip>
</div>
{/if}
{/if}
{#if item.model.loaded}
@@ -256,7 +275,7 @@
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
unloadModelHandler(item.value);
unloadModelHandler(item.model);
}}
>
<ArrowUpTray className="size-3" />

View File

@@ -22,6 +22,8 @@
export let deleteModelHandler: Function = () => {};
export let onClose: Function = () => {};
const providerSupportsDelete = (provider = '') => provider === 'llama.cpp';
</script>
<Dropdown
@@ -66,7 +68,7 @@
<div class="flex items-center">{$i18n.t('Edit')}</div>
</button>
{#if $user?.role === 'admin' && model?.owned_by === 'ollama'}
{#if $user?.role === 'admin' && (model?.owned_by === 'ollama' || providerSupportsDelete(model?.provider))}
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"

View File

@@ -15,6 +15,12 @@
import { deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama';
import { deleteModelById } from '$lib/apis/models';
import { unloadModel } from '$lib/apis';
import {
downloadProviderModel,
getErrorMessage,
getOpenAIConfig,
getProviderModelDownloadStatus
} from '$lib/apis/openai';
import {
user,
@@ -32,6 +38,7 @@
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Check from '$lib/components/icons/Check.svelte';
import Download from '$lib/components/icons/Download.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';
@@ -190,6 +197,10 @@
if (show) {
searchValue = '';
listScrollTop = 0;
if (!selectionOnly) {
setOllamaVersion();
setProviderDownloadConnections();
}
resetView();
updatePosition();
await tick();
@@ -252,8 +263,27 @@
let modelFilterItems = [];
let ollamaVersion = null;
let providerDownloadConnections = [];
let selectedModelIdx = 0;
const MANAGEMENT_PROVIDERS = new Set(['llama.cpp', 'lmstudio']);
const normalizeProvider = (provider = '') => {
const value = provider.trim().toLowerCase();
if (value === 'lm studio' || value === 'lm-studio') return 'lmstudio';
return value;
};
const getProviderLabel = (provider = '') => {
const normalizedProvider = normalizeProvider(provider);
if (normalizedProvider === 'lmstudio') return $i18n.t('LM Studio');
if (normalizedProvider === 'llama.cpp') return $i18n.t('llama.cpp');
return provider;
};
const getProviderPoolKey = (connection, model: string) =>
`${connection.provider}:${connection.idx}:${model}`;
const fuse = new Fuse(
items.map((item) => {
const _item = {
@@ -339,13 +369,41 @@
})
).filter((item) => includeHidden || !(item.model?.info?.meta?.hidden ?? false));
$: showPullModelButton = !!(
!selectionOnly &&
!(searchValue.trim() in $MODEL_DOWNLOAD_POOL) &&
searchValue &&
ollamaVersion &&
$user?.role === 'admin'
);
$: sanitizedSearchValue = searchValue.trim();
$: downloadTargets = !selectionOnly && sanitizedSearchValue && $user?.role === 'admin'
? [
...(ollamaVersion
? [
{
id: 'ollama',
label: $i18n.t('Ollama'),
poolKey: sanitizedSearchValue,
download: $MODEL_DOWNLOAD_POOL[sanitizedSearchValue],
actionLabel: $i18n.t(`Pull "{{searchValue}}" from Ollama.com`, {
searchValue: searchValue
}),
type: 'ollama'
}
]
: []),
...providerDownloadConnections.map((connection) => {
const poolKey = getProviderPoolKey(connection, sanitizedSearchValue);
return {
...connection,
id: `${connection.provider}:${connection.idx}`,
label: getProviderLabel(connection.provider),
poolKey,
download: $MODEL_DOWNLOAD_POOL[poolKey],
actionLabel: $i18n.t(`Download "{{searchValue}}" from {{provider}}`, {
searchValue: searchValue,
provider: getProviderLabel(connection.provider)
}),
type: 'provider'
};
})
]
: [];
$: activeDownloadKeys = new Set(downloadTargets.filter((target) => target.download).map((target) => target.poolKey));
$: if (
selectedTag !== undefined ||
@@ -491,13 +549,14 @@
MODEL_DOWNLOAD_POOL.set({
...$MODEL_DOWNLOAD_POOL,
[sanitizedModelTag]: {
...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
abortController: controller,
reader,
done: false
}
});
[sanitizedModelTag]: {
...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
abortController: controller,
reader,
model: sanitizedModelTag,
done: false
}
});
while (true) {
try {
@@ -554,7 +613,7 @@
error = error.message;
}
toast.error(`${error}`);
toast.error(getErrorMessage(error));
// opts.callback({ success: false, error, modelName: opts.modelName });
break;
}
@@ -589,6 +648,157 @@
ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false);
};
const downloadProviderModelHandler = async (connection) => {
const model = sanitizedSearchValue;
const poolKey = getProviderPoolKey(connection, model);
if ($MODEL_DOWNLOAD_POOL[poolKey]) {
toast.error(
$i18n.t(`Model '{{modelTag}}' is already in queue for downloading.`, {
modelTag: model
})
);
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 controller = new AbortController();
MODEL_DOWNLOAD_POOL.set({
...$MODEL_DOWNLOAD_POOL,
[poolKey]: {
abortController: controller,
model,
providerLabel: getProviderLabel(connection.provider),
done: false
}
});
try {
const res = await downloadProviderModel(localStorage.token, connection.idx, model, controller.signal);
const jobId = res?.job_id;
if (res?.status) {
MODEL_DOWNLOAD_POOL.set({
...$MODEL_DOWNLOAD_POOL,
[poolKey]: {
...$MODEL_DOWNLOAD_POOL[poolKey],
digest: res.status,
done: ['completed', 'already_downloaded'].includes(res.status)
}
});
}
if (jobId) {
while (!controller.signal.aborted) {
await new Promise((resolve) => setTimeout(resolve, 1500));
if (controller.signal.aborted) break;
const status = await getProviderModelDownloadStatus(
localStorage.token,
connection.idx,
jobId,
controller.signal
);
const total = status?.total_size_bytes ?? 0;
const downloaded = status?.downloaded_bytes ?? 0;
const pullProgress = total ? Math.round((downloaded / total) * 1000) / 10 : undefined;
MODEL_DOWNLOAD_POOL.set({
...$MODEL_DOWNLOAD_POOL,
[poolKey]: {
...$MODEL_DOWNLOAD_POOL[poolKey],
...(pullProgress !== undefined ? { pullProgress } : {}),
digest: status?.status ?? ''
}
});
if (status?.status === 'completed') {
MODEL_DOWNLOAD_POOL.set({
...$MODEL_DOWNLOAD_POOL,
[poolKey]: {
...$MODEL_DOWNLOAD_POOL[poolKey],
pullProgress: 100,
done: true
}
});
break;
}
if (status?.status === 'failed') {
throw status?.error ?? 'Download failed';
}
}
} else if (!$MODEL_DOWNLOAD_POOL[poolKey]?.done) {
MODEL_DOWNLOAD_POOL.set({
...$MODEL_DOWNLOAD_POOL,
[poolKey]: {
...$MODEL_DOWNLOAD_POOL[poolKey],
pullProgress: 100,
done: true
}
});
}
if ($MODEL_DOWNLOAD_POOL[poolKey]?.done) {
toast.success(
$i18n.t(`Model '{{modelName}}' has been successfully downloaded.`, {
modelName: model
})
);
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
}
} catch (error) {
if (!controller.signal.aborted) {
toast.error(getErrorMessage(error));
}
}
delete $MODEL_DOWNLOAD_POOL[poolKey];
MODEL_DOWNLOAD_POOL.set({
...$MODEL_DOWNLOAD_POOL
});
};
const downloadModelHandler = (target) => {
if (target.type === 'ollama') {
pullModelHandler();
return;
}
downloadProviderModelHandler(target);
};
const setProviderDownloadConnections = async () => {
const openaiConfig = await getOpenAIConfig(localStorage.token).catch(() => null);
providerDownloadConnections =
openaiConfig?.ENABLE_OPENAI_API
? (openaiConfig.OPENAI_API_BASE_URLS ?? [])
.map((url: string, idx: number) => {
const config =
openaiConfig.OPENAI_API_CONFIGS?.[idx] ??
openaiConfig.OPENAI_API_CONFIGS?.[String(idx)] ??
openaiConfig.OPENAI_API_CONFIGS?.[url] ??
{};
return {
idx,
url,
provider: normalizeProvider(config?.provider ?? '')
};
})
.filter((connection) => MANAGEMENT_PROVIDERS.has(connection.provider))
: [];
};
onMount(() => {
if (items) {
tags = items
@@ -612,12 +822,8 @@
};
});
$: if (show && !selectionOnly) {
setOllamaVersion();
}
const cancelModelPullHandler = async (model: string) => {
const { reader, abortController } = $MODEL_DOWNLOAD_POOL[model];
const { reader, abortController, providerLabel } = $MODEL_DOWNLOAD_POOL[model];
if (abortController) {
abortController.abort();
}
@@ -629,6 +835,17 @@
});
await deleteModel(localStorage.token, model);
toast.success($i18n.t('{{model}} download has been canceled', { model: model }));
} else {
const displayModel = $MODEL_DOWNLOAD_POOL[model]?.model ?? model;
delete $MODEL_DOWNLOAD_POOL[model];
MODEL_DOWNLOAD_POOL.set({
...$MODEL_DOWNLOAD_POOL
});
toast.success(
$i18n.t('{{model}} download has been canceled', {
model: providerLabel ? `${displayModel} (${providerLabel})` : displayModel
})
);
}
};
@@ -810,21 +1027,24 @@
class="w-full bg-transparent text-[0.8125rem] 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') {
if (showPullModelButton && selectedModelIdx === filteredItems.length) {
pullModelHandler();
} else if (filteredItems[selectedModelIdx]) {
selectItem(filteredItems[selectedModelIdx], selectedModelIdx);
}
aria-label={$i18n.t('Search In Models')}
on:keydown={(e) => {
if (e.code === 'Enter') {
if (selectedModelIdx >= filteredItems.length) {
const target = downloadTargets[selectedModelIdx - filteredItems.length];
if (target && !target.download) {
downloadModelHandler(target);
}
} else if (filteredItems[selectedModelIdx]) {
selectItem(filteredItems[selectedModelIdx], selectedModelIdx);
}
return; // dont need to scroll on selection
} else if (e.code === 'ArrowDown') {
e.stopPropagation();
selectedModelIdx = Math.min(
selectedModelIdx + 1,
Math.max(filteredItems.length - 1 + (showPullModelButton ? 1 : 0), 0)
);
e.stopPropagation();
selectedModelIdx = Math.min(
selectedModelIdx + 1,
Math.max(filteredItems.length - 1 + downloadTargets.length, 0)
);
} else if (e.code === 'ArrowUp') {
e.stopPropagation();
selectedModelIdx = Math.max(selectedModelIdx - 1, 0);
@@ -904,12 +1124,12 @@
{$i18n.t('Manage Connections')}
</button>
</div>
{:else}
<div class="">
<div class="block px-2 py-1 text-[0.8125rem] text-gray-700 dark:text-gray-100">
{$i18n.t('No results found')}
{:else}
<div class="">
<div class="flex min-h-8 items-center rounded-xl px-2 text-[0.8125rem] text-gray-700 dark:text-gray-100">
{$i18n.t('No results found')}
</div>
</div>
</div>
{/if}
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
@@ -947,76 +1167,108 @@
</div>
{/if}
{#if showPullModelButton}
<Tooltip
content={$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, {
searchValue: searchValue
})}
placement="top-start"
>
<button
type="button"
role="option"
aria-selected={selectedModelIdx === filteredItems.length}
data-arrow-selected={selectedModelIdx === filteredItems.length}
class="focus-ring flex h-[1.6875rem] w-full cursor-pointer select-none items-center rounded-xl px-2 text-[0.8125rem] 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 {selectedModelIdx ===
filteredItems.length
? 'bg-gray-50/70 dark:bg-gray-800/60'
: ''}"
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-[0.8125rem] 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}%)`
: ''}
{#each downloadTargets as target, targetIndex (target.id)}
{#if target.download}
<Tooltip content={target.download?.digest ?? ''} placement="top-start">
<div
role="option"
aria-selected={selectedModelIdx === filteredItems.length + targetIndex}
data-arrow-selected={selectedModelIdx === filteredItems.length + targetIndex}
class="flex h-8 w-full select-none items-center gap-2 rounded-xl px-2 text-left text-[0.8125rem] font-normal text-gray-700 outline-hidden transition-colors duration-75 dark:text-gray-100 {selectedModelIdx ===
filteredItems.length + targetIndex
? 'bg-gray-50/70 dark:bg-gray-800/60'
: ''}"
>
<Spinner className="size-3 shrink-0 text-gray-400 dark:text-gray-500" />
<div class="min-w-0 flex-1 truncate">
{$i18n.t('Downloading "{{searchValue}}"', { searchValue: searchValue })}
</div>
{#if 'pullProgress' in target.download}
<div class="shrink-0 text-[0.6875rem] tabular-nums text-gray-500 dark:text-gray-400">
{target.download.pullProgress}%
</div>
{/if}
<button
class="focus-ring flex size-4 shrink-0 items-center justify-center rounded text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
aria-label={$i18n.t('Cancel download of {{model}}', { model: searchValue })}
on:click|stopPropagation={() => {
cancelModelPullHandler(target.poolKey);
}}
>
<svg
class="size-2.5"
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.5"
d="M6 18 17.94 6M18 18 6.06 6"
/>
</svg>
</button>
</div>
</Tooltip>
{:else}
<Tooltip content={target.actionLabel} placement="top-start">
<button
type="button"
role="option"
aria-selected={selectedModelIdx === filteredItems.length + targetIndex}
data-arrow-selected={selectedModelIdx === filteredItems.length + targetIndex}
class="focus-ring flex h-8 w-full cursor-pointer select-none items-center gap-2 rounded-xl px-2 text-left text-[0.8125rem] 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 {selectedModelIdx ===
filteredItems.length + targetIndex
? 'bg-gray-50/70 dark:bg-gray-800/60'
: ''}"
on:click={() => {
downloadModelHandler(target);
}}
>
<Download className="size-3.5 shrink-0 text-gray-400 dark:text-gray-500" />
<div class="min-w-0 flex-1 truncate">
{$i18n.t('Download "{{searchValue}}"', { searchValue: searchValue })}
</div>
<div class="shrink-0 truncate text-[0.6875rem] text-gray-500 dark:text-gray-400">
{target.label}
</div>
</button>
</Tooltip>
{/if}
{/each}
{#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}
{#each selectionOnly ? [] : Object.keys($MODEL_DOWNLOAD_POOL).filter((model) => !activeDownloadKeys.has(model)) as model}
{@const download = $MODEL_DOWNLOAD_POOL[model]}
{@const downloadName = download?.model ?? model}
<Tooltip content={download?.digest ?? ''} placement="top-start">
<div
class="flex h-8 w-full select-none items-center gap-2 rounded-xl px-2 text-left text-[0.8125rem] font-normal text-gray-700 outline-hidden transition-colors duration-75 dark:text-gray-100"
>
<Spinner className="size-3 shrink-0 text-gray-400 dark:text-gray-500" />
<div class="min-w-0 flex-1 truncate">
Downloading "{downloadName}"{download?.providerLabel
? ` from ${download.providerLabel}`
: ''}
</div>
{#if 'pullProgress' in download}
<div class="shrink-0 text-[0.6875rem] tabular-nums text-gray-500 dark:text-gray-400">
{download.pullProgress}%
</div>
{/if}
</div>
</div>
<div class="mr-2 ml-1 translate-y-0.5">
<Tooltip content={$i18n.t('Cancel')}>
<button
class="focus-ring text-gray-800 dark:text-gray-100"
aria-label={$i18n.t('Cancel download of {{model}}', { model: model })}
on:click={() => {
class="focus-ring flex size-4 shrink-0 items-center justify-center rounded text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
aria-label={$i18n.t('Cancel download of {{model}}', { model: downloadName })}
on:click|stopPropagation={() => {
cancelModelPullHandler(model);
}}
>
<svg
class="w-4 h-4 text-gray-800 dark:text-white"
class="size-2.5"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
@@ -1028,15 +1280,14 @@
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke-width="2.5"
d="M6 18 17.94 6M18 18 6.06 6"
/>
</svg>
</button>
</Tooltip>
</div>
</div>
{/each}
</div>
</Tooltip>
{/each}
</div>
{#if showSetDefault}