dsh-custom-mode 1.0.1 → 1.0.3
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 +9 -7
- package/composition.mjs +30 -8
- package/index.mjs +110 -163
- package/locales.mjs +6 -6
- package/package.json +3 -3
package/client.js
CHANGED
|
@@ -45,7 +45,9 @@ try {
|
|
|
45
45
|
|
|
46
46
|
const react = require("react")
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// 路由注册在平台的共享 `/api` 频道上(由载体在分发前施加信任与鉴权),
|
|
49
|
+
// 所以这里的路径必须带 `/api` 前缀。
|
|
50
|
+
const ROUTE = "/api/custom-mode"
|
|
49
51
|
|
|
50
52
|
const NS = "settings.customMode"
|
|
51
53
|
|
|
@@ -103,11 +105,11 @@ try {
|
|
|
103
105
|
"name.placeholder": "自定义模式",
|
|
104
106
|
"name.descriptionPlaceholder": "模式描述(显示在模式选择器里,可留空)",
|
|
105
107
|
"mode.heading": "基础模式",
|
|
106
|
-
"mode.hint": "
|
|
108
|
+
"mode.hint": "选一个官方模式作为底子,下面再按行微调。注意:底子只决定**行集合**与工具能力 —— 本模式的 persona 行始终替换掉底子那一行(提示词由你编辑,complete: false),底子的提示词语义不会被继承。改完保存后,新建会话即生效,不需要重启。",
|
|
107
109
|
"rows.heading": "插件开关",
|
|
108
110
|
"rows.hint": "逐行控制这个模式挂载哪些插件,和官方插件列表一样按行铺开。没拨过的行保持官方默认(含平台判断);你手动拨了就以你的为准。",
|
|
109
111
|
"prompt.heading": "系统提示词",
|
|
110
|
-
"prompt.hint": "
|
|
112
|
+
"prompt.hint": "这段文本在每个模型调用前重新读取,所以保存后下一步即生效,且只影响使用这个助手的会话。「导入」把文件读进编辑器(未保存前不写入任何东西);「导出」把当前文本存成 .md 文件。",
|
|
111
113
|
"status.enabled": "已启用",
|
|
112
114
|
"status.disabled": "已停用",
|
|
113
115
|
"status.changed": "已改",
|
|
@@ -181,7 +183,7 @@ try {
|
|
|
181
183
|
"base.ptc.label": "PTC 模式",
|
|
182
184
|
"base.ptc.note": "在标准模式基础上启用 PTC 工具呈现(tool-presentation)",
|
|
183
185
|
"base.minimal.label": "极简模式",
|
|
184
|
-
"base.minimal.note": "只有 Shell 与终端,共 7
|
|
186
|
+
"base.minimal.note": "只有 Shell 与终端,共 7 行;没有文件、检索、技能、子代理。系统提示词不是极简那一套(persona 行被本模式替换)",
|
|
185
187
|
"base.cordis.label": "Cordis 模式",
|
|
186
188
|
"base.cordis.note": "标准模式 + 读写运行时的 Cordis 工具集,可让 agent 自己改 harness"
|
|
187
189
|
}
|
|
@@ -233,11 +235,11 @@ try {
|
|
|
233
235
|
"name.placeholder": "Custom mode",
|
|
234
236
|
"name.descriptionPlaceholder": "Mode description (shown in the mode picker, optional)",
|
|
235
237
|
"mode.heading": "Base mode",
|
|
236
|
-
"mode.hint": "
|
|
238
|
+
"mode.hint": "Pick an official mode as the base, then adjust it row by row. Note: the base only decides which rows exist — this mode always replaces the base's persona row with its own reader (complete: false), so the base's prompt semantics are not inherited. A save applies to the next new session; no restart needed.",
|
|
237
239
|
"rows.heading": "Plugin switches",
|
|
238
240
|
"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.",
|
|
239
241
|
"prompt.heading": "System prompt",
|
|
240
|
-
"prompt.hint": "This text is re-read before every model call, so a save applies on the next step
|
|
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.",
|
|
241
243
|
"status.enabled": "Enabled",
|
|
242
244
|
"status.disabled": "Disabled",
|
|
243
245
|
"status.changed": "changed",
|
|
@@ -311,7 +313,7 @@ try {
|
|
|
311
313
|
"base.ptc.label": "PTC",
|
|
312
314
|
"base.ptc.note": "Standard plus PTC tool presentation (tool-presentation)",
|
|
313
315
|
"base.minimal.label": "Minimal",
|
|
314
|
-
"base.minimal.note": "Shell and terminal only, 7 rows; no files, search, skills or subagents",
|
|
316
|
+
"base.minimal.note": "Shell and terminal only, 7 rows; no files, search, skills or subagents. The system prompt is not minimal's either (this mode replaces the persona row)",
|
|
315
317
|
"base.cordis.label": "Cordis",
|
|
316
318
|
"base.cordis.note": "Standard plus the Cordis toolset, letting the agent modify its own harness"
|
|
317
319
|
}
|
package/composition.mjs
CHANGED
|
@@ -297,10 +297,16 @@ function setDisabled(segmentText, disabled) {
|
|
|
297
297
|
const own = disabledLineAt(indent)
|
|
298
298
|
const index = lines.findIndex((line) => own.test(line))
|
|
299
299
|
if (index !== -1) {
|
|
300
|
+
const shipped = lines[index].trim().replace(/^disabled:\s*/, '')
|
|
300
301
|
if (disabled) {
|
|
301
302
|
lines[index] = `${' '.repeat(indent)}disabled: true`
|
|
303
|
+
} else if (shipped.startsWith('!!js') && evalDisabledExpression(shipped) !== true) {
|
|
304
|
+
// 显式打开,但出厂那行是平台表达式、且它在本机求值就是"开":这不是覆盖,而是**撤销覆盖** ——
|
|
305
|
+
// 保持出厂表达式原样,这一行就回到"跟随平台"。(原先这里删掉整行,结果是文件既不是出厂原样、
|
|
306
|
+
// 也不是显式覆盖,页面却显示"未拨过" —— 文件与显示同时失真。)
|
|
307
|
+
return segmentText
|
|
302
308
|
} else {
|
|
303
|
-
lines.
|
|
309
|
+
lines[index] = `${' '.repeat(indent)}disabled: false`
|
|
304
310
|
}
|
|
305
311
|
return lines.join('\n')
|
|
306
312
|
}
|
|
@@ -344,7 +350,8 @@ function describeRow(id, segmentText, children) {
|
|
|
344
350
|
const meta = ROW_META[id] ?? {}
|
|
345
351
|
// A literal `disabled: true` is off. A `!!js` predicate is resolved for THIS
|
|
346
352
|
// machine, so the page shows the state actually in force rather than "has a key".
|
|
347
|
-
|
|
353
|
+
// 只看"有没有 disabled 键"是不够的:`disabled: false` 也是字面量,但它表示**开着**。
|
|
354
|
+
const literalOff = own.present && !own.value.startsWith('!!js') && own.value === 'true'
|
|
348
355
|
const fromExpression = own.present ? evalDisabledExpression(own.value) : undefined
|
|
349
356
|
return {
|
|
350
357
|
id,
|
|
@@ -564,6 +571,15 @@ function flattenRows(rows, into = new Map()) {
|
|
|
564
571
|
return into
|
|
565
572
|
}
|
|
566
573
|
|
|
574
|
+
/** 同 {@link flattenRows},但保留整行对象:判断"未触碰"要看**文本形态**,不只是求值结果。 */
|
|
575
|
+
function flattenRowObjects(rows, into = new Map()) {
|
|
576
|
+
for (const row of rows) {
|
|
577
|
+
into.set(row.id, row)
|
|
578
|
+
flattenRowObjects(row.children, into)
|
|
579
|
+
}
|
|
580
|
+
return into
|
|
581
|
+
}
|
|
582
|
+
|
|
567
583
|
/**
|
|
568
584
|
* Derive the explicit overrides that turned `base` into `text`.
|
|
569
585
|
*
|
|
@@ -578,13 +594,19 @@ function flattenRows(rows, into = new Map()) {
|
|
|
578
594
|
* @returns {Record<string, boolean>} row id -> enabled.
|
|
579
595
|
*/
|
|
580
596
|
export function overridesOf(text, modeId) {
|
|
581
|
-
const base =
|
|
582
|
-
for (const extra of EXTRA_ROWS) base.set(extra.id, false)
|
|
583
|
-
const current =
|
|
597
|
+
const base = flattenRowObjects(collectRows(readBaseComposition(modeId)))
|
|
598
|
+
for (const extra of EXTRA_ROWS) base.set(extra.id, { id: extra.id, disabled: false, disabledExpression: null })
|
|
599
|
+
const current = flattenRowObjects(collectRows(text))
|
|
584
600
|
const overrides = {}
|
|
585
|
-
for (const [id,
|
|
586
|
-
|
|
587
|
-
|
|
601
|
+
for (const [id, row] of current) {
|
|
602
|
+
const baseRow = base.get(id)
|
|
603
|
+
// "未触碰"的判据是**文本形态相同**:出厂行可能是 `!!js` 平台表达式,而它在本机的求值结果恰好与
|
|
604
|
+
// 某个字面量一致 —— 只看求值结果,会把"表达式被换成字面量"误判成"没动过"。
|
|
605
|
+
const sameForm = baseRow !== undefined
|
|
606
|
+
&& baseRow.disabled === row.disabled
|
|
607
|
+
&& baseRow.disabledExpression === row.disabledExpression
|
|
608
|
+
if (sameForm) continue
|
|
609
|
+
overrides[id] = row.disabled !== true
|
|
588
610
|
}
|
|
589
611
|
return overrides
|
|
590
612
|
}
|
package/index.mjs
CHANGED
|
@@ -1,45 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Host half: the 「自定义模式」 settings page —
|
|
2
|
+
* Host half: the 「自定义模式」 settings page — an ASSISTANT MANAGER.
|
|
3
3
|
*
|
|
4
|
-
* It
|
|
4
|
+
* It registers five exact routes on the platform's shared `/api` channel; the browser half calls them
|
|
5
5
|
* with `fetch`:
|
|
6
6
|
*
|
|
7
|
-
* GET /custom-mode — every assistant this feature manages.
|
|
8
|
-
* GET /custom-mode/state?id=… — one assistant: base mode, rows, switches, prompt.
|
|
9
|
-
* POST /custom-mode/state — { id, mode, overrides, prompt, name, description }:
|
|
10
|
-
*
|
|
11
|
-
* POST /custom-mode/create — { name, description }: seed a new assistant from the
|
|
12
|
-
*
|
|
13
|
-
* POST /custom-mode/delete — { id }: remove a locally authored assistant.
|
|
14
|
-
* POST /custom-mode/reorder — { id, direction }: move one assistant up/down in the
|
|
15
|
-
*
|
|
7
|
+
* GET /api/custom-mode — every assistant this feature manages.
|
|
8
|
+
* GET /api/custom-mode/state?id=… — one assistant: base mode, rows, switches, prompt.
|
|
9
|
+
* POST /api/custom-mode/state — { id, mode, overrides, prompt, name, description }:
|
|
10
|
+
* validate, render a fresh `agent.cordis.yml`, write both files.
|
|
11
|
+
* POST /api/custom-mode/create — { name, description }: seed a new assistant from the
|
|
12
|
+
* packaged template and give it the standard base mode.
|
|
13
|
+
* POST /api/custom-mode/delete — { id }: remove a locally authored assistant.
|
|
14
|
+
* POST /api/custom-mode/reorder — { id, direction }: move one assistant up/down in the
|
|
15
|
+
* picker order by writing `order` into each `preset.yml`.
|
|
16
16
|
*
|
|
17
|
-
* Why
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* **Why `/api` and not the raw `webServer` table**: the carrier that owns the `/api` channel applies the
|
|
18
|
+
* platform's trust and authentication policy — loopback/`trustedHosts` Host check, `Sec-Fetch-Site`,
|
|
19
|
+
* `Origin`, and the signed browser-session cookie — *before* dispatching to a route. Registering there
|
|
20
|
+
* makes the fence part of the structure: a route cannot exist without it. Registering on the raw table
|
|
21
|
+
* instead puts the route outside that policy and leaves "check the request first" as a rule a human has
|
|
22
|
+
* to remember — and an earlier version of this plugin, doing exactly that, let an unauthenticated GET
|
|
23
|
+
* read the whole system prompt and an unauthenticated cross-site POST rewrite `prompt.md` (every official
|
|
24
|
+
* route answered 401; a plain form post needs no preflight, so CORS would not have helped either). The
|
|
25
|
+
* registered paths are absolute *including* `/api`, which is what the platform's own packages pass.
|
|
21
26
|
*
|
|
22
|
-
* Why
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* releases (see docs/ARCHITECTURE.md).
|
|
27
|
+
* **Why exact routes rather than one prefix**: the Fetch registry matches exact paths. Five registrations
|
|
28
|
+
* cost nothing and each declares the methods it owns, so the method table doubles as the guarantee that a
|
|
29
|
+
* prefetched `GET …/create` cannot create anything — that method is simply not registered for that path.
|
|
26
30
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* POST with `content-type: text/plain` rewrote `prompt.md`, while every official
|
|
31
|
-
* route answered 401 — and a cross-site form post needs no preflight, so any page
|
|
32
|
-
* the user visited could have rewritten their agent's system prompt. The route
|
|
33
|
-
* therefore runs the platform's own check first, via
|
|
34
|
-
* `ctx.connection.requestRejection(req)` (Host/Origin fence + browser auth),
|
|
35
|
-
* which is the same verdict `/api` gets.
|
|
31
|
+
* Why a private route instead of a Remote namespace or `dsh-settings`: this plugin then owns no Cordis
|
|
32
|
+
* service name and cannot collide with anything, and it stays independent of the settings API whose helper
|
|
33
|
+
* names differ between dsh releases (see docs/ARCHITECTURE.md §5).
|
|
36
34
|
*
|
|
37
35
|
* Storage is deliberately file-only and stateless: the composition file IS the
|
|
38
36
|
* saved state, so no second document can drift from it. The page derives which
|
|
39
37
|
* rows the user changed by diffing against the same shipped base mode.
|
|
40
38
|
*/
|
|
41
39
|
|
|
42
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
40
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
43
41
|
import { dirname, join } from 'node:path'
|
|
44
42
|
import { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, PRESET_DIR } from './paths.mjs'
|
|
45
43
|
import {
|
|
@@ -64,7 +62,7 @@ import {
|
|
|
64
62
|
userPresetRoot,
|
|
65
63
|
} from './assistants.mjs'
|
|
66
64
|
|
|
67
|
-
export { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, PRESET_DIR, PRESET_META_PATH }
|
|
65
|
+
export { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, API_PREFIX, PRESET_DIR, PRESET_META_PATH }
|
|
68
66
|
|
|
69
67
|
/** The list endpoint is the route path itself; the rest hang off it. */
|
|
70
68
|
const STATE_PATH = ROUTE_PATH + '/state'
|
|
@@ -282,8 +280,8 @@ export function saveState(rows, input) {
|
|
|
282
280
|
}
|
|
283
281
|
|
|
284
282
|
try {
|
|
285
|
-
|
|
286
|
-
|
|
283
|
+
writeAtomic(compositionFile(directory), composition)
|
|
284
|
+
writeAtomic(promptFile(directory), prompt)
|
|
287
285
|
} catch (error) {
|
|
288
286
|
return { ok: false, error: '写入失败:' + describe(error) }
|
|
289
287
|
}
|
|
@@ -380,7 +378,7 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
|
|
|
380
378
|
// The template ships a starter prompt; a duplicate replaces it with the source's.
|
|
381
379
|
if (source !== null) {
|
|
382
380
|
try {
|
|
383
|
-
|
|
381
|
+
writeAtomic(promptFile(created.dir), source.prompt)
|
|
384
382
|
} catch (error) {
|
|
385
383
|
return { ok: false, error: '写入提示词失败:' + describe(error) }
|
|
386
384
|
}
|
|
@@ -424,92 +422,35 @@ export async function deleteAssistant(rows, input, agentPresets) {
|
|
|
424
422
|
return { ok: true, id, note: '已删除「' + id + '」。正在使用它的会话不受影响;新建会话时不再出现。' }
|
|
425
423
|
}
|
|
426
424
|
|
|
427
|
-
/** Send JSON with no-store caching, so a save is never read back stale. */
|
|
428
|
-
function sendJson(res, status, value) {
|
|
429
|
-
const body = JSON.stringify(value)
|
|
430
|
-
res.writeHead(status, {
|
|
431
|
-
'content-type': 'application/json; charset=utf-8',
|
|
432
|
-
'cache-control': 'no-store',
|
|
433
|
-
'content-length': String(Buffer.byteLength(body, 'utf8')),
|
|
434
|
-
})
|
|
435
|
-
res.end(body)
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/** Read a request body with a hard cap. */
|
|
439
|
-
async function readBody(req) {
|
|
440
|
-
const chunks = []
|
|
441
|
-
let size = 0
|
|
442
|
-
for await (const chunk of req) {
|
|
443
|
-
size += chunk.length
|
|
444
|
-
if (size > 4_000_000) throw new Error('请求体过大(上限 4MB)')
|
|
445
|
-
chunks.push(chunk)
|
|
446
|
-
}
|
|
447
|
-
return Buffer.concat(chunks).toString('utf8')
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/**
|
|
451
|
-
* The request's path and query.
|
|
452
|
-
*
|
|
453
|
-
* A missing or unparsable `url` resolves to the route path itself, which is the
|
|
454
|
-
* list endpoint — the lenient branch exists so a malformed request reads as an
|
|
455
|
-
* ordinary list call instead of throwing inside the fence.
|
|
456
|
-
*/
|
|
457
|
-
function requestUrl(req) {
|
|
458
|
-
const raw =
|
|
459
|
-
req !== null && typeof req === 'object' && typeof req.url === 'string' && req.url !== '' ? req.url : ROUTE_PATH
|
|
460
|
-
try {
|
|
461
|
-
return new URL(raw, 'http://localhost')
|
|
462
|
-
} catch {
|
|
463
|
-
return new URL(ROUTE_PATH, 'http://localhost')
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
425
|
/**
|
|
468
|
-
*
|
|
426
|
+
* The plugin's HTTP surface, registered on the platform's **shared `/api` channel**.
|
|
469
427
|
*
|
|
470
|
-
*
|
|
471
|
-
* the
|
|
428
|
+
* `ctx.connection.fetch.register({ path, methods, requestBody, fetch })` mounts one exact route under
|
|
429
|
+
* `/api`, and the carrier applies its trust and authentication policy **before** dispatch. That is a
|
|
430
|
+
* structural guarantee, not a convention: a route cannot exist without the fence, so "did you remember
|
|
431
|
+
* to check?" is not a question anyone has to answer. An earlier version of this plugin registered on the
|
|
432
|
+
* raw `ctx.webServer` table and called `ctx.connection.requestRejection` by hand — measured then, an
|
|
433
|
+
* unauthenticated GET returned the whole system prompt and an unauthenticated cross-site POST rewrote
|
|
434
|
+
* `prompt.md`, while every official route answered 401.
|
|
472
435
|
*
|
|
473
|
-
*
|
|
474
|
-
* rebinding, where the socket reaches this server but the Host names the
|
|
475
|
-
* attacker's domain;
|
|
476
|
-
* - `Sec-Fetch-Site: cross-site` and a mismatching `Origin` are refused → defeats
|
|
477
|
-
* a malicious page posting to this local port (a simple form post needs no
|
|
478
|
-
* preflight, so CORS alone would not have stopped it);
|
|
479
|
-
* - the signed `dsh-auth-*` cookie must be present → without the browser session
|
|
480
|
-
* that the launch URL establishes, the route is closed.
|
|
436
|
+
* Measured on 0.1.6-alpha.2 with a throwaway probe plugin (see `docs/MEASUREMENTS.md` §16):
|
|
481
437
|
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
438
|
+
* GET /api/<route> no session cookie → 401 unauthorized
|
|
439
|
+
* GET /api/<route> Origin: https://evil.example cross-site → 403
|
|
440
|
+
* GET /api/<route> Host: evil.example DNS-rebinding shape → 403
|
|
441
|
+
* GET /api/<route> session cookie → 200
|
|
442
|
+
* POST /api/<route> on a route that owns GET only → 404 (never dispatched)
|
|
486
443
|
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
* system prompt is a silent one.
|
|
444
|
+
* The paths are absolute *including* `/api` — that is what the platform's own packages pass
|
|
445
|
+
* (`/api/present.host`, `/api/changes.summary`), despite the type saying "below /api".
|
|
490
446
|
*/
|
|
491
|
-
|
|
492
|
-
function connectionRejection(ctx, req) {
|
|
493
|
-
const connection = ctx.get('connection')
|
|
494
|
-
if (connection !== undefined && typeof connection.requestRejection === 'function') {
|
|
495
|
-
return connection.requestRejection(req)
|
|
496
|
-
}
|
|
497
|
-
if (!warnedMissingConnection) {
|
|
498
|
-
warnedMissingConnection = true
|
|
499
|
-
console.error(
|
|
500
|
-
'custom-mode: connection 服务不可用(DSH 版本不匹配?),已拒绝该设置页的所有请求以保守处理。' +
|
|
501
|
-
'prompt.md 与 custom_prompt 工具不受影响。',
|
|
502
|
-
)
|
|
503
|
-
}
|
|
504
|
-
return 503
|
|
505
|
-
}
|
|
447
|
+
const API_PREFIX = '/api'
|
|
506
448
|
|
|
507
449
|
/**
|
|
508
450
|
* Where the settings page can exist at all.
|
|
509
451
|
*
|
|
510
|
-
* The page is a WEB page: without `
|
|
511
|
-
*
|
|
512
|
-
* neither.
|
|
452
|
+
* The page is a WEB page: without `connection` there is no `/api` channel to register on, and without
|
|
453
|
+
* `agentPresets` the assistant list cannot be built. The tui profile has neither.
|
|
513
454
|
*
|
|
514
455
|
* These must NOT go into the row's own `inject`. Measured on 0.1.6-alpha.1:
|
|
515
456
|
* `./install.sh --profile tui` — a usage both `install.sh --help` and the READMEs
|
|
@@ -520,11 +461,11 @@ function connectionRejection(ctx, req) {
|
|
|
520
461
|
* custom-mode (dsh-custom-mode): pending (waiting for services: webServer, agentPresets)
|
|
521
462
|
*
|
|
522
463
|
* That is the SAME line a broken installation prints, so it teaches users to ignore the
|
|
523
|
-
* one warning that matters. Instead the row always activates, and the
|
|
464
|
+
* one warning that matters. Instead the row always activates, and the routes are
|
|
524
465
|
* registered from a scoped fiber that waits for those two services (`ctx.inject`),
|
|
525
466
|
* which is the dynamic form of the same declaration.
|
|
526
467
|
*/
|
|
527
|
-
const WEB_SERVICES = ['
|
|
468
|
+
const WEB_SERVICES = ['connection', 'agentPresets']
|
|
528
469
|
|
|
529
470
|
export function apply(ctx) {
|
|
530
471
|
// Before anything else: make sure the preset tree on disk is complete.
|
|
@@ -552,7 +493,7 @@ export function apply(ctx) {
|
|
|
552
493
|
const missing = []
|
|
553
494
|
if (typeof scope.agentPresets?.list !== 'function') missing.push('agentPresets.list()')
|
|
554
495
|
if (typeof scope.agentPresets?.remove !== 'function') missing.push('agentPresets.remove()')
|
|
555
|
-
if (typeof scope.
|
|
496
|
+
if (typeof scope.connection?.fetch?.register !== 'function') missing.push('connection.fetch.register()')
|
|
556
497
|
if (missing.length > 0) {
|
|
557
498
|
console.error(
|
|
558
499
|
'custom-mode: 当前 DSH 版本缺少所需 API:' +
|
|
@@ -589,86 +530,92 @@ export function apply(ctx) {
|
|
|
589
530
|
return Array.isArray(rows) ? rows : []
|
|
590
531
|
}
|
|
591
532
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
533
|
+
/**
|
|
534
|
+
* One Fetch-shaped handler for the whole surface.
|
|
535
|
+
*
|
|
536
|
+
* `pathname` is the logical path (without the `/api` prefix) so the method table above stays the
|
|
537
|
+
* single place where the surface is described.
|
|
538
|
+
*/
|
|
539
|
+
/**
|
|
540
|
+
* JSON response with the page's caching policy attached.
|
|
541
|
+
*
|
|
542
|
+
* `no-store` because every one of these answers is state the page then displays: a cached
|
|
543
|
+
* `GET /api/custom-mode/state` would show the user rows they already changed. (The old
|
|
544
|
+
* hand-rolled response helper set the same header; dropping it here would have been a silent
|
|
545
|
+
* regression.)
|
|
546
|
+
*/
|
|
547
|
+
const json = (value, status = 200) =>
|
|
548
|
+
Response.json(value, { status, headers: { 'cache-control': 'no-store' } })
|
|
605
549
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
if (
|
|
610
|
-
sendJson(res, 404, { ok: false, error: '未知的子路径:' + pathname })
|
|
611
|
-
return
|
|
612
|
-
}
|
|
613
|
-
// Per-path method table, not a global GET/POST gate: `create` and `delete`
|
|
614
|
-
// are POST-only, and letting a GET fall through to them would create an
|
|
615
|
-
// assistant off a link a browser prefetched.
|
|
616
|
-
if (!methods.includes(req.method)) {
|
|
617
|
-
sendJson(res, 405, { ok: false, error: '只支持 ' + methods.join(' 与 ') })
|
|
618
|
-
return
|
|
619
|
-
}
|
|
620
|
-
if (req.method === 'GET' && pathname === ROUTE_PATH) {
|
|
550
|
+
const handle = async (request, pathname) => {
|
|
551
|
+
try {
|
|
552
|
+
const url = new URL(request.url)
|
|
553
|
+
if (request.method === 'GET' && pathname === ROUTE_PATH) {
|
|
621
554
|
await ensureShipped()
|
|
622
|
-
|
|
623
|
-
return
|
|
555
|
+
return json(readList(await roster()))
|
|
624
556
|
}
|
|
625
|
-
if (
|
|
557
|
+
if (request.method === 'GET' && pathname === STATE_PATH) {
|
|
626
558
|
await ensureShipped()
|
|
627
|
-
|
|
628
|
-
return
|
|
559
|
+
return json(readState(await roster(), url.searchParams.get('id') ?? ''))
|
|
629
560
|
}
|
|
630
561
|
|
|
631
562
|
// Everything below writes, so the body is read and parsed exactly once.
|
|
632
|
-
const raw = await readBody(req)
|
|
633
563
|
let parsed
|
|
634
564
|
try {
|
|
635
|
-
parsed =
|
|
565
|
+
parsed = await request.json()
|
|
636
566
|
} catch {
|
|
637
|
-
|
|
638
|
-
return
|
|
567
|
+
return json({ ok: false, error: '请求体不是合法 JSON' }, 400)
|
|
639
568
|
}
|
|
640
569
|
await ensureShipped()
|
|
641
570
|
if (pathname === STATE_PATH) {
|
|
642
571
|
const result = saveState(await roster(), parsed)
|
|
643
|
-
|
|
644
|
-
return
|
|
572
|
+
return json(result, result.ok === true ? 200 : 400)
|
|
645
573
|
}
|
|
646
574
|
if (pathname === CREATE_PATH) {
|
|
647
575
|
const result = createAssistant(await roster(), parsed)
|
|
648
|
-
|
|
649
|
-
return
|
|
576
|
+
return json(result, result.ok === true ? 200 : 400)
|
|
650
577
|
}
|
|
651
578
|
if (pathname === REORDER_PATH) {
|
|
652
579
|
const result = reorderAssistant(await roster(), parsed)
|
|
653
|
-
|
|
654
|
-
return
|
|
580
|
+
return json(result, result.ok === true ? 200 : 400)
|
|
655
581
|
}
|
|
656
582
|
const result = await deleteAssistant(await roster(), parsed, scope.agentPresets)
|
|
657
|
-
|
|
583
|
+
return json(result, result.ok === true ? 200 : 400)
|
|
658
584
|
} catch (error) {
|
|
659
|
-
|
|
585
|
+
return json({ ok: false, error: describe(error) }, 500)
|
|
660
586
|
}
|
|
661
587
|
}
|
|
662
588
|
|
|
663
|
-
//
|
|
664
|
-
// the
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
589
|
+
// One registration per exact path — the platform's Fetch registry matches exact paths, not prefixes —
|
|
590
|
+
// each declaring the methods it owns. The method table doubles as the guarantee that a prefetched
|
|
591
|
+
// `GET /api/custom-mode/create` cannot create anything: that method is not registered for that path.
|
|
592
|
+
for (const [pathname, methods] of Object.entries(METHODS)) {
|
|
593
|
+
scope.effect(
|
|
594
|
+
() => scope.connection.fetch.register({
|
|
595
|
+
path: API_PREFIX + pathname,
|
|
596
|
+
methods: [...methods],
|
|
597
|
+
requestBody: 'buffered',
|
|
598
|
+
fetch: (request) => handle(request, pathname),
|
|
599
|
+
}),
|
|
600
|
+
'custom-mode.route' + pathname,
|
|
601
|
+
)
|
|
602
|
+
}
|
|
669
603
|
})
|
|
670
604
|
}
|
|
671
605
|
|
|
606
|
+
/**
|
|
607
|
+
* 写文件:先写同目录的临时文件,再 rename 覆盖。
|
|
608
|
+
*
|
|
609
|
+
* `rename(2)` 在同一文件系统内是原子的,所以读者(提示词读取器按 mtime+size 缓存)要么看到旧内容、
|
|
610
|
+
* 要么看到新内容,不会读到写了一半的文件。原来连续两次 writeFileSync 在极端时序下可能被读成撕裂的
|
|
611
|
+
* prompt,而这个文件正是"用户的提示词"。
|
|
612
|
+
*/
|
|
613
|
+
function writeAtomic(file, text) {
|
|
614
|
+
const temporary = `${file}.tmp-${String(process.pid)}`
|
|
615
|
+
writeFileSync(temporary, text, 'utf8')
|
|
616
|
+
renameSync(temporary, file)
|
|
617
|
+
}
|
|
618
|
+
|
|
672
619
|
/** `error` as a readable string, without assuming it is an Error. */
|
|
673
620
|
function describe(error) {
|
|
674
621
|
return String((error && error.message) || error)
|
package/locales.mjs
CHANGED
|
@@ -67,14 +67,14 @@ export const zh = {
|
|
|
67
67
|
'name.descriptionPlaceholder': '模式描述(显示在模式选择器里,可留空)',
|
|
68
68
|
|
|
69
69
|
'mode.heading': '基础模式',
|
|
70
|
-
'mode.hint': '
|
|
70
|
+
'mode.hint': '选一个官方模式作为底子,下面再按行微调。注意:底子只决定**行集合**与工具能力 —— 本模式的 persona 行始终替换掉底子那一行(提示词由你编辑,complete: false),底子的提示词语义不会被继承。改完保存后,新建会话即生效,不需要重启。',
|
|
71
71
|
|
|
72
72
|
'rows.heading': '插件开关',
|
|
73
73
|
'rows.hint': '逐行控制这个模式挂载哪些插件,和官方插件列表一样按行铺开。没拨过的行保持官方默认(含平台判断);你手动拨了就以你的为准。',
|
|
74
74
|
|
|
75
75
|
'prompt.heading': '系统提示词',
|
|
76
76
|
'prompt.hint':
|
|
77
|
-
'
|
|
77
|
+
'这段文本在每个模型调用前重新读取,所以保存后下一步即生效,且只影响使用这个助手的会话。「导入」把文件读进编辑器(未保存前不写入任何东西);「导出」把当前文本存成 .md 文件。',
|
|
78
78
|
|
|
79
79
|
'status.enabled': '已启用',
|
|
80
80
|
'status.disabled': '已停用',
|
|
@@ -156,7 +156,7 @@ export const zh = {
|
|
|
156
156
|
'base.ptc.label': 'PTC 模式',
|
|
157
157
|
'base.ptc.note': '在标准模式基础上启用 PTC 工具呈现(tool-presentation)',
|
|
158
158
|
'base.minimal.label': '极简模式',
|
|
159
|
-
'base.minimal.note': '只有 Shell 与终端,共 7
|
|
159
|
+
'base.minimal.note': '只有 Shell 与终端,共 7 行;没有文件、检索、技能、子代理。系统提示词不是极简那一套(persona 行被本模式替换)',
|
|
160
160
|
'base.cordis.label': 'Cordis 模式',
|
|
161
161
|
'base.cordis.note': '标准模式 + 读写运行时的 Cordis 工具集,可让 agent 自己改 harness',
|
|
162
162
|
}
|
|
@@ -216,7 +216,7 @@ export const en = {
|
|
|
216
216
|
|
|
217
217
|
'mode.heading': 'Base mode',
|
|
218
218
|
'mode.hint':
|
|
219
|
-
'
|
|
219
|
+
'Pick an official mode as the base, then adjust it row by row. Note: the base only decides which rows exist — this mode always replaces the base\'s persona row with its own reader (complete: false), so the base\'s prompt semantics are not inherited. A save applies to the next new session; no restart needed.',
|
|
220
220
|
|
|
221
221
|
'rows.heading': 'Plugin switches',
|
|
222
222
|
'rows.hint':
|
|
@@ -224,7 +224,7 @@ export const en = {
|
|
|
224
224
|
|
|
225
225
|
'prompt.heading': 'System prompt',
|
|
226
226
|
'prompt.hint':
|
|
227
|
-
'This text is re-read before every model call, so a save applies on the next step
|
|
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.',
|
|
228
228
|
|
|
229
229
|
'status.enabled': 'Enabled',
|
|
230
230
|
'status.disabled': 'Disabled',
|
|
@@ -306,7 +306,7 @@ export const en = {
|
|
|
306
306
|
'base.ptc.label': 'PTC',
|
|
307
307
|
'base.ptc.note': 'Standard plus PTC tool presentation (tool-presentation)',
|
|
308
308
|
'base.minimal.label': 'Minimal',
|
|
309
|
-
'base.minimal.note': 'Shell and terminal only, 7 rows; no files, search, skills or subagents',
|
|
309
|
+
'base.minimal.note': 'Shell and terminal only, 7 rows; no files, search, skills or subagents. The system prompt is not minimal\'s either (this mode replaces the persona row)',
|
|
310
310
|
'base.cordis.label': 'Cordis',
|
|
311
311
|
'base.cordis.note': 'Standard plus the Cordis toolset, letting the agent modify its own harness',
|
|
312
312
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-custom-mode",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
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",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"test": "node ../test/run.mjs"
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
|
62
|
-
"@deepseek-ai/dsh": ">=0.1.6-alpha.1"
|
|
62
|
+
"@deepseek-ai/dsh": ">=0.1.6-alpha.1 <0.2.0-0"
|
|
63
63
|
},
|
|
64
64
|
"publishConfig": {
|
|
65
65
|
"access": "public",
|
|
@@ -71,6 +71,6 @@
|
|
|
71
71
|
}
|
|
72
72
|
},
|
|
73
73
|
"engines": {
|
|
74
|
-
"dsh": ">=0.1.6-alpha.1"
|
|
74
|
+
"dsh": ">=0.1.6-alpha.1 <0.2.0-0"
|
|
75
75
|
}
|
|
76
76
|
}
|