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:
parent
0e75b3f9e3
commit
78e737146e
9 changed files with 1468 additions and 6 deletions
|
|
@ -2,12 +2,14 @@
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import pathlib
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, AsyncGenerator, Optional
|
from typing import Any, AsyncGenerator, Optional
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
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.fastmcp import FastMCP
|
||||||
from mcp.server.sse import SseServerTransport
|
from mcp.server.sse import SseServerTransport
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
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)
|
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:
|
def _detect_sensitive(content: str) -> bool:
|
||||||
"""Check if content contains credentials using the credential detector."""
|
"""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(
|
async def list_memories(
|
||||||
category: Optional[str] = None,
|
category: Optional[str] = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
user: AuthUser = Depends(get_current_user),
|
user: AuthUser = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
|
|
||||||
if category:
|
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 = """
|
query = """
|
||||||
SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at
|
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
|
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:
|
else:
|
||||||
|
count_query = "SELECT COUNT(*) FROM memories WHERE user_id = $1 AND deleted_at IS NULL"
|
||||||
|
count_params = [user.user_id]
|
||||||
query = """
|
query = """
|
||||||
SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at
|
SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at
|
||||||
FROM memories WHERE user_id = $1 AND deleted_at IS NULL
|
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:
|
async with pool.acquire() as conn:
|
||||||
|
total = await conn.fetchval(count_query, *count_params)
|
||||||
rows = await conn.fetch(query, *params)
|
rows = await conn.fetch(query, *params)
|
||||||
|
|
||||||
results = []
|
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}")
|
@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/
|
# Client connects to /mcp/sse, posts to /mcp/messages/
|
||||||
app.router.routes.insert(0, Mount("/mcp", routes=[
|
app.router.routes.insert(0, Mount("/mcp", routes=[
|
||||||
Route("/sse", endpoint=HandleSSE()),
|
Route("/sse", endpoint=HandleSSE()),
|
||||||
|
|
|
||||||
302
src/claude_memory/ui/static/css/app.css
Normal file
302
src/claude_memory/ui/static/css/app.css
Normal 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;
|
||||||
|
}
|
||||||
424
src/claude_memory/ui/static/index.html
Normal file
424
src/claude_memory/ui/static/index.html
Normal 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>
|
||||||
|
· Updated: <span x-text="new Date(mem.updated_at).toLocaleString()"></span>
|
||||||
|
· 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>
|
||||||
|
· 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>
|
||||||
|
· Updated: <span x-text="new Date(mem.updated_at).toLocaleString()"></span>
|
||||||
|
<template x-if="mem.share_permission">
|
||||||
|
<span> · 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>
|
||||||
84
src/claude_memory/ui/static/js/api.js
Normal file
84
src/claude_memory/ui/static/js/api.js
Normal 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'));
|
||||||
|
},
|
||||||
|
};
|
||||||
41
src/claude_memory/ui/static/js/app.js
Normal file
41
src/claude_memory/ui/static/js/app.js
Normal 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 = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
137
src/claude_memory/ui/static/js/dashboard.js
Normal file
137
src/claude_memory/ui/static/js/dashboard.js
Normal 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;
|
||||||
|
}
|
||||||
161
src/claude_memory/ui/static/js/graph.js
Normal file
161
src/claude_memory/ui/static/js/graph.js
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
145
src/claude_memory/ui/static/js/memories.js
Normal file
145
src/claude_memory/ui/static/js/memories.js
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
67
src/claude_memory/ui/static/js/search.js
Normal file
67
src/claude_memory/ui/static/js/search.js
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue