feat: Switching models logic for ChatForm or when regenerating messges + modality detection logic

This commit is contained in:
Aleksander Grygier 2025-11-25 17:13:10 +01:00
parent 4c24ead8e0
commit b9a3129d42
16 changed files with 429 additions and 72 deletions

View File

@ -9,8 +9,14 @@
} from '$lib/components/app'; } from '$lib/components/app';
import { INPUT_CLASSES } from '$lib/constants/input-classes'; import { INPUT_CLASSES } from '$lib/constants/input-classes';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { selectedModelId } from '$lib/stores/models.svelte'; import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/props.svelte'; import {
isRouterMode,
supportsAudio,
supportsVision,
fetchModelProps,
getModelProps
} from '$lib/stores/props.svelte';
import { getConversationModel } from '$lib/stores/chat.svelte'; import { getConversationModel } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte'; import { activeMessages } from '$lib/stores/conversations.svelte';
import { import {
@ -74,6 +80,68 @@
let isRouter = $derived(isRouterMode()); let isRouter = $derived(isRouterMode());
let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId()); 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 { function checkModelSelected(): boolean {
if (!hasModelSelected) { if (!hasModelSelected) {
// Open the model selector // Open the model selector
@ -251,6 +319,8 @@
<ChatFormFileInputInvisible <ChatFormFileInputInvisible
bind:this={fileInputRef} bind:this={fileInputRef}
bind:accept={fileAcceptString} bind:accept={fileAcceptString}
{hasAudioModality}
{hasVisionModality}
onFileSelect={handleFileSelect} onFileSelect={handleFileSelect}
/> />

View File

@ -5,18 +5,25 @@
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config'; import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
import { FileTypeCategory } from '$lib/enums'; import { FileTypeCategory } from '$lib/enums';
import { supportsAudio, supportsVision } from '$lib/stores/props.svelte';
interface Props { interface Props {
class?: string; class?: string;
disabled?: boolean; disabled?: boolean;
hasAudioModality?: boolean;
hasVisionModality?: boolean;
onFileUpload?: (fileType?: FileTypeCategory) => void; 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(() => { const fileUploadTooltipText = $derived.by(() => {
return !supportsVision() return !hasVisionModality
? 'Text files and PDFs supported. Images, audio, and video require vision models.' ? 'Text files and PDFs supported. Images, audio, and video require vision models.'
: 'Attach files'; : 'Attach files';
}); });
@ -53,7 +60,7 @@
<Tooltip.Trigger class="w-full"> <Tooltip.Trigger class="w-full">
<DropdownMenu.Item <DropdownMenu.Item
class="images-button flex cursor-pointer items-center gap-2" class="images-button flex cursor-pointer items-center gap-2"
disabled={!supportsVision()} disabled={!hasVisionModality}
onclick={() => handleFileUpload(FileTypeCategory.IMAGE)} onclick={() => handleFileUpload(FileTypeCategory.IMAGE)}
> >
<Image class="h-4 w-4" /> <Image class="h-4 w-4" />
@ -62,7 +69,7 @@
</DropdownMenu.Item> </DropdownMenu.Item>
</Tooltip.Trigger> </Tooltip.Trigger>
{#if !supportsVision()} {#if !hasVisionModality}
<Tooltip.Content> <Tooltip.Content>
<p>Images require vision models to be processed</p> <p>Images require vision models to be processed</p>
</Tooltip.Content> </Tooltip.Content>
@ -73,7 +80,7 @@
<Tooltip.Trigger class="w-full"> <Tooltip.Trigger class="w-full">
<DropdownMenu.Item <DropdownMenu.Item
class="audio-button flex cursor-pointer items-center gap-2" class="audio-button flex cursor-pointer items-center gap-2"
disabled={!supportsAudio()} disabled={!hasAudioModality}
onclick={() => handleFileUpload(FileTypeCategory.AUDIO)} onclick={() => handleFileUpload(FileTypeCategory.AUDIO)}
> >
<Volume2 class="h-4 w-4" /> <Volume2 class="h-4 w-4" />
@ -82,7 +89,7 @@
</DropdownMenu.Item> </DropdownMenu.Item>
</Tooltip.Trigger> </Tooltip.Trigger>
{#if !supportsAudio()} {#if !hasAudioModality}
<Tooltip.Content> <Tooltip.Content>
<p>Audio files require audio models to be processed</p> <p>Audio files require audio models to be processed</p>
</Tooltip.Content> </Tooltip.Content>
@ -110,7 +117,7 @@
</DropdownMenu.Item> </DropdownMenu.Item>
</Tooltip.Trigger> </Tooltip.Trigger>
{#if !supportsVision()} {#if !hasVisionModality}
<Tooltip.Content> <Tooltip.Content>
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p> <p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
</Tooltip.Content> </Tooltip.Content>

View File

@ -2,11 +2,11 @@
import { Mic, Square } from '@lucide/svelte'; import { Mic, Square } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { supportsAudio } from '$lib/stores/props.svelte';
interface Props { interface Props {
class?: string; class?: string;
disabled?: boolean; disabled?: boolean;
hasAudioModality?: boolean;
isLoading?: boolean; isLoading?: boolean;
isRecording?: boolean; isRecording?: boolean;
onMicClick?: () => void; onMicClick?: () => void;
@ -15,6 +15,7 @@
let { let {
class: className = '', class: className = '',
disabled = false, disabled = false,
hasAudioModality = false,
isLoading = false, isLoading = false,
isRecording = false, isRecording = false,
onMicClick onMicClick
@ -28,7 +29,7 @@
class="h-8 w-8 rounded-full p-0 {isRecording class="h-8 w-8 rounded-full p-0 {isRecording
? 'animate-pulse bg-red-500 text-white hover:bg-red-600' ? 'animate-pulse bg-red-500 text-white hover:bg-red-600'
: ''}" : ''}"
disabled={disabled || isLoading || !supportsAudio()} disabled={disabled || isLoading || !hasAudioModality}
onclick={onMicClick} onclick={onMicClick}
type="button" type="button"
> >
@ -42,7 +43,7 @@
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
{#if !supportsAudio()} {#if !hasAudioModality}
<Tooltip.Content> <Tooltip.Content>
<p>Current model does not support audio</p> <p>Current model does not support audio</p>
</Tooltip.Content> </Tooltip.Content>

View File

@ -9,12 +9,17 @@
} from '$lib/components/app'; } from '$lib/components/app';
import { FileTypeCategory } from '$lib/enums'; import { FileTypeCategory } from '$lib/enums';
import { getFileTypeCategory } from '$lib/utils/file-type'; 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 { config } from '$lib/stores/settings.svelte';
import { modelOptions, selectedModelId, selectModelByName } from '$lib/stores/models.svelte'; import { modelOptions, selectedModelId, selectModelByName } from '$lib/stores/models.svelte';
import { getConversationModel } from '$lib/stores/chat.svelte'; import { getConversationModel } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte'; import { activeMessages } from '$lib/stores/conversations.svelte';
import { isRouterMode } from '$lib/stores/props.svelte';
import type { ChatUploadedFile } from '$lib/types/chat'; import type { ChatUploadedFile } from '$lib/types/chat';
interface Props { interface Props {
@ -44,7 +49,84 @@
}: Props = $props(); }: Props = $props();
let currentConfig = $derived(config()); 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=<id>)
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( let hasAudioAttachments = $derived(
uploadedFiles.some((file) => getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) uploadedFiles.some((file) => getFileTypeCategory(file.type) === FileTypeCategory.AUDIO)
); );
@ -52,22 +134,6 @@
hasAudioModality && !hasText && !hasAudioAttachments && currentConfig.autoMicOnEmpty 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 hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
let isSelectedModelInCache = $derived.by(() => { let isSelectedModelInCache = $derived.by(() => {
@ -91,28 +157,35 @@
if (!hasModelSelected) { if (!hasModelSelected) {
return 'Please select a model first'; return 'Please select a model first';
} }
if (!isSelectedModelInCache) { if (!isSelectedModelInCache) {
return 'Selected model is not available, please select another'; return 'Selected model is not available, please select another';
} }
return ''; return '';
}); });
// Ref to SelectorModel for programmatic opening
let selectorModelRef: SelectorModel | undefined = $state(undefined); let selectorModelRef: SelectorModel | undefined = $state(undefined);
// Export function to open the model selector
export function openModelSelector() { export function openModelSelector() {
selectorModelRef?.open(); selectorModelRef?.open();
} }
</script> </script>
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size"> <div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
<ChatFormActionFileAttachments class="mr-auto" {disabled} {onFileUpload} /> <ChatFormActionFileAttachments
class="mr-auto"
{disabled}
{hasAudioModality}
{hasVisionModality}
{onFileUpload}
/>
<SelectorModel <SelectorModel
bind:this={selectorModelRef} bind:this={selectorModelRef}
currentModel={conversationModel} currentModel={conversationModel}
forceForegroundText={true} forceForegroundText={true}
useGlobalSelection={true}
/> />
{#if isLoading} {#if isLoading}
@ -125,7 +198,7 @@
<Square class="h-8 w-8 fill-destructive stroke-destructive" /> <Square class="h-8 w-8 fill-destructive stroke-destructive" />
</Button> </Button>
{:else if shouldShowRecordButton} {:else if shouldShowRecordButton}
<ChatFormActionRecord {disabled} {isLoading} {isRecording} {onMicClick} /> <ChatFormActionRecord {disabled} {hasAudioModality} {isLoading} {isRecording} {onMicClick} />
{:else} {:else}
<ChatFormActionSubmit <ChatFormActionSubmit
canSend={canSend && hasModelSelected && isSelectedModelInCache} canSend={canSend && hasModelSelected && isSelectedModelInCache}

View File

@ -4,6 +4,8 @@
interface Props { interface Props {
accept?: string; accept?: string;
class?: string; class?: string;
hasAudioModality?: boolean;
hasVisionModality?: boolean;
multiple?: boolean; multiple?: boolean;
onFileSelect?: (files: File[]) => void; onFileSelect?: (files: File[]) => void;
} }
@ -11,6 +13,8 @@
let { let {
accept = $bindable(), accept = $bindable(),
class: className = '', class: className = '',
hasAudioModality = false,
hasVisionModality = false,
multiple = true, multiple = true,
onFileSelect onFileSelect
}: Props = $props(); }: Props = $props();
@ -18,7 +22,13 @@
let fileInputElement: HTMLInputElement | undefined; let fileInputElement: HTMLInputElement | undefined;
// Use modality-aware accept string by default, but allow override // 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() { export function click() {
fileInputElement?.click(); fileInputElement?.click();

View File

@ -20,7 +20,7 @@
) => void; ) => void;
onEditUserMessagePreserveResponses?: (message: DatabaseMessage, newContent: string) => void; onEditUserMessagePreserveResponses?: (message: DatabaseMessage, newContent: string) => void;
onNavigateToSibling?: (siblingId: string) => void; onNavigateToSibling?: (siblingId: string) => void;
onRegenerateWithBranching?: (message: DatabaseMessage) => void; onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void;
siblingInfo?: ChatMessageSiblingInfo | null; siblingInfo?: ChatMessageSiblingInfo | null;
} }
@ -133,8 +133,8 @@
} }
} }
function handleRegenerate() { function handleRegenerate(modelOverride?: string) {
onRegenerateWithBranching?.(message); onRegenerateWithBranching?.(message, modelOverride);
} }
function handleContinue() { function handleContinue() {

View File

@ -44,7 +44,7 @@
onEditKeydown?: (event: KeyboardEvent) => void; onEditKeydown?: (event: KeyboardEvent) => void;
onEditedContentChange?: (content: string) => void; onEditedContentChange?: (content: string) => void;
onNavigateToSibling?: (siblingId: string) => void; onNavigateToSibling?: (siblingId: string) => void;
onRegenerate: () => void; onRegenerate: (modelOverride?: string) => void;
onSaveEdit?: () => void; onSaveEdit?: () => void;
onShowDeleteDialogChange: (show: boolean) => void; onShowDeleteDialogChange: (show: boolean) => void;
onShouldBranchAfterEditChange?: (value: boolean) => void; onShouldBranchAfterEditChange?: (value: boolean) => void;
@ -101,11 +101,12 @@
return null; return null;
}); });
async function handleModelChange(modelId: string) { async function handleModelChange(modelId: string, modelName: string) {
try { try {
await selectModel(modelId); await selectModel(modelId);
onRegenerate(); // Pass the selected model name for regeneration
onRegenerate(modelName);
} catch (error) { } catch (error) {
console.error('Failed to change model:', error); console.error('Failed to change model:', error);
} }

View File

@ -87,10 +87,10 @@
refreshAllMessages(); refreshAllMessages();
} }
async function handleRegenerateWithBranching(message: DatabaseMessage) { async function handleRegenerateWithBranching(message: DatabaseMessage, modelOverride?: string) {
onUserAction?.(); onUserAction?.();
await regenerateMessageWithBranching(message.id); await regenerateMessageWithBranching(message.id, modelOverride);
refreshAllMessages(); refreshAllMessages();
} }

View File

@ -36,8 +36,13 @@
supportsAudio, supportsAudio,
propsLoading, propsLoading,
serverWarning, serverWarning,
propsStore propsStore,
isRouterMode,
fetchModelProps,
getModelProps
} from '$lib/stores/props.svelte'; } 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 { parseFilesToMessageExtras } from '$lib/utils/convert-files-to-extra';
import { isFileTypeSupported } from '$lib/utils/file-type'; import { isFileTypeSupported } from '$lib/utils/file-type';
import { filterFilesByModalities } from '$lib/utils/modality-file-validation'; import { filterFilesByModalities } from '$lib/utils/modality-file-validation';
@ -89,6 +94,72 @@
let isCurrentConversationLoading = $derived(isLoading()); 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() { async function handleDeleteConfirm() {
const conversation = activeConversation(); const conversation = activeConversation();
if (conversation) { if (conversation) {
@ -220,16 +291,20 @@
} }
} }
const { supportedFiles, unsupportedFiles, modalityReasons } = // Use model-specific capabilities for file validation
filterFilesByModalities(generallySupported); const capabilities = { hasVision: hasVisionModality, hasAudio: hasAudioModality };
const { supportedFiles, unsupportedFiles, modalityReasons } = filterFilesByModalities(
generallySupported,
capabilities
);
const allUnsupportedFiles = [...generallyUnsupported, ...unsupportedFiles]; const allUnsupportedFiles = [...generallyUnsupported, ...unsupportedFiles];
if (allUnsupportedFiles.length > 0) { if (allUnsupportedFiles.length > 0) {
const supportedTypes: string[] = ['text files', 'PDFs']; const supportedTypes: string[] = ['text files', 'PDFs'];
if (supportsVision()) supportedTypes.push('images'); if (hasVisionModality) supportedTypes.push('images');
if (supportsAudio()) supportedTypes.push('audio files'); if (hasAudioModality) supportedTypes.push('audio files');
fileErrorData = { fileErrorData = {
generallyUnsupported, generallyUnsupported,

View File

@ -21,6 +21,8 @@
onModelChange?: (modelId: string, modelName: string) => void; onModelChange?: (modelId: string, modelName: string) => void;
disabled?: boolean; disabled?: boolean;
forceForegroundText?: boolean; forceForegroundText?: boolean;
/** When true, user's global selection takes priority over currentModel (for form selector) */
useGlobalSelection?: boolean;
} }
let { let {
@ -28,7 +30,8 @@
currentModel = null, currentModel = null,
onModelChange, onModelChange,
disabled = false, disabled = false,
forceForegroundText = false forceForegroundText = false,
useGlobalSelection = false
}: Props = $props(); }: Props = $props();
let options = $derived(modelOptions()); let options = $derived(modelOptions());
@ -260,6 +263,14 @@
return undefined; 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 (currentModel) {
if (!isCurrentModelInCache()) { if (!isCurrentModelInCache()) {
return { return {
@ -273,7 +284,7 @@
return options.find((option) => option.model === currentModel); 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) { if (activeId) {
return options.find((option) => option.id === activeId); return options.find((option) => option.id === activeId);
} }

View File

@ -150,7 +150,7 @@ export class ChatService {
}; };
const isRouter = isRouterMode(); const isRouter = isRouterMode();
const activeModel = isRouter ? selectedModelName() : null; const activeModel = isRouter ? options.model || selectedModelName() : null;
if (isRouter && activeModel) { if (isRouter && activeModel) {
requestBody.model = activeModel; requestBody.model = activeModel;

View File

@ -40,4 +40,34 @@ export class PropsService {
const data = await response.json(); const data = await response.json();
return data as ApiLlamaCppServerProps; return data as ApiLlamaCppServerProps;
} }
/**
* Fetches server properties for a specific model (ROUTER mode)
*
* @param modelId - The model ID to fetch properties for
* @returns {Promise<ApiLlamaCppServerProps>} Server properties for the model
* @throws {Error} If the request fails or returns invalid data
*/
static async fetchForModel(modelId: string): Promise<ApiLlamaCppServerProps> {
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;
}
} }

View File

@ -489,7 +489,8 @@ class ChatStore {
allMessages: DatabaseMessage[], allMessages: DatabaseMessage[],
assistantMessage: DatabaseMessage, assistantMessage: DatabaseMessage,
onComplete?: (content: string) => Promise<void>, onComplete?: (content: string) => Promise<void>,
onError?: (error: Error) => void onError?: (error: Error) => void,
modelOverride?: string | null
): Promise<void> { ): Promise<void> {
let streamedContent = ''; let streamedContent = '';
let streamedReasoningContent = ''; let streamedReasoningContent = '';
@ -520,6 +521,7 @@ class ChatStore {
allMessages, allMessages,
{ {
...this.getApiOptions(), ...this.getApiOptions(),
...(modelOverride ? { model: modelOverride } : {}),
onChunk: (chunk: string) => { onChunk: (chunk: string) => {
streamedContent += chunk; streamedContent += chunk;
this.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id); this.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id);
@ -996,7 +998,7 @@ class ChatStore {
} }
} }
async regenerateMessageWithBranching(messageId: string): Promise<void> { async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise<void> {
const activeConv = conversationsStore.activeConversation; const activeConv = conversationsStore.activeConversation;
if (!activeConv || this.isLoading) return; if (!activeConv || this.isLoading) return;
try { try {
@ -1035,7 +1037,16 @@ class ChatStore {
parentMessage.id, parentMessage.id,
false false
) as DatabaseMessage[]; ) 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) { } catch (error) {
if (!this.isAbortError(error)) if (!this.isAbortError(error))
console.error('Failed to regenerate message with branching:', error); console.error('Failed to regenerate message with branching:', error);

View File

@ -39,6 +39,10 @@ class PropsStore {
private _serverMode = $state<ServerMode | null>(null); private _serverMode = $state<ServerMode | null>(null);
private fetchPromise: Promise<void> | null = null; private fetchPromise: Promise<void> | null = null;
// Model-specific props cache (ROUTER mode)
private _modelPropsCache = $state<Map<string, ApiLlamaCppServerProps>>(new Map());
private _modelPropsFetching = $state<Set<string>>(new Set());
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// LocalStorage persistence // LocalStorage persistence
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@ -238,6 +242,52 @@ class PropsStore {
await fetchPromise; 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<ApiLlamaCppServerProps | null> {
// 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 // Error Handling
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@ -365,3 +415,5 @@ export const isModelMode = () => propsStore.isModelMode;
// Actions // Actions
export const fetchProps = propsStore.fetch.bind(propsStore); export const fetchProps = propsStore.fetch.bind(propsStore);
export const fetchModelProps = propsStore.fetchModelProps.bind(propsStore);
export const getModelProps = propsStore.getModelProps.bind(propsStore);

View File

@ -14,6 +14,8 @@ export interface SettingsFieldConfig {
export interface SettingsChatServiceOptions { export interface SettingsChatServiceOptions {
stream?: boolean; stream?: boolean;
// Model override (for regenerate with specific model)
model?: string;
// Generation parameters // Generation parameters
temperature?: number; temperature?: number;
max_tokens?: number; max_tokens?: number;

View File

@ -4,7 +4,6 @@
*/ */
import { getFileTypeCategory } from '$lib/utils/file-type'; import { getFileTypeCategory } from '$lib/utils/file-type';
import { supportsVision, supportsAudio } from '$lib/stores/props.svelte';
import { import {
FileExtensionAudio, FileExtensionAudio,
FileExtensionImage, FileExtensionImage,
@ -17,13 +16,24 @@ import {
FileTypeCategory FileTypeCategory
} from '$lib/enums'; } 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 filename - The filename to check
* @param mimeType - The MIME type of the file * @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; const category = mimeType ? getFileTypeCategory(mimeType) : null;
// If we can't determine the category from MIME type, fall back to general support check // 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: case FileTypeCategory.IMAGE:
// Images require vision support // Images require vision support
return supportsVision(); return capabilities.hasVision;
case FileTypeCategory.AUDIO: case FileTypeCategory.AUDIO:
// Audio files require audio support // Audio files require audio support
return supportsAudio(); return capabilities.hasAudio;
default: default:
// Unknown categories - be conservative and allow // 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 * Filter files based on model modalities and return supported/unsupported lists
* @param files - Array of files to filter * @param files - Array of files to filter
* @param capabilities - The modality capabilities to check against
* @returns Object with supportedFiles and unsupportedFiles arrays * @returns Object with supportedFiles and unsupportedFiles arrays
*/ */
export function filterFilesByModalities(files: File[]): { export function filterFilesByModalities(
files: File[],
capabilities: ModalityCapabilities
): {
supportedFiles: File[]; supportedFiles: File[];
unsupportedFiles: File[]; unsupportedFiles: File[];
modalityReasons: Record<string, string>; modalityReasons: Record<string, string>;
@ -70,8 +84,7 @@ export function filterFilesByModalities(files: File[]): {
const unsupportedFiles: File[] = []; const unsupportedFiles: File[] = [];
const modalityReasons: Record<string, string> = {}; const modalityReasons: Record<string, string> = {};
const hasVision = supportsVision(); const { hasVision, hasAudio } = capabilities;
const hasAudio = supportsAudio();
for (const file of files) { for (const file of files) {
const category = getFileTypeCategory(file.type); const category = getFileTypeCategory(file.type);
@ -119,16 +132,17 @@ export function filterFilesByModalities(files: File[]): {
* Generate a user-friendly error message for unsupported files * Generate a user-friendly error message for unsupported files
* @param unsupportedFiles - Array of unsupported files * @param unsupportedFiles - Array of unsupported files
* @param modalityReasons - Reasons why files are unsupported * @param modalityReasons - Reasons why files are unsupported
* @param capabilities - The modality capabilities to check against
* @returns Formatted error message * @returns Formatted error message
*/ */
export function generateModalityErrorMessage( export function generateModalityErrorMessage(
unsupportedFiles: File[], unsupportedFiles: File[],
modalityReasons: Record<string, string> modalityReasons: Record<string, string>,
capabilities: ModalityCapabilities
): string { ): string {
if (unsupportedFiles.length === 0) return ''; if (unsupportedFiles.length === 0) return '';
const hasVision = supportsVision(); const { hasVision, hasAudio } = capabilities;
const hasAudio = supportsAudio();
let message = ''; 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 * @returns Accept string for HTML file input element
*/ */
export function generateModalityAwareAcceptString(): string { export function generateModalityAwareAcceptString(capabilities: ModalityCapabilities): string {
const hasVision = supportsVision(); const { hasVision, hasAudio } = capabilities;
const hasAudio = supportsAudio();
const acceptedExtensions: string[] = []; const acceptedExtensions: string[] = [];
const acceptedMimeTypes: string[] = []; const acceptedMimeTypes: string[] = [];