Changeset 0.22.8 (#150)

This commit is contained in:
2026-03-04 16:06:12 +00:00
parent 389e47b0f9
commit 7e26a2a261
114 changed files with 3700 additions and 7572 deletions

View File

@@ -291,11 +291,11 @@ async function createAdminPersona(vals) {
try {
let personaId = _editingPersonaId;
if (_editingPersonaId) {
await API.adminUpdatePersona(_editingPersonaId, preset);
await API.adminUpdatePersona(_editingPersonaId, persona);
UI.toast('Persona updated', 'success');
} else {
const result = await API.adminCreatePersona(preset);
personaId = result?.id || result?.preset?.id;
const result = await API.adminCreatePersona(persona);
personaId = result?.id || result?.persona?.id;
UI.toast('Persona created', 'success');
}
@@ -307,7 +307,7 @@ async function createAdminPersona(vals) {
// Save KB bindings (v0.17.0)
if (personaId && vals._kbValues && _adminPersonaForm?.kbPicker) {
try { await API.adminSetPresetKBs(personaId, vals._kbValues); }
try { await API.adminSetPersonaKBs(personaId, vals._kbValues); }
catch (e) { console.warn('Persona KB binding failed:', e.message); }
}
@@ -403,7 +403,7 @@ async function settingsRemoveTeamMember(teamId, memberId, email) {
async function settingsDeleteTeamPersona(teamId, personaId, name) {
if (!await showConfirm(`Delete team persona "${name}"?`)) return;
try {
await API.teamDeletePreset(teamId, personaId);
await API.teamDeletePersona(teamId, personaId);
UI.toast('Persona deleted');
await UI.loadTeamManagePersonas(teamId);
await fetchModels();

View File

@@ -146,11 +146,7 @@
'<div class="settings-section"><h4>Environment Banner</h4>' +
'<label class="checkbox-label"><input type="checkbox" id="adminBannerEnabled"> Show environment banner</label>' +
'<div id="bannerConfigFields" style="display:none;margin-top:8px">' +
'<div class="form-group"><label>Preset</label><select id="adminBannerPreset"><option value="">Custom</option></select></div>' +
'<div class="form-group"><label>Text</label><input type="text" id="adminBannerText" placeholder="DEVELOPMENT"></div>' +
'<div class="form-group"><label>Position</label>' +
'<select id="adminBannerPosition"><option value="both">Top &amp; Bottom</option><option value="top">Top</option><option value="bottom">Bottom</option></select>' +
'</div>' +
'<div class="form-row">' +
'<div class="form-group"><label>Background</label><input type="color" id="adminBannerBg" value="#007a33"><input type="text" id="adminBannerBgHex" value="#007a33" style="width:80px;margin-left:4px"></div>' +
'<div class="form-group"><label>Foreground</label><input type="color" id="adminBannerFg" value="#ffffff"><input type="text" id="adminBannerFgHex" value="#ffffff" style="width:80px;margin-left:4px"></div>' +

View File

@@ -121,6 +121,26 @@ const API = {
return this._refreshing;
},
// Centralized 401 recovery: try refresh, redirect to login on failure.
// Returns true if refresh succeeded (caller should retry), false otherwise.
async _handle401() {
if (this.refreshToken) {
if (await this.refresh()) return true;
}
// No refresh token or refresh failed — redirect to login
this.clearTokens();
const base = window.__BASE__ || '';
window.location.href = base + '/login';
return false;
},
// Alias for legacy call sites
async _refreshAccessToken() {
if (!(await this._handle401())) {
throw new Error('Session expired');
}
},
_setAuth(data) {
this.accessToken = data.access_token;
this.refreshToken = data.refresh_token;
@@ -277,7 +297,7 @@ const API = {
body: file,
});
let resp = await doFetch();
if (resp.status === 401 && this.refreshToken) {
if (resp.status === 401) {
await this._refreshAccessToken();
resp = await doFetch();
}
@@ -325,8 +345,8 @@ const API = {
signal
});
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) {
if (resp.status === 401) {
if (await this._handle401()) {
resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
method: 'POST',
headers: this._authHeaders(),
@@ -370,7 +390,7 @@ const API = {
// ── Completions ──────────────────────────
async streamCompletion(channelId, content, model, signal, apiConfigId, personaId, attachmentIds, disabledTools) {
async streamCompletion(channelId, content, model, signal, apiConfigId, personaId, fileIds, disabledTools) {
const body = { channel_id: channelId, content, stream: true };
if (personaId) {
body.persona_id = personaId;
@@ -382,7 +402,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;
if (fileIds?.length) body.file_ids = fileIds;
if (disabledTools?.length) body.disabled_tools = disabledTools;
let resp = await fetch(BASE + '/api/v1/chat/completions', {
@@ -392,8 +412,8 @@ const API = {
signal
});
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) {
if (resp.status === 401) {
if (await this._handle401()) {
resp = await fetch(BASE + '/api/v1/chat/completions', {
method: 'POST',
headers: this._authHeaders(),
@@ -648,17 +668,17 @@ const API = {
getNoteBacklinks(id) { return this._get(`/api/v1/notes/${id}/backlinks`); },
getNoteGraph() { return this._get('/api/v1/notes/graph'); },
// ── Attachments ─────────────────────────
// ── Files ────────────────────────────────
/**
* Upload a file to a channel. Uses multipart/form-data (not JSON).
* Returns the created attachment object with id, metadata, etc.
* Returns the created file object with id, metadata, etc.
*/
async uploadAttachment(channelId, file) {
async uploadFile(channelId, file) {
const form = new FormData();
form.append('file', file, file.name);
const doFetch = () => fetch(BASE + `/api/v1/channels/${channelId}/attachments`, {
const doFetch = () => fetch(BASE + `/api/v1/channels/${channelId}/files`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.accessToken}` },
// No Content-Type — browser sets multipart boundary automatically
@@ -666,8 +686,8 @@ const API = {
});
let resp = await doFetch();
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) resp = await doFetch();
if (resp.status === 401) {
if (await this._handle401()) resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
@@ -675,19 +695,19 @@ const API = {
err.status = resp.status;
throw err;
}
return this._parseJSON(resp, `/api/v1/channels/${channelId}/attachments`);
return this._parseJSON(resp, `/api/v1/channels/${channelId}/files`);
},
getAttachment(id) { return this._get(`/api/v1/attachments/${id}`); },
getFile(id) { return this._get(`/api/v1/files/${id}`); },
async downloadAttachmentBlob(id) {
const doFetch = () => fetch(BASE + `/api/v1/attachments/${id}/download`, {
async downloadFileBlob(id) {
const doFetch = () => fetch(BASE + `/api/v1/files/${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.status === 401) {
if (await this._handle401()) resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
@@ -696,8 +716,8 @@ const API = {
return resp.blob();
},
deleteAttachment(id) { return this._del(`/api/v1/attachments/${id}`); },
listChannelAttachments(channelId) { return this._get(`/api/v1/channels/${channelId}/attachments`); },
deleteFile(id) { return this._del(`/api/v1/files/${id}`); },
listChannelFiles(channelId) { return this._get(`/api/v1/channels/${channelId}/files`); },
// ── Admin Storage ───────────────────────
adminGetStorageStatus() { return this._get('/api/v1/admin/storage/status'); },
@@ -772,7 +792,7 @@ const API = {
body: form,
});
let resp = await doFetch();
if (resp.status === 401 && this.refreshToken) {
if (resp.status === 401) {
await this._refreshAccessToken();
resp = await doFetch();
}
@@ -844,8 +864,8 @@ const API = {
});
let resp = await doFetch();
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) resp = await doFetch();
if (resp.status === 401) {
if (await this._handle401()) resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
@@ -902,8 +922,8 @@ const API = {
try {
return await this._raw(path, method, body, true);
} catch (e) {
if (e.status === 401 && this.refreshToken) {
if (await this.refresh()) {
if (e.status === 401) {
if (await this._handle401()) {
return this._raw(path, method, body, true);
}
}

View File

@@ -14,8 +14,8 @@ const App = {
abortController: null,
serverSettings: {},
policies: {},
stagedAttachments: [],
channelAttachments: {},
stagedFiles: [],
channelFiles: {},
storageConfigured: false,
projects: [],
collapsedProjects: {},

View File

@@ -1,17 +1,11 @@
// ==========================================
// Chat Switchboard Chat Surface Application
// Chat Switchboard Application (Chat Surface)
// ==========================================
// Chat-specific init, boot, auth, listener dispatch.
// App state, fetchModels, resolveCapabilities defined in app-state.js.
// Domain logic in chat.js, notes.js, settings-handlers.js, admin-handlers.js.
// ── Chat Surface Model Load ────────────────
// Wraps shared fetchModels() with chat-specific UI updates.
async function fetchModelsAndUpdateUI() {
await fetchModels();
UI.updateModelSelector();
UI.updateCapabilityBadges();
}
// Boot, auth, init, listener dispatch.
// Shared state (App) and model loading (fetchModels) live in app-state.js
// (loaded by base.html for all surfaces).
// Domain logic lives in chat.js, notes.js, settings-handlers.js,
// admin-handlers.js. UI rendering in ui-*.js files.
async function init() {
console.log('🔀 Chat Switchboard initializing...');
@@ -57,7 +51,15 @@ async function init() {
}
if (API.isAuthed) {
await startApp();
try {
await startApp();
} catch (e) {
console.error('💥 startApp crashed:', e);
// Surface the error visibly (crash banner from chat.html)
const b = document.getElementById('crashBanner');
const d = document.getElementById('crashDetail');
if (b && d) { b.style.display = ''; d.textContent += 'startApp: ' + e.message + '\n' + (e.stack || '') + '\n'; }
}
} else {
showSplash(health);
}
@@ -80,7 +82,12 @@ async function startApp() {
await loadChats();
await loadProjects();
await loadProjectChannelPositions(); // v0.19.2: load channel order per project
await fetchModelsAndUpdateUI();
await fetchModels();
// app-state.js fetchModels populates App.models; UI updates are surface-specific
if (typeof UI !== 'undefined') {
UI.updateModelSelector();
UI.updateCapabilityBadges();
}
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
// kick back to login instead of rendering a broken UI.
@@ -91,7 +98,7 @@ async function startApp() {
}
await initBanners();
initAttachments();
initFiles();
if (typeof ToolsToggle !== 'undefined') ToolsToggle.init();
if (typeof REPL !== 'undefined') REPL.init(); // v0.21.3: admin-gated REPL tab
if (typeof KnowledgeUI !== 'undefined') {
@@ -232,7 +239,8 @@ function updateTabArrows(tabs) {
function showSplash(health) {
// v0.22.6: Server-rendered architecture uses /login page.
// Redirect instead of showing the SPA splash gate.
// Clear stale cookie so the redirect sticks (no loop).
document.cookie = 'sb_token=; path=/; max-age=0';
const base = window.__BASE__ || '';
window.location.href = base + '/login';
}
@@ -324,7 +332,7 @@ function initListeners() {
_initSettingsListeners(); // from settings-handlers.js
_initAdminListeners(); // from admin-handlers.js
_initNotesListeners(); // from notes.js
_initAttachmentListeners(); // from attachments.js
_initFileListeners(); // from files.js
_registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry
_registerNotesPanel(); // from notes.js — register notes with PanelRegistry
_registerProjectPanel(); // from projects-ui.js — register project detail panel
@@ -563,20 +571,16 @@ async function initBanners() {
root.style.setProperty('--banner-fg', banner.fg || '#ffffff');
const text = banner.text || '';
const pos = banner.position || 'both';
if (pos === 'top' || pos === 'both') {
const el = document.getElementById('bannerTop');
el.textContent = text;
el.classList.add('active');
root.style.setProperty('--banner-top-height', '22px');
}
if (pos === 'bottom' || pos === 'both') {
const el = document.getElementById('bannerBottom');
el.textContent = text;
el.classList.add('active');
root.style.setProperty('--banner-bottom-height', '22px');
}
const top = document.getElementById('bannerTop');
top.textContent = text;
top.classList.add('active');
root.style.setProperty('--banner-top-height', '22px');
const bot = document.getElementById('bannerBottom');
bot.textContent = text;
bot.classList.add('active');
root.style.setProperty('--banner-bottom-height', '22px');
} catch (e) {
// Banners are optional — non-admin users may not have access to settings
console.debug('Banner init skipped:', e.message);

View File

@@ -205,7 +205,7 @@ async function loadChats() {
}
async function selectChat(chatId) {
clearStaged(); // Discard any staged attachments from previous chat
clearStaged(); // Discard any staged files from previous chat
App.currentChatId = chatId;
try { sessionStorage.setItem('cs-active-chat', chatId); } catch (_) {}
UI.renderChatList();
@@ -246,8 +246,8 @@ async function selectChat(chatId) {
// Load multi-model roster for @mention routing (v0.20.0)
if (typeof ChannelModels !== 'undefined') ChannelModels.load(chatId);
// Load attachments for message rendering (non-blocking, best-effort)
await loadChannelAttachments(chatId);
// Load files for message rendering (non-blocking, best-effort)
await loadChannelFiles(chatId);
UI.renderMessages(chat.messages);
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
@@ -378,7 +378,7 @@ function _cleanStaleChatModel(chatId) {
}
async function newChat() {
clearStaged(); // Discard any staged attachments
clearStaged(); // Discard any staged files
App.currentChatId = null;
UI.renderChatList();
UI.showEmptyState();
@@ -501,15 +501,15 @@ function startRenameChat(chatId) {
async function sendMessage() {
const text = ChatInput.getValue().trim();
const hasAttachments = hasStagedAttachments();
const hasFiles = hasStagedFiles();
// Need text or attachments, not generating, not blocked by uploads
if ((!text && !hasAttachments) || App.isGenerating || isSendBlocked()) return;
// Need text or files, not generating, not blocked by uploads
if ((!text && !hasFiles) || App.isGenerating || isSendBlocked()) return;
ChatInput.setValue('');
// Snapshot attachment IDs before clearing staged state
const attachmentIds = getStagedAttachmentIds();
// Snapshot file IDs before clearing staged state
const fileIds = getStagedFileIds();
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
@@ -542,19 +542,19 @@ async function sendMessage() {
if (!chat) return;
// Optimistic: show user message immediately
const userContent = text || (hasAttachments ? `[${attachmentIds.length} file${attachmentIds.length > 1 ? 's' : ''} attached]` : '');
const userContent = text || (hasFiles ? `[${fileIds.length} file${fileIds.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();
// Clear staged files (they're now part of this message)
if (hasFiles) 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, personaId, attachmentIds,
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, personaId, fileIds,
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []);
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
@@ -620,7 +620,7 @@ async function reloadActivePath() {
siblingIndex: m.sibling_index || 0,
}));
chat.messageCount = chat.messages.length;
await loadChannelAttachments(App.currentChatId);
await loadChannelFiles(App.currentChatId);
UI.renderMessages(chat.messages);
updateContextWarning(); updateChatTokenCount();
} catch (e) {

View File

@@ -1,5 +1,5 @@
// ==========================================
// Chat Switchboard Attachments
// Chat Switchboard Files
// ==========================================
// File upload, smart paste, drag-and-drop, extraction
// polling, staged lifecycle, message rendering, lightbox,
@@ -11,8 +11,8 @@
// ── Constants ───────────────────────────────
const ATTACHMENT_POLL_INTERVAL = 2000; // ms between extraction status checks
const ATTACHMENT_MAX_PER_MSG = 5; // mirrors backend defaultMaxAttachmentsPerMsg
const FILE_POLL_INTERVAL = 2000; // ms between extraction status checks
const FILE_MAX_PER_MSG = 5; // mirrors backend defaultMaxFilesPerMsg
// MIME categories for capability-aware filtering
const IMAGE_TYPES = new Set([
@@ -21,11 +21,11 @@ const IMAGE_TYPES = new Set([
// ── State ───────────────────────────────────
// Staged attachments waiting to be sent with the next message.
// Staged files 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)
// serverId: string? — file UUID from backend (null while uploading)
// filename: string
// contentType: string
// sizeBytes: number
@@ -37,7 +37,7 @@ const IMAGE_TYPES = new Set([
// error: string? — error message if status is 'error' or 'failed'
// }
// App.stagedAttachments is initialized in app.js.
// App.stagedFiles is initialized in app.js.
// Polling timers are tracked here for cleanup.
const _pollTimers = new Map(); // localId → intervalId
@@ -57,8 +57,8 @@ 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');
if (App.stagedFiles.length >= FILE_MAX_PER_MSG) {
UI.toast(`Maximum ${FILE_MAX_PER_MSG} files per message`, 'warning');
return;
}
@@ -76,11 +76,11 @@ async function stageFile(file) {
queuePosition: null,
error: null,
};
App.stagedAttachments.push(staged);
App.stagedFiles.push(staged);
_renderStrip();
_updateSendBlock();
// Ensure a channel exists (attachments are channel-scoped)
// Ensure a channel exists (files are channel-scoped)
if (!App.currentChatId) {
try {
const title = file.name.slice(0, 50);
@@ -106,7 +106,7 @@ async function stageFile(file) {
// Upload
try {
const result = await API.uploadAttachment(App.currentChatId, file);
const result = await API.uploadFile(App.currentChatId, file);
staged.serverId = result.id;
staged.status = _mapExtractionStatus(result.metadata?.extraction_status);
staged.extractionStatus = result.metadata?.extraction_status || null;
@@ -119,7 +119,7 @@ async function stageFile(file) {
} catch (e) {
staged.status = 'error';
staged.error = e.message;
console.error('Attachment upload failed:', e);
console.error('File upload failed:', e);
}
_renderStrip();
@@ -127,14 +127,14 @@ async function stageFile(file) {
}
/**
* Remove a staged attachment by localId.
* Remove a staged file 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);
const idx = App.stagedFiles.findIndex(a => a.localId === localId);
if (idx === -1) return;
const staged = App.stagedAttachments[idx];
const staged = App.stagedFiles[idx];
// Clean up preview blob
if (staged.previewUrl) {
@@ -147,27 +147,27 @@ async function unstageFile(localId) {
// Delete from backend if it was uploaded
if (staged.serverId) {
try {
await API.deleteAttachment(staged.serverId);
await API.deleteFile(staged.serverId);
} catch (e) {
console.warn('Failed to delete staged attachment:', e.message);
console.warn('Failed to delete staged file:', e.message);
}
}
App.stagedAttachments.splice(idx, 1);
App.stagedFiles.splice(idx, 1);
_renderStrip();
_updateSendBlock();
}
/**
* Clear all staged attachments (chat switch, cancel, etc.)
* Clear all staged files (chat switch, cancel, etc.)
* Does NOT delete from backend orphan cleanup handles abandoned uploads.
*/
function clearStaged() {
for (const staged of App.stagedAttachments) {
for (const staged of App.stagedFiles) {
if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl);
_stopPolling(staged.localId);
}
App.stagedAttachments = [];
App.stagedFiles = [];
_renderStrip();
_updateSendBlock();
}
@@ -176,11 +176,11 @@ function clearStaged() {
// ── Send Integration ────────────────────────
/**
* Returns attachment IDs (server-side UUIDs) for the current staged set.
* Only includes successfully uploaded attachments.
* Returns file IDs (server-side UUIDs) for the current staged set.
* Only includes successfully uploaded files.
*/
function getStagedAttachmentIds() {
return App.stagedAttachments
function getStagedFileIds() {
return App.stagedFiles
.filter(a => a.serverId)
.map(a => a.serverId);
}
@@ -191,26 +191,26 @@ function getStagedAttachmentIds() {
* Queued/processing/failed extraction does NOT block send.
*/
function isSendBlocked() {
return App.stagedAttachments.some(a => a.status === 'uploading');
return App.stagedFiles.some(a => a.status === 'uploading');
}
/**
* Whether there are any staged attachments (for UI visibility).
* Whether there are any staged files (for UI visibility).
*/
function hasStagedAttachments() {
return App.stagedAttachments.length > 0;
function hasStagedFiles() {
return App.stagedFiles.length > 0;
}
/**
* Post-send cleanup: detach staged list without deleting from backend
* (attachments are now linked to the sent message).
* (files are now linked to the sent message).
*/
function consumeStaged() {
for (const staged of App.stagedAttachments) {
for (const staged of App.stagedFiles) {
if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl);
_stopPolling(staged.localId);
}
App.stagedAttachments = [];
App.stagedFiles = [];
_renderStrip();
_updateSendBlock();
}
@@ -225,7 +225,7 @@ function _startPolling(staged) {
if (!staged.serverId) return;
try {
const data = await API.getAttachment(staged.serverId);
const data = await API.getFile(staged.serverId);
const meta = data.metadata || {};
staged.extractionStatus = meta.extraction_status || null;
staged.queuePosition = meta.queue_position ?? null;
@@ -245,7 +245,7 @@ function _startPolling(staged) {
console.warn('Extraction poll failed for', staged.localId, e.message);
// Don't stop polling on transient errors — backend might be restarting
}
}, ATTACHMENT_POLL_INTERVAL);
}, FILE_POLL_INTERVAL);
_pollTimers.set(staged.localId, timer);
}
@@ -284,9 +284,9 @@ function _mapExtractionStatus(backendStatus) {
}
/**
* Human-readable status label for a staged attachment.
* Human-readable status label for a staged file.
*/
function attachmentStatusLabel(staged) {
function fileStatusLabel(staged) {
switch (staged.status) {
case 'uploading':
return staged.uploadProgress > 0 ? `Uploading ${staged.uploadProgress}%` : 'Uploading…';
@@ -306,7 +306,7 @@ function attachmentStatusLabel(staged) {
/**
* Whether the given content type is an image (for vision gating).
*/
function isImageAttachment(contentType) {
function isImageFile(contentType) {
return IMAGE_TYPES.has(contentType);
}
@@ -323,21 +323,21 @@ function formatFileSize(bytes) {
// ── Strip Rendering ─────────────────────────
function _renderStrip() {
const strip = document.getElementById('attachmentStrip');
const strip = document.getElementById('fileStrip');
if (!strip) return;
if (App.stagedAttachments.length === 0) {
if (App.stagedFiles.length === 0) {
strip.style.display = 'none';
strip.innerHTML = '';
return;
}
strip.style.display = 'flex';
strip.innerHTML = App.stagedAttachments.map(a => {
const icon = isImageAttachment(a.contentType) ? '🖼' : '📄';
strip.innerHTML = App.stagedFiles.map(a => {
const icon = isImageFile(a.contentType) ? '🖼' : '📄';
const name = _truncFilename(a.filename, 20);
const size = formatFileSize(a.sizeBytes);
const label = attachmentStatusLabel(a);
const label = fileStatusLabel(a);
const statusClass = a.status === 'error' || a.status === 'failed' ? 'att-error' :
a.status === 'complete' ? 'att-ready' : 'att-pending';
@@ -371,45 +371,45 @@ function _esc(s) {
}
// ── Channel Attachment Loading ──────────────
// ── Channel File Loading ──────────────
/**
* Fetch all attachments for a channel, build messageId [att] map.
* Fetch all files for a channel, build messageId [att] map.
* Called after loading messages in selectChat / reloadActivePath.
* Non-blocking: failures just leave the map empty (attachments are optional UX).
* Non-blocking: failures just leave the map empty (files are optional UX).
*/
async function loadChannelAttachments(channelId) {
App.channelAttachments = {};
async function loadChannelFiles(channelId) {
App.channelFiles = {};
if (!channelId || !App.storageConfigured) return;
try {
const data = await API.listChannelAttachments(channelId);
const list = data.attachments || data || [];
const data = await API.listChannelFiles(channelId);
const list = data.files || data || [];
for (const att of list) {
if (!att.message_id) continue;
if (!App.channelAttachments[att.message_id]) {
App.channelAttachments[att.message_id] = [];
if (!App.channelFiles[att.message_id]) {
App.channelFiles[att.message_id] = [];
}
App.channelAttachments[att.message_id].push(att);
App.channelFiles[att.message_id].push(att);
}
} catch (e) {
console.debug('Attachment load skipped:', e.message);
console.debug('File load skipped:', e.message);
}
}
// ── Message Attachment Rendering ────────────
// ── Message File Rendering ────────────
/**
* Returns HTML string for attachments associated with a message.
* Returns HTML string for files associated with a message.
* Called from ui-core.js _messageHTML(). Returns '' if none.
*
* Images use data-att-id; actual src is loaded post-render with auth
* via loadAuthImages(). Doc downloads use onclick handlers.
*/
function renderMessageAttachments(msgId) {
function renderMessageFiles(msgId) {
if (!msgId) return '';
const atts = App.channelAttachments[msgId];
const atts = App.channelFiles[msgId];
if (!atts || atts.length === 0) return '';
// Check current model vision capability for hint
@@ -418,7 +418,7 @@ function renderMessageAttachments(msgId) {
const hasVision = !!modelInfo?.capabilities?.vision;
const items = atts.map(att => {
const isImage = isImageAttachment(att.content_type);
const isImage = isImageFile(att.content_type);
if (isImage) {
const dims = att.metadata?.thumb_dimensions || att.metadata?.dimensions;
@@ -436,7 +436,7 @@ function renderMessageAttachments(msgId) {
// 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">
return `<a class="msg-att msg-att-doc" href="#" onclick="downloadFile('${_esc(att.id)}','${_esc(att.filename)}');return false">
<span class="att-doc-icon">${icon}</span>
<span class="att-doc-info">
<span class="att-doc-name">${_esc(att.filename)}</span>
@@ -446,7 +446,7 @@ function renderMessageAttachments(msgId) {
</a>`;
});
return `<div class="msg-attachments">${items.join('')}</div>`;
return `<div class="msg-files">${items.join('')}</div>`;
}
function _docIcon(contentType) {
@@ -461,7 +461,7 @@ function _docIcon(contentType) {
// ── Image Lightbox ──────────────────────────
function openLightbox(attachmentId) {
function openLightbox(fileId) {
const overlay = document.getElementById('lightbox');
const img = document.getElementById('lightboxImg');
if (!overlay || !img) return;
@@ -472,7 +472,7 @@ function openLightbox(attachmentId) {
document.body.style.overflow = 'hidden';
// Fetch full-size image with auth
API.downloadAttachmentBlob(attachmentId)
API.downloadFileBlob(fileId)
.then(blob => { img.src = URL.createObjectURL(blob); })
.catch(() => { img.alt = 'Failed to load image'; });
}
@@ -507,7 +507,7 @@ function loadAuthImages(container) {
if (!attId) continue;
img.dataset.loaded = '1';
API.downloadAttachmentBlob(attId)
API.downloadFileBlob(attId)
.then(blob => { img.src = URL.createObjectURL(blob); })
.catch(() => {
img.alt = '⚠ Load failed';
@@ -520,12 +520,12 @@ function loadAuthImages(container) {
// ── Document Download ───────────────────────
/**
* Download an attachment file via auth-aware fetch.
* Download a file via auth-aware fetch.
* Creates a temporary <a> element to trigger the browser download dialog.
*/
async function downloadAttachment(attachmentId, filename) {
async function downloadFile(fileId, filename) {
try {
const blob = await API.downloadAttachmentBlob(attachmentId);
const blob = await API.downloadFileBlob(fileId);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -700,9 +700,9 @@ function _updateSendBlock() {
/**
* Called from app.js startApp(). Reads storage_configured from boot
* payload and sets up attachment UI visibility.
* payload and sets up file UI visibility.
*/
function initAttachments() {
function initFiles() {
const attachBtn = document.getElementById('attachBtn');
const fileInput = document.getElementById('fileInput');
if (attachBtn) {
@@ -711,14 +711,14 @@ function initAttachments() {
if (fileInput) {
fileInput.accept = FILE_ACCEPT;
}
console.log(`📎 Attachments: storage ${App.storageConfigured ? 'configured' : 'not configured'}`);
console.log(`📎 Files: storage ${App.storageConfigured ? 'configured' : 'not configured'}`);
}
/**
* Called from app.js initListeners(). Wires up all attachment input
* Called from app.js initListeners(). Wires up all file input
* handlers: button, file picker, paste, and drag-and-drop.
*/
function _initAttachmentListeners() {
function _initFileListeners() {
const attachBtn = document.getElementById('attachBtn');
const fileInput = document.getElementById('fileInput');
const chatArea = document.getElementById('chatMessages');

View File

@@ -3,7 +3,7 @@
// ==========================================
// User-facing memory management in Settings,
// admin memory review in Admin Panel,
// and persona memory config in preset forms.
// and persona memory config in persona forms.
const MemoryUI = {

View File

@@ -195,7 +195,6 @@ const Pages = {
text: _val('settBannerText'),
bg: _val('settBannerBg'),
fg: _val('settBannerFg'),
position: 'both',
};
// System prompt

View File

@@ -153,7 +153,7 @@ function renderPersonaKBPicker(containerEl, options = {}) {
async function loadPersonaKBs(personaId, kbPicker, apiPrefix) {
if (!personaId || !kbPicker) return;
try {
const resp = await API._get(`${apiPrefix}/presets/${personaId}/knowledge-bases`);
const resp = await API._get(`${apiPrefix}/personas/${personaId}/knowledge-bases`);
kbPicker.setValues(resp.data || []);
} catch (e) {
console.warn('Failed to load persona KBs:', e.message);
@@ -170,7 +170,7 @@ async function savePersonaKBs(personaId, kbPicker, apiPrefix) {
if (!personaId || !kbPicker) return;
const values = kbPicker.getValues();
try {
await API._put(`${apiPrefix}/presets/${personaId}/knowledge-bases`, values);
await API._put(`${apiPrefix}/personas/${personaId}/knowledge-bases`, values);
} catch (e) {
console.warn('Failed to save persona KBs:', e.message);
}

View File

@@ -218,7 +218,6 @@ async function handleSaveAdminSettings() {
const banner = {
enabled: document.getElementById('adminBannerEnabled').checked,
text: document.getElementById('adminBannerText').value,
position: document.getElementById('adminBannerPosition').value,
bg: document.getElementById('adminBannerBg').value,
fg: document.getElementById('adminBannerFg').value,
};
@@ -568,7 +567,7 @@ function _initSettingsListeners() {
catch (e) { console.warn('Team persona avatar upload failed:', e.message); }
}
if (personaId && vals._kbValues) {
try { await API.teamSetPresetKBs(teamId, personaId, vals._kbValues); }
try { await API.teamSetPersonaKBs(teamId, personaId, vals._kbValues); }
catch (e) { console.warn('Team persona KB binding failed:', e.message); }
}
container.style.display = 'none';
@@ -594,7 +593,7 @@ function _initSettingsListeners() {
formEl.style.display = formEl.style.display === 'none' ? '' : 'none';
});
// User — personal presets
// User — personal personas
var _userPersonaForm = null;
document.getElementById('userAddPersonaBtn')?.addEventListener('click', () => {
const container = document.getElementById('userAddPersonaForm');
@@ -617,7 +616,7 @@ function _initSettingsListeners() {
catch (e) { console.warn('User persona avatar upload failed:', e.message); }
}
if (personaId && vals._kbValues) {
try { await API.setUserPresetKBs(personaId, vals._kbValues); }
try { await API.setUserPersonaKBs(personaId, vals._kbValues); }
catch (e) { console.warn('User persona KB binding failed:', e.message); }
}
container.style.display = 'none';
@@ -646,17 +645,6 @@ function _initSettingsListeners() {
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
});
document.getElementById('adminBannerPreset')?.addEventListener('change', (e) => {
const preset = UI._bannerPresets[e.target.value];
if (persona) {
document.getElementById('adminBannerText').value = preset.text || '';
document.getElementById('adminBannerBg').value = preset.bg || '#007a33';
document.getElementById('adminBannerBgHex').value = preset.bg || '#007a33';
document.getElementById('adminBannerFg').value = preset.fg || '#ffffff';
document.getElementById('adminBannerFgHex').value = preset.fg || '#ffffff';
UI.updateBannerPreview();
}
});
['adminBannerText', 'adminBannerBg', 'adminBannerFg'].forEach(id => {
document.getElementById(id)?.addEventListener('input', UI.updateBannerPreview);
});

View File

@@ -25,14 +25,14 @@ const Tokens = {
return total;
},
// Estimate tokens for a set of attachments (staged or sent).
// Estimate tokens for a set of files (staged or sent).
// Images: ~765 tokens (base tile estimate, reasonable cross-provider).
// Documents with extracted text: text length / 4.
// Other/unknown: raw size / 4 (rough fallback).
estimateAttachments(attachments) {
if (!attachments?.length) return 0;
estimateFiles(files) {
if (!files?.length) return 0;
let total = 0;
for (const att of attachments) {
for (const att of files) {
if (att.content_type?.startsWith('image/')) {
total += 765; // base tile estimate
} else if (att.extracted_text) {
@@ -83,16 +83,16 @@ function updateInputTokens() {
// Show relative to available context
const chat = App.chats.find(c => c.id === App.currentChatId);
const convTokens = Tokens.estimateConversation(chat?.messages || [], App.settings.systemPrompt);
// Include staged attachment estimates
const attTokens = Tokens.estimateAttachments(
(App.stagedAttachments || []).filter(a => a.status !== 'error').map(a => ({
// Include staged file estimates
const fileTokens = Tokens.estimateFiles(
(App.stagedFiles || []).filter(a => a.status !== 'error').map(a => ({
content_type: a.contentType,
size_bytes: a.sizeBytes,
}))
);
const totalWithInput = convTokens + inputTokens + attTokens;
const totalWithInput = convTokens + inputTokens + fileTokens;
const pct = totalWithInput / budget.maxContext;
const attLabel = attTokens > 0 ? ` +${Tokens.format(attTokens)} files` : '';
const attLabel = fileTokens > 0 ? ` +${Tokens.format(fileTokens)} files` : '';
el.textContent = `~${Tokens.format(inputTokens)} tokens${attLabel} · ${Tokens.format(totalWithInput)} / ${Tokens.format(budget.maxContext)} context`;
el.className = 'input-token-count' + (pct > 0.9 ? ' danger' : pct > 0.75 ? ' warning' : '');
} else {

View File

@@ -15,7 +15,7 @@ const ADMIN_SECTIONS = {
const ADMIN_LABELS = {
users: 'Users', teams: 'Teams', groups: 'Groups',
providers: 'Providers', models: 'Models', presets: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
settings: 'Settings', storage: 'Storage', extensions: 'Extensions',
usage: 'Usage', audit: 'Audit', stats: 'Stats',
@@ -29,7 +29,7 @@ const ADMIN_LOADERS = {
roles: () => UI.loadAdminRoles(),
providers: () => UI.loadAdminProviders(),
models: () => UI.loadAdminModels(),
personas: () => UI.loadAdminPersonas(),
personas: () => UI.loadAdminPersonas(),
knowledgeBases: () => {
if (typeof KnowledgeUI === 'undefined') { console.error('[Admin] KnowledgeUI not loaded — check js/knowledge-ui.js'); return; }
KnowledgeUI.openAdminPanel();
@@ -185,7 +185,7 @@ Object.assign(UI, {
const teamId = UI._managingTeamId;
if (tab === 'members') UI.loadTeamManageMembers(teamId);
if (tab === 'providers') UI.loadTeamManageProviders(teamId);
if (tab === 'presets') UI.loadTeamManagePersonas(teamId);
if (tab === 'personas') UI.loadTeamManagePersonas(teamId);
if (tab === 'usage') UI.loadTeamUsage();
if (tab === 'activity') UI.loadTeamAuditLog(1);
if (tab === 'knowledge') {
@@ -1185,7 +1185,7 @@ Object.assign(UI, {
// User providers / BYOK (policy: allow_user_byok)
document.getElementById('adminUserProvidersToggle').checked = policies.allow_user_byok === 'true';
// User personas / personas (policy: allow_user_personas)
// User personas (policy: allow_user_personas)
document.getElementById('adminUserPersonasToggle').checked = policies.allow_user_personas === 'true';
// KB direct access (policy: kb_direct_access — v0.17.0)
@@ -1223,7 +1223,6 @@ Object.assign(UI, {
const banner = getSetting('banner', {}) || {};
document.getElementById('adminBannerEnabled').checked = !!banner.enabled;
document.getElementById('adminBannerText').value = banner.text || '';
document.getElementById('adminBannerPosition').value = banner.position || 'both';
document.getElementById('adminBannerBg').value = banner.bg || '#007a33';
document.getElementById('adminBannerBgHex').value = banner.bg || '#007a33';
document.getElementById('adminBannerFg').value = banner.fg || '#ffffff';
@@ -1231,15 +1230,6 @@ Object.assign(UI, {
document.getElementById('bannerConfigFields').style.display = banner.enabled ? '' : 'none';
UI.updateBannerPreview();
// Load banner presets (global_settings)
const personas = getSetting('banner_presets', {}) || {};
const sel = document.getElementById('adminBannerPreset');
sel.innerHTML = '<option value="">Custom</option>';
Object.entries(presets).forEach(([key, p]) => {
sel.innerHTML += `<option value="${esc(key)}">${esc(p.text || key)}</option>`;
});
UI._bannerPresets = presets;
// Vault / Encryption status
const vaultEl = document.getElementById('adminVaultStatus');
@@ -1314,8 +1304,6 @@ Object.assign(UI, {
} catch (e) { console.debug('Failed to load admin settings:', e); }
},
_bannerPresets: {},
updateBannerPreview() {
const prev = document.getElementById('bannerPreview');
if (!prev) return;

View File

@@ -532,7 +532,7 @@ const UI = {
</div>
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
<div class="msg-text">${formatMessage(msg.content)}</div>
${typeof renderMessageAttachments === 'function' ? renderMessageAttachments(msgId) : ''}
${typeof renderMessageFiles === 'function' ? renderMessageFiles(msgId) : ''}
</div>
</div>
</div>`;
@@ -838,7 +838,7 @@ const UI = {
} else {
const globalPersonas = App.models.filter(m => m.isPersona && m.personaScope === 'global');
const teamPersonas = App.models.filter(m => m.isPersona && m.personaScope === 'team');
const personalPresets = App.models.filter(m => m.isPersona && m.personaScope === 'personal');
const personalPersonas = App.models.filter(m => m.isPersona && m.personaScope === 'personal');
const models = App.models.filter(m => !m.isPersona && !m.hidden);
const addGroup = (label, items) => {
@@ -862,7 +862,7 @@ const UI = {
addGroup(`👥 ${tn}`, teamGroups[tn]);
});
addGroup('🔧 My Personas', personalPresets);
addGroup('🔧 My Personas', personalPersonas);
// No team model groups — team providers are for persona building only.
// Team members access team models through curated personas.

View File

@@ -770,10 +770,10 @@ Object.assign(UI, {
if (!el) return;
try {
const data = await API.listUserPersonas();
// Only show personal presets owned by user
// Only show personal personas owned by user
const personas = (data.personas || []).filter(p => p.scope === 'personal');
if (!personas.length) {
el.innerHTML = '<div class="empty-hint">No personal presets yet</div>';
el.innerHTML = '<div class="empty-hint">No personal personas yet</div>';
return;
}
el.innerHTML = personas.map(p => {