This commit is contained in:
Timothy Jaeryang Baek
2026-03-16 01:04:17 -05:00
parent 68973f9b39
commit 54f7861b2e
7 changed files with 442 additions and 341 deletions
+91 -31
View File
@@ -45,7 +45,7 @@
"@xyflow/svelte": "^0.1.19", "@xyflow/svelte": "^0.1.19",
"alpinejs": "^3.15.0", "alpinejs": "^3.15.0",
"async": "^3.2.5", "async": "^3.2.5",
"bits-ui": "^0.21.15", "bits-ui": "^2.0.0",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"codemirror-lang-elixir": "^4.0.0", "codemirror-lang-elixir": "^4.0.0",
@@ -1770,10 +1770,11 @@
} }
}, },
"node_modules/@internationalized/date": { "node_modules/@internationalized/date": {
"version": "3.8.2", "version": "3.12.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz",
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==", "integrity": "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@swc/helpers": "^0.5.0" "@swc/helpers": "^0.5.0"
} }
@@ -2969,10 +2970,11 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.17", "version": "0.5.19",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
@@ -4982,37 +4984,27 @@
} }
}, },
"node_modules/bits-ui": { "node_modules/bits-ui": {
"version": "0.21.15", "version": "2.16.3",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-0.21.15.tgz", "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.16.3.tgz",
"integrity": "sha512-+m5WSpJnFdCcNdXSTIVC1WYBozipO03qRh03GFWgrdxoHiolCfwW71EYG4LPCWYPG6KcTZV0Cj6iHSiZ7cdKdg==", "integrity": "sha512-5hJ5dEhf5yPzkRFcxzgQHScGodeo0gK0MUUXrdLlRHWaBOBGZiacWLG96j/wwFatKwZvouw7q+sn14i0fx3RIg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@internationalized/date": "^3.5.1", "@floating-ui/core": "^1.7.1",
"@melt-ui/svelte": "0.76.2", "@floating-ui/dom": "^1.7.1",
"nanoid": "^5.0.5" "esm-env": "^1.1.2",
"runed": "^0.35.1",
"svelte-toolbelt": "^0.10.6",
"tabbable": "^6.2.0"
},
"engines": {
"node": ">=20"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/huntabyte" "url": "https://github.com/sponsors/huntabyte"
}, },
"peerDependencies": { "peerDependencies": {
"svelte": "^4.0.0 || ^5.0.0-next.118" "@internationalized/date": "^3.8.1",
} "svelte": "^5.33.0"
},
"node_modules/bits-ui/node_modules/@melt-ui/svelte": {
"version": "0.76.2",
"resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.76.2.tgz",
"integrity": "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.3.1",
"@floating-ui/dom": "^1.4.5",
"@internationalized/date": "^3.5.0",
"dequal": "^2.0.3",
"focus-trap": "^7.5.2",
"nanoid": "^5.0.4"
},
"peerDependencies": {
"svelte": ">=3 <5"
} }
}, },
"node_modules/bl": { "node_modules/bl": {
@@ -8611,6 +8603,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"license": "MIT"
},
"node_modules/internmap": { "node_modules/internmap": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -9662,6 +9660,15 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"license": "MIT",
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -11691,6 +11698,30 @@
"queue-microtask": "^1.2.2" "queue-microtask": "^1.2.2"
} }
}, },
"node_modules/runed": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz",
"integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==",
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"esm-env": "^1.0.0",
"lz-string": "^1.5.0"
},
"peerDependencies": {
"@sveltejs/kit": "^2.21.0",
"svelte": "^5.7.0"
},
"peerDependenciesMeta": {
"@sveltejs/kit": {
"optional": true
}
}
},
"node_modules/rw": { "node_modules/rw": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
@@ -12600,6 +12631,15 @@
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==" "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="
}, },
"node_modules/style-to-object": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
"integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
"license": "MIT",
"dependencies": {
"inline-style-parser": "0.2.7"
}
},
"node_modules/stylis": { "node_modules/stylis": {
"version": "4.3.6", "version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
@@ -12788,6 +12828,26 @@
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1"
} }
}, },
"node_modules/svelte-toolbelt": {
"version": "0.10.6",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
"integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==",
"funding": [
"https://github.com/sponsors/huntabyte"
],
"dependencies": {
"clsx": "^2.1.1",
"runed": "^0.35.1",
"style-to-object": "^1.0.8"
},
"engines": {
"node": ">=18",
"pnpm": ">=8.7.0"
},
"peerDependencies": {
"svelte": "^5.30.2"
}
},
"node_modules/svelte/node_modules/@types/estree": { "node_modules/svelte/node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+1 -1
View File
@@ -89,7 +89,7 @@
"@xyflow/svelte": "^0.1.19", "@xyflow/svelte": "^0.1.19",
"alpinejs": "^3.15.0", "alpinejs": "^3.15.0",
"async": "^3.2.5", "async": "^3.2.5",
"bits-ui": "^0.21.15", "bits-ui": "^2.0.0",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"codemirror-lang-elixir": "^4.0.0", "codemirror-lang-elixir": "^4.0.0",
@@ -25,12 +25,14 @@
</script> </script>
{#if user} {#if user}
<LinkPreview.Content <LinkPreview.Portal>
class="w-full max-w-[260px] rounded-2xl border border-gray-100 dark:border-gray-800 z-[9999] bg-white dark:bg-gray-850 dark:text-white shadow-lg transition" <LinkPreview.Content
{side} class="w-[260px] rounded-2xl border border-gray-100 dark:border-gray-800 z-[9999] bg-white dark:bg-gray-850 dark:text-white shadow-lg transition"
{align} {side}
{sideOffset} {align}
> {sideOffset}
<UserStatus {user} /> >
</LinkPreview.Content> <UserStatus {user} />
</LinkPreview.Content>
</LinkPreview.Portal>
{/if} {/if}
@@ -60,12 +60,12 @@
</span> </span>
</button> </button>
</LinkPreview.Trigger> </LinkPreview.Trigger>
<LinkPreview.Portal>
<LinkPreview.Content <LinkPreview.Content
class="z-[999]" class="z-[999]"
align="start" align="start"
strategy="fixed" strategy="fixed"
sideOffset={6} sideOffset={6}
el={containerElement}
> >
<div class="bg-gray-50 dark:bg-gray-850 rounded-xl p-1 cursor-pointer"> <div class="bg-gray-50 dark:bg-gray-850 rounded-xl p-1 cursor-pointer">
{#each token.citationIdentifiers ?? token.ids as identifier} {#each token.citationIdentifiers ?? token.ids as identifier}
@@ -77,6 +77,7 @@
{/each} {/each}
</div> </div>
</LinkPreview.Content> </LinkPreview.Content>
</LinkPreview.Portal>
</LinkPreview.Root> </LinkPreview.Root>
{/if} {/if}
{:else} {:else}
@@ -9,6 +9,7 @@
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import { flyAndScale } from '$lib/utils/transitions'; import { flyAndScale } from '$lib/utils/transitions';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte'; import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -396,7 +397,12 @@
resetView(); resetView();
}} }}
closeFocus={false} onOpenChangeComplete={(open) => {
if (!open) {
// Replaces the old closeFocus={false} behavior - prevent focus jump back to trigger
document.getElementById(`model-selector-${id}-button`)?.blur();
}
}}
> >
<DropdownMenu.Trigger <DropdownMenu.Trigger
class="relative w-full {($settings?.highContrastMode ?? false) class="relative w-full {($settings?.highContrastMode ?? false)
@@ -430,294 +436,311 @@
</div> </div>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Portal>
class=" z-40 {$mobile <DropdownMenu.Content
? `w-full` forceMount
: `${className}`} max-w-[calc(100vw-1rem)] justify-start rounded-2xl bg-white dark:bg-gray-850 dark:text-white shadow-lg outline-hidden" trapFocus={false}
transition={flyAndScale} preventScroll={false}
side={$mobile ? 'bottom' : 'bottom-start'} side="bottom"
sideOffset={2} align={$mobile ? 'center' : 'start'}
alignOffset={-1} sideOffset={2}
> alignOffset={-1}
<slot> >
{#if searchEnabled} {#snippet child({ wrapperProps, props, open })}
<div class="flex items-center gap-2.5 px-4.5 mt-3.5 mb-1.5"> {#if open}
<Search className="size-4" strokeWidth="2.5" /> <div {...wrapperProps}>
<input
id="model-search-input"
bind:value={searchValue}
class="w-full text-sm bg-transparent outline-hidden"
placeholder={searchPlaceholder}
autocomplete="off"
aria-label={$i18n.t('Search In Models')}
on:keydown={(e) => {
if (e.code === 'Enter' && filteredItems.length > 0) {
value = filteredItems[selectedModelIdx].value;
show = false;
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' });
}}
/>
</div>
{/if}
<div class="px-2">
{#if tags && items.filter((item) => !(item.model?.info?.meta?.hidden ?? false)).length > 0}
<div
class=" flex w-full bg-white dark:bg-gray-850 overflow-x-auto scrollbar-none font-[450] mb-0.5"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div <div
class="flex gap-1 w-fit text-center text-sm rounded-full bg-transparent px-1.5 whitespace-nowrap" {...props}
bind:this={tagsContainerElement} class="{props.class} z-40 {$mobile
? `w-full`
: `${className}`} max-w-[calc(100vw-1rem)] justify-start rounded-2xl bg-white dark:bg-gray-850 dark:text-white shadow-lg outline-hidden"
transition:flyAndScale
> >
{#if items.find((item) => item.model?.connection_type === 'local') || items.find((item) => item.model?.connection_type === 'external') || items.find((item) => item.model?.direct) || tags.length > 0} <slot>
<button {#if searchEnabled}
class="min-w-fit outline-none px-1.5 py-0.5 {selectedTag === '' && <div class="flex items-center gap-2.5 px-4.5 pt-3.5 mb-1.5">
selectedConnectionType === '' <Search className="size-4" strokeWidth="2.5" />
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedTag === '' && selectedConnectionType === ''}
on:click={() => {
selectedConnectionType = '';
selectedTag = '';
}}
>
{$i18n.t('All')}
</button>
{/if}
{#if items.find((item) => item.model?.connection_type === 'local')} <input
<button id="model-search-input"
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType === 'local' bind:value={searchValue}
? '' class="w-full text-sm bg-transparent outline-hidden"
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize" placeholder={searchPlaceholder}
aria-pressed={selectedConnectionType === 'local'} autocomplete="off"
on:click={() => { aria-label={$i18n.t('Search In Models')}
selectedTag = ''; on:keydown={(e) => {
selectedConnectionType = 'local'; if (e.code === 'Enter' && filteredItems.length > 0) {
}} value = filteredItems[selectedModelIdx].value;
> show = false;
{$i18n.t('Local')} return; // dont need to scroll on selection
</button> } else if (e.code === 'ArrowDown') {
{/if} 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;
}
{#if items.find((item) => item.model?.connection_type === 'external')} const item = document.querySelector(`[data-arrow-selected="true"]`);
<button item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType === 'external' }}
? '' />
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'external'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'external';
}}
>
{$i18n.t('External')}
</button>
{/if}
{#if items.find((item) => item.model?.direct)}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType === 'direct'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'direct'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'direct';
}}
>
{$i18n.t('Direct')}
</button>
{/if}
{#each tags as tag}
<Tooltip content={tag}>
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedTag === tag
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedTag === tag}
on:click={() => {
selectedConnectionType = '';
selectedTag = tag;
}}
>
{tag.length > 16 ? `${tag.slice(0, 16)}...` : tag}
</button>
</Tooltip>
{/each}
</div>
</div> </div>
{/if} {/if}
</div>
<div class="px-2.5 group relative"> <div class="px-2">
{#if filteredItems.length === 0} {#if tags && items.filter((item) => !(item.model?.info?.meta?.hidden ?? false)).length > 0}
{#if items.length === 0 && $user?.role === 'admin'} <div
<div class="flex flex-col items-start justify-center py-6 px-4 text-start"> class=" flex w-full bg-white dark:bg-gray-850 overflow-x-auto scrollbar-none font-[450] mb-0.5"
<div class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1"> on:wheel={(e) => {
{$i18n.t('No models available')} if (e.deltaY !== 0) {
</div> e.preventDefault();
<div class="text-xs text-gray-500 dark:text-gray-400 mb-4"> e.currentTarget.scrollLeft += e.deltaY;
{$i18n.t('Connect to an AI provider to start chatting')} }
</div> }}
<a >
href="/admin/settings/connections" <div
class="px-4 py-1.5 rounded-xl text-xs font-medium bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 transition" class="flex gap-1 w-fit text-center text-sm rounded-full bg-transparent px-1.5 whitespace-nowrap"
on:click={() => { bind:this={tagsContainerElement}
show = false;
}}
> >
{$i18n.t('Manage Connections')} {#if items.find((item) => item.model?.connection_type === 'local') || items.find((item) => item.model?.connection_type === 'external') || items.find((item) => item.model?.direct) || tags.length > 0}
</a> <button
</div> class="min-w-fit outline-none px-1.5 py-0.5 {selectedTag === '' &&
{:else} selectedConnectionType === ''
<div class=""> ? ''
<div class="block px-3 py-2 text-sm text-gray-700 dark:text-gray-100"> : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
{$i18n.t('No results found')} aria-pressed={selectedTag === '' && selectedConnectionType === ''}
on:click={() => {
selectedConnectionType = '';
selectedTag = '';
}}
>
{$i18n.t('All')}
</button>
{/if}
{#if items.find((item) => item.model?.connection_type === 'local')}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType === 'local'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'local'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'local';
}}
>
{$i18n.t('Local')}
</button>
{/if}
{#if items.find((item) => item.model?.connection_type === 'external')}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType ===
'external'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'external'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'external';
}}
>
{$i18n.t('External')}
</button>
{/if}
{#if items.find((item) => item.model?.direct)}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType === 'direct'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'direct'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'direct';
}}
>
{$i18n.t('Direct')}
</button>
{/if}
{#each tags as tag}
<Tooltip content={tag}>
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedTag === tag
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedTag === tag}
on:click={() => {
selectedConnectionType = '';
selectedTag = tag;
}}
>
{tag.length > 16 ? `${tag.slice(0, 16)}...` : tag}
</button>
</Tooltip>
{/each}
</div> </div>
</div> </div>
{/if} {/if}
{:else} </div>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="max-h-64 overflow-y-auto"
role="listbox"
aria-label={$i18n.t('Available models')}
bind:this={listContainer}
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}
{pinModelHandler}
{unloadModelHandler}
onClick={() => {
value = item.value;
selectedModelIdx = index;
show = false; <div class="px-2.5 group relative">
}} {#if filteredItems.length === 0}
/> {#if items.length === 0 && $user?.role === 'admin'}
{/each} <div class="flex flex-col items-start justify-center py-6 px-4 text-start">
<div style="height: {(filteredItems.length - visibleEnd) * ITEM_HEIGHT}px;" /> <div class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
</div> {$i18n.t('No models available')}
{/if}
{#if !(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 w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl cursor-pointer data-highlighted:bg-muted"
on:click={() => {
pullModelHandler();
}}
>
<div class=" truncate">
{$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, { searchValue: searchValue })}
</div>
</button>
</Tooltip>
{/if}
{#each Object.keys($MODEL_DOWNLOAD_POOL) as model}
<div
class="flex w-full justify-between font-medium select-none rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 rounded-xl cursor-pointer data-highlighted:bg-muted"
>
<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> </div>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-4">
{#if 'digest' in $MODEL_DOWNLOAD_POOL[model] && $MODEL_DOWNLOAD_POOL[model].digest} {$i18n.t('Connect to an AI provider to start chatting')}
<div class="-mt-1 h-fit text-[0.7rem] dark:text-gray-500 line-clamp-1"> </div>
{$MODEL_DOWNLOAD_POOL[model].digest} <a
</div> href="/admin/settings/connections"
{/if} class="px-4 py-1.5 rounded-xl text-xs font-medium bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 transition"
</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={() => { on:click={() => {
cancelModelPullHandler(model); show = false;
}} }}
> >
<svg {$i18n.t('Manage Connections')}
class="w-4 h-4 text-gray-800 dark:text-white" </a>
aria-hidden="true" </div>
xmlns="http://www.w3.org/2000/svg" {:else}
width="24" <div class="">
height="24" <div class="block px-3 py-2 text-sm text-gray-700 dark:text-gray-100">
fill="currentColor" {$i18n.t('No results found')}
viewBox="0 0 24 24" </div>
</div>
{/if}
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="max-h-64 overflow-y-auto"
role="listbox"
aria-label={$i18n.t('Available models')}
bind:this={listContainer}
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}
{pinModelHandler}
{unloadModelHandler}
onClick={() => {
value = item.value;
selectedModelIdx = index;
show = false;
}}
/>
{/each}
<div style="height: {(filteredItems.length - visibleEnd) * ITEM_HEIGHT}px;" />
</div>
{/if}
{#if !(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 w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl cursor-pointer data-highlighted:bg-muted"
on:click={() => {
pullModelHandler();
}}
>
<div class=" truncate">
{$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, { searchValue: searchValue })}
</div>
</button>
</Tooltip>
{/if}
{#each Object.keys($MODEL_DOWNLOAD_POOL) as model}
<div
class="flex w-full justify-between font-medium select-none rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 rounded-xl cursor-pointer data-highlighted:bg-muted"
>
<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);
}}
> >
<path <svg
stroke="currentColor" class="w-4 h-4 text-gray-800 dark:text-white"
stroke-linecap="round" aria-hidden="true"
stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"
stroke-width="2" width="24"
d="M6 18 17.94 6M18 18 6.06 6" height="24"
/> fill="currentColor"
</svg> viewBox="0 0 24 24"
</button> >
</Tooltip> <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>
<div class="pb-2.5"></div>
<div class="hidden w-[42rem]" />
<div class="hidden w-[32rem]" />
</slot>
</div> </div>
</div> </div>
{/each} {/if}
</div> {/snippet}
</DropdownMenu.Content>
<div class="mb-2.5"></div> </DropdownMenu.Portal>
<div class="hidden w-[42rem]" />
<div class="hidden w-[32rem]" />
</slot>
</DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
+15 -2
View File
@@ -120,7 +120,7 @@
}); });
} }
function handleWindowClick(event) { function handleWindowPointerDown(event) {
if (!show || !closeOnOutsideClick) return; if (!show || !closeOnOutsideClick) return;
if (triggerEl?.contains(event.target)) return; if (triggerEl?.contains(event.target)) return;
if (contentEl?.contains(event.target)) return; if (contentEl?.contains(event.target)) return;
@@ -140,9 +140,22 @@
show = false; show = false;
onOpenChange(false); onOpenChange(false);
} }
import { onMount, onDestroy } from 'svelte';
let onPointerDown;
onMount(() => {
onPointerDown = (e) => handleWindowPointerDown(e);
document.addEventListener('pointerdown', onPointerDown, true);
});
onDestroy(() => {
if (onPointerDown) {
document.removeEventListener('pointerdown', onPointerDown, true);
}
});
</script> </script>
<svelte:window on:click={handleWindowClick} on:keydown={handleKeydown} on:scroll|capture={positionContent} on:resize={positionContent} /> <svelte:window on:keydown={handleKeydown} on:scroll|capture={positionContent} on:resize={positionContent} />
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
+28 -26
View File
@@ -11,32 +11,34 @@
</script> </script>
<div class="flex justify-center"> <div class="flex justify-center">
<Pagination.Root bind:page {count} {perPage} let:pages> <Pagination.Root bind:page {count} {perPage}>
<div class="my-2 flex items-center"> {#snippet children({ pages })}
<Pagination.PrevButton <div class="my-2 flex items-center">
class="mr-[25px] inline-flex size-8 items-center justify-center rounded-[9px] bg-transparent hover:bg-gray-50 dark:hover:bg-gray-850 active:scale-98 disabled:cursor-not-allowed disabled:text-gray-400 dark:disabled:text-gray-700 hover:disabled:bg-transparent dark:hover:disabled:bg-transparent" <Pagination.PrevButton
> class="mr-[25px] inline-flex size-8 items-center justify-center rounded-[9px] bg-transparent hover:bg-gray-50 dark:hover:bg-gray-850 active:scale-98 disabled:cursor-not-allowed disabled:text-gray-400 dark:disabled:text-gray-700 hover:disabled:bg-transparent dark:hover:disabled:bg-transparent"
<ChevronLeft className="size-4" strokeWidth="2" /> >
</Pagination.PrevButton> <ChevronLeft className="size-4" strokeWidth="2" />
<div class="flex items-center gap-2.5"> </Pagination.PrevButton>
{#each pages as page (page.key)} <div class="flex items-center gap-2.5">
{#if page.type === 'ellipsis'} {#each pages as page (page.key)}
<div class="text-sm font-medium text-foreground-alt">...</div> {#if page.type === 'ellipsis'}
{:else} <div class="text-sm font-medium text-foreground-alt">...</div>
<Pagination.Page {:else}
{page} <Pagination.Page
class="inline-flex size-8 items-center justify-center rounded-[9px] bg-transparent hover:bg-gray-50 dark:hover:bg-gray-850 text-sm font-medium hover:bg-dark-10 active:scale-98 disabled:cursor-not-allowed disabled:opacity-50 hover:disabled:bg-transparent data-selected:bg-gray-50 data-selected:text-gray-700 data-selected:hover:bg-gray-100 dark:data-selected:bg-gray-850 dark:data-selected:text-gray-50 dark:data-selected:hover:bg-gray-800 transition" {page}
> class="inline-flex size-8 items-center justify-center rounded-[9px] bg-transparent hover:bg-gray-50 dark:hover:bg-gray-850 text-sm font-medium hover:bg-dark-10 active:scale-98 disabled:cursor-not-allowed disabled:opacity-50 hover:disabled:bg-transparent data-selected:bg-gray-50 data-selected:text-gray-700 data-selected:hover:bg-gray-100 dark:data-selected:bg-gray-850 dark:data-selected:text-gray-50 dark:data-selected:hover:bg-gray-800 transition"
{page.value} >
</Pagination.Page> {page.value}
{/if} </Pagination.Page>
{/each} {/if}
{/each}
</div>
<Pagination.NextButton
class="ml-[25px] inline-flex size-8 items-center justify-center rounded-[9px] bg-transparent hover:bg-gray-50 dark:hover:bg-gray-850 active:scale-98 disabled:cursor-not-allowed disabled:text-gray-400 dark:disabled:text-gray-700 hover:disabled:bg-transparent dark:hover:disabled:bg-transparent"
>
<ChevronRight className="size-4" strokeWidth="2" />
</Pagination.NextButton>
</div> </div>
<Pagination.NextButton {/snippet}
class="ml-[25px] inline-flex size-8 items-center justify-center rounded-[9px] bg-transparent hover:bg-gray-50 dark:hover:bg-gray-850 active:scale-98 disabled:cursor-not-allowed disabled:text-gray-400 dark:disabled:text-gray-700 hover:disabled:bg-transparent dark:hover:disabled:bg-transparent"
>
<ChevronRight className="size-4" strokeWidth="2" />
</Pagination.NextButton>
</div>
</Pagination.Root> </Pagination.Root>
</div> </div>