@andespindola/brainlink 1.6.6 → 1.6.8
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 +202 -0
- package/dist/application/frontend/client/worker-bootstrap.js +1 -0
- package/dist/application/frontend/client-js.js +2 -0
- package/dist/application/frontend/settings-page.js +322 -0
- package/dist/application/server/routes.js +19 -0
- package/dist/application/server/settings-store.js +51 -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,202 @@
|
|
|
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: relative;',
|
|
25
|
+
' display: inline-flex;',
|
|
26
|
+
' align-items: center;',
|
|
27
|
+
' margin-left: 8px;',
|
|
28
|
+
' font-family: "Crowquill Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;',
|
|
29
|
+
'}',
|
|
30
|
+
'.bl-account--floating {',
|
|
31
|
+
' position: fixed;',
|
|
32
|
+
' top: 12px;',
|
|
33
|
+
' right: 12px;',
|
|
34
|
+
' z-index: 2147483000;',
|
|
35
|
+
'}',
|
|
36
|
+
'.bl-account-button {',
|
|
37
|
+
' display: inline-flex;',
|
|
38
|
+
' align-items: center;',
|
|
39
|
+
' gap: 6px;',
|
|
40
|
+
' height: 32px;',
|
|
41
|
+
' padding: 0 12px;',
|
|
42
|
+
' border: 1px solid rgba(143, 172, 204, 0.18);',
|
|
43
|
+
' border-radius: 8px;',
|
|
44
|
+
' background: rgba(13, 24, 35, 0.92);',
|
|
45
|
+
' color: #edf4ff;',
|
|
46
|
+
' font: inherit;',
|
|
47
|
+
' font-size: 12px;',
|
|
48
|
+
' cursor: pointer;',
|
|
49
|
+
' transition: border-color 120ms ease;',
|
|
50
|
+
'}',
|
|
51
|
+
'.bl-account-button:hover,',
|
|
52
|
+
'.bl-account-button:focus {',
|
|
53
|
+
' border-color: #5aa8ff;',
|
|
54
|
+
' outline: none;',
|
|
55
|
+
'}',
|
|
56
|
+
'.bl-account-menu {',
|
|
57
|
+
' position: absolute;',
|
|
58
|
+
' top: 40px;',
|
|
59
|
+
' right: 0;',
|
|
60
|
+
' z-index: 2147483000;',
|
|
61
|
+
' min-width: 168px;',
|
|
62
|
+
' padding: 6px;',
|
|
63
|
+
' border: 1px solid rgba(143, 172, 204, 0.18);',
|
|
64
|
+
' border-radius: 10px;',
|
|
65
|
+
' background: rgba(13, 24, 35, 0.98);',
|
|
66
|
+
' box-shadow: 0 18px 60px rgba(1, 6, 13, 0.48);',
|
|
67
|
+
' display: grid;',
|
|
68
|
+
' gap: 2px;',
|
|
69
|
+
'}',
|
|
70
|
+
'.bl-account-menu[hidden] {',
|
|
71
|
+
' display: none;',
|
|
72
|
+
'}',
|
|
73
|
+
'.bl-account-item {',
|
|
74
|
+
' display: block;',
|
|
75
|
+
' width: 100%;',
|
|
76
|
+
' padding: 8px 10px;',
|
|
77
|
+
' border: 0;',
|
|
78
|
+
' border-radius: 6px;',
|
|
79
|
+
' background: transparent;',
|
|
80
|
+
' color: #edf4ff;',
|
|
81
|
+
' font: inherit;',
|
|
82
|
+
' font-size: 12px;',
|
|
83
|
+
' text-align: left;',
|
|
84
|
+
' cursor: pointer;',
|
|
85
|
+
'}',
|
|
86
|
+
'.bl-account-item:hover,',
|
|
87
|
+
'.bl-account-item:focus {',
|
|
88
|
+
' background: rgba(90, 168, 255, 0.18);',
|
|
89
|
+
' outline: none;',
|
|
90
|
+
'}'
|
|
91
|
+
].join('\\n')
|
|
92
|
+
document.head.appendChild(style)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const build = (user) => {
|
|
96
|
+
injectStyles()
|
|
97
|
+
|
|
98
|
+
const root = document.createElement('div')
|
|
99
|
+
root.id = 'bl-account'
|
|
100
|
+
root.className = 'bl-account'
|
|
101
|
+
|
|
102
|
+
const button = document.createElement('button')
|
|
103
|
+
button.type = 'button'
|
|
104
|
+
button.className = 'bl-account-button'
|
|
105
|
+
button.textContent = typeof user === 'string' && user.length > 0 ? user : 'Account'
|
|
106
|
+
button.setAttribute('aria-haspopup', 'true')
|
|
107
|
+
button.setAttribute('aria-expanded', 'false')
|
|
108
|
+
|
|
109
|
+
const menu = document.createElement('div')
|
|
110
|
+
menu.className = 'bl-account-menu'
|
|
111
|
+
menu.setAttribute('role', 'menu')
|
|
112
|
+
menu.hidden = true
|
|
113
|
+
|
|
114
|
+
const addItem = (label, onSelect) => {
|
|
115
|
+
const item = document.createElement('button')
|
|
116
|
+
item.type = 'button'
|
|
117
|
+
item.className = 'bl-account-item'
|
|
118
|
+
item.setAttribute('role', 'menuitem')
|
|
119
|
+
item.textContent = label
|
|
120
|
+
item.addEventListener('click', onSelect)
|
|
121
|
+
menu.appendChild(item)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const closeMenu = () => {
|
|
125
|
+
menu.hidden = true
|
|
126
|
+
button.setAttribute('aria-expanded', 'false')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const openMenu = () => {
|
|
130
|
+
menu.hidden = false
|
|
131
|
+
button.setAttribute('aria-expanded', 'true')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
addItem('Settings', () => {
|
|
135
|
+
closeMenu()
|
|
136
|
+
window.location.href = '/settings'
|
|
137
|
+
})
|
|
138
|
+
addItem('Change password', () => {
|
|
139
|
+
closeMenu()
|
|
140
|
+
window.location.href = '/change-password'
|
|
141
|
+
})
|
|
142
|
+
addItem('Sign out', () => {
|
|
143
|
+
closeMenu()
|
|
144
|
+
fetch('/api/logout', { method: 'POST' })
|
|
145
|
+
.catch(() => {})
|
|
146
|
+
.then(() => {
|
|
147
|
+
window.location.href = '/login'
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
button.addEventListener('click', (event) => {
|
|
152
|
+
event.stopPropagation()
|
|
153
|
+
if (menu.hidden) {
|
|
154
|
+
openMenu()
|
|
155
|
+
} else {
|
|
156
|
+
closeMenu()
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
document.addEventListener('click', (event) => {
|
|
161
|
+
if (!root.contains(event.target)) {
|
|
162
|
+
closeMenu()
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
document.addEventListener('keydown', (event) => {
|
|
167
|
+
if (event.key === 'Escape') {
|
|
168
|
+
closeMenu()
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
root.appendChild(button)
|
|
173
|
+
root.appendChild(menu)
|
|
174
|
+
|
|
175
|
+
// Mount inside the graph header so the control flows with the existing
|
|
176
|
+
// toolbar instead of overlapping it. Fall back to a floating corner only if
|
|
177
|
+
// the header is not present.
|
|
178
|
+
const host = document.querySelector('.header-actions') || document.querySelector('.graph-header')
|
|
179
|
+
if (host) {
|
|
180
|
+
host.appendChild(root)
|
|
181
|
+
} else {
|
|
182
|
+
root.classList.add('bl-account--floating')
|
|
183
|
+
document.body.appendChild(root)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
fetch('/api/auth-status', { headers: { accept: 'application/json' } })
|
|
188
|
+
.then((response) => {
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
return response.json().catch(() => null)
|
|
193
|
+
})
|
|
194
|
+
.then((payload) => {
|
|
195
|
+
if (!payload || payload.enabled !== true) {
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
build(payload.user)
|
|
199
|
+
})
|
|
200
|
+
.catch(() => {})
|
|
201
|
+
}
|
|
202
|
+
`;
|
|
@@ -9,6 +9,7 @@ import { createInputJs } from './client/input.js';
|
|
|
9
9
|
import { createUploadJs } from './client/upload.js';
|
|
10
10
|
import { createControlsJs } from './client/controls.js';
|
|
11
11
|
import { createLiveRefreshJs } from './client/live-refresh.js';
|
|
12
|
+
import { createAccountMenuJs } from './client/account-menu.js';
|
|
12
13
|
import { createGraphRendererJs } from './client/graph-renderer.js';
|
|
13
14
|
import { createWorkerBootstrapJs } from './client/worker-bootstrap.js';
|
|
14
15
|
export const createClientJs = () => [
|
|
@@ -23,6 +24,7 @@ export const createClientJs = () => [
|
|
|
23
24
|
createUploadJs(),
|
|
24
25
|
createControlsJs(),
|
|
25
26
|
createLiveRefreshJs(),
|
|
27
|
+
createAccountMenuJs(),
|
|
26
28
|
createGraphRendererJs(),
|
|
27
29
|
createWorkerBootstrapJs()
|
|
28
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>`;
|
|
@@ -25,6 +25,8 @@ import { createClientRenderWorkerJs } from '../frontend/client-render-worker-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
27
|
import { getGraphVersion } from './graph-version.js';
|
|
28
|
+
import { readAuthStatus, readEditableSettings, updateEditableSettings } from './settings-store.js';
|
|
29
|
+
import { createSettingsPageHtml } from '../frontend/settings-page.js';
|
|
28
30
|
import { contentTypes, createJsonResponse, isReadMethod, parsePositiveInteger } from './http.js';
|
|
29
31
|
import { parseMultipartForm } from './multipart.js';
|
|
30
32
|
const readRuntimeDefaults = async (url) => {
|
|
@@ -525,6 +527,23 @@ export const route = async (request, url, vaultPath, options = {}) => {
|
|
|
525
527
|
if (isReadMethod(request) && url.pathname === '/api/graph-version') {
|
|
526
528
|
return createResponse(createJsonResponse({ version: getGraphVersion() }), 200, contentTypes['.json']);
|
|
527
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
|
+
}
|
|
528
547
|
if (isReadMethod(request) && url.pathname === '/api/graph-contexts') {
|
|
529
548
|
return createResponse(createJsonResponse({ contexts: await getGraphContexts(vaultPath, readAgentQuery(url)) }), 200, contentTypes['.json']);
|
|
530
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
|
+
});
|
package/package.json
CHANGED