Changeset 0.12.0 (#63)
This commit is contained in:
@@ -226,7 +226,7 @@ const API = {
|
||||
|
||||
// ── Completions ──────────────────────────
|
||||
|
||||
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId) {
|
||||
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId, attachmentIds) {
|
||||
const body = { channel_id: channelId, content, stream: true };
|
||||
if (presetId) {
|
||||
body.preset_id = presetId;
|
||||
@@ -238,6 +238,7 @@ const API = {
|
||||
if (apiConfigId) body.provider_config_id = apiConfigId;
|
||||
// Only send max_tokens if user explicitly set it (non-zero = override)
|
||||
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
|
||||
if (attachmentIds?.length) body.attachment_ids = attachmentIds;
|
||||
|
||||
let resp = await fetch(BASE + '/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
@@ -455,6 +456,66 @@ const API = {
|
||||
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
|
||||
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
|
||||
|
||||
// ── Attachments ─────────────────────────
|
||||
|
||||
/**
|
||||
* Upload a file to a channel. Uses multipart/form-data (not JSON).
|
||||
* Returns the created attachment object with id, metadata, etc.
|
||||
*/
|
||||
async uploadAttachment(channelId, file) {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
|
||||
const doFetch = () => fetch(BASE + `/api/v1/channels/${channelId}/attachments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${this.accessToken}` },
|
||||
// No Content-Type — browser sets multipart boundary automatically
|
||||
body: form,
|
||||
});
|
||||
|
||||
let resp = await doFetch();
|
||||
if (resp.status === 401 && this.refreshToken) {
|
||||
if (await this.refresh()) resp = await doFetch();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
const err = new Error(data.error || `Upload failed: HTTP ${resp.status}`);
|
||||
err.status = resp.status;
|
||||
throw err;
|
||||
}
|
||||
return this._parseJSON(resp, `/api/v1/channels/${channelId}/attachments`);
|
||||
},
|
||||
|
||||
getAttachment(id) { return this._get(`/api/v1/attachments/${id}`); },
|
||||
|
||||
async downloadAttachmentBlob(id) {
|
||||
const doFetch = () => fetch(BASE + `/api/v1/attachments/${id}/download`, {
|
||||
headers: { 'Authorization': `Bearer ${this.accessToken}` },
|
||||
});
|
||||
|
||||
let resp = await doFetch();
|
||||
if (resp.status === 401 && this.refreshToken) {
|
||||
if (await this.refresh()) resp = await doFetch();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Download failed: HTTP ${resp.status}`);
|
||||
}
|
||||
return resp.blob();
|
||||
},
|
||||
|
||||
deleteAttachment(id) { return this._del(`/api/v1/attachments/${id}`); },
|
||||
listChannelAttachments(channelId) { return this._get(`/api/v1/channels/${channelId}/attachments`); },
|
||||
|
||||
// ── Admin Storage ───────────────────────
|
||||
adminGetStorageStatus() { return this._get('/api/v1/admin/storage/status'); },
|
||||
adminGetOrphanCount() { return this._get('/api/v1/admin/storage/orphans'); },
|
||||
adminRunStorageCleanup() { return this._post('/api/v1/admin/storage/cleanup', {}); },
|
||||
adminGetExtractionStatus() { return this._get('/api/v1/admin/storage/extraction'); },
|
||||
|
||||
// Vault
|
||||
adminGetVaultStatus() { return this._get('/api/v1/admin/vault/status'); },
|
||||
|
||||
// ── Admin Roles ─────────────────────────
|
||||
adminListRoles() { return this._get('/api/v1/admin/roles'); },
|
||||
adminGetRole(role) { return this._get(`/api/v1/admin/roles/${role}`); },
|
||||
|
||||
@@ -14,6 +14,9 @@ const App = {
|
||||
abortController: null,
|
||||
serverSettings: {},
|
||||
policies: {},
|
||||
stagedAttachments: [],
|
||||
channelAttachments: {}, // messageId → [attachment, ...] — populated on chat load
|
||||
storageConfigured: false,
|
||||
|
||||
// Find model by composite ID, with fallback to bare model_id match
|
||||
findModel(id) {
|
||||
@@ -183,6 +186,7 @@ async function startApp() {
|
||||
}
|
||||
|
||||
await initBanners();
|
||||
initAttachments();
|
||||
UI.renderChatList();
|
||||
UI.updateModelSelector();
|
||||
UI.updateUser();
|
||||
@@ -370,6 +374,7 @@ function initListeners() {
|
||||
_initSettingsListeners(); // from settings-handlers.js
|
||||
_initAdminListeners(); // from admin-handlers.js
|
||||
_initNotesListeners(); // from notes.js
|
||||
_initAttachmentListeners(); // from attachments.js
|
||||
_initGlobalKeyboard(); // local: Escape, Ctrl+K, resize
|
||||
_initSidePanelResize(); // from ui-format.js
|
||||
}
|
||||
@@ -380,6 +385,9 @@ function _initGlobalKeyboard() {
|
||||
// Escape: stop generation → close command palette → close side panel → close topmost modal
|
||||
if (e.key === 'Escape') {
|
||||
if (App.isGenerating) { stopGeneration(); return; }
|
||||
if (document.getElementById('lightbox')?.classList.contains('active')) {
|
||||
closeLightbox(); return;
|
||||
}
|
||||
if (document.getElementById('cmdPalette')?.classList.contains('active')) {
|
||||
closeCmdPalette(); return;
|
||||
}
|
||||
@@ -532,6 +540,9 @@ async function initBanners() {
|
||||
// Store policies for user-facing checks (allow_user_byok, etc.)
|
||||
App.policies = data.policies || {};
|
||||
|
||||
// Storage capabilities (file upload, vision)
|
||||
App.storageConfigured = !!data.storage_configured;
|
||||
|
||||
// Also flatten into serverSettings for backward compat
|
||||
App.serverSettings = {};
|
||||
if (data.banner) App.serverSettings.banner = data.banner;
|
||||
|
||||
836
src/js/attachments.js
Normal file
836
src/js/attachments.js
Normal file
@@ -0,0 +1,836 @@
|
||||
// ==========================================
|
||||
// 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<void>}
|
||||
*/
|
||||
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 `<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 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
|
||||
? '<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="downloadAttachment('${_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-attachments">${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(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 <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.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 <a> 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 = '<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.
|
||||
// TODO: sync from backend global_settings (storage_paste_to_file_chars)
|
||||
// once it's included in the PublicSettings boot payload.
|
||||
const PASTE_TO_FILE_CHARS = 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
|
||||
if (PASTE_TO_FILE_CHARS > 0) {
|
||||
const text = e.clipboardData?.getData('text/plain');
|
||||
if (text && text.length > PASTE_TO_FILE_CHARS) {
|
||||
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');
|
||||
}
|
||||
@@ -64,7 +64,8 @@ async function loadChats() {
|
||||
model: c.model || '',
|
||||
messageCount: c.message_count || 0,
|
||||
messages: [],
|
||||
updatedAt: c.updated_at
|
||||
updatedAt: c.updated_at,
|
||||
settings: c.settings || {},
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to load chats:', e.message);
|
||||
@@ -73,6 +74,7 @@ async function loadChats() {
|
||||
}
|
||||
|
||||
async function selectChat(chatId) {
|
||||
clearStaged(); // Discard any staged attachments from previous chat
|
||||
App.currentChatId = chatId;
|
||||
UI.renderChatList();
|
||||
if (window.innerWidth <= 768) {
|
||||
@@ -109,6 +111,9 @@ async function selectChat(chatId) {
|
||||
// Restore the last-used model/preset for this chat
|
||||
_restoreChatModel(chatId, chat);
|
||||
|
||||
// Load attachments for message rendering (non-blocking, best-effort)
|
||||
await loadChannelAttachments(chatId);
|
||||
|
||||
UI.renderMessages(chat.messages);
|
||||
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
||||
Tokens._warningDismissed = false;
|
||||
@@ -117,46 +122,63 @@ async function selectChat(chatId) {
|
||||
}
|
||||
|
||||
// ── Per-Chat Model/Preset Persistence ────────
|
||||
// BANDAID: localStorage — doesn't roam across devices.
|
||||
// TODO: move to channel.settings.last_selector_id (server-side). See ROADMAP TBD.
|
||||
// Server-side: saved in channel.settings.last_selector_id (JSONB merge).
|
||||
// localStorage used as write-through cache for instant restore without
|
||||
// waiting for the channel list API call.
|
||||
|
||||
function _saveChatModel(chatId) {
|
||||
if (!chatId) return;
|
||||
const selectorId = UI.getModelValue();
|
||||
if (!selectorId) return;
|
||||
|
||||
// Write-through: localStorage for instant restore on next visit
|
||||
try {
|
||||
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
||||
map[chatId] = selectorId;
|
||||
localStorage.setItem('cs-chat-models', JSON.stringify(map));
|
||||
} catch (e) { /* quota exceeded, etc */ }
|
||||
|
||||
// Update in-memory chat object
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
if (chat) {
|
||||
if (!chat.settings) chat.settings = {};
|
||||
chat.settings.last_selector_id = selectorId;
|
||||
}
|
||||
|
||||
// Persist to server (fire-and-forget — non-critical)
|
||||
API.updateChannel(chatId, { settings: { last_selector_id: selectorId } })
|
||||
.catch(e => console.debug('Failed to persist model selection:', e.message));
|
||||
}
|
||||
|
||||
function _restoreChatModel(chatId, chat) {
|
||||
// Priority: stored selector ID → channel base model → keep current
|
||||
// Priority: server settings → localStorage fallback → channel base model → keep current
|
||||
let targetId = null;
|
||||
|
||||
// 1. Check localStorage for exact selector ID (handles presets)
|
||||
try {
|
||||
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
||||
if (map[chatId]) targetId = map[chatId];
|
||||
} catch (e) { /* */ }
|
||||
// 1. Server-side settings (most authoritative, roams across devices)
|
||||
if (chat?.settings?.last_selector_id) {
|
||||
targetId = chat.settings.last_selector_id;
|
||||
}
|
||||
|
||||
// 2. Verify the stored ID still exists in available models
|
||||
// 2. localStorage fallback (covers race where settings haven't loaded yet)
|
||||
if (!targetId) {
|
||||
try {
|
||||
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
||||
if (map[chatId]) targetId = map[chatId];
|
||||
} catch (e) { /* */ }
|
||||
}
|
||||
|
||||
// 3. Verify the stored ID still exists in available models
|
||||
if (targetId) {
|
||||
const found = App.models.find(m => m.id === targetId);
|
||||
if (found) {
|
||||
UI.setModelValue(found.id, found.name + (found.provider ? ` (${found.provider})` : ''));
|
||||
return;
|
||||
}
|
||||
// Stored model removed — clean up stale entry
|
||||
try {
|
||||
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
||||
delete map[chatId];
|
||||
localStorage.setItem('cs-chat-models', JSON.stringify(map));
|
||||
} catch (e) { /* */ }
|
||||
// Stored model removed — clean up stale entries
|
||||
_cleanStaleChatModel(chatId);
|
||||
}
|
||||
|
||||
// 3. Fall back to channel's base model ID (match by baseModelId)
|
||||
// 4. Fall back to channel's base model ID (match by baseModelId)
|
||||
if (chat?.model) {
|
||||
const match = App.models.find(m => m.baseModelId === chat.model && !m.isPreset && !m.hidden);
|
||||
if (match) {
|
||||
@@ -165,7 +187,7 @@ function _restoreChatModel(chatId, chat) {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Stored model gone — fall back to admin default → first visible
|
||||
// 5. Stored model gone — fall back to admin default → first visible
|
||||
const visible = App.models.filter(m => !m.hidden);
|
||||
const def = App.defaultModel
|
||||
? visible.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel)
|
||||
@@ -176,7 +198,22 @@ function _restoreChatModel(chatId, chat) {
|
||||
}
|
||||
}
|
||||
|
||||
function _cleanStaleChatModel(chatId) {
|
||||
// Remove from localStorage
|
||||
try {
|
||||
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
||||
delete map[chatId];
|
||||
localStorage.setItem('cs-chat-models', JSON.stringify(map));
|
||||
} catch (e) { /* */ }
|
||||
// Clear from in-memory settings (server cleanup is fire-and-forget)
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
if (chat?.settings?.last_selector_id) {
|
||||
delete chat.settings.last_selector_id;
|
||||
}
|
||||
}
|
||||
|
||||
async function newChat() {
|
||||
clearStaged(); // Discard any staged attachments
|
||||
App.currentChatId = null;
|
||||
UI.renderChatList();
|
||||
UI.showEmptyState();
|
||||
@@ -213,11 +250,17 @@ async function deleteChat(chatId) {
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById('messageInput');
|
||||
const text = input.value.trim();
|
||||
if (!text || App.isGenerating) return;
|
||||
const hasAttachments = hasStagedAttachments();
|
||||
|
||||
// Need text or attachments, not generating, not blocked by uploads
|
||||
if ((!text && !hasAttachments) || App.isGenerating || isSendBlocked()) return;
|
||||
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
|
||||
// Snapshot attachment IDs before clearing staged state
|
||||
const attachmentIds = getStagedAttachmentIds();
|
||||
|
||||
const selectedId = UI.getModelValue();
|
||||
const modelInfo = App.findModel(selectedId);
|
||||
// For presets, send the base model ID; for regular models, send the model ID
|
||||
@@ -239,15 +282,19 @@ async function sendMessage() {
|
||||
if (!chat) return;
|
||||
|
||||
// Optimistic: show user message immediately
|
||||
chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
|
||||
const userContent = text || (hasAttachments ? `[${attachmentIds.length} file${attachmentIds.length > 1 ? 's' : ''} attached]` : '');
|
||||
chat.messages.push({ role: 'user', content: userContent, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
|
||||
UI.renderMessages(chat.messages);
|
||||
|
||||
// Clear staged attachments (they're now part of this message)
|
||||
if (hasAttachments) consumeStaged();
|
||||
|
||||
App.isGenerating = true;
|
||||
App.abortController = new AbortController();
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId);
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId, attachmentIds);
|
||||
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
||||
|
||||
// Reload active path from server to get proper IDs and sibling metadata
|
||||
@@ -307,6 +354,7 @@ async function reloadActivePath() {
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
}));
|
||||
chat.messageCount = chat.messages.length;
|
||||
await loadChannelAttachments(App.currentChatId);
|
||||
UI.renderMessages(chat.messages);
|
||||
updateContextWarning();
|
||||
} catch (e) {
|
||||
|
||||
@@ -76,6 +76,7 @@ Object.assign(UI, {
|
||||
if (tab === 'roles') await this.loadAdminRoles();
|
||||
if (tab === 'usage') await this.loadAdminUsage();
|
||||
if (tab === 'extensions') await this.loadAdminExtensions();
|
||||
if (tab === 'storage' && typeof loadAdminStorage === 'function') await loadAdminStorage();
|
||||
},
|
||||
|
||||
async loadAdminUsers(quiet) {
|
||||
@@ -679,6 +680,34 @@ Object.assign(UI, {
|
||||
sel.innerHTML += `<option value="${esc(key)}">${esc(p.text || key)}</option>`;
|
||||
});
|
||||
UI._bannerPresets = presets;
|
||||
|
||||
// Vault / Encryption status
|
||||
const vaultEl = document.getElementById('adminVaultStatus');
|
||||
if (vaultEl) {
|
||||
try {
|
||||
const vault = await API.adminGetVaultStatus();
|
||||
const keyBadge = vault.encryption_key_set
|
||||
? '<span class="badge badge-success">Active</span>'
|
||||
: '<span class="badge badge-warning">Not Set</span>';
|
||||
vaultEl.innerHTML = `
|
||||
<div class="admin-storage-grid" style="margin-top:6px">
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Encryption</span>
|
||||
${keyBadge}
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Encrypted Keys</span>
|
||||
<span class="admin-storage-value">${vault.encrypted_keys}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Vault Users</span>
|
||||
<span class="admin-storage-value">${vault.vault_users}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
vaultEl.innerHTML = '<span class="empty-hint">Could not load vault status</span>';
|
||||
}
|
||||
}
|
||||
} catch (e) { console.debug('Failed to load admin settings:', e); }
|
||||
},
|
||||
|
||||
|
||||
@@ -353,6 +353,7 @@ const UI = {
|
||||
|
||||
el.innerHTML = html;
|
||||
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(el);
|
||||
if (typeof loadAuthImages === 'function') loadAuthImages(el);
|
||||
this._scrollToBottom(true);
|
||||
},
|
||||
|
||||
@@ -420,6 +421,7 @@ const UI = {
|
||||
</div>
|
||||
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
|
||||
<div class="msg-text">${formatMessage(msg.content)}</div>
|
||||
${typeof renderMessageAttachments === 'function' ? renderMessageAttachments(msgId) : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
Reference in New Issue
Block a user