tinywebui 0.1.1 → 0.1.2

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/bin/tinywebui.js CHANGED
@@ -16,7 +16,7 @@ if (configArg) process.env.TINYWEBUI_CONFIG = resolve(configArg);
16
16
 
17
17
  if (args.includes('--help') || args.includes('-h')) {
18
18
  console.log(`tinywebui [start] [--port 7777] [--host 127.0.0.1] [--config path]
19
- tinywebui set-password turn on password login ("single" auth mode)
19
+ tinywebui set-password create or change the owner password
20
20
  tinywebui validate [path] check the config files; prints the fingerprint, exits 1 on problems
21
21
  tinywebui migrate [--check]
22
22
  bring the database to the current schema (--check: exit 1 if behind)
@@ -67,12 +67,6 @@ if (envPath && /\.[cm]?js$/.test(envPath)) {
67
67
  }
68
68
  }
69
69
 
70
- if (args[0] === 'set-password') {
71
- const { setPassword } = await import('../src/set-password.js');
72
- try { await setPassword(); process.exit(0); }
73
- catch (err) { console.error(`[tinywebui] ${err.message}`); process.exit(1); }
74
- }
75
-
76
70
  try {
77
71
  let opts = {};
78
72
  if (jsPath && !existsSync(jsPath)) throw new Error(`config file not found: ${jsPath}`);
@@ -83,6 +77,11 @@ try {
83
77
  // stderr, so a command's stdout stays pure JSON for scripts.
84
78
  console.error(`[tinywebui] options: ${jsPath}`);
85
79
  }
80
+ if (args[0] === 'set-password') {
81
+ const { setPassword } = await import('../src/set-password.js');
82
+ await setPassword(opts);
83
+ process.exit(0);
84
+ }
86
85
  const COMMANDS = ['validate', 'effective', 'fingerprint', 'users', 'schema', 'migrate', 'doctor', 'config', 'models'];
87
86
  if (COMMANDS.includes(args[0])) {
88
87
  const { runCli } = await import('../src/cli.js');
@@ -92,6 +91,16 @@ try {
92
91
  console.error(`[tinywebui] unknown command "${args[0]}" (see --help)`);
93
92
  process.exit(1);
94
93
  }
94
+ const { createConfigSource } = await import('../src/config/config.js');
95
+ const initial = createConfigSource(opts).load({ persistSecret: false });
96
+ if (initial.authMode === 'single' && !initial.authPassword && !process.env.TINYWEBUI_PASSWORD) {
97
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
98
+ throw new Error('owner password required: run `tinywebui set-password` in a terminal, or set TINYWEBUI_PASSWORD before starting');
99
+ }
100
+ const { setPassword } = await import('../src/set-password.js');
101
+ console.log('[tinywebui] Set an owner password before the first start.');
102
+ await setPassword(opts);
103
+ }
95
104
  const { start } = await import('../src/server.js');
96
105
  // Command-line flags beat the file; the file beats the built-in defaults.
97
106
  await start({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinywebui",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A small, local-first chat workspace for any OpenAI-compatible model, with MCP tools.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -34,11 +34,11 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@jsquash/avif": "^2.1.1",
37
- "@modelcontextprotocol/sdk": "^1.12.0",
37
+ "@modelcontextprotocol/sdk": "^1.32.1",
38
38
  "cron-parser": "^5.10.1",
39
39
  "heic-convert": "^2.1.0",
40
40
  "jpeg-js": "^0.4.4",
41
- "mammoth": "^1.12.3",
41
+ "mammoth": "^1.13.0",
42
42
  "unpdf": "^1.8.1"
43
43
  },
44
44
  "license": "AGPL-3.0-or-later",
@@ -5,7 +5,7 @@
5
5
  * it from the local queue; there is nothing server-side to clean up.
6
6
  */
7
7
  import { $, el } from '../core/dom.js';
8
- import { can } from '../core/access.js';
8
+ import { can, modelAcceptsImages } from '../core/access.js';
9
9
  import { state } from '../core/state.js';
10
10
  import { addError } from './transcript.js';
11
11
  import { api } from '../core/api.js';
@@ -184,6 +184,10 @@ function stageAttachment(file) {
184
184
  addError(`attach "${file.name}": ${isImageFile(file) ? 'images are' : 'attachments are'} turned off for your account`);
185
185
  return;
186
186
  }
187
+ if (isImageFile(file) && !modelAcceptsImages()) {
188
+ addError(`attach "${file.name}": the current model does not take images`);
189
+ return;
190
+ }
187
191
  const entry = { file };
188
192
  if (isImageFile(file)) {
189
193
  entry.isImage = true;
@@ -25,6 +25,7 @@ let openMcp;
25
25
 
26
26
  function closeMenu() {
27
27
  if (!open) return;
28
+ open.watch.disconnect();
28
29
  open.pop.remove();
29
30
  open.btn.setAttribute('aria-expanded', 'false');
30
31
  open = null;
@@ -38,27 +39,35 @@ function openMenu(btn, side, build) {
38
39
  pop.setAttribute('role', 'menu');
39
40
  form.appendChild(pop);
40
41
  btn.setAttribute('aria-expanded', 'true');
41
- open = { pop, btn };
42
+ // Menus fill in after a fetch and change height when a group expands, so
43
+ // refit whenever their contents change, not just once at open.
44
+ const watch = new MutationObserver(() => fitToViewport(pop));
45
+ watch.observe(pop, { childList: true, subtree: true, attributes: true, attributeFilter: ['open'] });
46
+ open = { pop, btn, watch };
42
47
  build(pop);
43
48
  fitToViewport(pop);
44
49
  }
45
50
 
46
51
  /**
47
- * Opens the popover on whichever side of the composer has more room and caps
48
- * its height to that room, so it never hangs off the viewport (short phone
49
- * screens, the centred empty-chat composer, an open mobile keyboard).
52
+ * Opens the popover below the composer in a new chat, where the composer is
53
+ * centred, and in a conversation, where it is pinned to the bottom, below if
54
+ * the menu fits there and above otherwise. Its height is capped to the chosen
55
+ * side's room so it never hangs off the viewport (an open mobile keyboard).
50
56
  */
51
57
  function fitToViewport(pop) {
52
58
  const gap = 8;
59
+ pop.style.maxHeight = 'none';
60
+ const want = Math.min(pop.offsetHeight, 448);
53
61
  const rect = form.getBoundingClientRect();
54
62
  const vh = window.visualViewport?.height ?? window.innerHeight;
55
63
  const above = rect.top - gap * 2;
56
64
  const below = vh - rect.bottom - gap * 2;
57
- const up = above >= below;
65
+ const centred = !!form.closest('main:has(#wrap:empty)');
66
+ const up = !centred && below < want && above > below;
58
67
  pop.style.top = up ? 'auto' : 'calc(100% + .5rem)';
59
68
  pop.style.bottom = up ? 'calc(100% + .5rem)' : 'auto';
60
69
  pop.style.transformOrigin = `${up ? 'bottom' : 'top'} ${pop.classList.contains('popover-right') ? 'right' : 'left'}`;
61
- pop.style.maxHeight = `${Math.max(120, Math.min(448, up ? above : below))}px`;
70
+ pop.style.maxHeight = `${Math.max(64, Math.min(want, up ? above : below))}px`;
62
71
  }
63
72
 
64
73
  /**
@@ -228,6 +237,7 @@ async function buildModelMenu(pop) {
228
237
  else await api.post('/api/config', { model: id });
229
238
  } catch (err) { return addError(err.message); }
230
239
  await config.load();
240
+ await loadAccess(); // the new model's capability tags
231
241
  };
232
242
 
233
243
  let models = [];
@@ -14,6 +14,9 @@ let features = new Set();
14
14
  export const can = (feature) => features.has(feature);
15
15
  export const whoami = () => me;
16
16
 
17
+ /** False only when the catalog tags the current model and `vision` is not among them: untagged means unknown, so allowed. */
18
+ export const modelAcceptsImages = () => !me.modelTags?.length || me.modelTags.includes('vision');
19
+
17
20
  // Nav items and controls that exist only for one feature.
18
21
  const GATED = {
19
22
  [FEATURE.SETTINGS]: ['toggle-settings'],
@@ -30,6 +33,8 @@ export async function loadAccess() {
30
33
  for (const [feature, ids] of Object.entries(GATED)) {
31
34
  for (const id of ids) { const node = $(id); if (node) node.hidden = !can(feature); }
32
35
  }
36
+ // Settings also opens for someone whose admin lets them customise their own experience.
37
+ $('toggle-settings').hidden = !(can(FEATURE.SETTINGS) || me.customize?.length);
33
38
  const search = $('chatSearch')?.closest('.search-row');
34
39
  if (search) search.hidden = !can(FEATURE.SEARCH);
35
40
  // The + button opens attach and tools; with neither there is nothing in it.
package/public/index.html CHANGED
@@ -132,7 +132,51 @@
132
132
  </div>
133
133
  </section>
134
134
 
135
+ <div id="adminSettings" hidden>
136
+ <div class="set-divider"><h2>Admin</h2><p id="adminSettingsNote">Deployment-wide: these apply to everyone and are stored in the config file, so copying it reproduces this setup.</p></div>
137
+
138
+ <section class="set-group">
139
+ <header><h3>Default endpoint</h3><p>The OpenAI-compatible API used by every model that does not name a connector.</p></header>
140
+ <div class="set-grid">
141
+ <div class="span-2"><label for="baseUrl">API URL</label><input id="baseUrl" spellcheck="false" placeholder="https://openrouter.ai/api/v1"></div>
142
+ <div class="span-2"><label for="apiKey">API key</label><input id="apiKey" type="password" autocomplete="off" spellcheck="false"><small id="apiKeyNote">Write-only: stored in the config file, never shown again. Leave blank to keep the current key.</small></div>
143
+ <div class="span-2"><button id="testDefault" type="button">Test connection</button> <span id="testDefaultMsg" class="usage"></span></div>
144
+ </div>
145
+ </section>
146
+
135
147
  <section class="set-group">
148
+ <header><h3>Connectors</h3><p>More endpoints, each with its own URL and key. A model picks one by name.</p></header>
149
+ <div id="connectorsBox"></div>
150
+ <button id="addConnector" type="button">Add connector</button>
151
+ </section>
152
+
153
+ <section class="set-group">
154
+ <header><h3>Models</h3><p>The catalog. Once any model is listed, only listed models can be picked, and people see the label, never the provider id.</p></header>
155
+ <div id="modelsBox"></div>
156
+ <button id="addModel" type="button">Add model</button>
157
+ </section>
158
+
159
+ <section class="set-group">
160
+ <header><h3>Advanced model behaviour</h3><p>Rarely changed. Per-model values in the catalog override these.</p></header>
161
+ <div class="set-grid">
162
+ <div><label for="llmCompaction">Summarise dropped turns</label><select id="llmCompaction"><option value="true">Yes, with the chat's model</option><option value="false">No, just drop them</option></select></div>
163
+ <div><label for="compactionMaxTokens">Summary length cap</label><input id="compactionMaxTokens" type="number" min="256" step="256"><small>Output tokens for that summary request.</small></div>
164
+ <div><label for="expandCharBudget">Expand budget</label><input id="expandCharBudget" type="number" min="0" step="1000"><small>Characters one expand_context call may return.</small></div>
165
+ <div><label for="cache">Prompt caching</label><select id="cache"><option value="true">On</option><option value="false">Off</option></select></div>
166
+ <div><label for="reasoningReplay">Replay past reasoning</label><select id="reasoningReplay"><option value="auto">Automatic</option><option value="omit">Never</option><option value="reasoning_content">reasoning_content</option><option value="reasoning_details">reasoning_details</option></select></div>
167
+ <div class="span-2"><label for="extraBody">Extra request body (JSON)</label><textarea id="extraBody" rows="4" spellcheck="false" placeholder="{}"></textarea><small>Merged into every completion request, e.g. gateway routing.</small></div>
168
+ </div>
169
+ </section>
170
+ </div>
171
+
172
+ <div class="set-divider mine" id="myPrefsHead"><h2>My preferences</h2><p>Only for you.</p></div>
173
+
174
+ <section class="set-group mine" id="myInstructionsGroup" hidden>
175
+ <header><h3>My instructions</h3><p>Added after the system prompt on your chats only.</p></header>
176
+ <textarea id="myInstructions" rows="5" maxlength="4000" aria-label="My instructions"></textarea>
177
+ </section>
178
+
179
+ <section class="set-group mine" id="themeGroup">
136
180
  <header><h3>Appearance</h3></header>
137
181
  <div class="set-grid">
138
182
  <div><label for="theme">Theme</label><select id="theme"><option value="">Default</option></select></div>
@@ -204,6 +248,15 @@
204
248
  <div id="adminMsg" class="usage"></div>
205
249
  <div id="adminUsers" class="admin-users"></div>
206
250
  </section>
251
+ <section id="adminPolicy" class="statistics-block">
252
+ <div class="sec-h">what users can do <span id="policyMsg" class="usage"></span></div>
253
+ <div class="usage admin-note">Applies to everyone with the user role. Admins always have everything. Saved to the config file.</div>
254
+ <div class="policy-group"><b>Features</b><div id="policyFeatures" class="policy-checks"></div></div>
255
+ <div class="policy-group" id="policyModelsGroup"><b>Models they may pick</b><div id="policyModels" class="policy-checks"></div></div>
256
+ <div class="policy-group"><b>They may customise</b><div id="policyCustomize" class="policy-checks"></div></div>
257
+ <div class="policy-group"><b>New users start as</b> <select id="policyNewUsers"><option value="approved">approved</option><option value="pending">pending (an admin approves)</option></select></div>
258
+ <button id="policySave" class="primary" type="button">Save policy</button>
259
+ </section>
207
260
  <section id="adminView" class="statistics-block" hidden>
208
261
  <div class="sec-h"><button id="adminBack" type="button">← back</button> <span id="adminViewTitle"></span><span id="adminViewMeta" class="usage"></span></div>
209
262
  <div class="usage admin-note">Read-only admin view. Every view is recorded in the audit log.</div>
@@ -86,6 +86,7 @@ function showView(title, meta) {
86
86
  adminViewRequest++;
87
87
  stopPoll();
88
88
  $('adminUsers').parentElement.hidden = true;
89
+ $('adminPolicy').hidden = true;
89
90
  $('adminView').hidden = false;
90
91
  $('adminViewTitle').textContent = title;
91
92
  $('adminViewMeta').textContent = meta || '';
@@ -172,12 +173,53 @@ async function openChat(chatId, user) {
172
173
  if (out.running) pollTimer = setTimeout(() => { if (request === adminViewRequest && $('admin').classList.contains('open') && !$('adminView').hidden) openChat(chatId, user); }, 4000);
173
174
  }
174
175
 
176
+ /* ---------- access policy: what the user role may do ---------- */
177
+
178
+ function checks(box, name, options, chosen, label = (v) => v) {
179
+ box.replaceChildren(...options.map((v) => {
180
+ const cb = Object.assign(el('input'), { type: 'checkbox', value: v, checked: chosen.includes(v) });
181
+ const l = el('label');
182
+ l.append(cb, ` ${label(v)}`);
183
+ return l;
184
+ }));
185
+ }
186
+ const picked = (box) => [...box.querySelectorAll('input:checked')].map((i) => i.value);
187
+
188
+ async function loadPolicy() {
189
+ let p;
190
+ try { p = await api.get('/api/admin/policy'); } catch (err) { $('policyMsg').textContent = err.message; return; }
191
+ const all = (v, list) => (v === '*' ? list : v || []);
192
+ checks($('policyFeatures'), 'features', p.features.filter((f) => f !== 'admin' && f !== 'oversight'), all(p.user.features, p.features));
193
+ $('policyModelsGroup').hidden = !p.models.length;
194
+ checks($('policyModels'), 'models', p.models, all(p.user.models, p.models));
195
+ checks($('policyCustomize'), 'customize', p.customizable, p.customize);
196
+ $('policyNewUsers').value = p.newUsers;
197
+ const locked = p.locked;
198
+ for (const n of $('adminPolicy').querySelectorAll('input,select,button')) n.disabled = locked;
199
+ $('policyMsg').textContent = locked ? 'set in code or frozen: change the files' : '';
200
+ $('policySave').onclick = async () => {
201
+ const models = picked($('policyModels'));
202
+ try {
203
+ await api.post('/api/admin/policy', {
204
+ features: picked($('policyFeatures')),
205
+ // Everything ticked means "no restriction", so a model added later is not silently excluded.
206
+ ...(p.models.length ? { models: models.length === p.models.length ? '*' : models } : {}),
207
+ customize: picked($('policyCustomize')),
208
+ newUsers: $('policyNewUsers').value
209
+ });
210
+ $('policyMsg').textContent = 'saved';
211
+ } catch (err) { $('policyMsg').textContent = err.message; }
212
+ };
213
+ }
214
+
175
215
  export async function loadAdmin() {
176
216
  const request = ++adminLoadRequest;
177
217
  adminViewRequest++;
178
218
  stopPoll();
179
219
  $('adminView').hidden = true;
180
220
  $('adminUsers').parentElement.hidden = false;
221
+ $('adminPolicy').hidden = false;
222
+ loadPolicy();
181
223
  const box = $('adminUsers');
182
224
  $('adminMsg').textContent = '';
183
225
  let out;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The admin editors for connectors (endpoint + key) and the model catalog.
3
+ * Keys are write-only: a row shows whether one is set (and its last four
4
+ * characters), and a blank key field means "keep it". Everything is read back
5
+ * from the DOM on save, so the rows are the draft until then.
6
+ */
7
+ import { $, el } from '../core/dom.js';
8
+ import { api } from '../core/api.js';
9
+
10
+ const TAGS = ['vision', 'reasoning', 'tools'];
11
+ // Per-model settings with no field of their own; edited as one JSON object.
12
+ const OVERRIDE_KEYS = ['systemPrompt', 'temperature', 'maxTokens', 'maxToolRounds', 'cacheMode', 'cacheTtl', 'extraBody', 'reasoningReplay'];
13
+
14
+ function field(label, input, note) {
15
+ const wrap = el('div');
16
+ const l = el('label');
17
+ l.textContent = label;
18
+ wrap.append(l, input);
19
+ if (note) wrap.appendChild(Object.assign(el('small'), { textContent: note }));
20
+ return wrap;
21
+ }
22
+
23
+ const textInput = (value, placeholder) => Object.assign(el('input'), { value: value ?? '', placeholder: placeholder || '', spellcheck: false });
24
+
25
+ function removeButton(row) {
26
+ const b = Object.assign(el('button'), { type: 'button', textContent: 'Remove' });
27
+ b.onclick = () => row.remove();
28
+ return b;
29
+ }
30
+
31
+ /* ---------- connectors ---------- */
32
+
33
+ function connectorRow(c = {}) {
34
+ const row = el('div', 'cfg-row');
35
+ row.dataset.kind = 'connector';
36
+ const id = textInput(c.id, 'local');
37
+ // An id is what models name; renaming a saved one would orphan them.
38
+ if (c.id) id.readOnly = true;
39
+ const label = textInput(c.label, 'Local models');
40
+ const url = textInput(c.baseUrl, 'http://localhost:11434/v1');
41
+ const key = Object.assign(textInput('', c.hasApiKey ? `set (…${c.keyHint || ''})` : 'none'), { type: 'password', autocomplete: 'off' });
42
+ const clear = Object.assign(el('input'), { type: 'checkbox' });
43
+ const grid = el('div', 'set-grid');
44
+ grid.append(
45
+ field('Id', id, 'What a model names in its connector field.'),
46
+ field('Label', label),
47
+ Object.assign(field('API URL', url), { className: 'span-2' }),
48
+ Object.assign(field('API key', key, 'Write-only. Blank keeps the current key.'), { className: 'span-2' })
49
+ );
50
+ const test = Object.assign(el('button'), { type: 'button', textContent: 'Test connection' });
51
+ const msg = el('span', 'usage');
52
+ test.onclick = async () => {
53
+ msg.textContent = 'testing…';
54
+ try {
55
+ const out = await api.post('/api/connectors/test', { id: id.value.trim(), baseUrl: url.value.trim(), apiKey: key.value });
56
+ msg.textContent = out.ok ? `reachable${out.models != null ? ` · ${out.models} models` : ''}` : out.error || `status ${out.status}`;
57
+ } catch (err) { msg.textContent = err.message; }
58
+ };
59
+ const clearLabel = el('label');
60
+ clearLabel.append(clear, ' remove stored key');
61
+ const actions = el('div', 'cfg-actions');
62
+ actions.append(test, msg, ...(c.hasApiKey ? [clearLabel] : []), removeButton(row));
63
+ row.append(grid, actions);
64
+ row.read = () => ({
65
+ id: id.value.trim(),
66
+ ...(label.value.trim() ? { label: label.value.trim() } : {}),
67
+ baseUrl: url.value.trim(),
68
+ // undefined keeps the stored key on the server; null clears it.
69
+ ...(key.value ? { apiKey: key.value } : clear.checked ? { apiKey: null } : {})
70
+ });
71
+ return row;
72
+ }
73
+
74
+ /* ---------- model catalog ---------- */
75
+
76
+ function modelRow(m = {}, connectorIds = []) {
77
+ const row = el('div', 'cfg-row');
78
+ row.dataset.kind = 'model';
79
+ const id = textInput(m.id, 'fast');
80
+ const label = textInput(m.label, 'Quick');
81
+ const description = textInput(m.description, 'Everyday questions');
82
+ const model = textInput(m.model, 'vendor/model-id');
83
+ const connector = el('select');
84
+ connector.className = 'model-connector';
85
+ const none = Object.assign(el('option'), { value: '', textContent: 'Default endpoint' });
86
+ connector.appendChild(none);
87
+ for (const cid of connectorIds) connector.appendChild(Object.assign(el('option'), { value: cid, textContent: cid }));
88
+ if (m.connector && !connectorIds.includes(m.connector)) connector.appendChild(Object.assign(el('option'), { value: m.connector, textContent: m.connector }));
89
+ connector.value = m.connector || '';
90
+ const enabled = Object.assign(el('input'), { type: 'checkbox', checked: m.enabled !== false });
91
+ const tags = el('div', 'cfg-tags');
92
+ const tagBoxes = TAGS.map((t) => {
93
+ const cb = Object.assign(el('input'), { type: 'checkbox', checked: (m.tags || []).includes(t) });
94
+ const l = el('label');
95
+ l.append(cb, ` ${t}`);
96
+ tags.appendChild(l);
97
+ return [t, cb];
98
+ });
99
+ const overrides = Object.assign(el('textarea'), { rows: 3, spellcheck: false, placeholder: '{ "temperature": 0.3 }' });
100
+ const kept = Object.fromEntries(OVERRIDE_KEYS.filter((k) => m[k] !== undefined).map((k) => [k, m[k]]));
101
+ overrides.value = Object.keys(kept).length ? JSON.stringify(kept, null, 2) : '';
102
+ const enabledLabel = el('label');
103
+ enabledLabel.append(enabled, ' enabled');
104
+
105
+ const grid = el('div', 'set-grid');
106
+ grid.append(
107
+ field('Id', id, 'Stable handle: roles and preferences name this.'),
108
+ field('Label', label, 'What people see.'),
109
+ Object.assign(field('Description', description), { className: 'span-2' }),
110
+ field('Provider model id', model, 'Blank: the id above.'),
111
+ field('Connector', connector),
112
+ Object.assign(field('Capabilities', tags), { className: 'span-2' }),
113
+ Object.assign(field('Overrides (JSON)', overrides, `Optional: ${OVERRIDE_KEYS.join(', ')}.`), { className: 'span-2' })
114
+ );
115
+ const actions = el('div', 'cfg-actions');
116
+ actions.append(enabledLabel, removeButton(row));
117
+ row.append(grid, actions);
118
+ row.read = () => {
119
+ let extra = {};
120
+ if (overrides.value.trim()) {
121
+ try { extra = JSON.parse(overrides.value); } catch (err) { throw new Error(`model "${id.value.trim()}" overrides: ${err.message}`); }
122
+ const bad = Object.keys(extra).filter((k) => !OVERRIDE_KEYS.includes(k));
123
+ if (bad.length || !extra || typeof extra !== 'object' || Array.isArray(extra)) throw new Error(`model "${id.value.trim()}" overrides must be an object of: ${OVERRIDE_KEYS.join(', ')}`);
124
+ }
125
+ const chosen = tagBoxes.filter(([, cb]) => cb.checked).map(([t]) => t);
126
+ return {
127
+ id: id.value.trim(),
128
+ ...(label.value.trim() ? { label: label.value.trim() } : {}),
129
+ ...(description.value.trim() ? { description: description.value.trim() } : {}),
130
+ ...(model.value.trim() ? { model: model.value.trim() } : {}),
131
+ ...(connector.value ? { connector: connector.value } : {}),
132
+ ...(chosen.length ? { tags: chosen } : {}),
133
+ ...(enabled.checked ? {} : { enabled: false }),
134
+ ...extra
135
+ };
136
+ };
137
+ return row;
138
+ }
139
+
140
+ const rows = (box) => [...box.children];
141
+ const connectorIds = () => rows($('connectorsBox')).map((r) => r.read().id).filter(Boolean);
142
+
143
+ /** Rebuilds both editors from /api/config. */
144
+ export function renderConnectors(cfg) {
145
+ $('connectorsBox').replaceChildren(...(cfg.connectors || []).map((c) => connectorRow(c)));
146
+ const ids = (cfg.connectors || []).map((c) => c.id);
147
+ $('modelsBox').replaceChildren(...(cfg.models || []).map((m) => modelRow(m, ids)));
148
+ }
149
+
150
+ /** What the editors currently hold, as the config patch to save. Throws on bad JSON. */
151
+ export function collectConnectors() {
152
+ return {
153
+ connectors: rows($('connectorsBox')).map((r) => r.read()),
154
+ models: rows($('modelsBox')).map((r) => r.read())
155
+ };
156
+ }
157
+
158
+ /** Locks or unlocks every control in the three admin editors. */
159
+ export function lockConnectors(locked) {
160
+ for (const id of ['connectorsBox', 'modelsBox']) for (const n of $(id).querySelectorAll('input,select,textarea,button')) n.disabled = locked;
161
+ $('addConnector').disabled = locked;
162
+ $('addModel').disabled = locked;
163
+ }
164
+
165
+ export function initConnectors() {
166
+ $('addConnector').onclick = () => {
167
+ $('connectorsBox').appendChild(connectorRow());
168
+ // New connector ids become choices in every model row.
169
+ refreshConnectorChoices();
170
+ };
171
+ $('addModel').onclick = () => $('modelsBox').appendChild(modelRow({}, connectorIds()));
172
+ // Ids are typed after the row exists, so the model rows re-read them on focus.
173
+ $('modelsBox').addEventListener('focusin', (e) => { if (e.target.classList?.contains('model-connector')) refreshConnectorChoices(); });
174
+ }
175
+
176
+ function refreshConnectorChoices() {
177
+ const ids = connectorIds();
178
+ for (const sel of $('modelsBox').querySelectorAll('.model-connector')) {
179
+ const current = sel.value;
180
+ sel.replaceChildren(Object.assign(el('option'), { value: '', textContent: 'Default endpoint' }),
181
+ ...[...new Set([...ids, ...(current && !ids.includes(current) ? [current] : [])])].map((cid) => Object.assign(el('option'), { value: cid, textContent: cid })));
182
+ sel.value = current;
183
+ }
184
+ }
@@ -1,9 +1,10 @@
1
1
  /** The settings panel: model/runtime config, MCP servers, and the tools list. */
2
2
  import { $, el, num, installLink } from '../core/dom.js';
3
3
  import { addError } from '../chat/transcript.js';
4
- import { whoami } from '../core/access.js';
4
+ import { whoami, loadAccess } from '../core/access.js';
5
5
  import { api } from '../core/api.js';
6
6
  import { PANEL, closePanel } from './panels.js';
7
+ import { initConnectors, renderConnectors, collectConnectors, lockConnectors } from './connectors.js';
7
8
 
8
9
  /**
9
10
  * The tools panel: built-ins first, then every MCP server with its own health
@@ -233,6 +234,17 @@ export async function loadMcp() {
233
234
  export function initSettings() {
234
235
  $('saveMcp').onclick = saveMcp;
235
236
  $('save').onclick = saveConfig;
237
+ $('testDefault').onclick = testDefault;
238
+ initConnectors();
239
+ }
240
+
241
+ async function testDefault() {
242
+ const msg = $('testDefaultMsg');
243
+ msg.textContent = 'testing…';
244
+ try {
245
+ const out = await api.post('/api/connectors/test', { id: 'default', baseUrl: $('baseUrl').value.trim(), apiKey: $('apiKey').value });
246
+ msg.textContent = out.ok ? `reachable${out.models != null ? ` · ${out.models} models` : ''}` : out.error || `status ${out.status}`;
247
+ } catch (err) { msg.textContent = err.message; }
236
248
  }
237
249
 
238
250
  async function saveMcp() {
@@ -254,7 +266,8 @@ async function saveMcp() {
254
266
 
255
267
  const FORM_KEYS = ['systemPrompt', 'model', 'temperature', 'maxTokens', 'timezone', 'maxToolRounds', 'askUserTimeoutSeconds', 'cacheTtl',
256
268
  'cacheMode', 'compactThreshold', 'keepTurns', 'maxInlineChars', 'maxTurnChars', 'compactMinSaved',
257
- 'maxHistoryTokens', 'toolApproval'];
269
+ 'maxHistoryTokens', 'toolApproval', 'baseUrl', 'apiKey', 'llmCompaction', 'compactionMaxTokens', 'expandCharBudget',
270
+ 'cache', 'reasoningReplay', 'extraBody'];
258
271
 
259
272
  // The browser's IANA zones, plus whatever the config holds if the browser
260
273
  // doesn't list it, so a saved value is never silently swapped for another.
@@ -285,13 +298,19 @@ export async function loadConfig() {
285
298
  }
286
299
  lockedKeys = new Set(cfg.lockedKeys || []);
287
300
  readOnly = Boolean(cfg.readOnly);
301
+ const mine = whoami().customize || [];
302
+ $('settings').classList.toggle('prefs-only', readOnly);
303
+ $('myInstructionsGroup').hidden = !mine.includes('instructions');
304
+ $('myInstructions').value = whoami().instructions || '';
305
+ $('themeGroup').hidden = readOnly && !mine.includes('theme');
306
+ $('myPrefsHead').hidden = readOnly ? !mine.length : false;
288
307
  for (const id of FORM_KEYS) {
289
308
  const locked = readOnly || lockedKeys.has(id);
290
309
  $(id).disabled = locked;
291
310
  $(id).title = readOnly ? 'managed by your administrator' : locked ? 'set in code' : '';
292
311
  }
293
- $('save').disabled = readOnly || Boolean(cfg.frozen);
294
- if (readOnly) $('saveMsg').textContent = 'managed by your administrator';
312
+ $('save').disabled = readOnly ? !mine.includes('instructions') : Boolean(cfg.frozen);
313
+ if (readOnly) $('saveMsg').textContent = mine.length ? '' : 'managed by your administrator';
295
314
  else if (cfg.frozen) $('saveMsg').textContent = 'frozen deployment: settings are managed in its files';
296
315
  if (cfg.frozen) for (const id of FORM_KEYS) $(id).title = 'frozen deployment: change the config file';
297
316
  $('systemPrompt').value = cfg.systemPrompt;
@@ -312,6 +331,22 @@ export async function loadConfig() {
312
331
  fillTimezones(cfg.timezone);
313
332
  $('timezone').value = cfg.timezone ?? '';
314
333
  $('toolApproval').value = cfg.toolApproval ?? 'writes';
334
+ // The admin block is only sent to (and only meaningful for) the sole user or an admin.
335
+ const admin = Boolean(whoami().isAdmin) && !readOnly && cfg.baseUrl !== undefined;
336
+ $('adminSettings').hidden = !admin;
337
+ if (admin) {
338
+ $('baseUrl').value = cfg.baseUrl ?? '';
339
+ $('apiKey').value = '';
340
+ $('apiKey').placeholder = cfg.hasApiKey ? `set (…${cfg.keyHint || ''}) — blank keeps it` : 'not set';
341
+ $('llmCompaction').value = String(cfg.llmCompaction !== false);
342
+ $('compactionMaxTokens').value = cfg.compactionMaxTokens ?? 8192;
343
+ $('expandCharBudget').value = cfg.expandCharBudget ?? 8000;
344
+ $('cache').value = String(cfg.cache !== false);
345
+ $('reasoningReplay').value = cfg.reasoningReplay ?? 'auto';
346
+ $('extraBody').value = Object.keys(cfg.extraBody || {}).length ? JSON.stringify(cfg.extraBody, null, 2) : '';
347
+ renderConnectors(cfg);
348
+ lockConnectors(Boolean(cfg.frozen) || lockedKeys.has('models') || lockedKeys.has('connectors'));
349
+ }
315
350
  // The chip is the model picker's button now; the tool count lives in the
316
351
  // + menu, where the tools themselves are.
317
352
  // Tier 3 shows your own model; the configured one is only the default.
@@ -323,6 +358,15 @@ export async function loadConfig() {
323
358
  }
324
359
 
325
360
  async function saveConfig() {
361
+ // Personal instructions go to the person's own prefs, whatever else they may edit.
362
+ if (!$('myInstructionsGroup').hidden) {
363
+ try { await api.post('/api/me/prefs', { instructions: $('myInstructions').value.trim() || null }); } catch (err) {
364
+ $('saveMsg').textContent = `save failed (${err.message})`;
365
+ return;
366
+ }
367
+ await loadAccess();
368
+ if (readOnly) { $('saveMsg').textContent = 'saved'; closePanel(PANEL.SETTINGS); return; }
369
+ }
326
370
  const patch = {
327
371
  systemPrompt: $('systemPrompt').value,
328
372
  model: $('model').value.trim(),
@@ -341,12 +385,32 @@ async function saveConfig() {
341
385
  timezone: $('timezone').value,
342
386
  toolApproval: $('toolApproval').value
343
387
  };
388
+ if (!$('adminSettings').hidden) {
389
+ try {
390
+ const apiKey = $('apiKey').value;
391
+ Object.assign(patch, {
392
+ baseUrl: $('baseUrl').value.trim(),
393
+ ...(apiKey ? { apiKey } : {}),
394
+ llmCompaction: $('llmCompaction').value === 'true',
395
+ compactionMaxTokens: Number($('compactionMaxTokens').value) || 8192,
396
+ expandCharBudget: Number($('expandCharBudget').value) || 0,
397
+ cache: $('cache').value === 'true',
398
+ reasoningReplay: $('reasoningReplay').value,
399
+ extraBody: $('extraBody').value.trim() ? JSON.parse($('extraBody').value) : {},
400
+ ...collectConnectors()
401
+ });
402
+ } catch (err) {
403
+ $('saveMsg').textContent = `save failed (${err.message})`;
404
+ return;
405
+ }
406
+ }
344
407
  for (const k of lockedKeys) delete patch[k];
345
408
  try { await api.post('/api/config', patch); } catch (err) {
346
409
  $('saveMsg').textContent = `save failed (${err.message})`;
347
410
  return;
348
411
  }
349
412
  await loadConfig();
413
+ await loadAccess(); // a changed model changes what it can take
350
414
  await loadTools(); // the per-tool "default" labels follow the global mode
351
415
  $('saveMsg').textContent = 'saved';
352
416
  closePanel(PANEL.SETTINGS);
package/public/styles.css CHANGED
@@ -1390,6 +1390,22 @@ button.pop-item:active:not(:disabled) { transform: none; }
1390
1390
  .set-group small { display: block; margin-top: .3125rem; color: var(--faint); font-size: var(--fs-meta); line-height: 1.4; }
1391
1391
  .set-group textarea#systemPrompt { font-family: var(--sans); font-size: var(--fs-ui); line-height: 1.55; }
1392
1392
  @media (max-width: 720px) { .set-grid { grid-template-columns: 1fr; } }
1393
+ .set-divider { width: min(46rem, 100%); margin: 1.5rem auto .75rem; padding-top: 1rem; border-top: 1px solid var(--line); }
1394
+ .set-divider h2 { margin: 0; font-size: 1.0625rem; }
1395
+ .set-divider p { margin: .125rem 0 0; color: var(--muted); font-size: var(--fs-small); }
1396
+ .policy-group { margin: .75rem 0; }
1397
+ .policy-checks { display: flex; flex-wrap: wrap; gap: .25rem 1rem; margin-top: .25rem; }
1398
+ .policy-checks label { display: inline-flex; gap: .3rem; align-items: center; }
1399
+ /* A person without the settings feature sees only what an admin lets them customise. */
1400
+ #settings.prefs-only .set-group:not(.mine), #settings.prefs-only .set-divider:not(.mine), #settings.prefs-only #adminSettings { display: none; }
1401
+ #settings .mine[hidden] { display: none; }
1402
+ /* Connector and model editors: one bordered row per entry. */
1403
+ .cfg-row { margin: 0 0 .75rem; padding: .75rem; border: 1px solid var(--line); border-radius: var(--r-sm, 6px); }
1404
+ .cfg-row .set-grid { gap: .75rem; }
1405
+ .cfg-row textarea { font-family: var(--mono, monospace); font-size: var(--fs-small); }
1406
+ .cfg-row .cfg-actions { display: flex; gap: .5rem; align-items: center; margin-top: .5rem; }
1407
+ .cfg-row .cfg-tags { display: flex; gap: 1rem; flex-wrap: wrap; align-items: center; }
1408
+ .cfg-row .cfg-tags label { display: inline-flex; gap: .3rem; align-items: center; margin: 0; }
1393
1409
 
1394
1410
  .brand-home {
1395
1411
  flex: 1; min-width: 0; display: flex; align-items: center; gap: .5rem;