magpie-page-token-widget 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/README.md CHANGED
@@ -14,6 +14,7 @@
14
14
  - 组件里显示每家订阅的剩余额度、5 小时与周额度窗口的百分比、重置倒计时、今日 Token、七日趋势和各家排行。参数可以选订阅(`codex`、`minimax`、`glm`,或 `codex@账号名`),也可以把百分比换成已用(`|used`)。
15
15
  - 插件页还能改中号和大号组件里显示哪些订阅,能看到 Tailscale 的状态和同一尾网里的 iPhone。
16
16
  - 勾一个厂商=这个厂商的所有账号都显示;只勾一个账号=只有这个账号。勾选框显示的就是手机上会出现的卡片。
17
+ - 点一下手机上的组件,Safari 会打开手机版用量页:每家订阅的剩余额度、今日与近七日的 Token、模型/厂商/客户端排行,和电脑上看到的是同一份数据。
17
18
  - 服务只读 Magpie 的本地接口,只在本机打开,手机经 Tailscale 的端口转发进来,令牌存放在 Magpie 自己的目录里。
18
19
 
19
20
  ## 需要什么
@@ -48,6 +49,12 @@ magpie plugin add ./magpie-page-token-widget
48
49
 
49
50
  不想扫码也行:插件页上有「只读令牌」,把它和「服务地址」填进 `scriptable/SetupUsageWidget.js`(在 Scriptable 里新建一个脚本粘贴运行)也行。
50
51
 
52
+ ## 手机上的用量页
53
+
54
+ 点一下桌面上的组件,Safari 会打开手机版的用量页:每家订阅的剩余额度、今日与近七日的 Token、模型/厂商/客户端排行,和电脑上看到的是同一份数据。
55
+
56
+ 页面用组件里的那个只读令牌打开,令牌只走地址栏的 `#token=` 一段,页面打开后立刻把它抹掉,之后由 Scriptable 自己记着。手机版页面只在尾网这一侧提供,电脑上的控制台仍然只在本机。
57
+
51
58
  ## 组件参数
52
59
 
53
60
  | 尺寸 | 内容 | Widget Parameter |
@@ -128,6 +135,12 @@ From a checkout: `magpie plugin add ./magpie-page-token-widget`.
128
135
 
129
136
  The pairing code works once. The script carries only that one-time code; on its first run it exchanges the code for a token of its own and keeps it in the keychain of Scriptable.
130
137
 
138
+ ## The page on the phone
139
+
140
+ Tapping the widget opens the usage page on the phone: the remaining quota of every subscription, today's tokens, the last seven days and the rankings by model, vendor and client, the same numbers the computer shows.
141
+
142
+ The page opens with the read-only token of the widget. The token travels in the `#token=` part of the address and is wiped from there as soon as the page is open; Scriptable keeps it from then on. The page is served on the tailnet side only, the console of the computer stays on this computer.
143
+
131
144
  ## Widget parameter
132
145
 
133
146
  | Size | Shows | Widget Parameter |
package/lib/server.mjs CHANGED
@@ -300,6 +300,26 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
300
300
  response.end(body);
301
301
  };
302
302
 
303
+ // The page of the phone. It carries no token: the phone brings its own in
304
+ // the address, and the page drops it as soon as it has read it.
305
+ const phoneCsp =
306
+ "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'";
307
+
308
+ const serveMobile = async (response, name, type) => {
309
+ try {
310
+ const body = await readFile(path.join(root, 'mobile', name), 'utf8');
311
+ response.writeHead(200, {
312
+ 'Content-Type': type,
313
+ 'Content-Security-Policy': phoneCsp,
314
+ 'Cache-Control': 'no-store',
315
+ 'X-Content-Type-Options': 'nosniff',
316
+ });
317
+ response.end(body);
318
+ } catch {
319
+ text(response, 404, 'Not found');
320
+ }
321
+ };
322
+
303
323
  const serveUi = async (response, name, type, port) => {
304
324
  try {
305
325
  const body = await readFile(path.join(root, 'ui', name), 'utf8');
@@ -441,6 +461,18 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
441
461
  }
442
462
 
443
463
  if (!consoleScope) {
464
+ // The phone opens the page of the phone here. It carries no token: the
465
+ // phone brings its own in the address and drops it as soon as it has
466
+ // read it.
467
+ if ((pathname === '/' || pathname === '/index.html') && method === 'GET') {
468
+ return serveMobile(response, 'index.html', 'text/html; charset=utf-8');
469
+ }
470
+ if (pathname === '/app.js' && method === 'GET') {
471
+ return serveMobile(response, 'app.js', 'text/javascript; charset=utf-8');
472
+ }
473
+ if (pathname === '/style.css' && method === 'GET') {
474
+ return serveMobile(response, 'style.css', 'text/css; charset=utf-8');
475
+ }
444
476
  return json(response, 403, { error: 'This page is only opened on the computer of the service.' });
445
477
  }
446
478
  if (!consoleOpen) {
package/mobile/app.js ADDED
@@ -0,0 +1,242 @@
1
+ const $ = id => document.getElementById(id);
2
+ const savedTheme = sessionStorage.getItem('usage-theme');
3
+ if (savedTheme) document.documentElement.dataset.theme = savedTheme;
4
+ $('theme').addEventListener('click', () => {
5
+ const dark = document.documentElement.dataset.theme === 'dark' || !document.documentElement.dataset.theme && matchMedia('(prefers-color-scheme: dark)').matches;
6
+ const theme = dark ? 'light' : 'dark';
7
+ document.documentElement.dataset.theme = theme;
8
+ sessionStorage.setItem('usage-theme', theme);
9
+ });
10
+ const compact = value => value == null ? '—' : new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 2 }).format(value);
11
+ const exact = value => new Intl.NumberFormat('en').format(value);
12
+ const money = value => value == null ? '—' : value >= 100 ? value.toFixed(0) : value.toFixed(2);
13
+ const time = value => value ? new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value)) : '未知';
14
+ const node = (tag, className, text) => {
15
+ const element = document.createElement(tag);
16
+ if (className) element.className = className;
17
+ if (text != null) element.textContent = text;
18
+ return element;
19
+ };
20
+ let token = new URLSearchParams(location.hash.slice(1)).get('token');
21
+ history.replaceState(null, '', location.pathname);
22
+ if (token) sessionStorage.setItem('usage-token', token);
23
+ token ||= sessionStorage.getItem('usage-token');
24
+ let last;
25
+ try { last = JSON.parse(sessionStorage.getItem('usage-overview')); } catch { /* No cached response. */ }
26
+ // Percent direction and ranking tab are per-device viewing choices,
27
+ // not changes to what the service projects.
28
+ let quotaMode = sessionStorage.getItem('usage-quota-mode') === 'used' ? 'used' : 'remaining';
29
+ const RANKING_TABS = ['models', 'providers', 'agents', 'computers'];
30
+ let rankTab = RANKING_TABS.includes(sessionStorage.getItem('usage-ranking')) ? sessionStorage.getItem('usage-ranking') : 'models';
31
+ function syncQuotaToggle() {
32
+ for (const mode of ['remaining', 'used']) {
33
+ const button = $('mode-' + mode);
34
+ button.classList.toggle('active', quotaMode === mode);
35
+ button.setAttribute('aria-pressed', String(quotaMode === mode));
36
+ }
37
+ }
38
+ function syncRankTabs() {
39
+ for (const id of RANKING_TABS) {
40
+ const button = $('tab-' + id);
41
+ button.classList.toggle('active', rankTab === id);
42
+ button.setAttribute('aria-pressed', String(rankTab === id));
43
+ }
44
+ }
45
+ for (const mode of ['remaining', 'used']) $('mode-' + mode).addEventListener('click', () => {
46
+ quotaMode = mode;
47
+ sessionStorage.setItem('usage-quota-mode', mode);
48
+ syncQuotaToggle();
49
+ if (last) renderQuotas(last.subscriptions);
50
+ });
51
+ for (const id of RANKING_TABS) $('tab-' + id).addEventListener('click', () => {
52
+ rankTab = id;
53
+ sessionStorage.setItem('usage-ranking', id);
54
+ syncRankTabs();
55
+ if (last) renderRankings(last);
56
+ });
57
+ syncQuotaToggle();
58
+ syncRankTabs();
59
+ function resetLabel(window) {
60
+ if (window.expired) return '窗口已到期· 等待更新';
61
+ if (!window.resetsAt) return '重置时间未提供';
62
+ const minutes = Math.ceil((Date.parse(window.resetsAt) - Date.now()) / 60000);
63
+ if (minutes <= 0) return '窗口已到期· 等待更新';
64
+ const duration = minutes < 60 ? minutes + ' 分钟' : minutes < 1440 ? Math.floor(minutes / 60) + ' 小时 ' + minutes % 60 + ' 分钟' : Math.floor(minutes / 1440) + ' 天 ' + Math.floor(minutes % 1440 / 60) + ' 小时';
65
+ return duration + '后重置';
66
+ }
67
+ function windowName(name) {
68
+ const suffix = quotaMode === 'used' ? '已用' : '剩余';
69
+ return /5\s*h|5\s*hour/i.test(name) ? '5 小时' + suffix : /7\s*d|7\s*day|week/i.test(name) ? '本周' + suffix : name + suffix;
70
+ }
71
+ function progress(value, className, label) {
72
+ const bar = node('progress', className);
73
+ bar.max = 100;
74
+ bar.value = value || 0;
75
+ bar.setAttribute('aria-label', (label || '剩余') + ' ' + (value == null ? '未知' : Math.round(value) + '%'));
76
+ return bar;
77
+ }
78
+ function quotaValue(window) {
79
+ return window.expired ? null : quotaMode === 'used' ? window.usedPercent : window.remainingPercent;
80
+ }
81
+ function quotaState(value) {
82
+ if (value == null) return '';
83
+ const consumed = quotaMode === 'used' ? value : 100 - value;
84
+ return consumed >= 90 ? 'critical' : consumed >= 75 ? 'low' : '';
85
+ }
86
+ function renderQuotas(cards) {
87
+ const parent = $('quotas'); parent.replaceChildren();
88
+ if (!cards.length) { parent.append(node('div', 'empty panel', '尚未读取到有效套餐。请确认 Magpie 已登录订阅并更新额度。')); return; }
89
+ for (const card of cards) {
90
+ const article = node('article', 'panel quota-card');
91
+ const heading = node('div', 'provider-heading');
92
+ heading.append(node('span', 'provider-monogram', card.label[0]), node('span', 'provider-name', card.label), node('span', 'tag', card.plan));
93
+ article.append(heading);
94
+ const windows = node('div', 'windows');
95
+ card.windows.forEach((window, i) => {
96
+ const value = quotaValue(window);
97
+ const state = quotaState(value);
98
+ const block = node('div', 'window ' + (i ? '' : 'primary ') + state);
99
+ const row = node('div', 'window-heading');
100
+ const valueNode = node('span', 'window-value', value == null ? '—' : String(Math.round(value)));
101
+ if (value != null) valueNode.append(node('small', '', '%'));
102
+ row.append(node('span', 'window-name', windowName(window.label)), valueNode);
103
+ block.append(row, progress(value, state, quotaMode === 'used' ? '已用' : '剩余'), node('p', 'window-reset', resetLabel(window)));
104
+ if (window.amount != null && window.limit != null) {
105
+ block.append(node('p', 'window-amount', '读数 ' + compact(window.amount) + ' / ' + compact(window.limit)));
106
+ }
107
+ windows.append(block);
108
+ });
109
+ article.append(windows);
110
+ if (!card.windows.length) article.append(node('p', 'fine-print', '此套餐未提供额度窗口'));
111
+ if (card.usage && card.usage.today) {
112
+ const today = card.usage.today;
113
+ const line = '今日 ' + compact(today.totalTokens) + ' Token';
114
+ const detail = Number.isFinite(today.requests) ? ' · ' + today.requests + ' 次请求' : '';
115
+ article.append(node('p', 'fine-print', line + detail + (card.usageStale ? ' · 缓存读数' : '')));
116
+ } else {
117
+ // The usage of this subscription is unknown; the total of all is never
118
+ // shown in its place.
119
+ article.append(node('p', 'fine-print', '今日用量未知'));
120
+ }
121
+ if (card.stale) article.append(node('p', 'stale-label', '缓存额度· ' + (card.updatedAt ? time(card.updatedAt) : '读取时间未知')));
122
+ article.title = '额度读数:' + time(card.updatedAt);
123
+ parent.append(article);
124
+ }
125
+ }
126
+ const RANKING_NOTES = {
127
+ models: '同名模型已合并,含不同厂商前缀与大小写',
128
+ providers: '按上游稳定 id 解析厂商',
129
+ agents: 'Magpie 识别的调用客户端',
130
+ computers: '发起请求的设备,沿用 Magpie 的合并结果',
131
+ };
132
+ function renderRanking(rows, total) {
133
+ const parent = $('rank-body'); parent.replaceChildren();
134
+ if (!rows.length) { parent.append(node('p', 'empty', '此周期暂无记录')); return; }
135
+ for (const item of rows) {
136
+ const row = node('div', 'ranking-row'), heading = node('div', 'ranking-heading');
137
+ const value = node('span', 'ranking-value');
138
+ value.append(node('strong', '', compact(item.totalTokens)));
139
+ if (item.cost > 0) value.append(node('span', 'ranking-cost', money(item.cost)));
140
+ heading.append(node('span', '', item.label), value);
141
+ const bar = progress(total ? item.totalTokens / total * 100 : 0);
142
+ bar.setAttribute('aria-label', item.label + ':' + exact(item.totalTokens) + ' Token' + (item.cost > 0 ? '· 预估费用 ' + money(item.cost) : ''));
143
+ row.append(heading, bar); parent.append(row);
144
+ }
145
+ }
146
+ function renderRankings(data) {
147
+ renderRanking(data.rankings[rankTab] || [], data.usage.week?.totalTokens);
148
+ $('rank-note').textContent = RANKING_NOTES[rankTab] || '';
149
+ }
150
+ const NS = 'http://www.w3.org/2000/svg';
151
+ function svgNode(tag, attrs, text) {
152
+ const element = document.createElementNS(NS, tag);
153
+ for (const [key, value] of Object.entries(attrs)) element.setAttribute(key, value);
154
+ if (text != null) element.textContent = text;
155
+ return element;
156
+ }
157
+ function renderChart(rows) {
158
+ const parent = $('chart'); parent.replaceChildren();
159
+ if (!rows.length) { parent.append(node('p', 'empty', '暂无趋势数据')); return; }
160
+ const svg = svgNode('svg', { viewBox: '0 0 420 175', role: 'img', 'aria-label': '近七日 Token 用量' });
161
+ const max = Math.max(1, ...rows.map(row => row.totalTokens));
162
+ for (const y of [35, 80, 125]) svg.append(svgNode('line', { x1: 0, x2: 420, y1: y, y2: y, class: 'gridline' }));
163
+ rows.forEach((row, i) => {
164
+ const x = 420 / rows.length * (i + .5), height = row.totalTokens / max * 100;
165
+ const rect = svgNode('rect', { x: x - 12, y: 139 - height, width: 24, height: Math.max(2, height), rx: 3, class: i === rows.length - 1 ? 'last' : '' });
166
+ rect.append(svgNode('title', {}, row.label + ':' + exact(row.totalTokens) + ' Token'));
167
+ const date = row.at ? new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: 'numeric', day: 'numeric' }).format(new Date(row.at)) : row.label;
168
+ svg.append(rect, svgNode('text', { x, y: 159, 'text-anchor': 'middle' }, date));
169
+ });
170
+ parent.append(svg);
171
+ }
172
+ function speedText(metrics) {
173
+ const parts = [];
174
+ if (metrics?.outputTokensPerSecond != null) parts.push('输出 约' + metrics.outputTokensPerSecond + ' tok/s');
175
+ if (metrics?.ttftMs != null) parts.push('首字 约' + (metrics.ttftMs >= 1000 ? (metrics.ttftMs / 1000).toFixed(1) + ' 秒' : metrics.ttftMs + ' 毫秒'));
176
+ return parts.join('· ');
177
+ }
178
+ function render(data, offline = false) {
179
+ const stale = offline || Object.values(data.sources).some(source => source.state !== 'ok');
180
+ $('connection').textContent = offline ? '离线· 显示缓存' : stale ? '部分数据待更新' : '已连接· Tailscale';
181
+ $('status-dot').className = 'status-dot ' + (stale ? 'warn' : 'ok');
182
+ const notices = [];
183
+ if (data.demo) notices.push('演示模式· 所有数字、模型和设备用量均为虚构数据。');
184
+ if (stale) notices.push('当前显示最后成功的统计,请查看下方更新时间;未获取到的数据以 — 表示。');
185
+ $('notice').hidden = !notices.length; $('notice').textContent = notices.join(' ');
186
+ renderQuotas(data.subscriptions);
187
+ const day = value => new Date(value + 8 * 3600000).toISOString().slice(0, 10);
188
+ const oldDay = data.sources.today.outdated || data.sources.today.observedAt && day(Date.parse(data.sources.today.observedAt)) !== day(Date.now());
189
+ const today = oldDay ? null : data.usage.today, week = data.usage.week;
190
+ $('today-total').textContent = compact(today?.totalTokens);
191
+ $('today-total').title = today ? exact(today.totalTokens) + ' Token' : '';
192
+ $('today-caption').textContent = today ? exact(today.totalTokens) + ' Token·上海时间今日累计' : '尚未读取到今日用量';
193
+ const breakdown = $('breakdown'); breakdown.replaceChildren();
194
+ for (const [text, field] of [['输入', 'inputTokens'], ['输出', 'outputTokens'], ['缓存读取', 'cacheReadTokens'], ['缓存写入', 'cacheWriteTokens']]) {
195
+ const item = node('div', 'breakdown-item');
196
+ item.append(node('span', '', text), node('strong', '', compact(today?.[field]))); breakdown.append(item);
197
+ }
198
+ $('reasoning').textContent = '推理 ' + (today ? compact(today.reasoningTokens) : '—') + ' Token· 已包含在输出中';
199
+ const extras = [speedText(today), today && today.cost != null ? '预估费用 ' + money(today.cost) : ''].filter(Boolean).join('· ');
200
+ $('today-extras').textContent = extras;
201
+ $('today-extras').title = today && today.unpriced > 0 ? '有 ' + today.unpriced + ' 条未计价请求,费用估算可能偏低' : '';
202
+ $('requests').textContent = (today ? exact(today.requests) : '—') + ' 次请求' + (today && today.errors > 0 ? '·失败 ' + today.errors : '');
203
+ $('cache-hit').textContent = (today?.cacheHitPercent == null ? '—' : today.cacheHitPercent.toFixed(1) + '%') + ' 缓存命中';
204
+ $('week-total').textContent = compact(week?.totalTokens);
205
+ $('week-requests').textContent = (week ? exact(week.requests) : '—') + ' 次请求' + (week && week.cost != null ? '· 预估费用 ' + money(week.cost) : '');
206
+ $('week-requests').title = week && week.unpriced > 0 ? '有 ' + week.unpriced + ' 条未计价请求,费用估算可能偏低' : '';
207
+ renderChart(data.trend);
208
+ renderRankings(data);
209
+ const observations = Object.values(data.sources).map(source => source.observedAt).filter(Boolean).sort();
210
+ $('updated').textContent = '汇总更新于 ' + time(observations[0]);
211
+ }
212
+ function auth(message = '') {
213
+ $('auth-error').textContent = message;
214
+ if (!$('auth').open) $('auth').showModal();
215
+ }
216
+ async function load() {
217
+ if (!token) { auth(); return; }
218
+ $('refresh').disabled = true;
219
+ try {
220
+ const response = await fetch('/api/v1/overview', { headers: { Authorization: 'Bearer ' + token }, cache: 'no-store', signal: AbortSignal.timeout(10000) });
221
+ if (response.status === 401) {
222
+ token = null; sessionStorage.removeItem('usage-token'); auth('令牌无效,请重新输入。'); return;
223
+ }
224
+ if (!response.ok) throw new Error('Unavailable');
225
+ const data = await response.json();
226
+ if (data.schemaVersion !== 1) throw new Error('Unsupported response');
227
+ last = data; sessionStorage.setItem('usage-overview', JSON.stringify(data));
228
+ $('auth').close(); render(data);
229
+ } catch {
230
+ if (last) render(last, true);
231
+ else { $('connection').textContent = '连接失败'; $('notice').hidden = false; $('notice').textContent = '无法连接服务。请检查 PC 服务和 iPhone 的 Tailscale 连接。'; }
232
+ } finally { $('refresh').disabled = false; }
233
+ }
234
+ $('auth-form').addEventListener('submit', event => {
235
+ event.preventDefault(); token = $('token').value.trim(); sessionStorage.setItem('usage-token', token); void load();
236
+ });
237
+ $('auth').addEventListener('cancel', event => event.preventDefault());
238
+ $('refresh').addEventListener('click', () => void load());
239
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) void load(); });
240
+ if (last) render(last, true);
241
+ void load();
242
+ setInterval(() => { if (!document.hidden && token) void load(); }, 60000);
@@ -0,0 +1,66 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="color-scheme" content="light dark">
7
+ <meta name="theme-color" content="#f7f7f5">
8
+ <title>Usage · 用量总览</title>
9
+ <link rel="stylesheet" href="/style.css">
10
+ <script src="/app.js" defer></script>
11
+ </head>
12
+ <body>
13
+ <div class="shell">
14
+ <header class="topbar">
15
+ <a class="brand" href="/" aria-label="Usage 首页"><span class="brand-mark">◒</span> usage<span class="brand-dot">.</span></a>
16
+ <div class="top-actions"><span class="connection"><span class="status-dot" id="status-dot"></span><span id="connection">正在连接</span></span><button class="theme-toggle" id="theme" aria-label="切换深浅色" title="切换深浅色">◐</button></div>
17
+ </header>
18
+ <main>
19
+ <div class="page-heading">
20
+ <div><p class="eyebrow">A LITTLE ROOM TO CREATE</p><h1>用量总览</h1><p class="intro">套餐余量与跨设备用量,一处查看。</p></div>
21
+ <button class="refresh" id="refresh" aria-label="刷新数据"><span aria-hidden="true">↻</span><span>刷新</span></button>
22
+ </div>
23
+ <div class="notice" id="notice" role="status" hidden></div>
24
+ <section aria-labelledby="quota-heading">
25
+ <div class="section-heading"><h2 id="quota-heading">订阅额度</h2><div class="segmented" role="group" aria-label="额度视图"><button type="button" id="mode-remaining" aria-pressed="true">剩余</button><button type="button" id="mode-used" aria-pressed="false">已用</button></div><span class="muted">按套餐实际窗口更新</span></div>
26
+ <div class="quota-grid" id="quotas"><div class="empty panel">正在读取套餐额度…</div></div>
27
+ </section>
28
+ <section class="activity-grid" aria-label="Token 用量">
29
+ <article class="panel today-card">
30
+ <div class="section-heading"><h2>今日 Token</h2><span class="tag">含缓存</span></div>
31
+ <div class="daily-number" id="today-total">—</div>
32
+ <p class="metric-caption" id="today-caption">正在读取用量</p>
33
+ <div class="breakdown" id="breakdown"></div>
34
+ <p class="reasoning" id="reasoning">推理用量尚未读取</p>
35
+ <p class="extras" id="today-extras"></p>
36
+ <div class="card-foot"><span id="requests">— 次请求</span><span id="cache-hit">— 缓存命中</span></div>
37
+ </article>
38
+ <article class="panel trend-card">
39
+ <div class="section-heading"><h2>近七日节奏</h2><span class="muted">Asia / Shanghai</span></div>
40
+ <div class="week-heading"><strong id="week-total">—</strong><span class="metric-caption">累计 Token,含缓存</span></div>
41
+ <div class="chart" id="chart" aria-label="近七日 Token 柱状图"></div>
42
+ <div class="card-foot"><span>包含本机与已同步的服务器用量</span><span id="week-requests">— 次请求</span></div>
43
+ </article>
44
+ </section>
45
+ <section aria-label="近七日分布">
46
+ <div class="section-heading"><h2>近七日分布</h2><div class="segmented" role="group" aria-label="分布维度"><button type="button" id="tab-models" aria-pressed="true">模型</button><button type="button" id="tab-providers" aria-pressed="false">厂商</button><button type="button" id="tab-agents" aria-pressed="false">客户端</button><button type="button" id="tab-computers" aria-pressed="false">设备</button></div></div>
47
+ <article class="panel detail-panel"><div id="rank-body"></div><p class="muted rank-note" id="rank-note"></p></article>
48
+ </section>
49
+ <details class="install">
50
+ <summary>添加到 iPhone 桌面 <span aria-hidden="true">↗</span></summary>
51
+ <ol><li>在 iPhone 安装 Scriptable,并保持 Tailscale 已连接。</li><li>新建脚本,粘贴 <a href="/scriptable/SetupUsageWidget.js">SetupUsageWidget.js</a> 的完整内容并运行。</li><li>输入本服务地址与只读令牌。安装器会自动下载桌面组件。</li><li>长按桌面添加 Scriptable,选择 UsageWidget;推荐中号。</li></ol>
52
+ <p>小号组件参数:<code>codex</code>、<code>minimax</code> 或 <code>glm</code>;追加 <code>|used</code> 改显已用(如 <code>glm|used</code>),中号组件填 <code>used</code> 可整体切换。令牌只保存在本机 Keychain。</p>
53
+ </details>
54
+ <footer><span>OBSERVED BY MAGPIE <span class="footer-separator">/</span> MADE FOR YOUR IPHONE</span><span id="updated">尚未更新</span></footer>
55
+ <p class="method">Token = 输入 + 输出 + 缓存读取 + 缓存写入;推理用量已包含在输出中,不重复相加。额度比例由套餐提供,与 Token 统计独立。费用与速度为 Magpie 按实效价格的估算值,仅供参考。</p>
56
+ </main>
57
+ </div>
58
+ <dialog id="auth">
59
+ <form id="auth-form">
60
+ <span class="brand-mark">◒</span><h2>连接你的用量</h2><p>输入本服务的只读令牌。它与模型 API Key 无关。</p>
61
+ <label for="token">只读访问令牌</label><input id="token" type="password" autocomplete="off" required placeholder="粘贴本地生成的令牌">
62
+ <button type="submit">连接</button><p class="auth-error" id="auth-error" role="alert"></p>
63
+ </form>
64
+ </dialog>
65
+ </body>
66
+ </html>
@@ -0,0 +1,9 @@
1
+ @font-face{font-family:UsageSystem;src:local("SF Pro Display"),local("Segoe UI")}
2
+ :root{color-scheme:light dark;--bg:#f7f7f5;--panel:#fff;--text:#232523;--muted:#888c86;--line:#e9ebe5;--track:#eef0eb;--fill:#4c544a;--accent:#6d8067;--warn:#b77c36;--bad:#b35447;--soft:#f2f4ef;--shadow:0 3px 18px #22270503}
3
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:UsageSystem,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;-webkit-font-smoothing:antialiased}button,input{font:inherit}a{color:inherit;text-underline-offset:4px}.shell{max-width:1120px;margin:auto;padding:0 44px}.topbar{height:100px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{display:flex;align-items:center;gap:10px;font-size:27px;font-weight:650;letter-spacing:-1.4px;text-decoration:none}.brand-mark{display:inline-block;font-size:32px;line-height:1;transform:rotate(-35deg)}.brand-dot{margin-left:-9px;color:var(--accent)}.top-actions{display:flex;align-items:center;gap:13px}.theme-toggle{border:0;background:transparent;color:var(--muted);font-size:20px;padding:5px;line-height:1}.connection{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}.status-dot{height:6px;width:6px;background:var(--muted);border-radius:50%}.status-dot.ok{background:var(--accent)}.status-dot.warn{background:var(--warn)}main{padding:46px 0 28px}.page-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:40px}.eyebrow{font-size:9px;font-weight:600;letter-spacing:2.8px;color:var(--muted);margin:0 0 13px}h1{font-size:32px;letter-spacing:-1px;font-weight:600;margin:0 0 12px}.intro{margin:0;font-size:13px;color:var(--muted);letter-spacing:.3px}button{cursor:pointer}.refresh{display:flex;align-items:center;gap:8px;border:1px solid var(--line);background:var(--panel);border-radius:24px;padding:9px 15px;color:var(--text);font-size:12px}.refresh>span:first-child{font-size:20px;line-height:1}.refresh:disabled{opacity:.45;cursor:wait}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:20px}.section-heading h2{font-size:13px;font-weight:550;letter-spacing:.3px;margin:0}.muted{font-size:10px;color:var(--muted)}.quota-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-bottom:26px}.panel{border:1px solid var(--line);background:var(--panel);border-radius:16px;padding:25px;box-shadow:var(--shadow);min-width:0}.quota-card{padding:22px 24px}.provider-heading{display:flex;align-items:center;gap:10px;margin-bottom:25px}.provider-monogram{border:1px solid var(--line);border-radius:9px;width:31px;height:31px;display:grid;place-items:center;font-size:14px;font-weight:650}.provider-name{font-size:15px;font-weight:600;letter-spacing:-.4px}.tag{padding:4px 7px;background:var(--soft);color:var(--muted);font-size:9px;border-radius:4px;text-transform:uppercase;letter-spacing:.7px}.provider-heading .tag{margin-left:auto;max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.window{margin:17px 0 0}.window.primary{margin-top:0}.window-heading{display:flex;align-items:baseline;justify-content:space-between;gap:8px;margin-bottom:8px}.window-name{font-size:11px;color:var(--muted)}.window-value{font-size:13px;font-weight:600;font-variant-numeric:tabular-nums}.primary .window-value{font-size:36px;font-weight:500;letter-spacing:-1.6px}.primary .window-value small{font-size:17px;letter-spacing:0;margin-left:3px;color:var(--muted)}progress{width:100%;height:4px;display:block;border:0;border-radius:3px;overflow:hidden;background:var(--track);color:var(--fill);accent-color:var(--fill)}progress::-webkit-progress-bar{background:var(--track);border-radius:3px}progress::-webkit-progress-value{background:var(--fill);border-radius:3px}progress::-moz-progress-bar{background:var(--fill);border-radius:3px}.low{--fill:var(--warn)}.critical{--fill:var(--bad)}.window-reset{font-size:10px;color:var(--muted);margin:8px 0 0}.stale-label{font-size:10px;color:var(--warn);margin:14px 0 0}.activity-grid{display:grid;grid-template-columns:1fr 1.15fr;gap:20px;margin:0 0 20px}.daily-number{font-size:57px;line-height:1.1;letter-spacing:-3px;font-weight:450;font-variant-numeric:tabular-nums;margin:28px 0 9px}.metric-caption{font-size:10px;color:var(--muted);margin:0}.breakdown{display:grid;grid-template-columns:repeat(2,1fr);gap:18px 16px;margin:27px 0 24px}.breakdown-item{display:flex;align-items:baseline;justify-content:space-between;font-size:10px;gap:6px;color:var(--muted)}.breakdown-item strong{font-size:12px;font-weight:500;color:var(--text);font-variant-numeric:tabular-nums}.reasoning{font-size:9px;color:var(--muted);margin:-8px 0 18px}.card-foot{border-top:1px solid var(--line);padding-top:15px;display:flex;justify-content:space-between;gap:8px;font-size:10px;color:var(--muted)}.week-heading{display:flex;align-items:baseline;gap:10px;margin:27px 0 0}.week-heading strong{font-size:29px;letter-spacing:-1px;font-weight:500}.chart{height:175px;margin:6px 0 12px}.chart svg{width:100%;height:100%;overflow:visible}.chart rect{fill:#cfd5ca}.chart rect.last{fill:var(--fill)}.chart text{fill:var(--muted);font-size:9px;font-family:inherit}.chart .gridline{stroke:var(--line);stroke-width:1}.ranking-row{padding:13px 0;border-bottom:1px solid var(--line)}.ranking-row:last-child{border-bottom:0}.ranking-heading{display:flex;justify-content:space-between;align-items:center;gap:12px;font-size:12px;margin-bottom:9px}.ranking-heading span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ranking-heading strong{font-size:11px;font-weight:500;font-variant-numeric:tabular-nums;white-space:nowrap}.ranking-row progress{height:3px;--fill:#c7cec2}.fine-print{font-size:10px;line-height:1.9;color:var(--muted);margin:22px 0 0}.empty{color:var(--muted);font-size:12px;padding:24px 0}.quota-grid>.empty{padding:30px;grid-column:1/-1}.install{border-top:1px solid var(--line);margin-top:35px;padding-top:22px;font-size:11px;color:var(--muted);line-height:2}.install summary{cursor:pointer;list-style:none;color:var(--text);display:flex;justify-content:space-between;font-size:12px}.install li{padding:3px 0}.install code{color:var(--text)}footer{display:flex;justify-content:space-between;align-items:center;gap:20px;margin:30px 0 12px;font-size:8px;color:var(--muted);letter-spacing:1px}.footer-separator{margin:0 8px;color:#c5c9bf}.method{font-size:9px;color:var(--muted);line-height:1.9;margin:0}.notice{border:1px solid var(--line);background:var(--soft);font-size:11px;padding:12px 16px;border-radius:9px;margin-bottom:24px;line-height:1.7}.notice[hidden]{display:none}dialog{border:1px solid var(--line);border-radius:18px;padding:30px;background:var(--panel);color:var(--text);width:min(400px,calc(100% - 32px));box-shadow:0 20px 100px #0002}dialog::backdrop{background:#272b2777;backdrop-filter:blur(5px)}dialog h2{font-size:22px;font-weight:550;margin:22px 0 12px}dialog p{font-size:12px;color:var(--muted);line-height:1.8}dialog label{display:block;font-size:11px;margin:22px 0 9px}dialog input{width:100%;padding:12px;border:1px solid var(--line);background:var(--bg);color:var(--text);border-radius:8px;font-size:13px}dialog button{width:100%;margin-top:13px;padding:12px;border:0;border-radius:8px;background:var(--text);color:var(--panel);font-size:13px}.auth-error{color:var(--bad);min-height:18px;margin-bottom:0}:focus-visible{outline:2px solid var(--accent);outline-offset:4px}
4
+ @media(prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#171916;--panel:#1f221e;--text:#ebeee7;--muted:#92998d;--line:#31362e;--track:#30362d;--fill:#b9c8b0;--accent:#a6bb9a;--warn:#d2a061;--bad:#e09c88;--soft:#292e26}:root:not([data-theme="light"]) .chart rect{fill:#525f4b}:root:not([data-theme="light"]) .ranking-row progress{--fill:#66765c}}
5
+ @media(max-width:700px){.shell{padding:0 22px}.topbar{height:79px}main{padding-top:30px}.page-heading{margin-bottom:30px}h1{font-size:28px}.eyebrow{font-size:8px;letter-spacing:2px}.intro{font-size:11px}.quota-grid{grid-template-columns:1fr;gap:12px;margin-bottom:18px}.quota-card{padding:19px 21px}.provider-heading{margin-bottom:15px}.quota-card .windows{display:grid;grid-template-columns:1fr 1fr;gap:25px}.quota-card .window{margin:0}.quota-card .window-value,.quota-card .primary .window-value{font-size:27px;letter-spacing:-1px}.quota-card .primary .window-value small{font-size:14px}.quota-card .window-heading{flex-wrap:wrap;gap:4px}.quota-card .window-name{order:2;width:100%;font-size:10px}.quota-card .window-reset{font-size:9px}.activity-grid{grid-template-columns:1fr;gap:16px}.panel{border-radius:13px}.today-card,.trend-card,.detail-panel{padding:23px}.daily-number{font-size:51px;margin-top:24px}.breakdown{margin-top:25px}.chart{height:165px}.section-heading{margin-bottom:14px}.section-heading>.muted{font-size:9px}.detail-grid .ranking-row{padding:13px 0}footer{align-items:flex-start;line-height:1.8;flex-direction:column;gap:8px}.connection{font-size:10px}.brand{font-size:25px}.refresh{padding:9px 12px}.refresh>span:last-child{display:none}.method{font-size:8px}.install{margin-top:26px}.week-heading{margin-top:22px}}
6
+ :root[data-theme="dark"]{--bg:#171916;--panel:#1f221e;--text:#ebeee7;--muted:#92998d;--line:#31362e;--track:#30362d;--fill:#b9c8b0;--accent:#a6bb9a;--warn:#d2a061;--bad:#e09c88;--soft:#292e26}:root[data-theme="dark"] .chart rect{fill:#525f4b}:root[data-theme="dark"] .ranking-row progress{--fill:#66765c}
7
+ :root[data-theme="dark"] .chart rect.last{fill:var(--fill)}@media(prefers-color-scheme:dark){:root:not([data-theme="light"]) .chart rect.last{fill:var(--fill)}}
8
+
9
+ .segmented{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel)}.segmented button{border:0;background:transparent;color:var(--muted);font-size:11px;padding:6px 12px;letter-spacing:.3px;cursor:pointer}.segmented button+button{border-left:1px solid var(--line)}.segmented button.active{background:var(--soft);color:var(--text);font-weight:550}.window-amount{font-size:10px;color:var(--muted);margin:6px 0 0;font-variant-numeric:tabular-nums}.extras{font-size:9px;color:var(--muted);margin:-8px 0 18px;font-variant-numeric:tabular-nums}.rank-note{margin:16px 0 0;font-size:10px}.ranking-value{display:inline-flex;align-items:baseline;gap:8px}.ranking-cost{font-size:10px;color:var(--muted);font-variant-numeric:tabular-nums}@media(max-width:700px){.segmented button{padding:5px 9px;font-size:10px}.rank-note{font-size:9px}}
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "magpie-page-token-widget",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "An iPhone home screen widget for Magpie subscription quotas: install the plugin, scan a code, and the widget shows each subscription's remaining quota.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "files": [
8
8
  "index.mjs",
9
9
  "lib",
10
+ "mobile",
10
11
  "page",
11
12
  "scriptable",
12
13
  "ui",