agello 0.4.1 → 0.4.3
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 +35 -1
- package/bin/agello.ts +68 -0
- package/package.json +1 -1
- package/src/player.ts +244 -0
- package/src/screencast.ts +71 -1
- package/src/server.ts +129 -15
- package/web/agent-bridge.js +372 -54
package/web/agent-bridge.js
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
// no-history do not persist chat in localStorage (default: persisted per server + pane)
|
|
13
13
|
// screen show live terminal-browser screen panel (side by side, stacked when narrow)
|
|
14
14
|
// with a toggle that lets the viewer control the browser (mouse, wheel, keyboard)
|
|
15
|
+
// and an annotate toggle: hovering tints the element under the pointer, clicking
|
|
16
|
+
// outlines it and opens an input; the request goes to the agent with the element's
|
|
17
|
+
// CSS selector (action=annotate). The wheel still scrolls the page.
|
|
15
18
|
//
|
|
16
19
|
// Theming (CSS custom properties on the element or an ancestor)
|
|
17
20
|
// --ab-bg --ab-panel --ab-text --ab-muted --ab-line --ab-accent --ab-user --ab-font
|
|
@@ -39,12 +42,17 @@
|
|
|
39
42
|
// A raise-hand button next to the bubbles opens a small input so the viewer
|
|
40
43
|
// can ask the agent something mid-presentation (sent as action=message).
|
|
41
44
|
// Opening it tells the agent right away (action=hand-raise) so it can pause;
|
|
42
|
-
// 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.
|
|
43
48
|
// The viewer's questions show as bubbles too, with the same 10s lifetime.
|
|
44
49
|
// An end button above it stops the presentation for every viewer.
|
|
50
|
+
// Script lines (`agello script` + `present resume`) arrive as agent messages
|
|
51
|
+
// with `script: "step.line"`. While the script is not playing and the agent
|
|
52
|
+
// is working (e.g. answering a question), a small loader is shown.
|
|
45
53
|
//
|
|
46
54
|
// Methods
|
|
47
|
-
// el.send(text, action = 'message', images
|
|
55
|
+
// el.send(text, action = 'message', extra) -> Promise<{ok, error?}> (extra: {images: [{type, data (base64)}]}, {target} for annotate)
|
|
48
56
|
// el.clearHistory() clear saved chat for the current pane
|
|
49
57
|
// el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
|
|
50
58
|
|
|
@@ -75,7 +83,7 @@ const SCREEN_REASONS = {
|
|
|
75
83
|
};
|
|
76
84
|
const ACTIONS = {
|
|
77
85
|
message: "메시지", approve: "승인", reject: "거절",
|
|
78
|
-
"present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림",
|
|
86
|
+
"present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림", "hand-done": "질문 완료", annotate: "주석",
|
|
79
87
|
};
|
|
80
88
|
|
|
81
89
|
const CSS = `
|
|
@@ -228,6 +236,30 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
228
236
|
@media (prefers-reduced-motion: reduce) { .control-btn .track, .control-btn .knob { transition: none; } }
|
|
229
237
|
.placeholder { font-size: 13px; color: var(--_muted); text-align: center; }
|
|
230
238
|
|
|
239
|
+
/* annotate: hover tint, selected outline, request input next to the element */
|
|
240
|
+
.annot-btn { flex: none; font-size: 12px; padding: 2px 10px; border-radius: 999px; }
|
|
241
|
+
.annot-btn[aria-pressed="true"] { background: var(--_accent); border-color: var(--_accent); color: #fff; }
|
|
242
|
+
.viewport.annot img { cursor: crosshair; }
|
|
243
|
+
.hl { position: absolute; z-index: 3; pointer-events: none; display: none; border-radius: 2px; }
|
|
244
|
+
.hl.on { display: block; }
|
|
245
|
+
.hl.hover { background: color-mix(in srgb, var(--_accent) 22%, transparent);
|
|
246
|
+
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--_accent) 55%, transparent); }
|
|
247
|
+
.hl.sel { box-shadow: 0 0 0 2px var(--_accent), 0 0 0 5px color-mix(in srgb, var(--_accent) 25%, transparent); }
|
|
248
|
+
.hl-label { position: absolute; z-index: 4; pointer-events: none; display: none; max-width: 60%;
|
|
249
|
+
padding: 2px 7px; border-radius: 5px; font: 11px/1.5 ui-monospace, Menlo, monospace; color: #fff;
|
|
250
|
+
background: rgba(20, 20, 22, .85); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
251
|
+
.hl-label.on { display: block; }
|
|
252
|
+
.note { position: absolute; z-index: 5; width: min(340px, calc(100% - 16px)); padding: 10px; border-radius: 12px;
|
|
253
|
+
background: var(--_panel); border: 1px solid var(--_line); box-shadow: 0 8px 28px rgba(0,0,0,.22);
|
|
254
|
+
animation: capIn .18s ease; }
|
|
255
|
+
.note[hidden] { display: none; }
|
|
256
|
+
.note .note-target { font: 11px/1.4 ui-monospace, Menlo, monospace; color: var(--_muted); margin-bottom: 6px;
|
|
257
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
258
|
+
.note textarea { min-height: 52px; font-size: 14px; }
|
|
259
|
+
.note .note-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
|
260
|
+
.note .note-hint { flex: 1; font-size: 12px; color: var(--_muted); }
|
|
261
|
+
@media (prefers-reduced-motion: reduce) { .note { animation: none; } }
|
|
262
|
+
|
|
231
263
|
/* presentation mode: the screen panel covers the window, chat stays hidden underneath.
|
|
232
264
|
container-type would make the host the containing block of the fixed layer, so drop it. */
|
|
233
265
|
:host([presenting]) { container-type: normal; }
|
|
@@ -246,6 +278,9 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
246
278
|
box-shadow: 0 8px 30px rgba(0, 0, 0, .35); animation: capIn .3s ease; }
|
|
247
279
|
.caption.plain { white-space: pre-wrap; }
|
|
248
280
|
.caption.user { background: color-mix(in srgb, var(--_accent) 88%, transparent); }
|
|
281
|
+
/* system event (hand raised / lowered / done): small, light, not part of the conversation */
|
|
282
|
+
.caption.event { padding: 6px 14px; border-radius: 999px; font-size: 13px; color: #1d1d1f;
|
|
283
|
+
background: rgba(255, 255, 255, .9); box-shadow: 0 4px 16px rgba(0,0,0,.25); }
|
|
249
284
|
.caption.out { opacity: 0; translate: 0 6px; transition: opacity .5s ease, translate .5s ease; }
|
|
250
285
|
.caption > :first-child { margin-top: 0; } .caption > :last-child { margin-bottom: 0; }
|
|
251
286
|
.caption p, .caption ul, .caption ol { margin: .35em 0; }
|
|
@@ -255,6 +290,14 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
255
290
|
.caption a { color: inherit; }
|
|
256
291
|
@keyframes capIn { from { opacity: 0; translate: 0 10px; } }
|
|
257
292
|
|
|
293
|
+
/* agent busy while presenting (answering, fixing the script): minimal loader */
|
|
294
|
+
.busy { display: none; }
|
|
295
|
+
.body.present .busy.on { position: absolute; left: 22px; bottom: 28px; z-index: 5; display: flex; align-items: center;
|
|
296
|
+
gap: 9px; padding: 9px 14px; border-radius: 999px; font-size: 13px; color: #fff; background: rgba(20, 20, 22, .72);
|
|
297
|
+
-webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); box-shadow: 0 8px 30px rgba(0,0,0,.35);
|
|
298
|
+
animation: capIn .25s ease; }
|
|
299
|
+
.busy .spinner { border-color: rgba(255,255,255,.25); border-top-color: #fff; }
|
|
300
|
+
|
|
258
301
|
/* raise hand: ask the agent mid-presentation */
|
|
259
302
|
.hand-wrap { display: none; }
|
|
260
303
|
.body.present .hand-wrap { position: absolute; right: 22px; bottom: 28px; z-index: 5; display: flex;
|
|
@@ -279,6 +322,13 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
279
322
|
.ask textarea::placeholder { color: rgba(255,255,255,.5); }
|
|
280
323
|
.ask .ask-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
|
281
324
|
.ask .ask-hint { font-size: 12px; color: rgba(255,255,255,.6); flex: 1; }
|
|
325
|
+
.ask .ask-row .primary { margin-left: 0; }
|
|
326
|
+
.ask .ask-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 13px; color: rgba(255,255,255,.75); }
|
|
327
|
+
.ask .ask-title { flex: 1; }
|
|
328
|
+
.ask .ask-done { width: 28px; height: 28px; padding: 0; border-radius: 50%; display: grid; place-items: center;
|
|
329
|
+
background: transparent; color: #fff; border-color: rgba(255,255,255,.22); }
|
|
330
|
+
.ask .ask-done:hover { background: rgba(255,255,255,.14); }
|
|
331
|
+
.ask .ask-done svg { width: 16px; height: 16px; }
|
|
282
332
|
@media (prefers-reduced-motion: reduce) { .hand, .ask { transition: none; animation: none; } }
|
|
283
333
|
@media (prefers-reduced-motion: reduce) { .caption { animation: none; } .caption.out { transition: none; } }
|
|
284
334
|
@container (max-width: 820px) {
|
|
@@ -401,11 +451,18 @@ const TEMPLATE = `
|
|
|
401
451
|
</section>
|
|
402
452
|
<aside class="screen" part="screen">
|
|
403
453
|
<div class="screen-bar"><span class="live" hidden>에이전트 조작 중</span><span class="url"></span>
|
|
454
|
+
<button class="annot-btn" type="button" aria-pressed="false" title="화면의 요소를 골라 에이전트에게 요청 (선택자와 함께 전달)">주석</button>
|
|
404
455
|
<button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
|
|
405
456
|
<span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
|
|
406
|
-
<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="
|
|
457
|
+
<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>
|
|
458
|
+
<form class="note" part="note" hidden><div class="note-target"></div><textarea aria-label="선택한 요소에 대한 요청" placeholder="이 요소에 대한 요청 (Enter 전송, Esc 취소)"></textarea>
|
|
459
|
+
<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>
|
|
460
|
+
<div class="busy" part="busy" role="status"><span class="spinner"></span><span>답변 준비 중</span></div>
|
|
407
461
|
<div class="hand-wrap">
|
|
408
|
-
<form class="ask" part="ask" hidden
|
|
462
|
+
<form class="ask" part="ask" hidden>
|
|
463
|
+
<div class="ask-head"><span class="ask-title"></span><button type="button" class="ask-done">
|
|
464
|
+
<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>
|
|
465
|
+
<textarea aria-label="에이전트에게 질문"></textarea>
|
|
409
466
|
<div class="ask-row"><span class="ask-hint"></span><button type="submit" class="primary">보내기</button></div></form>
|
|
410
467
|
<button class="end" part="end" type="button" aria-label="발표 종료" title="발표 종료">
|
|
411
468
|
<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>
|
|
@@ -422,7 +479,14 @@ class AgentBridge extends HTMLElement {
|
|
|
422
479
|
#root = this.attachShadow({ mode: "open" });
|
|
423
480
|
#es = null;
|
|
424
481
|
#screenEs = null;
|
|
425
|
-
#
|
|
482
|
+
#ws = null; // /input: control input and annotate inspect queries
|
|
483
|
+
#control = false;
|
|
484
|
+
#annot = false;
|
|
485
|
+
#hover = null; // inspected element under the pointer (annotate)
|
|
486
|
+
#hoverPt = null;
|
|
487
|
+
#sel = null; // selected element (annotate)
|
|
488
|
+
#inspectId = 0;
|
|
489
|
+
#inspectWait = new Map();
|
|
426
490
|
#terminalCleanup = null;
|
|
427
491
|
#terminalVersion = 0;
|
|
428
492
|
#terminalStyles = null;
|
|
@@ -442,6 +506,7 @@ class AgentBridge extends HTMLElement {
|
|
|
442
506
|
#present = { on: false };
|
|
443
507
|
static HISTORY_LIMIT = 500;
|
|
444
508
|
static CAPTION_MS = 10000; // presentation bubble lifetime
|
|
509
|
+
static EVENT_MS = 5000; // system event bubble lifetime
|
|
445
510
|
static CAPTION_MAX = 3; // bubbles on screen at once (oldest leaves early)
|
|
446
511
|
|
|
447
512
|
constructor() {
|
|
@@ -455,7 +520,10 @@ class AgentBridge extends HTMLElement {
|
|
|
455
520
|
live: q(".live"), url: q(".screen-bar .url"), viewport: q(".viewport"), img: q(".viewport img"),
|
|
456
521
|
controlBtn: q(".control-btn"), kbd: q(".kbd"),
|
|
457
522
|
body: q(".body"), screen: q(".screen"), captions: q(".captions"),
|
|
458
|
-
hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
|
|
523
|
+
busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
|
|
524
|
+
askTitle: q(".ask-title"), askDone: q(".ask-done"),
|
|
525
|
+
annotBtn: q(".annot-btn"), hlHover: q(".hl.hover"), hlSel: q(".hl.sel"), hlLabel: q(".hl-label"),
|
|
526
|
+
note: q(".note"), noteBox: q(".note textarea"), noteHint: q(".note-hint"), noteTarget: q(".note-target"),
|
|
459
527
|
};
|
|
460
528
|
this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
|
|
461
529
|
this.#bindGrip();
|
|
@@ -467,6 +535,7 @@ class AgentBridge extends HTMLElement {
|
|
|
467
535
|
});
|
|
468
536
|
loadMarkdown().then((md) => (this.#md = md));
|
|
469
537
|
this.#bindControl();
|
|
538
|
+
this.#bindAnnotate();
|
|
470
539
|
this.#bindHand();
|
|
471
540
|
q(".term-btn").addEventListener("click", () => this.#showTerminal(!this.hasAttribute("terminal-open")));
|
|
472
541
|
q(".terminal-retry").addEventListener("click", () => this.#showTerminal(true));
|
|
@@ -490,6 +559,7 @@ class AgentBridge extends HTMLElement {
|
|
|
490
559
|
this.#screenEs?.close();
|
|
491
560
|
this.#screenEs = null;
|
|
492
561
|
this.#setControl(false);
|
|
562
|
+
this.#setAnnotate(false);
|
|
493
563
|
}
|
|
494
564
|
attributeChangedCallback(name) {
|
|
495
565
|
if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "agello";
|
|
@@ -506,6 +576,7 @@ class AgentBridge extends HTMLElement {
|
|
|
506
576
|
q(".term-btn").setAttribute("aria-pressed", String(on));
|
|
507
577
|
if (!on) return;
|
|
508
578
|
this.#setControl(false);
|
|
579
|
+
this.#setAnnotate(false);
|
|
509
580
|
q(".terminal-status").textContent = "터미널 로딩 중…";
|
|
510
581
|
try {
|
|
511
582
|
if (!this.#terminalStyles) {
|
|
@@ -772,12 +843,12 @@ class AgentBridge extends HTMLElement {
|
|
|
772
843
|
}));
|
|
773
844
|
}
|
|
774
845
|
|
|
775
|
-
async send(text, action = "message",
|
|
846
|
+
async send(text, action = "message", extra = {}) {
|
|
776
847
|
try {
|
|
777
848
|
const res = await fetch(`${this.server}/send`, {
|
|
778
849
|
method: "POST",
|
|
779
850
|
headers: { "Content-Type": "application/json" },
|
|
780
|
-
body: JSON.stringify({ action, text
|
|
851
|
+
body: JSON.stringify({ ...extra, action, text }),
|
|
781
852
|
});
|
|
782
853
|
return await res.json();
|
|
783
854
|
} catch {
|
|
@@ -816,8 +887,13 @@ class AgentBridge extends HTMLElement {
|
|
|
816
887
|
on("message", (m) => {
|
|
817
888
|
this.#addMessage(m);
|
|
818
889
|
this.#saveMessage(m);
|
|
819
|
-
|
|
890
|
+
// script lines carry their own on-screen time (>= 10s), others use CAPTION_MS
|
|
891
|
+
if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined);
|
|
820
892
|
if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
|
|
893
|
+
// a reply to the viewer's question (status polling may miss a short working phase)
|
|
894
|
+
// (the agent took the question once it is echoed in the transcript; earlier work was the hand-raise turn)
|
|
895
|
+
if (this.#qa === "asked" && m.role === "browser" && m.action === "message") this.#qaTaken = true;
|
|
896
|
+
if (this.#qa === "asked" && this.#qaTaken && m.role === "assistant" && !m.script) { this.#qaWorked = true; this.#checkAnswered(); }
|
|
821
897
|
this.#emit("agent-message", m);
|
|
822
898
|
});
|
|
823
899
|
on("present", (p) => this.#setPresent(p));
|
|
@@ -842,7 +918,7 @@ class AgentBridge extends HTMLElement {
|
|
|
842
918
|
|
|
843
919
|
#syncScreen() {
|
|
844
920
|
const want = this.hasAttribute("screen") || this.#present.on;
|
|
845
|
-
if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); return; }
|
|
921
|
+
if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); this.#setAnnotate(false); return; }
|
|
846
922
|
if (this.#screenEs) return;
|
|
847
923
|
const es = (this.#screenEs = new EventSource(`${this.server}/screen`));
|
|
848
924
|
es.addEventListener("meta", (e) => this.#renderScreenMeta(JSON.parse(e.data)));
|
|
@@ -851,6 +927,7 @@ class AgentBridge extends HTMLElement {
|
|
|
851
927
|
this.#frame = { w: f.w, h: f.h };
|
|
852
928
|
this.$.img.src = `data:image/jpeg;base64,${f.data}`;
|
|
853
929
|
this.$.viewport.classList.add("has");
|
|
930
|
+
if (this.#annot) this.#annotFrame();
|
|
854
931
|
});
|
|
855
932
|
es.onerror = () => this.#renderScreenMeta({ connected: false });
|
|
856
933
|
}
|
|
@@ -862,9 +939,10 @@ class AgentBridge extends HTMLElement {
|
|
|
862
939
|
const was = this.#present.on;
|
|
863
940
|
this.#present = p ?? { on: false };
|
|
864
941
|
this.#emit("agent-present", this.#present);
|
|
942
|
+
this.#renderBusy();
|
|
865
943
|
if (on === was) return;
|
|
866
|
-
if (on) { this.#showTerminal(false); this.#setControl(false); }
|
|
867
|
-
else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#
|
|
944
|
+
if (on) { this.#showTerminal(false); this.#setControl(false); this.#setAnnotate(false); }
|
|
945
|
+
else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#qa = null; }
|
|
868
946
|
this.#syncScreen();
|
|
869
947
|
// FLIP: grow from (or shrink back to) the side panel
|
|
870
948
|
const { screen, body } = this.$;
|
|
@@ -879,18 +957,35 @@ class AgentBridge extends HTMLElement {
|
|
|
879
957
|
{ duration: on ? 420 : 360, easing: "cubic-bezier(.2, .7, .2, 1)" });
|
|
880
958
|
}
|
|
881
959
|
|
|
882
|
-
|
|
960
|
+
#renderBusy() {
|
|
961
|
+
const s = this.#status;
|
|
962
|
+
const busy = this.#present.on && s?.alive && s.status === "working" && this.#present.player?.state !== "playing";
|
|
963
|
+
this.$.busy.classList.toggle("on", !!busy);
|
|
964
|
+
this.#checkAnswered();
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Raise hand: a question session over the presentation.
|
|
968
|
+
// The input stays open for the whole session; X / Esc / the hand button close it.
|
|
969
|
+
// raised: nothing asked yet. close -> hand-lower (agent resumes)
|
|
970
|
+
// asked: question sent, agent answering (follow-ups can be typed already)
|
|
971
|
+
// follow: answered, waiting for a follow-up. close -> hand-done (agent resumes)
|
|
972
|
+
// The session ends when the script plays again or the presentation stops.
|
|
973
|
+
#qa = null; // null | "raised" | "asked" | "follow"
|
|
974
|
+
#qaTaken = false; // the last question reached the agent (echoed in the transcript)
|
|
975
|
+
#qaWorked = false; // agent seen working on it
|
|
976
|
+
|
|
883
977
|
#bindHand() {
|
|
884
978
|
const { hand, ask, askBox, askHint } = this.$;
|
|
885
979
|
hand.addEventListener("click", () => {
|
|
886
|
-
if (
|
|
887
|
-
else this.#
|
|
980
|
+
if (!this.#qa) this.#raiseHand();
|
|
981
|
+
else this.#closeHand();
|
|
888
982
|
});
|
|
983
|
+
this.$.askDone.addEventListener("click", () => { this.#closeHand(); hand.focus(); });
|
|
889
984
|
this.$.end.addEventListener("click", async () => {
|
|
890
985
|
this.$.end.disabled = true;
|
|
891
986
|
const res = await this.stopPresent();
|
|
892
987
|
this.$.end.disabled = false;
|
|
893
|
-
if (!res.ok) { this.#openAsk(
|
|
988
|
+
if (!res.ok) { this.#openAsk(this.#qa ?? "raised"); askHint.textContent = `발표 종료 실패: ${res.error}`; }
|
|
894
989
|
});
|
|
895
990
|
ask.addEventListener("submit", async (e) => {
|
|
896
991
|
e.preventDefault();
|
|
@@ -900,50 +995,95 @@ class AgentBridge extends HTMLElement {
|
|
|
900
995
|
const res = await this.send(text, "message");
|
|
901
996
|
if (!res.ok) { askHint.textContent = `전송 실패: ${res.error}`; return; }
|
|
902
997
|
askBox.value = "";
|
|
903
|
-
this.#
|
|
904
|
-
this.#
|
|
998
|
+
this.#qa = "asked";
|
|
999
|
+
this.#qaTaken = this.#qaWorked = false;
|
|
1000
|
+
this.#openAsk("asked");
|
|
905
1001
|
hand.classList.add("sent");
|
|
906
1002
|
setTimeout(() => hand.classList.remove("sent"), 1500);
|
|
907
1003
|
});
|
|
908
1004
|
askBox.addEventListener("keydown", (e) => {
|
|
909
1005
|
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); ask.requestSubmit(); }
|
|
910
|
-
if (e.key === "Escape") { e.preventDefault(); this.#
|
|
1006
|
+
if (e.key === "Escape") { e.preventDefault(); this.#closeHand(); hand.focus(); }
|
|
911
1007
|
});
|
|
912
1008
|
}
|
|
913
1009
|
|
|
914
|
-
// Raise: open the input and tell the agent to pause.
|
|
915
|
-
// asking): tell it to go on. Failures only show a hint; asking still works.
|
|
916
|
-
#handUp = false;
|
|
1010
|
+
// Raise: open the input and tell the agent to pause. Failures only show a hint; asking still works.
|
|
917
1011
|
async #raiseHand() {
|
|
918
|
-
this.#
|
|
919
|
-
|
|
920
|
-
this.#
|
|
1012
|
+
this.#event("손들기 확인 · 발표 일시정지");
|
|
1013
|
+
this.#qa = "raised";
|
|
1014
|
+
this.#openAsk("raised");
|
|
921
1015
|
const res = await this.send("사용자가 손을 듦: 질문 입력 중", "hand-raise");
|
|
922
1016
|
if (!res.ok && !this.$.ask.hidden) this.$.askHint.textContent = `손들기 알림 실패: ${res.error}`;
|
|
923
1017
|
}
|
|
1018
|
+
// X / Esc / hand button: before asking = cancel (hand-lower), after = resolved (hand-done).
|
|
1019
|
+
#closeHand() {
|
|
1020
|
+
if (this.#qa === "raised") this.#lowerHand(); else this.#doneHand();
|
|
1021
|
+
}
|
|
1022
|
+
// Cancel before asking: tell the agent to go on.
|
|
924
1023
|
async #lowerHand() {
|
|
925
1024
|
this.#openAsk(false);
|
|
926
|
-
if (
|
|
927
|
-
this.#
|
|
928
|
-
|
|
1025
|
+
if (this.#qa !== "raised") return;
|
|
1026
|
+
this.#qa = null;
|
|
1027
|
+
this.#event("손들기 취소 · 곧 발표 재개");
|
|
1028
|
+
await this.send("사용자가 질문 없이 손을 내림: 발표 계속", "hand-lower");
|
|
1029
|
+
}
|
|
1030
|
+
// X after an answer: the question is resolved, tell the agent to go on.
|
|
1031
|
+
async #doneHand() {
|
|
1032
|
+
this.#openAsk(false);
|
|
1033
|
+
if (!this.#qa || this.#qa === "raised") return;
|
|
1034
|
+
this.#qa = null;
|
|
1035
|
+
this.#event("질문 완료 · 곧 발표 재개");
|
|
1036
|
+
await this.send("사용자가 질문 완료 (추가 질문 없음): 발표 계속", "hand-done");
|
|
929
1037
|
}
|
|
930
1038
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1039
|
+
// Answer finished (agent was working, now is not): reopen the input for a follow-up.
|
|
1040
|
+
// If the script plays again (agent resumed on its own), the session is over.
|
|
1041
|
+
#checkAnswered() {
|
|
1042
|
+
if (!this.#qa) return;
|
|
1043
|
+
if (this.#present.player?.state === "playing") {
|
|
1044
|
+
if (this.#qa !== "raised") { this.#qa = null; this.#openAsk(false); }
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
if (this.#qa !== "asked") return;
|
|
1048
|
+
const s = this.#status;
|
|
1049
|
+
if (s?.alive && s.status === "working") { if (this.#qaTaken) this.#qaWorked = true; return; }
|
|
1050
|
+
if (this.#qaWorked && s?.alive) { this.#qa = "follow"; this.#openAsk("follow"); }
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// The input stays open for the whole session. mode: false (close) | "raised" | "asked" | "follow"
|
|
1054
|
+
static ASK_TEXT = {
|
|
1055
|
+
raised: ["질문 입력", "에이전트에게 질문 (Enter 전송, Esc·X 취소)", "손들기 취소: 발표 계속"],
|
|
1056
|
+
asked: ["답변 준비 중…", "추가 질문 (Enter 전송, Esc·X 질문 완료)", "질문 완료: 발표 계속"],
|
|
1057
|
+
follow: ["추가 질문이 있으면 입력", "추가 질문 (Enter 전송, Esc·X 질문 완료)", "질문 완료: 발표 계속"],
|
|
1058
|
+
};
|
|
1059
|
+
#openAsk(mode) {
|
|
1060
|
+
const { hand, ask, askBox, askHint, askTitle, askDone } = this.$;
|
|
1061
|
+
const wasOpen = !ask.hidden;
|
|
1062
|
+
ask.hidden = !mode;
|
|
1063
|
+
hand.setAttribute("aria-expanded", String(!!mode));
|
|
935
1064
|
askHint.textContent = "";
|
|
936
|
-
if (
|
|
1065
|
+
if (!mode) return;
|
|
1066
|
+
const [title, placeholder, done] = AgentBridge.ASK_TEXT[mode];
|
|
1067
|
+
askTitle.textContent = title;
|
|
1068
|
+
askBox.placeholder = placeholder;
|
|
1069
|
+
askDone.setAttribute("aria-label", done);
|
|
1070
|
+
askDone.title = done;
|
|
1071
|
+
if (!wasOpen || mode !== "asked") askBox.focus();
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Instant local feedback for a hand interaction (no agent round trip).
|
|
1075
|
+
#event(text) {
|
|
1076
|
+
this.#caption(text, "event", AgentBridge.EVENT_MS);
|
|
937
1077
|
}
|
|
938
1078
|
|
|
939
1079
|
// Agent reply as a short-lived bubble over the presented screen.
|
|
940
1080
|
// kind "user": the viewer's own question (plain text, accent colour).
|
|
941
|
-
#caption(text, kind = "agent") {
|
|
1081
|
+
#caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS) {
|
|
942
1082
|
if (!text?.trim()) return;
|
|
943
1083
|
const el = document.createElement("div");
|
|
944
1084
|
el.className = `caption ${kind}`;
|
|
945
1085
|
el.part = "caption";
|
|
946
|
-
if (kind === "user") { el.textContent = text; el.classList.add("plain"); }
|
|
1086
|
+
if (kind === "user" || kind === "event") { el.textContent = text; el.classList.add("plain"); }
|
|
947
1087
|
else if (this.#md) el.innerHTML = this.#md(text);
|
|
948
1088
|
else { el.textContent = text; el.classList.add("plain"); }
|
|
949
1089
|
const box = this.$.captions;
|
|
@@ -953,7 +1093,7 @@ class AgentBridge extends HTMLElement {
|
|
|
953
1093
|
el.classList.add("out");
|
|
954
1094
|
setTimeout(() => el.remove(), 500);
|
|
955
1095
|
};
|
|
956
|
-
setTimeout(leave,
|
|
1096
|
+
setTimeout(leave, ms);
|
|
957
1097
|
const live = [...box.children].filter((c) => !c.classList.contains("out"));
|
|
958
1098
|
live.slice(0, Math.max(0, live.length - AgentBridge.CAPTION_MAX)).forEach((c) => c.__leave?.());
|
|
959
1099
|
el.__leave = leave;
|
|
@@ -996,24 +1136,43 @@ class AgentBridge extends HTMLElement {
|
|
|
996
1136
|
|
|
997
1137
|
// ---------- user control ----------
|
|
998
1138
|
|
|
999
|
-
|
|
1000
|
-
|
|
1139
|
+
// One socket for both modes; open while control or annotate is on.
|
|
1140
|
+
#syncWs() {
|
|
1141
|
+
const want = this.#control || this.#annot;
|
|
1142
|
+
if (want && !this.#ws) {
|
|
1001
1143
|
const ws = new WebSocket(`${this.server.replace(/^http/, "ws")}/input`);
|
|
1002
|
-
ws.
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1144
|
+
ws.onmessage = (e) => {
|
|
1145
|
+
let m;
|
|
1146
|
+
try { m = JSON.parse(e.data); } catch { return; }
|
|
1147
|
+
this.#inspectWait.get(m.id)?.(m.result ?? null);
|
|
1148
|
+
};
|
|
1149
|
+
ws.onclose = () => {
|
|
1150
|
+
if (this.#ws !== ws) return;
|
|
1151
|
+
this.#ws = null;
|
|
1152
|
+
this.#setControl(false);
|
|
1153
|
+
this.#setAnnotate(false);
|
|
1154
|
+
};
|
|
1155
|
+
this.#ws = ws;
|
|
1156
|
+
} else if (!want && this.#ws) {
|
|
1157
|
+
const ws = this.#ws;
|
|
1158
|
+
this.#ws = null;
|
|
1007
1159
|
ws.close();
|
|
1008
1160
|
}
|
|
1009
|
-
this
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1161
|
+
if (!this.#ws) { this.#inspectWait.forEach((r) => r(null)); this.#inspectWait.clear(); }
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
#setControl(on) {
|
|
1165
|
+
this.#control = !!on && !this.#present.on;
|
|
1166
|
+
if (this.#control) this.#setAnnotate(false);
|
|
1167
|
+
this.#syncWs();
|
|
1168
|
+
this.$.controlBtn.setAttribute("aria-checked", String(this.#control));
|
|
1169
|
+
this.$.controlBtn.querySelector(".state-label").textContent = this.#control ? "ON" : "OFF";
|
|
1170
|
+
this.$.viewport.classList.toggle("control", this.#control);
|
|
1171
|
+
if (this.#control) this.$.kbd.focus();
|
|
1013
1172
|
}
|
|
1014
1173
|
|
|
1015
1174
|
#cdp(method, params) {
|
|
1016
|
-
const ws = this.#
|
|
1175
|
+
const ws = this.#ws;
|
|
1017
1176
|
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ method, params }));
|
|
1018
1177
|
}
|
|
1019
1178
|
|
|
@@ -1027,11 +1186,11 @@ class AgentBridge extends HTMLElement {
|
|
|
1027
1186
|
|
|
1028
1187
|
#bindControl() {
|
|
1029
1188
|
const { img, kbd, controlBtn } = this.$;
|
|
1030
|
-
const on = () =>
|
|
1189
|
+
const on = () => this.#control && this.#frame.w > 0;
|
|
1031
1190
|
const mods = (e) => (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
|
|
1032
1191
|
const BUTTONS = ["left", "middle", "right"];
|
|
1033
1192
|
|
|
1034
|
-
controlBtn.addEventListener("click", () =>
|
|
1193
|
+
controlBtn.addEventListener("click", () => this.#setControl(!this.#control));
|
|
1035
1194
|
|
|
1036
1195
|
img.addEventListener("mousedown", (e) => {
|
|
1037
1196
|
if (!on()) return;
|
|
@@ -1059,7 +1218,7 @@ class AgentBridge extends HTMLElement {
|
|
|
1059
1218
|
});
|
|
1060
1219
|
});
|
|
1061
1220
|
img.addEventListener("wheel", (e) => {
|
|
1062
|
-
if (!on()) return;
|
|
1221
|
+
if (!on() && !(this.#annot && this.#frame.w > 0)) return; // annotate: scroll to reach the element
|
|
1063
1222
|
e.preventDefault();
|
|
1064
1223
|
this.#cdp("Input.dispatchMouseEvent", { type: "mouseWheel", ...this.#point(e), deltaX: e.deltaX, deltaY: e.deltaY,
|
|
1065
1224
|
modifiers: mods(e) });
|
|
@@ -1097,6 +1256,163 @@ class AgentBridge extends HTMLElement {
|
|
|
1097
1256
|
});
|
|
1098
1257
|
}
|
|
1099
1258
|
|
|
1259
|
+
// ---------- annotate ----------
|
|
1260
|
+
|
|
1261
|
+
#setAnnotate(on) {
|
|
1262
|
+
on = !!on && !this.#present.on;
|
|
1263
|
+
if (on === this.#annot) return;
|
|
1264
|
+
this.#annot = on;
|
|
1265
|
+
if (on) this.#setControl(false);
|
|
1266
|
+
else { this.#hover = null; this.#hoverPt = null; this.#select(null); }
|
|
1267
|
+
this.#syncWs();
|
|
1268
|
+
this.$.annotBtn.setAttribute("aria-pressed", String(on));
|
|
1269
|
+
this.$.viewport.classList.toggle("annot", on);
|
|
1270
|
+
this.#drawHl();
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// Element at a frame point ({x, y}) or by selector; null if unavailable.
|
|
1274
|
+
#inspect(q, full = false) {
|
|
1275
|
+
const ws = this.#ws;
|
|
1276
|
+
if (ws?.readyState !== WebSocket.OPEN) return Promise.resolve(null);
|
|
1277
|
+
const id = ++this.#inspectId;
|
|
1278
|
+
return new Promise((resolve) => {
|
|
1279
|
+
const done = (r) => { clearTimeout(t); this.#inspectWait.delete(id); resolve(r); };
|
|
1280
|
+
const t = setTimeout(() => done(null), 3000);
|
|
1281
|
+
this.#inspectWait.set(id, done);
|
|
1282
|
+
ws.send(JSON.stringify({ id, inspect: { ...q, full } }));
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// Latest pointer position wins; at most one hover query in flight.
|
|
1287
|
+
#probing = false;
|
|
1288
|
+
#probeAgain = false;
|
|
1289
|
+
async #probeHover() {
|
|
1290
|
+
if (this.#probing) { this.#probeAgain = true; return; }
|
|
1291
|
+
this.#probing = true;
|
|
1292
|
+
try {
|
|
1293
|
+
do {
|
|
1294
|
+
this.#probeAgain = false;
|
|
1295
|
+
const p = this.#hoverPt;
|
|
1296
|
+
if (!p || !this.#annot) break;
|
|
1297
|
+
const r = await this.#inspect(p);
|
|
1298
|
+
if (this.#hoverPt && this.#annot) { this.#hover = r; this.#drawHl(); }
|
|
1299
|
+
} while (this.#probeAgain);
|
|
1300
|
+
} finally {
|
|
1301
|
+
this.#probing = false;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// The page changed (scroll, layout, HMR): follow the selected element and
|
|
1306
|
+
// re-check what is under the pointer.
|
|
1307
|
+
#tracking = false;
|
|
1308
|
+
async #annotFrame() {
|
|
1309
|
+
if (this.#hoverPt) this.#probeHover();
|
|
1310
|
+
const sel = this.#sel;
|
|
1311
|
+
if (!sel || this.#tracking) { this.#drawHl(); return; }
|
|
1312
|
+
this.#tracking = true;
|
|
1313
|
+
try {
|
|
1314
|
+
const r = await this.#inspect({ selector: sel.selector });
|
|
1315
|
+
if (r && this.#sel === sel) sel.rect = r.rect; // gone from the page: keep the last box
|
|
1316
|
+
} finally {
|
|
1317
|
+
this.#tracking = false;
|
|
1318
|
+
}
|
|
1319
|
+
this.#drawHl();
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
#select(t) {
|
|
1323
|
+
const { note, noteBox, noteHint, noteTarget } = this.$;
|
|
1324
|
+
this.#sel = t;
|
|
1325
|
+
note.hidden = !t;
|
|
1326
|
+
noteHint.textContent = "";
|
|
1327
|
+
if (t) {
|
|
1328
|
+
noteTarget.textContent = t.selector;
|
|
1329
|
+
noteTarget.title = t.selector;
|
|
1330
|
+
noteBox.focus();
|
|
1331
|
+
}
|
|
1332
|
+
this.#drawHl();
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// Frame (CSS px of the page viewport) -> position over the displayed image.
|
|
1336
|
+
#drawHl() {
|
|
1337
|
+
const { viewport, img, hlHover, hlSel, hlLabel, note } = this.$;
|
|
1338
|
+
const show = this.#annot && viewport.classList.contains("has") && this.#frame.w > 0;
|
|
1339
|
+
const vr = viewport.getBoundingClientRect();
|
|
1340
|
+
const ir = img.getBoundingClientRect();
|
|
1341
|
+
const sx = ir.width / this.#frame.w, sy = ir.height / this.#frame.h;
|
|
1342
|
+
const box = (r) => {
|
|
1343
|
+
if (!show || !r) return null;
|
|
1344
|
+
const x0 = Math.max(r.x, 0), y0 = Math.max(r.y, 0);
|
|
1345
|
+
const x1 = Math.min(r.x + r.w, this.#frame.w), y1 = Math.min(r.y + r.h, this.#frame.h);
|
|
1346
|
+
if (x1 <= x0 || y1 <= y0) return null;
|
|
1347
|
+
return { left: ir.left - vr.left + x0 * sx, top: ir.top - vr.top + y0 * sy, width: (x1 - x0) * sx, height: (y1 - y0) * sy };
|
|
1348
|
+
};
|
|
1349
|
+
const place = (el, b) => {
|
|
1350
|
+
el.classList.toggle("on", !!b);
|
|
1351
|
+
if (b) Object.assign(el.style, { left: `${b.left}px`, top: `${b.top}px`, width: `${b.width}px`, height: `${b.height}px` });
|
|
1352
|
+
return b;
|
|
1353
|
+
};
|
|
1354
|
+
const hb = place(hlHover, box(this.#hover?.rect));
|
|
1355
|
+
const sb = place(hlSel, box(this.#sel?.rect));
|
|
1356
|
+
|
|
1357
|
+
hlLabel.classList.toggle("on", !!hb);
|
|
1358
|
+
if (hb) {
|
|
1359
|
+
const r = this.#hover.rect;
|
|
1360
|
+
hlLabel.textContent = `${this.#hover.label} ${Math.round(r.w)}×${Math.round(r.h)}`;
|
|
1361
|
+
// above the box, else below it, else inside its top edge
|
|
1362
|
+
const top = hb.top >= 22 ? hb.top - 22 : hb.top + hb.height + 26 <= vr.height ? hb.top + hb.height + 4 : hb.top + 4;
|
|
1363
|
+
Object.assign(hlLabel.style, { left: `${hb.left + (top === hb.top + 4 ? 4 : 0)}px`, top: `${top}px` });
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// input below the selected element, above it if there is no room
|
|
1367
|
+
if (!note.hidden) {
|
|
1368
|
+
const W = note.offsetWidth, H = note.offsetHeight, pad = 8;
|
|
1369
|
+
const b = sb ?? { left: pad, top: pad, width: 0, height: 0 };
|
|
1370
|
+
let top = b.top + b.height + pad;
|
|
1371
|
+
if (top + H > vr.height - pad) top = b.top - H - pad;
|
|
1372
|
+
top = Math.max(pad, Math.min(top, vr.height - H - pad));
|
|
1373
|
+
const left = Math.max(pad, Math.min(b.left, vr.width - W - pad));
|
|
1374
|
+
Object.assign(note.style, { left: `${left}px`, top: `${top}px` });
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
#bindAnnotate() {
|
|
1379
|
+
const { img, annotBtn, note, noteBox, noteHint } = this.$;
|
|
1380
|
+
annotBtn.addEventListener("click", () => this.#setAnnotate(!this.#annot));
|
|
1381
|
+
img.addEventListener("mousemove", (e) => {
|
|
1382
|
+
if (!this.#annot || !this.#frame.w) return;
|
|
1383
|
+
this.#hoverPt = this.#point(e);
|
|
1384
|
+
this.#probeHover();
|
|
1385
|
+
});
|
|
1386
|
+
img.addEventListener("mouseleave", () => {
|
|
1387
|
+
if (!this.#annot) return;
|
|
1388
|
+
this.#hoverPt = null;
|
|
1389
|
+
this.#hover = null;
|
|
1390
|
+
this.#drawHl();
|
|
1391
|
+
});
|
|
1392
|
+
img.addEventListener("click", async (e) => {
|
|
1393
|
+
if (!this.#annot || !this.#frame.w) return;
|
|
1394
|
+
e.preventDefault();
|
|
1395
|
+
const t = await this.#inspect(this.#point(e), true);
|
|
1396
|
+
if (t && this.#annot) this.#select(t);
|
|
1397
|
+
});
|
|
1398
|
+
new ResizeObserver(() => this.#drawHl()).observe(img);
|
|
1399
|
+
note.addEventListener("submit", async (e) => {
|
|
1400
|
+
e.preventDefault();
|
|
1401
|
+
const text = noteBox.value.trim();
|
|
1402
|
+
const target = this.#sel;
|
|
1403
|
+
if (!text || !target) return;
|
|
1404
|
+
noteHint.textContent = "보내는 중…";
|
|
1405
|
+
const res = await this.send(text, "annotate", { target });
|
|
1406
|
+
if (!res.ok) { noteHint.textContent = `전송 실패: ${res.error}`; return; }
|
|
1407
|
+
noteBox.value = "";
|
|
1408
|
+
if (this.#sel === target) this.#select(null);
|
|
1409
|
+
});
|
|
1410
|
+
noteBox.addEventListener("keydown", (e) => {
|
|
1411
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); note.requestSubmit(); }
|
|
1412
|
+
if (e.key === "Escape") { e.preventDefault(); this.#select(null); }
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1100
1416
|
#renderScreenMeta(m) {
|
|
1101
1417
|
const { live, url, viewport } = this.$;
|
|
1102
1418
|
if (!m.connected) {
|
|
@@ -1112,6 +1428,7 @@ class AgentBridge extends HTMLElement {
|
|
|
1112
1428
|
url.textContent = m.title ? `${m.title} · ${m.url}` : m.url || "";
|
|
1113
1429
|
url.title = m.url || "";
|
|
1114
1430
|
}
|
|
1431
|
+
this.#drawHl();
|
|
1115
1432
|
this.#emit("agent-screen", m);
|
|
1116
1433
|
}
|
|
1117
1434
|
|
|
@@ -1248,7 +1565,7 @@ class AgentBridge extends HTMLElement {
|
|
|
1248
1565
|
return;
|
|
1249
1566
|
} else if (m.role === "assistant") {
|
|
1250
1567
|
wrap.className = "msg assistant";
|
|
1251
|
-
meta.textContent =
|
|
1568
|
+
meta.textContent = `에이전트${m.script ? ` · 대본 ${m.script}` : ""} · ${time}`;
|
|
1252
1569
|
if (this.#md) bubble.innerHTML = this.#md(m.text);
|
|
1253
1570
|
else { bubble.textContent = m.text; bubble.classList.add("plain"); }
|
|
1254
1571
|
} else if (m.role === "browser") {
|
|
@@ -1310,6 +1627,7 @@ class AgentBridge extends HTMLElement {
|
|
|
1310
1627
|
this.$.buttons.forEach((b) => (b.disabled = !canSend));
|
|
1311
1628
|
if (!s || !s.alive || s.status === "idle") this.#tools.clear();
|
|
1312
1629
|
this.#renderActivity();
|
|
1630
|
+
this.#renderBusy();
|
|
1313
1631
|
this.#emit("agent-status", s);
|
|
1314
1632
|
}
|
|
1315
1633
|
|
|
@@ -1318,7 +1636,7 @@ class AgentBridge extends HTMLElement {
|
|
|
1318
1636
|
const images = this.#images.map(({ type, data }) => ({ type, data }));
|
|
1319
1637
|
if (action === "message" && !text && !images.length) return;
|
|
1320
1638
|
this.$.buttons.forEach((b) => (b.disabled = true));
|
|
1321
|
-
const res = await this.send(text, action, images);
|
|
1639
|
+
const res = await this.send(text, action, { images: images.length ? images : undefined });
|
|
1322
1640
|
if (res.ok) {
|
|
1323
1641
|
this.$.box.value = "";
|
|
1324
1642
|
this.$.hint.textContent = "";
|