agello 0.4.2 → 0.4.4
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/README.md +36 -3
- package/package.json +1 -1
- package/src/page-audio.ts +159 -0
- package/src/player.ts +51 -4
- package/src/screencast.ts +68 -2
- package/src/server.ts +47 -9
- package/src/tts.ts +289 -0
- package/web/agent-bridge.js +338 -36
- package/web/presentation-media.js +459 -0
package/web/agent-bridge.js
CHANGED
|
@@ -42,7 +42,9 @@
|
|
|
42
42
|
// A raise-hand button next to the bubbles opens a small input so the viewer
|
|
43
43
|
// can ask the agent something mid-presentation (sent as action=message).
|
|
44
44
|
// Opening it tells the agent right away (action=hand-raise) so it can pause;
|
|
45
|
-
// closing it
|
|
45
|
+
// closing it with X (or Esc) before asking sends action=hand-lower so it can go on.
|
|
46
|
+
// The input stays open while the agent answers, for follow-up questions; X
|
|
47
|
+
// (or Esc) sends action=hand-done so the agent resumes the presentation.
|
|
46
48
|
// The viewer's questions show as bubbles too, with the same 10s lifetime.
|
|
47
49
|
// An end button above it stops the presentation for every viewer.
|
|
48
50
|
// Script lines (`agello script` + `present resume`) arrive as agent messages
|
|
@@ -54,6 +56,8 @@
|
|
|
54
56
|
// el.clearHistory() clear saved chat for the current pane
|
|
55
57
|
// el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
|
|
56
58
|
|
|
59
|
+
import { PresentationMedia, DEFAULT_VOICE } from "./presentation-media.js";
|
|
60
|
+
|
|
57
61
|
const SCRIPT_ORIGIN = new URL(import.meta.url).origin;
|
|
58
62
|
|
|
59
63
|
let mdPromise;
|
|
@@ -79,9 +83,31 @@ const SCREEN_REASONS = {
|
|
|
79
83
|
tab_unknown: "에이전트의 herdr 탭을 확인할 수 없음",
|
|
80
84
|
no_active_tab: "브라우저에 활성 탭 없음",
|
|
81
85
|
};
|
|
86
|
+
const MEDIA_ERRORS = {
|
|
87
|
+
tts_key_required: "Typecast API 키를 적용한 뒤 TTS를 켜 주세요.",
|
|
88
|
+
tts_invalid_key: "Typecast API 키가 올바르지 않아요. 키를 확인한 뒤 다시 재생해 주세요.",
|
|
89
|
+
tts_local_key_unavailable: "로컬 키를 찾지 못했어요. ../yt-outlier/.env의 TYPECAST_API_KEY를 확인하거나 키를 직접 입력해 주세요.",
|
|
90
|
+
tts_driver_disconnected: "음성을 재생하던 화면의 연결이 끊겼어요. 이 화면에서 TTS를 켠 뒤 다시 재생해 주세요.",
|
|
91
|
+
tts_prepare_timeout: "음성 생성 시간이 초과됐어요. 연결을 확인하고 다시 재생해 주세요.",
|
|
92
|
+
tts_start_timeout: "음성이 시작되지 않았어요. TTS 켜기로 음성 재생을 허용한 뒤 다시 재생해 주세요.",
|
|
93
|
+
tts_end_timeout: "음성 완료를 확인하지 못했어요. 연결을 확인하고 다시 재생해 주세요.",
|
|
94
|
+
tts_playback_error: "음성을 재생하지 못했어요. TTS 켜기로 음성 재생을 허용한 뒤 다시 재생해 주세요.",
|
|
95
|
+
tts_provider_error: "Typecast 요청이 실패했어요. 키·목소리·사용 한도를 확인하고 다시 재생해 주세요.",
|
|
96
|
+
tts_network_error: "Typecast에 연결하지 못했어요. 인터넷 연결을 확인하고 다시 재생해 주세요.",
|
|
97
|
+
tts_text_limit: "대본 한 줄이 너무 길어요. 2,000자 이하로 나눠 주세요.",
|
|
98
|
+
tts_invalid_audio: "올바른 음성을 받지 못했어요. 목소리 설정을 확인하고 다시 재생해 주세요.",
|
|
99
|
+
tts_audio_not_found: "준비된 음성이 만료됐어요. 대본을 다시 재생해 주세요.",
|
|
100
|
+
tts_failed: "TTS 처리에 실패했어요. 설정을 확인하고 다시 재생하거나 TTS를 꺼 주세요.",
|
|
101
|
+
tts_cancelled: "음성 생성이 취소됐어요. 계속하려면 대본을 다시 재생해 주세요.",
|
|
102
|
+
tts_config_superseded: "TTS 설정이 변경됐어요. 현재 설정을 확인하고 다시 적용해 주세요.",
|
|
103
|
+
tts_stopped: "음성 재생이 종료됐어요. 계속하려면 TTS를 켠 뒤 대본을 재생해 주세요.",
|
|
104
|
+
invalid_voice: "목소리 ID가 올바르지 않아요. Typecast 목소리 ID를 확인해 주세요.",
|
|
105
|
+
invalid_viewer: "발표 화면 연결을 확인하지 못했어요. 페이지를 새로 고친 뒤 다시 시도해 주세요.",
|
|
106
|
+
invalid_tts_config: "TTS 설정이 올바르지 않아요. 키와 목소리 ID를 확인해 주세요.",
|
|
107
|
+
};
|
|
82
108
|
const ACTIONS = {
|
|
83
109
|
message: "메시지", approve: "승인", reject: "거절",
|
|
84
|
-
"present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림", annotate: "주석",
|
|
110
|
+
"present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림", "hand-done": "질문 완료", annotate: "주석",
|
|
85
111
|
};
|
|
86
112
|
|
|
87
113
|
const CSS = `
|
|
@@ -173,6 +199,21 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
173
199
|
.screen-bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 12px; color: var(--_muted);
|
|
174
200
|
border-bottom: 1px solid var(--_line); min-width: 0; }
|
|
175
201
|
.screen-bar .url { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
|
202
|
+
.screen { position: relative; }
|
|
203
|
+
.media-controls { padding: 7px 12px; border-bottom: 1px solid var(--_line); font-size: 12px; background: var(--_panel); z-index: 6; }
|
|
204
|
+
.media-toolbar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
205
|
+
.media-toolbar button { padding: 3px 9px; border-radius: 7px; font-size: 12px; }
|
|
206
|
+
.media-record[aria-pressed="true"] { color: #c92a2a; border-color: #c92a2a; }
|
|
207
|
+
.media-download { color: var(--_accent); }
|
|
208
|
+
.media-panel { max-width: 420px; display: grid; gap: 8px; padding: 12px 0 4px; }
|
|
209
|
+
.media-panel[hidden], .media-download[hidden] { display: none; }
|
|
210
|
+
.media-panel label { display: grid; gap: 3px; }
|
|
211
|
+
.media-panel input { min-width: 0; width: 100%; padding: 6px 8px; font: inherit; color: var(--_text); background: var(--_bg); border: 1px solid var(--_line); border-radius: 6px; }
|
|
212
|
+
.media-panel .media-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
213
|
+
.media-status { margin: 4px 0 0; color: var(--_muted); overflow-wrap: anywhere; }
|
|
214
|
+
.media-status.error { color: #c92a2a; }
|
|
215
|
+
.media-note { color: var(--_muted); }
|
|
216
|
+
.body.present .media-controls { position: absolute; top: 12px; right: 12px; left: 12px; width: fit-content; max-width: calc(100% - 24px); border: 1px solid var(--_line); border-radius: 10px; box-shadow: 0 4px 16px rgb(0 0 0 / .2); }
|
|
176
217
|
.live[hidden] { display: none; }
|
|
177
218
|
.live { flex: none; font-size: 11px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--_line); }
|
|
178
219
|
.live.on { background: var(--_accent); border-color: var(--_accent); color: #fff; }
|
|
@@ -237,6 +278,8 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
237
278
|
/* annotate: hover tint, selected outline, request input next to the element */
|
|
238
279
|
.annot-btn { flex: none; font-size: 12px; padding: 2px 10px; border-radius: 999px; }
|
|
239
280
|
.annot-btn[aria-pressed="true"] { background: var(--_accent); border-color: var(--_accent); color: #fff; }
|
|
281
|
+
.sound-btn { flex: none; font-size: 12px; padding: 2px 10px; border-radius: 999px; }
|
|
282
|
+
.sound-btn[aria-pressed="true"] { background: var(--_accent); border-color: var(--_accent); color: #fff; }
|
|
240
283
|
.viewport.annot img { cursor: crosshair; }
|
|
241
284
|
.hl { position: absolute; z-index: 3; pointer-events: none; display: none; border-radius: 2px; }
|
|
242
285
|
.hl.on { display: block; }
|
|
@@ -276,6 +319,9 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
276
319
|
box-shadow: 0 8px 30px rgba(0, 0, 0, .35); animation: capIn .3s ease; }
|
|
277
320
|
.caption.plain { white-space: pre-wrap; }
|
|
278
321
|
.caption.user { background: color-mix(in srgb, var(--_accent) 88%, transparent); }
|
|
322
|
+
/* system event (hand raised / lowered / done): small, light, not part of the conversation */
|
|
323
|
+
.caption.event { padding: 6px 14px; border-radius: 999px; font-size: 13px; color: #1d1d1f;
|
|
324
|
+
background: rgba(255, 255, 255, .9); box-shadow: 0 4px 16px rgba(0,0,0,.25); }
|
|
279
325
|
.caption.out { opacity: 0; translate: 0 6px; transition: opacity .5s ease, translate .5s ease; }
|
|
280
326
|
.caption > :first-child { margin-top: 0; } .caption > :last-child { margin-bottom: 0; }
|
|
281
327
|
.caption p, .caption ul, .caption ol { margin: .35em 0; }
|
|
@@ -317,6 +363,13 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
317
363
|
.ask textarea::placeholder { color: rgba(255,255,255,.5); }
|
|
318
364
|
.ask .ask-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
|
319
365
|
.ask .ask-hint { font-size: 12px; color: rgba(255,255,255,.6); flex: 1; }
|
|
366
|
+
.ask .ask-row .primary { margin-left: 0; }
|
|
367
|
+
.ask .ask-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 13px; color: rgba(255,255,255,.75); }
|
|
368
|
+
.ask .ask-title { flex: 1; }
|
|
369
|
+
.ask .ask-done { width: 28px; height: 28px; padding: 0; border-radius: 50%; display: grid; place-items: center;
|
|
370
|
+
background: transparent; color: #fff; border-color: rgba(255,255,255,.22); }
|
|
371
|
+
.ask .ask-done:hover { background: rgba(255,255,255,.14); }
|
|
372
|
+
.ask .ask-done svg { width: 16px; height: 16px; }
|
|
320
373
|
@media (prefers-reduced-motion: reduce) { .hand, .ask { transition: none; animation: none; } }
|
|
321
374
|
@media (prefers-reduced-motion: reduce) { .caption { animation: none; } .caption.out { transition: none; } }
|
|
322
375
|
@container (max-width: 820px) {
|
|
@@ -439,15 +492,31 @@ const TEMPLATE = `
|
|
|
439
492
|
</section>
|
|
440
493
|
<aside class="screen" part="screen">
|
|
441
494
|
<div class="screen-bar"><span class="live" hidden>에이전트 조작 중</span><span class="url"></span>
|
|
495
|
+
<button class="sound-btn" type="button" aria-pressed="false" title="켜면 브라우저 탭 소리가 이 화면에서만 나와요 (에이전트 쪽 브라우저는 음소거)">소리</button>
|
|
442
496
|
<button class="annot-btn" type="button" aria-pressed="false" title="화면의 요소를 골라 에이전트에게 요청 (선택자와 함께 전달)">주석</button>
|
|
443
497
|
<button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
|
|
444
498
|
<span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
|
|
499
|
+
<div class="media-controls" aria-label="발표 설정 및 녹화">
|
|
500
|
+
<div class="media-toolbar"><button type="button" class="media-settings" aria-expanded="false">발표 설정</button>
|
|
501
|
+
<button type="button" class="media-play">대본 재생</button><button type="button" class="media-record" aria-pressed="false">녹화 시작</button>
|
|
502
|
+
<span class="media-elapsed"></span><a class="media-download" hidden>영상 저장</a></div>
|
|
503
|
+
<div class="media-panel" hidden role="group" aria-label="TTS 설정">
|
|
504
|
+
<label>Typecast API 키<input class="media-key" type="password" autocomplete="off" placeholder="키는 서버 메모리에만 보관해요"></label>
|
|
505
|
+
<label>목소리 ID<input class="media-voice" type="text" spellcheck="false" value="${DEFAULT_VOICE}"></label>
|
|
506
|
+
<div class="media-actions"><button type="button" class="media-save">키·목소리 적용</button><button type="button" class="media-local">로컬 키 불러오기</button><button type="button" class="media-clear">키 삭제</button></div>
|
|
507
|
+
<button type="button" class="media-tts" aria-pressed="false">TTS 켜기</button>
|
|
508
|
+
<span class="media-note">TTS와 녹화는 각각 선택해요. 발표 화면 이미지와 대본을 녹화하며, TTS 음성은 TTS를 켠 제어 화면의 녹화에만 담겨요. 최대 15분·256MB.</span>
|
|
509
|
+
</div><p class="media-status" role="status" aria-live="polite"></p>
|
|
510
|
+
</div>
|
|
445
511
|
<div class="viewport"><div class="ring"></div><div class="glow"></div><div class="agent-badge">에이전트 조작 중</div><img alt="에이전트 브라우저 화면" draggable="false"><textarea class="kbd" aria-label="브라우저 키보드 입력"></textarea><div class="placeholder">열린 terminal-browser 없음</div><div class="hl hover"></div><div class="hl sel"></div><div class="hl-label"></div>
|
|
446
512
|
<form class="note" part="note" hidden><div class="note-target"></div><textarea aria-label="선택한 요소에 대한 요청" placeholder="이 요소에 대한 요청 (Enter 전송, Esc 취소)"></textarea>
|
|
447
513
|
<div class="note-row"><span class="note-hint"></span><button type="submit" class="primary">보내기</button></div></form><div class="captions" part="captions" aria-live="polite"></div>
|
|
448
514
|
<div class="busy" part="busy" role="status"><span class="spinner"></span><span>답변 준비 중</span></div>
|
|
449
515
|
<div class="hand-wrap">
|
|
450
|
-
<form class="ask" part="ask" hidden
|
|
516
|
+
<form class="ask" part="ask" hidden>
|
|
517
|
+
<div class="ask-head"><span class="ask-title"></span><button type="button" class="ask-done">
|
|
518
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button></div>
|
|
519
|
+
<textarea aria-label="에이전트에게 질문"></textarea>
|
|
451
520
|
<div class="ask-row"><span class="ask-hint"></span><button type="submit" class="primary">보내기</button></div></form>
|
|
452
521
|
<button class="end" part="end" type="button" aria-label="발표 종료" title="발표 종료">
|
|
453
522
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
@@ -464,6 +533,7 @@ class AgentBridge extends HTMLElement {
|
|
|
464
533
|
#root = this.attachShadow({ mode: "open" });
|
|
465
534
|
#es = null;
|
|
466
535
|
#screenEs = null;
|
|
536
|
+
#sound = null; // page audio: { ctx, es, next: Map<stream id, end time> }
|
|
467
537
|
#ws = null; // /input: control input and annotate inspect queries
|
|
468
538
|
#control = false;
|
|
469
539
|
#annot = false;
|
|
@@ -489,8 +559,13 @@ class AgentBridge extends HTMLElement {
|
|
|
489
559
|
#historyKey = null;
|
|
490
560
|
#history = [];
|
|
491
561
|
#present = { on: false };
|
|
562
|
+
#viewerId = crypto.randomUUID();
|
|
563
|
+
#media;
|
|
564
|
+
#mediaBusy = false;
|
|
565
|
+
#mediaConnection = 0;
|
|
492
566
|
static HISTORY_LIMIT = 500;
|
|
493
567
|
static CAPTION_MS = 10000; // presentation bubble lifetime
|
|
568
|
+
static EVENT_MS = 5000; // system event bubble lifetime
|
|
494
569
|
static CAPTION_MAX = 3; // bubbles on screen at once (oldest leaves early)
|
|
495
570
|
|
|
496
571
|
constructor() {
|
|
@@ -505,7 +580,8 @@ class AgentBridge extends HTMLElement {
|
|
|
505
580
|
controlBtn: q(".control-btn"), kbd: q(".kbd"),
|
|
506
581
|
body: q(".body"), screen: q(".screen"), captions: q(".captions"),
|
|
507
582
|
busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
|
|
508
|
-
|
|
583
|
+
askTitle: q(".ask-title"), askDone: q(".ask-done"),
|
|
584
|
+
annotBtn: q(".annot-btn"), soundBtn: q(".sound-btn"), hlHover: q(".hl.hover"), hlSel: q(".hl.sel"), hlLabel: q(".hl-label"),
|
|
509
585
|
note: q(".note"), noteBox: q(".note textarea"), noteHint: q(".note-hint"), noteTarget: q(".note-target"),
|
|
510
586
|
};
|
|
511
587
|
this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
|
|
@@ -519,11 +595,13 @@ class AgentBridge extends HTMLElement {
|
|
|
519
595
|
loadMarkdown().then((md) => (this.#md = md));
|
|
520
596
|
this.#bindControl();
|
|
521
597
|
this.#bindAnnotate();
|
|
598
|
+
this.$.soundBtn.addEventListener("click", () => this.#setSound(!this.#sound));
|
|
522
599
|
this.#bindHand();
|
|
523
600
|
q(".term-btn").addEventListener("click", () => this.#showTerminal(!this.hasAttribute("terminal-open")));
|
|
524
601
|
q(".terminal-retry").addEventListener("click", () => this.#showTerminal(true));
|
|
525
602
|
this.#bindPanes();
|
|
526
603
|
this.#bindImages();
|
|
604
|
+
this.#bindMedia();
|
|
527
605
|
}
|
|
528
606
|
|
|
529
607
|
get server() {
|
|
@@ -536,17 +614,21 @@ class AgentBridge extends HTMLElement {
|
|
|
536
614
|
this.#syncScreen();
|
|
537
615
|
}
|
|
538
616
|
disconnectedCallback() {
|
|
617
|
+
this.#mediaConnection++;
|
|
618
|
+
this.#media.reset(this.server, true);
|
|
619
|
+
this.$.captions.replaceChildren();
|
|
539
620
|
this.#showTerminal(false);
|
|
540
621
|
this.#es?.close();
|
|
541
622
|
this.#es = null;
|
|
542
623
|
this.#screenEs?.close();
|
|
543
624
|
this.#screenEs = null;
|
|
625
|
+
this.#setSound(false);
|
|
544
626
|
this.#setControl(false);
|
|
545
627
|
this.#setAnnotate(false);
|
|
546
628
|
}
|
|
547
629
|
attributeChangedCallback(name) {
|
|
548
630
|
if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "agello";
|
|
549
|
-
if (name === "server" && this.isConnected) { this.#showTerminal(false); this.#openPanes(false); this.#connect(); this.#screenEs?.close(); this.#screenEs = null; this.#syncScreen(); }
|
|
631
|
+
if (name === "server" && this.isConnected) { this.#showTerminal(false); this.#openPanes(false); this.#connect(); this.#screenEs?.close(); this.#screenEs = null; this.#setSound(false); this.#syncScreen(); }
|
|
550
632
|
if (name === "screen" && this.isConnected) this.#syncScreen();
|
|
551
633
|
}
|
|
552
634
|
|
|
@@ -858,7 +940,13 @@ class AgentBridge extends HTMLElement {
|
|
|
858
940
|
|
|
859
941
|
#connect() {
|
|
860
942
|
this.#es?.close();
|
|
861
|
-
|
|
943
|
+
this.#media.reset(this.server);
|
|
944
|
+
this.$.captions.replaceChildren();
|
|
945
|
+
const version = ++this.#mediaConnection;
|
|
946
|
+
const es = (this.#es = new EventSource(`${this.server}/events?viewer=${encodeURIComponent(this.#viewerId)}`));
|
|
947
|
+
this.#media.request("/tts").then((config) => {
|
|
948
|
+
if (version === this.#mediaConnection) this.#media.setConfig(config);
|
|
949
|
+
}).catch((error) => { if (version === this.#mediaConnection) this.#media.report(error); });
|
|
862
950
|
const on = (name, fn) => es.addEventListener(name, (e) => fn(JSON.parse(e.data)));
|
|
863
951
|
on("hello", (d) => {
|
|
864
952
|
if (!d.transcript) this.$.hint.textContent = "대화 기록 파일 없음: 메시지 표시 불가";
|
|
@@ -868,14 +956,25 @@ class AgentBridge extends HTMLElement {
|
|
|
868
956
|
on("unqueued", ({ id }) => { this.#unqueue(id); this.#emit("agent-queue", { phase: "unqueued", id }); });
|
|
869
957
|
on("status", (s) => this.#renderStatus(s));
|
|
870
958
|
on("message", (m) => {
|
|
871
|
-
|
|
872
|
-
this
|
|
959
|
+
// Joining during speech replays its caption snapshot, which may already
|
|
960
|
+
// be in this pane's saved history. Keep the live caption without a duplicate row.
|
|
961
|
+
const replayedNarration = typeof m.narration === "string" && this.#history.some((saved) => saved.narration === m.narration);
|
|
962
|
+
if (!replayedNarration) {
|
|
963
|
+
this.#addMessage(m);
|
|
964
|
+
this.#saveMessage(m);
|
|
965
|
+
}
|
|
873
966
|
// script lines carry their own on-screen time (>= 10s), others use CAPTION_MS
|
|
874
|
-
if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined);
|
|
967
|
+
if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined, typeof m.narration === "string" ? m.narration : null);
|
|
875
968
|
if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
|
|
969
|
+
// a reply to the viewer's question (status polling may miss a short working phase)
|
|
970
|
+
// (the agent took the question once it is echoed in the transcript; earlier work was the hand-raise turn)
|
|
971
|
+
if (this.#qa === "asked" && m.role === "browser" && m.action === "message") this.#qaTaken = true;
|
|
972
|
+
if (this.#qa === "asked" && this.#qaTaken && m.role === "assistant" && !m.script) { this.#qaWorked = true; this.#checkAnswered(); }
|
|
876
973
|
this.#emit("agent-message", m);
|
|
877
974
|
});
|
|
878
975
|
on("present", (p) => this.#setPresent(p));
|
|
976
|
+
on("tts", (config) => this.#media.setConfig(config));
|
|
977
|
+
on("speech", (event) => { void this.#media.handleSpeech(event); });
|
|
879
978
|
on("tool_start", (t) => {
|
|
880
979
|
this.#tools.set(t.id, t);
|
|
881
980
|
this.#renderActivity();
|
|
@@ -892,12 +991,17 @@ class AgentBridge extends HTMLElement {
|
|
|
892
991
|
this.#setToolState(t.id, t.isError ? "error" : "done");
|
|
893
992
|
this.#emit("agent-tool", { phase: "end", ...t });
|
|
894
993
|
});
|
|
895
|
-
es.onerror = () => {
|
|
994
|
+
es.onerror = () => {
|
|
995
|
+
this.#mediaConnection++;
|
|
996
|
+
this.#media.reset(this.server);
|
|
997
|
+
this.#renderStatus(null); this.#setPresent({ on: false });
|
|
998
|
+
this.#media.report("서버 연결이 끊겨 음성과 녹화를 종료했어요. 다시 연결한 뒤 TTS를 켜 주세요.");
|
|
999
|
+
};
|
|
896
1000
|
}
|
|
897
1001
|
|
|
898
1002
|
#syncScreen() {
|
|
899
1003
|
const want = this.hasAttribute("screen") || this.#present.on;
|
|
900
|
-
if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); this.#setAnnotate(false); return; }
|
|
1004
|
+
if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setSound(false); this.#setControl(false); this.#setAnnotate(false); return; }
|
|
901
1005
|
if (this.#screenEs) return;
|
|
902
1006
|
const es = (this.#screenEs = new EventSource(`${this.server}/screen`));
|
|
903
1007
|
es.addEventListener("meta", (e) => this.#renderScreenMeta(JSON.parse(e.data)));
|
|
@@ -911,17 +1015,154 @@ class AgentBridge extends HTMLElement {
|
|
|
911
1015
|
es.onerror = () => this.#renderScreenMeta({ connected: false });
|
|
912
1016
|
}
|
|
913
1017
|
|
|
1018
|
+
// ---------- page audio ----------
|
|
1019
|
+
// On: the relayed tab's audio plays here and is muted in the agent's
|
|
1020
|
+
// browser (while any viewer has it on). The click creates the AudioContext,
|
|
1021
|
+
// which is what the browser's autoplay policy needs.
|
|
1022
|
+
#setSound(on) {
|
|
1023
|
+
if (!on) {
|
|
1024
|
+
if (!this.#sound) return;
|
|
1025
|
+
this.#sound.es.close();
|
|
1026
|
+
this.#sound.ctx.close().catch(() => {});
|
|
1027
|
+
this.#sound = null;
|
|
1028
|
+
} else {
|
|
1029
|
+
if (this.#sound) return;
|
|
1030
|
+
const ctx = new AudioContext();
|
|
1031
|
+
ctx.resume().catch(() => {});
|
|
1032
|
+
const es = new EventSource(`${this.server}/screen/audio`);
|
|
1033
|
+
const s = (this.#sound = { ctx, es, next: new Map() });
|
|
1034
|
+
es.addEventListener("pcm", (e) => this.#playPcm(s, JSON.parse(e.data)));
|
|
1035
|
+
}
|
|
1036
|
+
this.$.soundBtn.setAttribute("aria-pressed", String(!!this.#sound));
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// One chunk: 16-bit little-endian stereo. Chunks of a stream are queued
|
|
1040
|
+
// back to back; a gap or a backlog over 1s restarts with a 150ms buffer.
|
|
1041
|
+
#playPcm(s, { c, r, d }) {
|
|
1042
|
+
if (this.#sound !== s) return;
|
|
1043
|
+
const bin = atob(d);
|
|
1044
|
+
const pcm = new Int16Array(bin.length >> 1);
|
|
1045
|
+
for (let i = 0; i < pcm.length; i++) pcm[i] = (bin.charCodeAt(2 * i) | (bin.charCodeAt(2 * i + 1) << 8)) << 16 >> 16;
|
|
1046
|
+
const n = pcm.length >> 1;
|
|
1047
|
+
if (!n) return;
|
|
1048
|
+
const buf = s.ctx.createBuffer(2, n, r);
|
|
1049
|
+
const L = buf.getChannelData(0), R = buf.getChannelData(1);
|
|
1050
|
+
for (let i = 0; i < n; i++) { L[i] = pcm[2 * i] / 32768; R[i] = pcm[2 * i + 1] / 32768; }
|
|
1051
|
+
const src = s.ctx.createBufferSource();
|
|
1052
|
+
src.buffer = buf;
|
|
1053
|
+
src.connect(s.ctx.destination);
|
|
1054
|
+
const now = s.ctx.currentTime;
|
|
1055
|
+
let at = s.next.get(c) ?? 0;
|
|
1056
|
+
if (at < now + 0.02 || at > now + 1) at = now + 0.15;
|
|
1057
|
+
src.start(at);
|
|
1058
|
+
s.next.set(c, at + buf.duration);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
914
1061
|
// ---------- presentation mode ----------
|
|
915
1062
|
|
|
1063
|
+
#bindMedia() {
|
|
1064
|
+
const q = (selector) => this.#root.querySelector(selector);
|
|
1065
|
+
this.#media = new PresentationMedia({
|
|
1066
|
+
viewerId: this.#viewerId, image: this.$.img,
|
|
1067
|
+
captions: () => [...this.$.captions.children].filter((el) => !el.classList.contains("out")).map((el) => ({ text: el.textContent, kind: el.__captionKind || "agent" })),
|
|
1068
|
+
onChange: () => this.#renderMedia(),
|
|
1069
|
+
onSpeech: ({ id, phase }) => {
|
|
1070
|
+
if (["ended", "cancel", "error"].includes(phase)) {
|
|
1071
|
+
for (const el of this.$.captions.children) if (el.dataset.narration === id) el.__leave?.();
|
|
1072
|
+
}
|
|
1073
|
+
},
|
|
1074
|
+
});
|
|
1075
|
+
this.$.img.addEventListener("load", () => this.#media.flushRecordingFrame());
|
|
1076
|
+
q(".media-settings").addEventListener("click", () => {
|
|
1077
|
+
const panel = q(".media-panel");
|
|
1078
|
+
panel.hidden = !panel.hidden;
|
|
1079
|
+
q(".media-settings").setAttribute("aria-expanded", String(!panel.hidden));
|
|
1080
|
+
if (!panel.hidden) q(".media-key").focus();
|
|
1081
|
+
});
|
|
1082
|
+
const run = async (fn) => {
|
|
1083
|
+
if (this.#mediaBusy) return;
|
|
1084
|
+
this.#mediaBusy = true;
|
|
1085
|
+
this.#renderMedia();
|
|
1086
|
+
try { await fn(); } catch (error) { this.#media.report(error); }
|
|
1087
|
+
finally { this.#mediaBusy = false; this.#renderMedia(); }
|
|
1088
|
+
};
|
|
1089
|
+
q(".media-save").addEventListener("click", () => {
|
|
1090
|
+
const input = q(".media-key");
|
|
1091
|
+
const apiKey = input.value.trim();
|
|
1092
|
+
input.value = "";
|
|
1093
|
+
void run(() => this.#media.configure({ ...(apiKey ? { apiKey } : {}), voiceId: q(".media-voice").value.trim() }));
|
|
1094
|
+
});
|
|
1095
|
+
q(".media-local").addEventListener("click", () => { q(".media-key").value = ""; void run(() => this.#media.configure({ useLocalKey: true, voiceId: q(".media-voice").value.trim() })); });
|
|
1096
|
+
q(".media-clear").addEventListener("click", () => { q(".media-key").value = ""; void run(() => this.#media.configure({ clearKey: true })); });
|
|
1097
|
+
q(".media-tts").addEventListener("click", () => {
|
|
1098
|
+
const enabled = !(this.#media.config.enabled && this.#media.config.driverId === this.#viewerId);
|
|
1099
|
+
// Invoke resume while this click still holds browser user activation.
|
|
1100
|
+
void run(async () => {
|
|
1101
|
+
if (enabled) await this.#media.unlock();
|
|
1102
|
+
await this.#media.configure({ enabled, voiceId: q(".media-voice").value.trim() });
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
1105
|
+
q(".media-play").addEventListener("click", () => {
|
|
1106
|
+
const cmd = this.#present.player?.state === "playing" ? "pause" : "resume";
|
|
1107
|
+
if (cmd === "pause") this.#media.pauseSpeech();
|
|
1108
|
+
else this.#media.resumeSpeech();
|
|
1109
|
+
void run(async () => {
|
|
1110
|
+
if (cmd === "resume" && this.#present.player?.state === "done") await this.#media.request("/player", { cmd: "goto", at: "1" });
|
|
1111
|
+
const data = await this.#media.request("/player", { cmd });
|
|
1112
|
+
this.#present.player = data.player;
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
q(".media-record").addEventListener("click", () => {
|
|
1116
|
+
try {
|
|
1117
|
+
if (this.#media.recording) this.#media.stopRecording();
|
|
1118
|
+
else this.#media.startRecording();
|
|
1119
|
+
} catch (error) { this.#media.report(error); }
|
|
1120
|
+
});
|
|
1121
|
+
this.#renderMedia();
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
#renderMedia() {
|
|
1125
|
+
if (!this.#media) return;
|
|
1126
|
+
const q = (selector) => this.#root.querySelector(selector);
|
|
1127
|
+
const media = this.#media, config = media.config;
|
|
1128
|
+
const recording = media.recording;
|
|
1129
|
+
const own = config.enabled && config.driverId === this.#viewerId;
|
|
1130
|
+
const tts = q(".media-tts");
|
|
1131
|
+
tts.textContent = own ? "TTS 끄기" : config.enabled ? "이 화면에서 TTS 켜기" : "TTS 켜기";
|
|
1132
|
+
tts.setAttribute("aria-pressed", String(own));
|
|
1133
|
+
for (const selector of [".media-tts", ".media-save", ".media-local", ".media-clear", ".media-voice"]) q(selector).disabled = this.#mediaBusy || !!recording;
|
|
1134
|
+
const play = q(".media-play");
|
|
1135
|
+
play.textContent = this.#present.player?.state === "playing" ? "일시정지" : this.#present.player?.state === "done" ? "다시 재생" : "대본 재생";
|
|
1136
|
+
play.disabled = this.#mediaBusy || !this.#present.player?.steps;
|
|
1137
|
+
const record = q(".media-record");
|
|
1138
|
+
record.textContent = recording?.stopping ? "영상 저장 중…" : recording ? "녹화 종료" : "녹화 시작";
|
|
1139
|
+
record.setAttribute("aria-pressed", String(!!recording));
|
|
1140
|
+
record.disabled = !!recording?.stopping;
|
|
1141
|
+
const seconds = recording ? Math.floor((performance.now() - recording.started) / 1000) : 0;
|
|
1142
|
+
q(".media-elapsed").textContent = recording ? `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}` : "";
|
|
1143
|
+
const link = q(".media-download");
|
|
1144
|
+
link.hidden = !media.download;
|
|
1145
|
+
if (media.download) { link.href = media.download.url; link.download = media.download.name; }
|
|
1146
|
+
else { link.removeAttribute("href"); link.removeAttribute("download"); }
|
|
1147
|
+
const status = q(".media-status");
|
|
1148
|
+
status.classList.toggle("error", !!media.error);
|
|
1149
|
+
const errorText = Object.hasOwn(MEDIA_ERRORS, media.error) ? MEDIA_ERRORS[media.error] : media.error;
|
|
1150
|
+
status.textContent = errorText || (own ? "TTS ON · 이 화면에서 음성 재생" : config.enabled ? "TTS ON · 다른 화면에서 음성 재생" : config.configured ? "TTS OFF · API 키 준비됨" : "TTS OFF · API 키 미설정");
|
|
1151
|
+
}
|
|
1152
|
+
|
|
916
1153
|
#setPresent(p) {
|
|
917
1154
|
const on = !!p?.on;
|
|
918
1155
|
const was = this.#present.on;
|
|
919
1156
|
this.#present = p ?? { on: false };
|
|
1157
|
+
this.#media.observePlayer(p?.player?.state);
|
|
1158
|
+
if (!on) this.#media.cancelSpeech();
|
|
1159
|
+
if (!on) this.#media.stopRecording();
|
|
1160
|
+
this.#renderMedia();
|
|
920
1161
|
this.#emit("agent-present", this.#present);
|
|
921
1162
|
this.#renderBusy();
|
|
922
1163
|
if (on === was) return;
|
|
923
1164
|
if (on) { this.#showTerminal(false); this.#setControl(false); this.#setAnnotate(false); }
|
|
924
|
-
else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#
|
|
1165
|
+
else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#qa = null; }
|
|
925
1166
|
this.#syncScreen();
|
|
926
1167
|
// FLIP: grow from (or shrink back to) the side panel
|
|
927
1168
|
const { screen, body } = this.$;
|
|
@@ -940,20 +1181,31 @@ class AgentBridge extends HTMLElement {
|
|
|
940
1181
|
const s = this.#status;
|
|
941
1182
|
const busy = this.#present.on && s?.alive && s.status === "working" && this.#present.player?.state !== "playing";
|
|
942
1183
|
this.$.busy.classList.toggle("on", !!busy);
|
|
1184
|
+
this.#checkAnswered();
|
|
943
1185
|
}
|
|
944
1186
|
|
|
945
|
-
// Raise hand:
|
|
1187
|
+
// Raise hand: a question session over the presentation.
|
|
1188
|
+
// The input stays open for the whole session; X / Esc / the hand button close it.
|
|
1189
|
+
// raised: nothing asked yet. close -> hand-lower (agent resumes)
|
|
1190
|
+
// asked: question sent, agent answering (follow-ups can be typed already)
|
|
1191
|
+
// follow: answered, waiting for a follow-up. close -> hand-done (agent resumes)
|
|
1192
|
+
// The session ends when the script plays again or the presentation stops.
|
|
1193
|
+
#qa = null; // null | "raised" | "asked" | "follow"
|
|
1194
|
+
#qaTaken = false; // the last question reached the agent (echoed in the transcript)
|
|
1195
|
+
#qaWorked = false; // agent seen working on it
|
|
1196
|
+
|
|
946
1197
|
#bindHand() {
|
|
947
1198
|
const { hand, ask, askBox, askHint } = this.$;
|
|
948
1199
|
hand.addEventListener("click", () => {
|
|
949
|
-
if (
|
|
950
|
-
else this.#
|
|
1200
|
+
if (!this.#qa) this.#raiseHand();
|
|
1201
|
+
else this.#closeHand();
|
|
951
1202
|
});
|
|
1203
|
+
this.$.askDone.addEventListener("click", () => { this.#closeHand(); hand.focus(); });
|
|
952
1204
|
this.$.end.addEventListener("click", async () => {
|
|
953
1205
|
this.$.end.disabled = true;
|
|
954
1206
|
const res = await this.stopPresent();
|
|
955
1207
|
this.$.end.disabled = false;
|
|
956
|
-
if (!res.ok) { this.#openAsk(
|
|
1208
|
+
if (!res.ok) { this.#openAsk(this.#qa ?? "raised"); askHint.textContent = `발표 종료 실패: ${res.error}`; }
|
|
957
1209
|
});
|
|
958
1210
|
ask.addEventListener("submit", async (e) => {
|
|
959
1211
|
e.preventDefault();
|
|
@@ -963,60 +1215,110 @@ class AgentBridge extends HTMLElement {
|
|
|
963
1215
|
const res = await this.send(text, "message");
|
|
964
1216
|
if (!res.ok) { askHint.textContent = `전송 실패: ${res.error}`; return; }
|
|
965
1217
|
askBox.value = "";
|
|
966
|
-
this.#
|
|
967
|
-
this.#
|
|
1218
|
+
this.#qa = "asked";
|
|
1219
|
+
this.#qaTaken = this.#qaWorked = false;
|
|
1220
|
+
this.#openAsk("asked");
|
|
968
1221
|
hand.classList.add("sent");
|
|
969
1222
|
setTimeout(() => hand.classList.remove("sent"), 1500);
|
|
970
1223
|
});
|
|
971
1224
|
askBox.addEventListener("keydown", (e) => {
|
|
972
1225
|
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); ask.requestSubmit(); }
|
|
973
|
-
if (e.key === "Escape") { e.preventDefault(); this.#
|
|
1226
|
+
if (e.key === "Escape") { e.preventDefault(); this.#closeHand(); hand.focus(); }
|
|
974
1227
|
});
|
|
975
1228
|
}
|
|
976
1229
|
|
|
977
|
-
// Raise: open the input and tell the agent to pause.
|
|
978
|
-
// asking): tell it to go on. Failures only show a hint; asking still works.
|
|
979
|
-
#handUp = false;
|
|
1230
|
+
// Raise: open the input and tell the agent to pause. Failures only show a hint; asking still works.
|
|
980
1231
|
async #raiseHand() {
|
|
981
|
-
this.#
|
|
982
|
-
|
|
983
|
-
this.#
|
|
1232
|
+
this.#media.pauseSpeech();
|
|
1233
|
+
this.#event("손들기 확인 · 발표 일시정지");
|
|
1234
|
+
this.#qa = "raised";
|
|
1235
|
+
this.#openAsk("raised");
|
|
984
1236
|
const res = await this.send("사용자가 손을 듦: 질문 입력 중", "hand-raise");
|
|
985
1237
|
if (!res.ok && !this.$.ask.hidden) this.$.askHint.textContent = `손들기 알림 실패: ${res.error}`;
|
|
986
1238
|
}
|
|
1239
|
+
// X / Esc / hand button: before asking = cancel (hand-lower), after = resolved (hand-done).
|
|
1240
|
+
#closeHand() {
|
|
1241
|
+
if (this.#qa === "raised") this.#lowerHand(); else this.#doneHand();
|
|
1242
|
+
}
|
|
1243
|
+
// Cancel before asking: tell the agent to go on.
|
|
987
1244
|
async #lowerHand() {
|
|
988
1245
|
this.#openAsk(false);
|
|
989
|
-
if (
|
|
990
|
-
this.#
|
|
991
|
-
|
|
1246
|
+
if (this.#qa !== "raised") return;
|
|
1247
|
+
this.#qa = null;
|
|
1248
|
+
this.#event("손들기 취소 · 곧 발표 재개");
|
|
1249
|
+
await this.send("사용자가 질문 없이 손을 내림: 발표 계속", "hand-lower");
|
|
1250
|
+
}
|
|
1251
|
+
// X after an answer: the question is resolved, tell the agent to go on.
|
|
1252
|
+
async #doneHand() {
|
|
1253
|
+
this.#openAsk(false);
|
|
1254
|
+
if (!this.#qa || this.#qa === "raised") return;
|
|
1255
|
+
this.#qa = null;
|
|
1256
|
+
this.#event("질문 완료 · 곧 발표 재개");
|
|
1257
|
+
await this.send("사용자가 질문 완료 (추가 질문 없음): 발표 계속", "hand-done");
|
|
992
1258
|
}
|
|
993
1259
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1260
|
+
// Answer finished (agent was working, now is not): reopen the input for a follow-up.
|
|
1261
|
+
// If the script plays again (agent resumed on its own), the session is over.
|
|
1262
|
+
#checkAnswered() {
|
|
1263
|
+
if (!this.#qa) return;
|
|
1264
|
+
if (this.#present.player?.state === "playing") {
|
|
1265
|
+
if (this.#qa !== "raised") { this.#qa = null; this.#openAsk(false); }
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
if (this.#qa !== "asked") return;
|
|
1269
|
+
const s = this.#status;
|
|
1270
|
+
if (s?.alive && s.status === "working") { if (this.#qaTaken) this.#qaWorked = true; return; }
|
|
1271
|
+
if (this.#qaWorked && s?.alive) { this.#qa = "follow"; this.#openAsk("follow"); }
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// The input stays open for the whole session. mode: false (close) | "raised" | "asked" | "follow"
|
|
1275
|
+
static ASK_TEXT = {
|
|
1276
|
+
raised: ["질문 입력", "에이전트에게 질문 (Enter 전송, Esc·X 취소)", "손들기 취소: 발표 계속"],
|
|
1277
|
+
asked: ["답변 준비 중…", "추가 질문 (Enter 전송, Esc·X 질문 완료)", "질문 완료: 발표 계속"],
|
|
1278
|
+
follow: ["추가 질문이 있으면 입력", "추가 질문 (Enter 전송, Esc·X 질문 완료)", "질문 완료: 발표 계속"],
|
|
1279
|
+
};
|
|
1280
|
+
#openAsk(mode) {
|
|
1281
|
+
const { hand, ask, askBox, askHint, askTitle, askDone } = this.$;
|
|
1282
|
+
const wasOpen = !ask.hidden;
|
|
1283
|
+
ask.hidden = !mode;
|
|
1284
|
+
hand.setAttribute("aria-expanded", String(!!mode));
|
|
998
1285
|
askHint.textContent = "";
|
|
999
|
-
if (
|
|
1286
|
+
if (!mode) return;
|
|
1287
|
+
const [title, placeholder, done] = AgentBridge.ASK_TEXT[mode];
|
|
1288
|
+
askTitle.textContent = title;
|
|
1289
|
+
askBox.placeholder = placeholder;
|
|
1290
|
+
askDone.setAttribute("aria-label", done);
|
|
1291
|
+
askDone.title = done;
|
|
1292
|
+
if (!wasOpen || mode !== "asked") askBox.focus();
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// Instant local feedback for a hand interaction (no agent round trip).
|
|
1296
|
+
#event(text) {
|
|
1297
|
+
this.#caption(text, "event", AgentBridge.EVENT_MS);
|
|
1000
1298
|
}
|
|
1001
1299
|
|
|
1002
1300
|
// Agent reply as a short-lived bubble over the presented screen.
|
|
1003
1301
|
// kind "user": the viewer's own question (plain text, accent colour).
|
|
1004
|
-
#caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS) {
|
|
1302
|
+
#caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS, narration = null) {
|
|
1005
1303
|
if (!text?.trim()) return;
|
|
1006
1304
|
const el = document.createElement("div");
|
|
1007
1305
|
el.className = `caption ${kind}`;
|
|
1008
1306
|
el.part = "caption";
|
|
1009
|
-
if (
|
|
1307
|
+
if (narration) el.dataset.narration = narration;
|
|
1308
|
+
el.__captionKind = kind;
|
|
1309
|
+
if (kind === "user" || kind === "event") { el.textContent = text; el.classList.add("plain"); }
|
|
1010
1310
|
else if (this.#md) el.innerHTML = this.#md(text);
|
|
1011
1311
|
else { el.textContent = text; el.classList.add("plain"); }
|
|
1012
1312
|
const box = this.$.captions;
|
|
1013
1313
|
box.append(el);
|
|
1314
|
+
this.#media.flushRecordingFrame();
|
|
1014
1315
|
const leave = () => {
|
|
1015
1316
|
if (el.classList.contains("out")) return;
|
|
1016
1317
|
el.classList.add("out");
|
|
1318
|
+
this.#media.flushRecordingFrame();
|
|
1017
1319
|
setTimeout(() => el.remove(), 500);
|
|
1018
1320
|
};
|
|
1019
|
-
setTimeout(leave, ms);
|
|
1321
|
+
if (!narration) setTimeout(leave, ms);
|
|
1020
1322
|
const live = [...box.children].filter((c) => !c.classList.contains("out"));
|
|
1021
1323
|
live.slice(0, Math.max(0, live.length - AgentBridge.CAPTION_MAX)).forEach((c) => c.__leave?.());
|
|
1022
1324
|
el.__leave = leave;
|