add web management UI for browsing, searching, and visualizing memories

Alpine.js + Tailwind CSS (CDN) served by FastAPI StaticFiles — no build step,
zero Docker changes. Features: memory browser with inline edit/delete, full-text
search with debounce, D3.js force-directed graph, Chart.js stats dashboard.
New endpoints: GET /api/stats, GET /api/categories, pagination on GET /api/memories.
This commit is contained in:
Viktor Barzin 2026-03-22 21:10:53 +02:00
parent 0e75b3f9e3
commit 78e737146e
No known key found for this signature in database
GPG key ID: 0EB088298288D958
9 changed files with 1468 additions and 6 deletions

View file

@ -2,12 +2,14 @@
import json
import logging
import pathlib
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any, AsyncGenerator, Optional
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import Response
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from mcp.server.fastmcp import FastMCP
from mcp.server.sse import SseServerTransport
from starlette.middleware.base import BaseHTTPMiddleware
@ -39,6 +41,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app = FastAPI(title="Claude Memory API", lifespan=lifespan)
UI_DIR = pathlib.Path(__file__).parent.parent / "ui" / "static"
@app.get("/")
async def ui_root():
"""Serve the UI single-page app."""
return FileResponse(UI_DIR / "index.html")
def _detect_sensitive(content: str) -> bool:
"""Check if content contains credentials using the credential detector."""
@ -315,26 +325,32 @@ async def recall_memories(body: MemoryRecall, user: AuthUser = Depends(get_curre
async def list_memories(
category: Optional[str] = None,
limit: int = 50,
offset: int = 0,
user: AuthUser = Depends(get_current_user),
) -> dict[str, Any]:
pool = await get_pool()
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
ORDER BY importance DESC LIMIT $3 OFFSET $4
"""
params: list[Any] = [user.user_id, category, limit]
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
ORDER BY importance DESC LIMIT $2 OFFSET $3
"""
params = [user.user_id, limit]
params = [user.user_id, limit, offset]
async with pool.acquire() as conn:
total = await conn.fetchval(count_query, *count_params)
rows = await conn.fetch(query, *params)
results = []
@ -355,7 +371,89 @@ async def list_memories(
}
)
return {"memories": results}
return {"memories": results, "total": total}
@app.get("/api/categories")
async def list_categories(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]:
"""Return distinct category values for the current user."""
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT DISTINCT category FROM memories WHERE user_id = $1 AND deleted_at IS NULL ORDER BY category",
user.user_id,
)
return {"categories": [r["category"] 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."""
pool = await get_pool()
async with pool.acquire() as conn:
total = await conn.fetchval(
"SELECT COUNT(*) FROM memories WHERE user_id = $1 AND deleted_at IS NULL",
user.user_id,
)
cat_rows = await conn.fetch(
"SELECT category, COUNT(*) AS cnt FROM memories WHERE user_id = $1 AND deleted_at IS NULL GROUP BY category ORDER BY cnt DESC",
user.user_id,
)
by_category = {r["category"]: r["cnt"] for r in cat_rows}
imp_rows = await conn.fetch(
"""
SELECT
CASE
WHEN importance < 0.2 THEN '0.0-0.2'
WHEN importance < 0.4 THEN '0.2-0.4'
WHEN importance < 0.6 THEN '0.4-0.6'
WHEN importance < 0.8 THEN '0.6-0.8'
ELSE '0.8-1.0'
END AS bucket,
COUNT(*) AS cnt
FROM memories WHERE user_id = $1 AND deleted_at IS NULL
GROUP BY bucket ORDER BY bucket
""",
user.user_id,
)
by_importance = {r["bucket"]: r["cnt"] for r in imp_rows}
activity_rows = await conn.fetch(
"""
SELECT d::date AS date,
COUNT(*) FILTER (WHERE created_at::date = d::date) AS created,
COUNT(*) FILTER (WHERE updated_at::date = d::date AND updated_at > created_at + interval '1 second') AS updated
FROM memories,
generate_series(CURRENT_DATE - interval '29 days', CURRENT_DATE, '1 day') AS d
WHERE user_id = $1 AND deleted_at IS NULL
AND (created_at::date = d::date OR (updated_at::date = d::date AND updated_at > created_at + interval '1 second'))
GROUP BY d::date ORDER BY d::date
""",
user.user_id,
)
recent_activity = [
{"date": r["date"].isoformat(), "created": r["created"], "updated": r["updated"]}
for r in activity_rows
]
shared_by_me = await conn.fetchval(
"SELECT COUNT(*) FROM memory_shares WHERE owner_id = $1",
user.user_id,
)
shared_with_me = await conn.fetchval(
"SELECT COUNT(*) FROM memory_shares WHERE shared_with = $1",
user.user_id,
)
return {
"total_memories": total,
"by_category": by_category,
"by_importance": by_importance,
"recent_activity": recent_activity,
"sharing_stats": {"shared_by_me": shared_by_me, "shared_with_me": shared_with_me},
}
@app.delete("/api/memories/{memory_id}")
@ -1033,6 +1131,9 @@ class HandleSSE:
)
# Static files for UI (before MCP mount)
app.mount("/static", StaticFiles(directory=UI_DIR), name="static")
# Client connects to /mcp/sse, posts to /mcp/messages/
app.router.routes.insert(0, Mount("/mcp", routes=[
Route("/sse", endpoint=HandleSSE()),

View file

@ -0,0 +1,302 @@
/* Dark theme base */
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--accent: #6366f1;
--accent-hover: #818cf8;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--border: #334155;
}
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg-primary); }
::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 3px; }
/* Login modal */
.login-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.login-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 400px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
/* Tab navigation */
.tab-btn {
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.15s;
cursor: pointer;
border: none;
background: transparent;
color: var(--text-secondary);
}
.tab-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.tab-btn.active {
background: var(--accent);
color: white;
}
/* Cards */
.memory-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
transition: border-color 0.15s;
cursor: pointer;
}
.memory-card:hover {
border-color: var(--accent);
}
/* Category badge */
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-category {
background: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
}
.badge-sensitive {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
.badge-read {
background: rgba(148, 163, 184, 0.2);
color: #94a3b8;
}
.badge-write {
background: rgba(34, 197, 94, 0.2);
color: #86efac;
}
/* Tag pills */
.tag-pill {
display: inline-block;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.7rem;
background: var(--bg-tertiary);
color: var(--text-secondary);
margin-right: 0.25rem;
margin-bottom: 0.25rem;
}
/* Importance bar */
.importance-bar {
height: 4px;
border-radius: 2px;
background: var(--bg-tertiary);
overflow: hidden;
}
.importance-fill {
height: 100%;
border-radius: 2px;
background: var(--accent);
transition: width 0.3s;
}
/* Form inputs */
.input-field {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem 0.75rem;
color: var(--text-primary);
font-size: 0.875rem;
width: 100%;
outline: none;
transition: border-color 0.15s;
}
.input-field:focus {
border-color: var(--accent);
}
textarea.input-field {
resize: vertical;
min-height: 80px;
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.15s;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-ghost:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
/* Stats card */
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.25rem;
}
.stat-card .stat-value {
font-size: 1.75rem;
font-weight: 700;
color: var(--accent);
}
.stat-card .stat-label {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
/* Chart container */
.chart-container {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
position: relative;
}
/* Graph */
.graph-container {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
min-height: 500px;
}
.graph-container svg {
display: block;
}
/* Node detail panel */
.node-detail {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
/* Shared memory indicator */
.shared-indicator {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
/* Loading spinner */
.spinner {
border: 2px solid var(--bg-tertiary);
border-top-color: var(--accent);
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 0.6s linear infinite;
display: inline-block;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Range slider */
input[type="range"] {
-webkit-appearance: none;
background: var(--bg-tertiary);
height: 4px;
border-radius: 2px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
}
/* Section header */
.section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: 0.75rem;
}

View file

@ -0,0 +1,424 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Memory</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<link rel="stylesheet" href="/static/css/app.css">
<script src="/static/js/api.js"></script>
<script src="/static/js/app.js"></script>
<script src="/static/js/memories.js"></script>
<script src="/static/js/search.js"></script>
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/graph.js"></script>
</head>
<body class="min-h-screen">
<!-- Login Modal -->
<template x-if="!$store.app.authenticated" x-data>
<div class="login-backdrop">
<div class="login-card">
<h2 class="text-xl font-bold mb-1">Claude Memory</h2>
<p class="text-sm text-slate-400 mb-6">Enter your API key to continue</p>
<form @submit.prevent="$store.app.login()">
<input
type="password"
class="input-field mb-3"
placeholder="API Key"
x-model="$store.app.loginKey"
autofocus
>
<template x-if="$store.app.loginError">
<p class="text-red-400 text-sm mb-3" x-text="$store.app.loginError"></p>
</template>
<button type="submit" class="btn btn-primary w-full" :disabled="$store.app.loginLoading">
<span x-show="!$store.app.loginLoading">Sign In</span>
<span x-show="$store.app.loginLoading" class="flex items-center justify-center gap-2">
<span class="spinner"></span> Authenticating...
</span>
</button>
</form>
</div>
</div>
</template>
<!-- Main App -->
<template x-if="$store.app.authenticated" x-data>
<div class="max-w-7xl mx-auto px-4 py-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<h1 class="text-xl font-bold">Claude Memory</h1>
<span class="text-sm text-slate-400">|</span>
<span class="text-sm text-slate-400" x-text="$store.app.userId"></span>
</div>
<button @click="$store.app.logout()" class="btn btn-ghost text-sm">Logout</button>
</div>
<!-- Tab Navigation -->
<div class="flex gap-1 mb-6 bg-slate-800/50 p-1 rounded-lg w-fit">
<button
class="tab-btn"
:class="{ active: $store.app.activeTab === 'memories' }"
@click="$store.app.switchTab('memories')"
>Memories</button>
<button
class="tab-btn"
:class="{ active: $store.app.activeTab === 'search' }"
@click="$store.app.switchTab('search')"
>Search</button>
<button
class="tab-btn"
:class="{ active: $store.app.activeTab === 'graph' }"
@click="$store.app.switchTab('graph')"
>Graph</button>
<button
class="tab-btn"
:class="{ active: $store.app.activeTab === 'dashboard' }"
@click="$store.app.switchTab('dashboard')"
>Dashboard</button>
</div>
<!-- Tab: Memories -->
<div x-show="$store.app.activeTab === 'memories'" x-data="memoriesBrowser()" x-init="init()">
<!-- Filters -->
<div class="flex items-center gap-3 mb-4">
<select class="input-field" style="width:auto" x-model="selectedCategory" @change="filterByCategory()">
<option value="">All categories</option>
<template x-for="cat in categories" :key="cat">
<option :value="cat" x-text="cat"></option>
</template>
</select>
<span class="text-sm text-slate-400">
<span x-text="memories.length"></span> / <span x-text="total"></span> memories
</span>
<span x-show="loading" class="spinner"></span>
</div>
<!-- My Memories -->
<div class="section-title">My Memories</div>
<div class="space-y-2 mb-6">
<template x-for="mem in memories" :key="mem.id">
<div class="memory-card" @click="toggle(mem.id)">
<!-- Collapsed -->
<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 text-slate-500">#<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 text-slate-500" 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>
<!-- Tags -->
<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 border-t border-slate-700">
<!-- View mode -->
<template x-if="editingId !== mem.id">
<div>
<p class="text-sm whitespace-pre-wrap mb-3" x-text="mem.content"></p>
<div class="text-xs text-slate-500 mb-3">
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 class="flex gap-2">
<button class="btn btn-ghost text-xs" @click.stop="startEdit(mem)" x-show="!mem.is_sensitive">Edit</button>
<button class="btn btn-ghost text-xs" @click.stop="toggleShares(mem.id)">Shares</button>
<button class="btn text-xs" style="color: var(--danger)" @click.stop="confirmDelete(mem.id)">Delete</button>
</div>
<!-- Delete confirmation -->
<div x-show="deleteConfirm === mem.id" class="mt-2 p-2 rounded bg-red-900/30 border border-red-800 text-sm">
Delete this memory?
<button class="btn btn-danger text-xs ml-2" @click.stop="doDelete(mem.id)">Confirm</button>
<button class="btn btn-ghost text-xs ml-1" @click.stop="deleteConfirm = null">Cancel</button>
</div>
<!-- Shares info -->
<div x-show="sharesExpanded === mem.id" class="mt-2">
<template x-if="sharesData.length === 0">
<p class="text-xs text-slate-500">Not shared with anyone</p>
</template>
<template x-for="s in sharesData" :key="s.shared_with">
<div class="text-xs text-slate-400">
Shared with <span class="text-slate-200" x-text="s.shared_with"></span>
<span class="badge" :class="s.permission === 'write' ? 'badge-write' : 'badge-read'" x-text="s.permission"></span>
</div>
</template>
</div>
</div>
</template>
<!-- Edit mode -->
<template x-if="editingId === mem.id">
<div>
<textarea class="input-field mb-2" x-model="editForm.content" rows="4"></textarea>
<div class="flex items-center gap-3 mb-2">
<label class="text-xs text-slate-400">Tags:</label>
<input type="text" class="input-field" x-model="editForm.tags" placeholder="tag1, tag2">
</div>
<div class="flex items-center gap-3 mb-3">
<label class="text-xs text-slate-400">Importance:</label>
<input type="range" min="0" max="1" step="0.1" x-model="editForm.importance" class="flex-1">
<span class="text-xs text-slate-400" x-text="editForm.importance"></span>
</div>
<div class="flex gap-2">
<button class="btn btn-primary text-xs" @click.stop="saveEdit(mem.id)">Save</button>
<button class="btn btn-ghost text-xs" @click.stop="cancelEdit()">Cancel</button>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
<!-- Load more -->
<div x-show="hasMore" class="text-center mb-8">
<button class="btn btn-ghost" @click="loadMore()" :disabled="loading">
<span x-show="!loading">Load More</span>
<span x-show="loading" class="spinner"></span>
</button>
</div>
<!-- Shared With Me -->
<template x-if="sharedMemories.length > 0">
<div>
<div class="section-title">Shared With Me</div>
<div class="space-y-2">
<template x-for="mem in sharedMemories" :key="mem.id">
<div class="memory-card" @click="toggle('s' + 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 text-slate-500">#<span x-text="mem.id"></span></span>
<span class="badge badge-category" x-text="mem.category"></span>
<span class="badge" :class="mem.permission === 'write' ? 'badge-write' : 'badge-read'" x-text="mem.permission"></span>
<span class="shared-indicator">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span x-text="mem.shared_by"></span>
</span>
</div>
<p class="text-sm" x-show="expandedId !== 's' + mem.id" x-text="preview(mem.content)"></p>
</div>
<div class="flex flex-col items-end gap-1 shrink-0">
<span class="text-xs text-slate-500" 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>
<div x-show="expandedId === 's' + mem.id" @click.stop class="mt-3 pt-3 border-t border-slate-700">
<p class="text-sm whitespace-pre-wrap mb-2" x-text="mem.content"></p>
<div class="text-xs text-slate-500">
Created: <span x-text="new Date(mem.created_at).toLocaleString()"></span>
&middot; Updated: <span x-text="new Date(mem.updated_at).toLocaleString()"></span>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
<!-- Tab: Search -->
<div x-show="$store.app.activeTab === 'search'" x-data="searchComponent()" x-init="init()">
<div class="flex flex-wrap gap-3 mb-4">
<input
type="text"
class="input-field flex-1"
style="min-width: 200px"
placeholder="Search memories..."
x-model="query"
@input="onInput()"
>
<select class="input-field" style="width:auto" x-model="category" @change="doSearch()">
<option value="">All categories</option>
<template x-for="cat in categories" :key="cat">
<option :value="cat" x-text="cat"></option>
</template>
</select>
<select class="input-field" style="width:auto" x-model="sortBy" @change="doSearch()">
<option value="importance">Importance</option>
<option value="relevance">Relevance</option>
<option value="recency">Recency</option>
</select>
</div>
<span x-show="loading" class="spinner"></span>
<div class="space-y-2">
<template x-for="mem in results" :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 text-slate-500">#<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>
<template x-if="mem.shared_by">
<span class="shared-indicator">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>
<span x-text="mem.shared_by"></span>
</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 text-slate-500" x-text="relativeTime(mem.updated_at)"></span>
<div class="importance-bar w-16">
<div class="importance-fill" :style="`width: ${mem.importance * 100}%`"></div>
</div>
<span class="text-xs text-indigo-400" x-text="'rank: ' + (mem.rank || 0).toFixed(2)"></span>
</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 border-t border-slate-700">
<p class="text-sm whitespace-pre-wrap mb-2" x-text="mem.content"></p>
<div class="text-xs text-slate-500">
Created: <span x-text="new Date(mem.created_at).toLocaleString()"></span>
&middot; Updated: <span x-text="new Date(mem.updated_at).toLocaleString()"></span>
<template x-if="mem.share_permission">
<span> &middot; Permission: <span class="badge" :class="mem.share_permission === 'write' ? 'badge-write' : 'badge-read'" x-text="mem.share_permission"></span></span>
</template>
</div>
</div>
</div>
</template>
</div>
<p x-show="query && !loading && results.length === 0" class="text-sm text-slate-500 mt-4">No results found</p>
</div>
<!-- Tab: Graph -->
<div x-show="$store.app.activeTab === 'graph'" x-data="graphComponent()" x-init="$watch('$store.app.activeTab', v => { if (v === 'graph' && memories.length === 0) init(); })">
<div class="flex flex-wrap gap-2 mb-4">
<template x-for="cat in categories" :key="cat">
<label class="flex items-center gap-1 text-sm cursor-pointer">
<input type="checkbox" :checked="selectedCategories[cat]" @change="toggleCategory(cat)">
<span x-text="cat"></span>
</label>
</template>
<span x-show="loading" class="spinner ml-2"></span>
</div>
<div class="flex gap-4">
<div class="flex-1 graph-container" x-ref="graphContainer"></div>
<template x-if="selectedNode">
<div class="node-detail w-80 shrink-0">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs text-slate-500">#<span x-text="selectedNode.id"></span></span>
<span class="badge badge-category" x-text="selectedNode.category"></span>
<template x-if="selectedNode.is_sensitive">
<span class="badge badge-sensitive">SENSITIVE</span>
</template>
</div>
<p class="text-sm whitespace-pre-wrap mb-3" x-text="preview(selectedNode.content)"></p>
<div class="mb-2" x-show="selectedNode.tags">
<template x-for="tag in (selectedNode.tags || '').split(',').map(t => t.trim()).filter(Boolean)" :key="tag">
<span class="tag-pill" x-text="tag"></span>
</template>
</div>
<div class="importance-bar w-full mb-2">
<div class="importance-fill" :style="`width: ${selectedNode.importance * 100}%`"></div>
</div>
<span class="text-xs text-slate-500">Importance: <span x-text="selectedNode.importance"></span></span>
<button class="btn btn-ghost text-xs mt-3 block" @click="selectedNode = null">Close</button>
</div>
</template>
</div>
</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>
<template x-if="stats">
<div>
<!-- Summary cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="stat-card">
<div class="stat-value" x-text="stats.total_memories"></div>
<div class="stat-label">Total Memories</div>
</div>
<div class="stat-card">
<div class="stat-value" x-text="Object.keys(stats.by_category).length"></div>
<div class="stat-label">Categories</div>
</div>
<div class="stat-card">
<div class="stat-value" x-text="stats.sharing_stats.shared_by_me"></div>
<div class="stat-label">Shared by Me</div>
</div>
<div class="stat-card">
<div class="stat-value" x-text="stats.sharing_stats.shared_with_me"></div>
<div class="stat-label">Shared with Me</div>
</div>
</div>
<!-- Charts -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="chart-container">
<div class="section-title">Categories</div>
<div style="height: 280px">
<canvas x-ref="categoryChart"></canvas>
</div>
</div>
<div class="chart-container">
<div class="section-title">Importance Distribution</div>
<div style="height: 280px">
<canvas x-ref="importanceChart"></canvas>
</div>
</div>
</div>
<div class="chart-container">
<div class="section-title">Activity (Last 30 Days)</div>
<div style="height: 250px">
<canvas x-ref="activityChart"></canvas>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
</body>
</html>

View file

@ -0,0 +1,84 @@
// API client with Bearer auth
const api = {
getToken() {
return sessionStorage.getItem('api_key');
},
getUserId() {
return sessionStorage.getItem('user_id');
},
setAuth(key, userId) {
sessionStorage.setItem('api_key', key);
sessionStorage.setItem('user_id', userId);
},
clearAuth() {
sessionStorage.removeItem('api_key');
sessionStorage.removeItem('user_id');
},
isAuthenticated() {
return !!this.getToken();
},
async fetch(url, options = {}) {
const token = this.getToken();
if (!token) {
window.dispatchEvent(new CustomEvent('auth:required'));
throw new Error('Not authenticated');
}
const headers = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...options.headers,
};
const res = await fetch(url, { ...options, headers });
if (res.status === 401) {
this.clearAuth();
window.dispatchEvent(new CustomEvent('auth:required'));
throw new Error('Unauthorized');
}
if (!res.ok) {
const body = await res.text();
throw new Error(`API error ${res.status}: ${body}`);
}
return res.json();
},
get(url) {
return this.fetch(url);
},
post(url, data) {
return this.fetch(url, { method: 'POST', body: JSON.stringify(data) });
},
put(url, data) {
return this.fetch(url, { method: 'PUT', body: JSON.stringify(data) });
},
del(url) {
return this.fetch(url, { method: 'DELETE' });
},
async login(key) {
const res = await fetch('/api/auth-check', {
headers: { 'Authorization': `Bearer ${key}` },
});
if (!res.ok) throw new Error('Invalid API key');
const data = await res.json();
this.setAuth(key, data.user_id);
return data;
},
logout() {
this.clearAuth();
window.dispatchEvent(new CustomEvent('auth:required'));
},
};

View file

@ -0,0 +1,41 @@
// Alpine.js global store and tab navigation
document.addEventListener('alpine:init', () => {
Alpine.store('app', {
activeTab: 'memories',
userId: api.getUserId() || '',
authenticated: api.isAuthenticated(),
loginKey: '',
loginError: '',
loginLoading: false,
async login() {
this.loginError = '';
this.loginLoading = true;
try {
const data = await api.login(this.loginKey);
this.userId = data.user_id;
this.authenticated = true;
this.loginKey = '';
} catch (e) {
this.loginError = e.message;
} finally {
this.loginLoading = false;
}
},
logout() {
api.logout();
this.authenticated = false;
this.userId = '';
},
switchTab(tab) {
this.activeTab = tab;
},
});
window.addEventListener('auth:required', () => {
Alpine.store('app').authenticated = false;
Alpine.store('app').userId = '';
});
});

View file

@ -0,0 +1,137 @@
// Dashboard stats component
function dashboardComponent() {
return {
stats: null,
loading: false,
charts: {},
async init() {
this.loading = true;
try {
this.stats = await api.get('/api/stats');
this.$nextTick(() => this.renderCharts());
} catch (e) {
console.error('Failed to load stats:', e);
}
this.loading = false;
},
renderCharts() {
if (!this.stats) return;
this.renderCategoryChart();
this.renderImportanceChart();
this.renderActivityChart();
},
renderCategoryChart() {
const ctx = this.$refs.categoryChart;
if (!ctx) return;
if (this.charts.category) this.charts.category.destroy();
const labels = Object.keys(this.stats.by_category);
const data = Object.values(this.stats.by_category);
const colors = generateColors(labels.length);
this.charts.category = new Chart(ctx, {
type: 'doughnut',
data: {
labels,
datasets: [{ data, backgroundColor: colors, borderColor: '#1e293b', borderWidth: 2 }],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { color: '#94a3b8', padding: 12 } },
},
},
});
},
renderImportanceChart() {
const ctx = this.$refs.importanceChart;
if (!ctx) return;
if (this.charts.importance) this.charts.importance.destroy();
const labels = ['0.0-0.2', '0.2-0.4', '0.4-0.6', '0.6-0.8', '0.8-1.0'];
const data = labels.map(l => this.stats.by_importance[l] || 0);
this.charts.importance = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [{
label: 'Memories',
data,
backgroundColor: '#6366f1',
borderRadius: 4,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: '#334155' } },
y: { ticks: { color: '#94a3b8' }, grid: { color: '#334155' }, beginAtZero: true },
},
plugins: { legend: { display: false } },
},
});
},
renderActivityChart() {
const ctx = this.$refs.activityChart;
if (!ctx) return;
if (this.charts.activity) this.charts.activity.destroy();
const activity = this.stats.recent_activity || [];
const labels = activity.map(a => a.date);
const created = activity.map(a => a.created);
const updated = activity.map(a => a.updated);
this.charts.activity = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
label: 'Created',
data: created,
borderColor: '#22c55e',
backgroundColor: 'rgba(34,197,94,0.1)',
fill: true,
tension: 0.3,
},
{
label: 'Updated',
data: updated,
borderColor: '#f59e0b',
backgroundColor: 'rgba(245,158,11,0.1)',
fill: true,
tension: 0.3,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { ticks: { color: '#94a3b8', maxTicksLimit: 10 }, grid: { color: '#334155' } },
y: { ticks: { color: '#94a3b8' }, grid: { color: '#334155' }, beginAtZero: true },
},
plugins: { legend: { labels: { color: '#94a3b8' } } },
},
});
},
};
}
function generateColors(count) {
const palette = [
'#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#06b6d4',
'#ec4899', '#8b5cf6', '#14b8a6', '#f97316', '#64748b',
];
const result = [];
for (let i = 0; i < count; i++) result.push(palette[i % palette.length]);
return result;
}

View file

@ -0,0 +1,161 @@
// D3.js force-directed graph visualization
function graphComponent() {
return {
memories: [],
categories: [],
selectedCategories: {},
loading: false,
selectedNode: null,
simulation: null,
async init() {
this.loading = true;
try {
const [memData, catData] = await Promise.all([
api.get('/api/memories?limit=200'),
api.get('/api/categories'),
]);
this.memories = memData.memories;
this.categories = catData.categories;
this.categories.forEach(c => this.selectedCategories[c] = true);
this.$nextTick(() => this.render());
} catch (e) {
console.error('Failed to load graph data:', e);
}
this.loading = false;
},
toggleCategory(cat) {
this.selectedCategories[cat] = !this.selectedCategories[cat];
this.render();
},
getFilteredMemories() {
return this.memories.filter(m => this.selectedCategories[m.category]);
},
render() {
const container = this.$refs.graphContainer;
if (!container) return;
// Clear previous
d3.select(container).selectAll('*').remove();
if (this.simulation) this.simulation.stop();
const memories = this.getFilteredMemories().slice(0, 300);
if (memories.length === 0) return;
const width = container.clientWidth;
const height = container.clientHeight || 500;
// Build nodes and tag-based edges
const nodes = memories.map(m => ({
id: m.id,
content: m.content,
category: m.category,
tags: m.tags,
importance: m.importance,
is_sensitive: m.is_sensitive,
}));
const tagMap = {};
nodes.forEach(n => {
if (!n.tags) return;
n.tags.split(',').map(t => t.trim()).filter(Boolean).forEach(tag => {
if (!tagMap[tag]) tagMap[tag] = [];
tagMap[tag].push(n.id);
});
});
const links = [];
const linkSet = new Set();
Object.values(tagMap).forEach(ids => {
for (let i = 0; i < ids.length && i < 5; i++) {
for (let j = i + 1; j < ids.length && j < 5; j++) {
const key = `${ids[i]}-${ids[j]}`;
if (!linkSet.has(key)) {
linkSet.add(key);
links.push({ source: ids[i], target: ids[j] });
}
}
}
});
const categoryColors = {};
const palette = ['#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#06b6d4', '#ec4899', '#8b5cf6', '#14b8a6', '#f97316', '#64748b'];
this.categories.forEach((c, i) => categoryColors[c] = palette[i % palette.length]);
const svg = d3.select(container)
.append('svg')
.attr('width', width)
.attr('height', height);
const g = svg.append('g');
// Zoom
const zoom = d3.zoom()
.scaleExtent([0.2, 5])
.on('zoom', (event) => g.attr('transform', event.transform));
svg.call(zoom);
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(80))
.force('charge', d3.forceManyBody().strength(-100))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collide', d3.forceCollide().radius(d => 8 + d.importance * 15));
this.simulation = simulation;
const link = g.append('g')
.selectAll('line')
.data(links)
.join('line')
.attr('stroke', '#334155')
.attr('stroke-opacity', 0.4)
.attr('stroke-width', 1);
const node = g.append('g')
.selectAll('circle')
.data(nodes)
.join('circle')
.attr('r', d => 5 + d.importance * 15)
.attr('fill', d => categoryColors[d.category] || '#64748b')
.attr('stroke', '#1e293b')
.attr('stroke-width', 1.5)
.attr('cursor', 'pointer')
.on('click', (event, d) => {
this.selectedNode = d;
})
.call(d3.drag()
.on('start', (event, d) => {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x; d.fy = d.y;
})
.on('drag', (event, d) => {
d.fx = event.x; d.fy = event.y;
})
.on('end', (event, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null; d.fy = null;
}));
node.append('title').text(d => d.content ? d.content.substring(0, 60) : '');
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
node
.attr('cx', d => d.x)
.attr('cy', d => d.y);
});
},
preview(content, len = 200) {
if (!content) return '';
return content.length > len ? content.substring(0, len) + '...' : content;
},
};
}

View file

@ -0,0 +1,145 @@
// Memory browser/editor component
function memoriesBrowser() {
return {
memories: [],
sharedMemories: [],
categories: [],
selectedCategory: '',
total: 0,
offset: 0,
limit: 50,
loading: false,
expandedId: null,
editingId: null,
editForm: { content: '', tags: '', importance: 0.5 },
sharesExpanded: null,
sharesData: [],
deleteConfirm: null,
async init() {
await Promise.all([this.loadMemories(), this.loadShared(), this.loadCategories()]);
},
async loadCategories() {
try {
const data = await api.get('/api/categories');
this.categories = data.categories;
} catch (e) { console.error('Failed to load categories:', e); }
},
async loadMemories() {
this.loading = true;
try {
let url = `/api/memories?limit=${this.limit}&offset=${this.offset}`;
if (this.selectedCategory) url += `&category=${encodeURIComponent(this.selectedCategory)}`;
const data = await api.get(url);
if (this.offset === 0) {
this.memories = data.memories;
} else {
this.memories = [...this.memories, ...data.memories];
}
this.total = data.total;
} catch (e) { console.error('Failed to load memories:', e); }
this.loading = false;
},
async loadShared() {
try {
const data = await api.get('/api/memories/shared-with-me');
this.sharedMemories = data.memories;
} catch (e) { console.error('Failed to load shared:', e); }
},
async filterByCategory() {
this.offset = 0;
await this.loadMemories();
},
async loadMore() {
this.offset += this.limit;
await this.loadMemories();
},
get hasMore() {
return this.memories.length < this.total;
},
toggle(id) {
this.expandedId = this.expandedId === id ? null : id;
this.editingId = null;
this.sharesExpanded = null;
},
startEdit(mem) {
this.editingId = mem.id;
this.editForm = {
content: mem.content,
tags: mem.tags || '',
importance: mem.importance,
};
},
cancelEdit() {
this.editingId = null;
},
async saveEdit(id) {
try {
await api.put(`/api/memories/${id}`, {
content: this.editForm.content,
tags: this.editForm.tags,
importance: parseFloat(this.editForm.importance),
});
this.editingId = null;
this.offset = 0;
await this.loadMemories();
} catch (e) {
alert('Save failed: ' + e.message);
}
},
async confirmDelete(id) {
this.deleteConfirm = id;
},
async doDelete(id) {
try {
await api.del(`/api/memories/${id}`);
this.deleteConfirm = null;
this.offset = 0;
await this.loadMemories();
} catch (e) {
alert('Delete failed: ' + e.message);
}
},
async toggleShares(memId) {
if (this.sharesExpanded === memId) {
this.sharesExpanded = null;
return;
}
try {
const data = await api.get('/api/memories/my-shares');
this.sharesData = data.memory_shares.filter(s => s.memory_id === memId);
this.sharesExpanded = memId;
} catch (e) { console.error('Failed to load shares:', e); }
},
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();
},
};
}

View file

@ -0,0 +1,67 @@
// Search interface component
function searchComponent() {
return {
query: '',
category: '',
sortBy: 'importance',
results: [],
categories: [],
loading: false,
expandedId: null,
debounceTimer: null,
async init() {
try {
const data = await api.get('/api/categories');
this.categories = data.categories;
} catch (e) { console.error(e); }
},
onInput() {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => this.doSearch(), 300);
},
async doSearch() {
if (!this.query.trim()) {
this.results = [];
return;
}
this.loading = true;
try {
const body = {
context: this.query,
sort_by: this.sortBy,
limit: 20,
};
if (this.category) body.category = this.category;
const data = await api.post('/api/memories/recall', body);
this.results = data.memories;
} catch (e) {
console.error('Search failed:', e);
}
this.loading = false;
},
toggle(id) {
this.expandedId = this.expandedId === id ? null : id;
},
preview(content, len = 120) {
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();
},
};
}