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,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The side panels: one open at a time, each toggled by its nav button and
|
|
3
|
+
* closed by its close button or Escape. This is the only code that opens or
|
|
4
|
+
* closes one; everything else calls openPanel/closePanel.
|
|
5
|
+
*
|
|
6
|
+
* Element ids follow the name: the panel is #<name>, its nav button
|
|
7
|
+
* #toggle-<name>, its close button #close-<name> unless the panel names another.
|
|
8
|
+
*/
|
|
9
|
+
import { $ } from '../core/dom.js';
|
|
10
|
+
|
|
11
|
+
export const PANEL = Object.freeze({
|
|
12
|
+
SETTINGS: 'settings',
|
|
13
|
+
MCP: 'mcp',
|
|
14
|
+
AUTOMATIONS: 'automations',
|
|
15
|
+
STATISTICS: 'statistics',
|
|
16
|
+
ADMIN: 'admin'
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const panels = new Map(); // name -> { onOpen?, onClose?, closeId? }
|
|
20
|
+
|
|
21
|
+
const isOpen = (name) => $(name).classList.contains('open');
|
|
22
|
+
|
|
23
|
+
function show(name, on) {
|
|
24
|
+
$(name).classList.toggle('open', on);
|
|
25
|
+
$(`toggle-${name}`).classList.toggle('active', on);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function openPanel(name) {
|
|
29
|
+
if (isOpen(name)) return;
|
|
30
|
+
for (const other of panels.keys()) if (other !== name) closePanel(other, { swapping: true });
|
|
31
|
+
show(name, true);
|
|
32
|
+
await panels.get(name).onOpen?.();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `swapping`: another panel is taking its place, so focus belongs there. */
|
|
36
|
+
export function closePanel(name, { swapping = false } = {}) {
|
|
37
|
+
if (!isOpen(name)) return;
|
|
38
|
+
show(name, false);
|
|
39
|
+
panels.get(name).onClose?.({ swapping });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `defs`: { [PANEL.*]: { onOpen?, onClose?({ swapping }), closeId? } } */
|
|
43
|
+
export function initPanels(defs) {
|
|
44
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
45
|
+
panels.set(name, def);
|
|
46
|
+
$(`toggle-${name}`).onclick = () => (isOpen(name) ? closePanel(name) : openPanel(name));
|
|
47
|
+
$(def.closeId || `close-${name}`).onclick = () => closePanel(name);
|
|
48
|
+
}
|
|
49
|
+
document.addEventListener('keydown', (event) => {
|
|
50
|
+
if (event.key === 'Escape') for (const name of panels.keys()) closePanel(name);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/** The settings panel: model/runtime config, MCP servers, and the tools list. */
|
|
2
|
+
import { $, el, num, installLink } from '../core/dom.js';
|
|
3
|
+
import { addError } from '../chat/transcript.js';
|
|
4
|
+
import { whoami } from '../core/access.js';
|
|
5
|
+
import { api } from '../core/api.js';
|
|
6
|
+
import { PANEL, closePanel } from './panels.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The tools panel: built-ins first, then every MCP server with its own health
|
|
10
|
+
* and its tools under it. Each tool and each server carries a checkbox --
|
|
11
|
+
* unchecking a tool drops it from what the model is offered; unchecking a
|
|
12
|
+
* server disconnects it outright, which is one fewer live connection rather
|
|
13
|
+
* than just one the model is not shown.
|
|
14
|
+
*
|
|
15
|
+
* Rebuilt wholesale from a fresh /api/tools payload after every toggle, so the
|
|
16
|
+
* panel can never drift from what the server actually did with the request.
|
|
17
|
+
*/
|
|
18
|
+
// Set from /api/config: a non-admin sees the deployment's settings but
|
|
19
|
+
// cannot change them.
|
|
20
|
+
let readOnly = false;
|
|
21
|
+
export const isReadOnly = () => readOnly;
|
|
22
|
+
// The deployment's model as last loaded; the composer's model menu starts from it.
|
|
23
|
+
let model = '';
|
|
24
|
+
export const configuredModel = () => model;
|
|
25
|
+
|
|
26
|
+
function renderToolPanel(data) {
|
|
27
|
+
const box = $('tools');
|
|
28
|
+
box.innerHTML = '';
|
|
29
|
+
const mode = data.toolApproval || 'writes';
|
|
30
|
+
|
|
31
|
+
const enabled = data.internal.filter((t) => !t.disabled).length
|
|
32
|
+
+ data.servers.reduce((n, s) => n + s.tools.filter((t) => !t.disabled).length, 0);
|
|
33
|
+
const total = data.internal.length + data.servers.reduce((n, s) => n + s.tools.length, 0);
|
|
34
|
+
const up = data.servers.filter((s) => s.status === 'ok').length;
|
|
35
|
+
$('toolCount').textContent = total
|
|
36
|
+
? `${enabled}/${total} enabled · ${up}/${data.servers.length} server${data.servers.length === 1 ? '' : 's'} up`
|
|
37
|
+
: 'none';
|
|
38
|
+
|
|
39
|
+
if (data.internal.length) {
|
|
40
|
+
const grp = el('div', 'tool-group');
|
|
41
|
+
const h = el('div', 'tool-group-h');
|
|
42
|
+
h.textContent = 'built-in';
|
|
43
|
+
grp.appendChild(h);
|
|
44
|
+
for (const t of data.internal) grp.appendChild(toolRow(t, mode));
|
|
45
|
+
box.appendChild(grp);
|
|
46
|
+
}
|
|
47
|
+
for (const s of data.servers) box.appendChild(serverGroup(s, mode));
|
|
48
|
+
|
|
49
|
+
if (!data.internal.length && !data.servers.length) {
|
|
50
|
+
box.innerHTML = '<div class="empty">no tools</div>';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function toggleTool(name, disabled) {
|
|
55
|
+
try { renderToolPanel(await api.post('/api/tools/toggle', { name, disabled })); } catch (err) { addError(err.message); }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function toggleServer(name, disabled) {
|
|
59
|
+
try { renderToolPanel(await api.post(`/api/mcp/servers/${encodeURIComponent(name)}/toggle`, { disabled })); } catch (err) { return addError(err.message); }
|
|
60
|
+
loadMcp(); // the raw editor's disabled: true/false has to catch up too
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A checkbox living inside a <summary> still triggers the details' native
|
|
64
|
+
// open/close on click, since that is the browser's default action for the
|
|
65
|
+
// element the click landed in, not a listener that stopPropagation alone
|
|
66
|
+
// would beat -- so the click itself has to be stopped from ever reaching it.
|
|
67
|
+
function checkbox(checked, title, onToggle) {
|
|
68
|
+
const label = el('label', 'tgl');
|
|
69
|
+
const cb = el('input');
|
|
70
|
+
cb.type = 'checkbox';
|
|
71
|
+
cb.checked = checked;
|
|
72
|
+
cb.title = title;
|
|
73
|
+
cb.onclick = (e) => e.stopPropagation();
|
|
74
|
+
cb.onchange = () => { cb.disabled = true; onToggle(!cb.checked); };
|
|
75
|
+
if (readOnly) cb.disabled = true;
|
|
76
|
+
label.appendChild(cb);
|
|
77
|
+
return label;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// What "default" resolves to for this tool under the global mode, so the
|
|
81
|
+
// select says what will actually happen rather than just "default".
|
|
82
|
+
function defaultApproval(t, mode) {
|
|
83
|
+
if (mode === 'off') return 'never';
|
|
84
|
+
if (mode === 'all') return 'ask';
|
|
85
|
+
return t.readOnly === true ? 'never (read-only)' : 'ask (may write)';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function approvalSelect(t, mode) {
|
|
89
|
+
const sel = el('select', 'approval');
|
|
90
|
+
sel.title = 'Whether calls to this tool wait for your approval';
|
|
91
|
+
for (const [value, label] of [
|
|
92
|
+
['default', `approval: ${defaultApproval(t, mode)}`],
|
|
93
|
+
['ask', 'approval: always ask'],
|
|
94
|
+
['auto', 'approval: never ask']
|
|
95
|
+
]) {
|
|
96
|
+
const o = el('option');
|
|
97
|
+
o.value = value;
|
|
98
|
+
o.textContent = label;
|
|
99
|
+
sel.appendChild(o);
|
|
100
|
+
}
|
|
101
|
+
sel.value = t.approval;
|
|
102
|
+
if (readOnly) sel.disabled = true;
|
|
103
|
+
// Same reason as the checkbox: a click in a <summary> toggles the details.
|
|
104
|
+
sel.onclick = (e) => e.stopPropagation();
|
|
105
|
+
sel.onchange = async () => {
|
|
106
|
+
sel.disabled = true;
|
|
107
|
+
try { renderToolPanel(await api.post('/api/tools/approval', { name: t.name, policy: sel.value })); } catch (err) {
|
|
108
|
+
sel.disabled = false;
|
|
109
|
+
addError(err.message);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
return sel;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function toolRow(t, mode) {
|
|
116
|
+
const d = el('details', 'tool' + (t.disabled ? ' off' : ''));
|
|
117
|
+
const sum = el('summary');
|
|
118
|
+
sum.appendChild(checkbox(
|
|
119
|
+
!t.disabled,
|
|
120
|
+
t.disabled ? 'Disabled -- click to let the model use this tool again' : 'Click to stop offering this tool to the model',
|
|
121
|
+
(disabled) => toggleTool(t.name, disabled)
|
|
122
|
+
));
|
|
123
|
+
const name = el('span', 'name');
|
|
124
|
+
name.textContent = t.name.replace(/^[^_]+__/, '');
|
|
125
|
+
const desc = el('span', 'desc');
|
|
126
|
+
desc.textContent = (t.description || '').split('\n')[0];
|
|
127
|
+
sum.append(name, desc);
|
|
128
|
+
// MCP tools only: built-ins never ask, so they have no approval state.
|
|
129
|
+
if (t.approval) sum.appendChild(approvalSelect(t, mode));
|
|
130
|
+
d.append(sum, toolDoc(t));
|
|
131
|
+
return d;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The expanded part of a tool row: its description, parameters and raw schema. */
|
|
135
|
+
function toolDoc(t) {
|
|
136
|
+
const doc = el('div', 'doc');
|
|
137
|
+
doc.textContent = t.description || '(no description)';
|
|
138
|
+
const props = t.parameters?.properties || {};
|
|
139
|
+
const required = new Set(t.parameters?.required || []);
|
|
140
|
+
if (Object.keys(props).length) {
|
|
141
|
+
const list = el('div', 'params');
|
|
142
|
+
for (const [key, spec] of Object.entries(props)) {
|
|
143
|
+
const row = el('div', 'param');
|
|
144
|
+
const b = el('b');
|
|
145
|
+
b.textContent = key;
|
|
146
|
+
const ty = el('span', 'ty');
|
|
147
|
+
ty.textContent = spec.type || (spec.anyOf ? 'any' : '?');
|
|
148
|
+
const req = el('span', required.has(key) ? 'req' : 'ty');
|
|
149
|
+
req.textContent = required.has(key) ? 'required' : 'optional';
|
|
150
|
+
const dd = el('span', 'd');
|
|
151
|
+
dd.textContent = (spec.description || '').split('\n')[0];
|
|
152
|
+
row.append(b, ty, req, dd);
|
|
153
|
+
list.appendChild(row);
|
|
154
|
+
}
|
|
155
|
+
doc.appendChild(list);
|
|
156
|
+
}
|
|
157
|
+
const raw = el('pre');
|
|
158
|
+
raw.textContent = JSON.stringify(t.parameters ?? {}, null, 2);
|
|
159
|
+
doc.appendChild(raw);
|
|
160
|
+
return doc;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const SERVER_STATUS_LABEL = { ok: 'connected', disabled: 'disabled', error: 'failed to connect' };
|
|
164
|
+
|
|
165
|
+
function serverGroup(s, mode) {
|
|
166
|
+
const grp = el('div', `tool-group server ${s.status}`);
|
|
167
|
+
const h = el('div', 'tool-group-h');
|
|
168
|
+
h.appendChild(checkbox(
|
|
169
|
+
s.status !== 'disabled',
|
|
170
|
+
s.status === 'disabled' ? 'Disabled -- click to reconnect' : 'Click to disconnect this server',
|
|
171
|
+
(disabled) => toggleServer(s.name, disabled)
|
|
172
|
+
));
|
|
173
|
+
const dot = el('span', 'dot');
|
|
174
|
+
const name = el('span', 'name');
|
|
175
|
+
name.textContent = s.name;
|
|
176
|
+
const meta = el('span', 'meta');
|
|
177
|
+
meta.textContent = s.status === 'ok'
|
|
178
|
+
? `${s.tools.length} tool${s.tools.length === 1 ? '' : 's'}`
|
|
179
|
+
: s.install ? `needs ${s.install.name}` : SERVER_STATUS_LABEL[s.status];
|
|
180
|
+
h.append(dot, name, meta);
|
|
181
|
+
grp.appendChild(h);
|
|
182
|
+
|
|
183
|
+
if (s.status === 'error' && s.error) {
|
|
184
|
+
const err = el('div', 'tool-group-err');
|
|
185
|
+
err.textContent = s.error;
|
|
186
|
+
if (s.install) err.append(' ', installLink(s.install));
|
|
187
|
+
grp.appendChild(err);
|
|
188
|
+
}
|
|
189
|
+
if (s.status === 'ok' && s.instructions) {
|
|
190
|
+
const note = el('details', 'tool-group-instructions');
|
|
191
|
+
const sum = el('summary');
|
|
192
|
+
sum.textContent = 'instructions';
|
|
193
|
+
const body = el('div', 'body');
|
|
194
|
+
body.textContent = s.instructions;
|
|
195
|
+
note.append(sum, body);
|
|
196
|
+
grp.appendChild(note);
|
|
197
|
+
}
|
|
198
|
+
if (s.status === 'ok') {
|
|
199
|
+
if (!s.tools.length) {
|
|
200
|
+
const empty = el('div', 'empty');
|
|
201
|
+
empty.textContent = 'no tools';
|
|
202
|
+
grp.appendChild(empty);
|
|
203
|
+
} else {
|
|
204
|
+
for (const t of s.tools) grp.appendChild(toolRow(t, mode));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return grp;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function loadTools() {
|
|
211
|
+
let data;
|
|
212
|
+
try { data = await api.get('/api/tools'); } catch (err) {
|
|
213
|
+
$('tools').replaceChildren(Object.assign(el('div', 'empty'), { textContent: `Couldn't load tools (${err.message}).` }));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
renderToolPanel(data);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function loadMcp() {
|
|
220
|
+
let out;
|
|
221
|
+
try { out = await api.get('/api/mcp'); } catch (err) {
|
|
222
|
+
$('mcpMsg').textContent = `Couldn't load MCP config (${err.message}).`;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const { path, text, locked, readOnly: managed } = out;
|
|
226
|
+
$('mcpText').value = text;
|
|
227
|
+
$('mcpText').readOnly = Boolean(locked);
|
|
228
|
+
$('saveMcp').disabled = Boolean(locked);
|
|
229
|
+
$('mcpPath').textContent = managed ? 'managed by your administrator'
|
|
230
|
+
: locked ? 'set in code (read-only)' : path;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function initSettings() {
|
|
234
|
+
$('saveMcp').onclick = saveMcp;
|
|
235
|
+
$('save').onclick = saveConfig;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function saveMcp() {
|
|
239
|
+
const btn = $('saveMcp');
|
|
240
|
+
btn.disabled = true;
|
|
241
|
+
btn.textContent = 'reconnecting…';
|
|
242
|
+
try {
|
|
243
|
+
const out = await api.post('/api/mcp', { text: $('mcpText').value });
|
|
244
|
+
await loadTools();
|
|
245
|
+
$('mcpMsg').textContent = out.mcpErrors?.length ? out.mcpErrors.join('; ') : 'connected';
|
|
246
|
+
await loadConfig();
|
|
247
|
+
} catch (err) {
|
|
248
|
+
$('mcpMsg').textContent = err.message;
|
|
249
|
+
} finally {
|
|
250
|
+
btn.disabled = false;
|
|
251
|
+
btn.textContent = 'save & reconnect';
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const FORM_KEYS = ['systemPrompt', 'model', 'temperature', 'maxTokens', 'timezone', 'maxToolRounds', 'askUserTimeoutSeconds', 'cacheTtl',
|
|
256
|
+
'cacheMode', 'compactThreshold', 'keepTurns', 'maxInlineChars', 'maxTurnChars', 'compactMinSaved',
|
|
257
|
+
'maxHistoryTokens', 'toolApproval'];
|
|
258
|
+
|
|
259
|
+
// The browser's IANA zones, plus whatever the config holds if the browser
|
|
260
|
+
// doesn't list it, so a saved value is never silently swapped for another.
|
|
261
|
+
function fillTimezones(current) {
|
|
262
|
+
const sel = $('timezone');
|
|
263
|
+
if (sel.options.length === 1) {
|
|
264
|
+
for (const tz of Intl.supportedValuesOf?.('timeZone') ?? []) sel.appendChild(Object.assign(el('option'), { value: tz, textContent: tz }));
|
|
265
|
+
}
|
|
266
|
+
if (current && ![...sel.options].some((o) => o.value === current)) {
|
|
267
|
+
sel.appendChild(Object.assign(el('option'), { value: current, textContent: current }));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Keys the server was started with in code; shown read-only and never posted.
|
|
272
|
+
let lockedKeys = new Set();
|
|
273
|
+
|
|
274
|
+
/** Whether a setting is pinned in code, so the UI cannot change it. */
|
|
275
|
+
export const isLocked = (key) => readOnly || lockedKeys.has(key);
|
|
276
|
+
|
|
277
|
+
export async function loadConfig() {
|
|
278
|
+
let cfg;
|
|
279
|
+
try { cfg = await api.get('/api/config'); } catch (err) {
|
|
280
|
+
// Without the lock list the form can't be edited safely; keep it read-only.
|
|
281
|
+
for (const id of FORM_KEYS) $(id).disabled = true;
|
|
282
|
+
$('save').disabled = true;
|
|
283
|
+
$('saveMsg').textContent = `Couldn't load settings (${err.message}).`;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
lockedKeys = new Set(cfg.lockedKeys || []);
|
|
287
|
+
readOnly = Boolean(cfg.readOnly);
|
|
288
|
+
for (const id of FORM_KEYS) {
|
|
289
|
+
const locked = readOnly || lockedKeys.has(id);
|
|
290
|
+
$(id).disabled = locked;
|
|
291
|
+
$(id).title = readOnly ? 'managed by your administrator' : locked ? 'set in code' : '';
|
|
292
|
+
}
|
|
293
|
+
$('save').disabled = readOnly || Boolean(cfg.frozen);
|
|
294
|
+
if (readOnly) $('saveMsg').textContent = 'managed by your administrator';
|
|
295
|
+
else if (cfg.frozen) $('saveMsg').textContent = 'frozen deployment: settings are managed in its files';
|
|
296
|
+
if (cfg.frozen) for (const id of FORM_KEYS) $(id).title = 'frozen deployment: change the config file';
|
|
297
|
+
$('systemPrompt').value = cfg.systemPrompt;
|
|
298
|
+
model = cfg.model;
|
|
299
|
+
$('model').value = cfg.model;
|
|
300
|
+
$('temperature').value = cfg.temperature ?? '';
|
|
301
|
+
$('maxTokens').value = cfg.maxTokens ?? '';
|
|
302
|
+
$('maxToolRounds').value = cfg.maxToolRounds;
|
|
303
|
+
$('askUserTimeoutSeconds').value = cfg.askUserTimeoutSeconds ?? 120;
|
|
304
|
+
$('cacheTtl').value = cfg.cacheTtl ?? '5m';
|
|
305
|
+
$('compactThreshold').value = cfg.compactThreshold ?? 0;
|
|
306
|
+
$('keepTurns').value = cfg.keepTurns ?? 2;
|
|
307
|
+
$('maxInlineChars').value = cfg.maxInlineChars ?? 0;
|
|
308
|
+
$('maxTurnChars').value = cfg.maxTurnChars ?? 0;
|
|
309
|
+
$('compactMinSaved').value = cfg.compactMinSaved ?? 0;
|
|
310
|
+
$('maxHistoryTokens').value = cfg.maxHistoryTokens ?? 0;
|
|
311
|
+
$('cacheMode').value = cfg.cacheMode ?? 'auto';
|
|
312
|
+
fillTimezones(cfg.timezone);
|
|
313
|
+
$('timezone').value = cfg.timezone ?? '';
|
|
314
|
+
$('toolApproval').value = cfg.toolApproval ?? 'writes';
|
|
315
|
+
// The chip is the model picker's button now; the tool count lives in the
|
|
316
|
+
// + menu, where the tools themselves are.
|
|
317
|
+
// Tier 3 shows your own model; the configured one is only the default.
|
|
318
|
+
const me = whoami();
|
|
319
|
+
const bits = [me.authMode === 'trusted-header' && me.model ? me.modelLabel || me.model : cfg.modelLabel || cfg.model];
|
|
320
|
+
if (!cfg.hasApiKey) bits.push('NO API KEY');
|
|
321
|
+
if (cfg.mcpErrors?.length) bits.push(`${cfg.mcpErrors.length} mcp err`);
|
|
322
|
+
$('status').textContent = bits.join(' · ');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function saveConfig() {
|
|
326
|
+
const patch = {
|
|
327
|
+
systemPrompt: $('systemPrompt').value,
|
|
328
|
+
model: $('model').value.trim(),
|
|
329
|
+
temperature: num($('temperature').value),
|
|
330
|
+
maxTokens: num($('maxTokens').value),
|
|
331
|
+
maxToolRounds: Number($('maxToolRounds').value) || 12,
|
|
332
|
+
askUserTimeoutSeconds: Math.max(0, Math.round(Number($('askUserTimeoutSeconds').value) || 0)),
|
|
333
|
+
cacheTtl: $('cacheTtl').value,
|
|
334
|
+
compactThreshold: Number($('compactThreshold').value) || 0,
|
|
335
|
+
keepTurns: Number($('keepTurns').value) || 2,
|
|
336
|
+
maxInlineChars: Number($('maxInlineChars').value) || 0,
|
|
337
|
+
maxTurnChars: Number($('maxTurnChars').value) || 0,
|
|
338
|
+
compactMinSaved: Number($('compactMinSaved').value) || 0,
|
|
339
|
+
maxHistoryTokens: Number($('maxHistoryTokens').value) || 0,
|
|
340
|
+
cacheMode: $('cacheMode').value,
|
|
341
|
+
timezone: $('timezone').value,
|
|
342
|
+
toolApproval: $('toolApproval').value
|
|
343
|
+
};
|
|
344
|
+
for (const k of lockedKeys) delete patch[k];
|
|
345
|
+
try { await api.post('/api/config', patch); } catch (err) {
|
|
346
|
+
$('saveMsg').textContent = `save failed (${err.message})`;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
await loadConfig();
|
|
350
|
+
await loadTools(); // the per-tool "default" labels follow the global mode
|
|
351
|
+
$('saveMsg').textContent = 'saved';
|
|
352
|
+
closePanel(PANEL.SETTINGS);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The panel's onOpen: a stale "saved" would read as this visit's. */
|
|
356
|
+
export function onSettingsOpen() {
|
|
357
|
+
if (!$('save').disabled) $('saveMsg').textContent = '';
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The panel's onClose. */
|
|
361
|
+
export function onSettingsClose({ swapping }) {
|
|
362
|
+
// A panel swap should leave focus to the panel being opened. Focusing the
|
|
363
|
+
// composer here would otherwise raise the mobile keyboard behind it.
|
|
364
|
+
if (!swapping) $('input').focus();
|
|
365
|
+
}
|