Files
odysseus/static/js/fileHandler.js

292 lines
8.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
const MAX_FILES = 10;
const MAX_VISIBLE = 3;
let _expanded = false;
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();
}
/**
* 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');
badge.style.cursor = 'pointer';
badge.addEventListener('click', (e) => {
if (e.target.closest('.thumb-collapsed-x')) return;
_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();
}
}
/**
* 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;