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

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.
Files changed (44) hide show
  1. package/README.md +23 -8
  2. package/client.js +894 -6
  3. package/cordis.patch.yml +1 -1
  4. package/index.js +248 -11
  5. package/package.json +6 -2
  6. package/src/adapter.js +54 -6
  7. package/src/aggregate.js +53 -13
  8. package/src/bun-fetch.js +33 -10
  9. package/src/catalog.js +238 -58
  10. package/src/core.js +838 -0
  11. package/src/egress.js +204 -3
  12. package/src/engine-parts/win32-x64.000.part +0 -0
  13. package/src/engine-parts/win32-x64.001.part +0 -0
  14. package/src/engine-parts/win32-x64.002.part +0 -0
  15. package/src/engine-parts/win32-x64.003.part +0 -0
  16. package/src/engine-parts/win32-x64.004.part +0 -0
  17. package/src/engine-parts/win32-x64.005.part +0 -0
  18. package/src/engine-parts/win32-x64.006.part +0 -0
  19. package/src/extensions.js +1888 -0
  20. package/src/feed.js +6 -0
  21. package/src/http.js +15 -2
  22. package/src/kilo.js +55 -5
  23. package/src/magpie-engine.js +61 -2
  24. package/src/magpie-models.js +31 -3
  25. package/src/model-test.js +3 -1
  26. package/src/o2a.js +956 -0
  27. package/src/open-external.js +43 -5
  28. package/src/plugins.js +1240 -0
  29. package/src/providers/registry.json +72 -30
  30. package/src/semver.js +40 -0
  31. package/src/store.js +17 -1
  32. package/src/stream.js +39 -4
  33. package/src/subscription-completion.js +12 -3
  34. package/src/updater.js +5 -33
  35. package/src/upstream.js +283 -363
  36. package/vendor/channel-pack/NOTICE.md +1 -1
  37. package/vendor/channel-pack/pack.js +7242 -3952
  38. package/vendor/magpie/NOTICE.md +159 -6
  39. package/vendor/magpie/engine.json +58 -27
  40. package/src/core/completion.js +0 -196
  41. package/src/core/runtime.js +0 -456
  42. package/src/core/stats.js +0 -98
  43. package/src/engine-parts/win32-x64.007.part +0 -0
  44. package/src/engine-parts/win32-x64.008.part +0 -0
package/client.js CHANGED
@@ -305,6 +305,7 @@ window.__ModuleLoader__.load({
305
305
  'news.osDenied': '浏览器拒绝了通知权限;需要在系统/浏览器设置里手动恢复。',
306
306
  'news.link': '查看详情',
307
307
  'news.urgentTitle': '重要公告',
308
+ 'news.holdHint': '为确保读完,{s} 秒后可关闭',
308
309
  'news.gotIt': '知道了',
309
310
  'news.lastFetch': '最近拉取',
310
311
  'news.fetchFailed': '公告源暂不可达(显示的是缓存)',
@@ -392,6 +393,103 @@ window.__ModuleLoader__.load({
392
393
  'picks.design.tag4': '真实文件导出',
393
394
  'free.title': '免费模型',
394
395
  'free.sub': '匿名免费与 Kilo 模型统一展示:水位表示可选比例,实测状态单独显示。',
396
+ 'nav.tab.extensions': 'OFM 渠道扩展',
397
+ 'ext.search': '搜索扩展',
398
+ 'ext.all': '全部',
399
+ 'ext.title': 'OFM 渠道扩展',
400
+ 'ext.bunMissing': 'Bun 未安装',
401
+ 'ext.changelog': '更新内容',
402
+ 'ext.viewSource': '查看源码',
403
+ 'ext.indexFresh': '签名索引生成于 {at}',
404
+ 'ext.bunHint': 'OpenCode 免费车道推理需要本机 Bun:安装 Bun(bun.sh 或 npm i -g bun)后重启,或把可执行文件路径写入 OUR_FREE_MODEL_BUN。',
405
+ 'ext.sub': '渠道与增强模块的市场:签名索引、沙箱运行、与本体解耦;装一个渠道,模型立即出现在免费页。',
406
+ 'ext.tabDiscover': '发现',
407
+ 'ext.tabInstalled': '已安装',
408
+ 'ext.tabSecurity': '安全',
409
+ 'ext.install': '安装',
410
+ 'ext.installing': '安装中…',
411
+ 'ext.installed': '已安装',
412
+ 'ext.update': '更新',
413
+ 'ext.updateAvailable': '有更新',
414
+ 'ext.enable': '启用',
415
+ 'ext.disable': '停用',
416
+ 'ext.uninstall': '卸载',
417
+ 'ext.channel': '渠道',
418
+ 'ext.enhancement': '增强',
419
+ 'ext.verified': '已签名',
420
+ 'ext.verifiedTitle': '该扩展来自 Ed25519 签名的官方索引',
421
+ 'ext.refresh': '刷新索引',
422
+ 'ext.empty': '扩展索引不可达',
423
+ 'ext.indexHint': '索引可能仍在预热或被网络拦截;点右上角「刷新索引」重试。',
424
+ 'ext.failed': '操作失败',
425
+ 'ext.security': '安全模型',
426
+ 'ext.securityBody': '扩展是可执行代码——只安装这份签名索引里的扩展。安装时会校验 Ed25519 签名与 SHA-256 摘要,做静态扫描(拒绝 node:*、process、eval、动态 import 与压缩产物);运行在独立 Worker 的 VM 沙箱里,唯一的 import 是受控的 ofm:sdk;网络只能访问 manifest 里声明过的域名,密钥只进不出,每个 hook 都有超时熔断。',
427
+ 'ext.securityBody2': '这套防御针对的是意外与草率,不是蓄意作者——真正的门是签名、人工审核与这份索引的提交历史。发现可疑扩展请通过 ofm-extensions 仓库的 SECURITY.md 举报。',
428
+ 'ext.openRepo': '打开 ofm-extensions 仓库',
429
+ 'ext.detail': '详情',
430
+ 'ext.confirmRemove': '卸载 {name}?它的配置与密钥会一并删除。',
431
+ 'ext.notRunning': '未运行',
432
+ 'ext.count': '索引 {n} 个扩展',
433
+ 'ext.discoverEmpty': '没有匹配的扩展。',
434
+ 'ext.installedEmpty': '还没有安装任何扩展。去「发现」页挑一个。',
435
+ 'ext.permissions': '权限',
436
+ 'ext.permNetwork': '网络:可访问 {n} 个已声明的域名',
437
+ 'ext.permSecrets': '密钥:{n} 项(只写不读回)',
438
+ 'ext.openHomepage': '打开主页',
439
+ 'ext.secretPlaceholder': '输入后保存,仅显示尾 4 位',
440
+ 'ext.close': '关闭',
441
+ 'ext.countByType': '渠道 {c} · 增强 {e}',
442
+ 'ext.sort': '排序',
443
+ 'ext.sortCurated': '默认',
444
+ 'ext.sortNewest': '最新',
445
+ 'ext.sortName': '名称',
446
+ 'ext.searchHint': '按 / 聚焦',
447
+ 'ext.clearSearch': '清除筛选',
448
+ 'ext.updateAll': '全部更新',
449
+ 'ext.updateProgress': '更新中 {i}/{n}',
450
+ 'ext.updateCount': '{n} 个可更新',
451
+ 'ext.modelCount': '模型 {n}',
452
+ 'ext.requiresHost': '需宿主 ≥ {v}',
453
+ 'ext.selfTest': '自检',
454
+ 'ext.selfTestOk': '自检通过',
455
+ 'ext.selfTestFail': '自检失败',
456
+ 'ext.replaces': '接管内置 {name} 渠道',
457
+ 'ext.filterTag': '标签',
458
+ 'ext.hooks': '钩子',
459
+ 'ext.type': '类型',
460
+ 'ext.license': '许可证',
461
+ 'ext.published': '发布于',
462
+ 'ext.models': '模型',
463
+ 'ext.repository': '仓库',
464
+ 'ext.openRepository': '打开仓库',
465
+ 'ext.storage': '本地存储',
466
+ 'ext.storageOn': '可读写本地存储文件',
467
+ 'ext.storageOff': '不读写本地文件',
468
+ 'ext.originEmpty': '无网络访问',
469
+ 'ext.notInstalledHint': '尚未安装,详情仅供参考。',
470
+ 'ext.autoUpdate': '自动更新',
471
+ 'ext.autoUpdateHint': '索引刷新时,自动安装已过观察窗口且处于启用状态的扩展更新;新版本先沉淀 {h} 小时再自动安装,防供应链投毒。',
472
+ 'ext.source': '索引来源',
473
+ 'ext.sourceBundled': '内置快照',
474
+ 'ext.soakReady': '已过观察窗口,可立即更新',
475
+ 'ext.soakWaiting': '观察窗口约剩 {h} 小时(新版本先沉淀再自动装,防供应链投毒);也可手动立即更新',
476
+ 'ext.auditTitle': '最近操作',
477
+ 'ext.auditEmpty': '暂无记录。',
478
+ 'ext.auditInstall': '安装',
479
+ 'ext.auditInstallLocal': '本地安装',
480
+ 'ext.auditUninstall': '卸载',
481
+ 'ext.auditEnabled': '启停',
482
+ 'ext.auditConfig': '配置',
483
+ 'ext.auditSecret': '密钥',
484
+ 'ext.auditAutoUpdate': '自动更新',
485
+ 'ext.auditRegistry': '刷新市场',
486
+ 'ext.exportShelf': '导出清单',
487
+ 'ext.importShelf': '导入清单',
488
+ 'ext.exportDone': '扩展清单已导出(JSON,不含密钥)',
489
+ 'ext.importDone': '已恢复 {ok} 个扩展{fail}',
490
+ 'ext.importFail': ',{n} 个失败(可能已下架或不兼容)',
491
+ 'ext.importInvalid': '清单文件不是有效的 JSON,或没有可安装的扩展',
492
+ 'ext.shelfTitle': '迁移助手:清单只含 id、版本、启停与配置,密钥永不导出',
395
493
  'sponsor.bannerTag': '中转站',
396
494
  'sponsor.bannerTitle': '北辰 API 中转站',
397
495
  'sponsor.bannerDescription': 'OFM 官方合作中转站,为用户提供稳定、开放、高性价比的大模型 API 接入服务。支持 Claude、ChatGPT、Grok、Gemini、智谱 GLM、Kimi、DeepSeek 等主流模型,适合在 Codex、Gemini CLI、Claude Code 及其他开发工具中统一配置使用。邀请可获百分之十的返现。',
@@ -1082,6 +1180,7 @@ window.__ModuleLoader__.load({
1082
1180
  'news.osDenied': 'Notification permission was denied; restore it in your system or browser settings.',
1083
1181
  'news.link': 'Read more',
1084
1182
  'news.urgentTitle': 'Important announcement',
1183
+ 'news.holdHint': 'Read on — this dialog can close in {s}s',
1085
1184
  'news.gotIt': 'Got it',
1086
1185
  'news.lastFetch': 'Last fetch',
1087
1186
  'news.fetchFailed': 'Feed unreachable right now (showing the cached copy)',
@@ -1169,6 +1268,103 @@ window.__ModuleLoader__.load({
1169
1268
  'picks.design.tag4': 'Real file export',
1170
1269
  'free.title': 'Free models',
1171
1270
  'free.sub': 'Anonymous free and Kilo models share this pool. Water shows the selectable share; measured availability is shown separately.',
1271
+ 'nav.tab.extensions': 'OFM channel extensions',
1272
+ 'ext.search': 'Search extensions',
1273
+ 'ext.all': 'All',
1274
+ 'ext.title': 'OFM channel extensions',
1275
+ 'ext.bunMissing': 'Bun not installed',
1276
+ 'ext.changelog': "What's new",
1277
+ 'ext.viewSource': 'View source',
1278
+ 'ext.indexFresh': 'Signed index generated {at}',
1279
+ 'ext.bunHint': 'OpenCode free-lane inference needs a local Bun. Install Bun (bun.sh or npm i -g bun) and restart, or point OUR_FREE_MODEL_BUN at its executable.',
1280
+ 'ext.sub': 'A marketplace for channel and enhancement modules: signed index, sandboxed runs, decoupled from the core — install a channel and its models appear on the free page.',
1281
+ 'ext.tabDiscover': 'Discover',
1282
+ 'ext.tabInstalled': 'Installed',
1283
+ 'ext.tabSecurity': 'Safety',
1284
+ 'ext.install': 'Install',
1285
+ 'ext.installing': 'Installing…',
1286
+ 'ext.installed': 'Installed',
1287
+ 'ext.update': 'Update',
1288
+ 'ext.updateAvailable': 'Update ready',
1289
+ 'ext.enable': 'Enable',
1290
+ 'ext.disable': 'Disable',
1291
+ 'ext.uninstall': 'Uninstall',
1292
+ 'ext.channel': 'Channel',
1293
+ 'ext.enhancement': 'Enhancement',
1294
+ 'ext.verified': 'Signed',
1295
+ 'ext.verifiedTitle': 'This extension comes from the Ed25519-signed index',
1296
+ 'ext.refresh': 'Refresh index',
1297
+ 'ext.empty': 'The extension registry is unreachable',
1298
+ 'ext.indexHint': 'The index may still be warming up or blocked by the network; use “Refresh index” to retry.',
1299
+ 'ext.failed': 'The action failed',
1300
+ 'ext.security': 'The safety model',
1301
+ 'ext.securityBody': 'Extensions are executable code — install only what this signed index carries. An install verifies the Ed25519 signature and the SHA-256 digest and runs a static scan (node:*, process, eval, dynamic import and minified bundles are refused); the code runs inside a Worker VM sandbox whose only import is the controlled ofm:sdk; the network is limited to the domains the manifest declares, secrets go in and never come out, and every hook has a timeout breaker.',
1302
+ 'ext.securityBody2': "These defenses target accidents and carelessness, not a determined author — the real gates are the signature, human review, and this index's commit history. Report anything suspicious through SECURITY.md in the ofm-extensions repository.",
1303
+ 'ext.openRepo': 'Open the ofm-extensions repository',
1304
+ 'ext.detail': 'Details',
1305
+ 'ext.confirmRemove': 'Uninstall {name}? Its configuration and secrets are removed too.',
1306
+ 'ext.notRunning': 'Not running',
1307
+ 'ext.count': '{n} extensions in the index',
1308
+ 'ext.discoverEmpty': 'No extensions match.',
1309
+ 'ext.installedEmpty': 'Nothing installed yet — pick something on the Discover tab.',
1310
+ 'ext.permissions': 'Permissions',
1311
+ 'ext.permNetwork': 'Network: {n} declared host(s)',
1312
+ 'ext.permSecrets': 'Secrets: {n} (write-only)',
1313
+ 'ext.openHomepage': 'Open homepage',
1314
+ 'ext.secretPlaceholder': 'Type to save; only the last 4 are shown',
1315
+ 'ext.close': 'Close',
1316
+ 'ext.countByType': '{c} channels · {e} enhancements',
1317
+ 'ext.sort': 'Sort',
1318
+ 'ext.sortCurated': 'Curated',
1319
+ 'ext.sortNewest': 'Newest',
1320
+ 'ext.sortName': 'Name',
1321
+ 'ext.searchHint': 'Press / to search',
1322
+ 'ext.clearSearch': 'Clear filters',
1323
+ 'ext.updateAll': 'Update all',
1324
+ 'ext.updateProgress': 'Updating {i}/{n}',
1325
+ 'ext.updateCount': '{n} update(s) ready',
1326
+ 'ext.modelCount': '{n} models',
1327
+ 'ext.requiresHost': 'needs host ≥ {v}',
1328
+ 'ext.selfTest': 'Self-test',
1329
+ 'ext.selfTestOk': 'Self-test passed',
1330
+ 'ext.selfTestFail': 'Self-test failed',
1331
+ 'ext.replaces': 'takes over the built-in {name} lane',
1332
+ 'ext.filterTag': 'Tag',
1333
+ 'ext.hooks': 'Hooks',
1334
+ 'ext.type': 'Type',
1335
+ 'ext.license': 'License',
1336
+ 'ext.published': 'Published',
1337
+ 'ext.models': 'Models',
1338
+ 'ext.repository': 'Repository',
1339
+ 'ext.openRepository': 'Open repository',
1340
+ 'ext.storage': 'Local storage',
1341
+ 'ext.storageOn': 'May read and write a local storage file',
1342
+ 'ext.storageOff': 'Reads and writes no local files',
1343
+ 'ext.originEmpty': 'No network access',
1344
+ 'ext.notInstalledHint': 'Not installed yet — details are for reference.',
1345
+ 'ext.autoUpdate': 'Auto-update',
1346
+ 'ext.autoUpdateHint': 'On each registry refresh, install enabled updates that have cleared the observation window; a new version soaks {h} hours before it auto-installs, blunting supply-chain attacks.',
1347
+ 'ext.source': 'Index source',
1348
+ 'ext.sourceBundled': 'bundled snapshot',
1349
+ 'ext.soakReady': 'Cleared the observation window — update now',
1350
+ 'ext.soakWaiting': 'About {h}h left in the observation window (new versions soak before auto-install, blunting supply-chain attacks); manual update is always available',
1351
+ 'ext.auditTitle': 'Recent actions',
1352
+ 'ext.auditEmpty': 'Nothing yet.',
1353
+ 'ext.auditInstall': 'Install',
1354
+ 'ext.auditInstallLocal': 'Local install',
1355
+ 'ext.auditUninstall': 'Uninstall',
1356
+ 'ext.auditEnabled': 'Enable/disable',
1357
+ 'ext.auditConfig': 'Config',
1358
+ 'ext.auditSecret': 'Secret',
1359
+ 'ext.auditAutoUpdate': 'Auto-update',
1360
+ 'ext.auditRegistry': 'Refresh index',
1361
+ 'ext.exportShelf': 'Export list',
1362
+ 'ext.importShelf': 'Import list',
1363
+ 'ext.exportDone': 'Extension list exported (JSON, no secrets)',
1364
+ 'ext.importDone': 'Restored {ok} extension(s){fail}',
1365
+ 'ext.importFail': ', {n} failed (delisted or incompatible)',
1366
+ 'ext.importInvalid': 'The file is not valid JSON, or carries no installable extensions',
1367
+ 'ext.shelfTitle': 'Migration: the list carries ids, versions, toggles and config — never secrets',
1172
1368
  'sponsor.bannerTag': 'Relay',
1173
1369
  'sponsor.bannerTitle': 'Beichen API relay',
1174
1370
  'sponsor.bannerDescription': 'OFM’s official relay partner provides stable, open and cost-effective model API access. Claude, ChatGPT, Grok, Gemini, GLM, Kimi and DeepSeek are supported for one consistent setup in Codex, Gemini CLI, Claude Code and other developer tools. Invite others to receive 10% cashback.',
@@ -1826,6 +2022,8 @@ window.__ModuleLoader__.load({
1826
2022
  .ofm_toast.warn::before{background:var(--dsw-alias-state-warning-primary,#f0a441)}
1827
2023
  .ofm_toast.urgent::before{background:var(--dsw-alias-state-error-primary)}
1828
2024
  .ofm_toast.warn{border-color:color-mix(in srgb,var(--dsw-alias-state-warning-primary,#f0a441) 42%,var(--dsw-alias-border-l2))}
2025
+ /* urgent 弹层的关闭倒计时提示:弱化、右对齐到按钮旁 */
2026
+ .ofm_holdhint{font-size:11.5px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
1829
2027
  .ofm_toast.urgent{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 42%,var(--dsw-alias-border-l2))}
1830
2028
  .ofm_toast .ofm_toasttitle{font-weight:660;font-size:12.5px;display:flex;align-items:center;gap:7px}
1831
2029
  .ofm_toast .ofm_toastbody{color:var(--dsw-alias-label-secondary);max-height:180px;overflow:auto;overflow-wrap:anywhere}
@@ -2665,6 +2863,71 @@ window.__ModuleLoader__.load({
2665
2863
  .ofm_shell .ofm_navtools .ofm_switch span{display:none}
2666
2864
  .ofm_shell .ofm_navtools .ofm_starbtn{font-size:0;padding:6px;gap:0}
2667
2865
  }
2866
+ /* ── 扩展市场卡片与详情抽屉(2.8.1 新增页签,其余样式全部复用既有令牌) ── */
2867
+ .ofm_extcard{gap:10px;padding-left:17px}
2868
+ .ofm_extcard::before{content:"";position:absolute;left:6px;top:13px;bottom:13px;width:3px;border-radius:999px;background:var(--dsw-alias-state-business-primary);opacity:.75;transition:opacity .2s var(--ease-instrument)}
2869
+ .ofm_extcard.channel::before{background:linear-gradient(180deg,var(--dsw-alias-state-business-primary),color-mix(in srgb,var(--dsw-alias-state-business-primary) 30%,transparent))}
2870
+ .ofm_extcard.enhancement::before{background:linear-gradient(180deg,var(--dsw-alias-state-success-primary),color-mix(in srgb,var(--dsw-alias-state-success-primary) 30%,transparent))}
2871
+ .ofm_extcard:hover::before{opacity:1}
2872
+ .ofm_extgrid>.ofm_extcard{animation:ofmrisein .4s var(--ease-instrument) backwards;animation-delay:calc(var(--i,0)*45ms)}
2873
+ .ofm_extskel{position:relative;overflow:hidden;border-radius:13px;border:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb,var(--dsw-alias-bg-layer-3) 80%,transparent);min-height:150px;padding:13px;display:flex;flex-direction:column;gap:9px;justify-content:center;animation:ofmrisein .4s var(--ease-instrument) backwards;animation-delay:calc(var(--i,0)*45ms)}
2874
+ .ofm_extskel::after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(100deg,transparent 30%,color-mix(in srgb,var(--dsw-alias-bg-layer-1) 55%,transparent) 50%,transparent 70%);transform:translateX(-100%);animation:ofm-skel-shine 1.8s ease-in-out infinite}
2875
+ .ofm_extskel i{display:block;height:11px;border-radius:6px;background:color-mix(in srgb,var(--dsw-alias-label-secondary) 14%,transparent);animation:ofmpulse 1.8s ease-in-out infinite}
2876
+ .ofm_extdetail{width:min(560px,92vw)}
2877
+ .ofm_extdetail:focus{outline:none}
2878
+ .ofm_extcard .ofm_id{white-space:normal;text-overflow:clip;line-height:1.5}
2879
+ /* 卡片头:字母头像 + 标题列;详情弹层头复用同一行排布(后声明覆盖弹层的纵向列) */
2880
+ .ofm_exthead{display:flex;flex-direction:row;align-items:center;gap:10px;min-width:0}
2881
+ .ofm_exttitle{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}
2882
+ .ofm_extavatar{flex:none;width:34px;height:34px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:15px;font-weight:750;color:var(--dsw-alias-label-on-accent,#fff);background:linear-gradient(135deg,var(--dsw-alias-state-business-primary),color-mix(in srgb,var(--dsw-alias-state-business-primary) 55%,#000));box-shadow:inset 0 1px 0 rgb(255 255 255 / 22%),0 2px 6px color-mix(in srgb,var(--dsw-alias-state-business-primary) 30%,transparent);transition:transform .2s var(--ease-instrument)}
2883
+ .ofm_extavatar.enhancement{background:linear-gradient(135deg,var(--dsw-alias-state-success-primary),color-mix(in srgb,var(--dsw-alias-state-success-primary) 55%,#000));box-shadow:inset 0 1px 0 rgb(255 255 255 / 22%),0 2px 6px color-mix(in srgb,var(--dsw-alias-state-success-primary) 30%,transparent)}
2884
+ .ofm_extcard:hover .ofm_extavatar{transform:scale(1.06)}
2885
+ .ofm_extcard .ofm_cardhead{gap:6px}
2886
+ .ofm_extblurb{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:0}
2887
+ .ofm_extfoot{display:flex;gap:8px;align-items:center;padding-top:9px;margin-top:auto;border-top:1px solid color-mix(in srgb,var(--dsw-alias-border-l1) 72%,transparent)}
2888
+ .ofm_extfoot .ofm_btn+.ofm_btn,.ofm_extfoot .ofm_badge{margin-left:auto}
2889
+ /* 工具行:搜索框内嵌 / 快捷键角标 */
2890
+ .ofm_exttoolbar{flex-wrap:wrap}
2891
+ .ofm_extsearch{position:relative;display:inline-flex;align-items:center}
2892
+ .ofm_extsearch .ofm_input{padding-right:30px}
2893
+ .ofm_kbd{position:absolute;right:9px;pointer-events:none;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10.5px;line-height:1;padding:3px 6px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-1);box-shadow:inset 0 -1px 0 var(--dsw-alias-border-l1)}
2894
+ /* 空态与详情弹层 */
2895
+ .ofm_extempty{display:flex;flex-direction:column;align-items:center;gap:10px;padding:34px 12px;border:1px dashed var(--dsw-alias-border-l2);border-radius:14px}
2896
+ .ofm_extorigins{display:flex;flex-wrap:wrap;gap:6px}
2897
+ .ofm_extorigins code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10.5px;padding:3px 8px;border-radius:7px;border:1px solid var(--dsw-alias-border-l1);background:color-mix(in srgb,var(--dsw-alias-bg-layer-1) 74%,transparent);color:var(--dsw-alias-label-secondary)}
2898
+ .ofm_extdetail .ofm_anntitle{font-size:16.5px}
2899
+ .ofm_extdetail .ofm_annsub{overflow-wrap:anywhere}
2900
+ .ofm_extdetail .ofm_annfoot .ofm_badge{margin-left:0}
2901
+ /* 更新内容:详情弹层里的 newest-first 发布说明列表 */
2902
+ .ofm_changelog{list-style:none;margin:2px 0 0;padding:0;display:flex;flex-direction:column;gap:5px}
2903
+ .ofm_changelog li{font-size:12px;line-height:1.55;color:var(--dsw-alias-label-secondary);padding:6px 9px;border-radius:8px;border:1px solid color-mix(in srgb,var(--dsw-alias-border-l1) 78%,transparent);background:color-mix(in srgb,var(--dsw-alias-bg-layer-1) 55%,transparent);overflow-wrap:anywhere}
2904
+ .ofm_changelog li:first-child{color:var(--dsw-alias-label-primary)}
2905
+ /* 封面横幅:负 margin 出血到卡片内缘(padding 上 12/右 13/左 17),加载失败由
2906
+ 组件整块退场;详情弹层第一子元素自然贴顶,仅配内圆角 */
2907
+ .ofm_extcover{position:relative;height:96px;flex:none;margin:-12px -13px 0 -17px;border-radius:12px 12px 0 0;overflow:hidden;background:color-mix(in srgb,var(--dsw-alias-bg-layer-1) 55%,transparent);border-bottom:1px solid color-mix(in srgb,var(--dsw-alias-border-l1) 82%,transparent)}
2908
+ .ofm_extcover img{display:block;width:100%;height:100%;object-fit:cover}
2909
+ .ofm_extdetail .ofm_extcover{height:150px;margin:0;border-radius:17px 17px 0 0;border-bottom:1px solid var(--dsw-alias-border-l2)}
2910
+ .ofm_extdetail .ofm_annhead{position:relative}
2911
+ /* 标签点选浏览 / 接管内置渠道徽标 / 分段控件计数(magpie 市场的分类手势) */
2912
+ .ofm_tagbtn{cursor:pointer;font:inherit;transition:border-color .18s var(--ease-instrument),color .18s var(--ease-instrument),transform .18s var(--ease-instrument)}
2913
+ .ofm_tagbtn:hover{border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 62%,transparent);color:var(--dsw-alias-state-business-primary);transform:translateY(-1px)}
2914
+ .ofm_replacetag{flex:none;font-size:10px;font-weight:700;line-height:1;padding:2px 8px;border-radius:999px;white-space:nowrap;color:color-mix(in srgb,var(--dsw-alias-state-warning-primary,#f2a65a) 80%,var(--dsw-alias-label-primary));background:color-mix(in srgb,var(--dsw-alias-state-warning-primary,#f2a65a) 22%,transparent);border:1px solid color-mix(in srgb,var(--dsw-alias-state-warning-primary,#f2a65a) 52%,transparent)}
2915
+ .ofm_segcount{margin-left:5px;font-size:9.5px;font-weight:650;color:var(--dsw-alias-label-tertiary);background:color-mix(in srgb,var(--dsw-alias-label-secondary) 10%,transparent);padding:1px 5px;border-radius:999px}
2916
+ .ofm_seg button[aria-pressed="true"] .ofm_segcount{color:var(--dsw-alias-label-secondary)}
2917
+ /* 2.10 增补:soak 说明行、索引来源徽标、最近操作行、模型货架、清单导入标签 */
2918
+ .ofm_soakline{margin:2px 0 0;font-size:11.5px;line-height:1.55;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}
2919
+ .ofm_extsource{flex:none;font-size:10.5px;line-height:1;padding:4px 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:color-mix(in srgb,var(--dsw-alias-bg-layer-1) 74%,transparent);white-space:nowrap}
2920
+ .ofm_importlabel{display:inline-flex;align-items:center}
2921
+ .ofm_audrows{display:flex;flex-direction:column;gap:4px}
2922
+ .ofm_audrow{display:flex;align-items:baseline;gap:10px;font-size:12px;flex-wrap:wrap}
2923
+ .ofm_audrow code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--dsw-alias-label-secondary)}
2924
+ .ofm_audat{flex:none;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
2925
+ .ofm_inlinehint{display:inline-flex;align-items:center;cursor:help}
2926
+ .ofm_modelshelf{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
2927
+ .ofm_modelchip{font-size:10.5px;line-height:1;padding:4px 9px;border-radius:999px;border:1px solid color-mix(in srgb,var(--dsw-alias-border-l1) 82%,transparent);background:color-mix(in srgb,var(--dsw-alias-bg-layer-1) 62%,transparent);color:var(--dsw-alias-label-secondary);white-space:nowrap;max-width:210px;overflow:hidden;text-overflow:ellipsis}
2928
+ /* 封面淡入:字节到位前不占住卡片顶部的视觉重量 */
2929
+ .ofm_extcover img{opacity:0;transition:opacity .45s var(--ease-instrument)}
2930
+ .ofm_extcover img.ofm_loaded{opacity:1}
2668
2931
  `
2669
2932
 
2670
2933
  // ── helpers ───────────────────────────────────────────────────────────────
@@ -2997,8 +3260,20 @@ window.__ModuleLoader__.load({
2997
3260
  return undefined
2998
3261
  }
2999
3262
 
3000
- /** Modal surface for `urgent` announcements; dismiss runs the ack callback. */
3001
- function showUrgentModal({ title, html, confirmLabel, onClose }) {
3263
+ /** Urgent announcement ids already shown on this page. Both the arrival
3264
+ * push and the boot re-check consult it, so one announcement opens one
3265
+ * modal per page load — a replace, never a stack. */
3266
+ const urgentShownIds = new Set()
3267
+
3268
+ /** Modal surface for `urgent` announcements; dismiss runs the ack callback.
3269
+ *
3270
+ * `holdMs` keeps the close control locked for that long with a visible
3271
+ * countdown: an announcement that must be read should not be dismissible
3272
+ * by reflex. The timer is one-shot and cleared with the modal. The hint
3273
+ * text travels in from the caller like `confirmLabel` — this function
3274
+ * predates any `t` binding and must not grow one (same rule as
3275
+ * showToast's comment below). */
3276
+ function showUrgentModal({ title, html, confirmLabel, onClose, holdMs = 0, holdHint = '' }) {
3002
3277
  try {
3003
3278
  document.querySelector('.ofm_scrim[data-ofm-urgent]')?.remove()
3004
3279
  const scrim = document.createElement('div')
@@ -3020,8 +3295,35 @@ window.__ModuleLoader__.load({
3020
3295
  ok.type = 'button'
3021
3296
  ok.className = 'ofm_btn primary'
3022
3297
  ok.textContent = confirmLabel ?? 'OK'
3023
- ok.addEventListener('click', () => { scrim.remove(); onClose?.() })
3298
+ ok.addEventListener('click', () => {
3299
+ if (ok.disabled === true) return
3300
+ scrim.remove()
3301
+ if (holdTimer !== undefined) { clearTimeout(holdTimer); holdTimer = undefined }
3302
+ onClose?.()
3303
+ })
3024
3304
  foot.appendChild(ok)
3305
+ let remaining = Math.max(0, Math.round(Number(holdMs) || 0))
3306
+ let holdTimer
3307
+ const settle = () => {
3308
+ ok.disabled = false
3309
+ ok.textContent = confirmLabel ?? 'OK'
3310
+ hint?.remove()
3311
+ hint = undefined
3312
+ }
3313
+ // 倒计时提示行:告诉用户这不是坏的按钮,是刻意的三秒。
3314
+ let hint = remaining > 0 ? document.createElement('span') : undefined
3315
+ if (hint !== undefined) {
3316
+ hint.className = 'ofm_holdhint'
3317
+ hint.setAttribute('aria-live', 'polite')
3318
+ foot.appendChild(hint)
3319
+ }
3320
+ const tick = () => {
3321
+ if (remaining <= 0) { settle(); return }
3322
+ ok.disabled = true
3323
+ hint.textContent = String(holdHint ?? '').replace('{s}', String(Math.ceil(remaining / 1000)))
3324
+ holdTimer = setTimeout(() => { remaining = Math.max(0, remaining - 250); tick() }, 250)
3325
+ }
3326
+ tick()
3025
3327
  modal.appendChild(head)
3026
3328
  modal.appendChild(body)
3027
3329
  modal.appendChild(foot)
@@ -3063,6 +3365,7 @@ window.__ModuleLoader__.load({
3063
3365
  onClick: props.onClick,
3064
3366
  title: props.title,
3065
3367
  'aria-label': props['aria-label'],
3368
+ 'aria-busy': props.busy === true ? 'true' : props['aria-busy'],
3066
3369
  }, props.children)
3067
3370
 
3068
3371
  function Section(props) {
@@ -3231,7 +3534,7 @@ window.__ModuleLoader__.load({
3231
3534
  }
3232
3535
 
3233
3536
  function isFreePoolModel(model) {
3234
- return !model.channel || model.channel === 'anonymous' || model.channel === 'kilo'
3537
+ return !model.channel || model.channel === 'anonymous' || model.channel === 'kilo' || model.channel === 'extension'
3235
3538
  }
3236
3539
 
3237
3540
  function freePoolView(data) {
@@ -6872,7 +7175,7 @@ window.__ModuleLoader__.load({
6872
7175
  // upgrader triggers — comes back to the page the user was reading.
6873
7176
  const EFFECTS_KEY = 'ofm.visualEffects'
6874
7177
  const TAB_KEY = 'ofm.tab'
6875
- const TABS = ['free', 'eac', 'channels', 'aggregate', 'workspace', 'saver', 'routing', 'ledger', 'logs', 'gateway', 'upgrade', 'picks']
7178
+ const TABS = ['free', 'eac', 'channels', 'aggregate', 'workspace', 'extensions', 'saver', 'routing', 'ledger', 'logs', 'gateway', 'upgrade', 'picks']
6876
7179
  const WORKSPACE_CSS = `
6877
7180
  .who{min-width:0}
6878
7181
  .who .name,.nm .n,.lib-path{white-space:normal!important;overflow-wrap:anywhere;overflow:visible!important;text-overflow:clip!important}
@@ -7169,6 +7472,7 @@ window.__ModuleLoader__.load({
7169
7472
  ['channels', t('nav.tab.channels'), CHANNEL_PROVIDERS.length],
7170
7473
  ['aggregate', t('nav.tab.aggregate'), data.catalog.filter(m => m.channel === 'aggregate').length],
7171
7474
  ['workspace', t('nav.tab.workspace'), data.catalog.filter(m => m.channel === 'subscription').length],
7475
+ ['extensions', t('nav.tab.extensions'), (data.extensions?.installed ?? []).length],
7172
7476
  ['saver', t('nav.tab.saver'), null],
7173
7477
  ['routing', t('nav.tab.routing'), data.catalog.filter(m => m.channel === 'combo').length],
7174
7478
  ['ledger', t('nav.tab.ledger'), null],
@@ -7205,7 +7509,9 @@ window.__ModuleLoader__.load({
7205
7509
  h(StarButton, { t })))
7206
7510
 
7207
7511
  const page = tab === 'workspace'
7208
- ? h(WorkspacePage, { t, summary, section: workspaceSection, onSelect: setWorkspaceSection }) : tab === 'channels'
7512
+ ? h(WorkspacePage, { t, summary, section: workspaceSection, onSelect: setWorkspaceSection }) : tab === 'extensions'
7513
+ ? h(ExtensionsPage, { t, onChanged: summary.reload })
7514
+ : tab === 'channels'
7209
7515
  ? h(ChannelsPage, { t, ctx, summary })
7210
7516
  : tab === 'aggregate'
7211
7517
  ? h(AggregatePage, { t, summary })
@@ -7269,6 +7575,10 @@ window.__ModuleLoader__.load({
7269
7575
  h('p', { className: 'ofm_pagesub' }, t('free.sub'))),
7270
7576
  h('div', { className: 'ofm_pills' },
7271
7577
  h(Pill, { strong: true, tone: data.settings.enabled !== false ? 'ok' : 'err' }, data.settings.enabled !== false ? t('pref.enabled') : 'off'),
7578
+ /* Environment readiness: a missing Bun silently kills the official
7579
+ free lane's inference — surface it where the lane lives. */
7580
+ data.bun === false ? h('span', { title: t('ext.bunHint') },
7581
+ h(Pill, { strong: true, tone: 'err' }, t('ext.bunMissing'))) : null,
7272
7582
  h(Pill, null, `${t('pref.egress')} ${data.egress?.country ?? data.egress?.ip ?? '—'}`),
7273
7583
  h(Pill, null, `${t('pref.probedAt')} ${ago(data.probedAt, t.locale)}`))),
7274
7584
  h('div', { className: 'ofm_tankrow' },
@@ -7306,6 +7616,560 @@ window.__ModuleLoader__.load({
7306
7616
  h(Section, { title: t('section.news'), hint: t('section.newsHint') }, h(NewsPanel, { t })))
7307
7617
  }
7308
7618
 
7619
+ /** The extension marketplace: discover, installed, and the safety contract. */
7620
+ function ExtensionsPage({ t, onChanged }) {
7621
+ const [state, setState] = useState({ status: 'loading' })
7622
+ const [busyId, setBusyId] = useState('')
7623
+ const [detail, setDetail] = useState(undefined)
7624
+ const [tab, setTab] = useState('discover')
7625
+ const [query, setQuery] = useState('')
7626
+ const [typeFilter, setTypeFilter] = useState('all')
7627
+ const [sortKey, setSortKey] = useState('curated')
7628
+ const [updatingAll, setUpdatingAll] = useState(false)
7629
+ /* 2.10 增补:自动更新开关与它的 soak 提示、索引来源、最近操作。
7630
+ `updates` 由后端在每次索引读取时算好(seenAt 记第一次看到新版本),
7631
+ 页面只读;写开关走既有 /settings 路由。 */
7632
+ const [autoUpdateBusy, setAutoUpdateBusy] = useState(false)
7633
+ /* Tag browsing (magpie's category gesture): click a tag chip, the grid
7634
+ narrows to it. `updateProgress` counts a running 全部更新; `selfTests`
7635
+ keeps each card's latest selfTest verdict. */
7636
+ const [tagFilter, setTagFilter] = useState('')
7637
+ const [updateProgress, setUpdateProgress] = useState(null)
7638
+ const [selfTests, setSelfTests] = useState({})
7639
+ const searchRef = useRef(null)
7640
+ const load = useCallback(() => {
7641
+ /* Re-reads keep the previous payload so a refresh (or the read after an
7642
+ install) does not blank the page out mid-flight. */
7643
+ setState(current => ({ ...current, status: 'loading' }))
7644
+ api('/extensions', { timeout: 20_000 })
7645
+ .then(payload => setState({ status: 'ready', ...payload }))
7646
+ .catch(error => setState(current => ({ ...current, status: 'error', error: String(error?.message ?? error) })))
7647
+ }, [])
7648
+ useEffect(() => { load() }, [load])
7649
+ /* `/` focuses the search box — the marketplace's first gesture is search,
7650
+ and the pointer should not be required for it. */
7651
+ useEffect(() => {
7652
+ const onKey = event => {
7653
+ if (event.key !== '/' || event.defaultPrevented === true) return
7654
+ const target = event.target
7655
+ const tag = target !== null && typeof target === 'object' ? String(target.tagName ?? '').toLowerCase() : ''
7656
+ if (tag === 'input' || tag === 'textarea' || tag === 'select' || target?.isContentEditable === true) return
7657
+ const input = searchRef.current
7658
+ if (input === null || input === undefined) return
7659
+ event.preventDefault()
7660
+ input.focus()
7661
+ }
7662
+ window.addEventListener('keydown', onKey)
7663
+ return () => window.removeEventListener('keydown', onKey)
7664
+ }, [])
7665
+ /* A mutation's response refreshes `installed`, but each registry row also
7666
+ carries its own installed/update/enabled copy — left alone, a card keeps
7667
+ its stale "update ready" tag (or stays on Install) until a full reload. */
7668
+ const patchRegistryRow = (id, patch) => setState(current => {
7669
+ const registry = current.registry
7670
+ if (registry === null || registry === undefined) return current
7671
+ return { ...current, registry: { ...registry, plugins: registry.plugins.map(row => row.id === id ? { ...row, ...patch } : row) } }
7672
+ })
7673
+ const act = async (path, body, refresh = true) => {
7674
+ setBusyId(body?.id ?? path)
7675
+ try {
7676
+ const payload = await post(path, body, 120_000)
7677
+ if (refresh) setState(current => ({ ...current, ...payload, status: 'ready' }))
7678
+ else load()
7679
+ if (path === '/extensions/install' && payload?.record !== undefined) {
7680
+ patchRegistryRow(payload.record.id, {
7681
+ installed: true, installedVersion: payload.record.version, updateAvailable: false,
7682
+ enabled: payload.record.enabled === true, state: payload.record.state,
7683
+ })
7684
+ } else if (path === '/extensions/uninstall' && typeof body?.id === 'string') {
7685
+ patchRegistryRow(body.id, { installed: false, enabled: false, updateAvailable: false, state: 'stopped' })
7686
+ } else if (path === '/extensions/toggle' && payload?.record !== undefined) {
7687
+ patchRegistryRow(payload.record.id, { enabled: payload.record.enabled === true, state: payload.record.state })
7688
+ }
7689
+ /* The nav badge and the free-page roster read the summary — refresh it
7690
+ so an install/uninstall is reflected everywhere, not just here. */
7691
+ onChanged?.()
7692
+ return payload
7693
+ } catch (error) {
7694
+ showToast({ title: t('ext.failed'), body: String(error?.message ?? error), tone: 'warn' })
7695
+ throw error
7696
+ } finally { setBusyId('') }
7697
+ }
7698
+ const installFromDetail = async id => {
7699
+ try { await act('/extensions/install', { id }); setDetail(undefined) } catch { /* the toast already said why */ }
7700
+ }
7701
+ const installed = state.installed ?? []
7702
+ const registry = state.registry
7703
+ const registryById = new Map((registry?.plugins ?? []).map(plugin => [plugin.id, plugin]))
7704
+ const needle = query.trim().toLowerCase()
7705
+ const discover = (registry?.plugins ?? []).filter(plugin => {
7706
+ if (typeFilter !== 'all' && plugin.type !== typeFilter) return false
7707
+ /* Tags ride the haystack: "kilo" should find the entry that is tagged kilo
7708
+ even when its name never says it. */
7709
+ if (needle !== '' && !`${plugin.name} ${plugin.id} ${plugin.summary ?? ''} ${(plugin.tags ?? []).join(' ')}`.toLowerCase().includes(needle)) return false
7710
+ if (tagFilter !== '' && !(plugin.tags ?? []).includes(tagFilter)) return false
7711
+ return true
7712
+ })
7713
+ if (sortKey === 'newest') discover.sort((a, b) => String(b.publishedAt ?? '').localeCompare(String(a.publishedAt ?? '')))
7714
+ else if (sortKey === 'name') discover.sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? '')))
7715
+ const indexRows = registry?.plugins ?? []
7716
+ const channelCount = indexRows.filter(plugin => plugin.type === 'channel').length
7717
+ /* magpie's Installed tab: what is outdated floats to the top with a count
7718
+ and an "update all" beside it, so a stale shelf never hides. */
7719
+ const updates = installed.filter(record => registryById.get(record.id)?.updateAvailable === true)
7720
+ const installedSorted = [...installed].sort((a, b) => {
7721
+ const ua = registryById.get(a.id)?.updateAvailable === true ? 0 : 1
7722
+ const ub = registryById.get(b.id)?.updateAvailable === true ? 0 : 1
7723
+ if (ua !== ub) return ua - ub
7724
+ return String(b.installedAt ?? '').localeCompare(String(a.installedAt ?? ''))
7725
+ })
7726
+ /* soak 状态:updates[] 由后端在索引读取时算好,id 对齐。 */
7727
+ const updatesById = new Map((state.updates ?? []).map(update => [update.id, update]))
7728
+ const soakHint = plugin => {
7729
+ const update = updatesById.get(plugin.id)
7730
+ if (update === undefined) return ''
7731
+ if (update.ready === true) return t('ext.soakReady')
7732
+ return t('ext.soakWaiting').replace('{h}', String(Math.max(1, Math.ceil(update.soakRemainingMs / 3_600_000))))
7733
+ }
7734
+ const auditLabel = action => ({ install: t('ext.auditInstall'), 'install-local': t('ext.auditInstallLocal'), uninstall: t('ext.auditUninstall'), 'set-enabled': t('ext.auditEnabled'), config: t('ext.auditConfig'), secret: t('ext.auditSecret'), 'self-test': t('ext.selfTest'), 'auto-update': t('ext.auditAutoUpdate'), 'registry-refresh': t('ext.auditRegistry') })[action] ?? action
7735
+ const sourceLabel = meta => {
7736
+ if (meta?.source === 'bundled') return t('ext.sourceBundled')
7737
+ const source = String(meta?.source ?? '')
7738
+ if (source === '') return ''
7739
+ try { return `${new URL(source).hostname}(缓存)` } catch { return source }
7740
+ }
7741
+
7742
+ const updateAll = async () => {
7743
+ if (updatingAll === true || updates.length === 0) return
7744
+ setUpdatingAll(true)
7745
+ setUpdateProgress({ done: 0, total: updates.length })
7746
+ try {
7747
+ let done = 0
7748
+ for (const record of updates) {
7749
+ try { await act('/extensions/install', { id: record.id }) } catch { /* one refusal must not strand the rest */ }
7750
+ done += 1
7751
+ setUpdateProgress({ done, total: updates.length })
7752
+ }
7753
+ } finally { setUpdatingAll(false); setUpdateProgress(null) }
7754
+ }
7755
+ /* 自动更新开关:写回 /settings 的持久位;页面值以响应的 settings 为准。 */
7756
+ const toggleAutoUpdate = async () => {
7757
+ if (autoUpdateBusy === true) return
7758
+ setAutoUpdateBusy(true)
7759
+ try {
7760
+ const next = state.autoUpdate !== true
7761
+ await post('/settings', { autoUpdateExtensions: next }, 15_000)
7762
+ setState(current => ({ ...current, autoUpdate: next }))
7763
+ } catch (error) {
7764
+ showToast({ title: t('ext.failed'), body: String(error?.message ?? error), tone: 'warn' })
7765
+ } finally { setAutoUpdateBusy(false) }
7766
+ }
7767
+ /* 迁移助手:导出已安装清单(id、版本、启停、配置;密钥永不导出)。 */
7768
+ const exportShelf = () => {
7769
+ const rows = state.installed ?? []
7770
+ if (rows.length === 0) return
7771
+ const shelf = {
7772
+ version: 1, exportedAt: new Date().toISOString(),
7773
+ plugins: rows.map(record => ({ id: record.id, version: record.version, enabled: record.enabled === true, config: record.values ?? {} })),
7774
+ }
7775
+ const blob = new Blob([JSON.stringify(shelf, null, 2)], { type: 'application/json' })
7776
+ const url = URL.createObjectURL(blob)
7777
+ const link = document.createElement('a')
7778
+ link.href = url
7779
+ link.download = `ofm-extensions-${new Date().toISOString().slice(0, 10)}.json`
7780
+ link.click()
7781
+ URL.revokeObjectURL(url)
7782
+ showToast({ title: t('ext.exportDone'), holdMs: 6000 })
7783
+ }
7784
+ /* 迁移助手:按清单逐个重装并恢复配置与启停;一个失败不拖累其余。 */
7785
+ const importShelf = async file => {
7786
+ let shelf
7787
+ try { shelf = JSON.parse(await file.text()) } catch { shelf = null }
7788
+ const entries = shelf !== null && typeof shelf === 'object' && Array.isArray(shelf?.plugins)
7789
+ ? shelf.plugins.filter(row => row !== null && typeof row === 'object' && typeof row.id === 'string')
7790
+ : []
7791
+ if (entries.length === 0) { showToast({ title: t('ext.importInvalid'), tone: 'warn' }); return }
7792
+ let restored = 0
7793
+ let failed = 0
7794
+ for (const entry of entries) {
7795
+ try {
7796
+ await post('/extensions/install', { id: entry.id }, 120_000)
7797
+ if (entry.config !== null && typeof entry.config === 'object' && !Array.isArray(entry.config)) {
7798
+ await post('/extensions/config', { id: entry.id, values: entry.config }, 30_000)
7799
+ }
7800
+ if (entry.enabled === true) await post('/extensions/toggle', { id: entry.id, enabled: true }, 60_000)
7801
+ restored += 1
7802
+ } catch { failed += 1 }
7803
+ }
7804
+ await load()
7805
+ showToast({ title: t('ext.importDone').replace('{ok}', String(restored)).replace('{fail}', failed > 0 ? t('ext.importFail').replace('{n}', String(failed)) : ''), tone: failed > 0 ? 'warn' : undefined, holdMs: 8000 })
7806
+ }
7807
+
7808
+ /* The manifest's optional selfTest(ctx), run from the card. The verdict is
7809
+ data, not a toast: a failed hook paints ✗ + its reason on the card. */
7810
+ const runSelfTest = async id => {
7811
+ setSelfTests(current => ({ ...current, [id]: { busy: true } }))
7812
+ try {
7813
+ const verdict = await post('/extensions/self-test', { id }, 20_000)
7814
+ setSelfTests(current => ({ ...current, [id]: { ok: verdict?.ok === true, message: verdict?.message ?? '' } }))
7815
+ } catch (error) {
7816
+ setSelfTests(current => ({ ...current, [id]: { ok: false, message: String(error?.message ?? error) } }))
7817
+ }
7818
+ }
7819
+ const cardFor = (plugin, index = 0, filterable = false) => {
7820
+ const record = installed.find(row => row.id === plugin.id)
7821
+ const busy = busyId === plugin.id
7822
+ const modelCount = plugin.models?.length ?? plugin.modelCount
7823
+ const hasCover = plugin.cover !== null && plugin.cover !== undefined
7824
+ const head = h('div', { className: 'ofm_exthead' },
7825
+ extensionAvatar(plugin),
7826
+ h('div', { className: 'ofm_exttitle' },
7827
+ h('div', { className: 'ofm_cardhead' },
7828
+ h('span', { className: 'ofm_cardname', title: plugin.name }, plugin.name),
7829
+ h('span', { className: 'ofm_badge ok', title: t('ext.verifiedTitle') }, t('ext.verified')),
7830
+ plugin.updateAvailable === true ? h('span', { className: 'ofm_hottag' }, t('ext.updateAvailable')) : null),
7831
+ h('div', { className: 'ofm_id' }, `${plugin.id} · v${plugin.version} · ${plugin.author || '—'}`)))
7832
+ const blurb = h('p', { className: 'ofm_note ofm_extblurb' }, plugin.summary || plugin.description || '')
7833
+ const hostGate = plugin.installable === false
7834
+ const hostHint = t('ext.requiresHost').replace('{v}', plugin.minHost ?? '')
7835
+ const tagRow = h('div', { className: 'ofm_tags' },
7836
+ h('span', { className: 'ofm_chantag' }, plugin.type === 'channel' ? t('ext.channel') : t('ext.enhancement')),
7837
+ /* A silent lane takeover (kilo-free → the built-in Kilo) must be visible
7838
+ where the user decides to install, not buried in the manifest. */
7839
+ plugin.replaces ? h('span', { className: 'ofm_replacetag', title: t('ext.replaces').replace('{name}', plugin.replaces) }, t('ext.replaces').replace('{name}', plugin.replaces)) : null,
7840
+ (plugin.tags ?? []).slice(0, 3).map(tag => filterable
7841
+ ? h('button', { className: 'ofm_tag ofm_tagbtn', key: tag, type: 'button', title: t('ext.filterTag'), onClick: () => { setTab('discover'); setTagFilter(tag) } }, tag)
7842
+ : h('span', { className: 'ofm_tag', key: tag }, tag)),
7843
+ typeof modelCount === 'number' && modelCount > 0 ? h('span', { className: 'ofm_badge' }, t('ext.modelCount').replace('{n}', String(modelCount))) : null)
7844
+ const acquire = label => h(Button, {
7845
+ kind: 'primary', disabled: busy || hostGate, busy,
7846
+ title: hostGate ? hostHint : undefined,
7847
+ onClick: () => act('/extensions/install', { id: plugin.id }),
7848
+ }, busy ? t('ext.installing') : label)
7849
+ const action = record === undefined
7850
+ ? hostGate
7851
+ ? h('span', { className: 'ofm_badge throttled', title: hostHint }, hostHint)
7852
+ : acquire(t('ext.install'))
7853
+ : plugin.updateAvailable === true
7854
+ ? acquire(t('ext.update'))
7855
+ : h('span', { className: 'ofm_badge ok' }, t('ext.installed'))
7856
+ const actions = h('div', { className: 'ofm_extfoot' },
7857
+ h(Button, { onClick: () => setDetail(plugin) }, t('ext.detail')),
7858
+ action)
7859
+ if (record === undefined) {
7860
+ return h('article', {
7861
+ className: `ofm_card ofm_extcard ${plugin.type === 'channel' ? 'channel' : 'enhancement'}${hasCover ? ' hascover' : ''}`,
7862
+ key: plugin.id, style: { '--i': String(index) },
7863
+ }, h(ExtensionCover, { plugin }), head, blurb, tagRow, actions)
7864
+ }
7865
+ const fields = []
7866
+ for (const field of record.config) {
7867
+ if (field.type === 'toggle') {
7868
+ fields.push(h('div', { className: 'ofm_row', key: field.key, style: { gap: 8 } },
7869
+ h('span', { className: 'ofm_note', style: { minWidth: 90 } }, field.label),
7870
+ h(Switch, {
7871
+ checked: record.values?.[field.key] === true, label: field.label,
7872
+ onChange: () => act('/extensions/config', { id: plugin.id, values: { [field.key]: !(record.values?.[field.key] === true) } }),
7873
+ })))
7874
+ } else if (field.type === 'select') {
7875
+ // The manifest's option list is the whole truth: render it, save the
7876
+ // pick the moment it changes.
7877
+ const options = field.options ?? []
7878
+ const current = String(record.values?.[field.key] ?? (options.includes(String(field.default)) ? field.default : options[0] ?? ''))
7879
+ fields.push(h('div', { className: 'ofm_row', key: field.key, style: { gap: 8 } },
7880
+ h('span', { className: 'ofm_note', style: { minWidth: 90 } }, field.label),
7881
+ h('select', {
7882
+ className: 'ofm_input', value: current, style: { minWidth: 140 },
7883
+ onChange: event => {
7884
+ if (event.target.value !== current) act('/extensions/config', { id: plugin.id, values: { [field.key]: event.target.value } })
7885
+ },
7886
+ }, options.map(option => h('option', { key: option, value: option }, option)))))
7887
+ } else {
7888
+ const numeric = field.type === 'number'
7889
+ fields.push(h('div', { className: 'ofm_row', key: field.key, style: { gap: 8 } },
7890
+ h('span', { className: 'ofm_note', style: { minWidth: 90 } }, field.label),
7891
+ h('input', {
7892
+ className: 'ofm_input', type: field.type === 'password' ? 'password' : numeric ? 'number' : 'text', style: { minWidth: 140 },
7893
+ defaultValue: record.values?.[field.key] ?? '', placeholder: field.type === 'password' ? t('ext.secretPlaceholder') : '',
7894
+ onBlur: event => {
7895
+ const raw = event.target.value
7896
+ if (numeric && raw === '') return // untouched, not a zero
7897
+ const next = numeric ? Number(raw) : raw
7898
+ if (next !== (record.values?.[field.key] ?? '')) {
7899
+ act('/extensions/config', { id: plugin.id, values: { [field.key]: next } })
7900
+ }
7901
+ },
7902
+ })))
7903
+ }
7904
+ }
7905
+ for (const key of record.permissions.secrets) {
7906
+ const hint = record.secrets.find(row => row.key === key)?.hint ?? ''
7907
+ fields.push(h('div', { className: 'ofm_row', key, style: { gap: 8 } },
7908
+ h('span', { className: 'ofm_note', style: { minWidth: 90 } }, key),
7909
+ h('input', {
7910
+ className: 'ofm_input', type: 'password', style: { minWidth: 140 }, placeholder: t('ext.secretPlaceholder'),
7911
+ onBlur: event => {
7912
+ if (event.target.value !== '') {
7913
+ act('/extensions/secret', { id: plugin.id, key, value: event.target.value })
7914
+ event.target.value = ''
7915
+ }
7916
+ },
7917
+ }),
7918
+ h('span', { className: 'ofm_note' }, hint)))
7919
+ }
7920
+ const selfTest = selfTests[plugin.id]
7921
+ const controls = h('div', { className: 'ofm_row' },
7922
+ record.selfTestAvailable === true
7923
+ ? h(Button, { disabled: busy || selfTest?.busy === true, busy: selfTest?.busy === true, onClick: () => runSelfTest(plugin.id) }, t('ext.selfTest'))
7924
+ : null,
7925
+ h(Button, { disabled: busy, busy, onClick: () => act('/extensions/toggle', { id: plugin.id, enabled: !(record.enabled === true) }) },
7926
+ record.enabled === true ? t('ext.disable') : t('ext.enable')),
7927
+ h(Button, {
7928
+ danger: true, disabled: busy, busy,
7929
+ onClick: () => { if (window.confirm(t('ext.confirmRemove').replace('{name}', plugin.name))) act('/extensions/uninstall', { id: plugin.id }) },
7930
+ }, t('ext.uninstall')),
7931
+ record.state !== 'ready' && record.enabled === true
7932
+ ? h('span', { className: 'ofm_badge throttled', title: record.error }, t('ext.notRunning'))
7933
+ : null,
7934
+ selfTest !== undefined && selfTest.busy !== true
7935
+ ? selfTest.ok === true
7936
+ ? h('span', { className: 'ofm_badge ok', title: selfTest.message }, t('ext.selfTestOk'))
7937
+ : h('span', { className: 'ofm_badge throttled', title: selfTest.message }, t('ext.selfTestFail'))
7938
+ : null)
7939
+ return h('article', {
7940
+ className: `ofm_card ofm_extcard ${plugin.type === 'channel' ? 'channel' : 'enhancement'}${hasCover ? ' hascover' : ''}`,
7941
+ key: plugin.id, style: { '--i': String(index) },
7942
+ },
7943
+ h(ExtensionCover, { plugin }), head, blurb, tagRow, actions,
7944
+ /* 可更新的卡片多一行:最新一条更新说明 + soak 状态。一个「有更新」
7945
+ 徽标不解释为什么值得装,这一行解释。 */
7946
+ plugin.updateAvailable === true
7947
+ ? h('p', { className: 'ofm_soakline' }, [
7948
+ (plugin.changelog ?? [])[0] ?? '',
7949
+ (plugin.changelog ?? []).length > 0 ? ' · ' : '',
7950
+ soakHint(plugin),
7951
+ ].join('')) : null,
7952
+ fields.length > 0 ? h('div', { className: 'ofm_sec', style: { gap: 4 } }, fields) : null,
7953
+ controls)
7954
+ }
7955
+ return h('div', { className: 'ofm_page' },
7956
+ h('header', { className: 'ofm_hero ofm_glass' },
7957
+ h('div', { className: 'ofm_pagehead' },
7958
+ h('div', { className: 'ofm_pagetitle' },
7959
+ h('h2', null, t('ext.title')),
7960
+ h('p', { className: 'ofm_pagesub' }, t('ext.sub'))),
7961
+ h('div', { className: 'ofm_pills' },
7962
+ h(Pill, { strong: true }, t('ext.count').replace('{n}', String(indexRows.length))),
7963
+ h(Pill, null, t('ext.countByType').replace('{c}', String(channelCount)).replace('{e}', String(indexRows.length - channelCount))),
7964
+ h(Button, {
7965
+ disabled: busyId === '/extensions/registry/refresh', busy: busyId === '/extensions/registry/refresh',
7966
+ onClick: () => act('/extensions/registry/refresh', undefined, false),
7967
+ }, t('ext.refresh')))),
7968
+ h('div', { className: 'ofm_seg', role: 'group', style: { alignSelf: 'flex-start' } },
7969
+ [['discover', t('ext.tabDiscover')], ['installed', t('ext.tabInstalled')], ['security', t('ext.tabSecurity')]].map(([key, label]) =>
7970
+ h('button', { key, type: 'button', 'aria-pressed': tab === key ? 'true' : 'false', onClick: () => setTab(key) }, label)))),
7971
+ state.status === 'error' ? h('div', { className: 'ofm_callout ofm_error' }, h('div', null, h('b', null, t('ext.empty')), h('div', null, state.error))) : null,
7972
+ tab === 'discover' ? h(Fragment, null,
7973
+ h('div', { className: 'ofm_row ofm_exttoolbar' },
7974
+ h('div', { className: 'ofm_extsearch' },
7975
+ h('input', {
7976
+ ref: searchRef, className: 'ofm_input', type: 'search', placeholder: t('ext.search'),
7977
+ value: query, 'aria-keyshortcuts': '/', onChange: event => setQuery(event.target.value),
7978
+ }),
7979
+ h('kbd', { className: 'ofm_kbd', title: t('ext.searchHint'), 'aria-hidden': 'true' }, '/')),
7980
+ h('div', { className: 'ofm_seg', role: 'group', 'aria-label': t('ext.type') },
7981
+ [['all', t('ext.all'), indexRows.length], ['channel', t('ext.channel'), channelCount], ['enhancement', t('ext.enhancement'), indexRows.length - channelCount]].map(([key, label, count]) =>
7982
+ h('button', { key, type: 'button', 'aria-pressed': typeFilter === key ? 'true' : 'false', onClick: () => setTypeFilter(key) },
7983
+ label, h('span', { className: 'ofm_segcount' }, String(count))))),
7984
+ h('div', { className: 'ofm_seg', role: 'group', 'aria-label': t('ext.sort') },
7985
+ [['curated', t('ext.sortCurated')], ['newest', t('ext.sortNewest')], ['name', t('ext.sortName')]].map(([key, label]) =>
7986
+ h('button', { key, type: 'button', 'aria-pressed': sortKey === key ? 'true' : 'false', onClick: () => setSortKey(key) }, label)))),
7987
+ h('div', { className: 'ofm_row ofm_exttoolbar' },
7988
+ h('span', { className: 'ofm_inlinehint', title: t('ext.autoUpdateHint').replace('{h}', String(state.registryMeta?.soakHours ?? 2)) },
7989
+ h(Switch, {
7990
+ label: t('ext.autoUpdate'), checked: state.autoUpdate === true, disabled: autoUpdateBusy,
7991
+ onChange: () => { void toggleAutoUpdate() },
7992
+ })),
7993
+ sourceLabel(state.registryMeta) !== ''
7994
+ ? h('span', { className: 'ofm_extsource', title: `${t('ext.source')}: ${state.registryMeta?.source ?? ''}` },
7995
+ `${t('ext.source')} · ${sourceLabel(state.registryMeta)}`) : null,
7996
+ tagFilter !== '' ? h('span', { className: 'ofm_note' }, `${t('ext.filterTag')}:`) : null,
7997
+ tagFilter !== '' ? h('span', { className: 'ofm_tag' }, tagFilter) : null,
7998
+ tagFilter !== '' ? h(Button, { onClick: () => setTagFilter('') }, t('ext.clearSearch')) : null),
7999
+ !registry && state.status === 'ready' ? h('div', { className: 'ofm_callout' },
8000
+ h('div', null,
8001
+ h('b', null, t('ext.empty')),
8002
+ h('div', null, state.registryError !== undefined && state.registryError !== '' ? state.registryError : t('ext.indexHint')))) : null,
8003
+ !registry && state.status === 'loading'
8004
+ ? h('div', { className: 'ofm_grid ofm_extgrid' },
8005
+ [0, 1, 2, 3, 4, 5].map(index => h('div', {
8006
+ className: 'ofm_extskel', key: String(index), 'aria-hidden': 'true', style: { '--i': String(index) },
8007
+ }, h('i', { style: { width: '42%' } }), h('i', { style: { width: '68%' } }),
8008
+ h('i', { style: { width: '86%' } }), h('i', { style: { width: '55%' } }))))
8009
+ : h('div', { className: 'ofm_grid ofm_extgrid' }, discover.map((plugin, index) => cardFor(plugin, index, true))),
8010
+ discover.length === 0 && registry !== null && state.status === 'ready' ? h('div', { className: 'ofm_extempty' },
8011
+ h('p', { className: 'ofm_note' }, t('ext.discoverEmpty')),
8012
+ (query.trim() !== '' || typeFilter !== 'all' || tagFilter !== '')
8013
+ ? h(Button, { onClick: () => { setQuery(''); setTypeFilter('all'); setTagFilter(''); searchRef.current?.focus?.() } }, t('ext.clearSearch'))
8014
+ : h('a', { className: 'ofm_linkbtn', href: 'https://github.com/Ebony-Vinyl/ofm-extensions', target: '_blank', rel: 'noopener noreferrer' }, t('ext.openRepo'))) : null)
8015
+ : tab === 'installed' ? h(Fragment, null,
8016
+ h('div', { className: 'ofm_row ofm_exttoolbar' },
8017
+ updates.length > 0 ? h('span', { className: 'ofm_badge region-blocked' }, t('ext.updateCount').replace('{n}', String(updates.length))) : null,
8018
+ updates.length > 0 ? h(Button, { kind: 'primary', disabled: updatingAll, busy: updatingAll, onClick: updateAll },
8019
+ updatingAll && updateProgress !== null
8020
+ ? t('ext.updateProgress').replace('{i}', String(updateProgress.done)).replace('{n}', String(updateProgress.total))
8021
+ : t('ext.updateAll')) : null,
8022
+ h(Button, { onClick: exportShelf, disabled: installed.length === 0, title: t('ext.shelfTitle') }, t('ext.exportShelf')),
8023
+ h('label', { className: 'ofm_importlabel', title: t('ext.shelfTitle') },
8024
+ h('input', {
8025
+ type: 'file', accept: 'application/json,.json', hidden: true,
8026
+ onChange: event => { const file = event.target.files?.[0]; if (file !== undefined) void importShelf(file); event.target.value = '' },
8027
+ }),
8028
+ h(Button, { disabled: busyId !== '' }, t('ext.importShelf')))),
8029
+ installed.length === 0 ? h('p', { className: 'ofm_note' }, t('ext.installedEmpty'))
8030
+ : h('div', { className: 'ofm_grid ofm_extgrid' }, installedSorted.map((record, index) => cardFor({
8031
+ ...record, summary: record.description,
8032
+ tags: registryById.get(record.id)?.tags ?? [],
8033
+ publishedAt: registryById.get(record.id)?.publishedAt ?? record.installedAt,
8034
+ updateAvailable: registryById.get(record.id)?.updateAvailable === true,
8035
+ changelog: registryById.get(record.id)?.changelog ?? [],
8036
+ cover: registryById.get(record.id)?.cover,
8037
+ installed: true,
8038
+ }, index))),
8039
+ installed.length > 0 ? h(Section, { title: t('ext.auditTitle') },
8040
+ (state.audit ?? []).length > 0
8041
+ ? h('div', { className: 'ofm_audrows' },
8042
+ state.audit.slice().reverse().slice(0, 12).map((entry, index) => h('div', { className: 'ofm_audrow', key: `${entry.at}-${index}` },
8043
+ h('span', { className: 'ofm_audat' }, new Date(entry.at).toLocaleString(t.locale)),
8044
+ h('b', null, auditLabel(entry.action)),
8045
+ h('code', null, entry.id),
8046
+ entry.version !== undefined ? h('span', { className: 'ofm_note' }, `v${entry.version}`) : null,
8047
+ entry.enabled !== undefined ? h('span', { className: 'ofm_note' }, entry.enabled === true ? t('ext.enable') : t('ext.disable')) : null)))
8048
+ : h('p', { className: 'ofm_note' }, t('ext.auditEmpty'))) : null)
8049
+ : h('div', { className: 'ofm_panel' },
8050
+ h('div', { className: 'ofm_paneltitle' }, t('ext.security')),
8051
+ h('p', { className: 'ofm_note', style: { lineHeight: 1.7 } }, t('ext.securityBody')),
8052
+ h('p', { className: 'ofm_note', style: { lineHeight: 1.7 } }, t('ext.securityBody2')),
8053
+ typeof state.registry?.generatedAt === 'string' && state.registry.generatedAt !== ''
8054
+ ? h('p', { className: 'ofm_note', style: { lineHeight: 1.7 } }, t('ext.indexFresh').replace('{at}', new Date(state.registry.generatedAt).toLocaleString(t.locale)))
8055
+ : null,
8056
+ h('div', { className: 'ofm_row' },
8057
+ h('a', { className: 'ofm_linkbtn', href: `https://github.com/${'Ebony-Vinyl'}/ofm-extensions`, target: '_blank', rel: 'noopener noreferrer' }, t('ext.openRepo')))),
8058
+ detail !== undefined ? h(ExtensionDetail, { t, detail, busy: busyId === detail.id, onInstall: installFromDetail, onClose: () => setDetail(undefined) }) : null)
8059
+ }
8060
+
8061
+ /* A type-tinted letter avatar stands in for the manifest's (still optional)
8062
+ icon: every card gets a face even when the author shipped none. */
8063
+ function extensionAvatar(plugin) {
8064
+ const initial = String(plugin?.name ?? plugin?.id ?? '?').trim().charAt(0).toUpperCase()
8065
+ return h('span', {
8066
+ className: `ofm_extavatar ${plugin?.type === 'channel' ? 'channel' : 'enhancement'}`,
8067
+ 'aria-hidden': 'true',
8068
+ }, initial === '' ? '·' : initial)
8069
+ }
8070
+
8071
+ /* The card's cover banner. The bytes ride the host's own /extensions/cover
8072
+ route (digest-verified server-side, cached on disk); when the author
8073
+ shipped none — or the fetch fails — the card simply renders without one.
8074
+ The fallback state is per-card so one bad image never blanks the grid. */
8075
+ function ExtensionCover({ plugin }) {
8076
+ const [failed, setFailed] = useState(false)
8077
+ if (plugin?.cover === null || plugin?.cover === undefined || failed) return null
8078
+ return h('div', { className: 'ofm_extcover' },
8079
+ h('img', {
8080
+ src: `${API}/extensions/cover?id=${encodeURIComponent(plugin.id)}`,
8081
+ alt: '', loading: 'lazy', decoding: 'async',
8082
+ // 淡入:字节到位前不占住卡片顶部的视觉重量,一张慢图不撕开骨架。
8083
+ onLoad: event => event.currentTarget.classList.add('ofm_loaded'),
8084
+ onError: () => setFailed(true),
8085
+ }))
8086
+ }
8087
+
8088
+ /* The detail drawer portals to BODY, exactly like the disclaimer and
8089
+ announcement modals: page containers are z-index:1 stacking contexts
8090
+ while the nav card is z-index:30, so an inline fixed scrim would slide
8091
+ under the nav card and get clipped in the desktop host. */
8092
+ function ExtensionDetail({ t, detail, busy, onInstall, onClose }) {
8093
+ const host = useMemo(() => {
8094
+ const node = document.createElement('div')
8095
+ node.setAttribute('data-ofm-extension-detail-root', 'true')
8096
+ document.body.appendChild(node)
8097
+ return node
8098
+ }, [])
8099
+ useEffect(() => () => { if (host.parentNode !== null) host.parentNode.removeChild(host) }, [host])
8100
+ const dialogRef = useRef(null)
8101
+ useEffect(() => {
8102
+ dialogRef.current?.focus()
8103
+ const onKey = event => { if (event.key === 'Escape') onClose() }
8104
+ window.addEventListener('keydown', onKey)
8105
+ return () => window.removeEventListener('keydown', onKey)
8106
+ }, [onClose])
8107
+ const origins = detail.permissions?.network ?? []
8108
+ const modelCount = detail.models?.length ?? detail.modelCount
8109
+ const repositoryHost = (() => {
8110
+ try { return new URL(detail.repository).host } catch { return '' }
8111
+ })()
8112
+ const meta = [
8113
+ [t('ext.type'), detail.type === 'channel' ? t('ext.channel') : t('ext.enhancement')],
8114
+ [t('ext.license'), detail.license || '—'],
8115
+ [t('ext.published'), String(detail.publishedAt ?? '').slice(0, 10) || '—'],
8116
+ [t('ext.models'), typeof modelCount === 'number' && modelCount > 0 ? String(modelCount) : '—'],
8117
+ ...(repositoryHost !== '' ? [[t('ext.repository'), repositoryHost]] : []),
8118
+ ]
8119
+ const canAcquire = (detail.installed !== true || detail.updateAvailable === true) && detail.installable !== false
8120
+ return ReactDOM.createPortal(h('div', { className: 'ofm_scrim', onClick: onClose },
8121
+ h('div', {
8122
+ className: 'ofm_ann ofm_extdetail', role: 'dialog', 'aria-modal': 'true', 'aria-label': detail.name,
8123
+ tabIndex: -1, ref: dialogRef, onClick: event => event.stopPropagation(),
8124
+ },
8125
+ h(ExtensionCover, { plugin: detail }),
8126
+ h('div', { className: 'ofm_annhead ofm_exthead' },
8127
+ extensionAvatar(detail),
8128
+ h('div', { className: 'ofm_exttitle' },
8129
+ h('h3', { className: 'ofm_anntitle' }, detail.name),
8130
+ h('p', { className: 'ofm_annsub' }, `${detail.id} · v${detail.version} · ${detail.author || ''}`))),
8131
+ h('div', { className: 'ofm_annbody' },
8132
+ h('p', null, detail.summary || detail.description || ''),
8133
+ (detail.tags ?? []).length > 0 ? h('div', { className: 'ofm_tags' }, detail.tags.map(tag => h('span', { className: 'ofm_tag', key: tag }, tag))) : null,
8134
+ detail.replaces ? h('div', { className: 'ofm_tags' }, h('span', { className: 'ofm_replacetag' }, t('ext.replaces').replace('{name}', detail.replaces))) : null,
8135
+ detail.installed !== true ? h('p', { className: 'ofm_note' }, t('ext.notInstalledHint')) : null,
8136
+ detail.installable === false ? h('p', { className: 'ofm_note' }, t('ext.requiresHost').replace('{v}', detail.minHost ?? '')) : null,
8137
+ (detail.hooks ?? []).length > 0 ? h('div', { className: 'ofm_row', style: { gap: 6, alignItems: 'center' } },
8138
+ h('span', { className: 'ofm_note' }, t('ext.hooks')),
8139
+ detail.hooks.map(hookName => h('code', { key: hookName }, hookName))) : null,
8140
+ h('div', { className: 'ofm_kv' }, meta.map(([label, value]) => h('div', { className: 'ofm_kvc', key: label },
8141
+ h('b', null, value), h('span', null, label)))),
8142
+ /* 渠道插件的模型货架:装之前就知道它接进哪些模型,而不只是个数。 */
8143
+ (detail.models ?? []).length > 0 ? h('div', { className: 'ofm_modelshelf' },
8144
+ detail.models.slice(0, 14).map(model => h('span', {
8145
+ className: 'ofm_modelchip', key: model.id, title: `${model.id}${model.wire ? ` · ${model.wire}` : ''}`,
8146
+ }, model.name ?? model.id)),
8147
+ detail.models.length > 14 ? h('span', { className: 'ofm_note' }, `+${detail.models.length - 14}`) : null) : null,
8148
+ (detail.changelog ?? []).length > 0 ? h('div', { className: 'ofm_sec' },
8149
+ h('b', { style: { fontSize: 12.5 } }, t('ext.changelog')),
8150
+ h('ul', { className: 'ofm_changelog' }, detail.changelog.map((row, index) =>
8151
+ h('li', { key: index }, row)))) : null,
8152
+ h('div', { className: 'ofm_sec' },
8153
+ h('b', { style: { fontSize: 12.5 } }, t('ext.permissions')),
8154
+ h('p', { className: 'ofm_note' }, t('ext.permNetwork').replace('{n}', String(origins.length))),
8155
+ origins.length > 0
8156
+ ? h('div', { className: 'ofm_extorigins' }, origins.map(origin => h('code', { key: origin }, origin)))
8157
+ : h('p', { className: 'ofm_note' }, t('ext.originEmpty')),
8158
+ h('p', { className: 'ofm_note' }, t('ext.permSecrets').replace('{n}', String(detail.permissions?.secrets?.length ?? 0))),
8159
+ h('p', { className: 'ofm_note' }, `${t('ext.storage')}: ${detail.permissions?.storage === true ? t('ext.storageOn') : t('ext.storageOff')}`))),
8160
+ h('div', { className: 'ofm_annfoot' },
8161
+ detail.installed === true && detail.updateAvailable !== true ? h('span', { className: 'ofm_badge available' }, t('ext.installed')) : null,
8162
+ h('span', { className: 'spacer' }),
8163
+ typeof detail.sourceUrl === 'string' && detail.sourceUrl !== '' ? h('a', { className: 'ofm_linkbtn', href: detail.sourceUrl, target: '_blank', rel: 'noopener noreferrer' }, t('ext.viewSource')) : null,
8164
+ detail.homepage ? h('a', { className: 'ofm_linkbtn', href: detail.homepage, target: '_blank', rel: 'noopener noreferrer' }, t('ext.openHomepage')) : null,
8165
+ detail.repository && detail.repository !== detail.homepage ? h('a', { className: 'ofm_linkbtn', href: detail.repository, target: '_blank', rel: 'noopener noreferrer' }, t('ext.openRepository')) : null,
8166
+ canAcquire ? h(Button, {
8167
+ kind: 'primary', disabled: busy === true, busy: busy === true,
8168
+ onClick: () => onInstall?.(detail.id),
8169
+ }, busy === true ? t('ext.installing') : detail.installed === true ? t('ext.update') : t('ext.install')) : null,
8170
+ h(Button, { onClick: onClose }, t('ext.close'))))), host)
8171
+ }
8172
+
7309
8173
  function EacPage(props) {
7310
8174
  const { t, data, summary, eacAuth, setEacAuth, eacLogin, busy, catalogBusy, apply, bench, benches, refresh, reprobe } = props
7311
8175
  const { pool, poolError } = usePool()
@@ -7554,10 +8418,14 @@ window.__ModuleLoader__.load({
7554
8418
  for (const item of data.items ?? []) {
7555
8419
  osNotify(t('toast.annTitle'), item.title)
7556
8420
  if (item.level === 'urgent') {
8421
+ if (urgentShownIds.has(item.id)) continue
8422
+ urgentShownIds.add(item.id)
7557
8423
  showUrgentModal({
7558
8424
  title: `${t('news.urgentTitle')} · ${item.title}`,
7559
8425
  html: item.html ?? '',
7560
8426
  confirmLabel: t('news.gotIt'),
8427
+ holdMs: (item.holdSeconds ?? 0) * 1000,
8428
+ holdHint: t('news.holdHint'),
7561
8429
  onClose: () => { void post('/announcements/ack', { id: item.id }).catch(() => {}) },
7562
8430
  })
7563
8431
  } else {
@@ -7588,6 +8456,26 @@ window.__ModuleLoader__.load({
7588
8456
  window.dispatchEvent(new CustomEvent('ofm:hello', { detail: data }))
7589
8457
  })
7590
8458
  }
8459
+ // 升级场景:公告在页面关闭期间到达(插件重启后的首轮轮询),到达推送
8460
+ // 没有任何观看者。打开流之前补一次已读检查——最新一条未确认的 urgent
8461
+ // 照样弹层,关闭即 ack。显示过的一律不重复(本次页面生命周期内)。
8462
+ void (async () => {
8463
+ try {
8464
+ const view = await api('/announcements')
8465
+ const candidates = (view?.items ?? []).filter(item => item.level === 'urgent' && item.acked !== true && urgentShownIds.has(item.id) === false)
8466
+ const item = candidates.sort((left, right) => Number(right.createdAt ?? 0) - Number(left.createdAt ?? 0))[0]
8467
+ if (item === undefined || disposed === true) return
8468
+ urgentShownIds.add(item.id)
8469
+ showUrgentModal({
8470
+ title: `${t('news.urgentTitle')} · ${item.title}`,
8471
+ html: item.html ?? '',
8472
+ confirmLabel: t('news.gotIt'),
8473
+ holdMs: (item.holdSeconds ?? 0) * 1000,
8474
+ holdHint: t('news.holdHint'),
8475
+ onClose: () => { void post('/announcements/ack', { id: item.id }).catch(() => {}) },
8476
+ })
8477
+ } catch { /* the modal is best effort; the center still lists it */ }
8478
+ })()
7591
8479
  open()
7592
8480
  return () => {
7593
8481
  disposed = true