소스 검색

Better UX for handling multiple attachments in WebUI (#17246)

Aleksander Grygier 2 달 전
부모
커밋
f1bad23f88

BIN
tools/server/public/index.html.gz


+ 19 - 29
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentFilePreview.svelte

@@ -1,6 +1,5 @@
 <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';
 
@@ -66,17 +65,15 @@
 		</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>
@@ -85,7 +82,7 @@
 					<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>
@@ -98,11 +95,11 @@
 					</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
@@ -112,7 +109,9 @@
 		</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>
 
@@ -122,18 +121,9 @@
 		</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}

+ 5 - 14
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentImagePreview.svelte

@@ -1,6 +1,5 @@
 <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;
@@ -26,12 +25,12 @@
 		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"
@@ -55,17 +54,9 @@
 
 	{#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>

+ 1 - 1
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreviewDialog.svelte

@@ -153,7 +153,7 @@
 <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" />

+ 143 - 60
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte

@@ -1,11 +1,16 @@
 <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;
@@ -16,10 +21,13 @@
 		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,
@@ -27,36 +35,23 @@
 		// 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) {
@@ -127,14 +122,12 @@
 			}
 		}
 
-		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,
@@ -147,38 +140,118 @@
 		};
 		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}
 
@@ -194,3 +267,13 @@
 		textContent={previewItem.textContent}
 	/>
 {/if}
+
+<ChatAttachmentsViewAllDialog
+	bind:open={viewAllDialogOpen}
+	{uploadedFiles}
+	{attachments}
+	{readonly}
+	{onFileRemove}
+	imageHeight="h-64"
+	{imageClass}
+/>

+ 203 - 0
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAllDialog.svelte

@@ -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}

+ 7 - 1
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte

@@ -232,7 +232,13 @@
 	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"

+ 9 - 11
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte

@@ -333,7 +333,7 @@
 		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>
 
@@ -368,7 +368,7 @@
 	<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>
 
@@ -377,7 +377,7 @@
 				</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>
@@ -398,8 +398,6 @@
 
 				{#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">
@@ -415,14 +413,14 @@
 						</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>

+ 3 - 0
tools/server/webui/src/lib/components/app/index.ts

@@ -2,6 +2,7 @@ export { default as ChatAttachmentsList } from './chat/ChatAttachments/ChatAttac
 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';
@@ -42,6 +43,8 @@ export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.sve
 
 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';

+ 26 - 0
tools/server/webui/src/lib/components/app/misc/RemoveButton.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>

+ 23 - 0
tools/server/webui/src/lib/types/chat.d.ts

@@ -11,6 +11,29 @@ export interface ChatUploadedFile {
 	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[];