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:
@@ -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}
|
||||||
|
|||||||
+37
-43
@@ -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"
|
||||||
|
|||||||
+59
-61
@@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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,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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export type {
|
|||||||
|
|
||||||
// Model types
|
// Model types
|
||||||
export type {
|
export type {
|
||||||
|
ModelCapabilities,
|
||||||
ModelModalities,
|
ModelModalities,
|
||||||
ModelOption,
|
ModelOption,
|
||||||
ModelLoadProgress,
|
ModelLoadProgress,
|
||||||
|
|||||||
Vendored
+4
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user