dsh-custom-mode 1.1.0 → 1.3.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 +101 -0
- package/index.mjs +54 -0
- package/journal.mjs +157 -0
- package/locales.mjs +22 -0
- package/package.json +2 -1
- package/preset/prompt-tool.mjs +56 -0
package/client.js
CHANGED
|
@@ -92,6 +92,17 @@ try {
|
|
|
92
92
|
"msg.reorderFailed": "调整顺序失败",
|
|
93
93
|
"msg.imported": "已导入到编辑器(还没有保存):检查后点「保存」。",
|
|
94
94
|
"msg.importFailed": "导入失败",
|
|
95
|
+
"warn.personaOffWithPrompt": "「身份(系统提示词)」这一行是关的,所以 prompt.md 不会被注入 —— 你写的提示词现在不起作用。要么打开这一行,要么清空提示词。",
|
|
96
|
+
"warn.toolOff": "「custom_prompt 工具」这一行是关的:会话里无法让 agent 改提示词,只能在本页改。",
|
|
97
|
+
"warn.noDescription": "没有描述:新建会话的模式选择器里会显示成「暂无描述」。",
|
|
98
|
+
"warn.noName": "没有名字:模式选择器里会显示成目录 id(例如 custom)。",
|
|
99
|
+
"history.label": "改动历史",
|
|
100
|
+
"history.pick": "选择要载入的版本…",
|
|
101
|
+
"history.load": "载入这一版",
|
|
102
|
+
"history.hint": "每次保存、以及会话内工具或手工改动,都会在这里留一版;载入只改草稿,保存前不落盘。",
|
|
103
|
+
"history.by.settings": "设置页保存",
|
|
104
|
+
"history.by.external": "会话内/手工改动",
|
|
105
|
+
"msg.loadFailed": "载入这一版失败",
|
|
95
106
|
"msg.importEmpty": "这个文件是空的。",
|
|
96
107
|
"msg.importTooLarge": "文件太大(上限 1MB)。",
|
|
97
108
|
"msg.exported": "已导出为文件。",
|
|
@@ -224,6 +235,17 @@ try {
|
|
|
224
235
|
"msg.reorderFailed": "Could not reorder",
|
|
225
236
|
"msg.imported": "Imported into the editor (not saved yet) — review it, then click Save.",
|
|
226
237
|
"msg.importFailed": "Import failed",
|
|
238
|
+
"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
|
+
"warn.toolOff": "The \"custom_prompt tool\" row is off: the agent cannot change the prompt from inside a session, only this page can.",
|
|
240
|
+
"warn.noDescription": "No description: the new-session mode picker will show it as \"no description yet\".",
|
|
241
|
+
"warn.noName": "No name: the mode picker will show the directory id (e.g. custom).",
|
|
242
|
+
"history.label": "Change history",
|
|
243
|
+
"history.pick": "Pick a version to load…",
|
|
244
|
+
"history.load": "Load this version",
|
|
245
|
+
"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.",
|
|
246
|
+
"history.by.settings": "saved from this page",
|
|
247
|
+
"history.by.external": "changed in a session / by hand",
|
|
248
|
+
"msg.loadFailed": "Could not load that version",
|
|
227
249
|
"msg.importEmpty": "That file is empty.",
|
|
228
250
|
"msg.importTooLarge": "That file is too large (1 MB limit).",
|
|
229
251
|
"msg.exported": "Exported to a file.",
|
|
@@ -535,6 +557,7 @@ try {
|
|
|
535
557
|
const ROUTES = {
|
|
536
558
|
list: ROUTE,
|
|
537
559
|
state: ROUTE + "/state",
|
|
560
|
+
history: ROUTE + "/history",
|
|
538
561
|
create: ROUTE + "/create",
|
|
539
562
|
delete: ROUTE + "/delete",
|
|
540
563
|
reorder: ROUTE + "/reorder",
|
|
@@ -576,6 +599,13 @@ try {
|
|
|
576
599
|
}
|
|
577
600
|
|
|
578
601
|
/** The editable draft for one assistant, derived from its loaded state. */
|
|
602
|
+
/** 时间戳给人看:转本地时间;解析不了就原样显示(历史文件是纯文本,什么都有可能)。 */
|
|
603
|
+
function formatWhen(at) {
|
|
604
|
+
const parsed = new Date(at)
|
|
605
|
+
if (Number.isNaN(parsed.getTime())) return String(at).slice(0, 16)
|
|
606
|
+
return parsed.toLocaleString()
|
|
607
|
+
}
|
|
608
|
+
|
|
579
609
|
function draftOf(state) {
|
|
580
610
|
return {
|
|
581
611
|
id: state.id,
|
|
@@ -587,6 +617,11 @@ try {
|
|
|
587
617
|
// 「恢复出厂提示词」要用它。它不是草稿的一部分,`sameDraft` 不比较它 ——
|
|
588
618
|
// 漏掉这一行按钮会一直置灰(实测:真点了没反应,浏览器验收抓到)。
|
|
589
619
|
factoryPrompt: typeof state.factoryPrompt === "string" ? state.factoryPrompt : null,
|
|
620
|
+
// 改动历史:列表来自 state(只有元数据),正文点「载入这一版」时按需取。
|
|
621
|
+
history: Array.isArray(state.history) ? state.history : [],
|
|
622
|
+
historyPick: "",
|
|
623
|
+
// 「配置了却不生效」的告警码;文案按当前语言渲染。
|
|
624
|
+
warnings: Array.isArray(state.warnings) ? state.warnings : [],
|
|
590
625
|
}
|
|
591
626
|
}
|
|
592
627
|
|
|
@@ -915,6 +950,24 @@ try {
|
|
|
915
950
|
update({ prompt: factory })
|
|
916
951
|
}
|
|
917
952
|
|
|
953
|
+
/** 把某个历史版本载入编辑器。与「恢复出厂」同一条纪律:只改草稿,保存前不落盘。 */
|
|
954
|
+
const loadVersion = async () => {
|
|
955
|
+
const picked = typeof draft.historyPick === "string" ? draft.historyPick : ""
|
|
956
|
+
if (picked === "" || draft.id === undefined) return
|
|
957
|
+
try {
|
|
958
|
+
// 版本键是序号(时间戳会在同一毫秒内撞车 —— 单测就是那样抓到它的)。
|
|
959
|
+
const response = await fetch(ROUTES.history + "?id=" + encodeURIComponent(draft.id) + "&n=" + encodeURIComponent(picked))
|
|
960
|
+
const payload = await asJson(response)
|
|
961
|
+
if (payload === null || payload.ok !== true) {
|
|
962
|
+
setStatus(t("msg.loadFailed"))
|
|
963
|
+
return
|
|
964
|
+
}
|
|
965
|
+
update({ prompt: payload.text })
|
|
966
|
+
} catch (error) {
|
|
967
|
+
setStatus(t("msg.loadFailed") + ":" + describeError(error))
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
918
971
|
const exportPrompt = () => {
|
|
919
972
|
try {
|
|
920
973
|
const blob = new Blob([draft.prompt], { type: "text/markdown;charset=utf-8" })
|
|
@@ -1314,6 +1367,42 @@ try {
|
|
|
1314
1367
|
"aria-label": t("prompt.heading"),
|
|
1315
1368
|
onChange: (event) => update({ prompt: event.target.value }),
|
|
1316
1369
|
}),
|
|
1370
|
+
// 改动历史:谁在什么时候改过。之前这里只有"当前文本",所以会话内的工具
|
|
1371
|
+
// (或手工编辑)改掉提示词时,用户既看不见也回不去。
|
|
1372
|
+
draft.history.length === 0
|
|
1373
|
+
? null
|
|
1374
|
+
: react.createElement(
|
|
1375
|
+
"div",
|
|
1376
|
+
{ className: "cpfe-history" },
|
|
1377
|
+
react.createElement("span", { className: "cpfe-history-label" }, t("history.label")),
|
|
1378
|
+
react.createElement(
|
|
1379
|
+
"select",
|
|
1380
|
+
{
|
|
1381
|
+
value: draft.historyPick,
|
|
1382
|
+
"aria-label": t("history.label"),
|
|
1383
|
+
onChange: (event) => update({ historyPick: event.target.value }),
|
|
1384
|
+
},
|
|
1385
|
+
react.createElement("option", { value: "" }, t("history.pick")),
|
|
1386
|
+
...draft.history.map((entry) =>
|
|
1387
|
+
react.createElement(
|
|
1388
|
+
"option",
|
|
1389
|
+
{ key: String(entry.n), value: String(entry.n) },
|
|
1390
|
+
formatWhen(entry.at) + " · " + t("history.by." + entry.by) + " · " + String(entry.bytes) + " B",
|
|
1391
|
+
),
|
|
1392
|
+
),
|
|
1393
|
+
),
|
|
1394
|
+
react.createElement(
|
|
1395
|
+
A.Button,
|
|
1396
|
+
{
|
|
1397
|
+
variant: "outline",
|
|
1398
|
+
size: "sm",
|
|
1399
|
+
disabled: busy || draft.historyPick === "",
|
|
1400
|
+
onClick: loadVersion,
|
|
1401
|
+
},
|
|
1402
|
+
t("history.load"),
|
|
1403
|
+
),
|
|
1404
|
+
react.createElement("span", { className: "cpfe-history-hint" }, t("history.hint")),
|
|
1405
|
+
),
|
|
1317
1406
|
),
|
|
1318
1407
|
]
|
|
1319
1408
|
: []
|
|
@@ -1323,6 +1412,18 @@ try {
|
|
|
1323
1412
|
{ className: "cpfe" },
|
|
1324
1413
|
assistantList,
|
|
1325
1414
|
react.createElement("p", { className: "cpfe-note" }, t("assistant.switchHint")),
|
|
1415
|
+
// 配了却不生效的项:主动点名,而不是让用户对着"我明明写了"发呆。
|
|
1416
|
+
// `draft` 在没选中任何助手时是 null(列表还没加载完 / 一个都没有)—— 这里必须先守卫,
|
|
1417
|
+
// 否则整块设置页崩掉(实测:浏览器验收当场报 Cannot read properties of null)。
|
|
1418
|
+
draft === null || draft.warnings === undefined || draft.warnings.length === 0
|
|
1419
|
+
? null
|
|
1420
|
+
: react.createElement(
|
|
1421
|
+
"div",
|
|
1422
|
+
{ className: "cpfe-warns" },
|
|
1423
|
+
...draft.warnings.map((code) =>
|
|
1424
|
+
react.createElement("p", { key: code, className: "cpfe-warn" }, "⚠ " + t("warn." + code)),
|
|
1425
|
+
),
|
|
1426
|
+
),
|
|
1326
1427
|
...editorSections,
|
|
1327
1428
|
react.createElement(
|
|
1328
1429
|
"div",
|
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'],
|
|
@@ -208,6 +211,37 @@ function packagedPrompt() {
|
|
|
208
211
|
return packagedPromptCache
|
|
209
212
|
}
|
|
210
213
|
|
|
214
|
+
/**
|
|
215
|
+
* 「配置了,但不生效」的告警码。
|
|
216
|
+
*
|
|
217
|
+
* 这一条借自同生态的 whale-persona:它会主动点名"配了却没起作用"的项。这里对应四种**静默**的
|
|
218
|
+
* 自相矛盾 —— 每一种此前都只会让人以为"我明明写了提示词/描述,怎么没效果":
|
|
219
|
+
*
|
|
220
|
+
* - `personaOffWithPrompt`:身份行被关掉,`prompt.md` 根本不会被注入(最坑的一种);
|
|
221
|
+
* - `toolOff`:`custom_prompt` 工具行关掉 → 会话内改提示词不可用(设置页照旧);
|
|
222
|
+
* - `noDescription`:描述为空 → 新建会话的模式选择器里显示「暂无描述」;
|
|
223
|
+
* - `noName`:名字为空 → 选择器里显示成裸目录 id。
|
|
224
|
+
*
|
|
225
|
+
* 只返回**码**,文案由页面按语言渲染(双语文案的真值源在 locales.mjs 一处)。
|
|
226
|
+
*
|
|
227
|
+
* @param {string} text - the composition text.
|
|
228
|
+
* @param {string} prompt - the prompt file's content.
|
|
229
|
+
* @param {{name?: string, description?: string}} meta - `preset.yml`.
|
|
230
|
+
* @returns {string[]} warning codes, stable order.
|
|
231
|
+
*/
|
|
232
|
+
export function configWarnings(text, prompt, meta) {
|
|
233
|
+
const warnings = []
|
|
234
|
+
const rows = collectRows(text)
|
|
235
|
+
const find = (id) => rows.find((row) => row.id === id)
|
|
236
|
+
if (find('persona')?.disabled === true && typeof prompt === 'string' && prompt.trim() !== '') {
|
|
237
|
+
warnings.push('personaOffWithPrompt')
|
|
238
|
+
}
|
|
239
|
+
if (find('custom-prompt-tool')?.disabled === true) warnings.push('toolOff')
|
|
240
|
+
if (typeof meta?.description !== 'string' || meta.description.trim() === '') warnings.push('noDescription')
|
|
241
|
+
if (typeof meta?.name !== 'string' || meta.name.trim() === '') warnings.push('noName')
|
|
242
|
+
return warnings
|
|
243
|
+
}
|
|
244
|
+
|
|
211
245
|
export function readState(rows, id, options = {}) {
|
|
212
246
|
const directory = assistantDir(rows, id)
|
|
213
247
|
if (directory === undefined) return unknownAssistant(id)
|
|
@@ -217,6 +251,9 @@ export function readState(rows, id, options = {}) {
|
|
|
217
251
|
const mode = modeOf(text)
|
|
218
252
|
const prompt = readPrompt(directory)
|
|
219
253
|
const meta = readPresetMeta(directory)
|
|
254
|
+
// 页面要打开时顺便对账:磁盘上的文本若与日志末条不同,说明它在设置页之外被改过
|
|
255
|
+
// (会话内的 custom_prompt 工具、手工编辑、别处同步)—— 补记一条 external,于是"被改过"看得见。
|
|
256
|
+
if (prompt.ok === true) recordExternalChange(directory, prompt.text)
|
|
220
257
|
return {
|
|
221
258
|
ok: true,
|
|
222
259
|
id,
|
|
@@ -234,6 +271,10 @@ export function readState(rows, id, options = {}) {
|
|
|
234
271
|
// 回退用的出厂文本。它不是"当前值",页面只把它填进编辑器,保存前不落盘 —— 所以
|
|
235
272
|
// 一次误点不会破坏任何东西(重新读取即可丢弃)。
|
|
236
273
|
factoryPrompt: typeof options.factoryPrompt === 'string' ? options.factoryPrompt : null,
|
|
274
|
+
// 改动历史(只有元数据,正文按需取:见 GET /custom-mode/history)。
|
|
275
|
+
history: listHistory(directory),
|
|
276
|
+
// 「配置了却不生效」的告警码(文案在页面侧按语言渲染)。
|
|
277
|
+
warnings: configWarnings(text, prompt.ok === true ? prompt.text : '', meta),
|
|
237
278
|
}
|
|
238
279
|
}
|
|
239
280
|
|
|
@@ -311,6 +352,9 @@ export function saveState(rows, input) {
|
|
|
311
352
|
try {
|
|
312
353
|
writeAtomic(compositionFile(directory), composition)
|
|
313
354
|
writeAtomic(promptFile(directory), prompt)
|
|
355
|
+
// 改动留痕:三个改动路径(设置页 / 会话内工具 / 手工编辑)里,只有设置页是"当场知道"的。
|
|
356
|
+
// 另外两条由 readState 对比补记(见 journal.mjs 的单写者说明)。
|
|
357
|
+
recordPrompt(directory, prompt, HISTORY_SOURCE.settings)
|
|
314
358
|
} catch (error) {
|
|
315
359
|
return { ok: false, error: '写入失败:' + describe(error) }
|
|
316
360
|
}
|
|
@@ -601,6 +645,16 @@ export function apply(ctx) {
|
|
|
601
645
|
return json(readState(await roster(), url.searchParams.get('id') ?? '', { factoryPrompt: packagedPrompt() }))
|
|
602
646
|
}
|
|
603
647
|
|
|
648
|
+
if (request.method === 'GET' && pathname === HISTORY_PATH) {
|
|
649
|
+
await ensureShipped()
|
|
650
|
+
const id = url.searchParams.get('id') ?? ''
|
|
651
|
+
const directory = assistantDir(await roster(), id)
|
|
652
|
+
if (directory === undefined) return json(unknownAssistant(id), 404)
|
|
653
|
+
const text = readVersion(directory, url.searchParams.get('n') ?? '')
|
|
654
|
+
if (text === null) return json({ ok: false, error: '找不到这个版本(历史可能已被上限裁剪)。' }, 404)
|
|
655
|
+
return json({ ok: true, id, n: url.searchParams.get('n'), text })
|
|
656
|
+
}
|
|
657
|
+
|
|
604
658
|
// Backstop on request size. `requestBody: 'buffered'` means the platform applies its own
|
|
605
659
|
// JSON cap, but that cap is the host's configuration, not a contract — measured on Windows,
|
|
606
660
|
// a 5 MB body reached us happily. This keeps one authenticated request from making us buffer
|
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
|
@@ -52,6 +52,17 @@ export const zh = {
|
|
|
52
52
|
'msg.reorderFailed': '调整顺序失败',
|
|
53
53
|
'msg.imported': '已导入到编辑器(还没有保存):检查后点「保存」。',
|
|
54
54
|
'msg.importFailed': '导入失败',
|
|
55
|
+
'warn.personaOffWithPrompt': '「身份(系统提示词)」这一行是关的,所以 prompt.md 不会被注入 —— 你写的提示词现在不起作用。要么打开这一行,要么清空提示词。',
|
|
56
|
+
'warn.toolOff': '「custom_prompt 工具」这一行是关的:会话里无法让 agent 改提示词,只能在本页改。',
|
|
57
|
+
'warn.noDescription': '没有描述:新建会话的模式选择器里会显示成「暂无描述」。',
|
|
58
|
+
'warn.noName': '没有名字:模式选择器里会显示成目录 id(例如 custom)。',
|
|
59
|
+
'history.label': '改动历史',
|
|
60
|
+
'history.pick': '选择要载入的版本…',
|
|
61
|
+
'history.load': '载入这一版',
|
|
62
|
+
'history.hint': '每次保存、以及会话内工具或手工改动,都会在这里留一版;载入只改草稿,保存前不落盘。',
|
|
63
|
+
'history.by.settings': '设置页保存',
|
|
64
|
+
'history.by.external': '会话内/手工改动',
|
|
65
|
+
'msg.loadFailed': '载入这一版失败',
|
|
55
66
|
'msg.importEmpty': '这个文件是空的。',
|
|
56
67
|
'msg.importTooLarge': '文件太大(上限 1MB)。',
|
|
57
68
|
'msg.exported': '已导出为文件。',
|
|
@@ -201,6 +212,17 @@ export const en = {
|
|
|
201
212
|
'msg.reorderFailed': 'Could not reorder',
|
|
202
213
|
'msg.imported': 'Imported into the editor (not saved yet) — review it, then click Save.',
|
|
203
214
|
'msg.importFailed': 'Import failed',
|
|
215
|
+
'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
|
+
'warn.toolOff': 'The "custom_prompt tool" row is off: the agent cannot change the prompt from inside a session, only this page can.',
|
|
217
|
+
'warn.noDescription': 'No description: the new-session mode picker will show it as "no description yet".',
|
|
218
|
+
'warn.noName': 'No name: the mode picker will show the directory id (e.g. custom).',
|
|
219
|
+
'history.label': 'Change history',
|
|
220
|
+
'history.pick': 'Pick a version to load…',
|
|
221
|
+
'history.load': 'Load this version',
|
|
222
|
+
'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.',
|
|
223
|
+
'history.by.settings': 'saved from this page',
|
|
224
|
+
'history.by.external': 'changed in a session / by hand',
|
|
225
|
+
'msg.loadFailed': 'Could not load that version',
|
|
204
226
|
'msg.importEmpty': 'That file is empty.',
|
|
205
227
|
'msg.importTooLarge': 'That file is too large (1 MB limit).',
|
|
206
228
|
'msg.exported': 'Exported to a file.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-custom-mode",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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
|
],
|
package/preset/prompt-tool.mjs
CHANGED
|
@@ -40,6 +40,7 @@ const META_PATH = fileURLToPath(new URL('./preset.yml', import.meta.url))
|
|
|
40
40
|
const MISSING = '(prompt.md 不存在,当前模式会退回上一次成功的提示词文本)'
|
|
41
41
|
|
|
42
42
|
/** Name used when neither the composition nor `preset.yml` supplies one. */
|
|
43
|
+
const TOOL_NAME = 'custom_prompt'
|
|
43
44
|
const FALLBACK_MODE_NAME = '自定义模式'
|
|
44
45
|
|
|
45
46
|
/**
|
|
@@ -201,12 +202,67 @@ function makeDefinition(modeName) {
|
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
/**
|
|
206
|
+
* 自我改提示词的审批闸门。
|
|
207
|
+
*
|
|
208
|
+
* `action: "write"` 会**覆盖整个系统提示词**,而调用它的可能是模型自己 —— 一次提示词注入就足以
|
|
209
|
+
* 让它重写自己的身份。此前这条路径没有任何门:工具直接落盘。
|
|
210
|
+
*
|
|
211
|
+
* 现在它走平台的审批缝:`tools/pre-execute` 是一个 waterfall,返回 `{kind:'ask'}` 会由审批策略决定
|
|
212
|
+
* 怎么处理。**实测(0.1.6-alpha.2,一次性实例)**:
|
|
213
|
+
*
|
|
214
|
+
* - 审批策略为 `ask`(权限预设 `workspace-write`):页面出现「等待审批」行,带这里的 reason,
|
|
215
|
+
* 按钮为「拒绝 / 允许一次」;点「允许一次」后工具真的执行,prompt.md 变成写入的内容;
|
|
216
|
+
* - 审批策略为 `never`(权限预设 `danger-full-access`):**不弹窗,直接判为拒绝**
|
|
217
|
+
* (轨迹里是 `Error: the user rejected tool "custom_prompt"`,文件未被修改)。
|
|
218
|
+
*
|
|
219
|
+
* 也就是说这条闸门**最坏情况是"改不成"而不是"悄悄改成了"** —— 与平台的默认语义一致
|
|
220
|
+
* (类型注释原话:missing approval support turns `ask` into denial)。
|
|
221
|
+
*
|
|
222
|
+
* 只拦 `write`:`read` 不改变任何东西,弹窗只会让人麻木。
|
|
223
|
+
*
|
|
224
|
+
* @param {object} ctx - the preset row's scope.
|
|
225
|
+
* @returns {boolean} whether a gate was registered.
|
|
226
|
+
*/
|
|
227
|
+
function registerApprovalGate(ctx) {
|
|
228
|
+
if (typeof ctx.on !== 'function') return false
|
|
229
|
+
ctx.effect(
|
|
230
|
+
() => ctx.on('tools/pre-execute', (exec, next) => {
|
|
231
|
+
if (exec === null || typeof exec !== 'object' || exec.name !== TOOL_NAME) return next()
|
|
232
|
+
const args = exec.arguments
|
|
233
|
+
const action = args !== null && typeof args === 'object' ? args.action : undefined
|
|
234
|
+
// 只拦写入;未指定 action 时工具按 read 处理,同样不拦。
|
|
235
|
+
if (action !== 'write') return next()
|
|
236
|
+
const text = typeof args.text === 'string' ? args.text : ''
|
|
237
|
+
const firstLine = text.split('\n').find((line) => line.trim() !== '') ?? ''
|
|
238
|
+
return {
|
|
239
|
+
kind: 'ask',
|
|
240
|
+
reason:
|
|
241
|
+
'把「' + resolveModeName(undefined) + '」的系统提示词整体替换为 ' + String(text.length) + ' 字符' +
|
|
242
|
+
(firstLine === '' ? '' : ':' + firstLine.trim().slice(0, 60)) +
|
|
243
|
+
'(写入 ' + PROMPT_PATH + ')',
|
|
244
|
+
}
|
|
245
|
+
}),
|
|
246
|
+
'custom-prompt.approval-gate',
|
|
247
|
+
)
|
|
248
|
+
return true
|
|
249
|
+
}
|
|
250
|
+
|
|
204
251
|
/** The tool registry is a hard dependency; without it there is no tool. */
|
|
205
252
|
export const inject = ['tools']
|
|
206
253
|
|
|
207
254
|
export function apply(ctx, config = {}) {
|
|
208
255
|
const definition = makeDefinition(resolveModeName(config))
|
|
209
256
|
ctx.effect(() => ctx.tools.register(definition), 'custom-prompt.tool')
|
|
257
|
+
|
|
258
|
+
// 审批闸门。宿主若不支持 `tools/pre-execute`(比本插件声明的下限还老的构建),这里会**明确**
|
|
259
|
+
// 说一声再继续 —— 降级是有的,但不许静默。
|
|
260
|
+
if (registerApprovalGate(ctx) !== true) {
|
|
261
|
+
console.error(
|
|
262
|
+
'custom-mode: 这个宿主没有 tools/pre-execute 事件,会话内改写系统提示词的审批闸门**未启用**' +
|
|
263
|
+
'(设置页不受影响)。请升级 DSH,或把「custom_prompt 工具」这一行关掉。',
|
|
264
|
+
)
|
|
265
|
+
}
|
|
210
266
|
}
|
|
211
267
|
|
|
212
268
|
/**
|