diff options
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments')
5 files changed, 872 insertions, 0 deletions
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte new file mode 100644 index 0000000..0b0bf52 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte | |||
| @@ -0,0 +1,283 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { Button } from '$lib/components/ui/button'; | ||
| 3 | import * as Alert from '$lib/components/ui/alert'; | ||
| 4 | import { SyntaxHighlightedCode } from '$lib/components/app'; | ||
| 5 | import { FileText, Image, Music, FileIcon, Eye, Info } from '@lucide/svelte'; | ||
| 6 | import { | ||
| 7 | isTextFile, | ||
| 8 | isImageFile, | ||
| 9 | isPdfFile, | ||
| 10 | isAudioFile, | ||
| 11 | getLanguageFromFilename | ||
| 12 | } from '$lib/utils'; | ||
| 13 | import { convertPDFToImage } from '$lib/utils/browser-only'; | ||
| 14 | import { modelsStore } from '$lib/stores/models.svelte'; | ||
| 15 | |||
| 16 | interface Props { | ||
| 17 | // Either an uploaded file or a stored attachment | ||
| 18 | uploadedFile?: ChatUploadedFile; | ||
| 19 | attachment?: DatabaseMessageExtra; | ||
| 20 | // For uploaded files | ||
| 21 | preview?: string; | ||
| 22 | name?: string; | ||
| 23 | textContent?: string; | ||
| 24 | // For checking vision modality | ||
| 25 | activeModelId?: string; | ||
| 26 | } | ||
| 27 | |||
| 28 | let { uploadedFile, attachment, preview, name, textContent, activeModelId }: Props = $props(); | ||
| 29 | |||
| 30 | let hasVisionModality = $derived( | ||
| 31 | activeModelId ? modelsStore.modelSupportsVision(activeModelId) : false | ||
| 32 | ); | ||
| 33 | |||
| 34 | let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File'); | ||
| 35 | |||
| 36 | // Determine file type from uploaded file or attachment | ||
| 37 | let isAudio = $derived(isAudioFile(attachment, uploadedFile)); | ||
| 38 | let isImage = $derived(isImageFile(attachment, uploadedFile)); | ||
| 39 | let isPdf = $derived(isPdfFile(attachment, uploadedFile)); | ||
| 40 | let isText = $derived(isTextFile(attachment, uploadedFile)); | ||
| 41 | |||
| 42 | let displayPreview = $derived( | ||
| 43 | uploadedFile?.preview || | ||
| 44 | (isImage && attachment && 'base64Url' in attachment ? attachment.base64Url : preview) | ||
| 45 | ); | ||
| 46 | |||
| 47 | let displayTextContent = $derived( | ||
| 48 | uploadedFile?.textContent || | ||
| 49 | (attachment && 'content' in attachment ? attachment.content : textContent) | ||
| 50 | ); | ||
| 51 | |||
| 52 | let language = $derived(getLanguageFromFilename(displayName)); | ||
| 53 | |||
| 54 | let IconComponent = $derived(() => { | ||
| 55 | if (isImage) return Image; | ||
| 56 | if (isText || isPdf) return FileText; | ||
| 57 | if (isAudio) return Music; | ||
| 58 | |||
| 59 | return FileIcon; | ||
| 60 | }); | ||
| 61 | |||
| 62 | let pdfViewMode = $state<'text' | 'pages'>('pages'); | ||
| 63 | |||
| 64 | let pdfImages = $state<string[]>([]); | ||
| 65 | |||
| 66 | let pdfImagesLoading = $state(false); | ||
| 67 | |||
| 68 | let pdfImagesError = $state<string | null>(null); | ||
| 69 | |||
| 70 | async function loadPdfImages() { | ||
| 71 | if (!isPdf || pdfImages.length > 0 || pdfImagesLoading) return; | ||
| 72 | |||
| 73 | pdfImagesLoading = true; | ||
| 74 | pdfImagesError = null; | ||
| 75 | |||
| 76 | try { | ||
| 77 | let file: File | null = null; | ||
| 78 | |||
| 79 | if (uploadedFile?.file) { | ||
| 80 | file = uploadedFile.file; | ||
| 81 | } else if (isPdf && attachment) { | ||
| 82 | // Check if we have pre-processed images | ||
| 83 | if ( | ||
| 84 | 'images' in attachment && | ||
| 85 | attachment.images && | ||
| 86 | Array.isArray(attachment.images) && | ||
| 87 | attachment.images.length > 0 | ||
| 88 | ) { | ||
| 89 | pdfImages = attachment.images; | ||
| 90 | return; | ||
| 91 | } | ||
| 92 | |||
| 93 | // Convert base64 back to File for processing | ||
| 94 | if ('base64Data' in attachment && attachment.base64Data) { | ||
| 95 | const base64Data = attachment.base64Data; | ||
| 96 | const byteCharacters = atob(base64Data); | ||
| 97 | const byteNumbers = new Array(byteCharacters.length); | ||
| 98 | for (let i = 0; i < byteCharacters.length; i++) { | ||
| 99 | byteNumbers[i] = byteCharacters.charCodeAt(i); | ||
| 100 | } | ||
| 101 | const byteArray = new Uint8Array(byteNumbers); | ||
| 102 | file = new File([byteArray], displayName, { type: 'application/pdf' }); | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | if (file) { | ||
| 107 | pdfImages = await convertPDFToImage(file); | ||
| 108 | } else { | ||
| 109 | throw new Error('No PDF file available for conversion'); | ||
| 110 | } | ||
| 111 | } catch (error) { | ||
| 112 | pdfImagesError = error instanceof Error ? error.message : 'Failed to load PDF images'; | ||
| 113 | } finally { | ||
| 114 | pdfImagesLoading = false; | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | export function reset() { | ||
| 119 | pdfImages = []; | ||
| 120 | pdfImagesLoading = false; | ||
| 121 | pdfImagesError = null; | ||
| 122 | pdfViewMode = 'pages'; | ||
| 123 | } | ||
| 124 | |||
| 125 | $effect(() => { | ||
| 126 | if (isPdf && pdfViewMode === 'pages') { | ||
| 127 | loadPdfImages(); | ||
| 128 | } | ||
| 129 | }); | ||
| 130 | </script> | ||
| 131 | |||
| 132 | <div class="space-y-4"> | ||
| 133 | <div class="flex items-center justify-end gap-6"> | ||
| 134 | {#if isPdf} | ||
| 135 | <div class="flex items-center gap-2"> | ||
| 136 | <Button | ||
| 137 | variant={pdfViewMode === 'text' ? 'default' : 'outline'} | ||
| 138 | size="sm" | ||
| 139 | onclick={() => (pdfViewMode = 'text')} | ||
| 140 | disabled={pdfImagesLoading} | ||
| 141 | > | ||
| 142 | <FileText class="mr-1 h-4 w-4" /> | ||
| 143 | |||
| 144 | Text | ||
| 145 | </Button> | ||
| 146 | |||
| 147 | <Button | ||
| 148 | variant={pdfViewMode === 'pages' ? 'default' : 'outline'} | ||
| 149 | size="sm" | ||
| 150 | onclick={() => { | ||
| 151 | pdfViewMode = 'pages'; | ||
| 152 | loadPdfImages(); | ||
| 153 | }} | ||
| 154 | disabled={pdfImagesLoading} | ||
| 155 | > | ||
| 156 | {#if pdfImagesLoading} | ||
| 157 | <div | ||
| 158 | class="mr-1 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" | ||
| 159 | ></div> | ||
| 160 | {:else} | ||
| 161 | <Eye class="mr-1 h-4 w-4" /> | ||
| 162 | {/if} | ||
| 163 | |||
| 164 | Pages | ||
| 165 | </Button> | ||
| 166 | </div> | ||
| 167 | {/if} | ||
| 168 | </div> | ||
| 169 | |||
| 170 | <div class="flex-1 overflow-auto"> | ||
| 171 | {#if isImage && displayPreview} | ||
| 172 | <div class="flex items-center justify-center"> | ||
| 173 | <img | ||
| 174 | src={displayPreview} | ||
| 175 | alt={displayName} | ||
| 176 | class="max-h-full rounded-lg object-contain shadow-lg" | ||
| 177 | /> | ||
| 178 | </div> | ||
| 179 | {:else if isPdf && pdfViewMode === 'pages'} | ||
| 180 | {#if !hasVisionModality && activeModelId} | ||
| 181 | <Alert.Root class="mb-4"> | ||
| 182 | <Info class="h-4 w-4" /> | ||
| 183 | <Alert.Title>Preview only</Alert.Title> | ||
| 184 | <Alert.Description> | ||
| 185 | <span class="inline-flex"> | ||
| 186 | The selected model does not support vision. Only the extracted | ||
| 187 | <!-- svelte-ignore a11y_click_events_have_key_events --> | ||
| 188 | <!-- svelte-ignore a11y_no_static_element_interactions --> | ||
| 189 | <span class="mx-1 cursor-pointer underline" onclick={() => (pdfViewMode = 'text')}> | ||
| 190 | text | ||
| 191 | </span> | ||
| 192 | will be sent to the model. | ||
| 193 | </span> | ||
| 194 | </Alert.Description> | ||
| 195 | </Alert.Root> | ||
| 196 | {/if} | ||
| 197 | |||
| 198 | {#if pdfImagesLoading} | ||
| 199 | <div class="flex items-center justify-center p-8"> | ||
| 200 | <div class="text-center"> | ||
| 201 | <div | ||
| 202 | class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" | ||
| 203 | ></div> | ||
| 204 | |||
| 205 | <p class="text-muted-foreground">Converting PDF to images...</p> | ||
| 206 | </div> | ||
| 207 | </div> | ||
| 208 | {:else if pdfImagesError} | ||
| 209 | <div class="flex items-center justify-center p-8"> | ||
| 210 | <div class="text-center"> | ||
| 211 | <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" /> | ||
| 212 | |||
| 213 | <p class="mb-4 text-muted-foreground">Failed to load PDF images</p> | ||
| 214 | |||
| 215 | <p class="text-sm text-muted-foreground">{pdfImagesError}</p> | ||
| 216 | |||
| 217 | <Button class="mt-4" onclick={() => (pdfViewMode = 'text')}>View as Text</Button> | ||
| 218 | </div> | ||
| 219 | </div> | ||
| 220 | {:else if pdfImages.length > 0} | ||
| 221 | <div class="max-h-[70vh] space-y-4 overflow-auto"> | ||
| 222 | {#each pdfImages as image, index (image)} | ||
| 223 | <div class="text-center"> | ||
| 224 | <p class="mb-2 text-sm text-muted-foreground">Page {index + 1}</p> | ||
| 225 | |||
| 226 | <img | ||
| 227 | src={image} | ||
| 228 | alt="PDF Page {index + 1}" | ||
| 229 | class="mx-auto max-w-full rounded-lg shadow-lg" | ||
| 230 | /> | ||
| 231 | </div> | ||
| 232 | {/each} | ||
| 233 | </div> | ||
| 234 | {:else} | ||
| 235 | <div class="flex items-center justify-center p-8"> | ||
| 236 | <div class="text-center"> | ||
| 237 | <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" /> | ||
| 238 | |||
| 239 | <p class="mb-4 text-muted-foreground">No PDF pages available</p> | ||
| 240 | </div> | ||
| 241 | </div> | ||
| 242 | {/if} | ||
| 243 | {:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent} | ||
| 244 | <SyntaxHighlightedCode code={displayTextContent} {language} maxWidth="calc(69rem - 2rem)" /> | ||
| 245 | {:else if isAudio} | ||
| 246 | <div class="flex items-center justify-center p-8"> | ||
| 247 | <div class="w-full max-w-md text-center"> | ||
| 248 | <Music class="mx-auto mb-4 h-16 w-16 text-muted-foreground" /> | ||
| 249 | |||
| 250 | {#if uploadedFile?.preview} | ||
| 251 | <audio controls class="mb-4 w-full" src={uploadedFile.preview}> | ||
| 252 | Your browser does not support the audio element. | ||
| 253 | </audio> | ||
| 254 | {:else if isAudio && attachment && 'mimeType' in attachment && 'base64Data' in attachment} | ||
| 255 | <audio | ||
| 256 | controls | ||
| 257 | class="mb-4 w-full" | ||
| 258 | src={`data:${attachment.mimeType};base64,${attachment.base64Data}`} | ||
| 259 | > | ||
| 260 | Your browser does not support the audio element. | ||
| 261 | </audio> | ||
| 262 | {:else} | ||
| 263 | <p class="mb-4 text-muted-foreground">Audio preview not available</p> | ||
| 264 | {/if} | ||
| 265 | |||
| 266 | <p class="text-sm text-muted-foreground"> | ||
| 267 | {displayName} | ||
| 268 | </p> | ||
| 269 | </div> | ||
| 270 | </div> | ||
| 271 | {:else} | ||
| 272 | <div class="flex items-center justify-center p-8"> | ||
| 273 | <div class="text-center"> | ||
| 274 | {#if IconComponent} | ||
| 275 | <IconComponent class="mx-auto mb-4 h-16 w-16 text-muted-foreground" /> | ||
| 276 | {/if} | ||
| 277 | |||
| 278 | <p class="mb-4 text-muted-foreground">Preview not available for this file type</p> | ||
| 279 | </div> | ||
| 280 | </div> | ||
| 281 | {/if} | ||
| 282 | </div> | ||
| 283 | </div> | ||
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte new file mode 100644 index 0000000..908db58 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte | |||
| @@ -0,0 +1,165 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { RemoveButton } from '$lib/components/app'; | ||
| 3 | import { formatFileSize, getFileTypeLabel, getPreviewText, isTextFile } from '$lib/utils'; | ||
| 4 | import { AttachmentType } from '$lib/enums'; | ||
| 5 | |||
| 6 | interface Props { | ||
| 7 | class?: string; | ||
| 8 | id: string; | ||
| 9 | onClick?: (event?: MouseEvent) => void; | ||
| 10 | onRemove?: (id: string) => void; | ||
| 11 | name: string; | ||
| 12 | readonly?: boolean; | ||
| 13 | size?: number; | ||
| 14 | textContent?: string; | ||
| 15 | // Either uploaded file or stored attachment | ||
| 16 | uploadedFile?: ChatUploadedFile; | ||
| 17 | attachment?: DatabaseMessageExtra; | ||
| 18 | } | ||
| 19 | |||
| 20 | let { | ||
| 21 | class: className = '', | ||
| 22 | id, | ||
| 23 | onClick, | ||
| 24 | onRemove, | ||
| 25 | name, | ||
| 26 | readonly = false, | ||
| 27 | size, | ||
| 28 | textContent, | ||
| 29 | uploadedFile, | ||
| 30 | attachment | ||
| 31 | }: Props = $props(); | ||
| 32 | |||
| 33 | let isText = $derived(isTextFile(attachment, uploadedFile)); | ||
| 34 | |||
| 35 | let fileTypeLabel = $derived.by(() => { | ||
| 36 | if (uploadedFile?.type) { | ||
| 37 | return getFileTypeLabel(uploadedFile.type); | ||
| 38 | } | ||
| 39 | |||
| 40 | if (attachment) { | ||
| 41 | if ('mimeType' in attachment && attachment.mimeType) { | ||
| 42 | return getFileTypeLabel(attachment.mimeType); | ||
| 43 | } | ||
| 44 | |||
| 45 | if (attachment.type) { | ||
| 46 | return getFileTypeLabel(attachment.type); | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | return getFileTypeLabel(name); | ||
| 51 | }); | ||
| 52 | |||
| 53 | let pdfProcessingMode = $derived.by(() => { | ||
| 54 | if (attachment?.type === AttachmentType.PDF) { | ||
| 55 | const pdfAttachment = attachment as DatabaseMessageExtraPdfFile; | ||
| 56 | |||
| 57 | return pdfAttachment.processedAsImages ? 'Sent as Image' : 'Sent as Text'; | ||
| 58 | } | ||
| 59 | return null; | ||
| 60 | }); | ||
| 61 | </script> | ||
| 62 | |||
| 63 | {#if isText} | ||
| 64 | {#if readonly} | ||
| 65 | <!-- Readonly mode (ChatMessage) --> | ||
| 66 | <button | ||
| 67 | class="cursor-pointer rounded-lg border border-border bg-muted p-3 transition-shadow hover:shadow-md {className} w-full max-w-2xl" | ||
| 68 | onclick={onClick} | ||
| 69 | aria-label={`Preview ${name}`} | ||
| 70 | type="button" | ||
| 71 | > | ||
| 72 | <div class="flex items-start gap-3"> | ||
| 73 | <div class="flex min-w-0 flex-1 flex-col items-start text-left"> | ||
| 74 | <span class="w-full truncate text-sm font-medium text-foreground">{name}</span> | ||
| 75 | |||
| 76 | {#if size} | ||
| 77 | <span class="text-xs text-muted-foreground">{formatFileSize(size)}</span> | ||
| 78 | {/if} | ||
| 79 | |||
| 80 | {#if textContent} | ||
| 81 | <div class="relative mt-2 w-full"> | ||
| 82 | <div | ||
| 83 | class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground" | ||
| 84 | > | ||
| 85 | {getPreviewText(textContent)} | ||
| 86 | </div> | ||
| 87 | |||
| 88 | {#if textContent.length > 150} | ||
| 89 | <div | ||
| 90 | class="pointer-events-none absolute right-0 bottom-0 left-0 h-6 bg-gradient-to-t from-muted to-transparent" | ||
| 91 | ></div> | ||
| 92 | {/if} | ||
| 93 | </div> | ||
| 94 | {/if} | ||
| 95 | </div> | ||
| 96 | </div> | ||
| 97 | </button> | ||
| 98 | {:else} | ||
| 99 | <!-- Non-readonly mode (ChatForm) --> | ||
| 100 | <button | ||
| 101 | class="group relative rounded-lg border border-border bg-muted p-3 {className} {textContent | ||
| 102 | ? 'max-h-24 max-w-72' | ||
| 103 | : 'max-w-36'} cursor-pointer text-left" | ||
| 104 | onclick={onClick} | ||
| 105 | > | ||
| 106 | <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100"> | ||
| 107 | <RemoveButton {id} {onRemove} /> | ||
| 108 | </div> | ||
| 109 | |||
| 110 | <div class="pr-8"> | ||
| 111 | <span class="mb-3 block truncate text-sm font-medium text-foreground">{name}</span> | ||
| 112 | |||
| 113 | {#if textContent} | ||
| 114 | <div class="relative"> | ||
| 115 | <div | ||
| 116 | class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground" | ||
| 117 | style="max-height: 3rem; line-height: 1.2em;" | ||
| 118 | > | ||
| 119 | {getPreviewText(textContent)} | ||
| 120 | </div> | ||
| 121 | |||
| 122 | {#if textContent.length > 150} | ||
| 123 | <div | ||
| 124 | class="pointer-events-none absolute right-0 bottom-0 left-0 h-4 bg-gradient-to-t from-muted to-transparent" | ||
| 125 | ></div> | ||
| 126 | {/if} | ||
| 127 | </div> | ||
| 128 | {/if} | ||
| 129 | </div> | ||
| 130 | </button> | ||
| 131 | {/if} | ||
| 132 | {:else} | ||
| 133 | <button | ||
| 134 | class="group flex items-center gap-3 rounded-lg border border-border bg-muted p-3 {className} relative" | ||
| 135 | onclick={onClick} | ||
| 136 | > | ||
| 137 | <div | ||
| 138 | class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary" | ||
| 139 | > | ||
| 140 | {fileTypeLabel} | ||
| 141 | </div> | ||
| 142 | |||
| 143 | <div class="flex flex-col gap-0.5"> | ||
| 144 | <span | ||
| 145 | class="max-w-24 truncate text-sm font-medium text-foreground {readonly | ||
| 146 | ? '' | ||
| 147 | : 'group-hover:pr-6'} md:max-w-32" | ||
| 148 | > | ||
| 149 | {name} | ||
| 150 | </span> | ||
| 151 | |||
| 152 | {#if pdfProcessingMode} | ||
| 153 | <span class="text-left text-xs text-muted-foreground">{pdfProcessingMode}</span> | ||
| 154 | {:else if size} | ||
| 155 | <span class="text-left text-xs text-muted-foreground">{formatFileSize(size)}</span> | ||
| 156 | {/if} | ||
| 157 | </div> | ||
| 158 | |||
| 159 | {#if !readonly} | ||
| 160 | <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100"> | ||
| 161 | <RemoveButton {id} {onRemove} /> | ||
| 162 | </div> | ||
| 163 | {/if} | ||
| 164 | </button> | ||
| 165 | {/if} | ||
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte new file mode 100644 index 0000000..ba711a9 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte | |||
| @@ -0,0 +1,64 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { RemoveButton } from '$lib/components/app'; | ||
| 3 | |||
| 4 | interface Props { | ||
| 5 | id: string; | ||
| 6 | name: string; | ||
| 7 | preview: string; | ||
| 8 | readonly?: boolean; | ||
| 9 | onRemove?: (id: string) => void; | ||
| 10 | onClick?: (event?: MouseEvent) => void; | ||
| 11 | class?: string; | ||
| 12 | // Customizable size props | ||
| 13 | width?: string; | ||
| 14 | height?: string; | ||
| 15 | imageClass?: string; | ||
| 16 | } | ||
| 17 | |||
| 18 | let { | ||
| 19 | id, | ||
| 20 | name, | ||
| 21 | preview, | ||
| 22 | readonly = false, | ||
| 23 | onRemove, | ||
| 24 | onClick, | ||
| 25 | class: className = '', | ||
| 26 | // Default to small size for form previews | ||
| 27 | width = 'w-auto', | ||
| 28 | height = 'h-16', | ||
| 29 | imageClass = '' | ||
| 30 | }: Props = $props(); | ||
| 31 | </script> | ||
| 32 | |||
| 33 | <div | ||
| 34 | class="group relative overflow-hidden rounded-lg bg-muted shadow-lg dark:border dark:border-muted {className}" | ||
| 35 | > | ||
| 36 | {#if onClick} | ||
| 37 | <button | ||
| 38 | type="button" | ||
| 39 | class="block h-full w-full rounded-lg focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:outline-none" | ||
| 40 | onclick={onClick} | ||
| 41 | aria-label="Preview {name}" | ||
| 42 | > | ||
| 43 | <img | ||
| 44 | src={preview} | ||
| 45 | alt={name} | ||
| 46 | class="{height} {width} cursor-pointer object-cover {imageClass}" | ||
| 47 | /> | ||
| 48 | </button> | ||
| 49 | {:else} | ||
| 50 | <img | ||
| 51 | src={preview} | ||
| 52 | alt={name} | ||
| 53 | class="{height} {width} cursor-pointer object-cover {imageClass}" | ||
| 54 | /> | ||
| 55 | {/if} | ||
| 56 | |||
| 57 | {#if !readonly} | ||
| 58 | <div | ||
| 59 | class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100" | ||
| 60 | > | ||
| 61 | <RemoveButton {id} {onRemove} class="text-white" /> | ||
| 62 | </div> | ||
| 63 | {/if} | ||
| 64 | </div> | ||
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte new file mode 100644 index 0000000..a1f5af5 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte | |||
| @@ -0,0 +1,243 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { ChatAttachmentThumbnailImage, ChatAttachmentThumbnailFile } from '$lib/components/app'; | ||
| 3 | import { Button } from '$lib/components/ui/button'; | ||
| 4 | import { ChevronLeft, ChevronRight } from '@lucide/svelte'; | ||
| 5 | import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app'; | ||
| 6 | import { getAttachmentDisplayItems } from '$lib/utils'; | ||
| 7 | |||
| 8 | interface Props { | ||
| 9 | class?: string; | ||
| 10 | style?: string; | ||
| 11 | // For ChatMessage - stored attachments | ||
| 12 | attachments?: DatabaseMessageExtra[]; | ||
| 13 | readonly?: boolean; | ||
| 14 | // For ChatForm - pending uploads | ||
| 15 | onFileRemove?: (fileId: string) => void; | ||
| 16 | uploadedFiles?: ChatUploadedFile[]; | ||
| 17 | // Image size customization | ||
| 18 | imageClass?: string; | ||
| 19 | imageHeight?: string; | ||
| 20 | imageWidth?: string; | ||
| 21 | // Limit display to single row with "+ X more" button | ||
| 22 | limitToSingleRow?: boolean; | ||
| 23 | // For vision modality check | ||
| 24 | activeModelId?: string; | ||
| 25 | } | ||
| 26 | |||
| 27 | let { | ||
| 28 | class: className = '', | ||
| 29 | style = '', | ||
| 30 | attachments = [], | ||
| 31 | readonly = false, | ||
| 32 | onFileRemove, | ||
| 33 | uploadedFiles = $bindable([]), | ||
| 34 | // Default to small size for form previews | ||
| 35 | imageClass = '', | ||
| 36 | imageHeight = 'h-24', | ||
| 37 | imageWidth = 'w-auto', | ||
| 38 | limitToSingleRow = false, | ||
| 39 | activeModelId | ||
| 40 | }: Props = $props(); | ||
| 41 | |||
| 42 | let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments })); | ||
| 43 | |||
| 44 | let canScrollLeft = $state(false); | ||
| 45 | let canScrollRight = $state(false); | ||
| 46 | let isScrollable = $state(false); | ||
| 47 | let previewDialogOpen = $state(false); | ||
| 48 | let previewItem = $state<ChatAttachmentPreviewItem | null>(null); | ||
| 49 | let scrollContainer: HTMLDivElement | undefined = $state(); | ||
| 50 | let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable); | ||
| 51 | let viewAllDialogOpen = $state(false); | ||
| 52 | |||
| 53 | function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) { | ||
| 54 | event?.stopPropagation(); | ||
| 55 | event?.preventDefault(); | ||
| 56 | |||
| 57 | previewItem = { | ||
| 58 | uploadedFile: item.uploadedFile, | ||
| 59 | attachment: item.attachment, | ||
| 60 | preview: item.preview, | ||
| 61 | name: item.name, | ||
| 62 | size: item.size, | ||
| 63 | textContent: item.textContent | ||
| 64 | }; | ||
| 65 | previewDialogOpen = true; | ||
| 66 | } | ||
| 67 | |||
| 68 | function scrollLeft(event?: MouseEvent) { | ||
| 69 | event?.stopPropagation(); | ||
| 70 | event?.preventDefault(); | ||
| 71 | |||
| 72 | if (!scrollContainer) return; | ||
| 73 | |||
| 74 | scrollContainer.scrollBy({ left: scrollContainer.clientWidth * -0.67, behavior: 'smooth' }); | ||
| 75 | } | ||
| 76 | |||
| 77 | function scrollRight(event?: MouseEvent) { | ||
| 78 | event?.stopPropagation(); | ||
| 79 | event?.preventDefault(); | ||
| 80 | |||
| 81 | if (!scrollContainer) return; | ||
| 82 | |||
| 83 | scrollContainer.scrollBy({ left: scrollContainer.clientWidth * 0.67, behavior: 'smooth' }); | ||
| 84 | } | ||
| 85 | |||
| 86 | function updateScrollButtons() { | ||
| 87 | if (!scrollContainer) return; | ||
| 88 | |||
| 89 | const { scrollLeft, scrollWidth, clientWidth } = scrollContainer; | ||
| 90 | |||
| 91 | canScrollLeft = scrollLeft > 0; | ||
| 92 | canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; | ||
| 93 | isScrollable = scrollWidth > clientWidth; | ||
| 94 | } | ||
| 95 | |||
| 96 | $effect(() => { | ||
| 97 | if (scrollContainer && displayItems.length) { | ||
| 98 | scrollContainer.scrollLeft = 0; | ||
| 99 | |||
| 100 | setTimeout(() => { | ||
| 101 | updateScrollButtons(); | ||
| 102 | }, 0); | ||
| 103 | } | ||
| 104 | }); | ||
| 105 | </script> | ||
| 106 | |||
| 107 | {#if displayItems.length > 0} | ||
| 108 | <div class={className} {style}> | ||
| 109 | {#if limitToSingleRow} | ||
| 110 | <div class="relative"> | ||
| 111 | <button | ||
| 112 | 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 | ||
| 113 | ? 'opacity-100' | ||
| 114 | : 'pointer-events-none opacity-0'}" | ||
| 115 | onclick={scrollLeft} | ||
| 116 | aria-label="Scroll left" | ||
| 117 | > | ||
| 118 | <ChevronLeft class="h-4 w-4" /> | ||
| 119 | </button> | ||
| 120 | |||
| 121 | <div | ||
| 122 | class="scrollbar-hide flex items-start gap-3 overflow-x-auto" | ||
| 123 | bind:this={scrollContainer} | ||
| 124 | onscroll={updateScrollButtons} | ||
| 125 | > | ||
| 126 | {#each displayItems as item (item.id)} | ||
| 127 | {#if item.isImage && item.preview} | ||
| 128 | <ChatAttachmentThumbnailImage | ||
| 129 | class="flex-shrink-0 cursor-pointer {limitToSingleRow | ||
| 130 | ? 'first:ml-4 last:mr-4' | ||
| 131 | : ''}" | ||
| 132 | id={item.id} | ||
| 133 | name={item.name} | ||
| 134 | preview={item.preview} | ||
| 135 | {readonly} | ||
| 136 | onRemove={onFileRemove} | ||
| 137 | height={imageHeight} | ||
| 138 | width={imageWidth} | ||
| 139 | {imageClass} | ||
| 140 | onClick={(event) => openPreview(item, event)} | ||
| 141 | /> | ||
| 142 | {:else} | ||
| 143 | <ChatAttachmentThumbnailFile | ||
| 144 | class="flex-shrink-0 cursor-pointer {limitToSingleRow | ||
| 145 | ? 'first:ml-4 last:mr-4' | ||
| 146 | : ''}" | ||
| 147 | id={item.id} | ||
| 148 | name={item.name} | ||
| 149 | size={item.size} | ||
| 150 | {readonly} | ||
| 151 | onRemove={onFileRemove} | ||
| 152 | textContent={item.textContent} | ||
| 153 | attachment={item.attachment} | ||
| 154 | uploadedFile={item.uploadedFile} | ||
| 155 | onClick={(event) => openPreview(item, event)} | ||
| 156 | /> | ||
| 157 | {/if} | ||
| 158 | {/each} | ||
| 159 | </div> | ||
| 160 | |||
| 161 | <button | ||
| 162 | 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 | ||
| 163 | ? 'opacity-100' | ||
| 164 | : 'pointer-events-none opacity-0'}" | ||
| 165 | onclick={scrollRight} | ||
| 166 | aria-label="Scroll right" | ||
| 167 | > | ||
| 168 | <ChevronRight class="h-4 w-4" /> | ||
| 169 | </button> | ||
| 170 | </div> | ||
| 171 | |||
| 172 | {#if showViewAll} | ||
| 173 | <div class="mt-2 -mr-2 flex justify-end px-4"> | ||
| 174 | <Button | ||
| 175 | type="button" | ||
| 176 | variant="ghost" | ||
| 177 | size="sm" | ||
| 178 | class="h-6 text-xs text-muted-foreground hover:text-foreground" | ||
| 179 | onclick={() => (viewAllDialogOpen = true)} | ||
| 180 | > | ||
| 181 | View all ({displayItems.length}) | ||
| 182 | </Button> | ||
| 183 | </div> | ||
| 184 | {/if} | ||
| 185 | {:else} | ||
| 186 | <div class="flex flex-wrap items-start justify-end gap-3"> | ||
| 187 | {#each displayItems as item (item.id)} | ||
| 188 | {#if item.isImage && item.preview} | ||
| 189 | <ChatAttachmentThumbnailImage | ||
| 190 | class="cursor-pointer" | ||
| 191 | id={item.id} | ||
| 192 | name={item.name} | ||
| 193 | preview={item.preview} | ||
| 194 | {readonly} | ||
| 195 | onRemove={onFileRemove} | ||
| 196 | height={imageHeight} | ||
| 197 | width={imageWidth} | ||
| 198 | {imageClass} | ||
| 199 | onClick={(event) => openPreview(item, event)} | ||
| 200 | /> | ||
| 201 | {:else} | ||
| 202 | <ChatAttachmentThumbnailFile | ||
| 203 | class="cursor-pointer" | ||
| 204 | id={item.id} | ||
| 205 | name={item.name} | ||
| 206 | size={item.size} | ||
| 207 | {readonly} | ||
| 208 | onRemove={onFileRemove} | ||
| 209 | textContent={item.textContent} | ||
| 210 | attachment={item.attachment} | ||
| 211 | uploadedFile={item.uploadedFile} | ||
| 212 | onClick={(event?: MouseEvent) => openPreview(item, event)} | ||
| 213 | /> | ||
| 214 | {/if} | ||
| 215 | {/each} | ||
| 216 | </div> | ||
| 217 | {/if} | ||
| 218 | </div> | ||
| 219 | {/if} | ||
| 220 | |||
| 221 | {#if previewItem} | ||
| 222 | <DialogChatAttachmentPreview | ||
| 223 | bind:open={previewDialogOpen} | ||
| 224 | uploadedFile={previewItem.uploadedFile} | ||
| 225 | attachment={previewItem.attachment} | ||
| 226 | preview={previewItem.preview} | ||
| 227 | name={previewItem.name} | ||
| 228 | size={previewItem.size} | ||
| 229 | textContent={previewItem.textContent} | ||
| 230 | {activeModelId} | ||
| 231 | /> | ||
| 232 | {/if} | ||
| 233 | |||
| 234 | <DialogChatAttachmentsViewAll | ||
| 235 | bind:open={viewAllDialogOpen} | ||
| 236 | {uploadedFiles} | ||
| 237 | {attachments} | ||
| 238 | {readonly} | ||
| 239 | {onFileRemove} | ||
| 240 | imageHeight="h-64" | ||
| 241 | {imageClass} | ||
| 242 | {activeModelId} | ||
| 243 | /> | ||
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte new file mode 100644 index 0000000..279b2e2 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte | |||
| @@ -0,0 +1,117 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { | ||
| 3 | ChatAttachmentThumbnailImage, | ||
| 4 | ChatAttachmentThumbnailFile, | ||
| 5 | DialogChatAttachmentPreview | ||
| 6 | } from '$lib/components/app'; | ||
| 7 | import { getAttachmentDisplayItems } from '$lib/utils'; | ||
| 8 | |||
| 9 | interface Props { | ||
| 10 | uploadedFiles?: ChatUploadedFile[]; | ||
| 11 | attachments?: DatabaseMessageExtra[]; | ||
| 12 | readonly?: boolean; | ||
| 13 | onFileRemove?: (fileId: string) => void; | ||
| 14 | imageHeight?: string; | ||
| 15 | imageWidth?: string; | ||
| 16 | imageClass?: string; | ||
| 17 | activeModelId?: string; | ||
| 18 | } | ||
| 19 | |||
| 20 | let { | ||
| 21 | uploadedFiles = [], | ||
| 22 | attachments = [], | ||
| 23 | readonly = false, | ||
| 24 | onFileRemove, | ||
| 25 | imageHeight = 'h-24', | ||
| 26 | imageWidth = 'w-auto', | ||
| 27 | imageClass = '', | ||
| 28 | activeModelId | ||
| 29 | }: Props = $props(); | ||
| 30 | |||
| 31 | let previewDialogOpen = $state(false); | ||
| 32 | let previewItem = $state<ChatAttachmentPreviewItem | null>(null); | ||
| 33 | |||
| 34 | let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments })); | ||
| 35 | let imageItems = $derived(displayItems.filter((item) => item.isImage)); | ||
| 36 | let fileItems = $derived(displayItems.filter((item) => !item.isImage)); | ||
| 37 | |||
| 38 | function openPreview(item: (typeof displayItems)[0], event?: Event) { | ||
| 39 | if (event) { | ||
| 40 | event.preventDefault(); | ||
| 41 | event.stopPropagation(); | ||
| 42 | } | ||
| 43 | |||
| 44 | previewItem = { | ||
| 45 | uploadedFile: item.uploadedFile, | ||
| 46 | attachment: item.attachment, | ||
| 47 | preview: item.preview, | ||
| 48 | name: item.name, | ||
| 49 | size: item.size, | ||
| 50 | textContent: item.textContent | ||
| 51 | }; | ||
| 52 | previewDialogOpen = true; | ||
| 53 | } | ||
| 54 | </script> | ||
| 55 | |||
| 56 | <div class="space-y-4"> | ||
| 57 | <div class="min-h-0 flex-1 space-y-6 overflow-y-auto px-1"> | ||
| 58 | {#if fileItems.length > 0} | ||
| 59 | <div> | ||
| 60 | <h3 class="mb-3 text-sm font-medium text-foreground">Files ({fileItems.length})</h3> | ||
| 61 | <div class="flex flex-wrap items-start gap-3"> | ||
| 62 | {#each fileItems as item (item.id)} | ||
| 63 | <ChatAttachmentThumbnailFile | ||
| 64 | class="cursor-pointer" | ||
| 65 | id={item.id} | ||
| 66 | name={item.name} | ||
| 67 | size={item.size} | ||
| 68 | {readonly} | ||
| 69 | onRemove={onFileRemove} | ||
| 70 | textContent={item.textContent} | ||
| 71 | attachment={item.attachment} | ||
| 72 | uploadedFile={item.uploadedFile} | ||
| 73 | onClick={(event?: MouseEvent) => openPreview(item, event)} | ||
| 74 | /> | ||
| 75 | {/each} | ||
| 76 | </div> | ||
| 77 | </div> | ||
| 78 | {/if} | ||
| 79 | |||
| 80 | {#if imageItems.length > 0} | ||
| 81 | <div> | ||
| 82 | <h3 class="mb-3 text-sm font-medium text-foreground">Images ({imageItems.length})</h3> | ||
| 83 | <div class="flex flex-wrap items-start gap-3"> | ||
| 84 | {#each imageItems as item (item.id)} | ||
| 85 | {#if item.preview} | ||
| 86 | <ChatAttachmentThumbnailImage | ||
| 87 | class="cursor-pointer" | ||
| 88 | id={item.id} | ||
| 89 | name={item.name} | ||
| 90 | preview={item.preview} | ||
| 91 | {readonly} | ||
| 92 | onRemove={onFileRemove} | ||
| 93 | height={imageHeight} | ||
| 94 | width={imageWidth} | ||
| 95 | {imageClass} | ||
| 96 | onClick={(event) => openPreview(item, event)} | ||
| 97 | /> | ||
| 98 | {/if} | ||
| 99 | {/each} | ||
| 100 | </div> | ||
| 101 | </div> | ||
| 102 | {/if} | ||
| 103 | </div> | ||
| 104 | </div> | ||
| 105 | |||
| 106 | {#if previewItem} | ||
| 107 | <DialogChatAttachmentPreview | ||
| 108 | bind:open={previewDialogOpen} | ||
| 109 | uploadedFile={previewItem.uploadedFile} | ||
| 110 | attachment={previewItem.attachment} | ||
| 111 | preview={previewItem.preview} | ||
| 112 | name={previewItem.name} | ||
| 113 | size={previewItem.size} | ||
| 114 | textContent={previewItem.textContent} | ||
| 115 | {activeModelId} | ||
| 116 | /> | ||
| 117 | {/if} | ||
