feat: Attachment logic & UI improvements

This commit is contained in:
Aleksander Grygier 2025-11-29 01:36:05 +01:00
parent d49d97c642
commit 648d2deebc
20 changed files with 610 additions and 130 deletions

View File

@ -64,7 +64,7 @@
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^1.0.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.3.5",
"typescript": "^5.0.0",
@ -8324,31 +8324,23 @@
}
},
"node_modules/tailwind-variants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-1.0.0.tgz",
"integrity": "sha512-2WSbv4ulEEyuBKomOunut65D8UZwxrHoRfYnxGcQNnHqlSCp2+B7Yz2W+yrNDrxRodOXtGD/1oCcKGNBnUqMqA==",
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz",
"integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tailwind-merge": "3.0.2"
},
"engines": {
"node": ">=16.x",
"pnpm": ">=7.x"
},
"peerDependencies": {
"tailwind-merge": ">=3.0.0",
"tailwindcss": "*"
}
},
"node_modules/tailwind-variants/node_modules/tailwind-merge": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.2.tgz",
"integrity": "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
},
"peerDependenciesMeta": {
"tailwind-merge": {
"optional": true
}
}
},
"node_modules/tailwindcss": {

View File

@ -66,7 +66,7 @@
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^1.0.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.3.5",
"typescript": "^5.0.0",

View File

@ -1,9 +1,13 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { FileText, Image, Music, FileIcon, Eye } from '@lucide/svelte';
import * as Alert from '$lib/components/ui/alert';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { FileText, Image, Music, FileIcon, Eye, Info } from '@lucide/svelte';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { convertPDFToImage } from '$lib/utils/pdf-processing';
import { isTextFile, isImageFile, isPdfFile, isAudioFile } from '$lib/utils/attachment-type';
import { getLanguageFromFilename } from '$lib/utils/syntax-highlight-language';
import { modelsStore } from '$lib/stores/models.svelte';
interface Props {
// Either an uploaded file or a stored attachment
@ -13,9 +17,15 @@
preview?: string;
name?: string;
textContent?: string;
// For checking vision modality
activeModelId?: string;
}
let { uploadedFile, attachment, preview, name, textContent }: Props = $props();
let { uploadedFile, attachment, preview, name, textContent, activeModelId }: Props = $props();
let hasVisionModality = $derived(
activeModelId ? modelsStore.modelSupportsVision(activeModelId) : false
);
let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
@ -35,6 +45,8 @@
(attachment && 'content' in attachment ? attachment.content : textContent)
);
let language = $derived(getLanguageFromFilename(displayName));
let IconComponent = $derived(() => {
if (isImage) return Image;
if (isText || isPdf) return FileText;
@ -161,6 +173,24 @@
/>
</div>
{:else if isPdf && pdfViewMode === 'pages'}
{#if !hasVisionModality && activeModelId}
<Alert.Root class="mb-4">
<Info class="h-4 w-4" />
<Alert.Title>Preview only</Alert.Title>
<Alert.Description>
<span class="inline-flex">
The selected model does not support vision. Only the extracted
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span class="mx-1 cursor-pointer underline" onclick={() => (pdfViewMode = 'text')}>
text
</span>
will be sent to the model.
</span>
</Alert.Description>
</Alert.Root>
{/if}
{#if pdfImagesLoading}
<div class="flex items-center justify-center p-8">
<div class="text-center">
@ -207,11 +237,7 @@
</div>
{/if}
{:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
<div
class="max-h-[60vh] overflow-auto rounded-lg bg-muted p-4 font-mono text-sm break-words whitespace-pre-wrap"
>
{displayTextContent}
</div>
<SyntaxHighlightedCode code={displayTextContent} {language} maxWidth="69rem" />
{:else if isAudio}
<div class="flex items-center justify-center p-8">
<div class="w-full max-w-md text-center">

View File

@ -22,6 +22,8 @@
imageWidth?: string;
// Limit display to single row with "+ X more" button
limitToSingleRow?: boolean;
// For vision modality check
activeModelId?: string;
}
let {
@ -35,7 +37,8 @@
imageClass = '',
imageHeight = 'h-24',
imageWidth = 'w-auto',
limitToSingleRow = false
limitToSingleRow = false,
activeModelId
}: Props = $props();
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
@ -226,6 +229,7 @@
name={previewItem.name}
size={previewItem.size}
textContent={previewItem.textContent}
{activeModelId}
/>
{/if}
@ -237,4 +241,5 @@
{onFileRemove}
imageHeight="h-64"
{imageClass}
{activeModelId}
/>

View File

@ -16,6 +16,7 @@
imageHeight?: string;
imageWidth?: string;
imageClass?: string;
activeModelId?: string;
}
let {
@ -25,7 +26,8 @@
onFileRemove,
imageHeight = 'h-24',
imageWidth = 'w-auto',
imageClass = ''
imageClass = '',
activeModelId
}: Props = $props();
let previewDialogOpen = $state(false);
@ -112,5 +114,6 @@
name={previewItem.name}
size={previewItem.size}
textContent={previewItem.textContent}
{activeModelId}
/>
{/if}

View File

@ -333,6 +333,7 @@
limitToSingleRow
class="py-5"
style="scroll-padding: 1rem;"
activeModelId={activeModelId ?? undefined}
/>
<div

View File

@ -10,6 +10,7 @@
ServerLoadingSplash,
DialogConfirmation
} from '$lib/components/app';
import * as Alert from '$lib/components/ui/alert';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import {
AUTO_SCROLL_AT_BOTTOM_THRESHOLD,
@ -389,23 +390,21 @@
class="pointer-events-auto mx-auto mb-3 max-w-[48rem] px-4"
in:fly={{ y: 10, duration: 250 }}
>
<div class="rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<AlertTriangle class="h-4 w-4 text-destructive" />
<span class="text-sm font-medium text-destructive">Server unavailable</span>
<span class="text-sm text-muted-foreground">{serverError()}</span>
</div>
<Alert.Root variant="destructive">
<AlertTriangle class="h-4 w-4" />
<Alert.Title class="flex items-center justify-between">
<span>Server unavailable</span>
<button
onclick={() => serverStore.fetch()}
disabled={isServerLoading}
class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-3 py-1.5 text-xs font-medium text-destructive hover:bg-destructive/30 disabled:opacity-50"
class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
>
<RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
{isServerLoading ? 'Retrying...' : 'Retry'}
</button>
</div>
</div>
</Alert.Title>
<Alert.Description>{serverError()}</Alert.Description>
</Alert.Root>
</div>
{/if}
@ -449,23 +448,21 @@
{#if hasPropsError}
<div class="mb-4" in:fly={{ y: 10, duration: 250 }}>
<div class="rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<AlertTriangle class="h-4 w-4 text-destructive" />
<span class="text-sm font-medium text-destructive">Server unavailable</span>
<span class="text-sm text-muted-foreground">{serverError()}</span>
</div>
<Alert.Root variant="destructive">
<AlertTriangle class="h-4 w-4" />
<Alert.Title class="flex items-center justify-between">
<span>Server unavailable</span>
<button
onclick={() => serverStore.fetch()}
disabled={isServerLoading}
class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-3 py-1.5 text-xs font-medium text-destructive hover:bg-destructive/30 disabled:opacity-50"
class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
>
<RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
{isServerLoading ? 'Retrying...' : 'Retry'}
</button>
</div>
</div>
</Alert.Title>
<Alert.Description>{serverError()}</Alert.Description>
</Alert.Root>
</div>
{/if}

View File

@ -3,7 +3,6 @@
import type { DatabaseMessageExtra } from '$lib/types/database';
import { ChatAttachmentPreview } from '$lib/components/app';
import { formatFileSize } from '$lib/utils/formatters';
import { getAttachmentTypeLabel } from '$lib/utils/attachment-type';
interface Props {
open: boolean;
@ -16,6 +15,8 @@
name?: string;
size?: number;
textContent?: string;
// For vision modality check
activeModelId?: string;
}
let {
@ -26,7 +27,8 @@
preview,
name,
size,
textContent
textContent,
activeModelId
}: Props = $props();
let chatAttachmentPreviewRef: ChatAttachmentPreview | undefined = $state();
@ -35,8 +37,6 @@
let displaySize = $derived(uploadedFile?.size || size);
let typeLabel = $derived(getAttachmentTypeLabel(uploadedFile, attachment));
$effect(() => {
if (open && chatAttachmentPreviewRef) {
chatAttachmentPreviewRef.reset();
@ -49,9 +49,8 @@
<Dialog.Header>
<Dialog.Title class="pr-8">{displayName}</Dialog.Title>
<Dialog.Description>
{typeLabel}
{#if displaySize}
{formatFileSize(displaySize)}
{formatFileSize(displaySize)}
{/if}
</Dialog.Description>
</Dialog.Header>
@ -63,6 +62,7 @@
{preview}
name={displayName}
{textContent}
{activeModelId}
/>
</Dialog.Content>
</Dialog.Root>

View File

@ -11,6 +11,7 @@
imageHeight?: string;
imageWidth?: string;
imageClass?: string;
activeModelId?: string;
}
let {
@ -21,7 +22,8 @@
onFileRemove,
imageHeight = 'h-24',
imageWidth = 'w-auto',
imageClass = ''
imageClass = '',
activeModelId
}: Props = $props();
let totalCount = $derived(uploadedFiles.length + attachments.length);
@ -45,6 +47,7 @@
{imageHeight}
{imageWidth}
{imageClass}
{activeModelId}
/>
</Dialog.Content>
</Dialog.Portal>

View File

@ -62,6 +62,7 @@ export { default as CopyToClipboardIcon } from './misc/CopyToClipboardIcon.svelt
export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
export { default as RemoveButton } from './misc/RemoveButton.svelte';
export { default as SyntaxHighlightedCode } from './misc/SyntaxHighlightedCode.svelte';
export { default as ModelsSelector } from './models/ModelsSelector.svelte';
// Server

View File

@ -0,0 +1,96 @@
<script lang="ts">
import hljs from 'highlight.js';
import { browser } from '$app/environment';
import { mode } from 'mode-watcher';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline';
interface Props {
code: string;
language?: string;
class?: string;
maxHeight?: string;
maxWidth?: string;
}
let {
code,
language = 'text',
class: className = '',
maxHeight = '60vh',
maxWidth = ''
}: Props = $props();
let highlightedHtml = $state('');
function loadHighlightTheme(isDark: boolean) {
if (!browser) return;
const existingThemes = document.querySelectorAll('style[data-highlight-theme-preview]');
existingThemes.forEach((style) => style.remove());
const style = document.createElement('style');
style.setAttribute('data-highlight-theme-preview', 'true');
style.textContent = isDark ? githubDarkCss : githubLightCss;
document.head.appendChild(style);
}
$effect(() => {
const currentMode = mode.current;
const isDark = currentMode === 'dark';
loadHighlightTheme(isDark);
});
$effect(() => {
if (!code) {
highlightedHtml = '';
return;
}
try {
// Check if the language is supported
const lang = language.toLowerCase();
const isSupported = hljs.getLanguage(lang);
if (isSupported) {
const result = hljs.highlight(code, { language: lang });
highlightedHtml = result.value;
} else {
// Try auto-detection or fallback to plain text
const result = hljs.highlightAuto(code);
highlightedHtml = result.value;
}
} catch {
// Fallback to escaped plain text
highlightedHtml = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
});
</script>
<div
class="code-preview-wrapper overflow-auto rounded-lg border border-border bg-muted {className}"
style="max-height: {maxHeight};"
>
<pre class="m-0 overflow-x-auto p-4 max-w-[{maxWidth}]"><code class="hljs text-sm leading-relaxed"
>{@html highlightedHtml}</code
></pre>
</div>
<style>
.code-preview-wrapper {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
'Liberation Mono', Menlo, monospace;
}
.code-preview-wrapper pre {
background: transparent;
}
.code-preview-wrapper code {
background: transparent;
}
</style>

View File

@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-description"
class={cn(
'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-title"
class={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@ -0,0 +1,44 @@
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants';
export const alertVariants = tv({
base: 'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current'
}
},
defaultVariants: {
variant: 'default'
}
});
export type AlertVariant = VariantProps<typeof alertVariants>['variant'];
</script>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
let {
ref = $bindable(null),
class: className,
variant = 'default',
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
>
{@render children?.()}
</div>

View File

@ -0,0 +1,14 @@
import Root from './alert.svelte';
import Description from './alert-description.svelte';
import Title from './alert-title.svelte';
export { alertVariants, type AlertVariant } from './alert.svelte';
export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle
};

View File

@ -2,13 +2,26 @@ import { FileTypeCategory } from '$lib/enums';
import type { ChatAttachmentDisplayItem } from '$lib/types/chat';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { isImageFile } from '$lib/utils/attachment-type';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { getFileTypeCategory, getFileTypeCategoryByExtension } from '$lib/utils/file-type';
export interface AttachmentDisplayItemsOptions {
uploadedFiles?: ChatUploadedFile[];
attachments?: DatabaseMessageExtra[];
}
/**
* Gets the file type category from an uploaded file, checking both MIME type and extension
*/
function getUploadedFileCategory(file: ChatUploadedFile): FileTypeCategory | null {
const categoryByMime = getFileTypeCategory(file.type);
if (categoryByMime) {
return categoryByMime;
}
return getFileTypeCategoryByExtension(file.name);
}
/**
* Creates a unified list of display items from uploaded files and stored attachments.
* Items are returned in reverse order (newest first).
@ -26,7 +39,7 @@ export function getAttachmentDisplayItems(
name: file.name,
size: file.size,
preview: file.preview,
isImage: getFileTypeCategory(file.type) === FileTypeCategory.IMAGE,
isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE,
uploadedFile: file,
textContent: file.textContent
});

View File

@ -1,8 +1,24 @@
import { AttachmentType, FileTypeCategory } from '$lib/enums';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { getFileTypeLabel } from '$lib/utils/file-preview';
import { getFileTypeCategory, getFileTypeCategoryByExtension } from '$lib/utils/file-type';
import type { DatabaseMessageExtra } from '$lib/types/database';
/**
* Gets the file type category from an uploaded file, checking both MIME type and extension
* @param uploadedFile - The uploaded file to check
* @returns The file type category or null if not recognized
*/
function getUploadedFileCategory(uploadedFile: ChatUploadedFile): FileTypeCategory | null {
// First try MIME type
const categoryByMime = getFileTypeCategory(uploadedFile.type);
if (categoryByMime) {
return categoryByMime;
}
// Fallback to extension (browsers don't always provide correct MIME types)
return getFileTypeCategoryByExtension(uploadedFile.name);
}
/**
* Determines if an attachment or uploaded file is a text file
* @param uploadedFile - Optional uploaded file
@ -14,7 +30,7 @@ export function isTextFile(
uploadedFile?: ChatUploadedFile
): boolean {
if (uploadedFile) {
return getFileTypeCategory(uploadedFile.type) === FileTypeCategory.TEXT;
return getUploadedFileCategory(uploadedFile) === FileTypeCategory.TEXT;
}
if (attachment) {
@ -37,7 +53,7 @@ export function isImageFile(
uploadedFile?: ChatUploadedFile
): boolean {
if (uploadedFile) {
return getFileTypeCategory(uploadedFile.type) === FileTypeCategory.IMAGE;
return getUploadedFileCategory(uploadedFile) === FileTypeCategory.IMAGE;
}
if (attachment) {
@ -58,7 +74,7 @@ export function isPdfFile(
uploadedFile?: ChatUploadedFile
): boolean {
if (uploadedFile) {
return uploadedFile.type === 'application/pdf';
return getUploadedFileCategory(uploadedFile) === FileTypeCategory.PDF;
}
if (attachment) {
@ -79,7 +95,7 @@ export function isAudioFile(
uploadedFile?: ChatUploadedFile
): boolean {
if (uploadedFile) {
return getFileTypeCategory(uploadedFile.type) === FileTypeCategory.AUDIO;
return getUploadedFileCategory(uploadedFile) === FileTypeCategory.AUDIO;
}
if (attachment) {
@ -88,38 +104,3 @@ export function isAudioFile(
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';
}

View File

@ -4,42 +4,151 @@ import {
PDF_FILE_TYPES,
TEXT_FILE_TYPES
} from '$lib/constants/supported-file-types';
import { FileTypeCategory } from '$lib/enums';
import {
FileExtensionAudio,
FileExtensionImage,
FileExtensionPdf,
FileExtensionText,
FileTypeCategory,
MimeTypeApplication,
MimeTypeAudio,
MimeTypeImage,
MimeTypeText
} from '$lib/enums';
export function getFileTypeCategory(mimeType: string): FileTypeCategory | null {
if (
Object.values(IMAGE_FILE_TYPES).some((type) =>
(type.mimeTypes as readonly string[]).includes(mimeType)
)
) {
return FileTypeCategory.IMAGE;
}
switch (mimeType) {
// Images
case MimeTypeImage.JPEG:
case MimeTypeImage.PNG:
case MimeTypeImage.GIF:
case MimeTypeImage.WEBP:
case MimeTypeImage.SVG:
return FileTypeCategory.IMAGE;
if (
Object.values(AUDIO_FILE_TYPES).some((type) =>
(type.mimeTypes as readonly string[]).includes(mimeType)
)
) {
return FileTypeCategory.AUDIO;
}
// Audio
case MimeTypeAudio.MP3_MPEG:
case MimeTypeAudio.MP3:
case MimeTypeAudio.MP4:
case MimeTypeAudio.WAV:
case MimeTypeAudio.WEBM:
case MimeTypeAudio.WEBM_OPUS:
return FileTypeCategory.AUDIO;
if (
Object.values(PDF_FILE_TYPES).some((type) =>
(type.mimeTypes as readonly string[]).includes(mimeType)
)
) {
return FileTypeCategory.PDF;
}
// PDF
case MimeTypeApplication.PDF:
return FileTypeCategory.PDF;
if (
Object.values(TEXT_FILE_TYPES).some((type) =>
(type.mimeTypes as readonly string[]).includes(mimeType)
)
) {
return FileTypeCategory.TEXT;
}
// Text
case MimeTypeText.PLAIN:
case MimeTypeText.MARKDOWN:
case MimeTypeText.ASCIIDOC:
case MimeTypeText.JAVASCRIPT:
case MimeTypeText.JAVASCRIPT_APP:
case MimeTypeText.TYPESCRIPT:
case MimeTypeText.JSX:
case MimeTypeText.TSX:
case MimeTypeText.CSS:
case MimeTypeText.HTML:
case MimeTypeText.JSON:
case MimeTypeText.XML_TEXT:
case MimeTypeText.XML_APP:
case MimeTypeText.YAML_TEXT:
case MimeTypeText.YAML_APP:
case MimeTypeText.CSV:
case MimeTypeText.PYTHON:
case MimeTypeText.JAVA:
case MimeTypeText.CPP_SRC:
case MimeTypeText.C_SRC:
case MimeTypeText.C_HDR:
case MimeTypeText.PHP:
case MimeTypeText.RUBY:
case MimeTypeText.GO:
case MimeTypeText.RUST:
case MimeTypeText.SHELL:
case MimeTypeText.BAT:
case MimeTypeText.SQL:
case MimeTypeText.R:
case MimeTypeText.SCALA:
case MimeTypeText.KOTLIN:
case MimeTypeText.SWIFT:
case MimeTypeText.DART:
case MimeTypeText.VUE:
case MimeTypeText.SVELTE:
case MimeTypeText.LATEX:
case MimeTypeText.BIBTEX:
return FileTypeCategory.TEXT;
return null;
default:
return null;
}
}
export function getFileTypeCategoryByExtension(filename: string): FileTypeCategory | null {
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
switch (extension) {
// Images
case FileExtensionImage.JPG:
case FileExtensionImage.JPEG:
case FileExtensionImage.PNG:
case FileExtensionImage.GIF:
case FileExtensionImage.WEBP:
case FileExtensionImage.SVG:
return FileTypeCategory.IMAGE;
// Audio
case FileExtensionAudio.MP3:
case FileExtensionAudio.WAV:
return FileTypeCategory.AUDIO;
// PDF
case FileExtensionPdf.PDF:
return FileTypeCategory.PDF;
// Text
case FileExtensionText.TXT:
case FileExtensionText.MD:
case FileExtensionText.ADOC:
case FileExtensionText.JS:
case FileExtensionText.TS:
case FileExtensionText.JSX:
case FileExtensionText.TSX:
case FileExtensionText.CSS:
case FileExtensionText.HTML:
case FileExtensionText.HTM:
case FileExtensionText.JSON:
case FileExtensionText.XML:
case FileExtensionText.YAML:
case FileExtensionText.YML:
case FileExtensionText.CSV:
case FileExtensionText.LOG:
case FileExtensionText.PY:
case FileExtensionText.JAVA:
case FileExtensionText.CPP:
case FileExtensionText.C:
case FileExtensionText.H:
case FileExtensionText.PHP:
case FileExtensionText.RB:
case FileExtensionText.GO:
case FileExtensionText.RS:
case FileExtensionText.SH:
case FileExtensionText.BAT:
case FileExtensionText.SQL:
case FileExtensionText.R:
case FileExtensionText.SCALA:
case FileExtensionText.KT:
case FileExtensionText.SWIFT:
case FileExtensionText.DART:
case FileExtensionText.VUE:
case FileExtensionText.SVELTE:
case FileExtensionText.TEX:
case FileExtensionText.BIB:
return FileTypeCategory.TEXT;
default:
return null;
}
}
export function getFileTypeByExtension(filename: string): string | null {

View File

@ -6,6 +6,7 @@ import { getFileTypeCategory } from '$lib/utils/file-type';
import { modelsStore } from '$lib/stores/models.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toast } from 'svelte-sonner';
import { convertPDFToText } from '$lib/utils/pdf-processing';
/**
* Read a file as a data URL (base64 encoded)
@ -95,8 +96,14 @@ export async function processFilesToChatUploaded(
results.push(base);
}
} else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) {
// PDFs handled later when building extras; keep metadata only
results.push(base);
// Extract text content from PDF for preview
try {
const textContent = await convertPDFToText(file);
results.push({ ...base, textContent });
} catch (err) {
console.warn('Failed to extract text from PDF, adding without content:', err);
results.push(base);
}
// Show suggestion toast if vision model is available but PDF as image is disabled
const hasVisionSupport = activeModelId

View File

@ -0,0 +1,145 @@
/**
* Maps file extensions to highlight.js language identifiers
*/
export function getLanguageFromFilename(filename: string): string {
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
switch (extension) {
// JavaScript / TypeScript
case '.js':
case '.mjs':
case '.cjs':
return 'javascript';
case '.ts':
case '.mts':
case '.cts':
return 'typescript';
case '.jsx':
return 'javascript';
case '.tsx':
return 'typescript';
// Web
case '.html':
case '.htm':
return 'html';
case '.css':
return 'css';
case '.scss':
return 'scss';
case '.less':
return 'less';
case '.vue':
return 'html';
case '.svelte':
return 'html';
// Data formats
case '.json':
return 'json';
case '.xml':
return 'xml';
case '.yaml':
case '.yml':
return 'yaml';
case '.toml':
return 'ini';
case '.csv':
return 'plaintext';
// Programming languages
case '.py':
return 'python';
case '.java':
return 'java';
case '.kt':
case '.kts':
return 'kotlin';
case '.scala':
return 'scala';
case '.cpp':
case '.cc':
case '.cxx':
case '.c++':
return 'cpp';
case '.c':
return 'c';
case '.h':
case '.hpp':
return 'cpp';
case '.cs':
return 'csharp';
case '.go':
return 'go';
case '.rs':
return 'rust';
case '.rb':
return 'ruby';
case '.php':
return 'php';
case '.swift':
return 'swift';
case '.dart':
return 'dart';
case '.r':
return 'r';
case '.lua':
return 'lua';
case '.pl':
case '.pm':
return 'perl';
// Shell
case '.sh':
case '.bash':
case '.zsh':
return 'bash';
case '.bat':
case '.cmd':
return 'dos';
case '.ps1':
return 'powershell';
// Database
case '.sql':
return 'sql';
// Markup / Documentation
case '.md':
case '.markdown':
return 'markdown';
case '.tex':
case '.latex':
return 'latex';
case '.adoc':
case '.asciidoc':
return 'asciidoc';
// Config
case '.ini':
case '.cfg':
case '.conf':
return 'ini';
case '.dockerfile':
return 'dockerfile';
case '.nginx':
return 'nginx';
// Other
case '.graphql':
case '.gql':
return 'graphql';
case '.proto':
return 'protobuf';
case '.diff':
case '.patch':
return 'diff';
case '.log':
return 'plaintext';
case '.txt':
return 'plaintext';
default:
return 'plaintext';
}
}