dsh-custom-mode 1.3.0 → 1.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/assistants.mjs CHANGED
@@ -334,14 +334,14 @@ export function reorderAssistant(rows, input, write = writePresetMeta) {
334
334
  const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
335
335
  const direction = input !== null && typeof input === 'object' ? input.direction : undefined
336
336
  if (direction !== 'up' && direction !== 'down') {
337
- return { ok: false, error: '未知的排序方向:' + String(direction) }
337
+ return { ok: false, code: 'badDirection', params: { direction: String(direction) }, error: '未知的排序方向:' + String(direction) }
338
338
  }
339
339
  const list = assistantsFromRoster(rows)
340
340
  const index = list.findIndex((item) => item.id === id)
341
- if (index === -1) return { ok: false, error: '找不到助手「' + id + '」。' }
341
+ if (index === -1) return { ok: false, code: 'unknownAssistant', params: { id }, error: '找不到助手「' + id + '」。' }
342
342
  const target = direction === 'up' ? index - 1 : index + 1
343
- if (target < 0) return { ok: false, error: '「' + (list[index].name || id) + '」已经在最前面。' }
344
- if (target >= list.length) return { ok: false, error: '「' + (list[index].name || id) + '」已经在最后面。' }
343
+ if (target < 0) return { ok: false, code: 'alreadyFirst', params: { name: list[index].name || id }, error: '「' + (list[index].name || id) + '」已经在最前面。' }
344
+ if (target >= list.length) return { ok: false, code: 'alreadyLast', params: { name: list[index].name || id }, error: '「' + (list[index].name || id) + '」已经在最后面。' }
345
345
 
346
346
  const next = [...list]
347
347
  const [moved] = next.splice(index, 1)
@@ -357,6 +357,7 @@ export function reorderAssistant(rows, input, write = writePresetMeta) {
357
357
  ok: true,
358
358
  id,
359
359
  order: next.map((item) => item.id),
360
+ code: 'reordered',
360
361
  note: '顺序已保存:新建会话时的模式选择器按这个顺序排列。',
361
362
  }
362
363
  }
package/client.js CHANGED
@@ -92,6 +92,31 @@ try {
92
92
  "msg.reorderFailed": "调整顺序失败",
93
93
  "msg.imported": "已导入到编辑器(还没有保存):检查后点「保存」。",
94
94
  "msg.importFailed": "导入失败",
95
+ "api.badDirection": "未知的排序方向:{direction}",
96
+ "api.unknownAssistant": "找不到助手「{id}」:这一页只管理本工具创建的助手(目录里有 prompt.md,且组成文件用 prompt-reader.mjs 注入身份)。",
97
+ "api.alreadyFirst": "「{name}」已经在最前面。",
98
+ "api.alreadyLast": "「{name}」已经在最后面。",
99
+ "api.saved": "已保存({name},基础模式 {mode})。新建会话即生效,当前会话保持原配置。",
100
+ "api.created": "已创建「{name}」。现在可以为它写系统提示词。",
101
+ "api.duplicated": "已复制自「{from}」。两份从此各改各的。",
102
+ "api.deleted": "已删除「{name}」。正在使用它的会话不受影响;新建会话时它不再出现。",
103
+ "api.reordered": "顺序已保存:新建会话时的模式选择器按这个顺序排列。",
104
+ "api.nameRequired": "请先给新助手起个名字。",
105
+ "api.nameTooLong": "名字太长了(上限 {max} 个字符)。",
106
+ "api.descriptionTooLong": "描述太长了(上限 {max} 个字符)。",
107
+ "api.promptEmpty": "保存被拒绝:系统提示词为空。留空不会清空身份,读取器会沿用上一版。",
108
+ "api.badMode": "未知的基础模式:{mode}",
109
+ "api.dirExists": "目录已存在,请换一个名字:{path}",
110
+ "api.compositionMissing": "找不到组成文件:{path}",
111
+ "api.writeFailed": "写入失败:{detail}",
112
+ "api.promptWriteFailed": "写入提示词失败:{detail}",
113
+ "api.renderFailed": "生成组成文件失败:{detail}",
114
+ "api.selfCheckFailed": "生成结果自检失败,已放弃写入:{detail}",
115
+ "api.promptReadFailed": "读取提示词失败:{detail}",
116
+ "api.noRemoveApi": "当前 DSH 版本没有 agentPresets.remove(),无法删除。",
117
+ "api.deleteFailed": "删除失败:{detail}",
118
+ "api.versionMissing": "找不到这个版本(历史可能已被上限裁剪)。",
119
+ "api.badJson": "请求体不是合法 JSON",
95
120
  "warn.personaOffWithPrompt": "「身份(系统提示词)」这一行是关的,所以 prompt.md 不会被注入 —— 你写的提示词现在不起作用。要么打开这一行,要么清空提示词。",
96
121
  "warn.toolOff": "「custom_prompt 工具」这一行是关的:会话里无法让 agent 改提示词,只能在本页改。",
97
122
  "warn.noDescription": "没有描述:新建会话的模式选择器里会显示成「暂无描述」。",
@@ -235,6 +260,31 @@ try {
235
260
  "msg.reorderFailed": "Could not reorder",
236
261
  "msg.imported": "Imported into the editor (not saved yet) — review it, then click Save.",
237
262
  "msg.importFailed": "Import failed",
263
+ "api.badDirection": "Unknown reorder direction: {direction}",
264
+ "api.unknownAssistant": "No assistant 「{id}」: this page only manages the assistants it created (a directory with prompt.md whose composition injects the identity through prompt-reader.mjs).",
265
+ "api.alreadyFirst": "「{name}」 is already first.",
266
+ "api.alreadyLast": "「{name}」 is already last.",
267
+ "api.saved": "Saved ({name}, base mode {mode}). A new session picks it up; the current one keeps its configuration.",
268
+ "api.created": "Created 「{name}」. You can write its system prompt now.",
269
+ "api.duplicated": "Copied from 「{from}」. The two are independent from now on.",
270
+ "api.deleted": "Deleted 「{name}」. Sessions already using it keep running; it no longer appears for new sessions.",
271
+ "api.reordered": "Order saved: the new-session mode picker follows it.",
272
+ "api.nameRequired": "Give the new assistant a name first.",
273
+ "api.nameTooLong": "That name is too long (limit {max} characters).",
274
+ "api.descriptionTooLong": "That description is too long (limit {max} characters).",
275
+ "api.promptEmpty": "Save rejected: the system prompt is empty. Emptying it would not clear the identity — the reader keeps the last good text.",
276
+ "api.badMode": "Unknown base mode: {mode}",
277
+ "api.dirExists": "That directory already exists — pick another name: {path}",
278
+ "api.compositionMissing": "Composition file not found: {path}",
279
+ "api.writeFailed": "Write failed: {detail}",
280
+ "api.promptWriteFailed": "Writing the prompt failed: {detail}",
281
+ "api.renderFailed": "Rendering the composition failed: {detail}",
282
+ "api.selfCheckFailed": "The generated composition failed its self-check, so nothing was written: {detail}",
283
+ "api.promptReadFailed": "Reading the prompt failed: {detail}",
284
+ "api.noRemoveApi": "This DSH build has no agentPresets.remove(), so deleting is unavailable.",
285
+ "api.deleteFailed": "Delete failed: {detail}",
286
+ "api.versionMissing": "That version is gone (the history is capped).",
287
+ "api.badJson": "The request body is not valid JSON",
238
288
  "warn.personaOffWithPrompt": "The \"Identity (system prompt)\" row is off, so prompt.md is never injected — the prompt you wrote has no effect. Turn the row on, or clear the prompt.",
239
289
  "warn.toolOff": "The \"custom_prompt tool\" row is off: the agent cannot change the prompt from inside a session, only this page can.",
240
290
  "warn.noDescription": "No description: the new-session mode picker will show it as \"no description yet\".",
@@ -720,6 +770,32 @@ try {
720
770
  */
721
771
  const t = (key, fallback) => translate(key, fallback, shellT)
722
772
 
773
+ /** 把 `{name}` 这类占位符换成 params 里的值(缺失就原样留着,便于发现漏参)。 */
774
+ const fillPlaceholders = (template, params) =>
775
+ template.replace(/\{(\w+)\}/g, (match, key) =>
776
+ params !== null && typeof params === "object" && params[key] !== undefined ? String(params[key]) : match,
777
+ )
778
+
779
+ /**
780
+ * 服务端结果的**本地化渲染**。
781
+ *
782
+ * 宿主仍然回中文的 `note`/`error`(那是 HTTP API 的兼容面),但页面优先用自己的词典按 `code`
783
+ * 渲染 —— 否则英文界面会在出错那一刻掉回中文(外部审阅点名的"硬伤")。服务端若带来了页面无从
784
+ * 知道的细节(路径、底层错误),放在 `params` 里填进模板。没有 `code` 时退回宿主文案,兼容旧宿主。
785
+ */
786
+ const apiText = (result, fallbackKey) => {
787
+ const code = result !== null && typeof result === "object" && typeof result.code === "string" ? result.code : ""
788
+ if (code !== "") {
789
+ const template = translate("api." + code, "", shellT)
790
+ if (template !== "") return fillPlaceholders(template, result.params)
791
+ }
792
+ if (result !== null && typeof result === "object") {
793
+ if (typeof result.error === "string" && result.error !== "") return result.error
794
+ if (typeof result.note === "string" && result.note !== "") return result.note
795
+ }
796
+ return t(fallbackKey)
797
+ }
798
+
723
799
  const [list, setList] = react.useState(null)
724
800
  const [selected, setSelected] = react.useState("")
725
801
  /** id → { payload, saved, value }: loaded state, baseline and live draft. */
@@ -764,7 +840,7 @@ try {
764
840
  const result = await fetchState(id)
765
841
  if (result === null || result === undefined || result.ok !== true) {
766
842
  setFailed(true)
767
- setStatus((result && result.error) || t("msg.readFailed"))
843
+ setStatus(apiText(result, "msg.readFailed"))
768
844
  return
769
845
  }
770
846
  const fresh = draftOf(result)
@@ -781,7 +857,7 @@ try {
781
857
  const result = await fetchList()
782
858
  if (result === null || result === undefined || result.ok !== true) {
783
859
  setFailed(true)
784
- setStatus((result && result.error) || t("msg.readFailed"))
860
+ setStatus(apiText(result, "msg.readFailed"))
785
861
  return
786
862
  }
787
863
  const assistants = Array.isArray(result.assistants) ? result.assistants : []
@@ -895,11 +971,11 @@ try {
895
971
  if (result !== null && result !== undefined && result.ok === true) {
896
972
  setNewName("")
897
973
  setFailed(false)
898
- setStatus(result.note || (from === undefined ? t("msg.created") : t("msg.duplicated")))
974
+ setStatus(apiText(result, from === undefined ? "msg.created" : "msg.duplicated"))
899
975
  await reload(result.id)
900
976
  } else {
901
977
  setFailed(true)
902
- setStatus((result && result.error) || t("msg.createFailed"))
978
+ setStatus(apiText(result, "msg.createFailed"))
903
979
  }
904
980
  } catch (error) {
905
981
  setFailed(true)
@@ -923,11 +999,11 @@ try {
923
999
  const result = await postJson(ROUTES.reorder, { id: draft.id, direction })
924
1000
  if (result !== null && result !== undefined && result.ok === true) {
925
1001
  setFailed(false)
926
- setStatus(result.note || t("msg.reordered"))
1002
+ setStatus(apiText(result, "msg.reordered"))
927
1003
  await reload(draft.id)
928
1004
  } else {
929
1005
  setFailed(true)
930
- setStatus((result && result.error) || t("msg.reorderFailed"))
1006
+ setStatus(apiText(result, "msg.reorderFailed"))
931
1007
  }
932
1008
  } catch (error) {
933
1009
  setFailed(true)
@@ -1032,7 +1108,7 @@ try {
1032
1108
  setDeleteOpen(false)
1033
1109
  setAcknowledged(false)
1034
1110
  setFailed(false)
1035
- setStatus(result.note || t("msg.deleted"))
1111
+ setStatus(apiText(result, "msg.deleted"))
1036
1112
  setEntries((previous) => {
1037
1113
  const next = { ...previous }
1038
1114
  delete next[id]
@@ -1041,7 +1117,7 @@ try {
1041
1117
  await reload("")
1042
1118
  } else {
1043
1119
  setFailed(true)
1044
- setStatus((result && result.error) || t("msg.deleteFailed"))
1120
+ setStatus(apiText(result, "msg.deleteFailed"))
1045
1121
  }
1046
1122
  } catch (error) {
1047
1123
  setFailed(true)
@@ -1087,7 +1163,7 @@ try {
1087
1163
  })
1088
1164
  if (result !== null && result !== undefined && result.ok === true) {
1089
1165
  setFailed(false)
1090
- setStatus(result.note || t("msg.saved"))
1166
+ setStatus(apiText(result, "msg.saved"))
1091
1167
  // The server normalises what it stores (trimmed name, recomputed overrides),
1092
1168
  // so the just-saved draft is replaced by what it actually wrote. Without this
1093
1169
  // the page would show 「已保存」 and 「未保存」 at the same time.
@@ -1104,7 +1180,7 @@ try {
1104
1180
  }
1105
1181
  } else {
1106
1182
  setFailed(true)
1107
- setStatus((result && result.error) || t("msg.saveFailed"))
1183
+ setStatus(apiText(result, "msg.saveFailed"))
1108
1184
  }
1109
1185
  } catch (error) {
1110
1186
  setFailed(true)
package/index.mjs CHANGED
@@ -38,7 +38,7 @@
38
38
  */
39
39
 
40
40
  import { randomBytes } from 'node:crypto'
41
- import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
41
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
42
42
  import { dirname, join } from 'node:path'
43
43
  import { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, PRESET_DIR } from './paths.mjs'
44
44
  import {
@@ -166,7 +166,7 @@ export function readPrompt(directory) {
166
166
  try {
167
167
  return { ok: true, path, text: readFileSync(path, 'utf8') }
168
168
  } catch (error) {
169
- return { ok: false, error: '读取提示词失败:' + describe(error) }
169
+ return { ok: false, code: 'promptReadFailed', params: { detail: describe(error) }, error: '读取提示词失败:' + describe(error) }
170
170
  }
171
171
  }
172
172
 
@@ -246,7 +246,7 @@ export function readState(rows, id, options = {}) {
246
246
  const directory = assistantDir(rows, id)
247
247
  if (directory === undefined) return unknownAssistant(id)
248
248
  const composition = compositionFile(directory)
249
- if (!existsSync(composition)) return { ok: false, error: '找不到组成文件:' + composition }
249
+ if (!existsSync(composition)) return { ok: false, code: 'compositionMissing', params: { path: composition }, error: '找不到组成文件:' + composition }
250
250
  const text = readFileSync(composition, 'utf8')
251
251
  const mode = modeOf(text)
252
252
  const prompt = readPrompt(directory)
@@ -304,11 +304,11 @@ export function saveState(rows, input) {
304
304
 
305
305
  const mode = input !== null && typeof input === 'object' && typeof input.mode === 'string' ? input.mode : ''
306
306
  if (!BASE_MODES.some((entry) => entry.id === mode)) {
307
- return { ok: false, error: '未知的基础模式:' + mode }
307
+ return { ok: false, code: 'badMode', params: { mode }, error: '未知的基础模式:' + mode }
308
308
  }
309
309
  const prompt = input !== null && typeof input === 'object' && typeof input.prompt === 'string' ? input.prompt : ''
310
310
  if (prompt.trim() === '') {
311
- return { ok: false, error: '保存被拒绝:系统提示词为空。留空不会清空身份,读取器会沿用上一版。' }
311
+ return { ok: false, code: 'promptEmpty', error: '保存被拒绝:系统提示词为空。留空不会清空身份,读取器会沿用上一版。' }
312
312
  }
313
313
  const verdict = checkPromptText(prompt)
314
314
  if (verdict.ok !== true) return { ok: false, error: verdict.error }
@@ -316,13 +316,13 @@ export function saveState(rows, input) {
316
316
  const rawName = input !== null && typeof input === 'object' && typeof input.name === 'string' ? input.name : ''
317
317
  const name = rawName.replace(/\r?\n/g, ' ').trim()
318
318
  if (name.length > MAX_NAME) {
319
- return { ok: false, error: '保存被拒绝:模式名称过长(上限 ' + String(MAX_NAME) + ' 个字符)。' }
319
+ return { ok: false, code: 'nameTooLong', params: { max: MAX_NAME }, error: '保存被拒绝:模式名称过长(上限 ' + String(MAX_NAME) + ' 个字符)。' }
320
320
  }
321
321
  const rawDescription =
322
322
  input !== null && typeof input === 'object' && typeof input.description === 'string' ? input.description : ''
323
323
  const description = rawDescription.replace(/\r?\n/g, ' ').trim()
324
324
  if (description.length > MAX_DESCRIPTION) {
325
- return { ok: false, error: '保存被拒绝:模式描述过长(上限 ' + String(MAX_DESCRIPTION) + ' 个字符)。' }
325
+ return { ok: false, code: 'descriptionTooLong', params: { max: MAX_DESCRIPTION }, error: '保存被拒绝:模式描述过长(上限 ' + String(MAX_DESCRIPTION) + ' 个字符)。' }
326
326
  }
327
327
 
328
328
  const overrides = new Map()
@@ -337,7 +337,7 @@ export function saveState(rows, input) {
337
337
  try {
338
338
  composition = renderComposition(mode, overrides, { modeName: name, assistantId: id })
339
339
  } catch (error) {
340
- return { ok: false, error: '生成组成文件失败:' + describe(error) }
340
+ return { ok: false, code: 'renderFailed', params: { detail: describe(error) }, error: '生成组成文件失败:' + describe(error) }
341
341
  }
342
342
 
343
343
  // Self-check our own output before publishing it: a composition that lost its
@@ -346,7 +346,7 @@ export function saveState(rows, input) {
346
346
  if (collectRows(composition).length === 0) throw new Error('生成的组成文件没有任何行')
347
347
  readBaseComposition(mode)
348
348
  } catch (error) {
349
- return { ok: false, error: '生成结果自检失败,已放弃写入:' + describe(error) }
349
+ return { ok: false, code: 'selfCheckFailed', params: { detail: describe(error) }, error: '生成结果自检失败,已放弃写入:' + describe(error) }
350
350
  }
351
351
 
352
352
  try {
@@ -356,7 +356,7 @@ export function saveState(rows, input) {
356
356
  // 另外两条由 readState 对比补记(见 journal.mjs 的单写者说明)。
357
357
  recordPrompt(directory, prompt, HISTORY_SOURCE.settings)
358
358
  } catch (error) {
359
- return { ok: false, error: '写入失败:' + describe(error) }
359
+ return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
360
360
  }
361
361
 
362
362
  // A name the user cleared is left alone rather than written as an empty scalar:
@@ -370,6 +370,8 @@ export function saveState(rows, input) {
370
370
  ok: true,
371
371
  id,
372
372
  mode,
373
+ code: 'saved',
374
+ params: { name: name === '' ? id : name, mode },
373
375
  note: '已保存(' + (name === '' ? id : name) + ',基础模式 ' + mode + ')。新建会话即生效,当前会话保持原配置。',
374
376
  }
375
377
  }
@@ -390,15 +392,15 @@ export function saveState(rows, input) {
390
392
  export function createAssistant(rows, input, templateDir = packagedPresetDir()) {
391
393
  const rawName = input !== null && typeof input === 'object' && typeof input.name === 'string' ? input.name : ''
392
394
  const name = rawName.replace(/\r?\n/g, ' ').trim()
393
- if (name === '') return { ok: false, error: '请先给新助手起个名字。' }
395
+ if (name === '') return { ok: false, code: 'nameRequired', error: '请先给新助手起个名字。' }
394
396
  if (name.length > MAX_NAME) {
395
- return { ok: false, error: '名字太长了(上限 ' + String(MAX_NAME) + ' 个字符)。' }
397
+ return { ok: false, code: 'nameTooLong', params: { max: MAX_NAME }, error: '名字太长了(上限 ' + String(MAX_NAME) + ' 个字符)。' }
396
398
  }
397
399
  const rawDescription =
398
400
  input !== null && typeof input === 'object' && typeof input.description === 'string' ? input.description : ''
399
401
  const description = rawDescription.replace(/\r?\n/g, ' ').trim()
400
402
  if (description.length > MAX_DESCRIPTION) {
401
- return { ok: false, error: '描述太长了(上限 ' + String(MAX_DESCRIPTION) + ' 个字符)。' }
403
+ return { ok: false, code: 'descriptionTooLong', params: { max: MAX_DESCRIPTION }, error: '描述太长了(上限 ' + String(MAX_DESCRIPTION) + ' 个字符)。' }
402
404
  }
403
405
 
404
406
  const root = userPresetRoot(rows)
@@ -410,7 +412,7 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
410
412
  // The roster can lag a directory it skipped (a hand-made one with no
411
413
  // composition). Refuse the name rather than half-own that directory.
412
414
  if (existsSync(join(root, id))) {
413
- return { ok: false, error: '目录已存在,请换一个名字:' + join(root, id) }
415
+ return { ok: false, code: 'dirExists', params: { path: join(root, id) }, error: '目录已存在,请换一个名字:' + join(root, id) }
414
416
  }
415
417
 
416
418
  // A "duplicate" carries the source's prompt, base mode and row switches into a
@@ -424,7 +426,7 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
424
426
  const fromDir = assistantDir(rows, from)
425
427
  if (fromDir === undefined) return unknownAssistant(from)
426
428
  const fromComposition = compositionFile(fromDir)
427
- if (!existsSync(fromComposition)) return { ok: false, error: '找不到组成文件:' + fromComposition }
429
+ if (!existsSync(fromComposition)) return { ok: false, code: 'compositionMissing', params: { path: fromComposition }, error: '找不到组成文件:' + fromComposition }
428
430
  const text = readFileSync(fromComposition, 'utf8')
429
431
  const sourceMode = modeOf(text)
430
432
  const sourcePrompt = readPrompt(fromDir)
@@ -453,7 +455,7 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
453
455
  try {
454
456
  writeAtomic(promptFile(created.dir), source.prompt)
455
457
  } catch (error) {
456
- return { ok: false, error: '写入提示词失败:' + describe(error) }
458
+ return { ok: false, code: 'promptWriteFailed', params: { detail: describe(error) }, error: '写入提示词失败:' + describe(error) }
457
459
  }
458
460
  }
459
461
  const metaResult = writePresetMeta(name, description === '' && source !== null ? source.description : description, created.dir)
@@ -463,6 +465,9 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
463
465
  ok: true,
464
466
  id,
465
467
  name,
468
+ code: source === null ? 'created' : 'duplicated',
469
+ // D5:文案里用**显示名**而不是内部目录 id。
470
+ params: source === null ? { name } : { name, from: source === null ? '' : from },
466
471
  note: source === null
467
472
  ? '已创建「' + name + '」。它的系统提示词现在是模板默认文本;写好后新建会话即可选择它。'
468
473
  : '已复制出「' + name + '」:提示词、基础模式与插件开关都来自「' + from + '」,之后各改各的,互不影响。',
@@ -485,14 +490,14 @@ export async function deleteAssistant(rows, input, agentPresets) {
485
490
  const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
486
491
  if (assistantDir(rows, id) === undefined) return unknownAssistant(id)
487
492
  if (typeof agentPresets?.remove !== 'function') {
488
- return { ok: false, error: '当前 DSH 版本没有 agentPresets.remove(),无法删除。' }
493
+ return { ok: false, code: 'noRemoveApi', error: '当前 DSH 版本没有 agentPresets.remove(),无法删除。' }
489
494
  }
490
495
  try {
491
496
  await agentPresets.remove(id)
492
497
  } catch (error) {
493
- return { ok: false, error: '删除失败:' + describe(error) }
498
+ return { ok: false, code: 'deleteFailed', params: { detail: describe(error) }, error: '删除失败:' + describe(error) }
494
499
  }
495
- return { ok: true, id, note: '已删除「' + id + '」。正在使用它的会话不受影响;新建会话时不再出现。' }
500
+ return { ok: true, id, code: 'deleted', params: { name: id }, note: '已删除「' + id + '」。正在使用它的会话不受影响;新建会话时不再出现。' }
496
501
  }
497
502
 
498
503
  /**
@@ -651,7 +656,7 @@ export function apply(ctx) {
651
656
  const directory = assistantDir(await roster(), id)
652
657
  if (directory === undefined) return json(unknownAssistant(id), 404)
653
658
  const text = readVersion(directory, url.searchParams.get('n') ?? '')
654
- if (text === null) return json({ ok: false, error: '找不到这个版本(历史可能已被上限裁剪)。' }, 404)
659
+ if (text === null) return json({ ok: false, code: 'versionMissing', error: '找不到这个版本(历史可能已被上限裁剪)。' }, 404)
655
660
  return json({ ok: true, id, n: url.searchParams.get('n'), text })
656
661
  }
657
662
 
@@ -669,7 +674,7 @@ export function apply(ctx) {
669
674
  try {
670
675
  parsed = await request.json()
671
676
  } catch {
672
- return json({ ok: false, error: '请求体不是合法 JSON' }, 400)
677
+ return json({ ok: false, code: 'badJson', error: '请求体不是合法 JSON' }, 400)
673
678
  }
674
679
  await ensureShipped()
675
680
  if (pathname === STATE_PATH) {
@@ -716,12 +721,56 @@ export function apply(ctx) {
716
721
  * prompt,而这个文件正是"用户的提示词"。
717
722
  */
718
723
  function writeAtomic(file, text) {
719
- // 临时名必须**每个请求唯一**:只带 pid 时,同一进程内两个并发保存会争同一个临时名,
720
- // Windows 上两个 rename 指向同一目标会以 EPERM 失败(实测:10 并发保存 2 例 400),
721
- // POSIX rename 原子覆盖、静默地后写胜出 —— 也就是说这个缺陷只在 Windows 显现。
724
+ // 临时名必须**每个请求唯一**:只带 pid 时,同一进程内两个并发保存会争同一个临时名。
725
+ // 但随机后缀只消除 tmp-vs-tmp 竞争 —— Windows 上**两个 rename 指向同一目标**仍会以
726
+ // EPERM/EBUSY 失败(实测:10 并发保存 2 400),所以还要短退避重试。
722
727
  const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
723
728
  writeFileSync(temporary, text, 'utf8')
724
- renameSync(temporary, file)
729
+ try {
730
+ renameWithRetry(temporary, file)
731
+ } catch (error) {
732
+ // 失败必须清掉临时文件:助手目录会被 agent-presets 扫描,孤儿 tmp 是脏残留(实测留下过
733
+ // `agent.cordis.yml.tmp-135052-36a5bf5c`)。
734
+ try {
735
+ rmSync(temporary, { force: true })
736
+ } catch {
737
+ /* 清理失败不再掩盖原始错误 */
738
+ }
739
+ throw error
740
+ }
741
+ }
742
+
743
+ /** 同步退避:这条写路径本身是同步的,等一小会儿比把整个调用链改成异步更合适。 */
744
+ function sleepSync(ms) {
745
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
746
+ }
747
+
748
+ /**
749
+ * `rename` 带重试。
750
+ *
751
+ * Windows 上并发 rename 到同一目标会短暂报 `EPERM`/`EBUSY`/`EACCES`(目标被另一个 rename 持有),
752
+ * 退避几毫秒就能跨过这个窗口;其它错误立刻抛出,不做无谓等待。`rename`/`sleep` 可注入,
753
+ * 于是这条重试逻辑在 Linux 上也能被测试钉住(见 test/journal.test.mjs 的对应条目)。
754
+ *
755
+ * @param {string} from - 临时文件名(已写好内容)。
756
+ * @param {string} to - 目标文件名。
757
+ * @returns {void}
758
+ */
759
+ export function renameWithRetry(from, to, options = {}) {
760
+ const rename = typeof options.rename === 'function' ? options.rename : renameSync
761
+ const sleep = typeof options.sleep === 'function' ? options.sleep : sleepSync
762
+ const attempts = Number.isInteger(options.attempts) ? options.attempts : 5
763
+ for (let attempt = 1; ; attempt += 1) {
764
+ try {
765
+ rename(from, to)
766
+ return
767
+ } catch (error) {
768
+ const code = error !== null && typeof error === 'object' ? error.code : undefined
769
+ const retryable = code === 'EPERM' || code === 'EBUSY' || code === 'EACCES'
770
+ if (retryable !== true || attempt >= attempts) throw error
771
+ sleep(20 * attempt)
772
+ }
773
+ }
725
774
  }
726
775
 
727
776
  /** `error` as a readable string, without assuming it is an Error. */
package/journal.mjs CHANGED
@@ -23,9 +23,30 @@
23
23
  * 但这里记录的是**已发生的事实**而不是待确认的提议 —— 我们的场景是"改了什么要看得见、回得去",
24
24
  * 不需要它的确认闸门。
25
25
  */
26
- import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
26
+ import { randomBytes } from 'node:crypto'
27
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
27
28
  import { join } from 'node:path'
28
29
 
30
+ /**
31
+ * `rename` 带重试(与宿主半同一实现,见 editor/index.mjs 的说明)。
32
+ *
33
+ * 这两个文件刻意各留一份:preset 侧的模块是独立分发的,不能 import 宿主半 —— 与 `checkPromptText`
34
+ * 同样的取舍。journal 只在宿主半写,所以这里只需与宿主半保持一致的重试策略。
35
+ */
36
+ function renameWithRetry(from, to, attempts = 5) {
37
+ for (let attempt = 1; ; attempt += 1) {
38
+ try {
39
+ renameSync(from, to)
40
+ return
41
+ } catch (error) {
42
+ const code = error !== null && typeof error === 'object' ? error.code : undefined
43
+ const retryable = code === 'EPERM' || code === 'EBUSY' || code === 'EACCES'
44
+ if (retryable !== true || attempt >= attempts) throw error
45
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * attempt)
46
+ }
47
+ }
48
+ }
49
+
29
50
  /** 每个助手目录下的日志文件名。 */
30
51
  export const HISTORY_NAME = 'prompt-history.jsonl'
31
52
 
@@ -89,16 +110,29 @@ export function recordPrompt(directory, text, by = HISTORY_SOURCE.settings) {
89
110
 
90
111
  const at = new Date().toISOString()
91
112
  const n = entries.length === 0 ? 1 : entries[entries.length - 1].n + 1
113
+ // `bytes` 是给人看的体积,所以按 **UTF-8 字节**计(原先用 text.length = UTF-16 码元,
114
+ // CJK 内容下显示值只有真实字节的约 1/3,而界面标注是 "B")。
92
115
  const lines = entries.slice(-(HISTORY_MAX - 1)).map((entry) =>
93
- JSON.stringify({ n: entry.n, at: entry.at, by: entry.by, bytes: entry.text.length, text: entry.text }),
116
+ JSON.stringify({ n: entry.n, at: entry.at, by: entry.by, bytes: Buffer.byteLength(entry.text, 'utf8'), text: entry.text }),
94
117
  )
95
- lines.push(JSON.stringify({ n, at, by, bytes: text.length, text }))
118
+ lines.push(JSON.stringify({ n, at, by, bytes: Buffer.byteLength(text, 'utf8'), text }))
96
119
 
97
120
  // 唯一的整文件重写:顺手把上限外的旧条目丢掉,用原子写落盘(读取器不会看到写了一半的文件)。
98
121
  const file = historyFile(directory)
99
- const temporary = `${file}.tmp-${String(process.pid)}`
122
+ // 与宿主半同一条纪律:随机临时名(消除 tmp-vs-tmp 碰撞)+ 重试(Windows 上目标被并发 rename
123
+ // 持有时会短暂 EPERM)+ 失败清理。
124
+ const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
100
125
  writeFileSync(temporary, lines.join('\n') + '\n', 'utf8')
101
- renameSync(temporary, file)
126
+ try {
127
+ renameWithRetry(temporary, file)
128
+ } catch (error) {
129
+ try {
130
+ rmSync(temporary, { force: true })
131
+ } catch {
132
+ /* 不掩盖原始错误 */
133
+ }
134
+ throw error
135
+ }
102
136
  return { recorded: true, at, n }
103
137
  }
104
138
 
@@ -119,7 +153,7 @@ export function listHistory(directory, limit = HISTORY_MAX) {
119
153
  n: entry.n,
120
154
  at: entry.at,
121
155
  by: entry.by,
122
- bytes: entry.text.length,
156
+ bytes: Buffer.byteLength(entry.text, 'utf8'),
123
157
  preview: firstLine.trim().slice(0, 80),
124
158
  })
125
159
  }
package/locales.mjs CHANGED
@@ -52,6 +52,31 @@ export const zh = {
52
52
  'msg.reorderFailed': '调整顺序失败',
53
53
  'msg.imported': '已导入到编辑器(还没有保存):检查后点「保存」。',
54
54
  'msg.importFailed': '导入失败',
55
+ 'api.badDirection': '未知的排序方向:{direction}',
56
+ 'api.unknownAssistant': '找不到助手「{id}」:这一页只管理本工具创建的助手(目录里有 prompt.md,且组成文件用 prompt-reader.mjs 注入身份)。',
57
+ 'api.alreadyFirst': '「{name}」已经在最前面。',
58
+ 'api.alreadyLast': '「{name}」已经在最后面。',
59
+ 'api.saved': '已保存({name},基础模式 {mode})。新建会话即生效,当前会话保持原配置。',
60
+ 'api.created': '已创建「{name}」。现在可以为它写系统提示词。',
61
+ 'api.duplicated': '已复制自「{from}」。两份从此各改各的。',
62
+ 'api.deleted': '已删除「{name}」。正在使用它的会话不受影响;新建会话时它不再出现。',
63
+ 'api.reordered': '顺序已保存:新建会话时的模式选择器按这个顺序排列。',
64
+ 'api.nameRequired': '请先给新助手起个名字。',
65
+ 'api.nameTooLong': '名字太长了(上限 {max} 个字符)。',
66
+ 'api.descriptionTooLong': '描述太长了(上限 {max} 个字符)。',
67
+ 'api.promptEmpty': '保存被拒绝:系统提示词为空。留空不会清空身份,读取器会沿用上一版。',
68
+ 'api.badMode': '未知的基础模式:{mode}',
69
+ 'api.dirExists': '目录已存在,请换一个名字:{path}',
70
+ 'api.compositionMissing': '找不到组成文件:{path}',
71
+ 'api.writeFailed': '写入失败:{detail}',
72
+ 'api.promptWriteFailed': '写入提示词失败:{detail}',
73
+ 'api.renderFailed': '生成组成文件失败:{detail}',
74
+ 'api.selfCheckFailed': '生成结果自检失败,已放弃写入:{detail}',
75
+ 'api.promptReadFailed': '读取提示词失败:{detail}',
76
+ 'api.noRemoveApi': '当前 DSH 版本没有 agentPresets.remove(),无法删除。',
77
+ 'api.deleteFailed': '删除失败:{detail}',
78
+ 'api.versionMissing': '找不到这个版本(历史可能已被上限裁剪)。',
79
+ 'api.badJson': '请求体不是合法 JSON',
55
80
  'warn.personaOffWithPrompt': '「身份(系统提示词)」这一行是关的,所以 prompt.md 不会被注入 —— 你写的提示词现在不起作用。要么打开这一行,要么清空提示词。',
56
81
  'warn.toolOff': '「custom_prompt 工具」这一行是关的:会话里无法让 agent 改提示词,只能在本页改。',
57
82
  'warn.noDescription': '没有描述:新建会话的模式选择器里会显示成「暂无描述」。',
@@ -212,6 +237,31 @@ export const en = {
212
237
  'msg.reorderFailed': 'Could not reorder',
213
238
  'msg.imported': 'Imported into the editor (not saved yet) — review it, then click Save.',
214
239
  'msg.importFailed': 'Import failed',
240
+ 'api.badDirection': 'Unknown reorder direction: {direction}',
241
+ 'api.unknownAssistant': 'No assistant 「{id}」: this page only manages the assistants it created (a directory with prompt.md whose composition injects the identity through prompt-reader.mjs).',
242
+ 'api.alreadyFirst': '「{name}」 is already first.',
243
+ 'api.alreadyLast': '「{name}」 is already last.',
244
+ 'api.saved': 'Saved ({name}, base mode {mode}). A new session picks it up; the current one keeps its configuration.',
245
+ 'api.created': 'Created 「{name}」. You can write its system prompt now.',
246
+ 'api.duplicated': 'Copied from 「{from}」. The two are independent from now on.',
247
+ 'api.deleted': 'Deleted 「{name}」. Sessions already using it keep running; it no longer appears for new sessions.',
248
+ 'api.reordered': 'Order saved: the new-session mode picker follows it.',
249
+ 'api.nameRequired': 'Give the new assistant a name first.',
250
+ 'api.nameTooLong': 'That name is too long (limit {max} characters).',
251
+ 'api.descriptionTooLong': 'That description is too long (limit {max} characters).',
252
+ 'api.promptEmpty': 'Save rejected: the system prompt is empty. Emptying it would not clear the identity — the reader keeps the last good text.',
253
+ 'api.badMode': 'Unknown base mode: {mode}',
254
+ 'api.dirExists': 'That directory already exists — pick another name: {path}',
255
+ 'api.compositionMissing': 'Composition file not found: {path}',
256
+ 'api.writeFailed': 'Write failed: {detail}',
257
+ 'api.promptWriteFailed': 'Writing the prompt failed: {detail}',
258
+ 'api.renderFailed': 'Rendering the composition failed: {detail}',
259
+ 'api.selfCheckFailed': 'The generated composition failed its self-check, so nothing was written: {detail}',
260
+ 'api.promptReadFailed': 'Reading the prompt failed: {detail}',
261
+ 'api.noRemoveApi': 'This DSH build has no agentPresets.remove(), so deleting is unavailable.',
262
+ 'api.deleteFailed': 'Delete failed: {detail}',
263
+ 'api.versionMissing': 'That version is gone (the history is capped).',
264
+ 'api.badJson': 'The request body is not valid JSON',
215
265
  'warn.personaOffWithPrompt': 'The "Identity (system prompt)" row is off, so prompt.md is never injected — the prompt you wrote has no effect. Turn the row on, or clear the prompt.',
216
266
  'warn.toolOff': 'The "custom_prompt tool" row is off: the agent cannot change the prompt from inside a session, only this page can.',
217
267
  'warn.noDescription': 'No description: the new-session mode picker will show it as "no description yet".',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-custom-mode",
3
- "version": "1.3.0",
3
+ "version": "1.4.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",
@@ -60,7 +60,7 @@
60
60
  "test": "node ../test/run.mjs"
61
61
  },
62
62
  "peerDependencies": {
63
- "@deepseek-ai/dsh": ">=0.1.6-alpha.1 <0.2.0-0"
63
+ "@deepseek-ai/dsh": ">=0.1.5-rc.2 <0.2.0-0"
64
64
  },
65
65
  "publishConfig": {
66
66
  "access": "public",
@@ -72,6 +72,6 @@
72
72
  }
73
73
  },
74
74
  "engines": {
75
- "dsh": ">=0.1.6-alpha.1 <0.2.0-0"
75
+ "dsh": ">=0.1.5-rc.2 <0.2.0-0"
76
76
  }
77
77
  }