micro-models-agent 2.0.0 → 2.4.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 +72 -0
- package/README.md +6 -3
- package/dist/assets/app.js +3051 -0
- package/dist/assets/index.html +131 -0
- package/dist/assets/styles.css +863 -0
- package/dist/i18n/en.json +274 -4
- package/dist/i18n/ru.json +274 -4
- package/dist/main.js +3925 -247
- package/package.json +1 -1
|
@@ -0,0 +1,3051 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const token = new URLSearchParams(location.search).get("token") || "";
|
|
5
|
+
const THEME_KEY = "mma-theme";
|
|
6
|
+
const MAX_IMAGES = 3;
|
|
7
|
+
const MAX_IMAGE_BYTES = 6 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
const msgs = document.getElementById("msgs");
|
|
10
|
+
const inp = document.getElementById("inp");
|
|
11
|
+
const tabstripEl = document.getElementById("tabstrip");
|
|
12
|
+
const planEl = document.getElementById("plan");
|
|
13
|
+
const planCountEl = document.getElementById("planCount");
|
|
14
|
+
const usageEl = document.getElementById("usage");
|
|
15
|
+
const envEl = document.getElementById("env");
|
|
16
|
+
const wsLine = document.getElementById("wsLine");
|
|
17
|
+
const saveIndicator = document.getElementById("saveIndicator");
|
|
18
|
+
const attachStrip = document.getElementById("attachStrip");
|
|
19
|
+
const attachErr = document.getElementById("attachErr");
|
|
20
|
+
const imgPicker = document.getElementById("imgPicker");
|
|
21
|
+
const connStatus = document.getElementById("connStatus");
|
|
22
|
+
const liveEl = document.getElementById("live");
|
|
23
|
+
const sendBtn = document.getElementById("sendBtn");
|
|
24
|
+
|
|
25
|
+
// Announce a short status line to assistive tech (turn end, warnings/errors).
|
|
26
|
+
// Clearing first forces repeats to be re-announced.
|
|
27
|
+
function announce(text) {
|
|
28
|
+
if (!liveEl || !text) return;
|
|
29
|
+
liveEl.textContent = "";
|
|
30
|
+
setTimeout(() => {
|
|
31
|
+
liveEl.textContent = text;
|
|
32
|
+
}, 30);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const CATEGORIES = {
|
|
36
|
+
recon: [
|
|
37
|
+
"read_file", "list_dir", "glob", "grep", "file_info", "project_map",
|
|
38
|
+
"chunk_query", "web_search", "web_fetch", "history", "recall",
|
|
39
|
+
"process_list", "process_log",
|
|
40
|
+
],
|
|
41
|
+
mutate: [
|
|
42
|
+
"write_file", "edit_file", "create_dir", "delete_file", "move_file",
|
|
43
|
+
"attach_image",
|
|
44
|
+
],
|
|
45
|
+
execute: [
|
|
46
|
+
"bash", "process_kill", "pipeline_run", "browser", "download_file",
|
|
47
|
+
"subagent", "mcp_call",
|
|
48
|
+
],
|
|
49
|
+
meta: [
|
|
50
|
+
"plan", "todo", "verify", "lsp_check", "enable_tools", "set_thinking",
|
|
51
|
+
"load_skill", "remember",
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const FILE_TOOLS = ["read_file", "write_file", "edit_file", "list_dir", "delete_file", "move_file", "file_info"];
|
|
56
|
+
const IMAGE_TOOLS = ["attach_image", "browser"];
|
|
57
|
+
const LINK_TOOLS = ["web_fetch", "download_file"];
|
|
58
|
+
|
|
59
|
+
const state = {
|
|
60
|
+
config: null,
|
|
61
|
+
registry: [],
|
|
62
|
+
webToken: null,
|
|
63
|
+
models: [],
|
|
64
|
+
sessions: [],
|
|
65
|
+
host: {},
|
|
66
|
+
busy: false,
|
|
67
|
+
activeSessionId: null,
|
|
68
|
+
memory: undefined,
|
|
69
|
+
skills: undefined,
|
|
70
|
+
pluginsData: undefined,
|
|
71
|
+
providers: undefined,
|
|
72
|
+
providerForm: null,
|
|
73
|
+
// Transient error shown inline in the sessions dropdown (visible without the modal).
|
|
74
|
+
sessionsError: null,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
let tabs = [];
|
|
78
|
+
let activeTabId = null;
|
|
79
|
+
let liveTabId = null;
|
|
80
|
+
let newSeq = 0;
|
|
81
|
+
let attachments = [];
|
|
82
|
+
let attachErrTimer = null;
|
|
83
|
+
|
|
84
|
+
let curText = null;
|
|
85
|
+
let curTextNodes = [];
|
|
86
|
+
let curMeta = null;
|
|
87
|
+
let lastPlanSig = null;
|
|
88
|
+
let lastUsageSig = null;
|
|
89
|
+
let lastEnvSig = null;
|
|
90
|
+
let runCount = 0;
|
|
91
|
+
let runFails = 0;
|
|
92
|
+
let busyTimer = null;
|
|
93
|
+
const pendingArgs = new Map();
|
|
94
|
+
const toolNodes = new Map();
|
|
95
|
+
|
|
96
|
+
function el(tag, cls, text) {
|
|
97
|
+
const node = document.createElement(tag);
|
|
98
|
+
if (cls) node.className = cls;
|
|
99
|
+
if (text != null) node.textContent = text;
|
|
100
|
+
return node;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- Lucide icons (MIT), inlined: chrome only. Chat status glyphs (◇ ✓ ✗ ✦ ⟡ …)
|
|
104
|
+
// stay textual per docs/design/ui/STYLE.md; icons replace emoji/gear chrome.
|
|
105
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
106
|
+
const ICONS = {
|
|
107
|
+
plus: [["path", { d: "M5 12h14" }], ["path", { d: "M12 5v14" }]],
|
|
108
|
+
menu: [
|
|
109
|
+
["line", { x1: 4, y1: 6, x2: 20, y2: 6 }],
|
|
110
|
+
["line", { x1: 4, y1: 12, x2: 20, y2: 12 }],
|
|
111
|
+
["line", { x1: 4, y1: 18, x2: 20, y2: 18 }],
|
|
112
|
+
],
|
|
113
|
+
settings: [
|
|
114
|
+
["path", { d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" }],
|
|
115
|
+
["circle", { cx: 12, cy: 12, r: 3 }],
|
|
116
|
+
],
|
|
117
|
+
image: [
|
|
118
|
+
["rect", { width: 18, height: 18, x: 3, y: 3, rx: 2, ry: 2 }],
|
|
119
|
+
["circle", { cx: 9, cy: 9, r: 2 }],
|
|
120
|
+
["path", { d: "m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" }],
|
|
121
|
+
],
|
|
122
|
+
x: [["path", { d: "M18 6 6 18" }], ["path", { d: "m6 6 12 12" }]],
|
|
123
|
+
trash: [
|
|
124
|
+
["path", { d: "M3 6h18" }],
|
|
125
|
+
["path", { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" }],
|
|
126
|
+
["path", { d: "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }],
|
|
127
|
+
["line", { x1: 10, y1: 11, x2: 10, y2: 17 }],
|
|
128
|
+
["line", { x1: 14, y1: 11, x2: 14, y2: 17 }],
|
|
129
|
+
],
|
|
130
|
+
refresh: [
|
|
131
|
+
["path", { d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" }],
|
|
132
|
+
["path", { d: "M21 3v5h-5" }],
|
|
133
|
+
["path", { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" }],
|
|
134
|
+
["path", { d: "M8 21H3v-5" }],
|
|
135
|
+
],
|
|
136
|
+
"chevron-down": [["path", { d: "m6 9 6 6 6-6" }]],
|
|
137
|
+
"chevron-right": [["path", { d: "m9 18 6-6-6-6" }]],
|
|
138
|
+
"arrow-up": [["path", { d: "M12 19V5" }], ["path", { d: "m5 12 7-7 7 7" }]],
|
|
139
|
+
square: [["rect", { width: 14, height: 14, x: 5, y: 5, rx: 2 }]],
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
function icon(name, cls) {
|
|
143
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
144
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
145
|
+
svg.setAttribute("fill", "none");
|
|
146
|
+
svg.setAttribute("stroke", "currentColor");
|
|
147
|
+
svg.setAttribute("stroke-width", "2");
|
|
148
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
149
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
150
|
+
svg.setAttribute("aria-hidden", "true");
|
|
151
|
+
svg.setAttribute("focusable", "false");
|
|
152
|
+
svg.setAttribute("class", "svg-icon" + (cls ? " " + cls : ""));
|
|
153
|
+
(ICONS[name] || []).forEach(([tag, attrs]) => {
|
|
154
|
+
const child = document.createElementNS(SVG_NS, tag);
|
|
155
|
+
Object.keys(attrs).forEach((k) => child.setAttribute(k, String(attrs[k])));
|
|
156
|
+
svg.appendChild(child);
|
|
157
|
+
});
|
|
158
|
+
return svg;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Replace a node's contents with an icon + optional label, keeping it a real
|
|
162
|
+
// control. Returns the label node so callers can preserve text-only updates.
|
|
163
|
+
function setIcon(button, name, label) {
|
|
164
|
+
if (!button) return null;
|
|
165
|
+
button.textContent = "";
|
|
166
|
+
button.appendChild(icon(name));
|
|
167
|
+
if (label != null) button.appendChild(el("span", "", label));
|
|
168
|
+
return button;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Text action control (`edit`, `forget`, `add`, …) as a focusable <button>.
|
|
172
|
+
function link(label, cls, onClick) {
|
|
173
|
+
const b = el("button", "linkbtn" + (cls ? " " + cls : ""), label);
|
|
174
|
+
b.type = "button";
|
|
175
|
+
b.addEventListener("click", onClick);
|
|
176
|
+
return b;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Upgrade an existing non-button node (a span/div kept for layout) into a
|
|
180
|
+
// keyboard-activatable control.
|
|
181
|
+
function activate(node, fn) {
|
|
182
|
+
node.setAttribute("role", "button");
|
|
183
|
+
if (!node.hasAttribute("tabindex")) node.setAttribute("tabindex", "0");
|
|
184
|
+
node.addEventListener("click", fn);
|
|
185
|
+
node.addEventListener("keydown", (e) => {
|
|
186
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
fn(e);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return node;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Render raw memory text (`- **date** -- **pattern:** body`) as safe DOM nodes:
|
|
195
|
+
// `**…**` spans become <b>, everything else stays a text node. Never uses
|
|
196
|
+
// innerHTML, so untrusted/model-written content cannot inject markup.
|
|
197
|
+
function inlineTextNodes(text) {
|
|
198
|
+
const frag = document.createDocumentFragment();
|
|
199
|
+
const raw = String(text == null ? "" : text).replace(/^-\s*/, "");
|
|
200
|
+
raw.split("**").forEach((part, i) => {
|
|
201
|
+
if (part === "") return;
|
|
202
|
+
if (i % 2 === 1) {
|
|
203
|
+
const b = document.createElement("b");
|
|
204
|
+
b.textContent = part;
|
|
205
|
+
frag.appendChild(b);
|
|
206
|
+
} else {
|
|
207
|
+
frag.appendChild(document.createTextNode(part));
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
return frag;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function setPath(obj, path, value) {
|
|
214
|
+
const parts = path.split(".");
|
|
215
|
+
let cur = obj;
|
|
216
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
217
|
+
if (cur[parts[i]] == null || typeof cur[parts[i]] !== "object") cur[parts[i]] = {};
|
|
218
|
+
cur = cur[parts[i]];
|
|
219
|
+
}
|
|
220
|
+
cur[parts[parts.length - 1]] = value;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function getPath(obj, path) {
|
|
224
|
+
const parts = path.split(".");
|
|
225
|
+
let cur = obj;
|
|
226
|
+
for (const p of parts) {
|
|
227
|
+
if (cur == null || typeof cur !== "object") return undefined;
|
|
228
|
+
cur = cur[p];
|
|
229
|
+
}
|
|
230
|
+
return cur;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function api(path, opts = {}) {
|
|
234
|
+
const headers = Object.assign({}, opts.headers || {});
|
|
235
|
+
if (opts.body !== undefined) headers["content-type"] = "application/json";
|
|
236
|
+
if (token) headers["x-mma-token"] = token;
|
|
237
|
+
let res;
|
|
238
|
+
try {
|
|
239
|
+
res = await fetch(path, {
|
|
240
|
+
method: opts.method || "GET",
|
|
241
|
+
headers,
|
|
242
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
243
|
+
});
|
|
244
|
+
} catch (err) {
|
|
245
|
+
return { ok: false, status: 0, data: null };
|
|
246
|
+
}
|
|
247
|
+
let data = null;
|
|
248
|
+
try {
|
|
249
|
+
data = await res.json();
|
|
250
|
+
} catch (err) {
|
|
251
|
+
data = null;
|
|
252
|
+
}
|
|
253
|
+
return { ok: res.ok, status: res.status, data };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const I18N = { locale: "en", dicts: {} };
|
|
257
|
+
|
|
258
|
+
function t(key, params) {
|
|
259
|
+
const dict = I18N.dicts[I18N.locale] || I18N.dicts.en || {};
|
|
260
|
+
const en = I18N.dicts.en || {};
|
|
261
|
+
let template = dict[key];
|
|
262
|
+
if (template === undefined) template = en[key];
|
|
263
|
+
if (template === undefined) return key;
|
|
264
|
+
if (params) {
|
|
265
|
+
Object.keys(params).forEach((k) => {
|
|
266
|
+
template = template.split("{" + k + "}").join(String(params[k]));
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return template;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Plural form for a `{count}` template: uses `baseKey_one|_few|_many`
|
|
273
|
+
// (RU one/few/many; EN one/many) and falls back to `baseKey` if absent.
|
|
274
|
+
function pluralCount(baseKey, n) {
|
|
275
|
+
let form;
|
|
276
|
+
if (I18N.locale === "ru") {
|
|
277
|
+
const m10 = n % 10;
|
|
278
|
+
const m100 = n % 100;
|
|
279
|
+
if (m10 === 1 && m100 !== 11) form = "one";
|
|
280
|
+
else if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20)) form = "few";
|
|
281
|
+
else form = "many";
|
|
282
|
+
} else {
|
|
283
|
+
form = n === 1 ? "one" : "many";
|
|
284
|
+
}
|
|
285
|
+
const key = baseKey + "_" + form;
|
|
286
|
+
const s = t(key, { count: n });
|
|
287
|
+
return s === key ? t(baseKey, { count: n }) : s;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function loadI18n() {
|
|
291
|
+
const r = await api("/api/i18n");
|
|
292
|
+
if (r.ok && r.data) {
|
|
293
|
+
I18N.locale = r.data.locale || "en";
|
|
294
|
+
I18N.dicts = r.data.dicts || {};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function categoryOf(name) {
|
|
299
|
+
for (const cat of Object.keys(CATEGORIES)) {
|
|
300
|
+
if (CATEGORIES[cat].indexOf(name) !== -1) return cat;
|
|
301
|
+
}
|
|
302
|
+
return "meta";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Friendly, localized tool label from `tool.friendly.*`; humanize unknown names.
|
|
306
|
+
function labelOf(name) {
|
|
307
|
+
const key = "tool.friendly." + name;
|
|
308
|
+
const s = t(key);
|
|
309
|
+
if (s !== key) return s;
|
|
310
|
+
const human = String(name).replace(/_/g, " ");
|
|
311
|
+
return human.charAt(0).toUpperCase() + human.slice(1);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function argText(args) {
|
|
315
|
+
if (!args || typeof args !== "object") return "";
|
|
316
|
+
const keys = ["path", "file", "dir", "command", "query", "url", "pattern", "name", "id", "text", "prompt"];
|
|
317
|
+
for (const key of keys) {
|
|
318
|
+
if (typeof args[key] === "string" && args[key]) return args[key];
|
|
319
|
+
}
|
|
320
|
+
const keys2 = Object.keys(args);
|
|
321
|
+
if (!keys2.length) return "";
|
|
322
|
+
const val = args[keys2[0]];
|
|
323
|
+
if (typeof val === "string") return val;
|
|
324
|
+
if (typeof val === "number" || typeof val === "boolean") return String(val);
|
|
325
|
+
try {
|
|
326
|
+
const json = JSON.stringify(args);
|
|
327
|
+
return json === "{}" ? "" : json.slice(0, 120);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
return "";
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function formatMs(ms) {
|
|
334
|
+
if (typeof ms !== "number" || !isFinite(ms)) return "";
|
|
335
|
+
return ms >= 1000 ? (ms / 1000).toFixed(1) + "s" : Math.round(ms) + "ms";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function relTime(ts) {
|
|
339
|
+
if (!ts) return "";
|
|
340
|
+
const diff = Date.now() - new Date(ts).getTime();
|
|
341
|
+
if (!isFinite(diff) || diff < 0) return "";
|
|
342
|
+
const min = Math.floor(diff / 60000);
|
|
343
|
+
if (min < 1) return "now";
|
|
344
|
+
if (min < 60) return min + "m";
|
|
345
|
+
const hrs = Math.floor(min / 60);
|
|
346
|
+
if (hrs < 24) return hrs + "h";
|
|
347
|
+
const days = Math.floor(hrs / 24);
|
|
348
|
+
return days + "d";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function emptyView() {
|
|
352
|
+
const wrap = el("div", "empty");
|
|
353
|
+
wrap.appendChild(el("span", "g", "✦"));
|
|
354
|
+
wrap.appendChild(document.createTextNode(" " + t("web.empty.title")));
|
|
355
|
+
wrap.appendChild(document.createElement("br"));
|
|
356
|
+
wrap.appendChild(el("span", "hint", t("web.empty.hint")));
|
|
357
|
+
return wrap;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function makeFeed() {
|
|
361
|
+
const feed = el("div", "feed");
|
|
362
|
+
feed.appendChild(emptyView());
|
|
363
|
+
return feed;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function showEmptyFeed() {
|
|
367
|
+
msgs.replaceChildren(emptyView());
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function clearFeed(feed) {
|
|
371
|
+
if (!feed) return;
|
|
372
|
+
feed.replaceChildren(emptyView());
|
|
373
|
+
const tab = tabs.find((x) => x.feed === feed);
|
|
374
|
+
if (tab) tab.planCard = null;
|
|
375
|
+
if (feed === activeFeed()) scroll();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function activeFeed() {
|
|
379
|
+
const tab = tabs.find((x) => x.id === activeTabId);
|
|
380
|
+
return tab ? tab.feed : null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function liveFeed() {
|
|
384
|
+
const tab = tabs.find((x) => x.id === liveTabId) || tabs.find((x) => x.id === activeTabId);
|
|
385
|
+
return tab ? tab.feed : null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Route a server event to the tab that owns its session (item from `broadcast`
|
|
389
|
+
// enrichment); fall back to the live/active tab for events without one.
|
|
390
|
+
function feedForEvent(sessionId) {
|
|
391
|
+
if (sessionId) {
|
|
392
|
+
const tab = tabs.find((x) => x.id === sessionId);
|
|
393
|
+
if (tab) return tab.feed;
|
|
394
|
+
}
|
|
395
|
+
return liveFeed();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function nearBottom() {
|
|
399
|
+
return msgs.scrollHeight - msgs.scrollTop - msgs.clientHeight < 140;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function scroll() {
|
|
403
|
+
if (nearBottom()) msgs.scrollTop = msgs.scrollHeight;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function appendTo(feed, node) {
|
|
407
|
+
if (!feed) return node;
|
|
408
|
+
feed.appendChild(node);
|
|
409
|
+
if (feed === activeFeed()) scroll();
|
|
410
|
+
return node;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function append(node) {
|
|
414
|
+
return appendTo(liveFeed(), node);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function clearEmpty(feed) {
|
|
418
|
+
const empty = feed && feed.querySelector(".empty");
|
|
419
|
+
if (empty) empty.remove();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function clearBusy(feed) {
|
|
423
|
+
if (busyTimer) {
|
|
424
|
+
clearInterval(busyTimer);
|
|
425
|
+
busyTimer = null;
|
|
426
|
+
}
|
|
427
|
+
feed = feed || liveFeed();
|
|
428
|
+
if (!feed) return;
|
|
429
|
+
const line = feed.querySelector(".busy");
|
|
430
|
+
if (line) line.remove();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Show the in-feed "thinking" line. Called immediately after a successful
|
|
434
|
+
// `/api/message` POST (202) so the wait during a slow first-run
|
|
435
|
+
// summarization is visible before the first `phase` event arrives; the
|
|
436
|
+
// normal event flow (`onText`/`onPhase`/`onTurnEnd`) clears it.
|
|
437
|
+
function showBusy(feed) {
|
|
438
|
+
feed = feed || liveFeed();
|
|
439
|
+
if (!feed) return;
|
|
440
|
+
clearEmpty(feed);
|
|
441
|
+
if (feed.querySelector(".busy")) return;
|
|
442
|
+
if (busyTimer) {
|
|
443
|
+
clearInterval(busyTimer);
|
|
444
|
+
busyTimer = null;
|
|
445
|
+
}
|
|
446
|
+
const line = el("div", "busy");
|
|
447
|
+
line.appendChild(el("span", "spin", "✦"));
|
|
448
|
+
line.appendChild(el("span", "lab", t("web.action.thinking")));
|
|
449
|
+
const time = el("span", "busytime", "· 0s");
|
|
450
|
+
line.appendChild(time);
|
|
451
|
+
line.appendChild(el("span", "", "· " + t("web.input.esc")));
|
|
452
|
+
appendTo(feed, line);
|
|
453
|
+
const started = Date.now();
|
|
454
|
+
busyTimer = setInterval(() => {
|
|
455
|
+
if (!line.parentNode) {
|
|
456
|
+
clearInterval(busyTimer);
|
|
457
|
+
busyTimer = null;
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
time.textContent = "· " + Math.round((Date.now() - started) / 1000) + "s";
|
|
461
|
+
}, 1000);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function flushRun() {
|
|
465
|
+
if (runCount <= 0) return;
|
|
466
|
+
const feed = liveFeed();
|
|
467
|
+
const line = el("div", "grouped");
|
|
468
|
+
line.appendChild(el("span", "pipe", "│"));
|
|
469
|
+
const fails = runFails > 0 ? " · " + runFails + " ✗" : " ✓";
|
|
470
|
+
line.appendChild(document.createTextNode(" " + pluralCount("web.action.actions", runCount) + fails));
|
|
471
|
+
appendTo(feed, line);
|
|
472
|
+
runCount = 0;
|
|
473
|
+
runFails = 0;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function parseFences(text) {
|
|
477
|
+
const lines = String(text).split("\n");
|
|
478
|
+
const parts = [];
|
|
479
|
+
let textBuf = [];
|
|
480
|
+
let codeBuf = [];
|
|
481
|
+
let inCode = false;
|
|
482
|
+
const flushText = () => {
|
|
483
|
+
if (textBuf.length) {
|
|
484
|
+
parts.push({ type: "text", content: textBuf.join("\n") });
|
|
485
|
+
textBuf = [];
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
for (const line of lines) {
|
|
489
|
+
if (/^\s*```/.test(line)) {
|
|
490
|
+
if (!inCode) {
|
|
491
|
+
flushText();
|
|
492
|
+
inCode = true;
|
|
493
|
+
codeBuf = [];
|
|
494
|
+
} else {
|
|
495
|
+
parts.push({ type: "code", content: codeBuf.join("\n") });
|
|
496
|
+
inCode = false;
|
|
497
|
+
codeBuf = [];
|
|
498
|
+
}
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (inCode) codeBuf.push(line);
|
|
502
|
+
else textBuf.push(line);
|
|
503
|
+
}
|
|
504
|
+
if (inCode) parts.push({ type: "code", content: codeBuf.join("\n") });
|
|
505
|
+
else flushText();
|
|
506
|
+
return parts;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function renderTextSegment(feed, raw) {
|
|
510
|
+
curTextNodes.forEach((n) => n.remove());
|
|
511
|
+
curTextNodes = [];
|
|
512
|
+
parseFences(raw).forEach((part) => {
|
|
513
|
+
if (part.type === "code") {
|
|
514
|
+
const code = el("div", "code");
|
|
515
|
+
code.textContent = part.content;
|
|
516
|
+
feed.appendChild(code);
|
|
517
|
+
curTextNodes.push(code);
|
|
518
|
+
} else if (part.content) {
|
|
519
|
+
const ans = el("div", "ans");
|
|
520
|
+
ans.textContent = part.content;
|
|
521
|
+
feed.appendChild(ans);
|
|
522
|
+
curTextNodes.push(ans);
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
if (feed === activeFeed()) scroll();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function onText(chunk, sid) {
|
|
529
|
+
const feed = feedForEvent(sid);
|
|
530
|
+
clearBusy(feed);
|
|
531
|
+
if (!feed) return;
|
|
532
|
+
clearEmpty(feed);
|
|
533
|
+
if (curMeta) {
|
|
534
|
+
finishMeta();
|
|
535
|
+
}
|
|
536
|
+
if (curText == null) {
|
|
537
|
+
flushRun();
|
|
538
|
+
curText = "";
|
|
539
|
+
curTextNodes = [];
|
|
540
|
+
}
|
|
541
|
+
curText += chunk;
|
|
542
|
+
renderTextSegment(feed, curText);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function finishMeta() {
|
|
546
|
+
if (curMeta && curMeta.startedAt) {
|
|
547
|
+
const dur = curMeta.head.querySelector(".dur");
|
|
548
|
+
if (dur) dur.textContent = "· " + ((Date.now() - curMeta.startedAt) / 1000).toFixed(1) + "s";
|
|
549
|
+
}
|
|
550
|
+
curMeta = null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function onMeta(chunk, sid) {
|
|
554
|
+
const feed = feedForEvent(sid);
|
|
555
|
+
clearBusy(feed);
|
|
556
|
+
if (!feed) return;
|
|
557
|
+
clearEmpty(feed);
|
|
558
|
+
curText = null;
|
|
559
|
+
if (!curMeta) {
|
|
560
|
+
flushRun();
|
|
561
|
+
const wrap = el("div", "think");
|
|
562
|
+
const head = el("button", "think-h");
|
|
563
|
+
head.type = "button";
|
|
564
|
+
head.setAttribute("aria-expanded", "true");
|
|
565
|
+
head.appendChild(el("span", "g", "✦"));
|
|
566
|
+
head.appendChild(el("span", "lab", t("web.action.thinking")));
|
|
567
|
+
head.appendChild(el("span", "dur", ""));
|
|
568
|
+
const tog = el("span", "tog");
|
|
569
|
+
setIcon(tog, "chevron-down", t("web.action.hide"));
|
|
570
|
+
head.appendChild(tog);
|
|
571
|
+
const body = el("div", "think-body");
|
|
572
|
+
wrap.appendChild(head);
|
|
573
|
+
wrap.appendChild(body);
|
|
574
|
+
appendTo(feed, wrap);
|
|
575
|
+
curMeta = { wrap, head, body, startedAt: Date.now() };
|
|
576
|
+
}
|
|
577
|
+
curMeta.body.textContent += chunk;
|
|
578
|
+
if (feed === activeFeed()) scroll();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function onPhase(phase, sid) {
|
|
582
|
+
const feed = feedForEvent(sid);
|
|
583
|
+
if (phase === "thinking") {
|
|
584
|
+
showBusy(feed);
|
|
585
|
+
} else {
|
|
586
|
+
clearBusy(feed);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function onToolStart(data) {
|
|
591
|
+
const feed = feedForEvent(data.sessionId);
|
|
592
|
+
clearBusy(feed);
|
|
593
|
+
if (!feed) return;
|
|
594
|
+
clearEmpty(feed);
|
|
595
|
+
curText = null;
|
|
596
|
+
finishMeta();
|
|
597
|
+
pendingArgs.set(data.toolCallId, data.args || {});
|
|
598
|
+
const line = el("div", "act " + categoryOf(data.name));
|
|
599
|
+
line.appendChild(el("span", "g", "◇"));
|
|
600
|
+
line.appendChild(el("span", "lab", labelOf(data.name)));
|
|
601
|
+
const args = argText(data.args);
|
|
602
|
+
if (args) {
|
|
603
|
+
line.appendChild(el("span", "sep", "·"));
|
|
604
|
+
line.appendChild(el("span", "args", args));
|
|
605
|
+
}
|
|
606
|
+
const status = el("span", "ok", "");
|
|
607
|
+
line.appendChild(status);
|
|
608
|
+
appendTo(feed, line);
|
|
609
|
+
toolNodes.set(data.toolCallId, { status, line });
|
|
610
|
+
runCount++;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function renderToolOutput(line, text) {
|
|
614
|
+
if (typeof text !== "string" || !text) return;
|
|
615
|
+
const wrap = el("div", "toolout");
|
|
616
|
+
const code = el("div", "code");
|
|
617
|
+
code.textContent = text;
|
|
618
|
+
wrap.appendChild(code);
|
|
619
|
+
const long = text.length > 600 || text.split("\n").length > 12;
|
|
620
|
+
if (long) {
|
|
621
|
+
wrap.classList.add("collapsed");
|
|
622
|
+
const outLabel = "▸ " + pluralCount("web.tool.output", text.length);
|
|
623
|
+
const more = el("span", "more", outLabel);
|
|
624
|
+
more.addEventListener("click", () => {
|
|
625
|
+
const collapsed = wrap.classList.toggle("collapsed");
|
|
626
|
+
more.textContent = collapsed ? outLabel : "▾ " + t("web.tool.collapse");
|
|
627
|
+
});
|
|
628
|
+
wrap.appendChild(more);
|
|
629
|
+
}
|
|
630
|
+
const feed = line && line.parentNode ? null : liveFeed();
|
|
631
|
+
if (line && line.parentNode) line.parentNode.insertBefore(wrap, line.nextSibling);
|
|
632
|
+
else appendTo(feed, wrap);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function safeUrl(url) {
|
|
636
|
+
if (typeof url !== "string" || !url) return null;
|
|
637
|
+
try {
|
|
638
|
+
const u = new URL(url, location.href);
|
|
639
|
+
if (u.protocol === "http:" || u.protocol === "https:" || u.protocol === "file:") {
|
|
640
|
+
return u.href;
|
|
641
|
+
}
|
|
642
|
+
} catch (err) {
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function cardFor(name, args, sid) {
|
|
649
|
+
const feed = feedForEvent(sid);
|
|
650
|
+
if (!feed) return;
|
|
651
|
+
if (FILE_TOOLS.indexOf(name) !== -1) {
|
|
652
|
+
const path = args.path || args.file || args.dir;
|
|
653
|
+
if (!path) return;
|
|
654
|
+
const card = el("div", "filecard");
|
|
655
|
+
card.appendChild(el("span", "g", "◇"));
|
|
656
|
+
card.appendChild(el("span", "name", path));
|
|
657
|
+
card.appendChild(el("span", "", labelOf(name)));
|
|
658
|
+
card.appendChild(el("span", "meta", t("web.card.view")));
|
|
659
|
+
appendTo(feed, card);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (LINK_TOOLS.indexOf(name) !== -1 && args.url) {
|
|
663
|
+
const line = el("div", "linkline");
|
|
664
|
+
line.appendChild(document.createTextNode("↳ "));
|
|
665
|
+
const safe = safeUrl(args.url);
|
|
666
|
+
if (safe) {
|
|
667
|
+
const a = el("a", "", args.url);
|
|
668
|
+
a.href = safe;
|
|
669
|
+
a.target = "_blank";
|
|
670
|
+
a.rel = "noopener noreferrer";
|
|
671
|
+
line.appendChild(a);
|
|
672
|
+
try {
|
|
673
|
+
const host = new URL(safe).hostname;
|
|
674
|
+
if (host) line.appendChild(el("span", "dom", " · " + host));
|
|
675
|
+
} catch (err) {
|
|
676
|
+
/* no hostname */
|
|
677
|
+
}
|
|
678
|
+
} else {
|
|
679
|
+
line.appendChild(el("span", "", args.url));
|
|
680
|
+
}
|
|
681
|
+
appendTo(feed, line);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (IMAGE_TOOLS.indexOf(name) !== -1) {
|
|
685
|
+
const card = el("div", "imgcard");
|
|
686
|
+
card.appendChild(el("div", "ph", "◧ " + t("web.card.screenshot")));
|
|
687
|
+
card.appendChild(el("div", "cap", name === "attach_image" ? t("web.card.attached_image") : t("web.card.browser_viewport")));
|
|
688
|
+
appendTo(feed, card);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function onToolEnd(data) {
|
|
693
|
+
const node = toolNodes.get(data.toolCallId);
|
|
694
|
+
const line = node ? node.line : null;
|
|
695
|
+
if (node) {
|
|
696
|
+
const dur = formatMs(data.durationMs);
|
|
697
|
+
node.status.textContent = data.success ? " ✓" + (dur ? " " + dur : "") : " ✗" + (dur ? " " + dur : "");
|
|
698
|
+
if (!data.success) {
|
|
699
|
+
node.status.classList.add("fail");
|
|
700
|
+
runFails++;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
toolNodes.delete(data.toolCallId);
|
|
704
|
+
const args = pendingArgs.get(data.toolCallId) || {};
|
|
705
|
+
pendingArgs.delete(data.toolCallId);
|
|
706
|
+
if (data.success) cardFor(data.name, args, data.sessionId);
|
|
707
|
+
renderToolOutput(line, data.output);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function onTurnEnd(data) {
|
|
711
|
+
state.busy = false;
|
|
712
|
+
const feed = feedForEvent(data.sessionId);
|
|
713
|
+
clearBusy(feed);
|
|
714
|
+
finishMeta();
|
|
715
|
+
curText = null;
|
|
716
|
+
flushRun();
|
|
717
|
+
const owner = tabs.find((x) => x.id === data.sessionId);
|
|
718
|
+
if (owner) owner.planCard = null;
|
|
719
|
+
if (!feed) return;
|
|
720
|
+
if (data.success === false) {
|
|
721
|
+
if (data.interrupted) {
|
|
722
|
+
appendTo(feed, el("div", "warnline", "⚠ " + t("web.turn.stopped")));
|
|
723
|
+
announce(t("web.turn.stopped"));
|
|
724
|
+
} else {
|
|
725
|
+
appendTo(feed, el("div", "errline", "✗ " + t("web.turn.failed")));
|
|
726
|
+
announce(t("web.turn.failed"));
|
|
727
|
+
}
|
|
728
|
+
} else {
|
|
729
|
+
const parts = [];
|
|
730
|
+
if (typeof data.toolCalls === "number" && data.toolCalls > 0) {
|
|
731
|
+
parts.push(t("web.turn.tools", { count: data.toolCalls }));
|
|
732
|
+
}
|
|
733
|
+
if (typeof data.durationMs === "number") parts.push(formatMs(data.durationMs));
|
|
734
|
+
if (parts.length) appendTo(feed, el("div", "metric", "⟡ " + parts.join(" · ")));
|
|
735
|
+
appendTo(feed, el("div", "sysline", "— " + t("web.turn.end", { count: data.toolCalls || 0 }) + " —"));
|
|
736
|
+
announce(t("web.turn.end", { count: data.toolCalls || 0 }));
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function onErrorEvent(data) {
|
|
741
|
+
if (!data || !data.error) return;
|
|
742
|
+
const feed = feedForEvent(data.sessionId);
|
|
743
|
+
if (feed) appendTo(feed, el("div", "errline", "✗ " + data.error));
|
|
744
|
+
announce(String(data.error));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function onCommandOutput(text, sid) {
|
|
748
|
+
const feed = feedForEvent(sid) || activeFeed();
|
|
749
|
+
if (!feed) return;
|
|
750
|
+
clearEmpty(feed);
|
|
751
|
+
String(text)
|
|
752
|
+
.split("\n")
|
|
753
|
+
.forEach((line) => appendTo(feed, el("div", "cmdout", line)));
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Server-forwarded `OutputBus` line (system-prompt overflow, context probe,
|
|
757
|
+
// summarization). Text is already redacted server-side; render via
|
|
758
|
+
// `textContent` so nothing untrusted is parsed as HTML.
|
|
759
|
+
function onLog(data) {
|
|
760
|
+
if (!data || typeof data.text !== "string" || !data.text) return;
|
|
761
|
+
const feed = feedForEvent(data.sessionId) || activeFeed();
|
|
762
|
+
if (!feed) return;
|
|
763
|
+
const level = data.level;
|
|
764
|
+
const cls = level === "error" ? "errline" : level === "warn" ? "warnline" : "detail";
|
|
765
|
+
const glyph = level === "error" ? "✗ " : level === "warn" ? "⚠ " : "↳ ";
|
|
766
|
+
const line = el("div", cls);
|
|
767
|
+
line.textContent = glyph + data.text;
|
|
768
|
+
appendTo(feed, line);
|
|
769
|
+
if (level === "error" || level === "warn") announce(data.text);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function renderAssistantText(feed, text) {
|
|
773
|
+
parseFences(String(text)).forEach((part) => {
|
|
774
|
+
if (part.type === "code") {
|
|
775
|
+
const code = el("div", "code");
|
|
776
|
+
code.textContent = part.content;
|
|
777
|
+
feed.appendChild(code);
|
|
778
|
+
} else if (part.content) {
|
|
779
|
+
feed.appendChild(el("div", "ans", part.content));
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function renderThinkBlock(feed, text) {
|
|
785
|
+
const wrap = el("div", "think");
|
|
786
|
+
const head = el("button", "think-h");
|
|
787
|
+
head.type = "button";
|
|
788
|
+
head.setAttribute("aria-expanded", "true");
|
|
789
|
+
head.appendChild(el("span", "g", "✦"));
|
|
790
|
+
head.appendChild(el("span", "lab", t("web.action.thinking")));
|
|
791
|
+
const tog = el("span", "tog");
|
|
792
|
+
setIcon(tog, "chevron-down", t("web.action.hide"));
|
|
793
|
+
head.appendChild(tog);
|
|
794
|
+
const body = el("div", "think-body");
|
|
795
|
+
body.textContent = text || "";
|
|
796
|
+
wrap.appendChild(head);
|
|
797
|
+
wrap.appendChild(body);
|
|
798
|
+
feed.appendChild(wrap);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function renderHistoryAct(feed, entry, statusNodes) {
|
|
802
|
+
const line = el("div", "act " + categoryOf(entry.name));
|
|
803
|
+
line.appendChild(el("span", "g", "◇"));
|
|
804
|
+
line.appendChild(el("span", "lab", labelOf(entry.name)));
|
|
805
|
+
const args = argText(entry.args);
|
|
806
|
+
if (args) {
|
|
807
|
+
line.appendChild(el("span", "sep", "·"));
|
|
808
|
+
line.appendChild(el("span", "args", args));
|
|
809
|
+
}
|
|
810
|
+
const status = el("span", "ok", "");
|
|
811
|
+
line.appendChild(status);
|
|
812
|
+
feed.appendChild(line);
|
|
813
|
+
if (entry.toolCallId) statusNodes.set(entry.toolCallId, status);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function renderHistoryResult(feed, entry, statusNodes) {
|
|
817
|
+
const dur = formatMs(entry.duration);
|
|
818
|
+
const mark = (entry.success ? " ✓" : " ✗") + (dur ? " " + dur : "");
|
|
819
|
+
const status = entry.toolCallId ? statusNodes.get(entry.toolCallId) : null;
|
|
820
|
+
if (status) {
|
|
821
|
+
status.textContent = mark;
|
|
822
|
+
if (!entry.success) status.classList.add("fail");
|
|
823
|
+
statusNodes.delete(entry.toolCallId);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const line = el("div", "act " + categoryOf(entry.tool));
|
|
827
|
+
line.appendChild(el("span", "g", "↳"));
|
|
828
|
+
line.appendChild(el("span", "lab", labelOf(entry.tool)));
|
|
829
|
+
line.appendChild(el("span", "ok" + (entry.success ? "" : " fail"), mark));
|
|
830
|
+
feed.appendChild(line);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function renderHistory(feed, entries) {
|
|
834
|
+
const statusNodes = new Map();
|
|
835
|
+
entries.forEach((entry) => {
|
|
836
|
+
if (!entry || typeof entry.kind !== "string") return;
|
|
837
|
+
switch (entry.kind) {
|
|
838
|
+
case "user":
|
|
839
|
+
feed.appendChild(el("div", "u", entry.text || ""));
|
|
840
|
+
break;
|
|
841
|
+
case "assistant":
|
|
842
|
+
renderAssistantText(feed, entry.text || "");
|
|
843
|
+
break;
|
|
844
|
+
case "reasoning":
|
|
845
|
+
renderThinkBlock(feed, entry.text || "");
|
|
846
|
+
break;
|
|
847
|
+
case "tool_call":
|
|
848
|
+
renderHistoryAct(feed, entry, statusNodes);
|
|
849
|
+
break;
|
|
850
|
+
case "tool_result":
|
|
851
|
+
renderHistoryResult(feed, entry, statusNodes);
|
|
852
|
+
break;
|
|
853
|
+
case "plan":
|
|
854
|
+
if (entry.text) feed.appendChild(el("div", "sysline", "▸ " + entry.text));
|
|
855
|
+
break;
|
|
856
|
+
case "context":
|
|
857
|
+
if (entry.text) feed.appendChild(el("div", "detail", "↳ " + entry.text));
|
|
858
|
+
break;
|
|
859
|
+
case "error":
|
|
860
|
+
feed.appendChild(el("div", "errline", "✗ " + (entry.text || "")));
|
|
861
|
+
break;
|
|
862
|
+
case "system":
|
|
863
|
+
if (entry.text) feed.appendChild(el("div", "sysline", entry.text));
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function feedHasContent(feed) {
|
|
870
|
+
return !!feed.querySelector(".u, .ans, .code, .act, .think, .errline, .sysline");
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async function loadHistory(tab) {
|
|
874
|
+
if (!tab || tab.historyLoaded || tab.historyLoading) return;
|
|
875
|
+
tab.historyLoading = true;
|
|
876
|
+
const placeholder = el("div", "detail", t("web.history.loading"));
|
|
877
|
+
if (!feedHasContent(tab.feed)) {
|
|
878
|
+
clearEmpty(tab.feed);
|
|
879
|
+
tab.feed.appendChild(placeholder);
|
|
880
|
+
}
|
|
881
|
+
const r = await api("/api/sessions/" + encodeURIComponent(tab.id) + "/history");
|
|
882
|
+
tab.historyLoading = false;
|
|
883
|
+
placeholder.remove();
|
|
884
|
+
tab.historyLoaded = true;
|
|
885
|
+
if (!r.ok || !r.data || !Array.isArray(r.data.history)) {
|
|
886
|
+
if (!feedHasContent(tab.feed)) tab.feed.appendChild(el("div", "detail", t("web.history.unavailable")));
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
// A live session already rendered its content over SSE: don't duplicate it.
|
|
890
|
+
if (feedHasContent(tab.feed)) return;
|
|
891
|
+
renderHistory(tab.feed, r.data.history);
|
|
892
|
+
if (!r.data.history.length) tab.feed.appendChild(el("div", "detail", t("web.history.empty")));
|
|
893
|
+
if (tab.feed === activeFeed()) scroll();
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function renderTabstrip() {
|
|
897
|
+
tabstripEl.textContent = "";
|
|
898
|
+
tabs.forEach((item) => {
|
|
899
|
+
const on = item.id === activeTabId;
|
|
900
|
+
const label = item.name || item.id;
|
|
901
|
+
const tab = el("div", "stab" + (on ? " on" : ""));
|
|
902
|
+
tab.setAttribute("role", "tab");
|
|
903
|
+
tab.setAttribute("tabindex", on ? "0" : "-1");
|
|
904
|
+
tab.setAttribute("aria-selected", on ? "true" : "false");
|
|
905
|
+
tab.setAttribute("aria-label", label);
|
|
906
|
+
tab.title = label;
|
|
907
|
+
const nameEl = el("span", "name", label);
|
|
908
|
+
nameEl.title = label;
|
|
909
|
+
tab.appendChild(nameEl);
|
|
910
|
+
if (item.kind === "session") tab.appendChild(el("span", "sid", String(item.id)));
|
|
911
|
+
const x = el("button", "stab-x");
|
|
912
|
+
x.type = "button";
|
|
913
|
+
x.title = t("web.topbar.close");
|
|
914
|
+
x.setAttribute("aria-label", t("web.topbar.close") + ": " + label);
|
|
915
|
+
x.appendChild(icon("x"));
|
|
916
|
+
x.addEventListener("click", (e) => {
|
|
917
|
+
e.stopPropagation();
|
|
918
|
+
closeTab(item.id);
|
|
919
|
+
});
|
|
920
|
+
tab.appendChild(x);
|
|
921
|
+
const open = () => openTab(item.id, item.name, item.kind, true);
|
|
922
|
+
tab.addEventListener("click", open);
|
|
923
|
+
tab.addEventListener("keydown", (e) => {
|
|
924
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
925
|
+
e.preventDefault();
|
|
926
|
+
open();
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
tabstripEl.appendChild(tab);
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
tabstripEl.addEventListener(
|
|
934
|
+
"wheel",
|
|
935
|
+
(e) => {
|
|
936
|
+
if (e.deltaY === 0) return;
|
|
937
|
+
e.preventDefault();
|
|
938
|
+
tabstripEl.scrollLeft += e.deltaY;
|
|
939
|
+
},
|
|
940
|
+
{ passive: false }
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
function openTab(id, name, kind, server) {
|
|
944
|
+
let tab = tabs.find((x) => x.id === id);
|
|
945
|
+
if (!tab) {
|
|
946
|
+
tab = {
|
|
947
|
+
id,
|
|
948
|
+
name: name || id,
|
|
949
|
+
kind: kind || "session",
|
|
950
|
+
feed: makeFeed(),
|
|
951
|
+
historyLoaded: false,
|
|
952
|
+
historyLoading: false,
|
|
953
|
+
planCard: null,
|
|
954
|
+
};
|
|
955
|
+
tabs.push(tab);
|
|
956
|
+
} else if (name) {
|
|
957
|
+
tab.name = name;
|
|
958
|
+
}
|
|
959
|
+
activeTabId = id;
|
|
960
|
+
liveTabId = id;
|
|
961
|
+
msgs.replaceChildren(tab.feed);
|
|
962
|
+
renderTabstrip();
|
|
963
|
+
renderSessionsDropdown();
|
|
964
|
+
msgs.scrollTop = msgs.scrollHeight;
|
|
965
|
+
if (tab.kind === "session" && !server) {
|
|
966
|
+
// Auto-opened live session: SSE fills the feed, no transcript fetch.
|
|
967
|
+
tab.historyLoaded = true;
|
|
968
|
+
}
|
|
969
|
+
if (server && tab.kind === "session") {
|
|
970
|
+
api("/api/sessions/activate", { method: "POST", body: { id } }).then(() => loadSessions());
|
|
971
|
+
loadHistory(tab);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function closeTab(id) {
|
|
976
|
+
const i = tabs.findIndex((x) => x.id === id);
|
|
977
|
+
if (i === -1) return;
|
|
978
|
+
tabs = tabs.filter((x) => x.id !== id);
|
|
979
|
+
if (activeTabId === id) {
|
|
980
|
+
const next = tabs[Math.min(i, tabs.length - 1)];
|
|
981
|
+
if (next) {
|
|
982
|
+
activeTabId = next.id;
|
|
983
|
+
liveTabId = next.id;
|
|
984
|
+
msgs.replaceChildren(next.feed);
|
|
985
|
+
} else {
|
|
986
|
+
activeTabId = null;
|
|
987
|
+
liveTabId = null;
|
|
988
|
+
showEmptyFeed();
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
renderTabstrip();
|
|
992
|
+
renderSessionsDropdown();
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
async function newChatTab() {
|
|
996
|
+
const r = await api("/api/sessions", { method: "POST", body: {} });
|
|
997
|
+
if (r.ok && r.data && r.data.session && r.data.session.id) {
|
|
998
|
+
const meta = r.data.session;
|
|
999
|
+
openTab(meta.id, meta.name || String(meta.id), "session", false);
|
|
1000
|
+
await loadSessions();
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
// Server-side creation failed (no manager / offline): keep a local-only tab
|
|
1004
|
+
// so the user is not blocked, but surface the failure.
|
|
1005
|
+
newSeq++;
|
|
1006
|
+
openTab("new" + newSeq, t("web.empty.title"), "new", false);
|
|
1007
|
+
setIndicator(t("web.sessions.create_failed"), true);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
document.getElementById("newChat").addEventListener("click", newChatTab);
|
|
1011
|
+
document.getElementById("sessNew").addEventListener("click", () => {
|
|
1012
|
+
newChatTab();
|
|
1013
|
+
setSessOpen(false);
|
|
1014
|
+
});
|
|
1015
|
+
document.getElementById("sessRefresh").addEventListener("click", () => {
|
|
1016
|
+
loadSessions();
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
function renderSessionsDropdown() {
|
|
1020
|
+
const activeList = document.getElementById("sessActiveList");
|
|
1021
|
+
const recentList = document.getElementById("sessRecentList");
|
|
1022
|
+
activeList.textContent = "";
|
|
1023
|
+
recentList.textContent = "";
|
|
1024
|
+
const activeId = state.activeSessionId;
|
|
1025
|
+
const sorted = state.sessions.slice().sort((a, b) => {
|
|
1026
|
+
const at = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
|
1027
|
+
const bt = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
|
1028
|
+
return bt - at;
|
|
1029
|
+
});
|
|
1030
|
+
let activePlaced = false;
|
|
1031
|
+
sorted.forEach((s) => {
|
|
1032
|
+
const isActive = s.id === activeId;
|
|
1033
|
+
const row = sessionRow(s, isActive);
|
|
1034
|
+
if (isActive && !activePlaced) {
|
|
1035
|
+
activeList.appendChild(row);
|
|
1036
|
+
activePlaced = true;
|
|
1037
|
+
} else {
|
|
1038
|
+
recentList.appendChild(row);
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
if (!activePlaced) {
|
|
1042
|
+
activeList.appendChild(el("div", "si", t("web.sessions.none_active")));
|
|
1043
|
+
}
|
|
1044
|
+
if (!recentList.childNodes.length) {
|
|
1045
|
+
recentList.appendChild(el("div", "si", t("web.sessions.none")));
|
|
1046
|
+
}
|
|
1047
|
+
if (state.sessionsError) {
|
|
1048
|
+
recentList.appendChild(el("div", "dd-err", state.sessionsError));
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function sessionRow(s, isActive) {
|
|
1053
|
+
const row = el("div", "si si-row" + (isActive ? " on" : ""));
|
|
1054
|
+
const name = s.name || String(s.id);
|
|
1055
|
+
const main = el("button", "dd-row-main");
|
|
1056
|
+
main.type = "button";
|
|
1057
|
+
main.setAttribute("aria-label", name);
|
|
1058
|
+
main.appendChild(el("span", "id", String(s.id)));
|
|
1059
|
+
main.appendChild(el("span", "name", name));
|
|
1060
|
+
main.appendChild(el("span", "meta", relTime(s.updatedAt)));
|
|
1061
|
+
main.addEventListener("click", () => {
|
|
1062
|
+
setSessOpen(false);
|
|
1063
|
+
openTab(s.id, name, "session", true);
|
|
1064
|
+
});
|
|
1065
|
+
row.appendChild(main);
|
|
1066
|
+
// The active session can't be removed server-side (409), so it has no
|
|
1067
|
+
// delete control at all.
|
|
1068
|
+
if (!isActive) {
|
|
1069
|
+
const del = el("button", "del-btn");
|
|
1070
|
+
del.type = "button";
|
|
1071
|
+
del.appendChild(icon("trash"));
|
|
1072
|
+
const label = t("web.sessions.delete");
|
|
1073
|
+
del.title = label;
|
|
1074
|
+
del.setAttribute("aria-label", label + ": " + name);
|
|
1075
|
+
del.addEventListener("click", (e) => {
|
|
1076
|
+
e.stopPropagation();
|
|
1077
|
+
confirmDeleteSession(s.id, name);
|
|
1078
|
+
});
|
|
1079
|
+
row.appendChild(del);
|
|
1080
|
+
}
|
|
1081
|
+
return row;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// --- session delete: confirmation dialog --------------------------------
|
|
1085
|
+
function confirmDeleteSession(id, name) {
|
|
1086
|
+
openConfirm({
|
|
1087
|
+
title: t("web.sessions.confirm_title"),
|
|
1088
|
+
body: t("web.sessions.confirm_body", { name }),
|
|
1089
|
+
onAccept: () => deleteSession(id),
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function showSessionsError(msg) {
|
|
1094
|
+
state.sessionsError = msg;
|
|
1095
|
+
renderSessionsDropdown();
|
|
1096
|
+
if (sessionsErrTimer) clearTimeout(sessionsErrTimer);
|
|
1097
|
+
sessionsErrTimer = setTimeout(() => {
|
|
1098
|
+
state.sessionsError = null;
|
|
1099
|
+
renderSessionsDropdown();
|
|
1100
|
+
}, 4500);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
async function deleteSession(id) {
|
|
1104
|
+
const r = await api("/api/sessions/" + encodeURIComponent(id), { method: "DELETE" });
|
|
1105
|
+
if (!r.ok) {
|
|
1106
|
+
const code = r.data && typeof r.data.error === "string" ? r.data.error : "";
|
|
1107
|
+
showSessionsError(code === "active_session" ? t("web.sessions.active_guard") : t("web.sessions.delete_failed"));
|
|
1108
|
+
} else {
|
|
1109
|
+
state.sessionsError = null;
|
|
1110
|
+
}
|
|
1111
|
+
await loadSessions();
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const sessDD = document.getElementById("sessDD");
|
|
1115
|
+
const sessBtn = document.getElementById("sessBtn");
|
|
1116
|
+
function setSessOpen(open) {
|
|
1117
|
+
sessDD.classList.toggle("open", !!open);
|
|
1118
|
+
sessBtn.setAttribute("aria-expanded", open ? "true" : "false");
|
|
1119
|
+
}
|
|
1120
|
+
sessBtn.addEventListener("click", (e) => {
|
|
1121
|
+
e.stopPropagation();
|
|
1122
|
+
document.querySelectorAll("[data-sel]").forEach((s) => s.classList.remove("open"));
|
|
1123
|
+
setSessOpen(!sessDD.classList.contains("open"));
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
document.addEventListener("click", () => {
|
|
1127
|
+
document.querySelectorAll("[data-sel]").forEach((s) => s.classList.remove("open"));
|
|
1128
|
+
setSessOpen(false);
|
|
1129
|
+
closeCmdk();
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
msgs.addEventListener("click", (e) => {
|
|
1133
|
+
const head = e.target.closest(".think-h");
|
|
1134
|
+
if (!head) return;
|
|
1135
|
+
const body = head.nextElementSibling;
|
|
1136
|
+
if (!body) return;
|
|
1137
|
+
const hidden = body.classList.toggle("hidden");
|
|
1138
|
+
head.setAttribute("aria-expanded", hidden ? "false" : "true");
|
|
1139
|
+
const tog = head.querySelector(".tog");
|
|
1140
|
+
if (tog) setIcon(tog, hidden ? "chevron-right" : "chevron-down", t(hidden ? "web.action.show" : "web.action.hide"));
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
function setAttachError(msg) {
|
|
1144
|
+
if (!attachErr) return;
|
|
1145
|
+
attachErr.textContent = msg;
|
|
1146
|
+
attachErr.hidden = false;
|
|
1147
|
+
clearTimeout(attachErrTimer);
|
|
1148
|
+
attachErrTimer = setTimeout(() => {
|
|
1149
|
+
attachErr.hidden = true;
|
|
1150
|
+
}, 4000);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function renderAttachments() {
|
|
1154
|
+
if (!attachStrip) return;
|
|
1155
|
+
attachStrip.textContent = "";
|
|
1156
|
+
attachStrip.hidden = attachments.length === 0;
|
|
1157
|
+
attachments.forEach((src, i) => {
|
|
1158
|
+
const thumb = el("div", "thumb");
|
|
1159
|
+
const img = document.createElement("img");
|
|
1160
|
+
img.alt = "";
|
|
1161
|
+
img.src = src;
|
|
1162
|
+
thumb.appendChild(img);
|
|
1163
|
+
const rm = el("button", "rm");
|
|
1164
|
+
rm.type = "button";
|
|
1165
|
+
rm.title = t("web.attach.remove");
|
|
1166
|
+
rm.setAttribute("aria-label", t("web.attach.remove"));
|
|
1167
|
+
rm.appendChild(icon("x"));
|
|
1168
|
+
rm.addEventListener("click", () => {
|
|
1169
|
+
attachments.splice(i, 1);
|
|
1170
|
+
renderAttachments();
|
|
1171
|
+
});
|
|
1172
|
+
thumb.appendChild(rm);
|
|
1173
|
+
attachStrip.appendChild(thumb);
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function clearAttachments() {
|
|
1178
|
+
attachments = [];
|
|
1179
|
+
renderAttachments();
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function readAsDataUrl(file) {
|
|
1183
|
+
return new Promise((resolve, reject) => {
|
|
1184
|
+
const fr = new FileReader();
|
|
1185
|
+
fr.onload = () => resolve(String(fr.result));
|
|
1186
|
+
fr.onerror = () => reject(fr.error || new Error("read failed"));
|
|
1187
|
+
fr.readAsDataURL(file);
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
async function addFiles(files) {
|
|
1192
|
+
let err = "";
|
|
1193
|
+
for (const file of files) {
|
|
1194
|
+
if (!file || typeof file.type !== "string" || file.type.indexOf("image/") !== 0) {
|
|
1195
|
+
err = t("web.attach.only_images");
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
if (attachments.length >= MAX_IMAGES) {
|
|
1199
|
+
err = t("web.attach.too_many", { max: MAX_IMAGES });
|
|
1200
|
+
break;
|
|
1201
|
+
}
|
|
1202
|
+
if (file.size > MAX_IMAGE_BYTES) {
|
|
1203
|
+
err = t("web.attach.too_large");
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
try {
|
|
1207
|
+
const url = await readAsDataUrl(file);
|
|
1208
|
+
if (url.indexOf("data:image/") !== 0) {
|
|
1209
|
+
err = t("web.attach.invalid");
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
if (attachments.length >= MAX_IMAGES) {
|
|
1213
|
+
err = t("web.attach.too_many", { max: MAX_IMAGES });
|
|
1214
|
+
break;
|
|
1215
|
+
}
|
|
1216
|
+
attachments.push(url);
|
|
1217
|
+
} catch (e) {
|
|
1218
|
+
err = t("web.attach.read_failed");
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
renderAttachments();
|
|
1222
|
+
if (err) setAttachError(err);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function userMessageNode(text, images) {
|
|
1226
|
+
const node = el("div", "u", text);
|
|
1227
|
+
(images || []).forEach((src) => {
|
|
1228
|
+
const img = document.createElement("img");
|
|
1229
|
+
img.className = "uimg";
|
|
1230
|
+
img.alt = "";
|
|
1231
|
+
img.src = src;
|
|
1232
|
+
node.appendChild(img);
|
|
1233
|
+
});
|
|
1234
|
+
return node;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (imgPicker) {
|
|
1238
|
+
imgPicker.addEventListener("change", () => {
|
|
1239
|
+
addFiles(Array.from(imgPicker.files || []));
|
|
1240
|
+
imgPicker.value = "";
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
const attachBtn = document.getElementById("attachBtn");
|
|
1244
|
+
if (attachBtn && imgPicker) {
|
|
1245
|
+
attachBtn.addEventListener("click", () => imgPicker.click());
|
|
1246
|
+
}
|
|
1247
|
+
inp.addEventListener("paste", (e) => {
|
|
1248
|
+
const items = e.clipboardData && e.clipboardData.items;
|
|
1249
|
+
if (!items) return;
|
|
1250
|
+
const files = [];
|
|
1251
|
+
for (const it of items) {
|
|
1252
|
+
if (it.kind === "file" && it.type && it.type.indexOf("image/") === 0) {
|
|
1253
|
+
const f = it.getAsFile();
|
|
1254
|
+
if (f) files.push(f);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
if (files.length) {
|
|
1258
|
+
e.preventDefault();
|
|
1259
|
+
addFiles(files);
|
|
1260
|
+
}
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
const inputArea = document.querySelector(".input-area");
|
|
1264
|
+
if (inputArea) {
|
|
1265
|
+
["dragenter", "dragover"].forEach((ev) => {
|
|
1266
|
+
inputArea.addEventListener(ev, (e) => {
|
|
1267
|
+
e.preventDefault();
|
|
1268
|
+
inputArea.classList.add("dragover");
|
|
1269
|
+
});
|
|
1270
|
+
});
|
|
1271
|
+
inputArea.addEventListener("dragleave", (e) => {
|
|
1272
|
+
if (e.relatedTarget && inputArea.contains(e.relatedTarget)) return;
|
|
1273
|
+
inputArea.classList.remove("dragover");
|
|
1274
|
+
});
|
|
1275
|
+
inputArea.addEventListener("drop", (e) => {
|
|
1276
|
+
e.preventDefault();
|
|
1277
|
+
inputArea.classList.remove("dragover");
|
|
1278
|
+
const dt = e.dataTransfer;
|
|
1279
|
+
if (dt && dt.files && dt.files.length) addFiles(Array.from(dt.files));
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// --- slash-command autocomplete ----------------------------------------
|
|
1284
|
+
// List comes from the server (`/api/commands`) so it stays in sync with `/help`.
|
|
1285
|
+
const cmdk = el("div", "cmdk");
|
|
1286
|
+
cmdk.id = "cmdk-list";
|
|
1287
|
+
cmdk.hidden = true;
|
|
1288
|
+
cmdk.setAttribute("role", "listbox");
|
|
1289
|
+
if (inputArea) inputArea.appendChild(cmdk);
|
|
1290
|
+
let webCommands = [];
|
|
1291
|
+
let cmdkItems = [];
|
|
1292
|
+
let cmdkIndex = 0;
|
|
1293
|
+
|
|
1294
|
+
async function loadCommands() {
|
|
1295
|
+
const r = await api("/api/commands");
|
|
1296
|
+
if (r.ok && r.data && Array.isArray(r.data.commands)) webCommands = r.data.commands;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function slashToken() {
|
|
1300
|
+
const v = inp.value;
|
|
1301
|
+
return v.startsWith("/") && !/\s/.test(v) ? v : null;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function closeCmdk() {
|
|
1305
|
+
if (cmdk.hidden) return;
|
|
1306
|
+
cmdk.hidden = true;
|
|
1307
|
+
cmdk.textContent = "";
|
|
1308
|
+
cmdkItems = [];
|
|
1309
|
+
cmdkIndex = 0;
|
|
1310
|
+
inp.setAttribute("aria-expanded", "false");
|
|
1311
|
+
inp.removeAttribute("aria-activedescendant");
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function highlightCmdk() {
|
|
1315
|
+
Array.from(cmdk.children).forEach((node, i) => node.classList.toggle("on", i === cmdkIndex));
|
|
1316
|
+
const on = cmdk.children[cmdkIndex];
|
|
1317
|
+
if (on) inp.setAttribute("aria-activedescendant", on.id);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function updateCmdk() {
|
|
1321
|
+
const token = slashToken();
|
|
1322
|
+
if (token === null || !webCommands.length) {
|
|
1323
|
+
closeCmdk();
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
const q = token.slice(1).toLowerCase();
|
|
1327
|
+
const matches = webCommands.filter((c) => String(c.name).toLowerCase().startsWith(q)).slice(0, 8);
|
|
1328
|
+
if (!matches.length) {
|
|
1329
|
+
closeCmdk();
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
cmdkItems = matches;
|
|
1333
|
+
cmdkIndex = 0;
|
|
1334
|
+
cmdk.textContent = "";
|
|
1335
|
+
matches.forEach((c, i) => {
|
|
1336
|
+
const item = el("div", "cmdk-item" + (i === 0 ? " on" : ""));
|
|
1337
|
+
item.id = "cmdk-opt-" + i;
|
|
1338
|
+
item.setAttribute("role", "option");
|
|
1339
|
+
item.appendChild(el("span", "cmd", c.usage));
|
|
1340
|
+
if (c.desc) item.appendChild(el("span", "desc", c.desc));
|
|
1341
|
+
item.addEventListener("mousedown", (e) => {
|
|
1342
|
+
e.preventDefault();
|
|
1343
|
+
acceptCmdk(i);
|
|
1344
|
+
});
|
|
1345
|
+
cmdk.appendChild(item);
|
|
1346
|
+
});
|
|
1347
|
+
cmdk.hidden = false;
|
|
1348
|
+
cmdk.setAttribute("aria-label", t("web.cmdk.label"));
|
|
1349
|
+
inp.setAttribute("aria-expanded", "true");
|
|
1350
|
+
inp.setAttribute("aria-controls", cmdk.id);
|
|
1351
|
+
highlightCmdk();
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function acceptCmdk(i) {
|
|
1355
|
+
const c = cmdkItems[i];
|
|
1356
|
+
if (!c) return;
|
|
1357
|
+
inp.value = "/" + c.name + " ";
|
|
1358
|
+
autogrow();
|
|
1359
|
+
closeCmdk();
|
|
1360
|
+
inp.focus();
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Grow the composer with its content up to a max, then scroll.
|
|
1364
|
+
const INPUT_MAX_PX = 200;
|
|
1365
|
+
function autogrow() {
|
|
1366
|
+
if (!inp) return;
|
|
1367
|
+
inp.style.height = "auto";
|
|
1368
|
+
inp.style.height = Math.min(inp.scrollHeight, INPUT_MAX_PX) + "px";
|
|
1369
|
+
}
|
|
1370
|
+
function clearInput() {
|
|
1371
|
+
inp.value = "";
|
|
1372
|
+
autogrow();
|
|
1373
|
+
closeCmdk();
|
|
1374
|
+
}
|
|
1375
|
+
if (inp)
|
|
1376
|
+
inp.addEventListener("input", () => {
|
|
1377
|
+
autogrow();
|
|
1378
|
+
updateCmdk();
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
async function send() {
|
|
1382
|
+
const text = inp.value.trim();
|
|
1383
|
+
const images = attachments.slice();
|
|
1384
|
+
if (!text && !images.length) return;
|
|
1385
|
+
const feed = liveFeed() || activeFeed();
|
|
1386
|
+
if (state.busy) {
|
|
1387
|
+
// Don't drop the input silently: tell the user a turn is still running.
|
|
1388
|
+
if (feed) appendTo(feed, el("div", "warnline", "⚠ " + t("web.input.busy")));
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
clearEmpty(feed);
|
|
1392
|
+
curText = null;
|
|
1393
|
+
finishMeta();
|
|
1394
|
+
if (text.startsWith("/")) {
|
|
1395
|
+
appendTo(feed, el("div", "u", text));
|
|
1396
|
+
clearInput();
|
|
1397
|
+
const name = text.slice(1).split(/\s+/)[0].toLowerCase();
|
|
1398
|
+
if (name === "clear") {
|
|
1399
|
+
clearFeed(feed);
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
if (name === "new") {
|
|
1403
|
+
newChatTab();
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
const r = await api("/api/command", { method: "POST", body: { line: text } });
|
|
1407
|
+
if (!r.ok) {
|
|
1408
|
+
appendTo(feed, el("div", "detail", "↳ " + t("web.misc.command_unavailable")));
|
|
1409
|
+
}
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
if (feed && feed.querySelector(".startbanner") === null && feed.querySelector(".u") === null) {
|
|
1413
|
+
const banner = el("div", "startbanner");
|
|
1414
|
+
banner.appendChild(document.createTextNode(t("web.start.label") + " "));
|
|
1415
|
+
banner.appendChild(el("b", "", (state.config && state.config.model) || state.host.model || "—"));
|
|
1416
|
+
const sessionPart = state.host.sessionId ? " · " + t("web.start.session") + " " + String(state.host.sessionId) : "";
|
|
1417
|
+
banner.appendChild(document.createTextNode(" · " + (state.host.provider || (state.config && state.config.provider && state.config.provider.active) || "—") + " · " + contextLabel() + sessionPart));
|
|
1418
|
+
feed.insertBefore(banner, feed.firstChild);
|
|
1419
|
+
}
|
|
1420
|
+
appendTo(feed, userMessageNode(text, images));
|
|
1421
|
+
clearInput();
|
|
1422
|
+
const body = { text };
|
|
1423
|
+
if (images.length) body.images = images;
|
|
1424
|
+
const r = await api("/api/message", { method: "POST", body });
|
|
1425
|
+
if (!r.ok) {
|
|
1426
|
+
const msg = (r.data && r.data.error) || r.status;
|
|
1427
|
+
appendTo(feed, el("div", "warnline", "⚠ " + msg));
|
|
1428
|
+
} else {
|
|
1429
|
+
if (images.length) clearAttachments();
|
|
1430
|
+
showBusy(feed);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
async function interrupt() {
|
|
1435
|
+
if (!state.busy) return;
|
|
1436
|
+
await api("/api/interrupt", { method: "POST", body: {} });
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
inp.addEventListener("keydown", (e) => {
|
|
1440
|
+
// Ignore keys that confirm an IME composition (CJK/RU input methods):
|
|
1441
|
+
// sending on that Enter would submit a half-composed message.
|
|
1442
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
1443
|
+
// Slash-command autocomplete owns the arrows/Enter/Tab/Esc while open.
|
|
1444
|
+
if (!cmdk.hidden) {
|
|
1445
|
+
if (e.key === "ArrowDown") {
|
|
1446
|
+
e.preventDefault();
|
|
1447
|
+
cmdkIndex = Math.min(cmdkIndex + 1, cmdkItems.length - 1);
|
|
1448
|
+
highlightCmdk();
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
if (e.key === "ArrowUp") {
|
|
1452
|
+
e.preventDefault();
|
|
1453
|
+
cmdkIndex = Math.max(cmdkIndex - 1, 0);
|
|
1454
|
+
highlightCmdk();
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
if (e.key === "Escape") {
|
|
1458
|
+
e.preventDefault();
|
|
1459
|
+
closeCmdk();
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
if (e.key === "Tab") {
|
|
1463
|
+
e.preventDefault();
|
|
1464
|
+
acceptCmdk(cmdkIndex);
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
1468
|
+
const token = slashToken();
|
|
1469
|
+
const selected = cmdkItems[cmdkIndex];
|
|
1470
|
+
// Exact command typed → send; otherwise complete the highlighted one.
|
|
1471
|
+
if (token && selected && token.slice(1).toLowerCase() !== String(selected.name).toLowerCase()) {
|
|
1472
|
+
e.preventDefault();
|
|
1473
|
+
acceptCmdk(cmdkIndex);
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
1479
|
+
e.preventDefault();
|
|
1480
|
+
send();
|
|
1481
|
+
} else if (e.key === "Escape") {
|
|
1482
|
+
e.preventDefault();
|
|
1483
|
+
interrupt();
|
|
1484
|
+
}
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
if (sendBtn) {
|
|
1488
|
+
sendBtn.addEventListener("click", () => {
|
|
1489
|
+
if (state.busy) interrupt();
|
|
1490
|
+
else send();
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
document.querySelectorAll("[data-collapse]").forEach((head) => {
|
|
1495
|
+
const initArrow = head.querySelector(".arrow");
|
|
1496
|
+
if (initArrow) {
|
|
1497
|
+
initArrow.textContent = "";
|
|
1498
|
+
initArrow.appendChild(icon("chevron-down"));
|
|
1499
|
+
}
|
|
1500
|
+
head.addEventListener("click", () => {
|
|
1501
|
+
const body = head.nextElementSibling;
|
|
1502
|
+
if (!body) return;
|
|
1503
|
+
const collapsed = body.classList.toggle("collapsed");
|
|
1504
|
+
head.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
|
1505
|
+
const arrow = head.querySelector(".arrow");
|
|
1506
|
+
if (arrow) {
|
|
1507
|
+
arrow.textContent = "";
|
|
1508
|
+
arrow.appendChild(icon(collapsed ? "chevron-right" : "chevron-down"));
|
|
1509
|
+
}
|
|
1510
|
+
});
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
function contextLabel() {
|
|
1514
|
+
const n = (state.config && state.config.contextWindow) || 0;
|
|
1515
|
+
return n ? Math.round(n / 1024) + "K" : "—";
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function setSelButton(btn, label, value) {
|
|
1519
|
+
btn.textContent = label + ":";
|
|
1520
|
+
btn.appendChild(el("b", "", value));
|
|
1521
|
+
btn.appendChild(icon("chevron-down"));
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function closeAllMenus() {
|
|
1525
|
+
document.querySelectorAll("[data-sel]").forEach((s) => {
|
|
1526
|
+
s.classList.remove("open");
|
|
1527
|
+
const b = s.querySelector("button");
|
|
1528
|
+
if (b) b.setAttribute("aria-expanded", "false");
|
|
1529
|
+
});
|
|
1530
|
+
setSessOpen(false);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function menuItem(label, sub, active, onClick) {
|
|
1534
|
+
const mi = el("button", "mi" + (active ? " on" : ""));
|
|
1535
|
+
mi.type = "button";
|
|
1536
|
+
mi.setAttribute("aria-pressed", active ? "true" : "false");
|
|
1537
|
+
mi.appendChild(el("span", "", label));
|
|
1538
|
+
if (sub) mi.appendChild(el("span", "sub", sub));
|
|
1539
|
+
mi.addEventListener("click", (e) => {
|
|
1540
|
+
e.stopPropagation();
|
|
1541
|
+
onClick();
|
|
1542
|
+
closeAllMenus();
|
|
1543
|
+
});
|
|
1544
|
+
return mi;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// Menu action row (refresh, custom, add…) with an optional leading icon.
|
|
1548
|
+
function menuAction(label, iconName, onClick) {
|
|
1549
|
+
const mi = el("button", "mi row-mi");
|
|
1550
|
+
mi.type = "button";
|
|
1551
|
+
if (iconName) mi.appendChild(icon(iconName));
|
|
1552
|
+
mi.appendChild(el("span", "", label));
|
|
1553
|
+
mi.addEventListener("click", (e) => {
|
|
1554
|
+
e.stopPropagation();
|
|
1555
|
+
closeAllMenus();
|
|
1556
|
+
onClick();
|
|
1557
|
+
});
|
|
1558
|
+
return mi;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function renderSelectors() {
|
|
1562
|
+
const cfg = state.config || {};
|
|
1563
|
+
const provider = cfg.provider || {};
|
|
1564
|
+
|
|
1565
|
+
setSelButton(document.getElementById("selModelBtn"), t("web.selectors.model"), cfg.model || "—");
|
|
1566
|
+
const modelMenu = document.getElementById("selModelMenu");
|
|
1567
|
+
modelMenu.textContent = "";
|
|
1568
|
+
if (!state.models.length) {
|
|
1569
|
+
modelMenu.appendChild(el("div", "mi dim", t("web.selectors.no_models")));
|
|
1570
|
+
} else {
|
|
1571
|
+
state.models.forEach((m) => {
|
|
1572
|
+
modelMenu.appendChild(menuItem(m, "", m === cfg.model, () => postConfig("model", m, true)));
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
modelMenu.appendChild(el("div", "sep"));
|
|
1576
|
+
modelMenu.appendChild(
|
|
1577
|
+
menuAction(t("web.selectors.refresh_list"), "refresh", () => {
|
|
1578
|
+
loadModels().then(renderSelectors);
|
|
1579
|
+
}),
|
|
1580
|
+
);
|
|
1581
|
+
|
|
1582
|
+
setSelButton(document.getElementById("selProviderBtn"), t("web.selectors.provider"), provider.active || provider.type || "—");
|
|
1583
|
+
const provMenu = document.getElementById("selProviderMenu");
|
|
1584
|
+
provMenu.textContent = "";
|
|
1585
|
+
const entries = Array.isArray(provider.entries) && provider.entries.length
|
|
1586
|
+
? provider.entries
|
|
1587
|
+
: [{ label: provider.active || provider.type, type: provider.type }];
|
|
1588
|
+
entries.forEach((entry) => {
|
|
1589
|
+
const name = entry.label || entry.type;
|
|
1590
|
+
if (!name) return;
|
|
1591
|
+
provMenu.appendChild(menuItem(name, entry.type || "", name === (provider.active || provider.type), () => postConfig("provider.active", name, true)));
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
setSelButton(document.getElementById("selContextBtn"), t("web.selectors.context"), contextLabel());
|
|
1595
|
+
const ctxMenu = document.getElementById("selContextMenu");
|
|
1596
|
+
ctxMenu.textContent = "";
|
|
1597
|
+
const sizes = [8192, 16384, 32768, 65536, 131072];
|
|
1598
|
+
sizes.forEach((n) => {
|
|
1599
|
+
ctxMenu.appendChild(menuItem(Math.round(n / 1024) + "K", n === cfg.contextWindow ? t("web.selectors.current") : "", n === cfg.contextWindow, () => postConfig("contextWindow", n, false)));
|
|
1600
|
+
});
|
|
1601
|
+
ctxMenu.appendChild(el("div", "sep"));
|
|
1602
|
+
// Inline custom size (no native `window.prompt`): the menu stays open while
|
|
1603
|
+
// typing; Enter/save commits and closes it.
|
|
1604
|
+
const custom = el("button", "mi row-mi");
|
|
1605
|
+
custom.type = "button";
|
|
1606
|
+
custom.appendChild(icon("settings"));
|
|
1607
|
+
custom.appendChild(el("span", "", t("web.selectors.custom")));
|
|
1608
|
+
custom.addEventListener("click", (e) => {
|
|
1609
|
+
e.stopPropagation();
|
|
1610
|
+
if (ctxMenu.querySelector(".ctx-custom")) return;
|
|
1611
|
+
const wrap = el("div", "ctx-custom");
|
|
1612
|
+
const input = el("input");
|
|
1613
|
+
input.type = "number";
|
|
1614
|
+
input.min = "1024";
|
|
1615
|
+
input.step = "1024";
|
|
1616
|
+
input.value = String(cfg.contextWindow || 32768);
|
|
1617
|
+
input.placeholder = t("web.selectors.context_prompt");
|
|
1618
|
+
input.setAttribute("aria-label", t("web.selectors.context_prompt"));
|
|
1619
|
+
const commit = () => {
|
|
1620
|
+
const n = Number(input.value.trim());
|
|
1621
|
+
if (Number.isFinite(n) && n >= 1024) {
|
|
1622
|
+
closeAllMenus();
|
|
1623
|
+
postConfig("contextWindow", n, false);
|
|
1624
|
+
} else {
|
|
1625
|
+
input.focus();
|
|
1626
|
+
}
|
|
1627
|
+
};
|
|
1628
|
+
input.addEventListener("click", (ev) => ev.stopPropagation());
|
|
1629
|
+
input.addEventListener("keydown", (ev) => {
|
|
1630
|
+
ev.stopPropagation();
|
|
1631
|
+
if (ev.key === "Enter") {
|
|
1632
|
+
ev.preventDefault();
|
|
1633
|
+
commit();
|
|
1634
|
+
} else if (ev.key === "Escape") {
|
|
1635
|
+
wrap.remove();
|
|
1636
|
+
}
|
|
1637
|
+
});
|
|
1638
|
+
const ok = link(t("web.settings.save"), "", commit);
|
|
1639
|
+
wrap.appendChild(input);
|
|
1640
|
+
wrap.appendChild(ok);
|
|
1641
|
+
ctxMenu.appendChild(wrap);
|
|
1642
|
+
setTimeout(() => input.focus(), 0);
|
|
1643
|
+
});
|
|
1644
|
+
ctxMenu.appendChild(custom);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
document.querySelectorAll("[data-sel]").forEach((sel) => {
|
|
1648
|
+
const btn = sel.querySelector("button");
|
|
1649
|
+
btn.setAttribute("aria-haspopup", "menu");
|
|
1650
|
+
btn.setAttribute("aria-expanded", "false");
|
|
1651
|
+
btn.addEventListener("click", (e) => {
|
|
1652
|
+
e.stopPropagation();
|
|
1653
|
+
setSessOpen(false);
|
|
1654
|
+
document.querySelectorAll("[data-sel]").forEach((s) => {
|
|
1655
|
+
if (s !== sel) {
|
|
1656
|
+
s.classList.remove("open");
|
|
1657
|
+
const b = s.querySelector("button");
|
|
1658
|
+
if (b) b.setAttribute("aria-expanded", "false");
|
|
1659
|
+
}
|
|
1660
|
+
});
|
|
1661
|
+
const open = sel.classList.toggle("open");
|
|
1662
|
+
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
|
1663
|
+
});
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
let sessionsErrTimer = null;
|
|
1667
|
+
let indicatorTimer = null;
|
|
1668
|
+
function setIndicator(msg, fail) {
|
|
1669
|
+
if (!saveIndicator) return;
|
|
1670
|
+
saveIndicator.textContent = msg;
|
|
1671
|
+
saveIndicator.classList.toggle("fail", !!fail);
|
|
1672
|
+
saveIndicator.classList.add("show");
|
|
1673
|
+
clearTimeout(indicatorTimer);
|
|
1674
|
+
indicatorTimer = setTimeout(() => saveIndicator.classList.remove("show"), 2400);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
async function postConfig(key, value, refreshSettings) {
|
|
1678
|
+
const r = await api("/api/config", { method: "POST", body: { key, value } });
|
|
1679
|
+
if (r.ok && r.data && r.data.ok) {
|
|
1680
|
+
const persisted = r.data.persisted !== false;
|
|
1681
|
+
setIndicator(persisted ? t("web.settings.saved") : (r.data.warning || "not persisted"), !persisted);
|
|
1682
|
+
if (state.config && r.data.value !== undefined) setPath(state.config, key, r.data.value);
|
|
1683
|
+
if (key === "locale") {
|
|
1684
|
+
await loadI18n();
|
|
1685
|
+
applyStaticI18n();
|
|
1686
|
+
renderSettings();
|
|
1687
|
+
}
|
|
1688
|
+
renderSelectors();
|
|
1689
|
+
if (refreshSettings) renderSettings();
|
|
1690
|
+
return true;
|
|
1691
|
+
}
|
|
1692
|
+
const err = (r.data && (r.data.error || r.data.reason)) || r.status || t("web.settings.save_failed");
|
|
1693
|
+
setIndicator(String(err), true);
|
|
1694
|
+
return false;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
// Reflect the busy flag on the composer so a turn in progress is visible and
|
|
1698
|
+
// `aria-busy` is honest for assistive tech.
|
|
1699
|
+
function reflectBusy() {
|
|
1700
|
+
if (inp) inp.setAttribute("aria-busy", state.busy ? "true" : "false");
|
|
1701
|
+
const area = document.querySelector(".input-area");
|
|
1702
|
+
if (area) area.classList.toggle("busy", state.busy);
|
|
1703
|
+
// Send button doubles as stop while a turn is running; the Esc hint is only
|
|
1704
|
+
// meaningful then.
|
|
1705
|
+
if (sendBtn) {
|
|
1706
|
+
const label = state.busy ? t("web.input.stop") : t("web.input.send");
|
|
1707
|
+
sendBtn.textContent = "";
|
|
1708
|
+
sendBtn.appendChild(icon(state.busy ? "square" : "arrow-up"));
|
|
1709
|
+
sendBtn.classList.toggle("busy", state.busy);
|
|
1710
|
+
sendBtn.title = label;
|
|
1711
|
+
sendBtn.setAttribute("aria-label", label);
|
|
1712
|
+
}
|
|
1713
|
+
const escHint = document.getElementById("hintEsc");
|
|
1714
|
+
if (escHint) escHint.hidden = !state.busy;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
function renderState(snapshot, busy) {
|
|
1718
|
+
state.host = snapshot || {};
|
|
1719
|
+
state.busy = !!busy;
|
|
1720
|
+
reflectBusy();
|
|
1721
|
+
const sessionId = state.host.sessionId || null;
|
|
1722
|
+
state.activeSessionId = sessionId;
|
|
1723
|
+
if (sessionId && !tabs.some((x) => x.id === sessionId)) {
|
|
1724
|
+
openTab(sessionId, state.host.sessionName || String(sessionId), "session", false);
|
|
1725
|
+
}
|
|
1726
|
+
if (!tabs.length) showEmptyFeed();
|
|
1727
|
+
renderPlan(state.host.plan);
|
|
1728
|
+
renderUsage(state.host);
|
|
1729
|
+
renderEnvironment(state.host);
|
|
1730
|
+
renderWorkspace(state.host);
|
|
1731
|
+
renderSessionsDropdown();
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
function renderPlan(plan) {
|
|
1735
|
+
// The sidebar polls `/api/state` every 2s; skip a rebuild (and the scroll
|
|
1736
|
+
// reset it would cause) when the snapshot is unchanged.
|
|
1737
|
+
const sig = JSON.stringify([plan, I18N.locale]);
|
|
1738
|
+
if (sig === lastPlanSig) return;
|
|
1739
|
+
lastPlanSig = sig;
|
|
1740
|
+
planEl.textContent = "";
|
|
1741
|
+
if (!plan || !Array.isArray(plan.steps) || !plan.steps.length) {
|
|
1742
|
+
planCountEl.textContent = "";
|
|
1743
|
+
planEl.appendChild(el("div", "kv dim", t("web.plan.none")));
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
const done = plan.steps.filter((s) => s.status === "done").length;
|
|
1747
|
+
const total = plan.steps.length;
|
|
1748
|
+
const pct = total ? Math.round((done / total) * 100) : 0;
|
|
1749
|
+
planCountEl.textContent = done + " / " + total;
|
|
1750
|
+
const bar = el("div", "plan-bar");
|
|
1751
|
+
const track = el("span", "pbar");
|
|
1752
|
+
const fill = el("i");
|
|
1753
|
+
fill.style.width = pct + "%";
|
|
1754
|
+
track.appendChild(fill);
|
|
1755
|
+
bar.appendChild(track);
|
|
1756
|
+
bar.appendChild(el("span", "", pct + "%"));
|
|
1757
|
+
planEl.appendChild(bar);
|
|
1758
|
+
plan.steps.forEach((step, i) => {
|
|
1759
|
+
const current = i === plan.currentIndex && step.status !== "done" && step.status !== "failed";
|
|
1760
|
+
const status = step.status === "done" ? "done" : step.status === "failed" ? "fail" : current ? "cur" : "";
|
|
1761
|
+
const glyph = step.status === "done" ? "✓ " : step.status === "failed" ? "✗ " : current ? "▸ " : "○ ";
|
|
1762
|
+
planEl.appendChild(el("div", ("plan-step " + status).trim(), glyph + step.text));
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
function onPlan(data) {
|
|
1767
|
+
const feed = feedForEvent(data && data.sessionId);
|
|
1768
|
+
if (!feed || !data || !Array.isArray(data.steps) || !data.steps.length) return;
|
|
1769
|
+
clearEmpty(feed);
|
|
1770
|
+
const tab = tabs.find((x) => x.feed === feed);
|
|
1771
|
+
let card = tab ? tab.planCard : null;
|
|
1772
|
+
if (!card || card.parentNode !== feed) {
|
|
1773
|
+
card = el("div", "planblock");
|
|
1774
|
+
appendTo(feed, card);
|
|
1775
|
+
if (tab) tab.planCard = card;
|
|
1776
|
+
}
|
|
1777
|
+
card.textContent = "";
|
|
1778
|
+
const total = data.steps.length;
|
|
1779
|
+
const cur = typeof data.currentIndex === "number" ? data.currentIndex : -1;
|
|
1780
|
+
card.appendChild(el("div", "ph2", "◇ " + pluralCount("web.plan.heading", total)));
|
|
1781
|
+
data.steps.forEach((step, i) => {
|
|
1782
|
+
const status =
|
|
1783
|
+
step.status === "done" ? "done" : step.status === "failed" ? "fail" : i === cur ? "cur" : "";
|
|
1784
|
+
const glyph =
|
|
1785
|
+
step.status === "done" ? "✓ " : step.status === "failed" ? "✗ " : i === cur ? "▸ " : "○ ";
|
|
1786
|
+
card.appendChild(el("div", ("st " + status).trim(), glyph + step.text));
|
|
1787
|
+
});
|
|
1788
|
+
if (feed === activeFeed()) scroll();
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// `? ` glyph on the last line, matching the terminal menu (the tool may have
|
|
1792
|
+
// already prefixed it — don't double it).
|
|
1793
|
+
function questionLabel(text) {
|
|
1794
|
+
const lines = String(text).split("\n");
|
|
1795
|
+
const last = lines[lines.length - 1];
|
|
1796
|
+
if (/^\s*\?/.test(last)) return String(text);
|
|
1797
|
+
lines[lines.length - 1] = "? " + last;
|
|
1798
|
+
return lines.join("\n");
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function onQuestion(data) {
|
|
1802
|
+
if (!data || typeof data.text !== "string") return;
|
|
1803
|
+
const feed = feedForEvent(data.sessionId);
|
|
1804
|
+
if (!feed) return;
|
|
1805
|
+
clearEmpty(feed);
|
|
1806
|
+
const promptText = questionLabel(data.text);
|
|
1807
|
+
const block = el("div", "q");
|
|
1808
|
+
block.appendChild(el("div", "qtext", promptText));
|
|
1809
|
+
let answered = false;
|
|
1810
|
+
|
|
1811
|
+
// `display` is what the user sees; `raw` is exactly what goes to `/api/answer`.
|
|
1812
|
+
const settle = (display, raw) => {
|
|
1813
|
+
if (answered) return;
|
|
1814
|
+
answered = true;
|
|
1815
|
+
block.classList.add("answered");
|
|
1816
|
+
block.textContent = "";
|
|
1817
|
+
block.appendChild(el("div", "qtext", promptText));
|
|
1818
|
+
block.appendChild(el("div", "qans", "↳ " + (String(display || "").trim() || t("web.question.answered"))));
|
|
1819
|
+
api("/api/answer", { method: "POST", body: { id: data.id, value: raw } });
|
|
1820
|
+
if (feed === activeFeed()) scroll();
|
|
1821
|
+
};
|
|
1822
|
+
|
|
1823
|
+
// Free-text prompt (a `text` question, or the tool's follow-up after the
|
|
1824
|
+
// "own answer" choice — see `askUser`/`CUSTOM_INDEX`).
|
|
1825
|
+
const textInput = (placeholder) => {
|
|
1826
|
+
const wrap = el("div", "qcustom");
|
|
1827
|
+
const input = el("input", "qinput");
|
|
1828
|
+
input.type = "text";
|
|
1829
|
+
input.placeholder = placeholder;
|
|
1830
|
+
input.setAttribute("aria-label", placeholder);
|
|
1831
|
+
input.addEventListener("keydown", (e) => {
|
|
1832
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
1833
|
+
if (e.key === "Enter") {
|
|
1834
|
+
e.preventDefault();
|
|
1835
|
+
const value = input.value.trim();
|
|
1836
|
+
if (value) settle(value, value);
|
|
1837
|
+
}
|
|
1838
|
+
});
|
|
1839
|
+
wrap.appendChild(input);
|
|
1840
|
+
wrap.appendChild(
|
|
1841
|
+
link(t("web.question.custom_send"), "", () => {
|
|
1842
|
+
const value = input.value.trim();
|
|
1843
|
+
if (value) settle(value, value);
|
|
1844
|
+
}),
|
|
1845
|
+
);
|
|
1846
|
+
if (feed === activeFeed()) setTimeout(() => input.focus(), 0);
|
|
1847
|
+
return wrap;
|
|
1848
|
+
};
|
|
1849
|
+
|
|
1850
|
+
const options = Array.isArray(data.options) ? data.options : [];
|
|
1851
|
+
if (data.kind === "choice" && options.length) {
|
|
1852
|
+
const multiple = !!data.multiple;
|
|
1853
|
+
const allowCustom = !!data.allowCustom;
|
|
1854
|
+
// UI entries; the "own answer" entry maps to CUSTOM_INDEX (-1) so the tool
|
|
1855
|
+
// then asks the free-text follow-up.
|
|
1856
|
+
const entries = options.map((label) => ({ label, raw: null }));
|
|
1857
|
+
if (allowCustom) entries.push({ label: t("web.question.custom"), raw: -1 });
|
|
1858
|
+
const rawOf = (i) => (entries[i].raw != null ? entries[i].raw : i);
|
|
1859
|
+
const opts = el("div", "opts");
|
|
1860
|
+
const selected = [];
|
|
1861
|
+
let sendBtn = null;
|
|
1862
|
+
entries.forEach((entry, i) => {
|
|
1863
|
+
const row = el("button", "qopt", i + 1 + ". " + entry.label);
|
|
1864
|
+
row.type = "button";
|
|
1865
|
+
row.setAttribute("aria-checked", "false");
|
|
1866
|
+
row.addEventListener("click", () => {
|
|
1867
|
+
if (answered) return;
|
|
1868
|
+
if (multiple) {
|
|
1869
|
+
const pos = selected.indexOf(i);
|
|
1870
|
+
if (pos === -1) {
|
|
1871
|
+
selected.push(i);
|
|
1872
|
+
row.classList.add("on");
|
|
1873
|
+
row.setAttribute("aria-checked", "true");
|
|
1874
|
+
} else {
|
|
1875
|
+
selected.splice(pos, 1);
|
|
1876
|
+
row.classList.remove("on");
|
|
1877
|
+
row.setAttribute("aria-checked", "false");
|
|
1878
|
+
}
|
|
1879
|
+
if (sendBtn) sendBtn.disabled = selected.length === 0;
|
|
1880
|
+
} else {
|
|
1881
|
+
row.setAttribute("aria-checked", "true");
|
|
1882
|
+
settle(entry.label, [rawOf(i)]);
|
|
1883
|
+
}
|
|
1884
|
+
});
|
|
1885
|
+
opts.appendChild(row);
|
|
1886
|
+
});
|
|
1887
|
+
block.appendChild(opts);
|
|
1888
|
+
if (multiple) {
|
|
1889
|
+
block.appendChild(el("div", "qmulti-hint", t("web.question.multi_hint")));
|
|
1890
|
+
sendBtn = el("button", "qsend", t("web.question.send"));
|
|
1891
|
+
sendBtn.type = "button";
|
|
1892
|
+
sendBtn.disabled = true;
|
|
1893
|
+
sendBtn.addEventListener("click", () => {
|
|
1894
|
+
if (answered || !selected.length) return;
|
|
1895
|
+
const chosen = selected.slice().sort((a, b) => a - b);
|
|
1896
|
+
settle(chosen.map((i) => entries[i].label).join(", "), chosen.map(rawOf));
|
|
1897
|
+
});
|
|
1898
|
+
block.appendChild(sendBtn);
|
|
1899
|
+
}
|
|
1900
|
+
} else {
|
|
1901
|
+
block.appendChild(textInput(t("web.question.placeholder")));
|
|
1902
|
+
}
|
|
1903
|
+
appendTo(feed, block);
|
|
1904
|
+
if (feed === activeFeed()) scroll();
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
function usageRow(label, value, cls) {
|
|
1908
|
+
const row = el("div", "kv");
|
|
1909
|
+
row.appendChild(el("span", "", label));
|
|
1910
|
+
row.appendChild(el("b", cls || "", value));
|
|
1911
|
+
return row;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
function renderUsage(h) {
|
|
1915
|
+
const cfg = state.config || {};
|
|
1916
|
+
const sig = JSON.stringify([
|
|
1917
|
+
h.sessionId, h.model, h.contextUsed, h.contextLimit, h.cacheHitRate, h.costUsd,
|
|
1918
|
+
h.compactionCount, h.contextQuality, h.contextProbe, cfg.model, cfg.contextWindow,
|
|
1919
|
+
I18N.locale,
|
|
1920
|
+
]);
|
|
1921
|
+
if (sig === lastUsageSig) return;
|
|
1922
|
+
lastUsageSig = sig;
|
|
1923
|
+
usageEl.textContent = "";
|
|
1924
|
+
const sid = h.sessionId ? String(h.sessionId) : "—";
|
|
1925
|
+
usageEl.appendChild(usageRow(t("web.usage.session"), sid));
|
|
1926
|
+
usageEl.appendChild(usageRow(t("web.usage.model"), h.model || cfg.model || "—"));
|
|
1927
|
+
const used = typeof h.contextUsed === "number" ? h.contextUsed : null;
|
|
1928
|
+
const limit = typeof h.contextLimit === "number" ? h.contextLimit : cfg.contextWindow || null;
|
|
1929
|
+
usageEl.appendChild(usageRow(t("web.usage.context"), used != null && limit ? fmtTokens(used) + " / " + fmtTokens(limit) : "—"));
|
|
1930
|
+
if (used != null && limit) {
|
|
1931
|
+
const bar = el("div", "bar");
|
|
1932
|
+
const fill = el("i");
|
|
1933
|
+
fill.style.width = Math.min(100, Math.round((used / limit) * 100)) + "%";
|
|
1934
|
+
bar.appendChild(fill);
|
|
1935
|
+
usageEl.appendChild(bar);
|
|
1936
|
+
}
|
|
1937
|
+
const hit = typeof h.cacheHitRate === "number" ? h.cacheHitRate : null;
|
|
1938
|
+
usageEl.appendChild(usageRow(t("web.usage.cache"), hit != null ? Math.round(hit * 100) + "%" : "—", hit != null ? "" : "dim"));
|
|
1939
|
+
usageEl.appendChild(usageRow(t("web.usage.cost"), typeof h.costUsd === "number" ? "$" + h.costUsd.toFixed(4) : "—"));
|
|
1940
|
+
usageEl.appendChild(usageRow(t("web.usage.compactions"), "⟳ " + (h.compactionCount || 0), h.compactionCount ? "wn" : "dim"));
|
|
1941
|
+
const q = typeof h.contextQuality === "number" ? h.contextQuality : null;
|
|
1942
|
+
usageEl.appendChild(usageRow(t("web.usage.quality"), q != null ? Math.round(q) + "%" : "—", q != null ? "" : "dim"));
|
|
1943
|
+
|
|
1944
|
+
// Context probe: real LM Studio loaded context vs configured. Pending/absent
|
|
1945
|
+
// renders a dim dash; loaded < configured warns.
|
|
1946
|
+
let probeText = "—";
|
|
1947
|
+
let probeCls = "dim";
|
|
1948
|
+
const probe = h.contextProbe;
|
|
1949
|
+
if (probe && typeof probe.actual === "number") {
|
|
1950
|
+
const actual = fmtTokens(probe.actual);
|
|
1951
|
+
const configured = h.contextLimit || cfg.contextWindow || null;
|
|
1952
|
+
if (configured && probe.actual < configured) {
|
|
1953
|
+
probeText = t("web.usage.probe_low", { actual, configured: fmtTokens(configured) });
|
|
1954
|
+
probeCls = "wn";
|
|
1955
|
+
} else {
|
|
1956
|
+
probeText = t("web.usage.probe_loaded", { actual });
|
|
1957
|
+
probeCls = "";
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
usageEl.appendChild(usageRow(t("web.usage.probe"), probeText, probeCls));
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// Workspace path under the composer (dim, own line).
|
|
1964
|
+
function renderWorkspace(h) {
|
|
1965
|
+
const ws = h.workspace || "";
|
|
1966
|
+
if (wsLine.dataset.ws === ws) return;
|
|
1967
|
+
wsLine.dataset.ws = ws;
|
|
1968
|
+
wsLine.textContent = "";
|
|
1969
|
+
wsLine.hidden = !ws;
|
|
1970
|
+
if (!ws) return;
|
|
1971
|
+
wsLine.appendChild(el("span", "ws-k", t("web.env.dir")));
|
|
1972
|
+
const v = el("span", "ws-v", ws);
|
|
1973
|
+
v.title = ws;
|
|
1974
|
+
wsLine.appendChild(v);
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// Environment (sidebar): stacked name/value items — AGENTS.md sources, map size.
|
|
1978
|
+
function envItem(label, values) {
|
|
1979
|
+
const item = el("div", "env-item");
|
|
1980
|
+
item.appendChild(el("div", "env-k", label));
|
|
1981
|
+
values.forEach((val) => {
|
|
1982
|
+
const v = el("div", "env-v", val);
|
|
1983
|
+
v.title = val;
|
|
1984
|
+
item.appendChild(v);
|
|
1985
|
+
});
|
|
1986
|
+
return item;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
function renderEnvironment(h) {
|
|
1990
|
+
const disabled = !!(h.instructions && h.instructions.disabled);
|
|
1991
|
+
const files = h.instructions && !disabled ? h.instructions.files || [] : [];
|
|
1992
|
+
const map = h.projectMap || null;
|
|
1993
|
+
const sig = JSON.stringify([disabled, files, map, I18N.locale]);
|
|
1994
|
+
if (sig === lastEnvSig) return;
|
|
1995
|
+
lastEnvSig = sig;
|
|
1996
|
+
envEl.textContent = "";
|
|
1997
|
+
envEl.appendChild(
|
|
1998
|
+
envItem(
|
|
1999
|
+
t("web.env.agents"),
|
|
2000
|
+
disabled ? [t("web.env.agents_disabled")] : files.length ? files : [t("web.env.agents_none")],
|
|
2001
|
+
),
|
|
2002
|
+
);
|
|
2003
|
+
envEl.appendChild(
|
|
2004
|
+
envItem(
|
|
2005
|
+
t("web.env.map"),
|
|
2006
|
+
map && map.indexed
|
|
2007
|
+
? [
|
|
2008
|
+
[
|
|
2009
|
+
pluralCount("web.env.files", map.files),
|
|
2010
|
+
pluralCount("web.env.symbols", map.symbols),
|
|
2011
|
+
pluralCount("web.env.tokens", map.tokens),
|
|
2012
|
+
].join(" · "),
|
|
2013
|
+
]
|
|
2014
|
+
: [t("web.env.map_not_indexed")],
|
|
2015
|
+
),
|
|
2016
|
+
);
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
function fmtTokens(n) {
|
|
2020
|
+
return n >= 1000 ? (n / 1000).toFixed(1) + "K" : String(n);
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
async function refresh() {
|
|
2024
|
+
const r = await api("/api/state");
|
|
2025
|
+
if (!r.ok || !r.data) return;
|
|
2026
|
+
renderState(r.data.state, r.data.busy);
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
async function loadConfig() {
|
|
2030
|
+
const r = await api("/api/config");
|
|
2031
|
+
if (!r.ok || !r.data) return;
|
|
2032
|
+
state.config = r.data.config || {};
|
|
2033
|
+
state.registry = Array.isArray(r.data.registry) ? r.data.registry : [];
|
|
2034
|
+
state.webToken = typeof r.data.webToken === "string" ? r.data.webToken : null;
|
|
2035
|
+
renderSelectors();
|
|
2036
|
+
renderSettings();
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
async function loadModels() {
|
|
2040
|
+
const r = await api("/api/models");
|
|
2041
|
+
if (!r.ok || !r.data) return;
|
|
2042
|
+
state.models = Array.isArray(r.data.models) ? r.data.models : [];
|
|
2043
|
+
renderSelectors();
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
async function loadProviders() {
|
|
2047
|
+
const r = await api("/api/providers");
|
|
2048
|
+
state.providers = r.ok && r.data && Array.isArray(r.data.entries) ? r.data : undefined;
|
|
2049
|
+
renderModelPanel();
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
async function loadSessions() {
|
|
2053
|
+
const r = await api("/api/sessions");
|
|
2054
|
+
if (!r.ok || !r.data) return;
|
|
2055
|
+
state.sessions = Array.isArray(r.data.sessions) ? r.data.sessions : [];
|
|
2056
|
+
renderSessionsDropdown();
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// Connection-state banner: shown while the SSE stream is down. The server
|
|
2060
|
+
// never replays events, so a reconnect only resyncs state — the feed keeps
|
|
2061
|
+
// whatever the live session already rendered (no duplication).
|
|
2062
|
+
function setConnStatus(disconnected) {
|
|
2063
|
+
if (!connStatus) return;
|
|
2064
|
+
connStatus.hidden = !disconnected;
|
|
2065
|
+
connStatus.textContent = disconnected ? "⋯ " + t("web.conn.reconnecting") : "";
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
function resyncAfterReconnect() {
|
|
2069
|
+
refresh();
|
|
2070
|
+
const tab = tabs.find((x) => x.id === activeTabId);
|
|
2071
|
+
if (tab && tab.kind === "session" && !feedHasContent(tab.feed)) {
|
|
2072
|
+
tab.historyLoaded = false;
|
|
2073
|
+
loadHistory(tab);
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
function connectSSE() {
|
|
2078
|
+
const sseUrl = "/events" + (token ? "?token=" + encodeURIComponent(token) : "");
|
|
2079
|
+
const es = new EventSource(sseUrl);
|
|
2080
|
+
let dropped = false;
|
|
2081
|
+
es.onopen = () => {
|
|
2082
|
+
if (!dropped) return;
|
|
2083
|
+
dropped = false;
|
|
2084
|
+
setConnStatus(false);
|
|
2085
|
+
resyncAfterReconnect();
|
|
2086
|
+
};
|
|
2087
|
+
es.onerror = (e) => {
|
|
2088
|
+
// A server-sent `event: error` also lands here but carries `data`; only a
|
|
2089
|
+
// real transport drop has none. Don't flag a live stream as disconnected.
|
|
2090
|
+
if (e && typeof e.data === "string") return;
|
|
2091
|
+
dropped = true;
|
|
2092
|
+
setConnStatus(true);
|
|
2093
|
+
};
|
|
2094
|
+
const parse = (e) => {
|
|
2095
|
+
try {
|
|
2096
|
+
return JSON.parse(e.data);
|
|
2097
|
+
} catch (err) {
|
|
2098
|
+
return null;
|
|
2099
|
+
}
|
|
2100
|
+
};
|
|
2101
|
+
es.addEventListener("text", (e) => {
|
|
2102
|
+
const d = parse(e);
|
|
2103
|
+
if (d) onText(d.chunk || "", d.sessionId);
|
|
2104
|
+
});
|
|
2105
|
+
es.addEventListener("meta", (e) => {
|
|
2106
|
+
const d = parse(e);
|
|
2107
|
+
if (d) onMeta(d.chunk || "", d.sessionId);
|
|
2108
|
+
});
|
|
2109
|
+
es.addEventListener("phase", (e) => {
|
|
2110
|
+
const d = parse(e);
|
|
2111
|
+
if (d) onPhase(d.phase, d.sessionId);
|
|
2112
|
+
});
|
|
2113
|
+
es.addEventListener("tool_start", (e) => {
|
|
2114
|
+
const d = parse(e);
|
|
2115
|
+
if (d) onToolStart(d);
|
|
2116
|
+
});
|
|
2117
|
+
es.addEventListener("tool_end", (e) => {
|
|
2118
|
+
const d = parse(e);
|
|
2119
|
+
if (d) onToolEnd(d);
|
|
2120
|
+
});
|
|
2121
|
+
es.addEventListener("plan", (e) => {
|
|
2122
|
+
const d = parse(e);
|
|
2123
|
+
if (d) onPlan(d);
|
|
2124
|
+
});
|
|
2125
|
+
es.addEventListener("question", (e) => {
|
|
2126
|
+
const d = parse(e);
|
|
2127
|
+
if (d) onQuestion(d);
|
|
2128
|
+
});
|
|
2129
|
+
es.addEventListener("turn_end", (e) => {
|
|
2130
|
+
const d = parse(e);
|
|
2131
|
+
if (d) onTurnEnd(d);
|
|
2132
|
+
});
|
|
2133
|
+
es.addEventListener("command", (e) => {
|
|
2134
|
+
const d = parse(e);
|
|
2135
|
+
if (d && typeof d.text === "string") onCommandOutput(d.text, d.sessionId);
|
|
2136
|
+
});
|
|
2137
|
+
es.addEventListener("log", (e) => {
|
|
2138
|
+
const d = parse(e);
|
|
2139
|
+
if (d) onLog(d);
|
|
2140
|
+
});
|
|
2141
|
+
es.addEventListener("state", (e) => {
|
|
2142
|
+
const d = parse(e);
|
|
2143
|
+
if (d) {
|
|
2144
|
+
state.busy = !!d.busy;
|
|
2145
|
+
reflectBusy();
|
|
2146
|
+
if (d.usage && typeof d.usage === "object") {
|
|
2147
|
+
state.host = Object.assign({}, state.host, d.usage);
|
|
2148
|
+
renderUsage(state.host);
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
});
|
|
2152
|
+
es.addEventListener("error", (e) => {
|
|
2153
|
+
if (!e.data) return;
|
|
2154
|
+
const d = parse(e);
|
|
2155
|
+
if (d) onErrorEvent(d);
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
const SECTION_KEYS = {
|
|
2160
|
+
general: ["locale", "ui.verbosity", "maxToolIterations", "stuckThreshold"],
|
|
2161
|
+
context: ["contextWindow", "contextBudget.systemPrompt", "contextBudget.responseReserve"],
|
|
2162
|
+
reasoning: ["showReasoning", "reasoning.mode"],
|
|
2163
|
+
security: [
|
|
2164
|
+
"security.enabled",
|
|
2165
|
+
"security.paths.enabled",
|
|
2166
|
+
"security.contentScan.enabled",
|
|
2167
|
+
"security.sessionEncryption.enabled",
|
|
2168
|
+
],
|
|
2169
|
+
advanced: ["logLevel", "version"],
|
|
2170
|
+
};
|
|
2171
|
+
|
|
2172
|
+
// Localized label for a registry key: `config.key.<canonical>`, falling back
|
|
2173
|
+
// to the raw key when no translation exists (so a new key is never blank).
|
|
2174
|
+
function configLabel(def) {
|
|
2175
|
+
const k = "config.key." + def.key;
|
|
2176
|
+
const tr = t(k);
|
|
2177
|
+
return tr === k ? def.key : tr;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
// Localized enum value: `config.enum.<value>`, falling back to the raw value.
|
|
2181
|
+
// The raw value is still what gets POSTed — only the label is translated.
|
|
2182
|
+
function enumLabel(v) {
|
|
2183
|
+
const k = "config.enum." + v;
|
|
2184
|
+
const tr = t(k);
|
|
2185
|
+
return tr === k ? v : tr;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
function defFor(key) {
|
|
2189
|
+
return state.registry.find((d) => d.key === key) || null;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
function row(label, control, desc) {
|
|
2193
|
+
const r = el("div", "row");
|
|
2194
|
+
const left = el("div", "row-label");
|
|
2195
|
+
left.appendChild(el("span", "row-name", label));
|
|
2196
|
+
if (desc) left.appendChild(el("span", "row-desc", desc));
|
|
2197
|
+
r.appendChild(left);
|
|
2198
|
+
if (typeof control === "string") {
|
|
2199
|
+
const v = el("span", "val", control);
|
|
2200
|
+
v.title = control;
|
|
2201
|
+
r.appendChild(v);
|
|
2202
|
+
} else if (control) {
|
|
2203
|
+
r.appendChild(control);
|
|
2204
|
+
}
|
|
2205
|
+
return r;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
function descFor(key) {
|
|
2209
|
+
const k = "config.desc." + key;
|
|
2210
|
+
const d = t(k);
|
|
2211
|
+
return d === k ? "" : d;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
function configControl(def, key) {
|
|
2215
|
+
const value = state.config ? getPath(state.config, key) : undefined;
|
|
2216
|
+
if (def.valueType === "bool") {
|
|
2217
|
+
const toggle = el("button", "toggle" + (value ? " on" : ""));
|
|
2218
|
+
toggle.type = "button";
|
|
2219
|
+
toggle.setAttribute("role", "switch");
|
|
2220
|
+
toggle.setAttribute("aria-checked", value ? "true" : "false");
|
|
2221
|
+
toggle.addEventListener("click", () => {
|
|
2222
|
+
const next = !toggle.classList.contains("on");
|
|
2223
|
+
toggle.classList.toggle("on", next);
|
|
2224
|
+
toggle.setAttribute("aria-checked", next ? "true" : "false");
|
|
2225
|
+
postConfig(key, next, false);
|
|
2226
|
+
});
|
|
2227
|
+
return toggle;
|
|
2228
|
+
}
|
|
2229
|
+
if (def.valueType === "enum") {
|
|
2230
|
+
const seg = el("span", "seg");
|
|
2231
|
+
(def.enum || []).forEach((v) => {
|
|
2232
|
+
const b = el("button", v === value ? "on" : "", enumLabel(v));
|
|
2233
|
+
b.type = "button";
|
|
2234
|
+
b.setAttribute("aria-pressed", v === value ? "true" : "false");
|
|
2235
|
+
b.addEventListener("click", () => {
|
|
2236
|
+
seg.querySelectorAll("button").forEach((x) => {
|
|
2237
|
+
x.classList.remove("on");
|
|
2238
|
+
x.setAttribute("aria-pressed", "false");
|
|
2239
|
+
});
|
|
2240
|
+
b.classList.add("on");
|
|
2241
|
+
b.setAttribute("aria-pressed", "true");
|
|
2242
|
+
postConfig(key, v, false);
|
|
2243
|
+
});
|
|
2244
|
+
seg.appendChild(b);
|
|
2245
|
+
});
|
|
2246
|
+
return seg;
|
|
2247
|
+
}
|
|
2248
|
+
if (def.readOnly) {
|
|
2249
|
+
const ro = el("span", "val dim", value == null ? "—" : String(value));
|
|
2250
|
+
if (value != null) ro.title = String(value);
|
|
2251
|
+
return ro;
|
|
2252
|
+
}
|
|
2253
|
+
const input = el("input");
|
|
2254
|
+
input.setAttribute("aria-label", configLabel(def));
|
|
2255
|
+
if (def.valueType === "int") {
|
|
2256
|
+
input.type = "number";
|
|
2257
|
+
if (def.min != null) input.min = String(def.min);
|
|
2258
|
+
if (def.max != null) input.max = String(def.max);
|
|
2259
|
+
if (def.step != null) input.step = String(def.step);
|
|
2260
|
+
input.value = value == null ? "" : String(value);
|
|
2261
|
+
} else if (def.valueType === "share") {
|
|
2262
|
+
input.value = value == null ? "" : Math.round(Number(value) * 100) + "%";
|
|
2263
|
+
} else {
|
|
2264
|
+
input.value = value == null ? "" : String(value);
|
|
2265
|
+
}
|
|
2266
|
+
input.addEventListener("change", () => {
|
|
2267
|
+
postConfig(key, input.value, false);
|
|
2268
|
+
});
|
|
2269
|
+
return input;
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
function renderGenericPanel(name, keys) {
|
|
2273
|
+
const container = document.querySelector('[data-panel="' + name + '"]');
|
|
2274
|
+
if (!container) return;
|
|
2275
|
+
container.textContent = "";
|
|
2276
|
+
keys.forEach((key) => {
|
|
2277
|
+
const def = defFor(key);
|
|
2278
|
+
if (!def) return;
|
|
2279
|
+
container.appendChild(row(configLabel(def), configControl(def, key), descFor(def.key)));
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
function providerEntries() {
|
|
2284
|
+
if (state.providers && Array.isArray(state.providers.entries) && state.providers.entries.length) {
|
|
2285
|
+
return state.providers.entries;
|
|
2286
|
+
}
|
|
2287
|
+
const provider = (state.config && state.config.provider) || {};
|
|
2288
|
+
if (Array.isArray(provider.entries) && provider.entries.length) {
|
|
2289
|
+
return provider.entries.map((e) => ({
|
|
2290
|
+
type: e.type,
|
|
2291
|
+
label: e.label || e.type,
|
|
2292
|
+
baseUrl: e.baseUrl,
|
|
2293
|
+
hasApiKey: !!e.apiKey,
|
|
2294
|
+
contextWindow: e.contextWindow,
|
|
2295
|
+
priority: e.priority,
|
|
2296
|
+
}));
|
|
2297
|
+
}
|
|
2298
|
+
return [
|
|
2299
|
+
{
|
|
2300
|
+
type: provider.type,
|
|
2301
|
+
label: provider.active || provider.type,
|
|
2302
|
+
baseUrl: provider.baseUrl,
|
|
2303
|
+
hasApiKey: !!provider.apiKey,
|
|
2304
|
+
},
|
|
2305
|
+
];
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
function providerName(p) {
|
|
2309
|
+
return p.label || p.type || "—";
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
function providerMeta(p) {
|
|
2313
|
+
const parts = [p.type];
|
|
2314
|
+
if (p.contextWindow) parts.push(Math.round(p.contextWindow / 1024) + "K");
|
|
2315
|
+
if (typeof p.rpm === "number" || typeof p.parallel === "number") {
|
|
2316
|
+
parts.push("rpm " + (p.rpm == null ? "—" : p.rpm) + " · x" + (p.parallel == null ? "—" : p.parallel));
|
|
2317
|
+
}
|
|
2318
|
+
return parts.join(" · ");
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
function pfield(labelKey, input) {
|
|
2322
|
+
const f = el("div", "pform-field");
|
|
2323
|
+
f.appendChild(el("label", "", t(labelKey)));
|
|
2324
|
+
input.setAttribute("aria-label", t(labelKey));
|
|
2325
|
+
f.appendChild(input);
|
|
2326
|
+
return f;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
function providerInput(value, type, placeholder) {
|
|
2330
|
+
const input = el("input");
|
|
2331
|
+
input.type = type || "text";
|
|
2332
|
+
if (placeholder) input.placeholder = placeholder;
|
|
2333
|
+
if (value != null) input.value = String(value);
|
|
2334
|
+
return input;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
function providerForm() {
|
|
2338
|
+
const editing = (state.providerForm && state.providerForm.entry) || {};
|
|
2339
|
+
const box = el("div", "pform");
|
|
2340
|
+
const grid = el("div", "pform-grid");
|
|
2341
|
+
|
|
2342
|
+
const fType = providerInput(editing.type, "text", "openai-compat");
|
|
2343
|
+
const fLabel = providerInput(editing.label, "text", "my-provider");
|
|
2344
|
+
const fBase = providerInput(editing.baseUrl, "text", "http://localhost:1234/v1");
|
|
2345
|
+
const fKey = providerInput("", "password", editing.hasApiKey ? t("web.settings.provider_key_keep") : "");
|
|
2346
|
+
const fCtx = providerInput(editing.contextWindow, "number", "32768");
|
|
2347
|
+
const fPrio = providerInput(editing.priority, "number", "0");
|
|
2348
|
+
const fRpm = providerInput(editing.rpm, "number", "60");
|
|
2349
|
+
const fPar = providerInput(editing.parallel, "number", "1");
|
|
2350
|
+
|
|
2351
|
+
grid.appendChild(pfield("web.settings.provider_type", fType));
|
|
2352
|
+
grid.appendChild(pfield("web.settings.provider_label_field", fLabel));
|
|
2353
|
+
grid.appendChild(pfield("web.settings.provider_baseurl", fBase));
|
|
2354
|
+
grid.appendChild(pfield("web.settings.provider_apikey", fKey));
|
|
2355
|
+
grid.appendChild(pfield("web.settings.provider_context", fCtx));
|
|
2356
|
+
grid.appendChild(pfield("web.settings.provider_priority", fPrio));
|
|
2357
|
+
grid.appendChild(pfield("web.settings.provider_rpm", fRpm));
|
|
2358
|
+
grid.appendChild(pfield("web.settings.provider_parallel", fPar));
|
|
2359
|
+
box.appendChild(grid);
|
|
2360
|
+
|
|
2361
|
+
const actions = el("div", "pform-actions");
|
|
2362
|
+
const save = link(t("web.settings.save"), "", async () => {
|
|
2363
|
+
const type = fType.value.trim();
|
|
2364
|
+
const baseUrl = fBase.value.trim();
|
|
2365
|
+
if (!type || !baseUrl) {
|
|
2366
|
+
setIndicator(t("web.settings.provider_required"), true);
|
|
2367
|
+
return;
|
|
2368
|
+
}
|
|
2369
|
+
const body = { type, baseUrl };
|
|
2370
|
+
const label = fLabel.value.trim();
|
|
2371
|
+
if (label) body.label = label;
|
|
2372
|
+
const key = fKey.value.trim();
|
|
2373
|
+
if (key && key !== "***") body.apiKey = key;
|
|
2374
|
+
const nums = [
|
|
2375
|
+
["contextWindow", fCtx, 1024],
|
|
2376
|
+
["priority", fPrio, 0],
|
|
2377
|
+
["rpm", fRpm, 1],
|
|
2378
|
+
["parallel", fPar, 1],
|
|
2379
|
+
];
|
|
2380
|
+
for (const entry of nums) {
|
|
2381
|
+
const raw = entry[1].value.trim();
|
|
2382
|
+
if (!raw) continue;
|
|
2383
|
+
const n = Number(raw);
|
|
2384
|
+
if (!Number.isInteger(n) || n < entry[2]) {
|
|
2385
|
+
setIndicator(t("web.settings.provider_invalid"), true);
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
2388
|
+
body[entry[0]] = n;
|
|
2389
|
+
}
|
|
2390
|
+
const r = await api("/api/providers", { method: "POST", body });
|
|
2391
|
+
if (r.ok && r.data && r.data.ok) {
|
|
2392
|
+
const persisted = r.data.persisted !== false;
|
|
2393
|
+
state.providerForm = null;
|
|
2394
|
+
setIndicator(persisted ? t("web.settings.saved") : r.data.warning || "not persisted", !persisted);
|
|
2395
|
+
await loadProviders();
|
|
2396
|
+
await loadConfig();
|
|
2397
|
+
} else {
|
|
2398
|
+
setIndicator((r.data && r.data.error) || t("web.settings.save_failed"), true);
|
|
2399
|
+
}
|
|
2400
|
+
});
|
|
2401
|
+
const cancel = link(t("web.settings.cancel"), "dim", () => {
|
|
2402
|
+
state.providerForm = null;
|
|
2403
|
+
renderModelPanel();
|
|
2404
|
+
});
|
|
2405
|
+
actions.appendChild(save);
|
|
2406
|
+
actions.appendChild(cancel);
|
|
2407
|
+
box.appendChild(actions);
|
|
2408
|
+
return box;
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
function renderModelPanel() {
|
|
2412
|
+
const container = document.querySelector('[data-panel="model"]');
|
|
2413
|
+
if (!container) return;
|
|
2414
|
+
container.textContent = "";
|
|
2415
|
+
const cfg = state.config || {};
|
|
2416
|
+
|
|
2417
|
+
const search = el("input", "searchbox");
|
|
2418
|
+
search.placeholder = t("web.settings.search_models");
|
|
2419
|
+
search.setAttribute("aria-label", t("web.settings.search_models"));
|
|
2420
|
+
container.appendChild(search);
|
|
2421
|
+
const list = el("div");
|
|
2422
|
+
container.appendChild(list);
|
|
2423
|
+
const draw = () => {
|
|
2424
|
+
list.textContent = "";
|
|
2425
|
+
const q = search.value.trim().toLowerCase();
|
|
2426
|
+
const models = state.models.filter((m) => !q || m.toLowerCase().indexOf(q) !== -1);
|
|
2427
|
+
if (!models.length) {
|
|
2428
|
+
list.appendChild(el("div", "placeholder", state.models.length ? t("web.settings.no_matches") : t("web.settings.no_models")));
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
models.forEach((m) => {
|
|
2432
|
+
const active = m === cfg.model;
|
|
2433
|
+
const r = el("div", "row");
|
|
2434
|
+
const nameEl = el("span", active ? "val accent" : "", m);
|
|
2435
|
+
nameEl.title = m;
|
|
2436
|
+
r.appendChild(nameEl);
|
|
2437
|
+
if (active) {
|
|
2438
|
+
r.appendChild(el("span", "val dim", contextLabel() + " · " + t("web.settings.active_check")));
|
|
2439
|
+
} else {
|
|
2440
|
+
r.appendChild(link(t("web.settings.use"), "", () => postConfig("model", m, true)));
|
|
2441
|
+
}
|
|
2442
|
+
list.appendChild(r);
|
|
2443
|
+
});
|
|
2444
|
+
};
|
|
2445
|
+
search.addEventListener("input", draw);
|
|
2446
|
+
draw();
|
|
2447
|
+
|
|
2448
|
+
const entries = providerEntries();
|
|
2449
|
+
const activeName = (state.providers && state.providers.active) || cfg.provider?.active || cfg.provider?.type || "—";
|
|
2450
|
+
const activeVal = el("span", "val", activeName);
|
|
2451
|
+
activeVal.title = activeName;
|
|
2452
|
+
container.appendChild(row(t("web.settings.active_provider"), activeVal));
|
|
2453
|
+
|
|
2454
|
+
const provList = el("div", "settings-list");
|
|
2455
|
+
entries.forEach((entry) => {
|
|
2456
|
+
const name = providerName(entry);
|
|
2457
|
+
if (!name || name === "—") return;
|
|
2458
|
+
const line = el("div", "list-line");
|
|
2459
|
+
const nameEl = el("span", "name", name);
|
|
2460
|
+
nameEl.title = name;
|
|
2461
|
+
line.appendChild(nameEl);
|
|
2462
|
+
line.appendChild(el("span", "src", providerMeta(entry)));
|
|
2463
|
+
if (name === activeName) {
|
|
2464
|
+
line.appendChild(el("span", "st", t("web.settings.active")));
|
|
2465
|
+
} else {
|
|
2466
|
+
line.appendChild(link(t("web.settings.use"), "", () => postConfig("provider.active", name, true)));
|
|
2467
|
+
}
|
|
2468
|
+
line.appendChild(
|
|
2469
|
+
link(t("web.settings.edit"), "", () => {
|
|
2470
|
+
state.providerForm = { mode: "edit", entry };
|
|
2471
|
+
renderModelPanel();
|
|
2472
|
+
}),
|
|
2473
|
+
);
|
|
2474
|
+
if (name !== activeName) {
|
|
2475
|
+
line.appendChild(
|
|
2476
|
+
link(t("web.settings.delete"), "danger", async () => {
|
|
2477
|
+
const r = await api("/api/providers/" + encodeURIComponent(name), { method: "DELETE" });
|
|
2478
|
+
if (r.ok && r.data && r.data.ok) {
|
|
2479
|
+
setIndicator(t("web.settings.saved"));
|
|
2480
|
+
await loadProviders();
|
|
2481
|
+
await loadConfig();
|
|
2482
|
+
} else {
|
|
2483
|
+
const err = (r.data && r.data.error) || t("web.settings.delete_failed");
|
|
2484
|
+
setIndicator(err === "active-provider" ? t("web.settings.provider_active_guard") : String(err), true);
|
|
2485
|
+
}
|
|
2486
|
+
}),
|
|
2487
|
+
);
|
|
2488
|
+
}
|
|
2489
|
+
provList.appendChild(line);
|
|
2490
|
+
});
|
|
2491
|
+
container.appendChild(provList);
|
|
2492
|
+
|
|
2493
|
+
container.appendChild(
|
|
2494
|
+
link(t("web.settings.add_provider"), "add-provider", () => {
|
|
2495
|
+
state.providerForm = { mode: "add", entry: null };
|
|
2496
|
+
renderModelPanel();
|
|
2497
|
+
}),
|
|
2498
|
+
);
|
|
2499
|
+
if (state.providerForm) container.appendChild(providerForm());
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
function renderUiPanel() {
|
|
2503
|
+
const container = document.querySelector('[data-panel="ui"]');
|
|
2504
|
+
if (!container) return;
|
|
2505
|
+
container.textContent = "";
|
|
2506
|
+
const seg = el("span", "seg");
|
|
2507
|
+
["dark", "light", "auto"].forEach((theme) => {
|
|
2508
|
+
const b = el("button", currentTheme() === theme ? "on" : "", theme);
|
|
2509
|
+
b.type = "button";
|
|
2510
|
+
b.setAttribute("aria-pressed", currentTheme() === theme ? "true" : "false");
|
|
2511
|
+
b.addEventListener("click", () => {
|
|
2512
|
+
seg.querySelectorAll("button").forEach((x) => {
|
|
2513
|
+
x.classList.remove("on");
|
|
2514
|
+
x.setAttribute("aria-pressed", "false");
|
|
2515
|
+
});
|
|
2516
|
+
b.classList.add("on");
|
|
2517
|
+
b.setAttribute("aria-pressed", "true");
|
|
2518
|
+
applyTheme(theme);
|
|
2519
|
+
});
|
|
2520
|
+
seg.appendChild(b);
|
|
2521
|
+
});
|
|
2522
|
+
container.appendChild(row(t("web.settings.theme"), seg));
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
function renderSecurityPanel() {
|
|
2526
|
+
const container = document.querySelector('[data-panel="security"]');
|
|
2527
|
+
if (!container) return;
|
|
2528
|
+
container.textContent = "";
|
|
2529
|
+
SECTION_KEYS.security.forEach((key) => {
|
|
2530
|
+
const def = defFor(key);
|
|
2531
|
+
if (!def) return;
|
|
2532
|
+
container.appendChild(row(configLabel(def), configControl(def, key), descFor(def.key)));
|
|
2533
|
+
});
|
|
2534
|
+
container.appendChild(
|
|
2535
|
+
row(t("web.settings.web_token"), el("span", "val dim", state.webToken || t("web.settings.web_token_none")), t("config.desc.webToken")),
|
|
2536
|
+
);
|
|
2537
|
+
const note = el("div", "placeholder");
|
|
2538
|
+
note.appendChild(el("span", "tag", t("web.settings.terminal_tag")));
|
|
2539
|
+
note.appendChild(document.createTextNode(" " + t("web.settings.security_note")));
|
|
2540
|
+
container.appendChild(note);
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
function placeholderFor(data, empty) {
|
|
2544
|
+
const d = el("div", "placeholder");
|
|
2545
|
+
if (data === undefined) d.textContent = t("web.settings.loading");
|
|
2546
|
+
else d.textContent = empty;
|
|
2547
|
+
return d;
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
function groupHeader(label, count) {
|
|
2551
|
+
const head = el("div", "row");
|
|
2552
|
+
head.appendChild(el("span", "val accent", label));
|
|
2553
|
+
if (count != null) head.appendChild(el("span", "val dim", String(count)));
|
|
2554
|
+
return head;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
const MEMORY_CATEGORIES = ["conventions", "decisions", "errors", "facts"];
|
|
2558
|
+
|
|
2559
|
+
function renderMemoryPanel() {
|
|
2560
|
+
const container = document.querySelector('[data-panel="memory"]');
|
|
2561
|
+
if (!container) return;
|
|
2562
|
+
container.textContent = "";
|
|
2563
|
+
if (state.memory === undefined || state.memory === null) {
|
|
2564
|
+
container.appendChild(placeholderFor(state.memory, t("web.settings.memory_unavailable")));
|
|
2565
|
+
return;
|
|
2566
|
+
}
|
|
2567
|
+
const data = state.memory;
|
|
2568
|
+
|
|
2569
|
+
const addRow = el("div", "mem-add");
|
|
2570
|
+
const catSel = el("select");
|
|
2571
|
+
catSel.setAttribute("aria-label", t("web.settings.memory_category"));
|
|
2572
|
+
MEMORY_CATEGORIES.forEach((c) => catSel.appendChild(el("option", "", c)));
|
|
2573
|
+
catSel.value = "facts";
|
|
2574
|
+
addRow.appendChild(catSel);
|
|
2575
|
+
const textIn = el("input");
|
|
2576
|
+
textIn.placeholder = t("web.settings.new_memory");
|
|
2577
|
+
textIn.setAttribute("aria-label", t("web.settings.new_memory"));
|
|
2578
|
+
addRow.appendChild(textIn);
|
|
2579
|
+
addRow.appendChild(
|
|
2580
|
+
link(t("web.settings.add"), "", async () => {
|
|
2581
|
+
const text = textIn.value.trim();
|
|
2582
|
+
if (!text) return;
|
|
2583
|
+
const r = await api("/api/memory", { method: "POST", body: { category: catSel.value, text } });
|
|
2584
|
+
if (r.ok && r.data && r.data.ok) {
|
|
2585
|
+
textIn.value = "";
|
|
2586
|
+
await loadMemory();
|
|
2587
|
+
} else {
|
|
2588
|
+
setIndicator(t("web.settings.add_failed"), true);
|
|
2589
|
+
}
|
|
2590
|
+
}),
|
|
2591
|
+
);
|
|
2592
|
+
container.appendChild(addRow);
|
|
2593
|
+
|
|
2594
|
+
const search = el("input", "searchbox");
|
|
2595
|
+
search.placeholder = t("web.settings.search_memory");
|
|
2596
|
+
search.setAttribute("aria-label", t("web.settings.search_memory"));
|
|
2597
|
+
container.appendChild(search);
|
|
2598
|
+
const list = el("div", "settings-list");
|
|
2599
|
+
container.appendChild(list);
|
|
2600
|
+
|
|
2601
|
+
const categoryHeader = (label, count) => {
|
|
2602
|
+
const head = el("div", "mem-cat");
|
|
2603
|
+
head.appendChild(el("span", "", label));
|
|
2604
|
+
if (count != null) head.appendChild(el("span", "n", String(count)));
|
|
2605
|
+
return head;
|
|
2606
|
+
};
|
|
2607
|
+
|
|
2608
|
+
const entryBlock = (text, onForget) => {
|
|
2609
|
+
const block = el("div", "mem-entry");
|
|
2610
|
+
const body = el("span", "mem-text");
|
|
2611
|
+
body.appendChild(inlineTextNodes(text));
|
|
2612
|
+
body.title = String(text == null ? "" : text);
|
|
2613
|
+
block.appendChild(body);
|
|
2614
|
+
if (onForget) block.appendChild(link(t("web.settings.forget"), "mem-del", onForget));
|
|
2615
|
+
return block;
|
|
2616
|
+
};
|
|
2617
|
+
|
|
2618
|
+
const draw = () => {
|
|
2619
|
+
list.textContent = "";
|
|
2620
|
+
const q = search.value.trim().toLowerCase();
|
|
2621
|
+
let any = false;
|
|
2622
|
+
(data.categories || []).forEach((cat) => {
|
|
2623
|
+
const entries = (cat.entries || []).filter(
|
|
2624
|
+
(e) => !q || String(e.text).toLowerCase().indexOf(q) !== -1,
|
|
2625
|
+
);
|
|
2626
|
+
if (!entries.length) return;
|
|
2627
|
+
any = true;
|
|
2628
|
+
list.appendChild(categoryHeader(cat.name, entries.length));
|
|
2629
|
+
entries.forEach((e) => {
|
|
2630
|
+
list.appendChild(
|
|
2631
|
+
entryBlock(e.text, async () => {
|
|
2632
|
+
const r = await api(
|
|
2633
|
+
"/api/memory/" + encodeURIComponent(cat.name) + "/" + e.n,
|
|
2634
|
+
{ method: "DELETE" },
|
|
2635
|
+
);
|
|
2636
|
+
if (r.ok) await loadMemory();
|
|
2637
|
+
else setIndicator(t("web.settings.forget_failed"), true);
|
|
2638
|
+
}),
|
|
2639
|
+
);
|
|
2640
|
+
});
|
|
2641
|
+
});
|
|
2642
|
+
const prefs = Object.entries(data.preferences || {});
|
|
2643
|
+
if (prefs.length && !q) {
|
|
2644
|
+
any = true;
|
|
2645
|
+
list.appendChild(categoryHeader(t("web.settings.preferences"), prefs.length));
|
|
2646
|
+
prefs.forEach(([k, v]) => {
|
|
2647
|
+
list.appendChild(entryBlock(k + " = " + v));
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
if (!any) {
|
|
2651
|
+
const empty = el("div", "placeholder");
|
|
2652
|
+
empty.textContent = q ? t("web.settings.no_matches") : t("web.settings.no_memory");
|
|
2653
|
+
list.appendChild(empty);
|
|
2654
|
+
}
|
|
2655
|
+
};
|
|
2656
|
+
search.addEventListener("input", draw);
|
|
2657
|
+
draw();
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
function renderSkillsPanel() {
|
|
2661
|
+
const container = document.querySelector('[data-panel="skills"]');
|
|
2662
|
+
if (!container) return;
|
|
2663
|
+
container.textContent = "";
|
|
2664
|
+
if (state.skills === undefined || state.skills === null) {
|
|
2665
|
+
container.appendChild(placeholderFor(state.skills, t("web.settings.skills_unavailable")));
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
if (!state.skills.length) {
|
|
2669
|
+
const empty = el("div", "placeholder");
|
|
2670
|
+
empty.textContent = t("web.settings.no_skills");
|
|
2671
|
+
container.appendChild(empty);
|
|
2672
|
+
return;
|
|
2673
|
+
}
|
|
2674
|
+
const list = el("div", "settings-list");
|
|
2675
|
+
state.skills.forEach((s) => {
|
|
2676
|
+
const item = el("div", "skill-item");
|
|
2677
|
+
const head = el("div", "skill-head");
|
|
2678
|
+
head.appendChild(el("span", s.enabled ? "g" : "dim", s.enabled ? "✓" : "○"));
|
|
2679
|
+
const nameEl = el("span", "name", s.name);
|
|
2680
|
+
nameEl.title = s.name;
|
|
2681
|
+
head.appendChild(nameEl);
|
|
2682
|
+
head.appendChild(el("span", "st", s.enabled ? t("web.settings.enabled") : t("web.settings.available")));
|
|
2683
|
+
item.appendChild(head);
|
|
2684
|
+
if (s.source) {
|
|
2685
|
+
const src = el("div", "skill-src", s.source);
|
|
2686
|
+
src.title = s.source;
|
|
2687
|
+
item.appendChild(src);
|
|
2688
|
+
}
|
|
2689
|
+
list.appendChild(item);
|
|
2690
|
+
});
|
|
2691
|
+
container.appendChild(list);
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
function renderPluginsPanel() {
|
|
2695
|
+
const container = document.querySelector('[data-panel="plugins"]');
|
|
2696
|
+
if (!container) return;
|
|
2697
|
+
container.textContent = "";
|
|
2698
|
+
if (state.pluginsData === undefined || state.pluginsData === null) {
|
|
2699
|
+
container.appendChild(placeholderFor(state.pluginsData, t("web.settings.plugins_unavailable")));
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
const data = state.pluginsData;
|
|
2703
|
+
const list = el("div", "settings-list");
|
|
2704
|
+
list.appendChild(groupHeader(t("web.settings.plugins_header"), (data.plugins || []).length));
|
|
2705
|
+
if (!(data.plugins || []).length) {
|
|
2706
|
+
const empty = el("div", "placeholder");
|
|
2707
|
+
empty.textContent = t("web.settings.no_plugins");
|
|
2708
|
+
list.appendChild(empty);
|
|
2709
|
+
}
|
|
2710
|
+
(data.plugins || []).forEach((p) => {
|
|
2711
|
+
const line = el("div", "list-line");
|
|
2712
|
+
line.appendChild(el("span", "g", "✓"));
|
|
2713
|
+
const nameEl = el("span", "name", p.name);
|
|
2714
|
+
nameEl.title = p.name;
|
|
2715
|
+
line.appendChild(nameEl);
|
|
2716
|
+
if (p.version) line.appendChild(el("span", "src", "v" + p.version));
|
|
2717
|
+
if (p.source) {
|
|
2718
|
+
const src = el("span", "src", p.source);
|
|
2719
|
+
src.title = p.source;
|
|
2720
|
+
line.appendChild(src);
|
|
2721
|
+
}
|
|
2722
|
+
if (p.builtin) line.appendChild(el("span", "src", t("web.settings.builtin")));
|
|
2723
|
+
line.appendChild(el("span", "st", p.enabled === false ? t("web.settings.disabled") : t("web.settings.enabled")));
|
|
2724
|
+
list.appendChild(line);
|
|
2725
|
+
});
|
|
2726
|
+
const pnote = el("div", "settings-note");
|
|
2727
|
+
pnote.textContent = t("web.settings.plugins_note");
|
|
2728
|
+
list.appendChild(pnote);
|
|
2729
|
+
|
|
2730
|
+
list.appendChild(groupHeader(t("web.settings.mcp_servers"), (data.mcp || []).length));
|
|
2731
|
+
if (!(data.mcp || []).length) {
|
|
2732
|
+
const empty = el("div", "placeholder");
|
|
2733
|
+
empty.textContent = t("web.settings.no_mcp");
|
|
2734
|
+
list.appendChild(empty);
|
|
2735
|
+
}
|
|
2736
|
+
(data.mcp || []).forEach((m) => {
|
|
2737
|
+
const line = el("div", "list-line");
|
|
2738
|
+
const on = m.status !== "disabled";
|
|
2739
|
+
const toggle = el("button", "toggle" + (on ? " on" : ""));
|
|
2740
|
+
toggle.type = "button";
|
|
2741
|
+
toggle.setAttribute("role", "switch");
|
|
2742
|
+
toggle.setAttribute("aria-checked", on ? "true" : "false");
|
|
2743
|
+
toggle.title = on ? t("web.settings.mcp_disable") : t("web.settings.mcp_enable");
|
|
2744
|
+
toggle.setAttribute("aria-label", toggle.title);
|
|
2745
|
+
toggle.addEventListener("click", async () => {
|
|
2746
|
+
const next = !toggle.classList.contains("on");
|
|
2747
|
+
const r = await api("/api/mcp/" + encodeURIComponent(m.id || m.name), {
|
|
2748
|
+
method: "POST",
|
|
2749
|
+
body: { enabled: next },
|
|
2750
|
+
});
|
|
2751
|
+
if (r.ok && r.data && r.data.ok) {
|
|
2752
|
+
setIndicator(t("web.settings.mcp_saved"), false);
|
|
2753
|
+
await loadPlugins();
|
|
2754
|
+
} else {
|
|
2755
|
+
setIndicator(t("web.settings.save_failed"), true);
|
|
2756
|
+
}
|
|
2757
|
+
});
|
|
2758
|
+
line.appendChild(toggle);
|
|
2759
|
+
const nameEl = el("span", "name", m.name);
|
|
2760
|
+
nameEl.title = m.name;
|
|
2761
|
+
line.appendChild(nameEl);
|
|
2762
|
+
line.appendChild(el("span", "st", m.status || "—"));
|
|
2763
|
+
list.appendChild(line);
|
|
2764
|
+
});
|
|
2765
|
+
if ((data.mcp || []).length) {
|
|
2766
|
+
const mnote = el("div", "settings-note");
|
|
2767
|
+
mnote.textContent = t("web.settings.mcp_note");
|
|
2768
|
+
list.appendChild(mnote);
|
|
2769
|
+
}
|
|
2770
|
+
container.appendChild(list);
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
async function loadMemory() {
|
|
2774
|
+
const r = await api("/api/memory");
|
|
2775
|
+
state.memory = r.ok && r.data ? r.data : null;
|
|
2776
|
+
renderMemoryPanel();
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
async function loadSkills() {
|
|
2780
|
+
const r = await api("/api/skills");
|
|
2781
|
+
state.skills = r.ok && r.data && Array.isArray(r.data.skills) ? r.data.skills : null;
|
|
2782
|
+
renderSkillsPanel();
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
async function loadPlugins() {
|
|
2786
|
+
const r = await api("/api/plugins");
|
|
2787
|
+
state.pluginsData =
|
|
2788
|
+
r.ok && r.data
|
|
2789
|
+
? { plugins: r.data.plugins || [], mcp: r.data.mcp || [] }
|
|
2790
|
+
: null;
|
|
2791
|
+
renderPluginsPanel();
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
function renderSettings() {
|
|
2795
|
+
renderGenericPanel("general", SECTION_KEYS.general);
|
|
2796
|
+
renderModelPanel();
|
|
2797
|
+
renderGenericPanel("context", SECTION_KEYS.context);
|
|
2798
|
+
renderGenericPanel("reasoning", SECTION_KEYS.reasoning);
|
|
2799
|
+
renderUiPanel();
|
|
2800
|
+
renderMemoryPanel();
|
|
2801
|
+
renderSkillsPanel();
|
|
2802
|
+
renderPluginsPanel();
|
|
2803
|
+
renderSecurityPanel();
|
|
2804
|
+
renderGenericPanel("advanced", SECTION_KEYS.advanced);
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
// Re-apply the dictionary to static chrome after a locale switch. Dynamic
|
|
2808
|
+
// feeds (tool output, model text) are localized server-side and untouched.
|
|
2809
|
+
function applyStaticI18n() {
|
|
2810
|
+
document.documentElement.lang = I18N.locale;
|
|
2811
|
+
const setText = (id, text) => {
|
|
2812
|
+
const node = document.getElementById(id);
|
|
2813
|
+
if (node) node.textContent = text;
|
|
2814
|
+
};
|
|
2815
|
+
setText("planHeaderLabel", t("web.sidebar.plan"));
|
|
2816
|
+
setText("usageHeaderLabel", t("web.sidebar.session") + " · " + t("web.sidebar.usage"));
|
|
2817
|
+
setText("envHeaderLabel", t("web.sidebar.env"));
|
|
2818
|
+
setText("settingsTitle", t("web.settings.title"));
|
|
2819
|
+
setText("sessActiveHeader", t("web.sessions.active"));
|
|
2820
|
+
setText("sessRecentHeader", t("web.sessions.recent"));
|
|
2821
|
+
setText("hintEnter", t("web.input.enter"));
|
|
2822
|
+
setText("hintShift", t("web.input.shift_enter"));
|
|
2823
|
+
setText("hintEsc", t("web.input.esc"));
|
|
2824
|
+
setText("hintAttach", t("web.input.attach"));
|
|
2825
|
+
|
|
2826
|
+
// Chrome controls: Lucide icon + label, plus matching title/aria-label.
|
|
2827
|
+
const controls = {
|
|
2828
|
+
newChat: ["plus", t("web.topbar.new_chat")],
|
|
2829
|
+
sessBtn: ["menu", t("web.topbar.all_sessions")],
|
|
2830
|
+
openSettings: ["settings", t("web.topbar.settings")],
|
|
2831
|
+
attachBtn: ["image", t("web.attach.title")],
|
|
2832
|
+
closeSettings: ["x", t("web.topbar.close")],
|
|
2833
|
+
sessNew: ["plus", t("web.sessions.new")],
|
|
2834
|
+
sessRefresh: ["refresh", t("web.sessions.refresh")],
|
|
2835
|
+
};
|
|
2836
|
+
Object.keys(controls).forEach((id) => {
|
|
2837
|
+
const node = document.getElementById(id);
|
|
2838
|
+
if (!node) return;
|
|
2839
|
+
const [name, label] = controls[id];
|
|
2840
|
+
// Text-bearing buttons keep their label; icon-only ones don't add one.
|
|
2841
|
+
const withLabel = id === "closeSettings" || id === "sessNew" || id === "sessRefresh";
|
|
2842
|
+
setIcon(node, name, withLabel ? label : null);
|
|
2843
|
+
node.title = label;
|
|
2844
|
+
node.setAttribute("aria-label", label);
|
|
2845
|
+
});
|
|
2846
|
+
const settingsIcon = document.getElementById("settingsIcon");
|
|
2847
|
+
if (settingsIcon) setIcon(settingsIcon, "settings", null);
|
|
2848
|
+
if (inp) {
|
|
2849
|
+
inp.placeholder = t("web.input.placeholder");
|
|
2850
|
+
inp.setAttribute("aria-label", t("web.input.placeholder"));
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
document.querySelectorAll("#setMenu .mi").forEach((mi) => {
|
|
2854
|
+
const p = mi.dataset.p;
|
|
2855
|
+
if (p) mi.textContent = t("web.settings." + p);
|
|
2856
|
+
});
|
|
2857
|
+
document.querySelectorAll(".content-col .panel").forEach((panel) => {
|
|
2858
|
+
const h3 = panel.querySelector("h3");
|
|
2859
|
+
const name = panel.id.replace(/^p-/, "");
|
|
2860
|
+
if (h3 && name) h3.textContent = t("web.settings." + name);
|
|
2861
|
+
});
|
|
2862
|
+
|
|
2863
|
+
renderSelectors();
|
|
2864
|
+
renderSessionsDropdown();
|
|
2865
|
+
if (msgs.querySelector(".empty")) showEmptyFeed();
|
|
2866
|
+
if (connStatus && !connStatus.hidden) setConnStatus(true);
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
const overlay = document.getElementById("overlay");
|
|
2870
|
+
const settingsBtn = document.getElementById("openSettings");
|
|
2871
|
+
const closeSettingsBtn = document.getElementById("closeSettings");
|
|
2872
|
+
let settingsReturnFocus = null;
|
|
2873
|
+
|
|
2874
|
+
// Settings sections act as a real tablist: roles, roving tabindex, arrow keys.
|
|
2875
|
+
const settingsTabs = Array.from(document.querySelectorAll("#setMenu .mi"));
|
|
2876
|
+
function selectSettingsPanel(name) {
|
|
2877
|
+
settingsTabs.forEach((x) => {
|
|
2878
|
+
const on = x.dataset.p === name;
|
|
2879
|
+
x.classList.toggle("on", on);
|
|
2880
|
+
x.setAttribute("aria-selected", on ? "true" : "false");
|
|
2881
|
+
x.tabIndex = on ? 0 : -1;
|
|
2882
|
+
});
|
|
2883
|
+
document.querySelectorAll(".content-col .panel").forEach((x) => x.classList.remove("on"));
|
|
2884
|
+
const panel = document.getElementById("p-" + name);
|
|
2885
|
+
if (panel) panel.classList.add("on");
|
|
2886
|
+
}
|
|
2887
|
+
settingsTabs.forEach((mi) => {
|
|
2888
|
+
const name = mi.dataset.p;
|
|
2889
|
+
mi.setAttribute("role", "tab");
|
|
2890
|
+
mi.setAttribute("id", "tab-" + name);
|
|
2891
|
+
mi.setAttribute("aria-controls", "p-" + name);
|
|
2892
|
+
mi.setAttribute("aria-selected", mi.classList.contains("on") ? "true" : "false");
|
|
2893
|
+
mi.tabIndex = mi.classList.contains("on") ? 0 : -1;
|
|
2894
|
+
mi.addEventListener("click", () => selectSettingsPanel(name));
|
|
2895
|
+
mi.addEventListener("keydown", (e) => {
|
|
2896
|
+
const i = settingsTabs.indexOf(mi);
|
|
2897
|
+
let next = null;
|
|
2898
|
+
if (e.key === "ArrowDown" || e.key === "ArrowRight") next = settingsTabs[(i + 1) % settingsTabs.length];
|
|
2899
|
+
else if (e.key === "ArrowUp" || e.key === "ArrowLeft") next = settingsTabs[(i - 1 + settingsTabs.length) % settingsTabs.length];
|
|
2900
|
+
else if (e.key === "Home") next = settingsTabs[0];
|
|
2901
|
+
else if (e.key === "End") next = settingsTabs[settingsTabs.length - 1];
|
|
2902
|
+
if (next) {
|
|
2903
|
+
e.preventDefault();
|
|
2904
|
+
selectSettingsPanel(next.dataset.p);
|
|
2905
|
+
next.focus();
|
|
2906
|
+
}
|
|
2907
|
+
});
|
|
2908
|
+
});
|
|
2909
|
+
document.querySelectorAll(".content-col .panel").forEach((panel) => {
|
|
2910
|
+
panel.setAttribute("role", "tabpanel");
|
|
2911
|
+
panel.setAttribute("aria-labelledby", "tab-" + panel.id.replace(/^p-/, ""));
|
|
2912
|
+
});
|
|
2913
|
+
|
|
2914
|
+
function openSettings() {
|
|
2915
|
+
settingsReturnFocus = document.activeElement;
|
|
2916
|
+
overlay.classList.add("open");
|
|
2917
|
+
settingsBtn.setAttribute("aria-expanded", "true");
|
|
2918
|
+
void loadMemory();
|
|
2919
|
+
void loadSkills();
|
|
2920
|
+
void loadPlugins();
|
|
2921
|
+
const active = document.querySelector("#setMenu .mi.on") || settingsTabs[0];
|
|
2922
|
+
if (active) setTimeout(() => active.focus(), 0);
|
|
2923
|
+
}
|
|
2924
|
+
function closeSettings() {
|
|
2925
|
+
overlay.classList.remove("open");
|
|
2926
|
+
settingsBtn.setAttribute("aria-expanded", "false");
|
|
2927
|
+
if (settingsReturnFocus && settingsReturnFocus.focus) settingsReturnFocus.focus();
|
|
2928
|
+
settingsReturnFocus = null;
|
|
2929
|
+
}
|
|
2930
|
+
settingsBtn.addEventListener("click", openSettings);
|
|
2931
|
+
closeSettingsBtn.addEventListener("click", closeSettings);
|
|
2932
|
+
overlay.addEventListener("click", (e) => {
|
|
2933
|
+
if (e.target === overlay) closeSettings();
|
|
2934
|
+
});
|
|
2935
|
+
// Trap Tab inside the dialog while it is open.
|
|
2936
|
+
overlay.addEventListener("keydown", (e) => {
|
|
2937
|
+
if (e.key !== "Tab") return;
|
|
2938
|
+
const items = Array.from(
|
|
2939
|
+
overlay.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'),
|
|
2940
|
+
).filter((node) => node.offsetParent !== null);
|
|
2941
|
+
if (!items.length) return;
|
|
2942
|
+
const first = items[0];
|
|
2943
|
+
const last = items[items.length - 1];
|
|
2944
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
2945
|
+
e.preventDefault();
|
|
2946
|
+
last.focus();
|
|
2947
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
2948
|
+
e.preventDefault();
|
|
2949
|
+
first.focus();
|
|
2950
|
+
}
|
|
2951
|
+
});
|
|
2952
|
+
document.addEventListener("keydown", (e) => {
|
|
2953
|
+
if (e.key === "Escape" && overlay.classList.contains("open")) closeSettings();
|
|
2954
|
+
});
|
|
2955
|
+
|
|
2956
|
+
// --- generic confirmation dialog (alertdialog) -------------------------
|
|
2957
|
+
const confirmOverlay = document.getElementById("confirmOverlay");
|
|
2958
|
+
const confirmTitle = document.getElementById("confirmTitle");
|
|
2959
|
+
const confirmMsg = document.getElementById("confirmMsg");
|
|
2960
|
+
const confirmAccept = document.getElementById("confirmAccept");
|
|
2961
|
+
const confirmCancel = document.getElementById("confirmCancel");
|
|
2962
|
+
let confirmOnAccept = null;
|
|
2963
|
+
let confirmReturnFocus = null;
|
|
2964
|
+
|
|
2965
|
+
function openConfirm(opts) {
|
|
2966
|
+
if (!confirmOverlay) return;
|
|
2967
|
+
confirmReturnFocus = document.activeElement;
|
|
2968
|
+
confirmTitle.textContent = opts.title || "";
|
|
2969
|
+
confirmMsg.textContent = opts.body || "";
|
|
2970
|
+
confirmAccept.textContent = opts.acceptLabel || t("web.sessions.delete");
|
|
2971
|
+
confirmCancel.textContent = opts.cancelLabel || t("web.action.cancel");
|
|
2972
|
+
confirmOnAccept = opts.onAccept || null;
|
|
2973
|
+
confirmOverlay.classList.add("open");
|
|
2974
|
+
setTimeout(() => confirmCancel.focus(), 0);
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
function closeConfirm() {
|
|
2978
|
+
if (!confirmOverlay || !confirmOverlay.classList.contains("open")) return;
|
|
2979
|
+
confirmOverlay.classList.remove("open");
|
|
2980
|
+
confirmOnAccept = null;
|
|
2981
|
+
if (confirmReturnFocus && confirmReturnFocus.focus) confirmReturnFocus.focus();
|
|
2982
|
+
confirmReturnFocus = null;
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
if (confirmOverlay) {
|
|
2986
|
+
confirmCancel.addEventListener("click", closeConfirm);
|
|
2987
|
+
confirmAccept.addEventListener("click", () => {
|
|
2988
|
+
const fn = confirmOnAccept;
|
|
2989
|
+
closeConfirm();
|
|
2990
|
+
if (fn) fn();
|
|
2991
|
+
});
|
|
2992
|
+
confirmOverlay.addEventListener("click", (e) => {
|
|
2993
|
+
// Keep clicks inside the dialog from reaching the global handler, so the
|
|
2994
|
+
// sessions dropdown stays open behind it.
|
|
2995
|
+
e.stopPropagation();
|
|
2996
|
+
if (e.target === confirmOverlay) closeConfirm();
|
|
2997
|
+
});
|
|
2998
|
+
// Keep Tab between the two actions while the dialog is open.
|
|
2999
|
+
confirmOverlay.addEventListener("keydown", (e) => {
|
|
3000
|
+
if (e.key !== "Tab") return;
|
|
3001
|
+
e.preventDefault();
|
|
3002
|
+
(document.activeElement === confirmAccept ? confirmCancel : confirmAccept).focus();
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
document.addEventListener("keydown", (e) => {
|
|
3007
|
+
if (e.key !== "Escape") return;
|
|
3008
|
+
if (confirmOverlay && confirmOverlay.classList.contains("open")) closeConfirm();
|
|
3009
|
+
else if (overlay.classList.contains("open")) closeSettings();
|
|
3010
|
+
});
|
|
3011
|
+
|
|
3012
|
+
function currentTheme() {
|
|
3013
|
+
return localStorage.getItem(THEME_KEY) || "auto";
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
function applyTheme(theme) {
|
|
3017
|
+
localStorage.setItem(THEME_KEY, theme);
|
|
3018
|
+
const light = theme === "light" || (theme === "auto" && window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches);
|
|
3019
|
+
document.body.classList.toggle("light", !!light);
|
|
3020
|
+
renderUiPanel();
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
if (window.matchMedia) {
|
|
3024
|
+
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => {
|
|
3025
|
+
if (currentTheme() === "auto") applyTheme("auto");
|
|
3026
|
+
});
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
async function boot() {
|
|
3030
|
+
await loadI18n();
|
|
3031
|
+
await loadCommands();
|
|
3032
|
+
applyTheme(currentTheme());
|
|
3033
|
+
applyStaticI18n();
|
|
3034
|
+
showEmptyFeed();
|
|
3035
|
+
renderSelectors();
|
|
3036
|
+
renderSettings();
|
|
3037
|
+
await loadConfig();
|
|
3038
|
+
await loadModels();
|
|
3039
|
+
await loadProviders();
|
|
3040
|
+
await loadSessions();
|
|
3041
|
+
await loadMemory();
|
|
3042
|
+
await loadSkills();
|
|
3043
|
+
await loadPlugins();
|
|
3044
|
+
await refresh();
|
|
3045
|
+
connectSSE();
|
|
3046
|
+
setInterval(refresh, 2000);
|
|
3047
|
+
setInterval(loadSessions, 15000);
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
boot();
|
|
3051
|
+
})();
|