dsh-loadout 0.12.1 → 0.12.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/CHANGELOG.md +10 -0
- package/client.js +34 -3
- package/index.js +51 -1
- package/package.json +1 -1
- package/scripts/client-smoke.mjs +15 -0
- package/scripts/selftest.mjs +50 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
「装备栏」是一个 DeepSeek Harness 插件:给每个对话选一套工具(套装),只把用得上的工具投给模型。
|
|
4
4
|
|
|
5
|
+
## 0.12.3
|
|
6
|
+
|
|
7
|
+
- **标题栏按钮不再「点一下才对」**:芯片只在菜单被打开时才去读状态,所以进对话先看到「装备栏」,点开一次才换成套装名。现在挂载就读一次,并监听 dsh-loadout:changed 与窗口 ocus,别处切了套装也会立刻跟上。
|
|
8
|
+
- **设置页「预设套装」默认折叠**:与「我的套装」一致——折叠时只显示当前被设为默认的那一套,展开才列出五套。
|
|
9
|
+
- **同名撞车**:0.12 之前手建的套装如果叫 chat / plan 这类预置名,
|
|
10
|
+
- 回归断言: user kit shadowing a preset is renamed、 he renamed kit keeps its default and its bindings、presets start collapsed、 collapsed preset group keeps the default visible。
|
|
11
|
+
## 0.12.2
|
|
12
|
+
|
|
13
|
+
- **修「绑定到已不存在的套装」**:如果某个对话绑的套装在应用关着的时候被删掉(旧版本、手改 loadout.json、或别的机器同步过来的文件),启动后它会被当成「空套装」套用——等于把这对话的工具连同内置工具一起裁光,只剩 kit_*。现在这种绑定在第一次被读到时回退到「全部」,并把 ssignments 里那个失效名字改成
|
|
14
|
+
ull,日志里留一行说明。回归断言: binding to a deleted kit falls back to 全部(用第二个模块实例模拟重启)。
|
|
5
15
|
## 0.12.1
|
|
6
16
|
|
|
7
17
|
- **修卡死**:编辑(覆盖保存)、重命名或删除一个正在被对话使用的套装时,宿主会遍历「正在用它的对话」并逐个重新套用工具;这个遍历边改边往 `Map` 里重写条目,迭代器被塞回一个已经走过的键,于是永远走不完——表现是界面点下去没有任何反应、进程占满一个核。现在遍历快照(`[...state]`),编辑与删除都会立刻返回。
|
package/client.js
CHANGED
|
@@ -475,9 +475,24 @@ window.__ModuleLoader__.load({
|
|
|
475
475
|
}
|
|
476
476
|
}, [sessionId])
|
|
477
477
|
|
|
478
|
+
// The label is this conversation's persisted binding, so read it as soon as
|
|
479
|
+
// the chip mounts: waiting for the first click is what made a bound
|
|
480
|
+
// conversation show 装备栏 until the menu was opened once.
|
|
478
481
|
useEffect(() => {
|
|
479
|
-
|
|
480
|
-
}, [
|
|
482
|
+
refresh()
|
|
483
|
+
}, [refresh])
|
|
484
|
+
|
|
485
|
+
// Another surface (the settings page, the /kit command) may have changed
|
|
486
|
+
// this conversation's binding while the chip sat idle.
|
|
487
|
+
useEffect(() => {
|
|
488
|
+
const sync = () => { refresh() }
|
|
489
|
+
document.addEventListener('dsh-loadout:changed', sync)
|
|
490
|
+
window.addEventListener('focus', sync)
|
|
491
|
+
return () => {
|
|
492
|
+
document.removeEventListener('dsh-loadout:changed', sync)
|
|
493
|
+
window.removeEventListener('focus', sync)
|
|
494
|
+
}
|
|
495
|
+
}, [refresh])
|
|
481
496
|
|
|
482
497
|
useEffect(() => {
|
|
483
498
|
if (!open) return undefined
|
|
@@ -946,7 +961,7 @@ window.__ModuleLoader__.load({
|
|
|
946
961
|
// 我的套装 starts collapsed, like the chip: the page's job is "which set does
|
|
947
962
|
// a new conversation start with", and a list of five kits does not answer it.
|
|
948
963
|
// The test seam can seed it open so the card assertions never depend on a click.
|
|
949
|
-
const [openSections, setOpenSections] = useState(preview?.openSections ?? { presets:
|
|
964
|
+
const [openSections, setOpenSections] = useState(preview?.openSections ?? { presets: false, kits: false })
|
|
950
965
|
|
|
951
966
|
const refresh = useCallback(async () => {
|
|
952
967
|
try {
|
|
@@ -1007,6 +1022,7 @@ window.__ModuleLoader__.load({
|
|
|
1007
1022
|
// A collapsed 我的套装 still answers "which kit is the default", the same way
|
|
1008
1023
|
// the chip does.
|
|
1009
1024
|
const defaultKitRow = kits.find((kit) => kit.name === data.defaultKit)
|
|
1025
|
+
const defaultPresetRow = presets.find((preset) => preset.name === data.defaultKit)
|
|
1010
1026
|
|
|
1011
1027
|
const saveDraft = async () => {
|
|
1012
1028
|
if (draft === null) return
|
|
@@ -1195,6 +1211,21 @@ window.__ModuleLoader__.load({
|
|
|
1195
1211
|
onDelete: () => {},
|
|
1196
1212
|
}))))
|
|
1197
1213
|
: null,
|
|
1214
|
+
SHOW_PRESETS === true && openSections.presets === false
|
|
1215
|
+
// Collapsed, exactly like 我的套装: only the preset that is the
|
|
1216
|
+
// default stays visible, so the group still answers "new conversations
|
|
1217
|
+
// start with this".
|
|
1218
|
+
? (defaultPresetRow === undefined
|
|
1219
|
+
? null
|
|
1220
|
+
: h(MenuRow, {
|
|
1221
|
+
key: `default-preset-${defaultPresetRow.name}`,
|
|
1222
|
+
label: kitLabel(defaultPresetRow),
|
|
1223
|
+
hint: '当前默认',
|
|
1224
|
+
indent: true,
|
|
1225
|
+
active: true,
|
|
1226
|
+
onClick: () => act({ action: 'default', name: defaultPresetRow.name }),
|
|
1227
|
+
}))
|
|
1228
|
+
: null,
|
|
1198
1229
|
h(GroupHead, {
|
|
1199
1230
|
title: '我的套装',
|
|
1200
1231
|
count: kits.length,
|
package/index.js
CHANGED
|
@@ -418,7 +418,46 @@ export function apply(ctx, config = {}) {
|
|
|
418
418
|
}
|
|
419
419
|
}
|
|
420
420
|
|
|
421
|
+
/**
|
|
422
|
+
* A kit saved before the preset list existed may carry a preset's name or
|
|
423
|
+
* title (`chat`, `plan`, `写作`…). `resolveKit` prefers user kits, so a
|
|
424
|
+
* default naming such a preset would actually answer the user's kit while the
|
|
425
|
+
* settings page marked the preset — two rows claiming 「默认」. Renaming is the
|
|
426
|
+
* only honest fix: keep the user's kit (with its tools) under a free name and
|
|
427
|
+
* repoint every binding that used the old one.
|
|
428
|
+
*/
|
|
429
|
+
function disambiguatePresetClashes() {
|
|
430
|
+
const taken = new Set(presets.flatMap((preset) => [preset.name.toLowerCase(), preset.title.toLowerCase()]))
|
|
431
|
+
const changed = []
|
|
432
|
+
for (const kit of book.kits) {
|
|
433
|
+
const nameClash = taken.has(kit.name.toLowerCase())
|
|
434
|
+
const titleClash = taken.has(kit.title.toLowerCase())
|
|
435
|
+
if (!nameClash && !titleClash) continue
|
|
436
|
+
const before = kit.name
|
|
437
|
+
if (nameClash) {
|
|
438
|
+
let suffix = 2
|
|
439
|
+
let candidate = `${before}-${suffix}`
|
|
440
|
+
while (taken.has(candidate.toLowerCase()) || findKit(book.kits, candidate) !== undefined) {
|
|
441
|
+
suffix += 1
|
|
442
|
+
candidate = `${before}-${suffix}`
|
|
443
|
+
}
|
|
444
|
+
kit.name = candidate
|
|
445
|
+
taken.add(candidate.toLowerCase())
|
|
446
|
+
}
|
|
447
|
+
if (titleClash) kit.title = `${kit.title}(自建)`
|
|
448
|
+
for (const [sessionId, value] of Object.entries(book.assignments)) {
|
|
449
|
+
if (value === before) book.assignments[sessionId] = kit.name
|
|
450
|
+
}
|
|
451
|
+
if (book.defaultKit === before) book.defaultKit = kit.name
|
|
452
|
+
changed.push(`${before} → ${kit.name}`)
|
|
453
|
+
}
|
|
454
|
+
if (changed.length === 0) return
|
|
455
|
+
save()
|
|
456
|
+
ctx.logger?.warn(`dsh-loadout: 自建套装与预置同名,已改名(没有删除):${changed.join(',')}`)
|
|
457
|
+
}
|
|
458
|
+
|
|
421
459
|
migrateBook()
|
|
460
|
+
disambiguatePresetClashes()
|
|
422
461
|
|
|
423
462
|
/** agent -> { selection: {base,kit,extras}, label, dispose } */
|
|
424
463
|
const state = new Map()
|
|
@@ -1132,8 +1171,19 @@ export function apply(ctx, config = {}) {
|
|
|
1132
1171
|
*/
|
|
1133
1172
|
function ensureBindingApplied(agent) {
|
|
1134
1173
|
if (agent === undefined || agent === null) return
|
|
1135
|
-
|
|
1174
|
+
let value = ensureBinding(agent)
|
|
1136
1175
|
if (value === undefined) return
|
|
1176
|
+
// A stored binding can name a kit that no longer exists — an older build, a
|
|
1177
|
+
// hand-edited store, or a kit deleted while the app was closed. Applying it
|
|
1178
|
+
// would compute an empty allow list and strip the conversation down to the
|
|
1179
|
+
// kit_* tools, so such a conversation falls back to 全部 and the dangling
|
|
1180
|
+
// name is rewritten to stop it resurfacing on every read.
|
|
1181
|
+
if (value !== null && value !== DEFAULT_DSH && resolveKit(lastAgent, value) === undefined) {
|
|
1182
|
+
book.assignments[sessionIdOf(agent)] = null
|
|
1183
|
+
save()
|
|
1184
|
+
ctx.logger?.warn(`dsh-loadout: 套装「${value}」已不存在,这个对话回到「全部」`)
|
|
1185
|
+
value = null
|
|
1186
|
+
}
|
|
1137
1187
|
const selection = selectionForBinding(value)
|
|
1138
1188
|
const current = state.get(agent)?.selection
|
|
1139
1189
|
if (current !== undefined && sameSelection(current, selection)) return
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-loadout",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.3",
|
|
4
4
|
"description": "Loadout for DeepSeek Harness: group tools into kits, keep only the current kit's tools visible in each conversation, and manage everything from a settings panel.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
package/scripts/client-smoke.mjs
CHANGED
|
@@ -344,6 +344,21 @@ try {
|
|
|
344
344
|
check('kit cards show their own estimate', panelText.includes('≈860 tokens') && panelText.includes('≈420 tokens'), panelText.slice(0, 400))
|
|
345
345
|
const deleteButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '删除')
|
|
346
346
|
check('only my kits can be deleted', deleteButtons.length === 2, `${deleteButtons.length} · 删除@${panelText.indexOf('删除')} · ${panelText.slice(0, 200)}`)
|
|
347
|
+
|
|
348
|
+
// Presets start collapsed, and while collapsed they behave like 我的套装: the
|
|
349
|
+
// one that is the default stays visible so the page still answers "a new
|
|
350
|
+
// conversation starts with this".
|
|
351
|
+
const presetDefault = { ...fixture, defaultKit: 'chat', selection: { base: 'kit', kit: 'chat', extras: [] } }
|
|
352
|
+
const collapsed = expand(registration.component({ preview: { data: presetDefault } }))
|
|
353
|
+
const collapsedText = textOf(collapsed)
|
|
354
|
+
check('presets start collapsed',
|
|
355
|
+
collapsedText.includes('预设套装 · 只读(5)') && !collapsedText.includes('写作'), collapsedText.slice(0, 400))
|
|
356
|
+
check('a collapsed preset group keeps the default visible',
|
|
357
|
+
collapsedText.includes('仅聊天') && collapsedText.includes('当前默认'), collapsedText.slice(0, 400))
|
|
358
|
+
const presetExpanded = expand(registration.component({ preview: { data: presetDefault, openSections: { presets: true, kits: false } } }))
|
|
359
|
+
const presetExpandedText = textOf(presetExpanded)
|
|
360
|
+
check('only the default preset carries the default tag',
|
|
361
|
+
presetExpandedText.includes('仅聊天') && presetExpandedText.includes('默认'), presetExpandedText.slice(0, 400))
|
|
347
362
|
const renameButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '重命名')
|
|
348
363
|
// Renaming moved into 编辑: one editor for name, note and tools on the card.
|
|
349
364
|
check('renaming lives inside 编辑 rather than its own button', renameButtons.length === 0 && editButtons.length === 2, `${renameButtons.length} · 重命名 / ${editButtons.length} · 编辑`)
|
package/scripts/selftest.mjs
CHANGED
|
@@ -846,6 +846,24 @@ try {
|
|
|
846
846
|
check('a preset name cannot be taken by a user kit',
|
|
847
847
|
/预置/u.test(presetClashError ?? ''), presetClashError)
|
|
848
848
|
|
|
849
|
+
// T22: a binding that names a kit removed outside the app falls back to 全部.
|
|
850
|
+
// Simulated the way it really happens: the kit is gone from the store file when
|
|
851
|
+
// the app starts again, so a fresh module instance has to cope with it.
|
|
852
|
+
await ctxV2.registered.get('kit_create').execute(
|
|
853
|
+
{ name: 'ghost-kit', title: '已删除的', builtins: ['read'], plugins: [] }, exec(agentB))
|
|
854
|
+
await ctxV2.registered.get('kit_assign').execute({ sessionId: 'session-ghost', kit: 'ghost-kit' }, exec(agentB))
|
|
855
|
+
const storeWithGhost = storeOf()
|
|
856
|
+
storeWithGhost.kits = storeWithGhost.kits.filter((kit) => kit.name !== 'ghost-kit')
|
|
857
|
+
writeFileSync(join(v2Home, '.dsh', 'loadout.json'), JSON.stringify(storeWithGhost), 'utf8')
|
|
858
|
+
const v2b = await import('../index.js?v2=2')
|
|
859
|
+
const ctxV2b = makeCtx()
|
|
860
|
+
v2b.apply(ctxV2b, { defaultKit: '', kits: config.kits })
|
|
861
|
+
const agentGhost = makeAgent(undefined, { id: 'session-ghost' })
|
|
862
|
+
for (const handler of ctxV2b.handlers.get('agent/created') ?? []) handler(agentGhost)
|
|
863
|
+
check('a binding to a deleted kit falls back to 全部',
|
|
864
|
+
storeOf().assignments['session-ghost'] === null && keptOf(agentGhost).includes('web_search'),
|
|
865
|
+
JSON.stringify({ assignments: storeOf().assignments, kept: keptOf(agentGhost) }))
|
|
866
|
+
|
|
849
867
|
// T03/T05: the default changes again; conversations that already answered keep theirs.
|
|
850
868
|
const agentC = makeAgent(undefined, { id: 'session-c' })
|
|
851
869
|
await ctxV2.registered.get('kit_set_default').execute({ name: 'chat' }, exec(agentB))
|
|
@@ -904,6 +922,38 @@ process.env.USERPROFILE = savedHomeV2
|
|
|
904
922
|
process.env.HOME = savedHomeV2b
|
|
905
923
|
rmSync(v2Home, { recursive: true, force: true })
|
|
906
924
|
|
|
925
|
+
// ---- a user kit that shadows a preset is renamed, never deleted ------------
|
|
926
|
+
// Stores written before the preset list existed can carry `chat` / `plan`, which
|
|
927
|
+
// `resolveKit` prefers over the preset of the same name — the settings page then
|
|
928
|
+
// showed two rows as 默认. The upgrade renames the user's kit and repoints every
|
|
929
|
+
// binding that used it.
|
|
930
|
+
const clashHome = join(tmpdir(), `loadout-clash-${process.pid}`)
|
|
931
|
+
rmSync(clashHome, { recursive: true, force: true })
|
|
932
|
+
mkdirSync(join(clashHome, '.dsh', 'sessions', 'ws', 'session-clash'), { recursive: true })
|
|
933
|
+
process.env.USERPROFILE = clashHome
|
|
934
|
+
process.env.HOME = clashHome
|
|
935
|
+
writeFileSync(join(clashHome, '.dsh', 'loadout.json'), JSON.stringify({
|
|
936
|
+
schema: 2,
|
|
937
|
+
kits: [{ name: 'chat', title: '聊天', builtins: ['read'], plugins: [] }],
|
|
938
|
+
defaultKit: 'chat',
|
|
939
|
+
assignments: { 'session-clash': 'chat' },
|
|
940
|
+
migratedAt: new Date().toISOString(),
|
|
941
|
+
}), 'utf8')
|
|
942
|
+
const clash = await import('../index.js?clash=1')
|
|
943
|
+
const ctxClash = makeCtx()
|
|
944
|
+
clash.apply(ctxClash, { defaultKit: '', kits: config.kits })
|
|
945
|
+
const clashStore = JSON.parse(readFileSync(join(clashHome, '.dsh', 'loadout.json'), 'utf8'))
|
|
946
|
+
check('a user kit shadowing a preset is renamed',
|
|
947
|
+
clashStore.kits.length === 1 && clashStore.kits[0].name === 'chat-2'
|
|
948
|
+
&& clashStore.kits[0].title === '聊天' && clashStore.kits[0].builtins.join(',') === 'read',
|
|
949
|
+
JSON.stringify(clashStore.kits))
|
|
950
|
+
check('the renamed kit keeps its default and its bindings',
|
|
951
|
+
clashStore.defaultKit === 'chat-2' && clashStore.assignments['session-clash'] === 'chat-2',
|
|
952
|
+
JSON.stringify({ defaultKit: clashStore.defaultKit, assignments: clashStore.assignments }))
|
|
953
|
+
process.env.USERPROFILE = savedHomeV2
|
|
954
|
+
process.env.HOME = savedHomeV2b
|
|
955
|
+
rmSync(clashHome, { recursive: true, force: true })
|
|
956
|
+
|
|
907
957
|
// ---- legacy store: plugin-kits.json is read once ---------------------------
|
|
908
958
|
const legacyHome = join(tmpdir(), `loadout-legacy-${process.pid}`)
|
|
909
959
|
rmSync(legacyHome, { recursive: true, force: true })
|