@weiping/mbridge-page 0.1.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weiping/mbridge-page",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "mbridge's usage at a glance inside magpie: what magpie's ledger recorded, and where mbridge's own status page is.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -33,6 +33,12 @@
33
33
  "file": "page/index.html",
34
34
  "icon": "page/icon.svg"
35
35
  },
36
- "permissions": ["usage:summary"]
36
+ "permissions": [
37
+ "usage:summary"
38
+ ],
39
+ "options": {
40
+ "period": "7d",
41
+ "sort": "tokens"
42
+ }
37
43
  }
38
44
  }
package/page/app.js CHANGED
@@ -1,161 +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, 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);
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";
26
37
  }
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
38
 
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
- });
39
+ function pick(group, attr, value) {
40
+ $(group).querySelectorAll("button").forEach((b) => {
41
+ b.setAttribute("aria-selected", String(b.dataset[attr] === value));
42
+ });
43
+ }
46
44
 
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);
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;
65
98
  }
66
- box.appendChild(cell);
67
99
  }
68
100
 
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);
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);
96
107
  }
97
- body.appendChild(tr);
108
+ paint();
98
109
  }
99
- $("empty").textContent = r.items.length ? "" : t.emptyText;
100
110
 
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
- }
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
+ }
109
125
 
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
- }
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
+ });
116
133
 
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
- }
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
+ });
126
141
 
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
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);
136
145
  }
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
146
+ for (const b of $("sorts").querySelectorAll("button[data-sort]")) {
147
+ b.textContent = V.sortLabel(b.dataset.sort, lang);
147
148
  }
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();
149
+ $("addr").textContent = V.STATUS_URL;
150
+ words();
159
151
  await load();
160
- setInterval(load, REFRESH_MS);
161
- })();
152
+ setInterval(load, 60000);
153
+ })();
package/page/index.html CHANGED
@@ -9,50 +9,56 @@
9
9
  <script src="view.js"></script>
10
10
  </head>
11
11
  <body>
12
- <main id="root" class="root">
12
+ <main class="root">
13
13
  <header class="head">
14
- <h1 id="title" class="title">mbridge</h1>
15
- <p id="subtitle" class="subtitle"></p>
14
+ <h1 data-t="title">mbridge</h1>
15
+ <p class="subtitle" data-t="subtitle"></p>
16
+ <p class="since" id="since"></p>
16
17
  </header>
17
18
 
18
19
  <div class="bar">
19
- <div id="periods" class="segmented"></div>
20
- <div id="sorts" class="segmented"></div>
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>
21
30
  </div>
22
31
 
23
- <section id="tiles" class="tiles"></section>
32
+ <section class="tiles" id="tiles"></section>
24
33
 
25
34
  <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>
35
+ <h2 data-t="models">by model</h2>
30
36
  <table class="table">
31
37
  <thead>
32
38
  <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>
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>
37
43
  </tr>
38
44
  </thead>
39
45
  <tbody id="rows"></tbody>
40
46
  </table>
41
- <p id="empty" class="muted empty"></p>
47
+ <p class="muted empty" id="empty" hidden></p>
42
48
  </section>
43
49
 
44
50
  <section class="notes">
45
- <p id="disclaimer" class="note"></p>
51
+ <p class="note" data-t="disclaimer"></p>
46
52
  <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>
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>
51
57
  </p>
52
58
  </section>
53
59
 
54
- <p id="error" class="error"></p>
60
+ <p class="error" id="error" hidden></p>
55
61
  </main>
56
62
  <script src="app.js" defer></script>
57
63
  </body>
58
- </html>
64
+ </html>
package/page/style.css CHANGED
@@ -1,5 +1,5 @@
1
- /* Only magpie's own variables, so the page follows the window's theme
2
- in both light and dark without knowing either. */
1
+ /* Only magpie's own variables, so the page follows the window's theme in
2
+ both light and dark without knowing either. */
3
3
  .root {
4
4
  --pad: 12px;
5
5
  padding: var(--pad);
@@ -7,30 +7,31 @@
7
7
  color: var(--magpie-fg, #1a1a1a);
8
8
  background: var(--magpie-bg, transparent);
9
9
  }
10
- .title { margin: 0; font-size: 1.05em; font-weight: 600; }
11
- .subtitle { margin: 2px 0 10px; color: var(--magpie-fg-muted, #6b6b6b); }
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; }
12
13
  .bar { display: flex; gap: 8px; justify-content: space-between; flex-wrap: wrap; margin-bottom: 10px; }
13
14
  .segmented { display: flex; gap: 2px; }
14
- .seg {
15
- font: inherit; color: inherit; cursor: pointer;
16
- padding: 2px 8px; border-radius: 5px;
15
+ .segmented button {
16
+ font: inherit; color: inherit; cursor: pointer; padding: 2px 8px; border-radius: 5px;
17
17
  border: 1px solid var(--magpie-border, #d8d8d8);
18
18
  background: var(--magpie-bg-subtle, transparent);
19
19
  }
20
- .seg-on { background: var(--magpie-accent, #2f6feb); border-color: transparent; color: #fff; }
20
+ .segmented button[aria-selected="true"] {
21
+ background: var(--magpie-accent, #2f6feb); border-color: transparent; color: #fff;
22
+ }
21
23
  .tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 14px; }
22
24
  .tile {
23
25
  padding: 6px 8px; border-radius: 6px;
24
26
  border: 1px solid var(--magpie-border, #e3e3e3);
25
27
  background: var(--magpie-bg-subtle, transparent);
26
28
  }
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; }
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; }
32
33
  .muted { color: var(--magpie-fg-muted, #6b6b6b); }
33
- .table { width: 100%; border-collapse: collapse; margin-top: 4px; }
34
+ .table { width: 100%; border-collapse: collapse; }
34
35
  .table th, .table td { text-align: left; padding: 3px 4px; border-bottom: 1px solid var(--magpie-border, #e3e3e3); }
35
36
  .table .num { text-align: right; font-variant-numeric: tabular-nums; }
36
37
  .table th.num { text-align: right; color: var(--magpie-fg-muted, #6b6b6b); font-weight: 500; }
@@ -40,7 +41,8 @@
40
41
  .empty { margin: 6px 0; }
41
42
  .notes { margin-top: 14px; }
42
43
  .note { margin: 4px 0; color: var(--magpie-fg-muted, #6b6b6b); }
43
- /* the address is selectable text, not a link */
44
+ /* the address is selectable text, not a link: the sandbox may navigate
45
+ itself, but that is a one-way ticket (SP14 §2) */
44
46
  .addr { user-select: all; color: var(--magpie-fg, inherit); }
45
47
  .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; }
48
+ .error { color: var(--magpie-danger, #b42318); margin: 8px 0 0; }
package/page/view.js CHANGED
@@ -1,178 +1,173 @@
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.
1
+ // view.js is what the page shows, worked out from usage:summary without
2
+ // the DOM, so a test can check it.
3
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.
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.
8
9
 
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
- };
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
+ };
57
63
 
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
- }
64
+ const PERIODS = ["today", "7d", "30d"];
65
+ const SORTS = ["tokens", "calls", "cost"];
66
+ const STATUS_URL = "http://127.0.0.1:3426/status";
66
67
 
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
- }
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
+ }
77
72
 
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
- }
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
+ }
87
82
 
88
- function lang(l) {
89
- return l === "zh" ? "zh" : "en";
90
- }
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
+ }
91
93
 
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
- }
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
+ }
108
103
 
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) => ({
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) => ({
127
131
  model: m.model || "?",
128
132
  calls: count(m.calls),
129
133
  tokens: count((m.input || 0) + (m.output || 0)),
130
134
  cost: money(m.cost),
131
135
  share: pct(value(m), total),
132
136
  shareValue: total > 0 ? value(m) / total : 0,
133
- })),
134
- };
135
- }
137
+ }));
138
+ }
136
139
 
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
- }
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
+ }
142
146
 
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
- }
147
+ // periodLabel is the name of a period.
148
+ function periodLabel(period, lang) {
149
+ return text(lang)[period] || period;
150
+ }
148
151
 
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
- }
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
+ }
167
157
 
168
- globalThis.MbridgeView = {
169
- TEXT,
170
- count,
171
- money,
172
- pct,
173
- tiles,
174
- rows,
175
- periods,
176
- sorts,
177
- header,
178
- };
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
+ })();