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:
parent
a180ba78c7
commit
ec8fd7876b
Binary file not shown.
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,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}"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,6 @@ export {
|
||||||
isFileTypeSupportedByModel,
|
isFileTypeSupportedByModel,
|
||||||
filterFilesByModalities,
|
filterFilesByModalities,
|
||||||
generateModalityErrorMessage,
|
generateModalityErrorMessage,
|
||||||
generateModalityAwareAcceptString,
|
|
||||||
type ModalityCapabilities
|
type ModalityCapabilities
|
||||||
} from './modality-file-validation';
|
} from './modality-file-validation';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(',');
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue