Browse Source

Webui/file upload (#18694)

* webui: fix restrictive file type validation

* webui: simplify file processing logic

* chore: update webui build output

* webui: remove file picker extension whitelist (1/2)

* webui: remove file picker extension whitelist (2/2)

* chore: update webui build output

* refactor: Cleanup

* chore: update webui build output

* fix: update ChatForm storybook test after removing accept attribute

* chore: update webui build output

* refactor: more cleanup

* chore: update webui build output
Pascal 2 weeks ago
parent
commit
ec8fd7876b

BIN
tools/server/public/index.html.gz


+ 5 - 86
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte

@@ -10,21 +10,11 @@
 	import { INPUT_CLASSES } from '$lib/constants/input-classes';
 	import { INPUT_CLASSES } from '$lib/constants/input-classes';
 	import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
 	import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
 	import { config } from '$lib/stores/settings.svelte';
 	import { config } from '$lib/stores/settings.svelte';
-	import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
+	import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
 	import { isRouterMode } from '$lib/stores/server.svelte';
 	import { isRouterMode } from '$lib/stores/server.svelte';
 	import { chatStore } from '$lib/stores/chat.svelte';
 	import { chatStore } from '$lib/stores/chat.svelte';
 	import { activeMessages } from '$lib/stores/conversations.svelte';
 	import { activeMessages } from '$lib/stores/conversations.svelte';
-	import {
-		FileTypeCategory,
-		MimeTypeApplication,
-		FileExtensionAudio,
-		FileExtensionImage,
-		FileExtensionPdf,
-		FileExtensionText,
-		MimeTypeAudio,
-		MimeTypeImage,
-		MimeTypeText
-	} from '$lib/enums';
+	import { MimeTypeText } from '$lib/enums';
 	import { isIMEComposing, parseClipboardContent } from '$lib/utils';
 	import { isIMEComposing, parseClipboardContent } from '$lib/utils';
 	import {
 	import {
 		AudioRecorder,
 		AudioRecorder,
@@ -61,7 +51,6 @@
 	let audioRecorder: AudioRecorder | undefined;
 	let audioRecorder: AudioRecorder | undefined;
 	let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
 	let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
 	let currentConfig = $derived(config());
 	let currentConfig = $derived(config());
-	let fileAcceptString = $state<string | undefined>(undefined);
 	let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
 	let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
 	let isRecording = $state(false);
 	let isRecording = $state(false);
 	let message = $state('');
 	let message = $state('');
@@ -104,40 +93,6 @@
 		return null;
 		return null;
 	});
 	});
 
 
-	// State for model props reactivity
-	let modelPropsVersion = $state(0);
-
-	// Fetch model props when active model changes (works for both MODEL and ROUTER mode)
-	$effect(() => {
-		if (activeModelId) {
-			const cached = modelsStore.getModelProps(activeModelId);
-			if (!cached) {
-				modelsStore.fetchModelProps(activeModelId).then(() => {
-					modelPropsVersion++;
-				});
-			}
-		}
-	});
-
-	// Derive modalities from active model (works for both MODEL and ROUTER mode)
-	let hasAudioModality = $derived.by(() => {
-		if (activeModelId) {
-			void modelPropsVersion; // Trigger reactivity on props fetch
-			return modelsStore.modelSupportsAudio(activeModelId);
-		}
-
-		return false;
-	});
-
-	let hasVisionModality = $derived.by(() => {
-		if (activeModelId) {
-			void modelPropsVersion; // Trigger reactivity on props fetch
-			return modelsStore.modelSupportsVision(activeModelId);
-		}
-
-		return false;
-	});
-
 	function checkModelSelected(): boolean {
 	function checkModelSelected(): boolean {
 		if (!hasModelSelected) {
 		if (!hasModelSelected) {
 			// Open the model selector
 			// Open the model selector
@@ -148,42 +103,12 @@
 		return true;
 		return true;
 	}
 	}
 
 
-	function getAcceptStringForFileType(fileType: FileTypeCategory): string {
-		switch (fileType) {
-			case FileTypeCategory.IMAGE:
-				return [...Object.values(FileExtensionImage), ...Object.values(MimeTypeImage)].join(',');
-
-			case FileTypeCategory.AUDIO:
-				return [...Object.values(FileExtensionAudio), ...Object.values(MimeTypeAudio)].join(',');
-
-			case FileTypeCategory.PDF:
-				return [...Object.values(FileExtensionPdf), ...Object.values(MimeTypeApplication)].join(
-					','
-				);
-
-			case FileTypeCategory.TEXT:
-				return [...Object.values(FileExtensionText), MimeTypeText.PLAIN].join(',');
-
-			default:
-				return '';
-		}
-	}
-
 	function handleFileSelect(files: File[]) {
 	function handleFileSelect(files: File[]) {
 		onFileUpload?.(files);
 		onFileUpload?.(files);
 	}
 	}
 
 
-	function handleFileUpload(fileType?: FileTypeCategory) {
-		if (fileType) {
-			fileAcceptString = getAcceptStringForFileType(fileType);
-		} else {
-			fileAcceptString = undefined;
-		}
-
-		// Use setTimeout to ensure the accept attribute is applied before opening dialog
-		setTimeout(() => {
-			fileInputRef?.click();
-		}, 10);
+	function handleFileUpload() {
+		fileInputRef?.click();
 	}
 	}
 
 
 	async function handleKeydown(event: KeyboardEvent) {
 	async function handleKeydown(event: KeyboardEvent) {
@@ -343,13 +268,7 @@
 	});
 	});
 </script>
 </script>
 
 
-<ChatFormFileInputInvisible
-	bind:this={fileInputRef}
-	bind:accept={fileAcceptString}
-	{hasAudioModality}
-	{hasVisionModality}
-	onFileSelect={handleFileSelect}
-/>
+<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
 
 
 <form
 <form
 	onsubmit={handleSubmit}
 	onsubmit={handleSubmit}

+ 5 - 10
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte

@@ -4,14 +4,13 @@
 	import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
 	import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
 	import * as Tooltip from '$lib/components/ui/tooltip';
 	import * as Tooltip from '$lib/components/ui/tooltip';
 	import { FILE_TYPE_ICONS } from '$lib/constants/icons';
 	import { FILE_TYPE_ICONS } from '$lib/constants/icons';
-	import { FileTypeCategory } from '$lib/enums';
 
 
 	interface Props {
 	interface Props {
 		class?: string;
 		class?: string;
 		disabled?: boolean;
 		disabled?: boolean;
 		hasAudioModality?: boolean;
 		hasAudioModality?: boolean;
 		hasVisionModality?: boolean;
 		hasVisionModality?: boolean;
-		onFileUpload?: (fileType?: FileTypeCategory) => void;
+		onFileUpload?: () => void;
 	}
 	}
 
 
 	let {
 	let {
@@ -27,10 +26,6 @@
 			? 'Text files and PDFs supported. Images, audio, and video require vision models.'
 			? 'Text files and PDFs supported. Images, audio, and video require vision models.'
 			: 'Attach files';
 			: 'Attach files';
 	});
 	});
-
-	function handleFileUpload(fileType?: FileTypeCategory) {
-		onFileUpload?.(fileType);
-	}
 </script>
 </script>
 
 
 <div class="flex items-center gap-1 {className}">
 <div class="flex items-center gap-1 {className}">
@@ -61,7 +56,7 @@
 					<DropdownMenu.Item
 					<DropdownMenu.Item
 						class="images-button flex cursor-pointer items-center gap-2"
 						class="images-button flex cursor-pointer items-center gap-2"
 						disabled={!hasVisionModality}
 						disabled={!hasVisionModality}
-						onclick={() => handleFileUpload(FileTypeCategory.IMAGE)}
+						onclick={() => onFileUpload?.()}
 					>
 					>
 						<FILE_TYPE_ICONS.image class="h-4 w-4" />
 						<FILE_TYPE_ICONS.image class="h-4 w-4" />
 
 
@@ -81,7 +76,7 @@
 					<DropdownMenu.Item
 					<DropdownMenu.Item
 						class="audio-button flex cursor-pointer items-center gap-2"
 						class="audio-button flex cursor-pointer items-center gap-2"
 						disabled={!hasAudioModality}
 						disabled={!hasAudioModality}
-						onclick={() => handleFileUpload(FileTypeCategory.AUDIO)}
+						onclick={() => onFileUpload?.()}
 					>
 					>
 						<FILE_TYPE_ICONS.audio class="h-4 w-4" />
 						<FILE_TYPE_ICONS.audio class="h-4 w-4" />
 
 
@@ -98,7 +93,7 @@
 
 
 			<DropdownMenu.Item
 			<DropdownMenu.Item
 				class="flex cursor-pointer items-center gap-2"
 				class="flex cursor-pointer items-center gap-2"
-				onclick={() => handleFileUpload(FileTypeCategory.TEXT)}
+				onclick={() => onFileUpload?.()}
 			>
 			>
 				<FILE_TYPE_ICONS.text class="h-4 w-4" />
 				<FILE_TYPE_ICONS.text class="h-4 w-4" />
 
 
@@ -109,7 +104,7 @@
 				<Tooltip.Trigger class="w-full">
 				<Tooltip.Trigger class="w-full">
 					<DropdownMenu.Item
 					<DropdownMenu.Item
 						class="flex cursor-pointer items-center gap-2"
 						class="flex cursor-pointer items-center gap-2"
-						onclick={() => handleFileUpload(FileTypeCategory.PDF)}
+						onclick={() => onFileUpload?.()}
 					>
 					>
 						<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
 						<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
 
 

+ 1 - 1
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte

@@ -24,7 +24,7 @@
 		isRecording?: boolean;
 		isRecording?: boolean;
 		hasText?: boolean;
 		hasText?: boolean;
 		uploadedFiles?: ChatUploadedFile[];
 		uploadedFiles?: ChatUploadedFile[];
-		onFileUpload?: (fileType?: FileTypeCategory) => void;
+		onFileUpload?: () => void;
 		onMicClick?: () => void;
 		onMicClick?: () => void;
 		onStop?: () => void;
 		onStop?: () => void;
 	}
 	}

+ 1 - 23
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte

@@ -1,35 +1,14 @@
 <script lang="ts">
 <script lang="ts">
-	import { generateModalityAwareAcceptString } from '$lib/utils';
-
 	interface Props {
 	interface Props {
-		accept?: string;
 		class?: string;
 		class?: string;
-		hasAudioModality?: boolean;
-		hasVisionModality?: boolean;
 		multiple?: boolean;
 		multiple?: boolean;
 		onFileSelect?: (files: File[]) => void;
 		onFileSelect?: (files: File[]) => void;
 	}
 	}
 
 
-	let {
-		accept = $bindable(),
-		class: className = '',
-		hasAudioModality = false,
-		hasVisionModality = false,
-		multiple = true,
-		onFileSelect
-	}: Props = $props();
+	let { class: className = '', multiple = true, onFileSelect }: Props = $props();
 
 
 	let fileInputElement: HTMLInputElement | undefined;
 	let fileInputElement: HTMLInputElement | undefined;
 
 
-	// Use modality-aware accept string by default, but allow override
-	let finalAccept = $derived(
-		accept ??
-			generateModalityAwareAcceptString({
-				hasVision: hasVisionModality,
-				hasAudio: hasAudioModality
-			})
-	);
-
 	export function click() {
 	export function click() {
 		fileInputElement?.click();
 		fileInputElement?.click();
 	}
 	}
@@ -46,7 +25,6 @@
 	bind:this={fileInputElement}
 	bind:this={fileInputElement}
 	type="file"
 	type="file"
 	{multiple}
 	{multiple}
-	accept={finalAccept}
 	onchange={handleFileSelect}
 	onchange={handleFileSelect}
 	class="hidden {className}"
 	class="hidden {className}"
 />
 />

+ 21 - 2
tools/server/webui/src/lib/utils/file-type.ts

@@ -195,9 +195,28 @@ export function getFileTypeByExtension(filename: string): string | null {
 }
 }
 
 
 export function isFileTypeSupported(filename: string, mimeType?: string): boolean {
 export function isFileTypeSupported(filename: string, mimeType?: string): boolean {
-	if (mimeType && getFileTypeCategory(mimeType)) {
+	// Images are detected and handled separately for vision models
+	if (mimeType) {
+		const category = getFileTypeCategory(mimeType);
+		if (
+			category === FileTypeCategory.IMAGE ||
+			category === FileTypeCategory.AUDIO ||
+			category === FileTypeCategory.PDF
+		) {
+			return true;
+		}
+	}
+
+	// Check extension for known types (especially images without MIME)
+	const extCategory = getFileTypeCategoryByExtension(filename);
+	if (
+		extCategory === FileTypeCategory.IMAGE ||
+		extCategory === FileTypeCategory.AUDIO ||
+		extCategory === FileTypeCategory.PDF
+	) {
 		return true;
 		return true;
 	}
 	}
 
 
-	return getFileTypeByExtension(filename) !== null;
+	// Fallback: treat everything else as text (inclusive by default)
+	return true;
 }
 }

+ 0 - 1
tools/server/webui/src/lib/utils/index.ts

@@ -76,7 +76,6 @@ export {
 	isFileTypeSupportedByModel,
 	isFileTypeSupportedByModel,
 	filterFilesByModalities,
 	filterFilesByModalities,
 	generateModalityErrorMessage,
 	generateModalityErrorMessage,
-	generateModalityAwareAcceptString,
 	type ModalityCapabilities
 	type ModalityCapabilities
 } from './modality-file-validation';
 } from './modality-file-validation';
 
 

+ 1 - 37
tools/server/webui/src/lib/utils/modality-file-validation.ts

@@ -4,17 +4,7 @@
  */
  */
 
 
 import { getFileTypeCategory } from '$lib/utils';
 import { getFileTypeCategory } from '$lib/utils';
-import {
-	FileExtensionAudio,
-	FileExtensionImage,
-	FileExtensionPdf,
-	FileExtensionText,
-	MimeTypeAudio,
-	MimeTypeImage,
-	MimeTypeApplication,
-	MimeTypeText,
-	FileTypeCategory
-} from '$lib/enums';
+import { FileTypeCategory } from '$lib/enums';
 
 
 /** Modality capabilities for file validation */
 /** Modality capabilities for file validation */
 export interface ModalityCapabilities {
 export interface ModalityCapabilities {
@@ -170,29 +160,3 @@ export function generateModalityErrorMessage(
  * @param capabilities - The modality capabilities to check against
  * @param capabilities - The modality capabilities to check against
  * @returns Accept string for HTML file input element
  * @returns Accept string for HTML file input element
  */
  */
-export function generateModalityAwareAcceptString(capabilities: ModalityCapabilities): string {
-	const { hasVision, hasAudio } = capabilities;
-
-	const acceptedExtensions: string[] = [];
-	const acceptedMimeTypes: string[] = [];
-
-	// Always include text files and PDFs
-	acceptedExtensions.push(...Object.values(FileExtensionText));
-	acceptedMimeTypes.push(...Object.values(MimeTypeText));
-	acceptedExtensions.push(...Object.values(FileExtensionPdf));
-	acceptedMimeTypes.push(...Object.values(MimeTypeApplication));
-
-	// Include images only if vision is supported
-	if (hasVision) {
-		acceptedExtensions.push(...Object.values(FileExtensionImage));
-		acceptedMimeTypes.push(...Object.values(MimeTypeImage));
-	}
-
-	// Include audio only if audio is supported
-	if (hasAudio) {
-		acceptedExtensions.push(...Object.values(FileExtensionAudio));
-		acceptedMimeTypes.push(...Object.values(MimeTypeAudio));
-	}
-
-	return [...acceptedExtensions, ...acceptedMimeTypes].join(',');
-}

+ 8 - 14
tools/server/webui/src/lib/utils/process-uploaded-files.ts

@@ -1,5 +1,4 @@
 import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
 import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
-import { isTextFileByName } from './text-files';
 import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
 import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
 import { FileTypeCategory } from '$lib/enums';
 import { FileTypeCategory } from '$lib/enums';
 import { modelsStore } from '$lib/stores/models.svelte';
 import { modelsStore } from '$lib/stores/models.svelte';
@@ -84,17 +83,6 @@ export async function processFilesToChatUploaded(
 				}
 				}
 
 
 				results.push({ ...base, preview });
 				results.push({ ...base, preview });
-			} else if (
-				getFileTypeCategory(file.type) === FileTypeCategory.TEXT ||
-				isTextFileByName(file.name)
-			) {
-				try {
-					const textContent = await readFileAsUTF8(file);
-					results.push({ ...base, textContent });
-				} catch (err) {
-					console.warn('Failed to read text file, adding without content:', err);
-					results.push(base);
-				}
 			} else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) {
 			} else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) {
 				// Extract text content from PDF for preview
 				// Extract text content from PDF for preview
 				try {
 				try {
@@ -129,8 +117,14 @@ export async function processFilesToChatUploaded(
 				const preview = await readFileAsDataURL(file);
 				const preview = await readFileAsDataURL(file);
 				results.push({ ...base, preview });
 				results.push({ ...base, preview });
 			} else {
 			} else {
-				// Other files: add as-is
-				results.push(base);
+				// Fallback: treat unknown files as text
+				try {
+					const textContent = await readFileAsUTF8(file);
+					results.push({ ...base, textContent });
+				} catch (err) {
+					console.warn('Failed to read file as text, adding without content:', err);
+					results.push(base);
+				}
 			}
 			}
 		} catch (error) {
 		} catch (error) {
 			console.error('Error processing file', file.name, error);
 			console.error('Error processing file', file.name, error);

+ 1 - 4
tools/server/webui/tests/stories/ChatForm.stories.svelte

@@ -65,10 +65,7 @@
 		await expect(textarea).toHaveValue(text);
 		await expect(textarea).toHaveValue(text);
 
 
 		const fileInput = document.querySelector('input[type="file"]');
 		const fileInput = document.querySelector('input[type="file"]');
-		const acceptAttr = fileInput?.getAttribute('accept');
-		await expect(fileInput).toHaveAttribute('accept');
-		await expect(acceptAttr).not.toContain('image/');
-		await expect(acceptAttr).not.toContain('audio/');
+		await expect(fileInput).not.toHaveAttribute('accept');
 
 
 		// Open file attachments dropdown
 		// Open file attachments dropdown
 		const fileUploadButton = canvas.getByText('Attach files');
 		const fileUploadButton = canvas.getByText('Attach files');