diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte index 4f5d802e43..cecdc784c9 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte @@ -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; }); @@ -123,17 +140,19 @@
- {getFileTypeLabel(fileType)} + {fileTypeLabel}
-
+
{name} - {#if size} + {#if pdfProcessingMode} + {pdfProcessingMode} + {:else if size} {formatFileSize(size)} {/if}
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 d94bad60b6..26afe56f20 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 @@ -229,7 +229,9 @@ } async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise { - 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]; } } diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte index 94903d3235..fd0823c33e 100644 --- a/tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte +++ b/tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte @@ -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(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}
diff --git a/tools/server/webui/src/lib/components/app/misc/BadgeModelName.svelte b/tools/server/webui/src/lib/components/app/misc/BadgeModelName.svelte index a8e3822237..275699be7a 100644 --- a/tools/server/webui/src/lib/components/app/misc/BadgeModelName.svelte +++ b/tools/server/webui/src/lib/components/app/misc/BadgeModelName.svelte @@ -1,6 +1,7 @@ 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 1b1326fefb..44a1468ca6 100644 --- a/tools/server/webui/src/lib/components/app/misc/SelectorModel.svelte +++ b/tools/server/webui/src/lib/components/app/misc/SelectorModel.svelte @@ -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(); diff --git a/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte b/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte index f04c954d70..d9f6d4a32a 100644 --- a/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte +++ b/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte @@ -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() { diff --git a/tools/server/webui/src/lib/stores/models.svelte.ts b/tools/server/webui/src/lib/stores/models.svelte.ts index a30ad6962c..8c53ae268d 100644 --- a/tools/server/webui/src/lib/stores/models.svelte.ts +++ b/tools/server/webui/src/lib/stores/models.svelte.ts @@ -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>(new Map()); private modelPropsFetching = $state>(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; diff --git a/tools/server/webui/src/lib/stores/server.svelte.ts b/tools/server/webui/src/lib/stores/server.svelte.ts index f6ea29183d..1e36b970f4 100644 --- a/tools/server/webui/src/lib/stores/server.svelte.ts +++ b/tools/server/webui/src/lib/stores/server.svelte.ts @@ -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; diff --git a/tools/server/webui/src/lib/utils/convert-files-to-extra.ts b/tools/server/webui/src/lib/utils/convert-files-to-extra.ts index 2ebac5a761..a82deb2447 100644 --- a/tools/server/webui/src/lib/utils/convert-files-to-extra.ts +++ b/tools/server/webui/src/lib/utils/convert-files-to-extra.ts @@ -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 { 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; diff --git a/tools/server/webui/src/lib/utils/file-preview.ts b/tools/server/webui/src/lib/utils/file-preview.ts index c082034a39..115f8727a9 100644 --- a/tools/server/webui/src/lib/utils/file-preview.ts +++ b/tools/server/webui/src/lib/utils/file-preview.ts @@ -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(); } /** diff --git a/tools/server/webui/src/lib/utils/process-uploaded-files.ts b/tools/server/webui/src/lib/utils/process-uploaded-files.ts index c4f84eeedf..372b433502 100644 --- a/tools/server/webui/src/lib/utils/process-uploaded-files.ts +++ b/tools/server/webui/src/lib/utils/process-uploaded-files.ts @@ -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 { * @param files - Array of File objects to process * @returns Promise resolving to array of ChatUploadedFile objects */ -export async function processFilesToChatUploaded(files: File[]): Promise { +export async function processFilesToChatUploaded( + files: File[], + activeModelId?: string +): Promise { const results: ChatUploadedFile[] = []; for (const file of files) { @@ -96,7 +99,9 @@ export async function processFilesToChatUploaded(files: File[]): Promise