agello 0.2.0 → 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 +19 -0
- package/bin/agello.ts +68 -0
- package/package.json +1 -1
- package/src/player.ts +234 -0
- package/src/screencast.ts +8 -1
- package/src/server.ts +84 -9
- package/web/agent-bridge.js +26 -5
package/README.md
CHANGED
|
@@ -69,6 +69,8 @@ The approve / reject buttons send `action=approve` / `action=reject`.
|
|
|
69
69
|
| `agello open [--port N \| --pane ID]` | Open the page (default: this pane's server) |
|
|
70
70
|
| `agello present [x,y,w,h] [--port N \| --pane ID]` | Presentation mode (see below) |
|
|
71
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 |
|
|
72
74
|
|
|
73
75
|
`start` options: `--pane <id>`, `--port <n>` (default: first free from 8765), `--session <id>`, `--browser <terminal-browser key>`, `--allow-origin <origin>` (repeatable), `--open`, `--foreground`.
|
|
74
76
|
|
|
@@ -87,6 +89,23 @@ State and logs: `~/.local/state/agello/<port>.json`, `<port>.log`.
|
|
|
87
89
|
|
|
88
90
|
Run `present` again to move the crop. User control is off while presenting.
|
|
89
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
|
+
|
|
90
109
|
## Embed
|
|
91
110
|
|
|
92
111
|
```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,12 @@ 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
|
+
return { handle, input, describe, setClip, evaluate };
|
|
272
279
|
}
|
package/src/server.ts
CHANGED
|
@@ -11,10 +11,14 @@
|
|
|
11
11
|
// POST /present {rect?: {x,y,w,h}} start (or move the crop), {stop: true} end
|
|
12
12
|
// ({stop: true, from: "viewer"}: ended from the page, the agent is told
|
|
13
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"}
|
|
14
17
|
|
|
15
18
|
import { homedir } from "node:os";
|
|
16
19
|
import { join } from "node:path";
|
|
17
20
|
import { runJson } from "./run.ts";
|
|
21
|
+
import { createPlayer, parsePos, parseScript, type PlayerInfo } from "./player.ts";
|
|
18
22
|
import { createScreencast, type Rect } from "./screencast.ts";
|
|
19
23
|
|
|
20
24
|
export type ServerOptions = {
|
|
@@ -90,7 +94,7 @@ export function parseRect(r: any): Rect | null {
|
|
|
90
94
|
return { x, y, w, h };
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
export type Present = { on: boolean; rect?: Rect; since?: string };
|
|
97
|
+
export type Present = { on: boolean; rect?: Rect; since?: string; player?: PlayerInfo };
|
|
94
98
|
|
|
95
99
|
type Status =
|
|
96
100
|
| { alive: false; reason: string; pane: string }
|
|
@@ -311,10 +315,67 @@ export async function startServer(opts: ServerOptions) {
|
|
|
311
315
|
// short-lived bubbles. rect: crop of the visible viewport (unset = whole screen).
|
|
312
316
|
|
|
313
317
|
let present: Present = { on: false };
|
|
318
|
+
const presentView = (): Present => ({ ...present, player: player.info() });
|
|
314
319
|
function setPresent(next: Present) {
|
|
315
320
|
present = next;
|
|
316
321
|
screen.setClip(next.on ? (next.rect ?? null) : null);
|
|
317
|
-
|
|
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() });
|
|
318
379
|
}
|
|
319
380
|
|
|
320
381
|
async function presentRequest(req: Request): Promise<Response> {
|
|
@@ -326,10 +387,11 @@ export async function startServer(opts: ServerOptions) {
|
|
|
326
387
|
// (The CLI stop comes from the agent itself and needs no notice.)
|
|
327
388
|
let notified: string | undefined;
|
|
328
389
|
if (was && data.from === "viewer") {
|
|
329
|
-
const
|
|
390
|
+
const at = where();
|
|
391
|
+
const r = await promptAgent("present-stop", `사용자가 페이지에서 발표를 종료함${at ? ` (대본 정지: ${at})` : ""}`);
|
|
330
392
|
notified = r.ok ? "ok" : r.error;
|
|
331
393
|
}
|
|
332
|
-
return Response.json({ ok: true, present, notified });
|
|
394
|
+
return Response.json({ ok: true, present: presentView(), notified });
|
|
333
395
|
}
|
|
334
396
|
let rect: Rect | undefined;
|
|
335
397
|
if (data.rect != null) {
|
|
@@ -337,7 +399,7 @@ export async function startServer(opts: ServerOptions) {
|
|
|
337
399
|
if (!rect) return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
|
|
338
400
|
}
|
|
339
401
|
setPresent({ on: true, rect, since: present.on ? present.since : new Date().toISOString() });
|
|
340
|
-
return Response.json({ ok: true, present });
|
|
402
|
+
return Response.json({ ok: true, present: presentView() });
|
|
341
403
|
}
|
|
342
404
|
|
|
343
405
|
// ----- HTTP -----
|
|
@@ -378,7 +440,7 @@ export async function startServer(opts: ServerOptions) {
|
|
|
378
440
|
tools: [...pendingTools.keys()],
|
|
379
441
|
});
|
|
380
442
|
if (lastStatus) send(c, "status", lastStatus);
|
|
381
|
-
send(c, "present",
|
|
443
|
+
send(c, "present", presentView());
|
|
382
444
|
for (const t of pendingTools.values()) send(c, "tool_start", t);
|
|
383
445
|
ping = setInterval(() => send(c, "ping", {}), 15000);
|
|
384
446
|
},
|
|
@@ -405,7 +467,15 @@ export async function startServer(opts: ServerOptions) {
|
|
|
405
467
|
async function sendPrompt(req: Request): Promise<Response> {
|
|
406
468
|
const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string };
|
|
407
469
|
const action = /^[\w-]{1,32}$/.test(data.action ?? "") ? data.action! : "message";
|
|
408
|
-
|
|
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);
|
|
409
479
|
return r.ok ? Response.json({ ok: true }) : Response.json({ ok: false, error: r.error }, { status: r.status });
|
|
410
480
|
}
|
|
411
481
|
|
|
@@ -416,7 +486,7 @@ export async function startServer(opts: ServerOptions) {
|
|
|
416
486
|
...(await checkStatus()),
|
|
417
487
|
browser: opts.browser,
|
|
418
488
|
screen: await screen.describe(),
|
|
419
|
-
present,
|
|
489
|
+
present: presentView(),
|
|
420
490
|
});
|
|
421
491
|
case "/events":
|
|
422
492
|
return events();
|
|
@@ -430,7 +500,12 @@ export async function startServer(opts: ServerOptions) {
|
|
|
430
500
|
if (req.method === "POST") return sendPrompt(req);
|
|
431
501
|
break;
|
|
432
502
|
case "/present":
|
|
433
|
-
return req.method === "POST" ? presentRequest(req) : Response.json(
|
|
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;
|
|
434
509
|
}
|
|
435
510
|
return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
|
|
436
511
|
}
|
package/web/agent-bridge.js
CHANGED
|
@@ -36,6 +36,9 @@
|
|
|
36
36
|
// closing it without asking sends action=hand-lower so it can go on.
|
|
37
37
|
// The viewer's questions show as bubbles too, with the same 10s lifetime.
|
|
38
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.
|
|
39
42
|
//
|
|
40
43
|
// Methods
|
|
41
44
|
// el.send(text, action = 'message') -> Promise<{ok, error?}>
|
|
@@ -199,6 +202,14 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
199
202
|
.caption a { color: inherit; }
|
|
200
203
|
@keyframes capIn { from { opacity: 0; translate: 0 10px; } }
|
|
201
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
|
+
|
|
202
213
|
/* raise hand: ask the agent mid-presentation */
|
|
203
214
|
.hand-wrap { display: none; }
|
|
204
215
|
.body.present .hand-wrap { position: absolute; right: 22px; bottom: 28px; z-index: 5; display: flex;
|
|
@@ -328,6 +339,7 @@ const TEMPLATE = `
|
|
|
328
339
|
<button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
|
|
329
340
|
<span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
|
|
330
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>
|
|
331
343
|
<div class="hand-wrap">
|
|
332
344
|
<form class="ask" part="ask" hidden><textarea aria-label="에이전트에게 질문" placeholder="에이전트에게 질문 (Enter 전송, Esc 닫기)"></textarea>
|
|
333
345
|
<div class="ask-row"><span class="ask-hint"></span><button type="submit" class="primary">보내기</button></div></form>
|
|
@@ -370,7 +382,7 @@ class AgentBridge extends HTMLElement {
|
|
|
370
382
|
live: q(".live"), url: q(".screen-bar .url"), viewport: q(".viewport"), img: q(".viewport img"),
|
|
371
383
|
controlBtn: q(".control-btn"), kbd: q(".kbd"), clearBtn: q(".clear-btn"),
|
|
372
384
|
body: q(".body"), screen: q(".screen"), captions: q(".captions"),
|
|
373
|
-
hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
|
|
385
|
+
busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
|
|
374
386
|
};
|
|
375
387
|
this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
|
|
376
388
|
this.#bindGrip();
|
|
@@ -452,7 +464,8 @@ class AgentBridge extends HTMLElement {
|
|
|
452
464
|
on("message", (m) => {
|
|
453
465
|
this.#addMessage(m);
|
|
454
466
|
this.#saveMessage(m);
|
|
455
|
-
|
|
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);
|
|
456
469
|
if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
|
|
457
470
|
this.#emit("agent-message", m);
|
|
458
471
|
});
|
|
@@ -498,6 +511,7 @@ class AgentBridge extends HTMLElement {
|
|
|
498
511
|
const was = this.#present.on;
|
|
499
512
|
this.#present = p ?? { on: false };
|
|
500
513
|
this.#emit("agent-present", this.#present);
|
|
514
|
+
this.#renderBusy();
|
|
501
515
|
if (on === was) return;
|
|
502
516
|
if (on) this.#setControl(false);
|
|
503
517
|
else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#handUp = false; }
|
|
@@ -515,6 +529,12 @@ class AgentBridge extends HTMLElement {
|
|
|
515
529
|
{ duration: on ? 420 : 360, easing: "cubic-bezier(.2, .7, .2, 1)" });
|
|
516
530
|
}
|
|
517
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
|
+
|
|
518
538
|
// Raise hand: open a small input over the presentation and send it as a message.
|
|
519
539
|
#bindHand() {
|
|
520
540
|
const { hand, ask, askBox, askHint } = this.$;
|
|
@@ -574,7 +594,7 @@ class AgentBridge extends HTMLElement {
|
|
|
574
594
|
|
|
575
595
|
// Agent reply as a short-lived bubble over the presented screen.
|
|
576
596
|
// kind "user": the viewer's own question (plain text, accent colour).
|
|
577
|
-
#caption(text, kind = "agent") {
|
|
597
|
+
#caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS) {
|
|
578
598
|
if (!text?.trim()) return;
|
|
579
599
|
const el = document.createElement("div");
|
|
580
600
|
el.className = `caption ${kind}`;
|
|
@@ -589,7 +609,7 @@ class AgentBridge extends HTMLElement {
|
|
|
589
609
|
el.classList.add("out");
|
|
590
610
|
setTimeout(() => el.remove(), 500);
|
|
591
611
|
};
|
|
592
|
-
setTimeout(leave,
|
|
612
|
+
setTimeout(leave, ms);
|
|
593
613
|
const live = [...box.children].filter((c) => !c.classList.contains("out"));
|
|
594
614
|
live.slice(0, Math.max(0, live.length - AgentBridge.CAPTION_MAX)).forEach((c) => c.__leave?.());
|
|
595
615
|
el.__leave = leave;
|
|
@@ -884,7 +904,7 @@ class AgentBridge extends HTMLElement {
|
|
|
884
904
|
return;
|
|
885
905
|
} else if (m.role === "assistant") {
|
|
886
906
|
wrap.className = "msg assistant";
|
|
887
|
-
meta.textContent =
|
|
907
|
+
meta.textContent = `에이전트${m.script ? ` · 대본 ${m.script}` : ""} · ${time}`;
|
|
888
908
|
if (this.#md) bubble.innerHTML = this.#md(m.text);
|
|
889
909
|
else { bubble.textContent = m.text; bubble.classList.add("plain"); }
|
|
890
910
|
} else if (m.role === "browser") {
|
|
@@ -946,6 +966,7 @@ class AgentBridge extends HTMLElement {
|
|
|
946
966
|
this.$.buttons.forEach((b) => (b.disabled = !canSend));
|
|
947
967
|
if (!s || !s.alive || s.status === "idle") this.#tools.clear();
|
|
948
968
|
this.#renderActivity();
|
|
969
|
+
this.#renderBusy();
|
|
949
970
|
this.#emit("agent-status", s);
|
|
950
971
|
}
|
|
951
972
|
|