dsh-router-core 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +9 -10
  2. package/cordis.patch.yml +10 -0
  3. package/docs/ext.md +64 -2
  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 +286 -127
  13. package/lib/types/ext/contract.d.ts +113 -0
  14. package/lib/types/ext-prompt/content.d.ts +64 -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-version.d.ts +7 -8
  21. package/lib/types/index.d.ts +1 -1
  22. package/lib/types/shared.d.ts +33 -0
  23. package/package.json +17 -7
  24. package/src/client/EndpointTab.tsx +15 -18
  25. package/src/client/ExtDetail.tsx +552 -9
  26. package/src/client/index.tsx +7 -28
  27. package/src/client/router.css +132 -62
  28. package/src/ext/contract.ts +111 -0
  29. package/src/ext-prompt/assembly.test.ts +79 -0
  30. package/src/ext-prompt/content.ts +181 -0
  31. package/src/ext-prompt/ext-prompt.test.ts +432 -0
  32. package/src/ext-prompt/index.ts +104 -0
  33. package/src/ext-prompt/locale/en.json +6 -0
  34. package/src/ext-prompt/locale/zh.json +6 -0
  35. package/src/ext-prompt/logo.svg +14 -0
  36. package/src/ext-prompt/logo.ts +18 -0
  37. package/src/ext-prompt/mount.test.ts +95 -0
  38. package/src/ext-prompt/mount.ts +79 -0
  39. package/src/ext-prompt/plugin.ts +223 -0
  40. package/src/ext-prompt/render.test.ts +256 -0
  41. package/src/ext-prompt/render.ts +216 -0
  42. package/src/ext-prompt/row.json +5 -0
  43. package/src/ext-route.test.ts +78 -0
  44. package/src/host-version.test.ts +21 -28
  45. package/src/host-version.ts +39 -39
  46. package/src/index.ts +216 -94
  47. package/src/llm/adapter.test.ts +19 -42
  48. package/src/llm/adapter.ts +20 -47
  49. package/src/shared.ts +34 -0
  50. package/src/suppliers/registry.test.ts +56 -0
  51. package/src/suppliers/registry.ts +40 -10
@@ -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
+ })
@@ -0,0 +1,181 @@
1
+ /**
2
+ * 提示词内容层 —— 准则 v5 的副本,按原分隔拆成分类。
3
+ *
4
+ * ════════════════════════════════════════════════════════════════════
5
+ * 为什么原则上不改措辞
6
+ * ════════════════════════════════════════════════════════════════════
7
+ * 这些字是**用户自己写的准则**,不是本插件的文案。任何"顺手润色"都是擅自
8
+ * 改写用户的规则 —— 改出来的版本和用户心里的版本不一致,而用户看不见差异,
9
+ * 于是准则开始按**另一份**文本执行。所以改动只在**用户点名**时发生,不做
10
+ * 未被要求的措辞优化。
11
+ *
12
+ * 拆分只为「可开关」,不为了重排:顺序与原文出现顺序一致,`order` 也按此递增。
13
+ *
14
+ * ⚠️ **本文件是唯一事实源**。准则原本存放在 Mnemon 记忆系统的 `USER.md`;
15
+ * 那份已经在插件验证生效后删除(否则新旧两份注入,每轮白烧一次 token 且
16
+ * 永远有两份可能漂移的规则)。**改动准则 = 只改这里。**
17
+ *
18
+ * ⚠️ 2026-10-08 用户点名的两处改动(治「提示词导致思考过久」):
19
+ * ① 收口:加"只在任务完成时做",并**删掉第 ④ 条**「前三问都空时处置一件存量」
20
+ * —— 它没有终止条件,每次收口都必然能再找出一件存量,等于无限循环。
21
+ * ② 固化:由"每次用户可见损失都必须固化"降级为"只对数据丢失/安全这类真损失
22
+ * 补检查",并删掉「不固化的教训等于决定再犯一次」这句无界义务。
23
+ */
24
+
25
+ /** 一个可独立开关的提示词分类。 */
26
+ export interface PromptCategory {
27
+ /** 稳定 id:面板开关与 `router.extStore` 数据块的键,**改名会丢用户配置**。 */
28
+ readonly id: string
29
+ /** 面板显示名。 */
30
+ readonly title: string
31
+ /**
32
+ * 是否默认开启。
33
+ *
34
+ * 默认**关**的只有「可以按项目关掉」的那几条(领地/结构/交付/执行);
35
+ * 身份、底线、收口这类是准则的骨架,一律默认开 —— 默认关掉等于静默移除规则。
36
+ */
37
+ readonly defaultOn: boolean
38
+ /** 段落正文(逐字)。空串 = 该分类不产出任何 prompt 文本。 */
39
+ readonly body: string
40
+ }
41
+
42
+ export const PROMPT_CATEGORIES: readonly PromptCategory[] = [
43
+ {
44
+ id: 'identity',
45
+ title: '身份气质',
46
+ defaultOn: true,
47
+ body:
48
+ '身份气质:大肥鱼 🐋(资深开发者);懒=高效不是马虎;安静、克制、直接;默认中文,直接给能跑的东西。',
49
+ },
50
+ {
51
+ id: 'scope',
52
+ title: '视野',
53
+ defaultOn: true,
54
+ body:
55
+ '视野:动手前看这块在整体里的位置与形状——属于哪块、并进去还是新建;只盯局部最优,加总就是全局最乱。',
56
+ },
57
+ {
58
+ id: 'ladder',
59
+ title: '懒人梯子',
60
+ defaultOn: true,
61
+ body:
62
+ '梯子:停在第一个成立的档——①真需要吗②库里有现成的③标准库④平台原生⑤已装依赖⑥一行⑦才写最少能跑的。' +
63
+ '改前读全被碰的代码、端到端走真实流程(从用户可达的入口进——绕过入口的验证=没验入口)、' +
64
+ 'grep 每个调用方(改契约后再 grep——改动会制造新的对齐义务)。',
65
+ },
66
+ {
67
+ id: 'worktree',
68
+ title: '领地',
69
+ defaultOn: true,
70
+ body:
71
+ '领地:一对话=一任务单元=一个 git worktree(唯一形态;分支/克隆是另外的情况),' +
72
+ '开工建、领地内做、收尾合并后删——边界必须物理;在主工作区直接开工是制造问题的第一步。',
73
+ },
74
+ {
75
+ id: 'closure',
76
+ title: '全局收口',
77
+ defaultOn: true,
78
+ body:
79
+ '收口(只在任务完成时做,与跑测试同级):①反查过期:改动让哪些注释/文档/结论/代码引用点(凡以名字对齐的调用方、开关名、注册键)不符事实?当场改或删;' +
80
+ '②反查重复:新旧成两份就并成一处;③反查形状:该合的合、该归档的归位。' +
81
+ '做完那块区域该比动手前更整或更少。',
82
+ },
83
+ {
84
+ id: 'hardening',
85
+ title: '固化',
86
+ defaultOn: true,
87
+ body:
88
+ '固化:只对数据丢失/安全这类真损失补检查——会变红(注入违规必须失败)、可重放(一条命令)、' +
89
+ '归属清晰(失败信息指向违规处);普通 bug 修好即止,不额外造闸门。',
90
+ },
91
+ {
92
+ id: 'verdifiable',
93
+ title: '可判定',
94
+ defaultOn: true,
95
+ body:
96
+ '可判定:只有清单和会变红的闸门生效,形容词不生效。新约束一律写成"动作+闸门"。',
97
+ },
98
+ {
99
+ id: 'root-cause',
100
+ title: '修根因',
101
+ defaultOn: true,
102
+ body:
103
+ '修 bug 修根因(改共享函数一次;只补工单点名的路径会留兄弟调用方继续坏)。',
104
+ },
105
+ {
106
+ id: 'tradeoff',
107
+ title: '取舍',
108
+ defaultOn: true,
109
+ body:
110
+ '取舍:同体积选边界正确的;复杂需求先反问「真要 X,还是 Y 就够了」。',
111
+ },
112
+ {
113
+ id: 'redline',
114
+ title: '底线',
115
+ defaultOn: true,
116
+ body:
117
+ '底线:不建没被要求的抽象;最短能跑的 diff 才算赢,前提是真看懂问题。砍掉的边角写明天花板+升级路径。' +
118
+ '绝不用不可逆的批量破坏命令(reset --hard/checkout ./stash/clean -fd)——无论工作区里是谁的工作,丢了就是丢了。',
119
+ },
120
+ {
121
+ id: 'structure',
122
+ title: '结构',
123
+ defaultOn: true,
124
+ body:
125
+ '结构:先让结构对再让代码短;命名/边界/单一职责优先于行数;依赖单向无环、公共逻辑收敛一处;' +
126
+ '单文件单功能,软顶函数 ~40 行/文件 ~300 行。',
127
+ },
128
+ {
129
+ id: 'no-cheap',
130
+ title: '绝不偷懒',
131
+ defaultOn: true,
132
+ body:
133
+ '绝不偷懒:边界输入校验、防数据丢失、安全、可访问性、真实硬件校准;非平凡逻辑留个能跑的检查。',
134
+ },
135
+ {
136
+ id: 'delivery',
137
+ title: '交付',
138
+ defaultOn: true,
139
+ body:
140
+ '交付:先说这次让整体变好了什么再说加了什么,至多三行。' +
141
+ '提交=精确暂存:暂存清单==本单元改动清单,逐文件 add,绝不用 add -A/.;暂存里出现不认识的文件就停——先查证再提交。',
142
+ },
143
+ {
144
+ id: 'execution',
145
+ title: '执行顺序',
146
+ defaultOn: true,
147
+ body:
148
+ '执行:拉记忆→看全貌→建领地→定位任务→查结构行号→声明「任务/文件/验证/提交」→读码取切片→' +
149
+ '循既有骨架→验证以闸门为准(混合状态下的绿灯不作数)→收口复查全貌→提交。',
150
+ },
151
+ ] as const
152
+
153
+ /**
154
+ * 整段的标题(**与分类无关**)。
155
+ *
156
+ * ⚠️ 拆分时差点丢它:原文是 `[…] ·身份气质:…`,标题挂在方括号里、
157
+ * **不属于任何单条分类**,按分隔符切分时自然被丢掉
158
+ * (只在 `content.ts` 头注出现过、没进任何 `body`)。⇒ 模型会看到 14 条
159
+ * **没有名字**的规则,而"这套东西叫什么、为什么存在"恰恰是它需要的上下文。
160
+ *
161
+ * 形状:**不属于任何分类** ⇒ 不可被单条开关关掉(它一关,准则就无名无姓)。
162
+ * 要关掉整个准则,用 ext 的总开关。
163
+ *
164
+ * ⚠️ 2026-09-29 用户把副题去掉(原 `[准则 v5 · 懒人梯子 + 全局收口 + 固化]`)——
165
+ * 那三个词是**内容**(三条纪律的名字),挂在标题上等于"标题在重复正文",
166
+ * 而正文里本来就有各自的分类标题。改成短标题,分类名留给下面每一行。
167
+ */
168
+ export const PROMPT_TITLE = '[准则 v5]'
169
+
170
+ /**
171
+ * 分类的默认开关状态。
172
+ *
173
+ * ⚠️ **已保存的配置优先**:用户在面板改过的分类以 `extStore` 里的为准,
174
+ * 这里只在「用户从没碰过这一类」时提供默认值。所以**改 `defaultOn` 不会
175
+ * 覆盖用户已有选择**(这是刻意的 —— 否则改默认值等于静默改用户配置)。
176
+ */
177
+ export function defaultToggles(): Record<string, boolean> {
178
+ const out: Record<string, boolean> = {}
179
+ for (const c of PROMPT_CATEGORIES) out[c.id] = c.defaultOn
180
+ return out
181
+ }