Skip to content

Commit f9619fb

Browse files
committed
fix: lifecycle agent filter, relation graph layout, agent detail ID
- Lifecycle logs: add agent_id to all operation logs (expire/promote/archive/merge/compress) - Lifecycle filter: properly filter by agent_id, legacy logs without agent_id only show in unfiltered view - RelationGraph: fix toolbar layout - remove checkbox (replaced with toggle button), prevent CJK text overlap - RelationGraph: add touch gesture support (pinch zoom, drag), zoom controls overlay, double-click fit - RelationGraph: increase node repulsion/spacing for better readability - AgentDetail: add Agent ID row to basic info table - Global: add overflow-x:hidden + min-width:0 to .main container
1 parent e6de61b commit f9619fb

5 files changed

Lines changed: 148 additions & 54 deletions

File tree

packages/dashboard/src/pages/AgentDetail.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -827,7 +827,8 @@ def ingest(user_msg: str, assistant_msg: str):
827827
) : (
828828
<table>
829829
<tbody>
830-
<tr><td style={{ width: '30%' }}>{t('agentDetail.name')}</td><td>{agent.name}</td></tr>
830+
<tr><td style={{ width: '30%' }}>Agent ID</td><td><code style={{ fontSize: 13, padding: '2px 8px', background: 'var(--bg)', borderRadius: 4 }}>{agent.id}</code></td></tr>
831+
<tr><td>{t('agentDetail.name')}</td><td>{agent.name}</td></tr>
831832
<tr><td>{t('agentDetail.description')}</td><td>{agent.description || <span style={{ color: 'var(--text-muted)' }}>{t('agentDetail.noDescription')}</span>}</td></tr>
832833
<tr><td>{t('agentDetail.created')}</td><td>{new Date(agent.created_at).toLocaleString()}</td></tr>
833834
<tr><td>{t('agentDetail.updated')}</td><td>{new Date(agent.updated_at).toLocaleString()}</td></tr>

packages/dashboard/src/pages/RelationGraph.tsx

Lines changed: 135 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -220,11 +220,11 @@ export default function RelationGraph() {
220220
}
221221

222222
const nodeArr = Array.from(nodes.values());
223-
const REPULSION = 2000;
224-
const ATTRACTION = 0.008;
223+
const REPULSION = 5000;
224+
const ATTRACTION = 0.005;
225225
const DAMPING = 0.55;
226-
const CENTER_GRAVITY = 0.02;
227-
const IDEAL_DIST = 140;
226+
const CENTER_GRAVITY = 0.01;
227+
const IDEAL_DIST = 200;
228228
const MAX_VELOCITY = 5;
229229
const ALPHA_DECAY = 0.995;
230230
const ALPHA_MIN = 0.001;
@@ -552,6 +552,101 @@ export default function RelationGraph() {
552552
return () => canvas.removeEventListener('wheel', handler);
553553
}, [filteredRelations]);
554554

555+
// ── Touch interaction (pinch zoom + drag) ──
556+
557+
const touchRef = useRef<{ startDist: number; startScale: number; startX: number; startY: number; startTx: number; startTy: number; fingers: number }>({ startDist: 0, startScale: 1, startX: 0, startY: 0, startTx: 0, startTy: 0, fingers: 0 });
558+
559+
const getTouchDist = (touches: React.TouchList) => {
560+
if (touches.length < 2) return 0;
561+
const dx = touches[1]!.clientX - touches[0]!.clientX;
562+
const dy = touches[1]!.clientY - touches[0]!.clientY;
563+
return Math.sqrt(dx * dx + dy * dy);
564+
};
565+
566+
const getTouchCenter = (touches: React.TouchList) => {
567+
if (touches.length < 2) {
568+
const canvas = canvasRef.current!;
569+
const rect = canvas.getBoundingClientRect();
570+
return {
571+
cx: (touches[0]!.clientX - rect.left) * (canvas.width / rect.width),
572+
cy: (touches[0]!.clientY - rect.top) * (canvas.height / rect.height),
573+
};
574+
}
575+
const canvas = canvasRef.current!;
576+
const rect = canvas.getBoundingClientRect();
577+
return {
578+
cx: ((touches[0]!.clientX + touches[1]!.clientX) / 2 - rect.left) * (canvas.width / rect.width),
579+
cy: ((touches[0]!.clientY + touches[1]!.clientY) / 2 - rect.top) * (canvas.height / rect.height),
580+
};
581+
};
582+
583+
const onTouchStart = (e: React.TouchEvent<HTMLCanvasElement>) => {
584+
e.preventDefault();
585+
const t = transformRef.current;
586+
const { cx, cy } = getTouchCenter(e.touches);
587+
touchRef.current = {
588+
startDist: getTouchDist(e.touches),
589+
startScale: t.scale,
590+
startX: cx, startY: cy,
591+
startTx: t.offsetX, startTy: t.offsetY,
592+
fingers: e.touches.length,
593+
};
594+
595+
// Single finger: check node drag
596+
if (e.touches.length === 1) {
597+
const { wx, wy } = canvasToWorld(cx, cy);
598+
const nodeId = getNodeAt(wx, wy);
599+
if (nodeId) {
600+
const n = nodesRef.current.get(nodeId)!;
601+
dragRef.current = { nodeId, offsetX: n.x - wx, offsetY: n.y - wy, isPanning: false, startX: 0, startY: 0, startTx: 0, startTy: 0 };
602+
}
603+
}
604+
};
605+
606+
const onTouchMove = (e: React.TouchEvent<HTMLCanvasElement>) => {
607+
e.preventDefault();
608+
const tr = touchRef.current;
609+
const { cx, cy } = getTouchCenter(e.touches);
610+
611+
if (e.touches.length >= 2) {
612+
// Pinch zoom
613+
const dist = getTouchDist(e.touches);
614+
if (tr.startDist > 0) {
615+
const ratio = dist / tr.startDist;
616+
const newScale = Math.min(Math.max(tr.startScale * ratio, 0.1), 8);
617+
const wx = (tr.startX - tr.startTx) / tr.startScale;
618+
const wy = (tr.startY - tr.startTy) / tr.startScale;
619+
transformRef.current = {
620+
scale: newScale,
621+
offsetX: cx - wx * newScale + (cx - tr.startX),
622+
offsetY: cy - wy * newScale + (cy - tr.startY),
623+
};
624+
}
625+
} else if (e.touches.length === 1) {
626+
if (dragRef.current.nodeId) {
627+
// Drag node
628+
const { wx, wy } = canvasToWorld(cx, cy);
629+
const n = nodesRef.current.get(dragRef.current.nodeId);
630+
if (n) { n.x = wx + dragRef.current.offsetX; n.y = wy + dragRef.current.offsetY; n.vx = 0; n.vy = 0; }
631+
} else {
632+
// Pan
633+
transformRef.current = {
634+
...transformRef.current,
635+
offsetX: tr.startTx + (cx - tr.startX),
636+
offsetY: tr.startTy + (cy - tr.startY),
637+
};
638+
}
639+
}
640+
};
641+
642+
const onTouchEnd = (e: React.TouchEvent<HTMLCanvasElement>) => {
643+
e.preventDefault();
644+
if (dragRef.current.nodeId) {
645+
alphaRef.current = Math.max(alphaRef.current, 0.3);
646+
}
647+
dragRef.current = { nodeId: null, offsetX: 0, offsetY: 0, isPanning: false, startX: 0, startY: 0, startTx: 0, startTy: 0 };
648+
};
649+
555650
// Node stats
556651
const nodeSet = new Set<string>();
557652
filteredRelations.forEach(r => { nodeSet.add(r.subject); nodeSet.add(r.object); });
@@ -563,57 +658,61 @@ export default function RelationGraph() {
563658

564659
return (
565660
<div>
566-
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
567-
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('relations.title')}</h1>
568-
<button className="btn primary" onClick={() => setCreating(true)}>{t('relations.newRelation')}</button>
569-
</div>
661+
<h1 className="page-title">{t('relations.title')}</h1>
570662

571-
{/* Filters */}
572-
{predicates.length > 0 && (
573-
<div className="toolbar">
574-
<select value={predicateFilter} onChange={e => setPredicateFilter(e.target.value)} style={{ width: 'auto' }}>
663+
{/* Toolbar */}
664+
<div className="toolbar">
665+
{predicates.length > 0 && (
666+
<select value={predicateFilter} onChange={e => setPredicateFilter(e.target.value)}>
575667
<option value="">{t('relations.allPredicates', { count: predicates.length })}</option>
576668
{predicates.map(p => (
577669
<option key={p} value={p}>{p} ({relations.filter(r => r.predicate === p).length})</option>
578670
))}
579671
</select>
580-
{selectedNode && (
581-
<button className="btn" onClick={() => { setSelectedNode(null); setNodeMemories([]); }} style={{ fontSize: 12 }}>
582-
{t('relations.deselect', { node: selectedNode })}
583-
</button>
584-
)}
585-
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
586-
<button className="btn" onClick={fitToView} style={{ fontSize: 12, padding: '4px 10px' }}>
587-
{t('relations.fit')}
588-
</button>
589-
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-muted)', cursor: 'pointer', whiteSpace: 'nowrap' }}>
590-
<input type="checkbox" checked={autoRefresh} onChange={e => setAutoRefresh(e.target.checked)} />
591-
{t('relations.autoRefresh')}
592-
</label>
593-
<span style={{ color: 'var(--text-muted)', fontSize: 13, whiteSpace: 'nowrap' }}>
594-
{t('relations.nodeEdgeCount', { nodes: nodeSet.size, edges: filteredRelations.length })}
595-
</span>
596-
</div>
597-
</div>
598-
)}
672+
)}
673+
{selectedNode && (
674+
<button className="btn" onClick={() => { setSelectedNode(null); setNodeMemories([]); }} style={{ fontSize: 12 }}>
675+
{selectedNode}
676+
</button>
677+
)}
678+
<button className="btn" onClick={() => setAutoRefresh(!autoRefresh)} style={{ fontSize: 11, padding: '2px 8px' }}>
679+
{autoRefresh ? '⏸' : '▶'} {t('relations.autoRefresh')}
680+
</button>
681+
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
682+
{t('relations.nodeEdgeCount', { nodes: nodeSet.size, edges: filteredRelations.length })}
683+
</span>
684+
<div style={{ flex: 1 }} />
685+
<button className="btn primary" onClick={() => setCreating(true)}>{t('relations.newRelation')}</button>
686+
</div>
599687

600688
{/* Force-directed graph */}
601689
{filteredRelations.length > 0 && (
602-
<div className="card" style={{ marginBottom: 16 }}>
690+
<div className="card" style={{ marginBottom: 16, position: 'relative' }}>
603691
<canvas
604692
ref={canvasRef}
605693
width={900}
606694
height={500}
607-
style={{ width: '100%', height: 'auto', background: '#0f0f1a', borderRadius: 8, cursor: dragRef.current.nodeId ? 'grabbing' : dragRef.current.isPanning ? 'grabbing' : 'grab' }}
695+
style={{ width: '100%', height: 'auto', background: '#0f0f1a', borderRadius: 8, cursor: dragRef.current.nodeId ? 'grabbing' : dragRef.current.isPanning ? 'grabbing' : 'grab', touchAction: 'none' }}
608696
onMouseDown={onMouseDown}
609697
onMouseMove={onMouseMove}
610698
onMouseUp={onMouseUp}
611699
onMouseLeave={onMouseLeave}
700+
onDoubleClick={() => { fitToView(); alphaRef.current = Math.max(alphaRef.current, 0.3); }}
701+
onTouchStart={onTouchStart}
702+
onTouchMove={onTouchMove}
703+
onTouchEnd={onTouchEnd}
612704
/>
705+
{/* Zoom controls - bottom right */}
706+
<div style={{ position: 'absolute', bottom: 40, right: 16, display: 'flex', gap: 4, background: 'rgba(15,15,26,0.8)', borderRadius: 6, padding: 4 }}>
707+
<button style={{ width: 28, height: 28, border: '1px solid rgba(255,255,255,0.15)', borderRadius: 4, background: 'rgba(255,255,255,0.08)', color: '#ccc', fontSize: 16, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
708+
onClick={() => { const t = transformRef.current; const cx = canvasRef.current!.width / 2; const cy = canvasRef.current!.height / 2; const wx = (cx - t.offsetX) / t.scale; const wy = (cy - t.offsetY) / t.scale; const ns = Math.min(t.scale * 1.3, 8); transformRef.current = { scale: ns, offsetX: cx - wx * ns, offsetY: cy - wy * ns }; }}>+</button>
709+
<button style={{ width: 28, height: 28, border: '1px solid rgba(255,255,255,0.15)', borderRadius: 4, background: 'rgba(255,255,255,0.08)', color: '#ccc', fontSize: 16, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
710+
onClick={() => { const t = transformRef.current; const cx = canvasRef.current!.width / 2; const cy = canvasRef.current!.height / 2; const wx = (cx - t.offsetX) / t.scale; const wy = (cy - t.offsetY) / t.scale; const ns = Math.max(t.scale / 1.3, 0.1); transformRef.current = { scale: ns, offsetX: cx - wx * ns, offsetY: cy - wy * ns }; }}></button>
711+
<button style={{ width: 28, height: 28, border: '1px solid rgba(255,255,255,0.15)', borderRadius: 4, background: 'rgba(255,255,255,0.08)', color: '#ccc', fontSize: 11, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
712+
onClick={() => { fitToView(); alphaRef.current = Math.max(alphaRef.current, 0.3); }}>Fit</button>
713+
</div>
613714
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 6 }}>
614-
{t('relations.graphHint')}
615-
{' '}{t('relations.lineThickness')}
616-
{' '}{t('relations.zoomHint')}
715+
{t('relations.graphHint')} {t('relations.zoomHint')}
617716
</p>
618717
{tooMany && (
619718
<p style={{ fontSize: 12, color: '#f59e0b', marginTop: 4 }}>

packages/dashboard/src/style.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ a:hover { color: var(--primary-hover); }
6969
.sidebar nav a.active { color: var(--primary); }
7070

7171
/* Main content */
72-
.main { flex: 1; padding: 24px 32px; overflow-y: auto; }
72+
.main { flex: 1; padding: 24px 32px; overflow-y: auto; overflow-x: hidden; min-width: 0; }
7373

7474
.page-title {
7575
font-size: 24px;

packages/server/src/api/lifecycle.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,14 @@ export function registerLifecycleRoutes(app: FastifyInstance, cortex: CortexApp)
2323
const limit = q.limit ? parseInt(q.limit) : 50;
2424
const logs = getLifecycleLogs(limit);
2525

26-
// Filter by agent_id if provided (check memory_ids and details)
26+
// Filter by agent_id if provided
2727
if (q.agent_id) {
28-
// For lifecycle_run logs, filter by checking if the run was for a specific agent
29-
// For promote/archive/expire logs, check if memory_ids relate to agent
3028
return logs.filter((l: any) => {
3129
try {
3230
const details = l.details ? JSON.parse(l.details) : {};
33-
if (details.agent_id === q.agent_id) return true;
34-
// lifecycle_run with agent_id in details
35-
if (l.action === 'lifecycle_run' && details.agent_id === q.agent_id) return true;
36-
// For promote/expire, we need to check — but memory_ids don't carry agent info
37-
// So for non-lifecycle_run entries, include all (they're per-memory ops)
38-
if (l.action !== 'lifecycle_run') return true;
39-
// lifecycle_run without agent filter = global run, include if no agent filter on run
40-
if (!details.agent_id && !q.agent_id) return true;
41-
return false;
31+
const logAgent = details.agent_id;
32+
// Match: exact agent or global runs ('all'); legacy logs without agent_id only show in unfiltered view
33+
return logAgent === q.agent_id || logAgent === 'all';
4234
} catch { return true; }
4335
});
4436
}

packages/server/src/decay/lifecycle.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ export class LifecycleEngine {
197197
}
198198
})();
199199
await this.vectorBackend.delete(ids);
200-
insertLifecycleLog('expire_working', ids);
200+
insertLifecycleLog('expire_working', ids, { agent_id: agentId || 'all' });
201201
}
202202

203203
return expired.length;
@@ -244,7 +244,7 @@ export class LifecycleEngine {
244244
if (emb.length > 0) await this.vectorBackend.upsert(newId, emb);
245245
await this.vectorBackend.delete([entry.id]);
246246
} catch { /* best effort */ }
247-
insertLifecycleLog('promote', [entry.id, newId], { score: 1.0, from: 'working', to: 'core', reason: 'high_importance_auto' });
247+
insertLifecycleLog('promote', [entry.id, newId], { score: 1.0, from: 'working', to: 'core', reason: 'high_importance_auto', agent_id: agentId || 'all' });
248248
}
249249
promoted++;
250250
continue;
@@ -276,7 +276,7 @@ export class LifecycleEngine {
276276
await this.vectorBackend.delete([entry.id]);
277277
} catch { /* best effort */ }
278278

279-
insertLifecycleLog('promote', [entry.id, newId], { score, from: 'working', to: 'core' });
279+
insertLifecycleLog('promote', [entry.id, newId], { score, from: 'working', to: 'core', agent_id: agentId || 'all' });
280280
}
281281
promoted++;
282282
}
@@ -335,6 +335,7 @@ export class LifecycleEngine {
335335
kept: entry.id,
336336
removed: existing.id,
337337
distance: hit.distance,
338+
agent_id: agentId || 'all',
338339
});
339340
}
340341
superseded.add(existing.id);
@@ -384,7 +385,7 @@ export class LifecycleEngine {
384385
layer: 'archive',
385386
expires_at: new Date(Date.now() + archiveTtlMs).toISOString(),
386387
});
387-
insertLifecycleLog('archive', [entry.id], { decay_score: entry.decay_score });
388+
insertLifecycleLog('archive', [entry.id], { decay_score: entry.decay_score, agent_id: agentId || 'all' });
388389
}
389390
archived++;
390391
}
@@ -489,6 +490,7 @@ export class LifecycleEngine {
489490
insertLifecycleLog('compress', allOriginalIds, {
490491
compressed_count: expired.length,
491492
groups: groups.size,
493+
agent_id: 'all',
492494
});
493495
}
494496

0 commit comments

Comments
 (0)