dsh-router-core 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -15
- package/cordis.patch.yml +10 -0
- package/docs/ext.md +65 -3
- package/lib/client-registry.js +633 -50
- package/lib/client.js +633 -50
- package/lib/contract-D4BWZ7Xe.js +13 -0
- package/lib/ext-prompt/index.js +527 -0
- package/lib/ext-prompt/locale/en.json +6 -0
- package/lib/ext-prompt/locale/zh.json +6 -0
- package/lib/ext-prompt/package.json +5 -0
- package/lib/ext-test/index.js +1 -12
- package/lib/index.js +298 -119
- package/lib/types/ext/contract.d.ts +113 -0
- package/lib/types/ext-prompt/content.d.ts +57 -0
- package/lib/types/ext-prompt/index.d.ts +53 -0
- package/lib/types/ext-prompt/logo.d.ts +17 -0
- package/lib/types/ext-prompt/mount.d.ts +51 -0
- package/lib/types/ext-prompt/plugin.d.ts +32 -0
- package/lib/types/ext-prompt/render.d.ts +105 -0
- package/lib/types/host-base-url.d.ts +31 -0
- package/lib/types/host-version.d.ts +7 -8
- package/lib/types/index.d.ts +1 -1
- package/lib/types/llm/adapter.d.ts +6 -1
- package/lib/types/shared.d.ts +33 -0
- package/package.json +17 -7
- package/src/client/EndpointTab.tsx +15 -18
- package/src/client/ExtDetail.tsx +552 -9
- package/src/client/index.tsx +7 -28
- package/src/client/router.css +132 -62
- package/src/ext/contract.ts +111 -0
- package/src/ext-prompt/assembly.test.ts +79 -0
- package/src/ext-prompt/content.ts +174 -0
- package/src/ext-prompt/ext-prompt.test.ts +432 -0
- package/src/ext-prompt/index.ts +104 -0
- package/src/ext-prompt/locale/en.json +6 -0
- package/src/ext-prompt/locale/zh.json +6 -0
- package/src/ext-prompt/logo.svg +14 -0
- package/src/ext-prompt/logo.ts +18 -0
- package/src/ext-prompt/mount.test.ts +95 -0
- package/src/ext-prompt/mount.ts +79 -0
- package/src/ext-prompt/plugin.ts +223 -0
- package/src/ext-prompt/render.test.ts +256 -0
- package/src/ext-prompt/render.ts +216 -0
- package/src/ext-prompt/row.json +5 -0
- package/src/ext-route.test.ts +78 -0
- package/src/host-base-url.test.ts +129 -0
- package/src/host-base-url.ts +43 -0
- package/src/host-version.test.ts +21 -28
- package/src/host-version.ts +39 -39
- package/src/index.ts +230 -96
- package/src/llm/adapter.test.ts +19 -42
- package/src/llm/adapter.ts +32 -50
- package/src/shared.ts +34 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 纯逻辑单测 —— 分类开关的语义。
|
|
3
|
+
*
|
|
4
|
+
* 纪律:每条"注入违规必须失败"的用例都在这里。判据不靠自述,靠**注入反例**:
|
|
5
|
+
* 下面 `注入` 系列就是把已知坏输入喂进去、断言它**不会**被当成正常处理。
|
|
6
|
+
*/
|
|
7
|
+
import test from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import {
|
|
10
|
+
resolveEnabledCategories,
|
|
11
|
+
renderCategories,
|
|
12
|
+
renderPromptText,
|
|
13
|
+
resolveCategories,
|
|
14
|
+
knownCategoryIds,
|
|
15
|
+
type PromptExtData,
|
|
16
|
+
} from './render.ts'
|
|
17
|
+
import { PROMPT_CATEGORIES, PROMPT_TITLE, defaultToggles } from './content.ts'
|
|
18
|
+
|
|
19
|
+
// ── 分类表本身 ────────────────────────────────────────────────
|
|
20
|
+
test('分类 id 唯一(重名会让开关互相覆盖)', () => {
|
|
21
|
+
const ids = PROMPT_CATEGORIES.map((c) => c.id)
|
|
22
|
+
assert.equal(new Set(ids).size, ids.length, `重复 id:${ids.join(',')}`)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('每个分类正文非空(空正文 = 该分类注入了但什么都不说)', () => {
|
|
26
|
+
for (const c of PROMPT_CATEGORIES) {
|
|
27
|
+
assert.ok(c.body.trim().length > 0, `${c.id} 正文为空`)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('默认开关全覆盖,且默认开的分类占多数(准则骨架不能默认关)', () => {
|
|
32
|
+
const t = defaultToggles()
|
|
33
|
+
for (const id of knownCategoryIds()) assert.equal(typeof t[id], 'boolean')
|
|
34
|
+
const on = PROMPT_CATEGORIES.filter((c) => c.defaultOn).length
|
|
35
|
+
assert.ok(on >= PROMPT_CATEGORIES.length / 2, `默认开只有 ${on}/${PROMPT_CATEGORIES.length}`)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// ── 开关解析 ──────────────────────────────────────────────────
|
|
39
|
+
test('总开关关 ⇒ 一条都不渲染(不是"空段落",是完全没有)', () => {
|
|
40
|
+
const on = resolveEnabledCategories(false, undefined)
|
|
41
|
+
assert.equal(on.size, 0)
|
|
42
|
+
assert.equal(renderPromptText(false, undefined), '')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('总开关开、无数据 ⇒ 走 defaultOn', () => {
|
|
46
|
+
const on = resolveEnabledCategories(true, undefined)
|
|
47
|
+
for (const c of PROMPT_CATEGORIES) {
|
|
48
|
+
assert.equal(on.has(c.id), c.defaultOn, `${c.id} 解析与 defaultOn 不符`)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('用户显式关掉某分类 ⇒ 该分类不渲染(这是"可开关"的正向判据)', () => {
|
|
53
|
+
const data: PromptExtData = { categories: { identity: false } }
|
|
54
|
+
const on = resolveEnabledCategories(true, data)
|
|
55
|
+
assert.equal(on.has('identity'), false)
|
|
56
|
+
assert.equal(renderPromptText(true, data).includes('大肥鱼'), false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('用户显式开某分类 ⇒ 该分类渲染(默认关的分类也能被打开)', () => {
|
|
60
|
+
const data: PromptExtData = { categories: { identity: true } }
|
|
61
|
+
assert.equal(renderPromptText(true, data).includes('大肥鱼'), true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('false 与 undefined 语义不同:显式 false 不被 defaultOn 覆盖', () => {
|
|
65
|
+
// identity 默认开;用户显式关掉后,仍必须是关的
|
|
66
|
+
const on = resolveEnabledCategories(true, { categories: { identity: false } })
|
|
67
|
+
assert.equal(on.has('identity'), false, '显式 false 被 defaultOn 覆盖 = 用户选择被静默丢弃')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// ── 注入违规:坏输入不得被静默接受 ────────────────────────────
|
|
71
|
+
test('注入:categories 不是对象 ⇒ 当作没配,不炸不吞(total crash 是不合格行为)', () => {
|
|
72
|
+
for (const bad of [null, 'x', 42, [] as unknown]) {
|
|
73
|
+
const data = { categories: bad } as unknown as PromptExtData
|
|
74
|
+
// 直接调用:真抛了测试自然失败,不需要 doesNotThrow 再包一层。
|
|
75
|
+
const text = renderPromptText(true, data)
|
|
76
|
+
// 非法数据 ⇒ 回落到默认值,且仍然产出文本(不能因为数据坏就丢掉整个准则)
|
|
77
|
+
assert.ok(text.length > 0, `categories=${JSON.stringify(bad)} 导致准则整体消失`)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('注入:未知分类 id 被忽略,不进渲染(防止脏数据塞进 prompt)', () => {
|
|
82
|
+
const data: PromptExtData = {
|
|
83
|
+
categories: { identity: true, __evil__: true, '../../etc/passwd': true },
|
|
84
|
+
}
|
|
85
|
+
const on = resolveEnabledCategories(true, data)
|
|
86
|
+
assert.equal(on.has('__evil__'), false)
|
|
87
|
+
assert.equal(on.has('../../etc/passwd'), false)
|
|
88
|
+
const text = renderPromptText(true, data)
|
|
89
|
+
assert.equal(text.includes('__evil__'), false)
|
|
90
|
+
assert.equal(text.includes('passwd'), false)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('注入:非布尔真值("false" 字符串)不当作 true(否则用户写了字符串=静默反转选择)', () => {
|
|
94
|
+
// 注意:TS 类型上这是非法的,但落盘 JSON 不可信 —— 判据必须在运行时守住
|
|
95
|
+
const data = { categories: { identity: 'false' as unknown as boolean } }
|
|
96
|
+
const on = resolveEnabledCategories(true, data)
|
|
97
|
+
// 'false' 既非 undefined 也非 true ⇒ 不进 enabled(保守:不猜)
|
|
98
|
+
assert.equal(on.has('identity'), false, '字符串 "false" 被当成开启 = 用户选择被反转')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// ── 渲染 ──────────────────────────────────────────────────────
|
|
102
|
+
test('渲染:每个开启的分类正文都出现一次', () => {
|
|
103
|
+
const text = renderPromptText(true, undefined)
|
|
104
|
+
for (const c of PROMPT_CATEGORIES) {
|
|
105
|
+
if (!c.defaultOn) continue
|
|
106
|
+
const n = text.split(c.body).length - 1
|
|
107
|
+
assert.equal(n, 1, `${c.id} 出现 ${n} 次(期望 1)`)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('渲染:顺序与分类表一致(准则是有先后的执行流程)', () => {
|
|
112
|
+
const text = renderPromptText(true, undefined)
|
|
113
|
+
const positions = PROMPT_CATEGORIES
|
|
114
|
+
.filter((c) => c.defaultOn)
|
|
115
|
+
.map((c) => text.indexOf(c.body))
|
|
116
|
+
for (let i = 1; i < positions.length; i++) {
|
|
117
|
+
assert.ok(
|
|
118
|
+
positions[i]! > positions[i - 1]!,
|
|
119
|
+
`第 ${i} 个分类位置 ${positions[i]} 未排在第 ${i - 1} 个 ${positions[i - 1]} 之后`,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('渲染:全关 ⇒ 空串(空串会让上游丢弃该段,不是"注入一段空白")', () => {
|
|
125
|
+
const off: Record<string, boolean> = {}
|
|
126
|
+
for (const id of knownCategoryIds()) off[id] = false
|
|
127
|
+
assert.equal(renderPromptText(true, { categories: off }), '')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('注入:renderCategories 传外部分类表时只渲染其中启用的(可扩展性)', () => {
|
|
131
|
+
const custom = [
|
|
132
|
+
{ id: 'x', title: 'X', defaultOn: true, body: 'X-BODY' },
|
|
133
|
+
{ id: 'y', title: 'Y', defaultOn: true, body: 'Y-BODY' },
|
|
134
|
+
]
|
|
135
|
+
// ⚠️ `withTitle = false` 必须**显式**传:原先靠「传的不是 PROMPT_CATEGORIES
|
|
136
|
+
// 那个对象」来隐式决定加不加标题(`categories === PROMPT_CATEGORIES`)。
|
|
137
|
+
// 接入自建条目后调用方传的是合成列表,那个判定恒假 ⇒ 标题静默消失。
|
|
138
|
+
const out = renderCategories(new Set(['y']), custom, undefined, false)
|
|
139
|
+
assert.equal(out, 'Y-BODY')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('★ 渲染出来的第一行是整段标题(拆分时差点丢的就是它)', () => {
|
|
143
|
+
// ⚠️ 原文是 `[准则 …] ·身份气质:…`,标题在方括号里、**不属于任何单条分类**
|
|
144
|
+
// ⇒ 按分隔符切分时天然被丢掉
|
|
145
|
+
// (它只出现在 content.ts 头注,没进任何 body)。
|
|
146
|
+
// 丢了的后果:模型看到 14 条**没有名字**的规则,不知道这套东西是什么。
|
|
147
|
+
const text = renderPromptText(true, undefined)
|
|
148
|
+
assert.ok(text.startsWith(PROMPT_TITLE), `第一行不是标题;开头是:${text.slice(0, 30)}`)
|
|
149
|
+
assert.equal(text.split('\n')[0], PROMPT_TITLE, '标题必须在最前面 —— 它要解释后面的规则')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('全关时标题也不出现(不能有一套叫准则 v5 的东西却一条规则都没有)', () => {
|
|
153
|
+
const off: Record<string, boolean> = {}
|
|
154
|
+
for (const id of knownCategoryIds()) off[id] = false
|
|
155
|
+
const text = renderPromptText(true, { categories: off })
|
|
156
|
+
assert.equal(text, '', '只剩标题 = 一套空准则,比没有更糟')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// ── 面板编辑文本(2026-09-29)────────────────────────────────
|
|
160
|
+
test('★ 改过的正文真的进 prompt(不是只改了显示)', () => {
|
|
161
|
+
// ⚠️ 这是"编辑"这件事的**全部意义所在**:漏了这一步,面板改了字、prompt
|
|
162
|
+
// 里还是旧的,而用户会以为成功了 —— 最坏的"看起来成功"。
|
|
163
|
+
const data: PromptExtData = { text: { ladder: { body: '梯子:只走第一档。' } } }
|
|
164
|
+
const text = renderPromptText(true, data)
|
|
165
|
+
assert.ok(text.includes('梯子:只走第一档。'), '编辑后的正文没进 prompt')
|
|
166
|
+
assert.equal(text.includes('停在第一个成立的档'), false, '旧的正文还在')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('改过的标题不影响正文(两个字段各管各的)', () => {
|
|
170
|
+
const data: PromptExtData = { text: { identity: { title: '我是谁' } } }
|
|
171
|
+
const text = renderPromptText(true, data)
|
|
172
|
+
assert.ok(text.includes('大肥鱼'), '只改标题不该动正文')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('注入:空串覆盖被忽略(不能把一条规则"变成空的")', () => {
|
|
176
|
+
const data: PromptExtData = { text: { ladder: { body: '' } } }
|
|
177
|
+
const text = renderPromptText(true, data)
|
|
178
|
+
assert.ok(text.includes('停在第一个成立的档'), '空串把这条规则清空了 —— 看起来像被关了')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('未改过的分类不出现在 text 里(内置内容仍是唯一事实源)', () => {
|
|
182
|
+
// 只存覆盖值:用户改一个字就永久冻结整段是反效果,内置更新要能继续生效
|
|
183
|
+
const data: PromptExtData = { text: { identity: { body: '我是谁。' } } }
|
|
184
|
+
const text = renderPromptText(true, data)
|
|
185
|
+
assert.ok(text.includes('停在第一个成立的档'), '只改了一条,其余应仍跟内置走')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('注入:text 不是对象时不炸(脏数据不该让整段准则消失)', () => {
|
|
189
|
+
for (const bad of ['x', 42, []] as unknown[]) {
|
|
190
|
+
const data = { text: bad } as unknown as PromptExtData
|
|
191
|
+
const text = renderPromptText(true, data)
|
|
192
|
+
assert.ok(text.includes('停在第一个成立的档'), `text=${JSON.stringify(bad)} 把准则弄没了`)
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// ── 排序 / 自建 / 还原(2026-09-29)─────────────────────────
|
|
197
|
+
test('★ 自建条目真的进 prompt(不是只出现在面板上)', () => {
|
|
198
|
+
const data: PromptExtData = { custom: [{ id: 'my-1', title: '我的准则', defaultOn: true, body: '我的准则:做完就修。' }] }
|
|
199
|
+
const text = renderPromptText(true, data)
|
|
200
|
+
assert.ok(text.includes('我的准则:做完就修。'), '自建条目没进 prompt')
|
|
201
|
+
assert.ok(text.indexOf('我的准则') > text.indexOf('执行:'), '自建条目应排在内置之后')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('★ 拖动排序真的改变 prompt 里的顺序', () => {
|
|
205
|
+
const ids = PROMPT_CATEGORIES.map((c) => c.id)
|
|
206
|
+
const flipped = [...ids].reverse()
|
|
207
|
+
const text = renderPromptText(true, { order: flipped })
|
|
208
|
+
assert.ok(
|
|
209
|
+
text.indexOf('执行:') < text.indexOf('身份气质:'),
|
|
210
|
+
'倒序后 prompt 里顺序没变 ⇒ 拖动只改了面板',
|
|
211
|
+
)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('★ 还原(删掉覆盖)后回到内置文本', () => {
|
|
215
|
+
const edited = renderPromptText(true, { text: { ladder: { body: '改过的' } } })
|
|
216
|
+
assert.ok(edited.includes('改过的'))
|
|
217
|
+
const restored = renderPromptText(true, {}) // 还原 = data.text 里没有该键
|
|
218
|
+
assert.ok(!restored.includes('改过的'))
|
|
219
|
+
assert.ok(restored.includes('停在第一个成立的档'))
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('注入:order 里的未知 id 被忽略,不影响其它条目', () => {
|
|
223
|
+
const text = renderPromptText(true, { order: ['__nope__', 'ladder', 'identity'] })
|
|
224
|
+
assert.ok(text.includes('停在第一个成立的档'), '合法条目被脏 id 连累掉了')
|
|
225
|
+
assert.equal(text.includes('__nope__'), false)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('注入:order 漏掉的条目补在后面(丢一条 ≠ 它消失)', () => {
|
|
229
|
+
const text = renderPromptText(true, { order: ['ladder'] })
|
|
230
|
+
// ⚠️ 只查**默认开启**的那些:万一将来加进默认关的分类(如某天新增的实验项),
|
|
231
|
+
// 它本就不该出现在 prompt 里,否则这条判据会把它误判成"被 order 吞了"。
|
|
232
|
+
// (这条判据第一次红就是因为假设了"全部开启"—— 比被守的逻辑宽;
|
|
233
|
+
// 当年的例子是 `ocr` 那条分类,它 2026-09-30 已整条移出内置,见 git 历史。)
|
|
234
|
+
for (const c of PROMPT_CATEGORIES) {
|
|
235
|
+
if (!c.defaultOn) continue
|
|
236
|
+
assert.ok(text.includes(c.body.slice(0, 8)), `${c.id} 在 order 里缺席就消失了`)
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('注入:脏的 custom 条目(空 id / 空正文)被过滤', () => {
|
|
241
|
+
const data = { custom: [
|
|
242
|
+
{ id: '', title: 'x', defaultOn: true, body: '空 id' },
|
|
243
|
+
{ id: 'ok', title: 'y', defaultOn: true, body: '' },
|
|
244
|
+
{ id: 'good', title: 'z', defaultOn: true, body: '好的' },
|
|
245
|
+
] } as unknown as PromptExtData
|
|
246
|
+
const text = renderPromptText(true, data)
|
|
247
|
+
assert.ok(text.includes('好的'), '合法自建条目被脏数据连累')
|
|
248
|
+
assert.equal(text.includes('空 id'), false)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('resolveCategories:内置在前、自建在后(代码顺序不被用户拖动改写)', () => {
|
|
252
|
+
const list = resolveCategories({ custom: [{ id: 'c1', title: 'C', defaultOn: true, body: 'B' }] })
|
|
253
|
+
assert.equal(list[list.length - 1]?.id, 'c1', '自建条目应排在最后')
|
|
254
|
+
assert.equal(list[0]?.custom, false)
|
|
255
|
+
assert.equal(list[list.length - 1]?.custom, true)
|
|
256
|
+
})
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 纯逻辑层 —— 分类开关的读取、校验与 prompt 文本渲染。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成文件:**不 import cordis / 任何 DSH 包**,所以能被 `node --test`
|
|
5
|
+
* 直接跑。DSH 侧的注册/挂载(ext.ts / inject.ts)只是薄壳;所有会出错的判断
|
|
6
|
+
* 都在这里,且这里能注入违规用例证明它们会变红。
|
|
7
|
+
*/
|
|
8
|
+
import { PROMPT_CATEGORIES, PROMPT_TITLE, type PromptCategory } from './content.ts'
|
|
9
|
+
|
|
10
|
+
/** 落盘在 `router.extStore` 数据块里的形状。 */
|
|
11
|
+
export interface PromptExtData {
|
|
12
|
+
/** 分类开关:`{ [categoryId]: boolean }`。缺项走 `defaultOn`。 */
|
|
13
|
+
categories?: Record<string, boolean>
|
|
14
|
+
/**
|
|
15
|
+
* 用户在面板里改过的文本:`{ [categoryId]: { title?, body? } }`。
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ **与 `categories` 分开两个键**:开关是"开不开"、文本是"写成什么样"。
|
|
18
|
+
* 挤在一个键里,改标题会顺手碰到开关状态(`writeData` 是**整块替换**,
|
|
19
|
+
* 少一个键就是少一份数据)。
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ **只存覆盖值,不存全文**:没改过的分类不出现在这里 ⇒ 内置内容仍是唯一
|
|
22
|
+
* 事实源,升级内置文案时没改过的那几条会跟着更新。存全文 = 用户改一个字
|
|
23
|
+
* 就永久冻结了整段,之后内置更新对它们完全失效。
|
|
24
|
+
*/
|
|
25
|
+
text?: Record<string, PromptCategoryOverride>
|
|
26
|
+
/**
|
|
27
|
+
* 用户自建的准则条目。**与内置的 `PROMPT_CATEGORIES` 分开存** ——
|
|
28
|
+
* 内置那 14 条是**代码常量**(发版即定),混在一起就会出现"代码里有一条、
|
|
29
|
+
* 落盘里又有一条"的归属问题:升级时新加的内置条目会和用户的旧数据打架。
|
|
30
|
+
*
|
|
31
|
+
* 存的是**完整条目**(不是覆盖值):自建的东西没有"内置版本"可回退。
|
|
32
|
+
*/
|
|
33
|
+
custom?: PromptCategory[]
|
|
34
|
+
/**
|
|
35
|
+
* 展示顺序:`[id, …]`,含内置与自建。
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ **只存 id 顺序,不重排内置数组本身**:内置 14 条的次序是 `content.ts`
|
|
38
|
+
* 里的代码顺序,它同时是"准则作为一份文档的阅读顺序";让用户拖动去改它,
|
|
39
|
+
* 等于让代码顺序随某个用户的操作漂移 —— 别人拿到新版本看到的次序就变了。
|
|
40
|
+
* 落盘只记"这次会话里按什么顺序显示",代码顺序永远是兜底。
|
|
41
|
+
*/
|
|
42
|
+
order?: string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 一条分类上用户改过的字段(只记改了的那些)。 */
|
|
46
|
+
export interface PromptCategoryOverride {
|
|
47
|
+
title?: string
|
|
48
|
+
body?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 解出某分类**生效用**的文本:用户覆盖优先,否则用内置的。 */
|
|
52
|
+
export function effectiveText(
|
|
53
|
+
id: string,
|
|
54
|
+
override: PromptCategoryOverride | undefined,
|
|
55
|
+
base: PromptCategory,
|
|
56
|
+
): { title: string; body: string } {
|
|
57
|
+
return {
|
|
58
|
+
// ⚠️ 空串是**非法**覆盖(写入端已挡),万一脏数据进来了也不能让它把
|
|
59
|
+
// 分类"变成空的" —— 那会让那条规则凭空消失,看起来像被关了。
|
|
60
|
+
title: override?.title !== undefined && override.title !== '' ? override.title : base.title,
|
|
61
|
+
body: override?.body !== undefined && override.body !== '' ? override.body : base.body,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 分类 id 的全集(校验用)。 */
|
|
66
|
+
export function knownCategoryIds(): string[] {
|
|
67
|
+
return PROMPT_CATEGORIES.map((c) => c.id)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 解出「哪些分类要渲染」。
|
|
72
|
+
*
|
|
73
|
+
* 三层语义,**顺序不可换**:
|
|
74
|
+
* ① 总开关关 ⇒ 一条都不渲染(不是"渲染但没内容"——那样面板显示已开启、实际无效果);
|
|
75
|
+
* ② 数据块里显式写了的分类,用**用户存的值**(含显式 `false`);
|
|
76
|
+
* ③ 没写过的分类,用 `defaultOn`。
|
|
77
|
+
*
|
|
78
|
+
* ⚠️ 为什么 ② 要区别于「没写」:`false` 与 `undefined` 语义完全不同 ——
|
|
79
|
+
* 前者是用户主动关掉,后者是"没碰过"。把两者当同一件事,用户第一次关掉某分类
|
|
80
|
+
* 后升级插件、新增同名分类,那条就永远是关的(用户没要求过)。
|
|
81
|
+
*/
|
|
82
|
+
export function resolveEnabledCategories(
|
|
83
|
+
masterOn: boolean,
|
|
84
|
+
data: PromptExtData | undefined,
|
|
85
|
+
): Set<string> {
|
|
86
|
+
const out = new Set<string>()
|
|
87
|
+
if (!masterOn) return out
|
|
88
|
+
const saved = data?.categories
|
|
89
|
+
// ⚠️ 遍历的是**合成后的列表**(内置 + 自建),不是内置常量 ——
|
|
90
|
+
// 否则自建条目的开关永远算不出来(它不在 PROMPT_CATEGORIES 里)。
|
|
91
|
+
for (const c of resolveCategories(data)) {
|
|
92
|
+
const v = saved ? saved[c.id] : undefined
|
|
93
|
+
if (v === undefined) {
|
|
94
|
+
if (c.defaultOn) out.add(c.id)
|
|
95
|
+
} else if (v === true) {
|
|
96
|
+
out.add(c.id)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 渲染成**一个**段落文本(多分类拼一起)。
|
|
104
|
+
*
|
|
105
|
+
* 为什么要拼成一段而不是一段一个:面板开关是"分类"粒度,而 prompt 读起来
|
|
106
|
+
* 是一段连续的准则 —— 分成 14 个 section 会让 KV cache 每个分类各占一段、
|
|
107
|
+
* 增删任意一个分类都让其后全部位移。这里**注册一个 section,文本由开关拼装**。
|
|
108
|
+
*
|
|
109
|
+
* @returns 关闭的分类不进结果;全关 ⇒ 空串(上游 `renderPrompt` 会丢弃空段)。
|
|
110
|
+
*/
|
|
111
|
+
export function renderCategories(
|
|
112
|
+
enabled: ReadonlySet<string>,
|
|
113
|
+
categories: readonly PromptCategory[] = PROMPT_CATEGORIES,
|
|
114
|
+
overrides?: PromptExtData['text'],
|
|
115
|
+
/** 有没有加整段标题。默认 true;传**外部分类表**时传 false(见下方注释)。 */
|
|
116
|
+
withTitle = true,
|
|
117
|
+
): string {
|
|
118
|
+
const lines: string[] = []
|
|
119
|
+
for (const c of categories) {
|
|
120
|
+
if (!enabled.has(c.id)) continue
|
|
121
|
+
// ⚠️ **这里用覆盖后的 body** —— 面板改的内容必须真的进 prompt。
|
|
122
|
+
// 漏了这一行,编辑就只是改了**显示**(用户会以为改了却没生效,
|
|
123
|
+
// 而 prompt 里还是旧的 ⇒ 最坏的一种"看起来成功了")。
|
|
124
|
+
// 自建条目没有覆盖(`text` 里不会有它的键),`effectiveText` 走原值。
|
|
125
|
+
lines.push(effectiveText(c.id, overrides?.[c.id], c).body)
|
|
126
|
+
}
|
|
127
|
+
// ⚠️ **标题放在最后 join 之前**:它不属于任何分类(见 PROMPT_TITLE 的注释),
|
|
128
|
+
// 且只在**至少有一条规则开启**时才出现 —— 全关时整段返回空串被平台丢弃,
|
|
129
|
+
// 留着标题就变成"有一套叫准则 v5 的东西"却一条规则都没有。
|
|
130
|
+
// 标题放在**前面**:它要解释后面这些规则是什么。
|
|
131
|
+
// ⚠️ **分类之间空一行**(2026-09-29 用户拍板)。单 `\n` 时 15 条规则在窄栏里
|
|
132
|
+
// 密排成一块,肉眼看不出是 15 条独立的规则 —— 那正是"全都跑成一行"的观感
|
|
133
|
+
// 来源(实测系统提示词里 `\n` 一直都在,是**排版**问题不是**内容**问题)。
|
|
134
|
+
// 空行让每条规则成为独立段落。代价是 13 个换行符(~13 token,可忽略),
|
|
135
|
+
// 且不改变前缀位置 ⇒ KV cache 行为不变。
|
|
136
|
+
// ⚠️ 标题由**显式参数**决定,**不靠 `categories === PROMPT_CATEGORIES` 这种
|
|
137
|
+
// 引用相等**(2026-09-29 实测踩过):接入自建条目后调用方传的是
|
|
138
|
+
// `resolveCategories(data)` 合成列表,引用永远不相等 ⇒ 标题静默消失
|
|
139
|
+
// (测试当场抓到)。「是不是默认那张表」是个**语义问题**,不该由
|
|
140
|
+
// 「是不是同一个对象」这种实现细节决定。
|
|
141
|
+
if (lines.length === 0) return ''
|
|
142
|
+
return withTitle ? [PROMPT_TITLE, ...lines].join('\n\n') : lines.join('\n\n')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 渲染当前应注入的完整 prompt 文本(总开关 + 分类开关都算进去)。
|
|
147
|
+
*
|
|
148
|
+
* 总开关关 ⇒ 空串,让上游 `renderPrompt` 丢弃该段 —— **不是**注入一段空白。
|
|
149
|
+
*/
|
|
150
|
+
export function renderPromptText(masterOn: boolean, data: PromptExtData | undefined): string {
|
|
151
|
+
if (!masterOn) return ''
|
|
152
|
+
// ⚠️ 传入 `resolveCategories(data)`(含自建 + 排序)而不是常量
|
|
153
|
+
// `PROMPT_CATEGORIES` —— 否则拖动排序和自建条目都只停在面板上,不进 prompt。
|
|
154
|
+
return renderCategories(
|
|
155
|
+
resolveEnabledCategories(masterOn, data),
|
|
156
|
+
resolveCategories(data),
|
|
157
|
+
data?.text,
|
|
158
|
+
true, // 合成列表也是本扩展的条目集,照样加标题
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 合成后的**一条**准则条目:内置或自建,已应用标题/正文覆盖。
|
|
164
|
+
* `builtin` 用来决定按钮(内置没有「删除」,只有「还原」)。
|
|
165
|
+
*/
|
|
166
|
+
export interface ResolvedCategory extends PromptCategory {
|
|
167
|
+
/** 自建(用户加的)——可删除;内置的不可删除。 */
|
|
168
|
+
readonly custom: boolean
|
|
169
|
+
/** 文本被用户改过(决定「还原」按钮是否可点)。 */
|
|
170
|
+
readonly overridden: boolean
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 内置 + 自建 + 排序 ⇒ 有序的最终条目列表。
|
|
175
|
+
*
|
|
176
|
+
* ⚠️ 三条规则(顺序不可换):
|
|
177
|
+
* 1. **内置在前、自建在后**,除非 `order` 显式给了别的次序。代码顺序是
|
|
178
|
+
* "准则作为一份文档的阅读顺序",不该被用户拖动改掉(见 `PromptExtData.order`)。
|
|
179
|
+
* 2. `order` 里**不认识的 id 一律忽略**,落盘不可信(手改的 ext.json、上一版的残渣)。
|
|
180
|
+
* 3. `order` **没提到的条目按原序补在后面** —— 丢了某条 ≠ 它消失了。
|
|
181
|
+
*/
|
|
182
|
+
export function resolveCategories(data: PromptExtData | undefined): ResolvedCategory[] {
|
|
183
|
+
const custom = Array.isArray(data?.custom) ? data.custom : []
|
|
184
|
+
// 自建条目也要过滤:脏数据里的空 id / 空正文会让渲染产出空白段落。
|
|
185
|
+
const validCustom = custom.filter(
|
|
186
|
+
(c) => !!c && typeof c.id === 'string' && c.id !== '' && typeof c.body === 'string' && c.body !== '',
|
|
187
|
+
)
|
|
188
|
+
const base: ResolvedCategory[] = [
|
|
189
|
+
...PROMPT_CATEGORIES.map((c) => ({
|
|
190
|
+
...c,
|
|
191
|
+
custom: false,
|
|
192
|
+
overridden: data?.text?.[c.id] !== undefined,
|
|
193
|
+
})),
|
|
194
|
+
...validCustom.map((c) => ({
|
|
195
|
+
...c,
|
|
196
|
+
title: c.title,
|
|
197
|
+
defaultOn: c.defaultOn !== false,
|
|
198
|
+
custom: true,
|
|
199
|
+
overridden: false, // 自建条目没有"内置版本",改了就回不去
|
|
200
|
+
})),
|
|
201
|
+
]
|
|
202
|
+
const order = Array.isArray(data?.order) ? data.order : []
|
|
203
|
+
if (order.length === 0) return base
|
|
204
|
+
const byId = new Map(base.map((c) => [c.id, c]))
|
|
205
|
+
const out: ResolvedCategory[] = []
|
|
206
|
+
for (const id of order) {
|
|
207
|
+
const hit = byId.get(id)
|
|
208
|
+
if (hit === undefined) continue // 规则 2
|
|
209
|
+
out.push(hit)
|
|
210
|
+
byId.delete(id)
|
|
211
|
+
}
|
|
212
|
+
for (const c of base) {
|
|
213
|
+
if (byId.has(c.id)) out.push(c) // 规则 3
|
|
214
|
+
}
|
|
215
|
+
return out
|
|
216
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/router/api/ext` 路由的**出口判据**。
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ 为什么需要它(2026-09-30 实测的「扩展一直加载中、然后卡死」):
|
|
5
|
+
* 我重构 op 分支时以为「成功路径统一落到末尾响应」,把 GET 的
|
|
6
|
+
* `writeJson(res, 200, {ok:true, enhancers:list})` 删了 —— 结果**没有任何地方
|
|
7
|
+
* 写响应** ⇒ 请求永不结束 ⇒ 面板一直转圈。
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ **它躲过了全部现有闸门**:typecheck 通过(`writeJson` 少调一次不是类型错误)、
|
|
10
|
+
* 387 条单测全绿(没有一条从 HTTP 入口打过这个路由)、build 通过。
|
|
11
|
+
* 典型的「绕过入口的验证 = 没验入口」—— 判据全在函数级,缺一条**路由级**的。
|
|
12
|
+
*
|
|
13
|
+
* 做法:不启真服务器,直接读源码断言**每个 return 之前都有出口**这种形状 ——
|
|
14
|
+
* 太脆。改为**结构判据**:GET 与 PATCH 两条成功路径都必须能走到 `writeJson`。
|
|
15
|
+
*/
|
|
16
|
+
import test from 'node:test'
|
|
17
|
+
import assert from 'node:assert/strict'
|
|
18
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
19
|
+
|
|
20
|
+
const src = readFileSync(new URL('./index.ts', import.meta.url), 'utf8')
|
|
21
|
+
|
|
22
|
+
/** 取出 `/ext` 路由的整段源码。 */
|
|
23
|
+
function extRouteBody(): string {
|
|
24
|
+
const start = src.indexOf('route(`${ROUTER_API_BASE}/ext`')
|
|
25
|
+
assert.ok(start > 0, '找不到 /ext 路由')
|
|
26
|
+
// 到下一个 `route(` 之前
|
|
27
|
+
const next = src.indexOf('\n route(', start + 10)
|
|
28
|
+
return src.slice(start, next > 0 ? next : start + 4000)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('★ /ext 路由必须有 GET 的响应出口(少写一次 = 请求永不结束)', () => {
|
|
32
|
+
const body = extRouteBody()
|
|
33
|
+
const writes = body.match(/writeJson\(res, 2\d\d/g) ?? []
|
|
34
|
+
assert.ok(writes.length >= 1,
|
|
35
|
+
'/ext 路由里一处 writeJson 都没有 ⇒ 请求永不返回(面板一直"加载中")')
|
|
36
|
+
// GET 成功路径:算完 list 之后必须写出
|
|
37
|
+
const listLine = body.indexOf('const list: ExtInfo[]')
|
|
38
|
+
assert.ok(listLine > 0, '找不到 GET 的 list 计算')
|
|
39
|
+
const after = body.slice(listLine)
|
|
40
|
+
// ⚠️ 窗口取 600 而非 200:出口上方有 5 行注释,窗口太窄会**误判成缺失**
|
|
41
|
+
// (我第一版 200 就红了,而代码是好的 —— 又一次「判据比被守的东西严」)。
|
|
42
|
+
assert.match(after.slice(0, 600), /writeJson\(res,\s*200/,
|
|
43
|
+
'GET 算完 list 之后没有 writeJson ⇒ 响应不出口')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('★ op 成功路径也必须带 enhancers(客户端靠它刷新)', () => {
|
|
47
|
+
const body = extRouteBody()
|
|
48
|
+
// op 块里那处:两侧都带 enhancers
|
|
49
|
+
const opWrites = body.match(/writeJson\(res,\s*200,\s*addedId[^)]*enhancers/g) ?? []
|
|
50
|
+
assert.ok(opWrites.length >= 1, 'op 成功没有回 enhancers ⇒ 增删改后界面停在旧状态')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('注入:删掉 GET 的 writeJson ⇒ 本判据会红', () => {
|
|
54
|
+
// 反向自检:把出口拿掉,上面那条必须不成立。
|
|
55
|
+
const stripped = extRouteBody().replace(
|
|
56
|
+
/writeJson\(res,\s*200,\s*\{\s*ok:\s*true,\s*enhancers:\s*list\s*\}\)/,
|
|
57
|
+
'/* removed */',
|
|
58
|
+
)
|
|
59
|
+
const after = stripped.slice(stripped.indexOf('const list: ExtInfo[]'))
|
|
60
|
+
assert.doesNotMatch(after.slice(0, 600), /writeJson\(res,\s*200/,
|
|
61
|
+
'注入没生效:这个替换匹配不上(判据会静默全绿)')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// ── 源码目录的野文件(2026-09-30 实测:`.bak` 被提交进仓)────────
|
|
65
|
+
test('★ src 下不许有备份/临时文件(它们会被 `git add -A` 顺手带进仓)', () => {
|
|
66
|
+
// ⚠️ 实测经过:我用 `sed -i.bak` 做注入验证,备份文件 `content.ts.bak`
|
|
67
|
+
// **被 `git add src/ext-prompt/` 带进了提交**。
|
|
68
|
+
// 它没被任何代码引用(`.bak` 后缀),所以**测试、typecheck、build 全绿** ——
|
|
69
|
+
// 典型的「产物看起来对、仓里却多了一份东西」。
|
|
70
|
+
// 而且它危险在:下一个人看到 `content.ts.bak` 会以为是**第二份内容**,
|
|
71
|
+
// 正是准则里「反查重复:新旧成两份」要防的。
|
|
72
|
+
//
|
|
73
|
+
// `check-publish` 只管「patch / exports / files 白名单 / 源码清单」的一致性,
|
|
74
|
+
// **不管源码目录里的野文件** ⇒ 这里补上。
|
|
75
|
+
const junk = [...readdirSync(new URL('.', import.meta.url), { recursive: true } as never) as string[]]
|
|
76
|
+
.filter((f) => /\.(bak|orig|rej|tmp|swp)$/.test(f) || /~$/.test(f))
|
|
77
|
+
assert.deepEqual(junk, [], `源码目录里有临时/备份文件:${junk.join(', ')}`)
|
|
78
|
+
})
|