1// static/js/fileHandler.js 2 3/** 4 * File attachment and upload handling 5 */ 6 7import uiModule from './ui.js'; 8import spinnerModule from './spinner.js'; 9 10let pendingFiles = []; 11let uploaded = []; 12// Holds the full meta (id/name/mime/size/width/height/â¦) from the most recent 13// uploadPending() so callers can stamp width/height onto their attachment 14// objects without changing uploadPending()'s return signature. 15let _lastUploadedMeta = []; 16let API_BASE = ''; 17let _uploadSpinners = []; 18const _previewUrls = new WeakMap(); 19 20const MAX_FILES = 10; 21const MAX_VISIBLE = 3; 22let _expanded = false; 23 24function _getPreviewUrl(f) { 25 if (!f) return ''; 26 let url = _previewUrls.get(f); 27 if (!url) { 28 url = URL.createObjectURL(f); 29 _previewUrls.set(f, url); 30 } 31 return url; 32} 33 34function _revokePreviewUrl(f) { 35 const url = _previewUrls.get(f); 36 if (url) { 37 try { URL.revokeObjectURL(url); } catch (_) {} 38 _previewUrls.delete(f); 39 } 40} 41 42/** 43 * Initialize with dependencies 44 */ 45export function init(apiBase) { 46 API_BASE = apiBase; 47} 48 49/** 50 * Open file picker dialog 51 */ 52export function openPicker() { 53 document.getElementById('file-input').click(); 54} 55 56/** 57 * Render the attachment strip with pending files. 58 * 1-3 files: show individual chips. 59 * 4+ files: collapse into a single "N files" badge (click to expand). 60 */ 61export function renderAttachStrip() { 62 const strip = document.getElementById('attach-strip'); 63 64 while (strip.firstChild) strip.removeChild(strip.firstChild); 65 if (pendingFiles.length === 0) { 66 _expanded = false; 67 if (window._updateSendBtnIcon) window._updateSendBtnIcon(); 68 return; 69 } 70 71 const total = pendingFiles.length; 72 const collapsed = total > MAX_VISIBLE && !_expanded; 73 74 if (collapsed) { 75 // Single compact badge: "5 files Ã" 76 const badge = document.createElement('div'); 77 badge.className = 'thumb thumb-collapsed'; 78 const label = document.createElement('span'); 79 label.textContent = total + ' file' + (total > 1 ? 's' : ''); 80 label.className = 'thumb-collapsed-label'; 81 badge.appendChild(label); 82 badge.title = pendingFiles.map(f => f.name || 'pasted-image').join('\n'); 83 badge.style.cursor = 'pointer'; 84 badge.addEventListener('click', (e) => { 85 if (e.target.closest('.thumb-collapsed-x')) return; 86 _expanded = true; 87 renderAttachStrip(); 88 }); 89 const x = document.createElement('button'); 90 x.className = 'thumb-collapsed-x'; 91 x.textContent = '\u00d7'; 92 x.title = 'Remove all'; 93 x.addEventListener('click', (e) => { e.stopPropagation(); clearPending(); }); 94 badge.appendChild(x); 95 strip.appendChild(badge); 96 } else { 97 // Show individual chips 98 for (let idx = 0; idx < total; idx++) { 99 strip.appendChild(_createChip(pendingFiles[idx], idx)); 100 } 101 } 102 if (window._updateSendBtnIcon) window._updateSendBtnIcon(); 103} 104 105function _createChip(f, idx) { 106 const chip = document.createElement('div'); 107 chip.className = 'thumb'; 108 const isImage = f.type?.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f.name || ''); 109 if (isImage) { 110 chip.classList.add('thumb-image'); // lets CSS overlay the remove-X on the corner (mobile) 111 const img = document.createElement('img'); 112 img.className = 'thumb-img'; 113 img.src = _getPreviewUrl(f); 114 img.alt = f.name || 'image'; 115 chip.appendChild(img); 116 } else { 117 const span = document.createElement('span'); 118 span.textContent = f.name || 'pasted-image'; 119 chip.appendChild(span); 120 } 121 const x = document.createElement('button'); 122 x.textContent = '\u00d7'; 123 x.setAttribute('aria-label', 'Remove attachment'); 124 x.addEventListener('click', (e) => { e.stopPropagation(); removePending(idx); }); 125 chip.appendChild(x); 126 return chip; 127} 128 129/** 130 * Remove a pending file by index 131 */ 132export function removePending(idx) { 133 _revokePreviewUrl(pendingFiles[idx]); 134 pendingFiles.splice(idx, 1); 135 renderAttachStrip(); 136} 137 138/** 139 * Upload all pending files to server 140 */ 141export async function uploadPending() { 142 if (pendingFiles.length === 0) return []; 143 144 // The message bubble is shown immediately, but the upload can take a moment â 145 // dim the chips and overlay a whirlpool so it's clear the files are still 146 // being sent (and aren't stuck). Cleared in the finally below. 147 const strip = document.getElementById('attach-strip'); 148 if (strip) { 149 strip.classList.add('attach-uploading'); 150 // Put a whirlpool ON each attachment chip (image/doc) so the spinner sits on 151 // the thing being uploaded, not floating over the whole strip. 152 strip.querySelectorAll('.thumb').forEach(chip => {
153 try { 154 const sp = spinnerModule.create('', 'clean', 'whirlpool'); 155 const ov = document.createElement('span'); 156 ov.className = 'thumb-upload-spinner'; 157 ov.appendChild(sp.createElement()); 158 chip.appendChild(ov); 159 sp.start(); 160 _uploadSpinners.push(sp); 161 } catch (_) { /* spinner is best-effort */ } 162 }); 163 } 164 165 const fd = new FormData(); 166 pendingFiles.forEach(f => fd.append('files', f, f.name || 'paste.png')); 167 168 try { 169 const res = await fetch(`${API_BASE}/api/upload`, { 170 method: 'POST', 171 body: fd 172 }); 173 if (!res.ok) { 174 // Surface the failure instead of swallowing it. Previously a non-OK 175 // response (e.g. 429 rate limit, 413 too large) was ignored: the files 176 // silently vanished and the chat sent with no attachments, so the model 177 // "didn't even see them" (issue #1346). Show the server's reason and keep 178 // pendingFiles so the strip re-renders for a retry (see finally below). 179 let detail = ''; 180 try { const e = await res.json(); detail = e.detail || e.error || ''; } catch (_) {} 181 _showToast('Upload failed' + (detail ? ': ' + detail : ` (HTTP ${res.status})`)); 182 return []; 183 } 184 const data = await res.json(); 185 uploaded = (data.files || []); 186 pendingFiles = []; // clear only on success 187 // Stash the full meta (incl. width/height for images) on the module so 188 // callers that want it can grab it via getLastUploadedMeta(). Keep the 189 // returned shape as `ids` for backward-compatibility with existing call sites. 190 _lastUploadedMeta = uploaded; 191 return uploaded.map(x => x.id); 192 } finally { 193 _uploadSpinners.forEach(sp => { try { sp.stop && sp.stop(); } catch (_) {} }); 194 _uploadSpinners = []; 195 if (strip) strip.classList.remove('attach-uploading'); 196 // Re-render: empty on success (chips gone), or restored on error so the 197 // user can retry â and either way the spinners are removed. 198 renderAttachStrip(); 199 } 200} 201 202/** 203 * Add files to pending list (capped at MAX_FILES) 204 */ 205export function addFiles(files) { 206 for (const f of files) { 207 if (pendingFiles.length >= MAX_FILES) { 208 _showToast(`Max ${MAX_FILES} files allowed`); 209 break; 210 } 211 pendingFiles.push(f); 212 } 213 renderAttachStrip(); 214} 215 216function _showToast(msg) { 217 if (window.showToast) { window.showToast(msg); return; } 218 // Fallback inline toast 219 let t = document.getElementById('_attach-toast'); 220 if (!t) { 221 t = document.createElement('div'); 222 t.id = '_attach-toast'; 223 t.style.cssText = 'position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:var(--panel);border:1px solid var(--red);color:var(--red);padding:6px 14px;border-radius:6px;font-size:13px;z-index:9999;opacity:0;transition:opacity .3s'; 224 document.body.appendChild(t); 225 } 226 t.textContent = msg; 227 t.style.opacity = '1'; 228 clearTimeout(t._timer); 229 t._timer = setTimeout(() => { t.style.opacity = '0'; }, 2500); 230} 231 232/** 233 * Get pending files count 234 */ 235export function getPendingCount() { 236 return pendingFiles.length; 237} 238 239/** 240 * Get raw pending File objects (for reading content before upload clears them) 241 */ 242export function getPendingRaw() { 243 return [...pendingFiles]; 244} 245 246/** 247 * Get pending file metadata (name, size, type) for display 248 */ 249export function getPendingInfo() { 250 return pendingFiles.map(f => { 251 const isImage = f.type?.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f.name || ''); 252 return { 253 name: f.name || 'pasted-image', 254 size: f.size || 0, 255 mime: f.type || '', 256 previewUrl: isImage ? _getPreviewUrl(f) : '', 257 }; 258 }); 259} 260 261/** 262 * Clear all pending files 263 */ 264export function clearPending() { 265 pendingFiles.forEach(_revokePreviewUrl); 266 pendingFiles = []; 267 renderAttachStrip(); 268} 269 270/** Full meta (incl. width/height for images) from the most recent uploadPending(). */ 271export function getLastUploadedMeta() { 272 return _lastUploadedMeta; 273} 274 275var escapeHtml = uiModule.esc; 276 277const fileHandlerModule = { 278 init, 279 openPicker, 280 renderAttachStrip, 281 removePending, 282 uploadPending, 283 addFiles, 284 getPendingCount, 285 getPendingInfo, 286 getPendingRaw, 287 clearPending, 288 getLastUploadedMeta, 289}; 290 291export default fileHandlerModule;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.