dsh-custom-mode 1.9.13 → 1.9.15
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 +20 -3
- package/client.js +2 -0
- package/index.mjs +45 -13
- package/locales.mjs +2 -0
- package/package.json +1 -1
- package/preset-backend/declarative.mjs +27 -2
package/assistants.mjs
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* file work, so it can be tested without a running harness.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
28
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
29
29
|
import { dirname, join } from 'node:path'
|
|
30
30
|
import { dshHome, PRESET_DIR } from './paths.mjs'
|
|
31
31
|
// `presetMetaPath` 曾被漏掉:reorderAssistant 用它做"快照 → 失败回滚",于是两处调用都抛
|
|
@@ -267,14 +267,31 @@ export function createAssistantDir({ root, id, composition, templateDir }) {
|
|
|
267
267
|
const dir = join(root, id)
|
|
268
268
|
if (existsSync(dir)) return { ok: false, code: 'dirExists', params: { path: dir }, error: `目录已存在:${dir}` }
|
|
269
269
|
|
|
270
|
+
// ★ 失败必须**撤干净**(issue #4)。上面的 existsSync 已经证明这个目录是本函数刚创建的,所以
|
|
271
|
+
// 失败时把它整体删掉是安全的。不这么做的话:用户看到"没建成",磁盘上却留下一个半成品目录,
|
|
272
|
+
// 重启后它作为助手出现在选择器里,而且这个名字被**永久占用**(再建同名直接报"目录已存在")。
|
|
273
|
+
// 删除本身也做失败兜底:删不掉时如实说明"留下了什么",而不是假装干净。
|
|
274
|
+
const rollback = (result) => {
|
|
275
|
+
try {
|
|
276
|
+
rmSync(dir, { recursive: true, force: true })
|
|
277
|
+
return result
|
|
278
|
+
} catch (error) {
|
|
279
|
+
return {
|
|
280
|
+
...result,
|
|
281
|
+
params: { ...result.params, leftover: dir, cleanupFailed: describe(error) },
|
|
282
|
+
error: result.error + ';且未能清理半成品目录 ' + dir + '(' + describe(error) + ')',
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
270
287
|
const seeded = seedPreset(dir, templateDir)
|
|
271
288
|
if (seeded.errors.length > 0) {
|
|
272
|
-
return { ok: false, code: 'seedFailed', params: { detail: seeded.errors.join(';') }, error: '复制模式模板失败:' + seeded.errors.join(';') }
|
|
289
|
+
return rollback({ ok: false, code: 'seedFailed', params: { detail: seeded.errors.join(';') }, error: '复制模式模板失败:' + seeded.errors.join(';') })
|
|
273
290
|
}
|
|
274
291
|
try {
|
|
275
292
|
writeFileSync(join(dir, COMPOSITION_FILE), composition, 'utf8')
|
|
276
293
|
} catch (error) {
|
|
277
|
-
return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入组成文件失败:' + describe(error) }
|
|
294
|
+
return rollback({ ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入组成文件失败:' + describe(error) })
|
|
278
295
|
}
|
|
279
296
|
return { ok: true, id, dir }
|
|
280
297
|
}
|
package/client.js
CHANGED
|
@@ -148,6 +148,7 @@ try {
|
|
|
148
148
|
"api.metaWriteFailed": "写入 preset.yml 失败:{detail}",
|
|
149
149
|
"api.promptReadFailed": "读取提示词失败:{detail}",
|
|
150
150
|
"api.noRemoveApi": "当前 DSH 版本没有 agentPresets.remove(),无法删除。",
|
|
151
|
+
"api.noDirectory": "删除失败:本机找不到这个助手的目录,页面上的列表可能是旧的。刷新一次再试。",
|
|
151
152
|
"api.deleteFailed": "删除失败:{detail}",
|
|
152
153
|
"api.versionMissing": "找不到这个版本(历史可能已被上限裁剪)。",
|
|
153
154
|
"api.badJson": "请求体不是合法 JSON",
|
|
@@ -361,6 +362,7 @@ try {
|
|
|
361
362
|
"api.metaWriteFailed": "Writing preset.yml failed: {detail}",
|
|
362
363
|
"api.promptReadFailed": "Reading the prompt failed: {detail}",
|
|
363
364
|
"api.noRemoveApi": "This DSH build has no agentPresets.remove(), so deleting is unavailable.",
|
|
365
|
+
"api.noDirectory": "Delete failed: this assistant's directory is gone from disk, so the list on screen is stale. Refresh and try again.",
|
|
364
366
|
"api.deleteFailed": "Delete failed: {detail}",
|
|
365
367
|
"api.versionMissing": "That version is gone (the history is capped).",
|
|
366
368
|
"api.badJson": "The request body is not valid JSON",
|
package/index.mjs
CHANGED
|
@@ -402,10 +402,12 @@ function serializedWrite(key, work) {
|
|
|
402
402
|
* 的行集合也就不变,这是所有选项里破坏性最小的一个。
|
|
403
403
|
*
|
|
404
404
|
* @param {string} directory - the assistant's preset directory.
|
|
405
|
-
* @param {{id: string, mode: string, prompt: string, name: string, description: string}} input
|
|
405
|
+
* @param {{id: string, mode: string, prompt: string, name: string, description: string, displayName?: string}} input
|
|
406
|
+
* - the validated save; `name` decides whether `preset.yml` is written, `displayName` is what the response
|
|
407
|
+
* reports (the disk name when the request omitted one — issue #9).
|
|
406
408
|
* @returns {object} the shape `saveState` returns, with `code: 'savedPromptOnly'`.
|
|
407
409
|
*/
|
|
408
|
-
export function savePromptOnly(directory, { id, mode, prompt, name, description }) {
|
|
410
|
+
export function savePromptOnly(directory, { id, mode, prompt, name, description, displayName }) {
|
|
409
411
|
// 与正常保存同一条纪律:提示词与 preset.yml 一起换名,失败则两个都不动。
|
|
410
412
|
let metaText = null
|
|
411
413
|
if (name !== '') {
|
|
@@ -431,7 +433,8 @@ export function savePromptOnly(directory, { id, mode, prompt, name, description
|
|
|
431
433
|
id,
|
|
432
434
|
mode,
|
|
433
435
|
code: 'savedPromptOnly',
|
|
434
|
-
|
|
436
|
+
// 响应里的名字用**磁盘上的那个**(issue #9):请求省略 name 时不该把 params.name 报成裸目录 id。
|
|
437
|
+
params: { name: (displayName === undefined || displayName === '' ? name : displayName) === '' ? id : (displayName === undefined || displayName === '' ? name : displayName), mode },
|
|
435
438
|
note: '已保存系统提示词(本机取不到基础模式的出厂组成,插件开关与基础模式未改动)。新建会话即生效。',
|
|
436
439
|
}
|
|
437
440
|
}
|
|
@@ -462,6 +465,12 @@ export function saveState(rows, input) {
|
|
|
462
465
|
}
|
|
463
466
|
// 请求里**没带** description 时保留原值:API 调用方只改提示词,不该顺手把描述清空(外部评审实测)。
|
|
464
467
|
const meta = readPresetMeta(directory)
|
|
468
|
+
// ★ 请求里没带 name(或页面清空)时,渲染组成仍要用**磁盘上那个名字**(issue #9)。
|
|
469
|
+
// 组成里 custom-prompt-tool 行的 config.modeName 是"会话内工具知道自己在改哪个助手"的唯一凭据;
|
|
470
|
+
// 用空串渲染会把它整块删掉,而 preset.yml 又(有意)保留旧名字 —— 两处就此不一致,
|
|
471
|
+
// 同时成功响应还会把 params.name 报成裸目录 id。请求**没带** name 与"用户把名字清空"在这里
|
|
472
|
+
// 是同一个语义:保持现状,而不是把它抹掉。
|
|
473
|
+
const effectiveName = name !== '' ? name : typeof meta.name === 'string' ? meta.name : ''
|
|
465
474
|
const rawDescription =
|
|
466
475
|
input !== null && typeof input === 'object' && typeof input.description === 'string'
|
|
467
476
|
? input.description
|
|
@@ -481,14 +490,14 @@ export function saveState(rows, input) {
|
|
|
481
490
|
|
|
482
491
|
let composition
|
|
483
492
|
try {
|
|
484
|
-
composition = renderComposition(mode, overrides, { modeName:
|
|
493
|
+
composition = renderComposition(mode, overrides, { modeName: effectiveName, assistantId: id })
|
|
485
494
|
} catch (error) {
|
|
486
495
|
// 拿不到出厂组成:这一页**降级**但不能全废 —— 提示词仍可单独保存(组成文件原样不动)。
|
|
487
496
|
// 页面在降级态下把基础模式与开关都设为不可编辑,所以开关集合必为空;HTTP API 直接调用
|
|
488
497
|
// 还想改开关时明确拒绝,而不是悄悄丢掉用户的意图。
|
|
489
498
|
if (isBaseCompositionUnavailable(error)) {
|
|
490
499
|
if (overrides.size === 0 && existsSync(compositionFile(directory))) {
|
|
491
|
-
return savePromptOnly(directory, { id, mode, prompt, name, description })
|
|
500
|
+
return savePromptOnly(directory, { id, mode, prompt, name, description, displayName: effectiveName })
|
|
492
501
|
}
|
|
493
502
|
return {
|
|
494
503
|
ok: false,
|
|
@@ -547,7 +556,7 @@ export function saveState(rows, input) {
|
|
|
547
556
|
id,
|
|
548
557
|
mode,
|
|
549
558
|
code: 'saved',
|
|
550
|
-
params: { name:
|
|
559
|
+
params: { name: effectiveName === '' ? id : effectiveName, mode },
|
|
551
560
|
note: '已保存(' + (name === '' ? id : name) + ',基础模式 ' + mode + ')。新建会话即生效,当前会话保持原配置。',
|
|
552
561
|
}
|
|
553
562
|
}
|
|
@@ -724,11 +733,14 @@ export function repairComposition(rows, input) {
|
|
|
724
733
|
}
|
|
725
734
|
}
|
|
726
735
|
|
|
727
|
-
export async function deleteAssistant(rows, input,
|
|
736
|
+
export async function deleteAssistant(rows, input, remover) {
|
|
728
737
|
const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
|
|
729
|
-
|
|
730
|
-
if (
|
|
731
|
-
|
|
738
|
+
const directory = assistantDir(rows, id)
|
|
739
|
+
if (directory === undefined) return unknownAssistant(id)
|
|
740
|
+
// `remover` 是**按后端选出来的**删除入口(见 {@link presetRemover}):旧线是平台的
|
|
741
|
+
// `agentPresets.remove()`,新线是声明式后端的 dispose + 删目录。两边都没有时才是真的做不到。
|
|
742
|
+
if (remover === undefined || remover === null || typeof remover.remove !== 'function') {
|
|
743
|
+
return { ok: false, code: 'noRemoveApi', error: '当前 DSH 版本没有可用的删除入口(agentPresets.remove() 与声明式后端都不可用),无法删除。' }
|
|
732
744
|
}
|
|
733
745
|
// 文案用**显示名**,不用内部目录 id(复制/删除的状态行曾把 id 暴露给用户,审阅点名)。
|
|
734
746
|
const displayName = (() => {
|
|
@@ -736,9 +748,17 @@ export async function deleteAssistant(rows, input, agentPresets) {
|
|
|
736
748
|
return typeof row?.name === 'string' && row.name.trim() !== '' ? row.name : id
|
|
737
749
|
})()
|
|
738
750
|
try {
|
|
739
|
-
await
|
|
751
|
+
const outcome = await remover.remove(id, directory)
|
|
752
|
+
if (outcome !== null && typeof outcome === 'object' && outcome.ok === false) {
|
|
753
|
+
// 字面量 code(不用三元表达式):test/editor-route.test.mjs 会扫 `code:` 后面的字符串字面量,
|
|
754
|
+
// 要求每个都在词典里中英各一条 —— 三元里的 'string' 会被它当成一个 code。
|
|
755
|
+
if (outcome.code === 'noDirectory') {
|
|
756
|
+
return { ok: false, code: 'noDirectory', params: { path: directory }, error: '删除失败:找不到「' + displayName + '」的目录。' }
|
|
757
|
+
}
|
|
758
|
+
return { ok: false, code: 'deleteFailed', params: { detail: describe(outcome.code ?? 'unknown'), path: directory }, error: '删除失败:' + describe(outcome.code ?? 'unknown') }
|
|
759
|
+
}
|
|
740
760
|
} catch (error) {
|
|
741
|
-
return { ok: false, code: 'deleteFailed', params: { detail: describe(error) }, error: '删除失败:' + describe(error) }
|
|
761
|
+
return { ok: false, code: 'deleteFailed', params: { detail: describe(error), path: directory }, error: '删除失败:' + describe(error) }
|
|
742
762
|
}
|
|
743
763
|
return { ok: true, id, code: 'deleted', params: { name: displayName }, note: '已删除「' + displayName + '」。正在使用它的会话不受影响;新建会话时不再出现。' }
|
|
744
764
|
}
|
|
@@ -903,6 +923,18 @@ export function apply(ctx) {
|
|
|
903
923
|
? createDeclarativeBackend({ scope, log: console.log, warn: console.error })
|
|
904
924
|
: null
|
|
905
925
|
|
|
926
|
+
/**
|
|
927
|
+
* 删除入口,按后端选。新线上服务**没有** `remove()`(唯一手段是 `register()` 的 disposer),
|
|
928
|
+
* 所以由声明式后端自己 dispose + 删目录;旧线继续用平台的文件系统语义。
|
|
929
|
+
*
|
|
930
|
+
* 这里返回 `undefined` 只发生在"两套都没有"的线上,那时才允许报 `noRemoveApi`。
|
|
931
|
+
*/
|
|
932
|
+
const presetRemover = () => {
|
|
933
|
+
if (declarative !== null) return { remove: (id, dir) => declarative.remove(id, dir) }
|
|
934
|
+
if (typeof scope.agentPresets?.remove === 'function') return { remove: (id) => scope.agentPresets.remove(id) }
|
|
935
|
+
return undefined
|
|
936
|
+
}
|
|
937
|
+
|
|
906
938
|
/** 原始 roster:只读、无副作用。同步逻辑走它,避免与 {@link roster} 互相递归。 */
|
|
907
939
|
const rawRoster = async () => {
|
|
908
940
|
const rows = await scope.agentPresets.list()
|
|
@@ -1125,7 +1157,7 @@ export function apply(ctx) {
|
|
|
1125
1157
|
const result = await serializedWrite('tree', async () => reorderAssistant(await roster(), parsed))
|
|
1126
1158
|
return json(result, result.ok === true ? 200 : 400)
|
|
1127
1159
|
}
|
|
1128
|
-
const result = await serializedWrite('delete:' + targetId, async () => deleteAssistant(await roster(), parsed,
|
|
1160
|
+
const result = await serializedWrite('delete:' + targetId, async () => deleteAssistant(await roster(), parsed, presetRemover()))
|
|
1129
1161
|
return json(result, result.ok === true ? 200 : 400)
|
|
1130
1162
|
} catch (error) {
|
|
1131
1163
|
// 兜底也要带 code:这是**意料之外**的异常,页面拿到的是 Node 的原始错误串,方向反着也一样糟
|
package/locales.mjs
CHANGED
|
@@ -108,6 +108,7 @@ export const zh = {
|
|
|
108
108
|
'api.metaWriteFailed': '写入 preset.yml 失败:{detail}',
|
|
109
109
|
'api.promptReadFailed': '读取提示词失败:{detail}',
|
|
110
110
|
'api.noRemoveApi': '当前 DSH 版本没有 agentPresets.remove(),无法删除。',
|
|
111
|
+
'api.noDirectory': '删除失败:本机找不到这个助手的目录,页面上的列表可能是旧的。刷新一次再试。',
|
|
111
112
|
'api.deleteFailed': '删除失败:{detail}',
|
|
112
113
|
'api.versionMissing': '找不到这个版本(历史可能已被上限裁剪)。',
|
|
113
114
|
'api.badJson': '请求体不是合法 JSON',
|
|
@@ -339,6 +340,7 @@ export const en = {
|
|
|
339
340
|
'api.metaWriteFailed': 'Writing preset.yml failed: {detail}',
|
|
340
341
|
'api.promptReadFailed': 'Reading the prompt failed: {detail}',
|
|
341
342
|
'api.noRemoveApi': 'This DSH build has no agentPresets.remove(), so deleting is unavailable.',
|
|
343
|
+
'api.noDirectory': "Delete failed: this assistant's directory is gone from disk, so the list on screen is stale. Refresh and try again.",
|
|
342
344
|
'api.deleteFailed': 'Delete failed: {detail}',
|
|
343
345
|
'api.versionMissing': 'That version is gone (the history is capped).',
|
|
344
346
|
'api.badJson': 'The request body is not valid JSON',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-custom-mode",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.15",
|
|
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), pick its base mode, switch plugin rows one by one, and keep several assistants side by side — coding, chat or roleplay (RP) personas (multi-mode / multi-agent).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* 相对路径;旧线上它相对 preset 目录解析,而 `register()` 在 profile 的 baseUrl 下挂载。
|
|
13
13
|
* 实测:相对路径 → `broken: … never started`;绝对 `file://` URL → 正常 mount。
|
|
14
14
|
* ⇒ `toPluginRows()` 必须把相对说明符改写成绝对 URL。
|
|
15
|
-
* 2. **`register()` 的返回值是 disposer**,注销唯一手段;服务上没有 `remove()
|
|
15
|
+
* 2. **`register()` 的返回值是 disposer**,注销唯一手段;服务上没有 `remove()` —— 所以"删除助手"
|
|
16
|
+
* 由本后端的 {@link remove} 自己做(dispose + 删目录),宿主那半不再要求平台提供 `remove()`。
|
|
16
17
|
* 3. **同一个 id 重复注册会抛 `Duplicate agent preset`**
|
|
17
18
|
* ⇒ 重新挂载前必须先 dispose 旧的。
|
|
18
19
|
*
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
* `agent.cordis.yml` 仍然写盘 —— 它是用户可见、可迁移、可手改的真相,也是旧线唯一的输入。
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
26
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
26
27
|
import { join } from 'node:path'
|
|
27
28
|
import { pathToFileURL } from 'node:url'
|
|
28
29
|
import { parseComposition } from './parse-composition.mjs'
|
|
@@ -163,6 +164,29 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
|
|
|
163
164
|
for (const id of [...mounted.keys()]) await unmount(id)
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
/**
|
|
168
|
+
* 删除一个助手:**先注销,再删目录**。
|
|
169
|
+
*
|
|
170
|
+
* 这条线上服务没有 `remove()`(唯一手段是 `register()` 返回的 disposer),所以"删除"必须由本插件
|
|
171
|
+
* 自己完成 —— 而这正是设置页在 0.1.7 上**根本删不掉助手**的原因:宿主那半只认 `agentPresets.remove()`,
|
|
172
|
+
* 拿不到就回一个类型化的 `noRemoveApi`,页面弹完确认框只显示"删除失败"。
|
|
173
|
+
*
|
|
174
|
+
* 顺序不能反:先注销,选择器立刻不再提供它;再删目录。删目录失败时**如实抛出**(调用方转成
|
|
175
|
+
* `deleteFailed` 并把路径带给用户),而不是报告"已删除"却把目录留在磁盘上 —— 那样下次同步
|
|
176
|
+
* 会把它重新挂回来,用户看到的是"删了又回来了"。
|
|
177
|
+
*
|
|
178
|
+
* @param {string} id - assistant id.
|
|
179
|
+
* @param {string} [dir] - its directory; when omitted, the one remembered at mount time is used.
|
|
180
|
+
*/
|
|
181
|
+
async function remove(id, dir) {
|
|
182
|
+
const remembered = mounted.get(id)?.dir
|
|
183
|
+
await unmount(id)
|
|
184
|
+
const target = typeof dir === 'string' && dir !== '' ? dir : remembered
|
|
185
|
+
if (typeof target !== 'string' || target === '') return { ok: false, code: 'noDirectory', id }
|
|
186
|
+
rmSync(target, { recursive: true, force: true })
|
|
187
|
+
return { ok: true, id, dir: target }
|
|
188
|
+
}
|
|
189
|
+
|
|
166
190
|
/**
|
|
167
191
|
* Fetch the shipped base compositions the host declares, as entry-list YAML.
|
|
168
192
|
*
|
|
@@ -205,6 +229,7 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
|
|
|
205
229
|
sync,
|
|
206
230
|
mountOne,
|
|
207
231
|
unmount,
|
|
232
|
+
remove,
|
|
208
233
|
disposeAll,
|
|
209
234
|
fetchBaseCompositions,
|
|
210
235
|
mountedIds: () => [...mounted.keys()],
|