agello 0.2.0 → 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 +32 -0
- package/bin/agello.ts +68 -0
- package/package.json +1 -1
- package/src/player.ts +234 -0
- package/src/screencast.ts +71 -1
- package/src/server.ts +127 -14
- package/web/agent-bridge.js +268 -28
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 |
|
|
@@ -69,6 +82,8 @@ The approve / reject buttons send `action=approve` / `action=reject`.
|
|
|
69
82
|
| `agello open [--port N \| --pane ID]` | Open the page (default: this pane's server) |
|
|
70
83
|
| `agello present [x,y,w,h] [--port N \| --pane ID]` | Presentation mode (see below) |
|
|
71
84
|
| `agello present stop` | End presentation mode |
|
|
85
|
+
| `agello script load <file> \| show` | Load / show a presentation script |
|
|
86
|
+
| `agello present resume \| pause \| goto <n[.m]>` | Play the script from where it stopped / stop it / move to a step |
|
|
72
87
|
|
|
73
88
|
`start` options: `--pane <id>`, `--port <n>` (default: first free from 8765), `--session <id>`, `--browser <terminal-browser key>`, `--allow-origin <origin>` (repeatable), `--open`, `--foreground`.
|
|
74
89
|
|
|
@@ -87,6 +102,23 @@ State and logs: `~/.local/state/agello/<port>.json`, `<port>.log`.
|
|
|
87
102
|
|
|
88
103
|
Run `present` again to move the crop. User control is off while presenting.
|
|
89
104
|
|
|
105
|
+
#### Scripts
|
|
106
|
+
|
|
107
|
+
Write the talk ahead so each line appears the moment its screen does:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{ "rect": "28,157,688,477",
|
|
111
|
+
"steps": [
|
|
112
|
+
{ "go": "#1", "say": ["First line", "Second line"] },
|
|
113
|
+
{ "go": "#2", "say": ["..."], "hold": 6 } ] }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- step: `go` brings the screen there (`#n` sets `location.hash`, anything else is JS run in the page; agello knows nothing about the deck), `say` lines are one bubble each, `hold` seconds a line stays on screen (default from length, 10 to 20s; never below 10s). Pacing: screen change, 1s, line, fade out, 0.3s, next line; after a step's last line 0.7s before the next screen
|
|
117
|
+
- `present resume` plays from where it stopped (starts presentation mode with the script's `rect`); it first re-runs the current step's `go`, so the agent may move the screen freely while answering
|
|
118
|
+
- raising a hand pauses at once; the agent gets `action=hand-raise` with the position (`마지막 표시 2.1 · 다음 2.2`)
|
|
119
|
+
- `script load` again after editing keeps the position; `present goto 2.2` then `present resume` replays from a fixed line
|
|
120
|
+
- at the end the agent gets `action=present-done`; while the script is paused and the agent is working, the page shows a small loader
|
|
121
|
+
|
|
90
122
|
## Embed
|
|
91
123
|
|
|
92
124
|
```html
|
package/bin/agello.ts
CHANGED
|
@@ -27,6 +27,13 @@ Usage:
|
|
|
27
27
|
whole screen), with agent replies as bubbles that fade after 10s.
|
|
28
28
|
Run again to move the crop.
|
|
29
29
|
${NAME} present stop [--port N|--pane ID] End presentation mode (bubbles stay in the chat)
|
|
30
|
+
${NAME} script load <file.json> Load a presentation script (keeps the current position):
|
|
31
|
+
{"rect": "x,y,w,h", "steps": [{"go": "#1", "say": ["line", ...], "hold": 6}]}
|
|
32
|
+
go: "#n" sets location.hash, otherwise JS run in the page; one line = one bubble
|
|
33
|
+
${NAME} script show Script with positions (step.line) and player state
|
|
34
|
+
${NAME} present resume Play the script from where it stopped (starts presentation mode)
|
|
35
|
+
${NAME} present pause Stop the script (raising a hand on the page also pauses)
|
|
36
|
+
${NAME} present goto <n[.m]> Move to step n (line m) and show its screen
|
|
30
37
|
${NAME} help | --version
|
|
31
38
|
|
|
32
39
|
start options:
|
|
@@ -284,6 +291,56 @@ async function cmdOpen(argv: string[]) {
|
|
|
284
291
|
console.log(inst.url);
|
|
285
292
|
}
|
|
286
293
|
|
|
294
|
+
async function api(url: string, path: string, body?: object): Promise<any> {
|
|
295
|
+
try {
|
|
296
|
+
const r = await fetch(`${url}${path}`, {
|
|
297
|
+
method: body ? "POST" : "GET",
|
|
298
|
+
headers: { "Content-Type": "application/json" },
|
|
299
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
300
|
+
signal: AbortSignal.timeout(8000),
|
|
301
|
+
});
|
|
302
|
+
return await r.json();
|
|
303
|
+
} catch {
|
|
304
|
+
return fail(`server unreachable: ${url}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const playerLine = (p: any) =>
|
|
309
|
+
`script: ${p.state}${p.last ? ` last=${p.last}` : ""}${p.next ? ` next=${p.next}` : ""} steps=${p.steps}`;
|
|
310
|
+
|
|
311
|
+
async function cmdScript(argv: string[]) {
|
|
312
|
+
const { values: o, positionals } = parseArgs({
|
|
313
|
+
args: argv,
|
|
314
|
+
options: { port: { type: "string" }, pane: { type: "string" } },
|
|
315
|
+
allowPositionals: true,
|
|
316
|
+
});
|
|
317
|
+
const [sub, file] = positionals;
|
|
318
|
+
const inst = await resolveTarget(o);
|
|
319
|
+
if (sub === "load") {
|
|
320
|
+
if (!file) fail("usage: script load <file.json>");
|
|
321
|
+
let script: unknown;
|
|
322
|
+
try {
|
|
323
|
+
script = await Bun.file(file!).json();
|
|
324
|
+
} catch (e) {
|
|
325
|
+
fail(`cannot read ${file}: ${(e as Error).message}`);
|
|
326
|
+
}
|
|
327
|
+
const res = await api(inst.url, "/script", { script });
|
|
328
|
+
if (!res.ok) fail(res.error);
|
|
329
|
+
console.log(playerLine(res.player));
|
|
330
|
+
} else if (sub === "show" || !sub) {
|
|
331
|
+
const res = await api(inst.url, "/script");
|
|
332
|
+
console.log(playerLine(res.player));
|
|
333
|
+
for (const [i, st] of (res.script?.steps ?? []).entries()) {
|
|
334
|
+
console.log(`${i + 1}${st.go ? ` go=${st.go}` : ""}${st.hold ? ` hold=${st.hold}s` : ""}`);
|
|
335
|
+
st.say.forEach((l: string, j: number) => {
|
|
336
|
+
const at = `${i + 1}.${j + 1}`;
|
|
337
|
+
const mark = at === res.player.next ? ">" : at === res.player.last ? "*" : " ";
|
|
338
|
+
console.log(` ${mark} ${at} ${l}`);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
} else fail(`unknown script command: ${sub}`);
|
|
342
|
+
}
|
|
343
|
+
|
|
287
344
|
async function cmdPresent(argv: string[]) {
|
|
288
345
|
const { values: o, positionals } = parseArgs({
|
|
289
346
|
args: argv,
|
|
@@ -291,6 +348,14 @@ async function cmdPresent(argv: string[]) {
|
|
|
291
348
|
allowPositionals: true,
|
|
292
349
|
});
|
|
293
350
|
const inst = await resolveTarget(o);
|
|
351
|
+
const [sub, at] = positionals;
|
|
352
|
+
if (sub === "resume" || sub === "pause" || sub === "goto") {
|
|
353
|
+
if (sub === "goto" && !at) fail("usage: present goto <n[.m]>");
|
|
354
|
+
const res = await api(inst.url, "/player", { cmd: sub, at });
|
|
355
|
+
if (!res.ok) fail(`${res.error}${res.player ? ` (${playerLine(res.player)})` : ""}`);
|
|
356
|
+
console.log(playerLine(res.player));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
294
359
|
const arg = positionals.join(",");
|
|
295
360
|
let body: object;
|
|
296
361
|
if (arg === "stop") body = { stop: true };
|
|
@@ -337,6 +402,9 @@ switch (cmd) {
|
|
|
337
402
|
case "present":
|
|
338
403
|
await cmdPresent(rest);
|
|
339
404
|
break;
|
|
405
|
+
case "script":
|
|
406
|
+
await cmdScript(rest);
|
|
407
|
+
break;
|
|
340
408
|
case "--version":
|
|
341
409
|
case "-v":
|
|
342
410
|
console.log(pkg.version);
|
package/package.json
CHANGED
package/src/player.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Presentation script player.
|
|
2
|
+
//
|
|
3
|
+
// A script is a list of steps. Each step has an optional `go` (how to bring the
|
|
4
|
+
// screen to that step: "#3" sets location.hash, anything else is evaluated as
|
|
5
|
+
// JS in the page; written by the agent, agello knows nothing about the deck)
|
|
6
|
+
// and lines (`say`), each shown as one bubble.
|
|
7
|
+
//
|
|
8
|
+
// The player owns order and timing: entering a step runs its `go`, then every
|
|
9
|
+
// line is sent and held for its reading time (see PACE). pause() stops at once and keeps
|
|
10
|
+
// the position, resume() re-runs the current step's `go` (the agent may have
|
|
11
|
+
// moved the screen meanwhile) and continues from the next unshown line.
|
|
12
|
+
// goto() moves the position and shows that step without playing.
|
|
13
|
+
//
|
|
14
|
+
// Positions are "step.line", 1-based, in everything that leaves this module.
|
|
15
|
+
|
|
16
|
+
import type { Rect } from "./screencast.ts";
|
|
17
|
+
|
|
18
|
+
export type Step = { go?: string; say: string[]; hold?: number };
|
|
19
|
+
export type Script = { rect?: Rect; steps: Step[] };
|
|
20
|
+
export type PlayerInfo = {
|
|
21
|
+
state: "empty" | "ready" | "playing" | "paused" | "done";
|
|
22
|
+
next?: string; // next line to show ("3.2")
|
|
23
|
+
last?: string; // last line shown
|
|
24
|
+
steps: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Pacing: the audience looks at a new screen first, then reads and thinks.
|
|
28
|
+
// go -> 1s -> line (shown >= 10s) -> fades out -> 0.3s -> next line
|
|
29
|
+
// last line -> fades out -> 0.7s -> next step's go
|
|
30
|
+
const MIN_HOLD = 10;
|
|
31
|
+
const MAX_HOLD = 20;
|
|
32
|
+
export const PACE = { afterGo: 1000, fade: 500, betweenLines: 300, afterStep: 700 };
|
|
33
|
+
|
|
34
|
+
// How long one line stays on screen (s): at least 10s, longer text a bit more
|
|
35
|
+
// (about 8 chars/s past the first ~50 chars), at most 20s. `hold` can only
|
|
36
|
+
// lengthen it.
|
|
37
|
+
export function lineHold(text: string, hold?: number): number {
|
|
38
|
+
const auto = Math.min(MAX_HOLD, Math.max(MIN_HOLD, 4 + [...text].length / 8));
|
|
39
|
+
return hold != null && Number.isFinite(hold) && hold > 0 ? Math.max(MIN_HOLD, hold) : auto;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseRectString(v: unknown): Rect | undefined {
|
|
43
|
+
if (v && typeof v === "object") {
|
|
44
|
+
const r = v as any;
|
|
45
|
+
return { x: r.x, y: r.y, w: r.w, h: r.h };
|
|
46
|
+
}
|
|
47
|
+
if (typeof v !== "string") return undefined;
|
|
48
|
+
const n = v.split(/[\s,]+/).filter(Boolean).map(Number);
|
|
49
|
+
return n.length === 4 ? { x: n[0], y: n[1], w: n[2], h: n[3] } : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Validate a script from JSON. Returns the script or an error message.
|
|
53
|
+
export function parseScript(raw: any): Script | string {
|
|
54
|
+
if (!raw || typeof raw !== "object") return "script must be an object";
|
|
55
|
+
if (!Array.isArray(raw.steps) || !raw.steps.length) return "steps must be a non-empty array";
|
|
56
|
+
const steps: Step[] = [];
|
|
57
|
+
for (const [i, s] of raw.steps.entries()) {
|
|
58
|
+
const at = `steps[${i}]`;
|
|
59
|
+
if (!s || typeof s !== "object") return `${at} must be an object`;
|
|
60
|
+
if (s.go != null && typeof s.go !== "string") return `${at}.go must be a string`;
|
|
61
|
+
const say = s.say == null ? [] : Array.isArray(s.say) ? s.say : [s.say];
|
|
62
|
+
if (!say.every((l: unknown) => typeof l === "string" && l.trim())) return `${at}.say must be non-empty strings`;
|
|
63
|
+
if (s.hold != null && !(Number.isFinite(s.hold) && s.hold > 0)) return `${at}.hold must be a positive number`;
|
|
64
|
+
steps.push({ go: s.go || undefined, say: say.map((l: string) => l.trim()), hold: s.hold });
|
|
65
|
+
}
|
|
66
|
+
const rect = raw.rect == null ? undefined : parseRectString(raw.rect);
|
|
67
|
+
if (raw.rect != null && !rect) return "rect must be \"x,y,w,h\" or {x,y,w,h}";
|
|
68
|
+
return { rect, steps };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// "3" or "3.2" (1-based) -> 0-based position
|
|
72
|
+
export function parsePos(v: string): { step: number; line: number } | null {
|
|
73
|
+
const m = String(v).trim().match(/^(\d+)(?:\.(\d+))?$/);
|
|
74
|
+
if (!m) return null;
|
|
75
|
+
const step = Number(m[1]) - 1;
|
|
76
|
+
const line = m[2] ? Number(m[2]) - 1 : 0;
|
|
77
|
+
return step >= 0 && line >= 0 ? { step, line } : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createPlayer(deps: {
|
|
81
|
+
go: (expr: string) => Promise<void>;
|
|
82
|
+
say: (text: string, at: string, hold: number) => void;
|
|
83
|
+
done: () => void;
|
|
84
|
+
changed: () => void;
|
|
85
|
+
}) {
|
|
86
|
+
let script: Script | null = null;
|
|
87
|
+
let state: PlayerInfo["state"] = "empty";
|
|
88
|
+
let cur = { step: 0, line: 0 }; // next line to show
|
|
89
|
+
let last: { step: number; line: number } | null = null;
|
|
90
|
+
let entered = false; // `go` of cur.step already run in this play run
|
|
91
|
+
let run = 0; // bumps on every pause/goto/load, stale timers check it
|
|
92
|
+
let timer: Timer | null = null;
|
|
93
|
+
|
|
94
|
+
const fmt = (p: { step: number; line: number }) => `${p.step + 1}.${p.line + 1}`;
|
|
95
|
+
const set = (s: PlayerInfo["state"]) => {
|
|
96
|
+
state = s;
|
|
97
|
+
deps.changed();
|
|
98
|
+
};
|
|
99
|
+
const cancel = () => {
|
|
100
|
+
run++;
|
|
101
|
+
if (timer) clearTimeout(timer);
|
|
102
|
+
timer = null;
|
|
103
|
+
};
|
|
104
|
+
const wait = (ms: number, id: number, fn: () => void) => {
|
|
105
|
+
timer = setTimeout(() => id === run && fn(), ms);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
function info(): PlayerInfo {
|
|
109
|
+
const steps = script?.steps.length ?? 0;
|
|
110
|
+
// next line as it will be played (a finished step points at the next step)
|
|
111
|
+
let p = cur;
|
|
112
|
+
while (script && p.step < steps && p.line >= Math.max(1, script.steps[p.step].say.length))
|
|
113
|
+
p = { step: p.step + 1, line: 0 };
|
|
114
|
+
return {
|
|
115
|
+
state,
|
|
116
|
+
steps,
|
|
117
|
+
next: script && p.step < steps ? fmt(p) : undefined,
|
|
118
|
+
last: last ? fmt(last) : undefined,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Skip past finished steps (cur.line beyond the step's lines).
|
|
123
|
+
function normalize() {
|
|
124
|
+
if (!script) return;
|
|
125
|
+
while (cur.step < script.steps.length && cur.line >= Math.max(1, script.steps[cur.step].say.length)) {
|
|
126
|
+
cur = { step: cur.step + 1, line: 0 };
|
|
127
|
+
entered = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function tick(id: number) {
|
|
132
|
+
if (id !== run || state !== "playing" || !script) return;
|
|
133
|
+
normalize();
|
|
134
|
+
if (cur.step >= script.steps.length) {
|
|
135
|
+
set("done");
|
|
136
|
+
deps.done();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const step = script.steps[cur.step];
|
|
140
|
+
if (!entered) {
|
|
141
|
+
entered = true;
|
|
142
|
+
if (step.go) {
|
|
143
|
+
await deps.go(step.go);
|
|
144
|
+
if (id !== run) return;
|
|
145
|
+
wait(PACE.afterGo, id, () => tick(id));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!step.say.length) {
|
|
150
|
+
// silent step: just show the screen for `hold` seconds
|
|
151
|
+
cur = { step: cur.step, line: 1 };
|
|
152
|
+
deps.changed();
|
|
153
|
+
wait(lineHold("", step.hold) * 1000 + PACE.afterStep, id, () => tick(id));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const text = step.say[cur.line];
|
|
157
|
+
const hold = lineHold(text, step.hold);
|
|
158
|
+
const lastLine = cur.line === step.say.length - 1;
|
|
159
|
+
last = { ...cur };
|
|
160
|
+
deps.say(text, fmt(cur), hold);
|
|
161
|
+
cur = { step: cur.step, line: cur.line + 1 };
|
|
162
|
+
deps.changed();
|
|
163
|
+
const gap = lastLine ? PACE.afterStep : PACE.betweenLines;
|
|
164
|
+
wait(hold * 1000 + PACE.fade + gap, id, () => tick(id));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
info,
|
|
169
|
+
script: () => script,
|
|
170
|
+
|
|
171
|
+
// Replace the script, keeping the position (clamped) and the state.
|
|
172
|
+
load(next: Script) {
|
|
173
|
+
const playing = state === "playing";
|
|
174
|
+
cancel();
|
|
175
|
+
script = next;
|
|
176
|
+
if (cur.step > next.steps.length) cur = { step: next.steps.length, line: 0 };
|
|
177
|
+
normalize();
|
|
178
|
+
if (state === "empty") state = "ready";
|
|
179
|
+
if (state === "done" && cur.step < next.steps.length) state = "paused";
|
|
180
|
+
if (playing) {
|
|
181
|
+
entered = true; // screen is already on this step
|
|
182
|
+
tick(run);
|
|
183
|
+
}
|
|
184
|
+
deps.changed();
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
resume(): string | null {
|
|
188
|
+
if (!script) return "no_script";
|
|
189
|
+
if (state === "playing") return null;
|
|
190
|
+
if (state === "done") return "done";
|
|
191
|
+
cancel();
|
|
192
|
+
entered = false; // bring the screen back to the current step first
|
|
193
|
+
set("playing");
|
|
194
|
+
tick(run);
|
|
195
|
+
return null;
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
pause() {
|
|
199
|
+
if (state !== "playing") return;
|
|
200
|
+
cancel();
|
|
201
|
+
set("paused");
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
// Move to a step (and line) and show its screen. Does not play; if it was
|
|
205
|
+
// playing, it keeps playing from there.
|
|
206
|
+
async goto(pos: { step: number; line: number }): Promise<string | null> {
|
|
207
|
+
if (!script) return "no_script";
|
|
208
|
+
const step = script.steps[pos.step];
|
|
209
|
+
if (!step) return "no_such_step";
|
|
210
|
+
if (pos.line > 0 && pos.line >= step.say.length) return "no_such_line";
|
|
211
|
+
const playing = state === "playing";
|
|
212
|
+
cancel();
|
|
213
|
+
cur = { ...pos };
|
|
214
|
+
entered = false;
|
|
215
|
+
if (playing) {
|
|
216
|
+
tick(run);
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
if (state !== "ready") state = "paused";
|
|
220
|
+
if (step.go) await deps.go(step.go);
|
|
221
|
+
entered = true;
|
|
222
|
+
deps.changed();
|
|
223
|
+
return null;
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
// Presentation ended: stop where it is.
|
|
227
|
+
stop() {
|
|
228
|
+
if (state === "playing") {
|
|
229
|
+
cancel();
|
|
230
|
+
set("paused");
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
package/src/screencast.ts
CHANGED
|
@@ -268,5 +268,75 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
|
|
|
268
268
|
return browser ? { connected: false, browser: browser.key } : { connected: false, reason };
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
-
|
|
271
|
+
// Run JS in the relayed tab (presentation script `go`). Needs the relay to be
|
|
272
|
+
// connected, i.e. at least one viewer. Returns false if it could not run.
|
|
273
|
+
async function evaluate(expression: string): Promise<boolean> {
|
|
274
|
+
const r = await request("Runtime.evaluate", { expression, awaitPromise: true });
|
|
275
|
+
return !!r && !r.exceptionDetails;
|
|
276
|
+
}
|
|
277
|
+
|
|
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 };
|
|
272
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,17 +5,23 @@
|
|
|
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
|
|
13
15
|
// with "[browser] action=present-stop")
|
|
16
|
+
// GET /script presentation script + player position
|
|
17
|
+
// POST /script {script: {rect?, steps: [{go?, say: string[], hold?}]}} load (keeps position)
|
|
18
|
+
// POST /player {cmd: "resume" | "pause" | "goto", at?: "3" | "3.2"}
|
|
14
19
|
|
|
15
20
|
import { homedir } from "node:os";
|
|
16
21
|
import { join } from "node:path";
|
|
17
22
|
import { runJson } from "./run.ts";
|
|
18
|
-
import {
|
|
23
|
+
import { createPlayer, parsePos, parseScript, type PlayerInfo } from "./player.ts";
|
|
24
|
+
import { createScreencast, type Inspected, type Rect } from "./screencast.ts";
|
|
19
25
|
|
|
20
26
|
export type ServerOptions = {
|
|
21
27
|
port: number;
|
|
@@ -90,7 +96,22 @@ export function parseRect(r: any): Rect | null {
|
|
|
90
96
|
return { x, y, w, h };
|
|
91
97
|
}
|
|
92
98
|
|
|
93
|
-
|
|
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
|
+
|
|
114
|
+
export type Present = { on: boolean; rect?: Rect; since?: string; player?: PlayerInfo };
|
|
94
115
|
|
|
95
116
|
type Status =
|
|
96
117
|
| { alive: false; reason: string; pane: string }
|
|
@@ -311,10 +332,67 @@ export async function startServer(opts: ServerOptions) {
|
|
|
311
332
|
// short-lived bubbles. rect: crop of the visible viewport (unset = whole screen).
|
|
312
333
|
|
|
313
334
|
let present: Present = { on: false };
|
|
335
|
+
const presentView = (): Present => ({ ...present, player: player.info() });
|
|
314
336
|
function setPresent(next: Present) {
|
|
315
337
|
present = next;
|
|
316
338
|
screen.setClip(next.on ? (next.rect ?? null) : null);
|
|
317
|
-
|
|
339
|
+
if (!next.on) player.stop();
|
|
340
|
+
broadcast("present", presentView());
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Script player: agello owns order and timing, the agent owns the deck
|
|
344
|
+
// (each step's `go`) and decides when to resume after a question.
|
|
345
|
+
const player = createPlayer({
|
|
346
|
+
async go(expr) {
|
|
347
|
+
const js = expr.startsWith("#") ? `location.hash = ${JSON.stringify(expr)}` : expr;
|
|
348
|
+
if (!(await screen.evaluate(js))) console.error(`present: go failed: ${expr}`);
|
|
349
|
+
},
|
|
350
|
+
say(text, at, hold) {
|
|
351
|
+
broadcast("message", { role: "assistant", text, ts: new Date().toISOString(), script: at, hold });
|
|
352
|
+
},
|
|
353
|
+
done() {
|
|
354
|
+
const n = player.info().steps;
|
|
355
|
+
promptAgent("present-done", `대본 끝까지 재생함 (${n}장)`);
|
|
356
|
+
},
|
|
357
|
+
changed() {
|
|
358
|
+
broadcast("present", presentView());
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// "마지막 표시 3.2 · 다음 3.3" for agent notices
|
|
363
|
+
const where = () => {
|
|
364
|
+
const i = player.info();
|
|
365
|
+
if (i.state === "empty") return "";
|
|
366
|
+
return [i.last && `마지막 표시 ${i.last}`, i.next && `다음 ${i.next}`].filter(Boolean).join(" · ");
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
async function scriptRequest(req: Request): Promise<Response> {
|
|
370
|
+
if (req.method !== "POST") return Response.json({ script: player.script(), player: player.info() });
|
|
371
|
+
const data = (await req.json().catch(() => ({}))) as { script?: unknown };
|
|
372
|
+
const script = parseScript(data.script);
|
|
373
|
+
if (typeof script === "string") return Response.json({ ok: false, error: script }, { status: 400 });
|
|
374
|
+
if (script.rect && !parseRect(script.rect))
|
|
375
|
+
return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
|
|
376
|
+
player.load(script);
|
|
377
|
+
return Response.json({ ok: true, player: player.info() });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function playerRequest(req: Request): Promise<Response> {
|
|
381
|
+
const data = (await req.json().catch(() => ({}))) as { cmd?: string; at?: string };
|
|
382
|
+
let error: string | null = null;
|
|
383
|
+
if (data.cmd === "resume") {
|
|
384
|
+
if (!present.on) {
|
|
385
|
+
const rect = player.script()?.rect;
|
|
386
|
+
setPresent({ on: true, rect: rect ? parseRect(rect)! : undefined, since: new Date().toISOString() });
|
|
387
|
+
}
|
|
388
|
+
error = player.resume();
|
|
389
|
+
} else if (data.cmd === "pause") player.pause();
|
|
390
|
+
else if (data.cmd === "goto") {
|
|
391
|
+
const pos = parsePos(data.at ?? "");
|
|
392
|
+
error = pos ? await player.goto(pos) : "invalid_position";
|
|
393
|
+
} else error = "unknown_cmd";
|
|
394
|
+
if (error) return Response.json({ ok: false, error, player: player.info() }, { status: 400 });
|
|
395
|
+
return Response.json({ ok: true, player: player.info() });
|
|
318
396
|
}
|
|
319
397
|
|
|
320
398
|
async function presentRequest(req: Request): Promise<Response> {
|
|
@@ -326,10 +404,11 @@ export async function startServer(opts: ServerOptions) {
|
|
|
326
404
|
// (The CLI stop comes from the agent itself and needs no notice.)
|
|
327
405
|
let notified: string | undefined;
|
|
328
406
|
if (was && data.from === "viewer") {
|
|
329
|
-
const
|
|
407
|
+
const at = where();
|
|
408
|
+
const r = await promptAgent("present-stop", `사용자가 페이지에서 발표를 종료함${at ? ` (대본 정지: ${at})` : ""}`);
|
|
330
409
|
notified = r.ok ? "ok" : r.error;
|
|
331
410
|
}
|
|
332
|
-
return Response.json({ ok: true, present, notified });
|
|
411
|
+
return Response.json({ ok: true, present: presentView(), notified });
|
|
333
412
|
}
|
|
334
413
|
let rect: Rect | undefined;
|
|
335
414
|
if (data.rect != null) {
|
|
@@ -337,7 +416,7 @@ export async function startServer(opts: ServerOptions) {
|
|
|
337
416
|
if (!rect) return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
|
|
338
417
|
}
|
|
339
418
|
setPresent({ on: true, rect, since: present.on ? present.since : new Date().toISOString() });
|
|
340
|
-
return Response.json({ ok: true, present });
|
|
419
|
+
return Response.json({ ok: true, present: presentView() });
|
|
341
420
|
}
|
|
342
421
|
|
|
343
422
|
// ----- HTTP -----
|
|
@@ -378,7 +457,7 @@ export async function startServer(opts: ServerOptions) {
|
|
|
378
457
|
tools: [...pendingTools.keys()],
|
|
379
458
|
});
|
|
380
459
|
if (lastStatus) send(c, "status", lastStatus);
|
|
381
|
-
send(c, "present",
|
|
460
|
+
send(c, "present", presentView());
|
|
382
461
|
for (const t of pendingTools.values()) send(c, "tool_start", t);
|
|
383
462
|
ping = setInterval(() => send(c, "ping", {}), 15000);
|
|
384
463
|
},
|
|
@@ -403,9 +482,22 @@ export async function startServer(opts: ServerOptions) {
|
|
|
403
482
|
}
|
|
404
483
|
|
|
405
484
|
async function sendPrompt(req: Request): Promise<Response> {
|
|
406
|
-
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 };
|
|
407
486
|
const action = /^[\w-]{1,32}$/.test(data.action ?? "") ? data.action! : "message";
|
|
408
|
-
|
|
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
|
+
}
|
|
493
|
+
// Raised hand: stop the script at once (no agent round trip) and tell the
|
|
494
|
+
// agent where it stopped.
|
|
495
|
+
if (action === "hand-raise") {
|
|
496
|
+
player.pause();
|
|
497
|
+
const at = where();
|
|
498
|
+
if (at) text += ` (대본 정지: ${at})`;
|
|
499
|
+
}
|
|
500
|
+
const r = await promptAgent(action, text);
|
|
409
501
|
return r.ok ? Response.json({ ok: true }) : Response.json({ ok: false, error: r.error }, { status: r.status });
|
|
410
502
|
}
|
|
411
503
|
|
|
@@ -416,7 +508,7 @@ export async function startServer(opts: ServerOptions) {
|
|
|
416
508
|
...(await checkStatus()),
|
|
417
509
|
browser: opts.browser,
|
|
418
510
|
screen: await screen.describe(),
|
|
419
|
-
present,
|
|
511
|
+
present: presentView(),
|
|
420
512
|
});
|
|
421
513
|
case "/events":
|
|
422
514
|
return events();
|
|
@@ -430,7 +522,12 @@ export async function startServer(opts: ServerOptions) {
|
|
|
430
522
|
if (req.method === "POST") return sendPrompt(req);
|
|
431
523
|
break;
|
|
432
524
|
case "/present":
|
|
433
|
-
return req.method === "POST" ? presentRequest(req) : Response.json(
|
|
525
|
+
return req.method === "POST" ? presentRequest(req) : Response.json(presentView());
|
|
526
|
+
case "/script":
|
|
527
|
+
return scriptRequest(req);
|
|
528
|
+
case "/player":
|
|
529
|
+
if (req.method === "POST") return playerRequest(req);
|
|
530
|
+
break;
|
|
434
531
|
}
|
|
435
532
|
return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
|
|
436
533
|
}
|
|
@@ -440,8 +537,24 @@ export async function startServer(opts: ServerOptions) {
|
|
|
440
537
|
port: opts.port,
|
|
441
538
|
idleTimeout: 0, // keep SSE open
|
|
442
539
|
websocket: {
|
|
443
|
-
message(
|
|
444
|
-
|
|
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);
|
|
445
558
|
},
|
|
446
559
|
},
|
|
447
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
|
|
@@ -36,9 +39,12 @@
|
|
|
36
39
|
// closing it without asking sends action=hand-lower so it can go on.
|
|
37
40
|
// The viewer's questions show as bubbles too, with the same 10s lifetime.
|
|
38
41
|
// An end button above it stops the presentation for every viewer.
|
|
42
|
+
// Script lines (`agello script` + `present resume`) arrive as agent messages
|
|
43
|
+
// with `script: "step.line"`. While the script is not playing and the agent
|
|
44
|
+
// is working (e.g. answering a question), a small loader is shown.
|
|
39
45
|
//
|
|
40
46
|
// Methods
|
|
41
|
-
// el.send(text, action = 'message') -> Promise<{ok, error?}>
|
|
47
|
+
// el.send(text, action = 'message', extra) -> Promise<{ok, error?}> (extra: e.g. {target} for annotate)
|
|
42
48
|
// el.clearHistory() clear saved chat for the current pane
|
|
43
49
|
// el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
|
|
44
50
|
|
|
@@ -69,7 +75,7 @@ const SCREEN_REASONS = {
|
|
|
69
75
|
};
|
|
70
76
|
const ACTIONS = {
|
|
71
77
|
message: "메시지", approve: "승인", reject: "거절",
|
|
72
|
-
"present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림",
|
|
78
|
+
"present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림", annotate: "주석",
|
|
73
79
|
};
|
|
74
80
|
|
|
75
81
|
const CSS = `
|
|
@@ -172,6 +178,30 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
172
178
|
@media (prefers-reduced-motion: reduce) { .control-btn .track, .control-btn .knob { transition: none; } }
|
|
173
179
|
.placeholder { font-size: 13px; color: var(--_muted); text-align: center; }
|
|
174
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
|
+
|
|
175
205
|
/* presentation mode: the screen panel covers the window, chat stays hidden underneath.
|
|
176
206
|
container-type would make the host the containing block of the fixed layer, so drop it. */
|
|
177
207
|
:host([presenting]) { container-type: normal; }
|
|
@@ -199,6 +229,14 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
199
229
|
.caption a { color: inherit; }
|
|
200
230
|
@keyframes capIn { from { opacity: 0; translate: 0 10px; } }
|
|
201
231
|
|
|
232
|
+
/* agent busy while presenting (answering, fixing the script): minimal loader */
|
|
233
|
+
.busy { display: none; }
|
|
234
|
+
.body.present .busy.on { position: absolute; left: 22px; bottom: 28px; z-index: 5; display: flex; align-items: center;
|
|
235
|
+
gap: 9px; padding: 9px 14px; border-radius: 999px; font-size: 13px; color: #fff; background: rgba(20, 20, 22, .72);
|
|
236
|
+
-webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); box-shadow: 0 8px 30px rgba(0,0,0,.35);
|
|
237
|
+
animation: capIn .25s ease; }
|
|
238
|
+
.busy .spinner { border-color: rgba(255,255,255,.25); border-top-color: #fff; }
|
|
239
|
+
|
|
202
240
|
/* raise hand: ask the agent mid-presentation */
|
|
203
241
|
.hand-wrap { display: none; }
|
|
204
242
|
.body.present .hand-wrap { position: absolute; right: 22px; bottom: 28px; z-index: 5; display: flex;
|
|
@@ -325,9 +363,13 @@ const TEMPLATE = `
|
|
|
325
363
|
</section>
|
|
326
364
|
<aside class="screen" part="screen">
|
|
327
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>
|
|
328
367
|
<button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
|
|
329
368
|
<span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
|
|
330
|
-
<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>
|
|
372
|
+
<div class="busy" part="busy" role="status"><span class="spinner"></span><span>답변 준비 중</span></div>
|
|
331
373
|
<div class="hand-wrap">
|
|
332
374
|
<form class="ask" part="ask" hidden><textarea aria-label="에이전트에게 질문" placeholder="에이전트에게 질문 (Enter 전송, Esc 닫기)"></textarea>
|
|
333
375
|
<div class="ask-row"><span class="ask-hint"></span><button type="submit" class="primary">보내기</button></div></form>
|
|
@@ -346,7 +388,14 @@ class AgentBridge extends HTMLElement {
|
|
|
346
388
|
#root = this.attachShadow({ mode: "open" });
|
|
347
389
|
#es = null;
|
|
348
390
|
#screenEs = null;
|
|
349
|
-
#
|
|
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();
|
|
350
399
|
#frame = { w: 0, h: 0 };
|
|
351
400
|
#moveQueued = null;
|
|
352
401
|
#status = null;
|
|
@@ -370,7 +419,9 @@ class AgentBridge extends HTMLElement {
|
|
|
370
419
|
live: q(".live"), url: q(".screen-bar .url"), viewport: q(".viewport"), img: q(".viewport img"),
|
|
371
420
|
controlBtn: q(".control-btn"), kbd: q(".kbd"), clearBtn: q(".clear-btn"),
|
|
372
421
|
body: q(".body"), screen: q(".screen"), captions: q(".captions"),
|
|
373
|
-
hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
|
|
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"),
|
|
374
425
|
};
|
|
375
426
|
this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
|
|
376
427
|
this.#bindGrip();
|
|
@@ -382,6 +433,7 @@ class AgentBridge extends HTMLElement {
|
|
|
382
433
|
});
|
|
383
434
|
loadMarkdown().then((md) => (this.#md = md));
|
|
384
435
|
this.#bindControl();
|
|
436
|
+
this.#bindAnnotate();
|
|
385
437
|
this.#bindHand();
|
|
386
438
|
this.$.clearBtn.addEventListener("click", () => this.clearHistory());
|
|
387
439
|
}
|
|
@@ -401,6 +453,7 @@ class AgentBridge extends HTMLElement {
|
|
|
401
453
|
this.#screenEs?.close();
|
|
402
454
|
this.#screenEs = null;
|
|
403
455
|
this.#setControl(false);
|
|
456
|
+
this.#setAnnotate(false);
|
|
404
457
|
}
|
|
405
458
|
attributeChangedCallback(name) {
|
|
406
459
|
if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "agello";
|
|
@@ -408,12 +461,12 @@ class AgentBridge extends HTMLElement {
|
|
|
408
461
|
if (name === "screen" && this.isConnected) this.#syncScreen();
|
|
409
462
|
}
|
|
410
463
|
|
|
411
|
-
async send(text, action = "message") {
|
|
464
|
+
async send(text, action = "message", extra = {}) {
|
|
412
465
|
try {
|
|
413
466
|
const res = await fetch(`${this.server}/send`, {
|
|
414
467
|
method: "POST",
|
|
415
468
|
headers: { "Content-Type": "application/json" },
|
|
416
|
-
body: JSON.stringify({ action, text }),
|
|
469
|
+
body: JSON.stringify({ ...extra, action, text }),
|
|
417
470
|
});
|
|
418
471
|
return await res.json();
|
|
419
472
|
} catch {
|
|
@@ -452,7 +505,8 @@ class AgentBridge extends HTMLElement {
|
|
|
452
505
|
on("message", (m) => {
|
|
453
506
|
this.#addMessage(m);
|
|
454
507
|
this.#saveMessage(m);
|
|
455
|
-
|
|
508
|
+
// script lines carry their own on-screen time (>= 10s), others use CAPTION_MS
|
|
509
|
+
if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined);
|
|
456
510
|
if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
|
|
457
511
|
this.#emit("agent-message", m);
|
|
458
512
|
});
|
|
@@ -478,7 +532,7 @@ class AgentBridge extends HTMLElement {
|
|
|
478
532
|
|
|
479
533
|
#syncScreen() {
|
|
480
534
|
const want = this.hasAttribute("screen") || this.#present.on;
|
|
481
|
-
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; }
|
|
482
536
|
if (this.#screenEs) return;
|
|
483
537
|
const es = (this.#screenEs = new EventSource(`${this.server}/screen`));
|
|
484
538
|
es.addEventListener("meta", (e) => this.#renderScreenMeta(JSON.parse(e.data)));
|
|
@@ -487,6 +541,7 @@ class AgentBridge extends HTMLElement {
|
|
|
487
541
|
this.#frame = { w: f.w, h: f.h };
|
|
488
542
|
this.$.img.src = `data:image/jpeg;base64,${f.data}`;
|
|
489
543
|
this.$.viewport.classList.add("has");
|
|
544
|
+
if (this.#annot) this.#annotFrame();
|
|
490
545
|
});
|
|
491
546
|
es.onerror = () => this.#renderScreenMeta({ connected: false });
|
|
492
547
|
}
|
|
@@ -498,8 +553,9 @@ class AgentBridge extends HTMLElement {
|
|
|
498
553
|
const was = this.#present.on;
|
|
499
554
|
this.#present = p ?? { on: false };
|
|
500
555
|
this.#emit("agent-present", this.#present);
|
|
556
|
+
this.#renderBusy();
|
|
501
557
|
if (on === was) return;
|
|
502
|
-
if (on) this.#setControl(false);
|
|
558
|
+
if (on) { this.#setControl(false); this.#setAnnotate(false); }
|
|
503
559
|
else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#handUp = false; }
|
|
504
560
|
this.#syncScreen();
|
|
505
561
|
// FLIP: grow from (or shrink back to) the side panel
|
|
@@ -515,6 +571,12 @@ class AgentBridge extends HTMLElement {
|
|
|
515
571
|
{ duration: on ? 420 : 360, easing: "cubic-bezier(.2, .7, .2, 1)" });
|
|
516
572
|
}
|
|
517
573
|
|
|
574
|
+
#renderBusy() {
|
|
575
|
+
const s = this.#status;
|
|
576
|
+
const busy = this.#present.on && s?.alive && s.status === "working" && this.#present.player?.state !== "playing";
|
|
577
|
+
this.$.busy.classList.toggle("on", !!busy);
|
|
578
|
+
}
|
|
579
|
+
|
|
518
580
|
// Raise hand: open a small input over the presentation and send it as a message.
|
|
519
581
|
#bindHand() {
|
|
520
582
|
const { hand, ask, askBox, askHint } = this.$;
|
|
@@ -574,7 +636,7 @@ class AgentBridge extends HTMLElement {
|
|
|
574
636
|
|
|
575
637
|
// Agent reply as a short-lived bubble over the presented screen.
|
|
576
638
|
// kind "user": the viewer's own question (plain text, accent colour).
|
|
577
|
-
#caption(text, kind = "agent") {
|
|
639
|
+
#caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS) {
|
|
578
640
|
if (!text?.trim()) return;
|
|
579
641
|
const el = document.createElement("div");
|
|
580
642
|
el.className = `caption ${kind}`;
|
|
@@ -589,7 +651,7 @@ class AgentBridge extends HTMLElement {
|
|
|
589
651
|
el.classList.add("out");
|
|
590
652
|
setTimeout(() => el.remove(), 500);
|
|
591
653
|
};
|
|
592
|
-
setTimeout(leave,
|
|
654
|
+
setTimeout(leave, ms);
|
|
593
655
|
const live = [...box.children].filter((c) => !c.classList.contains("out"));
|
|
594
656
|
live.slice(0, Math.max(0, live.length - AgentBridge.CAPTION_MAX)).forEach((c) => c.__leave?.());
|
|
595
657
|
el.__leave = leave;
|
|
@@ -632,24 +694,43 @@ class AgentBridge extends HTMLElement {
|
|
|
632
694
|
|
|
633
695
|
// ---------- user control ----------
|
|
634
696
|
|
|
635
|
-
|
|
636
|
-
|
|
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) {
|
|
637
701
|
const ws = new WebSocket(`${this.server.replace(/^http/, "ws")}/input`);
|
|
638
|
-
ws.
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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;
|
|
643
717
|
ws.close();
|
|
644
718
|
}
|
|
645
|
-
this
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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();
|
|
649
730
|
}
|
|
650
731
|
|
|
651
732
|
#cdp(method, params) {
|
|
652
|
-
const ws = this.#
|
|
733
|
+
const ws = this.#ws;
|
|
653
734
|
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ method, params }));
|
|
654
735
|
}
|
|
655
736
|
|
|
@@ -663,11 +744,11 @@ class AgentBridge extends HTMLElement {
|
|
|
663
744
|
|
|
664
745
|
#bindControl() {
|
|
665
746
|
const { img, kbd, controlBtn } = this.$;
|
|
666
|
-
const on = () =>
|
|
747
|
+
const on = () => this.#control && this.#frame.w > 0;
|
|
667
748
|
const mods = (e) => (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
|
|
668
749
|
const BUTTONS = ["left", "middle", "right"];
|
|
669
750
|
|
|
670
|
-
controlBtn.addEventListener("click", () =>
|
|
751
|
+
controlBtn.addEventListener("click", () => this.#setControl(!this.#control));
|
|
671
752
|
|
|
672
753
|
img.addEventListener("mousedown", (e) => {
|
|
673
754
|
if (!on()) return;
|
|
@@ -695,7 +776,7 @@ class AgentBridge extends HTMLElement {
|
|
|
695
776
|
});
|
|
696
777
|
});
|
|
697
778
|
img.addEventListener("wheel", (e) => {
|
|
698
|
-
if (!on()) return;
|
|
779
|
+
if (!on() && !(this.#annot && this.#frame.w > 0)) return; // annotate: scroll to reach the element
|
|
699
780
|
e.preventDefault();
|
|
700
781
|
this.#cdp("Input.dispatchMouseEvent", { type: "mouseWheel", ...this.#point(e), deltaX: e.deltaX, deltaY: e.deltaY,
|
|
701
782
|
modifiers: mods(e) });
|
|
@@ -733,6 +814,163 @@ class AgentBridge extends HTMLElement {
|
|
|
733
814
|
});
|
|
734
815
|
}
|
|
735
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
|
+
|
|
736
974
|
#renderScreenMeta(m) {
|
|
737
975
|
const { live, url, viewport } = this.$;
|
|
738
976
|
if (!m.connected) {
|
|
@@ -748,6 +986,7 @@ class AgentBridge extends HTMLElement {
|
|
|
748
986
|
url.textContent = m.title ? `${m.title} · ${m.url}` : m.url || "";
|
|
749
987
|
url.title = m.url || "";
|
|
750
988
|
}
|
|
989
|
+
this.#drawHl();
|
|
751
990
|
this.#emit("agent-screen", m);
|
|
752
991
|
}
|
|
753
992
|
|
|
@@ -884,7 +1123,7 @@ class AgentBridge extends HTMLElement {
|
|
|
884
1123
|
return;
|
|
885
1124
|
} else if (m.role === "assistant") {
|
|
886
1125
|
wrap.className = "msg assistant";
|
|
887
|
-
meta.textContent =
|
|
1126
|
+
meta.textContent = `에이전트${m.script ? ` · 대본 ${m.script}` : ""} · ${time}`;
|
|
888
1127
|
if (this.#md) bubble.innerHTML = this.#md(m.text);
|
|
889
1128
|
else { bubble.textContent = m.text; bubble.classList.add("plain"); }
|
|
890
1129
|
} else if (m.role === "browser") {
|
|
@@ -946,6 +1185,7 @@ class AgentBridge extends HTMLElement {
|
|
|
946
1185
|
this.$.buttons.forEach((b) => (b.disabled = !canSend));
|
|
947
1186
|
if (!s || !s.alive || s.status === "idle") this.#tools.clear();
|
|
948
1187
|
this.#renderActivity();
|
|
1188
|
+
this.#renderBusy();
|
|
949
1189
|
this.#emit("agent-status", s);
|
|
950
1190
|
}
|
|
951
1191
|
|