initial attachment improvements w/ preview on reload

deci committed Jun 23, 2025 at 15:37 UTC f85e0707f9b6e4ddbf128d313681604933fed07c
7 files changed +381 -169
python/api/image_get.py
+76 -10
@@ -16,19 +16,85 @@ class ImageGet(ApiHandler):
16 if not files.is_in_base_dir(path):
17 raise ValueError("Path is outside of allowed directory")
18
19 - # check if file has an image extension
20 - # list of allowed image extensions
21 - allowed_extensions = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"]
19 # get file extension
20 file_ext = os.path.splitext(path)[1].lower()
24 - if file_ext not in allowed_extensions:
25 - raise ValueError(f"File type not allowed. Allowed types: {', '.join(allowed_extensions)}")
21
27 - # check if file exists
28 - if not os.path.exists(path):
29 - raise ValueError("File not found")
22 + # list of allowed image extensions
23 + image_extensions = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"]
24 +
25 + if file_ext in image_extensions:
26 + # Handle image files
27 + if not os.path.exists(path):
28 + # If image doesn't exist, return default image icon
29 + return self._get_fallback_icon("image")
30 +
31 + # send actual image file
32 + return send_file(path)
33 + else:
34 + # Handle non-image files with fallback icons
35 + return self._get_file_type_icon(file_ext)
36 +
37 + def _get_file_type_icon(self, file_ext):
38 + """Return appropriate icon for file type"""
39 + # Map file extensions to icon names
40 + icon_mapping = {
41 + # Archive files
42 + '.zip': 'archive',
43 + '.rar': 'archive',
44 + '.7z': 'archive',
45 + '.tar': 'archive',
46 + '.gz': 'archive',
47 +
48 + # Document files
49 + '.pdf': 'document',
50 + '.doc': 'document',
51 + '.docx': 'document',
52 + '.txt': 'document',
53 + '.rtf': 'document',
54 + '.odt': 'document',
55 +
56 + # Code files
57 + '.py': 'code',
58 + '.js': 'code',
59 + '.html': 'code',
60 + '.css': 'code',
61 + '.json': 'code',
62 + '.xml': 'code',
63 + '.md': 'code',
64 + '.yml': 'code',
65 + '.yaml': 'code',
66 + '.sql': 'code',
67 + '.sh': 'code',
68 + '.bat': 'code',
69 +
70 + # Spreadsheet files
71 + '.xls': 'document',
72 + '.xlsx': 'document',
73 + '.csv': 'document',
74
31 - # send file
32 - return send_file(path)
75 + # Presentation files
76 + '.ppt': 'document',
77 + '.pptx': 'document',
78 + '.odp': 'document',
79 + }
80 +
81 + # Get icon name, default to 'file' if not found
82 + icon_name = icon_mapping.get(file_ext, 'file')
83 + return self._get_fallback_icon(icon_name)
84 +
85 + def _get_fallback_icon(self, icon_name):
86 + """Return fallback icon from public directory"""
87 + # Path to public icons
88 + icon_path = files.get_abs_path(f"webui/public/{icon_name}.svg")
89 +
90 + # Check if specific icon exists, fallback to generic file icon
91 + if not os.path.exists(icon_path):
92 + icon_path = files.get_abs_path("webui/public/file.svg")
93 +
94 + # Final fallback if file.svg doesn't exist
95 + if not os.path.exists(icon_path):
96 + raise ValueError(f"Fallback icon not found: {icon_path}")
97 +
98 + return send_file(icon_path, mimetype='image/svg+xml')
99
100
\ No newline at end of file
webui/components/chat/attachments/attachmentsStore.js new
+195
@@ -0,0 +1,195 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +
3 +const model = {
4 + // State properties
5 + attachments: [],
6 + hasAttachments: false,
7 + dragDropOverlayVisible: false,
8 +
9 + // Initialize the store
10 + async initialize() {
11 + // Setup event listeners for drag and drop
12 + this.setupDragDropHandlers();
13 + // Setup paste event listener for clipboard images
14 + this.setupPasteHandler();
15 + },
16 +
17 + // Basic attachment management methods
18 + addAttachment(attachment) {
19 + // Validate for duplicates
20 + if (this.validateDuplicates(attachment)) {
21 + this.attachments.push(attachment);
22 + this.updateAttachmentState();
23 + }
24 + },
25 +
26 + removeAttachment(index) {
27 + if (index >= 0 && index < this.attachments.length) {
28 + this.attachments.splice(index, 1);
29 + this.updateAttachmentState();
30 + }
31 + },
32 +
33 + clearAttachments() {
34 + this.attachments = [];
35 + this.updateAttachmentState();
36 + },
37 +
38 + validateDuplicates(newAttachment) {
39 + // Check if attachment already exists based on name and size
40 + const isDuplicate = this.attachments.some(existing =>
41 + existing.name === newAttachment.name &&
42 + existing.file && newAttachment.file &&
43 + existing.file.size === newAttachment.file.size
44 + );
45 + return !isDuplicate;
46 + },
47 +
48 + updateAttachmentState() {
49 + this.hasAttachments = this.attachments.length > 0;
50 + },
51 +
52 + // Drag drop overlay control methods
53 + showDragDropOverlay() {
54 + this.dragDropOverlayVisible = true;
55 + },
56 +
57 + hideDragDropOverlay() {
58 + this.dragDropOverlayVisible = false;
59 + },
60 +
61 + // Setup drag and drop event handlers
62 + setupDragDropHandlers() {
63 + let dragCounter = 0;
64 +
65 + // Prevent default drag behaviors
66 + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
67 + document.addEventListener(eventName, (e) => {
68 + e.preventDefault();
69 + e.stopPropagation();
70 + }, false);
71 + });
72 +
73 + // Handle drag enter
74 + document.addEventListener('dragenter', (e) => {
75 + dragCounter++;
76 + if (dragCounter === 1) {
77 + this.showDragDropOverlay();
78 + }
79 + }, false);
80 +
81 + // Handle drag leave
82 + document.addEventListener('dragleave', (e) => {
83 + dragCounter--;
84 + if (dragCounter === 0) {
85 + this.hideDragDropOverlay();
86 + }
87 + }, false);
88 +
89 + // Handle drop
90 + document.addEventListener('drop', (e) => {
91 + dragCounter = 0;
92 + this.hideDragDropOverlay();
93 +
94 + const files = e.dataTransfer.files;
95 + this.handleFiles(files);
96 + }, false);
97 + },
98 +
99 + // Setup paste event handler for clipboard images
100 + setupPasteHandler() {
101 + document.addEventListener('paste', (e) => {
102 + const items = e.clipboardData.items;
103 + for (let i = 0; i < items.length; i++) {
104 + const item = items[i];
105 + if (item.type.indexOf('image') !== -1) {
106 + const blob = item.getAsFile();
107 + this.handleClipboardImage(blob);
108 + }
109 + }
110 + });
111 + },
112 +
113 + // Handle clipboard image pasting
114 + async handleClipboardImage(blob) {
115 + // Generate unique filename
116 + const guid = this.generateGUID();
117 + const filename = `clipboard-${guid}.png`;
118 +
119 + // Create file object from blob
120 + const file = new File([blob], filename, { type: 'image/png' });
121 +
122 + // Create attachment object
123 + const attachment = {
124 + file: file,
125 + type: 'image',
126 + name: filename,
127 + extension: 'png'
128 + };
129 +
130 + // Read as data URL for preview
131 + const reader = new FileReader();
132 + reader.onload = (e) => {
133 + attachment.url = e.target.result;
134 + this.addAttachment(attachment);
135 + };
136 + reader.readAsDataURL(file);
137 + },
138 +
139 + // File handling logic (moved from index.js)
140 + handleFiles(files) {
141 + Array.from(files).forEach(file => {
142 + const ext = file.name.split('.').pop().toLowerCase();
143 + const isImage = ['jpg', 'jpeg', 'png', 'bmp', 'gif', 'webp'].includes(ext);
144 +
145 + const attachment = {
146 + file: file,
147 + type: isImage ? 'image' : 'file',
148 + name: file.name,
149 + extension: ext
150 + };
151 +
152 + if (isImage) {
153 + // Read image as data URL for preview
154 + const reader = new FileReader();
155 + reader.onload = (e) => {
156 + attachment.url = e.target.result;
157 + this.addAttachment(attachment);
158 + };
159 + reader.readAsDataURL(file);
160 + } else {
161 + // For non-image files, add directly
162 + this.addAttachment(attachment);
163 + }
164 + });
165 + },
166 +
167 + // Get attachments for sending message
168 + getAttachmentsForSending() {
169 + return this.attachments.map(attachment => {
170 + if (attachment.type === 'image') {
171 + return {
172 + ...attachment,
173 + url: URL.createObjectURL(attachment.file)
174 + };
175 + } else {
176 + return {
177 + ...attachment
178 + };
179 + }
180 + });
181 + },
182 +
183 + // Generate GUID for unique filenames
184 + generateGUID() {
185 + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
186 + const r = Math.random() * 16 | 0;
187 + const v = c == 'x' ? r : (r & 0x3 | 0x8);
188 + return v.toString(16);
189 + });
190 + }
191 +};
192 +
193 +const store = createStore("chatAttachments", model);
194 +
195 +export { store };
\ No newline at end of file
webui/components/chat/attachments/dragDropOverlay.html new
+29
@@ -0,0 +1,29 @@
1 +<html>
2 +
3 +<head>
4 + <title>Drag Drop Overlay</title>
5 +
6 + <script type="module">
7 + import { store } from "/components/chat/attachments/attachmentsStore.js";
8 + </script>
9 +</head>
10 +
11 +<body>
12 + <!-- Drag and Drop Overlay -->
13 + <div id="dragdrop-overlay"
14 + x-cloak
15 + x-show="$store.chatAttachments.dragDropOverlayVisible"
16 + x-transition:enter="transition ease-out duration-300"
17 + x-transition:enter-start="opacity-0"
18 + x-transition:enter-end="opacity-100"
19 + x-transition:leave="transition ease-in duration-300"
20 + x-transition:leave-start="opacity-100"
21 + x-transition:leave-end="opacity-0"
22 + class="dragdrop-overlay">
23 + <img src="public/dragndrop.svg" alt="Drop files" class="dragdrop-icon">
24 + <div class="dragdrop-text">Drop files to attach them to your message</div>
25 + <div class="dragdrop-subtext"></div>
26 + </div>
27 +</body>
28 +
29 +</html>
\ No newline at end of file
webui/index.html
+8 -58
@@ -101,6 +101,7 @@
101 <script type="module" src="js/scheduler.js"></script>
102 <script type="module" src="js/speech.js"></script>
103 <script type="module" src="js/history.js"></script>
104 + <script type="module" src="components/chat/attachments/attachmentsStore.js"></script>
105 <script type="module" src="index.js"></script>
106
107 <!-- Then load Alpine.js -->
@@ -370,49 +371,12 @@
371 </h4>
372 </div>
373 <div id="input-section" x-data="{
373 - paused: false,
374 - attachments: [],
375 - hasAttachments: false,
376 -
377 - handleFileUpload(event) {
378 - const files = event.target.files;
379 -
380 - Array.from(files).forEach(file => {
381 - const ext = file.name.split('.').pop().toLowerCase();
382 -
383 - const isImage = ['jpg', 'jpeg', 'png', 'bmp'].includes(ext);
384 -
385 - if (isImage) {
386 - // Handle image preview
387 - const reader = new FileReader();
388 - reader.onload = e => {
389 - this.attachments.push({
390 - file: file,
391 - url: e.target.result,
392 - type: 'image',
393 - name: file.name,
394 - extension: ext
395 - });
396 - this.hasAttachments = true;
397 - };
398 - reader.readAsDataURL(file);
399 - } else {
400 - // Handle other file types
401 - this.attachments.push({
402 - file: file,
403 - type: 'file',
404 - name: file.name,
405 - extension: ext
406 - });
407 - this.hasAttachments = true;
408 - }
409 - });
410 - }
374 + paused: false
375 }">
376
377 <!-- Preview section -->
414 - <div x-show="hasAttachments" class="preview-section">
415 - <template x-for="(attachment, index) in attachments" :key="index">
378 + <div x-show="$store.chatAttachments.hasAttachments" class="preview-section">
379 + <template x-for="(attachment, index) in $store.chatAttachments.attachments" :key="index">
380 <div class="preview-item" :class="{'image-preview': attachment.type === 'image'}">
381 <template x-if="attachment.type === 'image'">
382 <img :src="attachment.url" :alt="attachment.name">
@@ -423,7 +387,7 @@
387 <span class="extension" x-text="attachment.extension.toUpperCase()"></span>
388 </div>
389 </template>
426 - <button @click="attachments.splice(index, 1); hasAttachments = attachments.length > 0"
390 + <button @click="$store.chatAttachments.removeAttachment(index)"
391 class="remove-attachment">&times;</button>
392 </div>
393 </template>
@@ -441,7 +405,7 @@
405 d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
406 </svg>
407 </label>
444 - <input type="file" id="file-input" accept="*" multiple style="display: none" @change="handleFileUpload($event)">
408 + <input type="file" id="file-input" accept="*" multiple style="display: none" @change="window.handleFileUpload($event)">
409
410 <div x-show="showTooltip" class="tooltip">
411 Add attachments to the message
@@ -1680,22 +1644,8 @@
1644 </template>
1645 </div>
1646
1683 - <!-- Drag and Drop Overlay -->
1684 - <div id="dragdrop-overlay"
1685 - x-cloak
1686 - x-data="{ isVisible: false }"
1687 - x-show="isVisible"
1688 - x-transition:enter="transition ease-out duration-300"
1689 - x-transition:enter-start="opacity-0"
1690 - x-transition:enter-end="opacity-100"
1691 - x-transition:leave="transition ease-in duration-300"
1692 - x-transition:leave-start="opacity-100"
1693 - x-transition:leave-end="opacity-0"
1694 - class="dragdrop-overlay">
1695 - <img src="public/dragndrop.svg" alt="Drop files" class="dragdrop-icon">
1696 - <div class="dragdrop-text">Drop files to attach them to your message</div>
1697 - <div class="dragdrop-subtext"></div>
1698 - </div>
1647 + <!-- Drag and Drop Overlay Component -->
1648 + <x-component path="chat/attachments/dragDropOverlay.html"></x-component>
1649
1650 </body>
1651
webui/index.js
+12 -92
@@ -84,9 +84,9 @@ document.addEventListener('DOMContentLoaded', setupSidebarToggle);
84 export async function sendMessage() {
85 try {
86 const message = chatInput.value.trim();
87 - const inputAD = Alpine.$data(inputSection);
88 - const attachments = inputAD.attachments;
89 - const hasAttachments = attachments && attachments.length > 0;
87 + const attachmentsStore = Alpine.store('chatAttachments');
88 + const attachments = attachmentsStore ? attachmentsStore.attachments : [];
89 + const hasAttachments = attachmentsStore ? attachmentsStore.hasAttachments : false;
90
91 if (message || hasAttachments) {
92 let response;
@@ -94,18 +94,7 @@ export async function sendMessage() {
94
95 // Include attachments in the user message
96 if (hasAttachments) {
97 - const attachmentsWithUrls = attachments.map(attachment => {
98 - if (attachment.type === 'image') {
99 - return {
100 - ...attachment,
101 - url: URL.createObjectURL(attachment.file)
102 - };
103 - } else {
104 - return {
105 - ...attachment
106 - };
107 - }
108 - });
97 + const attachmentsWithUrls = attachmentsStore.getAttachmentsForSending();
98
99 // Render user message with attachments
100 setMessage(messageId, 'user', '', message, false, {
@@ -159,8 +148,9 @@ export async function sendMessage() {
148
149 // Clear input and attachments
150 chatInput.value = '';
162 - inputAD.attachments = [];
163 - inputAD.hasAttachments = false;
151 + if (attachmentsStore) {
152 + attachmentsStore.clearAttachments();
153 + }
154 adjustTextareaHeight();
155 }
156 } catch (e) {
@@ -1079,84 +1069,14 @@ async function startPolling() {
1069
1070 document.addEventListener("DOMContentLoaded", startPolling);
1071
1082 -document.addEventListener('DOMContentLoaded', () => {
1083 - const dragDropOverlay = document.getElementById('dragdrop-overlay');
1084 - const inputSection = document.getElementById('input-section');
1085 - let dragCounter = 0;
1086 -
1087 - // Prevent default drag behaviors
1088 - ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
1089 - document.addEventListener(eventName, (e) => {
1090 - e.preventDefault();
1091 - e.stopPropagation();
1092 - }, false);
1093 - });
1094 -
1095 - // Handle drag enter
1096 - document.addEventListener('dragenter', (e) => {
1097 - dragCounter++;
1098 - if (dragCounter === 1) {
1099 - Alpine.$data(dragDropOverlay).isVisible = true;
1100 - }
1101 - }, false);
1102 -
1103 - // Handle drag leave
1104 - document.addEventListener('dragleave', (e) => {
1105 - dragCounter--;
1106 - if (dragCounter === 0) {
1107 - Alpine.$data(dragDropOverlay).isVisible = false;
1108 - }
1109 - }, false);
1110 -
1111 - // Handle drop
1112 - dragDropOverlay.addEventListener('drop', (e) => {
1113 - dragCounter = 0;
1114 - Alpine.$data(dragDropOverlay).isVisible = false;
1072 +// Drag and drop functionality has been moved to attachmentsStore.js
1073
1116 - const inputAD = Alpine.$data(inputSection);
1117 - const files = e.dataTransfer.files;
1118 - handleFiles(files, inputAD);
1119 - }, false);
1120 -});
1121 -
1122 -// Separate file handling logic to be used by both drag-drop and file input
1123 -function handleFiles(files, inputAD) {
1124 - Array.from(files).forEach(file => {
1125 - const ext = file.name.split('.').pop().toLowerCase();
1126 -
1127 - const isImage = ['jpg', 'jpeg', 'png', 'bmp'].includes(ext);
1128 -
1129 - if (isImage) {
1130 - const reader = new FileReader();
1131 - reader.onload = e => {
1132 - inputAD.attachments.push({
1133 - file: file,
1134 - url: e.target.result,
1135 - type: 'image',
1136 - name: file.name,
1137 - extension: ext
1138 - });
1139 - inputAD.hasAttachments = true;
1140 - };
1141 - reader.readAsDataURL(file);
1142 - } else {
1143 - inputAD.attachments.push({
1144 - file: file,
1145 - type: 'file',
1146 - name: file.name,
1147 - extension: ext
1148 - });
1149 - inputAD.hasAttachments = true;
1150 - }
1151 -
1152 - });
1153 -}
1154 -
1155 -// Modify the existing handleFileUpload to use the new handleFiles function
1074 +// Update handleFileUpload to use the attachments store
1075 window.handleFileUpload = function(event) {
1076 const files = event.target.files;
1158 - const inputAD = Alpine.$data(inputSection);
1159 - handleFiles(files, inputAD);
1077 + if (Alpine.store('chatAttachments')) {
1078 + Alpine.store('chatAttachments').handleFiles(files);
1079 + }
1080 }
1081
1082 // Setup event handlers once the DOM is fully loaded
webui/js/initFw.js
+12
@@ -3,6 +3,9 @@ import * as _components from "./components.js";
3
4 await import("./alpine.min.js");
5
6 +// Import attachments store
7 +import { store as attachmentsStore } from "../components/chat/attachments/attachmentsStore.js";
8 +
9 // add x-destroy directive
10 Alpine.directive(
11 "destroy",
@@ -11,3 +14,12 @@ Alpine.directive(
14 cleanup(() => onDestroy());
15 }
16 );
17 +
18 +// Initialize attachments store when Alpine.js is ready
19 +document.addEventListener('alpine:init', () => {
20 + // Store is already created via createStore in attachmentsStore.js
21 + // Just call initialize to set up event handlers
22 + if (Alpine.store('chatAttachments')) {
23 + Alpine.store('chatAttachments').initialize();
24 + }
25 +});
webui/js/messages.js
+49 -9
@@ -318,25 +318,65 @@ export function drawMessageUser(
318 const attachmentDiv = document.createElement("div");
319 attachmentDiv.classList.add("attachment-item");
320
321 + // Helper function to generate server-side image URL
322 + const getServerImageUrl = (filename) => {
323 + return `/image_get?path=/a0/tmp/uploads/${encodeURIComponent(filename)}`;
324 + };
325 +
326 + // Helper function to check if file is an image
327 + const isImageFile = (filename) => {
328 + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];
329 + const extension = filename.split('.').pop().toLowerCase();
330 + return imageExtensions.includes(extension);
331 + };
332 +
333 if (typeof attachment === "string") {
322 - // attachment is filename
334 + // attachment is filename only (from persistent storage)
335 const filename = attachment;
336 const extension = filename.split(".").pop().toUpperCase();
337
326 - attachmentDiv.classList.add("file-type");
327 - attachmentDiv.innerHTML = `
328 - <div class="file-preview">
338 + if (isImageFile(filename)) {
339 + // Render as image with server URL
340 + const imgWrapper = document.createElement("div");
341 + imgWrapper.classList.add("image-wrapper");
342 +
343 + const img = document.createElement("img");
344 + img.src = getServerImageUrl(filename);
345 + img.alt = filename;
346 + img.classList.add("attachment-preview");
347 +
348 + const fileInfo = document.createElement("div");
349 + fileInfo.classList.add("file-info");
350 + fileInfo.innerHTML = `
351 <span class="filename">${filename}</span>
352 <span class="extension">${extension}</span>
331 - </div>
332 - `;
353 + `;
354 +
355 + imgWrapper.appendChild(img);
356 + attachmentDiv.appendChild(imgWrapper);
357 + attachmentDiv.appendChild(fileInfo);
358 + } else {
359 + // Render as file icon
360 + attachmentDiv.classList.add("file-type");
361 + attachmentDiv.innerHTML = `
362 + <div class="file-preview">
363 + <span class="filename">${filename}</span>
364 + <span class="extension">${extension}</span>
365 + </div>
366 + `;
367 + }
368 } else if (attachment.type === "image") {
334 - // Existing logic for images
369 + // attachment is object (from current session)
370 const imgWrapper = document.createElement("div");
371 imgWrapper.classList.add("image-wrapper");
372
373 const img = document.createElement("img");
339 - img.src = attachment.url;
374 + // Use server URL if we have filename, otherwise fall back to blob URL for current session
375 + if (attachment.name && !attachment.url.startsWith('blob:')) {
376 + img.src = getServerImageUrl(attachment.name);
377 + } else {
378 + img.src = attachment.url;
379 + }
380 img.alt = attachment.name;
381 img.classList.add("attachment-preview");
382
@@ -351,7 +391,7 @@ export function drawMessageUser(
391 attachmentDiv.appendChild(imgWrapper);
392 attachmentDiv.appendChild(fileInfo);
393 } else {
354 - // Existing logic for non-image files
394 + // attachment is object but not image (from current session)
395 attachmentDiv.classList.add("file-type");
396 attachmentDiv.innerHTML = `
397 <div class="file-preview">