agello 0.1.4 → 0.2.1
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 +34 -0
- package/bin/agello.ts +110 -0
- package/package.json +1 -1
- package/src/player.ts +234 -0
- package/src/screencast.ts +88 -5
- package/src/server.ts +147 -9
- package/web/agent-bridge.js +239 -7
package/README.md
CHANGED
|
@@ -67,11 +67,45 @@ The approve / reject buttons send `action=approve` / `action=reject`.
|
|
|
67
67
|
| `agello stop [--port N \| --pane ID \| --all]` | Stop a server (default: this pane's) |
|
|
68
68
|
| `agello status [--json]` | List running servers, agent state and screen browser |
|
|
69
69
|
| `agello open [--port N \| --pane ID]` | Open the page (default: this pane's server) |
|
|
70
|
+
| `agello present [x,y,w,h] [--port N \| --pane ID]` | Presentation mode (see below) |
|
|
71
|
+
| `agello present stop` | End presentation mode |
|
|
72
|
+
| `agello script load <file> \| show` | Load / show a presentation script |
|
|
73
|
+
| `agello present resume \| pause \| goto <n[.m]>` | Play the script from where it stopped / stop it / move to a step |
|
|
70
74
|
|
|
71
75
|
`start` options: `--pane <id>`, `--port <n>` (default: first free from 8765), `--session <id>`, `--browser <terminal-browser key>`, `--allow-origin <origin>` (repeatable), `--open`, `--foreground`.
|
|
72
76
|
|
|
73
77
|
State and logs: `~/.local/state/agello/<port>.json`, `<port>.log`.
|
|
74
78
|
|
|
79
|
+
### Presentation mode
|
|
80
|
+
|
|
81
|
+
`agello present x,y,w,h` (CSS px of the browser's visible viewport; omit for the whole screen):
|
|
82
|
+
|
|
83
|
+
- only that rectangle is sent to the page (`Page.captureScreenshot` with a clip, at device resolution)
|
|
84
|
+
- the screen panel expands to fill the window
|
|
85
|
+
- agent replies appear over it as bubbles that fade after 10s
|
|
86
|
+
- `agello present stop` returns to the normal layout; the replies are in the chat as usual
|
|
87
|
+
- the viewer can ask a question with the raise-hand button (its bubble fades after 10s too) or end the presentation with the end button; the agent then receives `[browser] action=present-stop`
|
|
88
|
+
- raising the hand tells the agent at once (`action=hand-raise`) so it can pause; closing the input without asking sends `action=hand-lower`
|
|
89
|
+
|
|
90
|
+
Run `present` again to move the crop. User control is off while presenting.
|
|
91
|
+
|
|
92
|
+
#### Scripts
|
|
93
|
+
|
|
94
|
+
Write the talk ahead so each line appears the moment its screen does:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{ "rect": "28,157,688,477",
|
|
98
|
+
"steps": [
|
|
99
|
+
{ "go": "#1", "say": ["First line", "Second line"] },
|
|
100
|
+
{ "go": "#2", "say": ["..."], "hold": 6 } ] }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- 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
|
|
104
|
+
- `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
|
|
105
|
+
- raising a hand pauses at once; the agent gets `action=hand-raise` with the position (`마지막 표시 2.1 · 다음 2.2`)
|
|
106
|
+
- `script load` again after editing keeps the position; `present goto 2.2` then `present resume` replays from a fixed line
|
|
107
|
+
- 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
|
|
108
|
+
|
|
75
109
|
## Embed
|
|
76
110
|
|
|
77
111
|
```html
|
package/bin/agello.ts
CHANGED
|
@@ -22,6 +22,18 @@ Usage:
|
|
|
22
22
|
${NAME} stop [--port N|--pane ID|--all] Stop a server (default: this pane's)
|
|
23
23
|
${NAME} status [--json] List running servers
|
|
24
24
|
${NAME} open [--port N|--pane ID] Open the page (default: this pane's server)
|
|
25
|
+
${NAME} present [x,y,w,h] [--port N|--pane ID] Presentation mode: the page shows only the screen,
|
|
26
|
+
full size, cropped to the rect (CSS px of the visible viewport; omit for the
|
|
27
|
+
whole screen), with agent replies as bubbles that fade after 10s.
|
|
28
|
+
Run again to move the crop.
|
|
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
|
|
25
37
|
${NAME} help | --version
|
|
26
38
|
|
|
27
39
|
start options:
|
|
@@ -279,6 +291,98 @@ async function cmdOpen(argv: string[]) {
|
|
|
279
291
|
console.log(inst.url);
|
|
280
292
|
}
|
|
281
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
|
+
|
|
344
|
+
async function cmdPresent(argv: string[]) {
|
|
345
|
+
const { values: o, positionals } = parseArgs({
|
|
346
|
+
args: argv,
|
|
347
|
+
options: { port: { type: "string" }, pane: { type: "string" } },
|
|
348
|
+
allowPositionals: true,
|
|
349
|
+
});
|
|
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
|
+
}
|
|
359
|
+
const arg = positionals.join(",");
|
|
360
|
+
let body: object;
|
|
361
|
+
if (arg === "stop") body = { stop: true };
|
|
362
|
+
else if (!arg) body = {};
|
|
363
|
+
else {
|
|
364
|
+
const v = arg.split(/[\s,]+/).filter(Boolean).map(Number);
|
|
365
|
+
if (v.length !== 4 || !v.every(Number.isFinite)) fail(`rect must be x,y,w,h (got "${arg}")`);
|
|
366
|
+
body = { rect: { x: v[0], y: v[1], w: v[2], h: v[3] } };
|
|
367
|
+
}
|
|
368
|
+
let res: any;
|
|
369
|
+
try {
|
|
370
|
+
const r = await fetch(`${inst.url}/present`, {
|
|
371
|
+
method: "POST",
|
|
372
|
+
headers: { "Content-Type": "application/json" },
|
|
373
|
+
body: JSON.stringify(body),
|
|
374
|
+
signal: AbortSignal.timeout(4000),
|
|
375
|
+
});
|
|
376
|
+
res = await r.json();
|
|
377
|
+
} catch {
|
|
378
|
+
fail(`server unreachable: ${inst.url}`);
|
|
379
|
+
}
|
|
380
|
+
if (!res?.ok) fail(res?.error ?? "failed");
|
|
381
|
+
const p = res.present;
|
|
382
|
+
if (!p.on) console.log("presentation: off");
|
|
383
|
+
else console.log(`presentation: on ${p.rect ? `rect=${p.rect.x},${p.rect.y},${p.rect.w},${p.rect.h}` : "(whole screen)"}`);
|
|
384
|
+
}
|
|
385
|
+
|
|
282
386
|
// ---------- main ----------
|
|
283
387
|
|
|
284
388
|
const [cmd, ...rest] = Bun.argv.slice(2);
|
|
@@ -295,6 +399,12 @@ switch (cmd) {
|
|
|
295
399
|
case "open":
|
|
296
400
|
await cmdOpen(rest);
|
|
297
401
|
break;
|
|
402
|
+
case "present":
|
|
403
|
+
await cmdPresent(rest);
|
|
404
|
+
break;
|
|
405
|
+
case "script":
|
|
406
|
+
await cmdScript(rest);
|
|
407
|
+
break;
|
|
298
408
|
case "--version":
|
|
299
409
|
case "-v":
|
|
300
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
|
@@ -14,6 +14,9 @@ type Browser = {
|
|
|
14
14
|
tabs: { id: number; url: string; title: string; active: boolean; targetId: string; agentControlled: boolean }[];
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
// Presentation crop: a rectangle of the visible viewport, in CSS pixels.
|
|
18
|
+
export type Rect = { x: number; y: number; w: number; h: number };
|
|
19
|
+
|
|
17
20
|
export type ScreenMeta = {
|
|
18
21
|
connected: boolean;
|
|
19
22
|
reason?: "browser_not_found" | "no_browser_in_tab" | "tab_unknown" | "no_active_tab";
|
|
@@ -28,11 +31,15 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
|
|
|
28
31
|
const enc = new TextEncoder();
|
|
29
32
|
|
|
30
33
|
let meta: ScreenMeta = { connected: false };
|
|
31
|
-
|
|
34
|
+
type Frame = { data: string; w: number; h: number; clip?: Rect };
|
|
35
|
+
let lastFrame: Frame | null = null; // full screencast frame
|
|
36
|
+
let lastCrop: Frame | null = null; // cropped frame while a clip is set
|
|
37
|
+
let clip: Rect | null = null;
|
|
32
38
|
let ws: WebSocket | null = null;
|
|
33
39
|
let wsTarget = "";
|
|
34
40
|
let msgId = 0;
|
|
35
41
|
let timer: Timer | null = null;
|
|
42
|
+
const pending = new Map<number, (result: any) => void>();
|
|
36
43
|
|
|
37
44
|
const emit = (c: ReadableStreamDefaultController, event: string, data: unknown) => {
|
|
38
45
|
try {
|
|
@@ -74,6 +81,63 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
|
|
|
74
81
|
ws?.close();
|
|
75
82
|
ws = null;
|
|
76
83
|
wsTarget = "";
|
|
84
|
+
pending.forEach((r) => r(null));
|
|
85
|
+
pending.clear();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// CDP call that resolves with the result (null on error or closed socket).
|
|
89
|
+
function request(method: string, params: object = {}): Promise<any> {
|
|
90
|
+
const sock = ws;
|
|
91
|
+
if (!sock || sock.readyState !== WebSocket.OPEN) return Promise.resolve(null);
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
const id = ++msgId;
|
|
94
|
+
const t = setTimeout(() => pending.delete(id) && resolve(null), 5000);
|
|
95
|
+
pending.set(id, (r) => (clearTimeout(t), resolve(r)));
|
|
96
|
+
sock.send(JSON.stringify({ id, method, params }));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// While a clip is set, only the clipped region is sent to viewers: every
|
|
101
|
+
// screencast frame (= the page changed) triggers one Page.captureScreenshot
|
|
102
|
+
// of the rectangle at full device resolution. At most one capture runs at a
|
|
103
|
+
// time; changes during a capture are coalesced into one more capture.
|
|
104
|
+
let capturing = false;
|
|
105
|
+
let dirty = false;
|
|
106
|
+
async function capture() {
|
|
107
|
+
if (!clip) return;
|
|
108
|
+
if (capturing) {
|
|
109
|
+
dirty = true;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
capturing = true;
|
|
113
|
+
try {
|
|
114
|
+
do {
|
|
115
|
+
dirty = false;
|
|
116
|
+
const c = clip;
|
|
117
|
+
if (!c) break;
|
|
118
|
+
// clip coordinates are document-relative; the rect is viewport-relative
|
|
119
|
+
const vv = (await request("Page.getLayoutMetrics"))?.cssVisualViewport;
|
|
120
|
+
if (!vv) break;
|
|
121
|
+
const shot = await request("Page.captureScreenshot", {
|
|
122
|
+
format: "jpeg",
|
|
123
|
+
quality: 85,
|
|
124
|
+
clip: { x: c.x + vv.pageX, y: c.y + vv.pageY, width: c.w, height: c.h, scale: 1 },
|
|
125
|
+
});
|
|
126
|
+
if (!shot?.data || clip !== c) continue;
|
|
127
|
+
lastCrop = { data: shot.data, w: c.w, h: c.h, clip: c };
|
|
128
|
+
broadcast("frame", lastCrop);
|
|
129
|
+
await Bun.sleep(100); // cap at ~10 captures/s
|
|
130
|
+
} while (dirty && clip);
|
|
131
|
+
} finally {
|
|
132
|
+
capturing = false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function setClip(next: Rect | null) {
|
|
137
|
+
clip = next;
|
|
138
|
+
lastCrop = null;
|
|
139
|
+
if (clip) capture();
|
|
140
|
+
else if (lastFrame) broadcast("frame", lastFrame);
|
|
77
141
|
}
|
|
78
142
|
|
|
79
143
|
function connect(port: number, targetId: string) {
|
|
@@ -88,14 +152,22 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
|
|
|
88
152
|
sock.onopen = () => {
|
|
89
153
|
call("Page.enable");
|
|
90
154
|
call("Page.startScreencast", { format: "jpeg", quality: 70, maxWidth: 1600, maxHeight: 1600 });
|
|
155
|
+
if (clip) capture();
|
|
91
156
|
};
|
|
92
157
|
sock.onmessage = (ev) => {
|
|
93
158
|
const msg = JSON.parse(String(ev.data));
|
|
159
|
+
if (msg.id && pending.has(msg.id)) {
|
|
160
|
+
const resolve = pending.get(msg.id)!;
|
|
161
|
+
pending.delete(msg.id);
|
|
162
|
+
resolve(msg.result ?? null);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
94
165
|
if (msg.method !== "Page.screencastFrame") return;
|
|
95
166
|
const { data, metadata, sessionId } = msg.params;
|
|
96
167
|
call("Page.screencastFrameAck", { sessionId });
|
|
97
168
|
lastFrame = { data, w: metadata.deviceWidth, h: metadata.deviceHeight };
|
|
98
|
-
|
|
169
|
+
if (clip) capture();
|
|
170
|
+
else broadcast("frame", lastFrame);
|
|
99
171
|
};
|
|
100
172
|
sock.onclose = () => {
|
|
101
173
|
if (ws === sock) {
|
|
@@ -122,6 +194,7 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
|
|
|
122
194
|
if (!b || !tab) {
|
|
123
195
|
disconnect();
|
|
124
196
|
lastFrame = null;
|
|
197
|
+
lastCrop = null;
|
|
125
198
|
setMeta({ connected: false, reason: reason ?? "no_active_tab" });
|
|
126
199
|
return;
|
|
127
200
|
}
|
|
@@ -146,6 +219,7 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
|
|
|
146
219
|
timer = null;
|
|
147
220
|
disconnect();
|
|
148
221
|
lastFrame = null;
|
|
222
|
+
lastCrop = null;
|
|
149
223
|
meta = { connected: false };
|
|
150
224
|
}
|
|
151
225
|
|
|
@@ -158,7 +232,8 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
|
|
|
158
232
|
ctrl = c;
|
|
159
233
|
viewers.add(c);
|
|
160
234
|
emit(c, "meta", meta);
|
|
161
|
-
|
|
235
|
+
const f = clip ? lastCrop : lastFrame;
|
|
236
|
+
if (f) emit(c, "frame", f);
|
|
162
237
|
ping = setInterval(() => emit(c, "ping", {}), 15000);
|
|
163
238
|
start();
|
|
164
239
|
},
|
|
@@ -181,7 +256,8 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
|
|
|
181
256
|
} catch {
|
|
182
257
|
return;
|
|
183
258
|
}
|
|
184
|
-
|
|
259
|
+
// input coordinates assume the full frame: ignore input while cropped
|
|
260
|
+
if (clip || !INPUT_METHODS.has(m?.method) || !ws || ws.readyState !== WebSocket.OPEN) return;
|
|
185
261
|
ws.send(JSON.stringify({ id: ++msgId, method: m.method, params: m.params ?? {} }));
|
|
186
262
|
}
|
|
187
263
|
|
|
@@ -192,5 +268,12 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
|
|
|
192
268
|
return browser ? { connected: false, browser: browser.key } : { connected: false, reason };
|
|
193
269
|
}
|
|
194
270
|
|
|
195
|
-
|
|
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
|
+
return { handle, input, describe, setClip, evaluate };
|
|
196
279
|
}
|
package/src/server.ts
CHANGED
|
@@ -7,11 +7,19 @@
|
|
|
7
7
|
// POST /send {action, text} -> herdr agent prompt
|
|
8
8
|
// GET /screen SSE: terminal-browser screen frames (CDP screencast)
|
|
9
9
|
// WS /input user control: CDP Input.* commands forwarded to the page
|
|
10
|
+
// GET /present presentation state
|
|
11
|
+
// POST /present {rect?: {x,y,w,h}} start (or move the crop), {stop: true} end
|
|
12
|
+
// ({stop: true, from: "viewer"}: ended from the page, the agent is told
|
|
13
|
+
// with "[browser] action=present-stop")
|
|
14
|
+
// GET /script presentation script + player position
|
|
15
|
+
// POST /script {script: {rect?, steps: [{go?, say: string[], hold?}]}} load (keeps position)
|
|
16
|
+
// POST /player {cmd: "resume" | "pause" | "goto", at?: "3" | "3.2"}
|
|
10
17
|
|
|
11
18
|
import { homedir } from "node:os";
|
|
12
19
|
import { join } from "node:path";
|
|
13
20
|
import { runJson } from "./run.ts";
|
|
14
|
-
import {
|
|
21
|
+
import { createPlayer, parsePos, parseScript, type PlayerInfo } from "./player.ts";
|
|
22
|
+
import { createScreencast, type Rect } from "./screencast.ts";
|
|
15
23
|
|
|
16
24
|
export type ServerOptions = {
|
|
17
25
|
port: number;
|
|
@@ -77,6 +85,17 @@ async function herdr(timeout: number, ...cmd: string[]): Promise<any> {
|
|
|
77
85
|
return (await runJson(["herdr", ...cmd], timeout)) ?? { error: { code: "herdr_failed" } };
|
|
78
86
|
}
|
|
79
87
|
|
|
88
|
+
// Presentation rect from the API: finite, non-negative origin, positive size (CSS px).
|
|
89
|
+
export function parseRect(r: any): Rect | null {
|
|
90
|
+
if (!r || typeof r !== "object") return null;
|
|
91
|
+
const v = [r.x, r.y, r.w, r.h].map(Number);
|
|
92
|
+
if (!v.every(Number.isFinite) || v[0] < 0 || v[1] < 0 || v[2] < 1 || v[3] < 1) return null;
|
|
93
|
+
const [x, y, w, h] = v.map(Math.round);
|
|
94
|
+
return { x, y, w, h };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type Present = { on: boolean; rect?: Rect; since?: string; player?: PlayerInfo };
|
|
98
|
+
|
|
80
99
|
type Status =
|
|
81
100
|
| { alive: false; reason: string; pane: string }
|
|
82
101
|
| { alive: true; status: string; pane: string; label?: string; session: string; title?: string };
|
|
@@ -291,6 +310,98 @@ export async function startServer(opts: ServerOptions) {
|
|
|
291
310
|
herdrTab: async () => (await agentInfo())?.tab_id,
|
|
292
311
|
});
|
|
293
312
|
|
|
313
|
+
// ----- presentation mode -----
|
|
314
|
+
// on: viewers show only the screen, full size, with agent replies as
|
|
315
|
+
// short-lived bubbles. rect: crop of the visible viewport (unset = whole screen).
|
|
316
|
+
|
|
317
|
+
let present: Present = { on: false };
|
|
318
|
+
const presentView = (): Present => ({ ...present, player: player.info() });
|
|
319
|
+
function setPresent(next: Present) {
|
|
320
|
+
present = next;
|
|
321
|
+
screen.setClip(next.on ? (next.rect ?? null) : null);
|
|
322
|
+
if (!next.on) player.stop();
|
|
323
|
+
broadcast("present", presentView());
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Script player: agello owns order and timing, the agent owns the deck
|
|
327
|
+
// (each step's `go`) and decides when to resume after a question.
|
|
328
|
+
const player = createPlayer({
|
|
329
|
+
async go(expr) {
|
|
330
|
+
const js = expr.startsWith("#") ? `location.hash = ${JSON.stringify(expr)}` : expr;
|
|
331
|
+
if (!(await screen.evaluate(js))) console.error(`present: go failed: ${expr}`);
|
|
332
|
+
},
|
|
333
|
+
say(text, at, hold) {
|
|
334
|
+
broadcast("message", { role: "assistant", text, ts: new Date().toISOString(), script: at, hold });
|
|
335
|
+
},
|
|
336
|
+
done() {
|
|
337
|
+
const n = player.info().steps;
|
|
338
|
+
promptAgent("present-done", `대본 끝까지 재생함 (${n}장)`);
|
|
339
|
+
},
|
|
340
|
+
changed() {
|
|
341
|
+
broadcast("present", presentView());
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// "마지막 표시 3.2 · 다음 3.3" for agent notices
|
|
346
|
+
const where = () => {
|
|
347
|
+
const i = player.info();
|
|
348
|
+
if (i.state === "empty") return "";
|
|
349
|
+
return [i.last && `마지막 표시 ${i.last}`, i.next && `다음 ${i.next}`].filter(Boolean).join(" · ");
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
async function scriptRequest(req: Request): Promise<Response> {
|
|
353
|
+
if (req.method !== "POST") return Response.json({ script: player.script(), player: player.info() });
|
|
354
|
+
const data = (await req.json().catch(() => ({}))) as { script?: unknown };
|
|
355
|
+
const script = parseScript(data.script);
|
|
356
|
+
if (typeof script === "string") return Response.json({ ok: false, error: script }, { status: 400 });
|
|
357
|
+
if (script.rect && !parseRect(script.rect))
|
|
358
|
+
return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
|
|
359
|
+
player.load(script);
|
|
360
|
+
return Response.json({ ok: true, player: player.info() });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function playerRequest(req: Request): Promise<Response> {
|
|
364
|
+
const data = (await req.json().catch(() => ({}))) as { cmd?: string; at?: string };
|
|
365
|
+
let error: string | null = null;
|
|
366
|
+
if (data.cmd === "resume") {
|
|
367
|
+
if (!present.on) {
|
|
368
|
+
const rect = player.script()?.rect;
|
|
369
|
+
setPresent({ on: true, rect: rect ? parseRect(rect)! : undefined, since: new Date().toISOString() });
|
|
370
|
+
}
|
|
371
|
+
error = player.resume();
|
|
372
|
+
} else if (data.cmd === "pause") player.pause();
|
|
373
|
+
else if (data.cmd === "goto") {
|
|
374
|
+
const pos = parsePos(data.at ?? "");
|
|
375
|
+
error = pos ? await player.goto(pos) : "invalid_position";
|
|
376
|
+
} else error = "unknown_cmd";
|
|
377
|
+
if (error) return Response.json({ ok: false, error, player: player.info() }, { status: 400 });
|
|
378
|
+
return Response.json({ ok: true, player: player.info() });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function presentRequest(req: Request): Promise<Response> {
|
|
382
|
+
const data = (await req.json().catch(() => ({}))) as { rect?: unknown; stop?: boolean; from?: string };
|
|
383
|
+
if (data.stop) {
|
|
384
|
+
const was = present.on;
|
|
385
|
+
setPresent({ on: false });
|
|
386
|
+
// Ended from the page: the agent may still be presenting, so tell it.
|
|
387
|
+
// (The CLI stop comes from the agent itself and needs no notice.)
|
|
388
|
+
let notified: string | undefined;
|
|
389
|
+
if (was && data.from === "viewer") {
|
|
390
|
+
const at = where();
|
|
391
|
+
const r = await promptAgent("present-stop", `사용자가 페이지에서 발표를 종료함${at ? ` (대본 정지: ${at})` : ""}`);
|
|
392
|
+
notified = r.ok ? "ok" : r.error;
|
|
393
|
+
}
|
|
394
|
+
return Response.json({ ok: true, present: presentView(), notified });
|
|
395
|
+
}
|
|
396
|
+
let rect: Rect | undefined;
|
|
397
|
+
if (data.rect != null) {
|
|
398
|
+
rect = parseRect(data.rect) ?? undefined;
|
|
399
|
+
if (!rect) return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
|
|
400
|
+
}
|
|
401
|
+
setPresent({ on: true, rect, since: present.on ? present.since : new Date().toISOString() });
|
|
402
|
+
return Response.json({ ok: true, present: presentView() });
|
|
403
|
+
}
|
|
404
|
+
|
|
294
405
|
// ----- HTTP -----
|
|
295
406
|
|
|
296
407
|
// Same-origin and localhost pages are allowed by default; add more with
|
|
@@ -329,6 +440,7 @@ export async function startServer(opts: ServerOptions) {
|
|
|
329
440
|
tools: [...pendingTools.keys()],
|
|
330
441
|
});
|
|
331
442
|
if (lastStatus) send(c, "status", lastStatus);
|
|
443
|
+
send(c, "present", presentView());
|
|
332
444
|
for (const t of pendingTools.values()) send(c, "tool_start", t);
|
|
333
445
|
ping = setInterval(() => send(c, "ping", {}), 15000);
|
|
334
446
|
},
|
|
@@ -342,22 +454,40 @@ export async function startServer(opts: ServerOptions) {
|
|
|
342
454
|
});
|
|
343
455
|
}
|
|
344
456
|
|
|
345
|
-
|
|
346
|
-
|
|
457
|
+
// Type a [browser] prompt into the agent session.
|
|
458
|
+
async function promptAgent(action: string, text: string): Promise<{ ok: boolean; error?: string; status?: number }> {
|
|
347
459
|
const st = await checkStatus();
|
|
348
|
-
if (!st.alive) return
|
|
349
|
-
if (st.status === "blocked") return
|
|
460
|
+
if (!st.alive) return { ok: false, error: st.reason, status: 409 };
|
|
461
|
+
if (st.status === "blocked") return { ok: false, error: "agent_blocked", status: 409 };
|
|
462
|
+
const res = await herdr(10000, "agent", "prompt", PANE, formatPrompt(action, text.trim()));
|
|
463
|
+
if (res?.error) return { ok: false, error: res.error.code, status: 502 };
|
|
464
|
+
return { ok: true };
|
|
465
|
+
}
|
|
350
466
|
|
|
467
|
+
async function sendPrompt(req: Request): Promise<Response> {
|
|
468
|
+
const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string };
|
|
351
469
|
const action = /^[\w-]{1,32}$/.test(data.action ?? "") ? data.action! : "message";
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
470
|
+
let text = data.text ?? "";
|
|
471
|
+
// Raised hand: stop the script at once (no agent round trip) and tell the
|
|
472
|
+
// agent where it stopped.
|
|
473
|
+
if (action === "hand-raise") {
|
|
474
|
+
player.pause();
|
|
475
|
+
const at = where();
|
|
476
|
+
if (at) text += ` (대본 정지: ${at})`;
|
|
477
|
+
}
|
|
478
|
+
const r = await promptAgent(action, text);
|
|
479
|
+
return r.ok ? Response.json({ ok: true }) : Response.json({ ok: false, error: r.error }, { status: r.status });
|
|
355
480
|
}
|
|
356
481
|
|
|
357
482
|
async function route(req: Request, url: URL): Promise<Response> {
|
|
358
483
|
switch (url.pathname) {
|
|
359
484
|
case "/status":
|
|
360
|
-
return Response.json({
|
|
485
|
+
return Response.json({
|
|
486
|
+
...(await checkStatus()),
|
|
487
|
+
browser: opts.browser,
|
|
488
|
+
screen: await screen.describe(),
|
|
489
|
+
present: presentView(),
|
|
490
|
+
});
|
|
361
491
|
case "/events":
|
|
362
492
|
return events();
|
|
363
493
|
case "/screen":
|
|
@@ -368,6 +498,14 @@ export async function startServer(opts: ServerOptions) {
|
|
|
368
498
|
});
|
|
369
499
|
case "/send":
|
|
370
500
|
if (req.method === "POST") return sendPrompt(req);
|
|
501
|
+
break;
|
|
502
|
+
case "/present":
|
|
503
|
+
return req.method === "POST" ? presentRequest(req) : Response.json(presentView());
|
|
504
|
+
case "/script":
|
|
505
|
+
return scriptRequest(req);
|
|
506
|
+
case "/player":
|
|
507
|
+
if (req.method === "POST") return playerRequest(req);
|
|
508
|
+
break;
|
|
371
509
|
}
|
|
372
510
|
return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
|
|
373
511
|
}
|
package/web/agent-bridge.js
CHANGED
|
@@ -23,10 +23,27 @@
|
|
|
23
23
|
// agent-tool detail: { phase: 'start'|'end', id, name?, summary?, isError? }
|
|
24
24
|
// (tool calls are also kept in the conversation as one-line rows, like the terminal)
|
|
25
25
|
// agent-screen detail: { connected, browser?, url?, title?, agentControlled? }
|
|
26
|
+
// agent-present detail: { on, rect?: {x,y,w,h}, since? }
|
|
27
|
+
//
|
|
28
|
+
// Presentation mode (started by the agent: `agello present [x,y,w,h]`)
|
|
29
|
+
// The screen panel expands to fill the window and shows only the given
|
|
30
|
+
// rectangle of the browser. Agent replies appear over it as bubbles that fade
|
|
31
|
+
// after 10s; they are also added to the chat, so it is all there when the
|
|
32
|
+
// presentation ends. User control is off while presenting.
|
|
33
|
+
// A raise-hand button next to the bubbles opens a small input so the viewer
|
|
34
|
+
// can ask the agent something mid-presentation (sent as action=message).
|
|
35
|
+
// Opening it tells the agent right away (action=hand-raise) so it can pause;
|
|
36
|
+
// closing it without asking sends action=hand-lower so it can go on.
|
|
37
|
+
// The viewer's questions show as bubbles too, with the same 10s lifetime.
|
|
38
|
+
// An end button above it stops the presentation for every viewer.
|
|
39
|
+
// Script lines (`agello script` + `present resume`) arrive as agent messages
|
|
40
|
+
// with `script: "step.line"`. While the script is not playing and the agent
|
|
41
|
+
// is working (e.g. answering a question), a small loader is shown.
|
|
26
42
|
//
|
|
27
43
|
// Methods
|
|
28
44
|
// el.send(text, action = 'message') -> Promise<{ok, error?}>
|
|
29
45
|
// el.clearHistory() clear saved chat for the current pane
|
|
46
|
+
// el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
|
|
30
47
|
|
|
31
48
|
const SCRIPT_ORIGIN = new URL(import.meta.url).origin;
|
|
32
49
|
|
|
@@ -53,7 +70,10 @@ const SCREEN_REASONS = {
|
|
|
53
70
|
tab_unknown: "에이전트의 herdr 탭을 확인할 수 없음",
|
|
54
71
|
no_active_tab: "브라우저에 활성 탭 없음",
|
|
55
72
|
};
|
|
56
|
-
const ACTIONS = {
|
|
73
|
+
const ACTIONS = {
|
|
74
|
+
message: "메시지", approve: "승인", reject: "거절",
|
|
75
|
+
"present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림",
|
|
76
|
+
};
|
|
57
77
|
|
|
58
78
|
const CSS = `
|
|
59
79
|
:host {
|
|
@@ -154,6 +174,68 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
154
174
|
.control-btn:focus-visible { outline: 2px solid var(--_accent); outline-offset: 2px; border-radius: 6px; }
|
|
155
175
|
@media (prefers-reduced-motion: reduce) { .control-btn .track, .control-btn .knob { transition: none; } }
|
|
156
176
|
.placeholder { font-size: 13px; color: var(--_muted); text-align: center; }
|
|
177
|
+
|
|
178
|
+
/* presentation mode: the screen panel covers the window, chat stays hidden underneath.
|
|
179
|
+
container-type would make the host the containing block of the fixed layer, so drop it. */
|
|
180
|
+
:host([presenting]) { container-type: normal; }
|
|
181
|
+
.body.present .screen { display: flex; position: fixed; inset: 0; z-index: 2147483000; border: 0;
|
|
182
|
+
width: auto; height: auto; background: #0b0b0c; }
|
|
183
|
+
.body.present .screen-bar, .body.present .ring, .body.present .glow, .body.present .agent-badge { display: none; }
|
|
184
|
+
.body.present .viewport { padding: 0; background: #0b0b0c; }
|
|
185
|
+
.body.present .viewport img { width: 100%; height: 100%; max-width: none; max-height: none; border-radius: 0;
|
|
186
|
+
box-shadow: none; }
|
|
187
|
+
.captions { display: none; }
|
|
188
|
+
.body.present .captions { position: absolute; left: 0; right: 0; bottom: 0; z-index: 4; display: flex;
|
|
189
|
+
flex-direction: column; align-items: center; gap: 10px; padding: 0 88px 28px; pointer-events: none; }
|
|
190
|
+
.caption { max-width: min(900px, 86%); padding: 12px 18px; border-radius: 16px; overflow-wrap: anywhere;
|
|
191
|
+
font-size: 18px; line-height: 1.5; color: #fff; background: rgba(20, 20, 22, .72);
|
|
192
|
+
-webkit-backdrop-filter: blur(16px) saturate(1.3); backdrop-filter: blur(16px) saturate(1.3);
|
|
193
|
+
box-shadow: 0 8px 30px rgba(0, 0, 0, .35); animation: capIn .3s ease; }
|
|
194
|
+
.caption.plain { white-space: pre-wrap; }
|
|
195
|
+
.caption.user { background: color-mix(in srgb, var(--_accent) 88%, transparent); }
|
|
196
|
+
.caption.out { opacity: 0; translate: 0 6px; transition: opacity .5s ease, translate .5s ease; }
|
|
197
|
+
.caption > :first-child { margin-top: 0; } .caption > :last-child { margin-bottom: 0; }
|
|
198
|
+
.caption p, .caption ul, .caption ol { margin: .35em 0; }
|
|
199
|
+
.caption code { font-family: ui-monospace, Menlo, monospace; font-size: .85em; background: rgba(255,255,255,.14);
|
|
200
|
+
padding: 1px 5px; border-radius: 4px; }
|
|
201
|
+
.caption pre { background: rgba(255,255,255,.1); padding: 8px 10px; border-radius: 8px; overflow-x: auto; }
|
|
202
|
+
.caption a { color: inherit; }
|
|
203
|
+
@keyframes capIn { from { opacity: 0; translate: 0 10px; } }
|
|
204
|
+
|
|
205
|
+
/* agent busy while presenting (answering, fixing the script): minimal loader */
|
|
206
|
+
.busy { display: none; }
|
|
207
|
+
.body.present .busy.on { position: absolute; left: 22px; bottom: 28px; z-index: 5; display: flex; align-items: center;
|
|
208
|
+
gap: 9px; padding: 9px 14px; border-radius: 999px; font-size: 13px; color: #fff; background: rgba(20, 20, 22, .72);
|
|
209
|
+
-webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); box-shadow: 0 8px 30px rgba(0,0,0,.35);
|
|
210
|
+
animation: capIn .25s ease; }
|
|
211
|
+
.busy .spinner { border-color: rgba(255,255,255,.25); border-top-color: #fff; }
|
|
212
|
+
|
|
213
|
+
/* raise hand: ask the agent mid-presentation */
|
|
214
|
+
.hand-wrap { display: none; }
|
|
215
|
+
.body.present .hand-wrap { position: absolute; right: 22px; bottom: 28px; z-index: 5; display: flex;
|
|
216
|
+
flex-direction: column; align-items: flex-end; gap: 10px; }
|
|
217
|
+
.hand, .end { width: 48px; height: 48px; padding: 0; border-radius: 50%; display: grid; place-items: center;
|
|
218
|
+
color: #fff; border: 1px solid rgba(255,255,255,.18); background: rgba(20, 20, 22, .72);
|
|
219
|
+
-webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); box-shadow: 0 8px 30px rgba(0,0,0,.35);
|
|
220
|
+
transition: background .2s ease, transform .2s ease; }
|
|
221
|
+
.hand:hover, .end:hover { background: rgba(40, 40, 44, .85); transform: translateY(-2px); }
|
|
222
|
+
.hand[aria-expanded="true"] { background: var(--_accent); border-color: var(--_accent); }
|
|
223
|
+
.hand.sent { background: var(--_ok); border-color: var(--_ok); }
|
|
224
|
+
.end:hover { background: var(--_dead); border-color: var(--_dead); }
|
|
225
|
+
.end:disabled { opacity: .5; cursor: progress; }
|
|
226
|
+
.hand:focus-visible, .end:focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
|
|
227
|
+
.hand svg, .end svg { width: 22px; height: 22px; }
|
|
228
|
+
.ask { width: min(420px, calc(100vw - 44px)); padding: 12px; border-radius: 16px; color: #fff;
|
|
229
|
+
background: rgba(20, 20, 22, .82); -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px);
|
|
230
|
+
box-shadow: 0 8px 30px rgba(0,0,0,.4); animation: capIn .2s ease; }
|
|
231
|
+
.ask[hidden] { display: none; }
|
|
232
|
+
.ask textarea { min-height: 64px; background: rgba(255,255,255,.08); color: #fff;
|
|
233
|
+
border-color: rgba(255,255,255,.16); font-size: 15px; }
|
|
234
|
+
.ask textarea::placeholder { color: rgba(255,255,255,.5); }
|
|
235
|
+
.ask .ask-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
|
236
|
+
.ask .ask-hint { font-size: 12px; color: rgba(255,255,255,.6); flex: 1; }
|
|
237
|
+
@media (prefers-reduced-motion: reduce) { .hand, .ask { transition: none; animation: none; } }
|
|
238
|
+
@media (prefers-reduced-motion: reduce) { .caption { animation: none; } .caption.out { transition: none; } }
|
|
157
239
|
@container (max-width: 820px) {
|
|
158
240
|
.body { flex-direction: column; }
|
|
159
241
|
:host([screen]) .screen { order: -1; flex: none; height: 45%; border-left: 0; border-bottom: 1px solid var(--_line); }
|
|
@@ -256,7 +338,18 @@ const TEMPLATE = `
|
|
|
256
338
|
<div class="screen-bar"><span class="live" hidden>에이전트 조작 중</span><span class="url"></span>
|
|
257
339
|
<button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
|
|
258
340
|
<span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
|
|
259
|
-
<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>
|
|
341
|
+
<div class="viewport"><div class="ring"></div><div class="glow"></div><div class="agent-badge">에이전트 조작 중</div><img alt="에이전트 브라우저 화면" draggable="false"><textarea class="kbd" aria-label="브라우저 키보드 입력"></textarea><div class="placeholder">열린 terminal-browser 없음</div><div class="captions" part="captions" aria-live="polite"></div>
|
|
342
|
+
<div class="busy" part="busy" role="status"><span class="spinner"></span><span>답변 준비 중</span></div>
|
|
343
|
+
<div class="hand-wrap">
|
|
344
|
+
<form class="ask" part="ask" hidden><textarea aria-label="에이전트에게 질문" placeholder="에이전트에게 질문 (Enter 전송, Esc 닫기)"></textarea>
|
|
345
|
+
<div class="ask-row"><span class="ask-hint"></span><button type="submit" class="primary">보내기</button></div></form>
|
|
346
|
+
<button class="end" part="end" type="button" aria-label="발표 종료" title="발표 종료">
|
|
347
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
348
|
+
</button>
|
|
349
|
+
<button class="hand" part="hand" type="button" aria-expanded="false" aria-label="손들기: 에이전트에게 질문" title="손들기: 에이전트에게 질문">
|
|
350
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>
|
|
351
|
+
</button>
|
|
352
|
+
</div></div>
|
|
260
353
|
</aside></div>`;
|
|
261
354
|
|
|
262
355
|
class AgentBridge extends HTMLElement {
|
|
@@ -273,7 +366,10 @@ class AgentBridge extends HTMLElement {
|
|
|
273
366
|
#md = null;
|
|
274
367
|
#historyKey = null;
|
|
275
368
|
#history = [];
|
|
369
|
+
#present = { on: false };
|
|
276
370
|
static HISTORY_LIMIT = 500;
|
|
371
|
+
static CAPTION_MS = 10000; // presentation bubble lifetime
|
|
372
|
+
static CAPTION_MAX = 3; // bubbles on screen at once (oldest leaves early)
|
|
277
373
|
|
|
278
374
|
constructor() {
|
|
279
375
|
super();
|
|
@@ -285,6 +381,8 @@ class AgentBridge extends HTMLElement {
|
|
|
285
381
|
buttons: this.#root.querySelectorAll("button[data-action]"),
|
|
286
382
|
live: q(".live"), url: q(".screen-bar .url"), viewport: q(".viewport"), img: q(".viewport img"),
|
|
287
383
|
controlBtn: q(".control-btn"), kbd: q(".kbd"), clearBtn: q(".clear-btn"),
|
|
384
|
+
body: q(".body"), screen: q(".screen"), captions: q(".captions"),
|
|
385
|
+
busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
|
|
288
386
|
};
|
|
289
387
|
this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
|
|
290
388
|
this.#bindGrip();
|
|
@@ -296,6 +394,7 @@ class AgentBridge extends HTMLElement {
|
|
|
296
394
|
});
|
|
297
395
|
loadMarkdown().then((md) => (this.#md = md));
|
|
298
396
|
this.#bindControl();
|
|
397
|
+
this.#bindHand();
|
|
299
398
|
this.$.clearBtn.addEventListener("click", () => this.clearHistory());
|
|
300
399
|
}
|
|
301
400
|
|
|
@@ -334,6 +433,19 @@ class AgentBridge extends HTMLElement {
|
|
|
334
433
|
}
|
|
335
434
|
}
|
|
336
435
|
|
|
436
|
+
async stopPresent() {
|
|
437
|
+
try {
|
|
438
|
+
const res = await fetch(`${this.server}/present`, {
|
|
439
|
+
method: "POST",
|
|
440
|
+
headers: { "Content-Type": "application/json" },
|
|
441
|
+
body: JSON.stringify({ stop: true, from: "viewer" }),
|
|
442
|
+
});
|
|
443
|
+
return await res.json();
|
|
444
|
+
} catch {
|
|
445
|
+
return { ok: false, error: "server_unreachable" };
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
337
449
|
#emit(type, detail) {
|
|
338
450
|
this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
|
|
339
451
|
}
|
|
@@ -349,7 +461,15 @@ class AgentBridge extends HTMLElement {
|
|
|
349
461
|
on("delivered", ({ id }) => { this.#deliver(id); this.#emit("agent-queue", { phase: "delivered", id }); });
|
|
350
462
|
on("unqueued", ({ id }) => { this.#unqueue(id); this.#emit("agent-queue", { phase: "unqueued", id }); });
|
|
351
463
|
on("status", (s) => this.#renderStatus(s));
|
|
352
|
-
on("message", (m) => {
|
|
464
|
+
on("message", (m) => {
|
|
465
|
+
this.#addMessage(m);
|
|
466
|
+
this.#saveMessage(m);
|
|
467
|
+
// script lines carry their own on-screen time (>= 10s), others use CAPTION_MS
|
|
468
|
+
if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined);
|
|
469
|
+
if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
|
|
470
|
+
this.#emit("agent-message", m);
|
|
471
|
+
});
|
|
472
|
+
on("present", (p) => this.#setPresent(p));
|
|
353
473
|
on("tool_start", (t) => {
|
|
354
474
|
this.#tools.set(t.id, t);
|
|
355
475
|
this.#renderActivity();
|
|
@@ -366,11 +486,11 @@ class AgentBridge extends HTMLElement {
|
|
|
366
486
|
this.#setToolState(t.id, t.isError ? "error" : "done");
|
|
367
487
|
this.#emit("agent-tool", { phase: "end", ...t });
|
|
368
488
|
});
|
|
369
|
-
es.onerror = () => this.#renderStatus(null);
|
|
489
|
+
es.onerror = () => { this.#renderStatus(null); this.#setPresent({ on: false }); };
|
|
370
490
|
}
|
|
371
491
|
|
|
372
492
|
#syncScreen() {
|
|
373
|
-
const want = this.hasAttribute("screen");
|
|
493
|
+
const want = this.hasAttribute("screen") || this.#present.on;
|
|
374
494
|
if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); return; }
|
|
375
495
|
if (this.#screenEs) return;
|
|
376
496
|
const es = (this.#screenEs = new EventSource(`${this.server}/screen`));
|
|
@@ -384,6 +504,117 @@ class AgentBridge extends HTMLElement {
|
|
|
384
504
|
es.onerror = () => this.#renderScreenMeta({ connected: false });
|
|
385
505
|
}
|
|
386
506
|
|
|
507
|
+
// ---------- presentation mode ----------
|
|
508
|
+
|
|
509
|
+
#setPresent(p) {
|
|
510
|
+
const on = !!p?.on;
|
|
511
|
+
const was = this.#present.on;
|
|
512
|
+
this.#present = p ?? { on: false };
|
|
513
|
+
this.#emit("agent-present", this.#present);
|
|
514
|
+
this.#renderBusy();
|
|
515
|
+
if (on === was) return;
|
|
516
|
+
if (on) this.#setControl(false);
|
|
517
|
+
else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#handUp = false; }
|
|
518
|
+
this.#syncScreen();
|
|
519
|
+
// FLIP: grow from (or shrink back to) the side panel
|
|
520
|
+
const { screen, body } = this.$;
|
|
521
|
+
const before = screen.getBoundingClientRect();
|
|
522
|
+
body.classList.toggle("present", on);
|
|
523
|
+
this.toggleAttribute("presenting", on);
|
|
524
|
+
const after = screen.getBoundingClientRect();
|
|
525
|
+
if (matchMedia("(prefers-reduced-motion: reduce)").matches || !before.width || !after.width) return;
|
|
526
|
+
const t = `translate(${before.left - after.left}px, ${before.top - after.top}px) ` +
|
|
527
|
+
`scale(${before.width / after.width}, ${before.height / after.height})`;
|
|
528
|
+
screen.animate([{ transformOrigin: "0 0", transform: t }, { transformOrigin: "0 0", transform: "none" }],
|
|
529
|
+
{ duration: on ? 420 : 360, easing: "cubic-bezier(.2, .7, .2, 1)" });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
#renderBusy() {
|
|
533
|
+
const s = this.#status;
|
|
534
|
+
const busy = this.#present.on && s?.alive && s.status === "working" && this.#present.player?.state !== "playing";
|
|
535
|
+
this.$.busy.classList.toggle("on", !!busy);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Raise hand: open a small input over the presentation and send it as a message.
|
|
539
|
+
#bindHand() {
|
|
540
|
+
const { hand, ask, askBox, askHint } = this.$;
|
|
541
|
+
hand.addEventListener("click", () => {
|
|
542
|
+
if (ask.hidden) this.#raiseHand();
|
|
543
|
+
else this.#lowerHand();
|
|
544
|
+
});
|
|
545
|
+
this.$.end.addEventListener("click", async () => {
|
|
546
|
+
this.$.end.disabled = true;
|
|
547
|
+
const res = await this.stopPresent();
|
|
548
|
+
this.$.end.disabled = false;
|
|
549
|
+
if (!res.ok) { this.#openAsk(true); askHint.textContent = `발표 종료 실패: ${res.error}`; }
|
|
550
|
+
});
|
|
551
|
+
ask.addEventListener("submit", async (e) => {
|
|
552
|
+
e.preventDefault();
|
|
553
|
+
const text = askBox.value.trim();
|
|
554
|
+
if (!text) return;
|
|
555
|
+
askHint.textContent = "보내는 중…";
|
|
556
|
+
const res = await this.send(text, "message");
|
|
557
|
+
if (!res.ok) { askHint.textContent = `전송 실패: ${res.error}`; return; }
|
|
558
|
+
askBox.value = "";
|
|
559
|
+
this.#handUp = false; // the question itself ends the raised hand
|
|
560
|
+
this.#openAsk(false);
|
|
561
|
+
hand.classList.add("sent");
|
|
562
|
+
setTimeout(() => hand.classList.remove("sent"), 1500);
|
|
563
|
+
});
|
|
564
|
+
askBox.addEventListener("keydown", (e) => {
|
|
565
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); ask.requestSubmit(); }
|
|
566
|
+
if (e.key === "Escape") { e.preventDefault(); this.#lowerHand(); hand.focus(); }
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Raise: open the input and tell the agent to pause. Lower (closed without
|
|
571
|
+
// asking): tell it to go on. Failures only show a hint; asking still works.
|
|
572
|
+
#handUp = false;
|
|
573
|
+
async #raiseHand() {
|
|
574
|
+
this.#openAsk(true);
|
|
575
|
+
if (this.#handUp) return;
|
|
576
|
+
this.#handUp = true;
|
|
577
|
+
const res = await this.send("사용자가 손을 듦: 질문 입력 중", "hand-raise");
|
|
578
|
+
if (!res.ok && !this.$.ask.hidden) this.$.askHint.textContent = `손들기 알림 실패: ${res.error}`;
|
|
579
|
+
}
|
|
580
|
+
async #lowerHand() {
|
|
581
|
+
this.#openAsk(false);
|
|
582
|
+
if (!this.#handUp) return;
|
|
583
|
+
this.#handUp = false;
|
|
584
|
+
await this.send("사용자가 질문 없이 손을 내림", "hand-lower");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
#openAsk(open) {
|
|
588
|
+
const { hand, ask, askBox, askHint } = this.$;
|
|
589
|
+
ask.hidden = !open;
|
|
590
|
+
hand.setAttribute("aria-expanded", String(open));
|
|
591
|
+
askHint.textContent = "";
|
|
592
|
+
if (open) askBox.focus();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Agent reply as a short-lived bubble over the presented screen.
|
|
596
|
+
// kind "user": the viewer's own question (plain text, accent colour).
|
|
597
|
+
#caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS) {
|
|
598
|
+
if (!text?.trim()) return;
|
|
599
|
+
const el = document.createElement("div");
|
|
600
|
+
el.className = `caption ${kind}`;
|
|
601
|
+
el.part = "caption";
|
|
602
|
+
if (kind === "user") { el.textContent = text; el.classList.add("plain"); }
|
|
603
|
+
else if (this.#md) el.innerHTML = this.#md(text);
|
|
604
|
+
else { el.textContent = text; el.classList.add("plain"); }
|
|
605
|
+
const box = this.$.captions;
|
|
606
|
+
box.append(el);
|
|
607
|
+
const leave = () => {
|
|
608
|
+
if (el.classList.contains("out")) return;
|
|
609
|
+
el.classList.add("out");
|
|
610
|
+
setTimeout(() => el.remove(), 500);
|
|
611
|
+
};
|
|
612
|
+
setTimeout(leave, ms);
|
|
613
|
+
const live = [...box.children].filter((c) => !c.classList.contains("out"));
|
|
614
|
+
live.slice(0, Math.max(0, live.length - AgentBridge.CAPTION_MAX)).forEach((c) => c.__leave?.());
|
|
615
|
+
el.__leave = leave;
|
|
616
|
+
}
|
|
617
|
+
|
|
387
618
|
// ---------- composer resize grip (top edge) ----------
|
|
388
619
|
|
|
389
620
|
#bindGrip() {
|
|
@@ -456,7 +687,7 @@ class AgentBridge extends HTMLElement {
|
|
|
456
687
|
const mods = (e) => (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
|
|
457
688
|
const BUTTONS = ["left", "middle", "right"];
|
|
458
689
|
|
|
459
|
-
controlBtn.addEventListener("click", () => this.#setControl(!this.#controlWs));
|
|
690
|
+
controlBtn.addEventListener("click", () => { if (!this.#present.on) this.#setControl(!this.#controlWs); });
|
|
460
691
|
|
|
461
692
|
img.addEventListener("mousedown", (e) => {
|
|
462
693
|
if (!on()) return;
|
|
@@ -673,7 +904,7 @@ class AgentBridge extends HTMLElement {
|
|
|
673
904
|
return;
|
|
674
905
|
} else if (m.role === "assistant") {
|
|
675
906
|
wrap.className = "msg assistant";
|
|
676
|
-
meta.textContent =
|
|
907
|
+
meta.textContent = `에이전트${m.script ? ` · 대본 ${m.script}` : ""} · ${time}`;
|
|
677
908
|
if (this.#md) bubble.innerHTML = this.#md(m.text);
|
|
678
909
|
else { bubble.textContent = m.text; bubble.classList.add("plain"); }
|
|
679
910
|
} else if (m.role === "browser") {
|
|
@@ -735,6 +966,7 @@ class AgentBridge extends HTMLElement {
|
|
|
735
966
|
this.$.buttons.forEach((b) => (b.disabled = !canSend));
|
|
736
967
|
if (!s || !s.alive || s.status === "idle") this.#tools.clear();
|
|
737
968
|
this.#renderActivity();
|
|
969
|
+
this.#renderBusy();
|
|
738
970
|
this.#emit("agent-status", s);
|
|
739
971
|
}
|
|
740
972
|
|