feat: New Model Selection UX WIP
This commit is contained in:
parent
c26c3402fe
commit
8b1d96755e
|
|
@ -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)}
|
|
||||||
/>
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue