tinywebui 0.1.4 → 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.
- package/README.md +20 -13
- package/bin/tinywebui.js +69 -53
- package/package.json +25 -8
- package/public/app.js +40 -34
- package/public/chat/composer.js +1 -1
- package/public/chat/sidebar.js +8 -5
- package/public/index.html +88 -53
- package/public/panels/admin.js +40 -1
- package/public/panels/model-editor.js +336 -0
- package/public/panels/panels.js +37 -12
- package/public/panels/settings-home.js +125 -0
- package/public/panels/settings.js +124 -36
- package/public/shared/attachment_note.js +7 -1
- package/public/shared/events.js +1 -0
- package/public/shared/features.js +1 -0
- package/public/shared/origins.js +1 -0
- package/public/shared/usage.js +16 -1
- package/public/styles.css +84 -21
- package/src/access/auth.js +155 -155
- package/src/access/auth_gate.js +39 -38
- package/src/access/email.js +17 -0
- package/src/access/policy.js +123 -136
- package/src/audit.js +1 -1
- package/src/automations/automation.js +135 -123
- package/src/automations/scheduler.js +138 -127
- package/src/chat/compact.js +149 -156
- package/src/chat/llm.js +380 -382
- package/src/chat/runs.js +162 -151
- package/src/chat/tool_executor.js +229 -218
- package/src/cli.js +535 -433
- package/src/config/approval.js +35 -29
- package/src/config/config.js +546 -454
- package/src/config/data_dir.js +24 -0
- package/src/config/models.js +191 -115
- package/src/config/refs.js +32 -0
- package/src/config/secrets.js +153 -0
- package/src/files/documents.js +57 -68
- package/src/files/images.js +30 -31
- package/src/harness/agent.js +60 -60
- package/src/harness/attribution.js +66 -58
- package/src/harness/checkpoint.js +67 -62
- package/src/harness/history.js +28 -23
- package/src/harness/prompt.js +40 -57
- package/src/harness/provider.js +284 -283
- package/src/harness/types.js +1 -0
- package/src/http.js +90 -72
- package/src/log.js +11 -12
- package/src/mcp.js +398 -374
- package/src/retrieval/embedding.js +228 -240
- package/src/retrieval/retrieval.js +182 -173
- package/src/routes/admin.js +178 -138
- package/src/routes/auth.js +184 -111
- package/src/routes/automations.js +99 -82
- package/src/routes/chats.js +240 -218
- package/src/routes/documents.js +130 -120
- package/src/routes/library.js +43 -46
- package/src/routes/mcp.js +49 -49
- package/src/routes/settings.js +127 -122
- package/src/routes/types.js +1 -0
- package/src/server.js +426 -415
- package/src/store/automations.js +106 -106
- package/src/store/chats.js +217 -242
- package/src/store/documents.js +181 -185
- package/src/store/embeddings.js +52 -54
- package/src/store/index.js +95 -49
- package/src/store/messages.js +55 -80
- package/src/store/schema.js +128 -107
- package/src/store/scope.js +18 -17
- package/src/store/search.js +25 -27
- package/src/store/turns.js +53 -53
- package/src/store/usage.js +343 -269
- package/src/store/users.js +162 -87
- package/src/store/wire.js +50 -41
- package/src/tools/ask_tool.js +68 -68
- package/src/tools/chat_search_tool.js +38 -44
- package/src/tools/context_tool.js +131 -138
- package/src/tools/document_tool.js +73 -78
- package/src/tools/mcp_tool.js +274 -232
- package/src/tools/task_tool.js +53 -50
- package/src/tools/types.js +1 -0
- package/public/panels/connectors.js +0 -184
- 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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
130
|
+
},
|
|
131
|
+
fingerprint(args, opts) {
|
|
132
|
+
const { cfg, servers } = loadAll(opts);
|
|
133
|
+
out(fingerprint(cfg, servers));
|
|
271
134
|
return 0;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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:')
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
462
|
-
type: '
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
type: 'object',
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
587
|
+
const fn = commands[command];
|
|
588
|
+
if (!fn)
|
|
589
|
+
return null;
|
|
590
|
+
return fn(args, opts);
|
|
489
591
|
}
|