dsh-custom-mode 1.0.3 → 1.1.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/client.js CHANGED
@@ -76,6 +76,8 @@ try {
76
76
  "btn.moveDown": "下移",
77
77
  "btn.import": "导入提示词",
78
78
  "btn.export": "导出提示词",
79
+ "btn.reset": "恢复出厂提示词",
80
+ "btn.resetHint": "把编辑器里的内容换成出厂模板(新建助手时得到的那一份)。它同样只改草稿,点保存才落盘 —— 误点可以用「重新读取」撤销。",
79
81
  "btn.delete": "删除这个助手",
80
82
  "btn.cancel": "取消",
81
83
  "msg.created": "已创建。现在可以为它写系统提示词。",
@@ -109,7 +111,7 @@ try {
109
111
  "rows.heading": "插件开关",
110
112
  "rows.hint": "逐行控制这个模式挂载哪些插件,和官方插件列表一样按行铺开。没拨过的行保持官方默认(含平台判断);你手动拨了就以你的为准。",
111
113
  "prompt.heading": "系统提示词",
112
- "prompt.hint": "这段文本在每个模型调用前重新读取,所以保存后下一步即生效,且只影响使用这个助手的会话。「导入」把文件读进编辑器(未保存前不写入任何东西);「导出」把当前文本存成 .md 文件。",
114
+ "prompt.hint": "这段文本在每个模型调用前重新读取,所以保存后下一步即生效,且只影响使用这个助手的会话。「导入」把文件读进编辑器(未保存前不写入任何东西);「导出」把当前文本存成 .md 文件;「恢复出厂提示词」把出厂模板填回编辑器(同样要保存才写入)。",
113
115
  "status.enabled": "已启用",
114
116
  "status.disabled": "已停用",
115
117
  "status.changed": "已改",
@@ -206,6 +208,8 @@ try {
206
208
  "btn.moveDown": "Move down",
207
209
  "btn.import": "Import prompt",
208
210
  "btn.export": "Export prompt",
211
+ "btn.reset": "Reset to factory prompt",
212
+ "btn.resetHint": "Puts the shipped template back into the editor (the text a new assistant starts from). Like every other edit it only changes the draft — save to apply, or use Reload to discard.",
209
213
  "btn.delete": "Delete this assistant",
210
214
  "btn.cancel": "Cancel",
211
215
  "msg.created": "Created. Now you can write its system prompt.",
@@ -239,7 +243,7 @@ try {
239
243
  "rows.heading": "Plugin switches",
240
244
  "rows.hint": "Control row by row which plugins this mode mounts, laid out like the official plugin list. Untouched rows keep the official default (platform conditions included); your manual choice wins.",
241
245
  "prompt.heading": "System prompt",
242
- "prompt.hint": "This text is re-read before every model call, so a save applies on the next step and only affects sessions on this assistant. \"Import\" reads a file into the editor (nothing is written until you save); \"Export\" saves the current text as a .md file.",
246
+ "prompt.hint": "This text is re-read before every model call, so a save applies on the next step and only affects sessions on this assistant. \"Import\" reads a file into the editor (nothing is written until you save); \"Export\" saves the current text as a .md file; \"Reset to factory prompt\" puts the shipped template back into the editor (also saved only when you save).",
243
247
  "status.enabled": "Enabled",
244
248
  "status.disabled": "Disabled",
245
249
  "status.changed": "changed",
@@ -580,6 +584,9 @@ try {
580
584
  prompt: state.prompt,
581
585
  name: typeof state.name === "string" ? state.name : "",
582
586
  description: typeof state.description === "string" ? state.description : "",
587
+ // 「恢复出厂提示词」要用它。它不是草稿的一部分,`sameDraft` 不比较它 ——
588
+ // 漏掉这一行按钮会一直置灰(实测:真点了没反应,浏览器验收抓到)。
589
+ factoryPrompt: typeof state.factoryPrompt === "string" ? state.factoryPrompt : null,
583
590
  }
584
591
  }
585
592
 
@@ -896,6 +903,18 @@ try {
896
903
  }
897
904
 
898
905
  /** Download the prompt being edited (client-side only — the host is not involved). */
906
+ /**
907
+ * 把编辑器内容换回出厂提示词。
908
+ *
909
+ * 只改**草稿**,不写盘:这样"一键回到官方"既立刻可见,又可被「重新读取」撤销 ——
910
+ * 与页面其余部分一样,一切改动都要点保存才落盘。
911
+ */
912
+ const resetPrompt = () => {
913
+ const factory = typeof draft.factoryPrompt === "string" ? draft.factoryPrompt : ""
914
+ if (factory === "") return
915
+ update({ prompt: factory })
916
+ }
917
+
899
918
  const exportPrompt = () => {
900
919
  try {
901
920
  const blob = new Blob([draft.prompt], { type: "text/markdown;charset=utf-8" })
@@ -1259,6 +1278,21 @@ try {
1259
1278
  },
1260
1279
  t("btn.import"),
1261
1280
  ),
1281
+ react.createElement(
1282
+ A.Button,
1283
+ {
1284
+ variant: "outline",
1285
+ size: "sm",
1286
+ disabled:
1287
+ busy ||
1288
+ typeof draft.factoryPrompt !== "string" ||
1289
+ draft.factoryPrompt === "" ||
1290
+ draft.prompt === draft.factoryPrompt,
1291
+ onClick: resetPrompt,
1292
+ title: t("btn.resetHint"),
1293
+ },
1294
+ t("btn.reset"),
1295
+ ),
1262
1296
  // The picker itself is invisible; the button above is the affordance. The
1263
1297
  // file is read locally and lands in the editor, never straight on disk.
1264
1298
  react.createElement("input", {
package/index.mjs CHANGED
@@ -37,6 +37,7 @@
37
37
  * rows the user changed by diffing against the same shipped base mode.
38
38
  */
39
39
 
40
+ import { randomBytes } from 'node:crypto'
40
41
  import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
41
42
  import { dirname, join } from 'node:path'
42
43
  import { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, PRESET_DIR } from './paths.mjs'
@@ -79,6 +80,12 @@ const METHODS = {
79
80
  [REORDER_PATH]: ['POST'],
80
81
  }
81
82
 
83
+ /** 只告警一次:避免每个请求都刷同一行日志。 */
84
+ let warnedRosterShape = false
85
+
86
+ /** 应用层请求体上限;平台的 buffered cap 是第一道,这个是我们自己的兜底(见 handler 里的注释)。 */
87
+ const MAX_BODY_BYTES = 4 * 1024 * 1024
88
+
82
89
  /** Longest display name / description the page accepts, so one paste cannot bloat every picker. */
83
90
  const MAX_NAME = 80
84
91
  const MAX_DESCRIPTION = 400
@@ -182,7 +189,26 @@ function unknownAssistant(id) {
182
189
  * @param {string} id - the assistant's preset id.
183
190
  * @returns {object} the payload the browser half reads.
184
191
  */
185
- export function readState(rows, id) {
192
+ /**
193
+ * 出厂提示词:新建助手时拿到的那一份(打包在包里的 preset 模板)。
194
+ *
195
+ * 设置页用它实现「恢复出厂提示词」—— 在此之前,用户改坏了提示词只能把整个助手删掉重建。
196
+ * 读不到就返回 null(页面会把那个按钮置灰,而不是给一个会写坏文件的按钮)。
197
+ */
198
+ let packagedPromptCache
199
+ function packagedPrompt() {
200
+ if (packagedPromptCache === undefined) {
201
+ try {
202
+ packagedPromptCache = readFileSync(join(packagedPresetDir(), 'prompt.md'), 'utf8')
203
+ } catch (error) {
204
+ packagedPromptCache = null
205
+ console.error('custom-mode: 读不到出厂提示词模板(' + describe(error) + '),设置页将不提供「恢复出厂提示词」。')
206
+ }
207
+ }
208
+ return packagedPromptCache
209
+ }
210
+
211
+ export function readState(rows, id, options = {}) {
186
212
  const directory = assistantDir(rows, id)
187
213
  if (directory === undefined) return unknownAssistant(id)
188
214
  const composition = compositionFile(directory)
@@ -205,6 +231,9 @@ export function readState(rows, id) {
205
231
  promptPath: promptFile(directory),
206
232
  compositionPath: composition,
207
233
  presetMetaPath: presetMetaPath(directory),
234
+ // 回退用的出厂文本。它不是"当前值",页面只把它填进编辑器,保存前不落盘 —— 所以
235
+ // 一次误点不会破坏任何东西(重新读取即可丢弃)。
236
+ factoryPrompt: typeof options.factoryPrompt === 'string' ? options.factoryPrompt : null,
208
237
  }
209
238
  }
210
239
 
@@ -490,10 +519,14 @@ export function apply(ctx) {
490
519
  // Compatibility guard: this plugin reads host APIs that a future DSH release could
491
520
  // reshape. Check them once and say so plainly, instead of letting every request
492
521
  // fail with an opaque 500.
493
- const missing = []
494
- if (typeof scope.agentPresets?.list !== 'function') missing.push('agentPresets.list()')
495
- if (typeof scope.agentPresets?.remove !== 'function') missing.push('agentPresets.remove()')
496
- if (typeof scope.connection?.fetch?.register !== 'function') missing.push('connection.fetch.register()')
522
+ // 数据表形式:以后新增一处耦合点,只要在这里加一行 —— README 的耦合点清单与本表同源,
523
+ // 让"上游改了 API 形状"在启动日志里就能看见,而不是等用户报"设置页白屏"。
524
+ const REQUIRED_APIS = [
525
+ ['agentPresets.list()', () => typeof scope.agentPresets?.list === 'function'],
526
+ ['agentPresets.remove()', () => typeof scope.agentPresets?.remove === 'function'],
527
+ ['connection.fetch.register()', () => typeof scope.connection?.fetch?.register === 'function'],
528
+ ]
529
+ const missing = REQUIRED_APIS.filter(([, probe]) => !probe()).map(([name]) => name)
497
530
  if (missing.length > 0) {
498
531
  console.error(
499
532
  'custom-mode: 当前 DSH 版本缺少所需 API:' +
@@ -527,7 +560,16 @@ export function apply(ctx) {
527
560
  /** The roster, or an empty list — discovery itself reports broken rows rather than throwing. */
528
561
  const roster = async () => {
529
562
  const rows = await scope.agentPresets.list()
530
- return Array.isArray(rows) ? rows : []
563
+ if (!Array.isArray(rows)) {
564
+ // 形状变了:静默返回空列表会让页面显示"一个助手都没有",比报错更难查(曾经就因为
565
+ // 缺少这种告警,一个 API 形状变化以"设置页白屏"的形式出现)。
566
+ if (!warnedRosterShape) {
567
+ warnedRosterShape = true
568
+ console.error('custom-mode: agentPresets.list() 没有返回数组(DSH 版本不匹配?),助手列表将为空。')
569
+ }
570
+ return []
571
+ }
572
+ return rows
531
573
  }
532
574
 
533
575
  /**
@@ -556,7 +598,16 @@ export function apply(ctx) {
556
598
  }
557
599
  if (request.method === 'GET' && pathname === STATE_PATH) {
558
600
  await ensureShipped()
559
- return json(readState(await roster(), url.searchParams.get('id') ?? ''))
601
+ return json(readState(await roster(), url.searchParams.get('id') ?? '', { factoryPrompt: packagedPrompt() }))
602
+ }
603
+
604
+ // Backstop on request size. `requestBody: 'buffered'` means the platform applies its own
605
+ // JSON cap, but that cap is the host's configuration, not a contract — measured on Windows,
606
+ // a 5 MB body reached us happily. This keeps one authenticated request from making us buffer
607
+ // an unbounded amount; the platform's cap remains the primary guard.
608
+ const declared = Number(request.headers.get('content-length') ?? '')
609
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
610
+ return json({ ok: false, error: `请求体过大(上限 ${String(MAX_BODY_BYTES)} 字节)` }, 413)
560
611
  }
561
612
 
562
613
  // Everything below writes, so the body is read and parsed exactly once.
@@ -611,7 +662,10 @@ export function apply(ctx) {
611
662
  * prompt,而这个文件正是"用户的提示词"。
612
663
  */
613
664
  function writeAtomic(file, text) {
614
- const temporary = `${file}.tmp-${String(process.pid)}`
665
+ // 临时名必须**每个请求唯一**:只带 pid 时,同一进程内两个并发保存会争同一个临时名,
666
+ // Windows 上两个 rename 指向同一目标会以 EPERM 失败(实测:10 并发保存 2 例 400),
667
+ // 而 POSIX 上 rename 原子覆盖、静默地后写胜出 —— 也就是说这个缺陷只在 Windows 显现。
668
+ const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
615
669
  writeFileSync(temporary, text, 'utf8')
616
670
  renameSync(temporary, file)
617
671
  }
package/locales.mjs CHANGED
@@ -36,6 +36,8 @@ export const zh = {
36
36
  'btn.moveDown': '下移',
37
37
  'btn.import': '导入提示词',
38
38
  'btn.export': '导出提示词',
39
+ 'btn.reset': '恢复出厂提示词',
40
+ 'btn.resetHint': '把编辑器里的内容换成出厂模板(新建助手时得到的那一份)。它同样只改草稿,点保存才落盘 —— 误点可以用「重新读取」撤销。',
39
41
  'btn.delete': '删除这个助手',
40
42
  'btn.cancel': '取消',
41
43
  'msg.created': '已创建。现在可以为它写系统提示词。',
@@ -74,7 +76,7 @@ export const zh = {
74
76
 
75
77
  'prompt.heading': '系统提示词',
76
78
  'prompt.hint':
77
- '这段文本在每个模型调用前重新读取,所以保存后下一步即生效,且只影响使用这个助手的会话。「导入」把文件读进编辑器(未保存前不写入任何东西);「导出」把当前文本存成 .md 文件。',
79
+ '这段文本在每个模型调用前重新读取,所以保存后下一步即生效,且只影响使用这个助手的会话。「导入」把文件读进编辑器(未保存前不写入任何东西);「导出」把当前文本存成 .md 文件;「恢复出厂提示词」把出厂模板填回编辑器(同样要保存才写入)。',
78
80
 
79
81
  'status.enabled': '已启用',
80
82
  'status.disabled': '已停用',
@@ -183,6 +185,8 @@ export const en = {
183
185
  'btn.moveDown': 'Move down',
184
186
  'btn.import': 'Import prompt',
185
187
  'btn.export': 'Export prompt',
188
+ 'btn.reset': 'Reset to factory prompt',
189
+ 'btn.resetHint': 'Puts the shipped template back into the editor (the text a new assistant starts from). Like every other edit it only changes the draft — save to apply, or use Reload to discard.',
186
190
  'btn.delete': 'Delete this assistant',
187
191
  'btn.cancel': 'Cancel',
188
192
  'msg.created': 'Created. Now you can write its system prompt.',
@@ -224,7 +228,7 @@ export const en = {
224
228
 
225
229
  'prompt.heading': 'System prompt',
226
230
  'prompt.hint':
227
- 'This text is re-read before every model call, so a save applies on the next step and only affects sessions on this assistant. "Import" reads a file into the editor (nothing is written until you save); "Export" saves the current text as a .md file.',
231
+ 'This text is re-read before every model call, so a save applies on the next step and only affects sessions on this assistant. "Import" reads a file into the editor (nothing is written until you save); "Export" saves the current text as a .md file; "Reset to factory prompt" puts the shipped template back into the editor (also saved only when you save).',
228
232
 
229
233
  'status.enabled': 'Enabled',
230
234
  'status.disabled': 'Disabled',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-custom-mode",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "Custom modes and custom prompts for DeepSeek Harness (dsh): edit a mode's system prompt on the settings page (it takes effect on the next model step), choose its base mode, switch plugins row by row, and keep several assistants side by side.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,7 +25,8 @@
25
25
  * needs no isolate realm.
26
26
  */
27
27
 
28
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
28
+ import { randomBytes } from 'node:crypto'
29
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
29
30
  import { dirname } from 'node:path'
30
31
  import { fileURLToPath } from 'node:url'
31
32
 
@@ -188,7 +189,10 @@ function makeDefinition(modeName) {
188
189
  if (verdict.ok !== true) return verdict.error
189
190
  try {
190
191
  mkdirSync(dirname(PROMPT_PATH), { recursive: true })
191
- writeFileSync(PROMPT_PATH, args.text, 'utf8')
192
+ // 与设置页同一条纪律:临时文件 + rename。读取器(prompt-reader.mjs)按 mtime+size 缓存,
193
+ // 非原子写会让它有机会读到写了一半的提示词 —— 设置页早已改用原子写,这里原先还是
194
+ // 直接 writeFileSync,属于"自我标准不一致"。
195
+ writeAtomic(PROMPT_PATH, args.text)
192
196
  return '已写入 ' + PROMPT_PATH + '(' + String(args.text.length) + ' 字符)。本会话下一步模型调用即使用新提示词。'
193
197
  } catch (error) {
194
198
  return '写入失败:' + String((error && error.message) || error)
@@ -204,3 +208,15 @@ export function apply(ctx, config = {}) {
204
208
  const definition = makeDefinition(resolveModeName(config))
205
209
  ctx.effect(() => ctx.tools.register(definition), 'custom-prompt.tool')
206
210
  }
211
+
212
+ /**
213
+ * 写文件:同目录临时文件 + rename(rename 在同一文件系统内原子)。
214
+ *
215
+ * 临时名带 pid 与随机后缀:同一进程内的并发写必须各用各的临时名,否则 Windows 上两个
216
+ * rename 指向同一目标会以 EPERM 失败。
217
+ */
218
+ function writeAtomic(file, text) {
219
+ const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
220
+ writeFileSync(temporary, text, 'utf8')
221
+ renameSync(temporary, file)
222
+ }