|
@@ -0,0 +1,703 @@
|
|
|
|
|
+<!DOCTYPE html>
|
|
|
|
|
+<html lang="en">
|
|
|
|
|
+<head>
|
|
|
|
|
+<meta charset="UTF-8">
|
|
|
|
|
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
+<title>mem0 dashboard</title>
|
|
|
|
|
+<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
|
|
|
+<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500&family=IBM+Plex+Sans:wght@300;400;500&display=swap" rel="stylesheet">
|
|
|
|
|
+<style>
|
|
|
|
|
+ :root {
|
|
|
|
|
+ --bg: #0a0a0a;
|
|
|
|
|
+ --surface: #111111;
|
|
|
|
|
+ --surface2: #181818;
|
|
|
|
|
+ --border: #242424;
|
|
|
|
|
+ --border2: #2e2e2e;
|
|
|
|
|
+ --text: #d4d0c8;
|
|
|
|
|
+ --muted: #555550;
|
|
|
|
|
+ --accent: #c8a96e;
|
|
|
|
|
+ --accent2: #6e9ec8;
|
|
|
|
|
+ --danger: #c86e6e;
|
|
|
|
|
+ --ok: #6ec87a;
|
|
|
|
|
+ --mono: 'IBM Plex Mono', monospace;
|
|
|
|
|
+ --sans: 'IBM Plex Sans', sans-serif;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
|
+
|
|
|
|
|
+ body {
|
|
|
|
|
+ background: var(--bg);
|
|
|
|
|
+ color: var(--text);
|
|
|
|
|
+ font-family: var(--sans);
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ font-weight: 300;
|
|
|
|
|
+ min-height: 100vh;
|
|
|
|
|
+ /* subtle noise texture */
|
|
|
|
|
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.03'/%3E%3C/svg%3E");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* ── HEADER ─────────────────────────────────────────────── */
|
|
|
|
|
+ header {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ padding: 18px 32px;
|
|
|
|
|
+ border-bottom: 1px solid var(--border);
|
|
|
|
|
+ background: var(--surface);
|
|
|
|
|
+ position: sticky;
|
|
|
|
|
+ top: 0;
|
|
|
|
|
+ z-index: 100;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .header-left {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: baseline;
|
|
|
|
|
+ gap: 14px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .logo {
|
|
|
|
|
+ font-family: var(--mono);
|
|
|
|
|
+ font-size: 15px;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+ letter-spacing: 0.08em;
|
|
|
|
|
+ color: var(--accent);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .logo span { color: var(--muted); font-weight: 300; }
|
|
|
|
|
+
|
|
|
|
|
+ .tagline {
|
|
|
|
|
+ font-family: var(--mono);
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ letter-spacing: 0.04em;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .header-right {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 20px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .status-pill {
|
|
|
|
|
+ font-family: var(--mono);
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+ padding: 4px 10px;
|
|
|
|
|
+ border: 1px solid var(--border2);
|
|
|
|
|
+ border-radius: 2px;
|
|
|
|
|
+ background: var(--surface2);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .status-dot {
|
|
|
|
|
+ width: 6px; height: 6px;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+ background: var(--muted);
|
|
|
|
|
+ transition: background 0.3s;
|
|
|
|
|
+ }
|
|
|
|
|
+ .status-dot.ok { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
|
|
|
|
|
+ .status-dot.err { background: var(--danger); box-shadow: 0 0 6px var(--danger); }
|
|
|
|
|
+
|
|
|
|
|
+ .btn-refresh {
|
|
|
|
|
+ font-family: var(--mono);
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ letter-spacing: 0.06em;
|
|
|
|
|
+ background: none;
|
|
|
|
|
+ border: 1px solid var(--border2);
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ padding: 5px 14px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ border-radius: 2px;
|
|
|
|
|
+ transition: color 0.2s, border-color 0.2s;
|
|
|
|
|
+ }
|
|
|
|
|
+ .btn-refresh:hover { color: var(--text); border-color: var(--accent); }
|
|
|
|
|
+
|
|
|
|
|
+ /* ── LAYOUT ─────────────────────────────────────────────── */
|
|
|
|
|
+ main {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: 1fr 1fr;
|
|
|
|
|
+ gap: 0;
|
|
|
|
|
+ max-width: 1400px;
|
|
|
|
|
+ margin: 0 auto;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .panel {
|
|
|
|
|
+ padding: 28px 32px;
|
|
|
|
|
+ border-right: 1px solid var(--border);
|
|
|
|
|
+ animation: fadeIn 0.4s ease both;
|
|
|
|
|
+ }
|
|
|
|
|
+ .panel:last-child { border-right: none; }
|
|
|
|
|
+
|
|
|
|
|
+ @keyframes fadeIn {
|
|
|
|
|
+ from { opacity: 0; transform: translateY(8px); }
|
|
|
|
|
+ to { opacity: 1; transform: translateY(0); }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .panel-header {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: baseline;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ margin-bottom: 22px;
|
|
|
|
|
+ padding-bottom: 14px;
|
|
|
|
|
+ border-bottom: 1px solid var(--border);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .panel-title {
|
|
|
|
|
+ font-family: var(--mono);
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+ letter-spacing: 0.12em;
|
|
|
|
|
+ text-transform: uppercase;
|
|
|
|
|
+ color: var(--accent);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .panel-count {
|
|
|
|
|
+ font-family: var(--mono);
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* ── BOOK GROUPS ─────────────────────────────────────────── */
|
|
|
|
|
+ .book-group {
|
|
|
|
|
+ border: 1px solid var(--border);
|
|
|
|
|
+ border-radius: 2px;
|
|
|
|
|
+ margin-bottom: 10px;
|
|
|
|
|
+ background: var(--surface);
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ animation: fadeIn 0.3s ease both;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .book-header {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ padding: 11px 14px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ user-select: none;
|
|
|
|
|
+ transition: background 0.15s;
|
|
|
|
|
+ }
|
|
|
|
|
+ .book-header:hover { background: var(--surface2); }
|
|
|
|
|
+
|
|
|
|
|
+ .book-header-left {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .chevron {
|
|
|
|
|
+ font-size: 10px;
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ transition: transform 0.2s;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ .book-group.open .chevron { transform: rotate(90deg); }
|
|
|
|
|
+
|
|
|
|
|
+ .book-name {
|
|
|
|
|
+ font-family: var(--mono);
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: var(--text);
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .book-badge {
|
|
|
|
|
+ font-family: var(--mono);
|
|
|
|
|
+ font-size: 10px;
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ background: var(--surface2);
|
|
|
|
|
+ border: 1px solid var(--border);
|
|
|
|
|
+ padding: 2px 7px;
|
|
|
|
|
+ border-radius: 2px;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .btn-delete-book {
|
|
|
|
|
+ font-family: var(--mono);
|
|
|
|
|
+ font-size: 10px;
|
|
|
|
|
+ letter-spacing: 0.05em;
|
|
|
|
|
+ background: none;
|
|
|
|
|
+ border: 1px solid transparent;
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ padding: 3px 10px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ border-radius: 2px;
|
|
|
|
|
+ transition: color 0.2s, border-color 0.2s;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ .btn-delete-book:hover {
|
|
|
|
|
+ color: var(--danger);
|
|
|
|
|
+ border-color: var(--danger);
|
|
|
|
|
+ }
|
|
|
|
|
+ .btn-delete-book.confirming {
|
|
|
|
|
+ color: var(--danger);
|
|
|
|
|
+ border-color: var(--danger);
|
|
|
|
|
+ background: rgba(200, 110, 110, 0.08);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .book-entries {
|
|
|
|
|
+ display: none;
|
|
|
|
|
+ border-top: 1px solid var(--border);
|
|
|
|
|
+ }
|
|
|
|
|
+ .book-group.open .book-entries { display: block; }
|
|
|
|
|
+
|
|
|
|
|
+ .entry {
|
|
|
|
|
+ padding: 10px 14px;
|
|
|
|
|
+ border-bottom: 1px solid var(--border);
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: 1fr auto;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ align-items: start;
|
|
|
|
|
+ }
|
|
|
|
|
+ .entry:last-child { border-bottom: none; }
|
|
|
|
|
+
|
|
|
|
|
+ .entry-text {
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ line-height: 1.5;
|
|
|
|
|
+ color: var(--text);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .entry-meta {
|
|
|
|
|
+ font-family: var(--mono);
|
|
|
|
|
+ font-size: 10px;
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ text-align: right;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* ── CONVERSATIONAL MEMORIES ─────────────────────────────── */
|
|
|
|
|
+ .memory-item {
|
|
|
|
|
+ padding: 12px 0;
|
|
|
|
|
+ border-bottom: 1px solid var(--border);
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: 1fr auto;
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+ align-items: start;
|
|
|
|
|
+ animation: fadeIn 0.3s ease both;
|
|
|
|
|
+ }
|
|
|
|
|
+ .memory-item:last-child { border-bottom: none; }
|
|
|
|
|
+
|
|
|
|
|
+ .memory-text {
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ line-height: 1.55;
|
|
|
|
|
+ color: var(--text);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .memory-time {
|
|
|
|
|
+ font-family: var(--mono);
|
|
|
|
|
+ font-size: 10px;
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ padding-top: 2px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .user-section {
|
|
|
|
|
+ margin-bottom: 20px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .user-label {
|
|
|
|
|
+ font-family: var(--mono);
|
|
|
|
|
+ font-size: 10px;
|
|
|
|
|
+ letter-spacing: 0.1em;
|
|
|
|
|
+ color: var(--accent2);
|
|
|
|
|
+ text-transform: uppercase;
|
|
|
|
|
+ margin-bottom: 8px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .user-label::after {
|
|
|
|
|
+ content: '';
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ height: 1px;
|
|
|
|
|
+ background: var(--border);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* ── EMPTY / LOADING STATES ──────────────────────────────── */
|
|
|
|
|
+ .state-msg {
|
|
|
|
|
+ font-family: var(--mono);
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ padding: 24px 0;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ letter-spacing: 0.04em;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .loading-bar {
|
|
|
|
|
+ height: 1px;
|
|
|
|
|
+ background: var(--border);
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ margin-bottom: 20px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .loading-bar::after {
|
|
|
|
|
+ content: '';
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ width: 40%;
|
|
|
|
|
+ background: var(--accent);
|
|
|
|
|
+ animation: slide 1.2s ease-in-out infinite;
|
|
|
|
|
+ }
|
|
|
|
|
+ @keyframes slide {
|
|
|
|
|
+ 0% { transform: translateX(-100%); }
|
|
|
|
|
+ 100% { transform: translateX(350%); }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* ── TOAST ───────────────────────────────────────────────── */
|
|
|
|
|
+ #toast {
|
|
|
|
|
+ position: fixed;
|
|
|
|
|
+ bottom: 24px;
|
|
|
|
|
+ right: 24px;
|
|
|
|
|
+ font-family: var(--mono);
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ padding: 10px 18px;
|
|
|
|
|
+ border-radius: 2px;
|
|
|
|
|
+ border: 1px solid var(--border2);
|
|
|
|
|
+ background: var(--surface);
|
|
|
|
|
+ color: var(--text);
|
|
|
|
|
+ opacity: 0;
|
|
|
|
|
+ transform: translateY(8px);
|
|
|
|
|
+ transition: opacity 0.25s, transform 0.25s;
|
|
|
|
|
+ z-index: 999;
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+ }
|
|
|
|
|
+ #toast.show { opacity: 1; transform: translateY(0); }
|
|
|
|
|
+ #toast.ok { border-color: var(--ok); color: var(--ok); }
|
|
|
|
|
+ #toast.err { border-color: var(--danger); color: var(--danger); }
|
|
|
|
|
+
|
|
|
|
|
+ /* ── RESPONSIVE ──────────────────────────────────────────── */
|
|
|
|
|
+ @media (max-width: 900px) {
|
|
|
|
|
+ main { grid-template-columns: 1fr; }
|
|
|
|
|
+ .panel { border-right: none; border-bottom: 1px solid var(--border); }
|
|
|
|
|
+ }
|
|
|
|
|
+</style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+
|
|
|
|
|
+<header>
|
|
|
|
|
+ <div class="header-left">
|
|
|
|
|
+ <div class="logo">mem0<span>/dashboard</span></div>
|
|
|
|
|
+ <div class="tagline">192.168.0.200:8420</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="header-right">
|
|
|
|
|
+ <div class="status-pill">
|
|
|
|
|
+ <div class="status-dot" id="statusDot"></div>
|
|
|
|
|
+ <span id="statusText" style="font-family:var(--mono);font-size:11px;color:var(--muted)">connecting</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button class="btn-refresh" onclick="loadAll()">⟳ refresh</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</header>
|
|
|
|
|
+
|
|
|
|
|
+<main>
|
|
|
|
|
+ <!-- ── KNOWLEDGE PANEL ── -->
|
|
|
|
|
+ <div class="panel" style="animation-delay:0.05s">
|
|
|
|
|
+ <div class="panel-header">
|
|
|
|
|
+ <div class="panel-title">knowledge base</div>
|
|
|
|
|
+ <div class="panel-count" id="knowledgeCount">—</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div id="knowledgeLoading" class="loading-bar"></div>
|
|
|
|
|
+ <div id="knowledgeContent"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ── MEMORIES PANEL ── -->
|
|
|
|
|
+ <div class="panel" style="animation-delay:0.1s">
|
|
|
|
|
+ <div class="panel-header">
|
|
|
|
|
+ <div class="panel-title">conversational memory</div>
|
|
|
|
|
+ <div class="panel-count" id="memoriesCount">—</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div id="memoriesLoading" class="loading-bar"></div>
|
|
|
|
|
+ <div id="memoriesContent"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</main>
|
|
|
|
|
+
|
|
|
|
|
+<div id="toast"></div>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+const BASE = 'http://192.168.0.200:8420';
|
|
|
|
|
+
|
|
|
|
|
+// ── TOAST ──────────────────────────────────────────────────────
|
|
|
|
|
+function toast(msg, type = 'ok') {
|
|
|
|
|
+ const el = document.getElementById('toast');
|
|
|
|
|
+ el.textContent = msg;
|
|
|
|
|
+ el.className = `show ${type}`;
|
|
|
|
|
+ clearTimeout(el._t);
|
|
|
|
|
+ el._t = setTimeout(() => el.className = '', 2800);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── HEALTH ─────────────────────────────────────────────────────
|
|
|
|
|
+async function checkHealth() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await fetch(`${BASE}/health`);
|
|
|
|
|
+ const dot = document.getElementById('statusDot');
|
|
|
|
|
+ const txt = document.getElementById('statusText');
|
|
|
|
|
+ if (r.ok) {
|
|
|
|
|
+ dot.className = 'status-dot ok';
|
|
|
|
|
+ txt.textContent = 'online';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ throw new Error();
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ document.getElementById('statusDot').className = 'status-dot err';
|
|
|
|
|
+ document.getElementById('statusText').textContent = 'unreachable';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── HELPERS ────────────────────────────────────────────────────
|
|
|
|
|
+function fmtDate(iso) {
|
|
|
|
|
+ if (!iso) return '—';
|
|
|
|
|
+ const d = new Date(iso);
|
|
|
|
|
+ return d.toLocaleDateString('en-GB', { day:'2-digit', month:'short' })
|
|
|
|
|
+ + ' ' + d.toLocaleTimeString('en-GB', { hour:'2-digit', minute:'2-digit' });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function stopLoading(id) {
|
|
|
|
|
+ const el = document.getElementById(id);
|
|
|
|
|
+ if (el) el.style.display = 'none';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── KNOWLEDGE ──────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+// Phase 1: fetch just enough to build the book list (headers only)
|
|
|
|
|
+async function loadKnowledge() {
|
|
|
|
|
+ document.getElementById('knowledgeLoading').style.display = 'block';
|
|
|
|
|
+ document.getElementById('knowledgeContent').innerHTML = '';
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await fetch(`${BASE}/knowledge/recent`, {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ user_id: 'knowledge_base', limit: 50 })
|
|
|
|
|
+ });
|
|
|
|
|
+ const data = await r.json();
|
|
|
|
|
+ const items = data.results || [];
|
|
|
|
|
+ stopLoading('knowledgeLoading');
|
|
|
|
|
+ renderKnowledge(items);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ stopLoading('knowledgeLoading');
|
|
|
|
|
+ document.getElementById('knowledgeContent').innerHTML =
|
|
|
|
|
+ '<div class="state-msg">failed to load — is the server reachable?</div>';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderKnowledge(items) {
|
|
|
|
|
+ const container = document.getElementById('knowledgeContent');
|
|
|
|
|
+
|
|
|
|
|
+ if (!items.length) {
|
|
|
|
|
+ container.innerHTML = '<div class="state-msg">no knowledge entries found</div>';
|
|
|
|
|
+ document.getElementById('knowledgeCount').textContent = '0 entries';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Group by source_file to build headers
|
|
|
|
|
+ const groups = {};
|
|
|
|
|
+ for (const item of items) {
|
|
|
|
|
+ const src = item.metadata?.source_file || '(no source)';
|
|
|
|
|
+ if (!groups[src]) groups[src] = 0;
|
|
|
|
|
+ groups[src]++;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const sorted = Object.entries(groups).sort((a, b) => b[1] - a[1]);
|
|
|
|
|
+ document.getElementById('knowledgeCount').textContent =
|
|
|
|
|
+ `${items.length}+ entries · ${sorted.length} books`;
|
|
|
|
|
+
|
|
|
|
|
+ container.innerHTML = '';
|
|
|
|
|
+ for (const [src, count] of sorted) {
|
|
|
|
|
+ container.appendChild(buildBookGroup(src, count));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Phase 2: fetch entries for a specific book on expand (lazy)
|
|
|
|
|
+async function loadBookEntries(src, entriesDiv) {
|
|
|
|
|
+ entriesDiv.innerHTML = '<div class="state-msg">loading…</div>';
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await fetch(`${BASE}/knowledge/search`, {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({
|
|
|
|
|
+ query: src.replace(/\.pdf$/i, '').replace(/[-_]/g, ' '),
|
|
|
|
|
+ user_id: 'knowledge_base',
|
|
|
|
|
+ limit: 200
|
|
|
|
|
+ })
|
|
|
|
|
+ });
|
|
|
|
|
+ const data = await r.json();
|
|
|
|
|
+ const entries = (data.results || []).sort(
|
|
|
|
|
+ (a, b) => (a.metadata?.page ?? 0) - (b.metadata?.page ?? 0)
|
|
|
|
|
+ );
|
|
|
|
|
+ entriesDiv.innerHTML = '';
|
|
|
|
|
+ if (!entries.length) {
|
|
|
|
|
+ entriesDiv.innerHTML = '<div class="state-msg">no entries found</div>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const entry of entries) {
|
|
|
|
|
+ const row = document.createElement('div');
|
|
|
|
|
+ row.className = 'entry';
|
|
|
|
|
+ const page = entry.metadata?.page ? `p.${entry.metadata.page}` : '';
|
|
|
|
|
+ const ch = entry.metadata?.chapter ? `ch.${entry.metadata.chapter}` : '';
|
|
|
|
|
+ const loc = [ch, page].filter(Boolean).join(' · ');
|
|
|
|
|
+ row.innerHTML = `
|
|
|
|
|
+ <div class="entry-text">${entry.memory || ''}</div>
|
|
|
|
|
+ <div class="entry-meta">${loc}<br>${fmtDate(entry.created_at)}</div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ entriesDiv.appendChild(row);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ entriesDiv.innerHTML = '<div class="state-msg">failed to load entries</div>';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function buildBookGroup(src, count) {
|
|
|
|
|
+ const group = document.createElement('div');
|
|
|
|
|
+ group.className = 'book-group';
|
|
|
|
|
+ group.dataset.src = src;
|
|
|
|
|
+
|
|
|
|
|
+ const header = document.createElement('div');
|
|
|
|
|
+ header.className = 'book-header';
|
|
|
|
|
+ header.innerHTML = `
|
|
|
|
|
+ <div class="book-header-left">
|
|
|
|
|
+ <span class="chevron">▶</span>
|
|
|
|
|
+ <span class="book-name" title="${src}">${src}</span>
|
|
|
|
|
+ <span class="book-badge">${count}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button class="btn-delete-book" data-src="${src}">delete</button>
|
|
|
|
|
+ `;
|
|
|
|
|
+
|
|
|
|
|
+ const entriesDiv = document.createElement('div');
|
|
|
|
|
+ entriesDiv.className = 'book-entries';
|
|
|
|
|
+
|
|
|
|
|
+ // Lazy load on first expand
|
|
|
|
|
+ header.querySelector('.book-header-left').addEventListener('click', () => {
|
|
|
|
|
+ group.classList.toggle('open');
|
|
|
|
|
+ if (group.classList.contains('open') && !group.dataset.loaded) {
|
|
|
|
|
+ group.dataset.loaded = 'true';
|
|
|
|
|
+ loadBookEntries(src, entriesDiv);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Delete — confirm on first click, execute on second
|
|
|
|
|
+ const delBtn = header.querySelector('.btn-delete-book');
|
|
|
|
|
+ delBtn.addEventListener('click', (e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ if (!delBtn.classList.contains('confirming')) {
|
|
|
|
|
+ delBtn.classList.add('confirming');
|
|
|
|
|
+ delBtn.textContent = 'confirm?';
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ delBtn.classList.remove('confirming');
|
|
|
|
|
+ delBtn.textContent = 'delete';
|
|
|
|
|
+ }, 2500);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ deleteBook(src, group);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ group.appendChild(header);
|
|
|
|
|
+ group.appendChild(entriesDiv);
|
|
|
|
|
+ return group;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function deleteBook(src, groupEl) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await fetch(`${BASE}/knowledge`, {
|
|
|
|
|
+ method: 'DELETE',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ filter: { 'metadata.source_file': src } })
|
|
|
|
|
+ });
|
|
|
|
|
+ if (r.ok) {
|
|
|
|
|
+ groupEl.style.transition = 'opacity 0.3s';
|
|
|
|
|
+ groupEl.style.opacity = '0';
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ groupEl.remove();
|
|
|
|
|
+ // update count
|
|
|
|
|
+ const remaining = document.querySelectorAll('.book-group').length;
|
|
|
|
|
+ const entries = document.querySelectorAll('.entry').length;
|
|
|
|
|
+ document.getElementById('knowledgeCount').textContent =
|
|
|
|
|
+ `${entries} entries · ${remaining} books`;
|
|
|
|
|
+ }, 300);
|
|
|
|
|
+ toast(`deleted: ${src}`);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ toast('delete failed', 'err');
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ toast('delete failed — server unreachable', 'err');
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── MEMORIES ───────────────────────────────────────────────────
|
|
|
|
|
+async function loadMemories() {
|
|
|
|
|
+ document.getElementById('memoriesLoading').style.display = 'block';
|
|
|
|
|
+ document.getElementById('memoriesContent').innerHTML = '';
|
|
|
|
|
+
|
|
|
|
|
+ // Fetch for known user IDs — extend this array as needed
|
|
|
|
|
+ const userIds = ['main', 'testuser', 'default'];
|
|
|
|
|
+ const allResults = {};
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ await Promise.all(userIds.map(async uid => {
|
|
|
|
|
+ const r = await fetch(`${BASE}/memories/recent`, {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ user_id: uid, limit: 100 })
|
|
|
|
|
+ });
|
|
|
|
|
+ const data = await r.json();
|
|
|
|
|
+ const items = data.results || [];
|
|
|
|
|
+ if (items.length) allResults[uid] = items;
|
|
|
|
|
+ }));
|
|
|
|
|
+
|
|
|
|
|
+ stopLoading('memoriesLoading');
|
|
|
|
|
+ renderMemories(allResults);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ stopLoading('memoriesLoading');
|
|
|
|
|
+ document.getElementById('memoriesContent').innerHTML =
|
|
|
|
|
+ '<div class="state-msg">failed to load</div>';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderMemories(byUser) {
|
|
|
|
|
+ const container = document.getElementById('memoriesContent');
|
|
|
|
|
+ const allEntries = Object.values(byUser).flat();
|
|
|
|
|
+
|
|
|
|
|
+ if (!allEntries.length) {
|
|
|
|
|
+ container.innerHTML = '<div class="state-msg">no memories found</div>';
|
|
|
|
|
+ document.getElementById('memoriesCount').textContent = '0 memories';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ document.getElementById('memoriesCount').textContent =
|
|
|
|
|
+ `${allEntries.length} memories · ${Object.keys(byUser).length} users`;
|
|
|
|
|
+
|
|
|
|
|
+ container.innerHTML = '';
|
|
|
|
|
+
|
|
|
|
|
+ for (const [uid, items] of Object.entries(byUser)) {
|
|
|
|
|
+ const section = document.createElement('div');
|
|
|
|
|
+ section.className = 'user-section';
|
|
|
|
|
+
|
|
|
|
|
+ const label = document.createElement('div');
|
|
|
|
|
+ label.className = 'user-label';
|
|
|
|
|
+ label.textContent = uid;
|
|
|
|
|
+ section.appendChild(label);
|
|
|
|
|
+
|
|
|
|
|
+ for (const item of items) {
|
|
|
|
|
+ const row = document.createElement('div');
|
|
|
|
|
+ row.className = 'memory-item';
|
|
|
|
|
+ row.innerHTML = `
|
|
|
|
|
+ <div class="memory-text">${item.memory || ''}</div>
|
|
|
|
|
+ <div class="memory-time">${fmtDate(item.created_at)}</div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ section.appendChild(row);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ container.appendChild(section);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── LOAD ALL ───────────────────────────────────────────────────
|
|
|
|
|
+async function loadAll() {
|
|
|
|
|
+ await checkHealth();
|
|
|
|
|
+ await Promise.all([loadKnowledge(), loadMemories()]);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Init
|
|
|
|
|
+loadAll();
|
|
|
|
|
+</script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|