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.
This commit is contained in:
Viktor Barzin 2026-03-23 00:29:02 +02:00
parent 9edb381c85
commit f3e61d8c77
No known key found for this signature in database
GPG key ID: 0EB088298288D958
4 changed files with 245 additions and 17 deletions

View file

@ -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."""

View file

@ -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);
}

View file

@ -29,6 +29,7 @@
<script src="/static/js/search.js"></script>
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/graph.js"></script>
<script src="/static/js/tags.js"></script>
</head>
<body class="min-h-screen">
@ -91,6 +92,11 @@
:class="{ active: $store.app.activeTab === 'graph' }"
@click="$store.app.switchTab('graph')"
>Graph</button>
<button
class="tab-btn"
:class="{ active: $store.app.activeTab === 'tags' }"
@click="$store.app.switchTab('tags')"
>Tags</button>
<button
class="tab-btn"
:class="{ active: $store.app.activeTab === 'dashboard' }"
@ -507,6 +513,87 @@
</div>
</div>
<!-- Tab: Tags -->
<div x-show="$store.app.activeTab === 'tags'" x-data="tagsComponent()" x-init="$watch('$store.app.activeTab', v => { if (v === 'tags' && tags.length === 0) init(); })">
<div class="flex gap-3 mb-4">
<input
type="text"
class="input-field"
style="max-width: 300px"
placeholder="Filter tags..."
x-model="searchQuery"
@input="filterTags()"
>
<span x-show="loading" class="spinner"></span>
</div>
<!-- Tag Cloud -->
<div class="tag-cloud mb-6">
<template x-for="t in filteredTags" :key="t.tag">
<button
class="tag-cloud-pill"
:class="{ 'tag-cloud-pill-active': selectedTag === t.tag }"
:style="`font-size: ${Math.max(0.75, Math.min(1.4, 0.75 + t.count * 0.05))}rem`"
@click="selectTag(t.tag)"
>
<span x-text="t.tag"></span>
<span class="tag-cloud-count" x-text="t.count"></span>
</button>
</template>
</div>
<p x-show="!loading && filteredTags.length === 0 && searchQuery" class="text-sm" style="color: var(--text-muted)">No tags match your filter</p>
<!-- Selected tag memories -->
<template x-if="selectedTag">
<div>
<div class="section-title">
Memories tagged "<span x-text="selectedTag"></span>"
<button class="btn btn-ghost text-xs ml-2" @click="selectedTag = null; memories = []">Clear</button>
</div>
<span x-show="memoriesLoading" class="spinner"></span>
<div class="space-y-2">
<template x-for="mem in memories" :key="mem.id">
<div class="memory-card" @click="toggle(mem.id)">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs" style="color: var(--text-muted); font-family: 'JetBrains Mono', monospace">#<span x-text="mem.id"></span></span>
<span class="badge badge-category" x-text="mem.category"></span>
<template x-if="mem.is_sensitive">
<span class="badge badge-sensitive">SENSITIVE</span>
</template>
</div>
<p class="text-sm" x-show="expandedId !== mem.id" x-text="preview(mem.content)"></p>
</div>
<div class="flex flex-col items-end gap-1 shrink-0">
<span class="text-xs" style="color: var(--text-muted)" x-text="relativeTime(mem.updated_at)"></span>
<div class="importance-bar w-16">
<div class="importance-fill" :style="`width: ${mem.importance * 100}%`"></div>
</div>
</div>
</div>
<div class="mt-1" x-show="mem.tags">
<template x-for="tag in (mem.tags || '').split(',').map(t => t.trim()).filter(Boolean)" :key="tag">
<span class="tag-pill" x-text="tag"></span>
</template>
</div>
<!-- Expanded -->
<div x-show="expandedId === mem.id" @click.stop class="mt-3 pt-3" style="border-top: 1px solid var(--border)">
<p class="text-sm whitespace-pre-wrap mb-2" x-text="mem.content"></p>
<div class="text-xs" style="color: var(--text-muted); font-family: 'JetBrains Mono', monospace">
Created: <span x-text="new Date(mem.created_at).toLocaleString()"></span>
&middot; Updated: <span x-text="new Date(mem.updated_at).toLocaleString()"></span>
&middot; Importance: <span x-text="mem.importance"></span>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
<!-- Tab: Dashboard -->
<div x-show="$store.app.activeTab === 'dashboard'" x-data="dashboardComponent()" x-init="$watch('$store.app.activeTab', v => { if (v === 'dashboard' && !stats) init(); })">
<span x-show="loading" class="spinner"></span>

View file

@ -0,0 +1,68 @@
// Tags browser component
function tagsComponent() {
return {
tags: [],
filteredTags: [],
searchQuery: '',
selectedTag: null,
memories: [],
loading: false,
memoriesLoading: false,
expandedId: null,
async init() {
this.loading = true;
try {
const data = await api.get('/api/tags');
this.tags = data.tags;
this.filteredTags = data.tags;
} catch (e) { console.error('Failed to load tags:', e); }
this.loading = false;
},
filterTags() {
const q = this.searchQuery.toLowerCase();
if (!q) {
this.filteredTags = this.tags;
} else {
this.filteredTags = this.tags.filter(t => t.tag.toLowerCase().includes(q));
}
},
async selectTag(tag) {
if (this.selectedTag === tag) {
this.selectedTag = null;
this.memories = [];
return;
}
this.selectedTag = tag;
this.memoriesLoading = true;
try {
const data = await api.get(`/api/memories?tag=${encodeURIComponent(tag)}&limit=100`);
this.memories = data.memories;
} catch (e) { console.error('Failed to load memories for tag:', e); }
this.memoriesLoading = false;
},
toggle(id) {
this.expandedId = this.expandedId === id ? null : id;
},
preview(content, len = 100) {
if (!content) return '';
return content.length > len ? content.substring(0, len) + '...' : content;
},
relativeTime(iso) {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
return new Date(iso).toLocaleDateString();
},
};
}