From b9a3129d428c29a4c48d2e43c656ea9ca97ae8fc Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Tue, 25 Nov 2025 17:13:10 +0100 Subject: [PATCH] feat: Switching models logic for ChatForm or when regenerating messges + modality detection logic --- .../app/chat/ChatForm/ChatForm.svelte | 74 ++++++++++- .../ChatFormActionFileAttachments.svelte | 23 ++-- .../ChatFormActionRecord.svelte | 7 +- .../ChatFormActions/ChatFormActions.svelte | 119 ++++++++++++++---- .../ChatFormFileInputInvisible.svelte | 12 +- .../app/chat/ChatMessages/ChatMessage.svelte | 6 +- .../ChatMessages/ChatMessageAssistant.svelte | 7 +- .../app/chat/ChatMessages/ChatMessages.svelte | 4 +- .../app/chat/ChatScreen/ChatScreen.svelte | 85 ++++++++++++- .../components/app/misc/SelectorModel.svelte | 15 ++- tools/server/webui/src/lib/services/chat.ts | 2 +- tools/server/webui/src/lib/services/props.ts | 30 +++++ .../webui/src/lib/stores/chat.svelte.ts | 17 ++- .../webui/src/lib/stores/props.svelte.ts | 52 ++++++++ .../server/webui/src/lib/types/settings.d.ts | 2 + .../src/lib/utils/modality-file-validation.ts | 46 ++++--- 16 files changed, 429 insertions(+), 72 deletions(-) diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte index 46841e4a3d..bf74790a3f 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -9,8 +9,14 @@ } from '$lib/components/app'; import { INPUT_CLASSES } from '$lib/constants/input-classes'; import { config } from '$lib/stores/settings.svelte'; - import { selectedModelId } from '$lib/stores/models.svelte'; - import { isRouterMode } from '$lib/stores/props.svelte'; + import { modelOptions, selectedModelId } from '$lib/stores/models.svelte'; + import { + isRouterMode, + supportsAudio, + supportsVision, + fetchModelProps, + getModelProps + } from '$lib/stores/props.svelte'; import { getConversationModel } from '$lib/stores/chat.svelte'; import { activeMessages } from '$lib/stores/conversations.svelte'; import { @@ -74,6 +80,68 @@ let isRouter = $derived(isRouterMode()); let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId()); + // Get active model ID for capability detection + let activeModelId = $derived.by(() => { + if (!isRouter) return null; + + const options = modelOptions(); + + // First try user-selected model + const selectedId = selectedModelId(); + if (selectedId) { + const model = options.find((m) => m.id === selectedId); + if (model) return model.model; + } + + // Fallback to conversation model + if (conversationModel) { + const model = options.find((m) => m.model === conversationModel); + if (model) return model.model; + } + + return null; + }); + + // State for model props reactivity + let modelPropsVersion = $state(0); + + // Fetch model props when active model changes + $effect(() => { + if (isRouter && activeModelId) { + const cached = getModelProps(activeModelId); + if (!cached) { + fetchModelProps(activeModelId).then(() => { + modelPropsVersion++; + }); + } + } + }); + + // Derive modalities from model props (ROUTER) or server props (MODEL) + let hasAudioModality = $derived.by(() => { + if (!isRouter) return supportsAudio(); + + if (activeModelId) { + void modelPropsVersion; + const props = getModelProps(activeModelId); + if (props) return props.modalities?.audio ?? false; + } + + return false; + }); + + let hasVisionModality = $derived.by(() => { + if (!isRouter) return supportsVision(); + + if (activeModelId) { + void modelPropsVersion; + const props = getModelProps(activeModelId); + if (props) return props.modalities?.vision ?? false; + } + + return false; + }); + function checkModelSelected(): boolean { if (!hasModelSelected) { // Open the model selector @@ -251,6 +319,8 @@ diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte index ea4c5cc3c1..887e4cc3f8 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte @@ -5,18 +5,25 @@ import * as Tooltip from '$lib/components/ui/tooltip'; import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config'; import { FileTypeCategory } from '$lib/enums'; - import { supportsAudio, supportsVision } from '$lib/stores/props.svelte'; interface Props { class?: string; disabled?: boolean; + hasAudioModality?: boolean; + hasVisionModality?: boolean; onFileUpload?: (fileType?: FileTypeCategory) => void; } - let { class: className = '', disabled = false, onFileUpload }: Props = $props(); + let { + class: className = '', + disabled = false, + hasAudioModality = false, + hasVisionModality = false, + onFileUpload + }: Props = $props(); const fileUploadTooltipText = $derived.by(() => { - return !supportsVision() + return !hasVisionModality ? 'Text files and PDFs supported. Images, audio, and video require vision models.' : 'Attach files'; }); @@ -53,7 +60,7 @@ handleFileUpload(FileTypeCategory.IMAGE)} > @@ -62,7 +69,7 @@ - {#if !supportsVision()} + {#if !hasVisionModality}

Images require vision models to be processed

@@ -73,7 +80,7 @@ handleFileUpload(FileTypeCategory.AUDIO)} > @@ -82,7 +89,7 @@ - {#if !supportsAudio()} + {#if !hasAudioModality}

Audio files require audio models to be processed

@@ -110,7 +117,7 @@ - {#if !supportsVision()} + {#if !hasVisionModality}

PDFs will be converted to text. Image-based PDFs may not work properly.

diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte index 4bab64dea3..29c50e4d81 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte @@ -2,11 +2,11 @@ import { Mic, Square } from '@lucide/svelte'; import { Button } from '$lib/components/ui/button'; import * as Tooltip from '$lib/components/ui/tooltip'; - import { supportsAudio } from '$lib/stores/props.svelte'; interface Props { class?: string; disabled?: boolean; + hasAudioModality?: boolean; isLoading?: boolean; isRecording?: boolean; onMicClick?: () => void; @@ -15,6 +15,7 @@ let { class: className = '', disabled = false, + hasAudioModality = false, isLoading = false, isRecording = false, onMicClick @@ -28,7 +29,7 @@ class="h-8 w-8 rounded-full p-0 {isRecording ? 'animate-pulse bg-red-500 text-white hover:bg-red-600' : ''}" - disabled={disabled || isLoading || !supportsAudio()} + disabled={disabled || isLoading || !hasAudioModality} onclick={onMicClick} type="button" > @@ -42,7 +43,7 @@ - {#if !supportsAudio()} + {#if !hasAudioModality}

Current model does not support audio

diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte index 6b8180409f..d6b8d13a28 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte @@ -9,12 +9,17 @@ } from '$lib/components/app'; import { FileTypeCategory } from '$lib/enums'; import { getFileTypeCategory } from '$lib/utils/file-type'; - import { supportsAudio } from '$lib/stores/props.svelte'; + import { + supportsAudio, + supportsVision, + isRouterMode, + fetchModelProps, + getModelProps + } from '$lib/stores/props.svelte'; import { config } from '$lib/stores/settings.svelte'; import { modelOptions, selectedModelId, selectModelByName } from '$lib/stores/models.svelte'; import { getConversationModel } from '$lib/stores/chat.svelte'; import { activeMessages } from '$lib/stores/conversations.svelte'; - import { isRouterMode } from '$lib/stores/props.svelte'; import type { ChatUploadedFile } from '$lib/types/chat'; interface Props { @@ -44,7 +49,84 @@ }: Props = $props(); let currentConfig = $derived(config()); - let hasAudioModality = $derived(supportsAudio()); + let isRouter = $derived(isRouterMode()); + + let conversationModel = $derived(getConversationModel(activeMessages() as DatabaseMessage[])); + + let previousConversationModel: string | null = null; + + $effect(() => { + if (conversationModel && conversationModel !== previousConversationModel) { + previousConversationModel = conversationModel; + selectModelByName(conversationModel); + } + }); + + // Get active model ID for fetching props + // Priority: user-selected model > conversation model (allows changing model mid-chat) + let activeModelId = $derived.by(() => { + if (!isRouter) return null; + + const options = modelOptions(); + + const selectedId = selectedModelId(); + if (selectedId) { + const model = options.find((m) => m.id === selectedId); + if (model) return model.model; + } + + if (conversationModel) { + const model = options.find((m) => m.model === conversationModel); + if (model) return model.model; + } + + return null; + }); + + // State for model props (fetched from /props?model=) + let modelPropsVersion = $state(0); // Used to trigger reactivity after fetch + + // Fetch model props when active model changes + $effect(() => { + if (isRouter && activeModelId) { + // Check if we already have cached props + const cached = getModelProps(activeModelId); + if (!cached) { + // Fetch props for this model + fetchModelProps(activeModelId).then(() => { + // Trigger reactivity update + modelPropsVersion++; + }); + } + } + }); + + let hasAudioModality = $derived.by(() => { + if (!isRouter) return supportsAudio(); + + if (activeModelId) { + void modelPropsVersion; + + const props = getModelProps(activeModelId); + if (props) return props.modalities?.audio ?? false; + } + + return false; + }); + + let hasVisionModality = $derived.by(() => { + if (!isRouter) return supportsVision(); + + if (activeModelId) { + void modelPropsVersion; + + const props = getModelProps(activeModelId); + if (props) return props.modalities?.vision ?? false; + } + + return false; + }); + let hasAudioAttachments = $derived( uploadedFiles.some((file) => getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) ); @@ -52,22 +134,6 @@ hasAudioModality && !hasText && !hasAudioAttachments && currentConfig.autoMicOnEmpty ); - // Get model from conversation messages (last assistant message with model) - let conversationModel = $derived(getConversationModel(activeMessages() as DatabaseMessage[])); - - // Sync selected model with conversation model when it changes - // Only sync when conversation HAS a model - don't clear selection for new chats - // to allow user to select a model before first message - $effect(() => { - if (conversationModel) { - selectModelByName(conversationModel); - } - }); - - let isRouter = $derived(isRouterMode()); - - // Check if any model is selected (either from conversation or user selection) - // In single MODEL mode, there's always a model available let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId()); let isSelectedModelInCache = $derived.by(() => { @@ -91,28 +157,35 @@ if (!hasModelSelected) { return 'Please select a model first'; } + if (!isSelectedModelInCache) { return 'Selected model is not available, please select another'; } + return ''; }); - // Ref to SelectorModel for programmatic opening let selectorModelRef: SelectorModel | undefined = $state(undefined); - // Export function to open the model selector export function openModelSelector() { selectorModelRef?.open(); }
- + {#if isLoading} @@ -125,7 +198,7 @@ {:else if shouldShowRecordButton} - + {:else} void; } @@ -11,6 +13,8 @@ let { accept = $bindable(), class: className = '', + hasAudioModality = false, + hasVisionModality = false, multiple = true, onFileSelect }: Props = $props(); @@ -18,7 +22,13 @@ let fileInputElement: HTMLInputElement | undefined; // Use modality-aware accept string by default, but allow override - let finalAccept = $derived(accept ?? generateModalityAwareAcceptString()); + let finalAccept = $derived( + accept ?? + generateModalityAwareAcceptString({ + hasVision: hasVisionModality, + hasAudio: hasAudioModality + }) + ); export function click() { fileInputElement?.click(); diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte index ae0dc2ed9f..5656e08334 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte @@ -20,7 +20,7 @@ ) => void; onEditUserMessagePreserveResponses?: (message: DatabaseMessage, newContent: string) => void; onNavigateToSibling?: (siblingId: string) => void; - onRegenerateWithBranching?: (message: DatabaseMessage) => void; + onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void; siblingInfo?: ChatMessageSiblingInfo | null; } @@ -133,8 +133,8 @@ } } - function handleRegenerate() { - onRegenerateWithBranching?.(message); + function handleRegenerate(modelOverride?: string) { + onRegenerateWithBranching?.(message, modelOverride); } function handleContinue() { diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte index 7034c17a3e..68ebde42b8 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte @@ -44,7 +44,7 @@ onEditKeydown?: (event: KeyboardEvent) => void; onEditedContentChange?: (content: string) => void; onNavigateToSibling?: (siblingId: string) => void; - onRegenerate: () => void; + onRegenerate: (modelOverride?: string) => void; onSaveEdit?: () => void; onShowDeleteDialogChange: (show: boolean) => void; onShouldBranchAfterEditChange?: (value: boolean) => void; @@ -101,11 +101,12 @@ return null; }); - async function handleModelChange(modelId: string) { + async function handleModelChange(modelId: string, modelName: string) { try { await selectModel(modelId); - onRegenerate(); + // Pass the selected model name for regeneration + onRegenerate(modelName); } catch (error) { console.error('Failed to change model:', error); } diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte index 020873dc6e..b13d5100ba 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte @@ -87,10 +87,10 @@ refreshAllMessages(); } - async function handleRegenerateWithBranching(message: DatabaseMessage) { + async function handleRegenerateWithBranching(message: DatabaseMessage, modelOverride?: string) { onUserAction?.(); - await regenerateMessageWithBranching(message.id); + await regenerateMessageWithBranching(message.id, modelOverride); refreshAllMessages(); } diff --git a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte index d8952ba07e..7c8d3f042f 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte @@ -36,8 +36,13 @@ supportsAudio, propsLoading, serverWarning, - propsStore + propsStore, + isRouterMode, + fetchModelProps, + getModelProps } from '$lib/stores/props.svelte'; + import { modelOptions, selectedModelId } from '$lib/stores/models.svelte'; + import { getConversationModel } from '$lib/stores/chat.svelte'; import { parseFilesToMessageExtras } from '$lib/utils/convert-files-to-extra'; import { isFileTypeSupported } from '$lib/utils/file-type'; import { filterFilesByModalities } from '$lib/utils/modality-file-validation'; @@ -89,6 +94,72 @@ let isCurrentConversationLoading = $derived(isLoading()); + // Model-specific capability detection (same logic as ChatFormActions) + let isRouter = $derived(isRouterMode()); + let conversationModel = $derived(getConversationModel(activeMessages() as DatabaseMessage[])); + + // Get active model ID for fetching props + let activeModelId = $derived.by(() => { + if (!isRouter) return null; + + const options = modelOptions(); + + // First try user-selected model + const selectedId = selectedModelId(); + if (selectedId) { + const model = options.find((m) => m.id === selectedId); + if (model) return model.model; + } + + // Fallback to conversation model + if (conversationModel) { + const model = options.find((m) => m.model === conversationModel); + if (model) return model.model; + } + + return null; + }); + + // State for model props reactivity + let modelPropsVersion = $state(0); + + // Fetch model props when active model changes + $effect(() => { + if (isRouter && activeModelId) { + const cached = getModelProps(activeModelId); + if (!cached) { + fetchModelProps(activeModelId).then(() => { + modelPropsVersion++; + }); + } + } + }); + + // Derive modalities from model props (ROUTER) or server props (MODEL) + let hasAudioModality = $derived.by(() => { + if (!isRouter) return supportsAudio(); + + if (activeModelId) { + void modelPropsVersion; + const props = getModelProps(activeModelId); + if (props) return props.modalities?.audio ?? false; + } + + return false; + }); + + let hasVisionModality = $derived.by(() => { + if (!isRouter) return supportsVision(); + + if (activeModelId) { + void modelPropsVersion; + const props = getModelProps(activeModelId); + if (props) return props.modalities?.vision ?? false; + } + + return false; + }); + async function handleDeleteConfirm() { const conversation = activeConversation(); if (conversation) { @@ -220,16 +291,20 @@ } } - const { supportedFiles, unsupportedFiles, modalityReasons } = - filterFilesByModalities(generallySupported); + // Use model-specific capabilities for file validation + const capabilities = { hasVision: hasVisionModality, hasAudio: hasAudioModality }; + const { supportedFiles, unsupportedFiles, modalityReasons } = filterFilesByModalities( + generallySupported, + capabilities + ); const allUnsupportedFiles = [...generallyUnsupported, ...unsupportedFiles]; if (allUnsupportedFiles.length > 0) { const supportedTypes: string[] = ['text files', 'PDFs']; - if (supportsVision()) supportedTypes.push('images'); - if (supportsAudio()) supportedTypes.push('audio files'); + if (hasVisionModality) supportedTypes.push('images'); + if (hasAudioModality) supportedTypes.push('audio files'); fileErrorData = { generallyUnsupported, diff --git a/tools/server/webui/src/lib/components/app/misc/SelectorModel.svelte b/tools/server/webui/src/lib/components/app/misc/SelectorModel.svelte index fd1e6b5694..fc65f1fee5 100644 --- a/tools/server/webui/src/lib/components/app/misc/SelectorModel.svelte +++ b/tools/server/webui/src/lib/components/app/misc/SelectorModel.svelte @@ -21,6 +21,8 @@ onModelChange?: (modelId: string, modelName: string) => void; disabled?: boolean; forceForegroundText?: boolean; + /** When true, user's global selection takes priority over currentModel (for form selector) */ + useGlobalSelection?: boolean; } let { @@ -28,7 +30,8 @@ currentModel = null, onModelChange, disabled = false, - forceForegroundText = false + forceForegroundText = false, + useGlobalSelection = false }: Props = $props(); let options = $derived(modelOptions()); @@ -260,6 +263,14 @@ return undefined; } + // When useGlobalSelection is true (form selector), prioritize user selection + // Otherwise (message display), prioritize currentModel + if (useGlobalSelection && activeId) { + const selected = options.find((option) => option.id === activeId); + if (selected) return selected; + } + + // Show currentModel (from message payload or conversation) if (currentModel) { if (!isCurrentModelInCache()) { return { @@ -273,7 +284,7 @@ return options.find((option) => option.model === currentModel); } - // Check if user has selected a model (for new chats before first message) + // Fallback to user selection (for new chats before first message) if (activeId) { return options.find((option) => option.id === activeId); } diff --git a/tools/server/webui/src/lib/services/chat.ts b/tools/server/webui/src/lib/services/chat.ts index 1474f9b692..c781e3badc 100644 --- a/tools/server/webui/src/lib/services/chat.ts +++ b/tools/server/webui/src/lib/services/chat.ts @@ -150,7 +150,7 @@ export class ChatService { }; const isRouter = isRouterMode(); - const activeModel = isRouter ? selectedModelName() : null; + const activeModel = isRouter ? options.model || selectedModelName() : null; if (isRouter && activeModel) { requestBody.model = activeModel; diff --git a/tools/server/webui/src/lib/services/props.ts b/tools/server/webui/src/lib/services/props.ts index f133619fca..bc0dd7a965 100644 --- a/tools/server/webui/src/lib/services/props.ts +++ b/tools/server/webui/src/lib/services/props.ts @@ -40,4 +40,34 @@ export class PropsService { const data = await response.json(); return data as ApiLlamaCppServerProps; } + + /** + * Fetches server properties for a specific model (ROUTER mode) + * + * @param modelId - The model ID to fetch properties for + * @returns {Promise} Server properties for the model + * @throws {Error} If the request fails or returns invalid data + */ + static async fetchForModel(modelId: string): Promise { + const currentConfig = config(); + const apiKey = currentConfig.apiKey?.toString().trim(); + + const url = new URL('./props', window.location.href); + url.searchParams.set('model', modelId); + + const response = await fetch(url.toString(), { + headers: { + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) + } + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch model properties: ${response.status} ${response.statusText}` + ); + } + + const data = await response.json(); + return data as ApiLlamaCppServerProps; + } } diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts index b12c5d8d20..d6d4cc4ba9 100644 --- a/tools/server/webui/src/lib/stores/chat.svelte.ts +++ b/tools/server/webui/src/lib/stores/chat.svelte.ts @@ -489,7 +489,8 @@ class ChatStore { allMessages: DatabaseMessage[], assistantMessage: DatabaseMessage, onComplete?: (content: string) => Promise, - onError?: (error: Error) => void + onError?: (error: Error) => void, + modelOverride?: string | null ): Promise { let streamedContent = ''; let streamedReasoningContent = ''; @@ -520,6 +521,7 @@ class ChatStore { allMessages, { ...this.getApiOptions(), + ...(modelOverride ? { model: modelOverride } : {}), onChunk: (chunk: string) => { streamedContent += chunk; this.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id); @@ -996,7 +998,7 @@ class ChatStore { } } - async regenerateMessageWithBranching(messageId: string): Promise { + async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise { const activeConv = conversationsStore.activeConversation; if (!activeConv || this.isLoading) return; try { @@ -1035,7 +1037,16 @@ class ChatStore { parentMessage.id, false ) as DatabaseMessage[]; - await this.streamChatCompletion(conversationPath, newAssistantMessage); + // Use modelOverride if provided, otherwise use the original message's model + // If neither is available, don't pass model (will use global selection) + const modelToUse = modelOverride || msg.model || undefined; + await this.streamChatCompletion( + conversationPath, + newAssistantMessage, + undefined, + undefined, + modelToUse + ); } catch (error) { if (!this.isAbortError(error)) console.error('Failed to regenerate message with branching:', error); diff --git a/tools/server/webui/src/lib/stores/props.svelte.ts b/tools/server/webui/src/lib/stores/props.svelte.ts index a996bdfed9..e68b422d4b 100644 --- a/tools/server/webui/src/lib/stores/props.svelte.ts +++ b/tools/server/webui/src/lib/stores/props.svelte.ts @@ -39,6 +39,10 @@ class PropsStore { private _serverMode = $state(null); private fetchPromise: Promise | null = null; + // Model-specific props cache (ROUTER mode) + private _modelPropsCache = $state>(new Map()); + private _modelPropsFetching = $state>(new Set()); + // ───────────────────────────────────────────────────────────────────────────── // LocalStorage persistence // ───────────────────────────────────────────────────────────────────────────── @@ -238,6 +242,52 @@ class PropsStore { await fetchPromise; } + // ───────────────────────────────────────────────────────────────────────────── + // Fetch Model-Specific Properties (ROUTER mode) + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Get cached props for a specific model + */ + getModelProps(modelId: string): ApiLlamaCppServerProps | null { + return this._modelPropsCache.get(modelId) ?? null; + } + + /** + * Check if model props are being fetched + */ + isModelPropsFetching(modelId: string): boolean { + return this._modelPropsFetching.has(modelId); + } + + /** + * Fetches properties for a specific model (ROUTER mode) + * Results are cached for subsequent calls + */ + async fetchModelProps(modelId: string): Promise { + // Return cached if available + const cached = this._modelPropsCache.get(modelId); + if (cached) return cached; + + // Don't fetch if already fetching + if (this._modelPropsFetching.has(modelId)) { + return null; + } + + this._modelPropsFetching.add(modelId); + + try { + const props = await PropsService.fetchForModel(modelId); + this._modelPropsCache.set(modelId, props); + return props; + } catch (error) { + console.warn(`Failed to fetch props for model ${modelId}:`, error); + return null; + } finally { + this._modelPropsFetching.delete(modelId); + } + } + // ───────────────────────────────────────────────────────────────────────────── // Error Handling // ───────────────────────────────────────────────────────────────────────────── @@ -365,3 +415,5 @@ export const isModelMode = () => propsStore.isModelMode; // Actions export const fetchProps = propsStore.fetch.bind(propsStore); +export const fetchModelProps = propsStore.fetchModelProps.bind(propsStore); +export const getModelProps = propsStore.getModelProps.bind(propsStore); diff --git a/tools/server/webui/src/lib/types/settings.d.ts b/tools/server/webui/src/lib/types/settings.d.ts index f0c055c62e..eac3da1610 100644 --- a/tools/server/webui/src/lib/types/settings.d.ts +++ b/tools/server/webui/src/lib/types/settings.d.ts @@ -14,6 +14,8 @@ export interface SettingsFieldConfig { export interface SettingsChatServiceOptions { stream?: boolean; + // Model override (for regenerate with specific model) + model?: string; // Generation parameters temperature?: number; max_tokens?: number; diff --git a/tools/server/webui/src/lib/utils/modality-file-validation.ts b/tools/server/webui/src/lib/utils/modality-file-validation.ts index e13445313f..fd87985ba4 100644 --- a/tools/server/webui/src/lib/utils/modality-file-validation.ts +++ b/tools/server/webui/src/lib/utils/modality-file-validation.ts @@ -4,7 +4,6 @@ */ import { getFileTypeCategory } from '$lib/utils/file-type'; -import { supportsVision, supportsAudio } from '$lib/stores/props.svelte'; import { FileExtensionAudio, FileExtensionImage, @@ -17,13 +16,24 @@ import { FileTypeCategory } from '$lib/enums'; +/** Modality capabilities for file validation */ +export interface ModalityCapabilities { + hasVision: boolean; + hasAudio: boolean; +} + /** - * Check if a file type is supported by the current model's modalities + * Check if a file type is supported by the given modalities * @param filename - The filename to check * @param mimeType - The MIME type of the file - * @returns true if the file type is supported by the current model + * @param capabilities - The modality capabilities to check against + * @returns true if the file type is supported */ -export function isFileTypeSupportedByModel(filename: string, mimeType?: string): boolean { +export function isFileTypeSupportedByModel( + filename: string, + mimeType: string | undefined, + capabilities: ModalityCapabilities +): boolean { const category = mimeType ? getFileTypeCategory(mimeType) : null; // If we can't determine the category from MIME type, fall back to general support check @@ -44,11 +54,11 @@ export function isFileTypeSupportedByModel(filename: string, mimeType?: string): case FileTypeCategory.IMAGE: // Images require vision support - return supportsVision(); + return capabilities.hasVision; case FileTypeCategory.AUDIO: // Audio files require audio support - return supportsAudio(); + return capabilities.hasAudio; default: // Unknown categories - be conservative and allow @@ -59,9 +69,13 @@ export function isFileTypeSupportedByModel(filename: string, mimeType?: string): /** * Filter files based on model modalities and return supported/unsupported lists * @param files - Array of files to filter + * @param capabilities - The modality capabilities to check against * @returns Object with supportedFiles and unsupportedFiles arrays */ -export function filterFilesByModalities(files: File[]): { +export function filterFilesByModalities( + files: File[], + capabilities: ModalityCapabilities +): { supportedFiles: File[]; unsupportedFiles: File[]; modalityReasons: Record; @@ -70,8 +84,7 @@ export function filterFilesByModalities(files: File[]): { const unsupportedFiles: File[] = []; const modalityReasons: Record = {}; - const hasVision = supportsVision(); - const hasAudio = supportsAudio(); + const { hasVision, hasAudio } = capabilities; for (const file of files) { const category = getFileTypeCategory(file.type); @@ -119,16 +132,17 @@ export function filterFilesByModalities(files: File[]): { * Generate a user-friendly error message for unsupported files * @param unsupportedFiles - Array of unsupported files * @param modalityReasons - Reasons why files are unsupported + * @param capabilities - The modality capabilities to check against * @returns Formatted error message */ export function generateModalityErrorMessage( unsupportedFiles: File[], - modalityReasons: Record + modalityReasons: Record, + capabilities: ModalityCapabilities ): string { if (unsupportedFiles.length === 0) return ''; - const hasVision = supportsVision(); - const hasAudio = supportsAudio(); + const { hasVision, hasAudio } = capabilities; let message = ''; @@ -152,12 +166,12 @@ export function generateModalityErrorMessage( } /** - * Generate file input accept string based on current model modalities + * Generate file input accept string based on model modalities + * @param capabilities - The modality capabilities to check against * @returns Accept string for HTML file input element */ -export function generateModalityAwareAcceptString(): string { - const hasVision = supportsVision(); - const hasAudio = supportsAudio(); +export function generateModalityAwareAcceptString(capabilities: ModalityCapabilities): string { + const { hasVision, hasAudio } = capabilities; const acceptedExtensions: string[] = []; const acceptedMimeTypes: string[] = [];