diff --git a/admin/index.html b/admin/index.html index 57f6d17..908082b 100644 --- a/admin/index.html +++ b/admin/index.html @@ -216,6 +216,7 @@
Loading memory…
+
@@ -253,7 +254,7 @@ users() { return this.get('/admin/users'); }, sessions(qs='') { return this.get('/admin/sessions' + (qs ? '?' + qs : '')); }, - memory() { return this.get('/admin/memory'); }, + memory(qs='') { return this.get('/admin/memory' + (qs ? '?' + qs : '')); }, profiles() { return this.get('/admin/profiles'); }, session(id){ return this.get(`/admin/sessions/${id}`); }, delSession(id){ return this.del(`/admin/sessions/${id}`); }, @@ -452,61 +453,107 @@ }; // ── Memory ───────────────────────────────────────────────────────────────── +const memState = { limit: 50, offset: 0, search: '', sortBy: 'updated_at', sortOrder: 'desc', total: 0, items: [] }; + async function loadMemory() { const el = $('#memory-content'); + const countEl = $('#mem-count'); try { - const data = await api.memory(); + const params = new URLSearchParams(); + params.set('limit', String(memState.limit)); + params.set('offset', String(memState.offset)); + if (memState.search) params.set('search', memState.search); + params.set('sort_by', memState.sortBy); + params.set('sort_order', memState.sortOrder); + const data = await api.memory(params.toString()); state.memoryLoaded = true; - state.memoryFacts = data.facts || []; - renderMemory(state.memoryFacts); + memState.total = data.total || 0; + memState.items = data.items || []; + el.innerHTML = renderMemoryTable(memState.items); + countEl.textContent = `${memState.total} total`; + renderMemoryPagination(); } catch (err) { el.innerHTML = `
${esc(err.message)}
`; + countEl.textContent = ''; } } -function renderMemory(facts) { - const el = $('#memory-content'); - const q = ($('#mem-search').value || '').trim().toLowerCase(); - let filtered = facts; - if (q) { - filtered = facts.filter(f => - (f.key||'').toLowerCase().includes(q) || - (f.value||'').toLowerCase().includes(q) || - (f.category||'').toLowerCase().includes(q) - ); - } - $('#mem-count').textContent = `${filtered.length} / ${facts.length} facts`; +const MEMORY_COLUMNS = [ + { key: 'category', label: 'Category' }, + { key: 'key', label: 'Key' }, + { key: 'value', label: 'Value', sortable: false }, + { key: 'source', label: 'Source' }, + { key: 'confidence', label: 'Confidence' }, + { key: 'updated_at', label: 'Updated' }, +]; - if (!filtered.length) { el.innerHTML = '
No facts.
'; return; } - - const groups = {}; - for (const f of filtered) { - const cat = f.category || 'other'; - if (!groups[cat]) groups[cat] = []; - groups[cat].push(f); +function renderMemoryTable(items) { + if (!items.length) return '
No facts.
'; + let html = ''; + for (const col of MEMORY_COLUMNS) { + const isSorted = memState.sortBy === col.key; + const cls = col.sortable !== false ? `sortable ${isSorted ? 'sort-' + memState.sortOrder : ''}` : ''; + html += ``; } - - let html = ''; - for (const cat of Object.keys(groups).sort()) { - html += `

${esc(cat)} (${groups[cat].length})

`; - html += '
${esc(col.label)}
'; - ['Key','Value','Source','Confidence','Updated'].forEach(h => html += ``); - html += ''; - for (const f of groups[cat]) { - html += ` - - - - - - `; - } - html += '
${h}
${esc(f.key)}${esc(f.value)}${esc(f.source)}${f.confidence}${fmtDate(f.updated_at)}
'; + html += ''; + for (const f of items) { + html += ` + ${esc(f.category)} + ${esc(f.key)} + ${esc(f.value)} + ${esc(f.source)} + ${f.confidence} + ${fmtDate(f.updated_at)} + `; } - el.innerHTML = html; + html += ''; + return html; } -$('#mem-search').addEventListener('input', () => renderMemory(state.memoryFacts)); +function renderMemoryPagination() { + const el = $('#mem-pagination'); + const start = memState.total ? memState.offset + 1 : 0; + const end = Math.min(memState.offset + memState.limit, memState.total); + const hasPrev = memState.offset > 0; + const hasNext = memState.offset + memState.limit < memState.total; + el.innerHTML = ` + + ${start}–${end} of ${memState.total} + + + `; +} + +window.changeMemoryPage = (delta) => { + memState.offset = Math.max(0, memState.offset + delta * memState.limit); + loadMemory(); +}; +window.setMemoryLimit = (val) => { + memState.limit = parseInt(val, 10); + memState.offset = 0; + loadMemory(); +}; +window.onMemorySort = (col) => { + if (memState.sortBy === col) { + memState.sortOrder = memState.sortOrder === 'asc' ? 'desc' : 'asc'; + } else { + memState.sortBy = col; + memState.sortOrder = 'desc'; + } + memState.offset = 0; + loadMemory(); +}; + +$('#mem-search').addEventListener('input', debounce(() => { + memState.search = $('#mem-search').value.trim(); + memState.offset = 0; + loadMemory(); +}, 300)); // ── Profiles ────────────────────────────────────────────────────────────── async function loadProfiles() { diff --git a/navi/api/routes/admin.py b/navi/api/routes/admin.py index 4fa7b85..059ae62 100644 --- a/navi/api/routes/admin.py +++ b/navi/api/routes/admin.py @@ -178,13 +178,26 @@ async def admin_list_memory( store: Annotated[SessionStore, Depends(get_session_store)], user: Annotated[User, Depends(require_permission("navi.memory.read_all"))], + limit: int = 50, + offset: int = 0, + search: str | None = None, + sort_by: str = "updated_at", + sort_order: str = "desc", ): - """Return all memory facts (global view).""" + """Return memory facts with pagination, search and sorting.""" from navi.api.deps import get_memory_store memory = get_memory_store() - facts = await memory.get_all_facts(limit=500) - return {"facts": facts, "count": len(facts)} + facts = await memory.get_all_facts( + limit=limit, + offset=offset, + search=search or None, + sort_by=sort_by, + sort_order=sort_order, + all_users=True, + ) + total = await memory.fact_count(all_users=True, search=search or None) + return {"total": total, "limit": limit, "offset": offset, "items": facts} @router.patch("/users/{user_id}/role") diff --git a/navi/memory/_facts.py b/navi/memory/_facts.py index b75bea8..2e71e6c 100644 --- a/navi/memory/_facts.py +++ b/navi/memory/_facts.py @@ -210,32 +210,88 @@ ) return int(result.split()[1]) - async def get_all_facts(self, user_id: str | None = None, limit: int | None = None) -> list[dict]: + async def get_all_facts( + self, + user_id: str | None = None, + limit: int | None = None, + offset: int = 0, + search: str | None = None, + sort_by: str = "category", + sort_order: str = "desc", + all_users: bool = False, + ) -> list[dict]: q = ( "SELECT id, category, key, value, updated_at, source, confidence, " "expires_at, source_context FROM memory_facts " ) params: list = [] - if user_id is None: - q += "WHERE user_id IS NULL " + param_idx = 0 + + def add_param(value): + nonlocal param_idx + param_idx += 1 + params.append(value) + return f"${param_idx}" + + conditions = [] + if not all_users: + if user_id is None: + conditions.append("user_id IS NULL") + else: + conditions.append(f"user_id = {add_param(user_id)}") + if search: + like = f"%{search}%" + conditions.append( + f"(key ILIKE {add_param(like)} OR value ILIKE {add_param(like)} OR category ILIKE {add_param(like)})" + ) + + if conditions: + q += "WHERE " + " AND ".join(conditions) + " " + + allowed_cols = {"category", "key", "updated_at", "confidence", "source"} + col = sort_by if sort_by in allowed_cols else "category" + order = "DESC" if sort_order == "desc" else "ASC" + if col == "category": + q += f"ORDER BY {col} {order}, updated_at DESC" else: - q += "WHERE user_id = $1 " - params.append(user_id) - q += "ORDER BY category, updated_at DESC" - if limit: - q += f" LIMIT ${len(params) + 1}" - params.append(limit) + q += f"ORDER BY {col} {order}" + + if limit is not None: + q += f" LIMIT {add_param(limit)}" + if offset: + q += f" OFFSET {add_param(offset)}" + pool = await self._get_pool() async with pool.acquire() as conn: rows = await conn.fetch(q, *params) return [_row_to_dict(r) for r in rows] - async def fact_count(self, user_id: str | None = None) -> int: + async def fact_count( + self, + user_id: str | None = None, + all_users: bool = False, + search: str | None = None, + ) -> int: + q = "SELECT COUNT(*) FROM memory_facts" + params: list = [] + conditions = [] + if not all_users: + if user_id is None: + conditions.append("user_id IS NULL") + else: + conditions.append(f"user_id = ${len(params) + 1}") + params.append(user_id) + if search: + like = f"%{search}%" + conditions.append( + f"(key ILIKE ${len(params) + 1} OR value ILIKE ${len(params) + 2} OR category ILIKE ${len(params) + 3})" + ) + params.extend([like, like, like]) + if conditions: + q += " WHERE " + " AND ".join(conditions) pool = await self._get_pool() async with pool.acquire() as conn: - if user_id is None: - return await conn.fetchval("SELECT COUNT(*) FROM memory_facts WHERE user_id IS NULL") or 0 - return await conn.fetchval("SELECT COUNT(*) FROM memory_facts WHERE user_id = $1", user_id) or 0 + return await conn.fetchval(q, *params) or 0 def _row_to_dict(row: asyncpg.Record) -> dict: diff --git a/tests/unit/memory/test_store.py b/tests/unit/memory/test_store.py index d01c4d1..3982f1b 100644 --- a/tests/unit/memory/test_store.py +++ b/tests/unit/memory/test_store.py @@ -128,6 +128,17 @@ await store.get_all_facts(limit=5) assert "LIMIT $1" in conn.calls[0][1] + async def test_all_users(self): + conn = FakeConnection() + conn.enqueue([FakeRecord(id="1", category="profile", key="name", value="Eugene", + updated_at=None, source="conversation", confidence=90, + expires_at=None, source_context="")]) + store = make_store_with_pool(conn) + results = await store.get_all_facts(all_users=True) + assert len(results) == 1 + # Should not filter by user_id at all + assert "user_id" not in conn.calls[0][1] + class TestFactCount: async def test_returns_count(self): @@ -136,6 +147,13 @@ store = make_store_with_pool(conn) assert await store.fact_count() == 42 + async def test_all_users(self): + conn = FakeConnection() + conn.enqueue(100) + store = make_store_with_pool(conn) + assert await store.fact_count(all_users=True) == 100 + assert "WHERE" not in conn.calls[0][1] + class TestSummary: async def test_get_summary(self):