dsh-router-core 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +249 -117
- 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-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 +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-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
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region src/ext/contract.ts
|
|
2
|
+
/** 读取当前 router.ext 表(同一 live 对象,可追加)——扩展插件用它注册自己。 */
|
|
3
|
+
function currentExts(ctx) {
|
|
4
|
+
const c = ctx;
|
|
5
|
+
return c.get?.("router.ext") ?? c.router?.ext;
|
|
6
|
+
}
|
|
7
|
+
/** 读取扩展存储服务——扩展插件用它读开关、读写自己存的数据。 */
|
|
8
|
+
function currentExtStore(ctx) {
|
|
9
|
+
const c = ctx;
|
|
10
|
+
return c.get?.("router.extStore") ?? c.router?.extStore;
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
export { currentExts as n, currentExtStore as t };
|
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import { n as currentExts, t as currentExtStore } from "../contract-D4BWZ7Xe.js";
|
|
2
|
+
//#region src/ext-prompt/content.ts
|
|
3
|
+
const PROMPT_CATEGORIES = [
|
|
4
|
+
{
|
|
5
|
+
id: "identity",
|
|
6
|
+
title: "身份气质",
|
|
7
|
+
defaultOn: true,
|
|
8
|
+
body: "身份气质:大肥鱼 🐋(资深开发者);懒=高效不是马虎;安静、克制、直接;默认中文,直接给能跑的东西。"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
id: "scope",
|
|
12
|
+
title: "视野",
|
|
13
|
+
defaultOn: true,
|
|
14
|
+
body: "视野:动手前看这块在整体里的位置与形状——属于哪块、并进去还是新建;只盯局部最优,加总就是全局最乱。"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: "ladder",
|
|
18
|
+
title: "懒人梯子",
|
|
19
|
+
defaultOn: true,
|
|
20
|
+
body: "梯子:停在第一个成立的档——①真需要吗②库里有现成的③标准库④平台原生⑤已装依赖⑥一行⑦才写最少能跑的。改前读全被碰的代码、端到端走真实流程(从用户可达的入口进——绕过入口的验证=没验入口)、grep 每个调用方(改契约后再 grep——改动会制造新的对齐义务)。"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "worktree",
|
|
24
|
+
title: "领地",
|
|
25
|
+
defaultOn: true,
|
|
26
|
+
body: "领地:一对话=一任务单元=一个 git worktree(唯一形态;分支/克隆是另外的情况),开工建、领地内做、收尾合并后删——边界必须物理;在主工作区直接开工是制造问题的第一步。"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "closure",
|
|
30
|
+
title: "全局收口",
|
|
31
|
+
defaultOn: true,
|
|
32
|
+
body: "收口(与跑测试同级):①反查过期:改动让哪些注释/文档/结论/代码引用点(凡以名字对齐的调用方、开关名、注册键)不符事实?当场改或删;②反查重复:新旧成两份就并成一处;③反查形状:该合的合、该归档的归位;④前三问都空时处置一件存量(删/并/归档)。做完那块区域该比动手前更整或更少。"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "hardening",
|
|
36
|
+
title: "固化",
|
|
37
|
+
defaultOn: true,
|
|
38
|
+
body: "固化:每次造成用户可见损失的失败,当天固化为可自动重放的检查——会变红(注入违规必须失败)、可重放(一条命令)、归属清晰(失败信息指向违规处);面向他人的检查只警告不阻断。不固化的教训等于决定再犯一次。"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "verdifiable",
|
|
42
|
+
title: "可判定",
|
|
43
|
+
defaultOn: true,
|
|
44
|
+
body: "可判定:只有清单和会变红的闸门生效,形容词不生效。新约束一律写成\"动作+闸门\"。"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "root-cause",
|
|
48
|
+
title: "修根因",
|
|
49
|
+
defaultOn: true,
|
|
50
|
+
body: "修 bug 修根因(改共享函数一次;只补工单点名的路径会留兄弟调用方继续坏)。"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "tradeoff",
|
|
54
|
+
title: "取舍",
|
|
55
|
+
defaultOn: true,
|
|
56
|
+
body: "取舍:同体积选边界正确的;复杂需求先反问「真要 X,还是 Y 就够了」。"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "redline",
|
|
60
|
+
title: "底线",
|
|
61
|
+
defaultOn: true,
|
|
62
|
+
body: "底线:不建没被要求的抽象;最短能跑的 diff 才算赢,前提是真看懂问题。砍掉的边角写明天花板+升级路径。绝不用不可逆的批量破坏命令(reset --hard/checkout ./stash/clean -fd)——无论工作区里是谁的工作,丢了就是丢了。"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "structure",
|
|
66
|
+
title: "结构",
|
|
67
|
+
defaultOn: true,
|
|
68
|
+
body: "结构:先让结构对再让代码短;命名/边界/单一职责优先于行数;依赖单向无环、公共逻辑收敛一处;单文件单功能,软顶函数 ~40 行/文件 ~300 行。"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "no-cheap",
|
|
72
|
+
title: "绝不偷懒",
|
|
73
|
+
defaultOn: true,
|
|
74
|
+
body: "绝不偷懒:边界输入校验、防数据丢失、安全、可访问性、真实硬件校准;非平凡逻辑留个能跑的检查。"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "delivery",
|
|
78
|
+
title: "交付",
|
|
79
|
+
defaultOn: true,
|
|
80
|
+
body: "交付:先说这次让整体变好了什么再说加了什么,至多三行。提交=精确暂存:暂存清单==本单元改动清单,逐文件 add,绝不用 add -A/.;暂存里出现不认识的文件就停——先查证再提交。"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "execution",
|
|
84
|
+
title: "执行顺序",
|
|
85
|
+
defaultOn: true,
|
|
86
|
+
body: "执行:拉记忆→看全貌→建领地→定位任务→查结构行号→声明「任务/文件/验证/提交」→读码取切片→循既有骨架→验证以闸门为准(混合状态下的绿灯不作数)→收口复查全貌→提交。"
|
|
87
|
+
}
|
|
88
|
+
];
|
|
89
|
+
/**
|
|
90
|
+
* 整段的标题(**与分类无关**)。
|
|
91
|
+
*
|
|
92
|
+
* ⚠️ 拆分时差点丢它:原文是 `[…] ·身份气质:…`,标题挂在方括号里、
|
|
93
|
+
* **不属于任何单条分类**,按分隔符切分时自然被丢掉
|
|
94
|
+
* (只在 `content.ts` 头注出现过、没进任何 `body`)。⇒ 模型会看到 14 条
|
|
95
|
+
* **没有名字**的规则,而"这套东西叫什么、为什么存在"恰恰是它需要的上下文。
|
|
96
|
+
*
|
|
97
|
+
* 形状:**不属于任何分类** ⇒ 不可被单条开关关掉(它一关,准则就无名无姓)。
|
|
98
|
+
* 要关掉整个准则,用 ext 的总开关。
|
|
99
|
+
*
|
|
100
|
+
* ⚠️ 2026-09-29 用户把副题去掉(原 `[准则 v5 · 懒人梯子 + 全局收口 + 固化]`)——
|
|
101
|
+
* 那三个词是**内容**(三条纪律的名字),挂在标题上等于"标题在重复正文",
|
|
102
|
+
* 而正文里本来就有各自的分类标题。改成短标题,分类名留给下面每一行。
|
|
103
|
+
*/
|
|
104
|
+
const PROMPT_TITLE = "[准则 v5]";
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region src/ext-prompt/logo.ts
|
|
107
|
+
/**
|
|
108
|
+
* 分层提示词扩展的图标 —— **一张深色圆角瓦片**,以本地 base64 内联(离线可用)。
|
|
109
|
+
*
|
|
110
|
+
* 为什么这样画:它做的事是「一份可以逐条开关的规则清单」⇒ 图形 = 清单 + 勾选。
|
|
111
|
+
* 不用齿轮(那是设置)、不用扳手(那是工具)—— 那两样也在同一个面板里,
|
|
112
|
+
* 混用会让人分不清「改行为」和「改这套规则本身」。
|
|
113
|
+
*
|
|
114
|
+
* ⚠️ **单色 + 深底**(与 RTK logo 同款处理):`.dshr-providerIcon` 只给圆角与
|
|
115
|
+
* 尺寸、**不给底色**,所以底色必须由本图自带 ⇒ 深浅两套主题下都成立。
|
|
116
|
+
* 勾选用略暖的绿(`#A7C0A5`)与清单的灰白拉开,暗示"这些可以勾掉"。
|
|
117
|
+
*
|
|
118
|
+
* ⚠️ **base64 必须由 `logo.svg` 生成**(`base64 -i logo.svg | tr -d '\n'`)。
|
|
119
|
+
* 我第一版**手编**了一串 base64,解出来是乱码(`tept-Gradient`、`#17118=D`)——
|
|
120
|
+
* 而客户端的 `onError` 会把加载失败的图**静默藏掉**、退回默认闪电图标,
|
|
121
|
+
* **看起来"正常"、实际图标根本没换**。编造的常量不会报错,这是它最险的地方。
|
|
122
|
+
*/
|
|
123
|
+
const PROMPT_LOGO_URL = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTYiIGZpbGw9IiMxNzE4MUQiLz4KICA8ZyBmaWxsPSJub25lIiBzdHJva2U9IiNFREVERUIiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj4KICAgIDxwYXRoIGQ9Ik0xOCAxNmgyMCIvPgogICAgPHBhdGggZD0iTTE4IDI3aDI0Ii8+CiAgICA8cGF0aCBkPSJNMTggMzhoMjQiLz4KICAgIDxwYXRoIGQ9Ik0xOCA0OWgyNCIvPgogIDwvZz4KICA8ZyBmaWxsPSJub25lIiBzdHJva2U9IiNBN0MwQTUiIHN0cm9rZS13aWR0aD0iMy40IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPgogICAgPHBhdGggZD0iTTkgMTYuNWwyLjUgMi41TDE2IDE0LjUiLz4KICAgIDxwYXRoIGQ9Ik05IDI3LjVsMi41IDIuNUwxNiAyNS41Ii8+CiAgICA8cGF0aCBkPSJNOSAzOC41bDIuNSAyLjVMMTYgMzYuNSIvPgogIDwvZz4KPC9zdmc+Cg==";
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region src/ext-prompt/render.ts
|
|
126
|
+
/**
|
|
127
|
+
* 纯逻辑层 —— 分类开关的读取、校验与 prompt 文本渲染。
|
|
128
|
+
*
|
|
129
|
+
* 为什么单独成文件:**不 import cordis / 任何 DSH 包**,所以能被 `node --test`
|
|
130
|
+
* 直接跑。DSH 侧的注册/挂载(ext.ts / inject.ts)只是薄壳;所有会出错的判断
|
|
131
|
+
* 都在这里,且这里能注入违规用例证明它们会变红。
|
|
132
|
+
*/
|
|
133
|
+
/** 解出某分类**生效用**的文本:用户覆盖优先,否则用内置的。 */
|
|
134
|
+
function effectiveText(id, override, base) {
|
|
135
|
+
return {
|
|
136
|
+
title: override?.title !== void 0 && override.title !== "" ? override.title : base.title,
|
|
137
|
+
body: override?.body !== void 0 && override.body !== "" ? override.body : base.body
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* 解出「哪些分类要渲染」。
|
|
142
|
+
*
|
|
143
|
+
* 三层语义,**顺序不可换**:
|
|
144
|
+
* ① 总开关关 ⇒ 一条都不渲染(不是"渲染但没内容"——那样面板显示已开启、实际无效果);
|
|
145
|
+
* ② 数据块里显式写了的分类,用**用户存的值**(含显式 `false`);
|
|
146
|
+
* ③ 没写过的分类,用 `defaultOn`。
|
|
147
|
+
*
|
|
148
|
+
* ⚠️ 为什么 ② 要区别于「没写」:`false` 与 `undefined` 语义完全不同 ——
|
|
149
|
+
* 前者是用户主动关掉,后者是"没碰过"。把两者当同一件事,用户第一次关掉某分类
|
|
150
|
+
* 后升级插件、新增同名分类,那条就永远是关的(用户没要求过)。
|
|
151
|
+
*/
|
|
152
|
+
function resolveEnabledCategories(masterOn, data) {
|
|
153
|
+
const out = /* @__PURE__ */ new Set();
|
|
154
|
+
if (!masterOn) return out;
|
|
155
|
+
const saved = data?.categories;
|
|
156
|
+
for (const c of resolveCategories(data)) {
|
|
157
|
+
const v = saved ? saved[c.id] : void 0;
|
|
158
|
+
if (v === void 0) {
|
|
159
|
+
if (c.defaultOn) out.add(c.id);
|
|
160
|
+
} else if (v === true) out.add(c.id);
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* 渲染成**一个**段落文本(多分类拼一起)。
|
|
166
|
+
*
|
|
167
|
+
* 为什么要拼成一段而不是一段一个:面板开关是"分类"粒度,而 prompt 读起来
|
|
168
|
+
* 是一段连续的准则 —— 分成 14 个 section 会让 KV cache 每个分类各占一段、
|
|
169
|
+
* 增删任意一个分类都让其后全部位移。这里**注册一个 section,文本由开关拼装**。
|
|
170
|
+
*
|
|
171
|
+
* @returns 关闭的分类不进结果;全关 ⇒ 空串(上游 `renderPrompt` 会丢弃空段)。
|
|
172
|
+
*/
|
|
173
|
+
function renderCategories(enabled, categories = PROMPT_CATEGORIES, overrides, withTitle = true) {
|
|
174
|
+
const lines = [];
|
|
175
|
+
for (const c of categories) {
|
|
176
|
+
if (!enabled.has(c.id)) continue;
|
|
177
|
+
lines.push(effectiveText(c.id, overrides?.[c.id], c).body);
|
|
178
|
+
}
|
|
179
|
+
if (lines.length === 0) return "";
|
|
180
|
+
return withTitle ? [PROMPT_TITLE, ...lines].join("\n\n") : lines.join("\n\n");
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* 渲染当前应注入的完整 prompt 文本(总开关 + 分类开关都算进去)。
|
|
184
|
+
*
|
|
185
|
+
* 总开关关 ⇒ 空串,让上游 `renderPrompt` 丢弃该段 —— **不是**注入一段空白。
|
|
186
|
+
*/
|
|
187
|
+
function renderPromptText(masterOn, data) {
|
|
188
|
+
if (!masterOn) return "";
|
|
189
|
+
return renderCategories(resolveEnabledCategories(masterOn, data), resolveCategories(data), data?.text, true);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* 内置 + 自建 + 排序 ⇒ 有序的最终条目列表。
|
|
193
|
+
*
|
|
194
|
+
* ⚠️ 三条规则(顺序不可换):
|
|
195
|
+
* 1. **内置在前、自建在后**,除非 `order` 显式给了别的次序。代码顺序是
|
|
196
|
+
* "准则作为一份文档的阅读顺序",不该被用户拖动改掉(见 `PromptExtData.order`)。
|
|
197
|
+
* 2. `order` 里**不认识的 id 一律忽略**,落盘不可信(手改的 ext.json、上一版的残渣)。
|
|
198
|
+
* 3. `order` **没提到的条目按原序补在后面** —— 丢了某条 ≠ 它消失了。
|
|
199
|
+
*/
|
|
200
|
+
function resolveCategories(data) {
|
|
201
|
+
const validCustom = (Array.isArray(data?.custom) ? data.custom : []).filter((c) => !!c && typeof c.id === "string" && c.id !== "" && typeof c.body === "string" && c.body !== "");
|
|
202
|
+
const base = [...PROMPT_CATEGORIES.map((c) => ({
|
|
203
|
+
...c,
|
|
204
|
+
custom: false,
|
|
205
|
+
overridden: data?.text?.[c.id] !== void 0
|
|
206
|
+
})), ...validCustom.map((c) => ({
|
|
207
|
+
...c,
|
|
208
|
+
title: c.title,
|
|
209
|
+
defaultOn: c.defaultOn !== false,
|
|
210
|
+
custom: true,
|
|
211
|
+
overridden: false
|
|
212
|
+
}))];
|
|
213
|
+
const order = Array.isArray(data?.order) ? data.order : [];
|
|
214
|
+
if (order.length === 0) return base;
|
|
215
|
+
const byId = new Map(base.map((c) => [c.id, c]));
|
|
216
|
+
const out = [];
|
|
217
|
+
for (const id of order) {
|
|
218
|
+
const hit = byId.get(id);
|
|
219
|
+
if (hit === void 0) continue;
|
|
220
|
+
out.push(hit);
|
|
221
|
+
byId.delete(id);
|
|
222
|
+
}
|
|
223
|
+
for (const c of base) if (byId.has(c.id)) out.push(c);
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region src/ext-prompt/plugin.ts
|
|
228
|
+
/** 从对象里去掉某个键(不改原对象)。`writeData` 是整块替换 ⇒ 删一个键要重写。 */
|
|
229
|
+
function omit(obj, key) {
|
|
230
|
+
const out = { ...obj };
|
|
231
|
+
delete out[key];
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
/** 扩展开关表里的注册键。 */
|
|
235
|
+
const EXT_PROMPT_ID = "prompt";
|
|
236
|
+
/**
|
|
237
|
+
* 构造这个扩展器。
|
|
238
|
+
*
|
|
239
|
+
* `getState()` 报**唯一的真实就绪条件**:`ctx.systemPrompt` 不可用时无处注入
|
|
240
|
+
* (section 是唯一的注入通道),此时必须红字报出,而不是静默不生效 ——
|
|
241
|
+
* 判据能变红比"看起来一直开着"有用。
|
|
242
|
+
*
|
|
243
|
+
* @param deps.store - `router.extStore`(读写分类开关的落盘数据块)。缺省时
|
|
244
|
+
* `controls` 报全部默认、`setControl` 恒 false —— 那样面板显示可点但点了
|
|
245
|
+
* 没反应,**比不显示更坏**。
|
|
246
|
+
*/
|
|
247
|
+
function createPromptExt(deps) {
|
|
248
|
+
const store = deps.store;
|
|
249
|
+
/**
|
|
250
|
+
* 读落盘数据。**没有记录时返回空对象而不是 undefined**。
|
|
251
|
+
*
|
|
252
|
+
* ⚠️ 2026-09-30 实测修的真 bug:`<dataDir>/ext.json` 里 `prompt` 只有
|
|
253
|
+
* `{enabled:true}`(**用户没改过任何东西** ⇒ 没有 `data` 键),
|
|
254
|
+
* 而我原先把 `data === undefined` 当成"存储不可用"直接 `return null/false`
|
|
255
|
+
* ⇒ 「添加准则」永远报 `title and body are required`,
|
|
256
|
+
* 而 title/body 明明都传了 —— 报错信息还指向错误的字段,排查成本极高。
|
|
257
|
+
*
|
|
258
|
+
* ⇒ 「没记录」与「记录为空」**必须同义**:都当作"从空白开始"。
|
|
259
|
+
* 真正该拒的是 **store 本身不存在**(扩展没挂上),那个在下面单独判。
|
|
260
|
+
*/
|
|
261
|
+
const readData = () => store?.readData("prompt") ?? {};
|
|
262
|
+
return {
|
|
263
|
+
id: EXT_PROMPT_ID,
|
|
264
|
+
name: "分层提示词",
|
|
265
|
+
icon: PROMPT_LOGO_URL,
|
|
266
|
+
description: PROMPT_TITLE,
|
|
267
|
+
source: "builtin",
|
|
268
|
+
/**
|
|
269
|
+
* ⚠️ **用 getter,每次读都重算**(2026-09-29 实测修的 bug)。
|
|
270
|
+
* 原来写的是 `PROMPT_CATEGORIES.map(...)` —— 那个 map 在
|
|
271
|
+
* `createPromptExt()` 调用时就跑完了,是个**快照**。于是编辑落盘后:
|
|
272
|
+
* prompt 变了(渲染层每次现算),但面板再读 `controls` 拿到的还是**旧标题**
|
|
273
|
+
* ⇒ 用户看到「保存了、又变回去了」。而我当时的注释还写着"现算不缓存"——
|
|
274
|
+
* **注释与事实不符,比没注释更坏**。
|
|
275
|
+
*
|
|
276
|
+
* getter 让「面板显示的」与「prompt 里的」永远来自同一次 `readData()`。
|
|
277
|
+
*/
|
|
278
|
+
get controls() {
|
|
279
|
+
const data = readData();
|
|
280
|
+
const on = resolveEnabledCategories(true, data);
|
|
281
|
+
return resolveCategories(data).map((c) => {
|
|
282
|
+
const t = effectiveText(c.id, data?.text?.[c.id], c);
|
|
283
|
+
return {
|
|
284
|
+
id: c.id,
|
|
285
|
+
title: t.title,
|
|
286
|
+
body: t.body,
|
|
287
|
+
editable: true,
|
|
288
|
+
custom: c.custom,
|
|
289
|
+
overridden: c.overridden,
|
|
290
|
+
on: on.has(c.id)
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
},
|
|
294
|
+
setControl: (controlId, on) => {
|
|
295
|
+
if (!store) return false;
|
|
296
|
+
const data = readData();
|
|
297
|
+
if (!resolveCategories(data).some((c) => c.id === controlId)) return false;
|
|
298
|
+
store.writeData(EXT_PROMPT_ID, {
|
|
299
|
+
...data,
|
|
300
|
+
categories: {
|
|
301
|
+
...data.categories,
|
|
302
|
+
[controlId]: on
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
return true;
|
|
306
|
+
},
|
|
307
|
+
setControlText: (controlId, patch) => {
|
|
308
|
+
if (!store) return false;
|
|
309
|
+
const data = readData();
|
|
310
|
+
if (!resolveCategories(data).some((c) => c.id === controlId)) return false;
|
|
311
|
+
if (patch.title !== void 0 && patch.title.trim() === "") return false;
|
|
312
|
+
if (patch.body !== void 0 && patch.body.trim() === "") return false;
|
|
313
|
+
const prev = data.text?.[controlId] ?? {};
|
|
314
|
+
store.writeData(EXT_PROMPT_ID, {
|
|
315
|
+
...data,
|
|
316
|
+
text: {
|
|
317
|
+
...data.text,
|
|
318
|
+
[controlId]: {
|
|
319
|
+
...prev,
|
|
320
|
+
...patch
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
return true;
|
|
325
|
+
},
|
|
326
|
+
/** 改顺序(拖动)。落盘 id 列表;未知/重复 id 由 `resolveCategories` 兜住。 */
|
|
327
|
+
setControlOrder: (ids) => {
|
|
328
|
+
if (!store) return false;
|
|
329
|
+
const data = readData();
|
|
330
|
+
const known = new Set(resolveCategories(data).map((c) => c.id));
|
|
331
|
+
const next = [...new Set(ids)].filter((id) => known.has(id));
|
|
332
|
+
store.writeData(EXT_PROMPT_ID, {
|
|
333
|
+
...data,
|
|
334
|
+
order: next
|
|
335
|
+
});
|
|
336
|
+
return true;
|
|
337
|
+
},
|
|
338
|
+
/** 新增一条自定义准则。id 由**扩展**生成(`cu-<n>`),避免前端编 id 撞内置。 */
|
|
339
|
+
addCustomControl: (title, body) => {
|
|
340
|
+
if (!store) return null;
|
|
341
|
+
if (typeof title !== "string" || title.trim() === "") return null;
|
|
342
|
+
if (typeof body !== "string" || body.trim() === "") return null;
|
|
343
|
+
const data = readData();
|
|
344
|
+
const custom = Array.isArray(data.custom) ? [...data.custom] : [];
|
|
345
|
+
const used = /* @__PURE__ */ new Set([...PROMPT_CATEGORIES.map((c) => c.id), ...custom.map((c) => c.id)]);
|
|
346
|
+
let n = custom.length + 1;
|
|
347
|
+
while (used.has(`cu-${n}`)) n++;
|
|
348
|
+
const id = `cu-${n}`;
|
|
349
|
+
custom.push({
|
|
350
|
+
id,
|
|
351
|
+
title: title.trim(),
|
|
352
|
+
body: body.trim(),
|
|
353
|
+
defaultOn: true
|
|
354
|
+
});
|
|
355
|
+
store.writeData(EXT_PROMPT_ID, {
|
|
356
|
+
...data,
|
|
357
|
+
custom
|
|
358
|
+
});
|
|
359
|
+
return id;
|
|
360
|
+
},
|
|
361
|
+
/**
|
|
362
|
+
* 删一条 —— **只允许自定义**。
|
|
363
|
+
*
|
|
364
|
+
* ⚠️ 内置条目返回 false(→ 核心回 400):它是**代码的一部分**,
|
|
365
|
+
* 删掉就意味着下一次发版它又回来了;用户对内置的处置手段是**关开关**
|
|
366
|
+
* 与**还原文本**,不是删除。
|
|
367
|
+
*/
|
|
368
|
+
removeCustomControl: (controlId) => {
|
|
369
|
+
if (!store) return false;
|
|
370
|
+
const data = readData();
|
|
371
|
+
const custom = Array.isArray(data.custom) ? data.custom : [];
|
|
372
|
+
if (!custom.some((c) => c.id === controlId)) return false;
|
|
373
|
+
store.writeData(EXT_PROMPT_ID, {
|
|
374
|
+
...data,
|
|
375
|
+
custom: custom.filter((c) => c.id !== controlId),
|
|
376
|
+
...data.categories ? { categories: omit(data.categories, controlId) } : {},
|
|
377
|
+
...data.text ? { text: omit(data.text, controlId) } : {},
|
|
378
|
+
...data.order ? { order: data.order.filter((id) => id !== controlId) } : {}
|
|
379
|
+
});
|
|
380
|
+
return true;
|
|
381
|
+
},
|
|
382
|
+
/**
|
|
383
|
+
* 还原一条的文本(丢掉覆盖,回到内置)。
|
|
384
|
+
*
|
|
385
|
+
* ⚠️ 自定义条目返回 false —— 它**没有"内置版本"**可回退;
|
|
386
|
+
* 它的对应手段是「删除」。
|
|
387
|
+
*/
|
|
388
|
+
resetControlText: (controlId) => {
|
|
389
|
+
if (!store) return false;
|
|
390
|
+
const data = readData();
|
|
391
|
+
if (!PROMPT_CATEGORIES.some((c) => c.id === controlId)) return false;
|
|
392
|
+
if (data.text?.[controlId] === void 0) return false;
|
|
393
|
+
store.writeData(EXT_PROMPT_ID, {
|
|
394
|
+
...data,
|
|
395
|
+
text: omit(data.text, controlId)
|
|
396
|
+
});
|
|
397
|
+
return true;
|
|
398
|
+
},
|
|
399
|
+
getState: () => deps.isSystemPromptReady() ? { ready: true } : {
|
|
400
|
+
ready: false,
|
|
401
|
+
detail: "ctx.systemPrompt 不可用 —— 本扩展无处注入"
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
//#endregion
|
|
406
|
+
//#region src/ext-prompt/mount.ts
|
|
407
|
+
/**
|
|
408
|
+
* 挂载层 —— 把准则注册成 `ctx.systemPrompt` 的**一个**段落。
|
|
409
|
+
*
|
|
410
|
+
* ════════════════════════════════════════════════════════════════════
|
|
411
|
+
* 为什么只注册一段,而不是 14 段
|
|
412
|
+
* ════════════════════════════════════════════════════════════════════
|
|
413
|
+
* 面板开关的粒度是「分类」,但 prompt 读起来应该是一段连续的准则。分成 14 段
|
|
414
|
+
* 会让 KV cache 每个分类各占一段,且**增删任意一个分类都让它之后的全部分位**。
|
|
415
|
+
* 一段 + 拼装 ⇒ 分类增删只改这一段内部,段的位置与前缀稳定。
|
|
416
|
+
*
|
|
417
|
+
* ⚠️ **空串即关闭,这是平台原生语义**(`renderPrompt` 的注释原文:
|
|
418
|
+
* "drop empty sections")⇒ "可开关"不需要任何自造机制:本函数返回 `''`,
|
|
419
|
+
* 上游就把整段丢掉。**不要自己实现"删段落"的逻辑**,那会和平台重复且会漂移。
|
|
420
|
+
*
|
|
421
|
+
* ⚠️ 文本用 provider 函数(`(context) => string`)而不是静态字符串:
|
|
422
|
+
* 分类开关随时可改,静态文本**注册那一刻就定死了**,改开关不生效。
|
|
423
|
+
*/
|
|
424
|
+
/** 段落名(**稳定**):改名会让已注册的作用域 section 与旧名并存。 */
|
|
425
|
+
const PROMPT_SECTION_NAME = "dsh-router-ext-prompt:guideline";
|
|
426
|
+
/**
|
|
427
|
+
* 段落 order:100。
|
|
428
|
+
*
|
|
429
|
+
* 官方分配的槽位(`dsh-system-prompt` 的 `SECTION_ORDERS`):
|
|
430
|
+
* `PERSONA_PREFIX: 0` · `PLAN_POLICY: 500` · `TEAM_POLICY: 600` …·
|
|
431
|
+
* 取 100 = 紧随 persona、远早于任何工具段 ⇒ 准则出现在身份之后、工具说明之前,
|
|
432
|
+
* 与用户看到的记忆快照位置一致。
|
|
433
|
+
*/
|
|
434
|
+
const SECTION_ORDER = 100;
|
|
435
|
+
/**
|
|
436
|
+
* 挂载准则段落。返回卸载函数。
|
|
437
|
+
*
|
|
438
|
+
* ⚠️ `systemPrompt` 尚不可用时**不挂**(返回 no-op 清理),并由 `ext.getState()`
|
|
439
|
+
* 报 `ready: false` 让面板红字显示。**不做轮询重试** —— 显式"没就绪"好过
|
|
440
|
+
* 半开的注入:后者会让准则随机时有时无,而用户看不出为什么。
|
|
441
|
+
* `index.ts` 用 `ctx.inject(['systemPrompt'])` 保证挂载时机,本函数只管挂。
|
|
442
|
+
*/
|
|
443
|
+
function mountPromptSection(deps) {
|
|
444
|
+
const sp = deps.getSystemPrompt();
|
|
445
|
+
if (!sp) return () => {};
|
|
446
|
+
const dispose = sp.section({
|
|
447
|
+
name: PROMPT_SECTION_NAME,
|
|
448
|
+
order: SECTION_ORDER,
|
|
449
|
+
text: () => renderCurrent(deps)
|
|
450
|
+
});
|
|
451
|
+
return () => {
|
|
452
|
+
dispose();
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/** 算出当前应注入的文本(薄壳:真逻辑在 render.ts,可单测)。 */
|
|
456
|
+
function renderCurrent(deps) {
|
|
457
|
+
return renderPromptText(deps.isEnabled(), deps.readData());
|
|
458
|
+
}
|
|
459
|
+
//#endregion
|
|
460
|
+
//#region src/ext-prompt/index.ts
|
|
461
|
+
/** cordis 插件身份(配置树里这一行的技术名;显示名走同目录的 locale)。 */
|
|
462
|
+
const name = "dsh-router-ext-prompt";
|
|
463
|
+
/** `ctx.systemPrompt` 的最小可见面(见 mount.ts)。 */
|
|
464
|
+
function systemPromptOf(sctx) {
|
|
465
|
+
const c = sctx;
|
|
466
|
+
const sp = c.get?.("systemPrompt") ?? c.systemPrompt;
|
|
467
|
+
return sp && typeof sp.section === "function" ? sp : void 0;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* 声明本扩展依赖的 service。
|
|
471
|
+
*
|
|
472
|
+
* ⚠️⚠️ **必须导出这个数组,且 `apply` 里直接用 `ctx.systemPrompt`**(2026-09-29 修)
|
|
473
|
+
* ——我原先写成「在 apply 里 `ctx.inject(['systemPrompt'], cb)`」,**准则一直不进
|
|
474
|
+
* prompt**:面板上卡片、开关、原文全都正常(`GET /router/api/ext` 实测有 14 条
|
|
475
|
+
* controls),但模型看到的 system prompt 里没有它。
|
|
476
|
+
*
|
|
477
|
+
* 根因(本机 cordis 4.0.4 实测):`ctx.inject(deps, cb)` 的回调**不是同步触发** ——
|
|
478
|
+
* provide 之后立刻 `inject`,回调要等到下一个异步点才跑。插件的 `apply` 是**同步
|
|
479
|
+
* 函数**,返回时那次回调还没发生;而真实加载路径不保证之后还有机会去等它,
|
|
480
|
+
* 于是 `mountPromptSection` 从没被执行 ⇒ **准则一次都没进过 prompt**。
|
|
481
|
+
*
|
|
482
|
+
* ⚠️ 我第一版把这个机制写成「要等 `ctx.start()`」——**那是错的**:`start` 根本不在
|
|
483
|
+
* Context 的公开面上(原型只有 extend/isolate/intercept)。准确说法是「下一个
|
|
484
|
+
* 异步点」,具体机制随版本变。**结论不依赖这个细节**,只依赖「apply 里手动
|
|
485
|
+
* inject 不可靠」这一事实。
|
|
486
|
+
*
|
|
487
|
+
* 正确形状(对照 `dsh-client-ui-deliverables` 等真实使用者):导出 `inject` 数组,
|
|
488
|
+
* cordis 看到它就**把整个 apply 推迟到这些 service 就绪之后**再调用 ——
|
|
489
|
+
* 于是 `ctx.systemPrompt` 一定是活的,`section()` 可以直接调。
|
|
490
|
+
*
|
|
491
|
+
* ⚠️ `router.ext` 也要列:那一半(面板登记)此前"碰巧"能用,是因为
|
|
492
|
+
* `dsh-router` 核心先 provide 了它、`apply` 又跑在它之后。**那是时序上的运气,
|
|
493
|
+
* 不是保证** —— 一并列进 `inject`,两种 service 都由 cordis 编排。
|
|
494
|
+
*/
|
|
495
|
+
const inject = [
|
|
496
|
+
"systemPrompt",
|
|
497
|
+
"router.ext",
|
|
498
|
+
"router.extStore"
|
|
499
|
+
];
|
|
500
|
+
/**
|
|
501
|
+
* 登记本扩展**并**挂载准则段落。
|
|
502
|
+
*
|
|
503
|
+
* 此刻 `ctx.systemPrompt` / `ctx.router.ext` / `ctx.router.extStore` **必定已就绪**
|
|
504
|
+
* (见上面 `inject` 的注释),所以下面**直接用**,不再做二次判空。
|
|
505
|
+
*/
|
|
506
|
+
function apply(ctx) {
|
|
507
|
+
const table = currentExts(ctx);
|
|
508
|
+
const store = currentExtStore(ctx);
|
|
509
|
+
const dispose = mountPromptSection({
|
|
510
|
+
getSystemPrompt: () => systemPromptOf(ctx),
|
|
511
|
+
isEnabled: () => store?.isEnabled(EXT_PROMPT_ID) === true,
|
|
512
|
+
readData: () => store?.readData(EXT_PROMPT_ID)
|
|
513
|
+
});
|
|
514
|
+
if (table !== void 0 && table["prompt"] === void 0) {
|
|
515
|
+
table[EXT_PROMPT_ID] = createPromptExt({
|
|
516
|
+
isSystemPromptReady: () => !!systemPromptOf(ctx),
|
|
517
|
+
store
|
|
518
|
+
});
|
|
519
|
+
store?.setEnabled(EXT_PROMPT_ID, true);
|
|
520
|
+
}
|
|
521
|
+
ctx.effect(() => () => {
|
|
522
|
+
dispose();
|
|
523
|
+
if (table !== void 0) delete table[EXT_PROMPT_ID];
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
//#endregion
|
|
527
|
+
export { apply, inject, name };
|
package/lib/ext-test/index.js
CHANGED
|
@@ -1,15 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
/** 读取当前 router.ext 表(同一 live 对象,可追加)——扩展插件用它注册自己。 */
|
|
3
|
-
function currentExts(ctx) {
|
|
4
|
-
const c = ctx;
|
|
5
|
-
return c.get?.("router.ext") ?? c.router?.ext;
|
|
6
|
-
}
|
|
7
|
-
/** 读取扩展存储服务——扩展插件用它读开关、读写自己存的数据。 */
|
|
8
|
-
function currentExtStore(ctx) {
|
|
9
|
-
const c = ctx;
|
|
10
|
-
return c.get?.("router.extStore") ?? c.router?.extStore;
|
|
11
|
-
}
|
|
12
|
-
//#endregion
|
|
1
|
+
import { n as currentExts, t as currentExtStore } from "../contract-D4BWZ7Xe.js";
|
|
13
2
|
//#region src/ext-test/plugin.ts
|
|
14
3
|
/** 扩展开关表里的注册键(也是 URL 面板读它的 id)。 */
|
|
15
4
|
const EXT_TEST_ID = "test";
|