tinywebui 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -13
- package/bin/tinywebui.js +69 -53
- package/package.json +25 -8
- package/public/app.js +40 -34
- package/public/chat/composer.js +1 -1
- package/public/chat/sidebar.js +8 -5
- package/public/index.html +88 -53
- package/public/panels/admin.js +40 -1
- package/public/panels/model-editor.js +336 -0
- package/public/panels/panels.js +37 -12
- package/public/panels/settings-home.js +125 -0
- package/public/panels/settings.js +124 -36
- package/public/shared/attachment_note.js +7 -1
- package/public/shared/events.js +1 -0
- package/public/shared/features.js +1 -0
- package/public/shared/origins.js +1 -0
- package/public/shared/usage.js +16 -1
- package/public/styles.css +84 -21
- package/src/access/auth.js +155 -155
- package/src/access/auth_gate.js +39 -38
- package/src/access/email.js +17 -0
- package/src/access/policy.js +123 -136
- package/src/audit.js +1 -1
- package/src/automations/automation.js +135 -123
- package/src/automations/scheduler.js +138 -127
- package/src/chat/compact.js +149 -156
- package/src/chat/llm.js +380 -382
- package/src/chat/runs.js +162 -151
- package/src/chat/tool_executor.js +229 -218
- package/src/cli.js +535 -433
- package/src/config/approval.js +35 -29
- package/src/config/config.js +546 -454
- package/src/config/data_dir.js +24 -0
- package/src/config/models.js +191 -115
- package/src/config/refs.js +32 -0
- package/src/config/secrets.js +153 -0
- package/src/files/documents.js +57 -68
- package/src/files/images.js +30 -31
- package/src/harness/agent.js +60 -60
- package/src/harness/attribution.js +66 -58
- package/src/harness/checkpoint.js +67 -62
- package/src/harness/history.js +28 -23
- package/src/harness/prompt.js +40 -57
- package/src/harness/provider.js +284 -283
- package/src/harness/types.js +1 -0
- package/src/http.js +90 -72
- package/src/log.js +11 -12
- package/src/mcp.js +398 -374
- package/src/retrieval/embedding.js +228 -240
- package/src/retrieval/retrieval.js +182 -173
- package/src/routes/admin.js +178 -138
- package/src/routes/auth.js +184 -111
- package/src/routes/automations.js +99 -82
- package/src/routes/chats.js +240 -218
- package/src/routes/documents.js +130 -120
- package/src/routes/library.js +43 -46
- package/src/routes/mcp.js +49 -49
- package/src/routes/settings.js +127 -122
- package/src/routes/types.js +1 -0
- package/src/server.js +426 -415
- package/src/store/automations.js +106 -106
- package/src/store/chats.js +217 -242
- package/src/store/documents.js +181 -185
- package/src/store/embeddings.js +52 -54
- package/src/store/index.js +95 -49
- package/src/store/messages.js +55 -80
- package/src/store/schema.js +128 -107
- package/src/store/scope.js +18 -17
- package/src/store/search.js +25 -27
- package/src/store/turns.js +53 -53
- package/src/store/usage.js +343 -269
- package/src/store/users.js +162 -87
- package/src/store/wire.js +50 -41
- package/src/tools/ask_tool.js +68 -68
- package/src/tools/chat_search_tool.js +38 -44
- package/src/tools/context_tool.js +131 -138
- package/src/tools/document_tool.js +73 -78
- package/src/tools/mcp_tool.js +274 -232
- package/src/tools/task_tool.js +53 -50
- package/src/tools/types.js +1 -0
- package/public/panels/connectors.js +0 -184
- package/src/set-password.js +0 -43
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The admin's draft of the connectors (endpoint + key) and the model catalog,
|
|
3
|
+
* and the editor page for one of each. Every entry is an editor element kept
|
|
4
|
+
* in memory, so its fields are the draft: switching pages detaches it, saving
|
|
5
|
+
* reads them all back. Keys are write-only: an editor shows whether one is set
|
|
6
|
+
* (and its last four characters), and a blank key field means "keep it".
|
|
7
|
+
*/
|
|
8
|
+
import { $, el } from '../core/dom.js';
|
|
9
|
+
import { api } from '../core/api.js';
|
|
10
|
+
|
|
11
|
+
const TAGS = ['vision', 'reasoning', 'tools'];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The settings a model may override, as fields. Each one inherits the global
|
|
15
|
+
* value of the same id (on the "Defaults for all models" page) while blank;
|
|
16
|
+
* select options are copied from that global select.
|
|
17
|
+
*/
|
|
18
|
+
const OVERRIDES = [
|
|
19
|
+
{ key: 'systemPrompt', section: 'Prompt', label: 'System prompt', type: 'textarea', note: 'Replaces the default prompt for this model.' },
|
|
20
|
+
{ key: 'temperature', section: 'Sampling', label: 'Temperature', type: 'number', attrs: { step: 0.1, min: 0, max: 2 } },
|
|
21
|
+
{ key: 'maxTokens', section: 'Sampling', label: 'Max output tokens', type: 'number', attrs: { min: 1 } },
|
|
22
|
+
{ key: 'maxToolRounds', section: 'Tools', label: 'Max tool turns', type: 'number', attrs: { min: 1, max: 50 }, note: 'Tool calls allowed per reply.' },
|
|
23
|
+
{ key: 'cacheMode', section: 'Caching', label: 'Cache style', type: 'select' },
|
|
24
|
+
{ key: 'cacheTtl', section: 'Caching', label: 'Prompt cache lifetime', type: 'select' },
|
|
25
|
+
{ key: 'reasoningReplay', section: 'Advanced', label: 'Replay past reasoning', type: 'select' },
|
|
26
|
+
{ key: 'extraBody', section: 'Advanced', label: 'Extra request body (JSON)', type: 'json', note: 'Merged over the default body.' }
|
|
27
|
+
];
|
|
28
|
+
const SECTIONS = ['Prompt', 'Sampling', 'Tools', 'Caching', 'Advanced'];
|
|
29
|
+
|
|
30
|
+
const connectorEditors = [];
|
|
31
|
+
const modelEditors = [];
|
|
32
|
+
let locked = false;
|
|
33
|
+
// Whether the top-level endpoint serves anything (set in code or the
|
|
34
|
+
// environment); otherwise every model belongs to a connector.
|
|
35
|
+
let defaultEndpoint = false;
|
|
36
|
+
|
|
37
|
+
function field(label, input, note) {
|
|
38
|
+
const wrap = el('div');
|
|
39
|
+
const l = el('label');
|
|
40
|
+
l.textContent = label;
|
|
41
|
+
wrap.append(l, input);
|
|
42
|
+
if (note) wrap.appendChild(Object.assign(el('small'), { textContent: note }));
|
|
43
|
+
return wrap;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const span2 = (node) => { node.classList.add('span-2'); return node; };
|
|
47
|
+
const textInput = (value, placeholder) => Object.assign(el('input'), { value: value ?? '', placeholder: placeholder || '', spellcheck: false });
|
|
48
|
+
|
|
49
|
+
function group(title, note, ...children) {
|
|
50
|
+
const g = el('section', 'set-group');
|
|
51
|
+
const h = el('header');
|
|
52
|
+
h.appendChild(Object.assign(el('h3'), { textContent: title }));
|
|
53
|
+
if (note) h.appendChild(Object.assign(el('p'), { textContent: note }));
|
|
54
|
+
g.append(h, ...children);
|
|
55
|
+
return g;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function grid(...children) {
|
|
59
|
+
const g = el('div', 'set-grid');
|
|
60
|
+
g.append(...children);
|
|
61
|
+
return g;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* ---------- connectors ---------- */
|
|
65
|
+
|
|
66
|
+
function connectorEditor(c = {}, { onRemove, onRename, onAddModel }) {
|
|
67
|
+
const id = textInput(c.id, 'local');
|
|
68
|
+
// An id is what models name; renaming a saved one would orphan them.
|
|
69
|
+
if (c.id) id.readOnly = true;
|
|
70
|
+
let lastId = c.id || '';
|
|
71
|
+
id.addEventListener('change', () => { onRename(lastId, id.value.trim()); lastId = id.value.trim(); });
|
|
72
|
+
const label = textInput(c.label, 'Local models');
|
|
73
|
+
const url = textInput(c.baseUrl, 'http://localhost:11434/v1');
|
|
74
|
+
const key = Object.assign(textInput('', c.hasApiKey ? `set (…${c.keyHint || ''}) — blank keeps it` : 'optional'), { type: 'password', autocomplete: 'off' });
|
|
75
|
+
const clear = Object.assign(el('input'), { type: 'checkbox' });
|
|
76
|
+
const test = Object.assign(el('button'), { type: 'button', textContent: 'Test connection' });
|
|
77
|
+
const msg = el('span', 'usage');
|
|
78
|
+
test.onclick = async () => {
|
|
79
|
+
msg.textContent = 'testing…';
|
|
80
|
+
try {
|
|
81
|
+
const out = await api.post('/api/connectors/test', { id: id.value.trim(), baseUrl: url.value.trim(), apiKey: key.value });
|
|
82
|
+
msg.textContent = out.ok ? `reachable${out.models != null ? ` · ${out.models} models` : ''}` : out.error || `status ${out.status}`;
|
|
83
|
+
} catch (err) { msg.textContent = err.message; }
|
|
84
|
+
};
|
|
85
|
+
const clearLabel = el('label', 'inline-check');
|
|
86
|
+
clearLabel.append(clear, ' remove stored key');
|
|
87
|
+
const remove = Object.assign(el('button'), { type: 'button', textContent: 'Remove connector', className: 'danger' });
|
|
88
|
+
const actions = el('div', 'cfg-actions span-2');
|
|
89
|
+
actions.append(test, msg);
|
|
90
|
+
// The second step of setting one up: the models people pick from it.
|
|
91
|
+
const addModel = Object.assign(el('button'), { type: 'button', textContent: 'Add a model →', className: 'primary' });
|
|
92
|
+
addModel.onclick = () => {
|
|
93
|
+
if (!id.value.trim()) { msg.textContent = 'give the connector an id first'; id.focus(); return; }
|
|
94
|
+
onAddModel(id.value.trim());
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const page = el('div');
|
|
98
|
+
page.append(
|
|
99
|
+
group('Connector', 'An OpenAI-compatible API: its URL, and a key if it needs one. Its models are added under it.', grid(
|
|
100
|
+
field('Id', id, 'What a model names in its connector field.'),
|
|
101
|
+
field('Label', label),
|
|
102
|
+
span2(field('API URL', url)),
|
|
103
|
+
span2(field('API key', key, c.hasApiKey ? 'Write-only. Blank keeps the current key.' : 'Optional: leave blank for a server that needs none.')),
|
|
104
|
+
...(c.hasApiKey ? [span2(clearLabel)] : []),
|
|
105
|
+
actions
|
|
106
|
+
)),
|
|
107
|
+
el('div', 'set-foot')
|
|
108
|
+
);
|
|
109
|
+
page.lastChild.append(remove, addModel);
|
|
110
|
+
remove.onclick = () => onRemove(page);
|
|
111
|
+
page.read = () => ({
|
|
112
|
+
id: id.value.trim(),
|
|
113
|
+
...(label.value.trim() ? { label: label.value.trim() } : {}),
|
|
114
|
+
baseUrl: url.value.trim(),
|
|
115
|
+
// undefined keeps the stored key on the server; null clears it.
|
|
116
|
+
...(key.value ? { apiKey: key.value } : clear.checked ? { apiKey: null } : {})
|
|
117
|
+
});
|
|
118
|
+
page.summary = () => ({ id: id.value.trim(), label: label.value.trim(), baseUrl: url.value.trim(), hasKey: Boolean(key.value || (c.hasApiKey && !clear.checked)) });
|
|
119
|
+
return page;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* ---------- models ---------- */
|
|
123
|
+
|
|
124
|
+
function overrideInput(spec, value) {
|
|
125
|
+
if (spec.type === 'select') {
|
|
126
|
+
const sel = el('select');
|
|
127
|
+
for (const o of $(spec.key).options) sel.appendChild(Object.assign(el('option'), { value: o.value, textContent: o.textContent }));
|
|
128
|
+
sel.prepend(Object.assign(el('option'), { value: '', textContent: 'Inherit' }));
|
|
129
|
+
sel.value = value ?? '';
|
|
130
|
+
// A value this page has no option for (set in the file) must survive a save.
|
|
131
|
+
if (value != null && sel.value !== value) {
|
|
132
|
+
sel.appendChild(Object.assign(el('option'), { value, textContent: value }));
|
|
133
|
+
sel.value = value;
|
|
134
|
+
}
|
|
135
|
+
return sel;
|
|
136
|
+
}
|
|
137
|
+
if (spec.type === 'textarea' || spec.type === 'json') {
|
|
138
|
+
const ta = Object.assign(el('textarea'), { rows: spec.type === 'json' ? 4 : 6, spellcheck: spec.type !== 'json', className: spec.type === 'json' ? 'mono' : '' });
|
|
139
|
+
ta.value = value == null ? '' : spec.type === 'json' ? JSON.stringify(value, null, 2) : value;
|
|
140
|
+
return ta;
|
|
141
|
+
}
|
|
142
|
+
const input = Object.assign(el('input'), { type: 'number', value: value ?? '' });
|
|
143
|
+
for (const [k, v] of Object.entries(spec.attrs || {})) input.setAttribute(k, v);
|
|
144
|
+
return input;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// What a blank override falls back to, read off the global field each time
|
|
148
|
+
// the page is shown, so an edit on the defaults page is reflected at once.
|
|
149
|
+
function inheritHint(spec, input) {
|
|
150
|
+
const global = $(spec.key);
|
|
151
|
+
if (spec.type === 'select') {
|
|
152
|
+
input.options[0].textContent = `Inherit (${global.selectedOptions[0]?.textContent || global.value})`;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const v = global.value.trim();
|
|
156
|
+
input.placeholder = spec.key === 'systemPrompt' ? (v ? `Inherit the default prompt: ${v.slice(0, 120)}${v.length > 120 ? '…' : ''}` : 'Inherit (no default prompt)')
|
|
157
|
+
: spec.type === 'json' ? `Inherit: ${v || '{}'}`
|
|
158
|
+
: `Inherit: ${v || global.placeholder || 'provider default'}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readOverride(spec, input, name) {
|
|
162
|
+
const raw = input.value.trim();
|
|
163
|
+
if (raw === '') return undefined;
|
|
164
|
+
if (spec.type === 'number') return Number(raw);
|
|
165
|
+
if (spec.type === 'json') {
|
|
166
|
+
let v;
|
|
167
|
+
try { v = JSON.parse(raw); } catch (err) { throw new Error(`model "${name}" ${spec.label}: ${err.message}`); }
|
|
168
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error(`model "${name}" ${spec.label} must be a JSON object`);
|
|
169
|
+
return v;
|
|
170
|
+
}
|
|
171
|
+
return spec.key === 'systemPrompt' ? input.value : raw;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function connectorChoices(sel, current) {
|
|
175
|
+
const ids = connectorEditors.map((c) => c.summary().id).filter(Boolean);
|
|
176
|
+
const keepDefault = defaultEndpoint || (current === '' && !ids.length);
|
|
177
|
+
sel.replaceChildren(...(keepDefault ? [Object.assign(el('option'), { value: '', textContent: 'Default endpoint' })] : []),
|
|
178
|
+
...[...new Set([...ids, ...(current && !ids.includes(current) ? [current] : [])])].map((cid) => Object.assign(el('option'), { value: cid, textContent: cid })));
|
|
179
|
+
sel.value = current || (keepDefault ? '' : ids[0] || '');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function modelEditor(m = {}, { onRemove, isDefault, makeDefault, renameDefault }) {
|
|
183
|
+
const id = textInput(m.id, 'fast');
|
|
184
|
+
let lastId = m.id || '';
|
|
185
|
+
id.addEventListener('input', () => { renameDefault(lastId, id.value.trim()); lastId = id.value.trim(); });
|
|
186
|
+
const label = textInput(m.label, 'Quick');
|
|
187
|
+
const description = textInput(m.description, 'Everyday questions');
|
|
188
|
+
const model = textInput(m.model, 'vendor/model-id');
|
|
189
|
+
const connector = el('select');
|
|
190
|
+
connectorChoices(connector, m.connector);
|
|
191
|
+
const enabled = Object.assign(el('input'), { type: 'checkbox', checked: m.enabled !== false });
|
|
192
|
+
const enabledLabel = el('label', 'inline-check');
|
|
193
|
+
enabledLabel.append(enabled, ' Enabled: people can pick it');
|
|
194
|
+
const tags = el('div', 'cfg-tags');
|
|
195
|
+
const tagBoxes = TAGS.map((t) => {
|
|
196
|
+
const cb = Object.assign(el('input'), { type: 'checkbox', checked: (m.tags || []).includes(t) });
|
|
197
|
+
const l = el('label');
|
|
198
|
+
l.append(cb, ` ${t}`);
|
|
199
|
+
tags.appendChild(l);
|
|
200
|
+
return [t, cb];
|
|
201
|
+
});
|
|
202
|
+
const defaultBtn = Object.assign(el('button'), { type: 'button' });
|
|
203
|
+
const showDefault = () => {
|
|
204
|
+
const on = isDefault(id.value.trim());
|
|
205
|
+
defaultBtn.textContent = on ? 'Default model' : 'Make default';
|
|
206
|
+
defaultBtn.disabled = on || locked;
|
|
207
|
+
};
|
|
208
|
+
defaultBtn.onclick = () => { makeDefault(id.value.trim()); showDefault(); };
|
|
209
|
+
|
|
210
|
+
const inputs = OVERRIDES.map((spec) => [spec, overrideInput(spec, m[spec.key])]);
|
|
211
|
+
const page = el('div');
|
|
212
|
+
page.appendChild(group('Model', 'Who people see and where it is served from.', grid(
|
|
213
|
+
field('Id', id, 'Stable handle: roles and preferences name this.'),
|
|
214
|
+
field('Label', label, 'What people see.'),
|
|
215
|
+
span2(field('Description', description)),
|
|
216
|
+
field('Provider model id', model, 'Blank: the id above.'),
|
|
217
|
+
field('Connector', connector),
|
|
218
|
+
span2(field('Capabilities', tags)),
|
|
219
|
+
span2(el('div', 'cfg-actions'))
|
|
220
|
+
)));
|
|
221
|
+
page.querySelector('.cfg-actions').append(enabledLabel, defaultBtn);
|
|
222
|
+
for (const section of SECTIONS) {
|
|
223
|
+
const mine = inputs.filter(([s]) => s.section === section);
|
|
224
|
+
page.appendChild(group(section, section === 'Prompt' ? 'Blank fields inherit the defaults for all models.' : null,
|
|
225
|
+
grid(...mine.map(([s, input]) => {
|
|
226
|
+
const f = field(s.label, input, s.note);
|
|
227
|
+
return s.type === 'textarea' || s.type === 'json' ? span2(f) : f;
|
|
228
|
+
}))));
|
|
229
|
+
}
|
|
230
|
+
const remove = Object.assign(el('button'), { type: 'button', textContent: 'Remove model', className: 'danger' });
|
|
231
|
+
remove.onclick = () => onRemove(page);
|
|
232
|
+
const foot = el('div', 'set-foot');
|
|
233
|
+
foot.appendChild(remove);
|
|
234
|
+
page.appendChild(foot);
|
|
235
|
+
|
|
236
|
+
page.refresh = () => {
|
|
237
|
+
connectorChoices(connector, connector.value);
|
|
238
|
+
for (const [spec, input] of inputs) inheritHint(spec, input);
|
|
239
|
+
showDefault();
|
|
240
|
+
};
|
|
241
|
+
page.read = () => {
|
|
242
|
+
const name = id.value.trim();
|
|
243
|
+
const out = {
|
|
244
|
+
id: name,
|
|
245
|
+
...(label.value.trim() ? { label: label.value.trim() } : {}),
|
|
246
|
+
...(description.value.trim() ? { description: description.value.trim() } : {}),
|
|
247
|
+
...(model.value.trim() ? { model: model.value.trim() } : {}),
|
|
248
|
+
...(connector.value ? { connector: connector.value } : {})
|
|
249
|
+
};
|
|
250
|
+
const chosen = tagBoxes.filter(([, cb]) => cb.checked).map(([t]) => t);
|
|
251
|
+
if (chosen.length) out.tags = chosen;
|
|
252
|
+
if (!enabled.checked) out.enabled = false;
|
|
253
|
+
for (const [spec, input] of inputs) {
|
|
254
|
+
const v = readOverride(spec, input, name);
|
|
255
|
+
if (v !== undefined) out[spec.key] = v;
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
};
|
|
259
|
+
page.summary = () => ({
|
|
260
|
+
id: id.value.trim(), label: label.value.trim(), model: model.value.trim(), connector: connector.value,
|
|
261
|
+
enabled: enabled.checked, tags: tagBoxes.filter(([, cb]) => cb.checked).map(([t]) => t)
|
|
262
|
+
});
|
|
263
|
+
page.setConnector = (cid) => { connectorChoices(connector, cid); };
|
|
264
|
+
return page;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/* ---------- the draft ---------- */
|
|
268
|
+
|
|
269
|
+
let hooks = {};
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* `deps`: { isDefault(id), makeDefault(id), renameDefault(from, to), removed(),
|
|
273
|
+
* addModel(connectorId) }
|
|
274
|
+
* -- the default model lives on the settings page, not in an entry.
|
|
275
|
+
*/
|
|
276
|
+
export function initModelEditor(deps) { hooks = deps; }
|
|
277
|
+
|
|
278
|
+
const removeFrom = (list, page) => { const i = list.indexOf(page); if (i >= 0) list.splice(i, 1); hooks.removed?.(); };
|
|
279
|
+
|
|
280
|
+
function addConnectorEditor(c) {
|
|
281
|
+
const page = connectorEditor(c, {
|
|
282
|
+
onRemove: (p) => removeFrom(connectorEditors, p),
|
|
283
|
+
// A connector still being named carries the models already added under it.
|
|
284
|
+
onRename: (from, to) => { if (from) for (const m of modelEditors) if (m.summary().connector === from) m.setConnector(to); },
|
|
285
|
+
onAddModel: (cid) => hooks.addModel(cid)
|
|
286
|
+
});
|
|
287
|
+
connectorEditors.push(page);
|
|
288
|
+
return page;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function addModelEditor(m) {
|
|
292
|
+
const page = modelEditor(m, {
|
|
293
|
+
onRemove: (p) => removeFrom(modelEditors, p),
|
|
294
|
+
isDefault: (id) => hooks.isDefault(id),
|
|
295
|
+
makeDefault: (id) => hooks.makeDefault(id),
|
|
296
|
+
renameDefault: (from, to) => hooks.renameDefault(from, to)
|
|
297
|
+
});
|
|
298
|
+
modelEditors.push(page);
|
|
299
|
+
return page;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Rebuilds the draft from /api/config. */
|
|
303
|
+
export function renderCatalog(cfg) {
|
|
304
|
+
defaultEndpoint = Boolean(cfg.usesDefaultEndpoint);
|
|
305
|
+
connectorEditors.length = 0;
|
|
306
|
+
modelEditors.length = 0;
|
|
307
|
+
for (const c of cfg.connectors || []) addConnectorEditor(c);
|
|
308
|
+
for (const m of cfg.models || []) addModelEditor(m);
|
|
309
|
+
setLocked(locked);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export const newConnector = () => { const p = addConnectorEditor({}); setLocked(locked); return p; };
|
|
313
|
+
export const newModel = (connector) => {
|
|
314
|
+
const cid = connector || (defaultEndpoint ? '' : connectorEditors[0]?.summary().id);
|
|
315
|
+
const p = addModelEditor(cid ? { connector: cid } : {});
|
|
316
|
+
setLocked(locked);
|
|
317
|
+
return p;
|
|
318
|
+
};
|
|
319
|
+
export const usesDefaultEndpoint = () => defaultEndpoint;
|
|
320
|
+
export const connectorPages = () => [...connectorEditors];
|
|
321
|
+
export const modelPages = () => [...modelEditors];
|
|
322
|
+
|
|
323
|
+
/** What the draft holds, as the config patch to save. Throws on bad JSON. */
|
|
324
|
+
export function collectCatalog() {
|
|
325
|
+
return {
|
|
326
|
+
connectors: connectorEditors.map((p) => p.read()),
|
|
327
|
+
models: modelEditors.map((p) => p.read())
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Locks or unlocks every control in the catalog editors. */
|
|
332
|
+
export function setLocked(on) {
|
|
333
|
+
locked = on;
|
|
334
|
+
for (const p of [...connectorEditors, ...modelEditors]) for (const n of p.querySelectorAll('input,select,textarea,button')) n.disabled = on;
|
|
335
|
+
}
|
|
336
|
+
export const isCatalogLocked = () => locked;
|
package/public/panels/panels.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The side panels: one open at a time, each toggled by its nav button and
|
|
3
|
-
* closed by
|
|
4
|
-
*
|
|
3
|
+
* closed by Escape or by going back to a chat from the sidebar
|
|
4
|
+
* (closeAllPanels, wired in app.js). Chats are the app; a panel is a detour
|
|
5
|
+
* from them, so the way out is the sidebar toggle that is always on screen,
|
|
6
|
+
* not a close button per panel. This is the only code that opens
|
|
7
|
+
* or closes one; everything else calls openPanel/closePanel/closeAllPanels.
|
|
5
8
|
*
|
|
6
9
|
* Element ids follow the name: the panel is #<name>, its nav button
|
|
7
|
-
* #toggle-<name
|
|
10
|
+
* #toggle-<name>. A panel with its own dismiss button (Settings' Close, which
|
|
11
|
+
* also discards edits) names it as closeId.
|
|
8
12
|
*/
|
|
9
13
|
import { $ } from '../core/dom.js';
|
|
10
14
|
|
|
@@ -16,7 +20,8 @@ export const PANEL = Object.freeze({
|
|
|
16
20
|
ADMIN: 'admin'
|
|
17
21
|
});
|
|
18
22
|
|
|
19
|
-
const panels = new Map(); // name -> { onOpen?, onClose?, closeId? }
|
|
23
|
+
const panels = new Map(); // name -> { onOpen?, onClose?, canClose?, onEscape?, closeId? }
|
|
24
|
+
let onAnyOpen = () => {};
|
|
20
25
|
|
|
21
26
|
const isOpen = (name) => $(name).classList.contains('open');
|
|
22
27
|
|
|
@@ -27,26 +32,46 @@ function show(name, on) {
|
|
|
27
32
|
|
|
28
33
|
export async function openPanel(name) {
|
|
29
34
|
if (isOpen(name)) return;
|
|
30
|
-
for (const other of panels.keys()) if (other !== name
|
|
35
|
+
for (const other of panels.keys()) if (other !== name && !closePanel(other, { swapping: true })) return;
|
|
31
36
|
show(name, true);
|
|
37
|
+
onAnyOpen();
|
|
32
38
|
await panels.get(name).onOpen?.();
|
|
33
39
|
}
|
|
34
40
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
/**
|
|
42
|
+
* `swapping`: another panel is taking its place, so focus belongs there.
|
|
43
|
+
* `force` skips the panel's canClose (it is closing after a save). False when
|
|
44
|
+
* the panel refused, e.g. unsaved edits the person chose to keep.
|
|
45
|
+
*/
|
|
46
|
+
export function closePanel(name, { swapping = false, force = false } = {}) {
|
|
47
|
+
if (!isOpen(name)) return true;
|
|
48
|
+
if (!force && panels.get(name).canClose?.() === false) return false;
|
|
38
49
|
show(name, false);
|
|
39
50
|
panels.get(name).onClose?.({ swapping });
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** False when an open panel refused to close (unsaved edits kept). */
|
|
55
|
+
export function closeAllPanels() {
|
|
56
|
+
for (const name of panels.keys()) if (!closePanel(name)) return false;
|
|
57
|
+
return true;
|
|
40
58
|
}
|
|
41
59
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
60
|
+
/**
|
|
61
|
+
* `defs`: { [PANEL.*]: { onOpen?, onClose?({ swapping }), canClose?() -> bool,
|
|
62
|
+
* onEscape?() -> bool (true: handled, stay open), closeId? } }
|
|
63
|
+
* `opts.onAnyOpen()` runs whenever a panel opens -- on mobile the nav buttons
|
|
64
|
+
* live in the drawer, which would otherwise stay over the panel just opened.
|
|
65
|
+
*/
|
|
66
|
+
export function initPanels(defs, opts = {}) {
|
|
67
|
+
if (opts.onAnyOpen) onAnyOpen = opts.onAnyOpen;
|
|
44
68
|
for (const [name, def] of Object.entries(defs)) {
|
|
45
69
|
panels.set(name, def);
|
|
46
70
|
$(`toggle-${name}`).onclick = () => (isOpen(name) ? closePanel(name) : openPanel(name));
|
|
47
|
-
$(def.closeId
|
|
71
|
+
if (def.closeId) $(def.closeId).onclick = () => closePanel(name);
|
|
48
72
|
}
|
|
49
73
|
document.addEventListener('keydown', (event) => {
|
|
50
|
-
if (event.key
|
|
74
|
+
if (event.key !== 'Escape') return;
|
|
75
|
+
for (const [name, def] of panels) if (isOpen(name) && !def.onEscape?.()) closePanel(name);
|
|
51
76
|
});
|
|
52
77
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings home page: one card per connector with the models it serves
|
|
3
|
+
* listed under it, and the top-level endpoint first only while it serves
|
|
4
|
+
* anything (code or the environment set it). With none of them it is the
|
|
5
|
+
* first step of setting up: connect an endpoint, then add its models.
|
|
6
|
+
* Rebuilt from the draft every time it is shown, so it always reflects
|
|
7
|
+
* unsaved edits.
|
|
8
|
+
*/
|
|
9
|
+
import { $, el } from '../core/dom.js';
|
|
10
|
+
import { connectorPages, modelPages, newModel, newConnector, isCatalogLocked, usesDefaultEndpoint } from './model-editor.js';
|
|
11
|
+
|
|
12
|
+
function modelLink(page, { open, defaultModel }) {
|
|
13
|
+
const s = page.summary();
|
|
14
|
+
const b = el('button', 'model-link');
|
|
15
|
+
b.type = 'button';
|
|
16
|
+
if (!s.enabled) b.classList.add('off');
|
|
17
|
+
const name = el('span', 'model-name');
|
|
18
|
+
name.textContent = s.label || s.id || 'Untitled model';
|
|
19
|
+
const sub = el('span', 'model-sub');
|
|
20
|
+
sub.textContent = [s.model || s.id, s.enabled ? '' : 'disabled'].filter(Boolean).join(' · ');
|
|
21
|
+
const chips = el('span', 'model-chips');
|
|
22
|
+
if (s.id && s.id === defaultModel) chips.appendChild(Object.assign(el('span', 'chip chip-default'), { textContent: 'default' }));
|
|
23
|
+
for (const t of s.tags) chips.appendChild(Object.assign(el('span', 'chip'), { textContent: t }));
|
|
24
|
+
const text = el('span', 'model-text');
|
|
25
|
+
text.append(name, sub);
|
|
26
|
+
b.append(text, chips, Object.assign(el('span', 'chev'), { textContent: '›', ariaHidden: 'true' }));
|
|
27
|
+
b.onclick = () => open('model', page);
|
|
28
|
+
return b;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function card({ title, detail, onEdit, connectorId, models, deps, extra }) {
|
|
32
|
+
const c = el('section', 'set-group ep-card');
|
|
33
|
+
const head = el('div', 'ep-head');
|
|
34
|
+
const t = el('div');
|
|
35
|
+
t.appendChild(Object.assign(el('h3'), { textContent: title }));
|
|
36
|
+
t.appendChild(Object.assign(el('p'), { textContent: detail }));
|
|
37
|
+
const edit = Object.assign(el('button'), { type: 'button', textContent: 'Edit' });
|
|
38
|
+
edit.onclick = onEdit;
|
|
39
|
+
head.append(t, ...(onEdit ? [edit] : []));
|
|
40
|
+
c.appendChild(head);
|
|
41
|
+
if (models.length) {
|
|
42
|
+
const list = el('div', 'model-list');
|
|
43
|
+
for (const p of models) list.appendChild(modelLink(p, deps));
|
|
44
|
+
c.appendChild(list);
|
|
45
|
+
}
|
|
46
|
+
if (extra) c.appendChild(extra);
|
|
47
|
+
if (connectorId !== null && !models.length) {
|
|
48
|
+
c.appendChild(Object.assign(el('p', 'ep-empty'), { textContent: 'No models yet: add the ones people can pick.' }));
|
|
49
|
+
}
|
|
50
|
+
if (connectorId !== null && !isCatalogLocked()) {
|
|
51
|
+
const add = Object.assign(el('button', 'add-model'), { type: 'button', textContent: '+ Add model' });
|
|
52
|
+
add.onclick = () => deps.open('model', newModel(connectorId));
|
|
53
|
+
c.appendChild(add);
|
|
54
|
+
}
|
|
55
|
+
return c;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const host = (url) => { try { return new URL(url).host || url; } catch { return url; } };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `deps`: { open(view, page?), defaultModel, defaultKey: { set, hint },
|
|
62
|
+
* defaultModelField } -- the field is shown on the default card while the
|
|
63
|
+
* catalog is empty, since any model id goes then.
|
|
64
|
+
*/
|
|
65
|
+
export function renderHome(deps) {
|
|
66
|
+
const models = modelPages();
|
|
67
|
+
const connectors = connectorPages();
|
|
68
|
+
const ids = new Set(connectors.map((p) => p.summary().id));
|
|
69
|
+
const under = (cid) => models.filter((p) => (p.summary().connector || '') === cid);
|
|
70
|
+
const baseUrl = $('baseUrl').value.trim();
|
|
71
|
+
const cards = [];
|
|
72
|
+
|
|
73
|
+
if (usesDefaultEndpoint()) {
|
|
74
|
+
let extra = null;
|
|
75
|
+
if (!models.length) {
|
|
76
|
+
extra = el('div', 'ep-open');
|
|
77
|
+
extra.appendChild(deps.defaultModelField);
|
|
78
|
+
extra.appendChild(Object.assign(el('small'), { textContent: 'No models are listed, so any model id goes. Add one to choose exactly which models people can pick.' }));
|
|
79
|
+
}
|
|
80
|
+
cards.push(card({
|
|
81
|
+
title: 'Default endpoint',
|
|
82
|
+
detail: `${baseUrl ? host(baseUrl) : 'no URL set'} · ${deps.defaultKey.set ? `key …${deps.defaultKey.hint || ''}` : 'no key'}`,
|
|
83
|
+
onEdit: () => deps.open('endpoint'),
|
|
84
|
+
connectorId: '',
|
|
85
|
+
models: under(''),
|
|
86
|
+
deps,
|
|
87
|
+
extra
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
for (const p of connectors) {
|
|
91
|
+
const s = p.summary();
|
|
92
|
+
cards.push(card({
|
|
93
|
+
title: s.label || s.id || 'New connector',
|
|
94
|
+
detail: `${s.id ? `${s.id} · ` : ''}${s.baseUrl ? host(s.baseUrl) : 'no URL set'} · ${s.hasKey ? 'key set' : 'no key'}`,
|
|
95
|
+
onEdit: () => deps.open('connector', p),
|
|
96
|
+
connectorId: s.id,
|
|
97
|
+
models: under(s.id),
|
|
98
|
+
deps
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
// Models naming a connector that no longer exists still need a way in.
|
|
102
|
+
const orphans = models.filter((p) => { const c = p.summary().connector; return c && !ids.has(c); });
|
|
103
|
+
if (orphans.length) {
|
|
104
|
+
cards.push(card({ title: 'Unknown connector', detail: 'These models name a connector that is not defined.', connectorId: null, models: orphans, deps }));
|
|
105
|
+
}
|
|
106
|
+
const empty = !cards.length;
|
|
107
|
+
if (empty) cards.push(setupCard(deps));
|
|
108
|
+
// The setup card has its own button; a second one under it would only repeat it.
|
|
109
|
+
$('addConnector').parentElement.hidden = empty;
|
|
110
|
+
$('endpointCards').replaceChildren(...cards);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function setupCard(deps) {
|
|
114
|
+
const c = el('section', 'set-group ep-card ep-setup');
|
|
115
|
+
c.appendChild(Object.assign(el('h3'), { textContent: 'Connect a model endpoint' }));
|
|
116
|
+
c.appendChild(Object.assign(el('p'), {
|
|
117
|
+
textContent: 'Any OpenAI-compatible API: its URL, and an API key if it needs one. Then add the models people can pick from it.'
|
|
118
|
+
}));
|
|
119
|
+
if (!isCatalogLocked()) {
|
|
120
|
+
const add = Object.assign(el('button', 'primary'), { type: 'button', textContent: 'Add a connector' });
|
|
121
|
+
add.onclick = () => deps.open('connector', newConnector());
|
|
122
|
+
c.appendChild(add);
|
|
123
|
+
}
|
|
124
|
+
return c;
|
|
125
|
+
}
|