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
@@ -61,11 +61,124 @@ export interface RouterExt {
61
61
  * 再在下面列一遍就是同一个东西显示两处。
62
62
  */
63
63
  readonly source?: 'builtin';
64
+ /**
65
+ * 扩展自带的**子开关**(可选;不声明 = 只有总开关)。
66
+ *
67
+ * 为什么需要:有些扩展的「开/关」不是一件事而是**几件独立的事** —— 例如分层
68
+ * 提示词有 14 个准则分类,每条都该能单独关。硬塞进一个总开关 = 要么全给、
69
+ * 要么全不给。
70
+ *
71
+ * ⚠️ **与总开关的关系**:子开关是**叠加**在总开关之上的,总开关关 ⇒ 全部子开关
72
+ * 一起失效(但**不清零**用户的子开关选择)。详情页在总开关关时把它们显示为
73
+ * 禁用而不是隐藏 —— 隐藏会让用户以为它们不存在。
74
+ *
75
+ * ⚠️ **值存哪**:核心**不认识**这些子开关的语义,只负责搬运。落盘一律走
76
+ * `ExtStoreService.writeData`(`data` 抽屉),由扩展自己读回。核心不替扩展
77
+ * 存一份 —— 那样会变成两份可能漂移的真相。
78
+ */
79
+ /**
80
+ * 扩展自报的**子开关**列表。
81
+ *
82
+ * ⚠️ **实现方应当每次读都重算**(getter),而不是在构造时算好一个数组快照:
83
+ * 核心每次 `GET /ext` 都读它,面板也靠它刷新。若它是快照,改完文本再刷新
84
+ * 面板拿到的仍是**旧值** —— 用户会看到"保存了、又变回去了"
85
+ * (实测踩过:2026-09-29 第一版用 `CATEGORIES.map(...)`,编辑生效但列表
86
+ * 不跟着变,而注释里还写着"现算不缓存"——**注释与事实不符**,比没写更坏)。
87
+ */
88
+ readonly controls?: ExtControl[];
89
+ /**
90
+ * 改一个子开关(面板调用;可选 —— 没有 `controls` 就不需要)。
91
+ *
92
+ * ⚠️ **为什么由扩展自己写、核心不代劳**:子开关的落盘形状归扩展所有
93
+ * (本仓是 `data.categories[id]`),核心一旦代写就得认识那个形状 ——
94
+ * 换个扩展就是换个形状,核心会变成「认识所有扩展的私有数据格式」的地方。
95
+ * 核心只负责:把 `{ controlId, on }` 递进来,并把**成功/失败**如实回给面板。
96
+ *
97
+ * @param controlId - 要改的子开关 id。
98
+ * @param on - 目标状态。
99
+ * @returns 落盘结果。返回 false 时核心回 400,且**不写盘**。
100
+ */
101
+ setControl?(controlId: string, on: boolean): boolean;
102
+ /**
103
+ * 改一个子开关的**文本**(标题 / 正文),可选 —— 不实现就是不可编辑。
104
+ *
105
+ * ⚠️ **与 `setControl` 分开而不是合并**:开关是**布尔**、文本是**字符串**,
106
+ * 合成一个方法就得在里面判类型,面板那边也要问"这次改的是哪个" ——
107
+ * 那是两个独立动作、两种失败原因(未知 id / 空标题)。
108
+ *
109
+ * ⚠️ 落盘形状仍归扩展(本仓是 `data.text[id]`,与开关的 `data.categories[id]`
110
+ * **分开两个键**):开关是"开不开",文本是"写成什么样",混在一个键里
111
+ * 改标题会顺手碰到开关状态。
112
+ *
113
+ * @param controlId - 要改的子开关 id。
114
+ * @param patch - 要覆盖的字段;**未出现的字段保持原样**(部分更新)。
115
+ * @returns 落盘结果。返回 false 时核心回 400,且**不写盘**。
116
+ */
117
+ setControlText?(controlId: string, patch: {
118
+ title?: string;
119
+ body?: string;
120
+ }): 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;
64
142
  /** 当前运行时状态(ready + 不就绪时的说明)。 */
65
143
  getState(): ExtState;
66
144
  /** 卸载清理(表删除时由 dsh-router 调用,可选)。 */
67
145
  dispose?(): void;
68
146
  }
147
+ /**
148
+ * 一个可独立开关的子项(如准则的某一条)。
149
+ *
150
+ * ⚠️ `id` 是**持久化键**(落进 `data` 抽屉),**改名会让用户已保存的选择丢失**。
151
+ */
152
+ export interface ExtControl {
153
+ /** 稳定 id(持久化键)。 */
154
+ readonly id: string;
155
+ /** 面板显示名。 */
156
+ readonly title: string;
157
+ /** 当前是否开启(扩展自算:默认值 + 用户已存的选择)。 */
158
+ readonly on: boolean;
159
+ /** 一句话说明这一条管什么(面板副行,可选)。 */
160
+ readonly detail?: string;
161
+ /**
162
+ * 这一条的**原文**(可选)—— 面板点开即可读到。
163
+ *
164
+ * 为什么值得加:不给出原文的话,用户是在**盲切** —— 只看到「结构」「交付」
165
+ * 这样的名字,不知道它到底写了什么,也无从判断该不该关掉这一条。给出原文
166
+ * ⇒「看内容 → 决定开关」这个动作才成立。
167
+ */
168
+ readonly body?: string;
169
+ /**
170
+ * 这一条的**文本可被编辑**(可选;缺省 = 只读)。
171
+ *
172
+ * ⚠️ 为什么要显式声明而不是「有 `setControlText` 就可编辑」:扩展实现了写入
173
+ * 能力但某些条目**刻意锁住**(例如标题是分类 id 的可读形式,改了会和
174
+ * `data` 里的键失去对应关系)。由扩展逐条说了算。
175
+ */
176
+ readonly editable?: boolean;
177
+ /** 自建条目(可删除)。缺省 = 内置(不可删除,只有开关与还原)。 */
178
+ readonly custom?: boolean;
179
+ /** 文本被用户改过(决定「还原」按钮是否可点)。 */
180
+ readonly overridden?: boolean;
181
+ }
69
182
  /** 面板/API 用的一条扩展器信息 = 核心存的开关 + 插件报的运行时事实。 */
70
183
  export interface ExtInfo {
71
184
  id: string;
@@ -0,0 +1,64 @@
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
+ export interface PromptCategory {
26
+ /** 稳定 id:面板开关与 `router.extStore` 数据块的键,**改名会丢用户配置**。 */
27
+ readonly id: string;
28
+ /** 面板显示名。 */
29
+ readonly title: string;
30
+ /**
31
+ * 是否默认开启。
32
+ *
33
+ * 默认**关**的只有「可以按项目关掉」的那几条(领地/结构/交付/执行);
34
+ * 身份、底线、收口这类是准则的骨架,一律默认开 —— 默认关掉等于静默移除规则。
35
+ */
36
+ readonly defaultOn: boolean;
37
+ /** 段落正文(逐字)。空串 = 该分类不产出任何 prompt 文本。 */
38
+ readonly body: string;
39
+ }
40
+ export declare const PROMPT_CATEGORIES: readonly PromptCategory[];
41
+ /**
42
+ * 整段的标题(**与分类无关**)。
43
+ *
44
+ * ⚠️ 拆分时差点丢它:原文是 `[…] ·身份气质:…`,标题挂在方括号里、
45
+ * **不属于任何单条分类**,按分隔符切分时自然被丢掉
46
+ * (只在 `content.ts` 头注出现过、没进任何 `body`)。⇒ 模型会看到 14 条
47
+ * **没有名字**的规则,而"这套东西叫什么、为什么存在"恰恰是它需要的上下文。
48
+ *
49
+ * 形状:**不属于任何分类** ⇒ 不可被单条开关关掉(它一关,准则就无名无姓)。
50
+ * 要关掉整个准则,用 ext 的总开关。
51
+ *
52
+ * ⚠️ 2026-09-29 用户把副题去掉(原 `[准则 v5 · 懒人梯子 + 全局收口 + 固化]`)——
53
+ * 那三个词是**内容**(三条纪律的名字),挂在标题上等于"标题在重复正文",
54
+ * 而正文里本来就有各自的分类标题。改成短标题,分类名留给下面每一行。
55
+ */
56
+ export declare const PROMPT_TITLE = "[\u51C6\u5219 v5]";
57
+ /**
58
+ * 分类的默认开关状态。
59
+ *
60
+ * ⚠️ **已保存的配置优先**:用户在面板改过的分类以 `extStore` 里的为准,
61
+ * 这里只在「用户从没碰过这一类」时提供默认值。所以**改 `defaultOn` 不会
62
+ * 覆盖用户已有选择**(这是刻意的 —— 否则改默认值等于静默改用户配置)。
63
+ */
64
+ export declare function defaultToggles(): Record<string, boolean>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 分层提示词扩展的 **cordis 行** —— 官方插件页(设置 → 插件 → dsh-router-core)详情页
3
+ * 原生「包含的组件」里的那一行。
4
+ *
5
+ * 壳的形状与 `ext-test` 同款(cordis 身份 + 登记,实现 = `plugin.ts`),
6
+ * 但**多一件执行面的事**:挂 system prompt 段落。ext-test 不做拦截、只登记;
7
+ * 本扩展要把准则真注入,所以额外等 `systemPrompt` service 就绪后挂 section。
8
+ *
9
+ * **默认开启**(patch 里不带 `disabled`,与三行内置供应商一致):
10
+ * 协作准则是每次协作都要的基础设施,不是可选增强。与 `ext-test` 的
11
+ * `disabled: true` **刻意不同** —— 自检是「想跑才跑」的工具,准则不是;
12
+ * 让准则默认缺席,会变成「用户不知道它存在 ⇒ 协作按另一套规则执行」。
13
+ *
14
+ * 登记时把扩展开关置为开:本扩展**没有独立的运行期开关**,它的开关就是这一行
15
+ * (宿主管的那个)。不置上的话,用户开了行还要再点一次,两级开关说同一件事。
16
+ */
17
+ import type { Context } from '@deepseek-ai/cordis';
18
+ /** cordis 插件身份(配置树里这一行的技术名;显示名走同目录的 locale)。 */
19
+ export declare const name = "dsh-router-ext-prompt";
20
+ /**
21
+ * 声明本扩展依赖的 service。
22
+ *
23
+ * ⚠️⚠️ **必须导出这个数组,且 `apply` 里直接用 `ctx.systemPrompt`**(2026-09-29 修)
24
+ * ——我原先写成「在 apply 里 `ctx.inject(['systemPrompt'], cb)`」,**准则一直不进
25
+ * prompt**:面板上卡片、开关、原文全都正常(`GET /router/api/ext` 实测有 14 条
26
+ * controls),但模型看到的 system prompt 里没有它。
27
+ *
28
+ * 根因(本机 cordis 4.0.4 实测):`ctx.inject(deps, cb)` 的回调**不是同步触发** ——
29
+ * provide 之后立刻 `inject`,回调要等到下一个异步点才跑。插件的 `apply` 是**同步
30
+ * 函数**,返回时那次回调还没发生;而真实加载路径不保证之后还有机会去等它,
31
+ * 于是 `mountPromptSection` 从没被执行 ⇒ **准则一次都没进过 prompt**。
32
+ *
33
+ * ⚠️ 我第一版把这个机制写成「要等 `ctx.start()`」——**那是错的**:`start` 根本不在
34
+ * Context 的公开面上(原型只有 extend/isolate/intercept)。准确说法是「下一个
35
+ * 异步点」,具体机制随版本变。**结论不依赖这个细节**,只依赖「apply 里手动
36
+ * inject 不可靠」这一事实。
37
+ *
38
+ * 正确形状(对照 `dsh-client-ui-deliverables` 等真实使用者):导出 `inject` 数组,
39
+ * cordis 看到它就**把整个 apply 推迟到这些 service 就绪之后**再调用 ——
40
+ * 于是 `ctx.systemPrompt` 一定是活的,`section()` 可以直接调。
41
+ *
42
+ * ⚠️ `router.ext` 也要列:那一半(面板登记)此前"碰巧"能用,是因为
43
+ * `dsh-router` 核心先 provide 了它、`apply` 又跑在它之后。**那是时序上的运气,
44
+ * 不是保证** —— 一并列进 `inject`,两种 service 都由 cordis 编排。
45
+ */
46
+ export declare const inject: string[];
47
+ /**
48
+ * 登记本扩展**并**挂载准则段落。
49
+ *
50
+ * 此刻 `ctx.systemPrompt` / `ctx.router.ext` / `ctx.router.extStore` **必定已就绪**
51
+ * (见上面 `inject` 的注释),所以下面**直接用**,不再做二次判空。
52
+ */
53
+ export declare function apply(ctx: Context): void;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 分层提示词扩展的图标 —— **一张深色圆角瓦片**,以本地 base64 内联(离线可用)。
3
+ *
4
+ * 为什么这样画:它做的事是「一份可以逐条开关的规则清单」⇒ 图形 = 清单 + 勾选。
5
+ * 不用齿轮(那是设置)、不用扳手(那是工具)—— 那两样也在同一个面板里,
6
+ * 混用会让人分不清「改行为」和「改这套规则本身」。
7
+ *
8
+ * ⚠️ **单色 + 深底**(与 RTK logo 同款处理):`.dshr-providerIcon` 只给圆角与
9
+ * 尺寸、**不给底色**,所以底色必须由本图自带 ⇒ 深浅两套主题下都成立。
10
+ * 勾选用略暖的绿(`#A7C0A5`)与清单的灰白拉开,暗示"这些可以勾掉"。
11
+ *
12
+ * ⚠️ **base64 必须由 `logo.svg` 生成**(`base64 -i logo.svg | tr -d '\n'`)。
13
+ * 我第一版**手编**了一串 base64,解出来是乱码(`tept-Gradient`、`#17118=D`)——
14
+ * 而客户端的 `onError` 会把加载失败的图**静默藏掉**、退回默认闪电图标,
15
+ * **看起来"正常"、实际图标根本没换**。编造的常量不会报错,这是它最险的地方。
16
+ */
17
+ export declare const PROMPT_LOGO_URL = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTYiIGZpbGw9IiMxNzE4MUQiLz4KICA8ZyBmaWxsPSJub25lIiBzdHJva2U9IiNFREVERUIiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj4KICAgIDxwYXRoIGQ9Ik0xOCAxNmgyMCIvPgogICAgPHBhdGggZD0iTTE4IDI3aDI0Ii8+CiAgICA8cGF0aCBkPSJNMTggMzhoMjQiLz4KICAgIDxwYXRoIGQ9Ik0xOCA0OWgyNCIvPgogIDwvZz4KICA8ZyBmaWxsPSJub25lIiBzdHJva2U9IiNBN0MwQTUiIHN0cm9rZS13aWR0aD0iMy40IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPgogICAgPHBhdGggZD0iTTkgMTYuNWwyLjUgMi41TDE2IDE0LjUiLz4KICAgIDxwYXRoIGQ9Ik05IDI3LjVsMi41IDIuNUwxNiAyNS41Ii8+CiAgICA8cGF0aCBkPSJNOSAzOC41bDIuNSAyLjVMMTYgMzYuNSIvPgogIDwvZz4KPC9zdmc+Cg==";
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 挂载层 —— 把准则注册成 `ctx.systemPrompt` 的**一个**段落。
3
+ *
4
+ * ════════════════════════════════════════════════════════════════════
5
+ * 为什么只注册一段,而不是 14 段
6
+ * ════════════════════════════════════════════════════════════════════
7
+ * 面板开关的粒度是「分类」,但 prompt 读起来应该是一段连续的准则。分成 14 段
8
+ * 会让 KV cache 每个分类各占一段,且**增删任意一个分类都让它之后的全部分位**。
9
+ * 一段 + 拼装 ⇒ 分类增删只改这一段内部,段的位置与前缀稳定。
10
+ *
11
+ * ⚠️ **空串即关闭,这是平台原生语义**(`renderPrompt` 的注释原文:
12
+ * "drop empty sections")⇒ "可开关"不需要任何自造机制:本函数返回 `''`,
13
+ * 上游就把整段丢掉。**不要自己实现"删段落"的逻辑**,那会和平台重复且会漂移。
14
+ *
15
+ * ⚠️ 文本用 provider 函数(`(context) => string`)而不是静态字符串:
16
+ * 分类开关随时可改,静态文本**注册那一刻就定死了**,改开关不生效。
17
+ */
18
+ import { type PromptExtData } from './render.ts';
19
+ /** `ctx.systemPrompt` 的最小可见面。 */
20
+ export interface SystemPromptFace {
21
+ section(section: {
22
+ name: string;
23
+ order: number;
24
+ text: string | (() => string);
25
+ }): () => void;
26
+ getSectionOrder?(name: string): number;
27
+ }
28
+ /** 段落名(**稳定**):改名会让已注册的作用域 section 与旧名并存。 */
29
+ export declare const PROMPT_SECTION_NAME = "dsh-router-ext-prompt:guideline";
30
+ export interface MountPromptDeps {
31
+ /** 取 `ctx.systemPrompt`(可能尚未就绪 ⇒ undefined)。 */
32
+ getSystemPrompt: () => SystemPromptFace | undefined;
33
+ /** 总开关(问核心 `router.extStore`)。 */
34
+ isEnabled: () => boolean;
35
+ /**
36
+ * 读本扩展的数据块(分类开关)。
37
+ *
38
+ * 落盘 JSON 不可信 ⇒ 返回值**不保证**是合法 `PromptExtData`,
39
+ * `renderPromptText` 负责容错(见 render.test.ts 的注入用例)。
40
+ */
41
+ readData: () => PromptExtData | undefined;
42
+ }
43
+ /**
44
+ * 挂载准则段落。返回卸载函数。
45
+ *
46
+ * ⚠️ `systemPrompt` 尚不可用时**不挂**(返回 no-op 清理),并由 `ext.getState()`
47
+ * 报 `ready: false` 让面板红字显示。**不做轮询重试** —— 显式"没就绪"好过
48
+ * 半开的注入:后者会让准则随机时有时无,而用户看不出为什么。
49
+ * `index.ts` 用 `ctx.inject(['systemPrompt'])` 保证挂载时机,本函数只管挂。
50
+ */
51
+ export declare function mountPromptSection(deps: MountPromptDeps): () => void;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 分层提示词扩展(`router.ext` 侧的实现)—— 声明与状态都在这里。
3
+ *
4
+ * 与 `ext-test`(插件自检)同款:**不挂任何监听、不改写任何东西**,
5
+ * 唯一作用是让用户在官方插件页里看到一张卡片、能开关它,并把协作准则
6
+ * 注册成一段 system prompt。
7
+ *
8
+ * 与 `rtk` / `jev` 那类扩展的区别要写清楚:那两家挂 `tools/execute`
9
+ * 改写**工具调用**;本扩展只往 **system prompt** 加一段。名字同族是因为
10
+ * 都在 `router.ext` 这张表里,拦截面不重叠。
11
+ *
12
+ * 复用 `router.ext` 通道只为拿到**发现 + 开关**两件事(见 ext-test 的同款说明),
13
+ * 真正的注入在 `mount.ts`。
14
+ */
15
+ import type { ExtStoreService, RouterExt } from '../ext/contract.ts';
16
+ /** 扩展开关表里的注册键。 */
17
+ export declare const EXT_PROMPT_ID = "prompt";
18
+ /**
19
+ * 构造这个扩展器。
20
+ *
21
+ * `getState()` 报**唯一的真实就绪条件**:`ctx.systemPrompt` 不可用时无处注入
22
+ * (section 是唯一的注入通道),此时必须红字报出,而不是静默不生效 ——
23
+ * 判据能变红比"看起来一直开着"有用。
24
+ *
25
+ * @param deps.store - `router.extStore`(读写分类开关的落盘数据块)。缺省时
26
+ * `controls` 报全部默认、`setControl` 恒 false —— 那样面板显示可点但点了
27
+ * 没反应,**比不显示更坏**。
28
+ */
29
+ export declare function createPromptExt(deps: {
30
+ isSystemPromptReady: () => boolean;
31
+ store?: ExtStoreService;
32
+ }): RouterExt;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * 纯逻辑层 —— 分类开关的读取、校验与 prompt 文本渲染。
3
+ *
4
+ * 为什么单独成文件:**不 import cordis / 任何 DSH 包**,所以能被 `node --test`
5
+ * 直接跑。DSH 侧的注册/挂载(ext.ts / inject.ts)只是薄壳;所有会出错的判断
6
+ * 都在这里,且这里能注入违规用例证明它们会变红。
7
+ */
8
+ import { type PromptCategory } from './content.ts';
9
+ /** 落盘在 `router.extStore` 数据块里的形状。 */
10
+ export interface PromptExtData {
11
+ /** 分类开关:`{ [categoryId]: boolean }`。缺项走 `defaultOn`。 */
12
+ categories?: Record<string, boolean>;
13
+ /**
14
+ * 用户在面板里改过的文本:`{ [categoryId]: { title?, body? } }`。
15
+ *
16
+ * ⚠️ **与 `categories` 分开两个键**:开关是"开不开"、文本是"写成什么样"。
17
+ * 挤在一个键里,改标题会顺手碰到开关状态(`writeData` 是**整块替换**,
18
+ * 少一个键就是少一份数据)。
19
+ *
20
+ * ⚠️ **只存覆盖值,不存全文**:没改过的分类不出现在这里 ⇒ 内置内容仍是唯一
21
+ * 事实源,升级内置文案时没改过的那几条会跟着更新。存全文 = 用户改一个字
22
+ * 就永久冻结了整段,之后内置更新对它们完全失效。
23
+ */
24
+ text?: Record<string, PromptCategoryOverride>;
25
+ /**
26
+ * 用户自建的准则条目。**与内置的 `PROMPT_CATEGORIES` 分开存** ——
27
+ * 内置那 14 条是**代码常量**(发版即定),混在一起就会出现"代码里有一条、
28
+ * 落盘里又有一条"的归属问题:升级时新加的内置条目会和用户的旧数据打架。
29
+ *
30
+ * 存的是**完整条目**(不是覆盖值):自建的东西没有"内置版本"可回退。
31
+ */
32
+ custom?: PromptCategory[];
33
+ /**
34
+ * 展示顺序:`[id, …]`,含内置与自建。
35
+ *
36
+ * ⚠️ **只存 id 顺序,不重排内置数组本身**:内置 14 条的次序是 `content.ts`
37
+ * 里的代码顺序,它同时是"准则作为一份文档的阅读顺序";让用户拖动去改它,
38
+ * 等于让代码顺序随某个用户的操作漂移 —— 别人拿到新版本看到的次序就变了。
39
+ * 落盘只记"这次会话里按什么顺序显示",代码顺序永远是兜底。
40
+ */
41
+ order?: string[];
42
+ }
43
+ /** 一条分类上用户改过的字段(只记改了的那些)。 */
44
+ export interface PromptCategoryOverride {
45
+ title?: string;
46
+ body?: string;
47
+ }
48
+ /** 解出某分类**生效用**的文本:用户覆盖优先,否则用内置的。 */
49
+ export declare function effectiveText(id: string, override: PromptCategoryOverride | undefined, base: PromptCategory): {
50
+ title: string;
51
+ body: string;
52
+ };
53
+ /** 分类 id 的全集(校验用)。 */
54
+ export declare function knownCategoryIds(): string[];
55
+ /**
56
+ * 解出「哪些分类要渲染」。
57
+ *
58
+ * 三层语义,**顺序不可换**:
59
+ * ① 总开关关 ⇒ 一条都不渲染(不是"渲染但没内容"——那样面板显示已开启、实际无效果);
60
+ * ② 数据块里显式写了的分类,用**用户存的值**(含显式 `false`);
61
+ * ③ 没写过的分类,用 `defaultOn`。
62
+ *
63
+ * ⚠️ 为什么 ② 要区别于「没写」:`false` 与 `undefined` 语义完全不同 ——
64
+ * 前者是用户主动关掉,后者是"没碰过"。把两者当同一件事,用户第一次关掉某分类
65
+ * 后升级插件、新增同名分类,那条就永远是关的(用户没要求过)。
66
+ */
67
+ export declare function resolveEnabledCategories(masterOn: boolean, data: PromptExtData | undefined): Set<string>;
68
+ /**
69
+ * 渲染成**一个**段落文本(多分类拼一起)。
70
+ *
71
+ * 为什么要拼成一段而不是一段一个:面板开关是"分类"粒度,而 prompt 读起来
72
+ * 是一段连续的准则 —— 分成 14 个 section 会让 KV cache 每个分类各占一段、
73
+ * 增删任意一个分类都让其后全部位移。这里**注册一个 section,文本由开关拼装**。
74
+ *
75
+ * @returns 关闭的分类不进结果;全关 ⇒ 空串(上游 `renderPrompt` 会丢弃空段)。
76
+ */
77
+ export declare function renderCategories(enabled: ReadonlySet<string>, categories?: readonly PromptCategory[], overrides?: PromptExtData['text'],
78
+ /** 有没有加整段标题。默认 true;传**外部分类表**时传 false(见下方注释)。 */
79
+ withTitle?: boolean): string;
80
+ /**
81
+ * 渲染当前应注入的完整 prompt 文本(总开关 + 分类开关都算进去)。
82
+ *
83
+ * 总开关关 ⇒ 空串,让上游 `renderPrompt` 丢弃该段 —— **不是**注入一段空白。
84
+ */
85
+ export declare function renderPromptText(masterOn: boolean, data: PromptExtData | undefined): string;
86
+ /**
87
+ * 合成后的**一条**准则条目:内置或自建,已应用标题/正文覆盖。
88
+ * `builtin` 用来决定按钮(内置没有「删除」,只有「还原」)。
89
+ */
90
+ export interface ResolvedCategory extends PromptCategory {
91
+ /** 自建(用户加的)——可删除;内置的不可删除。 */
92
+ readonly custom: boolean;
93
+ /** 文本被用户改过(决定「还原」按钮是否可点)。 */
94
+ readonly overridden: boolean;
95
+ }
96
+ /**
97
+ * 内置 + 自建 + 排序 ⇒ 有序的最终条目列表。
98
+ *
99
+ * ⚠️ 三条规则(顺序不可换):
100
+ * 1. **内置在前、自建在后**,除非 `order` 显式给了别的次序。代码顺序是
101
+ * "准则作为一份文档的阅读顺序",不该被用户拖动改掉(见 `PromptExtData.order`)。
102
+ * 2. `order` 里**不认识的 id 一律忽略**,落盘不可信(手改的 ext.json、上一版的残渣)。
103
+ * 3. `order` **没提到的条目按原序补在后面** —— 丢了某条 ≠ 它消失了。
104
+ */
105
+ export declare function resolveCategories(data: PromptExtData | undefined): ResolvedCategory[];
@@ -1,15 +1,14 @@
1
1
  /**
2
2
  * 探测 profile 里安装的 DSH 宿主版本。
3
3
  * @param baseUrl 宿主注入的 profile 锚点(`ctx.baseUrl`)
4
- * @returns 形如 `0.1.7-alpha.2`;读不到返回 undefined(调用方按「不支持」处理)
4
+ * @returns 形如 `0.2.0-rc.1`;读不到返回 undefined
5
5
  */
6
6
  export declare function detectHostVersion(baseUrl?: string): string | undefined;
7
7
  /**
8
- * 宿主是否 >= 0.1.7。三处兼容分叉共用这一个判据:
9
- * - 「最近命中」徽章(composer.dock 位置正确)
10
- * - 设置命名空间(Config 驱动而非 installSection)
11
- * - adapter 的独立 role:'tool' 消息形态
8
+ * 宿主是否在本插件支持的地板之上(>= 0.2.0)。
9
+ *
10
+ * **只用于报警,不用于分流** —— 低于地板时插件照常加载,但要在日志里说清「这台宿主
11
+ * 不再兼容、会出现什么症状」,把静默的错变成可查的错。读不到版本 → false(当作
12
+ * 「无法确认」,同样值得说一句)。
12
13
  */
13
- export declare function isHost017Plus(baseUrl?: string): boolean;
14
- /** 「最近命中」徽章是否可用(= 宿主 >= 0.1.7)。 */
15
- export declare function supportsLastHitDock(baseUrl?: string): boolean;
14
+ export declare function isHostSupported(baseUrl?: string): boolean;
@@ -6,7 +6,7 @@ export declare const name = "dsh-router-core";
6
6
  /** Services required before mounting: the webserver (routes) + llm (设置-模型). */
7
7
  export declare const inject: string[];
8
8
  /**
9
- * 0.1.7 的设置镜像只认「插件导出的 Config + 配置树行 id」这条收录链,且空 object
9
+ * 设置镜像只认「插件导出的 Config + 配置树行 id」这条收录链,且空 object
10
10
  * 会被 volatileForm 过滤掉 —— 顶层 `.volatile()` 才能让 Router 卡片出现在设置-模型。
11
11
  * Router 的真配置在自己的 state.json(路由系统面板),这里故意不暴露任何字段。
12
12
  */
@@ -158,6 +158,39 @@ export interface RouterExtItem {
158
158
  * 「包含的组件」里占一行了,两处都列就是同一个东西显示两遍(与内置供应商同理)。
159
159
  */
160
160
  source?: 'builtin';
161
+ /**
162
+ * 扩展自带的子开关(如准则的每一条)。**由扩展自报**,核心只搬运。
163
+ *
164
+ * ⚠️ 与 `enabled`(总开关)不同:子开关**不改变扩展是否被加载**,只改它
165
+ * 自己的行为。总开关关时这些子开关一并失效(面板上禁用,不隐藏)。
166
+ */
167
+ controls?: ExtControlItem[];
168
+ }
169
+ /** `/router/api/ext` 里的一条子开关(与 `router.ext` 契约的 `ExtControl` 对应)。 */
170
+ export interface ExtControlItem {
171
+ /** 稳定 id(持久化键;改名会丢用户已保存的选择)。 */
172
+ id: string;
173
+ title: string;
174
+ /** 当前是否开启(扩展自算:默认值 + 用户已存选择)。 */
175
+ on: boolean;
176
+ /** 一句话说明(面板副行,可选)。 */
177
+ detail?: string;
178
+ /** 原文(面板点开即可读到;不给出就是在盲切,见 `ExtControl.body`)。 */
179
+ body?: string;
180
+ /** 文本可编辑(缺省 = 只读;见 `ExtControl.editable`)。 */
181
+ editable?: boolean;
182
+ /**
183
+ * 自建条目(可删)还是内置(不可删、只有开关与还原)。
184
+ *
185
+ * ⚠️ **必填,不给可选**(2026-09-30 实测踩过):曾声明成 `custom?: boolean`,
186
+ * 而服务端只在 `custom === true` 时才带这个字段 ⇒ 内置条目拿到的是
187
+ * `undefined`,面板判 `c.custom === true` 为假,**恰好是对的** ——
188
+ * 但那条正确性建立在"缺省恰好等于 false"上,改任何一边的默认值都会翻转。
189
+ * ⇒ 让服务端**总是**给显式布尔,两边不必猜。
190
+ */
191
+ custom: boolean;
192
+ /** 文本被改过(决定「还原」是否可点)。必填,理由同 `custom`。 */
193
+ overridden: boolean;
161
194
  }
162
195
  /** `/router/api/ext` response(扩展插件列表 + 开关)。 */
163
196
  export interface RouterExtResponse {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -42,6 +42,12 @@
42
42
  },
43
43
  "./ext-test/package.json": "./lib/ext-test/package.json",
44
44
  "./ext-test/locale/*.json": "./lib/ext-test/locale/*.json",
45
+ "./ext-prompt": {
46
+ "types": "./lib/types/ext-prompt/index.d.ts",
47
+ "default": "./lib/ext-prompt/index.js"
48
+ },
49
+ "./ext-prompt/package.json": "./lib/ext-prompt/package.json",
50
+ "./ext-prompt/locale/*.json": "./lib/ext-prompt/locale/*.json",
45
51
  "./client": "./lib/client.js",
46
52
  "./src/*": "./src/*",
47
53
  "./package.json": "./package.json"
@@ -76,6 +82,9 @@
76
82
  "lib/ext-test/index.js",
77
83
  "lib/ext-test/package.json",
78
84
  "lib/ext-test/locale/*.json",
85
+ "lib/ext-prompt/index.js",
86
+ "lib/ext-prompt/package.json",
87
+ "lib/ext-prompt/locale/*.json",
79
88
  "lib/types/**/*.d.ts",
80
89
  "src",
81
90
  "cordis.patch.yml",
@@ -99,12 +108,13 @@
99
108
  "@deepseek-ai/schemastery": "^3.18.4"
100
109
  },
101
110
  "peerDependencies": {
102
- "@deepseek-ai/cordis": "^4.0.2",
103
- "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.2",
104
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.2",
105
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.2",
106
- "@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
107
- "@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
111
+ "@deepseek-ai/cordis": "^4.0.4",
112
+ "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.2",
113
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.2.0-rc.2",
114
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
115
+ "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
116
+ "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2",
117
+ "@deepseek-ai/dsh-settings": "^0.2.0-rc.2",
108
118
  "cordis": "^4.0.0-rc.7",
109
119
  "react": "^18.2.0",
110
120
  "react-dom": "^18.2.0"
@@ -4,6 +4,7 @@
4
4
  * Create/暂停确认/Created 弹窗)。
5
5
  */
6
6
  import { useCallback, useEffect, useRef, useState } from 'react'
7
+ import { Switch } from '@deepseek-ai/dsh-client-ui-primitives'
7
8
  import {
8
9
  ROUTER_API_BASE,
9
10
  type RouterKey,
@@ -219,16 +220,16 @@ export function EndpointTab(): JSX.Element {
219
220
  <p className="dshr-requireTitle">Require API key</p>
220
221
  <p className="dshr-requireDesc">不带有效 key 的请求将被拒绝</p>
221
222
  </div>
222
- <button
223
- type="button"
224
- className={`dshr-toggle ${requireApiKey ? 'dshr-toggle-on' : ''}`}
225
- role="switch"
226
- aria-checked={requireApiKey}
227
- onClick={() => void toggleRequire(!requireApiKey)}
223
+ {/* ⚠️ 用 DSH 原生 `Switch`,不用自造的 `dshr-toggle` 按钮。
224
+ 2026-09-29 用户指出的:同一个设置页里两处开关长得不一样(这一页自造、
225
+ 扩展页用原生)⇒ 深浅色两套主题、焦点环、键盘行为都会分叉。
226
+ 原生那个还自带 `label`(读屏可读),自造那个只有 `role="switch"`。 */}
227
+ <Switch
228
+ checked={requireApiKey}
229
+ label="Require API key 开关"
228
230
  title={requireApiKey ? '关闭鉴权' : '开启鉴权'}
229
- >
230
- <span className="dshr-toggleKnob" />
231
- </button>
231
+ onChange={(next) => { void toggleRequire(next) }}
232
+ />
232
233
  </div>
233
234
 
234
235
  {/* key 列表 / 空状态 */}
@@ -272,16 +273,12 @@ export function EndpointTab(): JSX.Element {
272
273
  </p>
273
274
  </div>
274
275
  <div className="dshr-keyOps">
275
- <button
276
- type="button"
277
- className={`dshr-toggle ${key.isActive ? 'dshr-toggle-on' : ''}`}
278
- role="switch"
279
- aria-checked={key.isActive}
276
+ <Switch
277
+ checked={key.isActive}
278
+ label={`${key.name} 开关`}
280
279
  title={key.isActive ? '暂停 key' : '恢复 key'}
281
- onClick={() => onKeySwitch(key, !key.isActive)}
282
- >
283
- <span className="dshr-toggleKnob" />
284
- </button>
280
+ onChange={(next) => { onKeySwitch(key, next) }}
281
+ />
285
282
  </div>
286
283
  </div>
287
284
  ))}