micro-models-agent 2.5.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- package/README.md +88 -38
- package/bin/mma.mjs +41 -41
- package/dist/assets/app.js +246 -3086
- package/dist/assets/hljs.js +1244 -0
- package/dist/assets/index.html +15 -1
- package/dist/assets/js/api.js +28 -0
- package/dist/assets/js/dialog.js +105 -0
- package/dist/assets/js/dom.js +157 -0
- package/dist/assets/js/feed.js +995 -0
- package/dist/assets/js/format.js +114 -0
- package/dist/assets/js/i18n.js +46 -0
- package/dist/assets/js/settings.js +1126 -0
- package/dist/assets/js/store.js +79 -0
- package/dist/assets/js/tabs.js +672 -0
- package/dist/assets/md.js +277 -0
- package/dist/assets/setup.js +557 -0
- package/dist/assets/smd.js +1665 -0
- package/dist/assets/styles.css +248 -16
- package/dist/i18n/en.json +163 -2
- package/dist/i18n/ru.json +163 -2
- package/dist/main.js +4835 -2618
- package/dist/modules/browser/bridge-server.mjs +3 -3
- package/package.json +10 -2
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
// Feed, turn-stream handlers, history, question UI and sidebar renders.
|
|
2
|
+
// All SSE `on*` handlers live here; `app.js` only wires them to EventSource.
|
|
3
|
+
import { mountMarkdown, renderMarkdown, copyText } from "../md.js";
|
|
4
|
+
import {
|
|
5
|
+
msgs, planEl, planCountEl, usageEl, envEl, wsLine,
|
|
6
|
+
el, icon, setIcon, link, announce,
|
|
7
|
+
} from "./dom.js";
|
|
8
|
+
import { api } from "./api.js";
|
|
9
|
+
import { I18N, t, pluralCount } from "./i18n.js";
|
|
10
|
+
import { S } from "./store.js";
|
|
11
|
+
import {
|
|
12
|
+
FILE_TOOLS, OPENABLE_TOOLS, IMAGE_TOOLS, LINK_TOOLS,
|
|
13
|
+
categoryOf, labelOf, argText, formatMs, verbosity, firstOutputLine, fmtTokens,
|
|
14
|
+
} from "./format.js";
|
|
15
|
+
|
|
16
|
+
function insertAfter(ref, node) {
|
|
17
|
+
if (ref && ref.parentNode) ref.parentNode.insertBefore(node, ref.nextSibling);
|
|
18
|
+
else appendTo(liveFeed(), node);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
function emptyView() {
|
|
23
|
+
const wrap = el("div", "empty");
|
|
24
|
+
wrap.appendChild(el("span", "g", "✦"));
|
|
25
|
+
wrap.appendChild(document.createTextNode(" " + t("web.empty.title")));
|
|
26
|
+
wrap.appendChild(document.createElement("br"));
|
|
27
|
+
wrap.appendChild(el("span", "hint", t("web.empty.hint")));
|
|
28
|
+
return wrap;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function makeFeed() {
|
|
32
|
+
const feed = el("div", "feed");
|
|
33
|
+
feed.appendChild(emptyView());
|
|
34
|
+
return feed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function showEmptyFeed() {
|
|
38
|
+
msgs.replaceChildren(emptyView());
|
|
39
|
+
syncUrl();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function clearFeed(feed) {
|
|
43
|
+
if (!feed) return;
|
|
44
|
+
feed.replaceChildren(emptyView());
|
|
45
|
+
const tab = S.tabs.find((x) => x.feed === feed);
|
|
46
|
+
if (tab) tab.planCard = null;
|
|
47
|
+
if (feed === activeFeed()) scroll();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function activeFeed() {
|
|
51
|
+
const tab = S.tabs.find((x) => x.id === S.activeTabId);
|
|
52
|
+
return tab ? tab.feed : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function liveFeed() {
|
|
56
|
+
const tab = S.tabs.find((x) => x.id === S.liveTabId) || S.tabs.find((x) => x.id === S.activeTabId);
|
|
57
|
+
return tab ? tab.feed : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Route a server event to the tab that owns its session (item from `broadcast`
|
|
61
|
+
// enrichment); fall back to the live/active tab for events without one.
|
|
62
|
+
function feedForEvent(sessionId) {
|
|
63
|
+
if (sessionId) {
|
|
64
|
+
const tab = S.tabs.find((x) => x.id === sessionId);
|
|
65
|
+
if (tab) return tab.feed;
|
|
66
|
+
}
|
|
67
|
+
return liveFeed();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function nearBottom() {
|
|
71
|
+
return msgs.scrollHeight - msgs.scrollTop - msgs.clientHeight < 140;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function scroll() {
|
|
75
|
+
if (nearBottom()) msgs.scrollTop = msgs.scrollHeight;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function appendTo(feed, node) {
|
|
79
|
+
if (!feed) return node;
|
|
80
|
+
feed.appendChild(node);
|
|
81
|
+
if (feed === activeFeed()) scroll();
|
|
82
|
+
return node;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function append(node) {
|
|
86
|
+
return appendTo(liveFeed(), node);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function clearEmpty(feed) {
|
|
90
|
+
const empty = feed && feed.querySelector(".empty");
|
|
91
|
+
if (empty) empty.remove();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function clearBusy(feed) {
|
|
95
|
+
if (S.busyTimer) {
|
|
96
|
+
clearInterval(S.busyTimer);
|
|
97
|
+
S.busyTimer = null;
|
|
98
|
+
}
|
|
99
|
+
feed = feed || liveFeed();
|
|
100
|
+
if (!feed) return;
|
|
101
|
+
const line = feed.querySelector(".busy");
|
|
102
|
+
if (line) line.remove();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Show the in-feed "thinking" line. Called immediately after a successful
|
|
106
|
+
// `/api/message` POST (202) so the wait during a slow first-run
|
|
107
|
+
// summarization is visible before the first `phase` event arrives; the
|
|
108
|
+
// normal event flow (`onText`/`onPhase`/`onTurnEnd`) clears it.
|
|
109
|
+
export function showBusy(feed) {
|
|
110
|
+
feed = feed || liveFeed();
|
|
111
|
+
if (!feed) return;
|
|
112
|
+
clearEmpty(feed);
|
|
113
|
+
if (feed.querySelector(".busy")) return;
|
|
114
|
+
if (S.busyTimer) {
|
|
115
|
+
clearInterval(S.busyTimer);
|
|
116
|
+
S.busyTimer = null;
|
|
117
|
+
}
|
|
118
|
+
const line = el("div", "busy");
|
|
119
|
+
line.appendChild(el("span", "spin", "✦"));
|
|
120
|
+
line.appendChild(el("span", "lab", t("web.action.thinking")));
|
|
121
|
+
const time = el("span", "busytime", "· 0s");
|
|
122
|
+
line.appendChild(time);
|
|
123
|
+
line.appendChild(el("span", "", "· " + t("web.input.esc")));
|
|
124
|
+
appendTo(feed, line);
|
|
125
|
+
const started = Date.now();
|
|
126
|
+
S.busyTimer = setInterval(() => {
|
|
127
|
+
if (!line.parentNode) {
|
|
128
|
+
clearInterval(S.busyTimer);
|
|
129
|
+
S.busyTimer = null;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
time.textContent = "· " + Math.round((Date.now() - started) / 1000) + "s";
|
|
133
|
+
}, 1000);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function flushRun() {
|
|
137
|
+
if (S.runCount <= 0) {
|
|
138
|
+
resetRun();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// Quiet: a run of successful read-only actions folds into one summary line,
|
|
142
|
+
// replacing the per-action lines (mirrors `Renderer.flushRecon`). `normal`
|
|
143
|
+
// and `verbose` keep the individual action lines and add nothing.
|
|
144
|
+
if (verbosity() === "quiet" && S.runReconOnly && S.runFails === 0 && S.runCount > 1) {
|
|
145
|
+
S.runNodes.forEach((node) => node.remove());
|
|
146
|
+
const line = el("div", "grouped");
|
|
147
|
+
line.appendChild(el("span", "pipe", "│"));
|
|
148
|
+
const dur = formatMs(S.runDuration);
|
|
149
|
+
line.appendChild(
|
|
150
|
+
document.createTextNode(
|
|
151
|
+
" " + pluralCount("web.action.actions", S.runCount) + " ✓" + (dur ? " · " + dur : ""),
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
appendTo(liveFeed(), line);
|
|
155
|
+
}
|
|
156
|
+
resetRun();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function resetRun() {
|
|
160
|
+
S.runCount = 0;
|
|
161
|
+
S.runFails = 0;
|
|
162
|
+
S.runReconOnly = true;
|
|
163
|
+
S.runNodes = [];
|
|
164
|
+
S.runDuration = 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Options passed to the streaming Markdown renderer: localized code-block
|
|
168
|
+
// copy labels. Highlighting is applied by `md.js` once a fence closes.
|
|
169
|
+
function mdOpts() {
|
|
170
|
+
return {
|
|
171
|
+
copyLabel: t("web.code.copy"),
|
|
172
|
+
copiedLabel: t("web.code.copied"),
|
|
173
|
+
copyFailedLabel: t("web.code.copy_failed"),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Close the active Markdown segment (if any) and clear the text S.state.
|
|
178
|
+
export function finishMd() {
|
|
179
|
+
if (S.curMd) {
|
|
180
|
+
S.curMd.end();
|
|
181
|
+
S.curMd = null;
|
|
182
|
+
}
|
|
183
|
+
S.curText = null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function onText(chunk, sid) {
|
|
187
|
+
const feed = feedForEvent(sid);
|
|
188
|
+
clearBusy(feed);
|
|
189
|
+
if (!feed) return;
|
|
190
|
+
clearEmpty(feed);
|
|
191
|
+
if (S.curThink) {
|
|
192
|
+
finishThink();
|
|
193
|
+
}
|
|
194
|
+
if (S.curText == null) {
|
|
195
|
+
flushRun();
|
|
196
|
+
S.curText = "";
|
|
197
|
+
S.curMd = mountMarkdown(feed, mdOpts());
|
|
198
|
+
S.turnRoots.push(S.curMd.root);
|
|
199
|
+
}
|
|
200
|
+
S.curText += chunk;
|
|
201
|
+
S.turnMd += chunk;
|
|
202
|
+
S.curMd.write(chunk);
|
|
203
|
+
if (feed === activeFeed()) scroll();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function finishThink() {
|
|
207
|
+
if (S.curThink && S.curThink.startedAt) {
|
|
208
|
+
const dur = S.curThink.head.querySelector(".dur");
|
|
209
|
+
if (dur) dur.textContent = "· " + ((Date.now() - S.curThink.startedAt) / 1000).toFixed(1) + "s";
|
|
210
|
+
}
|
|
211
|
+
S.curThink = null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Build the collapsible reasoning block. The global `.think-h` click handler
|
|
215
|
+
// depends on this exact structure: wrap > [head, body], body carries `hidden`.
|
|
216
|
+
function buildThinkBlock(withDur) {
|
|
217
|
+
const wrap = el("div", "think");
|
|
218
|
+
const head = el("button", "think-h");
|
|
219
|
+
head.type = "button";
|
|
220
|
+
head.setAttribute("aria-expanded", "false");
|
|
221
|
+
head.appendChild(el("span", "g", "✦"));
|
|
222
|
+
head.appendChild(el("span", "lab", t("web.action.thinking")));
|
|
223
|
+
if (withDur) head.appendChild(el("span", "dur", ""));
|
|
224
|
+
const tog = el("span", "tog");
|
|
225
|
+
setIcon(tog, "chevron-right", t("web.action.show"));
|
|
226
|
+
head.appendChild(tog);
|
|
227
|
+
const body = el("div", "think-body hidden");
|
|
228
|
+
wrap.appendChild(head);
|
|
229
|
+
wrap.appendChild(body);
|
|
230
|
+
return { wrap, head, body };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function onReasoning(chunk, sid) {
|
|
234
|
+
const feed = feedForEvent(sid);
|
|
235
|
+
clearBusy(feed);
|
|
236
|
+
if (!feed) return;
|
|
237
|
+
clearEmpty(feed);
|
|
238
|
+
finishMd();
|
|
239
|
+
if (!S.curThink) {
|
|
240
|
+
flushRun();
|
|
241
|
+
const { wrap, head, body } = buildThinkBlock(true);
|
|
242
|
+
appendTo(feed, wrap);
|
|
243
|
+
S.curThink = { wrap, head, body, startedAt: Date.now() };
|
|
244
|
+
}
|
|
245
|
+
S.curThink.body.textContent += chunk;
|
|
246
|
+
if (feed === activeFeed()) scroll();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Generic agent status (context stats, compaction, MoE status, warnings):
|
|
250
|
+
// always visible, one dim line per chunk, never collapsed.
|
|
251
|
+
export function onMeta(chunk, sid) {
|
|
252
|
+
const feed = feedForEvent(sid);
|
|
253
|
+
clearBusy(feed);
|
|
254
|
+
const text = (chunk || "").trim();
|
|
255
|
+
if (!feed || !text) return;
|
|
256
|
+
clearEmpty(feed);
|
|
257
|
+
finishMd();
|
|
258
|
+
const warn = text.indexOf("⚠") !== -1 || text.indexOf("✗") !== -1;
|
|
259
|
+
appendTo(feed, el("div", warn ? "metaln warn" : "metaln", text));
|
|
260
|
+
if (feed === activeFeed()) scroll();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function onPhase(phase, sid) {
|
|
264
|
+
const feed = feedForEvent(sid);
|
|
265
|
+
if (phase === "thinking") {
|
|
266
|
+
showBusy(feed);
|
|
267
|
+
} else {
|
|
268
|
+
clearBusy(feed);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function onToolStart(data) {
|
|
273
|
+
const feed = feedForEvent(data.sessionId);
|
|
274
|
+
clearBusy(feed);
|
|
275
|
+
if (!feed) return;
|
|
276
|
+
clearEmpty(feed);
|
|
277
|
+
finishMd();
|
|
278
|
+
finishThink();
|
|
279
|
+
S.pendingArgs.set(data.toolCallId, data.args || {});
|
|
280
|
+
const line = el("div", "act " + categoryOf(data.name));
|
|
281
|
+
line.appendChild(el("span", "g", "◇"));
|
|
282
|
+
line.appendChild(el("span", "lab", labelOf(data.name)));
|
|
283
|
+
const args = argText(data.args);
|
|
284
|
+
if (args) {
|
|
285
|
+
line.appendChild(el("span", "sep", "·"));
|
|
286
|
+
line.appendChild(el("span", "args", args));
|
|
287
|
+
}
|
|
288
|
+
const status = el("span", "ok", "");
|
|
289
|
+
line.appendChild(status);
|
|
290
|
+
appendTo(feed, line);
|
|
291
|
+
S.toolNodes.set(data.toolCallId, { status, line });
|
|
292
|
+
if (categoryOf(data.name) !== "recon") S.runReconOnly = false;
|
|
293
|
+
S.runCount++;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function renderToolOutput(line, text) {
|
|
297
|
+
if (typeof text !== "string" || !text) return;
|
|
298
|
+
const wrap = el("div", "toolout");
|
|
299
|
+
const code = el("div", "code");
|
|
300
|
+
code.textContent = text;
|
|
301
|
+
wrap.appendChild(code);
|
|
302
|
+
const long = text.length > 600 || text.split("\n").length > 12;
|
|
303
|
+
if (long) {
|
|
304
|
+
wrap.classList.add("collapsed");
|
|
305
|
+
const outLabel = "▸ " + pluralCount("web.tool.output", text.length);
|
|
306
|
+
const more = el("span", "more", outLabel);
|
|
307
|
+
more.addEventListener("click", () => {
|
|
308
|
+
const collapsed = wrap.classList.toggle("collapsed");
|
|
309
|
+
more.textContent = collapsed ? outLabel : "▾ " + t("web.tool.collapse");
|
|
310
|
+
});
|
|
311
|
+
wrap.appendChild(more);
|
|
312
|
+
}
|
|
313
|
+
const feed = line && line.parentNode ? null : liveFeed();
|
|
314
|
+
if (line && line.parentNode) line.parentNode.insertBefore(wrap, line.nextSibling);
|
|
315
|
+
else appendTo(feed, wrap);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function safeUrl(url) {
|
|
319
|
+
if (typeof url !== "string" || !url) return null;
|
|
320
|
+
try {
|
|
321
|
+
const u = new URL(url, location.href);
|
|
322
|
+
if (u.protocol === "http:" || u.protocol === "https:" || u.protocol === "file:") {
|
|
323
|
+
return u.href;
|
|
324
|
+
}
|
|
325
|
+
} catch (err) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function cardFor(name, args, sid) {
|
|
332
|
+
const feed = feedForEvent(sid);
|
|
333
|
+
if (!feed) return null;
|
|
334
|
+
if (FILE_TOOLS.indexOf(name) !== -1) {
|
|
335
|
+
const path = args.path || args.file || args.dir;
|
|
336
|
+
if (!path) return null;
|
|
337
|
+
const card = el("div", "filecard");
|
|
338
|
+
const top = el("div", "fc-top");
|
|
339
|
+
top.appendChild(el("span", "g", "◇"));
|
|
340
|
+
const nameEl = el("span", "name", path);
|
|
341
|
+
nameEl.title = path;
|
|
342
|
+
top.appendChild(nameEl);
|
|
343
|
+
card.appendChild(top);
|
|
344
|
+
const bottom = el("div", "fc-bottom");
|
|
345
|
+
bottom.appendChild(el("span", "lab", labelOf(name)));
|
|
346
|
+
if (OPENABLE_TOOLS.indexOf(name) !== -1) {
|
|
347
|
+
const openLink = el("a", "fc-open", t("web.card.open"));
|
|
348
|
+
openLink.href = "/api/file?path=" + encodeURIComponent(path);
|
|
349
|
+
openLink.target = "_blank";
|
|
350
|
+
openLink.rel = "noopener noreferrer";
|
|
351
|
+
bottom.appendChild(openLink);
|
|
352
|
+
}
|
|
353
|
+
card.appendChild(bottom);
|
|
354
|
+
appendTo(feed, card);
|
|
355
|
+
return card;
|
|
356
|
+
}
|
|
357
|
+
if (LINK_TOOLS.indexOf(name) !== -1 && args.url) {
|
|
358
|
+
const line = el("div", "linkline");
|
|
359
|
+
line.appendChild(document.createTextNode("↳ "));
|
|
360
|
+
const safe = safeUrl(args.url);
|
|
361
|
+
if (safe) {
|
|
362
|
+
const a = el("a", "", args.url);
|
|
363
|
+
a.href = safe;
|
|
364
|
+
a.target = "_blank";
|
|
365
|
+
a.rel = "noopener noreferrer";
|
|
366
|
+
line.appendChild(a);
|
|
367
|
+
try {
|
|
368
|
+
const host = new URL(safe).hostname;
|
|
369
|
+
if (host) line.appendChild(el("span", "dom", " · " + host));
|
|
370
|
+
} catch (err) {
|
|
371
|
+
/* no hostname */
|
|
372
|
+
}
|
|
373
|
+
} else {
|
|
374
|
+
line.appendChild(el("span", "", args.url));
|
|
375
|
+
}
|
|
376
|
+
appendTo(feed, line);
|
|
377
|
+
return line;
|
|
378
|
+
}
|
|
379
|
+
if (IMAGE_TOOLS.indexOf(name) !== -1) {
|
|
380
|
+
const card = el("div", "imgcard");
|
|
381
|
+
card.appendChild(el("div", "ph", "◧ " + t("web.card.screenshot")));
|
|
382
|
+
card.appendChild(el("div", "cap", name === "attach_image" ? t("web.card.attached_image") : t("web.card.browser_viewport")));
|
|
383
|
+
appendTo(feed, card);
|
|
384
|
+
return card;
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function onToolEnd(data) {
|
|
390
|
+
const node = S.toolNodes.get(data.toolCallId);
|
|
391
|
+
const line = node ? node.line : null;
|
|
392
|
+
if (node) {
|
|
393
|
+
const dur = formatMs(data.durationMs);
|
|
394
|
+
node.status.textContent = data.success ? " ✓" + (dur ? " " + dur : "") : " ✗" + (dur ? " " + dur : "");
|
|
395
|
+
if (!data.success) {
|
|
396
|
+
node.status.classList.add("fail");
|
|
397
|
+
S.runFails++;
|
|
398
|
+
S.runReconOnly = false;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
S.toolNodes.delete(data.toolCallId);
|
|
402
|
+
const args = S.pendingArgs.get(data.toolCallId) || {};
|
|
403
|
+
S.pendingArgs.delete(data.toolCallId);
|
|
404
|
+
if (verbosity() === "quiet") {
|
|
405
|
+
// Quiet hides tool bodies. Track successful nodes so a run of read-only
|
|
406
|
+
// actions can fold at the next segment; a failure keeps its line and adds
|
|
407
|
+
// a single `↳` cause instead of the whole model-facing body.
|
|
408
|
+
if (data.success) {
|
|
409
|
+
if (line) S.runNodes.push(line);
|
|
410
|
+
const card = cardFor(data.name, args, data.sessionId);
|
|
411
|
+
if (card) S.runNodes.push(card);
|
|
412
|
+
S.runDuration += typeof data.durationMs === "number" ? data.durationMs : 0;
|
|
413
|
+
} else {
|
|
414
|
+
const cause = firstOutputLine(data.output);
|
|
415
|
+
if (cause) insertAfter(line, el("div", "detail", "↳ " + cause));
|
|
416
|
+
}
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (data.success) cardFor(data.name, args, data.sessionId);
|
|
420
|
+
renderToolOutput(line, data.output);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function onTurnEnd(data) {
|
|
424
|
+
S.state.busy = false;
|
|
425
|
+
const feed = feedForEvent(data.sessionId);
|
|
426
|
+
clearBusy(feed);
|
|
427
|
+
finishThink();
|
|
428
|
+
finishMd();
|
|
429
|
+
flushRun();
|
|
430
|
+
const owner = S.tabs.find((x) => x.id === data.sessionId);
|
|
431
|
+
if (owner) owner.planCard = null;
|
|
432
|
+
if (!feed) {
|
|
433
|
+
resetTurnText();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (data.success === false) {
|
|
437
|
+
if (data.interrupted) {
|
|
438
|
+
appendTo(feed, el("div", "warnline", "⚠ " + t("web.turn.stopped")));
|
|
439
|
+
announce(t("web.turn.stopped"));
|
|
440
|
+
} else {
|
|
441
|
+
appendTo(feed, el("div", "errline", "✗ " + t("web.turn.failed")));
|
|
442
|
+
announce(t("web.turn.failed"));
|
|
443
|
+
}
|
|
444
|
+
} else {
|
|
445
|
+
const parts = [];
|
|
446
|
+
if (typeof data.toolCalls === "number" && data.toolCalls > 0) {
|
|
447
|
+
parts.push(t("web.turn.tools", { count: data.toolCalls }));
|
|
448
|
+
}
|
|
449
|
+
if (typeof data.durationMs === "number") parts.push(formatMs(data.durationMs));
|
|
450
|
+
if (parts.length) appendTo(feed, el("div", "metric", "⟡ " + parts.join(" · ")));
|
|
451
|
+
// The visible turn-end banner is redundant noise; keep the screen-reader cue.
|
|
452
|
+
announce(t("web.turn.end", { count: data.toolCalls || 0 }));
|
|
453
|
+
}
|
|
454
|
+
const plain = S.turnRoots.map((r) => r.innerText || r.textContent || "").join("\n\n");
|
|
455
|
+
appendActionsRow(feed, plain, S.turnMd);
|
|
456
|
+
resetTurnText();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function resetTurnText() {
|
|
460
|
+
S.turnMd = "";
|
|
461
|
+
S.turnRoots = [];
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Per-message copy actions (whole response as plain text / as Markdown).
|
|
465
|
+
function copyAction(payload, labelKey) {
|
|
466
|
+
const btn = el("button", "act-btn", t(labelKey));
|
|
467
|
+
btn.type = "button";
|
|
468
|
+
btn.addEventListener("click", () => {
|
|
469
|
+
copyText(payload).then((ok) => {
|
|
470
|
+
btn.textContent = ok ? "✓ " + t("web.code.copied") : "⚠ " + t("web.code.copy_failed");
|
|
471
|
+
window.setTimeout(() => {
|
|
472
|
+
btn.textContent = t(labelKey);
|
|
473
|
+
}, 1400);
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
return btn;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function appendActionsRow(feed, plain, markdown) {
|
|
480
|
+
const text = String(plain == null ? "" : plain);
|
|
481
|
+
const md = String(markdown == null ? "" : markdown);
|
|
482
|
+
if (!feed || (!text.trim() && !md.trim())) return;
|
|
483
|
+
const bar = el("div", "msg-actions");
|
|
484
|
+
if (text.trim()) bar.appendChild(copyAction(text, "web.msg.copy"));
|
|
485
|
+
if (md.trim()) bar.appendChild(copyAction(md, "web.msg.copy_md"));
|
|
486
|
+
appendTo(feed, bar);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function onErrorEvent(data) {
|
|
490
|
+
if (!data || !data.error) return;
|
|
491
|
+
const feed = feedForEvent(data.sessionId);
|
|
492
|
+
if (feed) appendTo(feed, el("div", "errline", "✗ " + data.error));
|
|
493
|
+
announce(String(data.error));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function onCommandOutput(text, sid) {
|
|
497
|
+
const feed = feedForEvent(sid) || activeFeed();
|
|
498
|
+
if (!feed) return;
|
|
499
|
+
clearEmpty(feed);
|
|
500
|
+
String(text)
|
|
501
|
+
.split("\n")
|
|
502
|
+
.forEach((line) => appendTo(feed, el("div", "cmdout", line)));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Server-forwarded `OutputBus` line (system-prompt overflow, context probe,
|
|
506
|
+
// summarization). Text is already redacted server-side; render via
|
|
507
|
+
// `textContent` so nothing untrusted is parsed as HTML.
|
|
508
|
+
export function onLog(data) {
|
|
509
|
+
if (!data || typeof data.text !== "string" || !data.text) return;
|
|
510
|
+
const feed = feedForEvent(data.sessionId) || activeFeed();
|
|
511
|
+
if (!feed) return;
|
|
512
|
+
const level = data.level;
|
|
513
|
+
const cls = level === "error" ? "errline" : level === "warn" ? "warnline" : "detail";
|
|
514
|
+
const glyph = level === "error" ? "✗ " : level === "warn" ? "⚠ " : "↳ ";
|
|
515
|
+
const line = el("div", cls);
|
|
516
|
+
line.textContent = glyph + data.text;
|
|
517
|
+
appendTo(feed, line);
|
|
518
|
+
if (level === "error" || level === "warn") announce(data.text);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// In-loop critic verdict (concern/critical). Mirrors the terminal verbosity
|
|
522
|
+
// (quiet hides, normal shows one line, verbose adds the summary). The
|
|
523
|
+
// repair/advice directive itself is model-facing and never sent here.
|
|
524
|
+
export function onCritic(data) {
|
|
525
|
+
if (!data || (data.status !== "concern" && data.status !== "critical")) return;
|
|
526
|
+
const level = verbosity();
|
|
527
|
+
if (level === "quiet") return;
|
|
528
|
+
const feed = feedForEvent(data.sessionId) || activeFeed();
|
|
529
|
+
if (!feed) return;
|
|
530
|
+
const critical = data.status === "critical" || data.repair === true;
|
|
531
|
+
const label = critical
|
|
532
|
+
? t("web.critic_repair")
|
|
533
|
+
: pluralCount("web.critic_concern", Number(data.issueCount) || 0);
|
|
534
|
+
const line = el("div", critical ? "warnline" : "detail");
|
|
535
|
+
line.textContent = (critical ? "⚠ " : "⟡ ") + label;
|
|
536
|
+
appendTo(feed, line);
|
|
537
|
+
if (level === "verbose" && typeof data.summary === "string" && data.summary) {
|
|
538
|
+
const detail = el("div", "detail");
|
|
539
|
+
detail.textContent = "↳ " + data.summary;
|
|
540
|
+
appendTo(feed, detail);
|
|
541
|
+
}
|
|
542
|
+
if (critical) announce(label);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function renderAssistantText(feed, text) {
|
|
546
|
+
const raw = String(text == null ? "" : text);
|
|
547
|
+
if (!raw.trim()) return;
|
|
548
|
+
const root = renderMarkdown(feed, raw, mdOpts());
|
|
549
|
+
appendActionsRow(feed, root.innerText || root.textContent || "", raw);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function renderThinkBlock(feed, text) {
|
|
553
|
+
const { wrap, body } = buildThinkBlock(false);
|
|
554
|
+
body.textContent = text || "";
|
|
555
|
+
feed.appendChild(wrap);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function renderHistoryAct(feed, entry, statusNodes) {
|
|
559
|
+
const line = el("div", "act " + categoryOf(entry.name));
|
|
560
|
+
line.appendChild(el("span", "g", "◇"));
|
|
561
|
+
line.appendChild(el("span", "lab", labelOf(entry.name)));
|
|
562
|
+
const args = argText(entry.args);
|
|
563
|
+
if (args) {
|
|
564
|
+
line.appendChild(el("span", "sep", "·"));
|
|
565
|
+
line.appendChild(el("span", "args", args));
|
|
566
|
+
}
|
|
567
|
+
const status = el("span", "ok", "");
|
|
568
|
+
line.appendChild(status);
|
|
569
|
+
feed.appendChild(line);
|
|
570
|
+
if (entry.toolCallId) statusNodes.set(entry.toolCallId, status);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function renderHistoryResult(feed, entry, statusNodes) {
|
|
574
|
+
const dur = formatMs(entry.duration);
|
|
575
|
+
const mark = (entry.success ? " ✓" : " ✗") + (dur ? " " + dur : "");
|
|
576
|
+
const status = entry.toolCallId ? statusNodes.get(entry.toolCallId) : null;
|
|
577
|
+
if (status) {
|
|
578
|
+
status.textContent = mark;
|
|
579
|
+
if (!entry.success) status.classList.add("fail");
|
|
580
|
+
statusNodes.delete(entry.toolCallId);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const line = el("div", "act " + categoryOf(entry.tool));
|
|
584
|
+
line.appendChild(el("span", "g", "↳"));
|
|
585
|
+
line.appendChild(el("span", "lab", labelOf(entry.tool)));
|
|
586
|
+
line.appendChild(el("span", "ok" + (entry.success ? "" : " fail"), mark));
|
|
587
|
+
feed.appendChild(line);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function renderHistory(feed, entries) {
|
|
591
|
+
const statusNodes = new Map();
|
|
592
|
+
entries.forEach((entry) => {
|
|
593
|
+
if (!entry || typeof entry.kind !== "string") return;
|
|
594
|
+
switch (entry.kind) {
|
|
595
|
+
case "user":
|
|
596
|
+
feed.appendChild(el("div", "u", entry.text || ""));
|
|
597
|
+
break;
|
|
598
|
+
case "assistant":
|
|
599
|
+
renderAssistantText(feed, entry.text || "");
|
|
600
|
+
break;
|
|
601
|
+
case "reasoning":
|
|
602
|
+
renderThinkBlock(feed, entry.text || "");
|
|
603
|
+
break;
|
|
604
|
+
case "tool_call":
|
|
605
|
+
renderHistoryAct(feed, entry, statusNodes);
|
|
606
|
+
break;
|
|
607
|
+
case "tool_result":
|
|
608
|
+
renderHistoryResult(feed, entry, statusNodes);
|
|
609
|
+
break;
|
|
610
|
+
case "plan":
|
|
611
|
+
if (entry.text) feed.appendChild(el("div", "sysline", "▸ " + entry.text));
|
|
612
|
+
break;
|
|
613
|
+
case "context":
|
|
614
|
+
if (entry.text) feed.appendChild(el("div", "detail", "↳ " + entry.text));
|
|
615
|
+
break;
|
|
616
|
+
case "error":
|
|
617
|
+
feed.appendChild(el("div", "errline", "✗ " + (entry.text || "")));
|
|
618
|
+
break;
|
|
619
|
+
case "system":
|
|
620
|
+
if (entry.text) feed.appendChild(el("div", "sysline", entry.text));
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function feedHasContent(feed) {
|
|
627
|
+
return !!feed.querySelector(".u, .md, .ans, .code, .act, .think, .metaln, .errline, .sysline");
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export async function loadHistory(tab) {
|
|
631
|
+
if (!tab || tab.historyLoaded || tab.historyLoading) return;
|
|
632
|
+
tab.historyLoading = true;
|
|
633
|
+
const placeholder = el("div", "detail", t("web.history.loading"));
|
|
634
|
+
if (!feedHasContent(tab.feed)) {
|
|
635
|
+
clearEmpty(tab.feed);
|
|
636
|
+
tab.feed.appendChild(placeholder);
|
|
637
|
+
}
|
|
638
|
+
const r = await api("/api/sessions/" + encodeURIComponent(tab.id) + "/history");
|
|
639
|
+
tab.historyLoading = false;
|
|
640
|
+
placeholder.remove();
|
|
641
|
+
tab.historyLoaded = true;
|
|
642
|
+
if (!r.ok || !r.data || !Array.isArray(r.data.history)) {
|
|
643
|
+
if (!feedHasContent(tab.feed)) tab.feed.appendChild(el("div", "detail", t("web.history.unavailable")));
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
// A live session already rendered its content over SSE: don't duplicate it.
|
|
647
|
+
if (feedHasContent(tab.feed)) return;
|
|
648
|
+
renderHistory(tab.feed, r.data.history);
|
|
649
|
+
if (!r.data.history.length) tab.feed.appendChild(el("div", "detail", t("web.history.empty")));
|
|
650
|
+
if (tab.feed === activeFeed()) scroll();
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function syncUrl() {
|
|
654
|
+
const url = new URL(location.href);
|
|
655
|
+
const tab = S.tabs.find((x) => x.id === S.activeTabId);
|
|
656
|
+
if (tab && tab.kind === "session") url.searchParams.set("session", tab.id);
|
|
657
|
+
else url.searchParams.delete("session");
|
|
658
|
+
history.replaceState(null, "", url);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// One marking for a plan step, shared by the sidebar (`renderPlan`) and the
|
|
662
|
+
// feed card (`onPlan`): status class + leading glyph for done/failed/current.
|
|
663
|
+
function planStepMark(step, isCurrent) {
|
|
664
|
+
const status =
|
|
665
|
+
step.status === "done" ? "done" : step.status === "failed" ? "fail" : isCurrent ? "cur" : "";
|
|
666
|
+
const glyph =
|
|
667
|
+
step.status === "done" ? "✓ " : step.status === "failed" ? "✗ " : isCurrent ? "▸ " : "○ ";
|
|
668
|
+
return { status, glyph };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export function renderPlan(plan) {
|
|
672
|
+
// The sidebar polls `/api/state` every 2s; skip a rebuild (and the scroll
|
|
673
|
+
// reset it would cause) when the snapshot is unchanged.
|
|
674
|
+
const sig = JSON.stringify([plan, I18N.locale]);
|
|
675
|
+
if (sig === S.lastPlanSig) return;
|
|
676
|
+
S.lastPlanSig = sig;
|
|
677
|
+
planEl.textContent = "";
|
|
678
|
+
if (!plan || !Array.isArray(plan.steps) || !plan.steps.length) {
|
|
679
|
+
planCountEl.textContent = "";
|
|
680
|
+
planEl.appendChild(el("div", "kv dim", t("web.plan.none")));
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const done = plan.steps.filter((s) => s.status === "done").length;
|
|
684
|
+
const total = plan.steps.length;
|
|
685
|
+
const pct = total ? Math.round((done / total) * 100) : 0;
|
|
686
|
+
planCountEl.textContent = done + " / " + total;
|
|
687
|
+
const bar = el("div", "plan-bar");
|
|
688
|
+
const track = el("span", "pbar");
|
|
689
|
+
const fill = el("i");
|
|
690
|
+
fill.style.width = pct + "%";
|
|
691
|
+
track.appendChild(fill);
|
|
692
|
+
bar.appendChild(track);
|
|
693
|
+
bar.appendChild(el("span", "", pct + "%"));
|
|
694
|
+
planEl.appendChild(bar);
|
|
695
|
+
plan.steps.forEach((step, i) => {
|
|
696
|
+
const current = i === plan.currentIndex && step.status !== "done" && step.status !== "failed";
|
|
697
|
+
const { status, glyph } = planStepMark(step, current);
|
|
698
|
+
planEl.appendChild(el("div", ("plan-step " + status).trim(), glyph + step.text));
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
export function onPlan(data) {
|
|
703
|
+
const feed = feedForEvent(data && data.sessionId);
|
|
704
|
+
if (!feed || !data || !Array.isArray(data.steps) || !data.steps.length) return;
|
|
705
|
+
clearEmpty(feed);
|
|
706
|
+
const tab = S.tabs.find((x) => x.feed === feed);
|
|
707
|
+
let card = tab ? tab.planCard : null;
|
|
708
|
+
if (!card || card.parentNode !== feed) {
|
|
709
|
+
card = el("div", "planblock");
|
|
710
|
+
appendTo(feed, card);
|
|
711
|
+
if (tab) tab.planCard = card;
|
|
712
|
+
}
|
|
713
|
+
card.textContent = "";
|
|
714
|
+
const total = data.steps.length;
|
|
715
|
+
const cur = typeof data.currentIndex === "number" ? data.currentIndex : -1;
|
|
716
|
+
card.appendChild(el("div", "ph2", "◇ " + pluralCount("web.plan.heading", total)));
|
|
717
|
+
data.steps.forEach((step, i) => {
|
|
718
|
+
const { status, glyph } = planStepMark(step, i === cur);
|
|
719
|
+
card.appendChild(el("div", ("st " + status).trim(), glyph + step.text));
|
|
720
|
+
});
|
|
721
|
+
if (feed === activeFeed()) scroll();
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// `? ` glyph on the last line, matching the terminal menu (the tool may have
|
|
725
|
+
// already prefixed it — don't double it).
|
|
726
|
+
function questionLabel(text) {
|
|
727
|
+
const lines = String(text).split("\n");
|
|
728
|
+
const last = lines[lines.length - 1];
|
|
729
|
+
if (/^\s*\?/.test(last)) return String(text);
|
|
730
|
+
lines[lines.length - 1] = "? " + last;
|
|
731
|
+
return lines.join("\n");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Answered-state + settle wiring shared by both question kinds. `display` is
|
|
735
|
+
// what the user sees; `raw` is exactly what goes to `/api/answer`.
|
|
736
|
+
function makeQuestionBlock(feed, promptText, id) {
|
|
737
|
+
const block = el("div", "q");
|
|
738
|
+
block.appendChild(el("div", "qtext", promptText));
|
|
739
|
+
let answered = false;
|
|
740
|
+
const settle = (display, raw) => {
|
|
741
|
+
if (answered) return;
|
|
742
|
+
answered = true;
|
|
743
|
+
block.classList.add("answered");
|
|
744
|
+
block.textContent = "";
|
|
745
|
+
block.appendChild(el("div", "qtext", promptText));
|
|
746
|
+
block.appendChild(el("div", "qans", "↳ " + (String(display || "").trim() || t("web.question.answered"))));
|
|
747
|
+
api("/api/answer", { method: "POST", body: { id, value: raw } });
|
|
748
|
+
if (feed === activeFeed()) scroll();
|
|
749
|
+
};
|
|
750
|
+
return { block, feed, settle, isAnswered: () => answered };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Free-text prompt (a `text` question, or the tool's follow-up after the
|
|
754
|
+
// "own answer" choice — see `askUser`/`CUSTOM_INDEX`).
|
|
755
|
+
function questionTextInput(q, placeholder) {
|
|
756
|
+
const wrap = el("div", "qcustom");
|
|
757
|
+
const input = el("input", "qinput");
|
|
758
|
+
input.type = "text";
|
|
759
|
+
input.placeholder = placeholder;
|
|
760
|
+
input.setAttribute("aria-label", placeholder);
|
|
761
|
+
input.addEventListener("keydown", (e) => {
|
|
762
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
763
|
+
if (e.key === "Enter") {
|
|
764
|
+
e.preventDefault();
|
|
765
|
+
const value = input.value.trim();
|
|
766
|
+
if (value) q.settle(value, value);
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
wrap.appendChild(input);
|
|
770
|
+
wrap.appendChild(
|
|
771
|
+
link(t("web.question.custom_send"), "", () => {
|
|
772
|
+
const value = input.value.trim();
|
|
773
|
+
if (value) q.settle(value, value);
|
|
774
|
+
}),
|
|
775
|
+
);
|
|
776
|
+
if (q.feed === activeFeed()) setTimeout(() => input.focus(), 0);
|
|
777
|
+
return wrap;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Choice prompt: single-select settles on click, multi-select collects into a
|
|
781
|
+
// send button. The "own answer" entry maps to CUSTOM_INDEX (-1) so the tool
|
|
782
|
+
// then asks the free-text follow-up.
|
|
783
|
+
function questionChoice(q, data, options) {
|
|
784
|
+
const multiple = !!data.multiple;
|
|
785
|
+
const allowCustom = !!data.allowCustom;
|
|
786
|
+
const entries = options.map((label) => ({ label, raw: null }));
|
|
787
|
+
if (allowCustom) entries.push({ label: t("web.question.custom"), raw: -1 });
|
|
788
|
+
const rawOf = (i) => (entries[i].raw != null ? entries[i].raw : i);
|
|
789
|
+
const opts = el("div", "opts");
|
|
790
|
+
const selected = [];
|
|
791
|
+
let sendBtn = null;
|
|
792
|
+
entries.forEach((entry, i) => {
|
|
793
|
+
const row = el("button", "qopt", i + 1 + ". " + entry.label);
|
|
794
|
+
row.type = "button";
|
|
795
|
+
row.setAttribute("aria-checked", "false");
|
|
796
|
+
row.addEventListener("click", () => {
|
|
797
|
+
if (q.isAnswered()) return;
|
|
798
|
+
if (multiple) {
|
|
799
|
+
const pos = selected.indexOf(i);
|
|
800
|
+
if (pos === -1) {
|
|
801
|
+
selected.push(i);
|
|
802
|
+
row.classList.add("on");
|
|
803
|
+
row.setAttribute("aria-checked", "true");
|
|
804
|
+
} else {
|
|
805
|
+
selected.splice(pos, 1);
|
|
806
|
+
row.classList.remove("on");
|
|
807
|
+
row.setAttribute("aria-checked", "false");
|
|
808
|
+
}
|
|
809
|
+
if (sendBtn) sendBtn.disabled = selected.length === 0;
|
|
810
|
+
} else {
|
|
811
|
+
row.setAttribute("aria-checked", "true");
|
|
812
|
+
q.settle(entry.label, [rawOf(i)]);
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
opts.appendChild(row);
|
|
816
|
+
});
|
|
817
|
+
q.block.appendChild(opts);
|
|
818
|
+
if (multiple) {
|
|
819
|
+
q.block.appendChild(el("div", "qmulti-hint", t("web.question.multi_hint")));
|
|
820
|
+
sendBtn = el("button", "qsend", t("web.question.send"));
|
|
821
|
+
sendBtn.type = "button";
|
|
822
|
+
sendBtn.disabled = true;
|
|
823
|
+
sendBtn.addEventListener("click", () => {
|
|
824
|
+
if (q.isAnswered() || !selected.length) return;
|
|
825
|
+
const chosen = selected.slice().sort((a, b) => a - b);
|
|
826
|
+
q.settle(chosen.map((i) => entries[i].label).join(", "), chosen.map(rawOf));
|
|
827
|
+
});
|
|
828
|
+
q.block.appendChild(sendBtn);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
export function onQuestion(data) {
|
|
833
|
+
if (!data || typeof data.text !== "string") return;
|
|
834
|
+
const feed = feedForEvent(data.sessionId);
|
|
835
|
+
if (!feed) return;
|
|
836
|
+
clearEmpty(feed);
|
|
837
|
+
const q = makeQuestionBlock(feed, questionLabel(data.text), data.id);
|
|
838
|
+
const options = Array.isArray(data.options) ? data.options : [];
|
|
839
|
+
if (data.kind === "choice" && options.length) questionChoice(q, data, options);
|
|
840
|
+
else q.block.appendChild(questionTextInput(q, t("web.question.placeholder")));
|
|
841
|
+
appendTo(feed, q.block);
|
|
842
|
+
if (feed === activeFeed()) scroll();
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function usageRow(label, value, cls) {
|
|
846
|
+
const row = el("div", "kv");
|
|
847
|
+
row.appendChild(el("span", "", label));
|
|
848
|
+
row.appendChild(el("b", cls || "", value));
|
|
849
|
+
return row;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Session row with a copy button next to the id (icon-only, ✓ on success).
|
|
853
|
+
function usageSessionRow(sessionId) {
|
|
854
|
+
const row = el("div", "kv");
|
|
855
|
+
row.appendChild(el("span", "", t("web.usage.session")));
|
|
856
|
+
const val = el("span", "kv-val");
|
|
857
|
+
if (!sessionId) {
|
|
858
|
+
val.appendChild(el("b", "", "—"));
|
|
859
|
+
} else {
|
|
860
|
+
val.appendChild(el("b", "", sessionId));
|
|
861
|
+
const btn = el("button", "copy-btn");
|
|
862
|
+
btn.type = "button";
|
|
863
|
+
btn.title = t("web.usage.copy_id");
|
|
864
|
+
btn.setAttribute("aria-label", t("web.usage.copy_id"));
|
|
865
|
+
btn.appendChild(icon("copy"));
|
|
866
|
+
btn.addEventListener("click", () => {
|
|
867
|
+
copyText(sessionId).then((ok) => {
|
|
868
|
+
btn.replaceChildren(icon(ok ? "check" : "copy"));
|
|
869
|
+
setTimeout(() => btn.replaceChildren(icon("copy")), 1500);
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
val.appendChild(btn);
|
|
873
|
+
}
|
|
874
|
+
row.appendChild(val);
|
|
875
|
+
return row;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
export function renderUsage(h) {
|
|
879
|
+
const cfg = S.state.config || {};
|
|
880
|
+
const sig = JSON.stringify([
|
|
881
|
+
h.sessionId, h.model, h.contextUsed, h.contextLimit, h.cacheHitRate, h.costUsd,
|
|
882
|
+
h.compactionCount, h.contextQuality, h.contextProbe, cfg.model, cfg.contextWindow,
|
|
883
|
+
I18N.locale,
|
|
884
|
+
]);
|
|
885
|
+
if (sig === S.lastUsageSig) return;
|
|
886
|
+
S.lastUsageSig = sig;
|
|
887
|
+
usageEl.textContent = "";
|
|
888
|
+
const sid = h.sessionId ? String(h.sessionId) : "";
|
|
889
|
+
usageEl.appendChild(usageSessionRow(sid));
|
|
890
|
+
usageEl.appendChild(usageRow(t("web.usage.model"), h.model || cfg.model || "—"));
|
|
891
|
+
const used = typeof h.contextUsed === "number" ? h.contextUsed : null;
|
|
892
|
+
const limit = typeof h.contextLimit === "number" ? h.contextLimit : cfg.contextWindow || null;
|
|
893
|
+
usageEl.appendChild(usageRow(t("web.usage.context"), used != null && limit ? fmtTokens(used) + " / " + fmtTokens(limit) : "—"));
|
|
894
|
+
if (used != null && limit) {
|
|
895
|
+
const bar = el("div", "bar");
|
|
896
|
+
const fill = el("i");
|
|
897
|
+
fill.style.width = Math.min(100, Math.round((used / limit) * 100)) + "%";
|
|
898
|
+
bar.appendChild(fill);
|
|
899
|
+
usageEl.appendChild(bar);
|
|
900
|
+
}
|
|
901
|
+
const hit = typeof h.cacheHitRate === "number" ? h.cacheHitRate : null;
|
|
902
|
+
usageEl.appendChild(usageRow(t("web.usage.cache"), hit != null ? Math.round(hit * 100) + "%" : "—", hit != null ? "" : "dim"));
|
|
903
|
+
usageEl.appendChild(usageRow(t("web.usage.cost"), typeof h.costUsd === "number" ? "$" + h.costUsd.toFixed(4) : "—"));
|
|
904
|
+
usageEl.appendChild(usageRow(t("web.usage.compactions"), "⟳ " + (h.compactionCount || 0), h.compactionCount ? "wn" : "dim"));
|
|
905
|
+
const q = typeof h.contextQuality === "number" ? h.contextQuality : null;
|
|
906
|
+
usageEl.appendChild(usageRow(t("web.usage.quality"), q != null ? Math.round(q) + "%" : "—", q != null ? "" : "dim"));
|
|
907
|
+
|
|
908
|
+
// Context probe: real LM Studio loaded context vs configured. Pending/absent
|
|
909
|
+
// renders a dim dash; loaded < configured warns.
|
|
910
|
+
let probeText = "—";
|
|
911
|
+
let probeCls = "dim";
|
|
912
|
+
const probe = h.contextProbe;
|
|
913
|
+
if (probe && typeof probe.actual === "number") {
|
|
914
|
+
const actual = fmtTokens(probe.actual);
|
|
915
|
+
const configured = h.contextLimit || cfg.contextWindow || null;
|
|
916
|
+
if (configured && probe.actual < configured) {
|
|
917
|
+
probeText = t("web.usage.probe_low", { actual, configured: fmtTokens(configured) });
|
|
918
|
+
probeCls = "wn";
|
|
919
|
+
} else {
|
|
920
|
+
probeText = t("web.usage.probe_loaded", { actual });
|
|
921
|
+
probeCls = "";
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
usageEl.appendChild(usageRow(t("web.usage.probe"), probeText, probeCls));
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Workspace path under the composer (dim, own line).
|
|
928
|
+
export function renderWorkspace(h) {
|
|
929
|
+
const ws = h.workspace || "";
|
|
930
|
+
if (wsLine.dataset.ws === ws) return;
|
|
931
|
+
wsLine.dataset.ws = ws;
|
|
932
|
+
wsLine.textContent = "";
|
|
933
|
+
wsLine.hidden = !ws;
|
|
934
|
+
if (!ws) return;
|
|
935
|
+
wsLine.appendChild(el("span", "ws-k", t("web.env.dir")));
|
|
936
|
+
const v = el("span", "ws-v", ws);
|
|
937
|
+
v.title = ws;
|
|
938
|
+
wsLine.appendChild(v);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Environment (sidebar): stacked name/value items — AGENTS.md sources, map size.
|
|
942
|
+
function envItem(label, values) {
|
|
943
|
+
const item = el("div", "env-item");
|
|
944
|
+
item.appendChild(el("div", "env-k", label));
|
|
945
|
+
values.forEach((val) => {
|
|
946
|
+
const v = el("div", "env-v", val);
|
|
947
|
+
v.title = val;
|
|
948
|
+
item.appendChild(v);
|
|
949
|
+
});
|
|
950
|
+
return item;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
export function renderEnvironment(h) {
|
|
954
|
+
const disabled = !!(h.instructions && h.instructions.disabled);
|
|
955
|
+
const files = h.instructions && !disabled ? h.instructions.files || [] : [];
|
|
956
|
+
const map = h.projectMap || null;
|
|
957
|
+
const sig = JSON.stringify([disabled, files, map, I18N.locale]);
|
|
958
|
+
if (sig === S.lastEnvSig) return;
|
|
959
|
+
S.lastEnvSig = sig;
|
|
960
|
+
envEl.textContent = "";
|
|
961
|
+
envEl.appendChild(
|
|
962
|
+
envItem(
|
|
963
|
+
t("web.env.agents"),
|
|
964
|
+
disabled ? [t("web.env.agents_disabled")] : files.length ? files : [t("web.env.agents_none")],
|
|
965
|
+
),
|
|
966
|
+
);
|
|
967
|
+
envEl.appendChild(
|
|
968
|
+
envItem(
|
|
969
|
+
t("web.env.map"),
|
|
970
|
+
map && map.indexed
|
|
971
|
+
? [
|
|
972
|
+
[
|
|
973
|
+
pluralCount("web.env.files", map.files),
|
|
974
|
+
pluralCount("web.env.symbols", map.symbols),
|
|
975
|
+
pluralCount("web.env.tokens", map.tokens),
|
|
976
|
+
].join(" · "),
|
|
977
|
+
]
|
|
978
|
+
: [t("web.env.map_not_indexed")],
|
|
979
|
+
),
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Global `.think-h` toggle (depends on the wrap > [head, body] structure
|
|
984
|
+
// built by `buildThinkBlock`).
|
|
985
|
+
msgs.addEventListener("click", (e) => {
|
|
986
|
+
const head = e.target.closest(".think-h");
|
|
987
|
+
if (!head) return;
|
|
988
|
+
const body = head.nextElementSibling;
|
|
989
|
+
if (!body) return;
|
|
990
|
+
const hidden = body.classList.toggle("hidden");
|
|
991
|
+
head.setAttribute("aria-expanded", hidden ? "false" : "true");
|
|
992
|
+
const tog = head.querySelector(".tog");
|
|
993
|
+
if (tog) setIcon(tog, hidden ? "chevron-right" : "chevron-down", t(hidden ? "web.action.show" : "web.action.hide"));
|
|
994
|
+
});
|
|
995
|
+
|