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
This commit is contained in:
Pascal 2026-01-09 16:45:32 +01:00 committed by GitHub
parent a180ba78c7
commit ec8fd7876b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 43 additions and 178 deletions

Binary file not shown.

View File

@ -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 { import { MimeTypeText } from '$lib/enums';
FileTypeCategory,
MimeTypeApplication,
FileExtensionAudio,
FileExtensionImage,
FileExtensionPdf,
FileExtensionText,
MimeTypeAudio,
MimeTypeImage,
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) { function handleFileUpload() {
if (fileType) { fileInputRef?.click();
fileAcceptString = getAcceptStringForFileType(fileType);
} else {
fileAcceptString = undefined;
}
// Use setTimeout to ensure the accept attribute is applied before opening dialog
setTimeout(() => {
fileInputRef?.click();
}, 10);
} }
async function handleKeydown(event: KeyboardEvent) { async function handleKeydown(event: KeyboardEvent) {
@ -343,13 +268,7 @@
}); });
</script> </script>
<ChatFormFileInputInvisible <ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
bind:this={fileInputRef}
bind:accept={fileAcceptString}
{hasAudioModality}
{hasVisionModality}
onFileSelect={handleFileSelect}
/>
<form <form
onsubmit={handleSubmit} onsubmit={handleSubmit}

View File

@ -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" />

View File

@ -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;
} }

View File

@ -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 { let { class: className = '', multiple = true, onFileSelect }: Props = $props();
accept = $bindable(),
class: className = '',
hasAudioModality = false,
hasVisionModality = false,
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}"
/> />

View File

@ -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;
} }

View File

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

View File

@ -4,17 +4,7 @@
*/ */
import { getFileTypeCategory } from '$lib/utils'; import { getFileTypeCategory } from '$lib/utils';
import { import { FileTypeCategory } from '$lib/enums';
FileExtensionAudio,
FileExtensionImage,
FileExtensionPdf,
FileExtensionText,
MimeTypeAudio,
MimeTypeImage,
MimeTypeApplication,
MimeTypeText,
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(',');
}

View File

@ -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 // Fallback: treat unknown files as text
results.push(base); 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);

View File

@ -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).not.toHaveAttribute('accept');
await expect(fileInput).toHaveAttribute('accept');
await expect(acceptAttr).not.toContain('image/');
await expect(acceptAttr).not.toContain('audio/');
// Open file attachments dropdown // Open file attachments dropdown
const fileUploadButton = canvas.getByText('Attach files'); const fileUploadButton = canvas.getByText('Attach files');