<script lang="ts">
- import { X } from '@lucide/svelte';
- import { Button } from '$lib/components/ui/button';
+ import { RemoveButton } from '$lib/components/app';
import { formatFileSize, getFileTypeLabel, getPreviewText } from '$lib/utils/file-preview';
import { FileTypeCategory, MimeTypeText } from '$lib/enums/files';
</button>
{:else}
<!-- Non-readonly mode (ChatForm) -->
- <div class="relative rounded-lg border border-border bg-muted p-3 {className} w-64">
- <Button
- type="button"
- variant="ghost"
- size="sm"
- class="absolute top-2 right-2 h-6 w-6 bg-white/20 p-0 hover:bg-white/30"
- onclick={() => onRemove?.(id)}
- aria-label="Remove file"
- >
- <X class="h-3 w-3" />
- </Button>
+ <button
+ class="group relative rounded-lg border border-border bg-muted p-3 {className} {textContent
+ ? 'max-h-24 max-w-72'
+ : 'max-w-36'} cursor-pointer text-left"
+ onclick={onClick}
+ >
+ <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
+ <RemoveButton {id} {onRemove} />
+ </div>
<div class="pr-8">
<span class="mb-3 block truncate text-sm font-medium text-foreground">{name}</span>
<div class="relative">
<div
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)}
</div>
</div>
{/if}
</div>
- </div>
+ </button>
{/if}
{:else}
<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}
>
<div
</div>
<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}
</span>
</div>
{#if !readonly}
- <Button
- type="button"
- variant="ghost"
- size="sm"
- class="h-6 w-6 p-0"
- onclick={(e) => {
- e.stopPropagation();
- onRemove?.(id);
- }}
- >
- <X class="h-3 w-3" />
- </Button>
+ <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
+ <RemoveButton {id} {onRemove} />
+ </div>
{/if}
</button>
{/if}
<script lang="ts">
- import { X } from '@lucide/svelte';
- import { Button } from '$lib/components/ui/button';
+ import { RemoveButton } from '$lib/components/app';
interface Props {
id: string;
class: className = '',
// Default to small size for form previews
width = 'w-auto',
- height = 'h-24',
+ height = 'h-16',
imageClass = ''
}: Props = $props();
</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}
<button
type="button"
{#if !readonly}
<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
- 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>
+ <RemoveButton {id} {onRemove} class="text-white" />
</div>
{/if}
</div>
<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.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">
{#if IconComponent}
<IconComponent class="h-5 w-5 text-muted-foreground" />
<script lang="ts">
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 { getFileTypeCategory } from '$lib/utils/file-type';
import ChatAttachmentPreviewDialog from './ChatAttachmentPreviewDialog.svelte';
+ import ChatAttachmentsViewAllDialog from './ChatAttachmentsViewAllDialog.svelte';
+ import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
interface Props {
class?: string;
+ style?: string;
// For ChatMessage - stored attachments
attachments?: DatabaseMessageExtra[];
readonly?: boolean;
imageClass?: string;
imageHeight?: string;
imageWidth?: string;
+ // Limit display to single row with "+ X more" button
+ limitToSingleRow?: boolean;
}
let {
class: className = '',
+ style = '',
attachments = [],
readonly = false,
onFileRemove,
// Default to small size for form previews
imageClass = '',
imageHeight = 'h-24',
- imageWidth = 'w-auto'
+ imageWidth = 'w-auto',
+ limitToSingleRow = false
}: Props = $props();
let displayItems = $derived(getDisplayItems());
- // Preview dialog state
+ let canScrollLeft = $state(false);
+ let canScrollRight = $state(false);
+ let isScrollable = $state(false);
let previewDialogOpen = $state(false);
- let previewItem = $state<{
- uploadedFile?: ChatUploadedFile;
- attachment?: DatabaseMessageExtra;
- preview?: string;
- name?: string;
- type?: string;
- size?: number;
- textContent?: string;
- } | null>(null);
-
- function getDisplayItems() {
- const items: Array<{
- id: string;
- name: string;
- size?: number;
- preview?: string;
- type: string;
- isImage: boolean;
- uploadedFile?: ChatUploadedFile;
- attachment?: DatabaseMessageExtra;
- attachmentIndex?: number;
- textContent?: string;
- }> = [];
+ let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
+ let scrollContainer: HTMLDivElement | undefined = $state();
+ let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
+ let viewAllDialogOpen = $state(false);
+
+ function getDisplayItems(): ChatAttachmentDisplayItem[] {
+ const items: ChatAttachmentDisplayItem[] = [];
// Add uploaded files (ChatForm)
for (const file of uploadedFiles) {
}
}
- return items;
+ return items.reverse();
}
- function openPreview(item: (typeof displayItems)[0], event?: Event) {
- if (event) {
- event.preventDefault();
- event.stopPropagation();
- }
+ function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) {
+ event?.stopPropagation();
+ event?.preventDefault();
previewItem = {
uploadedFile: item.uploadedFile,
};
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>
{#if displayItems.length > 0}
- <div class="flex flex-wrap items-start {readonly ? 'justify-end' : ''} gap-3 {className}">
- {#each displayItems as item (item.id)}
- {#if item.isImage && 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)}
- />
- {:else}
- <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)}
- />
- {/if}
- {/each}
+ <div class={className} {style}>
+ <div class="relative">
+ <button
+ 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
+ ? 'opacity-100'
+ : 'pointer-events-none opacity-0'}"
+ onclick={scrollLeft}
+ aria-label="Scroll left"
+ >
+ <ChevronLeft class="h-4 w-4" />
+ </button>
+
+ <div
+ class="scrollbar-hide flex items-start gap-3 overflow-x-auto"
+ bind:this={scrollContainer}
+ onscroll={updateScrollButtons}
+ >
+ {#each displayItems as item (item.id)}
+ {#if item.isImage && item.preview}
+ <ChatAttachmentImagePreview
+ class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
+ id={item.id}
+ name={item.name}
+ preview={item.preview}
+ {readonly}
+ onRemove={onFileRemove}
+ height={imageHeight}
+ width={imageWidth}
+ {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>
{/if}
textContent={previewItem.textContent}
/>
{/if}
+
+<ChatAttachmentsViewAllDialog
+ bind:open={viewAllDialogOpen}
+ {uploadedFiles}
+ {attachments}
+ {readonly}
+ {onFileRemove}
+ imageHeight="h-64"
+ {imageClass}
+/>
--- /dev/null
+<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}
onsubmit={handleSubmit}
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
class="flex-column relative min-h-[48px] items-center rounded-3xl px-5 py-3 shadow-sm transition-all focus-within:shadow-md"
ondrop={handleDrop}
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 }}>
<h1 class="mb-2 text-3xl font-semibold tracking-tight">llama.cpp</h1>
<AlertDialog.Portal>
<AlertDialog.Overlay />
- <AlertDialog.Content class="max-w-md">
+ <AlertDialog.Content class="flex max-w-md flex-col">
<AlertDialog.Header>
<AlertDialog.Title>File Upload Error</AlertDialog.Title>
</AlertDialog.Description>
</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}
<div class="space-y-2">
<h4 class="text-sm font-medium text-destructive">Unsupported File Types</h4>
{#if fileErrorData.modalityUnsupported.length > 0}
<div class="space-y-2">
- <h4 class="text-sm font-medium text-destructive">Model Compatibility Issues</h4>
-
<div class="space-y-1">
{#each fileErrorData.modalityUnsupported as file (file.name)}
<div class="rounded-md bg-destructive/10 px-3 py-2">
</div>
</div>
{/if}
+ </div>
- <div class="rounded-md bg-muted/50 p-3">
- <h4 class="mb-2 text-sm font-medium">This model supports:</h4>
+ <div class="rounded-md bg-muted/50 p-3">
+ <h4 class="mb-2 text-sm font-medium">This model supports:</h4>
- <p class="text-sm text-muted-foreground">
- {fileErrorData.supportedTypes.join(', ')}
- </p>
- </div>
+ <p class="text-sm text-muted-foreground">
+ {fileErrorData.supportedTypes.join(', ')}
+ </p>
</div>
<AlertDialog.Footer>
export { default as ChatAttachmentFilePreview } from './chat/ChatAttachments/ChatAttachmentFilePreview.svelte';
export { default as ChatAttachmentImagePreview } from './chat/ChatAttachments/ChatAttachmentImagePreview.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 ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.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 ServerErrorSplash } from './server/ServerErrorSplash.svelte';
export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte';
--- /dev/null
+<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>
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 {
message: DatabaseMessage;
siblingIds: string[];