openvibers/openvibe.shared dock-agent.js

dock-agent.js

main 19.6 KB · 316 lines · javascript

1// ═══════════════════════════════════════════════════════════════
2// OpenVibe — the Dock's agent view (Dock plan 2026-10-30, K-D3; Agent Streams S1), Shared 4.120.0; Float 4.154.0
3//
4// The person's own agent desk stream at the top of the Dock's Agents tab. dock.js loads this file the first time that
5// tab opens:
6// OpenVibeDockAgent.mount(container, { token, base, active }) → { refresh(), destroy() }
7//
8// The read is OpenVibe.Codes' GET /api/v1/me/agent-view (the same POST for pause, Curtain now, mute and End, and the
9// frame). It asks Network's same-origin relay (/api/frame/agent-view…) because every site's connect-src allows
10// openvibe.network and refuses openvibe.codes; `frameBase` overrides the relay, and `base` reaches Codes itself in a
11// test or the extension (an explicit base wins). Nothing is asked when there is no token: a signed-out page can never
12// read the view. Agent Streams S5 adds the four controls: a moderator cannot resume the owner's pause on Live, and the
13// Dock is the owner's own view, so it may always resume.
14// Codes mints the view ticket server-side with the person's own token — an iframe on another site has no Live cookie —
15// so the frame it answers is already Live's /embed/agent/<ags_id>?vt=wvt_…: the picture, the "AI agent" label, the
16// curtain state and the viewer count. The embed renews its own ticket while it plays.
17//
18// Nothing live: the last frame through the same API (an <img> cannot carry a token), with the task and the worked time.
19// Live's S2 current step comes through Codes when available; an older stream simply has no step line.
20// Everything is set as text, never markup; the frame's address is the one Codes answered, as an https Live address.
21// ═══════════════════════════════════════════════════════════════
22(function (root) {
23 'use strict';
24 if (typeof document === 'undefined' || root.OpenVibeDockAgent) return;
25
26 const POLL_MS = 20000;
27 const EMBED = /^https:\/\/openvibe\.live\/embed\/agent\/ags_[0-9A-HJKMNP-TV-Z]{26}\?vt=wvt_[A-Za-z0-9_-]+$/;
28 const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
29 const G = {
30 eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="3"/><path d="M4 4l16 16"/>',
31 pause: '<rect x="7" y="5" width="3.5" height="14" rx="1"/><rect x="13.5" y="5" width="3.5" height="14" rx="1"/>',
32 play: '<path d="M8 5.5v13l10-6.5z"/>',
33 mute: '<path d="M4 9.5h4L13 5v14l-5-4.5H4z"/><path d="M17 9l4 4m0-4l-4 4"/>',
34 sound: '<path d="M4 9.5h4L13 5v14l-5-4.5H4z"/><path d="M17 9.5a4 4 0 0 1 0 5"/>',
35 stop: '<rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/>',
36 open: '<path d="M14 4h6v6M20 4l-9 9M19 14v5a1.5 1.5 0 0 1-1.5 1.5h-12A1.5 1.5 0 0 1 4 19V7a1.5 1.5 0 0 1 1.5-1.5H10"/>',
37 float: '<rect x="3.5" y="5" width="17" height="14" rx="2.5"/><rect x="11.5" y="11" width="7" height="6" rx="1.2" fill="currentColor" stroke="none"/>',
38 };
39
40 // The style goes into the document the view lives in: the page, or the Dock's own window when it is popped out.
41 function styles(doc) {
42 const d = doc || (typeof document !== 'undefined' ? document : null);
43 if (!d || d.getElementById('ovda-styles')) return;
44 const st = d.createElement('style');
45 st.id = 'ovda-styles';
46 st.textContent = `
47 .ovda { display: none; flex: none; padding: 8px 8px 2px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }
48 .ovda.on { display: block; }
49 .ovda-stage { position: relative; overflow: hidden; aspect-ratio: 16 / 9; border-radius: 14px; background: #0a0c11; border: 1px solid var(--border, rgba(255,255,255,.1)); }
50 .ovda-frame, .ovda-still { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; object-fit: contain; background: #0a0c11; }
51 .ovda-still { object-fit: cover; opacity: .92; }
52 .ovda-stage[hidden] { display: none; }
53 .ovda-wait { position: absolute; inset: 0; display: grid; place-items: center; color: var(--text-muted, #8b8b96); font-size: 12.5px; text-align: center; padding: 12px; }
54 .ovda-line { display: flex; align-items: center; gap: 6px; min-height: 34px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }
55 .ovda-line b { color: var(--text-primary, #ececf1); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
56 .ovda-line b a { color: inherit; text-decoration: none; }
57 .ovda-line b a:hover { text-decoration: underline; }
58 .ovda-line .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); flex: none; }
59 .ovda-line .dot.live { background: var(--live-red, #e5484d); animation: ovda-pulse 1.6s ease-in-out infinite; }
60 .ovda-line .dot.off { background: var(--text-muted, #8b8b96); }
61 .ovda-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 11.5px; color: var(--text-muted, #8b8b96); }
62 .ovda-acts { display: flex; gap: 6px; margin-left: auto; }
63 .ovda-acts button, .ovda-acts a { display: inline-flex; align-items: center; gap: 5px; min-height: 30px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12));
64 background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; text-decoration: none; }
65 .ovda-acts button:hover, .ovda-acts a:hover { color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); }
66 .ovda-acts button:disabled { opacity: .5; cursor: default; }
67 .ovda-acts svg { width: 14px; height: 14px; }
68 .ovda-acts button[data-act="end"] { color: color-mix(in srgb, var(--live-red, #e5484d) 65%, var(--text-primary, #ececf1)); }
69 /* A session that ended is two lines of words with its one way out beside them, not a button on a row of its own. */
70 .ovda.is-ended .ovda-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 8px; align-items: center; padding: 2px 4px 6px; }
71 .ovda.is-ended .ovda-line, .ovda.is-ended .ovda-meta { grid-column: 1; }
72 .ovda.is-ended .ovda-acts { grid-column: 2; grid-row: 1 / span 2; margin: 0; }
73 .ovda.is-ended .ovda-acts:empty { display: none; }
74 @media (pointer: coarse) { .ovda-acts button, .ovda-acts a { min-height: 44px; min-width: 44px; justify-content: center; } }
75 .ovda svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
76 @keyframes ovda-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
77 `;
78 (d.head || d.documentElement).appendChild(st);
79 }
80
81 const mins = (s) => {
82 const m = Math.max(0, Math.round((Number(s) || 0) / 60));
83 return m < 1 ? 'under a minute' : m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
84 };
85
86 function mount(container, opts = {}) {
87 const d = container.ownerDocument || document;
88 styles(d);
89 const o = { ...opts };
90 const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
91 // Network's frame relay on a site; an explicit base reaches Codes itself (a test, or the extension's panel).
92 const direct = o.base ? String(o.base).replace(/\/+$/, '') : '';
93 const frameBase = String(o.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
94 const endpoint = (path) => (direct ? direct + path : frameBase + path.replace(/^\/api\/v1\/me\/agent-view/, '/agent-view'));
95 let gone = false, timer = 0, busy = false, agent = null, frameUrl = null, lastFrame = '';
96 container.innerHTML = '';
97 container.classList.add('ovda');
98 const stage = h('div', 'ovda-stage');
99 const line = h('div', 'ovda-line');
100 const step = h('div', 'ovda-meta ovda-step');
101 const meta = h('div', 'ovda-meta');
102 const acts = h('div', 'ovda-acts');
103 const head = h('div', 'ovda-head');
104 head.append(line, meta, step, acts);
105 const body = h('div');
106 body.append(stage, head);
107 container.append(body);
108
109 async function api(path, init = {}) {
110 const t = typeof o.token === 'function' ? await o.token() : o.token;
111 if (!t) throw Object.assign(new Error('Sign in to see your agent'), { status: 401 });
112 const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), authorization: `Bearer ${t}` };
113 const r = await fetch(endpoint(path), { ...init, headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });
114 const b = await r.json().catch(() => ({}));
115 if (!r.ok) throw Object.assign(new Error(b.detail || `HTTP ${r.status}`), { status: r.status });
116 return b;
117 }
118
119 /** The still is behind the person's token, so it is read here and shown as a local object URL. */
120 function still(url) {
121 if (!url || frameUrl === url) return;
122 frameUrl = url;
123 Promise.resolve(typeof o.token === 'function' ? o.token() : o.token).then((t) => (t
124 ? fetch(endpoint('/api/v1/me/agent-view/frame'), { headers: { authorization: `Bearer ${t}`, accept: 'image/jpeg, image/webp' }, credentials: 'omit' })
125 : null))
126 .then((r) => (r && r.ok ? r.blob() : null))
127 .then((blob) => {
128 if (!blob || gone || frameUrl !== url) return;
129 const next = URL.createObjectURL(blob);
130 const img = h('img', 'ovda-still');
131 img.alt = 'The last frame of your agent';
132 img.src = next;
133 img.decoding = 'async';
134 const old = stage.querySelector('img.ovda-still');
135 if (old) { stage.removeChild(old); }
136 stage.append(img);
137 if (lastFrame) URL.revokeObjectURL(lastFrame);
138 lastFrame = next;
139 })
140 .catch(() => { /* no still: the line says enough */ });
141 }
142
143 function paint() {
144 if (gone) return;
145 const has = !!agent;
146 container.classList.toggle('on', has);
147 if (!has) { stage.innerHTML = ''; line.innerHTML = ''; meta.textContent = ''; acts.innerHTML = ''; return; }
148
149 const live = agent.state === 'live';
150 const ended = agent.state === 'ended';
151 container.classList.toggle('is-ended', ended);
152 // The picture: the live embed when there is one, else the still.
153 const embed = typeof agent.embed_url === 'string' && EMBED.test(agent.embed_url) ? agent.embed_url : null;
154 const shown = stage.querySelector('iframe');
155 if (embed && (!shown || shown.getAttribute('src') !== embed)) {
156 stage.innerHTML = '';
157 const f = h('iframe', 'ovda-frame');
158 f.src = embed;
159 f.title = 'Your agent\'s screen';
160 f.setAttribute('allow', 'autoplay; fullscreen; picture-in-picture');
161 f.setAttribute('referrerpolicy', 'no-referrer');
162 stage.append(f);
163 } else if (!embed) {
164 if (shown) stage.innerHTML = '';
165 if (agent.last_frame_url) still(agent.last_frame_url);
166 else if (!ended && !stage.querySelector('img')) stage.append(h('div', 'ovda-wait', 'Waiting for the desk'));
167 }
168 // A session that ended and left no picture has no stage: a black box saying "Nothing to show" told nobody
169 // anything (the owner, 2026-11-10). The line under it still says what it was and how long it worked.
170 stage.hidden = ended && !embed && !agent.last_frame_url && !stage.querySelector('img');
171
172 // The line: what it is doing, one row.
173 line.innerHTML = '';
174 line.append(h('span', `dot ${live ? 'live' : 'off'}`));
175 const title = h('b');
176 const url = agent.task && /^https:\/\//.test(String(agent.task.url || '')) ? agent.task.url : (/^https:\/\//.test(String(agent.page_url || '')) ? agent.page_url : null);
177 const text = (agent.task && agent.task.title) || agent.title || 'Your agent';
178 if (url) { const a = h('a', null, text); a.href = url; a.rel = 'noopener'; a.target = '_blank'; title.append(a); }
179 else title.textContent = text;
180 line.append(title);
181
182 const current = agent.current_step;
183 step.textContent = current && typeof current.title === 'string' && current.title.trim()
184 ? `Step: ${current.title} · ${current.status || 'started'}` : '';
185 step.hidden = !step.textContent;
186
187 // The meta: the state, the viewers, the curtain, the worked time.
188 meta.textContent = '';
189 const bits = [live ? 'Live' : ended ? 'Ended' : 'Waiting for the desk'];
190 if (live && agent.viewers > 0) bits.push(`${agent.viewers} watching`);
191 if (agent.paused) bits.push(agent.paused_by ? `Paused by @${agent.paused_by}` : 'Paused');
192 if (live && agent.curtain && agent.curtain.up) bits.push('Private screen');
193 bits.push(`worked ${mins(agent.worked_seconds)}`);
194 meta.textContent = bits.join(' · ');
195
196 // S5: the four controls, for the owner (the Dock is their own view, so resume is always theirs).
197 acts.innerHTML = '';
198 if (!ended) {
199 const pause = h('button', null);
200 pause.type = 'button';
201 pause.dataset.act = 'pause';
202 pause.innerHTML = `${svg(agent.paused ? G.play : G.pause)}${agent.paused ? 'Resume' : 'Pause'}`;
203 pause.title = agent.paused && agent.paused_by ? `Paused by @${agent.paused_by}` : 'Pause the agent at the next safe point';
204 acts.append(pause);
205 }
206 if (live && !(agent.curtain && agent.curtain.up)) {
207 const b = h('button', null);
208 b.type = 'button';
209 b.dataset.act = 'curtain';
210 b.innerHTML = `${svg(G.eye)}Curtain now`;
211 b.title = 'Hide the screen now; your desk lifts it when it is clear';
212 acts.append(b);
213 }
214 if (!ended) {
215 const mute = h('button', null);
216 mute.type = 'button';
217 mute.dataset.act = 'mute';
218 mute.innerHTML = `${svg(agent.voice_muted ? G.sound : G.mute)}${agent.voice_muted ? 'Unmute' : 'Mute'}`;
219 mute.title = agent.voice_muted ? 'Let the agent speak again' : 'Mute the agent\'s voice';
220 acts.append(mute);
221 }
222 if (!ended) {
223 const b = h('button', null);
224 b.type = 'button';
225 b.dataset.act = 'end';
226 b.innerHTML = `${svg(G.stop)}End`;
227 acts.append(b);
228 }
229 // 4.154.0: Float puts the agent's screen in a floating card (overlay.js) that stays on screen as the person
230 // moves around this site and on to other OpenVibe sites, so the Dock can close while they watch.
231 if (embed && !ended) {
232 const b = h('button', null);
233 b.type = 'button';
234 b.dataset.act = 'float';
235 b.innerHTML = `${svg(G.float)}Float`;
236 b.title = 'Keep watching in a small window that follows you';
237 acts.append(b);
238 }
239 if (agent.page_url && /^https:\/\/openvibe\.live\//.test(agent.page_url)) {
240 const a = h('a');
241 a.href = agent.page_url;
242 a.target = '_blank';
243 a.rel = 'noopener';
244 a.setAttribute('aria-label', 'Open the stream on OpenVibe.Live');
245 a.title = 'Open the stream on OpenVibe.Live';
246 a.innerHTML = svg(G.open);
247 acts.append(a);
248 }
249 }
250
251 async function refresh() {
252 if (gone || busy) return;
253 if (typeof o.active === 'function' && !o.active()) { schedule(); return; }
254 busy = true;
255 clearTimeout(timer);
256 try {
257 const doc = await api('/api/v1/me/agent-view');
258 if (gone) return;
259 agent = doc && doc.agent && typeof doc.agent === 'object' ? doc.agent : null;
260 paint();
261 } catch (e) {
262 // Codes away, or Live away: no stream, and the rest of the tab is untouched.
263 if (!gone) { agent = null; paint(); }
264 } finally {
265 busy = false;
266 schedule();
267 }
268 }
269 function schedule() {
270 clearTimeout(timer);
271 if (!gone && agent && agent.state !== 'ended') timer = setTimeout(refresh, POLL_MS);
272 }
273
274 async function control(what, btn, body = {}) {
275 if (!btn || btn.disabled) return;
276 btn.disabled = true;
277 try {
278 const doc = await api(`/api/v1/me/agent-view/${what}`, { method: 'POST', json: true, body });
279 agent = doc && doc.agent ? doc.agent : agent;
280 if (what === 'end') frameUrl = '';
281 paint();
282 } catch (e) {
283 btn.disabled = false;
284 btn.title = e.message || 'That did not work. Try again.';
285 return;
286 }
287 if (agent && agent.state !== 'ended') schedule();
288 }
289
290 acts.addEventListener('click', (e) => {
291 const b = e.target.closest('button[data-act]');
292 if (!b) return;
293 if (b.dataset.act === 'pause') control('pause', b, { paused: !(agent && agent.paused) });
294 else if (b.dataset.act === 'curtain') control('curtain', b);
295 else if (b.dataset.act === 'mute') control('mute', b, { muted: !(agent && agent.voice_muted) });
296 else if (b.dataset.act === 'end') control('end', b);
297 else if (b.dataset.act === 'float') floatIt(b);
298 });
299
300 function floatIt(btn) {
301 const embed = agent && typeof agent.embed_url === 'string' && EMBED.test(agent.embed_url) ? agent.embed_url : null;
302 const nav = root.OpenVibeNavbar;
303 if (!embed || !nav || typeof nav.overlay !== 'function') { btn.title = 'Floating is not available on this page.'; return; }
304 const pageUrl = agent.page_url && /^https:\/\/openvibe\.live\//.test(agent.page_url) ? agent.page_url : null;
305 nav.overlay({ id: 'agent', src: embed, title: (agent.task && agent.task.title) || agent.title || 'Your agent', live: agent.state === 'live', href: pageUrl || undefined })
306 .then((card) => { if (card && root.OpenVibeDock && typeof root.OpenVibeDock.close === 'function') root.OpenVibeDock.close(); })
307 .catch(() => { btn.title = 'That did not work. Try again.'; });
308 }
309
310 refresh();
311 return { refresh, destroy() { gone = true; clearTimeout(timer); if (lastFrame) URL.revokeObjectURL(lastFrame); container.innerHTML = ''; container.classList.remove('ovda'); } };
312 }
313
314 root.OpenVibeDockAgent = { mount };
315})(typeof window !== 'undefined' ? window : globalThis);
316