openvibers/openvibe.shared dock-actor.js
dock-actor.js
| 1 | // ═══════════════════════════════════════════════════════════════ |
| 2 | // OpenVibe — the Dock's Actor block (Actor v3 plan, stage A3; redrawn 2026-11-10) |
| 3 | // |
| 4 | // Your Actor at the top of the Dock's Agents tab, on every OpenVibe site. dock.js loads this file the first time that |
| 5 | // tab opens: |
| 6 | // OpenVibeDockActor.mount(container, { token, base, active }) → { refresh(), destroy() } |
| 7 | // |
| 8 | // It reads OpenVibe.Actor's GET /api/v1/me/actor with the person's Network token (scope actor.me.read); the answers, |
| 9 | // pause, resume and tell are POSTs with actor.me.write. On a site every call goes through Network's same-origin relay |
| 10 | // (/api/frame/actor…) because every site's connect-src allows openvibe.network and refuses openvibe.actor; `base` |
| 11 | // reaches Actor itself in a test, and `frameBase` names the relay. Nothing is asked without a token. |
| 12 | // |
| 13 | // The look (the owner, 2026-11-10: "more intuitive, minimalist, elegant"): one row and one line to type in. |
| 14 | // - Who: the face with a ring in the colour of its state, the name, and one quiet line (the state, today's spend |
| 15 | // against the cap). The row is the way to the Actor's page; there is no separate "Open". |
| 16 | // - One action beside it, the one that fits: "Resume" in the accent when it is paused, a quiet pause when it runs. |
| 17 | // - What it does, only when there is something to say: the step it is on (a soft pulse), what it looks at, the |
| 18 | // steps before it, and what comes next, as a short line of dots. A paused Actor shows none of this: the row says it. |
| 19 | // - What it asks you, with its own buttons, in the warning tone (a lean and a glow, never a bar). |
| 20 | // - One field to tell it something, with send inside the field. |
| 21 | // A person with no Actor sees nothing here. Everything people or the Actor wrote is set as text, never markup. The |
| 22 | // block is built once: a refresh repaints what changed and never touches the line being typed. Every control is 44 px. |
| 23 | // ═══════════════════════════════════════════════════════════════ |
| 24 | (function (root) { |
| 25 | ; |
| 26 | if (typeof document === 'undefined' || root.OpenVibeDockActor) return; |
| 27 | |
| 28 | const POLL_MS = 15000; |
| 29 | const ACTOR_REV = { 'ov-window.js': '1c4db9b0ce9c', 'agent-sheet.js': '8d150490ad64' }; |
| 30 | const SCRIPT_BASE = document.currentScript && document.currentScript.src ? new URL('.', document.currentScript.src).href : '/shared/'; |
| 31 | const STATE = { working: 'Working', resting: 'Resting', paused: 'Paused', waiting: 'Waiting for you' }; |
| 32 | // The lines Actor sends as "the current step" when there is none: the state already says them. |
| 33 | const NOT_A_STEP = /^(paused|resting\b.*)\.?$/i; |
| 34 | const ICON = { |
| 35 | play: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8.5 5.6v12.8a.9.9 0 0 0 1.37.77l10.3-6.4a.9.9 0 0 0 0-1.54l-10.3-6.4a.9.9 0 0 0-1.37.77z" fill="currentColor"/></svg>', |
| 36 | pause: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6.5" y="5" width="4" height="14" rx="1.3" fill="currentColor"/><rect x="13.5" y="5" width="4" height="14" rx="1.3" fill="currentColor"/></svg>', |
| 37 | go: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 6.5 15 12l-5.5 5.5"/></svg>', |
| 38 | send: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5.5M6 11l6-6 6 6"/></svg>', |
| 39 | ask: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.2 9.3a2.9 2.9 0 1 1 4.4 2.5c-.9.6-1.6 1.2-1.6 2.3"/><path d="M12 17.6v.1"/></svg>', |
| 40 | past: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12a8 8 0 1 0 2.6-5.9"/><path d="M4 5v3.6h3.6"/><path d="M12 8.2V12l2.6 1.7"/></svg>', |
| 41 | }; |
| 42 | |
| 43 | function styles(doc) { |
| 44 | const d = doc || document; |
| 45 | if (!d || d.getElementById('ovdac-styles')) return; |
| 46 | const st = d.createElement('style'); |
| 47 | st.id = 'ovdac-styles'; |
| 48 | st.textContent = ` |
| 49 | .ovdac { display: none; flex: none; padding: 12px 12px 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); |
| 50 | --ovdac-ok: var(--success, #22c55e); --ovdac-warn: var(--warning, #f5a524); --ovdac-ink: var(--text-primary, #ececf1); } |
| 51 | .ovdac.on { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; } |
| 52 | .ovdac [hidden] { display: none !important; } |
| 53 | .ovdac svg { width: 18px; height: 18px; flex: none; } |
| 54 | .ovdac .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } |
| 55 | |
| 56 | .ovdac-head { display: flex; align-items: center; gap: 8px; min-width: 0; } |
| 57 | .ovdac-id { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 2px 8px 2px 4px; margin-left: -4px; border-radius: 14px; |
| 58 | color: inherit; text-decoration: none; transition: background .15s ease; } |
| 59 | .ovdac-id:hover { background: color-mix(in srgb, var(--ovdac-ink) 5%, transparent); } |
| 60 | .ovdac-id:focus-visible, .ovdac-go:focus-visible, .ovdac-btn:focus-visible, .ovdac-link:focus-visible, .ovdac-send:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 2px; } |
| 61 | .ovdac-face { position: relative; flex: none; width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; |
| 62 | background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font-weight: 800; font-size: 16px; transition: filter .2s ease, opacity .2s ease; } |
| 63 | .ovdac-face img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; } |
| 64 | .ovdac-face::after { content: ''; position: absolute; inset: -4px; border-radius: 50%; border: 2px solid transparent; transition: border-color .2s ease; } |
| 65 | .ovdac[data-state="working"] .ovdac-face::after { border-color: var(--ovdac-ok); animation: ovdac-ring 2.4s ease-in-out infinite; } |
| 66 | .ovdac[data-state="waiting"] .ovdac-face::after { border-color: var(--ovdac-warn); } |
| 67 | .ovdac[data-state="paused"] .ovdac-face { filter: saturate(.5); opacity: .78; } |
| 68 | .ovdac-who { min-width: 0; display: grid; gap: 1px; } |
| 69 | .ovdac-name { display: flex; align-items: center; gap: 1px; min-width: 0; font-weight: 750; font-size: 15px; line-height: 1.25; color: var(--ovdac-ink); } |
| 70 | .ovdac-name span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 71 | .ovdac-name svg { width: 16px; height: 16px; color: var(--text-muted, #8b8b96); transition: transform .18s ease, color .18s ease; } |
| 72 | .ovdac-id:hover .ovdac-name svg { transform: translateX(2px); color: var(--text-secondary, #b6b6c0); } |
| 73 | .ovdac-line { display: flex; gap: 6px; min-width: 0; font-size: 12.5px; line-height: 1.35; color: var(--text-muted, #8b8b96); white-space: nowrap; } |
| 74 | .ovdac-state { flex: none; font-weight: 650; color: var(--text-secondary, #b6b6c0); } |
| 75 | .ovdac[data-state="working"] .ovdac-state { color: color-mix(in srgb, var(--ovdac-ok) 72%, var(--ovdac-ink)); } |
| 76 | .ovdac[data-state="waiting"] .ovdac-state { color: color-mix(in srgb, var(--ovdac-warn) 76%, var(--ovdac-ink)); } |
| 77 | .ovdac-spend { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; } |
| 78 | .ovdac-spend::before { content: '·'; margin-right: 6px; } |
| 79 | |
| 80 | .ovdac-go { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 44px; height: 44px; padding: 0; border-radius: 999px; |
| 81 | border: 1px solid var(--border, rgba(255,255,255,.14)); background: transparent; color: var(--text-secondary, #b6b6c0); |
| 82 | font: inherit; font-size: 13.5px; font-weight: 700; cursor: pointer; white-space: nowrap; transition: background .15s ease, color .15s ease, transform .12s ease, box-shadow .2s ease; } |
| 83 | .ovdac-go:hover { color: var(--ovdac-ink); background: color-mix(in srgb, var(--ovdac-ink) 7%, transparent); } |
| 84 | .ovdac-go.is-main { padding: 0 18px 0 13px; border-color: transparent; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); |
| 85 | box-shadow: 0 8px 20px -10px var(--accent, #3b82f6); } |
| 86 | .ovdac-go.is-main:hover { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); filter: brightness(1.08); } |
| 87 | .ovdac-go:active, .ovdac-btn:active, .ovdac-send:active { transform: scale(.96); } |
| 88 | .ovdac .is-busy { opacity: .6; pointer-events: none; animation: ovdac-pulse 1.1s ease-in-out infinite; } |
| 89 | |
| 90 | .ovdac-rail { list-style: none; margin: 0; padding: 2px 4px 2px 6px; display: grid; gap: 9px; } |
| 91 | .ovdac-rail li { position: relative; padding-left: 22px; font-size: 12.5px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); overflow-wrap: anywhere; } |
| 92 | .ovdac-rail li::before { content: ''; position: absolute; left: 1px; top: .36em; width: 6px; height: 6px; border-radius: 50%; border: 1.5px solid var(--text-muted, #8b8b96); } |
| 93 | .ovdac-rail li:not(:last-child)::after { content: ''; position: absolute; left: 5px; top: calc(.36em + 13px); bottom: -8px; width: 1px; |
| 94 | background: color-mix(in srgb, var(--ovdac-ink) 14%, transparent); } |
| 95 | .ovdac-rail .is-live { font-size: 13.5px; font-weight: 650; color: var(--ovdac-ink); } |
| 96 | .ovdac-rail .is-live::before { border-color: var(--ovdac-ok); background: var(--ovdac-ok); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ovdac-ok) 20%, transparent); animation: ovdac-pulse 1.8s ease-in-out infinite; } |
| 97 | .ovdac-rail .is-next { color: var(--text-muted, #8b8b96); } |
| 98 | .ovdac-rail .is-next::before { border-style: dashed; } |
| 99 | .ovdac-rail b { font-weight: 650; } |
| 100 | .ovdac-look { display: block; margin-top: 2px; font-size: 12px; font-weight: 400; color: var(--text-muted, #8b8b96); } |
| 101 | |
| 102 | .ovdac-ask { display: grid; gap: 10px; padding: 12px 14px 10px; border-radius: 16px; |
| 103 | background: linear-gradient(135deg, color-mix(in srgb, var(--ovdac-warn) 15%, transparent), color-mix(in srgb, var(--ovdac-warn) 5%, transparent) 62%); |
| 104 | box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovdac-warn) 22%, transparent), 0 12px 28px -20px var(--ovdac-warn); } |
| 105 | .ovdac-ask-k { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; color: color-mix(in srgb, var(--ovdac-warn) 74%, var(--ovdac-ink)); } |
| 106 | .ovdac-ask-k i { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: color-mix(in srgb, var(--ovdac-warn) 24%, transparent); } |
| 107 | .ovdac-ask-k svg { width: 16px; height: 16px; } |
| 108 | .ovdac-ask p { margin: 0; font-size: 14px; line-height: 1.45; color: var(--ovdac-ink); overflow-wrap: anywhere; } |
| 109 | .ovdac-ask .row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } |
| 110 | .ovdac-btn { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 16px; border-radius: 999px; |
| 111 | border: 1px solid color-mix(in srgb, var(--ovdac-ink) 18%, transparent); background: transparent; color: var(--ovdac-ink); |
| 112 | font: inherit; font-size: 13.5px; font-weight: 700; cursor: pointer; white-space: nowrap; transition: background .15s ease, transform .12s ease; } |
| 113 | .ovdac-btn:hover { background: color-mix(in srgb, var(--ovdac-ink) 8%, transparent); } |
| 114 | .ovdac-btn.is-yes { background: var(--accent-strong, var(--accent, #2563eb)); border-color: transparent; color: var(--on-accent-strong, #fff); } |
| 115 | .ovdac-btn.is-yes:hover { background: var(--accent-strong, var(--accent, #2563eb)); filter: brightness(1.08); } |
| 116 | .ovdac-btn.is-quiet { padding: 0 10px; border-color: transparent; color: var(--text-secondary, #b6b6c0); font-weight: 650; } |
| 117 | .ovdac-more { display: inline-flex; align-items: center; min-height: 44px; margin: -8px 0 -8px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); text-decoration: underline; text-underline-offset: 3px; } |
| 118 | |
| 119 | .ovdac-tell { position: relative; display: flex; } |
| 120 | .ovdac-tell input { flex: 1; min-width: 0; height: 52px; box-sizing: border-box; padding: 0 58px 0 18px; border-radius: 26px; |
| 121 | border: 1px solid var(--border, rgba(255,255,255,.14)); background: var(--bg-input, color-mix(in srgb, var(--ovdac-ink) 4%, transparent)); |
| 122 | color: var(--ovdac-ink); font: inherit; font-size: 14.5px; outline: none; transition: border-color .15s ease, box-shadow .15s ease; } |
| 123 | .ovdac-tell input::placeholder { color: var(--text-muted, #8b8b96); } |
| 124 | .ovdac-tell input:focus { border-color: color-mix(in srgb, var(--accent, #3b82f6) 62%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); } |
| 125 | .ovdac-send { position: absolute; right: 4px; top: 4px; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%; |
| 126 | background: color-mix(in srgb, var(--ovdac-ink) 8%, transparent); color: var(--text-muted, #8b8b96); cursor: pointer; |
| 127 | transition: background .18s ease, color .18s ease, transform .12s ease, box-shadow .2s ease; } |
| 128 | .ovdac-tell.has-text .ovdac-send { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); box-shadow: 0 6px 16px -8px var(--accent, #3b82f6); } |
| 129 | |
| 130 | .ovdac-foot { display: flex; align-items: center; gap: 10px; min-width: 0; margin-top: -6px; } |
| 131 | .ovdac-link { flex: none; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 8px; margin-left: -8px; border: 0; border-radius: 10px; |
| 132 | background: none; color: var(--text-muted, #8b8b96); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; transition: color .15s ease; } |
| 133 | .ovdac-link:hover, .ovdac-link[aria-expanded="true"] { color: var(--ovdac-ink); } |
| 134 | .ovdac-link svg { width: 15px; height: 15px; } |
| 135 | .ovdac-note { flex: 1; min-width: 0; text-align: right; font-size: 12.5px; line-height: 1.35; color: var(--text-muted, #8b8b96); overflow-wrap: anywhere; } |
| 136 | .ovdac-note.is-ok { color: color-mix(in srgb, var(--ovdac-ok) 70%, var(--ovdac-ink)); } |
| 137 | .ovdac-note.is-bad { color: color-mix(in srgb, var(--danger, #ef4444) 74%, var(--ovdac-ink)); } |
| 138 | .ovdac-hist { list-style: none; margin: -4px 0 4px; padding: 0; display: grid; gap: 6px; font-size: 12.5px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); overflow-wrap: anywhere; } |
| 139 | .ovdac-hist b { margin-right: 6px; font-weight: 650; color: var(--text-muted, #8b8b96); } |
| 140 | |
| 141 | @keyframes ovdac-pulse { 50% { opacity: .45; } } |
| 142 | @keyframes ovdac-ring { 50% { opacity: .35; transform: scale(1.06); } } |
| 143 | @media (pointer: coarse) { .ovdac-tell input { font-size: 16px; } } |
| 144 | html[data-ov-motion=off] .ovdac *, html[data-ov-motion=off] .ovdac *::before, html[data-ov-motion=off] .ovdac *::after { animation: none !important; transition: none !important; } |
| 145 | `; |
| 146 | (d.head || d.documentElement).appendChild(st); |
| 147 | } |
| 148 | |
| 149 | const money = (n) => { const v = Number(n) || 0; return v < 0.01 && v > 0 ? `$${v.toFixed(3)}` : `$${v.toFixed(2)}`; }; |
| 150 | /** "in 40 min", "in 3 h", "tomorrow": when the next goal is due; '' when unknown or already due. */ |
| 151 | function due(at, now = Date.now()) { |
| 152 | const t = Date.parse(at || ''); |
| 153 | if (!Number.isFinite(t)) return ''; |
| 154 | const min = Math.round((t - now) / 60000); |
| 155 | if (min < 1) return ''; |
| 156 | if (min < 60) return `in ${min} min`; |
| 157 | if (min < 24 * 60) return `in ${Math.round(min / 60)} h`; |
| 158 | return min < 48 * 60 ? 'tomorrow' : `in ${Math.round(min / 1440)} days`; |
| 159 | } |
| 160 | |
| 161 | function mount(container, opts = {}) { |
| 162 | const d = container.ownerDocument || document; |
| 163 | styles(d); |
| 164 | const o = { ...opts }; |
| 165 | const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }; |
| 166 | const icon = (name) => { const t = d.createElement('template'); t.innerHTML = ICON[name]; return t.content.firstChild; }; |
| 167 | const tip = (n, text) => { if (root.OVTip) n.setAttribute('data-ov-tip', text); else n.title = text; }; |
| 168 | let gone = false, timer = 0, busy = false, shown = false, last = '', actor = null, noteTimer = 0; |
| 169 | container.replaceChildren(); |
| 170 | container.classList.add('ovdac'); |
| 171 | // Network's frame relay on a site; an explicit base reaches Actor itself (a test). |
| 172 | const direct = o.base ? String(o.base).replace(/\/+$/, '') : ''; |
| 173 | const frameBase = String(o.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, ''); |
| 174 | const endpoint = (path) => (direct ? direct + '/api/v1/me/actor' + path : frameBase + '/actor' + path); |
| 175 | // The way to the Actor's page is a navigation, never a fetch: Actor's own site, wherever the API is. |
| 176 | const openBase = direct || 'https://openvibe.actor'; |
| 177 | |
| 178 | async function api(path, init = {}) { |
| 179 | const t = typeof o.token === 'function' ? await o.token() : o.token; |
| 180 | if (!t) throw Object.assign(new Error('Sign in to use your Actor'), { status: 401 }); |
| 181 | const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), authorization: `Bearer ${t}` }; |
| 182 | const r = await fetch(endpoint(path), { method: init.method || 'GET', headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined }); |
| 183 | const b = await r.json().catch(() => ({})); |
| 184 | if (!r.ok) throw Object.assign(new Error(b.detail || `HTTP ${r.status}`), { status: r.status }); |
| 185 | return b; |
| 186 | } |
| 187 | |
| 188 | function stateOf(a) { |
| 189 | if (Array.isArray(a.asks) && a.asks.length) return 'waiting'; |
| 190 | return STATE[a.status] ? a.status : 'paused'; |
| 191 | } |
| 192 | |
| 193 | // ── The parts, built once ──────────────────────────────────────────────────────────────────────────────── |
| 194 | const head = h('div', 'ovdac-head'); |
| 195 | const id = h('a', 'ovdac-id'); id.href = openBase + '/you'; id.target = '_blank'; id.rel = 'noopener'; |
| 196 | function loadScript(file, ready) { |
| 197 | if (ready()) return Promise.resolve(true); |
| 198 | return new Promise((resolve) => { |
| 199 | const sid = `ovdac-${file}`; |
| 200 | let script = d.getElementById(sid); |
| 201 | if (!script) { script = d.createElement('script'); script.id = sid; script.src = SCRIPT_BASE + file + (ACTOR_REV[file] ? `?v=${ACTOR_REV[file]}` : ''); script.async = true; d.head.appendChild(script); } |
| 202 | script.addEventListener('load', () => resolve(!!ready()), { once: true }); |
| 203 | script.addEventListener('error', () => resolve(false), { once: true }); |
| 204 | }); |
| 205 | } |
| 206 | // The row opens its sheet (agent-sheet.js in the one window: now, asks, work, schedule, memory, computer); the |
| 207 | // Actor's page is the sheet's "Open". A modified click, or a sheet that cannot load, goes to the page as before. |
| 208 | id.addEventListener('click', async (e) => { |
| 209 | if (!actor || !(actor.agent_id || actor.id) || e.metaKey || e.ctrlKey || e.shiftKey || e.button) return; |
| 210 | e.preventDefault(); |
| 211 | const ok = (await Promise.all([loadScript('ov-window.js', () => !!root.OVWindow), loadScript('agent-sheet.js', () => !!root.OVAgentSheet)])).every(Boolean); |
| 212 | if (!ok) { d.defaultView.open(id.href, '_blank', 'noopener'); return; } |
| 213 | root.OVAgentSheet.open({ kind: 'actor', id: actor.agent_id || actor.id, name: actor.name || 'Your Actor', token: o.token, |
| 214 | frameBase: o.frameBase, base: direct || null, from: id, openHref: openBase + '/you' }); |
| 215 | }); |
| 216 | const face = h('span', 'ovdac-face'); |
| 217 | const who = h('span', 'ovdac-who'); |
| 218 | const name = h('span', 'ovdac-name'); const nameText = h('span'); name.append(nameText, icon('go')); |
| 219 | const line = h('span', 'ovdac-line'); const stateText = h('span', 'ovdac-state'); const spend = h('span', 'ovdac-spend'); line.append(stateText, spend); |
| 220 | who.append(name, line); id.append(face, who); |
| 221 | const go = h('button', 'ovdac-go'); go.type = 'button'; |
| 222 | go.addEventListener('click', () => { |
| 223 | if (!actor) return; |
| 224 | const paused = actor.status === 'paused'; |
| 225 | act(paused ? '/resume' : '/pause', {}, go, paused ? 'Resumed.' : 'Paused.'); |
| 226 | }); |
| 227 | head.append(id, go); |
| 228 | |
| 229 | const rail = h('ul', 'ovdac-rail'); rail.hidden = true; |
| 230 | const askBox = h('div', 'ovdac-ask'); askBox.hidden = true; |
| 231 | |
| 232 | const form = h('form', 'ovdac-tell'); |
| 233 | const input = h('input'); input.type = 'text'; input.maxLength = 2000; input.autocomplete = 'off'; input.enterKeyHint = 'send'; |
| 234 | input.placeholder = 'Tell your Actor something'; input.setAttribute('aria-label', 'Tell your Actor something'); |
| 235 | const send = h('button', 'ovdac-send'); send.type = 'submit'; send.setAttribute('aria-label', 'Send'); send.append(icon('send')); |
| 236 | input.addEventListener('input', () => form.classList.toggle('has-text', !!input.value.trim())); |
| 237 | form.append(input, send); |
| 238 | form.addEventListener('submit', (e) => { |
| 239 | e.preventDefault(); |
| 240 | const text = input.value.trim(); |
| 241 | if (!text) { input.focus(); return; } |
| 242 | const paused = actor && actor.status === 'paused'; |
| 243 | act('/tell', { text }, send, paused ? 'Sent. Your Actor reads it once you resume it.' : 'Sent. Your Actor reads it at its next step.') |
| 244 | .then((ok) => { if (ok) { input.value = ''; form.classList.remove('has-text'); } }); |
| 245 | }); |
| 246 | |
| 247 | const foot = h('div', 'ovdac-foot'); |
| 248 | const past = h('button', 'ovdac-link'); past.type = 'button'; past.setAttribute('aria-expanded', 'false'); past.append(icon('past'), d.createTextNode('Recent history')); |
| 249 | const note = h('div', 'ovdac-note'); note.setAttribute('role', 'status'); note.setAttribute('aria-live', 'polite'); |
| 250 | const hist = h('ul', 'ovdac-hist'); hist.hidden = true; |
| 251 | foot.append(past, note); |
| 252 | // The button that opens the list closes it. |
| 253 | past.addEventListener('click', async () => { |
| 254 | if (!hist.hidden) { hist.hidden = true; past.setAttribute('aria-expanded', 'false'); return; } |
| 255 | past.disabled = true; past.classList.add('is-busy'); |
| 256 | const one = (text) => { hist.replaceChildren(h('li', '', text)); }; |
| 257 | try { |
| 258 | const t = typeof o.token === 'function' ? await o.token() : o.token; |
| 259 | if (!t) throw new Error('Sign in to read history.'); |
| 260 | const url = String(o.networkBase || 'https://openvibe.network').replace(/\/+$/, '') + '/api/v1/history/agents/recent?limit=10'; |
| 261 | const response = await fetch(url, { headers: { accept: 'application/json', authorization: `Bearer ${t}` }, credentials: 'omit' }); |
| 262 | const result = await response.json().catch(() => ({})); |
| 263 | if (response.status === 403) one('Your agent history setting is off. Turn it on in My settings to allow this read.'); |
| 264 | else if (!response.ok) one('Recent history is unavailable.'); |
| 265 | else { |
| 266 | const items = (result.items || []).slice(0, 10); |
| 267 | if (!items.length) one('No recent history.'); |
| 268 | else hist.replaceChildren(...items.map((x) => { const li = h('li'); li.append(h('b', '', String(x.type || '')), d.createTextNode(String(x.title || ''))); return li; })); |
| 269 | } |
| 270 | } catch { one('Recent history is unavailable.'); } |
| 271 | finally { past.disabled = false; past.classList.remove('is-busy'); } |
| 272 | hist.hidden = false; past.setAttribute('aria-expanded', 'true'); |
| 273 | }); |
| 274 | |
| 275 | /** One line under the field: what just happened. A good word goes by itself after a moment; a problem stays. */ |
| 276 | function say(text, tone) { |
| 277 | clearTimeout(noteTimer); |
| 278 | note.textContent = text || ''; |
| 279 | note.className = 'ovdac-note' + (tone ? ` is-${tone}` : ''); |
| 280 | if (text && tone === 'ok') noteTimer = setTimeout(() => { note.textContent = ''; note.className = 'ovdac-note'; }, 6000); |
| 281 | } |
| 282 | |
| 283 | // ── A refresh repaints the row, the steps and the ask; the field and the history stay as they are ──────── |
| 284 | function paint(a) { |
| 285 | if (!a || (a.enabled === false && !a.id)) { |
| 286 | actor = null; shown = false; last = ''; |
| 287 | container.replaceChildren(); container.classList.remove('on'); container.removeAttribute('data-state'); |
| 288 | return; |
| 289 | } |
| 290 | actor = a; |
| 291 | if (!shown) { container.replaceChildren(head, rail, askBox, form, foot, hist); container.classList.add('on'); shown = true; } |
| 292 | const sig = JSON.stringify([a.name, a.status, a.avatar, a.brain, a.caps, a.now, a.asks]); |
| 293 | if (sig === last) return; |
| 294 | last = sig; |
| 295 | const state = stateOf(a), now = a.now || {}; |
| 296 | container.setAttribute('data-state', state); |
| 297 | |
| 298 | // Who, and the one line about it. |
| 299 | const label = String(a.name || 'Your Actor'); |
| 300 | const pic = a.avatar && typeof a.avatar.url === 'string' && /^https:\/\//.test(a.avatar.url) ? a.avatar.url : ''; |
| 301 | if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.replaceChildren(img); } else face.replaceChildren(d.createTextNode(label.trim().charAt(0).toUpperCase() || 'A')); |
| 302 | nameText.textContent = label; |
| 303 | id.setAttribute('aria-label', `${label}, ${STATE[state]}. Open your Actor's page`); |
| 304 | stateText.textContent = STATE[state]; |
| 305 | const cap = a.caps && Number(a.caps.usd) > 0 ? ` of ${money(a.caps.usd)}` : ''; |
| 306 | spend.textContent = `${money(now.cost_today)}${cap} today`; |
| 307 | tip(spend, `Model: ${a.brain && a.brain !== 'auto' ? a.brain : 'Auto'}`); |
| 308 | |
| 309 | // The one action that fits. |
| 310 | const paused = a.status === 'paused'; |
| 311 | go.className = 'ovdac-go' + (paused ? ' is-main' : ''); |
| 312 | go.replaceChildren(icon(paused ? 'play' : 'pause'), h('span', paused ? '' : 'sr', paused ? 'Resume' : 'Pause')); |
| 313 | tip(go, paused ? 'Your Actor does nothing until you resume it' : 'Pause your Actor'); |
| 314 | |
| 315 | // What it does: the step it is on, the steps before it, what comes next. Nothing when there is nothing to say. |
| 316 | const asks = Array.isArray(a.asks) ? a.asks : []; |
| 317 | const ask = asks[0]; |
| 318 | const step = String(now.current_step || '').trim(); |
| 319 | const live = a.status === 'working' && step && !NOT_A_STEP.test(step) && !(ask && step === String(ask.text || '').trim()) ? step : ''; |
| 320 | const items = []; |
| 321 | if (live) { |
| 322 | const li = h('li', 'is-live'); li.append(d.createTextNode(live)); |
| 323 | const looking = now.looking_at && (now.looking_at.title || now.looking_at); |
| 324 | if (looking) li.append(h('span', 'ovdac-look', `Looking at ${String(looking)}`)); |
| 325 | items.push(li); |
| 326 | } |
| 327 | const before = (Array.isArray(now.last_three_steps) ? now.last_three_steps : []).map((x) => String((x && x.text) || x || '').trim()).filter((t) => t && t !== live).slice(0, 3); |
| 328 | if (!paused) before.forEach((t) => items.push(h('li', '', t))); |
| 329 | if (!paused && now.next && (now.next.text || typeof now.next === 'string')) { |
| 330 | const li = h('li', 'is-next'); const when = due(now.next.at); |
| 331 | li.append(h('b', '', 'Next: '), d.createTextNode(String(now.next.text || now.next) + (when ? `, ${when}` : ''))); |
| 332 | items.push(li); |
| 333 | } |
| 334 | rail.replaceChildren(...items); rail.hidden = !items.length; |
| 335 | |
| 336 | // The first thing it asks, with its own buttons. |
| 337 | if (ask) { |
| 338 | const k = h('div', 'ovdac-ask-k'); const chip = h('i'); chip.append(icon('ask')); k.append(chip, d.createTextNode(`${label} asks`)); |
| 339 | const row = h('div', 'row'); |
| 340 | const options = Array.isArray(ask.options) && ask.options.length ? ask.options : [{ id: 'yes', label: 'Yes' }]; |
| 341 | options.slice(0, 3).forEach((op, i) => { |
| 342 | const b = h('button', `ovdac-btn${i === 0 ? ' is-yes' : ''}`, String(op.label || op)); b.type = 'button'; |
| 343 | b.addEventListener('click', () => act(`/asks/${encodeURIComponent(ask.id)}/answer`, { option: op.id || op }, b, 'Answered.')); |
| 344 | row.append(b); |
| 345 | }); |
| 346 | const no = h('button', 'ovdac-btn is-quiet', 'Not now'); no.type = 'button'; |
| 347 | no.addEventListener('click', () => act(`/asks/${encodeURIComponent(ask.id)}/decline`, {}, no)); |
| 348 | row.append(no); |
| 349 | askBox.replaceChildren(k, h('p', '', String(ask.text || '')), row); |
| 350 | if (asks.length > 1) { const more = h('a', 'ovdac-more', `${asks.length - 1} more on your Actor's page`); more.href = openBase + '/you'; more.target = '_blank'; more.rel = 'noopener'; askBox.append(more); } |
| 351 | } else askBox.replaceChildren(); |
| 352 | askBox.hidden = !ask; |
| 353 | } |
| 354 | |
| 355 | async function act(path, body, btn, done) { |
| 356 | if (busy) return false; |
| 357 | busy = true; if (btn) { btn.disabled = true; btn.classList.add('is-busy'); } |
| 358 | say(''); |
| 359 | try { |
| 360 | await api(path, { method: 'POST', json: true, body }); |
| 361 | last = ''; |
| 362 | await refresh(); |
| 363 | if (done) say(done, 'ok'); |
| 364 | return true; |
| 365 | } catch (err) { say(`That did not go through: ${err.message}`, 'bad'); return false; } |
| 366 | finally { busy = false; if (btn) { btn.disabled = false; btn.classList.remove('is-busy'); } } |
| 367 | } |
| 368 | |
| 369 | async function refresh() { |
| 370 | if (gone) return; |
| 371 | if (o.active && !o.active()) return; |
| 372 | try { const data = await api(''); if (!gone) paint(data); } |
| 373 | catch (err) { if (!gone && (err.status === 401 || err.status === 403 || err.status === 404)) paint(null); } |
| 374 | } |
| 375 | refresh(); |
| 376 | timer = setInterval(refresh, POLL_MS); |
| 377 | return { refresh, destroy() { gone = true; clearInterval(timer); clearTimeout(noteTimer); container.replaceChildren(); container.classList.remove('on'); container.removeAttribute('data-state'); } }; |
| 378 | } |
| 379 | |
| 380 | root.OpenVibeDockActor = { mount }; |
| 381 | })(typeof window !== 'undefined' ? window : globalThis); |
| 382 |