feat: New Model Selection UX WIP

This commit is contained in:
Aleksander Grygier 2025-11-21 14:26:50 +01:00
parent c26c3402fe
commit 8b1d96755e
6 changed files with 175 additions and 213 deletions

View File

@ -2,15 +2,13 @@
import { Square, ArrowUp } from '@lucide/svelte'; import { Square, ArrowUp } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { import {
BadgeModelName,
ChatFormActionFileAttachments, ChatFormActionFileAttachments,
ChatFormModelSelector,
ChatFormActionRecord, ChatFormActionRecord,
DialogModelInformation SelectorModel
} from '$lib/components/app'; } from '$lib/components/app';
import { FileTypeCategory } from '$lib/enums/files'; import { FileTypeCategory } from '$lib/enums/files';
import { getFileTypeCategory } from '$lib/utils/file-type'; import { getFileTypeCategory } from '$lib/utils/file-type';
import { isRouterMode, supportsAudio } from '$lib/stores/server.svelte'; import { supportsAudio } from '$lib/stores/server.svelte';
import type { ChatUploadedFile } from '$lib/types/chat'; import type { ChatUploadedFile } from '$lib/types/chat';
interface Props { interface Props {
@ -39,29 +37,18 @@
onStop onStop
}: Props = $props(); }: Props = $props();
let inRouterMode = $derived(isRouterMode());
let hasAudioModality = $derived(supportsAudio()); let hasAudioModality = $derived(supportsAudio());
let hasAudioAttachments = $derived( let hasAudioAttachments = $derived(
uploadedFiles.some((file) => getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) uploadedFiles.some((file) => getFileTypeCategory(file.type) === FileTypeCategory.AUDIO)
); );
let shouldShowRecordButton = $derived(hasAudioModality && !hasText && !hasAudioAttachments); let shouldShowRecordButton = $derived(hasAudioModality && !hasText && !hasAudioAttachments);
let shouldShowSubmitButton = $derived(!shouldShowRecordButton || hasAudioAttachments); let shouldShowSubmitButton = $derived(!shouldShowRecordButton || hasAudioAttachments);
let showModelInfoDialog = $state(false);
</script> </script>
<div class="flex w-full items-center gap-3 {className}"> <div class="flex w-full items-center gap-3 {className}">
<ChatFormActionFileAttachments class="mr-auto" {disabled} {onFileUpload} /> <ChatFormActionFileAttachments class="mr-auto" {disabled} {onFileUpload} />
{#if !inRouterMode} <SelectorModel class="max-w-80" />
<BadgeModelName
class="clickable max-w-80"
onclick={() => (showModelInfoDialog = true)}
showTooltip
/>
{:else}
<ChatFormModelSelector class="shrink-0" />
{/if}
{#if isLoading} {#if isLoading}
<Button <Button
@ -89,8 +76,3 @@
{/if} {/if}
{/if} {/if}
</div> </div>
<DialogModelInformation
bind:open={showModelInfoDialog}
onOpenChange={(open) => (showModelInfoDialog = open)}
/>

View File

@ -1,26 +1,18 @@
<script lang="ts"> <script lang="ts">
import { ChatMessageThinkingBlock, MarkdownContent } from '$lib/components/app'; import { ChatMessageThinkingBlock, MarkdownContent, SelectorModel } from '$lib/components/app';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte'; import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { isLoading } from '$lib/stores/chat.svelte'; import { isLoading } from '$lib/stores/chat.svelte';
import autoResizeTextarea from '$lib/utils/autoresize-textarea'; import autoResizeTextarea from '$lib/utils/autoresize-textarea';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { import { Check, Copy, X, Gauge, Clock, WholeWord, Wrench } from '@lucide/svelte';
Check,
Copy,
Package,
X,
Gauge,
Clock,
WholeWord,
ChartNoAxesColumn,
Wrench
} from '@lucide/svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import { INPUT_CLASSES } from '$lib/constants/input-classes'; import { INPUT_CLASSES } from '$lib/constants/input-classes';
import ChatMessageActions from './ChatMessageActions.svelte'; import ChatMessageActions from './ChatMessageActions.svelte';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { selectModel } from '$lib/stores/models.svelte';
import { copyToClipboard } from '$lib/utils/copy'; import { copyToClipboard } from '$lib/utils/copy';
import type { ApiChatCompletionToolCall } from '$lib/types/api'; import type { ApiChatCompletionToolCall } from '$lib/types/api';
@ -92,6 +84,7 @@
const processingState = useProcessingState(); const processingState = useProcessingState();
let currentConfig = $derived(config()); let currentConfig = $derived(config());
let isRouter = $derived(isRouterMode());
let displayedModel = $derived((): string | null => { let displayedModel = $derived((): string | null => {
if (!currentConfig.showModelInfo) return null; if (!currentConfig.showModelInfo) return null;
@ -103,6 +96,16 @@
return null; return null;
}); });
async function handleModelChange(modelId: string) {
try {
await selectModel(modelId);
onRegenerate();
} catch (error) {
console.error('Failed to change model:', error);
}
}
function handleCopyModel() { function handleCopyModel() {
const model = displayedModel(); const model = displayedModel();
@ -243,21 +246,52 @@
<div class="info my-6 grid gap-4"> <div class="info my-6 grid gap-4">
{#if displayedModel()} {#if displayedModel()}
<span class="inline-flex items-center gap-2 text-xs text-muted-foreground"> <span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1"> {#if isRouter && currentConfig.modelSelectorEnabled}
<Package class="h-3.5 w-3.5" /> <SelectorModel
currentModel={displayedModel()}
onModelChange={handleModelChange}
disabled={isLoading()}
/>
{:else}
<button
class="inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
onclick={handleCopyModel}
>
{displayedModel()}
<span>Model used:</span> <Copy class="ml-1 h-3 w-3 " />
</span> </button>
{/if}
<button {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
class="inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75" {@const tokensPerSecond =
onclick={handleCopyModel} (message.timings.predicted_n / message.timings.predicted_ms) * 1000}
>
{displayedModel()}
<Copy class="ml-1 h-3 w-3 " /> <span
</button> class="inline-flex items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
>
<WholeWord class="h-3 w-3" />
{message.timings.predicted_n} tokens
</span>
<span
class="inline-flex items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
>
<Clock class="h-3 w-3" />
{(message.timings.predicted_ms / 1000).toFixed(2)}s
</span>
<span
class="inline-flex items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
>
<Gauge class="h-3 w-3" />
{tokensPerSecond.toFixed(2)} tokens/s
</span>
{/if}
</span> </span>
{/if} {/if}
@ -301,38 +335,6 @@
</span> </span>
{/if} {/if}
{/if} {/if}
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
{@const tokensPerSecond = (message.timings.predicted_n / message.timings.predicted_ms) * 1000}
<span class="inline-flex items-center gap-2 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1">
<ChartNoAxesColumn class="h-3.5 w-3.5" />
<span>Statistics:</span>
</span>
<div class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span
class="inline-flex items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
>
<Gauge class="h-3 w-3" />
{tokensPerSecond.toFixed(2)} tokens/s
</span>
<span
class="inline-flex items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
>
<WholeWord class="h-3 w-3" />
{message.timings.predicted_n} tokens
</span>
<span
class="inline-flex items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
>
<Clock class="h-3 w-3" />
{(message.timings.predicted_ms / 1000).toFixed(2)}s
</span>
</div>
</span>
{/if}
</div> </div>
{#if message.timestamp && !isEditing} {#if message.timestamp && !isEditing}

View File

@ -154,8 +154,6 @@
</Sidebar.Menu> </Sidebar.Menu>
</Sidebar.GroupContent> </Sidebar.GroupContent>
</Sidebar.Group> </Sidebar.Group>
<div class="bottom-0 z-10 bg-sidebar bg-sidebar/50 px-4 py-4 backdrop-blur-lg md:sticky"></div>
</ScrollArea> </ScrollArea>
<DialogConfirmation <DialogConfirmation

View File

@ -12,7 +12,6 @@ export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActions
export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.svelte'; export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.svelte';
export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte'; export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte'; export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
export { default as ChatFormModelSelector } from './chat/ChatForm/ChatFormModelSelector.svelte';
export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte'; export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte'; export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
@ -57,6 +56,7 @@ export { default as ConversationSelection } from './misc/ConversationSelection.s
export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte'; export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
export { default as MarkdownContent } from './misc/MarkdownContent.svelte'; export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
export { default as RemoveButton } from './misc/RemoveButton.svelte'; export { default as RemoveButton } from './misc/RemoveButton.svelte';
export { default as SelectorModel } from './misc/SelectorModel.svelte';
// Server // Server

View File

@ -1,36 +1,46 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { ChevronDown, Loader2 } from '@lucide/svelte'; import { ChevronDown, Loader2, Package } from '@lucide/svelte';
import { cn } from '$lib/components/ui/utils'; import { cn } from '$lib/components/ui/utils';
import { portalToBody } from '$lib/utils/portal-to-body'; import { portalToBody } from '$lib/utils/portal-to-body';
import { import {
fetchModels, fetchModels,
modelOptions, modelOptions,
modelsError,
modelsLoading, modelsLoading,
modelsUpdating, modelsUpdating,
selectModel, selectModel,
selectedModelId selectedModelId
} from '$lib/stores/models.svelte'; } from '$lib/stores/models.svelte';
import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
import { DialogModelInformation } from '$lib/components/app';
import type { ModelOption } from '$lib/types/models'; import type { ModelOption } from '$lib/types/models';
interface Props { interface Props {
class?: string; class?: string;
currentModel?: string | null;
onModelChange?: (modelId: string, modelName: string) => void;
disabled?: boolean;
} }
let { class: className = '' }: Props = $props(); let {
class: className = '',
currentModel = null,
onModelChange,
disabled = false
}: Props = $props();
let options = $derived(modelOptions()); let options = $derived(modelOptions());
let loading = $derived(modelsLoading()); let loading = $derived(modelsLoading());
let updating = $derived(modelsUpdating()); let updating = $derived(modelsUpdating());
let error = $derived(modelsError());
let activeId = $derived(selectedModelId()); let activeId = $derived(selectedModelId());
let isRouter = $derived(isRouterMode());
let serverModel = $derived(serverStore.modelName);
let isMounted = $state(false);
let isOpen = $state(false); let isOpen = $state(false);
let showModelDialog = $state(false);
let container: HTMLDivElement | null = null; let container: HTMLDivElement | null = null;
let triggerButton = $state<HTMLButtonElement | null>(null);
let menuRef = $state<HTMLDivElement | null>(null); let menuRef = $state<HTMLDivElement | null>(null);
let triggerButton = $state<HTMLButtonElement | null>(null);
let menuPosition = $state<{ let menuPosition = $state<{
top: number; top: number;
left: number; left: number;
@ -38,18 +48,51 @@
placement: 'top' | 'bottom'; placement: 'top' | 'bottom';
maxHeight: number; maxHeight: number;
} | null>(null); } | null>(null);
let lockedWidth: number | null = null;
const VIEWPORT_GUTTER = 8;
const MENU_OFFSET = 6;
const MENU_MAX_WIDTH = 320;
onMount(async () => { onMount(async () => {
try { try {
await fetchModels(); await fetchModels();
} catch (error) { } catch (error) {
console.error('Unable to load models:', error); console.error('Unable to load models:', error);
} finally {
isMounted = true;
} }
}); });
function toggleOpen() {
if (loading || updating) return;
if (isRouter) {
// Router mode: show dropdown
if (isOpen) {
closeMenu();
} else {
openMenu();
}
} else {
// Single model mode: show dialog
showModelDialog = true;
}
}
async function openMenu() {
if (loading || updating) return;
isOpen = true;
await tick();
updateMenuPosition();
requestAnimationFrame(() => updateMenuPosition());
}
function closeMenu() {
if (!isOpen) return;
isOpen = false;
menuPosition = null;
}
function handlePointerDown(event: PointerEvent) { function handlePointerDown(event: PointerEvent) {
if (!container) return; if (!container) return;
@ -72,75 +115,6 @@
} }
} }
async function handleSelect(value: string | undefined) {
if (!value) return;
const option = options.find((item) => item.id === value);
if (!option) {
console.error('Model is no longer available');
return;
}
try {
await selectModel(option.id);
} catch (error) {
console.error('Failed to switch model:', error);
}
}
const VIEWPORT_GUTTER = 8;
const MENU_OFFSET = 6;
const MENU_MAX_WIDTH = 320;
async function openMenu() {
if (loading || updating) return;
isOpen = true;
await tick();
updateMenuPosition();
requestAnimationFrame(() => updateMenuPosition());
}
function toggleOpen() {
if (loading || updating) return;
if (isOpen) {
closeMenu();
} else {
void openMenu();
}
}
function closeMenu() {
if (!isOpen) return;
isOpen = false;
menuPosition = null;
lockedWidth = null;
}
async function handleOptionSelect(optionId: string) {
try {
await handleSelect(optionId);
} finally {
closeMenu();
}
}
$effect(() => {
if (loading || updating) {
closeMenu();
}
});
$effect(() => {
const optionCount = options.length;
if (!isOpen || optionCount <= 0) return;
queueMicrotask(() => updateMenuPosition());
});
function updateMenuPosition() { function updateMenuPosition() {
if (!isOpen || !triggerButton || !menuRef) return; if (!isOpen || !triggerButton || !menuRef) return;
@ -159,19 +133,10 @@
constrainedMaxWidth > 0 ? constrainedMaxWidth : Math.min(MENU_MAX_WIDTH, viewportWidth); constrainedMaxWidth > 0 ? constrainedMaxWidth : Math.min(MENU_MAX_WIDTH, viewportWidth);
const desiredMinWidth = Math.min(160, safeMaxWidth || 160); const desiredMinWidth = Math.min(160, safeMaxWidth || 160);
let width = lockedWidth; let width = Math.min(
if (width === null) { Math.max(triggerRect.width, scrollWidth, desiredMinWidth),
const naturalWidth = Math.min(scrollWidth, safeMaxWidth); safeMaxWidth || 320
const baseWidth = Math.max(triggerRect.width, naturalWidth, desiredMinWidth); );
width = Math.min(baseWidth, safeMaxWidth || baseWidth);
lockedWidth = width;
} else {
width = Math.min(Math.max(width, desiredMinWidth), safeMaxWidth || width);
}
if (width > 0) {
menuRef.style.width = `${width}px`;
}
const availableBelow = Math.max( const availableBelow = Math.max(
0, 0,
@ -220,8 +185,6 @@
metrics = aboveMetrics; metrics = aboveMetrics;
} }
menuRef.style.maxHeight = metrics.maxHeight > 0 ? `${Math.round(metrics.maxHeight)}px` : '';
let left = triggerRect.right - width; let left = triggerRect.right - width;
const maxLeft = viewportWidth - VIEWPORT_GUTTER - width; const maxLeft = viewportWidth - VIEWPORT_GUTTER - width;
if (maxLeft < VIEWPORT_GUTTER) { if (maxLeft < VIEWPORT_GUTTER) {
@ -244,63 +207,85 @@
}; };
} }
function handleSelect(modelId: string) {
const option = options.find((opt) => opt.id === modelId);
if (option && onModelChange) {
// If callback provided, use it (for regenerate functionality)
onModelChange(option.id, option.model);
} else if (option) {
// Otherwise, just update the global selection (for form selector)
selectModel(option.id).catch(console.error);
}
closeMenu();
}
function getDisplayOption(): ModelOption | undefined { function getDisplayOption(): ModelOption | undefined {
if (!isRouter) {
// Single model mode: create fake option from server model
if (serverModel) {
return {
id: 'current',
model: serverModel,
name: serverModel.split('/').pop() || serverModel,
capabilities: [] // Empty array for single model mode
};
}
return undefined;
}
// Router mode: use existing logic
if (currentModel) {
return options.find((option) => option.model === currentModel);
}
if (activeId) { if (activeId) {
return options.find((option) => option.id === activeId); return options.find((option) => option.id === activeId);
} }
return options[0]; return options[0];
} }
</script> </script>
<svelte:window onresize={handleResize} /> <svelte:window onresize={handleResize} />
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} /> <svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
<div <div class={cn('relative inline-flex flex-col items-end gap-1', className)} bind:this={container}>
class={cn('relative z-10 flex max-w-[200px] min-w-[120px] flex-col items-end gap-1', className)} {#if loading && options.length === 0 && isRouter}
bind:this={container}
>
{#if loading && options.length === 0 && !isMounted}
<div class="flex items-center gap-2 text-xs text-muted-foreground"> <div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" /> <Loader2 class="h-3.5 w-3.5 animate-spin" />
Loading models… Loading models…
</div> </div>
{:else if options.length === 0} {:else if options.length === 0 && isRouter}
<p class="text-xs text-muted-foreground">No models available.</p> <p class="text-xs text-muted-foreground">No models available.</p>
{:else} {:else}
{@const selectedOption = getDisplayOption()} {@const selectedOption = getDisplayOption()}
<div class="relative w-full"> <div class="relative">
<button <button
type="button" type="button"
class={cn( class={cn(
'flex w-full items-center justify-end gap-2 rounded-md px-2 py-1 text-sm text-muted-foreground transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60', 'inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75 text-xs text-muted-foreground transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60',
isOpen ? 'text-foreground' : '' isOpen ? 'text-foreground' : ''
)} )}
aria-haspopup="listbox" style="max-width: min(calc(100vw - 2rem), 32rem)"
aria-expanded={isOpen} aria-haspopup={isRouter ? 'listbox' : undefined}
aria-expanded={isRouter ? isOpen : undefined}
onclick={toggleOpen} onclick={toggleOpen}
bind:this={triggerButton} bind:this={triggerButton}
disabled={loading || updating} disabled={disabled || updating}
> >
<span class="max-w-[160px] truncate text-right font-medium"> <Package class="h-3.5 w-3.5" />
<span class="truncate font-medium">
{selectedOption?.name || 'Select model'} {selectedOption?.name || 'Select model'}
</span> </span>
{#if updating} {#if updating}
<Loader2 class="h-3.5 w-3.5 animate-spin text-muted-foreground" /> <Loader2 class="h-3 w-3.5 animate-spin" />
{:else} {:else if isRouter}
<ChevronDown <ChevronDown class="h-3 w-3.5" />
class={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isOpen ? 'rotate-180 text-foreground' : ''
)}
/>
{/if} {/if}
</button> </button>
{#if isOpen} {#if isOpen && isRouter}
<div <div
bind:this={menuRef} bind:this={menuRef}
use:portalToBody use:portalToBody
@ -324,20 +309,16 @@
<button <button
type="button" type="button"
class={cn( class={cn(
'flex w-full flex-col items-start gap-0.5 px-3 py-2 text-left text-sm transition hover:bg-muted focus:bg-muted focus:outline-none', 'flex w-full cursor-pointer items-center px-3 py-2 text-left text-sm transition hover:bg-muted focus:bg-muted focus:outline-none',
option.id === selectedOption?.id ? 'bg-accent text-accent-foreground' : '' currentModel === option.model || activeId === option.id
? 'bg-accent text-accent-foreground'
: 'text-popover-foreground hover:bg-accent hover:text-accent-foreground'
)} )}
role="option" role="option"
aria-selected={option.id === selectedOption?.id} aria-selected={currentModel === option.model || activeId === option.id}
onclick={() => handleOptionSelect(option.id)} onclick={() => handleSelect(option.id)}
> >
<span class="block w-full truncate font-medium" title={option.name}> <span class="truncate">{option.name}</span>
{option.name}
</span>
{#if option.description}
<span class="text-xs text-muted-foreground">{option.description}</span>
{/if}
</button> </button>
{/each} {/each}
</div> </div>
@ -345,8 +326,8 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{#if error}
<p class="text-xs text-destructive">{error}</p>
{/if}
</div> </div>
{#if showModelDialog && !isRouter}
<DialogModelInformation bind:open={showModelDialog} />
{/if}

View File

@ -45,7 +45,6 @@ export interface SettingsChatServiceOptions {
onReasoningChunk?: (chunk: string) => void; onReasoningChunk?: (chunk: string) => void;
onToolCallChunk?: (chunk: string) => void; onToolCallChunk?: (chunk: string) => void;
onModel?: (model: string) => void; onModel?: (model: string) => void;
onFirstValidChunk?: () => void;
onComplete?: ( onComplete?: (
response: string, response: string,
reasoningContent?: string, reasoningContent?: string,