dsh-loadout 0.12.2 → 0.12.4
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 +14 -0
- package/client.js +106 -29
- package/index.js +41 -1
- package/package.json +1 -1
- package/scripts/client-smoke.mjs +36 -0
- package/scripts/selftest.mjs +32 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
「装备栏」是一个 DeepSeek Harness 插件:给每个对话选一套工具(套装),只把用得上的工具投给模型。
|
|
4
4
|
|
|
5
|
+
## 0.12.4(本地测试版,未发布)
|
|
6
|
+
|
|
7
|
+
- **重名当场说清楚**:新建/编辑套装时,输入框下方直接给出原因——「「仅聊天」是预置套装(标识 chat),不能用这个名字」,或是「已经有叫「我的套」的套装了」。占用时保存按钮置灰,不用等提交后才失败。
|
|
8
|
+
- 说明重名判定:名字先 trim + NFKC + 转小写,然后同时比对**自建套装的 name 与 title**、**预置套装的 name(稳定标识)与 title(界面显示)**、以及保留字(全部 / 仅内置 / all / default…)。所以预置显示「仅聊天」时,chat 依然被占用——chat 是它的标识。
|
|
9
|
+
- 宿主错误文案同步:「仅聊天」是预置套装(标识 chat),不能占用这个名字。
|
|
10
|
+
- **「+ 新建套装」改成动作色**:蓝色文字 + 浅蓝底 + 悬停加深,右侧补一句「从零配一套」,跟下面那些「点一下就切换」的套装行区分开。
|
|
11
|
+
- **「我的套装」折叠时也留着「+ 新建套装」**:网格收起只显示当前在用的那一套,创建入口始终在最后一行,不用先点开分组。
|
|
12
|
+
|
|
13
|
+
## 0.12.3
|
|
14
|
+
|
|
15
|
+
- **标题栏按钮不再「点一下才对」**:芯片只在菜单被打开时才去读状态,所以进对话先看到「装备栏」,点开一次才换成套装名。现在挂载就读一次,并监听 dsh-loadout:changed 与窗口 focus,别处切了套装也会立刻跟上。
|
|
16
|
+
- **设置页「预设套装」默认折叠**:与「我的套装」一致——折叠时只显示当前被设为默认的那一套,展开才列出五套。
|
|
17
|
+
- **同名撞车**:0.12 之前手建的套装如果叫 chat / plan 这类预置名,
|
|
18
|
+
- 回归断言: 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。
|
|
5
19
|
## 0.12.2
|
|
6
20
|
|
|
7
21
|
- **修「绑定到已不存在的套装」**:如果某个对话绑的套装在应用关着的时候被删掉(旧版本、手改 loadout.json、或别的机器同步过来的文件),启动后它会被当成「空套装」套用——等于把这对话的工具连同内置工具一起裁光,只剩 kit_*。现在这种绑定在第一次被读到时回退到「全部」,并把 ssignments 里那个失效名字改成
|
package/client.js
CHANGED
|
@@ -46,6 +46,28 @@ window.__ModuleLoader__.load({
|
|
|
46
46
|
return kit.note ? `${kit.title}(${kit.note})` : kit.title
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Why a name cannot be saved, in the user's terms. The host compares a kit
|
|
51
|
+
* against preset *ids* as well as titles (a preset's `name` is a stable id —
|
|
52
|
+
* `chat` — while the UI shows its title, 仅聊天), so "chat" is refused even
|
|
53
|
+
* though it "looks different". Return '' when the name is free.
|
|
54
|
+
*/
|
|
55
|
+
function nameClash(raw, presets, kits, ignore) {
|
|
56
|
+
const value = String(raw ?? '').trim().toLowerCase()
|
|
57
|
+
if (value === '') return ''
|
|
58
|
+
const preset = (presets ?? []).find((item) =>
|
|
59
|
+
String(item.name ?? '').toLowerCase() === value || String(item.title ?? '').toLowerCase() === value)
|
|
60
|
+
if (preset !== undefined) {
|
|
61
|
+
return `「${preset.title}」是预置套装(标识 ${preset.name}),不能用这个名字,换一个吧`
|
|
62
|
+
}
|
|
63
|
+
const kit = (kits ?? []).find((item) =>
|
|
64
|
+
String(item.name ?? '').toLowerCase() === value || String(item.title ?? '').toLowerCase() === value)
|
|
65
|
+
if (kit !== undefined && kit.name !== ignore) {
|
|
66
|
+
return `已经有叫「${kit.title ?? kit.name}」的套装了,换一个名字,或者进去编辑它`
|
|
67
|
+
}
|
|
68
|
+
return ''
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
/**
|
|
50
72
|
* Presets (仅聊天 / 编程用 / 查找资料 / 项目梳理 / 写作) are shown again from
|
|
51
73
|
* 0.12: they are the fastest way to start a conversation of a known kind,
|
|
@@ -133,6 +155,10 @@ window.__ModuleLoader__.load({
|
|
|
133
155
|
},
|
|
134
156
|
rowHover: { background: 'rgba(59,130,246,.08)' },
|
|
135
157
|
rowActive: { background: 'rgba(59,130,246,.10)' },
|
|
158
|
+
// The one row that creates something, not selects something: keep it
|
|
159
|
+
// blue and tinted so it reads as an action at a glance.
|
|
160
|
+
rowPrimary: { color: '#2563eb', background: 'rgba(37,99,235,.07)', fontWeight: 600 },
|
|
161
|
+
rowPrimaryHover: { background: 'rgba(37,99,235,.14)' },
|
|
136
162
|
rowDisabled: { opacity: 0.45, cursor: 'default' },
|
|
137
163
|
indent: { paddingLeft: 26 },
|
|
138
164
|
mark: { width: 12, flex: 'none', color: '#2563eb', fontSize: 12, textAlign: 'center' },
|
|
@@ -273,7 +299,8 @@ window.__ModuleLoader__.load({
|
|
|
273
299
|
...styles.row,
|
|
274
300
|
...(props.indent === true ? styles.indent : null),
|
|
275
301
|
...(props.active === true ? styles.rowActive : null),
|
|
276
|
-
...(
|
|
302
|
+
...(props.tone === 'primary' ? styles.rowPrimary : null),
|
|
303
|
+
...(hover && !disabled ? (props.tone === 'primary' ? styles.rowPrimaryHover : styles.rowHover) : null),
|
|
277
304
|
...(disabled ? styles.rowDisabled : null),
|
|
278
305
|
}
|
|
279
306
|
const activate = () => {
|
|
@@ -295,7 +322,7 @@ window.__ModuleLoader__.load({
|
|
|
295
322
|
},
|
|
296
323
|
},
|
|
297
324
|
h('span', { style: styles.mark }, props.active === true ? '✓' : ''),
|
|
298
|
-
h('span', { style: styles.rowTitle },
|
|
325
|
+
h('span', { style: { ...styles.rowTitle, ...(props.tone === 'primary' ? { color: '#2563eb' } : null) } },
|
|
299
326
|
props.tip === undefined
|
|
300
327
|
? h('span', null, props.label)
|
|
301
328
|
: h(Tip, { text: props.tip }, h('span', null, props.label))),
|
|
@@ -337,7 +364,7 @@ window.__ModuleLoader__.load({
|
|
|
337
364
|
function Builder(props) {
|
|
338
365
|
const {
|
|
339
366
|
catalog, draft, onChange, busy, onCancel, onSave, saveLabel,
|
|
340
|
-
title, onSaveAs, saveAsLabel, nameField, namePlaceholder,
|
|
367
|
+
title, onSaveAs, saveAsLabel, nameField, namePlaceholder, clash,
|
|
341
368
|
} = props
|
|
342
369
|
// Both lists start collapsed: the builder opens with a name field and a
|
|
343
370
|
// summary, not with forty checkboxes.
|
|
@@ -368,6 +395,12 @@ window.__ModuleLoader__.load({
|
|
|
368
395
|
value: draft[nameField ?? 'name'] ?? '',
|
|
369
396
|
onChange: (event) => onChange({ ...draft, [nameField ?? 'name']: event.target.value }),
|
|
370
397
|
}),
|
|
398
|
+
// The host rejects a name that a preset or another kit already uses;
|
|
399
|
+
// saying so *here* keeps the user from staring at a save button that
|
|
400
|
+
// silently does nothing.
|
|
401
|
+
typeof clash === 'string' && clash !== ''
|
|
402
|
+
? h('div', { style: { ...styles.error, marginTop: 4 } }, clash)
|
|
403
|
+
: null,
|
|
371
404
|
h('input', {
|
|
372
405
|
style: { ...styles.input, marginTop: 6 },
|
|
373
406
|
placeholder: `备注(可选,最多 ${NOTE_MAX} 字),例如 用于视频制作`,
|
|
@@ -413,7 +446,12 @@ window.__ModuleLoader__.load({
|
|
|
413
446
|
onSaveAs === undefined
|
|
414
447
|
? null
|
|
415
448
|
: h('button', { type: 'button', style: styles.ghost, disabled: busy, onClick: onSaveAs }, saveAsLabel ?? '另存为新套装'),
|
|
416
|
-
h('button', {
|
|
449
|
+
h('button', {
|
|
450
|
+
type: 'button',
|
|
451
|
+
style: { ...styles.primary, ...(typeof clash === 'string' && clash !== '' ? { opacity: 0.5 } : null) },
|
|
452
|
+
disabled: busy || (typeof clash === 'string' && clash !== ''),
|
|
453
|
+
onClick: onSave,
|
|
454
|
+
}, saveLabel ?? '保存')))
|
|
417
455
|
}
|
|
418
456
|
|
|
419
457
|
// ---- the header control -------------------------------------------------
|
|
@@ -429,7 +467,7 @@ window.__ModuleLoader__.load({
|
|
|
429
467
|
// collapsed group still shows the one active row, and clicking the head is
|
|
430
468
|
// how you go browsing.
|
|
431
469
|
const [groups, setGroups] = useState(preview?.groups ?? { kits: false, presets: false, extras: false, applied: false })
|
|
432
|
-
const [builder, setBuilder] = useState(null)
|
|
470
|
+
const [builder, setBuilder] = useState(preview?.builder ?? null)
|
|
433
471
|
const [saveAs, setSaveAs] = useState(false)
|
|
434
472
|
const [saveName, setSaveName] = useState('')
|
|
435
473
|
const [saveNote, setSaveNote] = useState('')
|
|
@@ -475,9 +513,24 @@ window.__ModuleLoader__.load({
|
|
|
475
513
|
}
|
|
476
514
|
}, [sessionId])
|
|
477
515
|
|
|
516
|
+
// The label is this conversation's persisted binding, so read it as soon as
|
|
517
|
+
// the chip mounts: waiting for the first click is what made a bound
|
|
518
|
+
// conversation show 装备栏 until the menu was opened once.
|
|
478
519
|
useEffect(() => {
|
|
479
|
-
|
|
480
|
-
}, [
|
|
520
|
+
refresh()
|
|
521
|
+
}, [refresh])
|
|
522
|
+
|
|
523
|
+
// Another surface (the settings page, the /kit command) may have changed
|
|
524
|
+
// this conversation's binding while the chip sat idle.
|
|
525
|
+
useEffect(() => {
|
|
526
|
+
const sync = () => { refresh() }
|
|
527
|
+
document.addEventListener('dsh-loadout:changed', sync)
|
|
528
|
+
window.addEventListener('focus', sync)
|
|
529
|
+
return () => {
|
|
530
|
+
document.removeEventListener('dsh-loadout:changed', sync)
|
|
531
|
+
window.removeEventListener('focus', sync)
|
|
532
|
+
}
|
|
533
|
+
}, [refresh])
|
|
481
534
|
|
|
482
535
|
useEffect(() => {
|
|
483
536
|
if (!open) return undefined
|
|
@@ -681,9 +734,10 @@ window.__ModuleLoader__.load({
|
|
|
681
734
|
tip: '你自己保存的套装。点一下即可切换。',
|
|
682
735
|
onToggle: () => setGroups({ ...groups, kits: !groups.kits }),
|
|
683
736
|
}),
|
|
684
|
-
// A collapsed group shows
|
|
685
|
-
//
|
|
737
|
+
// A collapsed group shows the set that is on, plus the one action that
|
|
738
|
+
// creates a set — so "new kit" never needs a click on the head first.
|
|
686
739
|
activeUserKit === undefined ? null : h(MenuRow, {
|
|
740
|
+
key: 'active-kit',
|
|
687
741
|
label: kitLabel(activeUserKit),
|
|
688
742
|
hint: activeUserKit.isDefault ? '默认' : '',
|
|
689
743
|
active: true,
|
|
@@ -691,27 +745,31 @@ window.__ModuleLoader__.load({
|
|
|
691
745
|
onClick: () => run({ action: 'applyBase', base: 'kit', kit: activeUserKit.name }),
|
|
692
746
|
}),
|
|
693
747
|
groups.kits === true
|
|
694
|
-
?
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
.
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
hint: kit.isDefault ? '默认' : '',
|
|
701
|
-
indent: true,
|
|
702
|
-
onClick: () => {
|
|
703
|
-
run({ action: 'applyBase', base: 'kit', kit: kit.name })
|
|
704
|
-
// Picking one puts the list away again; the row that stays is the
|
|
705
|
-
// one just chosen, right under the group head.
|
|
706
|
-
setGroups({ ...groups, kits: false })
|
|
707
|
-
},
|
|
708
|
-
})),
|
|
709
|
-
h(MenuRow, {
|
|
710
|
-
label: '+ 新建套装',
|
|
748
|
+
? kits
|
|
749
|
+
.filter((kit) => activeUserKit === undefined || kit.name !== activeUserKit.name)
|
|
750
|
+
.map((kit) => h(MenuRow, {
|
|
751
|
+
key: kit.name,
|
|
752
|
+
label: kitLabel(kit),
|
|
753
|
+
hint: kit.isDefault ? '默认' : '',
|
|
711
754
|
indent: true,
|
|
712
|
-
onClick: () =>
|
|
755
|
+
onClick: () => {
|
|
756
|
+
run({ action: 'applyBase', base: 'kit', kit: kit.name })
|
|
757
|
+
// Picking one puts the list away again; the row that stays is the
|
|
758
|
+
// one just chosen, right under the group head.
|
|
759
|
+
setGroups({ ...groups, kits: false })
|
|
760
|
+
},
|
|
713
761
|
}))
|
|
714
762
|
: null,
|
|
763
|
+
// Always last inside 我的套装, open or collapsed: the create action sits
|
|
764
|
+
// where the eye ends up after scanning the kits.
|
|
765
|
+
h(MenuRow, {
|
|
766
|
+
key: 'new-kit',
|
|
767
|
+
label: '+ 新建套装',
|
|
768
|
+
tone: 'primary',
|
|
769
|
+
hint: '从零配一套',
|
|
770
|
+
indent: true,
|
|
771
|
+
onClick: () => openBuilder({ builtins: allBuiltins, plugins: [] }),
|
|
772
|
+
}),
|
|
715
773
|
SHOW_PRESETS === true
|
|
716
774
|
? h(GroupHead, {
|
|
717
775
|
title: '预设套装',
|
|
@@ -835,6 +893,7 @@ window.__ModuleLoader__.load({
|
|
|
835
893
|
onCancel: () => setBuilder(null),
|
|
836
894
|
onSave: saveBuilder,
|
|
837
895
|
saveLabel: '保存并启用',
|
|
896
|
+
clash: nameClash(builder.name, presets, kits, null),
|
|
838
897
|
})
|
|
839
898
|
|
|
840
899
|
return h('div', { style: styles.wrap, ref: box },
|
|
@@ -939,14 +998,14 @@ window.__ModuleLoader__.load({
|
|
|
939
998
|
const [data, setData] = useState(preview?.data ?? null)
|
|
940
999
|
const [busy, setBusy] = useState(false)
|
|
941
1000
|
const [message, setMessage] = useState('')
|
|
942
|
-
const [draft, setDraft] = useState(null)
|
|
1001
|
+
const [draft, setDraft] = useState(preview?.draft ?? null)
|
|
943
1002
|
// Editing a saved kit reuses the same builder as creating one; only the
|
|
944
1003
|
// action buttons differ (覆盖保存 keeps the name, 另存为新套装 mints a new one).
|
|
945
1004
|
const [editingKit, setEditingKit] = useState(null)
|
|
946
1005
|
// 我的套装 starts collapsed, like the chip: the page's job is "which set does
|
|
947
1006
|
// a new conversation start with", and a list of five kits does not answer it.
|
|
948
1007
|
// The test seam can seed it open so the card assertions never depend on a click.
|
|
949
|
-
const [openSections, setOpenSections] = useState(preview?.openSections ?? { presets:
|
|
1008
|
+
const [openSections, setOpenSections] = useState(preview?.openSections ?? { presets: false, kits: false })
|
|
950
1009
|
|
|
951
1010
|
const refresh = useCallback(async () => {
|
|
952
1011
|
try {
|
|
@@ -1007,6 +1066,7 @@ window.__ModuleLoader__.load({
|
|
|
1007
1066
|
// A collapsed 我的套装 still answers "which kit is the default", the same way
|
|
1008
1067
|
// the chip does.
|
|
1009
1068
|
const defaultKitRow = kits.find((kit) => kit.name === data.defaultKit)
|
|
1069
|
+
const defaultPresetRow = presets.find((preset) => preset.name === data.defaultKit)
|
|
1010
1070
|
|
|
1011
1071
|
const saveDraft = async () => {
|
|
1012
1072
|
if (draft === null) return
|
|
@@ -1130,6 +1190,7 @@ window.__ModuleLoader__.load({
|
|
|
1130
1190
|
onCancel: () => setDraft(null),
|
|
1131
1191
|
onSave: saveDraft,
|
|
1132
1192
|
saveLabel: '保存',
|
|
1193
|
+
clash: nameClash(draft.name, presets, kits, null),
|
|
1133
1194
|
})),
|
|
1134
1195
|
message === '' ? null : h('div', { style: styles.error }, message),
|
|
1135
1196
|
h('div', { style: styles.ledger },
|
|
@@ -1195,6 +1256,21 @@ window.__ModuleLoader__.load({
|
|
|
1195
1256
|
onDelete: () => {},
|
|
1196
1257
|
}))))
|
|
1197
1258
|
: null,
|
|
1259
|
+
SHOW_PRESETS === true && openSections.presets === false
|
|
1260
|
+
// Collapsed, exactly like 我的套装: only the preset that is the
|
|
1261
|
+
// default stays visible, so the group still answers "new conversations
|
|
1262
|
+
// start with this".
|
|
1263
|
+
? (defaultPresetRow === undefined
|
|
1264
|
+
? null
|
|
1265
|
+
: h(MenuRow, {
|
|
1266
|
+
key: `default-preset-${defaultPresetRow.name}`,
|
|
1267
|
+
label: kitLabel(defaultPresetRow),
|
|
1268
|
+
hint: '当前默认',
|
|
1269
|
+
indent: true,
|
|
1270
|
+
active: true,
|
|
1271
|
+
onClick: () => act({ action: 'default', name: defaultPresetRow.name }),
|
|
1272
|
+
}))
|
|
1273
|
+
: null,
|
|
1198
1274
|
h(GroupHead, {
|
|
1199
1275
|
title: '我的套装',
|
|
1200
1276
|
count: kits.length,
|
|
@@ -1265,6 +1341,7 @@ window.__ModuleLoader__.load({
|
|
|
1265
1341
|
},
|
|
1266
1342
|
onSave: saveOverwrite,
|
|
1267
1343
|
saveLabel: '覆盖保存',
|
|
1344
|
+
clash: nameClash(draft.title, presets, kits, editingKit),
|
|
1268
1345
|
onSaveAs: saveEditingAsNew,
|
|
1269
1346
|
saveAsLabel: '另存为新套装',
|
|
1270
1347
|
}))
|
package/index.js
CHANGED
|
@@ -98,7 +98,8 @@ function assertKitName(raw, presets) {
|
|
|
98
98
|
if (NAME_FORBIDDEN.test(value)) throw new Error('名字里不能有 / \\ : * ? " < > | 或控制字符')
|
|
99
99
|
const lower = value.toLowerCase()
|
|
100
100
|
if (RESERVED_NAMES.includes(lower)) throw new Error(`"${value}" 是保留名字,请换一个名字`)
|
|
101
|
-
|
|
101
|
+
const preset = presets === undefined ? undefined : findKit(presets, value)
|
|
102
|
+
if (preset !== undefined) throw new Error(`「${preset.title}」是预置套装(标识 ${preset.name}),不能占用这个名字`)
|
|
102
103
|
return value
|
|
103
104
|
}
|
|
104
105
|
|
|
@@ -418,7 +419,46 @@ export function apply(ctx, config = {}) {
|
|
|
418
419
|
}
|
|
419
420
|
}
|
|
420
421
|
|
|
422
|
+
/**
|
|
423
|
+
* A kit saved before the preset list existed may carry a preset's name or
|
|
424
|
+
* title (`chat`, `plan`, `写作`…). `resolveKit` prefers user kits, so a
|
|
425
|
+
* default naming such a preset would actually answer the user's kit while the
|
|
426
|
+
* settings page marked the preset — two rows claiming 「默认」. Renaming is the
|
|
427
|
+
* only honest fix: keep the user's kit (with its tools) under a free name and
|
|
428
|
+
* repoint every binding that used the old one.
|
|
429
|
+
*/
|
|
430
|
+
function disambiguatePresetClashes() {
|
|
431
|
+
const taken = new Set(presets.flatMap((preset) => [preset.name.toLowerCase(), preset.title.toLowerCase()]))
|
|
432
|
+
const changed = []
|
|
433
|
+
for (const kit of book.kits) {
|
|
434
|
+
const nameClash = taken.has(kit.name.toLowerCase())
|
|
435
|
+
const titleClash = taken.has(kit.title.toLowerCase())
|
|
436
|
+
if (!nameClash && !titleClash) continue
|
|
437
|
+
const before = kit.name
|
|
438
|
+
if (nameClash) {
|
|
439
|
+
let suffix = 2
|
|
440
|
+
let candidate = `${before}-${suffix}`
|
|
441
|
+
while (taken.has(candidate.toLowerCase()) || findKit(book.kits, candidate) !== undefined) {
|
|
442
|
+
suffix += 1
|
|
443
|
+
candidate = `${before}-${suffix}`
|
|
444
|
+
}
|
|
445
|
+
kit.name = candidate
|
|
446
|
+
taken.add(candidate.toLowerCase())
|
|
447
|
+
}
|
|
448
|
+
if (titleClash) kit.title = `${kit.title}(自建)`
|
|
449
|
+
for (const [sessionId, value] of Object.entries(book.assignments)) {
|
|
450
|
+
if (value === before) book.assignments[sessionId] = kit.name
|
|
451
|
+
}
|
|
452
|
+
if (book.defaultKit === before) book.defaultKit = kit.name
|
|
453
|
+
changed.push(`${before} → ${kit.name}`)
|
|
454
|
+
}
|
|
455
|
+
if (changed.length === 0) return
|
|
456
|
+
save()
|
|
457
|
+
ctx.logger?.warn(`dsh-loadout: 自建套装与预置同名,已改名(没有删除):${changed.join(',')}`)
|
|
458
|
+
}
|
|
459
|
+
|
|
421
460
|
migrateBook()
|
|
461
|
+
disambiguatePresetClashes()
|
|
422
462
|
|
|
423
463
|
/** agent -> { selection: {base,kit,extras}, label, dispose } */
|
|
424
464
|
const state = new Map()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-loadout",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.4",
|
|
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
|
@@ -233,6 +233,8 @@ if (menuDefault !== undefined) {
|
|
|
233
233
|
check('presets start collapsed', !flat.includes('写作'), flat.slice(0, 400))
|
|
234
234
|
check('user kits start collapsed', !flat.includes('另一套'), flat.slice(0, 400))
|
|
235
235
|
check('the active kit is the only kit row while collapsed', flat.includes('我的套(用于视频制作)'), flat.slice(0, 400))
|
|
236
|
+
// Creating a kit must not need a click on the group head first.
|
|
237
|
+
check('the new-kit row is visible while my kits are collapsed', flat.includes('+ 新建套装'), flat.slice(0, 400))
|
|
236
238
|
check('applied plugins start collapsed', !flat.includes('alpha 插件'), flat.slice(0, 400))
|
|
237
239
|
check('applied plugins collapse into one summary row', flat.includes('已应用 1 个插件'), flat.slice(0, 400))
|
|
238
240
|
check('menu opens with a status line', flat.includes('当前:我的套') && flat.includes('加挂 1'), flat.slice(0, 200))
|
|
@@ -344,9 +346,43 @@ try {
|
|
|
344
346
|
check('kit cards show their own estimate', panelText.includes('≈860 tokens') && panelText.includes('≈420 tokens'), panelText.slice(0, 400))
|
|
345
347
|
const deleteButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '删除')
|
|
346
348
|
check('only my kits can be deleted', deleteButtons.length === 2, `${deleteButtons.length} · 删除@${panelText.indexOf('删除')} · ${panelText.slice(0, 200)}`)
|
|
349
|
+
|
|
350
|
+
// Presets start collapsed, and while collapsed they behave like 我的套装: the
|
|
351
|
+
// one that is the default stays visible so the page still answers "a new
|
|
352
|
+
// conversation starts with this".
|
|
353
|
+
const presetDefault = { ...fixture, defaultKit: 'chat', selection: { base: 'kit', kit: 'chat', extras: [] } }
|
|
354
|
+
const collapsed = expand(registration.component({ preview: { data: presetDefault } }))
|
|
355
|
+
const collapsedText = textOf(collapsed)
|
|
356
|
+
check('presets start collapsed',
|
|
357
|
+
collapsedText.includes('预设套装 · 只读(5)') && !collapsedText.includes('写作'), collapsedText.slice(0, 400))
|
|
358
|
+
check('a collapsed preset group keeps the default visible',
|
|
359
|
+
collapsedText.includes('仅聊天') && collapsedText.includes('当前默认'), collapsedText.slice(0, 400))
|
|
360
|
+
const presetExpanded = expand(registration.component({ preview: { data: presetDefault, openSections: { presets: true, kits: false } } }))
|
|
361
|
+
const presetExpandedText = textOf(presetExpanded)
|
|
362
|
+
check('only the default preset carries the default tag',
|
|
363
|
+
presetExpandedText.includes('仅聊天') && presetExpandedText.includes('默认'), presetExpandedText.slice(0, 400))
|
|
364
|
+
// 「+ 新建套装」 is the only row that creates something; it must read as an
|
|
365
|
+
// action (blue, tinted), not as another selectable kit.
|
|
366
|
+
const menuWithKits = expand(header.component({ sessionId: 'session-1', preview: { data: fixture, open: true, groups: { kits: true, presets: false, extras: false } } }))
|
|
367
|
+
const newKit = nodesIn(menuWithKits, (node) => node.props?.role === 'menuitem' && node.props?.style?.color === '#2563eb' && textOf(node).includes('+ 新建套装'))
|
|
368
|
+
check('the new-kit row is highlighted as an action', newKit.length === 1, String(newKit.length))
|
|
347
369
|
const renameButtons = nodesIn(panel, (node) => node.type === 'button' && textOf(node) === '重命名')
|
|
348
370
|
// Renaming moved into 编辑: one editor for name, note and tools on the card.
|
|
349
371
|
check('renaming lives inside 编辑 rather than its own button', renameButtons.length === 0 && editButtons.length === 2, `${renameButtons.length} · 重命名 / ${editButtons.length} · 编辑`)
|
|
372
|
+
|
|
373
|
+
// A name the host would refuse is explained where the user typed it: a preset's
|
|
374
|
+
// `name` is a private id (`chat`), so "chat" looks free and is not.
|
|
375
|
+
const clashPanel = expand(registration.component({
|
|
376
|
+
preview: { data: fixture, draft: { name: 'chat', note: '', builtins: ['read'], plugins: [] } },
|
|
377
|
+
}))
|
|
378
|
+
const clashText = textOf(clashPanel)
|
|
379
|
+
check('a name taken by a preset is explained inline',
|
|
380
|
+
clashText.includes('是预置套装') && clashText.includes('仅聊天'), clashText.slice(0, 600))
|
|
381
|
+
const clashKitPanel = expand(registration.component({
|
|
382
|
+
preview: { data: fixture, draft: { name: '我的套', note: '', builtins: ['read'], plugins: [] } },
|
|
383
|
+
}))
|
|
384
|
+
check('a name taken by my own kit is explained inline',
|
|
385
|
+
textOf(clashKitPanel).includes('已经有叫「我的套」的套装了'), textOf(clashKitPanel).slice(0, 600))
|
|
350
386
|
} catch (error) {
|
|
351
387
|
failures.push(`panel render threw: ${error instanceof Error ? error.message : String(error)}`)
|
|
352
388
|
}
|
package/scripts/selftest.mjs
CHANGED
|
@@ -922,6 +922,38 @@ process.env.USERPROFILE = savedHomeV2
|
|
|
922
922
|
process.env.HOME = savedHomeV2b
|
|
923
923
|
rmSync(v2Home, { recursive: true, force: true })
|
|
924
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
|
+
|
|
925
957
|
// ---- legacy store: plugin-kits.json is read once ---------------------------
|
|
926
958
|
const legacyHome = join(tmpdir(), `loadout-legacy-${process.pid}`)
|
|
927
959
|
rmSync(legacyHome, { recursive: true, force: true })
|