feat: Attachment logic & UI improvements
This commit is contained in:
parent
d49d97c642
commit
648d2deebc
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -333,6 +333,7 @@
|
|||
limitToSingleRow
|
||||
class="py-5"
|
||||
style="scroll-padding: 1rem;"
|
||||
activeModelId={activeModelId ?? undefined}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 { 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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
) {
|
||||
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)
|
||||
)
|
||||
) {
|
||||
// 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)
|
||||
)
|
||||
) {
|
||||
// PDF
|
||||
case MimeTypeApplication.PDF:
|
||||
return FileTypeCategory.PDF;
|
||||
}
|
||||
|
||||
if (
|
||||
Object.values(TEXT_FILE_TYPES).some((type) =>
|
||||
(type.mimeTypes as readonly string[]).includes(mimeType)
|
||||
)
|
||||
) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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