refactor: Attachments data

This commit is contained in:
Aleksander Grygier 2025-11-23 21:46:43 +01:00
parent 1f0cb3ab26
commit b7ba13b6a0
7 changed files with 204 additions and 196 deletions

View File

@ -1,12 +1,9 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { FileText, Image, Music, FileIcon, Eye } from '@lucide/svelte';
import { FileTypeCategory, MimeTypeApplication } from '$lib/enums/files';
import { ModelModality } from '$lib/enums/model';
import { AttachmentType } from '$lib/enums/attachment';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { convertPDFToImage } from '$lib/utils/pdf-processing';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { isTextFile, isImageFile, isPdfFile, isAudioFile } from '$lib/utils/attachment-type';
interface Props {
// Either an uploaded file or a stored attachment
@ -15,55 +12,27 @@
// For uploaded files
preview?: string;
name?: string;
type?: string;
textContent?: string;
}
let { uploadedFile, attachment, preview, name, type, textContent }: Props = $props();
let { uploadedFile, attachment, preview, name, textContent }: Props = $props();
let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
// Determine file type from uploaded file or attachment
let isAudio = $derived(isAudioFile(attachment, uploadedFile));
let isImage = $derived(isImageFile(attachment, uploadedFile));
let isPdf = $derived(isPdfFile(attachment, uploadedFile));
let isText = $derived(isTextFile(attachment, uploadedFile));
let displayPreview = $derived(
uploadedFile?.preview ||
(attachment?.type === AttachmentType.IMAGE ? attachment.base64Url : preview)
);
let displayType = $derived(
uploadedFile
? uploadedFile.type
: attachment?.type === AttachmentType.IMAGE
? 'image'
: attachment?.type === AttachmentType.TEXT
? 'text'
: attachment?.type === AttachmentType.AUDIO
? attachment.mimeType || ModelModality.AUDIO
: attachment?.type === AttachmentType.PDF
? MimeTypeApplication.PDF
: type || 'unknown'
(isImage && attachment && 'base64Url' in attachment ? attachment.base64Url : preview)
);
let displayTextContent = $derived(
uploadedFile?.textContent ||
(attachment?.type === AttachmentType.TEXT
? attachment.content
: attachment?.type === AttachmentType.PDF
? attachment.content
: textContent)
);
let isAudio = $derived(
getFileTypeCategory(displayType) === FileTypeCategory.AUDIO ||
displayType === ModelModality.AUDIO
);
let isImage = $derived(
getFileTypeCategory(displayType) === FileTypeCategory.IMAGE || displayType === 'image'
);
let isPdf = $derived(displayType === MimeTypeApplication.PDF);
let isText = $derived(
getFileTypeCategory(displayType) === FileTypeCategory.TEXT || displayType === 'text'
(attachment && 'content' in attachment ? attachment.content : textContent)
);
let IconComponent = $derived(() => {
@ -93,15 +62,20 @@
if (uploadedFile?.file) {
file = uploadedFile.file;
} else if (attachment?.type === AttachmentType.PDF) {
} else if (isPdf && attachment) {
// Check if we have pre-processed images
if (attachment.images && Array.isArray(attachment.images) && attachment.images.length > 0) {
if (
'images' in attachment &&
attachment.images &&
Array.isArray(attachment.images) &&
attachment.images.length > 0
) {
pdfImages = attachment.images;
return;
}
// Convert base64 back to File for processing
if (attachment.base64Data) {
if ('base64Data' in attachment && attachment.base64Data) {
const base64Data = attachment.base64Data;
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
@ -109,7 +83,7 @@
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
file = new File([byteArray], displayName, { type: MimeTypeApplication.PDF });
file = new File([byteArray], displayName, { type: 'application/pdf' });
}
}
@ -243,18 +217,18 @@
<div class="w-full max-w-md text-center">
<Music class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
{#if attachment?.type === AttachmentType.AUDIO}
{#if uploadedFile?.preview}
<audio controls class="mb-4 w-full" src={uploadedFile.preview}>
Your browser does not support the audio element.
</audio>
{:else if isAudio && attachment && 'mimeType' in attachment && 'base64Data' in attachment}
<audio
controls
class="mb-4 w-full"
src="data:{attachment.mimeType};base64,{attachment.base64Data}"
src={`data:${attachment.mimeType};base64,${attachment.base64Data}`}
>
Your browser does not support the audio element.
</audio>
{:else if uploadedFile?.preview}
<audio controls class="mb-4 w-full" src={uploadedFile.preview}>
Your browser does not support the audio element.
</audio>
{:else}
<p class="mb-4 text-muted-foreground">Audio preview not available</p>
{/if}

View File

@ -1,7 +1,8 @@
<script lang="ts">
import { RemoveButton } from '$lib/components/app';
import { formatFileSize, getFileTypeLabel, getPreviewText } from '$lib/utils/file-preview';
import { FileTypeCategory, MimeTypeText } from '$lib/enums/files';
import { isTextFile } from '$lib/utils/attachment-type';
import type { DatabaseMessageExtra } from '$lib/types/database';
interface Props {
class?: string;
@ -12,7 +13,9 @@
readonly?: boolean;
size?: number;
textContent?: string;
type: string;
// Either uploaded file or stored attachment
uploadedFile?: ChatUploadedFile;
attachment?: DatabaseMessageExtra;
}
let {
@ -24,11 +27,17 @@
readonly = false,
size,
textContent,
type
uploadedFile,
attachment
}: Props = $props();
let isText = $derived(isTextFile(attachment, uploadedFile));
// Get file type for display
let fileType = $derived(uploadedFile?.type || 'unknown');
</script>
{#if type === MimeTypeText.PLAIN || type === FileTypeCategory.TEXT}
{#if isText}
{#if readonly}
<!-- Readonly mode (ChatMessage) -->
<button
@ -45,7 +54,7 @@
<span class="text-xs text-muted-foreground">{formatFileSize(size)}</span>
{/if}
{#if textContent && type === 'text'}
{#if textContent}
<div class="relative mt-2 w-full">
<div
class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
@ -105,7 +114,7 @@
<div
class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
>
{getFileTypeLabel(type)}
{getFileTypeLabel(fileType)}
</div>
<div class="flex flex-col gap-1">

View File

@ -4,8 +4,7 @@
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { FileTypeCategory } from '$lib/enums/files';
import { ModelModality } from '$lib/enums/model';
import { AttachmentType } from '$lib/enums/attachment';
import { isImageFile } from '$lib/utils/attachment-type';
import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
import type { DatabaseMessageExtra } from '$lib/types/database';
@ -62,7 +61,6 @@
name: file.name,
size: file.size,
preview: file.preview,
type: file.type,
isImage: getFileTypeCategory(file.type) === FileTypeCategory.IMAGE,
uploadedFile: file,
textContent: file.textContent
@ -71,56 +69,17 @@
// Add stored attachments (ChatMessage)
for (const [index, attachment] of attachments.entries()) {
if (attachment.type === AttachmentType.IMAGE) {
items.push({
id: `attachment-${index}`,
name: attachment.name,
preview: attachment.base64Url,
type: 'image',
isImage: true,
attachment,
attachmentIndex: index
});
} else if (attachment.type === AttachmentType.TEXT) {
items.push({
id: `attachment-${index}`,
name: attachment.name,
type: 'text',
isImage: false,
attachment,
attachmentIndex: index,
textContent: attachment.content
});
} else if (attachment.type === AttachmentType.AUDIO) {
items.push({
id: `attachment-${index}`,
name: attachment.name,
type: attachment.mimeType || ModelModality.AUDIO,
isImage: false,
attachment,
attachmentIndex: index
});
} else if (attachment.type === AttachmentType.PDF) {
items.push({
id: `attachment-${index}`,
name: attachment.name,
type: 'application/pdf',
isImage: false,
attachment,
attachmentIndex: index
});
} else if (attachment.type === AttachmentType.LEGACY_CONTEXT) {
// Legacy format from old webui - treat as text file
items.push({
id: `attachment-${index}`,
name: attachment.name,
type: 'text',
isImage: false,
attachment,
attachmentIndex: index,
textContent: attachment.content
});
}
const isImage = isImageFile(attachment);
items.push({
id: `attachment-${index}`,
name: attachment.name,
preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
isImage,
attachment,
attachmentIndex: index,
textContent: 'content' in attachment ? attachment.content : undefined
});
}
return items.reverse();
@ -135,7 +94,6 @@
attachment: item.attachment,
preview: item.preview,
name: item.name,
type: item.type,
size: item.size,
textContent: item.textContent
};
@ -223,11 +181,12 @@
: ''}"
id={item.id}
name={item.name}
type={item.type}
size={item.size}
{readonly}
onRemove={onFileRemove}
textContent={item.textContent}
attachment={item.attachment}
uploadedFile={item.uploadedFile}
onClick={(event) => openPreview(item, event)}
/>
{/if}
@ -279,12 +238,13 @@
class="cursor-pointer"
id={item.id}
name={item.name}
type={item.type}
size={item.size}
{readonly}
onRemove={onFileRemove}
textContent={item.textContent}
onClick={(event) => openPreview(item, event)}
attachment={item.attachment}
uploadedFile={item.uploadedFile}
onClick={(event?: MouseEvent) => openPreview(item, event)}
/>
{/if}
{/each}
@ -300,7 +260,6 @@
attachment={previewItem.attachment}
preview={previewItem.preview}
name={previewItem.name}
type={previewItem.type}
size={previewItem.size}
textContent={previewItem.textContent}
/>

View File

@ -5,9 +5,8 @@
DialogChatAttachmentPreview
} from '$lib/components/app';
import { FileTypeCategory } from '$lib/enums/files';
import { ModelModality } from '$lib/enums/model';
import { AttachmentType } from '$lib/enums/attachment';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { isImageFile } from '$lib/utils/attachment-type';
import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
import type { DatabaseMessageExtra } from '$lib/types/database';
@ -47,7 +46,6 @@
name: file.name,
size: file.size,
preview: file.preview,
type: file.type,
isImage: getFileTypeCategory(file.type) === FileTypeCategory.IMAGE,
uploadedFile: file,
textContent: file.textContent
@ -55,56 +53,17 @@
}
for (const [index, attachment] of attachments.entries()) {
if (attachment.type === AttachmentType.IMAGE) {
items.push({
id: `attachment-${index}`,
name: attachment.name,
preview: attachment.base64Url,
type: 'image',
isImage: true,
attachment,
attachmentIndex: index
});
} else if (attachment.type === AttachmentType.TEXT) {
items.push({
id: `attachment-${index}`,
name: attachment.name,
type: 'text',
isImage: false,
attachment,
attachmentIndex: index,
textContent: attachment.content
});
} else if (attachment.type === AttachmentType.AUDIO) {
items.push({
id: `attachment-${index}`,
name: attachment.name,
type: attachment.mimeType || ModelModality.AUDIO,
isImage: false,
attachment,
attachmentIndex: index
});
} else if (attachment.type === AttachmentType.PDF) {
items.push({
id: `attachment-${index}`,
name: attachment.name,
type: 'application/pdf',
isImage: false,
attachment,
attachmentIndex: index
});
} else if (attachment.type === AttachmentType.LEGACY_CONTEXT) {
// Legacy format from old webui - treat as text file
items.push({
id: `attachment-${index}`,
name: attachment.name,
type: 'text',
isImage: false,
attachment,
attachmentIndex: index,
textContent: attachment.content
});
}
const isImage = isImageFile(attachment);
items.push({
id: `attachment-${index}`,
name: attachment.name,
preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
isImage,
attachment,
attachmentIndex: index,
textContent: 'content' in attachment ? attachment.content : undefined
});
}
return items.reverse();
@ -121,7 +80,6 @@
attachment: item.attachment,
preview: item.preview,
name: item.name,
type: item.type,
size: item.size,
textContent: item.textContent
};
@ -140,12 +98,13 @@
class="cursor-pointer"
id={item.id}
name={item.name}
type={item.type}
size={item.size}
{readonly}
onRemove={onFileRemove}
textContent={item.textContent}
onClick={(event) => openPreview(item, event)}
attachment={item.attachment}
uploadedFile={item.uploadedFile}
onClick={(event?: MouseEvent) => openPreview(item, event)}
/>
{/each}
</div>
@ -185,7 +144,6 @@
attachment={previewItem.attachment}
preview={previewItem.preview}
name={previewItem.name}
type={previewItem.type}
size={previewItem.size}
textContent={previewItem.textContent}
/>

View File

@ -1,10 +1,9 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { ModelModality } from '$lib/enums/model';
import { AttachmentType } from '$lib/enums/attachment';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { ChatAttachmentPreview } from '$lib/components/app';
import { formatFileSize } from '$lib/utils/file-preview';
import { getAttachmentTypeLabel } from '$lib/utils/attachment-type';
interface Props {
open: boolean;
@ -15,7 +14,6 @@
// For uploaded files
preview?: string;
name?: string;
type?: string;
size?: number;
textContent?: string;
}
@ -27,7 +25,6 @@
attachment,
preview,
name,
type,
size,
textContent
}: Props = $props();
@ -36,22 +33,10 @@
let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
let displayType = $derived(
uploadedFile
? uploadedFile.type
: attachment?.type === AttachmentType.IMAGE
? 'image'
: attachment?.type === AttachmentType.TEXT
? 'text'
: attachment?.type === AttachmentType.AUDIO
? attachment.mimeType || ModelModality.AUDIO
: attachment?.type === AttachmentType.PDF
? 'application/pdf'
: type || 'unknown'
);
let displaySize = $derived(uploadedFile?.size || size);
let typeLabel = $derived(getAttachmentTypeLabel(uploadedFile, attachment));
$effect(() => {
if (open && chatAttachmentPreviewRef) {
chatAttachmentPreviewRef.reset();
@ -62,9 +47,9 @@
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden sm:w-auto sm:max-w-6xl">
<Dialog.Header>
<Dialog.Title>{displayName}</Dialog.Title>
<Dialog.Title class="pr-8">{displayName}</Dialog.Title>
<Dialog.Description>
{displayType}
{typeLabel}
{#if displaySize}
{formatFileSize(displaySize)}
{/if}
@ -76,8 +61,7 @@
{uploadedFile}
{attachment}
{preview}
{name}
{type}
name={displayName}
{textContent}
/>
</Dialog.Content>

View File

@ -16,7 +16,6 @@ export interface ChatAttachmentDisplayItem {
name: string;
size?: number;
preview?: string;
type: string;
isImage: boolean;
uploadedFile?: ChatUploadedFile;
attachment?: DatabaseMessageExtra;
@ -29,7 +28,6 @@ export interface ChatAttachmentPreviewItem {
attachment?: DatabaseMessageExtra;
preview?: string;
name?: string;
type?: string;
size?: number;
textContent?: string;
}

View File

@ -0,0 +1,126 @@
import { AttachmentType } from '$lib/enums/attachment';
import { FileTypeCategory } from '$lib/enums/files';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { getFileTypeLabel } from '$lib/utils/file-preview';
import type { DatabaseMessageExtra } from '$lib/types/database';
/**
* Determines if an attachment or uploaded file is a text file
* @param uploadedFile - Optional uploaded file
* @param attachment - Optional database attachment
* @returns true if the file is a text file
*/
export function isTextFile(
attachment?: DatabaseMessageExtra,
uploadedFile?: ChatUploadedFile
): boolean {
if (uploadedFile) {
return getFileTypeCategory(uploadedFile.type) === FileTypeCategory.TEXT;
}
if (attachment) {
return (
attachment.type === AttachmentType.TEXT || attachment.type === AttachmentType.LEGACY_CONTEXT
);
}
return false;
}
/**
* Determines if an attachment or uploaded file is an image
* @param uploadedFile - Optional uploaded file
* @param attachment - Optional database attachment
* @returns true if the file is an image
*/
export function isImageFile(
attachment?: DatabaseMessageExtra,
uploadedFile?: ChatUploadedFile
): boolean {
if (uploadedFile) {
return getFileTypeCategory(uploadedFile.type) === FileTypeCategory.IMAGE;
}
if (attachment) {
return attachment.type === AttachmentType.IMAGE;
}
return false;
}
/**
* Determines if an attachment or uploaded file is a PDF
* @param uploadedFile - Optional uploaded file
* @param attachment - Optional database attachment
* @returns true if the file is a PDF
*/
export function isPdfFile(
attachment?: DatabaseMessageExtra,
uploadedFile?: ChatUploadedFile
): boolean {
if (uploadedFile) {
return uploadedFile.type === 'application/pdf';
}
if (attachment) {
return attachment.type === AttachmentType.PDF;
}
return false;
}
/**
* Determines if an attachment or uploaded file is an audio file
* @param uploadedFile - Optional uploaded file
* @param attachment - Optional database attachment
* @returns true if the file is an audio file
*/
export function isAudioFile(
attachment?: DatabaseMessageExtra,
uploadedFile?: ChatUploadedFile
): boolean {
if (uploadedFile) {
return getFileTypeCategory(uploadedFile.type) === FileTypeCategory.AUDIO;
}
if (attachment) {
return attachment.type === AttachmentType.AUDIO;
}
return false;
}
/**
* Gets a human-readable type label for display
* @param uploadedFile - Optional uploaded file
* @param attachment - Optional database attachment
* @returns A formatted type label string
*/
export function getAttachmentTypeLabel(
attachment?: DatabaseMessageExtra,
uploadedFile?: ChatUploadedFile
): string {
if (uploadedFile) {
// For uploaded files, use the file type label utility
return getFileTypeLabel(uploadedFile.type);
}
if (attachment) {
// For attachments, convert enum to readable format
switch (attachment.type) {
case AttachmentType.IMAGE:
return 'image';
case AttachmentType.AUDIO:
return 'audio';
case AttachmentType.PDF:
return 'pdf';
case AttachmentType.TEXT:
case AttachmentType.LEGACY_CONTEXT:
return 'text';
default:
return 'unknown';
}
}
return 'unknown';
}