Spaces:
Runtime error
Runtime error
| // static/js/fileHandler.js | |
| /** | |
| * File attachment and upload handling | |
| */ | |
| import uiModule from './ui.js'; | |
| import spinnerModule from './spinner.js'; | |
| let pendingFiles = []; | |
| let uploaded = []; | |
| // Holds the full meta (id/name/mime/size/width/height/…) from the most recent | |
| // uploadPending() so callers can stamp width/height onto their attachment | |
| // objects without changing uploadPending()'s return signature. | |
| let _lastUploadedMeta = []; | |
| let API_BASE = ''; | |
| let _uploadSpinners = []; | |
| const _previewUrls = new WeakMap(); | |
| function _getPreviewUrl(f) { | |
| if (!f) return ''; | |
| let url = _previewUrls.get(f); | |
| if (!url) { | |
| url = URL.createObjectURL(f); | |
| _previewUrls.set(f, url); | |
| } | |
| return url; | |
| } | |
| function _revokePreviewUrl(f) { | |
| const url = _previewUrls.get(f); | |
| if (url) { | |
| try { URL.revokeObjectURL(url); } catch (_) {} | |
| _previewUrls.delete(f); | |
| } | |
| } | |
| /** | |
| * Initialize with dependencies | |
| */ | |
| export function init(apiBase) { | |
| API_BASE = apiBase; | |
| } | |
| /** | |
| * Open file picker dialog | |
| */ | |
| export function openPicker() { | |
| document.getElementById('file-input').click(); | |
| } | |
| const MAX_VISIBLE = 3; | |
| const MAX_EXPAND = 6; // beyond this, the badge stays collapsed (too many chips to preview) | |
| let _expanded = false; | |
| /** | |
| * Render the attachment strip with pending files. | |
| * 1-3 files: show individual chips. | |
| * 4+ files: collapse into a single "N files" badge (click to expand). | |
| */ | |
| export function renderAttachStrip() { | |
| const strip = document.getElementById('attach-strip'); | |
| while (strip.firstChild) strip.removeChild(strip.firstChild); | |
| if (pendingFiles.length === 0) { | |
| _expanded = false; | |
| if (window._updateSendBtnIcon) window._updateSendBtnIcon(); | |
| return; | |
| } | |
| const total = pendingFiles.length; | |
| const collapsed = total > MAX_VISIBLE && !_expanded; | |
| if (collapsed) { | |
| // Single compact badge: "5 files ×" | |
| const badge = document.createElement('div'); | |
| badge.className = 'thumb thumb-collapsed'; | |
| const label = document.createElement('span'); | |
| label.textContent = total + ' file' + (total > 1 ? 's' : ''); | |
| label.className = 'thumb-collapsed-label'; | |
| badge.appendChild(label); | |
| badge.title = pendingFiles.map(f => f.name || 'pasted-image').join('\n'); | |
| const canExpand = total <= MAX_EXPAND; | |
| badge.style.cursor = canExpand ? 'pointer' : 'default'; | |
| badge.addEventListener('click', (e) => { | |
| if (e.target.closest('.thumb-collapsed-x')) return; | |
| if (!canExpand) return; // too many files — don't expand into chips | |
| _expanded = true; | |
| renderAttachStrip(); | |
| }); | |
| const x = document.createElement('button'); | |
| x.className = 'thumb-collapsed-x'; | |
| x.textContent = '\u00d7'; | |
| x.title = 'Remove all'; | |
| x.addEventListener('click', (e) => { e.stopPropagation(); clearPending(); }); | |
| badge.appendChild(x); | |
| strip.appendChild(badge); | |
| } else { | |
| // Show individual chips | |
| for (let idx = 0; idx < total; idx++) { | |
| strip.appendChild(_createChip(pendingFiles[idx], idx)); | |
| } | |
| } | |
| if (window._updateSendBtnIcon) window._updateSendBtnIcon(); | |
| } | |
| function _createChip(f, idx) { | |
| const chip = document.createElement('div'); | |
| chip.className = 'thumb'; | |
| const isImage = f.type?.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f.name || ''); | |
| if (isImage) { | |
| chip.classList.add('thumb-image'); // lets CSS overlay the remove-X on the corner (mobile) | |
| const img = document.createElement('img'); | |
| img.className = 'thumb-img'; | |
| img.src = _getPreviewUrl(f); | |
| img.alt = f.name || 'image'; | |
| chip.appendChild(img); | |
| } else { | |
| const span = document.createElement('span'); | |
| span.textContent = f.name || 'pasted-image'; | |
| chip.appendChild(span); | |
| } | |
| const x = document.createElement('button'); | |
| x.textContent = '\u00d7'; | |
| x.setAttribute('aria-label', 'Remove attachment'); | |
| x.addEventListener('click', (e) => { e.stopPropagation(); removePending(idx); }); | |
| chip.appendChild(x); | |
| return chip; | |
| } | |
| /** | |
| * Remove a pending file by index | |
| */ | |
| export function removePending(idx) { | |
| _revokePreviewUrl(pendingFiles[idx]); | |
| pendingFiles.splice(idx, 1); | |
| renderAttachStrip(); | |
| } | |
| /** | |
| * Upload all pending files to server | |
| */ | |
| export async function uploadPending() { | |
| if (pendingFiles.length === 0) return []; | |
| // The message bubble is shown immediately, but the upload can take a moment — | |
| // dim the chips and overlay a whirlpool so it's clear the files are still | |
| // being sent (and aren't stuck). Cleared in the finally below. | |
| const strip = document.getElementById('attach-strip'); | |
| if (strip) { | |
| strip.classList.add('attach-uploading'); | |
| // Put a whirlpool ON each attachment chip (image/doc) so the spinner sits on | |
| // the thing being uploaded, not floating over the whole strip. | |
| strip.querySelectorAll('.thumb').forEach(chip => { | |
| try { | |
| const sp = spinnerModule.create('', 'clean', 'whirlpool'); | |
| const ov = document.createElement('span'); | |
| ov.className = 'thumb-upload-spinner'; | |
| ov.appendChild(sp.createElement()); | |
| chip.appendChild(ov); | |
| sp.start(); | |
| _uploadSpinners.push(sp); | |
| } catch (_) { /* spinner is best-effort */ } | |
| }); | |
| } | |
| const fd = new FormData(); | |
| pendingFiles.forEach(f => fd.append('files', f, f.name || 'paste.png')); | |
| try { | |
| const res = await fetch(`${API_BASE}/api/upload`, { | |
| method: 'POST', | |
| body: fd | |
| }); | |
| if (!res.ok) { | |
| // Surface the failure instead of swallowing it. Previously a non-OK | |
| // response (e.g. 429 rate limit, 413 too large) was ignored: the files | |
| // silently vanished and the chat sent with no attachments, so the model | |
| // "didn't even see them" (issue #1346). Show the server's reason and keep | |
| // pendingFiles so the strip re-renders for a retry (see finally below). | |
| let detail = ''; | |
| try { const e = await res.json(); detail = e.detail || e.error || ''; } catch (_) {} | |
| _showToast('Upload failed' + (detail ? ': ' + detail : ` (HTTP ${res.status})`)); | |
| return []; | |
| } | |
| const data = await res.json(); | |
| uploaded = (data.files || []); | |
| pendingFiles = []; // clear only on success | |
| // Stash the full meta (incl. width/height for images) on the module so | |
| // callers that want it can grab it via getLastUploadedMeta(). Keep the | |
| // returned shape as `ids` for backward-compatibility with existing call sites. | |
| _lastUploadedMeta = uploaded; | |
| return uploaded.map(x => x.id); | |
| } finally { | |
| _uploadSpinners.forEach(sp => { try { sp.stop && sp.stop(); } catch (_) {} }); | |
| _uploadSpinners = []; | |
| if (strip) strip.classList.remove('attach-uploading'); | |
| // Re-render: empty on success (chips gone), or restored on error so the | |
| // user can retry — and either way the spinners are removed. | |
| renderAttachStrip(); | |
| } | |
| } | |
| const MAX_FILES = 10; | |
| /** | |
| * Add files to pending list (capped at MAX_FILES) | |
| */ | |
| export function addFiles(files) { | |
| for (const f of files) { | |
| if (pendingFiles.length >= MAX_FILES) { | |
| _showToast(`Max ${MAX_FILES} files allowed`); | |
| break; | |
| } | |
| pendingFiles.push(f); | |
| } | |
| renderAttachStrip(); | |
| } | |
| function _showToast(msg) { | |
| if (window.showToast) { window.showToast(msg); return; } | |
| // Fallback inline toast | |
| let t = document.getElementById('_attach-toast'); | |
| if (!t) { | |
| t = document.createElement('div'); | |
| t.id = '_attach-toast'; | |
| 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'; | |
| document.body.appendChild(t); | |
| } | |
| t.textContent = msg; | |
| t.style.opacity = '1'; | |
| clearTimeout(t._timer); | |
| t._timer = setTimeout(() => { t.style.opacity = '0'; }, 2500); | |
| } | |
| /** | |
| * Get pending files count | |
| */ | |
| export function getPendingCount() { | |
| return pendingFiles.length; | |
| } | |
| /** | |
| * Get raw pending File objects (for reading content before upload clears them) | |
| */ | |
| export function getPendingRaw() { | |
| return [...pendingFiles]; | |
| } | |
| /** | |
| * Get pending file metadata (name, size, type) for display | |
| */ | |
| export function getPendingInfo() { | |
| return pendingFiles.map(f => { | |
| const isImage = f.type?.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f.name || ''); | |
| return { | |
| name: f.name || 'pasted-image', | |
| size: f.size || 0, | |
| mime: f.type || '', | |
| previewUrl: isImage ? _getPreviewUrl(f) : '', | |
| }; | |
| }); | |
| } | |
| /** | |
| * Clear all pending files | |
| */ | |
| export function clearPending() { | |
| pendingFiles.forEach(_revokePreviewUrl); | |
| pendingFiles = []; | |
| renderAttachStrip(); | |
| } | |
| /** Full meta (incl. width/height for images) from the most recent uploadPending(). */ | |
| export function getLastUploadedMeta() { | |
| return _lastUploadedMeta; | |
| } | |
| var escapeHtml = uiModule.esc; | |
| const fileHandlerModule = { | |
| init, | |
| openPicker, | |
| renderAttachStrip, | |
| removePending, | |
| uploadPending, | |
| addFiles, | |
| getPendingCount, | |
| getPendingInfo, | |
| getPendingRaw, | |
| clearPending, | |
| getLastUploadedMeta, | |
| }; | |
| export default fileHandlerModule; | |