dsh-loadout 0.11.1 → 0.12.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/CHANGELOG.md +24 -0
- package/README.en.md +19 -12
- package/README.md +38 -12
- package/client.js +24 -10
- package/cordis.patch.yml +62 -13
- package/icon.svg +1 -1
- package/index.js +473 -67
- package/package.json +1 -1
- package/scripts/agent-lookup-probe.mjs +24 -0
- package/scripts/agent-probe.mjs +25 -0
- package/scripts/attribution-probe.mjs +91 -0
- package/scripts/client-smoke.mjs +8 -6
- package/scripts/cmd-probe.mjs +30 -0
- package/scripts/dock-probe.mjs +53 -0
- package/scripts/selftest.mjs +149 -11
- package/scripts/slots-probe.mjs +59 -0
package/index.js
CHANGED
|
@@ -14,13 +14,27 @@
|
|
|
14
14
|
* dsh → catalog built-ins ∪ tools(extras) ∪ OWN_TOOLS
|
|
15
15
|
* kit → tools(kit.builtins) ∪ tools(kit.plugins ∪ extras) ∪ OWN_TOOLS
|
|
16
16
|
*
|
|
17
|
-
* Presets (
|
|
18
|
-
* User kits live in `~/.dsh/loadout.json`:
|
|
17
|
+
* Presets (仅聊天 / 编程用 / 查找资料 / 项目梳理 / 写作) ship in `cordis.patch.yml`
|
|
18
|
+
* and are read-only. User kits live in `~/.dsh/loadout.json`:
|
|
19
19
|
*
|
|
20
|
-
* {
|
|
20
|
+
* {
|
|
21
|
+
* "schema": 2,
|
|
22
|
+
* "kits": [{ "name", "title", "note", "builtins", "plugins" }],
|
|
23
|
+
* "defaultKit": "",
|
|
24
|
+
* "assignments": { "<sessionId>": "kit-name" | "@dsh" | null },
|
|
25
|
+
* "migratedAt": "2026-11-02T13:00:00.000Z"
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* `assignments` is what makes a conversation's choice last: every conversation
|
|
29
|
+
* gets ONE persisted binding when it is created (never re-derived afterwards),
|
|
30
|
+
* so changing the default only affects conversations created later, and an
|
|
31
|
+
* explicit switch survives a restart. `null` means 全部 — an explicit choice, not
|
|
32
|
+
* "no answer". `migratedAt` lets a conversation that predates the upgrade keep
|
|
33
|
+
* the state it effectively had instead of inheriting today's default.
|
|
21
34
|
*
|
|
22
35
|
* Three surfaces share the same store and the same operations:
|
|
23
|
-
* - tools: kit_list / kit_apply / kit_create / kit_set_tools / kit_set_default
|
|
36
|
+
* - tools: kit_list / kit_apply / kit_create / kit_set_tools / kit_set_default
|
|
37
|
+
* / kit_delete / kit_assign / kit_rename
|
|
24
38
|
* - chat: the `/kit [name]` command
|
|
25
39
|
* - web UI: the header chip and 设置 → 装备栏, served under /dsh-loadout/
|
|
26
40
|
*
|
|
@@ -35,7 +49,7 @@
|
|
|
35
49
|
* @module dsh-loadout
|
|
36
50
|
*/
|
|
37
51
|
|
|
38
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs'
|
|
52
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs'
|
|
39
53
|
import { homedir } from 'node:os'
|
|
40
54
|
import { dirname, join } from 'node:path'
|
|
41
55
|
import { fileURLToPath } from 'node:url'
|
|
@@ -45,7 +59,7 @@ export const name = 'dsh-loadout'
|
|
|
45
59
|
/** Cordis waits for the tool registry before applying this plugin. */
|
|
46
60
|
export const inject = ['tools']
|
|
47
61
|
|
|
48
|
-
const OWN_TOOLS = ['kit_list', 'kit_apply', 'kit_create', 'kit_set_tools', 'kit_set_default', 'kit_delete']
|
|
62
|
+
const OWN_TOOLS = ['kit_list', 'kit_apply', 'kit_create', 'kit_set_tools', 'kit_set_default', 'kit_delete', 'kit_assign', 'kit_rename']
|
|
49
63
|
|
|
50
64
|
/** Runtime store. Profile-independent on purpose: one place, easy to back up. */
|
|
51
65
|
const STORE_PATH = join(homedir(), '.dsh', 'loadout.json')
|
|
@@ -54,11 +68,39 @@ const STORE_PATH = join(homedir(), '.dsh', 'loadout.json')
|
|
|
54
68
|
const LEGACY_STORE_PATH = join(homedir(), '.dsh', 'plugin-kits.json')
|
|
55
69
|
|
|
56
70
|
/**
|
|
57
|
-
* Version of the on-disk shape.
|
|
58
|
-
*
|
|
59
|
-
*
|
|
71
|
+
* Version of the on-disk shape. 2 introduced `assignments` (one persisted
|
|
72
|
+
* binding per conversation) and `migratedAt`; 1 and 0 are the same kit-only
|
|
73
|
+
* shape and are upgraded by `migrateBook`. A shape change bumps this, so a
|
|
74
|
+
* release never has to guess what it is looking at.
|
|
75
|
+
*/
|
|
76
|
+
const STORE_SCHEMA = 2
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Names a user kit may not take: the two built-in choices, the words the UI
|
|
80
|
+
* shows for "no kit", and the shipped preset names (checked separately because
|
|
81
|
+
* they come from the bundle). `title` is free text; only `name` is an id.
|
|
82
|
+
*/
|
|
83
|
+
const RESERVED_NAMES = ['全部', '全部工具', '仅内置', '自定义', '默认', 'all', 'builtin', 'custom', 'default']
|
|
84
|
+
|
|
85
|
+
const NAME_MAX = 32
|
|
86
|
+
|
|
87
|
+
/** Ids the UI parses, so a kit may not look like a path or a glob. */
|
|
88
|
+
const NAME_FORBIDDEN = /[\\/:*?"<>|\u0000-\u001f\u007f]/u
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Normalize and validate a kit id. NFKC folds full-width letters (`chat` →
|
|
92
|
+
* `chat`), which otherwise produce two ids that look identical in the menu.
|
|
60
93
|
*/
|
|
61
|
-
|
|
94
|
+
function assertKitName(raw, presets) {
|
|
95
|
+
const value = typeof raw === 'string' ? raw.trim().normalize('NFKC') : ''
|
|
96
|
+
if (value === '') throw new Error('名字不能为空')
|
|
97
|
+
if (value.length > NAME_MAX) throw new Error(`名字最多 ${NAME_MAX} 个字符`)
|
|
98
|
+
if (NAME_FORBIDDEN.test(value)) throw new Error('名字里不能有 / \\ : * ? " < > | 或控制字符')
|
|
99
|
+
const lower = value.toLowerCase()
|
|
100
|
+
if (RESERVED_NAMES.includes(lower)) throw new Error(`"${value}" 是保留名字,请换一个名字`)
|
|
101
|
+
if (presets !== undefined && findKit(presets, value) !== undefined) throw new Error(`"${value}" 与预置套装同名,请换一个名字`)
|
|
102
|
+
return value
|
|
103
|
+
}
|
|
62
104
|
|
|
63
105
|
/**
|
|
64
106
|
* One-line reminder on a kit ("套装1(用于视频制作)"). It rides in the menu next
|
|
@@ -130,22 +172,44 @@ function normalizeKits(rows) {
|
|
|
130
172
|
* Normalize the on-disk store. Missing arrays, unknown fields and a bare array
|
|
131
173
|
* (a hand-written store) all degrade to an empty, still-usable book.
|
|
132
174
|
*
|
|
133
|
-
* `schema` records what the file claimed. 0
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* reading what we
|
|
175
|
+
* `schema` records what the file claimed. 0/1 mean "written before 0.11.x" and
|
|
176
|
+
* carry no bindings; `migrateBook` stamps `migratedAt` and lets conversations
|
|
177
|
+
* that already existed keep the state they effectively had. Unknown future
|
|
178
|
+
* versions are still parsed by their known fields rather than rejected — losing
|
|
179
|
+
* a user's kits over a number would be worse than reading what we understand.
|
|
138
180
|
*/
|
|
139
181
|
function normalizeStore(value) {
|
|
140
|
-
if (Array.isArray(value))
|
|
182
|
+
if (Array.isArray(value)) {
|
|
183
|
+
return { schema: 0, kits: normalizeKits(value), defaultKit: '', assignments: {}, migratedAt: undefined }
|
|
184
|
+
}
|
|
141
185
|
if (typeof value !== 'object' || value === null) return undefined
|
|
142
186
|
return {
|
|
143
187
|
schema: Number.isInteger(value.schema) ? value.schema : 0,
|
|
144
188
|
kits: normalizeKits(value.kits),
|
|
145
189
|
defaultKit: typeof value.defaultKit === 'string' ? value.defaultKit.trim() : '',
|
|
190
|
+
assignments: normalizeAssignments(value.assignments),
|
|
191
|
+
migratedAt: typeof value.migratedAt === 'string' && !Number.isNaN(Date.parse(value.migratedAt))
|
|
192
|
+
? value.migratedAt
|
|
193
|
+
: undefined,
|
|
146
194
|
}
|
|
147
195
|
}
|
|
148
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Session id → binding. `null` is a real value here (全部), so a missing key and
|
|
199
|
+
* a stored `null` must stay distinguishable: `hasOwnProperty` decides, never a
|
|
200
|
+
* truthiness test.
|
|
201
|
+
*/
|
|
202
|
+
function normalizeAssignments(value) {
|
|
203
|
+
const out = {}
|
|
204
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return out
|
|
205
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
206
|
+
if (typeof key !== 'string' || key === '') continue
|
|
207
|
+
if (entry === null) out[key] = null
|
|
208
|
+
else if (typeof entry === 'string' && entry.trim() !== '') out[key] = entry.trim()
|
|
209
|
+
}
|
|
210
|
+
return out
|
|
211
|
+
}
|
|
212
|
+
|
|
149
213
|
function readStore() {
|
|
150
214
|
// New file first, then the pre-0.5 copy: a rename must not silently drop the
|
|
151
215
|
// kits someone already saved. The legacy file is never written again, so the
|
|
@@ -254,9 +318,18 @@ export function apply(ctx, config = {}) {
|
|
|
254
318
|
const presets = normalizeKits(config?.kits)
|
|
255
319
|
const loaded = readStore()
|
|
256
320
|
/** My kits: the user-created ones only. Presets are never stored here. */
|
|
321
|
+
/**
|
|
322
|
+
* My kits: the user-created ones only. Presets are never stored here.
|
|
323
|
+
*
|
|
324
|
+
* `assignments` is the v2 binding table: one entry per conversation, written
|
|
325
|
+
* once and then only on an explicit switch. `migratedAt` marks the upgrade, so
|
|
326
|
+
* a conversation older than it keeps the state it effectively had.
|
|
327
|
+
*/
|
|
257
328
|
const book = {
|
|
258
329
|
kits: migrateUserKits(loaded?.store.kits ?? [], presets),
|
|
259
330
|
defaultKit: loaded?.store.defaultKit ?? (typeof config?.defaultKit === 'string' ? config.defaultKit.trim() : ''),
|
|
331
|
+
assignments: loaded?.store.assignments ?? {},
|
|
332
|
+
migratedAt: loaded?.store.migratedAt,
|
|
260
333
|
// The file the kits actually came from: the legacy path on the first boot
|
|
261
334
|
// after the rename, the new one afterwards.
|
|
262
335
|
source: loaded?.path ?? 'bundle 配置',
|
|
@@ -266,7 +339,7 @@ export function apply(ctx, config = {}) {
|
|
|
266
339
|
writeStore({
|
|
267
340
|
// Stamped so a future release can migrate instead of guessing. Files from
|
|
268
341
|
// 0.10.x and earlier have no `schema`; normalizeStore reads them as 0 and
|
|
269
|
-
//
|
|
342
|
+
// migrateBook upgrades them.
|
|
270
343
|
schema: STORE_SCHEMA,
|
|
271
344
|
kits: book.kits.map((kit) => ({
|
|
272
345
|
name: kit.name,
|
|
@@ -279,10 +352,74 @@ export function apply(ctx, config = {}) {
|
|
|
279
352
|
note: kit.note ?? '',
|
|
280
353
|
})),
|
|
281
354
|
defaultKit: book.defaultKit,
|
|
355
|
+
assignments: book.assignments,
|
|
356
|
+
...(book.migratedAt === undefined ? {} : { migratedAt: book.migratedAt }),
|
|
282
357
|
})
|
|
283
|
-
|
|
358
|
+
// `source` answers "where do the user's kits live". Writing assignments or a
|
|
359
|
+
// default is bookkeeping, not a kit, so it does not turn a fresh install's
|
|
360
|
+
// 「bundle 配置」 into a file path; saving a kit does.
|
|
361
|
+
if (book.kits.length > 0) book.source = STORE_PATH
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Session ids that already exist on disk (conversations only, not subagents). */
|
|
365
|
+
function knownConversations() {
|
|
366
|
+
const root = join(homedir(), '.dsh', 'sessions')
|
|
367
|
+
const out = []
|
|
368
|
+
try {
|
|
369
|
+
for (const workspace of readdirSync(root, { withFileTypes: true })) {
|
|
370
|
+
if (!workspace.isDirectory()) continue
|
|
371
|
+
const dir = join(root, workspace.name)
|
|
372
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
373
|
+
if (entry.isDirectory() && entry.name.startsWith('session-')) out.push(entry.name)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} catch {
|
|
377
|
+
// No sessions directory yet: a fresh install has nothing to migrate.
|
|
378
|
+
}
|
|
379
|
+
return out
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Upgrade the store to schema 2 exactly once.
|
|
384
|
+
*
|
|
385
|
+
* Before v2 a conversation had no stored choice, so whatever it showed was
|
|
386
|
+
* inherited from the default at the moment it was created. Freezing that as
|
|
387
|
+
* `null` (全部) for the conversations that already exist is the honest reading:
|
|
388
|
+
* it changes nobody's tools today, and it stops a later default change from
|
|
389
|
+
* reaching back into conversations the user never touched. The pre-upgrade
|
|
390
|
+
* file is copied aside first, so a wrong guess is undoable by hand.
|
|
391
|
+
*/
|
|
392
|
+
function migrateBook() {
|
|
393
|
+
if (book.migratedAt !== undefined) return
|
|
394
|
+
if (loaded !== undefined && loaded.store.schema >= STORE_SCHEMA) {
|
|
395
|
+
book.migratedAt = new Date().toISOString()
|
|
396
|
+
save()
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
if (loaded?.path !== undefined && existsSync(loaded.path)) {
|
|
401
|
+
copyFileSync(loaded.path, `${loaded.path}.v1.bak-${Date.now()}`)
|
|
402
|
+
}
|
|
403
|
+
} catch {
|
|
404
|
+
// A backup is best-effort; the migration itself still has to run.
|
|
405
|
+
}
|
|
406
|
+
book.migratedAt = new Date().toISOString()
|
|
407
|
+
for (const sessionId of knownConversations()) {
|
|
408
|
+
if (!Object.prototype.hasOwnProperty.call(book.assignments, sessionId)) book.assignments[sessionId] = null
|
|
409
|
+
}
|
|
410
|
+
// Writing the upgraded file must not make an untouched install look as if the
|
|
411
|
+
// user had saved kits there: `source` is what the UI prints, and a fresh
|
|
412
|
+
// install with nothing of the user's own still reads 「bundle 配置」.
|
|
413
|
+
const before = book.source
|
|
414
|
+
save()
|
|
415
|
+
if (book.kits.length === 0 && book.defaultKit === '') book.source = before
|
|
416
|
+
if (knownConversations().length > 0) {
|
|
417
|
+
ctx.logger?.info('dsh-loadout: 升级到 schema 2,已有对话保持原状(全部)')
|
|
418
|
+
}
|
|
284
419
|
}
|
|
285
420
|
|
|
421
|
+
migrateBook()
|
|
422
|
+
|
|
286
423
|
/** agent -> { selection: {base,kit,extras}, label, dispose } */
|
|
287
424
|
const state = new Map()
|
|
288
425
|
|
|
@@ -296,6 +433,11 @@ export function apply(ctx, config = {}) {
|
|
|
296
433
|
* so this is captured through an optional injection instead of read directly.
|
|
297
434
|
*/
|
|
298
435
|
let sessionController
|
|
436
|
+
/**
|
|
437
|
+
* The session service, used only to read a Session's header (createdAt /
|
|
438
|
+
* parentSession / origin). Optional for the same reason as sessionController.
|
|
439
|
+
*/
|
|
440
|
+
let sessions
|
|
299
441
|
let lastAgent
|
|
300
442
|
/** Every tool name this profile can expose; filled by learnTools below. */
|
|
301
443
|
const knownTools = new Set(OWN_TOOLS)
|
|
@@ -316,10 +458,91 @@ export function apply(ctx, config = {}) {
|
|
|
316
458
|
return { base: BASE_ALL, kit: null, extras: [] }
|
|
317
459
|
}
|
|
318
460
|
|
|
461
|
+
/** The Session id behind an agent; `Agent.id` is the Session id. */
|
|
462
|
+
function sessionIdOf(agent) {
|
|
463
|
+
const id = agent?.id ?? agent?.session?.id
|
|
464
|
+
return typeof id === 'string' && id !== '' ? id : undefined
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** The Session header (createdAt / parentSession / origin), when available. */
|
|
468
|
+
function sessionHeaderOf(sessionId) {
|
|
469
|
+
if (sessionId === undefined || sessions === undefined) return undefined
|
|
470
|
+
try {
|
|
471
|
+
return sessions.get?.(sessionId)?.header
|
|
472
|
+
} catch {
|
|
473
|
+
return undefined
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** `hasOwnProperty`, because a stored `null` (全部) is a real answer. */
|
|
478
|
+
function bindingOf(sessionId) {
|
|
479
|
+
if (typeof sessionId !== 'string') return undefined
|
|
480
|
+
return Object.prototype.hasOwnProperty.call(book.assignments, sessionId)
|
|
481
|
+
? book.assignments[sessionId]
|
|
482
|
+
: undefined
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function selectionForBinding(value) {
|
|
486
|
+
if (value === undefined || value === null) return emptySelection()
|
|
487
|
+
if (value === DEFAULT_DSH) return { base: BASE_DSH, kit: null, extras: [] }
|
|
488
|
+
return { base: BASE_KIT, kit: value, extras: [] }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function bindingForSelection(selection) {
|
|
492
|
+
if (selection.base === BASE_DSH) return DEFAULT_DSH
|
|
493
|
+
if (selection.base === BASE_KIT) return selection.kit ?? null
|
|
494
|
+
return null
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** What a conversation created right now would start from. */
|
|
498
|
+
function defaultBinding() {
|
|
499
|
+
const wanted = book.defaultKit
|
|
500
|
+
if (wanted === '') return null
|
|
501
|
+
if (wanted === DEFAULT_DSH) return DEFAULT_DSH
|
|
502
|
+
return resolveKit(lastAgent, wanted) === undefined ? null : wanted
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Give this conversation its ONE binding, if it has none yet.
|
|
507
|
+
*
|
|
508
|
+
* Called lazily (first state read, first tool call) as well as on
|
|
509
|
+
* `agent/created`, because the tool layer may not be readable yet when
|
|
510
|
+
* `agent/created` fires. Three rules:
|
|
511
|
+
* - a fork inherits its parent's binding (the parent's context is what the
|
|
512
|
+
* child was cut from; re-deriving it from today's default would change
|
|
513
|
+
* the tools mid-fork);
|
|
514
|
+
* - a conversation that predates the upgrade keeps the state it effectively
|
|
515
|
+
* had (全部) instead of inheriting today's default;
|
|
516
|
+
* - a subagent gets no entry at all (R11) — its parent's filters already
|
|
517
|
+
* shape what it can see, and it must not pin a kit for itself.
|
|
518
|
+
*/
|
|
519
|
+
function ensureBinding(agent) {
|
|
520
|
+
const sessionId = sessionIdOf(agent)
|
|
521
|
+
if (sessionId === undefined) return undefined
|
|
522
|
+
const existing = bindingOf(sessionId)
|
|
523
|
+
if (existing !== undefined) return existing
|
|
524
|
+
const header = sessionHeaderOf(sessionId)
|
|
525
|
+
if (header?.origin === 'subagent') return undefined
|
|
526
|
+
let value
|
|
527
|
+
const parentBinding = header?.parentSession === undefined ? undefined : bindingOf(header.parentSession)
|
|
528
|
+
if (parentBinding !== undefined) value = parentBinding
|
|
529
|
+
else if (book.migratedAt !== undefined && typeof header?.createdAt === 'number'
|
|
530
|
+
&& header.createdAt < Date.parse(book.migratedAt)) value = null
|
|
531
|
+
else value = defaultBinding()
|
|
532
|
+
book.assignments[sessionId] = value
|
|
533
|
+
save()
|
|
534
|
+
return value
|
|
535
|
+
}
|
|
536
|
+
|
|
319
537
|
function currentSelection(agent) {
|
|
320
538
|
const entry = agent === undefined || agent === null ? undefined : state.get(agent)
|
|
321
539
|
const raw = entry?.selection
|
|
322
|
-
if (raw === undefined)
|
|
540
|
+
if (raw === undefined) {
|
|
541
|
+
// No live restriction yet: fall back to the persisted binding, so a
|
|
542
|
+
// restarted app and a fresh HTTP read agree with what the chip shows.
|
|
543
|
+
const bound = bindingOf(sessionIdOf(agent))
|
|
544
|
+
return bound === undefined ? emptySelection() : selectionForBinding(bound)
|
|
545
|
+
}
|
|
323
546
|
const base = raw.base === BASE_DSH || raw.base === BASE_KIT ? raw.base : BASE_ALL
|
|
324
547
|
if (base === BASE_ALL) return emptySelection()
|
|
325
548
|
if (base === BASE_DSH) return { base: BASE_DSH, kit: null, extras: dedupe(asNames(raw.extras)) }
|
|
@@ -756,7 +979,7 @@ export function apply(ctx, config = {}) {
|
|
|
756
979
|
* Apply one choice to one agent. Every surface (menu, settings page, /kit)
|
|
757
980
|
* funnels through here so the three never disagree.
|
|
758
981
|
*/
|
|
759
|
-
function useSelection(agent, selection) {
|
|
982
|
+
function useSelection(agent, selection, options = {}) {
|
|
760
983
|
if (agent?.ctx?.tools === undefined) return { ok: false, reason: '这个会话没有可用的工具作用域(agent.ctx.tools)' }
|
|
761
984
|
const normalized = normalizeSelection(agent, selection)
|
|
762
985
|
if (normalized === undefined) {
|
|
@@ -774,6 +997,17 @@ export function apply(ctx, config = {}) {
|
|
|
774
997
|
const result = applyMask(agent, keep)
|
|
775
998
|
if (!result.ok) return result
|
|
776
999
|
state.set(agent, { selection: normalized, label, dispose: result.dispose })
|
|
1000
|
+
// An explicit switch is the only thing that rewrites this conversation's
|
|
1001
|
+
// binding (ensureBinding writes the first one). Everything else — a default
|
|
1002
|
+
// change, a restart — must leave it alone.
|
|
1003
|
+
const sessionId = sessionIdOf(agent)
|
|
1004
|
+
if (sessionId !== undefined && options.persist !== false) {
|
|
1005
|
+
const next = bindingForSelection(normalized)
|
|
1006
|
+
if (bindingOf(sessionId) !== next) {
|
|
1007
|
+
book.assignments[sessionId] = next
|
|
1008
|
+
save()
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
777
1011
|
if (result.bypassed.length > 0) {
|
|
778
1012
|
ctx.logger?.warn(`dsh-loadout: 隐藏 ${result.hidden.length} 个工具,其中 ${result.bypassed.length} 个走工具层遮罩(${result.bypassed.join(', ')})`)
|
|
779
1013
|
}
|
|
@@ -874,39 +1108,47 @@ export function apply(ctx, config = {}) {
|
|
|
874
1108
|
}
|
|
875
1109
|
|
|
876
1110
|
/**
|
|
877
|
-
*
|
|
1111
|
+
* Does the live selection already come from this binding?
|
|
1112
|
+
*
|
|
1113
|
+
* Only the base and the kit are compared: 加挂 (extras) are session-local and
|
|
1114
|
+
* deliberately NOT persisted to `assignments`, so a state read that treated a
|
|
1115
|
+
* different extras list as "the binding changed" would wipe them — the exact
|
|
1116
|
+
* regression this comparison exists to prevent.
|
|
1117
|
+
*/
|
|
1118
|
+
function sameSelection(left, right) {
|
|
1119
|
+
return left.base === right.base
|
|
1120
|
+
&& (left.kit ?? null) === (right.kit ?? null)
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Make this conversation's tool layer match its persisted binding.
|
|
878
1125
|
*
|
|
879
|
-
* `
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
* first message, and `state.has(agent)` keeps an explicit choice from being
|
|
886
|
-
* overwritten.
|
|
1126
|
+
* `ensureBinding` owns the "what does this conversation start from" decision;
|
|
1127
|
+
* this function only applies it. It is idempotent (once base+kit match, nothing
|
|
1128
|
+
* is re-applied), and it runs from `agent/created`, from one deferred retry
|
|
1129
|
+
* (the tool layer can attach a tick after creation), and from `snapshot()` —
|
|
1130
|
+
* the UI reads state before the first message, which closes the 0.9.x window
|
|
1131
|
+
* where a saved default silently did nothing.
|
|
887
1132
|
*/
|
|
888
|
-
function
|
|
1133
|
+
function ensureBindingApplied(agent) {
|
|
889
1134
|
if (agent === undefined || agent === null) return
|
|
890
|
-
|
|
891
|
-
if (
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
const
|
|
898
|
-
if (
|
|
899
|
-
const result = useSelection(agent, { base: BASE_KIT, kit: kit.name, extras: [] })
|
|
900
|
-
if (!result.ok) ctx.logger?.warn(`dsh-loadout: 默认套装 "${kit.name}" 未应用:${result.reason}`)
|
|
1135
|
+
const value = ensureBinding(agent)
|
|
1136
|
+
if (value === undefined) return
|
|
1137
|
+
const selection = selectionForBinding(value)
|
|
1138
|
+
const current = state.get(agent)?.selection
|
|
1139
|
+
if (current !== undefined && sameSelection(current, selection)) return
|
|
1140
|
+
// The binding is already stored by ensureBinding; applying it must not write
|
|
1141
|
+
// it again (that would turn a default into an explicit choice).
|
|
1142
|
+
const result = useSelection(agent, selection, { persist: false })
|
|
1143
|
+
if (!result.ok) ctx.logger?.warn(`dsh-loadout: 会话绑定 ${labelOf(agent, selection)} 未应用:${result.reason}`)
|
|
901
1144
|
}
|
|
902
1145
|
|
|
903
1146
|
ctx.on('agent/created', (agent) => {
|
|
904
1147
|
rememberAgent(agent)
|
|
905
|
-
|
|
1148
|
+
ensureBindingApplied(agent)
|
|
906
1149
|
// The tool layer can attach a tick after creation; one deferred retry covers
|
|
907
|
-
// that race without putting timers on any hot path
|
|
908
|
-
|
|
909
|
-
const retry = setTimeout(() => ensureDefault(agent), 100)
|
|
1150
|
+
// that race without putting timers on any hot path.
|
|
1151
|
+
const retry = setTimeout(() => ensureBindingApplied(agent), 100)
|
|
910
1152
|
if (typeof retry?.unref === 'function') retry.unref()
|
|
911
1153
|
})
|
|
912
1154
|
|
|
@@ -1114,6 +1356,13 @@ export function apply(ctx, config = {}) {
|
|
|
1114
1356
|
}
|
|
1115
1357
|
|
|
1116
1358
|
// ---- shared operations, used by tools, the command and the routes --------
|
|
1359
|
+
/** How many conversations are pinned to this kit (a delete warning quotes it). */
|
|
1360
|
+
function boundCountOf(name) {
|
|
1361
|
+
let count = 0
|
|
1362
|
+
for (const value of Object.values(book.assignments)) if (value === name) count += 1
|
|
1363
|
+
return count
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1117
1366
|
function kitRow(kit) {
|
|
1118
1367
|
const parts = kitParts(kit)
|
|
1119
1368
|
return {
|
|
@@ -1125,15 +1374,16 @@ export function apply(ctx, config = {}) {
|
|
|
1125
1374
|
allow: rawKitAllow(kit),
|
|
1126
1375
|
description: describeKit(kit),
|
|
1127
1376
|
isDefault: kit.name === book.defaultKit,
|
|
1377
|
+
boundCount: boundCountOf(kit.name),
|
|
1128
1378
|
}
|
|
1129
1379
|
}
|
|
1130
1380
|
|
|
1131
1381
|
function snapshot(agent = lastAgent) {
|
|
1132
|
-
// A
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
|
|
1382
|
+
// A conversation may reach its first state read before the creation listener
|
|
1383
|
+
// could install its binding (the tool layer is not readable yet at that
|
|
1384
|
+
// moment). Retrying here means the chip shows, and the first message uses,
|
|
1385
|
+
// the binding instead of 全部.
|
|
1386
|
+
ensureBindingApplied(agent)
|
|
1137
1387
|
const selection = currentSelection(agent)
|
|
1138
1388
|
const appliedPlugins = appliedPluginsFor(agent, selection)
|
|
1139
1389
|
const availableExtras = availableExtrasFor(agent, selection)
|
|
@@ -1166,6 +1416,10 @@ export function apply(ctx, config = {}) {
|
|
|
1166
1416
|
presetNames: presets.map((kit) => kit.name),
|
|
1167
1417
|
kits: book.kits.map((kit) => priced(kitRow(kit))),
|
|
1168
1418
|
defaultKit: book.defaultKit,
|
|
1419
|
+
// This conversation's own binding: `null` (全部), '@dsh', a kit name, or
|
|
1420
|
+
// '' when the conversation has none (a subagent). The chip reads this
|
|
1421
|
+
// instead of inferring "no kit" from the live label.
|
|
1422
|
+
binding: bindingOf(sessionIdOf(agent)) ?? '',
|
|
1169
1423
|
ledger,
|
|
1170
1424
|
catalog,
|
|
1171
1425
|
tools: toolCatalog(),
|
|
@@ -1200,8 +1454,8 @@ export function apply(ctx, config = {}) {
|
|
|
1200
1454
|
const kit = normalizeKit(input ?? {})
|
|
1201
1455
|
|
|
1202
1456
|
if (kit === undefined) throw new Error('名字不能为空')
|
|
1457
|
+
kit.name = assertKitName(kit.name, presets)
|
|
1203
1458
|
if (findKit(book.kits, kit.name) !== undefined) throw new Error(`套装 "${kit.name}" 已存在`)
|
|
1204
|
-
if (findKit(presets, kit.name) !== undefined) throw new Error(`"${kit.name}" 与预置套装同名,请换一个名字`)
|
|
1205
1459
|
if (Array.isArray(input?.builtins) || Array.isArray(input?.plugins)) {
|
|
1206
1460
|
kit.builtins = dedupe(asNames(input.builtins))
|
|
1207
1461
|
kit.plugins = dedupe(asNames(input.plugins))
|
|
@@ -1227,10 +1481,15 @@ export function apply(ctx, config = {}) {
|
|
|
1227
1481
|
}
|
|
1228
1482
|
const oldName = kit.name
|
|
1229
1483
|
if (typeof input.rename === 'string' && input.rename.trim() !== '' && input.rename.trim() !== kit.name) {
|
|
1230
|
-
|
|
1231
|
-
if (findKit(
|
|
1232
|
-
if (book.defaultKit === kit.name) book.defaultKit =
|
|
1233
|
-
|
|
1484
|
+
const nextName = assertKitName(input.rename, presets)
|
|
1485
|
+
if (findKit(book.kits, nextName) !== undefined) throw new Error(`套装 "${nextName}" 已存在`)
|
|
1486
|
+
if (book.defaultKit === kit.name) book.defaultKit = nextName
|
|
1487
|
+
// A rename moves the identity, so every conversation pinned to the old
|
|
1488
|
+
// name follows it — otherwise those conversations would read as 全部.
|
|
1489
|
+
for (const [sessionId, value] of Object.entries(book.assignments)) {
|
|
1490
|
+
if (value === oldName) book.assignments[sessionId] = nextName
|
|
1491
|
+
}
|
|
1492
|
+
kit.name = nextName
|
|
1234
1493
|
}
|
|
1235
1494
|
if (typeof input.title === 'string' && input.title.trim() !== '') kit.title = input.title.trim()
|
|
1236
1495
|
if (typeof input.note === 'string') kit.note = clampNote(input.note)
|
|
@@ -1276,21 +1535,31 @@ export function apply(ctx, config = {}) {
|
|
|
1276
1535
|
return kit
|
|
1277
1536
|
},
|
|
1278
1537
|
remove(input) {
|
|
1538
|
+
const dryRun = input?.dryRun === true
|
|
1279
1539
|
const wanted = String(input?.name ?? '')
|
|
1280
1540
|
const kit = findKit(book.kits, wanted)
|
|
1281
1541
|
if (kit === undefined) {
|
|
1282
1542
|
if (findKit(presets, wanted) !== undefined) throw new Error('预置套装只读,请另存为我的套装')
|
|
1283
1543
|
throw new Error(`未知套装 "${wanted}"`)
|
|
1284
1544
|
}
|
|
1545
|
+
// Quoted by the delete confirmation ("N 个对话正在用它") and by the tool's
|
|
1546
|
+
// dry run, so a destructive click is never a surprise.
|
|
1547
|
+
const affected = boundCountOf(kit.name)
|
|
1548
|
+
if (dryRun) return { kit, affected }
|
|
1285
1549
|
book.kits = book.kits.filter((row) => row !== kit)
|
|
1286
1550
|
if (book.defaultKit === kit.name) book.defaultKit = ''
|
|
1551
|
+
// A binding is an explicit answer; the kit is gone, so the honest answer is
|
|
1552
|
+
// 全部 (null) — never "inherit whatever the default is today".
|
|
1553
|
+
for (const [sessionId, value] of Object.entries(book.assignments)) {
|
|
1554
|
+
if (value === kit.name) book.assignments[sessionId] = null
|
|
1555
|
+
}
|
|
1287
1556
|
for (const [agent, entry] of state) {
|
|
1288
1557
|
if (entry.selection.base === BASE_KIT && entry.selection.kit === kit.name) {
|
|
1289
1558
|
useSelection(agent, emptySelection())
|
|
1290
1559
|
}
|
|
1291
1560
|
}
|
|
1292
1561
|
save()
|
|
1293
|
-
return kit
|
|
1562
|
+
return { kit, affected }
|
|
1294
1563
|
},
|
|
1295
1564
|
setDefault(input) {
|
|
1296
1565
|
const wanted = typeof input?.name === 'string' ? input.name.trim() : ''
|
|
@@ -1322,32 +1591,36 @@ export function apply(ctx, config = {}) {
|
|
|
1322
1591
|
base: { type: 'string' },
|
|
1323
1592
|
extras: { type: 'array', items: { type: 'string' } },
|
|
1324
1593
|
default: { type: 'string' },
|
|
1594
|
+
binding: { type: 'string' },
|
|
1325
1595
|
source: { type: 'string' },
|
|
1326
1596
|
kits: {
|
|
1327
1597
|
type: 'array',
|
|
1328
1598
|
items: outputSchema({
|
|
1329
1599
|
name: { type: 'string' },
|
|
1330
1600
|
title: { type: 'string' },
|
|
1601
|
+
note: { type: 'string' },
|
|
1331
1602
|
tools: { type: 'array', items: { type: 'string' } },
|
|
1332
|
-
|
|
1603
|
+
boundCount: { type: 'number' },
|
|
1604
|
+
}, ['name', 'title', 'tools', 'boundCount']),
|
|
1333
1605
|
},
|
|
1334
1606
|
presets: {
|
|
1335
1607
|
type: 'array',
|
|
1336
1608
|
items: outputSchema({
|
|
1337
1609
|
name: { type: 'string' },
|
|
1338
1610
|
title: { type: 'string' },
|
|
1611
|
+
note: { type: 'string' },
|
|
1339
1612
|
tools: { type: 'array', items: { type: 'string' } },
|
|
1340
1613
|
}, ['name', 'title', 'tools']),
|
|
1341
1614
|
},
|
|
1342
|
-
}, ['active', 'base', 'extras', 'default', 'source', 'kits', 'presets']),
|
|
1615
|
+
}, ['active', 'base', 'extras', 'default', 'binding', 'source', 'kits', 'presets']),
|
|
1343
1616
|
render: (_args, value) => text([
|
|
1344
1617
|
`当前:${value.active === '' ? '(未设置)' : value.active}(base=${value.base}${value.extras.length > 0 ? `,加挂 ${value.extras.length} 个` : ''})`,
|
|
1345
1618
|
`默认套装:${value.default === '' ? '(未设置)' : value.default}`,
|
|
1346
1619
|
`存储:${value.source}`,
|
|
1347
1620
|
'我的套装:',
|
|
1348
|
-
...(value.kits.length === 0 ? ['- (还没有)'] : value.kits.map((kit) => `- ${kit.name} (${kit.title}): ${kit.tools.length > 0 ? kit.tools.join(', ') : '全部工具'}`)),
|
|
1621
|
+
...(value.kits.length === 0 ? ['- (还没有)'] : value.kits.map((kit) => `- ${kit.name} (${kit.title})${kit.note ? `(${kit.note})` : ''}: ${kit.tools.length > 0 ? kit.tools.join(', ') : '全部工具'}`)),
|
|
1349
1622
|
'预设套装(只读):',
|
|
1350
|
-
...(value.presets.length === 0 ? ['- (无)'] : value.presets.map((kit) => `- ${kit.name} (${kit.title}): ${kit.tools.length > 0 ? kit.tools.join(', ') : '全部工具'}`)),
|
|
1623
|
+
...(value.presets.length === 0 ? ['- (无)'] : value.presets.map((kit) => `- ${kit.name} (${kit.title})${kit.note ? `(${kit.note})` : ''}: ${kit.tools.length > 0 ? kit.tools.join(', ') : '全部工具'}`)),
|
|
1351
1624
|
].join('\n')),
|
|
1352
1625
|
},
|
|
1353
1626
|
async execute(_args, exec) {
|
|
@@ -1359,8 +1632,15 @@ export function apply(ctx, config = {}) {
|
|
|
1359
1632
|
base: selection.base,
|
|
1360
1633
|
extras: selection.extras,
|
|
1361
1634
|
default: book.defaultKit,
|
|
1635
|
+
binding: bindingOf(sessionIdOf(agent)) ?? '',
|
|
1362
1636
|
source: book.source,
|
|
1363
|
-
kits: book.kits.map((kit) => ({
|
|
1637
|
+
kits: book.kits.map((kit) => ({
|
|
1638
|
+
name: kit.name,
|
|
1639
|
+
title: kit.title,
|
|
1640
|
+
note: kit.note ?? '',
|
|
1641
|
+
tools: rawKitAllow(kit),
|
|
1642
|
+
boundCount: boundCountOf(kit.name),
|
|
1643
|
+
})),
|
|
1364
1644
|
presets: presets.map((kit) => ({ name: kit.name, title: kit.title, note: kit.note ?? '', tools: rawKitAllow(kit) })),
|
|
1365
1645
|
}
|
|
1366
1646
|
},
|
|
@@ -1487,20 +1767,132 @@ export function apply(ctx, config = {}) {
|
|
|
1487
1767
|
ctx.tools.register({
|
|
1488
1768
|
name: 'kit_delete',
|
|
1489
1769
|
description: 'Remove one of my kits from the store. Presets cannot be deleted. '
|
|
1490
|
-
+ 'Conversations
|
|
1770
|
+
+ 'Conversations pinned to the kit fall back to 全部; pass dryRun to see how many first.',
|
|
1491
1771
|
parameters: {
|
|
1492
1772
|
type: 'object',
|
|
1493
|
-
properties: {
|
|
1773
|
+
properties: {
|
|
1774
|
+
name: { type: 'string' },
|
|
1775
|
+
dryRun: { type: 'boolean', description: 'Only report affectedSessions; delete nothing.' },
|
|
1776
|
+
},
|
|
1494
1777
|
required: ['name'],
|
|
1495
1778
|
additionalProperties: false,
|
|
1496
1779
|
},
|
|
1497
1780
|
output: {
|
|
1498
|
-
schema: outputSchema({
|
|
1781
|
+
schema: outputSchema({
|
|
1782
|
+
name: { type: 'string' },
|
|
1783
|
+
affectedSessions: { type: 'number' },
|
|
1784
|
+
deleted: { type: 'boolean' },
|
|
1785
|
+
message: { type: 'string' },
|
|
1786
|
+
}, ['name', 'affectedSessions', 'deleted', 'message']),
|
|
1787
|
+
render: (_args, value) => text(value.message),
|
|
1788
|
+
},
|
|
1789
|
+
async execute(args) {
|
|
1790
|
+
const dryRun = args?.dryRun === true
|
|
1791
|
+
const { kit, affected } = operations.remove(args)
|
|
1792
|
+
if (dryRun) {
|
|
1793
|
+
return {
|
|
1794
|
+
name: kit.name,
|
|
1795
|
+
affectedSessions: affected,
|
|
1796
|
+
deleted: false,
|
|
1797
|
+
message: affected > 0
|
|
1798
|
+
? `套装 ${kit.name} 正被 ${affected} 个对话使用;删除后它们会回到「全部」。`
|
|
1799
|
+
: `套装 ${kit.name} 没有对话在用,可以直接删除。`,
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
return {
|
|
1803
|
+
name: kit.name,
|
|
1804
|
+
affectedSessions: affected,
|
|
1805
|
+
deleted: true,
|
|
1806
|
+
message: affected > 0
|
|
1807
|
+
? `已删除套装 ${kit.name};${affected} 个对话回到「全部」。`
|
|
1808
|
+
: `已删除套装 ${kit.name}`,
|
|
1809
|
+
}
|
|
1810
|
+
},
|
|
1811
|
+
})
|
|
1812
|
+
|
|
1813
|
+
ctx.tools.register({
|
|
1814
|
+
name: 'kit_assign',
|
|
1815
|
+
description: 'Pin one conversation (by Session id) to a kit, 仅内置, or 全部. '
|
|
1816
|
+
+ 'Normally you switch the conversation you are in with kit_apply; this is for correcting '
|
|
1817
|
+
+ 'another conversation whose tools were set up wrong.',
|
|
1818
|
+
parameters: {
|
|
1819
|
+
type: 'object',
|
|
1820
|
+
properties: {
|
|
1821
|
+
sessionId: { type: 'string', description: 'Target Session id.' },
|
|
1822
|
+
kit: { type: 'string', description: 'Kit name or title; "@dsh" for 仅内置; "" or "全部" for 全部.' },
|
|
1823
|
+
},
|
|
1824
|
+
required: ['sessionId', 'kit'],
|
|
1825
|
+
additionalProperties: false,
|
|
1826
|
+
},
|
|
1827
|
+
output: {
|
|
1828
|
+
schema: outputSchema({
|
|
1829
|
+
sessionId: { type: 'string' },
|
|
1830
|
+
binding: { type: 'string' },
|
|
1831
|
+
applied: { type: 'boolean' },
|
|
1832
|
+
message: { type: 'string' },
|
|
1833
|
+
}, ['sessionId', 'binding', 'applied', 'message']),
|
|
1499
1834
|
render: (_args, value) => text(value.message),
|
|
1500
1835
|
},
|
|
1501
1836
|
async execute(args) {
|
|
1502
|
-
const
|
|
1503
|
-
|
|
1837
|
+
const sessionId = String(args?.sessionId ?? '')
|
|
1838
|
+
if (sessionId === '') throw new Error('kit_assign: 缺少 sessionId')
|
|
1839
|
+
const wanted = String(args?.kit ?? '').trim()
|
|
1840
|
+
let value
|
|
1841
|
+
if (wanted === '' || wanted === '全部' || wanted.toLowerCase() === 'all') value = null
|
|
1842
|
+
else if (wanted === DEFAULT_DSH || wanted === '仅内置') value = DEFAULT_DSH
|
|
1843
|
+
else {
|
|
1844
|
+
const kit = resolveKit(lastAgent, wanted)
|
|
1845
|
+
if (kit === undefined) throw new Error(`kit_assign: 未知套装 "${wanted}"`)
|
|
1846
|
+
value = kit.name
|
|
1847
|
+
}
|
|
1848
|
+
book.assignments[sessionId] = value
|
|
1849
|
+
save()
|
|
1850
|
+
// Apply it live when that conversation is still open; otherwise the pin is
|
|
1851
|
+
// picked up the next time the conversation is read.
|
|
1852
|
+
let applied = false
|
|
1853
|
+
if (sessionController !== undefined) {
|
|
1854
|
+
try {
|
|
1855
|
+
const found = await sessionController.resolveAgent(sessionId)
|
|
1856
|
+
if (found?.agent !== undefined) {
|
|
1857
|
+
const result = useSelection(found.agent, selectionForBinding(value))
|
|
1858
|
+
applied = result.ok
|
|
1859
|
+
}
|
|
1860
|
+
} catch {
|
|
1861
|
+
// The pin is stored; a closed conversation will read it on resume.
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
const label = value === null ? '全部' : value === DEFAULT_DSH ? '仅内置' : value
|
|
1865
|
+
return {
|
|
1866
|
+
sessionId,
|
|
1867
|
+
binding: value ?? '',
|
|
1868
|
+
applied,
|
|
1869
|
+
message: `${sessionId} 现在固定使用「${label}」(${applied ? '已即时应用' : '下次打开该对话时生效'})。`,
|
|
1870
|
+
}
|
|
1871
|
+
},
|
|
1872
|
+
})
|
|
1873
|
+
|
|
1874
|
+
ctx.tools.register({
|
|
1875
|
+
name: 'kit_rename',
|
|
1876
|
+
description: 'Change the display title of one of my kits. The kit id stays the same, '
|
|
1877
|
+
+ 'so its default and every conversation pinned to it keep working.',
|
|
1878
|
+
parameters: {
|
|
1879
|
+
type: 'object',
|
|
1880
|
+
properties: {
|
|
1881
|
+
name: { type: 'string', description: 'Kit id (not the title).' },
|
|
1882
|
+
title: { type: 'string', description: 'New display title.' },
|
|
1883
|
+
},
|
|
1884
|
+
required: ['name', 'title'],
|
|
1885
|
+
additionalProperties: false,
|
|
1886
|
+
},
|
|
1887
|
+
output: {
|
|
1888
|
+
schema: outputSchema({ name: { type: 'string' }, title: { type: 'string' }, message: { type: 'string' } }, ['name', 'title', 'message']),
|
|
1889
|
+
render: (_args, value) => text(value.message),
|
|
1890
|
+
},
|
|
1891
|
+
async execute(args) {
|
|
1892
|
+
const title = typeof args?.title === 'string' ? args.title.trim() : ''
|
|
1893
|
+
if (title === '') throw new Error('kit_rename: 缺少新的标题')
|
|
1894
|
+
const kit = operations.update({ name: args?.name, title })
|
|
1895
|
+
return { name: kit.name, title: kit.title, message: `套装 ${kit.name} 改名为「${kit.title}」(id 不变)。` }
|
|
1504
1896
|
},
|
|
1505
1897
|
})
|
|
1506
1898
|
|
|
@@ -1637,7 +2029,18 @@ export function apply(ctx, config = {}) {
|
|
|
1637
2029
|
case 'create': operations.create(body); break
|
|
1638
2030
|
case 'update': operations.update(body); break
|
|
1639
2031
|
case 'delete':
|
|
1640
|
-
case 'remove':
|
|
2032
|
+
case 'remove': {
|
|
2033
|
+
// The settings page asks with dryRun first so it can quote how many
|
|
2034
|
+
// conversations a delete would release, then confirms.
|
|
2035
|
+
const { kit, affected } = operations.remove(body)
|
|
2036
|
+
return sendJson(res, 200, {
|
|
2037
|
+
ok: true,
|
|
2038
|
+
name: kit.name,
|
|
2039
|
+
affectedSessions: affected,
|
|
2040
|
+
deleted: body?.dryRun !== true,
|
|
2041
|
+
...snapshot(),
|
|
2042
|
+
})
|
|
2043
|
+
}
|
|
1641
2044
|
case 'default': operations.setDefault(body); break
|
|
1642
2045
|
case 'applySet': {
|
|
1643
2046
|
// Backward compatibility for panels written against 0.5: an explicit
|
|
@@ -1748,6 +2151,9 @@ export function apply(ctx, config = {}) {
|
|
|
1748
2151
|
optional(['sessionController'], (child) => {
|
|
1749
2152
|
sessionController = child.sessionController
|
|
1750
2153
|
})
|
|
2154
|
+
optional(['sessions'], (child) => {
|
|
2155
|
+
sessions = child.sessions
|
|
2156
|
+
})
|
|
1751
2157
|
|
|
1752
2158
|
ctx.logger?.info(`dsh-loadout: ${book.kits.length} 个我的套装 + ${presets.length} 个预设,默认 ${book.defaultKit || '(未设置)'},来源 ${book.source}`)
|
|
1753
2159
|
}
|