@weiping/mbridge-page 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,44 @@
1
- # Temporary Holding Version
1
+ # mbridge's page for magpie
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ magpie 窗口侧栏 Plugins 组里的一个页面:magpie 记下的用量速览。
4
+
5
+ ## 它显示什么
6
+
7
+ - **周期切换**(今天 / 7 天 / 30 天)与**排序**(Token / 调用 / 费用)
8
+ - 四块:调用、错误(占调用的比例)、Token、费用
9
+ - 模型表:按所选排序,每行一条占比条
10
+ - 每 60 秒刷新一次,周期与排序记在 magpie 的 options 里
11
+
12
+ ## 它不显示什么
13
+
14
+ **mbridge 自己的状态不在这里。** magpie 的页面插件跑在 `iframe sandbox="allow-scripts"`
15
+ 里,CSP 是 `connect-src 'none'`:它连不上 `127.0.0.1:3426`,也读不了 `router.json`。
16
+ 所以状态、报表、重启都在 mbridge 自己的状态页:
17
+
18
+ http://127.0.0.1:3426/status
19
+
20
+ 页面上把那行地址做成**可选择的文字**而不是链接:沙盒允许导航自己,但那是单程票
21
+ (magpie 的 sidebar 不会把 frame 的 src 重置回来)。
22
+
23
+ **不做档位归因**:magpie 记下的是最终回答的模型,`usage:summary` 里没有档位。
24
+
25
+ ## 装法
26
+
27
+ 文件夹插件,改了源码在终端重开一个 `magpie` 命令就是新的:
28
+
29
+ magpie plugin add ./clients/magpie-page
30
+
31
+ 确认装上了(`plugin list` 这个子命令不存在,用 `--json`):
32
+
33
+ magpie plugin --json
34
+
35
+ ## 这个包必须永远是纯页面插件
36
+
37
+ `package.json` 里**没有** `main`、**没有** `exports`,这不是偏好:
38
+ magpie 的 `magpie.options.set()` 对纯窗口插件(有 `page`、无 `main`/`exports`)
39
+ 不重启插件宿主,而 CLI 写 options 每次都会。一旦给这个包加了 `main` 或 `exports`,
40
+ 用户每点一次周期切换就可能把所有插件重启一轮。`page.test.mjs` 里有一条守着。
41
+
42
+ ## 测试
43
+
44
+ bun test clients/magpie-page
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@weiping/mbridge-page",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "mbridge's usage at a glance inside magpie: what magpie's ledger recorded, and where mbridge's own status page is.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "magpie",
8
+ "magpie-plugin",
9
+ "mbridge",
10
+ "model-router"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/weiping/mbridge.git",
15
+ "directory": "clients/magpie-page"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/weiping/mbridge/issues"
19
+ },
20
+ "files": [
21
+ "page",
22
+ "README.md"
23
+ ],
24
+ "magpie": {
25
+ "page": {
26
+ "title": "mbridge",
27
+ "titles": {
28
+ "zh": "mbridge",
29
+ "zh-TW": "mbridge",
30
+ "ja": "mbridge",
31
+ "de": "mbridge"
32
+ },
33
+ "file": "page/index.html",
34
+ "icon": "page/icon.svg"
35
+ },
36
+ "permissions": ["usage:summary"]
37
+ }
38
+ }
package/page/app.js ADDED
@@ -0,0 +1,161 @@
1
+ /* app.js reads magpie's ledger and draws it. It never reaches mbridge:
2
+ the sandbox a magpie page runs in has no network at all, and the only
3
+ thing this page needs is what magpie already recorded (SP14 §2). */
4
+
5
+ import { tiles, rows, periods, sorts, header } from "./view.js";
6
+
7
+ const REFRESH_MS = 60000; // magpie's ledger moves slowly enough
8
+ const STATUS_URL = "http://127.0.0.1:3426/status";
9
+ const OPTIONS_KEY = "mbridge-page"; // magpie options keep the period and the sort
10
+
11
+ const state = { period: "7d", sort: "tokens", summary: null, error: null, lang: "en" };
12
+
13
+ function $(id) {
14
+ return document.getElementById(id);
15
+ }
16
+
17
+ function segment(container, items, current, onPick) {
18
+ container.textContent = "";
19
+ for (const item of items) {
20
+ const b = document.createElement("button");
21
+ b.type = "button";
22
+ b.className = "seg" + (item.key === current ? " seg-on" : "");
23
+ b.textContent = item.label;
24
+ b.addEventListener("click", () => onPick(item.key));
25
+ container.appendChild(b);
26
+ }
27
+ }
28
+
29
+ function render() {
30
+ const t = header(state.summary, state.lang);
31
+ $("title").textContent = t.title;
32
+ $("subtitle").textContent = state.summary ? t.subtitle : "";
33
+ if (t.since) $("subtitle").textContent += " · " + t.since;
34
+
35
+ segment($("periods"), periods(state.lang), state.period, (k) => {
36
+ if (k === state.period) return;
37
+ state.period = k;
38
+ save();
39
+ load();
40
+ });
41
+ segment($("sorts"), sorts(state.lang), state.sort, (k) => {
42
+ state.sort = k;
43
+ save();
44
+ render();
45
+ });
46
+
47
+ const box = $("tiles");
48
+ box.textContent = "";
49
+ for (const tile of tiles(state.summary, state.lang)) {
50
+ const cell = document.createElement("div");
51
+ cell.className = "tile";
52
+ const label = document.createElement("div");
53
+ label.className = "tile-label";
54
+ label.textContent = tile.label;
55
+ const value = document.createElement("div");
56
+ value.className = "tile-value";
57
+ value.textContent = tile.value;
58
+ cell.appendChild(label);
59
+ cell.appendChild(value);
60
+ if (tile.sub) {
61
+ const sub = document.createElement("div");
62
+ sub.className = "tile-sub";
63
+ sub.textContent = tile.sub;
64
+ cell.appendChild(sub);
65
+ }
66
+ box.appendChild(cell);
67
+ }
68
+
69
+ const r = rows(state.summary, state.sort, state.lang);
70
+ $("models-label").textContent = r.label;
71
+ $("models-sort").textContent = r.sortLabel;
72
+ $("th-model").textContent = viewText("model");
73
+ $("th-calls").textContent = viewText("calls");
74
+ $("th-tokens").textContent = viewText("tokens");
75
+ $("th-cost").textContent = viewText("cost");
76
+
77
+ const body = $("rows");
78
+ body.textContent = "";
79
+ for (const item of r.items) {
80
+ const tr = document.createElement("tr");
81
+ const name = document.createElement("td");
82
+ name.className = "model";
83
+ name.textContent = item.model;
84
+ const bar = document.createElement("span");
85
+ bar.className = "bar";
86
+ const fill = document.createElement("i");
87
+ fill.style.width = Math.round(item.shareValue * 100) + "%";
88
+ bar.appendChild(fill);
89
+ name.appendChild(bar);
90
+ tr.appendChild(name);
91
+ for (const key of ["calls", "tokens", "cost"]) {
92
+ const td = document.createElement("td");
93
+ td.className = "num";
94
+ td.textContent = item[key];
95
+ tr.appendChild(td);
96
+ }
97
+ body.appendChild(tr);
98
+ }
99
+ $("empty").textContent = r.items.length ? "" : t.emptyText;
100
+
101
+ $("disclaimer").textContent = t.disclaimer;
102
+ $("status-label").textContent = t.status + " ";
103
+ // the address is selectable text, not a link: the sandbox can navigate
104
+ // itself, but that is a one-way ticket (SP14 §2)
105
+ $("status-addr").textContent = t.statusWhere + " " + STATUS_URL;
106
+ $("restart-label").textContent = " · " + t.restart + " ";
107
+ $("error").textContent = state.error ? state.error + " · " + t.retry : "";
108
+ }
109
+
110
+ // viewText is one word from the page's vocabulary.
111
+ function viewText(key) {
112
+ const v = globalThis.MbridgeView;
113
+ const l = state.lang === "zh" ? "zh" : "en";
114
+ return v.TEXT[l][key] || key;
115
+ }
116
+
117
+ async function load() {
118
+ try {
119
+ state.summary = await magpie.read("usage:summary", { period: state.period });
120
+ state.error = null;
121
+ } catch (err) {
122
+ state.error = (err && err.message) || String(err);
123
+ }
124
+ render();
125
+ }
126
+
127
+ // save remembers the period and the sort in magpie's own options, so the
128
+ // page comes back the way it was left. magpie takes one write a second.
129
+ async function save() {
130
+ try {
131
+ const opts = (await magpie.options.get()) || {};
132
+ opts[OPTIONS_KEY] = { period: state.period, sort: state.sort };
133
+ await magpie.options.set(opts);
134
+ } catch {
135
+ // not being able to remember is not worth an error on the page
136
+ }
137
+ }
138
+
139
+ async function restore() {
140
+ try {
141
+ const opts = (await magpie.options.get()) || {};
142
+ const mine = opts[OPTIONS_KEY];
143
+ if (mine && mine.period) state.period = mine.period;
144
+ if (mine && mine.sort) state.sort = mine.sort;
145
+ } catch {
146
+ // the defaults stand
147
+ }
148
+ }
149
+
150
+ (async function start() {
151
+ await magpie.ready;
152
+ const theme = magpie.theme || {};
153
+ if ((theme.lang || "").toLowerCase().startsWith("zh")) state.lang = "zh";
154
+ if (magpie.onTheme) magpie.onTheme((t) => {
155
+ if ((t && t.lang || "").toLowerCase().startsWith("zh")) state.lang = "zh";
156
+ render();
157
+ });
158
+ await restore();
159
+ await load();
160
+ setInterval(load, REFRESH_MS);
161
+ })();
package/page/icon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"
2
+ stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"
3
+ role="img" aria-label="mbridge">
4
+ <path d="M2 15c4.5 0 4.5-4 9-4s4.5 4 9 4" />
5
+ <path d="M2 19c4.5 0 4.5-4 9-4s4.5 4 9 4" opacity=".45" />
6
+ <path d="M5 15V9m7 6V9m7 6V9" />
7
+ </svg>
@@ -0,0 +1,58 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>mbridge</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ <script src="_magpie/bridge.js"></script>
9
+ <script src="view.js"></script>
10
+ </head>
11
+ <body>
12
+ <main id="root" class="root">
13
+ <header class="head">
14
+ <h1 id="title" class="title">mbridge</h1>
15
+ <p id="subtitle" class="subtitle"></p>
16
+ </header>
17
+
18
+ <div class="bar">
19
+ <div id="periods" class="segmented"></div>
20
+ <div id="sorts" class="segmented"></div>
21
+ </div>
22
+
23
+ <section id="tiles" class="tiles"></section>
24
+
25
+ <section class="models">
26
+ <div class="models-head">
27
+ <h2 id="models-label" class="label"></h2>
28
+ <span id="models-sort" class="muted"></span>
29
+ </div>
30
+ <table class="table">
31
+ <thead>
32
+ <tr>
33
+ <th id="th-model"></th>
34
+ <th class="num" id="th-calls"></th>
35
+ <th class="num" id="th-tokens"></th>
36
+ <th class="num" id="th-cost"></th>
37
+ </tr>
38
+ </thead>
39
+ <tbody id="rows"></tbody>
40
+ </table>
41
+ <p id="empty" class="muted empty"></p>
42
+ </section>
43
+
44
+ <section class="notes">
45
+ <p id="disclaimer" class="note"></p>
46
+ <p class="note">
47
+ <span id="status-label"></span>
48
+ <span id="status-addr" class="addr" title="select and copy it"></span>
49
+ <span id="restart-label"></span>
50
+ <code class="cmd">mbridge service restart</code>
51
+ </p>
52
+ </section>
53
+
54
+ <p id="error" class="error"></p>
55
+ </main>
56
+ <script src="app.js" defer></script>
57
+ </body>
58
+ </html>
package/page/style.css ADDED
@@ -0,0 +1,46 @@
1
+ /* Only magpie's own variables, so the page follows the window's theme
2
+ in both light and dark without knowing either. */
3
+ .root {
4
+ --pad: 12px;
5
+ padding: var(--pad);
6
+ font: 12px/1.5 var(--magpie-font, system-ui, -apple-system, "Segoe UI", sans-serif);
7
+ color: var(--magpie-fg, #1a1a1a);
8
+ background: var(--magpie-bg, transparent);
9
+ }
10
+ .title { margin: 0; font-size: 1.05em; font-weight: 600; }
11
+ .subtitle { margin: 2px 0 10px; color: var(--magpie-fg-muted, #6b6b6b); }
12
+ .bar { display: flex; gap: 8px; justify-content: space-between; flex-wrap: wrap; margin-bottom: 10px; }
13
+ .segmented { display: flex; gap: 2px; }
14
+ .seg {
15
+ font: inherit; color: inherit; cursor: pointer;
16
+ padding: 2px 8px; border-radius: 5px;
17
+ border: 1px solid var(--magpie-border, #d8d8d8);
18
+ background: var(--magpie-bg-subtle, transparent);
19
+ }
20
+ .seg-on { background: var(--magpie-accent, #2f6feb); border-color: transparent; color: #fff; }
21
+ .tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 14px; }
22
+ .tile {
23
+ padding: 6px 8px; border-radius: 6px;
24
+ border: 1px solid var(--magpie-border, #e3e3e3);
25
+ background: var(--magpie-bg-subtle, transparent);
26
+ }
27
+ .tile-label { color: var(--magpie-fg-muted, #6b6b6b); }
28
+ .tile-value { font-size: 1.15em; font-weight: 600; font-variant-numeric: tabular-nums; }
29
+ .tile-sub { color: var(--magpie-fg-muted, #6b6b6b); }
30
+ .label { margin: 0; font-size: 1em; font-weight: 600; }
31
+ .models-head { display: flex; justify-content: space-between; align-items: baseline; }
32
+ .muted { color: var(--magpie-fg-muted, #6b6b6b); }
33
+ .table { width: 100%; border-collapse: collapse; margin-top: 4px; }
34
+ .table th, .table td { text-align: left; padding: 3px 4px; border-bottom: 1px solid var(--magpie-border, #e3e3e3); }
35
+ .table .num { text-align: right; font-variant-numeric: tabular-nums; }
36
+ .table th.num { text-align: right; color: var(--magpie-fg-muted, #6b6b6b); font-weight: 500; }
37
+ .model { width: 55%; overflow-wrap: anywhere; }
38
+ .bar { display: block; height: 2px; margin-top: 2px; background: var(--magpie-border, #e3e3e3); border-radius: 1px; overflow: hidden; }
39
+ .bar > i { display: block; height: 100%; background: var(--magpie-accent, #2f6feb); }
40
+ .empty { margin: 6px 0; }
41
+ .notes { margin-top: 14px; }
42
+ .note { margin: 4px 0; color: var(--magpie-fg-muted, #6b6b6b); }
43
+ /* the address is selectable text, not a link */
44
+ .addr { user-select: all; color: var(--magpie-fg, inherit); }
45
+ .cmd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--magpie-fg, inherit); }
46
+ .error { color: var(--magpie-danger, #b42318); min-height: 1em; margin: 8px 0 0; }
package/page/view.js ADDED
@@ -0,0 +1,178 @@
1
+ // view.js is mbridge's magpie page: the words and the arithmetic, with
2
+ // no DOM in it, so the page and the tests can both use it.
3
+ //
4
+ // The numbers come from magpie's own ledger (`magpie.read("usage:summary")`),
5
+ // never from mbridge: the page runs in a sandbox that cannot reach mbridge
6
+ // at all (SP14 §2). Nothing here knows about tiers — magpie records the
7
+ // model that answered, and mbridge's group is not in there.
8
+
9
+ const TEXT = {
10
+ en: {
11
+ title: "mbridge",
12
+ subtitle: "what magpie's ledger recorded",
13
+ calls: "calls",
14
+ errors: "errors",
15
+ errorShare: "of the calls",
16
+ tokens: "tokens",
17
+ cost: "cost",
18
+ models: "by model",
19
+ model: "model",
20
+ period: { today: "today", "7d": "7 days", "30d": "30 days" },
21
+ sort: { tokens: "tokens", calls: "calls", cost: "cost" },
22
+ empty: "no calls in this period",
23
+ error: "magpie's usage could not be read",
24
+ retry: "try again in a minute",
25
+ since: "since",
26
+ total: "total",
27
+ disclaimer:
28
+ "This is everything magpie recorded. It is mbridge's usage only when every client goes through mbridge; whatever you use magpie for directly is in here too.",
29
+ status: "mbridge's own status",
30
+ statusWhere: "mbridge's status page:",
31
+ restart: "restart it with",
32
+ },
33
+ zh: {
34
+ title: "mbridge",
35
+ subtitle: "magpie 记下的全部用量",
36
+ calls: "调用",
37
+ errors: "错误",
38
+ errorShare: "占调用",
39
+ tokens: "Token",
40
+ cost: "费用",
41
+ models: "按模型",
42
+ model: "模型",
43
+ period: { today: "今天", "7d": "7 天", "30d": "30 天" },
44
+ sort: { tokens: "Token", calls: "调用", cost: "费用" },
45
+ empty: "这个区间没有调用",
46
+ error: "读不到 magpie 的用量",
47
+ retry: "一分钟后自动重试",
48
+ since: "起",
49
+ total: "合计",
50
+ disclaimer:
51
+ "这是 magpie 记下的全部用量。客户端都走 mbridge 时它才等于 mbridge 的用量;你直接用 magpie 的那部分也在里面。",
52
+ status: "mbridge 自己的状态",
53
+ statusWhere: "mbridge 的状态页:",
54
+ restart: "重启用",
55
+ },
56
+ };
57
+
58
+ /** count is a number a person reads: 1,234 or 1.23M. */
59
+ export function count(n) {
60
+ const v = Number(n) || 0;
61
+ if (Math.abs(v) >= 1e9) return (v / 1e9).toFixed(2) + "B";
62
+ if (Math.abs(v) >= 1e6) return (v / 1e6).toFixed(2) + "M";
63
+ if (Math.abs(v) >= 1e3) return v.toLocaleString("en-US");
64
+ return String(v);
65
+ }
66
+
67
+ /** money is a price, never NaN, and never "$0.00" for something real. */
68
+ export function money(n) {
69
+ const v = Number(n);
70
+ if (!isFinite(v)) return "$0";
71
+ if (v === 0) return "$0";
72
+ if (v > 0 && v < 0.01) return "<$0.01";
73
+ if (v < 10) return "$" + v.toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
74
+ if (v < 1000) return "$" + v.toFixed(2);
75
+ return "$" + v.toFixed(0);
76
+ }
77
+
78
+ /** share is a percentage, and never NaN. */
79
+ export function pct(part, whole) {
80
+ const p = Number(part) || 0;
81
+ const w = Number(whole) || 0;
82
+ if (w <= 0) return "0%";
83
+ const v = (100 * p) / w;
84
+ if (!isFinite(v)) return "0%";
85
+ return (Math.round(v * 10) / 10).toString() + "%";
86
+ }
87
+
88
+ function lang(l) {
89
+ return l === "zh" ? "zh" : "en";
90
+ }
91
+
92
+ /** tiles are the four numbers at the top: calls, errors, tokens, cost. */
93
+ export function tiles(summary, l = "en") {
94
+ const t = TEXT[lang(l)];
95
+ const s = summary || {};
96
+ return [
97
+ { key: "calls", label: t.calls, value: count(s.calls) },
98
+ {
99
+ key: "errors",
100
+ label: t.errors,
101
+ value: count(s.errors),
102
+ sub: t.errorShare + " " + pct(s.errors, s.calls),
103
+ },
104
+ { key: "tokens", label: t.tokens, value: count((s.input || 0) + (s.output || 0)) },
105
+ { key: "cost", label: t.cost, value: money(s.cost) },
106
+ ];
107
+ }
108
+
109
+ /** rows are the model table, sorted by tokens, calls or cost, with each
110
+ * model's share of the total. */
111
+ export function rows(summary, sort = "tokens", l = "en") {
112
+ const t = TEXT[lang(l)];
113
+ const s = summary || {};
114
+ const models = Array.isArray(s.models) ? s.models : [];
115
+ const key = sort === "calls" ? "calls" : sort === "cost" ? "cost" : "tokens";
116
+ const value = (m) =>
117
+ key === "tokens" ? (m.input || 0) + (m.output || 0) : m[key] || 0;
118
+ const sorted = models.slice().sort((a, b) => value(b) - value(a));
119
+ const total = sorted.reduce((a, m) => a + value(m), 0);
120
+ const labels = t.sort;
121
+ return {
122
+ sort: key,
123
+ label: t.models,
124
+ sortLabel: labels[key] || key,
125
+ total,
126
+ items: sorted.map((m) => ({
127
+ model: m.model || "?",
128
+ calls: count(m.calls),
129
+ tokens: count((m.input || 0) + (m.output || 0)),
130
+ cost: money(m.cost),
131
+ share: pct(value(m), total),
132
+ shareValue: total > 0 ? value(m) / total : 0,
133
+ })),
134
+ };
135
+ }
136
+
137
+ /** period labels, in the order magpie offers them. */
138
+ export function periods(l = "en") {
139
+ const t = TEXT[lang(l)];
140
+ return ["today", "7d", "30d"].map((k) => ({ key: k, label: t.period[k] }));
141
+ }
142
+
143
+ /** sorts, in the order the page offers them. */
144
+ export function sorts(l = "en") {
145
+ const t = TEXT[lang(l)];
146
+ return ["tokens", "calls", "cost"].map((k) => ({ key: k, label: t.sort[k] }));
147
+ }
148
+
149
+ /** header is what the page says above the numbers. */
150
+ export function header(summary, l = "en") {
151
+ const t = TEXT[lang(l)];
152
+ const s = summary || {};
153
+ return {
154
+ title: t.title,
155
+ subtitle: t.subtitle,
156
+ since: s.since ? t.since + " " + s.since : "",
157
+ empty: !s.calls,
158
+ emptyText: t.empty,
159
+ error: t.error,
160
+ retry: t.retry,
161
+ disclaimer: t.disclaimer,
162
+ status: t.status,
163
+ statusWhere: t.statusWhere,
164
+ restart: t.restart,
165
+ };
166
+ }
167
+
168
+ globalThis.MbridgeView = {
169
+ TEXT,
170
+ count,
171
+ money,
172
+ pct,
173
+ tiles,
174
+ rows,
175
+ periods,
176
+ sorts,
177
+ header,
178
+ };