@chentyit/dsh-chatbot-panel 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/client.js ADDED
@@ -0,0 +1,516 @@
1
+ /**
2
+ * Browser half of the ChatBot capability panel bundle.
3
+ *
4
+ * Renders a split capsule immediately right of the permission chip in the
5
+ * composer tool row: the left segment toggles the session's web capability and
6
+ * the right segment opens an upward panel listing the capabilities plus this
7
+ * session's usage and estimated cost. Capability state and usage are Host
8
+ * projections; this file only reads them and dispatches `/chatbot ...` lines.
9
+ */
10
+ window.__ModuleLoader__.load({
11
+ id: '@chentyit/dsh-chatbot-panel',
12
+ factory(require) {
13
+ const React = require('react')
14
+ const { createPortal } = require('react-dom')
15
+ const h = React.createElement
16
+
17
+ /** Locale namespace for the capsule copy and the registration's `t`. */
18
+ const NS = 'chatbot-panel'
19
+ /** Official platform usage page; the Host projection carries the same link. */
20
+ const USAGE_URL = 'https://platform.deepseek.com/usage'
21
+ /** Panel width in CSS pixels, used to keep it inside the viewport. */
22
+ const PANEL_WIDTH = 328
23
+
24
+ const zh = {
25
+ label: 'ChatBot',
26
+ openPanel: '展开 ChatBot 能力',
27
+ panelTitle: 'ChatBot 能力',
28
+ scope: '本会话',
29
+ groupWeb: '联网工具',
30
+ web: '联网搜索',
31
+ webHint: '¥5 / 千次 · 附图提问即搜图',
32
+ deep: '深度思考搜索',
33
+ deepHint: '多步推理 · 支持以图搜图 · 更耗 token',
34
+ deepNeedsWeb: '先开启联网搜索',
35
+ searchNote: '以图搜图只读本轮图片;搜图与深搜需 DeepSeek 账号或 API key。',
36
+ pending: '应用中…',
37
+ usageTitle: '本会话用量',
38
+ search: '搜索',
39
+ fetch: '抓取',
40
+ imageUsage: '搜图',
41
+ billed: '次计费请求',
42
+ imagesCount: '张图',
43
+ deepUsage: '深搜',
44
+ failed: '失败',
45
+ times: '次',
46
+ sources: '个来源',
47
+ toolCalls: '次工具调用',
48
+ input: '输入',
49
+ output: '输出',
50
+ feeTitle: '费用估算',
51
+ searchFee: '搜索费',
52
+ fetchFee: '抓取费',
53
+ imageToolFee: '搜图工具费',
54
+ imageTokenFee: '搜图 token 费',
55
+ deepToolFee: '深搜工具费',
56
+ deepTokenFee: '深搜 token 费',
57
+ totalFee: '合计',
58
+ fetchFree: '匿名抓取不计费',
59
+ feeNote: '按官方价估算,实际以平台账单为准',
60
+ usageLink: '查看官方用量 ↗',
61
+ active: '已开启',
62
+ inactive: '未开启',
63
+ errorUnknown: '命令未生效',
64
+ errorPrefix: '操作失败:',
65
+ }
66
+
67
+ const en = {
68
+ label: 'ChatBot',
69
+ openPanel: 'Open ChatBot capabilities',
70
+ panelTitle: 'ChatBot capabilities',
71
+ scope: 'this session',
72
+ groupWeb: 'Web tools',
73
+ web: 'Web search',
74
+ webHint: '¥5 / 1,000 · attach an image and ask',
75
+ deep: 'Deep thinking search',
76
+ deepHint: 'Multi-step · image search · token-heavy',
77
+ deepNeedsWeb: 'Turn on web search first',
78
+ searchNote: 'Image search reads only this turn\'s images; image and deep search need a DeepSeek sign-in or API key.',
79
+ pending: 'Applying…',
80
+ usageTitle: 'Session usage',
81
+ search: 'Search',
82
+ fetch: 'Fetch',
83
+ imageUsage: 'Image search',
84
+ billed: 'billed requests',
85
+ imagesCount: 'images',
86
+ deepUsage: 'Deep',
87
+ failed: 'Failed',
88
+ times: '',
89
+ sources: 'sources',
90
+ toolCalls: 'tool calls',
91
+ input: 'in',
92
+ output: 'out',
93
+ feeTitle: 'Estimated cost',
94
+ searchFee: 'Search',
95
+ fetchFee: 'Fetch',
96
+ imageToolFee: 'Image tools',
97
+ imageTokenFee: 'Image tokens',
98
+ deepToolFee: 'Deep tools',
99
+ deepTokenFee: 'Deep tokens',
100
+ totalFee: 'Total',
101
+ fetchFree: 'Anonymous fetch is free',
102
+ feeNote: 'Estimated from official prices; the platform bill is authoritative',
103
+ usageLink: 'Open platform usage ↗',
104
+ active: 'on',
105
+ inactive: 'off',
106
+ errorUnknown: 'The command did not apply',
107
+ errorPrefix: 'Failed: ',
108
+ }
109
+
110
+ const CSS = [
111
+ /* Chip chrome copied from the permission trigger: transparent, 28px,
112
+ label-secondary, hover-only tint, small glyph and chevron. No border,
113
+ no fill, so it reads as another composer tool next to 「工作区内修改」. */
114
+ '.cbp-capsule{display:inline-flex;align-items:center;height:28px;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-family,inherit);font-size:13px;line-height:20px;font-weight:500;vertical-align:middle}',
115
+ '.cbp-main{display:inline-flex;align-items:center;gap:4px;min-width:0;max-width:220px;height:28px;box-sizing:border-box;padding:0 4px 0 8px;border:none;border-radius:var(--dsw-radius-sm);outline:none;background:transparent;color:inherit;font:inherit;cursor:pointer}',
116
+ /* The chip is one control: clicking anywhere on it opens or closes the
117
+ capability panel. Hover tints the whole chip, arrow included. */
118
+ '@media (hover: hover) {',
119
+ '.cbp-capsule:hover{background:var(--dsw-alias-interactive-bg-hover)}',
120
+ '}',
121
+ '.cbp-main:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}',
122
+ '.cbp-icon{display:inline-flex;flex:0 0 auto;color:var(--dsw-alias-label-caption);transition:color .12s ease}',
123
+ '.cbp-icon svg{display:block;width:14px;height:14px}',
124
+ '.cbp-capsule[data-on="true"] .cbp-icon{color:var(--dsw-alias-state-success-primary)}',
125
+ '.cbp-capsule[data-on="true"] .cbp-label{color:var(--dsw-alias-label-primary)}',
126
+ '.cbp-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
127
+ '.cbp-arrow{display:inline-flex;flex:0 0 auto;margin-left:1px;color:var(--dsw-alias-label-caption)}',
128
+ '.cbp-arrow svg{display:block;width:12px;height:12px;transition:transform 120ms ease}',
129
+ '.cbp-main[data-open="true"] .cbp-arrow svg{transform:rotate(180deg)}',
130
+ '.cbp-panel{position:fixed;z-index:1200;box-sizing:border-box;display:flex;flex-direction:column;padding:6px;border-radius:var(--dsw-radius-lg);background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#fff));backdrop-filter:var(--dsw-menu-backdrop-filter,none);box-shadow:var(--dsw-elevation-prominent,0 12px 32px rgba(0,0,0,.18));color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family,inherit);overflow-y:auto;overscroll-behavior:contain}',
131
+ '.cbp-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px;padding:6px 8px 2px}',
132
+ '.cbp-head-title{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary)}',
133
+ '.cbp-head-scope{font-size:11px;color:var(--dsw-alias-label-caption)}',
134
+ '.cbp-group{padding:8px 8px 2px;font-size:11px;font-weight:600;letter-spacing:.03em;color:var(--dsw-alias-label-tertiary)}',
135
+ '.cbp-group-note{padding:4px 8px 2px}',
136
+ '.cbp-row{display:flex;align-items:center;gap:10px;width:100%;box-sizing:border-box;padding:7px 8px;border:0;border-radius:var(--dsw-radius-md,8px);background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;text-align:left;cursor:pointer}',
137
+ '.cbp-row:hover:not([aria-disabled="true"]){background:var(--dsw-alias-interactive-bg-hover)}',
138
+ '.cbp-row:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-1px}',
139
+ '.cbp-row[aria-disabled="true"]{cursor:default;opacity:.55}',
140
+ '.cbp-row-sub{padding-left:22px}',
141
+ '.cbp-row-text{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}',
142
+ '.cbp-row-label{font-size:13px;line-height:18px}',
143
+ '.cbp-row-hint{font-size:11px;line-height:15px;color:var(--dsw-alias-label-secondary)}',
144
+ '.cbp-check{display:inline-flex;align-items:center;justify-content:center;flex:none;width:18px;height:18px;border-radius:var(--dsw-radius-sm,6px);border:1.5px solid var(--dsw-alias-border-l2,currentColor);box-sizing:border-box;color:transparent}',
145
+ '.cbp-check[data-checked="true"]{border-color:transparent;background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary-inverted)}',
146
+ '.cbp-check svg{display:block;width:12px;height:12px}',
147
+ '.cbp-sep{height:1px;margin:6px 6px;background:var(--dsw-alias-border-l1)}',
148
+ '.cbp-usage{display:flex;flex-direction:column;gap:2px;padding:2px 8px 4px;font-size:11px;line-height:17px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}',
149
+ '.cbp-usage-title{font-size:11px;font-weight:600;letter-spacing:.03em;color:var(--dsw-alias-label-tertiary);padding:0 0 2px}',
150
+ '.cbp-usage-line{display:flex;justify-content:space-between;gap:10px}',
151
+ '.cbp-usage-line b{font-weight:600;color:var(--dsw-alias-label-primary)}',
152
+ '.cbp-fee{border-top:1px dashed var(--dsw-alias-border-l1);margin-top:4px;padding-top:4px}',
153
+ '.cbp-total{display:flex;justify-content:space-between;gap:10px;font-size:12px;color:var(--dsw-alias-label-primary);font-weight:600}',
154
+ '.cbp-note{font-size:10px;line-height:15px;color:var(--dsw-alias-label-caption)}',
155
+ '.cbp-link{font-size:11px;line-height:17px;color:var(--dsw-alias-link);text-decoration:none}',
156
+ '.cbp-link:hover{text-decoration:underline}',
157
+ '.cbp-error{margin:4px 6px 2px;padding:5px 8px;border-radius:var(--dsw-radius-sm,6px);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent);color:var(--dsw-alias-state-error-primary);font-size:11px;line-height:15px}',
158
+ ].join('')
159
+
160
+ /** The capsule owns its stylesheet, removed with the plugin's activation. */
161
+ function insertStyles() {
162
+ if (typeof document === 'undefined') return () => {}
163
+ const tag = document.createElement('style')
164
+ tag.setAttribute('data-plugin', '@chentyit/dsh-chatbot-panel')
165
+ tag.textContent = CSS
166
+ document.head.appendChild(tag)
167
+ return () => { tag.remove() }
168
+ }
169
+
170
+ /** Format a yuan amount for the detail rows. */
171
+ function yuan(value) {
172
+ const amount = typeof value === 'number' && Number.isFinite(value) ? value : 0
173
+ return `¥${amount.toFixed(4)}`
174
+ }
175
+
176
+ /** Format a token count compactly. */
177
+ function tokens(value) {
178
+ const amount = typeof value === 'number' && Number.isFinite(value) ? value : 0
179
+ return amount >= 1000 ? `${(amount / 1000).toFixed(1)}k` : String(amount)
180
+ }
181
+
182
+ /** Join a count and its unit without a trailing space when the unit is empty. */
183
+ function count(value, unit) {
184
+ const amount = typeof value === 'number' && Number.isFinite(value) ? value : 0
185
+ return typeof unit === 'string' && unit.length > 0 ? `${amount} ${unit}` : String(amount)
186
+ }
187
+
188
+ /** A small check mark drawn in the theme's inverted label color. */
189
+ function CheckGlyph() {
190
+ return h('svg', { viewBox: '0 0 12 12', 'aria-hidden': true },
191
+ h('path', {
192
+ d: 'M2.2 6.3 4.6 8.7 9.8 3.4',
193
+ fill: 'none',
194
+ stroke: 'currentColor',
195
+ strokeWidth: '1.8',
196
+ strokeLinecap: 'round',
197
+ strokeLinejoin: 'round',
198
+ }))
199
+ }
200
+
201
+ /** Chevron pointing up while the panel is closed, down while it is open. */
202
+ /** A small bot-head mark (antenna, head, eyes) matching the 14px chip glyphs. */
203
+ function BotGlyph() {
204
+ // Optically centered against the 13px label: text ink sits ~0.35px above
205
+ // the flex-line center, so the glyph's ink bounds (outer top 1.17 … outer
206
+ // bottom 12.12, stroke included) center on y≈6.65 instead of 7. Measured
207
+ // from a rendered chip screenshot, this lands the icon level with the text.
208
+ return h('svg', { viewBox: '0 0 14 14', 'aria-hidden': true },
209
+ h('path', {
210
+ d: 'M7 3.67v1.2',
211
+ fill: 'none',
212
+ stroke: 'currentColor',
213
+ strokeWidth: '1.2',
214
+ strokeLinecap: 'round',
215
+ }),
216
+ h('circle', { cx: 7, cy: 2.72, r: 0.95, fill: 'currentColor' }),
217
+ h('rect', {
218
+ x: 2.1,
219
+ y: 4.92,
220
+ width: 9.8,
221
+ height: 6.6,
222
+ rx: 2.2,
223
+ fill: 'none',
224
+ stroke: 'currentColor',
225
+ strokeWidth: '1.2',
226
+ }),
227
+ h('circle', { cx: 5.3, cy: 7.27, r: 0.85, fill: 'currentColor' }),
228
+ h('circle', { cx: 8.7, cy: 7.27, r: 0.85, fill: 'currentColor' }),
229
+ h('path', {
230
+ d: 'M5.6 9.37h2.8',
231
+ fill: 'none',
232
+ stroke: 'currentColor',
233
+ strokeWidth: '1.1',
234
+ strokeLinecap: 'round',
235
+ }))
236
+ }
237
+
238
+ /** Chevron pointing down; the open state rotates it in CSS. */
239
+ function ChevronGlyph() {
240
+ return h('svg', { viewBox: '0 0 12 12', 'aria-hidden': true },
241
+ h('path', {
242
+ d: 'M2.4 4.4 6 8 9.6 4.4',
243
+ fill: 'none',
244
+ stroke: 'currentColor',
245
+ strokeWidth: '1.6',
246
+ strokeLinecap: 'round',
247
+ strokeLinejoin: 'round',
248
+ }))
249
+ }
250
+
251
+ /** One multi-select capability row. */
252
+ function CapabilityRow(props) {
253
+ const { t, checked, label, hint, sub, disabled, onToggle } = props
254
+ return h('button', {
255
+ type: 'button',
256
+ role: 'checkbox',
257
+ 'aria-checked': checked ? 'true' : 'false',
258
+ 'aria-disabled': disabled ? 'true' : undefined,
259
+ className: sub === true ? 'cbp-row cbp-row-sub' : 'cbp-row',
260
+ onClick: disabled ? undefined : onToggle,
261
+ },
262
+ h('span', { className: 'cbp-row-text' },
263
+ h('span', { className: 'cbp-row-label' }, label),
264
+ typeof hint === 'string' && hint.length > 0 ? h('span', { className: 'cbp-row-hint' }, hint) : null),
265
+ h('span', { className: 'cbp-check', 'data-checked': checked ? 'true' : 'false', 'aria-hidden': true },
266
+ checked ? h(CheckGlyph) : null))
267
+ }
268
+
269
+ /** Detailed usage and estimate block. */
270
+ function UsageBlock(props) {
271
+ const { t, usage } = props
272
+ if (usage === undefined || usage === null) {
273
+ return h('div', { className: 'cbp-usage' },
274
+ h('div', { className: 'cbp-usage-title' }, t('usageTitle')),
275
+ h('div', { className: 'cbp-usage-line' }, h('span', null, t('search')), h('b', null, count(0, t('times')))))
276
+ }
277
+ const search = usage.search || { calls: 0, queries: 0, sources: 0, failed: 0, failedQueries: 0 }
278
+ const fetch = usage.fetch || { calls: 0, failed: 0 }
279
+ const image = usage.image || { calls: 0, failed: 0, images: 0, results: 0, imageResults: 0, toolSearch: 0, toolFetch: 0, imageSearch: 0, inputTokens: 0, outputTokens: 0 }
280
+ const deep = usage.deep || { calls: 0, failed: 0, toolSearch: 0, toolFetch: 0, imageSearch: 0, inputTokens: 0, outputTokens: 0, sources: 0 }
281
+ const fees = usage.fees || { search: 0, fetch: 0, image: 0, imageTools: 0, imageTokens: 0, deepTools: 0, deepTokens: 0, total: 0 }
282
+ const deepTools = (deep.toolSearch || 0) + (deep.toolFetch || 0) + (deep.imageSearch || 0)
283
+ const imageBilled = (image.toolSearch || 0) + (image.toolFetch || 0) + (image.imageSearch || 0)
284
+ const showImage = image.calls > 0 || image.failed > 0 || imageBilled > 0
285
+ const failures = (search.failed || 0) + (fetch.failed || 0) + (image.failed || 0) + (deep.failed || 0)
286
+ const lines = []
287
+ lines.push(h('div', { key: 'title', className: 'cbp-usage-title' }, t('usageTitle')))
288
+ lines.push(h('div', { key: 'search', className: 'cbp-usage-line' },
289
+ h('span', null, t('search')),
290
+ h('span', null,
291
+ h('b', null, count(search.calls, t('billed'))),
292
+ ` · ${count(search.sources, t('sources'))}`)))
293
+ if (showImage) {
294
+ lines.push(h('div', { key: 'image', className: 'cbp-usage-line' },
295
+ h('span', null, t('imageUsage')),
296
+ h('span', null,
297
+ h('b', null, count(image.calls, t('times'))),
298
+ ` · ${count(image.images, t('imagesCount'))} · ${count(imageBilled, t('billed'))}`)))
299
+ }
300
+ lines.push(h('div', { key: 'fetch', className: 'cbp-usage-line' },
301
+ h('span', null, t('fetch')),
302
+ h('span', null,
303
+ h('b', null, count(fetch.calls, t('times'))),
304
+ usage.countFetchCost === true ? '' : ` · ${t('fetchFree')}`)))
305
+ lines.push(h('div', { key: 'deep', className: 'cbp-usage-line' },
306
+ h('span', null, t('deepUsage')),
307
+ h('span', null,
308
+ h('b', null, count(deep.calls, t('times'))),
309
+ ` · ${count(deepTools, t('toolCalls'))} · ${tokens(deep.inputTokens)}/${tokens(deep.outputTokens)} tok`)))
310
+ if (failures > 0) {
311
+ lines.push(h('div', { key: 'failed', className: 'cbp-usage-line' },
312
+ h('span', null, t('failed')),
313
+ h('span', null, count(failures, t('times')))))
314
+ }
315
+ lines.push(h('div', { key: 'sep', className: 'cbp-sep' }))
316
+ lines.push(h('div', { key: 'fee-title', className: 'cbp-usage-title' }, t('feeTitle')))
317
+ lines.push(h('div', { key: 'fee-search', className: 'cbp-usage-line' },
318
+ h('span', null, t('searchFee')), h('span', null, yuan(fees.search))))
319
+ if (showImage) {
320
+ lines.push(h('div', { key: 'fee-image-tools', className: 'cbp-usage-line' },
321
+ h('span', null, t('imageToolFee')), h('span', null, yuan(fees.imageTools))))
322
+ lines.push(h('div', { key: 'fee-image-tokens', className: 'cbp-usage-line' },
323
+ h('span', null, t('imageTokenFee')), h('span', null, yuan(fees.imageTokens))))
324
+ }
325
+ lines.push(h('div', { key: 'fee-fetch', className: 'cbp-usage-line' },
326
+ h('span', null, t('fetchFee')), h('span', null, yuan(fees.fetch))))
327
+ lines.push(h('div', { key: 'fee-deep-tools', className: 'cbp-usage-line' },
328
+ h('span', null, t('deepToolFee')), h('span', null, yuan(fees.deepTools))))
329
+ lines.push(h('div', { key: 'fee-deep-tokens', className: 'cbp-usage-line' },
330
+ h('span', null, t('deepTokenFee')), h('span', null, yuan(fees.deepTokens))))
331
+ lines.push(h('div', { key: 'total', className: 'cbp-total' },
332
+ h('span', null, t('totalFee')), h('span', null, `≈ ${yuan(fees.total)}`)))
333
+ lines.push(h('div', { key: 'note', className: 'cbp-note' }, t('feeNote')))
334
+ lines.push(h('a', {
335
+ key: 'link',
336
+ className: 'cbp-link',
337
+ href: typeof usage.usageUrl === 'string' && usage.usageUrl.length > 0 ? usage.usageUrl : USAGE_URL,
338
+ target: '_blank',
339
+ rel: 'noopener noreferrer',
340
+ }, t('usageLink')))
341
+ return h('div', { className: 'cbp-usage' }, ...lines)
342
+ }
343
+
344
+ /** The composer control: split capsule plus the upward panel. */
345
+ function ChatBotControl(props) {
346
+ const t = typeof props.t === 'function' ? props.t : key => key
347
+ const caps = props.useProjection('chatbotCapabilities')
348
+ const usage = props.useProjection('chatbotUsage')
349
+ const web = caps !== undefined && caps !== null && caps.web === true
350
+ const deep = caps !== undefined && caps !== null && caps.deep === true
351
+ const pending = caps !== undefined && caps !== null && caps.pending === true
352
+ const [open, setOpen] = React.useState(false)
353
+ const [busy, setBusy] = React.useState(false)
354
+ const [error, setError] = React.useState(null)
355
+ const [position, setPosition] = React.useState(null)
356
+ const wrapRef = React.useRef(null)
357
+ const panelRef = React.useRef(null)
358
+
359
+ const submit = typeof props.submit === 'function' ? props.submit : async () => undefined
360
+ const sessionId = props.sessionId
361
+ const run = React.useCallback((line) => {
362
+ setBusy(true)
363
+ setError(null)
364
+ Promise.resolve()
365
+ .then(() => submit(sessionId, line))
366
+ .then((result) => {
367
+ if (result === undefined || result === null) { setError(t('errorUnknown')); return }
368
+ if (result.ok !== true) {
369
+ const message = result.error && typeof result.error.message === 'string' ? result.error.message : t('errorUnknown')
370
+ setError(message)
371
+ return
372
+ }
373
+ const value = result.value
374
+ if (value === undefined) { setError(t('errorUnknown')); return }
375
+ if (value !== null && typeof value === 'object' && value.kind === 'error') {
376
+ setError(typeof value.text === 'string' ? value.text : t('errorUnknown'))
377
+ }
378
+ })
379
+ .catch((failure) => { setError(`${t('errorPrefix')}${String(failure)}`) })
380
+ .finally(() => { setBusy(false) })
381
+ }, [submit, sessionId, t])
382
+
383
+ const place = React.useCallback(() => {
384
+ const element = wrapRef.current
385
+ if (element === null || typeof window === 'undefined') return
386
+ const rect = element.getBoundingClientRect()
387
+ const width = Math.min(PANEL_WIDTH, Math.max(160, window.innerWidth - 16))
388
+ const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8))
389
+ const bottom = Math.max(8, window.innerHeight - rect.top + 8)
390
+ setPosition({ left, bottom, width, maxHeight: Math.max(120, window.innerHeight - bottom - 8) })
391
+ }, [])
392
+
393
+ React.useLayoutEffect(() => {
394
+ if (!open) return undefined
395
+ place()
396
+ const onResize = () => { place() }
397
+ window.addEventListener('resize', onResize)
398
+ return () => { window.removeEventListener('resize', onResize) }
399
+ }, [open, place])
400
+
401
+ React.useEffect(() => {
402
+ if (!open || panelRef.current === null) return undefined
403
+ const first = panelRef.current.querySelector('button[role="checkbox"]:not([aria-disabled="true"])')
404
+ if (first !== null && typeof first.focus === 'function') first.focus({ preventScroll: true })
405
+ return undefined
406
+ }, [open])
407
+
408
+ React.useEffect(() => {
409
+ if (!open) return undefined
410
+ const onPointerDown = (event) => {
411
+ if (wrapRef.current !== null && wrapRef.current.contains(event.target)) return
412
+ if (panelRef.current !== null && panelRef.current.contains(event.target)) return
413
+ setOpen(false)
414
+ }
415
+ const onKeyDown = (event) => {
416
+ if (event.key !== 'Escape') return
417
+ setOpen(false)
418
+ const trigger = wrapRef.current === null ? null : wrapRef.current.querySelector('button')
419
+ if (trigger !== null && typeof trigger.focus === 'function') trigger.focus()
420
+ }
421
+ document.addEventListener('pointerdown', onPointerDown, true)
422
+ document.addEventListener('keydown', onKeyDown, true)
423
+ return () => {
424
+ document.removeEventListener('pointerdown', onPointerDown, true)
425
+ document.removeEventListener('keydown', onKeyDown, true)
426
+ }
427
+ }, [open])
428
+
429
+ const disabled = busy || pending
430
+
431
+ const active = web || deep
432
+
433
+ const capsule = h('span', {
434
+ ref: wrapRef,
435
+ className: 'cbp-capsule',
436
+ 'data-on': active ? 'true' : 'false',
437
+ },
438
+ h('button', {
439
+ type: 'button',
440
+ className: 'cbp-main',
441
+ 'data-open': open ? 'true' : 'false',
442
+ 'aria-haspopup': 'dialog',
443
+ 'aria-expanded': open ? 'true' : 'false',
444
+ 'aria-label': `${t('openPanel')} · ${active ? t('active') : t('inactive')}`,
445
+ title: t('openPanel'),
446
+ onClick: () => { setOpen(value => !value) },
447
+ },
448
+ h('span', { className: 'cbp-icon', 'aria-hidden': true }, h(BotGlyph)),
449
+ h('span', { className: 'cbp-label' }, t('label')),
450
+ h('span', { className: 'cbp-arrow', 'aria-hidden': true }, h(ChevronGlyph))))
451
+
452
+ let panel = null
453
+ if (open && typeof document !== 'undefined') {
454
+ panel = createPortal(h('div', {
455
+ ref: panelRef,
456
+ className: 'cbp-panel',
457
+ role: 'dialog',
458
+ 'aria-label': t('panelTitle'),
459
+ style: {
460
+ left: position === null ? 0 : `${position.left}px`,
461
+ bottom: position === null ? 12 : `${position.bottom}px`,
462
+ width: position === null ? 'min(328px, calc(100vw - 16px))' : `${position.width}px`,
463
+ maxHeight: position === null ? 'calc(100vh - 24px)' : `${position.maxHeight}px`,
464
+ },
465
+ },
466
+ h('div', { className: 'cbp-head' },
467
+ h('span', { className: 'cbp-head-title' }, t('panelTitle')),
468
+ h('span', { className: 'cbp-head-scope' }, t('scope'))),
469
+ h('div', { className: 'cbp-group' }, t('groupWeb')),
470
+ h(CapabilityRow, {
471
+ t,
472
+ checked: web,
473
+ label: t('web'),
474
+ hint: t('webHint'),
475
+ disabled,
476
+ onToggle: () => { run(web ? 'web off' : 'web on') },
477
+ }),
478
+ h(CapabilityRow, {
479
+ t,
480
+ checked: deep,
481
+ label: t('deep'),
482
+ hint: web ? t('deepHint') : t('deepNeedsWeb'),
483
+ sub: true,
484
+ disabled: disabled || !web,
485
+ onToggle: () => { run(deep ? 'deep off' : 'deep on') },
486
+ }),
487
+ h('div', { className: 'cbp-note cbp-group-note' }, t('searchNote')),
488
+ h('div', { className: 'cbp-sep' }),
489
+ h(UsageBlock, { t, usage }),
490
+ pending || busy ? h('div', { className: 'cbp-note' }, t('pending')) : null,
491
+ error === null ? null : h('div', { className: 'cbp-error', role: 'alert' }, error)),
492
+ document.body)
493
+ }
494
+
495
+ return h(React.Fragment, null, capsule, panel)
496
+ }
497
+
498
+ return {
499
+ inject: ['slots', 'locale', 'remote', 'remote.commands'],
500
+
501
+ apply(ctx) {
502
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'chatbot-panel: dictionaries')
503
+ ctx.effect(insertStyles, 'chatbot-panel: styles')
504
+ ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
505
+ name: 'conversation.input.left',
506
+ id: 'chatbot-panel',
507
+ order: 0,
508
+ locale: NS,
509
+ inject: () => ({
510
+ submit: (sessionId, line) => ctx.remote.commands.execute(sessionId, `/chatbot ${line}`, []),
511
+ }),
512
+ }, ChatBotControl))
513
+ },
514
+ }
515
+ },
516
+ })
@@ -0,0 +1,24 @@
1
+ # Profile patch for the ChatBot capability panel bundle: one Host row whose
2
+ # package also carries the browser half (`dsh.client` in package.json).
3
+ - insert:
4
+ - id: chatbot-panel
5
+ name: '@chentyit/dsh-chatbot-panel'
6
+ config:
7
+ # New sessions start with every capability off; the panel turns them on.
8
+ manageBuiltinWebTools: true
9
+ # Official web-tool price: 5 CNY per 1,000 successful requests.
10
+ pricePerThousand: 5
11
+ # Anonymous HTTP fetch is free; enable only when the fetch provider is
12
+ # the DeepSeek Fetch API.
13
+ countFetchCost: false
14
+ # Deep and image search share this endpoint, model and credential.
15
+ deepSearch:
16
+ baseURL: https://api.deepseek.com/anthropic/v1
17
+ apiKeyEnv: DEEPSEEK_API_KEY
18
+ model: deepseek-flash
19
+ maxTokens: 4096
20
+ timeoutMs: 180000
21
+ tokenPrice:
22
+ # deepseek-flash off-peak prices; peak hours are double.
23
+ inputPerMillion: 1
24
+ outputPerMillion: 4
package/icon.svg ADDED
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="ChatBot capability panel">
2
+ <defs>
3
+ <linearGradient id="cbp-bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#101319"/>
5
+ <stop offset="1" stop-color="#2a3342"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="2" y="2" width="60" height="60" rx="16" fill="url(#cbp-bg)"/>
9
+ <path d="M14 22.5c0-3 2.4-5.5 5.4-5.5h25.2c3 0 5.4 2.5 5.4 5.5v14c0 3-2.4 5.5-5.4 5.5H29.6l-8.4 7.2c-.8.7-2 .1-2-1V42h-.8c-2.4 0-4.4-2-4.4-4.5z" fill="#f7f8fb"/>
10
+ <circle cx="24" cy="29.5" r="2.6" fill="#101319"/>
11
+ <circle cx="32" cy="29.5" r="2.6" fill="#101319"/>
12
+ <circle cx="40" cy="29.5" r="2.6" fill="#101319"/>
13
+ <circle cx="47" cy="47" r="9" fill="#4b8bf5"/>
14
+ <path d="M43 47h8M47 43v8" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/>
15
+ </svg>