agello 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/package.json +1 -1
- package/src/screencast.ts +64 -1
- package/src/server.ts +43 -5
- package/web/agent-bridge.js +242 -23
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
- Live session status, tool activity, and prompts queued while the agent is busy (same order as the terminal)
|
|
7
7
|
- Conversation saved per pane in the browser, kept across reloads
|
|
8
8
|
- Optional live view of a `terminal-browser` screen, with an on/off switch to control it yourself
|
|
9
|
+
- Annotate: point at an element on that screen and send a request about it, with its CSS selector
|
|
9
10
|
- Embeddable `<agent-bridge>` web component
|
|
10
11
|
|
|
11
12
|
Currently supports **Claude Code** sessions.
|
|
@@ -59,6 +60,18 @@ Messages sent from the page arrive in the agent as:
|
|
|
59
60
|
|
|
60
61
|
The approve / reject buttons send `action=approve` / `action=reject`.
|
|
61
62
|
|
|
63
|
+
### Annotate
|
|
64
|
+
|
|
65
|
+
The `주석` button on the screen panel turns on annotate mode: hovering tints the element under the pointer (with its tag and size), clicking outlines it and opens an input next to it. The wheel still scrolls the page, and the outline follows the element as the page changes. The request arrives as:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
[browser] action=annotate make this red / and bolder
|
|
69
|
+
대상: #app > main > button:nth-of-type(2)
|
|
70
|
+
요소: button.primary "Save" · 위치 120,340 크기 96x32 · http://localhost:3000/
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Selector: nearest unique `id` / `data-testid` ancestor, then `tag:nth-of-type` steps (light DOM only). Coordinates are CSS px of the viewport. Off while presenting and while "내 조작" is on.
|
|
74
|
+
|
|
62
75
|
### Commands
|
|
63
76
|
|
|
64
77
|
| Command | Description |
|
package/package.json
CHANGED
package/src/screencast.ts
CHANGED
|
@@ -275,5 +275,68 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
|
|
|
275
275
|
return !!r && !r.exceptionDetails;
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
-
|
|
278
|
+
// Annotation: which element is at a point (hover / click) or where a
|
|
279
|
+
// selected element is now (by selector). Coordinates are CSS px of the
|
|
280
|
+
// visible viewport, same as the full screencast frame. Not while cropped.
|
|
281
|
+
async function inspect(q: InspectQuery): Promise<Inspected | null> {
|
|
282
|
+
if (clip) return null;
|
|
283
|
+
const r = await request("Runtime.evaluate", {
|
|
284
|
+
expression: `(${INSPECT_JS})(${JSON.stringify(q)})`,
|
|
285
|
+
returnByValue: true,
|
|
286
|
+
});
|
|
287
|
+
return r && !r.exceptionDetails ? (r.result?.value ?? null) : null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { handle, input, describe, setClip, evaluate, inspect };
|
|
279
291
|
}
|
|
292
|
+
|
|
293
|
+
export type InspectQuery = { x?: number; y?: number; selector?: string; full?: boolean };
|
|
294
|
+
export type Inspected = {
|
|
295
|
+
selector: string;
|
|
296
|
+
label: string; // tag#id.class, for the hover tooltip
|
|
297
|
+
rect: { x: number; y: number; w: number; h: number };
|
|
298
|
+
text?: string; // full only
|
|
299
|
+
url?: string; // full only
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// Runs in the page. Selector: nearest unique id / data-testid ancestor, then
|
|
303
|
+
// tag:nth-of-type steps down to the element (light DOM only).
|
|
304
|
+
const INSPECT_JS = String((q: InspectQuery) => {
|
|
305
|
+
let el: Element | null = null;
|
|
306
|
+
if (q.selector) {
|
|
307
|
+
try {
|
|
308
|
+
el = document.querySelector(q.selector);
|
|
309
|
+
} catch {}
|
|
310
|
+
} else el = document.elementFromPoint(q.x ?? 0, q.y ?? 0);
|
|
311
|
+
if (!el || el === document.documentElement) return null;
|
|
312
|
+
const esc = CSS.escape;
|
|
313
|
+
const unique = (s: string) => document.querySelectorAll(s).length === 1;
|
|
314
|
+
const step = (e: Element): [string, boolean] => {
|
|
315
|
+
if (e.id && unique(`#${esc(e.id)}`)) return [`#${esc(e.id)}`, true];
|
|
316
|
+
for (const a of ["data-testid", "data-test", "data-cy"]) {
|
|
317
|
+
const v = e.getAttribute(a);
|
|
318
|
+
if (v && unique(`[${a}="${esc(v)}"]`)) return [`[${a}="${esc(v)}"]`, true];
|
|
319
|
+
}
|
|
320
|
+
const same = e.parentElement ? [...e.parentElement.children].filter((c) => c.localName === e.localName) : [];
|
|
321
|
+
return [same.length > 1 ? `${e.localName}:nth-of-type(${same.indexOf(e) + 1})` : e.localName, false];
|
|
322
|
+
};
|
|
323
|
+
const parts: string[] = [];
|
|
324
|
+
for (let e: Element | null = el; e && e !== document.documentElement; e = e.parentElement) {
|
|
325
|
+
const [s, anchor] = step(e);
|
|
326
|
+
parts.unshift(s);
|
|
327
|
+
if (anchor || e === document.body) break;
|
|
328
|
+
}
|
|
329
|
+
const cls = [...el.classList].slice(0, 2).map((c) => `.${c}`).join("");
|
|
330
|
+
const r = el.getBoundingClientRect();
|
|
331
|
+
const out: any = {
|
|
332
|
+
selector: parts.join(" > "),
|
|
333
|
+
label: `${el.localName}${el.id ? `#${el.id}` : ""}${cls}`,
|
|
334
|
+
rect: { x: r.x, y: r.y, w: r.width, h: r.height },
|
|
335
|
+
};
|
|
336
|
+
if (q.full) {
|
|
337
|
+
const t = (el as HTMLInputElement).value || el.getAttribute("aria-label") || el.getAttribute("alt") || el.textContent || "";
|
|
338
|
+
out.text = t.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
339
|
+
out.url = location.href;
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
// GET /status session liveness (herdr agent get + pane get)
|
|
6
6
|
// GET /events SSE: status, chat messages, tool start/end (from transcript JSONL)
|
|
7
7
|
// POST /send {action, text} -> herdr agent prompt
|
|
8
|
+
// (action "annotate": + target {selector, label, rect, text?, url?} from inspect)
|
|
8
9
|
// GET /screen SSE: terminal-browser screen frames (CDP screencast)
|
|
9
|
-
// WS /input user control: CDP Input.* commands forwarded to the page
|
|
10
|
+
// WS /input user control: CDP Input.* commands forwarded to the page;
|
|
11
|
+
// {id, inspect: {x, y} | {selector}, full?} -> {id, result}: element at a point (annotation)
|
|
10
12
|
// GET /present presentation state
|
|
11
13
|
// POST /present {rect?: {x,y,w,h}} start (or move the crop), {stop: true} end
|
|
12
14
|
// ({stop: true, from: "viewer"}: ended from the page, the agent is told
|
|
@@ -19,7 +21,7 @@ import { homedir } from "node:os";
|
|
|
19
21
|
import { join } from "node:path";
|
|
20
22
|
import { runJson } from "./run.ts";
|
|
21
23
|
import { createPlayer, parsePos, parseScript, type PlayerInfo } from "./player.ts";
|
|
22
|
-
import { createScreencast, type Rect } from "./screencast.ts";
|
|
24
|
+
import { createScreencast, type Inspected, type Rect } from "./screencast.ts";
|
|
23
25
|
|
|
24
26
|
export type ServerOptions = {
|
|
25
27
|
port: number;
|
|
@@ -94,6 +96,21 @@ export function parseRect(r: any): Rect | null {
|
|
|
94
96
|
return { x, y, w, h };
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
// Annotation prompt: the viewer's request plus the element it points at, in
|
|
100
|
+
// 3 lines (request / selector / element summary).
|
|
101
|
+
export function formatAnnotation(text: string, t: any): string | null {
|
|
102
|
+
if (!t || typeof t.selector !== "string" || !t.selector || t.selector.length > 1000) return null;
|
|
103
|
+
const clean = (v: unknown, n: number) => String(v ?? "").replace(/\s+/g, " ").trim().slice(0, n);
|
|
104
|
+
const request = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).join(" / ");
|
|
105
|
+
const r = t.rect ?? {};
|
|
106
|
+
const [x, y, w, h] = [r.x, r.y, r.w, r.h].map((v) => Math.round(Number(v) || 0));
|
|
107
|
+
const label = clean(t.label, 120);
|
|
108
|
+
const snippet = clean(t.text, 80);
|
|
109
|
+
const url = clean(t.url, 300);
|
|
110
|
+
const info = [`요소: ${label}${snippet ? ` "${snippet}"` : ""}`, `위치 ${x},${y} 크기 ${w}x${h}`, url].filter(Boolean);
|
|
111
|
+
return [request, `대상: ${clean(t.selector, 1000)}`, info.join(" · ")].join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
97
114
|
export type Present = { on: boolean; rect?: Rect; since?: string; player?: PlayerInfo };
|
|
98
115
|
|
|
99
116
|
type Status =
|
|
@@ -465,9 +482,14 @@ export async function startServer(opts: ServerOptions) {
|
|
|
465
482
|
}
|
|
466
483
|
|
|
467
484
|
async function sendPrompt(req: Request): Promise<Response> {
|
|
468
|
-
const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string };
|
|
485
|
+
const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string; target?: Inspected };
|
|
469
486
|
const action = /^[\w-]{1,32}$/.test(data.action ?? "") ? data.action! : "message";
|
|
470
487
|
let text = data.text ?? "";
|
|
488
|
+
if (action === "annotate") {
|
|
489
|
+
const t = formatAnnotation(text, data.target);
|
|
490
|
+
if (!t || !text.trim()) return Response.json({ ok: false, error: "invalid_target" }, { status: 400 });
|
|
491
|
+
text = t;
|
|
492
|
+
}
|
|
471
493
|
// Raised hand: stop the script at once (no agent round trip) and tell the
|
|
472
494
|
// agent where it stopped.
|
|
473
495
|
if (action === "hand-raise") {
|
|
@@ -515,8 +537,24 @@ export async function startServer(opts: ServerOptions) {
|
|
|
515
537
|
port: opts.port,
|
|
516
538
|
idleTimeout: 0, // keep SSE open
|
|
517
539
|
websocket: {
|
|
518
|
-
message(
|
|
519
|
-
|
|
540
|
+
async message(ws, msg) {
|
|
541
|
+
const raw = String(msg);
|
|
542
|
+
let m: any;
|
|
543
|
+
try {
|
|
544
|
+
m = JSON.parse(raw);
|
|
545
|
+
} catch {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (m?.inspect) {
|
|
549
|
+
const q = m.inspect;
|
|
550
|
+
const query =
|
|
551
|
+
typeof q.selector === "string"
|
|
552
|
+
? { selector: q.selector.slice(0, 1000), full: !!q.full }
|
|
553
|
+
: { x: Number(q.x) || 0, y: Number(q.y) || 0, full: !!q.full };
|
|
554
|
+
ws.send(JSON.stringify({ id: m.id, result: await screen.inspect(query) }));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
screen.input(raw);
|
|
520
558
|
},
|
|
521
559
|
},
|
|
522
560
|
async fetch(req, server) {
|
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
|
|
@@ -41,7 +44,7 @@
|
|
|
41
44
|
// is working (e.g. answering a question), a small loader is shown.
|
|
42
45
|
//
|
|
43
46
|
// Methods
|
|
44
|
-
// el.send(text, action = 'message') -> Promise<{ok, error?}>
|
|
47
|
+
// el.send(text, action = 'message', extra) -> Promise<{ok, error?}> (extra: e.g. {target} for annotate)
|
|
45
48
|
// el.clearHistory() clear saved chat for the current pane
|
|
46
49
|
// el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
|
|
47
50
|
|
|
@@ -72,7 +75,7 @@ const SCREEN_REASONS = {
|
|
|
72
75
|
};
|
|
73
76
|
const ACTIONS = {
|
|
74
77
|
message: "메시지", approve: "승인", reject: "거절",
|
|
75
|
-
"present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림",
|
|
78
|
+
"present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림", annotate: "주석",
|
|
76
79
|
};
|
|
77
80
|
|
|
78
81
|
const CSS = `
|
|
@@ -175,6 +178,30 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
175
178
|
@media (prefers-reduced-motion: reduce) { .control-btn .track, .control-btn .knob { transition: none; } }
|
|
176
179
|
.placeholder { font-size: 13px; color: var(--_muted); text-align: center; }
|
|
177
180
|
|
|
181
|
+
/* annotate: hover tint, selected outline, request input next to the element */
|
|
182
|
+
.annot-btn { flex: none; font-size: 12px; padding: 2px 10px; border-radius: 999px; }
|
|
183
|
+
.annot-btn[aria-pressed="true"] { background: var(--_accent); border-color: var(--_accent); color: #fff; }
|
|
184
|
+
.viewport.annot img { cursor: crosshair; }
|
|
185
|
+
.hl { position: absolute; z-index: 3; pointer-events: none; display: none; border-radius: 2px; }
|
|
186
|
+
.hl.on { display: block; }
|
|
187
|
+
.hl.hover { background: color-mix(in srgb, var(--_accent) 22%, transparent);
|
|
188
|
+
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--_accent) 55%, transparent); }
|
|
189
|
+
.hl.sel { box-shadow: 0 0 0 2px var(--_accent), 0 0 0 5px color-mix(in srgb, var(--_accent) 25%, transparent); }
|
|
190
|
+
.hl-label { position: absolute; z-index: 4; pointer-events: none; display: none; max-width: 60%;
|
|
191
|
+
padding: 2px 7px; border-radius: 5px; font: 11px/1.5 ui-monospace, Menlo, monospace; color: #fff;
|
|
192
|
+
background: rgba(20, 20, 22, .85); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
193
|
+
.hl-label.on { display: block; }
|
|
194
|
+
.note { position: absolute; z-index: 5; width: min(340px, calc(100% - 16px)); padding: 10px; border-radius: 12px;
|
|
195
|
+
background: var(--_panel); border: 1px solid var(--_line); box-shadow: 0 8px 28px rgba(0,0,0,.22);
|
|
196
|
+
animation: capIn .18s ease; }
|
|
197
|
+
.note[hidden] { display: none; }
|
|
198
|
+
.note .note-target { font: 11px/1.4 ui-monospace, Menlo, monospace; color: var(--_muted); margin-bottom: 6px;
|
|
199
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
200
|
+
.note textarea { min-height: 52px; font-size: 14px; }
|
|
201
|
+
.note .note-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
|
202
|
+
.note .note-hint { flex: 1; font-size: 12px; color: var(--_muted); }
|
|
203
|
+
@media (prefers-reduced-motion: reduce) { .note { animation: none; } }
|
|
204
|
+
|
|
178
205
|
/* presentation mode: the screen panel covers the window, chat stays hidden underneath.
|
|
179
206
|
container-type would make the host the containing block of the fixed layer, so drop it. */
|
|
180
207
|
:host([presenting]) { container-type: normal; }
|
|
@@ -336,9 +363,12 @@ const TEMPLATE = `
|
|
|
336
363
|
</section>
|
|
337
364
|
<aside class="screen" part="screen">
|
|
338
365
|
<div class="screen-bar"><span class="live" hidden>에이전트 조작 중</span><span class="url"></span>
|
|
366
|
+
<button class="annot-btn" type="button" aria-pressed="false" title="화면의 요소를 골라 에이전트에게 요청 (선택자와 함께 전달)">주석</button>
|
|
339
367
|
<button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
|
|
340
368
|
<span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
|
|
341
|
-
<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="
|
|
369
|
+
<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>
|
|
370
|
+
<form class="note" part="note" hidden><div class="note-target"></div><textarea aria-label="선택한 요소에 대한 요청" placeholder="이 요소에 대한 요청 (Enter 전송, Esc 취소)"></textarea>
|
|
371
|
+
<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>
|
|
342
372
|
<div class="busy" part="busy" role="status"><span class="spinner"></span><span>답변 준비 중</span></div>
|
|
343
373
|
<div class="hand-wrap">
|
|
344
374
|
<form class="ask" part="ask" hidden><textarea aria-label="에이전트에게 질문" placeholder="에이전트에게 질문 (Enter 전송, Esc 닫기)"></textarea>
|
|
@@ -358,7 +388,14 @@ class AgentBridge extends HTMLElement {
|
|
|
358
388
|
#root = this.attachShadow({ mode: "open" });
|
|
359
389
|
#es = null;
|
|
360
390
|
#screenEs = null;
|
|
361
|
-
#
|
|
391
|
+
#ws = null; // /input: control input and annotate inspect queries
|
|
392
|
+
#control = false;
|
|
393
|
+
#annot = false;
|
|
394
|
+
#hover = null; // inspected element under the pointer (annotate)
|
|
395
|
+
#hoverPt = null;
|
|
396
|
+
#sel = null; // selected element (annotate)
|
|
397
|
+
#inspectId = 0;
|
|
398
|
+
#inspectWait = new Map();
|
|
362
399
|
#frame = { w: 0, h: 0 };
|
|
363
400
|
#moveQueued = null;
|
|
364
401
|
#status = null;
|
|
@@ -383,6 +420,8 @@ class AgentBridge extends HTMLElement {
|
|
|
383
420
|
controlBtn: q(".control-btn"), kbd: q(".kbd"), clearBtn: q(".clear-btn"),
|
|
384
421
|
body: q(".body"), screen: q(".screen"), captions: q(".captions"),
|
|
385
422
|
busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
|
|
423
|
+
annotBtn: q(".annot-btn"), hlHover: q(".hl.hover"), hlSel: q(".hl.sel"), hlLabel: q(".hl-label"),
|
|
424
|
+
note: q(".note"), noteBox: q(".note textarea"), noteHint: q(".note-hint"), noteTarget: q(".note-target"),
|
|
386
425
|
};
|
|
387
426
|
this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
|
|
388
427
|
this.#bindGrip();
|
|
@@ -394,6 +433,7 @@ class AgentBridge extends HTMLElement {
|
|
|
394
433
|
});
|
|
395
434
|
loadMarkdown().then((md) => (this.#md = md));
|
|
396
435
|
this.#bindControl();
|
|
436
|
+
this.#bindAnnotate();
|
|
397
437
|
this.#bindHand();
|
|
398
438
|
this.$.clearBtn.addEventListener("click", () => this.clearHistory());
|
|
399
439
|
}
|
|
@@ -413,6 +453,7 @@ class AgentBridge extends HTMLElement {
|
|
|
413
453
|
this.#screenEs?.close();
|
|
414
454
|
this.#screenEs = null;
|
|
415
455
|
this.#setControl(false);
|
|
456
|
+
this.#setAnnotate(false);
|
|
416
457
|
}
|
|
417
458
|
attributeChangedCallback(name) {
|
|
418
459
|
if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "agello";
|
|
@@ -420,12 +461,12 @@ class AgentBridge extends HTMLElement {
|
|
|
420
461
|
if (name === "screen" && this.isConnected) this.#syncScreen();
|
|
421
462
|
}
|
|
422
463
|
|
|
423
|
-
async send(text, action = "message") {
|
|
464
|
+
async send(text, action = "message", extra = {}) {
|
|
424
465
|
try {
|
|
425
466
|
const res = await fetch(`${this.server}/send`, {
|
|
426
467
|
method: "POST",
|
|
427
468
|
headers: { "Content-Type": "application/json" },
|
|
428
|
-
body: JSON.stringify({ action, text }),
|
|
469
|
+
body: JSON.stringify({ ...extra, action, text }),
|
|
429
470
|
});
|
|
430
471
|
return await res.json();
|
|
431
472
|
} catch {
|
|
@@ -491,7 +532,7 @@ class AgentBridge extends HTMLElement {
|
|
|
491
532
|
|
|
492
533
|
#syncScreen() {
|
|
493
534
|
const want = this.hasAttribute("screen") || this.#present.on;
|
|
494
|
-
if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); return; }
|
|
535
|
+
if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); this.#setAnnotate(false); return; }
|
|
495
536
|
if (this.#screenEs) return;
|
|
496
537
|
const es = (this.#screenEs = new EventSource(`${this.server}/screen`));
|
|
497
538
|
es.addEventListener("meta", (e) => this.#renderScreenMeta(JSON.parse(e.data)));
|
|
@@ -500,6 +541,7 @@ class AgentBridge extends HTMLElement {
|
|
|
500
541
|
this.#frame = { w: f.w, h: f.h };
|
|
501
542
|
this.$.img.src = `data:image/jpeg;base64,${f.data}`;
|
|
502
543
|
this.$.viewport.classList.add("has");
|
|
544
|
+
if (this.#annot) this.#annotFrame();
|
|
503
545
|
});
|
|
504
546
|
es.onerror = () => this.#renderScreenMeta({ connected: false });
|
|
505
547
|
}
|
|
@@ -513,7 +555,7 @@ class AgentBridge extends HTMLElement {
|
|
|
513
555
|
this.#emit("agent-present", this.#present);
|
|
514
556
|
this.#renderBusy();
|
|
515
557
|
if (on === was) return;
|
|
516
|
-
if (on) this.#setControl(false);
|
|
558
|
+
if (on) { this.#setControl(false); this.#setAnnotate(false); }
|
|
517
559
|
else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#handUp = false; }
|
|
518
560
|
this.#syncScreen();
|
|
519
561
|
// FLIP: grow from (or shrink back to) the side panel
|
|
@@ -652,24 +694,43 @@ class AgentBridge extends HTMLElement {
|
|
|
652
694
|
|
|
653
695
|
// ---------- user control ----------
|
|
654
696
|
|
|
655
|
-
|
|
656
|
-
|
|
697
|
+
// One socket for both modes; open while control or annotate is on.
|
|
698
|
+
#syncWs() {
|
|
699
|
+
const want = this.#control || this.#annot;
|
|
700
|
+
if (want && !this.#ws) {
|
|
657
701
|
const ws = new WebSocket(`${this.server.replace(/^http/, "ws")}/input`);
|
|
658
|
-
ws.
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
702
|
+
ws.onmessage = (e) => {
|
|
703
|
+
let m;
|
|
704
|
+
try { m = JSON.parse(e.data); } catch { return; }
|
|
705
|
+
this.#inspectWait.get(m.id)?.(m.result ?? null);
|
|
706
|
+
};
|
|
707
|
+
ws.onclose = () => {
|
|
708
|
+
if (this.#ws !== ws) return;
|
|
709
|
+
this.#ws = null;
|
|
710
|
+
this.#setControl(false);
|
|
711
|
+
this.#setAnnotate(false);
|
|
712
|
+
};
|
|
713
|
+
this.#ws = ws;
|
|
714
|
+
} else if (!want && this.#ws) {
|
|
715
|
+
const ws = this.#ws;
|
|
716
|
+
this.#ws = null;
|
|
663
717
|
ws.close();
|
|
664
718
|
}
|
|
665
|
-
this
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
719
|
+
if (!this.#ws) { this.#inspectWait.forEach((r) => r(null)); this.#inspectWait.clear(); }
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
#setControl(on) {
|
|
723
|
+
this.#control = !!on && !this.#present.on;
|
|
724
|
+
if (this.#control) this.#setAnnotate(false);
|
|
725
|
+
this.#syncWs();
|
|
726
|
+
this.$.controlBtn.setAttribute("aria-checked", String(this.#control));
|
|
727
|
+
this.$.controlBtn.querySelector(".state-label").textContent = this.#control ? "ON" : "OFF";
|
|
728
|
+
this.$.viewport.classList.toggle("control", this.#control);
|
|
729
|
+
if (this.#control) this.$.kbd.focus();
|
|
669
730
|
}
|
|
670
731
|
|
|
671
732
|
#cdp(method, params) {
|
|
672
|
-
const ws = this.#
|
|
733
|
+
const ws = this.#ws;
|
|
673
734
|
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ method, params }));
|
|
674
735
|
}
|
|
675
736
|
|
|
@@ -683,11 +744,11 @@ class AgentBridge extends HTMLElement {
|
|
|
683
744
|
|
|
684
745
|
#bindControl() {
|
|
685
746
|
const { img, kbd, controlBtn } = this.$;
|
|
686
|
-
const on = () =>
|
|
747
|
+
const on = () => this.#control && this.#frame.w > 0;
|
|
687
748
|
const mods = (e) => (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
|
|
688
749
|
const BUTTONS = ["left", "middle", "right"];
|
|
689
750
|
|
|
690
|
-
controlBtn.addEventListener("click", () =>
|
|
751
|
+
controlBtn.addEventListener("click", () => this.#setControl(!this.#control));
|
|
691
752
|
|
|
692
753
|
img.addEventListener("mousedown", (e) => {
|
|
693
754
|
if (!on()) return;
|
|
@@ -715,7 +776,7 @@ class AgentBridge extends HTMLElement {
|
|
|
715
776
|
});
|
|
716
777
|
});
|
|
717
778
|
img.addEventListener("wheel", (e) => {
|
|
718
|
-
if (!on()) return;
|
|
779
|
+
if (!on() && !(this.#annot && this.#frame.w > 0)) return; // annotate: scroll to reach the element
|
|
719
780
|
e.preventDefault();
|
|
720
781
|
this.#cdp("Input.dispatchMouseEvent", { type: "mouseWheel", ...this.#point(e), deltaX: e.deltaX, deltaY: e.deltaY,
|
|
721
782
|
modifiers: mods(e) });
|
|
@@ -753,6 +814,163 @@ class AgentBridge extends HTMLElement {
|
|
|
753
814
|
});
|
|
754
815
|
}
|
|
755
816
|
|
|
817
|
+
// ---------- annotate ----------
|
|
818
|
+
|
|
819
|
+
#setAnnotate(on) {
|
|
820
|
+
on = !!on && !this.#present.on;
|
|
821
|
+
if (on === this.#annot) return;
|
|
822
|
+
this.#annot = on;
|
|
823
|
+
if (on) this.#setControl(false);
|
|
824
|
+
else { this.#hover = null; this.#hoverPt = null; this.#select(null); }
|
|
825
|
+
this.#syncWs();
|
|
826
|
+
this.$.annotBtn.setAttribute("aria-pressed", String(on));
|
|
827
|
+
this.$.viewport.classList.toggle("annot", on);
|
|
828
|
+
this.#drawHl();
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Element at a frame point ({x, y}) or by selector; null if unavailable.
|
|
832
|
+
#inspect(q, full = false) {
|
|
833
|
+
const ws = this.#ws;
|
|
834
|
+
if (ws?.readyState !== WebSocket.OPEN) return Promise.resolve(null);
|
|
835
|
+
const id = ++this.#inspectId;
|
|
836
|
+
return new Promise((resolve) => {
|
|
837
|
+
const done = (r) => { clearTimeout(t); this.#inspectWait.delete(id); resolve(r); };
|
|
838
|
+
const t = setTimeout(() => done(null), 3000);
|
|
839
|
+
this.#inspectWait.set(id, done);
|
|
840
|
+
ws.send(JSON.stringify({ id, inspect: { ...q, full } }));
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Latest pointer position wins; at most one hover query in flight.
|
|
845
|
+
#probing = false;
|
|
846
|
+
#probeAgain = false;
|
|
847
|
+
async #probeHover() {
|
|
848
|
+
if (this.#probing) { this.#probeAgain = true; return; }
|
|
849
|
+
this.#probing = true;
|
|
850
|
+
try {
|
|
851
|
+
do {
|
|
852
|
+
this.#probeAgain = false;
|
|
853
|
+
const p = this.#hoverPt;
|
|
854
|
+
if (!p || !this.#annot) break;
|
|
855
|
+
const r = await this.#inspect(p);
|
|
856
|
+
if (this.#hoverPt && this.#annot) { this.#hover = r; this.#drawHl(); }
|
|
857
|
+
} while (this.#probeAgain);
|
|
858
|
+
} finally {
|
|
859
|
+
this.#probing = false;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// The page changed (scroll, layout, HMR): follow the selected element and
|
|
864
|
+
// re-check what is under the pointer.
|
|
865
|
+
#tracking = false;
|
|
866
|
+
async #annotFrame() {
|
|
867
|
+
if (this.#hoverPt) this.#probeHover();
|
|
868
|
+
const sel = this.#sel;
|
|
869
|
+
if (!sel || this.#tracking) { this.#drawHl(); return; }
|
|
870
|
+
this.#tracking = true;
|
|
871
|
+
try {
|
|
872
|
+
const r = await this.#inspect({ selector: sel.selector });
|
|
873
|
+
if (r && this.#sel === sel) sel.rect = r.rect; // gone from the page: keep the last box
|
|
874
|
+
} finally {
|
|
875
|
+
this.#tracking = false;
|
|
876
|
+
}
|
|
877
|
+
this.#drawHl();
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
#select(t) {
|
|
881
|
+
const { note, noteBox, noteHint, noteTarget } = this.$;
|
|
882
|
+
this.#sel = t;
|
|
883
|
+
note.hidden = !t;
|
|
884
|
+
noteHint.textContent = "";
|
|
885
|
+
if (t) {
|
|
886
|
+
noteTarget.textContent = t.selector;
|
|
887
|
+
noteTarget.title = t.selector;
|
|
888
|
+
noteBox.focus();
|
|
889
|
+
}
|
|
890
|
+
this.#drawHl();
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Frame (CSS px of the page viewport) -> position over the displayed image.
|
|
894
|
+
#drawHl() {
|
|
895
|
+
const { viewport, img, hlHover, hlSel, hlLabel, note } = this.$;
|
|
896
|
+
const show = this.#annot && viewport.classList.contains("has") && this.#frame.w > 0;
|
|
897
|
+
const vr = viewport.getBoundingClientRect();
|
|
898
|
+
const ir = img.getBoundingClientRect();
|
|
899
|
+
const sx = ir.width / this.#frame.w, sy = ir.height / this.#frame.h;
|
|
900
|
+
const box = (r) => {
|
|
901
|
+
if (!show || !r) return null;
|
|
902
|
+
const x0 = Math.max(r.x, 0), y0 = Math.max(r.y, 0);
|
|
903
|
+
const x1 = Math.min(r.x + r.w, this.#frame.w), y1 = Math.min(r.y + r.h, this.#frame.h);
|
|
904
|
+
if (x1 <= x0 || y1 <= y0) return null;
|
|
905
|
+
return { left: ir.left - vr.left + x0 * sx, top: ir.top - vr.top + y0 * sy, width: (x1 - x0) * sx, height: (y1 - y0) * sy };
|
|
906
|
+
};
|
|
907
|
+
const place = (el, b) => {
|
|
908
|
+
el.classList.toggle("on", !!b);
|
|
909
|
+
if (b) Object.assign(el.style, { left: `${b.left}px`, top: `${b.top}px`, width: `${b.width}px`, height: `${b.height}px` });
|
|
910
|
+
return b;
|
|
911
|
+
};
|
|
912
|
+
const hb = place(hlHover, box(this.#hover?.rect));
|
|
913
|
+
const sb = place(hlSel, box(this.#sel?.rect));
|
|
914
|
+
|
|
915
|
+
hlLabel.classList.toggle("on", !!hb);
|
|
916
|
+
if (hb) {
|
|
917
|
+
const r = this.#hover.rect;
|
|
918
|
+
hlLabel.textContent = `${this.#hover.label} ${Math.round(r.w)}×${Math.round(r.h)}`;
|
|
919
|
+
// above the box, else below it, else inside its top edge
|
|
920
|
+
const top = hb.top >= 22 ? hb.top - 22 : hb.top + hb.height + 26 <= vr.height ? hb.top + hb.height + 4 : hb.top + 4;
|
|
921
|
+
Object.assign(hlLabel.style, { left: `${hb.left + (top === hb.top + 4 ? 4 : 0)}px`, top: `${top}px` });
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// input below the selected element, above it if there is no room
|
|
925
|
+
if (!note.hidden) {
|
|
926
|
+
const W = note.offsetWidth, H = note.offsetHeight, pad = 8;
|
|
927
|
+
const b = sb ?? { left: pad, top: pad, width: 0, height: 0 };
|
|
928
|
+
let top = b.top + b.height + pad;
|
|
929
|
+
if (top + H > vr.height - pad) top = b.top - H - pad;
|
|
930
|
+
top = Math.max(pad, Math.min(top, vr.height - H - pad));
|
|
931
|
+
const left = Math.max(pad, Math.min(b.left, vr.width - W - pad));
|
|
932
|
+
Object.assign(note.style, { left: `${left}px`, top: `${top}px` });
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
#bindAnnotate() {
|
|
937
|
+
const { img, annotBtn, note, noteBox, noteHint } = this.$;
|
|
938
|
+
annotBtn.addEventListener("click", () => this.#setAnnotate(!this.#annot));
|
|
939
|
+
img.addEventListener("mousemove", (e) => {
|
|
940
|
+
if (!this.#annot || !this.#frame.w) return;
|
|
941
|
+
this.#hoverPt = this.#point(e);
|
|
942
|
+
this.#probeHover();
|
|
943
|
+
});
|
|
944
|
+
img.addEventListener("mouseleave", () => {
|
|
945
|
+
if (!this.#annot) return;
|
|
946
|
+
this.#hoverPt = null;
|
|
947
|
+
this.#hover = null;
|
|
948
|
+
this.#drawHl();
|
|
949
|
+
});
|
|
950
|
+
img.addEventListener("click", async (e) => {
|
|
951
|
+
if (!this.#annot || !this.#frame.w) return;
|
|
952
|
+
e.preventDefault();
|
|
953
|
+
const t = await this.#inspect(this.#point(e), true);
|
|
954
|
+
if (t && this.#annot) this.#select(t);
|
|
955
|
+
});
|
|
956
|
+
new ResizeObserver(() => this.#drawHl()).observe(img);
|
|
957
|
+
note.addEventListener("submit", async (e) => {
|
|
958
|
+
e.preventDefault();
|
|
959
|
+
const text = noteBox.value.trim();
|
|
960
|
+
const target = this.#sel;
|
|
961
|
+
if (!text || !target) return;
|
|
962
|
+
noteHint.textContent = "보내는 중…";
|
|
963
|
+
const res = await this.send(text, "annotate", { target });
|
|
964
|
+
if (!res.ok) { noteHint.textContent = `전송 실패: ${res.error}`; return; }
|
|
965
|
+
noteBox.value = "";
|
|
966
|
+
if (this.#sel === target) this.#select(null);
|
|
967
|
+
});
|
|
968
|
+
noteBox.addEventListener("keydown", (e) => {
|
|
969
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); note.requestSubmit(); }
|
|
970
|
+
if (e.key === "Escape") { e.preventDefault(); this.#select(null); }
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
756
974
|
#renderScreenMeta(m) {
|
|
757
975
|
const { live, url, viewport } = this.$;
|
|
758
976
|
if (!m.connected) {
|
|
@@ -768,6 +986,7 @@ class AgentBridge extends HTMLElement {
|
|
|
768
986
|
url.textContent = m.title ? `${m.title} · ${m.url}` : m.url || "";
|
|
769
987
|
url.title = m.url || "";
|
|
770
988
|
}
|
|
989
|
+
this.#drawHl();
|
|
771
990
|
this.#emit("agent-screen", m);
|
|
772
991
|
}
|
|
773
992
|
|