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.
Files changed (53) hide show
  1. package/README.md +18 -15
  2. package/cordis.patch.yml +10 -0
  3. package/docs/ext.md +65 -3
  4. package/lib/client-registry.js +633 -50
  5. package/lib/client.js +633 -50
  6. package/lib/contract-D4BWZ7Xe.js +13 -0
  7. package/lib/ext-prompt/index.js +527 -0
  8. package/lib/ext-prompt/locale/en.json +6 -0
  9. package/lib/ext-prompt/locale/zh.json +6 -0
  10. package/lib/ext-prompt/package.json +5 -0
  11. package/lib/ext-test/index.js +1 -12
  12. package/lib/index.js +298 -119
  13. package/lib/types/ext/contract.d.ts +113 -0
  14. package/lib/types/ext-prompt/content.d.ts +57 -0
  15. package/lib/types/ext-prompt/index.d.ts +53 -0
  16. package/lib/types/ext-prompt/logo.d.ts +17 -0
  17. package/lib/types/ext-prompt/mount.d.ts +51 -0
  18. package/lib/types/ext-prompt/plugin.d.ts +32 -0
  19. package/lib/types/ext-prompt/render.d.ts +105 -0
  20. package/lib/types/host-base-url.d.ts +31 -0
  21. package/lib/types/host-version.d.ts +7 -8
  22. package/lib/types/index.d.ts +1 -1
  23. package/lib/types/llm/adapter.d.ts +6 -1
  24. package/lib/types/shared.d.ts +33 -0
  25. package/package.json +17 -7
  26. package/src/client/EndpointTab.tsx +15 -18
  27. package/src/client/ExtDetail.tsx +552 -9
  28. package/src/client/index.tsx +7 -28
  29. package/src/client/router.css +132 -62
  30. package/src/ext/contract.ts +111 -0
  31. package/src/ext-prompt/assembly.test.ts +79 -0
  32. package/src/ext-prompt/content.ts +174 -0
  33. package/src/ext-prompt/ext-prompt.test.ts +432 -0
  34. package/src/ext-prompt/index.ts +104 -0
  35. package/src/ext-prompt/locale/en.json +6 -0
  36. package/src/ext-prompt/locale/zh.json +6 -0
  37. package/src/ext-prompt/logo.svg +14 -0
  38. package/src/ext-prompt/logo.ts +18 -0
  39. package/src/ext-prompt/mount.test.ts +95 -0
  40. package/src/ext-prompt/mount.ts +79 -0
  41. package/src/ext-prompt/plugin.ts +223 -0
  42. package/src/ext-prompt/render.test.ts +256 -0
  43. package/src/ext-prompt/render.ts +216 -0
  44. package/src/ext-prompt/row.json +5 -0
  45. package/src/ext-route.test.ts +78 -0
  46. package/src/host-base-url.test.ts +129 -0
  47. package/src/host-base-url.ts +43 -0
  48. package/src/host-version.test.ts +21 -28
  49. package/src/host-version.ts +39 -39
  50. package/src/index.ts +230 -96
  51. package/src/llm/adapter.test.ts +19 -42
  52. package/src/llm/adapter.ts +32 -50
  53. package/src/shared.ts +34 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * 分层提示词扩展的 **cordis 行** —— 官方插件页(设置 → 插件 → dsh-router-core)详情页
3
+ * 原生「包含的组件」里的那一行。
4
+ *
5
+ * 壳的形状与 `ext-test` 同款(cordis 身份 + 登记,实现 = `plugin.ts`),
6
+ * 但**多一件执行面的事**:挂 system prompt 段落。ext-test 不做拦截、只登记;
7
+ * 本扩展要把准则真注入,所以额外等 `systemPrompt` service 就绪后挂 section。
8
+ *
9
+ * **默认开启**(patch 里不带 `disabled`,与三行内置供应商一致):
10
+ * 协作准则是每次协作都要的基础设施,不是可选增强。与 `ext-test` 的
11
+ * `disabled: true` **刻意不同** —— 自检是「想跑才跑」的工具,准则不是;
12
+ * 让准则默认缺席,会变成「用户不知道它存在 ⇒ 协作按另一套规则执行」。
13
+ *
14
+ * 登记时把扩展开关置为开:本扩展**没有独立的运行期开关**,它的开关就是这一行
15
+ * (宿主管的那个)。不置上的话,用户开了行还要再点一次,两级开关说同一件事。
16
+ */
17
+ import type { Context } from '@deepseek-ai/cordis'
18
+ import {
19
+ currentExts,
20
+ currentExtStore,
21
+ type ExtStoreService,
22
+ type RouterExtService,
23
+ } from '../ext/contract.ts'
24
+ import { createPromptExt, EXT_PROMPT_ID } from './plugin.ts'
25
+ import { mountPromptSection, type SystemPromptFace } from './mount.ts'
26
+ import type { PromptExtData } from './render.ts'
27
+
28
+ /** cordis 插件身份(配置树里这一行的技术名;显示名走同目录的 locale)。 */
29
+ export const name = 'dsh-router-ext-prompt'
30
+
31
+ /** `ctx.systemPrompt` 的最小可见面(见 mount.ts)。 */
32
+ function systemPromptOf(sctx: Context): SystemPromptFace | undefined {
33
+ const c = sctx as unknown as {
34
+ get?: (k: string) => unknown
35
+ systemPrompt?: SystemPromptFace
36
+ }
37
+ const sp = (c.get?.('systemPrompt') ?? c.systemPrompt) as SystemPromptFace | undefined
38
+ return sp && typeof sp.section === 'function' ? sp : undefined
39
+ }
40
+
41
+ /**
42
+ * 声明本扩展依赖的 service。
43
+ *
44
+ * ⚠️⚠️ **必须导出这个数组,且 `apply` 里直接用 `ctx.systemPrompt`**(2026-09-29 修)
45
+ * ——我原先写成「在 apply 里 `ctx.inject(['systemPrompt'], cb)`」,**准则一直不进
46
+ * prompt**:面板上卡片、开关、原文全都正常(`GET /router/api/ext` 实测有 14 条
47
+ * controls),但模型看到的 system prompt 里没有它。
48
+ *
49
+ * 根因(本机 cordis 4.0.4 实测):`ctx.inject(deps, cb)` 的回调**不是同步触发** ——
50
+ * provide 之后立刻 `inject`,回调要等到下一个异步点才跑。插件的 `apply` 是**同步
51
+ * 函数**,返回时那次回调还没发生;而真实加载路径不保证之后还有机会去等它,
52
+ * 于是 `mountPromptSection` 从没被执行 ⇒ **准则一次都没进过 prompt**。
53
+ *
54
+ * ⚠️ 我第一版把这个机制写成「要等 `ctx.start()`」——**那是错的**:`start` 根本不在
55
+ * Context 的公开面上(原型只有 extend/isolate/intercept)。准确说法是「下一个
56
+ * 异步点」,具体机制随版本变。**结论不依赖这个细节**,只依赖「apply 里手动
57
+ * inject 不可靠」这一事实。
58
+ *
59
+ * 正确形状(对照 `dsh-client-ui-deliverables` 等真实使用者):导出 `inject` 数组,
60
+ * cordis 看到它就**把整个 apply 推迟到这些 service 就绪之后**再调用 ——
61
+ * 于是 `ctx.systemPrompt` 一定是活的,`section()` 可以直接调。
62
+ *
63
+ * ⚠️ `router.ext` 也要列:那一半(面板登记)此前"碰巧"能用,是因为
64
+ * `dsh-router` 核心先 provide 了它、`apply` 又跑在它之后。**那是时序上的运气,
65
+ * 不是保证** —— 一并列进 `inject`,两种 service 都由 cordis 编排。
66
+ */
67
+ export const inject = ['systemPrompt', 'router.ext', 'router.extStore']
68
+
69
+ /**
70
+ * 登记本扩展**并**挂载准则段落。
71
+ *
72
+ * 此刻 `ctx.systemPrompt` / `ctx.router.ext` / `ctx.router.extStore` **必定已就绪**
73
+ * (见上面 `inject` 的注释),所以下面**直接用**,不再做二次判空。
74
+ */
75
+ export function apply(ctx: Context): void {
76
+ const table = currentExts(ctx)
77
+ const store = currentExtStore(ctx)
78
+
79
+ // ① 准则段落。`section()` 注册一个**provider 函数**作为 text ——
80
+ // 每次装配现算,所以改开关立即生效,无需重注册。
81
+ const dispose = mountPromptSection({
82
+ getSystemPrompt: () => systemPromptOf(ctx),
83
+ isEnabled: () => store?.isEnabled(EXT_PROMPT_ID) === true,
84
+ readData: () => store?.readData<PromptExtData>(EXT_PROMPT_ID),
85
+ })
86
+
87
+ // ② 面板登记。幂等:同一个 id 已在表里就不顶掉别人的登记。
88
+ if (table !== undefined && table[EXT_PROMPT_ID] === undefined) {
89
+ table[EXT_PROMPT_ID] = createPromptExt({
90
+ isSystemPromptReady: () => !!systemPromptOf(ctx),
91
+ // ⚠️ **store 必须在这时就能拿到**:拿不到的话面板上的分类开关显示可点、
92
+ // 点了 400 —— 「看起来能用但不能用」比「不显示」更难查。
93
+ store,
94
+ })
95
+ store?.setEnabled(EXT_PROMPT_ID, true)
96
+ // 核心持有的是**同一个 live 对象**(它 provide 的空表),且 `/ext` 每次请求都
97
+ // 现读这张表 —— 所以往里 append 就够了,不需要(也不存在)广播事件。
98
+ }
99
+
100
+ ctx.effect(() => () => {
101
+ dispose()
102
+ if (table !== undefined) delete table[EXT_PROMPT_ID]
103
+ })
104
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Layered prompt",
4
+ "description": "Split the working guidelines into individually switchable categories and inject them into the system prompt; disabled categories contribute no prompt text"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "分层提示词",
4
+ "description": "把协作准则拆成可独立开关的分类,注入 system prompt;关闭的分类不产出任何 prompt 文本"
5
+ }
6
+ }
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect width="64" height="64" rx="16" fill="#17181D"/>
3
+ <g fill="none" stroke="#EDEDEB" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
4
+ <path d="M18 16h20"/>
5
+ <path d="M18 27h24"/>
6
+ <path d="M18 38h24"/>
7
+ <path d="M18 49h24"/>
8
+ </g>
9
+ <g fill="none" stroke="#A7C0A5" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round">
10
+ <path d="M9 16.5l2.5 2.5L16 14.5"/>
11
+ <path d="M9 27.5l2.5 2.5L16 25.5"/>
12
+ <path d="M9 38.5l2.5 2.5L16 36.5"/>
13
+ </g>
14
+ </svg>
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 分层提示词扩展的图标 —— **一张深色圆角瓦片**,以本地 base64 内联(离线可用)。
3
+ *
4
+ * 为什么这样画:它做的事是「一份可以逐条开关的规则清单」⇒ 图形 = 清单 + 勾选。
5
+ * 不用齿轮(那是设置)、不用扳手(那是工具)—— 那两样也在同一个面板里,
6
+ * 混用会让人分不清「改行为」和「改这套规则本身」。
7
+ *
8
+ * ⚠️ **单色 + 深底**(与 RTK logo 同款处理):`.dshr-providerIcon` 只给圆角与
9
+ * 尺寸、**不给底色**,所以底色必须由本图自带 ⇒ 深浅两套主题下都成立。
10
+ * 勾选用略暖的绿(`#A7C0A5`)与清单的灰白拉开,暗示"这些可以勾掉"。
11
+ *
12
+ * ⚠️ **base64 必须由 `logo.svg` 生成**(`base64 -i logo.svg | tr -d '\n'`)。
13
+ * 我第一版**手编**了一串 base64,解出来是乱码(`tept-Gradient`、`#17118=D`)——
14
+ * 而客户端的 `onError` 会把加载失败的图**静默藏掉**、退回默认闪电图标,
15
+ * **看起来"正常"、实际图标根本没换**。编造的常量不会报错,这是它最险的地方。
16
+ */
17
+ export const PROMPT_LOGO_URL =
18
+ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTYiIGZpbGw9IiMxNzE4MUQiLz4KICA8ZyBmaWxsPSJub25lIiBzdHJva2U9IiNFREVERUIiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj4KICAgIDxwYXRoIGQ9Ik0xOCAxNmgyMCIvPgogICAgPHBhdGggZD0iTTE4IDI3aDI0Ii8+CiAgICA8cGF0aCBkPSJNMTggMzhoMjQiLz4KICAgIDxwYXRoIGQ9Ik0xOCA0OWgyNCIvPgogIDwvZz4KICA8ZyBmaWxsPSJub25lIiBzdHJva2U9IiNBN0MwQTUiIHN0cm9rZS13aWR0aD0iMy40IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPgogICAgPHBhdGggZD0iTTkgMTYuNWwyLjUgMi41TDE2IDE0LjUiLz4KICAgIDxwYXRoIGQ9Ik05IDI3LjVsMi41IDIuNUwxNiAyNS41Ii8+CiAgICA8cGF0aCBkPSJNOSAzOC41bDIuNSAyLjVMMTYgMzYuNSIvPgogIDwvZz4KPC9zdmc+Cg=='
@@ -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
+ }