dsh-custom-mode 1.9.12 → 1.9.14
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/LICENSE +21 -0
- package/assistants.mjs +20 -3
- package/base-composition.mjs +419 -0
- package/client.js +47 -26
- package/composition.mjs +47 -74
- package/index.mjs +144 -18
- package/locales.mjs +10 -0
- package/meta.mjs +32 -11
- package/package.json +3 -1
- package/preset-backend/declarative.mjs +36 -0
- package/preset-backend/detect.mjs +9 -1
- package/preset-backend/index.mjs +10 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BOWLUNA
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
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
|
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a base mode's composition text comes from — **the third thing the two dsh lines disagree about**.
|
|
3
|
+
*
|
|
4
|
+
* The settings page composes a mode by editing a *shipped* base composition (base mode + row switches).
|
|
5
|
+
* That text has to come from somewhere, and the two dsh lines keep it in different places:
|
|
6
|
+
*
|
|
7
|
+
* | line | where the shipped composition lives |
|
|
8
|
+
* | --- | --- |
|
|
9
|
+
* | 0.1.2 … 0.1.6 | `@deepseek-ai/dsh-agent-presets/presets/<mode>/agent.cordis.yml` (a package that **no longer exists** from 0.1.7) |
|
|
10
|
+
* | 0.1.7 + | inside the host: `agentPresets.readDocument(<mode>).content` — the declared entry-list YAML |
|
|
11
|
+
*
|
|
12
|
+
* At 1.9.12 only the first route existed, so on 0.1.7 every read of a base composition threw
|
|
13
|
+
* (`无法定位出厂基础模式`) and the settings page answered **500**: no rows, no switches, and — worse —
|
|
14
|
+
* the prompt editor never rendered. That is the P0 an adversarial review reproduced on a stock
|
|
15
|
+
* `0.1.7-rc.2` instance (the exact line the official desktop app bundles).
|
|
16
|
+
*
|
|
17
|
+
* ## Resolution order (highest first)
|
|
18
|
+
*
|
|
19
|
+
* 1. `DSH_SHIPPED_PRESETS_DIR` — explicit override, **exclusive**: when set, nothing else is consulted.
|
|
20
|
+
* Tests use it, and an operator who points it somewhere gets exactly that.
|
|
21
|
+
* 2. the text the host handed over (`setBaseCompositions`, filled from `agentPresets.readDocument`).
|
|
22
|
+
* 3. `setShippedPresetsDir`, then Node/profile discovery of the legacy presets package.
|
|
23
|
+
* 4. `DSH_PRESET_PATCH_DIR`, then discovery of `@deepseek-ai/dsh-web-app/presets/<mode>.patch.yml`
|
|
24
|
+
* — the same declaration in its packaged form, for a host whose `readDocument` is unavailable.
|
|
25
|
+
* 5. nothing left → {@link BaseCompositionUnavailableError}, which callers turn into a **visible,
|
|
26
|
+
* typed** state instead of a 500.
|
|
27
|
+
*
|
|
28
|
+
* Why `readDocument` and not `compositionInventory()`: the inventory is **flattened**
|
|
29
|
+
* (`entryId`/`moduleName`/`enabled`/`condition`), so the `isolate` realms a service row must sit inside
|
|
30
|
+
* are gone (measured 2026-09-25; `parse-composition.mjs` rules the same route out for the same reason).
|
|
31
|
+
* `readDocument` returns the declaration as YAML **including** those groups and `!!js` expressions.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
35
|
+
import { createRequire } from 'node:module'
|
|
36
|
+
import { dirname, join } from 'node:path'
|
|
37
|
+
import { fileURLToPath } from 'node:url'
|
|
38
|
+
|
|
39
|
+
import { dshHome } from './paths.mjs'
|
|
40
|
+
|
|
41
|
+
/** Explicit override for the legacy file layout (a directory holding `<mode>/agent.cordis.yml`). */
|
|
42
|
+
export const LEGACY_DIR_ENV = 'DSH_SHIPPED_PRESETS_DIR'
|
|
43
|
+
|
|
44
|
+
/** Explicit override for the declarative file layout (a directory holding `<mode>.patch.yml`). */
|
|
45
|
+
export const PATCH_DIR_ENV = 'DSH_PRESET_PATCH_DIR'
|
|
46
|
+
|
|
47
|
+
/** The base modes a user may build on. Kept here so discovery has no opinion about the UI. */
|
|
48
|
+
export const BASE_MODE_IDS = ['standard', 'ptc', 'minimal', 'cordis']
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Thrown when no route yields a base composition.
|
|
52
|
+
*
|
|
53
|
+
* Carries `code` so the HTTP layer can answer with a typed, localizable result, and `attempts` so the
|
|
54
|
+
* operator sees *which* places were tried instead of a bare failure.
|
|
55
|
+
*/
|
|
56
|
+
export class BaseCompositionUnavailableError extends Error {
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} modeId - the base mode that could not be resolved.
|
|
59
|
+
* @param {string[]} attempts - one line per route tried, in order.
|
|
60
|
+
*/
|
|
61
|
+
constructor(modeId, attempts) {
|
|
62
|
+
super(
|
|
63
|
+
'无法取得基础模式「' + modeId + '」的出厂组成。已尝试:' +
|
|
64
|
+
(attempts.length > 0 ? attempts.join(';') : '(无可用途径)'),
|
|
65
|
+
)
|
|
66
|
+
this.name = 'BaseCompositionUnavailableError'
|
|
67
|
+
this.code = 'baseCompositionUnavailable'
|
|
68
|
+
this.modeId = modeId
|
|
69
|
+
this.attempts = attempts
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether a value is the typed "no base composition anywhere" failure. */
|
|
74
|
+
export function isBaseCompositionUnavailable(error) {
|
|
75
|
+
return error !== null && typeof error === 'object' && error.code === 'baseCompositionUnavailable'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Read one candidate file, reporting a miss instead of throwing. */
|
|
79
|
+
function tryReadFile(file, attempts, label) {
|
|
80
|
+
try {
|
|
81
|
+
const text = readFileSync(file, 'utf8')
|
|
82
|
+
if (typeof text === 'string' && text.trim() !== '') return text
|
|
83
|
+
attempts.push(label + ':文件为空')
|
|
84
|
+
} catch (error) {
|
|
85
|
+
const code = error !== null && typeof error === 'object' ? error.code : undefined
|
|
86
|
+
attempts.push(label + ':' + (code === 'ENOENT' ? '不存在' : String((error && error.message) || error)))
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── route 1 / 3: the legacy file layout ───────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/** Directory injected by the host half (resolved from the roster's system row). */
|
|
94
|
+
let injectedLegacyDir
|
|
95
|
+
|
|
96
|
+
/** Point the resolver at a legacy presets directory discovered at runtime. */
|
|
97
|
+
export function setShippedPresetsDir(dir) {
|
|
98
|
+
if (typeof dir === 'string' && dir !== '') injectedLegacyDir = dir
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Discovery result, cached **only when it succeeded** (a cold miss must stay retryable). */
|
|
102
|
+
let legacyCache
|
|
103
|
+
|
|
104
|
+
/** Resolve `@deepseek-ai/dsh-agent-presets` the two ways that ever worked on the legacy line. */
|
|
105
|
+
function discoverLegacyPresetsDir() {
|
|
106
|
+
const require = createRequire(import.meta.url)
|
|
107
|
+
try {
|
|
108
|
+
const manifest = require.resolve('@deepseek-ai/dsh-agent-presets/package.json')
|
|
109
|
+
return join(dirname(manifest), 'presets')
|
|
110
|
+
} catch {
|
|
111
|
+
/* fall through */
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const fromProfile = createRequire(join(dshHome(), 'profiles', 'package.json'))
|
|
115
|
+
const manifest = fromProfile.resolve('@deepseek-ai/dsh-agent-presets/package.json')
|
|
116
|
+
return join(dirname(manifest), 'presets')
|
|
117
|
+
} catch {
|
|
118
|
+
/* fall through */
|
|
119
|
+
}
|
|
120
|
+
throw new Error(
|
|
121
|
+
'无法定位出厂基础模式(@deepseek-ai/dsh-agent-presets 是否已安装?)。' +
|
|
122
|
+
'0.1.7 起该包不再发布,那条线由 agentPresets.readDocument() 提供同一份声明。',
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The legacy presets directory.
|
|
128
|
+
*
|
|
129
|
+
* `DSH_SHIPPED_PRESETS_DIR` is **exclusive**: an explicit override must not silently fall back to
|
|
130
|
+
* whatever else happens to be installed on the machine (that is how a test would stop testing).
|
|
131
|
+
*
|
|
132
|
+
* @returns {string} absolute directory.
|
|
133
|
+
*/
|
|
134
|
+
export function shippedPresetsDir() {
|
|
135
|
+
const override = process.env[LEGACY_DIR_ENV]
|
|
136
|
+
if (typeof override === 'string' && override !== '') return override
|
|
137
|
+
if (injectedLegacyDir !== undefined) return injectedLegacyDir
|
|
138
|
+
if (legacyCache === undefined) legacyCache = discoverLegacyPresetsDir()
|
|
139
|
+
return legacyCache
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Absolute path of one mode's shipped composition in the legacy layout. */
|
|
143
|
+
export function baseCompositionPath(modeId, directory = shippedPresetsDir()) {
|
|
144
|
+
return join(directory, modeId, 'agent.cordis.yml')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── route 2: the text was handed over by the host (dsh ≥ 0.1.7) ───────────────
|
|
148
|
+
|
|
149
|
+
/** `id → composition text`, as fetched from `agentPresets.readDocument()`. */
|
|
150
|
+
let injectedCompositions
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Hand over the base compositions the host declared.
|
|
154
|
+
*
|
|
155
|
+
* Called on every registry sync, so a host that gains or changes a preset is picked up without a restart.
|
|
156
|
+
* An empty/failed fetch clears the map rather than keeping a stale one — the caller logs the reason.
|
|
157
|
+
*
|
|
158
|
+
* @param {Map<string, string>|Record<string, string>|null} [compositions] - `id → entry-list YAML`.
|
|
159
|
+
*/
|
|
160
|
+
export function setBaseCompositions(compositions) {
|
|
161
|
+
if (compositions === null || compositions === undefined) {
|
|
162
|
+
injectedCompositions = undefined
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
const map = compositions instanceof Map ? compositions : new Map(Object.entries(compositions))
|
|
166
|
+
const clean = new Map()
|
|
167
|
+
for (const [id, text] of map) {
|
|
168
|
+
if (typeof id === 'string' && typeof text === 'string' && text.trim() !== '') clean.set(id, text)
|
|
169
|
+
}
|
|
170
|
+
injectedCompositions = clean.size > 0 ? clean : undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The host-provided text for one mode, when there is one. */
|
|
174
|
+
export function injectedBaseComposition(modeId) {
|
|
175
|
+
return injectedCompositions === undefined ? undefined : injectedCompositions.get(modeId)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Which modes the host handed over — for the startup log and for tests. */
|
|
179
|
+
export function injectedBaseCompositionIds() {
|
|
180
|
+
return injectedCompositions === undefined ? [] : [...injectedCompositions.keys()]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── route 4: the declarative declaration in its packaged form ─────────────────
|
|
184
|
+
|
|
185
|
+
/** Directory injected for the patch-file layout. */
|
|
186
|
+
let injectedPatchDir
|
|
187
|
+
|
|
188
|
+
/** Point the resolver at a directory holding `<mode>.patch.yml`. */
|
|
189
|
+
export function setPatchPresetsDir(dir) {
|
|
190
|
+
if (typeof dir === 'string' && dir !== '') injectedPatchDir = dir
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Discovery result, cached only on success. */
|
|
194
|
+
let patchCache
|
|
195
|
+
|
|
196
|
+
/** `{ prefix, kind }` candidates for a global npm install of dsh, from this process' own node. */
|
|
197
|
+
function executablePrefixes() {
|
|
198
|
+
const out = []
|
|
199
|
+
const dir = dirname(process.execPath)
|
|
200
|
+
// POSIX global install: <prefix>/lib/node_modules/@deepseek-ai/dsh
|
|
201
|
+
out.push(join(dir, '..', 'lib', 'node_modules', '@deepseek-ai', 'dsh'))
|
|
202
|
+
// Windows global install: <prefix>/node_modules/@deepseek-ai/dsh
|
|
203
|
+
out.push(join(dir, 'node_modules', '@deepseek-ai', 'dsh'))
|
|
204
|
+
// A node bundled inside an app (desktop shells): keep the app's own tree reachable.
|
|
205
|
+
out.push(join(dir, '..', 'node_modules', '@deepseek-ai', 'dsh'))
|
|
206
|
+
return out
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** `dsh` on PATH → its install root, so a non-standard prefix still resolves. */
|
|
210
|
+
function pathDiscoveredDshRoots() {
|
|
211
|
+
const out = []
|
|
212
|
+
const entries = String(process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':')
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
if (entry === '') continue
|
|
215
|
+
for (const name of process.platform === 'win32' ? ['dsh.cmd', 'dsh.exe', 'dsh'] : ['dsh']) {
|
|
216
|
+
const candidate = join(entry, name)
|
|
217
|
+
if (!existsSync(candidate)) continue
|
|
218
|
+
out.push(join(entry, '..', 'lib', 'node_modules', '@deepseek-ai', 'dsh'))
|
|
219
|
+
out.push(join(entry, '..', 'node_modules', '@deepseek-ai', 'dsh'))
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return out
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Where `@deepseek-ai/dsh-web-app` ships `presets/<mode>.patch.yml`.
|
|
227
|
+
*
|
|
228
|
+
* Every candidate is verified by looking for a shipped base patch, so a wrong guess costs a stat call
|
|
229
|
+
* instead of a wrong answer.
|
|
230
|
+
*
|
|
231
|
+
* @returns {string|undefined} the presets directory, or undefined when nothing matched.
|
|
232
|
+
*/
|
|
233
|
+
export function discoverPatchPresetsDir() {
|
|
234
|
+
const candidates = []
|
|
235
|
+
// (a) Node resolution from this module: hits when the plugin sits in a tree that can see the host
|
|
236
|
+
// (a source checkout with the host installed beside it, a flat profile, CI).
|
|
237
|
+
for (const spec of ['@deepseek-ai/dsh-web-app/package.json', '@deepseek-ai/dsh/package.json']) {
|
|
238
|
+
try {
|
|
239
|
+
candidates.push(dirname(createRequire(import.meta.url).resolve(spec)))
|
|
240
|
+
} catch {
|
|
241
|
+
/* fall through */
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// (b) Walk up from this module's own directory — the same shape, for trees Node's resolver skips.
|
|
245
|
+
{
|
|
246
|
+
let here = dirname(fileURLToPath(import.meta.url))
|
|
247
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
248
|
+
candidates.push(join(here, 'node_modules', '@deepseek-ai', 'dsh-web-app'))
|
|
249
|
+
candidates.push(join(here, 'node_modules', '@deepseek-ai', 'dsh'))
|
|
250
|
+
here = dirname(here)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// (c) The host's own module tree, reachable from the DSH home when the profile has real deps.
|
|
254
|
+
try {
|
|
255
|
+
const fromProfile = createRequire(join(dshHome(), 'profiles', 'package.json'))
|
|
256
|
+
candidates.push(dirname(fromProfile.resolve('@deepseek-ai/dsh-web-app/package.json')))
|
|
257
|
+
candidates.push(dirname(fromProfile.resolve('@deepseek-ai/dsh/package.json')))
|
|
258
|
+
} catch {
|
|
259
|
+
/* fall through */
|
|
260
|
+
}
|
|
261
|
+
for (const root of [...executablePrefixes(), ...pathDiscoveredDshRoots()]) {
|
|
262
|
+
candidates.push(join(root, 'node_modules', '@deepseek-ai', 'dsh-web-app'))
|
|
263
|
+
candidates.push(root)
|
|
264
|
+
}
|
|
265
|
+
for (const candidate of candidates) {
|
|
266
|
+
const presets = candidate.endsWith('dsh-web-app') ? join(candidate, 'presets') : join(candidate, 'node_modules', '@deepseek-ai', 'dsh-web-app', 'presets')
|
|
267
|
+
if (BASE_MODE_IDS.some((mode) => existsSync(join(presets, mode + '.patch.yml')))) return presets
|
|
268
|
+
}
|
|
269
|
+
return undefined
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The patch-file presets directory.
|
|
274
|
+
*
|
|
275
|
+
* `DSH_PRESET_PATCH_DIR` is exclusive for the same reason as its legacy twin.
|
|
276
|
+
*
|
|
277
|
+
* @returns {string|undefined} undefined when no patch layout is present (not an error by itself).
|
|
278
|
+
*/
|
|
279
|
+
export function patchPresetsDir() {
|
|
280
|
+
const override = process.env[PATCH_DIR_ENV]
|
|
281
|
+
if (typeof override === 'string' && override !== '') return override
|
|
282
|
+
if (injectedPatchDir !== undefined) return injectedPatchDir
|
|
283
|
+
if (patchCache === undefined) {
|
|
284
|
+
const found = discoverPatchPresetsDir()
|
|
285
|
+
if (found !== undefined) patchCache = found
|
|
286
|
+
return found
|
|
287
|
+
}
|
|
288
|
+
return patchCache
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Lift the `plugins:` sequence out of a declarative preset patch.
|
|
293
|
+
*
|
|
294
|
+
* The patch (`@deepseek-ai/dsh-web-app/presets/<mode>.patch.yml`) wraps the same row list this feature
|
|
295
|
+
* composes against:
|
|
296
|
+
*
|
|
297
|
+
* - insert:
|
|
298
|
+
* - id: preset-standard
|
|
299
|
+
* name: '@deepseek-ai/dsh-agent-preset'
|
|
300
|
+
* config:
|
|
301
|
+
* id: standard
|
|
302
|
+
* plugins:
|
|
303
|
+
* - id: persona
|
|
304
|
+
* ...
|
|
305
|
+
*
|
|
306
|
+
* Text surgery, not a YAML round-trip — exactly like the composer it feeds: dedenting the block keeps
|
|
307
|
+
* comments, quoting and `!!js` byte-for-byte, and the result is already in the shape
|
|
308
|
+
* `splitSegments()`/`collectRows()` expect (top-level rows at column 0, nested rows at four spaces).
|
|
309
|
+
*
|
|
310
|
+
* @param {string} text - the patch file's contents.
|
|
311
|
+
* @returns {string|null} entry-list YAML, or null when the file carries no `plugins:` block.
|
|
312
|
+
*/
|
|
313
|
+
export function extractPluginsBlock(text) {
|
|
314
|
+
if (typeof text !== 'string' || text === '') return null
|
|
315
|
+
const lines = text.split('\n')
|
|
316
|
+
const start = lines.findIndex((line) => /^\s*plugins:\s*$/.test(line))
|
|
317
|
+
if (start === -1) return null
|
|
318
|
+
const base = lines[start].search(/\S/)
|
|
319
|
+
const out = []
|
|
320
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
321
|
+
const line = lines[index]
|
|
322
|
+
if (line.trim() === '') {
|
|
323
|
+
out.push('')
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
const indent = line.search(/\S/)
|
|
327
|
+
if (indent <= base) break
|
|
328
|
+
out.push(line.slice(Math.min(base + 2, indent)))
|
|
329
|
+
}
|
|
330
|
+
while (out.length > 0 && out[0].trim() === '') out.shift()
|
|
331
|
+
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop()
|
|
332
|
+
return out.length === 0 ? null : out.join('\n')
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Absolute path of one mode's shipped patch, in the declarative layout. */
|
|
336
|
+
export function basePatchPath(modeId, directory = patchPresetsDir()) {
|
|
337
|
+
return directory === undefined ? undefined : join(directory, modeId + '.patch.yml')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── the chain ────────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Read one base mode's shipped composition, from whichever route this installation has.
|
|
344
|
+
*
|
|
345
|
+
* @param {string} modeId - one of {@link BASE_MODE_IDS}.
|
|
346
|
+
* @returns {{text: string, source: string}} the composition and where it came from (for the log).
|
|
347
|
+
* @throws {BaseCompositionUnavailableError} when no route yields text.
|
|
348
|
+
*/
|
|
349
|
+
export function readBaseCompositionText(modeId) {
|
|
350
|
+
const attempts = []
|
|
351
|
+
|
|
352
|
+
// 1. explicit legacy override: tried first, and it **suppresses discovery** (the point of an explicit
|
|
353
|
+
// override is that the answer cannot wander to whatever else is installed). It does *not* suppress
|
|
354
|
+
// step 2: a host that declares the composition knows better than a stale path in the environment.
|
|
355
|
+
const legacyOverride = process.env[LEGACY_DIR_ENV]
|
|
356
|
+
const legacyExplicit = typeof legacyOverride === 'string' && legacyOverride !== ''
|
|
357
|
+
if (legacyExplicit) {
|
|
358
|
+
const file = join(legacyOverride, modeId, 'agent.cordis.yml')
|
|
359
|
+
const text = tryReadFile(file, attempts, LEGACY_DIR_ENV + '=' + legacyOverride)
|
|
360
|
+
if (text !== null) return { text, source: LEGACY_DIR_ENV }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// 2. text handed over by the host (dsh ≥ 0.1.7): the authoritative answer on that line.
|
|
364
|
+
const injected = injectedBaseComposition(modeId)
|
|
365
|
+
if (injected !== undefined) return { text: injected, source: 'agentPresets.readDocument()' }
|
|
366
|
+
|
|
367
|
+
const patchOverride = process.env[PATCH_DIR_ENV]
|
|
368
|
+
const patchExplicit = typeof patchOverride === 'string' && patchOverride !== ''
|
|
369
|
+
|
|
370
|
+
if (legacyExplicit === false) {
|
|
371
|
+
// 3. injected legacy dir, then discovery
|
|
372
|
+
if (injectedLegacyDir !== undefined) {
|
|
373
|
+
const text = tryReadFile(join(injectedLegacyDir, modeId, 'agent.cordis.yml'), attempts, 'setShippedPresetsDir()')
|
|
374
|
+
if (text !== null) return { text, source: 'agentPresets.list() → presets 目录' }
|
|
375
|
+
} else {
|
|
376
|
+
try {
|
|
377
|
+
const dir = discoverLegacyPresetsDir()
|
|
378
|
+
const text = tryReadFile(join(dir, modeId, 'agent.cordis.yml'), attempts, '@deepseek-ai/dsh-agent-presets')
|
|
379
|
+
if (text !== null) return { text, source: '@deepseek-ai/dsh-agent-presets' }
|
|
380
|
+
} catch (error) {
|
|
381
|
+
attempts.push('@deepseek-ai/dsh-agent-presets:' + String((error && error.message) || error))
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 4. the declarative declaration in its packaged form
|
|
387
|
+
const patchDir = patchExplicit ? patchOverride : patchPresetsDir()
|
|
388
|
+
if (patchDir !== undefined) {
|
|
389
|
+
const file = join(patchDir, modeId + '.patch.yml')
|
|
390
|
+
const raw = tryReadFile(file, attempts, PATCH_DIR_ENV + '/发现 dsh-web-app')
|
|
391
|
+
if (raw !== null) {
|
|
392
|
+
const text = extractPluginsBlock(raw)
|
|
393
|
+
if (text !== null) return { text, source: 'dsh-web-app/presets/' + modeId + '.patch.yml' }
|
|
394
|
+
attempts.push(file + ':没有 plugins: 区块')
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
attempts.push('dsh-web-app/presets:未找到(可用 ' + PATCH_DIR_ENV + ' 指定)')
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
throw new BaseCompositionUnavailableError(modeId, attempts)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** The same chain, one-line description of the outcome — for startup logs. */
|
|
404
|
+
export function describeBaseCompositionSource(modeId) {
|
|
405
|
+
try {
|
|
406
|
+
return readBaseCompositionText(modeId).source
|
|
407
|
+
} catch (error) {
|
|
408
|
+
return '不可用(' + String((error && error.message) || error) + ')'
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Exported for tests: forget every cached discovery result. */
|
|
413
|
+
export function resetBaseCompositionCachesForTests() {
|
|
414
|
+
legacyCache = undefined
|
|
415
|
+
patchCache = undefined
|
|
416
|
+
injectedLegacyDir = undefined
|
|
417
|
+
injectedPatchDir = undefined
|
|
418
|
+
injectedCompositions = undefined
|
|
419
|
+
}
|
package/client.js
CHANGED
|
@@ -86,7 +86,6 @@ try {
|
|
|
86
86
|
"msg.createFailed": "创建失败",
|
|
87
87
|
"msg.deleteFailed": "删除失败",
|
|
88
88
|
"msg.nameRequired": "请先给新助手起个名字。",
|
|
89
|
-
"msg.unsaved": "有未保存的修改",
|
|
90
89
|
"msg.readOnlyHint": "这一页只管理本工具创建的助手;手写的 preset 不在这里,也不会被改写。",
|
|
91
90
|
"msg.reordered": "顺序已保存:新建会话时的模式选择器按这个顺序排列。",
|
|
92
91
|
"msg.reorderFailed": "调整顺序失败",
|
|
@@ -101,6 +100,8 @@ try {
|
|
|
101
100
|
"assistant.short": "每个助手是一个独立模式:自己的系统提示词、基础模式与插件开关。",
|
|
102
101
|
"name.short": "改名只影响显示,内部标识与已有会话不受影响。",
|
|
103
102
|
"mode.short": "底子决定「行集合」与工具能力;persona 行始终由本模式替换。",
|
|
103
|
+
"mode.unavailable": "本机这条 DSH 线没有可读的出厂组成,基础模式暂时不能切换。",
|
|
104
|
+
"rows.unavailable": "出厂组成取不到,插件开关暂时不能编辑;磁盘上原有的行状态仍然生效。",
|
|
104
105
|
"mode.pendingRows": "底子已改为「{mode}」:保存后,下面的行列表会按新底子重算。",
|
|
105
106
|
"rows.short": "逐行控制挂载哪些插件;没拨过的行保持官方默认。",
|
|
106
107
|
"prompt.short": "这段文本就是本模式的系统提示词,保存后下一步生效。",
|
|
@@ -150,8 +151,11 @@ try {
|
|
|
150
151
|
"api.deleteFailed": "删除失败:{detail}",
|
|
151
152
|
"api.versionMissing": "找不到这个版本(历史可能已被上限裁剪)。",
|
|
152
153
|
"api.badJson": "请求体不是合法 JSON",
|
|
154
|
+
"api.savedPromptOnly": "已保存系统提示词(本机取不到基础模式的出厂组成,插件开关与基础模式未改动)。",
|
|
155
|
+
"api.baseCompositionUnavailable": "本机取不到基础模式「{mode}」的出厂组成,无法改动插件开关或基础模式(系统提示词可以单独保存)。",
|
|
153
156
|
"warn.approvalGateMissing": "审批闸门未启用:本机这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词不会弹审批。见「详情」。",
|
|
154
157
|
"warn.unresolvableRows": "有行在本机这条 DSH 线上无法解析:平台会把整个模式判为 broken,并从新会话的选择器里**静默丢弃**。点右侧的「按本线修复」即可(只关掉那几行,其它选择不动)。",
|
|
158
|
+
"warn.baseCompositionUnavailable": "取不到本机这条 DSH 线的出厂组成:基础模式与插件开关暂时不能改(系统提示词不受影响,照常可保存)。已尝试的途径写在宿主日志里。",
|
|
155
159
|
"warn.approvalGateMissing.label": "审批闸门未启用",
|
|
156
160
|
"warn.approvalGateMissing.hint": "这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词**不会**弹审批。设置页不受影响;要恢复保护请升级 DSH,或把「custom_prompt 工具」那一行关掉。",
|
|
157
161
|
"warn.personaOffWithPrompt": "「身份(系统提示词)」这一行是关的,所以 prompt.md 不会被注入 —— 你写的提示词现在不起作用。要么打开这一行,要么清空提示词。",
|
|
@@ -194,6 +198,7 @@ try {
|
|
|
194
198
|
"btn.saving": "处理中…",
|
|
195
199
|
"btn.reload": "重新读取",
|
|
196
200
|
"btn.reloadDiscard": "放弃修改并重新读取",
|
|
201
|
+
"msg.unsaved": "有未保存的修改",
|
|
197
202
|
"msg.loading": "正在读取…",
|
|
198
203
|
"msg.notLoaded": "(尚未读取)",
|
|
199
204
|
"msg.reread": "已重新读取",
|
|
@@ -294,7 +299,6 @@ try {
|
|
|
294
299
|
"msg.createFailed": "Could not create",
|
|
295
300
|
"msg.deleteFailed": "Could not delete",
|
|
296
301
|
"msg.nameRequired": "Give the new assistant a name first.",
|
|
297
|
-
"msg.unsaved": "Unsaved changes",
|
|
298
302
|
"msg.readOnlyHint": "This page manages only the assistants this tool created; a hand-written preset is not listed here and is never rewritten.",
|
|
299
303
|
"msg.reordered": "Order saved: the mode picker for new sessions follows it.",
|
|
300
304
|
"msg.reorderFailed": "Could not reorder",
|
|
@@ -309,6 +313,8 @@ try {
|
|
|
309
313
|
"assistant.short": "Each assistant is its own mode: its own system prompt, base mode and plugin switches.",
|
|
310
314
|
"name.short": "Renaming only changes what is displayed — not the internal id or existing sessions.",
|
|
311
315
|
"mode.short": "The base decides the row set and tool abilities; the persona row is always replaced by this mode.",
|
|
316
|
+
"mode.unavailable": "This DSH line exposes no readable shipped composition, so the base mode cannot be switched right now.",
|
|
317
|
+
"rows.unavailable": "No shipped composition is available, so the plugin switches cannot be edited right now; the row states already on disk still apply.",
|
|
312
318
|
"mode.pendingRows": "Base changed to {mode}: the row list below is recomputed from the new base when you save.",
|
|
313
319
|
"rows.short": "Control which plugins this mode mounts, row by row; untouched rows keep the shipped default.",
|
|
314
320
|
"prompt.short": "Saving this text makes it the system prompt of this mode, and it takes effect on the next step.",
|
|
@@ -358,6 +364,8 @@ try {
|
|
|
358
364
|
"api.deleteFailed": "Delete failed: {detail}",
|
|
359
365
|
"api.versionMissing": "That version is gone (the history is capped).",
|
|
360
366
|
"api.badJson": "The request body is not valid JSON",
|
|
367
|
+
"api.savedPromptOnly": "System prompt saved (this machine exposes no shipped composition, so the plugin switches and the base mode were left untouched).",
|
|
368
|
+
"api.baseCompositionUnavailable": "No shipped composition for base mode \"{mode}\" on this machine, so its plugin switches and base mode cannot be changed (the system prompt can still be saved on its own).",
|
|
361
369
|
"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.",
|
|
362
370
|
"warn.toolOff": "The \"custom_prompt tool\" row is off: the agent cannot change the prompt from inside a session, only this page can.",
|
|
363
371
|
"warn.noDescription": "No description: the new-session mode picker will show it as \"no description yet\".",
|
|
@@ -366,6 +374,7 @@ try {
|
|
|
366
374
|
"warn.approvalGateMissing.label": "Approval gate is off",
|
|
367
375
|
"warn.approvalGateMissing.hint": "This DSH build has no tools/pre-execute event, so in-session prompt rewrites do NOT ask for approval. The settings page is unaffected; upgrade DSH or turn the custom_prompt tool row off to restore the gate.",
|
|
368
376
|
"warn.noName": "No name: the mode picker will show the directory id (e.g. custom).",
|
|
377
|
+
"warn.baseCompositionUnavailable": "This DSH line exposes no shipped composition: the base mode and the plugin switches cannot be changed right now (the system prompt is unaffected and still saves). The routes that were tried are in the host log.",
|
|
369
378
|
"history.label": "Change history",
|
|
370
379
|
"history.pick": "Pick a version to load…",
|
|
371
380
|
"history.load": "Load this version",
|
|
@@ -402,6 +411,7 @@ try {
|
|
|
402
411
|
"btn.saving": "Working…",
|
|
403
412
|
"btn.reload": "Reload",
|
|
404
413
|
"btn.reloadDiscard": "Discard edits and reload",
|
|
414
|
+
"msg.unsaved": "Unsaved changes",
|
|
405
415
|
"msg.loading": "Loading…",
|
|
406
416
|
"msg.notLoaded": "(not loaded)",
|
|
407
417
|
"msg.reread": "Reloaded",
|
|
@@ -770,6 +780,9 @@ try {
|
|
|
770
780
|
return {
|
|
771
781
|
id: state.id,
|
|
772
782
|
mode: state.mode,
|
|
783
|
+
// 出厂组成取不到时的降级标记(服务端给,只读)。它不是用户可改的字段,sameDraft 不比较它。
|
|
784
|
+
baseUnavailable:
|
|
785
|
+
state.baseUnavailable === undefined || state.baseUnavailable === null ? null : state.baseUnavailable,
|
|
773
786
|
overrides: { ...state.overrides },
|
|
774
787
|
prompt: state.prompt,
|
|
775
788
|
name: typeof state.name === "string" ? state.name : "",
|
|
@@ -1673,22 +1686,28 @@ try {
|
|
|
1673
1686
|
onToggleExpand: toggleRowExpanded,
|
|
1674
1687
|
t: t,
|
|
1675
1688
|
}),
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
{
|
|
1683
|
-
|
|
1689
|
+
// 降级态(本机这条线没有可读的出厂组成):不给可点的模式药丸,改说一句为什么 ——
|
|
1690
|
+
// 点了也只会拿到一个服务端错误,那比置灰更糟。
|
|
1691
|
+
draft.baseUnavailable !== null
|
|
1692
|
+
? react.createElement("p", { className: "cpfe-note" }, t("mode.unavailable"))
|
|
1693
|
+
: react.createElement(
|
|
1694
|
+
"div",
|
|
1695
|
+
{ className: "cpfe-pills" },
|
|
1696
|
+
payload.modes.map((mode) =>
|
|
1697
|
+
react.createElement(
|
|
1698
|
+
A.Pill,
|
|
1699
|
+
{ key: mode.id, active: draft.mode === mode.id, onClick: () => pickMode(mode.id) },
|
|
1700
|
+
t("base." + mode.id + ".label", mode.label),
|
|
1701
|
+
),
|
|
1702
|
+
),
|
|
1703
|
+
),
|
|
1704
|
+
draft.baseUnavailable !== null
|
|
1705
|
+
? null
|
|
1706
|
+
: react.createElement(
|
|
1707
|
+
"p",
|
|
1708
|
+
{ className: "cpfe-note" },
|
|
1709
|
+
payload.modes.reduce((note, mode) => (mode.id === draft.mode ? t("base." + mode.id + ".note", mode.note) : note), ""),
|
|
1684
1710
|
),
|
|
1685
|
-
),
|
|
1686
|
-
),
|
|
1687
|
-
react.createElement(
|
|
1688
|
-
"p",
|
|
1689
|
-
{ className: "cpfe-note" },
|
|
1690
|
-
payload.modes.reduce((note, mode) => (mode.id === draft.mode ? t("base." + mode.id + ".note", mode.note) : note), ""),
|
|
1691
|
-
),
|
|
1692
1711
|
// 底子改过但还没保存时明说一句:行列表是按**已保存**的组成渲染的,审阅把它记成了"点了没反应"。
|
|
1693
1712
|
draft.mode !== savedMode
|
|
1694
1713
|
? react.createElement(
|
|
@@ -1710,15 +1729,17 @@ try {
|
|
|
1710
1729
|
onToggleExpand: toggleRowExpanded,
|
|
1711
1730
|
t: t,
|
|
1712
1731
|
}),
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1732
|
+
draft.baseUnavailable !== null
|
|
1733
|
+
? react.createElement("p", { className: "cpfe-note" }, t("rows.unavailable"))
|
|
1734
|
+
: react.createElement(RowList, {
|
|
1735
|
+
expanded: expandedRows,
|
|
1736
|
+
onToggleExpand: toggleRowExpanded,
|
|
1737
|
+
rows: payload.rows,
|
|
1738
|
+
overrides: draft.overrides,
|
|
1739
|
+
onToggle: (id, next) => update({ overrides: { ...draft.overrides, [id]: next } }),
|
|
1740
|
+
depth: 0,
|
|
1741
|
+
t: t,
|
|
1742
|
+
}),
|
|
1722
1743
|
),
|
|
1723
1744
|
react.createElement(
|
|
1724
1745
|
"section",
|