@weiping/mbridge-page 0.3.0 → 0.5.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/package.json +6 -6
- package/page/app.js +90 -166
- package/page/index.html +2 -44
- package/page/style.css +110 -64
- package/page/view.js +682 -186
package/page/view.js
CHANGED
|
@@ -1,197 +1,693 @@
|
|
|
1
|
-
// view.js
|
|
2
|
-
// the
|
|
3
|
-
//
|
|
4
|
-
// It is a classic script, not a module: a magpie page runs in a sandbox
|
|
5
|
-
// whose CSP serves the plugin's own files, and the community's usage
|
|
6
|
-
// page does it this way too (globalThis.UsageView). A module script
|
|
7
|
-
// would be a different thing to load, and the test below loads this file
|
|
8
|
-
// the same way the page does.
|
|
1
|
+
// view.js provides the complete status UI renderer for the magpie plugin page.
|
|
2
|
+
// It shares the exact visual design and data representation with the mbridge /status page.
|
|
9
3
|
|
|
10
4
|
(() => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
const REFRESH_MS = 5000;
|
|
8
|
+
|
|
9
|
+
const TEXT = {
|
|
10
|
+
en: {
|
|
11
|
+
title: "mbridge",
|
|
12
|
+
subtitle: "per-turn model routing, on this machine",
|
|
13
|
+
window: "window",
|
|
14
|
+
unknown: "unknown",
|
|
15
|
+
listenPending: "the listen in router.json takes effect only after a restart",
|
|
16
|
+
turns: "turns",
|
|
17
|
+
coverage: "asked the classifier",
|
|
18
|
+
failures: "failures",
|
|
19
|
+
cost: "cost",
|
|
20
|
+
topReason: "most common reason",
|
|
21
|
+
process: "process",
|
|
22
|
+
pid: "pid", uptime: "uptime", listen: "listen", magpie: "magpie",
|
|
23
|
+
service: "service", running: "running", notRunning: "installed, not running",
|
|
24
|
+
notInstalled: "not installed",
|
|
25
|
+
live: "sessions running now",
|
|
26
|
+
noLive: "no session is going through mbridge right now",
|
|
27
|
+
justWent: "the last turns that went through it",
|
|
28
|
+
decisions: "recent decisions",
|
|
29
|
+
noDecisions: "no decision yet",
|
|
30
|
+
classifier: "classifier",
|
|
31
|
+
distributions: "distributions",
|
|
32
|
+
arms: "two arms, and the calibration",
|
|
33
|
+
config: "configuration in force",
|
|
34
|
+
configNote: "a project-level .mbridge/router.json may override each tier's criteria",
|
|
35
|
+
since: "since",
|
|
36
|
+
tier: "tier", advised: "advised", reason: "reason", harness: "harness",
|
|
37
|
+
agent: "agent", source: "source", conf: "confidence", ms: "ms",
|
|
38
|
+
tierConf: "tier confidence", dissatisfied: "dissatisfied", compact: "compact",
|
|
39
|
+
classifyMs: "classifier", latency: "decision (includes classification)",
|
|
40
|
+
notAsked: "not asked", notMeasured: "—", failed: "failed",
|
|
41
|
+
askHint: "not every turn asks it: pinned agents, plan mode and compaction never do, so a coverage below 100% is normal.",
|
|
42
|
+
failureFix: { timeout: "raise classify_timeout_ms",
|
|
43
|
+
canceled: "the client gave up first: lower the timeout or use a faster classifier",
|
|
44
|
+
magpie: "magpie's own words; no fix is suggested" },
|
|
45
|
+
sampleShort: "too few samples",
|
|
46
|
+
feedbackOnly: "from feedback only, not gh",
|
|
47
|
+
unpriced: "requests magpie has no price for",
|
|
48
|
+
cacheWrite: "cache write (estimated)",
|
|
49
|
+
merged: "merged PR", manual: "manual switch", sessions: "sessions",
|
|
50
|
+
median: "median", p90: "p90",
|
|
51
|
+
restart: "restart mbridge",
|
|
52
|
+
restarting: "restarting…",
|
|
53
|
+
restartAsked: "restart asked for — the pid changes when it comes back",
|
|
54
|
+
restartRefused: "refused (not same-origin?)",
|
|
55
|
+
foot: [
|
|
56
|
+
"the harness is whatever the client says it is; mbridge does not check it, and an unknown one is shown as it came",
|
|
57
|
+
"the session list is what mbridge still remembers: it is memory, and a restart empties it",
|
|
58
|
+
"a project-level .mbridge/router.json may override each tier's criteria; which one a session used is not recorded",
|
|
59
|
+
"the merged-PR rate here comes from the sessions' own feedback, not from gh: mbridge router report runs gh",
|
|
60
|
+
"the tier on a card is where that turn went, not where it should have gone; the classifier's advice is shown next to it",
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
zh: {
|
|
64
|
+
title: "鹊桥",
|
|
65
|
+
subtitle: "本机的逐轮选档",
|
|
66
|
+
window: "区间",
|
|
67
|
+
unknown: "未知",
|
|
68
|
+
listenPending: "router.json 里的 listen 要重启才生效",
|
|
69
|
+
turns: "轮",
|
|
70
|
+
coverage: "问了分类器",
|
|
71
|
+
failures: "失败",
|
|
72
|
+
cost: "成本",
|
|
73
|
+
topReason: "最常见的 reason",
|
|
74
|
+
process: "进程",
|
|
75
|
+
pid: "pid", uptime: "运行时长", listen: "listen", magpie: "magpie",
|
|
76
|
+
service: "service", running: "在跑", notRunning: "装了,没在跑",
|
|
77
|
+
notInstalled: "没装",
|
|
78
|
+
live: "正在选档的会话",
|
|
79
|
+
noLive: "现在没有会话在走 mbridge",
|
|
80
|
+
justWent: "刚刚走过的几轮",
|
|
81
|
+
decisions: "最近判档",
|
|
82
|
+
noDecisions: "还没有判档",
|
|
83
|
+
classifier: "分类器",
|
|
84
|
+
distributions: "分布",
|
|
85
|
+
arms: "两臂与校准",
|
|
86
|
+
config: "生效中的配置",
|
|
87
|
+
configNote: "项目级 .mbridge/router.json 可以覆盖每档的 criteria",
|
|
88
|
+
since: "起",
|
|
89
|
+
tier: "档", advised: "建议", reason: "reason", harness: "harness",
|
|
90
|
+
agent: "agent", source: "来源", conf: "置信度", ms: "ms",
|
|
91
|
+
tierConf: "档位置信度", dissatisfied: "不满意", compact: "压缩",
|
|
92
|
+
classifyMs: "分类耗时", latency: "判档(含分类)",
|
|
93
|
+
notAsked: "没问", notMeasured: "—", failed: "失败",
|
|
94
|
+
askHint: "不是每一轮都问:被钉死的 agent、计划模式和压缩轮次都不问,所以覆盖率低于 100% 是正常的。",
|
|
95
|
+
failureFix: { timeout: "调大 classify_timeout_ms",
|
|
96
|
+
canceled: "客户端先放弃了:调小超时,或换更快的分类器",
|
|
97
|
+
magpie: "这是 magpie 自己报的原文,不给建议" },
|
|
98
|
+
sampleShort: "样本不足",
|
|
99
|
+
feedbackOnly: "只来自会话自报的 feedback,不含 gh",
|
|
100
|
+
unpriced: "magpie 没有价的请求",
|
|
101
|
+
cacheWrite: "cache write(估算)",
|
|
102
|
+
merged: "PR 合并", manual: "手动切模型", sessions: "会话数",
|
|
103
|
+
median: "中位数", p90: "p90",
|
|
104
|
+
restart: "重启 mbridge",
|
|
105
|
+
restarting: "正在重启…",
|
|
106
|
+
restartAsked: "已发出重启指令 —— pid 变了就是回来了",
|
|
107
|
+
restartRefused: "被拒绝(不是同源?)",
|
|
108
|
+
foot: [
|
|
109
|
+
"harness 是客户端自报的,mbridge 不校验;没见过的值照原样显示",
|
|
110
|
+
"会话列表只到 mbridge 记得住的为止:它在内存里,重启即空",
|
|
111
|
+
"项目级 .mbridge/router.json 可以覆盖每档的 criteria;某个会话当时用的是哪一份,没有记录",
|
|
112
|
+
"这里的 PR 合并率来自会话自报的 feedback,不是 gh:mbridge router report 才跑 gh",
|
|
113
|
+
"卡片上的档是「这一轮送去了哪」,不是「该用哪」;分类器的建议就显示在旁边",
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const REASONS = {
|
|
119
|
+
"R1-fixed": ["that agent is pinned by fixed_agents", "该 agent 被 fixed_agents 钉死"],
|
|
120
|
+
"R2-plan-mode": ["plan mode: performance directly", "计划模式,直接 performance"],
|
|
121
|
+
"R9-compact": ["a compaction or summary turn", "压缩 / 摘要轮次"],
|
|
122
|
+
"R3-escalate": ["the last turn was judged unsatisfying: one tier up", "上一轮被判不满意 → 升一档"],
|
|
123
|
+
"R3-tools": ["3+ tool calls, half of them failing: one tier up", "上一轮至少 3 次工具调用、一半以上失败 → 升一档"],
|
|
124
|
+
"R3-review": ["the review said it was not resolved: one tier up", "复核说上一轮没解决 → 升一档"],
|
|
125
|
+
"R4-escalation-hold": ["holding the escalated tier", "升档后保持"],
|
|
126
|
+
"R6-adopt": ["took the classifier's answer", "采用分类结果"],
|
|
127
|
+
"R6-lower": ["lower tier (cache expired, or lowered twice)", "降档(缓存过期,或连降两次)"],
|
|
128
|
+
"R6-keep": ["not lowered", "不降档"],
|
|
129
|
+
"R7-carry": ["classification failed or was unsure: kept the last tier", "分类失败或置信度不够,沿用上一档"],
|
|
130
|
+
"R8-default": ["no last turn: the default tier", "没有上一轮,用 default_tier"],
|
|
131
|
+
"hint": ["a tier a /turn stored", "用了 /turn 存下的判档"],
|
|
132
|
+
"R-held": ["kept the tier the turn began with", "本轮内沿用开轮时那一档"],
|
|
133
|
+
"codex-title": ["Codex naming the thread, not a turn", "Codex 给线程起标题,不是真的一轮"],
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const TIERS = ["fast", "balanced", "performance"];
|
|
137
|
+
|
|
138
|
+
let lang = (navigator.language || "en").toLowerCase().startsWith("zh") ? "zh" : "en";
|
|
139
|
+
const T = () => TEXT[lang];
|
|
140
|
+
|
|
141
|
+
function el(tag, attrs, kids) {
|
|
142
|
+
const n = document.createElement(tag);
|
|
143
|
+
for (const [k, v] of Object.entries(attrs || {})) {
|
|
144
|
+
if (v === undefined || v === null || v === false) continue;
|
|
145
|
+
if (k === "text") n.textContent = v;
|
|
146
|
+
else if (k === "class") n.className = v;
|
|
147
|
+
else n.setAttribute(k, v);
|
|
148
|
+
}
|
|
149
|
+
for (const kid of [].concat(kids || [])) {
|
|
150
|
+
if (kid === undefined || kid === null || kid === false) continue;
|
|
151
|
+
n.appendChild(typeof kid === "object" ? kid : document.createTextNode(String(kid)));
|
|
152
|
+
}
|
|
153
|
+
return n;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function kv(rows) {
|
|
157
|
+
const box = el("div", { class: "kv" });
|
|
158
|
+
for (const [k, v] of rows) {
|
|
159
|
+
if (v === undefined || v === null) continue;
|
|
160
|
+
box.appendChild(el("div", { text: k }));
|
|
161
|
+
box.appendChild(typeof v === "object" ? el("div", {}, v) : el("div", { text: String(v) }));
|
|
162
|
+
}
|
|
163
|
+
return box;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function pct(part, whole) {
|
|
167
|
+
if (!whole) return "0%";
|
|
168
|
+
const v = (100 * part) / whole;
|
|
169
|
+
return (Math.round(v * 10) / 10).toString() + "%";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function money(v) {
|
|
173
|
+
v = Number(v) || 0;
|
|
174
|
+
if (v === 0) return "$0";
|
|
175
|
+
if (v > 0 && v < 0.01) return "<$0.01";
|
|
176
|
+
if (v < 10) return "$" + v.toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
|
|
177
|
+
if (v < 1000) return "$" + v.toFixed(2);
|
|
178
|
+
return "$" + v.toFixed(0);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function ago(iso) {
|
|
182
|
+
if (!iso) return "";
|
|
183
|
+
const t = new Date(iso);
|
|
184
|
+
if (isNaN(t)) return "";
|
|
185
|
+
const s = Math.max(0, Math.round((Date.now() - t) / 1000));
|
|
186
|
+
if (s < 60) return s + "s";
|
|
187
|
+
if (s < 3600) return Math.round(s / 60) + "m";
|
|
188
|
+
if (s < 86400) return Math.round(s / 3600) + "h";
|
|
189
|
+
return Math.round(s / 86400) + "d";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function clock(iso) {
|
|
193
|
+
if (!iso) return "";
|
|
194
|
+
const t = new Date(iso);
|
|
195
|
+
if (isNaN(t)) return "";
|
|
196
|
+
return t.toLocaleTimeString(lang === "zh" ? "zh-CN" : "en-GB", { hour12: false });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function reasonText(r) {
|
|
200
|
+
const both = REASONS[r];
|
|
201
|
+
if (!both) return r || "";
|
|
202
|
+
return lang === "zh" ? both[1] : both[0];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function tierChip(tier) {
|
|
206
|
+
return el("span", { class: "chip tier-" + tier, text: tier });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* The turns to show: the window's last ones, plus the ones this process
|
|
210
|
+
decided since it started, newest first. Without the window's turns a
|
|
211
|
+
fresh process would show an empty page. */
|
|
212
|
+
function turnsOf(data) {
|
|
213
|
+
const out = [];
|
|
214
|
+
const seen = new Set();
|
|
215
|
+
const push = (ev) => {
|
|
216
|
+
if (!ev || (ev.kind !== "decide" && ev.kind !== "shadow")) return;
|
|
217
|
+
const id = ev.turn_id || (ev.t + "|" + ev.session + "|" + ev.reason);
|
|
218
|
+
if (seen.has(id)) return;
|
|
219
|
+
seen.add(id);
|
|
220
|
+
out.push(ev);
|
|
82
221
|
};
|
|
222
|
+
for (const ev of (data.window && data.window.turns) || []) push(ev);
|
|
223
|
+
for (const ev of data.decisions || []) push(ev);
|
|
224
|
+
out.sort((a, b) => String(b.t || "").localeCompare(String(a.t || "")));
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
83
227
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
: (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
:
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
|
|
228
|
+
function scoreCell(value, asked) {
|
|
229
|
+
if (!asked) return el("div", { class: "off", text: T().notAsked });
|
|
230
|
+
if (value === undefined || value === null) return el("div", { text: T().notMeasured });
|
|
231
|
+
return el("div", {}, [bar(value), el("div", { class: "muted", text: value.toFixed(2) })]);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function bar(v) {
|
|
235
|
+
const b = el("div", { class: "bar" });
|
|
236
|
+
b.appendChild(el("i", { style: "width:" + Math.max(0, Math.min(1, v)) * 100 + "%" }));
|
|
237
|
+
return b;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function liveCard(c) {
|
|
241
|
+
const bits = [];
|
|
242
|
+
if (c.harness) bits.push(c.harness);
|
|
243
|
+
if (c.agent) bits.push(c.agent);
|
|
244
|
+
const row2 = el("div", { class: "row2", text: (c.session || "").slice(0, 8) + " · " + (bits.join(" · ") || T().unknown) });
|
|
245
|
+
const row3 = [];
|
|
246
|
+
if (c.reason) row3.push(reasonText(c.reason));
|
|
247
|
+
if (c.tier_confidence !== undefined && c.tier_confidence !== null) {
|
|
248
|
+
row3.push("conf " + c.tier_confidence.toFixed(2));
|
|
249
|
+
}
|
|
250
|
+
row3.push(ago(c.last_at));
|
|
251
|
+
const row4 = [];
|
|
252
|
+
if (c.turns) row4.push(c.turns + " " + T().turns);
|
|
253
|
+
row4.push(T().tools + " " + (c.agent === "gateway" ? (c.tools || 0) + "/" + (c.tool_failures || 0) : "—"));
|
|
254
|
+
if (c.escalated_left) row4.push("↑" + c.escalated_left);
|
|
255
|
+
if (c.lower_streak) row4.push("↓" + c.lower_streak);
|
|
256
|
+
if (c.pinned) row4.push("📌");
|
|
257
|
+
return el("div", { class: "card" }, [
|
|
258
|
+
el("div", { class: "row1" }, [
|
|
259
|
+
el("span", { text: (c.harness || T().unknown) + " · " + (c.agent || "") }),
|
|
260
|
+
el("span", {}, [c.tier, " ", tierChip(c.tier)]),
|
|
261
|
+
]),
|
|
262
|
+
row2,
|
|
263
|
+
el("div", { class: "row3 muted", text: row3.join(" · ") }),
|
|
264
|
+
el("div", { class: "row3 muted", text: row4.join(" · ") }),
|
|
265
|
+
]);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function decisionTable(turns) {
|
|
269
|
+
const t = el("table", { class: "wide" });
|
|
270
|
+
t.appendChild(el("thead", {}, el("tr", {}, [
|
|
271
|
+
el("th", { text: "" }), el("th", { text: T().harness }), el("th", { text: T().agent }),
|
|
272
|
+
el("th", { text: T().tier }), el("th", { text: T().advised }), el("th", { text: T().reason }),
|
|
273
|
+
el("th", { text: T().source }), el("th", { class: "num", text: T().conf }),
|
|
274
|
+
el("th", { class: "num", text: T().latency }),
|
|
275
|
+
])));
|
|
276
|
+
const body = el("tbody");
|
|
277
|
+
for (const ev of turns.slice(0, 20)) {
|
|
278
|
+
const differs = ev.classified_tier && ev.classified_tier !== ev.tier;
|
|
279
|
+
body.appendChild(el("tr", {}, [
|
|
280
|
+
el("td", { class: "muted", text: clock(ev.t) }),
|
|
281
|
+
el("td", { text: ev.harness || T().unknown }),
|
|
282
|
+
el("td", { text: ev.agent || "" }),
|
|
283
|
+
el("td", {}, tierChip(ev.tier || "?")),
|
|
284
|
+
el("td", { class: differs ? "chip differs" : "muted",
|
|
285
|
+
text: differs ? ev.classified_tier : (ev.classified_tier || T().notMeasured) }),
|
|
286
|
+
el("td", { class: "reason", title: reasonText(ev.reason), text: ev.reason || "" }),
|
|
287
|
+
el("td", { class: "muted", text: ev.classifier || ev.source || "" }),
|
|
288
|
+
el("td", { class: "num", text: ev.tier_confidence !== undefined && ev.tier_confidence !== null ?
|
|
289
|
+
ev.tier_confidence.toFixed(2) : T().notMeasured }),
|
|
290
|
+
el("td", { class: "num", text: ev.latency_ms ? ev.latency_ms + "ms" : T().notMeasured }),
|
|
291
|
+
]));
|
|
292
|
+
}
|
|
293
|
+
t.appendChild(body);
|
|
294
|
+
return t;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function distribution(title, counts, total) {
|
|
298
|
+
const keys = Object.keys(counts || {}).sort((a, b) => counts[b] - counts[a]);
|
|
299
|
+
const box = el("div", { class: "dist" });
|
|
300
|
+
box.appendChild(el("h3", { text: title }));
|
|
301
|
+
if (!keys.length) {
|
|
302
|
+
box.appendChild(el("div", { class: "muted", text: "—" }));
|
|
303
|
+
return box;
|
|
304
|
+
}
|
|
305
|
+
for (const k of keys) {
|
|
306
|
+
box.appendChild(el("div", { class: "distrow" }, [
|
|
307
|
+
el("span", { class: "distname", text: k }),
|
|
308
|
+
el("span", { class: "distbar" }, bar(counts[k] / (total || 1))),
|
|
309
|
+
el("span", { class: "num distnum", text: counts[k] + " · " + pct(counts[k], total) }),
|
|
310
|
+
]));
|
|
311
|
+
}
|
|
312
|
+
return box;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function calibrationTable(c) {
|
|
316
|
+
const t = el("table");
|
|
317
|
+
t.appendChild(el("thead", {}, el("tr", {}, [
|
|
318
|
+
el("th", { text: "score" }), el("th", { class: "num", text: "n" }),
|
|
319
|
+
el("th", { class: "num", text: "share" }), el("th", { class: "num", text: "under-tier" }),
|
|
320
|
+
])));
|
|
321
|
+
const body = el("tbody");
|
|
322
|
+
const total = (c.bands || []).reduce((a, b) => a + b.n, 0);
|
|
323
|
+
for (const b of c.bands || []) {
|
|
324
|
+
body.appendChild(el("tr", {}, [
|
|
325
|
+
el("td", { text: "[" + b.lo + ", " + b.hi + ")" }),
|
|
326
|
+
el("td", { class: "num", text: String(b.n) }),
|
|
327
|
+
el("td", { class: "num", text: pct(b.n, total) }),
|
|
328
|
+
// an empty band is "—", never 0%: no samples is not "never under-tier"
|
|
329
|
+
el("td", { class: "num", text: b.n ? pct(b.under_rate * 100, 100) : T().notMeasured }),
|
|
330
|
+
]));
|
|
331
|
+
}
|
|
332
|
+
t.appendChild(body);
|
|
333
|
+
const foot = [c.current !== undefined ? "current " + c.current : null,
|
|
334
|
+
c.suggested !== undefined && c.suggested !== null ? "suggested " + c.suggested : null,
|
|
335
|
+
c.sample_short ? T().sampleShort : null].filter(Boolean).join(" · ");
|
|
336
|
+
return el("div", { class: "cal" }, [t, el("div", { class: "muted", text: foot })]);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
function detectOS() {
|
|
341
|
+
const nav = typeof navigator !== "undefined" ? navigator : {};
|
|
342
|
+
const ua = (nav.userAgent || "").toLowerCase();
|
|
343
|
+
const plat = (nav.platform || "").toLowerCase();
|
|
344
|
+
if (plat.includes("win") || ua.includes("windows")) return "windows";
|
|
345
|
+
if (plat.includes("linux") || ua.includes("linux") || ua.includes("x11")) return "linux";
|
|
346
|
+
return "darwin"; // default mac
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function renderGuide(osName, lang) {
|
|
350
|
+
const isZh = String(lang || "").toLowerCase().startsWith("zh");
|
|
351
|
+
const os = osName || detectOS();
|
|
352
|
+
const root = document.getElementById("root");
|
|
353
|
+
root.textContent = "";
|
|
354
|
+
|
|
355
|
+
const box = el("div", { class: "setup-guide" });
|
|
356
|
+
|
|
357
|
+
// Header
|
|
358
|
+
const head = el("header", { class: "head" }, [
|
|
359
|
+
el("h1", {}, [el("span", { text: isZh ? "鹊桥" : "mbridge" })]),
|
|
360
|
+
el("p", { class: "sub", text: isZh ? "未检测到运行中的 mbridge 实例。请按以下步骤安装、配置并启动:" : "mbridge instance is not running or not installed. Follow the steps below to setup:" }),
|
|
361
|
+
]);
|
|
362
|
+
box.appendChild(head);
|
|
363
|
+
|
|
364
|
+
// Guide card 1: Service Install & Start
|
|
365
|
+
const card1 = el("div", { class: "guide-card" });
|
|
366
|
+
const osTitles = { darwin: "macOS", linux: "Linux", windows: "Windows" };
|
|
367
|
+
card1.appendChild(el("h2", { text: (isZh ? "步骤一:安装与启动 · " : "Step 1: Install & Start · ") + (osTitles[os] || "macOS") }));
|
|
368
|
+
card1.appendChild(el("p", { class: "guide-desc", text: isZh ? "鹊桥 是独立于 magpie 的智能模型选档守护进程,服务跑在 127.0.0.1:3426。" : "Magpie Bridge is a per-turn model router daemon running on 127.0.0.1:3426." }));
|
|
369
|
+
|
|
370
|
+
const steps1 = el("div", { class: "guide-steps" });
|
|
371
|
+
|
|
372
|
+
// 1. Install
|
|
373
|
+
const s1 = el("div", { class: "guide-step" });
|
|
374
|
+
s1.appendChild(el("div", { class: "step-title", text: isZh ? "1. 安装 mbridge 并注册自启服务" : "1. Install mbridge & register login service" }));
|
|
375
|
+
const cmd1 = os === "windows"
|
|
376
|
+
? "irm https://raw.githubusercontent.com/weiping/mbridge/main/install.ps1 | iex"
|
|
377
|
+
: "curl -fsSL https://raw.githubusercontent.com/weiping/mbridge/main/install.sh | sh";
|
|
378
|
+
s1.appendChild(el("div", { class: "step-cmd" }, [el("pre", { text: cmd1 })]));
|
|
379
|
+
steps1.appendChild(s1);
|
|
380
|
+
|
|
381
|
+
// 2. Init groups
|
|
382
|
+
const s2 = el("div", { class: "guide-step" });
|
|
383
|
+
s2.appendChild(el("div", { class: "step-title", text: isZh ? "2. 在 magpie 中初始化路由分组与配置" : "2. Initialize routing groups & config in magpie" }));
|
|
384
|
+
const cmd2 = "mbridge router init --preset cn --groups-only";
|
|
385
|
+
s2.appendChild(el("div", { class: "step-cmd" }, [el("pre", { text: cmd2 })]));
|
|
386
|
+
s2.appendChild(el("div", { class: "guide-tip", text: isZh ? "(也可选择 --preset frontier 或 anthropic 预设)" : "(or choose --preset frontier / anthropic)" }));
|
|
387
|
+
steps1.appendChild(s2);
|
|
388
|
+
|
|
389
|
+
// 3. Start / Verify
|
|
390
|
+
const s3 = el("div", { class: "guide-step" });
|
|
391
|
+
s3.appendChild(el("div", { class: "step-title", text: isZh ? "3. 启动与验证" : "3. Start & Verify" }));
|
|
392
|
+
const cmd3 = "# 检查服务运行状态\nmbridge status\n\n# 或手动在前台启动\nmbridge serve";
|
|
393
|
+
s3.appendChild(el("div", { class: "step-cmd" }, [el("pre", { text: cmd3 })]));
|
|
394
|
+
steps1.appendChild(s3);
|
|
395
|
+
|
|
396
|
+
card1.appendChild(steps1);
|
|
397
|
+
box.appendChild(card1);
|
|
398
|
+
|
|
399
|
+
// Guide card 2: Agent Plugins (Claude Code, Codex, Pi)
|
|
400
|
+
const card2 = el("div", { class: "guide-card" });
|
|
401
|
+
card2.appendChild(el("h2", { text: isZh ? "步骤二:安装 Agent 插件与使用" : "Step 2: Install Agent Plugins & Usage" }));
|
|
402
|
+
card2.appendChild(el("p", { class: "guide-desc", text: isZh ? "主流编码 Agent 均有一键插件支持,按需安装对应客户端:" : "Supported coding agents have dedicated plugins. Install as needed:" }));
|
|
403
|
+
|
|
404
|
+
const steps2 = el("div", { class: "guide-steps" });
|
|
405
|
+
|
|
406
|
+
// Claude Code
|
|
407
|
+
const pClaude = el("div", { class: "guide-step" });
|
|
408
|
+
pClaude.appendChild(el("div", { class: "step-title", text: "Claude Code(≥ v2.1.287)" }));
|
|
409
|
+
const cmdClaude = `# 1. 安装插件\nclaude plugin marketplace add weiping/mbridge\nclaude plugin install mbridge@mbridge\n\n# 2. 项目配置:在项目的 .claude/settings.json 中指定模型\n{\n "model": "group/mbridge[1m]"\n}`;
|
|
410
|
+
pClaude.appendChild(el("div", { class: "step-cmd" }, [el("pre", { text: cmdClaude })]));
|
|
411
|
+
steps2.appendChild(pClaude);
|
|
412
|
+
|
|
413
|
+
// Codex
|
|
414
|
+
const pCodex = el("div", { class: "guide-step" });
|
|
415
|
+
pCodex.appendChild(el("div", { class: "step-title", text: "Codex" }));
|
|
416
|
+
const cmdCodex = `# 1. 安装插件(使用 --sparse 避免拉取庞大历史)\ncodex plugin marketplace add weiping/mbridge --sparse .agents --sparse clients/codex\ncodex plugin add mbridge@mbridge\n\n# 2. 启动使用:通过 mbridge 配置文件启动\ncodex -p mbridge\n# 或在项目的 .codex/config.toml 中指定\nmodel = "group/mbridge"`;
|
|
417
|
+
pCodex.appendChild(el("div", { class: "step-cmd" }, [el("pre", { text: cmdCodex })]));
|
|
418
|
+
steps2.appendChild(pCodex);
|
|
419
|
+
|
|
420
|
+
// Pi
|
|
421
|
+
const pPi = el("div", { class: "guide-step" });
|
|
422
|
+
pPi.appendChild(el("div", { class: "step-title", text: "Pi" }));
|
|
423
|
+
const cmdPi = `# 1. 安装扩展\npi install npm:@weiping/pi-mbridge\n\n# 2. 项目配置:在项目的 .pi/settings.json 中指定\n{\n "defaultProvider": "magpie",\n "defaultModel": "group/mbridge"\n}`;
|
|
424
|
+
pPi.appendChild(el("div", { class: "step-cmd" }, [el("pre", { text: cmdPi })]));
|
|
425
|
+
steps2.appendChild(pPi);
|
|
426
|
+
|
|
427
|
+
card2.appendChild(steps2);
|
|
428
|
+
box.appendChild(card2);
|
|
429
|
+
|
|
430
|
+
// Actions & Live polling indicator
|
|
431
|
+
const actions = el("div", { style: "margin: 1rem 0; display: flex; align-items: center; gap: 1rem;" });
|
|
432
|
+
const refBtn = el("button", { type: "button", class: "guide-refresh", text: isZh ? "🔄 检查服务状态" : "🔄 Check Status" });
|
|
433
|
+
refBtn.onclick = () => {
|
|
434
|
+
refBtn.textContent = isZh ? "正在刷新…" : "Refreshing…";
|
|
435
|
+
if (globalThis.pollMbridge) globalThis.pollMbridge();
|
|
188
436
|
};
|
|
437
|
+
actions.appendChild(refBtn);
|
|
438
|
+
|
|
439
|
+
const tipLink = el("span", { class: "guide-tip", text: isZh ? "mbridge 服务启动后,本页面将自动实时切换为数据监控大盘。" : "Once mbridge starts, this page will automatically display the dashboard." });
|
|
440
|
+
actions.appendChild(tipLink);
|
|
441
|
+
|
|
442
|
+
box.appendChild(actions);
|
|
443
|
+
root.appendChild(box);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function render(data) {
|
|
447
|
+
const root = document.getElementById("root");
|
|
448
|
+
root.textContent = "";
|
|
449
|
+
const t = T();
|
|
450
|
+
const w = data.window || {};
|
|
451
|
+
const turns = turnsOf(data);
|
|
452
|
+
const turnsCount = Object.values(w.tiers || {}).reduce((a, b) => a + b, 0);
|
|
453
|
+
const cov = w.coverage || { asked: 0, total: 0 };
|
|
454
|
+
const reasons = w.reasons || {};
|
|
455
|
+
const topReason = Object.keys(reasons).sort((a, b) => reasons[b] - reasons[a])[0] || "";
|
|
456
|
+
const cost = (w.report && w.report.router && w.report.router.total_cost) || 0;
|
|
457
|
+
|
|
458
|
+
// the headline: mbridge's own numbers, not the process table
|
|
459
|
+
root.appendChild(el("header", { class: "head" }, [
|
|
460
|
+
el("h1", {}, [el("span", { text: "mbridge" }),
|
|
461
|
+
el("span", { class: "version", text: " " + (data.version || "") })]),
|
|
462
|
+
el("p", { class: "sub", text: t.subtitle }),
|
|
463
|
+
]));
|
|
464
|
+
|
|
465
|
+
const tabs = el("div", { class: "tabs" });
|
|
466
|
+
for (const kind of ["1d", "7d", "30d"]) {
|
|
467
|
+
tabs.appendChild(el("button", { type: "button", "aria-pressed": String(w.kind === kind),
|
|
468
|
+
text: kind, onclick: () => { current.window = kind; load(); } }));
|
|
469
|
+
}
|
|
470
|
+
tabs.appendChild(el("span", { class: "muted", text: t.window }));
|
|
471
|
+
|
|
472
|
+
root.appendChild(el("div", { class: "pulse" }, [
|
|
473
|
+
el("div", { class: "stat" }, [el("b", { text: String(turnsCount) }), el("span", { text: " " + t.turns })]),
|
|
474
|
+
el("div", { class: "stat" }, [el("b", { text: pct(cov.asked, cov.total) }),
|
|
475
|
+
el("span", { text: " " + t.coverage })]),
|
|
476
|
+
el("div", { class: "stat" }, [el("b", { text: String((w.classify || {}).failures || 0) }),
|
|
477
|
+
el("span", { text: " " + t.failures })]),
|
|
478
|
+
el("div", { class: "stat" }, [el("b", { text: money(cost) }), el("span", { text: " " + t.cost })]),
|
|
479
|
+
topReason ? el("div", { class: "stat" }, [el("b", { text: topReason }),
|
|
480
|
+
el("span", { text: " " + t.topReason })]) : null,
|
|
481
|
+
el("div", { class: "tabs" }, tabs),
|
|
482
|
+
]));
|
|
483
|
+
|
|
484
|
+
if (data.listen_pending) {
|
|
485
|
+
root.appendChild(el("div", { class: "banner", text: t.listenPending || "the listen in router.json takes effect only after a restart" }));
|
|
486
|
+
}
|
|
487
|
+
if (data.config_error) {
|
|
488
|
+
root.appendChild(el("div", { class: "banner bad", text: "router.json: " + data.config_error }));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// the process, one line
|
|
492
|
+
const svc = data.service || {};
|
|
493
|
+
root.appendChild(el("div", { class: "procline" }, [
|
|
494
|
+
el("span", { text: "pid " + (data.pid || "") }),
|
|
495
|
+
el("span", { text: "↑ " + Math.round((data.uptime_s || 0) / 60) + "m" }),
|
|
496
|
+
el("span", { text: data.listen || "" }),
|
|
497
|
+
el("span", { text: (data.magpie_ok ? "magpie " + (data.magpie_version || "") : "magpie —") }),
|
|
498
|
+
el("span", { text: "service " + (svc.installed ? (svc.running ? t.running : t.notRunning) : t.notInstalled) }),
|
|
499
|
+
]));
|
|
500
|
+
|
|
501
|
+
// the sessions that are running now
|
|
502
|
+
root.appendChild(el("h2", { text: t.live }));
|
|
503
|
+
const live = data.live || [];
|
|
504
|
+
if (live.length) {
|
|
505
|
+
const cards = el("div", { class: "cards" });
|
|
506
|
+
for (const c of live) cards.appendChild(liveCard(c));
|
|
507
|
+
root.appendChild(cards);
|
|
508
|
+
const extra = (data.live_total || 0) - live.length;
|
|
509
|
+
if (extra > 0) root.appendChild(el("div", { class: "muted", text: "+" + extra }));
|
|
510
|
+
} else {
|
|
511
|
+
root.appendChild(el("div", { class: "muted", text: t.noLive }));
|
|
512
|
+
// nothing is running: show the turns that just went through, so the
|
|
513
|
+
// section is not empty
|
|
514
|
+
if (turns.length) {
|
|
515
|
+
root.appendChild(el("h3", { class: "muted", text: t.justWent }));
|
|
516
|
+
const cards = el("div", { class: "cards" });
|
|
517
|
+
for (const ev of turns.slice(0, 4)) {
|
|
518
|
+
cards.appendChild(el("div", { class: "card" }, [
|
|
519
|
+
el("div", { class: "row1" }, [
|
|
520
|
+
el("span", { text: (ev.harness || t.unknown) + " · " + (ev.agent || "") }),
|
|
521
|
+
el("span", {}, [ev.tier, " ", tierChip(ev.tier || "?")]),
|
|
522
|
+
]),
|
|
523
|
+
el("div", { class: "row2", text: clock(ev.t) + " · " + (ev.session || "").slice(0, 8) }),
|
|
524
|
+
el("div", { class: "row3 muted", text: reasonText(ev.reason) }),
|
|
525
|
+
]));
|
|
526
|
+
}
|
|
527
|
+
root.appendChild(cards);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// the decisions
|
|
532
|
+
root.appendChild(el("h2", { text: t.decisions }));
|
|
533
|
+
if (!turns.length) {
|
|
534
|
+
root.appendChild(el("div", { class: "muted", text: t.noDecisions }));
|
|
535
|
+
} else {
|
|
536
|
+
root.appendChild(decisionTable(turns));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// the classifier
|
|
540
|
+
root.appendChild(el("h2", { text: t.classifier }));
|
|
541
|
+
const cl = w.classify || { asked: 0, failures: 0, error_kinds: {} };
|
|
542
|
+
root.appendChild(kv([
|
|
543
|
+
[t.coverage, pct(cov.asked, cov.total) + " (" + cov.asked + "/" + cov.total + ")"],
|
|
544
|
+
[t.failures, cl.failures + " / " + cl.asked],
|
|
545
|
+
["", el("span", { class: "muted", text: t.askHint })],
|
|
546
|
+
]));
|
|
547
|
+
if (cl.failures) {
|
|
548
|
+
const ul = el("ul", { class: "fixes" });
|
|
549
|
+
for (const [kind, n] of Object.entries(cl.error_kinds || {})) {
|
|
550
|
+
ul.appendChild(el("li", { text: kind + " × " + n + " — " + ((t.failureFix || {})[kind] || "") }));
|
|
551
|
+
}
|
|
552
|
+
root.appendChild(ul);
|
|
553
|
+
}
|
|
554
|
+
if (turns.length) {
|
|
555
|
+
const gt = el("table", { class: "wide" });
|
|
556
|
+
gt.appendChild(el("thead", {}, el("tr", {}, [
|
|
557
|
+
el("th", { text: "" }), el("th", { text: t.harness }),
|
|
558
|
+
el("th", { text: t.tierConf }), el("th", { text: t.dissatisfied }), el("th", { text: t.compact }),
|
|
559
|
+
el("th", { class: "num", text: t.classifyMs }), el("th", { class: "num", text: t.latency }),
|
|
560
|
+
])));
|
|
561
|
+
const body = el("tbody");
|
|
562
|
+
for (const ev of turns.slice(0, 10)) {
|
|
563
|
+
const asked = !!ev.classifier || !!ev.classify_error;
|
|
564
|
+
body.appendChild(el("tr", { class: asked ? "" : "off" }, [
|
|
565
|
+
el("td", { class: "muted", text: clock(ev.t) }),
|
|
566
|
+
el("td", { text: ev.harness || t.unknown }),
|
|
567
|
+
el("td", {}, scoreCell(ev.tier_confidence, asked)),
|
|
568
|
+
el("td", {}, scoreCell(ev.dissatisfied, asked)),
|
|
569
|
+
el("td", {}, scoreCell(ev.compact, asked)),
|
|
570
|
+
el("td", { class: "num", text: asked && ev.classify_ms ? ev.classify_ms + "ms" :
|
|
571
|
+
(ev.classify_error ? t.failed : t.notMeasured) }),
|
|
572
|
+
el("td", { class: "num", text: ev.latency_ms ? ev.latency_ms + "ms" : t.notMeasured }),
|
|
573
|
+
]));
|
|
574
|
+
}
|
|
575
|
+
gt.appendChild(body);
|
|
576
|
+
root.appendChild(gt);
|
|
189
577
|
}
|
|
190
578
|
|
|
579
|
+
// the distributions
|
|
580
|
+
root.appendChild(el("h2", { text: t.distributions }));
|
|
581
|
+
const dists = el("div", { class: "dists" });
|
|
582
|
+
dists.appendChild(distribution("tier", w.tiers, turnsCount));
|
|
583
|
+
dists.appendChild(distribution("reason", w.reasons, turnsCount));
|
|
584
|
+
dists.appendChild(distribution("harness", w.harnesses, turnsCount));
|
|
585
|
+
dists.appendChild(distribution("arm", w.arms, Object.values(w.arms || {}).reduce((a, b) => a + b, 0)));
|
|
586
|
+
root.appendChild(dists);
|
|
587
|
+
|
|
588
|
+
// the two arms
|
|
589
|
+
root.appendChild(el("h2", { text: t.arms }));
|
|
590
|
+
const rep = w.report || {};
|
|
591
|
+
const rows = [];
|
|
592
|
+
for (const arm of ["router", "control"]) {
|
|
593
|
+
const a = rep[arm];
|
|
594
|
+
if (!a || !a.sessions) continue; // an arm with nothing in it is not drawn
|
|
595
|
+
rows.push([arm, String(a.sessions), money(a.cost_median || 0), money(a.cost_p90 || 0),
|
|
596
|
+
String(a.merged_sessions || 0), String(a.manual_switch_sessions || 0)]);
|
|
597
|
+
}
|
|
598
|
+
if (rows.length) {
|
|
599
|
+
const at = el("table");
|
|
600
|
+
at.appendChild(el("thead", {}, el("tr", {}, [
|
|
601
|
+
el("th", { text: "arm" }), el("th", { class: "num", text: t.sessions }),
|
|
602
|
+
el("th", { class: "num", text: t.median }), el("th", { class: "num", text: t.p90 }),
|
|
603
|
+
el("th", { class: "num", text: t.merged }), el("th", { class: "num", text: t.manual }),
|
|
604
|
+
])));
|
|
605
|
+
const body = el("tbody");
|
|
606
|
+
for (const r of rows) {
|
|
607
|
+
body.appendChild(el("tr", {}, r.map((cell, i) => el("td", { class: i ? "num" : "", text: cell }))));
|
|
608
|
+
}
|
|
609
|
+
at.appendChild(body);
|
|
610
|
+
root.appendChild(at);
|
|
611
|
+
} else {
|
|
612
|
+
root.appendChild(el("div", { class: "muted", text: "—" }));
|
|
613
|
+
}
|
|
614
|
+
const notes = [t.feedbackOnly,
|
|
615
|
+
t.unpriced + ": " + ((rep.router || {}).unpriced_requests || 0),
|
|
616
|
+
t.cacheWrite + ": " + money((rep.router || {}).cache_write_cost || 0)];
|
|
617
|
+
if (rep.sample_short) notes.push(t.sampleShort);
|
|
618
|
+
root.appendChild(el("div", { class: "muted", text: notes.join(" · ") }));
|
|
619
|
+
for (const c of w.calibrate || []) root.appendChild(calibrationTable(c));
|
|
620
|
+
|
|
621
|
+
// the configuration, last: it is reference, not the story
|
|
622
|
+
const cfg = data.config || {};
|
|
623
|
+
const th = cfg.thresholds || {};
|
|
624
|
+
const details = el("details", { class: "cfg" });
|
|
625
|
+
details.appendChild(el("summary", { text: t.config }));
|
|
626
|
+
details.appendChild(kv([
|
|
627
|
+
["router_group", cfg.router_group],
|
|
628
|
+
["default_tier", cfg.default_tier],
|
|
629
|
+
["classifier", cfg.classifier],
|
|
630
|
+
["classify_timeout_ms", String(cfg.classify_timeout_ms || "")],
|
|
631
|
+
["escalate_turns", String(cfg.escalate_turns || "")],
|
|
632
|
+
["cache_ttl_seconds", String(cfg.cache_ttl_seconds || "")],
|
|
633
|
+
["thresholds", "tier_min " + th.tier_min + " · dissatisfied " + th.dissatisfied_min +
|
|
634
|
+
" · review " + th.review_min + " · compact " + th.compact_min],
|
|
635
|
+
["review", (cfg.review || {}).mode + " · " + (cfg.review || {}).timeout_ms + "ms"],
|
|
636
|
+
["experiment", ((cfg.experiment || {}).enabled ? "on " : "off ") +
|
|
637
|
+
(cfg.experiment || {}).router_percent + "% → " + (cfg.experiment || {}).control_tier],
|
|
638
|
+
["fixed_agents", Object.entries(cfg.fixed_agents || {}).map(([a, tier]) => a + " → " + tier).join(", ")],
|
|
639
|
+
["tiers", TIERS.map((name) => {
|
|
640
|
+
const tc = (cfg.tiers || {})[name] || {};
|
|
641
|
+
return name + " → " + (tc.group || "?") + " (" + (tc.claude_alias || "?") + ")";
|
|
642
|
+
}).join(" · ")],
|
|
643
|
+
["", el("span", { class: "muted", text: t.configNote })],
|
|
644
|
+
]));
|
|
645
|
+
root.appendChild(details);
|
|
646
|
+
|
|
647
|
+
// the restart button
|
|
648
|
+
const btn = el("button", { type: "button", class: "restart", text: t.restart });
|
|
649
|
+
btn.addEventListener("click", async () => {
|
|
650
|
+
btn.disabled = true;
|
|
651
|
+
btn.textContent = t.restarting;
|
|
652
|
+
try {
|
|
653
|
+
const res = await fetch("/v1/bridge/restart", { method: "POST" });
|
|
654
|
+
// the instruction is out, not the restart: the pid tells us when
|
|
655
|
+
btn.textContent = res.status === 202 ? t.restartAsked : t.restartRefused;
|
|
656
|
+
if (res.status !== 202) btn.disabled = false;
|
|
657
|
+
} catch (err) {
|
|
658
|
+
btn.textContent = String((err && err.message) || err);
|
|
659
|
+
btn.disabled = false;
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
root.appendChild(el("div", { class: "restartrow" }, btn));
|
|
663
|
+
|
|
664
|
+
// the honest boundaries
|
|
665
|
+
const foot = el("div", { class: "foot" });
|
|
666
|
+
foot.appendChild(el("ul", {}, t.foot.map((line) => el("li", { text: line }))));
|
|
667
|
+
root.appendChild(foot);
|
|
668
|
+
}
|
|
669
|
+
|
|
191
670
|
globalThis.MbridgeView = {
|
|
192
671
|
TEXT,
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
672
|
+
REASONS,
|
|
673
|
+
TIERS,
|
|
674
|
+
el,
|
|
675
|
+
kv,
|
|
676
|
+
pct,
|
|
677
|
+
money,
|
|
678
|
+
ago,
|
|
679
|
+
clock,
|
|
680
|
+
reasonText,
|
|
681
|
+
tierChip,
|
|
682
|
+
turnsOf,
|
|
683
|
+
scoreCell,
|
|
684
|
+
bar,
|
|
685
|
+
liveCard,
|
|
686
|
+
decisionTable,
|
|
687
|
+
distribution,
|
|
688
|
+
calibrationTable,
|
|
689
|
+
render,
|
|
690
|
+
renderGuide,
|
|
691
|
+
detectOS,
|
|
196
692
|
};
|
|
197
693
|
})();
|