dsh-loadout 0.12.6 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.en.md +37 -1
- package/README.md +41 -3
- package/client.js +434 -32
- package/index.js +216 -4
- package/package.json +3 -2
- package/scripts/client-smoke.mjs +303 -10
- package/scripts/selftest.mjs +150 -0
package/client.js
CHANGED
|
@@ -35,8 +35,9 @@ window.__ModuleLoader__.load({
|
|
|
35
35
|
/** This plugin's own tools never belong inside a kit. */
|
|
36
36
|
const OWN_TOOLS = ['kit_list', 'kit_apply', 'kit_create', 'kit_set_tools', 'kit_set_default', 'kit_delete']
|
|
37
37
|
|
|
38
|
-
/** The
|
|
39
|
-
const FOOTER = '
|
|
38
|
+
/** The panel footer: what this menu does, then the one thing worth acting on. */
|
|
39
|
+
const FOOTER = '装备栏只决定「这个对话能用哪些工具」:切换后下一条消息即按新清单组装,随时可以切回「全部」;装备栏作用于工具(tool)这一层——界面面板、宿主服务类插件不注册工具,因此不受影响。'
|
|
40
|
+
const FOOTER_WARN = '建议新建对话时直接选择合适套装!对话中途切换套装会改变插件清单,下一条对话要按未命中价重新计算一次,之后恢复至缓存价。长对话影响较大,短对话基本无影响;空闲对话、刚压缩的对话、重启后再切换无影响。'
|
|
40
41
|
|
|
41
42
|
/** A kit's one-line reminder; keep in step with NOTE_MAX in index.js. */
|
|
42
43
|
const NOTE_MAX = 20
|
|
@@ -86,6 +87,53 @@ window.__ModuleLoader__.load({
|
|
|
86
87
|
return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}k`
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
/** The one binding value that is not a kit: "Harness built-ins only". */
|
|
91
|
+
const DEFAULT_DSH = '@dsh'
|
|
92
|
+
|
|
93
|
+
// The cost of a switch is quoted at provider list prices, in ¥ per million
|
|
94
|
+
// tokens. It is a setting, not a constant: prices move, and a wrong number
|
|
95
|
+
// here can only make an estimate wrong — it never changes what a switch does.
|
|
96
|
+
const PRICE_KEY = 'dsh-loadout:pricing'
|
|
97
|
+
const PRICE_DEFAULT = { miss: 2, hit: 0.5 }
|
|
98
|
+
/** Below this the switch is lost in the noise, and a warning would be noise. */
|
|
99
|
+
const PRICE_MIN_TOKENS = 20000
|
|
100
|
+
|
|
101
|
+
function readPrices() {
|
|
102
|
+
try {
|
|
103
|
+
const raw = window.localStorage?.getItem(PRICE_KEY)
|
|
104
|
+
if (raw === null || raw === undefined) return { ...PRICE_DEFAULT }
|
|
105
|
+
const parsed = JSON.parse(raw)
|
|
106
|
+
const miss = Number(parsed?.miss)
|
|
107
|
+
const hit = Number(parsed?.hit)
|
|
108
|
+
return {
|
|
109
|
+
miss: Number.isFinite(miss) && miss >= 0 ? miss : PRICE_DEFAULT.miss,
|
|
110
|
+
hit: Number.isFinite(hit) && hit >= 0 ? hit : PRICE_DEFAULT.hit,
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
return { ...PRICE_DEFAULT }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writePrices(next) {
|
|
118
|
+
try {
|
|
119
|
+
window.localStorage?.setItem(PRICE_KEY, JSON.stringify(next))
|
|
120
|
+
} catch {
|
|
121
|
+
// A browser without storage just keeps the defaults for this page.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The bill for one switch. The floor that kept row labels quiet is gone —
|
|
127
|
+
* rows no longer quote money at all (they quote tool tokens), so the footer
|
|
128
|
+
* is the only place the number appears and it always appears there.
|
|
129
|
+
*/
|
|
130
|
+
function switchCost(tokens, prices) {
|
|
131
|
+
const n = Number(tokens)
|
|
132
|
+
if (!Number.isFinite(n) || n <= 0) return ''
|
|
133
|
+
const yuan = (n / 1e6) * Math.max(0, prices.miss - prices.hit)
|
|
134
|
+
return `≈¥${yuan < 0.1 ? yuan.toFixed(3) : yuan.toFixed(2)}`
|
|
135
|
+
}
|
|
136
|
+
|
|
89
137
|
// Every mutation fans out to the other panel. The chip and the settings page
|
|
90
138
|
// are separate React trees with separate snapshots, so a switch made in one
|
|
91
139
|
// used to leave the other showing the old numbers until it remounted.
|
|
@@ -195,6 +243,12 @@ window.__ModuleLoader__.load({
|
|
|
195
243
|
fontSize: 11, opacity: 0.62, marginTop: 8, lineHeight: 1.6,
|
|
196
244
|
borderTop: '1px solid var(--ds-border, #e5e7eb)', paddingTop: 6,
|
|
197
245
|
},
|
|
246
|
+
// The one paragraph in the panel that asks for a decision before the
|
|
247
|
+
// first message, so it is not dimmed with the rest of the footer.
|
|
248
|
+
warnLine: { marginTop: 6, color: '#b45309', opacity: 0.95, lineHeight: 1.6 },
|
|
249
|
+
// The bill, stated once under the reminder: quieter than the warning
|
|
250
|
+
// because it explains the numbers rather than asking for anything.
|
|
251
|
+
billLine: { marginTop: 4, fontWeight: 600, color: '#111827', lineHeight: 1.6 },
|
|
198
252
|
error: { fontSize: 11, color: '#dc2626', marginTop: 6, lineHeight: 1.5 },
|
|
199
253
|
notice: { fontSize: 11, color: '#2563eb', marginTop: 6, lineHeight: 1.5 },
|
|
200
254
|
input: {
|
|
@@ -202,6 +256,12 @@ window.__ModuleLoader__.load({
|
|
|
202
256
|
border: '1px solid var(--ds-border, #d8dbe0)', background: 'transparent', color: 'inherit',
|
|
203
257
|
font: 'inherit', fontSize: 12,
|
|
204
258
|
},
|
|
259
|
+
// A price is four characters wide, not the width of the panel.
|
|
260
|
+
priceInput: {
|
|
261
|
+
width: 72, boxSizing: 'border-box', padding: '3px 6px', borderRadius: 6,
|
|
262
|
+
border: '1px solid var(--ds-border, #d8dbe0)', background: 'transparent', color: 'inherit',
|
|
263
|
+
font: 'inherit', fontSize: 12,
|
|
264
|
+
},
|
|
205
265
|
iconButton: {
|
|
206
266
|
border: 'none', background: 'transparent', color: 'inherit', cursor: 'pointer',
|
|
207
267
|
fontSize: 13, lineHeight: 1, padding: '2px 4px', opacity: 0.7, flex: 'none',
|
|
@@ -231,6 +291,19 @@ window.__ModuleLoader__.load({
|
|
|
231
291
|
background: 'rgba(37, 99, 235, 0.05)',
|
|
232
292
|
},
|
|
233
293
|
tag: { fontSize: 10, padding: '0 5px', borderRadius: 4, border: '1px solid currentColor', opacity: 0.7 },
|
|
294
|
+
// The new-conversation picker, sitting in the composer's tool row next to
|
|
295
|
+
// 权限/计划. It is one compact chip; the list floats above the row (below
|
|
296
|
+
// is the window edge), and opening it never reflows the page.
|
|
297
|
+
pickWrap: { position: 'relative', display: 'inline-flex', alignItems: 'center' },
|
|
298
|
+
chipMuted: { opacity: 0.55 },
|
|
299
|
+
chipStrong: { fontWeight: 600 },
|
|
300
|
+
pickPanel: {
|
|
301
|
+
position: 'absolute', bottom: 'calc(100% + 6px)', left: 0, zIndex: 60,
|
|
302
|
+
display: 'flex', flexDirection: 'column', gap: 2, padding: 6, borderRadius: 10,
|
|
303
|
+
border: '1px solid var(--ds-border, #d8dbe0)', background: 'var(--ds-surface, #fff)',
|
|
304
|
+
boxShadow: '0 12px 32px rgba(0,0,0,.16)', width: 260, maxHeight: 320, overflowY: 'auto',
|
|
305
|
+
fontSize: 12,
|
|
306
|
+
},
|
|
234
307
|
}
|
|
235
308
|
|
|
236
309
|
// ---- small pieces -------------------------------------------------------
|
|
@@ -291,7 +364,10 @@ window.__ModuleLoader__.load({
|
|
|
291
364
|
}, toolName)))
|
|
292
365
|
}
|
|
293
366
|
|
|
294
|
-
/**
|
|
367
|
+
/**
|
|
368
|
+
* A selectable line: ✓ marker, label (with an optional tooltip), hint, and
|
|
369
|
+
* an optional `aside` — a quiet secondary action that stops the click.
|
|
370
|
+
*/
|
|
295
371
|
function MenuRow(props) {
|
|
296
372
|
const [hover, setHover] = useState(false)
|
|
297
373
|
const disabled = props.disabled === true
|
|
@@ -327,7 +403,9 @@ window.__ModuleLoader__.load({
|
|
|
327
403
|
? h('span', null, props.label)
|
|
328
404
|
: h(Tip, { text: props.tip }, h('span', null, props.label))),
|
|
329
405
|
h('span', { style: { flex: 1 } }),
|
|
330
|
-
props.hint === undefined || props.hint === '' ? null :
|
|
406
|
+
props.hint === undefined || props.hint === '' ? null : (
|
|
407
|
+
h('span', { style: styles.meta }, props.hint)
|
|
408
|
+
))
|
|
331
409
|
}
|
|
332
410
|
|
|
333
411
|
/** A collapsible group header: caret, title with its count, optional action. */
|
|
@@ -365,6 +443,7 @@ window.__ModuleLoader__.load({
|
|
|
365
443
|
const {
|
|
366
444
|
catalog, draft, onChange, busy, onCancel, onSave, saveLabel,
|
|
367
445
|
title, onSaveAs, saveAsLabel, nameField, namePlaceholder, clash,
|
|
446
|
+
onSaveKeep, saveKeepLabel, saveTip,
|
|
368
447
|
} = props
|
|
369
448
|
// Both lists start collapsed: the builder opens with a name field and a
|
|
370
449
|
// summary, not with forty checkboxes.
|
|
@@ -446,14 +525,247 @@ window.__ModuleLoader__.load({
|
|
|
446
525
|
onSaveAs === undefined
|
|
447
526
|
? null
|
|
448
527
|
: h('button', { type: 'button', style: styles.ghost, disabled: busy, onClick: onSaveAs }, saveAsLabel ?? '另存为新套装'),
|
|
528
|
+
// Saving and using are two decisions. The menu may do both, so it
|
|
529
|
+
// offers both; the settings page only ever saves a kit for later.
|
|
530
|
+
onSaveKeep === undefined
|
|
531
|
+
? null
|
|
532
|
+
: h('button', { type: 'button', style: styles.ghost, disabled: busy, onClick: onSaveKeep }, saveKeepLabel ?? '保存'),
|
|
449
533
|
h('button', {
|
|
450
534
|
type: 'button',
|
|
451
535
|
style: { ...styles.primary, ...(typeof clash === 'string' && clash !== '' ? { opacity: 0.5 } : null) },
|
|
452
536
|
disabled: busy || (typeof clash === 'string' && clash !== ''),
|
|
537
|
+
// `title`, not Tip: this button sits inside the menu's scrolling
|
|
538
|
+
// body, so a floating bubble would be clipped by the same overflow
|
|
539
|
+
// that keeps the panel short. A native tooltip always fits.
|
|
540
|
+
title: saveTip,
|
|
453
541
|
onClick: onSave,
|
|
454
542
|
}, saveLabel ?? '保存')))
|
|
455
543
|
}
|
|
456
544
|
|
|
545
|
+
// ---- the new-conversation picker ---------------------------------------
|
|
546
|
+
/**
|
|
547
|
+
* The kit picker a brand-new conversation shows in the hero row.
|
|
548
|
+
*
|
|
549
|
+
* A conversation that has not sent a message usually has no agent yet, so
|
|
550
|
+
* there is nothing to "switch": the pick is stored as this Session's binding
|
|
551
|
+
* and installed the moment the agent appears — before the first request
|
|
552
|
+
* builds a prompt, which makes it the one switch that never re-bills the
|
|
553
|
+
* prompt cache. The row leaves with the hero at the first send.
|
|
554
|
+
*
|
|
555
|
+
* Chips instead of a popover on purpose: the hero area is empty by
|
|
556
|
+
* definition, so an inline row needs no measuring and cannot be clipped the
|
|
557
|
+
* way the header's panel would be.
|
|
558
|
+
*/
|
|
559
|
+
function HeroKit(props) {
|
|
560
|
+
const sessionId = props.sessionId
|
|
561
|
+
// `preview` is a test seam for scripts/client-smoke.mjs, same as HeaderMenu.
|
|
562
|
+
const preview = props.preview ?? null
|
|
563
|
+
const [data, setData] = useState(preview?.data ?? null)
|
|
564
|
+
const [busy, setBusy] = useState(false)
|
|
565
|
+
// What the hover sentence says once a pick has landed (or why it failed);
|
|
566
|
+
// the row itself never grows a message line.
|
|
567
|
+
const [picked, setPicked] = useState(false)
|
|
568
|
+
const [failed, setFailed] = useState('')
|
|
569
|
+
// Collapsed by default: the picker answers "which kit will this
|
|
570
|
+
// conversation start with", and the list is what you open to change it.
|
|
571
|
+
const [open, setOpen] = useState(preview?.open === true)
|
|
572
|
+
const [groups, setGroups] = useState(preview?.groups ?? { presets: false, kits: false })
|
|
573
|
+
const box = useRef(null)
|
|
574
|
+
// One visit, one first look: a draft left by an earlier visit is dropped
|
|
575
|
+
// before it is ever shown, so the screen starts where the host's own mode
|
|
576
|
+
// starts — at the default.
|
|
577
|
+
const firstLook = useRef(true)
|
|
578
|
+
const lastSession = useRef(sessionId)
|
|
579
|
+
// `useSession` is a standard prop for session-scoped slots; a host that
|
|
580
|
+
// does not hand it over still only renders this slot in the hero phase.
|
|
581
|
+
const useSession = props.useSession
|
|
582
|
+
const blank = preview?.blank ?? (typeof useSession === 'function'
|
|
583
|
+
? (useSession((snapshot) => snapshot?.blank) ?? true)
|
|
584
|
+
: true)
|
|
585
|
+
|
|
586
|
+
const refresh = useCallback(async () => {
|
|
587
|
+
if (typeof sessionId !== 'string' || sessionId === '') return
|
|
588
|
+
// A different conversation in the same slot is a different visit.
|
|
589
|
+
if (lastSession.current !== sessionId) {
|
|
590
|
+
lastSession.current = sessionId
|
|
591
|
+
firstLook.current = true
|
|
592
|
+
}
|
|
593
|
+
try {
|
|
594
|
+
let next = await load(sessionId)
|
|
595
|
+
// A draft pick left on this welcome screen by a previous visit is not a
|
|
596
|
+
// decision: ask the host to drop it before showing anything, so the
|
|
597
|
+
// user sees the same clean slate the host gives its own mode. This has
|
|
598
|
+
// to cover a welcome screen that already has an agent behind it — that
|
|
599
|
+
// is the common case, and skipping it there is what let a draft from
|
|
600
|
+
// the last visit outlive the restart that should have cleared it.
|
|
601
|
+
if (firstLook.current === true && next.draft === true) {
|
|
602
|
+
next = await request({ action: 'release', sessionId })
|
|
603
|
+
}
|
|
604
|
+
// A blank screen has no history, so nothing is lost by agreeing with
|
|
605
|
+
// it: whatever the chip shows is what the next message must use. That
|
|
606
|
+
// covers a conversation created while a different default was set —
|
|
607
|
+
// the store still carries that kit, and it used to be shown as if it
|
|
608
|
+
// had been chosen here. `follow` keeps the correction borrowed (no
|
|
609
|
+
// draft), so a later default change moves it again.
|
|
610
|
+
if (blank === true) {
|
|
611
|
+
const wanted = next.draft === true
|
|
612
|
+
? String(next.binding ?? '')
|
|
613
|
+
: (typeof next.defaultKit === 'string' ? next.defaultKit : '')
|
|
614
|
+
if (String(next.binding ?? '') !== wanted) {
|
|
615
|
+
try {
|
|
616
|
+
next = await request({ action: 'bind', sessionId, name: wanted, follow: true })
|
|
617
|
+
} catch {
|
|
618
|
+
// Following is a courtesy, not the answer: if the host refuses,
|
|
619
|
+
// `load` already told the truth about what this screen uses.
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
firstLook.current = false
|
|
624
|
+
setData(next)
|
|
625
|
+
} catch {
|
|
626
|
+
// "No agent yet" is a normal answer for this route, not something to
|
|
627
|
+
// paint red above the composer.
|
|
628
|
+
}
|
|
629
|
+
}, [sessionId])
|
|
630
|
+
|
|
631
|
+
useEffect(() => {
|
|
632
|
+
refresh()
|
|
633
|
+
}, [refresh])
|
|
634
|
+
|
|
635
|
+
// The picker outlives the settings page: the default kit it shows when
|
|
636
|
+
// nothing is bound can change while this visit stays open, and coming back
|
|
637
|
+
// to the composer must not show the kit that was current at mount. Same
|
|
638
|
+
// sources the header menu listens to, plus focus for changes made in
|
|
639
|
+
// another window.
|
|
640
|
+
useEffect(() => {
|
|
641
|
+
const sync = () => { refresh() }
|
|
642
|
+
window.addEventListener(CHANGED_EVENT, sync)
|
|
643
|
+
window.addEventListener('focus', sync)
|
|
644
|
+
return () => {
|
|
645
|
+
window.removeEventListener(CHANGED_EVENT, sync)
|
|
646
|
+
window.removeEventListener('focus', sync)
|
|
647
|
+
}
|
|
648
|
+
}, [refresh])
|
|
649
|
+
|
|
650
|
+
// The list floats over the composer instead of pushing it down: a select
|
|
651
|
+
// that resizes the page every time it opens is worse than a covered input.
|
|
652
|
+
useEffect(() => {
|
|
653
|
+
if (!open) return undefined
|
|
654
|
+
const away = (event) => {
|
|
655
|
+
if (box.current !== null && !box.current.contains(event.target)) setOpen(false)
|
|
656
|
+
}
|
|
657
|
+
document.addEventListener('mousedown', away)
|
|
658
|
+
return () => document.removeEventListener('mousedown', away)
|
|
659
|
+
}, [open])
|
|
660
|
+
|
|
661
|
+
// The name a pick shows: the kit's title, or the two base names — `@dsh`
|
|
662
|
+
// is this plugin's wire value for 仅内置 and must never surface as text.
|
|
663
|
+
const labelOf = (name) => {
|
|
664
|
+
if (name === '') return '全部'
|
|
665
|
+
if (name === DEFAULT_DSH) return '仅内置'
|
|
666
|
+
const found = [...(data?.kits ?? []), ...(data?.presets ?? [])].find((kit) => kit.name === name)
|
|
667
|
+
return found?.title ?? found?.name ?? name
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const pick = async (name) => {
|
|
671
|
+
if (busy || typeof sessionId !== 'string' || sessionId === '') return
|
|
672
|
+
setBusy(true)
|
|
673
|
+
setFailed('')
|
|
674
|
+
try {
|
|
675
|
+
setData(await request({ action: 'bind', sessionId, name }))
|
|
676
|
+
setOpen(false)
|
|
677
|
+
setPicked(true)
|
|
678
|
+
} catch (error) {
|
|
679
|
+
setFailed(String(error?.message ?? error))
|
|
680
|
+
} finally {
|
|
681
|
+
setBusy(false)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (!blank || data === null || typeof sessionId !== 'string' || sessionId === '') return null
|
|
686
|
+
const kits = data.kits ?? []
|
|
687
|
+
const presets = data.presets ?? []
|
|
688
|
+
// `bound` separates "this conversation chose 全部" from "nothing chosen
|
|
689
|
+
// yet": without it, a fresh conversation would claim 全部 even when the
|
|
690
|
+
// default kit is what will actually start it.
|
|
691
|
+
const current = data.bound === true
|
|
692
|
+
? (typeof data.binding === 'string' ? data.binding : '')
|
|
693
|
+
: (typeof data.defaultKit === 'string' ? data.defaultKit : '')
|
|
694
|
+
// The list marks the default kit the way the header menu does — after the
|
|
695
|
+
// name. The collapsed chip stays plain (`选择对话套装:idea`): there it is
|
|
696
|
+
// the current pick, and "default" is not what the sentence says.
|
|
697
|
+
const isDefaultName = (name) => typeof data.defaultKit === 'string' && data.defaultKit === name
|
|
698
|
+
const marked = (label, name) => (isDefaultName(name) ? `${label}(默认)` : label)
|
|
699
|
+
const defaultKitRow = kits.find((kit) => kit.name === data.defaultKit)
|
|
700
|
+
const defaultPresetRow = presets.find((preset) => preset.name === data.defaultKit)
|
|
701
|
+
const row = (name, label, tip) => h(MenuRow, {
|
|
702
|
+
key: `pick-${name === '' ? 'all' : name}`,
|
|
703
|
+
label,
|
|
704
|
+
tip,
|
|
705
|
+
active: current === name,
|
|
706
|
+
indent: true,
|
|
707
|
+
disabled: busy,
|
|
708
|
+
onClick: () => pick(name),
|
|
709
|
+
})
|
|
710
|
+
const kitRow = (kit) => h(MenuRow, {
|
|
711
|
+
key: `pick-${kit.name}`,
|
|
712
|
+
label: marked(kitLabel(kit), kit.name),
|
|
713
|
+
active: current === kit.name,
|
|
714
|
+
indent: true,
|
|
715
|
+
disabled: busy,
|
|
716
|
+
onClick: () => pick(kit.name),
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
// The sentence lives on hover, not on the row: the composer line is shared
|
|
720
|
+
// with the permission and plan chips, so the control only says what it is
|
|
721
|
+
// plus the current pick.
|
|
722
|
+
const tipText = failed !== ''
|
|
723
|
+
? failed
|
|
724
|
+
: picked
|
|
725
|
+
? `这个对话会用「${labelOf(current)}」套装,下一条消息生效`
|
|
726
|
+
: `这个新对话会用「${labelOf(current)}」套装`
|
|
727
|
+
|
|
728
|
+
return h('span', { ref: box, style: styles.pickWrap, 'data-loadout': 'kit-pick' },
|
|
729
|
+
h('button', {
|
|
730
|
+
type: 'button',
|
|
731
|
+
style: { ...styles.chip, ...(open ? styles.chipActive : null) },
|
|
732
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
733
|
+
'aria-label': tipText,
|
|
734
|
+
title: tipText,
|
|
735
|
+
onClick: () => setOpen(!open),
|
|
736
|
+
},
|
|
737
|
+
h('span', { style: styles.chipMuted }, '选择对话套装'),
|
|
738
|
+
h('span', { style: styles.chipStrong }, labelOf(current)),
|
|
739
|
+
h('span', { style: styles.caret }, open ? '▾' : '▸')),
|
|
740
|
+
open
|
|
741
|
+
? h('div', { style: styles.pickPanel },
|
|
742
|
+
row('', marked('全部', ''), '不裁剪:Harness 自带工具和所有已启用插件的工具都能用。'),
|
|
743
|
+
row(DEFAULT_DSH, marked('仅内置', DEFAULT_DSH), '只保留 Harness 自带的工具,下载插件的工具全部隐藏。'),
|
|
744
|
+
h(GroupHead, {
|
|
745
|
+
title: '预设套装',
|
|
746
|
+
count: presets.length,
|
|
747
|
+
open: groups.presets,
|
|
748
|
+
tip: '出厂自带的只读套装,点一下即可套用。',
|
|
749
|
+
onToggle: () => setGroups({ ...groups, presets: !groups.presets }),
|
|
750
|
+
}),
|
|
751
|
+
// A collapsed group still shows the default kit: the picker is how
|
|
752
|
+
// you see what this conversation will start with, and hiding it
|
|
753
|
+
// behind a fold would answer "nothing chosen" on a fresh screen.
|
|
754
|
+
groups.presets === true
|
|
755
|
+
? presets.map(kitRow)
|
|
756
|
+
: (defaultPresetRow ? kitRow(defaultPresetRow) : null),
|
|
757
|
+
h(GroupHead, {
|
|
758
|
+
title: '我的套装',
|
|
759
|
+
count: kits.length,
|
|
760
|
+
open: groups.kits,
|
|
761
|
+
tip: '你自己保存的套装,点一下即可套用。',
|
|
762
|
+
onToggle: () => setGroups({ ...groups, kits: !groups.kits }),
|
|
763
|
+
}),
|
|
764
|
+
groups.kits === true ? kits.map(kitRow) : (defaultKitRow ? kitRow(defaultKitRow) : null))
|
|
765
|
+
: null,
|
|
766
|
+
)
|
|
767
|
+
}
|
|
768
|
+
|
|
457
769
|
// ---- the header control -------------------------------------------------
|
|
458
770
|
function HeaderMenu(props) {
|
|
459
771
|
const sessionId = props.sessionId
|
|
@@ -462,6 +774,9 @@ window.__ModuleLoader__.load({
|
|
|
462
774
|
const preview = props.preview ?? null
|
|
463
775
|
const [open, setOpen] = useState(preview?.open === true)
|
|
464
776
|
const [data, setData] = useState(preview?.data ?? null)
|
|
777
|
+
// Prices live in this browser, so the chip reads them at mount and again
|
|
778
|
+
// whenever the settings page announces a change.
|
|
779
|
+
const [prices, setPrices] = useState(readPrices)
|
|
465
780
|
// Both kit groups start collapsed. The menu is a switcher, not a directory:
|
|
466
781
|
// it has to show which set is on, not enumerate every set every time. A
|
|
467
782
|
// collapsed group still shows the one active row, and clicking the head is
|
|
@@ -521,9 +836,12 @@ window.__ModuleLoader__.load({
|
|
|
521
836
|
}, [refresh])
|
|
522
837
|
|
|
523
838
|
// Another surface (the settings page, the /kit command) may have changed
|
|
524
|
-
// this conversation's binding while the chip sat idle.
|
|
839
|
+
// this conversation's binding — or the price settings — while the chip sat idle.
|
|
525
840
|
useEffect(() => {
|
|
526
|
-
const sync = () => {
|
|
841
|
+
const sync = () => {
|
|
842
|
+
setPrices(readPrices())
|
|
843
|
+
refresh()
|
|
844
|
+
}
|
|
527
845
|
document.addEventListener('dsh-loadout:changed', sync)
|
|
528
846
|
window.addEventListener('focus', sync)
|
|
529
847
|
return () => {
|
|
@@ -569,6 +887,38 @@ window.__ModuleLoader__.load({
|
|
|
569
887
|
const activePreset = base === 'kit' ? presets.find((kit) => kit.name === selection.kit) : undefined
|
|
570
888
|
const applied = data?.appliedPlugins ?? []
|
|
571
889
|
const available = data?.availableExtras ?? plugins.map((plugin) => plugin.id)
|
|
890
|
+
// Prompt size comes from the host's `contextPressure` projection; it is
|
|
891
|
+
// null until this conversation has sent its first request.
|
|
892
|
+
const contextTokens = Number(data?.context?.tokens) || 0
|
|
893
|
+
// One sentence for the bill, stated for every conversation size. The same
|
|
894
|
+
// text a tooltip used to carry per row — moved here because the rows are
|
|
895
|
+
// read at a glance and the reminder under them is what people act on.
|
|
896
|
+
const billSentence = contextTokens > 0
|
|
897
|
+
? `本对话上下文(约 ${formatTokens(contextTokens)} tokens),下一条未命中价${switchCost(contextTokens, prices)},之后恢复至缓存价。`
|
|
898
|
+
: '这个对话还没有发出第一条消息;切换后第一条消息会把整套工具定义按未命中价算一次,之后恢复至缓存价。'
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* This menu belongs to one conversation: it may switch that conversation
|
|
902
|
+
* and it may build a new kit, but choosing what *new* conversations start
|
|
903
|
+
* from is a global decision and lives in 设置 → 装备栏 (`defaultKit`).
|
|
904
|
+
*/
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* A row's weight in the prompt: the tool definitions it would install.
|
|
908
|
+
*
|
|
909
|
+
* The price of *switching* is one number for the whole conversation — it
|
|
910
|
+
* depends on how long the conversation is, not on which kit you pick — so
|
|
911
|
+
* printing it on every row made 全部, 仅内置 and each kit read identically.
|
|
912
|
+
* The rows differ in how many tokens of tools they add, and that is what
|
|
913
|
+
* these hints now say; the bill itself sits once, under the reminder.
|
|
914
|
+
*/
|
|
915
|
+
const toolTokens = (entry) => (Number.isFinite(entry?.tokens) ? `≈${formatTokens(entry.tokens)} tokens` : '')
|
|
916
|
+
const withDefault = (label, isDefault) => (isDefault ? `${label}(默认)` : label)
|
|
917
|
+
|
|
918
|
+
/** `≈2.4k tokens · 只读`, and nothing at all when the row has no price. */
|
|
919
|
+
const rowHint = (parts) => parts
|
|
920
|
+
.filter((part) => part !== undefined && part !== '' && part !== false)
|
|
921
|
+
.join(' · ')
|
|
572
922
|
|
|
573
923
|
const run = async (payload) => {
|
|
574
924
|
setBusy(true)
|
|
@@ -595,7 +945,7 @@ window.__ModuleLoader__.load({
|
|
|
595
945
|
setSaveAs(false)
|
|
596
946
|
}
|
|
597
947
|
|
|
598
|
-
const saveBuilder = async () => {
|
|
948
|
+
const saveBuilder = async (mode) => {
|
|
599
949
|
if (builder === null) return
|
|
600
950
|
const kitName = builder.name.trim()
|
|
601
951
|
if (kitName === '') {
|
|
@@ -617,8 +967,14 @@ window.__ModuleLoader__.load({
|
|
|
617
967
|
plugins: builder.plugins,
|
|
618
968
|
})
|
|
619
969
|
setData(created)
|
|
620
|
-
const appliedKit = await run({ action: 'applyBase', base: 'kit', kit: kitName })
|
|
621
970
|
setBuilder(null)
|
|
971
|
+
if (mode === 'keep') {
|
|
972
|
+
// Saving is not switching: the kit is now on the list, and this
|
|
973
|
+
// conversation keeps the tools it already paid for.
|
|
974
|
+
setMessage(`已保存「${kitName}」。要在这个对话用它,点标题栏的「装备栏」。`)
|
|
975
|
+
return
|
|
976
|
+
}
|
|
977
|
+
const appliedKit = await run({ action: 'applyBase', base: 'kit', kit: kitName })
|
|
622
978
|
if (appliedKit !== null) setMessage(`已保存并启用「${kitName}」`)
|
|
623
979
|
} catch (error) {
|
|
624
980
|
setMessage(String(error.message ?? error))
|
|
@@ -690,7 +1046,10 @@ window.__ModuleLoader__.load({
|
|
|
690
1046
|
|
|
691
1047
|
const ledger = data?.ledger
|
|
692
1048
|
const saved = ledger?.savedTokens ?? 0
|
|
693
|
-
|
|
1049
|
+
// The context size is not a warning, it is the denominator of every price
|
|
1050
|
+
// below: "why is this 0.23" needs "because this conversation is 200k".
|
|
1051
|
+
const contextNote = contextTokens >= PRICE_MIN_TOKENS ? ` · 上下文 ~${formatTokens(contextTokens)}` : ''
|
|
1052
|
+
const summary = `当前:${label === '' ? '全部' : label}${extras.length === 0 ? '' : ` · 加挂 ${extras.length}`}${saved > 0 ? ` · 省 ~${formatTokens(saved)} tokens` : ''}${contextNote}`
|
|
694
1053
|
|
|
695
1054
|
|
|
696
1055
|
const rootBody = h('div', null,
|
|
@@ -705,16 +1064,19 @@ window.__ModuleLoader__.load({
|
|
|
705
1064
|
onClick: () => { run({ action: 'refresh' }) },
|
|
706
1065
|
}, '⟳')),
|
|
707
1066
|
h(MenuRow, {
|
|
708
|
-
label: '全部',
|
|
1067
|
+
label: withDefault('全部', (data?.defaultKit ?? '') === ''),
|
|
709
1068
|
tip: '不裁剪:Harness 自带工具和所有已启用插件的工具都能用。',
|
|
710
|
-
|
|
1069
|
+
// Both base rows pay for their own tool table: 全部 installs every
|
|
1070
|
+
// tool there is, 仅内置 only the built-in ones. That difference is the
|
|
1071
|
+
// reason to pick one over the other, so both rows state it.
|
|
1072
|
+
hint: rowHint([toolTokens({ tokens: data?.baseTokens?.all })]),
|
|
711
1073
|
active: isAll,
|
|
712
1074
|
onClick: () => run({ action: 'applyBase', base: 'all' }),
|
|
713
1075
|
}),
|
|
714
1076
|
h(MenuRow, {
|
|
715
|
-
label: '仅内置',
|
|
1077
|
+
label: withDefault('仅内置', (data?.defaultKit ?? '') === '@dsh'),
|
|
716
1078
|
tip: '只保留 Harness 自带的工具,下载插件的工具全部隐藏。',
|
|
717
|
-
hint:
|
|
1079
|
+
hint: rowHint([toolTokens({ tokens: data?.baseTokens?.dsh })]),
|
|
718
1080
|
active: isDsh,
|
|
719
1081
|
onClick: () => run({ action: 'applyBase', base: 'dsh' }),
|
|
720
1082
|
}),
|
|
@@ -738,8 +1100,8 @@ window.__ModuleLoader__.load({
|
|
|
738
1100
|
// creates a set — so "new kit" never needs a click on the head first.
|
|
739
1101
|
activeUserKit === undefined ? null : h(MenuRow, {
|
|
740
1102
|
key: 'active-kit',
|
|
741
|
-
label: kitLabel(activeUserKit),
|
|
742
|
-
hint: activeUserKit
|
|
1103
|
+
label: withDefault(kitLabel(activeUserKit), activeUserKit.isDefault),
|
|
1104
|
+
hint: rowHint([toolTokens(activeUserKit)]),
|
|
743
1105
|
active: true,
|
|
744
1106
|
indent: true,
|
|
745
1107
|
onClick: () => run({ action: 'applyBase', base: 'kit', kit: activeUserKit.name }),
|
|
@@ -749,8 +1111,8 @@ window.__ModuleLoader__.load({
|
|
|
749
1111
|
.filter((kit) => activeUserKit === undefined || kit.name !== activeUserKit.name)
|
|
750
1112
|
.map((kit) => h(MenuRow, {
|
|
751
1113
|
key: kit.name,
|
|
752
|
-
label: kitLabel(kit),
|
|
753
|
-
hint: kit
|
|
1114
|
+
label: withDefault(kitLabel(kit), kit.isDefault),
|
|
1115
|
+
hint: rowHint([toolTokens(kit)]),
|
|
754
1116
|
indent: true,
|
|
755
1117
|
onClick: () => {
|
|
756
1118
|
run({ action: 'applyBase', base: 'kit', kit: kit.name })
|
|
@@ -783,8 +1145,8 @@ window.__ModuleLoader__.load({
|
|
|
783
1145
|
SHOW_PRESETS === true && groups.presets !== true && activePreset !== undefined
|
|
784
1146
|
? h(MenuRow, {
|
|
785
1147
|
key: 'active-preset',
|
|
786
|
-
label: activePreset.title,
|
|
787
|
-
hint: '只读',
|
|
1148
|
+
label: withDefault(activePreset.title, data?.defaultKit === activePreset.name),
|
|
1149
|
+
hint: rowHint([toolTokens(activePreset), '只读']),
|
|
788
1150
|
active: true,
|
|
789
1151
|
indent: true,
|
|
790
1152
|
onClick: () => run({ action: 'applyBase', base: 'kit', kit: activePreset.name }),
|
|
@@ -795,8 +1157,8 @@ window.__ModuleLoader__.load({
|
|
|
795
1157
|
? h('div', { key: 'none', style: { ...styles.meta, padding: '2px 6px' } }, '没有预置套装')
|
|
796
1158
|
: presets.map((preset) => h(MenuRow, {
|
|
797
1159
|
key: preset.name,
|
|
798
|
-
label: preset.title,
|
|
799
|
-
hint: '只读',
|
|
1160
|
+
label: withDefault(preset.title, data?.defaultKit === preset.name),
|
|
1161
|
+
hint: rowHint([toolTokens(preset), '只读']),
|
|
800
1162
|
active: activePreset !== undefined && activePreset.name === preset.name,
|
|
801
1163
|
indent: true,
|
|
802
1164
|
onClick: () => {
|
|
@@ -808,9 +1170,13 @@ window.__ModuleLoader__.load({
|
|
|
808
1170
|
appliedBlock,
|
|
809
1171
|
h(GroupHead, {
|
|
810
1172
|
title: '加挂',
|
|
811
|
-
|
|
1173
|
+
// "already in this conversation / plugins there are at all". For the
|
|
1174
|
+
// left number that means the plugins the base brings plus the ones
|
|
1175
|
+
// attached on top; under 全部 it is all of them, so it reads 6/6 and
|
|
1176
|
+
// there is nothing left to add.
|
|
1177
|
+
count: `${plugins.filter((plugin) => applied.includes(plugin.id) || extras.includes(plugin.id)).length}/${plugins.length}`,
|
|
812
1178
|
open: groups.extras,
|
|
813
|
-
tip: '
|
|
1179
|
+
tip: '在当前选择上临时加开插件,不新建套装。已经包含的插件不能再加。',
|
|
814
1180
|
onToggle: () => setGroups({ ...groups, extras: !groups.extras }),
|
|
815
1181
|
action: extras.length === 0
|
|
816
1182
|
? null
|
|
@@ -880,7 +1246,14 @@ window.__ModuleLoader__.load({
|
|
|
880
1246
|
: null)
|
|
881
1247
|
: null)
|
|
882
1248
|
: null,
|
|
883
|
-
h('div', { style: styles.footer },
|
|
1249
|
+
h('div', { style: styles.footer },
|
|
1250
|
+
h('div', null, FOOTER),
|
|
1251
|
+
h('div', { style: styles.warnLine },
|
|
1252
|
+
h('strong', { style: { fontWeight: 600 } }, '重要提醒:'),
|
|
1253
|
+
FOOTER_WARN),
|
|
1254
|
+
// The bill, in one place instead of under every row: the reminder
|
|
1255
|
+
// above is the thing people read, and a tooltip has to be found.
|
|
1256
|
+
h('div', { style: styles.billLine }, billSentence)),
|
|
884
1257
|
message === '' ? null : h('div', { style: styles.error }, message))
|
|
885
1258
|
|
|
886
1259
|
const body = builder === null
|
|
@@ -891,8 +1264,10 @@ window.__ModuleLoader__.load({
|
|
|
891
1264
|
onChange: (next) => { setBuilder(next); setMessage('') },
|
|
892
1265
|
busy,
|
|
893
1266
|
onCancel: () => setBuilder(null),
|
|
894
|
-
onSave: saveBuilder,
|
|
1267
|
+
onSave: () => saveBuilder('apply'),
|
|
1268
|
+
onSaveKeep: () => saveBuilder('keep'),
|
|
895
1269
|
saveLabel: '保存并启用',
|
|
1270
|
+
saveTip: '工具清单会变,这个对话下一条消息要按未命中价重新计算一次。',
|
|
896
1271
|
clash: nameClash(builder.name, presets, kits, null),
|
|
897
1272
|
})
|
|
898
1273
|
|
|
@@ -931,10 +1306,15 @@ window.__ModuleLoader__.load({
|
|
|
931
1306
|
// settings list they sit next to kits, and two actions make sense for them:
|
|
932
1307
|
// copy the command that switches a conversation, or make them the default.
|
|
933
1308
|
function DefaultCard(props) {
|
|
934
|
-
const { title, description, isDefault, onUse, onDefault } = props
|
|
1309
|
+
const { title, description, isDefault, tokens, onUse, onDefault } = props
|
|
935
1310
|
return h('div', { style: styles.card },
|
|
936
1311
|
h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
|
|
937
1312
|
h('strong', null, title),
|
|
1313
|
+
// Same unit as the kit cards: what this choice costs in tools. Without
|
|
1314
|
+
// it 全部 and 仅内置 were the only two cards in the page with no number.
|
|
1315
|
+
Number.isFinite(tokens)
|
|
1316
|
+
? h('span', { style: styles.meta, title: '按工具定义估算(约 3.5 字符 / token)' }, `≈${formatTokens(tokens)} tokens`)
|
|
1317
|
+
: null,
|
|
938
1318
|
isDefault ? h('span', { style: { ...styles.meta, color: '#2563eb' } }, '默认') : null,
|
|
939
1319
|
h('span', { style: { flex: 1 } }),
|
|
940
1320
|
h('button', { type: 'button', style: styles.ghost, onClick: onUse }, '复制切换命令'),
|
|
@@ -1164,6 +1544,11 @@ window.__ModuleLoader__.load({
|
|
|
1164
1544
|
}
|
|
1165
1545
|
|
|
1166
1546
|
return h('div', { style: { padding: '4px 2px', fontSize: 12 } },
|
|
1547
|
+
// This page edits defaults, not a conversation: saying so up front is
|
|
1548
|
+
// cheaper than explaining why the numbers below never change while the
|
|
1549
|
+
// user is only renaming a kit.
|
|
1550
|
+
h('div', { style: { ...styles.meta, marginBottom: 8, lineHeight: 1.6 } },
|
|
1551
|
+
'设置页不绑定具体会话,上面的数字来自最近活跃的那个对话。'),
|
|
1167
1552
|
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
|
|
1168
1553
|
h('button', { type: 'button', style: styles.ghost, disabled: busy, onClick: refresh }, '刷新'),
|
|
1169
1554
|
h('button', {
|
|
@@ -1203,13 +1588,15 @@ window.__ModuleLoader__.load({
|
|
|
1203
1588
|
'(估算:按工具定义长度 ÷ 3.5 字符/token)',
|
|
1204
1589
|
].join(' · ')),
|
|
1205
1590
|
// This panel has no Session of its own: the host answers a bare GET from
|
|
1206
|
-
// the most recently seen agent
|
|
1207
|
-
//
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1591
|
+
// the most recently seen agent, so the ledger above is that conversation's.
|
|
1592
|
+
// One string, one style. As two spans the caveat sat behind a flex gap
|
|
1593
|
+
// and carried its own 0.6 opacity inside a heading that is already 0.6 —
|
|
1594
|
+
// nested opacity multiplies, so it rendered dimmer than the title next to
|
|
1595
|
+
// it and read as a different font rather than the rest of the sentence.
|
|
1596
|
+
h('div', { style: { ...styles.sectionTitle, marginTop: 14 } }, '新对话默认套装只影响新建对话'),
|
|
1211
1597
|
h('div', { style: { ...styles.meta, marginBottom: 6 } },
|
|
1212
|
-
|
|
1598
|
+
// The rest of the sentence stays abbreviated for the 370px panel.
|
|
1599
|
+
'当前对话在「装备栏」切换;新建时也可选套装。'),
|
|
1213
1600
|
// 全部 and 仅内置 are choices, not kits, but they read like the kit cards
|
|
1214
1601
|
// below (copy the switch command / make it the default) so the page has
|
|
1215
1602
|
// one idiom instead of two.
|
|
@@ -1217,6 +1604,7 @@ window.__ModuleLoader__.load({
|
|
|
1217
1604
|
h(DefaultCard, {
|
|
1218
1605
|
title: '全部',
|
|
1219
1606
|
description: '内置工具和已启用插件的工具都能用。',
|
|
1607
|
+
tokens: data.baseTokens?.all,
|
|
1220
1608
|
isDefault: defaultIsAll,
|
|
1221
1609
|
onUse: () => copyCommand('全部'),
|
|
1222
1610
|
onDefault: () => act({ action: 'default', name: '' }),
|
|
@@ -1224,6 +1612,7 @@ window.__ModuleLoader__.load({
|
|
|
1224
1612
|
h(DefaultCard, {
|
|
1225
1613
|
title: '仅内置',
|
|
1226
1614
|
description: '只带 Harness 内置的工具,下载插件的工具默认隐藏。',
|
|
1615
|
+
tokens: data.baseTokens?.dsh,
|
|
1227
1616
|
isDefault: defaultIsDsh,
|
|
1228
1617
|
onUse: () => copyCommand('仅内置'),
|
|
1229
1618
|
onDefault: () => act({ action: 'default', name: '@dsh' }),
|
|
@@ -1400,6 +1789,19 @@ window.__ModuleLoader__.load({
|
|
|
1400
1789
|
order: -100,
|
|
1401
1790
|
extra: { inject: (sessionId) => ({ sessionId }) },
|
|
1402
1791
|
}, HeaderMenu)
|
|
1792
|
+
// The new-conversation picker: the one moment where switching is free,
|
|
1793
|
+
// because the agent — and its first request — does not exist yet. It sits
|
|
1794
|
+
// at the front of the composer's right-hand tool group, i.e. immediately
|
|
1795
|
+
// to the right of the 标准模式 selector, and that row is mounted in both
|
|
1796
|
+
// phases — so the component gates itself on `blank`.
|
|
1797
|
+
registerSurface(ctx, {
|
|
1798
|
+
name: 'conversation.input.right',
|
|
1799
|
+
order: -10,
|
|
1800
|
+
// Session-scoped slots get their Session id through `inject`; without
|
|
1801
|
+
// it the picker could neither read this conversation's binding nor
|
|
1802
|
+
// write one.
|
|
1803
|
+
extra: { inject: (sessionId) => ({ sessionId }) },
|
|
1804
|
+
}, HeroKit)
|
|
1403
1805
|
// The settings page: the full editor and the place kits are stored from.
|
|
1404
1806
|
registerSurface(ctx, {
|
|
1405
1807
|
name: 'settings.section',
|