feat: Attachment logic & UI improvements
This commit is contained in:
parent
d49d97c642
commit
648d2deebc
|
|
@ -64,7 +64,7 @@
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwind-variants": "^1.0.0",
|
"tailwind-variants": "^3.2.2",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
|
|
@ -8324,31 +8324,23 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwind-variants": {
|
"node_modules/tailwind-variants": {
|
||||||
"version": "1.0.0",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz",
|
||||||
"integrity": "sha512-2WSbv4ulEEyuBKomOunut65D8UZwxrHoRfYnxGcQNnHqlSCp2+B7Yz2W+yrNDrxRodOXtGD/1oCcKGNBnUqMqA==",
|
"integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"tailwind-merge": "3.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.x",
|
"node": ">=16.x",
|
||||||
"pnpm": ">=7.x"
|
"pnpm": ">=7.x"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"tailwind-merge": ">=3.0.0",
|
||||||
"tailwindcss": "*"
|
"tailwindcss": "*"
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/tailwind-variants/node_modules/tailwind-merge": {
|
"peerDependenciesMeta": {
|
||||||
"version": "3.0.2",
|
"tailwind-merge": {
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.2.tgz",
|
"optional": true
|
||||||
"integrity": "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==",
|
}
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/dcastil"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwind-variants": "^1.0.0",
|
"tailwind-variants": "^3.2.2",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
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 type { DatabaseMessageExtra } from '$lib/types/database';
|
||||||
import { convertPDFToImage } from '$lib/utils/pdf-processing';
|
import { convertPDFToImage } from '$lib/utils/pdf-processing';
|
||||||
import { isTextFile, isImageFile, isPdfFile, isAudioFile } from '$lib/utils/attachment-type';
|
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 {
|
interface Props {
|
||||||
// Either an uploaded file or a stored attachment
|
// Either an uploaded file or a stored attachment
|
||||||
|
|
@ -13,9 +17,15 @@
|
||||||
preview?: string;
|
preview?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
textContent?: 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');
|
let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
|
||||||
|
|
||||||
|
|
@ -35,6 +45,8 @@
|
||||||
(attachment && 'content' in attachment ? attachment.content : textContent)
|
(attachment && 'content' in attachment ? attachment.content : textContent)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let language = $derived(getLanguageFromFilename(displayName));
|
||||||
|
|
||||||
let IconComponent = $derived(() => {
|
let IconComponent = $derived(() => {
|
||||||
if (isImage) return Image;
|
if (isImage) return Image;
|
||||||
if (isText || isPdf) return FileText;
|
if (isText || isPdf) return FileText;
|
||||||
|
|
@ -161,6 +173,24 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if isPdf && pdfViewMode === 'pages'}
|
{: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}
|
{#if pdfImagesLoading}
|
||||||
<div class="flex items-center justify-center p-8">
|
<div class="flex items-center justify-center p-8">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
|
|
@ -207,11 +237,7 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
|
{:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
|
||||||
<div
|
<SyntaxHighlightedCode code={displayTextContent} {language} maxWidth="69rem" />
|
||||||
class="max-h-[60vh] overflow-auto rounded-lg bg-muted p-4 font-mono text-sm break-words whitespace-pre-wrap"
|
|
||||||
>
|
|
||||||
{displayTextContent}
|
|
||||||
</div>
|
|
||||||
{:else if isAudio}
|
{:else if isAudio}
|
||||||
<div class="flex items-center justify-center p-8">
|
<div class="flex items-center justify-center p-8">
|
||||||
<div class="w-full max-w-md text-center">
|
<div class="w-full max-w-md text-center">
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@
|
||||||
imageWidth?: string;
|
imageWidth?: string;
|
||||||
// Limit display to single row with "+ X more" button
|
// Limit display to single row with "+ X more" button
|
||||||
limitToSingleRow?: boolean;
|
limitToSingleRow?: boolean;
|
||||||
|
// For vision modality check
|
||||||
|
activeModelId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -35,7 +37,8 @@
|
||||||
imageClass = '',
|
imageClass = '',
|
||||||
imageHeight = 'h-24',
|
imageHeight = 'h-24',
|
||||||
imageWidth = 'w-auto',
|
imageWidth = 'w-auto',
|
||||||
limitToSingleRow = false
|
limitToSingleRow = false,
|
||||||
|
activeModelId
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
|
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
|
||||||
|
|
@ -226,6 +229,7 @@
|
||||||
name={previewItem.name}
|
name={previewItem.name}
|
||||||
size={previewItem.size}
|
size={previewItem.size}
|
||||||
textContent={previewItem.textContent}
|
textContent={previewItem.textContent}
|
||||||
|
{activeModelId}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -237,4 +241,5 @@
|
||||||
{onFileRemove}
|
{onFileRemove}
|
||||||
imageHeight="h-64"
|
imageHeight="h-64"
|
||||||
{imageClass}
|
{imageClass}
|
||||||
|
{activeModelId}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
imageHeight?: string;
|
imageHeight?: string;
|
||||||
imageWidth?: string;
|
imageWidth?: string;
|
||||||
imageClass?: string;
|
imageClass?: string;
|
||||||
|
activeModelId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -25,7 +26,8 @@
|
||||||
onFileRemove,
|
onFileRemove,
|
||||||
imageHeight = 'h-24',
|
imageHeight = 'h-24',
|
||||||
imageWidth = 'w-auto',
|
imageWidth = 'w-auto',
|
||||||
imageClass = ''
|
imageClass = '',
|
||||||
|
activeModelId
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let previewDialogOpen = $state(false);
|
let previewDialogOpen = $state(false);
|
||||||
|
|
@ -112,5 +114,6 @@
|
||||||
name={previewItem.name}
|
name={previewItem.name}
|
||||||
size={previewItem.size}
|
size={previewItem.size}
|
||||||
textContent={previewItem.textContent}
|
textContent={previewItem.textContent}
|
||||||
|
{activeModelId}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -333,6 +333,7 @@
|
||||||
limitToSingleRow
|
limitToSingleRow
|
||||||
class="py-5"
|
class="py-5"
|
||||||
style="scroll-padding: 1rem;"
|
style="scroll-padding: 1rem;"
|
||||||
|
activeModelId={activeModelId ?? undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
ServerLoadingSplash,
|
ServerLoadingSplash,
|
||||||
DialogConfirmation
|
DialogConfirmation
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
|
import * as Alert from '$lib/components/ui/alert';
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
import {
|
import {
|
||||||
AUTO_SCROLL_AT_BOTTOM_THRESHOLD,
|
AUTO_SCROLL_AT_BOTTOM_THRESHOLD,
|
||||||
|
|
@ -389,23 +390,21 @@
|
||||||
class="pointer-events-auto mx-auto mb-3 max-w-[48rem] px-4"
|
class="pointer-events-auto mx-auto mb-3 max-w-[48rem] px-4"
|
||||||
in:fly={{ y: 10, duration: 250 }}
|
in:fly={{ y: 10, duration: 250 }}
|
||||||
>
|
>
|
||||||
<div class="rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3">
|
<Alert.Root variant="destructive">
|
||||||
<div class="flex items-center justify-between">
|
<AlertTriangle class="h-4 w-4" />
|
||||||
<div class="flex items-center gap-2">
|
<Alert.Title class="flex items-center justify-between">
|
||||||
<AlertTriangle class="h-4 w-4 text-destructive" />
|
<span>Server unavailable</span>
|
||||||
<span class="text-sm font-medium text-destructive">Server unavailable</span>
|
|
||||||
<span class="text-sm text-muted-foreground">— {serverError()}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onclick={() => serverStore.fetch()}
|
onclick={() => serverStore.fetch()}
|
||||||
disabled={isServerLoading}
|
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' : ''}" />
|
<RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
|
||||||
{isServerLoading ? 'Retrying...' : 'Retry'}
|
{isServerLoading ? 'Retrying...' : 'Retry'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</Alert.Title>
|
||||||
</div>
|
<Alert.Description>{serverError()}</Alert.Description>
|
||||||
|
</Alert.Root>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -449,23 +448,21 @@
|
||||||
|
|
||||||
{#if hasPropsError}
|
{#if hasPropsError}
|
||||||
<div class="mb-4" in:fly={{ y: 10, duration: 250 }}>
|
<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">
|
<Alert.Root variant="destructive">
|
||||||
<div class="flex items-center justify-between">
|
<AlertTriangle class="h-4 w-4" />
|
||||||
<div class="flex items-center gap-2">
|
<Alert.Title class="flex items-center justify-between">
|
||||||
<AlertTriangle class="h-4 w-4 text-destructive" />
|
<span>Server unavailable</span>
|
||||||
<span class="text-sm font-medium text-destructive">Server unavailable</span>
|
|
||||||
<span class="text-sm text-muted-foreground">— {serverError()}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onclick={() => serverStore.fetch()}
|
onclick={() => serverStore.fetch()}
|
||||||
disabled={isServerLoading}
|
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' : ''}" />
|
<RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
|
||||||
{isServerLoading ? 'Retrying...' : 'Retry'}
|
{isServerLoading ? 'Retrying...' : 'Retry'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</Alert.Title>
|
||||||
</div>
|
<Alert.Description>{serverError()}</Alert.Description>
|
||||||
|
</Alert.Root>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||||
import { ChatAttachmentPreview } from '$lib/components/app';
|
import { ChatAttachmentPreview } from '$lib/components/app';
|
||||||
import { formatFileSize } from '$lib/utils/formatters';
|
import { formatFileSize } from '$lib/utils/formatters';
|
||||||
import { getAttachmentTypeLabel } from '$lib/utils/attachment-type';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -16,6 +15,8 @@
|
||||||
name?: string;
|
name?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
textContent?: string;
|
textContent?: string;
|
||||||
|
// For vision modality check
|
||||||
|
activeModelId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -26,7 +27,8 @@
|
||||||
preview,
|
preview,
|
||||||
name,
|
name,
|
||||||
size,
|
size,
|
||||||
textContent
|
textContent,
|
||||||
|
activeModelId
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let chatAttachmentPreviewRef: ChatAttachmentPreview | undefined = $state();
|
let chatAttachmentPreviewRef: ChatAttachmentPreview | undefined = $state();
|
||||||
|
|
@ -35,8 +37,6 @@
|
||||||
|
|
||||||
let displaySize = $derived(uploadedFile?.size || size);
|
let displaySize = $derived(uploadedFile?.size || size);
|
||||||
|
|
||||||
let typeLabel = $derived(getAttachmentTypeLabel(uploadedFile, attachment));
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open && chatAttachmentPreviewRef) {
|
if (open && chatAttachmentPreviewRef) {
|
||||||
chatAttachmentPreviewRef.reset();
|
chatAttachmentPreviewRef.reset();
|
||||||
|
|
@ -49,9 +49,8 @@
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title class="pr-8">{displayName}</Dialog.Title>
|
<Dialog.Title class="pr-8">{displayName}</Dialog.Title>
|
||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
{typeLabel}
|
|
||||||
{#if displaySize}
|
{#if displaySize}
|
||||||
• {formatFileSize(displaySize)}
|
{formatFileSize(displaySize)}
|
||||||
{/if}
|
{/if}
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
@ -63,6 +62,7 @@
|
||||||
{preview}
|
{preview}
|
||||||
name={displayName}
|
name={displayName}
|
||||||
{textContent}
|
{textContent}
|
||||||
|
{activeModelId}
|
||||||
/>
|
/>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
imageHeight?: string;
|
imageHeight?: string;
|
||||||
imageWidth?: string;
|
imageWidth?: string;
|
||||||
imageClass?: string;
|
imageClass?: string;
|
||||||
|
activeModelId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -21,7 +22,8 @@
|
||||||
onFileRemove,
|
onFileRemove,
|
||||||
imageHeight = 'h-24',
|
imageHeight = 'h-24',
|
||||||
imageWidth = 'w-auto',
|
imageWidth = 'w-auto',
|
||||||
imageClass = ''
|
imageClass = '',
|
||||||
|
activeModelId
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let totalCount = $derived(uploadedFiles.length + attachments.length);
|
let totalCount = $derived(uploadedFiles.length + attachments.length);
|
||||||
|
|
@ -45,6 +47,7 @@
|
||||||
{imageHeight}
|
{imageHeight}
|
||||||
{imageWidth}
|
{imageWidth}
|
||||||
{imageClass}
|
{imageClass}
|
||||||
|
{activeModelId}
|
||||||
/>
|
/>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ export { default as CopyToClipboardIcon } from './misc/CopyToClipboardIcon.svelt
|
||||||
export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
|
export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
|
||||||
export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
|
export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
|
||||||
export { default as RemoveButton } from './misc/RemoveButton.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';
|
export { default as ModelsSelector } from './models/ModelsSelector.svelte';
|
||||||
|
|
||||||
// Server
|
// Server
|
||||||
|
|
|
||||||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -2,13 +2,26 @@ import { FileTypeCategory } from '$lib/enums';
|
||||||
import type { ChatAttachmentDisplayItem } from '$lib/types/chat';
|
import type { ChatAttachmentDisplayItem } from '$lib/types/chat';
|
||||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||||
import { isImageFile } from '$lib/utils/attachment-type';
|
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 {
|
export interface AttachmentDisplayItemsOptions {
|
||||||
uploadedFiles?: ChatUploadedFile[];
|
uploadedFiles?: ChatUploadedFile[];
|
||||||
attachments?: DatabaseMessageExtra[];
|
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.
|
* Creates a unified list of display items from uploaded files and stored attachments.
|
||||||
* Items are returned in reverse order (newest first).
|
* Items are returned in reverse order (newest first).
|
||||||
|
|
@ -26,7 +39,7 @@ export function getAttachmentDisplayItems(
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
preview: file.preview,
|
preview: file.preview,
|
||||||
isImage: getFileTypeCategory(file.type) === FileTypeCategory.IMAGE,
|
isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE,
|
||||||
uploadedFile: file,
|
uploadedFile: file,
|
||||||
textContent: file.textContent
|
textContent: file.textContent
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,24 @@
|
||||||
import { AttachmentType, FileTypeCategory } from '$lib/enums';
|
import { AttachmentType, FileTypeCategory } from '$lib/enums';
|
||||||
import { getFileTypeCategory } from '$lib/utils/file-type';
|
import { getFileTypeCategory, getFileTypeCategoryByExtension } from '$lib/utils/file-type';
|
||||||
import { getFileTypeLabel } from '$lib/utils/file-preview';
|
|
||||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
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
|
* Determines if an attachment or uploaded file is a text file
|
||||||
* @param uploadedFile - Optional uploaded file
|
* @param uploadedFile - Optional uploaded file
|
||||||
|
|
@ -14,7 +30,7 @@ export function isTextFile(
|
||||||
uploadedFile?: ChatUploadedFile
|
uploadedFile?: ChatUploadedFile
|
||||||
): boolean {
|
): boolean {
|
||||||
if (uploadedFile) {
|
if (uploadedFile) {
|
||||||
return getFileTypeCategory(uploadedFile.type) === FileTypeCategory.TEXT;
|
return getUploadedFileCategory(uploadedFile) === FileTypeCategory.TEXT;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachment) {
|
if (attachment) {
|
||||||
|
|
@ -37,7 +53,7 @@ export function isImageFile(
|
||||||
uploadedFile?: ChatUploadedFile
|
uploadedFile?: ChatUploadedFile
|
||||||
): boolean {
|
): boolean {
|
||||||
if (uploadedFile) {
|
if (uploadedFile) {
|
||||||
return getFileTypeCategory(uploadedFile.type) === FileTypeCategory.IMAGE;
|
return getUploadedFileCategory(uploadedFile) === FileTypeCategory.IMAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachment) {
|
if (attachment) {
|
||||||
|
|
@ -58,7 +74,7 @@ export function isPdfFile(
|
||||||
uploadedFile?: ChatUploadedFile
|
uploadedFile?: ChatUploadedFile
|
||||||
): boolean {
|
): boolean {
|
||||||
if (uploadedFile) {
|
if (uploadedFile) {
|
||||||
return uploadedFile.type === 'application/pdf';
|
return getUploadedFileCategory(uploadedFile) === FileTypeCategory.PDF;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachment) {
|
if (attachment) {
|
||||||
|
|
@ -79,7 +95,7 @@ export function isAudioFile(
|
||||||
uploadedFile?: ChatUploadedFile
|
uploadedFile?: ChatUploadedFile
|
||||||
): boolean {
|
): boolean {
|
||||||
if (uploadedFile) {
|
if (uploadedFile) {
|
||||||
return getFileTypeCategory(uploadedFile.type) === FileTypeCategory.AUDIO;
|
return getUploadedFileCategory(uploadedFile) === FileTypeCategory.AUDIO;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachment) {
|
if (attachment) {
|
||||||
|
|
@ -88,38 +104,3 @@ export function isAudioFile(
|
||||||
|
|
||||||
return false;
|
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';
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,42 +4,151 @@ import {
|
||||||
PDF_FILE_TYPES,
|
PDF_FILE_TYPES,
|
||||||
TEXT_FILE_TYPES
|
TEXT_FILE_TYPES
|
||||||
} from '$lib/constants/supported-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 {
|
export function getFileTypeCategory(mimeType: string): FileTypeCategory | null {
|
||||||
if (
|
switch (mimeType) {
|
||||||
Object.values(IMAGE_FILE_TYPES).some((type) =>
|
// Images
|
||||||
(type.mimeTypes as readonly string[]).includes(mimeType)
|
case MimeTypeImage.JPEG:
|
||||||
)
|
case MimeTypeImage.PNG:
|
||||||
) {
|
case MimeTypeImage.GIF:
|
||||||
|
case MimeTypeImage.WEBP:
|
||||||
|
case MimeTypeImage.SVG:
|
||||||
return FileTypeCategory.IMAGE;
|
return FileTypeCategory.IMAGE;
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
// Audio
|
||||||
Object.values(AUDIO_FILE_TYPES).some((type) =>
|
case MimeTypeAudio.MP3_MPEG:
|
||||||
(type.mimeTypes as readonly string[]).includes(mimeType)
|
case MimeTypeAudio.MP3:
|
||||||
)
|
case MimeTypeAudio.MP4:
|
||||||
) {
|
case MimeTypeAudio.WAV:
|
||||||
|
case MimeTypeAudio.WEBM:
|
||||||
|
case MimeTypeAudio.WEBM_OPUS:
|
||||||
return FileTypeCategory.AUDIO;
|
return FileTypeCategory.AUDIO;
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
// PDF
|
||||||
Object.values(PDF_FILE_TYPES).some((type) =>
|
case MimeTypeApplication.PDF:
|
||||||
(type.mimeTypes as readonly string[]).includes(mimeType)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return FileTypeCategory.PDF;
|
return FileTypeCategory.PDF;
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
// Text
|
||||||
Object.values(TEXT_FILE_TYPES).some((type) =>
|
case MimeTypeText.PLAIN:
|
||||||
(type.mimeTypes as readonly string[]).includes(mimeType)
|
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 FileTypeCategory.TEXT;
|
||||||
}
|
|
||||||
|
|
||||||
|
default:
|
||||||
return null;
|
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 {
|
export function getFileTypeByExtension(filename: string): string | null {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { getFileTypeCategory } from '$lib/utils/file-type';
|
||||||
import { modelsStore } from '$lib/stores/models.svelte';
|
import { modelsStore } from '$lib/stores/models.svelte';
|
||||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { convertPDFToText } from '$lib/utils/pdf-processing';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read a file as a data URL (base64 encoded)
|
* Read a file as a data URL (base64 encoded)
|
||||||
|
|
@ -95,8 +96,14 @@ export async function processFilesToChatUploaded(
|
||||||
results.push(base);
|
results.push(base);
|
||||||
}
|
}
|
||||||
} else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) {
|
} else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) {
|
||||||
// PDFs handled later when building extras; keep metadata only
|
// 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);
|
results.push(base);
|
||||||
|
}
|
||||||
|
|
||||||
// Show suggestion toast if vision model is available but PDF as image is disabled
|
// Show suggestion toast if vision model is available but PDF as image is disabled
|
||||||
const hasVisionSupport = activeModelId
|
const hasVisionSupport = activeModelId
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue