Changeset 0.18.0 (#79)
This commit is contained in:
298
src/css/memory.css
Normal file
298
src/css/memory.css
Normal file
@@ -0,0 +1,298 @@
|
||||
/* ==========================================
|
||||
* Chat Switchboard – Memory UI (v0.18.0)
|
||||
* ========================================== */
|
||||
|
||||
/* ── Summary Stats ─────────────────────── */
|
||||
|
||||
.memory-summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.memory-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
background: var(--bg-2);
|
||||
border-radius: 8px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.memory-stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-1);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.memory-stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Toolbar ───────────────────────────── */
|
||||
|
||||
.memory-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.memory-filter-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.memory-select {
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-1);
|
||||
color: var(--text-1);
|
||||
font-size: 0.82rem;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.memory-search {
|
||||
flex: 1;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-1);
|
||||
color: var(--text-1);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.memory-search::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.memory-actions-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Memory List ───────────────────────── */
|
||||
|
||||
.memory-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* ── Memory Card ───────────────────────── */
|
||||
|
||||
.memory-card {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.memory-card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.memory-card-pending {
|
||||
border-left: 3px solid var(--warning, #f0ad4e);
|
||||
}
|
||||
|
||||
.memory-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.memory-key {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.memory-badges {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.memory-scope-badge {
|
||||
font-size: 0.68rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.memory-scope-user { background: var(--accent-dim, #1a3a5c); color: var(--accent-text, #6db3f2); }
|
||||
.memory-scope-persona { background: #3a2a1a; color: #f0ad4e; }
|
||||
.memory-scope-persona_user { background: #2a1a3a; color: #c084fc; }
|
||||
|
||||
.memory-confidence {
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
background: var(--bg-3);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.memory-confidence-high { color: var(--success, #5cb85c); }
|
||||
.memory-confidence-medium { color: var(--warning, #f0ad4e); }
|
||||
.memory-confidence-low { color: var(--text-3); }
|
||||
|
||||
.memory-owner {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-3);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.memory-card-value {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-2);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.memory-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.memory-date {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.memory-card-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ── Tiny Action Buttons ───────────────── */
|
||||
|
||||
.btn-tiny {
|
||||
padding: 2px 8px;
|
||||
font-size: 0.72rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-1);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.btn-tiny:hover {
|
||||
background: var(--bg-3);
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.btn-tiny.btn-approve {
|
||||
color: var(--success, #5cb85c);
|
||||
border-color: var(--success, #5cb85c);
|
||||
}
|
||||
|
||||
.btn-tiny.btn-approve:hover {
|
||||
background: var(--success, #5cb85c);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-tiny.btn-reject {
|
||||
color: var(--danger, #d9534f);
|
||||
border-color: var(--danger, #d9534f);
|
||||
}
|
||||
|
||||
.btn-tiny.btn-reject:hover {
|
||||
background: var(--danger, #d9534f);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Edit Form ─────────────────────────── */
|
||||
|
||||
.memory-edit-form {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.memory-edit-form .form-group {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.memory-edit-input {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-1);
|
||||
color: var(--text-1);
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.memory-edit-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Persona Memory Section ────────────── */
|
||||
|
||||
.memory-persona-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.form-divider {
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
/* ── Admin Toolbar ─────────────────────── */
|
||||
|
||||
.admin-toolbar-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-2);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────── */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.memory-summary {
|
||||
gap: 8px;
|
||||
}
|
||||
.memory-stat {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.memory-filter-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.memory-card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="stylesheet" href="css/styles.css?v=%%APP_VERSION%%">
|
||||
<link rel="stylesheet" href="css/persona-kb.css?v=%%APP_VERSION%%">
|
||||
<link rel="stylesheet" href="css/memory.css?v=%%APP_VERSION%%">
|
||||
<link rel="stylesheet" href="branding/custom.css" onerror="this.remove()">
|
||||
</head>
|
||||
<body>
|
||||
@@ -387,6 +388,7 @@
|
||||
<button class="settings-tab" data-stab="usage" onclick="UI.switchSettingsTab('usage')" id="settingsUsageTabBtn">Usage</button>
|
||||
<button class="settings-tab" data-stab="roles" onclick="UI.switchSettingsTab('roles')" id="settingsRolesTabBtn">Model Roles</button>
|
||||
<button class="settings-tab" data-stab="knowledgeBases" onclick="UI.switchSettingsTab('knowledgeBases')" id="settingsKBTabBtn" style="display:none">Knowledge</button>
|
||||
<button class="settings-tab" data-stab="memory" onclick="UI.switchSettingsTab('memory')" id="settingsMemoryTabBtn">Memory</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- General Tab -->
|
||||
@@ -550,6 +552,14 @@
|
||||
<div class="settings-tab-content" id="settingsKnowledgeBasesTab" style="display:none">
|
||||
<div id="kbManagePanel"></div>
|
||||
</div>
|
||||
<!-- Memory Tab (v0.18.0) -->
|
||||
<div class="settings-tab-content" id="settingsMemoryTab" style="display:none">
|
||||
<section class="settings-section">
|
||||
<h3 style="font-size:14px;margin-bottom:4px">My Memories</h3>
|
||||
<p class="section-hint" style="margin-bottom:8px">Facts and preferences learned from your conversations. Memories help AI provide more personalized responses.</p>
|
||||
<div id="settingsMemoryContent"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div>
|
||||
</div>
|
||||
@@ -791,6 +801,9 @@
|
||||
<div class="admin-section-content" id="adminKnowledgeBasesTab" style="display:none">
|
||||
<div id="adminKBContent"></div>
|
||||
</div>
|
||||
<div class="admin-section-content" id="adminMemoryTab" style="display:none">
|
||||
<div id="adminMemoryContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- System -->
|
||||
<div class="admin-section-content" id="adminSettingsTab" style="display:none">
|
||||
@@ -904,6 +917,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-section">
|
||||
<h3>Memory Extraction</h3>
|
||||
<label class="checkbox-label"><input type="checkbox" id="adminMemoryExtractionEnabled"> Enable automatic memory extraction</label>
|
||||
<p class="section-hint">When enabled, the system automatically extracts memorable facts from conversations using the utility model. Extracted memories require approval before becoming active. Requires a utility model role.</p>
|
||||
<div id="memoryExtractionConfigFields" style="display:none;margin-top:8px">
|
||||
<label class="checkbox-label"><input type="checkbox" id="adminMemoryAutoApprove"> Auto-approve extracted memories</label>
|
||||
<p class="section-hint">Skip the review pipeline and activate extracted memories immediately. Not recommended for sensitive environments.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="admin-section">
|
||||
<h4>🔐 Encryption</h4>
|
||||
<div id="adminVaultStatus"><span class="empty-hint">Loading…</span></div>
|
||||
@@ -1100,6 +1122,7 @@
|
||||
<script src="js/attachments.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/tools-toggle.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/knowledge-ui.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/memory-ui.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/persona-kb.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/chat.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
|
||||
|
||||
@@ -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
364
src/js/memory-ui.js
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user