agello 0.4.1 → 0.4.2
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 +128 -15
- package/web/agent-bridge.js +270 -29
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
- Conversation saved per pane in the browser, kept across reloads
|
|
8
8
|
- Interactive terminal view of the existing herdr pane: live cursor, keyboard input, and automatic resize
|
|
9
9
|
- Optional live view of a `terminal-browser` screen, with an on/off switch to control it yourself
|
|
10
|
+
- Annotate: point at an element on that screen and send a request about it, with its CSS selector
|
|
10
11
|
- Embeddable `<agent-bridge>` web component
|
|
11
12
|
|
|
12
13
|
Chat currently supports **Claude Code** sessions. The terminal view connects directly to the pane, including ordinary shells and other terminal applications.
|
|
@@ -60,6 +61,18 @@ Messages sent from the page arrive in the agent as:
|
|
|
60
61
|
|
|
61
62
|
The approve / reject buttons send `action=approve` / `action=reject`.
|
|
62
63
|
|
|
64
|
+
### Annotate
|
|
65
|
+
|
|
66
|
+
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:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
[browser] action=annotate make this red / and bolder
|
|
70
|
+
대상: #app > main > button:nth-of-type(2)
|
|
71
|
+
요소: button.primary "Save" · 위치 120,340 크기 96x32 · http://localhost:3000/
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
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.
|
|
75
|
+
|
|
63
76
|
### Commands
|
|
64
77
|
|
|
65
78
|
| Command | Description |
|
|
@@ -70,6 +83,8 @@ The approve / reject buttons send `action=approve` / `action=reject`.
|
|
|
70
83
|
| `agello open [--port N \| --pane ID]` | Open the page (default: this pane's server) |
|
|
71
84
|
| `agello present [x,y,w,h] [--port N \| --pane ID]` | Presentation mode (see below) |
|
|
72
85
|
| `agello present stop` | End presentation mode |
|
|
86
|
+
| `agello script load <file> \| show` | Load / show a presentation script |
|
|
87
|
+
| `agello present resume \| pause \| goto <n[.m]>` | Play the script from where it stopped / stop it / move to a step |
|
|
73
88
|
|
|
74
89
|
`start` options: `--pane <id>`, `--port <n>` (default: first free from 8765), `--session <id>`, `--browser <terminal-browser key>`, `--allow-origin <origin>` (repeatable), `--open`, `--foreground`.
|
|
75
90
|
|
|
@@ -102,6 +117,23 @@ The xterm.js renderer and its styles are served locally; no terminal CDN is requ
|
|
|
102
117
|
|
|
103
118
|
Run `present` again to move the crop. User control is off while presenting.
|
|
104
119
|
|
|
120
|
+
#### Scripts
|
|
121
|
+
|
|
122
|
+
Write the talk ahead so each line appears the moment its screen does:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{ "rect": "28,157,688,477",
|
|
126
|
+
"steps": [
|
|
127
|
+
{ "go": "#1", "say": ["First line", "Second line"] },
|
|
128
|
+
{ "go": "#2", "say": ["..."], "hold": 6 } ] }
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
- 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
|
|
132
|
+
- `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
|
|
133
|
+
- raising a hand pauses at once; the agent gets `action=hand-raise` with the position (`마지막 표시 2.1 · 다음 2.2`)
|
|
134
|
+
- `script load` again after editing keeps the position; `present goto 2.2` then `present resume` replays from a fixed line
|
|
135
|
+
- 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
|
|
136
|
+
|
|
105
137
|
## Embed
|
|
106
138
|
|
|
107
139
|
```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
|
@@ -6,17 +6,22 @@
|
|
|
6
6
|
// GET /events SSE: status, chat messages, tool start/end (from transcript JSONL)
|
|
7
7
|
// POST /send {action, text, images?: [{type, data(base64)}]} -> herdr agent prompt
|
|
8
8
|
// (images are saved to $TMPDIR/agello-uploads and sent as [image: <path>])
|
|
9
|
+
// (action "annotate": + target {selector, label, rect, text?, url?} from inspect)
|
|
9
10
|
// GET /screen SSE: terminal-browser screen frames (CDP screencast)
|
|
10
11
|
// POST /upload {images: [{type, data(base64)}]} -> {paths}: terminal image paste
|
|
11
12
|
// WS /terminal interactive herdr terminal frames, keyboard input, resize
|
|
12
13
|
// GET /panes herdr workspace -> tab -> pane tree
|
|
13
14
|
// POST /panes/connect {pane} -> url of that pane's server (started if needed)
|
|
14
15
|
// POST /panes/create {kind: workspace|tab|pane, cwd?, label?, workspace?, pane?, direction?}
|
|
15
|
-
// WS /input user control: CDP Input.* commands forwarded to the page
|
|
16
|
+
// WS /input user control: CDP Input.* commands forwarded to the page;
|
|
17
|
+
// {id, inspect: {x, y} | {selector}, full?} -> {id, result}: element at a point (annotation)
|
|
16
18
|
// GET /present presentation state
|
|
17
19
|
// POST /present {rect?: {x,y,w,h}} start (or move the crop), {stop: true} end
|
|
18
20
|
// ({stop: true, from: "viewer"}: ended from the page, the agent is told
|
|
19
21
|
// with "[browser] action=present-stop")
|
|
22
|
+
// GET /script presentation script + player position
|
|
23
|
+
// POST /script {script: {rect?, steps: [{go?, say: string[], hold?}]}} load (keeps position)
|
|
24
|
+
// POST /player {cmd: "resume" | "pause" | "goto", at?: "3" | "3.2"}
|
|
20
25
|
|
|
21
26
|
import { createTerminal, type SocketData } from "./terminal.ts";
|
|
22
27
|
import { panesRoute } from "./panes.ts";
|
|
@@ -24,7 +29,8 @@ import { mkdir } from "node:fs/promises";
|
|
|
24
29
|
import { homedir, tmpdir } from "node:os";
|
|
25
30
|
import { join } from "node:path";
|
|
26
31
|
import { runJson } from "./run.ts";
|
|
27
|
-
import {
|
|
32
|
+
import { createPlayer, parsePos, parseScript, type PlayerInfo } from "./player.ts";
|
|
33
|
+
import { createScreencast, type Inspected, type Rect } from "./screencast.ts";
|
|
28
34
|
|
|
29
35
|
export type ServerOptions = {
|
|
30
36
|
port: number;
|
|
@@ -123,7 +129,22 @@ export function parseRect(r: any): Rect | null {
|
|
|
123
129
|
return { x, y, w, h };
|
|
124
130
|
}
|
|
125
131
|
|
|
126
|
-
|
|
132
|
+
// Annotation prompt: the viewer's request plus the element it points at, in
|
|
133
|
+
// 3 lines (request / selector / element summary).
|
|
134
|
+
export function formatAnnotation(text: string, t: any): string | null {
|
|
135
|
+
if (!t || typeof t.selector !== "string" || !t.selector || t.selector.length > 1000) return null;
|
|
136
|
+
const clean = (v: unknown, n: number) => String(v ?? "").replace(/\s+/g, " ").trim().slice(0, n);
|
|
137
|
+
const request = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).join(" / ");
|
|
138
|
+
const r = t.rect ?? {};
|
|
139
|
+
const [x, y, w, h] = [r.x, r.y, r.w, r.h].map((v) => Math.round(Number(v) || 0));
|
|
140
|
+
const label = clean(t.label, 120);
|
|
141
|
+
const snippet = clean(t.text, 80);
|
|
142
|
+
const url = clean(t.url, 300);
|
|
143
|
+
const info = [`요소: ${label}${snippet ? ` "${snippet}"` : ""}`, `위치 ${x},${y} 크기 ${w}x${h}`, url].filter(Boolean);
|
|
144
|
+
return [request, `대상: ${clean(t.selector, 1000)}`, info.join(" · ")].join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type Present = { on: boolean; rect?: Rect; since?: string; player?: PlayerInfo };
|
|
127
148
|
|
|
128
149
|
type Status =
|
|
129
150
|
| { alive: false; reason: string; pane: string }
|
|
@@ -351,10 +372,67 @@ export async function startServer(opts: ServerOptions) {
|
|
|
351
372
|
// short-lived bubbles. rect: crop of the visible viewport (unset = whole screen).
|
|
352
373
|
|
|
353
374
|
let present: Present = { on: false };
|
|
375
|
+
const presentView = (): Present => ({ ...present, player: player.info() });
|
|
354
376
|
function setPresent(next: Present) {
|
|
355
377
|
present = next;
|
|
356
378
|
screen.setClip(next.on ? (next.rect ?? null) : null);
|
|
357
|
-
|
|
379
|
+
if (!next.on) player.stop();
|
|
380
|
+
broadcast("present", presentView());
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Script player: agello owns order and timing, the agent owns the deck
|
|
384
|
+
// (each step's `go`) and decides when to resume after a question.
|
|
385
|
+
const player = createPlayer({
|
|
386
|
+
async go(expr) {
|
|
387
|
+
const js = expr.startsWith("#") ? `location.hash = ${JSON.stringify(expr)}` : expr;
|
|
388
|
+
if (!(await screen.evaluate(js))) console.error(`present: go failed: ${expr}`);
|
|
389
|
+
},
|
|
390
|
+
say(text, at, hold) {
|
|
391
|
+
broadcast("message", { role: "assistant", text, ts: new Date().toISOString(), script: at, hold });
|
|
392
|
+
},
|
|
393
|
+
done() {
|
|
394
|
+
const n = player.info().steps;
|
|
395
|
+
promptAgent("present-done", `대본 끝까지 재생함 (${n}장)`);
|
|
396
|
+
},
|
|
397
|
+
changed() {
|
|
398
|
+
broadcast("present", presentView());
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// "마지막 표시 3.2 · 다음 3.3" for agent notices
|
|
403
|
+
const where = () => {
|
|
404
|
+
const i = player.info();
|
|
405
|
+
if (i.state === "empty") return "";
|
|
406
|
+
return [i.last && `마지막 표시 ${i.last}`, i.next && `다음 ${i.next}`].filter(Boolean).join(" · ");
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
async function scriptRequest(req: Request): Promise<Response> {
|
|
410
|
+
if (req.method !== "POST") return Response.json({ script: player.script(), player: player.info() });
|
|
411
|
+
const data = (await req.json().catch(() => ({}))) as { script?: unknown };
|
|
412
|
+
const script = parseScript(data.script);
|
|
413
|
+
if (typeof script === "string") return Response.json({ ok: false, error: script }, { status: 400 });
|
|
414
|
+
if (script.rect && !parseRect(script.rect))
|
|
415
|
+
return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
|
|
416
|
+
player.load(script);
|
|
417
|
+
return Response.json({ ok: true, player: player.info() });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function playerRequest(req: Request): Promise<Response> {
|
|
421
|
+
const data = (await req.json().catch(() => ({}))) as { cmd?: string; at?: string };
|
|
422
|
+
let error: string | null = null;
|
|
423
|
+
if (data.cmd === "resume") {
|
|
424
|
+
if (!present.on) {
|
|
425
|
+
const rect = player.script()?.rect;
|
|
426
|
+
setPresent({ on: true, rect: rect ? parseRect(rect)! : undefined, since: new Date().toISOString() });
|
|
427
|
+
}
|
|
428
|
+
error = player.resume();
|
|
429
|
+
} else if (data.cmd === "pause") player.pause();
|
|
430
|
+
else if (data.cmd === "goto") {
|
|
431
|
+
const pos = parsePos(data.at ?? "");
|
|
432
|
+
error = pos ? await player.goto(pos) : "invalid_position";
|
|
433
|
+
} else error = "unknown_cmd";
|
|
434
|
+
if (error) return Response.json({ ok: false, error, player: player.info() }, { status: 400 });
|
|
435
|
+
return Response.json({ ok: true, player: player.info() });
|
|
358
436
|
}
|
|
359
437
|
|
|
360
438
|
async function presentRequest(req: Request): Promise<Response> {
|
|
@@ -366,10 +444,11 @@ export async function startServer(opts: ServerOptions) {
|
|
|
366
444
|
// (The CLI stop comes from the agent itself and needs no notice.)
|
|
367
445
|
let notified: string | undefined;
|
|
368
446
|
if (was && data.from === "viewer") {
|
|
369
|
-
const
|
|
447
|
+
const at = where();
|
|
448
|
+
const r = await promptAgent("present-stop", `사용자가 페이지에서 발표를 종료함${at ? ` (대본 정지: ${at})` : ""}`);
|
|
370
449
|
notified = r.ok ? "ok" : r.error;
|
|
371
450
|
}
|
|
372
|
-
return Response.json({ ok: true, present, notified });
|
|
451
|
+
return Response.json({ ok: true, present: presentView(), notified });
|
|
373
452
|
}
|
|
374
453
|
let rect: Rect | undefined;
|
|
375
454
|
if (data.rect != null) {
|
|
@@ -377,7 +456,7 @@ export async function startServer(opts: ServerOptions) {
|
|
|
377
456
|
if (!rect) return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
|
|
378
457
|
}
|
|
379
458
|
setPresent({ on: true, rect, since: present.on ? present.since : new Date().toISOString() });
|
|
380
|
-
return Response.json({ ok: true, present });
|
|
459
|
+
return Response.json({ ok: true, present: presentView() });
|
|
381
460
|
}
|
|
382
461
|
|
|
383
462
|
// ----- HTTP -----
|
|
@@ -418,7 +497,7 @@ export async function startServer(opts: ServerOptions) {
|
|
|
418
497
|
tools: [...pendingTools.keys()],
|
|
419
498
|
});
|
|
420
499
|
if (lastStatus) send(c, "status", lastStatus);
|
|
421
|
-
send(c, "present",
|
|
500
|
+
send(c, "present", presentView());
|
|
422
501
|
for (const t of pendingTools.values()) send(c, "tool_start", t);
|
|
423
502
|
ping = setInterval(() => send(c, "ping", {}), 15000);
|
|
424
503
|
},
|
|
@@ -443,11 +522,24 @@ export async function startServer(opts: ServerOptions) {
|
|
|
443
522
|
}
|
|
444
523
|
|
|
445
524
|
async function sendPrompt(req: Request): Promise<Response> {
|
|
446
|
-
const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string; images?: unknown };
|
|
525
|
+
const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string; images?: unknown; target?: Inspected };
|
|
447
526
|
const action = /^[\w-]{1,32}$/.test(data.action ?? "") ? data.action! : "message";
|
|
527
|
+
let text = data.text ?? "";
|
|
528
|
+
if (action === "annotate") {
|
|
529
|
+
const t = formatAnnotation(text, data.target);
|
|
530
|
+
if (!t || !text.trim()) return Response.json({ ok: false, error: "invalid_target" }, { status: 400 });
|
|
531
|
+
text = t;
|
|
532
|
+
}
|
|
533
|
+
// Raised hand: stop the script at once (no agent round trip) and tell the
|
|
534
|
+
// agent where it stopped.
|
|
535
|
+
if (action === "hand-raise") {
|
|
536
|
+
player.pause();
|
|
537
|
+
const at = where();
|
|
538
|
+
if (at) text += ` (대본 정지: ${at})`;
|
|
539
|
+
}
|
|
448
540
|
const images = await saveImages(data.images);
|
|
449
541
|
if (!images) return Response.json({ ok: false, error: "invalid_images" }, { status: 400 });
|
|
450
|
-
|
|
542
|
+
text = [text, ...images.map((p) => `[image: ${p}]`)].join("\n");
|
|
451
543
|
const r = await promptAgent(action, text);
|
|
452
544
|
return r.ok ? Response.json({ ok: true }) : Response.json({ ok: false, error: r.error }, { status: r.status });
|
|
453
545
|
}
|
|
@@ -463,7 +555,7 @@ export async function startServer(opts: ServerOptions) {
|
|
|
463
555
|
...(await checkStatus()),
|
|
464
556
|
browser: opts.browser,
|
|
465
557
|
screen: await screen.describe(),
|
|
466
|
-
present,
|
|
558
|
+
present: presentView(),
|
|
467
559
|
});
|
|
468
560
|
case "/events":
|
|
469
561
|
return events();
|
|
@@ -488,7 +580,12 @@ export async function startServer(opts: ServerOptions) {
|
|
|
488
580
|
}
|
|
489
581
|
break;
|
|
490
582
|
case "/present":
|
|
491
|
-
return req.method === "POST" ? presentRequest(req) : Response.json(
|
|
583
|
+
return req.method === "POST" ? presentRequest(req) : Response.json(presentView());
|
|
584
|
+
case "/script":
|
|
585
|
+
return scriptRequest(req);
|
|
586
|
+
case "/player":
|
|
587
|
+
if (req.method === "POST") return playerRequest(req);
|
|
588
|
+
break;
|
|
492
589
|
}
|
|
493
590
|
return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
|
|
494
591
|
}
|
|
@@ -500,9 +597,25 @@ export async function startServer(opts: ServerOptions) {
|
|
|
500
597
|
websocket: {
|
|
501
598
|
maxPayloadLength: 128 * 1024,
|
|
502
599
|
open(ws) { if (ws.data.kind === "terminal") terminal.open(ws); },
|
|
503
|
-
message(ws, msg) {
|
|
504
|
-
|
|
505
|
-
|
|
600
|
+
async message(ws, msg) {
|
|
601
|
+
const raw = String(msg);
|
|
602
|
+
if (ws.data.kind === "terminal") return terminal.message(ws, raw);
|
|
603
|
+
let m: any;
|
|
604
|
+
try {
|
|
605
|
+
m = JSON.parse(raw);
|
|
606
|
+
} catch {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (m?.inspect) {
|
|
610
|
+
const q = m.inspect;
|
|
611
|
+
const query =
|
|
612
|
+
typeof q.selector === "string"
|
|
613
|
+
? { selector: q.selector.slice(0, 1000), full: !!q.full }
|
|
614
|
+
: { x: Number(q.x) || 0, y: Number(q.y) || 0, full: !!q.full };
|
|
615
|
+
ws.send(JSON.stringify({ id: m.id, result: await screen.inspect(query) }));
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
screen.input(raw);
|
|
506
619
|
},
|
|
507
620
|
close(ws) { if (ws.data.kind === "terminal") terminal.close(ws); },
|
|
508
621
|
},
|
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
|
|
@@ -42,9 +45,12 @@
|
|
|
42
45
|
// closing it without asking sends action=hand-lower so it can go on.
|
|
43
46
|
// The viewer's questions show as bubbles too, with the same 10s lifetime.
|
|
44
47
|
// An end button above it stops the presentation for every viewer.
|
|
48
|
+
// Script lines (`agello script` + `present resume`) arrive as agent messages
|
|
49
|
+
// with `script: "step.line"`. While the script is not playing and the agent
|
|
50
|
+
// is working (e.g. answering a question), a small loader is shown.
|
|
45
51
|
//
|
|
46
52
|
// Methods
|
|
47
|
-
// el.send(text, action = 'message', images
|
|
53
|
+
// el.send(text, action = 'message', extra) -> Promise<{ok, error?}> (extra: {images: [{type, data (base64)}]}, {target} for annotate)
|
|
48
54
|
// el.clearHistory() clear saved chat for the current pane
|
|
49
55
|
// el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
|
|
50
56
|
|
|
@@ -75,7 +81,7 @@ const SCREEN_REASONS = {
|
|
|
75
81
|
};
|
|
76
82
|
const ACTIONS = {
|
|
77
83
|
message: "메시지", approve: "승인", reject: "거절",
|
|
78
|
-
"present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림",
|
|
84
|
+
"present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림", annotate: "주석",
|
|
79
85
|
};
|
|
80
86
|
|
|
81
87
|
const CSS = `
|
|
@@ -228,6 +234,30 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
228
234
|
@media (prefers-reduced-motion: reduce) { .control-btn .track, .control-btn .knob { transition: none; } }
|
|
229
235
|
.placeholder { font-size: 13px; color: var(--_muted); text-align: center; }
|
|
230
236
|
|
|
237
|
+
/* annotate: hover tint, selected outline, request input next to the element */
|
|
238
|
+
.annot-btn { flex: none; font-size: 12px; padding: 2px 10px; border-radius: 999px; }
|
|
239
|
+
.annot-btn[aria-pressed="true"] { background: var(--_accent); border-color: var(--_accent); color: #fff; }
|
|
240
|
+
.viewport.annot img { cursor: crosshair; }
|
|
241
|
+
.hl { position: absolute; z-index: 3; pointer-events: none; display: none; border-radius: 2px; }
|
|
242
|
+
.hl.on { display: block; }
|
|
243
|
+
.hl.hover { background: color-mix(in srgb, var(--_accent) 22%, transparent);
|
|
244
|
+
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--_accent) 55%, transparent); }
|
|
245
|
+
.hl.sel { box-shadow: 0 0 0 2px var(--_accent), 0 0 0 5px color-mix(in srgb, var(--_accent) 25%, transparent); }
|
|
246
|
+
.hl-label { position: absolute; z-index: 4; pointer-events: none; display: none; max-width: 60%;
|
|
247
|
+
padding: 2px 7px; border-radius: 5px; font: 11px/1.5 ui-monospace, Menlo, monospace; color: #fff;
|
|
248
|
+
background: rgba(20, 20, 22, .85); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
249
|
+
.hl-label.on { display: block; }
|
|
250
|
+
.note { position: absolute; z-index: 5; width: min(340px, calc(100% - 16px)); padding: 10px; border-radius: 12px;
|
|
251
|
+
background: var(--_panel); border: 1px solid var(--_line); box-shadow: 0 8px 28px rgba(0,0,0,.22);
|
|
252
|
+
animation: capIn .18s ease; }
|
|
253
|
+
.note[hidden] { display: none; }
|
|
254
|
+
.note .note-target { font: 11px/1.4 ui-monospace, Menlo, monospace; color: var(--_muted); margin-bottom: 6px;
|
|
255
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
256
|
+
.note textarea { min-height: 52px; font-size: 14px; }
|
|
257
|
+
.note .note-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
|
258
|
+
.note .note-hint { flex: 1; font-size: 12px; color: var(--_muted); }
|
|
259
|
+
@media (prefers-reduced-motion: reduce) { .note { animation: none; } }
|
|
260
|
+
|
|
231
261
|
/* presentation mode: the screen panel covers the window, chat stays hidden underneath.
|
|
232
262
|
container-type would make the host the containing block of the fixed layer, so drop it. */
|
|
233
263
|
:host([presenting]) { container-type: normal; }
|
|
@@ -255,6 +285,14 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
255
285
|
.caption a { color: inherit; }
|
|
256
286
|
@keyframes capIn { from { opacity: 0; translate: 0 10px; } }
|
|
257
287
|
|
|
288
|
+
/* agent busy while presenting (answering, fixing the script): minimal loader */
|
|
289
|
+
.busy { display: none; }
|
|
290
|
+
.body.present .busy.on { position: absolute; left: 22px; bottom: 28px; z-index: 5; display: flex; align-items: center;
|
|
291
|
+
gap: 9px; padding: 9px 14px; border-radius: 999px; font-size: 13px; color: #fff; background: rgba(20, 20, 22, .72);
|
|
292
|
+
-webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); box-shadow: 0 8px 30px rgba(0,0,0,.35);
|
|
293
|
+
animation: capIn .25s ease; }
|
|
294
|
+
.busy .spinner { border-color: rgba(255,255,255,.25); border-top-color: #fff; }
|
|
295
|
+
|
|
258
296
|
/* raise hand: ask the agent mid-presentation */
|
|
259
297
|
.hand-wrap { display: none; }
|
|
260
298
|
.body.present .hand-wrap { position: absolute; right: 22px; bottom: 28px; z-index: 5; display: flex;
|
|
@@ -401,9 +439,13 @@ const TEMPLATE = `
|
|
|
401
439
|
</section>
|
|
402
440
|
<aside class="screen" part="screen">
|
|
403
441
|
<div class="screen-bar"><span class="live" hidden>에이전트 조작 중</span><span class="url"></span>
|
|
442
|
+
<button class="annot-btn" type="button" aria-pressed="false" title="화면의 요소를 골라 에이전트에게 요청 (선택자와 함께 전달)">주석</button>
|
|
404
443
|
<button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
|
|
405
444
|
<span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
|
|
406
|
-
<div class="viewport"><div class="ring"></div><div class="glow"></div><div class="agent-badge">에이전트 조작 중</div><img alt="에이전트 브라우저 화면" draggable="false"><textarea class="kbd" aria-label="브라우저 키보드 입력"></textarea><div class="placeholder">열린 terminal-browser 없음</div><div class="
|
|
445
|
+
<div class="viewport"><div class="ring"></div><div class="glow"></div><div class="agent-badge">에이전트 조작 중</div><img alt="에이전트 브라우저 화면" draggable="false"><textarea class="kbd" aria-label="브라우저 키보드 입력"></textarea><div class="placeholder">열린 terminal-browser 없음</div><div class="hl hover"></div><div class="hl sel"></div><div class="hl-label"></div>
|
|
446
|
+
<form class="note" part="note" hidden><div class="note-target"></div><textarea aria-label="선택한 요소에 대한 요청" placeholder="이 요소에 대한 요청 (Enter 전송, Esc 취소)"></textarea>
|
|
447
|
+
<div class="note-row"><span class="note-hint"></span><button type="submit" class="primary">보내기</button></div></form><div class="captions" part="captions" aria-live="polite"></div>
|
|
448
|
+
<div class="busy" part="busy" role="status"><span class="spinner"></span><span>답변 준비 중</span></div>
|
|
407
449
|
<div class="hand-wrap">
|
|
408
450
|
<form class="ask" part="ask" hidden><textarea aria-label="에이전트에게 질문" placeholder="에이전트에게 질문 (Enter 전송, Esc 닫기)"></textarea>
|
|
409
451
|
<div class="ask-row"><span class="ask-hint"></span><button type="submit" class="primary">보내기</button></div></form>
|
|
@@ -422,7 +464,14 @@ class AgentBridge extends HTMLElement {
|
|
|
422
464
|
#root = this.attachShadow({ mode: "open" });
|
|
423
465
|
#es = null;
|
|
424
466
|
#screenEs = null;
|
|
425
|
-
#
|
|
467
|
+
#ws = null; // /input: control input and annotate inspect queries
|
|
468
|
+
#control = false;
|
|
469
|
+
#annot = false;
|
|
470
|
+
#hover = null; // inspected element under the pointer (annotate)
|
|
471
|
+
#hoverPt = null;
|
|
472
|
+
#sel = null; // selected element (annotate)
|
|
473
|
+
#inspectId = 0;
|
|
474
|
+
#inspectWait = new Map();
|
|
426
475
|
#terminalCleanup = null;
|
|
427
476
|
#terminalVersion = 0;
|
|
428
477
|
#terminalStyles = null;
|
|
@@ -455,7 +504,9 @@ class AgentBridge extends HTMLElement {
|
|
|
455
504
|
live: q(".live"), url: q(".screen-bar .url"), viewport: q(".viewport"), img: q(".viewport img"),
|
|
456
505
|
controlBtn: q(".control-btn"), kbd: q(".kbd"),
|
|
457
506
|
body: q(".body"), screen: q(".screen"), captions: q(".captions"),
|
|
458
|
-
hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
|
|
507
|
+
busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
|
|
508
|
+
annotBtn: q(".annot-btn"), hlHover: q(".hl.hover"), hlSel: q(".hl.sel"), hlLabel: q(".hl-label"),
|
|
509
|
+
note: q(".note"), noteBox: q(".note textarea"), noteHint: q(".note-hint"), noteTarget: q(".note-target"),
|
|
459
510
|
};
|
|
460
511
|
this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
|
|
461
512
|
this.#bindGrip();
|
|
@@ -467,6 +518,7 @@ class AgentBridge extends HTMLElement {
|
|
|
467
518
|
});
|
|
468
519
|
loadMarkdown().then((md) => (this.#md = md));
|
|
469
520
|
this.#bindControl();
|
|
521
|
+
this.#bindAnnotate();
|
|
470
522
|
this.#bindHand();
|
|
471
523
|
q(".term-btn").addEventListener("click", () => this.#showTerminal(!this.hasAttribute("terminal-open")));
|
|
472
524
|
q(".terminal-retry").addEventListener("click", () => this.#showTerminal(true));
|
|
@@ -490,6 +542,7 @@ class AgentBridge extends HTMLElement {
|
|
|
490
542
|
this.#screenEs?.close();
|
|
491
543
|
this.#screenEs = null;
|
|
492
544
|
this.#setControl(false);
|
|
545
|
+
this.#setAnnotate(false);
|
|
493
546
|
}
|
|
494
547
|
attributeChangedCallback(name) {
|
|
495
548
|
if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "agello";
|
|
@@ -506,6 +559,7 @@ class AgentBridge extends HTMLElement {
|
|
|
506
559
|
q(".term-btn").setAttribute("aria-pressed", String(on));
|
|
507
560
|
if (!on) return;
|
|
508
561
|
this.#setControl(false);
|
|
562
|
+
this.#setAnnotate(false);
|
|
509
563
|
q(".terminal-status").textContent = "터미널 로딩 중…";
|
|
510
564
|
try {
|
|
511
565
|
if (!this.#terminalStyles) {
|
|
@@ -772,12 +826,12 @@ class AgentBridge extends HTMLElement {
|
|
|
772
826
|
}));
|
|
773
827
|
}
|
|
774
828
|
|
|
775
|
-
async send(text, action = "message",
|
|
829
|
+
async send(text, action = "message", extra = {}) {
|
|
776
830
|
try {
|
|
777
831
|
const res = await fetch(`${this.server}/send`, {
|
|
778
832
|
method: "POST",
|
|
779
833
|
headers: { "Content-Type": "application/json" },
|
|
780
|
-
body: JSON.stringify({ action, text
|
|
834
|
+
body: JSON.stringify({ ...extra, action, text }),
|
|
781
835
|
});
|
|
782
836
|
return await res.json();
|
|
783
837
|
} catch {
|
|
@@ -816,7 +870,8 @@ class AgentBridge extends HTMLElement {
|
|
|
816
870
|
on("message", (m) => {
|
|
817
871
|
this.#addMessage(m);
|
|
818
872
|
this.#saveMessage(m);
|
|
819
|
-
|
|
873
|
+
// script lines carry their own on-screen time (>= 10s), others use CAPTION_MS
|
|
874
|
+
if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined);
|
|
820
875
|
if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
|
|
821
876
|
this.#emit("agent-message", m);
|
|
822
877
|
});
|
|
@@ -842,7 +897,7 @@ class AgentBridge extends HTMLElement {
|
|
|
842
897
|
|
|
843
898
|
#syncScreen() {
|
|
844
899
|
const want = this.hasAttribute("screen") || this.#present.on;
|
|
845
|
-
if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); return; }
|
|
900
|
+
if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); this.#setAnnotate(false); return; }
|
|
846
901
|
if (this.#screenEs) return;
|
|
847
902
|
const es = (this.#screenEs = new EventSource(`${this.server}/screen`));
|
|
848
903
|
es.addEventListener("meta", (e) => this.#renderScreenMeta(JSON.parse(e.data)));
|
|
@@ -851,6 +906,7 @@ class AgentBridge extends HTMLElement {
|
|
|
851
906
|
this.#frame = { w: f.w, h: f.h };
|
|
852
907
|
this.$.img.src = `data:image/jpeg;base64,${f.data}`;
|
|
853
908
|
this.$.viewport.classList.add("has");
|
|
909
|
+
if (this.#annot) this.#annotFrame();
|
|
854
910
|
});
|
|
855
911
|
es.onerror = () => this.#renderScreenMeta({ connected: false });
|
|
856
912
|
}
|
|
@@ -862,8 +918,9 @@ class AgentBridge extends HTMLElement {
|
|
|
862
918
|
const was = this.#present.on;
|
|
863
919
|
this.#present = p ?? { on: false };
|
|
864
920
|
this.#emit("agent-present", this.#present);
|
|
921
|
+
this.#renderBusy();
|
|
865
922
|
if (on === was) return;
|
|
866
|
-
if (on) { this.#showTerminal(false); this.#setControl(false); }
|
|
923
|
+
if (on) { this.#showTerminal(false); this.#setControl(false); this.#setAnnotate(false); }
|
|
867
924
|
else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#handUp = false; }
|
|
868
925
|
this.#syncScreen();
|
|
869
926
|
// FLIP: grow from (or shrink back to) the side panel
|
|
@@ -879,6 +936,12 @@ class AgentBridge extends HTMLElement {
|
|
|
879
936
|
{ duration: on ? 420 : 360, easing: "cubic-bezier(.2, .7, .2, 1)" });
|
|
880
937
|
}
|
|
881
938
|
|
|
939
|
+
#renderBusy() {
|
|
940
|
+
const s = this.#status;
|
|
941
|
+
const busy = this.#present.on && s?.alive && s.status === "working" && this.#present.player?.state !== "playing";
|
|
942
|
+
this.$.busy.classList.toggle("on", !!busy);
|
|
943
|
+
}
|
|
944
|
+
|
|
882
945
|
// Raise hand: open a small input over the presentation and send it as a message.
|
|
883
946
|
#bindHand() {
|
|
884
947
|
const { hand, ask, askBox, askHint } = this.$;
|
|
@@ -938,7 +1001,7 @@ class AgentBridge extends HTMLElement {
|
|
|
938
1001
|
|
|
939
1002
|
// Agent reply as a short-lived bubble over the presented screen.
|
|
940
1003
|
// kind "user": the viewer's own question (plain text, accent colour).
|
|
941
|
-
#caption(text, kind = "agent") {
|
|
1004
|
+
#caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS) {
|
|
942
1005
|
if (!text?.trim()) return;
|
|
943
1006
|
const el = document.createElement("div");
|
|
944
1007
|
el.className = `caption ${kind}`;
|
|
@@ -953,7 +1016,7 @@ class AgentBridge extends HTMLElement {
|
|
|
953
1016
|
el.classList.add("out");
|
|
954
1017
|
setTimeout(() => el.remove(), 500);
|
|
955
1018
|
};
|
|
956
|
-
setTimeout(leave,
|
|
1019
|
+
setTimeout(leave, ms);
|
|
957
1020
|
const live = [...box.children].filter((c) => !c.classList.contains("out"));
|
|
958
1021
|
live.slice(0, Math.max(0, live.length - AgentBridge.CAPTION_MAX)).forEach((c) => c.__leave?.());
|
|
959
1022
|
el.__leave = leave;
|
|
@@ -996,24 +1059,43 @@ class AgentBridge extends HTMLElement {
|
|
|
996
1059
|
|
|
997
1060
|
// ---------- user control ----------
|
|
998
1061
|
|
|
999
|
-
|
|
1000
|
-
|
|
1062
|
+
// One socket for both modes; open while control or annotate is on.
|
|
1063
|
+
#syncWs() {
|
|
1064
|
+
const want = this.#control || this.#annot;
|
|
1065
|
+
if (want && !this.#ws) {
|
|
1001
1066
|
const ws = new WebSocket(`${this.server.replace(/^http/, "ws")}/input`);
|
|
1002
|
-
ws.
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1067
|
+
ws.onmessage = (e) => {
|
|
1068
|
+
let m;
|
|
1069
|
+
try { m = JSON.parse(e.data); } catch { return; }
|
|
1070
|
+
this.#inspectWait.get(m.id)?.(m.result ?? null);
|
|
1071
|
+
};
|
|
1072
|
+
ws.onclose = () => {
|
|
1073
|
+
if (this.#ws !== ws) return;
|
|
1074
|
+
this.#ws = null;
|
|
1075
|
+
this.#setControl(false);
|
|
1076
|
+
this.#setAnnotate(false);
|
|
1077
|
+
};
|
|
1078
|
+
this.#ws = ws;
|
|
1079
|
+
} else if (!want && this.#ws) {
|
|
1080
|
+
const ws = this.#ws;
|
|
1081
|
+
this.#ws = null;
|
|
1007
1082
|
ws.close();
|
|
1008
1083
|
}
|
|
1009
|
-
this
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1084
|
+
if (!this.#ws) { this.#inspectWait.forEach((r) => r(null)); this.#inspectWait.clear(); }
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
#setControl(on) {
|
|
1088
|
+
this.#control = !!on && !this.#present.on;
|
|
1089
|
+
if (this.#control) this.#setAnnotate(false);
|
|
1090
|
+
this.#syncWs();
|
|
1091
|
+
this.$.controlBtn.setAttribute("aria-checked", String(this.#control));
|
|
1092
|
+
this.$.controlBtn.querySelector(".state-label").textContent = this.#control ? "ON" : "OFF";
|
|
1093
|
+
this.$.viewport.classList.toggle("control", this.#control);
|
|
1094
|
+
if (this.#control) this.$.kbd.focus();
|
|
1013
1095
|
}
|
|
1014
1096
|
|
|
1015
1097
|
#cdp(method, params) {
|
|
1016
|
-
const ws = this.#
|
|
1098
|
+
const ws = this.#ws;
|
|
1017
1099
|
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ method, params }));
|
|
1018
1100
|
}
|
|
1019
1101
|
|
|
@@ -1027,11 +1109,11 @@ class AgentBridge extends HTMLElement {
|
|
|
1027
1109
|
|
|
1028
1110
|
#bindControl() {
|
|
1029
1111
|
const { img, kbd, controlBtn } = this.$;
|
|
1030
|
-
const on = () =>
|
|
1112
|
+
const on = () => this.#control && this.#frame.w > 0;
|
|
1031
1113
|
const mods = (e) => (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
|
|
1032
1114
|
const BUTTONS = ["left", "middle", "right"];
|
|
1033
1115
|
|
|
1034
|
-
controlBtn.addEventListener("click", () =>
|
|
1116
|
+
controlBtn.addEventListener("click", () => this.#setControl(!this.#control));
|
|
1035
1117
|
|
|
1036
1118
|
img.addEventListener("mousedown", (e) => {
|
|
1037
1119
|
if (!on()) return;
|
|
@@ -1059,7 +1141,7 @@ class AgentBridge extends HTMLElement {
|
|
|
1059
1141
|
});
|
|
1060
1142
|
});
|
|
1061
1143
|
img.addEventListener("wheel", (e) => {
|
|
1062
|
-
if (!on()) return;
|
|
1144
|
+
if (!on() && !(this.#annot && this.#frame.w > 0)) return; // annotate: scroll to reach the element
|
|
1063
1145
|
e.preventDefault();
|
|
1064
1146
|
this.#cdp("Input.dispatchMouseEvent", { type: "mouseWheel", ...this.#point(e), deltaX: e.deltaX, deltaY: e.deltaY,
|
|
1065
1147
|
modifiers: mods(e) });
|
|
@@ -1097,6 +1179,163 @@ class AgentBridge extends HTMLElement {
|
|
|
1097
1179
|
});
|
|
1098
1180
|
}
|
|
1099
1181
|
|
|
1182
|
+
// ---------- annotate ----------
|
|
1183
|
+
|
|
1184
|
+
#setAnnotate(on) {
|
|
1185
|
+
on = !!on && !this.#present.on;
|
|
1186
|
+
if (on === this.#annot) return;
|
|
1187
|
+
this.#annot = on;
|
|
1188
|
+
if (on) this.#setControl(false);
|
|
1189
|
+
else { this.#hover = null; this.#hoverPt = null; this.#select(null); }
|
|
1190
|
+
this.#syncWs();
|
|
1191
|
+
this.$.annotBtn.setAttribute("aria-pressed", String(on));
|
|
1192
|
+
this.$.viewport.classList.toggle("annot", on);
|
|
1193
|
+
this.#drawHl();
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Element at a frame point ({x, y}) or by selector; null if unavailable.
|
|
1197
|
+
#inspect(q, full = false) {
|
|
1198
|
+
const ws = this.#ws;
|
|
1199
|
+
if (ws?.readyState !== WebSocket.OPEN) return Promise.resolve(null);
|
|
1200
|
+
const id = ++this.#inspectId;
|
|
1201
|
+
return new Promise((resolve) => {
|
|
1202
|
+
const done = (r) => { clearTimeout(t); this.#inspectWait.delete(id); resolve(r); };
|
|
1203
|
+
const t = setTimeout(() => done(null), 3000);
|
|
1204
|
+
this.#inspectWait.set(id, done);
|
|
1205
|
+
ws.send(JSON.stringify({ id, inspect: { ...q, full } }));
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Latest pointer position wins; at most one hover query in flight.
|
|
1210
|
+
#probing = false;
|
|
1211
|
+
#probeAgain = false;
|
|
1212
|
+
async #probeHover() {
|
|
1213
|
+
if (this.#probing) { this.#probeAgain = true; return; }
|
|
1214
|
+
this.#probing = true;
|
|
1215
|
+
try {
|
|
1216
|
+
do {
|
|
1217
|
+
this.#probeAgain = false;
|
|
1218
|
+
const p = this.#hoverPt;
|
|
1219
|
+
if (!p || !this.#annot) break;
|
|
1220
|
+
const r = await this.#inspect(p);
|
|
1221
|
+
if (this.#hoverPt && this.#annot) { this.#hover = r; this.#drawHl(); }
|
|
1222
|
+
} while (this.#probeAgain);
|
|
1223
|
+
} finally {
|
|
1224
|
+
this.#probing = false;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// The page changed (scroll, layout, HMR): follow the selected element and
|
|
1229
|
+
// re-check what is under the pointer.
|
|
1230
|
+
#tracking = false;
|
|
1231
|
+
async #annotFrame() {
|
|
1232
|
+
if (this.#hoverPt) this.#probeHover();
|
|
1233
|
+
const sel = this.#sel;
|
|
1234
|
+
if (!sel || this.#tracking) { this.#drawHl(); return; }
|
|
1235
|
+
this.#tracking = true;
|
|
1236
|
+
try {
|
|
1237
|
+
const r = await this.#inspect({ selector: sel.selector });
|
|
1238
|
+
if (r && this.#sel === sel) sel.rect = r.rect; // gone from the page: keep the last box
|
|
1239
|
+
} finally {
|
|
1240
|
+
this.#tracking = false;
|
|
1241
|
+
}
|
|
1242
|
+
this.#drawHl();
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
#select(t) {
|
|
1246
|
+
const { note, noteBox, noteHint, noteTarget } = this.$;
|
|
1247
|
+
this.#sel = t;
|
|
1248
|
+
note.hidden = !t;
|
|
1249
|
+
noteHint.textContent = "";
|
|
1250
|
+
if (t) {
|
|
1251
|
+
noteTarget.textContent = t.selector;
|
|
1252
|
+
noteTarget.title = t.selector;
|
|
1253
|
+
noteBox.focus();
|
|
1254
|
+
}
|
|
1255
|
+
this.#drawHl();
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Frame (CSS px of the page viewport) -> position over the displayed image.
|
|
1259
|
+
#drawHl() {
|
|
1260
|
+
const { viewport, img, hlHover, hlSel, hlLabel, note } = this.$;
|
|
1261
|
+
const show = this.#annot && viewport.classList.contains("has") && this.#frame.w > 0;
|
|
1262
|
+
const vr = viewport.getBoundingClientRect();
|
|
1263
|
+
const ir = img.getBoundingClientRect();
|
|
1264
|
+
const sx = ir.width / this.#frame.w, sy = ir.height / this.#frame.h;
|
|
1265
|
+
const box = (r) => {
|
|
1266
|
+
if (!show || !r) return null;
|
|
1267
|
+
const x0 = Math.max(r.x, 0), y0 = Math.max(r.y, 0);
|
|
1268
|
+
const x1 = Math.min(r.x + r.w, this.#frame.w), y1 = Math.min(r.y + r.h, this.#frame.h);
|
|
1269
|
+
if (x1 <= x0 || y1 <= y0) return null;
|
|
1270
|
+
return { left: ir.left - vr.left + x0 * sx, top: ir.top - vr.top + y0 * sy, width: (x1 - x0) * sx, height: (y1 - y0) * sy };
|
|
1271
|
+
};
|
|
1272
|
+
const place = (el, b) => {
|
|
1273
|
+
el.classList.toggle("on", !!b);
|
|
1274
|
+
if (b) Object.assign(el.style, { left: `${b.left}px`, top: `${b.top}px`, width: `${b.width}px`, height: `${b.height}px` });
|
|
1275
|
+
return b;
|
|
1276
|
+
};
|
|
1277
|
+
const hb = place(hlHover, box(this.#hover?.rect));
|
|
1278
|
+
const sb = place(hlSel, box(this.#sel?.rect));
|
|
1279
|
+
|
|
1280
|
+
hlLabel.classList.toggle("on", !!hb);
|
|
1281
|
+
if (hb) {
|
|
1282
|
+
const r = this.#hover.rect;
|
|
1283
|
+
hlLabel.textContent = `${this.#hover.label} ${Math.round(r.w)}×${Math.round(r.h)}`;
|
|
1284
|
+
// above the box, else below it, else inside its top edge
|
|
1285
|
+
const top = hb.top >= 22 ? hb.top - 22 : hb.top + hb.height + 26 <= vr.height ? hb.top + hb.height + 4 : hb.top + 4;
|
|
1286
|
+
Object.assign(hlLabel.style, { left: `${hb.left + (top === hb.top + 4 ? 4 : 0)}px`, top: `${top}px` });
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// input below the selected element, above it if there is no room
|
|
1290
|
+
if (!note.hidden) {
|
|
1291
|
+
const W = note.offsetWidth, H = note.offsetHeight, pad = 8;
|
|
1292
|
+
const b = sb ?? { left: pad, top: pad, width: 0, height: 0 };
|
|
1293
|
+
let top = b.top + b.height + pad;
|
|
1294
|
+
if (top + H > vr.height - pad) top = b.top - H - pad;
|
|
1295
|
+
top = Math.max(pad, Math.min(top, vr.height - H - pad));
|
|
1296
|
+
const left = Math.max(pad, Math.min(b.left, vr.width - W - pad));
|
|
1297
|
+
Object.assign(note.style, { left: `${left}px`, top: `${top}px` });
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
#bindAnnotate() {
|
|
1302
|
+
const { img, annotBtn, note, noteBox, noteHint } = this.$;
|
|
1303
|
+
annotBtn.addEventListener("click", () => this.#setAnnotate(!this.#annot));
|
|
1304
|
+
img.addEventListener("mousemove", (e) => {
|
|
1305
|
+
if (!this.#annot || !this.#frame.w) return;
|
|
1306
|
+
this.#hoverPt = this.#point(e);
|
|
1307
|
+
this.#probeHover();
|
|
1308
|
+
});
|
|
1309
|
+
img.addEventListener("mouseleave", () => {
|
|
1310
|
+
if (!this.#annot) return;
|
|
1311
|
+
this.#hoverPt = null;
|
|
1312
|
+
this.#hover = null;
|
|
1313
|
+
this.#drawHl();
|
|
1314
|
+
});
|
|
1315
|
+
img.addEventListener("click", async (e) => {
|
|
1316
|
+
if (!this.#annot || !this.#frame.w) return;
|
|
1317
|
+
e.preventDefault();
|
|
1318
|
+
const t = await this.#inspect(this.#point(e), true);
|
|
1319
|
+
if (t && this.#annot) this.#select(t);
|
|
1320
|
+
});
|
|
1321
|
+
new ResizeObserver(() => this.#drawHl()).observe(img);
|
|
1322
|
+
note.addEventListener("submit", async (e) => {
|
|
1323
|
+
e.preventDefault();
|
|
1324
|
+
const text = noteBox.value.trim();
|
|
1325
|
+
const target = this.#sel;
|
|
1326
|
+
if (!text || !target) return;
|
|
1327
|
+
noteHint.textContent = "보내는 중…";
|
|
1328
|
+
const res = await this.send(text, "annotate", { target });
|
|
1329
|
+
if (!res.ok) { noteHint.textContent = `전송 실패: ${res.error}`; return; }
|
|
1330
|
+
noteBox.value = "";
|
|
1331
|
+
if (this.#sel === target) this.#select(null);
|
|
1332
|
+
});
|
|
1333
|
+
noteBox.addEventListener("keydown", (e) => {
|
|
1334
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); note.requestSubmit(); }
|
|
1335
|
+
if (e.key === "Escape") { e.preventDefault(); this.#select(null); }
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1100
1339
|
#renderScreenMeta(m) {
|
|
1101
1340
|
const { live, url, viewport } = this.$;
|
|
1102
1341
|
if (!m.connected) {
|
|
@@ -1112,6 +1351,7 @@ class AgentBridge extends HTMLElement {
|
|
|
1112
1351
|
url.textContent = m.title ? `${m.title} · ${m.url}` : m.url || "";
|
|
1113
1352
|
url.title = m.url || "";
|
|
1114
1353
|
}
|
|
1354
|
+
this.#drawHl();
|
|
1115
1355
|
this.#emit("agent-screen", m);
|
|
1116
1356
|
}
|
|
1117
1357
|
|
|
@@ -1248,7 +1488,7 @@ class AgentBridge extends HTMLElement {
|
|
|
1248
1488
|
return;
|
|
1249
1489
|
} else if (m.role === "assistant") {
|
|
1250
1490
|
wrap.className = "msg assistant";
|
|
1251
|
-
meta.textContent =
|
|
1491
|
+
meta.textContent = `에이전트${m.script ? ` · 대본 ${m.script}` : ""} · ${time}`;
|
|
1252
1492
|
if (this.#md) bubble.innerHTML = this.#md(m.text);
|
|
1253
1493
|
else { bubble.textContent = m.text; bubble.classList.add("plain"); }
|
|
1254
1494
|
} else if (m.role === "browser") {
|
|
@@ -1310,6 +1550,7 @@ class AgentBridge extends HTMLElement {
|
|
|
1310
1550
|
this.$.buttons.forEach((b) => (b.disabled = !canSend));
|
|
1311
1551
|
if (!s || !s.alive || s.status === "idle") this.#tools.clear();
|
|
1312
1552
|
this.#renderActivity();
|
|
1553
|
+
this.#renderBusy();
|
|
1313
1554
|
this.#emit("agent-status", s);
|
|
1314
1555
|
}
|
|
1315
1556
|
|
|
@@ -1318,7 +1559,7 @@ class AgentBridge extends HTMLElement {
|
|
|
1318
1559
|
const images = this.#images.map(({ type, data }) => ({ type, data }));
|
|
1319
1560
|
if (action === "message" && !text && !images.length) return;
|
|
1320
1561
|
this.$.buttons.forEach((b) => (b.disabled = true));
|
|
1321
|
-
const res = await this.send(text, action, images);
|
|
1562
|
+
const res = await this.send(text, action, { images: images.length ? images : undefined });
|
|
1322
1563
|
if (res.ok) {
|
|
1323
1564
|
this.$.box.value = "";
|
|
1324
1565
|
this.$.hint.textContent = "";
|