mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-19 01:04:55 +02:00
ui : refactor model selector dropdown with inline reasoning panel
Replace the submenu-based dropdown with a flat scrollable list that has a sticky search header and actions footer. Add avatars and parameter badges to model options. Introduce ModelsSelectorReasoningPanel for in-place reasoning effort selection. Adjust dropdown sizing and input blur styling. Assisted-by: llama-ui:Qwen3.8-Flash-Next
This commit is contained in:
@@ -2,13 +2,13 @@
|
||||
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
|
||||
import { ChevronDown, Lightbulb, Loader2, PackageSearch } from '@lucide/svelte';
|
||||
import {
|
||||
ChatFormActionAddReasoningSubmenu,
|
||||
DialogModelInformation,
|
||||
DialogModelsDiscover,
|
||||
DropdownMenuSearchable,
|
||||
ModelId,
|
||||
ModelsSelectorList,
|
||||
ModelsSelectorOption
|
||||
ModelsSelectorOption,
|
||||
ModelsSelectorReasoningPanel
|
||||
} from '$lib/components/app';
|
||||
import type { ModelItem } from '$lib/components/app/navigation/utils';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
@@ -41,9 +41,6 @@
|
||||
let isOpen = $state(false);
|
||||
let highlightedId = $state<string | null>(null);
|
||||
let modelsHubOpen = $state(false);
|
||||
// The model submenu opens together with the menu so the list and its search
|
||||
// box are immediately available, as before the submenu was introduced
|
||||
let modelSubOpen = $state(false);
|
||||
|
||||
const ms = useModelsSelector({
|
||||
currentModel: () => currentModel,
|
||||
@@ -53,14 +50,16 @@
|
||||
highlightedId = null;
|
||||
|
||||
if (open) {
|
||||
// Defer submenu open so the Sub component is mounted first;
|
||||
// setting bind:open synchronously can be lost if the Sub hasn't
|
||||
// rendered yet.
|
||||
queueMicrotask(() => {
|
||||
if (isOpen) modelSubOpen = true;
|
||||
// Defer focus so the content is mounted; bits-ui auto-focuses the
|
||||
// opened content by default which can yank the page scroll, so we
|
||||
// prevent that on the Content and refocus the search here.
|
||||
requestAnimationFrame(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
document
|
||||
.querySelector<HTMLElement>('[data-slot="dropdown-menu-content"] input')
|
||||
?.focus({ preventScroll: true });
|
||||
});
|
||||
} else {
|
||||
modelSubOpen = false;
|
||||
}
|
||||
},
|
||||
useGlobalSelection: () => useGlobalSelection
|
||||
@@ -77,21 +76,6 @@
|
||||
highlightedId = null;
|
||||
});
|
||||
|
||||
// Focus the model submenu's search box without scrolling the page. bits-ui
|
||||
// auto-focuses the opened content by default, which can yank the page
|
||||
// scroll; we prevent that on the Content and refocus the search here.
|
||||
$effect(() => {
|
||||
if (!isOpen || !modelSubOpen) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const search = document.querySelector<HTMLElement>(
|
||||
'[data-slot="dropdown-menu-sub-content"] input'
|
||||
);
|
||||
|
||||
search?.focus({ preventScroll: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Keyboard navigation follows the on-screen row order, not the flat option list order.
|
||||
let visualOrder = $derived.by(() => {
|
||||
const order: string[] = [];
|
||||
@@ -127,6 +111,13 @@
|
||||
highlightedId = visualOrder[index];
|
||||
}
|
||||
|
||||
// Pointer/focus interaction with the sticky actions footer (reasoning submenu,
|
||||
// discover models) leaves the option list, so drop the row highlight the same
|
||||
// way we do when the dropdown first opens. Keyboard arrows set it again.
|
||||
function clearHighlight() {
|
||||
highlightedId = null;
|
||||
}
|
||||
|
||||
// Alt+Enter only unloads and keeps the dropdown open.
|
||||
async function handleModelKeyAction(modelId: string, unload: boolean) {
|
||||
if (!unload) {
|
||||
@@ -182,7 +173,7 @@
|
||||
'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)"
|
||||
style="max-width: min(calc(100cqw - 10rem), 48rem)"
|
||||
>
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
</span>
|
||||
@@ -266,105 +257,94 @@
|
||||
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
class="w-full md:min-w-64 md:max-w-80 max-w-[calc(100vw-2rem)]"
|
||||
class="w-full md:min-w-80 md:max-w-[26rem] max-w-[calc(100vw-2rem)] !py-0"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenu.Sub bind:open={modelSubOpen}>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<MODEL_SELECTOR_ICON class="h-4 w-4" />
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
class="min-w-0 flex-1 overflow-hidden"
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
hideQuantization
|
||||
modelId={selectedOption.model}
|
||||
/>
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 truncate text-muted-foreground">No model</span>
|
||||
{/if}
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent class="w-100 max-w-[calc(100vw-2rem)] pt-0">
|
||||
<DropdownMenuSearchable
|
||||
emptyMessage="No models found."
|
||||
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
placeholder="Search models..."
|
||||
searchValue={ms.searchTerm}
|
||||
>
|
||||
<div class="models-list">
|
||||
{#if !ms.isCurrentModelInCache && currentModel}
|
||||
<!-- Show unavailable model as first option (disabled) -->
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-selected="true"
|
||||
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
|
||||
disabled
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<ModelId class="flex-1" hideQuantization modelId={currentModel} />
|
||||
|
||||
<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 } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{hideOrgName}
|
||||
{isFav}
|
||||
{isHighlighted}
|
||||
{isSelected}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
void handleModelKeyAction(option.id, event.altKey);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => (highlightedId = option.id)}
|
||||
onSelect={ms.handleSelect}
|
||||
{option}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<ModelsSelectorList
|
||||
activeId={ms.activeId}
|
||||
{currentModel}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onSelect={ms.handleSelect}
|
||||
renderOption={modelOption}
|
||||
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuSearchable>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<ChatFormActionAddReasoningSubmenu />
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent"
|
||||
onclick={() => (modelsHubOpen = true)}
|
||||
<DropdownMenuSearchable
|
||||
emptyMessage="No models found."
|
||||
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
placeholder="Search models..."
|
||||
searchClass="bg-transparent"
|
||||
searchValue={ms.searchTerm}
|
||||
>
|
||||
<PackageSearch class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<!-- Option list; the search header sticks to the top and the actions
|
||||
footer to the bottom of the content scrollport. -->
|
||||
<div class="models-list">
|
||||
{#if !ms.isCurrentModelInCache && currentModel}
|
||||
<!-- Show unavailable model as first option (disabled) -->
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-selected="true"
|
||||
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
|
||||
disabled
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<ModelId class="flex-1" hideQuantization modelId={currentModel} />
|
||||
|
||||
<span>Discover models</span>
|
||||
</DropdownMenu.Item>
|
||||
<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 } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
hideOrgName
|
||||
{isFav}
|
||||
{isHighlighted}
|
||||
{isSelected}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
void handleModelKeyAction(option.id, event.altKey);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => (highlightedId = option.id)}
|
||||
onSelect={ms.handleSelect}
|
||||
{option}
|
||||
showBaseModelAvatar
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<ModelsSelectorList
|
||||
activeId={ms.activeId}
|
||||
{currentModel}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onSelect={ms.handleSelect}
|
||||
renderOption={modelOption}
|
||||
sectionHeaderClass="[&:not(:first-child)]:mt-3 mb-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<!-- Sticky actions footer: reasoning effort panel and discover
|
||||
models. Sticks to the bottom of the content scrollport. -->
|
||||
<div onfocusin={clearHighlight} onmouseenter={clearHighlight} role="none">
|
||||
<ModelsSelectorReasoningPanel />
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent"
|
||||
onclick={() => (modelsHubOpen = true)}
|
||||
>
|
||||
<PackageSearch class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<span>Discover models</span>
|
||||
</DropdownMenu.Item>
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownMenuSearchable>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
currentModel: string | null;
|
||||
activeId: string | null;
|
||||
sectionHeaderClass?: string;
|
||||
orgHeaderClass?: string;
|
||||
onSelect: (modelId: string) => void;
|
||||
onInfoClick: (modelName: string) => void;
|
||||
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
|
||||
@@ -20,20 +19,19 @@
|
||||
groups,
|
||||
onInfoClick,
|
||||
onSelect,
|
||||
orgHeaderClass = 'px-2 py-2 text-[11px] font-semibold text-muted-foreground/50 select-none [&:not(:first-child)]:mt-1',
|
||||
renderOption,
|
||||
sectionHeaderClass = 'my-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none'
|
||||
sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none'
|
||||
}: Props = $props();
|
||||
let render = $derived(renderOption ?? defaultOption);
|
||||
</script>
|
||||
|
||||
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{#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
|
||||
{hideOrgName}
|
||||
hideOrgName
|
||||
{isFav}
|
||||
isHighlighted={false}
|
||||
{isSelected}
|
||||
@@ -42,11 +40,12 @@
|
||||
onMouseEnter={() => {}}
|
||||
{onSelect}
|
||||
{option}
|
||||
showBaseModelAvatar
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#if groups.loaded.length > 0}
|
||||
<p class={sectionHeaderClass}>Loaded models</p>
|
||||
<p class="{sectionHeaderClass} mt-0">Loaded models</p>
|
||||
|
||||
{#each groups.loaded as item (`loaded-${item.option.id}`)}
|
||||
{@render render(item, false)}
|
||||
@@ -62,13 +61,9 @@
|
||||
{/if}
|
||||
|
||||
{#if groups.available.length > 0}
|
||||
<p class={sectionHeaderClass}>Available models</p>
|
||||
<h2 class={sectionHeaderClass}>Downloaded models</h2>
|
||||
|
||||
{#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}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverAvatar from './discover/ModelsDiscoverAvatar.svelte';
|
||||
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
|
||||
import {
|
||||
CircleAlert,
|
||||
@@ -11,11 +12,22 @@
|
||||
RotateCw
|
||||
} from '@lucide/svelte';
|
||||
import { ActionIcon, ModelId } from '$lib/components/app';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import {
|
||||
HF_BASE_MODEL_TAG_REGEX,
|
||||
ICON_CLASS_DEFAULT,
|
||||
MODEL_ID,
|
||||
PATH_SEPARATOR
|
||||
} from '$lib/constants';
|
||||
import { ModelCapability, ServerModelStatus } from '$lib/enums';
|
||||
import { HuggingFaceService, ModelsService } from '$lib/services';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
|
||||
import {
|
||||
formatParameters,
|
||||
modelLoadFraction,
|
||||
modelLoadProgressText,
|
||||
normalizeModelName
|
||||
} from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
option: ModelOption;
|
||||
@@ -27,6 +39,8 @@
|
||||
onMouseEnter: () => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
onInfoClick?: (modelName: string) => void;
|
||||
/** Show the base model's org as the main avatar and the repo (quant) org as the corner badge; resolves the base org lazily via HF. */
|
||||
showBaseModelAvatar?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -38,7 +52,8 @@
|
||||
onKeyDown,
|
||||
onMouseEnter,
|
||||
onSelect,
|
||||
option
|
||||
option,
|
||||
showBaseModelAvatar = false
|
||||
}: Props = $props();
|
||||
|
||||
let currentRouterModels = $derived(modelsStore.routerModels);
|
||||
@@ -59,10 +74,82 @@
|
||||
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
|
||||
let loadTitle = $derived(modelLoadProgressText(loadProgress));
|
||||
let modalities = $derived(option.modalities);
|
||||
// Avatar: with showBaseModelAvatar the original base model's org is the main
|
||||
// image and the repo (quantizer) org the corner badge, as in the models hub
|
||||
// list. Loaded models usually carry the `base_model` tag on the option; GGUF
|
||||
// repos only known to HF are resolved lazily via the cached getBaseModel
|
||||
// lookup.
|
||||
let parsedId = $derived(ModelsService.parseModelId(option.model));
|
||||
let orgName = $derived(parsedId.orgName);
|
||||
let tagBaseModel = $derived(
|
||||
(option.tags ?? [])
|
||||
.find((t) => HF_BASE_MODEL_TAG_REGEX.test(t))
|
||||
?.match(HF_BASE_MODEL_TAG_REGEX)?.[1] ?? null
|
||||
);
|
||||
let fetchedBaseModelOrg = $state<string | null>(null);
|
||||
let baseModelOrg = $derived(tagBaseModel?.split(PATH_SEPARATOR)[0] ?? fetchedBaseModelOrg);
|
||||
|
||||
$effect(() => {
|
||||
fetchedBaseModelOrg = null;
|
||||
|
||||
if (!showBaseModelAvatar || !orgName || tagBaseModel) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void HuggingFaceService.getBaseModel(option.model).then((base) => {
|
||||
if (!cancelled && base?.org) fetchedBaseModelOrg = base.org;
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
let capabilities = $derived.by(() => ({
|
||||
reasoning: modelsStore.props.checkModelSupportsThinking(option.model),
|
||||
tools: option.capabilities.includes(ModelCapability.TOOL_USE)
|
||||
}));
|
||||
|
||||
// Params badge fallback: the id usually carries the count (`Qwen3-8B`), but
|
||||
// ids like `Kimi-K3` do not. Prefer the loaded model's meta `n_params`, then
|
||||
// the HF GGUF total (`gguf.total`), fetched lazily like the models hub list
|
||||
// does, only when neither the id nor the meta has it.
|
||||
let parsedParams = $derived(parsedId.params);
|
||||
let metaParams = $derived.by(() => {
|
||||
const value = option.meta?.n_params;
|
||||
|
||||
return typeof value === 'number' && value > 0 ? value : null;
|
||||
});
|
||||
// HF repo id: the `org/model` part without the `:revision/quant` suffix;
|
||||
// local paths and bare names are not HF repos.
|
||||
let hfRepoId = $derived.by(() => {
|
||||
const [repo] = option.model.split(MODEL_ID.QUANTIZATION_SEPARATOR);
|
||||
const normalized = normalizeModelName(repo ?? '');
|
||||
|
||||
return normalized.includes(MODEL_ID.ORG_SEPARATOR) ? normalized : null;
|
||||
});
|
||||
let fetchedParams = $state<number | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
fetchedParams = null;
|
||||
|
||||
if (parsedParams || metaParams || !hfRepoId) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void HuggingFaceService.getDetails(hfRepoId).then((info) => {
|
||||
if (!cancelled && info?.gguf?.total) fetchedParams = info.gguf.total;
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
let paramsFallback = $derived(
|
||||
!parsedParams && (metaParams ?? fetchedParams)
|
||||
? formatParameters(metaParams ?? fetchedParams)
|
||||
: undefined
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -84,12 +171,24 @@
|
||||
tabindex="0"
|
||||
title={loadTitle}
|
||||
>
|
||||
{#if orgName}
|
||||
<ModelsDiscoverAvatar
|
||||
class="mt-0"
|
||||
org={baseModelOrg ?? orgName}
|
||||
quantOrg={showBaseModelAvatar ? orgName : undefined}
|
||||
quantPositionClass="-bottom-1 -right-1"
|
||||
quantSize="h-3 w-3"
|
||||
size="h-6 w-6"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ModelId
|
||||
aliases={option.aliases}
|
||||
class="flex-1"
|
||||
{hideOrgName}
|
||||
{modalities}
|
||||
modelId={option.model}
|
||||
params={paramsFallback}
|
||||
showRawTooltip
|
||||
supportsThinking={capabilities.reasoning}
|
||||
supportsToolUse={capabilities.tools}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { Check, ChevronDown, ChevronRight, Info, Lightbulb, LightbulbOff } from '@lucide/svelte';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
|
||||
|
||||
const reasoning = useReasoningMenu();
|
||||
|
||||
let expanded = $state(false);
|
||||
</script>
|
||||
|
||||
<!-- Reasoning effort picker for the models dropdown footer. Unlike
|
||||
ChatFormActionAddReasoningSubmenu, which opens a flyout submenu, this one
|
||||
expands in place: pinned to the bottom of a scrolling menu, a flyout would
|
||||
float over the very list it belongs to. -->
|
||||
<Collapsible.Root class="min-w-0" onOpenChange={(open) => (expanded = open)} open={expanded}>
|
||||
<!-- Rendered as a menu item so arrow keys, typeahead and Enter reach it, but
|
||||
through the `child` snippet to keep Collapsible.Trigger's native button.
|
||||
closeOnSelect keeps the menu open while the effort list is being used. -->
|
||||
<DropdownMenu.Item
|
||||
class="w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1.75 text-left text-sm"
|
||||
closeOnSelect={false}
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<!-- No `class` here on purpose: a static attribute would override the
|
||||
spread props.class, and the button is the menu item itself (the `child`
|
||||
snippet replaces Item's own div), so all styling must come from above. -->
|
||||
<Collapsible.Trigger {...props}>
|
||||
{#if expanded}
|
||||
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{:else}
|
||||
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||
{:else if reasoning.isOff}
|
||||
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{:else}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
<span class="flex-1 truncate">Reasoning</span>
|
||||
|
||||
<span class="shrink-0 text-xs capitalize text-muted-foreground">
|
||||
{reasoning.currentEffort}
|
||||
</span>
|
||||
</Collapsible.Trigger>
|
||||
{/snippet}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<Collapsible.Content>
|
||||
<!-- Collapsible.Content only toggles the `hidden` attribute, so gate on
|
||||
`expanded`: collapsed, the effort rows leave both the menu item order
|
||||
(bits-ui collects items by attribute) and the tab order. -->
|
||||
{#if expanded}
|
||||
<!-- Plain items, not a RadioGroup: the selected effort lives in the store,
|
||||
so the check mark derives from it and must not be duplicated as group
|
||||
state. -->
|
||||
<div class="mt-0.5 flex flex-col gap-0.5 pl-4">
|
||||
{#each reasoning.levels as level (level.value)}
|
||||
{@const tokenLabel = reasoning.tokenLabel(level)}
|
||||
<DropdownMenu.Item
|
||||
class="flex w-full cursor-pointer gap-3 px-2 py-1.5"
|
||||
closeOnSelect={false}
|
||||
onSelect={() => reasoning.select(level)}
|
||||
>
|
||||
{#if reasoning.isSelected(level)}
|
||||
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
|
||||
{:else}
|
||||
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
|
||||
{/if}
|
||||
|
||||
<span class="min-w-0 flex-1 truncate">{level.label}</span>
|
||||
|
||||
{#if tokenLabel}
|
||||
<span class="shrink-0 text-[11px] text-muted-foreground opacity-60">
|
||||
{tokenLabel}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if level.hasInfo}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content side="left">
|
||||
<p>Maximum reasoning effort with extended context usage</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
@@ -166,7 +166,6 @@
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onSelect={ms.handleSelect}
|
||||
orgHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
|
||||
sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
class?: string;
|
||||
/** Org whose avatar is shown (may differ from the repo's org for base models). */
|
||||
org: string;
|
||||
/** Repo's own org, shown as a small corner badge when provided. */
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
let {
|
||||
baseImageClass = '',
|
||||
class: className = '',
|
||||
class: className = '',
|
||||
org,
|
||||
quantImageClass = 'h-full w-full',
|
||||
quantOrg,
|
||||
|
||||
@@ -135,7 +135,11 @@
|
||||
onclick={() => onSelect?.(model.id)}
|
||||
type="button"
|
||||
>
|
||||
<ModelsDiscoverAvatar class="mt-1" org={avatarOrg} quantOrg={showBaseModelAvatar ? org : undefined} />
|
||||
<ModelsDiscoverAvatar
|
||||
class="mt-1"
|
||||
org={avatarOrg}
|
||||
quantOrg={showBaseModelAvatar ? org : undefined}
|
||||
/>
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
<ModelId
|
||||
|
||||
@@ -65,6 +65,15 @@ export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
|
||||
*/
|
||||
export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsSelectorReasoningPanel** - Inline reasoning effort picker
|
||||
*
|
||||
* Collapsible row that expands in place to reveal the reasoning effort levels.
|
||||
* Used in the sticky footer of ModelsSelectorDropdown, where a flyout submenu
|
||||
* would float over the model list it belongs to.
|
||||
*/
|
||||
export { default as ModelsSelectorReasoningPanel } from './ModelsSelectorReasoningPanel.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsSelectorSheet** - Mobile model selection sheet
|
||||
*
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -10,32 +9,46 @@
|
||||
onSearchKeyDown?: (event: KeyboardEvent) => void;
|
||||
emptyMessage?: string;
|
||||
isEmpty?: boolean;
|
||||
/** Extra classes for the wrapper around the option list. */
|
||||
contentClass?: string;
|
||||
/** Extra classes for the search input. */
|
||||
searchClass?: string;
|
||||
children: Snippet;
|
||||
/**
|
||||
* Optional sticky footer. It sticks to the bottom of the dropdown content's
|
||||
* own scrollport, so it stays visible while the option list scrolls. For this
|
||||
* to work, DropdownMenu.Content must be the scroll container (keep its
|
||||
* overflow-y-auto and a max-height) and must not be `overflow-hidden`.
|
||||
*/
|
||||
footer?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
children,
|
||||
contentClass = '',
|
||||
emptyMessage = 'No items found',
|
||||
footer,
|
||||
isEmpty = false,
|
||||
onSearchChange,
|
||||
onSearchKeyDown,
|
||||
placeholder = 'Search...',
|
||||
searchClass = '',
|
||||
searchValue = $bindable('')
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 z-10 mb-2 bg-popover p-1 pt-2">
|
||||
<!-- Sticks to the top of the dropdown content's scrollport. -->
|
||||
<div class="sticky top-0 z-20 px-1 pb-2 pt-2">
|
||||
<SearchInput
|
||||
bind:value={searchValue}
|
||||
class={searchClass}
|
||||
onInput={onSearchChange}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
{placeholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto">
|
||||
<div class={contentClass}>
|
||||
{@render children()}
|
||||
|
||||
{#if isEmpty}
|
||||
@@ -44,7 +57,10 @@
|
||||
</div>
|
||||
|
||||
{#if footer}
|
||||
<DropdownMenu.Separator />
|
||||
<!-- Sticks to the bottom of the dropdown content's scrollport. -->
|
||||
<div class="sticky bottom-0 z-20 bg-popover px-1 pb-2">
|
||||
<div class="-mx-1 mb-1 h-px bg-border/20"></div>
|
||||
|
||||
{@render footer()}
|
||||
{@render footer()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<DropdownMenuPrimitive.Content
|
||||
bind:ref
|
||||
class={cn(
|
||||
'z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
|
||||
'z-50 max-h-[calc(var(--bits-dropdown-menu-content-available-height)-1rem)] min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-content"
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
className
|
||||
)}
|
||||
data-slot="input"
|
||||
style="backdrop-filter: blur(0.5rem);"
|
||||
style="backdrop-filter: blur(1rem);"
|
||||
{type}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -20,9 +20,10 @@ export interface UseReasoningMenuReturn {
|
||||
/**
|
||||
* Shared reactive state and helpers for the reasoning effort menu.
|
||||
*
|
||||
* Used by both the desktop dropdown (`ChatFormActionAddReasoningSubmenu`)
|
||||
* and the mobile sheet (`ChatFormActionAddSheet`) to avoid duplicating the
|
||||
* thinking-support derivation and the effort selection logic.
|
||||
* Used by the models dropdown footer (`ModelsSelectorReasoningPanel`), the chat
|
||||
* add menu (`ChatFormActionAddReasoningSubmenu`) and the mobile sheet
|
||||
* (`ChatFormActionAddSheet`) to avoid duplicating the thinking-support
|
||||
* derivation and the effort selection logic.
|
||||
*/
|
||||
export function useReasoningMenu(): UseReasoningMenuReturn {
|
||||
const conversationModel = $derived(
|
||||
|
||||
@@ -28,6 +28,9 @@ export function formatFileSize(bytes: number | unknown): string {
|
||||
/**
|
||||
* Format parameter count to human-readable format (B, M, K)
|
||||
*
|
||||
* Billions render as whole numbers (`176.94e9` -> `177B`): the decimals are
|
||||
* noise at badge sizes and id-parsed counts are whole anyway (`Qwen3-8B`).
|
||||
*
|
||||
* @param params - Parameter count
|
||||
* @returns Human-readable parameter count
|
||||
*/
|
||||
@@ -35,7 +38,7 @@ export function formatParameters(params: number | unknown): string {
|
||||
if (typeof params !== 'number') return 'Unknown';
|
||||
|
||||
if (params >= 1e9) {
|
||||
return `${(params / 1e9).toFixed(2)}B`;
|
||||
return `${Math.round(params / 1e9)}B`;
|
||||
}
|
||||
|
||||
if (params >= 1e6) {
|
||||
|
||||
Reference in New Issue
Block a user