ui: Improve Chat Form Actions UI/UX (models selector, add panel) (#27746)

* ui : strip trailing container-format segments from parsed model names

* ui : show reasoning and modality icons on model options and search by modality

* ui : keep reasoning submenu visible regardless of model state

* ui : add show-org-name-in-trigger display setting

* ui : move model list into a submenu within the model selector

* ui : make model option hover and focus highlight override the active state

* ui : add raw model id tooltip to model selector options

* feat: Enable microphone input as default for audio models

* ui : fix eslint issues in chat form and model selector

* ui: show modality icons instead of file submenu in chat add menu

Assisted-by: pi

* chore: Format

* chore: Format

* ui: add ModelCapability enum and shared modality/capability icon constants

Assisted by: pi:GLM-5.3-Flash

* ui: derive modality badge icons and labels from shared constants

Assisted by: pi:GLM-5.3-Flash

* ui: split model option icons into capabilities and modalities

Replace the supportsThinking flag on ModelId with a capabilities object
keyed like ModelModalities, so future capabilities (tool calls, etc.)
slot in alongside reasoning. Icons and labels now come from the shared
CAPABILITY_ICONS/MODALITY_ICONS constants.

Assisted by: pi:GLM-5.3-Flash
This commit is contained in:
Aleksander Grygier
2026-08-27 14:47:36 +02:00
committed by GitHub
parent bcb6084a4e
commit cae63579b6
17 changed files with 475 additions and 239 deletions
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Eye, Mic, Video } from '@lucide/svelte'; import { MODALITY_ICONS, MODALITY_LABELS } from '$lib/constants';
import { ModelModality } from '$lib/enums'; import { ModelModality } from '$lib/enums';
interface Props { interface Props {
@@ -8,29 +8,22 @@
} }
let { class: className = '', modalities }: Props = $props(); let { class: className = '', modalities }: Props = $props();
const shownModalities = [ModelModality.VISION, ModelModality.AUDIO, ModelModality.VIDEO] as const;
let visible = $derived(shownModalities.filter((modality) => modalities.includes(modality)));
</script> </script>
{#each modalities as modality (modality)} {#each visible as modality (modality)}
{#if modality === ModelModality.VISION || modality === ModelModality.AUDIO || modality === ModelModality.VIDEO} {@const ModalityIcon = MODALITY_ICONS[modality]}
<span <span
class={[ class={[
'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium', 'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium',
className className
]} ]}
> >
{#if modality === ModelModality.VISION} <ModalityIcon class="h-3 w-3" />
<Eye class="h-3 w-3" />
Vision (Image) {MODALITY_LABELS[modality]}
{:else if modality === ModelModality.VIDEO} </span>
<Video class="h-3 w-3" />
Vision (Video)
{:else}
<Mic class="h-3 w-3" />
Audio
{/if}
</span>
{/if}
{/each} {/each}
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { File, MessageSquare, Plus } from '@lucide/svelte'; import { File, Image, MessageSquare, Mic, Plus, Video } from '@lucide/svelte';
import { ChatFormActionAddToolsSubmenu, McpLogo } from '$lib/components/app'; import { ChatFormActionAddToolsSubmenu, McpLogo } from '$lib/components/app';
import { buttonVariants } from '$lib/components/ui/button'; import { buttonVariants } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
@@ -8,10 +8,10 @@
import { import {
ATTACHMENT_FILE_ITEMS, ATTACHMENT_FILE_ITEMS,
ATTACHMENT_TOOLTIP_TEXT, ATTACHMENT_TOOLTIP_TEXT,
ICON_CLASS_DEFAULT, ICON_CLASS_DEFAULT
TOOLTIP_DELAY_DURATION
} from '$lib/constants'; } from '$lib/constants';
import { getChatFormActionsContext } from '$lib/contexts'; import { getChatFormActionsContext } from '$lib/contexts';
import { AttachmentAction, AttachmentItemEnabledWhen } from '$lib/enums';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte'; import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
interface Props { interface Props {
@@ -41,6 +41,18 @@
dropdownOpen = false; dropdownOpen = false;
} }
); );
const FILE_MODALITY_ICONS: Record<string, { icon: typeof Image; label: string }> = {
[AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]: { icon: Mic, label: 'Audio' },
[AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]: { icon: Video, label: 'Video' },
[AttachmentItemEnabledWhen.HAS_VISION_MODALITY]: { icon: Image, label: 'Vision' }
};
const supportedModalities = $derived.by(() =>
ATTACHMENT_FILE_ITEMS.filter((item) => attachmentMenu.isItemEnabled(item.enabledWhen))
.map((item) => FILE_MODALITY_ICONS[item.enabledWhen ?? ''])
.filter((modality) => modality !== undefined)
);
</script> </script>
<div class="flex items-center gap-1 {className}"> <div class="flex items-center gap-1 {className}">
@@ -80,50 +92,32 @@
} }
}} }}
> >
<DropdownMenu.Sub> <DropdownMenu.Item
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2"> class="flex cursor-pointer items-center gap-2"
<File class={ICON_CLASS_DEFAULT} /> onclick={() => attachmentMenu.callbacks[AttachmentAction.FILE_UPLOAD]()}
>
<File class={ICON_CLASS_DEFAULT} />
<span class="flex min-w-0 items-center gap-2">
<span>Add files</span> <span>Add files</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-48"> {#if supportedModalities.length > 0}
{#each ATTACHMENT_FILE_ITEMS as item (item.id)} <span class="flex items-center gap-0.75 text-muted-foreground">
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)} {#each supportedModalities as modality (modality.label)}
{#if enabled} <Tooltip.Root>
<DropdownMenu.Item <Tooltip.Trigger>
class="{item.class ?? ''} flex cursor-pointer items-center gap-2" <modality.icon class="size-2.75" />
onclick={() => attachmentMenu.callbacks[item.action]()} </Tooltip.Trigger>
>
<item.icon class={ICON_CLASS_DEFAULT} />
<span>{item.label}</span> <Tooltip.Content>
</DropdownMenu.Item> <p>{modality.label}</p>
{:else if item.disabledTooltip} </Tooltip.Content>
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}> </Tooltip.Root>
<Tooltip.Trigger tabindex={-1}> {/each}
{#snippet child({ props })} </span>
<div {...props} class="cursor-default"> {/if}
<DropdownMenu.Item </span>
class="{item.class ?? ''} flex items-center gap-2" </DropdownMenu.Item>
disabled
>
<item.icon class={ICON_CLASS_DEFAULT} />
<span>{item.label}</span>
</DropdownMenu.Item>
</div>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>{item.disabledTooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Item <DropdownMenu.Item
class="flex cursor-pointer items-center gap-2" class="flex cursor-pointer items-center gap-2"
@@ -8,70 +8,68 @@
const reasoning = useReasoningMenu(); const reasoning = useReasoningMenu();
</script> </script>
{#if reasoning.modelSupportsThinking} <DropdownMenu.Sub>
<DropdownMenu.Sub> <DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2"> {#if reasoning.isReasoningActive}
{#if reasoning.thinkingEnabled} <Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" /> {:else if reasoning.isOff}
{:else if reasoning.isOff} <LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" /> {:else}
{:else} <Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" /> {/if}
{/if}
<span <span
class="text-sm inline-flex gap-2 {!reasoning.thinkingEnabled class="text-sm inline-flex gap-2 {!reasoning.isReasoningActive
? 'text-muted-foreground' ? 'text-muted-foreground'
: ''}" : ''}"
>
Reasoning
<span class="capitalize text-muted-foreground">
{reasoning.currentEffort}
</span>
</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
> >
{#each reasoning.levels as level (level.value)} Reasoning
{@const tokenLabel = reasoning.tokenLabel(level)}
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
level
)
? 'bg-accent'
: ''}"
onclick={() => 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="flex-1">{level.label}</span> <span class="capitalize text-muted-foreground">
{reasoning.currentEffort}
</span>
</span>
</DropdownMenu.SubTrigger>
{#if tokenLabel} <DropdownMenu.SubContent
<span class="text-[11px] text-muted-foreground opacity-60"> class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
{tokenLabel} >
</span> {#each reasoning.levels as level (level.value)}
{/if} {@const tokenLabel = reasoning.tokenLabel(level)}
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
level
)
? 'bg-accent'
: ''}"
onclick={() => 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}
{#if level.hasInfo} <span class="flex-1">{level.label}</span>
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="left"> {#if tokenLabel}
<p>Maximum reasoning effort with extended context usage</p> <span class="text-[11px] text-muted-foreground opacity-60">
</Tooltip.Content> {tokenLabel}
</Tooltip.Root> </span>
{/if} {/if}
</DropdownMenu.Item>
{/each} {#if level.hasInfo}
</DropdownMenu.SubContent> <Tooltip.Root>
</DropdownMenu.Sub> <Tooltip.Trigger>
{/if} <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}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
@@ -1,27 +1,44 @@
<script lang="ts"> <script lang="ts">
import { TruncatedText } from '$lib/components/app'; import { TruncatedText } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
CAPABILITY_FLAG_KEYS,
CAPABILITY_ICONS,
CAPABILITY_LABELS,
MODALITY_FLAG_KEYS,
MODALITY_ICONS,
MODALITY_LABELS
} from '$lib/constants';
import { ModelCapability, ModelModality } from '$lib/enums';
import { ModelsService } from '$lib/services/models.service'; import { ModelsService } from '$lib/services/models.service';
import { settingsStore } from '$lib/stores'; import { settingsStore } from '$lib/stores';
import type { ModelCapabilities, ModelModalities } from '$lib/types/models';
interface Props { interface Props {
modelId: string; modelId: string;
hideOrgName?: boolean; hideOrgName?: boolean;
showRaw?: boolean; showRaw?: boolean;
showRawTooltip?: boolean;
hideQuantization?: boolean; hideQuantization?: boolean;
hideTags?: boolean; hideTags?: boolean;
aliases?: string[]; aliases?: string[];
tags?: string[]; tags?: string[];
modalities?: ModelModalities;
capabilities?: ModelCapabilities;
class?: string; class?: string;
} }
let { let {
aliases, aliases,
capabilities,
class: className = '', class: className = '',
hideOrgName = false, hideOrgName = false,
hideQuantization, hideQuantization,
hideTags, hideTags,
modalities,
modelId, modelId,
showRaw = undefined, showRaw = undefined,
showRawTooltip = false,
tags, tags,
...rest ...rest
}: Props = $props(); }: Props = $props();
@@ -43,6 +60,16 @@
let uniqueAliases = $derived([...new Set(aliases ?? [])]); let uniqueAliases = $derived([...new Set(aliases ?? [])]);
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]); let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
const allModalities = [ModelModality.VISION, ModelModality.VIDEO, ModelModality.AUDIO] as const;
const allCapabilities: ModelCapability[] = [ModelCapability.REASONING];
let activeModalities = $derived(
allModalities.filter((modality) => modalities?.[MODALITY_FLAG_KEYS[modality]])
);
let activeCapabilities = $derived(
allCapabilities.filter((capability) => capabilities?.[CAPABILITY_FLAG_KEYS[capability]])
);
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null); let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId); let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
</script> </script>
@@ -50,37 +77,87 @@
{#if resolvedShowRaw} {#if resolvedShowRaw}
<TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} {...rest} /> <TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} {...rest} />
{:else} {:else}
<span class="flex min-w-0 flex-wrap items-center gap-1 {className}" {...rest}> {#snippet nameAndBadges()}
<span class="min-w-0 truncate font-medium"> <span class="min-w-0 truncate font-medium">
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName} {#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
</span> </span>
{#if parsed.params} <span class="inline-flex items-center gap-1">
<span class={badgeClass}> {#if parsed.params}
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''} <span class={badgeClass}>
</span> {parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
{/if} </span>
{#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
{/if}
{#if primaryAlias}
{#if primaryAlias !== parsed.modelName}
<span class={badgeClass}>{parsed.modelName ?? modelId}</span>
{/if} {/if}
{:else if uniqueAliases.length > 1}
{#each uniqueAliases as alias (alias)} {#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>{alias}</span> <span class={badgeClass}>
{/each} {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 && !resolvedHideTags}
{#each uniqueTags as tag (tag)}
<span class={tagBadgeClass}>{tag}</span>
{/each}
{/if}
</span>
{/snippet}
<span class="flex min-w-0 items-center gap-1.5 {className}" {...rest}>
{#if showRawTooltip}
<Tooltip.Root>
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
{@render nameAndBadges()}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{modelId}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render nameAndBadges()}
{/if} {/if}
{#if uniqueTags.length > 0 && !resolvedHideTags} {#if activeCapabilities.length > 0 || activeModalities.length > 0}
{#each uniqueTags as tag (tag)} <span class="inline-flex items-center gap-1.25 text-muted-foreground">
<span class={tagBadgeClass}>{tag}</span> {#each activeCapabilities as capability (capability)}
{/each} {@const CapabilityIcon = CAPABILITY_ICONS[capability]}
<Tooltip.Root>
<Tooltip.Trigger>
<CapabilityIcon class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>{CAPABILITY_LABELS[capability]}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
{#each activeModalities as modality (modality)}
{@const ModalityIcon = MODALITY_ICONS[modality]}
<Tooltip.Root>
<Tooltip.Trigger>
<ModalityIcon class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>{MODALITY_LABELS[modality]}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</span>
{/if} {/if}
</span> </span>
{/if} {/if}
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import ModelLoadHighlight from './ModelLoadHighlight.svelte'; import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import type { ModelItem } from './utils'; import type { ModelItem } from './utils';
import { ChevronDown, Loader2 } from '@lucide/svelte'; import { ChevronDown, Lightbulb, Loader2 } from '@lucide/svelte';
import { import {
ChatFormActionAddReasoningSubmenu,
DialogModelInformation, DialogModelInformation,
DropdownMenuSearchable, DropdownMenuSearchable,
ModelId, ModelId,
@@ -11,10 +12,11 @@
} from '$lib/components/app'; } from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { MODEL_SELECTOR_ICON } from '$lib/constants'; import { MODEL_SELECTOR_ICON, SETTINGS_KEYS } from '$lib/constants';
import { KeyboardKey, ServerModelStatus } from '$lib/enums'; import { KeyboardKey, ServerModelStatus } from '$lib/enums';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte'; import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import { modelsStore } from '$lib/stores'; import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { modelsStore, settingsStore } from '$lib/stores';
import { modelLoadFraction } from '$lib/utils'; import { modelLoadFraction } from '$lib/utils';
interface Props { interface Props {
@@ -37,6 +39,9 @@
let isOpen = $state(false); let isOpen = $state(false);
let highlightedId = $state<string | null>(null); let highlightedId = $state<string | null>(null);
// 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({ const ms = useModelsSelector({
currentModel: () => currentModel, currentModel: () => currentModel,
@@ -44,24 +49,41 @@
onOpenChange: (open) => { onOpenChange: (open) => {
isOpen = open; isOpen = open;
highlightedId = null; 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;
});
} else {
modelSubOpen = false;
}
}, },
useGlobalSelection: () => useGlobalSelection useGlobalSelection: () => useGlobalSelection
}); });
const reasoning = useReasoningMenu();
const showOrgNameInTrigger = $derived(
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER] ?? false
);
$effect(() => { $effect(() => {
void ms.searchTerm; void ms.searchTerm;
highlightedId = null; highlightedId = null;
}); });
// Focus the dropdown's search box without scrolling the page. bits-ui // 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 // auto-focuses the opened content by default, which can yank the page
// scroll; we prevent that on the Content and refocus the search here. // scroll; we prevent that on the Content and refocus the search here.
$effect(() => { $effect(() => {
if (!isOpen) return; if (!isOpen || !modelSubOpen) return;
requestAnimationFrame(() => { requestAnimationFrame(() => {
const search = document.querySelector<HTMLElement>( const search = document.querySelector<HTMLElement>(
'[data-slot="dropdown-menu-content"] input' '[data-slot="dropdown-menu-sub-content"] input'
); );
search?.focus({ preventScroll: true }); search?.focus({ preventScroll: true });
@@ -188,7 +210,7 @@
<DropdownMenu.Trigger <DropdownMenu.Trigger
{...props} {...props}
class={[ class={[
`relative 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`, `relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 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 !ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400' ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText : forceForegroundText
@@ -203,16 +225,22 @@
> >
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" /> <MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
{#if selectedOption} <span class="flex min-w-0 items-center gap-1">
<ModelId {#if selectedOption}
class="min-w-0 overflow-hidden" <ModelId
hideOrgName={false} class="min-w-0 overflow-hidden"
hideQuantization hideOrgName={!showOrgNameInTrigger}
modelId={selectedOption.model} hideQuantization
/> modelId={selectedOption.model}
{:else} />
<span class="min-w-0 font-medium">Select model</span> {:else}
{/if} <span class="min-w-0 font-medium">Select model</span>
{/if}
{#if reasoning.isReasoningActive}
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
{/if}
</span>
{#if ms.updating || ms.isLoadingModel} {#if ms.updating || ms.isLoadingModel}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" /> <Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
@@ -236,73 +264,94 @@
<DropdownMenu.Content <DropdownMenu.Content
align="end" align="end"
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]" class="w-full md:min-w-64 md:max-w-80 max-w-[calc(100vw-2rem)]"
onOpenAutoFocus={(event) => event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()}
> >
<DropdownMenuSearchable <DropdownMenu.Sub bind:open={modelSubOpen}>
emptyMessage="No models found." <DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache} <MODEL_SELECTOR_ICON class="h-4 w-4" />
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> {#if selectedOption}
</button> <ModelId
{/if} class="min-w-0 flex-1 overflow-hidden"
hideOrgName={!showOrgNameInTrigger}
{#if ms.filteredOptions.length === 0} hideQuantization
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p> modelId={selectedOption.model}
{/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} {:else}
<span class="min-w-0 flex-1 truncate text-muted-foreground">No model</span>
{/if}
</DropdownMenu.SubTrigger>
<ModelsSelectorList <DropdownMenu.SubContent class="w-100 max-w-[calc(100vw-2rem)] pt-0">
activeId={ms.activeId} <DropdownMenuSearchable
{currentModel} emptyMessage="No models found."
groups={ms.groupedFilteredOptions} isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
onInfoClick={ms.handleInfoClick} onSearchChange={(v) => ms.setSearchTerm(v)}
onSelect={ms.handleSelect} onSearchKeyDown={handleSearchKeyDown}
renderOption={modelOption} placeholder="Search models..."
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none" searchValue={ms.searchTerm}
/> >
</div> <div class="models-list">
</DropdownMenuSearchable> {#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.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
{:else} {:else}
@@ -332,12 +381,16 @@
{#if selectedOption} {#if selectedOption}
<ModelId <ModelId
class="min-w-0 overflow-hidden" class="min-w-0 overflow-hidden"
hideOrgName={false} hideOrgName={!showOrgNameInTrigger}
hideQuantization hideQuantization
modelId={selectedOption.model} modelId={selectedOption.model}
/> />
{/if} {/if}
{#if reasoning.isReasoningActive}
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
{/if}
{#if ms.updating} {#if ms.updating}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" /> <Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
{/if} {/if}
@@ -58,6 +58,10 @@
let loadProgress = $derived(isLoading ? modelsStore.status.getLoadProgress(option.model) : null); let loadProgress = $derived(isLoading ? modelsStore.status.getLoadProgress(option.model) : null);
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100)); let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
let loadTitle = $derived(modelLoadProgressText(loadProgress)); let loadTitle = $derived(modelLoadProgressText(loadProgress));
let modalities = $derived(option.modalities);
let capabilities = $derived.by(() => ({
reasoning: modelsStore.props.checkModelSupportsThinking(option.model)
}));
</script> </script>
<div <div
@@ -65,9 +69,11 @@
class={[ class={[
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none', 'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
'cursor-pointer', 'cursor-pointer',
isSelected && 'bg-accent/50 text-accent-foreground', isSelected && !isHighlighted && 'bg-accent/50',
isHighlighted && 'bg-accent', isHighlighted && 'bg-accent',
!isSelected && !isHighlighted && 'hover:bg-muted', (isSelected || isHighlighted) && 'text-accent-foreground',
'hover:bg-accent',
'focus:bg-accent',
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground' isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
]} ]}
onclick={() => onSelect(option.id)} onclick={() => onSelect(option.id)}
@@ -79,9 +85,12 @@
> >
<ModelId <ModelId
aliases={option.aliases} aliases={option.aliases}
{capabilities}
class="flex-1" class="flex-1"
{hideOrgName} {hideOrgName}
{modalities}
modelId={option.model} modelId={option.model}
showRawTooltip
tags={option.tags} tags={option.tags}
/> />
@@ -1,3 +1,4 @@
import { ModelModality } from '$lib/enums';
import type { ModelOption } from '$lib/types/models'; import type { ModelOption } from '$lib/types/models';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
@@ -17,6 +18,23 @@ export interface GroupedModelOptions {
available: OrgGroup[]; available: OrgGroup[];
} }
function matchesModality(option: ModelOption, term: string): boolean {
const modalities = option.modalities;
if (!modalities) return false;
switch (term) {
case ModelModality.VISION.toLowerCase():
return modalities.vision;
case ModelModality.AUDIO.toLowerCase():
return modalities.audio;
case ModelModality.VIDEO.toLowerCase():
return modalities.video;
default:
return false;
}
}
export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] { export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] {
const term = searchTerm.trim().toLowerCase(); const term = searchTerm.trim().toLowerCase();
@@ -27,7 +45,8 @@ export function filterModelOptions(options: ModelOption[], searchTerm: string):
option.model.toLowerCase().includes(term) || option.model.toLowerCase().includes(term) ||
option.name?.toLowerCase().includes(term) || option.name?.toLowerCase().includes(term) ||
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) || option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) ||
matchesModality(option, term)
); );
} }
+27 -1
View File
@@ -8,10 +8,13 @@ import {
File as FileIcon, File as FileIcon,
FileText as FileTextIcon, FileText as FileTextIcon,
Image as ImageIcon, Image as ImageIcon,
Lightbulb as ReasoningIcon,
Mic as AudioIcon, Mic as AudioIcon,
Video as VideoIcon Video as VideoIcon
} from '@lucide/svelte'; } from '@lucide/svelte';
import { FileTypeCategory, ModelModality } from '$lib/enums'; import { FileTypeCategory, ModelCapability, ModelModality } from '$lib/enums';
import type { ModelCapabilities, ModelModalities } from '$lib/types/models';
import type { Component } from 'svelte';
export const FILE_TYPE_ICONS = { export const FILE_TYPE_ICONS = {
[FileTypeCategory.AUDIO]: AudioIcon, [FileTypeCategory.AUDIO]: AudioIcon,
@@ -35,6 +38,29 @@ export const MODALITY_LABELS = {
[ModelModality.VISION]: 'Vision' [ModelModality.VISION]: 'Vision'
} as const; } as const;
/** Maps an input ModelModality to the boolean flag it drives on the ModelModalities type */
export const MODALITY_FLAG_KEYS: Record<
Exclude<ModelModality, ModelModality.TEXT>,
keyof ModelModalities
> = {
[ModelModality.AUDIO]: 'audio',
[ModelModality.VIDEO]: 'video',
[ModelModality.VISION]: 'vision'
};
export const CAPABILITY_ICONS: Record<ModelCapability, Component> = {
[ModelCapability.REASONING]: ReasoningIcon
} as const;
export const CAPABILITY_LABELS: Record<ModelCapability, string> = {
[ModelCapability.REASONING]: 'Reasoning'
} as const;
/** Maps a ModelCapability to the boolean flag it drives on the ModelCapabilities type */
export const CAPABILITY_FLAG_KEYS: Record<ModelCapability, keyof ModelCapabilities> = {
[ModelCapability.REASONING]: 'reasoning'
};
// Shared SVG icon strings for copy and preview buttons // Shared SVG icon strings for copy and preview buttons
export const COPY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`; export const COPY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
@@ -54,6 +54,7 @@ export const SETTINGS_KEYS = {
SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions', SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
// Display // Display
SHOW_MESSAGE_STATS: 'showMessageStats', SHOW_MESSAGE_STATS: 'showMessageStats',
SHOW_MODEL_ORG_NAME_IN_TRIGGER: 'showModelOrgNameInTrigger',
SHOW_MODEL_QUANTIZATION: 'showModelQuantization', SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
SHOW_MODEL_TAGS: 'showModelTags', SHOW_MODEL_TAGS: 'showModelTags',
SHOW_RAW_MODEL_NAMES: 'showRawModelNames', SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
@@ -111,9 +111,8 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
type: SettingsFieldType.CHECKBOX type: SettingsFieldType.CHECKBOX
}, },
{ {
defaultValue: false, defaultValue: true,
help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.', help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
isExperimental: true,
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY, key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
label: 'Show microphone on empty input', label: 'Show microphone on empty input',
type: SettingsFieldType.CHECKBOX type: SettingsFieldType.CHECKBOX
@@ -283,6 +282,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
label: 'Show model tags', label: 'Show model tags',
type: SettingsFieldType.CHECKBOX type: SettingsFieldType.CHECKBOX
}, },
{
defaultValue: false,
help: 'Display the organization name in the model selector trigger button.',
key: SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER,
label: 'Show organization name in model selector trigger',
type: SettingsFieldType.CHECKBOX
},
{ {
defaultValue: false, defaultValue: false,
help: 'Display the current build version in the bottom-right corner of the interface.', help: 'Display the current build version in the bottom-right corner of the interface.',
+1 -1
View File
@@ -67,7 +67,7 @@ export {
JsonSchemaType JsonSchemaType
} from './mcp.enums'; } from './mcp.enums';
export { ModelModality } from './model.enums'; export { ModelCapability, ModelModality } from './model.enums';
export { ServerRole, ServerModelStatus, ServerModelsSseEventType } from './server.enums'; export { ServerRole, ServerModelStatus, ServerModelsSseEventType } from './server.enums';
+4
View File
@@ -4,3 +4,7 @@ export enum ModelModality {
VIDEO = 'VIDEO', VIDEO = 'VIDEO',
VISION = 'VISION' VISION = 'VISION'
} }
export enum ModelCapability {
REASONING = 'REASONING'
}
@@ -8,6 +8,7 @@ import { getConversationModel } from '$lib/utils';
export interface UseReasoningMenuReturn { export interface UseReasoningMenuReturn {
readonly modelSupportsThinking: boolean; readonly modelSupportsThinking: boolean;
readonly thinkingEnabled: boolean; readonly thinkingEnabled: boolean;
readonly isReasoningActive: boolean;
readonly isOff: boolean; readonly isOff: boolean;
readonly currentEffort: ReasoningEffort; readonly currentEffort: ReasoningEffort;
readonly levels: ReasoningEffortLevel[]; readonly levels: ReasoningEffortLevel[];
@@ -59,6 +60,12 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
const thinkingEnabled = $derived( const thinkingEnabled = $derived(
currentEffort !== ReasoningEffort.OFF && currentEffort !== ReasoningEffort.DEFAULT currentEffort !== ReasoningEffort.OFF && currentEffort !== ReasoningEffort.DEFAULT
); );
// Thinking is effectively on (lightbulb lit) either when an explicit effort
// is selected, or when the effort is left at "Default" and the model
// supports thinking.
const isReasoningActive = $derived(
thinkingEnabled || (currentEffort === ReasoningEffort.DEFAULT && modelSupportsThinking)
);
return { return {
get currentEffort() { get currentEffort() {
@@ -67,6 +74,9 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
get isOff() { get isOff() {
return currentEffort === ReasoningEffort.OFF; return currentEffort === ReasoningEffort.OFF;
}, },
get isReasoningActive() {
return isReasoningActive;
},
isSelected(level: ReasoningEffortLevel): boolean { isSelected(level: ReasoningEffortLevel): boolean {
return currentEffort === level.value; return currentEffort === level.value;
}, },
+10 -1
View File
@@ -187,8 +187,17 @@ export class ModelsService {
// 6. Model name = segments before params; tags = remaining segments after params // 6. Model name = segments before params; tags = remaining segments after params
const pivotIdx = paramsIdx !== MODEL_ID.NOT_FOUND ? paramsIdx : segments.length; const pivotIdx = paramsIdx !== MODEL_ID.NOT_FOUND ? paramsIdx : segments.length;
const modelSegments = segments.slice(0, pivotIdx);
result.modelName = segments.slice(0, pivotIdx).join(MODEL_ID.SEGMENT_SEPARATOR) || null; // strip trailing container-format segments (e.g. GGUF) from the model name
while (
modelSegments.length > 0 &&
MODEL_ID.IGNORED_SEGMENTS.has(modelSegments[modelSegments.length - 1].toUpperCase())
) {
modelSegments.pop();
}
result.modelName = modelSegments.join(MODEL_ID.SEGMENT_SEPARATOR) || null;
if (paramsIdx !== MODEL_ID.NOT_FOUND) { if (paramsIdx !== MODEL_ID.NOT_FOUND) {
result.tags = segments.slice(paramsIdx + 1).filter((_, relIdx) => { result.tags = segments.slice(paramsIdx + 1).filter((_, relIdx) => {
+1
View File
@@ -89,6 +89,7 @@ export type {
// Model types // Model types
export type { export type {
ModelCapabilities,
ModelModalities, ModelModalities,
ModelOption, ModelOption,
ModelLoadProgress, ModelLoadProgress,
+4
View File
@@ -6,6 +6,10 @@ export interface ModelModalities {
video: boolean; video: boolean;
} }
export interface ModelCapabilities {
reasoning: boolean;
}
export interface ModelOption { export interface ModelOption {
id: string; id: string;
name: string; name: string;
@@ -97,6 +97,38 @@ describe('parseModelId', () => {
}); });
}); });
it('strips trailing container format segments from model names', () => {
expect(parseModelId('unsloth/DeepSeek-V4-Flash-0731-GGUF:Q2_K_XL')).toStrictEqual({
activatedParams: null,
modelName: 'DeepSeek-V4-Flash-0731',
orgName: 'unsloth',
params: null,
quantization: 'Q2_K_XL',
raw: 'unsloth/DeepSeek-V4-Flash-0731-GGUF:Q2_K_XL',
tags: []
});
expect(parseModelId('unsloth/Laguna-S-2.1-GGUF:Q4_K_XL')).toStrictEqual({
activatedParams: null,
modelName: 'Laguna-S-2.1',
orgName: 'unsloth',
params: null,
quantization: 'Q4_K_XL',
raw: 'unsloth/Laguna-S-2.1-GGUF:Q4_K_XL',
tags: []
});
expect(parseModelId('org/Model-Name-GGUF')).toStrictEqual({
activatedParams: null,
modelName: 'Model-Name',
orgName: 'org',
params: null,
quantization: null,
raw: 'org/Model-Name-GGUF',
tags: []
});
});
it('handles real-world examples correctly', () => { it('handles real-world examples correctly', () => {
expect(parseModelId('meta-llama/Llama-3.1-8B')).toStrictEqual({ expect(parseModelId('meta-llama/Llama-3.1-8B')).toStrictEqual({
activatedParams: null, activatedParams: null,