dsh-router-core 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -10
- package/cordis.patch.yml +10 -0
- package/docs/ext.md +64 -2
- 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 +286 -127
- package/lib/types/ext/contract.d.ts +113 -0
- package/lib/types/ext-prompt/content.d.ts +64 -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-version.d.ts +7 -8
- package/lib/types/index.d.ts +1 -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 +181 -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-version.test.ts +21 -28
- package/src/host-version.ts +39 -39
- package/src/index.ts +216 -94
- package/src/llm/adapter.test.ts +19 -42
- package/src/llm/adapter.ts +20 -47
- package/src/shared.ts +34 -0
- package/src/suppliers/registry.test.ts +56 -0
- package/src/suppliers/registry.ts +40 -10
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 挂载层单测 —— 用假 `ctx.systemPrompt` 验证注册契约。
|
|
3
|
+
*
|
|
4
|
+
* 重点是**注入违规必须失败**:
|
|
5
|
+
* - 注册了静态字符串(改开关不生效)⇒ 必须被抓到;
|
|
6
|
+
* - 卸载不清理 ⇒ 必须被抓到。
|
|
7
|
+
*/
|
|
8
|
+
import test from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { mountPromptSection, PROMPT_SECTION_NAME } from './mount.ts'
|
|
11
|
+
import { EXT_PROMPT_ID } from './plugin.ts'
|
|
12
|
+
|
|
13
|
+
/** 记录注册内容的假 systemPrompt。 */
|
|
14
|
+
function fakeSystemPrompt() {
|
|
15
|
+
const registered: { name: string; order: number; text: string | (() => string) }[] = []
|
|
16
|
+
let disposals = 0
|
|
17
|
+
return {
|
|
18
|
+
registered,
|
|
19
|
+
get disposalCount() {
|
|
20
|
+
return disposals
|
|
21
|
+
},
|
|
22
|
+
section(s: { name: string; order: number; text: string | (() => string) }) {
|
|
23
|
+
registered.push(s)
|
|
24
|
+
return () => {
|
|
25
|
+
disposals++
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('注册恰好一个段落,名字稳定', () => {
|
|
32
|
+
const sp = fakeSystemPrompt()
|
|
33
|
+
mountPromptSection({ getSystemPrompt: () => sp, isEnabled: () => true, readData: () => undefined })
|
|
34
|
+
assert.equal(sp.registered.length, 1)
|
|
35
|
+
assert.equal(sp.registered[0]!.name, PROMPT_SECTION_NAME)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('⚠️ text 必须是 provider 函数而不是静态字符串(否则改开关不生效)', () => {
|
|
39
|
+
const sp = fakeSystemPrompt()
|
|
40
|
+
mountPromptSection({ getSystemPrompt: () => sp, isEnabled: () => true, readData: () => undefined })
|
|
41
|
+
const t = sp.registered[0]!.text
|
|
42
|
+
assert.equal(typeof t, 'function', 'text 是静态字符串 ⇒ 分类开关永远不生效')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('注入违规:静态 text ⇒ 断言会红(证明这条守卫抓得住)', () => {
|
|
46
|
+
// 反向证明:造一个"静态 text"的假 systemPrompt 看看会发生什么
|
|
47
|
+
const sp = fakeSystemPrompt()
|
|
48
|
+
let on = true
|
|
49
|
+
mountPromptSection({
|
|
50
|
+
getSystemPrompt: () => sp,
|
|
51
|
+
isEnabled: () => on,
|
|
52
|
+
readData: () => undefined,
|
|
53
|
+
})
|
|
54
|
+
// 第一次求值
|
|
55
|
+
const first = (sp.registered[0]!.text as () => string)()
|
|
56
|
+
assert.ok(first.includes('大肥鱼'), '总开关开时应有准则')
|
|
57
|
+
// 改开关后再求值 —— 若 text 是静态的,这里会拿到同一个字符串
|
|
58
|
+
on = false
|
|
59
|
+
const second = (sp.registered[0]!.text as () => string)()
|
|
60
|
+
assert.equal(second, '', 'provider 形式下改开关必须立刻反映')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('开关关闭 ⇒ provider 返回空串(上游据此丢弃该段)', () => {
|
|
64
|
+
const sp = fakeSystemPrompt()
|
|
65
|
+
mountPromptSection({ getSystemPrompt: () => sp, isEnabled: () => false, readData: () => undefined })
|
|
66
|
+
assert.equal((sp.registered[0]!.text as () => string)(), '')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('卸载会调用 disposer(不留孤儿段落)', () => {
|
|
70
|
+
const sp = fakeSystemPrompt()
|
|
71
|
+
const unmount = mountPromptSection({
|
|
72
|
+
getSystemPrompt: () => sp,
|
|
73
|
+
isEnabled: () => true,
|
|
74
|
+
readData: () => undefined,
|
|
75
|
+
})
|
|
76
|
+
assert.equal(sp.disposalCount, 0)
|
|
77
|
+
unmount()
|
|
78
|
+
assert.equal(sp.disposalCount, 1, '卸载没清理 ⇒ 段落泄漏,插件重载会叠加')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('systemPrompt 不可用 ⇒ 不注册、卸载是 no-op、且不抛', () => {
|
|
82
|
+
let unmount: () => void = () => {}
|
|
83
|
+
assert.doesNotThrow(() => {
|
|
84
|
+
unmount = mountPromptSection({
|
|
85
|
+
getSystemPrompt: () => undefined,
|
|
86
|
+
isEnabled: () => true,
|
|
87
|
+
readData: () => undefined,
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
assert.doesNotThrow(() => unmount())
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('ext id 稳定(改名会丢用户已存的开关配置)', () => {
|
|
94
|
+
assert.equal(EXT_PROMPT_ID, 'prompt')
|
|
95
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 挂载层 —— 把准则注册成 `ctx.systemPrompt` 的**一个**段落。
|
|
3
|
+
*
|
|
4
|
+
* ════════════════════════════════════════════════════════════════════
|
|
5
|
+
* 为什么只注册一段,而不是 14 段
|
|
6
|
+
* ════════════════════════════════════════════════════════════════════
|
|
7
|
+
* 面板开关的粒度是「分类」,但 prompt 读起来应该是一段连续的准则。分成 14 段
|
|
8
|
+
* 会让 KV cache 每个分类各占一段,且**增删任意一个分类都让它之后的全部分位**。
|
|
9
|
+
* 一段 + 拼装 ⇒ 分类增删只改这一段内部,段的位置与前缀稳定。
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ **空串即关闭,这是平台原生语义**(`renderPrompt` 的注释原文:
|
|
12
|
+
* "drop empty sections")⇒ "可开关"不需要任何自造机制:本函数返回 `''`,
|
|
13
|
+
* 上游就把整段丢掉。**不要自己实现"删段落"的逻辑**,那会和平台重复且会漂移。
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ 文本用 provider 函数(`(context) => string`)而不是静态字符串:
|
|
16
|
+
* 分类开关随时可改,静态文本**注册那一刻就定死了**,改开关不生效。
|
|
17
|
+
*/
|
|
18
|
+
import { renderPromptText, type PromptExtData } from './render.ts'
|
|
19
|
+
|
|
20
|
+
/** `ctx.systemPrompt` 的最小可见面。 */
|
|
21
|
+
export interface SystemPromptFace {
|
|
22
|
+
section(section: { name: string; order: number; text: string | (() => string) }): () => void
|
|
23
|
+
getSectionOrder?(name: string): number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 段落名(**稳定**):改名会让已注册的作用域 section 与旧名并存。 */
|
|
27
|
+
export const PROMPT_SECTION_NAME = 'dsh-router-ext-prompt:guideline'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 段落 order:100。
|
|
31
|
+
*
|
|
32
|
+
* 官方分配的槽位(`dsh-system-prompt` 的 `SECTION_ORDERS`):
|
|
33
|
+
* `PERSONA_PREFIX: 0` · `PLAN_POLICY: 500` · `TEAM_POLICY: 600` …·
|
|
34
|
+
* 取 100 = 紧随 persona、远早于任何工具段 ⇒ 准则出现在身份之后、工具说明之前,
|
|
35
|
+
* 与用户看到的记忆快照位置一致。
|
|
36
|
+
*/
|
|
37
|
+
const SECTION_ORDER = 100
|
|
38
|
+
|
|
39
|
+
export interface MountPromptDeps {
|
|
40
|
+
/** 取 `ctx.systemPrompt`(可能尚未就绪 ⇒ undefined)。 */
|
|
41
|
+
getSystemPrompt: () => SystemPromptFace | undefined
|
|
42
|
+
/** 总开关(问核心 `router.extStore`)。 */
|
|
43
|
+
isEnabled: () => boolean
|
|
44
|
+
/**
|
|
45
|
+
* 读本扩展的数据块(分类开关)。
|
|
46
|
+
*
|
|
47
|
+
* 落盘 JSON 不可信 ⇒ 返回值**不保证**是合法 `PromptExtData`,
|
|
48
|
+
* `renderPromptText` 负责容错(见 render.test.ts 的注入用例)。
|
|
49
|
+
*/
|
|
50
|
+
readData: () => PromptExtData | undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 挂载准则段落。返回卸载函数。
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ `systemPrompt` 尚不可用时**不挂**(返回 no-op 清理),并由 `ext.getState()`
|
|
57
|
+
* 报 `ready: false` 让面板红字显示。**不做轮询重试** —— 显式"没就绪"好过
|
|
58
|
+
* 半开的注入:后者会让准则随机时有时无,而用户看不出为什么。
|
|
59
|
+
* `index.ts` 用 `ctx.inject(['systemPrompt'])` 保证挂载时机,本函数只管挂。
|
|
60
|
+
*/
|
|
61
|
+
export function mountPromptSection(deps: MountPromptDeps): () => void {
|
|
62
|
+
const sp = deps.getSystemPrompt()
|
|
63
|
+
if (!sp) return () => {}
|
|
64
|
+
|
|
65
|
+
const dispose = sp.section({
|
|
66
|
+
name: PROMPT_SECTION_NAME,
|
|
67
|
+
order: SECTION_ORDER,
|
|
68
|
+
// ⚠️ provider 每次装配都现算 ⇒ 改开关**立即生效**,无需重注册。
|
|
69
|
+
text: () => renderCurrent(deps),
|
|
70
|
+
})
|
|
71
|
+
return () => {
|
|
72
|
+
dispose()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 算出当前应注入的文本(薄壳:真逻辑在 render.ts,可单测)。 */
|
|
77
|
+
function renderCurrent(deps: MountPromptDeps): string {
|
|
78
|
+
return renderPromptText(deps.isEnabled(), deps.readData())
|
|
79
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 分层提示词扩展(`router.ext` 侧的实现)—— 声明与状态都在这里。
|
|
3
|
+
*
|
|
4
|
+
* 与 `ext-test`(插件自检)同款:**不挂任何监听、不改写任何东西**,
|
|
5
|
+
* 唯一作用是让用户在官方插件页里看到一张卡片、能开关它,并把协作准则
|
|
6
|
+
* 注册成一段 system prompt。
|
|
7
|
+
*
|
|
8
|
+
* 与 `rtk` / `jev` 那类扩展的区别要写清楚:那两家挂 `tools/execute`
|
|
9
|
+
* 改写**工具调用**;本扩展只往 **system prompt** 加一段。名字同族是因为
|
|
10
|
+
* 都在 `router.ext` 这张表里,拦截面不重叠。
|
|
11
|
+
*
|
|
12
|
+
* 复用 `router.ext` 通道只为拿到**发现 + 开关**两件事(见 ext-test 的同款说明),
|
|
13
|
+
* 真正的注入在 `mount.ts`。
|
|
14
|
+
*/
|
|
15
|
+
import type { ExtStoreService, RouterExt } from '../ext/contract.ts'
|
|
16
|
+
import { PROMPT_CATEGORIES, PROMPT_TITLE } from './content.ts'
|
|
17
|
+
import { PROMPT_LOGO_URL } from './logo.ts'
|
|
18
|
+
import { effectiveText, resolveCategories, resolveEnabledCategories, type PromptExtData } from './render.ts'
|
|
19
|
+
|
|
20
|
+
/** 从对象里去掉某个键(不改原对象)。`writeData` 是整块替换 ⇒ 删一个键要重写。 */
|
|
21
|
+
function omit<T extends Record<string, unknown>>(obj: T, key: string): Partial<T> {
|
|
22
|
+
const out = { ...obj } as Record<string, unknown>
|
|
23
|
+
delete out[key]
|
|
24
|
+
return out as Partial<T>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 扩展开关表里的注册键。 */
|
|
28
|
+
export const EXT_PROMPT_ID = 'prompt'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 构造这个扩展器。
|
|
32
|
+
*
|
|
33
|
+
* `getState()` 报**唯一的真实就绪条件**:`ctx.systemPrompt` 不可用时无处注入
|
|
34
|
+
* (section 是唯一的注入通道),此时必须红字报出,而不是静默不生效 ——
|
|
35
|
+
* 判据能变红比"看起来一直开着"有用。
|
|
36
|
+
*
|
|
37
|
+
* @param deps.store - `router.extStore`(读写分类开关的落盘数据块)。缺省时
|
|
38
|
+
* `controls` 报全部默认、`setControl` 恒 false —— 那样面板显示可点但点了
|
|
39
|
+
* 没反应,**比不显示更坏**。
|
|
40
|
+
*/
|
|
41
|
+
export function createPromptExt(deps: {
|
|
42
|
+
isSystemPromptReady: () => boolean
|
|
43
|
+
store?: ExtStoreService
|
|
44
|
+
}): RouterExt {
|
|
45
|
+
const store = deps.store
|
|
46
|
+
/**
|
|
47
|
+
* 读落盘数据。**没有记录时返回空对象而不是 undefined**。
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ 2026-09-30 实测修的真 bug:`<dataDir>/ext.json` 里 `prompt` 只有
|
|
50
|
+
* `{enabled:true}`(**用户没改过任何东西** ⇒ 没有 `data` 键),
|
|
51
|
+
* 而我原先把 `data === undefined` 当成"存储不可用"直接 `return null/false`
|
|
52
|
+
* ⇒ 「添加准则」永远报 `title and body are required`,
|
|
53
|
+
* 而 title/body 明明都传了 —— 报错信息还指向错误的字段,排查成本极高。
|
|
54
|
+
*
|
|
55
|
+
* ⇒ 「没记录」与「记录为空」**必须同义**:都当作"从空白开始"。
|
|
56
|
+
* 真正该拒的是 **store 本身不存在**(扩展没挂上),那个在下面单独判。
|
|
57
|
+
*/
|
|
58
|
+
const readData = (): PromptExtData => store?.readData<PromptExtData>(EXT_PROMPT_ID) ?? {}
|
|
59
|
+
return {
|
|
60
|
+
id: EXT_PROMPT_ID,
|
|
61
|
+
name: '分层提示词',
|
|
62
|
+
// 图标(面板卡片 / 详情页头部的 44px 圆角位)。SVG base64 内联 ⇒ 离线可用、
|
|
63
|
+
// 不依赖外链、不受 CSP 的 img-src 限制。源文件在 `logo.svg`,base64 由它生成。
|
|
64
|
+
icon: PROMPT_LOGO_URL,
|
|
65
|
+
description: PROMPT_TITLE,
|
|
66
|
+
// ⚠️ `description` = 整段的**标题**(不是一句自我介绍)。2026-09-29 两次调整:
|
|
67
|
+
// 先是删掉它(详情页逐条列清单,「拆成 14 个分类」是纯冗余);
|
|
68
|
+
// 后又加回来、但只放**标题** `[准则 v5]`,并让详情页把它渲染在**最上面** ——
|
|
69
|
+
// 逐条清单之前先说清「这是什么」,比任何自我介绍都直接。
|
|
70
|
+
// ⚠️ **不再内插分类数量**(`${PROMPT_CATEGORIES.length}` 会与真实数量漂移)。
|
|
71
|
+
//
|
|
72
|
+
// 插件页「包含的组件」那一行仍然有**另一句**说明(来自 `locale/zh.json`,
|
|
73
|
+
// 宿主读的)—— 两者不是同一处,别混:那一行回答「不开它行不行」,
|
|
74
|
+
// 这里回答「它是什么」。
|
|
75
|
+
//
|
|
76
|
+
// 随核心分发 -> 插件页的原生「包含的组件」里有它一行,自绘节里不再重复列。
|
|
77
|
+
source: 'builtin' as const,
|
|
78
|
+
// ⚠️ 每次 `controls` 被读都现算(不缓存):用户在面板点一下立刻要看到新状态,
|
|
79
|
+
// 而核心每次 GET /ext 都重新取一次。缓存会显示"点了没反应"。
|
|
80
|
+
/**
|
|
81
|
+
* ⚠️ **用 getter,每次读都重算**(2026-09-29 实测修的 bug)。
|
|
82
|
+
* 原来写的是 `PROMPT_CATEGORIES.map(...)` —— 那个 map 在
|
|
83
|
+
* `createPromptExt()` 调用时就跑完了,是个**快照**。于是编辑落盘后:
|
|
84
|
+
* prompt 变了(渲染层每次现算),但面板再读 `controls` 拿到的还是**旧标题**
|
|
85
|
+
* ⇒ 用户看到「保存了、又变回去了」。而我当时的注释还写着"现算不缓存"——
|
|
86
|
+
* **注释与事实不符,比没注释更坏**。
|
|
87
|
+
*
|
|
88
|
+
* getter 让「面板显示的」与「prompt 里的」永远来自同一次 `readData()`。
|
|
89
|
+
*/
|
|
90
|
+
get controls() {
|
|
91
|
+
const data = readData()
|
|
92
|
+
const on = resolveEnabledCategories(true, data)
|
|
93
|
+
// ⚠️ 走 `resolveCategories`(内置 + 自建 + 排序),不是内置常量 ——
|
|
94
|
+
// 否则自建条目与拖动排序**只停在面板上**,不进 prompt。
|
|
95
|
+
return resolveCategories(data).map((c) => {
|
|
96
|
+
const t = effectiveText(c.id, data?.text?.[c.id], c)
|
|
97
|
+
return {
|
|
98
|
+
id: c.id,
|
|
99
|
+
title: t.title,
|
|
100
|
+
// ⚠️ **带上原文**:不给出原文的话用户是在**盲切** —— 只看到「结构」「交付」
|
|
101
|
+
// 这样的名字,不知道这一条到底写了什么,也无从判断该不该关掉它。
|
|
102
|
+
// 「看内容 → 决定开关」这个动作必须能一处完成。
|
|
103
|
+
body: t.body,
|
|
104
|
+
editable: true,
|
|
105
|
+
// ⚠️ **显式布尔,不靠缺省**:面板要靠 `custom === true` 决定
|
|
106
|
+
// 给「删除」还是「还原」,`undefined`(字段缺失)会让"内置"变成
|
|
107
|
+
// "不知道是哪种" ⇒ 落到错误的按钮上(2026-09-30 实测现象)。
|
|
108
|
+
// `custom` ⇒ 可删(自建);`overridden` ⇒ 「还原」可点(改过内置)。
|
|
109
|
+
custom: c.custom,
|
|
110
|
+
overridden: c.overridden,
|
|
111
|
+
on: on.has(c.id),
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
},
|
|
115
|
+
setControl: (controlId, on) => {
|
|
116
|
+
if (!store) return false
|
|
117
|
+
// ⚠️ **只改这一个键,不重写整块**:整块重写会顺手把 data 抽屉里的其它字段
|
|
118
|
+
// 抹掉(`store.test.ts` 有一条「置开关不能把 data 冲掉」的同款纪律)。
|
|
119
|
+
const data = readData()
|
|
120
|
+
// ⚠️ 用**合成列表**(内置 + 自建)而不是 `PROMPT_CATEGORIES`:
|
|
121
|
+
// 自建条目的 id 根本不在内置常量里 ⇒ 切它的开关恒被拒,
|
|
122
|
+
// 报 `unknown controlId`(2026-09-30 实测:用户看到"自定义的开关控制报错")。
|
|
123
|
+
const known = resolveCategories(data).some((c) => c.id === controlId)
|
|
124
|
+
if (!known) return false
|
|
125
|
+
store.writeData(EXT_PROMPT_ID, { ...data, categories: { ...data.categories, [controlId]: on } })
|
|
126
|
+
return true
|
|
127
|
+
},
|
|
128
|
+
// ⚠️ **只存覆盖值,不存全文**(见 PromptExtData.text):没改过的分类不出现在
|
|
129
|
+
// 落盘里 ⇒ 内置内容仍是唯一事实源,升级内置文案时它们会跟着更新。
|
|
130
|
+
// 存全文的话,用户改一个字就永久冻结整段,之后内置更新对它们完全失效。
|
|
131
|
+
setControlText: (controlId, patch) => {
|
|
132
|
+
if (!store) return false
|
|
133
|
+
const data = readData()
|
|
134
|
+
// ⚠️ 用**合成列表**(内置 + 自建)而不是 `PROMPT_CATEGORIES`:
|
|
135
|
+
// 自建条目的 id 根本不在内置常量里 ⇒ 切它的开关恒被拒,
|
|
136
|
+
// 报 `unknown controlId`(2026-09-30 实测:用户看到"自定义的开关控制报错")。
|
|
137
|
+
const known = resolveCategories(data).some((c) => c.id === controlId)
|
|
138
|
+
if (!known) return false
|
|
139
|
+
// ⚠️ **空串直接拒绝**(返回 false ⇒ 核心回 400):空标题会让这一行没名字,
|
|
140
|
+
// 空正文会让规则凭空消失 —— 两者都像"被关了"而不是"被改坏了"。
|
|
141
|
+
if (patch.title !== undefined && patch.title.trim() === '') return false
|
|
142
|
+
if (patch.body !== undefined && patch.body.trim() === '') return false
|
|
143
|
+
// ⚠️ **只并这一个键**:writeData 是整块替换,漏一个键就是丢一份数据
|
|
144
|
+
// (`store.test.ts` 有同款纪律:置开关不能把 data 冲掉)。
|
|
145
|
+
const prev = data.text?.[controlId] ?? {}
|
|
146
|
+
store.writeData(EXT_PROMPT_ID, {
|
|
147
|
+
...data,
|
|
148
|
+
text: { ...data.text, [controlId]: { ...prev, ...patch } },
|
|
149
|
+
})
|
|
150
|
+
return true
|
|
151
|
+
},
|
|
152
|
+
/** 改顺序(拖动)。落盘 id 列表;未知/重复 id 由 `resolveCategories` 兜住。 */
|
|
153
|
+
setControlOrder: (ids) => {
|
|
154
|
+
if (!store) return false
|
|
155
|
+
const data = readData()
|
|
156
|
+
const known = new Set(resolveCategories(data).map((c) => c.id))
|
|
157
|
+
// ⚠️ 只接受**当前存在**的 id:脏数据(手改 ext.json / 别的扩展写的)会
|
|
158
|
+
// 让顺序里出现幽灵条目,而渲染时会静默忽略它 ⇒ 面板上「存了但没生效」。
|
|
159
|
+
const next = [...new Set(ids)].filter((id) => known.has(id))
|
|
160
|
+
store.writeData(EXT_PROMPT_ID, { ...data, order: next })
|
|
161
|
+
return true
|
|
162
|
+
},
|
|
163
|
+
/** 新增一条自定义准则。id 由**扩展**生成(`cu-<n>`),避免前端编 id 撞内置。 */
|
|
164
|
+
addCustomControl: (title, body) => {
|
|
165
|
+
if (!store) return null
|
|
166
|
+
if (typeof title !== 'string' || title.trim() === '') return null
|
|
167
|
+
if (typeof body !== 'string' || body.trim() === '') return null
|
|
168
|
+
const data = readData()
|
|
169
|
+
const custom = Array.isArray(data.custom) ? [...data.custom] : []
|
|
170
|
+
// ⚠️ **id 必须避开内置**:撞了会让两条同 id,`resolveCategories` 的
|
|
171
|
+
// `byId` Map 只留一条 ⇒ 另一条凭空消失(且不报错)。
|
|
172
|
+
const used = new Set([...PROMPT_CATEGORIES.map((c) => c.id), ...custom.map((c) => c.id)])
|
|
173
|
+
let n = custom.length + 1
|
|
174
|
+
while (used.has(`cu-${n}`)) n++
|
|
175
|
+
const id = `cu-${n}`
|
|
176
|
+
custom.push({ id, title: title.trim(), body: body.trim(), defaultOn: true })
|
|
177
|
+
store.writeData(EXT_PROMPT_ID, { ...data, custom })
|
|
178
|
+
return id // ⚠️ 返回新 id:面板据此高亮/滚到新条目
|
|
179
|
+
},
|
|
180
|
+
/**
|
|
181
|
+
* 删一条 —— **只允许自定义**。
|
|
182
|
+
*
|
|
183
|
+
* ⚠️ 内置条目返回 false(→ 核心回 400):它是**代码的一部分**,
|
|
184
|
+
* 删掉就意味着下一次发版它又回来了;用户对内置的处置手段是**关开关**
|
|
185
|
+
* 与**还原文本**,不是删除。
|
|
186
|
+
*/
|
|
187
|
+
removeCustomControl: (controlId) => {
|
|
188
|
+
if (!store) return false
|
|
189
|
+
const data = readData()
|
|
190
|
+
const custom = Array.isArray(data.custom) ? data.custom : []
|
|
191
|
+
if (!custom.some((c) => c.id === controlId)) return false
|
|
192
|
+
store.writeData(EXT_PROMPT_ID, {
|
|
193
|
+
...data,
|
|
194
|
+
custom: custom.filter((c) => c.id !== controlId),
|
|
195
|
+
// 顺带清掉它的开关与覆盖,否则留下孤儿数据
|
|
196
|
+
...(data.categories ? { categories: omit(data.categories, controlId) } : {}),
|
|
197
|
+
...(data.text ? { text: omit(data.text, controlId) } : {}),
|
|
198
|
+
...(data.order ? { order: data.order.filter((id) => id !== controlId) } : {}),
|
|
199
|
+
})
|
|
200
|
+
return true
|
|
201
|
+
},
|
|
202
|
+
/**
|
|
203
|
+
* 还原一条的文本(丢掉覆盖,回到内置)。
|
|
204
|
+
*
|
|
205
|
+
* ⚠️ 自定义条目返回 false —— 它**没有"内置版本"**可回退;
|
|
206
|
+
* 它的对应手段是「删除」。
|
|
207
|
+
*/
|
|
208
|
+
resetControlText: (controlId) => {
|
|
209
|
+
if (!store) return false
|
|
210
|
+
const data = readData()
|
|
211
|
+
// ⚠️ 这里**刻意只用内置常量**:还原的语义是"回到内置内容",
|
|
212
|
+
// 自建条目**没有内置版本**可回退(它的对应手段是删除)。
|
|
213
|
+
if (!PROMPT_CATEGORIES.some((c) => c.id === controlId)) return false
|
|
214
|
+
if (data.text?.[controlId] === undefined) return false // 没改过,无需写盘
|
|
215
|
+
store.writeData(EXT_PROMPT_ID, { ...data, text: omit(data.text, controlId) })
|
|
216
|
+
return true
|
|
217
|
+
},
|
|
218
|
+
getState: () =>
|
|
219
|
+
deps.isSystemPromptReady()
|
|
220
|
+
? { ready: true }
|
|
221
|
+
: { ready: false, detail: 'ctx.systemPrompt 不可用 —— 本扩展无处注入' },
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -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
|
+
})
|