dsh-mini 0.0.0-stage → 0.1.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,622 @@
1
+ /**
2
+ * dsh-mini — browser half.
3
+ *
4
+ * Two buttons and one small window mode:
5
+ *
6
+ * - main window: an "pop out" button in the session header that asks the host to
7
+ * spawn an Edge/Chrome --app window on the same URL plus `dshHud=1`.
8
+ * - HUD window: the page carries `dshHud=1`, so it titles itself
9
+ * "DSH 小窗 · <session>" (the host finds the native window by that prefix),
10
+ * and grows a screenshot key plus pin/close controls.
11
+ *
12
+ * The screenshot key captures the *screen* with the HUD hidden again, then pastes
13
+ * the result into the composer — so "look at what is on my desktop and explain it"
14
+ * is one click inside the small window.
15
+ */
16
+
17
+ window.__ModuleLoader__.load({
18
+ id: 'dsh-mini',
19
+ factory: (require) => {
20
+ var module = { exports: {} }
21
+ var exports = module.exports
22
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
23
+
24
+ let react = require('react')
25
+
26
+ /** Must match PREFIX in lib/index.js and the repo route table. */
27
+ const PREFIX = '/dsh-mini'
28
+ const HUD_FLAG = 'dshHud'
29
+ /** Must start with `titlePrefix` in cordis.patch.yml. */
30
+ const TITLE_PREFIX = 'DSH 小窗'
31
+ const STYLE_ID = 'dsh-mini-style'
32
+
33
+ /** @param {string} key @returns {string} */
34
+ function queryFlag(key) {
35
+ try { return new URLSearchParams(window.location.search).get(key) ?? '' } catch { return '' }
36
+ }
37
+
38
+ /**
39
+ * @param {string} name
40
+ * @returns {string}
41
+ *
42
+ * The GUI logs a fresh profile in by redirecting to a cleaned URL, which drops
43
+ * our query flags; the native shell mirrors them into cookies that survive it.
44
+ */
45
+ function cookieValue(name) {
46
+ try {
47
+ const parts = String(document.cookie ?? '').split(';')
48
+ for (let index = 0; index < parts.length; index += 1) {
49
+ const raw = parts[index].trim()
50
+ const equals = raw.indexOf('=')
51
+ if (equals <= 0) continue
52
+ if (raw.slice(0, equals) === name) {
53
+ const value = raw.slice(equals + 1)
54
+ try { return decodeURIComponent(value) } catch { return value }
55
+ }
56
+ }
57
+ } catch {
58
+ // No cookie access: fall through.
59
+ }
60
+ return ''
61
+ }
62
+
63
+ const isHud = queryFlag(HUD_FLAG) === '1' || cookieValue('dsh_hud') === '1'
64
+
65
+ /**
66
+ * True inside the native WebView2 shell (app/HudWindow.cs). That shell draws
67
+ * its own title bar with the pin / back / shot / close keys, so the page must
68
+ * not add a second set of buttons there — it only renders the compact layout.
69
+ */
70
+ const inNativeShell = (() => {
71
+ try { return Boolean(window.chrome && window.chrome.webview && window.chrome.webview.postMessage) } catch { return false }
72
+ })()
73
+
74
+ // ---------------------------------------------------------------- styles
75
+
76
+ const CSS = [
77
+ '.dsh-hud-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;',
78
+ 'border-radius:8px;border:none;background:transparent;color:inherit;cursor:pointer;padding:0;',
79
+ 'opacity:.68;transition:background .15s ease,opacity .15s ease;flex:0 0 auto}',
80
+ '.dsh-hud-btn:hover{background:rgba(127,127,127,.16);opacity:1}',
81
+ '.dsh-hud-btn:active{transform:scale(.94)}',
82
+ '.dsh-hud-btn[disabled]{opacity:.35;cursor:default}',
83
+ '.dsh-hud-btn svg{width:16px;height:16px;display:block}',
84
+ '.dsh-hud-toast{position:fixed;left:50%;bottom:104px;transform:translateX(-50%);z-index:99999;',
85
+ 'padding:8px 14px;border-radius:10px;font-size:12.5px;line-height:1.45;color:#fff;',
86
+ 'background:rgba(28,28,30,.92);box-shadow:0 10px 28px rgba(0,0,0,.28);max-width:78vw;',
87
+ 'pointer-events:none;animation:dsh-hud-in .16s ease-out}',
88
+ '.dsh-hud-toast[data-kind="error"]{background:rgba(190,48,48,.95)}',
89
+ '@keyframes dsh-hud-in{from{opacity:0;transform:translate(-50%,6px)}to{opacity:1;transform:translate(-50%,0)}}',
90
+ ].join('')
91
+
92
+ function ensureStyles() {
93
+ if (document.getElementById(STYLE_ID) !== null) return
94
+ const style = document.createElement('style')
95
+ style.id = STYLE_ID
96
+ style.textContent = CSS
97
+ document.head.appendChild(style)
98
+ }
99
+
100
+ // ---------------------------------------------------------------- toast
101
+
102
+ let toastTimer = null
103
+
104
+ /** @param {string} message @param {'info'|'error'} [kind] */
105
+ function toast(message, kind = 'info') {
106
+ const node = document.createElement('div')
107
+ node.className = 'dsh-hud-toast'
108
+ node.dataset.kind = kind
109
+ node.textContent = message
110
+ document.body.appendChild(node)
111
+ if (toastTimer !== null) clearTimeout(toastTimer)
112
+ toastTimer = setTimeout(() => { node.remove(); toastTimer = null }, 2600)
113
+ }
114
+
115
+ /**
116
+ * Breadcrumb for diagnosing button wiring. The page has no console channel we
117
+ * can read from outside, so a one-pixel image request lands in the host's
118
+ * `trace.log` instead.
119
+ *
120
+ * @param {string} step
121
+ * @param {unknown} [detail]
122
+ */
123
+ function trace(step, detail) {
124
+ try {
125
+ const image = new window.Image()
126
+ image.src = `${PREFIX}/trace?step=${encodeURIComponent(step)}&detail=${encodeURIComponent(String(detail ?? ''))}&t=${String(Date.now())}`
127
+ } catch {
128
+ // Diagnostics must never break the click path.
129
+ }
130
+ }
131
+
132
+ /** `location.href` only when the page is really served over http(s). */
133
+ function webHref() {
134
+ try {
135
+ const raw = String(window.location.href ?? '')
136
+ return /^https?:/i.test(raw) ? raw : ''
137
+ } catch {
138
+ return ''
139
+ }
140
+ }
141
+
142
+ function safeProtocol() {
143
+ try {
144
+ return String(window.location.protocol ?? '?')
145
+ } catch {
146
+ return '?'
147
+ }
148
+ }
149
+
150
+ // ---------------------------------------------------------------- host api
151
+
152
+ async function postJson(path, body) {
153
+ const response = await fetch(`${PREFIX}${path}`, {
154
+ method: 'POST',
155
+ headers: { 'content-type': 'application/json' },
156
+ body: JSON.stringify(body ?? {}),
157
+ })
158
+ const text = await response.text()
159
+ let data = {}
160
+ try { data = text === '' ? {} : JSON.parse(text) } catch { data = { error: text } }
161
+ if (!response.ok || (data && data.error)) {
162
+ throw new Error(String(data?.error ?? `HTTP ${String(response.status)}`))
163
+ }
164
+ return data
165
+ }
166
+
167
+ // ---------------------------------------------------------------- composer
168
+
169
+ /**
170
+ * Paste an image into the conversation composer.
171
+ *
172
+ * The composer is a Lexical editor that accepts image files through its paste
173
+ * command; a synthetic ClipboardEvent with a DataTransfer carrying the File is
174
+ * enough, even though `isTrusted` is false.
175
+ *
176
+ * @param {string} dataUrl a `data:image/png;base64,...` string
177
+ * @returns {boolean} whether a composer was found
178
+ */
179
+ function insertIntoComposer(dataUrl) {
180
+ const editors = Array.from(document.querySelectorAll('[data-lexical-editor="true"]'))
181
+ const editor =
182
+ editors.find((node) => {
183
+ const rect = node.getBoundingClientRect()
184
+ return rect.width > 0 && rect.height > 0
185
+ }) ?? editors[0]
186
+ if (editor === undefined) return false
187
+
188
+ const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1)
189
+ const binary = atob(base64)
190
+ const bytes = new Uint8Array(binary.length)
191
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
192
+ const file = new window.File([bytes], `screenshot-${String(Date.now())}.png`, { type: 'image/png' })
193
+
194
+ const transfer = new window.DataTransfer()
195
+ transfer.items.add(file)
196
+
197
+ editor.focus()
198
+ let event
199
+ try {
200
+ event = new window.ClipboardEvent('paste', {
201
+ dataTransfer: transfer,
202
+ clipboardData: transfer,
203
+ bubbles: true,
204
+ cancelable: true,
205
+ composed: true,
206
+ })
207
+ } catch {
208
+ event = new window.ClipboardEvent('paste', { bubbles: true, cancelable: true, composed: true })
209
+ }
210
+ if (event.clipboardData === null || event.clipboardData === undefined) {
211
+ Object.defineProperty(event, 'clipboardData', { value: transfer })
212
+ }
213
+ editor.dispatchEvent(event)
214
+ return true
215
+ }
216
+
217
+ // ---------------------------------------------------------------- icons
218
+
219
+ function icon(children) {
220
+ return react.createElement(
221
+ 'svg',
222
+ {
223
+ viewBox: '0 0 16 16',
224
+ fill: 'none',
225
+ stroke: 'currentColor',
226
+ strokeWidth: 1.4,
227
+ strokeLinecap: 'round',
228
+ strokeLinejoin: 'round',
229
+ 'aria-hidden': 'true',
230
+ },
231
+ children,
232
+ )
233
+ }
234
+
235
+ const POPOUT_ICON = icon([
236
+ react.createElement('path', { key: 'a', d: 'M6.6 3.6H4.9c-.7 0-1.3.6-1.3 1.3v6.2c0 .7.6 1.3 1.3 1.3h6.2c.7 0 1.3-.6 1.3-1.3V9.4' }),
237
+ react.createElement('path', { key: 'b', d: 'M9.4 2.6h4v4' }),
238
+ react.createElement('path', { key: 'c', d: 'M13.1 2.9 7.7 8.3' }),
239
+ ])
240
+
241
+ const CAMERA_ICON = icon([
242
+ react.createElement('path', { key: 'a', d: 'M2.7 5.9h1.9l1-1.7h2.8l1 1.7h1.9c.5 0 .9.4.9.9v4.4c0 .5-.4.9-.9.9H2.7a.9.9 0 0 1-.9-.9V6.8c0-.5.4-.9.9-.9Z' }),
243
+ react.createElement('circle', { key: 'b', cx: 8, cy: 9.3, r: 1.9 }),
244
+ ])
245
+
246
+ const PIN_ICON = icon([
247
+ react.createElement('path', { key: 'a', d: 'M6.1 2.6h3.8l-.6 3.1 2 2H4.7l2-2-.6-3.1Z' }),
248
+ react.createElement('path', { key: 'b', d: 'M8 7.9v5.5' }),
249
+ ])
250
+
251
+ const CLOSE_ICON = icon([
252
+ react.createElement('path', { key: 'a', d: 'M4.2 4.2 11.8 11.8' }),
253
+ react.createElement('path', { key: 'b', d: 'M11.8 4.2 4.2 11.8' }),
254
+ ])
255
+
256
+ // ---------------------------------------------------------------- buttons
257
+
258
+ /**
259
+ * Header button in the main window: pop the current conversation out.
260
+ *
261
+ * The slot is session scoped, so `props.sessionId` names the conversation the
262
+ * header belongs to; the HUD window is a fresh document and can only learn it
263
+ * from this hand-off.
264
+ */
265
+ function PopoutButton(props) {
266
+ const [busy, setBusy] = react.useState(false)
267
+
268
+ async function onClick() {
269
+ trace('click', webHref() === '' ? `no-http:${safeProtocol()}` : 'http')
270
+ if (busy) return
271
+ setBusy(true)
272
+ try {
273
+ const sessionId = props && typeof props.sessionId === 'string' ? props.sessionId : ''
274
+ // The desktop renderer runs on a custom scheme, so `location.href` is not
275
+ // something the host can open. Send it only when it really is http(s);
276
+ // otherwise the host supplies the advertised address itself.
277
+ const data = await postJson('/open', { href: webHref(), sessionId })
278
+ trace('opened', `${String(data && data.pid)}|${sessionId}`)
279
+ toast(data.reused === true ? '小窗已在运行,已切到这个会话' : '已弹出小窗(置顶)')
280
+ } catch (error) {
281
+ trace('open-failed', String(error?.message ?? error))
282
+ toast(`弹出失败:${String(error?.message ?? error)}`, 'error')
283
+ } finally {
284
+ setBusy(false)
285
+ }
286
+ }
287
+
288
+ if (isHud) return null
289
+ return react.createElement(
290
+ 'button',
291
+ {
292
+ type: 'button',
293
+ className: 'dsh-hud-btn',
294
+ title: '弹出此窗口',
295
+ 'aria-label': '弹出此窗口',
296
+ disabled: busy,
297
+ onClick,
298
+ },
299
+ POPOUT_ICON,
300
+ )
301
+ }
302
+
303
+ /** HUD header button: keep the popout on top / close it. */
304
+ function PinButton() {
305
+ const [busy, setBusy] = react.useState(false)
306
+
307
+ async function onClick() {
308
+ if (busy) return
309
+ setBusy(true)
310
+ try {
311
+ await postJson('/focus', {})
312
+ toast('已重新置顶')
313
+ } catch (error) {
314
+ toast(`置顶失败:${String(error?.message ?? error)}`, 'error')
315
+ } finally {
316
+ setBusy(false)
317
+ }
318
+ }
319
+
320
+ if (!isHud) return null
321
+ return react.createElement(
322
+ 'button',
323
+ { type: 'button', className: 'dsh-hud-btn', title: '重新置顶', 'aria-label': '重新置顶', disabled: busy, onClick },
324
+ PIN_ICON,
325
+ )
326
+ }
327
+
328
+ function CloseButton() {
329
+ const [busy, setBusy] = react.useState(false)
330
+
331
+ async function onClick() {
332
+ if (busy) return
333
+ setBusy(true)
334
+ try {
335
+ await postJson('/close', {})
336
+ } catch {
337
+ // The window may already be gone; closing locally is still correct.
338
+ }
339
+ window.close()
340
+ }
341
+
342
+ if (!isHud) return null
343
+ return react.createElement(
344
+ 'button',
345
+ { type: 'button', className: 'dsh-hud-btn', title: '关闭小窗', 'aria-label': '关闭小窗', disabled: busy, onClick },
346
+ CLOSE_ICON,
347
+ )
348
+ }
349
+
350
+ /** HUD composer key: screenshot the desktop (HUD excluded) and paste it in. */
351
+ function ShotButton() {
352
+ const [busy, setBusy] = react.useState(false)
353
+
354
+ async function onClick() {
355
+ if (busy) return
356
+ setBusy(true)
357
+ try {
358
+ const data = await postJson('/shot', {})
359
+ if (typeof data.png !== 'string' || data.png === '') throw new Error('没有拿到图片')
360
+ if (!insertIntoComposer(data.png)) throw new Error('没找到输入框')
361
+ toast('截图已插入输入框')
362
+ } catch (error) {
363
+ toast(`截图失败:${String(error?.message ?? error)}`, 'error')
364
+ } finally {
365
+ setBusy(false)
366
+ }
367
+ }
368
+
369
+ if (!isHud) return null
370
+ return react.createElement(
371
+ 'button',
372
+ { type: 'button', className: 'dsh-hud-btn', title: '截图(不含本窗口)', 'aria-label': '截图', disabled: busy, onClick },
373
+ CAMERA_ICON,
374
+ )
375
+ }
376
+
377
+ // ---------------------------------------------------------------- hud title
378
+
379
+ /**
380
+ * Keep the window title starting with TITLE_PREFIX while still showing the
381
+ * session name, which is what the host's window lookup keys on.
382
+ */
383
+ function startTitleKeeper() {
384
+ if (!isHud) return
385
+ let sessionTitle = ''
386
+ const compose = () => (sessionTitle === '' ? TITLE_PREFIX : `${TITLE_PREFIX} · ${sessionTitle}`)
387
+ const apply = () => {
388
+ const current = document.title ?? ''
389
+ if (current.startsWith(TITLE_PREFIX)) {
390
+ // Already ours: harvest the session part if it was updated.
391
+ const tail = current.slice(TITLE_PREFIX.length).replace(/^\s*·\s*/, '')
392
+ if (tail !== '' && tail !== sessionTitle) sessionTitle = tail
393
+ return
394
+ }
395
+ if (current !== '') sessionTitle = current
396
+ document.title = compose()
397
+ }
398
+ apply()
399
+ const timer = setInterval(apply, 900)
400
+ window.addEventListener('pagehide', () => { clearInterval(timer) }, { once: true })
401
+ }
402
+
403
+ // ---------------------------------------------------------------- hand-off
404
+
405
+ /**
406
+ * In the small window, open the conversation the main window handed over.
407
+ *
408
+ * The HUD is a separate document with no shared client state, so the popout
409
+ * URL carries `session=<id>` and this selects that conversation as soon as the
410
+ * workspace service is reachable.
411
+ */
412
+ function followHandedSession(ctx) {
413
+ if (!isHud) return
414
+ const sessionId = queryFlag('session') || cookieValue('dsh_hud_session')
415
+ if (sessionId === '') return
416
+ let tries = 0
417
+ const step = () => {
418
+ tries += 1
419
+ try {
420
+ const workspace = typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : undefined
421
+ if (workspace !== undefined && workspace !== null && typeof workspace.openSession === 'function') {
422
+ workspace.openSession(sessionId)
423
+ return
424
+ }
425
+ } catch {
426
+ // Service not created yet: keep waiting.
427
+ }
428
+ if (tries < 40) setTimeout(step, 250)
429
+ }
430
+ setTimeout(step, 60)
431
+ }
432
+
433
+ // ---------------------------------------------------------------- settings page
434
+
435
+ const rowStyle = { display: 'flex', alignItems: 'center', gap: '12px', padding: '10px 0' }
436
+ const labelStyle = { minWidth: '132px', fontSize: '13px', opacity: 0.9 }
437
+ const inputStyle = {
438
+ width: '132px',
439
+ padding: '5px 8px',
440
+ fontSize: '13px',
441
+ color: 'inherit',
442
+ background: 'transparent',
443
+ border: '1px solid currentColor',
444
+ borderRadius: '6px',
445
+ opacity: 0.9,
446
+ }
447
+ const hintStyle = { fontSize: '12px', opacity: 0.6, margin: '4px 0 0' }
448
+
449
+ /** Settings page: screenshot behaviour + the two global hotkeys. */
450
+ function SettingsSection() {
451
+ const [config, setConfig] = react.useState(null)
452
+ const [status, setStatus] = react.useState('')
453
+
454
+ react.useEffect(() => {
455
+ let cancelled = false
456
+ const load = async () => {
457
+ try {
458
+ const response = await fetch(`${PREFIX}/config`, { headers: { accept: 'application/json' } })
459
+ const data = await response.json()
460
+ if (!cancelled) setConfig(data.config ?? {})
461
+ } catch (error) {
462
+ if (!cancelled) setStatus(`读取失败:${String(error?.message ?? error)}`)
463
+ }
464
+ }
465
+ load()
466
+ return () => { cancelled = true }
467
+ }, [])
468
+
469
+ const save = async (patch) => {
470
+ setConfig({ ...(config ?? {}), ...patch })
471
+ try {
472
+ const response = await fetch(`${PREFIX}/config`, {
473
+ method: 'POST',
474
+ headers: { 'content-type': 'application/json' },
475
+ body: JSON.stringify(patch),
476
+ })
477
+ const data = await response.json()
478
+ if (data.error) throw new Error(String(data.error))
479
+ setStatus('已保存')
480
+ setTimeout(() => setStatus(''), 1600)
481
+ } catch (error) {
482
+ setStatus(`保存失败:${String(error?.message ?? error)}`)
483
+ }
484
+ }
485
+
486
+ if (config === null) {
487
+ return react.createElement('div', { style: { padding: '8px 0', fontSize: '13px', opacity: 0.7 } },
488
+ status === '' ? '读取设置…' : status)
489
+ }
490
+
491
+ const checkbox = (key, text, hint) =>
492
+ react.createElement('div', { key, style: rowStyle },
493
+ react.createElement('label', { style: { ...labelStyle, display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' } },
494
+ react.createElement('input', {
495
+ type: 'checkbox',
496
+ checked: Boolean(config[key]),
497
+ onChange: (event) => { save({ [key]: event.target.checked }) },
498
+ }),
499
+ text,
500
+ ),
501
+ hint === undefined
502
+ ? null
503
+ : react.createElement('span', { style: { fontSize: '12px', opacity: 0.6 } }, hint),
504
+ )
505
+
506
+ const hotkey = (key, text) =>
507
+ react.createElement('div', { key, style: rowStyle },
508
+ react.createElement('span', { style: labelStyle }, text),
509
+ react.createElement('input', {
510
+ style: inputStyle,
511
+ value: String(config[key] ?? ''),
512
+ onChange: (event) => setConfig({ ...config, [key]: event.target.value }),
513
+ onBlur: (event) => { save({ [key]: event.target.value }) },
514
+ onKeyDown: (event) => { if (event.key === 'Enter') event.currentTarget.blur() },
515
+ }),
516
+ )
517
+
518
+ return react.createElement('div', null,
519
+ react.createElement('h3', { style: { fontSize: '14px', margin: '0 0 4px' } }, '小窗与截图'),
520
+ react.createElement('p', { style: hintStyle }, '设置对原生小窗和后台划词服务同时生效,保存后约 3 秒内应用。'),
521
+ checkbox('copyShot', '截图自动复制到剪贴板', '全屏、框选两种截图都会复制'),
522
+ hotkey('hotkeyFull', '全屏截图快捷键'),
523
+ hotkey('hotkeyRegion', '框选截图快捷键'),
524
+ react.createElement('p', { style: hintStyle }, '写法:Ctrl+Shift+1、Ctrl+Shift+S、Alt+PrintScreen、F8 等。组合里必须带 Ctrl/Alt/Shift/Win 之一。'),
525
+ status === ''
526
+ ? null
527
+ : react.createElement('p', { style: { fontSize: '12px', margin: '6px 0 0', opacity: 0.8 } }, status),
528
+ )
529
+ }
530
+
531
+ // ---------------------------------------------------------------- apply
532
+
533
+ exports.name = 'dsh-mini'
534
+ exports.inject = ['slots']
535
+
536
+ exports.apply = (ctx) => {
537
+ trace('apply', `${isHud ? 'hud' : 'main'}|${inNativeShell ? 'native' : 'web'}`)
538
+ ensureStyles()
539
+ startTitleKeeper()
540
+ followHandedSession(ctx)
541
+
542
+ // The settings page is global chrome, so it belongs to the main window.
543
+ if (!isHud) {
544
+ try {
545
+ trace('settings:start')
546
+ ctx.slots.inject('settings.section', () => {
547
+ trace('settings:inject-fired')
548
+ return ctx.slots.register(
549
+ {
550
+ name: 'settings.section',
551
+ id: 'hud-window',
552
+ order: 30,
553
+ label: () => 'DSH 小窗',
554
+ },
555
+ SettingsSection,
556
+ )
557
+ })
558
+ trace('settings:injected')
559
+ } catch (error) {
560
+ trace('settings:failed', error && error.message)
561
+ }
562
+ }
563
+
564
+ // The pop-out key lives in the *main* window only: the HUD is already popped out.
565
+ if (!isHud) {
566
+ ctx.slots.inject('conversation.session.header.utilities', () =>
567
+ ctx.slots.register(
568
+ {
569
+ name: 'conversation.session.header.utilities',
570
+ id: 'hud-popout',
571
+ order: 60,
572
+ label: () => '弹出此窗口',
573
+ },
574
+ PopoutButton,
575
+ ),
576
+ )
577
+ return
578
+ }
579
+
580
+ // Browser fallback HUD (no native title bar) still needs its own keys.
581
+ if (inNativeShell) return
582
+
583
+ ctx.slots.inject('conversation.session.header.utilities', () =>
584
+ ctx.slots.register(
585
+ {
586
+ name: 'conversation.session.header.utilities',
587
+ id: 'hud-pin',
588
+ order: 61,
589
+ label: () => '重新置顶',
590
+ },
591
+ PinButton,
592
+ ),
593
+ )
594
+
595
+ ctx.slots.inject('conversation.session.header.utilities', () =>
596
+ ctx.slots.register(
597
+ {
598
+ name: 'conversation.session.header.utilities',
599
+ id: 'hud-close',
600
+ order: 62,
601
+ label: () => '关闭小窗',
602
+ },
603
+ CloseButton,
604
+ ),
605
+ )
606
+
607
+ ctx.slots.inject('conversation.input.right', () =>
608
+ ctx.slots.register(
609
+ {
610
+ name: 'conversation.input.right',
611
+ id: 'hud-shot',
612
+ order: 20,
613
+ label: () => '截图',
614
+ },
615
+ ShotButton,
616
+ ),
617
+ )
618
+ }
619
+
620
+ return exports
621
+ },
622
+ })