From cae63579b67a2c51f160705956abbc8e8ebec458 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Thu, 27 Aug 2026 14:47:36 +0200 Subject: [PATCH] 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 --- .../app/badges/BadgesModality.svelte | 39 ++-- .../ChatFormActionAddDropdown.svelte | 80 +++---- .../ChatFormActionAddReasoningSubmenu.svelte | 120 +++++----- .../lib/components/app/models/ModelId.svelte | 125 ++++++++-- .../app/models/ModelsSelectorDropdown.svelte | 213 +++++++++++------- .../app/models/ModelsSelectorOption.svelte | 13 +- .../ui/src/lib/components/app/models/utils.ts | 21 +- tools/ui/src/lib/constants/icons.constants.ts | 28 ++- .../lib/constants/settings-keys.constants.ts | 1 + .../src/lib/constants/settings.constants.ts | 10 +- tools/ui/src/lib/enums/index.ts | 2 +- tools/ui/src/lib/enums/model.enums.ts | 4 + .../lib/hooks/use-reasoning-menu.svelte.ts | 10 + tools/ui/src/lib/services/models.service.ts | 11 +- tools/ui/src/lib/types/index.ts | 1 + tools/ui/src/lib/types/models.d.ts | 4 + tools/ui/tests/unit/model-id-parser.test.ts | 32 +++ 17 files changed, 475 insertions(+), 239 deletions(-) diff --git a/tools/ui/src/lib/components/app/badges/BadgesModality.svelte b/tools/ui/src/lib/components/app/badges/BadgesModality.svelte index 4eb3e7838d..83b1b46aff 100644 --- a/tools/ui/src/lib/components/app/badges/BadgesModality.svelte +++ b/tools/ui/src/lib/components/app/badges/BadgesModality.svelte @@ -1,5 +1,5 @@ -{#each modalities as modality (modality)} - {#if modality === ModelModality.VISION || modality === ModelModality.AUDIO || modality === ModelModality.VIDEO} - - {#if modality === ModelModality.VISION} - +{#each visible as modality (modality)} + {@const ModalityIcon = MODALITY_ICONS[modality]} + + - Vision (Image) - {:else if modality === ModelModality.VIDEO} - - {/if} + {MODALITY_LABELS[modality]} + {/each} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte index 61f816affa..9c3a9e8912 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte @@ -1,5 +1,5 @@
@@ -80,50 +92,32 @@ } }} > - - - + attachmentMenu.callbacks[AttachmentAction.FILE_UPLOAD]()} + > + + Add files - - - {#each ATTACHMENT_FILE_ITEMS as item (item.id)} - {@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)} - {#if enabled} - attachmentMenu.callbacks[item.action]()} - > - + {#if supportedModalities.length > 0} + + {#each supportedModalities as modality (modality.label)} + + + + - {item.label} - - {:else if item.disabledTooltip} - - - {#snippet child({ props })} -
- - - - {item.label} - -
- {/snippet} -
- - -

{item.disabledTooltip}

-
-
- {/if} - {/each} -
-
+ +

{modality.label}

+
+ + {/each} + + {/if} + + -{#if reasoning.modelSupportsThinking} - - - {#if reasoning.thinkingEnabled} - - {:else if reasoning.isOff} - - {:else} - - {/if} + + + {#if reasoning.isReasoningActive} + + {:else if reasoning.isOff} + + {:else} + + {/if} - - Reasoning - - - {reasoning.currentEffort} - - - - - - {#each reasoning.levels as level (level.value)} - {@const tokenLabel = reasoning.tokenLabel(level)} - reasoning.select(level)} - > - {#if reasoning.isSelected(level)} - - {:else} -
- {/if} + Reasoning - {level.label} + + {reasoning.currentEffort} + + +
- {#if tokenLabel} - - {tokenLabel} - - {/if} + + {#each reasoning.levels as level (level.value)} + {@const tokenLabel = reasoning.tokenLabel(level)} + reasoning.select(level)} + > + {#if reasoning.isSelected(level)} + + {:else} +
+ {/if} - {#if level.hasInfo} - - - - + {level.label} - -

Maximum reasoning effort with extended context usage

-
-
- {/if} -
- {/each} -
-
-{/if} + {#if tokenLabel} + + {tokenLabel} + + {/if} + + {#if level.hasInfo} + + + + + + +

Maximum reasoning effort with extended context usage

+
+
+ {/if} +
+ {/each} + + diff --git a/tools/ui/src/lib/components/app/models/ModelId.svelte b/tools/ui/src/lib/components/app/models/ModelId.svelte index cae0a7e3ed..0b9722a73c 100644 --- a/tools/ui/src/lib/components/app/models/ModelId.svelte +++ b/tools/ui/src/lib/components/app/models/ModelId.svelte @@ -1,27 +1,44 @@ @@ -50,37 +77,87 @@ {#if resolvedShowRaw} {:else} - + {#snippet nameAndBadges()} {#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName} - {#if parsed.params} - - {parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''} - - {/if} - - {#if parsed.quantization && !resolvedHideQuantization} - - {parsed.quantization} - - {/if} - - {#if primaryAlias} - {#if primaryAlias !== parsed.modelName} - {parsed.modelName ?? modelId} + + {#if parsed.params} + + {parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''} + {/if} - {:else if uniqueAliases.length > 1} - {#each uniqueAliases as alias (alias)} - {alias} - {/each} + + {#if parsed.quantization && !resolvedHideQuantization} + + {parsed.quantization} + + {/if} + + {#if primaryAlias} + {#if primaryAlias !== parsed.modelName} + {parsed.modelName ?? modelId} + {/if} + {:else if uniqueAliases.length > 1} + {#each uniqueAliases as alias (alias)} + {alias} + {/each} + {/if} + + {#if uniqueTags.length > 0 && !resolvedHideTags} + {#each uniqueTags as tag (tag)} + {tag} + {/each} + {/if} + + {/snippet} + + + {#if showRawTooltip} + + + {@render nameAndBadges()} + + + +

{modelId}

+
+
+ {:else} + {@render nameAndBadges()} {/if} - {#if uniqueTags.length > 0 && !resolvedHideTags} - {#each uniqueTags as tag (tag)} - {tag} - {/each} + {#if activeCapabilities.length > 0 || activeModalities.length > 0} + + {#each activeCapabilities as capability (capability)} + {@const CapabilityIcon = CAPABILITY_ICONS[capability]} + + + + + + + +

{CAPABILITY_LABELS[capability]}

+
+
+ {/each} + + {#each activeModalities as modality (modality)} + {@const ModalityIcon = MODALITY_ICONS[modality]} + + + + + + + +

{MODALITY_LABELS[modality]}

+
+
+ {/each} +
{/if}
{/if} diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte index 1e1798e2fa..30dedcbae9 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte @@ -1,8 +1,9 @@
onSelect(option.id)} @@ -79,9 +85,12 @@ > diff --git a/tools/ui/src/lib/components/app/models/utils.ts b/tools/ui/src/lib/components/app/models/utils.ts index b78e7085b7..35bfb58a25 100644 --- a/tools/ui/src/lib/components/app/models/utils.ts +++ b/tools/ui/src/lib/components/app/models/utils.ts @@ -1,3 +1,4 @@ +import { ModelModality } from '$lib/enums'; import type { ModelOption } from '$lib/types/models'; import { SvelteMap } from 'svelte/reactivity'; @@ -17,6 +18,23 @@ export interface GroupedModelOptions { 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[] { const term = searchTerm.trim().toLowerCase(); @@ -27,7 +45,8 @@ export function filterModelOptions(options: ModelOption[], searchTerm: string): option.model.toLowerCase().includes(term) || option.name?.toLowerCase().includes(term) || option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) || - option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) + option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) || + matchesModality(option, term) ); } diff --git a/tools/ui/src/lib/constants/icons.constants.ts b/tools/ui/src/lib/constants/icons.constants.ts index 5563740509..a0ce22f95e 100644 --- a/tools/ui/src/lib/constants/icons.constants.ts +++ b/tools/ui/src/lib/constants/icons.constants.ts @@ -8,10 +8,13 @@ import { File as FileIcon, FileText as FileTextIcon, Image as ImageIcon, + Lightbulb as ReasoningIcon, Mic as AudioIcon, Video as VideoIcon } 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 = { [FileTypeCategory.AUDIO]: AudioIcon, @@ -35,6 +38,29 @@ export const MODALITY_LABELS = { [ModelModality.VISION]: 'Vision' } as const; +/** Maps an input ModelModality to the boolean flag it drives on the ModelModalities type */ +export const MODALITY_FLAG_KEYS: Record< + Exclude, + keyof ModelModalities +> = { + [ModelModality.AUDIO]: 'audio', + [ModelModality.VIDEO]: 'video', + [ModelModality.VISION]: 'vision' +}; + +export const CAPABILITY_ICONS: Record = { + [ModelCapability.REASONING]: ReasoningIcon +} as const; + +export const CAPABILITY_LABELS: Record = { + [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.REASONING]: 'reasoning' +}; + // Shared SVG icon strings for copy and preview buttons export const COPY_ICON_SVG = ``; diff --git a/tools/ui/src/lib/constants/settings-keys.constants.ts b/tools/ui/src/lib/constants/settings-keys.constants.ts index c8761c7587..2c923cee38 100644 --- a/tools/ui/src/lib/constants/settings-keys.constants.ts +++ b/tools/ui/src/lib/constants/settings-keys.constants.ts @@ -54,6 +54,7 @@ export const SETTINGS_KEYS = { SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions', // Display SHOW_MESSAGE_STATS: 'showMessageStats', + SHOW_MODEL_ORG_NAME_IN_TRIGGER: 'showModelOrgNameInTrigger', SHOW_MODEL_QUANTIZATION: 'showModelQuantization', SHOW_MODEL_TAGS: 'showModelTags', SHOW_RAW_MODEL_NAMES: 'showRawModelNames', diff --git a/tools/ui/src/lib/constants/settings.constants.ts b/tools/ui/src/lib/constants/settings.constants.ts index b4699af197..7140bbcc4a 100644 --- a/tools/ui/src/lib/constants/settings.constants.ts +++ b/tools/ui/src/lib/constants/settings.constants.ts @@ -111,9 +111,8 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [ 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.', - isExperimental: true, key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY, label: 'Show microphone on empty input', type: SettingsFieldType.CHECKBOX @@ -283,6 +282,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [ label: 'Show model tags', 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, help: 'Display the current build version in the bottom-right corner of the interface.', diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts index efe5789e20..e0b15bc729 100644 --- a/tools/ui/src/lib/enums/index.ts +++ b/tools/ui/src/lib/enums/index.ts @@ -67,7 +67,7 @@ export { JsonSchemaType } from './mcp.enums'; -export { ModelModality } from './model.enums'; +export { ModelCapability, ModelModality } from './model.enums'; export { ServerRole, ServerModelStatus, ServerModelsSseEventType } from './server.enums'; diff --git a/tools/ui/src/lib/enums/model.enums.ts b/tools/ui/src/lib/enums/model.enums.ts index df85c9d896..db085a27a7 100644 --- a/tools/ui/src/lib/enums/model.enums.ts +++ b/tools/ui/src/lib/enums/model.enums.ts @@ -4,3 +4,7 @@ export enum ModelModality { VIDEO = 'VIDEO', VISION = 'VISION' } + +export enum ModelCapability { + REASONING = 'REASONING' +} diff --git a/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts b/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts index 2cb9c90609..3c52d7ab27 100644 --- a/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts +++ b/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts @@ -8,6 +8,7 @@ import { getConversationModel } from '$lib/utils'; export interface UseReasoningMenuReturn { readonly modelSupportsThinking: boolean; readonly thinkingEnabled: boolean; + readonly isReasoningActive: boolean; readonly isOff: boolean; readonly currentEffort: ReasoningEffort; readonly levels: ReasoningEffortLevel[]; @@ -59,6 +60,12 @@ export function useReasoningMenu(): UseReasoningMenuReturn { const thinkingEnabled = $derived( 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 { get currentEffort() { @@ -67,6 +74,9 @@ export function useReasoningMenu(): UseReasoningMenuReturn { get isOff() { return currentEffort === ReasoningEffort.OFF; }, + get isReasoningActive() { + return isReasoningActive; + }, isSelected(level: ReasoningEffortLevel): boolean { return currentEffort === level.value; }, diff --git a/tools/ui/src/lib/services/models.service.ts b/tools/ui/src/lib/services/models.service.ts index bb1bbd356a..b0fcca0eec 100644 --- a/tools/ui/src/lib/services/models.service.ts +++ b/tools/ui/src/lib/services/models.service.ts @@ -187,8 +187,17 @@ export class ModelsService { // 6. Model name = segments before params; tags = remaining segments after params 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) { result.tags = segments.slice(paramsIdx + 1).filter((_, relIdx) => { diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts index 62947cd493..d91c2811a4 100644 --- a/tools/ui/src/lib/types/index.ts +++ b/tools/ui/src/lib/types/index.ts @@ -89,6 +89,7 @@ export type { // Model types export type { + ModelCapabilities, ModelModalities, ModelOption, ModelLoadProgress, diff --git a/tools/ui/src/lib/types/models.d.ts b/tools/ui/src/lib/types/models.d.ts index b32c16f6f2..0b7523113d 100644 --- a/tools/ui/src/lib/types/models.d.ts +++ b/tools/ui/src/lib/types/models.d.ts @@ -6,6 +6,10 @@ export interface ModelModalities { video: boolean; } +export interface ModelCapabilities { + reasoning: boolean; +} + export interface ModelOption { id: string; name: string; diff --git a/tools/ui/tests/unit/model-id-parser.test.ts b/tools/ui/tests/unit/model-id-parser.test.ts index 3dac1dfaba..5903f196ab 100644 --- a/tools/ui/tests/unit/model-id-parser.test.ts +++ b/tools/ui/tests/unit/model-id-parser.test.ts @@ -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', () => { expect(parseModelId('meta-llama/Llama-3.1-8B')).toStrictEqual({ activatedParams: null,