Changeset 0.12.0 (#63)

This commit is contained in:
2026-02-25 21:38:49 +00:00
parent c9d8e9457e
commit 88216ec4cb
59 changed files with 13115 additions and 139 deletions

View File

@@ -901,6 +901,133 @@ a:hover { text-decoration: underline; }
.stop-btn { display: none; color: var(--warning); }
.stop-btn.visible { display: flex; }
/* ── Attachments ─────────────────────────── */
.attach-btn { color: var(--text-3); padding: 6px 4px 12px 10px; }
.attach-btn:hover { color: var(--text); }
.attachment-strip {
max-width: 768px; margin: 0 auto; padding: 6px 8px 2px;
display: flex; gap: 6px; flex-wrap: wrap;
}
.att-chip {
display: flex; align-items: center; gap: 6px;
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius); padding: 4px 6px;
font-size: 12px; max-width: 220px; position: relative;
}
.att-chip.att-ready { border-color: color-mix(in srgb, var(--success) 40%, var(--border)); }
.att-chip.att-error { border-color: color-mix(in srgb, var(--error) 40%, var(--border)); }
.att-chip.att-pending { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); }
.att-thumb {
width: 32px; height: 32px; object-fit: cover;
border-radius: 3px; flex-shrink: 0;
}
.att-icon { font-size: 18px; flex-shrink: 0; line-height: 1; }
.att-info { display: flex; flex-direction: column; min-width: 0; }
.att-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); font-weight: 500; }
.att-meta { color: var(--text-3); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.att-remove {
background: none; border: none; color: var(--text-3); cursor: pointer;
padding: 2px 4px; font-size: 13px; line-height: 1; border-radius: 3px;
flex-shrink: 0; margin-left: auto;
}
.att-remove:hover { background: var(--bg-hover); color: var(--text); }
/* Drag-and-drop overlay on chat area */
.messages.drag-over {
outline: 2px dashed var(--accent);
outline-offset: -4px;
background: color-mix(in srgb, var(--accent) 4%, var(--bg));
}
/* ── Message Attachments ────────────────────── */
.msg-attachments {
display: flex; gap: 8px; flex-wrap: wrap;
margin-top: 8px;
}
.msg-att-image {
cursor: pointer; position: relative; border-radius: var(--radius);
overflow: hidden; border: 1px solid var(--border);
transition: border-color var(--transition);
}
.msg-att-image:hover { border-color: var(--accent); }
.msg-att-image img {
display: block; max-height: 280px; border-radius: var(--radius);
background: var(--bg-raised);
}
.msg-att-image img.att-load-error {
min-width: 120px; min-height: 60px;
display: flex; align-items: center; justify-content: center;
font-size: 13px; color: var(--text-3);
}
.att-vision-hint {
position: absolute; top: 4px; right: 4px;
background: color-mix(in srgb, var(--bg) 85%, transparent);
border-radius: 4px; padding: 2px 5px; font-size: 14px;
cursor: help;
}
.msg-att-doc {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--bg-raised);
text-decoration: none; color: var(--text);
max-width: 280px; transition: border-color var(--transition);
}
.msg-att-doc:hover { border-color: var(--accent); }
.att-doc-icon { font-size: 20px; flex-shrink: 0; }
.att-doc-info { display: flex; flex-direction: column; min-width: 0; }
.att-doc-name {
font-size: 13px; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.att-doc-size { font-size: 11px; color: var(--text-3); }
.att-doc-dl { color: var(--text-3); font-size: 14px; margin-left: auto; }
/* ── Image Lightbox ─────────────────────────── */
.lightbox-overlay {
display: none; position: fixed; inset: 0; z-index: 10000;
background: rgba(0,0,0,0.85); align-items: center; justify-content: center;
}
.lightbox-overlay.active { display: flex; }
.lightbox-overlay img {
max-width: 92vw; max-height: 90vh; object-fit: contain;
border-radius: 4px; box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.lightbox-close {
position: absolute; top: 16px; right: 20px;
background: rgba(255,255,255,0.15); border: none; color: #fff;
font-size: 22px; width: 40px; height: 40px; border-radius: 50%;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.15s;
}
.lightbox-close:hover { background: rgba(255,255,255,0.3); }
/* ── Admin Storage Panel ────────────────────── */
.admin-storage-card {
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px; margin-bottom: 12px;
}
.admin-storage-card h4 { margin: 0 0 10px 0; font-size: 14px; }
.admin-storage-desc { font-size: 12px; color: var(--text-3); margin: 0 0 10px 0; }
.admin-storage-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px;
}
.admin-storage-item { display: flex; flex-direction: column; gap: 2px; }
.admin-storage-label { font-size: 11px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; }
.admin-storage-value { font-size: 14px; font-weight: 500; }
/* ── Buttons ─────────────────────────────── */
button { font-family: var(--font); cursor: pointer; }
@@ -1354,6 +1481,9 @@ button { font-family: var(--font); cursor: pointer; }
.team-admin-back:hover { opacity: 1; }
.team-tab-content { padding: 0; }
.badge-private { background: rgba(139,92,246,0.85); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-success { background: rgba(34,197,94,0.2); color: #4ade80; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-warning { background: rgba(234,179,8,0.2); color: #fbbf24; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-danger { background: rgba(239,68,68,0.2); color: #f87171; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.admin-user-row.user-inactive { opacity: 0.7; }
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }

View File

@@ -147,7 +147,12 @@
<button class="context-warning-action" id="summarizeBtn" onclick="summarizeAndContinue()" title="Summarize conversation and continue with reduced context" style="display:none">📝 Summarize & Continue</button>
<button class="context-warning-dismiss" onclick="dismissContextWarning()" title="Dismiss"></button>
</div>
<div class="attachment-strip" id="attachmentStrip" style="display:none"></div>
<div class="input-wrap">
<button class="action-btn attach-btn" id="attachBtn" title="Attach file" style="display:none">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
</button>
<input type="file" id="fileInput" multiple style="display:none">
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
<div class="input-actions">
<button class="action-btn stop-btn" id="stopBtn" title="Stop">
@@ -566,6 +571,7 @@
<button class="admin-tab" data-tab="roles">Roles</button>
<button class="admin-tab" data-tab="usage">Usage</button>
<button class="admin-tab" data-tab="extensions">Extensions</button>
<button class="admin-tab" data-tab="storage">Storage</button>
<button class="admin-tab" data-tab="stats">Stats</button>
<button class="admin-tab" data-tab="audit">Audit</button>
</div>
@@ -706,6 +712,10 @@
<div class="banner-preview" id="bannerPreview">PREVIEW</div>
</div>
</section>
<section class="admin-section">
<h4>🔐 Encryption</h4>
<div id="adminVaultStatus"><span class="empty-hint">Loading…</span></div>
</section>
<button class="btn-primary btn-small" id="adminSaveSettings">Save Settings</button>
</div>
@@ -766,6 +776,7 @@
</div>
</div>
</div>
<div class="admin-tab-content" id="adminStorageTab" style="display:none"><div id="adminStorageContent"></div></div>
<div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
<div class="admin-tab-content" id="adminAuditTab" style="display:none">
<div class="admin-toolbar" style="margin-bottom:8px">
@@ -858,6 +869,7 @@
<script src="js/ui-admin.js?v=%%APP_VERSION%%"></script>
<script src="js/tokens.js?v=%%APP_VERSION%%"></script>
<script src="js/notes.js?v=%%APP_VERSION%%"></script>
<script src="js/attachments.js?v=%%APP_VERSION%%"></script>
<script src="js/chat.js?v=%%APP_VERSION%%"></script>
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>
@@ -908,5 +920,12 @@ window.addEventListener('appinstalled', () => {
_deferredInstallPrompt = null;
});
</script>
<!-- Image lightbox overlay -->
<div class="lightbox-overlay" id="lightbox">
<button class="lightbox-close" title="Close (Esc)"></button>
<img id="lightboxImg" alt="">
</div>
</body>
</html>

View File

@@ -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}`); },

View File

@@ -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
View 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 — 0100 (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 &gt;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');
}

View File

@@ -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) {

View File

@@ -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); }
},

View File

@@ -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>`;

View File

@@ -21,6 +21,7 @@ const SHELL_FILES = [
'./js/ui-admin.js',
'./js/tokens.js',
'./js/notes.js',
'./js/attachments.js',
'./js/chat.js',
'./js/settings-handlers.js',
'./js/admin-handlers.js',