dsh-tool-switch 0.0.0-stage → 0.2.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/lib/client.js ADDED
@@ -0,0 +1,759 @@
1
+ // dsh-tool-switch — 会话级工具可见性开关(浏览器半侧)
2
+ //
3
+ // 这是一个手写的 DSH 客户端 bundle:协议是「用 window.__ModuleLoader__.load
4
+ // 注册一个 factory,factory 收到同步 require,返回 { apply, inject }」。
5
+ // 与官方插件产物同构,不需要构建步骤。
6
+ //
7
+ // 挂载点:`conversation.composer.dock` —— 输入框卡片正下方的那一条(上下文用量
8
+ // 指示器也在那里),所以按钮就长在对话框下面。
9
+ //
10
+ // 数据面:同源 fetch 到 Host 半侧注册的 /tool-switch/state。
11
+
12
+ window.__ModuleLoader__.load({
13
+ id: 'dsh-tool-switch',
14
+ factory: (require) => {
15
+ const module = { exports: {} }
16
+ const exports = module.exports
17
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
18
+
19
+ const React = require('react')
20
+ const { createPortal } = require('react-dom')
21
+
22
+ const h = React.createElement
23
+
24
+ /** Host 半侧注册的同源路由。 */
25
+ const ROUTE = '/tool-switch/state'
26
+
27
+ const COLOR = {
28
+ text: 'var(--dsw-alias-label-primary, #e6e6e6)',
29
+ secondary: 'var(--dsw-alias-label-secondary, #b4b4b4)',
30
+ caption: 'var(--dsw-alias-label-caption, #8b8b8b)',
31
+ border: 'var(--dsw-alias-border-l2, #3a3a3a)',
32
+ panel: 'var(--dsw-specific-input-major, #1e1e1e)',
33
+ hover: 'var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.06))',
34
+ accent: 'var(--dsw-alias-state-business-primary, #4d6bfe)',
35
+ radius: 'var(--dsw-radius-panel, 16px)',
36
+ }
37
+
38
+ // ---------------------------------------------------------------------
39
+ // 网络
40
+ // ---------------------------------------------------------------------
41
+
42
+ async function requestState(sessionId) {
43
+ const response = await fetch(`${ROUTE}?session=${encodeURIComponent(sessionId)}`, {
44
+ headers: { accept: 'application/json' },
45
+ credentials: 'same-origin',
46
+ })
47
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
48
+ return response.json()
49
+ }
50
+
51
+ async function saveState(sessionId, disabled) {
52
+ const response = await fetch(ROUTE, {
53
+ method: 'POST',
54
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
55
+ credentials: 'same-origin',
56
+ body: JSON.stringify({ session: sessionId, disabled }),
57
+ })
58
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
59
+ return response.json()
60
+ }
61
+
62
+ // ---------------------------------------------------------------------
63
+ // 小组件
64
+ // ---------------------------------------------------------------------
65
+
66
+ function SlidersIcon({ size = 14 }) {
67
+ return h(
68
+ 'svg',
69
+ {
70
+ width: size,
71
+ height: size,
72
+ viewBox: '0 0 16 16',
73
+ fill: 'none',
74
+ 'aria-hidden': true,
75
+ style: { display: 'block' },
76
+ },
77
+ h('path', { d: 'M2 4.5h5M11 4.5h3M2 11.5h3M9 11.5h5', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round' }),
78
+ h('circle', { cx: 9, cy: 4.5, r: 1.6, stroke: 'currentColor', strokeWidth: 1.3 }),
79
+ h('circle', { cx: 7, cy: 11.5, r: 1.6, stroke: 'currentColor', strokeWidth: 1.3 }),
80
+ )
81
+ }
82
+
83
+ function CheckMark({ checked, locked }) {
84
+ return h(
85
+ 'span',
86
+ {
87
+ 'aria-hidden': true,
88
+ style: {
89
+ width: 16,
90
+ height: 16,
91
+ flex: 'none',
92
+ borderRadius: 4,
93
+ border: `1px solid ${checked ? COLOR.accent : COLOR.border}`,
94
+ background: checked ? COLOR.accent : 'transparent',
95
+ opacity: locked ? 0.45 : 1,
96
+ display: 'grid',
97
+ placeItems: 'center',
98
+ transition: 'background .12s, border-color .12s',
99
+ },
100
+ },
101
+ checked
102
+ ? h('svg', { width: 11, height: 11, viewBox: '0 0 12 12', fill: 'none' }, h('path', { d: 'M2.5 6.2 4.8 8.5 9.5 3.8', stroke: '#fff', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' }))
103
+ : null,
104
+ )
105
+ }
106
+
107
+ function minimalButtonStyle(extra) {
108
+ return Object.assign(
109
+ {
110
+ display: 'inline-flex',
111
+ alignItems: 'center',
112
+ gap: 4,
113
+ height: 24,
114
+ padding: '0 8px',
115
+ border: 'none',
116
+ borderRadius: 999,
117
+ background: 'transparent',
118
+ color: COLOR.caption,
119
+ font: 'inherit',
120
+ fontSize: 12,
121
+ lineHeight: '18px',
122
+ cursor: 'pointer',
123
+ },
124
+ extra || {},
125
+ )
126
+ }
127
+
128
+ /** 分组标题行上「全关 / 全开」这种一键按钮的样式;不可用时置灰。 */
129
+ function groupActionStyle(active) {
130
+ return minimalButtonStyle({
131
+ height: 18,
132
+ padding: '0 6px',
133
+ borderRadius: 6,
134
+ fontSize: 11,
135
+ color: active ? COLOR.secondary : COLOR.caption,
136
+ background: active ? 'color-mix(in srgb, currentColor 10%, transparent)' : 'transparent',
137
+ opacity: active ? 1 : 0.4,
138
+ cursor: active ? 'pointer' : 'default',
139
+ })
140
+ }
141
+
142
+ function ActionButton({ children, onClick, disabled, title }) {
143
+ const [hover, setHover] = React.useState(false)
144
+ return h(
145
+ 'button',
146
+ {
147
+ type: 'button',
148
+ onClick,
149
+ disabled,
150
+ title,
151
+ onMouseEnter: () => setHover(true),
152
+ onMouseLeave: () => setHover(false),
153
+ style: minimalButtonStyle({
154
+ color: disabled ? COLOR.caption : COLOR.secondary,
155
+ background: hover && !disabled ? COLOR.hover : 'transparent',
156
+ opacity: disabled ? 0.5 : 1,
157
+ cursor: disabled ? 'default' : 'pointer',
158
+ }),
159
+ },
160
+ children,
161
+ )
162
+ }
163
+
164
+ // ---------------------------------------------------------------------
165
+ // 工具树:给定 state 直接产出元素(纯函数,便于离线验证)
166
+ // ---------------------------------------------------------------------
167
+
168
+ function filterGroups(groups, needle) {
169
+ if (needle.length === 0) return groups
170
+ const result = []
171
+ for (const group of groups) {
172
+ const tools = group.tools.filter(
173
+ (tool) =>
174
+ tool.name.toLowerCase().includes(needle) ||
175
+ tool.label.toLowerCase().includes(needle) ||
176
+ (tool.description ?? '').toLowerCase().includes(needle),
177
+ )
178
+ if (tools.length > 0) result.push(Object.assign({}, group, { tools }))
179
+ }
180
+ return result
181
+ }
182
+
183
+ function renderToolTree({ state, needle, expanded, setExpanded, toggleGroup, toggleTool }) {
184
+ const visibleGroups = filterGroups(state?.groups ?? [], needle)
185
+ if (visibleGroups.length === 0) {
186
+ return [h('div', { key: 'empty', style: { padding: '18px 14px', color: COLOR.caption, fontSize: 12 } }, '没有匹配的工具')]
187
+ }
188
+
189
+ const children = []
190
+ for (const group of visibleGroups) {
191
+ const closeable = group.tools.filter((tool) => !tool.locked)
192
+ const offCount = closeable.filter((tool) => !tool.enabled).length
193
+ const allEnabled = offCount === 0
194
+ // 默认全部收起:只有用户手动展开的组、或者正在搜索时才铺开。
195
+ const isOpen = needle.length > 0 || expanded[group.id] === true
196
+
197
+ children.push(
198
+ h(
199
+ 'div',
200
+ {
201
+ key: `g:${group.id}`,
202
+ style: {
203
+ display: 'flex',
204
+ alignItems: 'center',
205
+ gap: 6,
206
+ padding: '6px 12px 4px',
207
+ position: 'sticky',
208
+ top: 0,
209
+ background: COLOR.panel,
210
+ borderTop: `1px solid ${COLOR.border}`,
211
+ },
212
+ },
213
+ h(
214
+ 'button',
215
+ {
216
+ type: 'button',
217
+ 'data-group-id': group.id,
218
+ 'data-group-action': 'toggle',
219
+ title: isOpen ? '收起这一组' : `展开这一组(${group.tools.length} 个)`,
220
+ onClick: () =>
221
+ setExpanded((previous) => Object.assign({}, previous, { [group.id]: !isOpen })),
222
+ style: minimalButtonStyle({ width: 18, height: 18, padding: 0, justifyContent: 'center' }),
223
+ },
224
+ isOpen ? '▾' : '▸',
225
+ ),
226
+ h(
227
+ 'button',
228
+ {
229
+ type: 'button',
230
+ onClick: () => toggleGroup(group, !allEnabled),
231
+ disabled: closeable.length === 0,
232
+ title: allEnabled ? '关闭本组' : '启用本组',
233
+ style: minimalButtonStyle({ color: COLOR.secondary, fontSize: 12, fontWeight: 600, padding: '0 2px' }),
234
+ },
235
+ group.label,
236
+ ),
237
+ h(
238
+ 'span',
239
+ { style: { color: COLOR.caption, fontSize: 11 } },
240
+ `${group.tools.length - offCount}/${group.tools.length}`,
241
+ ),
242
+ h('span', { style: { flex: 1 } }),
243
+ // 一键开关整组:全关 关掉这一组下所有可关闭的工具,全开 反过来。
244
+ // 已经是目标状态的那个按钮置灰,避免看起来像还能再点。
245
+ h(
246
+ 'button',
247
+ {
248
+ type: 'button',
249
+ 'data-group-id': group.id,
250
+ 'data-group-action': 'off',
251
+ onClick: () => toggleGroup(group, false),
252
+ disabled: closeable.length === 0 || offCount === closeable.length,
253
+ title:
254
+ closeable.length === 0
255
+ ? '这一组的工具都由会话作用域固定,关不掉'
256
+ : `一键关闭「${group.label}」下的 ${closeable.length} 个工具`,
257
+ style: groupActionStyle(closeable.length > 0 && offCount < closeable.length),
258
+ },
259
+ '全关',
260
+ ),
261
+ h(
262
+ 'button',
263
+ {
264
+ type: 'button',
265
+ 'data-group-id': group.id,
266
+ 'data-group-action': 'on',
267
+ onClick: () => toggleGroup(group, true),
268
+ disabled: closeable.length === 0 || allEnabled,
269
+ title: `一键启用「${group.label}」下全部工具`,
270
+ style: groupActionStyle(closeable.length > 0 && !allEnabled),
271
+ },
272
+ '全开',
273
+ ),
274
+ ),
275
+ )
276
+
277
+ if (!isOpen) continue
278
+
279
+ for (const tool of group.tools) {
280
+ children.push(
281
+ h(
282
+ 'label',
283
+ {
284
+ key: `t:${tool.name}`,
285
+ title: tool.description || tool.name,
286
+ style: {
287
+ display: 'flex',
288
+ alignItems: 'flex-start',
289
+ gap: 8,
290
+ padding: '5px 12px 5px 30px',
291
+ cursor: tool.locked ? 'default' : 'pointer',
292
+ opacity: tool.enabled ? 1 : 0.55,
293
+ },
294
+ },
295
+ h(CheckMark, { checked: tool.enabled, locked: tool.locked }),
296
+ h(
297
+ 'div',
298
+ { style: { display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 } },
299
+ h(
300
+ 'span',
301
+ {
302
+ style: {
303
+ color: tool.locked ? COLOR.caption : COLOR.text,
304
+ fontSize: 12,
305
+ lineHeight: '17px',
306
+ fontFamily: 'var(--dsw-font-mono, ui-monospace, Menlo, Consolas, monospace)',
307
+ overflow: 'hidden',
308
+ textOverflow: 'ellipsis',
309
+ whiteSpace: 'nowrap',
310
+ },
311
+ },
312
+ tool.label + (tool.locked ? ' · 会话固定' : ''),
313
+ ),
314
+ tool.description.length > 0
315
+ ? h(
316
+ 'span',
317
+ {
318
+ style: {
319
+ color: COLOR.caption,
320
+ fontSize: 11,
321
+ lineHeight: '15px',
322
+ overflow: 'hidden',
323
+ textOverflow: 'ellipsis',
324
+ whiteSpace: 'nowrap',
325
+ },
326
+ },
327
+ tool.description,
328
+ )
329
+ : null,
330
+ ),
331
+ h('input', {
332
+ type: 'checkbox',
333
+ checked: tool.enabled,
334
+ disabled: tool.locked,
335
+ onChange: (event) => toggleTool(tool.name, event.target.checked),
336
+ style: { display: 'none' },
337
+ }),
338
+ ),
339
+ )
340
+ }
341
+ }
342
+ return children
343
+ }
344
+
345
+ // ---------------------------------------------------------------------
346
+ // 面板
347
+ // ---------------------------------------------------------------------
348
+
349
+ function ToolPanel({ sessionId, anchor, onClose, onModeChange }) {
350
+ const [state, setState] = React.useState(null)
351
+ const [phase, setPhase] = React.useState('loading')
352
+ const [error, setError] = React.useState('')
353
+ const [query, setQuery] = React.useState('')
354
+ // 展开状态(默认全收起):{ [groupId]: true } 表示这一组被手动展开。
355
+ const [expanded, setExpanded] = React.useState({})
356
+ const chain = React.useRef(Promise.resolve())
357
+
358
+ const disabledSet = React.useMemo(() => new Set(state?.disabled ?? []), [state])
359
+
360
+ // 切换会话(或重新打开面板)时回到「全收起」。
361
+ React.useEffect(() => {
362
+ setExpanded({})
363
+ setQuery('')
364
+ }, [sessionId])
365
+
366
+ React.useEffect(() => {
367
+ let cancelled = false
368
+ setPhase('loading')
369
+ requestState(sessionId)
370
+ .then((next) => {
371
+ if (cancelled) return
372
+ setState(next)
373
+ setPhase('ready')
374
+ setError('')
375
+ })
376
+ .catch((cause) => {
377
+ if (cancelled) return
378
+ setPhase('error')
379
+ setError(String(cause?.message ?? cause))
380
+ })
381
+ return () => {
382
+ cancelled = true
383
+ }
384
+ }, [sessionId])
385
+
386
+ const commit = React.useCallback(
387
+ (nextDisabled) => {
388
+ const list = [...new Set(nextDisabled)].sort()
389
+ // 先本地生效(点击即反馈),再按顺序落到 Host
390
+ setState((previous) =>
391
+ previous === null
392
+ ? previous
393
+ : Object.assign({}, previous, { disabled: list, mode: list.length === 0 ? 'all' : 'custom' }),
394
+ )
395
+ chain.current = chain.current
396
+ .then(() => saveState(sessionId, list))
397
+ .then((next) => {
398
+ if (next && next.ok) {
399
+ setState(next)
400
+ setPhase('ready')
401
+ setError('')
402
+ onModeChange?.(next.mode, next.counts)
403
+ }
404
+ })
405
+ .catch((cause) => {
406
+ setError(String(cause?.message ?? cause))
407
+ })
408
+ },
409
+ [sessionId, onModeChange],
410
+ )
411
+
412
+ const toggleTool = React.useCallback(
413
+ (name, nextEnabled) => {
414
+ const next = new Set(disabledSet)
415
+ if (nextEnabled) next.delete(name)
416
+ else next.add(name)
417
+ commit([...next])
418
+ },
419
+ [commit, disabledSet],
420
+ )
421
+
422
+ const toggleGroup = React.useCallback(
423
+ (group, nextEnabled) => {
424
+ const next = new Set(disabledSet)
425
+ for (const tool of group.tools) {
426
+ if (tool.locked) continue
427
+ if (nextEnabled) next.delete(tool.name)
428
+ else next.add(tool.name)
429
+ }
430
+ commit([...next])
431
+ },
432
+ [commit, disabledSet],
433
+ )
434
+
435
+ React.useEffect(() => {
436
+ const onKeyDown = (event) => {
437
+ if (event.key === 'Escape') {
438
+ event.stopPropagation()
439
+ onClose()
440
+ }
441
+ }
442
+ window.addEventListener('keydown', onKeyDown, true)
443
+ return () => window.removeEventListener('keydown', onKeyDown, true)
444
+ }, [onClose])
445
+
446
+ const width = 396
447
+ const left = anchor === null ? 12 : Math.max(12, Math.min(anchor.left, window.innerWidth - width - 12))
448
+ const bottom = anchor === null ? 96 : Math.max(12, window.innerHeight - anchor.top + 10)
449
+
450
+ const groups = state?.groups ?? []
451
+ const needle = query.trim().toLowerCase()
452
+
453
+ const enabledCount = state?.counts?.enabled ?? 0
454
+ const totalCount = state?.counts?.total ?? 0
455
+ const allOff = totalCount > 0 && enabledCount === 0
456
+
457
+ const header = h(
458
+ 'div',
459
+ { style: { display: 'flex', alignItems: 'center', gap: 8, padding: '12px 14px 8px' } },
460
+ h('span', { style: { color: COLOR.text, fontSize: 13, fontWeight: 600 } }, '本会话可用工具'),
461
+ h(
462
+ 'span',
463
+ { style: { color: COLOR.caption, fontSize: 12 } },
464
+ `${enabledCount}/${totalCount}`,
465
+ ),
466
+ h('span', { style: { flex: 1 } }),
467
+ h(
468
+ 'button',
469
+ {
470
+ type: 'button',
471
+ onClick: onClose,
472
+ title: '关闭 (Esc)',
473
+ style: minimalButtonStyle({ color: COLOR.caption, fontSize: 16, height: 20, padding: '0 6px' }),
474
+ },
475
+ '×',
476
+ ),
477
+ )
478
+
479
+ const toolbar = h(
480
+ 'div',
481
+ { style: { display: 'flex', alignItems: 'center', gap: 6, padding: '0 10px 8px', flexWrap: 'wrap' } },
482
+ h(
483
+ 'input',
484
+ {
485
+ value: query,
486
+ onChange: (event) => setQuery(event.target.value),
487
+ placeholder: '搜索工具…',
488
+ spellCheck: false,
489
+ style: {
490
+ flex: '1 1 130px',
491
+ minWidth: 120,
492
+ height: 26,
493
+ padding: '0 8px',
494
+ borderRadius: 8,
495
+ border: `1px solid ${COLOR.border}`,
496
+ background: 'transparent',
497
+ color: COLOR.text,
498
+ font: 'inherit',
499
+ fontSize: 12,
500
+ outline: 'none',
501
+ },
502
+ },
503
+ ),
504
+ h(
505
+ ActionButton,
506
+ {
507
+ onClick: () => setExpanded(Object.fromEntries(groups.map((group) => [group.id, true]))),
508
+ disabled: groups.length === 0,
509
+ title: '展开所有分组(只影响显示,不动工具开关)',
510
+ },
511
+ '展开',
512
+ ),
513
+ h(
514
+ ActionButton,
515
+ {
516
+ onClick: () => setExpanded({}),
517
+ disabled: groups.length === 0,
518
+ title: '收起所有分组',
519
+ },
520
+ '收起',
521
+ ),
522
+ h('span', {
523
+ 'aria-hidden': true,
524
+ style: { width: 1, height: 14, background: COLOR.border, margin: '0 2px' },
525
+ }),
526
+ h(ActionButton, { onClick: () => commit([]), disabled: state === null || (state.disabled ?? []).length === 0, title: '启用所有工具' }, '全开'),
527
+ h(
528
+ ActionButton,
529
+ {
530
+ onClick: () => {
531
+ const next = []
532
+ for (const group of groups) for (const tool of group.tools) if (!tool.locked) next.push(tool.name)
533
+ commit(next)
534
+ },
535
+ disabled: state === null || allOff,
536
+ title: '关闭所有可关闭的工具',
537
+ },
538
+ '全关',
539
+ ),
540
+ )
541
+
542
+ const bodyChildren =
543
+ phase === 'loading' && state === null
544
+ ? [h('div', { key: 'loading', style: { padding: '18px 14px', color: COLOR.caption, fontSize: 12 } }, '读取工具列表…')]
545
+ : phase === 'error' && state === null
546
+ ? [
547
+ h('div', { key: 'error', style: { padding: '18px 14px', color: COLOR.caption, fontSize: 12, lineHeight: '18px' } }, [
548
+ h('div', { key: 'message' }, '无法连接到 Host 侧:', String(error || '未知错误')),
549
+ h(
550
+ 'div',
551
+ { key: 'hint', style: { marginTop: 6 } },
552
+ '确认 dsh-tool-switch 主机插件已启用(设置 → 插件),然后刷新页面。',
553
+ ),
554
+ ]),
555
+ ]
556
+ : renderToolTree({ state, needle, expanded, setExpanded, toggleGroup, toggleTool })
557
+
558
+ const footer = h(
559
+ 'div',
560
+ {
561
+ style: {
562
+ borderTop: `1px solid ${COLOR.border}`,
563
+ padding: '8px 14px 10px',
564
+ color: COLOR.caption,
565
+ fontSize: 11,
566
+ lineHeight: '16px',
567
+ },
568
+ },
569
+ error.length > 0
570
+ ? h('div', { style: { color: 'var(--dsw-alias-state-warning-primary, #f0a020)' } }, `保存失败:${error}`)
571
+ : h(
572
+ 'div',
573
+ null,
574
+ state?.live === false
575
+ ? '该会话当前没有运行中的 Agent:改动会在下次开始时生效。'
576
+ : '改动从下一条消息开始生效;被关闭的工具模型既看不到也调用不了。',
577
+ ),
578
+ state?.missing?.length > 0 ? h('div', { style: { marginTop: 4 } }, `注意:${state.missing.length} 个已关闭的工具已不存在。`) : null,
579
+ )
580
+
581
+ return createPortal(
582
+ h(
583
+ React.Fragment,
584
+ null,
585
+ h('div', {
586
+ onClick: onClose,
587
+ style: { position: 'fixed', inset: 0, zIndex: 2147483000, background: 'transparent' },
588
+ }),
589
+ h(
590
+ 'div',
591
+ {
592
+ role: 'dialog',
593
+ 'aria-label': '本会话可用工具',
594
+ style: {
595
+ position: 'fixed',
596
+ left,
597
+ bottom,
598
+ width,
599
+ maxWidth: `calc(100vw - 24px)`,
600
+ maxHeight: 'min(62vh, 560px)',
601
+ display: 'flex',
602
+ flexDirection: 'column',
603
+ zIndex: 2147483001,
604
+ background: COLOR.panel,
605
+ color: COLOR.text,
606
+ border: `1px solid ${COLOR.border}`,
607
+ borderRadius: COLOR.radius,
608
+ boxShadow: 'var(--dsw-elevation-soft, 0 12px 32px rgba(0,0,0,.35))',
609
+ overflow: 'hidden',
610
+ font: 'inherit',
611
+ },
612
+ },
613
+ header,
614
+ toolbar,
615
+ h('div', { style: { overflowY: 'auto', flex: 1, paddingBottom: 6 } }, bodyChildren),
616
+ footer,
617
+ ),
618
+ ),
619
+ document.body,
620
+ )
621
+ }
622
+
623
+ // ---------------------------------------------------------------------
624
+ // 输入框下方的按钮
625
+ // ---------------------------------------------------------------------
626
+
627
+ function ToolSwitchButton(props) {
628
+ const sessionId = typeof props?.sessionId === 'string' ? props.sessionId : undefined
629
+ const [open, setOpen] = React.useState(false)
630
+ const [hover, setHover] = React.useState(false)
631
+ const [summary, setSummary] = React.useState(null)
632
+ const anchorRef = React.useRef(null)
633
+ const [anchor, setAnchor] = React.useState(null)
634
+
635
+ const refreshSummary = React.useCallback(() => {
636
+ if (sessionId === undefined) return
637
+ requestState(sessionId)
638
+ .then((next) => setSummary(next?.ok ? { mode: next.mode, counts: next.counts } : null))
639
+ .catch(() => setSummary(null))
640
+ }, [sessionId])
641
+
642
+ React.useEffect(() => {
643
+ refreshSummary()
644
+ }, [refreshSummary])
645
+
646
+ const openPanel = React.useCallback(() => {
647
+ const rect = anchorRef.current?.getBoundingClientRect()
648
+ setAnchor(rect === undefined ? null : { left: rect.left, top: rect.top })
649
+ setOpen(true)
650
+ refreshSummary()
651
+ }, [refreshSummary])
652
+
653
+ if (sessionId === undefined) return null
654
+
655
+ const custom = summary?.mode === 'custom'
656
+ const offCount = custom ? summary?.counts?.disabled ?? 0 : 0
657
+ const label = '工具'
658
+
659
+ const button = h(
660
+ 'button',
661
+ {
662
+ ref: anchorRef,
663
+ type: 'button',
664
+ onClick: () => (open ? setOpen(false) : openPanel()),
665
+ onMouseEnter: () => setHover(true),
666
+ onMouseLeave: () => setHover(false),
667
+ title: custom
668
+ ? `本会话已关闭 ${offCount} 个工具(点开调整)`
669
+ : '选择本会话里模型可用的工具',
670
+ 'aria-label': custom ? `工具开关:已关闭 ${offCount} 个` : '工具开关',
671
+ 'aria-haspopup': 'dialog',
672
+ 'aria-expanded': open,
673
+ style: minimalButtonStyle({
674
+ height: 24,
675
+ gap: 6,
676
+ padding: '0 10px 0 8px',
677
+ fontWeight: 500,
678
+ color: custom ? COLOR.accent : COLOR.secondary,
679
+ background: custom
680
+ ? 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d6bfe) 16%, transparent)'
681
+ : hover || open
682
+ ? COLOR.hover
683
+ : 'color-mix(in srgb, currentColor 8%, transparent)',
684
+ border: `1px solid ${custom ? 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d6bfe) 45%, transparent)' : COLOR.border}`,
685
+ }),
686
+ },
687
+ h(SlidersIcon, { size: 14 }),
688
+ h('span', null, label),
689
+ custom
690
+ ? h(
691
+ 'span',
692
+ {
693
+ style: {
694
+ minWidth: 16,
695
+ height: 16,
696
+ padding: '0 4px',
697
+ borderRadius: 999,
698
+ background: COLOR.accent,
699
+ color: '#fff',
700
+ fontSize: 10,
701
+ lineHeight: '16px',
702
+ textAlign: 'center',
703
+ fontWeight: 600,
704
+ },
705
+ },
706
+ String(offCount),
707
+ )
708
+ : null,
709
+ )
710
+
711
+ return h(
712
+ React.Fragment,
713
+ null,
714
+ button,
715
+ open
716
+ ? h(ToolPanel, {
717
+ sessionId,
718
+ anchor,
719
+ onClose: () => {
720
+ setOpen(false)
721
+ refreshSummary()
722
+ },
723
+ onModeChange: (mode, counts) => setSummary({ mode, counts }),
724
+ })
725
+ : null,
726
+ )
727
+ }
728
+
729
+ /** hover 时把说明文字提亮一点,面板标题固定用强调色。 */
730
+
731
+ // ---------------------------------------------------------------------
732
+ // 插件主体
733
+ // ---------------------------------------------------------------------
734
+
735
+ /** 需要的客户端服务:只有 slot 注册表。 */
736
+ const inject = ['slots']
737
+
738
+ function apply(ctx) {
739
+ // 槽位声明可能晚于本插件加载,ctx.slots.inject 会等到声明就绪再注册。
740
+ ctx.slots.inject('conversation.composer.dock', () =>
741
+ ctx.slots.register(
742
+ {
743
+ name: 'conversation.composer.dock',
744
+ id: 'tool-switch',
745
+ order: 40,
746
+ inject: (sessionId) => ({ sessionId }),
747
+ },
748
+ ToolSwitchButton,
749
+ ),
750
+ )
751
+ }
752
+
753
+ exports.apply = apply
754
+ exports.inject = inject
755
+ // 仅离线测试用(Host 侧 bundle loader 只用 apply / inject):
756
+ exports.__internals = { renderToolTree, filterGroups, ToolPanel, ToolSwitchButton }
757
+ return module.exports
758
+ },
759
+ })