dsh-custom-mode 1.0.4 → 1.2.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": "已创建。现在可以为它写系统提示词。",
@@ -90,6 +92,13 @@ try {
90
92
  "msg.reorderFailed": "调整顺序失败",
91
93
  "msg.imported": "已导入到编辑器(还没有保存):检查后点「保存」。",
92
94
  "msg.importFailed": "导入失败",
95
+ "history.label": "改动历史",
96
+ "history.pick": "选择要载入的版本…",
97
+ "history.load": "载入这一版",
98
+ "history.hint": "每次保存、以及会话内工具或手工改动,都会在这里留一版;载入只改草稿,保存前不落盘。",
99
+ "history.by.settings": "设置页保存",
100
+ "history.by.external": "会话内/手工改动",
101
+ "msg.loadFailed": "载入这一版失败",
93
102
  "msg.importEmpty": "这个文件是空的。",
94
103
  "msg.importTooLarge": "文件太大(上限 1MB)。",
95
104
  "msg.exported": "已导出为文件。",
@@ -109,7 +118,7 @@ try {
109
118
  "rows.heading": "插件开关",
110
119
  "rows.hint": "逐行控制这个模式挂载哪些插件,和官方插件列表一样按行铺开。没拨过的行保持官方默认(含平台判断);你手动拨了就以你的为准。",
111
120
  "prompt.heading": "系统提示词",
112
- "prompt.hint": "这段文本在每个模型调用前重新读取,所以保存后下一步即生效,且只影响使用这个助手的会话。「导入」把文件读进编辑器(未保存前不写入任何东西);「导出」把当前文本存成 .md 文件。",
121
+ "prompt.hint": "这段文本在每个模型调用前重新读取,所以保存后下一步即生效,且只影响使用这个助手的会话。「导入」把文件读进编辑器(未保存前不写入任何东西);「导出」把当前文本存成 .md 文件;「恢复出厂提示词」把出厂模板填回编辑器(同样要保存才写入)。",
113
122
  "status.enabled": "已启用",
114
123
  "status.disabled": "已停用",
115
124
  "status.changed": "已改",
@@ -206,6 +215,8 @@ try {
206
215
  "btn.moveDown": "Move down",
207
216
  "btn.import": "Import prompt",
208
217
  "btn.export": "Export prompt",
218
+ "btn.reset": "Reset to factory prompt",
219
+ "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
220
  "btn.delete": "Delete this assistant",
210
221
  "btn.cancel": "Cancel",
211
222
  "msg.created": "Created. Now you can write its system prompt.",
@@ -220,6 +231,13 @@ try {
220
231
  "msg.reorderFailed": "Could not reorder",
221
232
  "msg.imported": "Imported into the editor (not saved yet) — review it, then click Save.",
222
233
  "msg.importFailed": "Import failed",
234
+ "history.label": "Change history",
235
+ "history.pick": "Pick a version to load…",
236
+ "history.load": "Load this version",
237
+ "history.hint": "Every save — plus changes made in a session or by hand — leaves a version here. Loading one only edits the draft; nothing is written until you save.",
238
+ "history.by.settings": "saved from this page",
239
+ "history.by.external": "changed in a session / by hand",
240
+ "msg.loadFailed": "Could not load that version",
223
241
  "msg.importEmpty": "That file is empty.",
224
242
  "msg.importTooLarge": "That file is too large (1 MB limit).",
225
243
  "msg.exported": "Exported to a file.",
@@ -239,7 +257,7 @@ try {
239
257
  "rows.heading": "Plugin switches",
240
258
  "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
259
  "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.",
260
+ "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
261
  "status.enabled": "Enabled",
244
262
  "status.disabled": "Disabled",
245
263
  "status.changed": "changed",
@@ -531,6 +549,7 @@ try {
531
549
  const ROUTES = {
532
550
  list: ROUTE,
533
551
  state: ROUTE + "/state",
552
+ history: ROUTE + "/history",
534
553
  create: ROUTE + "/create",
535
554
  delete: ROUTE + "/delete",
536
555
  reorder: ROUTE + "/reorder",
@@ -572,6 +591,13 @@ try {
572
591
  }
573
592
 
574
593
  /** The editable draft for one assistant, derived from its loaded state. */
594
+ /** 时间戳给人看:转本地时间;解析不了就原样显示(历史文件是纯文本,什么都有可能)。 */
595
+ function formatWhen(at) {
596
+ const parsed = new Date(at)
597
+ if (Number.isNaN(parsed.getTime())) return String(at).slice(0, 16)
598
+ return parsed.toLocaleString()
599
+ }
600
+
575
601
  function draftOf(state) {
576
602
  return {
577
603
  id: state.id,
@@ -580,6 +606,12 @@ try {
580
606
  prompt: state.prompt,
581
607
  name: typeof state.name === "string" ? state.name : "",
582
608
  description: typeof state.description === "string" ? state.description : "",
609
+ // 「恢复出厂提示词」要用它。它不是草稿的一部分,`sameDraft` 不比较它 ——
610
+ // 漏掉这一行按钮会一直置灰(实测:真点了没反应,浏览器验收抓到)。
611
+ factoryPrompt: typeof state.factoryPrompt === "string" ? state.factoryPrompt : null,
612
+ // 改动历史:列表来自 state(只有元数据),正文点「载入这一版」时按需取。
613
+ history: Array.isArray(state.history) ? state.history : [],
614
+ historyPick: "",
583
615
  }
584
616
  }
585
617
 
@@ -896,6 +928,36 @@ try {
896
928
  }
897
929
 
898
930
  /** Download the prompt being edited (client-side only — the host is not involved). */
931
+ /**
932
+ * 把编辑器内容换回出厂提示词。
933
+ *
934
+ * 只改**草稿**,不写盘:这样"一键回到官方"既立刻可见,又可被「重新读取」撤销 ——
935
+ * 与页面其余部分一样,一切改动都要点保存才落盘。
936
+ */
937
+ const resetPrompt = () => {
938
+ const factory = typeof draft.factoryPrompt === "string" ? draft.factoryPrompt : ""
939
+ if (factory === "") return
940
+ update({ prompt: factory })
941
+ }
942
+
943
+ /** 把某个历史版本载入编辑器。与「恢复出厂」同一条纪律:只改草稿,保存前不落盘。 */
944
+ const loadVersion = async () => {
945
+ const picked = typeof draft.historyPick === "string" ? draft.historyPick : ""
946
+ if (picked === "" || draft.id === undefined) return
947
+ try {
948
+ // 版本键是序号(时间戳会在同一毫秒内撞车 —— 单测就是那样抓到它的)。
949
+ const response = await fetch(ROUTES.history + "?id=" + encodeURIComponent(draft.id) + "&n=" + encodeURIComponent(picked))
950
+ const payload = await asJson(response)
951
+ if (payload === null || payload.ok !== true) {
952
+ setStatus(t("msg.loadFailed"))
953
+ return
954
+ }
955
+ update({ prompt: payload.text })
956
+ } catch (error) {
957
+ setStatus(t("msg.loadFailed") + ":" + describeError(error))
958
+ }
959
+ }
960
+
899
961
  const exportPrompt = () => {
900
962
  try {
901
963
  const blob = new Blob([draft.prompt], { type: "text/markdown;charset=utf-8" })
@@ -1259,6 +1321,21 @@ try {
1259
1321
  },
1260
1322
  t("btn.import"),
1261
1323
  ),
1324
+ react.createElement(
1325
+ A.Button,
1326
+ {
1327
+ variant: "outline",
1328
+ size: "sm",
1329
+ disabled:
1330
+ busy ||
1331
+ typeof draft.factoryPrompt !== "string" ||
1332
+ draft.factoryPrompt === "" ||
1333
+ draft.prompt === draft.factoryPrompt,
1334
+ onClick: resetPrompt,
1335
+ title: t("btn.resetHint"),
1336
+ },
1337
+ t("btn.reset"),
1338
+ ),
1262
1339
  // The picker itself is invisible; the button above is the affordance. The
1263
1340
  // file is read locally and lands in the editor, never straight on disk.
1264
1341
  react.createElement("input", {
@@ -1280,6 +1357,42 @@ try {
1280
1357
  "aria-label": t("prompt.heading"),
1281
1358
  onChange: (event) => update({ prompt: event.target.value }),
1282
1359
  }),
1360
+ // 改动历史:谁在什么时候改过。之前这里只有"当前文本",所以会话内的工具
1361
+ // (或手工编辑)改掉提示词时,用户既看不见也回不去。
1362
+ draft.history.length === 0
1363
+ ? null
1364
+ : react.createElement(
1365
+ "div",
1366
+ { className: "cpfe-history" },
1367
+ react.createElement("span", { className: "cpfe-history-label" }, t("history.label")),
1368
+ react.createElement(
1369
+ "select",
1370
+ {
1371
+ value: draft.historyPick,
1372
+ "aria-label": t("history.label"),
1373
+ onChange: (event) => update({ historyPick: event.target.value }),
1374
+ },
1375
+ react.createElement("option", { value: "" }, t("history.pick")),
1376
+ ...draft.history.map((entry) =>
1377
+ react.createElement(
1378
+ "option",
1379
+ { key: String(entry.n), value: String(entry.n) },
1380
+ formatWhen(entry.at) + " · " + t("history.by." + entry.by) + " · " + String(entry.bytes) + " B",
1381
+ ),
1382
+ ),
1383
+ ),
1384
+ react.createElement(
1385
+ A.Button,
1386
+ {
1387
+ variant: "outline",
1388
+ size: "sm",
1389
+ disabled: busy || draft.historyPick === "",
1390
+ onClick: loadVersion,
1391
+ },
1392
+ t("history.load"),
1393
+ ),
1394
+ react.createElement("span", { className: "cpfe-history-hint" }, t("history.hint")),
1395
+ ),
1283
1396
  ),
1284
1397
  ]
1285
1398
  : []
package/index.mjs CHANGED
@@ -51,6 +51,7 @@ import {
51
51
  setShippedPresetsDir,
52
52
  } from './composition.mjs'
53
53
  import { readPresetMeta, writePresetMeta, presetMetaPath, PRESET_META_PATH } from './meta.mjs'
54
+ import { listHistory, readVersion, recordExternalChange, recordPrompt, HISTORY_SOURCE } from './journal.mjs'
54
55
  import { packagedPresetDir } from './seed.mjs'
55
56
  import {
56
57
  allocateId,
@@ -70,11 +71,13 @@ const STATE_PATH = ROUTE_PATH + '/state'
70
71
  const CREATE_PATH = ROUTE_PATH + '/create'
71
72
  const DELETE_PATH = ROUTE_PATH + '/delete'
72
73
  const REORDER_PATH = ROUTE_PATH + '/reorder'
74
+ const HISTORY_PATH = ROUTE_PATH + '/history'
73
75
 
74
76
  /** Which verbs each endpoint answers. `undefined` for a path means 404. */
75
77
  const METHODS = {
76
78
  [ROUTE_PATH]: ['GET'],
77
79
  [STATE_PATH]: ['GET', 'POST'],
80
+ [HISTORY_PATH]: ['GET'],
78
81
  [CREATE_PATH]: ['POST'],
79
82
  [DELETE_PATH]: ['POST'],
80
83
  [REORDER_PATH]: ['POST'],
@@ -189,7 +192,26 @@ function unknownAssistant(id) {
189
192
  * @param {string} id - the assistant's preset id.
190
193
  * @returns {object} the payload the browser half reads.
191
194
  */
192
- export function readState(rows, id) {
195
+ /**
196
+ * 出厂提示词:新建助手时拿到的那一份(打包在包里的 preset 模板)。
197
+ *
198
+ * 设置页用它实现「恢复出厂提示词」—— 在此之前,用户改坏了提示词只能把整个助手删掉重建。
199
+ * 读不到就返回 null(页面会把那个按钮置灰,而不是给一个会写坏文件的按钮)。
200
+ */
201
+ let packagedPromptCache
202
+ function packagedPrompt() {
203
+ if (packagedPromptCache === undefined) {
204
+ try {
205
+ packagedPromptCache = readFileSync(join(packagedPresetDir(), 'prompt.md'), 'utf8')
206
+ } catch (error) {
207
+ packagedPromptCache = null
208
+ console.error('custom-mode: 读不到出厂提示词模板(' + describe(error) + '),设置页将不提供「恢复出厂提示词」。')
209
+ }
210
+ }
211
+ return packagedPromptCache
212
+ }
213
+
214
+ export function readState(rows, id, options = {}) {
193
215
  const directory = assistantDir(rows, id)
194
216
  if (directory === undefined) return unknownAssistant(id)
195
217
  const composition = compositionFile(directory)
@@ -198,6 +220,9 @@ export function readState(rows, id) {
198
220
  const mode = modeOf(text)
199
221
  const prompt = readPrompt(directory)
200
222
  const meta = readPresetMeta(directory)
223
+ // 页面要打开时顺便对账:磁盘上的文本若与日志末条不同,说明它在设置页之外被改过
224
+ // (会话内的 custom_prompt 工具、手工编辑、别处同步)—— 补记一条 external,于是"被改过"看得见。
225
+ if (prompt.ok === true) recordExternalChange(directory, prompt.text)
201
226
  return {
202
227
  ok: true,
203
228
  id,
@@ -212,6 +237,11 @@ export function readState(rows, id) {
212
237
  promptPath: promptFile(directory),
213
238
  compositionPath: composition,
214
239
  presetMetaPath: presetMetaPath(directory),
240
+ // 回退用的出厂文本。它不是"当前值",页面只把它填进编辑器,保存前不落盘 —— 所以
241
+ // 一次误点不会破坏任何东西(重新读取即可丢弃)。
242
+ factoryPrompt: typeof options.factoryPrompt === 'string' ? options.factoryPrompt : null,
243
+ // 改动历史(只有元数据,正文按需取:见 GET /custom-mode/history)。
244
+ history: listHistory(directory),
215
245
  }
216
246
  }
217
247
 
@@ -289,6 +319,9 @@ export function saveState(rows, input) {
289
319
  try {
290
320
  writeAtomic(compositionFile(directory), composition)
291
321
  writeAtomic(promptFile(directory), prompt)
322
+ // 改动留痕:三个改动路径(设置页 / 会话内工具 / 手工编辑)里,只有设置页是"当场知道"的。
323
+ // 另外两条由 readState 对比补记(见 journal.mjs 的单写者说明)。
324
+ recordPrompt(directory, prompt, HISTORY_SOURCE.settings)
292
325
  } catch (error) {
293
326
  return { ok: false, error: '写入失败:' + describe(error) }
294
327
  }
@@ -576,7 +609,17 @@ export function apply(ctx) {
576
609
  }
577
610
  if (request.method === 'GET' && pathname === STATE_PATH) {
578
611
  await ensureShipped()
579
- return json(readState(await roster(), url.searchParams.get('id') ?? ''))
612
+ return json(readState(await roster(), url.searchParams.get('id') ?? '', { factoryPrompt: packagedPrompt() }))
613
+ }
614
+
615
+ if (request.method === 'GET' && pathname === HISTORY_PATH) {
616
+ await ensureShipped()
617
+ const id = url.searchParams.get('id') ?? ''
618
+ const directory = assistantDir(await roster(), id)
619
+ if (directory === undefined) return json(unknownAssistant(id), 404)
620
+ const text = readVersion(directory, url.searchParams.get('n') ?? '')
621
+ if (text === null) return json({ ok: false, error: '找不到这个版本(历史可能已被上限裁剪)。' }, 404)
622
+ return json({ ok: true, id, n: url.searchParams.get('n'), text })
580
623
  }
581
624
 
582
625
  // Backstop on request size. `requestBody: 'buffered'` means the platform applies its own
package/journal.mjs ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * 提示词改动日志:**只追加**、单写者、可审计。
3
+ *
4
+ * 为什么需要它:一个助手的 `prompt.md` 有三条改动路径 —— 设置页保存、会话内的 `custom_prompt`
5
+ * 工具、以及手工编辑文件。后两条对用户是**不可见**的:提示词变了,页面只会显示"当前文本",
6
+ * 没有任何地方能看出"谁在什么时候改的、上一版是什么"。这个模块把每一次变化留成一行,于是
7
+ * "恢复出厂"之外还能"回到上一版",会话内被改动也能被看见。
8
+ *
9
+ * 设计取舍(都写在这里,因为它们看起来可以更简单):
10
+ *
11
+ * 1. **单写者**:只有宿主半写这个文件。会话内的工具**不**直接写日志 —— 否则文件格式就得在
12
+ * preset 副本里再实现一遍(那些文件是独立分发的)。改为在读状态时对比"当前文本 vs 日志末条",
13
+ * 不一致就补记一条 `external`:工具改的、手工改的、甚至别的机器同步过来的,都会以同一种方式
14
+ * 留痕。代价是同一段文本的**中间若干次**改动看不到(我们只能看到"离开时的样子"),这一点
15
+ * 在文档里写明,不假装是版本控制。
16
+ * 2. **只追加 + 上限**:追加而不是覆盖,坏一行不牵连其余(解析时跳过坏行)。超过上限就整文件
17
+ * 重写成最后 N 条 —— 这是唯一的"重写",且只发生在追加之后,用原子写完成。
18
+ * 3. **相同内容不重复记**:保存两次而文本没变,不该产生两条历史。
19
+ * 4. **版本键是自增序号,不是时间戳**:同一毫秒内落两次记录完全可能(实测:连续两次保存),
20
+ * 时间戳做键会让"取回某一版"取到隔壁那条。时间戳只用于显示。
21
+ *
22
+ * 这个方向借鉴了同生态里 whale-persona 的"收件箱"设计(只追加、坏行不牵连、状态显式),
23
+ * 但这里记录的是**已发生的事实**而不是待确认的提议 —— 我们的场景是"改了什么要看得见、回得去",
24
+ * 不需要它的确认闸门。
25
+ */
26
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
27
+ import { join } from 'node:path'
28
+
29
+ /** 每个助手目录下的日志文件名。 */
30
+ export const HISTORY_NAME = 'prompt-history.jsonl'
31
+
32
+ /** 保留的最大版本数。超出后整文件重写为最后这么多条。 */
33
+ export const HISTORY_MAX = 30
34
+
35
+ /** 改动来源。`settings` 是设置页保存;`external` 是日志末条之后发生的任何改动。 */
36
+ export const HISTORY_SOURCE = { settings: 'settings', external: 'external' }
37
+
38
+ /** @param {string} directory - one assistant's preset directory. */
39
+ export function historyFile(directory) {
40
+ return join(directory, HISTORY_NAME)
41
+ }
42
+
43
+ /**
44
+ * Parse the log, skipping unreadable lines.
45
+ *
46
+ * A line that is not valid JSON, or lacks a text, is ignored rather than throwing: this file is
47
+ * written across process restarts and may be edited by hand, and one bad line must not cost the
48
+ * user every version (the same reason the inbox design in this ecosystem replays line by line).
49
+ *
50
+ * @returns {Array<{ at: string, by: string, text: string }>} oldest first.
51
+ */
52
+ export function readEntries(directory) {
53
+ const file = historyFile(directory)
54
+ if (!existsSync(file)) return []
55
+ const entries = []
56
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
57
+ if (line.trim() === '') continue
58
+ try {
59
+ const parsed = JSON.parse(line)
60
+ if (parsed === null || typeof parsed !== 'object') continue
61
+ if (typeof parsed.text !== 'string') continue
62
+ entries.push({
63
+ // 老日志没有 n 时按行序补一个,于是"序号"这个概念对任何日志都成立。
64
+ n: Number.isInteger(parsed.n) && parsed.n > 0 ? parsed.n : entries.length + 1,
65
+ at: typeof parsed.at === 'string' ? parsed.at : '',
66
+ by: typeof parsed.by === 'string' ? parsed.by : HISTORY_SOURCE.external,
67
+ text: parsed.text,
68
+ })
69
+ } catch {
70
+ continue
71
+ }
72
+ }
73
+ return entries
74
+ }
75
+
76
+ /**
77
+ * Append one revision, unless it repeats the newest one.
78
+ *
79
+ * @param {string} directory - one assistant's preset directory.
80
+ * @param {string} text - the prompt content **after** the change.
81
+ * @param {string} by - {@link HISTORY_SOURCE}.
82
+ * @returns {{ recorded: boolean, at: string, n: number }} whether a line was appended.
83
+ */
84
+ export function recordPrompt(directory, text, by = HISTORY_SOURCE.settings) {
85
+ if (typeof text !== 'string') return { recorded: false, at: '' }
86
+ const entries = readEntries(directory)
87
+ const newest = entries[entries.length - 1]
88
+ if (newest !== undefined && newest.text === text) return { recorded: false, at: newest.at }
89
+
90
+ const at = new Date().toISOString()
91
+ const n = entries.length === 0 ? 1 : entries[entries.length - 1].n + 1
92
+ 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 }),
94
+ )
95
+ lines.push(JSON.stringify({ n, at, by, bytes: text.length, text }))
96
+
97
+ // 唯一的整文件重写:顺手把上限外的旧条目丢掉,用原子写落盘(读取器不会看到写了一半的文件)。
98
+ const file = historyFile(directory)
99
+ const temporary = `${file}.tmp-${String(process.pid)}`
100
+ writeFileSync(temporary, lines.join('\n') + '\n', 'utf8')
101
+ renameSync(temporary, file)
102
+ return { recorded: true, at, n }
103
+ }
104
+
105
+ /**
106
+ * The log as the page needs it: newest first, **without** the texts.
107
+ *
108
+ * Text is fetched per revision on demand (`readVersion`) — a page that ships 30 prompts on every
109
+ * load would be slower than the thing it is documenting.
110
+ *
111
+ * @returns {Array<{ at: string, by: string, bytes: number, preview: string }>}
112
+ */
113
+ export function listHistory(directory, limit = HISTORY_MAX) {
114
+ const entries = readEntries(directory)
115
+ const out = []
116
+ for (const entry of entries.slice(-limit).reverse()) {
117
+ const firstLine = entry.text.split('\n').find((line) => line.trim() !== '') ?? ''
118
+ out.push({
119
+ n: entry.n,
120
+ at: entry.at,
121
+ by: entry.by,
122
+ bytes: entry.text.length,
123
+ preview: firstLine.trim().slice(0, 80),
124
+ })
125
+ }
126
+ return out
127
+ }
128
+
129
+ /**
130
+ * One revision's full text, by its sequence number.
131
+ *
132
+ * The key is `n`, not `at`: two revisions can share a timestamp (measured — two records inside the
133
+ * same millisecond), and keying on it silently returns the neighbour.
134
+ *
135
+ * @param {number|string} n - the revision number shown in {@link listHistory}.
136
+ * @returns {string|null}
137
+ */
138
+ export function readVersion(directory, n) {
139
+ const wanted = typeof n === 'string' ? Number(n) : n
140
+ if (!Number.isInteger(wanted)) return null
141
+ for (const entry of readEntries(directory)) {
142
+ if (entry.n === wanted) return entry.text
143
+ }
144
+ return null
145
+ }
146
+
147
+ /**
148
+ * Record `text` as an `external` revision when it is not what the log already ends with.
149
+ *
150
+ * This is how changes made outside the settings page become visible: the page asks for state, and
151
+ * the host notices that what is on disk is not what it last recorded.
152
+ *
153
+ * @returns {boolean} whether something was recorded.
154
+ */
155
+ export function recordExternalChange(directory, text) {
156
+ return recordPrompt(directory, text, HISTORY_SOURCE.external).recorded
157
+ }
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': '已创建。现在可以为它写系统提示词。',
@@ -50,6 +52,13 @@ export const zh = {
50
52
  'msg.reorderFailed': '调整顺序失败',
51
53
  'msg.imported': '已导入到编辑器(还没有保存):检查后点「保存」。',
52
54
  'msg.importFailed': '导入失败',
55
+ 'history.label': '改动历史',
56
+ 'history.pick': '选择要载入的版本…',
57
+ 'history.load': '载入这一版',
58
+ 'history.hint': '每次保存、以及会话内工具或手工改动,都会在这里留一版;载入只改草稿,保存前不落盘。',
59
+ 'history.by.settings': '设置页保存',
60
+ 'history.by.external': '会话内/手工改动',
61
+ 'msg.loadFailed': '载入这一版失败',
53
62
  'msg.importEmpty': '这个文件是空的。',
54
63
  'msg.importTooLarge': '文件太大(上限 1MB)。',
55
64
  'msg.exported': '已导出为文件。',
@@ -74,7 +83,7 @@ export const zh = {
74
83
 
75
84
  'prompt.heading': '系统提示词',
76
85
  'prompt.hint':
77
- '这段文本在每个模型调用前重新读取,所以保存后下一步即生效,且只影响使用这个助手的会话。「导入」把文件读进编辑器(未保存前不写入任何东西);「导出」把当前文本存成 .md 文件。',
86
+ '这段文本在每个模型调用前重新读取,所以保存后下一步即生效,且只影响使用这个助手的会话。「导入」把文件读进编辑器(未保存前不写入任何东西);「导出」把当前文本存成 .md 文件;「恢复出厂提示词」把出厂模板填回编辑器(同样要保存才写入)。',
78
87
 
79
88
  'status.enabled': '已启用',
80
89
  'status.disabled': '已停用',
@@ -183,6 +192,8 @@ export const en = {
183
192
  'btn.moveDown': 'Move down',
184
193
  'btn.import': 'Import prompt',
185
194
  'btn.export': 'Export prompt',
195
+ 'btn.reset': 'Reset to factory prompt',
196
+ '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
197
  'btn.delete': 'Delete this assistant',
187
198
  'btn.cancel': 'Cancel',
188
199
  'msg.created': 'Created. Now you can write its system prompt.',
@@ -197,6 +208,13 @@ export const en = {
197
208
  'msg.reorderFailed': 'Could not reorder',
198
209
  'msg.imported': 'Imported into the editor (not saved yet) — review it, then click Save.',
199
210
  'msg.importFailed': 'Import failed',
211
+ 'history.label': 'Change history',
212
+ 'history.pick': 'Pick a version to load…',
213
+ 'history.load': 'Load this version',
214
+ 'history.hint': 'Every save — plus changes made in a session or by hand — leaves a version here. Loading one only edits the draft; nothing is written until you save.',
215
+ 'history.by.settings': 'saved from this page',
216
+ 'history.by.external': 'changed in a session / by hand',
217
+ 'msg.loadFailed': 'Could not load that version',
200
218
  'msg.importEmpty': 'That file is empty.',
201
219
  'msg.importTooLarge': 'That file is too large (1 MB limit).',
202
220
  'msg.exported': 'Exported to a file.',
@@ -224,7 +242,7 @@ export const en = {
224
242
 
225
243
  'prompt.heading': 'System prompt',
226
244
  '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.',
245
+ '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
246
 
229
247
  'status.enabled': 'Enabled',
230
248
  'status.disabled': 'Disabled',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-custom-mode",
3
- "version": "1.0.4",
3
+ "version": "1.2.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",
@@ -52,6 +52,7 @@
52
52
  "meta.mjs",
53
53
  "paths.mjs",
54
54
  "locales.mjs",
55
+ "journal.mjs",
55
56
  "cordis.patch.yml",
56
57
  "preset/"
57
58
  ],