dsh-loadout 0.0.0-stage → 0.11.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/client.js ADDED
@@ -0,0 +1,1321 @@
1
+ /**
2
+ * 装备栏 dsh-loadout — the browser half.
3
+ *
4
+ * Two entry points, and only two:
5
+ * 1. the conversation header (the collapsible chip next to the title bar),
6
+ * 2. the settings page (the full editor).
7
+ *
8
+ * The header is the day-to-day control. It shows one base plus an optional set
9
+ * of extras:
10
+ *
11
+ * 全部 no restriction at all (built-ins + every enabled plugin)
12
+ * 仅内置 the Harness's own built-ins only
13
+ * 我的套装 a kit the user saved (the active one stays visible when collapsed)
14
+ * 预设套装 the shipped, read-only kits (写作 / 调研 / 编程)
15
+ * 加挂 plugins attached on top of the base without saving a kit
16
+ *
17
+ * Every action posts to the same host route the settings page uses, so the two
18
+ * can never disagree about what a conversation is running.
19
+ */
20
+ // `id` must be the package name, exactly (`dsh-loadout`) — never a scoped or
21
+ // `@local/…` alias. The boot graph looks the factory up under the package name; a
22
+ // different id means "this bundle loaded without registering", so the loader
23
+ // re-executes it on its fallback URL and the second registration throws
24
+ // `duplicate factory registration`, which fails the entry and the whole Web boot.
25
+ // scripts/preflight.mjs checks this against package.json before every install.
26
+ window.__ModuleLoader__.load({
27
+ id: 'dsh-loadout',
28
+ factory(require) {
29
+ const React = require('react')
30
+ const h = React.createElement
31
+ const { useState, useEffect, useCallback, useRef } = React
32
+
33
+ const ENDPOINT = '/dsh-loadout/state'
34
+
35
+ /** This plugin's own tools never belong inside a kit. */
36
+ const OWN_TOOLS = ['kit_list', 'kit_apply', 'kit_create', 'kit_set_tools', 'kit_set_default', 'kit_delete']
37
+
38
+ /** The exact sentence the spec fixes for the panel footer. */
39
+ const FOOTER = '装备栏只决定「这个对话能用哪些工具」:切换后下一条消息即按新清单组装,随时可以切回「全部」;新对话则从「设置 → 装备栏」的默认套装开始。它作用于工具(tool)这一层——界面面板、宿主服务类插件不注册工具,因此不受影响。'
40
+
41
+ /** A kit's one-line reminder; keep in step with NOTE_MAX in index.js. */
42
+ const NOTE_MAX = 20
43
+
44
+ /** `套装1(用于视频制作)` — the note is a reminder, not part of the name. */
45
+ function kitLabel(kit) {
46
+ return kit.note ? `${kit.title}(${kit.note})` : kit.title
47
+ }
48
+
49
+ /**
50
+ * Presets (写作 / 调研 / 编程) are held back from this release.
51
+ *
52
+ * They still ship in `cordis.patch.yml` and `/kit 写作` still works, so they
53
+ * stay usable for our own testing — they are simply not rendered in the chip
54
+ * or the settings page, because a shipped preset that quietly hides tools is
55
+ * easy to mistake for a bug ("where did web_search go?"). Flip this to true
56
+ * to bring both lists back.
57
+ */
58
+ const SHOW_PRESETS = false
59
+
60
+ /**
61
+ * Token counts are estimates (tool-definition bytes ÷ ~3.5 chars per token).
62
+ * Round hard: a precise-looking number would invite arithmetic on a guess.
63
+ */
64
+ function formatTokens(value) {
65
+ const n = Math.round(Number(value) || 0)
66
+ if (n < 1000) return String(n)
67
+ return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}k`
68
+ }
69
+
70
+ // Every mutation fans out to the other panel. The chip and the settings page
71
+ // are separate React trees with separate snapshots, so a switch made in one
72
+ // used to leave the other showing the old numbers until it remounted.
73
+ const CHANGED_EVENT = 'dsh-loadout:changed'
74
+
75
+ function announce() {
76
+ try {
77
+ document.dispatchEvent(new CustomEvent(CHANGED_EVENT))
78
+ } catch {
79
+ // A document without CustomEvent still gets the local setData() refresh.
80
+ }
81
+ }
82
+
83
+ async function request(payload) {
84
+ const response = await fetch(ENDPOINT, {
85
+ method: 'POST',
86
+ headers: { 'content-type': 'application/json' },
87
+ body: JSON.stringify(payload),
88
+ })
89
+ const body = await response.json().catch(() => ({}))
90
+ if (!response.ok || body.ok === false) throw new Error(body.error ?? `HTTP ${response.status}`)
91
+ announce()
92
+ return body
93
+ }
94
+
95
+ // A GET carries the Session it is about: the host keeps one selection per
96
+ // conversation, and without this the chip would show whichever one was
97
+ // touched last. A caller with no Session (the settings panel) keeps the bare
98
+ // URL, which the host answers from the most recently seen agent.
99
+ async function load(sessionId) {
100
+ const url = typeof sessionId === 'string' && sessionId !== ''
101
+ ? `${ENDPOINT}?sessionId=${encodeURIComponent(sessionId)}`
102
+ : ENDPOINT
103
+ const response = await fetch(url, { method: 'GET' })
104
+ const body = await response.json().catch(() => ({}))
105
+ if (!response.ok || body.ok === false) throw new Error(body.error ?? `HTTP ${response.status}`)
106
+ return body
107
+ }
108
+
109
+ const styles = {
110
+ wrap: { position: 'relative', display: 'inline-flex', alignItems: 'center' },
111
+ chip: {
112
+ display: 'inline-flex', alignItems: 'center', gap: 6, height: 28, padding: '0 10px',
113
+ border: '1px solid var(--ds-border, #d8dbe0)', borderRadius: 8, background: 'transparent',
114
+ color: 'var(--ds-text, inherit)', font: 'inherit', fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap',
115
+ },
116
+ chipActive: { borderColor: '#3b82f6', color: '#2563eb' },
117
+ dot: { width: 6, height: 6, borderRadius: 999, background: '#3b82f6', display: 'inline-block' },
118
+ panel: {
119
+ position: 'absolute', top: 34, right: 0, width: 370, maxHeight: 520, overflow: 'auto',
120
+ background: 'var(--ds-surface, #fff)', color: 'var(--ds-text, inherit)',
121
+ border: '1px solid var(--ds-border, #d8dbe0)', borderRadius: 10,
122
+ boxShadow: '0 12px 32px rgba(0,0,0,.16)', padding: 10, zIndex: 60, fontSize: 12,
123
+ },
124
+ section: { padding: '6px 4px' },
125
+ sectionTitle: { fontSize: 11, opacity: 0.6, margin: '2px 0 6px' },
126
+ groupHead: {
127
+ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 6px', borderRadius: 6,
128
+ cursor: 'pointer', userSelect: 'none',
129
+ },
130
+ groupHeadHover: { background: 'rgba(59,130,246,.06)' },
131
+ groupTitle: { fontWeight: 600 },
132
+ caret: { width: 10, fontSize: 10, opacity: 0.55, flex: 'none' },
133
+ count: { fontWeight: 400, opacity: 0.55 },
134
+ row: {
135
+ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 6px', borderRadius: 6, cursor: 'pointer',
136
+ },
137
+ rowHover: { background: 'rgba(59,130,246,.08)' },
138
+ rowActive: { background: 'rgba(59,130,246,.10)' },
139
+ rowDisabled: { opacity: 0.45, cursor: 'default' },
140
+ indent: { paddingLeft: 26 },
141
+ mark: { width: 12, flex: 'none', color: '#2563eb', fontSize: 12, textAlign: 'center' },
142
+ rowTitle: { display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 },
143
+ meta: { fontSize: 11, opacity: 0.6, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
144
+ topline: { display: 'flex', alignItems: 'center', gap: 6, padding: '2px 6px 8px' },
145
+ summary: {
146
+ fontSize: 11, opacity: 0.72, whiteSpace: 'nowrap',
147
+ overflow: 'hidden', textOverflow: 'ellipsis', flex: '1 1 auto', minWidth: 0,
148
+ },
149
+ divider: { height: 1, background: 'var(--ds-border, #e5e7eb)', margin: '6px 2px' },
150
+ grid: { display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 180, overflow: 'auto', padding: 2 },
151
+ tool: {
152
+ padding: '2px 6px', borderRadius: 5, border: '1px solid var(--ds-border, #d8dbe0)',
153
+ background: 'transparent', color: 'inherit', font: 'inherit', fontSize: 11, cursor: 'pointer',
154
+ },
155
+ toolOn: { background: 'rgba(59,130,246,.14)', borderColor: '#3b82f6', color: '#2563eb' },
156
+ actions: { display: 'flex', gap: 6, justifyContent: 'flex-end', marginTop: 6 },
157
+ primary: {
158
+ padding: '4px 10px', borderRadius: 6, border: '1px solid #3b82f6', background: '#3b82f6',
159
+ color: '#fff', font: 'inherit', fontSize: 12, cursor: 'pointer',
160
+ },
161
+ ghost: {
162
+ padding: '4px 10px', borderRadius: 6, border: '1px solid var(--ds-border, #d8dbe0)',
163
+ background: 'transparent', color: 'inherit', font: 'inherit', fontSize: 12, cursor: 'pointer',
164
+ },
165
+ hint: { fontSize: 11, opacity: 0.6, marginTop: 6, lineHeight: 1.5 },
166
+ ledger: {
167
+ fontSize: 11, marginTop: 8, lineHeight: 1.6, padding: '6px 8px', borderRadius: 6,
168
+ background: 'rgba(59,130,246,.06)', color: 'var(--ds-text, #334155)',
169
+ },
170
+ ledgerNumber: { fontWeight: 600 },
171
+ footer: {
172
+ fontSize: 11, opacity: 0.62, marginTop: 8, lineHeight: 1.6,
173
+ borderTop: '1px solid var(--ds-border, #e5e7eb)', paddingTop: 6,
174
+ },
175
+ error: { fontSize: 11, color: '#dc2626', marginTop: 6, lineHeight: 1.5 },
176
+ notice: { fontSize: 11, color: '#2563eb', marginTop: 6, lineHeight: 1.5 },
177
+ input: {
178
+ width: '100%', boxSizing: 'border-box', padding: '5px 8px', borderRadius: 6,
179
+ border: '1px solid var(--ds-border, #d8dbe0)', background: 'transparent', color: 'inherit',
180
+ font: 'inherit', fontSize: 12,
181
+ },
182
+ iconButton: {
183
+ border: 'none', background: 'transparent', color: 'inherit', cursor: 'pointer',
184
+ fontSize: 13, lineHeight: 1, padding: '2px 4px', opacity: 0.7, flex: 'none',
185
+ },
186
+ savePanel: {
187
+ margin: '4px 2px 6px', padding: 8, borderRadius: 8,
188
+ border: '1px solid var(--ds-border, #e5e7eb)',
189
+ },
190
+ tipWrap: { position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 4, minWidth: 0 },
191
+ bubble: {
192
+ position: 'absolute', left: 0, top: 'calc(100% + 6px)', zIndex: 90, width: 236,
193
+ padding: '6px 8px', borderRadius: 6, background: 'var(--ds-tooltip-bg, #1f2937)', color: '#fff',
194
+ fontSize: 11, lineHeight: 1.55, boxShadow: '0 6px 18px rgba(0,0,0,.25)',
195
+ // CJK breaks at any character by default, which splits a sentence in the
196
+ // middle of a word (对话 → 对 / 话). keep-all only breaks at punctuation,
197
+ // so the hint wraps into whole clauses; break-word still rescues an
198
+ // over-long Latin token.
199
+ wordBreak: 'keep-all', overflowWrap: 'break-word',
200
+ pointerEvents: 'none', transition: 'opacity .12s ease',
201
+ },
202
+ card: { border: '1px solid var(--ds-border, #e5e7eb)', borderRadius: 8, padding: '8px 10px' },
203
+ // The builder is an active form, not a read-only card: a blue frame plus a
204
+ // faint tint is the cheapest way to say "this one is editable" without
205
+ // adding a header bar.
206
+ editorCard: {
207
+ border: '1px solid #60a5fa', borderRadius: 8, padding: '8px 10px',
208
+ background: 'rgba(37, 99, 235, 0.05)',
209
+ },
210
+ tag: { fontSize: 10, padding: '0 5px', borderRadius: 4, border: '1px solid currentColor', opacity: 0.7 },
211
+ }
212
+
213
+ // ---- small pieces -------------------------------------------------------
214
+ function Toggle(props) {
215
+ const { on } = props
216
+ return h('span', {
217
+ style: {
218
+ width: 14, height: 14, borderRadius: 4, border: '1px solid',
219
+ borderColor: on ? '#3b82f6' : 'var(--ds-border, #c8ccd2)',
220
+ background: on ? '#3b82f6' : 'transparent',
221
+ color: '#fff', fontSize: 10, lineHeight: '13px', textAlign: 'center', flex: 'none',
222
+ },
223
+ }, on ? '✓' : '')
224
+ }
225
+
226
+ /**
227
+ * A floating hint, not a line of layout text: the bubble is always in the
228
+ * tree but hidden (opacity 0, no pointer events), so opening it never
229
+ * shifts a row. Hover or keyboard focus opens it after ~300ms.
230
+ */
231
+ function Tip(props) {
232
+ const [show, setShow] = useState(false)
233
+ const timer = useRef(null)
234
+ const open = () => {
235
+ if (timer.current !== null) clearTimeout(timer.current)
236
+ timer.current = setTimeout(() => setShow(true), 300)
237
+ }
238
+ const close = () => {
239
+ if (timer.current !== null) clearTimeout(timer.current)
240
+ timer.current = null
241
+ setShow(false)
242
+ }
243
+ return h('span', {
244
+ style: styles.tipWrap,
245
+ tabIndex: 0,
246
+ onMouseEnter: open,
247
+ onMouseLeave: close,
248
+ onFocus: open,
249
+ onBlur: close,
250
+ },
251
+ props.children,
252
+ h('span', {
253
+ role: 'tooltip',
254
+ style: { ...styles.bubble, opacity: show ? 1 : 0, visibility: show ? 'visible' : 'hidden' },
255
+ }, props.text))
256
+ }
257
+
258
+ function ToolGrid(props) {
259
+ const { tools, selected, onToggle, empty } = props
260
+ const chosen = new Set(selected)
261
+ if (tools.length === 0) return h('div', { style: styles.meta }, empty ?? '没有可选的工具')
262
+ return h('div', { style: styles.grid },
263
+ tools.map((toolName) => h('button', {
264
+ key: toolName,
265
+ type: 'button',
266
+ style: { ...styles.tool, ...(chosen.has(toolName) ? styles.toolOn : null) },
267
+ onClick: () => onToggle(toolName),
268
+ }, toolName)))
269
+ }
270
+
271
+ /** A selectable line: ✓ marker, label (with an optional tooltip), hint. */
272
+ function MenuRow(props) {
273
+ const [hover, setHover] = useState(false)
274
+ const disabled = props.disabled === true
275
+ const style = {
276
+ ...styles.row,
277
+ ...(props.indent === true ? styles.indent : null),
278
+ ...(props.active === true ? styles.rowActive : null),
279
+ ...(hover && !disabled ? styles.rowHover : null),
280
+ ...(disabled ? styles.rowDisabled : null),
281
+ }
282
+ const activate = () => {
283
+ if (!disabled && typeof props.onClick === 'function') props.onClick()
284
+ }
285
+ return h('div', {
286
+ role: 'menuitem',
287
+ tabIndex: disabled ? undefined : 0,
288
+ 'aria-disabled': disabled ? 'true' : 'false',
289
+ style,
290
+ onMouseEnter: () => setHover(true),
291
+ onMouseLeave: () => setHover(false),
292
+ onClick: activate,
293
+ onKeyDown: (event) => {
294
+ if (event.key === 'Enter' || event.key === ' ') {
295
+ event.preventDefault()
296
+ activate()
297
+ }
298
+ },
299
+ },
300
+ h('span', { style: styles.mark }, props.active === true ? '✓' : ''),
301
+ h('span', { style: styles.rowTitle },
302
+ props.tip === undefined
303
+ ? h('span', null, props.label)
304
+ : h(Tip, { text: props.tip }, h('span', null, props.label))),
305
+ h('span', { style: { flex: 1 } }),
306
+ props.hint === undefined || props.hint === '' ? null : h('span', { style: styles.meta }, props.hint))
307
+ }
308
+
309
+ /** A collapsible group header: caret, title with its count, optional action. */
310
+ function GroupHead(props) {
311
+ const [hover, setHover] = useState(false)
312
+ return h('div', {
313
+ style: { ...styles.groupHead, ...(hover ? styles.groupHeadHover : null) },
314
+ onMouseEnter: () => setHover(true),
315
+ onMouseLeave: () => setHover(false),
316
+ onClick: () => props.onToggle(),
317
+ role: 'button',
318
+ tabIndex: 0,
319
+ 'aria-expanded': props.open ? 'true' : 'false',
320
+ onKeyDown: (event) => {
321
+ if (event.key === 'Enter' || event.key === ' ') {
322
+ event.preventDefault()
323
+ props.onToggle()
324
+ }
325
+ },
326
+ },
327
+ h('span', { style: styles.caret }, props.open ? '▾' : '▸'),
328
+ h(Tip, { text: props.tip },
329
+ h('span', { style: styles.groupTitle }, props.title),
330
+ props.count === undefined ? null : h('span', { style: styles.count }, `(${props.count})`)),
331
+ h('span', { style: { flex: 1 } }),
332
+ props.action ?? null)
333
+ }
334
+
335
+ // ---- the kit builder ----------------------------------------------------
336
+ /**
337
+ * `+ 新建套装`: a name plus two collapsible pickers. Built-ins start fully
338
+ * checked (a new kit means "the usual Harness tools, unless I uncheck one").
339
+ */
340
+ function Builder(props) {
341
+ const {
342
+ catalog, draft, onChange, busy, onCancel, onSave, saveLabel,
343
+ title, onSaveAs, saveAsLabel, nameField, namePlaceholder,
344
+ } = props
345
+ // Both lists start collapsed: the builder opens with a name field and a
346
+ // summary, not with forty checkboxes.
347
+ const [openBuiltins, setOpenBuiltins] = useState(false)
348
+ const [openPlugins, setOpenPlugins] = useState(false)
349
+ const builtins = (catalog.builtins ?? []).filter((toolName) => !OWN_TOOLS.includes(toolName))
350
+ const plugins = catalog.plugins ?? []
351
+ const toggleBuiltin = (toolName) => {
352
+ const set = new Set(draft.builtins)
353
+ if (set.has(toolName)) set.delete(toolName)
354
+ else set.add(toolName)
355
+ onChange({ ...draft, builtins: [...set] })
356
+ }
357
+ const togglePlugin = (id) => {
358
+ const set = new Set(draft.plugins)
359
+ if (set.has(id)) set.delete(id)
360
+ else set.add(id)
361
+ onChange({ ...draft, plugins: [...set] })
362
+ }
363
+ return h('div', null,
364
+ h('div', { style: styles.sectionTitle }, title ?? '新建套装'),
365
+ // Creating binds this field to `name` (the identity the host stores);
366
+ // editing binds it to `title` (the label), because renaming a saved kit
367
+ // changes what it is called, never which kit the store knows it by.
368
+ h('input', {
369
+ style: styles.input,
370
+ placeholder: namePlaceholder ?? '名字,例如 写作 / 调研 / 修 bug',
371
+ value: draft[nameField ?? 'name'] ?? '',
372
+ onChange: (event) => onChange({ ...draft, [nameField ?? 'name']: event.target.value }),
373
+ }),
374
+ h('input', {
375
+ style: { ...styles.input, marginTop: 6 },
376
+ placeholder: `备注(可选,最多 ${NOTE_MAX} 字),例如 用于视频制作`,
377
+ maxLength: NOTE_MAX,
378
+ value: draft.note ?? '',
379
+ onChange: (event) => onChange({ ...draft, note: event.target.value }),
380
+ }),
381
+ h(GroupHead, {
382
+ title: '内置工具',
383
+ count: `${draft.builtins.length}/${builtins.length}`,
384
+ open: openBuiltins,
385
+ onToggle: () => setOpenBuiltins(!openBuiltins),
386
+ tip: 'Harness 自带的工具。默认全选;不想要的单独取消即可。',
387
+ }),
388
+ openBuiltins === true ? h(ToolGrid, {
389
+ tools: builtins,
390
+ selected: draft.builtins,
391
+ onToggle: toggleBuiltin,
392
+ empty: '这台机器还没回报内置工具,先在会话里用一次任意工具即可',
393
+ }) : null,
394
+ h(GroupHead, {
395
+ title: '插件',
396
+ count: `${draft.plugins.length}/${plugins.length}`,
397
+ open: openPlugins,
398
+ onToggle: () => setOpenPlugins(!openPlugins),
399
+ tip: '已启用的下载插件。勾上即把该插件的全部工具放进套装。',
400
+ }),
401
+ openPlugins === true
402
+ ? (plugins.length === 0
403
+ ? h('div', { style: { ...styles.meta, padding: '2px 6px' } }, '没有检测到已启用的插件')
404
+ : plugins.map((plugin) => h('div', {
405
+ key: plugin.id,
406
+ style: { ...styles.row, ...styles.indent },
407
+ onClick: () => togglePlugin(plugin.id),
408
+ },
409
+ h(Toggle, { on: draft.plugins.includes(plugin.id) }),
410
+ h('span', { style: styles.rowTitle }, plugin.title),
411
+ h('span', { style: { flex: 1 } }),
412
+ h('span', { style: styles.meta }, `${plugin.tools.length} 个工具`))))
413
+ : null,
414
+ h('div', { style: styles.actions },
415
+ h('button', { type: 'button', style: styles.ghost, disabled: busy, onClick: onCancel }, '取消'),
416
+ onSaveAs === undefined
417
+ ? null
418
+ : h('button', { type: 'button', style: styles.ghost, disabled: busy, onClick: onSaveAs }, saveAsLabel ?? '另存为新套装'),
419
+ h('button', { type: 'button', style: styles.primary, disabled: busy, onClick: onSave }, saveLabel ?? '保存')))
420
+ }
421
+
422
+ // ---- the header control -------------------------------------------------
423
+ function HeaderMenu(props) {
424
+ const sessionId = props.sessionId
425
+ // `preview` is a test seam for scripts/client-smoke.mjs: it seeds the open
426
+ // state and a state snapshot so the menu can be asserted without a server.
427
+ const preview = props.preview ?? null
428
+ const [open, setOpen] = useState(preview?.open === true)
429
+ const [data, setData] = useState(preview?.data ?? null)
430
+ // Both kit groups start collapsed. The menu is a switcher, not a directory:
431
+ // it has to show which set is on, not enumerate every set every time. A
432
+ // collapsed group still shows the one active row, and clicking the head is
433
+ // how you go browsing.
434
+ const [groups, setGroups] = useState(preview?.groups ?? { kits: false, presets: false, extras: false, applied: false })
435
+ const [builder, setBuilder] = useState(null)
436
+ const [saveAs, setSaveAs] = useState(false)
437
+ const [saveName, setSaveName] = useState('')
438
+ const [saveNote, setSaveNote] = useState('')
439
+ const [busy, setBusy] = useState(false)
440
+ const [message, setMessage] = useState('')
441
+ const [anchor, setAnchor] = useState(null)
442
+ const box = useRef(null)
443
+
444
+ // The menu is `position: fixed`, and that is not cosmetic.
445
+ //
446
+ // The chip lives in `conversation.session.header.actions`, whose header row
447
+ // clips its children (`overflow: hidden`): an absolutely positioned panel
448
+ // opened downward from a 28px chip is cut off at the header's bottom edge —
449
+ // the first row ("不限") survives, everything below it vanishes. A fixed
450
+ // element is positioned against the viewport, so the header's clipping no
451
+ // longer applies. The cost is that its coordinates must be measured rather
452
+ // than inherited, and re-measured when the page moves under it.
453
+ const placeMenu = useCallback(() => {
454
+ const element = box.current
455
+ if (element === null || typeof element.getBoundingClientRect !== 'function') return
456
+ const rect = element.getBoundingClientRect()
457
+ const viewportWidth = window.innerWidth
458
+ const viewportHeight = window.innerHeight
459
+ const width = Math.min(370, Math.max(280, viewportWidth - 24))
460
+ const below = viewportHeight - rect.bottom - 12
461
+ const above = rect.top - 12
462
+ const dropDown = below >= 240 || below >= above
463
+ const maxHeight = Math.max(180, Math.min(520, dropDown ? below : above))
464
+ const left = Math.min(Math.max(8, rect.right - width), Math.max(8, viewportWidth - width - 8))
465
+ const top = dropDown ? rect.bottom + 6 : Math.max(8, rect.top - maxHeight - 6)
466
+ setAnchor({ left, top, width, maxHeight })
467
+ }, [])
468
+
469
+ const refresh = useCallback(async () => {
470
+ try {
471
+ const next = await load(sessionId)
472
+ setData(next)
473
+ setMessage('')
474
+ return next
475
+ } catch (error) {
476
+ setMessage(String(error.message ?? error))
477
+ return null
478
+ }
479
+ }, [sessionId])
480
+
481
+ useEffect(() => {
482
+ if (open) refresh()
483
+ }, [open, refresh])
484
+
485
+ useEffect(() => {
486
+ if (!open) return undefined
487
+ const away = (event) => {
488
+ if (box.current !== null && !box.current.contains(event.target)) setOpen(false)
489
+ }
490
+ document.addEventListener('mousedown', away)
491
+ return () => document.removeEventListener('mousedown', away)
492
+ }, [open])
493
+
494
+ // Keep the fixed panel glued to the chip while the page scrolls or resizes.
495
+ useEffect(() => {
496
+ if (!open) return undefined
497
+ const follow = () => placeMenu()
498
+ window.addEventListener('scroll', follow, true)
499
+ window.addEventListener('resize', follow)
500
+ return () => {
501
+ window.removeEventListener('scroll', follow, true)
502
+ window.removeEventListener('resize', follow)
503
+ }
504
+ }, [open, placeMenu])
505
+
506
+ if (typeof sessionId !== 'string' || sessionId === '') return null
507
+
508
+ const catalog = data?.catalog ?? { builtins: [], plugins: [] }
509
+ const plugins = catalog.plugins ?? []
510
+ const kits = data?.kits ?? []
511
+ const presets = data?.presets ?? []
512
+ const selection = data?.selection ?? { base: 'all', kit: null, extras: [] }
513
+ const extras = selection.extras ?? []
514
+ const base = selection.base ?? 'all'
515
+ const label = data?.label ?? ''
516
+ const isAll = base === 'all'
517
+ const isDsh = base === 'dsh'
518
+ const activeUserKit = base === 'kit' ? kits.find((kit) => kit.name === selection.kit) : undefined
519
+ const activePreset = base === 'kit' ? presets.find((kit) => kit.name === selection.kit) : undefined
520
+ const applied = data?.appliedPlugins ?? []
521
+ const available = data?.availableExtras ?? plugins.map((plugin) => plugin.id)
522
+
523
+ const run = async (payload) => {
524
+ setBusy(true)
525
+ try {
526
+ const next = await request({ ...payload, sessionId })
527
+ setData(next)
528
+ setMessage('')
529
+ return next
530
+ } catch (error) {
531
+ setMessage(String(error.message ?? error))
532
+ return null
533
+ } finally {
534
+ setBusy(false)
535
+ }
536
+ }
537
+
538
+ const openBuilder = (seed) => {
539
+ setBuilder({
540
+ name: '',
541
+ note: '',
542
+ builtins: seed.builtins,
543
+ plugins: seed.plugins,
544
+ })
545
+ setSaveAs(false)
546
+ }
547
+
548
+ const saveBuilder = async () => {
549
+ if (builder === null) return
550
+ const kitName = builder.name.trim()
551
+ if (kitName === '') {
552
+ setMessage('给套装起个名字')
553
+ return
554
+ }
555
+ if (builder.builtins.length === 0 && builder.plugins.length === 0) {
556
+ setMessage('至少选一个内置工具或一个插件')
557
+ return
558
+ }
559
+ setBusy(true)
560
+ try {
561
+ const created = await request({
562
+ action: 'create',
563
+ name: kitName,
564
+ title: kitName,
565
+ note: builder.note ?? '',
566
+ builtins: builder.builtins,
567
+ plugins: builder.plugins,
568
+ })
569
+ setData(created)
570
+ const appliedKit = await run({ action: 'applyBase', base: 'kit', kit: kitName })
571
+ setBuilder(null)
572
+ if (appliedKit !== null) setMessage(`已保存并启用「${kitName}」`)
573
+ } catch (error) {
574
+ setMessage(String(error.message ?? error))
575
+ } finally {
576
+ setBusy(false)
577
+ }
578
+ }
579
+
580
+ const saveSelection = async (mode) => {
581
+ const kitName = saveName.trim()
582
+ if (mode === 'new' && kitName === '') {
583
+ setMessage('给套装起个名字')
584
+ return
585
+ }
586
+ const note = saveNote.trim()
587
+ const next = await run(mode === 'overwrite'
588
+ ? { action: 'saveAs', mode: 'overwrite' }
589
+ : { action: 'saveAs', mode: 'new', name: kitName, title: kitName, note })
590
+ if (next !== null) {
591
+ setSaveAs(false)
592
+ setSaveName('')
593
+ setSaveNote('')
594
+ setMessage(mode === 'overwrite' ? '已覆盖当前套装' : `已存为「${kitName}」`)
595
+ }
596
+ }
597
+
598
+ const allBuiltins = (catalog.builtins ?? []).filter((toolName) => !OWN_TOOLS.includes(toolName))
599
+
600
+ // Editing a preset is never allowed to mutate it: this turns its allow
601
+ // list into the starting point of a brand-new kit instead.
602
+ const forkSeed = (preset) => {
603
+ const allow = new Set(preset.allow ?? [])
604
+ return {
605
+ builtins: (catalog.builtins ?? []).filter((toolName) => allow.has(toolName) && !OWN_TOOLS.includes(toolName)),
606
+ plugins: (catalog.plugins ?? [])
607
+ .filter((plugin) => plugin.tools.length > 0 && plugin.tools.every((toolName) => allow.has(toolName)))
608
+ .map((plugin) => plugin.id),
609
+ }
610
+ }
611
+
612
+ const appliedRows = applied
613
+ .map((id) => plugins.find((plugin) => plugin.id === id))
614
+ .filter((plugin) => plugin !== undefined)
615
+ const freePlugins = plugins.filter((plugin) => !applied.includes(plugin.id))
616
+ const extrasRows = freePlugins.map((plugin) => h(MenuRow, {
617
+ key: plugin.id,
618
+ label: plugin.title,
619
+ hint: available.includes(plugin.id) ? '' : '不可用',
620
+ active: extras.includes(plugin.id),
621
+ disabled: !available.includes(plugin.id),
622
+ onClick: () => run({ action: 'toggleExtra', id: plugin.id, on: !extras.includes(plugin.id) }),
623
+ }))
624
+ const appliedBlock = appliedRows.length === 0 ? null : h('div', { key: 'applied' },
625
+ h(GroupHead, {
626
+ title: `已应用 ${appliedRows.length} 个插件`,
627
+ open: groups.applied,
628
+ tip: '这些插件由当前选择整体带上(「全部」或套装本身),不能在这里单独关掉。',
629
+ onToggle: () => setGroups({ ...groups, applied: !groups.applied }),
630
+ }),
631
+ groups.applied === true
632
+ ? h('div', null, ...appliedRows.map((plugin) => h(MenuRow, {
633
+ key: plugin.id,
634
+ label: plugin.title,
635
+ hint: '已在对话中',
636
+ disabled: true,
637
+ indent: true,
638
+ })))
639
+ : null)
640
+
641
+ const ledger = data?.ledger
642
+ const saved = ledger?.savedTokens ?? 0
643
+ const summary = `当前:${label === '' ? '全部' : label}${extras.length === 0 ? '' : ` · 加挂 ${extras.length}`}${saved > 0 ? ` · 省 ~${formatTokens(saved)} tokens` : ''}`
644
+
645
+
646
+ const rootBody = h('div', null,
647
+ h('div', { key: 'topline', style: styles.topline },
648
+ h('span', { style: styles.summary, title: summary }, summary),
649
+ h('button', {
650
+ type: 'button',
651
+ style: styles.iconButton,
652
+ title: '刷新:重新读取工具目录,并重新套用当前选择',
653
+ 'aria-label': '刷新工具列表',
654
+ disabled: busy,
655
+ onClick: () => { run({ action: 'refresh' }) },
656
+ }, '⟳')),
657
+ h(MenuRow, {
658
+ label: '全部',
659
+ tip: '不裁剪:Harness 自带工具和所有已启用插件的工具都能用。',
660
+ hint: '不限制',
661
+ active: isAll,
662
+ onClick: () => run({ action: 'applyBase', base: 'all' }),
663
+ }),
664
+ h(MenuRow, {
665
+ label: '仅内置',
666
+ tip: '只保留 Harness 自带的工具,下载插件的工具全部隐藏。',
667
+ hint: '隐藏插件',
668
+ active: isDsh,
669
+ onClick: () => run({ action: 'applyBase', base: 'dsh' }),
670
+ }),
671
+ isAll
672
+ ? null
673
+ : h(MenuRow, {
674
+ key: 'reset',
675
+ label: '恢复为全部',
676
+ hint: '取消应用',
677
+ onClick: () => run({ action: 'applyBase', base: 'all' }),
678
+ }),
679
+ h('div', { style: styles.divider }),
680
+ h(GroupHead, {
681
+ title: '我的套装',
682
+ count: kits.length,
683
+ open: groups.kits,
684
+ tip: '你自己保存的套装。点一下即可切换。',
685
+ onToggle: () => setGroups({ ...groups, kits: !groups.kits }),
686
+ }),
687
+ // A collapsed group shows exactly one row: the set that is on. Everything
688
+ // else waits for a click on the head.
689
+ activeUserKit === undefined ? null : h(MenuRow, {
690
+ label: kitLabel(activeUserKit),
691
+ hint: activeUserKit.isDefault ? '默认' : '',
692
+ active: true,
693
+ indent: true,
694
+ onClick: () => run({ action: 'applyBase', base: 'kit', kit: activeUserKit.name }),
695
+ }),
696
+ groups.kits === true
697
+ ? h('div', null,
698
+ ...kits
699
+ .filter((kit) => activeUserKit === undefined || kit.name !== activeUserKit.name)
700
+ .map((kit) => h(MenuRow, {
701
+ key: kit.name,
702
+ label: kitLabel(kit),
703
+ hint: kit.isDefault ? '默认' : '',
704
+ indent: true,
705
+ onClick: () => {
706
+ run({ action: 'applyBase', base: 'kit', kit: kit.name })
707
+ // Picking one puts the list away again; the row that stays is the
708
+ // one just chosen, right under the group head.
709
+ setGroups({ ...groups, kits: false })
710
+ },
711
+ })),
712
+ h(MenuRow, {
713
+ label: '+ 新建套装',
714
+ indent: true,
715
+ onClick: () => openBuilder({ builtins: allBuiltins, plugins: [] }),
716
+ }))
717
+ : null,
718
+ SHOW_PRESETS === true
719
+ ? h(GroupHead, {
720
+ title: '预设套装',
721
+ count: presets.length,
722
+ open: groups.presets,
723
+ tip: '出厂自带的只读套装,点一下即可套用。要改请在设置页另存为我的套装。',
724
+ onToggle: () => setGroups({ ...groups, presets: !groups.presets }),
725
+ })
726
+ : null,
727
+ // Same rule as 我的套装: collapsed shows the preset that is on, if any.
728
+ SHOW_PRESETS === true && groups.presets !== true && activePreset !== undefined
729
+ ? h(MenuRow, {
730
+ key: 'active-preset',
731
+ label: activePreset.title,
732
+ hint: '只读',
733
+ active: true,
734
+ indent: true,
735
+ onClick: () => run({ action: 'applyBase', base: 'kit', kit: activePreset.name }),
736
+ })
737
+ : null,
738
+ SHOW_PRESETS === true && groups.presets === true
739
+ ? (presets.length === 0
740
+ ? h('div', { key: 'none', style: { ...styles.meta, padding: '2px 6px' } }, '没有预置套装')
741
+ : presets.map((preset) => h(MenuRow, {
742
+ key: preset.name,
743
+ label: preset.title,
744
+ hint: '只读',
745
+ active: activePreset !== undefined && activePreset.name === preset.name,
746
+ indent: true,
747
+ onClick: () => {
748
+ run({ action: 'applyBase', base: 'kit', kit: preset.name })
749
+ setGroups({ ...groups, presets: false })
750
+ },
751
+ })))
752
+ : null,
753
+ appliedBlock,
754
+ h(GroupHead, {
755
+ title: '加挂',
756
+ count: extras.length,
757
+ open: groups.extras,
758
+ tip: '在当前选择之上临时加开插件,不新建套装。已经包含的插件不能再加。',
759
+ onToggle: () => setGroups({ ...groups, extras: !groups.extras }),
760
+ action: extras.length === 0
761
+ ? null
762
+ : h('button', {
763
+ type: 'button',
764
+ style: styles.iconButton,
765
+ title: '存为新套装',
766
+ 'aria-label': '存为新套装',
767
+ disabled: busy,
768
+ onClick: (event) => {
769
+ event.stopPropagation()
770
+ setSaveAs(!saveAs)
771
+ },
772
+ }, '💾'),
773
+ }),
774
+ groups.extras === true
775
+ ? h('div', null,
776
+ isAll
777
+ ? h('div', { key: 'all-hint', style: { ...styles.hint, padding: '0 6px' } }, '「全部」已经包含所有插件的工具,不需要加挂。')
778
+ : null,
779
+ plugins.length === 0
780
+ ? h('div', { key: 'empty', style: { ...styles.meta, padding: '2px 6px' } }, '没有检测到已启用的插件')
781
+ : null,
782
+ ...extrasRows,
783
+ saveAs === true
784
+ ? h('div', { key: 'saveAs', style: styles.savePanel },
785
+ h('input', {
786
+ style: styles.input,
787
+ placeholder: '新套装的名字',
788
+ value: saveName,
789
+ onChange: (event) => setSaveName(event.target.value),
790
+ }),
791
+ h('input', {
792
+ style: { ...styles.input, marginTop: 6 },
793
+ placeholder: `备注(可选,最多 ${NOTE_MAX} 字)`,
794
+ maxLength: NOTE_MAX,
795
+ value: saveNote,
796
+ onChange: (event) => setSaveNote(event.target.value),
797
+ }),
798
+ h('div', { style: styles.actions },
799
+ activeUserKit !== undefined
800
+ ? h('button', {
801
+ type: 'button',
802
+ style: styles.ghost,
803
+ disabled: busy,
804
+ onClick: () => saveSelection('overwrite'),
805
+ }, `覆盖「${activeUserKit.title}」`)
806
+ : null,
807
+ h('button', {
808
+ type: 'button',
809
+ style: styles.primary,
810
+ disabled: busy,
811
+ onClick: () => saveSelection('new'),
812
+ }, '存为新套装'),
813
+ h('button', {
814
+ type: 'button',
815
+ style: styles.ghost,
816
+ disabled: busy,
817
+ onClick: () => {
818
+ setSaveAs(false)
819
+ setSaveName('')
820
+ setSaveNote('')
821
+ },
822
+ }, '取消')),
823
+ activePreset !== undefined
824
+ ? h('div', { style: styles.hint }, '预置套装只读:这里只能另存为我的套装。')
825
+ : null)
826
+ : null)
827
+ : null,
828
+ h('div', { style: styles.footer }, FOOTER),
829
+ message === '' ? null : h('div', { style: styles.error }, message))
830
+
831
+ const body = builder === null
832
+ ? rootBody
833
+ : h(Builder, {
834
+ catalog,
835
+ draft: builder,
836
+ onChange: (next) => { setBuilder(next); setMessage('') },
837
+ busy,
838
+ onCancel: () => setBuilder(null),
839
+ onSave: saveBuilder,
840
+ saveLabel: '保存并启用',
841
+ })
842
+
843
+ return h('div', { style: styles.wrap, ref: box },
844
+ h('button', {
845
+ type: 'button',
846
+ style: { ...styles.chip, ...(isAll ? null : styles.chipActive) },
847
+ onClick: () => {
848
+ if (!open) placeMenu()
849
+ setOpen(!open)
850
+ },
851
+ 'aria-expanded': open ? 'true' : 'false',
852
+ 'aria-label': '装备栏',
853
+ title: '装备栏:当前对话用哪一组工具',
854
+ },
855
+ isAll ? null : h('span', { style: styles.dot }),
856
+ h('span', null, label === '' ? '装备栏' : label),
857
+ h('span', { style: { opacity: 0.5 } }, '▾')),
858
+ open ? h('div', {
859
+ style: anchor === null ? styles.panel : {
860
+ ...styles.panel,
861
+ position: 'fixed',
862
+ top: anchor.top,
863
+ left: anchor.left,
864
+ right: 'auto',
865
+ width: anchor.width,
866
+ maxHeight: anchor.maxHeight,
867
+ },
868
+ }, body) : null)
869
+ }
870
+
871
+ // ---- the settings page --------------------------------------------------
872
+ // 全部 / 仅内置 have no tools of their own, so they are not kits — but in the
873
+ // settings list they sit next to kits, and two actions make sense for them:
874
+ // copy the command that switches a conversation, or make them the default.
875
+ function DefaultCard(props) {
876
+ const { title, description, isDefault, onUse, onDefault } = props
877
+ return h('div', { style: styles.card },
878
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
879
+ h('strong', null, title),
880
+ isDefault ? h('span', { style: { ...styles.meta, color: '#2563eb' } }, '默认') : null,
881
+ h('span', { style: { flex: 1 } }),
882
+ h('button', { type: 'button', style: styles.ghost, onClick: onUse }, '复制切换命令'),
883
+ h('button', { type: 'button', style: styles.ghost, onClick: onDefault }, '设为默认')),
884
+ h('div', { style: { ...styles.meta, marginTop: 4 } }, description))
885
+ }
886
+
887
+ function KitCard(props) {
888
+ const { kit, preset, busy, isDefault, onUse, onDefault, onDelete, onNote, onEdit } = props
889
+ const [noteEditing, setNoteEditing] = useState(false)
890
+ const [noteValue, setNoteValue] = useState(kit.note ?? '')
891
+ return h('div', { style: styles.card },
892
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
893
+ h('strong', null, kitLabel(kit)),
894
+ Number.isFinite(kit.tokens)
895
+ ? h('span', { style: styles.meta, title: '按工具定义估算(约 3.5 字符 / token)' }, `≈${formatTokens(kit.tokens)} tokens`)
896
+ : null,
897
+ preset ? h('span', { style: styles.tag }, '预设') : null,
898
+ isDefault ? h('span', { style: { ...styles.meta, color: '#2563eb' } }, '默认') : null,
899
+ h('span', { style: { flex: 1 } }),
900
+ h('button', { type: 'button', style: styles.ghost, disabled: busy, onClick: onUse }, '复制切换命令'),
901
+ h('button', { type: 'button', style: styles.ghost, disabled: busy, onClick: onDefault }, '设为默认'),
902
+ preset
903
+ ? null
904
+ : h('button', { type: 'button', style: styles.ghost, disabled: busy, onClick: () => setNoteEditing(!noteEditing) }, '备注'),
905
+ preset
906
+ ? null
907
+ // 改名 lives inside 编辑: one editor for the name, the note and the tool
908
+ // list beats three overlapping inline forms on one card.
909
+ : h('button', { type: 'button', style: styles.ghost, disabled: busy, onClick: onEdit }, '编辑'),
910
+ preset
911
+ ? null
912
+ : h('button', { type: 'button', style: styles.ghost, disabled: busy, onClick: onDelete }, '删除')),
913
+ kit.description
914
+ ? h('div', { style: { ...styles.meta, marginTop: 4 } }, kit.description)
915
+ : null,
916
+ noteEditing
917
+ ? h('div', { style: { display: 'flex', gap: 6, marginTop: 6 } },
918
+ h('input', {
919
+ style: styles.input,
920
+ maxLength: NOTE_MAX,
921
+ placeholder: `一句备注,例如 用于视频制作(最多 ${NOTE_MAX} 字)`,
922
+ value: noteValue,
923
+ onChange: (event) => setNoteValue(event.target.value),
924
+ }),
925
+ h('button', {
926
+ type: 'button',
927
+ style: styles.primary,
928
+ disabled: busy,
929
+ onClick: () => {
930
+ onNote(noteValue.trim())
931
+ setNoteEditing(false)
932
+ },
933
+ }, '保存'))
934
+ : null)
935
+ }
936
+
937
+ function SettingsPanel(props) {
938
+ // Same test seam as the header menu: seed a snapshot without a server.
939
+ const preview = props.preview ?? null
940
+ const [data, setData] = useState(preview?.data ?? null)
941
+ const [busy, setBusy] = useState(false)
942
+ const [message, setMessage] = useState('')
943
+ const [draft, setDraft] = useState(null)
944
+ // Editing a saved kit reuses the same builder as creating one; only the
945
+ // action buttons differ (覆盖保存 keeps the name, 另存为新套装 mints a new one).
946
+ const [editingKit, setEditingKit] = useState(null)
947
+ // 我的套装 starts collapsed, like the chip: the page's job is "which set does
948
+ // a new conversation start with", and a list of five kits does not answer it.
949
+ // The test seam can seed it open so the card assertions never depend on a click.
950
+ const [openSections, setOpenSections] = useState(preview?.openSections ?? { presets: true, kits: false })
951
+
952
+ const refresh = useCallback(async () => {
953
+ try {
954
+ setData(await load())
955
+ setMessage('')
956
+ } catch (error) {
957
+ setMessage(`读不到套装状态:${String(error.message ?? error)}`)
958
+ }
959
+ }, [])
960
+
961
+ useEffect(() => { refresh() }, [refresh])
962
+
963
+ // The page stays mounted while the user switches tools from the header
964
+ // chip, so "read once on mount" showed the conversation's numbers from
965
+ // whenever it was first opened. Re-read whenever the other panel says it
966
+ // changed, and when the page comes back into focus.
967
+ useEffect(() => {
968
+ const sync = () => { refresh() }
969
+ const onVisible = () => {
970
+ if (typeof document.visibilityState === 'string' && document.visibilityState !== 'visible') return
971
+ refresh()
972
+ }
973
+ document.addEventListener(CHANGED_EVENT, sync)
974
+ document.addEventListener('visibilitychange', onVisible)
975
+ window.addEventListener('focus', sync)
976
+ return () => {
977
+ document.removeEventListener(CHANGED_EVENT, sync)
978
+ document.removeEventListener('visibilitychange', onVisible)
979
+ window.removeEventListener('focus', sync)
980
+ }
981
+ }, [refresh])
982
+
983
+ const act = async (payload) => {
984
+ setBusy(true)
985
+ try {
986
+ setData(await request(payload))
987
+ setMessage('')
988
+ return true
989
+ } catch (error) {
990
+ setMessage(String(error.message ?? error))
991
+ return false
992
+ } finally {
993
+ setBusy(false)
994
+ }
995
+ }
996
+
997
+ if (data === null) {
998
+ return h('div', { style: { padding: 8, fontSize: 12 } },
999
+ message === '' ? '正在读取套装…' : h('span', { style: styles.error }, message))
1000
+ }
1001
+
1002
+ const catalog = data.catalog ?? { builtins: [], plugins: [] }
1003
+ const kits = data.kits ?? []
1004
+ const presets = data.presets ?? []
1005
+ const defaultIsAll = (data.defaultKit ?? '') === ''
1006
+ const defaultIsDsh = (data.defaultKit ?? '') === '@dsh'
1007
+ const allBuiltins = (catalog.builtins ?? []).filter((toolName) => !OWN_TOOLS.includes(toolName))
1008
+ // A collapsed 我的套装 still answers "which kit is the default", the same way
1009
+ // the chip does.
1010
+ const defaultKitRow = kits.find((kit) => kit.name === data.defaultKit)
1011
+
1012
+ const saveDraft = async () => {
1013
+ if (draft === null) return
1014
+ if (draft.name.trim() === '') {
1015
+ setMessage('给套装起个名字')
1016
+ return
1017
+ }
1018
+ if (draft.builtins.length === 0 && draft.plugins.length === 0) {
1019
+ setMessage('至少选一个内置工具或一个插件')
1020
+ return
1021
+ }
1022
+ const ok = await act({
1023
+ action: 'create',
1024
+ name: draft.name.trim(),
1025
+ title: draft.name.trim(),
1026
+ note: draft.note ?? '',
1027
+ builtins: draft.builtins,
1028
+ plugins: draft.plugins,
1029
+ })
1030
+ if (ok) {
1031
+ setDraft(null)
1032
+ setMessage('已保存。到标题栏点它即可切换当前对话。')
1033
+ }
1034
+ }
1035
+
1036
+ // 编辑一个已保存的套装:同一个 Builder,名字锁定,保存按钮换成"覆盖保存",另有一个"另存为新套装"。
1037
+ const startEdit = (kit) => {
1038
+ setDraft({
1039
+ name: kit.name,
1040
+ title: kit.title,
1041
+ note: kit.note ?? '',
1042
+ builtins: [...(kit.builtins ?? [])],
1043
+ plugins: [...(kit.plugins ?? [])],
1044
+ })
1045
+ setEditingKit(kit.name)
1046
+ setMessage('')
1047
+ }
1048
+
1049
+ const saveOverwrite = async () => {
1050
+ if (editingKit === null || draft === null) return
1051
+ if ((draft.title ?? '').trim() === '') {
1052
+ setMessage('给套装起个名字')
1053
+ return
1054
+ }
1055
+ if (draft.builtins.length === 0 && draft.plugins.length === 0) {
1056
+ setMessage('至少选一个内置工具或一个插件')
1057
+ return
1058
+ }
1059
+ const ok = await act({
1060
+ action: 'update',
1061
+ name: editingKit,
1062
+ title: draft.title.trim(),
1063
+ note: draft.note ?? '',
1064
+ builtins: draft.builtins,
1065
+ plugins: draft.plugins,
1066
+ })
1067
+ if (ok) {
1068
+ setDraft(null)
1069
+ setEditingKit(null)
1070
+ setMessage('已覆盖保存;正在用它的对话立即生效。')
1071
+ }
1072
+ }
1073
+
1074
+ const saveEditingAsNew = async () => {
1075
+ if (draft === null) return
1076
+ // In the editor the name field edits `title`; a copy gets that title as
1077
+ // its identity too, so the new kit reads the same everywhere.
1078
+ const wanted = (draft.title ?? draft.name ?? '').trim()
1079
+ if (wanted === '' || wanted === editingKit) {
1080
+ setMessage('换个新名字再另存')
1081
+ return
1082
+ }
1083
+ const ok = await act({
1084
+ action: 'create',
1085
+ name: wanted,
1086
+ title: wanted,
1087
+ note: draft.note ?? '',
1088
+ builtins: draft.builtins,
1089
+ plugins: draft.plugins,
1090
+ })
1091
+ if (ok) {
1092
+ setDraft(null)
1093
+ setEditingKit(null)
1094
+ setMessage('已另存为新套装。')
1095
+ }
1096
+ }
1097
+
1098
+ const copyCommand = async (kitName) => { const command = `/kit ${kitName}`
1099
+ try {
1100
+ await navigator.clipboard.writeText(command)
1101
+ setMessage(`已复制「${command}」,到对话里发送即可切换当前对话`)
1102
+ } catch {
1103
+ setMessage(`把这行发到对话里即可切换:${command}`)
1104
+ }
1105
+ }
1106
+
1107
+ return h('div', { style: { padding: '4px 2px', fontSize: 12 } },
1108
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
1109
+ h('button', { type: 'button', style: styles.ghost, disabled: busy, onClick: refresh }, '刷新'),
1110
+ h('button', {
1111
+ type: 'button',
1112
+ style: styles.primary,
1113
+ disabled: busy,
1114
+ onClick: () => {
1115
+ setEditingKit(null)
1116
+ setDraft({ name: '', note: '', builtins: [...allBuiltins], plugins: [] })
1117
+ },
1118
+ }, '新建套装')),
1119
+ // Creating a kit is a page-level action, so its editor opens at the very
1120
+ // top, above the default list — the form you just asked for is the first
1121
+ // thing under the button. Editing an existing kit renders under that kit
1122
+ // instead (see the kits list), so fields never detach from their card.
1123
+ draft === null || editingKit !== null
1124
+ ? null
1125
+ : h('div', { style: { ...styles.editorCard, marginTop: 10 } },
1126
+ h(Builder, {
1127
+ catalog,
1128
+ draft,
1129
+ onChange: setDraft,
1130
+ busy,
1131
+ onCancel: () => setDraft(null),
1132
+ onSave: saveDraft,
1133
+ saveLabel: '保存',
1134
+ })),
1135
+ message === '' ? null : h('div', { style: styles.error }, message),
1136
+ h('div', { style: styles.ledger },
1137
+ data.ledger === undefined
1138
+ ? '读不到工具定义,这一页暂时算不出 token 账本。'
1139
+ : [
1140
+ `最近活跃对话保留 ${data.ledger.visibleTools} 个工具 · 约 ${formatTokens(data.ledger.visibleTokens)} tokens`,
1141
+ `「全部」约 ${formatTokens(data.ledger.totalTokens)} tokens`,
1142
+ `本套装省约 ${formatTokens(data.ledger.savedTokens)} tokens`,
1143
+ '(估算:按工具定义长度 ÷ 3.5 字符/token)',
1144
+ ].join(' · ')),
1145
+ // This panel has no Session of its own: the host answers a bare GET from
1146
+ // the most recently seen agent. Saying so is cheaper than explaining why
1147
+ // the numbers "don't change" when the user is only editing a default.
1148
+ h('div', { style: { ...styles.meta, marginTop: 4 } },
1149
+ '设置页不绑定具体会话,上面的数字来自最近活跃的那个对话;当前对话请在标题栏「装备栏」里切换。'),
1150
+ h('div', { style: styles.sectionTitle, marginTop: 14 }, '新对话默认套装'),
1151
+ // 全部 and 仅内置 are choices, not kits, but they read like the kit cards
1152
+ // below (copy the switch command / make it the default) so the page has
1153
+ // one idiom instead of two.
1154
+ h('div', { style: { display: 'grid', gap: 8, marginTop: 6 } },
1155
+ h(DefaultCard, {
1156
+ title: '全部',
1157
+ description: '内置工具和已启用插件的工具都能用。',
1158
+ isDefault: defaultIsAll,
1159
+ onUse: () => copyCommand('全部'),
1160
+ onDefault: () => act({ action: 'default', name: '' }),
1161
+ }),
1162
+ h(DefaultCard, {
1163
+ title: '仅内置',
1164
+ description: '只带 Harness 内置的工具,下载插件的工具默认隐藏。',
1165
+ isDefault: defaultIsDsh,
1166
+ onUse: () => copyCommand('仅内置'),
1167
+ onDefault: () => act({ action: 'default', name: '@dsh' }),
1168
+ })),
1169
+ message === '' ? null : h('div', { style: { ...styles.error, marginTop: 6 } }, message),
1170
+ // Presets are held back for now (SHOW_PRESETS): the data still ships and
1171
+ // the host still honours `/kit 写作`, but neither the chip nor this page
1172
+ // offers them, so a shipped preset cannot silently hide a tool.
1173
+ SHOW_PRESETS === true
1174
+ ? h(GroupHead, {
1175
+ title: '预设套装 · 只读',
1176
+ count: presets.length,
1177
+ open: openSections.presets,
1178
+ tip: '出厂自带的只读套装,只能在对话里套用,不能改。',
1179
+ onToggle: () => setOpenSections({ ...openSections, presets: !openSections.presets }),
1180
+ })
1181
+ : null,
1182
+ SHOW_PRESETS === true && openSections.presets === true
1183
+ ? (presets.length === 0
1184
+ ? h('div', { style: styles.meta }, '没有预置套装')
1185
+ : h('div', { style: { display: 'grid', gap: 8, marginTop: 4 } },
1186
+ presets.map((preset) => h(KitCard, {
1187
+ key: preset.name,
1188
+ kit: preset,
1189
+ preset: true,
1190
+ busy,
1191
+ isDefault: data.defaultKit === preset.name,
1192
+ onUse: () => copyCommand(preset.name),
1193
+ onDefault: () => act({ action: 'default', name: preset.name }),
1194
+ onDelete: () => {},
1195
+ }))))
1196
+ : null,
1197
+ h(GroupHead, {
1198
+ title: '我的套装',
1199
+ count: kits.length,
1200
+ open: openSections.kits,
1201
+ tip: '你自己配置的工具套装,内置工具和已启用插件的工具都能随意搭配。',
1202
+ onToggle: () => setOpenSections({ ...openSections, kits: !openSections.kits }),
1203
+ }),
1204
+ openSections.kits === false
1205
+ // Collapsed: show only the kit that is the default, exactly like the
1206
+ // chip does — the answer to "what does a new conversation start with".
1207
+ ? (defaultKitRow === undefined
1208
+ ? null
1209
+ : h(MenuRow, {
1210
+ key: `default-${defaultKitRow.name}`,
1211
+ label: kitLabel(defaultKitRow),
1212
+ hint: '当前默认',
1213
+ indent: true,
1214
+ active: true,
1215
+ onClick: () => act({ action: 'default', name: defaultKitRow.name }),
1216
+ }))
1217
+ : (kits.length === 0
1218
+ ? h('div', { style: styles.meta }, '还没有套装。上面的按钮可以新建一个。')
1219
+ : h('div', { style: { display: 'grid', gap: 8, marginTop: 4 } },
1220
+ kits.map((kit) => [
1221
+ h(KitCard, {
1222
+ key: kit.name,
1223
+ kit,
1224
+ preset: false,
1225
+ busy,
1226
+ isDefault: data.defaultKit === kit.name,
1227
+ onUse: () => copyCommand(kit.name),
1228
+ onDefault: () => act({ action: 'default', name: kit.name }),
1229
+ onDelete: async () => {
1230
+ if (!window.confirm(`删除套装「${kit.name}」?已经用它开的对话保持现状。`)) return
1231
+ await act({ action: 'delete', name: kit.name })
1232
+ },
1233
+ onNote: (note) => act({ action: 'update', name: kit.name, note }),
1234
+ onEdit: () => startEdit(kit),
1235
+ }),
1236
+ // The editor belongs to the card that opened it: same list, right
1237
+ // under the kit, so "which kit am I changing" needs no memory.
1238
+ editingKit === kit.name && draft !== null
1239
+ ? h('div', { key: `${kit.name}-editor`, style: { ...styles.editorCard, marginTop: -4 } },
1240
+ h(Builder, {
1241
+ catalog,
1242
+ draft,
1243
+ onChange: setDraft,
1244
+ busy,
1245
+ title: '编辑套装',
1246
+ nameField: 'title',
1247
+ namePlaceholder: '套装名字',
1248
+ onCancel: () => {
1249
+ setDraft(null)
1250
+ setEditingKit(null)
1251
+ },
1252
+ onSave: saveOverwrite,
1253
+ saveLabel: '覆盖保存',
1254
+ onSaveAs: saveEditingAsNew,
1255
+ saveAsLabel: '另存为新套装',
1256
+ }))
1257
+ : null,
1258
+ ]))),
1259
+ h('div', { style: styles.footer }, `存储:${data.source ?? '(未知)'} 新对话默认:${
1260
+ data.defaultKit === '' ? '(未设置)' : data.defaultKit === '@dsh' ? '仅内置' : data.defaultKit}`),
1261
+ h('div', { style: { ...styles.hint, marginTop: 4, lineHeight: 1.6 } },
1262
+ '边界:本插件只控制「对话看得到哪些工具」。已启用的插件仍在后台运行;界面插件(侧边栏、主题等)不受影响。'))
1263
+ }
1264
+
1265
+ /**
1266
+ * Register one surface, but never fail activation over it.
1267
+ *
1268
+ * A slot this plugin targets can disappear when the client UI is refactored;
1269
+ * an exception thrown out of `apply` leaves the Loader entry inactive, and the
1270
+ * Web boot audit treats any inactive entry as a fatal boot failure — the whole
1271
+ * app refuses to start because one optional chip went missing. Losing a chip
1272
+ * must cost the chip, not the application.
1273
+ */
1274
+ function registerSurface(ctx, slot, component) {
1275
+ try {
1276
+ ctx.slots.inject(slot.name, () => ctx.slots.register(
1277
+ {
1278
+ name: slot.name,
1279
+ id: 'dsh-loadout',
1280
+ order: slot.order,
1281
+ ...slot.extra,
1282
+ },
1283
+ component,
1284
+ ))
1285
+ } catch (error) {
1286
+ const message = error instanceof Error ? error.message : String(error)
1287
+ const line = `dsh-loadout: 界面插槽 ${slot.name} 不可用(${message}),该入口已跳过`
1288
+ if (typeof ctx.logger?.warn === 'function') ctx.logger.warn(line)
1289
+ else if (typeof console !== 'undefined') console.warn(line)
1290
+ }
1291
+ }
1292
+
1293
+ return {
1294
+ inject: ['slots'],
1295
+ apply(ctx) {
1296
+ // The header chip: the everyday control, one click from the conversation.
1297
+ //
1298
+ // Placement is the slot, not the order. In `header.utilities` the chip sits
1299
+ // in a run of small icon buttons (open-in-app -10, schedule-catalog -5,
1300
+ // session-log-download 0, the sidebar's toggles 10/20) and the far-right
1301
+ // cell is the shipped `header.corner` occupant, so no order makes the chip
1302
+ // look like it owns an edge — it always reads as a label squeezed between
1303
+ // two icons. `header.actions` is the title-adjacent row instead: -100 puts
1304
+ // the chip immediately after the title, before the shipped action buttons
1305
+ // (agent-preset -10, subagent-catalog 10, job-list 20), which is one clear
1306
+ // side of the header rather than a gap in the middle of it.
1307
+ registerSurface(ctx, {
1308
+ name: 'conversation.session.header.actions',
1309
+ order: -100,
1310
+ extra: { inject: (sessionId) => ({ sessionId }) },
1311
+ }, HeaderMenu)
1312
+ // The settings page: the full editor and the place kits are stored from.
1313
+ registerSurface(ctx, {
1314
+ name: 'settings.section',
1315
+ order: 40,
1316
+ extra: { label: () => '装备栏', inject: () => ({}) },
1317
+ }, SettingsPanel)
1318
+ },
1319
+ }
1320
+ },
1321
+ })