openvibers/openvibe.shared dock-agent.js
dock-agent.js
| 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 | ; |
| 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 |