@weiping/mbridge-page 0.0.0-stage → 0.1.1

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,44 @@
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.1",
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": [
37
+ "usage:summary"
38
+ ],
39
+ "options": {
40
+ "period": "7d",
41
+ "sort": "tokens"
42
+ }
43
+ }
44
+ }
package/page/app.js ADDED
@@ -0,0 +1,153 @@
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 (connect-src
3
+ // 'none'), and the only thing this page needs is what magpie already
4
+ // recorded (SP14 §2).
5
+ //
6
+ // A classic script, like the community's usage page: it reads the view
7
+ // from globalThis.
8
+ (async () => {
9
+ const V = globalThis.MbridgeView;
10
+ const $ = (id) => document.getElementById(id);
11
+ const theme = await magpie.ready;
12
+ let lang = theme.lang;
13
+
14
+ // the period and the sort live in this plugin's own options, so the
15
+ // page opens where it was left
16
+ let opts = {};
17
+ try {
18
+ opts = (await magpie.options.get()) || {};
19
+ } catch {
20
+ // no options yet: the defaults in package.json stand
21
+ }
22
+ const state = {
23
+ period: V.PERIODS.includes(opts.period) ? opts.period : "7d",
24
+ sort: V.SORTS.includes(opts.sort) ? opts.sort : "tokens",
25
+ summary: null,
26
+ error: null,
27
+ };
28
+
29
+ function words() {
30
+ const t = V.text(lang);
31
+ document.querySelectorAll("[data-t]").forEach((e) => {
32
+ e.textContent = t[e.dataset.t];
33
+ });
34
+ $("status").textContent = t.status;
35
+ $("restart").textContent = t.restart;
36
+ $("cmd").textContent = "mbridge service restart";
37
+ }
38
+
39
+ function pick(group, attr, value) {
40
+ $(group).querySelectorAll("button").forEach((b) => {
41
+ b.setAttribute("aria-selected", String(b.dataset[attr] === value));
42
+ });
43
+ }
44
+
45
+ function paint() {
46
+ pick("periods", "period", state.period);
47
+ pick("sorts", "sort", state.sort);
48
+ const s = state.summary;
49
+ if (!s) return;
50
+ $("since").textContent = V.since(s, lang);
51
+
52
+ $("tiles").replaceChildren(
53
+ ...V.tiles(s, lang).map((tile) => {
54
+ const cell = document.createElement("div");
55
+ cell.className = "tile";
56
+ for (const [cls, value] of [["k", tile.label], ["v", tile.value], ["s", tile.sub]]) {
57
+ if (value === undefined) continue;
58
+ const e = document.createElement("div");
59
+ e.className = cls;
60
+ e.textContent = value;
61
+ cell.append(e);
62
+ }
63
+ return cell;
64
+ })
65
+ );
66
+
67
+ const rows = V.rows(s, state.sort, lang);
68
+ $("rows").replaceChildren(
69
+ ...rows.map((r) => {
70
+ const tr = document.createElement("tr");
71
+ const name = document.createElement("td");
72
+ name.className = "model";
73
+ name.textContent = r.model;
74
+ const bar = document.createElement("span");
75
+ bar.className = "bar";
76
+ const fill = document.createElement("i");
77
+ fill.style.width = Math.max(2, Math.round(r.shareValue * 100)) + "%";
78
+ bar.append(fill);
79
+ name.append(bar);
80
+ const calls = document.createElement("td");
81
+ calls.className = "num";
82
+ calls.textContent = r.calls;
83
+ const tokens = document.createElement("td");
84
+ tokens.className = "num";
85
+ tokens.textContent = r.tokens;
86
+ const cost = document.createElement("td");
87
+ cost.className = "num";
88
+ cost.textContent = r.cost;
89
+ tr.append(name, calls, tokens, cost);
90
+ return tr;
91
+ })
92
+ );
93
+ $("empty").hidden = rows.length > 0;
94
+ $("empty").textContent = V.text(lang).empty;
95
+ $("error").hidden = !state.error;
96
+ if (state.error) {
97
+ $("error").textContent = V.text(lang).failed + ": " + state.error + " · " + V.text(lang).retry;
98
+ }
99
+ }
100
+
101
+ async function load() {
102
+ try {
103
+ state.summary = await magpie.read("usage:summary", { period: state.period });
104
+ state.error = null;
105
+ } catch (e) {
106
+ state.error = (e && e.message) || String(e);
107
+ }
108
+ paint();
109
+ }
110
+
111
+ // magpie takes one options write a second: a quick second click is
112
+ // saved with the next one
113
+ let saving = null;
114
+ function save() {
115
+ if (saving) return;
116
+ saving = setTimeout(async () => {
117
+ saving = null;
118
+ try {
119
+ opts = (await magpie.options.set({ ...opts, period: state.period, sort: state.sort })) || opts;
120
+ } catch {
121
+ // not remembering is not worth an error on the page
122
+ }
123
+ }, 1100);
124
+ }
125
+
126
+ $("periods").addEventListener("click", (e) => {
127
+ const b = e.target.closest("button[data-period]");
128
+ if (!b || b.dataset.period === state.period) return;
129
+ state.period = b.dataset.period;
130
+ save();
131
+ load();
132
+ });
133
+
134
+ $("sorts").addEventListener("click", (e) => {
135
+ const b = e.target.closest("button[data-sort]");
136
+ if (!b || b.dataset.sort === state.sort) return;
137
+ state.sort = b.dataset.sort;
138
+ save();
139
+ paint();
140
+ });
141
+
142
+ // the period and sort buttons carry their own words
143
+ for (const b of $("periods").querySelectorAll("button[data-period]")) {
144
+ b.textContent = V.periodLabel(b.dataset.period, lang);
145
+ }
146
+ for (const b of $("sorts").querySelectorAll("button[data-sort]")) {
147
+ b.textContent = V.sortLabel(b.dataset.sort, lang);
148
+ }
149
+ $("addr").textContent = V.STATUS_URL;
150
+ words();
151
+ await load();
152
+ setInterval(load, 60000);
153
+ })();
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,64 @@
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 class="root">
13
+ <header class="head">
14
+ <h1 data-t="title">mbridge</h1>
15
+ <p class="subtitle" data-t="subtitle"></p>
16
+ <p class="since" id="since"></p>
17
+ </header>
18
+
19
+ <div class="bar">
20
+ <div class="segmented" id="periods">
21
+ <button type="button" data-period="today" aria-selected="false"></button>
22
+ <button type="button" data-period="7d" aria-selected="false"></button>
23
+ <button type="button" data-period="30d" aria-selected="false"></button>
24
+ </div>
25
+ <div class="segmented" id="sorts">
26
+ <button type="button" data-sort="tokens" aria-selected="false"></button>
27
+ <button type="button" data-sort="calls" aria-selected="false"></button>
28
+ <button type="button" data-sort="cost" aria-selected="false"></button>
29
+ </div>
30
+ </div>
31
+
32
+ <section class="tiles" id="tiles"></section>
33
+
34
+ <section class="models">
35
+ <h2 data-t="models">by model</h2>
36
+ <table class="table">
37
+ <thead>
38
+ <tr>
39
+ <th data-t="model">model</th>
40
+ <th class="num" data-t="calls">calls</th>
41
+ <th class="num" data-t="tokens">tokens</th>
42
+ <th class="num" data-t="cost">cost</th>
43
+ </tr>
44
+ </thead>
45
+ <tbody id="rows"></tbody>
46
+ </table>
47
+ <p class="muted empty" id="empty" hidden></p>
48
+ </section>
49
+
50
+ <section class="notes">
51
+ <p class="note" data-t="disclaimer"></p>
52
+ <p class="note">
53
+ <span id="status"></span>
54
+ <span class="addr" id="addr" title="select and copy it"></span>
55
+ <span id="restart"></span>
56
+ <code class="cmd" id="cmd"></code>
57
+ </p>
58
+ </section>
59
+
60
+ <p class="error" id="error" hidden></p>
61
+ </main>
62
+ <script src="app.js" defer></script>
63
+ </body>
64
+ </html>
package/page/style.css ADDED
@@ -0,0 +1,48 @@
1
+ /* Only magpie's own variables, so the page follows the window's theme in
2
+ 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
+ h1 { margin: 0; font-size: 1.05em; font-weight: 600; }
11
+ .subtitle { margin: 2px 0 0; color: var(--magpie-fg-muted, #6b6b6b); }
12
+ .since { margin: 0 0 8px; color: var(--magpie-fg-muted, #6b6b6b); font-size: .9em; }
13
+ .bar { display: flex; gap: 8px; justify-content: space-between; flex-wrap: wrap; margin-bottom: 10px; }
14
+ .segmented { display: flex; gap: 2px; }
15
+ .segmented button {
16
+ font: inherit; color: inherit; cursor: pointer; padding: 2px 8px; border-radius: 5px;
17
+ border: 1px solid var(--magpie-border, #d8d8d8);
18
+ background: var(--magpie-bg-subtle, transparent);
19
+ }
20
+ .segmented button[aria-selected="true"] {
21
+ background: var(--magpie-accent, #2f6feb); border-color: transparent; color: #fff;
22
+ }
23
+ .tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 14px; }
24
+ .tile {
25
+ padding: 6px 8px; border-radius: 6px;
26
+ border: 1px solid var(--magpie-border, #e3e3e3);
27
+ background: var(--magpie-bg-subtle, transparent);
28
+ }
29
+ .tile .k { color: var(--magpie-fg-muted, #6b6b6b); }
30
+ .tile .v { font-size: 1.15em; font-weight: 600; font-variant-numeric: tabular-nums; }
31
+ .tile .s { color: var(--magpie-fg-muted, #6b6b6b); }
32
+ .models h2 { margin: 0 0 4px; font-size: 1em; font-weight: 600; }
33
+ .muted { color: var(--magpie-fg-muted, #6b6b6b); }
34
+ .table { width: 100%; border-collapse: collapse; }
35
+ .table th, .table td { text-align: left; padding: 3px 4px; border-bottom: 1px solid var(--magpie-border, #e3e3e3); }
36
+ .table .num { text-align: right; font-variant-numeric: tabular-nums; }
37
+ .table th.num { text-align: right; color: var(--magpie-fg-muted, #6b6b6b); font-weight: 500; }
38
+ .model { width: 55%; overflow-wrap: anywhere; }
39
+ .bar { display: block; height: 2px; margin-top: 2px; background: var(--magpie-border, #e3e3e3); border-radius: 1px; overflow: hidden; }
40
+ .bar > i { display: block; height: 100%; background: var(--magpie-accent, #2f6feb); }
41
+ .empty { margin: 6px 0; }
42
+ .notes { margin-top: 14px; }
43
+ .note { margin: 4px 0; color: var(--magpie-fg-muted, #6b6b6b); }
44
+ /* the address is selectable text, not a link: the sandbox may navigate
45
+ itself, but that is a one-way ticket (SP14 §2) */
46
+ .addr { user-select: all; color: var(--magpie-fg, inherit); }
47
+ .cmd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--magpie-fg, inherit); }
48
+ .error { color: var(--magpie-danger, #b42318); margin: 8px 0 0; }
package/page/view.js ADDED
@@ -0,0 +1,173 @@
1
+ // view.js is what the page shows, worked out from usage:summary without
2
+ // the DOM, so a test can check it.
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.
9
+
10
+ (() => {
11
+ const TEXT = {
12
+ en: {
13
+ title: "mbridge",
14
+ subtitle: "what magpie's ledger recorded",
15
+ calls: "calls",
16
+ errors: "errors",
17
+ errorShare: "of the calls",
18
+ tokens: "tokens",
19
+ cost: "cost",
20
+ models: "by model",
21
+ model: "model",
22
+ today: "today",
23
+ "7d": "7 days",
24
+ "30d": "30 days",
25
+ sortTokens: "tokens",
26
+ sortCalls: "calls",
27
+ sortCost: "cost",
28
+ empty: "no calls in this period",
29
+ failed: "couldn't read usage",
30
+ retry: "try again in a minute",
31
+ since: "since",
32
+ disclaimer:
33
+ "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.",
34
+ status: "mbridge's own status:",
35
+ restart: "restart it with",
36
+ },
37
+ zh: {
38
+ title: "mbridge",
39
+ subtitle: "magpie 记下的全部用量",
40
+ calls: "调用",
41
+ errors: "错误",
42
+ errorShare: "占调用",
43
+ tokens: "Token",
44
+ cost: "费用",
45
+ models: "按模型",
46
+ model: "模型",
47
+ today: "今天",
48
+ "7d": "7 天",
49
+ "30d": "30 天",
50
+ sortTokens: "Token",
51
+ sortCalls: "调用",
52
+ sortCost: "费用",
53
+ empty: "这个区间没有调用",
54
+ failed: "读不到 magpie 的用量",
55
+ retry: "一分钟后自动重试",
56
+ since: "起",
57
+ disclaimer:
58
+ "这是 magpie 记下的全部用量。客户端都走 mbridge 时它才等于 mbridge 的用量;你直接用 magpie 的那部分也在里面。",
59
+ status: "mbridge 自己的状态:",
60
+ restart: "重启用",
61
+ },
62
+ };
63
+
64
+ const PERIODS = ["today", "7d", "30d"];
65
+ const SORTS = ["tokens", "calls", "cost"];
66
+ const STATUS_URL = "http://127.0.0.1:3426/status";
67
+
68
+ // text is the words for a language: zh for anything that starts with zh.
69
+ function text(lang) {
70
+ return String(lang || "").toLowerCase().startsWith("zh") ? TEXT.zh : TEXT.en;
71
+ }
72
+
73
+ // count is a number a person reads: 1,234 or 1.23M.
74
+ function count(n) {
75
+ n = Number(n) || 0;
76
+ const a = Math.abs(n);
77
+ if (a >= 1e9) return (n / 1e9).toFixed(2) + "B";
78
+ if (a >= 1e6) return (n / 1e6).toFixed(2) + "M";
79
+ if (a >= 1e3) return n.toLocaleString("en-US");
80
+ return String(n);
81
+ }
82
+
83
+ // money is a price, never NaN, and never "$0.00" for something real.
84
+ function money(n) {
85
+ n = Number(n);
86
+ if (!isFinite(n)) return "$0";
87
+ if (n === 0) return "$0";
88
+ if (n > 0 && n < 0.01) return "<$0.01";
89
+ if (n < 10) return "$" + n.toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
90
+ if (n < 1000) return "$" + n.toFixed(2);
91
+ return "$" + n.toFixed(0);
92
+ }
93
+
94
+ // share is a percentage, and never NaN.
95
+ function pct(part, whole) {
96
+ const p = Number(part) || 0;
97
+ const w = Number(whole) || 0;
98
+ if (w <= 0) return "0%";
99
+ const v = (100 * p) / w;
100
+ if (!isFinite(v)) return "0%";
101
+ return (Math.round(v * 10) / 10).toString() + "%";
102
+ }
103
+
104
+ // tiles are the four numbers at the top: calls, errors, tokens, cost.
105
+ function tiles(summary, lang) {
106
+ const t = text(lang);
107
+ const s = summary || {};
108
+ return [
109
+ { key: "calls", label: t.calls, value: count(s.calls) },
110
+ {
111
+ key: "errors",
112
+ label: t.errors,
113
+ value: count(s.errors),
114
+ sub: t.errorShare + " " + pct(s.errors, s.calls),
115
+ },
116
+ { key: "tokens", label: t.tokens, value: count((s.input || 0) + (s.output || 0)) },
117
+ { key: "cost", label: t.cost, value: money(s.cost) },
118
+ ];
119
+ }
120
+
121
+ // rows are the model table, sorted by tokens, calls or cost, with each
122
+ // model's share of the total.
123
+ function rows(summary, sort, lang) {
124
+ const s = summary || {};
125
+ const models = Array.isArray(s.models) ? s.models : [];
126
+ const key = sort === "calls" ? "calls" : sort === "cost" ? "cost" : "tokens";
127
+ const value = (m) => (key === "tokens" ? (m.input || 0) + (m.output || 0) : m[key] || 0);
128
+ const sorted = models.slice().sort((a, b) => value(b) - value(a));
129
+ const total = sorted.reduce((a, m) => a + value(m), 0);
130
+ return sorted.map((m) => ({
131
+ model: m.model || "?",
132
+ calls: count(m.calls),
133
+ tokens: count((m.input || 0) + (m.output || 0)),
134
+ cost: money(m.cost),
135
+ share: pct(value(m), total),
136
+ shareValue: total > 0 ? value(m) / total : 0,
137
+ }));
138
+ }
139
+
140
+ // since is when the period started.
141
+ function since(summary, lang) {
142
+ const t = text(lang);
143
+ const s = summary || {};
144
+ return s.since ? t.since + " " + s.since : "";
145
+ }
146
+
147
+ // periodLabel is the name of a period.
148
+ function periodLabel(period, lang) {
149
+ return text(lang)[period] || period;
150
+ }
151
+
152
+ // sortLabel is the name of a sort.
153
+ function sortLabel(sort, lang) {
154
+ const t = text(lang);
155
+ return t["sort" + sort.charAt(0).toUpperCase() + sort.slice(1)] || sort;
156
+ }
157
+
158
+ globalThis.MbridgeView = {
159
+ TEXT,
160
+ PERIODS,
161
+ SORTS,
162
+ STATUS_URL,
163
+ text,
164
+ count,
165
+ money,
166
+ pct,
167
+ tiles,
168
+ rows,
169
+ since,
170
+ periodLabel,
171
+ sortLabel,
172
+ };
173
+ })();