Changeset 0.22.8 (#150)
This commit is contained in:
842
src/js/files.js
Normal file
842
src/js/files.js
Normal file
@@ -0,0 +1,842 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Files
|
||||
// ==========================================
|
||||
// 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 FILE_POLL_INTERVAL = 2000; // ms between extraction status checks
|
||||
const FILE_MAX_PER_MSG = 5; // mirrors backend defaultMaxFilesPerMsg
|
||||
|
||||
// MIME categories for capability-aware filtering
|
||||
const IMAGE_TYPES = new Set([
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
||||
]);
|
||||
|
||||
// ── State ───────────────────────────────────
|
||||
|
||||
// Staged files waiting to be sent with the next message.
|
||||
// Each entry:
|
||||
// {
|
||||
// localId: string — crypto.randomUUID(), stable key for DOM
|
||||
// serverId: string? — file 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.stagedFiles 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<void>}
|
||||
*/
|
||||
async function stageFile(file) {
|
||||
if (!App.storageConfigured) return;
|
||||
|
||||
// Enforce per-message limit
|
||||
if (App.stagedFiles.length >= FILE_MAX_PER_MSG) {
|
||||
UI.toast(`Maximum ${FILE_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.stagedFiles.push(staged);
|
||||
_renderStrip();
|
||||
_updateSendBlock();
|
||||
|
||||
// Ensure a channel exists (files 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.uploadFile(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('File upload failed:', e);
|
||||
}
|
||||
|
||||
_renderStrip();
|
||||
_updateSendBlock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a staged file by localId.
|
||||
* Revokes object URL, stops polling, and optionally deletes from backend.
|
||||
*/
|
||||
async function unstageFile(localId) {
|
||||
const idx = App.stagedFiles.findIndex(a => a.localId === localId);
|
||||
if (idx === -1) return;
|
||||
|
||||
const staged = App.stagedFiles[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.deleteFile(staged.serverId);
|
||||
} catch (e) {
|
||||
console.warn('Failed to delete staged file:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
App.stagedFiles.splice(idx, 1);
|
||||
_renderStrip();
|
||||
_updateSendBlock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all staged files (chat switch, cancel, etc.)
|
||||
* Does NOT delete from backend — orphan cleanup handles abandoned uploads.
|
||||
*/
|
||||
function clearStaged() {
|
||||
for (const staged of App.stagedFiles) {
|
||||
if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl);
|
||||
_stopPolling(staged.localId);
|
||||
}
|
||||
App.stagedFiles = [];
|
||||
_renderStrip();
|
||||
_updateSendBlock();
|
||||
}
|
||||
|
||||
|
||||
// ── Send Integration ────────────────────────
|
||||
|
||||
/**
|
||||
* Returns file IDs (server-side UUIDs) for the current staged set.
|
||||
* Only includes successfully uploaded files.
|
||||
*/
|
||||
function getStagedFileIds() {
|
||||
return App.stagedFiles
|
||||
.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.stagedFiles.some(a => a.status === 'uploading');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether there are any staged files (for UI visibility).
|
||||
*/
|
||||
function hasStagedFiles() {
|
||||
return App.stagedFiles.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-send cleanup: detach staged list without deleting from backend
|
||||
* (files are now linked to the sent message).
|
||||
*/
|
||||
function consumeStaged() {
|
||||
for (const staged of App.stagedFiles) {
|
||||
if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl);
|
||||
_stopPolling(staged.localId);
|
||||
}
|
||||
App.stagedFiles = [];
|
||||
_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.getFile(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
|
||||
}
|
||||
}, FILE_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 file.
|
||||
*/
|
||||
function fileStatusLabel(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 isImageFile(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('fileStrip');
|
||||
if (!strip) return;
|
||||
|
||||
if (App.stagedFiles.length === 0) {
|
||||
strip.style.display = 'none';
|
||||
strip.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
strip.style.display = 'flex';
|
||||
strip.innerHTML = App.stagedFiles.map(a => {
|
||||
const icon = isImageFile(a.contentType) ? '🖼' : '📄';
|
||||
const name = _truncFilename(a.filename, 20);
|
||||
const size = formatFileSize(a.sizeBytes);
|
||||
const label = fileStatusLabel(a);
|
||||
const statusClass = a.status === 'error' || a.status === 'failed' ? 'att-error' :
|
||||
a.status === 'complete' ? 'att-ready' : 'att-pending';
|
||||
|
||||
return `<div class="att-chip ${statusClass}" data-local-id="${a.localId}">
|
||||
${a.previewUrl ? `<img class="att-thumb" src="${a.previewUrl}" alt="">` : `<span class="att-icon">${icon}</span>`}
|
||||
<span class="att-info">
|
||||
<span class="att-name" title="${_esc(a.filename)}">${_esc(name)}</span>
|
||||
<span class="att-meta">${_esc(size)}${label ? ' · ' + label : ''}</span>
|
||||
</span>
|
||||
<button class="att-remove" onclick="unstageFile('${a.localId}')" title="Remove">✕</button>
|
||||
</div>`;
|
||||
}).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 File Loading ──────────────
|
||||
|
||||
/**
|
||||
* Fetch all files for a channel, build messageId → [att] map.
|
||||
* Called after loading messages in selectChat / reloadActivePath.
|
||||
* Non-blocking: failures just leave the map empty (files are optional UX).
|
||||
*/
|
||||
async function loadChannelFiles(channelId) {
|
||||
App.channelFiles = {};
|
||||
if (!channelId || !App.storageConfigured) return;
|
||||
|
||||
try {
|
||||
const data = await API.listChannelFiles(channelId);
|
||||
const list = data.files || data || [];
|
||||
for (const att of list) {
|
||||
if (!att.message_id) continue;
|
||||
if (!App.channelFiles[att.message_id]) {
|
||||
App.channelFiles[att.message_id] = [];
|
||||
}
|
||||
App.channelFiles[att.message_id].push(att);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('File load skipped:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Message File Rendering ────────────
|
||||
|
||||
/**
|
||||
* Returns HTML string for files 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 renderMessageFiles(msgId) {
|
||||
if (!msgId) return '';
|
||||
const atts = App.channelFiles[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 = isImageFile(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
|
||||
? '<span class="att-vision-hint" title="Current model does not support images">👁️🗨️</span>'
|
||||
: '';
|
||||
return `<div class="msg-att msg-att-image" onclick="openLightbox('${_esc(att.id)}')">
|
||||
<img class="msg-att-img" data-att-id="${_esc(att.id)}" alt="${_esc(att.filename)}"
|
||||
style="max-width:${w}px" loading="lazy">
|
||||
${visionHint}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Document chip
|
||||
const icon = _docIcon(att.content_type);
|
||||
const size = formatFileSize(att.size_bytes);
|
||||
return `<a class="msg-att msg-att-doc" href="#" onclick="downloadFile('${_esc(att.id)}','${_esc(att.filename)}');return false">
|
||||
<span class="att-doc-icon">${icon}</span>
|
||||
<span class="att-doc-info">
|
||||
<span class="att-doc-name">${_esc(att.filename)}</span>
|
||||
<span class="att-doc-size">${size}</span>
|
||||
</span>
|
||||
<span class="att-doc-dl">⬇</span>
|
||||
</a>`;
|
||||
});
|
||||
|
||||
return `<div class="msg-files">${items.join('')}</div>`;
|
||||
}
|
||||
|
||||
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(fileId) {
|
||||
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.downloadFileBlob(fileId)
|
||||
.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 <img data-att-id="..."> 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.downloadFileBlob(attId)
|
||||
.then(blob => { img.src = URL.createObjectURL(blob); })
|
||||
.catch(() => {
|
||||
img.alt = '⚠ Load failed';
|
||||
img.classList.add('att-load-error');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Document Download ───────────────────────
|
||||
|
||||
/**
|
||||
* Download a file via auth-aware fetch.
|
||||
* Creates a temporary <a> element to trigger the browser download dialog.
|
||||
*/
|
||||
async function downloadFile(fileId, filename) {
|
||||
try {
|
||||
const blob = await API.downloadFileBlob(fileId);
|
||||
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 = '<div class="loading">Loading...</div>';
|
||||
|
||||
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 += `<div class="admin-storage-card">
|
||||
<h4>Storage Backend</h4>
|
||||
<div class="admin-storage-grid">
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Backend</span>
|
||||
<span class="admin-storage-value">${_esc(status.backend || 'none')}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Status</span>
|
||||
<span class="badge ${healthClass}">${healthLabel}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Total Files</span>
|
||||
<span class="admin-storage-value">${status.total_files ?? '—'}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Total Size</span>
|
||||
<span class="admin-storage-value">${status.total_bytes ? formatFileSize(status.total_bytes) : '—'}</span>
|
||||
</div>
|
||||
${status.path ? `<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Path</span>
|
||||
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${_esc(status.path)}</span>
|
||||
</div>` : ''}
|
||||
${status.endpoint ? `<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Endpoint</span>
|
||||
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${_esc(status.endpoint)}</span>
|
||||
</div>` : ''}
|
||||
${status.bucket ? `<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Bucket</span>
|
||||
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${_esc(status.bucket)}</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
html += `<div class="admin-storage-card">
|
||||
<h4>Storage Backend</h4>
|
||||
<p class="empty-hint">Storage not configured. Set STORAGE_BACKEND=pvc or STORAGE_BACKEND=s3 to enable file uploads.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Orphan cleanup card
|
||||
if (orphans) {
|
||||
const count = orphans.count ?? 0;
|
||||
const bytes = orphans.total_bytes ?? 0;
|
||||
html += `<div class="admin-storage-card">
|
||||
<h4>Orphan Files</h4>
|
||||
<p class="admin-storage-desc">Files uploaded but never linked to a message for >24 hours.</p>
|
||||
<div class="admin-storage-grid">
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Orphaned</span>
|
||||
<span class="admin-storage-value">${count} file${count !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Reclaimable</span>
|
||||
<span class="admin-storage-value">${formatFileSize(bytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
${count > 0 ? `<button class="btn-small" id="adminRunCleanup" style="margin-top:8px">Run Cleanup Now</button>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = `<div class="empty-hint">Failed to load storage status: ${_esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── 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 file UI visibility.
|
||||
*/
|
||||
function initFiles() {
|
||||
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(`📎 Files: storage ${App.storageConfigured ? 'configured' : 'not configured'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from app.js initListeners(). Wires up all file input
|
||||
* handlers: button, file picker, paste, and drag-and-drop.
|
||||
*/
|
||||
function _initFileListeners() {
|
||||
const attachBtn = document.getElementById('attachBtn');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
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 ─────────────────────────
|
||||
// Attach to the active input element (CM6 contentDOM or textarea)
|
||||
const pasteTarget = (typeof ChatInput !== 'undefined' && ChatInput.getDom())
|
||||
? ChatInput.getDom()
|
||||
: document.getElementById('messageInput');
|
||||
if (pasteTarget) {
|
||||
pasteTarget.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');
|
||||
}
|
||||
Reference in New Issue
Block a user