]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
webui: support video files as input (#22830)
authorJudd <redacted>
Sun, 17 May 2026 00:13:44 +0000 (08:13 +0800)
committerGitHub <redacted>
Sun, 17 May 2026 00:13:44 +0000 (02:13 +0200)
36 files changed:
tools/ui/src/app.d.ts
tools/ui/src/lib/components/app/badges/BadgesModality.svelte
tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailFile.svelte
tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview.svelte
tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItem.svelte
tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemVideo.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
tools/ui/src/lib/constants/attachment-menu.ts
tools/ui/src/lib/constants/icons.ts
tools/ui/src/lib/constants/supported-file-types.ts
tools/ui/src/lib/enums/attachment.ts
tools/ui/src/lib/enums/chat.ts
tools/ui/src/lib/enums/files.ts
tools/ui/src/lib/enums/index.ts
tools/ui/src/lib/enums/model.ts
tools/ui/src/lib/hooks/use-attachment-menu.svelte.ts
tools/ui/src/lib/services/chat.service.ts
tools/ui/src/lib/stores/models.svelte.ts
tools/ui/src/lib/types/api.d.ts
tools/ui/src/lib/types/common.d.ts
tools/ui/src/lib/types/database.d.ts
tools/ui/src/lib/types/index.ts
tools/ui/src/lib/types/models.d.ts
tools/ui/src/lib/utils/attachment-type.ts
tools/ui/src/lib/utils/convert-files-to-extra.ts
tools/ui/src/lib/utils/file-type.ts
tools/ui/src/lib/utils/index.ts
tools/ui/src/lib/utils/modality-file-validation.ts
tools/ui/src/lib/utils/process-uploaded-files.ts
tools/ui/tests/stories/fixtures/storybook-mocks.ts

index f5af7323c8b5485319e877a72ca0e429a677a981..ec65952e9a7d3985b44d560f15e3f6014f6da897 100644 (file)
@@ -39,6 +39,7 @@ import type {
        DatabaseMessage,
        DatabaseMessageExtra,
        DatabaseMessageExtraAudioFile,
+       DatabaseMessageExtraVideoFile,
        DatabaseMessageExtraImageFile,
        DatabaseMessageExtraTextFile,
        DatabaseMessageExtraPdfFile,
@@ -102,6 +103,7 @@ declare global {
                DatabaseMessage,
                DatabaseMessageExtra,
                DatabaseMessageExtraAudioFile,
+               DatabaseMessageExtraVideoFile,
                DatabaseMessageExtraImageFile,
                DatabaseMessageExtraTextFile,
                DatabaseMessageExtraPdfFile,
index 841f1dd9fa9cdbe37eb72a0a2b5dedccdff0b612..d87184ea9bc8cef5ef9d4b359408604e720af5a9 100644 (file)
@@ -1,5 +1,5 @@
 <script lang="ts">
-       import { Eye, Mic } from '@lucide/svelte';
+       import { Eye, Mic, Video } from '@lucide/svelte';
        import { ModelModality } from '$lib/enums';
 
        interface Props {
@@ -11,7 +11,7 @@
 </script>
 
 {#each modalities as modality (modality)}
-       {#if modality === ModelModality.VISION || modality === ModelModality.AUDIO}
+       {#if modality === ModelModality.VISION || modality === ModelModality.AUDIO || modality === ModelModality.VIDEO}
                <span
                        class={[
                                'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium',
                        {#if modality === ModelModality.VISION}
                                <Eye class="h-3 w-3" />
 
-                               Vision
+                               Vision (Image)
+                       {:else if modality === ModelModality.VIDEO}
+                               <Video class="h-3 w-3" />
+
+                               Vision (Video)
                        {:else}
                                <Mic class="h-3 w-3" />
 
index 3eeace42f16fe71f934dac79ee162f78c94aa672..df49dd4673f5ecf18b2751a97992ff9438981b2a 100644 (file)
@@ -1,10 +1,12 @@
 <script lang="ts">
-       import { X } from '@lucide/svelte';
+       import { X, Music, Video } from '@lucide/svelte';
        import {
                formatFileSize,
                getFileTypeLabel,
                getPreviewText,
                isPdfFile,
+               isAudioFile,
+               isVideoFile,
                isTextFile
        } from '$lib/utils';
        import { ActionIcon } from '$lib/components/app';
@@ -38,6 +40,8 @@
        }: Props = $props();
 
        let isPdf = $derived(isPdfFile(attachment, uploadedFile));
+       let isAudio = $derived(isAudioFile(attachment, uploadedFile));
+       let isVideo = $derived(isVideoFile(attachment, uploadedFile));
        let isPdfWithContent = $derived(isPdf && !!textContent);
 
        let isText = $derived(isTextFile(attachment, uploadedFile));
        <div
                class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
        >
-               {fileTypeLabel}
+               {#if isAudio}
+                       <Music class="h-4 w-4 text-white/70" />
+               {:else if isVideo}
+                       <Video class="h-4 w-4 text-white/70" />
+               {:else}
+                       {fileTypeLabel}
+               {/if}
        </div>
 {/snippet}
 
index ca81e54432f351e006135715df17f340d110f617..cba323f2c370b1e593d570682479e4d8e12b822d 100644 (file)
@@ -12,6 +12,7 @@
                getAttachmentDisplayItems,
                getLanguageFromFilename,
                isAudioFile,
+               isVideoFile,
                isImageFile,
                isMcpPrompt,
                isMcpResource,
@@ -29,6 +30,7 @@
                textContent?: string;
                isImage: boolean;
                isAudio: boolean;
+               isVideo: boolean;
        }
 
        interface Props {
@@ -54,7 +56,8 @@
                                (item): PreviewItem => ({
                                        ...item,
                                        isImage: isImageFile(item.attachment, item.uploadedFile),
-                                       isAudio: isAudioFile(item.attachment, item.uploadedFile)
+                                       isAudio: isAudioFile(item.attachment, item.uploadedFile),
+                                       isVideo: isVideoFile(item.attachment, item.uploadedFile)
                                })
                        )
        );
        let isAudio = $derived(
                currentItem ? isAudioFile(currentItem.attachment, currentItem.uploadedFile) : false
        );
+       let isVideo = $derived(
+               currentItem ? isVideoFile(currentItem.attachment, currentItem.uploadedFile) : false
+       );
        let isImage = $derived(
                currentItem ? isImageFile(currentItem.attachment, currentItem.uploadedFile) : false
        );
                        : null
        );
 
+       let videoSrc = $derived(
+               isVideo && currentItem
+                       ? (currentItem.uploadedFile?.preview ??
+                                       (currentItem.attachment &&
+                                       'mimeType' in currentItem.attachment &&
+                                       'base64Data' in currentItem.attachment
+                                               ? createBase64DataUrl(
+                                                               currentItem.attachment.mimeType,
+                                                               currentItem.attachment.base64Data
+                                                       )
+                                               : null))
+                       : null
+       );
+
        export function prev() {
                currentIndex = currentIndex > 0 ? currentIndex - 1 : allItems.length - 1;
        }
                                        {currentItem}
                                        {isImage}
                                        {isAudio}
+                                       {isVideo}
                                        {isPdf}
                                        {isText}
                                        {displayPreview}
                                        {displayTextContent}
                                        {audioSrc}
+                                       {videoSrc}
                                        {language}
                                        {hasVisionModality}
                                        {activeModelId}
index 3451c89d3af8e01f689f153b05151d9914fc80e1..30e84812aaa2b97c583c0aec890fdce003a5ffd0 100644 (file)
@@ -1,9 +1,10 @@
 <script lang="ts">
        import type { ChatAttachmentDisplayItem } from '$lib/types';
-       import { Image, Music, FileText, FileIcon } from '@lucide/svelte';
+       import { Image, Music, Video, FileText, FileIcon } from '@lucide/svelte';
        import ChatAttachmentsPreviewCurrentItemPdf from './ChatAttachmentsPreviewCurrentItemPdf.svelte';
        import ChatAttachmentsPreviewCurrentItemImage from './ChatAttachmentsPreviewCurrentItemImage.svelte';
        import ChatAttachmentsPreviewCurrentItemAudio from './ChatAttachmentsPreviewCurrentItemAudio.svelte';
+       import ChatAttachmentsPreviewCurrentItemVideo from './ChatAttachmentsPreviewCurrentItemVideo.svelte';
        import ChatAttachmentsPreviewCurrentItemText from './ChatAttachmentsPreviewCurrentItemText.svelte';
        import ChatAttachmentsPreviewCurrentItemUnavailable from './ChatAttachmentsPreviewCurrentItemUnavailable.svelte';
 
                currentItem: ChatAttachmentDisplayItem | null;
                isImage: boolean;
                isAudio: boolean;
+               isVideo: boolean;
                isPdf: boolean;
                isText: boolean;
                displayPreview: string | undefined;
                displayTextContent: string | undefined;
                audioSrc: string | null;
+               videoSrc: string | null;
                language: string;
                hasVisionModality: boolean;
                activeModelId?: string;
                currentItem,
                isImage,
                isAudio,
+               isVideo,
                isPdf,
                isText,
                displayPreview,
                displayTextContent,
                audioSrc,
+               videoSrc,
                language,
                hasVisionModality,
                activeModelId
        }: Props = $props();
 
        let IconComponent = $derived(
-               isImage ? Image : isText || isPdf ? FileText : isAudio ? Music : FileIcon
+               isImage ? Image : isText || isPdf ? FileText : isAudio ? Music : isVideo ? Video : FileIcon
        );
 
-       let isUnavailable = $derived(!isPdf && !isImage && !(isText && displayTextContent) && !isAudio);
+       let isUnavailable = $derived(
+               !isPdf && !isImage && !(isText && displayTextContent) && !isAudio && !isVideo
+       );
 </script>
 
 {#if currentItem}
@@ -58,6 +65,8 @@
                        <ChatAttachmentsPreviewCurrentItemText {displayTextContent} {language} />
                {:else if isAudio}
                        <ChatAttachmentsPreviewCurrentItemAudio {currentItem} {audioSrc} />
+               {:else if isVideo}
+                       <ChatAttachmentsPreviewCurrentItemVideo {currentItem} {videoSrc} />
                {:else if isUnavailable}
                        <ChatAttachmentsPreviewCurrentItemUnavailable {IconComponent} />
                {/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemVideo.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemVideo.svelte
new file mode 100644 (file)
index 0000000..4ebbd59
--- /dev/null
@@ -0,0 +1,26 @@
+<script lang="ts">
+       import { Video } from '@lucide/svelte';
+
+       interface Props {
+               currentItem: { name?: string } | null;
+               videoSrc: string | null;
+       }
+
+       let { currentItem, videoSrc }: Props = $props();
+</script>
+
+<div class="flex flex-1 items-center justify-center p-8">
+       <div class="w-full max-w-md text-center">
+               <Video class="mx-auto mb-4 h-16 w-16 text-white/50" />
+
+               {#if videoSrc}
+                       <video controls class="mb-4 w-full" src={videoSrc}>
+                               Your browser does not support the video element.
+                       </video>
+               {:else}
+                       <p class="mb-4 text-white/70">Video preview not available</p>
+               {/if}
+
+               <p class="text-sm text-white/50">{currentItem?.name || 'Video'}</p>
+       </div>
+</div>
index 4c3bd780764885299e7de1c675ab26a52234e203..3f19ee39c108bd91074fbc9c76e5489281f2873e 100644 (file)
@@ -1,5 +1,5 @@
 <script lang="ts">
-       import { Music, FileText } from '@lucide/svelte';
+       import { Music, Video, FileText } from '@lucide/svelte';
        import { HorizontalScrollCarousel } from '$lib/components/app/misc';
 
        interface PreviewItem {
@@ -7,6 +7,7 @@
                name: string;
                isImage: boolean;
                isAudio: boolean;
+               isVideo: boolean;
                preview?: string;
        }
 
@@ -49,6 +50,8 @@
                                                >
                                                        {#if item.isAudio}
                                                                <Music class="h-4 w-4 text-white/70" />
+                                                       {:else if item.isVideo}
+                                                               <Video class="h-4 w-4 text-white/70" />
                                                        {:else}
                                                                <FileText class="h-4 w-4 text-white/70" />
                                                        {/if}
index a6bb0fc2a80fbb5fb973b614bfc869242191ead6..0a8260a2952b182804fe266cbe5a79aed040bf6b 100644 (file)
@@ -23,6 +23,7 @@
                class?: string;
                disabled?: boolean;
                hasAudioModality?: boolean;
+               hasVideoModality?: boolean;
                hasVisionModality?: boolean;
                hasMcpPromptsSupport?: boolean;
                hasMcpResourcesSupport?: boolean;
@@ -37,6 +38,7 @@
                class: className = '',
                disabled = false,
                hasAudioModality = false,
+               hasVideoModality = false,
                hasVisionModality = false,
                hasMcpPromptsSupport = false,
                hasMcpResourcesSupport = false,
@@ -58,6 +60,7 @@
                () => ({
                        hasVisionModality,
                        hasAudioModality,
+                       hasVideoModality,
                        hasMcpPromptsSupport,
                        hasMcpResourcesSupport
                }),
index 4713ec47758426724aeeb6a8dec00891304cca76..5ca5973668cc4d6909a4c1f4f3172f3da17ce4f5 100644 (file)
@@ -19,6 +19,7 @@
                class?: string;
                disabled?: boolean;
                hasAudioModality?: boolean;
+               hasVideoModality?: boolean;
                hasVisionModality?: boolean;
                hasMcpPromptsSupport?: boolean;
                hasMcpResourcesSupport?: boolean;
@@ -34,6 +35,7 @@
                disabled = false,
                hasAudioModality = false,
                hasVisionModality = false,
+               hasVideoModality = false,
                hasMcpPromptsSupport = false,
                hasMcpResourcesSupport = false,
                onFileUpload,
@@ -49,6 +51,7 @@
                () => ({
                        hasVisionModality,
                        hasAudioModality,
+                       hasVideoModality,
                        hasMcpPromptsSupport,
                        hasMcpResourcesSupport
                }),
index 5d3a20b16b9cbaecaf796279ca14964baccbd1ad..54ddcf9b0a766b824c2d30826a99542ef13ffca4 100644 (file)
@@ -7,6 +7,7 @@
        interface Props {
                disabled?: boolean;
                hasAudioModality?: boolean;
+               hasVideoModality?: boolean;
                hasMcpPromptsSupport?: boolean;
                hasMcpResourcesSupport?: boolean;
                hasVisionModality?: boolean;
@@ -20,6 +21,7 @@
        let {
                disabled = false,
                hasAudioModality = false,
+               hasVideoModality = false,
                hasMcpPromptsSupport = false,
                hasMcpResourcesSupport = false,
                hasVisionModality = false,
@@ -37,6 +39,7 @@
        <ChatFormActionAddSheet
                {disabled}
                {hasAudioModality}
+               {hasVideoModality}
                {hasVisionModality}
                {hasMcpPromptsSupport}
                {hasMcpResourcesSupport}
@@ -52,6 +55,7 @@
        <ChatFormActionAddDropdown
                {disabled}
                {hasAudioModality}
+               {hasVideoModality}
                {hasVisionModality}
                {hasMcpPromptsSupport}
                {hasMcpResourcesSupport}
index bdd84a4813850f569ffcdc41599b7a322c4fdd2e..2f9471e0ddda4d9d732f61288c9611bb180d3849 100644 (file)
@@ -11,6 +11,7 @@
                disabled?: boolean;
                forceForegroundText?: boolean;
                hasAudioModality?: boolean;
+               hasVideoModality?: boolean;
                hasVisionModality?: boolean;
                hasModelSelected?: boolean;
                isSelectedModelInCache?: boolean;
@@ -23,6 +24,7 @@
                disabled = false,
                forceForegroundText = false,
                hasAudioModality = $bindable(false),
+               hasVideoModality = $bindable(false),
                hasVisionModality = $bindable(false),
                hasModelSelected = $bindable(false),
                isSelectedModelInCache = $bindable(true),
                hasAudioModality = activeModelId ? modelsStore.modelSupportsAudio(activeModelId) : false;
        });
 
+       $effect(() => {
+               hasVideoModality = activeModelId ? modelsStore.modelSupportsVideo(activeModelId) : false;
+       });
+
        $effect(() => {
                void modelPropsVersion;
 
index 3945155ff1ea42b201409924b02bfc37d9e7b5cf..a94293dd95f905527420f5e6756348e8f4c1a809 100644 (file)
@@ -66,6 +66,7 @@
        });
 
        let hasAudioModality = $state(false);
+       let hasVideoModality = $state(false);
        let hasVisionModality = $state(false);
        let hasModelSelected = $state(false);
        let isSelectedModelInCache = $state(true);
@@ -94,6 +95,7 @@
                        <ChatFormActionsAdd
                                {disabled}
                                {hasAudioModality}
+                               {hasVideoModality}
                                {hasVisionModality}
                                {hasMcpPromptsSupport}
                                {hasMcpResourcesSupport}
                        {disabled}
                        bind:this={selectorModelRef}
                        bind:hasAudioModality
+                       bind:hasVideoModality
                        bind:hasVisionModality
                        bind:hasModelSelected
                        bind:isSelectedModelInCache
index 1351ed0a7d7e24ff49ba38f99b4f534dacdca4fd..dc3eab134f40be17333c955db5ca4826ac00479a 100644 (file)
                return false;
        });
 
+       let hasVideoModality = $derived.by(() => {
+               if (activeModelId) {
+                       void modelPropsVersion;
+
+                       return modelsStore.modelSupportsVideo(activeModelId);
+               }
+
+               return false;
+       });
+
        let hasVisionModality = $derived.by(() => {
                if (activeModelId) {
                        void modelPropsVersion;
                }
 
                // Use model-specific capabilities for file validation
-               const capabilities = { hasVision: hasVisionModality, hasAudio: hasAudioModality };
+               const capabilities = {
+                       hasVision: hasVisionModality,
+                       hasAudio: hasAudioModality,
+                       hasVideo: hasVideoModality
+               };
                const { supportedFiles, unsupportedFiles, modalityReasons } = filterFilesByModalities(
                        generallySupported,
                        capabilities
 
                        if (hasVisionModality) supportedTypes.push('images');
                        if (hasAudioModality) supportedTypes.push('audio files');
+                       if (hasVideoModality) supportedTypes.push('video files');
 
                        fileErrorData = {
                                generallyUnsupported,
index dea4d1a39dc516cd65dbb76acce5ccbf89728d29..966f46f41d08f67216b98ae8fa3a8f58113a1461 100644 (file)
@@ -52,6 +52,15 @@ export const ATTACHMENT_FILE_ITEMS: AttachmentMenuItem[] = [
                disabledTooltip: 'Audio files processing requires an audio model',
                action: AttachmentAction.FILE_UPLOAD
        },
+       {
+               id: AttachmentMenuItemId.VIDEO,
+               label: 'Video Files',
+               icon: FILE_TYPE_ICONS.video,
+               class: 'video-button',
+               enabledWhen: AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY,
+               disabledTooltip: 'Video files processing requires a video model',
+               action: AttachmentAction.FILE_UPLOAD
+       },
        {
                id: AttachmentMenuItemId.TEXT,
                label: 'Text Files',
index 1e88ab5b3a1d45ba85bd3507d7ec3b5a87e2d457..5a6bfa981194104450772f529921ca3029854d44 100644 (file)
@@ -8,13 +8,15 @@ import {
        FileText as FileTextIcon,
        Image as ImageIcon,
        Eye as VisionIcon,
-       Mic as AudioIcon
+       Mic as AudioIcon,
+       Video as VideoIcon
 } from '@lucide/svelte';
 import { FileTypeCategory, ModelModality } from '$lib/enums';
 
 export const FILE_TYPE_ICONS = {
        [FileTypeCategory.IMAGE]: ImageIcon,
        [FileTypeCategory.AUDIO]: AudioIcon,
+       [FileTypeCategory.VIDEO]: VideoIcon,
        [FileTypeCategory.TEXT]: FileTextIcon,
        [FileTypeCategory.PDF]: FileIcon
 } as const;
@@ -23,10 +25,12 @@ export const DEFAULT_FILE_ICON = FileIcon;
 
 export const MODALITY_ICONS = {
        [ModelModality.VISION]: VisionIcon,
-       [ModelModality.AUDIO]: AudioIcon
+       [ModelModality.AUDIO]: AudioIcon,
+       [ModelModality.VIDEO]: VideoIcon
 } as const;
 
 export const MODALITY_LABELS = {
        [ModelModality.VISION]: 'Vision',
-       [ModelModality.AUDIO]: 'Audio'
+       [ModelModality.AUDIO]: 'Audio',
+       [ModelModality.VIDEO]: 'Video'
 } as const;
index 7fc321e54ca5e67148090ad2548e0d5bfd0a55ee..345054389169465996b2741b037be77d61e8e237 100644 (file)
@@ -13,10 +13,12 @@ import {
        FileTypePdf,
        FileTypeText,
        MimeTypeAudio,
+       MimeTypeVideo,
        MimeTypeImage,
        MimeTypeApplication,
        MimeTypeText
 } from '$lib/enums';
+import { FileExtensionVideo, FileTypeVideo } from '$lib/enums/files';
 
 // File type configuration using enums
 export const AUDIO_FILE_TYPES = {
@@ -30,6 +32,17 @@ export const AUDIO_FILE_TYPES = {
        }
 } as const;
 
+export const VIDEO_FILE_TYPES = {
+       [FileTypeVideo.MP4]: {
+               extensions: [FileExtensionVideo.MP4],
+               mimeTypes: [MimeTypeVideo.MP4]
+       },
+       [FileTypeVideo.OGG]: {
+               extensions: [FileExtensionVideo.OGG],
+               mimeTypes: [MimeTypeVideo.OGG]
+       }
+} as const;
+
 export const IMAGE_FILE_TYPES = {
        [FileTypeImage.JPEG]: {
                extensions: [FileExtensionImage.JPG, FileExtensionImage.JPEG],
index 49baf6bae3f6d7256886f47d7a5ecbaf57009925..1c3258cb19da8c918e993256e9aa42c3b0028aa0 100644 (file)
@@ -4,6 +4,7 @@
 export enum AttachmentType {
        AUDIO = 'AUDIO',
        IMAGE = 'IMAGE',
+       VIDEO = 'VIDEO',
        MCP_PROMPT = 'MCP_PROMPT',
        MCP_RESOURCE = 'MCP_RESOURCE',
        PDF = 'PDF',
@@ -18,6 +19,7 @@ export enum AttachmentType {
 export enum AttachmentMenuItemId {
        IMAGES = 'images',
        AUDIO = 'audio',
+       VIDEO = 'video',
        TEXT = 'text',
        PDF = 'pdf',
        SYSTEM_MESSAGE = 'system-message',
@@ -31,7 +33,8 @@ export enum AttachmentMenuItemId {
 export enum AttachmentItemEnabledWhen {
        ALWAYS = 'always',
        HAS_VISION_MODALITY = 'hasVisionModality',
-       HAS_AUDIO_MODALITY = 'hasAudioModality'
+       HAS_AUDIO_MODALITY = 'hasAudioModality',
+       HAS_VIDEO_MODALITY = 'hasVideoModality'
 }
 
 /**
index ff67436ab90dc7c8d6e2f3243beda658aaef89f1..798fb1bf78ef2b0982e66cf11d1efebe4c827bd4 100644 (file)
@@ -39,7 +39,8 @@ export enum MessageType {
 export enum ContentPartType {
        TEXT = 'text',
        IMAGE_URL = 'image_url',
-       INPUT_AUDIO = 'input_audio'
+       INPUT_AUDIO = 'input_audio',
+       INPUT_VIDEO = 'input_video'
 }
 
 /**
index cf081485ced2a79cca602a5b5079caab0669a8b5..5aef3955ebeabb6e9bba35b15f358c7fa4f89281 100644 (file)
@@ -7,6 +7,7 @@
 export enum FileTypeCategory {
        IMAGE = 'image',
        AUDIO = 'audio',
+       VIDEO = 'video',
        PDF = 'pdf',
        TEXT = 'text'
 }
@@ -33,6 +34,11 @@ export enum FileTypeAudio {
        WEBM = 'webm'
 }
 
+export enum FileTypeVideo {
+       MP4 = 'mp4',
+       OGG = 'ogg'
+}
+
 export enum FileTypePdf {
        PDF = 'pdf'
 }
@@ -92,6 +98,11 @@ export enum FileExtensionAudio {
        WAV = '.wav'
 }
 
+export enum FileExtensionVideo {
+       MP4 = '.mp4',
+       OGG = '.ogg'
+}
+
 export enum FileExtensionPdf {
        PDF = '.pdf'
 }
@@ -176,6 +187,11 @@ export enum MimeTypeAudio {
        WEBM_OPUS = 'audio/webm;codecs=opus'
 }
 
+export enum MimeTypeVideo {
+       MP4 = 'video/mp4',
+       OGG = 'video/ogg'
+}
+
 export enum MimeTypeImage {
        JPEG = 'image/jpeg',
        JPG = 'image/jpg',
index 56e1d9f4ab978f10acb655ccfa80ef6c456b3ddd..3cf81286bc17917419d4ccd9ebd9057df9f69219 100644 (file)
@@ -34,6 +34,7 @@ export {
        UriPattern,
        MimeTypeApplication,
        MimeTypeAudio,
+       MimeTypeVideo,
        MimeTypeImage,
        MimeTypeText,
        SpecialFileType
index 7729ecfeabb03c29645e14840361b42ac3a3735f..7aa469947e2b0d405619f374bdbca70f1bdca2da 100644 (file)
@@ -1,5 +1,6 @@
 export enum ModelModality {
        TEXT = 'TEXT',
        AUDIO = 'AUDIO',
-       VISION = 'VISION'
+       VISION = 'VISION',
+       VIDEO = 'VIDEO'
 }
index ddb9994855776700bf61e5890e2e30d0ac8da317..0940ce7b6b1880c23ae8eda09f460b637365eb0e 100644 (file)
@@ -4,6 +4,7 @@ import { AttachmentAction } from '$lib/enums';
 export interface AttachmentModalityFlags {
        hasVisionModality: boolean;
        hasAudioModality: boolean;
+       hasVideoModality: boolean;
        hasMcpPromptsSupport: boolean;
        hasMcpResourcesSupport: boolean;
 }
index 08649abd2a30fa55c4b72dcfc6f82b1ebf3418aa..3e405e9e68915a7db3cdbaf7845588fe22ec1c4d 100644 (file)
@@ -884,6 +884,25 @@ export class ChatService {
                        });
                }
 
+               const videoFiles = message.extra.filter(
+                       (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraVideoFile =>
+                               extra.type === AttachmentType.VIDEO
+               );
+
+               for (const video of videoFiles) {
+                       contentParts.push({
+                               type: ContentPartType.INPUT_VIDEO,
+                               input_video: {
+                                       data: video.base64Data,
+                                       format: video.mimeType.includes('mp4')
+                                               ? 'mp4'
+                                               : video.mimeType.includes('ogg')
+                                                       ? 'ogg'
+                                                       : 'auto'
+                               }
+                       });
+               }
+
                const pdfFiles = message.extra.filter(
                        (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraPdfFile =>
                                extra.type === AttachmentType.PDF
index 63943ff7a1c0d3ab6bea0d7df80a9b129e4fc15f..45981b38fa07de97c91bc60dc07b659b7cdb2223 100644 (file)
@@ -148,7 +148,8 @@ class ModelsStore {
                if (props?.modalities) {
                        return {
                                vision: props.modalities.vision ?? false,
-                               audio: props.modalities.audio ?? false
+                               audio: props.modalities.audio ?? false,
+                               video: props.modalities.video ?? false
                        };
                }
 
@@ -169,6 +170,13 @@ class ModelsStore {
                return this.getModelModalities(modelId)?.audio ?? false;
        }
 
+       /**
+        * Check if a model supports video modality
+        */
+       modelSupportsVideo(modelId: string): boolean {
+               return this.getModelModalities(modelId)?.video ?? false;
+       }
+
        /**
         * Get model modalities as an array of ModelModality enum values
         */
@@ -180,6 +188,7 @@ class ModelsStore {
 
                if (modalities.vision) result.push(ModelModality.VISION);
                if (modalities.audio) result.push(ModelModality.AUDIO);
+               if (modalities.video) result.push(ModelModality.VIDEO);
 
                return result;
        }
@@ -316,7 +325,8 @@ class ModelsStore {
                        if (serverStore.isModelMode && this.models.length > 0 && serverProps?.modalities) {
                                const modalities: ModelModalities = {
                                        vision: serverProps.modalities.vision ?? false,
-                                       audio: serverProps.modalities.audio ?? false
+                                       audio: serverProps.modalities.audio ?? false,
+                                       video: serverProps.modalities.video ?? false
                                };
                                this.modelPropsCache.set(this.models[0].model, serverProps);
                                this.models = this.models.map((model, index) =>
@@ -410,7 +420,8 @@ class ModelsStore {
 
                                const modalities: ModelModalities = {
                                        vision: props.modalities.vision ?? false,
-                                       audio: props.modalities.audio ?? false
+                                       audio: props.modalities.audio ?? false,
+                                       video: props.modalities.video ?? false
                                };
 
                                return { ...model, modalities };
@@ -529,7 +540,8 @@ class ModelsStore {
 
                        const modalities: ModelModalities = {
                                vision: props.modalities.vision ?? false,
-                               audio: props.modalities.audio ?? false
+                               audio: props.modalities.audio ?? false,
+                               video: props.modalities.video ?? false
                        };
 
                        this.models = this.models.map((model) =>
index 808c44203cbc86e252619f4e9b49e9f236195591..316ad552842c2f0ed075285c0ca619ae08aa75c7 100644 (file)
@@ -22,6 +22,10 @@ export interface ApiChatMessageContentPart {
                data: string;
                format: 'wav' | 'mp3';
        };
+       input_video?: {
+               data: string;
+               format: 'mp4' | 'ogg' | 'auto';
+       };
 }
 
 export interface ApiContextSizeError {
@@ -190,6 +194,7 @@ export interface ApiLlamaCppServerProps {
        modalities: {
                vision: boolean;
                audio: boolean;
+               video: boolean;
        };
        chat_template: string;
        bos_token: string;
index 453d0cd74688b893e5fbc1dab1344677aab1dd7e..6de33aaaf8464598b04c28338254150200047f01 100644 (file)
@@ -64,4 +64,9 @@ export interface ParsedClipboardContent {
        mcpPromptAttachments: ClipboardMcpPromptAttachment[];
 }
 
-export type MimeTypeUnion = MimeTypeAudio | MimeTypeImage | MimeTypeApplication | MimeTypeText;
+export type MimeTypeUnion =
+       | MimeTypeAudio
+       | MimeTypeVideo
+       | MimeTypeImage
+       | MimeTypeApplication
+       | MimeTypeText;
index 044bd27aca4f3038df7da7f71f20e86fe673714f..02bdb3ee60d49b663f9c5a89a6d9495e133e2a4b 100644 (file)
@@ -23,6 +23,14 @@ export interface DatabaseMessageExtraAudioFile {
        mimeType: string;
 }
 
+export interface DatabaseMessageExtraVideoFile {
+       type: AttachmentType.VIDEO;
+       name: string;
+       size?: number;
+       base64Data: string;
+       mimeType: string;
+}
+
 export interface DatabaseMessageExtraImageFile {
        type: AttachmentType.IMAGE;
        name: string;
@@ -82,6 +90,7 @@ export type DatabaseMessageExtra =
        | DatabaseMessageExtraImageFile
        | DatabaseMessageExtraTextFile
        | DatabaseMessageExtraAudioFile
+       | DatabaseMessageExtraVideoFile
        | DatabaseMessageExtraPdfFile
        | DatabaseMessageExtraMcpPrompt
        | DatabaseMessageExtraMcpResource
index 03cb9c5a562cf9b640d49ec037c26c88ddea621d..d704a4b3c8abf10ec7026e87a1a5c2abf4580da8 100644 (file)
@@ -55,6 +55,7 @@ export type {
        McpServerOverride,
        DatabaseConversation,
        DatabaseMessageExtraAudioFile,
+       DatabaseMessageExtraVideoFile,
        DatabaseMessageExtraImageFile,
        DatabaseMessageExtraLegacyContext,
        DatabaseMessageExtraMcpPrompt,
index b4d5f11f576b2099416e6ce06ff320bb038f4f39..51069599d7fc0557259f44cf62ea410deb8fa825 100644 (file)
@@ -3,6 +3,7 @@ import type { ApiModelDataEntry, ApiModelDetails } from '$lib/types/api';
 export interface ModelModalities {
        vision: boolean;
        audio: boolean;
+       video: boolean;
 }
 
 export interface ModelOption {
@@ -35,4 +36,5 @@ export interface ParsedModelId {
 export interface ModalityCapabilities {
        hasVision: boolean;
        hasAudio: boolean;
+       hasVideo: boolean;
 }
index 9e9f09601221be1b6f6aafd1bebb3d28d9513ae5..d595afa6b7f489fe3ad2a022983f626184ce6356 100644 (file)
@@ -103,3 +103,24 @@ export function isAudioFile(
 
        return false;
 }
+
+/**
+ * Determines if an attachment or uploaded file is a video file
+ * @param uploadedFile - Optional uploaded file
+ * @param attachment - Optional database attachment
+ * @returns true if the file is a video file
+ */
+export function isVideoFile(
+       attachment?: DatabaseMessageExtra,
+       uploadedFile?: ChatUploadedFile
+): boolean {
+       if (uploadedFile) {
+               return getUploadedFileCategory(uploadedFile) === FileTypeCategory.VIDEO;
+       }
+
+       if (attachment) {
+               return attachment.type === AttachmentType.VIDEO;
+       }
+
+       return false;
+}
index 12be45485d3f1775e561a0dd693c3e5866ecd2ca..af404445f7889c85184329af9314f617a4408619 100644 (file)
@@ -89,6 +89,21 @@ export async function parseFilesToMessageExtras(
                        } catch (error) {
                                console.error(`Failed to process audio file ${file.name}:`, error);
                        }
+               } else if (getFileTypeCategory(file.type) === FileTypeCategory.VIDEO) {
+                       // Process video files (MP4, etc)
+                       try {
+                               const base64Data = await readFileAsBase64(file.file);
+
+                               extras.push({
+                                       type: AttachmentType.VIDEO,
+                                       name: file.name,
+                                       size: file.size,
+                                       base64Data: base64Data,
+                                       mimeType: file.type
+                               });
+                       } catch (error) {
+                               console.error(`Failed to process video file ${file.name}:`, error);
+                       }
                } else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) {
                        try {
                                // Always get base64 data for preview functionality
index 4c670600ce5cb76796c1922c3858d5e66213e528..ae814e8059a8883671073631f7d558b5b4e0d3ee 100644 (file)
@@ -1,5 +1,6 @@
 import {
        AUDIO_FILE_TYPES,
+       VIDEO_FILE_TYPES,
        IMAGE_FILE_TYPES,
        PDF_FILE_TYPES,
        TEXT_FILE_TYPES
@@ -12,6 +13,7 @@ import {
        FileTypeCategory,
        MimeTypeApplication,
        MimeTypeAudio,
+       MimeTypeVideo,
        MimeTypeImage,
        MimeTypeText
 } from '$lib/enums';
@@ -35,6 +37,11 @@ export function getFileTypeCategory(mimeType: string): FileTypeCategory | null {
                case MimeTypeAudio.WEBM_OPUS:
                        return FileTypeCategory.AUDIO;
 
+               // Video
+               case MimeTypeVideo.MP4:
+               case MimeTypeVideo.OGG:
+                       return FileTypeCategory.VIDEO;
+
                // PDF
                case MimeTypeApplication.PDF:
                        return FileTypeCategory.PDF;
@@ -179,6 +186,12 @@ export function getFileTypeByExtension(filename: string): string | null {
                }
        }
 
+       for (const [key, type] of Object.entries(VIDEO_FILE_TYPES)) {
+               if ((type.extensions as readonly string[]).includes(extension)) {
+                       return `${FileTypeCategory.VIDEO}:${key}`;
+               }
+       }
+
        for (const [key, type] of Object.entries(PDF_FILE_TYPES)) {
                if ((type.extensions as readonly string[]).includes(extension)) {
                        return `${FileTypeCategory.PDF}:${key}`;
index 1c7b5476a0e5070e7948d0c2e7f1e5716cf323ee..00aa49c41765740fd348cacdfb84a57e1b360cf0 100644 (file)
@@ -14,7 +14,7 @@ export { validateApiKey } from './api-key-validation';
 
 // Attachment utilities
 export { getAttachmentDisplayItems, isMcpPrompt, isMcpResource } from './attachment-display';
-export { isTextFile, isImageFile, isPdfFile, isAudioFile } from './attachment-type';
+export { isTextFile, isImageFile, isPdfFile, isAudioFile, isVideoFile } from './attachment-type';
 
 // Textarea utilities
 export { default as autoResizeTextarea } from './autoresize-textarea';
index 9b52e93dbe1aa7f219eac784f278f2ab2f0d269b..bf78a70088cf59a291b87355c7c3cc41ed7a274a 100644 (file)
@@ -45,6 +45,10 @@ export function isFileTypeSupportedByModel(
                        // Audio files require audio support
                        return capabilities.hasAudio;
 
+               case FileTypeCategory.VIDEO:
+                       // Video files require video support
+                       return capabilities.hasVideo;
+
                default:
                        // Unknown categories - be conservative and allow
                        return true;
@@ -69,7 +73,7 @@ export function filterFilesByModalities(
        const unsupportedFiles: File[] = [];
        const modalityReasons: Record<string, string> = {};
 
-       const { hasVision, hasAudio } = capabilities;
+       const { hasVision, hasAudio, hasVideo } = capabilities;
 
        for (const file of files) {
                const category = getFileTypeCategory(file.type);
@@ -91,6 +95,13 @@ export function filterFilesByModalities(
                                }
                                break;
 
+                       case FileTypeCategory.VIDEO:
+                               if (!hasVideo) {
+                                       isSupported = false;
+                                       reason = 'Video files require a video-capable model';
+                               }
+                               break;
+
                        case FileTypeCategory.TEXT:
                        case FileTypeCategory.PDF:
                                // Always supported
@@ -127,7 +138,7 @@ export function generateModalityErrorMessage(
 ): string {
        if (unsupportedFiles.length === 0) return '';
 
-       const { hasVision, hasAudio } = capabilities;
+       const { hasVision, hasAudio, hasVideo } = capabilities;
 
        let message = '';
 
@@ -144,6 +155,7 @@ export function generateModalityErrorMessage(
        const supportedTypes: string[] = ['text files', 'PDFs'];
        if (hasVision) supportedTypes.push('images');
        if (hasAudio) supportedTypes.push('audio files');
+       if (hasVideo) supportedTypes.push('video files');
 
        message += ` This model supports: ${supportedTypes.join(', ')}.`;
 
index 1f4068aeed46ddcb23477d466d490b904c423474..d0110f6cbcf126be4f8556731f8d717871e66e1f 100644 (file)
@@ -117,6 +117,10 @@ export async function processFilesToChatUploaded(
                                // Generate preview URL for audio files
                                const preview = await readFileAsDataURL(file);
                                results.push({ ...base, preview });
+                       } else if (getFileTypeCategory(file.type) === FileTypeCategory.VIDEO) {
+                               // Generate preview URL for video files
+                               const preview = await readFileAsDataURL(file);
+                               results.push({ ...base, preview });
                        } else {
                                // Fallback: treat unknown files as text
                                try {
index c40a74655a07727f4118a55b520c92cb90e53f84..ecfcdf09ddd45dff248419492358c10980833e75 100644 (file)
@@ -15,7 +15,8 @@ export function mockServerProps(props: Partial<ApiLlamaCppServerProps>): void {
                model_path: props.model_path || 'test-model',
                modalities: {
                        vision: props.modalities?.vision ?? false,
-                       audio: props.modalities?.audio ?? false
+                       audio: props.modalities?.audio ?? false,
+                       video: props.modalities?.video ?? false
                },
                ...props
        } as ApiLlamaCppServerProps;
@@ -26,11 +27,14 @@ export function mockServerProps(props: Partial<ApiLlamaCppServerProps>): void {
        // Also mock modelsStore methods for modality checking
        const vision = props.modalities?.vision ?? false;
        const audio = props.modalities?.audio ?? false;
+       const video = props.modalities?.video ?? false;
 
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (modelsStore as any).modelSupportsVision = () => vision;
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (modelsStore as any).modelSupportsAudio = () => audio;
+       // eslint-disable-next-line @typescript-eslint/no-explicit-any
+       (modelsStore as any).modelSupportsVideo = () => video;
 
        // Mock models list with a test model so activeModelId can be resolved
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -55,7 +59,8 @@ export function resetServerStore(): void {
                model_path: '',
                modalities: {
                        vision: false,
-                       audio: false
+                       audio: false,
+                       video: false
                }
        } as ApiLlamaCppServerProps;
        (serverStore as unknown as { error: string }).error = '';
@@ -76,6 +81,6 @@ export const mockConfigs = {
                modalities: { vision: true, audio: true }
        },
        noModalities: {
-               modalities: { vision: false, audio: false }
+               modalities: { vision: false, audio: false, video: false }
        }
 } as const;