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.
- package/LICENSE +661 -0
- package/README.md +586 -0
- package/bin/tinywebui.js +105 -0
- package/package.json +49 -0
- package/public/app.js +81 -0
- package/public/chat/attachments.js +304 -0
- package/public/chat/chat.js +282 -0
- package/public/chat/composer.js +289 -0
- package/public/chat/library.js +76 -0
- package/public/chat/outline.js +39 -0
- package/public/chat/queue.js +124 -0
- package/public/chat/sidebar.js +485 -0
- package/public/chat/stream.js +162 -0
- package/public/chat/tasks.js +36 -0
- package/public/chat/transcript.js +691 -0
- package/public/core/access.js +42 -0
- package/public/core/api.js +27 -0
- package/public/core/dom.js +13 -0
- package/public/core/id.js +7 -0
- package/public/core/md.js +131 -0
- package/public/core/state.js +31 -0
- package/public/favicon.svg +9 -0
- package/public/index.html +261 -0
- package/public/panels/admin.js +199 -0
- package/public/panels/automations.js +464 -0
- package/public/panels/panels.js +52 -0
- package/public/panels/settings.js +365 -0
- package/public/panels/usage.js +474 -0
- package/public/shared/attachment_note.js +23 -0
- package/public/shared/events.js +27 -0
- package/public/shared/features.js +21 -0
- package/public/shared/origins.js +8 -0
- package/public/shared/usage.js +59 -0
- package/public/styles.css +1455 -0
- package/public/themes/README.md +178 -0
- package/public/themes/cyber-grid/cyber-grid.css +59 -0
- package/public/themes/drivers/scene-kit.js +422 -0
- package/public/themes/fall-fairy/fall-fairy.css +221 -0
- package/public/themes/fall-fairy/fall-fairy.js +859 -0
- package/public/themes/manifest.json +6 -0
- package/public/themes/theme-loader.js +123 -0
- package/src/access/auth.js +228 -0
- package/src/access/auth_gate.js +67 -0
- package/src/access/policy.js +180 -0
- package/src/audit.js +7 -0
- package/src/automations/automation.js +136 -0
- package/src/automations/scheduler.js +147 -0
- package/src/chat/compact.js +242 -0
- package/src/chat/llm.js +448 -0
- package/src/chat/runs.js +180 -0
- package/src/chat/tool_executor.js +239 -0
- package/src/cli.js +478 -0
- package/src/config/approval.js +54 -0
- package/src/config/config.js +479 -0
- package/src/config/models.js +112 -0
- package/src/files/documents.js +91 -0
- package/src/files/images.js +62 -0
- package/src/harness/agent.js +115 -0
- package/src/harness/attribution.js +75 -0
- package/src/harness/checkpoint.js +96 -0
- package/src/harness/history.js +40 -0
- package/src/harness/prompt.js +93 -0
- package/src/harness/provider.js +430 -0
- package/src/http.js +95 -0
- package/src/log.js +24 -0
- package/src/mcp.js +405 -0
- package/src/retrieval/embedding.js +298 -0
- package/src/retrieval/retrieval.js +250 -0
- package/src/routes/admin.js +112 -0
- package/src/routes/auth.js +97 -0
- package/src/routes/automations.js +92 -0
- package/src/routes/chats.js +232 -0
- package/src/routes/documents.js +133 -0
- package/src/routes/library.js +51 -0
- package/src/routes/mcp.js +59 -0
- package/src/routes/settings.js +101 -0
- package/src/server.js +397 -0
- package/src/set-password.js +32 -0
- package/src/store/automations.js +119 -0
- package/src/store/chats.js +254 -0
- package/src/store/documents.js +256 -0
- package/src/store/embeddings.js +67 -0
- package/src/store/index.js +90 -0
- package/src/store/messages.js +93 -0
- package/src/store/schema.js +480 -0
- package/src/store/scope.js +43 -0
- package/src/store/search.js +56 -0
- package/src/store/turns.js +78 -0
- package/src/store/usage.js +327 -0
- package/src/store/users.js +102 -0
- package/src/store/wire.js +55 -0
- package/src/tools/ask_tool.js +93 -0
- package/src/tools/chat_search_tool.js +63 -0
- package/src/tools/context_tool.js +183 -0
- package/src/tools/document_tool.js +102 -0
- 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
|
+
}
|