Three fixes for consolidation reliability:
1. Fire-and-forget API: POST /memory/consolidate now launches consolidation
as an asyncio background task and returns immediately. The old approach
blocked until Cat's WS response, which could take 5+ minutes (LLM
extraction calls), exceeding both the WS timeout and browser fetch
timeout. Web UI now polls /memory/status to track completion.
2. Increased timeout: cat_client.trigger_consolidation() timeout raised
from 300s to 600s (configurable via parameter). Logs unexpected WS
message types for debugging.
3. Better logging: Consolidation log messages prefixed with 🌙 for
grep-friendliness. cat_client errors include exc_info=True for
traceback visibility. Web UI shows elapsed time while polling.
515 lines
20 KiB
JavaScript
515 lines
20 KiB
JavaScript
// ============================================================================
|
|
// Miku Control Panel — Memory Management Module
|
|
// ============================================================================
|
|
|
|
async function refreshMemoryStats() {
|
|
try {
|
|
// Fetch Cat status
|
|
const statusData = await apiCall('/memory/status');
|
|
|
|
const indicator = document.getElementById('cat-status-indicator');
|
|
const toggleBtn = document.getElementById('cat-toggle-btn');
|
|
|
|
if (statusData.healthy) {
|
|
indicator.innerHTML = `<span style="color: #6fdc6f;">● Connected</span> — ${statusData.url}`;
|
|
} else {
|
|
indicator.innerHTML = `<span style="color: #ff6b6b;">● Disconnected</span> — ${statusData.url}`;
|
|
}
|
|
|
|
if (statusData.circuit_breaker_active) {
|
|
indicator.innerHTML += ` <span style="color: #dcb06f;">(circuit breaker active)</span>`;
|
|
}
|
|
|
|
toggleBtn.textContent = statusData.enabled ? '🐱 Cat: ON' : '😿 Cat: OFF';
|
|
toggleBtn.style.background = statusData.enabled ? '#2a7a2a' : '#7a2a2a';
|
|
toggleBtn.style.borderColor = statusData.enabled ? '#4a9a4a' : '#9a4a4a';
|
|
|
|
// Fetch memory stats
|
|
const statsData = await apiCall('/memory/stats');
|
|
|
|
if (statsData.success && statsData.collections) {
|
|
const collections = {};
|
|
statsData.collections.forEach(c => { collections[c.name] = c.vectors_count; });
|
|
|
|
document.getElementById('stat-episodic-count').textContent = collections['episodic'] ?? '—';
|
|
document.getElementById('stat-declarative-count').textContent = collections['declarative'] ?? '—';
|
|
document.getElementById('stat-procedural-count').textContent = collections['procedural'] ?? '—';
|
|
} else {
|
|
document.getElementById('stat-episodic-count').textContent = '—';
|
|
document.getElementById('stat-declarative-count').textContent = '—';
|
|
document.getElementById('stat-procedural-count').textContent = '—';
|
|
}
|
|
|
|
// Show consolidation schedule info if available
|
|
const consInfo = document.getElementById('consolidation-schedule-info');
|
|
if (consInfo && statusData.consolidation) {
|
|
let infoHtml = '';
|
|
const cons = statusData.consolidation;
|
|
if (cons.last_run) {
|
|
const lastRun = new Date(cons.last_run).toLocaleString();
|
|
infoHtml += `🕐 Last run: ${lastRun}`;
|
|
if (cons.last_error) {
|
|
infoHtml += ` <span style="color: #ff6b6b;">❌ ${escapeHtml(cons.last_error)}</span>`;
|
|
} else if (cons.last_result) {
|
|
infoHtml += ` <span style="color: #6fdc6f;">✅</span>`;
|
|
}
|
|
infoHtml += `<br>`;
|
|
} else {
|
|
infoHtml += `🕐 Last run: never<br>`;
|
|
}
|
|
infoHtml += `📊 Runs: ${cons.successful_runs}/${cons.total_runs} successful`;
|
|
if (cons.is_running) {
|
|
infoHtml += ` <span style="color: #dcb06f;">⏳ (running now)</span>`;
|
|
}
|
|
consInfo.innerHTML = infoHtml;
|
|
}
|
|
} catch (err) {
|
|
console.error('Error refreshing memory stats:', err);
|
|
document.getElementById('cat-status-indicator').innerHTML = '<span style="color: #ff6b6b;">● Error checking status</span>';
|
|
}
|
|
}
|
|
|
|
async function toggleCatIntegration() {
|
|
try {
|
|
const statusData = await apiCall('/memory/status');
|
|
const newState = !statusData.enabled;
|
|
|
|
const formData = new FormData();
|
|
formData.append('enabled', newState);
|
|
const res = await fetch('/memory/toggle', { method: 'POST', body: formData });
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
showNotification(`Cheshire Cat ${newState ? 'enabled' : 'disabled'}`, newState ? 'success' : 'info');
|
|
refreshMemoryStats();
|
|
}
|
|
} catch (err) {
|
|
showNotification('Failed to toggle Cat integration', 'error');
|
|
}
|
|
}
|
|
|
|
async function triggerConsolidation() {
|
|
const btn = document.getElementById('consolidate-btn');
|
|
const status = document.getElementById('consolidation-status');
|
|
const resultDiv = document.getElementById('consolidation-result');
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = '⏳ Running...';
|
|
status.textContent = 'Consolidation in progress (this may take a few minutes)...';
|
|
status.style.color = '#dcb06f';
|
|
resultDiv.style.display = 'none';
|
|
|
|
try {
|
|
const data = await apiCall('/memory/consolidate', 'POST');
|
|
|
|
if (data.success) {
|
|
status.textContent = '⏳ Consolidation started — waiting for completion...';
|
|
|
|
// Poll /memory/status until consolidation finishes
|
|
const pollInterval = 5000; // 5 seconds
|
|
const maxPolls = 120; // 10 minutes max
|
|
|
|
for (let i = 0; i < maxPolls; i++) {
|
|
await new Promise(r => setTimeout(r, pollInterval));
|
|
|
|
const statusData = await apiCall('/memory/status');
|
|
const cons = statusData.consolidation;
|
|
|
|
if (!cons.is_running) {
|
|
// Consolidation finished
|
|
if (cons.last_error) {
|
|
status.textContent = '❌ ' + cons.last_error;
|
|
status.style.color = '#ff6b6b';
|
|
resultDiv.textContent = 'Error: ' + cons.last_error;
|
|
resultDiv.style.display = 'block';
|
|
showNotification('Consolidation failed: ' + cons.last_error, 'error');
|
|
} else {
|
|
status.textContent = '✅ Consolidation complete!';
|
|
status.style.color = '#6fdc6f';
|
|
resultDiv.textContent = cons.last_result || 'Consolidation finished successfully.';
|
|
resultDiv.style.display = 'block';
|
|
showNotification('Memory consolidation complete', 'success');
|
|
}
|
|
refreshMemoryStats();
|
|
break;
|
|
} else {
|
|
// Still running — update status message
|
|
status.textContent = `⏳ Consolidation still running... (${Math.round((i + 1) * pollInterval / 1000)}s elapsed)`;
|
|
}
|
|
}
|
|
|
|
// If we exited the loop without finishing
|
|
const finalStatus = await apiCall('/memory/status');
|
|
if (finalStatus.consolidation?.is_running) {
|
|
status.textContent = '⏳ Consolidation still running — check back later';
|
|
status.style.color = '#dcb06f';
|
|
}
|
|
} else {
|
|
status.textContent = '❌ ' + (data.error || 'Consolidation failed');
|
|
status.style.color = '#ff6b6b';
|
|
}
|
|
} catch (err) {
|
|
status.textContent = '❌ Error: ' + err.message;
|
|
status.style.color = '#ff6b6b';
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = '🌙 Run Consolidation';
|
|
}
|
|
}
|
|
|
|
async function loadFacts() {
|
|
const listDiv = document.getElementById('facts-list');
|
|
listDiv.innerHTML = '<div style="text-align: center; color: #888; padding: 1rem;">Loading facts...</div>';
|
|
|
|
try {
|
|
const data = await apiCall('/memory/facts');
|
|
|
|
if (!data.success || data.count === 0) {
|
|
listDiv.innerHTML = '<div style="text-align: center; color: #666; padding: 2rem;">No declarative facts stored yet.</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
data.facts.forEach((fact, i) => {
|
|
const source = fact.metadata?.source || 'unknown';
|
|
const when = fact.metadata?.when ? new Date(fact.metadata.when * 1000).toLocaleString() : 'unknown';
|
|
const persona = fact.metadata?.persona || 'miku';
|
|
const personaBadge = persona === 'evil_miku'
|
|
? '<span style="background: #7a2a2a; color: #ff9999; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 3px; margin-left: 0.3rem;">😈 Evil Miku</span>'
|
|
: '<span style="background: #2a5a7a; color: #99ccff; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 3px; margin-left: 0.3rem;">🎤 Miku</span>';
|
|
const factDataJson = escapeJsonForAttribute(fact);
|
|
html += `
|
|
<div class="memory-item" style="background: #242424; padding: 0.6rem 0.8rem; margin-bottom: 0.4rem; border-radius: 4px; border-left: 3px solid #2a9955; display: flex; justify-content: space-between; align-items: flex-start;">
|
|
<div style="flex: 1;">
|
|
<div style="color: #ddd; font-size: 0.9rem;">${escapeHtml(fact.content)}${personaBadge}</div>
|
|
<div style="color: #666; font-size: 0.75rem; margin-top: 0.3rem;">
|
|
Source: ${escapeHtml(source)} · ${when}
|
|
</div>
|
|
</div>
|
|
<div style="display: flex; gap: 0.3rem; flex-shrink: 0;">
|
|
<button data-memory='${factDataJson}' onclick='showEditMemoryModalFromButton(this, "declarative", "${fact.id}")'
|
|
style="background: none; border: none; color: #5599cc; cursor: pointer; padding: 0.2rem 0.4rem; font-size: 0.85rem;"
|
|
title="Edit this fact">✏️</button>
|
|
<button onclick="deleteMemoryPoint('declarative', '${fact.id}', this)"
|
|
style="background: none; border: none; color: #993333; cursor: pointer; padding: 0.2rem 0.4rem; font-size: 0.85rem;"
|
|
title="Delete this fact">🗑️</button>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
|
|
listDiv.innerHTML = `<div style="color: #888; font-size: 0.8rem; margin-bottom: 0.5rem;">${data.count} facts loaded</div>` + html;
|
|
} catch (err) {
|
|
listDiv.innerHTML = `<div style="color: #ff6b6b; padding: 1rem;">Error loading facts: ${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadEpisodicMemories() {
|
|
const listDiv = document.getElementById('episodic-list');
|
|
listDiv.innerHTML = '<div style="text-align: center; color: #888; padding: 1rem;">Loading memories...</div>';
|
|
|
|
try {
|
|
const data = await apiCall('/memory/episodic');
|
|
|
|
if (!data.success || data.count === 0) {
|
|
listDiv.innerHTML = '<div style="text-align: center; color: #666; padding: 2rem;">No episodic memories stored yet.</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
data.memories.forEach((mem, i) => {
|
|
const source = mem.metadata?.source || 'unknown';
|
|
const when = mem.metadata?.when ? new Date(mem.metadata.when * 1000).toLocaleString() : 'unknown';
|
|
const persona = mem.metadata?.persona || 'miku';
|
|
const personaBadge = persona === 'evil_miku'
|
|
? '<span style="background: #7a2a2a; color: #ff9999; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 3px; margin-left: 0.3rem;">😈 Evil Miku</span>'
|
|
: '<span style="background: #2a5a7a; color: #99ccff; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 3px; margin-left: 0.3rem;">🎤 Miku</span>';
|
|
const memDataJson = escapeJsonForAttribute(mem);
|
|
html += `
|
|
<div class="memory-item" style="background: #242424; padding: 0.6rem 0.8rem; margin-bottom: 0.4rem; border-radius: 4px; border-left: 3px solid #2a5599; display: flex; justify-content: space-between; align-items: flex-start;">
|
|
<div style="flex: 1;">
|
|
<div style="color: #ddd; font-size: 0.9rem;">${escapeHtml(mem.content)}${personaBadge}</div>
|
|
<div style="color: #666; font-size: 0.75rem; margin-top: 0.3rem;">
|
|
Source: ${escapeHtml(source)} · ${when}
|
|
</div>
|
|
</div>
|
|
<div style="display: flex; gap: 0.3rem; flex-shrink: 0;">
|
|
<button data-memory='${memDataJson}' onclick='showEditMemoryModalFromButton(this, "episodic", "${mem.id}")'
|
|
style="background: none; border: none; color: #5599cc; cursor: pointer; padding: 0.2rem 0.4rem; font-size: 0.85rem;"
|
|
title="Edit this memory">✏️</button>
|
|
<button onclick="deleteMemoryPoint('episodic', '${mem.id}', this)"
|
|
style="background: none; border: none; color: #993333; cursor: pointer; padding: 0.2rem 0.4rem; font-size: 0.85rem;"
|
|
title="Delete this memory">🗑️</button>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
|
|
listDiv.innerHTML = `<div style="color: #888; font-size: 0.8rem; margin-bottom: 0.5rem;">${data.count} memories loaded</div>` + html;
|
|
} catch (err) {
|
|
listDiv.innerHTML = `<div style="color: #ff6b6b; padding: 1rem;">Error loading memories: ${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function deleteMemoryPoint(collection, pointId, btnElement) {
|
|
if (!confirm(`Delete this ${collection} memory point?`)) return;
|
|
|
|
try {
|
|
const data = await apiCall(`/memory/point/${collection}/${pointId}`, 'DELETE');
|
|
|
|
if (data.success) {
|
|
// Remove the row from the UI
|
|
const row = btnElement.closest('div[style*="margin-bottom"]');
|
|
if (row) row.remove();
|
|
showNotification('Memory point deleted', 'success');
|
|
refreshMemoryStats();
|
|
} else {
|
|
showNotification('Failed to delete: ' + (data.error || 'Unknown error'), 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to delete memory point:', err);
|
|
}
|
|
}
|
|
|
|
// Delete All Memories — Multi-step confirmation flow
|
|
function onDeleteStep1Change() {
|
|
const checked = document.getElementById('delete-checkbox-1').checked;
|
|
document.getElementById('delete-step-2').style.display = checked ? 'block' : 'none';
|
|
if (!checked) {
|
|
document.getElementById('delete-checkbox-2').checked = false;
|
|
document.getElementById('delete-step-3').style.display = 'none';
|
|
document.getElementById('delete-step-final').style.display = 'none';
|
|
document.getElementById('delete-confirmation-input').value = '';
|
|
}
|
|
}
|
|
|
|
function onDeleteStep2Change() {
|
|
const checked = document.getElementById('delete-checkbox-2').checked;
|
|
document.getElementById('delete-step-3').style.display = checked ? 'block' : 'none';
|
|
document.getElementById('delete-step-final').style.display = checked ? 'block' : 'none';
|
|
if (!checked) {
|
|
document.getElementById('delete-confirmation-input').value = '';
|
|
updateDeleteButton();
|
|
}
|
|
}
|
|
|
|
function onDeleteInputChange() {
|
|
updateDeleteButton();
|
|
}
|
|
|
|
function updateDeleteButton() {
|
|
const input = document.getElementById('delete-confirmation-input').value;
|
|
const expected = "Yes, I am deleting Miku's memories fully.";
|
|
const btn = document.getElementById('delete-all-btn');
|
|
const match = input === expected;
|
|
|
|
btn.disabled = !match;
|
|
btn.style.cursor = match ? 'pointer' : 'not-allowed';
|
|
btn.style.opacity = match ? '1' : '0.5';
|
|
}
|
|
|
|
async function executeDeleteAllMemories() {
|
|
const input = document.getElementById('delete-confirmation-input').value;
|
|
const expected = "Yes, I am deleting Miku's memories fully.";
|
|
|
|
if (input !== expected) {
|
|
showNotification('Confirmation string does not match', 'error');
|
|
return;
|
|
}
|
|
|
|
const btn = document.getElementById('delete-all-btn');
|
|
btn.disabled = true;
|
|
btn.textContent = '⏳ Deleting...';
|
|
|
|
try {
|
|
const data = await apiCall('/memory/delete', 'POST', { confirmation: input });
|
|
|
|
if (data.success) {
|
|
showNotification('All memories have been permanently deleted', 'success');
|
|
resetDeleteFlow();
|
|
refreshMemoryStats();
|
|
} else {
|
|
showNotification('Deletion failed: ' + (data.error || 'Unknown error'), 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to delete all memories:', err);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = '🗑️ Permanently Delete All Memories';
|
|
}
|
|
}
|
|
|
|
function resetDeleteFlow() {
|
|
document.getElementById('delete-checkbox-1').checked = false;
|
|
document.getElementById('delete-checkbox-2').checked = false;
|
|
document.getElementById('delete-confirmation-input').value = '';
|
|
document.getElementById('delete-step-2').style.display = 'none';
|
|
document.getElementById('delete-step-3').style.display = 'none';
|
|
document.getElementById('delete-step-final').style.display = 'none';
|
|
updateDeleteButton();
|
|
}
|
|
|
|
// Memory Edit/Create Modal Functions
|
|
// currentEditMemory declared in core.js
|
|
|
|
function showEditMemoryModalFromButton(button, collection, pointId) {
|
|
const memoryJson = button.getAttribute('data-memory');
|
|
// Unescape HTML entities back to JSON
|
|
const unescapedJson = memoryJson
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/&/g, '&');
|
|
const memory = JSON.parse(unescapedJson);
|
|
showEditMemoryModal(collection, pointId, memory);
|
|
}
|
|
|
|
function showEditMemoryModal(collection, pointId, memoryData) {
|
|
const memory = typeof memoryData === 'string' ? JSON.parse(memoryData) : memoryData;
|
|
currentEditMemory = { collection, pointId, memory };
|
|
|
|
const modal = document.getElementById('edit-memory-modal');
|
|
const contentField = document.getElementById('edit-memory-content');
|
|
const sourceField = document.getElementById('edit-memory-source');
|
|
|
|
contentField.value = memory.content || '';
|
|
sourceField.value = memory.metadata?.source || '';
|
|
|
|
modal.style.display = 'flex';
|
|
}
|
|
|
|
function closeEditMemoryModal() {
|
|
document.getElementById('edit-memory-modal').style.display = 'none';
|
|
currentEditMemory = null;
|
|
}
|
|
|
|
async function saveMemoryEdit() {
|
|
if (!currentEditMemory) return;
|
|
|
|
const content = document.getElementById('edit-memory-content').value.trim();
|
|
const source = document.getElementById('edit-memory-source').value.trim();
|
|
|
|
if (!content) {
|
|
showNotification('Content cannot be empty', 'error');
|
|
return;
|
|
}
|
|
|
|
const { collection, pointId } = currentEditMemory;
|
|
const saveBtn = document.querySelector('#edit-memory-modal button[onclick="saveMemoryEdit()"]');
|
|
saveBtn.disabled = true;
|
|
saveBtn.textContent = 'Saving...';
|
|
|
|
try {
|
|
const data = await apiCall(`/memory/point/${collection}/${pointId}`, 'PUT', {
|
|
content: content,
|
|
metadata: { source: source || 'manual_edit' }
|
|
});
|
|
|
|
if (data.success) {
|
|
showNotification('Memory updated successfully', 'success');
|
|
closeEditMemoryModal();
|
|
// Reload the appropriate list
|
|
if (collection === 'declarative') {
|
|
loadFacts();
|
|
} else if (collection === 'episodic') {
|
|
loadEpisodicMemories();
|
|
}
|
|
} else {
|
|
showNotification('Failed to update: ' + (data.error || 'Unknown error'), 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to save memory edit:', err);
|
|
} finally {
|
|
saveBtn.disabled = false;
|
|
saveBtn.textContent = 'Save Changes';
|
|
}
|
|
}
|
|
|
|
function showCreateMemoryModal(collection) {
|
|
const modal = document.getElementById('create-memory-modal');
|
|
document.getElementById('create-memory-collection').value = collection;
|
|
document.getElementById('create-memory-content').value = '';
|
|
document.getElementById('create-memory-user-id').value = '';
|
|
document.getElementById('create-memory-source').value = 'manual';
|
|
|
|
// Update modal title based on collection type
|
|
const title = collection === 'declarative' ? 'Add New Fact' : 'Add New Memory';
|
|
document.querySelector('#create-memory-modal h3').textContent = title;
|
|
|
|
modal.style.display = 'flex';
|
|
}
|
|
|
|
function closeCreateMemoryModal() {
|
|
document.getElementById('create-memory-modal').style.display = 'none';
|
|
}
|
|
|
|
// Modal keyboard and backdrop close handlers
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') {
|
|
const editModal = document.getElementById('edit-memory-modal');
|
|
const createModal = document.getElementById('create-memory-modal');
|
|
if (editModal && editModal.style.display !== 'none') closeEditMemoryModal();
|
|
if (createModal && createModal.style.display !== 'none') closeCreateMemoryModal();
|
|
}
|
|
});
|
|
|
|
async function saveNewMemory() {
|
|
const collection = document.getElementById('create-memory-collection').value;
|
|
const content = document.getElementById('create-memory-content').value.trim();
|
|
const userId = document.getElementById('create-memory-user-id').value.trim();
|
|
const source = document.getElementById('create-memory-source').value.trim();
|
|
|
|
if (!content) {
|
|
showNotification('Content cannot be empty', 'error');
|
|
return;
|
|
}
|
|
|
|
const createBtn = document.querySelector('#create-memory-modal button[onclick="saveNewMemory()"]');
|
|
createBtn.disabled = true;
|
|
createBtn.textContent = 'Creating...';
|
|
|
|
try {
|
|
const data = await apiCall('/memory/create', 'POST', {
|
|
collection: collection,
|
|
content: content,
|
|
user_id: userId || null,
|
|
source: source || 'manual',
|
|
metadata: {}
|
|
});
|
|
|
|
if (data.success) {
|
|
showNotification(`${collection === 'declarative' ? 'Fact' : 'Memory'} created successfully`, 'success');
|
|
closeCreateMemoryModal();
|
|
// Reload the appropriate list
|
|
if (collection === 'declarative') {
|
|
loadFacts();
|
|
} else if (collection === 'episodic') {
|
|
loadEpisodicMemories();
|
|
}
|
|
refreshMemoryStats();
|
|
} else {
|
|
showNotification('Failed to create: ' + (data.error || 'Unknown error'), 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to save new memory:', err);
|
|
} finally {
|
|
createBtn.disabled = false;
|
|
createBtn.textContent = 'Create Memory';
|
|
}
|
|
}
|
|
|
|
// Search/Filter Function
|
|
function filterMemories(listId, searchTerm) {
|
|
const listDiv = document.getElementById(listId);
|
|
const items = listDiv.querySelectorAll('.memory-item');
|
|
const term = searchTerm.toLowerCase().trim();
|
|
|
|
items.forEach(item => {
|
|
const content = item.textContent.toLowerCase();
|
|
if (term === '' || content.includes(term)) {
|
|
item.style.display = 'flex';
|
|
} else {
|
|
item.style.display = 'none';
|
|
}
|
|
});
|
|
}
|