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
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Where an installed TinyWebUI (npx, or npm install -g) keeps its files when
|
|
3
|
+
* nothing says otherwise: one folder per user, the platform's usual place for
|
|
4
|
+
* application data, rather than whichever folder the command ran in. Config,
|
|
5
|
+
* keys, database and embedding models all sit there, beside the config, so
|
|
6
|
+
* one person has one of each and knows where to back it up or delete it.
|
|
7
|
+
* A clone and the Docker image keep using the working directory and their
|
|
8
|
+
* explicit paths.
|
|
9
|
+
*/
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { isAbsolute, join, sep } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
/** The per-user data folder on `platform`. */
|
|
14
|
+
export function userDataDir(env = process.env, platform = process.platform, home = homedir()) {
|
|
15
|
+
if (platform === 'darwin')
|
|
16
|
+
return join(home, 'Library', 'Application Support', 'TinyWebUI');
|
|
17
|
+
if (platform === 'win32')
|
|
18
|
+
return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'TinyWebUI');
|
|
19
|
+
// XDG: a relative value is invalid and must be ignored.
|
|
20
|
+
const xdg = env.XDG_DATA_HOME && isAbsolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : join(home, '.local', 'share');
|
|
21
|
+
return join(xdg, 'tinywebui');
|
|
22
|
+
}
|
|
23
|
+
/** Whether `moduleUrl` runs from an installed package rather than a checkout. */
|
|
24
|
+
export const isInstalled = (moduleUrl) => fileURLToPath(moduleUrl).split(sep).includes('node_modules');
|
package/src/config/models.js
CHANGED
|
@@ -1,153 +1,229 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The model catalog: which models people may pick, what they are called, and
|
|
3
|
-
* how each one is prompted and sampled. Declared in the config file:
|
|
4
|
-
*
|
|
5
|
-
* models: [
|
|
6
|
-
* { id: 'fast', label: 'Quick', model: 'deepseek/deepseek-v4.1-flash',
|
|
7
|
-
* description: 'Everyday questions', temperature: 0.3 },
|
|
8
|
-
* { id: 'deep', label: 'Thorough', model: 'anthropic/claude-sonnet-5',
|
|
9
|
-
* systemPrompt: '...', maxToolRounds: 20 }
|
|
10
|
-
* ]
|
|
11
|
-
*
|
|
12
|
-
* An empty catalog (the default) changes nothing: any model id goes. A
|
|
13
|
-
* non-empty one is closed: only its entries can be picked, the configured
|
|
14
|
-
* `model` and every role's `models` list name entries by `id`, and people see
|
|
15
|
-
* `label`, never the provider's id. `enabled: false` parks an entry: it keeps
|
|
16
|
-
* its settings but cannot be picked or run until switched back on. Pure, like
|
|
17
|
-
* policy.js.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
1
|
/** Per-model settings that replace the global value for that model's turns. */
|
|
21
2
|
export const MODEL_OVERRIDES = ['systemPrompt', 'temperature', 'maxTokens', 'maxToolRounds', 'cacheMode', 'cacheTtl', 'extraBody', 'reasoningReplay'];
|
|
22
|
-
|
|
23
3
|
/** Values of `reasoningReplay`; see reasoningReplay in harness/provider.js. */
|
|
24
4
|
export const REASONING_REPLAY = ['auto', 'omit', 'reasoning_content', 'reasoning_details'];
|
|
25
|
-
|
|
26
5
|
/** What a model can do; the page uses these (e.g. `vision` gates image upload). */
|
|
27
6
|
export const MODEL_TAGS = ['vision', 'reasoning', 'tools'];
|
|
28
|
-
|
|
29
7
|
const ENTRY_KEYS = new Set(['id', 'model', 'label', 'description', 'enabled', 'connector', 'tags', ...MODEL_OVERRIDES]);
|
|
30
|
-
|
|
31
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The file keeps each model with the endpoint that serves it: top-level
|
|
10
|
+
* `models` run on the default baseUrl/apiKey, `connectors[i].models` on that
|
|
11
|
+
* connector, and every entry carries its own prompt, sampling and caching.
|
|
12
|
+
* At runtime the catalog is one flat list whose entries name their connector,
|
|
13
|
+
* so nothing past loading knows about the nesting. The older flat file shape
|
|
14
|
+
* (entries with `connector: <id>`) loads unchanged.
|
|
15
|
+
*/
|
|
16
|
+
// Raw input: the file's contents, before validation.
|
|
17
|
+
export function flattenCatalog(cfg) {
|
|
18
|
+
if (!Array.isArray(cfg.connectors) || !Array.isArray(cfg.models ?? []))
|
|
19
|
+
return {};
|
|
20
|
+
const nested = [];
|
|
21
|
+
const connectors = cfg.connectors.map((c) => {
|
|
22
|
+
if (!c || typeof c !== 'object' || !Array.isArray(c.models))
|
|
23
|
+
return c;
|
|
24
|
+
const { models, ...rest } = c;
|
|
25
|
+
for (const m of models)
|
|
26
|
+
nested.push(m && typeof m === 'object' && !Array.isArray(m) ? { ...m, connector: c.id } : m);
|
|
27
|
+
return rest;
|
|
28
|
+
});
|
|
29
|
+
return { connectors, models: [...(cfg.models ?? []), ...nested] };
|
|
30
|
+
}
|
|
31
|
+
/** The flat runtime catalog back in the file's nested shape. */
|
|
32
|
+
export function nestCatalog(cfg) {
|
|
33
|
+
const out = {};
|
|
34
|
+
const connectors = Array.isArray(cfg.connectors) ? cfg.connectors : [];
|
|
35
|
+
const models = Array.isArray(cfg.models) ? cfg.models : [];
|
|
36
|
+
const ids = new Set(connectors.map((c) => c?.id));
|
|
37
|
+
// An entry naming a connector that is not listed stays flat, so validation
|
|
38
|
+
// still reports it instead of the entry silently moving endpoints.
|
|
39
|
+
if (cfg.models !== undefined)
|
|
40
|
+
out.models = models.filter((m) => !m?.connector || !ids.has(m.connector));
|
|
41
|
+
if (cfg.connectors !== undefined) {
|
|
42
|
+
out.connectors = connectors.map((c) => {
|
|
43
|
+
const mine = models.filter((m) => m?.connector && m.connector === c?.id).map(({ connector, ...m }) => m);
|
|
44
|
+
return mine.length ? { ...c, models: mine } : c;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Whether turns on `key` have somewhere to go: an entry's connector, else the
|
|
51
|
+
* top-level endpoint if it has a URL. A key is optional either way: a local
|
|
52
|
+
* server may need none.
|
|
53
|
+
*/
|
|
54
|
+
export function hasEndpoint(cfg, key) {
|
|
55
|
+
const e = findEntry(cfg, key ?? cfg.model);
|
|
56
|
+
return e?.connector && findConnector(cfg, e.connector) ? true : Boolean(cfg.baseUrl);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Named endpoints: { id, label?, baseUrl, apiKey? }. The top-level
|
|
60
|
+
* baseUrl/apiKey are the endpoint of a model that names none, for a
|
|
61
|
+
* deployment set up in code or the environment; unset by default.
|
|
62
|
+
*/
|
|
32
63
|
export const connectors = (cfg) => (Array.isArray(cfg.connectors) ? cfg.connectors : []);
|
|
33
64
|
export const findConnector = (cfg, id) => connectors(cfg).find((c) => c.id === id) || null;
|
|
34
|
-
|
|
35
65
|
export const catalog = (cfg) => (Array.isArray(cfg.models) ? cfg.models : []);
|
|
36
66
|
// Closed whenever entries are listed, even if all are disabled: parking every
|
|
37
67
|
// model must not reopen the picker to the provider's whole list.
|
|
38
68
|
export const isClosed = (cfg) => catalog(cfg).length > 0;
|
|
39
69
|
export const enabledEntries = (cfg) => catalog(cfg).filter((e) => e.enabled !== false);
|
|
40
|
-
|
|
70
|
+
/** The default model: `model` as set, or unset with a catalog, its first enabled entry. */
|
|
71
|
+
export const defaultModel = (cfg) => cfg.model || enabledEntries(cfg)[0]?.id || '';
|
|
41
72
|
/** The enabled entry `key` names: by id, or by its provider model id (the default `model` may be either). */
|
|
42
73
|
export function findEntry(cfg, key) {
|
|
43
|
-
|
|
44
|
-
|
|
74
|
+
const list = enabledEntries(cfg);
|
|
75
|
+
return list.find((e) => e.id === key) || list.find((e) => (e.model || e.id) === key) || null;
|
|
45
76
|
}
|
|
46
|
-
|
|
47
77
|
/** What people call a model: its label in the catalog, else the id itself. */
|
|
48
78
|
export function labelFor(cfg, key) {
|
|
49
|
-
|
|
50
|
-
|
|
79
|
+
const e = findEntry(cfg, key);
|
|
80
|
+
return e ? e.label || e.id : key;
|
|
51
81
|
}
|
|
52
|
-
|
|
53
82
|
/** What the picker lists for one entry. Prompts and knobs stay server-side. */
|
|
54
83
|
export const publicEntry = (e) => ({ id: e.id, name: e.label || e.id, description: e.description || null, tags: e.tags || [] });
|
|
55
|
-
|
|
56
84
|
/**
|
|
57
85
|
* The config one turn runs with: the entry's overrides over the global
|
|
58
86
|
* settings and its provider id as `model`. extraBody merges, so an entry can
|
|
59
87
|
* add a reasoning knob without restating the gateway's routing.
|
|
60
88
|
*/
|
|
61
89
|
export function effectiveConfig(cfg, key) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
90
|
+
const e = findEntry(cfg, key ?? cfg.model);
|
|
91
|
+
if (!e)
|
|
92
|
+
return key && key !== cfg.model ? { ...cfg, model: key } : cfg;
|
|
93
|
+
const out = { ...cfg };
|
|
94
|
+
for (const k of MODEL_OVERRIDES)
|
|
95
|
+
if (e[k] !== undefined)
|
|
96
|
+
out[k] = e[k];
|
|
97
|
+
out.extraBody = { ...(cfg.extraBody || {}), ...(e.extraBody || {}) };
|
|
98
|
+
// `provider` merges one level deeper: a global data policy (zdr,
|
|
99
|
+
// data_collection) must survive an entry that only pins its routing.
|
|
100
|
+
if (cfg.extraBody?.provider || e.extraBody?.provider) {
|
|
101
|
+
out.extraBody.provider = { ...(cfg.extraBody?.provider || {}), ...(e.extraBody?.provider || {}) };
|
|
102
|
+
}
|
|
103
|
+
out.model = e.model || e.id;
|
|
104
|
+
// The entry's endpoint replaces the global one; provider.js only ever reads
|
|
105
|
+
// cfg.baseUrl and cfg.apiKey, so nothing downstream knows about connectors.
|
|
106
|
+
const conn = e.connector ? findConnector(cfg, e.connector) : null;
|
|
107
|
+
if (conn) {
|
|
108
|
+
out.baseUrl = String(conn.baseUrl).replace(/\/+$/, '');
|
|
109
|
+
out.apiKey = conn.apiKey || '';
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
81
112
|
}
|
|
82
|
-
|
|
83
113
|
/** Every problem with the connector list. */
|
|
84
114
|
export function connectorProblems(cfg) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
115
|
+
const list = cfg.connectors;
|
|
116
|
+
if (list === undefined)
|
|
117
|
+
return [];
|
|
118
|
+
if (!Array.isArray(list))
|
|
119
|
+
return ['connectors must be a list of { id, baseUrl, apiKey?, label?, models? } entries'];
|
|
120
|
+
const out = [];
|
|
121
|
+
const ids = new Set();
|
|
122
|
+
list.forEach((c, i) => {
|
|
123
|
+
const at = `connectors[${i}]`;
|
|
124
|
+
if (!c || typeof c !== 'object' || Array.isArray(c)) {
|
|
125
|
+
out.push(`${at} must be an object`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (typeof c.id !== 'string' || !c.id.trim()) {
|
|
129
|
+
out.push(`${at}.id must be a non-empty string`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (ids.has(c.id))
|
|
133
|
+
out.push(`${at}.id "${c.id}" is listed twice`);
|
|
134
|
+
ids.add(c.id);
|
|
135
|
+
// A nested `models` that is a list was already lifted out by flattenCatalog.
|
|
136
|
+
if (c.models !== undefined)
|
|
137
|
+
out.push(`${at}.models must be a list of model entries`);
|
|
138
|
+
for (const k of Object.keys(c))
|
|
139
|
+
if (!['id', 'label', 'baseUrl', 'apiKey', 'models'].includes(k))
|
|
140
|
+
out.push(`${at}.${k} is not a known setting (known: id, label, baseUrl, apiKey, models)`);
|
|
141
|
+
for (const k of ['label', 'apiKey'])
|
|
142
|
+
if (c[k] !== undefined && typeof c[k] !== 'string')
|
|
143
|
+
out.push(`${at}.${k} must be a string`);
|
|
144
|
+
try {
|
|
145
|
+
if (!/^https?:$/.test(new URL(c.baseUrl).protocol))
|
|
146
|
+
throw new Error('scheme');
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
out.push(`${at}.baseUrl must be an http(s) URL`);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
for (const [i, e] of (Array.isArray(cfg.models) ? cfg.models : []).entries()) {
|
|
153
|
+
if (e?.connector !== undefined && !ids.has(e.connector))
|
|
154
|
+
out.push(`models[${i}].connector "${e.connector}" is not in connectors`);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
106
157
|
}
|
|
107
|
-
|
|
108
158
|
const NUMERIC = { temperature: 'a number or null', maxTokens: 'a number or null' };
|
|
109
|
-
|
|
110
159
|
/** Every problem with the catalog and what refers into it. */
|
|
111
160
|
export function modelProblems(cfg) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
161
|
+
const list = cfg.models;
|
|
162
|
+
if (list === undefined)
|
|
163
|
+
return [];
|
|
164
|
+
if (!Array.isArray(list))
|
|
165
|
+
return ['models must be a list of { id, model?, label?, ... } entries'];
|
|
166
|
+
const out = [];
|
|
167
|
+
const ids = new Set();
|
|
168
|
+
list.forEach((e, i) => {
|
|
169
|
+
const at = `models[${i}]`;
|
|
170
|
+
if (!e || typeof e !== 'object' || Array.isArray(e)) {
|
|
171
|
+
out.push(`${at} must be an object`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (typeof e.id !== 'string' || !e.id.trim()) {
|
|
175
|
+
out.push(`${at}.id must be a non-empty string`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (ids.has(e.id))
|
|
179
|
+
out.push(`${at}.id "${e.id}" is listed twice`);
|
|
180
|
+
ids.add(e.id);
|
|
181
|
+
for (const k of Object.keys(e))
|
|
182
|
+
if (!ENTRY_KEYS.has(k))
|
|
183
|
+
out.push(`${at}.${k} is not a known setting (known: ${[...ENTRY_KEYS].join(', ')})`);
|
|
184
|
+
for (const k of ['model', 'label', 'description', 'systemPrompt']) {
|
|
185
|
+
if (e[k] !== undefined && typeof e[k] !== 'string')
|
|
186
|
+
out.push(`${at}.${k} must be a string`);
|
|
187
|
+
}
|
|
188
|
+
for (const k of Object.keys(NUMERIC)) {
|
|
189
|
+
if (e[k] !== undefined && e[k] !== null && !(typeof e[k] === 'number' && Number.isFinite(e[k])))
|
|
190
|
+
out.push(`${at}.${k} must be ${NUMERIC[k]}`);
|
|
191
|
+
}
|
|
192
|
+
if (e.maxToolRounds !== undefined && !(Number.isInteger(e.maxToolRounds) && e.maxToolRounds >= 0))
|
|
193
|
+
out.push(`${at}.maxToolRounds must be a whole number >= 0`);
|
|
194
|
+
if (e.cacheMode !== undefined && !['auto', 'implicit', 'explicit', 'rolling', 'off'].includes(e.cacheMode))
|
|
195
|
+
out.push(`${at}.cacheMode must be one of auto, implicit, explicit, rolling, off`);
|
|
196
|
+
if (e.reasoningReplay !== undefined && !REASONING_REPLAY.includes(e.reasoningReplay))
|
|
197
|
+
out.push(`${at}.reasoningReplay must be one of ${REASONING_REPLAY.join(', ')}`);
|
|
198
|
+
if (e.cacheTtl !== undefined && !['5m', '1h'].includes(e.cacheTtl))
|
|
199
|
+
out.push(`${at}.cacheTtl must be 5m or 1h`);
|
|
200
|
+
if (e.enabled !== undefined && typeof e.enabled !== 'boolean')
|
|
201
|
+
out.push(`${at}.enabled must be true or false`);
|
|
202
|
+
if (e.connector !== undefined && typeof e.connector !== 'string')
|
|
203
|
+
out.push(`${at}.connector must be a connector id`);
|
|
204
|
+
if (e.tags !== undefined && !(Array.isArray(e.tags) && e.tags.every((t) => MODEL_TAGS.includes(t))))
|
|
205
|
+
out.push(`${at}.tags must be a list from ${MODEL_TAGS.join(', ')}`);
|
|
206
|
+
if (e.extraBody !== undefined && !(e.extraBody && typeof e.extraBody === 'object' && !Array.isArray(e.extraBody)))
|
|
207
|
+
out.push(`${at}.extraBody must be an object`);
|
|
208
|
+
});
|
|
209
|
+
if (!list.length || out.length)
|
|
210
|
+
return out;
|
|
211
|
+
// A closed catalog: everything that names a model has to name an entry.
|
|
212
|
+
// Role lists may name disabled entries (they just drop out); the default may not.
|
|
213
|
+
// An unset `model` is the first enabled entry (see defaultModel).
|
|
214
|
+
const on = enabledEntries(cfg).map((e) => e.id);
|
|
215
|
+
if (!on.length)
|
|
216
|
+
out.push('every entry in models is disabled: enable at least one');
|
|
217
|
+
else if (cfg.model && !findEntry(cfg, cfg.model)) {
|
|
218
|
+
const off = list.some((e) => e.id === cfg.model || e.model === cfg.model);
|
|
219
|
+
out.push(`model "${cfg.model}" ${off ? 'is disabled' : 'is not in the models catalog'}; enabled ids: ${on.join(', ')}`);
|
|
126
220
|
}
|
|
127
|
-
for (const
|
|
128
|
-
|
|
221
|
+
for (const [role, spec] of Object.entries(cfg.access?.roles || {})) {
|
|
222
|
+
if (!Array.isArray(spec?.models))
|
|
223
|
+
continue;
|
|
224
|
+
for (const m of spec.models)
|
|
225
|
+
if (!ids.has(m))
|
|
226
|
+
out.push(`access.roles.${role}.models: "${m}" is not a models catalog id`);
|
|
129
227
|
}
|
|
130
|
-
|
|
131
|
-
if (e.cacheMode !== undefined && !['auto', 'implicit', 'explicit', 'rolling', 'off'].includes(e.cacheMode)) out.push(`${at}.cacheMode must be one of auto, implicit, explicit, rolling, off`);
|
|
132
|
-
if (e.reasoningReplay !== undefined && !REASONING_REPLAY.includes(e.reasoningReplay)) out.push(`${at}.reasoningReplay must be one of ${REASONING_REPLAY.join(', ')}`);
|
|
133
|
-
if (e.cacheTtl !== undefined && !['5m', '1h'].includes(e.cacheTtl)) out.push(`${at}.cacheTtl must be 5m or 1h`);
|
|
134
|
-
if (e.enabled !== undefined && typeof e.enabled !== 'boolean') out.push(`${at}.enabled must be true or false`);
|
|
135
|
-
if (e.connector !== undefined && typeof e.connector !== 'string') out.push(`${at}.connector must be a connector id`);
|
|
136
|
-
if (e.tags !== undefined && !(Array.isArray(e.tags) && e.tags.every((t) => MODEL_TAGS.includes(t)))) out.push(`${at}.tags must be a list from ${MODEL_TAGS.join(', ')}`);
|
|
137
|
-
if (e.extraBody !== undefined && !(e.extraBody && typeof e.extraBody === 'object' && !Array.isArray(e.extraBody))) out.push(`${at}.extraBody must be an object`);
|
|
138
|
-
});
|
|
139
|
-
if (!list.length || out.length) return out;
|
|
140
|
-
// A closed catalog: everything that names a model has to name an entry.
|
|
141
|
-
// Role lists may name disabled entries (they just drop out); the default may not.
|
|
142
|
-
const on = enabledEntries(cfg).map((e) => e.id);
|
|
143
|
-
if (!on.length) out.push('every entry in models is disabled: enable at least one');
|
|
144
|
-
else if (!findEntry(cfg, cfg.model)) {
|
|
145
|
-
const off = list.some((e) => e.id === cfg.model || e.model === cfg.model);
|
|
146
|
-
out.push(`model "${cfg.model}" ${off ? 'is disabled' : 'is not in the models catalog'}; enabled ids: ${on.join(', ')}`);
|
|
147
|
-
}
|
|
148
|
-
for (const [role, spec] of Object.entries(cfg.access?.roles || {})) {
|
|
149
|
-
if (!Array.isArray(spec?.models)) continue;
|
|
150
|
-
for (const m of spec.models) if (!ids.has(m)) out.push(`access.roles.${role}.models: "${m}" is not a models catalog id`);
|
|
151
|
-
}
|
|
152
|
-
return out;
|
|
228
|
+
return out;
|
|
153
229
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `${NAME}` references to the environment, so a file can say where a secret
|
|
3
|
+
* comes from without holding it: mcp.json headers and env, and the keys in
|
|
4
|
+
* tinywebui.config.json.
|
|
5
|
+
*/
|
|
6
|
+
const REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
7
|
+
/** True when a value names at least one environment variable. */
|
|
8
|
+
export const hasRef = (value) => typeof value === 'string' && /\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(value);
|
|
9
|
+
/** The names a value refers to that the environment does not set. */
|
|
10
|
+
export function missingRefs(value, env = process.env) {
|
|
11
|
+
if (typeof value !== 'string')
|
|
12
|
+
return [];
|
|
13
|
+
return [...value.matchAll(REF)].map((m) => m[1]).filter((name) => env[name] === undefined);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Fills `${NAME}` from the environment. A name that is not set is left as
|
|
17
|
+
* written, so validation can say which one; it never becomes an empty
|
|
18
|
+
* string, which reads as "unauthorised" and sends people looking in the
|
|
19
|
+
* wrong place.
|
|
20
|
+
*/
|
|
21
|
+
export function fillRefs(value, env = process.env) {
|
|
22
|
+
if (typeof value !== 'string')
|
|
23
|
+
return value;
|
|
24
|
+
return value.replace(REF, (ref, name) => env[name] ?? ref);
|
|
25
|
+
}
|
|
26
|
+
/** fillRefs, but a name that is not set is an error naming it and `where` it was written. */
|
|
27
|
+
export function expandRefs(value, env = process.env, where = 'mcp.json') {
|
|
28
|
+
const [missing] = missingRefs(value, env);
|
|
29
|
+
if (missing)
|
|
30
|
+
throw new Error(`${where} refers to \${${missing}}, which is not set in TinyWebUI's environment`);
|
|
31
|
+
return fillRefs(value, env);
|
|
32
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Secrets kept out of tinywebui.config.json. The config file says what the
|
|
3
|
+
* deployment is -- endpoints, connectors, models, policy -- and is safe to
|
|
4
|
+
* read, diff and share. A key is not part of that shape, so it lives in one
|
|
5
|
+
* of two places instead:
|
|
6
|
+
*
|
|
7
|
+
* "${NAME}" in the config filled in from the environment (see refs.ts)
|
|
8
|
+
* tinywebui.secrets.json beside the config, owner-only: what the settings
|
|
9
|
+
* page saves, and the generated session secret
|
|
10
|
+
*
|
|
11
|
+
* A literal key still found in the config file is honoured, and moved into
|
|
12
|
+
* the secrets file the next time the server starts. Pure functions only; the
|
|
13
|
+
* files are read and written by createConfigSource().
|
|
14
|
+
*/
|
|
15
|
+
import { SECRET_KEYS } from '../access/policy.js';
|
|
16
|
+
import { fillRefs, hasRef, missingRefs } from './refs.js';
|
|
17
|
+
const TOP = [...SECRET_KEYS];
|
|
18
|
+
/** A value that is a secret itself, rather than empty or a pointer to one. */
|
|
19
|
+
const literal = (v) => typeof v === 'string' && v !== '' && !hasRef(v);
|
|
20
|
+
/** The literal secrets a file-shaped config holds, and the config without them. */
|
|
21
|
+
export function extractSecrets(file) {
|
|
22
|
+
const shape = { ...file };
|
|
23
|
+
const found = {};
|
|
24
|
+
const names = [];
|
|
25
|
+
for (const k of TOP) {
|
|
26
|
+
if (!literal(shape[k]))
|
|
27
|
+
continue;
|
|
28
|
+
found[k] = shape[k];
|
|
29
|
+
names.push(k);
|
|
30
|
+
delete shape[k];
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(shape.connectors)) {
|
|
33
|
+
shape.connectors = shape.connectors.map((c) => {
|
|
34
|
+
if (!c || typeof c !== 'object' || !literal(c.apiKey))
|
|
35
|
+
return c;
|
|
36
|
+
(found.connectors ??= {})[c.id] = { apiKey: c.apiKey };
|
|
37
|
+
names.push(`connectors.${c.id}.apiKey`);
|
|
38
|
+
const { apiKey, ...rest } = c;
|
|
39
|
+
return rest;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return { shape, found, names };
|
|
43
|
+
}
|
|
44
|
+
/** Folds newly found secrets into the stored ones; the found ones win. */
|
|
45
|
+
export function mergeSecrets(stored, found) {
|
|
46
|
+
const out = { ...stored, ...found, connectors: { ...(stored.connectors || {}), ...(found.connectors || {}) } };
|
|
47
|
+
if (!Object.keys(out.connectors).length)
|
|
48
|
+
delete out.connectors;
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A file-shaped config with its secrets filled in: a reference from the
|
|
53
|
+
* environment, a literal as written, anything absent from the secrets file.
|
|
54
|
+
*/
|
|
55
|
+
export function applySecrets(file, secrets, env = process.env) {
|
|
56
|
+
const out = { ...file };
|
|
57
|
+
for (const k of TOP) {
|
|
58
|
+
if (hasRef(out[k]))
|
|
59
|
+
out[k] = fillRefs(out[k], env);
|
|
60
|
+
else if (!literal(out[k]) && secrets[k])
|
|
61
|
+
out[k] = secrets[k];
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(out.connectors)) {
|
|
64
|
+
out.connectors = out.connectors.map((c) => {
|
|
65
|
+
if (!c || typeof c !== 'object')
|
|
66
|
+
return c;
|
|
67
|
+
if (hasRef(c.apiKey))
|
|
68
|
+
return { ...c, apiKey: fillRefs(c.apiKey, env) };
|
|
69
|
+
const stored = secrets.connectors?.[c.id]?.apiKey;
|
|
70
|
+
return !literal(c.apiKey) && stored ? { ...c, apiKey: stored } : c;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/** References in the config file the environment does not satisfy, as problems. */
|
|
76
|
+
export function refProblems(file, env = process.env) {
|
|
77
|
+
const out = [];
|
|
78
|
+
const check = (where, v) => {
|
|
79
|
+
for (const name of missingRefs(v, env))
|
|
80
|
+
out.push(`${where} refers to \${${name}}, which is not set in TinyWebUI's environment`);
|
|
81
|
+
};
|
|
82
|
+
for (const k of TOP)
|
|
83
|
+
check(k, file[k]);
|
|
84
|
+
for (const c of Array.isArray(file.connectors) ? file.connectors : []) {
|
|
85
|
+
if (c && typeof c === 'object')
|
|
86
|
+
check(`connectors.${c.id}.apiKey`, c.apiKey);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Splits a settings save. Keys typed into the page go to the secrets file and
|
|
92
|
+
* leave the patch (an `apiKey: undefined` left in it drops any literal from
|
|
93
|
+
* the config file); a "${NAME}" typed there is shape and stays in it. Sent
|
|
94
|
+
* empty or left out, a key is unchanged; null clears it. A key the config
|
|
95
|
+
* file takes from the environment cannot be overwritten from the page:
|
|
96
|
+
* `refused` names those.
|
|
97
|
+
*
|
|
98
|
+
* `current` is the config file as it stands (catalog flattened).
|
|
99
|
+
*/
|
|
100
|
+
export function takeSecrets(patch, current, stored) {
|
|
101
|
+
const out = { ...patch };
|
|
102
|
+
const secrets = { ...stored, connectors: { ...(stored.connectors || {}) } };
|
|
103
|
+
const refused = [];
|
|
104
|
+
if ('apiKey' in out) {
|
|
105
|
+
const v = out.apiKey;
|
|
106
|
+
delete out.apiKey;
|
|
107
|
+
if (v === undefined || v === '') { /* unchanged */ }
|
|
108
|
+
else if (hasRef(current.apiKey) && !hasRef(v))
|
|
109
|
+
refused.push(`apiKey (comes from ${current.apiKey})`);
|
|
110
|
+
else if (v === null) {
|
|
111
|
+
delete secrets.apiKey;
|
|
112
|
+
out.apiKey = undefined;
|
|
113
|
+
}
|
|
114
|
+
else if (hasRef(v)) {
|
|
115
|
+
delete secrets.apiKey;
|
|
116
|
+
out.apiKey = v;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
secrets.apiKey = String(v);
|
|
120
|
+
out.apiKey = undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (Array.isArray(out.connectors)) {
|
|
124
|
+
const old = new Map((Array.isArray(current.connectors) ? current.connectors : []).map((c) => [c?.id, c]));
|
|
125
|
+
const kept = {};
|
|
126
|
+
out.connectors = out.connectors.map((c) => {
|
|
127
|
+
if (!c || typeof c !== 'object')
|
|
128
|
+
return c;
|
|
129
|
+
const { apiKey, ...rest } = c;
|
|
130
|
+
const prev = old.get(c.id);
|
|
131
|
+
if (hasRef(prev?.apiKey) && !hasRef(apiKey)) {
|
|
132
|
+
if (apiKey !== undefined && apiKey !== '' && apiKey !== null)
|
|
133
|
+
refused.push(`connectors.${c.id}.apiKey (comes from ${prev.apiKey})`);
|
|
134
|
+
return { ...rest, apiKey: prev.apiKey };
|
|
135
|
+
}
|
|
136
|
+
if (hasRef(apiKey))
|
|
137
|
+
return { ...rest, apiKey };
|
|
138
|
+
if (apiKey === null)
|
|
139
|
+
return rest;
|
|
140
|
+
const key = apiKey === undefined || apiKey === ''
|
|
141
|
+
? secrets.connectors[c.id]?.apiKey ?? (literal(prev?.apiKey) ? prev.apiKey : undefined)
|
|
142
|
+
: String(apiKey);
|
|
143
|
+
if (key)
|
|
144
|
+
kept[c.id] = { apiKey: key };
|
|
145
|
+
return rest;
|
|
146
|
+
});
|
|
147
|
+
// A connector that is gone takes its key with it.
|
|
148
|
+
secrets.connectors = kept;
|
|
149
|
+
}
|
|
150
|
+
if (!Object.keys(secrets.connectors).length)
|
|
151
|
+
delete secrets.connectors;
|
|
152
|
+
return { patch: out, secrets, refused };
|
|
153
|
+
}
|