dsh-prompt-customizer 0.4.0 → 0.4.2
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/LICENSE +21 -21
- package/README.en.md +278 -227
- package/README.i18n.yaml +2 -2
- package/README.md +278 -227
- package/client/client.js +82 -80
- package/client/client.js.map +1 -1
- package/cordis.patch.yml +8 -8
- package/lib/catalog.js +131 -131
- package/lib/effective.js +161 -158
- package/lib/index.js +943 -943
- package/lib/promotion.js +80 -80
- package/lib/schema.js +124 -124
- package/lib/sectionOps.d.mts +32 -32
- package/lib/sectionOps.mjs +128 -133
- package/lib/store.js +124 -124
- package/lib/vars.js +147 -147
- package/package.json +84 -64
- package/src/client/PresetsTab.tsx +281 -281
- package/src/client/PreviewTab.tsx +91 -91
- package/src/client/PreviewTools.tsx +33 -33
- package/src/client/SectionsTab.tsx +539 -556
- package/src/client/ToolsTab.tsx +293 -266
- package/src/client/index.ts +433 -422
- package/src/client/locales.ts +263 -259
- package/src/client/preset-io.ts +226 -226
- package/src/client/presets.ts +413 -408
- package/src/client/styles.ts +49 -49
- package/src/client/types.ts +128 -128
package/lib/index.js
CHANGED
|
@@ -1,944 +1,944 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-prompt-customizer — 宿主端。
|
|
3
|
-
*
|
|
4
|
-
* - 配置存储在插件自有文件 `~/.dsh/prompt-customizer/config.yaml`(可用组合
|
|
5
|
-
* 入口 `dataDir` 覆盖目录),不再占用主文档 settings.yaml。首次启动时若
|
|
6
|
-
* 新文件不存在而主文档里有旧 `prompt-customizer:` 段,自动迁移(主文档
|
|
7
|
-
* 原样保留,可随时回滚旧版),此后本插件不再注册设置命名空间——主文档
|
|
8
|
-
* 的设置页也不会再显示本段落。
|
|
9
|
-
* - 挂钩 `system-prompt/assemble` 瀑布流,按名称屏蔽 / 替换 / 注入提示词段,
|
|
10
|
-
* 并从模型目录中隐藏工具。只影响面向模型的目录;工具与路由照常工作。
|
|
11
|
-
* - 提供 `GET /api/prompt-customizer/inventory`(段/工具清单,`?scope=` 可切
|
|
12
|
-
* agent 预设;`scopeResolved` 标记 scope 是否成功挂载)、
|
|
13
|
-
* `GET /api/prompt-customizer/config` 与 `POST /config/set|/config/unset`
|
|
14
|
-
* (单字段读写)、`POST /config/apply`(批量保存,UI 统一保存按钮的写盘通道)。
|
|
15
|
-
*
|
|
16
|
-
* 定制按作用域生效:顶层字段是全局默认;`overrides` 按 agent 预设 id 提供
|
|
17
|
-
* 字段级覆盖(非空字段整体接管,空缺回落全局)。注入段支持 `phase`
|
|
18
|
-
* (always/bootstrap/active),工具过滤支持未晋级阶段的 bootstrap 目录与
|
|
19
|
-
* 压缩后未晋级阶段的 compaction 目录(三态:compaction > bootstrap > 静态)——
|
|
20
|
-
* 晋级由 durable 会话事件推导(compaction 复位,subagent 恒已晋级)。
|
|
21
|
-
*
|
|
22
|
-
* 客户端对 bootstrap / compaction / active 三套名义装配各拉取一次
|
|
23
|
-
* `/preview?phase=…`,三个阶段部分**恒定全部显示**(预设没有某个阶段时,
|
|
24
|
-
* 该部分只是空的)—— 不同 agent 预设的「agent 周期」不同(standard 三态同形,
|
|
25
|
-
* minimal 一个形态,router / warmup 两个,anchored 家族三个),但那是各阶段
|
|
26
|
-
* 内容是否相同的问题,不构成把阶段藏起来的理由。
|
|
27
|
-
*
|
|
28
|
-
* 另一面:若该 scope 有以 `complete: true` 注册的段(persona 整段接管),宿主
|
|
29
|
-
* 会在装配瀑布流**之后**把 sections 强制还原成那一条段(见 dsh-system-prompt
|
|
30
|
-
* 的 assemble 契约),本插件的段级屏蔽 / 替换 / 注入 / 排序对其完全不生效
|
|
31
|
-
* (工具过滤不受影响)。`forceSections`(默认开)为此提供终极手段:包装宿主
|
|
32
|
-
* 的 assemble,在方法边界用注册表原始段重建 sections —— 预设插件的阶段裁段
|
|
33
|
-
* (实测 liangshen / warmupbetter 等 preset 过滤器全部 prepend: true)与
|
|
34
|
-
* complete 整段接管都无法再改写结果。关闭时预览过滤器会探测并把段名作为
|
|
35
|
-
* `takenOverBy` 透出,让提示词 / 预览两个 Tab 就这件事说同一句话。
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
import fs from 'node:fs'
|
|
39
|
-
import os from 'node:os'
|
|
40
|
-
import path from 'node:path'
|
|
41
|
-
import { Config, NS } from './schema.js'
|
|
42
|
-
import { mergeConfig, pickToolsFilter, pickSectionsForStatus, applyToolFilter, applySectionPolicy } from './effective.js'
|
|
43
|
-
import { createPromotion, presetOfSession } from './promotion.js'
|
|
44
|
-
import { createConfigStore, readLegacySection } from './store.js'
|
|
45
|
-
import { createCatalog } from './catalog.js'
|
|
46
|
-
import { registerVariables, listVariableNames } from './vars.js'
|
|
47
|
-
|
|
48
|
-
export const name = 'prompt-customizer'
|
|
49
|
-
// settings 仍被注入:只读 documentPath 做一次性旧版迁移。cordis 对未在
|
|
50
|
-
// inject 里声明的服务调用 ctx.get 会抛错,漏声明会让整个插件加载失败。
|
|
51
|
-
export const inject = ['settings', 'systemPrompt', 'tools']
|
|
52
|
-
|
|
53
|
-
/** assemble 强制覆盖的「原始方法」登记键:跨插件 HMR 重载共享(Symbol.for),
|
|
54
|
-
* 重载后永远包在原始方法之上,避免层层套娃。见 apply() 里的 forceSections。 */
|
|
55
|
-
const PATCHED = Symbol.for('prompt-customizer:assemble-pristine')
|
|
56
|
-
|
|
57
|
-
/** harness home;ponytail: DSH_HOME 为推测的官方环境变量,若宿主日后提供
|
|
58
|
-
* 正式的 home 解析 API 应换过去。回退 `~/.dsh`。 */
|
|
59
|
-
function dshHome() {
|
|
60
|
-
return process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** 收集并解析一个请求体(JSON),超过上限直接拒绝——路由是本进程私有的,上限只是防御。 */
|
|
64
|
-
function readJsonBody(req, limit = 2 * 1024 * 1024) {
|
|
65
|
-
return new Promise((resolve, reject) => {
|
|
66
|
-
let size = 0
|
|
67
|
-
const chunks = []
|
|
68
|
-
req.on('data', (chunk) => {
|
|
69
|
-
size += chunk.length
|
|
70
|
-
if (size > limit) {
|
|
71
|
-
reject(new Error('request body too large'))
|
|
72
|
-
req.destroy()
|
|
73
|
-
return
|
|
74
|
-
}
|
|
75
|
-
chunks.push(chunk)
|
|
76
|
-
})
|
|
77
|
-
req.on('end', () => {
|
|
78
|
-
try {
|
|
79
|
-
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
80
|
-
} catch (error) {
|
|
81
|
-
reject(new Error(`invalid JSON body: ${error.message}`))
|
|
82
|
-
}
|
|
83
|
-
})
|
|
84
|
-
req.on('error', reject)
|
|
85
|
-
})
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* 一次性清理遗留的 include(白名单)设置:过滤只剩 exclude 黑名单,服务端不再
|
|
90
|
-
* 读取 include,界面也不再写它。
|
|
91
|
-
*
|
|
92
|
-
* 只要 include 键存在就剥掉并落盘(空数组同样是无意义字段);但只有真去掉了
|
|
93
|
-
* 有内容的名单才记一条日志(列出去处与名字)—— 不为一串空数组惊动用户。
|
|
94
|
-
*/
|
|
95
|
-
function stripLegacyInclude(store, log) {
|
|
96
|
-
const raw = store.raw()
|
|
97
|
-
if (raw === null || typeof raw !== 'object') return
|
|
98
|
-
const stats = { stripped: 0, reported: [] }
|
|
99
|
-
const walk = (node, where) => {
|
|
100
|
-
if (node === null || typeof node !== 'object' || Array.isArray(node)) return
|
|
101
|
-
if (Array.isArray(node.include)) {
|
|
102
|
-
stats.stripped += 1
|
|
103
|
-
if (node.include.length > 0) stats.reported.push(`${where}: [${node.include.join(', ')}]`)
|
|
104
|
-
delete node.include
|
|
105
|
-
}
|
|
106
|
-
for (const phase of ['bootstrap', 'compaction']) walk(node[phase], `${where}.${phase}`)
|
|
107
|
-
}
|
|
108
|
-
walk(raw.tools, 'tools')
|
|
109
|
-
if (raw.overrides !== null && typeof raw.overrides === 'object') {
|
|
110
|
-
for (const id of Object.keys(raw.overrides)) walk(raw.overrides[id]?.tools, `overrides.${id}.tools`)
|
|
111
|
-
}
|
|
112
|
-
if (Array.isArray(raw.presets)) {
|
|
113
|
-
for (const preset of raw.presets) walk(preset?.data?.tools, `presets.${preset?.name}.data.tools`)
|
|
114
|
-
}
|
|
115
|
-
if (stats.stripped === 0) return
|
|
116
|
-
try {
|
|
117
|
-
store.writeSection(raw)
|
|
118
|
-
if (stats.reported.length > 0) {
|
|
119
|
-
log(`prompt-customizer: 白名单(include)语义已移除,已从配置里清掉 ${stats.reported.length} 处有内容的名单 —— ${stats.reported.join(';')}。这些阶段现在不设限制;要重新限制就去工具 Tab 逐项取消勾选。`)
|
|
120
|
-
}
|
|
121
|
-
} catch (error) {
|
|
122
|
-
try { log(`prompt-customizer: 遗留 include 清理失败(不影响运行,该字段已被忽略):${String(error && error.message ? error.message : error)}`) } catch { /* logger 缺席 */ }
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export function apply(ctx, entry = {}) {
|
|
127
|
-
// 1) 插件自有配置文件(不再注册 settings 命名空间)。
|
|
128
|
-
const dataDir = typeof entry.dataDir === 'string' && entry.dataDir.length > 0
|
|
129
|
-
? entry.dataDir
|
|
130
|
-
: path.join(dshHome(), NS)
|
|
131
|
-
const configFile = path.join(dataDir, 'config.yaml')
|
|
132
|
-
const store = createConfigStore({
|
|
133
|
-
file: configFile,
|
|
134
|
-
schema: Config,
|
|
135
|
-
warn: (message) => { try { ctx.logger?.warn?.(message) } catch { /* logger 缺席 */ } },
|
|
136
|
-
})
|
|
137
|
-
const read = () => store.readResolved()
|
|
138
|
-
// 「本系统全部提示词 / 全部工具」的累积登记表:清单与预览每次看到什么就并进
|
|
139
|
-
// 什么(同名后见到者赢),所以池子不随编辑目标切换、只增不减。派生缓存,
|
|
140
|
-
// 删掉随浏览重新长出来。
|
|
141
|
-
const catalog = createCatalog({
|
|
142
|
-
dir: dataDir,
|
|
143
|
-
warn: (message) => { try { ctx.logger?.warn?.(message) } catch { /* logger 缺席 */ } },
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
// 一次性迁移:新文件不存在而主文档里有旧段 → 原样复制过来。主文档不动,
|
|
147
|
-
// 旧版随时可回滚;迁移后新文件是唯一权威。
|
|
148
|
-
if (!fs.existsSync(configFile)) {
|
|
149
|
-
const master = ctx.get('settings')?.documentPath
|
|
150
|
-
const legacy = readLegacySection(master, NS)
|
|
151
|
-
if (legacy) {
|
|
152
|
-
store.writeSection(legacy)
|
|
153
|
-
try { ctx.logger?.info?.(`prompt-customizer: 已把旧版配置从 ${master} 迁移到 ${configFile}`) } catch { /* logger 缺席 */ }
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// 一次性清理:include(白名单)语义已从读取与写入两侧彻底移除。遗留的
|
|
158
|
-
// include 既不再生效、界面也不再表达,留着只会让配置堆积无意义字段 ——
|
|
159
|
-
// 启动时剥掉并记一条日志(逐处列出被去掉的名字),不静默改用户文件。
|
|
160
|
-
stripLegacyInclude(store, (message) => { try { ctx.logger?.info?.(message) } catch { /* logger 缺席 */ } })
|
|
161
|
-
|
|
162
|
-
// 2) 晋级 tracker:增量维护每会话的 bootstrap/active 阶段(durable 事件
|
|
163
|
-
// 推导,compaction 复位,subagent 恒已晋级)。
|
|
164
|
-
const promotion = createPromotion()
|
|
165
|
-
ctx.on('session/event', (session, event) => {
|
|
166
|
-
try { promotion.observe(session, event) } catch { /* tracker 故障绝不影响事件流 */ }
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
// 单次降级告警:过滤逻辑出错时退回未加工的装配,绝不拖垮会话。
|
|
170
|
-
let warned = false
|
|
171
|
-
const warnOnce = (message) => {
|
|
172
|
-
if (warned) return
|
|
173
|
-
warned = true
|
|
174
|
-
try { ctx.logger?.warn?.(message) } catch { /* logger 缺席只是不再告警 */ }
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// 提示词变量注册(内置系统事实 + process.env 全量,黑名单过滤):宿主只有
|
|
178
|
-
// provider / model / cwd 三个,段文本里的其余 {{name}} 一律严格报错。这里
|
|
179
|
-
// 通过官方扩展点 systemPrompt.variable() 补齐。懒同步:每次装配入口比对黑名单
|
|
180
|
-
// 签名,变了才注销重注册 —— 配置写入 / 外部手改都在下次装配自然生效。
|
|
181
|
-
let varsSignature
|
|
182
|
-
let varsDispose
|
|
183
|
-
const syncVariables = () => {
|
|
184
|
-
try {
|
|
185
|
-
const blocklist = read().envBlocklist ?? []
|
|
186
|
-
const signature = JSON.stringify(blocklist)
|
|
187
|
-
if (signature === varsSignature) return
|
|
188
|
-
try { varsDispose?.() } catch { /* 注销失败不阻塞重注册 */ }
|
|
189
|
-
varsDispose = registerVariables(ctx.get('systemPrompt'), blocklist)
|
|
190
|
-
varsSignature = signature
|
|
191
|
-
} catch (error) {
|
|
192
|
-
warnOnce(`prompt-customizer: 提示词变量注册失败({{…}} 仍只有宿主注册的变量可用):${String((error && error.message) || error)}`)
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// 3) 装配目标解析(瀑布流与 assemble 强制覆盖共用):生效配置 = 显式预览
|
|
197
|
-
// 提示 > 会话所属 agent 预设(字段级覆盖)> 全局默认。预览 hint 优先于
|
|
198
|
-
// 会话事件推导的阶段,让同一份配置能渲染 bootstrap / compaction / active
|
|
199
|
-
// 三种阶段视图。boundary 语义与 promotion.status 一致(-1 = 从未压缩)。
|
|
200
|
-
const resolveTarget = (context) => {
|
|
201
|
-
const raw = read()
|
|
202
|
-
const id = typeof context?.promptCustomizerPreset === 'string'
|
|
203
|
-
? context.promptCustomizerPreset
|
|
204
|
-
: presetOfSession(context?.agent?.session)
|
|
205
|
-
const cfg = mergeConfig(raw, raw.overrides?.[id])
|
|
206
|
-
const phaseHint = typeof context?.promptCustomizerPhase === 'string'
|
|
207
|
-
? context.promptCustomizerPhase
|
|
208
|
-
: undefined
|
|
209
|
-
const status = phaseHint === 'bootstrap' ? { promoted: false, boundary: -1 }
|
|
210
|
-
: phaseHint === 'compaction' ? { promoted: false, boundary: 1 }
|
|
211
|
-
: phaseHint === 'active' ? { promoted: true, boundary: -1 }
|
|
212
|
-
: promotion.status(context?.agent)
|
|
213
|
-
return { raw, cfg, status }
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// 装配瀑布流:实时应用字段级 override、屏蔽 / 替换 / 注入 / 阶段化工具过滤。
|
|
217
|
-
ctx.on('system-prompt/assemble', async (assembly, context, next) => {
|
|
218
|
-
// 下游错误原样传播;只有本过滤器自身的逻辑被兜底。
|
|
219
|
-
const assembled = await next()
|
|
220
|
-
try {
|
|
221
|
-
const { raw, cfg, status } = resolveTarget(context)
|
|
222
|
-
const denied = new Set(
|
|
223
|
-
const replace = cfg.replace ?? {}
|
|
224
|
-
const te = pickToolsFilter(cfg.tools, status)
|
|
225
|
-
|
|
226
|
-
// 预览专用的预过滤视图(提示词 / 工具 / 预览三个 Tab 的统一数据源):
|
|
227
|
-
// 记录进入本过滤规则的段 / 工具原文,并附上该阶段专属的屏蔽 / 隐藏标记,
|
|
228
|
-
// 让 UI 能正确回显「每阶段独立名单」和工具的逐项隐藏(隐藏后仍在列表)。
|
|
229
|
-
// 运行时装配不带该 hint,零额外开销。
|
|
230
|
-
const baseView = context?.promptCustomizerBase === true
|
|
231
|
-
? {
|
|
232
|
-
sections: Array.isArray(assembled.sections) ? assembled.sections.map((section) => ({
|
|
233
|
-
name: section.name,
|
|
234
|
-
text: typeof section.text === 'function' ? '<动态生成>' : String(section.text ?? ''),
|
|
235
|
-
blocked: denied.has(section.name),
|
|
236
|
-
replaced: Object.hasOwn(replace, section.name),
|
|
237
|
-
})) : [],
|
|
238
|
-
tools: Array.isArray(assembled.tools) ? assembled.tools.map((tool) => ({
|
|
239
|
-
name: typeof tool.name === 'string' ? tool.name : String(tool.name),
|
|
240
|
-
description: typeof tool.description === 'string' ? tool.description : '',
|
|
241
|
-
hidden: (te.exclude ?? []).includes(tool.name),
|
|
242
|
-
})) : [],
|
|
243
|
-
}
|
|
244
|
-
: undefined
|
|
245
|
-
|
|
246
|
-
// 本插件过滤前的原始输入(段 / 工具)—— 预过滤视图与 post 视图同源,
|
|
247
|
-
// 让提示词 / 工具 / 预览三个 Tab 与运行时装配所见一致。
|
|
248
|
-
const baseSections = Array.isArray(assembled.sections) ? assembled.sections : []
|
|
249
|
-
const baseTools = Array.isArray(assembled.tools) ? assembled.tools : []
|
|
250
|
-
|
|
251
|
-
// 段级策略(屏蔽 / 替换 / 注入 / 排序)由 effective.applySectionPolicy
|
|
252
|
-
// 统一实现:瀑布流与 assemble 强制覆盖共用同一份逻辑。
|
|
253
|
-
const sections = applySectionPolicy(baseSections, cfg, status)
|
|
254
|
-
|
|
255
|
-
const tools = appendAddedTools(ctx, context?.scope, applyToolFilter(baseTools, te), te.add, te.exclude)
|
|
256
|
-
|
|
257
|
-
const result = { ...assembled, sections, tools }
|
|
258
|
-
if (baseView) {
|
|
259
|
-
result.promptCustomizerBaseView = {
|
|
260
|
-
sections: baseSections.map((section) => ({
|
|
261
|
-
name: section.name,
|
|
262
|
-
text: typeof section.text === 'function' ? '<动态生成>' : String(section.text ?? ''),
|
|
263
|
-
blocked: denied.has(section.name),
|
|
264
|
-
replaced: Object.hasOwn(replace, section.name),
|
|
265
|
-
})),
|
|
266
|
-
tools: baseTools.map((tool) => ({
|
|
267
|
-
name: typeof tool.name === 'string' ? tool.name : String(tool.name),
|
|
268
|
-
description: typeof tool.description === 'string' ? tool.description : '',
|
|
269
|
-
hidden: (te.exclude ?? []).includes(tool.name),
|
|
270
|
-
})),
|
|
271
|
-
}
|
|
272
|
-
// 预览才探测(运行时装配零额外开销):有 complete 段时宿主要在水流
|
|
273
|
-
// 之后把 sections 整段还原,本插件的段级定制不会进最终提示词。
|
|
274
|
-
// forceSections 开启时该接管已被 assemble 包装绕过,警告不再成立。
|
|
275
|
-
const takenOver = detectCompletePrompt(ctx, context?.scope)
|
|
276
|
-
if (takenOver !== undefined && raw?.forceSections === false) result.promptCustomizerTakenOver = takenOver
|
|
277
|
-
// 本插件实际产出的段名清单:下游还有别的手段会丢弃段(实测 liangshen
|
|
278
|
-
// 引导期由预设自身规则把 20 段砍到 1 段,注册表里并没有 complete 段),
|
|
279
|
-
// 所以 buildPreview 还要拿这份与最终装配比对,做机制无关的兜底探测。
|
|
280
|
-
result.promptCustomizerEmitted = sections.map((section) => String(section.name))
|
|
281
|
-
}
|
|
282
|
-
return result
|
|
283
|
-
} catch (error) {
|
|
284
|
-
warnOnce(`prompt-customizer: 过滤器异常,本次装配回退为未加工结果:${String((error && error.message) || error)}`)
|
|
285
|
-
return assembled
|
|
286
|
-
}
|
|
287
|
-
})
|
|
288
|
-
|
|
289
|
-
// 4) 强制覆盖(forceSections,默认开):包装宿主 systemPrompt.assemble。
|
|
290
|
-
// 预设插件的 prepend 军备竞赛(实测 liangshen / warmupbetter 等 preset 的
|
|
291
|
-
// assemble 过滤器全部 `prepend: true`,standing mount 后注册永远排在本插件
|
|
292
|
-
// 前面)与宿主的 complete 整段接管(瀑布流之后还原 sections)都无法用瀑布流
|
|
293
|
-
// 位次对抗;唯一确定性的手段是在方法边界重建 sections —— 从注册表
|
|
294
|
-
// layers.merge() 取原始段定义(全局 + scope 链遮蔽后,scope = agent 时沿
|
|
295
|
-
// 链解析到 standing 预设层),套用本插件的屏蔽 / 替换 / 注入 / 排序,
|
|
296
|
-
// tools / contexts / variables 保持宿主与预设行为。
|
|
297
|
-
// ponytail: 依赖宿主未承诺的 service 方法与 layers 公开字段;宿主升级可能
|
|
298
|
-
// 静默失效 —— 失效时本包装不生效、瀑布流过滤仍在,可设 forceSections: false
|
|
299
|
-
// 退回旧行为,预览的 takenOverBy / lostSections 探测仍可作运行时自检。
|
|
300
|
-
const sp = ctx.get('systemPrompt')
|
|
301
|
-
if (sp && typeof sp.assemble === 'function') {
|
|
302
|
-
// 只在首个 apply() 记一次原始方法;插件 HMR 重载重跑 apply() 时用新闭包
|
|
303
|
-
// 重新包装,但永远包在原始方法之上,避免层层套娃。
|
|
304
|
-
const pristine = sp[PATCHED]?.orig ?? sp.assemble.bind(sp)
|
|
305
|
-
sp[PATCHED] = { orig: pristine }
|
|
306
|
-
sp.assemble = async (context = {}) => {
|
|
307
|
-
// 变量先于装配同步:宿主在 pristine 内部解析 {{name}},注册必须发生在前。
|
|
308
|
-
syncVariables()
|
|
309
|
-
const result = await pristine(context)
|
|
310
|
-
if (read()?.forceSections === false) return result
|
|
311
|
-
try {
|
|
312
|
-
const { cfg, status } = resolveTarget(context)
|
|
313
|
-
const defs = [...sp.layers.merge(context?.scope, (layer) => layer.sections).values()]
|
|
314
|
-
.sort((a, b) => a.order - b.order)
|
|
315
|
-
const baseSections = defs.map((section) => ({
|
|
316
|
-
name: section.name,
|
|
317
|
-
// 与宿主一致:动态段文本调用生成函数(可能被调用两次 —— 宿主装配
|
|
318
|
-
// 内部已调用一次;动态段通常是纯函数,可接受)。
|
|
319
|
-
text: typeof section.text === 'function' ? section.text(context) : section.text,
|
|
320
|
-
}))
|
|
321
|
-
const sections = applySectionPolicy(baseSections, cfg, status)
|
|
322
|
-
const patched = { ...result, sections }
|
|
323
|
-
// 预览的「产出 vs 模型所见」比对以强制覆盖后的最终段为准。
|
|
324
|
-
if (context?.promptCustomizerBase === true) {
|
|
325
|
-
patched.promptCustomizerEmitted = sections.map((section) => String(section.name))
|
|
326
|
-
}
|
|
327
|
-
return patched
|
|
328
|
-
} catch (error) {
|
|
329
|
-
warnOnce(`prompt-customizer: 强制覆盖 assemble 失败,本次回退为宿主结果:${String((error && error.message) || error)}`)
|
|
330
|
-
// 覆盖失效时恢复接管告警:否则 complete 段接管回来了、却因为上面的
|
|
331
|
-
// forceSections 开关 gate 而没有任何提示(静默降级)。
|
|
332
|
-
const takenOver = detectCompletePrompt(ctx, context?.scope)
|
|
333
|
-
if (takenOver !== undefined) result.promptCustomizerTakenOver = takenOver
|
|
334
|
-
return result
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// 5) 共享 webserver 上的清单路由(web profile 里必有)。
|
|
340
|
-
// 用 ctx.get() 保持可选 —— 未在 `inject` 中声明就去访问 ctx.webServer
|
|
341
|
-
// 会抛 "cannot get property without inject"。
|
|
342
|
-
const webserver = ctx.get('webServer')
|
|
343
|
-
if (webserver) {
|
|
344
|
-
const dispose = webserver.register({
|
|
345
|
-
kind: 'exact',
|
|
346
|
-
path: '/api/prompt-customizer/inventory',
|
|
347
|
-
handler: async (req, res) => {
|
|
348
|
-
try {
|
|
349
|
-
// 可选 `?scope=<agentPresetId>`:清单切换到该预设的 scope 与
|
|
350
|
-
// 生效配置(字段级 override 后),供 UI 按目标编辑;缺省为 standard。
|
|
351
|
-
// 宿主共享 webserver 的 req 是原始 IncomingMessage:没有 Express 的
|
|
352
|
-
// query 属性,必须自行解析 URL 参数。
|
|
353
|
-
const id = queryOf(req).get('scope') ?? undefined
|
|
354
|
-
// scope 挂载失败(如常驻挂载超时)会回退展示 standard / 全局层:
|
|
355
|
-
// scopeResolved 标记「请求的目标是否解析出了自己的 scope」,false 时
|
|
356
|
-
// UI 警示「清单并非该预设的原生目录」。
|
|
357
|
-
const resolved = id ? await resolveScopeFor(ctx, id) : await resolveStandardScope(ctx)
|
|
358
|
-
const scopeKey = resolved ?? (id ? await resolveStandardScope(ctx) : undefined)
|
|
359
|
-
const inventory = await buildInventory(ctx, read(), scopeKey, id, catalog)
|
|
360
|
-
// 变量清单随单:先同步注册(黑名单可能刚改过),再列出当前可用名字。
|
|
361
|
-
syncVariables()
|
|
362
|
-
writeJson(res, 200, { ...inventory, scopeResolved: resolved !== undefined, variables: listVariableNames(read().envBlocklist ?? []) })
|
|
363
|
-
} catch (error) {
|
|
364
|
-
writeJson(res, 500, {
|
|
365
|
-
ok: false,
|
|
366
|
-
error: String(error && error.message ? error.message : error),
|
|
367
|
-
})
|
|
368
|
-
}
|
|
369
|
-
},
|
|
370
|
-
})
|
|
371
|
-
ctx.effect(() => dispose, 'prompt-customizer: inventory route')
|
|
372
|
-
|
|
373
|
-
// 预览路由:渲染最终装配的系统提示词(经过所有插件的过滤,包括我们
|
|
374
|
-
// 自己的屏蔽/替换/注入),让用户确认没有其他插件注入的段造成污染。
|
|
375
|
-
// 可选 `?scope=<agentPresetId>` 预览指定 agent 预设的装配结果(省略时
|
|
376
|
-
// 使用 standard agent,与清单一致);可选 `?phase=<bootstrap|compaction|
|
|
377
|
-
// active>` 预览指定对话阶段的三态视图(省略时按晋级推导,只读路由下
|
|
378
|
-
// 即 active / 静态视图)。
|
|
379
|
-
const disposePreview = webserver.register({
|
|
380
|
-
kind: 'exact',
|
|
381
|
-
path: '/api/prompt-customizer/preview',
|
|
382
|
-
handler: async (req, res) => {
|
|
383
|
-
try {
|
|
384
|
-
const { scopeKey, hintId, phase, scopeResolved } = await previewTarget(ctx, req)
|
|
385
|
-
const preview = await buildPreview(ctx, scopeKey, hintId, phase, catalog)
|
|
386
|
-
writeJson(res, 200, { ...preview, scopeResolved, phase })
|
|
387
|
-
} catch (error) {
|
|
388
|
-
writeJson(res, 500, {
|
|
389
|
-
ok: false,
|
|
390
|
-
error: String(error && error.message ? error.message : error),
|
|
391
|
-
})
|
|
392
|
-
}
|
|
393
|
-
},
|
|
394
|
-
})
|
|
395
|
-
ctx.effect(() => disposePreview, 'prompt-customizer: preview route')
|
|
396
|
-
|
|
397
|
-
// agent 预设枚举路由:直接读 roster 的 list()(未 memo 化,运行中新增
|
|
398
|
-
// 的预设立即可见),给 UI 的目标选择器与预览选择器供货。
|
|
399
|
-
const disposePresets = webserver.register({
|
|
400
|
-
kind: 'exact',
|
|
401
|
-
path: '/api/prompt-customizer/agent-presets',
|
|
402
|
-
handler: async (_req, res) => {
|
|
403
|
-
try {
|
|
404
|
-
const presets = ctx.get('agentPresets')
|
|
405
|
-
const list = presets ? await presets.list() : []
|
|
406
|
-
writeJson(res, 200, {
|
|
407
|
-
ok: true,
|
|
408
|
-
presets: list.map((p) => ({ id: p.id, name: p.name ?? p.id, description: p.description, broken: p.broken })),
|
|
409
|
-
})
|
|
410
|
-
} catch (error) {
|
|
411
|
-
writeJson(res, 500, {
|
|
412
|
-
ok: false,
|
|
413
|
-
error: String(error && error.message ? error.message : error),
|
|
414
|
-
})
|
|
415
|
-
}
|
|
416
|
-
},
|
|
417
|
-
})
|
|
418
|
-
ctx.effect(() => disposePresets, 'prompt-customizer: agent-presets route')
|
|
419
|
-
|
|
420
|
-
// 存为 agent 预设:整体 fork 当前编辑目标的预设目录 —— 组成文件、preset.yml、
|
|
421
|
-
// 伴生 .mjs、技能目录一并带走(宿主 authoring API 负责复制并解引用符号链接,
|
|
422
|
-
// 副本自包含),再把提交来的配置写进本插件 overrides[新名]。于是新预设 =
|
|
423
|
-
// 来源预设的原样组成 + 我刚改好的定制,这才叫「保存的是我修改后的样子」。
|
|
424
|
-
// 我们不再自己生成骨架:骨架带着 persona complete: true,宿主会在水流之后把
|
|
425
|
-
// sections 还原成那一条段,段级定制在它上面根本不生效。
|
|
426
|
-
const disposePresetsCreate = webserver.register({
|
|
427
|
-
kind: 'exact',
|
|
428
|
-
path: '/api/prompt-customizer/presets',
|
|
429
|
-
handler: async (req, res) => {
|
|
430
|
-
try {
|
|
431
|
-
const body = await readJsonBody(req)
|
|
432
|
-
const name = typeof body?.name === 'string' ? body.name.trim() : ''
|
|
433
|
-
if (!/^[\p{L}\p{N}_-][\p{L}\p{N} _-]{0,63}$/u.test(name)) {
|
|
434
|
-
writeJson(res, 400, { ok: false, error: '预设名只能包含中英文、数字、空格、下划线与连字符(≤64 字符),且不以标点开头' })
|
|
435
|
-
return
|
|
436
|
-
}
|
|
437
|
-
const roster = ctx.get('agentPresets')
|
|
438
|
-
if (roster === undefined || typeof roster.copy !== 'function') {
|
|
439
|
-
writeJson(res, 501, { ok: false, error: '宿主未提供 agent 预设服务(dsh-agent-presets),无法创建预设' })
|
|
440
|
-
return
|
|
441
|
-
}
|
|
442
|
-
if (roster.authorable === false) {
|
|
443
|
-
writeJson(res, 400, { ok: false, error: '当前部署没有可写的用户预设根目录,无法创建预设' })
|
|
444
|
-
return
|
|
445
|
-
}
|
|
446
|
-
// from = 当前编辑目标;全局目标(未选预设)回落到 roster 默认预设。
|
|
447
|
-
const from = typeof body?.from === 'string' && body.from.length > 0 ? body.from : String(roster.defaultId ?? 'standard')
|
|
448
|
-
const displayName = typeof body?.displayName === 'string' && body.displayName.trim().length > 0 ? body.displayName.trim() : name
|
|
449
|
-
try {
|
|
450
|
-
await roster.copy(from, name, displayName)
|
|
451
|
-
} catch (error) {
|
|
452
|
-
// 先 fork 后写 override:名字被占 / 非法 / 来源不存在时配置里不留空壳。
|
|
453
|
-
writeJson(res, copyErrorStatus(error), {
|
|
454
|
-
ok: false,
|
|
455
|
-
error: String(error && error.message ? error.message : error),
|
|
456
|
-
})
|
|
457
|
-
return
|
|
458
|
-
}
|
|
459
|
-
const config = body.config && typeof body.config === 'object' && !Array.isArray(body.config) ? body.config : {}
|
|
460
|
-
// 落盘 overrides[name] = 提交来的配置(净化:去掉空列表 / 空对象,避免
|
|
461
|
-
// 生成空壳 override);装配 / 预览 / Tab 全部走同一路径读它。
|
|
462
|
-
const base = { ...(store.raw() ?? {}) }
|
|
463
|
-
const overrides = { ...(base.overrides ?? {}) }
|
|
464
|
-
const clean = {}
|
|
465
|
-
for (const key of ['sections', 'sectionsBootstrap', 'sectionsCompaction', 'replace', 'inject', 'tools']) {
|
|
466
|
-
const value = config[key]
|
|
467
|
-
if (value === undefined || value === null) continue
|
|
468
|
-
const empty = Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0
|
|
469
|
-
if (!empty) clean[key] = value
|
|
470
|
-
}
|
|
471
|
-
overrides[name] = clean
|
|
472
|
-
base.overrides = overrides
|
|
473
|
-
store.writeSection(base)
|
|
474
|
-
writeJson(res, 200, { ok: true, presetId: name, from, config: read() })
|
|
475
|
-
} catch (error) {
|
|
476
|
-
writeJson(res, 500, {
|
|
477
|
-
ok: false,
|
|
478
|
-
error: String(error && error.message ? error.message : error),
|
|
479
|
-
})
|
|
480
|
-
}
|
|
481
|
-
},
|
|
482
|
-
})
|
|
483
|
-
ctx.effect(() => disposePresetsCreate, 'prompt-customizer: presets-create route')
|
|
484
|
-
|
|
485
|
-
// 配置读写路由(插件自有 config.yaml 的前端通道):
|
|
486
|
-
// GET /config → { ok, config }(解析后含默认值)
|
|
487
|
-
// POST /config/set → body { field, value };写入并返回最新 config
|
|
488
|
-
// POST /config/unset → body { field };删除字段并返回最新 config
|
|
489
|
-
const disposeConfig = webserver.register({
|
|
490
|
-
kind: 'exact',
|
|
491
|
-
path: '/api/prompt-customizer/config',
|
|
492
|
-
handler: async (_req, res) => {
|
|
493
|
-
writeJson(res, 200, { ok: true, config: read() })
|
|
494
|
-
},
|
|
495
|
-
})
|
|
496
|
-
ctx.effect(() => disposeConfig, 'prompt-customizer: config route')
|
|
497
|
-
|
|
498
|
-
const withFieldWrite = (handler) => async (req, res) => {
|
|
499
|
-
try {
|
|
500
|
-
const body = await readJsonBody(req)
|
|
501
|
-
if (typeof body?.field !== 'string' || body.field.length === 0) {
|
|
502
|
-
writeJson(res, 400, { ok: false, error: 'field is required' })
|
|
503
|
-
return
|
|
504
|
-
}
|
|
505
|
-
handler(body)
|
|
506
|
-
writeJson(res, 200, { ok: true, config: read() })
|
|
507
|
-
} catch (error) {
|
|
508
|
-
writeJson(res, 400, {
|
|
509
|
-
ok: false,
|
|
510
|
-
error: String(error && error.message ? error.message : error),
|
|
511
|
-
})
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
const disposeConfigSet = webserver.register({
|
|
515
|
-
kind: 'exact',
|
|
516
|
-
path: '/api/prompt-customizer/config/set',
|
|
517
|
-
handler: withFieldWrite(({ field, value }) => store.setField(field, value)),
|
|
518
|
-
})
|
|
519
|
-
ctx.effect(() => disposeConfigSet, 'prompt-customizer: config set route')
|
|
520
|
-
|
|
521
|
-
const disposeConfigUnset = webserver.register({
|
|
522
|
-
kind: 'exact',
|
|
523
|
-
path: '/api/prompt-customizer/config/unset',
|
|
524
|
-
handler: withFieldWrite(({ field }) => store.setField(field, undefined)),
|
|
525
|
-
})
|
|
526
|
-
ctx.effect(() => disposeConfigUnset, 'prompt-customizer: config unset route')
|
|
527
|
-
|
|
528
|
-
// 恢复初始状态:清空一切定制并关闭 forceSections —— 与「不装本插件」等效,
|
|
529
|
-
// 无需卸载 / 重启(forceSections: false 时 assemble 包装直接放行宿主原样,
|
|
530
|
-
// 瀑布流里空配置的过滤是无操作)。文件本身保留,避免卸载重装后迁移逻辑
|
|
531
|
-
// 再次触发;之后要重新启用定制,把 forceSections 改回 true 或删除该行即可。
|
|
532
|
-
const disposeConfigReset = webserver.register({
|
|
533
|
-
kind: 'exact',
|
|
534
|
-
path: '/api/prompt-customizer/config/reset',
|
|
535
|
-
handler: async (_req, res) => {
|
|
536
|
-
try {
|
|
537
|
-
store.writeSection({ forceSections: false })
|
|
538
|
-
writeJson(res, 200, { ok: true, config: read() })
|
|
539
|
-
} catch (error) {
|
|
540
|
-
writeJson(res, 500, {
|
|
541
|
-
ok: false,
|
|
542
|
-
error: String(error && error.message ? error.message : error),
|
|
543
|
-
})
|
|
544
|
-
}
|
|
545
|
-
},
|
|
546
|
-
})
|
|
547
|
-
ctx.effect(() => disposeConfigReset, 'prompt-customizer: config reset route')
|
|
548
|
-
|
|
549
|
-
// 批量保存路由:UI「统一保存按钮」的唯一写盘通道。body { target?, patch },
|
|
550
|
-
// patch 只认业务字段(只认下列业务字段,含每阶段独立段名单),字段值为 null 表示
|
|
551
|
-
// 删除(回退默认/全局)。target 缺省写全局顶层;target 为 agent 预设 id
|
|
552
|
-
// 时写 overrides[id],清空后的空 override 连带删除,避免配置文件堆积空壳。
|
|
553
|
-
const APPLY_FIELDS = ['sections', 'sectionsBootstrap', 'sectionsCompaction', 'replace', 'inject', 'tools']
|
|
554
|
-
const disposeConfigApply = webserver.register({
|
|
555
|
-
kind: 'exact',
|
|
556
|
-
path: '/api/prompt-customizer/config/apply',
|
|
557
|
-
handler: async (req, res) => {
|
|
558
|
-
try {
|
|
559
|
-
const body = await readJsonBody(req)
|
|
560
|
-
const patch = body?.patch
|
|
561
|
-
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
562
|
-
writeJson(res, 400, { ok: false, error: 'patch object is required' })
|
|
563
|
-
return
|
|
564
|
-
}
|
|
565
|
-
const target = typeof body.target === 'string' && body.target.length > 0 ? body.target : undefined
|
|
566
|
-
const base = { ...(store.raw() ?? {}) }
|
|
567
|
-
const applyPatch = (obj) => {
|
|
568
|
-
for (const field of APPLY_FIELDS) {
|
|
569
|
-
if (!Object.hasOwn(patch, field)) continue
|
|
570
|
-
if (patch[field] === null) delete obj[field]
|
|
571
|
-
else obj[field] = patch[field]
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
if (target === undefined) {
|
|
575
|
-
applyPatch(base)
|
|
576
|
-
} else {
|
|
577
|
-
const overrides = { ...(base.overrides ?? {}) }
|
|
578
|
-
const ovr = { ...(overrides[target] ?? {}) }
|
|
579
|
-
applyPatch(ovr)
|
|
580
|
-
if (Object.keys(ovr).length > 0) overrides[target] = ovr
|
|
581
|
-
else delete overrides[target]
|
|
582
|
-
if (Object.keys(overrides).length > 0) base.overrides = overrides
|
|
583
|
-
else delete base.overrides
|
|
584
|
-
}
|
|
585
|
-
store.writeSection(base)
|
|
586
|
-
writeJson(res, 200, { ok: true, config: read() })
|
|
587
|
-
} catch (error) {
|
|
588
|
-
writeJson(res, 400, {
|
|
589
|
-
ok: false,
|
|
590
|
-
error: String(error && error.message ? error.message : error),
|
|
591
|
-
})
|
|
592
|
-
}
|
|
593
|
-
},
|
|
594
|
-
})
|
|
595
|
-
ctx.effect(() => disposeConfigApply, 'prompt-customizer: config apply route')
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
/** 解析 `value`,但最多等待 `ms` 毫秒,超时回退到 `fallback`。 */
|
|
600
|
-
function withTimeout(value, ms, fallback) {
|
|
601
|
-
return Promise.race([
|
|
602
|
-
Promise.resolve(value),
|
|
603
|
-
new Promise((resolve) => setTimeout(() => resolve(fallback), ms)),
|
|
604
|
-
])
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
/**
|
|
608
|
-
* 宿主 authoring API(`agentPresets.copy`)抛错 → HTTP 状态码。
|
|
609
|
-
*
|
|
610
|
-
* 按错误类的名字判定而不是 import 宿主包:`@deepseek-ai/dsh-agent-presets` 是宿主
|
|
611
|
-
* 依赖,本插件不声明它,子路径导出也不保证存在;名字对不上时退回 500,错误文本
|
|
612
|
-
* 原样回给 UI,至少不吞信息。
|
|
613
|
-
*/
|
|
614
|
-
function copyErrorStatus(error) {
|
|
615
|
-
const kind = String(error?.constructor?.name ?? '')
|
|
616
|
-
if (kind === 'PresetExistsError') return 409
|
|
617
|
-
if (kind === 'InvalidPresetIdError' || kind === 'PresetNotWritableError' || kind === 'UnknownPresetError') return 400
|
|
618
|
-
return 500
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/**
|
|
622
|
-
* 存在 roster 时解析 `standard` agent 的常驻 scope key。
|
|
623
|
-
*
|
|
624
|
-
* `standingKeyFor` 会挂载整个 agent 预设(组装插件),可能很慢或依赖尚未
|
|
625
|
-
* 就绪的服务 —— 只读的清单浏览绝不能被它挂住。若在短超时内没有结果,
|
|
626
|
-
* 回退到全局层(scopeKey = undefined)。
|
|
627
|
-
*/
|
|
628
|
-
async function resolveStandardScope(ctx) {
|
|
629
|
-
try {
|
|
630
|
-
const presets = ctx.get('agentPresets')
|
|
631
|
-
if (presets && typeof presets.standingKeyFor === 'function') {
|
|
632
|
-
return await withTimeout(presets.standingKeyFor('standard'), 1500, undefined)
|
|
633
|
-
}
|
|
634
|
-
} catch { /* 没有 roster —— 回退到全局层 */ }
|
|
635
|
-
return undefined
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
/** 枚举当前生效的段与工具,并附上各自的屏蔽 / 隐藏标记。 */
|
|
639
|
-
async function buildInventory(ctx, raw, scopeKey, presetId, catalog) {
|
|
640
|
-
// 生效配置:目标 agent 预设的字段级 override 优先于全局默认。
|
|
641
|
-
const cfg = mergeConfig(raw, raw.overrides?.[presetId])
|
|
642
|
-
const denied = new Set(cfg.sections ?? [])
|
|
643
|
-
const replace = cfg.replace ?? {}
|
|
644
|
-
const injectOrder = new Map((cfg.inject ?? []).map((item) => [item.name, item.order]))
|
|
645
|
-
const te = pickToolsFilter(cfg.tools, { promoted: true, boundary: -1 }) // 清单无会话概念:恒展示静态(晋级后)视图
|
|
646
|
-
const isHidden = (name) => (te.exclude ?? []).includes(name)
|
|
647
|
-
|
|
648
|
-
// 解析段的文本。动态段注册的是函数;用尽力而为的上下文调用它(与装配时
|
|
649
|
-
// 传入的形态一致),清单才能回显真实生成内容供替换编辑。函数抛错或返回
|
|
650
|
-
// 未决值时回退为占位标记。
|
|
651
|
-
const resolveText = (section) => {
|
|
652
|
-
if (typeof section.text === 'function') {
|
|
653
|
-
try {
|
|
654
|
-
const value = section.text({ scope: scopeKey })
|
|
655
|
-
// 异步动态段返回 thenable,无法同步回显。
|
|
656
|
-
if (value && typeof value.then === 'function') return '<动态生成>'
|
|
657
|
-
return String(value ?? '')
|
|
658
|
-
} catch { return '<动态生成>' }
|
|
659
|
-
}
|
|
660
|
-
return String(section.text ?? '')
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
// 本次这个 scope 真正见到的段与工具:只带身份与内容,标记一律现算。
|
|
664
|
-
const seenSections = [...ctx.systemPrompt.layers.merge(scopeKey, (layer) => layer.sections).entries()]
|
|
665
|
-
.map(([name, section]) => ({
|
|
666
|
-
name,
|
|
667
|
-
order: injectOrder.has(name) ? injectOrder.get(name) : (typeof section.order === 'number' ? section.order : 0),
|
|
668
|
-
text: resolveText(section),
|
|
669
|
-
}))
|
|
670
|
-
const seenTools = []
|
|
671
|
-
// 从解析出的 scope 枚举工具;scope key 不可用时(如常驻挂载超时)回退到
|
|
672
|
-
// 全局层,避免仅仅因为 agent 组装未就绪就返回空目录。
|
|
673
|
-
try {
|
|
674
|
-
const byName = new Map()
|
|
675
|
-
for (const scope of [scopeKey, undefined]) {
|
|
676
|
-
let schemas = []
|
|
677
|
-
try { schemas = ctx.tools.schemas(scope) ?? [] } catch { schemas = [] }
|
|
678
|
-
for (const tool of schemas) {
|
|
679
|
-
if (tool.name && tool.name !== undefined && !byName.has(tool.name)) byName.set(tool.name, tool)
|
|
680
|
-
}
|
|
681
|
-
if (byName.size > 0) break
|
|
682
|
-
}
|
|
683
|
-
for (const tool of byName.values()) {
|
|
684
|
-
seenTools.push({ name: tool.name, description: typeof tool.description === 'string' ? tool.description : '' })
|
|
685
|
-
}
|
|
686
|
-
} catch { /* 工具注册表未就绪 —— 本次不增量并入 */ }
|
|
687
|
-
|
|
688
|
-
// 并进累积登记表,再按并集出名单:本 scope 见到的排在前面(顺序更贴近当前
|
|
689
|
-
// 预设),其它预设见过的追加在后。同名内容后见到者赢,条目只增不减。
|
|
690
|
-
if (catalog !== undefined) catalog.observe({ sections: seenSections, tools: seenTools })
|
|
691
|
-
const pool = catalog === undefined ? { sections: seenSections, tools: seenTools } : catalog.read()
|
|
692
|
-
const seenNames = new Set(seenSections.map((x) => x.name))
|
|
693
|
-
const sections = [...seenSections, ...pool.sections.filter((x) => !seenNames.has(x.name))]
|
|
694
|
-
.map((item) => ({ ...item, active: !denied.has(item.name), replaced: Object.hasOwn(replace, item.name) }))
|
|
695
|
-
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
696
|
-
const seenToolNames = new Set(seenTools.map((x) => x.name))
|
|
697
|
-
const tools = [...seenTools, ...pool.tools.filter((x) => !seenToolNames.has(x.name))]
|
|
698
|
-
.map((item) => ({ ...item, hidden: isHidden(item.name) }))
|
|
699
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
700
|
-
|
|
701
|
-
return { sections, tools, config: cfg }
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
/**
|
|
705
|
-
* 解析一个 agent 预设 id 的常驻 scope key(供无 agent 的只读路由使用)。
|
|
706
|
-
* 显式点名某预设时给挂载留足时间(15s):standingKeyFor 要组装整个预设
|
|
707
|
-
* 插件树,慢挂载很常见,且挂载成功后 roster 常驻 memo,后续请求零开销。
|
|
708
|
-
* 注意 roster 的 standingKeyFor 对未知 id 也会兜底返回 standard scope,
|
|
709
|
-
* 因此先用 list() 确认 id 存在 —— 否则「预设不存在」会被误当成解析成功。
|
|
710
|
-
*/
|
|
711
|
-
async function resolveScopeFor(ctx, id) {
|
|
712
|
-
if (!id) return undefined
|
|
713
|
-
try {
|
|
714
|
-
const presets = ctx.get('agentPresets')
|
|
715
|
-
if (presets && typeof presets.standingKeyFor === 'function') {
|
|
716
|
-
if (typeof presets.list === 'function') {
|
|
717
|
-
const list = await withTimeout(presets.list(), 15000, undefined)
|
|
718
|
-
if (Array.isArray(list) && !list.some((p) => p.id === id)) return undefined
|
|
719
|
-
}
|
|
720
|
-
return await withTimeout(presets.standingKeyFor(id), 15000, undefined)
|
|
721
|
-
}
|
|
722
|
-
} catch { /* 未知预设或挂载失败(如预设依赖缺失)—— 回退全局层 */ }
|
|
723
|
-
return undefined
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
/**
|
|
727
|
-
* Resolve the scope key + preset hint a preview request targets. An explicit
|
|
728
|
-
* `?scope=<id>` requests that agent preset's standing scope; otherwise fall
|
|
729
|
-
* back to the `standard` agent (same default the inventory uses). Always
|
|
730
|
-
* resolves within the same short timeout so a slow standing mount cannot hang
|
|
731
|
-
* the request. The hint makes our own assemble filter apply that preset's
|
|
732
|
-
* field-level override during the preview waterfall.
|
|
733
|
-
*
|
|
734
|
-
* 返回的 `scopeResolved` 表示「请求的目标是否解析出了自己的 scope」:显式
|
|
735
|
-
* id 挂载失败时展示会回落 standard scope,但标记为 false,让 UI 明确警示
|
|
736
|
-
* 「预览的不是该预设的原生装配」。
|
|
737
|
-
*/
|
|
738
|
-
async function previewTarget(ctx, req) {
|
|
739
|
-
const id = queryOf(req).get('scope') ?? undefined
|
|
740
|
-
const resolved = id ? await resolveScopeFor(ctx, id) : await resolveStandardScope(ctx)
|
|
741
|
-
const scopeKey = resolved ?? (id ? await resolveStandardScope(ctx) : undefined)
|
|
742
|
-
// `?phase=`:bootstrap / compaction / active 三态预览;其它值一律回退实际推导。
|
|
743
|
-
const rawPhase = queryOf(req).get('phase') ?? undefined
|
|
744
|
-
const phase = rawPhase === 'bootstrap' || rawPhase === 'compaction' || rawPhase === 'active'
|
|
745
|
-
? rawPhase
|
|
746
|
-
: undefined
|
|
747
|
-
return { scopeKey, hintId: id, phase, scopeResolved: resolved !== undefined }
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
/**
|
|
751
|
-
* 按预览阶段合成一个最小伪会话 agent。预设原生的阶段裁剪插件(zero-tool
|
|
752
|
-
* bootstrap / router-bootstrap / warmup 等)在 assemble 瀑布流里只读
|
|
753
|
-
* `session.id / session.events / session.header / agent.options.model` 推导
|
|
754
|
-
* 晋级状态并动态裁剪工具目录 —— 合成的 durable 事件让这些规则在只读预览
|
|
755
|
-
* 里按所选阶段真实运行(而非挤在常驻视图):
|
|
756
|
-
* - bootstrap:空事件(boundary -1,未晋级,首轮形态)
|
|
757
|
-
* - compaction:一条 compaction/end(压缩后未重新晋级)
|
|
758
|
-
* - active:一条晋级信号(assistant/message,event 形状见各预设的
|
|
759
|
-
* compaction-epoch.mjs —— 只认 type 与 seq)
|
|
760
|
-
*/
|
|
761
|
-
export function fakeAgentFor(phase, presetId) {
|
|
762
|
-
const events = phase === 'compaction'
|
|
763
|
-
? [{ type: 'compaction/end', seq: 1 }]
|
|
764
|
-
: phase === 'active'
|
|
765
|
-
? [{ type: 'assistant/message', seq: 1 }]
|
|
766
|
-
: []
|
|
767
|
-
return {
|
|
768
|
-
session: {
|
|
769
|
-
id: `prompt-customizer-preview-${phase}`,
|
|
770
|
-
events,
|
|
771
|
-
header: {
|
|
772
|
-
delegationDepth: 0,
|
|
773
|
-
agentPreset: presetId,
|
|
774
|
-
cwd: process.cwd(),
|
|
775
|
-
meta: {},
|
|
776
|
-
},
|
|
777
|
-
},
|
|
778
|
-
options: { provider: '', model: '' },
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
/**
|
|
783
|
-
* 渲染某个 scope 的最终系统提示词:跑完整的装配瀑布流(所有插件的过滤、
|
|
784
|
-
* 包括我们自己的都会生效),再把幸存的段拼成文本。这就是模型实际看到的内容。
|
|
785
|
-
*
|
|
786
|
-
* `phase` 给定时额外携带伪会话 agent,让预设原生的阶段裁剪规则(引导期
|
|
787
|
-
* 收敛目录等)也按所选阶段真实运行;伪会话触发任何插件异常时降级为无
|
|
788
|
-
* 会话装配(原生规则视为已晋级 / 全目录)并标记 degraded,预览永远可用。
|
|
789
|
-
* 同时返回注册表原始目录总数 registryTotal,与模型可见目录形成对照
|
|
790
|
-
* (如 PTC / Code Mode 把全目录包装成单一 run_code)。
|
|
791
|
-
*/
|
|
792
|
-
async function buildPreview(ctx, scopeKey, hintId, phase, catalog) {
|
|
793
|
-
// promptCustomizerPreset / promptCustomizerPhase 都是本插件的自定义装配
|
|
794
|
-
// 字段:让我们的过滤器在这次装配里按该 agent 预设的 override 与指定阶段
|
|
795
|
-
// 视图生效(AssembleContext 允许插件自定义字段)。
|
|
796
|
-
const hint = {
|
|
797
|
-
scope: scopeKey,
|
|
798
|
-
promptCustomizerPreset: hintId,
|
|
799
|
-
promptCustomizerPhase: phase,
|
|
800
|
-
// 请求预过滤视图(base):本插件的过滤器把进入过滤前的段 / 工具原文
|
|
801
|
-
// 连同屏蔽 / 隐藏标记一起附在装配结果上,作为三个 Tab 的统一数据源。
|
|
802
|
-
promptCustomizerBase: true,
|
|
803
|
-
}
|
|
804
|
-
let assembled
|
|
805
|
-
let degraded = false
|
|
806
|
-
try {
|
|
807
|
-
assembled = await ctx.systemPrompt.assemble(
|
|
808
|
-
phase ? { ...hint, agent: fakeAgentFor(phase, hintId) } : hint,
|
|
809
|
-
)
|
|
810
|
-
} catch {
|
|
811
|
-
// 伪会话让某个预设插件抛错了:退回无会话装配,原生规则不参与本次预览。
|
|
812
|
-
degraded = phase !== undefined
|
|
813
|
-
assembled = await ctx.systemPrompt.assemble(hint)
|
|
814
|
-
}
|
|
815
|
-
const sections = Array.isArray(assembled.sections)
|
|
816
|
-
? assembled.sections.map((section) => ({ name: section.name, text: String(section.text ?? '') }))
|
|
817
|
-
: []
|
|
818
|
-
// 预过滤视图(base):与 post 视图来自同一次装配,三个 Tab 共用同一份数据。
|
|
819
|
-
const baseView = (assembled && typeof assembled === 'object' && assembled.promptCustomizerBaseView) || {}
|
|
820
|
-
// 预览带 scope,是「浏览其它预设」时唯一真正跑过该预设装配的入口 —— 把它
|
|
821
|
-
// 见到的段与工具并进池子(base 视图不含 order,新条目由登记表按追加序补)。
|
|
822
|
-
if (catalog !== undefined) {
|
|
823
|
-
catalog.observe({
|
|
824
|
-
sections: (Array.isArray(baseView.sections) ? baseView.sections : []).map((sec) => ({ name: sec.name, text: sec.text })),
|
|
825
|
-
tools: (Array.isArray(baseView.tools) ? baseView.tools : []).map((tool) => ({ name: tool.name, description: tool.description })),
|
|
826
|
-
})
|
|
827
|
-
}
|
|
828
|
-
const registryTools = registryToolNames(ctx, scopeKey)
|
|
829
|
-
const registryTotal = registryTools.length
|
|
830
|
-
// 兜底探测(机制无关):本插件产出的段有多少没进最终装配。下游丢弃段的规则
|
|
831
|
-
// 不止 persona complete 一种(实测 liangshen 引导期由预设自身规则 20 → 1 段,
|
|
832
|
-
// 注册表里没有 complete 段),所以按名字比对结果而不是只看注册表标记。
|
|
833
|
-
// emitted 缺席 = 我们的过滤器没跑(异常回退未加工装配),此时无从比较。
|
|
834
|
-
const emitted = Array.isArray(assembled.promptCustomizerEmitted) ? assembled.promptCustomizerEmitted : null
|
|
835
|
-
const survivedNames = new Set(sections.map((section) => String(section.name)))
|
|
836
|
-
const droppedNames = emitted === null ? [] : emitted.filter((name) => !survivedNames.has(String(name)))
|
|
837
|
-
const lostSections = droppedNames.length > 0
|
|
838
|
-
? { emitted: emitted.length, survived: sections.length, dropped: droppedNames.length }
|
|
839
|
-
: undefined
|
|
840
|
-
return {
|
|
841
|
-
ok: true,
|
|
842
|
-
scope: scopeKey,
|
|
843
|
-
degraded,
|
|
844
|
-
registryTotal,
|
|
845
|
-
/** 该 scope 注册表的工具名清单:供界面区分「在本预设里、只是被该阶段裁掉」
|
|
846
|
-
* 与「根本不属于本预设(无法加回)」。 */
|
|
847
|
-
registryTools,
|
|
848
|
-
/** 非空 = 该 scope 有 complete 段整段接管最终提示词(值为那段的名字)。 */
|
|
849
|
-
takenOverBy: assembled && typeof assembled === 'object'
|
|
850
|
-
? (typeof assembled.promptCustomizerTakenOver === 'string' ? assembled.promptCustomizerTakenOver : undefined)
|
|
851
|
-
: undefined,
|
|
852
|
-
/** 非空 = 本插件产出的段有若干被下游装配规则丢弃(计数,不含段名列表)。 */
|
|
853
|
-
lostSections,
|
|
854
|
-
sections,
|
|
855
|
-
text: renderPreviewText(assembled),
|
|
856
|
-
tools: Array.isArray(assembled.tools)
|
|
857
|
-
? assembled.tools.map((tool) => ({
|
|
858
|
-
name: typeof tool.name === 'string' ? tool.name : String(tool.name),
|
|
859
|
-
description: typeof tool.description === 'string' ? tool.description : '',
|
|
860
|
-
}))
|
|
861
|
-
: [],
|
|
862
|
-
// 预过滤视图:段(含该阶段独立屏蔽标记 blocked / 替换标记)与工具
|
|
863
|
-
// (含该阶段隐藏标记 hidden)—— 供提示词 / 工具 Tab 渲染「每阶段
|
|
864
|
-
// 独立名单」,被屏蔽 / 被隐藏的条目仍保留在此可反选。
|
|
865
|
-
baseSections: Array.isArray(baseView.sections) ? baseView.sections : [],
|
|
866
|
-
baseTools: Array.isArray(baseView.tools) ? baseView.tools : [],
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
/** 注册表的工具名清单(解析出的 scope 优先,回退全局层)。任何一步失败都返回
|
|
871
|
-
* 空数组,绝不让预览失败;长度即模型可见目录的对照总数。 */
|
|
872
|
-
function registryToolNames(ctx, scopeKey) {
|
|
873
|
-
try {
|
|
874
|
-
const byName = new Map()
|
|
875
|
-
for (const scope of [scopeKey, undefined]) {
|
|
876
|
-
let schemas = []
|
|
877
|
-
try { schemas = ctx.tools.schemas(scope) ?? [] } catch { schemas = [] }
|
|
878
|
-
for (const tool of schemas) if (tool.name) byName.set(tool.name, tool)
|
|
879
|
-
if (byName.size > 0) break
|
|
880
|
-
}
|
|
881
|
-
return [...byName.keys()]
|
|
882
|
-
} catch { return [] }
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
/**
|
|
886
|
-
* 把该阶段 `add` 名单里、当前目录还没有的工具,从注册表查回 schema 追加回去
|
|
887
|
-
*(「把被该阶段裁掉、但注册表里仍有的工具加回来」)。`exclude` 优先 —— 同一
|
|
888
|
-
* 名字两者都在 = 隐藏,不加;目录里已有的不重复;注册表查不到的名字跳过(通常
|
|
889
|
-
* 是别的预设独有的工具,当前预设没有可查的 schema,加不进来)。
|
|
890
|
-
*/
|
|
891
|
-
function appendAddedTools(ctx, scopeKey, tools, add, exclude) {
|
|
892
|
-
if (!Array.isArray(add) || add.length === 0) return tools
|
|
893
|
-
const excluded = new Set(exclude ?? [])
|
|
894
|
-
const present = new Set(tools.map((tool) => tool.name))
|
|
895
|
-
const wanted = add.filter((name) => !present.has(name) && !excluded.has(name))
|
|
896
|
-
if (wanted.length === 0) return tools
|
|
897
|
-
const registry = new Map()
|
|
898
|
-
for (const scope of [scopeKey, undefined]) {
|
|
899
|
-
let schemas = []
|
|
900
|
-
try { schemas = ctx.tools.schemas(scope) ?? [] } catch { schemas = [] }
|
|
901
|
-
for (const tool of schemas) if (tool.name) registry.set(tool.name, tool)
|
|
902
|
-
if (registry.size > 0) break
|
|
903
|
-
}
|
|
904
|
-
const extra = wanted.map((name) => registry.get(name)).filter(Boolean)
|
|
905
|
-
return extra.length > 0 ? [...tools, ...extra] : tools
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
/**
|
|
909
|
-
* 查出该 scope 里是否有一段以 `complete: true` 注册(persona 整段接管)。
|
|
910
|
-
* 宿主 `SystemPrompt.assemble()` 的契约:瀑布流的返回值虽然权威,但存在有效
|
|
911
|
-
* complete 段时会在瀑布流**之后**把 sections 强制还原成那一条段——因此本插件
|
|
912
|
-
* 在瀑布流里做的屏蔽 / 替换 / 注入 / 排序对这类预设完全不进最终提示词(工具
|
|
913
|
-
* 过滤不受影响)。插件侧无法绕开,只能探出来交给 UI 明确告知用户。
|
|
914
|
-
*/
|
|
915
|
-
function detectCompletePrompt(ctx, scopeKey) {
|
|
916
|
-
try {
|
|
917
|
-
const sections = ctx.systemPrompt.layers.merge(scopeKey, (layer) => layer.sections)
|
|
918
|
-
for (const section of sections.values()) {
|
|
919
|
-
if (section && section.complete === true) return String(section.name)
|
|
920
|
-
}
|
|
921
|
-
} catch { /* 注册表未就绪 / 无 layers 能力 —— 视为未接管 */ }
|
|
922
|
-
return undefined
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
/** 把幸存的段拼成最终提示词文本(与 dsh 的 renderPrompt 行为一致)。 */
|
|
926
|
-
function renderPreviewText(assembled) {
|
|
927
|
-
return (Array.isArray(assembled.sections) ? assembled.sections : [])
|
|
928
|
-
.map((section) => String(section.text ?? '').trim())
|
|
929
|
-
.filter((text) => text.length > 0)
|
|
930
|
-
.join('\n\n')
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
function writeJson(res, status, body) {
|
|
934
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
935
|
-
res.end(JSON.stringify(body))
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
/** 解析请求 URL 里的查询参数(宿主共享 webserver 的 req 是原始
|
|
939
|
-
* IncomingMessage,没有 Express 风格的 query 属性)。 */
|
|
940
|
-
function queryOf(req) {
|
|
941
|
-
const url = String(req?.url ?? '')
|
|
942
|
-
const i = url.indexOf('?')
|
|
943
|
-
return new URLSearchParams(i >= 0 ? url.slice(i + 1) : '')
|
|
1
|
+
/**
|
|
2
|
+
* dsh-prompt-customizer — 宿主端。
|
|
3
|
+
*
|
|
4
|
+
* - 配置存储在插件自有文件 `~/.dsh/prompt-customizer/config.yaml`(可用组合
|
|
5
|
+
* 入口 `dataDir` 覆盖目录),不再占用主文档 settings.yaml。首次启动时若
|
|
6
|
+
* 新文件不存在而主文档里有旧 `prompt-customizer:` 段,自动迁移(主文档
|
|
7
|
+
* 原样保留,可随时回滚旧版),此后本插件不再注册设置命名空间——主文档
|
|
8
|
+
* 的设置页也不会再显示本段落。
|
|
9
|
+
* - 挂钩 `system-prompt/assemble` 瀑布流,按名称屏蔽 / 替换 / 注入提示词段,
|
|
10
|
+
* 并从模型目录中隐藏工具。只影响面向模型的目录;工具与路由照常工作。
|
|
11
|
+
* - 提供 `GET /api/prompt-customizer/inventory`(段/工具清单,`?scope=` 可切
|
|
12
|
+
* agent 预设;`scopeResolved` 标记 scope 是否成功挂载)、
|
|
13
|
+
* `GET /api/prompt-customizer/config` 与 `POST /config/set|/config/unset`
|
|
14
|
+
* (单字段读写)、`POST /config/apply`(批量保存,UI 统一保存按钮的写盘通道)。
|
|
15
|
+
*
|
|
16
|
+
* 定制按作用域生效:顶层字段是全局默认;`overrides` 按 agent 预设 id 提供
|
|
17
|
+
* 字段级覆盖(非空字段整体接管,空缺回落全局)。注入段支持 `phase`
|
|
18
|
+
* (always/bootstrap/active),工具过滤支持未晋级阶段的 bootstrap 目录与
|
|
19
|
+
* 压缩后未晋级阶段的 compaction 目录(三态:compaction > bootstrap > 静态)——
|
|
20
|
+
* 晋级由 durable 会话事件推导(compaction 复位,subagent 恒已晋级)。
|
|
21
|
+
*
|
|
22
|
+
* 客户端对 bootstrap / compaction / active 三套名义装配各拉取一次
|
|
23
|
+
* `/preview?phase=…`,三个阶段部分**恒定全部显示**(预设没有某个阶段时,
|
|
24
|
+
* 该部分只是空的)—— 不同 agent 预设的「agent 周期」不同(standard 三态同形,
|
|
25
|
+
* minimal 一个形态,router / warmup 两个,anchored 家族三个),但那是各阶段
|
|
26
|
+
* 内容是否相同的问题,不构成把阶段藏起来的理由。
|
|
27
|
+
*
|
|
28
|
+
* 另一面:若该 scope 有以 `complete: true` 注册的段(persona 整段接管),宿主
|
|
29
|
+
* 会在装配瀑布流**之后**把 sections 强制还原成那一条段(见 dsh-system-prompt
|
|
30
|
+
* 的 assemble 契约),本插件的段级屏蔽 / 替换 / 注入 / 排序对其完全不生效
|
|
31
|
+
* (工具过滤不受影响)。`forceSections`(默认开)为此提供终极手段:包装宿主
|
|
32
|
+
* 的 assemble,在方法边界用注册表原始段重建 sections —— 预设插件的阶段裁段
|
|
33
|
+
* (实测 liangshen / warmupbetter 等 preset 过滤器全部 prepend: true)与
|
|
34
|
+
* complete 整段接管都无法再改写结果。关闭时预览过滤器会探测并把段名作为
|
|
35
|
+
* `takenOverBy` 透出,让提示词 / 预览两个 Tab 就这件事说同一句话。
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import fs from 'node:fs'
|
|
39
|
+
import os from 'node:os'
|
|
40
|
+
import path from 'node:path'
|
|
41
|
+
import { Config, NS } from './schema.js'
|
|
42
|
+
import { mergeConfig, pickToolsFilter, pickSectionsForStatus, applyToolFilter, applySectionPolicy } from './effective.js'
|
|
43
|
+
import { createPromotion, presetOfSession } from './promotion.js'
|
|
44
|
+
import { createConfigStore, readLegacySection } from './store.js'
|
|
45
|
+
import { createCatalog } from './catalog.js'
|
|
46
|
+
import { registerVariables, listVariableNames } from './vars.js'
|
|
47
|
+
|
|
48
|
+
export const name = 'prompt-customizer'
|
|
49
|
+
// settings 仍被注入:只读 documentPath 做一次性旧版迁移。cordis 对未在
|
|
50
|
+
// inject 里声明的服务调用 ctx.get 会抛错,漏声明会让整个插件加载失败。
|
|
51
|
+
export const inject = ['settings', 'systemPrompt', 'tools']
|
|
52
|
+
|
|
53
|
+
/** assemble 强制覆盖的「原始方法」登记键:跨插件 HMR 重载共享(Symbol.for),
|
|
54
|
+
* 重载后永远包在原始方法之上,避免层层套娃。见 apply() 里的 forceSections。 */
|
|
55
|
+
const PATCHED = Symbol.for('prompt-customizer:assemble-pristine')
|
|
56
|
+
|
|
57
|
+
/** harness home;ponytail: DSH_HOME 为推测的官方环境变量,若宿主日后提供
|
|
58
|
+
* 正式的 home 解析 API 应换过去。回退 `~/.dsh`。 */
|
|
59
|
+
function dshHome() {
|
|
60
|
+
return process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 收集并解析一个请求体(JSON),超过上限直接拒绝——路由是本进程私有的,上限只是防御。 */
|
|
64
|
+
function readJsonBody(req, limit = 2 * 1024 * 1024) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
let size = 0
|
|
67
|
+
const chunks = []
|
|
68
|
+
req.on('data', (chunk) => {
|
|
69
|
+
size += chunk.length
|
|
70
|
+
if (size > limit) {
|
|
71
|
+
reject(new Error('request body too large'))
|
|
72
|
+
req.destroy()
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
chunks.push(chunk)
|
|
76
|
+
})
|
|
77
|
+
req.on('end', () => {
|
|
78
|
+
try {
|
|
79
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
80
|
+
} catch (error) {
|
|
81
|
+
reject(new Error(`invalid JSON body: ${error.message}`))
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
req.on('error', reject)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 一次性清理遗留的 include(白名单)设置:过滤只剩 exclude 黑名单,服务端不再
|
|
90
|
+
* 读取 include,界面也不再写它。
|
|
91
|
+
*
|
|
92
|
+
* 只要 include 键存在就剥掉并落盘(空数组同样是无意义字段);但只有真去掉了
|
|
93
|
+
* 有内容的名单才记一条日志(列出去处与名字)—— 不为一串空数组惊动用户。
|
|
94
|
+
*/
|
|
95
|
+
function stripLegacyInclude(store, log) {
|
|
96
|
+
const raw = store.raw()
|
|
97
|
+
if (raw === null || typeof raw !== 'object') return
|
|
98
|
+
const stats = { stripped: 0, reported: [] }
|
|
99
|
+
const walk = (node, where) => {
|
|
100
|
+
if (node === null || typeof node !== 'object' || Array.isArray(node)) return
|
|
101
|
+
if (Array.isArray(node.include)) {
|
|
102
|
+
stats.stripped += 1
|
|
103
|
+
if (node.include.length > 0) stats.reported.push(`${where}: [${node.include.join(', ')}]`)
|
|
104
|
+
delete node.include
|
|
105
|
+
}
|
|
106
|
+
for (const phase of ['bootstrap', 'compaction']) walk(node[phase], `${where}.${phase}`)
|
|
107
|
+
}
|
|
108
|
+
walk(raw.tools, 'tools')
|
|
109
|
+
if (raw.overrides !== null && typeof raw.overrides === 'object') {
|
|
110
|
+
for (const id of Object.keys(raw.overrides)) walk(raw.overrides[id]?.tools, `overrides.${id}.tools`)
|
|
111
|
+
}
|
|
112
|
+
if (Array.isArray(raw.presets)) {
|
|
113
|
+
for (const preset of raw.presets) walk(preset?.data?.tools, `presets.${preset?.name}.data.tools`)
|
|
114
|
+
}
|
|
115
|
+
if (stats.stripped === 0) return
|
|
116
|
+
try {
|
|
117
|
+
store.writeSection(raw)
|
|
118
|
+
if (stats.reported.length > 0) {
|
|
119
|
+
log(`prompt-customizer: 白名单(include)语义已移除,已从配置里清掉 ${stats.reported.length} 处有内容的名单 —— ${stats.reported.join(';')}。这些阶段现在不设限制;要重新限制就去工具 Tab 逐项取消勾选。`)
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
try { log(`prompt-customizer: 遗留 include 清理失败(不影响运行,该字段已被忽略):${String(error && error.message ? error.message : error)}`) } catch { /* logger 缺席 */ }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function apply(ctx, entry = {}) {
|
|
127
|
+
// 1) 插件自有配置文件(不再注册 settings 命名空间)。
|
|
128
|
+
const dataDir = typeof entry.dataDir === 'string' && entry.dataDir.length > 0
|
|
129
|
+
? entry.dataDir
|
|
130
|
+
: path.join(dshHome(), NS)
|
|
131
|
+
const configFile = path.join(dataDir, 'config.yaml')
|
|
132
|
+
const store = createConfigStore({
|
|
133
|
+
file: configFile,
|
|
134
|
+
schema: Config,
|
|
135
|
+
warn: (message) => { try { ctx.logger?.warn?.(message) } catch { /* logger 缺席 */ } },
|
|
136
|
+
})
|
|
137
|
+
const read = () => store.readResolved()
|
|
138
|
+
// 「本系统全部提示词 / 全部工具」的累积登记表:清单与预览每次看到什么就并进
|
|
139
|
+
// 什么(同名后见到者赢),所以池子不随编辑目标切换、只增不减。派生缓存,
|
|
140
|
+
// 删掉随浏览重新长出来。
|
|
141
|
+
const catalog = createCatalog({
|
|
142
|
+
dir: dataDir,
|
|
143
|
+
warn: (message) => { try { ctx.logger?.warn?.(message) } catch { /* logger 缺席 */ } },
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// 一次性迁移:新文件不存在而主文档里有旧段 → 原样复制过来。主文档不动,
|
|
147
|
+
// 旧版随时可回滚;迁移后新文件是唯一权威。
|
|
148
|
+
if (!fs.existsSync(configFile)) {
|
|
149
|
+
const master = ctx.get('settings')?.documentPath
|
|
150
|
+
const legacy = readLegacySection(master, NS)
|
|
151
|
+
if (legacy) {
|
|
152
|
+
store.writeSection(legacy)
|
|
153
|
+
try { ctx.logger?.info?.(`prompt-customizer: 已把旧版配置从 ${master} 迁移到 ${configFile}`) } catch { /* logger 缺席 */ }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 一次性清理:include(白名单)语义已从读取与写入两侧彻底移除。遗留的
|
|
158
|
+
// include 既不再生效、界面也不再表达,留着只会让配置堆积无意义字段 ——
|
|
159
|
+
// 启动时剥掉并记一条日志(逐处列出被去掉的名字),不静默改用户文件。
|
|
160
|
+
stripLegacyInclude(store, (message) => { try { ctx.logger?.info?.(message) } catch { /* logger 缺席 */ } })
|
|
161
|
+
|
|
162
|
+
// 2) 晋级 tracker:增量维护每会话的 bootstrap/active 阶段(durable 事件
|
|
163
|
+
// 推导,compaction 复位,subagent 恒已晋级)。
|
|
164
|
+
const promotion = createPromotion()
|
|
165
|
+
ctx.on('session/event', (session, event) => {
|
|
166
|
+
try { promotion.observe(session, event) } catch { /* tracker 故障绝不影响事件流 */ }
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// 单次降级告警:过滤逻辑出错时退回未加工的装配,绝不拖垮会话。
|
|
170
|
+
let warned = false
|
|
171
|
+
const warnOnce = (message) => {
|
|
172
|
+
if (warned) return
|
|
173
|
+
warned = true
|
|
174
|
+
try { ctx.logger?.warn?.(message) } catch { /* logger 缺席只是不再告警 */ }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 提示词变量注册(内置系统事实 + process.env 全量,黑名单过滤):宿主只有
|
|
178
|
+
// provider / model / cwd 三个,段文本里的其余 {{name}} 一律严格报错。这里
|
|
179
|
+
// 通过官方扩展点 systemPrompt.variable() 补齐。懒同步:每次装配入口比对黑名单
|
|
180
|
+
// 签名,变了才注销重注册 —— 配置写入 / 外部手改都在下次装配自然生效。
|
|
181
|
+
let varsSignature
|
|
182
|
+
let varsDispose
|
|
183
|
+
const syncVariables = () => {
|
|
184
|
+
try {
|
|
185
|
+
const blocklist = read().envBlocklist ?? []
|
|
186
|
+
const signature = JSON.stringify(blocklist)
|
|
187
|
+
if (signature === varsSignature) return
|
|
188
|
+
try { varsDispose?.() } catch { /* 注销失败不阻塞重注册 */ }
|
|
189
|
+
varsDispose = registerVariables(ctx.get('systemPrompt'), blocklist)
|
|
190
|
+
varsSignature = signature
|
|
191
|
+
} catch (error) {
|
|
192
|
+
warnOnce(`prompt-customizer: 提示词变量注册失败({{…}} 仍只有宿主注册的变量可用):${String((error && error.message) || error)}`)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 3) 装配目标解析(瀑布流与 assemble 强制覆盖共用):生效配置 = 显式预览
|
|
197
|
+
// 提示 > 会话所属 agent 预设(字段级覆盖)> 全局默认。预览 hint 优先于
|
|
198
|
+
// 会话事件推导的阶段,让同一份配置能渲染 bootstrap / compaction / active
|
|
199
|
+
// 三种阶段视图。boundary 语义与 promotion.status 一致(-1 = 从未压缩)。
|
|
200
|
+
const resolveTarget = (context) => {
|
|
201
|
+
const raw = read()
|
|
202
|
+
const id = typeof context?.promptCustomizerPreset === 'string'
|
|
203
|
+
? context.promptCustomizerPreset
|
|
204
|
+
: presetOfSession(context?.agent?.session)
|
|
205
|
+
const cfg = mergeConfig(raw, raw.overrides?.[id])
|
|
206
|
+
const phaseHint = typeof context?.promptCustomizerPhase === 'string'
|
|
207
|
+
? context.promptCustomizerPhase
|
|
208
|
+
: undefined
|
|
209
|
+
const status = phaseHint === 'bootstrap' ? { promoted: false, boundary: -1 }
|
|
210
|
+
: phaseHint === 'compaction' ? { promoted: false, boundary: 1 }
|
|
211
|
+
: phaseHint === 'active' ? { promoted: true, boundary: -1 }
|
|
212
|
+
: promotion.status(context?.agent)
|
|
213
|
+
return { raw, cfg, status }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 装配瀑布流:实时应用字段级 override、屏蔽 / 替换 / 注入 / 阶段化工具过滤。
|
|
217
|
+
ctx.on('system-prompt/assemble', async (assembly, context, next) => {
|
|
218
|
+
// 下游错误原样传播;只有本过滤器自身的逻辑被兜底。
|
|
219
|
+
const assembled = await next()
|
|
220
|
+
try {
|
|
221
|
+
const { raw, cfg, status } = resolveTarget(context)
|
|
222
|
+
const denied = new Set(pickSectionsForStatus(cfg, status))
|
|
223
|
+
const replace = cfg.replace ?? {}
|
|
224
|
+
const te = pickToolsFilter(cfg.tools, status)
|
|
225
|
+
|
|
226
|
+
// 预览专用的预过滤视图(提示词 / 工具 / 预览三个 Tab 的统一数据源):
|
|
227
|
+
// 记录进入本过滤规则的段 / 工具原文,并附上该阶段专属的屏蔽 / 隐藏标记,
|
|
228
|
+
// 让 UI 能正确回显「每阶段独立名单」和工具的逐项隐藏(隐藏后仍在列表)。
|
|
229
|
+
// 运行时装配不带该 hint,零额外开销。
|
|
230
|
+
const baseView = context?.promptCustomizerBase === true
|
|
231
|
+
? {
|
|
232
|
+
sections: Array.isArray(assembled.sections) ? assembled.sections.map((section) => ({
|
|
233
|
+
name: section.name,
|
|
234
|
+
text: typeof section.text === 'function' ? '<动态生成>' : String(section.text ?? ''),
|
|
235
|
+
blocked: denied.has(section.name),
|
|
236
|
+
replaced: Object.hasOwn(replace, section.name),
|
|
237
|
+
})) : [],
|
|
238
|
+
tools: Array.isArray(assembled.tools) ? assembled.tools.map((tool) => ({
|
|
239
|
+
name: typeof tool.name === 'string' ? tool.name : String(tool.name),
|
|
240
|
+
description: typeof tool.description === 'string' ? tool.description : '',
|
|
241
|
+
hidden: (te.exclude ?? []).includes(tool.name),
|
|
242
|
+
})) : [],
|
|
243
|
+
}
|
|
244
|
+
: undefined
|
|
245
|
+
|
|
246
|
+
// 本插件过滤前的原始输入(段 / 工具)—— 预过滤视图与 post 视图同源,
|
|
247
|
+
// 让提示词 / 工具 / 预览三个 Tab 与运行时装配所见一致。
|
|
248
|
+
const baseSections = Array.isArray(assembled.sections) ? assembled.sections : []
|
|
249
|
+
const baseTools = Array.isArray(assembled.tools) ? assembled.tools : []
|
|
250
|
+
|
|
251
|
+
// 段级策略(屏蔽 / 替换 / 注入 / 排序)由 effective.applySectionPolicy
|
|
252
|
+
// 统一实现:瀑布流与 assemble 强制覆盖共用同一份逻辑。
|
|
253
|
+
const sections = applySectionPolicy(baseSections, cfg, status)
|
|
254
|
+
|
|
255
|
+
const tools = appendAddedTools(ctx, context?.scope, applyToolFilter(baseTools, te), te.add, te.exclude)
|
|
256
|
+
|
|
257
|
+
const result = { ...assembled, sections, tools }
|
|
258
|
+
if (baseView) {
|
|
259
|
+
result.promptCustomizerBaseView = {
|
|
260
|
+
sections: baseSections.map((section) => ({
|
|
261
|
+
name: section.name,
|
|
262
|
+
text: typeof section.text === 'function' ? '<动态生成>' : String(section.text ?? ''),
|
|
263
|
+
blocked: denied.has(section.name),
|
|
264
|
+
replaced: Object.hasOwn(replace, section.name),
|
|
265
|
+
})),
|
|
266
|
+
tools: baseTools.map((tool) => ({
|
|
267
|
+
name: typeof tool.name === 'string' ? tool.name : String(tool.name),
|
|
268
|
+
description: typeof tool.description === 'string' ? tool.description : '',
|
|
269
|
+
hidden: (te.exclude ?? []).includes(tool.name),
|
|
270
|
+
})),
|
|
271
|
+
}
|
|
272
|
+
// 预览才探测(运行时装配零额外开销):有 complete 段时宿主要在水流
|
|
273
|
+
// 之后把 sections 整段还原,本插件的段级定制不会进最终提示词。
|
|
274
|
+
// forceSections 开启时该接管已被 assemble 包装绕过,警告不再成立。
|
|
275
|
+
const takenOver = detectCompletePrompt(ctx, context?.scope)
|
|
276
|
+
if (takenOver !== undefined && raw?.forceSections === false) result.promptCustomizerTakenOver = takenOver
|
|
277
|
+
// 本插件实际产出的段名清单:下游还有别的手段会丢弃段(实测 liangshen
|
|
278
|
+
// 引导期由预设自身规则把 20 段砍到 1 段,注册表里并没有 complete 段),
|
|
279
|
+
// 所以 buildPreview 还要拿这份与最终装配比对,做机制无关的兜底探测。
|
|
280
|
+
result.promptCustomizerEmitted = sections.map((section) => String(section.name))
|
|
281
|
+
}
|
|
282
|
+
return result
|
|
283
|
+
} catch (error) {
|
|
284
|
+
warnOnce(`prompt-customizer: 过滤器异常,本次装配回退为未加工结果:${String((error && error.message) || error)}`)
|
|
285
|
+
return assembled
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
// 4) 强制覆盖(forceSections,默认开):包装宿主 systemPrompt.assemble。
|
|
290
|
+
// 预设插件的 prepend 军备竞赛(实测 liangshen / warmupbetter 等 preset 的
|
|
291
|
+
// assemble 过滤器全部 `prepend: true`,standing mount 后注册永远排在本插件
|
|
292
|
+
// 前面)与宿主的 complete 整段接管(瀑布流之后还原 sections)都无法用瀑布流
|
|
293
|
+
// 位次对抗;唯一确定性的手段是在方法边界重建 sections —— 从注册表
|
|
294
|
+
// layers.merge() 取原始段定义(全局 + scope 链遮蔽后,scope = agent 时沿
|
|
295
|
+
// 链解析到 standing 预设层),套用本插件的屏蔽 / 替换 / 注入 / 排序,
|
|
296
|
+
// tools / contexts / variables 保持宿主与预设行为。
|
|
297
|
+
// ponytail: 依赖宿主未承诺的 service 方法与 layers 公开字段;宿主升级可能
|
|
298
|
+
// 静默失效 —— 失效时本包装不生效、瀑布流过滤仍在,可设 forceSections: false
|
|
299
|
+
// 退回旧行为,预览的 takenOverBy / lostSections 探测仍可作运行时自检。
|
|
300
|
+
const sp = ctx.get('systemPrompt')
|
|
301
|
+
if (sp && typeof sp.assemble === 'function') {
|
|
302
|
+
// 只在首个 apply() 记一次原始方法;插件 HMR 重载重跑 apply() 时用新闭包
|
|
303
|
+
// 重新包装,但永远包在原始方法之上,避免层层套娃。
|
|
304
|
+
const pristine = sp[PATCHED]?.orig ?? sp.assemble.bind(sp)
|
|
305
|
+
sp[PATCHED] = { orig: pristine }
|
|
306
|
+
sp.assemble = async (context = {}) => {
|
|
307
|
+
// 变量先于装配同步:宿主在 pristine 内部解析 {{name}},注册必须发生在前。
|
|
308
|
+
syncVariables()
|
|
309
|
+
const result = await pristine(context)
|
|
310
|
+
if (read()?.forceSections === false) return result
|
|
311
|
+
try {
|
|
312
|
+
const { cfg, status } = resolveTarget(context)
|
|
313
|
+
const defs = [...sp.layers.merge(context?.scope, (layer) => layer.sections).values()]
|
|
314
|
+
.sort((a, b) => a.order - b.order)
|
|
315
|
+
const baseSections = defs.map((section) => ({
|
|
316
|
+
name: section.name,
|
|
317
|
+
// 与宿主一致:动态段文本调用生成函数(可能被调用两次 —— 宿主装配
|
|
318
|
+
// 内部已调用一次;动态段通常是纯函数,可接受)。
|
|
319
|
+
text: typeof section.text === 'function' ? section.text(context) : section.text,
|
|
320
|
+
}))
|
|
321
|
+
const sections = applySectionPolicy(baseSections, cfg, status)
|
|
322
|
+
const patched = { ...result, sections }
|
|
323
|
+
// 预览的「产出 vs 模型所见」比对以强制覆盖后的最终段为准。
|
|
324
|
+
if (context?.promptCustomizerBase === true) {
|
|
325
|
+
patched.promptCustomizerEmitted = sections.map((section) => String(section.name))
|
|
326
|
+
}
|
|
327
|
+
return patched
|
|
328
|
+
} catch (error) {
|
|
329
|
+
warnOnce(`prompt-customizer: 强制覆盖 assemble 失败,本次回退为宿主结果:${String((error && error.message) || error)}`)
|
|
330
|
+
// 覆盖失效时恢复接管告警:否则 complete 段接管回来了、却因为上面的
|
|
331
|
+
// forceSections 开关 gate 而没有任何提示(静默降级)。
|
|
332
|
+
const takenOver = detectCompletePrompt(ctx, context?.scope)
|
|
333
|
+
if (takenOver !== undefined) result.promptCustomizerTakenOver = takenOver
|
|
334
|
+
return result
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 5) 共享 webserver 上的清单路由(web profile 里必有)。
|
|
340
|
+
// 用 ctx.get() 保持可选 —— 未在 `inject` 中声明就去访问 ctx.webServer
|
|
341
|
+
// 会抛 "cannot get property without inject"。
|
|
342
|
+
const webserver = ctx.get('webServer')
|
|
343
|
+
if (webserver) {
|
|
344
|
+
const dispose = webserver.register({
|
|
345
|
+
kind: 'exact',
|
|
346
|
+
path: '/api/prompt-customizer/inventory',
|
|
347
|
+
handler: async (req, res) => {
|
|
348
|
+
try {
|
|
349
|
+
// 可选 `?scope=<agentPresetId>`:清单切换到该预设的 scope 与
|
|
350
|
+
// 生效配置(字段级 override 后),供 UI 按目标编辑;缺省为 standard。
|
|
351
|
+
// 宿主共享 webserver 的 req 是原始 IncomingMessage:没有 Express 的
|
|
352
|
+
// query 属性,必须自行解析 URL 参数。
|
|
353
|
+
const id = queryOf(req).get('scope') ?? undefined
|
|
354
|
+
// scope 挂载失败(如常驻挂载超时)会回退展示 standard / 全局层:
|
|
355
|
+
// scopeResolved 标记「请求的目标是否解析出了自己的 scope」,false 时
|
|
356
|
+
// UI 警示「清单并非该预设的原生目录」。
|
|
357
|
+
const resolved = id ? await resolveScopeFor(ctx, id) : await resolveStandardScope(ctx)
|
|
358
|
+
const scopeKey = resolved ?? (id ? await resolveStandardScope(ctx) : undefined)
|
|
359
|
+
const inventory = await buildInventory(ctx, read(), scopeKey, id, catalog)
|
|
360
|
+
// 变量清单随单:先同步注册(黑名单可能刚改过),再列出当前可用名字。
|
|
361
|
+
syncVariables()
|
|
362
|
+
writeJson(res, 200, { ...inventory, scopeResolved: resolved !== undefined, variables: listVariableNames(read().envBlocklist ?? []) })
|
|
363
|
+
} catch (error) {
|
|
364
|
+
writeJson(res, 500, {
|
|
365
|
+
ok: false,
|
|
366
|
+
error: String(error && error.message ? error.message : error),
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
})
|
|
371
|
+
ctx.effect(() => dispose, 'prompt-customizer: inventory route')
|
|
372
|
+
|
|
373
|
+
// 预览路由:渲染最终装配的系统提示词(经过所有插件的过滤,包括我们
|
|
374
|
+
// 自己的屏蔽/替换/注入),让用户确认没有其他插件注入的段造成污染。
|
|
375
|
+
// 可选 `?scope=<agentPresetId>` 预览指定 agent 预设的装配结果(省略时
|
|
376
|
+
// 使用 standard agent,与清单一致);可选 `?phase=<bootstrap|compaction|
|
|
377
|
+
// active>` 预览指定对话阶段的三态视图(省略时按晋级推导,只读路由下
|
|
378
|
+
// 即 active / 静态视图)。
|
|
379
|
+
const disposePreview = webserver.register({
|
|
380
|
+
kind: 'exact',
|
|
381
|
+
path: '/api/prompt-customizer/preview',
|
|
382
|
+
handler: async (req, res) => {
|
|
383
|
+
try {
|
|
384
|
+
const { scopeKey, hintId, phase, scopeResolved } = await previewTarget(ctx, req)
|
|
385
|
+
const preview = await buildPreview(ctx, scopeKey, hintId, phase, catalog)
|
|
386
|
+
writeJson(res, 200, { ...preview, scopeResolved, phase })
|
|
387
|
+
} catch (error) {
|
|
388
|
+
writeJson(res, 500, {
|
|
389
|
+
ok: false,
|
|
390
|
+
error: String(error && error.message ? error.message : error),
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
})
|
|
395
|
+
ctx.effect(() => disposePreview, 'prompt-customizer: preview route')
|
|
396
|
+
|
|
397
|
+
// agent 预设枚举路由:直接读 roster 的 list()(未 memo 化,运行中新增
|
|
398
|
+
// 的预设立即可见),给 UI 的目标选择器与预览选择器供货。
|
|
399
|
+
const disposePresets = webserver.register({
|
|
400
|
+
kind: 'exact',
|
|
401
|
+
path: '/api/prompt-customizer/agent-presets',
|
|
402
|
+
handler: async (_req, res) => {
|
|
403
|
+
try {
|
|
404
|
+
const presets = ctx.get('agentPresets')
|
|
405
|
+
const list = presets ? await presets.list() : []
|
|
406
|
+
writeJson(res, 200, {
|
|
407
|
+
ok: true,
|
|
408
|
+
presets: list.map((p) => ({ id: p.id, name: p.name ?? p.id, description: p.description, broken: p.broken })),
|
|
409
|
+
})
|
|
410
|
+
} catch (error) {
|
|
411
|
+
writeJson(res, 500, {
|
|
412
|
+
ok: false,
|
|
413
|
+
error: String(error && error.message ? error.message : error),
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
})
|
|
418
|
+
ctx.effect(() => disposePresets, 'prompt-customizer: agent-presets route')
|
|
419
|
+
|
|
420
|
+
// 存为 agent 预设:整体 fork 当前编辑目标的预设目录 —— 组成文件、preset.yml、
|
|
421
|
+
// 伴生 .mjs、技能目录一并带走(宿主 authoring API 负责复制并解引用符号链接,
|
|
422
|
+
// 副本自包含),再把提交来的配置写进本插件 overrides[新名]。于是新预设 =
|
|
423
|
+
// 来源预设的原样组成 + 我刚改好的定制,这才叫「保存的是我修改后的样子」。
|
|
424
|
+
// 我们不再自己生成骨架:骨架带着 persona complete: true,宿主会在水流之后把
|
|
425
|
+
// sections 还原成那一条段,段级定制在它上面根本不生效。
|
|
426
|
+
const disposePresetsCreate = webserver.register({
|
|
427
|
+
kind: 'exact',
|
|
428
|
+
path: '/api/prompt-customizer/presets',
|
|
429
|
+
handler: async (req, res) => {
|
|
430
|
+
try {
|
|
431
|
+
const body = await readJsonBody(req)
|
|
432
|
+
const name = typeof body?.name === 'string' ? body.name.trim() : ''
|
|
433
|
+
if (!/^[\p{L}\p{N}_-][\p{L}\p{N} _-]{0,63}$/u.test(name)) {
|
|
434
|
+
writeJson(res, 400, { ok: false, error: '预设名只能包含中英文、数字、空格、下划线与连字符(≤64 字符),且不以标点开头' })
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
const roster = ctx.get('agentPresets')
|
|
438
|
+
if (roster === undefined || typeof roster.copy !== 'function') {
|
|
439
|
+
writeJson(res, 501, { ok: false, error: '宿主未提供 agent 预设服务(dsh-agent-presets),无法创建预设' })
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
if (roster.authorable === false) {
|
|
443
|
+
writeJson(res, 400, { ok: false, error: '当前部署没有可写的用户预设根目录,无法创建预设' })
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
// from = 当前编辑目标;全局目标(未选预设)回落到 roster 默认预设。
|
|
447
|
+
const from = typeof body?.from === 'string' && body.from.length > 0 ? body.from : String(roster.defaultId ?? 'standard')
|
|
448
|
+
const displayName = typeof body?.displayName === 'string' && body.displayName.trim().length > 0 ? body.displayName.trim() : name
|
|
449
|
+
try {
|
|
450
|
+
await roster.copy(from, name, displayName)
|
|
451
|
+
} catch (error) {
|
|
452
|
+
// 先 fork 后写 override:名字被占 / 非法 / 来源不存在时配置里不留空壳。
|
|
453
|
+
writeJson(res, copyErrorStatus(error), {
|
|
454
|
+
ok: false,
|
|
455
|
+
error: String(error && error.message ? error.message : error),
|
|
456
|
+
})
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
const config = body.config && typeof body.config === 'object' && !Array.isArray(body.config) ? body.config : {}
|
|
460
|
+
// 落盘 overrides[name] = 提交来的配置(净化:去掉空列表 / 空对象,避免
|
|
461
|
+
// 生成空壳 override);装配 / 预览 / Tab 全部走同一路径读它。
|
|
462
|
+
const base = { ...(store.raw() ?? {}) }
|
|
463
|
+
const overrides = { ...(base.overrides ?? {}) }
|
|
464
|
+
const clean = {}
|
|
465
|
+
for (const key of ['sections', 'sectionsBootstrap', 'sectionsCompaction', 'replace', 'inject', 'tools']) {
|
|
466
|
+
const value = config[key]
|
|
467
|
+
if (value === undefined || value === null) continue
|
|
468
|
+
const empty = Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0
|
|
469
|
+
if (!empty) clean[key] = value
|
|
470
|
+
}
|
|
471
|
+
overrides[name] = clean
|
|
472
|
+
base.overrides = overrides
|
|
473
|
+
store.writeSection(base)
|
|
474
|
+
writeJson(res, 200, { ok: true, presetId: name, from, config: read() })
|
|
475
|
+
} catch (error) {
|
|
476
|
+
writeJson(res, 500, {
|
|
477
|
+
ok: false,
|
|
478
|
+
error: String(error && error.message ? error.message : error),
|
|
479
|
+
})
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
})
|
|
483
|
+
ctx.effect(() => disposePresetsCreate, 'prompt-customizer: presets-create route')
|
|
484
|
+
|
|
485
|
+
// 配置读写路由(插件自有 config.yaml 的前端通道):
|
|
486
|
+
// GET /config → { ok, config }(解析后含默认值)
|
|
487
|
+
// POST /config/set → body { field, value };写入并返回最新 config
|
|
488
|
+
// POST /config/unset → body { field };删除字段并返回最新 config
|
|
489
|
+
const disposeConfig = webserver.register({
|
|
490
|
+
kind: 'exact',
|
|
491
|
+
path: '/api/prompt-customizer/config',
|
|
492
|
+
handler: async (_req, res) => {
|
|
493
|
+
writeJson(res, 200, { ok: true, config: read() })
|
|
494
|
+
},
|
|
495
|
+
})
|
|
496
|
+
ctx.effect(() => disposeConfig, 'prompt-customizer: config route')
|
|
497
|
+
|
|
498
|
+
const withFieldWrite = (handler) => async (req, res) => {
|
|
499
|
+
try {
|
|
500
|
+
const body = await readJsonBody(req)
|
|
501
|
+
if (typeof body?.field !== 'string' || body.field.length === 0) {
|
|
502
|
+
writeJson(res, 400, { ok: false, error: 'field is required' })
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
handler(body)
|
|
506
|
+
writeJson(res, 200, { ok: true, config: read() })
|
|
507
|
+
} catch (error) {
|
|
508
|
+
writeJson(res, 400, {
|
|
509
|
+
ok: false,
|
|
510
|
+
error: String(error && error.message ? error.message : error),
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
const disposeConfigSet = webserver.register({
|
|
515
|
+
kind: 'exact',
|
|
516
|
+
path: '/api/prompt-customizer/config/set',
|
|
517
|
+
handler: withFieldWrite(({ field, value }) => store.setField(field, value)),
|
|
518
|
+
})
|
|
519
|
+
ctx.effect(() => disposeConfigSet, 'prompt-customizer: config set route')
|
|
520
|
+
|
|
521
|
+
const disposeConfigUnset = webserver.register({
|
|
522
|
+
kind: 'exact',
|
|
523
|
+
path: '/api/prompt-customizer/config/unset',
|
|
524
|
+
handler: withFieldWrite(({ field }) => store.setField(field, undefined)),
|
|
525
|
+
})
|
|
526
|
+
ctx.effect(() => disposeConfigUnset, 'prompt-customizer: config unset route')
|
|
527
|
+
|
|
528
|
+
// 恢复初始状态:清空一切定制并关闭 forceSections —— 与「不装本插件」等效,
|
|
529
|
+
// 无需卸载 / 重启(forceSections: false 时 assemble 包装直接放行宿主原样,
|
|
530
|
+
// 瀑布流里空配置的过滤是无操作)。文件本身保留,避免卸载重装后迁移逻辑
|
|
531
|
+
// 再次触发;之后要重新启用定制,把 forceSections 改回 true 或删除该行即可。
|
|
532
|
+
const disposeConfigReset = webserver.register({
|
|
533
|
+
kind: 'exact',
|
|
534
|
+
path: '/api/prompt-customizer/config/reset',
|
|
535
|
+
handler: async (_req, res) => {
|
|
536
|
+
try {
|
|
537
|
+
store.writeSection({ forceSections: false })
|
|
538
|
+
writeJson(res, 200, { ok: true, config: read() })
|
|
539
|
+
} catch (error) {
|
|
540
|
+
writeJson(res, 500, {
|
|
541
|
+
ok: false,
|
|
542
|
+
error: String(error && error.message ? error.message : error),
|
|
543
|
+
})
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
})
|
|
547
|
+
ctx.effect(() => disposeConfigReset, 'prompt-customizer: config reset route')
|
|
548
|
+
|
|
549
|
+
// 批量保存路由:UI「统一保存按钮」的唯一写盘通道。body { target?, patch },
|
|
550
|
+
// patch 只认业务字段(只认下列业务字段,含每阶段独立段名单),字段值为 null 表示
|
|
551
|
+
// 删除(回退默认/全局)。target 缺省写全局顶层;target 为 agent 预设 id
|
|
552
|
+
// 时写 overrides[id],清空后的空 override 连带删除,避免配置文件堆积空壳。
|
|
553
|
+
const APPLY_FIELDS = ['sections', 'sectionsBootstrap', 'sectionsCompaction', 'replace', 'inject', 'tools']
|
|
554
|
+
const disposeConfigApply = webserver.register({
|
|
555
|
+
kind: 'exact',
|
|
556
|
+
path: '/api/prompt-customizer/config/apply',
|
|
557
|
+
handler: async (req, res) => {
|
|
558
|
+
try {
|
|
559
|
+
const body = await readJsonBody(req)
|
|
560
|
+
const patch = body?.patch
|
|
561
|
+
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
562
|
+
writeJson(res, 400, { ok: false, error: 'patch object is required' })
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
const target = typeof body.target === 'string' && body.target.length > 0 ? body.target : undefined
|
|
566
|
+
const base = { ...(store.raw() ?? {}) }
|
|
567
|
+
const applyPatch = (obj) => {
|
|
568
|
+
for (const field of APPLY_FIELDS) {
|
|
569
|
+
if (!Object.hasOwn(patch, field)) continue
|
|
570
|
+
if (patch[field] === null) delete obj[field]
|
|
571
|
+
else obj[field] = patch[field]
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (target === undefined) {
|
|
575
|
+
applyPatch(base)
|
|
576
|
+
} else {
|
|
577
|
+
const overrides = { ...(base.overrides ?? {}) }
|
|
578
|
+
const ovr = { ...(overrides[target] ?? {}) }
|
|
579
|
+
applyPatch(ovr)
|
|
580
|
+
if (Object.keys(ovr).length > 0) overrides[target] = ovr
|
|
581
|
+
else delete overrides[target]
|
|
582
|
+
if (Object.keys(overrides).length > 0) base.overrides = overrides
|
|
583
|
+
else delete base.overrides
|
|
584
|
+
}
|
|
585
|
+
store.writeSection(base)
|
|
586
|
+
writeJson(res, 200, { ok: true, config: read() })
|
|
587
|
+
} catch (error) {
|
|
588
|
+
writeJson(res, 400, {
|
|
589
|
+
ok: false,
|
|
590
|
+
error: String(error && error.message ? error.message : error),
|
|
591
|
+
})
|
|
592
|
+
}
|
|
593
|
+
},
|
|
594
|
+
})
|
|
595
|
+
ctx.effect(() => disposeConfigApply, 'prompt-customizer: config apply route')
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** 解析 `value`,但最多等待 `ms` 毫秒,超时回退到 `fallback`。 */
|
|
600
|
+
function withTimeout(value, ms, fallback) {
|
|
601
|
+
return Promise.race([
|
|
602
|
+
Promise.resolve(value),
|
|
603
|
+
new Promise((resolve) => setTimeout(() => resolve(fallback), ms)),
|
|
604
|
+
])
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* 宿主 authoring API(`agentPresets.copy`)抛错 → HTTP 状态码。
|
|
609
|
+
*
|
|
610
|
+
* 按错误类的名字判定而不是 import 宿主包:`@deepseek-ai/dsh-agent-presets` 是宿主
|
|
611
|
+
* 依赖,本插件不声明它,子路径导出也不保证存在;名字对不上时退回 500,错误文本
|
|
612
|
+
* 原样回给 UI,至少不吞信息。
|
|
613
|
+
*/
|
|
614
|
+
function copyErrorStatus(error) {
|
|
615
|
+
const kind = String(error?.constructor?.name ?? '')
|
|
616
|
+
if (kind === 'PresetExistsError') return 409
|
|
617
|
+
if (kind === 'InvalidPresetIdError' || kind === 'PresetNotWritableError' || kind === 'UnknownPresetError') return 400
|
|
618
|
+
return 500
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* 存在 roster 时解析 `standard` agent 的常驻 scope key。
|
|
623
|
+
*
|
|
624
|
+
* `standingKeyFor` 会挂载整个 agent 预设(组装插件),可能很慢或依赖尚未
|
|
625
|
+
* 就绪的服务 —— 只读的清单浏览绝不能被它挂住。若在短超时内没有结果,
|
|
626
|
+
* 回退到全局层(scopeKey = undefined)。
|
|
627
|
+
*/
|
|
628
|
+
async function resolveStandardScope(ctx) {
|
|
629
|
+
try {
|
|
630
|
+
const presets = ctx.get('agentPresets')
|
|
631
|
+
if (presets && typeof presets.standingKeyFor === 'function') {
|
|
632
|
+
return await withTimeout(presets.standingKeyFor('standard'), 1500, undefined)
|
|
633
|
+
}
|
|
634
|
+
} catch { /* 没有 roster —— 回退到全局层 */ }
|
|
635
|
+
return undefined
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** 枚举当前生效的段与工具,并附上各自的屏蔽 / 隐藏标记。 */
|
|
639
|
+
async function buildInventory(ctx, raw, scopeKey, presetId, catalog) {
|
|
640
|
+
// 生效配置:目标 agent 预设的字段级 override 优先于全局默认。
|
|
641
|
+
const cfg = mergeConfig(raw, raw.overrides?.[presetId])
|
|
642
|
+
const denied = new Set(cfg.sections ?? [])
|
|
643
|
+
const replace = cfg.replace ?? {}
|
|
644
|
+
const injectOrder = new Map((cfg.inject ?? []).map((item) => [item.name, item.order]))
|
|
645
|
+
const te = pickToolsFilter(cfg.tools, { promoted: true, boundary: -1 }) // 清单无会话概念:恒展示静态(晋级后)视图
|
|
646
|
+
const isHidden = (name) => (te.exclude ?? []).includes(name)
|
|
647
|
+
|
|
648
|
+
// 解析段的文本。动态段注册的是函数;用尽力而为的上下文调用它(与装配时
|
|
649
|
+
// 传入的形态一致),清单才能回显真实生成内容供替换编辑。函数抛错或返回
|
|
650
|
+
// 未决值时回退为占位标记。
|
|
651
|
+
const resolveText = (section) => {
|
|
652
|
+
if (typeof section.text === 'function') {
|
|
653
|
+
try {
|
|
654
|
+
const value = section.text({ scope: scopeKey })
|
|
655
|
+
// 异步动态段返回 thenable,无法同步回显。
|
|
656
|
+
if (value && typeof value.then === 'function') return '<动态生成>'
|
|
657
|
+
return String(value ?? '')
|
|
658
|
+
} catch { return '<动态生成>' }
|
|
659
|
+
}
|
|
660
|
+
return String(section.text ?? '')
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// 本次这个 scope 真正见到的段与工具:只带身份与内容,标记一律现算。
|
|
664
|
+
const seenSections = [...ctx.systemPrompt.layers.merge(scopeKey, (layer) => layer.sections).entries()]
|
|
665
|
+
.map(([name, section]) => ({
|
|
666
|
+
name,
|
|
667
|
+
order: injectOrder.has(name) ? injectOrder.get(name) : (typeof section.order === 'number' ? section.order : 0),
|
|
668
|
+
text: resolveText(section),
|
|
669
|
+
}))
|
|
670
|
+
const seenTools = []
|
|
671
|
+
// 从解析出的 scope 枚举工具;scope key 不可用时(如常驻挂载超时)回退到
|
|
672
|
+
// 全局层,避免仅仅因为 agent 组装未就绪就返回空目录。
|
|
673
|
+
try {
|
|
674
|
+
const byName = new Map()
|
|
675
|
+
for (const scope of [scopeKey, undefined]) {
|
|
676
|
+
let schemas = []
|
|
677
|
+
try { schemas = ctx.tools.schemas(scope) ?? [] } catch { schemas = [] }
|
|
678
|
+
for (const tool of schemas) {
|
|
679
|
+
if (tool.name && tool.name !== undefined && !byName.has(tool.name)) byName.set(tool.name, tool)
|
|
680
|
+
}
|
|
681
|
+
if (byName.size > 0) break
|
|
682
|
+
}
|
|
683
|
+
for (const tool of byName.values()) {
|
|
684
|
+
seenTools.push({ name: tool.name, description: typeof tool.description === 'string' ? tool.description : '' })
|
|
685
|
+
}
|
|
686
|
+
} catch { /* 工具注册表未就绪 —— 本次不增量并入 */ }
|
|
687
|
+
|
|
688
|
+
// 并进累积登记表,再按并集出名单:本 scope 见到的排在前面(顺序更贴近当前
|
|
689
|
+
// 预设),其它预设见过的追加在后。同名内容后见到者赢,条目只增不减。
|
|
690
|
+
if (catalog !== undefined) catalog.observe({ sections: seenSections, tools: seenTools })
|
|
691
|
+
const pool = catalog === undefined ? { sections: seenSections, tools: seenTools } : catalog.read()
|
|
692
|
+
const seenNames = new Set(seenSections.map((x) => x.name))
|
|
693
|
+
const sections = [...seenSections, ...pool.sections.filter((x) => !seenNames.has(x.name))]
|
|
694
|
+
.map((item) => ({ ...item, active: !denied.has(item.name), replaced: Object.hasOwn(replace, item.name) }))
|
|
695
|
+
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
696
|
+
const seenToolNames = new Set(seenTools.map((x) => x.name))
|
|
697
|
+
const tools = [...seenTools, ...pool.tools.filter((x) => !seenToolNames.has(x.name))]
|
|
698
|
+
.map((item) => ({ ...item, hidden: isHidden(item.name) }))
|
|
699
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
700
|
+
|
|
701
|
+
return { sections, tools, config: cfg }
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* 解析一个 agent 预设 id 的常驻 scope key(供无 agent 的只读路由使用)。
|
|
706
|
+
* 显式点名某预设时给挂载留足时间(15s):standingKeyFor 要组装整个预设
|
|
707
|
+
* 插件树,慢挂载很常见,且挂载成功后 roster 常驻 memo,后续请求零开销。
|
|
708
|
+
* 注意 roster 的 standingKeyFor 对未知 id 也会兜底返回 standard scope,
|
|
709
|
+
* 因此先用 list() 确认 id 存在 —— 否则「预设不存在」会被误当成解析成功。
|
|
710
|
+
*/
|
|
711
|
+
async function resolveScopeFor(ctx, id) {
|
|
712
|
+
if (!id) return undefined
|
|
713
|
+
try {
|
|
714
|
+
const presets = ctx.get('agentPresets')
|
|
715
|
+
if (presets && typeof presets.standingKeyFor === 'function') {
|
|
716
|
+
if (typeof presets.list === 'function') {
|
|
717
|
+
const list = await withTimeout(presets.list(), 15000, undefined)
|
|
718
|
+
if (Array.isArray(list) && !list.some((p) => p.id === id)) return undefined
|
|
719
|
+
}
|
|
720
|
+
return await withTimeout(presets.standingKeyFor(id), 15000, undefined)
|
|
721
|
+
}
|
|
722
|
+
} catch { /* 未知预设或挂载失败(如预设依赖缺失)—— 回退全局层 */ }
|
|
723
|
+
return undefined
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Resolve the scope key + preset hint a preview request targets. An explicit
|
|
728
|
+
* `?scope=<id>` requests that agent preset's standing scope; otherwise fall
|
|
729
|
+
* back to the `standard` agent (same default the inventory uses). Always
|
|
730
|
+
* resolves within the same short timeout so a slow standing mount cannot hang
|
|
731
|
+
* the request. The hint makes our own assemble filter apply that preset's
|
|
732
|
+
* field-level override during the preview waterfall.
|
|
733
|
+
*
|
|
734
|
+
* 返回的 `scopeResolved` 表示「请求的目标是否解析出了自己的 scope」:显式
|
|
735
|
+
* id 挂载失败时展示会回落 standard scope,但标记为 false,让 UI 明确警示
|
|
736
|
+
* 「预览的不是该预设的原生装配」。
|
|
737
|
+
*/
|
|
738
|
+
async function previewTarget(ctx, req) {
|
|
739
|
+
const id = queryOf(req).get('scope') ?? undefined
|
|
740
|
+
const resolved = id ? await resolveScopeFor(ctx, id) : await resolveStandardScope(ctx)
|
|
741
|
+
const scopeKey = resolved ?? (id ? await resolveStandardScope(ctx) : undefined)
|
|
742
|
+
// `?phase=`:bootstrap / compaction / active 三态预览;其它值一律回退实际推导。
|
|
743
|
+
const rawPhase = queryOf(req).get('phase') ?? undefined
|
|
744
|
+
const phase = rawPhase === 'bootstrap' || rawPhase === 'compaction' || rawPhase === 'active'
|
|
745
|
+
? rawPhase
|
|
746
|
+
: undefined
|
|
747
|
+
return { scopeKey, hintId: id, phase, scopeResolved: resolved !== undefined }
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* 按预览阶段合成一个最小伪会话 agent。预设原生的阶段裁剪插件(zero-tool
|
|
752
|
+
* bootstrap / router-bootstrap / warmup 等)在 assemble 瀑布流里只读
|
|
753
|
+
* `session.id / session.events / session.header / agent.options.model` 推导
|
|
754
|
+
* 晋级状态并动态裁剪工具目录 —— 合成的 durable 事件让这些规则在只读预览
|
|
755
|
+
* 里按所选阶段真实运行(而非挤在常驻视图):
|
|
756
|
+
* - bootstrap:空事件(boundary -1,未晋级,首轮形态)
|
|
757
|
+
* - compaction:一条 compaction/end(压缩后未重新晋级)
|
|
758
|
+
* - active:一条晋级信号(assistant/message,event 形状见各预设的
|
|
759
|
+
* compaction-epoch.mjs —— 只认 type 与 seq)
|
|
760
|
+
*/
|
|
761
|
+
export function fakeAgentFor(phase, presetId) {
|
|
762
|
+
const events = phase === 'compaction'
|
|
763
|
+
? [{ type: 'compaction/end', seq: 1 }]
|
|
764
|
+
: phase === 'active'
|
|
765
|
+
? [{ type: 'assistant/message', seq: 1 }]
|
|
766
|
+
: []
|
|
767
|
+
return {
|
|
768
|
+
session: {
|
|
769
|
+
id: `prompt-customizer-preview-${phase}`,
|
|
770
|
+
events,
|
|
771
|
+
header: {
|
|
772
|
+
delegationDepth: 0,
|
|
773
|
+
agentPreset: presetId,
|
|
774
|
+
cwd: process.cwd(),
|
|
775
|
+
meta: {},
|
|
776
|
+
},
|
|
777
|
+
},
|
|
778
|
+
options: { provider: '', model: '' },
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* 渲染某个 scope 的最终系统提示词:跑完整的装配瀑布流(所有插件的过滤、
|
|
784
|
+
* 包括我们自己的都会生效),再把幸存的段拼成文本。这就是模型实际看到的内容。
|
|
785
|
+
*
|
|
786
|
+
* `phase` 给定时额外携带伪会话 agent,让预设原生的阶段裁剪规则(引导期
|
|
787
|
+
* 收敛目录等)也按所选阶段真实运行;伪会话触发任何插件异常时降级为无
|
|
788
|
+
* 会话装配(原生规则视为已晋级 / 全目录)并标记 degraded,预览永远可用。
|
|
789
|
+
* 同时返回注册表原始目录总数 registryTotal,与模型可见目录形成对照
|
|
790
|
+
* (如 PTC / Code Mode 把全目录包装成单一 run_code)。
|
|
791
|
+
*/
|
|
792
|
+
async function buildPreview(ctx, scopeKey, hintId, phase, catalog) {
|
|
793
|
+
// promptCustomizerPreset / promptCustomizerPhase 都是本插件的自定义装配
|
|
794
|
+
// 字段:让我们的过滤器在这次装配里按该 agent 预设的 override 与指定阶段
|
|
795
|
+
// 视图生效(AssembleContext 允许插件自定义字段)。
|
|
796
|
+
const hint = {
|
|
797
|
+
scope: scopeKey,
|
|
798
|
+
promptCustomizerPreset: hintId,
|
|
799
|
+
promptCustomizerPhase: phase,
|
|
800
|
+
// 请求预过滤视图(base):本插件的过滤器把进入过滤前的段 / 工具原文
|
|
801
|
+
// 连同屏蔽 / 隐藏标记一起附在装配结果上,作为三个 Tab 的统一数据源。
|
|
802
|
+
promptCustomizerBase: true,
|
|
803
|
+
}
|
|
804
|
+
let assembled
|
|
805
|
+
let degraded = false
|
|
806
|
+
try {
|
|
807
|
+
assembled = await ctx.systemPrompt.assemble(
|
|
808
|
+
phase ? { ...hint, agent: fakeAgentFor(phase, hintId) } : hint,
|
|
809
|
+
)
|
|
810
|
+
} catch {
|
|
811
|
+
// 伪会话让某个预设插件抛错了:退回无会话装配,原生规则不参与本次预览。
|
|
812
|
+
degraded = phase !== undefined
|
|
813
|
+
assembled = await ctx.systemPrompt.assemble(hint)
|
|
814
|
+
}
|
|
815
|
+
const sections = Array.isArray(assembled.sections)
|
|
816
|
+
? assembled.sections.map((section) => ({ name: section.name, text: String(section.text ?? '') }))
|
|
817
|
+
: []
|
|
818
|
+
// 预过滤视图(base):与 post 视图来自同一次装配,三个 Tab 共用同一份数据。
|
|
819
|
+
const baseView = (assembled && typeof assembled === 'object' && assembled.promptCustomizerBaseView) || {}
|
|
820
|
+
// 预览带 scope,是「浏览其它预设」时唯一真正跑过该预设装配的入口 —— 把它
|
|
821
|
+
// 见到的段与工具并进池子(base 视图不含 order,新条目由登记表按追加序补)。
|
|
822
|
+
if (catalog !== undefined) {
|
|
823
|
+
catalog.observe({
|
|
824
|
+
sections: (Array.isArray(baseView.sections) ? baseView.sections : []).map((sec) => ({ name: sec.name, text: sec.text })),
|
|
825
|
+
tools: (Array.isArray(baseView.tools) ? baseView.tools : []).map((tool) => ({ name: tool.name, description: tool.description })),
|
|
826
|
+
})
|
|
827
|
+
}
|
|
828
|
+
const registryTools = registryToolNames(ctx, scopeKey)
|
|
829
|
+
const registryTotal = registryTools.length
|
|
830
|
+
// 兜底探测(机制无关):本插件产出的段有多少没进最终装配。下游丢弃段的规则
|
|
831
|
+
// 不止 persona complete 一种(实测 liangshen 引导期由预设自身规则 20 → 1 段,
|
|
832
|
+
// 注册表里没有 complete 段),所以按名字比对结果而不是只看注册表标记。
|
|
833
|
+
// emitted 缺席 = 我们的过滤器没跑(异常回退未加工装配),此时无从比较。
|
|
834
|
+
const emitted = Array.isArray(assembled.promptCustomizerEmitted) ? assembled.promptCustomizerEmitted : null
|
|
835
|
+
const survivedNames = new Set(sections.map((section) => String(section.name)))
|
|
836
|
+
const droppedNames = emitted === null ? [] : emitted.filter((name) => !survivedNames.has(String(name)))
|
|
837
|
+
const lostSections = droppedNames.length > 0
|
|
838
|
+
? { emitted: emitted.length, survived: sections.length, dropped: droppedNames.length }
|
|
839
|
+
: undefined
|
|
840
|
+
return {
|
|
841
|
+
ok: true,
|
|
842
|
+
scope: scopeKey,
|
|
843
|
+
degraded,
|
|
844
|
+
registryTotal,
|
|
845
|
+
/** 该 scope 注册表的工具名清单:供界面区分「在本预设里、只是被该阶段裁掉」
|
|
846
|
+
* 与「根本不属于本预设(无法加回)」。 */
|
|
847
|
+
registryTools,
|
|
848
|
+
/** 非空 = 该 scope 有 complete 段整段接管最终提示词(值为那段的名字)。 */
|
|
849
|
+
takenOverBy: assembled && typeof assembled === 'object'
|
|
850
|
+
? (typeof assembled.promptCustomizerTakenOver === 'string' ? assembled.promptCustomizerTakenOver : undefined)
|
|
851
|
+
: undefined,
|
|
852
|
+
/** 非空 = 本插件产出的段有若干被下游装配规则丢弃(计数,不含段名列表)。 */
|
|
853
|
+
lostSections,
|
|
854
|
+
sections,
|
|
855
|
+
text: renderPreviewText(assembled),
|
|
856
|
+
tools: Array.isArray(assembled.tools)
|
|
857
|
+
? assembled.tools.map((tool) => ({
|
|
858
|
+
name: typeof tool.name === 'string' ? tool.name : String(tool.name),
|
|
859
|
+
description: typeof tool.description === 'string' ? tool.description : '',
|
|
860
|
+
}))
|
|
861
|
+
: [],
|
|
862
|
+
// 预过滤视图:段(含该阶段独立屏蔽标记 blocked / 替换标记)与工具
|
|
863
|
+
// (含该阶段隐藏标记 hidden)—— 供提示词 / 工具 Tab 渲染「每阶段
|
|
864
|
+
// 独立名单」,被屏蔽 / 被隐藏的条目仍保留在此可反选。
|
|
865
|
+
baseSections: Array.isArray(baseView.sections) ? baseView.sections : [],
|
|
866
|
+
baseTools: Array.isArray(baseView.tools) ? baseView.tools : [],
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** 注册表的工具名清单(解析出的 scope 优先,回退全局层)。任何一步失败都返回
|
|
871
|
+
* 空数组,绝不让预览失败;长度即模型可见目录的对照总数。 */
|
|
872
|
+
function registryToolNames(ctx, scopeKey) {
|
|
873
|
+
try {
|
|
874
|
+
const byName = new Map()
|
|
875
|
+
for (const scope of [scopeKey, undefined]) {
|
|
876
|
+
let schemas = []
|
|
877
|
+
try { schemas = ctx.tools.schemas(scope) ?? [] } catch { schemas = [] }
|
|
878
|
+
for (const tool of schemas) if (tool.name) byName.set(tool.name, tool)
|
|
879
|
+
if (byName.size > 0) break
|
|
880
|
+
}
|
|
881
|
+
return [...byName.keys()]
|
|
882
|
+
} catch { return [] }
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* 把该阶段 `add` 名单里、当前目录还没有的工具,从注册表查回 schema 追加回去
|
|
887
|
+
*(「把被该阶段裁掉、但注册表里仍有的工具加回来」)。`exclude` 优先 —— 同一
|
|
888
|
+
* 名字两者都在 = 隐藏,不加;目录里已有的不重复;注册表查不到的名字跳过(通常
|
|
889
|
+
* 是别的预设独有的工具,当前预设没有可查的 schema,加不进来)。
|
|
890
|
+
*/
|
|
891
|
+
function appendAddedTools(ctx, scopeKey, tools, add, exclude) {
|
|
892
|
+
if (!Array.isArray(add) || add.length === 0) return tools
|
|
893
|
+
const excluded = new Set(exclude ?? [])
|
|
894
|
+
const present = new Set(tools.map((tool) => tool.name))
|
|
895
|
+
const wanted = add.filter((name) => !present.has(name) && !excluded.has(name))
|
|
896
|
+
if (wanted.length === 0) return tools
|
|
897
|
+
const registry = new Map()
|
|
898
|
+
for (const scope of [scopeKey, undefined]) {
|
|
899
|
+
let schemas = []
|
|
900
|
+
try { schemas = ctx.tools.schemas(scope) ?? [] } catch { schemas = [] }
|
|
901
|
+
for (const tool of schemas) if (tool.name) registry.set(tool.name, tool)
|
|
902
|
+
if (registry.size > 0) break
|
|
903
|
+
}
|
|
904
|
+
const extra = wanted.map((name) => registry.get(name)).filter(Boolean)
|
|
905
|
+
return extra.length > 0 ? [...tools, ...extra] : tools
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* 查出该 scope 里是否有一段以 `complete: true` 注册(persona 整段接管)。
|
|
910
|
+
* 宿主 `SystemPrompt.assemble()` 的契约:瀑布流的返回值虽然权威,但存在有效
|
|
911
|
+
* complete 段时会在瀑布流**之后**把 sections 强制还原成那一条段——因此本插件
|
|
912
|
+
* 在瀑布流里做的屏蔽 / 替换 / 注入 / 排序对这类预设完全不进最终提示词(工具
|
|
913
|
+
* 过滤不受影响)。插件侧无法绕开,只能探出来交给 UI 明确告知用户。
|
|
914
|
+
*/
|
|
915
|
+
function detectCompletePrompt(ctx, scopeKey) {
|
|
916
|
+
try {
|
|
917
|
+
const sections = ctx.systemPrompt.layers.merge(scopeKey, (layer) => layer.sections)
|
|
918
|
+
for (const section of sections.values()) {
|
|
919
|
+
if (section && section.complete === true) return String(section.name)
|
|
920
|
+
}
|
|
921
|
+
} catch { /* 注册表未就绪 / 无 layers 能力 —— 视为未接管 */ }
|
|
922
|
+
return undefined
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** 把幸存的段拼成最终提示词文本(与 dsh 的 renderPrompt 行为一致)。 */
|
|
926
|
+
function renderPreviewText(assembled) {
|
|
927
|
+
return (Array.isArray(assembled.sections) ? assembled.sections : [])
|
|
928
|
+
.map((section) => String(section.text ?? '').trim())
|
|
929
|
+
.filter((text) => text.length > 0)
|
|
930
|
+
.join('\n\n')
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function writeJson(res, status, body) {
|
|
934
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
935
|
+
res.end(JSON.stringify(body))
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** 解析请求 URL 里的查询参数(宿主共享 webserver 的 req 是原始
|
|
939
|
+
* IncomingMessage,没有 Express 风格的 query 属性)。 */
|
|
940
|
+
function queryOf(req) {
|
|
941
|
+
const url = String(req?.url ?? '')
|
|
942
|
+
const i = url.indexOf('?')
|
|
943
|
+
return new URLSearchParams(i >= 0 ? url.slice(i + 1) : '')
|
|
944
944
|
}
|