Changeset 0.18.0 (#79)

This commit is contained in:
2026-02-28 18:24:19 +00:00
parent 12e316c234
commit e4a943b03e
48 changed files with 3999 additions and 1398 deletions

View File

@@ -671,6 +671,22 @@ const API = {
getChannelKBs(channelId) { return this._get(`/api/v1/channels/${channelId}/knowledge-bases`); },
setChannelKBs(channelId, kbIds) { return this._put(`/api/v1/channels/${channelId}/knowledge-bases`, { kb_ids: kbIds }); },
// ── Memories (v0.18.0) ───────────────────
listMemories(status, query) {
const params = new URLSearchParams();
if (status) params.set('status', status);
if (query) params.set('query', query);
const qs = params.toString();
return this._get('/api/v1/memories' + (qs ? '?' + qs : ''));
},
getMemoryCount() { return this._get('/api/v1/memories/count'); },
updateMemory(id, data) { return this._put(`/api/v1/memories/${id}`, data); },
deleteMemory(id) { return this._del(`/api/v1/memories/${id}`); },
approveMemory(id) { return this._post(`/api/v1/memories/${id}/approve`, {}); },
rejectMemory(id) { return this._post(`/api/v1/memories/${id}/reject`, {}); },
bulkApproveMemories(ids) { return this._post('/api/v1/admin/memories/bulk-approve', { ids }); },
adminListPendingMemories() { return this._get('/api/v1/admin/memories/pending'); },
// ── HTTP Internals ───────────────────────
_esc(s) {

364
src/js/memory-ui.js Normal file
View File

@@ -0,0 +1,364 @@
// ==========================================
// Chat Switchboard Memory UI (v0.18.0 Phase 3)
// ==========================================
// User-facing memory management in Settings,
// admin memory review in Admin Panel,
// and persona memory config in preset forms.
const MemoryUI = {
// ── State ────────────────────────────────
_memories: [],
_pendingMemories: [],
_filter: { scope: 'user', status: 'active', query: '' },
// ═════════════════════════════════════════
// User Settings Tab
// ═════════════════════════════════════════
async openSettingsPanel() {
const panel = document.getElementById('settingsMemoryContent');
if (!panel) return;
panel.innerHTML = '<div class="loading">Loading memories...</div>';
try {
// Load memory count summary
const count = await API.getMemoryCount();
const active = count.active || 0;
const pending = count.pending || 0;
panel.innerHTML = `
<div class="memory-summary">
<div class="memory-stat">
<span class="memory-stat-value">${active}</span>
<span class="memory-stat-label">Active</span>
</div>
<div class="memory-stat">
<span class="memory-stat-value">${pending}</span>
<span class="memory-stat-label">Pending Review</span>
</div>
</div>
<div class="memory-toolbar">
<div class="memory-filter-row">
<select id="memoryStatusFilter" class="memory-select">
<option value="active">Active</option>
<option value="pending_review">Pending Review</option>
<option value="archived">Archived</option>
</select>
<input type="text" id="memorySearchInput" placeholder="Search memories..." class="memory-search">
</div>
<div class="memory-actions-row">
<button class="btn-small" id="memoryRefreshBtn" title="Refresh">↻ Refresh</button>
${pending > 0 ? `<button class="btn-small btn-primary" id="memoryApproveAllBtn">✓ Approve All Pending</button>` : ''}
</div>
</div>
<div id="memoryList" class="memory-list"></div>
`;
// Wire events
document.getElementById('memoryStatusFilter')?.addEventListener('change', (e) => {
MemoryUI._filter.status = e.target.value;
MemoryUI.loadMemories();
});
document.getElementById('memorySearchInput')?.addEventListener('input', _debounce((e) => {
MemoryUI._filter.query = e.target.value;
MemoryUI.loadMemories();
}, 300));
document.getElementById('memoryRefreshBtn')?.addEventListener('click', () => MemoryUI.openSettingsPanel());
document.getElementById('memoryApproveAllBtn')?.addEventListener('click', () => MemoryUI.approveAllPending());
// Load initial list
await this.loadMemories();
} catch (e) {
panel.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
},
async loadMemories() {
const el = document.getElementById('memoryList');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.listMemories(this._filter.status, this._filter.query);
this._memories = data.memories || [];
if (!this._memories.length) {
el.innerHTML = `<div class="empty-hint">No ${this._filter.status.replace('_', ' ')} memories${this._filter.query ? ' matching "' + esc(this._filter.query) + '"' : ''}</div>`;
return;
}
el.innerHTML = this._memories.map(m => this._renderMemoryCard(m)).join('');
this._wireMemoryCards(el);
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
},
_renderMemoryCard(m) {
const confidence = Math.round(m.confidence * 100);
const confidenceClass = confidence >= 80 ? 'high' : confidence >= 50 ? 'medium' : 'low';
const updated = m.updated_at ? new Date(m.updated_at).toLocaleDateString() : '';
const scopeLabel = m.scope === 'persona_user' ? 'persona' : m.scope;
return `
<div class="memory-card" data-id="${m.id}">
<div class="memory-card-header">
<span class="memory-key">${esc(m.key)}</span>
<div class="memory-badges">
<span class="memory-scope-badge memory-scope-${m.scope}">${scopeLabel}</span>
<span class="memory-confidence memory-confidence-${confidenceClass}" title="Confidence: ${confidence}%">${confidence}%</span>
</div>
</div>
<div class="memory-card-value">${esc(m.value)}</div>
<div class="memory-card-footer">
<span class="memory-date">${updated}</span>
<div class="memory-card-actions">
${m.status === 'pending_review' ? `
<button class="btn-tiny btn-approve" data-action="approve" data-id="${m.id}" title="Approve">✓</button>
<button class="btn-tiny btn-reject" data-action="reject" data-id="${m.id}" title="Reject">✕</button>
` : ''}
<button class="btn-tiny" data-action="edit" data-id="${m.id}" title="Edit">✎</button>
<button class="btn-tiny btn-danger" data-action="delete" data-id="${m.id}" title="Delete">🗑</button>
</div>
</div>
</div>`;
},
_wireMemoryCards(container) {
container.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const id = btn.dataset.id;
const action = btn.dataset.action;
if (action === 'approve') await this.approveMemory(id);
else if (action === 'reject') await this.rejectMemory(id);
else if (action === 'delete') await this.deleteMemory(id);
else if (action === 'edit') this.editMemory(id);
});
});
},
async approveMemory(id) {
try {
await API.approveMemory(id);
UI.toast('Memory approved', 'success');
this.loadMemories();
} catch (e) { UI.toast(e.message, 'error'); }
},
async rejectMemory(id) {
try {
await API.rejectMemory(id);
UI.toast('Memory rejected', 'success');
this.loadMemories();
} catch (e) { UI.toast(e.message, 'error'); }
},
async deleteMemory(id) {
if (!confirm('Delete this memory permanently?')) return;
try {
await API.deleteMemory(id);
UI.toast('Memory deleted', 'success');
this.loadMemories();
} catch (e) { UI.toast(e.message, 'error'); }
},
editMemory(id) {
const m = this._memories.find(x => x.id === id);
if (!m) return;
const card = document.querySelector(`.memory-card[data-id="${id}"]`);
if (!card) return;
card.innerHTML = `
<div class="memory-edit-form">
<div class="form-group">
<label>Key</label>
<input type="text" id="memEdit_key_${id}" value="${esc(m.key)}" class="memory-edit-input">
</div>
<div class="form-group">
<label>Value</label>
<textarea id="memEdit_val_${id}" rows="2" class="memory-edit-input">${esc(m.value)}</textarea>
</div>
<div class="form-row" style="gap:6px">
<button class="btn-small btn-primary" id="memEditSave_${id}">Save</button>
<button class="btn-small" id="memEditCancel_${id}">Cancel</button>
</div>
</div>
`;
document.getElementById(`memEditSave_${id}`)?.addEventListener('click', async () => {
const key = document.getElementById(`memEdit_key_${id}`).value.trim();
const value = document.getElementById(`memEdit_val_${id}`).value.trim();
if (!key || !value) return UI.toast('Key and value are required', 'warning');
try {
await API.updateMemory(id, { key, value });
UI.toast('Memory updated', 'success');
this.loadMemories();
} catch (e) { UI.toast(e.message, 'error'); }
});
document.getElementById(`memEditCancel_${id}`)?.addEventListener('click', () => {
this.loadMemories();
});
},
async approveAllPending() {
try {
const data = await API.listMemories('pending_review', '');
const ids = (data.memories || []).map(m => m.id);
if (!ids.length) return UI.toast('No pending memories', 'info');
if (!confirm(`Approve all ${ids.length} pending memories?`)) return;
await API.bulkApproveMemories(ids);
UI.toast(`${ids.length} memories approved`, 'success');
this.openSettingsPanel();
} catch (e) { UI.toast(e.message, 'error'); }
},
// ═════════════════════════════════════════
// Admin Panel — Memory Review
// ═════════════════════════════════════════
async openAdminPanel() {
const el = document.getElementById('adminMemoryContent');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListPendingMemories();
const pending = data.memories || [];
el.innerHTML = `
<div class="admin-toolbar">
<span class="admin-toolbar-label">${pending.length} pending memories</span>
${pending.length > 0 ? `
<button class="btn-small btn-primary" id="adminBulkApproveBtn">✓ Approve All</button>
` : ''}
<button class="btn-small" id="adminMemoryRefreshBtn">↻ Refresh</button>
</div>
<div id="adminMemoryList" class="memory-list">
${pending.length === 0 ? '<div class="empty-hint">No memories pending review</div>' : ''}
${pending.map(m => this._renderAdminMemoryRow(m)).join('')}
</div>
`;
// Wire events
document.getElementById('adminBulkApproveBtn')?.addEventListener('click', async () => {
const ids = pending.map(m => m.id);
if (!confirm(`Approve all ${ids.length} pending memories?`)) return;
try {
await API.bulkApproveMemories(ids);
UI.toast(`${ids.length} memories approved`, 'success');
MemoryUI.openAdminPanel();
} catch (e) { UI.toast(e.message, 'error'); }
});
document.getElementById('adminMemoryRefreshBtn')?.addEventListener('click', () => MemoryUI.openAdminPanel());
// Wire per-row actions
el.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
try {
if (btn.dataset.action === 'approve') {
await API.approveMemory(id);
UI.toast('Approved', 'success');
} else if (btn.dataset.action === 'reject') {
await API.rejectMemory(id);
UI.toast('Rejected', 'success');
}
MemoryUI.openAdminPanel();
} catch (e) { UI.toast(e.message, 'error'); }
});
});
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
},
_renderAdminMemoryRow(m) {
const confidence = Math.round(m.confidence * 100);
const scope = m.scope === 'persona_user' ? 'persona' : m.scope;
return `
<div class="memory-card memory-card-pending">
<div class="memory-card-header">
<span class="memory-key">${esc(m.key)}</span>
<div class="memory-badges">
<span class="memory-scope-badge memory-scope-${m.scope}">${scope}</span>
<span class="memory-confidence">${confidence}%</span>
<span class="memory-owner">${esc(m.owner_id?.substring(0, 8) || '?')}…</span>
</div>
</div>
<div class="memory-card-value">${esc(m.value)}</div>
<div class="memory-card-footer">
<span class="memory-date">${m.updated_at ? new Date(m.updated_at).toLocaleDateString() : ''}</span>
<div class="memory-card-actions">
<button class="btn-tiny btn-approve" data-action="approve" data-id="${m.id}">✓ Approve</button>
<button class="btn-tiny btn-reject" data-action="reject" data-id="${m.id}">✕ Reject</button>
</div>
</div>
</div>`;
},
// ═════════════════════════════════════════
// Persona Form Memory Fields
// ═════════════════════════════════════════
// Call this after renderPresetForm to append memory fields.
// container: the form container element
// prefix: the form prefix (e.g. 'pf', 'apf')
appendMemoryFields(container, prefix) {
const section = document.createElement('div');
section.className = 'memory-persona-section';
section.innerHTML = `
<div class="form-divider"></div>
<h4 class="form-section-title">Memory</h4>
<label class="checkbox-label">
<input type="checkbox" id="${prefix}_memoryEnabled" checked>
Enable memory for this persona
</label>
<p class="section-hint" style="margin:4px 0 8px">When enabled, this persona can save and recall memories from conversations.</p>
<div class="form-group" id="${prefix}_memoryPromptGroup">
<label>Custom Extraction Prompt <span class="form-hint">(optional)</span></label>
<textarea id="${prefix}_memoryExtractionPrompt" rows="3" placeholder="Leave blank for default extraction behavior. Custom prompts let you control what facts this persona remembers."></textarea>
</div>
`;
// Insert before the submit/cancel buttons
const submitRow = container.querySelector('.form-row:last-child');
if (submitRow) {
container.insertBefore(section, submitRow);
} else {
container.appendChild(section);
}
},
// Read memory values from a persona form
getMemoryValues(prefix) {
const enabled = document.getElementById(`${prefix}_memoryEnabled`)?.checked ?? true;
const prompt = document.getElementById(`${prefix}_memoryExtractionPrompt`)?.value.trim() || null;
return { memory_enabled: enabled, memory_extraction_prompt: prompt };
},
// Set memory values in a persona form
setMemoryValues(prefix, persona) {
const enabledEl = document.getElementById(`${prefix}_memoryEnabled`);
if (enabledEl) enabledEl.checked = persona.memory_enabled !== false;
const promptEl = document.getElementById(`${prefix}_memoryExtractionPrompt`);
if (promptEl) promptEl.value = persona.memory_extraction_prompt || '';
},
};
// ── Debounce helper ──────────────────────────
function _debounce(fn, ms) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
}

View File

@@ -247,6 +247,15 @@ async function handleSaveAdminSettings() {
await API.adminUpdateSetting('auto_compaction_threshold', { value: compactionThreshold / 100 });
await API.adminUpdateSetting('auto_compaction_cooldown_minutes', { value: compactionCooldown });
// Memory extraction (v0.18.0)
const memExtractionEnabled = document.getElementById('adminMemoryExtractionEnabled')?.checked || false;
const memAutoApprove = document.getElementById('adminMemoryAutoApprove')?.checked || false;
await API.adminUpdateSetting('memory_extraction', { value: {
enabled: memExtractionEnabled,
auto_approve: memAutoApprove,
}});
await API.adminUpdateSetting('memory_extraction_enabled', { value: memExtractionEnabled });
UI.toast('Settings saved', 'success');
// Live-apply: refresh policies and dependent UI
await initBanners();

View File

@@ -7,14 +7,14 @@
// ── Category → Section mapping ────────────
const ADMIN_SECTIONS = {
people: ['users', 'teams', 'groups'],
ai: ['providers', 'models', 'presets', 'roles', 'knowledgeBases'],
ai: ['providers', 'models', 'presets', 'roles', 'knowledgeBases', 'memory'],
system: ['settings', 'storage', 'extensions'],
monitoring: ['usage', 'audit', 'stats'],
};
const ADMIN_LABELS = {
users: 'Users', teams: 'Teams', groups: 'Groups',
providers: 'Providers', models: 'Models', presets: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge',
providers: 'Providers', models: 'Models', presets: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
settings: 'Settings', storage: 'Storage', extensions: 'Extensions',
usage: 'Usage', audit: 'Audit', stats: 'Stats',
};
@@ -32,6 +32,10 @@ const ADMIN_LOADERS = {
if (typeof KnowledgeUI === 'undefined') { console.error('[Admin] KnowledgeUI not loaded — check js/knowledge-ui.js'); return; }
KnowledgeUI.openAdminPanel();
},
memory: () => {
if (typeof MemoryUI !== 'undefined') { MemoryUI.openAdminPanel(); }
else { console.error('[Admin] MemoryUI not loaded — check js/memory-ui.js'); }
},
settings: () => UI.loadAdminSettings(),
storage: () => typeof loadAdminStorage === 'function' ? loadAdminStorage() : null,
extensions: () => UI.loadAdminExtensions(),
@@ -963,6 +967,20 @@ Object.assign(UI, {
const compCooldown = document.getElementById('adminCompactionCooldown');
if (compCooldown) compCooldown.value = String(compactionCfg.cooldown || 30);
// Memory extraction config (v0.18.0)
const memExtractionCfg = getSetting('memory_extraction', {}) || {};
const memEnabled = document.getElementById('adminMemoryExtractionEnabled');
if (memEnabled) {
memEnabled.checked = !!memExtractionCfg.enabled;
const memFields = document.getElementById('memoryExtractionConfigFields');
if (memFields) memFields.style.display = memExtractionCfg.enabled ? '' : 'none';
memEnabled.addEventListener('change', () => {
if (memFields) memFields.style.display = memEnabled.checked ? '' : 'none';
});
}
const memAutoApprove = document.getElementById('adminMemoryAutoApprove');
if (memAutoApprove) memAutoApprove.checked = !!memExtractionCfg.auto_approve;
if (vaultEl) {
try {
const vault = await API.adminGetVaultStatus();

View File

@@ -59,6 +59,14 @@ function renderPresetForm(containerEl, options = {}) {
<div class="form-group"><label>Temperature</label><input type="number" id="${pfx}_temp" step="0.1" min="0" max="2" placeholder="default"></div>
<div class="form-group"><label>Max Tokens</label><input type="number" id="${pfx}_maxTokens" placeholder="default"></div>
</div>
<div class="form-group memory-persona-section" style="border-top:1px solid var(--border);padding-top:8px;margin-top:4px">
<label class="checkbox-label" style="font-weight:500"><input type="checkbox" id="${pfx}_memoryEnabled" checked> Enable Memory</label>
<p class="form-hint" style="margin:2px 0 6px">When enabled, the AI can save and recall facts about users across conversations.</p>
<div id="${pfx}_memoryExtractionPromptWrap">
<label>Custom Extraction Prompt <span class="form-hint">(optional)</span></label>
<textarea id="${pfx}_memoryExtractionPrompt" rows="2" placeholder="Override the default extraction prompt for this persona..."></textarea>
</div>
</div>
<div class="form-row">
<button class="btn-small btn-primary" id="${pfx}_submitBtn">Create</button>
<button class="btn-small" id="${pfx}_cancelBtn">Cancel</button>
@@ -111,6 +119,11 @@ function renderPresetForm(containerEl, options = {}) {
// Pending avatar data URI
const img = document.querySelector(`#${pfx}_avatarPreview img`);
if (img?.src?.startsWith('data:')) v._pendingAvatar = img.src;
// Memory fields (v0.18.0)
const memCb = document.getElementById(`${pfx}_memoryEnabled`);
if (memCb) v.memory_enabled = memCb.checked;
const memPrompt = document.getElementById(`${pfx}_memoryExtractionPrompt`)?.value?.trim();
if (memPrompt) v.memory_extraction_prompt = memPrompt;
return v;
},
setValues(p) {
@@ -123,6 +136,9 @@ function renderPresetForm(containerEl, options = {}) {
if (el('model')) el('model').value = p.base_model_id || '';
if (showConfig && el('config')) el('config').value = p.provider_config_id || '';
if (showAvatar) form.updateAvatarPreview(p.avatar || null);
// Memory fields (v0.18.0)
if (el('memoryEnabled')) el('memoryEnabled').checked = p.memory_enabled !== false;
if (el('memoryExtractionPrompt')) el('memoryExtractionPrompt').value = p.memory_extraction_prompt || '';
},
clearForm() {
['name','desc','prompt','temp','maxTokens'].forEach(f => {
@@ -930,6 +946,14 @@ const UI = {
}
}
if (tab === 'appearance') UI.loadAppearanceSettings();
if (tab === 'memory') {
if (typeof MemoryUI !== 'undefined') {
MemoryUI.openSettingsPanel();
} else {
const c = document.getElementById('settingsMemoryContent');
if (c) c.innerHTML = '<div style="padding:20px;color:var(--text-3);text-align:center">Memory UI failed to load.</div>';
}
}
},
// ── Export ────────────────────────────────