diff --git a/src/claude_memory/api/app.py b/src/claude_memory/api/app.py index cdebb1f..954c3d3 100644 --- a/src/claude_memory/api/app.py +++ b/src/claude_memory/api/app.py @@ -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()), diff --git a/src/claude_memory/ui/static/css/app.css b/src/claude_memory/ui/static/css/app.css new file mode 100644 index 0000000..538ed93 --- /dev/null +++ b/src/claude_memory/ui/static/css/app.css @@ -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; +} diff --git a/src/claude_memory/ui/static/index.html b/src/claude_memory/ui/static/index.html new file mode 100644 index 0000000..4aac0b4 --- /dev/null +++ b/src/claude_memory/ui/static/index.html @@ -0,0 +1,424 @@ + + + + + + Claude Memory + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/claude_memory/ui/static/js/api.js b/src/claude_memory/ui/static/js/api.js new file mode 100644 index 0000000..5b8b4ab --- /dev/null +++ b/src/claude_memory/ui/static/js/api.js @@ -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')); + }, +}; diff --git a/src/claude_memory/ui/static/js/app.js b/src/claude_memory/ui/static/js/app.js new file mode 100644 index 0000000..194ea66 --- /dev/null +++ b/src/claude_memory/ui/static/js/app.js @@ -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 = ''; + }); +}); diff --git a/src/claude_memory/ui/static/js/dashboard.js b/src/claude_memory/ui/static/js/dashboard.js new file mode 100644 index 0000000..7e15a0d --- /dev/null +++ b/src/claude_memory/ui/static/js/dashboard.js @@ -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; +} diff --git a/src/claude_memory/ui/static/js/graph.js b/src/claude_memory/ui/static/js/graph.js new file mode 100644 index 0000000..97fa2c6 --- /dev/null +++ b/src/claude_memory/ui/static/js/graph.js @@ -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; + }, + }; +} diff --git a/src/claude_memory/ui/static/js/memories.js b/src/claude_memory/ui/static/js/memories.js new file mode 100644 index 0000000..5c37495 --- /dev/null +++ b/src/claude_memory/ui/static/js/memories.js @@ -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(); + }, + }; +} diff --git a/src/claude_memory/ui/static/js/search.js b/src/claude_memory/ui/static/js/search.js new file mode 100644 index 0000000..8d57362 --- /dev/null +++ b/src/claude_memory/ui/static/js/search.js @@ -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(); + }, + }; +}