openvibers/openvibe.shared dock-vibe.js
dock-vibe.js
| 1 | /** |
| 2 | * openvibe-shared/dock-vibe.js: the Dock's Vibe tab (plan L48 V2), loaded by dock.js the first time the tab opens. |
| 3 | * OpenVibeDockVibe.mount(panel, { chatCall, active, styles, icons }) → { refresh(), pause(), talk() } |
| 4 | * chatCall is the Dock's call to OpenVibe.Chat with the person's Network token; active() says whether the Vibe tab is |
| 5 | * the one showing; styles() is the Dock's own <style> (this file adds its rules there, so a popped-out Dock has them). |
| 6 | */ |
| 7 | (function (root) { |
| 8 | ; |
| 9 | if (typeof document === 'undefined' || root.OpenVibeDockVibe) return; |
| 10 | const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`; |
| 11 | const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }; |
| 12 | // 4.220.0: the panel's marks are the network's glyphs (openvibe-shared/glyphs, shown on openvibe.design/icons; the owner, |
| 13 | // 11-12: "actually has icons for everything using OpenVibe.design"). test/dock-vibe-glyphs.test.js keeps them equal. |
| 14 | const V = { |
| 15 | back: '<path d="M16.1 3.6l-8.4 8.4l8.4 8.4"/>', |
| 16 | spark: '<path d="M10.4 3.6l1.7 4.8L17 10.1l-4.8 1.7L10.4 16.7l-1.7-4.8L3.9 10.1l4.8-1.7ZM17 14.8l0.8 2.1L19.8 17.6l-2.1 0.8L17 20.3l-0.8-2.1L14.3 17.6l2.1-0.8Z"/>', |
| 17 | image: '<rect x="3.5" y="5.4" width="16.7" height="13" rx="2.8"/><circle cx="9.1" cy="10" r="1.5"/><path d="M20.3 15.6l-4.7-4.7l-7.4 7.4"/>', |
| 18 | code: '<path d="M7.8 7.8l-4.2 4.2l4.2 4.2M16.2 7.8l4.2 4.2l-4.2 4.2M14.2 4.6l-4.2 14.8"/>', |
| 19 | send: '<path d="M12 20.7V4.2M3.7 12.4l8.3-8.3l8.3 8.3"/>', |
| 20 | bell: '<path d="M6 14.6V9.5a6.1 6.1 0 0 1 12.2 0v5.1l1.5 2.1h-15.3ZM10 18.7a2.1 2.1 0 0 0 4 0"/>', |
| 21 | calendar: '<rect x="4" y="5.5" width="16" height="15" rx="2"/><path d="M4 10.5h16M9 3.5v4M15 3.5v4"/>', |
| 22 | live: '<circle cx="12" cy="11.8" r="2"/><path d="M8.6 8.4a4.9 4.9 0 0 0 0 7.1M15.4 8.4a4.9 4.9 0 0 1 0 7.1M6.3 6.1a8.1 8.1 0 0 0 0 11.9M17.7 6.1a8.1 8.1 0 0 1 0 11.9"/>', |
| 23 | clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>', |
| 24 | phone: '<path d="M5.7 3.4h3.9l2 4.9l-2.5 1.5a10.8 10.8 0 0 0 4.9 4.9L15.6 12.3l4.9 2v3.9a2 2 0 0 1-2 2A15.8 15.8 0 0 1 3.8 5.4a2 2 0 0 1 2-2"/>', |
| 25 | search: '<circle cx="10.6" cy="10.6" r="7"/><path d="M20.4 20.4l-4.5-4.5"/>', |
| 26 | chevron: '<path d="M7.9 3.6l8.4 8.4l-8.4 8.4"/>', |
| 27 | plus: '<path d="M12 3.5v17M3.5 12h17"/>', |
| 28 | mic: '<rect x="9.2" y="3.5" width="5.7" height="10.4" rx="2.8"/><path d="M5.4 11.1a6.6 6.6 0 0 0 13.2 0M12 17.7v2.8"/>', |
| 29 | open: '<path d="M14 3.8h6.3v6.3M20.2 3.8l-9.4 9.4M18.2 14.2v4.2a2.1 2.1 0 0 1-2.1 2.1H5.6a2.1 2.1 0 0 1-2.1-2.1V7.9a2.1 2.1 0 0 1 2.1-2.1h4.2"/>', |
| 30 | }; |
| 31 | const CSS = ` |
| 32 | .ovdk-ib.is-new { background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); } |
| 33 | .ovdk-ib.is-new:hover, .ovdk-ib.is-new:focus-visible { background: color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); } |
| 34 | .ovdk-vibe-h .ovdk-i { color: var(--accent, #3b82f6); margin-left: 6px; } |
| 35 | .ovdk-vibe-list { list-style: none; padding: 2px 6px 6px; margin: 0; display: grid; gap: 2px; } |
| 36 | .ovdk-vibe-list button { width: 100%; min-height: 56px; display: grid; grid-template-columns: 36px minmax(0,1fr) auto; grid-template-rows: auto auto; gap: 2px 12px; align-items: center; padding: 9px 10px; border: 0; border-radius: 14px; background: transparent; color: var(--text-primary,#ececf1); text-align: left; cursor: pointer; font: inherit; } |
| 37 | .ovdk-vibe-list .ovdk-vibe-site { grid-row: 1 / 3; width: 36px; height: 36px; border-radius: 11px; display: grid; place-items: center; background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); } |
| 38 | .ovdk-vibe-list .ovdk-vibe-site svg { width: 19px; height: 19px; } |
| 39 | .ovdk-vibe-list b { font-size: 14px; } .ovdk-vibe-list span { font-size: 12.5px; color: var(--text-secondary, #b6b6c0); } |
| 40 | .ovdk-vibe-list small { justify-self: end; } |
| 41 | .ovdk-vibe-list button:hover, .ovdk-vibe-list button:focus-visible { background: var(--hover-bg,rgba(127,127,127,.12)); } |
| 42 | .ovdk-vibe-list b, .ovdk-vibe-list span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 43 | .ovdk-vibe-list small, .ovdk-vibe-list time { color: var(--text-muted,#8b8b96); font-size: 12px; } |
| 44 | .ovdk-vibe-back { min-width: 44px; min-height: 44px; display: grid; place-items: center; border: 0; background: transparent; color: inherit; cursor: pointer; } |
| 45 | .ovdk-msg.is-vibe .ovdk-text a { color: var(--accent, #3b82f6); text-decoration: underline; word-break: break-all; } |
| 46 | .ovdk-typing .ovdk-text { display: inline-flex; gap: 4px; align-items: center; padding: 10px 12px; } |
| 47 | .ovdk-typing i { width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted, #8b8b96); animation: ovdk-hop 1s ease-in-out infinite; } |
| 48 | .ovdk-typing i:nth-child(2) { animation-delay: .15s; } .ovdk-typing i:nth-child(3) { animation-delay: .3s; } |
| 49 | /* 4.220.0 (the owner, 11-12: "sleek … organized … sized well … without huge spaces … uses the full space … icons for |
| 50 | everything"): a header that says what Vibe is, a greeting at the top, the starters as rows with their marks, and the |
| 51 | shared composer's two-row shape. */ |
| 52 | .ovdk-vibe-h { gap: 8px; padding: 6px 8px; min-height: 56px; box-sizing: border-box; } |
| 53 | .ovdk-vibe-h .ovdk-vibe-back { width: 40px; height: 40px; min-width: 40px; min-height: 40px; border-radius: 12px; color: var(--text-secondary, #b6b6c0); } |
| 54 | .ovdk-vibe-h .ovdk-vibe-back:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); } |
| 55 | .ovdk-vibe-back svg { width: 17px; height: 17px; } .ovdk-chip-go svg { width: 13px; height: 13px; } |
| 56 | .ovdk-vibe-mark { flex: none; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 11px; color: #fff; |
| 57 | background: linear-gradient(135deg, color-mix(in srgb, var(--accent, #3b82f6) 90%, #a855f7), color-mix(in srgb, #a855f7 85%, var(--accent, #3b82f6))); box-shadow: 0 4px 14px color-mix(in srgb, #a855f7 28%, transparent); } |
| 58 | .ovdk-vibe-mark svg { width: 19px; height: 19px; } |
| 59 | .ovdk-vibe-mark.is-big { width: 46px; height: 46px; border-radius: 14px; } |
| 60 | .ovdk-vibe-mark.is-big svg { width: 24px; height: 24px; } |
| 61 | .ovdk-vibe-name { flex: 1; min-width: 0; display: grid; line-height: 1.2; } |
| 62 | .ovdk-vibe-name b { font-size: 15px; } |
| 63 | .ovdk-vibe-name small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #8b8b96); font-size: 12px; } |
| 64 | .ovdk-vibe-h .ovdk-ib { margin-left: 0; width: 40px; height: 40px; border-radius: 12px; background: transparent; color: var(--text-secondary, #b6b6c0); } |
| 65 | .ovdk-vibe-h .ovdk-ib:hover, .ovdk-vibe-h .ovdk-ib:focus-visible { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); } |
| 66 | .ovdk-vibe-h .ovdk-vibe-fresh { color: var(--accent-light, var(--accent, #60a5fa)); } |
| 67 | .ovdk-vibe-start { list-style: none; display: grid; gap: 14px; padding: 14px 4px 6px; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; text-align: left; } |
| 68 | .ovdk-vibe-hello { display: flex; align-items: center; gap: 12px; padding: 0 4px; } |
| 69 | .ovdk-vibe-hello-t { min-width: 0; display: grid; gap: 2px; } |
| 70 | .ovdk-vibe-start b { color: var(--text-primary, #ececf1); font-size: 16.5px; letter-spacing: -.01em; } |
| 71 | .ovdk-chips { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 240px), 1fr)); gap: 6px; margin: 0; } |
| 72 | .ovdk-chip { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 52px; padding: 8px 10px 8px 8px; border-radius: 14px; |
| 73 | border: 1px solid var(--border, rgba(255,255,255,.1)); background: color-mix(in srgb, var(--text-primary, #ececf1) 3%, transparent); color: var(--text-primary, #ececf1); |
| 74 | font: inherit; font-size: 14px; text-align: left; cursor: pointer; transition: border-color var(--ov-dur-2, .2s) ease, background-color var(--ov-dur-2, .2s) ease; } |
| 75 | .ovdk-chip:hover, .ovdk-chip:focus-visible { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); outline: none; } |
| 76 | .ovdk-chip-ic { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); } |
| 77 | .ovdk-chip-ic svg { width: 18px; height: 18px; } |
| 78 | .ovdk-chip-t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 79 | .ovdk-chip-go { display: grid; color: var(--text-muted, #8b8b96); } |
| 80 | /* The box: the words on top over the full width, the tools and a round send under them. */ |
| 81 | .ovdk-send.ovdk-send--two { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px; padding: 6px 6px 6px 8px; border-radius: 20px; } |
| 82 | .ovdk-send--two textarea { padding: 8px 6px 4px; min-height: 40px; font-size: 15.5px; } |
| 83 | .ovdk-send-tools { display: flex; align-items: center; gap: 2px; min-width: 0; } |
| 84 | .ovdk-send-tools .ovdk-attach, .ovdk-send-tools .ovdk-mic { width: 40px; height: 40px; border-radius: 12px; color: var(--accent-light, var(--accent, #60a5fa)); } |
| 85 | .ovdk-send-tools .ovdk-attach:hover, .ovdk-send-tools .ovdk-mic:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); } |
| 86 | .ovdk-send-tools .ovdk-attach svg, .ovdk-send-tools .ovdk-mic svg { width: 20px; height: 20px; } |
| 87 | .ovdk-msg.is-you .ovdk-text a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 55%, transparent); text-underline-offset: 2px; overflow-wrap: anywhere; } |
| 88 | .ovdk-send-tools .ovdk-go:not(:disabled) { background: linear-gradient(150deg, color-mix(in srgb, var(--accent, #3b82f6) 85%, #fff), var(--accent-strong, var(--accent, #2563eb))); color: var(--on-accent-strong, #fff); box-shadow: 0 6px 18px -6px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); } |
| 89 | .ovdk-send-tools .ovdk-go { margin-left: auto; width: 40px; height: 40px; border-radius: 50%; } |
| 90 | .ovdk-send-tools .ovdk-go svg { width: 18px; height: 18px; stroke-width: 2.2; } |
| 91 | .ovdk-msg.is-vibe .ovdk-text a { word-break: normal; overflow-wrap: anywhere; } |
| 92 | .ovdk-res-c { grid-template-columns: auto minmax(0, 1fr); } |
| 93 | .ovdk-res-c .ovdk-res-a { grid-column: 2; } |
| 94 | .ovdk-mic, .ovdk-attach { width: 44px; height: 44px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center; background: transparent; color: var(--text-muted, #8b8b96); touch-action: none; } |
| 95 | .ovdk-attach:hover { color: var(--text-primary,#ececf1); } |
| 96 | .ovdk-mic:hover { color: var(--text-primary, #ececf1); } |
| 97 | .ovdk-mic.is-on { color: var(--on-accent, #fff); background: var(--live-red, #e5484d); box-shadow: 0 0 0 4px color-mix(in srgb, var(--live-red, #e5484d) 28%, transparent); } |
| 98 | .ovdk-res { display: grid; gap: 6px; margin-top: 6px; max-width: 100%; } |
| 99 | .ovdk-res-c { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 4px 10px; padding: 8px 10px; border-radius: 12px; |
| 100 | border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 35%, var(--border, rgba(255,255,255,.12))); background: color-mix(in srgb, var(--accent, #3b82f6) 8%, transparent); } |
| 101 | .ovdk-res-c .ovdk-i { color: var(--accent, #3b82f6); } |
| 102 | .ovdk-res-t { min-width: 0; font-size: 13px; line-height: 1.35; color: var(--text-primary, #ececf1); } |
| 103 | .ovdk-res-t b { font-weight: 750; } |
| 104 | .ovdk-res-t small { display: block; color: var(--text-muted, #8b8b96); font-size: 12px; } |
| 105 | .ovdk-res-a { display: flex; gap: 4px; } |
| 106 | .ovdk-res-a button, .ovdk-res-a a { min-height: 30px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; |
| 107 | color: var(--text-primary, #ececf1); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; } |
| 108 | .ovdk-res-a button:hover, .ovdk-res-a a:hover { border-color: var(--accent, #3b82f6); } |
| 109 | .ovdk-res-a button:disabled { opacity: .55; cursor: default; } |
| 110 | .ovdk-res-c.is-stopped { border-color: var(--border, rgba(255,255,255,.12)); background: transparent; } |
| 111 | .ovdk-res-c.is-stopped .ovdk-i, .ovdk-res-c.is-stopped .ovdk-res-t { color: var(--text-muted, #8b8b96); } |
| 112 | `; |
| 113 | let ctx = null, G = {}; |
| 114 | // 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for |
| 115 | // drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload. |
| 116 | const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || ''; |
| 117 | const VIBE_REV = { 'ov-composer.js': 'fbc3cc30fd2e' }; |
| 118 | function joinComposer(form) { |
| 119 | if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box |
| 120 | form.setAttribute('data-ovc-no-files', ''); |
| 121 | if (!form.dataset.ovcExamples) form.dataset.ovcExamples = JSON.stringify(['Ask Vibe, or hold the mic', 'Find a live stream about cooking', 'Start a 10-minute timer', 'What did people talk about on stream today?', 'Summarize today’s Rundown', 'Write a title for my stream']); |
| 122 | const go = () => { try { if (root.OpenVibeComposer && form.isConnected) root.OpenVibeComposer.enhance(form); } catch (e) { console.error(e); } }; |
| 123 | if (root.OpenVibeComposer) { go(); return; } |
| 124 | const m = /^(https?:\/\/[^?#]*\/)dock-vibe(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc); |
| 125 | if (!m) return; |
| 126 | let sc = document.getElementById('ov-composer-script'); |
| 127 | if (!sc) { sc = document.createElement('script'); sc.id = 'ov-composer-script'; sc.async = true; sc.src = m[1] + 'ov-composer.js' + (VIBE_REV['ov-composer.js'] ? '?v=' + VIBE_REV['ov-composer.js'] : ''); document.head.appendChild(sc); } |
| 128 | sc.addEventListener('load', go, { once: true }); |
| 129 | } |
| 130 | const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; }; |
| 131 | /** A round icon button with its name as label and tooltip. */ |
| 132 | const iconButton = (tag, cls, path, label) => { const b = h(tag, cls); if (tag === 'button') b.type = 'button'; b.setAttribute('aria-label', label); b.setAttribute('data-ov-tip', label); b.title = label; b.innerHTML = svg(path); return b; }; |
| 133 | /** Open Vibe in OpenVibe.Chat, as an icon (the thread's own address when one is open). */ |
| 134 | const awayLink = () => { const a = iconButton('a', 'ovdk-ib ovdk-away', V.open, 'Open in Chat'); a.href = 'https://openvibe.chat/vibe'; a.target = '_blank'; a.rel = 'noopener'; return a; }; |
| 135 | |
| 136 | // ── Vibe (plan L48 V2; the owner, 11-01: "access your agent or the AI… from across all websites… use a voice |
| 137 | // feature to tell it to set a reminder… have a conversation with your Vibe AI") ── |
| 138 | // The same conversation as openvibe.chat/vibe, through Chat's /api/vibe with the person's Network token: what is |
| 139 | // asked on Live carries on on Chat and back. Vibe answers in the background; the tab waits for it (a quick poll |
| 140 | // for a minute and a half after a message, then every 15 s while the tab is open). The mic is the browser's own |
| 141 | // speech recognition, hold to talk or Alt+V, and only while the person asks: no wake word, never always on. A |
| 142 | // browser without it (Firefox) records instead, up to 30 s, and Chat's /api/vibe/listen turns that into text |
| 143 | // (OpenVibe.AI's speech-to-text on this network's own machines; the recording is never kept). |
| 144 | // Everything is set as text; a link is a link only when it is https. |
| 145 | const vibe = { panel: null, chat: null, view: 'list', fresh: false, msgs: [], last: 0, timer: 0, busy: false, waitUntil: 0, rec: null, talk: null, generation: 0 }; |
| 146 | const SR = root.SpeechRecognition || root.webkitSpeechRecognition || null; |
| 147 | const MR = !SR && root.MediaRecorder && root.navigator && root.navigator.mediaDevices && root.navigator.mediaDevices.getUserMedia ? root.MediaRecorder : null; |
| 148 | const TALK = Boolean(SR || MR); |
| 149 | const REC_TYPES = ['audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4', 'audio/webm', 'audio/ogg']; |
| 150 | const REC_MAX_MS = 30000; |
| 151 | const STARTERS = [ |
| 152 | ['bell', 'Remind me to stretch every 2 hours'], |
| 153 | ['calendar', 'What is on today?'], |
| 154 | ['live', 'Who is live right now?'], |
| 155 | ['clock', 'Start a 25-minute focus timer'], |
| 156 | ['phone', 'Remind me tomorrow at 9 to call Sam'], |
| 157 | ['search', 'What is new on OpenVibe this week?'], |
| 158 | ]; |
| 159 | function linkified(text) { |
| 160 | const span = h('span', 'ovdk-text'); |
| 161 | String(text || '').split(/(https:\/\/[^\s<>"')]+)/).forEach((part, i) => { |
| 162 | if (i % 2) { const a = h('a', '', part); a.href = part; a.target = '_blank'; a.rel = 'noopener noreferrer'; span.append(a); } else if (part) span.append(part); |
| 163 | }); |
| 164 | return span; |
| 165 | } |
| 166 | const DAY = 24 * 3600 * 1000; |
| 167 | /** One thing Vibe did (Chat's results on its answer): what, when, and Undo / Open. */ |
| 168 | function resultChip(m, r, i) { |
| 169 | const set = r.action === 'set'; |
| 170 | const c = h('div', `ovdk-res-c${set ? '' : ' is-stopped'}`); |
| 171 | c.append(icon(r.kind === 'timer' ? G.clock : G.bell)); |
| 172 | const t = h('div', 'ovdk-res-t'); |
| 173 | const what = r.kind === 'timer' ? (set ? 'Timer' : 'Timer stopped') : (set ? 'Reminder set' : 'Reminder stopped'); |
| 174 | t.append(h('b', '', `${what}: `), String(r.title || '')); |
| 175 | if (r.when && set) t.append(h('small', '', String(r.when))); |
| 176 | const a = h('div', 'ovdk-res-a'); |
| 177 | const young = !m.at || Date.now() - Date.parse(m.at) < DAY; |
| 178 | if (set && young && Number(m.id) > 0) { |
| 179 | const undo = h('button', '', 'Undo'); undo.type = 'button'; |
| 180 | undo.setAttribute('aria-label', `Undo: ${what} ${r.title || ''}`.trim()); |
| 181 | undo.addEventListener('click', async () => { |
| 182 | undo.disabled = true; |
| 183 | try { |
| 184 | const out = await ctx.chatCall('/api/vibe/undo', { method: 'POST', body: JSON.stringify({ message_id: Number(m.id), index: i }) }); |
| 185 | m.results = (out && out.results) || m.results.map((x, k) => (k === i ? { ...x, action: 'stopped' } : x)); |
| 186 | c.replaceWith(resultChip(m, m.results[i], i)); |
| 187 | if (r.kind === 'timer' && ctx.refresh) ctx.refresh(); |
| 188 | } catch (e) { |
| 189 | undo.disabled = false; undo.textContent = e && e.status === 410 ? 'Too old to undo' : 'Try again'; |
| 190 | if (e && (e.status === 409 || e.status === 410)) undo.disabled = true; |
| 191 | } |
| 192 | }); |
| 193 | a.append(undo); |
| 194 | } |
| 195 | if (r.url && /^https:\/\//.test(r.url)) { const o = h('a', '', 'Open'); o.href = r.url; o.target = '_blank'; o.rel = 'noopener'; a.append(o); } |
| 196 | c.append(t, a); |
| 197 | return c; |
| 198 | } |
| 199 | function results(m) { |
| 200 | const box = h('div', 'ovdk-res'); |
| 201 | m.results.slice(0, 4).forEach((r, i) => { if (r && (r.kind === 'reminder' || r.kind === 'timer')) box.append(resultChip(m, r, i)); }); |
| 202 | return box; |
| 203 | } |
| 204 | function vbubble(m, prev) { |
| 205 | const mine = m.from === 'you'; |
| 206 | const li = h('li', `ovdk-msg${mine ? ' is-mine' : ' is-vibe'}${prev && prev.from === m.from ? ' is-cont' : ''}`); |
| 207 | if (!mine && !(prev && prev.from === m.from)) li.append(h('span', 'ovdk-who', 'Vibe')); |
| 208 | li.append(linkified(m.text)); |
| 209 | if (!mine && Array.isArray(m.results) && m.results.length) li.append(results(m)); |
| 210 | if (m.at) li.title = new Date(m.at).toLocaleString(); |
| 211 | return li; |
| 212 | } |
| 213 | const age = (iso) => { const n = Date.parse(iso || ''); if (!Number.isFinite(n)) return ''; const m = Math.max(0, Math.floor((Date.now() - n) / 60000)); return m < 1 ? 'now' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.floor(m / 60)} h ago` : `${Math.floor(m / 1440)} d ago`; }; |
| 214 | async function openList() { |
| 215 | const generation = ++vibe.generation; |
| 216 | clearTimeout(vibe.timer); vibe.view = 'list'; vibe.chat = null; |
| 217 | const panel = vibe.panel; if (!panel) return; |
| 218 | panel.textContent = ''; |
| 219 | // No heading (ask 335, the owner: "remove the text … new conversation … an icon plus button … open in Chat a |
| 220 | // little button icon"): one slim row of two icon buttons over your chats. |
| 221 | const head = h('div', 'ovdk-bar'); |
| 222 | const newer = iconButton('button', 'ovdk-ib is-new ovdk-vibe-new', V.plus, 'New chat'); newer.addEventListener('click', () => { vibe.fresh = true; vibe.chat = null; ctx.save({ vibe: null }); openVibe(); }); |
| 223 | head.append(newer, awayLink()); |
| 224 | const scroll = h('div', 'ovdk-scroll'); const list = h('ul', 'ovdk-vibe-list'); |
| 225 | scroll.append(list); panel.append(head, scroll); |
| 226 | try { |
| 227 | const out = await ctx.chatCall('/api/vibe/conversations'); |
| 228 | if (generation !== vibe.generation || vibe.view !== 'list' || vibe.panel !== panel) return; |
| 229 | const conversations = Array.isArray(out.conversations) ? out.conversations : []; |
| 230 | if (!conversations.length) list.append(h('li', 'ovdk-none', 'Your chats with Vibe will appear here. Press + to start one.')); |
| 231 | conversations.forEach((c) => { |
| 232 | const li = h('li'), b = h('button'); b.type = 'button'; |
| 233 | const title = h('b', '', c.name || 'Conversation'); const time = h('time', '', age(c.last_message_at)); |
| 234 | const line = h('span', '', c.last_line || 'No messages yet'); |
| 235 | // The site it began on, by name ("Blog", not openvibe.blog), beside the time. |
| 236 | const host = String(c.site || '').replace(/^https?:\/\//, '').replace(/\/.*$/, ''); |
| 237 | const name = host ? (/^openvibe\.([a-z]+)$/.exec(host) || [])[1] : ''; |
| 238 | const site = h('small', '', name ? name.charAt(0).toUpperCase() + name.slice(1) : host); |
| 239 | const mark = h('span', 'ovdk-vibe-site'); mark.append(icon(G.vibe)); |
| 240 | b.append(mark, title, time, line, site); |
| 241 | b.setAttribute('aria-label', `${c.name || 'Conversation'}, ${age(c.last_message_at)}`); |
| 242 | b.addEventListener('click', () => { vibe.fresh = false; vibe.chat = { slug: c.slug }; ctx.save({ vibe: { slug: c.slug, at: new Date().toISOString() } }); openVibe(); }); |
| 243 | li.append(b); list.append(li); |
| 244 | }); |
| 245 | } catch (e) { if (generation === vibe.generation) list.append(h('li', 'ovdk-none', e && e.status === 401 ? 'Sign in to see your conversations.' : 'Conversations did not load. Open Vibe again to retry.')); } |
| 246 | } |
| 247 | async function openVibe() { |
| 248 | const generation = ++vibe.generation; |
| 249 | clearTimeout(vibe.timer); |
| 250 | vibe.view = 'conversation'; |
| 251 | const panel = vibe.panel; if (!panel) return; |
| 252 | vibe.msgs = []; vibe.last = 0; |
| 253 | panel.textContent = ''; |
| 254 | const head = h('div', 'ovdk-thread-h ovdk-vibe-h'); |
| 255 | const title = h('b', '', 'Vibe'); |
| 256 | const back = iconButton('button', 'ovdk-vibe-back', V.back, 'Back to your chats'); back.addEventListener('click', () => { ctx.save({ vibe: null }); openList(); }); |
| 257 | const away = awayLink(); |
| 258 | const mark = h('span', 'ovdk-vibe-mark'); mark.innerHTML = svg(V.spark); |
| 259 | const name = h('span', 'ovdk-vibe-name'); name.append(title, h('small', '', 'Your AI on every OpenVibe site')); |
| 260 | const fresh = iconButton('button', 'ovdk-ib ovdk-vibe-fresh', V.plus, 'New chat'); |
| 261 | fresh.addEventListener('click', () => { vibe.fresh = true; vibe.chat = null; ctx.save({ vibe: null }); openVibe(); }); |
| 262 | head.append(back, mark, name, fresh, away); |
| 263 | const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite'); |
| 264 | const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list); |
| 265 | const typing = h('li', 'ovdk-msg is-vibe ovdk-typing'); typing.innerHTML = '<span class="ovdk-text"><i></i><i></i><i></i></span>'; typing.setAttribute('aria-label', 'Vibe is writing'); |
| 266 | const form = h('form', 'ovdk-send'); |
| 267 | const box = h('textarea'); box.rows = 1; box.maxLength = 8000; box.placeholder = TALK ? 'Ask Vibe, or hold the mic' : 'Ask Vibe anything'; box.setAttribute('aria-label', 'Message Vibe'); |
| 268 | const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send)); |
| 269 | const err = h('p', 'ovdk-err'); err.hidden = true; |
| 270 | const reason = (e) => e && e.status === 401 ? 'Your sign in expired. Sign in again to continue.' |
| 271 | : e && e.status === 429 ? 'Too many requests were sent. Wait a minute, then retry.' |
| 272 | : e && e.code === 'vibe.off' ? 'Vibe is unavailable right now. Retry when it returns.' |
| 273 | : e && e.status === 503 ? 'The AI service is unavailable right now. Retry shortly.' |
| 274 | : 'The request could not reach Vibe. Check your connection and retry.'; |
| 275 | const showFailure = (e, retry) => { err.textContent = `Vibe did not answer: ${reason(e)} `; err.hidden = false; |
| 276 | if (retry) { const b = h('button', '', 'Retry'); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; try { await retry(); } catch (x) { showFailure(x, retry); } finally { b.disabled = false; } }); err.append(b); } |
| 277 | }; |
| 278 | const attach = iconButton('button', 'ovdk-attach', V.image, 'Attach a picture'); |
| 279 | const file = h('input'); file.type = 'file'; file.accept = 'image/*'; file.hidden = true; |
| 280 | attach.addEventListener('click', () => file.click()); |
| 281 | file.addEventListener('change', async () => { |
| 282 | const picture = file.files && file.files[0]; if (!picture) return; |
| 283 | attach.disabled = true; err.hidden = true; |
| 284 | try { |
| 285 | const path = vibe.chat ? `/api/chat/rooms/${encodeURIComponent(vibe.chat.slug)}/pictures` : '/api/chat/vibe/picture'; |
| 286 | const out = await ctx.chatCall(path, { method: 'POST', body: picture, type: picture.type || 'image/jpeg' }); |
| 287 | if (out.room && out.room.slug) { vibe.chat = { slug: out.room.slug }; vibe.fresh = false; ctx.save({ vibe: { slug: out.room.slug, at: new Date().toISOString() } }); } |
| 288 | openVibe(); |
| 289 | } catch { err.textContent = 'That picture could not be attached. Try again.'; err.hidden = false; } |
| 290 | finally { attach.disabled = false; file.value = ''; } |
| 291 | }); |
| 292 | // 350: Build it in Code. What you wrote, else Vibe's last answer, goes to the Code part as a new task's words. |
| 293 | const build = iconButton('button', 'ovdk-attach ovdk-build', V.code, 'Build it in Code'); |
| 294 | build.addEventListener('click', () => { |
| 295 | const last = [...vibe.msgs].reverse().find((m) => m.from !== 'you'); |
| 296 | const text = box.value.trim() || (last && last.text) || ''; |
| 297 | if (!text) { box.focus(); return; } |
| 298 | build.dispatchEvent(new CustomEvent('ov-dock:part', { bubbles: true, detail: { part: 'code', text: text.slice(0, 16000) } })); |
| 299 | }); |
| 300 | form.classList.add('ovdk-send--two'); |
| 301 | const tools = h('div', 'ovdk-send-tools'); tools.setAttribute('data-ovc-tools', ''); form.setAttribute('data-ovc-contain', ''); |
| 302 | tools.append(attach, file, build); |
| 303 | let mic = null; |
| 304 | if (TALK) { |
| 305 | mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic)); |
| 306 | mic.setAttribute('aria-label', 'Talk to Vibe: hold, or press Alt+V'); mic.title = 'Hold to talk (Alt+V)'; mic.setAttribute('aria-pressed', 'false'); |
| 307 | tools.prepend(mic); form.setAttribute('data-ovc-no-voice', ''); |
| 308 | } |
| 309 | go.textContent = ''; go.innerHTML = svg(V.send); |
| 310 | tools.append(go); |
| 311 | form.append(box, tools); |
| 312 | panel.append(head, scroll, err, form); |
| 313 | const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; }; |
| 314 | vibe.fill = (text) => { box.value = String(text || '').slice(0, 8000); fit(); try { box.focus(); box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } }; |
| 315 | if (vibe.pendingAsk) { vibe.fill(vibe.pendingAsk); vibe.pendingAsk = null; } |
| 316 | box.addEventListener('input', fit); |
| 317 | box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } }); |
| 318 | |
| 319 | async function send(text) { |
| 320 | if (!text || vibe.busy) return; |
| 321 | vibe.busy = true; go.disabled = true; err.hidden = true; |
| 322 | try { |
| 323 | let timezone = null; try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { /* */ } |
| 324 | const out = await ctx.chatCall('/api/vibe/ask', { method: 'POST', body: JSON.stringify({ message: text, slug: vibe.chat ? vibe.chat.slug : undefined, fresh: vibe.fresh, timezone }) }); |
| 325 | if (generation !== vibe.generation) return; |
| 326 | if (out.chat) { vibe.chat = out.chat; vibe.fresh = false; away.href = out.chat.url; ctx.save({ vibe: { slug: out.chat.slug, at: new Date().toISOString() } }); } |
| 327 | box.value = ''; fit(); |
| 328 | const empty = list.querySelector('.ovdk-vibe-start'); if (empty) empty.remove(); |
| 329 | if (out.message && !vibe.msgs.some((x) => x.id === out.message.id)) { list.append(vbubble(out.message, vibe.msgs[vibe.msgs.length - 1])); vibe.msgs.push(out.message); vibe.last = Math.max(vibe.last, out.message.id || 0); } |
| 330 | vibe.waitUntil = Date.now() + 90000; |
| 331 | list.append(typing); |
| 332 | scroll.scrollTop = scroll.scrollHeight; |
| 333 | clearTimeout(vibe.timer); vibe.timer = setTimeout(() => pull(), 1200); |
| 334 | } catch (x) { |
| 335 | showFailure(x, () => send(text)); |
| 336 | } finally { vibe.busy = false; go.disabled = false; box.focus(); } |
| 337 | } |
| 338 | form.addEventListener('submit', (e) => { e.preventDefault(); send(box.value.trim()); }); |
| 339 | joinComposer(form); |
| 340 | |
| 341 | async function pull(first) { |
| 342 | let got; |
| 343 | try { |
| 344 | got = vibe.fresh && !vibe.chat ? { chat: null, messages: [] } : await ctx.chatCall(`/api/vibe/chat${vibe.chat ? `?slug=${encodeURIComponent(vibe.chat.slug)}${vibe.last ? `&after=${vibe.last}` : ''}` : ''}`); |
| 345 | } catch (e) { |
| 346 | if (first && generation === vibe.generation) { list.textContent = ''; showFailure(e, () => pull(true)); } |
| 347 | return; |
| 348 | } |
| 349 | if (generation !== vibe.generation || !ctx.active() || vibe.panel !== panel) return; |
| 350 | if (got.chat) { vibe.chat = got.chat; away.href = got.chat.url; title.textContent = got.chat.name || 'Vibe'; } |
| 351 | if (first) list.textContent = ''; |
| 352 | const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40; |
| 353 | let answered = false; |
| 354 | (got.messages || []).forEach((m) => { |
| 355 | if (vibe.msgs.some((x) => x.id === m.id)) return; |
| 356 | list.insertBefore(vbubble(m, vibe.msgs[vibe.msgs.length - 1]), typing.parentNode === list ? typing : null); |
| 357 | vibe.msgs.push(m); vibe.last = Math.max(vibe.last, Number(m.id) || 0); |
| 358 | if (m.from === 'vibe') answered = true; |
| 359 | if (!first && Array.isArray(m.results) && m.results.some((r) => r && r.kind === 'timer') && ctx.refresh) ctx.refresh(); |
| 360 | }); |
| 361 | if (answered || Date.now() > vibe.waitUntil) { |
| 362 | typing.remove(); |
| 363 | if (!answered && vibe.waitUntil && Date.now() > vibe.waitUntil) { |
| 364 | showFailure({ status: 503 }, vibe.chat ? async () => { await ctx.chatCall(`/api/chat/vibe/${encodeURIComponent(vibe.chat.slug)}/retry`, { method: 'POST', body: '{}' }); err.hidden = true; vibe.waitUntil = Date.now() + 90000; list.append(typing); pull(); } : null); |
| 365 | } |
| 366 | vibe.waitUntil = 0; |
| 367 | } |
| 368 | if (first && !vibe.msgs.length) { |
| 369 | const start = h('li', 'ovdk-vibe-start'); |
| 370 | const hello = h('div', 'ovdk-vibe-hello'); |
| 371 | const big = h('span', 'ovdk-vibe-mark is-big'); big.innerHTML = svg(V.spark); |
| 372 | const words = h('div', 'ovdk-vibe-hello-t'); |
| 373 | words.append(h('b', '', 'What can I do for you?'), h('span', '', TALK ? 'Ask anything, set reminders and timers, find what is live. Hold the mic to talk.' : 'Ask anything, set reminders and timers, find what is live.')); |
| 374 | hello.append(big, words); |
| 375 | const chips = h('div', 'ovdk-chips'); chips.setAttribute('role', 'list'); |
| 376 | STARTERS.forEach(([ic, s]) => { |
| 377 | const c = h('button', 'ovdk-chip'); c.type = 'button'; c.setAttribute('role', 'listitem'); |
| 378 | const m = h('span', 'ovdk-chip-ic'); m.innerHTML = svg(V[ic]); |
| 379 | const t = h('span', 'ovdk-chip-t', s); |
| 380 | const go2 = h('span', 'ovdk-chip-go'); go2.innerHTML = svg(V.chevron); |
| 381 | c.append(m, t, go2); |
| 382 | c.addEventListener('click', () => send(s)); chips.append(c); |
| 383 | }); |
| 384 | start.append(hello, chips); |
| 385 | list.append(start); |
| 386 | } |
| 387 | if (first && !vibe.msgs.length) scroll.scrollTop = 0; |
| 388 | else if (first || atEnd || answered) scroll.scrollTop = scroll.scrollHeight; |
| 389 | clearTimeout(vibe.timer); |
| 390 | if (ctx.active()) vibe.timer = setTimeout(() => pull(), vibe.waitUntil ? 1500 : 15000); |
| 391 | } |
| 392 | |
| 393 | // The mic: hold to talk (or a click to start and another to stop), Alt+V from anywhere while the Dock is open. |
| 394 | if (mic) { |
| 395 | let said = '', heard = false; |
| 396 | const stop = () => { if (vibe.rec) { try { vibe.rec.stop(); } catch { /* */ } } }; |
| 397 | const on = (yes) => { mic.classList.toggle('is-on', yes); mic.setAttribute('aria-pressed', yes ? 'true' : 'false'); }; |
| 398 | const fail = (text) => { err.textContent = text; err.hidden = false; }; |
| 399 | // No speech recognition in this browser: record, then Chat's /api/vibe/listen says what was said. |
| 400 | const record = async () => { |
| 401 | const type = REC_TYPES.find((t) => { try { return MR.isTypeSupported(t); } catch { return false; } }) || ''; |
| 402 | let stream; |
| 403 | try { stream = await root.navigator.mediaDevices.getUserMedia({ audio: true }); } catch (e) { |
| 404 | return fail(e && e.name === 'NotAllowedError' ? 'The microphone is off for this site: allow it in the browser to talk to Vibe.' : 'No microphone was found.'); |
| 405 | } |
| 406 | const chunks = []; |
| 407 | let r; |
| 408 | try { r = new MR(stream, { ...(type ? { mimeType: type } : {}), audioBitsPerSecond: 32000 }); } catch { stream.getTracks().forEach((t) => t.stop()); return fail('This browser cannot record here.'); } |
| 409 | const cap = setTimeout(stop, REC_MAX_MS); |
| 410 | r.ondataavailable = (ev) => { if (ev.data && ev.data.size) chunks.push(ev.data); }; |
| 411 | r.onstop = async () => { |
| 412 | clearTimeout(cap); vibe.rec = null; on(false); |
| 413 | stream.getTracks().forEach((t) => t.stop()); |
| 414 | const kind = String(r.mimeType || type || 'audio/webm').split(';')[0]; |
| 415 | const blob = new Blob(chunks, { type: kind }); |
| 416 | if (blob.size < 800) { box.placeholder = 'Ask Vibe, or hold the mic'; return; } |
| 417 | box.placeholder = 'Working out what you said…'; mic.disabled = true; |
| 418 | try { |
| 419 | const lang = String(document.documentElement.lang || root.navigator.language || 'en').slice(0, 2).toLowerCase(); |
| 420 | const out = await ctx.chatCall(`/api/vibe/listen?lang=${encodeURIComponent(/^[a-z]{2}$/.test(lang) ? lang : 'en')}`, { method: 'POST', body: blob, type: kind }); |
| 421 | const said = String((out && out.text) || '').trim(); |
| 422 | if (said) { box.value = said; fit(); send(said); } else fail('I could not hear that. Try again, a little closer.'); |
| 423 | } catch (e) { |
| 424 | fail(e && e.status === 429 ? 'That is a lot of talking for one minute: type it, or try again shortly.' : e && e.status === 413 ? 'Keep it under 30 seconds.' : 'I could not hear that just now. Try again, or type it.'); |
| 425 | } finally { mic.disabled = false; box.placeholder = 'Ask Vibe, or hold the mic'; } |
| 426 | }; |
| 427 | vibe.rec = r; err.hidden = true; on(true); box.placeholder = 'Listening… let go or tap to send'; |
| 428 | try { r.start(); } catch { vibe.rec = null; on(false); stream.getTracks().forEach((t) => t.stop()); } |
| 429 | }; |
| 430 | const start = () => { |
| 431 | if (vibe.rec) return stop(); |
| 432 | if (!SR) return record(); |
| 433 | const r = new SR(); |
| 434 | r.lang = document.documentElement.lang || navigator.language || 'en-US'; |
| 435 | r.interimResults = true; r.continuous = false; |
| 436 | said = ''; heard = false; |
| 437 | r.onresult = (ev) => { |
| 438 | let text = ''; |
| 439 | for (let i = 0; i < ev.results.length; i++) text += ev.results[i][0].transcript; |
| 440 | said = text.trim(); heard = true; box.value = said; fit(); |
| 441 | }; |
| 442 | r.onerror = (ev) => { |
| 443 | if (ev.error === 'not-allowed' || ev.error === 'service-not-allowed') { err.textContent = 'The microphone is off for this site: allow it in the browser to talk to Vibe.'; err.hidden = false; } |
| 444 | }; |
| 445 | r.onend = () => { |
| 446 | vibe.rec = null; mic.classList.remove('is-on'); mic.setAttribute('aria-pressed', 'false'); |
| 447 | if (heard && said) send(said); |
| 448 | }; |
| 449 | vibe.rec = r; |
| 450 | mic.classList.add('is-on'); mic.setAttribute('aria-pressed', 'true'); |
| 451 | try { r.start(); } catch { vibe.rec = null; mic.classList.remove('is-on'); } |
| 452 | }; |
| 453 | let held = 0; |
| 454 | mic.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; held = Date.now(); start(); }); |
| 455 | mic.addEventListener('pointerup', () => { if (held && Date.now() - held > 400) stop(); held = 0; }); |
| 456 | mic.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); start(); } }); |
| 457 | vibe.talk = start; |
| 458 | } |
| 459 | list.append(h('li', 'ovdk-wait', null)); |
| 460 | list.firstChild.innerHTML = '<span></span>'; |
| 461 | await pull(true); |
| 462 | // A touch screen does not get the keyboard on opening (11-09, the owner: it "immediately opens the keyboard… you |
| 463 | // can't see the chat input or what it did"): the answer and the box show first; a tap on the box types. |
| 464 | if (!(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)) setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0); |
| 465 | } |
| 466 | |
| 467 | function mount(panel, c) { |
| 468 | ctx = c || {}; |
| 469 | G = { mic: V.mic, ...(ctx.icons || {}) }; |
| 470 | const st = typeof ctx.styles === 'function' ? ctx.styles() : null; |
| 471 | if (st && !st.textContent.includes('.ovdk-vibe-start')) st.textContent += CSS; |
| 472 | else if (!st && !document.getElementById('ovdk-vibe-styles')) { const s = document.createElement('style'); s.id = 'ovdk-vibe-styles'; s.textContent = CSS; document.head.appendChild(s); } |
| 473 | vibe.panel = panel; |
| 474 | // The Chats tab's AI chats row asks for the list, not for the last conversation. |
| 475 | const onList = ctx.wantList ? ctx.wantList() : false; |
| 476 | const selected = ctx.state && ctx.state().vibe; |
| 477 | if (!onList && selected && selected.slug && Date.now() - Date.parse(selected.at) < 20 * 60000) { vibe.chat = { slug: selected.slug }; openVibe(); } |
| 478 | else openList(); |
| 479 | return { |
| 480 | refresh: () => { vibe.panel = panel; const list = ctx.wantList ? ctx.wantList() : false; if (list) return openList(); return vibe.view === 'list' ? openList() : openVibe(); }, |
| 481 | list: () => { vibe.panel = panel; return openList(); }, |
| 482 | pause: () => { vibe.generation++; clearTimeout(vibe.timer); if (vibe.rec) { try { vibe.rec.abort(); } catch { /* */ } } }, |
| 483 | talk: () => { if (vibe.talk) vibe.talk(); }, |
| 484 | // A page's "Ask your agent" (4.135.0): the question waits in the box, with its context, for the person to send. |
| 485 | ask: (text) => { if (vibe.view === 'list') { vibe.pendingAsk = text; vibe.fresh = true; openVibe(); } else if (vibe.fill) vibe.fill(text); else vibe.pendingAsk = text; }, |
| 486 | }; |
| 487 | } |
| 488 | root.OpenVibeDockVibe = { mount, GLYPHS: V }; |
| 489 | })(typeof window !== 'undefined' ? window : globalThis); |
| 490 |