dsh-prompt-customizer 0.3.1 → 0.4.0
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.en.md +15 -1
- package/README.md +18 -3
- package/client/client.js +154 -13
- package/client/client.js.map +1 -1
- package/lib/effective.js +49 -0
- package/lib/index.js +132 -56
- package/lib/schema.js +10 -0
- package/lib/vars.js +147 -0
- package/package.json +1 -1
- package/src/client/PresetsTab.tsx +82 -4
- package/src/client/SectionsTab.tsx +4 -1
- package/src/client/index.ts +23 -0
- package/src/client/locales.ts +38 -2
- package/src/client/preset-io.ts +6 -5
- package/src/client/types.ts +8 -0
package/lib/effective.js
CHANGED
|
@@ -107,3 +107,52 @@ export function pickSectionsForStatus(config, status) {
|
|
|
107
107
|
if (!status.promoted && Array.isArray(bootstrap) && bootstrap.length > 0) return bootstrap
|
|
108
108
|
return []
|
|
109
109
|
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 段级策略:屏蔽 → 替换 → 注入 → 排序,作用于任意来源的基础段列表。
|
|
113
|
+
*
|
|
114
|
+
* 瀑布流装配与 assemble 强制覆盖(forceSections)共用同一份实现 —— 输入
|
|
115
|
+
* `baseSections` 可以是瀑布流里其它插件过滤后的段,也可以是注册表
|
|
116
|
+
* `layers.merge()` 的原始段定义。后一种输入就是「以本插件为准」的关键:
|
|
117
|
+
* 预设插件的阶段裁段与宿主的 complete 整段接管都发生在瀑布流内/后,
|
|
118
|
+
* 而强制覆盖在方法边界用这份原始定义重建 sections,前两者再无机会改写。
|
|
119
|
+
* 条目只需 { name, text };order 在映射时按数组序补上(与注册表顺序一致),
|
|
120
|
+
* 保证稳定排序,与注入段的虚拟 order 空间一致。
|
|
121
|
+
*/
|
|
122
|
+
export function applySectionPolicy(baseSections, cfg, status) {
|
|
123
|
+
const denied = new Set([...(cfg.sections ?? []), ...pickSectionsForStatus(cfg, status)])
|
|
124
|
+
const replace = cfg.replace ?? {}
|
|
125
|
+
const injectList = filterInjectByPhase(cfg.inject, status)
|
|
126
|
+
let sections = baseSections
|
|
127
|
+
.filter((section) => !denied.has(section.name))
|
|
128
|
+
.map((section, i) =>
|
|
129
|
+
Object.hasOwn(replace, section.name) ? { ...section, order: i, text: replace[section.name] } : { ...section, order: i },
|
|
130
|
+
)
|
|
131
|
+
for (const item of injectList) {
|
|
132
|
+
// 被屏蔽的段直接跳过 —— 上面已经把它们过滤掉了。
|
|
133
|
+
if (denied.has(item.name)) continue
|
|
134
|
+
const index = sections.findIndex((section) => section.name === item.name)
|
|
135
|
+
if (index >= 0) {
|
|
136
|
+
// 已存在的段:覆盖 order 来控制拼接顺序。文本按「本阶段注入文本 → 该
|
|
137
|
+
// 段当前文本」取(后者可能已被全局 replace 改过)—— injectList 已经按
|
|
138
|
+
// 阶段筛过,所以带 phase 的文本只作用在本阶段,三个阶段各改各的;全局
|
|
139
|
+
// replace 字典是跨阶段的(旧语义),只在没有阶段文本时生效。
|
|
140
|
+
sections[index] = {
|
|
141
|
+
...sections[index],
|
|
142
|
+
text: item.text ? item.text : sections[index].text,
|
|
143
|
+
order: item.order,
|
|
144
|
+
}
|
|
145
|
+
} else if (item.custom === true || Object.hasOwn(replace, item.name) || item.text) {
|
|
146
|
+
// 装配里没有这个名字:只有「本插件自撰的 custom 段」「被替换的段」
|
|
147
|
+
// 「自带文本的注入段」才允许新建条目。只带 order 的空文本系统名必须
|
|
148
|
+
// 跳过 —— 它通常来自快照里混进的别的预设独有段名(并集池会带回来),
|
|
149
|
+
// 新建就变成一条空白幽灵段,实打实进最终提示词。
|
|
150
|
+
sections.push({ name: item.name, order: item.order, text: item.text || replace[item.name] })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// 虚拟 order 排序:order 缺省按 0,稳定排序保持系统段原始相对顺序;
|
|
154
|
+
// 注入段携带各自的 order(120+ 落在系统段之后,或被重排到任意断点),
|
|
155
|
+
// 以 order 升序决定注入顺序 —— 与「每阶段独立名单」配合即每阶段独立序。
|
|
156
|
+
sections.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
157
|
+
return sections
|
|
158
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -28,24 +28,32 @@
|
|
|
28
28
|
* 另一面:若该 scope 有以 `complete: true` 注册的段(persona 整段接管),宿主
|
|
29
29
|
* 会在装配瀑布流**之后**把 sections 强制还原成那一条段(见 dsh-system-prompt
|
|
30
30
|
* 的 assemble 契约),本插件的段级屏蔽 / 替换 / 注入 / 排序对其完全不生效
|
|
31
|
-
*
|
|
32
|
-
*
|
|
31
|
+
* (工具过滤不受影响)。`forceSections`(默认开)为此提供终极手段:包装宿主
|
|
32
|
+
* 的 assemble,在方法边界用注册表原始段重建 sections —— 预设插件的阶段裁段
|
|
33
|
+
* (实测 liangshen / warmupbetter 等 preset 过滤器全部 prepend: true)与
|
|
34
|
+
* complete 整段接管都无法再改写结果。关闭时预览过滤器会探测并把段名作为
|
|
35
|
+
* `takenOverBy` 透出,让提示词 / 预览两个 Tab 就这件事说同一句话。
|
|
33
36
|
*/
|
|
34
37
|
|
|
35
38
|
import fs from 'node:fs'
|
|
36
39
|
import os from 'node:os'
|
|
37
40
|
import path from 'node:path'
|
|
38
41
|
import { Config, NS } from './schema.js'
|
|
39
|
-
import { mergeConfig,
|
|
42
|
+
import { mergeConfig, pickToolsFilter, pickSectionsForStatus, applyToolFilter, applySectionPolicy } from './effective.js'
|
|
40
43
|
import { createPromotion, presetOfSession } from './promotion.js'
|
|
41
44
|
import { createConfigStore, readLegacySection } from './store.js'
|
|
42
45
|
import { createCatalog } from './catalog.js'
|
|
46
|
+
import { registerVariables, listVariableNames } from './vars.js'
|
|
43
47
|
|
|
44
48
|
export const name = 'prompt-customizer'
|
|
45
49
|
// settings 仍被注入:只读 documentPath 做一次性旧版迁移。cordis 对未在
|
|
46
50
|
// inject 里声明的服务调用 ctx.get 会抛错,漏声明会让整个插件加载失败。
|
|
47
51
|
export const inject = ['settings', 'systemPrompt', 'tools']
|
|
48
52
|
|
|
53
|
+
/** assemble 强制覆盖的「原始方法」登记键:跨插件 HMR 重载共享(Symbol.for),
|
|
54
|
+
* 重载后永远包在原始方法之上,避免层层套娃。见 apply() 里的 forceSections。 */
|
|
55
|
+
const PATCHED = Symbol.for('prompt-customizer:assemble-pristine')
|
|
56
|
+
|
|
49
57
|
/** harness home;ponytail: DSH_HOME 为推测的官方环境变量,若宿主日后提供
|
|
50
58
|
* 正式的 home 解析 API 应换过去。回退 `~/.dsh`。 */
|
|
51
59
|
function dshHome() {
|
|
@@ -166,31 +174,53 @@ export function apply(ctx, entry = {}) {
|
|
|
166
174
|
try { ctx.logger?.warn?.(message) } catch { /* logger 缺席只是不再告警 */ }
|
|
167
175
|
}
|
|
168
176
|
|
|
169
|
-
//
|
|
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、屏蔽 / 替换 / 注入 / 阶段化工具过滤。
|
|
170
217
|
ctx.on('system-prompt/assemble', async (assembly, context, next) => {
|
|
171
218
|
// 下游错误原样传播;只有本过滤器自身的逻辑被兜底。
|
|
172
219
|
const assembled = await next()
|
|
173
220
|
try {
|
|
174
|
-
const raw =
|
|
175
|
-
// 生效配置:显式预览提示 > 会话所属 agent 预设(字段级覆盖)> 全局默认。
|
|
176
|
-
const id = typeof context?.promptCustomizerPreset === 'string'
|
|
177
|
-
? context.promptCustomizerPreset
|
|
178
|
-
: presetOfSession(context?.agent?.session)
|
|
179
|
-
const cfg = mergeConfig(raw, raw.overrides?.[id])
|
|
180
|
-
// 预览 hint:显式 promptCustomizerPhase 时以它替代由会话事件推导的
|
|
181
|
-
// 实际阶段,让同一份配置能渲染 bootstrap / compaction / active 三种
|
|
182
|
-
// 阶段视图。boundary 语义与 promotion.status 一致(-1 = 从未压缩)。
|
|
183
|
-
const phaseHint = typeof context?.promptCustomizerPhase === 'string'
|
|
184
|
-
? context.promptCustomizerPhase
|
|
185
|
-
: undefined
|
|
186
|
-
const status = phaseHint === 'bootstrap' ? { promoted: false, boundary: -1 }
|
|
187
|
-
: phaseHint === 'compaction' ? { promoted: false, boundary: 1 }
|
|
188
|
-
: phaseHint === 'active' ? { promoted: true, boundary: -1 }
|
|
189
|
-
: promotion.status(context?.agent)
|
|
221
|
+
const { raw, cfg, status } = resolveTarget(context)
|
|
190
222
|
const denied = new Set([...(cfg.sections ?? []), ...pickSectionsForStatus(cfg, status)])
|
|
191
223
|
const replace = cfg.replace ?? {}
|
|
192
|
-
// inject 按当前阶段模式筛选(bootstrap/active/compact 三态注入互相独立)。
|
|
193
|
-
const injectList = filterInjectByPhase(cfg.inject, status)
|
|
194
224
|
const te = pickToolsFilter(cfg.tools, status)
|
|
195
225
|
|
|
196
226
|
// 预览专用的预过滤视图(提示词 / 工具 / 预览三个 Tab 的统一数据源):
|
|
@@ -218,37 +248,9 @@ export function apply(ctx, entry = {}) {
|
|
|
218
248
|
const baseSections = Array.isArray(assembled.sections) ? assembled.sections : []
|
|
219
249
|
const baseTools = Array.isArray(assembled.tools) ? assembled.tools : []
|
|
220
250
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
Object.hasOwn(replace, section.name) ? { ...section, order: i, text: replace[section.name] } : { ...section, order: i },
|
|
225
|
-
)
|
|
226
|
-
for (const item of injectList) {
|
|
227
|
-
// 被屏蔽的段直接跳过 —— 上面已经把它们过滤掉了。
|
|
228
|
-
if (denied.has(item.name)) continue
|
|
229
|
-
const index = sections.findIndex((section) => section.name === item.name)
|
|
230
|
-
if (index >= 0) {
|
|
231
|
-
// 已存在的段:覆盖 order 来控制拼接顺序。文本按「本阶段注入文本 → 该
|
|
232
|
-
// 段当前文本」取(后者可能已被全局 replace 改过)—— injectList 已经按
|
|
233
|
-
// 阶段筛过,所以带 phase 的文本只作用在本阶段,三个阶段各改各的;全局
|
|
234
|
-
// replace 字典是跨阶段的(旧语义),只在没有阶段文本时生效。
|
|
235
|
-
sections[index] = {
|
|
236
|
-
...sections[index],
|
|
237
|
-
text: item.text ? item.text : sections[index].text,
|
|
238
|
-
order: item.order,
|
|
239
|
-
}
|
|
240
|
-
} else if (item.custom === true || Object.hasOwn(replace, item.name) || item.text) {
|
|
241
|
-
// 装配里没有这个名字:只有「本插件自撰的 custom 段」「被替换的段」
|
|
242
|
-
// 「自带文本的注入段」才允许新建条目。只带 order 的空文本系统名必须
|
|
243
|
-
// 跳过 —— 它通常来自快照里混进的别的预设独有段名(并集池会带回来),
|
|
244
|
-
// 新建就变成一条空白幽灵段,实打实进最终提示词。
|
|
245
|
-
sections.push({ name: item.name, order: item.order, text: item.text || replace[item.name] })
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
// 虚拟 order 排序:order 缺省按 0,稳定排序保持系统段原始相对顺序;
|
|
249
|
-
// 注入段携带各自的 order(120+ 落在系统段之后,或被重排到任意断点),
|
|
250
|
-
// 以 order 升序决定注入顺序 —— 与「每阶段独立名单」配合即每阶段独立序。
|
|
251
|
-
sections.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
251
|
+
// 段级策略(屏蔽 / 替换 / 注入 / 排序)由 effective.applySectionPolicy
|
|
252
|
+
// 统一实现:瀑布流与 assemble 强制覆盖共用同一份逻辑。
|
|
253
|
+
const sections = applySectionPolicy(baseSections, cfg, status)
|
|
252
254
|
|
|
253
255
|
const tools = appendAddedTools(ctx, context?.scope, applyToolFilter(baseTools, te), te.add, te.exclude)
|
|
254
256
|
|
|
@@ -269,8 +271,9 @@ export function apply(ctx, entry = {}) {
|
|
|
269
271
|
}
|
|
270
272
|
// 预览才探测(运行时装配零额外开销):有 complete 段时宿主要在水流
|
|
271
273
|
// 之后把 sections 整段还原,本插件的段级定制不会进最终提示词。
|
|
274
|
+
// forceSections 开启时该接管已被 assemble 包装绕过,警告不再成立。
|
|
272
275
|
const takenOver = detectCompletePrompt(ctx, context?.scope)
|
|
273
|
-
if (takenOver !== undefined) result.promptCustomizerTakenOver = takenOver
|
|
276
|
+
if (takenOver !== undefined && raw?.forceSections === false) result.promptCustomizerTakenOver = takenOver
|
|
274
277
|
// 本插件实际产出的段名清单:下游还有别的手段会丢弃段(实测 liangshen
|
|
275
278
|
// 引导期由预设自身规则把 20 段砍到 1 段,注册表里并没有 complete 段),
|
|
276
279
|
// 所以 buildPreview 还要拿这份与最终装配比对,做机制无关的兜底探测。
|
|
@@ -283,7 +286,57 @@ export function apply(ctx, entry = {}) {
|
|
|
283
286
|
}
|
|
284
287
|
})
|
|
285
288
|
|
|
286
|
-
// 4)
|
|
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 里必有)。
|
|
287
340
|
// 用 ctx.get() 保持可选 —— 未在 `inject` 中声明就去访问 ctx.webServer
|
|
288
341
|
// 会抛 "cannot get property without inject"。
|
|
289
342
|
const webserver = ctx.get('webServer')
|
|
@@ -304,7 +357,9 @@ export function apply(ctx, entry = {}) {
|
|
|
304
357
|
const resolved = id ? await resolveScopeFor(ctx, id) : await resolveStandardScope(ctx)
|
|
305
358
|
const scopeKey = resolved ?? (id ? await resolveStandardScope(ctx) : undefined)
|
|
306
359
|
const inventory = await buildInventory(ctx, read(), scopeKey, id, catalog)
|
|
307
|
-
|
|
360
|
+
// 变量清单随单:先同步注册(黑名单可能刚改过),再列出当前可用名字。
|
|
361
|
+
syncVariables()
|
|
362
|
+
writeJson(res, 200, { ...inventory, scopeResolved: resolved !== undefined, variables: listVariableNames(read().envBlocklist ?? []) })
|
|
308
363
|
} catch (error) {
|
|
309
364
|
writeJson(res, 500, {
|
|
310
365
|
ok: false,
|
|
@@ -470,6 +525,27 @@ export function apply(ctx, entry = {}) {
|
|
|
470
525
|
})
|
|
471
526
|
ctx.effect(() => disposeConfigUnset, 'prompt-customizer: config unset route')
|
|
472
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
|
+
|
|
473
549
|
// 批量保存路由:UI「统一保存按钮」的唯一写盘通道。body { target?, patch },
|
|
474
550
|
// patch 只认业务字段(只认下列业务字段,含每阶段独立段名单),字段值为 null 表示
|
|
475
551
|
// 删除(回退默认/全局)。target 缺省写全局顶层;target 为 agent 预设 id
|
package/lib/schema.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Schema from '@deepseek-ai/schemastery'
|
|
2
|
+
import { DEFAULT_ENV_BLOCKLIST } from './vars.js'
|
|
2
3
|
|
|
3
4
|
/** 本插件的设置命名空间(小写 kebab-case)。 */
|
|
4
5
|
export const NS = 'prompt-customizer'
|
|
@@ -48,6 +49,15 @@ export const Config = Schema.object({
|
|
|
48
49
|
add: Schema.array(Schema.string()).default([]),
|
|
49
50
|
}).default({}),
|
|
50
51
|
}).default({}),
|
|
52
|
+
// 强制覆盖(默认开):包装宿主的 assemble,直接从注册表原始段重建最终
|
|
53
|
+
// 提示词段 —— 预设插件的阶段裁段(实测 liangshen / warmupbetter 等 preset
|
|
54
|
+
// 过滤器全部 prepend: true)与宿主 complete 整段接管都无法再改写结果;
|
|
55
|
+
// tools / contexts 保持宿主与预设行为。设 false 退回瀑布流内过滤。
|
|
56
|
+
forceSections: Schema.boolean().default(true),
|
|
57
|
+
// process.env 变量黑名单:env 全量注册为提示词变量({{env_xxx}}),命中
|
|
58
|
+
// 本名单的键不注册 —— env 里常有密钥,进了提示词就会随请求发给模型。
|
|
59
|
+
// 条目支持 `*` 通配、大小写不敏感;预填常见密钥类键,界面可增删。
|
|
60
|
+
envBlocklist: Schema.array(Schema.string()).default(DEFAULT_ENV_BLOCKLIST),
|
|
51
61
|
// 预设:完整的定制快照(提示词 + 工具配置,含每阶段独立名单与阶段目录)。
|
|
52
62
|
presets: Schema.array(Schema.object({
|
|
53
63
|
id: Schema.string().required(),
|
package/lib/vars.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 提示词变量注册 —— 让段文本里的 `{{name}}` 拿到宿主三件套之外的值。
|
|
3
|
+
*
|
|
4
|
+
* 宿主 system-prompt 只由 agent-loop 注册 provider / model / cwd 三个变量,
|
|
5
|
+
* 渲染又是严格模式:引用未注册的名字直接抛错。本模块通过官方扩展点
|
|
6
|
+
* `ctx.systemPrompt.variable(name, provider)` 追加两类变量:
|
|
7
|
+
*
|
|
8
|
+
* 1. 内置系统事实(date / time / hostname / …)——每次装配现算,天然动态;
|
|
9
|
+
* 2. process.env 全量 —— 键名规范化为 `env_<小写>` 后注册,命中黑名单的
|
|
10
|
+
* 键不注册(env 里常有密钥,进了提示词就会随请求发给模型)。
|
|
11
|
+
*
|
|
12
|
+
* 约束来自宿主:变量名必须匹配 /^[a-z][a-z0-9_]*$/;provider 返回 undefined
|
|
13
|
+
* 会让渲染抛错,所以所有 provider 都保证返回字符串;与宿主重名会抛错,
|
|
14
|
+
* 保留字直接跳过(同名注册逐个 try 兜底,别家插件先注册了也只是跳过那一个)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import os from 'node:os'
|
|
18
|
+
|
|
19
|
+
/** 与宿主 system-prompt 的 VARIABLE_NAME 完全一致(不满足就不可能注册成功)。 */
|
|
20
|
+
const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
|
|
21
|
+
|
|
22
|
+
/** agent-loop 已注册的三个名字:重复注册会抛错,永远避开。 */
|
|
23
|
+
const RESERVED = new Set(['provider', 'model', 'cwd'])
|
|
24
|
+
|
|
25
|
+
/** 预填黑名单:常见密钥 / 凭据类环境变量。条目支持 `*` 通配,大小写不敏感。 */
|
|
26
|
+
export const DEFAULT_ENV_BLOCKLIST = [
|
|
27
|
+
'*SECRET*',
|
|
28
|
+
'*TOKEN*',
|
|
29
|
+
'*PASSWORD*',
|
|
30
|
+
'*PASSWD*',
|
|
31
|
+
'*CREDENTIAL*',
|
|
32
|
+
'*API_KEY*',
|
|
33
|
+
'*APIKEY*',
|
|
34
|
+
'*ACCESS_KEY*',
|
|
35
|
+
'*PRIVATE_KEY*',
|
|
36
|
+
'*AUTH*',
|
|
37
|
+
'*_DSN',
|
|
38
|
+
'*CONNECTION_STRING*',
|
|
39
|
+
'DATABASE_URL',
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 黑名单条目编译成匹配函数:含 `*` 按通配(`*` 匹配任意串),否则整串
|
|
44
|
+
* 精确比较;统一大小写不敏感(env 键多为大写,用户多写小写)。
|
|
45
|
+
*/
|
|
46
|
+
function compileEntries(blocklist) {
|
|
47
|
+
const matchers = []
|
|
48
|
+
for (const entry of Array.isArray(blocklist) ? blocklist : []) {
|
|
49
|
+
if (typeof entry !== 'string') continue
|
|
50
|
+
const text = entry.trim()
|
|
51
|
+
if (text === '') continue
|
|
52
|
+
if (text.includes('*')) {
|
|
53
|
+
const pattern = '^' + text.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$'
|
|
54
|
+
let re
|
|
55
|
+
try { re = new RegExp(pattern, 'i') } catch { continue }
|
|
56
|
+
matchers.push((key) => re.test(key))
|
|
57
|
+
} else {
|
|
58
|
+
const lower = text.toLowerCase()
|
|
59
|
+
matchers.push((key) => key.toLowerCase() === lower)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return matchers
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** env 键是否被黑名单挡下(不注册 = 提示词里引用它会得到宿主的「未注册」报错)。 */
|
|
66
|
+
export function isBlockedEnvKey(key, blocklist) {
|
|
67
|
+
return compileEntries(blocklist).some((match) => match(key))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 把任意 env 键映射为合法变量名:`env_` 前缀 + 小写 + 非法字符归并为 `_`。
|
|
72
|
+
* 例:`PATH` → `env_path`,`USER.NAME` → `env_user_name`。映射结果必然
|
|
73
|
+
* 匹配宿主命名规则;空键返回 undefined。
|
|
74
|
+
*/
|
|
75
|
+
export function envVarName(key) {
|
|
76
|
+
const core = String(key).toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
|
77
|
+
if (core === '') return undefined
|
|
78
|
+
const name = `env_${core}`
|
|
79
|
+
return VARIABLE_NAME.test(name) ? name : undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 内置系统事实变量:名字 → 无参提供器(注册时包一层保证返回字符串)。 */
|
|
83
|
+
function builtinProviders() {
|
|
84
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
85
|
+
const isoDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
86
|
+
const time = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
87
|
+
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
|
88
|
+
return {
|
|
89
|
+
date: () => isoDate(new Date()),
|
|
90
|
+
time: () => time(new Date()),
|
|
91
|
+
datetime: () => { const d = new Date(); return `${isoDate(d)} ${time(d)}` },
|
|
92
|
+
weekday: () => weekdays[new Date().getDay()],
|
|
93
|
+
hostname: () => { try { return os.hostname() } catch { return '' } },
|
|
94
|
+
platform: () => process.platform,
|
|
95
|
+
arch: () => process.arch,
|
|
96
|
+
username: () => { try { return os.userInfo().username } catch { return '' } },
|
|
97
|
+
home: () => { try { return os.homedir() } catch { return '' } },
|
|
98
|
+
shell: () => process.env.SHELL ?? process.env.COMSPEC ?? '',
|
|
99
|
+
locale: () => { try { return Intl.DateTimeFormat().resolvedOptions().locale ?? '' } catch { return '' } },
|
|
100
|
+
node_version: () => process.version,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 当前会注册的变量名清单(内置 + 未被黑名单挡下的 env):供清单路由展示。 */
|
|
105
|
+
export function listVariableNames(blocklist) {
|
|
106
|
+
const names = new Set(Object.keys(builtinProviders()))
|
|
107
|
+
const matchers = compileEntries(blocklist)
|
|
108
|
+
const seen = new Set()
|
|
109
|
+
for (const key of Object.keys(process.env)) {
|
|
110
|
+
if (matchers.some((match) => match(key))) continue
|
|
111
|
+
const name = envVarName(key)
|
|
112
|
+
if (name === undefined || seen.has(name)) continue
|
|
113
|
+
seen.add(name)
|
|
114
|
+
names.add(name)
|
|
115
|
+
}
|
|
116
|
+
return [...names].sort()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 向宿主注册全部变量,返回注销函数。黑名单变化时由调用方先注销再重注册
|
|
121
|
+
* (见 lib/index.js 的懒同步:装配入口比对签名,配置外改也在下次装配生效)。
|
|
122
|
+
*/
|
|
123
|
+
export function registerVariables(systemPrompt, blocklist) {
|
|
124
|
+
const disposes = []
|
|
125
|
+
const reg = (name, provider) => {
|
|
126
|
+
if (RESERVED.has(name)) return
|
|
127
|
+
try { disposes.push(systemPrompt.variable(name, provider)) } catch { /* 同名已被别处注册:跳过 */ }
|
|
128
|
+
}
|
|
129
|
+
for (const [name, provider] of Object.entries(builtinProviders())) {
|
|
130
|
+
// provider 必须返回字符串:严格渲染把 undefined 当错误。
|
|
131
|
+
reg(name, () => { const value = provider(); return typeof value === 'string' ? value : '' })
|
|
132
|
+
}
|
|
133
|
+
const matchers = compileEntries(blocklist)
|
|
134
|
+
const seen = new Set()
|
|
135
|
+
for (const key of Object.keys(process.env)) {
|
|
136
|
+
if (matchers.some((match) => match(key))) continue
|
|
137
|
+
const name = envVarName(key)
|
|
138
|
+
if (name === undefined || seen.has(name)) continue
|
|
139
|
+
seen.add(name)
|
|
140
|
+
reg(name, () => process.env[key] ?? '')
|
|
141
|
+
}
|
|
142
|
+
return () => {
|
|
143
|
+
for (const dispose of disposes) {
|
|
144
|
+
try { dispose() } catch { /* 注销失败不阻塞其余注销 */ }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-prompt-customizer",
|
|
3
3
|
"description": "System-prompt & tool customizer for DeepSeek Harness with a settings-panel UI: block / replace / inject prompt sections by name, and hide tools from the model catalog by exclude/include. Config lives in the plugin's own ~/.dsh/prompt-customizer/config.yaml (auto-migrated once from the legacy settings.yaml section).",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
7
7
|
"main": "lib/index.js",
|
|
@@ -18,7 +18,7 @@ interface Notice {
|
|
|
18
18
|
text: string
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export function PresetsTab({ cfg, inv, phases, t, writePatch, writeGlobal, saveAsPreset, forkSource }: {
|
|
21
|
+
export function PresetsTab({ cfg, inv, phases, t, writePatch, writeGlobal, saveAsPreset, forkSource, onReset, envBlocklist }: {
|
|
22
22
|
cfg: Config
|
|
23
23
|
inv: Inventory | null
|
|
24
24
|
/** Panel 并行拉取的三阶段装配:快照只捕获当前装配里真实存在的段。 */
|
|
@@ -32,9 +32,15 @@ export function PresetsTab({ cfg, inv, phases, t, writePatch, writeGlobal, saveA
|
|
|
32
32
|
saveAsPreset: (name: string) => Promise<boolean>
|
|
33
33
|
/** fork 来源的显示名(当前编辑目标预设);undefined = 全局目标,由服务端回落默认预设。 */
|
|
34
34
|
forkSource: string | undefined
|
|
35
|
+
/** 恢复初始状态:清空全部定制并关闭 forceSections(与不装插件等效)。 */
|
|
36
|
+
onReset: () => void
|
|
37
|
+
/** 全局环境变量黑名单(永远顶层字段,不分作用域);导出随行、导入并集。 */
|
|
38
|
+
envBlocklist: string[]
|
|
35
39
|
}): ReactElement {
|
|
36
40
|
const presets = cfg.presets ?? []
|
|
37
41
|
const [name, setName] = useState('')
|
|
42
|
+
// 黑名单「添加」输入框的暂存值。
|
|
43
|
+
const [blockInput, setBlockInput] = useState('')
|
|
38
44
|
// 「存为 agent 预设」的名字与在途标记(与服务端往返期间禁用按钮)。
|
|
39
45
|
const [agentName, setAgentName] = useState('')
|
|
40
46
|
const [creating, setCreating] = useState(false)
|
|
@@ -100,7 +106,7 @@ export function PresetsTab({ cfg, inv, phases, t, writePatch, writeGlobal, saveA
|
|
|
100
106
|
// 时)回退为下载。saved/downloaded = 成功;用户取消单独提示,不算失败。
|
|
101
107
|
const exportPreset = async (preset: Preset): Promise<void> => {
|
|
102
108
|
try {
|
|
103
|
-
const res = await exportPresetFile(preset)
|
|
109
|
+
const res = await exportPresetFile(preset, undefined, envBlocklist)
|
|
104
110
|
if (res.ok) show('ok', t('exportOk'))
|
|
105
111
|
else if (res.cancelled) show('ok', t('exportCancel'))
|
|
106
112
|
else show('error', `${t('exportFail')}${res.message ? ': ' + res.message : ''}`)
|
|
@@ -110,15 +116,24 @@ export function PresetsTab({ cfg, inv, phases, t, writePatch, writeGlobal, saveA
|
|
|
110
116
|
}
|
|
111
117
|
|
|
112
118
|
// 解析导入文本 → 去重追加 → 提示新增数量。解析失败提示错误。
|
|
119
|
+
// 单对象格式的文件还可能随行携带全局环境变量黑名单:与当前名单并集合并
|
|
120
|
+
// (只增不减),新增条数一并报进提示。
|
|
113
121
|
const importParsed = (text: string): void => {
|
|
114
122
|
try {
|
|
115
123
|
const parsed = decodePresetExport(text)
|
|
116
124
|
const next = addImportedPresets(presets, parsed, genId)
|
|
117
125
|
const added = next.length - presets.length
|
|
118
|
-
|
|
126
|
+
const fileBlock = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
127
|
+
&& Array.isArray((parsed as { envBlocklist?: unknown }).envBlocklist)
|
|
128
|
+
? ((parsed as { envBlocklist?: unknown[] }).envBlocklist ?? []).filter((e): e is string => typeof e === 'string')
|
|
129
|
+
: []
|
|
130
|
+
const fresh = fileBlock.filter((e) => !envBlocklist.includes(e))
|
|
131
|
+
if (fresh.length > 0) writeGlobal('envBlocklist', [...envBlocklist, ...fresh])
|
|
132
|
+
const mergedNote = fresh.length > 0 ? t('importBlockMerge', { count: fresh.length }) : ''
|
|
133
|
+
if (added <= 0) show('ok', t('importNone') + mergedNote)
|
|
119
134
|
else {
|
|
120
135
|
writeGlobal('presets', next)
|
|
121
|
-
show('ok', `${t('importOk')} (+${added})`)
|
|
136
|
+
show('ok', `${t('importOk')} (+${added})${mergedNote}`)
|
|
122
137
|
}
|
|
123
138
|
} catch (e) {
|
|
124
139
|
show('error', `${t('importFail')}: ${e instanceof Error ? e.message : String(e)}`)
|
|
@@ -160,6 +175,17 @@ export function PresetsTab({ cfg, inv, phases, t, writePatch, writeGlobal, saveA
|
|
|
160
175
|
setCreating(false)
|
|
161
176
|
}
|
|
162
177
|
|
|
178
|
+
// 环境变量黑名单:全局字段,改动即写(与 forceSections 同一通道,不经草稿)。
|
|
179
|
+
const removeBlockEntry = (entry: string): void => {
|
|
180
|
+
writeGlobal('envBlocklist', envBlocklist.filter((e) => e !== entry))
|
|
181
|
+
}
|
|
182
|
+
const addBlockEntry = (): void => {
|
|
183
|
+
const entry = blockInput.trim()
|
|
184
|
+
setBlockInput('')
|
|
185
|
+
if (entry === '' || envBlocklist.includes(entry)) return
|
|
186
|
+
writeGlobal('envBlocklist', [...envBlocklist, entry])
|
|
187
|
+
}
|
|
188
|
+
|
|
163
189
|
return h('div', { style: s.list }, [
|
|
164
190
|
notice ? h('div', { style: notice.kind === 'ok' ? s.noticeOk : s.error }, notice.text) : null,
|
|
165
191
|
h('div', { style: s.injectBox }, [
|
|
@@ -184,6 +210,58 @@ export function PresetsTab({ cfg, inv, phases, t, writePatch, writeGlobal, saveA
|
|
|
184
210
|
h('input', { ref: fileRef, type: 'file', accept: '.json,application/json', style: { display: 'none' }, onChange: onImportFile }),
|
|
185
211
|
]),
|
|
186
212
|
]),
|
|
213
|
+
h('div', { style: s.injectBox }, [
|
|
214
|
+
h('div', { style: s.rowTitle }, t('forceTitle')),
|
|
215
|
+
h('div', { style: s.muted }, t('forceHint')),
|
|
216
|
+
h('div', { style: s.injectRow }, [
|
|
217
|
+
h('label', { style: s.switchWrap }, [
|
|
218
|
+
h('input', {
|
|
219
|
+
type: 'checkbox',
|
|
220
|
+
checked: cfg.forceSections !== false,
|
|
221
|
+
// forceSections 是全局字段,与 presets/activePreset 一样永远写顶层。
|
|
222
|
+
onChange: (e: ChangeEvent<HTMLInputElement>) => writeGlobal('forceSections', e.target.checked),
|
|
223
|
+
}),
|
|
224
|
+
h('span', { style: cfg.forceSections !== false ? s.badgeOk : s.badgeBlocked },
|
|
225
|
+
cfg.forceSections !== false ? t('forceOn') : t('forceOff')),
|
|
226
|
+
]),
|
|
227
|
+
]),
|
|
228
|
+
]),
|
|
229
|
+
h('div', { style: s.injectBox }, [
|
|
230
|
+
h('div', { style: s.rowTitle }, t('envBlockTitle')),
|
|
231
|
+
h('div', { style: s.muted }, t('envBlockHint')),
|
|
232
|
+
h('div', { style: { ...s.toolWrap, marginTop: 6 } }, envBlocklist.map((entry) => h('span', {
|
|
233
|
+
key: entry,
|
|
234
|
+
style: { ...s.toolChip, display: 'inline-flex', alignItems: 'center', gap: 4 },
|
|
235
|
+
}, [
|
|
236
|
+
entry,
|
|
237
|
+
h('span', { style: { cursor: 'pointer' }, title: t('delete'), onClick: () => removeBlockEntry(entry) }, '×'),
|
|
238
|
+
]))),
|
|
239
|
+
envBlocklist.length === 0 ? h('div', { style: s.muted }, t('envBlockEmpty')) : null,
|
|
240
|
+
h('div', { style: { ...s.injectRow, marginTop: 6 } }, [
|
|
241
|
+
h('input', {
|
|
242
|
+
style: { ...s.input, flex: 1 },
|
|
243
|
+
placeholder: t('envBlockAdd'),
|
|
244
|
+
value: blockInput,
|
|
245
|
+
onChange: (e: ChangeEvent<HTMLInputElement>) => setBlockInput(e.target.value),
|
|
246
|
+
}),
|
|
247
|
+
h('button', { style: s.mini, disabled: blockInput.trim() === '', onClick: addBlockEntry }, t('envBlockAddAction')),
|
|
248
|
+
]),
|
|
249
|
+
h('details', { style: { marginTop: 6 } }, [
|
|
250
|
+
h('summary', { style: { ...s.muted, cursor: 'pointer' } }, `${t('envVarsTitle')} (${inv?.variables?.length ?? 0})`),
|
|
251
|
+
h('div', { style: { ...s.muted, marginTop: 4 } }, t('envVarsHint')),
|
|
252
|
+
h('div', { style: { ...s.toolWrap, marginTop: 4 } }, (inv?.variables ?? []).map((name) => h('span', { key: name, style: s.toolChip }, `{{${name}}}`))),
|
|
253
|
+
]),
|
|
254
|
+
]),
|
|
255
|
+
h('div', { style: s.injectBox }, [
|
|
256
|
+
h('div', { style: s.rowTitle }, t('resetTitle')),
|
|
257
|
+
h('div', { style: s.muted }, t('resetHint')),
|
|
258
|
+
h('div', { style: s.injectRow }, [
|
|
259
|
+
h('button', { style: s.mini, onClick: () => {
|
|
260
|
+
// 二次确认:清空不可撤销,必须明确点头才动手。
|
|
261
|
+
if (window.confirm(t('resetConfirm'))) onReset()
|
|
262
|
+
} }, t('resetAction')),
|
|
263
|
+
]),
|
|
264
|
+
]),
|
|
187
265
|
presets.length === 0 ? h('div', { style: s.muted }, t('empty')) : null,
|
|
188
266
|
presets.map((preset) => {
|
|
189
267
|
const active = cfg.activePreset === preset.id
|
|
@@ -137,9 +137,12 @@ export function SectionsTab({ cfg, inv, phases, target, globalSections, ownedSec
|
|
|
137
137
|
// 逐阶段的「段级定制没进最终提示词」警示:每个部分按自己那一阶段的装配结果
|
|
138
138
|
// 判定 —— 有 complete 段整段接管就说接管,否则探测本插件产出的段是否被下游
|
|
139
139
|
// 丢弃(丢弃规则不止 persona complete 一种)。
|
|
140
|
+
// forceSections(默认开)已在装配入口绕开这两类压制,警示不再成立;只有
|
|
141
|
+
// 显式关闭(降级路径)或强制覆盖失效(服务端会恢复 takenOverBy)才显示。
|
|
140
142
|
const partNote = (key: PhaseViewKey): ReactElement | null => {
|
|
141
143
|
const view = phases?.[key]
|
|
142
144
|
if (view === undefined || view === null) return null
|
|
145
|
+
if (cfg.forceSections !== false) return null
|
|
143
146
|
if (view.takenOverBy !== undefined) {
|
|
144
147
|
return h('div', { style: s.noticeWarn }, t('sectionsTakenOver', { name: view.takenOverBy }))
|
|
145
148
|
}
|
|
@@ -463,7 +466,7 @@ export function SectionsTab({ cfg, inv, phases, target, globalSections, ownedSec
|
|
|
463
466
|
h('textarea', { style: s.editInput, value: draft, onChange: (e: ChangeEvent<HTMLTextAreaElement>) => setDraft(e.target.value), rows: 3 }),
|
|
464
467
|
h('div', { style: s.injectRow }, [
|
|
465
468
|
h('button', { style: s.mini, onClick: () => commitReplace(key, row) }, t('save')),
|
|
466
|
-
h('button', { style: s.mini, onClick: () =>
|
|
469
|
+
h('button', { style: s.mini, onClick: () => setDraft('') }, t('clearInput')),
|
|
467
470
|
!row.custom && (row.override || Object.hasOwn(cfg.replace ?? {}, row.name))
|
|
468
471
|
? h('button', { style: s.mini, onClick: () => restoreReplace(key, row) }, t('restore'))
|
|
469
472
|
: null,
|