dsh-router-core 0.4.1 → 0.4.3

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 (53) hide show
  1. package/README.md +18 -15
  2. package/cordis.patch.yml +10 -0
  3. package/docs/ext.md +65 -3
  4. package/lib/client-registry.js +633 -50
  5. package/lib/client.js +633 -50
  6. package/lib/contract-D4BWZ7Xe.js +13 -0
  7. package/lib/ext-prompt/index.js +527 -0
  8. package/lib/ext-prompt/locale/en.json +6 -0
  9. package/lib/ext-prompt/locale/zh.json +6 -0
  10. package/lib/ext-prompt/package.json +5 -0
  11. package/lib/ext-test/index.js +1 -12
  12. package/lib/index.js +298 -119
  13. package/lib/types/ext/contract.d.ts +113 -0
  14. package/lib/types/ext-prompt/content.d.ts +57 -0
  15. package/lib/types/ext-prompt/index.d.ts +53 -0
  16. package/lib/types/ext-prompt/logo.d.ts +17 -0
  17. package/lib/types/ext-prompt/mount.d.ts +51 -0
  18. package/lib/types/ext-prompt/plugin.d.ts +32 -0
  19. package/lib/types/ext-prompt/render.d.ts +105 -0
  20. package/lib/types/host-base-url.d.ts +31 -0
  21. package/lib/types/host-version.d.ts +7 -8
  22. package/lib/types/index.d.ts +1 -1
  23. package/lib/types/llm/adapter.d.ts +6 -1
  24. package/lib/types/shared.d.ts +33 -0
  25. package/package.json +17 -7
  26. package/src/client/EndpointTab.tsx +15 -18
  27. package/src/client/ExtDetail.tsx +552 -9
  28. package/src/client/index.tsx +7 -28
  29. package/src/client/router.css +132 -62
  30. package/src/ext/contract.ts +111 -0
  31. package/src/ext-prompt/assembly.test.ts +79 -0
  32. package/src/ext-prompt/content.ts +174 -0
  33. package/src/ext-prompt/ext-prompt.test.ts +432 -0
  34. package/src/ext-prompt/index.ts +104 -0
  35. package/src/ext-prompt/locale/en.json +6 -0
  36. package/src/ext-prompt/locale/zh.json +6 -0
  37. package/src/ext-prompt/logo.svg +14 -0
  38. package/src/ext-prompt/logo.ts +18 -0
  39. package/src/ext-prompt/mount.test.ts +95 -0
  40. package/src/ext-prompt/mount.ts +79 -0
  41. package/src/ext-prompt/plugin.ts +223 -0
  42. package/src/ext-prompt/render.test.ts +256 -0
  43. package/src/ext-prompt/render.ts +216 -0
  44. package/src/ext-prompt/row.json +5 -0
  45. package/src/ext-route.test.ts +78 -0
  46. package/src/host-base-url.test.ts +129 -0
  47. package/src/host-base-url.ts +43 -0
  48. package/src/host-version.test.ts +21 -28
  49. package/src/host-version.ts +39 -39
  50. package/src/index.ts +230 -96
  51. package/src/llm/adapter.test.ts +19 -42
  52. package/src/llm/adapter.ts +32 -50
  53. package/src/shared.ts +34 -0
@@ -48,15 +48,11 @@ interface Ctx {
48
48
  * (kind: list, scope: session —— 即宿主 composer 卡片底部操作行
49
49
  * `.uV2eYG_dock` 里、原生上下文环旁边的位置)。
50
50
  * scope 为 session 时宿主给组件注入 `sessionId`,据此按**当前会话**取命中。
51
- *
52
- * **仅宿主 >= 0.1.7 才挂**:composer.dock 这个座位在 0.1.5 也在,但渲染在
53
- * InputBar 之后的独立块(位置不对),只有 0.1.7 才放进底部操作行。座位名相同
54
- * 没法靠契约区分,故先问核心 `/router/api/health` 的 `lastHitDock` 支持位。
55
- * @returns 清理函数;不支持或宿主无 slots 时为空操作
51
+ * @returns 清理函数;宿主没给 slots 时为空操作
56
52
  */
57
- function mountLastHitDock(ctx: Ctx, supported: boolean): () => void {
53
+ function mountLastHitDock(ctx: Ctx): () => void {
58
54
  const slots = ctx.slots
59
- if (slots === undefined || !supported) return () => {}
55
+ if (slots === undefined) return () => {}
60
56
  return slots.inject('conversation.composer.dock', () => slots.register({
61
57
  name: 'conversation.composer.dock',
62
58
  id: 'dsh-router-last-hit',
@@ -64,21 +60,6 @@ function mountLastHitDock(ctx: Ctx, supported: boolean): () => void {
64
60
  }, LastHitDock))
65
61
  }
66
62
 
67
- /**
68
- * 问核心是否支持「最近命中」徽章(宿主 >= 0.1.7)。
69
- * 网络/解析失败一律当**不支持**——宁可不显示,也不在 0.1.5 上摆错位置。
70
- */
71
- async function fetchLastHitDockSupport(): Promise<boolean> {
72
- try {
73
- const res = await fetch(`${ROUTER_API_BASE}/health`)
74
- if (!res.ok) return false
75
- const body = (await res.json()) as { lastHitDock?: unknown }
76
- return body.lastHitDock === true
77
- } catch {
78
- return false
79
- }
80
- }
81
-
82
63
  /** 挂载「设置 → 路由」页;拿不到 slots 时返回 undefined 表示跳过。 */
83
64
  function mountSettingsSection(ctx: Ctx): (() => void) | undefined {
84
65
  const slots = ctx.slots
@@ -131,12 +112,10 @@ function registerExtTestPanel(ctx: Ctx): void {
131
112
 
132
113
  export function apply(ctx: Ctx): void {
133
114
  // 「最近命中」徽章:走官方 conversation.composer.dock(scope: session → 组件
134
- // props 注入 sessionId,按当前会话取命中)。**仅宿主 >= 0.1.7 才挂**——
135
- // composer.dock 在 0.1.5 渲染位置不对,先问核心拿支持位,避免摆错位置。
136
- // 查询是异步的,拿到结果(无论成败)再决定注册与否。
137
- void fetchLastHitDockSupport().then((supported) => {
138
- ctx.effect(() => mountLastHitDock(ctx, supported), 'dsh-router: last-hit dock')
139
- })
115
+ // props 注入 sessionId,按当前会话取命中)。0.2.0 起这个座位无条件可用 ——
116
+ // 0.1.5 那个「渲染位置不对」的老宿主已不支持,所以不再问核心拿支持位,
117
+ // 启动期也就不用多一次 /health 往返。
118
+ ctx.effect(() => mountLastHitDock(ctx), 'dsh-router: last-hit dock')
140
119
  const disposeSettings = mountSettingsSection(ctx)
141
120
  if (disposeSettings === undefined) {
142
121
  // 老宿主(无 slots):回退到侧边栏入口 + 中心栏面板
@@ -982,44 +982,6 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
982
982
  }
983
983
 
984
984
  /* 开关 */
985
- .dshr-toggle {
986
- position: relative;
987
- flex: none;
988
- width: 30px;
989
- height: 16px;
990
- border: none;
991
- border-radius: 8px;
992
- background: var(--rs-line);
993
- cursor: pointer;
994
- padding: 0;
995
- transition: background 0.15s ease;
996
- }
997
-
998
- .dshr-toggle-on {
999
- background: var(--rs-ok);
1000
- }
1001
-
1002
- .dshr-toggleKnob {
1003
- position: absolute;
1004
- top: 2px;
1005
- left: 2px;
1006
- width: 12px;
1007
- height: 12px;
1008
- border-radius: 50%;
1009
- background: #fff;
1010
- transition: transform 0.15s ease;
1011
- }
1012
-
1013
- .dshr-toggle-on .dshr-toggleKnob {
1014
- transform: translateX(14px);
1015
- }
1016
-
1017
- /* 开关禁用态:不可用(未就绪)且未开启时,灰显 + 不可点 */
1018
- .dshr-toggle:disabled {
1019
- cursor: default;
1020
- opacity: 0.45;
1021
- }
1022
-
1023
985
  /* 扩展页:未就绪问题描述(红字) */
1024
986
  .dshr-ext-error {
1025
987
  color: var(--rs-danger);
@@ -1340,16 +1302,6 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
1340
1302
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--rs-accent) 18%, transparent);
1341
1303
  }
1342
1304
 
1343
- /* 开关微过渡 */
1344
- .dshr-toggle {
1345
- transition:
1346
- background 0.15s ease,
1347
- box-shadow 0.15s ease;
1348
- }
1349
- .dshr-toggle:hover {
1350
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--rs-accent) 18%, transparent);
1351
- }
1352
-
1353
1305
  /* 内容区细滚动条 */
1354
1306
  .dshr-content::-webkit-scrollbar,
1355
1307
  .dshr-tableWrap::-webkit-scrollbar,
@@ -2346,8 +2298,8 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
2346
2298
  justify-content: center;
2347
2299
  font-size: 10px;
2348
2300
  font-weight: 600;
2349
- color: var(--rs-primary);
2350
- background: color-mix(in srgb, var(--rs-primary) 12%, transparent);
2301
+ color: var(--rs-accent);
2302
+ background: color-mix(in srgb, var(--rs-accent) 12%, transparent);
2351
2303
  }
2352
2304
 
2353
2305
  .dshr-comboChipSupplier {
@@ -2395,7 +2347,9 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
2395
2347
  display: flex;
2396
2348
  flex-direction: column;
2397
2349
  gap: 6px;
2398
- max-height: 380px;
2350
+ /* 原 max-height: 380px —— 写死的值,与容器无关。改由 flex 链给。 */
2351
+ flex: 1;
2352
+ min-height: 0;
2399
2353
  overflow-y: auto;
2400
2354
  }
2401
2355
 
@@ -2442,8 +2396,8 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
2442
2396
  justify-content: center;
2443
2397
  font-size: 11px;
2444
2398
  font-weight: 600;
2445
- color: var(--rs-primary);
2446
- background: color-mix(in srgb, var(--rs-primary) 12%, transparent);
2399
+ color: var(--rs-accent);
2400
+ background: color-mix(in srgb, var(--rs-accent) 12%, transparent);
2447
2401
  }
2448
2402
 
2449
2403
  .dshr-comboStepSelect {
@@ -2463,7 +2417,7 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
2463
2417
  border: 1px dashed color-mix(in srgb, var(--rs-text) 20%, transparent);
2464
2418
  border-radius: 9px;
2465
2419
  background: transparent;
2466
- color: var(--rs-primary);
2420
+ color: var(--rs-accent);
2467
2421
  font-size: 12px;
2468
2422
  font-weight: 500;
2469
2423
  cursor: pointer;
@@ -2477,8 +2431,8 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
2477
2431
  }
2478
2432
 
2479
2433
  .dshr-comboAddStep:hover {
2480
- border-color: color-mix(in srgb, var(--rs-primary) 50%, transparent);
2481
- background: color-mix(in srgb, var(--rs-primary) 5%, transparent);
2434
+ border-color: color-mix(in srgb, var(--rs-accent) 50%, transparent);
2435
+ background: color-mix(in srgb, var(--rs-accent) 5%, transparent);
2482
2436
  }
2483
2437
 
2484
2438
  /* 组合:已选模型行 + 添加模型挑选列表 */
@@ -2505,7 +2459,7 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
2505
2459
  display: flex;
2506
2460
  flex-direction: column;
2507
2461
  gap: 2px;
2508
- max-height: 300px;
2462
+ /* 原 max-height: 300px —— 写死的值,与容器无关。改由 flex 链给。 */
2509
2463
  overflow-y: auto;
2510
2464
  }
2511
2465
 
@@ -2563,7 +2517,14 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
2563
2517
  display: grid;
2564
2518
  grid-template-columns: 1fr 1fr;
2565
2519
  gap: 24px;
2566
- align-items: start;
2520
+ /* stretch 而非 start:两列都吃满行高,右列那个滚动列表才有高可用。
2521
+ 用 start 的话行高由最高的一列决定,短的那列留白 —— 同一个病。 */
2522
+ align-items: stretch;
2523
+ /* 行高必须是 minmax(0,1fr) 而不是隐式 auto:auto 行高由内容决定,
2524
+ 视口一小内容压不下去,滚动就又跑到外层 body 去了。 */
2525
+ grid-template-rows: minmax(0, 1fr);
2526
+ flex: 1;
2527
+ min-height: 0;
2567
2528
  }
2568
2529
 
2569
2530
  .dshr-comboFormLeft {
@@ -2571,6 +2532,15 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
2571
2532
  flex-direction: column;
2572
2533
  gap: 12px;
2573
2534
  min-width: 0;
2535
+ min-height: 0;
2536
+ }
2537
+
2538
+ /* 「模型」那一坨(标题 + 列表)吃掉剩余高度,列表才有空间滚动 */
2539
+ .dshr-comboFormLeft > div:last-child {
2540
+ display: flex;
2541
+ flex-direction: column;
2542
+ flex: 1;
2543
+ min-height: 0;
2574
2544
  }
2575
2545
 
2576
2546
  .dshr-comboFormRight {
@@ -2578,7 +2548,7 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
2578
2548
  flex-direction: column;
2579
2549
  gap: 6px;
2580
2550
  min-width: 0;
2581
- max-height: 460px;
2551
+ min-height: 0;
2582
2552
  }
2583
2553
 
2584
2554
  .dshr-comboFormRight .dshr-comboPickList {
@@ -2588,12 +2558,33 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
2588
2558
 
2589
2559
  /* 组合编辑弹窗:加宽加高(body 更高,内部列表同步放大) */
2590
2560
  .dshr-modal-wide {
2561
+ /* 高度上限必须**扣掉遮罩的 24px×2 padding**:原来的 90vh 在视口
2562
+ 低于 480px 时,90vh + 48px 必然超过视口,弹窗顶出遮罩。
2563
+ (基础 .dshr-modal 用 80vh 所以一直没这问题,只有 -wide 变体有。) */
2564
+ --dshr-modalMaxH: calc(100vh - 48px);
2591
2565
  width: min(760px, 100%);
2592
- max-height: min(90vh, 820px);
2566
+ max-height: min(var(--dshr-modalMaxH), 820px);
2593
2567
  }
2594
2568
 
2569
+ /* ⚠️ 这条 min-height **只预留**了高度,不往内传递:body 是块容器,里面的内容
2570
+ 各自按自己的 max-height 摆(pick list 300 / 左列表 380 / 右列 460),
2571
+ 560 - 内容高 就是底部那条空白 —— 选 2 个模型时最明显,选满时反而看不见,
2572
+ 所以它一直躲过了「按内容相关问题」的眼。
2573
+ 改成 flex 列,并让 .dshr-modalForm 吃掉剩余高度,预留才真正被内容用掉。
2574
+
2575
+ ⚠️ 地板也不能写死:视口一小,560px 会把弹窗顶出遮罩,逼得**外层 body**
2576
+ 出来滚动(多一道滚动条,还裁掉一截)。上限跟着 --dshr-modalMaxH 走
2577
+ (60 = 头部 48 + 边框 2 + 余量),空间不够时该滚的是内层
2578
+ .dshr-comboPickList,不是整个 body。 */
2595
2579
  .dshr-modal-wide .dshr-modalBody {
2596
- min-height: 560px;
2580
+ min-height: min(560px, calc(var(--dshr-modalMaxH) - 60px));
2581
+ display: flex;
2582
+ flex-direction: column;
2583
+ }
2584
+
2585
+ .dshr-modal-wide .dshr-modalForm {
2586
+ flex: 1;
2587
+ min-height: 0;
2597
2588
  }
2598
2589
 
2599
2590
  /* 左右分栏之间的分割线 */
@@ -3375,7 +3366,9 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
3375
3366
  gap: 16px;
3376
3367
  min-width: 0;
3377
3368
  border-bottom: 0.5px solid var(--dsw-alias-border-l2);
3378
- padding: 12px 2px;
3369
+ /* ⚠️ 左右 14px(原先 2px):行贴着卡片边缘,文字几乎顶到边框,读起来"挤"。
3370
+ 2026-09-29 用户提出。与 `-compHead` 的内缩对齐。 */
3371
+ padding: 12px 14px;
3379
3372
  }
3380
3373
 
3381
3374
  .dshr-compRow:last-child {
@@ -3417,11 +3410,88 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
3417
3410
  overflow-wrap: anywhere;
3418
3411
  }
3419
3412
 
3413
+ /* 扩展子开关的浮动提示:`dshr-toast` 的**窄范围覆盖**。
3414
+ ⚠️ 不去改 `.dshr-toast` 本身 —— 它被 `SupplierDetail` 也在用(那边全是
3415
+ 「连接池顺序已保存」这种 9 字短句,所以从没暴露过宽度问题)。
3416
+ 本处的错误文案更长(「只能删除自定义准则(内置条目请用开关关掉)」19 字),
3417
+ 而 `.dshr-toast` 是 `white-space: nowrap` **且没有 `max-width`** ⇒ 会横向溢出屏幕。
3418
+ ⇒ 只加**换行 + 上限 + 居中**,定位用绝对定位挂在提示自身,**不影响既有调用方**。 */
3419
+ .dshr-toastWrap {
3420
+ position: fixed;
3421
+ z-index: 2000;
3422
+ left: 50%;
3423
+ bottom: 28px;
3424
+ transform: translateX(-50%);
3425
+ max-width: min(420px, calc(100vw - 32px));
3426
+ padding: 8px 16px;
3427
+ border-radius: 8px;
3428
+ color: var(--rs-text);
3429
+ background: var(--rs-layer-2);
3430
+ border: 1px solid var(--rs-line-strong);
3431
+ box-shadow: 0 6px 20px rgb(0 0 0 / 0.18);
3432
+ font-size: 12px;
3433
+ font-weight: 500;
3434
+ white-space: pre-wrap; /* 允许换行,不再 nowrap 溢出 */
3435
+ text-align: center;
3436
+ animation: dshrToastIn 0.18s ease;
3437
+ }
3438
+
3439
+ /* 标题 + 「修改」按钮的**同一行**(`ExtDetail` 的子开关区)。
3440
+ ⚠️ 单独一个类而不改 `.dshr-compRowName`:那个类 `RouterComponentsSection` /
3441
+ `ExtTestPanel` 也在用,给它加 `display:flex` 会把**那些页面的标题**也变成
3442
+ flex 盒子(它们标题后面没有按钮,平白多一层)。
3443
+ 没有这层时按钮会掉到**下一行**:`.dshr-compRowMain` 是 column,而
3444
+ `.dshr-compRowName` 是行内元素、标题一长就折行(2026-09-29 用户报)。 */
3445
+ /* 子开关区:`.dshr-compGroup` 的 `gap: 12px` 覆盖为 0(2026-09-30 用户要求)。
3446
+ ⚠️ **不能直接改 `.dshr-compGroup`**:它还被 `RouterComponentsSection` 用着,
3447
+ 那里的 12px 隔的是「组标题 / hint / 列表」三块,删了会挤成一坨。
3448
+ ⇒ 本处单独覆盖,且**只覆盖 gap**(其余属性仍复用同一个类)。
3449
+ 本页不需要这个间距:页头自带 `border-bottom` 分割线,再加 12px 留白
3450
+ 会让分割线"悬"在一段空白上方(用户说的「分割线有高度」)。 */
3451
+ .dshr-compGroupTight {
3452
+ gap: 0;
3453
+ }
3454
+
3455
+ /* 行内的「修改」按钮:**hover 该行才显形**(2026-09-29)。
3456
+ 14 行各挂一个常驻图标 = 一排按钮压过内容本身。
3457
+ ⚠️ **必须同时有 `:focus-within`**:纯 `:hover` 会让**键盘用户永远够不到**
3458
+ (Tab 到它时它是 `opacity:0`,看得见焦点环但看不见按钮)。
3459
+ ⚠️ 用 `opacity` 而不是 `visibility`/`display` —— 后两者会让它**不可聚焦**
3460
+ (`visibility:hidden` 的元素不进 Tab 序),`opacity:0` 仍在。 */
3461
+ .dshr-compRowAct {
3462
+ opacity: 0;
3463
+ transition: opacity 0.12s ease;
3464
+ }
3465
+
3466
+ .dshr-compRow:hover .dshr-compRowAct,
3467
+ .dshr-compRow:focus-within .dshr-compRowAct {
3468
+ opacity: 1;
3469
+ }
3470
+
3471
+ /* 触屏没有 hover ⇒ 按钮永远点不到。窄屏(触屏为主)直接常显。 */
3472
+ @media (hover: none) {
3473
+ .dshr-compRowAct {
3474
+ opacity: 1;
3475
+ }
3476
+ }
3477
+
3478
+ .dshr-compRowTitleLine {
3479
+ display: flex;
3480
+ align-items: center;
3481
+ gap: 6px;
3482
+ min-width: 0;
3483
+ }
3484
+
3420
3485
  .dshr-compRowState {
3421
3486
  color: var(--dsw-alias-label-tertiary);
3422
3487
  font-size: 11.5px;
3423
3488
  line-height: 16px;
3424
3489
  overflow-wrap: anywhere;
3490
+ /* 保留换行:子开关的**原文**是这个类的另一个用法(2026-09-29 合并进来),
3491
+ 它是多行段落。状态文案都是单行,所以这条对它们没有副作用。
3492
+ ⇒ 原来的 `.dshr-compRowBody` 整块删掉:14 条规则的正文不需要左边框 +
3493
+ 缩进那种「引用块」的额外装饰(用户要求),层级只靠颜色就够。 */
3494
+ white-space: pre-wrap;
3425
3495
  }
3426
3496
 
3427
3497
  /* ============================================================
@@ -64,12 +64,123 @@ export interface RouterExt {
64
64
  * 再在下面列一遍就是同一个东西显示两处。
65
65
  */
66
66
  readonly source?: 'builtin'
67
+ /**
68
+ * 扩展自带的**子开关**(可选;不声明 = 只有总开关)。
69
+ *
70
+ * 为什么需要:有些扩展的「开/关」不是一件事而是**几件独立的事** —— 例如分层
71
+ * 提示词有 14 个准则分类,每条都该能单独关。硬塞进一个总开关 = 要么全给、
72
+ * 要么全不给。
73
+ *
74
+ * ⚠️ **与总开关的关系**:子开关是**叠加**在总开关之上的,总开关关 ⇒ 全部子开关
75
+ * 一起失效(但**不清零**用户的子开关选择)。详情页在总开关关时把它们显示为
76
+ * 禁用而不是隐藏 —— 隐藏会让用户以为它们不存在。
77
+ *
78
+ * ⚠️ **值存哪**:核心**不认识**这些子开关的语义,只负责搬运。落盘一律走
79
+ * `ExtStoreService.writeData`(`data` 抽屉),由扩展自己读回。核心不替扩展
80
+ * 存一份 —— 那样会变成两份可能漂移的真相。
81
+ */
82
+ /**
83
+ * 扩展自报的**子开关**列表。
84
+ *
85
+ * ⚠️ **实现方应当每次读都重算**(getter),而不是在构造时算好一个数组快照:
86
+ * 核心每次 `GET /ext` 都读它,面板也靠它刷新。若它是快照,改完文本再刷新
87
+ * 面板拿到的仍是**旧值** —— 用户会看到"保存了、又变回去了"
88
+ * (实测踩过:2026-09-29 第一版用 `CATEGORIES.map(...)`,编辑生效但列表
89
+ * 不跟着变,而注释里还写着"现算不缓存"——**注释与事实不符**,比没写更坏)。
90
+ */
91
+ readonly controls?: ExtControl[]
92
+ /**
93
+ * 改一个子开关(面板调用;可选 —— 没有 `controls` 就不需要)。
94
+ *
95
+ * ⚠️ **为什么由扩展自己写、核心不代劳**:子开关的落盘形状归扩展所有
96
+ * (本仓是 `data.categories[id]`),核心一旦代写就得认识那个形状 ——
97
+ * 换个扩展就是换个形状,核心会变成「认识所有扩展的私有数据格式」的地方。
98
+ * 核心只负责:把 `{ controlId, on }` 递进来,并把**成功/失败**如实回给面板。
99
+ *
100
+ * @param controlId - 要改的子开关 id。
101
+ * @param on - 目标状态。
102
+ * @returns 落盘结果。返回 false 时核心回 400,且**不写盘**。
103
+ */
104
+ setControl?(controlId: string, on: boolean): boolean
105
+ /**
106
+ * 改一个子开关的**文本**(标题 / 正文),可选 —— 不实现就是不可编辑。
107
+ *
108
+ * ⚠️ **与 `setControl` 分开而不是合并**:开关是**布尔**、文本是**字符串**,
109
+ * 合成一个方法就得在里面判类型,面板那边也要问"这次改的是哪个" ——
110
+ * 那是两个独立动作、两种失败原因(未知 id / 空标题)。
111
+ *
112
+ * ⚠️ 落盘形状仍归扩展(本仓是 `data.text[id]`,与开关的 `data.categories[id]`
113
+ * **分开两个键**):开关是"开不开",文本是"写成什么样",混在一个键里
114
+ * 改标题会顺手碰到开关状态。
115
+ *
116
+ * @param controlId - 要改的子开关 id。
117
+ * @param patch - 要覆盖的字段;**未出现的字段保持原样**(部分更新)。
118
+ * @returns 落盘结果。返回 false 时核心回 400,且**不写盘**。
119
+ */
120
+ setControlText?(controlId: string, patch: { title?: string; body?: string }): boolean
121
+ /**
122
+ * 改**展示顺序**(拖动)。落盘一串 id;扩展负责过滤自己不认识的 id。
123
+ *
124
+ * ⚠️ 与前三者都不同:它改的是**列表本身**,不是某一条的内容。
125
+ */
126
+ setControlOrder?(ids: string[]): boolean
127
+ /**
128
+ * 新增一条**自建**条目,返回新 id(由扩展生成,避免前端编 id 撞内置)。
129
+ * @returns 新 id;失败(如空标题/空正文)返回 null。
130
+ */
131
+ addCustomControl?(title: string, body: string): string | null
132
+ /**
133
+ * 删一条 —— **扩展应当只允许删自建条目**(内置的删除手段是关开关)。
134
+ * @returns 落盘结果;false 时核心回 400 且不写盘。
135
+ */
136
+ removeCustomControl?(controlId: string): boolean
137
+ /**
138
+ * 还原一条的文本(丢掉覆盖,回到扩展自带的内容)。
139
+ * 自建条目**没有"自带内容"**可回退,扩展应当对它返回 false。
140
+ */
141
+ resetControlText?(controlId: string): boolean
67
142
  /** 当前运行时状态(ready + 不就绪时的说明)。 */
68
143
  getState(): ExtState
69
144
  /** 卸载清理(表删除时由 dsh-router 调用,可选)。 */
70
145
  dispose?(): void
71
146
  }
72
147
 
148
+ /**
149
+ * 一个可独立开关的子项(如准则的某一条)。
150
+ *
151
+ * ⚠️ `id` 是**持久化键**(落进 `data` 抽屉),**改名会让用户已保存的选择丢失**。
152
+ */
153
+ export interface ExtControl {
154
+ /** 稳定 id(持久化键)。 */
155
+ readonly id: string
156
+ /** 面板显示名。 */
157
+ readonly title: string
158
+ /** 当前是否开启(扩展自算:默认值 + 用户已存的选择)。 */
159
+ readonly on: boolean
160
+ /** 一句话说明这一条管什么(面板副行,可选)。 */
161
+ readonly detail?: string
162
+ /**
163
+ * 这一条的**原文**(可选)—— 面板点开即可读到。
164
+ *
165
+ * 为什么值得加:不给出原文的话,用户是在**盲切** —— 只看到「结构」「交付」
166
+ * 这样的名字,不知道它到底写了什么,也无从判断该不该关掉这一条。给出原文
167
+ * ⇒「看内容 → 决定开关」这个动作才成立。
168
+ */
169
+ readonly body?: string
170
+ /**
171
+ * 这一条的**文本可被编辑**(可选;缺省 = 只读)。
172
+ *
173
+ * ⚠️ 为什么要显式声明而不是「有 `setControlText` 就可编辑」:扩展实现了写入
174
+ * 能力但某些条目**刻意锁住**(例如标题是分类 id 的可读形式,改了会和
175
+ * `data` 里的键失去对应关系)。由扩展逐条说了算。
176
+ */
177
+ readonly editable?: boolean
178
+ /** 自建条目(可删除)。缺省 = 内置(不可删除,只有开关与还原)。 */
179
+ readonly custom?: boolean
180
+ /** 文本被用户改过(决定「还原」按钮是否可点)。 */
181
+ readonly overridden?: boolean
182
+ }
183
+
73
184
  /** 面板/API 用的一条扩展器信息 = 核心存的开关 + 插件报的运行时事实。 */
74
185
  export interface ExtInfo {
75
186
  id: string
@@ -0,0 +1,79 @@
1
+ /**
2
+ * 提示词**装配结果**的断言 —— 回答「我关掉的分类,真的没进 prompt 吗」。
3
+ *
4
+ * 为什么单独一个文件:这不是某个函数的单测,而是**跨模块的合成事实** ——
5
+ * content.ts(内容)→ render.ts(按开关拼)→ mount.ts(注册 provider)
6
+ * → ctx.systemPrompt(平台装配)→ renderPrompt(最终文本)
7
+ * 任何一环坏了,单测都绿而 prompt 里没有。
8
+ *
9
+ * ⚠️ 全部用**真 cordis + 真 dsh-system-prompt**。假 systemPrompt 会立即回调、
10
+ * 立刻返回,`assemble()` 返回什么全凭我写 —— 那就是自己给自己判分
11
+ * (2026-09-29 刚栽过:假 ctx 的 inject 立即回调,恰好把「准则没挂上」这个
12
+ * 真 bug 完美掩盖)。
13
+ */
14
+ import test from 'node:test'
15
+ import assert from 'node:assert/strict'
16
+ import { Context } from '@deepseek-ai/cordis'
17
+ import { SystemPrompt, renderPrompt, type PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
18
+ import { mountPromptSection, PROMPT_SECTION_NAME } from './mount.ts'
19
+ import { renderPromptText } from './render.ts'
20
+ import { PROMPT_CATEGORIES } from './content.ts'
21
+
22
+ /** 起一个真 systemPrompt 并挂上本扩展。 */
23
+ async function assemble(
24
+ opts: { masterOn?: boolean; data?: unknown } = {},
25
+ ): Promise<PromptAssembly> {
26
+ const ctx = new Context()
27
+ new SystemPrompt(ctx, {})
28
+ let enabled = true
29
+ const data = opts.data
30
+ const dispose = mountPromptSection({
31
+ getSystemPrompt: () => ctx.systemPrompt,
32
+ isEnabled: () => (opts.masterOn ?? enabled),
33
+ readData: () => data as never,
34
+ })
35
+ void enabled
36
+ void dispose
37
+ await new Promise((r) => setTimeout(r, 10))
38
+ return ctx.systemPrompt.assemble({})
39
+ }
40
+
41
+ test('★ 装配结果里真的有准则段落(不是「我以为我挂了」)', async () => {
42
+ const a = await assemble()
43
+ const mine = a.sections.find((s) => s.name === PROMPT_SECTION_NAME)
44
+ assert.ok(mine, `装配里没有 ${PROMPT_SECTION_NAME} 段落;现有:${a.sections.map((s) => s.name).join(', ')}`)
45
+ const text = renderPrompt(a)
46
+ // ⚠️ 断言用**正文里真有的串**,不用分类的**显示名** ——
47
+ // `懒人梯子` 只是分类的 title,它的 body 开头是「梯子:停在第一个成立的档」。
48
+ // 我原先断言 '懒人梯子',而那个串**只存在于旧标题**里 ⇒
49
+ // 2026-09-29 把标题改成 `[准则 v5]` 后,这断言红了 —— 它一直在测标题,
50
+ // 从来没测过正文。判据钉错了东西,改成钉正文里稳定的片段。
51
+ assert.ok(text.includes('停在第一个成立的档'), '最终 prompt 文本里没有准则正文')
52
+ assert.ok(text.includes('准则 v5'), '标题不在(拆分时最容易丢的就是它)')
53
+ })
54
+
55
+ test('★ 关掉某条分类 ⇒ 装配结果里那条真的不在', async () => {
56
+ const a = await assemble({ data: { categories: { structure: false } } })
57
+ const text = renderPrompt(a)
58
+ const body = PROMPT_CATEGORIES.find((c) => c.id === 'structure')!.body
59
+ assert.equal(text.includes(body), false, `「结构」关了却仍在 prompt 里:${body.slice(0, 20)}`)
60
+ // 反向:没关的必须在
61
+ const keep = PROMPT_CATEGORIES.find((c) => c.id === 'delivery')!.body
62
+ assert.ok(text.includes(keep), '没关的分类被误删了')
63
+ })
64
+
65
+ test('★ 总开关关 ⇒ 整个段落被丢弃(空文本不产出任何内容)', async () => {
66
+ const a = await assemble({ masterOn: false })
67
+ const mine = a.sections.find((s) => s.name === PROMPT_SECTION_NAME)
68
+ // 平台对空 section 的处理是「不产出文本」——断言**文本**里没有,而不是
69
+ // 「section 不存在」:section 仍注册着,只是 text 为空。
70
+ assert.equal(renderPrompt(a).includes('懒人梯子'), false, '总开关关了准则还在')
71
+ void mine
72
+ })
73
+
74
+ test('装配结果与 renderPromptText 的内容一致(渲染逻辑只有一处实现)', async () => {
75
+ const a = await assemble()
76
+ const text = renderPrompt(a)
77
+ const expected = renderPromptText(true, undefined)
78
+ assert.ok(text.includes(expected), '装配结果里找不到 renderPromptText 的输出 ⇒ 两处实现漂移了')
79
+ })