dashboard.html 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>mem0 dashboard</title>
  7. <link rel="preconnect" href="https://fonts.googleapis.com">
  8. <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">
  9. <style>
  10. :root {
  11. --bg: #0a0a0a;
  12. --surface: #111111;
  13. --surface2: #181818;
  14. --border: #242424;
  15. --border2: #2e2e2e;
  16. --text: #d4d0c8;
  17. --muted: #555550;
  18. --accent: #c8a96e;
  19. --accent2: #6e9ec8;
  20. --danger: #c86e6e;
  21. --ok: #6ec87a;
  22. --mono: 'IBM Plex Mono', monospace;
  23. --sans: 'IBM Plex Sans', sans-serif;
  24. }
  25. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  26. body {
  27. background: var(--bg);
  28. color: var(--text);
  29. font-family: var(--sans);
  30. font-size: 14px;
  31. font-weight: 300;
  32. min-height: 100vh;
  33. /* subtle noise texture */
  34. 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");
  35. }
  36. /* ── HEADER ─────────────────────────────────────────────── */
  37. header {
  38. display: flex;
  39. align-items: center;
  40. justify-content: space-between;
  41. padding: 18px 32px;
  42. border-bottom: 1px solid var(--border);
  43. background: var(--surface);
  44. position: sticky;
  45. top: 0;
  46. z-index: 100;
  47. }
  48. .header-left {
  49. display: flex;
  50. align-items: baseline;
  51. gap: 14px;
  52. }
  53. .logo {
  54. font-family: var(--mono);
  55. font-size: 15px;
  56. font-weight: 500;
  57. letter-spacing: 0.08em;
  58. color: var(--accent);
  59. }
  60. .logo span { color: var(--muted); font-weight: 300; }
  61. .tagline {
  62. font-family: var(--mono);
  63. font-size: 11px;
  64. color: var(--muted);
  65. letter-spacing: 0.04em;
  66. }
  67. .header-right {
  68. display: flex;
  69. align-items: center;
  70. gap: 20px;
  71. }
  72. .status-pill {
  73. font-family: var(--mono);
  74. font-size: 11px;
  75. display: flex;
  76. align-items: center;
  77. gap: 6px;
  78. padding: 4px 10px;
  79. border: 1px solid var(--border2);
  80. border-radius: 2px;
  81. background: var(--surface2);
  82. }
  83. .status-dot {
  84. width: 6px; height: 6px;
  85. border-radius: 50%;
  86. background: var(--muted);
  87. transition: background 0.3s;
  88. }
  89. .status-dot.ok { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
  90. .status-dot.err { background: var(--danger); box-shadow: 0 0 6px var(--danger); }
  91. .btn-refresh {
  92. font-family: var(--mono);
  93. font-size: 11px;
  94. letter-spacing: 0.06em;
  95. background: none;
  96. border: 1px solid var(--border2);
  97. color: var(--muted);
  98. padding: 5px 14px;
  99. cursor: pointer;
  100. border-radius: 2px;
  101. transition: color 0.2s, border-color 0.2s;
  102. }
  103. .btn-refresh:hover { color: var(--text); border-color: var(--accent); }
  104. /* ── LAYOUT ─────────────────────────────────────────────── */
  105. main {
  106. display: grid;
  107. grid-template-columns: 1fr 1fr;
  108. gap: 0;
  109. max-width: 1400px;
  110. margin: 0 auto;
  111. }
  112. .panel {
  113. padding: 28px 32px;
  114. border-right: 1px solid var(--border);
  115. animation: fadeIn 0.4s ease both;
  116. }
  117. .panel:last-child { border-right: none; }
  118. @keyframes fadeIn {
  119. from { opacity: 0; transform: translateY(8px); }
  120. to { opacity: 1; transform: translateY(0); }
  121. }
  122. .panel-header {
  123. display: flex;
  124. align-items: baseline;
  125. justify-content: space-between;
  126. margin-bottom: 22px;
  127. padding-bottom: 14px;
  128. border-bottom: 1px solid var(--border);
  129. }
  130. .panel-title {
  131. font-family: var(--mono);
  132. font-size: 11px;
  133. font-weight: 500;
  134. letter-spacing: 0.12em;
  135. text-transform: uppercase;
  136. color: var(--accent);
  137. }
  138. .panel-count {
  139. font-family: var(--mono);
  140. font-size: 11px;
  141. color: var(--muted);
  142. }
  143. /* ── BOOK GROUPS ─────────────────────────────────────────── */
  144. .book-group {
  145. border: 1px solid var(--border);
  146. border-radius: 2px;
  147. margin-bottom: 10px;
  148. background: var(--surface);
  149. overflow: hidden;
  150. animation: fadeIn 0.3s ease both;
  151. }
  152. .book-header {
  153. display: flex;
  154. align-items: center;
  155. justify-content: space-between;
  156. padding: 11px 14px;
  157. cursor: pointer;
  158. user-select: none;
  159. transition: background 0.15s;
  160. }
  161. .book-header:hover { background: var(--surface2); }
  162. .book-header-left {
  163. display: flex;
  164. align-items: center;
  165. gap: 10px;
  166. min-width: 0;
  167. }
  168. .chevron {
  169. font-size: 10px;
  170. color: var(--muted);
  171. transition: transform 0.2s;
  172. flex-shrink: 0;
  173. }
  174. .book-group.open .chevron { transform: rotate(90deg); }
  175. .book-name {
  176. font-family: var(--mono);
  177. font-size: 12px;
  178. color: var(--text);
  179. white-space: nowrap;
  180. overflow: hidden;
  181. text-overflow: ellipsis;
  182. }
  183. .book-badge {
  184. font-family: var(--mono);
  185. font-size: 10px;
  186. color: var(--muted);
  187. background: var(--surface2);
  188. border: 1px solid var(--border);
  189. padding: 2px 7px;
  190. border-radius: 2px;
  191. flex-shrink: 0;
  192. }
  193. .btn-delete-book {
  194. font-family: var(--mono);
  195. font-size: 10px;
  196. letter-spacing: 0.05em;
  197. background: none;
  198. border: 1px solid transparent;
  199. color: var(--muted);
  200. padding: 3px 10px;
  201. cursor: pointer;
  202. border-radius: 2px;
  203. transition: color 0.2s, border-color 0.2s;
  204. flex-shrink: 0;
  205. }
  206. .btn-delete-book:hover {
  207. color: var(--danger);
  208. border-color: var(--danger);
  209. }
  210. .btn-delete-book.confirming {
  211. color: var(--danger);
  212. border-color: var(--danger);
  213. background: rgba(200, 110, 110, 0.08);
  214. }
  215. .book-entries {
  216. display: none;
  217. border-top: 1px solid var(--border);
  218. }
  219. .book-group.open .book-entries { display: block; }
  220. .entry {
  221. padding: 10px 14px;
  222. border-bottom: 1px solid var(--border);
  223. display: grid;
  224. grid-template-columns: 1fr auto;
  225. gap: 8px;
  226. align-items: start;
  227. }
  228. .entry:last-child { border-bottom: none; }
  229. .entry-text {
  230. font-size: 12px;
  231. line-height: 1.5;
  232. color: var(--text);
  233. }
  234. .entry-meta {
  235. font-family: var(--mono);
  236. font-size: 10px;
  237. color: var(--muted);
  238. text-align: right;
  239. white-space: nowrap;
  240. }
  241. /* ── CONVERSATIONAL MEMORIES ─────────────────────────────── */
  242. .memory-item {
  243. padding: 12px 0;
  244. border-bottom: 1px solid var(--border);
  245. display: grid;
  246. grid-template-columns: 1fr auto;
  247. gap: 12px;
  248. align-items: start;
  249. animation: fadeIn 0.3s ease both;
  250. }
  251. .memory-item:last-child { border-bottom: none; }
  252. .memory-text {
  253. font-size: 13px;
  254. line-height: 1.55;
  255. color: var(--text);
  256. }
  257. .memory-time {
  258. font-family: var(--mono);
  259. font-size: 10px;
  260. color: var(--muted);
  261. white-space: nowrap;
  262. padding-top: 2px;
  263. }
  264. .user-section {
  265. margin-bottom: 20px;
  266. }
  267. .user-label {
  268. font-family: var(--mono);
  269. font-size: 10px;
  270. letter-spacing: 0.1em;
  271. color: var(--accent2);
  272. text-transform: uppercase;
  273. margin-bottom: 8px;
  274. display: flex;
  275. align-items: center;
  276. gap: 8px;
  277. }
  278. .user-label::after {
  279. content: '';
  280. flex: 1;
  281. height: 1px;
  282. background: var(--border);
  283. }
  284. /* ── EMPTY / LOADING STATES ──────────────────────────────── */
  285. .state-msg {
  286. font-family: var(--mono);
  287. font-size: 11px;
  288. color: var(--muted);
  289. padding: 24px 0;
  290. text-align: center;
  291. letter-spacing: 0.04em;
  292. }
  293. .loading-bar {
  294. height: 1px;
  295. background: var(--border);
  296. overflow: hidden;
  297. margin-bottom: 20px;
  298. }
  299. .loading-bar::after {
  300. content: '';
  301. display: block;
  302. height: 100%;
  303. width: 40%;
  304. background: var(--accent);
  305. animation: slide 1.2s ease-in-out infinite;
  306. }
  307. @keyframes slide {
  308. 0% { transform: translateX(-100%); }
  309. 100% { transform: translateX(350%); }
  310. }
  311. /* ── TOAST ───────────────────────────────────────────────── */
  312. #toast {
  313. position: fixed;
  314. bottom: 24px;
  315. right: 24px;
  316. font-family: var(--mono);
  317. font-size: 12px;
  318. padding: 10px 18px;
  319. border-radius: 2px;
  320. border: 1px solid var(--border2);
  321. background: var(--surface);
  322. color: var(--text);
  323. opacity: 0;
  324. transform: translateY(8px);
  325. transition: opacity 0.25s, transform 0.25s;
  326. z-index: 999;
  327. pointer-events: none;
  328. }
  329. #toast.show { opacity: 1; transform: translateY(0); }
  330. #toast.ok { border-color: var(--ok); color: var(--ok); }
  331. #toast.err { border-color: var(--danger); color: var(--danger); }
  332. /* ── RESPONSIVE ──────────────────────────────────────────── */
  333. @media (max-width: 900px) {
  334. main { grid-template-columns: 1fr; }
  335. .panel { border-right: none; border-bottom: 1px solid var(--border); }
  336. }
  337. </style>
  338. </head>
  339. <body>
  340. <header>
  341. <div class="header-left">
  342. <div class="logo">mem0<span>/dashboard</span></div>
  343. <div class="tagline">192.168.0.200:8420</div>
  344. </div>
  345. <div class="header-right">
  346. <div class="status-pill">
  347. <div class="status-dot" id="statusDot"></div>
  348. <span id="statusText" style="font-family:var(--mono);font-size:11px;color:var(--muted)">connecting</span>
  349. </div>
  350. <button class="btn-refresh" onclick="loadAll()">⟳ refresh</button>
  351. </div>
  352. </header>
  353. <main>
  354. <!-- ── KNOWLEDGE PANEL ── -->
  355. <div class="panel" style="animation-delay:0.05s">
  356. <div class="panel-header">
  357. <div class="panel-title">knowledge base</div>
  358. <div class="panel-count" id="knowledgeCount">—</div>
  359. </div>
  360. <div id="knowledgeLoading" class="loading-bar"></div>
  361. <div id="knowledgeContent"></div>
  362. </div>
  363. <!-- ── MEMORIES PANEL ── -->
  364. <div class="panel" style="animation-delay:0.1s">
  365. <div class="panel-header">
  366. <div class="panel-title">conversational memory</div>
  367. <div class="panel-count" id="memoriesCount">—</div>
  368. </div>
  369. <div id="memoriesLoading" class="loading-bar"></div>
  370. <div id="memoriesContent"></div>
  371. </div>
  372. </main>
  373. <div id="toast"></div>
  374. <script>
  375. const BASE = 'http://192.168.0.200:8420';
  376. // ── TOAST ──────────────────────────────────────────────────────
  377. function toast(msg, type = 'ok') {
  378. const el = document.getElementById('toast');
  379. el.textContent = msg;
  380. el.className = `show ${type}`;
  381. clearTimeout(el._t);
  382. el._t = setTimeout(() => el.className = '', 2800);
  383. }
  384. // ── HEALTH ─────────────────────────────────────────────────────
  385. async function checkHealth() {
  386. try {
  387. const r = await fetch(`${BASE}/health`);
  388. const dot = document.getElementById('statusDot');
  389. const txt = document.getElementById('statusText');
  390. if (r.ok) {
  391. dot.className = 'status-dot ok';
  392. txt.textContent = 'online';
  393. } else {
  394. throw new Error();
  395. }
  396. } catch {
  397. document.getElementById('statusDot').className = 'status-dot err';
  398. document.getElementById('statusText').textContent = 'unreachable';
  399. }
  400. }
  401. // ── HELPERS ────────────────────────────────────────────────────
  402. function fmtDate(iso) {
  403. if (!iso) return '—';
  404. const d = new Date(iso);
  405. return d.toLocaleDateString('en-GB', { day:'2-digit', month:'short' })
  406. + ' ' + d.toLocaleTimeString('en-GB', { hour:'2-digit', minute:'2-digit' });
  407. }
  408. function stopLoading(id) {
  409. const el = document.getElementById(id);
  410. if (el) el.style.display = 'none';
  411. }
  412. // ── KNOWLEDGE ──────────────────────────────────────────────────
  413. // Phase 1: fetch just enough to build the book list (headers only)
  414. async function loadKnowledge() {
  415. document.getElementById('knowledgeLoading').style.display = 'block';
  416. document.getElementById('knowledgeContent').innerHTML = '';
  417. try {
  418. const r = await fetch(`${BASE}/knowledge/recent`, {
  419. method: 'POST',
  420. headers: { 'Content-Type': 'application/json' },
  421. body: JSON.stringify({ user_id: 'knowledge_base', limit: 50 })
  422. });
  423. const data = await r.json();
  424. const items = data.results || [];
  425. stopLoading('knowledgeLoading');
  426. renderKnowledge(items);
  427. } catch (e) {
  428. stopLoading('knowledgeLoading');
  429. document.getElementById('knowledgeContent').innerHTML =
  430. '<div class="state-msg">failed to load — is the server reachable?</div>';
  431. }
  432. }
  433. function renderKnowledge(items) {
  434. const container = document.getElementById('knowledgeContent');
  435. if (!items.length) {
  436. container.innerHTML = '<div class="state-msg">no knowledge entries found</div>';
  437. document.getElementById('knowledgeCount').textContent = '0 entries';
  438. return;
  439. }
  440. // Group by source_file to build headers
  441. const groups = {};
  442. for (const item of items) {
  443. const src = item.metadata?.source_file || '(no source)';
  444. if (!groups[src]) groups[src] = 0;
  445. groups[src]++;
  446. }
  447. const sorted = Object.entries(groups).sort((a, b) => b[1] - a[1]);
  448. document.getElementById('knowledgeCount').textContent =
  449. `${items.length}+ entries · ${sorted.length} books`;
  450. container.innerHTML = '';
  451. for (const [src, count] of sorted) {
  452. container.appendChild(buildBookGroup(src, count));
  453. }
  454. }
  455. // Phase 2: fetch entries for a specific book on expand (lazy)
  456. async function loadBookEntries(src, entriesDiv) {
  457. entriesDiv.innerHTML = '<div class="state-msg">loading…</div>';
  458. try {
  459. const r = await fetch(`${BASE}/knowledge/search`, {
  460. method: 'POST',
  461. headers: { 'Content-Type': 'application/json' },
  462. body: JSON.stringify({
  463. query: src.replace(/\.pdf$/i, '').replace(/[-_]/g, ' '),
  464. user_id: 'knowledge_base',
  465. limit: 200
  466. })
  467. });
  468. const data = await r.json();
  469. const entries = (data.results || []).sort(
  470. (a, b) => (a.metadata?.page ?? 0) - (b.metadata?.page ?? 0)
  471. );
  472. entriesDiv.innerHTML = '';
  473. if (!entries.length) {
  474. entriesDiv.innerHTML = '<div class="state-msg">no entries found</div>';
  475. return;
  476. }
  477. for (const entry of entries) {
  478. const row = document.createElement('div');
  479. row.className = 'entry';
  480. const page = entry.metadata?.page ? `p.${entry.metadata.page}` : '';
  481. const ch = entry.metadata?.chapter ? `ch.${entry.metadata.chapter}` : '';
  482. const loc = [ch, page].filter(Boolean).join(' · ');
  483. row.innerHTML = `
  484. <div class="entry-text">${entry.memory || ''}</div>
  485. <div class="entry-meta">${loc}<br>${fmtDate(entry.created_at)}</div>
  486. `;
  487. entriesDiv.appendChild(row);
  488. }
  489. } catch (e) {
  490. entriesDiv.innerHTML = '<div class="state-msg">failed to load entries</div>';
  491. }
  492. }
  493. function buildBookGroup(src, count) {
  494. const group = document.createElement('div');
  495. group.className = 'book-group';
  496. group.dataset.src = src;
  497. const header = document.createElement('div');
  498. header.className = 'book-header';
  499. header.innerHTML = `
  500. <div class="book-header-left">
  501. <span class="chevron">▶</span>
  502. <span class="book-name" title="${src}">${src}</span>
  503. <span class="book-badge">${count}</span>
  504. </div>
  505. <button class="btn-delete-book" data-src="${src}">delete</button>
  506. `;
  507. const entriesDiv = document.createElement('div');
  508. entriesDiv.className = 'book-entries';
  509. // Lazy load on first expand
  510. header.querySelector('.book-header-left').addEventListener('click', () => {
  511. group.classList.toggle('open');
  512. if (group.classList.contains('open') && !group.dataset.loaded) {
  513. group.dataset.loaded = 'true';
  514. loadBookEntries(src, entriesDiv);
  515. }
  516. });
  517. // Delete — confirm on first click, execute on second
  518. const delBtn = header.querySelector('.btn-delete-book');
  519. delBtn.addEventListener('click', (e) => {
  520. e.stopPropagation();
  521. if (!delBtn.classList.contains('confirming')) {
  522. delBtn.classList.add('confirming');
  523. delBtn.textContent = 'confirm?';
  524. setTimeout(() => {
  525. delBtn.classList.remove('confirming');
  526. delBtn.textContent = 'delete';
  527. }, 2500);
  528. } else {
  529. deleteBook(src, group);
  530. }
  531. });
  532. group.appendChild(header);
  533. group.appendChild(entriesDiv);
  534. return group;
  535. }
  536. async function deleteBook(src, groupEl) {
  537. try {
  538. const r = await fetch(`${BASE}/knowledge`, {
  539. method: 'DELETE',
  540. headers: { 'Content-Type': 'application/json' },
  541. body: JSON.stringify({ filter: { 'metadata.source_file': src } })
  542. });
  543. if (r.ok) {
  544. groupEl.style.transition = 'opacity 0.3s';
  545. groupEl.style.opacity = '0';
  546. setTimeout(() => {
  547. groupEl.remove();
  548. // update count
  549. const remaining = document.querySelectorAll('.book-group').length;
  550. const entries = document.querySelectorAll('.entry').length;
  551. document.getElementById('knowledgeCount').textContent =
  552. `${entries} entries · ${remaining} books`;
  553. }, 300);
  554. toast(`deleted: ${src}`);
  555. } else {
  556. toast('delete failed', 'err');
  557. }
  558. } catch {
  559. toast('delete failed — server unreachable', 'err');
  560. }
  561. }
  562. // ── MEMORIES ───────────────────────────────────────────────────
  563. async function loadMemories() {
  564. document.getElementById('memoriesLoading').style.display = 'block';
  565. document.getElementById('memoriesContent').innerHTML = '';
  566. // Fetch for known user IDs — extend this array as needed
  567. const userIds = ['main', 'testuser', 'default'];
  568. const allResults = {};
  569. try {
  570. await Promise.all(userIds.map(async uid => {
  571. const r = await fetch(`${BASE}/memories/recent`, {
  572. method: 'POST',
  573. headers: { 'Content-Type': 'application/json' },
  574. body: JSON.stringify({ user_id: uid, limit: 100 })
  575. });
  576. const data = await r.json();
  577. const items = data.results || [];
  578. if (items.length) allResults[uid] = items;
  579. }));
  580. stopLoading('memoriesLoading');
  581. renderMemories(allResults);
  582. } catch (e) {
  583. stopLoading('memoriesLoading');
  584. document.getElementById('memoriesContent').innerHTML =
  585. '<div class="state-msg">failed to load</div>';
  586. }
  587. }
  588. function renderMemories(byUser) {
  589. const container = document.getElementById('memoriesContent');
  590. const allEntries = Object.values(byUser).flat();
  591. if (!allEntries.length) {
  592. container.innerHTML = '<div class="state-msg">no memories found</div>';
  593. document.getElementById('memoriesCount').textContent = '0 memories';
  594. return;
  595. }
  596. document.getElementById('memoriesCount').textContent =
  597. `${allEntries.length} memories · ${Object.keys(byUser).length} users`;
  598. container.innerHTML = '';
  599. for (const [uid, items] of Object.entries(byUser)) {
  600. const section = document.createElement('div');
  601. section.className = 'user-section';
  602. const label = document.createElement('div');
  603. label.className = 'user-label';
  604. label.textContent = uid;
  605. section.appendChild(label);
  606. for (const item of items) {
  607. const row = document.createElement('div');
  608. row.className = 'memory-item';
  609. row.innerHTML = `
  610. <div class="memory-text">${item.memory || ''}</div>
  611. <div class="memory-time">${fmtDate(item.created_at)}</div>
  612. `;
  613. section.appendChild(row);
  614. }
  615. container.appendChild(section);
  616. }
  617. }
  618. // ── LOAD ALL ───────────────────────────────────────────────────
  619. async function loadAll() {
  620. await checkHealth();
  621. await Promise.all([loadKnowledge(), loadMemories()]);
  622. }
  623. // Init
  624. loadAll();
  625. </script>
  626. </body>
  627. </html>