ui: Restructure repo to use tools/ui folder and ui / UI / llama-ui / LLAMA_UI naming (#23064)
* webui: Move static build output from `tools/server/public` to `build/ui` directory * refactor: Move to `tools/ui` * refactor: rename CMake variables and preprocessor defines - Rename LLAMA_BUILD_WEBUI -> LLAMA_BUILD_UI (old kept as deprecated) - Rename LLAMA_USE_PREBUILT_WEBUI -> LLAMA_USE_PREBUILT_UI (old kept as deprecated) - Backward compat: old vars auto-forward to new ones with DEPRECATION warning - Rename internal vars: WEBUI_SOURCE -> UI_SOURCE, WEBUI_SOURCE_DIR -> UI_SOURCE_DIR, etc. - Rename HF bucket: LLAMA_WEBUI_HF_BUCKET -> LLAMA_UI_HF_BUCKET - Emit both LLAMA_BUILD_WEBUI and LLAMA_BUILD_UI preprocessor defines - Emit both LLAMA_WEBUI_DEFAULT_ENABLED and LLAMA_UI_DEFAULT_ENABLED * refactor: rename CLI flags (--webui -> --ui) with backward compat - Add --ui/--no-ui (old --webui/--no-webui kept as deprecated aliases) - Add --ui-config (old --webui-config kept as deprecated alias) - Add --ui-config-file (old --webui-config-file kept as deprecated alias) - Add --ui-mcp-proxy/--no-ui-mcp-proxy (old --webui-mcp-proxy kept as deprecated) - Add new env vars: LLAMA_ARG_UI, LLAMA_ARG_UI_CONFIG, LLAMA_ARG_UI_CONFIG_FILE, LLAMA_ARG_UI_MCP_PROXY - C++ struct fields: params.ui, params.ui_config_json, params.ui_mcp_proxy added alongside old fields - Backward compat: old fields synced to new ones in g_params_to_internals * refactor: update C++ server internals with backward compat - Rename json_webui_settings -> json_ui_settings (both kept in server_context_meta) - Rename params.webui usage -> params.ui (both synced, old still works) - JSON API emits both "ui"/"ui_settings" and "webui"/"webui_settings" keys - Server routes use params.ui_mcp_proxy || params.webui_mcp_proxy - Preprocessor guards use #if defined(LLAMA_BUILD_UI) || defined(LLAMA_BUILD_WEBUI) * refactor: rename CI/CD workflows, artifacts, and build script - Rename webui-build.yml -> ui-build.yml; artifact webui-build -> ui-build - Rename webui-publish.yml -> ui-publish.yml; var HF_BUCKET_WEBUI_STATIC_OUTPUT -> HF_BUCKET_UI_STATIC_OUTPUT - Rename server-webui.yml -> server-ui.yml; job webui-build/checks -> ui-build/checks - Update server.yml: job/artifact refs webui-build -> ui-build - Update release.yml: all webui-build/publish refs -> ui-build/publish; HF_TOKEN_WEBUI_STATIC_OUTPUT -> HF_TOKEN_UI_STATIC_OUTPUT - Update server-self-hosted.yml: webui-build -> ui-build - Update build-self-hosted.yml: HF_WEBUI_VERSION -> HF_UI_VERSION - Rename webui-download.cmake -> ui-download.cmake (internal refs updated) - Update labeler.yml: server/webui -> server/ui path label * docs: update CODEOWNERS and server README docs - Update CODEOWNERS: team ggml-org/llama-webui -> ggml-org/llama-ui, path /tools/server/webui/ -> /tools/ui/ - Update server README.md: CLI tables show --ui flags with deprecated --webui aliases - Update server README-dev.md: "WebUI" -> "UI", paths updated to tools/ui/ * fix: Small fixes for UI build * fix: CMake.txt syntax * chore: Formatting * fix: `.editorconfig` for llama-ui * chore: Formatting * refactor: Use `APP_NAME` in Error route * refactor: Cleanup * refactor: Single migration service * make llama-ui a linkable target * fix: UI Build output * fix: Missing change * fix: separate llama-ui npm build output into build/tools/ui/dist subfolder + use cmake npm build instead of downloading ui-build.yml artifacts in CI * refactor: UI workflows cleanup --------- Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
This commit is contained in:
co-authored by
Xuan Son Nguyen
parent
49d1701bd2
commit
59778f0196
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { Package } from '@lucide/svelte';
|
||||
import { BadgeInfo, ActionIconCopyToClipboard } from '$lib/components/app';
|
||||
import ModelId from './ModelId.svelte';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
model?: string;
|
||||
onclick?: () => void;
|
||||
showCopyIcon?: boolean;
|
||||
showTooltip?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
model: modelProp,
|
||||
onclick,
|
||||
showCopyIcon = false,
|
||||
showTooltip = false
|
||||
}: Props = $props();
|
||||
|
||||
let model = $derived(modelProp || modelsStore.singleModelName);
|
||||
let isModelMode = $derived(serverStore.isModelMode);
|
||||
let shouldShow = $derived(model && (modelProp !== undefined || isModelMode));
|
||||
</script>
|
||||
|
||||
{#snippet badgeContent()}
|
||||
<BadgeInfo class={className} {onclick}>
|
||||
{#snippet icon()}
|
||||
<Package class="h-3 w-3" />
|
||||
{/snippet}
|
||||
|
||||
{#if model}
|
||||
<ModelId modelId={model} />
|
||||
{/if}
|
||||
|
||||
{#if showCopyIcon}
|
||||
<ActionIconCopyToClipboard text={model || ''} ariaLabel="Copy model name" />
|
||||
{/if}
|
||||
</BadgeInfo>
|
||||
{/snippet}
|
||||
|
||||
{#if shouldShow}
|
||||
{#if showTooltip}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{@render badgeContent()}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
{onclick ? 'Click for model details' : model}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
{@render badgeContent()}
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { TruncatedText } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
modelId: string;
|
||||
hideOrgName?: boolean;
|
||||
showRaw?: boolean;
|
||||
hideQuantization?: boolean;
|
||||
aliases?: string[];
|
||||
tags?: string[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
modelId,
|
||||
hideOrgName = false,
|
||||
showRaw = undefined,
|
||||
hideQuantization = false,
|
||||
aliases,
|
||||
tags,
|
||||
class: className = '',
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const badgeClass =
|
||||
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25';
|
||||
const tagBadgeClass =
|
||||
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground';
|
||||
|
||||
let parsed = $derived(ModelsService.parseModelId(modelId));
|
||||
let resolvedShowRaw = $derived(showRaw ?? (config().showRawModelNames as boolean) ?? false);
|
||||
|
||||
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
|
||||
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
|
||||
|
||||
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
|
||||
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
|
||||
</script>
|
||||
|
||||
{#if resolvedShowRaw}
|
||||
<TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} {...rest} />
|
||||
{:else}
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-1 {className}" {...rest}>
|
||||
<span class="min-w-0 truncate font-medium">
|
||||
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
|
||||
</span>
|
||||
|
||||
{#if parsed.params}
|
||||
<span class={badgeClass}>
|
||||
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if parsed.quantization && !hideQuantization}
|
||||
<span class={badgeClass}>
|
||||
{parsed.quantization}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if primaryAlias}
|
||||
{#if primaryAlias !== parsed.modelName}
|
||||
<span class={badgeClass}>{parsed.modelName ?? modelId}</span>
|
||||
{/if}
|
||||
{:else if uniqueAliases.length > 1}
|
||||
{#each uniqueAliases as alias (alias)}
|
||||
<span class={badgeClass}>{alias}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if uniqueTags.length > 0}
|
||||
{#each uniqueTags as tag (tag)}
|
||||
<span class={tagBadgeClass}>{tag}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,290 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
|
||||
import {
|
||||
DialogModelInformation,
|
||||
DropdownMenuSearchable,
|
||||
ModelId,
|
||||
ModelsSelectorList,
|
||||
ModelsSelectorOption
|
||||
} from '$lib/components/app';
|
||||
import type { ModelItem } from './utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
currentModel?: string | null;
|
||||
disabled?: boolean;
|
||||
forceForegroundText?: boolean;
|
||||
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
|
||||
useGlobalSelection?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
currentModel = null,
|
||||
disabled = false,
|
||||
forceForegroundText = false,
|
||||
onModelChange,
|
||||
useGlobalSelection = false
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let highlightedIndex = $state<number>(-1);
|
||||
|
||||
const ms = useModelsSelector({
|
||||
currentModel: () => currentModel,
|
||||
useGlobalSelection: () => useGlobalSelection,
|
||||
onModelChange: () => onModelChange,
|
||||
onOpenChange: (open) => {
|
||||
isOpen = open;
|
||||
highlightedIndex = -1;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void ms.searchTerm;
|
||||
highlightedIndex = -1;
|
||||
});
|
||||
|
||||
export function open() {
|
||||
ms.handleOpenChange(true);
|
||||
}
|
||||
|
||||
function handleSearchKeyDown(event: KeyboardEvent) {
|
||||
if (event.isComposing) return;
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
|
||||
if (ms.filteredOptions.length === 0) return;
|
||||
|
||||
if (highlightedIndex === -1 || highlightedIndex === ms.filteredOptions.length - 1) {
|
||||
highlightedIndex = 0;
|
||||
} else {
|
||||
highlightedIndex += 1;
|
||||
}
|
||||
} else if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
|
||||
if (ms.filteredOptions.length === 0) return;
|
||||
|
||||
if (highlightedIndex === -1 || highlightedIndex === 0) {
|
||||
highlightedIndex = ms.filteredOptions.length - 1;
|
||||
} else {
|
||||
highlightedIndex -= 1;
|
||||
}
|
||||
} else if (event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
|
||||
if (highlightedIndex >= 0 && highlightedIndex < ms.filteredOptions.length) {
|
||||
const option = ms.filteredOptions[highlightedIndex];
|
||||
|
||||
ms.handleSelect(option.id);
|
||||
} else if (ms.filteredOptions.length > 0) {
|
||||
highlightedIndex = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
|
||||
{#if ms.loading && ms.options.length === 0 && ms.isRouter}
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
Loading models…
|
||||
</div>
|
||||
{:else if ms.options.length === 0 && ms.isRouter}
|
||||
{#if currentModel}
|
||||
<span
|
||||
class={[
|
||||
'inline-flex items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs text-muted-foreground',
|
||||
className
|
||||
]}
|
||||
style="max-width: min(calc(100cqw - 10rem), 20rem)"
|
||||
>
|
||||
<Package class="h-3.5 w-3.5" />
|
||||
|
||||
<ModelId modelId={currentModel} class="min-w-0" hideQuantization />
|
||||
</span>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No models available.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
{@const selectedOption = ms.getDisplayOption()}
|
||||
|
||||
{#if ms.isRouter}
|
||||
<DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}>
|
||||
<DropdownMenu.Trigger
|
||||
class={[
|
||||
`inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground',
|
||||
isOpen && 'text-foreground',
|
||||
'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]'
|
||||
]}
|
||||
disabled={disabled || ms.updating}
|
||||
>
|
||||
<Package class="h-3.5 w-3.5" />
|
||||
|
||||
{#if selectedOption}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<!-- prevent another nested button element -->
|
||||
{#snippet child({ props })}
|
||||
<ModelId
|
||||
modelId={selectedOption.model}
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideOrgName={false}
|
||||
{...props}
|
||||
/>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p class="font-mono">{selectedOption.model}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
<span class="min-w-0 font-medium">Select model</span>
|
||||
{/if}
|
||||
|
||||
{#if ms.updating || ms.isLoadingModel}
|
||||
<Loader2 class="h-3 w-3.5 animate-spin" />
|
||||
{:else}
|
||||
<ChevronDown class="h-3 w-3.5" />
|
||||
{/if}
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
|
||||
>
|
||||
<DropdownMenuSearchable
|
||||
searchValue={ms.searchTerm}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
placeholder="Search models..."
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
emptyMessage="No models found."
|
||||
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
|
||||
>
|
||||
<div class="models-list">
|
||||
{#if !ms.isCurrentModelInCache && currentModel}
|
||||
<!-- Show unavailable model as first option (disabled) -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
|
||||
role="option"
|
||||
aria-selected="true"
|
||||
aria-disabled="true"
|
||||
disabled
|
||||
>
|
||||
<ModelId modelId={currentModel} class="flex-1" hideQuantization />
|
||||
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if ms.filteredOptions.length === 0}
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option, flatIndex } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = flatIndex === highlightedIndex}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{option}
|
||||
{isSelected}
|
||||
{isHighlighted}
|
||||
{isFav}
|
||||
{hideOrgName}
|
||||
onSelect={ms.handleSelect}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onMouseEnter={() => (highlightedIndex = flatIndex)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
ms.handleSelect(option.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<ModelsSelectorList
|
||||
groups={ms.groupedFilteredOptions}
|
||||
{currentModel}
|
||||
activeId={ms.activeId}
|
||||
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
|
||||
onSelect={ms.handleSelect}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
renderOption={modelOption}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuSearchable>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
<button
|
||||
class={[
|
||||
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground',
|
||||
isOpen && 'text-foreground'
|
||||
]}
|
||||
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
disabled={disabled || ms.updating}
|
||||
>
|
||||
<Package class="h-3.5 w-3.5" />
|
||||
|
||||
{#if selectedOption}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<!-- prevent another nested button element -->
|
||||
{#snippet child({ props })}
|
||||
<ModelId
|
||||
modelId={selectedOption.model}
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideOrgName={false}
|
||||
{...props}
|
||||
/>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p class="font-mono">{selectedOption.model}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if ms.updating}
|
||||
<Loader2 class="h-3 w-3.5 animate-spin" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if ms.showModelDialog}
|
||||
<DialogModelInformation
|
||||
open={ms.showModelDialog}
|
||||
onOpenChange={(v) => ms.setShowModelDialog(v)}
|
||||
modelId={ms.infoModelId}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { ModelsSelectorOption } from '$lib/components/app';
|
||||
import type { GroupedModelOptions, ModelItem } from './utils';
|
||||
|
||||
interface Props {
|
||||
groups: GroupedModelOptions;
|
||||
currentModel: string | null;
|
||||
activeId: string | null;
|
||||
sectionHeaderClass?: string;
|
||||
orgHeaderClass?: string;
|
||||
onSelect: (modelId: string) => void;
|
||||
onInfoClick: (modelName: string) => void;
|
||||
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
|
||||
}
|
||||
|
||||
let {
|
||||
groups,
|
||||
currentModel,
|
||||
activeId,
|
||||
sectionHeaderClass = 'my-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none',
|
||||
orgHeaderClass = 'px-2 py-2 text-[11px] font-semibold text-muted-foreground/50 select-none [&:not(:first-child)]:mt-1',
|
||||
onSelect,
|
||||
onInfoClick,
|
||||
renderOption
|
||||
}: Props = $props();
|
||||
let render = $derived(renderOption ?? defaultOption);
|
||||
</script>
|
||||
|
||||
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || activeId === option.id}
|
||||
{@const isFav = modelsStore.favoriteModelIds.has(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{option}
|
||||
{isSelected}
|
||||
isHighlighted={false}
|
||||
{isFav}
|
||||
{hideOrgName}
|
||||
{onSelect}
|
||||
{onInfoClick}
|
||||
onMouseEnter={() => {}}
|
||||
onKeyDown={() => {}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#if groups.loaded.length > 0}
|
||||
<p class={sectionHeaderClass}>Loaded models</p>
|
||||
{#each groups.loaded as item (`loaded-${item.option.id}`)}
|
||||
{@render render(item, false)}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if groups.favorites.length > 0}
|
||||
<p class={sectionHeaderClass}>Favorite models</p>
|
||||
{#each groups.favorites as item (`fav-${item.option.id}`)}
|
||||
{@render render(item, true)}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if groups.available.length > 0}
|
||||
<p class={sectionHeaderClass}>Available models</p>
|
||||
{#each groups.available as group (group.orgName)}
|
||||
{#if group.orgName}
|
||||
<p class={orgHeaderClass}>{group.orgName}</p>
|
||||
{/if}
|
||||
{#each group.items as item (item.option.id)}
|
||||
{@render render(item, true)}
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -0,0 +1,181 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
CircleAlert,
|
||||
Heart,
|
||||
HeartOff,
|
||||
Info,
|
||||
Loader2,
|
||||
Power,
|
||||
PowerOff,
|
||||
RotateCw
|
||||
} from '@lucide/svelte';
|
||||
import { ActionIcon, ModelId } from '$lib/components/app';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore, routerModels } from '$lib/stores/models.svelte';
|
||||
|
||||
interface Props {
|
||||
option: ModelOption;
|
||||
isSelected: boolean;
|
||||
isHighlighted: boolean;
|
||||
isFav: boolean;
|
||||
hideOrgName?: boolean;
|
||||
onSelect: (modelId: string) => void;
|
||||
onMouseEnter: () => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
onInfoClick?: (modelName: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
option,
|
||||
isSelected,
|
||||
isHighlighted,
|
||||
isFav,
|
||||
hideOrgName = false,
|
||||
onSelect,
|
||||
onMouseEnter,
|
||||
onKeyDown,
|
||||
onInfoClick
|
||||
}: Props = $props();
|
||||
|
||||
let currentRouterModels = $derived(routerModels());
|
||||
let serverStatus = $derived.by(() => {
|
||||
const model = currentRouterModels.find((m) => m.id === option.model);
|
||||
return (model?.status?.value as ServerModelStatus) ?? null;
|
||||
});
|
||||
let isOperationInProgress = $derived(modelsStore.isModelOperationInProgress(option.model));
|
||||
let isFailed = $derived(serverStatus === ServerModelStatus.FAILED);
|
||||
let isSleeping = $derived(serverStatus === ServerModelStatus.SLEEPING);
|
||||
let isLoaded = $derived(
|
||||
(serverStatus === ServerModelStatus.LOADED || isSleeping) && !isOperationInProgress
|
||||
);
|
||||
let isLoading = $derived(serverStatus === ServerModelStatus.LOADING || isOperationInProgress);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'group flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
|
||||
'cursor-pointer hover:bg-muted focus:bg-muted',
|
||||
(isSelected || isHighlighted) && 'bg-accent text-accent-foreground',
|
||||
!(isSelected || isHighlighted) && 'hover:bg-accent hover:text-accent-foreground',
|
||||
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
|
||||
]}
|
||||
role="option"
|
||||
aria-selected={isSelected || isHighlighted}
|
||||
tabindex="0"
|
||||
onclick={() => onSelect(option.id)}
|
||||
onmouseenter={onMouseEnter}
|
||||
onkeydown={onKeyDown}
|
||||
>
|
||||
<ModelId
|
||||
modelId={option.model}
|
||||
{hideOrgName}
|
||||
aliases={option.aliases}
|
||||
tags={option.tags}
|
||||
class="flex-1"
|
||||
/>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="pointer-events-none flex items-center justify-center gap-0.75 pl-2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{#if isFav}
|
||||
<ActionIcon
|
||||
iconSize="h-2.5 w-2.5"
|
||||
icon={HeartOff}
|
||||
tooltip="Remove from favorites"
|
||||
class="h-3 w-3 hover:text-foreground"
|
||||
onclick={() => modelsStore.toggleFavorite(option.model)}
|
||||
/>
|
||||
{:else}
|
||||
<ActionIcon
|
||||
iconSize="h-2.5 w-2.5"
|
||||
icon={Heart}
|
||||
tooltip="Add to favorites"
|
||||
class="h-3 w-3 hover:text-foreground"
|
||||
onclick={() => modelsStore.toggleFavorite(option.model)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- info button: only shown when model is loaded and callback is provided -->
|
||||
{#if isLoaded && onInfoClick}
|
||||
<ActionIcon
|
||||
iconSize="h-2.5 w-2.5"
|
||||
icon={Info}
|
||||
tooltip="Model information"
|
||||
class="h-3 w-3 hover:text-foreground"
|
||||
onclick={() => onInfoClick(option.model)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
{:else if isFailed}
|
||||
<div class="flex w-4 items-center justify-center">
|
||||
<CircleAlert class="h-3.5 w-3.5 text-red-500 group-hover:hidden" />
|
||||
|
||||
<div class="hidden group-hover:flex">
|
||||
<ActionIcon
|
||||
iconSize="h-2.5 w-2.5"
|
||||
icon={RotateCw}
|
||||
tooltip="Retry loading model"
|
||||
class="h-3 w-3 text-red-500 hover:text-foreground"
|
||||
onclick={() => modelsStore.loadModel(option.model)}
|
||||
stopPropagationOnClick
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if isSleeping}
|
||||
<div class="flex w-4 items-center justify-center">
|
||||
<span class="h-2 w-2 rounded-full bg-orange-400 group-hover:hidden"></span>
|
||||
|
||||
<div class="hidden group-hover:flex">
|
||||
<ActionIcon
|
||||
iconSize="h-2.5 w-2.5"
|
||||
icon={PowerOff}
|
||||
tooltip="Unload model"
|
||||
class="h-3 w-3 text-red-500 hover:text-red-600"
|
||||
onclick={(e) => {
|
||||
e?.stopPropagation();
|
||||
modelsStore.unloadModel(option.model);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if isLoaded}
|
||||
<div class="flex w-4 items-center justify-center">
|
||||
<span class="h-2 w-2 rounded-full bg-green-500 group-hover:hidden"></span>
|
||||
|
||||
<div class="hidden group-hover:flex">
|
||||
<ActionIcon
|
||||
iconSize="h-2.5 w-2.5"
|
||||
icon={PowerOff}
|
||||
tooltip="Unload model"
|
||||
class="h-3 w-3 text-red-500 hover:text-red-600"
|
||||
onclick={() => modelsStore.unloadModel(option.model)}
|
||||
stopPropagationOnClick
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex w-4 items-center justify-center">
|
||||
<span class="h-2 w-2 rounded-full bg-muted-foreground/50 group-hover:hidden"></span>
|
||||
|
||||
<div class="hidden group-hover:flex">
|
||||
<ActionIcon
|
||||
iconSize="h-2.5 w-2.5"
|
||||
icon={Power}
|
||||
tooltip="Load model"
|
||||
class="h-3 w-3"
|
||||
onclick={() => modelsStore.loadModel(option.model)}
|
||||
stopPropagationOnClick
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
|
||||
import {
|
||||
DialogModelInformation,
|
||||
ModelId,
|
||||
ModelsSelectorList,
|
||||
SearchInput,
|
||||
TruncatedText
|
||||
} from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
currentModel?: string | null;
|
||||
/** Callback when model changes. Return false to keep menu open (e.g., for validation failures) */
|
||||
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
|
||||
disabled?: boolean;
|
||||
forceForegroundText?: boolean;
|
||||
/** When true, user's global selection takes priority over currentModel (for form selector) */
|
||||
useGlobalSelection?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
currentModel = null,
|
||||
onModelChange,
|
||||
disabled = false,
|
||||
forceForegroundText = false,
|
||||
useGlobalSelection = false
|
||||
}: Props = $props();
|
||||
|
||||
let sheetOpen = $state(false);
|
||||
|
||||
const ms = useModelsSelector({
|
||||
currentModel: () => currentModel,
|
||||
useGlobalSelection: () => useGlobalSelection,
|
||||
onModelChange: () => onModelChange,
|
||||
onOpenChange: (open) => {
|
||||
sheetOpen = open;
|
||||
}
|
||||
});
|
||||
|
||||
export function open() {
|
||||
ms.handleOpenChange(true);
|
||||
}
|
||||
|
||||
function handleSheetOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
ms.handleOpenChange(false);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
|
||||
{#if ms.loading && ms.options.length === 0 && ms.isRouter}
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
Loading models…
|
||||
</div>
|
||||
{:else if ms.options.length === 0 && ms.isRouter}
|
||||
<p class="text-xs text-muted-foreground">No models available.</p>
|
||||
{:else}
|
||||
{@const selectedOption = ms.getDisplayOption()}
|
||||
|
||||
{#if ms.isRouter}
|
||||
<button
|
||||
type="button"
|
||||
class={[
|
||||
`inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground',
|
||||
sheetOpen && 'text-foreground'
|
||||
]}
|
||||
style="max-width: min(calc(100cqw - 9rem), 20rem)"
|
||||
disabled={disabled || ms.updating}
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
>
|
||||
<Package class="h-3.5 w-3.5" />
|
||||
|
||||
{#if !selectedOption}
|
||||
<span class="min-w-0 font-medium">Select model</span>
|
||||
{:else}
|
||||
<ModelId
|
||||
class="text-xs"
|
||||
modelId={selectedOption?.model || ''}
|
||||
hideQuantization
|
||||
hideOrgName
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if ms.updating || ms.isLoadingModel}
|
||||
<Loader2 class="h-3 w-3.5 animate-spin" />
|
||||
{:else}
|
||||
<ChevronDown class="h-3 w-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<Sheet.Root bind:open={sheetOpen} onOpenChange={handleSheetOpenChange}>
|
||||
<Sheet.Content side="bottom" class="max-h-[85vh] gap-1">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title>Select Model</Sheet.Title>
|
||||
|
||||
<Sheet.Description class="sr-only">
|
||||
Choose a model to use for the conversation
|
||||
</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
|
||||
<div class="flex flex-col gap-1 pb-4">
|
||||
<div class="mb-3 px-4">
|
||||
<SearchInput
|
||||
placeholder="Search models..."
|
||||
value={ms.searchTerm}
|
||||
onInput={(v) => ms.setSearchTerm(v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[60vh] overflow-y-auto px-2">
|
||||
{#if !ms.isCurrentModelInCache && currentModel}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-not-allowed items-center rounded-md bg-red-400/10 px-3 py-2.5 text-left text-sm text-red-400"
|
||||
disabled
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{selectedOption?.name || currentModel}
|
||||
</span>
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
{/if}
|
||||
|
||||
{#if ms.filteredOptions.length === 0}
|
||||
<p class="px-3 py-3 text-center text-sm text-muted-foreground">No models found.</p>
|
||||
{/if}
|
||||
|
||||
<ModelsSelectorList
|
||||
groups={ms.groupedFilteredOptions}
|
||||
{currentModel}
|
||||
activeId={ms.activeId}
|
||||
sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
|
||||
orgHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
|
||||
onSelect={ms.handleSelect}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
{:else}
|
||||
<button
|
||||
class={[
|
||||
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground'
|
||||
]}
|
||||
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
disabled={disabled || ms.updating}
|
||||
>
|
||||
<Package class="h-3.5 w-3.5" />
|
||||
|
||||
<TruncatedText text={selectedOption?.model || ''} class="font-medium" />
|
||||
|
||||
{#if ms.updating}
|
||||
<Loader2 class="h-3 w-3.5 animate-spin" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if ms.showModelDialog}
|
||||
<DialogModelInformation
|
||||
open={ms.showModelDialog}
|
||||
onOpenChange={(v) => ms.setShowModelDialog(v)}
|
||||
modelId={ms.infoModelId}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
*
|
||||
* MODELS
|
||||
*
|
||||
* Components for model selection and display. Supports two server modes:
|
||||
* - **Single model mode**: Server runs with one model, selector shows model info
|
||||
* - **Router mode**: Server runs with multiple models, selector enables switching
|
||||
*
|
||||
* Integrates with modelsStore for model data and serverStore for mode detection.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **ModelsSelectorDropdown** - Model selection dropdown (desktop)
|
||||
*
|
||||
* Dropdown for selecting AI models with status indicators,
|
||||
* search, and model information display. Adapts UI based on server mode.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses DropdownMenuSearchable for model list
|
||||
* - Integrates with modelsStore for model options and selection
|
||||
* - Detects router vs single mode from serverStore
|
||||
* - Opens DialogModelInformation for model details
|
||||
*
|
||||
* **Features:**
|
||||
* - Searchable model list with keyboard navigation
|
||||
* - Model status indicators (loading/ready/error/updating)
|
||||
* - Model capabilities badges (vision, tools, etc.)
|
||||
* - Current/active model highlighting
|
||||
* - Model information dialog on info button click
|
||||
* - Router mode: shows all available models with status
|
||||
* - Single mode: shows current model name only
|
||||
* - Loading/updating skeleton states
|
||||
* - Global selection support for form integration
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <ModelsSelectorDropdown
|
||||
* currentModel={conversation.modelId}
|
||||
* onModelChange={(id, name) => updateModel(id)}
|
||||
* useGlobalSelection
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsSelectorList** - Grouped model options list
|
||||
*
|
||||
* Renders grouped model options (loaded, favorites, available) with section
|
||||
* headers and org subgroups. Shared between ModelsSelectorDropdown and ModelsSelectorSheet
|
||||
* to avoid template duplication.
|
||||
*
|
||||
* Accepts an optional `renderOption` snippet to customize how each option is
|
||||
* rendered (e.g., to add keyboard navigation or highlighting).
|
||||
*/
|
||||
export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsSelectorOption** - Single model option row
|
||||
*
|
||||
* Renders a single model option with selection state, favorite toggle,
|
||||
* load/unload actions, status indicators, and an info button.
|
||||
* Used inside ModelsSelectorList or directly in custom render snippets.
|
||||
*/
|
||||
export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsSelectorSheet** - Mobile model selection sheet
|
||||
*
|
||||
* Bottom sheet variant of ModelsSelectorDropdown optimized for touch interaction
|
||||
* on mobile devices. Same functionality as ModelsSelectorDropdown but uses Sheet UI
|
||||
* instead of DropdownMenu.
|
||||
*/
|
||||
export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.svelte';
|
||||
|
||||
/**
|
||||
* **ModelBadge** - Model name display badge
|
||||
*
|
||||
* Compact badge showing current model name with package icon.
|
||||
* Only visible in single model mode. Supports tooltip and copy functionality.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Reads model name from modelsStore or prop
|
||||
* - Checks server mode from serverStore
|
||||
* - Uses BadgeInfo for consistent styling
|
||||
*
|
||||
* **Features:**
|
||||
* - Optional copy to clipboard button
|
||||
* - Optional tooltip with model details
|
||||
* - Click handler for model info dialog
|
||||
* - Only renders in model mode (not router)
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <ModelBadge
|
||||
* onclick={() => showModelInfo = true}
|
||||
* showTooltip
|
||||
* showCopyIcon
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as ModelBadge } from './ModelBadge.svelte';
|
||||
|
||||
/**
|
||||
* **ModelId** - Parsed model identifier display
|
||||
*
|
||||
* Displays a model ID with optional org name, parameter badges, quantization,
|
||||
* aliases, and tags. Supports raw mode to show the unprocessed model name.
|
||||
* Respects the user's `showRawModelNames` setting.
|
||||
*/
|
||||
export { default as ModelId } from './ModelId.svelte';
|
||||
@@ -0,0 +1,75 @@
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
export interface ModelItem {
|
||||
option: ModelOption;
|
||||
flatIndex: number;
|
||||
}
|
||||
|
||||
export interface OrgGroup {
|
||||
orgName: string | null;
|
||||
items: ModelItem[];
|
||||
}
|
||||
|
||||
export interface GroupedModelOptions {
|
||||
loaded: ModelItem[];
|
||||
favorites: ModelItem[];
|
||||
available: OrgGroup[];
|
||||
}
|
||||
|
||||
export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
if (!term) return options;
|
||||
|
||||
return options.filter(
|
||||
(option) =>
|
||||
option.model.toLowerCase().includes(term) ||
|
||||
option.name?.toLowerCase().includes(term) ||
|
||||
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
|
||||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term))
|
||||
);
|
||||
}
|
||||
|
||||
export function groupModelOptions(
|
||||
filteredOptions: ModelOption[],
|
||||
favoriteIds: Set<string>,
|
||||
isModelLoaded: (model: string) => boolean
|
||||
): GroupedModelOptions {
|
||||
// Loaded models
|
||||
const loaded: ModelItem[] = [];
|
||||
for (let i = 0; i < filteredOptions.length; i++) {
|
||||
if (isModelLoaded(filteredOptions[i].model)) {
|
||||
loaded.push({ option: filteredOptions[i], flatIndex: i });
|
||||
}
|
||||
}
|
||||
|
||||
// Favorites (excluding loaded)
|
||||
const loadedModelIds = new Set(loaded.map((item) => item.option.model));
|
||||
const favorites: ModelItem[] = [];
|
||||
for (let i = 0; i < filteredOptions.length; i++) {
|
||||
if (
|
||||
favoriteIds.has(filteredOptions[i].model) &&
|
||||
!loadedModelIds.has(filteredOptions[i].model)
|
||||
) {
|
||||
favorites.push({ option: filteredOptions[i], flatIndex: i });
|
||||
}
|
||||
}
|
||||
|
||||
// Available models grouped by org (excluding loaded and favorites)
|
||||
const available: OrgGroup[] = [];
|
||||
const orgGroups = new SvelteMap<string, ModelItem[]>();
|
||||
for (let i = 0; i < filteredOptions.length; i++) {
|
||||
const option = filteredOptions[i];
|
||||
if (loadedModelIds.has(option.model) || favoriteIds.has(option.model)) continue;
|
||||
|
||||
const key = option.parsedId?.orgName ?? '';
|
||||
if (!orgGroups.has(key)) orgGroups.set(key, []);
|
||||
orgGroups.get(key)!.push({ option, flatIndex: i });
|
||||
}
|
||||
|
||||
for (const [orgName, items] of orgGroups) {
|
||||
available.push({ orgName: orgName || null, items });
|
||||
}
|
||||
|
||||
return { loaded, favorites, available };
|
||||
}
|
||||
Reference in New Issue
Block a user