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.
@@ -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 without asking sends action=hand-lower so it can go on.
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><textarea aria-label="에이전트에게 질문" placeholder="에이전트에게 질문 (Enter 전송, Esc 닫기)"></textarea>
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
- annotBtn: q(".annot-btn"), hlHover: q(".hl.hover"), hlSel: q(".hl.sel"), hlLabel: q(".hl-label"),
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
- const es = (this.#es = new EventSource(`${this.server}/events`));
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
- this.#addMessage(m);
872
- this.#saveMessage(m);
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 = () => { this.#renderStatus(null); this.#setPresent({ on: false }); };
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.#handUp = false; }
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: open a small input over the presentation and send it as a message.
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 (ask.hidden) this.#raiseHand();
950
- else this.#lowerHand();
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(true); askHint.textContent = `발표 종료 실패: ${res.error}`; }
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.#handUp = false; // the question itself ends the raised hand
967
- this.#openAsk(false);
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.#lowerHand(); hand.focus(); }
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. Lower (closed without
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.#openAsk(true);
982
- if (this.#handUp) return;
983
- this.#handUp = true;
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 (!this.#handUp) return;
990
- this.#handUp = false;
991
- await this.send("사용자가 질문 없이 손을 내림", "hand-lower");
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
- #openAsk(open) {
995
- const { hand, ask, askBox, askHint } = this.$;
996
- ask.hidden = !open;
997
- hand.setAttribute("aria-expanded", String(open));
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 (open) askBox.focus();
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 (kind === "user") { el.textContent = text; el.classList.add("plain"); }
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;