tinywebui 0.1.3 → 0.1.5

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.
Files changed (82) hide show
  1. package/README.md +20 -13
  2. package/bin/tinywebui.js +69 -63
  3. package/package.json +25 -8
  4. package/public/app.js +40 -14
  5. package/public/chat/composer.js +1 -1
  6. package/public/chat/sidebar.js +8 -5
  7. package/public/index.html +88 -49
  8. package/public/panels/admin.js +40 -1
  9. package/public/panels/model-editor.js +336 -0
  10. package/public/panels/panels.js +37 -12
  11. package/public/panels/settings-home.js +125 -0
  12. package/public/panels/settings.js +124 -36
  13. package/public/shared/attachment_note.js +7 -1
  14. package/public/shared/events.js +1 -0
  15. package/public/shared/features.js +1 -0
  16. package/public/shared/origins.js +1 -0
  17. package/public/shared/usage.js +16 -1
  18. package/public/styles.css +83 -20
  19. package/src/access/auth.js +155 -155
  20. package/src/access/auth_gate.js +39 -38
  21. package/src/access/email.js +17 -0
  22. package/src/access/policy.js +123 -138
  23. package/src/audit.js +1 -1
  24. package/src/automations/automation.js +135 -123
  25. package/src/automations/scheduler.js +138 -127
  26. package/src/chat/compact.js +149 -156
  27. package/src/chat/llm.js +380 -382
  28. package/src/chat/runs.js +162 -151
  29. package/src/chat/tool_executor.js +229 -218
  30. package/src/cli.js +535 -433
  31. package/src/config/approval.js +35 -29
  32. package/src/config/config.js +546 -451
  33. package/src/config/data_dir.js +24 -0
  34. package/src/config/models.js +191 -115
  35. package/src/config/refs.js +32 -0
  36. package/src/config/secrets.js +153 -0
  37. package/src/files/documents.js +57 -68
  38. package/src/files/images.js +30 -31
  39. package/src/harness/agent.js +60 -60
  40. package/src/harness/attribution.js +66 -58
  41. package/src/harness/checkpoint.js +67 -62
  42. package/src/harness/history.js +28 -23
  43. package/src/harness/prompt.js +40 -57
  44. package/src/harness/provider.js +284 -283
  45. package/src/harness/types.js +1 -0
  46. package/src/http.js +98 -66
  47. package/src/log.js +11 -12
  48. package/src/mcp.js +398 -374
  49. package/src/retrieval/embedding.js +228 -240
  50. package/src/retrieval/retrieval.js +182 -173
  51. package/src/routes/admin.js +178 -138
  52. package/src/routes/auth.js +184 -100
  53. package/src/routes/automations.js +99 -82
  54. package/src/routes/chats.js +240 -218
  55. package/src/routes/documents.js +130 -120
  56. package/src/routes/library.js +43 -46
  57. package/src/routes/mcp.js +49 -49
  58. package/src/routes/settings.js +127 -122
  59. package/src/routes/types.js +1 -0
  60. package/src/server.js +428 -392
  61. package/src/store/automations.js +106 -106
  62. package/src/store/chats.js +217 -242
  63. package/src/store/documents.js +181 -185
  64. package/src/store/embeddings.js +52 -54
  65. package/src/store/index.js +95 -49
  66. package/src/store/messages.js +55 -80
  67. package/src/store/schema.js +128 -107
  68. package/src/store/scope.js +18 -17
  69. package/src/store/search.js +25 -27
  70. package/src/store/turns.js +53 -53
  71. package/src/store/usage.js +343 -269
  72. package/src/store/users.js +162 -87
  73. package/src/store/wire.js +50 -41
  74. package/src/tools/ask_tool.js +68 -68
  75. package/src/tools/chat_search_tool.js +38 -44
  76. package/src/tools/context_tool.js +131 -138
  77. package/src/tools/document_tool.js +73 -78
  78. package/src/tools/mcp_tool.js +274 -232
  79. package/src/tools/task_tool.js +53 -50
  80. package/src/tools/types.js +1 -0
  81. package/public/panels/connectors.js +0 -184
  82. package/src/set-password.js +0 -43
package/src/cli.js CHANGED
@@ -5,485 +5,587 @@ import { dirname, join } from 'node:path';
5
5
  import { PRESETS, resolveModel, findOnnxFile, sha256File, resolveRetrieval, defaultModelsDir } from './retrieval/embedding.js';
6
6
  import { createConfigSource, DEFAULTS, WRITABLE, configProblems } from './config/config.js';
7
7
  import { REASONING_REPLAY, MODEL_TAGS } from './config/models.js';
8
- import {
9
- validateConfig, fingerprint, featuresFor, modelsFor, resolveAccess, keyClass,
10
- FEATURES, ROLES, STATUSES, FILE_ONLY, SECRET_KEYS, CUSTOMIZABLE
11
- } from './access/policy.js';
8
+ import { validateConfig, fingerprint, featuresFor, modelsFor, resolveAccess, keyClass, FEATURES, ROLES, STATUSES, FILE_ONLY, SECRET_KEYS, CUSTOMIZABLE } from './access/policy.js';
12
9
  import { Store, SCHEMA_VERSION, MigrationRequiredError } from './store/index.js';
13
-
14
10
  /**
15
11
  * The scriptable side of TinyWebUI: everything an agent (or a person) needs
16
12
  * to check, explain and change a deployment from files, without the UI.
17
13
  * Each command reads the same layers start() does and never writes a
18
14
  * generated sessionSecret as a side effect.
19
15
  */
20
-
21
16
  const out = (v) => process.stdout.write(typeof v === 'string' ? `${v}\n` : `${JSON.stringify(v, null, 2)}\n`);
22
17
  const flag = (args, name) => { const i = args.indexOf(`--${name}`); return i === -1 ? undefined : args[i + 1]; };
23
-
24
18
  function loadAll(opts) {
25
- const source = createConfigSource(opts);
26
- const cfg = source.load({ persistSecret: false });
27
- let servers = null;
28
- let mcpError = null;
29
- try { servers = source.strictMcpServers(); } catch (err) { mcpError = `mcp.json: ${err.message}`; }
30
- return { source, cfg, servers: servers || {}, mcpError };
19
+ const source = createConfigSource(opts);
20
+ const cfg = source.load({ persistSecret: false });
21
+ let servers = null;
22
+ let mcpError = null;
23
+ try {
24
+ servers = source.strictMcpServers();
25
+ }
26
+ catch (err) {
27
+ mcpError = `mcp.json: ${err.message}`;
28
+ }
29
+ return { source, cfg, servers: servers || {}, mcpError };
31
30
  }
32
-
33
31
  const mb = (n) => (n < 1048576 ? `${Math.ceil(n / 1024)} KB` : `${(n / 1048576).toFixed(1)} MB`);
34
-
35
32
  /**
36
33
  * Streams a response body to disk. On a terminal it redraws one progress
37
34
  * line (the model file is ~90 MB and otherwise looks like a hang); piped
38
35
  * output gets just the finished line so logs stay clean.
39
36
  */
40
37
  async function download(res, target, label) {
41
- const total = Number(res.headers.get('content-length')) || 0;
42
- const tty = process.stderr.isTTY;
43
- let done = 0;
44
- let last = 0;
45
- const line = () => ` ${label} ${mb(done)}${total ? ` / ${mb(total)} (${Math.floor((done / total) * 100)}%)` : ''}`;
46
- const counter = new Transform({
47
- transform(chunk, _enc, cb) {
48
- done += chunk.length;
49
- const now = Date.now();
50
- if (tty && now - last > 100) { last = now; process.stderr.write(`\r${line()}\x1b[K`); }
51
- cb(null, chunk);
52
- }
53
- });
54
- await pipeline(Readable.fromWeb(res.body), counter, createWriteStream(target));
55
- process.stderr.write(tty ? `\r${line()}\x1b[K\n` : `${line()}\n`);
38
+ const total = Number(res.headers.get('content-length')) || 0;
39
+ const tty = process.stderr.isTTY;
40
+ let done = 0;
41
+ let last = 0;
42
+ const line = () => ` ${label} ${mb(done)}${total ? ` / ${mb(total)} (${Math.floor((done / total) * 100)}%)` : ''}`;
43
+ const counter = new Transform({
44
+ transform(chunk, _enc, cb) {
45
+ done += chunk.length;
46
+ const now = Date.now();
47
+ if (tty && now - last > 100) {
48
+ last = now;
49
+ process.stderr.write(`\r${line()}\x1b[K`);
50
+ }
51
+ cb(null, chunk);
52
+ }
53
+ });
54
+ await pipeline(Readable.fromWeb(res.body), counter, createWriteStream(target));
55
+ process.stderr.write(tty ? `\r${line()}\x1b[K\n` : `${line()}\n`);
56
56
  }
57
-
58
57
  /**
59
58
  * Downloads a preset bundle into `dest`, via a .partial folder so an
60
59
  * interrupted fetch never leaves a half bundle that looks complete.
61
60
  */
62
61
  async function pullPreset(name, dest) {
63
- const preset = PRESETS[name];
64
- const tmp = `${dest}.partial`;
65
- rmSync(tmp, { recursive: true, force: true });
66
- process.stderr.write(`[tinywebui] fetching ${preset.repoId} into ${dest} (see its Hugging Face model card for the license)\n`);
67
- try {
68
- for (const file of preset.files) {
69
- const url = `https://huggingface.co/${preset.repoId}/resolve/main/${file}`;
70
- const res = await fetch(url);
71
- if (res.status === 404 && file !== preset.onnxPaths[0] && file !== 'tokenizer.json') continue; // companion files are optional
72
- if (!res.ok) throw new Error(`${res.status} fetching ${url}`);
73
- const target = join(tmp, file);
74
- mkdirSync(dirname(target), { recursive: true });
75
- await download(res, target, file);
76
- }
77
- } catch (err) {
62
+ const preset = PRESETS[name];
63
+ const tmp = `${dest}.partial`;
78
64
  rmSync(tmp, { recursive: true, force: true });
79
- throw err;
80
- }
81
- rmSync(dest, { recursive: true, force: true });
82
- mkdirSync(dirname(dest), { recursive: true });
83
- renameSync(tmp, dest);
84
- const onnx = findOnnxFile({ ...preset, dir: dest });
85
- return { model: name, dir: dest, onnx, sha256: await sha256File(onnx) };
65
+ process.stderr.write(`[tinywebui] fetching ${preset.repoId} into ${dest} (see its Hugging Face model card for the license)\n`);
66
+ try {
67
+ for (const file of preset.files) {
68
+ const url = `https://huggingface.co/${preset.repoId}/resolve/main/${file}`;
69
+ const res = await fetch(url);
70
+ if (res.status === 404 && file !== preset.onnxPaths[0] && file !== 'tokenizer.json')
71
+ continue; // companion files are optional
72
+ if (!res.ok)
73
+ throw new Error(`${res.status} fetching ${url}`);
74
+ const target = join(tmp, file);
75
+ mkdirSync(dirname(target), { recursive: true });
76
+ await download(res, target, file);
77
+ }
78
+ }
79
+ catch (err) {
80
+ rmSync(tmp, { recursive: true, force: true });
81
+ throw err;
82
+ }
83
+ rmSync(dest, { recursive: true, force: true });
84
+ mkdirSync(dirname(dest), { recursive: true });
85
+ renameSync(tmp, dest);
86
+ const onnx = findOnnxFile({ ...preset, dir: dest });
87
+ return { model: name, dir: dest, onnx, sha256: await sha256File(onnx) };
86
88
  }
87
-
88
89
  function mcpProblems(servers) {
89
- const problems = [];
90
- for (const [name, spec] of Object.entries(servers)) {
91
- if (!spec || (!spec.command && !spec.url)) problems.push(`mcp server "${name}" needs either "command" or "url"`);
92
- }
93
- return problems;
90
+ const problems = [];
91
+ for (const [name, spec] of Object.entries(servers)) {
92
+ if (!spec || (!spec.command && !spec.url))
93
+ problems.push(`mcp server "${name}" needs either "command" or "url"`);
94
+ }
95
+ return problems;
94
96
  }
95
-
96
97
  /** Server specs with credentials blanked: env values and header values. */
97
98
  function redactServers(servers) {
98
- const clean = {};
99
- for (const [name, spec] of Object.entries(servers)) {
100
- const s = { ...spec };
101
- if (s.env) s.env = Object.fromEntries(Object.keys(s.env).map((k) => [k, '<redacted>']));
102
- if (s.headers) s.headers = Object.fromEntries(Object.keys(s.headers).map((k) => [k, '<redacted>']));
103
- clean[name] = s;
104
- }
105
- return clean;
99
+ const clean = {};
100
+ for (const [name, spec] of Object.entries(servers)) {
101
+ const s = { ...spec };
102
+ if (s.env)
103
+ s.env = Object.fromEntries(Object.keys(s.env).map((k) => [k, '<redacted>']));
104
+ if (s.headers)
105
+ s.headers = Object.fromEntries(Object.keys(s.headers).map((k) => [k, '<redacted>']));
106
+ clean[name] = s;
107
+ }
108
+ return clean;
106
109
  }
107
-
108
110
  const commands = {
109
- /** Exit 0 with the fingerprint when the files are valid; exit 1 listing every problem. */
110
- validate(args, opts) {
111
- const { source, cfg, servers, mcpError } = loadAll(opts);
112
- // The merged config can be valid while config.json holds a mistake that
113
- // code happens to shadow today; flag that too, before it surfaces later.
114
- const raw = source.readFile();
115
- const shadowed = raw.access === undefined ? []
116
- : validateConfig({ authMode: 'none', access: raw.access }).map((p) => `config.json: ${p}`);
117
- const problems = [...configProblems(cfg), ...shadowed, ...(mcpError ? [mcpError] : mcpProblems(servers))];
118
- if (problems.length) {
119
- for (const p of problems) process.stderr.write(`error: ${p}\n`);
120
- return 1;
121
- }
122
- out(`ok ${fingerprint(cfg, servers)}`);
123
- return 0;
124
- },
125
-
126
- fingerprint(args, opts) {
127
- const { cfg, servers } = loadAll(opts);
128
- out(fingerprint(cfg, servers));
129
- return 0;
130
- },
131
-
132
- /**
133
- * What is actually in effect, and how each setting can change. With
134
- * --role, just what that role gets. Secrets show only as set/unset.
135
- */
136
- effective(args, opts) {
137
- const { source, cfg, servers } = loadAll(opts);
138
- const locked = source.lockedKeys();
139
- const roleFor = (role) => ({
140
- features: [...featuresFor(cfg, role)],
141
- models: modelsFor(cfg, role)
142
- });
143
- const role = flag(args, 'role');
144
- if (role) {
145
- if (!ROLES.includes(role)) { process.stderr.write(`error: unknown role "${role}" (known: ${ROLES.join(', ')})\n`); return 1; }
146
- out({ role, authMode: cfg.authMode, ...roleFor(role) });
147
- return 0;
148
- }
149
- const settings = {};
150
- for (const key of Object.keys(cfg).sort()) {
151
- if (key === 'access') continue;
152
- const value = SECRET_KEYS.has(key) ? (cfg[key] ? '<set>' : '<unset>') : cfg[key];
153
- settings[key] = { value, change: WRITABLE.has(key) || FILE_ONLY.has(key) ? keyClass(key, locked) : 'file-only' };
154
- }
155
- const access = resolveAccess(cfg);
156
- out({
157
- fingerprint: fingerprint(cfg, servers),
158
- files: { config: source.path(), mcp: source.mcpPath() || '(in code)' },
159
- authMode: cfg.authMode,
160
- roles: cfg.authMode === 'trusted-header'
161
- ? Object.fromEntries(ROLES.map((r) => [r, roleFor(r)]))
162
- : { you: roleFor(null) },
163
- access: cfg.authMode === 'trusted-header'
164
- ? { bootstrapAdmins: access.bootstrapAdmins, newUsers: access.newUsers, users: access.users }
165
- : undefined,
166
- mcpServers: { locked: source.mcpLocked, servers: redactServers(servers) },
167
- settings
168
- });
169
- return 0;
170
- },
171
-
172
- /**
173
- * users list provisioned users, with any decision pinned in the files
174
- * users set <id> [--role admin|user] [--status pending|approved|disabled] [--clear]
175
- * records the decision in config.json (a running
176
- * server picks it up through its file watch)
177
- */
178
- users(args, opts) {
179
- const [sub, externalId] = args;
180
- const { source, cfg } = loadAll(opts);
181
- const access = resolveAccess(cfg);
182
- if (sub === 'list' || !sub) {
183
- const dbFile = source.dbPath(cfg);
184
- const rows = dbFile !== ':memory:' && existsSync(dbFile) ? (() => {
185
- const store = new Store(dbFile);
186
- try { return store.users.list(); } finally { store.close(); }
187
- })() : [];
188
- const seen = new Set();
189
- const users = rows.map((u) => {
190
- seen.add(u.external_id);
191
- return {
192
- externalId: u.external_id, email: u.email, name: u.name, role: u.role, status: u.status,
193
- chats: u.chat_count, lastLoginAt: u.last_login_at ? new Date(u.last_login_at).toISOString() : null,
194
- pinned: access.bootstrapAdmins.includes(u.external_id) ? 'bootstrapAdmins' : access.users[u.external_id] || null
195
- };
196
- });
197
- // Decisions for people who have not signed in yet still belong in the list.
198
- for (const [id, pin] of Object.entries(access.users)) if (!seen.has(id)) users.push({ externalId: id, pinned: pin, provisioned: false });
199
- for (const id of access.bootstrapAdmins) if (!seen.has(id)) users.push({ externalId: id, pinned: 'bootstrapAdmins', provisioned: false });
200
- out(users);
201
- return 0;
202
- }
203
- if (sub === 'set') {
204
- if (!externalId) { process.stderr.write('usage: tinywebui users set <gateway user id> [--role r] [--status s] [--clear]\n'); return 1; }
205
- if (!source.path()) { process.stderr.write('error: no config file to write to\n'); return 1; }
206
- if (source.codeAccessUsers()[externalId] || access.bootstrapAdmins.includes(externalId)) {
207
- process.stderr.write(`error: "${externalId}" is declared in tinywebui.config.js; change it there\n`);
208
- return 1;
209
- }
210
- const role = flag(args, 'role');
211
- const status = flag(args, 'status');
212
- const clear = args.includes('--clear');
213
- if (!clear && role === undefined && status === undefined) { process.stderr.write('error: nothing to set (use --role, --status or --clear)\n'); return 1; }
214
- if (role !== undefined && !ROLES.includes(role)) { process.stderr.write(`error: role must be one of ${ROLES.join(', ')}\n`); return 1; }
215
- if (status !== undefined && !STATUSES.includes(status)) { process.stderr.write(`error: status must be one of ${STATUSES.join(', ')}\n`); return 1; }
216
- const file = source.readFile();
217
- const fileAccess = file.access && typeof file.access === 'object' ? file.access : {};
218
- const users = { ...(fileAccess.users || {}) };
219
- if (clear) delete users[externalId];
220
- else users[externalId] = { ...(users[externalId] || {}), ...(role ? { role } : {}), ...(status ? { status } : {}) };
221
- source.writeFile({ ...file, access: { ...fileAccess, users } });
222
- out(clear ? `cleared ${externalId}` : `${externalId}: ${JSON.stringify(users[externalId])}`);
223
- return 0;
224
- }
225
- process.stderr.write(`error: unknown users command "${sub}" (list, set)\n`);
226
- return 1;
227
- },
228
-
229
- schema() {
230
- out(configSchema());
231
- return 0;
232
- },
233
-
234
- /**
235
- * models pull <fast|balanced|quality|multilingual> [--dir path]
236
- * Fetches a preset embedding bundle from Hugging Face -- the same repos
237
- * and files TinySearch uses (multilingual is TinyWebUI's own) -- into the models folder, then prints the
238
- * model's sha256 to pin as retrieval.modelSha256.
239
- * models ensure
240
- * Pulls the configured preset only if it is not there yet; `npm start`
241
- * runs it first. Nothing to do under mode 'lexical' or with a custom
242
- * modelDir. Under 'auto' a failed pull is a warning and exit 0 -- the
243
- * server then starts lexical -- so being offline never blocks a start.
244
- * models verify
245
- * Loads the configured bundle exactly as startup would and embeds a probe.
246
- *
247
- * pull and ensure are the only code that downloads a model; the server never does.
248
- */
249
- async models(args, opts) {
250
- const [sub, name] = args;
251
- const { source, cfg } = loadAll(opts);
252
- const modelsDir = defaultModelsDir(source.path());
253
- if (sub === 'pull') {
254
- const preset = PRESETS[name];
255
- if (!preset) { process.stderr.write(`usage: tinywebui models pull <${Object.keys(PRESETS).join('|')}> [--dir path]\n`); return 1; }
256
- const dest = flag(args, 'dir') ? join(flag(args, 'dir')) : join(modelsDir, preset.localDir);
257
- try { out(await pullPreset(name, dest)); return 0; } catch (err) { process.stderr.write(`error: ${err.message}\n`); return 1; }
258
- }
259
- if (sub === 'ensure') {
260
- const { mode, model, modelDir } = cfg.retrieval;
261
- if (mode === 'lexical' || modelDir || !PRESETS[model]) return 0;
262
- const spec = resolveModel(cfg.retrieval, modelsDir);
263
- if (findOnnxFile(spec) && existsSync(join(spec.dir, 'tokenizer.json'))) return 0;
264
- try {
265
- const pulled = await pullPreset(model, spec.dir);
266
- process.stderr.write(`[tinywebui] embedding model ${model} ready (sha256 ${pulled.sha256})\n`);
111
+ /** Exit 0 with the fingerprint when the files are valid; exit 1 listing every problem. */
112
+ validate(args, opts) {
113
+ const { source, cfg, servers, mcpError } = loadAll(opts);
114
+ // The merged config can be valid while config.json holds a mistake that
115
+ // code happens to shadow today; flag that too, before it surfaces later.
116
+ const raw = source.readFile();
117
+ const shadowed = raw.access === undefined ? []
118
+ : validateConfig({ authMode: 'local', access: raw.access }).map((p) => `config.json: ${p}`);
119
+ const problems = [...configProblems(cfg), ...shadowed, ...(mcpError ? [mcpError] : mcpProblems(servers))];
120
+ if (problems.length) {
121
+ for (const p of problems)
122
+ process.stderr.write(`error: ${p}\n`);
123
+ return 1;
124
+ }
125
+ const inFile = source.secretsInFile();
126
+ if (inFile.length)
127
+ process.stderr.write(`note: ${source.path()} holds ${inFile.join(', ')}; the server moves them to ${source.secretsPath()} when it starts\n`);
128
+ out(`ok ${fingerprint(cfg, servers)}`);
267
129
  return 0;
268
- } catch (err) {
269
- if (mode !== 'auto') { process.stderr.write(`error: ${err.message}\n`); return 1; }
270
- process.stderr.write(`[tinywebui] could not fetch embedding model ${model} (${err.message}); starting with lexical search -- set retrieval.mode to 'lexical' to skip this\n`);
130
+ },
131
+ fingerprint(args, opts) {
132
+ const { cfg, servers } = loadAll(opts);
133
+ out(fingerprint(cfg, servers));
271
134
  return 0;
272
- }
273
- }
274
- if (sub === 'verify') {
275
- const r = await resolveRetrieval(cfg.retrieval, modelsDir);
276
- if (!r.embedder) { out(r.fallback ? `retrieval.mode is auto: lexical, because ${r.fallback}` : 'retrieval.mode is lexical: no embedding model needed'); return 0; }
277
- const e = r.embedder;
278
- out({ mode: r.mode, model: e.spec.name, dir: e.spec.dir, sha256: e.sha256, pinned: Boolean(cfg.retrieval.modelSha256), dims: e.dim, key: e.key });
279
- e.close?.();
280
- return 0;
281
- }
282
- process.stderr.write('usage: tinywebui models pull <preset> [--dir path] | models ensure | models verify\n');
283
- return 1;
284
- },
285
-
286
- config(args, opts) {
287
- if (args[0] !== 'show') { process.stderr.write('usage: tinywebui config show [--role user|admin]\n'); return 1; }
288
- return commands.effective(args.slice(1), opts);
289
- },
290
-
291
- /**
292
- * Brings the database to the current schema, as a deliberate deployment
293
- * step (pair it with autoMigrate: false). --check changes nothing and
294
- * exits 1 when a migration is due.
295
- */
296
- migrate(args, opts) {
297
- const { source, cfg } = loadAll(opts);
298
- const file = source.dbPath(cfg);
299
- if (file === ':memory:') { out('in-memory database: nothing to migrate'); return 0; }
300
- if (args.includes('--check')) {
301
- if (!existsSync(file)) { out(`no database yet at ${file}; it will be created at schema ${SCHEMA_VERSION}`); return 0; }
302
- try {
303
- new Store(file, { migrate: false }).close();
304
- out(`up to date: schema ${SCHEMA_VERSION}`);
135
+ },
136
+ /**
137
+ * What is actually in effect, and how each setting can change. With
138
+ * --role, just what that role gets. Secrets show only as set/unset.
139
+ */
140
+ effective(args, opts) {
141
+ const { source, cfg, servers } = loadAll(opts);
142
+ const locked = source.lockedKeys();
143
+ const roleFor = (role) => ({
144
+ features: [...featuresFor(cfg, role)],
145
+ models: modelsFor(cfg, role)
146
+ });
147
+ const role = flag(args, 'role');
148
+ if (role) {
149
+ if (!ROLES.includes(role)) {
150
+ process.stderr.write(`error: unknown role "${role}" (known: ${ROLES.join(', ')})\n`);
151
+ return 1;
152
+ }
153
+ out({ role, authMode: cfg.authMode, ...roleFor(role) });
154
+ return 0;
155
+ }
156
+ const settings = {};
157
+ const values = cfg;
158
+ for (const key of Object.keys(cfg).sort()) {
159
+ if (key === 'access')
160
+ continue;
161
+ const value = SECRET_KEYS.has(key) ? (values[key] ? '<set>' : '<unset>') : values[key];
162
+ settings[key] = { value, change: WRITABLE.has(key) || FILE_ONLY.has(key) ? keyClass(key, locked) : 'file-only' };
163
+ }
164
+ const access = resolveAccess(cfg);
165
+ out({
166
+ fingerprint: fingerprint(cfg, servers),
167
+ files: { config: source.path(), secrets: source.secretsPath(), mcp: source.mcpPath() || '(in code)' },
168
+ authMode: cfg.authMode,
169
+ roles: ['multiuser', 'trusted-header'].includes(cfg.authMode)
170
+ ? Object.fromEntries(ROLES.map((r) => [r, roleFor(r)]))
171
+ : { you: roleFor(null) },
172
+ access: ['multiuser', 'trusted-header'].includes(cfg.authMode)
173
+ ? { bootstrapAdmins: access.bootstrapAdmins, newUsers: access.newUsers, users: access.users }
174
+ : undefined,
175
+ mcpServers: { locked: source.mcpLocked, servers: redactServers(servers) },
176
+ settings
177
+ });
305
178
  return 0;
306
- } catch (err) {
307
- if (!(err instanceof MigrationRequiredError)) throw err;
308
- process.stderr.write(`${err.message}\n`);
179
+ },
180
+ /**
181
+ * users list provisioned users, with any decision pinned in the files
182
+ * users set <id> [--role admin|user] [--status pending|approved|disabled] [--clear]
183
+ * records the decision in config.json (a running
184
+ * server picks it up through its file watch)
185
+ */
186
+ users(args, opts) {
187
+ const [sub, externalId] = args;
188
+ const { source, cfg } = loadAll(opts);
189
+ const access = resolveAccess(cfg);
190
+ if (sub === 'list' || !sub) {
191
+ const dbFile = source.dbPath(cfg);
192
+ const rows = dbFile !== ':memory:' && existsSync(dbFile) ? (() => {
193
+ const store = new Store(dbFile);
194
+ try {
195
+ return store.users.list();
196
+ }
197
+ finally {
198
+ store.close();
199
+ }
200
+ })() : [];
201
+ const seen = new Set();
202
+ const users = rows.map((u) => {
203
+ seen.add(u.external_id);
204
+ return {
205
+ externalId: u.external_id, email: u.email, name: u.name, role: u.role, status: u.status,
206
+ chats: u.chat_count, lastLoginAt: u.last_login_at ? new Date(u.last_login_at).toISOString() : null,
207
+ pinned: access.bootstrapAdmins.includes(u.external_id) ? 'bootstrapAdmins' : access.users[u.external_id] || null
208
+ };
209
+ });
210
+ // Decisions for people who have not signed in yet still belong in the list.
211
+ for (const [id, pin] of Object.entries(access.users))
212
+ if (!seen.has(id))
213
+ users.push({ externalId: id, pinned: pin, provisioned: false });
214
+ for (const id of access.bootstrapAdmins)
215
+ if (!seen.has(id))
216
+ users.push({ externalId: id, pinned: 'bootstrapAdmins', provisioned: false });
217
+ out(users);
218
+ return 0;
219
+ }
220
+ if (sub === 'set') {
221
+ if (!externalId) {
222
+ process.stderr.write('usage: tinywebui users set <gateway user id> [--role r] [--status s] [--clear]\n');
223
+ return 1;
224
+ }
225
+ if (!source.path()) {
226
+ process.stderr.write('error: no config file to write to\n');
227
+ return 1;
228
+ }
229
+ if (source.codeAccessUsers()[externalId] || access.bootstrapAdmins.includes(externalId)) {
230
+ process.stderr.write(`error: "${externalId}" is declared in tinywebui.config.js; change it there\n`);
231
+ return 1;
232
+ }
233
+ const role = flag(args, 'role');
234
+ const status = flag(args, 'status');
235
+ const clear = args.includes('--clear');
236
+ if (!clear && role === undefined && status === undefined) {
237
+ process.stderr.write('error: nothing to set (use --role, --status or --clear)\n');
238
+ return 1;
239
+ }
240
+ if (role !== undefined && !ROLES.includes(role)) {
241
+ process.stderr.write(`error: role must be one of ${ROLES.join(', ')}\n`);
242
+ return 1;
243
+ }
244
+ if (status !== undefined && !STATUSES.includes(status)) {
245
+ process.stderr.write(`error: status must be one of ${STATUSES.join(', ')}\n`);
246
+ return 1;
247
+ }
248
+ const file = source.readFile();
249
+ const fileAccess = file.access && typeof file.access === 'object' ? file.access : {};
250
+ const users = { ...(fileAccess.users || {}) };
251
+ if (clear)
252
+ delete users[externalId];
253
+ else
254
+ users[externalId] = { ...(users[externalId] || {}), ...(role ? { role } : {}), ...(status ? { status } : {}) };
255
+ source.writeFile({ ...file, access: { ...fileAccess, users } });
256
+ out(clear ? `cleared ${externalId}` : `${externalId}: ${JSON.stringify(users[externalId])}`);
257
+ return 0;
258
+ }
259
+ process.stderr.write(`error: unknown users command "${sub}" (list, set)\n`);
309
260
  return 1;
310
- }
311
- }
312
- const store = new Store(file);
313
- try {
314
- out(store.migratedFrom === null ? `created ${file} at schema ${SCHEMA_VERSION}`
315
- : store.migratedFrom === SCHEMA_VERSION ? `up to date: schema ${SCHEMA_VERSION}`
316
- : `migrated ${file} from schema ${store.migratedFrom} to ${SCHEMA_VERSION}`);
317
- } finally { store.close(); }
318
- return 0;
319
- },
320
-
321
- /**
322
- * Checks everything a deployment depends on, one line per check, and
323
- * exits 1 if any failed: node version, config, database, the model
324
- * endpoint, and each enabled MCP server (connected, then closed).
325
- */
326
- async doctor(args, opts) {
327
- const checks = [];
328
- const check = async (name, fn) => {
329
- try { const detail = await fn(); checks.push({ check: name, ok: true, ...(detail ? { detail } : {}) }); }
330
- catch (err) { checks.push({ check: name, ok: false, detail: err.message }); }
331
- };
332
- let loaded;
333
- await check('node', () => {
334
- // node:sqlite is available without a flag from 22.13.
335
- const [major, minor] = process.versions.node.split('.').map(Number);
336
- if (major < 22 || (major === 22 && minor < 13)) throw new Error(`node ${process.versions.node}; TinyWebUI needs 22.13 or later`);
337
- return process.versions.node;
338
- });
339
- await check('config', () => {
340
- loaded = loadAll(opts);
341
- const problems = [...configProblems(loaded.cfg), ...(loaded.mcpError ? [loaded.mcpError] : mcpProblems(loaded.servers))];
342
- if (problems.length) throw new Error(problems.join('; '));
343
- return `fingerprint ${fingerprint(loaded.cfg, loaded.servers)}`;
344
- });
345
- if (loaded) {
346
- const { source, cfg, servers } = loaded;
347
- await check('database', () => {
261
+ },
262
+ schema() {
263
+ out(configSchema());
264
+ return 0;
265
+ },
266
+ /**
267
+ * models pull <fast|balanced|quality|multilingual> [--dir path]
268
+ * Fetches a preset embedding bundle from Hugging Face -- the same repos
269
+ * and files TinySearch uses (multilingual is TinyWebUI's own) -- into the models folder, then prints the
270
+ * model's sha256 to pin as retrieval.modelSha256.
271
+ * models ensure
272
+ * Pulls the configured preset only if it is not there yet; `npm start`
273
+ * runs it first. Nothing to do under mode 'lexical' or with a custom
274
+ * modelDir. Under 'auto' a failed pull is a warning and exit 0 -- the
275
+ * server then starts lexical -- so being offline never blocks a start.
276
+ * models verify
277
+ * Loads the configured bundle exactly as startup would and embeds a probe.
278
+ *
279
+ * pull and ensure are the only code that downloads a model; the server never does.
280
+ */
281
+ async models(args, opts) {
282
+ const [sub, name] = args;
283
+ const { source, cfg } = loadAll(opts);
284
+ const modelsDir = defaultModelsDir(source.path());
285
+ if (sub === 'pull') {
286
+ const preset = PRESETS[name];
287
+ if (!preset) {
288
+ process.stderr.write(`usage: tinywebui models pull <${Object.keys(PRESETS).join('|')}> [--dir path]\n`);
289
+ return 1;
290
+ }
291
+ const dest = flag(args, 'dir') ? join(flag(args, 'dir')) : join(modelsDir, preset.localDir);
292
+ try {
293
+ out(await pullPreset(name, dest));
294
+ return 0;
295
+ }
296
+ catch (err) {
297
+ process.stderr.write(`error: ${err.message}\n`);
298
+ return 1;
299
+ }
300
+ }
301
+ if (sub === 'ensure') {
302
+ const { mode, model, modelDir } = cfg.retrieval;
303
+ if (mode === 'lexical' || modelDir || !PRESETS[model])
304
+ return 0;
305
+ const spec = resolveModel(cfg.retrieval, modelsDir);
306
+ if (findOnnxFile(spec) && existsSync(join(spec.dir, 'tokenizer.json')))
307
+ return 0;
308
+ try {
309
+ const pulled = await pullPreset(model, spec.dir);
310
+ process.stderr.write(`[tinywebui] embedding model ${model} ready (sha256 ${pulled.sha256})\n`);
311
+ return 0;
312
+ }
313
+ catch (err) {
314
+ if (mode !== 'auto') {
315
+ process.stderr.write(`error: ${err.message}\n`);
316
+ return 1;
317
+ }
318
+ process.stderr.write(`[tinywebui] could not fetch embedding model ${model} (${err.message}); starting with lexical search -- set retrieval.mode to 'lexical' to skip this\n`);
319
+ return 0;
320
+ }
321
+ }
322
+ if (sub === 'verify') {
323
+ const r = await resolveRetrieval(cfg.retrieval, modelsDir);
324
+ if (!r.embedder) {
325
+ out(r.fallback ? `retrieval.mode is auto: lexical, because ${r.fallback}` : 'retrieval.mode is lexical: no embedding model needed');
326
+ return 0;
327
+ }
328
+ const e = r.embedder;
329
+ out({ mode: r.mode, model: e.spec.name, dir: e.spec.dir, sha256: e.sha256, pinned: Boolean(cfg.retrieval.modelSha256), dims: e.dim, key: e.key });
330
+ e.close?.();
331
+ return 0;
332
+ }
333
+ process.stderr.write('usage: tinywebui models pull <preset> [--dir path] | models ensure | models verify\n');
334
+ return 1;
335
+ },
336
+ config(args, opts) {
337
+ if (args[0] !== 'show') {
338
+ process.stderr.write('usage: tinywebui config show [--role user|admin]\n');
339
+ return 1;
340
+ }
341
+ return commands.effective(args.slice(1), opts);
342
+ },
343
+ /**
344
+ * Brings the database to the current schema, as a deliberate deployment
345
+ * step (pair it with autoMigrate: false). --check changes nothing and
346
+ * exits 1 when a migration is due.
347
+ */
348
+ migrate(args, opts) {
349
+ const { source, cfg } = loadAll(opts);
348
350
  const file = source.dbPath(cfg);
349
- if (file === ':memory:') return 'in memory';
350
- if (!existsSync(file)) return `${file} will be created`;
351
- let store;
352
- try { store = new Store(file, { migrate: false }); } catch (err) {
353
- // Behind is fine when startup is allowed to migrate; it is only a
354
- // failure for deployments that migrate as a separate step.
355
- if (err instanceof MigrationRequiredError && cfg.autoMigrate !== false && !/newer/.test(err.message)) {
356
- return `${err.message.split(':')[0]}; will migrate on start (autoMigrate)`;
357
- }
358
- throw err;
351
+ if (file === ':memory:') {
352
+ out('in-memory database: nothing to migrate');
353
+ return 0;
354
+ }
355
+ if (args.includes('--check')) {
356
+ if (!existsSync(file)) {
357
+ out(`no database yet at ${file}; it will be created at schema ${SCHEMA_VERSION}`);
358
+ return 0;
359
+ }
360
+ try {
361
+ new Store(file, { migrate: false }).close();
362
+ out(`up to date: schema ${SCHEMA_VERSION}`);
363
+ return 0;
364
+ }
365
+ catch (err) {
366
+ if (!(err instanceof MigrationRequiredError))
367
+ throw err;
368
+ process.stderr.write(`${err.message}\n`);
369
+ return 1;
370
+ }
371
+ }
372
+ const store = new Store(file);
373
+ try {
374
+ out(store.migratedFrom === null ? `created ${file} at schema ${SCHEMA_VERSION}`
375
+ : store.migratedFrom === SCHEMA_VERSION ? `up to date: schema ${SCHEMA_VERSION}`
376
+ : `migrated ${file} from schema ${store.migratedFrom} to ${SCHEMA_VERSION}`);
377
+ }
378
+ finally {
379
+ store.close();
359
380
  }
360
- try { return `${file} at schema ${store.schemaVersion()}`; } finally { store.close(); }
361
- });
362
- await check('model endpoint', async () => {
363
- const r = await fetch(`${cfg.baseUrl}/models`, {
364
- headers: cfg.apiKey ? { authorization: `Bearer ${cfg.apiKey}` } : {},
365
- signal: AbortSignal.timeout(8000)
381
+ return 0;
382
+ },
383
+ /**
384
+ * Checks everything a deployment depends on, one line per check, and
385
+ * exits 1 if any failed: node version, config, database, the model
386
+ * endpoint, and each enabled MCP server (connected, then closed).
387
+ */
388
+ async doctor(args, opts) {
389
+ const checks = [];
390
+ const check = async (name, fn) => {
391
+ try {
392
+ const detail = await fn();
393
+ checks.push({ check: name, ok: true, ...(detail ? { detail } : {}) });
394
+ }
395
+ catch (err) {
396
+ checks.push({ check: name, ok: false, detail: err.message });
397
+ }
398
+ };
399
+ let loaded;
400
+ await check('node', () => {
401
+ // node:sqlite is available without a flag from 22.13.
402
+ const [major, minor] = process.versions.node.split('.').map(Number);
403
+ if (major < 22 || (major === 22 && minor < 13))
404
+ throw new Error(`node ${process.versions.node}; TinyWebUI needs 22.13 or later`);
405
+ return process.versions.node;
366
406
  });
367
- // Some OpenAI-compatible servers have no /models; reachable is what counts.
368
- if (r.status >= 500) throw new Error(`${r.status} from ${cfg.baseUrl}/models`);
369
- if (r.status === 401 || r.status === 403) throw new Error(`${r.status} from ${cfg.baseUrl}: check apiKey`);
370
- return `${cfg.baseUrl} answered ${r.status}`;
371
- });
372
- await check('retrieval', async () => {
373
- const r = await resolveRetrieval(cfg.retrieval, defaultModelsDir(source.path()));
374
- if (r.fallback) return `lexical (SQLite FTS5) -- auto, because ${r.fallback}`;
375
- if (!r.embedder) return 'lexical (SQLite FTS5)';
376
- const e = r.embedder;
377
- e.close?.();
378
- return `${r.mode} with ${e.spec.repoId}, ${e.dim} dims${cfg.retrieval.modelSha256 ? ', checksum pinned' : ', checksum NOT pinned (set retrieval.modelSha256)'}`;
379
- });
380
- const { McpHub } = await import('./mcp.js');
381
- for (const [name, spec] of Object.entries(servers)) {
382
- if (spec?.disabled) { checks.push({ check: `mcp ${name}`, ok: true, detail: 'disabled' }); continue; }
383
- await check(`mcp ${name}`, async () => {
384
- const hub = await new McpHub({ [name]: spec }).connect();
385
- try {
386
- if (hub.errors.length) throw new Error(hub.errors.join('; '));
387
- return `${hub.tools.length} tool(s)`;
388
- } finally { await hub.close(); }
407
+ await check('config', () => {
408
+ loaded = loadAll(opts);
409
+ const problems = [...configProblems(loaded.cfg), ...(loaded.mcpError ? [loaded.mcpError] : mcpProblems(loaded.servers))];
410
+ if (problems.length)
411
+ throw new Error(problems.join('; '));
412
+ return `fingerprint ${fingerprint(loaded.cfg, loaded.servers)}`;
389
413
  });
390
- }
414
+ if (loaded) {
415
+ const { source, cfg, servers } = loaded;
416
+ await check('database', () => {
417
+ const file = source.dbPath(cfg);
418
+ if (file === ':memory:')
419
+ return 'in memory';
420
+ if (!existsSync(file))
421
+ return `${file} will be created`;
422
+ let store;
423
+ try {
424
+ store = new Store(file, { migrate: false });
425
+ }
426
+ catch (err) {
427
+ // Behind is fine when startup is allowed to migrate; it is only a
428
+ // failure for deployments that migrate as a separate step.
429
+ if (err instanceof MigrationRequiredError && cfg.autoMigrate !== false && !/newer/.test(err.message)) {
430
+ return `${err.message.split(':')[0]}; will migrate on start (autoMigrate)`;
431
+ }
432
+ throw err;
433
+ }
434
+ try {
435
+ return `${file} at schema ${store.schemaVersion()}`;
436
+ }
437
+ finally {
438
+ store.close();
439
+ }
440
+ });
441
+ const endpoints = [
442
+ ...(cfg.baseUrl ? [{ name: 'model endpoint', baseUrl: cfg.baseUrl, apiKey: cfg.apiKey }] : []),
443
+ ...cfg.connectors.map((c) => ({ name: `connector ${c.id}`, baseUrl: String(c.baseUrl).replace(/\/+$/, ''), apiKey: c.apiKey || '' }))
444
+ ];
445
+ if (!endpoints.length)
446
+ await check('model endpoint', () => { throw new Error('none: add a connector and its models in Settings'); });
447
+ for (const ep of endpoints) {
448
+ await check(ep.name, async () => {
449
+ const r = await fetch(`${ep.baseUrl}/models`, {
450
+ headers: ep.apiKey ? { authorization: `Bearer ${ep.apiKey}` } : {},
451
+ signal: AbortSignal.timeout(8000)
452
+ });
453
+ // Some OpenAI-compatible servers have no /models; reachable is what counts.
454
+ if (r.status >= 500)
455
+ throw new Error(`${r.status} from ${ep.baseUrl}/models`);
456
+ if (r.status === 401 || r.status === 403)
457
+ throw new Error(`${r.status} from ${ep.baseUrl}: check apiKey`);
458
+ return `${ep.baseUrl} answered ${r.status}`;
459
+ });
460
+ }
461
+ await check('retrieval', async () => {
462
+ const r = await resolveRetrieval(cfg.retrieval, defaultModelsDir(source.path()));
463
+ if (r.fallback)
464
+ return `lexical (SQLite FTS5) -- auto, because ${r.fallback}`;
465
+ if (!r.embedder)
466
+ return 'lexical (SQLite FTS5)';
467
+ const e = r.embedder;
468
+ e.close?.();
469
+ return `${r.mode} with ${e.spec.repoId}, ${e.dim} dims${cfg.retrieval.modelSha256 ? ', checksum pinned' : ', checksum NOT pinned (set retrieval.modelSha256)'}`;
470
+ });
471
+ const { McpHub } = await import('./mcp.js');
472
+ for (const [name, spec] of Object.entries(servers)) {
473
+ if (spec?.disabled) {
474
+ checks.push({ check: `mcp ${name}`, ok: true, detail: 'disabled' });
475
+ continue;
476
+ }
477
+ await check(`mcp ${name}`, async () => {
478
+ const hub = await new McpHub({ [name]: spec }).connect();
479
+ try {
480
+ if (hub.errors.length)
481
+ throw new Error(hub.errors.join('; '));
482
+ return `${hub.tools.length} tool(s)`;
483
+ }
484
+ finally {
485
+ await hub.close();
486
+ }
487
+ });
488
+ }
489
+ }
490
+ for (const c of checks)
491
+ out(JSON.stringify(c));
492
+ return checks.every((c) => c.ok) ? 0 : 1;
391
493
  }
392
- for (const c of checks) out(JSON.stringify(c));
393
- return checks.every((c) => c.ok) ? 0 : 1;
394
- }
395
494
  };
396
-
397
495
  /** A JSON Schema for config.json (and the `config` block of tinywebui.config.js). */
398
496
  export function configSchema() {
399
- const typeOf = (v) => (v === null ? ['number', 'null'] : Array.isArray(v) ? 'array' : typeof v);
400
- const properties = {};
401
- for (const [k, v] of Object.entries(DEFAULTS)) {
402
- properties[k] = {
403
- type: typeOf(v),
404
- 'x-change': FILE_ONLY.has(k) ? 'file-only' : WRITABLE.has(k) ? 'editable (UI writes it back here)' : 'file-only'
405
- };
406
- }
407
- properties.authMode = { enum: ['none', 'single', 'trusted-header'], 'x-change': 'file-only' };
408
- properties.toolApproval = { enum: ['writes', 'all', 'off'], 'x-change': 'editable (UI writes it back here)' };
409
- properties.trustedProxyCidrs = { type: 'array', items: { type: 'string' }, 'x-change': 'file-only' };
410
- const featureList = { oneOf: [{ const: '*' }, { type: 'array', items: { enum: FEATURES }, uniqueItems: true }] };
411
- const role = {
412
- type: 'object', additionalProperties: false,
413
- properties: { features: featureList, models: { oneOf: [{ const: '*' }, { type: 'array', items: { type: 'string' } }] } }
414
- };
415
- properties.retrieval = {
416
- type: 'object', additionalProperties: false, 'x-change': 'file-only (restart to apply)',
417
- properties: {
418
- mode: { enum: ['auto', 'lexical', 'dense', 'hybrid'], description: 'auto: hybrid when the model is installed, else lexical' },
419
- model: { type: 'string', description: 'fast | balanced | quality | multilingual, or a name with modelDir' },
420
- modelDir: { type: 'string' },
421
- modelSha256: { type: 'string', pattern: '^([0-9a-fA-F]{64})?$' },
422
- denseWeight: { type: 'number', exclusiveMinimum: 0, exclusiveMaximum: 1 },
423
- rrfK: { type: 'integer', minimum: 0 },
424
- queryPrefix: { type: 'string' },
425
- documentPrefix: { type: 'string' }
426
- }
427
- };
428
- const num = { type: ['number', 'null'] };
429
- properties.connectors = {
430
- type: 'array', 'x-change': 'editable (UI writes it back here)',
431
- description: 'Named endpoints a catalog entry can use. apiKey is a secret: prefer an env var in committed config.',
432
- items: {
433
- type: 'object', additionalProperties: false, required: ['id', 'baseUrl'],
434
- properties: { id: { type: 'string' }, label: { type: 'string' }, baseUrl: { type: 'string' }, apiKey: { type: 'string' } }
435
- }
436
- };
437
- properties.models = {
438
- type: 'array', 'x-change': 'editable (UI writes it back here)',
439
- description: 'The model catalog. Empty: any model id. Listed: only these can be picked, by id, shown by label.',
440
- items: {
441
- type: 'object', additionalProperties: false, required: ['id'],
442
- properties: {
443
- id: { type: 'string', description: 'Stable handle: what model, roles and prefs name.' },
444
- model: { type: 'string', description: "Provider model id; defaults to id." },
445
- label: { type: 'string', description: 'What people see in the picker.' },
446
- description: { type: 'string' },
447
- enabled: { type: 'boolean', description: 'false: keep the settings, hide and refuse the model.' },
448
- connector: { type: 'string', description: 'Id of an entry in connectors; absent: the default baseUrl/apiKey.' },
449
- tags: { type: 'array', items: { enum: MODEL_TAGS }, uniqueItems: true, description: 'What the model can do.' },
450
- systemPrompt: { type: 'string' },
451
- temperature: num,
452
- maxTokens: num,
453
- maxToolRounds: { type: 'integer', minimum: 0 },
454
- cacheMode: { enum: ['auto', 'implicit', 'explicit', 'rolling', 'off'] },
455
- cacheTtl: { enum: ['5m', '1h'] },
456
- reasoningReplay: { enum: REASONING_REPLAY },
457
- extraBody: { type: 'object', description: 'Merged over the global extraBody.' }
458
- }
497
+ const typeOf = (v) => (v === null ? ['number', 'null'] : Array.isArray(v) ? 'array' : typeof v);
498
+ const properties = {};
499
+ for (const [k, v] of Object.entries(DEFAULTS)) {
500
+ properties[k] = {
501
+ type: typeOf(v),
502
+ 'x-change': FILE_ONLY.has(k) ? 'file-only' : WRITABLE.has(k) ? 'editable (UI writes it back here)' : 'file-only'
503
+ };
459
504
  }
460
- };
461
- properties.access = {
462
- type: 'object', additionalProperties: false, 'x-change': 'file-only (admins write access.users back)',
463
- properties: {
464
- bootstrapAdmins: { type: 'array', items: { type: 'string' }, description: 'Gateway user ids that are always approved admins.' },
465
- newUsers: { enum: ['approved', 'pending'] },
466
- customize: { type: 'array', items: { enum: CUSTOMIZABLE }, uniqueItems: true, description: 'What users may personalise.' },
467
- roles: { type: 'object', additionalProperties: false, properties: Object.fromEntries(ROLES.map((r) => [r, role])) },
468
- users: {
469
- type: 'object',
470
- additionalProperties: {
471
- type: 'object', additionalProperties: false,
472
- properties: { role: { enum: ROLES }, status: { enum: STATUSES } }
505
+ properties.authMode = { enum: ['local', 'multiuser', 'trusted-header'], 'x-change': 'file-only' };
506
+ properties.toolApproval = { enum: ['writes', 'all', 'off'], 'x-change': 'editable (UI writes it back here)' };
507
+ properties.trustedProxyCidrs = { type: 'array', items: { type: 'string' }, 'x-change': 'file-only' };
508
+ const featureList = { oneOf: [{ const: '*' }, { type: 'array', items: { enum: FEATURES }, uniqueItems: true }] };
509
+ const role = {
510
+ type: 'object', additionalProperties: false,
511
+ properties: { features: featureList, models: { oneOf: [{ const: '*' }, { type: 'array', items: { type: 'string' } }] } }
512
+ };
513
+ properties.retrieval = {
514
+ type: 'object', additionalProperties: false, 'x-change': 'file-only (restart to apply)',
515
+ properties: {
516
+ mode: { enum: ['auto', 'lexical', 'dense', 'hybrid'], description: 'auto: hybrid when the model is installed, else lexical' },
517
+ model: { type: 'string', description: 'fast | balanced | quality | multilingual, or a name with modelDir' },
518
+ modelDir: { type: 'string' },
519
+ modelSha256: { type: 'string', pattern: '^([0-9a-fA-F]{64})?$' },
520
+ denseWeight: { type: 'number', exclusiveMinimum: 0, exclusiveMaximum: 1 },
521
+ rrfK: { type: 'integer', minimum: 0 },
522
+ queryPrefix: { type: 'string' },
523
+ documentPrefix: { type: 'string' }
473
524
  }
474
- }
475
- }
476
- };
477
- return {
478
- $schema: 'https://json-schema.org/draft/2020-12/schema',
479
- title: 'TinyWebUI configuration',
480
- type: 'object',
481
- properties
482
- };
525
+ };
526
+ const num = { type: ['number', 'null'] };
527
+ const modelEntry = (nested) => ({
528
+ type: 'object', additionalProperties: false, required: ['id'],
529
+ properties: {
530
+ id: { type: 'string', description: 'Stable handle: what model, roles and prefs name.' },
531
+ model: { type: 'string', description: "Provider model id; defaults to id." },
532
+ label: { type: 'string', description: 'What people see in the picker.' },
533
+ description: { type: 'string' },
534
+ enabled: { type: 'boolean', description: 'false: keep the settings, hide and refuse the model.' },
535
+ ...(nested ? {} : { connector: { type: 'string', description: 'Older flat form: id of an entry in connectors. Prefer listing the model under that connector.' } }),
536
+ tags: { type: 'array', items: { enum: MODEL_TAGS }, uniqueItems: true, description: 'What the model can do.' },
537
+ systemPrompt: { type: 'string', description: 'Replaces the top-level systemPrompt for this model.' },
538
+ temperature: num,
539
+ maxTokens: num,
540
+ maxToolRounds: { type: 'integer', minimum: 0 },
541
+ cacheMode: { enum: ['auto', 'implicit', 'explicit', 'rolling', 'off'] },
542
+ cacheTtl: { enum: ['5m', '1h'] },
543
+ reasoningReplay: { enum: REASONING_REPLAY },
544
+ extraBody: { type: 'object', description: 'Merged over the global extraBody.' }
545
+ }
546
+ });
547
+ properties.connectors = {
548
+ type: 'array', 'x-change': 'editable (UI writes it back here)',
549
+ description: 'Named endpoints, each with the models it serves. apiKey is a secret: prefer an env var in committed config.',
550
+ items: {
551
+ type: 'object', additionalProperties: false, required: ['id', 'baseUrl'],
552
+ properties: {
553
+ id: { type: 'string' }, label: { type: 'string' }, baseUrl: { type: 'string' }, apiKey: { type: 'string' },
554
+ models: { type: 'array', items: modelEntry(true), description: 'Catalog entries served by this connector.' }
555
+ }
556
+ }
557
+ };
558
+ properties.models = {
559
+ type: 'array', 'x-change': 'editable (UI writes it back here)',
560
+ description: 'The model catalog on the default endpoint (models of a connector go under it). Empty everywhere: any model id. Listed: only these can be picked, by id, shown by label. Top-level settings are the defaults each entry may override.',
561
+ items: modelEntry(false)
562
+ };
563
+ properties.access = {
564
+ type: 'object', additionalProperties: false, 'x-change': 'file-only (admins write access.users back)',
565
+ properties: {
566
+ bootstrapAdmins: { type: 'array', items: { type: 'string' }, description: 'Gateway user ids that are always approved admins.' },
567
+ newUsers: { enum: ['approved', 'pending'] },
568
+ customize: { type: 'array', items: { enum: CUSTOMIZABLE }, uniqueItems: true, description: 'What users may personalise.' },
569
+ roles: { type: 'object', additionalProperties: false, properties: Object.fromEntries(ROLES.map((r) => [r, role])) },
570
+ users: {
571
+ type: 'object',
572
+ additionalProperties: {
573
+ type: 'object', additionalProperties: false,
574
+ properties: { role: { enum: ROLES }, status: { enum: STATUSES } }
575
+ }
576
+ }
577
+ }
578
+ };
579
+ return {
580
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
581
+ title: 'TinyWebUI configuration',
582
+ type: 'object',
583
+ properties
584
+ };
483
585
  }
484
-
485
586
  export async function runCli(command, args, opts) {
486
- const fn = commands[command];
487
- if (!fn) return null;
488
- return fn(args, opts);
587
+ const fn = commands[command];
588
+ if (!fn)
589
+ return null;
590
+ return fn(args, opts);
489
591
  }