Changeset 0.22.8 (#150)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 & 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>' +
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ const App = {
|
||||
abortController: null,
|
||||
serverSettings: {},
|
||||
policies: {},
|
||||
stagedAttachments: [],
|
||||
channelAttachments: {},
|
||||
stagedFiles: [],
|
||||
channelFiles: {},
|
||||
storageConfigured: false,
|
||||
projects: [],
|
||||
collapsedProjects: {},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
@@ -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 = {
|
||||
|
||||
|
||||
@@ -195,7 +195,6 @@ const Pages = {
|
||||
text: _val('settBannerText'),
|
||||
bg: _val('settBannerBg'),
|
||||
fg: _val('settBannerFg'),
|
||||
position: 'both',
|
||||
};
|
||||
|
||||
// System prompt
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user