// ========================================== // Chat Switchboard – Attachments // ========================================== // File upload, smart paste, drag-and-drop, extraction // polling, staged lifecycle, message rendering, lightbox, // and admin storage panel. // // Depends on: API, App, UI (toast, getModelValue, renderChatList) // Consumed by: chat.js (send flow), ui-core.js (message rendering), // ui-admin.js (storage tab) // ── Constants ─────────────────────────────── const ATTACHMENT_POLL_INTERVAL = 2000; // ms between extraction status checks const ATTACHMENT_MAX_PER_MSG = 5; // mirrors backend defaultMaxAttachmentsPerMsg // MIME categories for capability-aware filtering const IMAGE_TYPES = new Set([ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', ]); // ── State ─────────────────────────────────── // Staged attachments waiting to be sent with the next message. // Each entry: // { // localId: string — crypto.randomUUID(), stable key for DOM // serverId: string? — attachment UUID from backend (null while uploading) // filename: string // contentType: string // sizeBytes: number // previewUrl: string? — object URL for image preview (revoked on remove) // status: 'uploading' | 'queued' | 'processing' | 'complete' | 'failed' | 'error' // uploadProgress: number — 0–100 (only meaningful during 'uploading') // extractionStatus: string? — mirrors backend metadata.extraction_status // queuePosition: number? — extraction queue position (0 = processing) // error: string? — error message if status is 'error' or 'failed' // } // App.stagedAttachments is initialized in app.js. // Polling timers are tracked here for cleanup. const _pollTimers = new Map(); // localId → intervalId // ── Upload ────────────────────────────────── /** * Stage a file for upload. Creates the staged entry, uploads to backend, * and starts extraction polling. * * If no current chat exists, creates one first (same pattern as sendMessage). * * @param {File} file — File object from input, paste, or drop * @returns {Promise} */ async function stageFile(file) { if (!App.storageConfigured) return; // Enforce per-message limit if (App.stagedAttachments.length >= ATTACHMENT_MAX_PER_MSG) { UI.toast(`Maximum ${ATTACHMENT_MAX_PER_MSG} files per message`, 'warning'); return; } // Build staged entry const staged = { localId: crypto.randomUUID(), serverId: null, filename: file.name, contentType: file.type || 'application/octet-stream', sizeBytes: file.size, previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null, status: 'uploading', uploadProgress: 0, extractionStatus: null, queuePosition: null, error: null, }; App.stagedAttachments.push(staged); _renderStrip(); _updateSendBlock(); // Ensure a channel exists (attachments are channel-scoped) if (!App.currentChatId) { try { const title = file.name.slice(0, 50); const selectedId = UI.getModelValue(); const modelInfo = App.findModel(selectedId); const model = modelInfo?.baseModelId || selectedId; const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct'); const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, updatedAt: resp.updated_at, }; App.chats.unshift(chat); App.currentChatId = chat.id; UI.renderChatList(); } catch (e) { staged.status = 'error'; staged.error = 'Failed to create chat: ' + e.message; _renderStrip(); _updateSendBlock(); return; } } // Upload try { const result = await API.uploadAttachment(App.currentChatId, file); staged.serverId = result.id; staged.status = _mapExtractionStatus(result.metadata?.extraction_status); staged.extractionStatus = result.metadata?.extraction_status || null; staged.queuePosition = result.metadata?.queue_position ?? null; // Start polling if extraction is pending/processing if (staged.status === 'queued' || staged.status === 'processing') { _startPolling(staged); } } catch (e) { staged.status = 'error'; staged.error = e.message; console.error('Attachment upload failed:', e); } _renderStrip(); _updateSendBlock(); } /** * Remove a staged attachment by localId. * Revokes object URL, stops polling, and optionally deletes from backend. */ async function unstageFile(localId) { const idx = App.stagedAttachments.findIndex(a => a.localId === localId); if (idx === -1) return; const staged = App.stagedAttachments[idx]; // Clean up preview blob if (staged.previewUrl) { URL.revokeObjectURL(staged.previewUrl); } // Stop polling _stopPolling(localId); // Delete from backend if it was uploaded if (staged.serverId) { try { await API.deleteAttachment(staged.serverId); } catch (e) { console.warn('Failed to delete staged attachment:', e.message); } } App.stagedAttachments.splice(idx, 1); _renderStrip(); _updateSendBlock(); } /** * Clear all staged attachments (chat switch, cancel, etc.) * Does NOT delete from backend — orphan cleanup handles abandoned uploads. */ function clearStaged() { for (const staged of App.stagedAttachments) { if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl); _stopPolling(staged.localId); } App.stagedAttachments = []; _renderStrip(); _updateSendBlock(); } // ── Send Integration ──────────────────────── /** * Returns attachment IDs (server-side UUIDs) for the current staged set. * Only includes successfully uploaded attachments. */ function getStagedAttachmentIds() { return App.stagedAttachments .filter(a => a.serverId) .map(a => a.serverId); } /** * Whether the send button should be blocked due to in-flight uploads. * Per design: blocked ONLY while any file is actively uploading (bytes in flight). * Queued/processing/failed extraction does NOT block send. */ function isSendBlocked() { return App.stagedAttachments.some(a => a.status === 'uploading'); } /** * Whether there are any staged attachments (for UI visibility). */ function hasStagedAttachments() { return App.stagedAttachments.length > 0; } /** * Post-send cleanup: detach staged list without deleting from backend * (attachments are now linked to the sent message). */ function consumeStaged() { for (const staged of App.stagedAttachments) { if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl); _stopPolling(staged.localId); } App.stagedAttachments = []; _renderStrip(); _updateSendBlock(); } // ── Extraction Polling ────────────────────── function _startPolling(staged) { if (_pollTimers.has(staged.localId)) return; const timer = setInterval(async () => { if (!staged.serverId) return; try { const data = await API.getAttachment(staged.serverId); const meta = data.metadata || {}; staged.extractionStatus = meta.extraction_status || null; staged.queuePosition = meta.queue_position ?? null; staged.status = _mapExtractionStatus(meta.extraction_status); if (meta.extraction_error) { staged.error = meta.extraction_error; } _renderStrip(); // Stop polling on terminal state if (staged.status === 'complete' || staged.status === 'failed') { _stopPolling(staged.localId); } } catch (e) { console.warn('Extraction poll failed for', staged.localId, e.message); // Don't stop polling on transient errors — backend might be restarting } }, ATTACHMENT_POLL_INTERVAL); _pollTimers.set(staged.localId, timer); } function _stopPolling(localId) { const timer = _pollTimers.get(localId); if (timer) { clearInterval(timer); _pollTimers.delete(localId); } } function _stopAllPolling() { for (const [id, timer] of _pollTimers) { clearInterval(timer); } _pollTimers.clear(); } // ── Status Helpers ────────────────────────── /** * Map backend extraction_status to frontend staged status. * Images have no extraction — they go straight to 'complete'. */ function _mapExtractionStatus(backendStatus) { switch (backendStatus) { case 'pending': return 'queued'; case 'processing': return 'processing'; case 'complete': return 'complete'; case 'failed': return 'failed'; case 'unavailable': return 'complete'; // no extraction needed (e.g., images) default: return 'queued'; } } /** * Human-readable status label for a staged attachment. */ function attachmentStatusLabel(staged) { switch (staged.status) { case 'uploading': return staged.uploadProgress > 0 ? `Uploading ${staged.uploadProgress}%` : 'Uploading…'; case 'queued': if (staged.queuePosition != null && staged.queuePosition > 0) { return `Queued (${staged.queuePosition} ahead)`; } return 'Queued'; case 'processing': return 'Extracting…'; case 'complete': return '✓ Ready'; case 'failed': return '⚠ Extraction failed'; case 'error': return '✕ ' + (staged.error || 'Upload failed'); default: return ''; } } /** * Whether the given content type is an image (for vision gating). */ function isImageAttachment(contentType) { return IMAGE_TYPES.has(contentType); } /** * Format file size for display. */ function formatFileSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } // ── Strip Rendering ───────────────────────── function _renderStrip() { const strip = document.getElementById('attachmentStrip'); if (!strip) return; if (App.stagedAttachments.length === 0) { strip.style.display = 'none'; strip.innerHTML = ''; return; } strip.style.display = 'flex'; strip.innerHTML = App.stagedAttachments.map(a => { const icon = isImageAttachment(a.contentType) ? '🖼' : '📄'; const name = _truncFilename(a.filename, 20); const size = formatFileSize(a.sizeBytes); const label = attachmentStatusLabel(a); const statusClass = a.status === 'error' || a.status === 'failed' ? 'att-error' : a.status === 'complete' ? 'att-ready' : 'att-pending'; return `
${a.previewUrl ? `` : `${icon}`} ${_esc(name)} ${_esc(size)}${label ? ' · ' + label : ''}
`; }).join(''); } function _truncFilename(name, max) { if (name.length <= max) return name; const ext = name.lastIndexOf('.'); if (ext > 0 && name.length - ext <= 6) { // Preserve extension: "very-long-name...pdf" const suffix = name.slice(ext); return name.slice(0, max - suffix.length - 1) + '…' + suffix; } return name.slice(0, max - 1) + '…'; } function _esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } // ── Channel Attachment Loading ────────────── /** * Fetch all attachments for a channel, build messageId → [att] map. * Called after loading messages in selectChat / reloadActivePath. * Non-blocking: failures just leave the map empty (attachments are optional UX). */ async function loadChannelAttachments(channelId) { App.channelAttachments = {}; if (!channelId || !App.storageConfigured) return; try { const data = await API.listChannelAttachments(channelId); const list = data.attachments || data || []; for (const att of list) { if (!att.message_id) continue; if (!App.channelAttachments[att.message_id]) { App.channelAttachments[att.message_id] = []; } App.channelAttachments[att.message_id].push(att); } } catch (e) { console.debug('Attachment load skipped:', e.message); } } // ── Message Attachment Rendering ──────────── /** * Returns HTML string for attachments associated with a message. * Called from ui-core.js _messageHTML(). Returns '' if none. * * Images use data-att-id; actual src is loaded post-render with auth * via loadAuthImages(). Doc downloads use onclick handlers. */ function renderMessageAttachments(msgId) { if (!msgId) return ''; const atts = App.channelAttachments[msgId]; if (!atts || atts.length === 0) return ''; // Check current model vision capability for hint const selectedId = UI.getModelValue(); const modelInfo = App.findModel(selectedId); const hasVision = !!modelInfo?.capabilities?.vision; const items = atts.map(att => { const isImage = isImageAttachment(att.content_type); if (isImage) { const dims = att.metadata?.thumb_dimensions || att.metadata?.dimensions; const w = Math.min(dims?.width || 280, 320); const visionHint = !hasVision ? '👁️‍🗨️' : ''; return `
${_esc(att.filename)} ${visionHint}
`; } // Document chip const icon = _docIcon(att.content_type); const size = formatFileSize(att.size_bytes); return ` ${icon} ${_esc(att.filename)} ${size} `; }); return `
${items.join('')}
`; } function _docIcon(contentType) { if (contentType === 'application/pdf') return '📕'; if (contentType?.includes('spreadsheet') || contentType?.includes('excel')) return '📊'; if (contentType?.includes('presentation') || contentType?.includes('powerpoint')) return '📙'; if (contentType?.includes('word') || contentType?.includes('document')) return '📄'; if (contentType?.startsWith('text/')) return '📝'; return '📎'; } // ── Image Lightbox ────────────────────────── function openLightbox(attachmentId) { const overlay = document.getElementById('lightbox'); const img = document.getElementById('lightboxImg'); if (!overlay || !img) return; img.src = ''; img.alt = 'Loading…'; overlay.classList.add('active'); document.body.style.overflow = 'hidden'; // Fetch full-size image with auth API.downloadAttachmentBlob(attachmentId) .then(blob => { img.src = URL.createObjectURL(blob); }) .catch(() => { img.alt = 'Failed to load image'; }); } function closeLightbox() { const overlay = document.getElementById('lightbox'); const img = document.getElementById('lightboxImg'); if (!overlay) return; overlay.classList.remove('active'); document.body.style.overflow = ''; // Revoke previous blob URL to free memory if (img?.src?.startsWith('blob:')) URL.revokeObjectURL(img.src); if (img) { img.src = ''; img.alt = ''; } } // ── Auth Image Loading ────────────────────── /** * Post-render pass: finds all in the given * container and fetches them with auth headers, setting blob URLs. * Called after renderMessages and reloadActivePath. */ function loadAuthImages(container) { if (!container) container = document.getElementById('chatMessages'); if (!container) return; const imgs = container.querySelectorAll('img[data-att-id]:not([data-loaded])'); for (const img of imgs) { const attId = img.dataset.attId; if (!attId) continue; img.dataset.loaded = '1'; API.downloadAttachmentBlob(attId) .then(blob => { img.src = URL.createObjectURL(blob); }) .catch(() => { img.alt = '⚠ Load failed'; img.classList.add('att-load-error'); }); } } // ── Document Download ─────────────────────── /** * Download an attachment file via auth-aware fetch. * Creates a temporary element to trigger the browser download dialog. */ async function downloadAttachment(attachmentId, filename) { try { const blob = await API.downloadAttachmentBlob(attachmentId); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename || 'download'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (e) { UI.toast('Download failed: ' + e.message, 'error'); } } // ── Admin Storage Panel ───────────────────── async function loadAdminStorage() { const el = document.getElementById('adminStorageContent'); if (!el) return; el.innerHTML = '
Loading...
'; try { const [status, orphans] = await Promise.all([ API.adminGetStorageStatus().catch(() => null), API.adminGetOrphanCount().catch(() => null), ]); let html = ''; // Status card if (status) { const healthClass = status.healthy ? 'badge-success' : 'badge-danger'; const healthLabel = status.healthy ? 'Healthy' : 'Unhealthy'; html += `

Storage Backend

Backend ${_esc(status.backend || 'none')}
Status ${healthLabel}
Total Files ${status.total_files ?? '—'}
Total Size ${status.total_bytes ? formatFileSize(status.total_bytes) : '—'}
${status.path ? `
Path ${_esc(status.path)}
` : ''} ${status.endpoint ? `
Endpoint ${_esc(status.endpoint)}
` : ''} ${status.bucket ? `
Bucket ${_esc(status.bucket)}
` : ''}
`; } else { html += `

Storage Backend

Storage not configured. Set STORAGE_BACKEND=pvc or STORAGE_BACKEND=s3 to enable file uploads.

`; } // Orphan cleanup card if (orphans) { const count = orphans.count ?? 0; const bytes = orphans.total_bytes ?? 0; html += `

Orphan Files

Files uploaded but never linked to a message for >24 hours.

Orphaned ${count} file${count !== 1 ? 's' : ''}
Reclaimable ${formatFileSize(bytes)}
${count > 0 ? `` : ''}
`; } el.innerHTML = html; // Wire cleanup button document.getElementById('adminRunCleanup')?.addEventListener('click', async (e) => { const btn = e.target; btn.disabled = true; btn.textContent = 'Cleaning…'; try { const result = await API.adminRunStorageCleanup(); UI.toast(`Cleaned ${result.deleted || 0} orphan${result.deleted !== 1 ? 's' : ''}, freed ${formatFileSize(result.freed_bytes || 0)}`, 'success'); await loadAdminStorage(); // refresh counts } catch (err) { UI.toast('Cleanup failed: ' + err.message, 'error'); btn.disabled = false; btn.textContent = 'Run Cleanup Now'; } }); } catch (e) { el.innerHTML = `
Failed to load storage status: ${_esc(e.message)}
`; } } // ── Constants (Paste) ─────────────────────── // Threshold for auto-attaching pasted text as a file. // Synced from backend via PublicSettings (storage.paste_to_file_chars). // Falls back to 2000 if not yet loaded. function _pasteToFileChars() { return App.pasteToFileChars || 2000; } // Accept string for file picker — mirrors backend allowedMIMETypes. // Backend is authoritative (rejects disallowed types server-side), // this just gives the OS file dialog a better filter. const FILE_ACCEPT = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'application/pdf', 'text/plain', 'text/markdown', 'text/csv', '.docx', '.xlsx', '.pptx', '.doc', '.xls', '.odt', '.ods', '.odp', '.rtf', ].join(','); // ── MIME → Extension ──────────────────────── const _mimeToExt = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'image/svg+xml': 'svg', 'application/pdf': 'pdf', 'text/plain': 'txt', 'text/markdown': 'md', 'text/csv': 'csv', 'text/html': 'html', }; function _extFromMime(mime) { return _mimeToExt[mime] || mime.split('/').pop() || 'bin'; } // ── Send Button Blocking ──────────────────── function _updateSendBlock() { const sendBtn = document.getElementById('sendBtn'); if (!sendBtn) return; // Merge with existing generation check — don't override if generating if (App.isGenerating) return; if (isSendBlocked()) { sendBtn.disabled = true; sendBtn.title = 'Upload in progress…'; } else { sendBtn.disabled = false; sendBtn.title = 'Send'; } } // ── Init + Listeners ──────────────────────── /** * Called from app.js startApp(). Reads storage_configured from boot * payload and sets up attachment UI visibility. */ function initAttachments() { const attachBtn = document.getElementById('attachBtn'); const fileInput = document.getElementById('fileInput'); if (attachBtn) { attachBtn.style.display = App.storageConfigured ? '' : 'none'; } if (fileInput) { fileInput.accept = FILE_ACCEPT; } console.log(`📎 Attachments: storage ${App.storageConfigured ? 'configured' : 'not configured'}`); } /** * Called from app.js initListeners(). Wires up all attachment input * handlers: button, file picker, paste, and drag-and-drop. */ function _initAttachmentListeners() { const attachBtn = document.getElementById('attachBtn'); const fileInput = document.getElementById('fileInput'); const messageInput = document.getElementById('messageInput'); const chatArea = document.getElementById('chatMessages'); const lightbox = document.getElementById('lightbox'); // ── 📎 Button → File Picker ───────────── if (attachBtn && fileInput) { attachBtn.addEventListener('click', () => { if (!App.storageConfigured) return; fileInput.click(); }); fileInput.addEventListener('change', () => { const files = fileInput.files; if (!files?.length) return; for (const file of files) stageFile(file); fileInput.value = ''; // reset so same file can be re-selected }); } // ── Smart Paste ───────────────────────── if (messageInput) { messageInput.addEventListener('paste', (e) => { if (!App.storageConfigured) return; // fall through to normal paste const items = [...(e.clipboardData?.items || [])]; // Binary detection: images/files from clipboard const binaryItems = items.filter(i => i.kind === 'file'); if (binaryItems.length > 0) { e.preventDefault(); for (const item of binaryItems) { const file = item.getAsFile(); if (!file) continue; // Generate UUID filename — original clipboard items have no useful name const ext = _extFromMime(file.type); const renamed = new File([file], `${crypto.randomUUID()}.${ext}`, { type: file.type }); stageFile(renamed); } return; } // Large text detection — auto-attach as .txt file const _ptfChars = _pasteToFileChars(); if (_ptfChars > 0) { const text = e.clipboardData?.getData('text/plain'); if (text && text.length > _ptfChars) { e.preventDefault(); const blob = new Blob([text], { type: 'text/plain' }); const file = new File([blob], `${crypto.randomUUID()}.txt`, { type: 'text/plain' }); stageFile(file); return; } } // Below threshold or no text: normal paste (no preventDefault) }); } // ── Drag-and-Drop ─────────────────────── if (chatArea) { // Track drag enter/leave depth to avoid flicker from child elements let dragDepth = 0; chatArea.addEventListener('dragenter', (e) => { if (!App.storageConfigured) return; if (!_hasDragFiles(e)) return; e.preventDefault(); dragDepth++; if (dragDepth === 1) chatArea.classList.add('drag-over'); }); chatArea.addEventListener('dragover', (e) => { if (!App.storageConfigured) return; if (!_hasDragFiles(e)) return; e.preventDefault(); // required to allow drop e.dataTransfer.dropEffect = 'copy'; }); chatArea.addEventListener('dragleave', (e) => { if (!App.storageConfigured) return; dragDepth--; if (dragDepth <= 0) { dragDepth = 0; chatArea.classList.remove('drag-over'); } }); chatArea.addEventListener('drop', (e) => { if (!App.storageConfigured) return; e.preventDefault(); dragDepth = 0; chatArea.classList.remove('drag-over'); const files = e.dataTransfer?.files; if (!files?.length) return; for (const file of files) stageFile(file); }); } // ── Lightbox ──────────────────────────── if (lightbox) { // Click overlay (not the image) to close lightbox.addEventListener('click', (e) => { if (e.target === lightbox || e.target.classList.contains('lightbox-close')) { closeLightbox(); } }); } } /** * Check if a drag event contains files (not just text selection drag). */ function _hasDragFiles(e) { if (!e.dataTransfer?.types) return false; return e.dataTransfer.types.includes('Files'); }