@_daniel_jiang/ai-host 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/src/iframe.ts ADDED
@@ -0,0 +1,751 @@
1
+ import { AGENT_HOST_MSG, type ChildToParentMessage, type ParentToChildMessage, type WidgetPayload, cloneForPostMessage, isAgentHostMessage } from './messages'
2
+ import { getActionAnchorElement, getActionAnchorRect, type PrimaryPageHint } from './store'
3
+
4
+ const HUB_LAUNCHER_ID = 'agent-host-launcher'
5
+ const HUB_PANEL_OVERLAY_ID = 'agent-host-hub-panel-overlay'
6
+ const HUB_IFRAME_ID = 'agent-host-hub-iframe'
7
+ const WIDGET_OVERLAY_ID = 'agent-host-widget-overlay'
8
+ const WIDGET_IFRAME_ID = 'agent-host-widget-iframe'
9
+
10
+ const HOST_SDK_STYLE_ID = 'agent-host-ai-host-styles'
11
+
12
+ type IframeSurface = 'hub' | 'widget'
13
+
14
+ /** 保留部署子路径(iframe src),仅去掉末尾 `/` */
15
+ function normalizeBase(base: string): string {
16
+ return base.replace(/\/$/, '')
17
+ }
18
+
19
+ /**
20
+ * postMessage 的 targetOrigin / event.origin 只有「协议+主机+端口」,不含路径。
21
+ * agent_web_origin 常为 `https://host/agent-platform`,必须先取 URL.origin 再比对。
22
+ */
23
+ function toBrowserOrigin(value: string): string {
24
+ try {
25
+ return new URL(value).origin
26
+ } catch {
27
+ return value.replace(/\/$/, '')
28
+ }
29
+ }
30
+
31
+ /** localhost / 127.0.0.1 视为等价,避免 dev 环境 origin 校验失败 */
32
+ function originsMatch(expected: string, actual: string): boolean {
33
+ const a = toBrowserOrigin(expected)
34
+ const b = toBrowserOrigin(actual)
35
+ if (a === b) return true
36
+ try {
37
+ const ua = new URL(a)
38
+ const ub = new URL(b)
39
+ const loopback = new Set(['localhost', '127.0.0.1', '[::1]'])
40
+ return (
41
+ ua.protocol === ub.protocol &&
42
+ ua.port === ub.port &&
43
+ loopback.has(ua.hostname) &&
44
+ loopback.has(ub.hostname)
45
+ )
46
+ } catch {
47
+ return false
48
+ }
49
+ }
50
+
51
+ function ensureHostSdkStyles() {
52
+ if (typeof document === 'undefined' || document.getElementById(HOST_SDK_STYLE_ID)) return
53
+ const style = document.createElement('style')
54
+ style.id = HOST_SDK_STYLE_ID
55
+ style.textContent = `
56
+ #${HUB_LAUNCHER_ID} {
57
+ position: fixed;
58
+ right: 24px;
59
+ bottom: 24px;
60
+ z-index: 2147482999;
61
+ width: 56px;
62
+ height: 56px;
63
+ border: none;
64
+ border-radius: 50%;
65
+ cursor: pointer;
66
+ display: flex;
67
+ align-items: center;
68
+ justify-content: center;
69
+ color: #fff;
70
+ font-size: 22px;
71
+ line-height: 1;
72
+ background: linear-gradient(145deg, #12a892 0%, #0d8f7b 55%, #0a7a6a 100%);
73
+ box-shadow: 0 4px 18px rgba(13, 143, 123, 0.42);
74
+ transition: transform 0.15s ease, opacity 0.2s ease, visibility 0.2s ease;
75
+ }
76
+ #${HUB_LAUNCHER_ID}:hover { transform: scale(1.05); }
77
+ #${HUB_LAUNCHER_ID}.is-hidden {
78
+ opacity: 0;
79
+ visibility: hidden;
80
+ pointer-events: none;
81
+ transform: scale(0.85);
82
+ }
83
+ #${HUB_LAUNCHER_ID}.is-pulse {
84
+ animation: agent-host-fab-pulse 2s ease-in-out infinite;
85
+ }
86
+ #${HUB_LAUNCHER_ID} .agent-host-launcher-icon {
87
+ display: inline-block;
88
+ line-height: 1;
89
+ transform-origin: center center;
90
+ }
91
+ #${HUB_LAUNCHER_ID}.is-breathe .agent-host-launcher-icon {
92
+ animation: agent-host-clover-breathe 1.8s ease-in-out infinite;
93
+ }
94
+ #${HUB_LAUNCHER_ID} .agent-host-launcher-bell {
95
+ display: none;
96
+ width: 22px;
97
+ height: 22px;
98
+ line-height: 0;
99
+ transform-origin: top center;
100
+ }
101
+ #${HUB_LAUNCHER_ID} .agent-host-launcher-bell.is-visible {
102
+ display: inline-block;
103
+ animation: agent-host-bell-shake 0.85s ease-in-out infinite;
104
+ }
105
+ #${HUB_LAUNCHER_ID} .agent-host-launcher-bell svg {
106
+ width: 100%;
107
+ height: 100%;
108
+ display: block;
109
+ }
110
+ #${HUB_LAUNCHER_ID} .agent-host-launcher-badge {
111
+ position: absolute;
112
+ top: -4px;
113
+ right: -4px;
114
+ box-sizing: border-box;
115
+ min-width: 20px;
116
+ height: 20px;
117
+ padding: 0 5px;
118
+ border-radius: 999px;
119
+ font-size: 11px;
120
+ font-weight: 600;
121
+ line-height: 1;
122
+ display: none;
123
+ align-items: center;
124
+ justify-content: center;
125
+ border: 2px solid #fff;
126
+ background: #f59e0b;
127
+ color: #fff;
128
+ }
129
+ #${HUB_LAUNCHER_ID} .agent-host-launcher-badge.is-visible { display: flex; }
130
+ #${HUB_LAUNCHER_ID} .agent-host-launcher-badge.is-waiting { background: #ef4444; }
131
+ #agent-host-launcher-hint {
132
+ position: fixed;
133
+ right: 24px;
134
+ bottom: 88px;
135
+ z-index: 2147482998;
136
+ max-width: min(800px, calc(100vw - 48px));
137
+ padding: 10px 14px;
138
+ border-radius: 10px;
139
+ font-size: 13px;
140
+ line-height: 1.45;
141
+ color: #333;
142
+ background: #fff;
143
+ border: 1px solid rgba(13, 143, 123, 0.22);
144
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
145
+ display: none;
146
+ pointer-events: auto;
147
+ }
148
+ #agent-host-launcher-hint.is-visible { display: block; }
149
+ #agent-host-launcher-hint.is-anchored {
150
+ right: auto;
151
+ bottom: auto;
152
+ }
153
+ #agent-host-launcher-hint .agent-host-hint-inner {
154
+ display: flex;
155
+ align-items: center;
156
+ gap: 10px;
157
+ flex-wrap: wrap;
158
+ }
159
+ #agent-host-launcher-hint .agent-host-hint-icon {
160
+ flex-shrink: 0;
161
+ display: inline-flex;
162
+ align-items: center;
163
+ justify-content: center;
164
+ width: 20px;
165
+ height: 20px;
166
+ font-size: 16px;
167
+ line-height: 1;
168
+ color: #0d8f7b;
169
+ transform-origin: center center;
170
+ animation: agent-host-hint-clover 4.5s ease-in-out infinite;
171
+ }
172
+ #agent-host-launcher-hint .agent-host-hint-text {
173
+ flex: 1;
174
+ min-width: 140px;
175
+ color: #333;
176
+ }
177
+ #agent-host-launcher-hint .agent-host-hint-actions {
178
+ display: flex;
179
+ align-items: center;
180
+ gap: 4px;
181
+ flex-shrink: 0;
182
+ }
183
+ #agent-host-launcher-hint .agent-host-hint-use {
184
+ border: 1px solid #0d8f7b;
185
+ border-radius: 6px;
186
+ padding: 0 12px;
187
+ height: 28px;
188
+ font-size: 12px;
189
+ font-weight: 500;
190
+ color: #0d8f7b;
191
+ background: transparent;
192
+ cursor: pointer;
193
+ }
194
+ #agent-host-launcher-hint .agent-host-hint-use:hover {
195
+ color: #fff;
196
+ background: #0d8f7b;
197
+ }
198
+ #agent-host-launcher-hint .agent-host-hint-dismiss {
199
+ border: none;
200
+ border-radius: 6px;
201
+ padding: 0 8px;
202
+ height: 28px;
203
+ font-size: 12px;
204
+ color: #999;
205
+ background: transparent;
206
+ cursor: pointer;
207
+ }
208
+ #agent-host-launcher-hint .agent-host-hint-dismiss:hover { color: #666; }
209
+ #${HUB_PANEL_OVERLAY_ID} {
210
+ position: fixed;
211
+ inset: 0;
212
+ z-index: 2147483000;
213
+ pointer-events: none;
214
+ }
215
+ #${HUB_PANEL_OVERLAY_ID}.is-open {
216
+ pointer-events: auto;
217
+ }
218
+ #${HUB_PANEL_OVERLAY_ID} .agent-host-hub-backdrop {
219
+ position: absolute;
220
+ inset: 0;
221
+ background: rgba(15, 23, 42, 0.42);
222
+ opacity: 0;
223
+ transition: opacity 0.35s ease;
224
+ }
225
+ #${HUB_PANEL_OVERLAY_ID}.is-open .agent-host-hub-backdrop {
226
+ opacity: 1;
227
+ }
228
+ /* FAB 位圆形 → 居中工作室模态 */
229
+ #${HUB_PANEL_OVERLAY_ID} .agent-host-hub-panel-shell {
230
+ position: absolute;
231
+ right: 24px;
232
+ bottom: 24px;
233
+ width: 56px;
234
+ height: 56px;
235
+ border-radius: 50%;
236
+ background: #fff;
237
+ box-shadow: 0 4px 18px rgba(13, 143, 123, 0.35);
238
+ overflow: hidden;
239
+ opacity: 0;
240
+ transform: scale(0.92);
241
+ transition:
242
+ width 0.42s cubic-bezier(0.22, 1, 0.36, 1),
243
+ height 0.42s cubic-bezier(0.22, 1, 0.36, 1),
244
+ right 0.42s cubic-bezier(0.22, 1, 0.36, 1),
245
+ bottom 0.42s cubic-bezier(0.22, 1, 0.36, 1),
246
+ border-radius 0.42s cubic-bezier(0.22, 1, 0.36, 1),
247
+ box-shadow 0.42s ease,
248
+ opacity 0.2s ease,
249
+ transform 0.42s cubic-bezier(0.22, 1, 0.36, 1);
250
+ }
251
+ #${HUB_PANEL_OVERLAY_ID}.is-open .agent-host-hub-panel-shell {
252
+ right: max(24px, calc((100vw - min(1120px, calc(100vw - 48px))) / 2));
253
+ bottom: max(24px, calc((100vh - min(780px, calc(100vh - 48px))) / 2));
254
+ width: min(1120px, calc(100vw - 48px));
255
+ height: min(780px, calc(100vh - 48px));
256
+ border-radius: 16px;
257
+ box-shadow: 0 24px 64px rgba(15, 23, 42, 0.22);
258
+ opacity: 1;
259
+ transform: scale(1);
260
+ }
261
+ @media (max-width: 720px) {
262
+ #${HUB_PANEL_OVERLAY_ID}.is-open .agent-host-hub-panel-shell {
263
+ right: 12px;
264
+ bottom: 12px;
265
+ width: calc(100vw - 24px);
266
+ height: calc(100vh - 24px);
267
+ border-radius: 12px;
268
+ }
269
+ }
270
+ #${HUB_PANEL_OVERLAY_ID} iframe {
271
+ width: 100%;
272
+ height: 100%;
273
+ border: none;
274
+ background: #fff;
275
+ display: block;
276
+ opacity: 0;
277
+ transition: opacity 0.2s ease 0.18s;
278
+ }
279
+ #${HUB_PANEL_OVERLAY_ID}.is-open iframe {
280
+ opacity: 1;
281
+ }
282
+ @keyframes agent-host-fab-pulse {
283
+ 0%, 100% { box-shadow: 0 4px 18px rgba(13, 143, 123, 0.42); }
284
+ 50% { box-shadow: 0 4px 26px rgba(13, 143, 123, 0.62); }
285
+ }
286
+ @keyframes agent-host-clover-breathe {
287
+ 0%, 100% { transform: scale(1); }
288
+ 50% { transform: scale(1.28); }
289
+ }
290
+ @keyframes agent-host-hint-clover {
291
+ 0% { transform: scale(0.88) rotate(0deg); }
292
+ 16.67% { transform: scale(1.12) rotate(0deg); }
293
+ 33.33% { transform: scale(0.88) rotate(0deg); }
294
+ 50% { transform: scale(1.12) rotate(0deg); }
295
+ 66.67% { transform: scale(1) rotate(0deg); }
296
+ 100% { transform: scale(1) rotate(360deg); }
297
+ }
298
+ @keyframes agent-host-bell-shake {
299
+ 0%, 100% { transform: rotate(0deg); }
300
+ 12% { transform: rotate(14deg); }
301
+ 24% { transform: rotate(-12deg); }
302
+ 36% { transform: rotate(10deg); }
303
+ 48% { transform: rotate(-8deg); }
304
+ 60% { transform: rotate(5deg); }
305
+ 72% { transform: rotate(-3deg); }
306
+ 84% { transform: rotate(1deg); }
307
+ }
308
+ `
309
+ document.head.appendChild(style)
310
+ }
311
+
312
+ export class IframeBridge {
313
+ private agentOrigin = ''
314
+ private hubLauncher: HTMLButtonElement | null = null
315
+ private hubLauncherBadge: HTMLSpanElement | null = null
316
+ private hubLauncherHint: HTMLDivElement | null = null
317
+ private hubPanelOverlay: HTMLDivElement | null = null
318
+ private hubIframe: HTMLIFrameElement | null = null
319
+ private widgetIframe: HTMLIFrameElement | null = null
320
+ private hubReady = false
321
+ private widgetReady = false
322
+ private hubPanelOpen = false
323
+ private lastRunning = 0
324
+ private lastWaiting = 0
325
+ private launcherHintAction: PrimaryPageHint | null = null
326
+ private hintRepositionBound = false
327
+ private hintScrollParents: EventTarget[] = []
328
+ private pendingHubMessages: ParentToChildMessage[] = []
329
+ private pendingWidgetMessages: ParentToChildMessage[] = []
330
+ private onChildMessage?: (msg: ChildToParentMessage) => void
331
+ private onHintUse?: (pageId: string, actionId: string) => void
332
+ private onHintDismiss?: () => void
333
+
334
+ constructor(
335
+ agentOrigin: string,
336
+ onChildMessage: (msg: ChildToParentMessage) => void,
337
+ handlers?: {
338
+ onHintUse?: (pageId: string, actionId: string) => void
339
+ onHintDismiss?: () => void
340
+ },
341
+ ) {
342
+ this.agentOrigin = normalizeBase(agentOrigin)
343
+ this.onChildMessage = onChildMessage
344
+ this.onHintUse = handlers?.onHintUse
345
+ this.onHintDismiss = handlers?.onHintDismiss
346
+ window.addEventListener('message', this.handleMessage)
347
+ }
348
+
349
+ destroy() {
350
+ window.removeEventListener('message', this.handleMessage)
351
+ this.unbindHintReposition()
352
+ this.removeHub()
353
+ this.closeWidget()
354
+ }
355
+
356
+ /** Launcher(宿主 DOM)+ 居中工作室模态(显隐用 CSS,不改 iframe 尺寸) */
357
+ mountHub() {
358
+ if (this.hubPanelOverlay || typeof document === 'undefined') return
359
+ ensureHostSdkStyles()
360
+ this.mountLauncher()
361
+ this.mountHubPanel()
362
+ }
363
+
364
+ openHubPanel() {
365
+ if (!this.hubPanelOverlay) return
366
+ this.hubPanelOpen = true
367
+ this.hubPanelOverlay.classList.add('is-open')
368
+ this.hubLauncher?.classList.add('is-hidden')
369
+ this.hubLauncherHint?.classList.remove('is-visible')
370
+ this.post('hub', { channel: AGENT_HOST_MSG, type: 'open-hub-panel' })
371
+ this.applyLauncherVisual()
372
+ }
373
+
374
+ closeHubPanel() {
375
+ if (!this.hubPanelOverlay) return
376
+ this.hubPanelOpen = false
377
+ this.hubPanelOverlay.classList.remove('is-open')
378
+ this.hubLauncher?.classList.remove('is-hidden')
379
+ this.post('hub', { channel: AGENT_HOST_MSG, type: 'close-hub-panel' })
380
+ this.updateLauncherHint(this.launcherHintAction)
381
+ this.applyLauncherVisual()
382
+ }
383
+
384
+ /** @deprecated 工作室模态固定宽度,忽略半屏展开 */
385
+ setHubPanelExpanded(_expanded: boolean) {
386
+ /* no-op: studio modal has fixed size */
387
+ }
388
+
389
+ updateLauncherHint(action: PrimaryPageHint | null) {
390
+ this.launcherHintAction = action
391
+ const el = this.hubLauncherHint
392
+ if (!el) return
393
+ if (this.hubPanelOpen) {
394
+ el.classList.remove('is-visible', 'is-anchored')
395
+ this.unbindHintReposition()
396
+ return
397
+ }
398
+ if (!action || !action.hint.trim()) {
399
+ el.innerHTML = ''
400
+ el.classList.remove('is-visible', 'is-anchored')
401
+ this.unbindHintReposition()
402
+ return
403
+ }
404
+ el.innerHTML = `
405
+ <div class="agent-host-hint-inner">
406
+ <span class="agent-host-hint-icon" aria-hidden="true">✦</span>
407
+ <span class="agent-host-hint-text"></span>
408
+ <div class="agent-host-hint-actions">
409
+ <button type="button" class="agent-host-hint-use">立即开始</button>
410
+ <button type="button" class="agent-host-hint-dismiss">稍后</button>
411
+ </div>
412
+ </div>`
413
+ const textEl = el.querySelector('.agent-host-hint-text')
414
+ if (textEl) textEl.textContent = action.hint
415
+ const useBtn = el.querySelector('.agent-host-hint-use')
416
+ const dismissBtn = el.querySelector('.agent-host-hint-dismiss')
417
+ const preventFocusSteal = (e: Event) => e.preventDefault()
418
+ useBtn?.addEventListener('mousedown', preventFocusSteal)
419
+ dismissBtn?.addEventListener('mousedown', preventFocusSteal)
420
+ useBtn?.addEventListener('click', (e) => {
421
+ e.stopPropagation()
422
+ this.onHintUse?.(action.pageId, action.actionId)
423
+ })
424
+ dismissBtn?.addEventListener('click', (e) => {
425
+ e.stopPropagation()
426
+ this.onHintDismiss?.()
427
+ })
428
+ el.classList.add('is-visible')
429
+ this.layoutHint(el, action)
430
+ this.bindHintReposition()
431
+ }
432
+
433
+ private layoutHint(el: HTMLDivElement, action: PrimaryPageHint) {
434
+ if (action.placement !== 'anchor') {
435
+ el.classList.remove('is-anchored')
436
+ el.style.top = ''
437
+ el.style.left = ''
438
+ return
439
+ }
440
+ const rect = getActionAnchorRect(action.pageId, action.actionId)
441
+ if (!rect) {
442
+ el.classList.remove('is-anchored')
443
+ el.style.top = ''
444
+ el.style.left = ''
445
+ return
446
+ }
447
+ el.classList.add('is-anchored')
448
+ const gap = 8
449
+ const hintW = el.offsetWidth || 320
450
+ const hintH = el.offsetHeight || 48
451
+ let top = rect.top
452
+ let left = rect.right + gap
453
+ if (left + hintW > window.innerWidth - 16) {
454
+ left = Math.max(16, rect.left)
455
+ top = rect.bottom + gap
456
+ }
457
+ if (top + hintH > window.innerHeight - 16) {
458
+ top = Math.max(16, rect.top - hintH - gap)
459
+ }
460
+ if (top < 16) top = 16
461
+ if (left < 16) left = 16
462
+ el.style.top = `${Math.round(top)}px`
463
+ el.style.left = `${Math.round(left)}px`
464
+ }
465
+
466
+ private onHintReposition = () => {
467
+ const action = this.launcherHintAction
468
+ const el = this.hubLauncherHint
469
+ if (!action || !el || !el.classList.contains('is-visible')) return
470
+ this.layoutHint(el, action)
471
+ }
472
+
473
+ private collectScrollParents(node: HTMLElement | null): HTMLElement[] {
474
+ const out: HTMLElement[] = []
475
+ let p = node?.parentElement ?? null
476
+ while (p) {
477
+ const style = window.getComputedStyle(p)
478
+ const oy = style.overflowY
479
+ const ox = style.overflowX
480
+ if (/(auto|scroll|overlay)/.test(`${ox} ${oy}`)) out.push(p)
481
+ p = p.parentElement
482
+ }
483
+ return out
484
+ }
485
+
486
+ private bindHintReposition() {
487
+ if (typeof window === 'undefined') return
488
+ if (!this.hintRepositionBound) {
489
+ window.addEventListener('scroll', this.onHintReposition, true)
490
+ window.addEventListener('resize', this.onHintReposition)
491
+ window.visualViewport?.addEventListener('resize', this.onHintReposition)
492
+ window.visualViewport?.addEventListener('scroll', this.onHintReposition)
493
+ this.hintRepositionBound = true
494
+ }
495
+ const action = this.launcherHintAction
496
+ for (const node of this.hintScrollParents) {
497
+ node.removeEventListener('scroll', this.onHintReposition)
498
+ }
499
+ this.hintScrollParents = []
500
+ if (!action || action.placement !== 'anchor') return
501
+ const anchor = getActionAnchorElement(action.pageId, action.actionId)
502
+ this.hintScrollParents = this.collectScrollParents(anchor)
503
+ for (const node of this.hintScrollParents) {
504
+ node.addEventListener('scroll', this.onHintReposition, { passive: true })
505
+ }
506
+ }
507
+
508
+ private unbindHintReposition() {
509
+ if (typeof window === 'undefined') return
510
+ if (this.hintRepositionBound) {
511
+ window.removeEventListener('scroll', this.onHintReposition, true)
512
+ window.removeEventListener('resize', this.onHintReposition)
513
+ window.visualViewport?.removeEventListener('resize', this.onHintReposition)
514
+ window.visualViewport?.removeEventListener('scroll', this.onHintReposition)
515
+ this.hintRepositionBound = false
516
+ }
517
+ for (const node of this.hintScrollParents) {
518
+ node.removeEventListener('scroll', this.onHintReposition)
519
+ }
520
+ this.hintScrollParents = []
521
+ }
522
+
523
+ private mountLauncher() {
524
+ if (this.hubLauncher || document.getElementById(HUB_LAUNCHER_ID)) return
525
+ const btn = document.createElement('button')
526
+ btn.id = HUB_LAUNCHER_ID
527
+ btn.type = 'button'
528
+ btn.title = '景行 AI'
529
+ btn.setAttribute('aria-label', '景行 AI')
530
+ btn.innerHTML =
531
+ '<span class="agent-host-launcher-icon" aria-hidden="true">✦</span>' +
532
+ '<span class="agent-host-launcher-bell" aria-hidden="true">' +
533
+ '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">' +
534
+ '<path d="M12 3a5 5 0 0 0-5 5v1.5c0 1.3-.4 2.6-1.2 3.7L4.5 15.5c-.4.5-.1 1.3.5 1.3h14c.6 0 .9-.8.5-1.3l-1.3-2.3A6.6 6.6 0 0 1 17 9.5V8a5 5 0 0 0-5-5Z" fill="#f59e0b"/>' +
535
+ '<path d="M10 18.5a2 2 0 0 0 4 0" stroke="#f59e0b" stroke-width="1.8" stroke-linecap="round"/>' +
536
+ '</svg></span>' +
537
+ '<span class="agent-host-launcher-badge"></span>'
538
+ btn.addEventListener('click', () => this.openHubPanel())
539
+ document.body.appendChild(btn)
540
+ this.hubLauncher = btn
541
+ this.hubLauncherBadge = btn.querySelector('.agent-host-launcher-badge')
542
+
543
+ if (!document.getElementById('agent-host-launcher-hint')) {
544
+ const hint = document.createElement('div')
545
+ hint.id = 'agent-host-launcher-hint'
546
+ document.body.appendChild(hint)
547
+ this.hubLauncherHint = hint
548
+ } else {
549
+ this.hubLauncherHint = document.getElementById('agent-host-launcher-hint') as HTMLDivElement
550
+ }
551
+ }
552
+
553
+ private mountHubPanel() {
554
+ const overlay = document.createElement('div')
555
+ overlay.id = HUB_PANEL_OVERLAY_ID
556
+
557
+ const backdrop = document.createElement('div')
558
+ backdrop.className = 'agent-host-hub-backdrop'
559
+ backdrop.addEventListener('click', () => this.closeHubPanel())
560
+
561
+ const shell = document.createElement('div')
562
+ shell.className = 'agent-host-hub-panel-shell'
563
+
564
+ const iframe = document.createElement('iframe')
565
+ iframe.id = HUB_IFRAME_ID
566
+ iframe.title = '景行 AI'
567
+ iframe.src = `${this.agentOrigin}/host/hub?embed=1&panel=1&layout=studio`
568
+ iframe.allow = 'clipboard-read; clipboard-write'
569
+
570
+ shell.appendChild(iframe)
571
+ overlay.appendChild(backdrop)
572
+ overlay.appendChild(shell)
573
+ document.body.appendChild(overlay)
574
+
575
+ this.hubPanelOverlay = overlay
576
+ this.hubIframe = iframe
577
+ }
578
+
579
+ updateLauncherStats(running: number, waiting: number) {
580
+ this.lastRunning = running
581
+ this.lastWaiting = waiting
582
+ this.applyLauncherVisual()
583
+ }
584
+
585
+ private applyLauncherVisual() {
586
+ const btn = this.hubLauncher
587
+ const badge = this.hubLauncherBadge
588
+ if (!btn || !badge) return
589
+ const icon = btn.querySelector('.agent-host-launcher-icon') as HTMLElement | null
590
+ const bell = btn.querySelector('.agent-host-launcher-bell') as HTMLElement | null
591
+ const running = this.lastRunning
592
+ const waiting = this.lastWaiting
593
+ // 抽屉关闭且有待确认:中间换成橙色抖动铃铛(底色不变)
594
+ const hitlAlert = waiting > 0 && !this.hubPanelOpen
595
+
596
+ btn.classList.toggle('is-pulse', running > 0)
597
+ // 有进行中/待确认任务且显示四叶草时:图标变大呼吸
598
+ btn.classList.toggle('is-breathe', (running > 0 || waiting > 0) && !hitlAlert)
599
+ if (hitlAlert) {
600
+ btn.title = '有待确认事项,点击打开助手'
601
+ btn.setAttribute('aria-label', '有待确认事项,点击打开助手')
602
+ } else {
603
+ btn.title = '景行 AI'
604
+ btn.setAttribute('aria-label', '景行 AI')
605
+ }
606
+ if (icon) icon.style.display = hitlAlert ? 'none' : ''
607
+ if (bell) bell.classList.toggle('is-visible', hitlAlert)
608
+
609
+ if (running > 0) {
610
+ badge.textContent = String(running)
611
+ badge.classList.add('is-visible')
612
+ badge.classList.remove('is-waiting')
613
+ } else if (waiting > 0) {
614
+ badge.textContent = '!'
615
+ badge.classList.add('is-visible', 'is-waiting')
616
+ } else {
617
+ badge.textContent = ''
618
+ badge.classList.remove('is-visible', 'is-waiting')
619
+ }
620
+ }
621
+
622
+ removeHub() {
623
+ document.getElementById(HUB_LAUNCHER_ID)?.remove()
624
+ document.getElementById('agent-host-launcher-hint')?.remove()
625
+ document.getElementById(HUB_PANEL_OVERLAY_ID)?.remove()
626
+ this.hubLauncher = null
627
+ this.hubLauncherBadge = null
628
+ this.hubLauncherHint = null
629
+ this.hubPanelOverlay = null
630
+ this.hubIframe = null
631
+ this.hubReady = false
632
+ this.hubPanelOpen = false
633
+ this.pendingHubMessages = []
634
+ }
635
+
636
+ openWidget(payload: WidgetPayload, token: string, refreshToken?: string) {
637
+ this.closeWidget()
638
+ const overlay = document.createElement('div')
639
+ overlay.id = WIDGET_OVERLAY_ID
640
+ overlay.style.cssText =
641
+ 'position:fixed;inset:0;z-index:2147483646;background:rgba(15,23,42,0.45);display:flex;align-items:center;justify-content:center;padding:16px;'
642
+ overlay.addEventListener('click', (e) => {
643
+ if (e.target === overlay) this.closeWidget()
644
+ })
645
+
646
+ const iframe = document.createElement('iframe')
647
+ iframe.id = WIDGET_IFRAME_ID
648
+ iframe.title = payload.title || payload.workflow
649
+ const q = new URLSearchParams({
650
+ embed: '1',
651
+ workflow: payload.workflow,
652
+ host: payload.host || '',
653
+ title: payload.title || '',
654
+ })
655
+ iframe.src = `${this.agentOrigin}/host/widget?${q.toString()}`
656
+ iframe.style.cssText =
657
+ 'width:min(560px,100%);height:80vh;border:none;border-radius:12px;background:#fff;box-shadow:0 20px 60px rgba(0,0,0,0.25);display:block;'
658
+ overlay.appendChild(iframe)
659
+ document.body.appendChild(overlay)
660
+ this.widgetIframe = iframe
661
+ this.widgetReady = false
662
+
663
+ const initMsg: ParentToChildMessage = {
664
+ channel: AGENT_HOST_MSG,
665
+ type: 'init',
666
+ token,
667
+ refreshToken,
668
+ }
669
+ const safePayload: WidgetPayload = {
670
+ workflow: payload.workflow,
671
+ title: payload.title,
672
+ host: payload.host,
673
+ prefilled: payload.prefilled ? cloneForPostMessage(payload.prefilled) : undefined,
674
+ hostContext: payload.hostContext ? cloneForPostMessage(payload.hostContext) : undefined,
675
+ sessionId: payload.sessionId,
676
+ preferSandboxFiles: payload.preferSandboxFiles,
677
+ }
678
+ const openMsg: ParentToChildMessage = {
679
+ channel: AGENT_HOST_MSG,
680
+ type: 'open-widget',
681
+ payload: safePayload,
682
+ }
683
+ this.pendingWidgetMessages = [initMsg, openMsg]
684
+ // 须等 iframe 内 postMessage('ready') 后再 flush,否则 init/open-widget 会丢失
685
+ }
686
+
687
+ closeWidget() {
688
+ document.getElementById(WIDGET_OVERLAY_ID)?.remove()
689
+ this.widgetIframe = null
690
+ this.widgetReady = false
691
+ this.pendingWidgetMessages = []
692
+ }
693
+
694
+ sendToHub(msg: ParentToChildMessage) {
695
+ this.post('hub', msg)
696
+ }
697
+
698
+ private post(surface: IframeSurface, msg: ParentToChildMessage) {
699
+ const ready = surface === 'hub' ? this.hubReady : this.widgetReady
700
+ const queue = surface === 'hub' ? this.pendingHubMessages : this.pendingWidgetMessages
701
+ if (!ready) {
702
+ queue.push(msg)
703
+ return
704
+ }
705
+ const iframe = surface === 'hub' ? this.hubIframe : this.widgetIframe
706
+ iframe?.contentWindow?.postMessage(msg, toBrowserOrigin(this.agentOrigin))
707
+ }
708
+
709
+ private flush(surface: IframeSurface) {
710
+ const queue = surface === 'hub' ? this.pendingHubMessages : this.pendingWidgetMessages
711
+ const iframe = surface === 'hub' ? this.hubIframe : this.widgetIframe
712
+ if (!iframe?.contentWindow) return
713
+ const targetOrigin = toBrowserOrigin(this.agentOrigin)
714
+ while (queue.length) {
715
+ const msg = queue.shift()
716
+ if (msg) iframe.contentWindow.postMessage(msg, targetOrigin)
717
+ }
718
+ }
719
+
720
+ private handleMessage = (event: MessageEvent) => {
721
+ if (!originsMatch(this.agentOrigin, event.origin)) return
722
+ if (!isAgentHostMessage(event.data)) return
723
+ const msg = event.data as ChildToParentMessage
724
+ if (msg.type === 'ready') {
725
+ if (msg.surface === 'hub') {
726
+ this.hubReady = true
727
+ this.flush('hub')
728
+ } else {
729
+ this.widgetReady = true
730
+ this.flush('widget')
731
+ }
732
+ // 继续交给上层:Hub ready 时需重发 init + 同步 register-page
733
+ this.onChildMessage?.(msg)
734
+ return
735
+ }
736
+ if (msg.type === 'hub-panel-close') {
737
+ this.closeHubPanel()
738
+ return
739
+ }
740
+ if (msg.type === 'hub-panel-width') {
741
+ this.setHubPanelExpanded(Boolean(msg.expanded))
742
+ return
743
+ }
744
+ if (msg.type === 'hub-stats') {
745
+ this.updateLauncherStats(msg.running, msg.waiting)
746
+ this.onChildMessage?.(msg)
747
+ return
748
+ }
749
+ this.onChildMessage?.(msg)
750
+ }
751
+ }