dsh-router-core 0.4.0 → 0.4.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 (99) hide show
  1. package/README.md +174 -38
  2. package/cordis.patch.yml +22 -0
  3. package/docs/ext.md +29 -6
  4. package/docs/suppliers.md +28 -5
  5. package/lib/client-registry.js +939 -132
  6. package/lib/client.js +939 -132
  7. package/lib/ext-test/index.js +47 -0
  8. package/lib/ext-test/locale/en.json +6 -0
  9. package/lib/ext-test/locale/zh.json +6 -0
  10. package/lib/ext-test/package.json +5 -0
  11. package/lib/http-state-C1vb3gW2.js +48 -0
  12. package/lib/index.js +838 -89
  13. package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
  14. package/lib/suppliers/nvidia/locale/en.json +6 -0
  15. package/lib/suppliers/nvidia/locale/zh.json +6 -0
  16. package/lib/suppliers/nvidia/package.json +5 -0
  17. package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
  18. package/lib/suppliers/opencode/locale/en.json +6 -0
  19. package/lib/suppliers/opencode/locale/zh.json +6 -0
  20. package/lib/suppliers/opencode/package.json +5 -0
  21. package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
  22. package/lib/suppliers/openrouter/locale/en.json +6 -0
  23. package/lib/suppliers/openrouter/locale/zh.json +6 -0
  24. package/lib/suppliers/openrouter/package.json +5 -0
  25. package/lib/types/ext/contract.d.ts +9 -0
  26. package/lib/types/ext-test/index.d.ts +21 -0
  27. package/lib/types/ext-test/plugin.d.ts +23 -0
  28. package/lib/types/host-base-url.d.ts +31 -0
  29. package/lib/types/llm/adapter.d.ts +40 -4
  30. package/lib/types/router/index.d.ts +61 -8
  31. package/lib/types/shared.d.ts +12 -0
  32. package/lib/types/supplier-config.d.ts +15 -0
  33. package/lib/types/suppliers/contract.d.ts +35 -2
  34. package/lib/types/suppliers/http-state.d.ts +23 -0
  35. package/lib/types/suppliers/loader.d.ts +6 -4
  36. package/lib/types/suppliers/nvidia/index.d.ts +18 -0
  37. package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
  38. package/lib/types/suppliers/opencode/index.d.ts +18 -0
  39. package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
  40. package/lib/types/suppliers/openrouter/index.d.ts +18 -0
  41. package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
  42. package/lib/types/suppliers/probe.d.ts +160 -0
  43. package/lib/types/suppliers/registry.d.ts +3 -1
  44. package/package.json +39 -4
  45. package/src/client/ExtDetail.tsx +21 -22
  46. package/src/client/ExtTab.tsx +25 -42
  47. package/src/client/ExtTestPanel.tsx +392 -0
  48. package/src/client/RouterComponentsSection.tsx +241 -0
  49. package/src/client/RouterView.tsx +12 -5
  50. package/src/client/comp-button-scope.test.ts +58 -0
  51. package/src/client/comp-panel-layout.test.ts +58 -0
  52. package/src/client/env.d.ts +38 -1
  53. package/src/client/ext-panels.test.ts +44 -0
  54. package/src/client/ext-panels.ts +49 -0
  55. package/src/client/index.tsx +41 -0
  56. package/src/client/model-choice.test.ts +41 -0
  57. package/src/client/model-choice.ts +37 -0
  58. package/src/client/router-components.test.ts +114 -0
  59. package/src/client/router-components.ts +104 -0
  60. package/src/client/router.css +490 -9
  61. package/src/client/supplier-visibility.test.ts +57 -0
  62. package/src/client/supplier-visibility.ts +63 -0
  63. package/src/ext/contract.ts +9 -0
  64. package/src/ext-test/ext-test.test.ts +60 -0
  65. package/src/ext-test/index.ts +35 -0
  66. package/src/ext-test/locale/en.json +6 -0
  67. package/src/ext-test/locale/zh.json +6 -0
  68. package/src/ext-test/plugin.ts +34 -0
  69. package/src/ext-test/row.json +5 -0
  70. package/src/host-base-url.test.ts +129 -0
  71. package/src/host-base-url.ts +43 -0
  72. package/src/index.ts +46 -11
  73. package/src/llm/adapter.test.ts +101 -4
  74. package/src/llm/adapter.ts +89 -20
  75. package/src/router/disconnect-leak.test.ts +7 -12
  76. package/src/router/first-byte-budget.test.ts +160 -0
  77. package/src/router/index.ts +255 -35
  78. package/src/router/models-cache.test.ts +45 -0
  79. package/src/router/supplier-switch.test.ts +220 -0
  80. package/src/shared.ts +12 -0
  81. package/src/supplier-config.ts +32 -1
  82. package/src/suppliers/contract.ts +54 -2
  83. package/src/suppliers/loader.test.ts +36 -2
  84. package/src/suppliers/loader.ts +33 -18
  85. package/src/suppliers/nvidia/index.ts +27 -0
  86. package/src/suppliers/nvidia/locale/en.json +6 -0
  87. package/src/suppliers/nvidia/locale/zh.json +6 -0
  88. package/src/suppliers/nvidia/row.json +5 -0
  89. package/src/suppliers/opencode/index.ts +27 -0
  90. package/src/suppliers/opencode/locale/en.json +6 -0
  91. package/src/suppliers/opencode/locale/zh.json +6 -0
  92. package/src/suppliers/opencode/row.json +5 -0
  93. package/src/suppliers/openrouter/index.ts +27 -0
  94. package/src/suppliers/openrouter/locale/en.json +6 -0
  95. package/src/suppliers/openrouter/locale/zh.json +6 -0
  96. package/src/suppliers/openrouter/row.json +5 -0
  97. package/src/suppliers/probe.test.ts +266 -0
  98. package/src/suppliers/probe.ts +475 -0
  99. package/src/suppliers/registry.ts +102 -3
@@ -544,6 +544,14 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
544
544
  font-size: 13px;
545
545
  }
546
546
 
547
+ /* 两行空状态(标题 + 说明)。`.dshr-empty` 本身是横排的单行 flex,直接套两行
548
+ div 会左右排开,所以另给一个纵向堆叠的变体。 */
549
+ .dshr-emptyStack {
550
+ flex-direction: column;
551
+ gap: 6px;
552
+ text-align: center;
553
+ }
554
+
547
555
  /* Accounts table */
548
556
  .dshr-tableWrap {
549
557
  overflow: auto;
@@ -1025,11 +1033,6 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
1025
1033
  font-size: 11px;
1026
1034
  }
1027
1035
 
1028
- /* 扩展详情头部右侧的开关(顶到标题行右边,同供应商详情的编辑按钮位) */
1029
- .dshr-extDetailToggle {
1030
- margin-left: auto;
1031
- }
1032
-
1033
1036
  /* 扩展卡片内容区:文字左对齐,左侧留白与标题行的图标位对齐 */
1034
1037
  .dshr-extBody {
1035
1038
  padding: 10px 14px 12px 48px;
@@ -2392,7 +2395,7 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
2392
2395
  display: flex;
2393
2396
  flex-direction: column;
2394
2397
  gap: 6px;
2395
- max-height: 260px;
2398
+ max-height: 380px;
2396
2399
  overflow-y: auto;
2397
2400
  }
2398
2401
 
@@ -2502,7 +2505,7 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
2502
2505
  display: flex;
2503
2506
  flex-direction: column;
2504
2507
  gap: 2px;
2505
- max-height: 180px;
2508
+ max-height: 300px;
2506
2509
  overflow-y: auto;
2507
2510
  }
2508
2511
 
@@ -2575,7 +2578,7 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
2575
2578
  flex-direction: column;
2576
2579
  gap: 6px;
2577
2580
  min-width: 0;
2578
- max-height: 340px;
2581
+ max-height: 460px;
2579
2582
  }
2580
2583
 
2581
2584
  .dshr-comboFormRight .dshr-comboPickList {
@@ -2583,9 +2586,14 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
2583
2586
  min-height: 0;
2584
2587
  }
2585
2588
 
2586
- /* 组合编辑弹窗:加宽 */
2589
+ /* 组合编辑弹窗:加宽加高(body 更高,内部列表同步放大) */
2587
2590
  .dshr-modal-wide {
2588
2591
  width: min(760px, 100%);
2592
+ max-height: min(90vh, 820px);
2593
+ }
2594
+
2595
+ .dshr-modal-wide .dshr-modalBody {
2596
+ min-height: 560px;
2589
2597
  }
2590
2598
 
2591
2599
  /* 左右分栏之间的分割线 */
@@ -3293,3 +3301,476 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
3293
3301
  text-overflow: ellipsis;
3294
3302
  white-space: nowrap;
3295
3303
  }
3304
+
3305
+ /* ============================================================
3306
+ dsh-router — 官方插件页「路由组件」一节
3307
+ ------------------------------------------------------------
3308
+ 尺寸与配色照抄宿主 plugin-manager 的 detailSection/rows/row
3309
+ (14px 500 小标题、40px 圆角图标行、12px 行距),让这一节看起来
3310
+ 就是原生列表的一部分。
3311
+
3312
+ 注意:--rs-* 变量只定义在 .dshr-settings / .dshr-shell 上,而这一节
3313
+ 渲染在**插件页**里(两者的 DOM 祖先不同),所以这里直接用宿主的
3314
+ --dsw-alias-* 原始变量,不走中间层。
3315
+ ============================================================ */
3316
+
3317
+ .dshr-comp {
3318
+ display: flex;
3319
+ flex-direction: column;
3320
+ gap: 28px;
3321
+ min-width: 0;
3322
+ }
3323
+
3324
+ .dshr-compError {
3325
+ margin: 0;
3326
+ color: var(--dsw-alias-state-error-primary);
3327
+ font-size: 12px;
3328
+ line-height: 18px;
3329
+ }
3330
+
3331
+ .dshr-compGroup {
3332
+ display: flex;
3333
+ flex-direction: column;
3334
+ gap: 12px;
3335
+ min-width: 0;
3336
+ }
3337
+
3338
+ .dshr-compHead {
3339
+ display: flex;
3340
+ align-items: baseline;
3341
+ gap: 10px;
3342
+ }
3343
+
3344
+ .dshr-compTitle {
3345
+ margin: 0;
3346
+ font-size: 14px;
3347
+ font-weight: 500;
3348
+ line-height: 20px;
3349
+ }
3350
+
3351
+ .dshr-compCount {
3352
+ color: var(--dsw-alias-label-secondary);
3353
+ font-size: 12px;
3354
+ line-height: 18px;
3355
+ }
3356
+
3357
+ .dshr-compHint {
3358
+ margin: -4px 0 0;
3359
+ color: var(--dsw-alias-label-tertiary);
3360
+ font-size: 12px;
3361
+ line-height: 18px;
3362
+ }
3363
+
3364
+ .dshr-compRows {
3365
+ display: flex;
3366
+ flex-direction: column;
3367
+ margin: 0;
3368
+ padding: 0;
3369
+ list-style: none;
3370
+ }
3371
+
3372
+ .dshr-compRow {
3373
+ display: flex;
3374
+ align-items: center;
3375
+ gap: 16px;
3376
+ min-width: 0;
3377
+ border-bottom: 0.5px solid var(--dsw-alias-border-l2);
3378
+ padding: 12px 2px;
3379
+ }
3380
+
3381
+ .dshr-compRow:last-child {
3382
+ border-bottom: 0;
3383
+ }
3384
+
3385
+ .dshr-compRowIcon {
3386
+ display: inline-flex;
3387
+ flex: none;
3388
+ align-items: center;
3389
+ justify-content: center;
3390
+ width: 40px;
3391
+ height: 40px;
3392
+ border: 0.5px solid var(--dsw-alias-border-l3);
3393
+ border-radius: 10px;
3394
+ color: var(--dsw-alias-label-tertiary);
3395
+ overflow: hidden;
3396
+ }
3397
+
3398
+ .dshr-compRowIcon img {
3399
+ width: 100%;
3400
+ height: 100%;
3401
+ object-fit: contain;
3402
+ }
3403
+
3404
+ .dshr-compRowMain {
3405
+ display: flex;
3406
+ flex: 1;
3407
+ flex-direction: column;
3408
+ gap: 2px;
3409
+ min-width: 0;
3410
+ }
3411
+
3412
+ .dshr-compRowName {
3413
+ color: var(--dsw-alias-label-primary);
3414
+ font-size: 13.5px;
3415
+ font-weight: 500;
3416
+ line-height: 20px;
3417
+ overflow-wrap: anywhere;
3418
+ }
3419
+
3420
+ .dshr-compRowState {
3421
+ color: var(--dsw-alias-label-tertiary);
3422
+ font-size: 11.5px;
3423
+ line-height: 16px;
3424
+ overflow-wrap: anywhere;
3425
+ }
3426
+
3427
+ /* ============================================================
3428
+ dsh-router — 插件自检面板(设置 → 路由 → 扩展 → 插件自检)
3429
+ ------------------------------------------------------------
3430
+ 这一页渲染在**宿主的插件页**里,DOM 祖先既不是 .dshr-settings 也不是
3431
+ .dshr-shell,所以文件顶部那套 `--rs-*` 主题变量在这里**不存在**(未定义的
3432
+ var() 会让整条声明失效:边框/文字色退成继承值)。
3433
+
3434
+ 所以这里**不用**设置页那套组件样式,直接按宿主页面的视觉语言写,用宿主的
3435
+ `--dsw-alias-*` token:几何对齐它(14px 小标题、12px 次要文字、0.5px 描边、
3436
+ 12px 圆角),配色也从同一套 token 取 —— 于是这块面板看起来就是原生的一部分。
3437
+ 主按钮不用在这里画:交给宿主的 Button 原语(见 env.d.ts),配色由原语自己的
3438
+ token 家族决定,不必猜「主色底上配什么字色」。
3439
+ ============================================================ */
3440
+
3441
+ .dshr-comp {
3442
+ gap: 16px;
3443
+ }
3444
+
3445
+ .dshr-compIntro {
3446
+ margin: 0;
3447
+ color: var(--dsw-alias-label-secondary);
3448
+ font-size: 13px;
3449
+ line-height: 20px;
3450
+ }
3451
+
3452
+ /* 三栏收进同一块面板,读起来是「一组控件」而不是三个飘着的输入框。
3453
+ 窄了换行(详情区宽度随窗口变,写死三列会在窄窗口挤成一团)。 */
3454
+ .dshr-compForm {
3455
+ display: grid;
3456
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
3457
+ gap: 14px 16px;
3458
+ padding: 14px 16px;
3459
+ border: 0.5px solid var(--dsw-alias-border-l1);
3460
+ border-radius: 12px;
3461
+ background: var(--dsw-alias-bg-layer-1);
3462
+ }
3463
+
3464
+ .dshr-compField {
3465
+ display: flex;
3466
+ flex-direction: column;
3467
+ gap: 6px;
3468
+ min-width: 0;
3469
+ }
3470
+
3471
+ .dshr-compFieldHead {
3472
+ display: flex;
3473
+ align-items: baseline;
3474
+ justify-content: space-between;
3475
+ gap: 8px;
3476
+ }
3477
+
3478
+ .dshr-compFieldLabel {
3479
+ color: var(--dsw-alias-label-primary);
3480
+ font-size: 12px;
3481
+ font-weight: 500;
3482
+ line-height: 18px;
3483
+ }
3484
+
3485
+ /* 标题右侧的用法要点。语义放这里比塞进顶部那段长文字更容易被读到 ——
3486
+ 「模型只列可用的」「连接留空 = 池内任选」都是用这一栏时才关心的。 */
3487
+ .dshr-compFieldNote {
3488
+ color: var(--dsw-alias-label-tertiary);
3489
+ font-size: 12px;
3490
+ line-height: 18px;
3491
+ text-align: right;
3492
+ }
3493
+
3494
+ /* 36px / 12px 圆角,与宿主 Button 原语的 md 尺寸对齐。 */
3495
+ .dshr-compSelect {
3496
+ width: 100%;
3497
+ height: 36px;
3498
+ padding: 0 32px 0 11px;
3499
+ appearance: none;
3500
+ border: 0.5px solid var(--dsw-alias-border-l2);
3501
+ border-radius: 12px;
3502
+ background-color: var(--dsw-alias-bg-layer-2);
3503
+ background-image:
3504
+ linear-gradient(45deg, transparent 50%, var(--dsw-alias-label-tertiary) 50%),
3505
+ linear-gradient(135deg, var(--dsw-alias-label-tertiary) 50%, transparent 50%);
3506
+ background-position:
3507
+ calc(100% - 17px) 16px,
3508
+ calc(100% - 12px) 16px;
3509
+ background-size: 5px 5px;
3510
+ background-repeat: no-repeat;
3511
+ color: var(--dsw-alias-label-primary);
3512
+ font: inherit;
3513
+ font-size: 13px;
3514
+ line-height: 18px;
3515
+ text-overflow: ellipsis;
3516
+ cursor: pointer;
3517
+ }
3518
+
3519
+ .dshr-compSelect:hover:not(:disabled) {
3520
+ border-color: var(--dsw-alias-border-l3);
3521
+ }
3522
+
3523
+ .dshr-compSelect:focus {
3524
+ outline: none;
3525
+ border-color: var(--dsw-alias-brand-primary);
3526
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-brand-primary) 22%, transparent);
3527
+ }
3528
+
3529
+ .dshr-compSelect:disabled {
3530
+ border-color: var(--dsw-alias-border-l1);
3531
+ background-color: var(--dsw-alias-bg-layer-1);
3532
+ color: var(--dsw-alias-label-tertiary);
3533
+ cursor: not-allowed;
3534
+ }
3535
+
3536
+ /* 动作行:把按钮与它旁边的提示排在一条基线上。两个动作行(访问测试 / 契约体检)
3537
+ 共用 `dshr-compBtnRow` —— 那条抬特异性的字色规则就挂在它上面(见下)。 */
3538
+ .dshr-compActions {
3539
+ display: flex;
3540
+ align-items: center;
3541
+ gap: 12px;
3542
+ }
3543
+
3544
+ .dshr-compHint {
3545
+ color: var(--dsw-alias-label-tertiary);
3546
+ font-size: 12px;
3547
+ line-height: 18px;
3548
+ }
3549
+
3550
+ /* 结果区:成功/失败/请求失败 三种语气。底下垫一层极淡的状态色,比只有一行
3551
+ 彩色文字更容易扫到 —— 自检的结果是这一页唯一重要的信息。 */
3552
+ .dshr-compNotice {
3553
+ display: flex;
3554
+ gap: 10px;
3555
+ padding: 12px 14px;
3556
+ border: 0.5px solid var(--dsw-alias-border-l1);
3557
+ border-radius: 12px;
3558
+ background: var(--dsw-alias-bg-layer-1);
3559
+ }
3560
+
3561
+ .dshr-compNotice[data-tone="ok"] {
3562
+ border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary) 34%, transparent);
3563
+ background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent);
3564
+ }
3565
+
3566
+ .dshr-compNotice[data-tone="fail"],
3567
+ .dshr-compNotice[data-tone="network"] {
3568
+ border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 34%, transparent);
3569
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent);
3570
+ }
3571
+
3572
+ .dshr-compNoticeIcon {
3573
+ flex: none;
3574
+ width: 16px;
3575
+ height: 16px;
3576
+ margin-top: 2px;
3577
+ color: var(--dsw-alias-state-success-secondary);
3578
+ }
3579
+
3580
+ .dshr-compNotice[data-tone="fail"] .dshr-compNoticeIcon,
3581
+ .dshr-compNotice[data-tone="network"] .dshr-compNoticeIcon {
3582
+ color: var(--dsw-alias-state-error-primary);
3583
+ }
3584
+
3585
+ .dshr-compNoticeBody {
3586
+ display: flex;
3587
+ flex-direction: column;
3588
+ gap: 2px;
3589
+ min-width: 0;
3590
+ }
3591
+
3592
+ .dshr-compNoticeTitle {
3593
+ color: var(--dsw-alias-label-primary);
3594
+ font-size: 13px;
3595
+ font-weight: 500;
3596
+ line-height: 20px;
3597
+ }
3598
+
3599
+ .dshr-compNoticeText {
3600
+ color: var(--dsw-alias-label-secondary);
3601
+ font-size: 12.5px;
3602
+ line-height: 19px;
3603
+ /* 上游可能回一整段 HTML/JSON,限定宽度换行,别把布局撑破 */
3604
+ overflow-wrap: anywhere;
3605
+ }
3606
+
3607
+ /* 回显测的是哪三样。一条「通过」如果不带对象,就是没法归因的一串绿字。 */
3608
+ .dshr-compNoticeMeta {
3609
+ color: var(--dsw-alias-label-tertiary);
3610
+ font-size: 12px;
3611
+ line-height: 18px;
3612
+ overflow-wrap: anywhere;
3613
+ }
3614
+
3615
+ /* 宿主 Button 原语的 primary 想把字色设成 --dsw-alias-label-primary-foreground,
3616
+ 但上面那条兜底 `.dshr-settings button { color: inherit; font: inherit }`
3617
+ 特异性 (1,1) **高于**原语的 `.primary` (1,0) —— 赢的是它。于是按钮变成
3618
+ 「#0f1115 的底 + 继承来的 #0f1115 的字」,黑底黑字、按钮看着是空的。
3619
+
3620
+ 按本文件既有的抬特异性办法修(见文件里 .dshr-tab-on / .dshr-period 那几处注释:
3621
+ 凡是受这条兜底影响的规则,都必须带挂载容器抬到 (2,0) 以上)。这里
3622
+ `.dshr-comp .dshr-compBtnRow button` 是 (2,1),不依赖源码顺序,以后有人挪块也不会翻车。
3623
+
3624
+ 作用域挂在**动作行的共用类 `dshr-compBtnRow`** 上,不是某个具体容器:上一版这条
3625
+ 只写了 `.dshr-compActions`,随后新增的「契约体检」按钮在另一个容器里,于是又变回
3626
+ 黑底黑字。现在两个动作行共用这个类,再加按钮只需沿用它。
3627
+
3628
+ 修的是**把原语的意图还回去**,不是自己另挑一个颜色:字色仍由原语那个 token 决定。 */
3629
+ .dshr-comp .dshr-compBtnRow button {
3630
+ color: var(--dsw-alias-label-primary-foreground);
3631
+ font-size: 14px;
3632
+ line-height: 22px;
3633
+ }
3634
+
3635
+ /* --- 契约体检区(插件自检面板的第二块) ---
3636
+ 状态靠 data-state 上色,和「结果卡」那套语气一致:过了是绿、失败是红、
3637
+ 没实现是灰、不执行是中性(它是**故意**不跑的,不是问题)。 */
3638
+
3639
+ .dshr-compSection {
3640
+ display: flex;
3641
+ flex-direction: column;
3642
+ gap: 12px;
3643
+ margin-top: 4px;
3644
+ padding-top: 16px;
3645
+ border-top: 0.5px solid var(--dsw-alias-border-l1);
3646
+ }
3647
+
3648
+ .dshr-compHead {
3649
+ display: flex;
3650
+ align-items: baseline;
3651
+ justify-content: space-between;
3652
+ gap: 8px;
3653
+ }
3654
+
3655
+ .dshr-compSectionTitle {
3656
+ margin: 0;
3657
+ color: var(--dsw-alias-label-primary);
3658
+ font-size: 14px;
3659
+ font-weight: 500;
3660
+ line-height: 20px;
3661
+ }
3662
+
3663
+ .dshr-compSectionNote {
3664
+ color: var(--dsw-alias-label-tertiary);
3665
+ font-size: 12px;
3666
+ line-height: 18px;
3667
+ }
3668
+
3669
+ .dshr-compProbeRow {
3670
+ display: flex;
3671
+ align-items: flex-end;
3672
+ gap: 12px;
3673
+ }
3674
+
3675
+ .dshr-compProbeRow .dshr-compField {
3676
+ flex: 1;
3677
+ }
3678
+
3679
+ .dshr-compProbeBtn {
3680
+ flex: none;
3681
+ }
3682
+
3683
+ .dshr-compReport {
3684
+ display: flex;
3685
+ flex-direction: column;
3686
+ gap: 8px;
3687
+ padding: 12px 14px;
3688
+ border: 0.5px solid var(--dsw-alias-border-l1);
3689
+ border-radius: 12px;
3690
+ background: var(--dsw-alias-bg-layer-1);
3691
+ }
3692
+
3693
+ .dshr-compReportHead {
3694
+ display: flex;
3695
+ align-items: baseline;
3696
+ justify-content: space-between;
3697
+ gap: 10px;
3698
+ flex-wrap: wrap;
3699
+ }
3700
+
3701
+ .dshr-compReportName {
3702
+ color: var(--dsw-alias-label-primary);
3703
+ font-size: 13px;
3704
+ font-weight: 500;
3705
+ line-height: 20px;
3706
+ }
3707
+
3708
+ .dshr-compReportSum {
3709
+ color: var(--dsw-alias-label-tertiary);
3710
+ font-size: 12px;
3711
+ line-height: 18px;
3712
+ }
3713
+
3714
+ .dshr-compMembers {
3715
+ display: flex;
3716
+ flex-direction: column;
3717
+ margin: 0;
3718
+ padding: 0;
3719
+ list-style: none;
3720
+ }
3721
+
3722
+ .dshr-compMember {
3723
+ display: grid;
3724
+ grid-template-columns: 16px minmax(0, auto) minmax(0, 1fr);
3725
+ align-items: baseline;
3726
+ gap: 10px;
3727
+ padding: 5px 0;
3728
+ border-bottom: 0.5px solid var(--dsw-alias-border-l1);
3729
+ }
3730
+
3731
+ .dshr-compMember:last-child {
3732
+ border-bottom: 0;
3733
+ }
3734
+
3735
+ .dshr-compMemberState {
3736
+ flex: none;
3737
+ text-align: center;
3738
+ font-size: 12px;
3739
+ line-height: 18px;
3740
+ }
3741
+
3742
+ .dshr-compMember[data-state="ok"] .dshr-compMemberState {
3743
+ color: var(--dsw-alias-state-success-secondary);
3744
+ }
3745
+
3746
+ .dshr-compMember[data-state="fail"] .dshr-compMemberState {
3747
+ color: var(--dsw-alias-state-error-primary);
3748
+ }
3749
+
3750
+ .dshr-compMember[data-state="absent"] .dshr-compMemberState,
3751
+ .dshr-compMember[data-state="skipped"] .dshr-compMemberState {
3752
+ color: var(--dsw-alias-label-tertiary);
3753
+ }
3754
+
3755
+ .dshr-compMemberName {
3756
+ color: var(--dsw-alias-label-primary);
3757
+ font-size: 12.5px;
3758
+ line-height: 19px;
3759
+ white-space: nowrap;
3760
+ }
3761
+
3762
+ .dshr-compMemberKey {
3763
+ margin-left: 6px;
3764
+ color: var(--dsw-alias-label-tertiary);
3765
+ font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
3766
+ font-size: 11px;
3767
+ }
3768
+
3769
+ .dshr-compMemberDetail {
3770
+ color: var(--dsw-alias-label-secondary);
3771
+ font-size: 12px;
3772
+ line-height: 18px;
3773
+ overflow-wrap: anywhere;
3774
+ }
3775
+
3776
+
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 面板供应商可见性判据测试 —— 锁住三件事:
3
+ * 1. 关掉的卡片不出现(与扩展页同一形状)
4
+ * 2. 缺 `enabled` 按开着读(老核心没这个字段,不能让它们集体消失)
5
+ * 3. 空状态分「没装」与「全关掉」两种(装了却说暂无 = 说假话)
6
+ */
7
+ import test from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+ import { supplierVisibility, type SupplierSummary } from './supplier-visibility.ts'
10
+
11
+ const s = (id: string, source: SupplierSummary['source'], enabled?: boolean): SupplierSummary => ({
12
+ id,
13
+ name: id,
14
+ ...(source === undefined ? {} : { source }),
15
+ ...(enabled === undefined ? {} : { enabled }),
16
+ })
17
+
18
+ test('关掉的供应商不出现在面板(卡片不出现)', () => {
19
+ const v = supplierVisibility([
20
+ s('codebuddy', 'external', true),
21
+ s('traework', 'external', false),
22
+ s('opencode', 'builtin', false),
23
+ ])
24
+ assert.deepEqual(v.external.map((x) => x.id), ['codebuddy'])
25
+ assert.deepEqual(v.builtin, [])
26
+ assert.equal(v.hidden, 2)
27
+ })
28
+
29
+ test('缺 enabled 按开着读 —— 老核心没有这个字段,不能让供应商集体消失', () => {
30
+ const v = supplierVisibility([s('codebuddy', 'external'), s('opencode', 'builtin')])
31
+ assert.deepEqual(v.external.map((x) => x.id), ['codebuddy'])
32
+ assert.deepEqual(v.builtin.map((x) => x.id), ['opencode'])
33
+ assert.equal(v.hidden, 0)
34
+ assert.equal(v.empty, undefined, '有卡片可列时不该有空状态')
35
+ })
36
+
37
+ test('按来源分组不串(内置 / 插件两组互不吞)', () => {
38
+ const v = supplierVisibility([
39
+ s('opencode', 'builtin', true),
40
+ s('codebuddy', 'external', true),
41
+ s('nvidia', 'builtin', true),
42
+ ])
43
+ assert.deepEqual(v.builtin.map((x) => x.id), ['opencode', 'nvidia'])
44
+ assert.deepEqual(v.external.map((x) => x.id), ['codebuddy'])
45
+ })
46
+
47
+ test('全关掉 ≠ 暂无:空状态必须说清去哪开回来', () => {
48
+ const v = supplierVisibility([s('codebuddy', 'external', false)])
49
+ assert.equal(v.empty?.reason, 'disabled')
50
+ assert.match(v.empty?.desc ?? '', /设置 → 插件 → dsh-router-core/,
51
+ '空状态要把用户指到开关所在处,否则用户以为插件坏了')
52
+ })
53
+
54
+ test('一个都没装才是「暂无供应商」', () => {
55
+ assert.equal(supplierVisibility([]).empty?.reason, 'none')
56
+ assert.equal(supplierVisibility([s('x', 'external', false), s('y', 'builtin', false)]).empty?.reason, 'disabled')
57
+ })
@@ -0,0 +1,63 @@
1
+ /**
2
+ * 面板「供应商」页的可见性规则 —— 纯函数,不碰 React。
3
+ *
4
+ * 形状与插件页**刻意相反**:
5
+ * - 面板(设置 → 路由 → 供应商)= **在用什么**的工作面,只列**开着**的;
6
+ * 关掉的卡片不出现(与「扩展」页同一形状)。
7
+ * - 插件页(设置 → 插件 → dsh-router-core → 路由组件)= **控制面**,供应商组
8
+ * 列全部(含关掉的),否则关掉之后就再没有可点的开关能开回来。
9
+ *
10
+ * 为什么空状态要分两种:「暂无供应商」说的是**一个都没装**,而「都关掉了」是
11
+ * 装了但用户自己关的。两者共用一句「暂无供应商」就是说假话 —— 用户进面板发现
12
+ * 自己装的东西不见了,而真正的原因(开关在另一个页面关着)无处可寻。
13
+ *
14
+ * 抽出来是为了让「哪些卡片该出现」这条判据能被单测钉住:它一行就能写错,
15
+ * 而写错的表现是「面板说关了、请求照样打过去」或者「用户以为没装」。
16
+ */
17
+ import type { RouterHealthResponse } from '../shared.ts'
18
+
19
+ /** `/health` 里的一条供应商摘要(就地取型,不另抄一份形状)。 */
20
+ export type SupplierSummary = NonNullable<RouterHealthResponse['suppliers']>[number]
21
+
22
+ /** 面板要渲染的分组。 */
23
+ export interface SupplierVisibility {
24
+ /** 随核心分发、且开着的。 */
25
+ builtin: SupplierSummary[]
26
+ /** 独立安装、且开着的。 */
27
+ external: SupplierSummary[]
28
+ /** 装过但被开关关掉的个数(不列卡片,但空状态要用它说明去哪开回来)。 */
29
+ hidden: number
30
+ /**
31
+ * 空状态文案。`undefined` = 不该显示空状态(有卡片可列)。
32
+ * `reason: 'none'` 是一个都没装;`reason: 'disabled'` 是装了但全被关掉。
33
+ */
34
+ empty?: { reason: 'none' | 'disabled'; title: string; desc: string }
35
+ }
36
+
37
+ /** 开关在插件页的固定位置 —— 空状态要把用户指过去,不然就断在这里了。 */
38
+ const SWITCH_HOME = '设置 → 插件 → dsh-router-core'
39
+
40
+ /**
41
+ * 按「开着与否 + 来源」把供应商分成面板要渲染的两组,并给出空状态该说什么。
42
+ *
43
+ * `enabled` 缺省按**开着**读(老核心没有这个字段);只有明确的 `false` 才算关。
44
+ */
45
+ export function supplierVisibility(suppliers: readonly SupplierSummary[]): SupplierVisibility {
46
+ const on = suppliers.filter((s) => s.enabled !== false)
47
+ const builtin = on.filter((s) => s.source !== 'external')
48
+ const external = on.filter((s) => s.source === 'external')
49
+ const hidden = suppliers.length - on.length
50
+ if (on.length > 0) return { builtin, external, hidden }
51
+ return {
52
+ builtin,
53
+ external,
54
+ hidden,
55
+ empty: hidden > 0
56
+ ? {
57
+ reason: 'disabled',
58
+ title: '供应商都已关闭',
59
+ desc: `开关在 ${SWITCH_HOME} 的「路由组件」一节;打开后会重新参与路由`,
60
+ }
61
+ : { reason: 'none', title: '暂无供应商', desc: '' },
62
+ }
63
+ }
@@ -55,6 +55,15 @@ export interface RouterExt {
55
55
  readonly description?: string
56
56
  /** 卡片/详情图标的 logo URL(可选;缺省用默认图标)。 */
57
57
  readonly icon?: string
58
+ /**
59
+ * 这个扩展是**随核心分发**(`builtin`)还是**独立安装的插件**(不标 = plugin)。
60
+ *
61
+ * 为什么需要这个标记:随核心分发的扩展同时是插件页原生「包含的组件」里的**一行**
62
+ * (自带宿主管的开关),独立安装的扩展是各自 bundle 的行。插件页那个自绘的
63
+ * 「路由组件」节只该列后者 —— 与内置供应商的处理完全一样:已经有原生行的东西,
64
+ * 再在下面列一遍就是同一个东西显示两处。
65
+ */
66
+ readonly source?: 'builtin'
58
67
  /** 当前运行时状态(ready + 不就绪时的说明)。 */
59
68
  getState(): ExtState
60
69
  /** 卸载清理(表删除时由 dsh-router 调用,可选)。 */