Feature: Enhanced Memory Search UI #1049

Closed
gemini wants to merge 2 commits from feature/enhanced-memory-ui into main
2 changed files with 169 additions and 0 deletions

View File

@@ -13,9 +13,53 @@ from timmy.memory_system import (
update_personal_fact,
)
from timmy.memory_system import get_connection
router = APIRouter(prefix="/memory", tags=["memory"])
@router.get("/graph_data", response_class=JSONResponse)
async def memory_graph_data():
"""Return memory nodes and links for visualization."""
nodes = []
links = []
with get_connection() as conn:
rows = conn.execute(
"SELECT id, content, memory_type, session_id, agent_id FROM memories ORDER BY created_at DESC LIMIT 100"
).fetchall()
for row in rows:
nodes.append({
"id": row["id"],
"content": row["content"][:50],
"type": row["memory_type"]
})
# Simple linking logic: link memories in the same session
if row["session_id"]:
for other in nodes[:-1]:
# This is a bit slow but okay for 100 nodes
pass
# More robust linking: link by session_id
session_map = {}
for row in rows:
sid = row["session_id"]
if sid:
if sid not in session_map:
session_map[sid] = []
session_map[sid].append(row["id"])
for sid, ids in session_map.items():
for i in range(len(ids) - 1):
links.append({"source": ids[i], "target": ids[i+1]})
return {"nodes": nodes, "links": links}
@router.get("", response_class=HTMLResponse)
async def memory_page(
request: Request,

View File

@@ -3,7 +3,26 @@
{% block title %}Memory Browser{% endblock %}
{% block content %}
<div class="mc-panel">
<div class="mc-panel-header d-flex justify-content-between align-items-center">
<div>
<h1 class="page-title">Memory Browser</h1>
<p class="mc-text-secondary">Semantic search through conversation history and facts</p>
</div>
<ul class="nav nav-pills" id="memoryTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="list-tab" data-bs-toggle="pill" data-bs-target="#listView" type="button" role="tab">List View</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="graph-tab" data-bs-toggle="pill" data-bs-target="#graphView" type="button" role="tab">Graph View</button>
</li>
</ul>
</div>
<div class="tab-content" id="memoryTabsContent">
<div class="tab-pane fade show active" id="listView" role="tabpanel">
<div class="mc-panel-header">
<h1 class="page-title">Memory Browser</h1>
<p class="mc-text-secondary">Semantic search through conversation history and facts</p>
@@ -66,7 +85,14 @@
<span class="memory-score">{{ "%.2f"|format(mem.relevance_score) }}</span>
{% endif %}
</div>
<div class="memory-content">{{ mem.content }}</div>
{% if mem.relevance_score %}
<div class="relevance-bar-container mt-2" style="height: 4px; background: #eee; border-radius: 2px;">
<div class="relevance-bar" style="height: 100%; width: {{ mem.relevance_score * 100 }}%; background: #007bff; border-radius: 2px;"></div>
</div>
{% endif %}
<div class="memory-meta">
<span class="memory-time">{{ mem.timestamp[11:16] }}</span>
{% if mem.agent_id %}
@@ -119,9 +145,108 @@
{% endif %}
</div>
</div>
</div> <!-- End listView -->
<div class="tab-pane fade" id="graphView" role="tabpanel">
<div class="mc-graph-container border rounded bg-light" style="height: 600px; position: relative;">
<div id="memory-graph" style="width: 100%; height: 100%;"></div>
<div id="graph-tooltip" class="position-absolute p-2 bg-white border rounded shadow-sm d-none" style="z-index: 1000; pointer-events: none;"></div>
</div>
</div>
</div> <!-- End tab-content -->
</div>
<script src="https://d3js.org/d3.v7.min.js">
// Graph View Logic
document.getElementById('graph-tab').addEventListener('shown.bs.tab', function() {
fetch('/memory/graph_data')
.then(res => res.json())
.then(data => {
renderGraph(data);
});
});
function renderGraph(data) {
const container = document.getElementById('memory-graph');
if (!container) return;
container.innerHTML = '';
const width = container.clientWidth;
const height = container.clientHeight;
const svg = d3.select('#memory-graph')
.append('svg')
.attr('width', width)
.attr('height', height);
const simulation = d3.forceSimulation(data.nodes)
.force('link', d3.forceLink(data.links).id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(width / 2, height / 2));
const link = svg.append('g')
.selectAll('line')
.data(data.links)
.enter().append('line')
.attr('stroke', '#999')
.attr('stroke-opacity', 0.6);
const node = svg.append('g')
.selectAll('circle')
.data(data.nodes)
.enter().append('circle')
.attr('r', d => d.type === 'fact' ? 8 : 5)
.attr('fill', d => d.type === 'fact' ? '#ff7f0e' : '#1f77b4')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
const tooltip = document.getElementById('graph-tooltip');
node.on('mouseover', (event, d) => {
tooltip.classList.remove('d-none');
tooltip.innerHTML = '<strong>' + d.type + '</strong><br>' + d.content.substring(0, 100) + '...';
tooltip.style.left = (event.pageX - container.getBoundingClientRect().left + 10) + 'px';
tooltip.style.top = (event.pageY - container.getBoundingClientRect().top + 10) + 'px';
});
node.on('mouseout', () => {
tooltip.classList.add('d-none');
});
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
node
.attr('cx', d => d.x)
.attr('cy', d => d.y);
});
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
}
</script>
<script>
function deleteFact(id) {
if (!confirm('Delete this fact?')) return;
fetch('/memory/fact/' + id, { method: 'DELETE' })