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 +36 -2
- package/index.mjs +62 -8
- package/locales.mjs +6 -2
- package/package.json +1 -1
- package/preset/prompt-tool.mjs +18 -2
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
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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",
|
package/preset/prompt-tool.mjs
CHANGED
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
* needs no isolate realm.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import {
|
|
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
|
-
|
|
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
|
+
}
|