@andespindola/brainlink 1.6.5 → 1.6.7
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/README.md +13 -1
- package/dist/application/frontend/client/account-menu.js +185 -0
- package/dist/application/frontend/client/elements.js +1 -0
- package/dist/application/frontend/client/live-refresh.js +42 -0
- package/dist/application/frontend/client/worker-bootstrap.js +2 -0
- package/dist/application/frontend/client-js.js +4 -0
- package/dist/application/frontend/settings-page.js +322 -0
- package/dist/application/server/graph-version.js +11 -0
- package/dist/application/server/routes.js +25 -0
- package/dist/application/server/settings-store.js +51 -0
- package/dist/application/start-server.js +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -611,7 +611,19 @@ blink server --host 127.0.0.1 --port 4321
|
|
|
611
611
|
```
|
|
612
612
|
|
|
613
613
|
By default, the server uses `$HOME/.brainlink/vault`. Pass `--vault ./vault` only when you want to inspect a custom vault.
|
|
614
|
-
By default, the server watches Markdown files in local filesystem vaults and reindexes after note changes. Use `--no-watch` to disable realtime reindexing.
|
|
614
|
+
By default, the server watches Markdown files in local filesystem vaults and reindexes after note changes. Use `--no-watch` to disable realtime reindexing. While the graph is open it polls `/api/graph-version` and re-fetches automatically, so notes an agent writes (via MCP) surface live without a manual reload.
|
|
615
|
+
|
|
616
|
+
### Local vs exposed mode
|
|
617
|
+
|
|
618
|
+
`blink server` has two modes, mirroring the local/remote split of the MCP server:
|
|
619
|
+
|
|
620
|
+
- **Local (default):** loopback-only, no authentication — for your own machine.
|
|
621
|
+
- **Exposed (`blink server --expose`):** intended to sit behind a TLS-terminating reverse proxy. Every route is gated behind a **branded login** (not the browser's Basic-auth prompt); it allows a non-loopback bind and serves a public `/healthz`. Credentials come from a persistent store (`<brainlink-home>/web-auth.json`, seeded once from `BRAINLINK_WEB_USER` + `BRAINLINK_WEB_PASSWORD`); the session is a signed HttpOnly cookie, and the password can be changed at `/change-password` (the change persists across restarts). Exposed mode refuses to start without configured credentials.
|
|
622
|
+
|
|
623
|
+
```bash
|
|
624
|
+
BRAINLINK_WEB_USER="you" BRAINLINK_WEB_PASSWORD="change-me" \
|
|
625
|
+
blink server --host 127.0.0.1 --port 4321 --no-open --expose
|
|
626
|
+
```
|
|
615
627
|
By default, `blink server` tries to open the graph in a native desktop GUI window:
|
|
616
628
|
- macOS: Swift + WebKit
|
|
617
629
|
- Windows: PowerShell WinForms WebBrowser
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Client module (served as a plain string, same pattern as live-refresh.ts) that
|
|
2
|
+
// injects an account control into the graph page when server-side auth is
|
|
3
|
+
// enabled. In local (no-auth) mode there is no account, so the control is never
|
|
4
|
+
// shown. The bootstrap calls startAccountMenu(); the module only defines it.
|
|
5
|
+
// Styling mirrors the branded auth pages: dark #08131d surfaces, subtle borders,
|
|
6
|
+
// and the #5aa8ff accent, kept unobtrusive in the top-right corner.
|
|
7
|
+
export const createAccountMenuJs = () => `
|
|
8
|
+
const startAccountMenu = () => {
|
|
9
|
+
if (typeof fetch !== 'function' || typeof document === 'undefined' || !document.body) {
|
|
10
|
+
return
|
|
11
|
+
}
|
|
12
|
+
if (document.getElementById('bl-account')) {
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const injectStyles = () => {
|
|
17
|
+
if (document.getElementById('bl-account-styles')) {
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
const style = document.createElement('style')
|
|
21
|
+
style.id = 'bl-account-styles'
|
|
22
|
+
style.textContent = [
|
|
23
|
+
'.bl-account {',
|
|
24
|
+
' position: fixed;',
|
|
25
|
+
' top: 12px;',
|
|
26
|
+
' right: 12px;',
|
|
27
|
+
' z-index: 2147483000;',
|
|
28
|
+
' font-family: "Crowquill Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;',
|
|
29
|
+
'}',
|
|
30
|
+
'.bl-account-button {',
|
|
31
|
+
' display: inline-flex;',
|
|
32
|
+
' align-items: center;',
|
|
33
|
+
' gap: 6px;',
|
|
34
|
+
' height: 32px;',
|
|
35
|
+
' padding: 0 12px;',
|
|
36
|
+
' border: 1px solid rgba(143, 172, 204, 0.18);',
|
|
37
|
+
' border-radius: 8px;',
|
|
38
|
+
' background: rgba(13, 24, 35, 0.92);',
|
|
39
|
+
' color: #edf4ff;',
|
|
40
|
+
' font: inherit;',
|
|
41
|
+
' font-size: 12px;',
|
|
42
|
+
' cursor: pointer;',
|
|
43
|
+
' transition: border-color 120ms ease;',
|
|
44
|
+
'}',
|
|
45
|
+
'.bl-account-button:hover,',
|
|
46
|
+
'.bl-account-button:focus {',
|
|
47
|
+
' border-color: #5aa8ff;',
|
|
48
|
+
' outline: none;',
|
|
49
|
+
'}',
|
|
50
|
+
'.bl-account-menu {',
|
|
51
|
+
' position: absolute;',
|
|
52
|
+
' top: 40px;',
|
|
53
|
+
' right: 0;',
|
|
54
|
+
' min-width: 168px;',
|
|
55
|
+
' padding: 6px;',
|
|
56
|
+
' border: 1px solid rgba(143, 172, 204, 0.18);',
|
|
57
|
+
' border-radius: 10px;',
|
|
58
|
+
' background: rgba(13, 24, 35, 0.98);',
|
|
59
|
+
' box-shadow: 0 18px 60px rgba(1, 6, 13, 0.48);',
|
|
60
|
+
' display: grid;',
|
|
61
|
+
' gap: 2px;',
|
|
62
|
+
'}',
|
|
63
|
+
'.bl-account-menu[hidden] {',
|
|
64
|
+
' display: none;',
|
|
65
|
+
'}',
|
|
66
|
+
'.bl-account-item {',
|
|
67
|
+
' display: block;',
|
|
68
|
+
' width: 100%;',
|
|
69
|
+
' padding: 8px 10px;',
|
|
70
|
+
' border: 0;',
|
|
71
|
+
' border-radius: 6px;',
|
|
72
|
+
' background: transparent;',
|
|
73
|
+
' color: #edf4ff;',
|
|
74
|
+
' font: inherit;',
|
|
75
|
+
' font-size: 12px;',
|
|
76
|
+
' text-align: left;',
|
|
77
|
+
' cursor: pointer;',
|
|
78
|
+
'}',
|
|
79
|
+
'.bl-account-item:hover,',
|
|
80
|
+
'.bl-account-item:focus {',
|
|
81
|
+
' background: rgba(90, 168, 255, 0.18);',
|
|
82
|
+
' outline: none;',
|
|
83
|
+
'}'
|
|
84
|
+
].join('\\n')
|
|
85
|
+
document.head.appendChild(style)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const build = (user) => {
|
|
89
|
+
injectStyles()
|
|
90
|
+
|
|
91
|
+
const root = document.createElement('div')
|
|
92
|
+
root.id = 'bl-account'
|
|
93
|
+
root.className = 'bl-account'
|
|
94
|
+
|
|
95
|
+
const button = document.createElement('button')
|
|
96
|
+
button.type = 'button'
|
|
97
|
+
button.className = 'bl-account-button'
|
|
98
|
+
button.textContent = typeof user === 'string' && user.length > 0 ? user : 'Account'
|
|
99
|
+
button.setAttribute('aria-haspopup', 'true')
|
|
100
|
+
button.setAttribute('aria-expanded', 'false')
|
|
101
|
+
|
|
102
|
+
const menu = document.createElement('div')
|
|
103
|
+
menu.className = 'bl-account-menu'
|
|
104
|
+
menu.setAttribute('role', 'menu')
|
|
105
|
+
menu.hidden = true
|
|
106
|
+
|
|
107
|
+
const addItem = (label, onSelect) => {
|
|
108
|
+
const item = document.createElement('button')
|
|
109
|
+
item.type = 'button'
|
|
110
|
+
item.className = 'bl-account-item'
|
|
111
|
+
item.setAttribute('role', 'menuitem')
|
|
112
|
+
item.textContent = label
|
|
113
|
+
item.addEventListener('click', onSelect)
|
|
114
|
+
menu.appendChild(item)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const closeMenu = () => {
|
|
118
|
+
menu.hidden = true
|
|
119
|
+
button.setAttribute('aria-expanded', 'false')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const openMenu = () => {
|
|
123
|
+
menu.hidden = false
|
|
124
|
+
button.setAttribute('aria-expanded', 'true')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
addItem('Settings', () => {
|
|
128
|
+
closeMenu()
|
|
129
|
+
window.location.href = '/settings'
|
|
130
|
+
})
|
|
131
|
+
addItem('Change password', () => {
|
|
132
|
+
closeMenu()
|
|
133
|
+
window.location.href = '/change-password'
|
|
134
|
+
})
|
|
135
|
+
addItem('Sign out', () => {
|
|
136
|
+
closeMenu()
|
|
137
|
+
fetch('/api/logout', { method: 'POST' })
|
|
138
|
+
.catch(() => {})
|
|
139
|
+
.then(() => {
|
|
140
|
+
window.location.href = '/login'
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
button.addEventListener('click', (event) => {
|
|
145
|
+
event.stopPropagation()
|
|
146
|
+
if (menu.hidden) {
|
|
147
|
+
openMenu()
|
|
148
|
+
} else {
|
|
149
|
+
closeMenu()
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
document.addEventListener('click', (event) => {
|
|
154
|
+
if (!root.contains(event.target)) {
|
|
155
|
+
closeMenu()
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
document.addEventListener('keydown', (event) => {
|
|
160
|
+
if (event.key === 'Escape') {
|
|
161
|
+
closeMenu()
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
root.appendChild(button)
|
|
166
|
+
root.appendChild(menu)
|
|
167
|
+
document.body.appendChild(root)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
fetch('/api/auth-status', { headers: { accept: 'application/json' } })
|
|
171
|
+
.then((response) => {
|
|
172
|
+
if (!response.ok) {
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
return response.json().catch(() => null)
|
|
176
|
+
})
|
|
177
|
+
.then((payload) => {
|
|
178
|
+
if (!payload || payload.enabled !== true) {
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
build(payload.user)
|
|
182
|
+
})
|
|
183
|
+
.catch(() => {})
|
|
184
|
+
}
|
|
185
|
+
`;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const createLiveRefreshJs = () => `
|
|
2
|
+
// Poll a cheap generation counter so the graph reflects vault changes live:
|
|
3
|
+
// when an agent writes notes via MCP, the watcher bumps the version and the
|
|
4
|
+
// client re-fetches the current viewport and refreshes the context list, so new
|
|
5
|
+
// nodes and contexts surface without a manual reload.
|
|
6
|
+
const liveRefreshIntervalMs = 3000
|
|
7
|
+
|
|
8
|
+
const pollGraphVersion = async () => {
|
|
9
|
+
try {
|
|
10
|
+
const response = await fetch('/api/graph-version', { headers: { accept: 'application/json' } })
|
|
11
|
+
if (!response.ok) {
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
const payload = await response.json()
|
|
15
|
+
const version = typeof payload?.version === 'number' ? payload.version : null
|
|
16
|
+
if (version === null) {
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
if (state.graphVersion === null) {
|
|
20
|
+
state.graphVersion = version
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
if (version !== state.graphVersion) {
|
|
24
|
+
state.graphVersion = version
|
|
25
|
+
await loadContexts().catch((error) => console.error(error))
|
|
26
|
+
scheduleChunkFetch()
|
|
27
|
+
}
|
|
28
|
+
} catch (error) {
|
|
29
|
+
// Transient poll failures (offline, redeploy) are non-fatal; the next tick retries.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const startLiveRefresh = () => {
|
|
34
|
+
if (typeof fetch !== 'function') {
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
pollGraphVersion().catch(() => {})
|
|
38
|
+
setInterval(() => {
|
|
39
|
+
pollGraphVersion().catch(() => {})
|
|
40
|
+
}, liveRefreshIntervalMs)
|
|
41
|
+
}
|
|
42
|
+
`;
|
|
@@ -8,6 +8,8 @@ import { createChunkFetchJs } from './client/chunk-fetch.js';
|
|
|
8
8
|
import { createInputJs } from './client/input.js';
|
|
9
9
|
import { createUploadJs } from './client/upload.js';
|
|
10
10
|
import { createControlsJs } from './client/controls.js';
|
|
11
|
+
import { createLiveRefreshJs } from './client/live-refresh.js';
|
|
12
|
+
import { createAccountMenuJs } from './client/account-menu.js';
|
|
11
13
|
import { createGraphRendererJs } from './client/graph-renderer.js';
|
|
12
14
|
import { createWorkerBootstrapJs } from './client/worker-bootstrap.js';
|
|
13
15
|
export const createClientJs = () => [
|
|
@@ -21,6 +23,8 @@ export const createClientJs = () => [
|
|
|
21
23
|
createInputJs(),
|
|
22
24
|
createUploadJs(),
|
|
23
25
|
createControlsJs(),
|
|
26
|
+
createLiveRefreshJs(),
|
|
27
|
+
createAccountMenuJs(),
|
|
24
28
|
createGraphRendererJs(),
|
|
25
29
|
createWorkerBootstrapJs()
|
|
26
30
|
].join('');
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// Self-contained branded Settings page for the graph web server. Served as a
|
|
2
|
+
// plain string (inline <style> + inline <script>, no external assets, no
|
|
3
|
+
// framework) so it works identically for a global npm install. The visual
|
|
4
|
+
// identity mirrors the auth pages (see login-page.ts / oauth-authorize-page.ts):
|
|
5
|
+
// dark theme on #08131d, the same grid/radial gradient, Crowquill Mono with a
|
|
6
|
+
// monospace fallback, and the #5aa8ff accent used for graph nodes.
|
|
7
|
+
const settingsStyles = () => `:root {
|
|
8
|
+
color-scheme: dark;
|
|
9
|
+
--bg: #08131d;
|
|
10
|
+
--panel: #0d1823;
|
|
11
|
+
--panel-strong: #112130;
|
|
12
|
+
--line: rgba(143, 172, 204, 0.18);
|
|
13
|
+
--text: #edf4ff;
|
|
14
|
+
--muted: #97a9bd;
|
|
15
|
+
--accent: #5aa8ff;
|
|
16
|
+
--accent-weak: rgba(90, 168, 255, 0.18);
|
|
17
|
+
--danger: #ff6b6b;
|
|
18
|
+
--success: #57d9a3;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
* {
|
|
22
|
+
box-sizing: border-box;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
html,
|
|
26
|
+
body {
|
|
27
|
+
width: 100%;
|
|
28
|
+
min-height: 100%;
|
|
29
|
+
margin: 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
body {
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
justify-content: center;
|
|
36
|
+
min-height: 100vh;
|
|
37
|
+
min-height: 100dvh;
|
|
38
|
+
padding: 24px;
|
|
39
|
+
color: var(--text);
|
|
40
|
+
font-family: "Crowquill Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
41
|
+
background:
|
|
42
|
+
linear-gradient(rgba(164, 197, 230, 0.05) 1px, transparent 1px),
|
|
43
|
+
linear-gradient(90deg, rgba(164, 197, 230, 0.05) 1px, transparent 1px),
|
|
44
|
+
radial-gradient(circle at top, rgba(43, 93, 143, 0.22), transparent 44%),
|
|
45
|
+
#08131d;
|
|
46
|
+
background-size: 28px 28px, 28px 28px, auto;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
button,
|
|
50
|
+
input,
|
|
51
|
+
select {
|
|
52
|
+
font: inherit;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.settings-card {
|
|
56
|
+
width: 100%;
|
|
57
|
+
max-width: 440px;
|
|
58
|
+
padding: 32px 28px;
|
|
59
|
+
border: 1px solid var(--line);
|
|
60
|
+
border-radius: 14px;
|
|
61
|
+
background: rgba(13, 24, 35, 0.92);
|
|
62
|
+
box-shadow: 0 24px 80px rgba(1, 6, 13, 0.48);
|
|
63
|
+
backdrop-filter: blur(8px);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.settings-brand {
|
|
67
|
+
display: grid;
|
|
68
|
+
gap: 4px;
|
|
69
|
+
margin-bottom: 24px;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.settings-brand strong {
|
|
73
|
+
font-size: 26px;
|
|
74
|
+
letter-spacing: 0.01em;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.settings-brand span {
|
|
78
|
+
color: var(--muted);
|
|
79
|
+
font-size: 13px;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.settings-form {
|
|
83
|
+
display: grid;
|
|
84
|
+
gap: 16px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.settings-field {
|
|
88
|
+
display: grid;
|
|
89
|
+
gap: 7px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.settings-field label {
|
|
93
|
+
color: var(--muted);
|
|
94
|
+
font-size: 12px;
|
|
95
|
+
letter-spacing: 0.02em;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.settings-field input,
|
|
99
|
+
.settings-field select {
|
|
100
|
+
width: 100%;
|
|
101
|
+
height: 42px;
|
|
102
|
+
padding: 0 14px;
|
|
103
|
+
border: 1px solid var(--line);
|
|
104
|
+
border-radius: 8px;
|
|
105
|
+
outline: none;
|
|
106
|
+
background: rgba(12, 24, 36, 0.94);
|
|
107
|
+
color: var(--text);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.settings-field input:focus,
|
|
111
|
+
.settings-field select:focus {
|
|
112
|
+
border-color: var(--accent);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.settings-check {
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
gap: 10px;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.settings-check input {
|
|
122
|
+
width: 18px;
|
|
123
|
+
height: 18px;
|
|
124
|
+
margin: 0;
|
|
125
|
+
accent-color: var(--accent);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.settings-check label {
|
|
129
|
+
color: var(--text);
|
|
130
|
+
font-size: 13px;
|
|
131
|
+
letter-spacing: 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.settings-submit {
|
|
135
|
+
height: 44px;
|
|
136
|
+
margin-top: 4px;
|
|
137
|
+
border: 1px solid var(--accent);
|
|
138
|
+
border-radius: 8px;
|
|
139
|
+
background: var(--accent-weak);
|
|
140
|
+
color: var(--text);
|
|
141
|
+
cursor: pointer;
|
|
142
|
+
transition: background 120ms ease, opacity 120ms ease;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.settings-submit:hover:not(:disabled),
|
|
146
|
+
.settings-submit:focus:not(:disabled) {
|
|
147
|
+
background: rgba(90, 168, 255, 0.3);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.settings-submit:disabled {
|
|
151
|
+
cursor: progress;
|
|
152
|
+
opacity: 0.6;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.settings-message {
|
|
156
|
+
min-height: 18px;
|
|
157
|
+
margin: 0;
|
|
158
|
+
font-size: 12px;
|
|
159
|
+
line-height: 1.4;
|
|
160
|
+
overflow-wrap: anywhere;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.settings-message[hidden] {
|
|
164
|
+
display: none;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.settings-message.is-error {
|
|
168
|
+
color: var(--danger);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.settings-message.is-success {
|
|
172
|
+
color: var(--success);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.settings-back {
|
|
176
|
+
display: inline-block;
|
|
177
|
+
margin-top: 20px;
|
|
178
|
+
color: var(--muted);
|
|
179
|
+
font-size: 12px;
|
|
180
|
+
text-decoration: none;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.settings-back:hover,
|
|
184
|
+
.settings-back:focus {
|
|
185
|
+
color: var(--accent);
|
|
186
|
+
}`;
|
|
187
|
+
const settingsScript = () => `(function () {
|
|
188
|
+
var form = document.getElementById('settingsForm');
|
|
189
|
+
var button = document.getElementById('submit');
|
|
190
|
+
var message = document.getElementById('message');
|
|
191
|
+
var searchMode = document.getElementById('defaultSearchMode');
|
|
192
|
+
var contextStrategy = document.getElementById('defaultContextStrategy');
|
|
193
|
+
var contextTokens = document.getElementById('defaultContextTokens');
|
|
194
|
+
var autoVersion = document.getElementById('autoVersion');
|
|
195
|
+
var defaultAgent = document.getElementById('defaultAgent');
|
|
196
|
+
|
|
197
|
+
function show(text, kind) {
|
|
198
|
+
message.textContent = text;
|
|
199
|
+
message.className = 'settings-message ' + (kind === 'success' ? 'is-success' : 'is-error');
|
|
200
|
+
message.hidden = false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function clearMessage() {
|
|
204
|
+
message.textContent = '';
|
|
205
|
+
message.className = 'settings-message';
|
|
206
|
+
message.hidden = true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function setValue(select, value) {
|
|
210
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
211
|
+
select.value = value;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
fetch('/api/settings', { headers: { accept: 'application/json' } })
|
|
216
|
+
.then(function (response) {
|
|
217
|
+
if (!response.ok) {
|
|
218
|
+
return {};
|
|
219
|
+
}
|
|
220
|
+
return response.json().catch(function () {
|
|
221
|
+
return {};
|
|
222
|
+
});
|
|
223
|
+
})
|
|
224
|
+
.then(function (data) {
|
|
225
|
+
data = data || {};
|
|
226
|
+
setValue(searchMode, data.defaultSearchMode);
|
|
227
|
+
setValue(contextStrategy, data.defaultContextStrategy);
|
|
228
|
+
if (typeof data.defaultContextTokens === 'number') {
|
|
229
|
+
contextTokens.value = String(data.defaultContextTokens);
|
|
230
|
+
}
|
|
231
|
+
autoVersion.checked = data.autoVersion === true;
|
|
232
|
+
if (typeof data.defaultAgent === 'string') {
|
|
233
|
+
defaultAgent.value = data.defaultAgent;
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
.catch(function () {
|
|
237
|
+
// A failed prefetch leaves the defaults in place; the form is still usable.
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
form.addEventListener('submit', function (event) {
|
|
241
|
+
event.preventDefault();
|
|
242
|
+
clearMessage();
|
|
243
|
+
button.disabled = true;
|
|
244
|
+
|
|
245
|
+
var payload = {
|
|
246
|
+
defaultSearchMode: searchMode.value,
|
|
247
|
+
defaultContextStrategy: contextStrategy.value,
|
|
248
|
+
defaultContextTokens: Number(contextTokens.value),
|
|
249
|
+
autoVersion: autoVersion.checked,
|
|
250
|
+
defaultAgent: defaultAgent.value
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
fetch('/api/settings', {
|
|
254
|
+
method: 'POST',
|
|
255
|
+
headers: { 'content-type': 'application/json' },
|
|
256
|
+
body: JSON.stringify(payload)
|
|
257
|
+
})
|
|
258
|
+
.then(function (response) {
|
|
259
|
+
if (response.ok) {
|
|
260
|
+
show('Saved', 'success');
|
|
261
|
+
} else {
|
|
262
|
+
show('Unable to save settings. Please try again.', 'error');
|
|
263
|
+
}
|
|
264
|
+
button.disabled = false;
|
|
265
|
+
})
|
|
266
|
+
.catch(function () {
|
|
267
|
+
show('Something went wrong. Please try again.', 'error');
|
|
268
|
+
button.disabled = false;
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
})();`;
|
|
272
|
+
export const createSettingsPageHtml = () => `<!DOCTYPE html>
|
|
273
|
+
<html lang="en">
|
|
274
|
+
<head>
|
|
275
|
+
<meta charset="utf-8" />
|
|
276
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
277
|
+
<title>Settings · Brainlink</title>
|
|
278
|
+
<style>${settingsStyles()}</style>
|
|
279
|
+
</head>
|
|
280
|
+
<body>
|
|
281
|
+
<main class="settings-card">
|
|
282
|
+
<div class="settings-brand">
|
|
283
|
+
<strong>Brainlink</strong>
|
|
284
|
+
<span>Settings</span>
|
|
285
|
+
</div>
|
|
286
|
+
<form id="settingsForm" class="settings-form" novalidate>
|
|
287
|
+
<div class="settings-field">
|
|
288
|
+
<label for="defaultSearchMode">Default search mode</label>
|
|
289
|
+
<select id="defaultSearchMode" name="defaultSearchMode">
|
|
290
|
+
<option value="fts">fts</option>
|
|
291
|
+
<option value="semantic">semantic</option>
|
|
292
|
+
<option value="hybrid">hybrid</option>
|
|
293
|
+
</select>
|
|
294
|
+
</div>
|
|
295
|
+
<div class="settings-field">
|
|
296
|
+
<label for="defaultContextStrategy">Default context strategy</label>
|
|
297
|
+
<select id="defaultContextStrategy" name="defaultContextStrategy">
|
|
298
|
+
<option value="rag">rag</option>
|
|
299
|
+
<option value="cag">cag</option>
|
|
300
|
+
<option value="auto">auto</option>
|
|
301
|
+
</select>
|
|
302
|
+
</div>
|
|
303
|
+
<div class="settings-field">
|
|
304
|
+
<label for="defaultContextTokens">Default context tokens</label>
|
|
305
|
+
<input id="defaultContextTokens" name="defaultContextTokens" type="number" min="1" />
|
|
306
|
+
</div>
|
|
307
|
+
<div class="settings-check">
|
|
308
|
+
<input id="autoVersion" name="autoVersion" type="checkbox" />
|
|
309
|
+
<label for="autoVersion">Auto-version vault</label>
|
|
310
|
+
</div>
|
|
311
|
+
<div class="settings-field">
|
|
312
|
+
<label for="defaultAgent">Default agent</label>
|
|
313
|
+
<input id="defaultAgent" name="defaultAgent" type="text" autocomplete="off" />
|
|
314
|
+
</div>
|
|
315
|
+
<p id="message" class="settings-message" role="alert" aria-live="polite" hidden></p>
|
|
316
|
+
<button id="submit" class="settings-submit" type="submit">Save</button>
|
|
317
|
+
</form>
|
|
318
|
+
<a class="settings-back" href="/">← Back</a>
|
|
319
|
+
</main>
|
|
320
|
+
<script>${settingsScript()}</script>
|
|
321
|
+
</body>
|
|
322
|
+
</html>`;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Monotonic generation counter for the graph. The vault watcher bumps it after
|
|
2
|
+
// every reindex, and the web client polls /api/graph-version to know when to
|
|
3
|
+
// re-fetch — so nodes and contexts written by an agent (via MCP) surface live
|
|
4
|
+
// in an open graph without a manual reload. Process-local by design: a restart
|
|
5
|
+
// resets it, which the client simply treats as one more change.
|
|
6
|
+
let version = 0;
|
|
7
|
+
export const bumpGraphVersion = () => {
|
|
8
|
+
version += 1;
|
|
9
|
+
return version;
|
|
10
|
+
};
|
|
11
|
+
export const getGraphVersion = () => version;
|
|
@@ -24,6 +24,9 @@ import { createClientWorkerJs } from '../frontend/client-worker-js.js';
|
|
|
24
24
|
import { createClientRenderWorkerJs } from '../frontend/client-render-worker-js.js';
|
|
25
25
|
import { createLoginPageHtml, createChangePasswordPageHtml } from '../frontend/login-page.js';
|
|
26
26
|
import { buildClearSessionCookie, buildSessionSetCookie, changeWebPassword, createSessionToken, parseCookies, resolveWebAuth, SESSION_COOKIE_NAME, verifySessionToken, verifyWebPassword } from './web-auth.js';
|
|
27
|
+
import { getGraphVersion } from './graph-version.js';
|
|
28
|
+
import { readAuthStatus, readEditableSettings, updateEditableSettings } from './settings-store.js';
|
|
29
|
+
import { createSettingsPageHtml } from '../frontend/settings-page.js';
|
|
27
30
|
import { contentTypes, createJsonResponse, isReadMethod, parsePositiveInteger } from './http.js';
|
|
28
31
|
import { parseMultipartForm } from './multipart.js';
|
|
29
32
|
const readRuntimeDefaults = async (url) => {
|
|
@@ -519,6 +522,28 @@ export const route = async (request, url, vaultPath, options = {}) => {
|
|
|
519
522
|
if (isReadMethod(request) && url.pathname === '/api/agents') {
|
|
520
523
|
return createResponse(createJsonResponse({ agents: await listAgents(vaultPath) }), 200, contentTypes['.json']);
|
|
521
524
|
}
|
|
525
|
+
// Cheap generation counter the client polls to auto-refresh when the vault
|
|
526
|
+
// changes (new nodes/contexts written via MCP surface live).
|
|
527
|
+
if (isReadMethod(request) && url.pathname === '/api/graph-version') {
|
|
528
|
+
return createResponse(createJsonResponse({ version: getGraphVersion() }), 200, contentTypes['.json']);
|
|
529
|
+
}
|
|
530
|
+
// Account: whether the exposed-mode login is active (drives the graph's
|
|
531
|
+
// account menu) plus the settings the hosted account can edit.
|
|
532
|
+
if (isReadMethod(request) && url.pathname === '/api/auth-status') {
|
|
533
|
+
const user = options.requireAuth ? (await resolveWebAuth())?.user ?? null : null;
|
|
534
|
+
return createResponse(createJsonResponse(await readAuthStatus(options.requireAuth === true, user)), 200, contentTypes['.json']);
|
|
535
|
+
}
|
|
536
|
+
if (isReadMethod(request) && url.pathname === '/api/settings') {
|
|
537
|
+
return createResponse(createJsonResponse(await readEditableSettings()), 200, contentTypes['.json']);
|
|
538
|
+
}
|
|
539
|
+
if (request.method === 'POST' && url.pathname === '/api/settings') {
|
|
540
|
+
const body = await readJsonBody(request);
|
|
541
|
+
const updated = await updateEditableSettings((body ?? {}));
|
|
542
|
+
return createResponse(createJsonResponse(updated), 200, contentTypes['.json']);
|
|
543
|
+
}
|
|
544
|
+
if (isReadMethod(request) && url.pathname === '/settings') {
|
|
545
|
+
return createResponse(createSettingsPageHtml(), 200, contentTypes['.html']);
|
|
546
|
+
}
|
|
522
547
|
if (isReadMethod(request) && url.pathname === '/api/graph-contexts') {
|
|
523
548
|
return createResponse(createJsonResponse({ contexts: await getGraphContexts(vaultPath, readAgentQuery(url)) }), 200, contentTypes['.json']);
|
|
524
549
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { getGlobalConfigPath, loadBrainlinkConfig, sanitizeContextStrategy, sanitizeSearchMode, writeRawConfig } from '../../infrastructure/config.js';
|
|
3
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
4
|
+
const readRawGlobalConfig = async () => {
|
|
5
|
+
try {
|
|
6
|
+
const raw = await readFile(getGlobalConfigPath(), 'utf8');
|
|
7
|
+
const parsed = JSON.parse(raw);
|
|
8
|
+
return isRecord(parsed) ? parsed : {};
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
const searchModes = new Set(['fts', 'semantic', 'hybrid']);
|
|
15
|
+
const contextStrategies = new Set(['rag', 'cag', 'auto']);
|
|
16
|
+
const buildSanitizedPatch = (patch) => ({
|
|
17
|
+
...(typeof patch.defaultSearchMode === 'string' && searchModes.has(patch.defaultSearchMode)
|
|
18
|
+
? { defaultSearchMode: sanitizeSearchMode(patch.defaultSearchMode) }
|
|
19
|
+
: {}),
|
|
20
|
+
...(typeof patch.defaultContextStrategy === 'string' && contextStrategies.has(patch.defaultContextStrategy)
|
|
21
|
+
? { defaultContextStrategy: sanitizeContextStrategy(patch.defaultContextStrategy) }
|
|
22
|
+
: {}),
|
|
23
|
+
...(typeof patch.defaultContextTokens === 'number' &&
|
|
24
|
+
Number.isFinite(patch.defaultContextTokens) &&
|
|
25
|
+
patch.defaultContextTokens > 0
|
|
26
|
+
? { defaultContextTokens: Math.floor(patch.defaultContextTokens) }
|
|
27
|
+
: {}),
|
|
28
|
+
...(typeof patch.autoVersion === 'boolean' ? { autoVersion: patch.autoVersion } : {}),
|
|
29
|
+
...(typeof patch.defaultAgent === 'string' && patch.defaultAgent.trim().length > 0
|
|
30
|
+
? { defaultAgent: patch.defaultAgent.trim() }
|
|
31
|
+
: {})
|
|
32
|
+
});
|
|
33
|
+
const projectEditableSettings = (config) => ({
|
|
34
|
+
defaultSearchMode: config.defaultSearchMode,
|
|
35
|
+
defaultContextStrategy: config.defaultContextStrategy,
|
|
36
|
+
defaultContextTokens: config.defaultContextTokens,
|
|
37
|
+
autoVersion: config.autoVersion,
|
|
38
|
+
defaultAgent: config.defaultAgent ?? ''
|
|
39
|
+
});
|
|
40
|
+
export const readEditableSettings = async () => projectEditableSettings(await loadBrainlinkConfig());
|
|
41
|
+
export const updateEditableSettings = async (patch) => {
|
|
42
|
+
const sanitizedPatch = buildSanitizedPatch(patch);
|
|
43
|
+
const rawConfig = await readRawGlobalConfig();
|
|
44
|
+
const merged = { ...rawConfig, ...sanitizedPatch };
|
|
45
|
+
await writeRawConfig('global', merged);
|
|
46
|
+
return readEditableSettings();
|
|
47
|
+
};
|
|
48
|
+
export const readAuthStatus = async (requireAuth, user) => ({
|
|
49
|
+
enabled: requireAuth,
|
|
50
|
+
user
|
|
51
|
+
});
|
|
@@ -2,6 +2,7 @@ import { createServer } from 'node:http';
|
|
|
2
2
|
import { brotliCompressSync, constants, gzipSync } from 'node:zlib';
|
|
3
3
|
import { indexVault } from './index-vault.js';
|
|
4
4
|
import { startVaultWatcher } from './watch-vault.js';
|
|
5
|
+
import { bumpGraphVersion } from './server/graph-version.js';
|
|
5
6
|
import { assertLoopbackHost } from './server/host-security.js';
|
|
6
7
|
import { contentTypes, createJsonResponse, isHttpError } from './server/http.js';
|
|
7
8
|
import { route } from './server/routes.js';
|
|
@@ -82,10 +83,16 @@ export const startServer = async (input) => {
|
|
|
82
83
|
}
|
|
83
84
|
if (input.shouldIndex) {
|
|
84
85
|
await indexVault(input.vaultPath);
|
|
86
|
+
bumpGraphVersion();
|
|
85
87
|
}
|
|
86
88
|
const watcher = input.shouldWatch
|
|
87
89
|
? startVaultWatcher({
|
|
88
90
|
vaultPath: input.vaultPath,
|
|
91
|
+
// Signal the web client (via /api/graph-version) that the graph changed
|
|
92
|
+
// so it can re-fetch and surface new nodes/contexts live.
|
|
93
|
+
onIndex: () => {
|
|
94
|
+
bumpGraphVersion();
|
|
95
|
+
},
|
|
89
96
|
onError: (error) => console.error(error)
|
|
90
97
|
})
|
|
91
98
|
: null;
|
package/package.json
CHANGED