@fish-under-sea/magpie-neartoken 0.1.8 → 0.2.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
@@ -41,6 +41,16 @@
41
41
 
42
42
  模型目录读的是站点的 `/api/pricing`(公开、全量),不用 `/v1/models`——那个端点按令牌分组过滤,很多账号拿到的是空列表,magpie 的「重新获取模型」就会报 `no models listed`。这里不碰它。
43
43
 
44
+ ## 三个视角
45
+
46
+ 页面上方三个标签:
47
+
48
+ | 标签 | 内容 |
49
+ | --- | --- |
50
+ | 原本界面 | 账户、余额与代金券、消耗看板、分组开关、充值、供应商说明 |
51
+ | 模型广场 | 按站点返回的分组重绘:低价 / 满血 / 图片 / 视频 / 语音 / 向量,每组一枚可点的开关(关掉即从 magpie 的模型列表里移除) |
52
+ | 使用日志 | 这个窗口里每个模型花了多少、用了多少 token、占比多少,可按日 / 周 / 月切 |
53
+
44
54
  ## 页面怎么用
45
55
 
46
56
  1. 装上包,侧边栏「插件」→「NearToken」。
package/index.mjs CHANGED
@@ -117,7 +117,7 @@ const hasWord = (id, words) => words.some((w) => id.toLowerCase().includes(w))
117
117
  * 「站点原文组名 + 倍率 + 模型」。每行带自己的 x-neartoken-group 头,请求
118
118
  * 按那一行走那一组——这就是"模型后面跟着它的分组"。
119
119
  */
120
- async function catalog() {
120
+ async function catalog(disabled = new Set()) {
121
121
  const env = await call('/api/pricing')
122
122
  const rows = Array.isArray(env?.data) ? env.data : []
123
123
  const refs = env?.group_refs && typeof env.group_refs === 'object' ? env.group_refs : {}
@@ -131,7 +131,9 @@ async function catalog() {
131
131
  const drawer = hasWord(model, DRAWS) || ends.includes('image-generation')
132
132
  const film = hasWord(model, FILMS)
133
133
  if (!chat && !drawer && !film) continue
134
- const groups = Array.isArray(row?.enable_groups) && row.enable_groups.length ? row.enable_groups : ['']
134
+ const all = Array.isArray(row?.enable_groups) && row.enable_groups.length ? row.enable_groups : ['']
135
+ const groups = all.filter((g) => !disabled.has(String(g ?? '').trim()))
136
+ if (!groups.length) continue
135
137
  for (const raw of groups) {
136
138
  const group = String(raw ?? '').trim()
137
139
  const ref = group && refs[group] ? String(refs[group]) : ''
@@ -157,7 +159,22 @@ async function catalog() {
157
159
  return out
158
160
  }
159
161
 
160
- export default async function neartoken() {
162
+ export default async function neartoken(input, options) {
163
+ // 页面写进来的开关:关掉的分组,它们的模型不再出现在 magpie 的列表里。
164
+ // 走的是 magpie 的 plugin options(页面 magpie.options.set),不碰宿主代码。
165
+ const disabled = (() => {
166
+ const raw = options?.disabledGroups
167
+ if (Array.isArray(raw)) return new Set(raw.filter((g) => typeof g === 'string'))
168
+ if (typeof raw === 'string' && raw.trim()) {
169
+ try {
170
+ const parsed = JSON.parse(raw)
171
+ return new Set(Array.isArray(parsed) ? parsed.filter((g) => typeof g === 'string') : [])
172
+ } catch {
173
+ return new Set()
174
+ }
175
+ }
176
+ return new Set()
177
+ })()
161
178
  return {
162
179
  auth: {
163
180
  provider: PROVIDER_ID,
@@ -274,7 +291,7 @@ export default async function neartoken() {
274
291
  * @returns {Promise<Record<string, object>>}
275
292
  */
276
293
  async models() {
277
- return catalog()
294
+ return catalog(disabled)
278
295
  },
279
296
  },
280
297
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fish-under-sea/magpie-neartoken",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "NearToken for magpie: a provider whose sign-in is the site's own device authorization, models listed per billing group with their multiplier, balance and vouchers on magpie's usage card, plus a page for spending and top-ups.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
package/page/app.js CHANGED
@@ -18,6 +18,11 @@
18
18
  const windows = new Map()
19
19
  /** 充值面:档位、渠道、当前选择。 */
20
20
  let topupInfo = null
21
+ /** 插件自己的开关:关掉的分组(写进 magpie 的 plugin options,provider 插件按它过滤)。 */
22
+ let options = {}
23
+ let disabledGroups = new Set()
24
+ /** 当前视角:home / market / logs。 */
25
+ let tab = 'home'
21
26
  const pick = { amount: null, method: null, custom: '' }
22
27
  /** 正在等宿主的哪个动作。 */
23
28
  let pendingAct = ''
@@ -92,12 +97,22 @@
92
97
  // ---------- 主流程 ----------
93
98
 
94
99
  async function boot() {
100
+ // 预览用:?tab=market 可直接落在某个视角,生产里没这个参数就是原本界面
101
+ const want = new URLSearchParams(location.search).get('tab')
102
+ if (want === 'market' || want === 'logs' || want === 'home') tab = want
95
103
  if (!bridge || typeof bridge.call !== 'function') {
96
104
  fail('这个 magpie 还没有插件页的调用通道。')
97
105
  return
98
106
  }
99
107
  try {
100
108
  await bridge.ready
109
+ // 插件自己的开关(关掉的分组):写在这里,provider 插件按它过滤模型
110
+ try {
111
+ options = (await bridge.options.get()) || {}
112
+ } catch {
113
+ options = {}
114
+ }
115
+ disabledGroups = new Set(Array.isArray(options.disabledGroups) ? options.disabledGroups : [])
101
116
  await load()
102
117
  clock = setInterval(tick, 1000)
103
118
  bridge.onVisibility((visible) => {
@@ -161,7 +176,10 @@
161
176
  frag.append(pendingCard())
162
177
  } else if (view.configured) {
163
178
  setStatus('已连接', 'on')
164
- frag.append(accountCard(), boardCard(), topupCard(), providerCard())
179
+ frag.append(tabsBar())
180
+ if (tab === 'market') frag.append(marketCard())
181
+ else if (tab === 'logs') frag.append(logsCard())
182
+ else frag.append(accountCard(), boardCard(), groupsCard(), topupCard(), providerCard())
165
183
  } else {
166
184
  setStatus('未连接')
167
185
  frag.append(connectCard())
@@ -173,6 +191,25 @@
173
191
  if (view.pending) paintCountdown()
174
192
  }
175
193
 
194
+ /** tabsBar 是三个视角:原本界面、模型广场、使用日志。 */
195
+ function tabsBar() {
196
+ const tabs = [['home', '原本界面'], ['market', '模型广场'], ['logs', '使用日志']]
197
+ return el('div', { class: 'tabs', role: 'tablist' },
198
+ tabs.map(([key, label]) => el('button', {
199
+ role: 'tab',
200
+ 'aria-selected': String(tab === key),
201
+ class: tab === key ? 'on' : '',
202
+ onclick: () => {
203
+ if (tab === key) return
204
+ tab = key
205
+ render()
206
+ if (key !== 'home' && view.configured && !windows.has(span)) {
207
+ call('neartoken:usage', { range: span }).then((w) => { windows.set(span, w); render() }).catch(showError)
208
+ }
209
+ },
210
+ }, label)))
211
+ }
212
+
176
213
  /** 未连接:说清会发生什么,再给一个按钮。 */
177
214
  function connectCard() {
178
215
  return card('连接中转站',
@@ -330,6 +367,156 @@
330
367
  return card('充值', ...parts)
331
368
  }
332
369
 
370
+ /** 分组落在哪一类:低价 / 满血 / 图片 / 视频 / 语音 / 向量 / 其他。 */
371
+ function bucketOf(name) {
372
+ const n = String(name || '')
373
+ if (/生图|图片|image|画/i.test(n)) return '图片'
374
+ if (/视频|video/i.test(n)) return '视频'
375
+ if (/语音|tts|asr|转写|whisper|speech/i.test(n)) return '语音'
376
+ if (/向量|embedding/i.test(n)) return '向量'
377
+ if (/低价/.test(n)) return '低价'
378
+ if (/满血|官key|pro/i.test(n)) return '满血'
379
+ return '其他'
380
+ }
381
+ const BUCKET_ORDER = ['低价', '满血', '图片', '视频', '语音', '向量', '其他']
382
+
383
+ /** 模型广场:按站点返回的分组重绘,分组即为开关。 */
384
+ function marketCard() {
385
+ const list = Array.isArray(view.groupList) ? view.groupList : null
386
+ const head = el('div', { class: 'row' },
387
+ el('h2', { class: 'grow', text: '分组', style: 'margin:0' }),
388
+ el('a', { class: 'link', href: 'https://neartoken.cc/pricing', target: '_blank', rel: 'noreferrer' }, '站点广场 ↗'))
389
+ if (!list || !list.length) {
390
+ return card(null, head,
391
+ el('p', { class: 'note' },
392
+ '宿主还没把分组明细交给页面(`neartoken:state` 目前只回分组数)。'),
393
+ el('p', { class: 'note' },
394
+ '开关已接好:写进 magpie 的 plugin options,provider 插件按它过滤模型表;',
395
+ '宿主一回 groupList,这里就按站点分组重绘。先去 ',
396
+ el('a', { class: 'link', href: 'https://neartoken.cc/pricing', target: '_blank', rel: 'noreferrer' }, '站点广场'),
397
+ ' 看分组与倍率。'))
398
+ }
399
+ const buckets = new Map()
400
+ for (const g of list) {
401
+ const key = bucketOf(g?.name)
402
+ if (!buckets.has(key)) buckets.set(key, [])
403
+ buckets.get(key).push(g)
404
+ }
405
+ const parts = [head]
406
+ for (const key of BUCKET_ORDER) {
407
+ const groups = buckets.get(key)
408
+ if (!groups || !groups.length) continue
409
+ const chips = groups.map((g) => groupChip(g))
410
+ parts.push(el('div', { class: 'bucket' },
411
+ el('div', { class: 'bucket-name' }, key, el('span', { class: 'n', text: String(groups.length) })),
412
+ el('div', { class: 'groups' }, chips)))
413
+ }
414
+ const off = disabledGroups.size
415
+ parts.push(el('p', { class: 'note', text: off
416
+ ? '已关掉 ' + off + ' 个分组:它们的模型不再出现在 magpie 的模型列表里。'
417
+ : '全部开着。点一下关掉一组,它的模型就不再出现在 magpie 的模型列表里。' }))
418
+ return card(null, ...parts)
419
+ }
420
+
421
+ /** groupChip 是一枚分组开关:站点原文组名 + 倍率 + 模型数。 */
422
+ function groupChip(g) {
423
+ const name = String(g?.name ?? '')
424
+ const on = !disabledGroups.has(name)
425
+ const ratio = Number(g?.ratio) || 0
426
+ const label = ratio > 0 ? name + ' ' + ratioText(ratio) + 'x' : name
427
+ return el('button', {
428
+ class: 'group-chip',
429
+ 'aria-pressed': String(on),
430
+ title: g?.models ? g.models + ' 个模型' : '',
431
+ onclick: () => toggleGroup(name),
432
+ }, label, el('span', { class: 'n', text: String(g?.models || 0) }))
433
+ }
434
+
435
+ /** 使用日志:这个窗口里每个模型花了多少、用了多少 token。 */
436
+ function logsCard() {
437
+ const data = windows.get(span)
438
+ const seg = el('div', { class: 'seg' },
439
+ ['day', 'week', 'month'].map((key) =>
440
+ el('button', { 'aria-pressed': String(span === key), onclick: () => switchSpan(key) },
441
+ key === 'day' ? '日' : key === 'week' ? '周' : '月')))
442
+ const head = el('div', { class: 'row' },
443
+ el('h2', { class: 'grow', text: '使用日志', style: 'margin:0' }), seg)
444
+ if (!data) return card(null, head, el('p', { class: 'note', text: '正在读取用量…' }))
445
+ if (!data.available) {
446
+ return card(null, head, el('p', { class: 'note warn', text: data.reason === 'unreachable'
447
+ ? '暂时读不到站点的用量数据。' : '站点未提供用量统计。' }))
448
+ }
449
+ const rows = (data.models || []).slice().sort((a, b) => b.tokens - a.tokens)
450
+ const total = rows.reduce((sum, m) => sum + m.tokens, 0) || 1
451
+ const table = el('div', { class: 'log-table' },
452
+ el('div', { class: 'log-row head' },
453
+ el('span', { text: '模型' }), el('span', { text: 'Token' }), el('span', { text: '花费' }), el('span', { text: '占比' })),
454
+ rows.map((m) => el('div', { class: 'log-row' },
455
+ el('span', { class: 'who', title: m.model, text: m.model }),
456
+ el('span', { class: 'num', text: compact(m.tokens) }),
457
+ el('span', { class: 'num', text: m.costText || '' }),
458
+ el('span', { class: 'num', text: Math.round((m.tokens / total) * 100) + '%' }))))
459
+ return card(null, head,
460
+ el('div', { class: 'stats' },
461
+ el('div', { class: 'stat' }, el('div', { class: 'k', text: '消耗' }), el('div', { class: 'v', text: data.spent })),
462
+ el('div', { class: 'stat' }, el('div', { class: 'k', text: '当前余额' }), el('div', { class: 'v', text: data.balance || '—' })),
463
+ el('div', { class: 'stat' }, el('div', { class: 'k', text: 'Token' }), el('div', { class: 'v', text: compact(data.tokens) }))),
464
+ table,
465
+ el('p', { class: 'note', text: '数字来自站点用量端点;这个窗口里没有调用就不列模型。' }))
466
+ }
467
+ /** 分组墙:站点原文组名 + 倍率 + 模型数,点一下启停(写进插件 options)。 */
468
+ function groupsCard() {
469
+ const list = Array.isArray(view.groupList) ? view.groupList : null
470
+ const head = el('div', { class: 'row' },
471
+ el('h2', { class: 'grow', text: '分组', style: 'margin:0' }),
472
+ el('a', { class: 'link', href: 'https://neartoken.cc/pricing', target: '_blank', rel: 'noreferrer' }, '站点广场 ↗'))
473
+ if (!list || !list.length) {
474
+ return card(null, head,
475
+ el('p', { class: 'note' },
476
+ '宿主还没把分组明细交给页面。开关已经接好(写进 magpie 的 plugin options,provider 插件按它滤模型),',
477
+ '宿主一回 groupList,这里就是一墙可点的分组。先去 ',
478
+ el('a', { class: 'link', href: 'https://neartoken.cc/pricing', target: '_blank', rel: 'noreferrer' }, '站点广场'),
479
+ ' 看分组与倍率。'))
480
+ }
481
+ const chips = list.map((g) => {
482
+ const name = String(g?.name ?? '')
483
+ const on = !disabledGroups.has(name)
484
+ const ratio = Number(g?.ratio) || 0
485
+ const label = ratio > 0 ? name + ' ' + ratioText(ratio) + 'x' : name
486
+ return el('button', {
487
+ class: 'group-chip',
488
+ 'aria-pressed': String(on),
489
+ title: g?.models ? g.models + ' 个模型' : '',
490
+ onclick: () => toggleGroup(name),
491
+ }, label, el('span', { class: 'n', text: String(g?.models || 0) }))
492
+ })
493
+ const off = disabledGroups.size
494
+ return card(null, head,
495
+ el('div', { class: 'groups' }, chips),
496
+ el('p', { class: 'note', text: off
497
+ ? '已关掉 ' + off + ' 个分组:它们的模型不再出现在 magpie 的模型列表里。'
498
+ : '全部开着。点一下关掉一组,它的模型就不再出现在 magpie 的模型列表里。' }))
499
+ }
500
+
501
+ /** toggleGroup 翻转一个分组,并写回插件 options(provider 插件按它过滤)。 */
502
+ async function toggleGroup(name) {
503
+ if (!name) return
504
+ if (disabledGroups.has(name)) disabledGroups.delete(name)
505
+ else disabledGroups.add(name)
506
+ render()
507
+ try {
508
+ const next = await bridge.options.set({ ...options, disabledGroups: [...disabledGroups] })
509
+ if (next && typeof next === 'object') options = next
510
+ } catch (error) {
511
+ showError(error)
512
+ }
513
+ }
514
+
515
+ /** ratioText 是倍率的写法:0.1 → 0.1,1 → 1。 */
516
+ function ratioText(v) {
517
+ return String(Math.round(v * 10000) / 10000)
518
+ }
519
+
333
520
  /** 供应商卡:说明模型已经交给 magpie 了。 */
334
521
  function providerCard() {
335
522
  const models = view.models || 0
package/page/style.css CHANGED
@@ -382,4 +382,69 @@ select {
382
382
  clip: rect(0 0 0 0);
383
383
  white-space: nowrap;
384
384
  border: 0;
385
- }
385
+ }
386
+ /* 分组墙:像模型广场那样成片的组名与倍率 */
387
+ .groups { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 4px; }
388
+
389
+ .group-chip {
390
+ display: inline-flex;
391
+ align-items: baseline;
392
+ gap: 7px;
393
+ height: 30px;
394
+ padding: 0 11px;
395
+ border-radius: 999px;
396
+ border: 1px solid var(--nt-line);
397
+ background: transparent;
398
+ color: var(--nt-muted);
399
+ font-size: 12px;
400
+ white-space: nowrap;
401
+ }
402
+
403
+ .group-chip[aria-pressed="true"] {
404
+ color: var(--nt-ink);
405
+ border-color: var(--nt-accent);
406
+ background: var(--nt-accent-soft);
407
+ }
408
+
409
+ .group-chip .n {
410
+ font-family: var(--nt-code);
411
+ font-size: 10.5px;
412
+ color: var(--nt-faint);
413
+ font-variant-numeric: tabular-nums;
414
+ }
415
+
416
+ .group-chip[aria-pressed="true"] .n { color: var(--nt-muted); }
417
+
418
+ .link { color: var(--nt-accent); text-decoration: none; font-size: 11.5px; }
419
+ .link:hover { text-decoration: underline; }
420
+ /* 三个视角:原本界面 / 模型广场 / 使用日志 */
421
+ .tabs { display: inline-flex; gap: 2px; padding: 3px; margin: 14px 0 0; border: 1px solid var(--nt-line); border-radius: 10px; }
422
+
423
+ .tabs button {
424
+ border: 0; height: 26px; padding: 0 12px; border-radius: 8px;
425
+ color: var(--nt-muted); background: transparent; font-size: 12px;
426
+ }
427
+
428
+ .tabs button.on { color: var(--nt-ink); background: var(--nt-accent-soft); box-shadow: inset 0 0 0 1px var(--nt-accent); }
429
+
430
+ /* 模型广场:一类一组,组名在上,开关在下 */
431
+ .bucket { margin: 14px 0 0; }
432
+ .bucket-name { display: flex; align-items: baseline; gap: 7px; font-size: 11.5px; color: var(--nt-muted); margin-bottom: 8px; }
433
+ .bucket-name .n { font-family: var(--nt-code); font-size: 10.5px; color: var(--nt-faint); }
434
+
435
+ /* 使用日志 */
436
+ .log-table { margin-top: 12px; border-top: 1px solid var(--nt-line); }
437
+
438
+ .log-row {
439
+ display: grid;
440
+ grid-template-columns: 1fr 84px 92px 56px;
441
+ gap: 8px;
442
+ padding: 7px 0;
443
+ border-bottom: 1px solid var(--nt-line);
444
+ font-size: 12px;
445
+ align-items: baseline;
446
+ }
447
+
448
+ .log-row.head { color: var(--nt-muted); font-size: 11px; border-bottom-color: transparent; }
449
+ .log-row .who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
450
+ .log-row .num { text-align: right; font-family: var(--nt-code); font-variant-numeric: tabular-nums; color: var(--nt-muted); }