@polderlabs/bizar 4.7.2 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bizar-dash/dist/assets/index-DU61awG3.js +9 -0
- package/bizar-dash/dist/assets/index-DU61awG3.js.map +1 -0
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js +366 -0
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DX_Jh8Wc.css → main-DfmIfOUS.css} +1 -1
- package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js → mobile-CL5uUQEC.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js.map → mobile-CL5uUQEC.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +338 -0
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +1 -0
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/server/memory-lightrag.mjs +109 -0
- package/bizar-dash/src/server/memory-store.mjs +121 -0
- package/bizar-dash/src/server/otel.mjs +133 -0
- package/bizar-dash/src/server/routes/chat.mjs +246 -170
- package/bizar-dash/src/server/routes/memory.mjs +46 -0
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
- package/bizar-dash/src/server/server.mjs +40 -0
- package/bizar-dash/src/web/components/SettingsSearch.tsx +204 -89
- package/bizar-dash/src/web/lib/search.ts +115 -0
- package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
- package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
- package/bizar-dash/src/web/styles/memory.css +84 -1
- package/bizar-dash/src/web/styles/settings.css +80 -0
- package/bizar-dash/src/web/views/Memory.tsx +6 -1
- package/bizar-dash/src/web/views/Settings.tsx +96 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphLegend.tsx +29 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -0
- package/bizar-dash/tests/backup-restore.test.tsx +35 -17
- package/bizar-dash/tests/bundle-analysis.test.mjs +70 -0
- package/bizar-dash/tests/components/settings-search.test.tsx +180 -0
- package/bizar-dash/tests/docker-build.test.mjs +96 -0
- package/bizar-dash/tests/lib/search-fuzzy.test.ts +149 -0
- package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
- package/bizar-dash/tests/memory-graph.test.mjs +95 -0
- package/bizar-dash/tests/otel.test.mjs +188 -0
- package/cli/commands/dash.mjs +6 -0
- package/package.json +7 -1
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js +0 -361
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// src/mobile/views/QrCodePanel.tsx — lazy-loaded QR code panel.
|
|
2
|
+
// Split from MobileSettings to keep qrcode.react (~30 KB) out of the
|
|
3
|
+
// mobile entry chunk; it is only loaded when the user navigates to
|
|
4
|
+
// the Companion App section and taps "Generate QR Code".
|
|
5
|
+
import { useEffect, useState } from 'react';
|
|
6
|
+
import { RefreshCw } from 'lucide-react';
|
|
7
|
+
|
|
8
|
+
type PairSession = { token: string; qrPayload: string; publicUrl: string; expiresAt: number };
|
|
9
|
+
|
|
10
|
+
function formatCountdown(ms: number): string {
|
|
11
|
+
if (ms <= 0) return 'expired';
|
|
12
|
+
const s = Math.floor(ms / 1000);
|
|
13
|
+
const m = Math.floor(s / 60);
|
|
14
|
+
const r = s % 60;
|
|
15
|
+
return `${m}:${String(r).padStart(2, '0')}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function QrCodePanel({ pair, onStart }: {
|
|
19
|
+
pair: PairSession;
|
|
20
|
+
onStart: () => void;
|
|
21
|
+
}) {
|
|
22
|
+
const [QRCodeSVG, setQRCodeSVG] = useState<React.ComponentType<{ value: string; size: number; level: string }> | null>(null);
|
|
23
|
+
const [remaining, setRemaining] = useState(() => pair.expiresAt - Date.now());
|
|
24
|
+
const expired = remaining <= 0;
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
import('qrcode.react').then((mod) => {
|
|
28
|
+
setQRCodeSVG(mod.QRCodeSVG as React.ComponentType<{ value: string; size: number; level: string }>);
|
|
29
|
+
});
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!pair) return;
|
|
34
|
+
const tick = setInterval(() => {
|
|
35
|
+
setRemaining(pair.expiresAt - Date.now());
|
|
36
|
+
}, 1000);
|
|
37
|
+
return () => clearInterval(tick);
|
|
38
|
+
}, [pair]);
|
|
39
|
+
|
|
40
|
+
if (!QRCodeSVG) {
|
|
41
|
+
return (
|
|
42
|
+
<div style={{ textAlign: 'center', padding: '16px' }}>
|
|
43
|
+
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>Loading QR library…</span>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div style={{ textAlign: 'center' }}>
|
|
50
|
+
<div style={{ background: '#fff', padding: 12, borderRadius: 12, display: 'inline-block' }}>
|
|
51
|
+
<QRCodeSVG value={pair.qrPayload} size={180} level="M" />
|
|
52
|
+
</div>
|
|
53
|
+
<div style={{ marginTop: 8, fontSize: 12 }}>
|
|
54
|
+
Expires in <strong>{formatCountdown(remaining)}</strong>
|
|
55
|
+
</div>
|
|
56
|
+
<div className="mono" style={{ fontSize: 10, wordBreak: 'break-all', marginTop: 4 }}>{pair.publicUrl}</div>
|
|
57
|
+
{expired && (
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
className="mobile-btn"
|
|
61
|
+
onClick={onStart}
|
|
62
|
+
style={{ marginTop: 12, width: '100%' }}
|
|
63
|
+
>
|
|
64
|
+
<RefreshCw size={14} /> Generate new QR
|
|
65
|
+
</button>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -952,4 +952,87 @@ dl.memory-config-row dd {
|
|
|
952
952
|
|
|
953
953
|
.memory-status-card-sub {
|
|
954
954
|
font-size: 11px;
|
|
955
|
-
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/* ── Memory Graph ─────────────────────────────────────────────────────── */
|
|
958
|
+
|
|
959
|
+
.memory-graph-canvas {
|
|
960
|
+
display: block;
|
|
961
|
+
background: var(--bg, #0b0e14);
|
|
962
|
+
border: 1px solid var(--border, #232a39);
|
|
963
|
+
border-radius: var(--radius, 8px);
|
|
964
|
+
user-select: none;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
.memory-graph-canvas-svg {
|
|
968
|
+
height: 480px;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
.memory-graph-node {
|
|
972
|
+
transition: opacity var(--motion-fast, 120ms);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
.memory-graph-node:hover circle {
|
|
976
|
+
opacity: 1;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
.memory-graph-tooltip {
|
|
980
|
+
position: absolute;
|
|
981
|
+
padding: 6px 10px;
|
|
982
|
+
background: var(--bg-elev, #12161f);
|
|
983
|
+
border: 1px solid var(--border, #232a39);
|
|
984
|
+
border-radius: var(--radius-sm, 6px);
|
|
985
|
+
font-size: 12px;
|
|
986
|
+
color: var(--text, #c9d1d9);
|
|
987
|
+
pointer-events: none;
|
|
988
|
+
z-index: 10;
|
|
989
|
+
max-width: 200px;
|
|
990
|
+
white-space: nowrap;
|
|
991
|
+
overflow: hidden;
|
|
992
|
+
text-overflow: ellipsis;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
.memory-graph-controls {
|
|
996
|
+
display: flex;
|
|
997
|
+
flex-direction: column;
|
|
998
|
+
gap: 10px;
|
|
999
|
+
margin-top: 12px;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
.memory-graph-search-row {
|
|
1003
|
+
display: flex;
|
|
1004
|
+
gap: 8px;
|
|
1005
|
+
align-items: center;
|
|
1006
|
+
flex-wrap: wrap;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
.memory-graph-legend {
|
|
1010
|
+
display: flex;
|
|
1011
|
+
gap: 12px;
|
|
1012
|
+
flex-wrap: wrap;
|
|
1013
|
+
align-items: center;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
.memory-graph-legend-item {
|
|
1017
|
+
display: flex;
|
|
1018
|
+
align-items: center;
|
|
1019
|
+
gap: 6px;
|
|
1020
|
+
font-size: 12px;
|
|
1021
|
+
color: var(--text-dim, #b4bcd0);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
.memory-graph-legend-dot {
|
|
1025
|
+
display: inline-block;
|
|
1026
|
+
width: 10px;
|
|
1027
|
+
height: 10px;
|
|
1028
|
+
border-radius: 50%;
|
|
1029
|
+
flex-shrink: 0;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
.memory-graph-stats {
|
|
1033
|
+
display: flex;
|
|
1034
|
+
gap: 8px;
|
|
1035
|
+
align-items: center;
|
|
1036
|
+
font-size: 13px;
|
|
1037
|
+
color: var(--text, #c9d1d9);
|
|
1038
|
+
}
|
|
@@ -77,6 +77,86 @@
|
|
|
77
77
|
min-width: 0;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/* ─── Setting flash animation (quick-jump highlight) ─── */
|
|
81
|
+
.setting-flash {
|
|
82
|
+
animation: setting-flash 2s ease-out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@keyframes setting-flash {
|
|
86
|
+
0%, 100% { background-color: transparent; }
|
|
87
|
+
50% { background-color: var(--accent, rgba(139, 92, 246, 0.1)); border-radius: 6px; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* Highlight matched terms in search results */
|
|
91
|
+
.settings-search-result-label mark {
|
|
92
|
+
background: var(--accent, rgba(139, 92, 246, 0.2));
|
|
93
|
+
color: inherit;
|
|
94
|
+
border-radius: 2px;
|
|
95
|
+
padding: 0 2px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* Recent searches dropdown */
|
|
99
|
+
.settings-search-recent {
|
|
100
|
+
position: absolute;
|
|
101
|
+
top: calc(100% + 4px);
|
|
102
|
+
left: 0;
|
|
103
|
+
right: 0;
|
|
104
|
+
background: var(--bg-elev);
|
|
105
|
+
border: 1px solid var(--border);
|
|
106
|
+
border-radius: var(--radius-md);
|
|
107
|
+
box-shadow: var(--shadow-2);
|
|
108
|
+
z-index: 100;
|
|
109
|
+
padding: 4px;
|
|
110
|
+
display: flex;
|
|
111
|
+
flex-direction: column;
|
|
112
|
+
gap: 2px;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.settings-search-recent-header {
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
justify-content: space-between;
|
|
119
|
+
padding: 4px 8px 2px;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.settings-search-recent-label {
|
|
123
|
+
font-size: 10px;
|
|
124
|
+
text-transform: uppercase;
|
|
125
|
+
letter-spacing: 0.06em;
|
|
126
|
+
color: var(--text-dim);
|
|
127
|
+
font-weight: 600;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.settings-search-recent-clear {
|
|
131
|
+
font-size: 10px;
|
|
132
|
+
color: var(--accent-2);
|
|
133
|
+
background: none;
|
|
134
|
+
border: none;
|
|
135
|
+
cursor: pointer;
|
|
136
|
+
text-decoration: underline;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.settings-search-recent-item {
|
|
140
|
+
display: flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
gap: 6px;
|
|
143
|
+
padding: 5px 8px;
|
|
144
|
+
border-radius: var(--radius-sm);
|
|
145
|
+
border: none;
|
|
146
|
+
background: transparent;
|
|
147
|
+
color: var(--text-dim);
|
|
148
|
+
font-size: 12px;
|
|
149
|
+
cursor: pointer;
|
|
150
|
+
text-align: left;
|
|
151
|
+
width: 100%;
|
|
152
|
+
transition: background var(--motion-fast);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.settings-search-recent-item:hover {
|
|
156
|
+
background: var(--bg-elev-2);
|
|
157
|
+
color: var(--text);
|
|
158
|
+
}
|
|
159
|
+
|
|
80
160
|
/* Search within settings */
|
|
81
161
|
.settings-search {
|
|
82
162
|
position: relative;
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
FileText,
|
|
14
14
|
GitBranch,
|
|
15
15
|
LayoutDashboard,
|
|
16
|
+
Network,
|
|
16
17
|
RefreshCw,
|
|
17
18
|
Search as SearchIcon,
|
|
18
19
|
Sliders,
|
|
@@ -26,8 +27,9 @@ import { ObsidianPanel } from './memory/ObsidianPanel';
|
|
|
26
27
|
import { GitSyncPanel } from './memory/GitSyncPanel';
|
|
27
28
|
import { SemanticSearchPanel } from './memory/SemanticSearchPanel';
|
|
28
29
|
import { ConfigPanel } from './memory/ConfigPanel';
|
|
30
|
+
import { MemoryGraphPanel } from './memory/MemoryGraphPanel';
|
|
29
31
|
|
|
30
|
-
type SubPanel = 'overview' | 'lightrag' | 'obsidian' | 'git' | 'semantic' | 'config';
|
|
32
|
+
type SubPanel = 'overview' | 'lightrag' | 'obsidian' | 'git' | 'semantic' | 'config' | 'graph';
|
|
31
33
|
|
|
32
34
|
type Props = {
|
|
33
35
|
snapshot: unknown;
|
|
@@ -48,6 +50,7 @@ const SOURCES: Array<{
|
|
|
48
50
|
{ id: 'git', label: 'Git Sync', icon: GitBranch },
|
|
49
51
|
{ id: 'semantic', label: 'Semantic Search', icon: SearchIcon },
|
|
50
52
|
{ id: 'config', label: 'Config', icon: Sliders },
|
|
53
|
+
{ id: 'graph', label: 'Memory Graph', icon: Network },
|
|
51
54
|
];
|
|
52
55
|
|
|
53
56
|
function MemoryInner(_props: Props) {
|
|
@@ -78,6 +81,8 @@ function MemoryInner(_props: Props) {
|
|
|
78
81
|
return <SemanticSearchPanel refreshKey={refreshKey} />;
|
|
79
82
|
case 'config':
|
|
80
83
|
return <ConfigPanel refreshKey={refreshKey} />;
|
|
84
|
+
case 'graph':
|
|
85
|
+
return <MemoryGraphPanel refreshKey={refreshKey} />;
|
|
81
86
|
default:
|
|
82
87
|
return <Card>Unknown panel: {active}</Card>;
|
|
83
88
|
}
|
|
@@ -7,6 +7,7 @@ import { api } from '../lib/api';
|
|
|
7
7
|
import { cn } from '../lib/utils';
|
|
8
8
|
import { applyTheme, applyThemeTokens, type Settings, type SettingsResponse, type Snapshot, type TailscaleStatus } from '../lib/types';
|
|
9
9
|
|
|
10
|
+
import { SettingsSearch, type SettingsSection, type SettingsField } from '../components/SettingsSearch';
|
|
10
11
|
import { ThemeSection } from './settings/ThemeSection';
|
|
11
12
|
import { UpdatesSection } from './settings/UpdatesSection';
|
|
12
13
|
import { GeneralSection } from './settings/GeneralSection';
|
|
@@ -53,6 +54,99 @@ const SECTION_LINKS = [
|
|
|
53
54
|
{ id: 'backup', label: 'Backup' },
|
|
54
55
|
] as const;
|
|
55
56
|
|
|
57
|
+
/* ─── Settings search sections metadata ─── */
|
|
58
|
+
const SETTINGS_SECTIONS: SettingsSection[] = [
|
|
59
|
+
{ id: 'theme', label: 'Theme', fields: [
|
|
60
|
+
{ key: 'theme.presets', label: 'Accent presets', section: 'theme' },
|
|
61
|
+
{ key: 'theme.accent', label: 'Accent color', section: 'theme' },
|
|
62
|
+
{ key: 'theme.success', label: 'Success color', section: 'theme' },
|
|
63
|
+
{ key: 'theme.warning', label: 'Warning color', section: 'theme' },
|
|
64
|
+
{ key: 'theme.error', label: 'Error color', section: 'theme' },
|
|
65
|
+
{ key: 'theme.info', label: 'Info color', section: 'theme' },
|
|
66
|
+
{ key: 'theme.fontFamily', label: 'Font family', section: 'theme' },
|
|
67
|
+
{ key: 'theme.fontSize', label: 'Font size', section: 'theme' },
|
|
68
|
+
{ key: 'theme.compactMode', label: 'Compact mode', section: 'theme' },
|
|
69
|
+
{ key: 'theme.animations', label: 'Animations', section: 'theme' },
|
|
70
|
+
] },
|
|
71
|
+
{ id: 'updates', label: 'Updates', fields: [
|
|
72
|
+
{ key: 'updates.channel', label: 'Update channel', section: 'updates' },
|
|
73
|
+
] },
|
|
74
|
+
{ id: 'layout', label: 'Layout', fields: [
|
|
75
|
+
{ key: 'ui.layout', label: 'UI layout', section: 'layout' },
|
|
76
|
+
{ key: 'ui.showHeader', label: 'Show header', section: 'layout' },
|
|
77
|
+
{ key: 'ui.showStatusBar', label: 'Show status bar', section: 'layout' },
|
|
78
|
+
{ key: 'ui.defaultTab', label: 'Default tab', section: 'layout' },
|
|
79
|
+
] },
|
|
80
|
+
{ id: 'general', label: 'General', fields: [
|
|
81
|
+
{ key: 'defaultAgent', label: 'Default agent', section: 'general' },
|
|
82
|
+
{ key: 'defaultModel', label: 'Model override', section: 'general' },
|
|
83
|
+
] },
|
|
84
|
+
{ id: 'network', label: 'Network', fields: [
|
|
85
|
+
{ key: 'tailscale.enabled', label: 'Tailscale Serve', section: 'network' },
|
|
86
|
+
{ key: 'tailscale.port', label: 'Tailscale port', section: 'network' },
|
|
87
|
+
{ key: 'tailscale.https', label: 'Tailscale HTTPS', section: 'network' },
|
|
88
|
+
{ key: 'tailscale.hostname', label: 'Tailscale hostname', section: 'network' },
|
|
89
|
+
] },
|
|
90
|
+
{ id: 'notifications', label: 'Notifications', fields: [
|
|
91
|
+
{ key: 'notifications.onAgentComplete', label: 'Notify on agent complete', section: 'notifications' },
|
|
92
|
+
{ key: 'notifications.onPlanApproval', label: 'Notify on plan approval', section: 'notifications' },
|
|
93
|
+
] },
|
|
94
|
+
{ id: 'auth', label: 'Auth', fields: [
|
|
95
|
+
{ key: 'auth.enabled', label: 'Auth enabled', section: 'auth' },
|
|
96
|
+
{ key: 'auth.loopback', label: 'Loopback mode', section: 'auth' },
|
|
97
|
+
{ key: 'auth.token', label: 'Auth token', section: 'auth' },
|
|
98
|
+
] },
|
|
99
|
+
{ id: 'agents', label: 'Agents', fields: [
|
|
100
|
+
{ key: 'agents.maxParallel', label: 'Max parallel agents', section: 'agents' },
|
|
101
|
+
{ key: 'agents.stuckThresholdMs', label: 'Stuck threshold', section: 'agents' },
|
|
102
|
+
{ key: 'agents.autoRestart', label: 'Auto restart', section: 'agents' },
|
|
103
|
+
{ key: 'workflow.artifactsEnabled', label: 'Artifacts enabled', section: 'agents' },
|
|
104
|
+
{ key: 'workflow.agentsDecideAutonomously', label: 'Autonomous decisions', section: 'agents' },
|
|
105
|
+
] },
|
|
106
|
+
{ id: 'dashboard', label: 'Dashboard', fields: [
|
|
107
|
+
{ key: 'dashboard.autoLaunchWeb', label: 'Auto-launch web', section: 'dashboard' },
|
|
108
|
+
{ key: 'dashboard.projectsDirectory', label: 'Projects directory', section: 'dashboard' },
|
|
109
|
+
{ key: 'dashboard.allowedRoots', label: 'Allowed roots', section: 'dashboard' },
|
|
110
|
+
] },
|
|
111
|
+
{ id: 'background', label: 'Background', fields: [
|
|
112
|
+
{ key: 'service.enabled', label: 'Background service', section: 'background' },
|
|
113
|
+
{ key: 'service.autostart', label: 'Auto-start service', section: 'background' },
|
|
114
|
+
] },
|
|
115
|
+
{ id: 'system-llm', label: 'System LLM', fields: [
|
|
116
|
+
{ key: 'systemLlm.enabled', label: 'System LLM enabled', section: 'system-llm' },
|
|
117
|
+
] },
|
|
118
|
+
{ id: 'headroom', label: 'Headroom', fields: [
|
|
119
|
+
{ key: 'headroom.enabled', label: 'Headroom enabled', section: 'headroom' },
|
|
120
|
+
{ key: 'headroom.port', label: 'Headroom port', section: 'headroom' },
|
|
121
|
+
{ key: 'headroom.budget', label: 'Headroom budget', section: 'headroom' },
|
|
122
|
+
{ key: 'headroom.backend', label: 'Headroom backend', section: 'headroom' },
|
|
123
|
+
] },
|
|
124
|
+
{ id: 'activity-log', label: 'Activity', fields: [
|
|
125
|
+
{ key: 'activity.log', label: 'Activity log', section: 'activity-log' },
|
|
126
|
+
] },
|
|
127
|
+
{ id: 'about', label: 'About', fields: [
|
|
128
|
+
{ key: 'about.version', label: 'Version', section: 'about' },
|
|
129
|
+
{ key: 'about.homepage', label: 'Homepage', section: 'about' },
|
|
130
|
+
{ key: 'about.license', label: 'License', section: 'about' },
|
|
131
|
+
] },
|
|
132
|
+
{ id: 'env-vars', label: 'Env Vars', fields: [
|
|
133
|
+
{ key: 'env.add', label: 'Add variable', section: 'env-vars' },
|
|
134
|
+
] },
|
|
135
|
+
{ id: 'providers', label: 'Providers', fields: [
|
|
136
|
+
{ key: 'providers.list', label: 'Provider list', section: 'providers' },
|
|
137
|
+
] },
|
|
138
|
+
{ id: 'memory', label: 'Memory', fields: [
|
|
139
|
+
{ key: 'memory.config', label: 'Memory config', section: 'memory' },
|
|
140
|
+
] },
|
|
141
|
+
{ id: 'skills', label: 'Skills', fields: [
|
|
142
|
+
{ key: 'skills.paths', label: 'Skill paths', section: 'skills' },
|
|
143
|
+
] },
|
|
144
|
+
{ id: 'backup', label: 'Backup', fields: [
|
|
145
|
+
{ key: 'backup.create', label: 'Create backup', section: 'backup' },
|
|
146
|
+
{ key: 'backup.restore', label: 'Restore backup', section: 'backup' },
|
|
147
|
+
] },
|
|
148
|
+
];
|
|
149
|
+
|
|
56
150
|
function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
|
|
57
151
|
const toast = useToast();
|
|
58
152
|
const [settings, setSettings] = useState<Settings>(initial);
|
|
@@ -145,6 +239,8 @@ function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
|
|
|
145
239
|
</div>
|
|
146
240
|
</header>
|
|
147
241
|
|
|
242
|
+
<SettingsSearch sections={SETTINGS_SECTIONS} onJump={onJumpSection} />
|
|
243
|
+
|
|
148
244
|
<nav className="settings-subnav" aria-label="Settings sections">
|
|
149
245
|
<button type="button" className={cn('settings-subnav-button', 'settings-subnav-button-all', showAll && 'settings-subnav-button-active')} onClick={() => onJumpSection(null)} title="Show all settings sections">All</button>
|
|
150
246
|
{SECTION_LINKS.map((s) => (
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// src/web/views/memory/MemoryGraphLegend.tsx — color legend for node groups.
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { cn } from '../../lib/utils';
|
|
5
|
+
|
|
6
|
+
const LEGEND_ITEMS = [
|
|
7
|
+
{ color: '#8b5cf6', label: 'Note / Default' },
|
|
8
|
+
{ color: '#34d399', label: 'Entity (LightRAG)' },
|
|
9
|
+
{ color: '#fbbf24', label: 'Concept (LightRAG)' },
|
|
10
|
+
{ color: '#f87171', label: 'Root / Ungrouped' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
type Props = { className?: string };
|
|
14
|
+
|
|
15
|
+
export function MemoryGraphLegend({ className }: Props) {
|
|
16
|
+
return (
|
|
17
|
+
<div className={cn('memory-graph-legend', className)}>
|
|
18
|
+
{LEGEND_ITEMS.map((item) => (
|
|
19
|
+
<span key={item.label} className="memory-graph-legend-item">
|
|
20
|
+
<span
|
|
21
|
+
className="memory-graph-legend-dot"
|
|
22
|
+
style={{ background: item.color }}
|
|
23
|
+
/>
|
|
24
|
+
{item.label}
|
|
25
|
+
</span>
|
|
26
|
+
))}
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// src/web/views/memory/MemoryGraphPanel.tsx — Memory Graph panel (v4.9).
|
|
2
|
+
//
|
|
3
|
+
// Interactive SVG knowledge graph spanning LightRAG entities + Obsidian wikilinks.
|
|
4
|
+
|
|
5
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
6
|
+
import { Network, RefreshCw } from 'lucide-react';
|
|
7
|
+
import { Card, CardMeta, CardTitle } from '../../components/Card';
|
|
8
|
+
import { Spinner } from '../../components/Spinner';
|
|
9
|
+
import { useToast } from '../../components/Toast';
|
|
10
|
+
import { api } from '../../lib/api';
|
|
11
|
+
import { cn } from '../../lib/utils';
|
|
12
|
+
import { MemoryGraphView, type GraphNode, type GraphData } from './MemoryGraphView';
|
|
13
|
+
import { MemoryGraphLegend } from './MemoryGraphLegend';
|
|
14
|
+
|
|
15
|
+
type GraphResponse = {
|
|
16
|
+
nodes: GraphNode[];
|
|
17
|
+
edges: Array<{ source: string; target: string; type: string; weight: number }>;
|
|
18
|
+
totalNodes: number;
|
|
19
|
+
totalEdges: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type Props = { refreshKey: number };
|
|
23
|
+
|
|
24
|
+
export function MemoryGraphPanel({ refreshKey }: Props) {
|
|
25
|
+
const toast = useToast();
|
|
26
|
+
const [data, setData] = useState<GraphData>({ nodes: [], edges: [] });
|
|
27
|
+
const [loading, setLoading] = useState(true);
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
const [filter, setFilter] = useState('');
|
|
30
|
+
const [depth, setDepth] = useState(2);
|
|
31
|
+
const [root, setRoot] = useState('');
|
|
32
|
+
const [stats, setStats] = useState<{ totalNodes: number; totalEdges: number } | null>(null);
|
|
33
|
+
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
|
34
|
+
|
|
35
|
+
const fetchGraph = useCallback(async () => {
|
|
36
|
+
setLoading(true);
|
|
37
|
+
setError(null);
|
|
38
|
+
try {
|
|
39
|
+
const params = new URLSearchParams({ limit: '200' });
|
|
40
|
+
if (root) params.set('root', root);
|
|
41
|
+
params.set('depth', String(depth));
|
|
42
|
+
const res = await api.get<GraphResponse>(`/memory/graph?${params}`);
|
|
43
|
+
setData({ nodes: res.nodes || [], edges: res.edges || [] });
|
|
44
|
+
setStats({ totalNodes: res.totalNodes ?? res.nodes?.length ?? 0, totalEdges: res.totalEdges ?? res.edges?.length ?? 0 });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
setError((err as Error).message);
|
|
47
|
+
toast.error(`Graph load failed: ${(err as Error).message}`);
|
|
48
|
+
} finally {
|
|
49
|
+
setLoading(false);
|
|
50
|
+
}
|
|
51
|
+
}, [root, depth, toast]);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
fetchGraph();
|
|
55
|
+
}, [fetchGraph, refreshKey]);
|
|
56
|
+
|
|
57
|
+
const onNodeClick = useCallback((node: GraphNode) => {
|
|
58
|
+
setSelectedNode((prev) => (prev?.id === node.id ? null : node));
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
// Filter nodes by label.
|
|
62
|
+
const filteredData = filter.trim()
|
|
63
|
+
? {
|
|
64
|
+
nodes: data.nodes.filter((n) => n.label.toLowerCase().includes(filter.toLowerCase())),
|
|
65
|
+
edges: data.edges.filter(
|
|
66
|
+
(e) =>
|
|
67
|
+
data.nodes.some((n) => n.id === e.source && n.label.toLowerCase().includes(filter.toLowerCase())) ||
|
|
68
|
+
data.nodes.some((n) => n.id === e.target && n.label.toLowerCase().includes(filter.toLowerCase())),
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
: data;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="memory-panel-content">
|
|
75
|
+
{/* ── Controls ─────────────────────────────────────────────── */}
|
|
76
|
+
<Card>
|
|
77
|
+
<CardTitle>
|
|
78
|
+
<Network size={14} /> Memory Graph
|
|
79
|
+
</CardTitle>
|
|
80
|
+
<CardMeta>Interactive knowledge graph — LightRAG entities + Obsidian wikilinks. Drag to pan, scroll to zoom.</CardMeta>
|
|
81
|
+
|
|
82
|
+
<div className="memory-graph-controls">
|
|
83
|
+
<div className="memory-graph-search-row">
|
|
84
|
+
<label htmlFor="graph-filter" className="sr-only">Filter nodes</label>
|
|
85
|
+
<input
|
|
86
|
+
id="graph-filter"
|
|
87
|
+
type="text"
|
|
88
|
+
className="input"
|
|
89
|
+
placeholder="Filter nodes…"
|
|
90
|
+
value={filter}
|
|
91
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
92
|
+
style={{ maxWidth: 240 }}
|
|
93
|
+
/>
|
|
94
|
+
<input
|
|
95
|
+
type="text"
|
|
96
|
+
className="input"
|
|
97
|
+
placeholder="Root note id (optional)"
|
|
98
|
+
value={root}
|
|
99
|
+
onChange={(e) => setRoot(e.target.value)}
|
|
100
|
+
style={{ maxWidth: 240 }}
|
|
101
|
+
title="Start from a specific note"
|
|
102
|
+
/>
|
|
103
|
+
<label htmlFor="graph-depth" className="sr-only">Depth</label>
|
|
104
|
+
<span className="muted text-sm" style={{ whiteSpace: 'nowrap' }}>
|
|
105
|
+
Depth
|
|
106
|
+
<input
|
|
107
|
+
id="graph-depth"
|
|
108
|
+
type="range"
|
|
109
|
+
min={1}
|
|
110
|
+
max={3}
|
|
111
|
+
value={depth}
|
|
112
|
+
onChange={(e) => setDepth(parseInt(e.target.value, 10))}
|
|
113
|
+
style={{ marginLeft: 8, verticalAlign: 'middle' }}
|
|
114
|
+
/>
|
|
115
|
+
{depth}
|
|
116
|
+
</span>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
className="icon-btn"
|
|
120
|
+
onClick={fetchGraph}
|
|
121
|
+
title="Refresh graph"
|
|
122
|
+
disabled={loading}
|
|
123
|
+
>
|
|
124
|
+
<RefreshCw size={14} className={loading ? 'memory-spin' : ''} />
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<MemoryGraphLegend />
|
|
129
|
+
</div>
|
|
130
|
+
</Card>
|
|
131
|
+
|
|
132
|
+
{/* ── Graph canvas ─────────────────────────────────────────── */}
|
|
133
|
+
<Card>
|
|
134
|
+
{loading && !data.nodes.length ? (
|
|
135
|
+
<div className="view-loading">
|
|
136
|
+
<Spinner size="lg" />
|
|
137
|
+
<p>Loading graph…</p>
|
|
138
|
+
</div>
|
|
139
|
+
) : error && !data.nodes.length ? (
|
|
140
|
+
<div className="muted text-sm">Failed to load graph: {error}</div>
|
|
141
|
+
) : data.nodes.length === 0 ? (
|
|
142
|
+
<div className="muted text-sm">
|
|
143
|
+
No graph data available. Index some notes first with LightRAG or add wikilinks to your vault.
|
|
144
|
+
</div>
|
|
145
|
+
) : (
|
|
146
|
+
<div style={{ position: 'relative' }}>
|
|
147
|
+
<MemoryGraphView
|
|
148
|
+
data={filteredData}
|
|
149
|
+
onNodeClick={onNodeClick}
|
|
150
|
+
className="memory-graph-canvas-svg"
|
|
151
|
+
/>
|
|
152
|
+
{filter && (
|
|
153
|
+
<div className="text-xs muted" style={{ marginTop: 4 }}>
|
|
154
|
+
Showing {filteredData.nodes.length} of {data.nodes.length} nodes
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</Card>
|
|
160
|
+
|
|
161
|
+
{/* ── Node detail ──────────────────────────────────────────── */}
|
|
162
|
+
{selectedNode && (
|
|
163
|
+
<Card>
|
|
164
|
+
<CardTitle>{selectedNode.label}</CardTitle>
|
|
165
|
+
<CardMeta>
|
|
166
|
+
<code>{selectedNode.id}</code>
|
|
167
|
+
</CardMeta>
|
|
168
|
+
<dl className="memory-config-row">
|
|
169
|
+
<dt>Type</dt>
|
|
170
|
+
<dd><code>{selectedNode.type}</code></dd>
|
|
171
|
+
<dt>Group</dt>
|
|
172
|
+
<dd><code>{selectedNode.group}</code></dd>
|
|
173
|
+
<dt>Size</dt>
|
|
174
|
+
<dd>{selectedNode.size}</dd>
|
|
175
|
+
</dl>
|
|
176
|
+
</Card>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
{/* ── Stats footer ─────────────────────────────────────────── */}
|
|
180
|
+
{stats && (
|
|
181
|
+
<Card>
|
|
182
|
+
<div className="memory-graph-stats">
|
|
183
|
+
<span>{stats.totalNodes} node{stats.totalNodes !== 1 ? 's' : ''}</span>
|
|
184
|
+
<span className="muted">·</span>
|
|
185
|
+
<span>{stats.totalEdges} edge{stats.totalEdges !== 1 ? 's' : ''}</span>
|
|
186
|
+
{filter && <span className="muted">· {filteredData.nodes.length} shown</span>}
|
|
187
|
+
</div>
|
|
188
|
+
</Card>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|