feat: Switching models logic for ChatForm or when regenerating messges + modality detection logic
This commit is contained in:
parent
4c24ead8e0
commit
b9a3129d42
|
|
@ -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 @@
|
|||
<ChatFormFileInputInvisible
|
||||
bind:this={fileInputRef}
|
||||
bind:accept={fileAcceptString}
|
||||
{hasAudioModality}
|
||||
{hasVisionModality}
|
||||
onFileSelect={handleFileSelect}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<Tooltip.Trigger class="w-full">
|
||||
<DropdownMenu.Item
|
||||
class="images-button flex cursor-pointer items-center gap-2"
|
||||
disabled={!supportsVision()}
|
||||
disabled={!hasVisionModality}
|
||||
onclick={() => handleFileUpload(FileTypeCategory.IMAGE)}
|
||||
>
|
||||
<Image class="h-4 w-4" />
|
||||
|
|
@ -62,7 +69,7 @@
|
|||
</DropdownMenu.Item>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
{#if !supportsVision()}
|
||||
{#if !hasVisionModality}
|
||||
<Tooltip.Content>
|
||||
<p>Images require vision models to be processed</p>
|
||||
</Tooltip.Content>
|
||||
|
|
@ -73,7 +80,7 @@
|
|||
<Tooltip.Trigger class="w-full">
|
||||
<DropdownMenu.Item
|
||||
class="audio-button flex cursor-pointer items-center gap-2"
|
||||
disabled={!supportsAudio()}
|
||||
disabled={!hasAudioModality}
|
||||
onclick={() => handleFileUpload(FileTypeCategory.AUDIO)}
|
||||
>
|
||||
<Volume2 class="h-4 w-4" />
|
||||
|
|
@ -82,7 +89,7 @@
|
|||
</DropdownMenu.Item>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
{#if !supportsAudio()}
|
||||
{#if !hasAudioModality}
|
||||
<Tooltip.Content>
|
||||
<p>Audio files require audio models to be processed</p>
|
||||
</Tooltip.Content>
|
||||
|
|
@ -110,7 +117,7 @@
|
|||
</DropdownMenu.Item>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
{#if !supportsVision()}
|
||||
{#if !hasVisionModality}
|
||||
<Tooltip.Content>
|
||||
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
|
||||
</Tooltip.Content>
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
{#if !supportsAudio()}
|
||||
{#if !hasAudioModality}
|
||||
<Tooltip.Content>
|
||||
<p>Current model does not support audio</p>
|
||||
</Tooltip.Content>
|
||||
|
|
|
|||
|
|
@ -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=<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(
|
||||
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();
|
||||
}
|
||||
</script>
|
||||
|
||||
<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
|
||||
bind:this={selectorModelRef}
|
||||
currentModel={conversationModel}
|
||||
forceForegroundText={true}
|
||||
useGlobalSelection={true}
|
||||
/>
|
||||
|
||||
{#if isLoading}
|
||||
|
|
@ -125,7 +198,7 @@
|
|||
<Square class="h-8 w-8 fill-destructive stroke-destructive" />
|
||||
</Button>
|
||||
{:else if shouldShowRecordButton}
|
||||
<ChatFormActionRecord {disabled} {isLoading} {isRecording} {onMicClick} />
|
||||
<ChatFormActionRecord {disabled} {hasAudioModality} {isLoading} {isRecording} {onMicClick} />
|
||||
{:else}
|
||||
<ChatFormActionSubmit
|
||||
canSend={canSend && hasModelSelected && isSelectedModelInCache}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
interface Props {
|
||||
accept?: string;
|
||||
class?: string;
|
||||
hasAudioModality?: boolean;
|
||||
hasVisionModality?: boolean;
|
||||
multiple?: boolean;
|
||||
onFileSelect?: (files: File[]) => 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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -489,7 +489,8 @@ class ChatStore {
|
|||
allMessages: DatabaseMessage[],
|
||||
assistantMessage: DatabaseMessage,
|
||||
onComplete?: (content: string) => Promise<void>,
|
||||
onError?: (error: Error) => void
|
||||
onError?: (error: Error) => void,
|
||||
modelOverride?: string | null
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise<void> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ class PropsStore {
|
|||
private _serverMode = $state<ServerMode | 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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -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<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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
|
|
@ -70,8 +84,7 @@ export function filterFilesByModalities(files: File[]): {
|
|||
const unsupportedFiles: File[] = [];
|
||||
const modalityReasons: Record<string, string> = {};
|
||||
|
||||
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<string, string>
|
||||
modalityReasons: Record<string, string>,
|
||||
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[] = [];
|
||||
|
|
|
|||
Loading…
Reference in New Issue