tinywebui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +586 -0
  3. package/bin/tinywebui.js +105 -0
  4. package/package.json +49 -0
  5. package/public/app.js +81 -0
  6. package/public/chat/attachments.js +304 -0
  7. package/public/chat/chat.js +282 -0
  8. package/public/chat/composer.js +289 -0
  9. package/public/chat/library.js +76 -0
  10. package/public/chat/outline.js +39 -0
  11. package/public/chat/queue.js +124 -0
  12. package/public/chat/sidebar.js +485 -0
  13. package/public/chat/stream.js +162 -0
  14. package/public/chat/tasks.js +36 -0
  15. package/public/chat/transcript.js +691 -0
  16. package/public/core/access.js +42 -0
  17. package/public/core/api.js +27 -0
  18. package/public/core/dom.js +13 -0
  19. package/public/core/id.js +7 -0
  20. package/public/core/md.js +131 -0
  21. package/public/core/state.js +31 -0
  22. package/public/favicon.svg +9 -0
  23. package/public/index.html +261 -0
  24. package/public/panels/admin.js +199 -0
  25. package/public/panels/automations.js +464 -0
  26. package/public/panels/panels.js +52 -0
  27. package/public/panels/settings.js +365 -0
  28. package/public/panels/usage.js +474 -0
  29. package/public/shared/attachment_note.js +23 -0
  30. package/public/shared/events.js +27 -0
  31. package/public/shared/features.js +21 -0
  32. package/public/shared/origins.js +8 -0
  33. package/public/shared/usage.js +59 -0
  34. package/public/styles.css +1455 -0
  35. package/public/themes/README.md +178 -0
  36. package/public/themes/cyber-grid/cyber-grid.css +59 -0
  37. package/public/themes/drivers/scene-kit.js +422 -0
  38. package/public/themes/fall-fairy/fall-fairy.css +221 -0
  39. package/public/themes/fall-fairy/fall-fairy.js +859 -0
  40. package/public/themes/manifest.json +6 -0
  41. package/public/themes/theme-loader.js +123 -0
  42. package/src/access/auth.js +228 -0
  43. package/src/access/auth_gate.js +67 -0
  44. package/src/access/policy.js +180 -0
  45. package/src/audit.js +7 -0
  46. package/src/automations/automation.js +136 -0
  47. package/src/automations/scheduler.js +147 -0
  48. package/src/chat/compact.js +242 -0
  49. package/src/chat/llm.js +448 -0
  50. package/src/chat/runs.js +180 -0
  51. package/src/chat/tool_executor.js +239 -0
  52. package/src/cli.js +478 -0
  53. package/src/config/approval.js +54 -0
  54. package/src/config/config.js +479 -0
  55. package/src/config/models.js +112 -0
  56. package/src/files/documents.js +91 -0
  57. package/src/files/images.js +62 -0
  58. package/src/harness/agent.js +115 -0
  59. package/src/harness/attribution.js +75 -0
  60. package/src/harness/checkpoint.js +96 -0
  61. package/src/harness/history.js +40 -0
  62. package/src/harness/prompt.js +93 -0
  63. package/src/harness/provider.js +430 -0
  64. package/src/http.js +95 -0
  65. package/src/log.js +24 -0
  66. package/src/mcp.js +405 -0
  67. package/src/retrieval/embedding.js +298 -0
  68. package/src/retrieval/retrieval.js +250 -0
  69. package/src/routes/admin.js +112 -0
  70. package/src/routes/auth.js +97 -0
  71. package/src/routes/automations.js +92 -0
  72. package/src/routes/chats.js +232 -0
  73. package/src/routes/documents.js +133 -0
  74. package/src/routes/library.js +51 -0
  75. package/src/routes/mcp.js +59 -0
  76. package/src/routes/settings.js +101 -0
  77. package/src/server.js +397 -0
  78. package/src/set-password.js +32 -0
  79. package/src/store/automations.js +119 -0
  80. package/src/store/chats.js +254 -0
  81. package/src/store/documents.js +256 -0
  82. package/src/store/embeddings.js +67 -0
  83. package/src/store/index.js +90 -0
  84. package/src/store/messages.js +93 -0
  85. package/src/store/schema.js +480 -0
  86. package/src/store/scope.js +43 -0
  87. package/src/store/search.js +56 -0
  88. package/src/store/turns.js +78 -0
  89. package/src/store/usage.js +327 -0
  90. package/src/store/users.js +102 -0
  91. package/src/store/wire.js +55 -0
  92. package/src/tools/ask_tool.js +93 -0
  93. package/src/tools/chat_search_tool.js +63 -0
  94. package/src/tools/context_tool.js +183 -0
  95. package/src/tools/document_tool.js +102 -0
  96. package/src/tools/task_tool.js +63 -0
@@ -0,0 +1,479 @@
1
+ import { readFileSync, existsSync, writeFileSync } from 'node:fs';
2
+ import { resolve, dirname } from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { FILE_ONLY, mergeAccess, keyClass, validateConfig } from '../access/policy.js';
5
+ import { modelProblems, labelFor, REASONING_REPLAY } from './models.js';
6
+ import { logger } from '../log.js';
7
+
8
+ const log = logger('config');
9
+
10
+ const CONFIG_FILE = process.env.TINYWEBUI_CONFIG
11
+ ? resolve(process.env.TINYWEBUI_CONFIG)
12
+ : resolve(process.cwd(), 'tinywebui.config.json');
13
+
14
+ // MCP wiring lives in its own Claude-Desktop-shaped file, next to the config.
15
+ const MCP_FILE = process.env.TINYWEBUI_MCP
16
+ ? resolve(process.env.TINYWEBUI_MCP)
17
+ : resolve(dirname(CONFIG_FILE), 'mcp.json');
18
+
19
+ export const DEFAULTS = {
20
+ baseUrl: 'https://openrouter.ai/api/v1',
21
+ apiKey: '',
22
+ // A cheap, fast, tool-capable default that caches well on a stable prefix and
23
+ // needs no cache_control fields. Any OpenAI-compatible model id works.
24
+ model: 'deepseek/deepseek-v4.1-flash',
25
+ // The model catalog (see src/config/models.js). Empty: any model id goes. Listed:
26
+ // only these can be picked, `model` names one by id, and people see each
27
+ // entry's label, with its own prompt and sampling settings if it has them.
28
+ models: [],
29
+ systemPrompt: [
30
+ 'You are a direct, technically precise assistant.',
31
+ '',
32
+ 'Answer the question that was asked. Lead with the answer, then the reasoning that',
33
+ 'changes what the reader does -- no preamble, no restating the question, no summary',
34
+ 'of what you just said. Match length to the question: a one-line question gets a',
35
+ 'one-line answer; only go long when the task itself has that much surface area.',
36
+ '',
37
+ 'Use the tools available to you rather than guessing or asking the user for something',
38
+ 'a tool can tell you. Call a tool immediately when it can resolve the question --',
39
+ 'don\'t ask permission to look something up or run a read-only check. If a tool',
40
+ 'fails, say what failed and what you did instead, rather than silently retrying',
41
+ 'or inventing a plausible-looking result. If you still need to call a tool, make',
42
+ 'the call on its own and hold your answer until the result is back -- writing the',
43
+ 'full answer in the same reply as a pending tool call forces a wasted extra round',
44
+ 'just to say there is nothing left to add.',
45
+ '',
46
+ 'Say plainly when you are unsure or when something is outside what you can verify.',
47
+ 'Never invent APIs, flags, file paths, figures or citations.',
48
+ '',
49
+ 'Use markdown only where it earns its keep: code in fenced blocks with a language',
50
+ 'tag, tables for genuinely tabular data, lists for genuinely parallel items. Don\'t',
51
+ 'reach for headers, bold, or bullets to dress up a short answer that reads fine as',
52
+ 'plain sentences.'
53
+ ].join('\n'),
54
+ // null = let the provider decide. Setting max_tokens in particular silently
55
+ // truncates on models that would happily write more.
56
+ temperature: null,
57
+ maxTokens: null,
58
+ // How many rounds of tool calls one message may trigger before the loop is
59
+ // cut off. Raise it for deep research, lower it to cap spend per message.
60
+ maxToolRounds: 12,
61
+ // How long an ask_user question waits for an answer before the run carries
62
+ // on without one, on the model's own assumptions. 0 waits until the user
63
+ // answers or stops the run.
64
+ askUserTimeoutSeconds: 120,
65
+ // Merged into every completion request. Gateway-specific knobs live here.
66
+ // TinyWebUI adds OpenRouter session_id automatically; setting provider.order
67
+ // yourself takes precedence over sticky routing, so only pin providers when
68
+ // you deliberately want that behavior.
69
+ extraBody: {},
70
+ // Prompt caching is on by default, and for most backends -- including every
71
+ // local runtime -- that means nothing more than a stable request prefix,
72
+ // which costs nothing and needs no provider support.
73
+ cache: true,
74
+ // Which cache dialect to speak. 'auto' infers it from the endpoint and model
75
+ // and is right for OpenRouter and for anything local; set it explicitly when
76
+ // you are on a gateway TinyWebUI has not been taught about.
77
+ // 'auto' infer (default)
78
+ // 'implicit' stable prefix only, no cache fields on the wire
79
+ // 'explicit' Anthropic-style cache_control breakpoints in message content
80
+ // 'rolling' OpenRouter top-level automatic cache_control
81
+ // 'off' no cache shaping at all
82
+ cacheMode: 'auto',
83
+ // Anthropic cache writes default to five minutes. One hour costs more to write
84
+ // but is useful for research chats with pauses between turns.
85
+ cacheTtl: '5m',
86
+ // Which reasoning past assistant turns send back: 'auto' picks per endpoint
87
+ // (OpenRouter and Anthropic-style: reasoning_details; api.deepseek.com:
88
+ // reasoning_content on tool-call turns; anything else: none).
89
+ reasoningReplay: 'auto',
90
+ // Where conversations live. Tool output is kept whole here and only the
91
+ // compacted form goes on the wire, so this file grows faster than the window.
92
+ dbPath: '',
93
+ // Context compaction. Once the conversation history (system prompt and tool
94
+ // definitions excluded) crosses `compactThreshold` tokens, tool results older
95
+ // than the last `keepTurns` user messages are demoted to stubs -- once, and
96
+ // then frozen, so the cost is a single cache miss rather than a rewritten
97
+ // prefix on every turn. Set the threshold to 0 to disable.
98
+ compactThreshold: 60000,
99
+ keepTurns: 2,
100
+ // An epoch is a full cache miss, so it only happens when stubbing would
101
+ // remove at least this many characters from the window.
102
+ compactMinSaved: 20000,
103
+ // A single result larger than this is stubbed for every LATER turn the
104
+ // moment it arrives; the turn that fetched it still reads it whole. High
105
+ // enough that an ordinary page or search result stays readable for a
106
+ // follow-up question without an expand_context round.
107
+ maxInlineChars: 30000,
108
+ // Hard cap: a result larger than this is stubbed even for the turn that
109
+ // fetched it, since it would otherwise be resent in full on every remaining
110
+ // round and can blow the window on its own.
111
+ maxTurnChars: 120000,
112
+ // Hard window. If the history is still larger than this many tokens after
113
+ // compaction -- a long plain-text chat, where there is nothing to stub --
114
+ // the oldest turns stop being sent (they stay in the transcript), cutting
115
+ // back to half this size so it moves rarely. 0 disables it.
116
+ maxHistoryTokens: 100000,
117
+ // When that window drops turns, summarise them with the chat's model first
118
+ // and give the model the summary in their place. Off: they are just dropped.
119
+ llmCompaction: true,
120
+ // Output cap for that summary request. Reasoning models spend part of it
121
+ // thinking before they write, and a reply cut off by the cap is rejected,
122
+ // so this is headroom; the prompt itself asks for a short summary.
123
+ compactionMaxTokens: 8192,
124
+ // IANA zone the model is told the date in (and uses for automations). Empty
125
+ // means the server's own zone.
126
+ timezone: '',
127
+ // Which tool calls wait for the user (see src/config/approval.js).
128
+ // 'writes' ask before any call not declared read-only (default)
129
+ // 'all' ask before every call
130
+ // 'off' never ask
131
+ // Scheduled runs have no one to ask, so a call that would ask is refused.
132
+ toolApproval: 'writes',
133
+ // Per-tool overrides, by flat name: always ask / never ask.
134
+ confirmTools: [],
135
+ autoApproveTools: [],
136
+ // Cap on what one expand_context call may return.
137
+ expandCharBudget: 8000,
138
+ // Flat tool names switched off from the settings panel -- built-in or an
139
+ // MCP server's, named exactly as buildBody sends them. Filtered out of what
140
+ // is offered to the model; a server can still be reached by name, so this
141
+ // is a per-tool block, not a substitute for disabling the whole server.
142
+ disabledTools: [],
143
+ // Optional auth/RBAC. Off by default -- TinyWebUI stays single-user and
144
+ // local-first unless this is deliberately opted into for a managed deploy.
145
+ // 'none' no login: just you, on localhost (default)
146
+ // 'single' just you, behind a password (authPassword or
147
+ // $TINYWEBUI_PASSWORD; set it with `tinywebui set-password`)
148
+ // 'trusted-header' many users: an upstream gateway (Cloudflare Access,
149
+ // oauth2-proxy, Authelia, ...) signs people in with
150
+ // Google, Apple, SSO etc. and passes identity headers
151
+ authMode: 'none',
152
+ // Level 'single': an scrypt hash (never plaintext), from `tinywebui
153
+ // set-password`; never writable through /api/config.
154
+ authPassword: '',
155
+ // Signs session cookies. Auto-generated on first run when auth is enabled
156
+ // and this is empty; never sent to the frontend.
157
+ sessionSecret: '',
158
+ // Reserved for a native OAuth login ('multiuser'), not implemented yet --
159
+ // use 'trusted-header' behind a gateway for Google/Apple/SSO sign-in.
160
+ googleClientId: '',
161
+ googleClientSecret: '',
162
+ googleRedirectUri: '',
163
+ // Emails auto-approved as admin the first time they sign in -- how the
164
+ // first admin account is bootstrapped.
165
+ adminEmails: [],
166
+ sessionTtlDays: 30,
167
+ // Level 'trusted-header': an upstream gateway (Cloudflare Access, oauth2-
168
+ // proxy, ...) authenticates and injects identity headers on every request.
169
+ // Headers are believed only from peers inside trustedProxyCidrs; startup
170
+ // refuses an empty list. Users are provisioned on first sight.
171
+ trustedProxyCidrs: [],
172
+ trustedUserIdHeader: 'x-tinysuite-user-id',
173
+ trustedEmailHeader: 'x-tinysuite-email',
174
+ trustedNameHeader: 'x-tinysuite-name',
175
+ trustedRoleHeader: 'x-tinysuite-role',
176
+ // Where "log out" sends the browser (e.g. the Access logout URL).
177
+ logoutUrl: '',
178
+ // Extra origins allowed to send state-changing requests (e.g. an admin
179
+ // tool on another host). The page's own origin is always allowed.
180
+ allowedOrigins: [],
181
+ // false: refuse to start on a database that needs migrating; run
182
+ // `tinywebui migrate` as its own deployment step instead.
183
+ autoMigrate: true,
184
+ // true: nothing in the control plane can change from the UI or API --
185
+ // settings, tools, MCP servers. Change the files and reload instead.
186
+ frozen: false,
187
+ // read_document and search_chats ranking. 'lexical' is SQLite FTS5 BM25 and
188
+ // needs nothing extra. 'dense' and 'hybrid' use a local ONNX embedding
189
+ // bundle (fetched by `npm start` / `tinywebui models ensure`, or baked into
190
+ // the Docker image -- never by the server) and the onnxruntime-node +
191
+ // @huggingface/tokenizers optional dependencies. 'auto' is hybrid when both
192
+ // are there and lexical, with a warning, when they are not.
193
+ retrieval: {
194
+ mode: 'auto', // auto | lexical | dense | hybrid
195
+ model: 'fast', // fast | balanced | quality (TinySearch's presets) | multilingual, or a name with modelDir
196
+ modelDir: '', // bundle folder; default models/<preset> next to the config
197
+ modelSha256: '', // pin model.onnx; startup refuses a different file
198
+ denseWeight: 0.5, // hybrid: dense share of the fused ranking, BM25 gets the rest
199
+ rrfK: 60,
200
+ queryPrefix: '', // e.g. bge: 'Represent this sentence for searching relevant passages: '
201
+ documentPrefix: '',
202
+ // Small-to-big: PASSAGES are what read_document returns (every mode);
203
+ // CHUNKS are what gets embedded -- as long as the model reads (256
204
+ // tokens for fast, 512 for bge), so only their overlap is set here.
205
+ passageSize: 1800, // characters per passage
206
+ passageOverlap: 200, // characters shared by neighbouring passages
207
+ chunkOverlap: 32, // tokens shared by neighbouring chunks
208
+ },
209
+ };
210
+
211
+ // What an install with no mcp.json starts with: the TinySuite servers, which
212
+ // uv fetches and launches on first use. Any mcp.json, even an empty one,
213
+ // replaces them, so removing them is one save in the MCP panel.
214
+ export const DEFAULT_MCP_SERVERS = Object.freeze({
215
+ tinysearch: { command: 'uvx', args: ['--python', '3.12', '--from', 'tinysuite-search[server]', 'tinysearch'] },
216
+ tinycontext: { command: 'uvx', args: ['--python', '3.12', '--from', 'tinysuite-context[server]', 'tinycontext'] },
217
+ });
218
+
219
+ /**
220
+ * One instance's view of its configuration.
221
+ *
222
+ * Layers, lowest first: DEFAULTS < config file < env vars < `opts.config`.
223
+ * Keys set in code are locked: the UI cannot change them, since a saved value
224
+ * would be shadowed on the very next load anyway. `configFile: false` means no
225
+ * file is read or written; UI changes then live in memory for this process.
226
+ * `mcpServers` in code likewise replaces mcp.json and locks the MCP editor.
227
+ */
228
+ export function createConfigSource(opts = {}) {
229
+ const code = opts.config || {};
230
+ const file = opts.configFile === false ? null
231
+ : opts.configFile ? resolve(opts.configFile) : CONFIG_FILE;
232
+ const mcpFile = opts.mcpFile ? resolve(opts.mcpFile)
233
+ : file && file !== CONFIG_FILE ? resolve(dirname(file), 'mcp.json') : MCP_FILE;
234
+ const codeServers = opts.mcpServers || null;
235
+ const locked = new Set(Object.keys(code));
236
+ let memory = {}; // stands in for the file when there is none
237
+
238
+ const readFile = () => {
239
+ if (!file) return { ...memory };
240
+ if (!existsSync(file)) return {};
241
+ try {
242
+ return JSON.parse(readFileSync(file, 'utf8'));
243
+ } catch (err) {
244
+ throw new Error(`Bad config at ${file}: ${err.message}`);
245
+ }
246
+ };
247
+ const writeFile = (obj) => {
248
+ if (!file) { memory = obj; return; }
249
+ writeFileSync(file, JSON.stringify(obj, null, 2) + '\n');
250
+ };
251
+
252
+ function load({ persistSecret = true } = {}) {
253
+ const env = {};
254
+ if (process.env.TINYWEBUI_BASE_URL) env.baseUrl = process.env.TINYWEBUI_BASE_URL;
255
+ if (process.env.TINYWEBUI_API_KEY) env.apiKey = process.env.TINYWEBUI_API_KEY;
256
+ if (process.env.OPENROUTER_API_KEY) env.apiKey = process.env.OPENROUTER_API_KEY;
257
+ if (process.env.TINYWEBUI_MODEL) env.model = process.env.TINYWEBUI_MODEL;
258
+ // Secrets can come from the environment, so committed config never holds them.
259
+ if (process.env.TINYWEBUI_SESSION_SECRET) env.sessionSecret = process.env.TINYWEBUI_SESSION_SECRET;
260
+
261
+ const fromFile = readFile();
262
+ const cfg = { ...DEFAULTS, ...fromFile, ...env, ...code };
263
+ if (fromFile.access !== undefined || code.access !== undefined) cfg.access = mergeAccess(fromFile.access, code.access);
264
+ // Partial retrieval blocks fill in from the defaults rather than replace them.
265
+ // $TINYWEBUI_RETRIEVAL_MODE=lexical is the no-config way to opt out of hybrid.
266
+ const envRetrieval = process.env.TINYWEBUI_RETRIEVAL_MODE ? { mode: process.env.TINYWEBUI_RETRIEVAL_MODE } : {};
267
+ cfg.retrieval = { ...DEFAULTS.retrieval, ...(fromFile.retrieval || {}), ...envRetrieval, ...(code.retrieval || {}) };
268
+ // No silent fallbacks: a bad value is reported by configProblems() and
269
+ // refuses to start, rather than quietly becoming a default (a mistyped
270
+ // authMode used to become 'none').
271
+ if (typeof cfg.baseUrl === 'string') cfg.baseUrl = cfg.baseUrl.replace(/\/+$/, '');
272
+ // A session secret is required the moment auth is on; generate and persist
273
+ // one rather than signing cookies with an empty key.
274
+ if (cfg.authMode !== 'none' && !cfg.sessionSecret && persistSecret) {
275
+ cfg.sessionSecret = randomBytes(32).toString('hex');
276
+ try {
277
+ writeFile({ ...readFile(), sessionSecret: cfg.sessionSecret });
278
+ } catch (err) {
279
+ log.error(`could not persist generated sessionSecret: ${err.message}`);
280
+ }
281
+ }
282
+ return cfg;
283
+ }
284
+
285
+ /** Applies the writable, unlocked part of `patch`; locked keys are an error. */
286
+ function save(patch) {
287
+ if (isFrozen()) throw new LockedError('this deployment is frozen: change its files instead');
288
+ const fileOnly = Object.keys(patch).filter((k) => FILE_ONLY.has(k));
289
+ if (fileOnly.length) throw new LockedError(`set in files only, not editable here: ${fileOnly.join(', ')}`);
290
+ const blocked = Object.keys(patch).filter((k) => WRITABLE.has(k) && locked.has(k));
291
+ if (blocked.length) throw new LockedError(`set in code, not editable here: ${blocked.join(', ')}`);
292
+ const current = readFile();
293
+ for (const [k, v] of Object.entries(patch)) {
294
+ if (WRITABLE.has(k)) current[k] = v;
295
+ }
296
+ // Validate what the file would become before writing it.
297
+ const problems = configProblems({ ...DEFAULTS, ...current, ...code });
298
+ if (problems.length) throw new Error(problems.join('; '));
299
+ writeFile(current);
300
+ return load();
301
+ }
302
+
303
+ // `frozen` may be set in code or in the file; code wins, like any key.
304
+ function isFrozen() {
305
+ if (code.frozen !== undefined) return Boolean(code.frozen);
306
+ try { return Boolean(readFile().frozen); } catch { return false; }
307
+ }
308
+ const lockedNow = () => (isFrozen() ? new Set([...locked, ...WRITABLE]) : locked);
309
+
310
+ const readMcp = () => {
311
+ if (codeServers) return JSON.stringify({ mcpServers: codeServers }, null, 2) + '\n';
312
+ if (!existsSync(mcpFile)) return JSON.stringify({ mcpServers: DEFAULT_MCP_SERVERS }, null, 2) + '\n';
313
+ return readFileSync(mcpFile, 'utf8');
314
+ };
315
+
316
+ return {
317
+ load,
318
+ save,
319
+ public: (cfg) => ({
320
+ ...publicConfig(cfg), lockedKeys: [...lockedNow()], mcpLocked: Boolean(codeServers) || isFrozen(),
321
+ keyClasses: Object.fromEntries([...WRITABLE, ...FILE_ONLY].map((k) => [k, keyClass(k, lockedNow())]))
322
+ }),
323
+ lockedKeys: () => new Set(lockedNow()),
324
+ /** The raw mcp.json (or code) server map, throwing on bad JSON -- for tooling that must not guess. */
325
+ strictMcpServers() {
326
+ const parsed = JSON.parse(readMcp());
327
+ return parsed.mcpServers || parsed;
328
+ },
329
+ /** Users pinned in code's access.users: admins cannot overrule these. */
330
+ codeAccessUsers: () => ({ ...(code.access?.users || {}) }),
331
+ /** Low-level access for the policy write-back: read and replace the JSON file. */
332
+ readFile,
333
+ writeFile,
334
+ path: () => file,
335
+ mcpPath: () => (codeServers ? null : mcpFile),
336
+ get mcpLocked() { return Boolean(codeServers) || isFrozen(); },
337
+ isFrozen,
338
+ dbPath: (cfg) => {
339
+ const set = opts.dbPath || process.env.TINYWEBUI_DB || cfg?.dbPath;
340
+ if (set === ':memory:') return set;
341
+ return set ? resolve(set) : resolve(dirname(file || CONFIG_FILE), 'tinywebui.db');
342
+ },
343
+ /** Raw text of mcp.json, so the editor round-trips comments-free but verbatim. */
344
+ readMcpFile: readMcp,
345
+ loadMcpServers() {
346
+ try {
347
+ const parsed = JSON.parse(readMcp());
348
+ // Accept both {"mcpServers": {...}} and a bare {...} map.
349
+ return parsed.mcpServers || parsed || {};
350
+ } catch (err) {
351
+ log.error(`${mcpFile} is not valid JSON: ${err.message}`);
352
+ return {};
353
+ }
354
+ },
355
+ /** Validates before writing, so a typo cannot leave an unparseable file behind. */
356
+ saveMcpFile(text) {
357
+ if (codeServers) throw new LockedError('MCP servers are set in code, not editable here');
358
+ if (isFrozen()) throw new LockedError('this deployment is frozen: change mcp.json instead');
359
+ const parsed = JSON.parse(text);
360
+ const servers = parsed.mcpServers || parsed;
361
+ for (const [name, spec] of Object.entries(servers)) {
362
+ if (!spec || (!spec.command && !spec.url)) {
363
+ throw new Error(`"${name}" needs either "command" (stdio) or "url" (http)`);
364
+ }
365
+ }
366
+ writeFileSync(mcpFile, text.endsWith('\n') ? text : text + '\n');
367
+ return servers;
368
+ },
369
+ };
370
+ }
371
+
372
+ export class LockedError extends Error {}
373
+
374
+ // Keys that are not in DEFAULTS but are still settings.
375
+ const EXTRA_KEYS = new Set(['access']);
376
+ const ENUMS = {
377
+ authMode: ['none', 'single', 'multiuser', 'trusted-header'],
378
+ cacheTtl: ['5m', '1h'],
379
+ cacheMode: ['auto', 'implicit', 'explicit', 'rolling', 'off'],
380
+ reasoningReplay: REASONING_REPLAY,
381
+ toolApproval: ['writes', 'all', 'off'],
382
+ };
383
+ const kind = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);
384
+
385
+ /**
386
+ * Every problem with a loaded config: unknown keys (usually typos), values of
387
+ * the wrong type or outside their allowed set, plus the policy checks in
388
+ * validateConfig(). Startup, reload, save and `tinywebui validate` all refuse
389
+ * a config with any.
390
+ */
391
+ function retrievalProblems(r) {
392
+ if (!r || typeof r !== 'object' || Array.isArray(r)) return ['retrieval must be an object'];
393
+ const out = [];
394
+ for (const k of Object.keys(r)) if (!(k in DEFAULTS.retrieval)) out.push(`unknown setting "retrieval.${k}"`);
395
+ if (r.mode !== undefined && !['auto', 'lexical', 'dense', 'hybrid'].includes(r.mode)) out.push('retrieval.mode must be one of auto, lexical, dense, hybrid');
396
+ for (const k of ['model', 'modelDir', 'modelSha256', 'queryPrefix', 'documentPrefix']) {
397
+ if (r[k] !== undefined && typeof r[k] !== 'string') out.push(`retrieval.${k} must be a string`);
398
+ }
399
+ if (r.modelSha256 && !/^[0-9a-fA-F]{64}$/.test(r.modelSha256)) out.push('retrieval.modelSha256 must be a 64-character hex sha256');
400
+ if (r.denseWeight !== undefined && !(typeof r.denseWeight === 'number' && r.denseWeight > 0 && r.denseWeight < 1)) {
401
+ out.push('retrieval.denseWeight must be a number between 0 and 1 (exclusive); use mode "dense" or "lexical" for the extremes');
402
+ }
403
+ if (r.rrfK !== undefined && !(Number.isInteger(r.rrfK) && r.rrfK >= 0)) out.push('retrieval.rrfK must be a whole number >= 0');
404
+ const size = r.passageSize ?? DEFAULTS.retrieval.passageSize;
405
+ if (!(Number.isInteger(size) && size >= 200 && size <= 20000)) out.push('retrieval.passageSize must be a whole number of characters from 200 to 20000');
406
+ if (r.passageOverlap !== undefined && !(Number.isInteger(r.passageOverlap) && r.passageOverlap >= 0 && r.passageOverlap < size / 2)) {
407
+ out.push('retrieval.passageOverlap must be a whole number >= 0 and under half of passageSize');
408
+ }
409
+ if (r.chunkOverlap !== undefined && !(Number.isInteger(r.chunkOverlap) && r.chunkOverlap >= 0 && r.chunkOverlap <= 128)) {
410
+ out.push('retrieval.chunkOverlap must be a whole number of tokens from 0 to 128');
411
+ }
412
+ return out;
413
+ }
414
+
415
+ export function configProblems(cfg) {
416
+ const problems = [];
417
+ for (const [key, value] of Object.entries(cfg)) {
418
+ if (!(key in DEFAULTS) && !EXTRA_KEYS.has(key)) { problems.push(`unknown setting "${key}"`); continue; }
419
+ if (ENUMS[key]) {
420
+ if (!ENUMS[key].includes(value)) problems.push(`${key} must be one of ${ENUMS[key].join(', ')} (got ${JSON.stringify(value)})`);
421
+ continue;
422
+ }
423
+ if (key === 'models') continue; // modelProblems, below, knows the shape
424
+ if (key === 'retrieval') {
425
+ problems.push(...retrievalProblems(value));
426
+ continue;
427
+ }
428
+ if (key === 'frozen' || key === 'autoMigrate') {
429
+ if (typeof value !== 'boolean') problems.push(`${key} must be true or false`);
430
+ continue;
431
+ }
432
+ if (!(key in DEFAULTS)) continue;
433
+ if (key === 'compactionMaxTokens') {
434
+ if (!(Number.isInteger(value) && value >= 256)) problems.push('compactionMaxTokens must be a whole number >= 256');
435
+ continue;
436
+ }
437
+ if (key === 'askUserTimeoutSeconds') {
438
+ if (!(Number.isInteger(value) && value >= 0)) problems.push('askUserTimeoutSeconds must be a whole number of seconds >= 0 (0: no timeout)');
439
+ continue;
440
+ }
441
+ const want = kind(DEFAULTS[key]);
442
+ const got = kind(value);
443
+ // A null default is an optional number (temperature, maxTokens).
444
+ const ok = want === 'null' ? got === 'null' || (got === 'number' && Number.isFinite(value))
445
+ : want === 'number' ? got === 'number' && Number.isFinite(value)
446
+ : got === want;
447
+ if (!ok) problems.push(`${key} must be ${want === 'null' ? 'a number or null' : `a${want === 'array' || want === 'object' ? 'n' : ''} ${want}`} (got ${got})`);
448
+ }
449
+ return [...problems, ...modelProblems(cfg), ...validateConfig(cfg)];
450
+ }
451
+
452
+ // Only the knobs the UI is allowed to change. Secrets stay server-side.
453
+ export const WRITABLE = new Set([
454
+ 'model', 'systemPrompt', 'temperature', 'maxTokens', 'maxToolRounds', 'askUserTimeoutSeconds',
455
+ 'cacheTtl', 'cacheMode', 'compactThreshold', 'keepTurns', 'maxInlineChars',
456
+ 'compactMinSaved', 'maxTurnChars', 'maxHistoryTokens', 'timezone',
457
+ 'toolApproval', 'confirmTools', 'autoApproveTools', 'disabledTools'
458
+ ]);
459
+
460
+ export function publicConfig(cfg) {
461
+ const { apiKey, authPassword, sessionSecret, googleClientSecret, ...rest } = cfg;
462
+ return {
463
+ ...rest,
464
+ modelLabel: labelFor(cfg, cfg.model),
465
+ hasApiKey: Boolean(apiKey),
466
+ hasGoogleAuth: Boolean(cfg.googleClientId),
467
+ };
468
+ }
469
+
470
+ // The file-driven instance the CLI has always used, kept as plain functions.
471
+ const fileSource = createConfigSource();
472
+ export const configPath = () => fileSource.path();
473
+ export const mcpPath = () => fileSource.mcpPath();
474
+ export const dbPath = (cfg) => fileSource.dbPath(cfg);
475
+ export const readMcpFile = () => fileSource.readMcpFile();
476
+ export const loadMcpServers = () => fileSource.loadMcpServers();
477
+ export const saveMcpFile = (text) => fileSource.saveMcpFile(text);
478
+ export const loadConfig = () => fileSource.load();
479
+ export const saveConfig = (patch) => fileSource.save(patch);
@@ -0,0 +1,112 @@
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
+ /** Per-model settings that replace the global value for that model's turns. */
21
+ export const MODEL_OVERRIDES = ['systemPrompt', 'temperature', 'maxTokens', 'maxToolRounds', 'cacheMode', 'cacheTtl', 'extraBody', 'reasoningReplay'];
22
+
23
+ /** Values of `reasoningReplay`; see reasoningReplay in harness/provider.js. */
24
+ export const REASONING_REPLAY = ['auto', 'omit', 'reasoning_content', 'reasoning_details'];
25
+
26
+ const ENTRY_KEYS = new Set(['id', 'model', 'label', 'description', 'enabled', ...MODEL_OVERRIDES]);
27
+
28
+ export const catalog = (cfg) => (Array.isArray(cfg.models) ? cfg.models : []);
29
+ // Closed whenever entries are listed, even if all are disabled: parking every
30
+ // model must not reopen the picker to the provider's whole list.
31
+ export const isClosed = (cfg) => catalog(cfg).length > 0;
32
+ export const enabledEntries = (cfg) => catalog(cfg).filter((e) => e.enabled !== false);
33
+
34
+ /** The enabled entry `key` names: by id, or by its provider model id (the default `model` may be either). */
35
+ export function findEntry(cfg, key) {
36
+ const list = enabledEntries(cfg);
37
+ return list.find((e) => e.id === key) || list.find((e) => (e.model || e.id) === key) || null;
38
+ }
39
+
40
+ /** What people call a model: its label in the catalog, else the id itself. */
41
+ export function labelFor(cfg, key) {
42
+ const e = findEntry(cfg, key);
43
+ return e ? e.label || e.id : key;
44
+ }
45
+
46
+ /** What the picker lists for one entry. Prompts and knobs stay server-side. */
47
+ export const publicEntry = (e) => ({ id: e.id, name: e.label || e.id, description: e.description || null });
48
+
49
+ /**
50
+ * The config one turn runs with: the entry's overrides over the global
51
+ * settings and its provider id as `model`. extraBody merges, so an entry can
52
+ * add a reasoning knob without restating the gateway's routing.
53
+ */
54
+ export function effectiveConfig(cfg, key) {
55
+ const e = findEntry(cfg, key ?? cfg.model);
56
+ if (!e) return key && key !== cfg.model ? { ...cfg, model: key } : cfg;
57
+ const out = { ...cfg };
58
+ for (const k of MODEL_OVERRIDES) if (e[k] !== undefined) out[k] = e[k];
59
+ out.extraBody = { ...(cfg.extraBody || {}), ...(e.extraBody || {}) };
60
+ // `provider` merges one level deeper: a global data policy (zdr,
61
+ // data_collection) must survive an entry that only pins its routing.
62
+ if (cfg.extraBody?.provider || e.extraBody?.provider) {
63
+ out.extraBody.provider = { ...(cfg.extraBody?.provider || {}), ...(e.extraBody?.provider || {}) };
64
+ }
65
+ out.model = e.model || e.id;
66
+ return out;
67
+ }
68
+
69
+ const NUMERIC = { temperature: 'a number or null', maxTokens: 'a number or null' };
70
+
71
+ /** Every problem with the catalog and what refers into it. */
72
+ export function modelProblems(cfg) {
73
+ const list = cfg.models;
74
+ if (list === undefined) return [];
75
+ if (!Array.isArray(list)) return ['models must be a list of { id, model?, label?, ... } entries'];
76
+ const out = [];
77
+ const ids = new Set();
78
+ list.forEach((e, i) => {
79
+ const at = `models[${i}]`;
80
+ if (!e || typeof e !== 'object' || Array.isArray(e)) { out.push(`${at} must be an object`); return; }
81
+ if (typeof e.id !== 'string' || !e.id.trim()) { out.push(`${at}.id must be a non-empty string`); return; }
82
+ if (ids.has(e.id)) out.push(`${at}.id "${e.id}" is listed twice`);
83
+ ids.add(e.id);
84
+ for (const k of Object.keys(e)) if (!ENTRY_KEYS.has(k)) out.push(`${at}.${k} is not a known setting (known: ${[...ENTRY_KEYS].join(', ')})`);
85
+ for (const k of ['model', 'label', 'description', 'systemPrompt']) {
86
+ if (e[k] !== undefined && typeof e[k] !== 'string') out.push(`${at}.${k} must be a string`);
87
+ }
88
+ for (const k of Object.keys(NUMERIC)) {
89
+ if (e[k] !== undefined && e[k] !== null && !(typeof e[k] === 'number' && Number.isFinite(e[k]))) out.push(`${at}.${k} must be ${NUMERIC[k]}`);
90
+ }
91
+ if (e.maxToolRounds !== undefined && !(Number.isInteger(e.maxToolRounds) && e.maxToolRounds >= 0)) out.push(`${at}.maxToolRounds must be a whole number >= 0`);
92
+ 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`);
93
+ if (e.reasoningReplay !== undefined && !REASONING_REPLAY.includes(e.reasoningReplay)) out.push(`${at}.reasoningReplay must be one of ${REASONING_REPLAY.join(', ')}`);
94
+ if (e.cacheTtl !== undefined && !['5m', '1h'].includes(e.cacheTtl)) out.push(`${at}.cacheTtl must be 5m or 1h`);
95
+ if (e.enabled !== undefined && typeof e.enabled !== 'boolean') out.push(`${at}.enabled must be true or false`);
96
+ if (e.extraBody !== undefined && !(e.extraBody && typeof e.extraBody === 'object' && !Array.isArray(e.extraBody))) out.push(`${at}.extraBody must be an object`);
97
+ });
98
+ if (!list.length || out.length) return out;
99
+ // A closed catalog: everything that names a model has to name an entry.
100
+ // Role lists may name disabled entries (they just drop out); the default may not.
101
+ const on = enabledEntries(cfg).map((e) => e.id);
102
+ if (!on.length) out.push('every entry in models is disabled: enable at least one');
103
+ else if (!findEntry(cfg, cfg.model)) {
104
+ const off = list.some((e) => e.id === cfg.model || e.model === cfg.model);
105
+ out.push(`model "${cfg.model}" ${off ? 'is disabled' : 'is not in the models catalog'}; enabled ids: ${on.join(', ')}`);
106
+ }
107
+ for (const [role, spec] of Object.entries(cfg.access?.roles || {})) {
108
+ if (!Array.isArray(spec?.models)) continue;
109
+ for (const m of spec.models) if (!ids.has(m)) out.push(`access.roles.${role}.models: "${m}" is not a models catalog id`);
110
+ }
111
+ return out;
112
+ }