@ebony-vinyl/dsh-our-free-model 2.8.2 → 2.8.4

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
@@ -39,11 +39,11 @@
39
39
  ## 交流群
40
40
 
41
41
  <div align="center">
42
- <img src="docs/images/qq-group-386179533.png" alt="ai技术交流学习群入群二维码(群号:386179533)" width="300">
42
+ <img src="docs/images/qq-group-891170115.png" alt="OFM技术交流群17入群二维码(群号:891170115)" width="300">
43
43
  <br>
44
- <b>QQ 群:386179533(ai技术交流学习群)</b>
44
+ <b>QQ 群:891170115(OFM技术交流群17)</b>
45
45
  <br>
46
- 扫码或搜索群号加入群聊,一起交流插件使用心得、反馈问题与分享玩法。
46
+ 扫码加入群聊,一起交流插件使用心得、反馈问题与分享玩法。
47
47
  </div>
48
48
 
49
49
  ---
@@ -131,7 +131,7 @@ EAC、客户端与订阅、资源库、API 接入、设置与**插件中心**)
131
131
  > `dsh-our-free-model-<版本>.tgz` 同源、同版本。只想走 npm 时可以装它:
132
132
  >
133
133
  > ```
134
- > dsh plugin --profile web add @ebony-vinyl/dsh-our-free-model@2.8.2
134
+ > dsh plugin --profile web add @ebony-vinyl/dsh-our-free-model@2.8.4
135
135
  > ```
136
136
  >
137
137
  > 注意它的包名带 scope,因此挂载条目是 `@ebony-vinyl/dsh-our-free-model`,与下文
@@ -163,7 +163,7 @@ PROFILE_UPGRADE_REQUIRED: offline dependency migration is not yet available
163
163
  以真实目录手工安装时,使用可信源码打出的本地 tarball:
164
164
 
165
165
  1. 创建 `<DSH_HOME>/profiles/<profile>/vendor/`,在审核过的本仓库目录运行 `npm pack --ignore-scripts --pack-destination "<DSH_HOME>/profiles/<profile>/vendor"`,或将本仓库正式发布的同版本 tarball 放入该目录。本地打包不执行 npm 发布。
166
- 2. 在 profile 的 `package.json` 的 dependencies 中加入 `"dsh-our-free-model": "file:./vendor/dsh-our-free-model-2.8.2.tgz"`。文件名版本跟随仓库 `package.json`,不要改成同名 npm 注册表版本;再通过宿主的安装流程解析依赖。
166
+ 2. 在 profile 的 `package.json` 的 dependencies 中加入 `"dsh-our-free-model": "file:./vendor/dsh-our-free-model-2.8.4.tgz"`。文件名版本跟随仓库 `package.json`,不要改成同名 npm 注册表版本;再通过宿主的安装流程解析依赖。
167
167
  3. 在 dsh.profile.bundles 末尾追加 `"dsh-our-free-model"`。
168
168
 
169
169
  包管理器拥有这份安装的字节时,同时将 bundle config 或插件 `settings.json` 的 `distribution` 设为 `managed`,由包管理器统一更新;应用内升级、公告 feed 与热重载会停用,模型功能保留。以后更新时替换可信 tarball 并同步依赖路径,避免包管理器按旧锁文件恢复旧版本。
@@ -777,6 +777,13 @@ MIT,见 LICENSE。
777
777
  本项目为独立插件,与任何模型提供方无隶属、认可或赞助关系。使用该插件访问免费额度受各提供方
778
778
  自身条款约束;在超出个人机器的场景中部署前,请先确认这些条款。
779
779
 
780
+ ## 致谢
781
+
782
+ 感谢 [Jet(iJetLi)](https://gitee.com/iJetLi) 的渠道包(`vendor/channel-pack/`,MIT)——
783
+ 十个内置账号渠道的登录、账号池与本地网关都出自这里,许可证与出处见该目录下的 LICENSE 与 NOTICE.md;
784
+ 也感谢 [opencode2api](https://github.com/jasonxu114514/opencode2api)(`src/o2a/`,GPL-3.0)——
785
+ 免费车道的请求整形移植自它,逐文件对照表见 `src/o2a/SYNC.md`。
786
+
780
787
  ## Star 趋势
781
788
 
782
789
  图表由 Star History 自动更新,无需手动提交图片。受服务缓存(当前为 24 小时)和 GitHub 图片缓存影响,显示可能延迟,并非秒级实时;点击图表可查看在线趋势。
package/client.js CHANGED
@@ -80,6 +80,20 @@ window.__ModuleLoader__.load({
80
80
  'tag.context': '上下文',
81
81
  'tag.output': '最长输出',
82
82
  'tag.latency': '首字',
83
+ 'perf.more': '详情',
84
+ 'perf.less': '收起',
85
+ 'perf.rate': '状态',
86
+ 'perf.rateTitle': '成功率排除业务拒绝,统计包含当前尚未结束的小时。',
87
+ 'perf.barsTitle': '近期成功率采样;灰色条表示暂无数据。',
88
+ 'perf.latency': '延迟',
89
+ 'perf.speed': '吞吐',
90
+ 'perf.windowNote': '最近 {h} 小时的性能指标,来自本站统计。',
91
+ 'perf.latencyTrend': '延迟趋势(最近 24 小时)',
92
+ 'perf.latencySub': '平均 Token 延迟',
93
+ 'perf.rateTrend': '可用率(最近 24 小时)',
94
+ 'perf.rateSub': '成功率排除业务拒绝,统计包含当前尚未结束的小时。',
95
+ 'perf.loading': '正在读取性能数据…',
96
+ 'perf.noSeries': '本站暂无该模型的逐小时数据。',
83
97
  'tag.eacChannel': 'EAC 渠道 · 协付',
84
98
  'tag.new': 'NEW',
85
99
  'tag.newTitle': '新上线的模型,经 EAC 渠道转发自 OpenCode 免费车道。',
@@ -669,11 +683,11 @@ window.__ModuleLoader__.load({
669
683
  'chan.auto.off': '今日尚未签到',
670
684
  'chan.auto.last': '上次结果:{result}',
671
685
  'nav.tab.aggregate': '聚合渠道',
672
- 'nav.tab.workspace': '模型工作台',
686
+ 'nav.tab.workspace': '模型工作台(magpie)',
673
687
  'engine.start': '打开工作台',
674
688
  'engine.starting': '正在启动工作台…',
675
689
  'engine.failed': '工作台启动失败',
676
- 'engine.title': '模型工作台',
690
+ 'engine.title': '模型工作台(magpie)',
677
691
  'engine.refresh': '刷新模型',
678
692
  'engine.expand': '展开工作台',
679
693
  'engine.close': '收起工作台',
@@ -955,6 +969,20 @@ window.__ModuleLoader__.load({
955
969
  'tag.context': 'Context',
956
970
  'tag.output': 'Max output',
957
971
  'tag.latency': 'First token',
972
+ 'perf.more': 'Details',
973
+ 'perf.less': 'Hide',
974
+ 'perf.rate': 'Status',
975
+ 'perf.rateTitle': 'Success rate excludes business rejections; the current unfinished hour is included.',
976
+ 'perf.barsTitle': 'Recent success-rate samples; a grey tick means no data.',
977
+ 'perf.latency': 'Latency',
978
+ 'perf.speed': 'Throughput',
979
+ 'perf.windowNote': 'Performance over the last {h} hours, from this site\u2019s own metrics.',
980
+ 'perf.latencyTrend': 'Latency trend (last 24h)',
981
+ 'perf.latencySub': 'Average token latency',
982
+ 'perf.rateTrend': 'Availability (last 24h)',
983
+ 'perf.rateSub': 'Success rate excludes business rejections; the current unfinished hour is included.',
984
+ 'perf.loading': 'Reading performance data\u2026',
985
+ 'perf.noSeries': 'This site has no hourly data for this model.',
958
986
  'tag.eacChannel': 'EAC lane · co-paid',
959
987
  'tag.new': 'NEW',
960
988
  'tag.newTitle': 'Just added: relayed to the EAC lane from OpenCode’s free tier.',
@@ -1544,11 +1572,11 @@ window.__ModuleLoader__.load({
1544
1572
  'chan.auto.off': 'Not checked in today',
1545
1573
  'chan.auto.last': 'Last result: {result}',
1546
1574
  'nav.tab.aggregate': 'Aggregate',
1547
- 'nav.tab.workspace': 'Model workspace',
1575
+ 'nav.tab.workspace': 'Model workspace (magpie)',
1548
1576
  'engine.start': 'Open workspace',
1549
1577
  'engine.starting': 'Starting workspace…',
1550
1578
  'engine.failed': 'Workspace could not start',
1551
- 'engine.title': 'Model workspace',
1579
+ 'engine.title': 'Model workspace (magpie)',
1552
1580
  'engine.refresh': 'Refresh models',
1553
1581
  'engine.expand': 'Expand workspace',
1554
1582
  'engine.close': 'Collapse workspace',
@@ -1839,12 +1867,12 @@ window.__ModuleLoader__.load({
1839
1867
  .ofm_sechead{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;padding-bottom:6px;border-bottom:1px solid var(--dsw-alias-border-l1)}
1840
1868
  .ofm_sec_title{font-size:14px;font-weight:680;letter-spacing:-.2px}
1841
1869
  .ofm_sec_hint{font-size:11.5px;color:var(--dsw-alias-label-tertiary);margin-left:auto}
1842
- .ofm_grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(252px,1fr));gap:10px}
1870
+ .ofm_grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(252px,1fr));gap:10px;align-items:start}
1843
1871
  /* ══ 卡片(双层 bezel:hairline 边框 + 顶部内高光 + 柔和外阴影) ═══════ */
1844
1872
  .ofm_card{position:relative;display:flex;flex-direction:column;gap:8px;padding:12px 13px;border-radius:13px;border:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb,var(--dsw-alias-bg-layer-3) 80%,transparent);box-shadow:inset 0 1px 0 rgb(255 255 255 / 6%),0 1px 2px color-mix(in srgb,var(--dsw-alias-state-business-primary) 7%,transparent),0 3px 10px color-mix(in srgb,var(--dsw-alias-state-business-primary) 4%,transparent);transition:transform .2s cubic-bezier(.32,.72,0,1),box-shadow .24s ease,border-color .18s ease}
1845
1873
  .ofm_card:hover{transform:translateY(-2px);border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 66%,var(--dsw-alias-border-l2));box-shadow:inset 0 1px 0 rgb(255 255 255 / 9%),0 16px 34px color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent)}
1846
1874
  .ofm_card.dim{opacity:.68}
1847
- .ofm_cardhead{display:flex;align-items:center;gap:8px}
1875
+ .ofm_cardhead{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
1848
1876
  .ofm_cardname{font-size:13.5px;font-weight:650;letter-spacing:-.1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1849
1877
  .ofm_badge{margin-left:auto;font-size:10.5px;font-weight:600;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);background:color-mix(in srgb,var(--dsw-alias-bg-layer-1) 60%,transparent);white-space:nowrap}
1850
1878
  .ofm_badge.available{color:var(--dsw-alias-state-success-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 45%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent)}
@@ -1863,6 +1891,54 @@ window.__ModuleLoader__.load({
1863
1891
  .ofm_card.pinned:hover{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 62%,var(--dsw-alias-border-l2))}
1864
1892
  .ofm_metrics{display:flex;gap:12px;font-size:11px;color:var(--dsw-alias-label-tertiary);flex-wrap:wrap}
1865
1893
  .ofm_metrics b{color:var(--dsw-alias-label-secondary);font-weight:600;font-variant-numeric:tabular-nums}
1894
+ /* ══ 性能条(复刻站点模型广场:成功率柱 + 延迟 + 吞吐 + 详情) ═════════ */
1895
+ /* The site's strip is designed for a 317px card; the roster's grid is 252px.
1896
+ Two rows here rather than one: the three metrics keep the site's own gaps and
1897
+ type sizes, and the 详情 button drops beneath them instead of crushing the
1898
+ numbers it sits beside. */
1899
+ /* The strip has to survive the roster's 252px card, where three metric columns
1900
+ plus a button cannot share one line: the numbers would either spill past the
1901
+ border or collapse to "1…". So it wraps into two clean rows — the status
1902
+ column (label + 24-tick bar) with the 详情 button on the first, latency and
1903
+ throughput side by side on the second. At the site's own 317px and wider the
1904
+ status column and both figures still fit one line, and the wrap never fires. */
1905
+ .ofm_perf{display:flex;flex-wrap:wrap;align-items:center;gap:8px 10px;width:100%;min-width:0;padding-top:8px;border-top:1px solid var(--dsw-alias-border-l1)}
1906
+ .ofm_perfstats{display:flex;flex-wrap:wrap;align-items:flex-start;gap:8px 14px;flex:1 1 auto;min-width:0;margin:0;font-size:12px;font-variant-numeric:tabular-nums}
1907
+ .ofm_perfcol{min-width:0}
1908
+ /* The status column always owns its row, so the bar is never cut in half. */
1909
+ .ofm_perfcol.status{width:96px;flex:0 0 100%}
1910
+ /* The two figures then share the next row, each with room for a full reading
1911
+ (a value like 119.1t/s needs about 48px). */
1912
+ .ofm_perfcol.value{flex:1 1 60px;min-width:52px}
1913
+ .ofm_perflabel{display:flex;align-items:center;justify-content:space-between;gap:4px;margin:0;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;min-width:0}
1914
+ .ofm_perfnum{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-secondary)}
1915
+ .ofm_perflabel .ofm_perfnum{font-size:11px}
1916
+ /* A figure that still does not fit is clipped, never allowed to run past the
1917
+ card's edge: an ellipsis reads as "too narrow here", a number hanging outside
1918
+ the border reads as a broken layout. */
1919
+ .ofm_perfnum.wide{display:block;margin-top:4px;font-size:12px;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--dsw-alias-label-primary)}
1920
+ .ofm_perfbars{display:flex;align-items:center;gap:1px;height:12px;width:96px;max-width:100%;margin:4px 0 0;overflow:hidden}
1921
+ /* Each tick keeps the site's 3px at full width and shrinks proportionally when
1922
+ the column is narrower — the bar must never widen the card it sits in. */
1923
+ .ofm_perfbar{flex:0 1 3px;min-width:0;height:100%;border-radius:2px;background:var(--dsw-alias-border-l1)}
1924
+ .ofm_perfbar.good{background:#34d399}
1925
+ .ofm_perfbar.fair{background:#f59e0b}
1926
+ .ofm_perfbar.poor{background:#ef4444}
1927
+ .ofm_perfbar.none{background:color-mix(in srgb,var(--dsw-alias-border-l1) 70%,transparent)}
1928
+ .ofm_perfmore{display:inline-flex;align-items:center;gap:4px;flex:none;height:26px;padding:0 10px;border-radius:13px;border:1px solid var(--dsw-alias-border-l1);background:transparent;font:inherit;font-size:12px;font-weight:500;color:var(--dsw-alias-label-primary);cursor:pointer;transition:background .15s ease,border-color .15s ease;margin-left:auto}
1929
+ .ofm_perfmore:hover{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1) 70%,transparent);border-color:var(--dsw-alias-border-l2)}
1930
+ .ofm_perfchev{font-size:9px;color:var(--dsw-alias-label-tertiary);line-height:1}
1931
+ .ofm_perfnote{flex-basis:100%;margin:0;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
1932
+ /* ══ 趋势图(详情展开:延迟趋势 + 可用率,站点同款) ═══════════════════ */
1933
+ .ofm_charts{flex-basis:100%;display:flex;flex-direction:column;gap:12px;margin-top:2px}
1934
+ .ofm_chartblock{display:flex;flex-direction:column;gap:4px}
1935
+ .ofm_charthead{display:flex;flex-direction:column;gap:1px}
1936
+ .ofm_charttitle{font-size:11.5px;font-weight:600;color:var(--dsw-alias-label-secondary)}
1937
+ .ofm_chartsub{font-size:10.5px;color:var(--dsw-alias-label-tertiary)}
1938
+ .ofm_chart{width:100%;height:auto;display:block;overflow:visible}
1939
+ .ofm_chartgrid{stroke:var(--dsw-alias-border-l1);stroke-width:.6}
1940
+ .ofm_chartaxis{font-size:6.4px;fill:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
1941
+ .ofm_chartdot{stroke:#fff;stroke-width:.6}
1866
1942
  .ofm_note{font-size:11px;color:var(--dsw-alias-label-tertiary);line-height:1.5}
1867
1943
  /* ══ 面板 ══════════════════════════════════════════════════════════════ */
1868
1944
  .ofm_panel{padding:16px;border-radius:16px;border:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb,var(--dsw-alias-bg-layer-2) 82%,transparent);box-shadow:inset 0 1px 0 rgb(255 255 255 / 5%),0 2px 6px rgb(0 0 0 / 4%);display:flex;flex-direction:column;gap:12px}
@@ -3466,7 +3542,8 @@ window.__ModuleLoader__.load({
3466
3542
 
3467
3543
  // ── model roster ──────────────────────────────────────────────────────────
3468
3544
  function ModelCard(props) {
3469
- const { model: m, t, onBench, bench, locked, eacLogin, onVisibility, busy } = props
3545
+ const { model: m, t, onBench, bench, locked, eacLogin, onVisibility, busy, perf } = props
3546
+ const [perfOpen, setPerfOpen] = useState(false)
3470
3547
  const listed = m.availabilitySource === 'listing'
3471
3548
  const manual = m.availabilitySource === 'manual'
3472
3549
  const stateKey = `state.${m.availability}`
@@ -3487,14 +3564,20 @@ window.__ModuleLoader__.load({
3487
3564
  ? m.efforts.map(levelName).join(' / ')
3488
3565
  : undefined
3489
3566
  return h('article', { className: 'ofm_card' + (dim ? ' dim' : '') + (m.pinned === true ? ' pinned' : '') },
3567
+ // Two rows, not one: the chips row carries the channel tag and the
3568
+ // badges, and the model name gets a full-width row of its own. They
3569
+ // used to share one flex line, where the name was the only child that
3570
+ // could shrink — so a card with EAC + NEW + 爆 + a status badge left it
3571
+ // about seven pixels and the name rendered as "EA…". A name is the one
3572
+ // thing on the card that must never be the thing that gives way.
3490
3573
  h('div', { className: 'ofm_cardhead' },
3491
3574
  m.channel === 'eac' ? h('span', { className: 'ofm_chantag', title: t('tag.eacChannel') }, 'EAC') : null,
3492
3575
  m.channel === 'eac' && locked === true ? h('span', { className: 'ofm_chantag', title: t('eac.lockedTitle') }, '🔒') : null,
3493
3576
  m.channel === 'kilo' ? h('span', { className: 'ofm_chantag', title: t('tag.kiloChannel') }, 'Kilo') : null,
3494
- h('span', { className: 'ofm_cardname', title: m.name }, m.name),
3495
3577
  m.isNew === true ? h('span', { className: 'ofm_newtag', title: t('tag.newTitle') }, t('tag.new')) : null,
3496
3578
  m.hot === true ? h('span', { className: 'ofm_hottag', title: t('tag.hotTitle') }, t('tag.hot')) : null,
3497
3579
  h('span', { className: 'ofm_badge ' + (listed ? 'unknown' : m.availability), title: listed ? t('hint.listed') : undefined }, listed ? t('state.listed') : t(stateKey))),
3580
+ h('div', { className: 'ofm_cardname', title: m.name }, m.name),
3498
3581
  h('div', { className: 'ofm_id', title: m.id }, m.label ?? m.id),
3499
3582
  h('div', { className: 'ofm_tags' },
3500
3583
  m.hiddenByUser === true ? h('span', { className: 'ofm_tag' }, t('visibility.hidden')) : null,
@@ -3524,6 +3607,39 @@ window.__ModuleLoader__.load({
3524
3607
  m.ttftMs === undefined || m.ttftMs === 0 ? null : h('span', null, t('tag.latency'), ' ', h('b', null, Math.round(m.ttftMs)), ' ms'),
3525
3608
  h('span', null, manual ? t('pref.measuredAt') : t('pref.probedAt'), ' ', h('b', null, ago(m.probedAt, t.locale)))),
3526
3609
  m.canDisableThinking === false ? h('p', { className: 'ofm_note' }, t('hint.sharedBudget')) : null,
3610
+ perf === undefined || perf.settling === true ? null : h(Fragment, null,
3611
+ // The site's own performance strip, reproduced: a 24-tick success bar
3612
+ // under its rate, then latency and throughput, then 详情. The bar is
3613
+ // the whole point — it shows the shape of the day, not just its
3614
+ // average, so a model that was fine this morning and is failing now
3615
+ // cannot hide behind a healthy-looking mean.
3616
+ h('div', { className: 'ofm_perf' },
3617
+ h('dl', { className: 'ofm_perfstats' },
3618
+ h('div', { className: 'ofm_perfcol status' },
3619
+ h('dt', { className: 'ofm_perflabel', title: t('perf.rateTitle') },
3620
+ h('span', null, t('perf.rate')),
3621
+ h('span', { className: 'ofm_perfnum' }, `${perf.rate.toFixed(2)}%`)),
3622
+ h('dd', { className: 'ofm_perfbars', title: t('perf.barsTitle'), role: 'img',
3623
+ 'aria-label': t('perf.barsTitle') },
3624
+ (perf.ticks ?? []).map((tick, i) => h('span', {
3625
+ key: i, className: 'ofm_perfbar ' + perfTone(tick),
3626
+ })))),
3627
+ h('div', { className: 'ofm_perfcol value' },
3628
+ h('dt', { className: 'ofm_perflabel' }, t('perf.latency')),
3629
+ h('dd', { className: 'ofm_perfnum wide' }, formatLatency(perf.latencyMs))),
3630
+ h('div', { className: 'ofm_perfcol value' },
3631
+ h('dt', { className: 'ofm_perflabel' }, t('perf.speed')),
3632
+ h('dd', { className: 'ofm_perfnum wide' }, formatSpeed(perf.speed)))),
3633
+ h('button', {
3634
+ type: 'button', className: 'ofm_perfmore', 'aria-expanded': perfOpen ? 'true' : 'false',
3635
+ onClick: () => setPerfOpen(open => !open),
3636
+ }, perfOpen ? t('perf.less') : t('perf.more'), h('span', { className: 'ofm_perfchev' }, perfOpen ? '▴' : '▾')),
3637
+ // The window the figures describe. Without it a reader has to guess
3638
+ // whether "92%" is this hour or the whole day — and the bar's shape
3639
+ // is only meaningful once you know how far back it reaches.
3640
+ h('p', { className: 'ofm_perfnote' },
3641
+ t('perf.windowNote').replace('{h}', String(perf.windowHours ?? 24)))),
3642
+ perfOpen ? h(PerfCharts, { modelId: m.id, t }) : null),
3527
3643
  onVisibility === undefined ? null : h('div', { className: 'ofm_sec' },
3528
3644
  h(Switch, { checked: m.hiddenByUser !== true, label: t('visibility.showModel'),
3529
3645
  disabled: busy || m.hiddenByGroup === true, onChange: () => onVisibility(m) }),
@@ -3533,6 +3649,191 @@ window.__ModuleLoader__.load({
3533
3649
  bench?.result === undefined ? null : h('span', { className: 'ofm_note' }, bench.result)))
3534
3650
  }
3535
3651
 
3652
+ /**
3653
+ * The site's own per-model performance figures, for the roster's capsules.
3654
+ *
3655
+ * One read per roster mount, keyed by catalog id. It is best-effort by
3656
+ * design: a failure leaves the map empty and every card renders exactly as
3657
+ * it did before this feature — a card must never fail to draw because a
3658
+ * metrics endpoint was slow. The server hop exists because the site's
3659
+ * endpoint sends no CORS headers (see index.js `/perf`).
3660
+ */
3661
+ function usePerfFigures() {
3662
+ const [figures, setFigures] = useState({})
3663
+ useEffect(() => {
3664
+ let alive = true
3665
+ api('/perf').then(payload => {
3666
+ if (!alive) return
3667
+ const models = payload?.models
3668
+ setFigures(models !== null && typeof models === 'object' ? models : {})
3669
+ }).catch(() => { /* no figures: every card renders without a capsule */ })
3670
+ return () => { alive = false }
3671
+ }, [])
3672
+ return figures
3673
+ }
3674
+
3675
+ /**
3676
+ * The panel 详情 opens: the site's two trend charts.
3677
+ *
3678
+ * Latency over time (blue) and availability over time (green, with the
3679
+ * sub-band samples marked red). Both read one hourly series; when the site
3680
+ * has none, a single line says so rather than an empty box.
3681
+ */
3682
+ function PerfCharts(props) {
3683
+ const { modelId, t } = props
3684
+ const { status, series } = usePerfSeries(modelId, true)
3685
+ if (status === 'loading') return h('p', { className: 'ofm_perfnote' }, t('perf.loading'))
3686
+ if (series === null) return h('p', { className: 'ofm_perfnote' }, t('perf.noSeries'))
3687
+ return h('div', { className: 'ofm_charts' },
3688
+ h('div', { className: 'ofm_chartblock' },
3689
+ h('div', { className: 'ofm_charthead' },
3690
+ h('span', { className: 'ofm_charttitle' }, t('perf.latencyTrend')),
3691
+ h('span', { className: 'ofm_chartsub' }, t('perf.latencySub'))),
3692
+ h(PerfTrendChart, {
3693
+ points: series, pick: point => point.latencyMs, min: 0, unit: ' ms',
3694
+ color: '#2563eb', t, label: t('perf.latencyTrend'),
3695
+ })),
3696
+ h('div', { className: 'ofm_chartblock' },
3697
+ h('div', { className: 'ofm_charthead' },
3698
+ h('span', { className: 'ofm_charttitle' }, t('perf.rateTrend')),
3699
+ h('span', { className: 'ofm_chartsub' }, t('perf.rateSub'))),
3700
+ h(PerfTrendChart, {
3701
+ points: series, pick: point => point.rate, min: 0, max: 100, unit: '%',
3702
+ color: '#10b981', t, label: t('perf.rateTrend'),
3703
+ // The site marks a sub-band sample in the error colour; a dip is
3704
+ // the whole reason this chart exists beside the average.
3705
+ tone: point => (point.rate !== null && point.rate < 90 ? '#ef4444' : '#10b981'),
3706
+ })))
3707
+ }
3708
+
3709
+ /**
3710
+ * One model's hourly series, fetched when its 详情 is opened.
3711
+ *
3712
+ * Lazy on purpose: the roster shows twelve strips, and the charts behind
3713
+ * them would cost twelve extra requests to draw something nobody has asked
3714
+ * to see. `enabled` is false until the card expands.
3715
+ */
3716
+ function usePerfSeries(modelId, enabled) {
3717
+ const [state, setState] = useState({ status: 'idle', series: null })
3718
+ useEffect(() => {
3719
+ if (enabled !== true || typeof modelId !== 'string' || modelId === '') return undefined
3720
+ let alive = true
3721
+ setState({ status: 'loading', series: null })
3722
+ api(`/perf/series?model=${encodeURIComponent(modelId)}`, { timeout: 12000 }).then(payload => {
3723
+ if (!alive) return
3724
+ const series = Array.isArray(payload?.series) ? payload.series : null
3725
+ setState({ status: series === null ? 'none' : 'ready', series })
3726
+ }).catch(() => { if (alive) setState({ status: 'none', series: null }) })
3727
+ return () => { alive = false }
3728
+ }, [modelId, enabled])
3729
+ return state
3730
+ }
3731
+
3732
+ /**
3733
+ * The site's two trend charts, drawn as SVG.
3734
+ *
3735
+ * The site renders them to a canvas; SVG is used here so the lines stay
3736
+ * crisp at any density and the theme's own text colours apply. The shape
3737
+ * matches: 60px of Y-axis labels, horizontal gridlines, a line with a dot
3738
+ * per sample, and hour labels along the bottom. The availability chart
3739
+ * marks the samples that fell below the healthy band in the error colour,
3740
+ * which is how the site shows an incident at a glance.
3741
+ */
3742
+ function PerfTrendChart(props) {
3743
+ const { points, pick, min, max, unit, color, t, tone } = props
3744
+ const W = 300
3745
+ const H = 116
3746
+ const pad = { left: 34, right: 6, top: 8, bottom: 18 }
3747
+ const rows = points.filter(point => pick(point) !== null && pick(point) !== undefined)
3748
+ if (rows.length < 2) return h('p', { className: 'ofm_perfnote' }, t('perf.noSeries'))
3749
+ const lo = min === undefined ? 0 : min
3750
+ const hi = max === undefined ? Math.max(...rows.map(pick)) : max
3751
+ // Five gridlines on a round ladder, the way the site labels its axis
3752
+ // (0 / 60k / 120k / 180k / 240k / 300k) — not the raw peak, which would
3753
+ // print 20303 and 60908. The line scales to the same top the axis shows,
3754
+ // so a point never floats above its own gridline.
3755
+ const top = max === undefined ? niceCeil(hi) : hi
3756
+ const span = top - lo || 1
3757
+ const x = i => pad.left + (i / (rows.length - 1)) * (W - pad.left - pad.right)
3758
+ const y = v => pad.top + (1 - (v - lo) / span) * (H - pad.top - pad.bottom)
3759
+ const path = rows.map((point, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(pick(point)).toFixed(1)}`).join(' ')
3760
+ const ticks = Array.from({ length: 5 }, (_, i) => lo + (span * i) / 4)
3761
+ const tickLabel = value => unit === '%'
3762
+ ? `${Math.round(value)}%`
3763
+ : value >= 1000 ? `${Math.round(value / 1000)}k ms` : `${Math.round(value)} ms`
3764
+ const hourLabel = ts => {
3765
+ const d = new Date(ts * 1000)
3766
+ return `${String(d.getHours()).padStart(2, '0')}:00`
3767
+ }
3768
+ return h('svg', { className: 'ofm_chart', viewBox: `0 0 ${W} ${H}`, role: 'img',
3769
+ 'aria-label': props.label },
3770
+ ticks.map((tick, i) => h('g', { key: i },
3771
+ h('line', { x1: pad.left, x2: W - pad.right, y1: y(tick), y2: y(tick),
3772
+ className: 'ofm_chartgrid', strokeDasharray: i === 0 ? undefined : '3 3' }),
3773
+ h('text', { x: pad.left - 5, y: y(tick) + 3, className: 'ofm_chartaxis', textAnchor: 'end' },
3774
+ tickLabel(tick)))),
3775
+ h('path', { d: path, fill: 'none', stroke: color, strokeWidth: 1.6,
3776
+ strokeLinejoin: 'round', strokeLinecap: 'round' }),
3777
+ rows.map((point, i) => h('circle', {
3778
+ key: i, cx: x(i), cy: y(pick(point)), r: 1.9,
3779
+ fill: tone === undefined ? color : tone(point),
3780
+ className: 'ofm_chartdot',
3781
+ })),
3782
+ // Four hour labels across the window, as the site spaces them.
3783
+ [0, Math.floor(rows.length / 3), Math.floor((rows.length * 2) / 3), rows.length - 1].map((idx, i) => h('text', {
3784
+ key: `x${i}`, x: x(idx), y: H - 5, className: 'ofm_chartaxis',
3785
+ textAnchor: i === 0 ? 'start' : i === 3 ? 'end' : 'middle',
3786
+ }, hourLabel(rows[idx].ts))))
3787
+ }
3788
+
3789
+ /**
3790
+ * One bar's colour, using the site's own thresholds.
3791
+ *
3792
+ * The model square colours each hourly tick emerald at 99 and above, amber
3793
+ * from 90, red below — and grey for an hour with no sample. Copying those
3794
+ * cut-offs is the point: the same day must look the same in both places.
3795
+ */
3796
+ function perfTone(rate) {
3797
+ if (rate === null || rate === undefined) return 'none'
3798
+ const n = Number(rate)
3799
+ if (!Number.isFinite(n)) return 'none'
3800
+ if (n >= 99) return 'good'
3801
+ if (n >= 90) return 'fair'
3802
+ return 'poor'
3803
+ }
3804
+
3805
+ /**
3806
+ * The next round number at or above `value`, for an axis top.
3807
+ *
3808
+ * 60908 → 70000, 300000 → 300000, 96 → 100. One significant-figure step
3809
+ * above the data, so the top gridline is always a number a reader can hold
3810
+ * in their head rather than the exact peak of the sample.
3811
+ */
3812
+ function niceCeil(value) {
3813
+ const n = Number(value)
3814
+ if (!Number.isFinite(n) || n <= 0) return 1
3815
+ const magnitude = 10 ** Math.floor(Math.log10(n))
3816
+ for (const step of [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]) {
3817
+ const candidate = step * magnitude
3818
+ if (candidate >= n) return candidate
3819
+ }
3820
+ return 10 * magnitude
3821
+ }
3822
+
3823
+ /** The strip's latency figure, in the site's own spelling (`144.96s`). */
3824
+ function formatLatency(ms) {
3825
+ const n = Number(ms)
3826
+ if (!Number.isFinite(n) || n < 0) return '—'
3827
+ return `${(n / 1000).toFixed(2)}s`
3828
+ }
3829
+
3830
+ /** The strip's throughput figure, in the site's own spelling (`55.9t/s`). */
3831
+ function formatSpeed(tps) {
3832
+ const n = Number(tps)
3833
+ if (!Number.isFinite(n) || n < 0) return '—'
3834
+ return `${n.toFixed(1)}t/s`
3835
+ }
3836
+
3536
3837
  function isFreePoolModel(model) {
3537
3838
  return !model.channel || model.channel === 'anonymous' || model.channel === 'kilo' || model.channel === 'extension'
3538
3839
  }
@@ -3559,6 +3860,7 @@ window.__ModuleLoader__.load({
3559
3860
 
3560
3861
  function Roster(props) {
3561
3862
  const { summary, t, onBench, benches = {}, auth, eacLogin, only, onApply, busy } = props
3863
+ const perfFigures = usePerfFigures()
3562
3864
  // 免费页与水位采用相同范围:匿名免费模型和 Kilo;其他付费来源留在各自页面。
3563
3865
  const roster = only === undefined ? summary.catalog
3564
3866
  : only === 'eac' ? summary.catalog.filter(m => m.channel === 'eac')
@@ -3584,6 +3886,7 @@ window.__ModuleLoader__.load({
3584
3886
  h('div', { className: 'ofm_grid' }, list.map(m => h(ModelCard, {
3585
3887
  key: m.id, model: m, t, onBench, locked: m.channel === 'eac' && locked, eacLogin,
3586
3888
  onVisibility: toggleModel, busy,
3889
+ perf: perfFigures[m.id],
3587
3890
  bench: { running: benches[m.id]?.running === true, ...benches[m.id]?.result === undefined ? {} : { result: benches[m.id].result } },
3588
3891
  }))))
3589
3892
  // The lane closed at the gate is the one "no EAC models" case the page can
@@ -8362,6 +8665,36 @@ window.__ModuleLoader__.load({
8362
8665
  } catch { return 'en' }
8363
8666
  }
8364
8667
 
8668
+ /**
8669
+ * Decide whether this page announces the last hot reload.
8670
+ *
8671
+ * The server acknowledgement is authoritative because it survives a new
8672
+ * origin: another port, another hostname, or the desktop shell's random
8673
+ * port all have an empty localStorage. The cache only suppresses a repeat
8674
+ * toast in the same origin; an unconfirmed server acknowledgement must
8675
+ * still be retried if its previous write failed or has not landed yet.
8676
+ */
8677
+ function reloadNoticePlan(meta, cached) {
8678
+ const at = Number(meta?.reloadedAt ?? 0)
8679
+ if (!Number.isFinite(at) || at <= 0) return { at: 0, show: false }
8680
+ const seen = Number(meta?.reloadNoticeSeen ?? 0)
8681
+ if (seen === at) return { at, show: false }
8682
+ if (cached !== undefined && cached !== null && String(at) === String(cached)) return { at, show: false }
8683
+ return { at, show: true }
8684
+ }
8685
+
8686
+ /** Show the toast at most once, and retry any missing server acknowledgement. */
8687
+ async function announceReloadNotice(meta, cached, { show, ack, remember }) {
8688
+ const plan = reloadNoticePlan(meta, cached)
8689
+ if (plan.at <= 0) return { ...plan, shown: false }
8690
+ remember(plan.at)
8691
+ if (plan.show) show(plan)
8692
+ if (Number(meta?.reloadNoticeSeen ?? 0) !== plan.at) {
8693
+ try { await ack(plan.at) } catch { /* retry on the next check without repeating the toast */ }
8694
+ }
8695
+ return { ...plan, shown: plan.show }
8696
+ }
8697
+
8365
8698
  function apply(ctx) {
8366
8699
  const t = ctx.locale.bind(NS)
8367
8700
  ctx.effect(() => ctx.locale.register(NS, { zh: DICT.zh, en: DICT.en }), 'our-free-model: dictionaries')
@@ -8485,23 +8818,23 @@ window.__ModuleLoader__.load({
8485
8818
  }, 'our-free-model: push subscription')
8486
8819
 
8487
8820
  // A hot reload or in-app upgrade swaps this bundle while the page stays
8488
- // open. The only durable marker across that swap is localStorage, so the
8489
- // successor announces what happened exactly once.
8821
+ // open. The server records the acknowledgement, so the successor
8822
+ // announces what happened exactly once across every origin.
8490
8823
  ctx.effect(() => {
8491
8824
  void (async () => {
8492
8825
  try {
8493
8826
  const meta = await api('/meta')
8494
- const at = Number(meta?.reloadedAt ?? 0)
8495
- if (at <= 0) return
8496
- let seen = ''
8497
- try { seen = window.localStorage?.getItem('ofm.reloadedAt') ?? '' } catch { /* storage unavailable */ }
8498
- if (String(at) === seen) return
8499
- try { window.localStorage?.setItem('ofm.reloadedAt', String(at)) } catch { /* ignore */ }
8500
- showToast({
8501
- title: meta.version !== '' ? `Our Free Model ${meta.version}` : 'Our Free Model',
8502
- body: t('reload.done').replace('{n}', String(meta.reloadCount ?? 0)),
8503
- actions: [{ label: t('reload.refresh'), onClick: () => { try { window.location.reload() } catch { /* top-level navigation refused */ } } }],
8504
- holdMs: 12000,
8827
+ let cached = ''
8828
+ try { cached = window.localStorage?.getItem('ofm.reloadedAt') ?? '' } catch { /* storage unavailable */ }
8829
+ await announceReloadNotice(meta, cached, {
8830
+ show: () => showToast({
8831
+ title: meta.version !== '' ? `Our Free Model ${meta.version}` : 'Our Free Model',
8832
+ body: t('reload.done').replace('{n}', String(meta.reloadCount ?? 0)),
8833
+ actions: [{ label: t('reload.refresh'), onClick: () => { try { window.location.reload() } catch { /* top-level navigation refused */ } } }],
8834
+ holdMs: 12000,
8835
+ }),
8836
+ ack: at => post('/reload-notice/ack', { at }),
8837
+ remember: at => { try { window.localStorage?.setItem('ofm.reloadedAt', String(at)) } catch { /* ignore */ } },
8505
8838
  })
8506
8839
  } catch { /* backend absent */ }
8507
8840
  })()
@@ -8563,7 +8896,7 @@ window.__ModuleLoader__.load({
8563
8896
  exports.inject = inject
8564
8897
  exports.name = 'our-free-model'
8565
8898
  // Headless test seams use the same stub React as scripts/client-lint.mjs.
8566
- exports.__test = { parseSafeHtml, safeUrl, sanitizeStyle, htmlToDom, buildHeatCells, Heatmap, ChannelsPage, UpgradePanel, useEacLogin, EacAuth, PicksPage, RecoCard, DICT, useChannelRpc, Shell, SubscriptionsPage, Disclaimer, CSS }
8899
+ exports.__test = { parseSafeHtml, safeUrl, sanitizeStyle, htmlToDom, buildHeatCells, Heatmap, ChannelsPage, UpgradePanel, useEacLogin, EacAuth, PicksPage, RecoCard, DICT, useChannelRpc, Shell, SubscriptionsPage, Disclaimer, CSS, reloadNoticePlan, announceReloadNotice }
8567
8900
  return module.exports
8568
8901
  },
8569
8902
  })