Better UX for handling multiple attachments in WebUI (#17246)
This commit is contained in:
parent
becc4816dd
commit
f1bad23f88
Binary file not shown.
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X } from '@lucide/svelte';
|
import { RemoveButton } from '$lib/components/app';
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { formatFileSize, getFileTypeLabel, getPreviewText } from '$lib/utils/file-preview';
|
import { formatFileSize, getFileTypeLabel, getPreviewText } from '$lib/utils/file-preview';
|
||||||
import { FileTypeCategory, MimeTypeText } from '$lib/enums/files';
|
import { FileTypeCategory, MimeTypeText } from '$lib/enums/files';
|
||||||
|
|
||||||
|
|
@ -66,17 +65,15 @@
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Non-readonly mode (ChatForm) -->
|
<!-- Non-readonly mode (ChatForm) -->
|
||||||
<div class="relative rounded-lg border border-border bg-muted p-3 {className} w-64">
|
<button
|
||||||
<Button
|
class="group relative rounded-lg border border-border bg-muted p-3 {className} {textContent
|
||||||
type="button"
|
? 'max-h-24 max-w-72'
|
||||||
variant="ghost"
|
: 'max-w-36'} cursor-pointer text-left"
|
||||||
size="sm"
|
onclick={onClick}
|
||||||
class="absolute top-2 right-2 h-6 w-6 bg-white/20 p-0 hover:bg-white/30"
|
>
|
||||||
onclick={() => onRemove?.(id)}
|
<div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
aria-label="Remove file"
|
<RemoveButton {id} {onRemove} />
|
||||||
>
|
</div>
|
||||||
<X class="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div class="pr-8">
|
<div class="pr-8">
|
||||||
<span class="mb-3 block truncate text-sm font-medium text-foreground">{name}</span>
|
<span class="mb-3 block truncate text-sm font-medium text-foreground">{name}</span>
|
||||||
|
|
@ -85,7 +82,7 @@
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div
|
<div
|
||||||
class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
|
class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
|
||||||
style="max-height: 3.6em; line-height: 1.2em;"
|
style="max-height: 3rem; line-height: 1.2em;"
|
||||||
>
|
>
|
||||||
{getPreviewText(textContent)}
|
{getPreviewText(textContent)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -98,11 +95,11 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-2 gap-3 rounded-lg border border-border bg-muted p-3 {className}"
|
class="group flex items-center gap-3 rounded-lg border border-border bg-muted p-3 {className} relative"
|
||||||
onclick={onClick}
|
onclick={onClick}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -112,7 +109,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<span class="max-w-36 truncate text-sm font-medium text-foreground md:max-w-72">
|
<span
|
||||||
|
class="max-w-24 truncate text-sm font-medium text-foreground group-hover:pr-6 md:max-w-32"
|
||||||
|
>
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|
@ -122,18 +121,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !readonly}
|
{#if !readonly}
|
||||||
<Button
|
<div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
type="button"
|
<RemoveButton {id} {onRemove} />
|
||||||
variant="ghost"
|
</div>
|
||||||
size="sm"
|
|
||||||
class="h-6 w-6 p-0"
|
|
||||||
onclick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRemove?.(id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X class="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X } from '@lucide/svelte';
|
import { RemoveButton } from '$lib/components/app';
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -26,12 +25,12 @@
|
||||||
class: className = '',
|
class: className = '',
|
||||||
// Default to small size for form previews
|
// Default to small size for form previews
|
||||||
width = 'w-auto',
|
width = 'w-auto',
|
||||||
height = 'h-24',
|
height = 'h-16',
|
||||||
imageClass = ''
|
imageClass = ''
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative overflow-hidden rounded-lg border border-border bg-muted {className}">
|
<div class="group relative overflow-hidden rounded-lg border border-border bg-muted {className}">
|
||||||
{#if onClick}
|
{#if onClick}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -55,17 +54,9 @@
|
||||||
|
|
||||||
{#if !readonly}
|
{#if !readonly}
|
||||||
<div
|
<div
|
||||||
class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity hover:opacity-100"
|
class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<Button
|
<RemoveButton {id} {onRemove} class="text-white" />
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="h-6 w-6 bg-white/20 p-0 text-white hover:bg-white/30"
|
|
||||||
onclick={() => onRemove?.(id)}
|
|
||||||
>
|
|
||||||
<X class="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@
|
||||||
<Dialog.Root bind:open>
|
<Dialog.Root bind:open>
|
||||||
<Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden !p-10 sm:w-auto sm:max-w-6xl">
|
<Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden !p-10 sm:w-auto sm:max-w-6xl">
|
||||||
<Dialog.Header class="flex-shrink-0">
|
<Dialog.Header class="flex-shrink-0">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between gap-6">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{#if IconComponent}
|
{#if IconComponent}
|
||||||
<IconComponent class="h-5 w-5 text-muted-foreground" />
|
<IconComponent class="h-5 w-5 text-muted-foreground" />
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ChatAttachmentImagePreview, ChatAttachmentFilePreview } from '$lib/components/app';
|
import { ChatAttachmentImagePreview, ChatAttachmentFilePreview } from '$lib/components/app';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||||
import { FileTypeCategory } from '$lib/enums/files';
|
import { FileTypeCategory } from '$lib/enums/files';
|
||||||
import { getFileTypeCategory } from '$lib/utils/file-type';
|
import { getFileTypeCategory } from '$lib/utils/file-type';
|
||||||
import ChatAttachmentPreviewDialog from './ChatAttachmentPreviewDialog.svelte';
|
import ChatAttachmentPreviewDialog from './ChatAttachmentPreviewDialog.svelte';
|
||||||
|
import ChatAttachmentsViewAllDialog from './ChatAttachmentsViewAllDialog.svelte';
|
||||||
|
import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
|
style?: string;
|
||||||
// For ChatMessage - stored attachments
|
// For ChatMessage - stored attachments
|
||||||
attachments?: DatabaseMessageExtra[];
|
attachments?: DatabaseMessageExtra[];
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
|
|
@ -16,10 +21,13 @@
|
||||||
imageClass?: string;
|
imageClass?: string;
|
||||||
imageHeight?: string;
|
imageHeight?: string;
|
||||||
imageWidth?: string;
|
imageWidth?: string;
|
||||||
|
// Limit display to single row with "+ X more" button
|
||||||
|
limitToSingleRow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
class: className = '',
|
class: className = '',
|
||||||
|
style = '',
|
||||||
attachments = [],
|
attachments = [],
|
||||||
readonly = false,
|
readonly = false,
|
||||||
onFileRemove,
|
onFileRemove,
|
||||||
|
|
@ -27,36 +35,23 @@
|
||||||
// Default to small size for form previews
|
// Default to small size for form previews
|
||||||
imageClass = '',
|
imageClass = '',
|
||||||
imageHeight = 'h-24',
|
imageHeight = 'h-24',
|
||||||
imageWidth = 'w-auto'
|
imageWidth = 'w-auto',
|
||||||
|
limitToSingleRow = false
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let displayItems = $derived(getDisplayItems());
|
let displayItems = $derived(getDisplayItems());
|
||||||
|
|
||||||
// Preview dialog state
|
let canScrollLeft = $state(false);
|
||||||
|
let canScrollRight = $state(false);
|
||||||
|
let isScrollable = $state(false);
|
||||||
let previewDialogOpen = $state(false);
|
let previewDialogOpen = $state(false);
|
||||||
let previewItem = $state<{
|
let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
|
||||||
uploadedFile?: ChatUploadedFile;
|
let scrollContainer: HTMLDivElement | undefined = $state();
|
||||||
attachment?: DatabaseMessageExtra;
|
let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
|
||||||
preview?: string;
|
let viewAllDialogOpen = $state(false);
|
||||||
name?: string;
|
|
||||||
type?: string;
|
|
||||||
size?: number;
|
|
||||||
textContent?: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
function getDisplayItems() {
|
function getDisplayItems(): ChatAttachmentDisplayItem[] {
|
||||||
const items: Array<{
|
const items: ChatAttachmentDisplayItem[] = [];
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
size?: number;
|
|
||||||
preview?: string;
|
|
||||||
type: string;
|
|
||||||
isImage: boolean;
|
|
||||||
uploadedFile?: ChatUploadedFile;
|
|
||||||
attachment?: DatabaseMessageExtra;
|
|
||||||
attachmentIndex?: number;
|
|
||||||
textContent?: string;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
// Add uploaded files (ChatForm)
|
// Add uploaded files (ChatForm)
|
||||||
for (const file of uploadedFiles) {
|
for (const file of uploadedFiles) {
|
||||||
|
|
@ -127,14 +122,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items.reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPreview(item: (typeof displayItems)[0], event?: Event) {
|
function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) {
|
||||||
if (event) {
|
event?.stopPropagation();
|
||||||
event.preventDefault();
|
event?.preventDefault();
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
previewItem = {
|
previewItem = {
|
||||||
uploadedFile: item.uploadedFile,
|
uploadedFile: item.uploadedFile,
|
||||||
|
|
@ -147,38 +140,118 @@
|
||||||
};
|
};
|
||||||
previewDialogOpen = true;
|
previewDialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrollLeft(event?: MouseEvent) {
|
||||||
|
event?.stopPropagation();
|
||||||
|
event?.preventDefault();
|
||||||
|
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
scrollContainer.scrollBy({ left: scrollContainer.clientWidth * -0.67, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollRight(event?: MouseEvent) {
|
||||||
|
event?.stopPropagation();
|
||||||
|
event?.preventDefault();
|
||||||
|
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
scrollContainer.scrollBy({ left: scrollContainer.clientWidth * 0.67, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScrollButtons() {
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
|
||||||
|
|
||||||
|
canScrollLeft = scrollLeft > 0;
|
||||||
|
canScrollRight = scrollLeft < scrollWidth - clientWidth - 1;
|
||||||
|
isScrollable = scrollWidth > clientWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (scrollContainer && displayItems.length) {
|
||||||
|
scrollContainer.scrollLeft = 0;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
updateScrollButtons();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if displayItems.length > 0}
|
{#if displayItems.length > 0}
|
||||||
<div class="flex flex-wrap items-start {readonly ? 'justify-end' : ''} gap-3 {className}">
|
<div class={className} {style}>
|
||||||
{#each displayItems as item (item.id)}
|
<div class="relative">
|
||||||
{#if item.isImage && item.preview}
|
<button
|
||||||
<ChatAttachmentImagePreview
|
class="absolute top-1/2 left-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollLeft
|
||||||
class="cursor-pointer"
|
? 'opacity-100'
|
||||||
id={item.id}
|
: 'pointer-events-none opacity-0'}"
|
||||||
name={item.name}
|
onclick={scrollLeft}
|
||||||
preview={item.preview}
|
aria-label="Scroll left"
|
||||||
{readonly}
|
>
|
||||||
onRemove={onFileRemove}
|
<ChevronLeft class="h-4 w-4" />
|
||||||
height={imageHeight}
|
</button>
|
||||||
width={imageWidth}
|
|
||||||
{imageClass}
|
<div
|
||||||
onClick={(event) => openPreview(item, event)}
|
class="scrollbar-hide flex items-start gap-3 overflow-x-auto"
|
||||||
/>
|
bind:this={scrollContainer}
|
||||||
{:else}
|
onscroll={updateScrollButtons}
|
||||||
<ChatAttachmentFilePreview
|
>
|
||||||
class="cursor-pointer"
|
{#each displayItems as item (item.id)}
|
||||||
id={item.id}
|
{#if item.isImage && item.preview}
|
||||||
name={item.name}
|
<ChatAttachmentImagePreview
|
||||||
type={item.type}
|
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
|
||||||
size={item.size}
|
id={item.id}
|
||||||
{readonly}
|
name={item.name}
|
||||||
onRemove={onFileRemove}
|
preview={item.preview}
|
||||||
textContent={item.textContent}
|
{readonly}
|
||||||
onClick={(event) => openPreview(item, event)}
|
onRemove={onFileRemove}
|
||||||
/>
|
height={imageHeight}
|
||||||
{/if}
|
width={imageWidth}
|
||||||
{/each}
|
{imageClass}
|
||||||
|
onClick={(event) => openPreview(item, event)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<ChatAttachmentFilePreview
|
||||||
|
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
|
||||||
|
id={item.id}
|
||||||
|
name={item.name}
|
||||||
|
type={item.type}
|
||||||
|
size={item.size}
|
||||||
|
{readonly}
|
||||||
|
onRemove={onFileRemove}
|
||||||
|
textContent={item.textContent}
|
||||||
|
onClick={(event) => openPreview(item, event)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="absolute top-1/2 right-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollRight
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'pointer-events-none opacity-0'}"
|
||||||
|
onclick={scrollRight}
|
||||||
|
aria-label="Scroll right"
|
||||||
|
>
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showViewAll}
|
||||||
|
<div class="mt-2 -mr-2 flex justify-end px-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onclick={() => (viewAllDialogOpen = true)}
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -194,3 +267,13 @@
|
||||||
textContent={previewItem.textContent}
|
textContent={previewItem.textContent}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<ChatAttachmentsViewAllDialog
|
||||||
|
bind:open={viewAllDialogOpen}
|
||||||
|
{uploadedFiles}
|
||||||
|
{attachments}
|
||||||
|
{readonly}
|
||||||
|
{onFileRemove}
|
||||||
|
imageHeight="h-64"
|
||||||
|
{imageClass}
|
||||||
|
/>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
|
import { ChatAttachmentImagePreview, ChatAttachmentFilePreview } from '$lib/components/app';
|
||||||
|
import { FileTypeCategory } from '$lib/enums/files';
|
||||||
|
import { getFileTypeCategory } from '$lib/utils/file-type';
|
||||||
|
import ChatAttachmentPreviewDialog from './ChatAttachmentPreviewDialog.svelte';
|
||||||
|
import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open?: boolean;
|
||||||
|
uploadedFiles?: ChatUploadedFile[];
|
||||||
|
attachments?: DatabaseMessageExtra[];
|
||||||
|
readonly?: boolean;
|
||||||
|
onFileRemove?: (fileId: string) => void;
|
||||||
|
imageHeight?: string;
|
||||||
|
imageWidth?: string;
|
||||||
|
imageClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
uploadedFiles = [],
|
||||||
|
attachments = [],
|
||||||
|
readonly = false,
|
||||||
|
onFileRemove,
|
||||||
|
imageHeight = 'h-24',
|
||||||
|
imageWidth = 'w-auto',
|
||||||
|
imageClass = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let previewDialogOpen = $state(false);
|
||||||
|
let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
|
||||||
|
|
||||||
|
let displayItems = $derived(getDisplayItems());
|
||||||
|
let imageItems = $derived(displayItems.filter((item) => item.isImage));
|
||||||
|
let fileItems = $derived(displayItems.filter((item) => !item.isImage));
|
||||||
|
|
||||||
|
function getDisplayItems(): ChatAttachmentDisplayItem[] {
|
||||||
|
const items: ChatAttachmentDisplayItem[] = [];
|
||||||
|
|
||||||
|
for (const file of uploadedFiles) {
|
||||||
|
items.push({
|
||||||
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
preview: file.preview,
|
||||||
|
type: file.type,
|
||||||
|
isImage: getFileTypeCategory(file.type) === FileTypeCategory.IMAGE,
|
||||||
|
uploadedFile: file,
|
||||||
|
textContent: file.textContent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, attachment] of attachments.entries()) {
|
||||||
|
if (attachment.type === 'imageFile') {
|
||||||
|
items.push({
|
||||||
|
id: `attachment-${index}`,
|
||||||
|
name: attachment.name,
|
||||||
|
preview: attachment.base64Url,
|
||||||
|
type: 'image',
|
||||||
|
isImage: true,
|
||||||
|
attachment,
|
||||||
|
attachmentIndex: index
|
||||||
|
});
|
||||||
|
} else if (attachment.type === 'textFile') {
|
||||||
|
items.push({
|
||||||
|
id: `attachment-${index}`,
|
||||||
|
name: attachment.name,
|
||||||
|
type: 'text',
|
||||||
|
isImage: false,
|
||||||
|
attachment,
|
||||||
|
attachmentIndex: index,
|
||||||
|
textContent: attachment.content
|
||||||
|
});
|
||||||
|
} else if (attachment.type === 'context') {
|
||||||
|
// Legacy format from old webui - treat as text file
|
||||||
|
items.push({
|
||||||
|
id: `attachment-${index}`,
|
||||||
|
name: attachment.name,
|
||||||
|
type: 'text',
|
||||||
|
isImage: false,
|
||||||
|
attachment,
|
||||||
|
attachmentIndex: index,
|
||||||
|
textContent: attachment.content
|
||||||
|
});
|
||||||
|
} else if (attachment.type === 'audioFile') {
|
||||||
|
items.push({
|
||||||
|
id: `attachment-${index}`,
|
||||||
|
name: attachment.name,
|
||||||
|
type: attachment.mimeType || 'audio',
|
||||||
|
isImage: false,
|
||||||
|
attachment,
|
||||||
|
attachmentIndex: index
|
||||||
|
});
|
||||||
|
} else if (attachment.type === 'pdfFile') {
|
||||||
|
items.push({
|
||||||
|
id: `attachment-${index}`,
|
||||||
|
name: attachment.name,
|
||||||
|
type: 'application/pdf',
|
||||||
|
isImage: false,
|
||||||
|
attachment,
|
||||||
|
attachmentIndex: index,
|
||||||
|
textContent: attachment.content
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreview(item: (typeof displayItems)[0], event?: Event) {
|
||||||
|
if (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
previewItem = {
|
||||||
|
uploadedFile: item.uploadedFile,
|
||||||
|
attachment: item.attachment,
|
||||||
|
preview: item.preview,
|
||||||
|
name: item.name,
|
||||||
|
type: item.type,
|
||||||
|
size: item.size,
|
||||||
|
textContent: item.textContent
|
||||||
|
};
|
||||||
|
previewDialogOpen = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay />
|
||||||
|
|
||||||
|
<Dialog.Content class="flex !max-h-[90vh] !max-w-6xl flex-col">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>All Attachments ({displayItems.length})</Dialog.Title>
|
||||||
|
<Dialog.Description class="text-sm text-muted-foreground">
|
||||||
|
View and manage all attached files
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 space-y-6 overflow-y-auto px-1">
|
||||||
|
{#if fileItems.length > 0}
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-3 text-sm font-medium text-foreground">Files ({fileItems.length})</h3>
|
||||||
|
<div class="flex flex-wrap items-start gap-3">
|
||||||
|
{#each fileItems as item (item.id)}
|
||||||
|
<ChatAttachmentFilePreview
|
||||||
|
class="cursor-pointer"
|
||||||
|
id={item.id}
|
||||||
|
name={item.name}
|
||||||
|
type={item.type}
|
||||||
|
size={item.size}
|
||||||
|
{readonly}
|
||||||
|
onRemove={onFileRemove}
|
||||||
|
textContent={item.textContent}
|
||||||
|
onClick={(event) => openPreview(item, event)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if imageItems.length > 0}
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-3 text-sm font-medium text-foreground">Images ({imageItems.length})</h3>
|
||||||
|
<div class="flex flex-wrap items-start gap-3">
|
||||||
|
{#each imageItems as item (item.id)}
|
||||||
|
{#if item.preview}
|
||||||
|
<ChatAttachmentImagePreview
|
||||||
|
class="cursor-pointer"
|
||||||
|
id={item.id}
|
||||||
|
name={item.name}
|
||||||
|
preview={item.preview}
|
||||||
|
{readonly}
|
||||||
|
onRemove={onFileRemove}
|
||||||
|
height={imageHeight}
|
||||||
|
width={imageWidth}
|
||||||
|
{imageClass}
|
||||||
|
onClick={(event) => openPreview(item, event)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
{#if previewItem}
|
||||||
|
<ChatAttachmentPreviewDialog
|
||||||
|
bind:open={previewDialogOpen}
|
||||||
|
uploadedFile={previewItem.uploadedFile}
|
||||||
|
attachment={previewItem.attachment}
|
||||||
|
preview={previewItem.preview}
|
||||||
|
name={previewItem.name}
|
||||||
|
type={previewItem.type}
|
||||||
|
size={previewItem.size}
|
||||||
|
textContent={previewItem.textContent}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
@ -232,7 +232,13 @@
|
||||||
onsubmit={handleSubmit}
|
onsubmit={handleSubmit}
|
||||||
class="{INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {className}"
|
class="{INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {className}"
|
||||||
>
|
>
|
||||||
<ChatAttachmentsList bind:uploadedFiles {onFileRemove} class="mb-3 px-5 pt-5" />
|
<ChatAttachmentsList
|
||||||
|
bind:uploadedFiles
|
||||||
|
{onFileRemove}
|
||||||
|
limitToSingleRow
|
||||||
|
class="py-5"
|
||||||
|
style="scroll-padding: 1rem;"
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex-column relative min-h-[48px] items-center rounded-3xl px-5 py-3 shadow-sm transition-all focus-within:shadow-md"
|
class="flex-column relative min-h-[48px] items-center rounded-3xl px-5 py-3 shadow-sm transition-all focus-within:shadow-md"
|
||||||
|
|
|
||||||
|
|
@ -333,7 +333,7 @@
|
||||||
ondrop={handleDrop}
|
ondrop={handleDrop}
|
||||||
role="main"
|
role="main"
|
||||||
>
|
>
|
||||||
<div class="w-full max-w-2xl px-4">
|
<div class="w-full max-w-[48rem] px-4">
|
||||||
<div class="mb-8 text-center" in:fade={{ duration: 300 }}>
|
<div class="mb-8 text-center" in:fade={{ duration: 300 }}>
|
||||||
<h1 class="mb-2 text-3xl font-semibold tracking-tight">llama.cpp</h1>
|
<h1 class="mb-2 text-3xl font-semibold tracking-tight">llama.cpp</h1>
|
||||||
|
|
||||||
|
|
@ -368,7 +368,7 @@
|
||||||
<AlertDialog.Portal>
|
<AlertDialog.Portal>
|
||||||
<AlertDialog.Overlay />
|
<AlertDialog.Overlay />
|
||||||
|
|
||||||
<AlertDialog.Content class="max-w-md">
|
<AlertDialog.Content class="flex max-w-md flex-col">
|
||||||
<AlertDialog.Header>
|
<AlertDialog.Header>
|
||||||
<AlertDialog.Title>File Upload Error</AlertDialog.Title>
|
<AlertDialog.Title>File Upload Error</AlertDialog.Title>
|
||||||
|
|
||||||
|
|
@ -377,7 +377,7 @@
|
||||||
</AlertDialog.Description>
|
</AlertDialog.Description>
|
||||||
</AlertDialog.Header>
|
</AlertDialog.Header>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="!max-h-[50vh] min-h-0 flex-1 space-y-4 overflow-y-auto">
|
||||||
{#if fileErrorData.generallyUnsupported.length > 0}
|
{#if fileErrorData.generallyUnsupported.length > 0}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<h4 class="text-sm font-medium text-destructive">Unsupported File Types</h4>
|
<h4 class="text-sm font-medium text-destructive">Unsupported File Types</h4>
|
||||||
|
|
@ -398,8 +398,6 @@
|
||||||
|
|
||||||
{#if fileErrorData.modalityUnsupported.length > 0}
|
{#if fileErrorData.modalityUnsupported.length > 0}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<h4 class="text-sm font-medium text-destructive">Model Compatibility Issues</h4>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{#each fileErrorData.modalityUnsupported as file (file.name)}
|
{#each fileErrorData.modalityUnsupported as file (file.name)}
|
||||||
<div class="rounded-md bg-destructive/10 px-3 py-2">
|
<div class="rounded-md bg-destructive/10 px-3 py-2">
|
||||||
|
|
@ -415,14 +413,14 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-md bg-muted/50 p-3">
|
<div class="rounded-md bg-muted/50 p-3">
|
||||||
<h4 class="mb-2 text-sm font-medium">This model supports:</h4>
|
<h4 class="mb-2 text-sm font-medium">This model supports:</h4>
|
||||||
|
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
{fileErrorData.supportedTypes.join(', ')}
|
{fileErrorData.supportedTypes.join(', ')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AlertDialog.Footer>
|
<AlertDialog.Footer>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ export { default as ChatAttachmentsList } from './chat/ChatAttachments/ChatAttac
|
||||||
export { default as ChatAttachmentFilePreview } from './chat/ChatAttachments/ChatAttachmentFilePreview.svelte';
|
export { default as ChatAttachmentFilePreview } from './chat/ChatAttachments/ChatAttachmentFilePreview.svelte';
|
||||||
export { default as ChatAttachmentImagePreview } from './chat/ChatAttachments/ChatAttachmentImagePreview.svelte';
|
export { default as ChatAttachmentImagePreview } from './chat/ChatAttachments/ChatAttachmentImagePreview.svelte';
|
||||||
export { default as ChatAttachmentPreviewDialog } from './chat/ChatAttachments/ChatAttachmentPreviewDialog.svelte';
|
export { default as ChatAttachmentPreviewDialog } from './chat/ChatAttachments/ChatAttachmentPreviewDialog.svelte';
|
||||||
|
export { default as ChatAttachmentsViewAllDialog } from './chat/ChatAttachments/ChatAttachmentsViewAllDialog.svelte';
|
||||||
|
|
||||||
export { default as ChatForm } from './chat/ChatForm/ChatForm.svelte';
|
export { default as ChatForm } from './chat/ChatForm/ChatForm.svelte';
|
||||||
export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
|
export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
|
||||||
|
|
@ -42,6 +43,8 @@ export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.sve
|
||||||
|
|
||||||
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 ServerStatus } from './server/ServerStatus.svelte';
|
export { default as ServerStatus } from './server/ServerStatus.svelte';
|
||||||
export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte';
|
export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte';
|
||||||
export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte';
|
export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { X } from '@lucide/svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
onRemove?: (id: string) => void;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { id, onRemove, class: className = '' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 w-6 bg-white/20 p-0 hover:bg-white/30 {className}"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove?.(id);
|
||||||
|
}}
|
||||||
|
aria-label="Remove file"
|
||||||
|
>
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
|
@ -11,6 +11,29 @@ export interface ChatUploadedFile {
|
||||||
textContent?: string;
|
textContent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatAttachmentDisplayItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
preview?: string;
|
||||||
|
type: string;
|
||||||
|
isImage: boolean;
|
||||||
|
uploadedFile?: ChatUploadedFile;
|
||||||
|
attachment?: DatabaseMessageExtra;
|
||||||
|
attachmentIndex?: number;
|
||||||
|
textContent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatAttachmentPreviewItem {
|
||||||
|
uploadedFile?: ChatUploadedFile;
|
||||||
|
attachment?: DatabaseMessageExtra;
|
||||||
|
preview?: string;
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
size?: number;
|
||||||
|
textContent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatMessageSiblingInfo {
|
export interface ChatMessageSiblingInfo {
|
||||||
message: DatabaseMessage;
|
message: DatabaseMessage;
|
||||||
siblingIds: string[];
|
siblingIds: string[];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue