micro-models-agent 2.5.0 → 2.8.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/CHANGELOG.md +91 -0
- package/README.md +88 -38
- package/bin/mma.mjs +41 -41
- package/dist/assets/app.js +246 -3086
- package/dist/assets/hljs.js +1244 -0
- package/dist/assets/index.html +15 -1
- package/dist/assets/js/api.js +28 -0
- package/dist/assets/js/dialog.js +105 -0
- package/dist/assets/js/dom.js +157 -0
- package/dist/assets/js/feed.js +995 -0
- package/dist/assets/js/format.js +114 -0
- package/dist/assets/js/i18n.js +46 -0
- package/dist/assets/js/settings.js +1126 -0
- package/dist/assets/js/store.js +79 -0
- package/dist/assets/js/tabs.js +672 -0
- package/dist/assets/md.js +277 -0
- package/dist/assets/setup.js +557 -0
- package/dist/assets/smd.js +1665 -0
- package/dist/assets/styles.css +248 -16
- package/dist/i18n/en.json +163 -2
- package/dist/i18n/ru.json +163 -2
- package/dist/main.js +4835 -2618
- package/dist/modules/browser/bridge-server.mjs +3 -3
- package/package.json +10 -2
|
@@ -0,0 +1,1126 @@
|
|
|
1
|
+
// Settings panels, provider form + overlays, selectors, postConfig, loaders.
|
|
2
|
+
// Imports loaders-adjacent chrome (closeAllMenus, setSessOpen) and data loaders
|
|
3
|
+
// share from tabs; confirm/toast from dialog. Never imports app (the locale
|
|
4
|
+
// refresh reaches the app shell through the store hooks registry).
|
|
5
|
+
import {
|
|
6
|
+
el, icon, link, inlineTextNodes, connStatus, makeToggle,
|
|
7
|
+
} from "./dom.js";
|
|
8
|
+
import { api } from "./api.js";
|
|
9
|
+
import { t, loadI18n } from "./i18n.js";
|
|
10
|
+
import { S, setPath, getPath, THEME_KEY, hooks } from "./store.js";
|
|
11
|
+
import { contextLabel } from "./format.js";
|
|
12
|
+
import { closeAllMenus, setSessOpen } from "./tabs.js";
|
|
13
|
+
import { setIndicator, closeConfirm, confirmOverlay, trapTab } from "./dialog.js";
|
|
14
|
+
|
|
15
|
+
function setSelButton(btn, label, value) {
|
|
16
|
+
btn.textContent = label + ":";
|
|
17
|
+
btn.appendChild(el("b", "", value));
|
|
18
|
+
btn.appendChild(icon("chevron-down"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function menuItem(label, sub, active, onClick) {
|
|
22
|
+
const mi = el("button", "mi" + (active ? " on" : ""));
|
|
23
|
+
mi.type = "button";
|
|
24
|
+
mi.setAttribute("aria-pressed", active ? "true" : "false");
|
|
25
|
+
mi.appendChild(el("span", "", label));
|
|
26
|
+
if (sub) mi.appendChild(el("span", "sub", sub));
|
|
27
|
+
mi.addEventListener("click", (e) => {
|
|
28
|
+
e.stopPropagation();
|
|
29
|
+
onClick();
|
|
30
|
+
closeAllMenus();
|
|
31
|
+
});
|
|
32
|
+
return mi;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Menu action row (refresh, custom, add…) with an optional leading icon.
|
|
36
|
+
function menuAction(label, iconName, onClick) {
|
|
37
|
+
const mi = el("button", "mi row-mi");
|
|
38
|
+
mi.type = "button";
|
|
39
|
+
if (iconName) mi.appendChild(icon(iconName));
|
|
40
|
+
mi.appendChild(el("span", "", label));
|
|
41
|
+
mi.addEventListener("click", (e) => {
|
|
42
|
+
e.stopPropagation();
|
|
43
|
+
closeAllMenus();
|
|
44
|
+
onClick();
|
|
45
|
+
});
|
|
46
|
+
return mi;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function renderSelectors() {
|
|
50
|
+
const cfg = S.state.config || {};
|
|
51
|
+
const provider = cfg.provider || {};
|
|
52
|
+
|
|
53
|
+
setSelButton(document.getElementById("selModelBtn"), t("web.selectors.model"), cfg.model || "—");
|
|
54
|
+
const modelMenu = document.getElementById("selModelMenu");
|
|
55
|
+
modelMenu.textContent = "";
|
|
56
|
+
if (!S.state.models.length) {
|
|
57
|
+
modelMenu.appendChild(el("div", "mi dim", t("web.selectors.no_models")));
|
|
58
|
+
} else {
|
|
59
|
+
S.state.models.forEach((m) => {
|
|
60
|
+
modelMenu.appendChild(menuItem(m, "", m === cfg.model, () => postConfig("model", m, true)));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
modelMenu.appendChild(el("div", "sep"));
|
|
64
|
+
modelMenu.appendChild(
|
|
65
|
+
menuAction(t("web.selectors.refresh_list"), "refresh", () => {
|
|
66
|
+
loadModels().then(renderSelectors);
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
setSelButton(document.getElementById("selProviderBtn"), t("web.selectors.provider"), provider.active || provider.type || "—");
|
|
71
|
+
const provMenu = document.getElementById("selProviderMenu");
|
|
72
|
+
provMenu.textContent = "";
|
|
73
|
+
const entries = Array.isArray(provider.entries) && provider.entries.length
|
|
74
|
+
? provider.entries
|
|
75
|
+
: [{ label: provider.active || provider.type, type: provider.type }];
|
|
76
|
+
entries.forEach((entry) => {
|
|
77
|
+
const name = entry.label || entry.type;
|
|
78
|
+
if (!name) return;
|
|
79
|
+
provMenu.appendChild(menuItem(name, entry.type || "", name === (provider.active || provider.type), () => postConfig("provider.active", name, true)));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
setSelButton(document.getElementById("selContextBtn"), t("web.selectors.context"), contextLabel());
|
|
83
|
+
const ctxMenu = document.getElementById("selContextMenu");
|
|
84
|
+
ctxMenu.textContent = "";
|
|
85
|
+
const sizes = [8192, 16384, 32768, 65536, 131072];
|
|
86
|
+
sizes.forEach((n) => {
|
|
87
|
+
ctxMenu.appendChild(menuItem(Math.round(n / 1024) + "K", n === cfg.contextWindow ? t("web.selectors.current") : "", n === cfg.contextWindow, () => postConfig("contextWindow", n, false)));
|
|
88
|
+
});
|
|
89
|
+
ctxMenu.appendChild(el("div", "sep"));
|
|
90
|
+
// Inline custom size (no native `window.prompt`): the menu stays open while
|
|
91
|
+
// typing; Enter/save commits and closes it.
|
|
92
|
+
const custom = el("button", "mi row-mi");
|
|
93
|
+
custom.type = "button";
|
|
94
|
+
custom.appendChild(icon("settings"));
|
|
95
|
+
custom.appendChild(el("span", "", t("web.selectors.custom")));
|
|
96
|
+
custom.addEventListener("click", (e) => {
|
|
97
|
+
e.stopPropagation();
|
|
98
|
+
if (ctxMenu.querySelector(".ctx-custom")) return;
|
|
99
|
+
const wrap = el("div", "ctx-custom");
|
|
100
|
+
const input = el("input");
|
|
101
|
+
input.type = "number";
|
|
102
|
+
input.min = "1024";
|
|
103
|
+
input.step = "1024";
|
|
104
|
+
input.value = String(cfg.contextWindow || 32768);
|
|
105
|
+
input.placeholder = t("web.selectors.context_prompt");
|
|
106
|
+
input.setAttribute("aria-label", t("web.selectors.context_prompt"));
|
|
107
|
+
const commit = () => {
|
|
108
|
+
const n = Number(input.value.trim());
|
|
109
|
+
if (Number.isFinite(n) && n >= 1024) {
|
|
110
|
+
closeAllMenus();
|
|
111
|
+
postConfig("contextWindow", n, false);
|
|
112
|
+
} else {
|
|
113
|
+
input.focus();
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
input.addEventListener("click", (ev) => ev.stopPropagation());
|
|
117
|
+
input.addEventListener("keydown", (ev) => {
|
|
118
|
+
ev.stopPropagation();
|
|
119
|
+
if (ev.key === "Enter") {
|
|
120
|
+
ev.preventDefault();
|
|
121
|
+
commit();
|
|
122
|
+
} else if (ev.key === "Escape") {
|
|
123
|
+
wrap.remove();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
const ok = link(t("web.settings.save"), "", commit);
|
|
127
|
+
wrap.appendChild(input);
|
|
128
|
+
wrap.appendChild(ok);
|
|
129
|
+
ctxMenu.appendChild(wrap);
|
|
130
|
+
setTimeout(() => input.focus(), 0);
|
|
131
|
+
});
|
|
132
|
+
ctxMenu.appendChild(custom);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
document.querySelectorAll("[data-sel]").forEach((sel) => {
|
|
136
|
+
const btn = sel.querySelector("button");
|
|
137
|
+
btn.setAttribute("aria-haspopup", "menu");
|
|
138
|
+
btn.setAttribute("aria-expanded", "false");
|
|
139
|
+
btn.addEventListener("click", (e) => {
|
|
140
|
+
e.stopPropagation();
|
|
141
|
+
setSessOpen(false);
|
|
142
|
+
document.querySelectorAll("[data-sel]").forEach((s) => {
|
|
143
|
+
if (s !== sel) {
|
|
144
|
+
s.classList.remove("open");
|
|
145
|
+
const b = s.querySelector("button");
|
|
146
|
+
if (b) b.setAttribute("aria-expanded", "false");
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
const open = sel.classList.toggle("open");
|
|
150
|
+
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
async function postConfig(key, value, refreshSettings) {
|
|
155
|
+
const r = await api("/api/config", { method: "POST", body: { key, value } });
|
|
156
|
+
if (r.ok && r.data && r.data.ok) {
|
|
157
|
+
const persisted = r.data.persisted !== false;
|
|
158
|
+
setIndicator(persisted ? t("web.settings.saved") : (r.data.warning || "not persisted"), !persisted);
|
|
159
|
+
if (S.state.config && r.data.value !== undefined) setPath(S.state.config, key, r.data.value);
|
|
160
|
+
if (key === "locale") {
|
|
161
|
+
await loadI18n();
|
|
162
|
+
if (hooks.applyStaticI18n) hooks.applyStaticI18n();
|
|
163
|
+
renderSettings();
|
|
164
|
+
}
|
|
165
|
+
// A provider switch restores the per-provider model; a model change
|
|
166
|
+
// updates that provider's remembered model. Re-read both server-side.
|
|
167
|
+
if (key === "provider.active" || key === "model") {
|
|
168
|
+
await loadConfig();
|
|
169
|
+
await loadModels();
|
|
170
|
+
}
|
|
171
|
+
renderSelectors();
|
|
172
|
+
if (refreshSettings) renderSettings();
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
const err = (r.data && (r.data.error || r.data.reason)) || r.status || t("web.settings.save_failed");
|
|
176
|
+
setIndicator(String(err), true);
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function loadConfig() {
|
|
181
|
+
const r = await api("/api/config");
|
|
182
|
+
if (!r.ok || !r.data) return;
|
|
183
|
+
S.state.config = r.data.config || {};
|
|
184
|
+
S.state.registry = Array.isArray(r.data.registry) ? r.data.registry : [];
|
|
185
|
+
S.state.webToken = typeof r.data.webToken === "string" ? r.data.webToken : null;
|
|
186
|
+
renderSelectors();
|
|
187
|
+
renderSettings();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function loadModels() {
|
|
191
|
+
const r = await api("/api/models");
|
|
192
|
+
if (!r.ok || !r.data) return;
|
|
193
|
+
S.state.models = Array.isArray(r.data.models) ? r.data.models : [];
|
|
194
|
+
renderSelectors();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function loadProviders() {
|
|
198
|
+
const r = await api("/api/providers");
|
|
199
|
+
S.state.providers = r.ok && r.data && Array.isArray(r.data.entries) ? r.data : undefined;
|
|
200
|
+
S.state.providerPresets = r.ok && r.data && Array.isArray(r.data.presets) ? r.data.presets : [];
|
|
201
|
+
renderModelPanel();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Connection-S.state banner: shown while the SSE stream is down. The server
|
|
205
|
+
// never replays events, so a reconnect only resyncs S.state — the feed keeps
|
|
206
|
+
// whatever the live session already rendered (no duplication).
|
|
207
|
+
export function setConnStatus(disconnected) {
|
|
208
|
+
if (!connStatus) return;
|
|
209
|
+
connStatus.hidden = !disconnected;
|
|
210
|
+
connStatus.textContent = disconnected ? "⋯ " + t("web.conn.reconnecting") : "";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const SECTION_KEYS = {
|
|
214
|
+
general: ["locale", "ui.verbosity", "maxToolIterations", "stuckThreshold"],
|
|
215
|
+
context: ["contextWindow", "contextBudget.systemPrompt", "contextBudget.responseReserve"],
|
|
216
|
+
reasoning: ["showReasoning", "reasoning.mode"],
|
|
217
|
+
security: [
|
|
218
|
+
"security.enabled",
|
|
219
|
+
"security.paths.enabled",
|
|
220
|
+
"security.contentScan.enabled",
|
|
221
|
+
"security.sessionEncryption.enabled",
|
|
222
|
+
],
|
|
223
|
+
advanced: ["logLevel", "version"],
|
|
224
|
+
agent: [
|
|
225
|
+
"moe.enabled",
|
|
226
|
+
"moe.maxReplanCycles",
|
|
227
|
+
"critic.enabled",
|
|
228
|
+
"critic.maxCalls",
|
|
229
|
+
"critic.maxRepairCycles",
|
|
230
|
+
"critic.timeoutMs",
|
|
231
|
+
],
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Localized label for a registry key: `config.key.<canonical>`, falling back
|
|
235
|
+
// to the raw key when no translation exists (so a new key is never blank).
|
|
236
|
+
function configLabel(def) {
|
|
237
|
+
const k = "config.key." + def.key;
|
|
238
|
+
const tr = t(k);
|
|
239
|
+
return tr === k ? def.key : tr;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Localized enum value: `config.enum.<value>`, falling back to the raw value.
|
|
243
|
+
// The raw value is still what gets POSTed — only the label is translated.
|
|
244
|
+
function enumLabel(v) {
|
|
245
|
+
const k = "config.enum." + v;
|
|
246
|
+
const tr = t(k);
|
|
247
|
+
return tr === k ? v : tr;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function defFor(key) {
|
|
251
|
+
return S.state.registry.find((d) => d.key === key) || null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function row(label, control, desc) {
|
|
255
|
+
const r = el("div", "row");
|
|
256
|
+
const left = el("div", "row-label");
|
|
257
|
+
left.appendChild(el("span", "row-name", label));
|
|
258
|
+
if (desc) left.appendChild(el("span", "row-desc", desc));
|
|
259
|
+
r.appendChild(left);
|
|
260
|
+
if (typeof control === "string") {
|
|
261
|
+
const v = el("span", "val", control);
|
|
262
|
+
v.title = control;
|
|
263
|
+
r.appendChild(v);
|
|
264
|
+
} else if (control) {
|
|
265
|
+
r.appendChild(control);
|
|
266
|
+
}
|
|
267
|
+
return r;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function descFor(key) {
|
|
271
|
+
const k = "config.desc." + key;
|
|
272
|
+
const d = t(k);
|
|
273
|
+
return d === k ? "" : d;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function configControl(def, key) {
|
|
277
|
+
const value = S.state.config ? getPath(S.state.config, key) : undefined;
|
|
278
|
+
if (def.valueType === "bool") {
|
|
279
|
+
return makeToggle(!!value, (next) => {
|
|
280
|
+
postConfig(key, next, false);
|
|
281
|
+
return true;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (def.valueType === "enum") {
|
|
285
|
+
const seg = el("span", "seg");
|
|
286
|
+
(def.enum || []).forEach((v) => {
|
|
287
|
+
const b = el("button", v === value ? "on" : "", enumLabel(v));
|
|
288
|
+
b.type = "button";
|
|
289
|
+
b.setAttribute("aria-pressed", v === value ? "true" : "false");
|
|
290
|
+
b.addEventListener("click", () => {
|
|
291
|
+
seg.querySelectorAll("button").forEach((x) => {
|
|
292
|
+
x.classList.remove("on");
|
|
293
|
+
x.setAttribute("aria-pressed", "false");
|
|
294
|
+
});
|
|
295
|
+
b.classList.add("on");
|
|
296
|
+
b.setAttribute("aria-pressed", "true");
|
|
297
|
+
postConfig(key, v, false);
|
|
298
|
+
});
|
|
299
|
+
seg.appendChild(b);
|
|
300
|
+
});
|
|
301
|
+
return seg;
|
|
302
|
+
}
|
|
303
|
+
if (def.readOnly) {
|
|
304
|
+
const ro = el("span", "val dim", value == null ? "—" : String(value));
|
|
305
|
+
if (value != null) ro.title = String(value);
|
|
306
|
+
return ro;
|
|
307
|
+
}
|
|
308
|
+
const input = el("input");
|
|
309
|
+
input.setAttribute("aria-label", configLabel(def));
|
|
310
|
+
if (def.valueType === "int") {
|
|
311
|
+
input.type = "number";
|
|
312
|
+
if (def.min != null) input.min = String(def.min);
|
|
313
|
+
if (def.max != null) input.max = String(def.max);
|
|
314
|
+
if (def.step != null) input.step = String(def.step);
|
|
315
|
+
input.value = value == null ? "" : String(value);
|
|
316
|
+
} else if (def.valueType === "share") {
|
|
317
|
+
input.value = value == null ? "" : Math.round(Number(value) * 100) + "%";
|
|
318
|
+
} else {
|
|
319
|
+
input.value = value == null ? "" : String(value);
|
|
320
|
+
}
|
|
321
|
+
input.addEventListener("change", () => {
|
|
322
|
+
postConfig(key, input.value, false);
|
|
323
|
+
});
|
|
324
|
+
return input;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function renderGenericPanel(name, keys) {
|
|
328
|
+
const container = document.querySelector('[data-panel="' + name + '"]');
|
|
329
|
+
if (!container) return;
|
|
330
|
+
container.textContent = "";
|
|
331
|
+
keys.forEach((key) => {
|
|
332
|
+
const def = defFor(key);
|
|
333
|
+
if (!def) return;
|
|
334
|
+
container.appendChild(row(configLabel(def), configControl(def, key), descFor(def.key)));
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function providerEntries() {
|
|
339
|
+
if (S.state.providers && Array.isArray(S.state.providers.entries) && S.state.providers.entries.length) {
|
|
340
|
+
return S.state.providers.entries;
|
|
341
|
+
}
|
|
342
|
+
const provider = (S.state.config && S.state.config.provider) || {};
|
|
343
|
+
if (Array.isArray(provider.entries) && provider.entries.length) {
|
|
344
|
+
return provider.entries.map((e) => ({
|
|
345
|
+
type: e.type,
|
|
346
|
+
label: e.label || e.type,
|
|
347
|
+
baseUrl: e.baseUrl,
|
|
348
|
+
hasApiKey: !!e.apiKey,
|
|
349
|
+
contextWindow: e.contextWindow,
|
|
350
|
+
priority: e.priority,
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
return [
|
|
354
|
+
{
|
|
355
|
+
type: provider.type,
|
|
356
|
+
label: provider.active || provider.type,
|
|
357
|
+
baseUrl: provider.baseUrl,
|
|
358
|
+
hasApiKey: !!provider.apiKey,
|
|
359
|
+
},
|
|
360
|
+
];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function providerName(p) {
|
|
364
|
+
return p.label || p.type || "—";
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function providerMeta(p) {
|
|
368
|
+
const parts = [p.type];
|
|
369
|
+
if (p.contextWindow) parts.push(Math.round(p.contextWindow / 1024) + "K");
|
|
370
|
+
if (typeof p.rpm === "number" || typeof p.parallel === "number") {
|
|
371
|
+
parts.push("rpm " + (p.rpm == null ? "—" : p.rpm) + " · x" + (p.parallel == null ? "—" : p.parallel));
|
|
372
|
+
}
|
|
373
|
+
return parts.join(" · ");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function pfield(labelKey, input) {
|
|
377
|
+
const f = el("div", "pform-field");
|
|
378
|
+
f.appendChild(el("label", "", t(labelKey)));
|
|
379
|
+
input.setAttribute("aria-label", t(labelKey));
|
|
380
|
+
f.appendChild(input);
|
|
381
|
+
return f;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function providerInput(value, type, placeholder) {
|
|
385
|
+
const input = el("input");
|
|
386
|
+
input.type = type || "text";
|
|
387
|
+
if (placeholder) input.placeholder = placeholder;
|
|
388
|
+
if (value != null) input.value = String(value);
|
|
389
|
+
return input;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// A titled group inside a settings panel (models / providers).
|
|
393
|
+
function settingsSection(titleKey) {
|
|
394
|
+
const sec = el("div", "settings-section");
|
|
395
|
+
const head = el("div", "sec-head");
|
|
396
|
+
head.appendChild(el("span", "sec-title", t(titleKey)));
|
|
397
|
+
sec.appendChild(head);
|
|
398
|
+
const body = el("div", "sec-body");
|
|
399
|
+
sec.appendChild(body);
|
|
400
|
+
return { el: sec, head, body };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Compact action control used inside list items (use / edit / delete).
|
|
404
|
+
function chip(label, cls, onClick) {
|
|
405
|
+
const b = el("button", "chip" + (cls ? " " + cls : ""), label);
|
|
406
|
+
b.type = "button";
|
|
407
|
+
b.addEventListener("click", onClick);
|
|
408
|
+
return b;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Suggestions for the provider form: known provider types and common base URLs.
|
|
412
|
+
function providerHints() {
|
|
413
|
+
const frag = document.createDocumentFragment();
|
|
414
|
+
const types = el("datalist");
|
|
415
|
+
types.id = "providerTypes";
|
|
416
|
+
["openai-compat", "lmstudio", "openrouter", "openai", "anthropic", "opencode-zen", "opencode-go"].forEach((v) => {
|
|
417
|
+
const o = el("option");
|
|
418
|
+
o.value = v;
|
|
419
|
+
types.appendChild(o);
|
|
420
|
+
});
|
|
421
|
+
const urls = el("datalist");
|
|
422
|
+
urls.id = "providerBaseUrls";
|
|
423
|
+
[
|
|
424
|
+
["LM Studio", "http://localhost:1234/v1"],
|
|
425
|
+
["Ollama", "http://localhost:11434/v1"],
|
|
426
|
+
["OpenRouter", "https://openrouter.ai/api/v1"],
|
|
427
|
+
["OpenAI", "https://api.openai.com/v1"],
|
|
428
|
+
["Anthropic", "https://api.anthropic.com"],
|
|
429
|
+
].forEach(([label, value]) => {
|
|
430
|
+
const o = el("option");
|
|
431
|
+
o.value = value;
|
|
432
|
+
o.label = label;
|
|
433
|
+
urls.appendChild(o);
|
|
434
|
+
});
|
|
435
|
+
frag.appendChild(types);
|
|
436
|
+
frag.appendChild(urls);
|
|
437
|
+
return frag;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// One-click preset row: selecting a builtin fills type/label/base URL, so
|
|
441
|
+
// hosted providers need no URL knowledge. `custom` leaves the fields free.
|
|
442
|
+
// Returns the row node (null when no presets) plus the raw select for wiring.
|
|
443
|
+
function providerPresetField(editing) {
|
|
444
|
+
const presets = S.state.providerPresets || [];
|
|
445
|
+
if (!presets.length) return { presets, sel: null, node: null };
|
|
446
|
+
const sel = el("select", "pform-preset");
|
|
447
|
+
const custom = el("option", "", t("web.settings.provider_custom"));
|
|
448
|
+
custom.value = "";
|
|
449
|
+
sel.appendChild(custom);
|
|
450
|
+
presets.forEach((p) => {
|
|
451
|
+
const o = el("option", "", p.label || p.type);
|
|
452
|
+
o.value = p.id;
|
|
453
|
+
sel.appendChild(o);
|
|
454
|
+
});
|
|
455
|
+
const match =
|
|
456
|
+
presets.find((p) => p.type === editing.type && p.baseUrl && p.baseUrl === editing.baseUrl) ||
|
|
457
|
+
presets.find((p) => p.type === editing.type && !p.baseUrl) ||
|
|
458
|
+
presets.find((p) => p.type === editing.type);
|
|
459
|
+
if (match) sel.value = match.id;
|
|
460
|
+
return { presets, sel, node: pfield("web.settings.provider_preset", sel) };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Keep the preset select and the type field in sync both ways.
|
|
464
|
+
function wirePresetSync(presets, presetSel, mode, f) {
|
|
465
|
+
if (!presetSel) return;
|
|
466
|
+
presetSel.addEventListener("change", () => {
|
|
467
|
+
const p = presets.find((x) => x.id === presetSel.value);
|
|
468
|
+
if (!p) return;
|
|
469
|
+
f.type.value = p.type;
|
|
470
|
+
if (mode === "add" || !f.label.value.trim()) f.label.value = p.name;
|
|
471
|
+
f.base.value = p.baseUrl || "";
|
|
472
|
+
});
|
|
473
|
+
f.type.addEventListener("input", () => {
|
|
474
|
+
const sel = presets.find((x) => x.id === presetSel.value);
|
|
475
|
+
if (!sel || f.type.value.trim() !== sel.type) presetSel.value = "";
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Optional tuning fields stay collapsed so the form reads as "type + URL".
|
|
480
|
+
function providerAdvancedFields(editing) {
|
|
481
|
+
const adv = el("details", "pform-adv");
|
|
482
|
+
adv.appendChild(el("summary", "", t("web.settings.provider_advanced")));
|
|
483
|
+
const advGrid = el("div", "pform-grid");
|
|
484
|
+
const f = {
|
|
485
|
+
ctx: providerInput(editing.contextWindow, "number", "32768"),
|
|
486
|
+
prio: providerInput(editing.priority, "number", "0"),
|
|
487
|
+
rpm: providerInput(editing.rpm, "number", "60"),
|
|
488
|
+
par: providerInput(editing.parallel, "number", "1"),
|
|
489
|
+
};
|
|
490
|
+
advGrid.appendChild(pfield("web.settings.provider_context", f.ctx));
|
|
491
|
+
advGrid.appendChild(pfield("web.settings.provider_priority", f.prio));
|
|
492
|
+
advGrid.appendChild(pfield("web.settings.provider_rpm", f.rpm));
|
|
493
|
+
advGrid.appendChild(pfield("web.settings.provider_parallel", f.par));
|
|
494
|
+
adv.appendChild(advGrid);
|
|
495
|
+
return { node: adv, ...f };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Validate the field bag, POST it, and refresh the panels on success.
|
|
499
|
+
async function submitProviderForm(f, mode, editing) {
|
|
500
|
+
const type = f.type.value.trim();
|
|
501
|
+
const baseUrl = f.base.value.trim();
|
|
502
|
+
if (!type || !baseUrl) {
|
|
503
|
+
setIndicator(t("web.settings.provider_required"), true);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const body = { type, baseUrl };
|
|
507
|
+
const label = f.label.value.trim();
|
|
508
|
+
if (label) body.label = label;
|
|
509
|
+
if (mode === "edit") {
|
|
510
|
+
const original = providerName(editing);
|
|
511
|
+
if (original && original !== "—") body.originalName = original;
|
|
512
|
+
}
|
|
513
|
+
const key = f.key.value.trim();
|
|
514
|
+
// Empty means "keep the stored key" (the field is never prefilled, only a
|
|
515
|
+
// hint). `"***"` guards password-manager autofill of a masked placeholder.
|
|
516
|
+
if (key && key !== "***") body.apiKey = key;
|
|
517
|
+
const nums = [
|
|
518
|
+
["contextWindow", f.ctx, 1024],
|
|
519
|
+
["priority", f.prio, 0],
|
|
520
|
+
["rpm", f.rpm, 1],
|
|
521
|
+
["parallel", f.par, 1],
|
|
522
|
+
];
|
|
523
|
+
for (const entry of nums) {
|
|
524
|
+
const raw = entry[1].value.trim();
|
|
525
|
+
if (!raw) continue;
|
|
526
|
+
const n = Number(raw);
|
|
527
|
+
if (!Number.isInteger(n) || n < entry[2]) {
|
|
528
|
+
setIndicator(t("web.settings.provider_invalid"), true);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
body[entry[0]] = n;
|
|
532
|
+
}
|
|
533
|
+
const r = await api("/api/providers", { method: "POST", body });
|
|
534
|
+
if (r.ok && r.data && r.data.ok) {
|
|
535
|
+
const persisted = r.data.persisted !== false;
|
|
536
|
+
S.state.providerForm = null;
|
|
537
|
+
setIndicator(persisted ? t("web.settings.saved") : r.data.warning || "not persisted", !persisted);
|
|
538
|
+
hideProviderOverlay();
|
|
539
|
+
await loadProviders();
|
|
540
|
+
await loadConfig();
|
|
541
|
+
} else {
|
|
542
|
+
const err = (r.data && r.data.error) || "";
|
|
543
|
+
setIndicator(
|
|
544
|
+
err === "name-conflict" ? t("web.settings.provider_name_conflict") : err || t("web.settings.save_failed"),
|
|
545
|
+
true,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function providerForm() {
|
|
551
|
+
const mode = (S.state.providerForm && S.state.providerForm.mode) || "add";
|
|
552
|
+
const editing = (S.state.providerForm && S.state.providerForm.entry) || {};
|
|
553
|
+
const box = el("div", "pform");
|
|
554
|
+
|
|
555
|
+
const { presets, sel: presetSel, node: presetNode } = providerPresetField(editing);
|
|
556
|
+
if (presetNode) box.appendChild(presetNode);
|
|
557
|
+
|
|
558
|
+
const grid = el("div", "pform-grid");
|
|
559
|
+
const fType = providerInput(editing.type, "text", "openai-compat");
|
|
560
|
+
fType.setAttribute("list", "providerTypes");
|
|
561
|
+
const fLabel = providerInput(editing.label, "text", "my-provider");
|
|
562
|
+
const fBase = providerInput(editing.baseUrl, "text", "http://localhost:1234/v1");
|
|
563
|
+
fBase.setAttribute("list", "providerBaseUrls");
|
|
564
|
+
const fKey = providerInput("", "password", editing.hasApiKey ? t("web.settings.provider_key_keep") : "");
|
|
565
|
+
grid.appendChild(pfield("web.settings.provider_type", fType));
|
|
566
|
+
grid.appendChild(pfield("web.settings.provider_label_field", fLabel));
|
|
567
|
+
grid.appendChild(pfield("web.settings.provider_baseurl", fBase));
|
|
568
|
+
grid.appendChild(pfield("web.settings.provider_apikey", fKey));
|
|
569
|
+
box.appendChild(grid);
|
|
570
|
+
wirePresetSync(presets, presetSel, mode, { type: fType, label: fLabel, base: fBase });
|
|
571
|
+
|
|
572
|
+
const { node: advNode, ...advF } = providerAdvancedFields(editing);
|
|
573
|
+
box.appendChild(advNode);
|
|
574
|
+
const f = { type: fType, label: fLabel, base: fBase, key: fKey, ...advF };
|
|
575
|
+
|
|
576
|
+
box.appendChild(providerHints());
|
|
577
|
+
|
|
578
|
+
const actions = el("div", "pform-actions");
|
|
579
|
+
actions.appendChild(link(t("web.settings.save"), "", () => submitProviderForm(f, mode, editing)));
|
|
580
|
+
actions.appendChild(link(t("web.settings.cancel"), "dim", () => closeProviderForm()));
|
|
581
|
+
box.appendChild(actions);
|
|
582
|
+
return box;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function renderModelPanel() {
|
|
586
|
+
const container = document.querySelector('[data-panel="model"]');
|
|
587
|
+
if (!container) return;
|
|
588
|
+
container.textContent = "";
|
|
589
|
+
const cfg = S.state.config || {};
|
|
590
|
+
|
|
591
|
+
// --- Model section ---
|
|
592
|
+
const modelSec = settingsSection("web.settings.section_models");
|
|
593
|
+
const search = el("input", "searchbox");
|
|
594
|
+
search.placeholder = t("web.settings.search_models");
|
|
595
|
+
search.setAttribute("aria-label", t("web.settings.search_models"));
|
|
596
|
+
modelSec.body.appendChild(search);
|
|
597
|
+
const list = el("div", "model-list");
|
|
598
|
+
modelSec.body.appendChild(list);
|
|
599
|
+
const draw = () => {
|
|
600
|
+
list.textContent = "";
|
|
601
|
+
const q = search.value.trim().toLowerCase();
|
|
602
|
+
const models = S.state.models.filter((m) => !q || m.toLowerCase().indexOf(q) !== -1);
|
|
603
|
+
if (!models.length) {
|
|
604
|
+
list.appendChild(el("div", "placeholder", S.state.models.length ? t("web.settings.no_matches") : t("web.settings.no_models")));
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
models.forEach((m) => {
|
|
608
|
+
const active = m === cfg.model;
|
|
609
|
+
const item = el("div", "model-item" + (active ? " active" : ""));
|
|
610
|
+
const nameEl = el("span", "m-name", m);
|
|
611
|
+
nameEl.title = m;
|
|
612
|
+
item.appendChild(nameEl);
|
|
613
|
+
if (active) {
|
|
614
|
+
item.appendChild(el("span", "m-meta", contextLabel() + " · " + t("web.settings.active_check")));
|
|
615
|
+
} else {
|
|
616
|
+
item.appendChild(chip(t("web.settings.use"), "", () => postConfig("model", m, true)));
|
|
617
|
+
}
|
|
618
|
+
list.appendChild(item);
|
|
619
|
+
});
|
|
620
|
+
};
|
|
621
|
+
search.addEventListener("input", draw);
|
|
622
|
+
draw();
|
|
623
|
+
container.appendChild(modelSec.el);
|
|
624
|
+
|
|
625
|
+
// --- Providers section ---
|
|
626
|
+
const provSec = settingsSection("web.settings.section_providers");
|
|
627
|
+
const addBtn = el("button", "sec-add");
|
|
628
|
+
addBtn.type = "button";
|
|
629
|
+
addBtn.setAttribute("aria-haspopup", "dialog");
|
|
630
|
+
addBtn.appendChild(icon("plus"));
|
|
631
|
+
addBtn.appendChild(el("span", "", t("web.settings.add_provider")));
|
|
632
|
+
addBtn.addEventListener("click", () => openProviderForm("add", null));
|
|
633
|
+
provSec.head.appendChild(addBtn);
|
|
634
|
+
|
|
635
|
+
const entries = providerEntries().filter((e) => {
|
|
636
|
+
const n = providerName(e);
|
|
637
|
+
return n && n !== "—";
|
|
638
|
+
});
|
|
639
|
+
const activeName = (S.state.providers && S.state.providers.active) || cfg.provider?.active || cfg.provider?.type || "—";
|
|
640
|
+
const provList = el("div", "provider-list");
|
|
641
|
+
entries.forEach((entry) => {
|
|
642
|
+
const name = providerName(entry);
|
|
643
|
+
const active = name === activeName;
|
|
644
|
+
const item = el("div", "provider-item" + (active ? " active" : ""));
|
|
645
|
+
const head = el("div", "p-head");
|
|
646
|
+
head.appendChild(el("span", "p-name", name));
|
|
647
|
+
if (active) head.appendChild(el("span", "badge on", t("web.settings.active")));
|
|
648
|
+
item.appendChild(head);
|
|
649
|
+
const meta = providerMeta(entry);
|
|
650
|
+
if (meta) item.appendChild(el("div", "p-meta", meta));
|
|
651
|
+
const actions = el("div", "p-actions");
|
|
652
|
+
if (!active) {
|
|
653
|
+
actions.appendChild(chip(t("web.settings.provider_make_active"), "", () => postConfig("provider.active", name, true)));
|
|
654
|
+
}
|
|
655
|
+
actions.appendChild(
|
|
656
|
+
chip(t("web.settings.edit"), "", () => openProviderForm("edit", entry)),
|
|
657
|
+
);
|
|
658
|
+
if (!active) {
|
|
659
|
+
actions.appendChild(
|
|
660
|
+
chip(t("web.settings.delete"), "danger", async () => {
|
|
661
|
+
const r = await api("/api/providers/" + encodeURIComponent(name), { method: "DELETE" });
|
|
662
|
+
if (r.ok && r.data && r.data.ok) {
|
|
663
|
+
setIndicator(t("web.settings.saved"));
|
|
664
|
+
await loadProviders();
|
|
665
|
+
await loadConfig();
|
|
666
|
+
} else {
|
|
667
|
+
const err = (r.data && r.data.error) || t("web.settings.delete_failed");
|
|
668
|
+
setIndicator(err === "active-provider" ? t("web.settings.provider_active_guard") : String(err), true);
|
|
669
|
+
}
|
|
670
|
+
}),
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
item.appendChild(actions);
|
|
674
|
+
provList.appendChild(item);
|
|
675
|
+
});
|
|
676
|
+
if (!entries.length) provList.appendChild(el("div", "placeholder", t("web.settings.providers_empty")));
|
|
677
|
+
provSec.body.appendChild(provList);
|
|
678
|
+
container.appendChild(provSec.el);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function renderUiPanel() {
|
|
682
|
+
const container = document.querySelector('[data-panel="ui"]');
|
|
683
|
+
if (!container) return;
|
|
684
|
+
container.textContent = "";
|
|
685
|
+
const seg = el("span", "seg");
|
|
686
|
+
["dark", "light", "auto"].forEach((theme) => {
|
|
687
|
+
const b = el("button", currentTheme() === theme ? "on" : "", theme);
|
|
688
|
+
b.type = "button";
|
|
689
|
+
b.setAttribute("aria-pressed", currentTheme() === theme ? "true" : "false");
|
|
690
|
+
b.addEventListener("click", () => {
|
|
691
|
+
seg.querySelectorAll("button").forEach((x) => {
|
|
692
|
+
x.classList.remove("on");
|
|
693
|
+
x.setAttribute("aria-pressed", "false");
|
|
694
|
+
});
|
|
695
|
+
b.classList.add("on");
|
|
696
|
+
b.setAttribute("aria-pressed", "true");
|
|
697
|
+
applyTheme(theme);
|
|
698
|
+
});
|
|
699
|
+
seg.appendChild(b);
|
|
700
|
+
});
|
|
701
|
+
container.appendChild(row(t("web.settings.theme"), seg));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function renderSecurityPanel() {
|
|
705
|
+
const container = document.querySelector('[data-panel="security"]');
|
|
706
|
+
if (!container) return;
|
|
707
|
+
container.textContent = "";
|
|
708
|
+
SECTION_KEYS.security.forEach((key) => {
|
|
709
|
+
const def = defFor(key);
|
|
710
|
+
if (!def) return;
|
|
711
|
+
container.appendChild(row(configLabel(def), configControl(def, key), descFor(def.key)));
|
|
712
|
+
});
|
|
713
|
+
container.appendChild(
|
|
714
|
+
row(t("web.settings.web_token"), el("span", "val dim", S.state.webToken || t("web.settings.web_token_none")), t("config.desc.webToken")),
|
|
715
|
+
);
|
|
716
|
+
const note = el("div", "placeholder");
|
|
717
|
+
note.appendChild(el("span", "tag", t("web.settings.terminal_tag")));
|
|
718
|
+
note.appendChild(document.createTextNode(" " + t("web.settings.security_note")));
|
|
719
|
+
container.appendChild(note);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function placeholderFor(data, empty) {
|
|
723
|
+
const d = el("div", "placeholder");
|
|
724
|
+
if (data === undefined) d.textContent = t("web.settings.loading");
|
|
725
|
+
else d.textContent = empty;
|
|
726
|
+
return d;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function groupHeader(label, count) {
|
|
730
|
+
const head = el("div", "row");
|
|
731
|
+
head.appendChild(el("span", "val accent", label));
|
|
732
|
+
if (count != null) head.appendChild(el("span", "val dim", String(count)));
|
|
733
|
+
return head;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const MEMORY_CATEGORIES = ["conventions", "decisions", "errors", "facts"];
|
|
737
|
+
|
|
738
|
+
function renderMemoryPanel() {
|
|
739
|
+
const container = document.querySelector('[data-panel="memory"]');
|
|
740
|
+
if (!container) return;
|
|
741
|
+
container.textContent = "";
|
|
742
|
+
if (S.state.memory === undefined || S.state.memory === null) {
|
|
743
|
+
container.appendChild(placeholderFor(S.state.memory, t("web.settings.memory_unavailable")));
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
const data = S.state.memory;
|
|
747
|
+
|
|
748
|
+
const addRow = el("div", "mem-add");
|
|
749
|
+
const catSel = el("select");
|
|
750
|
+
catSel.setAttribute("aria-label", t("web.settings.memory_category"));
|
|
751
|
+
MEMORY_CATEGORIES.forEach((c) => catSel.appendChild(el("option", "", c)));
|
|
752
|
+
catSel.value = "facts";
|
|
753
|
+
addRow.appendChild(catSel);
|
|
754
|
+
const textIn = el("input");
|
|
755
|
+
textIn.placeholder = t("web.settings.new_memory");
|
|
756
|
+
textIn.setAttribute("aria-label", t("web.settings.new_memory"));
|
|
757
|
+
addRow.appendChild(textIn);
|
|
758
|
+
addRow.appendChild(
|
|
759
|
+
link(t("web.settings.add"), "", async () => {
|
|
760
|
+
const text = textIn.value.trim();
|
|
761
|
+
if (!text) return;
|
|
762
|
+
const r = await api("/api/memory", { method: "POST", body: { category: catSel.value, text } });
|
|
763
|
+
if (r.ok && r.data && r.data.ok) {
|
|
764
|
+
textIn.value = "";
|
|
765
|
+
await loadMemory();
|
|
766
|
+
} else {
|
|
767
|
+
setIndicator(t("web.settings.add_failed"), true);
|
|
768
|
+
}
|
|
769
|
+
}),
|
|
770
|
+
);
|
|
771
|
+
container.appendChild(addRow);
|
|
772
|
+
|
|
773
|
+
const search = el("input", "searchbox");
|
|
774
|
+
search.placeholder = t("web.settings.search_memory");
|
|
775
|
+
search.setAttribute("aria-label", t("web.settings.search_memory"));
|
|
776
|
+
container.appendChild(search);
|
|
777
|
+
const list = el("div", "settings-list");
|
|
778
|
+
container.appendChild(list);
|
|
779
|
+
|
|
780
|
+
const categoryHeader = (label, count) => {
|
|
781
|
+
const head = el("div", "mem-cat");
|
|
782
|
+
head.appendChild(el("span", "", label));
|
|
783
|
+
if (count != null) head.appendChild(el("span", "n", String(count)));
|
|
784
|
+
return head;
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
const entryBlock = (text, onForget) => {
|
|
788
|
+
const block = el("div", "mem-entry");
|
|
789
|
+
const body = el("span", "mem-text");
|
|
790
|
+
body.appendChild(inlineTextNodes(text));
|
|
791
|
+
body.title = String(text == null ? "" : text);
|
|
792
|
+
block.appendChild(body);
|
|
793
|
+
if (onForget) block.appendChild(link(t("web.settings.forget"), "mem-del", onForget));
|
|
794
|
+
return block;
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
const draw = () => {
|
|
798
|
+
list.textContent = "";
|
|
799
|
+
const q = search.value.trim().toLowerCase();
|
|
800
|
+
let any = false;
|
|
801
|
+
(data.categories || []).forEach((cat) => {
|
|
802
|
+
const entries = (cat.entries || []).filter(
|
|
803
|
+
(e) => !q || String(e.text).toLowerCase().indexOf(q) !== -1,
|
|
804
|
+
);
|
|
805
|
+
if (!entries.length) return;
|
|
806
|
+
any = true;
|
|
807
|
+
list.appendChild(categoryHeader(cat.name, entries.length));
|
|
808
|
+
entries.forEach((e) => {
|
|
809
|
+
list.appendChild(
|
|
810
|
+
entryBlock(e.text, async () => {
|
|
811
|
+
const r = await api(
|
|
812
|
+
"/api/memory/" + encodeURIComponent(cat.name) + "/" + e.n,
|
|
813
|
+
{ method: "DELETE" },
|
|
814
|
+
);
|
|
815
|
+
if (r.ok) await loadMemory();
|
|
816
|
+
else setIndicator(t("web.settings.forget_failed"), true);
|
|
817
|
+
}),
|
|
818
|
+
);
|
|
819
|
+
});
|
|
820
|
+
});
|
|
821
|
+
const prefs = Object.entries(data.preferences || {});
|
|
822
|
+
if (prefs.length && !q) {
|
|
823
|
+
any = true;
|
|
824
|
+
list.appendChild(categoryHeader(t("web.settings.preferences"), prefs.length));
|
|
825
|
+
prefs.forEach(([k, v]) => {
|
|
826
|
+
list.appendChild(entryBlock(k + " = " + v));
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
if (!any) {
|
|
830
|
+
const empty = el("div", "placeholder");
|
|
831
|
+
empty.textContent = q ? t("web.settings.no_matches") : t("web.settings.no_memory");
|
|
832
|
+
list.appendChild(empty);
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
search.addEventListener("input", draw);
|
|
836
|
+
draw();
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function renderSkillsPanel() {
|
|
840
|
+
const container = document.querySelector('[data-panel="skills"]');
|
|
841
|
+
if (!container) return;
|
|
842
|
+
container.textContent = "";
|
|
843
|
+
if (S.state.skills === undefined || S.state.skills === null) {
|
|
844
|
+
container.appendChild(placeholderFor(S.state.skills, t("web.settings.skills_unavailable")));
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (!S.state.skills.length) {
|
|
848
|
+
const empty = el("div", "placeholder");
|
|
849
|
+
empty.textContent = t("web.settings.no_skills");
|
|
850
|
+
container.appendChild(empty);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const list = el("div", "settings-list");
|
|
854
|
+
S.state.skills.forEach((s) => {
|
|
855
|
+
const item = el("div", "skill-item");
|
|
856
|
+
const head = el("div", "skill-head");
|
|
857
|
+
head.appendChild(el("span", s.enabled ? "g" : "dim", s.enabled ? "✓" : "○"));
|
|
858
|
+
const nameEl = el("span", "name", s.name);
|
|
859
|
+
nameEl.title = s.name;
|
|
860
|
+
head.appendChild(nameEl);
|
|
861
|
+
head.appendChild(el("span", "st", s.enabled ? t("web.settings.enabled") : t("web.settings.available")));
|
|
862
|
+
item.appendChild(head);
|
|
863
|
+
if (s.source) {
|
|
864
|
+
const src = el("div", "skill-src", s.source);
|
|
865
|
+
src.title = s.source;
|
|
866
|
+
item.appendChild(src);
|
|
867
|
+
}
|
|
868
|
+
list.appendChild(item);
|
|
869
|
+
});
|
|
870
|
+
container.appendChild(list);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function renderPluginsPanel() {
|
|
874
|
+
const container = document.querySelector('[data-panel="plugins"]');
|
|
875
|
+
if (!container) return;
|
|
876
|
+
container.textContent = "";
|
|
877
|
+
if (S.state.pluginsData === undefined || S.state.pluginsData === null) {
|
|
878
|
+
container.appendChild(placeholderFor(S.state.pluginsData, t("web.settings.plugins_unavailable")));
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const data = S.state.pluginsData;
|
|
882
|
+
const list = el("div", "settings-list");
|
|
883
|
+
list.appendChild(groupHeader(t("web.settings.plugins_header"), (data.plugins || []).length));
|
|
884
|
+
if (!(data.plugins || []).length) {
|
|
885
|
+
const empty = el("div", "placeholder");
|
|
886
|
+
empty.textContent = t("web.settings.no_plugins");
|
|
887
|
+
list.appendChild(empty);
|
|
888
|
+
}
|
|
889
|
+
(data.plugins || []).forEach((p) => {
|
|
890
|
+
const line = el("div", "list-line");
|
|
891
|
+
line.appendChild(el("span", "g", "✓"));
|
|
892
|
+
const nameEl = el("span", "name", p.name);
|
|
893
|
+
nameEl.title = p.name;
|
|
894
|
+
line.appendChild(nameEl);
|
|
895
|
+
if (p.version) line.appendChild(el("span", "src", "v" + p.version));
|
|
896
|
+
if (p.source) {
|
|
897
|
+
const src = el("span", "src", p.source);
|
|
898
|
+
src.title = p.source;
|
|
899
|
+
line.appendChild(src);
|
|
900
|
+
}
|
|
901
|
+
if (p.builtin) line.appendChild(el("span", "src", t("web.settings.builtin")));
|
|
902
|
+
line.appendChild(el("span", "st", p.enabled === false ? t("web.settings.disabled") : t("web.settings.enabled")));
|
|
903
|
+
list.appendChild(line);
|
|
904
|
+
});
|
|
905
|
+
const pnote = el("div", "settings-note");
|
|
906
|
+
pnote.textContent = t("web.settings.plugins_note");
|
|
907
|
+
list.appendChild(pnote);
|
|
908
|
+
|
|
909
|
+
list.appendChild(groupHeader(t("web.settings.mcp_servers"), (data.mcp || []).length));
|
|
910
|
+
if (!(data.mcp || []).length) {
|
|
911
|
+
const empty = el("div", "placeholder");
|
|
912
|
+
empty.textContent = t("web.settings.no_mcp");
|
|
913
|
+
list.appendChild(empty);
|
|
914
|
+
}
|
|
915
|
+
(data.mcp || []).forEach((m) => {
|
|
916
|
+
const line = el("div", "list-line");
|
|
917
|
+
const on = m.status !== "disabled";
|
|
918
|
+
const toggle = makeToggle(on, (next) => {
|
|
919
|
+
api("/api/mcp/" + encodeURIComponent(m.id || m.name), {
|
|
920
|
+
method: "POST",
|
|
921
|
+
body: { enabled: next },
|
|
922
|
+
}).then(async (r) => {
|
|
923
|
+
if (r.ok && r.data && r.data.ok) {
|
|
924
|
+
setIndicator(t("web.settings.mcp_saved"), false);
|
|
925
|
+
await loadPlugins();
|
|
926
|
+
} else {
|
|
927
|
+
setIndicator(t("web.settings.save_failed"), true);
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
toggle.title = on ? t("web.settings.mcp_disable") : t("web.settings.mcp_enable");
|
|
932
|
+
toggle.setAttribute("aria-label", toggle.title);
|
|
933
|
+
line.appendChild(toggle);
|
|
934
|
+
const nameEl = el("span", "name", m.name);
|
|
935
|
+
nameEl.title = m.name;
|
|
936
|
+
line.appendChild(nameEl);
|
|
937
|
+
line.appendChild(el("span", "st", m.status || "—"));
|
|
938
|
+
list.appendChild(line);
|
|
939
|
+
});
|
|
940
|
+
if ((data.mcp || []).length) {
|
|
941
|
+
const mnote = el("div", "settings-note");
|
|
942
|
+
mnote.textContent = t("web.settings.mcp_note");
|
|
943
|
+
list.appendChild(mnote);
|
|
944
|
+
}
|
|
945
|
+
container.appendChild(list);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
export async function loadMemory() {
|
|
949
|
+
const r = await api("/api/memory");
|
|
950
|
+
S.state.memory = r.ok && r.data ? r.data : null;
|
|
951
|
+
renderMemoryPanel();
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
export async function loadSkills() {
|
|
955
|
+
const r = await api("/api/skills");
|
|
956
|
+
S.state.skills = r.ok && r.data && Array.isArray(r.data.skills) ? r.data.skills : null;
|
|
957
|
+
renderSkillsPanel();
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export async function loadPlugins() {
|
|
961
|
+
const r = await api("/api/plugins");
|
|
962
|
+
S.state.pluginsData =
|
|
963
|
+
r.ok && r.data
|
|
964
|
+
? { plugins: r.data.plugins || [], mcp: r.data.mcp || [] }
|
|
965
|
+
: null;
|
|
966
|
+
renderPluginsPanel();
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
export function renderSettings() {
|
|
970
|
+
renderGenericPanel("general", SECTION_KEYS.general);
|
|
971
|
+
renderModelPanel();
|
|
972
|
+
renderGenericPanel("context", SECTION_KEYS.context);
|
|
973
|
+
renderGenericPanel("reasoning", SECTION_KEYS.reasoning);
|
|
974
|
+
renderGenericPanel("agent", SECTION_KEYS.agent);
|
|
975
|
+
renderUiPanel();
|
|
976
|
+
renderMemoryPanel();
|
|
977
|
+
renderSkillsPanel();
|
|
978
|
+
renderPluginsPanel();
|
|
979
|
+
renderSecurityPanel();
|
|
980
|
+
renderGenericPanel("advanced", SECTION_KEYS.advanced);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const overlay = document.getElementById("overlay");
|
|
984
|
+
const settingsBtn = document.getElementById("openSettings");
|
|
985
|
+
const closeSettingsBtn = document.getElementById("closeSettings");
|
|
986
|
+
let settingsReturnFocus = null;
|
|
987
|
+
|
|
988
|
+
// Settings sections act as a real tablist: roles, roving tabindex, arrow keys.
|
|
989
|
+
const settingsTabs = Array.from(document.querySelectorAll("#setMenu .mi"));
|
|
990
|
+
function selectSettingsPanel(name) {
|
|
991
|
+
settingsTabs.forEach((x) => {
|
|
992
|
+
const on = x.dataset.p === name;
|
|
993
|
+
x.classList.toggle("on", on);
|
|
994
|
+
x.setAttribute("aria-selected", on ? "true" : "false");
|
|
995
|
+
x.tabIndex = on ? 0 : -1;
|
|
996
|
+
});
|
|
997
|
+
document.querySelectorAll(".content-col .panel").forEach((x) => x.classList.remove("on"));
|
|
998
|
+
const panel = document.getElementById("p-" + name);
|
|
999
|
+
if (panel) panel.classList.add("on");
|
|
1000
|
+
}
|
|
1001
|
+
settingsTabs.forEach((mi) => {
|
|
1002
|
+
const name = mi.dataset.p;
|
|
1003
|
+
mi.setAttribute("role", "tab");
|
|
1004
|
+
mi.setAttribute("id", "tab-" + name);
|
|
1005
|
+
mi.setAttribute("aria-controls", "p-" + name);
|
|
1006
|
+
mi.setAttribute("aria-selected", mi.classList.contains("on") ? "true" : "false");
|
|
1007
|
+
mi.tabIndex = mi.classList.contains("on") ? 0 : -1;
|
|
1008
|
+
mi.addEventListener("click", () => selectSettingsPanel(name));
|
|
1009
|
+
mi.addEventListener("keydown", (e) => {
|
|
1010
|
+
const i = settingsTabs.indexOf(mi);
|
|
1011
|
+
let next = null;
|
|
1012
|
+
if (e.key === "ArrowDown" || e.key === "ArrowRight") next = settingsTabs[(i + 1) % settingsTabs.length];
|
|
1013
|
+
else if (e.key === "ArrowUp" || e.key === "ArrowLeft") next = settingsTabs[(i - 1 + settingsTabs.length) % settingsTabs.length];
|
|
1014
|
+
else if (e.key === "Home") next = settingsTabs[0];
|
|
1015
|
+
else if (e.key === "End") next = settingsTabs[settingsTabs.length - 1];
|
|
1016
|
+
if (next) {
|
|
1017
|
+
e.preventDefault();
|
|
1018
|
+
selectSettingsPanel(next.dataset.p);
|
|
1019
|
+
next.focus();
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
1022
|
+
});
|
|
1023
|
+
document.querySelectorAll(".content-col .panel").forEach((panel) => {
|
|
1024
|
+
panel.setAttribute("role", "tabpanel");
|
|
1025
|
+
panel.setAttribute("aria-labelledby", "tab-" + panel.id.replace(/^p-/, ""));
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
function openSettings() {
|
|
1029
|
+
settingsReturnFocus = document.activeElement;
|
|
1030
|
+
overlay.classList.add("open");
|
|
1031
|
+
settingsBtn.setAttribute("aria-expanded", "true");
|
|
1032
|
+
void loadMemory();
|
|
1033
|
+
void loadSkills();
|
|
1034
|
+
void loadPlugins();
|
|
1035
|
+
const active = document.querySelector("#setMenu .mi.on") || settingsTabs[0];
|
|
1036
|
+
if (active) setTimeout(() => active.focus(), 0);
|
|
1037
|
+
}
|
|
1038
|
+
function closeSettings() {
|
|
1039
|
+
hideProviderOverlay();
|
|
1040
|
+
S.state.providerForm = null;
|
|
1041
|
+
overlay.classList.remove("open");
|
|
1042
|
+
settingsBtn.setAttribute("aria-expanded", "false");
|
|
1043
|
+
if (settingsReturnFocus && settingsReturnFocus.focus) settingsReturnFocus.focus();
|
|
1044
|
+
settingsReturnFocus = null;
|
|
1045
|
+
}
|
|
1046
|
+
settingsBtn.addEventListener("click", openSettings);
|
|
1047
|
+
closeSettingsBtn.addEventListener("click", closeSettings);
|
|
1048
|
+
overlay.addEventListener("click", (e) => {
|
|
1049
|
+
if (e.target === overlay) closeSettings();
|
|
1050
|
+
});
|
|
1051
|
+
// Trap Tab inside the dialog while it is open.
|
|
1052
|
+
overlay.addEventListener("keydown", (e) => trapTab(overlay, e));
|
|
1053
|
+
|
|
1054
|
+
const providerOverlay = document.getElementById("providerOverlay");
|
|
1055
|
+
const providerFormHost = document.getElementById("providerFormHost");
|
|
1056
|
+
const providerFormTitle = document.getElementById("providerFormTitle");
|
|
1057
|
+
const closeProviderBtn = document.getElementById("closeProvider");
|
|
1058
|
+
let providerReturnFocus = null;
|
|
1059
|
+
|
|
1060
|
+
function isProviderOpen() {
|
|
1061
|
+
return !!providerOverlay && providerOverlay.classList.contains("open");
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function openProviderForm(mode, entry) {
|
|
1065
|
+
if (!providerOverlay || !providerFormHost) return;
|
|
1066
|
+
S.state.providerForm = { mode, entry: entry || null };
|
|
1067
|
+
providerReturnFocus = document.activeElement;
|
|
1068
|
+
providerFormTitle.textContent = t(
|
|
1069
|
+
mode === "edit" ? "web.settings.provider_edit_title" : "web.settings.provider_add_title",
|
|
1070
|
+
);
|
|
1071
|
+
providerFormHost.textContent = "";
|
|
1072
|
+
providerFormHost.appendChild(providerForm());
|
|
1073
|
+
providerOverlay.classList.add("open");
|
|
1074
|
+
setTimeout(() => {
|
|
1075
|
+
const first = providerFormHost.querySelector("input, select");
|
|
1076
|
+
if (first) first.focus();
|
|
1077
|
+
}, 0);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function hideProviderOverlay() {
|
|
1081
|
+
if (!providerOverlay) return;
|
|
1082
|
+
providerOverlay.classList.remove("open");
|
|
1083
|
+
if (providerReturnFocus && providerReturnFocus.focus) providerReturnFocus.focus();
|
|
1084
|
+
providerReturnFocus = null;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function closeProviderForm() {
|
|
1088
|
+
if (!isProviderOpen()) return;
|
|
1089
|
+
S.state.providerForm = null;
|
|
1090
|
+
hideProviderOverlay();
|
|
1091
|
+
renderModelPanel();
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (providerOverlay) {
|
|
1095
|
+
if (closeProviderBtn) closeProviderBtn.addEventListener("click", closeProviderForm);
|
|
1096
|
+
providerOverlay.addEventListener("click", (e) => {
|
|
1097
|
+
e.stopPropagation();
|
|
1098
|
+
if (e.target === providerOverlay) closeProviderForm();
|
|
1099
|
+
});
|
|
1100
|
+
// Keep Tab inside the nested dialog while it is open.
|
|
1101
|
+
providerOverlay.addEventListener("keydown", (e) => trapTab(providerOverlay, e));
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
document.addEventListener("keydown", (e) => {
|
|
1105
|
+
if (e.key !== "Escape") return;
|
|
1106
|
+
if (isProviderOpen()) closeProviderForm();
|
|
1107
|
+
else if (confirmOverlay && confirmOverlay.classList.contains("open")) closeConfirm();
|
|
1108
|
+
else if (overlay.classList.contains("open")) closeSettings();
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
export function currentTheme() {
|
|
1112
|
+
return localStorage.getItem(THEME_KEY) || "auto";
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
export function applyTheme(theme) {
|
|
1116
|
+
localStorage.setItem(THEME_KEY, theme);
|
|
1117
|
+
const light = theme === "light" || (theme === "auto" && window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches);
|
|
1118
|
+
document.body.classList.toggle("light", !!light);
|
|
1119
|
+
renderUiPanel();
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if (window.matchMedia) {
|
|
1123
|
+
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => {
|
|
1124
|
+
if (currentTheme() === "auto") applyTheme("auto");
|
|
1125
|
+
});
|
|
1126
|
+
}
|