Phase 3: Unified Cheshire Cat integration with WebSocket-based per-user isolation

Key changes:
- CatAdapter (bot/utils/cat_client.py): WebSocket /ws/{user_id} for chat
  queries instead of HTTP POST (fixes per-user memory isolation when no
  API keys are configured — HTTP defaults all users to user_id='user')
- Memory management API: 8 endpoints for status, stats, facts, episodic
  memories, consolidation trigger, multi-step delete with confirmation
- Web UI: Memory tab (tab9) with collection stats, fact/episodic browser,
  manual consolidation trigger, and 3-step delete flow requiring exact
  confirmation string
- Bot integration: Cat-first response path with query_llama fallback for
  both text and embed responses, server mood detection
- Discord bridge plugin: fixed .pop() to .get() (UserMessage is a Pydantic
  BaseModelDict, not a raw dict), metadata extraction via extra attributes
- Unified docker-compose: Cat + Qdrant services merged into main compose,
  bot depends_on Cat healthcheck
- All plugins (discord_bridge, memory_consolidation, miku_personality)
  consolidated into cat-plugins/ for volume mount
- query_llama deprecated but functional for compatibility
This commit is contained in:
2026-02-07 20:22:03 +02:00
parent edb88e9ede
commit 14e1a8df51
14 changed files with 1382 additions and 70 deletions

View File

@@ -665,6 +665,7 @@
<button class="tab-button" onclick="switchTab('tab6')">📊 Autonomous Stats</button>
<button class="tab-button" onclick="switchTab('tab7')">💬 Chat with LLM</button>
<button class="tab-button" onclick="switchTab('tab8')">📞 Voice Call</button>
<button class="tab-button" onclick="switchTab('tab9')">🧠 Memories</button>
<button class="tab-button" onclick="window.location.href='/static/system.html'">🎛️ System Settings</button>
</div>
@@ -1547,6 +1548,142 @@
</div>
</div>
<!-- Tab 9: Memory Management -->
<div id="tab9" class="tab-content">
<div class="section">
<h3>🧠 Cheshire Cat Memory Management</h3>
<p style="color: #aaa; margin-bottom: 1rem;">
Manage Miku's long-term memories powered by the Cheshire Cat AI pipeline.
Memories are stored in Qdrant vector database and used to give Miku persistent knowledge about users.
</p>
<!-- Cat Integration Status -->
<div id="cat-status-section" style="background: #1a1a2e; border: 1px solid #444; border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h4 style="margin: 0 0 0.3rem 0;">🐱 Cheshire Cat Status</h4>
<span id="cat-status-indicator" style="color: #888;">Checking...</span>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button id="cat-toggle-btn" onclick="toggleCatIntegration()" style="background: #333; color: #fff; padding: 0.4rem 0.8rem; border: 2px solid #666; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 0.85rem;">
Loading...
</button>
<button onclick="refreshMemoryStats()" style="background: #2a5599; color: #fff; padding: 0.4rem 0.8rem; border: none; border-radius: 4px; cursor: pointer;">
🔄 Refresh
</button>
</div>
</div>
</div>
<!-- Memory Statistics -->
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 1.5rem;">
<div id="stat-episodic" style="background: #1a2332; border: 1px solid #2a5599; border-radius: 8px; padding: 1rem; text-align: center;">
<div style="font-size: 2rem; font-weight: bold; color: #61dafb;" id="stat-episodic-count"></div>
<div style="color: #aaa; font-size: 0.85rem;">📝 Episodic Memories</div>
<div style="color: #666; font-size: 0.75rem; margin-top: 0.3rem;">Conversation snippets</div>
</div>
<div id="stat-declarative" style="background: #1a3322; border: 1px solid #2a9955; border-radius: 8px; padding: 1rem; text-align: center;">
<div style="font-size: 2rem; font-weight: bold; color: #6fdc6f;" id="stat-declarative-count"></div>
<div style="color: #aaa; font-size: 0.85rem;">📚 Declarative Facts</div>
<div style="color: #666; font-size: 0.75rem; margin-top: 0.3rem;">Learned knowledge</div>
</div>
<div id="stat-procedural" style="background: #332a1a; border: 1px solid #995e2a; border-radius: 8px; padding: 1rem; text-align: center;">
<div style="font-size: 2rem; font-weight: bold; color: #dcb06f;" id="stat-procedural-count"></div>
<div style="color: #aaa; font-size: 0.85rem;">⚙️ Procedural</div>
<div style="color: #666; font-size: 0.75rem; margin-top: 0.3rem;">Tools & procedures</div>
</div>
</div>
<!-- Consolidation -->
<div style="background: #1a1a2e; border: 1px solid #444; border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem;">
<h4 style="margin: 0 0 0.5rem 0;">🌙 Memory Consolidation</h4>
<p style="color: #aaa; font-size: 0.85rem; margin-bottom: 0.75rem;">
Trigger the sleep consolidation process: analyzes episodic memories, extracts important facts, and removes trivial entries.
</p>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button id="consolidate-btn" onclick="triggerConsolidation()" style="background: #5b3a8c; color: #fff; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
🌙 Run Consolidation
</button>
<span id="consolidation-status" style="color: #888; font-size: 0.85rem;"></span>
</div>
<div id="consolidation-result" style="display: none; margin-top: 0.75rem; background: #111; border: 1px solid #333; border-radius: 4px; padding: 0.75rem; font-size: 0.85rem; color: #ccc; white-space: pre-wrap; max-height: 200px; overflow-y: auto;"></div>
</div>
<!-- Declarative Facts Browser -->
<div style="background: #1a1a2e; border: 1px solid #444; border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
<h4 style="margin: 0;">📚 Declarative Facts</h4>
<button onclick="loadFacts()" style="background: #2a5599; color: #fff; padding: 0.3rem 0.7rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85rem;">
🔄 Load Facts
</button>
</div>
<div id="facts-list" style="max-height: 400px; overflow-y: auto;">
<div style="text-align: center; color: #666; padding: 2rem;">Click "Load Facts" to view stored knowledge</div>
</div>
</div>
<!-- Episodic Memories Browser -->
<div style="background: #1a1a2e; border: 1px solid #444; border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
<h4 style="margin: 0;">📝 Episodic Memories</h4>
<button onclick="loadEpisodicMemories()" style="background: #2a5599; color: #fff; padding: 0.3rem 0.7rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85rem;">
🔄 Load Memories
</button>
</div>
<div id="episodic-list" style="max-height: 400px; overflow-y: auto;">
<div style="text-align: center; color: #666; padding: 2rem;">Click "Load Memories" to view conversation snippets</div>
</div>
</div>
<!-- DANGER ZONE: Delete All Memories -->
<div style="background: #2e1a1a; border: 2px solid #993333; border-radius: 8px; padding: 1rem;">
<h4 style="margin: 0 0 0.5rem 0; color: #ff6b6b;">⚠️ Danger Zone — Delete All Memories</h4>
<p style="color: #cc9999; font-size: 0.85rem; margin-bottom: 1rem;">
This will permanently erase ALL of Miku's memories — episodic conversations, learned facts, everything.
This action is <strong>irreversible</strong>. Miku will forget everything she has ever learned.
</p>
<!-- Step 1: Initial checkbox -->
<div id="delete-step-1" style="margin-bottom: 0.75rem;">
<label style="cursor: pointer; color: #ff9999;">
<input type="checkbox" id="delete-checkbox-1" onchange="onDeleteStep1Change()">
I understand this will permanently delete all of Miku's memories
</label>
</div>
<!-- Step 2: Second confirmation (hidden initially) -->
<div id="delete-step-2" style="display: none; margin-bottom: 0.75rem;">
<label style="cursor: pointer; color: #ff9999;">
<input type="checkbox" id="delete-checkbox-2" onchange="onDeleteStep2Change()">
I confirm this is irreversible and I want to proceed
</label>
</div>
<!-- Step 3: Type confirmation string (hidden initially) -->
<div id="delete-step-3" style="display: none; margin-bottom: 0.75rem;">
<p style="color: #ff6b6b; font-size: 0.85rem; margin-bottom: 0.5rem;">
Type exactly: <code style="background: #333; padding: 0.2rem 0.4rem; border-radius: 3px; color: #ff9999;">Yes, I am deleting Miku's memories fully.</code>
</p>
<input type="text" id="delete-confirmation-input" placeholder="Type the confirmation string..."
style="width: 100%; padding: 0.5rem; background: #1a1a1a; color: #ff9999; border: 1px solid #993333; border-radius: 4px; font-family: monospace; box-sizing: border-box;"
oninput="onDeleteInputChange()">
</div>
<!-- Final delete button (hidden initially) -->
<div id="delete-step-final" style="display: none;">
<button id="delete-all-btn" onclick="executeDeleteAllMemories()" disabled
style="background: #cc3333; color: #fff; padding: 0.5rem 1.5rem; border: none; border-radius: 4px; cursor: not-allowed; font-weight: bold; opacity: 0.5;">
🗑️ Permanently Delete All Memories
</button>
<button onclick="resetDeleteFlow()" style="background: #444; color: #ccc; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; margin-left: 0.5rem;">
Cancel
</button>
</div>
</div>
</div>
</div>
<div class="logs">
<h3>Logs</h3>
<div id="logs-content"></div>
@@ -1611,6 +1748,10 @@ function switchTab(tabId) {
console.log('🔄 Refreshing figurine subscribers for Server Management tab');
refreshFigurineSubscribers();
}
if (tabId === 'tab9') {
console.log('🧠 Refreshing memory stats for Memories tab');
refreshMemoryStats();
}
}
// Initialize
@@ -5020,6 +5161,292 @@ function updateVoiceCallHistoryDisplay() {
historyDiv.innerHTML = html;
}
// ========== Memory Management (Tab 9) ==========
async function refreshMemoryStats() {
try {
// Fetch Cat status
const statusRes = await fetch('/memory/status');
const statusData = await statusRes.json();
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 statsRes = await fetch('/memory/stats');
const statsData = await statsRes.json();
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 = '—';
}
} 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 statusRes = await fetch('/memory/status');
const statusData = await statusRes.json();
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)...';
resultDiv.style.display = 'none';
try {
const res = await fetch('/memory/consolidate', { method: 'POST' });
const data = await res.json();
if (data.success) {
status.textContent = '✅ Consolidation complete!';
status.style.color = '#6fdc6f';
resultDiv.textContent = data.result || 'Consolidation finished successfully.';
resultDiv.style.display = 'block';
showNotification('Memory consolidation complete', 'success');
refreshMemoryStats();
} 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 res = await fetch('/memory/facts');
const data = await res.json();
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';
html += `
<div 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)}</div>
<div style="color: #666; font-size: 0.75rem; margin-top: 0.3rem;">
Source: ${escapeHtml(source)} · ${when}
</div>
</div>
<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; flex-shrink: 0;"
title="Delete this fact">🗑️</button>
</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 res = await fetch('/memory/episodic');
const data = await res.json();
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';
html += `
<div 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)}</div>
<div style="color: #666; font-size: 0.75rem; margin-top: 0.3rem;">
Source: ${escapeHtml(source)} · ${when}
</div>
</div>
<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; flex-shrink: 0;"
title="Delete this memory">🗑️</button>
</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 res = await fetch(`/memory/point/${collection}/${pointId}`, { method: 'DELETE' });
const data = await res.json();
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) {
showNotification('Error: ' + err.message, 'error');
}
}
// 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 res = await fetch('/memory/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirmation: input })
});
const data = await res.json();
if (data.success) {
showNotification('All memories have been permanently deleted', 'success');
resetDeleteFlow();
refreshMemoryStats();
} else {
showNotification('Deletion failed: ' + (data.error || 'Unknown error'), 'error');
}
} catch (err) {
showNotification('Error: ' + err.message, 'error');
} 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();
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
</script>
</body>