From f3e61d8c7703cd2ef71386fd4d181e765b715a4f Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Mon, 23 Mar 2026 00:29:02 +0200 Subject: [PATCH] feat: add Tags browser tab with tag cloud and filtered memory view Add GET /api/tags endpoint returning distinct tags with counts, add ?tag= filter to GET /api/memories, and a new Tags tab in the Neural Archive UI with searchable tag cloud and expandable memory cards. --- src/claude_memory/api/app.py | 61 ++++++++++++----- src/claude_memory/ui/static/css/app.css | 46 +++++++++++++ src/claude_memory/ui/static/index.html | 87 +++++++++++++++++++++++++ src/claude_memory/ui/static/js/tags.js | 68 +++++++++++++++++++ 4 files changed, 245 insertions(+), 17 deletions(-) create mode 100644 src/claude_memory/ui/static/js/tags.js diff --git a/src/claude_memory/api/app.py b/src/claude_memory/api/app.py index 86d8c8b..595fd24 100644 --- a/src/claude_memory/api/app.py +++ b/src/claude_memory/api/app.py @@ -335,30 +335,39 @@ async def recall_memories(body: MemoryRecall, user: AuthUser = Depends(get_curre @app.get("/api/memories") async def list_memories( category: Optional[str] = None, + tag: Optional[str] = None, limit: int = 50, offset: int = 0, user: AuthUser = Depends(get_current_user), ) -> dict[str, Any]: pool = await get_pool() + # Build WHERE clauses dynamically + where_clauses = ["user_id = $1", "deleted_at IS NULL"] + count_params: list[Any] = [user.user_id] + param_idx = 2 + if category: - count_query = "SELECT COUNT(*) FROM memories WHERE user_id = $1 AND deleted_at IS NULL AND category = $2" - count_params: list[Any] = [user.user_id, category] - query = """ - SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at - FROM memories WHERE user_id = $1 AND deleted_at IS NULL AND category = $2 - ORDER BY importance DESC LIMIT $3 OFFSET $4 - """ - params: list[Any] = [user.user_id, category, limit, offset] - else: - count_query = "SELECT COUNT(*) FROM memories WHERE user_id = $1 AND deleted_at IS NULL" - count_params = [user.user_id] - query = """ - SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at - FROM memories WHERE user_id = $1 AND deleted_at IS NULL - ORDER BY importance DESC LIMIT $2 OFFSET $3 - """ - params = [user.user_id, limit, offset] + where_clauses.append(f"category = ${param_idx}") + count_params.append(category) + param_idx += 1 + + if tag: + where_clauses.append( + f"${param_idx} = ANY(SELECT trim(t) FROM unnest(string_to_array(tags, ',')) AS t)" + ) + count_params.append(tag) + param_idx += 1 + + where = " AND ".join(where_clauses) + count_query = f"SELECT COUNT(*) FROM memories WHERE {where}" + + params: list[Any] = [*count_params, limit, offset] + query = f""" + SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at + FROM memories WHERE {where} + ORDER BY importance DESC LIMIT ${param_idx} OFFSET ${param_idx + 1} + """ async with pool.acquire() as conn: total = await conn.fetchval(count_query, *count_params) @@ -397,6 +406,24 @@ async def list_categories(user: AuthUser = Depends(get_current_user)) -> dict[st return {"categories": [r["category"] for r in rows]} +@app.get("/api/tags") +async def list_tags(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]: + """Return all distinct tags with memory counts for the current user.""" + pool = await get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT trim(t) as tag, COUNT(*) as count + FROM memories, unnest(string_to_array(tags, ',')) AS t + WHERE user_id = $1 AND deleted_at IS NULL AND tags != '' AND tags IS NOT NULL + GROUP BY trim(t) + ORDER BY count DESC + """, + user.user_id, + ) + return {"tags": [{"tag": r["tag"], "count": r["count"]} for r in rows]} + + @app.get("/api/stats") async def get_stats(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]: """Aggregated stats for the dashboard.""" diff --git a/src/claude_memory/ui/static/css/app.css b/src/claude_memory/ui/static/css/app.css index 6efd8b6..907095c 100644 --- a/src/claude_memory/ui/static/css/app.css +++ b/src/claude_memory/ui/static/css/app.css @@ -428,3 +428,49 @@ input[type="checkbox"] { select.input-field { cursor: pointer; } + +/* Tag cloud */ +.tag-cloud { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.tag-cloud-pill { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border-radius: 6px; + font-family: 'JetBrains Mono', monospace; + background: var(--bg-secondary); + color: var(--text-secondary); + border: 1px solid var(--border); + cursor: pointer; + transition: all 0.2s; +} + +.tag-cloud-pill:hover { + border-color: var(--border-accent); + color: var(--accent); + box-shadow: 0 2px 12px var(--accent-glow); +} + +.tag-cloud-pill-active { + background: var(--accent); + color: var(--bg-primary); + border-color: var(--accent); +} + +.tag-cloud-pill-active:hover { + color: var(--bg-primary); +} + +.tag-cloud-count { + font-size: 0.65em; + opacity: 0.7; + padding: 0.1rem 0.35rem; + border-radius: 9999px; + background: rgba(0, 0, 0, 0.15); +} diff --git a/src/claude_memory/ui/static/index.html b/src/claude_memory/ui/static/index.html index 8384d98..9d99b8c 100644 --- a/src/claude_memory/ui/static/index.html +++ b/src/claude_memory/ui/static/index.html @@ -29,6 +29,7 @@ + @@ -91,6 +92,11 @@ :class="{ active: $store.app.activeTab === 'graph' }" @click="$store.app.switchTab('graph')" >Graph +