refactor: Enhance model info and attachment handling

This commit is contained in:
Aleksander Grygier 2025-11-28 15:08:41 +01:00
parent 491fe2d3f7
commit eed1bd9b97
11 changed files with 185 additions and 90 deletions

View File

@ -3,7 +3,7 @@
import { getFileTypeLabel, getPreviewText } from '$lib/utils/file-preview';
import { formatFileSize } from '$lib/utils/formatters';
import { isTextFile } from '$lib/utils/attachment-type';
import type { DatabaseMessageExtra } from '$lib/types/database';
import type { DatabaseMessageExtra, DatabaseMessageExtraPdfFile } from '$lib/types/database';
import { AttachmentType } from '$lib/enums';
interface Props {
@ -35,14 +35,31 @@
let isText = $derived(isTextFile(attachment, uploadedFile));
// Get file type for display - check uploadedFile first, then attachment mimeType
let fileType = $derived.by(() => {
if (uploadedFile?.type) return uploadedFile.type;
// For audio attachments stored in DB, get mimeType from the attachment
if (attachment?.type === AttachmentType.AUDIO && 'mimeType' in attachment) {
return attachment.mimeType;
let fileTypeLabel = $derived.by(() => {
if (uploadedFile?.type) {
return getFileTypeLabel(uploadedFile.type);
}
return 'unknown';
if (attachment) {
if ('mimeType' in attachment && attachment.mimeType) {
return getFileTypeLabel(attachment.mimeType);
}
if (attachment.type) {
return getFileTypeLabel(attachment.type);
}
}
return getFileTypeLabel(name);
});
let pdfProcessingMode = $derived.by(() => {
if (attachment?.type === AttachmentType.PDF) {
const pdfAttachment = attachment as DatabaseMessageExtraPdfFile;
return pdfAttachment.processedAsImages ? 'Sent as Image' : 'Sent as Text';
}
return null;
});
</script>
@ -123,17 +140,19 @@
<div
class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
>
{getFileTypeLabel(fileType)}
{fileTypeLabel}
</div>
<div class="flex flex-col gap-1">
<div class="flex flex-col gap-0.5">
<span
class="max-w-24 truncate text-sm font-medium text-foreground group-hover:pr-6 md:max-w-32"
>
{name}
</span>
{#if size}
{#if pdfProcessingMode}
<span class="text-left text-xs text-muted-foreground">{pdfProcessingMode}</span>
{:else if size}
<span class="text-left text-xs text-muted-foreground">{formatFileSize(size)}</span>
{/if}
</div>

View File

@ -229,7 +229,9 @@
}
async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
const result = files ? await parseFilesToMessageExtras(files) : undefined;
const result = files
? await parseFilesToMessageExtras(files, activeModelId ?? undefined)
: undefined;
if (result?.emptyFiles && result.emptyFiles.length > 0) {
emptyFileNames = result.emptyFiles;
@ -292,7 +294,10 @@
}
if (supportedFiles.length > 0) {
const processed = await processFilesToChatUploaded(supportedFiles);
const processed = await processFilesToChatUploaded(
supportedFiles,
activeModelId ?? undefined
);
uploadedFiles = [...uploadedFiles, ...processed];
}
}

View File

@ -3,6 +3,7 @@
import * as Table from '$lib/components/ui/table';
import { BadgeModality, CopyToClipboardIcon } from '$lib/components/app';
import { serverStore } from '$lib/stores/server.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { ChatService } from '$lib/services/chat';
import type { ApiModelListResponse } from '$lib/types/api';
import { formatFileSize, formatParameters, formatNumber } from '$lib/utils/formatters';
@ -15,7 +16,14 @@
let { open = $bindable(), onOpenChange }: Props = $props();
let serverProps = $derived(serverStore.props);
let modalities = $derived(serverStore.supportedModalities);
let modelName = $derived(modelsStore.singleModelName);
// Get modalities from modelStore using the model ID from the first model
let modalities = $derived.by(() => {
if (!modelsData?.data?.[0]?.id) return [];
return modelsStore.getModelModalitiesArray(modelsData.data[0].id);
});
let modelsData = $state<ApiModelListResponse | null>(null);
let isLoadingModels = $state(false);
@ -77,12 +85,12 @@
class="resizable-text-container min-w-0 flex-1 truncate"
style:--threshold="12rem"
>
{serverStore.modelName}
{modelName}
</span>
<CopyToClipboardIcon
text={serverStore.modelName || ''}
canCopy={!!serverStore.modelName}
text={modelName || ''}
canCopy={!!modelName}
ariaLabel="Copy model name to clipboard"
/>
</div>

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { Package } from '@lucide/svelte';
import { BadgeInfo, CopyToClipboardIcon } from '$lib/components/app';
import { modelsStore } from '$lib/stores/models.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
@ -21,7 +22,7 @@
showTooltip = false
}: Props = $props();
let model = $derived(modelProp || serverStore.modelName);
let model = $derived(modelProp || modelsStore.singleModelName);
let isModelMode = $derived(serverStore.isModelMode);
</script>

View File

@ -10,11 +10,13 @@
modelsLoading,
modelsUpdating,
selectedModelId,
routerModels
routerModels,
propsCacheVersion,
singleModelName
} from '$lib/stores/models.svelte';
import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte';
import { ServerModelStatus } from '$lib/enums';
import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { DialogModelInformation } from '$lib/components/app';
import type { ModelOption } from '$lib/types/models';
@ -50,7 +52,7 @@
let updating = $derived(modelsUpdating());
let activeId = $derived(selectedModelId());
let isRouter = $derived(isRouterMode());
let serverModel = $derived(serverStore.modelName);
let serverModel = $derived(singleModelName());
// Reactive router models state - needed for proper reactivity of status checks
let currentRouterModels = $derived(routerModels());
@ -69,9 +71,19 @@
* Returns true if the model can be selected, false if it should be disabled.
*/
function isModelCompatible(option: ModelOption): boolean {
const modelModalities = option.modalities;
void propsCacheVersion();
if (!modelModalities) return true;
const modelModalities = modelsStore.getModelModalities(option.model);
if (!modelModalities) {
const status = getModelStatus(option.model);
if (status === ServerModelStatus.LOADED) {
if (requiredModalities.vision || requiredModalities.audio) return false;
}
return true;
}
if (requiredModalities.vision && !modelModalities.vision) return false;
if (requiredModalities.audio && !modelModalities.audio) return false;
@ -84,8 +96,24 @@
* Returns object with vision/audio booleans indicating what's missing.
*/
function getMissingModalities(option: ModelOption): { vision: boolean; audio: boolean } | null {
const modelModalities = option.modalities;
if (!modelModalities) return null;
void propsCacheVersion();
const modelModalities = modelsStore.getModelModalities(option.model);
if (!modelModalities) {
const status = getModelStatus(option.model);
if (status === ServerModelStatus.LOADED) {
const missing = {
vision: requiredModalities.vision,
audio: requiredModalities.audio
};
if (missing.vision || missing.audio) return missing;
}
return null;
}
const missing = {
vision: requiredModalities.vision && !modelModalities.vision,
@ -93,6 +121,7 @@
};
if (!missing.vision && !missing.audio) return null;
return missing;
}
@ -160,9 +189,10 @@
await tick();
updateMenuPosition();
requestAnimationFrame(() => updateMenuPosition());
modelsStore.fetchModalitiesForLoadedModels();
}
// Export open function for programmatic access
export function open() {
if (isRouter) {
openMenu();

View File

@ -2,7 +2,8 @@
import { AlertTriangle, Server } from '@lucide/svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { serverProps, serverLoading, serverError, modelName } from '$lib/stores/server.svelte';
import { serverProps, serverLoading, serverError } from '$lib/stores/server.svelte';
import { singleModelName } from '$lib/stores/models.svelte';
interface Props {
class?: string;
@ -13,7 +14,7 @@
let error = $derived(serverError());
let loading = $derived(serverLoading());
let model = $derived(modelName());
let model = $derived(singleModelName());
let serverData = $derived(serverProps());
function getStatusColor() {

View File

@ -1,7 +1,7 @@
import { SvelteSet } from 'svelte/reactivity';
import { ModelsService } from '$lib/services/models';
import { PropsService } from '$lib/services/props';
import { ServerModelStatus } from '$lib/enums';
import { ServerModelStatus, ModelModality } from '$lib/enums';
import { serverStore } from '$lib/stores/server.svelte';
import type { ModelOption, ModelModalities } from '$lib/types/models';
import type { ApiModelDataEntry } from '$lib/types/api';
@ -56,6 +56,11 @@ class ModelsStore {
private modelPropsCache = $state<Map<string, ApiLlamaCppServerProps>>(new Map());
private modelPropsFetching = $state<Set<string>>(new Set());
/**
* Version counter for props cache - used to trigger reactivity when props are updated
*/
propsCacheVersion = $state(0);
// ─────────────────────────────────────────────────────────────────────────────
// Computed Getters
// ─────────────────────────────────────────────────────────────────────────────
@ -77,6 +82,21 @@ class ModelsStore {
.map(([id]) => id);
}
/**
* Get model name in MODEL mode (single model).
* Extracts from model_path or model_alias from server props.
* In ROUTER mode, returns null (model is per-conversation).
*/
get singleModelName(): string | null {
if (serverStore.isRouterMode) return null;
const props = serverStore.props;
if (props?.model_alias) return props.model_alias;
if (!props?.model_path) return null;
return props.model_path.split(/(\\|\/)/).pop() || null;
}
// ─────────────────────────────────────────────────────────────────────────────
// Modalities
// ─────────────────────────────────────────────────────────────────────────────
@ -118,6 +138,21 @@ class ModelsStore {
return this.getModelModalities(modelId)?.audio ?? false;
}
/**
* Get model modalities as an array of ModelModality enum values
*/
getModelModalitiesArray(modelId: string): ModelModality[] {
const modalities = this.getModelModalities(modelId);
if (!modalities) return [];
const result: ModelModality[] = [];
if (modalities.vision) result.push(ModelModality.VISION);
if (modalities.audio) result.push(ModelModality.AUDIO);
return result;
}
/**
* Get props for a specific model (from cache)
*/
@ -300,6 +335,9 @@ class ModelsStore {
return { ...model, modalities };
});
// Increment version to trigger reactivity
this.propsCacheVersion++;
} catch (error) {
console.warn('Failed to fetch modalities for loaded models:', error);
}
@ -322,6 +360,9 @@ class ModelsStore {
this.models = this.models.map((model) =>
model.model === modelId ? { ...model, modalities } : model
);
// Increment version to trigger reactivity
this.propsCacheVersion++;
} catch (error) {
console.warn(`Failed to update modalities for model ${modelId}:`, error);
}
@ -583,3 +624,5 @@ export const selectedModelName = () => modelsStore.selectedModelName;
export const selectedModelOption = () => modelsStore.selectedModel;
export const loadedModelIds = () => modelsStore.loadedModelIds;
export const loadingModelIds = () => modelsStore.loadingModelIds;
export const propsCacheVersion = () => modelsStore.propsCacheVersion;
export const singleModelName = () => modelsStore.singleModelName;

View File

@ -1,5 +1,5 @@
import { PropsService } from '$lib/services/props';
import { ServerRole, ModelModality } from '$lib/enums';
import { ServerRole } from '$lib/enums';
/**
* serverStore - Server connection state, configuration, and role detection
@ -16,12 +16,6 @@ import { ServerRole, ModelModality } from '$lib/enums';
* - **Server State**: Connection status, loading, error handling
* - **Role Detection**: MODEL (single model) vs ROUTER (multi-model)
* - **Default Params**: Server-wide generation defaults
*
* **Note on Modalities:**
* Model-specific modalities (vision, audio) are now managed by modelsStore.
* Use `modelsStore.getModelModalities(modelId)` for per-model modality info.
* The `supportsVision`/`supportsAudio` getters here are deprecated and only
* apply to MODEL mode (single model).
*/
class ServerStore {
// ─────────────────────────────────────────────────────────────────────────────
@ -38,45 +32,6 @@ class ServerStore {
// Getters
// ─────────────────────────────────────────────────────────────────────────────
/**
* Get model name from server props.
* In MODEL mode: extracts from model_path or model_alias
* In ROUTER mode: returns null (model is per-conversation)
*/
get modelName(): string | null {
if (this.role === ServerRole.ROUTER) return null;
if (this.props?.model_alias) return this.props.model_alias;
if (!this.props?.model_path) return null;
return this.props.model_path.split(/(\\|\/)/).pop() || null;
}
/**
* @deprecated Use modelsStore.getModelModalities(modelId) for per-model modalities.
* This only works in MODEL mode (single model).
*/
get supportedModalities(): ModelModality[] {
const modalities: ModelModality[] = [];
if (this.props?.modalities?.audio) modalities.push(ModelModality.AUDIO);
if (this.props?.modalities?.vision) modalities.push(ModelModality.VISION);
return modalities;
}
/**
* @deprecated Use modelsStore.modelSupportsVision(modelId) for per-model check.
* This only works in MODEL mode (single model).
*/
get supportsVision(): boolean {
return this.props?.modalities?.vision ?? false;
}
/**
* @deprecated Use modelsStore.modelSupportsAudio(modelId) for per-model check.
* This only works in MODEL mode (single model).
*/
get supportsAudio(): boolean {
return this.props?.modalities?.audio ?? false;
}
get defaultParams(): ApiLlamaCppServerProps['default_generation_settings']['params'] | null {
return this.props?.default_generation_settings?.params || null;
}
@ -179,10 +134,6 @@ export const serverProps = () => serverStore.props;
export const serverLoading = () => serverStore.loading;
export const serverError = () => serverStore.error;
export const serverRole = () => serverStore.role;
export const modelName = () => serverStore.modelName;
export const supportedModalities = () => serverStore.supportedModalities;
export const supportsVision = () => serverStore.supportsVision;
export const supportsAudio = () => serverStore.supportsAudio;
export const slotsEndpointAvailable = () => serverStore.slotsEndpointAvailable;
export const defaultParams = () => serverStore.defaultParams;
export const contextSize = () => serverStore.contextSize;

View File

@ -3,7 +3,7 @@ import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { FileTypeCategory, AttachmentType } from '$lib/enums';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { supportsVision } from '$lib/stores/server.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { readFileAsText, isLikelyTextFile } from './text-files';
import { toast } from 'svelte-sonner';
@ -31,7 +31,8 @@ export interface FileProcessingResult {
}
export async function parseFilesToMessageExtras(
files: ChatUploadedFile[]
files: ChatUploadedFile[],
activeModelId?: string
): Promise<FileProcessingResult> {
const extras: DatabaseMessageExtra[] = [];
const emptyFiles: string[] = [];
@ -80,7 +81,10 @@ export async function parseFilesToMessageExtras(
// Always get base64 data for preview functionality
const base64Data = await readFileAsBase64(file.file);
const currentConfig = config();
const hasVisionSupport = supportsVision();
// Use per-model vision check for router mode
const hasVisionSupport = activeModelId
? modelsStore.modelSupportsVision(activeModelId)
: false;
// Force PDF-to-text for non-vision models
let shouldProcessAsImages = Boolean(currentConfig.pdfAsImage) && hasVisionSupport;

View File

@ -1,10 +1,38 @@
/**
* Gets a display label for a file type
* @param fileType - The file type/mime type
* @returns Formatted file type label
* Gets a display label for a file type from various input formats
*
* Handles:
* - MIME types: 'application/pdf' 'PDF'
* - AttachmentType values: 'PDF', 'AUDIO' 'PDF', 'AUDIO'
* - File names: 'document.pdf' 'PDF'
* - Unknown: returns 'FILE'
*
* @param input - MIME type, AttachmentType value, or file name
* @returns Formatted file type label (uppercase)
*/
export function getFileTypeLabel(fileType: string): string {
return fileType.split('/').pop()?.toUpperCase() || 'FILE';
export function getFileTypeLabel(input: string | undefined): string {
if (!input) return 'FILE';
// Handle MIME types (contains '/')
if (input.includes('/')) {
const subtype = input.split('/').pop();
if (subtype) {
// Handle special cases like 'vnd.ms-excel' → 'EXCEL'
if (subtype.includes('.')) {
return subtype.split('.').pop()?.toUpperCase() || 'FILE';
}
return subtype.toUpperCase();
}
}
// Handle file names (contains '.')
if (input.includes('.')) {
const ext = input.split('.').pop();
if (ext) return ext.toUpperCase();
}
// Handle AttachmentType or other plain strings
return input.toUpperCase();
}
/**

View File

@ -3,7 +3,7 @@ import { isTextFileByName } from './text-files';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { FileTypeCategory } from '$lib/enums';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { supportsVision } from '$lib/stores/server.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toast } from 'svelte-sonner';
@ -47,7 +47,10 @@ function readFileAsUTF8(file: File): Promise<string> {
* @param files - Array of File objects to process
* @returns Promise resolving to array of ChatUploadedFile objects
*/
export async function processFilesToChatUploaded(files: File[]): Promise<ChatUploadedFile[]> {
export async function processFilesToChatUploaded(
files: File[],
activeModelId?: string
): Promise<ChatUploadedFile[]> {
const results: ChatUploadedFile[] = [];
for (const file of files) {
@ -96,7 +99,9 @@ export async function processFilesToChatUploaded(files: File[]): Promise<ChatUpl
results.push(base);
// Show suggestion toast if vision model is available but PDF as image is disabled
const hasVisionSupport = supportsVision();
const hasVisionSupport = activeModelId
? modelsStore.modelSupportsVision(activeModelId)
: false;
const currentConfig = settingsStore.config;
if (hasVisionSupport && !currentConfig.pdfAsImage) {
toast.info(`You can enable parsing PDF as images with vision models.`, {