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/config/config.js
CHANGED
|
@@ -2,224 +2,225 @@ import { readFileSync, existsSync, writeFileSync, chmodSync } from 'node:fs';
|
|
|
2
2
|
import { resolve, dirname } from 'node:path';
|
|
3
3
|
import { randomBytes } from 'node:crypto';
|
|
4
4
|
import { FILE_ONLY, mergeAccess, keyClass, validateConfig } from '../access/policy.js';
|
|
5
|
-
import { modelProblems, connectorProblems, labelFor, REASONING_REPLAY } from './models.js';
|
|
5
|
+
import { modelProblems, connectorProblems, labelFor, flattenCatalog, nestCatalog, hasEndpoint, defaultModel, REASONING_REPLAY } from './models.js';
|
|
6
6
|
import { logger } from '../log.js';
|
|
7
|
-
|
|
7
|
+
import { applySecrets, extractSecrets, mergeSecrets, refProblems, takeSecrets } from './secrets.js';
|
|
8
8
|
const log = logger('config');
|
|
9
|
-
|
|
10
9
|
const CONFIG_FILE = process.env.TINYWEBUI_CONFIG
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
? resolve(process.env.TINYWEBUI_CONFIG)
|
|
11
|
+
: resolve(process.cwd(), 'tinywebui.config.json');
|
|
14
12
|
// MCP wiring lives in its own Claude-Desktop-shaped file, next to the config.
|
|
15
13
|
const MCP_FILE = process.env.TINYWEBUI_MCP
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
? resolve(process.env.TINYWEBUI_MCP)
|
|
15
|
+
: resolve(dirname(CONFIG_FILE), 'mcp.json');
|
|
16
|
+
// Keys and the session secret: kept out of the config file (see secrets.ts).
|
|
17
|
+
const SECRETS_FILE = process.env.TINYWEBUI_SECRETS
|
|
18
|
+
? resolve(process.env.TINYWEBUI_SECRETS)
|
|
19
|
+
: resolve(dirname(CONFIG_FILE), 'tinywebui.secrets.json');
|
|
19
20
|
export const DEFAULTS = {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
21
|
+
// The endpoint of a model that names no connector. Unset: there is none,
|
|
22
|
+
// and a fresh instance is set up by adding a connector, then its models.
|
|
23
|
+
// Code and the environment ($TINYWEBUI_BASE_URL) may still set one.
|
|
24
|
+
baseUrl: '',
|
|
25
|
+
apiKey: '',
|
|
26
|
+
// The default model: a catalog id, or with an empty catalog any model id.
|
|
27
|
+
model: '',
|
|
28
|
+
// The model catalog (see src/config/models.js). Empty everywhere: any model
|
|
29
|
+
// id goes. Listed: only these can be picked, `model` names one by id, and
|
|
30
|
+
// people see each entry's label. An entry may set its own prompt, sampling
|
|
31
|
+
// and caching; the top-level values below are what it inherits.
|
|
32
|
+
models: [],
|
|
33
|
+
// Named endpoints, each with its own baseUrl, optional apiKey and `models` list.
|
|
34
|
+
connectors: [],
|
|
35
|
+
systemPrompt: [
|
|
36
|
+
'You are a direct, technically precise assistant.',
|
|
37
|
+
'',
|
|
38
|
+
'Answer the question that was asked. Lead with the answer, then the reasoning that',
|
|
39
|
+
'changes what the reader does -- no preamble, no restating the question, no summary',
|
|
40
|
+
'of what you just said. Match length to the question: a one-line question gets a',
|
|
41
|
+
'one-line answer; only go long when the task itself has that much surface area.',
|
|
42
|
+
'',
|
|
43
|
+
'Use the tools available to you rather than guessing or asking the user for something',
|
|
44
|
+
'a tool can tell you. Call a tool immediately when it can resolve the question --',
|
|
45
|
+
'don\'t ask permission to look something up or run a read-only check. If a tool',
|
|
46
|
+
'fails, say what failed and what you did instead, rather than silently retrying',
|
|
47
|
+
'or inventing a plausible-looking result. If you still need to call a tool, make',
|
|
48
|
+
'the call on its own and hold your answer until the result is back -- writing the',
|
|
49
|
+
'full answer in the same reply as a pending tool call forces a wasted extra round',
|
|
50
|
+
'just to say there is nothing left to add.',
|
|
51
|
+
'',
|
|
52
|
+
'Say plainly when you are unsure or when something is outside what you can verify.',
|
|
53
|
+
'Never invent APIs, flags, file paths, figures or citations.',
|
|
54
|
+
'',
|
|
55
|
+
'Use markdown only where it earns its keep: code in fenced blocks with a language',
|
|
56
|
+
'tag, tables for genuinely tabular data, lists for genuinely parallel items. Don\'t',
|
|
57
|
+
'reach for headers, bold, or bullets to dress up a short answer that reads fine as',
|
|
58
|
+
'plain sentences.'
|
|
59
|
+
].join('\n'),
|
|
60
|
+
// null = let the provider decide. Setting max_tokens in particular silently
|
|
61
|
+
// truncates on models that would happily write more.
|
|
62
|
+
temperature: null,
|
|
63
|
+
maxTokens: null,
|
|
64
|
+
// How many rounds of tool calls one message may trigger before the loop is
|
|
65
|
+
// cut off. Raise it for deep research, lower it to cap spend per message.
|
|
66
|
+
maxToolRounds: 12,
|
|
67
|
+
// How long an ask_user question waits for an answer before the run carries
|
|
68
|
+
// on without one, on the model's own assumptions. 0 waits until the user
|
|
69
|
+
// answers or stops the run.
|
|
70
|
+
askUserTimeoutSeconds: 120,
|
|
71
|
+
// Merged into every completion request. Gateway-specific knobs live here.
|
|
72
|
+
// TinyWebUI adds OpenRouter session_id automatically; setting provider.order
|
|
73
|
+
// yourself takes precedence over sticky routing, so only pin providers when
|
|
74
|
+
// you deliberately want that behavior.
|
|
75
|
+
extraBody: {},
|
|
76
|
+
// Prompt caching is on by default, and for most backends -- including every
|
|
77
|
+
// local runtime -- that means nothing more than a stable request prefix,
|
|
78
|
+
// which costs nothing and needs no provider support.
|
|
79
|
+
cache: true,
|
|
80
|
+
// Which cache dialect to speak. 'auto' infers it from the endpoint and model
|
|
81
|
+
// and is right for OpenRouter and for anything local; set it explicitly when
|
|
82
|
+
// you are on a gateway TinyWebUI has not been taught about.
|
|
83
|
+
// 'auto' infer (default)
|
|
84
|
+
// 'implicit' stable prefix only, no cache fields on the wire
|
|
85
|
+
// 'explicit' Anthropic-style cache_control breakpoints in message content
|
|
86
|
+
// 'rolling' OpenRouter top-level automatic cache_control
|
|
87
|
+
// 'off' no cache shaping at all
|
|
88
|
+
cacheMode: 'auto',
|
|
89
|
+
// Anthropic cache writes default to five minutes. One hour costs more to write
|
|
90
|
+
// but is useful for research chats with pauses between turns.
|
|
91
|
+
cacheTtl: '5m',
|
|
92
|
+
// Which reasoning past assistant turns send back: 'auto' picks per endpoint
|
|
93
|
+
// (OpenRouter and Anthropic-style: reasoning_details; api.deepseek.com:
|
|
94
|
+
// reasoning_content on tool-call turns; anything else: none).
|
|
95
|
+
reasoningReplay: 'auto',
|
|
96
|
+
// Where conversations live. Tool output is kept whole here and only the
|
|
97
|
+
// compacted form goes on the wire, so this file grows faster than the window.
|
|
98
|
+
dbPath: '',
|
|
99
|
+
// Context compaction. Once the conversation history (system prompt and tool
|
|
100
|
+
// definitions excluded) crosses `compactThreshold` tokens, tool results older
|
|
101
|
+
// than the last `keepTurns` user messages are demoted to stubs -- once, and
|
|
102
|
+
// then frozen, so the cost is a single cache miss rather than a rewritten
|
|
103
|
+
// prefix on every turn. Set the threshold to 0 to disable.
|
|
104
|
+
compactThreshold: 60000,
|
|
105
|
+
keepTurns: 2,
|
|
106
|
+
// An epoch is a full cache miss, so it only happens when stubbing would
|
|
107
|
+
// remove at least this many characters from the window.
|
|
108
|
+
compactMinSaved: 20000,
|
|
109
|
+
// A single result larger than this is stubbed for every LATER turn the
|
|
110
|
+
// moment it arrives; the turn that fetched it still reads it whole. High
|
|
111
|
+
// enough that an ordinary page or search result stays readable for a
|
|
112
|
+
// follow-up question without an expand_context round.
|
|
113
|
+
maxInlineChars: 30000,
|
|
114
|
+
// Hard cap: a result larger than this is stubbed even for the turn that
|
|
115
|
+
// fetched it, since it would otherwise be resent in full on every remaining
|
|
116
|
+
// round and can blow the window on its own.
|
|
117
|
+
maxTurnChars: 120000,
|
|
118
|
+
// Hard window. If the history is still larger than this many tokens after
|
|
119
|
+
// compaction -- a long plain-text chat, where there is nothing to stub --
|
|
120
|
+
// the oldest turns stop being sent (they stay in the transcript), cutting
|
|
121
|
+
// back to half this size so it moves rarely. 0 disables it.
|
|
122
|
+
maxHistoryTokens: 100000,
|
|
123
|
+
// When that window drops turns, summarise them with the chat's model first
|
|
124
|
+
// and give the model the summary in their place. Off: they are just dropped.
|
|
125
|
+
llmCompaction: true,
|
|
126
|
+
// Output cap for that summary request. Reasoning models spend part of it
|
|
127
|
+
// thinking before they write, and a reply cut off by the cap is rejected,
|
|
128
|
+
// so this is headroom; the prompt itself asks for a short summary.
|
|
129
|
+
compactionMaxTokens: 8192,
|
|
130
|
+
// IANA zone the model is told the date in (and uses for automations). Empty
|
|
131
|
+
// means the server's own zone.
|
|
132
|
+
timezone: '',
|
|
133
|
+
// Which tool calls wait for the user (see src/config/approval.js).
|
|
134
|
+
// 'writes' ask before any call not declared read-only (default)
|
|
135
|
+
// 'all' ask before every call
|
|
136
|
+
// 'off' never ask
|
|
137
|
+
// Scheduled runs have no one to ask, so a call that would ask is refused.
|
|
138
|
+
toolApproval: 'writes',
|
|
139
|
+
// Per-tool overrides, by flat name: always ask / never ask.
|
|
140
|
+
confirmTools: [],
|
|
141
|
+
autoApproveTools: [],
|
|
142
|
+
// Cap on what one expand_context call may return.
|
|
143
|
+
expandCharBudget: 8000,
|
|
144
|
+
// Flat tool names switched off from the settings panel -- built-in or an
|
|
145
|
+
// MCP server's, named exactly as buildBody sends them. Filtered out of what
|
|
146
|
+
// is offered to the model; a server can still be reached by name, so this
|
|
147
|
+
// is a per-tool block, not a substitute for disabling the whole server.
|
|
148
|
+
disabledTools: [],
|
|
149
|
+
// 'local' explicit local-only use without login
|
|
150
|
+
// 'multiuser' local accounts: first signup is admin; later signups
|
|
151
|
+
// require an invitation from an admin
|
|
152
|
+
// 'trusted-header' many users: an upstream gateway (Cloudflare Access,
|
|
153
|
+
// oauth2-proxy, Authelia, ...) signs people in with
|
|
154
|
+
// Google, Apple, SSO etc. and passes identity headers
|
|
155
|
+
authMode: 'multiuser',
|
|
156
|
+
// Kept only so old password configurations can be rejected rather than
|
|
157
|
+
// silently opened without login after upgrading.
|
|
158
|
+
authPassword: '',
|
|
159
|
+
// Signs session cookies. Auto-generated on first run when auth is enabled
|
|
160
|
+
// and this is empty; never sent to the frontend.
|
|
161
|
+
sessionSecret: '',
|
|
162
|
+
// Reserved for a future native OAuth login; local multi-user accounts use
|
|
163
|
+
// email and password today.
|
|
164
|
+
googleClientId: '',
|
|
165
|
+
googleClientSecret: '',
|
|
166
|
+
googleRedirectUri: '',
|
|
167
|
+
// Emails auto-approved as admin the first time they sign in -- how the
|
|
168
|
+
// first admin account is bootstrapped.
|
|
169
|
+
adminEmails: [],
|
|
170
|
+
sessionTtlDays: 30,
|
|
171
|
+
// Level 'trusted-header': an upstream gateway (Cloudflare Access, oauth2-
|
|
172
|
+
// proxy, ...) authenticates and injects identity headers on every request.
|
|
173
|
+
// Headers are believed only from peers inside trustedProxyCidrs; startup
|
|
174
|
+
// refuses an empty list. Users are provisioned on first sight.
|
|
175
|
+
trustedProxyCidrs: [],
|
|
176
|
+
trustedUserIdHeader: 'x-tinysuite-user-id',
|
|
177
|
+
trustedEmailHeader: 'x-tinysuite-email',
|
|
178
|
+
trustedNameHeader: 'x-tinysuite-name',
|
|
179
|
+
trustedRoleHeader: 'x-tinysuite-role',
|
|
180
|
+
// Where "log out" sends the browser (e.g. the Access logout URL).
|
|
181
|
+
logoutUrl: '',
|
|
182
|
+
// Extra origins allowed to send state-changing requests (e.g. an admin
|
|
183
|
+
// tool on another host). The page's own origin is always allowed.
|
|
184
|
+
allowedOrigins: [],
|
|
185
|
+
// Extra Host names answered when authMode is 'local' (e.g. a LAN name for a
|
|
186
|
+
// local-only install). Loopback names are always allowed.
|
|
187
|
+
allowedHosts: [],
|
|
188
|
+
// false: refuse to start on a database that needs migrating; run
|
|
189
|
+
// `tinywebui migrate` as its own deployment step instead.
|
|
190
|
+
autoMigrate: true,
|
|
191
|
+
// true: nothing in the control plane can change from the UI or API --
|
|
192
|
+
// settings, tools, MCP servers. Change the files and reload instead.
|
|
193
|
+
frozen: false,
|
|
194
|
+
// read_document and search_chats ranking. 'lexical' is SQLite FTS5 BM25 and
|
|
195
|
+
// needs nothing extra. 'dense' and 'hybrid' use a local ONNX embedding
|
|
196
|
+
// bundle (fetched by `npm start` / `tinywebui models ensure`, or baked into
|
|
197
|
+
// the Docker image -- never by the server) and the onnxruntime-node +
|
|
198
|
+
// @huggingface/tokenizers optional dependencies. 'auto' is hybrid when both
|
|
199
|
+
// are there and lexical, with a warning, when they are not.
|
|
200
|
+
retrieval: {
|
|
201
|
+
mode: 'auto', // auto | lexical | dense | hybrid
|
|
202
|
+
model: 'fast', // fast | balanced | quality (TinySearch's presets) | multilingual, or a name with modelDir
|
|
203
|
+
modelDir: '', // bundle folder; default models/<preset> next to the config
|
|
204
|
+
modelSha256: '', // pin model.onnx; startup refuses a different file
|
|
205
|
+
denseWeight: 0.5, // hybrid: dense share of the fused ranking, BM25 gets the rest
|
|
206
|
+
rrfK: 60,
|
|
207
|
+
queryPrefix: '', // e.g. bge: 'Represent this sentence for searching relevant passages: '
|
|
208
|
+
documentPrefix: '',
|
|
209
|
+
// Small-to-big: PASSAGES are what read_document returns (every mode);
|
|
210
|
+
// CHUNKS are what gets embedded -- as long as the model reads (256
|
|
211
|
+
// tokens for fast, 512 for bge), so only their overlap is set here.
|
|
212
|
+
passageSize: 1800, // characters per passage
|
|
213
|
+
passageOverlap: 200, // characters shared by neighbouring passages
|
|
214
|
+
chunkOverlap: 32, // tokens shared by neighbouring chunks
|
|
215
|
+
},
|
|
213
216
|
};
|
|
214
|
-
|
|
215
217
|
// What an install with no mcp.json starts with: the TinySuite servers, which
|
|
216
218
|
// uv fetches and launches on first use. Any mcp.json, even an empty one,
|
|
217
219
|
// replaces them, so removing them is one save in the MCP panel.
|
|
218
220
|
export const DEFAULT_MCP_SERVERS = Object.freeze({
|
|
219
|
-
|
|
220
|
-
|
|
221
|
+
tinysearch: { command: 'uvx', args: ['--python', '3.12', '--from', 'tinysuite-search[server]', 'tinysearch'] },
|
|
222
|
+
tinycontext: { command: 'uvx', args: ['--python', '3.12', '--from', 'tinysuite-context[server]', 'tinycontext'] },
|
|
221
223
|
});
|
|
222
|
-
|
|
223
224
|
/**
|
|
224
225
|
* One instance's view of its configuration.
|
|
225
226
|
*
|
|
@@ -230,163 +231,250 @@ export const DEFAULT_MCP_SERVERS = Object.freeze({
|
|
|
230
231
|
* `mcpServers` in code likewise replaces mcp.json and locks the MCP editor.
|
|
231
232
|
*/
|
|
232
233
|
export function createConfigSource(opts = {}) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
234
|
+
const code = opts.config || {};
|
|
235
|
+
const file = opts.configFile === false ? null
|
|
236
|
+
: opts.configFile ? resolve(opts.configFile) : CONFIG_FILE;
|
|
237
|
+
const mcpFile = opts.mcpFile ? resolve(opts.mcpFile)
|
|
238
|
+
: file && file !== CONFIG_FILE ? resolve(dirname(file), 'mcp.json') : MCP_FILE;
|
|
239
|
+
const secretsFile = opts.secretsFile ? resolve(opts.secretsFile)
|
|
240
|
+
: !file ? null : file !== CONFIG_FILE ? resolve(dirname(file), 'tinywebui.secrets.json') : SECRETS_FILE;
|
|
241
|
+
const codeServers = opts.mcpServers || null;
|
|
242
|
+
const locked = new Set(Object.keys(code));
|
|
243
|
+
let memory = {}; // stands in for the file when there is none
|
|
244
|
+
let memorySecrets = {};
|
|
245
|
+
const readFile = () => {
|
|
246
|
+
if (!file)
|
|
247
|
+
return { ...memory };
|
|
248
|
+
if (!existsSync(file))
|
|
249
|
+
return {};
|
|
250
|
+
try {
|
|
251
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
throw new Error(`Bad config at ${file}: ${err.message}`);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
const writeFile = (obj) => {
|
|
258
|
+
if (!file) {
|
|
259
|
+
memory = obj;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (existsSync(file) && process.platform !== 'win32')
|
|
263
|
+
chmodSync(file, 0o600);
|
|
264
|
+
writeFileSync(file, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 });
|
|
265
|
+
};
|
|
266
|
+
const readSecrets = () => {
|
|
267
|
+
if (!secretsFile)
|
|
268
|
+
return structuredClone(memorySecrets);
|
|
269
|
+
if (!existsSync(secretsFile))
|
|
270
|
+
return {};
|
|
271
|
+
try {
|
|
272
|
+
return JSON.parse(readFileSync(secretsFile, 'utf8'));
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
throw new Error(`Bad secrets file at ${secretsFile}: ${err.message}`);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const writeSecrets = (secrets) => {
|
|
279
|
+
if (!secretsFile) {
|
|
280
|
+
memorySecrets = secrets;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (existsSync(secretsFile) && process.platform !== 'win32')
|
|
284
|
+
chmodSync(secretsFile, 0o600);
|
|
285
|
+
writeFileSync(secretsFile, JSON.stringify(secrets, null, 2) + '\n', { mode: 0o600 });
|
|
286
|
+
};
|
|
287
|
+
// Not validated here: startup, reload and save check the result with configProblems().
|
|
288
|
+
function load({ persistSecret = true } = {}) {
|
|
289
|
+
const env = {};
|
|
290
|
+
if (process.env.TINYWEBUI_BASE_URL)
|
|
291
|
+
env.baseUrl = process.env.TINYWEBUI_BASE_URL;
|
|
292
|
+
if (process.env.TINYWEBUI_API_KEY)
|
|
293
|
+
env.apiKey = process.env.TINYWEBUI_API_KEY;
|
|
294
|
+
if (process.env.OPENROUTER_API_KEY)
|
|
295
|
+
env.apiKey = process.env.OPENROUTER_API_KEY;
|
|
296
|
+
if (process.env.TINYWEBUI_MODEL)
|
|
297
|
+
env.model = process.env.TINYWEBUI_MODEL;
|
|
298
|
+
// Secrets can come from the environment, so committed config never holds them.
|
|
299
|
+
if (process.env.TINYWEBUI_SESSION_SECRET)
|
|
300
|
+
env.sessionSecret = process.env.TINYWEBUI_SESSION_SECRET;
|
|
301
|
+
let fromFile = readFile();
|
|
302
|
+
// Keys written into the config file by hand, or by a version before the
|
|
303
|
+
// secrets file, move there on start. Read-only callers (the CLI) and a
|
|
304
|
+
// frozen deployment leave the files alone; the keys still apply.
|
|
305
|
+
const { shape, found, names } = extractSecrets(fromFile);
|
|
306
|
+
if (names.length && persistSecret) {
|
|
307
|
+
if (isFrozen())
|
|
308
|
+
log.warn(`${file ?? 'the config'} holds ${names.join(', ')}; move them to ${secretsFile} or the environment`);
|
|
309
|
+
else {
|
|
310
|
+
try {
|
|
311
|
+
writeSecrets(mergeSecrets(readSecrets(), found));
|
|
312
|
+
writeFile(shape);
|
|
313
|
+
fromFile = shape;
|
|
314
|
+
log.info(`moved ${names.join(', ')} from ${file} to ${secretsFile}`);
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
log.error(`could not move secrets out of ${file}: ${err.message}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const cfg = { ...DEFAULTS, ...applySecrets(fromFile, readSecrets()), ...env, ...code };
|
|
322
|
+
if (fromFile.access !== undefined || code.access !== undefined)
|
|
323
|
+
cfg.access = mergeAccess(fromFile.access, code.access);
|
|
324
|
+
// Partial retrieval blocks fill in from the defaults rather than replace them.
|
|
325
|
+
// $TINYWEBUI_RETRIEVAL_MODE=lexical is the no-config way to opt out of hybrid.
|
|
326
|
+
const envRetrieval = process.env.TINYWEBUI_RETRIEVAL_MODE ? { mode: process.env.TINYWEBUI_RETRIEVAL_MODE } : {};
|
|
327
|
+
cfg.retrieval = { ...DEFAULTS.retrieval, ...(fromFile.retrieval || {}), ...envRetrieval, ...(code.retrieval || {}) };
|
|
328
|
+
// A mistyped authMode must fail validation rather than disable login.
|
|
329
|
+
if (typeof cfg.baseUrl === 'string')
|
|
330
|
+
cfg.baseUrl = cfg.baseUrl.replace(/\/+$/, '');
|
|
331
|
+
Object.assign(cfg, flattenCatalog(cfg));
|
|
332
|
+
cfg.model = defaultModel(cfg);
|
|
333
|
+
// A session secret is required the moment auth is on; generate and persist
|
|
334
|
+
// one rather than signing cookies with an empty key.
|
|
335
|
+
if (['multiuser', 'trusted-header'].includes(cfg.authMode) && !cfg.sessionSecret && persistSecret) {
|
|
336
|
+
cfg.sessionSecret = randomBytes(32).toString('hex');
|
|
337
|
+
try {
|
|
338
|
+
writeSecrets({ ...readSecrets(), sessionSecret: cfg.sessionSecret });
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
log.error(`could not persist generated sessionSecret: ${err.message}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return cfg;
|
|
286
345
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
346
|
+
/** Applies the writable, unlocked part of `patch`; locked keys are an error. */
|
|
347
|
+
function save(patch) {
|
|
348
|
+
if (isFrozen())
|
|
349
|
+
throw new LockedError('this deployment is frozen: change its files instead');
|
|
350
|
+
const fileOnly = Object.keys(patch).filter((k) => FILE_ONLY.has(k));
|
|
351
|
+
if (fileOnly.length)
|
|
352
|
+
throw new LockedError(`set in files only, not editable here: ${fileOnly.join(', ')}`);
|
|
353
|
+
const blocked = Object.keys(patch).filter((k) => WRITABLE.has(k) && locked.has(k));
|
|
354
|
+
if (blocked.length)
|
|
355
|
+
throw new LockedError(`set in code, not editable here: ${blocked.join(', ')}`);
|
|
356
|
+
// Edits apply to the flat catalog the page sees; the file gets it nested.
|
|
357
|
+
const current = readFile();
|
|
358
|
+
Object.assign(current, flattenCatalog(current));
|
|
359
|
+
const split = takeSecrets(patch, current, readSecrets());
|
|
360
|
+
if (split.refused.length)
|
|
361
|
+
throw new LockedError(`set from the environment, not editable here: ${split.refused.join(', ')}`);
|
|
362
|
+
for (const [k, v] of Object.entries(split.patch)) {
|
|
363
|
+
if (!WRITABLE.has(k))
|
|
364
|
+
continue;
|
|
365
|
+
if (v === undefined)
|
|
366
|
+
delete current[k];
|
|
367
|
+
else
|
|
368
|
+
current[k] = v;
|
|
369
|
+
}
|
|
370
|
+
// Validate what the files would become before writing them.
|
|
371
|
+
const problems = configProblems({ ...DEFAULTS, ...applySecrets(current, split.secrets), ...code });
|
|
372
|
+
if (problems.length)
|
|
373
|
+
throw new ConfigError(problems.join('; '));
|
|
374
|
+
// Secrets first: a key is never lost between the two writes.
|
|
375
|
+
writeSecrets(split.secrets);
|
|
376
|
+
writeFile({ ...current, ...nestCatalog(current) });
|
|
377
|
+
return load();
|
|
300
378
|
}
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
// `frozen` may be set in code or in the file; code wins, like any key.
|
|
309
|
-
function isFrozen() {
|
|
310
|
-
if (code.frozen !== undefined) return Boolean(code.frozen);
|
|
311
|
-
try { return Boolean(readFile().frozen); } catch { return false; }
|
|
312
|
-
}
|
|
313
|
-
const lockedNow = () => (isFrozen() ? new Set([...locked, ...WRITABLE]) : locked);
|
|
314
|
-
|
|
315
|
-
const readMcp = () => {
|
|
316
|
-
if (codeServers) return JSON.stringify({ mcpServers: codeServers }, null, 2) + '\n';
|
|
317
|
-
if (!existsSync(mcpFile)) return JSON.stringify({ mcpServers: DEFAULT_MCP_SERVERS }, null, 2) + '\n';
|
|
318
|
-
return readFileSync(mcpFile, 'utf8');
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
return {
|
|
322
|
-
load,
|
|
323
|
-
save,
|
|
324
|
-
public: (cfg) => ({
|
|
325
|
-
...publicConfig(cfg), lockedKeys: [...lockedNow()], mcpLocked: Boolean(codeServers) || isFrozen(),
|
|
326
|
-
keyClasses: Object.fromEntries([...WRITABLE, ...FILE_ONLY].map((k) => [k, keyClass(k, lockedNow())]))
|
|
327
|
-
}),
|
|
328
|
-
lockedKeys: () => new Set(lockedNow()),
|
|
329
|
-
/** The raw mcp.json (or code) server map, throwing on bad JSON -- for tooling that must not guess. */
|
|
330
|
-
strictMcpServers() {
|
|
331
|
-
const parsed = JSON.parse(readMcp());
|
|
332
|
-
return parsed.mcpServers || parsed;
|
|
333
|
-
},
|
|
334
|
-
/** Users pinned in code's access.users: admins cannot overrule these. */
|
|
335
|
-
codeAccessUsers: () => ({ ...(code.access?.users || {}) }),
|
|
336
|
-
/** Low-level access for the policy write-back: read and replace the JSON file. */
|
|
337
|
-
readFile,
|
|
338
|
-
writeFile,
|
|
339
|
-
path: () => file,
|
|
340
|
-
mcpPath: () => (codeServers ? null : mcpFile),
|
|
341
|
-
get mcpLocked() { return Boolean(codeServers) || isFrozen(); },
|
|
342
|
-
isFrozen,
|
|
343
|
-
dbPath: (cfg) => {
|
|
344
|
-
const set = opts.dbPath || process.env.TINYWEBUI_DB || cfg?.dbPath;
|
|
345
|
-
if (set === ':memory:') return set;
|
|
346
|
-
return set ? resolve(set) : resolve(dirname(file || CONFIG_FILE), 'tinywebui.db');
|
|
347
|
-
},
|
|
348
|
-
/** Raw text of mcp.json, so the editor round-trips comments-free but verbatim. */
|
|
349
|
-
readMcpFile: readMcp,
|
|
350
|
-
loadMcpServers() {
|
|
351
|
-
try {
|
|
352
|
-
const parsed = JSON.parse(readMcp());
|
|
353
|
-
// Accept both {"mcpServers": {...}} and a bare {...} map.
|
|
354
|
-
return parsed.mcpServers || parsed || {};
|
|
355
|
-
} catch (err) {
|
|
356
|
-
log.error(`${mcpFile} is not valid JSON: ${err.message}`);
|
|
357
|
-
return {};
|
|
358
|
-
}
|
|
359
|
-
},
|
|
360
|
-
/** Validates before writing, so a typo cannot leave an unparseable file behind. */
|
|
361
|
-
saveMcpFile(text) {
|
|
362
|
-
if (codeServers) throw new LockedError('MCP servers are set in code, not editable here');
|
|
363
|
-
if (isFrozen()) throw new LockedError('this deployment is frozen: change mcp.json instead');
|
|
364
|
-
const parsed = JSON.parse(text);
|
|
365
|
-
const servers = parsed.mcpServers || parsed;
|
|
366
|
-
for (const [name, spec] of Object.entries(servers)) {
|
|
367
|
-
if (!spec || (!spec.command && !spec.url)) {
|
|
368
|
-
throw new Error(`"${name}" needs either "command" (stdio) or "url" (http)`);
|
|
379
|
+
// `frozen` may be set in code or in the file; code wins, like any key.
|
|
380
|
+
function isFrozen() {
|
|
381
|
+
if (code.frozen !== undefined)
|
|
382
|
+
return Boolean(code.frozen);
|
|
383
|
+
try {
|
|
384
|
+
return Boolean(readFile().frozen);
|
|
369
385
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
}
|
|
374
|
-
|
|
386
|
+
catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const lockedNow = () => (isFrozen() ? new Set([...locked, ...WRITABLE]) : locked);
|
|
391
|
+
const readMcp = () => {
|
|
392
|
+
if (codeServers)
|
|
393
|
+
return JSON.stringify({ mcpServers: codeServers }, null, 2) + '\n';
|
|
394
|
+
if (!existsSync(mcpFile))
|
|
395
|
+
return JSON.stringify({ mcpServers: DEFAULT_MCP_SERVERS }, null, 2) + '\n';
|
|
396
|
+
return readFileSync(mcpFile, 'utf8');
|
|
397
|
+
};
|
|
398
|
+
return {
|
|
399
|
+
load,
|
|
400
|
+
save,
|
|
401
|
+
public: (cfg) => ({
|
|
402
|
+
...publicConfig(cfg), lockedKeys: [...lockedNow()], mcpLocked: Boolean(codeServers) || isFrozen(),
|
|
403
|
+
keyClasses: Object.fromEntries([...WRITABLE, ...FILE_ONLY].map((k) => [k, keyClass(k, lockedNow())]))
|
|
404
|
+
}),
|
|
405
|
+
lockedKeys: () => new Set(lockedNow()),
|
|
406
|
+
/** The raw mcp.json (or code) server map, throwing on bad JSON -- for tooling that must not guess. */
|
|
407
|
+
strictMcpServers() {
|
|
408
|
+
const parsed = JSON.parse(readMcp());
|
|
409
|
+
return parsed.mcpServers || parsed;
|
|
410
|
+
},
|
|
411
|
+
/** Users pinned in code's access.users: admins cannot overrule these. */
|
|
412
|
+
codeAccessUsers: () => ({ ...(code.access?.users || {}) }),
|
|
413
|
+
/** Low-level access for the policy write-back: read and replace the JSON file. */
|
|
414
|
+
readFile,
|
|
415
|
+
writeFile,
|
|
416
|
+
path: () => file,
|
|
417
|
+
secretsPath: () => secretsFile,
|
|
418
|
+
/** Secret values the config file holds as written, by name (they move out on start). */
|
|
419
|
+
secretsInFile: () => extractSecrets(readFile()).names,
|
|
420
|
+
mcpPath: () => (codeServers ? null : mcpFile),
|
|
421
|
+
get mcpLocked() { return Boolean(codeServers) || isFrozen(); },
|
|
422
|
+
isFrozen,
|
|
423
|
+
dbPath: (cfg) => {
|
|
424
|
+
const set = opts.dbPath || process.env.TINYWEBUI_DB || cfg?.dbPath;
|
|
425
|
+
if (set === ':memory:')
|
|
426
|
+
return set;
|
|
427
|
+
return set ? resolve(set) : resolve(dirname(file || CONFIG_FILE), 'tinywebui.db');
|
|
428
|
+
},
|
|
429
|
+
/** Raw text of mcp.json, so the editor round-trips comments-free but verbatim. */
|
|
430
|
+
readMcpFile: readMcp,
|
|
431
|
+
loadMcpServers() {
|
|
432
|
+
try {
|
|
433
|
+
const parsed = JSON.parse(readMcp());
|
|
434
|
+
// Accept both {"mcpServers": {...}} and a bare {...} map.
|
|
435
|
+
return parsed.mcpServers || parsed || {};
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
log.error(`${mcpFile} is not valid JSON: ${err.message}`);
|
|
439
|
+
return {};
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
/** Validates before writing, so a typo cannot leave an unparseable file behind. */
|
|
443
|
+
saveMcpFile(text) {
|
|
444
|
+
if (codeServers)
|
|
445
|
+
throw new LockedError('MCP servers are set in code, not editable here');
|
|
446
|
+
if (isFrozen())
|
|
447
|
+
throw new LockedError('this deployment is frozen: change mcp.json instead');
|
|
448
|
+
const parsed = JSON.parse(text);
|
|
449
|
+
const servers = parsed.mcpServers || parsed;
|
|
450
|
+
for (const [name, spec] of Object.entries(servers)) {
|
|
451
|
+
if (!spec || (!spec.command && !spec.url)) {
|
|
452
|
+
throw new Error(`"${name}" needs either "command" (stdio) or "url" (http)`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// Headers and env here can hold tokens: owner-only, like the config.
|
|
456
|
+
if (existsSync(mcpFile) && process.platform !== 'win32')
|
|
457
|
+
chmodSync(mcpFile, 0o600);
|
|
458
|
+
writeFileSync(mcpFile, text.endsWith('\n') ? text : text + '\n', { mode: 0o600 });
|
|
459
|
+
return servers;
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
export class LockedError extends Error {
|
|
464
|
+
}
|
|
465
|
+
/** A save that would leave the config invalid: the caller's mistake, not the server's. */
|
|
466
|
+
export class ConfigError extends Error {
|
|
375
467
|
}
|
|
376
|
-
|
|
377
|
-
export class LockedError extends Error {}
|
|
378
|
-
|
|
379
468
|
// Keys that are not in DEFAULTS but are still settings.
|
|
380
469
|
const EXTRA_KEYS = new Set(['access']);
|
|
381
470
|
const ENUMS = {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
471
|
+
authMode: ['local', 'multiuser', 'trusted-header'],
|
|
472
|
+
cacheTtl: ['5m', '1h'],
|
|
473
|
+
cacheMode: ['auto', 'implicit', 'explicit', 'rolling', 'off'],
|
|
474
|
+
reasoningReplay: REASONING_REPLAY,
|
|
475
|
+
toolApproval: ['writes', 'all', 'off'],
|
|
387
476
|
};
|
|
388
477
|
const kind = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);
|
|
389
|
-
|
|
390
478
|
/**
|
|
391
479
|
* Every problem with a loaded config: unknown keys (usually typos), values of
|
|
392
480
|
* the wrong type or outside their allowed set, plus the policy checks in
|
|
@@ -394,125 +482,129 @@ const kind = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v
|
|
|
394
482
|
* a config with any.
|
|
395
483
|
*/
|
|
396
484
|
function retrievalProblems(r) {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if (r.rrfK !== undefined && !(Number.isInteger(r.rrfK) && r.rrfK >= 0)) out.push('retrieval.rrfK must be a whole number >= 0');
|
|
409
|
-
const size = r.passageSize ?? DEFAULTS.retrieval.passageSize;
|
|
410
|
-
if (!(Number.isInteger(size) && size >= 200 && size <= 20000)) out.push('retrieval.passageSize must be a whole number of characters from 200 to 20000');
|
|
411
|
-
if (r.passageOverlap !== undefined && !(Number.isInteger(r.passageOverlap) && r.passageOverlap >= 0 && r.passageOverlap < size / 2)) {
|
|
412
|
-
out.push('retrieval.passageOverlap must be a whole number >= 0 and under half of passageSize');
|
|
413
|
-
}
|
|
414
|
-
if (r.chunkOverlap !== undefined && !(Number.isInteger(r.chunkOverlap) && r.chunkOverlap >= 0 && r.chunkOverlap <= 128)) {
|
|
415
|
-
out.push('retrieval.chunkOverlap must be a whole number of tokens from 0 to 128');
|
|
416
|
-
}
|
|
417
|
-
return out;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
export function configProblems(cfg) {
|
|
421
|
-
const problems = [];
|
|
422
|
-
for (const [key, value] of Object.entries(cfg)) {
|
|
423
|
-
if (!(key in DEFAULTS) && !EXTRA_KEYS.has(key)) { problems.push(`unknown setting "${key}"`); continue; }
|
|
424
|
-
if (ENUMS[key]) {
|
|
425
|
-
if (!ENUMS[key].includes(value)) problems.push(`${key} must be one of ${ENUMS[key].join(', ')} (got ${JSON.stringify(value)})`);
|
|
426
|
-
continue;
|
|
485
|
+
if (!r || typeof r !== 'object' || Array.isArray(r))
|
|
486
|
+
return ['retrieval must be an object'];
|
|
487
|
+
const out = [];
|
|
488
|
+
for (const k of Object.keys(r))
|
|
489
|
+
if (!(k in DEFAULTS.retrieval))
|
|
490
|
+
out.push(`unknown setting "retrieval.${k}"`);
|
|
491
|
+
if (r.mode !== undefined && !['auto', 'lexical', 'dense', 'hybrid'].includes(r.mode))
|
|
492
|
+
out.push('retrieval.mode must be one of auto, lexical, dense, hybrid');
|
|
493
|
+
for (const k of ['model', 'modelDir', 'modelSha256', 'queryPrefix', 'documentPrefix']) {
|
|
494
|
+
if (r[k] !== undefined && typeof r[k] !== 'string')
|
|
495
|
+
out.push(`retrieval.${k} must be a string`);
|
|
427
496
|
}
|
|
428
|
-
if (
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
497
|
+
if (r.modelSha256 && !/^[0-9a-fA-F]{64}$/.test(r.modelSha256))
|
|
498
|
+
out.push('retrieval.modelSha256 must be a 64-character hex sha256');
|
|
499
|
+
if (r.denseWeight !== undefined && !(typeof r.denseWeight === 'number' && r.denseWeight > 0 && r.denseWeight < 1)) {
|
|
500
|
+
out.push('retrieval.denseWeight must be a number between 0 and 1 (exclusive); use mode "dense" or "lexical" for the extremes');
|
|
432
501
|
}
|
|
433
|
-
if (
|
|
434
|
-
|
|
435
|
-
|
|
502
|
+
if (r.rrfK !== undefined && !(Number.isInteger(r.rrfK) && r.rrfK >= 0))
|
|
503
|
+
out.push('retrieval.rrfK must be a whole number >= 0');
|
|
504
|
+
const size = r.passageSize ?? DEFAULTS.retrieval.passageSize;
|
|
505
|
+
if (!(Number.isInteger(size) && size >= 200 && size <= 20000))
|
|
506
|
+
out.push('retrieval.passageSize must be a whole number of characters from 200 to 20000');
|
|
507
|
+
if (r.passageOverlap !== undefined && !(Number.isInteger(r.passageOverlap) && r.passageOverlap >= 0 && r.passageOverlap < size / 2)) {
|
|
508
|
+
out.push('retrieval.passageOverlap must be a whole number >= 0 and under half of passageSize');
|
|
436
509
|
}
|
|
437
|
-
if (!(
|
|
438
|
-
|
|
439
|
-
if (!(Number.isInteger(value) && value >= 256)) problems.push('compactionMaxTokens must be a whole number >= 256');
|
|
440
|
-
continue;
|
|
510
|
+
if (r.chunkOverlap !== undefined && !(Number.isInteger(r.chunkOverlap) && r.chunkOverlap >= 0 && r.chunkOverlap <= 128)) {
|
|
511
|
+
out.push('retrieval.chunkOverlap must be a whole number of tokens from 0 to 128');
|
|
441
512
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
513
|
+
return out;
|
|
514
|
+
}
|
|
515
|
+
export function configProblems(raw) {
|
|
516
|
+
const cfg = { ...raw, ...flattenCatalog(raw) };
|
|
517
|
+
const problems = [];
|
|
518
|
+
for (const [key, value] of Object.entries(cfg)) {
|
|
519
|
+
if (!(key in DEFAULTS) && !EXTRA_KEYS.has(key)) {
|
|
520
|
+
problems.push(`unknown setting "${key}"`);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (ENUMS[key]) {
|
|
524
|
+
if (!ENUMS[key].includes(value))
|
|
525
|
+
problems.push(`${key} must be one of ${ENUMS[key].join(', ')} (got ${JSON.stringify(value)})`);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
if (key === 'models' || key === 'connectors')
|
|
529
|
+
continue; // modelProblems/connectorProblems, below, know the shape
|
|
530
|
+
if (key === 'retrieval') {
|
|
531
|
+
problems.push(...retrievalProblems(value));
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (key === 'frozen' || key === 'autoMigrate') {
|
|
535
|
+
if (typeof value !== 'boolean')
|
|
536
|
+
problems.push(`${key} must be true or false`);
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (!(key in DEFAULTS))
|
|
540
|
+
continue;
|
|
541
|
+
if (key === 'compactionMaxTokens') {
|
|
542
|
+
if (!(Number.isInteger(value) && value >= 256))
|
|
543
|
+
problems.push('compactionMaxTokens must be a whole number >= 256');
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (key === 'askUserTimeoutSeconds') {
|
|
547
|
+
if (!(Number.isInteger(value) && value >= 0))
|
|
548
|
+
problems.push('askUserTimeoutSeconds must be a whole number of seconds >= 0 (0: no timeout)');
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const want = kind(DEFAULTS[key]);
|
|
552
|
+
const got = kind(value);
|
|
553
|
+
// A null default is an optional number (temperature, maxTokens).
|
|
554
|
+
const ok = want === 'null' ? got === 'null' || (got === 'number' && Number.isFinite(value))
|
|
555
|
+
: want === 'number' ? got === 'number' && Number.isFinite(value)
|
|
556
|
+
: got === want;
|
|
557
|
+
if (!ok)
|
|
558
|
+
problems.push(`${key} must be ${want === 'null' ? 'a number or null' : `a${want === 'array' || want === 'object' ? 'n' : ''} ${want}`} (got ${got})`);
|
|
445
559
|
}
|
|
446
|
-
|
|
447
|
-
const got = kind(value);
|
|
448
|
-
// A null default is an optional number (temperature, maxTokens).
|
|
449
|
-
const ok = want === 'null' ? got === 'null' || (got === 'number' && Number.isFinite(value))
|
|
450
|
-
: want === 'number' ? got === 'number' && Number.isFinite(value)
|
|
451
|
-
: got === want;
|
|
452
|
-
if (!ok) problems.push(`${key} must be ${want === 'null' ? 'a number or null' : `a${want === 'array' || want === 'object' ? 'n' : ''} ${want}`} (got ${got})`);
|
|
453
|
-
}
|
|
454
|
-
return [...problems, ...connectorProblems(cfg), ...modelProblems(cfg), ...validateConfig(cfg)];
|
|
560
|
+
return [...problems, ...connectorProblems(cfg), ...modelProblems(cfg), ...validateConfig(cfg), ...refProblems(cfg)];
|
|
455
561
|
}
|
|
456
|
-
|
|
457
562
|
// Only the knobs the UI is allowed to change. Secrets stay server-side.
|
|
458
563
|
export const WRITABLE = new Set([
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
564
|
+
'model', 'systemPrompt', 'temperature', 'maxTokens', 'maxToolRounds', 'askUserTimeoutSeconds',
|
|
565
|
+
'cacheTtl', 'cacheMode', 'compactThreshold', 'keepTurns', 'maxInlineChars',
|
|
566
|
+
'compactMinSaved', 'maxTurnChars', 'maxHistoryTokens', 'timezone',
|
|
567
|
+
'toolApproval', 'confirmTools', 'autoApproveTools', 'disabledTools',
|
|
568
|
+
// The deployment's endpoints and catalog: only the sole user or an admin may
|
|
569
|
+
// send these (see SECRET_PATCH_KEYS in policy.js); keys are write-only.
|
|
570
|
+
'baseUrl', 'apiKey', 'models', 'connectors',
|
|
571
|
+
'llmCompaction', 'compactionMaxTokens', 'expandCharBudget', 'cache', 'reasoningReplay', 'extraBody'
|
|
467
572
|
]);
|
|
468
|
-
|
|
469
573
|
// A short tail is enough to tell two keys apart in a list; short keys show none.
|
|
470
574
|
const keyHint = (key) => (key && key.length >= 12 ? key.slice(-4) : null);
|
|
471
|
-
|
|
472
575
|
/**
|
|
473
576
|
* API keys are write-only: the page learns that one is set (and its last four
|
|
474
|
-
* characters), never the value.
|
|
475
|
-
*
|
|
577
|
+
* characters), never the value. Where a save puts them is takeSecrets() in
|
|
578
|
+
* secrets.ts.
|
|
476
579
|
*/
|
|
477
|
-
export function mergeSecrets(patch, current) {
|
|
478
|
-
const out = { ...patch };
|
|
479
|
-
if ('apiKey' in out && (out.apiKey === undefined || out.apiKey === '')) delete out.apiKey;
|
|
480
|
-
else if (out.apiKey === null) out.apiKey = '';
|
|
481
|
-
if (Array.isArray(out.connectors)) {
|
|
482
|
-
const old = new Map((Array.isArray(current.connectors) ? current.connectors : []).map((c) => [c?.id, c]));
|
|
483
|
-
out.connectors = out.connectors.map((c) => {
|
|
484
|
-
if (!c || typeof c !== 'object') return c;
|
|
485
|
-
const { apiKey, ...rest } = c;
|
|
486
|
-
if (apiKey === null) return { ...rest, apiKey: '' };
|
|
487
|
-
if (apiKey === undefined || apiKey === '') return old.get(c.id)?.apiKey ? { ...rest, apiKey: old.get(c.id).apiKey } : rest;
|
|
488
|
-
return c;
|
|
489
|
-
});
|
|
490
|
-
}
|
|
491
|
-
return out;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
580
|
/** The patch as it may be logged: secret values replaced by whether they were set. */
|
|
495
581
|
export function redactSecrets(patch) {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
582
|
+
const out = { ...patch };
|
|
583
|
+
if ('apiKey' in out)
|
|
584
|
+
out.apiKey = Boolean(out.apiKey);
|
|
585
|
+
if (Array.isArray(out.connectors)) {
|
|
586
|
+
out.connectors = out.connectors.map((c) => (c && typeof c === 'object' && 'apiKey' in c ? { ...c, apiKey: Boolean(c.apiKey) } : c));
|
|
587
|
+
}
|
|
588
|
+
return out;
|
|
502
589
|
}
|
|
503
|
-
|
|
504
590
|
export function publicConfig(cfg) {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
591
|
+
const { apiKey, authPassword, sessionSecret, googleClientSecret, connectors, ...rest } = cfg;
|
|
592
|
+
return {
|
|
593
|
+
...rest,
|
|
594
|
+
connectors: (Array.isArray(connectors) ? connectors : []).map(({ apiKey: key, ...c }) => ({ ...c, hasApiKey: Boolean(key), keyHint: keyHint(key) })),
|
|
595
|
+
keyHint: keyHint(apiKey),
|
|
596
|
+
modelLabel: labelFor(cfg, cfg.model),
|
|
597
|
+
hasApiKey: Boolean(apiKey),
|
|
598
|
+
// Whether the default model's turns have an endpoint to go to.
|
|
599
|
+
ready: hasEndpoint(cfg),
|
|
600
|
+
// Whether the top-level endpoint serves anything, so the page shows it:
|
|
601
|
+
// a URL, a key, or a model naming no connector. Otherwise the page offers
|
|
602
|
+
// connectors only.
|
|
603
|
+
usesDefaultEndpoint: Boolean(cfg.baseUrl) || Boolean(apiKey)
|
|
604
|
+
|| (Array.isArray(cfg.models) && cfg.models.some((m) => !m.connector)),
|
|
605
|
+
hasGoogleAuth: Boolean(cfg.googleClientId),
|
|
606
|
+
};
|
|
514
607
|
}
|
|
515
|
-
|
|
516
608
|
// The file-driven instance the CLI has always used, kept as plain functions.
|
|
517
609
|
const fileSource = createConfigSource();
|
|
518
610
|
export const configPath = () => fileSource.path();
|