dsh-router-core 0.4.2 → 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 (49) 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 +249 -117
  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-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 +174 -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
@@ -0,0 +1,432 @@
1
+ /**
2
+ * 分层提示词扩展(`router.ext` 侧)测试 —— 锁住四件事:
3
+ * 1. 登记进 `router.ext` 表,带上可显示的身份(名字/说明/就绪);
4
+ * 2. **幂等**:同一个 id 已在表里就不顶掉别人的登记;
5
+ * 3. `source: 'builtin'`(随核心分发 ⇒ 插件页的原生行,自绘节不重复列);
6
+ * 4. **准则段落真的挂上了** —— ext-test 没有这一条,本扩展多做执行面,必须验。
7
+ *
8
+ * 为什么第 4 条不可省:扩展的「卡片出现与否」取决于它在不在这张表里,而**准则
9
+ * 有没有进 prompt** 取决于段落挂没挂 —— 两者是**不同的失效**,都静默且都不报错。
10
+ * 只测登记会漏掉「卡片亮着、准则没进 prompt」这个最难查的情况。
11
+ */
12
+ import test from 'node:test'
13
+ import assert from 'node:assert/strict'
14
+ import { readFileSync } from 'node:fs'
15
+ import { createPromptExt, EXT_PROMPT_ID } from './plugin.ts'
16
+ import { PROMPT_LOGO_URL } from './logo.ts'
17
+ import { apply, name as CORDIS_NAME, inject as CORDIS_INJECT } from './index.ts'
18
+ import { PROMPT_SECTION_NAME } from './mount.ts'
19
+ import { PROMPT_CATEGORIES, PROMPT_TITLE } from './content.ts'
20
+ import type { RouterExtService } from '../ext/contract.ts'
21
+
22
+ test('扩展器带上面板要用的身份', () => {
23
+ const ext = createPromptExt({ isSystemPromptReady: () => true })
24
+ assert.equal(ext.id, EXT_PROMPT_ID)
25
+ assert.equal(ext.name, '分层提示词')
26
+ assert.equal(ext.source, 'builtin', '随核心分发必须标 builtin,否则插件页会重复列它')
27
+ })
28
+
29
+ test('★ `description` 是整段标题(不是自我介绍,也不内插分类数)', () => {
30
+ // ⚠️ 这条断言**换了两次内容**,值得留档(2026-09-29):
31
+ // ① 最初写的是「没有说明,插件页那一行就没有副标题」——**前提是错的**,
32
+ // 那一行的副标题来自 `locale/*.json`,与 `description` 无关;
33
+ // ② 改成「`description` 必须是 undefined」(当时用户嫌「拆成 14 个分类」冗余);
34
+ // ③ 现在又要求**顶部显示标题** ⇒ 改成「等于 PROMPT_TITLE」。
35
+ // 两次反复的共同教训:**默认值/文案该是什么由用户拍板,判据只负责让改动可见**,
36
+ // 所以断言跟着改、并在注释里留着反复,免得下一个人以为是"又改坏了"。
37
+ const ext = createPromptExt({ isSystemPromptReady: () => true })
38
+ assert.equal(ext.description, PROMPT_TITLE, '详情页顶部要显示的就是整段标题')
39
+ // ⚠️ 尤其**不许**内插 `${PROMPT_CATEGORIES.length}`:加减分类后文案不跟着变,
40
+ // 就是同一件事的两个来源(「反查重复」)。
41
+ assert.doesNotMatch(String(ext.description), /\d+\s*个/,
42
+ 'description 里内插了分类数量 ⇒ 加减分类后会与真实数量漂移')
43
+ })
44
+
45
+ test('★ 插件页那一行的说明仍然在(来自 locale,不是 description)', () => {
46
+ // 「删 description」不等于「把说明也删了」:宿主读的是 locale 资源。
47
+ // 只写 host 半边的话,这一行会**没有副标题** —— 而不打开就不知道它是干什么的。
48
+ const locale = JSON.parse(
49
+ readFileSync(new URL('./locale/zh.json', import.meta.url), 'utf8'),
50
+ ) as { meta?: { title?: string; description?: string } }
51
+ assert.ok((locale.meta?.title ?? '').length > 0, 'locale 缺 title')
52
+ assert.ok((locale.meta?.description ?? '').length > 0, '插件页那一行没有副标题')
53
+ })
54
+
55
+ test('注入:systemPrompt 不可用 ⇒ 报 not ready(不能静默不生效)', () => {
56
+ const ext = createPromptExt({ isSystemPromptReady: () => false })
57
+ const st = ext.getState()
58
+ assert.equal(st.ready, false, '无处注入却报就绪 = 卡片亮着、准则没进 prompt')
59
+ assert.ok((st.detail ?? '').length > 0, '不报还不说原因 = 面板上无从判断')
60
+ })
61
+
62
+ test('cordis 身份与 patch 里声明的 id 逐字相同(否则 loader 认不出这一行)', () => {
63
+ assert.equal(CORDIS_NAME, 'dsh-router-ext-prompt')
64
+ })
65
+
66
+ test('★ patch 里真的声明了这一行,且 id/name 逐字对得上', () => {
67
+ // ⚠️ **必须真读 patch 文件**:只断言自己的常量的话,patch 写错了照样全绿
68
+ // —— 判据不碰被它守的东西,等于没守。
69
+ // 症状会非常隐蔽:构建通过、测试通过、装上后插件页**没有这一行**。
70
+ const patch = readFileSync(new URL('../../cordis.patch.yml', import.meta.url), 'utf8')
71
+ const block = patch.match(
72
+ new RegExp(`- id: ${CORDIS_NAME}\\s*\\n\\s*name: '([^']+)'(\\s*\\n\\s*(disabled:.*)?)?\\n`),
73
+ )
74
+ assert.ok(block, `cordis.patch.yml 里没有 id=${CORDIS_NAME} 的行`)
75
+ assert.equal(block![1], 'dsh-router-core/ext-prompt', 'patch 的 name 必须是能解析的子路径说明符')
76
+ })
77
+
78
+ test('★ 那一行默认关闭(与 ext-test 同款)', () => {
79
+ // `disabled: true` = 行默认关 ⇒ loader 不 import ⇒ 扩展不进 `router.ext` 表 ⇒
80
+ // 卡片不出现、准则不进 prompt。
81
+ //
82
+ // ⚠️ **这里来回改过一次**(2026-09-29):先按"准则是基础设施"改成默认开启,
83
+ // 又改回默认关闭。**用户对默认值的取舍优先于我的判断** —— 判据跟着改,
84
+ // 不是反过来。注释里留着这次反复,是为了让下一个人知道它**曾经**是默认开、
85
+ // 以及为什么现在不是(不是"忘了改回来")。
86
+ const patch = readFileSync(new URL('../../cordis.patch.yml', import.meta.url), 'utf8')
87
+ const block = patch.match(
88
+ new RegExp(`- id: ${CORDIS_NAME}[^]*?\\n(\\s*name: '([^']+)'\\n([^\\n]*))`),
89
+ )
90
+ assert.ok(block, 'patch 里找不到这一行')
91
+ assert.match(block![3] ?? '', /disabled:\s*true/,
92
+ '这一行不带 disabled: true ⇒ 装上就自动注入准则(与用户当前拍板相反)')
93
+ })
94
+
95
+ /**
96
+ * 真 cordis 夹具 —— **不用假 ctx**(2026-09-29 换掉)。
97
+ *
98
+ * ⚠️ 上一版的假 ctx `inject` 会**立即**回调 apply 里注册的回调。那恰好把真正的
99
+ * bug 掩盖掉了:`ctx.inject(deps, cb)` 对已就绪的 service 其实要等
100
+ * `ctx.start()` 之后才回调,所以旧实现里准则一次都没挂上,而假 ctx 全绿。
101
+ * ⇒ 判据的替身比被守的东西更宽容 = 判据无效。
102
+ * 真 cordis 会如实复现时序(本机 4.0.4 实测:provide 后 inject 不触发,
103
+ * start() 之后才触发)。
104
+ */
105
+ async function realCtx(opts: { enabled?: boolean; data?: unknown } = {}) {
106
+ const { Context } = await import('@deepseek-ai/cordis')
107
+ const table: RouterExtService = {}
108
+ const sections: { name: string; order: number; text: string | (() => string) }[] = []
109
+ const ctx = new Context()
110
+ ctx.provide('systemPrompt', {
111
+ section: (s: { name: string; order: number; text: string | (() => string) }) => {
112
+ sections.push(s)
113
+ return () => {}
114
+ },
115
+ getSectionOrder: () => 100,
116
+ })
117
+ ctx.provide('router.ext', table)
118
+ ctx.provide('router.extStore', {
119
+ isEnabled: () => opts.enabled ?? true,
120
+ setEnabled: () => {},
121
+ readData: () => opts.data,
122
+ writeData: () => {},
123
+ })
124
+ return { ctx, table, sections }
125
+ }
126
+
127
+ test('apply 把扩展登记进 router.ext 表(这张表就是「卡片出现」的唯一依据)', async () => {
128
+ const { ctx, table } = await realCtx()
129
+ apply(ctx as unknown as Parameters<typeof apply>[0])
130
+ assert.equal(table[EXT_PROMPT_ID]?.name, '分层提示词')
131
+ })
132
+
133
+ test('幂等:同一个 id 已在表里时不顶掉别人的登记', async () => {
134
+ const { ctx, table } = await realCtx()
135
+ apply(ctx as unknown as Parameters<typeof apply>[0])
136
+ const first = table[EXT_PROMPT_ID]
137
+ apply(ctx as unknown as Parameters<typeof apply>[0])
138
+ assert.equal(table[EXT_PROMPT_ID], first, '重复登记不该换成一个新对象')
139
+ })
140
+
141
+ test('★ apply 把准则挂成了 system prompt 段落(ext-test 没这层,必须单独验)', async () => {
142
+ const { ctx, sections } = await realCtx()
143
+ apply(ctx as unknown as Parameters<typeof apply>[0])
144
+ assert.equal(sections.length, 1, '准则没挂上 = 卡片亮着但 prompt 里没有准则')
145
+ assert.equal(sections[0]!.name, PROMPT_SECTION_NAME)
146
+ assert.equal(typeof sections[0]!.text, 'function', '静态文本 ⇒ 分类开关永远不生效')
147
+ })
148
+
149
+ test('注入:总开关关时段落文本为空(平台据此丢弃该段)', async () => {
150
+ const { ctx, sections } = await realCtx({ enabled: false })
151
+ apply(ctx as unknown as Parameters<typeof apply>[0])
152
+ const text = (sections[0]!.text as () => string)()
153
+ assert.equal(text, '', '总开关关时必须返回空串,而不是注入一段空白')
154
+ })
155
+
156
+ test('注入:用户关掉某分类 ⇒ 该分类不进 prompt(可开关的正向判据)', async () => {
157
+ const { ctx, sections } = await realCtx({ enabled: true, data: { categories: { identity: false } } })
158
+ apply(ctx as unknown as Parameters<typeof apply>[0])
159
+ const text = (sections[0]!.text as () => string)()
160
+ assert.equal(text.includes('大肥鱼'), false, 'identity 关了就不该出现')
161
+ assert.ok(text.includes('视野'), '其他分类照常')
162
+ })
163
+
164
+ // ── 面板编辑文本(2026-09-29)────────────────────────────────
165
+ test('★ 每条都标 editable,面板才知道该不该给「修改」按钮', () => {
166
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store: fakeStore({ categories: {} }) })
167
+ for (const c of ext.controls ?? []) assert.equal(c.editable, true, `${c.id} 没标 editable`)
168
+ assert.equal(typeof ext.setControlText, 'function', '没有 setControlText ⇒ 面板点了必然 400')
169
+ })
170
+
171
+ test('★ 编辑后 controls 立刻反映新标题/新正文(每次读都重算)', () => {
172
+ const store = fakeStore({ categories: {}, text: { identity: { title: '我是谁', body: '我是一个测试。' } } })
173
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
174
+ const c = (ext.controls ?? []).find((x) => x.id === 'identity')!
175
+ assert.equal(c.title, '我是谁')
176
+ assert.equal(c.body, '我是一个测试。')
177
+ })
178
+
179
+ test('★ controls 不是快照:改完之后**再读一次**要看到新值', () => {
180
+ // ⚠️ 2026-09-29 实测踩的 bug:`controls: CATEGORIES.map(...)` 那个 map 在
181
+ // `createPromptExt()` 时就跑完了 ⇒ 编辑落盘后 prompt 变了、**面板拿到的还是
182
+ // 旧标题**(用户看到"保存了又变回去")。而我当时注释写着"现算不缓存"。
183
+ // ⇒ 判据必须**跨两次读**才抓得住(只读一次的写法抓不到)。
184
+ const store = fakeStore({ categories: {} })
185
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
186
+ const before = (ext.controls ?? []).find((x) => x.id === 'ladder')!
187
+ assert.equal(before.title, '懒人梯子')
188
+ ext.setControlText?.('ladder', { title: '我的梯子' })
189
+ const after = (ext.controls ?? []).find((x) => x.id === 'ladder')!
190
+ assert.equal(after.title, '我的梯子', 'controls 是快照 ⇒ 面板刷新后编辑内容会「变回去」')
191
+ })
192
+
193
+ test('setControlText 只写覆盖值,且不碰 categories(同块的另一种数据)', () => {
194
+ const store = fakeStore({ categories: { identity: false }, keep: 'me' })
195
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
196
+ assert.equal(ext.setControlText?.('identity', { title: '新标题' }), true)
197
+ const after = store.readData<Record<string, unknown>>('')
198
+ assert.deepEqual(after?.categories, { identity: false }, '改标题把开关状态冲掉了')
199
+ assert.equal(after?.keep, 'me', '把 data 抽屉里别的字段抹了')
200
+ assert.deepEqual(after?.text, { identity: { title: '新标题' } }, '只该存改过的字段')
201
+ })
202
+
203
+ test('★ 改一条**不碰其它条**的覆盖(整块重写 text 会静默丢别人的)', () => {
204
+ // ⚠️ 注入验证过:`text: { [id]: patch }` 这种写法**全绿** —— 它的破坏
205
+ // 只在"用户改过两条以上"时显形,而单人单条测试看不出来。
206
+ const store = fakeStore({
207
+ categories: {},
208
+ text: { identity: { title: 'A' }, scope: { body: 'B' } },
209
+ })
210
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
211
+ ext.setControlText?.('ladder', { body: '新内容' })
212
+ assert.deepEqual(store.readData<Record<string, unknown>>('')?.text, {
213
+ identity: { title: 'A' },
214
+ scope: { body: 'B' },
215
+ ladder: { body: '新内容' },
216
+ }, '改一条把别人覆盖的抹了')
217
+ })
218
+
219
+ test('改同一条的第二个字段 ⇒ 第一个字段仍在(部分更新,不是替换)', () => {
220
+ const store = fakeStore({ categories: {}, text: { identity: { title: 'A' } } })
221
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
222
+ ext.setControlText?.('identity', { body: 'B' })
223
+ assert.deepEqual(store.readData<Record<string, unknown>>('')?.text, {
224
+ identity: { title: 'A', body: 'B' },
225
+ })
226
+ })
227
+
228
+ test('注入:空标题/空正文返回 false(不写盘)', () => {
229
+ const store = fakeStore({ categories: {} })
230
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
231
+ for (const bad of [{ title: ' ' }, { title: '' }, { body: '' }]) {
232
+ assert.equal(ext.setControlText?.('identity', bad), false, `空串被接受了:${JSON.stringify(bad)}`)
233
+ }
234
+ assert.equal(store.readData<Record<string, unknown>>('')?.text, undefined, '失败的写入留下了痕迹')
235
+ })
236
+
237
+ test('注入:未知 controlId 返回 false', () => {
238
+ const store = fakeStore({ categories: {} })
239
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
240
+ assert.equal(ext.setControlText?.('__evil__', { title: 'x' }), false)
241
+ })
242
+
243
+ /**
244
+ * 假 store:**整块替换**语义,与真实的 `ExtStore.writeData` 一致
245
+ * (`this.byId[id] = { …, data: value }`)。合并的替身会让「只改一个键」与
246
+ * 「整块重写」观测上完全一样 ⇒ 注入违规时测试照样全绿(2026-09-29 栽过)。
247
+ */
248
+ function fakeStore(initial: Record<string, unknown> | undefined) {
249
+ const box = { data: initial }
250
+ return {
251
+ isEnabled: () => true,
252
+ setEnabled: () => {},
253
+ readData: <T,>(_id: string) => box.data as T | undefined,
254
+ writeData: (_id: string, value: unknown) => { box.data = value as Record<string, unknown> },
255
+ } as unknown as import('../ext/contract.ts').ExtStoreService
256
+ }
257
+
258
+ // ── 排序 / 自建 / 还原 / 删除(2026-09-29)──────────────────
259
+ test('★ controls 走合成列表:自建条目出现、标 custom、内置标 overridden', () => {
260
+ const store = fakeStore({
261
+ categories: {},
262
+ custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }],
263
+ text: { ladder: { body: '改过的' } },
264
+ })
265
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
266
+ const list = ext.controls ?? []
267
+ assert.equal(list.length, PROMPT_CATEGORIES.length + 1, '自建条目没出现')
268
+ assert.equal(list.find((c) => c.id === 'cu-1')?.custom, true)
269
+ assert.equal(list.find((c) => c.id === 'ladder')?.overridden, true, '改过的内置条目要标 overridden(还原按钮可点)')
270
+ assert.equal(list.find((c) => c.id === 'identity')?.overridden, false, '没改过的不该标(还原按钮置灰)')
271
+ })
272
+
273
+ test('★ 新增自建条目 ⇒ 返回新 id,且落盘(id 不撞内置)', () => {
274
+ const store = fakeStore({ categories: {} })
275
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
276
+ const id = ext.addCustomControl?.('我的准则', '做完了就收。')
277
+ assert.ok(id !== null && typeof id === 'string', '没返回新 id')
278
+ assert.notEqual(id, 'identity', '新 id 撞上了内置条目')
279
+ assert.equal((ext.controls ?? []).some((c) => c.id === id), true, '落盘后没出现在 controls 里')
280
+ })
281
+
282
+ test('注入:空标题/空正文新增 ⇒ 返回 null(不写盘)', () => {
283
+ const store = fakeStore({ categories: {} })
284
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
285
+ assert.equal(ext.addCustomControl?.(' ', 'x'), null)
286
+ assert.equal(ext.addCustomControl?.('x', ''), null)
287
+ assert.equal(store.readData<Record<string, unknown>>('')?.custom, undefined, '失败的写入留下痕迹')
288
+ })
289
+
290
+ test('★ 删除只允许自定义:内置条目被拒、自建条目删掉并清干净关联数据', () => {
291
+ const store = fakeStore({
292
+ categories: { 'cu-1': true, identity: false },
293
+ text: { 'cu-1': { body: 'x' } },
294
+ order: ['cu-1', 'ladder'],
295
+ custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }],
296
+ })
297
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
298
+ assert.equal(ext.removeCustomControl?.('identity'), false, '内置条目不该被删(它是代码的一部分)')
299
+ assert.equal(ext.removeCustomControl?.('ladder'), false, '不存在的条目应被拒')
300
+ assert.equal(ext.removeCustomControl?.('cu-1'), true)
301
+ const after = store.readData<Record<string, unknown>>('') ?? {}
302
+ assert.deepEqual(after.custom, [], '自建条目没删掉')
303
+ assert.deepEqual(after.categories, { identity: false }, '删一条把别的开关状态弄丢了')
304
+ assert.deepEqual(after.order, ['ladder'], 'order 里留了幽灵 id')
305
+ })
306
+
307
+ test('★ 还原:只对被改过的内置条目成功,且回到内置文本', () => {
308
+ const store = fakeStore({ categories: {}, text: { ladder: { body: '改过的' } } })
309
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
310
+ assert.equal(ext.resetControlText?.('identity'), false, '没改过的内置条目不该「可还原」')
311
+ assert.equal(ext.resetControlText?.('cu-1'), false, '自建条目没有「内置版本」可回退')
312
+ assert.equal(ext.resetControlText?.('ladder'), true)
313
+ // ⚠️ 必须用 `deepEqual`:`assert.equal` 比的是**引用**,对象永远不等 ——
314
+ // 我第一版就写成这样,判据一直红而代码是对的。
315
+ assert.deepEqual(store.readData<Record<string, unknown>>('')?.text, {}, '覆盖没被丢掉')
316
+ assert.equal((ext.controls ?? []).find((c) => c.id === 'ladder')?.overridden, false)
317
+ })
318
+
319
+ test('★ 排序:只存 id,且过滤掉不存在的 id(脏数据不留幽灵)', () => {
320
+ const store = fakeStore({ categories: {} })
321
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
322
+ assert.equal(ext.setControlOrder?.(['__ghost__', 'ladder', 'ladder']), true)
323
+ assert.deepEqual(store.readData<Record<string, unknown>>('')?.order, ['ladder'], '幽灵/重复 id 没被过滤')
324
+ })
325
+
326
+ // ── 真实初始状态:`ext.json` 里只有 `{enabled:true}`,没有 `data` ──
327
+ test('★ 没有 data 记录时也能写("没改过" ≠ "不能写")', () => {
328
+ // ⚠️ 这是 2026-09-30 实测的真 bug:`ext.json` 里 `prompt` 只有 `{enabled:true}`
329
+ // (用户没改过任何东西 ⇒ 没有 `data` 键),而我把 `readData()` 返回 undefined
330
+ // 当成"存储不可用"直接拒 ⇒ 「添加准则」永远报 `title and body are required`
331
+ // ——而 title/body 明明都传了,报错还指向错误的字段,排查成本极高。
332
+ const store = fakeStore(undefined) // **没有 data**
333
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
334
+ const id = ext.addCustomControl?.('我的准则', '做完就收。')
335
+ assert.ok(id !== null, '初始状态下添加失败(真实用户第一次用就是这个状态)')
336
+ assert.equal((ext.controls ?? []).some((c) => c.id === id), true, '写盘了但 controls 里没有')
337
+ })
338
+
339
+ test('★ 四个写入口在"无 data"时都可用(不只是 add)', () => {
340
+ const store = fakeStore(undefined)
341
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
342
+ assert.equal(ext.setControl?.('ladder', false), true, '开关在初始状态下不可用')
343
+ assert.equal(ext.setControlText?.('ladder', { body: 'x' }), true, '改文本在初始状态下不可用')
344
+ assert.equal(ext.setControlOrder?.(['ladder']), true, '排序在初始状态下不可用')
345
+ })
346
+
347
+ test('★ 还原:改过 ⇒ true(且覆盖被清掉);没改过 ⇒ false', () => {
348
+ // ⚠️ 这个用例第一版**测不出来**:我的假 store 把 `readData: () => undefined`
349
+ // 写死了,于是 setControlText 写进去的东西**读不回来**,还原永远失败 ——
350
+ // 而代码是对的。**假 store 比被守的东西更宽容 = 判据无效**(第三次栽在这)。
351
+ // ⇒ 假 store 必须**真的读回自己写的东西**:`fakeStore` 的 `box` 已在做。
352
+ const store = fakeStore({ categories: {} })
353
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
354
+ assert.equal(ext.resetControlText?.('identity'), false, '没改过的却说可还原')
355
+ ext.setControlText?.('ladder', { body: '改过的' })
356
+ assert.equal(ext.resetControlText?.('ladder'), true, '改过的却还原不了')
357
+ assert.deepEqual(store.readData<Record<string, unknown>>('')?.text, {}, '覆盖没被清掉')
358
+ })
359
+
360
+ // ── op 成功必须回整张表(2026-09-30)───────────────────────
361
+ test('★ 契约层:controls 上带 custom 标记(面板据此决定给删除还是还原)', () => {
362
+ const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
363
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
364
+ const mine = (ext.controls ?? []).find((c) => c.id === 'cu-1')
365
+ const builtin = (ext.controls ?? []).find((c) => c.id === 'identity')
366
+ assert.equal(mine?.custom, true, '自建条目没标 custom ⇒ 面板给"还原"而不是"删除"')
367
+ assert.equal(builtin?.custom, false, '内置条目不该标 custom')
368
+ assert.notEqual(mine?.custom, undefined, 'custom 必须**显式**为 false,缺省与"未传"无法区分')
369
+ })
370
+
371
+ // ── 自建条目的写入口(2026-09-30 实测的「自定义开关报错」)──
372
+ test('★ 自建条目的开关可切(判据要用合成列表,不能只用内置常量)', () => {
373
+ // ⚠️ 真实 bug:改动前 `setControl` 用 `PROMPT_CATEGORIES.some(...)` 判存在性,
374
+ // 而自建条目的 id 不在内置常量里 ⇒ **恒被拒**,报 `unknown controlId`
375
+ // ⇒ 用户看到「自定义的开关控制报错」。而它**在 controls 列表里显示着** ——
376
+ // 列表用 `resolveCategories`、写入判据用 `PROMPT_CATEGORIES`,两处来源不一致。
377
+ const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
378
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
379
+ assert.equal((ext.controls ?? []).some((c) => c.id === 'cu-1'), true, '列表里没有它')
380
+ assert.equal(ext.setControl?.('cu-1', false), true, '列表里有、却切不了')
381
+ assert.equal(store.readData<Record<string, unknown>>('')?.categories && (store.readData<{ categories: Record<string, boolean> }>('')?.categories?.['cu-1']), false, '开关没落盘')
382
+ })
383
+
384
+ test('★ 自建条目的文本可改(同一处来源不一致)', () => {
385
+ const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
386
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
387
+ assert.equal(ext.setControlText?.('cu-1', { title: '新标题' }), true, '自建条目改不了标题')
388
+ assert.equal((ext.controls ?? []).find((c) => c.id === 'cu-1')?.title, '新标题')
389
+ })
390
+
391
+ test('还原仍然只允许内置条目(自建没有「内置版本」可回退)', () => {
392
+ const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
393
+ const ext = createPromptExt({ isSystemPromptReady: () => true, store })
394
+ assert.equal(ext.resetControlText?.('cu-1'), false, '自建条目不该能被「还原」')
395
+ })
396
+
397
+ // ── 图标(2026-09-30)────────────────────────────────────────
398
+ test('★ 图标能被真实渲染(base64 必须解得出合法 SVG)', () => {
399
+ // ⚠️ 为什么需要这条:我第一版**手编**了一串 base64,解出来是乱码
400
+ // (`tept-Gradient`、`#17118=D`…)。而客户端的 `onError` 会把加载失败的图
401
+ // **静默藏掉**、退回默认闪电图标 ⇒ 页面"看起来正常",图标却根本没换 ——
402
+ // **编造的常量不会报错,这是它最险的地方**。
403
+ // ⇒ 判据钉住"能往返成 `<svg` 开头的合法文档"。
404
+ assert.ok(PROMPT_LOGO_URL.startsWith('data:image/svg+xml;base64,'), '必须是内联 data URI(离线可用)')
405
+ const decoded = Buffer.from(PROMPT_LOGO_URL.split(',')[1] ?? '', 'base64').toString('utf8')
406
+ assert.ok(decoded.trimStart().startsWith('<svg'), 'base64 解不出 SVG')
407
+ assert.ok(decoded.includes('</svg>'), 'SVG 没闭合')
408
+ // 属性引号必须成对:手编最容易在这里错(如我第一版那个 `fill="#17118=D"` ——
409
+ // 少一个引号,后面整段被当成属性值)。
410
+ // ⚠️ 检测的是**引号总数为偶数**,不是"引号后跟属性名" —— 属性之间本来就该有
411
+ // 空格(`width="64" height="64"`),我第一版正则把合法的也判成错位了。
412
+ assert.equal((decoded.match(/"/g) ?? []).length % 2, 0, 'SVG 属性引号不成对(手编 base64 的典型症状)')
413
+ assert.doesNotMatch(decoded, /tept|#17118=D/, '出现了已知的编造残留')
414
+ // ⚠️ 只检查"解得出 `<svg`"**不够**:`<svg></svg>` 语法完全合法、却什么都没画 ——
415
+ // 注入验证时它就是从这条判据下面溜过去的。⇒ 还要有**实际图元**。
416
+ assert.match(decoded, /<(rect|path|circle|g|line|polyline|polygon)\b/,
417
+ 'SVG 里没有任何图元 —— 合法但画不出东西(空壳图标等于没换)')
418
+ })
419
+
420
+ test('图标挂在 ext 上(不设的话面板退回默认闪电)', () => {
421
+ const ext = createPromptExt({ isSystemPromptReady: () => true })
422
+ assert.equal(ext.icon, PROMPT_LOGO_URL)
423
+ })
424
+
425
+ test('logo.svg 源文件与 base64 一致(改了图忘了重生成就会漂)', () => {
426
+ // 源文件是唯一可编辑的形态;base64 是它的派生物。两者不一致 = 有人改了 svg
427
+ // 却没重新生成 ⇒ 面板显示的还是旧图,而且**没有任何报错**。
428
+ const src = readFileSync(new URL('./logo.svg', import.meta.url), 'utf8')
429
+ const decoded = Buffer.from(PROMPT_LOGO_URL.split(',')[1] ?? '', 'base64').toString('utf8')
430
+ const norm = (s: string): string => s.replace(/\s+/g, ' ').trim()
431
+ assert.equal(norm(decoded), norm(src), 'logo.svg 改了但 base64 没重新生成')
432
+ })
@@ -0,0 +1,104 @@
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
+ import {
19
+ currentExts,
20
+ currentExtStore,
21
+ type ExtStoreService,
22
+ type RouterExtService,
23
+ } from '../ext/contract.ts'
24
+ import { createPromptExt, EXT_PROMPT_ID } from './plugin.ts'
25
+ import { mountPromptSection, type SystemPromptFace } from './mount.ts'
26
+ import type { PromptExtData } from './render.ts'
27
+
28
+ /** cordis 插件身份(配置树里这一行的技术名;显示名走同目录的 locale)。 */
29
+ export const name = 'dsh-router-ext-prompt'
30
+
31
+ /** `ctx.systemPrompt` 的最小可见面(见 mount.ts)。 */
32
+ function systemPromptOf(sctx: Context): SystemPromptFace | undefined {
33
+ const c = sctx as unknown as {
34
+ get?: (k: string) => unknown
35
+ systemPrompt?: SystemPromptFace
36
+ }
37
+ const sp = (c.get?.('systemPrompt') ?? c.systemPrompt) as SystemPromptFace | undefined
38
+ return sp && typeof sp.section === 'function' ? sp : undefined
39
+ }
40
+
41
+ /**
42
+ * 声明本扩展依赖的 service。
43
+ *
44
+ * ⚠️⚠️ **必须导出这个数组,且 `apply` 里直接用 `ctx.systemPrompt`**(2026-09-29 修)
45
+ * ——我原先写成「在 apply 里 `ctx.inject(['systemPrompt'], cb)`」,**准则一直不进
46
+ * prompt**:面板上卡片、开关、原文全都正常(`GET /router/api/ext` 实测有 14 条
47
+ * controls),但模型看到的 system prompt 里没有它。
48
+ *
49
+ * 根因(本机 cordis 4.0.4 实测):`ctx.inject(deps, cb)` 的回调**不是同步触发** ——
50
+ * provide 之后立刻 `inject`,回调要等到下一个异步点才跑。插件的 `apply` 是**同步
51
+ * 函数**,返回时那次回调还没发生;而真实加载路径不保证之后还有机会去等它,
52
+ * 于是 `mountPromptSection` 从没被执行 ⇒ **准则一次都没进过 prompt**。
53
+ *
54
+ * ⚠️ 我第一版把这个机制写成「要等 `ctx.start()`」——**那是错的**:`start` 根本不在
55
+ * Context 的公开面上(原型只有 extend/isolate/intercept)。准确说法是「下一个
56
+ * 异步点」,具体机制随版本变。**结论不依赖这个细节**,只依赖「apply 里手动
57
+ * inject 不可靠」这一事实。
58
+ *
59
+ * 正确形状(对照 `dsh-client-ui-deliverables` 等真实使用者):导出 `inject` 数组,
60
+ * cordis 看到它就**把整个 apply 推迟到这些 service 就绪之后**再调用 ——
61
+ * 于是 `ctx.systemPrompt` 一定是活的,`section()` 可以直接调。
62
+ *
63
+ * ⚠️ `router.ext` 也要列:那一半(面板登记)此前"碰巧"能用,是因为
64
+ * `dsh-router` 核心先 provide 了它、`apply` 又跑在它之后。**那是时序上的运气,
65
+ * 不是保证** —— 一并列进 `inject`,两种 service 都由 cordis 编排。
66
+ */
67
+ export const inject = ['systemPrompt', 'router.ext', 'router.extStore']
68
+
69
+ /**
70
+ * 登记本扩展**并**挂载准则段落。
71
+ *
72
+ * 此刻 `ctx.systemPrompt` / `ctx.router.ext` / `ctx.router.extStore` **必定已就绪**
73
+ * (见上面 `inject` 的注释),所以下面**直接用**,不再做二次判空。
74
+ */
75
+ export function apply(ctx: Context): void {
76
+ const table = currentExts(ctx)
77
+ const store = currentExtStore(ctx)
78
+
79
+ // ① 准则段落。`section()` 注册一个**provider 函数**作为 text ——
80
+ // 每次装配现算,所以改开关立即生效,无需重注册。
81
+ const dispose = mountPromptSection({
82
+ getSystemPrompt: () => systemPromptOf(ctx),
83
+ isEnabled: () => store?.isEnabled(EXT_PROMPT_ID) === true,
84
+ readData: () => store?.readData<PromptExtData>(EXT_PROMPT_ID),
85
+ })
86
+
87
+ // ② 面板登记。幂等:同一个 id 已在表里就不顶掉别人的登记。
88
+ if (table !== undefined && table[EXT_PROMPT_ID] === undefined) {
89
+ table[EXT_PROMPT_ID] = createPromptExt({
90
+ isSystemPromptReady: () => !!systemPromptOf(ctx),
91
+ // ⚠️ **store 必须在这时就能拿到**:拿不到的话面板上的分类开关显示可点、
92
+ // 点了 400 —— 「看起来能用但不能用」比「不显示」更难查。
93
+ store,
94
+ })
95
+ store?.setEnabled(EXT_PROMPT_ID, true)
96
+ // 核心持有的是**同一个 live 对象**(它 provide 的空表),且 `/ext` 每次请求都
97
+ // 现读这张表 —— 所以往里 append 就够了,不需要(也不存在)广播事件。
98
+ }
99
+
100
+ ctx.effect(() => () => {
101
+ dispose()
102
+ if (table !== undefined) delete table[EXT_PROMPT_ID]
103
+ })
104
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Layered prompt",
4
+ "description": "Split the working guidelines into individually switchable categories and inject them into the system prompt; disabled categories contribute no prompt text"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "分层提示词",
4
+ "description": "把协作准则拆成可独立开关的分类,注入 system prompt;关闭的分类不产出任何 prompt 文本"
5
+ }
6
+ }
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect width="64" height="64" rx="16" fill="#17181D"/>
3
+ <g fill="none" stroke="#EDEDEB" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
4
+ <path d="M18 16h20"/>
5
+ <path d="M18 27h24"/>
6
+ <path d="M18 38h24"/>
7
+ <path d="M18 49h24"/>
8
+ </g>
9
+ <g fill="none" stroke="#A7C0A5" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round">
10
+ <path d="M9 16.5l2.5 2.5L16 14.5"/>
11
+ <path d="M9 27.5l2.5 2.5L16 25.5"/>
12
+ <path d="M9 38.5l2.5 2.5L16 36.5"/>
13
+ </g>
14
+ </svg>
@@ -0,0 +1,18 @@
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 const PROMPT_LOGO_URL =
18
+ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTYiIGZpbGw9IiMxNzE4MUQiLz4KICA8ZyBmaWxsPSJub25lIiBzdHJva2U9IiNFREVERUIiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj4KICAgIDxwYXRoIGQ9Ik0xOCAxNmgyMCIvPgogICAgPHBhdGggZD0iTTE4IDI3aDI0Ii8+CiAgICA8cGF0aCBkPSJNMTggMzhoMjQiLz4KICAgIDxwYXRoIGQ9Ik0xOCA0OWgyNCIvPgogIDwvZz4KICA8ZyBmaWxsPSJub25lIiBzdHJva2U9IiNBN0MwQTUiIHN0cm9rZS13aWR0aD0iMy40IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPgogICAgPHBhdGggZD0iTTkgMTYuNWwyLjUgMi41TDE2IDE0LjUiLz4KICAgIDxwYXRoIGQ9Ik05IDI3LjVsMi41IDIuNUwxNiAyNS41Ii8+CiAgICA8cGF0aCBkPSJNOSAzOC41bDIuNSAyLjVMMTYgMzYuNSIvPgogIDwvZz4KPC9zdmc+Cg=='