dsh-router-core 0.4.5 → 0.4.7
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.
- package/README.md +152 -12
- package/docs/ext.md +1 -1
- package/docs/protocols.md +198 -0
- package/docs/suppliers.md +2 -2
- package/lib/client-registry.js +146 -106
- package/lib/client.js +146 -106
- package/lib/ext-prompt/index.js +182 -17
- package/lib/index.js +2764 -63
- package/lib/suppliers/opencode/index.js +58 -30
- package/lib/types/ext/contract.d.ts +20 -1
- package/lib/types/ext-prompt/content.d.ts +7 -1
- package/lib/types/ext-prompt/mount.d.ts +12 -1
- package/lib/types/ext-prompt/plugin.d.ts +10 -0
- package/lib/types/ext-prompt/render.d.ts +14 -3
- package/lib/types/ext-prompt/sections.d.ts +95 -0
- package/lib/types/protocol/anthropic.d.ts +35 -0
- package/lib/types/protocol/chat.d.ts +25 -0
- package/lib/types/protocol/entry.d.ts +137 -0
- package/lib/types/protocol/gemini.d.ts +57 -0
- package/lib/types/protocol/index.d.ts +13 -0
- package/lib/types/protocol/responses.d.ts +41 -0
- package/lib/types/protocol/sse.d.ts +41 -0
- package/lib/types/protocol/stream.d.ts +92 -0
- package/lib/types/protocol/types.d.ts +224 -0
- package/lib/types/router/account-pool.d.ts +7 -2
- package/lib/types/router/index.d.ts +42 -1
- package/lib/types/router/types.d.ts +26 -3
- package/lib/types/router/usage-store.d.ts +4 -2
- package/lib/types/shared.d.ts +24 -0
- package/lib/types/suppliers/contract.d.ts +15 -1
- package/package.json +6 -6
- package/src/client/ExtDetail.tsx +25 -5
- package/src/client/group-controls.test.ts +53 -0
- package/src/client/group-controls.ts +43 -0
- package/src/client/router.css +20 -1
- package/src/ext/contract.ts +20 -1
- package/src/ext-prompt/assembly.test.ts +63 -0
- package/src/ext-prompt/content.ts +8 -10
- package/src/ext-prompt/ext-prompt.test.ts +38 -28
- package/src/ext-prompt/index.ts +50 -2
- package/src/ext-prompt/mount.test.ts +18 -1
- package/src/ext-prompt/mount.ts +22 -7
- package/src/ext-prompt/plugin.ts +64 -1
- package/src/ext-prompt/render.test.ts +1 -1
- package/src/ext-prompt/render.ts +17 -6
- package/src/ext-prompt/section-controls.test.ts +148 -0
- package/src/ext-prompt/section-filter.test.ts +140 -0
- package/src/ext-prompt/sections.test.ts +123 -0
- package/src/ext-prompt/sections.ts +193 -0
- package/src/ext-route.test.ts +16 -0
- package/src/index.ts +117 -6
- package/src/llm/adapter.test.ts +54 -0
- package/src/llm/adapter.ts +24 -2
- package/src/protocol/anthropic.ts +662 -0
- package/src/protocol/chat.ts +272 -0
- package/src/protocol/entry.ts +440 -0
- package/src/protocol/gemini.ts +548 -0
- package/src/protocol/index.ts +31 -0
- package/src/protocol/protocol.test.ts +728 -0
- package/src/protocol/responses.ts +551 -0
- package/src/protocol/sse.ts +94 -0
- package/src/protocol/stream.ts +195 -0
- package/src/protocol/types.ts +249 -0
- package/src/protocol/wire-e2e.test.ts +208 -0
- package/src/protocol/wiring.test.ts +225 -0
- package/src/router/account-pool.test.ts +35 -0
- package/src/router/account-pool.ts +15 -4
- package/src/router/index.ts +143 -24
- package/src/router/types.ts +26 -3
- package/src/router/usage-store.ts +4 -2
- package/src/shared.test.ts +35 -0
- package/src/shared.ts +33 -0
- package/src/suppliers/contract.ts +15 -2
- package/src/suppliers/opencode/plugin.test.ts +79 -25
- package/src/suppliers/opencode/plugin.ts +60 -29
|
@@ -20,7 +20,7 @@ import { PROMPT_CATEGORIES, PROMPT_TITLE } from './content.ts'
|
|
|
20
20
|
import type { RouterExtService } from '../ext/contract.ts'
|
|
21
21
|
|
|
22
22
|
test('扩展器带上面板要用的身份', () => {
|
|
23
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true })
|
|
23
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [] })
|
|
24
24
|
assert.equal(ext.id, EXT_PROMPT_ID)
|
|
25
25
|
assert.equal(ext.name, '分层提示词')
|
|
26
26
|
assert.equal(ext.source, 'builtin', '随核心分发必须标 builtin,否则插件页会重复列它')
|
|
@@ -34,7 +34,7 @@ test('★ `description` 是整段标题(不是自我介绍,也不内插分
|
|
|
34
34
|
// ③ 现在又要求**顶部显示标题** ⇒ 改成「等于 PROMPT_TITLE」。
|
|
35
35
|
// 两次反复的共同教训:**默认值/文案该是什么由用户拍板,判据只负责让改动可见**,
|
|
36
36
|
// 所以断言跟着改、并在注释里留着反复,免得下一个人以为是"又改坏了"。
|
|
37
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true })
|
|
37
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [] })
|
|
38
38
|
assert.equal(ext.description, PROMPT_TITLE, '详情页顶部要显示的就是整段标题')
|
|
39
39
|
// ⚠️ 尤其**不许**内插 `${PROMPT_CATEGORIES.length}`:加减分类后文案不跟着变,
|
|
40
40
|
// 就是同一件事的两个来源(「反查重复」)。
|
|
@@ -53,7 +53,7 @@ test('★ 插件页那一行的说明仍然在(来自 locale,不是 descript
|
|
|
53
53
|
})
|
|
54
54
|
|
|
55
55
|
test('注入:systemPrompt 不可用 ⇒ 报 not ready(不能静默不生效)', () => {
|
|
56
|
-
const ext = createPromptExt({ isSystemPromptReady: () => false })
|
|
56
|
+
const ext = createPromptExt({ isSystemPromptReady: () => false, getSections: () => [] })
|
|
57
57
|
const st = ext.getState()
|
|
58
58
|
assert.equal(st.ready, false, '无处注入却报就绪 = 卡片亮着、准则没进 prompt')
|
|
59
59
|
assert.ok((st.detail ?? '').length > 0, '不报还不说原因 = 面板上无从判断')
|
|
@@ -102,17 +102,27 @@ test('★ 那一行默认关闭(与 ext-test 同款)', () => {
|
|
|
102
102
|
* 真 cordis 会如实复现时序(本机 4.0.4 实测:provide 后 inject 不触发,
|
|
103
103
|
* start() 之后才触发)。
|
|
104
104
|
*/
|
|
105
|
-
async function realCtx(opts: { enabled?: boolean; data?: unknown } = {}) {
|
|
105
|
+
async function realCtx(opts: { enabled?: boolean; data?: unknown; assembled?: { name: string; text: string }[] } = {}) {
|
|
106
106
|
const { Context } = await import('@deepseek-ai/cordis')
|
|
107
107
|
const table: RouterExtService = {}
|
|
108
108
|
const sections: { name: string; order: number; text: string | (() => string) }[] = []
|
|
109
|
+
const assembled = opts.assembled ?? []
|
|
110
|
+
let assembleCalls = 0
|
|
109
111
|
const ctx = new Context()
|
|
110
112
|
ctx.provide('systemPrompt', {
|
|
111
113
|
section: (s: { name: string; order: number; text: string | (() => string) }) => {
|
|
112
114
|
sections.push(s)
|
|
113
115
|
return () => {}
|
|
114
116
|
},
|
|
115
|
-
getSectionOrder: () => 100
|
|
117
|
+
// ⚠️ 这里曾经有个 `getSectionOrder: () => 100` 的桩:没人调用它(`mount.ts`
|
|
118
|
+
// 自己定 order),而 100 已经不是本插件的段落 order(2026-10-21 改为 -2000
|
|
119
|
+
// 放到最前)⇒ 留着就是一句与事实不符的注脚,删掉。
|
|
120
|
+
// 段落总控的预热/清单源(apply 里 `void sp.assemble()`);返回值直接决定
|
|
121
|
+
// 面板清单,测试用 `assembled` 注入。
|
|
122
|
+
assemble: async () => {
|
|
123
|
+
assembleCalls++
|
|
124
|
+
return { sections: assembled.map((s) => ({ ...s })) }
|
|
125
|
+
},
|
|
116
126
|
})
|
|
117
127
|
ctx.provide('router.ext', table)
|
|
118
128
|
ctx.provide('router.extStore', {
|
|
@@ -121,7 +131,7 @@ async function realCtx(opts: { enabled?: boolean; data?: unknown } = {}) {
|
|
|
121
131
|
readData: () => opts.data,
|
|
122
132
|
writeData: () => {},
|
|
123
133
|
})
|
|
124
|
-
return { ctx, table, sections }
|
|
134
|
+
return { ctx, table, sections, getAssembleCalls: () => assembleCalls }
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
test('apply 把扩展登记进 router.ext 表(这张表就是「卡片出现」的唯一依据)', async () => {
|
|
@@ -163,14 +173,14 @@ test('注入:用户关掉某分类 ⇒ 该分类不进 prompt(可开关的
|
|
|
163
173
|
|
|
164
174
|
// ── 面板编辑文本(2026-09-29)────────────────────────────────
|
|
165
175
|
test('★ 每条都标 editable,面板才知道该不该给「修改」按钮', () => {
|
|
166
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store: fakeStore({ categories: {} }) })
|
|
176
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store: fakeStore({ categories: {} }) })
|
|
167
177
|
for (const c of ext.controls ?? []) assert.equal(c.editable, true, `${c.id} 没标 editable`)
|
|
168
178
|
assert.equal(typeof ext.setControlText, 'function', '没有 setControlText ⇒ 面板点了必然 400')
|
|
169
179
|
})
|
|
170
180
|
|
|
171
181
|
test('★ 编辑后 controls 立刻反映新标题/新正文(每次读都重算)', () => {
|
|
172
182
|
const store = fakeStore({ categories: {}, text: { identity: { title: '我是谁', body: '我是一个测试。' } } })
|
|
173
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
183
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
174
184
|
const c = (ext.controls ?? []).find((x) => x.id === 'identity')!
|
|
175
185
|
assert.equal(c.title, '我是谁')
|
|
176
186
|
assert.equal(c.body, '我是一个测试。')
|
|
@@ -182,7 +192,7 @@ test('★ controls 不是快照:改完之后**再读一次**要看到新值',
|
|
|
182
192
|
// 旧标题**(用户看到"保存了又变回去")。而我当时注释写着"现算不缓存"。
|
|
183
193
|
// ⇒ 判据必须**跨两次读**才抓得住(只读一次的写法抓不到)。
|
|
184
194
|
const store = fakeStore({ categories: {} })
|
|
185
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
195
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
186
196
|
const before = (ext.controls ?? []).find((x) => x.id === 'ladder')!
|
|
187
197
|
assert.equal(before.title, '懒人梯子')
|
|
188
198
|
ext.setControlText?.('ladder', { title: '我的梯子' })
|
|
@@ -192,7 +202,7 @@ test('★ controls 不是快照:改完之后**再读一次**要看到新值',
|
|
|
192
202
|
|
|
193
203
|
test('setControlText 只写覆盖值,且不碰 categories(同块的另一种数据)', () => {
|
|
194
204
|
const store = fakeStore({ categories: { identity: false }, keep: 'me' })
|
|
195
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
205
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
196
206
|
assert.equal(ext.setControlText?.('identity', { title: '新标题' }), true)
|
|
197
207
|
const after = store.readData<Record<string, unknown>>('')
|
|
198
208
|
assert.deepEqual(after?.categories, { identity: false }, '改标题把开关状态冲掉了')
|
|
@@ -207,7 +217,7 @@ test('★ 改一条**不碰其它条**的覆盖(整块重写 text 会静默丢
|
|
|
207
217
|
categories: {},
|
|
208
218
|
text: { identity: { title: 'A' }, scope: { body: 'B' } },
|
|
209
219
|
})
|
|
210
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
220
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
211
221
|
ext.setControlText?.('ladder', { body: '新内容' })
|
|
212
222
|
assert.deepEqual(store.readData<Record<string, unknown>>('')?.text, {
|
|
213
223
|
identity: { title: 'A' },
|
|
@@ -218,7 +228,7 @@ test('★ 改一条**不碰其它条**的覆盖(整块重写 text 会静默丢
|
|
|
218
228
|
|
|
219
229
|
test('改同一条的第二个字段 ⇒ 第一个字段仍在(部分更新,不是替换)', () => {
|
|
220
230
|
const store = fakeStore({ categories: {}, text: { identity: { title: 'A' } } })
|
|
221
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
231
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
222
232
|
ext.setControlText?.('identity', { body: 'B' })
|
|
223
233
|
assert.deepEqual(store.readData<Record<string, unknown>>('')?.text, {
|
|
224
234
|
identity: { title: 'A', body: 'B' },
|
|
@@ -227,7 +237,7 @@ test('改同一条的第二个字段 ⇒ 第一个字段仍在(部分更新,
|
|
|
227
237
|
|
|
228
238
|
test('注入:空标题/空正文返回 false(不写盘)', () => {
|
|
229
239
|
const store = fakeStore({ categories: {} })
|
|
230
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
240
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
231
241
|
for (const bad of [{ title: ' ' }, { title: '' }, { body: '' }]) {
|
|
232
242
|
assert.equal(ext.setControlText?.('identity', bad), false, `空串被接受了:${JSON.stringify(bad)}`)
|
|
233
243
|
}
|
|
@@ -236,7 +246,7 @@ test('注入:空标题/空正文返回 false(不写盘)', () => {
|
|
|
236
246
|
|
|
237
247
|
test('注入:未知 controlId 返回 false', () => {
|
|
238
248
|
const store = fakeStore({ categories: {} })
|
|
239
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
249
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
240
250
|
assert.equal(ext.setControlText?.('__evil__', { title: 'x' }), false)
|
|
241
251
|
})
|
|
242
252
|
|
|
@@ -262,7 +272,7 @@ test('★ controls 走合成列表:自建条目出现、标 custom、内置标
|
|
|
262
272
|
custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }],
|
|
263
273
|
text: { ladder: { body: '改过的' } },
|
|
264
274
|
})
|
|
265
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
275
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
266
276
|
const list = ext.controls ?? []
|
|
267
277
|
assert.equal(list.length, PROMPT_CATEGORIES.length + 1, '自建条目没出现')
|
|
268
278
|
assert.equal(list.find((c) => c.id === 'cu-1')?.custom, true)
|
|
@@ -272,7 +282,7 @@ test('★ controls 走合成列表:自建条目出现、标 custom、内置标
|
|
|
272
282
|
|
|
273
283
|
test('★ 新增自建条目 ⇒ 返回新 id,且落盘(id 不撞内置)', () => {
|
|
274
284
|
const store = fakeStore({ categories: {} })
|
|
275
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
285
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
276
286
|
const id = ext.addCustomControl?.('我的准则', '做完了就收。')
|
|
277
287
|
assert.ok(id !== null && typeof id === 'string', '没返回新 id')
|
|
278
288
|
assert.notEqual(id, 'identity', '新 id 撞上了内置条目')
|
|
@@ -281,7 +291,7 @@ test('★ 新增自建条目 ⇒ 返回新 id,且落盘(id 不撞内置)',
|
|
|
281
291
|
|
|
282
292
|
test('注入:空标题/空正文新增 ⇒ 返回 null(不写盘)', () => {
|
|
283
293
|
const store = fakeStore({ categories: {} })
|
|
284
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
294
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
285
295
|
assert.equal(ext.addCustomControl?.(' ', 'x'), null)
|
|
286
296
|
assert.equal(ext.addCustomControl?.('x', ''), null)
|
|
287
297
|
assert.equal(store.readData<Record<string, unknown>>('')?.custom, undefined, '失败的写入留下痕迹')
|
|
@@ -294,7 +304,7 @@ test('★ 删除只允许自定义:内置条目被拒、自建条目删掉并
|
|
|
294
304
|
order: ['cu-1', 'ladder'],
|
|
295
305
|
custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }],
|
|
296
306
|
})
|
|
297
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
307
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
298
308
|
assert.equal(ext.removeCustomControl?.('identity'), false, '内置条目不该被删(它是代码的一部分)')
|
|
299
309
|
assert.equal(ext.removeCustomControl?.('ladder'), false, '不存在的条目应被拒')
|
|
300
310
|
assert.equal(ext.removeCustomControl?.('cu-1'), true)
|
|
@@ -306,7 +316,7 @@ test('★ 删除只允许自定义:内置条目被拒、自建条目删掉并
|
|
|
306
316
|
|
|
307
317
|
test('★ 还原:只对被改过的内置条目成功,且回到内置文本', () => {
|
|
308
318
|
const store = fakeStore({ categories: {}, text: { ladder: { body: '改过的' } } })
|
|
309
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
319
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
310
320
|
assert.equal(ext.resetControlText?.('identity'), false, '没改过的内置条目不该「可还原」')
|
|
311
321
|
assert.equal(ext.resetControlText?.('cu-1'), false, '自建条目没有「内置版本」可回退')
|
|
312
322
|
assert.equal(ext.resetControlText?.('ladder'), true)
|
|
@@ -318,7 +328,7 @@ test('★ 还原:只对被改过的内置条目成功,且回到内置文本'
|
|
|
318
328
|
|
|
319
329
|
test('★ 排序:只存 id,且过滤掉不存在的 id(脏数据不留幽灵)', () => {
|
|
320
330
|
const store = fakeStore({ categories: {} })
|
|
321
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
331
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
322
332
|
assert.equal(ext.setControlOrder?.(['__ghost__', 'ladder', 'ladder']), true)
|
|
323
333
|
assert.deepEqual(store.readData<Record<string, unknown>>('')?.order, ['ladder'], '幽灵/重复 id 没被过滤')
|
|
324
334
|
})
|
|
@@ -330,7 +340,7 @@ test('★ 没有 data 记录时也能写("没改过" ≠ "不能写")', () =
|
|
|
330
340
|
// 当成"存储不可用"直接拒 ⇒ 「添加准则」永远报 `title and body are required`
|
|
331
341
|
// ——而 title/body 明明都传了,报错还指向错误的字段,排查成本极高。
|
|
332
342
|
const store = fakeStore(undefined) // **没有 data**
|
|
333
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
343
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
334
344
|
const id = ext.addCustomControl?.('我的准则', '做完就收。')
|
|
335
345
|
assert.ok(id !== null, '初始状态下添加失败(真实用户第一次用就是这个状态)')
|
|
336
346
|
assert.equal((ext.controls ?? []).some((c) => c.id === id), true, '写盘了但 controls 里没有')
|
|
@@ -338,7 +348,7 @@ test('★ 没有 data 记录时也能写("没改过" ≠ "不能写")', () =
|
|
|
338
348
|
|
|
339
349
|
test('★ 四个写入口在"无 data"时都可用(不只是 add)', () => {
|
|
340
350
|
const store = fakeStore(undefined)
|
|
341
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
351
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
342
352
|
assert.equal(ext.setControl?.('ladder', false), true, '开关在初始状态下不可用')
|
|
343
353
|
assert.equal(ext.setControlText?.('ladder', { body: 'x' }), true, '改文本在初始状态下不可用')
|
|
344
354
|
assert.equal(ext.setControlOrder?.(['ladder']), true, '排序在初始状态下不可用')
|
|
@@ -350,7 +360,7 @@ test('★ 还原:改过 ⇒ true(且覆盖被清掉);没改过 ⇒ false
|
|
|
350
360
|
// 而代码是对的。**假 store 比被守的东西更宽容 = 判据无效**(第三次栽在这)。
|
|
351
361
|
// ⇒ 假 store 必须**真的读回自己写的东西**:`fakeStore` 的 `box` 已在做。
|
|
352
362
|
const store = fakeStore({ categories: {} })
|
|
353
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
363
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
354
364
|
assert.equal(ext.resetControlText?.('identity'), false, '没改过的却说可还原')
|
|
355
365
|
ext.setControlText?.('ladder', { body: '改过的' })
|
|
356
366
|
assert.equal(ext.resetControlText?.('ladder'), true, '改过的却还原不了')
|
|
@@ -360,7 +370,7 @@ test('★ 还原:改过 ⇒ true(且覆盖被清掉);没改过 ⇒ false
|
|
|
360
370
|
// ── op 成功必须回整张表(2026-09-30)───────────────────────
|
|
361
371
|
test('★ 契约层:controls 上带 custom 标记(面板据此决定给删除还是还原)', () => {
|
|
362
372
|
const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
|
|
363
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
373
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
364
374
|
const mine = (ext.controls ?? []).find((c) => c.id === 'cu-1')
|
|
365
375
|
const builtin = (ext.controls ?? []).find((c) => c.id === 'identity')
|
|
366
376
|
assert.equal(mine?.custom, true, '自建条目没标 custom ⇒ 面板给"还原"而不是"删除"')
|
|
@@ -375,7 +385,7 @@ test('★ 自建条目的开关可切(判据要用合成列表,不能只用
|
|
|
375
385
|
// ⇒ 用户看到「自定义的开关控制报错」。而它**在 controls 列表里显示着** ——
|
|
376
386
|
// 列表用 `resolveCategories`、写入判据用 `PROMPT_CATEGORIES`,两处来源不一致。
|
|
377
387
|
const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
|
|
378
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
388
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
379
389
|
assert.equal((ext.controls ?? []).some((c) => c.id === 'cu-1'), true, '列表里没有它')
|
|
380
390
|
assert.equal(ext.setControl?.('cu-1', false), true, '列表里有、却切不了')
|
|
381
391
|
assert.equal(store.readData<Record<string, unknown>>('')?.categories && (store.readData<{ categories: Record<string, boolean> }>('')?.categories?.['cu-1']), false, '开关没落盘')
|
|
@@ -383,14 +393,14 @@ test('★ 自建条目的开关可切(判据要用合成列表,不能只用
|
|
|
383
393
|
|
|
384
394
|
test('★ 自建条目的文本可改(同一处来源不一致)', () => {
|
|
385
395
|
const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
|
|
386
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
396
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
387
397
|
assert.equal(ext.setControlText?.('cu-1', { title: '新标题' }), true, '自建条目改不了标题')
|
|
388
398
|
assert.equal((ext.controls ?? []).find((c) => c.id === 'cu-1')?.title, '新标题')
|
|
389
399
|
})
|
|
390
400
|
|
|
391
401
|
test('还原仍然只允许内置条目(自建没有「内置版本」可回退)', () => {
|
|
392
402
|
const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
|
|
393
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true, store })
|
|
403
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
394
404
|
assert.equal(ext.resetControlText?.('cu-1'), false, '自建条目不该能被「还原」')
|
|
395
405
|
})
|
|
396
406
|
|
|
@@ -418,7 +428,7 @@ test('★ 图标能被真实渲染(base64 必须解得出合法 SVG)', () =>
|
|
|
418
428
|
})
|
|
419
429
|
|
|
420
430
|
test('图标挂在 ext 上(不设的话面板退回默认闪电)', () => {
|
|
421
|
-
const ext = createPromptExt({ isSystemPromptReady: () => true })
|
|
431
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [] })
|
|
422
432
|
assert.equal(ext.icon, PROMPT_LOGO_URL)
|
|
423
433
|
})
|
|
424
434
|
|
package/src/ext-prompt/index.ts
CHANGED
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
* (宿主管的那个)。不置上的话,用户开了行还要再点一次,两级开关说同一件事。
|
|
16
16
|
*/
|
|
17
17
|
import type { Context } from '@deepseek-ai/cordis'
|
|
18
|
+
// ⚠️ **只为一个目的而 import type**:让 cordis 的 `Events` 增强
|
|
19
|
+
// (`system-prompt/assemble` / `system-prompt/change`)进入本文件所在的编译单元。
|
|
20
|
+
// 不 import 的话,`ctx.on('system-prompt/assemble', …)` 的参数不是
|
|
21
|
+
// `keyof Events` ⇒ **声明构建(tsconfig.build.json,host 半边)直接报错**,
|
|
22
|
+
// 而主 tsconfig 因为 include 了 `assembly.test.ts`(它 import 了同一个包)
|
|
23
|
+
// 恰好把增强带进来 ⇒ `tsc --noEmit` 全绿、`npm run build` 才红。
|
|
24
|
+
// 这类"两种配置下结论不同"的坑,靠显式依赖修,不靠碰巧。
|
|
25
|
+
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
18
26
|
import {
|
|
19
27
|
currentExts,
|
|
20
28
|
currentExtStore,
|
|
@@ -24,6 +32,7 @@ import {
|
|
|
24
32
|
import { createPromptExt, EXT_PROMPT_ID } from './plugin.ts'
|
|
25
33
|
import { mountPromptSection, type SystemPromptFace } from './mount.ts'
|
|
26
34
|
import type { PromptExtData } from './render.ts'
|
|
35
|
+
import { createSectionCache, disabledSectionNames, filterSections } from './sections.ts'
|
|
27
36
|
|
|
28
37
|
/** cordis 插件身份(配置树里这一行的技术名;显示名走同目录的 locale)。 */
|
|
29
38
|
export const name = 'dsh-router-ext-prompt'
|
|
@@ -76,7 +85,7 @@ export function apply(ctx: Context): void {
|
|
|
76
85
|
const table = currentExts(ctx)
|
|
77
86
|
const store = currentExtStore(ctx)
|
|
78
87
|
|
|
79
|
-
// ① 准则段落。`section()`
|
|
88
|
+
// ① 准则段落。`section()` 注册一个 **provider 函数**作为 text ——
|
|
80
89
|
// 每次装配现算,所以改开关立即生效,无需重注册。
|
|
81
90
|
const dispose = mountPromptSection({
|
|
82
91
|
getSystemPrompt: () => systemPromptOf(ctx),
|
|
@@ -84,13 +93,50 @@ export function apply(ctx: Context): void {
|
|
|
84
93
|
readData: () => store?.readData<PromptExtData>(EXT_PROMPT_ID),
|
|
85
94
|
})
|
|
86
95
|
|
|
87
|
-
// ②
|
|
96
|
+
// ② 段落总控:挂 `system-prompt/assemble` waterfall —— 每轮装配时
|
|
97
|
+
// ⑴ 先把**未过滤的全量段落**喂给清单缓存(面板的事实源,在过滤前取 ⇒
|
|
98
|
+
// 关掉的段仍列在面板上,不会"关了就消失、开不回来");
|
|
99
|
+
// ⑵ 再按落盘的关段表删掉关闭的段(宿主以监听器返回值为权威结果
|
|
100
|
+
// —— `assemble()` 的 `return transformed`,删掉的段不会被恢复)。
|
|
101
|
+
//
|
|
102
|
+
// ⚠️ 总开关关 ⇒ 原样放行:扩展关 = 本扩展什么都不做,宿主段落恢复
|
|
103
|
+
// 自然注入(与准则段"关总开关 ⇒ 段文本空 ⇒ 整段被丢"同一语义)。
|
|
104
|
+
// ⚠️ 监听器注册在**无 scope 标签**的 ctx 上,而派发走
|
|
105
|
+
// `scopeTarget`(dsh-scope):无标签监听器收一切 scope 的装配
|
|
106
|
+
// —— 这正是宿主"全局注册、参与所有 agent 装配"的通道,与准则
|
|
107
|
+
// section 进 prompt 同一条 scope 规则。
|
|
108
|
+
const cache = createSectionCache()
|
|
109
|
+
let disposeAssemble: (() => boolean) | undefined
|
|
110
|
+
let disposeChange: (() => boolean) | undefined
|
|
111
|
+
const sp = systemPromptOf(ctx)
|
|
112
|
+
if (sp) {
|
|
113
|
+
disposeAssemble = ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
|
|
114
|
+
const result = await next()
|
|
115
|
+
cache.update(result.sections)
|
|
116
|
+
if (store?.isEnabled(EXT_PROMPT_ID) !== true) return result
|
|
117
|
+
const off = disabledSectionNames(store.readData<PromptExtData>(EXT_PROMPT_ID))
|
|
118
|
+
if (off.size === 0) return result
|
|
119
|
+
return { ...result, sections: filterSections(result.sections, off) }
|
|
120
|
+
})
|
|
121
|
+
// 段在**没有新装配时**增删(插件开关变化等)⇒ 面板清单不会自己更新,
|
|
122
|
+
// 主动跑一次 assemble 刷新(它会再走上面的 waterfall ⇒ 缓存唯一写入点)。
|
|
123
|
+
disposeChange = ctx.on('system-prompt/change', () => {
|
|
124
|
+
void sp.assemble().catch(() => { /* 预热失败不致命:下次装配自然补上 */ })
|
|
125
|
+
})
|
|
126
|
+
// 预热:一次对话都还没发生时,面板也要有清单(无 scope 的 global 近似)。
|
|
127
|
+
void sp.assemble().catch(() => { /* 同上 */ })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ③ 面板登记。幂等:同一个 id 已在表里就不顶掉别人的登记。
|
|
88
131
|
if (table !== undefined && table[EXT_PROMPT_ID] === undefined) {
|
|
89
132
|
table[EXT_PROMPT_ID] = createPromptExt({
|
|
90
133
|
isSystemPromptReady: () => !!systemPromptOf(ctx),
|
|
91
134
|
// ⚠️ **store 必须在这时就能拿到**:拿不到的话面板上的分类开关显示可点、
|
|
92
135
|
// 点了 400 —— 「看起来能用但不能用」比「不显示」更难查。
|
|
93
136
|
store,
|
|
137
|
+
// 段清单来自 waterfall 缓存(若 ② 没挂上 ⇒ 空清单 ⇒ 段组不显示,
|
|
138
|
+
// 但不许这里给缺省 —— 见 createPromptExt 的 deps 注释)。
|
|
139
|
+
getSections: () => cache.list(),
|
|
94
140
|
})
|
|
95
141
|
store?.setEnabled(EXT_PROMPT_ID, true)
|
|
96
142
|
// 核心持有的是**同一个 live 对象**(它 provide 的空表),且 `/ext` 每次请求都
|
|
@@ -99,6 +145,8 @@ export function apply(ctx: Context): void {
|
|
|
99
145
|
|
|
100
146
|
ctx.effect(() => () => {
|
|
101
147
|
dispose()
|
|
148
|
+
disposeAssemble?.()
|
|
149
|
+
disposeChange?.()
|
|
102
150
|
if (table !== undefined) delete table[EXT_PROMPT_ID]
|
|
103
151
|
})
|
|
104
152
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import test from 'node:test'
|
|
9
9
|
import assert from 'node:assert/strict'
|
|
10
|
-
import { mountPromptSection, PROMPT_SECTION_NAME } from './mount.ts'
|
|
10
|
+
import { mountPromptSection, PROMPT_SECTION_NAME, OFFICIAL_LOWEST_SECTION_ORDER } from './mount.ts'
|
|
11
11
|
import { EXT_PROMPT_ID } from './plugin.ts'
|
|
12
12
|
|
|
13
13
|
/** 记录注册内容的假 systemPrompt。 */
|
|
@@ -25,6 +25,11 @@ function fakeSystemPrompt() {
|
|
|
25
25
|
disposals++
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
|
+
// 本文件测的是注册契约,不碰装配;给最小合法实现满足 `SystemPromptFace`
|
|
29
|
+
//(段清单与过滤的单测在 sections.test.ts / ext-prompt.test.ts)。
|
|
30
|
+
async assemble() {
|
|
31
|
+
return { sections: [] }
|
|
32
|
+
},
|
|
28
33
|
}
|
|
29
34
|
}
|
|
30
35
|
|
|
@@ -35,6 +40,18 @@ test('注册恰好一个段落,名字稳定', () => {
|
|
|
35
40
|
assert.equal(sp.registered[0]!.name, PROMPT_SECTION_NAME)
|
|
36
41
|
})
|
|
37
42
|
|
|
43
|
+
test('★ 段落 order 排在官方最低槽之前(2026-10-21 用户点名:准则放最前)', () => {
|
|
44
|
+
// 判据不写"是最小的"(谁将来再往前挪就误伤),写成**必须低于官方第一方最低槽**:
|
|
45
|
+
// 第一方开场白 `harness:identity` 在 -1000,别的部署可能没关它。
|
|
46
|
+
const sp = fakeSystemPrompt()
|
|
47
|
+
mountPromptSection({ getSystemPrompt: () => sp, isEnabled: () => true, readData: () => undefined })
|
|
48
|
+
const order = sp.registered[0]!.order
|
|
49
|
+
assert.ok(
|
|
50
|
+
order < OFFICIAL_LOWEST_SECTION_ORDER,
|
|
51
|
+
`order=${order} 未低于官方最低槽 ${OFFICIAL_LOWEST_SECTION_ORDER} ⇒ 准则排不到最前`,
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
|
|
38
55
|
test('⚠️ text 必须是 provider 函数而不是静态字符串(否则改开关不生效)', () => {
|
|
39
56
|
const sp = fakeSystemPrompt()
|
|
40
57
|
mountPromptSection({ getSystemPrompt: () => sp, isEnabled: () => true, readData: () => undefined })
|
package/src/ext-prompt/mount.ts
CHANGED
|
@@ -20,21 +20,36 @@ import { renderPromptText, type PromptExtData } from './render.ts'
|
|
|
20
20
|
/** `ctx.systemPrompt` 的最小可见面。 */
|
|
21
21
|
export interface SystemPromptFace {
|
|
22
22
|
section(section: { name: string; order: number; text: string | (() => string) }): () => void
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* 段落总控用(`index.ts` 的 waterfall 预热):宿主 `SystemPrompt.assemble()`。
|
|
25
|
+
* 只声明用得到的最小形状 —— 返回的 `sections` 已按 order/name 排好序。
|
|
26
|
+
*/
|
|
27
|
+
assemble(context?: Record<string, unknown>): Promise<{ sections: { name: string; text: string }[] }>
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
/** 段落名(**稳定**):改名会让已注册的作用域 section 与旧名并存。 */
|
|
27
31
|
export const PROMPT_SECTION_NAME = 'dsh-router-ext-prompt:guideline'
|
|
28
32
|
|
|
29
33
|
/**
|
|
30
|
-
* 段落 order
|
|
34
|
+
* 段落 order:**-2000 = 全场最前**(2026-10-21 用户点名「把准则放到最前」)。
|
|
35
|
+
*
|
|
36
|
+
* 官方分配的槽位(`dsh-system-prompt` 的 `SECTION_ORDERS`)最低一档是
|
|
37
|
+
* `HARNESS_IDENTITY: -1000`(第一方开场白 `You are an AI agent powered by
|
|
38
|
+
* DeepSeek Harness.`),其后才是 `DEPLOYMENT_PERSONA_PREFIX: 0`、`PLAN_POLICY: 500`
|
|
39
|
+
* … `HARNESS_SOURCE: 10000`。
|
|
31
40
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
41
|
+
* 取 **-2000**(低于官方最低槽 1000)而不是「正好 -1000」:
|
|
42
|
+
* - 撞在 -1000 上要靠**段名的字母序**决胜(`comparePromptSections` 是
|
|
43
|
+
* `order` 再按 `name`)—— 那是实现细节,改段名就翻盘,不作为位置依据;
|
|
44
|
+
* - 第一方那行在本部署被 `includeHarnessIdentity: false` 关掉了,但**别的
|
|
45
|
+
* 部署可能还开着** ⇒ 准则仍须排在它前面,不能假设它不存在。
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ `order` 只要求有限数(`Number.isFinite`),负数合法。
|
|
36
48
|
*/
|
|
37
|
-
const SECTION_ORDER =
|
|
49
|
+
const SECTION_ORDER = -2000
|
|
50
|
+
|
|
51
|
+
/** 官方 `SECTION_ORDERS` 里最低的那档(第一方开场白),测试用它当"最前"的判据。 */
|
|
52
|
+
export const OFFICIAL_LOWEST_SECTION_ORDER = -1000
|
|
38
53
|
|
|
39
54
|
export interface MountPromptDeps {
|
|
40
55
|
/** 取 `ctx.systemPrompt`(可能尚未就绪 ⇒ undefined)。 */
|
package/src/ext-prompt/plugin.ts
CHANGED
|
@@ -16,6 +16,15 @@ import type { ExtStoreService, RouterExt } from '../ext/contract.ts'
|
|
|
16
16
|
import { PROMPT_CATEGORIES, PROMPT_TITLE } from './content.ts'
|
|
17
17
|
import { PROMPT_LOGO_URL } from './logo.ts'
|
|
18
18
|
import { effectiveText, resolveCategories, resolveEnabledCategories, type PromptExtData } from './render.ts'
|
|
19
|
+
import {
|
|
20
|
+
OWN_SECTION_NAME,
|
|
21
|
+
disabledSectionNames,
|
|
22
|
+
groupLabelOf,
|
|
23
|
+
sectionControlId,
|
|
24
|
+
sectionNameOf,
|
|
25
|
+
sectionTitleOf,
|
|
26
|
+
type SectionEntry,
|
|
27
|
+
} from './sections.ts'
|
|
19
28
|
|
|
20
29
|
/** 从对象里去掉某个键(不改原对象)。`writeData` 是整块替换 ⇒ 删一个键要重写。 */
|
|
21
30
|
function omit<T extends Record<string, unknown>>(obj: T, key: string): Partial<T> {
|
|
@@ -41,6 +50,15 @@ export const EXT_PROMPT_ID = 'prompt'
|
|
|
41
50
|
export function createPromptExt(deps: {
|
|
42
51
|
isSystemPromptReady: () => boolean
|
|
43
52
|
store?: ExtStoreService
|
|
53
|
+
/**
|
|
54
|
+
* 当前已装配的段落清单(最近一次 `assemble` 的快照,来自 waterfall 缓存)。
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ **必选,不给缺省**:缺省成 `() => []` 的话,`index.ts` 忘接这个参数
|
|
57
|
+
* 就是"段清单恒空"的静默失效(面板无段、过滤不生效,但一切照常跑)——
|
|
58
|
+
* 让类型红比让它静默强。**空清单是合法值**(还没预热完/宿主一段都没有),
|
|
59
|
+
* 与"没接线"的区分靠这里不给缺省,不靠运行时猜。
|
|
60
|
+
*/
|
|
61
|
+
getSections: () => SectionEntry[]
|
|
44
62
|
}): RouterExt {
|
|
45
63
|
const store = deps.store
|
|
46
64
|
/**
|
|
@@ -92,7 +110,7 @@ export function createPromptExt(deps: {
|
|
|
92
110
|
const on = resolveEnabledCategories(true, data)
|
|
93
111
|
// ⚠️ 走 `resolveCategories`(内置 + 自建 + 排序),不是内置常量 ——
|
|
94
112
|
// 否则自建条目与拖动排序**只停在面板上**,不进 prompt。
|
|
95
|
-
|
|
113
|
+
const categories = resolveCategories(data).map((c) => {
|
|
96
114
|
const t = effectiveText(c.id, data?.text?.[c.id], c)
|
|
97
115
|
return {
|
|
98
116
|
id: c.id,
|
|
@@ -109,11 +127,56 @@ export function createPromptExt(deps: {
|
|
|
109
127
|
custom: c.custom,
|
|
110
128
|
overridden: c.overridden,
|
|
111
129
|
on: on.has(c.id),
|
|
130
|
+
// ⚠️ 准则**不标 group**:页头 `description` 已经是它的组头(+「添加」按钮),
|
|
131
|
+
// 再标一组就是同一件事显示两处(反查重复)。无组 = 平铺,行为不变。
|
|
112
132
|
}
|
|
113
133
|
})
|
|
134
|
+
// ② 宿主段落条目 —— 面板按 `group` 分节;`sortable: false` 不给拖
|
|
135
|
+
// (排序落盘不认段 id,拖了也是假交互);`editable` 缺省 = 只读
|
|
136
|
+
// (别人的段我们不提供改写,只开关)。
|
|
137
|
+
const off = disabledSectionNames(data)
|
|
138
|
+
const sections = deps.getSections()
|
|
139
|
+
// 防御过滤:自己的准则段恒不列(清单源已排除,这里再拦一道 ——
|
|
140
|
+
// 列了它就是"同一件事的第二个开关",两处状态必然漂移)。
|
|
141
|
+
.filter((s) => s.name !== OWN_SECTION_NAME)
|
|
142
|
+
.map((s) => ({
|
|
143
|
+
id: sectionControlId(s.name),
|
|
144
|
+
// 标题去掉组前缀(组名给了语境):组「工具说明」下的 `write`,
|
|
145
|
+
// 比孤零零的 `tool:write` 好读;完整名放 `detail`(报问题时可指认)。
|
|
146
|
+
title: sectionTitleOf(s.name),
|
|
147
|
+
on: !off.has(s.name),
|
|
148
|
+
detail: s.name,
|
|
149
|
+
// ⚠️ 空原文要**说清是空的**,不能给空串(2026-10-21 真机实测):
|
|
150
|
+
// `deployment:persona-prefix` / `persona-suffix` 的 provider 在未配置
|
|
151
|
+
// 人设时返回 "",面板上就是一条没有正文的行 —— 用户只能盲切。
|
|
152
|
+
// 这不是"忘了给原文",是这一段此刻**确实没内容**,说清楚才谈得上判断。
|
|
153
|
+
body: s.text === '' ? '(此段当前为空)' : s.text,
|
|
154
|
+
group: groupLabelOf(s.name),
|
|
155
|
+
sortable: false,
|
|
156
|
+
}))
|
|
157
|
+
return [...categories, ...sections]
|
|
114
158
|
},
|
|
115
159
|
setControl: (controlId, on) => {
|
|
116
160
|
if (!store) return false
|
|
161
|
+
// ---- 段落开关(`sec:` 前缀):写 `data.sections`,与分类开关分键 ----
|
|
162
|
+
const secName = sectionNameOf(controlId)
|
|
163
|
+
if (secName !== null) {
|
|
164
|
+
// 只认**当前清单里**的段(清单 = 装配事实),且永远不关自己那段
|
|
165
|
+
// (它已有总开关 + 分类开关,再给一个入口就是同一件事的两个开关)。
|
|
166
|
+
if (secName === OWN_SECTION_NAME) return false
|
|
167
|
+
if (!deps.getSections().some((s) => s.name === secName)) return false
|
|
168
|
+
const data = readData()
|
|
169
|
+
const sections = { ...(data.sections ?? {}) }
|
|
170
|
+
if (on) delete sections[secName]
|
|
171
|
+
else sections[secName] = false
|
|
172
|
+
const next: PromptExtData = { ...data }
|
|
173
|
+
// 只存覆盖值(见 `PromptExtData.sections`):全开时不留空对象键。
|
|
174
|
+
if (Object.keys(sections).length > 0) next.sections = sections
|
|
175
|
+
else delete next.sections
|
|
176
|
+
store.writeData(EXT_PROMPT_ID, next)
|
|
177
|
+
return true
|
|
178
|
+
}
|
|
179
|
+
// ---- 分类开关(原路径)----
|
|
117
180
|
// ⚠️ **只改这一个键,不重写整块**:整块重写会顺手把 data 抽屉里的其它字段
|
|
118
181
|
// 抹掉(`store.test.ts` 有一条「置开关不能把 data 冲掉」的同款纪律)。
|
|
119
182
|
const data = readData()
|
|
@@ -143,7 +143,7 @@ test('★ 渲染出来的第一行是整段标题(拆分时差点丢的就是
|
|
|
143
143
|
// ⚠️ 原文是 `[准则 …] ·身份气质:…`,标题在方括号里、**不属于任何单条分类**
|
|
144
144
|
// ⇒ 按分隔符切分时天然被丢掉
|
|
145
145
|
// (它只出现在 content.ts 头注,没进任何 body)。
|
|
146
|
-
//
|
|
146
|
+
// 丢了的后果:模型看到一堆**没有名字**的规则,不知道这套东西是什么。
|
|
147
147
|
const text = renderPromptText(true, undefined)
|
|
148
148
|
assert.ok(text.startsWith(PROMPT_TITLE), `第一行不是标题;开头是:${text.slice(0, 30)}`)
|
|
149
149
|
assert.equal(text.split('\n')[0], PROMPT_TITLE, '标题必须在最前面 —— 它要解释后面的规则')
|
package/src/ext-prompt/render.ts
CHANGED
|
@@ -25,7 +25,7 @@ export interface PromptExtData {
|
|
|
25
25
|
text?: Record<string, PromptCategoryOverride>
|
|
26
26
|
/**
|
|
27
27
|
* 用户自建的准则条目。**与内置的 `PROMPT_CATEGORIES` 分开存** ——
|
|
28
|
-
*
|
|
28
|
+
* 内置条目是**代码常量**(发版即定),混在一起就会出现"代码里有一条、
|
|
29
29
|
* 落盘里又有一条"的归属问题:升级时新加的内置条目会和用户的旧数据打架。
|
|
30
30
|
*
|
|
31
31
|
* 存的是**完整条目**(不是覆盖值):自建的东西没有"内置版本"可回退。
|
|
@@ -34,12 +34,23 @@ export interface PromptExtData {
|
|
|
34
34
|
/**
|
|
35
35
|
* 展示顺序:`[id, …]`,含内置与自建。
|
|
36
36
|
*
|
|
37
|
-
* ⚠️ **只存 id
|
|
37
|
+
* ⚠️ **只存 id 顺序,不重排内置数组本身**:内置条目的次序是 `content.ts`
|
|
38
38
|
* 里的代码顺序,它同时是"准则作为一份文档的阅读顺序";让用户拖动去改它,
|
|
39
39
|
* 等于让代码顺序随某个用户的操作漂移 —— 别人拿到新版本看到的次序就变了。
|
|
40
40
|
* 落盘只记"这次会话里按什么顺序显示",代码顺序永远是兜底。
|
|
41
41
|
*/
|
|
42
42
|
order?: string[]
|
|
43
|
+
/**
|
|
44
|
+
* 段落级开关:`{ [sectionName]: boolean }`,**只存关闭项**(`false` = 关)。
|
|
45
|
+
*
|
|
46
|
+
* 缺项 = 开(默认全开)——与 `text` 的"只存覆盖值"同一纪律:宿主的段落随
|
|
47
|
+
* 宿主/别的插件增删,存全文表就得在升级后逐条对账;只记"用户主动关了谁"
|
|
48
|
+
* 让新增段落自然落回默认(开),用户的选择不会被静默迁移。
|
|
49
|
+
*
|
|
50
|
+
* key 是**段的注册名**(如 `tool:write`),不是控制条 id(`sec:` 前缀是
|
|
51
|
+
* 传输层编码,落盘用裸名,见 sections.ts)。
|
|
52
|
+
*/
|
|
53
|
+
sections?: Record<string, boolean>
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
/** 一条分类上用户改过的字段(只记改了的那些)。 */
|
|
@@ -103,7 +114,7 @@ export function resolveEnabledCategories(
|
|
|
103
114
|
* 渲染成**一个**段落文本(多分类拼一起)。
|
|
104
115
|
*
|
|
105
116
|
* 为什么要拼成一段而不是一段一个:面板开关是"分类"粒度,而 prompt 读起来
|
|
106
|
-
* 是一段连续的准则 ——
|
|
117
|
+
* 是一段连续的准则 —— 分成多个 section 会让 KV cache 每个分类各占一段、
|
|
107
118
|
* 增删任意一个分类都让其后全部位移。这里**注册一个 section,文本由开关拼装**。
|
|
108
119
|
*
|
|
109
120
|
* @returns 关闭的分类不进结果;全关 ⇒ 空串(上游 `renderPrompt` 会丢弃空段)。
|
|
@@ -128,10 +139,10 @@ export function renderCategories(
|
|
|
128
139
|
// 且只在**至少有一条规则开启**时才出现 —— 全关时整段返回空串被平台丢弃,
|
|
129
140
|
// 留着标题就变成"有一套叫准则 v5 的东西"却一条规则都没有。
|
|
130
141
|
// 标题放在**前面**:它要解释后面这些规则是什么。
|
|
131
|
-
// ⚠️ **分类之间空一行**(2026-09-29 用户拍板)。单 `\n`
|
|
132
|
-
//
|
|
142
|
+
// ⚠️ **分类之间空一行**(2026-09-29 用户拍板)。单 `\n` 时各条规则在窄栏里
|
|
143
|
+
// 密排成一块,肉眼看不出是各自独立的规则 —— 那正是"全都跑成一行"的观感
|
|
133
144
|
// 来源(实测系统提示词里 `\n` 一直都在,是**排版**问题不是**内容**问题)。
|
|
134
|
-
//
|
|
145
|
+
// 空行让每条规则成为独立段落。代价只是分隔换行(十几个 token,可忽略),
|
|
135
146
|
// 且不改变前缀位置 ⇒ KV cache 行为不变。
|
|
136
147
|
// ⚠️ 标题由**显式参数**决定,**不靠 `categories === PROMPT_CATEGORIES` 这种
|
|
137
148
|
// 引用相等**(2026-09-29 实测踩过):接入自建条目后调用方传的是
|