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:
parent
9edb381c85
commit
f3e61d8c77
4 changed files with 245 additions and 17 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
· Updated: <span x-text="new Date(mem.updated_at).toLocaleString()"></span>
|
||||
· 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>
|
||||
|
|
|
|||
68
src/claude_memory/ui/static/js/tags.js
Normal file
68
src/claude_memory/ui/static/js/tags.js
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue