909c1577f1
- crates/ → engrams/ (Rust engrams live here) - bindings/ → receptors/ (cross-language access points into the graph) - Cargo.toml workspace paths updated
4262 lines
125 KiB
HTML
4262 lines
125 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Engram Studio</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,400&family=Syne:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--bg: #080b0f;
|
||
--bg2: #0d1117;
|
||
--bg3: #111820;
|
||
--bg4: #161e28;
|
||
--border: rgba(255,255,255,0.06);
|
||
--border2: rgba(255,255,255,0.12);
|
||
--text: #e8edf3;
|
||
--text2: #7a8a9a;
|
||
--text3: #4a5a6a;
|
||
|
||
--working: #ef4444;
|
||
--working-dim: rgba(239,68,68,0.15);
|
||
--working-glow: rgba(239,68,68,0.4);
|
||
|
||
--episodic: #f59e0b;
|
||
--episodic-dim: rgba(245,158,11,0.15);
|
||
--episodic-glow: rgba(245,158,11,0.4);
|
||
|
||
--semantic: #8b5cf6;
|
||
--semantic-dim: rgba(139,92,246,0.15);
|
||
--semantic-glow: rgba(139,92,246,0.4);
|
||
|
||
--procedural: #14b8a6;
|
||
--procedural-dim: rgba(20,184,166,0.15);
|
||
--procedural-glow: rgba(20,184,166,0.4);
|
||
|
||
--accent: #38bdf8;
|
||
--accent-dim: rgba(56,189,248,0.12);
|
||
--accent-glow: rgba(56,189,248,0.5);
|
||
|
||
--sidebar-w: 280px;
|
||
--radius: 6px;
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
body {
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: 'Syne', sans-serif;
|
||
font-size: 13px;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
display: flex;
|
||
}
|
||
|
||
/* ── SIDEBAR ── */
|
||
#sidebar {
|
||
width: var(--sidebar-w);
|
||
min-width: var(--sidebar-w);
|
||
background: var(--bg2);
|
||
border-right: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
position: relative;
|
||
z-index: 10;
|
||
}
|
||
|
||
#logo {
|
||
padding: 20px 20px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.logo-wordmark {
|
||
font-family: 'Syne', sans-serif;
|
||
font-weight: 800;
|
||
font-size: 20px;
|
||
letter-spacing: -0.02em;
|
||
color: var(--text);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.logo-dot {
|
||
width: 8px; height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
box-shadow: 0 0 12px var(--accent-glow);
|
||
animation: pulse-dot 2.5s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes pulse-dot {
|
||
0%,100% { box-shadow: 0 0 8px var(--accent-glow); opacity: 1; }
|
||
50% { box-shadow: 0 0 20px var(--accent-glow), 0 0 40px rgba(56,189,248,0.2); opacity: 0.8; }
|
||
}
|
||
|
||
.logo-sub {
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.12em;
|
||
color: var(--text3);
|
||
text-transform: uppercase;
|
||
margin-top: 3px;
|
||
}
|
||
|
||
.mode-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
background: var(--accent-dim);
|
||
border: 1px solid rgba(56,189,248,0.2);
|
||
border-radius: 3px;
|
||
padding: 2px 6px;
|
||
font-size: 9px;
|
||
font-family: 'DM Mono', monospace;
|
||
color: var(--accent);
|
||
letter-spacing: 0.08em;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.mode-badge::before {
|
||
content: '';
|
||
width: 5px; height: 5px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
animation: blink 1.5s step-end infinite;
|
||
}
|
||
|
||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
|
||
|
||
#sidebar-scroll {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
padding: 0 0 80px;
|
||
}
|
||
|
||
#sidebar-scroll::-webkit-scrollbar { width: 3px; }
|
||
#sidebar-scroll::-webkit-scrollbar-track { background: transparent; }
|
||
#sidebar-scroll::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
|
||
|
||
.s-section {
|
||
padding: 14px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.s-label {
|
||
font-size: 9px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.14em;
|
||
text-transform: uppercase;
|
||
color: var(--text3);
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.db-stats {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 6px;
|
||
}
|
||
|
||
.stat-box {
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 8px 10px;
|
||
}
|
||
|
||
.stat-val {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 18px;
|
||
font-weight: 500;
|
||
color: var(--text);
|
||
line-height: 1;
|
||
}
|
||
|
||
.stat-key {
|
||
font-size: 9px;
|
||
color: var(--text3);
|
||
margin-top: 3px;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
|
||
.tier-bars {
|
||
margin-top: 10px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
}
|
||
|
||
.tier-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.tier-dot {
|
||
width: 6px; height: 6px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.tier-name {
|
||
font-size: 10px;
|
||
color: var(--text2);
|
||
width: 60px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.tier-bar-bg {
|
||
flex: 1;
|
||
height: 3px;
|
||
background: var(--bg3);
|
||
border-radius: 2px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.tier-bar-fill {
|
||
height: 100%;
|
||
border-radius: 2px;
|
||
transition: width 0.5s ease;
|
||
}
|
||
|
||
.tier-count {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 10px;
|
||
color: var(--text3);
|
||
width: 14px;
|
||
text-align: right;
|
||
}
|
||
|
||
/* Actions */
|
||
.action-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 6px;
|
||
}
|
||
|
||
.btn {
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
color: var(--text2);
|
||
font-family: 'Syne', sans-serif;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.04em;
|
||
padding: 8px 10px;
|
||
cursor: pointer;
|
||
transition: all 0.15s ease;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.btn:hover {
|
||
background: var(--bg4);
|
||
border-color: var(--border2);
|
||
color: var(--text);
|
||
}
|
||
|
||
.btn.primary {
|
||
background: var(--accent-dim);
|
||
border-color: rgba(56,189,248,0.3);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.btn.primary:hover {
|
||
background: rgba(56,189,248,0.2);
|
||
box-shadow: 0 0 12px rgba(56,189,248,0.2);
|
||
}
|
||
|
||
.btn.full { grid-column: 1/-1; }
|
||
|
||
.btn:active { transform: scale(0.97); }
|
||
|
||
/* Query panel */
|
||
.query-input {
|
||
width: 100%;
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
color: var(--text);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11px;
|
||
padding: 8px 10px;
|
||
outline: none;
|
||
transition: border-color 0.15s;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.query-input:focus { border-color: rgba(56,189,248,0.4); }
|
||
.query-input::placeholder { color: var(--text3); }
|
||
|
||
.seed-label {
|
||
font-size: 9px;
|
||
color: var(--text3);
|
||
margin-bottom: 5px;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
|
||
.seed-select {
|
||
width: 100%;
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
color: var(--text2);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 10px;
|
||
padding: 4px 6px;
|
||
outline: none;
|
||
max-height: 80px;
|
||
scrollbar-width: thin;
|
||
}
|
||
|
||
.seed-select option { padding: 2px 4px; }
|
||
|
||
/* Results */
|
||
.results-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
max-height: 220px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.results-list::-webkit-scrollbar { width: 3px; }
|
||
.results-list::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
|
||
|
||
.result-item {
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 7px 9px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.result-item::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0; top: 0; bottom: 0;
|
||
width: 2px;
|
||
border-radius: 2px 0 0 2px;
|
||
}
|
||
|
||
.result-item.tier-Working::before { background: var(--working); }
|
||
.result-item.tier-Episodic::before { background: var(--episodic); }
|
||
.result-item.tier-Semantic::before { background: var(--semantic); }
|
||
.result-item.tier-Procedural::before { background: var(--procedural); }
|
||
|
||
.result-item:hover { border-color: var(--border2); background: var(--bg4); }
|
||
|
||
.result-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 3px;
|
||
}
|
||
|
||
.result-id {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 9px;
|
||
color: var(--text3);
|
||
}
|
||
|
||
.result-score {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.result-content {
|
||
font-size: 10px;
|
||
color: var(--text2);
|
||
line-height: 1.4;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.empty-state {
|
||
font-size: 10px;
|
||
color: var(--text3);
|
||
text-align: center;
|
||
padding: 16px 0;
|
||
font-style: italic;
|
||
}
|
||
|
||
/* ── MAIN AREA ── */
|
||
#main {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
/* Tabs */
|
||
#tabs {
|
||
display: flex;
|
||
align-items: center;
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--bg2);
|
||
padding: 0 20px;
|
||
gap: 0;
|
||
flex-shrink: 0;
|
||
height: 44px;
|
||
}
|
||
|
||
.tab {
|
||
padding: 0 18px;
|
||
height: 44px;
|
||
display: flex;
|
||
align-items: center;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.04em;
|
||
color: var(--text3);
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
transition: all 0.15s;
|
||
position: relative;
|
||
top: 1px;
|
||
}
|
||
|
||
.tab:hover { color: var(--text2); }
|
||
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||
|
||
#tab-content {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.tab-panel {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: none;
|
||
}
|
||
|
||
.tab-panel.active { display: flex; flex-direction: column; }
|
||
|
||
/* ── GRAPH TAB ── */
|
||
#graph-panel {
|
||
position: relative;
|
||
background: var(--bg);
|
||
}
|
||
|
||
#graph-canvas {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
cursor: grab;
|
||
}
|
||
|
||
#graph-canvas:active { cursor: grabbing; }
|
||
|
||
.graph-overlay {
|
||
position: absolute;
|
||
top: 16px;
|
||
right: 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.graph-legend {
|
||
background: rgba(13,17,23,0.85);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 10px 12px;
|
||
backdrop-filter: blur(8px);
|
||
pointer-events: all;
|
||
}
|
||
|
||
.legend-title {
|
||
font-size: 9px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
color: var(--text3);
|
||
margin-bottom: 7px;
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
margin-bottom: 4px;
|
||
font-size: 10px;
|
||
color: var(--text2);
|
||
}
|
||
|
||
.legend-dot {
|
||
width: 8px; height: 8px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Node detail popup */
|
||
#node-detail {
|
||
position: absolute;
|
||
bottom: 16px;
|
||
left: 16px;
|
||
background: rgba(13,17,23,0.92);
|
||
border: 1px solid var(--border2);
|
||
border-radius: 8px;
|
||
padding: 14px 16px;
|
||
max-width: 340px;
|
||
backdrop-filter: blur(12px);
|
||
display: none;
|
||
animation: slide-up 0.2s ease;
|
||
}
|
||
|
||
@keyframes slide-up {
|
||
from { opacity: 0; transform: translateY(8px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
.nd-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.nd-id {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11px;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.nd-close {
|
||
cursor: pointer;
|
||
color: var(--text3);
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
padding: 0 2px;
|
||
transition: color 0.15s;
|
||
}
|
||
|
||
.nd-close:hover { color: var(--text); }
|
||
|
||
.nd-badges {
|
||
display: flex;
|
||
gap: 5px;
|
||
margin-bottom: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.nd-badge {
|
||
font-size: 9px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.08em;
|
||
padding: 2px 7px;
|
||
border-radius: 3px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.nd-content {
|
||
font-size: 11px;
|
||
color: var(--text2);
|
||
line-height: 1.6;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.nd-stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 6px;
|
||
}
|
||
|
||
.nd-stat {
|
||
background: var(--bg3);
|
||
border-radius: 4px;
|
||
padding: 6px 8px;
|
||
}
|
||
|
||
.nd-stat-val {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text);
|
||
}
|
||
|
||
.nd-stat-key {
|
||
font-size: 8px;
|
||
color: var(--text3);
|
||
margin-top: 2px;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
|
||
/* ── NODES TAB ── */
|
||
#nodes-panel {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.nodes-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 12px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.chip-filters {
|
||
display: flex;
|
||
gap: 5px;
|
||
}
|
||
|
||
.chip {
|
||
padding: 4px 10px;
|
||
border-radius: 20px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.06em;
|
||
cursor: pointer;
|
||
border: 1px solid transparent;
|
||
transition: all 0.15s;
|
||
color: var(--text3);
|
||
background: var(--bg3);
|
||
}
|
||
|
||
.chip:hover { color: var(--text2); }
|
||
.chip.active { color: var(--text); }
|
||
.chip.all.active { background: var(--bg4); border-color: var(--border2); color: var(--text); }
|
||
.chip.Working.active { background: var(--working-dim); border-color: var(--working); color: var(--working); }
|
||
.chip.Episodic.active { background: var(--episodic-dim); border-color: var(--episodic); color: var(--episodic); }
|
||
.chip.Semantic.active { background: var(--semantic-dim); border-color: var(--semantic); color: var(--semantic); }
|
||
.chip.Procedural.active { background: var(--procedural-dim); border-color: var(--procedural); color: var(--procedural); }
|
||
|
||
.search-input {
|
||
flex: 1;
|
||
max-width: 220px;
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
color: var(--text);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11px;
|
||
padding: 5px 10px;
|
||
outline: none;
|
||
transition: border-color 0.15s;
|
||
}
|
||
|
||
.search-input:focus { border-color: rgba(56,189,248,0.4); }
|
||
.search-input::placeholder { color: var(--text3); }
|
||
|
||
.nodes-table-wrap {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.nodes-table-wrap::-webkit-scrollbar { width: 4px; height: 4px; }
|
||
.nodes-table-wrap::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
|
||
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 11px;
|
||
}
|
||
|
||
thead {
|
||
position: sticky;
|
||
top: 0;
|
||
background: var(--bg2);
|
||
z-index: 1;
|
||
}
|
||
|
||
th {
|
||
text-align: left;
|
||
padding: 10px 14px;
|
||
font-size: 9px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
color: var(--text3);
|
||
border-bottom: 1px solid var(--border);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
white-space: nowrap;
|
||
transition: color 0.15s;
|
||
}
|
||
|
||
th:hover { color: var(--text2); }
|
||
th.sorted { color: var(--accent); }
|
||
|
||
th .sort-arrow { margin-left: 4px; opacity: 0.6; }
|
||
|
||
td {
|
||
padding: 9px 14px;
|
||
border-bottom: 1px solid var(--border);
|
||
color: var(--text2);
|
||
vertical-align: middle;
|
||
}
|
||
|
||
tr:hover td { background: var(--bg3); }
|
||
tr { cursor: pointer; transition: background 0.1s; }
|
||
|
||
.tier-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
font-size: 9px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.06em;
|
||
padding: 2px 7px;
|
||
border-radius: 3px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.mono { font-family: 'DM Mono', monospace; }
|
||
|
||
/* Node detail side panel */
|
||
#node-side-panel {
|
||
position: absolute;
|
||
top: 0; right: 0; bottom: 0;
|
||
width: 360px;
|
||
background: var(--bg2);
|
||
border-left: 1px solid var(--border2);
|
||
transform: translateX(100%);
|
||
transition: transform 0.25s cubic-bezier(0.4,0,0.2,1);
|
||
z-index: 20;
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
#node-side-panel.open { transform: translateX(0); }
|
||
#node-side-panel::-webkit-scrollbar { width: 3px; }
|
||
#node-side-panel::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
|
||
|
||
.nsp-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.nsp-id {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 13px;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.nsp-close {
|
||
cursor: pointer;
|
||
color: var(--text3);
|
||
font-size: 20px;
|
||
line-height: 1;
|
||
transition: color 0.15s;
|
||
}
|
||
|
||
.nsp-close:hover { color: var(--text); }
|
||
|
||
.nsp-content {
|
||
font-size: 12px;
|
||
color: var(--text2);
|
||
line-height: 1.7;
|
||
margin: 12px 0;
|
||
padding: 12px;
|
||
background: var(--bg3);
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.nsp-stats {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr 1fr;
|
||
gap: 8px;
|
||
margin: 12px 0;
|
||
}
|
||
|
||
.nsp-stat {
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 8px 10px;
|
||
}
|
||
|
||
.nsp-stat-val {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--text);
|
||
}
|
||
|
||
.nsp-stat-key {
|
||
font-size: 8px;
|
||
color: var(--text3);
|
||
margin-top: 3px;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
|
||
.nsp-edges {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.nsp-edge-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 0;
|
||
border-bottom: 1px solid var(--border);
|
||
font-size: 10px;
|
||
}
|
||
|
||
.nsp-edge-rel {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 9px;
|
||
color: var(--text3);
|
||
padding: 1px 5px;
|
||
background: var(--bg3);
|
||
border-radius: 3px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.nsp-edge-target {
|
||
color: var(--accent);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 10px;
|
||
}
|
||
|
||
.nsp-edge-weight {
|
||
margin-left: auto;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 9px;
|
||
color: var(--text3);
|
||
}
|
||
|
||
/* ── TIMELINE TAB ── */
|
||
#timeline-panel { overflow: hidden; }
|
||
|
||
#timeline-canvas {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
}
|
||
|
||
/* ── CONSOLE TAB ── */
|
||
#console-panel { overflow: hidden; background: var(--bg); }
|
||
|
||
#console-output {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 16px 20px;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11px;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
#console-output::-webkit-scrollbar { width: 4px; }
|
||
#console-output::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
|
||
|
||
.console-entry {
|
||
margin-bottom: 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
padding-bottom: 12px;
|
||
}
|
||
|
||
.console-cmd {
|
||
color: var(--accent);
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.console-cmd::before { content: '> '; color: var(--text3); }
|
||
|
||
.console-out {
|
||
color: var(--text2);
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.console-out.success { color: #4ade80; }
|
||
.console-out.error { color: #f87171; }
|
||
.console-out.info { color: var(--text2); }
|
||
|
||
#console-input-row {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 12px 20px;
|
||
border-top: 1px solid var(--border);
|
||
gap: 10px;
|
||
flex-shrink: 0;
|
||
background: var(--bg2);
|
||
}
|
||
|
||
.console-prompt {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 12px;
|
||
color: var(--accent);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#console-input {
|
||
flex: 1;
|
||
background: transparent;
|
||
border: none;
|
||
outline: none;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
caret-color: var(--accent);
|
||
}
|
||
|
||
#console-input::placeholder { color: var(--text3); }
|
||
|
||
/* ── MODAL ── */
|
||
#modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.6);
|
||
backdrop-filter: blur(4px);
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 100;
|
||
}
|
||
|
||
#modal-overlay.open { display: flex; }
|
||
|
||
#modal {
|
||
background: var(--bg2);
|
||
border: 1px solid var(--border2);
|
||
border-radius: 10px;
|
||
padding: 24px;
|
||
width: 400px;
|
||
animation: modal-in 0.2s ease;
|
||
}
|
||
|
||
@keyframes modal-in {
|
||
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||
}
|
||
|
||
.modal-title {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
margin-bottom: 20px;
|
||
color: var(--text);
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.form-label {
|
||
display: block;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
color: var(--text3);
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.form-input, .form-select, .form-textarea {
|
||
width: 100%;
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
color: var(--text);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 11px;
|
||
padding: 8px 10px;
|
||
outline: none;
|
||
transition: border-color 0.15s;
|
||
}
|
||
|
||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||
border-color: rgba(56,189,248,0.4);
|
||
}
|
||
|
||
.form-textarea { resize: vertical; min-height: 80px; }
|
||
|
||
.form-select option { background: var(--bg3); }
|
||
|
||
.importance-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.importance-slider {
|
||
flex: 1;
|
||
-webkit-appearance: none;
|
||
height: 3px;
|
||
border-radius: 2px;
|
||
background: var(--bg4);
|
||
outline: none;
|
||
}
|
||
|
||
.importance-slider::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
width: 14px; height: 14px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
cursor: pointer;
|
||
box-shadow: 0 0 8px var(--accent-glow);
|
||
}
|
||
|
||
.importance-val {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 12px;
|
||
color: var(--accent);
|
||
width: 32px;
|
||
text-align: right;
|
||
}
|
||
|
||
.modal-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
/* ── TOAST ── */
|
||
#toast {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
right: 20px;
|
||
background: var(--bg2);
|
||
border: 1px solid var(--border2);
|
||
border-radius: 8px;
|
||
padding: 12px 16px;
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
z-index: 200;
|
||
transform: translateY(20px);
|
||
opacity: 0;
|
||
transition: all 0.25s ease;
|
||
max-width: 300px;
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
|
||
#toast.show { transform: translateY(0); opacity: 1; }
|
||
#toast.success { border-color: rgba(74,222,128,0.4); }
|
||
#toast.info { border-color: rgba(56,189,248,0.4); }
|
||
|
||
/* Scrollbar global */
|
||
* { scrollbar-width: thin; scrollbar-color: var(--border2) transparent; }
|
||
|
||
/* ── SWARM TAB ── */
|
||
.peer-card {
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 10px 12px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.peer-card.online { border-color: rgba(74,222,128,0.3); }
|
||
.peer-card.offline { border-color: rgba(248,113,113,0.2); }
|
||
|
||
.peer-status-dot {
|
||
width: 7px; height: 7px;
|
||
border-radius: 50%;
|
||
display: inline-block;
|
||
margin-right: 6px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.peer-status-dot.online { background: #4ade80; box-shadow: 0 0 6px rgba(74,222,128,0.6); }
|
||
.peer-status-dot.offline { background: #f87171; }
|
||
|
||
.peer-name {
|
||
font-weight: 600;
|
||
font-size: 11px;
|
||
color: var(--text);
|
||
}
|
||
|
||
.peer-addr {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 9px;
|
||
color: var(--text3);
|
||
margin-top: 2px;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.peer-meta {
|
||
display: flex;
|
||
gap: 6px;
|
||
margin-top: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.peer-tag {
|
||
font-size: 8px;
|
||
padding: 1px 5px;
|
||
border-radius: 3px;
|
||
font-family: 'DM Mono', monospace;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.peer-tag.tier { background: var(--semantic-dim); color: var(--semantic); border: 1px solid rgba(139,92,246,0.2); }
|
||
.peer-tag.trusted { background: rgba(56,189,248,0.1); color: var(--accent); border: 1px solid rgba(56,189,248,0.2); }
|
||
.peer-tag.sync-time { background: var(--bg4); color: var(--text3); border: 1px solid var(--border); }
|
||
|
||
.peer-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.peer-btn {
|
||
font-size: 9px;
|
||
padding: 3px 8px;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
font-family: 'Syne', sans-serif;
|
||
font-weight: 600;
|
||
letter-spacing: 0.04em;
|
||
border: 1px solid var(--border2);
|
||
background: var(--bg4);
|
||
color: var(--text2);
|
||
transition: all 0.1s;
|
||
}
|
||
|
||
.peer-btn:hover { background: var(--bg3); color: var(--text); }
|
||
.peer-btn.danger:hover { border-color: rgba(248,113,113,0.4); color: #f87171; }
|
||
|
||
/* Swarm result items */
|
||
.swarm-result-item {
|
||
background: var(--bg2);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 10px 12px;
|
||
margin-bottom: 6px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Remote nodes: dashed border */
|
||
.swarm-result-item.remote {
|
||
border-style: dashed;
|
||
border-color: rgba(56,189,248,0.25);
|
||
background: rgba(56,189,248,0.03);
|
||
}
|
||
|
||
.swarm-result-item::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0; top: 0; bottom: 0;
|
||
width: 2px;
|
||
border-radius: 2px 0 0 2px;
|
||
}
|
||
|
||
.swarm-result-item.tier-Working::before { background: var(--working); }
|
||
.swarm-result-item.tier-Episodic::before { background: var(--episodic); }
|
||
.swarm-result-item.tier-Semantic::before { background: var(--semantic); }
|
||
.swarm-result-item.tier-Procedural::before { background: var(--procedural); }
|
||
|
||
.swarm-result-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.swarm-result-peer {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 9px;
|
||
color: var(--accent);
|
||
background: var(--accent-dim);
|
||
padding: 1px 6px;
|
||
border-radius: 3px;
|
||
border: 1px solid rgba(56,189,248,0.2);
|
||
}
|
||
|
||
.swarm-result-local {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 9px;
|
||
color: var(--text3);
|
||
}
|
||
|
||
.swarm-result-strength {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.swarm-result-content {
|
||
font-size: 10px;
|
||
color: var(--text2);
|
||
line-height: 1.5;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Graph canvas: remote peer nodes get dashed overlay — drawn via canvas 2d */
|
||
|
||
/* ── CHAT TAB ── */
|
||
#chat-panel {
|
||
display: flex;
|
||
flex-direction: row;
|
||
overflow: hidden;
|
||
background: var(--bg);
|
||
}
|
||
|
||
#chat-sessions-col {
|
||
width: 220px;
|
||
min-width: 220px;
|
||
border-right: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background: var(--bg2);
|
||
}
|
||
|
||
#chat-sessions-header {
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#new-chat-btn {
|
||
width: 100%;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
#chat-session-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 6px 0;
|
||
}
|
||
|
||
#chat-session-list::-webkit-scrollbar { width: 3px; }
|
||
#chat-session-list::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
|
||
|
||
.chat-session-item {
|
||
padding: 8px 14px;
|
||
cursor: pointer;
|
||
border-left: 2px solid transparent;
|
||
transition: all 0.15s;
|
||
font-size: 11px;
|
||
color: var(--text3);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.chat-session-item:hover { background: var(--bg3); color: var(--text2); }
|
||
.chat-session-item.active {
|
||
border-left-color: var(--accent);
|
||
background: var(--accent-dim);
|
||
color: var(--accent);
|
||
}
|
||
|
||
#chat-main-col {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
#chat-messages {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
#chat-messages::-webkit-scrollbar { width: 4px; }
|
||
#chat-messages::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
|
||
|
||
.chat-msg {
|
||
max-width: 85%;
|
||
animation: msg-in 0.2s ease;
|
||
}
|
||
|
||
@keyframes msg-in {
|
||
from { opacity: 0; transform: translateY(6px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
.chat-msg.user { align-self: flex-end; }
|
||
.chat-msg.assistant { align-self: flex-start; }
|
||
|
||
.chat-msg-bubble {
|
||
padding: 10px 14px;
|
||
border-radius: 8px;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
position: relative;
|
||
}
|
||
|
||
.chat-msg.user .chat-msg-bubble {
|
||
background: var(--accent-dim);
|
||
border: 1px solid rgba(56,189,248,0.25);
|
||
color: var(--text);
|
||
}
|
||
|
||
.chat-msg.assistant .chat-msg-bubble {
|
||
background: var(--bg2);
|
||
border: 1px solid var(--border2);
|
||
color: var(--text2);
|
||
}
|
||
|
||
.chat-msg-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 4px;
|
||
padding: 0 2px;
|
||
}
|
||
|
||
.chat-msg-role {
|
||
font-size: 9px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--text3);
|
||
}
|
||
|
||
.chat-tts-btn {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
color: var(--text3);
|
||
font-size: 12px;
|
||
padding: 0 2px;
|
||
transition: color 0.15s;
|
||
line-height: 1;
|
||
}
|
||
|
||
.chat-tts-btn:hover { color: var(--accent); }
|
||
|
||
/* Tool call blocks */
|
||
.tool-block {
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: 6px;
|
||
margin: 8px 0;
|
||
overflow: hidden;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 10px;
|
||
}
|
||
|
||
.tool-block-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 10px;
|
||
cursor: pointer;
|
||
background: var(--bg4);
|
||
user-select: none;
|
||
}
|
||
|
||
.tool-block-name {
|
||
color: var(--accent);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.tool-block-toggle {
|
||
margin-left: auto;
|
||
color: var(--text3);
|
||
font-size: 9px;
|
||
}
|
||
|
||
.tool-block-body {
|
||
padding: 8px 10px;
|
||
display: none;
|
||
border-top: 1px solid var(--border);
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
color: var(--text2);
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.tool-block-body.open { display: block; }
|
||
|
||
.tool-result-content {
|
||
padding: 6px 10px;
|
||
color: #4ade80;
|
||
font-size: 10px;
|
||
font-family: 'DM Mono', monospace;
|
||
border-top: 1px solid var(--border);
|
||
max-height: 120px;
|
||
overflow-y: auto;
|
||
display: none;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.tool-result-content.show { display: block; }
|
||
|
||
/* Markdown rendered in chat */
|
||
.chat-md h1, .chat-md h2, .chat-md h3 {
|
||
color: var(--text);
|
||
font-family: 'Syne', sans-serif;
|
||
margin: 10px 0 5px;
|
||
}
|
||
.chat-md h1 { font-size: 15px; }
|
||
.chat-md h2 { font-size: 13px; }
|
||
.chat-md h3 { font-size: 12px; }
|
||
.chat-md p { margin: 5px 0; }
|
||
.chat-md code {
|
||
font-family: 'DM Mono', monospace;
|
||
background: var(--bg3);
|
||
padding: 1px 5px;
|
||
border-radius: 3px;
|
||
font-size: 11px;
|
||
color: var(--accent);
|
||
}
|
||
.chat-md pre {
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border);
|
||
border-radius: 5px;
|
||
padding: 10px 12px;
|
||
overflow-x: auto;
|
||
margin: 8px 0;
|
||
}
|
||
.chat-md pre code {
|
||
background: none;
|
||
padding: 0;
|
||
color: var(--text2);
|
||
}
|
||
.chat-md ul, .chat-md ol { padding-left: 18px; margin: 5px 0; }
|
||
.chat-md li { margin: 2px 0; }
|
||
.chat-md strong { color: var(--text); font-weight: 600; }
|
||
.chat-md em { font-style: italic; color: var(--text2); }
|
||
.chat-md a { color: var(--accent); }
|
||
.chat-md hr { border: none; border-top: 1px solid var(--border); margin: 10px 0; }
|
||
.chat-md blockquote {
|
||
border-left: 3px solid var(--accent);
|
||
padding-left: 10px;
|
||
color: var(--text3);
|
||
margin: 6px 0;
|
||
}
|
||
|
||
/* Chat input row */
|
||
#chat-input-row {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 8px;
|
||
padding: 12px 16px;
|
||
border-top: 1px solid var(--border);
|
||
background: var(--bg2);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#chat-textarea {
|
||
flex: 1;
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: 6px;
|
||
color: var(--text);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 12px;
|
||
padding: 9px 12px;
|
||
outline: none;
|
||
resize: none;
|
||
min-height: 40px;
|
||
max-height: 160px;
|
||
overflow-y: auto;
|
||
transition: border-color 0.15s;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
#chat-textarea:focus { border-color: rgba(56,189,248,0.4); }
|
||
#chat-textarea::placeholder { color: var(--text3); }
|
||
|
||
#chat-send-btn {
|
||
flex-shrink: 0;
|
||
width: 38px;
|
||
height: 38px;
|
||
background: var(--accent-dim);
|
||
border: 1px solid rgba(56,189,248,0.3);
|
||
border-radius: 6px;
|
||
color: var(--accent);
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
#chat-send-btn:hover { background: rgba(56,189,248,0.2); }
|
||
#chat-send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
|
||
#chat-tts-toggle {
|
||
flex-shrink: 0;
|
||
width: 38px;
|
||
height: 38px;
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: 6px;
|
||
color: var(--text3);
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
#chat-tts-toggle.active {
|
||
background: var(--accent-dim);
|
||
border-color: rgba(56,189,248,0.3);
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* System prompt collapsible */
|
||
#chat-system-row {
|
||
padding: 8px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--bg2);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#chat-system-toggle {
|
||
font-size: 9px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
color: var(--text3);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
user-select: none;
|
||
}
|
||
|
||
#chat-system-toggle:hover { color: var(--text2); }
|
||
|
||
#chat-system-textarea {
|
||
display: none;
|
||
width: 100%;
|
||
margin-top: 8px;
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: 4px;
|
||
color: var(--text2);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 10px;
|
||
padding: 7px 10px;
|
||
outline: none;
|
||
resize: vertical;
|
||
min-height: 60px;
|
||
}
|
||
|
||
#chat-system-textarea.open { display: block; }
|
||
|
||
.chat-empty-state {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
color: var(--text3);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.chat-empty-icon {
|
||
font-size: 32px;
|
||
opacity: 0.3;
|
||
}
|
||
|
||
.chat-thinking {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 12px;
|
||
background: var(--bg2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
font-size: 11px;
|
||
color: var(--text3);
|
||
font-family: 'DM Mono', monospace;
|
||
align-self: flex-start;
|
||
}
|
||
|
||
.chat-thinking-dots {
|
||
display: flex;
|
||
gap: 3px;
|
||
}
|
||
|
||
.chat-thinking-dots span {
|
||
width: 4px; height: 4px;
|
||
border-radius: 50%;
|
||
background: var(--text3);
|
||
animation: thinking-bounce 1.2s ease-in-out infinite;
|
||
}
|
||
|
||
.chat-thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||
.chat-thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||
|
||
@keyframes thinking-bounce {
|
||
0%,80%,100% { transform: scale(0.6); opacity: 0.4; }
|
||
40% { transform: scale(1); opacity: 1; }
|
||
}
|
||
|
||
#chat-model-select {
|
||
background: var(--bg3);
|
||
border: 1px solid var(--border2);
|
||
border-radius: 4px;
|
||
color: var(--text2);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: 10px;
|
||
padding: 3px 6px;
|
||
outline: none;
|
||
flex-shrink: 0;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- SIDEBAR -->
|
||
<div id="sidebar">
|
||
<div id="logo">
|
||
<div class="logo-wordmark">
|
||
<div class="logo-dot"></div>
|
||
Engram
|
||
</div>
|
||
<div class="logo-sub">Memory Studio</div>
|
||
<div class="mode-badge" id="mode-badge">CONNECTING...</div>
|
||
</div>
|
||
|
||
<div id="sidebar-scroll">
|
||
<!-- DB Stats -->
|
||
<div class="s-section">
|
||
<div class="s-label">Database</div>
|
||
<div class="db-stats">
|
||
<div class="stat-box">
|
||
<div class="stat-val" id="stat-nodes">15</div>
|
||
<div class="stat-key">nodes</div>
|
||
</div>
|
||
<div class="stat-box">
|
||
<div class="stat-val" id="stat-edges">22</div>
|
||
<div class="stat-key">edges</div>
|
||
</div>
|
||
</div>
|
||
<div class="tier-bars" id="tier-bars">
|
||
<div class="tier-row">
|
||
<div class="tier-dot" style="background:var(--working)"></div>
|
||
<div class="tier-name">Working</div>
|
||
<div class="tier-bar-bg"><div class="tier-bar-fill" id="bar-working" style="background:var(--working);width:0%"></div></div>
|
||
<div class="tier-count" id="cnt-working">0</div>
|
||
</div>
|
||
<div class="tier-row">
|
||
<div class="tier-dot" style="background:var(--episodic)"></div>
|
||
<div class="tier-name">Episodic</div>
|
||
<div class="tier-bar-bg"><div class="tier-bar-fill" id="bar-episodic" style="background:var(--episodic);width:0%"></div></div>
|
||
<div class="tier-count" id="cnt-episodic">0</div>
|
||
</div>
|
||
<div class="tier-row">
|
||
<div class="tier-dot" style="background:var(--semantic)"></div>
|
||
<div class="tier-name">Semantic</div>
|
||
<div class="tier-bar-bg"><div class="tier-bar-fill" id="bar-semantic" style="background:var(--semantic);width:0%"></div></div>
|
||
<div class="tier-count" id="cnt-semantic">0</div>
|
||
</div>
|
||
<div class="tier-row">
|
||
<div class="tier-dot" style="background:var(--procedural)"></div>
|
||
<div class="tier-name">Procedural</div>
|
||
<div class="tier-bar-bg"><div class="tier-bar-fill" id="bar-procedural" style="background:var(--procedural);width:0%"></div></div>
|
||
<div class="tier-count" id="cnt-procedural">0</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Actions -->
|
||
<div class="s-section">
|
||
<div class="s-label">Actions</div>
|
||
<div class="action-grid">
|
||
<button class="btn primary" onclick="runActivate()">Activate</button>
|
||
<button class="btn" onclick="runSearch()">Search</button>
|
||
<button class="btn" onclick="openAddNodeModal()">Add Node</button>
|
||
<button class="btn" onclick="runConsolidate()">Consolidate</button>
|
||
<button class="btn full" onclick="runDecay()">Apply Decay</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Query -->
|
||
<div class="s-section">
|
||
<div class="s-label">Query</div>
|
||
<input type="text" class="query-input" id="query-input" placeholder="Search concepts, memories...">
|
||
<div class="seed-label">Seed Nodes (multi-select)</div>
|
||
<select class="seed-select" id="seed-select" multiple size="4"></select>
|
||
</div>
|
||
|
||
<!-- Results -->
|
||
<div class="s-section">
|
||
<div class="s-label">Activated Nodes</div>
|
||
<div class="results-list" id="results-list">
|
||
<div class="empty-state">Run activation to see results</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MAIN -->
|
||
<div id="main">
|
||
<div id="tabs">
|
||
<div class="tab active" data-tab="graph">Graph</div>
|
||
<div class="tab" data-tab="nodes">Nodes</div>
|
||
<div class="tab" data-tab="timeline">Timeline</div>
|
||
<div class="tab" data-tab="console">Console</div>
|
||
<div class="tab" data-tab="swarm">Swarm</div>
|
||
<div class="tab" data-tab="chat">Chat</div>
|
||
</div>
|
||
|
||
<div id="tab-content">
|
||
|
||
<!-- GRAPH -->
|
||
<div class="tab-panel active" id="panel-graph">
|
||
<div id="graph-panel" style="flex:1;position:relative;">
|
||
<canvas id="graph-canvas"></canvas>
|
||
<div class="graph-overlay">
|
||
<div class="graph-legend">
|
||
<div class="legend-title">Tiers</div>
|
||
<div class="legend-item"><div class="legend-dot" style="background:var(--working)"></div>Working</div>
|
||
<div class="legend-item"><div class="legend-dot" style="background:var(--episodic)"></div>Episodic</div>
|
||
<div class="legend-item"><div class="legend-dot" style="background:var(--semantic)"></div>Semantic</div>
|
||
<div class="legend-item"><div class="legend-dot" style="background:var(--procedural)"></div>Procedural</div>
|
||
</div>
|
||
</div>
|
||
<div id="node-detail">
|
||
<div class="nd-header">
|
||
<span class="nd-id" id="nd-id"></span>
|
||
<span class="nd-close" onclick="closeNodeDetail()">×</span>
|
||
</div>
|
||
<div class="nd-badges" id="nd-badges"></div>
|
||
<div class="nd-content" id="nd-content"></div>
|
||
<div class="nd-stats" id="nd-stats"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- NODES TABLE -->
|
||
<div class="tab-panel" id="panel-nodes" style="position:relative;overflow:hidden;">
|
||
<div class="nodes-toolbar">
|
||
<div class="chip-filters" id="chip-filters">
|
||
<div class="chip all active" data-tier="all">All</div>
|
||
<div class="chip Working" data-tier="Working">Working</div>
|
||
<div class="chip Episodic" data-tier="Episodic">Episodic</div>
|
||
<div class="chip Semantic" data-tier="Semantic">Semantic</div>
|
||
<div class="chip Procedural" data-tier="Procedural">Procedural</div>
|
||
</div>
|
||
<input type="text" class="search-input" id="nodes-search" placeholder="Filter by content...">
|
||
</div>
|
||
<div class="nodes-table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th data-col="id">ID <span class="sort-arrow">↕</span></th>
|
||
<th data-col="type">Type <span class="sort-arrow">↕</span></th>
|
||
<th data-col="tier">Tier <span class="sort-arrow">↕</span></th>
|
||
<th data-col="content">Content</th>
|
||
<th data-col="salience">Salience <span class="sort-arrow">↕</span></th>
|
||
<th data-col="activationCount">Activations <span class="sort-arrow">↕</span></th>
|
||
<th data-col="lastActivated">Last Active <span class="sort-arrow">↕</span></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="nodes-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
<div id="node-side-panel">
|
||
<div class="nsp-header">
|
||
<span class="nsp-id" id="nsp-id"></span>
|
||
<span class="nsp-close" onclick="closeNodePanel()">×</span>
|
||
</div>
|
||
<div id="nsp-badges"></div>
|
||
<div class="nsp-content" id="nsp-content"></div>
|
||
<div class="nsp-stats" id="nsp-stats"></div>
|
||
<div class="nsp-edges">
|
||
<div class="s-label" style="margin-bottom:8px;">Connections</div>
|
||
<div id="nsp-edges-list"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TIMELINE -->
|
||
<div class="tab-panel" id="panel-timeline">
|
||
<canvas id="timeline-canvas"></canvas>
|
||
</div>
|
||
|
||
<!-- CONSOLE -->
|
||
<div class="tab-panel" id="panel-console">
|
||
<div id="console-output"></div>
|
||
<div id="console-input-row">
|
||
<span class="console-prompt">engram></span>
|
||
<input type="text" id="console-input" placeholder="stats · search query · get <uuid> · decay · consolidate · add node {...} · reload">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CHAT -->
|
||
<div class="tab-panel" id="panel-chat" style="display:none;flex-direction:row;overflow:hidden;">
|
||
<div id="chat-sessions-col">
|
||
<div id="chat-sessions-header">
|
||
<button class="btn primary" id="new-chat-btn" onclick="newChat()">+ New Chat</button>
|
||
</div>
|
||
<div id="chat-session-list"></div>
|
||
</div>
|
||
<div id="chat-main-col">
|
||
<div id="chat-system-row">
|
||
<div id="chat-system-toggle" onclick="toggleSystemPrompt()">
|
||
<span id="chat-system-arrow">▶</span> System Prompt
|
||
<select id="chat-model-select" onclick="event.stopPropagation()" onchange="event.stopPropagation()">
|
||
<option value="claude-sonnet-4-5">claude-sonnet-4-5</option>
|
||
<option value="claude-haiku-4-5">claude-haiku-4-5</option>
|
||
<option value="claude-opus-4-5">claude-opus-4-5</option>
|
||
</select>
|
||
</div>
|
||
<textarea id="chat-system-textarea" rows="3">You are Neuron, a CGI (Cultivated General Intelligence). You have full access to:
|
||
- The filesystem via bash and file tools
|
||
- The Engram memory graph (use engram_create_node and engram_search)
|
||
- Bash execution for any task
|
||
|
||
You are Will Anderson's primary AI agent. Be direct. Be precise. Help him build things.</textarea>
|
||
</div>
|
||
<div id="chat-messages">
|
||
<div class="chat-empty-state" id="chat-empty">
|
||
<div class="chat-empty-icon">◈</div>
|
||
<div>Start a conversation or select one from the sidebar</div>
|
||
</div>
|
||
</div>
|
||
<div id="chat-input-row">
|
||
<textarea id="chat-textarea" placeholder="Message Neuron..." rows="1"></textarea>
|
||
<button id="chat-tts-toggle" title="Auto-TTS" onclick="toggleAutoTTS()">🔊</button>
|
||
<button id="chat-send-btn" onclick="sendChat()" title="Send">↑</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SWARM -->
|
||
<div class="tab-panel" id="panel-swarm" style="display:none;flex-direction:row;overflow:hidden;">
|
||
|
||
<!-- Left: Peer list -->
|
||
<div id="swarm-peers-col" style="width:320px;min-width:320px;border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;">
|
||
<div style="padding:16px;border-bottom:1px solid var(--border);flex-shrink:0;">
|
||
<div class="s-label" style="margin-bottom:12px;">Connected Peers</div>
|
||
<div id="peer-list" style="display:flex;flex-direction:column;gap:6px;max-height:280px;overflow-y:auto;"></div>
|
||
<button class="btn primary" style="margin-top:12px;width:100%;" onclick="openAddPeerModal()">+ Add Peer</button>
|
||
</div>
|
||
|
||
<!-- Add Peer form (inline, hidden by default) -->
|
||
<div id="add-peer-form" style="display:none;padding:16px;border-bottom:1px solid var(--border);flex-shrink:0;">
|
||
<div class="s-label" style="margin-bottom:10px;">Add Peer</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Name</label>
|
||
<input type="text" class="form-input" id="peer-name" placeholder="neuron-sarah">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Address</label>
|
||
<input type="text" class="form-input" id="peer-address" placeholder="https://engram.example.ai">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">API Key</label>
|
||
<input type="text" class="form-input" id="peer-apikey" placeholder="bearer token">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Sync Tiers</label>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:4px;">
|
||
<label style="font-size:10px;color:var(--text2);display:flex;align-items:center;gap:4px;cursor:pointer;">
|
||
<input type="checkbox" id="tier-working"> Working
|
||
</label>
|
||
<label style="font-size:10px;color:var(--text2);display:flex;align-items:center;gap:4px;cursor:pointer;">
|
||
<input type="checkbox" id="tier-episodic"> Episodic
|
||
</label>
|
||
<label style="font-size:10px;color:var(--text2);display:flex;align-items:center;gap:4px;cursor:pointer;">
|
||
<input type="checkbox" id="tier-semantic" checked> Semantic
|
||
</label>
|
||
<label style="font-size:10px;color:var(--text2);display:flex;align-items:center;gap:4px;cursor:pointer;">
|
||
<input type="checkbox" id="tier-procedural"> Procedural
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<label style="font-size:10px;color:var(--text2);display:flex;align-items:center;gap:6px;cursor:pointer;margin-bottom:12px;">
|
||
<input type="checkbox" id="peer-trusted" checked> Trusted (full tier sync)
|
||
</label>
|
||
<div style="display:flex;gap:6px;">
|
||
<button class="btn" onclick="closeAddPeerForm()" style="flex:1;">Cancel</button>
|
||
<button class="btn primary" onclick="savePeer()" style="flex:1;">Save Peer</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Swarm activation toggle -->
|
||
<div style="padding:16px;flex-shrink:0;">
|
||
<div class="s-label" style="margin-bottom:10px;">Swarm Activation</div>
|
||
<label style="font-size:11px;color:var(--text2);display:flex;align-items:center;gap:8px;cursor:pointer;margin-bottom:12px;">
|
||
<input type="checkbox" id="swarm-activate-toggle">
|
||
Fan out activation to all trusted peers
|
||
</label>
|
||
<button class="btn primary" style="width:100%;" onclick="runSwarmActivate()">Run Swarm Activate</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right: Swarm results -->
|
||
<div id="swarm-results-col" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
|
||
<div style="padding:14px 20px;border-bottom:1px solid var(--border);flex-shrink:0;display:flex;align-items:center;justify-content:space-between;">
|
||
<div class="s-label" style="margin-bottom:0;">Merged Results</div>
|
||
<div id="swarm-status-badge" style="font-size:9px;font-family:'DM Mono',monospace;color:var(--text3);"></div>
|
||
</div>
|
||
<div id="swarm-results-list" style="flex:1;overflow-y:auto;padding:12px 20px;">
|
||
<div class="empty-state" style="margin-top:40px;">Run Swarm Activate to see merged results from all peers</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ADD NODE MODAL -->
|
||
<div id="modal-overlay">
|
||
<div id="modal">
|
||
<div class="modal-title">Add Memory Node</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Content</label>
|
||
<textarea class="form-textarea" id="new-content" placeholder="Describe this memory or concept..."></textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Type</label>
|
||
<select class="form-select" id="new-type">
|
||
<option>Concept</option>
|
||
<option>Memory</option>
|
||
<option>InternalState</option>
|
||
<option>Event</option>
|
||
<option>Process</option>
|
||
<option>Entity</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Tier</label>
|
||
<select class="form-select" id="new-tier">
|
||
<option>Working</option>
|
||
<option>Episodic</option>
|
||
<option selected>Semantic</option>
|
||
<option>Procedural</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Importance</label>
|
||
<div class="importance-row">
|
||
<input type="range" class="importance-slider" id="new-importance" min="0" max="1" step="0.01" value="0.7">
|
||
<span class="importance-val" id="importance-val">0.70</span>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn" onclick="closeModal()">Cancel</button>
|
||
<button class="btn primary" onclick="addNode()">Add Node</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toast"></div>
|
||
|
||
<script>
|
||
// ═══════════════════════════════════════════════════════════
|
||
// API CONFIGURATION
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
const ENGRAM_URL = 'http://localhost:8742';
|
||
const PROXY_URL = 'http://localhost:8080';
|
||
|
||
async function engramFetch(path, opts = {}) {
|
||
const res = await fetch(ENGRAM_URL + path, opts);
|
||
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
||
return res.json();
|
||
}
|
||
|
||
async function proxyFetch(path, opts = {}) {
|
||
const res = await fetch(PROXY_URL + path, opts);
|
||
if (!res.ok) throw new Error(`proxy ${path} → ${res.status}`);
|
||
return res.json();
|
||
}
|
||
|
||
// ─── Node content decode ──────────────────────────────────
|
||
function decodeContent(raw) {
|
||
if (!raw) return '';
|
||
if (typeof raw === 'string') return raw;
|
||
if (Array.isArray(raw)) {
|
||
try { return new TextDecoder().decode(new Uint8Array(raw)); } catch { return String(raw); }
|
||
}
|
||
return String(raw);
|
||
}
|
||
|
||
// ─── Normalize engram node → studio node ─────────────────
|
||
function normalizeNode(n) {
|
||
const content = decodeContent(n.content);
|
||
const tier = n.tier || 'Working';
|
||
const type = n.node_type || 'Memory';
|
||
const idStr = n.id || '';
|
||
return {
|
||
id: idStr,
|
||
type,
|
||
tier,
|
||
content,
|
||
importance: n.importance || 0.5,
|
||
activationCount: n.activation_count || 1,
|
||
lastActivated: n.last_activated || Date.now(),
|
||
salience: n.salience || n.importance || 0.5,
|
||
embedding: null,
|
||
};
|
||
}
|
||
|
||
// ─── Random embedding for local graph physics ─────────────
|
||
function randomEmbedding(dim = 8) {
|
||
const v = Array.from({length: dim}, () => (Math.random() * 2 - 1));
|
||
const mag = Math.sqrt(v.reduce((s, x) => s + x*x, 0));
|
||
return v.map(x => x / mag);
|
||
}
|
||
|
||
function randomEngramEmbedding(dim = 384) {
|
||
const v = Array.from({length: dim}, () => (Math.random() * 2 - 1));
|
||
const mag = Math.sqrt(v.reduce((s, x) => s + x*x, 0));
|
||
return v.map(x => x / mag);
|
||
}
|
||
|
||
// relation type colors
|
||
const RELATION_COLORS = {
|
||
exemplifies: '#8b5cf6',
|
||
references: '#38bdf8',
|
||
involves: '#f59e0b',
|
||
implies: '#8b5cf6',
|
||
uses: '#14b8a6',
|
||
created_by: '#f59e0b',
|
||
implements: '#14b8a6',
|
||
follows: '#38bdf8',
|
||
core_of: '#8b5cf6',
|
||
relates_to: '#38bdf8',
|
||
validates: '#4ade80',
|
||
motivates: '#f59e0b',
|
||
solved_by: '#14b8a6',
|
||
guides: '#14b8a6',
|
||
precedes: '#6b7280',
|
||
about: '#38bdf8',
|
||
builds: '#4ade80',
|
||
governed_by: '#ef4444',
|
||
defines: '#8b5cf6',
|
||
mechanism_of: '#8b5cf6',
|
||
documents: '#f59e0b',
|
||
authored_by: '#f59e0b',
|
||
owns: '#ef4444',
|
||
Associated: '#38bdf8',
|
||
Causal: '#f59e0b',
|
||
Temporal: '#6b7280',
|
||
Hierarchical: '#8b5cf6',
|
||
Contradicts: '#ef4444',
|
||
};
|
||
|
||
const TIER_COLORS = {
|
||
Working: '#ef4444',
|
||
Episodic: '#f59e0b',
|
||
Semantic: '#8b5cf6',
|
||
Procedural: '#14b8a6',
|
||
};
|
||
|
||
const TIER_GLOW = {
|
||
Working: 'rgba(239,68,68,',
|
||
Episodic: 'rgba(245,158,11,',
|
||
Semantic: 'rgba(139,92,246,',
|
||
Procedural: 'rgba(20,184,166,',
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// DB STATE — backed by engram-server
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
let DB = {
|
||
nodes: [],
|
||
edges: [],
|
||
};
|
||
|
||
function getNode(id) { return DB.nodes.find(n => n.id === id); }
|
||
|
||
function computeSalience(importance, lastActivatedMs, activationCount) {
|
||
const daysSince = (Date.now() - lastActivatedMs) / 86400000;
|
||
return importance * (1 / (1 + daysSince)) * Math.log(activationCount + 1);
|
||
}
|
||
|
||
function cosineSim(a, b) {
|
||
let dot = 0;
|
||
for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
|
||
return Math.max(0, dot);
|
||
}
|
||
|
||
// ─── Load nodes from engram-server ───────────────────────
|
||
async function loadEngramData() {
|
||
try {
|
||
// Get stats first
|
||
const stats = await engramFetch('/stats');
|
||
document.getElementById('stat-nodes').textContent = stats.nodes || 0;
|
||
document.getElementById('stat-edges').textContent = stats.edges || 0;
|
||
document.getElementById('mode-badge').textContent = 'LIVE';
|
||
|
||
// Load all nodes via list endpoint (faster than embedding search)
|
||
const rawNodes = await engramFetch('/nodes/list');
|
||
DB.nodes = rawNodes.map(normalizeNode);
|
||
DB.nodes.forEach(n => { n.embedding = randomEmbedding(); });
|
||
|
||
// Load edges for each node
|
||
DB.edges = [];
|
||
const edgeSet = new Set();
|
||
for (const n of DB.nodes.slice(0, 30)) { // cap to avoid hammering server
|
||
try {
|
||
const edges = await engramFetch(`/nodes/${n.id}/edges`);
|
||
for (const e of (Array.isArray(edges) ? edges : [])) {
|
||
const key = `${e.from_id}-${e.to_id}`;
|
||
if (!edgeSet.has(key)) {
|
||
edgeSet.add(key);
|
||
DB.edges.push({
|
||
from: e.from_id,
|
||
to: e.to_id,
|
||
relation: e.relation || 'Associated',
|
||
weight: e.weight || 0.5,
|
||
});
|
||
}
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
updateStats();
|
||
renderNodesTable();
|
||
initGraph();
|
||
|
||
if (DB.nodes.length === 0) {
|
||
showToast('No nodes in engram-server — add some!', 'info');
|
||
consolePrint('system', `Connected to engram-server. Database is empty.\nUse "add node {...}" to create nodes.`, 'info');
|
||
} else {
|
||
consolePrint('system', `Connected to engram-server.\nLoaded ${DB.nodes.length} nodes, ${DB.edges.length} edges.`, 'info');
|
||
}
|
||
} catch (err) {
|
||
document.getElementById('mode-badge').textContent = 'OFFLINE';
|
||
console.error('Failed to connect to engram-server:', err);
|
||
// Fall back to empty state — still show UI
|
||
updateStats();
|
||
renderNodesTable();
|
||
initGraph();
|
||
consolePrint('system', `Could not connect to engram-server at ${ENGRAM_URL}.\nError: ${err.message}\nMake sure the server is running.`, 'error');
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// SPREADING ACTIVATION — local fallback for graph animation
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
function activateLocal(seedIds, queryEmbedding, maxDepth = 3, limit = 10) {
|
||
const visited = new Map();
|
||
const results = [];
|
||
const adj = new Map();
|
||
DB.nodes.forEach(n => adj.set(n.id, []));
|
||
DB.edges.forEach(e => {
|
||
adj.get(e.from)?.push({ to: e.to, weight: e.weight, relation: e.relation });
|
||
adj.get(e.to)?.push({ to: e.from, weight: e.weight * 0.7, relation: e.relation });
|
||
});
|
||
seedIds.forEach(id => {
|
||
if (getNode(id)) { visited.set(id, 1.0); }
|
||
});
|
||
const pq = seedIds.map(id => ({ id, strength: 1.0, depth: 0 }));
|
||
while (pq.length > 0) {
|
||
pq.sort((a, b) => b.strength - a.strength);
|
||
const { id, strength, depth } = pq.shift();
|
||
const node = getNode(id);
|
||
if (!node) continue;
|
||
results.push({ id, strength, node });
|
||
if (depth >= maxDepth) continue;
|
||
for (const { to, weight } of (adj.get(id) || [])) {
|
||
const target = getNode(to);
|
||
if (!target) continue;
|
||
const sal = computeSalience(target.importance, target.lastActivated, target.activationCount);
|
||
const newStrength = strength * weight * Math.min(sal, 2.0);
|
||
if (newStrength < 0.01) continue;
|
||
const existing = visited.get(to);
|
||
if (existing === undefined || newStrength > existing) {
|
||
visited.set(to, newStrength);
|
||
pq.push({ id: to, strength: newStrength, depth: depth + 1 });
|
||
}
|
||
}
|
||
}
|
||
return results
|
||
.sort((a, b) => b.strength - a.strength)
|
||
.filter((r, i, arr) => arr.findIndex(x => x.id === r.id) === i)
|
||
.slice(0, limit);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// GRAPH ENGINE
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
const canvas = document.getElementById('graph-canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
let gNodes = [];
|
||
let gEdges = [];
|
||
let hoveredNode = null;
|
||
let selectedNode = null;
|
||
let isDragging = false;
|
||
let dragNode = null;
|
||
let dragOffX = 0, dragOffY = 0;
|
||
let panX = 0, panY = 0;
|
||
let panStartX = 0, panStartY = 0;
|
||
let isPanning = false;
|
||
let zoom = 1;
|
||
|
||
// Particles for activation animation
|
||
let particles = [];
|
||
// Activation wave
|
||
let activationWave = null;
|
||
|
||
function initGraph() {
|
||
gNodes = DB.nodes.map((n, i) => {
|
||
const angle = (i / DB.nodes.length) * Math.PI * 2;
|
||
const r = 200 + Math.random() * 80;
|
||
return {
|
||
...n,
|
||
x: Math.cos(angle) * r,
|
||
y: Math.sin(angle) * r,
|
||
vx: 0, vy: 0,
|
||
radius: 0,
|
||
};
|
||
});
|
||
|
||
gEdges = DB.edges.map(e => ({...e}));
|
||
|
||
resizeCanvas();
|
||
requestAnimationFrame(graphLoop);
|
||
}
|
||
|
||
function resizeCanvas() {
|
||
const panel = document.getElementById('graph-panel');
|
||
canvas.width = panel.clientWidth * devicePixelRatio;
|
||
canvas.height = panel.clientHeight * devicePixelRatio;
|
||
canvas.style.width = panel.clientWidth + 'px';
|
||
canvas.style.height = panel.clientHeight + 'px';
|
||
}
|
||
|
||
function findGNode(id) { return gNodes.find(n => n.id === id); }
|
||
|
||
// Force-directed layout
|
||
function applyForces() {
|
||
const k = 120;
|
||
const repulse = 8000;
|
||
|
||
// Repulsion
|
||
for (let i = 0; i < gNodes.length; i++) {
|
||
for (let j = i + 1; j < gNodes.length; j++) {
|
||
const a = gNodes[i], b = gNodes[j];
|
||
const dx = b.x - a.x, dy = b.y - a.y;
|
||
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
|
||
const force = repulse / (dist * dist);
|
||
const fx = (dx / dist) * force;
|
||
const fy = (dy / dist) * force;
|
||
a.vx -= fx; a.vy -= fy;
|
||
b.vx += fx; b.vy += fy;
|
||
}
|
||
}
|
||
|
||
// Attraction along edges
|
||
gEdges.forEach(e => {
|
||
const a = findGNode(e.from), b = findGNode(e.to);
|
||
if (!a || !b) return;
|
||
const dx = b.x - a.x, dy = b.y - a.y;
|
||
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
|
||
const naturalLen = k / (e.weight + 0.1);
|
||
const force = (dist - naturalLen) * 0.015;
|
||
const fx = (dx / dist) * force;
|
||
const fy = (dy / dist) * force;
|
||
a.vx += fx; a.vy += fy;
|
||
b.vx -= fx; b.vy -= fy;
|
||
});
|
||
|
||
// Center gravity
|
||
gNodes.forEach(n => {
|
||
n.vx -= n.x * 0.002;
|
||
n.vy -= n.y * 0.002;
|
||
});
|
||
|
||
// Integrate + dampen
|
||
gNodes.forEach(n => {
|
||
if (n === dragNode) return;
|
||
n.vx *= 0.85;
|
||
n.vy *= 0.85;
|
||
n.x += n.vx;
|
||
n.y += n.vy;
|
||
});
|
||
}
|
||
|
||
let frameCount = 0;
|
||
let activatedIds = new Set();
|
||
|
||
function graphLoop() {
|
||
frameCount++;
|
||
|
||
// Run simulation for first 300 frames, then slow down
|
||
if (frameCount < 300 || frameCount % 2 === 0) {
|
||
applyForces();
|
||
}
|
||
|
||
drawGraph();
|
||
updateParticles();
|
||
requestAnimationFrame(graphLoop);
|
||
}
|
||
|
||
function worldToScreen(x, y) {
|
||
const cx = canvas.width / devicePixelRatio / 2;
|
||
const cy = canvas.height / devicePixelRatio / 2;
|
||
return [
|
||
(x + panX) * zoom + cx,
|
||
(y + panY) * zoom + cy,
|
||
];
|
||
}
|
||
|
||
function screenToWorld(sx, sy) {
|
||
const cx = canvas.width / devicePixelRatio / 2;
|
||
const cy = canvas.height / devicePixelRatio / 2;
|
||
return [
|
||
(sx - cx) / zoom - panX,
|
||
(sy - cy) / zoom - panY,
|
||
];
|
||
}
|
||
|
||
function drawGraph() {
|
||
const W = canvas.width / devicePixelRatio;
|
||
const H = canvas.height / devicePixelRatio;
|
||
ctx.save();
|
||
ctx.scale(devicePixelRatio, devicePixelRatio);
|
||
|
||
// Background
|
||
ctx.fillStyle = '#080b0f';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// Subtle grid
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.02)';
|
||
ctx.lineWidth = 1;
|
||
const gridSize = 60 * zoom;
|
||
const offX = (panX * zoom + W/2) % gridSize;
|
||
const offY = (panY * zoom + H/2) % gridSize;
|
||
for (let x = offX; x < W; x += gridSize) {
|
||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
||
}
|
||
for (let y = offY; y < H; y += gridSize) {
|
||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
||
}
|
||
|
||
ctx.save();
|
||
const cx = W / 2, cy = H / 2;
|
||
ctx.translate(cx + panX * zoom, cy + panY * zoom);
|
||
ctx.scale(zoom, zoom);
|
||
|
||
// Draw edges
|
||
gEdges.forEach(e => {
|
||
const a = findGNode(e.from), b = findGNode(e.to);
|
||
if (!a || !b) return;
|
||
|
||
const isActive = activatedIds.has(e.from) && activatedIds.has(e.to);
|
||
const color = RELATION_COLORS[e.relation] || '#38bdf8';
|
||
const alpha = isActive ? 0.7 : 0.18;
|
||
const lw = e.weight * 2.5;
|
||
|
||
ctx.save();
|
||
ctx.globalAlpha = alpha;
|
||
if (isActive) {
|
||
ctx.shadowColor = color;
|
||
ctx.shadowBlur = 8;
|
||
}
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = isActive ? lw * 1.5 : lw;
|
||
ctx.beginPath();
|
||
ctx.moveTo(a.x, a.y);
|
||
ctx.lineTo(b.x, b.y);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
});
|
||
|
||
// Draw particles
|
||
particles.forEach(p => {
|
||
ctx.save();
|
||
ctx.globalAlpha = p.alpha;
|
||
ctx.fillStyle = p.color;
|
||
ctx.shadowColor = p.color;
|
||
ctx.shadowBlur = 6;
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
});
|
||
|
||
// Draw nodes
|
||
gNodes.forEach(n => {
|
||
const sal = computeSalience(n.importance, n.lastActivated, n.activationCount);
|
||
const baseR = 8 + sal * 10;
|
||
// Animate radius on spawn
|
||
if (n.spawning) {
|
||
n.spawnT = (n.spawnT || 0) + 0.08;
|
||
n.radius = baseR * Math.min(1, n.spawnT);
|
||
if (n.spawnT >= 1) n.spawning = false;
|
||
} else {
|
||
n.radius = baseR;
|
||
}
|
||
|
||
const r = n.radius;
|
||
const color = TIER_COLORS[n.tier] || '#fff';
|
||
const glow = TIER_GLOW[n.tier] || 'rgba(255,255,255,';
|
||
const isSelected = selectedNode?.id === n.id;
|
||
const isHovered = hoveredNode?.id === n.id;
|
||
const isActivated = activatedIds.has(n.id);
|
||
|
||
// Outer glow
|
||
if (isSelected || isActivated) {
|
||
const glowR = r + (isActivated ? 12 : 8);
|
||
const grad = ctx.createRadialGradient(n.x, n.y, r, n.x, n.y, glowR + 8);
|
||
grad.addColorStop(0, glow + '0.4)');
|
||
grad.addColorStop(1, glow + '0)');
|
||
ctx.fillStyle = grad;
|
||
ctx.beginPath();
|
||
ctx.arc(n.x, n.y, glowR + 8, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
|
||
// Salience ambient glow
|
||
const ambientGlowR = r + sal * 8;
|
||
const ambGrad = ctx.createRadialGradient(n.x, n.y, r * 0.5, n.x, n.y, ambientGlowR);
|
||
ambGrad.addColorStop(0, glow + (0.15 + sal * 0.1) + ')');
|
||
ambGrad.addColorStop(1, glow + '0)');
|
||
ctx.fillStyle = ambGrad;
|
||
ctx.beginPath();
|
||
ctx.arc(n.x, n.y, ambientGlowR, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// Pulse ring for activated
|
||
if (isActivated) {
|
||
const t = (frameCount * 0.04) % 1;
|
||
const ringR = r + t * 20;
|
||
ctx.save();
|
||
ctx.globalAlpha = (1 - t) * 0.5;
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
ctx.arc(n.x, n.y, ringR, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
// Node circle
|
||
ctx.save();
|
||
if (isHovered || isSelected) {
|
||
ctx.shadowColor = color;
|
||
ctx.shadowBlur = 16;
|
||
}
|
||
|
||
// Fill gradient
|
||
const fillGrad = ctx.createRadialGradient(n.x - r*0.3, n.y - r*0.3, 0, n.x, n.y, r);
|
||
fillGrad.addColorStop(0, lightenColor(color, 40));
|
||
fillGrad.addColorStop(1, color);
|
||
ctx.fillStyle = fillGrad;
|
||
ctx.beginPath();
|
||
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// Border — dashed for remote peer nodes
|
||
const isRemote = !!(n.fromPeer);
|
||
if (isRemote) {
|
||
ctx.setLineDash([3, 3]);
|
||
ctx.strokeStyle = 'rgba(56,189,248,0.7)'; // accent color for remote nodes
|
||
ctx.lineWidth = 1.5;
|
||
ctx.globalAlpha = 0.9;
|
||
} else {
|
||
ctx.setLineDash([]);
|
||
ctx.strokeStyle = isSelected ? '#fff' : lightenColor(color, 60);
|
||
ctx.lineWidth = isSelected ? 2 : 1;
|
||
ctx.globalAlpha = 0.6;
|
||
}
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.restore();
|
||
|
||
// Label
|
||
if (isHovered || isSelected || r > 14) {
|
||
ctx.save();
|
||
ctx.font = `${Math.min(11, r * 0.7)}px DM Mono, monospace`;
|
||
ctx.fillStyle = 'rgba(255,255,255,0.9)';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.shadowColor = 'rgba(0,0,0,0.8)';
|
||
ctx.shadowBlur = 4;
|
||
ctx.fillText(n.id, n.x, n.y);
|
||
ctx.restore();
|
||
|
||
if (isHovered || isSelected) {
|
||
// Content label below
|
||
ctx.save();
|
||
ctx.font = '9px Syne, sans-serif';
|
||
ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'top';
|
||
const label = n.content.slice(0, 35) + (n.content.length > 35 ? '…' : '');
|
||
ctx.fillText(label, n.x, n.y + r + 5);
|
||
ctx.restore();
|
||
|
||
// Peer name label for remote nodes
|
||
if (isRemote && n.peerName) {
|
||
ctx.save();
|
||
ctx.font = '8px DM Mono, monospace';
|
||
ctx.fillStyle = 'rgba(56,189,248,0.85)';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'top';
|
||
ctx.fillText(`[${n.peerName}]`, n.x, n.y + r + 16);
|
||
ctx.restore();
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
ctx.restore();
|
||
ctx.restore();
|
||
}
|
||
|
||
function lightenColor(hex, amount) {
|
||
const num = parseInt(hex.slice(1), 16);
|
||
const r = Math.min(255, (num >> 16) + amount);
|
||
const g = Math.min(255, ((num >> 8) & 0xff) + amount);
|
||
const b = Math.min(255, (num & 0xff) + amount);
|
||
return `rgb(${r},${g},${b})`;
|
||
}
|
||
|
||
// Particles
|
||
function spawnParticlesOnEdge(fromNode, toNode, color) {
|
||
const count = 5;
|
||
for (let i = 0; i < count; i++) {
|
||
const t = i / count;
|
||
particles.push({
|
||
x: fromNode.x + (toNode.x - fromNode.x) * t,
|
||
y: fromNode.y + (toNode.y - fromNode.y) * t,
|
||
tx: toNode.x, ty: toNode.y,
|
||
progress: t,
|
||
speed: 0.015 + Math.random() * 0.01,
|
||
color,
|
||
r: 2 + Math.random() * 2,
|
||
alpha: 0.8,
|
||
});
|
||
}
|
||
}
|
||
|
||
function updateParticles() {
|
||
particles = particles.filter(p => {
|
||
p.progress += p.speed;
|
||
const fx = p.tx - (p.tx - p.x) / (1 - (p.progress - p.speed));
|
||
// Linear interpolate from spawn point
|
||
const t = p.progress;
|
||
p.x = p.x + (p.tx - p.x) * p.speed * 3;
|
||
p.y = p.y + (p.ty - p.y) * p.speed * 3;
|
||
p.alpha -= 0.008;
|
||
return p.alpha > 0 && p.progress < 1.2;
|
||
});
|
||
}
|
||
|
||
// Activation animation
|
||
async function animateActivation(results) {
|
||
activatedIds.clear();
|
||
|
||
// Staggered reveal by depth
|
||
for (let i = 0; i < results.length; i++) {
|
||
const r = results[i];
|
||
activatedIds.add(r.id);
|
||
|
||
// Spawn particles on connecting edges
|
||
const connectedEdges = gEdges.filter(e => e.to === r.id || e.from === r.id);
|
||
connectedEdges.forEach(e => {
|
||
const a = findGNode(e.from), b = findGNode(e.to);
|
||
if (a && b) {
|
||
const color = RELATION_COLORS[e.relation] || '#38bdf8';
|
||
spawnParticlesOnEdge(a, b, color);
|
||
}
|
||
});
|
||
|
||
await sleep(120);
|
||
}
|
||
}
|
||
|
||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||
|
||
// Mouse interaction
|
||
function getNodeAtScreen(sx, sy) {
|
||
const [wx, wy] = screenToWorld(sx, sy);
|
||
for (let i = gNodes.length - 1; i >= 0; i--) {
|
||
const n = gNodes[i];
|
||
const dx = n.x - wx, dy = n.y - wy;
|
||
if (dx*dx + dy*dy <= n.radius * n.radius) return n;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
canvas.addEventListener('mousemove', e => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const sx = e.clientX - rect.left;
|
||
const sy = e.clientY - rect.top;
|
||
|
||
if (isDragging && dragNode) {
|
||
const [wx, wy] = screenToWorld(sx, sy);
|
||
dragNode.x = wx + dragOffX;
|
||
dragNode.y = wy + dragOffY;
|
||
dragNode.vx = 0; dragNode.vy = 0;
|
||
return;
|
||
}
|
||
|
||
if (isPanning) {
|
||
panX += (e.clientX - panStartX) / zoom;
|
||
panY += (e.clientY - panStartY) / zoom;
|
||
panStartX = e.clientX;
|
||
panStartY = e.clientY;
|
||
return;
|
||
}
|
||
|
||
hoveredNode = getNodeAtScreen(sx, sy);
|
||
canvas.style.cursor = hoveredNode ? 'pointer' : 'grab';
|
||
});
|
||
|
||
canvas.addEventListener('mousedown', e => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const sx = e.clientX - rect.left;
|
||
const sy = e.clientY - rect.top;
|
||
const n = getNodeAtScreen(sx, sy);
|
||
|
||
if (n) {
|
||
isDragging = true;
|
||
dragNode = n;
|
||
const [wx, wy] = screenToWorld(sx, sy);
|
||
dragOffX = n.x - wx;
|
||
dragOffY = n.y - wy;
|
||
} else {
|
||
isPanning = true;
|
||
panStartX = e.clientX;
|
||
panStartY = e.clientY;
|
||
}
|
||
});
|
||
|
||
canvas.addEventListener('mouseup', e => {
|
||
if (isDragging && dragNode) {
|
||
// Check if it was a click (not drag)
|
||
const rect = canvas.getBoundingClientRect();
|
||
selectNode(dragNode);
|
||
}
|
||
isDragging = false;
|
||
dragNode = null;
|
||
isPanning = false;
|
||
});
|
||
|
||
canvas.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
||
zoom = Math.max(0.2, Math.min(4, zoom * factor));
|
||
}, { passive: false });
|
||
|
||
function selectNode(n) {
|
||
selectedNode = n;
|
||
showNodeDetail(n);
|
||
}
|
||
|
||
function showNodeDetail(n) {
|
||
document.getElementById('node-detail').style.display = 'block';
|
||
document.getElementById('nd-id').textContent = n.id + ' · ' + n.type;
|
||
|
||
const sal = computeSalience(n.importance, n.lastActivated, n.activationCount);
|
||
const badges = document.getElementById('nd-badges');
|
||
badges.innerHTML = `
|
||
<span class="nd-badge" style="background:${TIER_GLOW[n.tier]}0.15);color:${TIER_COLORS[n.tier]};border:1px solid ${TIER_GLOW[n.tier]}0.3)">${n.tier}</span>
|
||
<span class="nd-badge" style="background:rgba(56,189,248,0.1);color:#38bdf8;border:1px solid rgba(56,189,248,0.2)">${n.type}</span>
|
||
`;
|
||
|
||
document.getElementById('nd-content').textContent = n.content;
|
||
document.getElementById('nd-stats').innerHTML = `
|
||
<div class="nd-stat"><div class="nd-stat-val">${sal.toFixed(3)}</div><div class="nd-stat-key">Salience</div></div>
|
||
<div class="nd-stat"><div class="nd-stat-val">${n.activationCount}</div><div class="nd-stat-key">Activations</div></div>
|
||
<div class="nd-stat"><div class="nd-stat-val">${n.importance.toFixed(2)}</div><div class="nd-stat-key">Importance</div></div>
|
||
`;
|
||
}
|
||
|
||
function closeNodeDetail() {
|
||
document.getElementById('node-detail').style.display = 'none';
|
||
selectedNode = null;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// SIDEBAR STATS
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
function updateStats() {
|
||
const counts = { Working: 0, Episodic: 0, Semantic: 0, Procedural: 0 };
|
||
DB.nodes.forEach(n => counts[n.tier] = (counts[n.tier] || 0) + 1);
|
||
const total = DB.nodes.length;
|
||
|
||
document.getElementById('stat-nodes').textContent = total;
|
||
document.getElementById('stat-edges').textContent = DB.edges.length;
|
||
|
||
['Working','Episodic','Semantic','Procedural'].forEach(t => {
|
||
document.getElementById('cnt-' + t.toLowerCase()).textContent = counts[t] || 0;
|
||
document.getElementById('bar-' + t.toLowerCase()).style.width = total ? ((counts[t]||0)/total*100)+'%' : '0%';
|
||
});
|
||
|
||
// Seed select
|
||
const sel = document.getElementById('seed-select');
|
||
const prevSelected = Array.from(sel.selectedOptions).map(o => o.value);
|
||
sel.innerHTML = DB.nodes.map(n =>
|
||
`<option value="${n.id}" ${prevSelected.includes(n.id)?'selected':''}>${n.id} — ${n.content.slice(0,30)}...</option>`
|
||
).join('');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// ACTIONS — real API calls
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
async function runActivate() {
|
||
const seedSel = document.getElementById('seed-select');
|
||
let seedIds = Array.from(seedSel.selectedOptions).map(o => o.value);
|
||
if (seedIds.length === 0 && DB.nodes.length > 0) {
|
||
const sorted = [...DB.nodes].sort((a, b) =>
|
||
computeSalience(b.importance, b.lastActivated, b.activationCount) -
|
||
computeSalience(a.importance, a.lastActivated, a.activationCount)
|
||
);
|
||
seedIds = [sorted[0].id];
|
||
}
|
||
if (seedIds.length === 0) { showToast('No nodes to activate', 'info'); return; }
|
||
|
||
try {
|
||
// Use local activation for graph animation (server requires UUIDs + real embeddings)
|
||
const localResults = activateLocal(seedIds, null, 3, 12);
|
||
animateActivation(localResults);
|
||
|
||
// Try server activation too
|
||
const embedding = randomEngramEmbedding();
|
||
const uuids = seedIds.filter(id => /^[0-9a-f-]{36}$/.test(id));
|
||
if (uuids.length > 0) {
|
||
const data = await engramFetch('/activate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ seeds: uuids, query_embedding: embedding, max_depth: 3, limit: 12 }),
|
||
});
|
||
const serverResults = (data.results || []).map(r => ({
|
||
id: r.node.id,
|
||
strength: r.activation_strength,
|
||
node: normalizeNode(r.node),
|
||
}));
|
||
// Merge into DB
|
||
for (const r of serverResults) {
|
||
if (!getNode(r.id)) {
|
||
DB.nodes.push({ ...r.node, embedding: randomEmbedding() });
|
||
}
|
||
}
|
||
const list = document.getElementById('results-list');
|
||
list.innerHTML = serverResults.map(r => {
|
||
const n = getNode(r.id) || r.node;
|
||
return `<div class="result-item tier-${n.tier}" onclick="selectNode(findGNode('${escHtml(n.id)}'))">
|
||
<div class="result-header">
|
||
<span class="result-id">${escHtml(n.id.slice(0,12))} · ${n.type}</span>
|
||
<span class="result-score">${r.strength.toFixed(3)}</span>
|
||
</div>
|
||
<div class="result-content">${escHtml(n.content.slice(0,80))}</div>
|
||
</div>`;
|
||
}).join('') || '<div class="empty-state">No results</div>';
|
||
showToast(`Server activation: ${serverResults.length} nodes`, 'success');
|
||
} else {
|
||
// Show local results
|
||
const list = document.getElementById('results-list');
|
||
list.innerHTML = localResults.map(r => {
|
||
const n = r.node;
|
||
return `<div class="result-item tier-${n.tier}" onclick="selectNode(findGNode('${escHtml(n.id)}'))">
|
||
<div class="result-header">
|
||
<span class="result-id">${escHtml(n.id)} · ${n.type}</span>
|
||
<span class="result-score">${r.strength.toFixed(3)}</span>
|
||
</div>
|
||
<div class="result-content">${escHtml(n.content.slice(0,80))}</div>
|
||
</div>`;
|
||
}).join('') || '<div class="empty-state">No results</div>';
|
||
showToast(`Activated ${localResults.length} nodes`, 'success');
|
||
}
|
||
updateStats();
|
||
} catch (err) {
|
||
showToast('Activation error: ' + err.message, 'info');
|
||
}
|
||
}
|
||
|
||
async function runSearch() {
|
||
const q = document.getElementById('query-input').value.trim();
|
||
if (!q) { showToast('Enter a query first', 'info'); return; }
|
||
|
||
// Primary: text search against in-memory nodes (fast, meaningful)
|
||
const q2 = q.toLowerCase();
|
||
const words = q2.split(/\s+/).filter(Boolean);
|
||
const scored = DB.nodes
|
||
.map(n => {
|
||
const c = n.content.toLowerCase();
|
||
// Score: exact phrase match > all words > any word
|
||
let score = 0;
|
||
if (c.includes(q2)) score = 1.0;
|
||
else {
|
||
const hits = words.filter(w => c.includes(w)).length;
|
||
score = hits / words.length;
|
||
}
|
||
return { n, score };
|
||
})
|
||
.filter(x => x.score > 0)
|
||
.sort((a, b) => b.score - a.score)
|
||
.slice(0, 20);
|
||
|
||
activatedIds = new Set(scored.map(x => x.n.id));
|
||
const list = document.getElementById('results-list');
|
||
list.innerHTML = scored.map(({ n, score }) =>
|
||
`<div class="result-item tier-${n.tier}" onclick="selectNode(findGNode('${escHtml(n.id)}'))">
|
||
<div class="result-header">
|
||
<span class="result-id">${escHtml(n.id.slice(0,12))}</span>
|
||
<span class="result-score">${score.toFixed(2)}</span>
|
||
</div>
|
||
<div class="result-content">${escHtml(n.content.slice(0,80))}</div>
|
||
</div>`
|
||
).join('') || '<div class="empty-state">No matches for "${escHtml(q)}"</div>';
|
||
updateStats();
|
||
showToast(`Found ${scored.length} nodes`, 'info');
|
||
|
||
// If no local results, try to load more from server (may find nodes not yet in memory)
|
||
if (scored.length === 0) {
|
||
try {
|
||
const allNodes = await engramFetch('/nodes/list');
|
||
for (const raw of allNodes) {
|
||
const n = normalizeNode(raw);
|
||
if (!getNode(n.id)) { DB.nodes.push({ ...n, embedding: randomEmbedding() }); }
|
||
}
|
||
// Re-run local search with fresh data
|
||
const rescored = DB.nodes
|
||
.map(n => {
|
||
const c = n.content.toLowerCase();
|
||
const hits = words.filter(w => c.includes(w)).length;
|
||
return { n, score: hits / words.length };
|
||
})
|
||
.filter(x => x.score > 0)
|
||
.sort((a, b) => b.score - a.score)
|
||
.slice(0, 20);
|
||
if (rescored.length > 0) {
|
||
activatedIds = new Set(rescored.map(x => x.n.id));
|
||
list.innerHTML = rescored.map(({ n, score }) =>
|
||
`<div class="result-item tier-${n.tier}" onclick="selectNode(findGNode('${escHtml(n.id)}'))">
|
||
<div class="result-header">
|
||
<span class="result-id">${escHtml(n.id.slice(0,12))}</span>
|
||
<span class="result-score">${score.toFixed(2)}</span>
|
||
</div>
|
||
<div class="result-content">${escHtml(n.content.slice(0,80))}</div>
|
||
</div>`
|
||
).join('');
|
||
updateStats();
|
||
showToast(`Found ${rescored.length} nodes (refreshed)`, 'info');
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
}
|
||
|
||
async function runDecay() {
|
||
try {
|
||
const data = await engramFetch('/decay', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ factor: 0.95 }),
|
||
});
|
||
// Reflect decay locally
|
||
DB.nodes.forEach(n => { n.importance = Math.max(0.1, n.importance * 0.95); });
|
||
updateStats();
|
||
renderNodesTable();
|
||
showToast(`Decay applied — ${data.nodes_updated || 'all'} nodes updated`, 'info');
|
||
} catch (err) {
|
||
DB.nodes.forEach(n => { n.importance = Math.max(0.1, n.importance * 0.95); });
|
||
updateStats();
|
||
renderNodesTable();
|
||
showToast('Decay applied (local)', 'info');
|
||
}
|
||
}
|
||
|
||
async function runConsolidate() {
|
||
try {
|
||
const data = await engramFetch('/consolidate', { method: 'POST' });
|
||
const promoted = data.promoted || 0;
|
||
// Reload nodes to get updated tiers
|
||
await loadEngramData();
|
||
if (promoted > 0) {
|
||
showToast(`Consolidated: ${promoted} node${promoted>1?'s':''} promoted Episodic → Semantic`, 'success');
|
||
} else {
|
||
showToast('No nodes met consolidation criteria', 'info');
|
||
}
|
||
} catch (err) {
|
||
showToast('Consolidate error: ' + err.message, 'info');
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// NODES TABLE
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
let sortCol = 'salience';
|
||
let sortDir = -1;
|
||
let filterTier = 'all';
|
||
let searchQuery = '';
|
||
|
||
function renderNodesTable() {
|
||
let nodes = [...DB.nodes];
|
||
|
||
// Filter
|
||
if (filterTier !== 'all') nodes = nodes.filter(n => n.tier === filterTier);
|
||
if (searchQuery) nodes = nodes.filter(n => n.content.toLowerCase().includes(searchQuery));
|
||
|
||
// Sort
|
||
nodes.sort((a, b) => {
|
||
let va = a[sortCol], vb = b[sortCol];
|
||
if (sortCol === 'salience') {
|
||
va = computeSalience(a.importance, a.lastActivated, a.activationCount);
|
||
vb = computeSalience(b.importance, b.lastActivated, b.activationCount);
|
||
}
|
||
if (typeof va === 'string') return sortDir * va.localeCompare(vb);
|
||
return sortDir * (va - vb);
|
||
});
|
||
|
||
const tbody = document.getElementById('nodes-tbody');
|
||
tbody.innerHTML = nodes.map(n => {
|
||
const sal = computeSalience(n.importance, n.lastActivated, n.activationCount);
|
||
const ago = timeAgo(n.lastActivated);
|
||
const color = TIER_COLORS[n.tier];
|
||
return `
|
||
<tr onclick="showNodePanel('${n.id}')">
|
||
<td class="mono" style="color:var(--accent)">${n.id}</td>
|
||
<td style="color:var(--text2)">${n.type}</td>
|
||
<td><span class="tier-pill" style="background:${TIER_GLOW[n.tier]}0.12);color:${color}">
|
||
<span style="width:5px;height:5px;border-radius:50%;background:${color};display:inline-block"></span>
|
||
${n.tier}
|
||
</span></td>
|
||
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text2)">${n.content}</td>
|
||
<td class="mono">${sal.toFixed(3)}</td>
|
||
<td class="mono">${n.activationCount}</td>
|
||
<td style="color:var(--text3)">${ago}</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
|
||
// Update sort arrows
|
||
document.querySelectorAll('th[data-col]').forEach(th => {
|
||
const arrow = th.querySelector('.sort-arrow');
|
||
if (!arrow) return;
|
||
th.classList.toggle('sorted', th.dataset.col === sortCol);
|
||
if (th.dataset.col === sortCol) {
|
||
arrow.textContent = sortDir === 1 ? '↑' : '↓';
|
||
} else {
|
||
arrow.textContent = '↕';
|
||
}
|
||
});
|
||
}
|
||
|
||
function timeAgo(ms) {
|
||
const diff = Date.now() - ms;
|
||
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';
|
||
return Math.floor(hrs / 24) + 'd ago';
|
||
}
|
||
|
||
document.querySelectorAll('th[data-col]').forEach(th => {
|
||
th.addEventListener('click', () => {
|
||
if (sortCol === th.dataset.col) sortDir *= -1;
|
||
else { sortCol = th.dataset.col; sortDir = -1; }
|
||
renderNodesTable();
|
||
});
|
||
});
|
||
|
||
document.getElementById('chip-filters').addEventListener('click', e => {
|
||
const chip = e.target.closest('.chip');
|
||
if (!chip) return;
|
||
document.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
|
||
chip.classList.add('active');
|
||
filterTier = chip.dataset.tier;
|
||
renderNodesTable();
|
||
});
|
||
|
||
document.getElementById('nodes-search').addEventListener('input', e => {
|
||
searchQuery = e.target.value.toLowerCase();
|
||
renderNodesTable();
|
||
});
|
||
|
||
function showNodePanel(id) {
|
||
const n = getNode(id);
|
||
if (!n) return;
|
||
const panel = document.getElementById('node-side-panel');
|
||
panel.classList.add('open');
|
||
|
||
document.getElementById('nsp-id').textContent = `${n.id} · ${n.type}`;
|
||
|
||
const color = TIER_COLORS[n.tier];
|
||
document.getElementById('nsp-badges').innerHTML = `
|
||
<div class="nd-badges">
|
||
<span class="nd-badge" style="background:${TIER_GLOW[n.tier]}0.15);color:${color};border:1px solid ${TIER_GLOW[n.tier]}0.3)">${n.tier}</span>
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById('nsp-content').textContent = n.content;
|
||
|
||
const sal = computeSalience(n.importance, n.lastActivated, n.activationCount);
|
||
document.getElementById('nsp-stats').innerHTML = `
|
||
<div class="nsp-stat"><div class="nsp-stat-val">${sal.toFixed(3)}</div><div class="nsp-stat-key">Salience</div></div>
|
||
<div class="nsp-stat"><div class="nsp-stat-val">${n.activationCount}</div><div class="nsp-stat-key">Activations</div></div>
|
||
<div class="nsp-stat"><div class="nsp-stat-val">${n.importance.toFixed(2)}</div><div class="nsp-stat-key">Importance</div></div>
|
||
`;
|
||
|
||
const edges = DB.edges.filter(e => e.from === id || e.to === id);
|
||
document.getElementById('nsp-edges-list').innerHTML = edges.map(e => {
|
||
const otherId = e.from === id ? e.to : e.from;
|
||
const direction = e.from === id ? '→' : '←';
|
||
return `
|
||
<div class="nsp-edge-item">
|
||
<span class="nsp-edge-rel">${e.relation}</span>
|
||
<span style="color:var(--text3)">${direction}</span>
|
||
<span class="nsp-edge-target">${otherId}</span>
|
||
<span class="nsp-edge-weight">w:${e.weight.toFixed(2)}</span>
|
||
</div>
|
||
`;
|
||
}).join('') || '<div class="empty-state">No connections</div>';
|
||
}
|
||
|
||
function closeNodePanel() {
|
||
document.getElementById('node-side-panel').classList.remove('open');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// TIMELINE
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
const tlCanvas = document.getElementById('timeline-canvas');
|
||
const tlCtx = tlCanvas.getContext('2d');
|
||
let tlTooltip = null;
|
||
let tlHovered = null;
|
||
|
||
function resizeTimeline() {
|
||
const panel = document.getElementById('panel-timeline');
|
||
tlCanvas.width = panel.clientWidth * devicePixelRatio;
|
||
tlCanvas.height = panel.clientHeight * devicePixelRatio;
|
||
tlCanvas.style.width = panel.clientWidth + 'px';
|
||
tlCanvas.style.height = panel.clientHeight + 'px';
|
||
drawTimeline();
|
||
}
|
||
|
||
function drawTimeline() {
|
||
const day = 86400000; // ms per day
|
||
const W = tlCanvas.width / devicePixelRatio;
|
||
const H = tlCanvas.height / devicePixelRatio;
|
||
tlCtx.save();
|
||
tlCtx.scale(devicePixelRatio, devicePixelRatio);
|
||
|
||
tlCtx.fillStyle = '#080b0f';
|
||
tlCtx.fillRect(0, 0, W, H);
|
||
|
||
const allNodes = DB.nodes;
|
||
const times = allNodes.map(n => n.lastActivated);
|
||
const minT = Math.min(...times) - day * 0.5;
|
||
const maxT = Math.max(...times) + day * 0.5;
|
||
|
||
const padL = 60, padR = 40, padT = 60, padB = 60;
|
||
const plotW = W - padL - padR;
|
||
const plotH = H - padT - padB;
|
||
|
||
const tiers = ['Working','Episodic','Semantic','Procedural'];
|
||
const tierY = {};
|
||
tiers.forEach((t, i) => {
|
||
tierY[t] = padT + (i + 0.5) * (plotH / tiers.length);
|
||
});
|
||
|
||
// Grid lines
|
||
tlCtx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||
tlCtx.lineWidth = 1;
|
||
tiers.forEach(t => {
|
||
tlCtx.beginPath();
|
||
tlCtx.moveTo(padL, tierY[t]);
|
||
tlCtx.lineTo(padL + plotW, tierY[t]);
|
||
tlCtx.stroke();
|
||
});
|
||
|
||
// X axis
|
||
tlCtx.strokeStyle = 'rgba(255,255,255,0.1)';
|
||
tlCtx.lineWidth = 1;
|
||
tlCtx.beginPath();
|
||
tlCtx.moveTo(padL, padT + plotH);
|
||
tlCtx.lineTo(padL + plotW, padT + plotH);
|
||
tlCtx.stroke();
|
||
|
||
// Tier labels
|
||
tiers.forEach(t => {
|
||
tlCtx.font = '10px Syne, sans-serif';
|
||
tlCtx.fillStyle = TIER_COLORS[t];
|
||
tlCtx.textAlign = 'right';
|
||
tlCtx.textBaseline = 'middle';
|
||
tlCtx.fillText(t, padL - 8, tierY[t]);
|
||
});
|
||
|
||
// Time labels
|
||
const dayRange = Math.ceil((maxT - minT) / day);
|
||
for (let d = 0; d <= dayRange; d++) {
|
||
const t = minT + d * day;
|
||
const x = padL + ((t - minT) / (maxT - minT)) * plotW;
|
||
tlCtx.font = '9px DM Mono, monospace';
|
||
tlCtx.fillStyle = 'rgba(255,255,255,0.2)';
|
||
tlCtx.textAlign = 'center';
|
||
tlCtx.textBaseline = 'top';
|
||
const label = d === 0 ? `${dayRange}d ago` : d === dayRange ? 'now' : `-${dayRange - d}d`;
|
||
tlCtx.fillText(label, x, padT + plotH + 8);
|
||
|
||
tlCtx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||
tlCtx.beginPath();
|
||
tlCtx.moveTo(x, padT);
|
||
tlCtx.lineTo(x, padT + plotH);
|
||
tlCtx.stroke();
|
||
}
|
||
|
||
// Nodes as dots
|
||
tlNodes = [];
|
||
allNodes.forEach(n => {
|
||
const x = padL + ((n.lastActivated - minT) / (maxT - minT)) * plotW;
|
||
const y = tierY[n.tier] || (H/2);
|
||
const sal = computeSalience(n.importance, n.lastActivated, n.activationCount);
|
||
const r = 5 + sal * 6;
|
||
const color = TIER_COLORS[n.tier];
|
||
const glow = TIER_GLOW[n.tier];
|
||
const isHov = tlHovered?.id === n.id;
|
||
|
||
tlNodes.push({ n, x, y, r });
|
||
|
||
// Glow
|
||
if (isHov) {
|
||
const grad = tlCtx.createRadialGradient(x, y, r, x, y, r + 12);
|
||
grad.addColorStop(0, glow + '0.5)');
|
||
grad.addColorStop(1, glow + '0)');
|
||
tlCtx.fillStyle = grad;
|
||
tlCtx.beginPath();
|
||
tlCtx.arc(x, y, r + 12, 0, Math.PI * 2);
|
||
tlCtx.fill();
|
||
}
|
||
|
||
tlCtx.save();
|
||
if (isHov) { tlCtx.shadowColor = color; tlCtx.shadowBlur = 10; }
|
||
tlCtx.fillStyle = color;
|
||
tlCtx.globalAlpha = 0.85;
|
||
tlCtx.beginPath();
|
||
tlCtx.arc(x, y, r, 0, Math.PI * 2);
|
||
tlCtx.fill();
|
||
tlCtx.restore();
|
||
|
||
if (isHov) {
|
||
// Tooltip
|
||
tlCtx.save();
|
||
const txt = n.id + ': ' + n.content.slice(0, 60) + '...';
|
||
tlCtx.font = '11px DM Mono, monospace';
|
||
const tw = tlCtx.measureText(txt).width;
|
||
const tx = Math.min(x - tw/2, W - tw - 10);
|
||
const ty = y - r - 30;
|
||
tlCtx.fillStyle = 'rgba(13,17,23,0.95)';
|
||
tlCtx.strokeStyle = 'rgba(255,255,255,0.12)';
|
||
tlCtx.lineWidth = 1;
|
||
roundRect(tlCtx, tx - 8, ty - 6, tw + 16, 22, 4);
|
||
tlCtx.fill();
|
||
tlCtx.stroke();
|
||
tlCtx.fillStyle = 'rgba(255,255,255,0.85)';
|
||
tlCtx.textAlign = 'left';
|
||
tlCtx.textBaseline = 'middle';
|
||
tlCtx.fillText(txt, tx, ty + 5);
|
||
tlCtx.restore();
|
||
}
|
||
});
|
||
|
||
tlCtx.restore();
|
||
}
|
||
|
||
let tlNodes = [];
|
||
|
||
function roundRect(ctx, x, y, w, h, r) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + r, y);
|
||
ctx.lineTo(x + w - r, y);
|
||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||
ctx.lineTo(x + w, y + h - r);
|
||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||
ctx.lineTo(x + r, y + h);
|
||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||
ctx.lineTo(x, y + r);
|
||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||
ctx.closePath();
|
||
}
|
||
|
||
tlCanvas.addEventListener('mousemove', e => {
|
||
const rect = tlCanvas.getBoundingClientRect();
|
||
const sx = e.clientX - rect.left;
|
||
const sy = e.clientY - rect.top;
|
||
tlHovered = null;
|
||
for (const {n, x, y, r} of tlNodes) {
|
||
const dx = sx - x, dy = sy - y;
|
||
if (dx*dx + dy*dy <= (r+4)*(r+4)) { tlHovered = n; break; }
|
||
}
|
||
drawTimeline();
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// CONSOLE
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
let cmdHistory = [];
|
||
let historyIdx = -1;
|
||
|
||
const consoleInput = document.getElementById('console-input');
|
||
const consoleOutput = document.getElementById('console-output');
|
||
|
||
function consolePrint(cmd, output, cls='info') {
|
||
const el = document.createElement('div');
|
||
el.className = 'console-entry';
|
||
el.innerHTML = `<div class="console-cmd">${escHtml(cmd)}</div><div class="console-out ${cls}">${output}</div>`;
|
||
consoleOutput.appendChild(el);
|
||
consoleOutput.scrollTop = consoleOutput.scrollHeight;
|
||
}
|
||
|
||
consoleInput.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') {
|
||
const cmd = consoleInput.value.trim();
|
||
if (!cmd) return;
|
||
cmdHistory.unshift(cmd);
|
||
historyIdx = -1;
|
||
consoleInput.value = '';
|
||
handleConsoleCmd(cmd);
|
||
}
|
||
if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
if (historyIdx < cmdHistory.length - 1) {
|
||
historyIdx++;
|
||
consoleInput.value = cmdHistory[historyIdx];
|
||
}
|
||
}
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
if (historyIdx > 0) {
|
||
historyIdx--;
|
||
consoleInput.value = cmdHistory[historyIdx];
|
||
} else {
|
||
historyIdx = -1;
|
||
consoleInput.value = '';
|
||
}
|
||
}
|
||
});
|
||
|
||
async function handleConsoleCmd(cmd) {
|
||
const parts = cmd.trim().split(/\s+/);
|
||
const verb = parts[0].toLowerCase();
|
||
|
||
if (verb === 'activate') {
|
||
const ids = parts.slice(1);
|
||
if (ids.length === 0) { consolePrint(cmd, 'Usage: activate <uuid> [...]', 'error'); return; }
|
||
try {
|
||
const embedding = randomEngramEmbedding();
|
||
const uuids = ids.filter(id => /^[0-9a-f-]{36}$/.test(id));
|
||
if (uuids.length > 0) {
|
||
const data = await engramFetch('/activate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ seeds: uuids, query_embedding: embedding, max_depth: 3, limit: 10 }),
|
||
});
|
||
const results = (data.results || []).map(r => ({
|
||
id: r.node.id,
|
||
strength: +r.activation_strength.toFixed(4),
|
||
tier: r.node.tier,
|
||
type: r.node.node_type,
|
||
hops: r.hops,
|
||
}));
|
||
// Animate local graph
|
||
const localResults = activateLocal(uuids, null, 3, 10);
|
||
animateActivation(localResults);
|
||
consolePrint(cmd, JSON.stringify(results, null, 2), 'success');
|
||
} else {
|
||
// Local IDs fallback
|
||
const results = activateLocal(ids, null, 3, 10);
|
||
animateActivation(results);
|
||
consolePrint(cmd, JSON.stringify(results.map(r => ({id:r.id,strength:+r.strength.toFixed(4),tier:r.node.tier})), null, 2), 'success');
|
||
}
|
||
} catch(err) {
|
||
consolePrint(cmd, 'Error: ' + err.message, 'error');
|
||
}
|
||
}
|
||
else if (verb === 'search') {
|
||
const q = parts.slice(1).join(' ');
|
||
if (!q) { consolePrint(cmd, 'Usage: search <query text>', 'error'); return; }
|
||
try {
|
||
const embedding = randomEngramEmbedding();
|
||
const data = await engramFetch('/search', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ embedding, limit: 20 }),
|
||
});
|
||
const results = (data.results || []).map(r => ({
|
||
id: r.node.id,
|
||
score: +r.score.toFixed(4),
|
||
tier: r.node.tier,
|
||
type: r.node.node_type,
|
||
content: decodeContent(r.node.content).slice(0, 60) + '...',
|
||
}));
|
||
consolePrint(cmd, JSON.stringify(results, null, 2), 'info');
|
||
} catch(err) {
|
||
const q2 = q.toLowerCase();
|
||
const matches = DB.nodes.filter(n => n.content.toLowerCase().includes(q2));
|
||
consolePrint(cmd, JSON.stringify(matches.map(n => ({id:n.id,tier:n.tier,type:n.type,content:n.content.slice(0,60)+'...'})), null, 2), 'info');
|
||
}
|
||
}
|
||
else if (verb === 'decay') {
|
||
try {
|
||
const data = await engramFetch('/decay', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ factor: 0.95 }),
|
||
});
|
||
DB.nodes.forEach(n => { n.importance = Math.max(0.1, n.importance * 0.95); });
|
||
updateStats();
|
||
consolePrint(cmd, JSON.stringify({ status:'ok', nodes_updated: data.nodes_updated }), 'success');
|
||
} catch(err) {
|
||
consolePrint(cmd, 'Error: ' + err.message, 'error');
|
||
}
|
||
}
|
||
else if (verb === 'consolidate') {
|
||
try {
|
||
const data = await engramFetch('/consolidate', { method: 'POST' });
|
||
await loadEngramData();
|
||
consolePrint(cmd, JSON.stringify({ status:'ok', promoted: data.promoted }), 'success');
|
||
} catch(err) {
|
||
consolePrint(cmd, 'Error: ' + err.message, 'error');
|
||
}
|
||
}
|
||
else if (verb === 'add' && parts[1] === 'node') {
|
||
try {
|
||
const jsonStr = cmd.slice(cmd.indexOf('{'));
|
||
const data = JSON.parse(jsonStr);
|
||
const id = await addNodeFromData(data);
|
||
consolePrint(cmd, JSON.stringify({status:'ok', id}), 'success');
|
||
} catch(err) {
|
||
consolePrint(cmd, 'Error: ' + err.message + '\nUsage: add node {"content":"...","type":"Concept","tier":"Semantic","importance":0.7}', 'error');
|
||
}
|
||
}
|
||
else if (verb === 'stats') {
|
||
try {
|
||
const data = await engramFetch('/stats');
|
||
consolePrint(cmd, JSON.stringify(data, null, 2), 'info');
|
||
} catch(err) {
|
||
consolePrint(cmd, JSON.stringify({nodes: DB.nodes.length, edges: DB.edges.length}), 'info');
|
||
}
|
||
}
|
||
else if (verb === 'get') {
|
||
const id = parts[1];
|
||
if (!id) { consolePrint(cmd, 'Usage: get <uuid>', 'error'); return; }
|
||
try {
|
||
const data = await engramFetch(`/nodes/${id}`);
|
||
const n = normalizeNode(data);
|
||
consolePrint(cmd, JSON.stringify({...n, content: n.content}, null, 2), 'info');
|
||
} catch(err) {
|
||
consolePrint(cmd, 'Error: ' + err.message, 'error');
|
||
}
|
||
}
|
||
else if (verb === 'nodes') {
|
||
consolePrint(cmd, JSON.stringify(DB.nodes.map(n => ({id:n.id,tier:n.tier,type:n.type,salience:+computeSalience(n.importance,n.lastActivated,n.activationCount).toFixed(3),content:n.content.slice(0,60)})), null, 2), 'info');
|
||
}
|
||
else if (verb === 'edges') {
|
||
consolePrint(cmd, JSON.stringify(DB.edges, null, 2), 'info');
|
||
}
|
||
else if (verb === 'reload') {
|
||
consolePrint(cmd, 'Reloading from engram-server...', 'info');
|
||
await loadEngramData();
|
||
consolePrint(cmd, JSON.stringify({status:'ok', nodes: DB.nodes.length, edges: DB.edges.length}), 'success');
|
||
}
|
||
else if (verb === 'help') {
|
||
consolePrint(cmd, `Commands:
|
||
activate <uuid> [...] — spread activation from seed nodes
|
||
search <query> — semantic search (random embedding proxy)
|
||
decay — apply salience decay (factor 0.95)
|
||
consolidate — promote Episodic → Semantic
|
||
add node <json> — add a new node
|
||
get <uuid> — get node by ID
|
||
stats — database statistics
|
||
nodes — list loaded nodes
|
||
edges — list loaded edges
|
||
reload — reload data from engram-server
|
||
help — this message`, 'info');
|
||
}
|
||
else {
|
||
consolePrint(cmd, `Unknown command: ${verb}. Type "help" for available commands.`, 'error');
|
||
}
|
||
}
|
||
|
||
// Welcome message — real data loaded async
|
||
consolePrint('system', `Engram Studio v0.2.0 — Connecting to ${ENGRAM_URL}...
|
||
Type "help" for available commands.`, 'info');
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// ADD NODE MODAL
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
document.getElementById('new-importance').addEventListener('input', e => {
|
||
document.getElementById('importance-val').textContent = parseFloat(e.target.value).toFixed(2);
|
||
});
|
||
|
||
function openAddNodeModal() {
|
||
document.getElementById('modal-overlay').classList.add('open');
|
||
document.getElementById('new-content').focus();
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('modal-overlay').classList.remove('open');
|
||
}
|
||
|
||
document.getElementById('modal-overlay').addEventListener('click', e => {
|
||
if (e.target === document.getElementById('modal-overlay')) closeModal();
|
||
});
|
||
|
||
function addNode() {
|
||
const content = document.getElementById('new-content').value.trim();
|
||
if (!content) { showToast('Content required', 'info'); return; }
|
||
const type = document.getElementById('new-type').value;
|
||
const tier = document.getElementById('new-tier').value;
|
||
const importance = parseFloat(document.getElementById('new-importance').value);
|
||
|
||
addNodeFromData({ content, type, tier, importance });
|
||
closeModal();
|
||
document.getElementById('new-content').value = '';
|
||
}
|
||
|
||
async function addNodeFromData(data) {
|
||
const embedding = randomEngramEmbedding();
|
||
const contentBytes = Array.from(new TextEncoder().encode(data.content || ''));
|
||
const body = {
|
||
node_type: data.type || data.node_type || 'Concept',
|
||
embedding,
|
||
content: contentBytes,
|
||
tier: data.tier || 'Working',
|
||
importance: data.importance || 0.5,
|
||
};
|
||
|
||
let id;
|
||
try {
|
||
const result = await engramFetch('/nodes', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
id = result.id;
|
||
} catch (err) {
|
||
// Fallback: local only
|
||
id = crypto.randomUUID();
|
||
showToast('Server unavailable — added locally only', 'info');
|
||
}
|
||
|
||
const node = {
|
||
id,
|
||
type: data.type || data.node_type || 'Concept',
|
||
tier: data.tier || 'Working',
|
||
content: data.content || '',
|
||
importance: data.importance || 0.5,
|
||
activationCount: 1,
|
||
lastActivated: Date.now(),
|
||
embedding: randomEmbedding(),
|
||
};
|
||
|
||
DB.nodes.push(node);
|
||
|
||
const angle = Math.random() * Math.PI * 2;
|
||
const r = 150 + Math.random() * 100;
|
||
gNodes.push({
|
||
...node,
|
||
x: Math.cos(angle) * r,
|
||
y: Math.sin(angle) * r,
|
||
vx: (Math.random() - 0.5) * 5,
|
||
vy: (Math.random() - 0.5) * 5,
|
||
radius: 0,
|
||
spawning: true,
|
||
spawnT: 0,
|
||
});
|
||
|
||
updateStats();
|
||
renderNodesTable();
|
||
switchTab('graph');
|
||
showToast(`Node ${String(id).slice(0,8)}… added — ${node.tier}/${node.type}`, 'success');
|
||
return id;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// TABS
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
function switchTab(name) {
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
||
document.querySelectorAll('.tab-panel').forEach(p => {
|
||
const isTarget = p.id === 'panel-' + name;
|
||
// flex-based panels
|
||
if (p.id === 'panel-swarm' || p.id === 'panel-chat') {
|
||
p.style.display = isTarget ? 'flex' : 'none';
|
||
} else {
|
||
p.classList.toggle('active', isTarget);
|
||
}
|
||
});
|
||
|
||
if (name === 'nodes') renderNodesTable();
|
||
if (name === 'timeline') { setTimeout(resizeTimeline, 50); }
|
||
if (name === 'console') { document.getElementById('console-input').focus(); }
|
||
if (name === 'swarm') { renderPeerList(); }
|
||
if (name === 'chat') { loadChatSessions(); document.getElementById('chat-textarea').focus(); }
|
||
}
|
||
|
||
document.querySelectorAll('.tab').forEach(t => {
|
||
t.addEventListener('click', () => switchTab(t.dataset.tab));
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// TOAST
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
let toastTimer = null;
|
||
function showToast(msg, type = 'info') {
|
||
const el = document.getElementById('toast');
|
||
el.textContent = msg;
|
||
el.className = 'show ' + type;
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => { el.className = ''; }, 3000);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// INIT
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
window.addEventListener('resize', () => {
|
||
resizeCanvas();
|
||
if (document.getElementById('panel-timeline').classList.contains('active')) {
|
||
resizeTimeline();
|
||
}
|
||
});
|
||
|
||
// Load real data from engram-server on startup
|
||
loadEngramData();
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// CHAT — Streaming chat with tool use
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
let chatSessions = [];
|
||
let activeChatSession = null;
|
||
let chatMessages = []; // current conversation messages
|
||
let chatAutoTTS = false;
|
||
let chatStreaming = false;
|
||
|
||
function toggleAutoTTS() {
|
||
chatAutoTTS = !chatAutoTTS;
|
||
document.getElementById('chat-tts-toggle').classList.toggle('active', chatAutoTTS);
|
||
}
|
||
|
||
function toggleSystemPrompt() {
|
||
const ta = document.getElementById('chat-system-textarea');
|
||
const arrow = document.getElementById('chat-system-arrow');
|
||
ta.classList.toggle('open');
|
||
arrow.textContent = ta.classList.contains('open') ? '▼' : '▶';
|
||
}
|
||
|
||
async function loadChatSessions() {
|
||
try {
|
||
const data = await proxyFetch('/api/conversation');
|
||
chatSessions = data.sessions || [];
|
||
renderChatSessionList();
|
||
} catch {}
|
||
}
|
||
|
||
function renderChatSessionList() {
|
||
const el = document.getElementById('chat-session-list');
|
||
if (chatSessions.length === 0) {
|
||
el.innerHTML = '<div class="empty-state" style="font-size:10px;padding:16px;">No conversations yet</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = chatSessions.map(s => `
|
||
<div class="chat-session-item ${activeChatSession === s.session_id ? 'active' : ''}"
|
||
onclick="loadChatSession('${escHtml(s.session_id)}')">
|
||
${escHtml(s.title || 'Conversation')}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
async function loadChatSession(sessionId) {
|
||
activeChatSession = sessionId;
|
||
try {
|
||
const data = await proxyFetch(`/api/conversation?session=${encodeURIComponent(sessionId)}`);
|
||
chatMessages = data.messages || [];
|
||
renderChatMessages();
|
||
renderChatSessionList();
|
||
} catch(err) {
|
||
showToast('Failed to load session: ' + err.message, 'info');
|
||
}
|
||
}
|
||
|
||
function newChat() {
|
||
activeChatSession = crypto.randomUUID();
|
||
chatMessages = [];
|
||
renderChatMessages();
|
||
renderChatSessionList();
|
||
document.getElementById('chat-textarea').focus();
|
||
}
|
||
|
||
// ─── Minimal markdown renderer ────────────────────────────
|
||
function renderMarkdown(text) {
|
||
let html = escHtml(text);
|
||
// Code blocks
|
||
html = html.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_, lang, code) =>
|
||
`<pre><code>${code}</code></pre>`);
|
||
// Inline code
|
||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||
// Bold
|
||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||
// Italic
|
||
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||
// Headings
|
||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||
// Blockquote
|
||
html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
|
||
// HR
|
||
html = html.replace(/^---$/gm, '<hr>');
|
||
// Lists
|
||
html = html.replace(/^\* (.+)$/gm, '<li>$1</li>');
|
||
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
|
||
html = html.replace(/^(\d+)\. (.+)$/gm, '<li>$2</li>');
|
||
html = html.replace(/(<li>.*<\/li>)/gs, '<ul>$1</ul>');
|
||
// Paragraphs (double newline)
|
||
html = html.replace(/\n\n/g, '</p><p>');
|
||
html = html.replace(/\n/g, '<br>');
|
||
html = '<p>' + html + '</p>';
|
||
// Clean up empty paragraphs
|
||
html = html.replace(/<p><\/p>/g, '');
|
||
html = html.replace(/<p>(<h[123]>)/g, '$1');
|
||
html = html.replace(/(<\/h[123]>)<\/p>/g, '$1');
|
||
html = html.replace(/<p>(<pre>)/g, '$1');
|
||
html = html.replace(/(<\/pre>)<\/p>/g, '$1');
|
||
html = html.replace(/<p>(<ul>)/g, '$1');
|
||
html = html.replace(/(<\/ul>)<\/p>/g, '$1');
|
||
html = html.replace(/<p>(<hr>)<\/p>/g, '$1');
|
||
html = html.replace(/<p>(<blockquote>)/g, '$1');
|
||
html = html.replace(/(<\/blockquote>)<\/p>/g, '$1');
|
||
return html;
|
||
}
|
||
|
||
function renderChatMessages() {
|
||
const container = document.getElementById('chat-messages');
|
||
const empty = document.getElementById('chat-empty');
|
||
|
||
if (chatMessages.length === 0) {
|
||
empty.style.display = 'flex';
|
||
// Remove all message elements
|
||
Array.from(container.children).forEach(c => {
|
||
if (c !== empty) c.remove();
|
||
});
|
||
return;
|
||
}
|
||
|
||
empty.style.display = 'none';
|
||
// Remove all non-empty elements
|
||
Array.from(container.children).forEach(c => {
|
||
if (c !== empty) c.remove();
|
||
});
|
||
|
||
for (const msg of chatMessages) {
|
||
const el = createMessageEl(msg);
|
||
container.appendChild(el);
|
||
}
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
function createMessageEl(msg) {
|
||
const div = document.createElement('div');
|
||
div.className = `chat-msg ${msg.role}`;
|
||
|
||
const bubble = document.createElement('div');
|
||
bubble.className = 'chat-msg-bubble';
|
||
|
||
if (msg.role === 'assistant') {
|
||
bubble.className += ' chat-md';
|
||
bubble.innerHTML = renderMarkdown(msg.content || '');
|
||
} else {
|
||
bubble.textContent = msg.content || '';
|
||
}
|
||
|
||
const meta = document.createElement('div');
|
||
meta.className = 'chat-msg-meta';
|
||
meta.innerHTML = `<span class="chat-msg-role">${msg.role}</span>`;
|
||
|
||
if (msg.role === 'assistant' && msg.content) {
|
||
const ttsBtn = document.createElement('button');
|
||
ttsBtn.className = 'chat-tts-btn';
|
||
ttsBtn.title = 'Read aloud';
|
||
ttsBtn.textContent = '🔊';
|
||
ttsBtn.onclick = () => playTTS(msg.content);
|
||
meta.appendChild(ttsBtn);
|
||
}
|
||
|
||
div.appendChild(bubble);
|
||
div.appendChild(meta);
|
||
return div;
|
||
}
|
||
|
||
function appendThinking() {
|
||
const container = document.getElementById('chat-messages');
|
||
document.getElementById('chat-empty').style.display = 'none';
|
||
const el = document.createElement('div');
|
||
el.id = 'chat-thinking';
|
||
el.className = 'chat-thinking';
|
||
el.innerHTML = `Neuron is thinking <div class="chat-thinking-dots"><span></span><span></span><span></span></div>`;
|
||
container.appendChild(el);
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
function removeThinking() {
|
||
document.getElementById('chat-thinking')?.remove();
|
||
}
|
||
|
||
// Streaming assistant message
|
||
function appendStreamingMsg() {
|
||
const container = document.getElementById('chat-messages');
|
||
const div = document.createElement('div');
|
||
div.className = 'chat-msg assistant';
|
||
div.id = 'chat-streaming-msg';
|
||
|
||
const bubble = document.createElement('div');
|
||
bubble.className = 'chat-msg-bubble chat-md';
|
||
bubble.id = 'chat-streaming-bubble';
|
||
|
||
div.appendChild(bubble);
|
||
container.appendChild(div);
|
||
container.scrollTop = container.scrollHeight;
|
||
return bubble;
|
||
}
|
||
|
||
function appendToolBlock(name, input) {
|
||
const container = document.getElementById('chat-messages');
|
||
const blockId = 'tool-' + Date.now();
|
||
|
||
const block = document.createElement('div');
|
||
block.className = 'tool-block';
|
||
block.id = blockId;
|
||
block.innerHTML = `
|
||
<div class="tool-block-header" onclick="toggleToolBlock('${blockId}')">
|
||
<span style="color:var(--text3);font-size:11px;">⚙</span>
|
||
<span class="tool-block-name">${escHtml(name)}</span>
|
||
<span class="tool-block-toggle">▶ expand</span>
|
||
</div>
|
||
<div class="tool-block-body" id="${blockId}-body">
|
||
<strong>Input:</strong>\n${escHtml(JSON.stringify(input, null, 2))}
|
||
</div>
|
||
<div class="tool-result-content" id="${blockId}-result"></div>
|
||
`;
|
||
|
||
container.appendChild(block);
|
||
container.scrollTop = container.scrollHeight;
|
||
return blockId;
|
||
}
|
||
|
||
function setToolResult(blockId, content) {
|
||
const el = document.getElementById(blockId + '-result');
|
||
if (!el) return;
|
||
el.textContent = content.slice(0, 500) + (content.length > 500 ? '…' : '');
|
||
el.classList.add('show');
|
||
}
|
||
|
||
function toggleToolBlock(blockId) {
|
||
const body = document.getElementById(blockId + '-body');
|
||
const toggle = document.querySelector(`#${blockId} .tool-block-toggle`);
|
||
if (!body) return;
|
||
body.classList.toggle('open');
|
||
if (toggle) toggle.textContent = body.classList.contains('open') ? '▼ collapse' : '▶ expand';
|
||
}
|
||
|
||
async function playTTS(text) {
|
||
try {
|
||
const r = await fetch(PROXY_URL + '/api/tts', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text: text.slice(0, 2000) }),
|
||
});
|
||
if (!r.ok) return;
|
||
const blob = await r.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const audio = new Audio(url);
|
||
audio.play();
|
||
audio.onended = () => URL.revokeObjectURL(url);
|
||
} catch {}
|
||
}
|
||
|
||
async function sendChat() {
|
||
if (chatStreaming) return;
|
||
const textarea = document.getElementById('chat-textarea');
|
||
const text = textarea.value.trim();
|
||
if (!text) return;
|
||
|
||
if (!activeChatSession) activeChatSession = crypto.randomUUID();
|
||
|
||
textarea.value = '';
|
||
textarea.style.height = 'auto';
|
||
|
||
// Add user message
|
||
chatMessages.push({ role: 'user', content: text });
|
||
const container = document.getElementById('chat-messages');
|
||
document.getElementById('chat-empty').style.display = 'none';
|
||
const userEl = createMessageEl({ role: 'user', content: text });
|
||
container.appendChild(userEl);
|
||
container.scrollTop = container.scrollHeight;
|
||
|
||
chatStreaming = true;
|
||
document.getElementById('chat-send-btn').disabled = true;
|
||
|
||
appendThinking();
|
||
|
||
const systemPrompt = document.getElementById('chat-system-textarea').value;
|
||
const model = document.getElementById('chat-model-select').value;
|
||
|
||
let streamingBubble = null;
|
||
let assistantText = '';
|
||
const toolBlockMap = {}; // name -> blockId (last one)
|
||
|
||
try {
|
||
const r = await fetch(PROXY_URL + '/api/chat', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
messages: chatMessages.slice(0, -1).concat([{ role: 'user', content: text }]),
|
||
sessionId: activeChatSession,
|
||
system: systemPrompt,
|
||
model,
|
||
}),
|
||
});
|
||
|
||
removeThinking();
|
||
streamingBubble = appendStreamingMsg();
|
||
|
||
const reader = r.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buf = '';
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buf += decoder.decode(value, { stream: true });
|
||
const lines = buf.split('\n');
|
||
buf = lines.pop() ?? '';
|
||
|
||
for (const line of lines) {
|
||
if (!line.startsWith('data: ')) continue;
|
||
const data = line.slice(6);
|
||
if (data === '[DONE]') continue;
|
||
|
||
let evt;
|
||
try { evt = JSON.parse(data); } catch { continue; }
|
||
|
||
if (evt.type === 'delta' && evt.text) {
|
||
assistantText += evt.text;
|
||
streamingBubble.innerHTML = renderMarkdown(assistantText);
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
if (evt.type === 'tool_call') {
|
||
const blockId = appendToolBlock(evt.name, evt.input);
|
||
toolBlockMap[evt.name + JSON.stringify(evt.input)] = blockId;
|
||
}
|
||
if (evt.type === 'tool_result') {
|
||
// Find the most recent tool block for this tool
|
||
const key = Object.keys(toolBlockMap).find(k => k.startsWith(evt.name));
|
||
if (key) setToolResult(toolBlockMap[key], String(evt.content || ''));
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
removeThinking();
|
||
if (!streamingBubble) streamingBubble = appendStreamingMsg();
|
||
streamingBubble.innerHTML = `<span style="color:#f87171">[Error: ${escHtml(err.message)}]</span>`;
|
||
}
|
||
|
||
// Finalize
|
||
document.getElementById('chat-streaming-msg')?.removeAttribute('id');
|
||
document.getElementById('chat-streaming-bubble')?.removeAttribute('id');
|
||
|
||
if (assistantText) {
|
||
chatMessages.push({ role: 'assistant', content: assistantText });
|
||
if (chatAutoTTS) playTTS(assistantText);
|
||
|
||
// Add TTS button to the finalized message
|
||
const lastMsg = container.querySelector('.chat-msg.assistant:last-child');
|
||
if (lastMsg) {
|
||
const meta = document.createElement('div');
|
||
meta.className = 'chat-msg-meta';
|
||
meta.innerHTML = `<span class="chat-msg-role">assistant</span>`;
|
||
const ttsBtn = document.createElement('button');
|
||
ttsBtn.className = 'chat-tts-btn';
|
||
ttsBtn.title = 'Read aloud';
|
||
ttsBtn.textContent = '🔊';
|
||
ttsBtn.onclick = () => playTTS(assistantText);
|
||
meta.appendChild(ttsBtn);
|
||
lastMsg.appendChild(meta);
|
||
}
|
||
}
|
||
|
||
chatStreaming = false;
|
||
document.getElementById('chat-send-btn').disabled = false;
|
||
|
||
// Update session list
|
||
await loadChatSessions();
|
||
}
|
||
|
||
// Textarea auto-resize + Enter to send
|
||
document.getElementById('chat-textarea').addEventListener('keydown', e => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendChat();
|
||
}
|
||
});
|
||
|
||
document.getElementById('chat-textarea').addEventListener('input', e => {
|
||
e.target.style.height = 'auto';
|
||
e.target.style.height = Math.min(e.target.scrollHeight, 160) + 'px';
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// SWARM — Peer management & distributed activation
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
// In-memory peer store (demo mode — in live mode this syncs with /sync/peers)
|
||
let SWARM_PEERS = [];
|
||
let SWARM_RESULTS = null;
|
||
|
||
// Track remote-sourced node IDs for graph rendering
|
||
let remotePeerNodeIds = new Set();
|
||
|
||
function renderPeerList() {
|
||
const container = document.getElementById('peer-list');
|
||
if (SWARM_PEERS.length === 0) {
|
||
container.innerHTML = '<div class="empty-state" style="font-size:10px;">No peers configured. Add one to start syncing.</div>';
|
||
return;
|
||
}
|
||
container.innerHTML = SWARM_PEERS.map((p, idx) => {
|
||
const statusClass = p.online ? 'online' : 'offline';
|
||
const lastSync = p.last_sync_at > 0
|
||
? new Date(p.last_sync_at).toLocaleTimeString()
|
||
: 'never';
|
||
const tiers = p.sync_tiers.join(', ') || 'Semantic';
|
||
return `
|
||
<div class="peer-card ${statusClass}">
|
||
<div style="display:flex;align-items:center;margin-bottom:4px;">
|
||
<span class="peer-status-dot ${statusClass}"></span>
|
||
<span class="peer-name">${escHtml(p.name)}</span>
|
||
</div>
|
||
<div class="peer-addr">${escHtml(p.address)}</div>
|
||
<div class="peer-meta">
|
||
<span class="peer-tag tier">${escHtml(tiers)}</span>
|
||
${p.trusted ? '<span class="peer-tag trusted">trusted</span>' : ''}
|
||
<span class="peer-tag sync-time">synced ${lastSync}</span>
|
||
</div>
|
||
<div class="peer-actions">
|
||
<button class="peer-btn" onclick="syncPeer(${idx})">Sync Now</button>
|
||
<button class="peer-btn danger" onclick="removePeer(${idx})">Remove</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function escHtml(str) {
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function openAddPeerModal() {
|
||
document.getElementById('add-peer-form').style.display = 'block';
|
||
document.getElementById('peer-name').focus();
|
||
}
|
||
|
||
function closeAddPeerForm() {
|
||
document.getElementById('add-peer-form').style.display = 'none';
|
||
}
|
||
|
||
function savePeer() {
|
||
const name = document.getElementById('peer-name').value.trim();
|
||
const address = document.getElementById('peer-address').value.trim();
|
||
const apiKey = document.getElementById('peer-apikey').value.trim();
|
||
const trusted = document.getElementById('peer-trusted').checked;
|
||
|
||
if (!name || !address) {
|
||
showToast('Name and address are required', 'info');
|
||
return;
|
||
}
|
||
|
||
const tiers = [];
|
||
if (document.getElementById('tier-working').checked) tiers.push('Working');
|
||
if (document.getElementById('tier-episodic').checked) tiers.push('Episodic');
|
||
if (document.getElementById('tier-semantic').checked) tiers.push('Semantic');
|
||
if (document.getElementById('tier-procedural').checked) tiers.push('Procedural');
|
||
if (tiers.length === 0) tiers.push('Semantic');
|
||
|
||
const peer = {
|
||
id: crypto.randomUUID(),
|
||
name,
|
||
address,
|
||
api_key: apiKey,
|
||
sync_tiers: tiers,
|
||
last_sync_at: 0,
|
||
trusted,
|
||
online: null, // unknown until probed
|
||
node_count_contributed: 0,
|
||
};
|
||
|
||
SWARM_PEERS.push(peer);
|
||
closeAddPeerForm();
|
||
|
||
// Clear inputs
|
||
document.getElementById('peer-name').value = '';
|
||
document.getElementById('peer-address').value = '';
|
||
document.getElementById('peer-apikey').value = '';
|
||
document.getElementById('tier-semantic').checked = true;
|
||
document.getElementById('tier-working').checked = false;
|
||
document.getElementById('tier-episodic').checked = false;
|
||
document.getElementById('tier-procedural').checked = false;
|
||
document.getElementById('peer-trusted').checked = true;
|
||
|
||
renderPeerList();
|
||
showToast(`Peer "${name}" added`, 'success');
|
||
|
||
// Probe reachability in background
|
||
probePeer(SWARM_PEERS.length - 1);
|
||
}
|
||
|
||
function removePeer(idx) {
|
||
const p = SWARM_PEERS[idx];
|
||
SWARM_PEERS.splice(idx, 1);
|
||
renderPeerList();
|
||
showToast(`Peer "${p.name}" removed`, 'info');
|
||
}
|
||
|
||
async function probePeer(idx) {
|
||
const peer = SWARM_PEERS[idx];
|
||
if (!peer) return;
|
||
try {
|
||
const url = `${peer.address}/sync/delta?since=${Date.now()}&peer_id=local`;
|
||
const resp = await fetch(url, {
|
||
headers: { Authorization: `Bearer ${peer.api_key}` },
|
||
signal: AbortSignal.timeout(5000),
|
||
});
|
||
peer.online = resp.ok;
|
||
} catch {
|
||
peer.online = false;
|
||
}
|
||
renderPeerList();
|
||
}
|
||
|
||
async function syncPeer(idx) {
|
||
const peer = SWARM_PEERS[idx];
|
||
if (!peer) return;
|
||
showToast(`Syncing with ${peer.name}...`, 'info');
|
||
try {
|
||
// Pull delta from peer
|
||
const pullUrl = `${peer.address}/sync/delta?since=${peer.last_sync_at}&peer_id=local`;
|
||
const pullResp = await fetch(pullUrl, {
|
||
headers: { Authorization: `Bearer ${peer.api_key}` },
|
||
signal: AbortSignal.timeout(10000),
|
||
});
|
||
if (!pullResp.ok) throw new Error(`HTTP ${pullResp.status}`);
|
||
const delta = await pullResp.json();
|
||
|
||
// Apply nodes to our demo DB
|
||
let imported = 0;
|
||
for (const node of delta.nodes || []) {
|
||
if (!peer.sync_tiers.includes(node.tier)) continue;
|
||
if (DB.nodes.find(n => n.id === node.id)) continue;
|
||
const content = typeof node.content === 'string'
|
||
? node.content
|
||
: new TextDecoder().decode(new Uint8Array(node.content));
|
||
DB.nodes.push({
|
||
id: node.id,
|
||
type: node.node_type || 'Concept',
|
||
tier: node.tier,
|
||
content,
|
||
importance: node.importance || 0.5,
|
||
activationCount: node.activation_count || 1,
|
||
lastActivated: node.last_activated || Date.now(),
|
||
embedding: randomEmbedding(),
|
||
fromPeer: peer.id,
|
||
peerName: peer.name,
|
||
});
|
||
remotePeerNodeIds.add(node.id);
|
||
imported++;
|
||
}
|
||
|
||
peer.last_sync_at = Date.now();
|
||
peer.online = true;
|
||
peer.node_count_contributed += imported;
|
||
|
||
updateStats();
|
||
renderPeerList();
|
||
if (document.getElementById('panel-graph').classList.contains('active')) {
|
||
initGraph();
|
||
}
|
||
showToast(`Sync complete — ${imported} nodes from ${peer.name}`, 'success');
|
||
} catch (err) {
|
||
peer.online = false;
|
||
renderPeerList();
|
||
showToast(`Sync failed: ${err.message}`, 'info');
|
||
}
|
||
}
|
||
|
||
async function runSwarmActivate() {
|
||
const seedOptions = Array.from(document.getElementById('seed-select').selectedOptions);
|
||
const seeds = seedOptions.length > 0
|
||
? seedOptions.map(o => o.value)
|
||
: (DB.nodes.length > 0 ? [DB.nodes[0].id] : []);
|
||
|
||
const includePeers = document.getElementById('swarm-activate-toggle').checked;
|
||
const statusBadge = document.getElementById('swarm-status-badge');
|
||
const resultsList = document.getElementById('swarm-results-list');
|
||
|
||
statusBadge.textContent = 'activating...';
|
||
resultsList.innerHTML = '<div class="empty-state">Running swarm activation...</div>';
|
||
|
||
// Local activation first
|
||
const localResults = activateLocal(seeds, null, 3, 10);
|
||
|
||
let peerResults = [];
|
||
|
||
if (includePeers) {
|
||
const onlinePeers = SWARM_PEERS.filter(p => p.trusted && p.online !== false);
|
||
statusBadge.textContent = `fanning out to ${onlinePeers.length} peer(s)...`;
|
||
|
||
// Fan out to peers in parallel (demo: simulated — real impl hits /swarm/activate)
|
||
const peerPromises = onlinePeers.map(async (peer) => {
|
||
try {
|
||
const url = `${peer.address}/swarm/activate`;
|
||
const body = JSON.stringify({
|
||
seeds,
|
||
query_embedding: Array.from({length: 16}, () => Math.random()),
|
||
max_depth: 3,
|
||
limit: 10,
|
||
include_peers: false,
|
||
});
|
||
const resp = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${peer.api_key}`,
|
||
},
|
||
body,
|
||
signal: AbortSignal.timeout(8000),
|
||
});
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const data = await resp.json();
|
||
return { peer, results: data.local_results || [], error: null };
|
||
} catch (err) {
|
||
return { peer, results: [], error: err.message };
|
||
}
|
||
});
|
||
|
||
peerResults = await Promise.all(peerPromises);
|
||
}
|
||
|
||
// Merge: deduplicate by id, keep strongest activation
|
||
const merged = new Map();
|
||
|
||
for (const r of localResults) {
|
||
merged.set(r.id, { ...r, sourcePeer: null, peerName: 'local' });
|
||
}
|
||
|
||
for (const { peer, results, error } of peerResults) {
|
||
if (error) continue;
|
||
for (const item of results) {
|
||
const id = item.node?.id || item.id;
|
||
const strength = item.activation_strength || item.strength || 0;
|
||
const existing = merged.get(id);
|
||
if (!existing || strength > existing.strength) {
|
||
merged.set(id, {
|
||
id,
|
||
strength,
|
||
node: item.node,
|
||
sourcePeer: peer.id,
|
||
peerName: peer.name,
|
||
tier: item.node?.tier || 'Semantic',
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
const mergedArr = Array.from(merged.values())
|
||
.sort((a, b) => b.strength - a.strength)
|
||
.slice(0, 15);
|
||
|
||
SWARM_RESULTS = mergedArr;
|
||
|
||
const localCount = mergedArr.filter(r => !r.sourcePeer).length;
|
||
const remoteCount = mergedArr.length - localCount;
|
||
statusBadge.textContent = `${mergedArr.length} results — ${localCount} local, ${remoteCount} remote`;
|
||
|
||
if (mergedArr.length === 0) {
|
||
resultsList.innerHTML = '<div class="empty-state">No results returned</div>';
|
||
return;
|
||
}
|
||
|
||
resultsList.innerHTML = mergedArr.map(r => {
|
||
const isRemote = !!r.sourcePeer;
|
||
const content = r.node
|
||
? (typeof r.node.content === 'string'
|
||
? r.node.content
|
||
: new TextDecoder().decode(new Uint8Array(r.node.content || [])))
|
||
: (r.node ? r.node.content : getNode(r.id)?.content || '—');
|
||
const tier = r.tier || r.node?.tier || 'Semantic';
|
||
const strength = (r.strength || r.activation_strength || 0).toFixed(3);
|
||
return `
|
||
<div class="swarm-result-item ${isRemote ? 'remote' : ''} tier-${tier}">
|
||
<div class="swarm-result-header">
|
||
<span>${isRemote
|
||
? `<span class="swarm-result-peer">${escHtml(r.peerName)}</span>`
|
||
: '<span class="swarm-result-local">local</span>'}</span>
|
||
<span class="swarm-result-strength">${strength}</span>
|
||
</div>
|
||
<div class="swarm-result-content">${escHtml(String(content))}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// Override graph rendering to visually distinguish peer nodes
|
||
const _origDrawNode = typeof drawNode === 'function' ? drawNode : null;
|
||
|
||
// Patch into the graph rendering loop: remote peer nodes get a dashed border
|
||
// We intercept at the draw level by monkey-patching after graph init.
|
||
// The graph draws nodes in the animation loop — we tag them after the fact.
|
||
function markRemoteNodesInGraph() {
|
||
// Tag gNodes that came from a remote peer
|
||
if (typeof gNodes !== 'undefined') {
|
||
gNodes.forEach(gn => {
|
||
const node = DB.nodes.find(n => n.id === gn.id);
|
||
gn.isRemote = !!(node && node.fromPeer);
|
||
gn.peerName = node?.peerName || null;
|
||
});
|
||
}
|
||
}
|
||
|
||
// Called after sync imports new nodes
|
||
const _origInitGraph = typeof initGraph === 'function' ? initGraph : null;
|
||
</script>
|
||
</body>
|
||
</html>
|