openvibers/openvibe.shared dock-vibe.js

dock-vibe.js

main 41.1 KB · 490 lines · javascript

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 'use strict';
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