openvibers/openvibe.chat Commits
Vibe is the network's conversation UI: the one sidebar, turns, the kit's composer and start page (#323)
Changes
@@@ -9,7 +9,7 @@"domain": "openvibe.chat (Chat's own site: global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24) and openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost)","contracts": "openvibe-contracts v0.222.0","sdk": "openvibe-sdk v0.81.0",-- "shared": "openvibe-shared v4.99.0",++ "shared": "openvibe-shared v4.99.1","capabilities": ["chat.message.send","chat.event.publish",@@@ -18,7 +18,7 @@"multer": "^2.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.1","pg": "^8.23.0","ws": "^8.18.0"},@@@ -1339,9 +1339,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.99.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.0",-- "integrity": "sha512-i8iioHF2nBboP5RHAoPKCVn6pYjfOqgDhkX4k8KIGLKqt5ItClwKL8S1giWJ7hFZc2XkuSsRHEC05nArMHqtyg==",++ "version": "4.99.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.1",++ "integrity": "sha512-9UhImTuFiSurZ1XnDfDeihlexwzWfCk2Pg+v6QAJ7GYf3HXdIaNoQO2XQJVu5+izf3FD+Ed0PyDYCRalSfPjOw==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -30,7 +30,7 @@"multer": "^2.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.1","pg": "^8.23.0","ws": "^8.18.0"},@@@ -1,7 +1,8 @@/* Vibe, OpenVibe's assistant, on openvibe.chat (server/web/vibe-pages.js): the full-window app. The chats down the left,the conversation in a readable column, one composer docked at the bottom. Colours are the network theme's (chat.cssmaps them to --oc-*), so every theme, light ones included, carries through. */--body.oc-app-body { overflow: hidden; }++/* The inbox is a full-window app (its own scroll box); Vibe's pages are the conversation kit's, and the page scrolls. */++body.oc-app-body:has(.vb) { overflow: hidden; }.vb-wrap { margin: 0; padding: 0; max-width: none; }.vb {--vb-rail: 272px;@@@ -363,3 +364,34 @@ body.oc-app-body { overflow: hidden; }.vb-res-c.is-stopped { border-color: var(--border, color-mix(in srgb, var(--text-primary, #fff) 10%, transparent)); background: transparent; }.vb-res-c.is-stopped > svg { stroke: var(--text-muted, #8b8b96); }.vb-res-c.is-stopped .vb-res-t { color: var(--text-muted, #8b8b96); }++++/* ── Vibe on the network's conversation UI (openvibe-shared/thread, plan L55 C4) ───────────────────────────────────++ The kit draws the chats (kit.sidebar), the turns, Copy, the composer and the page; these are Vibe's own parts in it:++ its icons, the modes and attach in the composer's tools, the picture preview, the fine print, the search's snippets,++ the chat's options and the welcome. (The inbox still draws the older .vb layout above.) */++.vbk { --vb-col: 780px; }++.vbk svg:not(.ovt-ic):not(.ovk-i) { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; flex: none; }++.vbk .ovt-btn svg:not(.ovt-ic) { width: 20px; height: 20px; }++.vbk .vb-modes { order: 0; flex-basis: auto; margin: 0; padding: 0; }++.vbk .vb-attach { flex: none; width: 36px; height: 36px; margin: 0; }++.vbk .vb-preview { max-width: none; margin: 0 0 8px; }++.vbk .vb-fine { margin: 6px 4px 0; text-align: left; }++.vbk .vb-fine a { color: var(--oc-accent); }++.vbk .ovt-c-note:empty { display: none; }++.vbk .vb-allow { margin: 8px 8px 0; }++.vbk .vb-local { display: block; max-width: 220px; max-height: 180px; margin-top: 8px; border-radius: 12px; object-fit: cover; }++/* A search: where each chat matched, under its name. */++.vbk .ovk-sidebar-item.vb-hit { flex-wrap: wrap; row-gap: 2px; padding-block: 6px; }++.vbk .vb-hit-s { flex-basis: 100%; min-width: 0; font-size: .78rem; line-height: 1.35; color: var(--oc-muted); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }++.vbk .vb-hit-s mark { background: color-mix(in srgb, var(--oc-accent) 28%, transparent); color: inherit; border-radius: 3px; padding: 0 1px; }++/* The chat's options (rename, delete) open from the head. */++.vbk .vb-actions > summary { cursor: pointer; }++.vbk .vb-pop { right: 0; }++/* What Vibe did, its sources and how, under the answer: the kit's spacing. */++.vbk .ovt-reply > .vb-res, .vbk .ovt-reply > .vb-sources, .vbk .ovt-reply > .vb-how { margin-top: 0; }++.vbk .vb-retry { display: inline; margin: 0; }++/* The welcome: Vibe's mark, the greeting, four ideas (the kit's start page puts the composer under them). */++.vbk .vb-hello { min-height: 0; padding: 0; }++.vbk .ovt-answer.vb-streaming { opacity: .92; }++/* Memory: a plain page in the frame; on a wide screen its head held only New chat, which the chats column has. */++@media (min-width: 761px) { .vbk .ovt.is-page .ovt-head { display: none; } }@@@ -1,8 +1,11 @@--/* Vibe on openvibe.chat (server/web/vibe-pages.js): makes the app live. The page works without this file (forms post,-- the server renders every message); here a first message starts the chat in place, answers arrive over the room-- socket (/ws/chat join_room, room_message, room_ai_typing), the composer grows with its text and sends on Enter, the-- rail slides in on phones, and a draft typed while signed out survives the sign-in. A person's text is always set-- with textContent; Vibe's answers go through VibeMarkdown (public/web/vibe-md.js), which escapes everything first. */++/* Vibe on openvibe.chat (server/web/vibe-pages.js): makes the app live. The page is the network's conversation UI++ (openvibe-shared/thread, plan L55 C4): ov-thread.js grows the composer, sends on Enter, adds Copy, filters the chats++ and keeps the thread at the bottom; kit-web.js opens the chats on a phone. The page works without either (forms++ post, the server renders every message). Here: a first message starts the chat in place, answers arrive over the++ room socket (/ws/chat join_room, room_message, room_ai_typing, room_ai_delta) and are written in as they come, a++ picture or a text file goes with a message, the modes, Stop, Try again, and a draft typed while signed out survives++ the sign-in. A person's text is always set with textContent; Vibe's answers go through VibeMarkdown++ (public/web/vibe-md.js), which escapes everything first. */(function () {'use strict';var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;@@@ -10,44 +13,42 @@var MD = window.VibeMarkdown;var DRAFT = 'ov-vibe-draft';var app = document.getElementById('vb');-- var scroll = document.getElementById('vb-scroll');++ if (!app) return;++ var box = app.querySelector('[data-ovt]');var form = document.getElementById('vb-composer');-- var input = document.getElementById('vb-input');-- var titleEl = document.getElementById('vb-title');++ var input = form && form.querySelector('[data-ovt-text]');++ var sendBtn = form && form.querySelector('[data-ovt-send]');++ var stopBtn = form && form.querySelector('[data-ovt-stop]');var room = cfg.room || null;var latest = Number(cfg.latest) || 0;var ws = null, delay = 1000, sending = false, waitTimer = null;var fileInput = document.getElementById('vb-file');var previewEl = document.getElementById('vb-preview');var picture = null; // { file, url }: a picture waiting to go with the next message++ var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }function token() { var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || ''); return m ? decodeURIComponent(m[1]) : undefined; }function store(fn) { try { return fn(window.localStorage); } catch (e) { return null; } }// The browser's IANA zone (America/New_York) with each message: Vibe's prompt then states the person's local date.function timezone() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || null; } catch (e) { return null; } }-- function nearBottom() { return scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 160; }-- function toBottom() { scroll.scrollTop = scroll.scrollHeight; }-- function markSvg() {-- var s = el('span', 'vb-mark'); s.setAttribute('aria-hidden', 'true');-- s.innerHTML = '<svg viewBox="0 0 24 24"><path d="M5 5l7 14 7-14"/></svg>';-- return s;-- }++ // The page scrolls as a whole (the kit has no inner scroll box).++ function nearBottom() { return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 220; }++ function toBottom() { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: reduce ? 'auto' : 'smooth' }); }++ function kitEnhance(scope) { if (window.OVThread && window.OVThread.enhance) window.OVThread.enhance(scope); }++ // The kit's composer reads its own input event (it grows and wakes Send); a box filled from here sends one too.++ function changed() { if (input) input.dispatchEvent(new Event('input', { bubbles: true })); }// ── The composer ──-- function grow() { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, window.innerHeight * 0.4) + 'px'; form.classList.toggle('is-empty', !input.value.trim() && !picture); }if (input) {-- input.addEventListener('input', grow);-- input.addEventListener('keydown', function (e) {-- if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && window.matchMedia('(hover: hover)').matches) { e.preventDefault(); form.requestSubmit ? form.requestSubmit() : form.submit(); }-- });++ // A picture alone may be sent: Send wakes for it even with no words (the kit wakes it only for words).++ input.addEventListener('input', function () { if (picture && sendBtn) sendBtn.disabled = false; });var saved = cfg.signedIn ? store(function (s) { var v = s.getItem(DRAFT); s.removeItem(DRAFT); return v; }) : null;-- if (saved && !input.value) input.value = saved;-- grow();++ if (saved && !input.value) { input.value = saved; changed(); }if (!('ontouchstart' in window)) input.focus();}-- document.querySelectorAll('.vb-chip[data-prompt]').forEach(function (b) {-- b.addEventListener('click', function () { input.value = b.getAttribute('data-prompt'); grow(); form.requestSubmit ? form.requestSubmit() : form.submit(); });++ [].forEach.call(document.querySelectorAll('.vb-chip[data-prompt]'), function (b) {++ b.addEventListener('click', function () { input.value = b.getAttribute('data-prompt'); changed(); if (form.requestSubmit) form.requestSubmit(); else form.submit(); });});// ── A picture with the message: it goes to OpenVibe.Pics as the person, and Vibe sees it ──@@@ -58,6 +59,7 @@if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; }if (fileInput) fileInput.value = '';if (input) input.required = true;++ changed();}// A text file goes into the message itself, as a fenced block named after it, cut to what still fits in the box.var TEXT_EXT = /\.(txt|md|markdown|csv|tsv|json|log|xml|ya?ml|toml|ini|html|css|m?js|ts|py|go|rs|java|c|h|cpp|rb|php|sh|sql)$/i;@@@ -74,7 +76,7 @@if (space < 200) { notice('The box is nearly full. Send what is there first.'); return; }var cut = text.length > space;input.value = head + (cut ? text.slice(0, space - 20) + '\n… (cut to fit)' : text) + '\n```';-- grow(); input.focus();++ changed(); input.focus();if (cut) notice('The file was longer than a message: its first part is in the box.');};reader.onerror = function () { notice('That file could not be read.'); };@@@ -89,12 +91,12 @@picture = { file: file, url: URL.createObjectURL(file) };var fig = el('figure');var img = el('img'); img.src = picture.url; img.alt = 'The picture you are sending';-- var rm = el('button', null, '\u00d7'); rm.type = 'button'; rm.setAttribute('aria-label', 'Remove the picture');++ var rm = el('button', null, '×'); rm.type = 'button'; rm.setAttribute('aria-label', 'Remove the picture');rm.addEventListener('click', function () { clearPicture(); input.focus(); });fig.appendChild(img); fig.appendChild(rm);previewEl.appendChild(fig); previewEl.hidden = false;input.required = false;-- form.classList.remove('is-empty');++ if (sendBtn) sendBtn.disabled = false;input.focus();}var attachBtn = app.querySelector('[data-vb-attach]');@@@ -108,92 +110,81 @@});}-- // ── The thread ──-- function thread() {-- var t = document.getElementById('vb-thread');-- if (t) return t;-- var hello = scroll.querySelector('.vb-hello'); if (hello) hello.remove();-- app.classList.remove('vb-empty');-- t = el('ol', 'vb-thread'); t.id = 'vb-thread';-- scroll.appendChild(t);++ // ── The thread (the kit's markup: a turn is the person's message and the answer under it) ──++ function threadEl() {++ var t = app.querySelector('[data-ovt-thread]');++ // The first message: the welcome goes, and the composer docks at the bottom.++ if (box) box.classList.remove('is-start');++ var hello = app.querySelector('.ovt-empty-state'); if (hello) hello.hidden = true;return t;}-- function have(id) { return id && document.querySelector('.vb-turn[data-id="' + Number(id) + '"]'); }++ function have(id) { return id && app.querySelector('[data-me="' + Number(id) + '"],[data-ai="' + Number(id) + '"]'); }++ function lastTurn() { var t = threadEl(); return t && t.lastElementChild; }function meTurn(text, id, localPicture) {-- var li = el('li', 'vb-turn vb-me'); if (id) li.dataset.id = String(id);-- var b = el('div', 'vb-bubble'); b.appendChild(el('p', 'oc-sr', 'You said:'));-- String(text).split('\n').forEach(function (line, i) { if (i) b.appendChild(document.createElement('br')); b.appendChild(document.createTextNode(line)); });++ var li = el('li', 'ovt-turn is-new'); if (id) li.setAttribute('data-me', String(id));++ var you = el('div', 'ovt-you');++ var b = el('div', 'ovt-bubble');++ b.appendChild(el('span', 'oc-sr', 'You said:'));++ b.appendChild(document.createTextNode(String(text)));if (localPicture) { var img = el('img', 'vb-local'); img.src = localPicture; img.alt = 'Your picture'; b.appendChild(img); }-- li.appendChild(b);++ you.appendChild(b); li.appendChild(you);return li;}-- function aiTurn(m) {-- var li = el('li', 'vb-turn vb-ai'); li.dataset.id = String(m.id);-- li.appendChild(markSvg());-- var body = el('div', 'vb-body'); body.appendChild(el('p', 'oc-sr', 'Vibe said:'));-- var md = el('div', 'vb-md'); md.innerHTML = MD ? MD.render(m.message) : '';-- if (!MD) md.textContent = m.message;-- body.appendChild(md); li.appendChild(body);-- // What Vibe did, as on the server's page (resultChips in server/web/vibe-pages.js): Undo is the same plain form.++ // The answer side of a turn: Vibe's mark, then its reply.++ function botOf(li) {++ var bot = li.querySelector('.ovt-bot');++ if (!bot) {++ bot = el('div', 'ovt-bot');++ var av = el('span', 'ovt-avatar'); av.setAttribute('aria-hidden', 'true');++ bot.appendChild(av); bot.appendChild(el('div', 'ovt-reply'));++ li.appendChild(bot);++ }++ return bot.querySelector('.ovt-reply');++ }++ // Vibe's answer, as the server draws it (answerOf in server/web/vibe-pages.js): Undo is the same plain form.++ function fillAnswer(reply, m) {++ reply.textContent = '';++ reply.appendChild(el('p', 'oc-sr', 'Vibe said:'));++ var ans = el('div', 'ovt-answer'); ans.setAttribute('data-ovt-answer', '');++ if (MD) ans.innerHTML = MD.render(m.message); else ans.textContent = m.message;++ reply.appendChild(ans);var res = Array.isArray(m.results) ? m.results : [];if (res.length && room) {-- var box = el('div', 'vb-res');++ var resBox = el('div', 'vb-res');res.slice(0, 4).forEach(function (r, i) {if (!r || (r.kind !== 'reminder' && r.kind !== 'timer')) return;var set = r.action === 'set';var c = el('div', 'vb-res-c' + (set ? '' : ' is-stopped'));var t = el('div', 'vb-res-t');-- var b = el('b', '', (r.kind === 'timer' ? (set ? 'Timer' : 'Timer stopped') : (set ? 'Reminder set' : 'Reminder stopped')) + ':');-- t.appendChild(b); t.appendChild(document.createTextNode(' ' + String(r.title || '')));++ t.appendChild(el('b', '', (r.kind === 'timer' ? (set ? 'Timer' : 'Timer stopped') : (set ? 'Reminder set' : 'Reminder stopped')) + ':'));++ t.appendChild(document.createTextNode(' ' + String(r.title || '')));if (r.when && set) t.appendChild(el('small', '', String(r.when)));c.appendChild(t);var a = el('div', 'vb-res-a');if (set) {var f = el('form'); f.method = 'post'; f.action = '/vibe/' + room + '/undo';[['message_id', String(m.id)], ['index', String(i)]].forEach(function (kv) { var h = el('input'); h.type = 'hidden'; h.name = kv[0]; h.value = kv[1]; f.appendChild(h); });-- var u = el('button', 'vb-act', 'Undo'); u.type = 'submit'; f.appendChild(u); a.appendChild(f);++ var u = el('button', 'ovt-act', 'Undo'); u.type = 'submit'; f.appendChild(u); a.appendChild(f);}-- if (r.url && /^https:\/\//.test(r.url)) { var o = el('a', 'vb-act', 'Open'); o.href = r.url; a.appendChild(o); }++ if (r.url && /^https:\/\//.test(r.url)) { var o = el('a', 'ovt-act', 'Open'); o.href = r.url; a.appendChild(o); }if (a.childNodes.length) c.appendChild(a);-- box.appendChild(c);++ resBox.appendChild(c);});-- body.appendChild(box);++ reply.appendChild(resBox);}-- // Copy, and Try again (it moves to this, the newest answer, when the person's message came just before).-- var row = el('div', 'vb-acts');-- var copy = el('button', 'vb-act', 'Copy'); copy.type = 'button'; copy.setAttribute('data-vb-copy', '');-- row.appendChild(copy);-- var prev = thread().lastElementChild;-- if (room && prev && prev.classList.contains('vb-me')) {-- [].forEach.call(document.querySelectorAll('.vb-retry'), function (f) { f.remove(); });-- var form = el('form', 'vb-retry'); form.method = 'post'; form.action = '/vibe/' + room + '/retry';-- var again = el('button', 'vb-act', 'Try again'); again.type = 'submit'; form.appendChild(again);-- row.appendChild(form);++ // Copy (ov-thread.js shows it where the clipboard works), and Try again under the newest answer to the person's++ // last message (it moves here from an older one).++ var meta = el('div', 'ovt-meta'); var acts = el('span', 'ovt-acts');++ var copy = el('button', 'ovt-act'); copy.type = 'button'; copy.setAttribute('data-ovt-copy', ''); copy.hidden = true;++ copy.appendChild(el('span', null, 'Copy'));++ acts.appendChild(copy);++ if (room && reply.closest('.ovt-turn').querySelector('.ovt-you')) {++ [].forEach.call(app.querySelectorAll('.vb-retry'), function (f) { f.remove(); });++ var again = el('form', 'vb-retry'); again.method = 'post'; again.action = '/vibe/' + room + '/retry';++ var b = el('button', 'ovt-act', 'Try again'); b.type = 'submit'; again.appendChild(b);++ acts.appendChild(again);}-- body.appendChild(row);-- enhance(li);-- return li;-- }-- // Copy buttons: the whole answer as its text, and each code block on its own. Shown only where the clipboard works.-- function copyText(btn, text) {-- if (!navigator.clipboard) return;-- navigator.clipboard.writeText(text).then(function () {-- // A code block's button draws its label in CSS (so copying the whole answer never picks it up): a class says Copied.-- var code = btn.classList.contains('vb-copy-code');-- var was = btn.textContent;-- if (!code) btn.textContent = 'Copied';-- btn.classList.add('is-done');-- setTimeout(function () { if (!code) btn.textContent = was; btn.classList.remove('is-done'); }, 1500);-- }, function () { notice('Copying did not work here. Select the text instead.'); });-- }-- function enhance(scope) {-- if (!navigator.clipboard) return;-- [].forEach.call(scope.querySelectorAll('[data-vb-copy]'), function (b) { b.hidden = false; });-- [].forEach.call(scope.querySelectorAll('.vb-ai .vb-md pre'), function (pre) {-- if (pre.querySelector('.vb-copy-code')) return;-- var b = el('button', 'vb-copy-code'); b.type = 'button'; b.setAttribute('aria-label', 'Copy this code');-- pre.appendChild(b);-- });++ meta.appendChild(acts); reply.appendChild(meta);}// "How Vibe did this" under an agent task's answer that arrives while the page is open (the server folds it on load).function attachHow(li, id) {@@@ -232,110 +223,120 @@}// The id "How Vibe did this" links to: the answer's one set of chips, never a second copy.function sourcesId(li) {-- var id = li && li.dataset ? li.dataset.id : null;++ var id = li && li.getAttribute('data-ai');return id ? 'vb-sources-' + String(id).replace(/[^a-zA-Z0-9_-]/g, '') : null;}function renderSources(li, info) {-- var body = li.querySelector('.vb-body');-- if (!info || !body || body.querySelector('.vb-sources')) return;++ var reply = li.querySelector('.ovt-reply');++ if (!info || !reply || reply.querySelector('.vb-sources')) return;var links = sourceChips(info);if (!links.length) return;var ul = el('ul', 'vb-sources'); ul.setAttribute('aria-label', 'Sources');var sid = sourcesId(li); if (sid) ul.id = sid;links.forEach(function (c) { var row = el('li'); row.appendChild(chipLink(c)); ul.appendChild(row); });-- var md = body.querySelector('.vb-md');-- if (md && md.nextSibling) body.insertBefore(ul, md.nextSibling);-- else if (md) body.appendChild(ul);-- else body.insertBefore(ul, body.firstChild);++ var ans = reply.querySelector('.ovt-answer');++ if (ans && ans.nextSibling) reply.insertBefore(ul, ans.nextSibling); else reply.appendChild(ul);}function stepsOf(raw) {var h = null; try { h = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (e) { h = null; }return h && h.steps && h.steps.length ? { lines: h.steps, sources: h.sources || [], citations: h.citations || [] } : null;}function renderHow(li, info) {-- var body = li.querySelector('.vb-body');-- if (!info || !body || body.querySelector('.vb-how')) return;-- renderSources(li, info);-- var det = el('details', 'vb-how'); det.appendChild(el('summary', null, 'How Vibe did this'));-- var ul = el('ul');-- (info.lines || []).forEach(function (l) { ul.appendChild(el('li', null, l)); });-- var links = sourceChips(info);-- if (links.length) {-- var src = el('li', 'vb-how-sources');-- var link = el('a', null, 'Sources (' + links.length + ') under this answer');-- var sid = sourcesId(li); if (sid) link.href = '#' + sid;-- src.appendChild(link);-- ul.appendChild(src);-- }-- if (info.actor_url && /^https:\/\/openvibe\.actor\//.test(info.actor_url)) { var li2 = el('li'); var a2 = el('a', null, 'Every step on OpenVibe Agents'); a2.href = info.actor_url; li2.appendChild(a2); ul.appendChild(li2); }-- det.appendChild(ul);-- var acts = body.querySelector('.vb-acts');-- if (acts) body.insertBefore(det, acts); else body.appendChild(det);++ var reply = li.querySelector('.ovt-reply');++ if (!info || !reply || reply.querySelector('.vb-how')) return;++ renderSources(li, info);++ var det = el('details', 'vb-how'); det.appendChild(el('summary', null, 'How Vibe did this'));++ var ul = el('ul');++ (info.lines || []).forEach(function (l) { ul.appendChild(el('li', null, l)); });++ var links = sourceChips(info);++ if (links.length) {++ var src = el('li', 'vb-how-sources');++ var link = el('a', null, 'Sources (' + links.length + ') under this answer');++ var sid = sourcesId(li); if (sid) link.href = '#' + sid;++ src.appendChild(link);++ ul.appendChild(src);++ }++ if (info.actor_url && /^https:\/\/openvibe\.actor\//.test(info.actor_url)) { var li2 = el('li'); var a2 = el('a', null, 'Every step on OpenVibe Agents'); a2.href = info.actor_url; li2.appendChild(a2); ul.appendChild(li2); }++ det.appendChild(ul);++ var meta = reply.querySelector('.ovt-meta');++ if (meta) reply.insertBefore(det, meta); else reply.appendChild(det);}-- function clearWait() { var w = document.getElementById('vb-wait'); if (w) w.remove(); if (waitTimer) { clearTimeout(waitTimer); waitTimer = null; } }-- // Stop: what Vibe wrote so far stays as its answer (POST /api/chat/vibe/:slug/stop); shown once the chat exists (a new-- // chat's first bubble is drawn before it does, so the first words add it).-- function addStop(li) {-- if (!room || !li || li.querySelector('.vb-stop')) return;-- var stop = el('button', 'vb-act vb-stop', 'Stop'); stop.type = 'button';-- stop.addEventListener('click', function () {-- stop.disabled = true;-- fetch('/api/chat/vibe/' + encodeURIComponent(room) + '/stop', { method: 'POST', credentials: 'same-origin' }).catch(function () { stop.disabled = false; });-- });-- li.appendChild(stop);++++ // ── Vibe writing ──++ // The turn that waits for Vibe: the newest one, open, its live line breathing (nothing spins).++ function waitingTurn() { return app.querySelector('[data-ovt-thread] > .ovt-turn[data-open]'); }++ function setStop(on) { if (stopBtn) stopBtn.hidden = !(on && room); if (sendBtn) sendBtn.hidden = !!(on && room); }++ function clearWait() {++ var w = waitingTurn();++ if (w) {++ w.removeAttribute('data-open');++ var bot = w.querySelector('.ovt-bot');++ if (bot && !bot.querySelector('[data-ovt-answer]:not(.vb-streaming)')) bot.remove();++ }++ if (waitTimer) { clearTimeout(waitTimer); waitTimer = null; }++ setStop(false);}-- function showWait() {-- if (document.getElementById('vb-wait')) return;-- var li = el('li', 'vb-turn vb-ai vb-wait'); li.id = 'vb-wait';-- li.appendChild(markSvg());-- var body = el('div', 'vb-body'); var dots = el('span', 'vb-dots'); dots.setAttribute('aria-label', 'Vibe is writing');-- dots.appendChild(el('i')); dots.appendChild(el('i')); dots.appendChild(el('i'));-- body.appendChild(dots); li.appendChild(body);-- addStop(li);-- thread().appendChild(li);-- // An answer takes up to a minute with tools; after two the dots give way to a way forward.++ function giveUpLater() {++ if (waitTimer) clearTimeout(waitTimer);++ // An answer takes up to a minute with tools; after two the live line gives way to a way forward.waitTimer = setTimeout(function () { clearWait(); notice('Vibe did not answer this time. Send your message again, or try again in a minute.'); }, 120000);}-- // The answer as it is written (room_ai_delta: the whole text so far): the waiting bubble shows it, rendered like the++ function showWait() {++ var li = waitingTurn();++ if (!li) {++ li = lastTurn();++ if (!li || li.querySelector('.ovt-bot')) { li = el('li', 'ovt-turn'); threadEl().appendChild(li); }++ li.setAttribute('data-open', '');++ var reply = botOf(li);++ var work = el('details', 'ovt-work'); work.open = true; work.setAttribute('data-open', '');++ var sum = el('summary'); var line = el('span', 'ovt-w-live', 'Vibe is writing…'); line.setAttribute('data-ovt-live', '');++ sum.appendChild(line); work.appendChild(sum);++ reply.appendChild(work);++ }++ setStop(true);++ giveUpLater();++ }++ // The answer as it is written (room_ai_delta: the whole text so far): the waiting turn shows it, rendered like the// finished answer, until that message arrives and takes its place. Screen readers hear the finished one only.function streamText(text) {showWait();-- var w = document.getElementById('vb-wait'); if (!w) return;-- addStop(w);++ var w = waitingTurn(); if (!w) return;var stick = nearBottom();-- var body = w.querySelector('.vb-body');-- var md = body.querySelector('.vb-md');-- if (!md) {-- body.textContent = '';-- md = el('div', 'vb-md vb-streaming'); md.setAttribute('aria-hidden', 'true');-- body.appendChild(md);-- var dots = el('span', 'vb-dots'); dots.setAttribute('aria-label', 'Vibe is writing');-- dots.appendChild(el('i')); dots.appendChild(el('i')); dots.appendChild(el('i'));-- body.appendChild(dots);-- }-- if (MD) md.innerHTML = MD.render(String(text || '')); else md.textContent = String(text || '');-- if (waitTimer) { clearTimeout(waitTimer); waitTimer = setTimeout(function () { clearWait(); notice('Vibe did not answer this time. Send your message again, or try again in a minute.'); }, 120000); }++ var reply = botOf(w);++ var ans = reply.querySelector('.vb-streaming');++ if (!ans) { ans = el('div', 'ovt-answer vb-streaming'); ans.setAttribute('aria-hidden', 'true'); reply.appendChild(ans); }++ if (MD) ans.innerHTML = MD.render(String(text || '')); else ans.textContent = String(text || '');++ giveUpLater();if (stick) toBottom();}function notice(text) {-- var old = scroll.querySelector('.vb-live-notice'); if (old) old.remove();++ var old = app.querySelector('.vb-live-notice'); if (old) old.remove();var p = el('p', 'oc-notice oc-error vb-live-notice', text); p.setAttribute('role', 'alert');-- scroll.appendChild(p); toBottom();++ var scroll = app.querySelector('.ovt-scroll'); (scroll || app).appendChild(p); toBottom();}function add(m) {if (!m || have(m.id)) return;latest = Math.max(latest, Number(m.id) || 0);var stick = nearBottom();if (m.ai_member_id) {-- clearWait(); var turnEl = aiTurn(m); thread().appendChild(turnEl); refreshRail();-- if (/^✅ Done:/.test(String(m.message || ''))) attachHow(turnEl, m.id);-- else if (m.how) renderHow(turnEl, stepsOf(m.how));-- }-- else {++ // The answer takes the waiting turn's place, or the newest turn that has none yet, or a turn of its own.++ var li = waitingTurn() || lastTurn();++ if (!li || (li.querySelector('.ovt-bot') && !li.hasAttribute('data-open'))) { li = el('li', 'ovt-turn'); threadEl().appendChild(li); }++ if (waitTimer) { clearTimeout(waitTimer); waitTimer = null; }++ setStop(false);++ li.removeAttribute('data-open');++ li.setAttribute('data-ai', String(m.id));++ fillAnswer(botOf(li), m);++ li.classList.add('is-fresh');++ kitEnhance(li);++ refreshRail();++ if (/^✅ Done:/.test(String(m.message || ''))) attachHow(li, m.id);++ else if (m.how) renderHow(li, stepsOf(m.how));++ } else {// The line sent from here already shows (without an id until the server answered): give it its id.-- var pending = document.querySelector('.vb-turn.vb-me:not([data-id])');-- if (pending && Number(m.user_id) === Number(cfg.me)) pending.dataset.id = String(m.id);-- else thread().appendChild(meTurn(m.message, m.id));++ var pending = app.querySelector('[data-ovt-thread] > .ovt-turn:not([data-me]) > .ovt-you');++ if (pending && Number(m.user_id) === Number(cfg.me)) pending.parentNode.setAttribute('data-me', String(m.id));++ else threadEl().appendChild(meTurn(m.message, m.id));}if (stick) toBottom();}@@@ -348,6 +349,11 @@.then(function (d) { if (d && d.messages) d.messages.forEach(add); }).catch(function () {});}++ function removeMessage(id) {++ var li = have(id); if (!li) return;++ if (li.getAttribute('data-me') === String(Number(id))) li.remove();++ else { var bot = li.querySelector('.ovt-bot'); if (bot) bot.remove(); li.removeAttribute('data-ai'); if (!li.querySelector('.ovt-you')) li.remove(); }++ }function connect() {if (!room || !window.WebSocket) return;ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');@@@ -358,62 +364,43 @@else if (m.type === 'room_message' && m.room === room) add(m.message);else if (m.type === 'room_ai_typing' && m.room === room) { showWait(); if (nearBottom()) toBottom(); }else if (m.type === 'room_ai_delta' && m.room === room) streamText(m.text);-- else if (m.type === 'room_message_deleted' && m.room === room) { var x = have(m.id); if (x) x.remove(); }++ else if (m.type === 'room_message_deleted' && m.room === room) removeMessage(m.id);else if (m.type === 'auth_revoked') notice('You were signed out. Sign in again to keep chatting.');};ws.onclose = function () { ws = null; if (room) { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); } };}-- // ── The rail ──++ // ── The chats (the network's sidebar, kit.sidebar): rebuilt from data with text nodes after each answer ──function refreshRail() {fetch('/api/chat/vibe?limit=100', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {if (!d) return;var allow = document.getElementById('vb-allow');if (allow && d.allowance && !allow.hasAttribute('data-own')) allow.textContent = Math.max(0, d.allowance.per_day - d.allowance.used_today) + ' of ' + d.allowance.per_day + ' answers left today';-- var list = document.querySelector('.vb-rail-list'); if (!list || !d.chats) return;-- // Rebuilt from data with text nodes (the server's grouping, kept simple here: Today and Earlier).++ var secs = app.querySelector('#vb-rail .ovk-sidebar-secs'); if (!secs || !d.chats) return;++ if (new URLSearchParams(location.search).get('q')) return; // search results stay until the search is leftvar start = new Date(); start.setUTCHours(0, 0, 0, 0);-- list.textContent = '';var groups = [{ label: 'Today', chats: [] }, { label: 'Earlier', chats: [] }];d.chats.forEach(function (c) { var at = Date.parse(String(c.last_message_at).indexOf('T') < 0 ? String(c.last_message_at).replace(' ', 'T') + 'Z' : c.last_message_at); (at >= start.getTime() ? groups[0] : groups[1]).chats.push(c); });++ secs.textContent = '';groups.forEach(function (g) {if (!g.chats.length) return;-- list.appendChild(el('p', 'vb-group', g.label));-- var ul = el('ul', 'vb-chats');++ var sec = el('section', 'ovk-sidebar-sec');++ sec.appendChild(el('h2', 'ovk-sidebar-h', g.label));g.chats.forEach(function (c) {-- var li = el('li'); var a = el('a', 'vb-chat', c.name); a.href = '/vibe/' + c.slug;++ var a = el('a', 'ovk-sidebar-item' + (c.slug === room ? ' is-here' : '')); a.href = '/vibe/' + c.slug;if (c.slug === room) a.setAttribute('aria-current', 'page');-- li.appendChild(a); ul.appendChild(li);++ a.appendChild(el('span', 'ovk-sidebar-t', c.name));++ sec.appendChild(a);});-- list.appendChild(ul);++ secs.appendChild(sec);});}).catch(function () {});}-- function setRail(open) {-- app.classList.toggle('is-open', open);-- var scrim = app.querySelector('.vb-scrim'); if (scrim) scrim.hidden = !open;-- var btn = app.querySelector('[data-vb-open]'); if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');-- }-- app.addEventListener('click', function (e) {-- if (e.target.closest('[data-vb-open]')) setRail(true);-- else if (e.target.closest('[data-vb-close]')) setRail(false);-- });-- document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && app.classList.contains('is-open')) setRail(false); });-- // ── Search chats: typing narrows the listed chats by name at once; Enter searches inside them too (?q=) ──-- var q = document.getElementById('vb-q');++ // The search's words come back into the box from the address (the page never repeats them).++ var q = app.querySelector('#vb-rail .ovk-sidebar-find input');var searched = null;try { searched = new URLSearchParams(location.search).get('q'); } catch (e) { searched = null; }-- if (q && searched) q.value = searched.slice(0, 80); // the page never repeats it (server/web/vibe-pages.js)-- if (q && !searched) q.addEventListener('input', function () {-- var v = q.value.trim().toLowerCase();-- var list = document.querySelector('.vb-rail-list'); if (!list) return;-- [].forEach.call(list.querySelectorAll('.vb-chats'), function (ul) {-- var shown = 0;-- [].forEach.call(ul.children, function (li) { var hit = !v || li.textContent.toLowerCase().indexOf(v) >= 0; li.hidden = !hit; if (hit) shown++; });-- ul.hidden = !shown;-- var head = ul.previousElementSibling; if (head && head.classList.contains('vb-group')) head.hidden = !shown;-- });-- });++ if (q && searched) q.value = searched.slice(0, 80);// Forms that undo something (Memory's "Forget everything") ask first.[].forEach.call(app.querySelectorAll('form[data-vb-confirm]'), function (f) { f.addEventListener('submit', function (e) { if (!window.confirm(f.getAttribute('data-vb-confirm'))) e.preventDefault(); }); });var del = app.querySelector('[data-vb-delete]');@@@ -424,8 +411,18 @@r.addEventListener('change', function () { if (r.checked && input && r.getAttribute('data-placeholder') && !form.dataset.signin) input.placeholder = r.getAttribute('data-placeholder'); input.focus(); });});++ // ── Stop: what Vibe wrote so far stays as its answer (POST /api/chat/vibe/:slug/stop) ──++ if (stopBtn) stopBtn.addEventListener('click', function (e) {++ e.preventDefault();++ if (!room) return;++ stopBtn.disabled = true;++ fetch('/api/chat/vibe/' + encodeURIComponent(room) + '/stop', { method: 'POST', credentials: 'same-origin' })++ .catch(function () {}).then(function () { stopBtn.disabled = false; });++ });++// ── Sending ──if (form) form.addEventListener('submit', function (e) {++ if (e.submitter && e.submitter === stopBtn) { e.preventDefault(); return; }var text = input.value.trim();if (!text && !picture) { e.preventDefault(); return; }if (form.dataset.signin) {@@@ -438,12 +435,12 @@if (!window.fetch || sending) return;e.preventDefault();sending = true;-- var old = scroll.querySelector('.vb-live-notice'); if (old) old.remove();++ var old = app.querySelector('.vb-live-notice'); if (old) old.remove();var sendingPicture = picture;var mine = meTurn(text, null, sendingPicture && sendingPicture.url);-- thread().appendChild(mine);++ threadEl().appendChild(mine);showWait(); toBottom();-- input.value = ''; picture = null; if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; } if (fileInput) fileInput.value = ''; input.required = true; grow();++ input.value = ''; picture = null; if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; } if (fileInput) fileInput.value = ''; input.required = true; changed();var url, init;var modeEl = form.querySelector('input[name="mode"]:checked'), mode = modeEl ? modeEl.value : 'chat';if (mode !== 'chat' && !sendingPicture) {@@@ -458,48 +455,42 @@url = room ? '/api/chat/rooms/' + encodeURIComponent(room) + '/messages' : '/api/chat/vibe';init = { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, timezone: timezone() }) };}++ function putBack(msg) { mine.remove(); clearWait(); input.value = text; if (sendingPicture) choosePicture(sendingPicture.file); changed(); notice(msg); }fetch(url, init).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: null }; }); }).then(function (x) {-- if (!x.ok) { mine.remove(); clearWait(); input.value = text; if (sendingPicture) choosePicture(sendingPicture.file); grow(); notice((x.d && x.d.error) || 'That did not send. Try again.'); return; }-- if (x.d.message && x.d.message.id) { mine.dataset.id = String(x.d.message.id); latest = Math.max(latest, Number(x.d.message.id)); }++ if (!x.ok) { putBack((x.d && x.d.error) || 'That did not send. Try again.'); return; }++ if (x.d.message && x.d.message.id) { mine.setAttribute('data-me', String(x.d.message.id)); latest = Math.max(latest, Number(x.d.message.id)); }if (!room && x.d.room) {room = x.d.room.slug;history.replaceState(null, '', '/vibe/' + room);-- if (titleEl) titleEl.textContent = x.d.room.name;++ var titleEl = app.querySelector('.ovt-title'); if (titleEl) titleEl.textContent = x.d.room.name;document.title = x.d.room.name + ' · Vibe · OpenVibe.Chat';form.action = '/vibe/' + room;++ setStop(true);refreshRail();connect();}})-- .catch(function () { mine.remove(); clearWait(); input.value = text; if (sendingPicture) choosePicture(sendingPicture.file); grow(); notice('You seem to be offline. Your message is back in the box.'); })++ .catch(function () { putBack('You seem to be offline. Your message is back in the box.'); }).then(function () { sending = false; });});-- enhance(app);-- app.addEventListener('click', function (e) {-- var whole = e.target.closest('[data-vb-copy]');-- if (whole) { var md = whole.closest('.vb-body').querySelector('.vb-md'); if (md) copyText(whole, md.innerText.trim()); return; }-- var code = e.target.closest('.vb-copy-code');-- if (code) { var c = code.parentNode.querySelector('code'); if (c) copyText(code, c.textContent); }-- });// Try again: Vibe's last answer goes and a new one is written (POST /api/chat/vibe/:slug/retry).app.addEventListener('submit', function (e) {var f = e.target.closest('.vb-retry'); if (!f || !room) return;e.preventDefault();-- var li = f.closest('.vb-turn');++ var li = f.closest('.ovt-turn');fetch('/api/chat/vibe/' + encodeURIComponent(room) + '/retry', { method: 'POST', credentials: 'same-origin' }).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: null }; }); }).then(function (x) {if (!x.ok) { notice((x.d && x.d.error) || 'That did not work. Try again in a minute.'); return; }-- if (li) li.remove();++ if (li) { var bot = li.querySelector('.ovt-bot'); if (bot) bot.remove(); li.removeAttribute('data-ai'); }showWait(); toBottom();}).catch(function () { notice('You seem to be offline.'); });});if (cfg.waiting) showWait();connect();-- if (!cfg.memory) toBottom(); // the Memory page reads from the topwindow.addEventListener('pagehide', function () { var w = ws; room = null; if (w) w.close(); });})();@@@ -102,7 +102,8 @@ function contentSecurityPolicy(nonce) {* prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges), counts (unread* rooms/messages for the navigation), script / callScript / ringScript (public/web/chat.js, call.js, ring.js: a call* coming in, on every page of a signed-in person while calls are on), app (Vibe's full-window app, server/web/vibe-pages.js:-- * vibe.css + vibe-md.js + vibe.js, and no footer), appScript (the app's own script instead of vibe.js: inbox.js), feedView++ * vibe.css + vibe-md.js + vibe.js, and no footer), kit (the network's conversation UI, openvibe-shared/thread: kit.css,++ * thread.css, kit-web.js and ov-thread.js; Vibe's pages), appScript (the app's own script instead of vibe.js: inbox.js), feedView* (a feed's card on the page: feed-view.css + feed-view.js, server/feeds/), scripts (more of* public/web's scripts by name, e.g. support.js), footer ('full', the default, on front pages, directories and docs;* 'compact', one quiet line, on pages people work in: a room, a channel, a conversation, a task, settings),@@@ -143,6 +144,7 @@ ${INSTALL_LINKS}<link rel="stylesheet" href="${ovServe.url('items.css')}">${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}++${o.kit ? `<link rel="stylesheet" href="${ovServe.url('kit.css')}">\n<link rel="stylesheet" href="${ovServe.url('thread.css')}">` : ''}${o.app ? `<link rel="stylesheet" href="${asset('vibe.css')}">` : ''}${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}@@@ -152,6 +154,7 @@ ${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}${o.ringScript ? `<script src="${asset('ring.js')}" defer></script>` : ''}++${o.kit ? `<script src="${ovServe.url('kit-web.js')}" defer></script>\n<script src="${ovServe.url('ov-thread.js')}" defer></script>` : ''}${o.app ? `<script src="${asset('vibe-md.js')}" defer></script>\n<script src="${asset(o.appScript || 'vibe.js')}" defer></script>` : ''}</head><body class="${[cls, o.app ? 'oc-app-body' : ''].filter(Boolean).join(' ')}"${scale}>@@@ -23,6 +23,8 @@ const vibe = require('../rooms/vibe');const how = require('../rooms/how');const memory = require('../rooms/vibe-memory');const { esc } = require('./layout');++const kit = require('openvibe-shared/kit');++const thread = require('openvibe-shared/thread');const SUGGESTIONS = [{ title: 'Catch me up', text: 'on today\'s tech news', prompt: 'What happened in tech news today? Give me the highlights with links.' },@@@ -79,48 +81,52 @@ function snippet(text, q) {const MARK = '<span class="vb-mark" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 5l7 14 7-14"/></svg></span>';function rail({ chats, current, signedIn, allowance, query = '', here = null }) {-- const groups = groupChats(chats);++ // The network's one sidebar (openvibe-shared kit.sidebar, plan L35; the conversation kit's, L55 C4): New chat, the++ // search, Vibe's places as rows, then the chats by when they last moved. A drawer on a phone (kit-web.js).// The search's own words are never written back into the page (a link could carry anyone's words): the box gets-- // them from the address in the browser, and the results say only what matched in the person's own chats.-- const found = query-- ? (chats.length-- ? `<p class="vb-group">Chats that match</p><ul class="vb-chats">${chats.map((c) => `<li><a class="vb-chat vb-hit" href="/vibe/${esc(c.slug)}"${c.slug === current ? ' aria-current="page"' : ''}><span>${esc(c.name)}</span>${c.hit ? `<small>${snippet(c.hit, query)}</small>` : ''}</a></li>`).join('')}</ul>`-- : '<p class="vb-rail-note">No chat matches your search.</p>') + '<p class="vb-rail-note"><a href="/vibe">All your chats</a></p>'-- : null;-- const list = !signedIn-- ? '<p class="vb-rail-note">Sign in to keep your chats. They are private to you.</p>'-- : found || (groups.length-- ? groups.map((g) => `<p class="vb-group">${esc(g.label)}</p><ul class="vb-chats">${g.chats.map((c) => `<li><a class="vb-chat" href="/vibe/${esc(c.slug)}"${c.slug === current ? ' aria-current="page"' : ''}>${esc(c.name)}</a></li>`).join('')}</ul>`).join('')-- : '<p class="vb-rail-note">Your chats with Vibe show up here.</p>');++ // them from the address in the browser, and the results say only which of the person's own chats matched.++ const item = (c) => ({ href: `/vibe/${c.slug}`, label: c.name, here: c.slug === current });++ let sections;++ if (!signedIn) sections = [{ title: '', items: [], empty: 'Sign in to keep your chats. They are private to you.' }];++ else if (query) {++ sections = chats.length ? [{ title: 'Chats that match', items: chats.map(item) }] : [{ title: '', items: [], empty: 'No chat matches your search.' }];++ sections.push({ title: '', items: [{ href: '/vibe', label: 'All your chats' }] });++ } else {++ const groups = groupChats(chats);++ sections = groups.length ? groups.map((g) => ({ title: g.label, items: g.chats.map(item) })) : [{ title: '', items: [], empty: 'Your chats with Vibe show up here.' }];++ }const left = allowance ? Math.max(0, allowance.per_day - allowance.used_today) : null;const own = allowance && allowance.engine && allowance.engine.own ? allowance.engine : null; // Vibe on their own model-- return `<aside class="vb-rail" id="vb-rail" aria-label="Your chats with Vibe">--<div class="vb-rail-top">--<a class="vb-new" href="/vibe">${ICON.new}<span>New chat</span></a>--<button class="vb-icon vb-rail-close" type="button" data-vb-close aria-label="Close the chat list">${ICON.close}</button>--</div>--${signedIn ? `<form class="vb-search" action="/vibe" method="get" role="search">${ICON.search}<label class="oc-sr" for="vb-q">Search your chats</label><input id="vb-q" name="q" type="search" maxlength="${vibe.SEARCH_MAX}" placeholder="Search chats" autocomplete="off" enterkeyhint="search"></form>` : ''}--<nav class="vb-rail-list" aria-label="Chats with Vibe">${list}</nav>--<nav class="vb-rail-more" aria-label="More of OpenVibe.Chat">--<a href="https://openvibe.actor/tasks" title="Every task you gave an agent, one-off or recurring, with what it cost">${ICON.tasks}<span>Tasks</span></a>--<a href="https://openvibe.actor/me/agents" title="Recurring work in plain words: an agent with standing instructions, a memory you can edit, a schedule and quiet hours">${ICON.agents}<span>Your agents</span></a>--${signedIn ? `<a href="/vibe/memory" title="What Vibe remembers about you across your chats: see, add or delete each note"${here === 'memory' ? ' aria-current="page"' : ''}>${ICON.memory}<span>Memory</span></a>` : ''}--<a href="https://openvibe.bot/machines" title="Your computers and robots: pair one, see its screen, set what agents may do on it">${ICON.computer}<span>Computers & robots</span></a>--<a href="/work" title="A room where your team and Vibe work together: a work queue, alerts and a shared screen">${ICON.team}<span>Work together</span></a>--<a href="/messages">${ICON.messages}<span>Messages</span></a>--<a href="/rooms">${ICON.rooms}<span>Rooms and servers</span></a>--<a href="/">${ICON.globe}<span>Global chat</span></a>--</nav>--${own ? `<p class="vb-allow" id="vb-allow" data-own="1">No daily limit: Vibe thinks with ${esc(own.you)}</p>` : left != null ? `<p class="vb-allow" id="vb-allow">${left} of ${allowance.per_day} answers left today</p>` : ''}--</aside>`;++ const allow = own ? `<p class="vb-allow" id="vb-allow" data-own="1">No daily limit: Vibe thinks with ${esc(own.you)}</p>`++ : left != null ? `<p class="vb-allow" id="vb-allow">${left} of ${allowance.per_day} answers left today</p>` : '';++ // Chat's other places, quietly at the foot (the network's navbar reaches them too).++ const more = [['https://openvibe.bot/machines', 'Computers & robots'], ['/work', 'Work together'], ['/messages', 'Messages'], ['/rooms', 'Rooms and servers'], ['/', 'Global chat']];++ const html = kit.sidebar({++ id: 'vb-rail', label: 'Your chats with Vibe',++ action: { href: '/vibe', label: 'New chat', icon: 'pencil' },++ find: signedIn ? { action: '/vibe', name: 'q', placeholder: 'Search chats', label: 'Search your chats' } : null,++ nav: [++ { href: 'https://openvibe.actor/tasks', label: 'Tasks', icon: 'list' },++ { href: 'https://openvibe.actor/me/agents', label: 'Your agents', icon: 'robot' },++ ...(signedIn ? [{ href: '/vibe/memory', label: 'Memory', icon: 'book', here: here === 'memory' }] : []),++ ],++ sections,++ foot: `<p class="ovt-side-foot">${more.map(([href, label]) => `<a href="${esc(href)}">${esc(label)}</a>`).join('')}</p>${allow}`,++ });++ if (!query) return html;++ // A search: under each chat that matched, where it matched (the person's own words or Vibe's), the match marked.++ return chats.reduce((out, c) => (c.hit ? out.replace(++ new RegExp(`<a class="ovk-sidebar-item( is-here)?" href="/vibe/${c.slug}"([^>]*)><span class="ovk-sidebar-t">([^<]*)</span>`),++ (m, here, rest, name) => `<a class="ovk-sidebar-item vb-hit${here || ''}" href="/vibe/${c.slug}"${rest}><span class="ovk-sidebar-t">${name}</span><small class="vb-hit-s">${snippet(c.hit, query)}</small>`,++ ) : out), html);}/**-- * Under each answer: Copy (shown by vibe.js; it needs the clipboard) and, under the answer to the person's last message,-- * Try again (a form, so it works without JavaScript too).++ * Under each answer: Copy (shown by ov-thread.js where the clipboard works) and, under the answer to the person's last++ * message, Try again (a form, so it works without JavaScript too).*/function acts(slug) {-- return `<div class="vb-acts"><button type="button" class="vb-act" data-vb-copy hidden>Copy</button>${slug ? `<form method="post" action="/vibe/${esc(slug)}/retry" class="vb-retry"><button type="submit" class="vb-act">Try again</button></form>` : ''}</div>`;++ return `<div class="ovt-meta"><span class="ovt-acts"><button type="button" class="ovt-act" data-ovt-copy hidden>${thread.icon('copy')}<span>Copy</span></button>${slug ? `<form method="post" action="/vibe/${esc(slug)}/retry" class="vb-retry"><button type="submit" class="ovt-act">Try again</button></form>` : ''}</span></div>`;}const RES_ICON = {@@@ -146,14 +152,36 @@ function resultChips(m, slug) {}).join('')}</div>`;}--function turn(m, me, hows = new Map(), retrySlug = null, slug = null) {-- const ai = !!m.ai_member_id;-- if (ai) {-- const info = hows.get(Number(m.id));-- return `<li class="vb-turn vb-ai" data-id="${Number(m.id) || 0}">${MARK}<div class="vb-body"><p class="oc-sr">Vibe said:</p><div class="vb-md">${md.render(m.message)}</div>${resultChips(m, slug)}${how.chips(info, m.id)}${how.html(info, m.id)}${acts(retrySlug)}</div></li>`;-- }++/** What the person said, for their bubble: their words (the bubble keeps line breaks) and any picture. */++function youOf(m, me) {const mine = me && Number(m.user_id) === Number(me.id);-- return `<li class="vb-turn vb-me" data-id="${Number(m.id) || 0}"><div class="vb-bubble"><p class="oc-sr">${mine ? 'You said:' : `${esc(m.display_name || m.username)} said:`}</p>${esc(m.message).replace(/\n/g, '<br>')}${pictures.pageHtml(m.message)}</div></li>`;++ return { html: `<span class="oc-sr">${mine ? 'You said:' : `${esc(m.display_name || m.username)} said:`}</span>${esc(m.message)}${pictures.pageHtml(m.message)}` };++}++/** Vibe's answer: the rendered Markdown, what it did (reminders, timers), its sources and how, then Copy and Try again. */++function answerOf(m, hows, retrySlug, slug) {++ const info = hows.get(Number(m.id));++ return `<p class="oc-sr">Vibe said:</p>${thread.answer({ html: md.render(m.message), copy: false })}${resultChips(m, slug)}${how.chips(info, m.id)}${how.html(info, m.id)}${acts(retrySlug)}`;++}++/** Vibe writing: the live line, open (ov-thread.js and vibe.js follow it). */++const WRITING = thread.work({ open: true, live: 'Vibe is writing' });++/**++ * The chat as turns: each message of the person's with the answer that follows it. data-me / data-ai carry the two++ * message ids, so a message that arrives over the socket is never drawn twice.++ */++function turnsOf(messages, me, hows, { retrySlug = null, slug = null, waiting = false } = {}) {++ const specs = [];++ for (const [i, m] of messages.entries()) {++ const last = i === messages.length - 1;++ if (m.ai_member_id) {++ const reply = answerOf(m, hows, last ? retrySlug : null, slug);++ const open = specs[specs.length - 1];++ if (open && open.reply == null) { open.reply = reply; open.ai = m.id; }++ else specs.push({ reply, ai: m.id });++ } else specs.push({ you: youOf(m, me), me: m.id, reply: null });++ }++ const tail = specs[specs.length - 1];++ if (waiting && tail && tail.reply == null) { tail.reply = WRITING; tail.open = true; }++ return specs.map((t) => thread.turn({ you: t.you || {}, reply: t.reply, open: !!t.open, attrs: { 'data-me': t.me, 'data-ai': t.ai } }));}// The composer's modes (plan track K): Chat answers here and now; the others are agent tasks that answer here when done.@@@ -174,18 +202,23 @@ function composer({ slug, signedIn, enabled, prefill = '', engine = null }) {const pics = pictures.enabled();const label = pics ? 'Add a picture or a text file' : 'Add a text file';const attach = signedIn && enabled-- ? `<input type="file" id="vb-file" accept="${pics ? 'image/jpeg,image/png,image/gif,image/webp,image/avif,' : ''}${TEXT_FILES}" hidden><button class="vb-icon vb-attach" type="button" data-vb-attach aria-label="${label}" title="${label}">${ICON.attach}</button>`++ ? `<input type="file" id="vb-file" accept="${pics ? 'image/jpeg,image/png,image/gif,image/webp,image/avif,' : ''}${TEXT_FILES}" hidden><button class="ovt-btn vb-attach" type="button" data-vb-attach aria-label="${label}" title="${label}">${ICON.attach}</button>`: '';-- return `<div class="vb-preview" id="vb-preview" hidden></div>--<form class="vb-composer" id="vb-composer" method="${signedIn ? 'post' : 'get'}" action="${action}"${signedIn ? '' : ' data-signin="1"'}>--${signedIn ? '' : `<input type="hidden" name="next" value="${slug ? `/vibe/${esc(slug)}` : (prefill ? `/vibe?ask=${encodeURIComponent(prefill)}` : '/vibe')}">`}--${attach}--<label class="oc-sr" for="vb-input">Message Vibe</label>--<textarea id="vb-input" name="message" rows="${prefill ? 3 : 1}" maxlength="12000" placeholder="${signedIn ? 'Message Vibe' : 'Message Vibe (you will sign in first)'}"${enabled ? '' : ' disabled'}${prefill ? ' autofocus' : ''} required>${esc(prefill)}</textarea>--<button class="vb-send" type="submit" aria-label="Send"${enabled ? '' : ' disabled'}>${ICON.send}</button>--<fieldset class="vb-modes" aria-label="What Vibe does with it">${MODES.map(([value, label, hint, placeholder], i) => `<label title="${esc(hint)}"><input type="radio" name="mode" value="${value}" data-placeholder="${esc(placeholder)}"${i ? '' : ' checked'}><span>${label}</span></label>`).join('')}</fieldset>--</form>--<p class="vb-fine">${!enabled ? 'Vibe is resting right now. Try again later.' : engine && engine.own ? `Vibe thinks with ${esc(engine.you)} (<a href="/settings#oc-ai-key">change</a>). It can get things wrong: check what matters.` : 'Vibe can get things wrong. Check what matters.'}</p>`;++ const modes = `<fieldset class="vb-modes" aria-label="What Vibe does with it">${MODES.map(([value, label, hint, placeholder], i) => `<label title="${esc(hint)}"><input type="radio" name="mode" value="${value}" data-placeholder="${esc(placeholder)}"${i ? '' : ' checked'}><span>${label}</span></label>`).join('')}</fieldset>`;++ const fine = !enabled ? 'Vibe is resting right now. Try again later.' : engine && engine.own ? `Vibe thinks with ${esc(engine.you)} (<a href="/settings#oc-ai-key">change</a>). It can get things wrong: check what matters.` : 'Vibe can get things wrong. Check what matters.';++ let html = thread.composer({++ action, method: signedIn ? 'post' : 'get', name: 'message', id: 'vb-composer', attrs: signedIn ? {} : { 'data-signin': '1' },++ label: 'Message Vibe', placeholder: signedIn ? 'Message Vibe' : 'Message Vibe (you will sign in first)', value: prefill, maxlength: 12000,++ hidden: signedIn ? {} : { next: slug ? `/vibe/${slug}` : (prefill ? `/vibe?ask=${encodeURIComponent(prefill)}` : '/vibe') },++ top: '<div class="vb-preview" id="vb-preview" hidden></div>', tools: attach + modes,++ // Stop is drawn hidden: vibe.js shows it while Vibe writes, and it keeps what was written so far.++ stopAction: signedIn ? '' : null,++ foot: `<p class="vb-fine">${fine}</p>`,++ });++ // A box filled from "Ask Vibe" is where the cursor goes, script or not.++ if (prefill) html = html.replace('data-ovt-text', 'data-ovt-text autofocus');++ if (!enabled) html = html.replace('data-ovt-text', 'data-ovt-text disabled').replace('data-ovt-send aria-label', 'data-ovt-send disabled aria-label');++ return html;}function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit, notice }) {@@@ -232,31 +265,25 @@ ${MARK}<p class="vb-sub">OpenVibe's assistant. I search the web and all of OpenVibe (news, deals, jobs, rentals, videos), read the pages and pictures you share, remember what you ask me to, and set up work that runs every day for you.</p><ul class="vb-suggest">${SUGGESTIONS.map((s) => `<li><button type="button" class="vb-chip" data-prompt="${esc(s.prompt)}"><b>${esc(s.title)}</b><span>${esc(s.text)}</span></button></li>`).join('')}</ul></div>`;-- const thread = room-- ? `<ol class="vb-thread" id="vb-thread">${messages.map((m, i) => turn(m, user, hows, i === messages.length - 1 && retryable ? room.slug : null, room.slug)).join('')}${waiting ? `<li class="vb-turn vb-ai vb-wait" id="vb-wait">${MARK}<div class="vb-body"><span class="vb-dots" aria-label="Vibe is writing"><i></i><i></i><i></i></span><noscript><a href="/vibe/${esc(room.slug)}">Refresh to see Vibe's answer</a></noscript></div></li>` : ''}</ol>`-- : '';-- const head = `<header class="vb-head">--<button class="vb-icon vb-menu" type="button" data-vb-open aria-controls="vb-rail" aria-expanded="false" aria-label="Show your chats">${ICON.menu}</button>--${room ? `<h1 class="vb-title" id="vb-title">${esc(title)}</h1>` : `<p class="vb-title" id="vb-title">${esc(title)}</p>`}--${room ? `<details class="vb-actions"><summary class="vb-icon" aria-label="Chat options">${ICON.more}</summary><div class="vb-pop">++ const head = thread.head({++ title: room ? title : '', sidebar: 'vb-rail', sidebarLabel: 'Show your chats',++ actions: room ? `<details class="vb-actions"><summary class="ovt-btn" aria-label="Chat options">${ICON.more}</summary><div class="vb-pop"><form method="post" action="/vibe/${esc(room.slug)}/rename" class="vb-rename"><label for="vb-name">Name</label><input id="vb-name" name="name" maxlength="${vibe.TITLE_MAX}" value="${esc(room.name)}" required><button type="submit">Rename</button></form><form method="post" action="/vibe/${esc(room.slug)}/delete" data-vb-delete><button type="submit" class="vb-danger">Delete this chat</button></form>--</div></details>` : `<a class="vb-icon vb-head-new" href="/vibe" aria-label="New chat">${ICON.new}</a>`}--</header>`;-- const body = `<div class="vb${room ? '' : ' vb-empty'}" id="vb">--${rail({ chats, current: room && room.slug, signedIn: !!user, allowance, query: searching ? query : '' })}--<div class="vb-scrim" data-vb-close hidden></div>--<section class="vb-main" aria-label="${room ? 'Your chat with Vibe' : 'A new chat with Vibe'}">--${head}--<div class="vb-scroll" id="vb-scroll">--${error ? notice('error', error) : ''}--${room ? thread : hello}--</div>--<div class="vb-dock">${composer({ slug: room && room.slug, signedIn: !!user, enabled, prefill: room ? '' : prefillOf(req.query || {}), engine: allowance && allowance.engine })}</div>--</section>--</div>`;++</div></details>` : '',++ });++ const turns = room ? turnsOf(messages, user, hows, { retrySlug: retryable ? room.slug : null, slug: room.slug, waiting: !!waiting }) : [];++ const problem = error ? notice('error', error) : '';++ const body = `<div class="vbk" id="vb">${thread.page({++ sidebar: rail({ chats, current: room && room.slug, signedIn: !!user, allowance, query: searching ? query : '' }),++ head,++ turns: problem && room ? [thread.turn({ avatar: false, reply: problem }), ...turns] : turns,++ empty: problem + hello,++ composer: composer({ slug: room && room.slug, signedIn: !!user, enabled, prefill: room ? '' : prefillOf(req.query || {}), engine: allowance && allowance.engine }),++ center: !room, label: room ? 'Your chat with Vibe' : 'A new chat with Vibe',++ })}</div>`;await page(req, res, status, {-- actor, app: true, active: 'vibe', path: room ? `/vibe/${room.slug}` : '/vibe',++ actor, app: true, kit: true, active: 'vibe', path: room ? `/vibe/${room.slug}` : '/vibe',title: room ? `${room.name} · Vibe` : 'Vibe, OpenVibe\'s assistant',fullTitle: room ? undefined : 'Vibe: chat with OpenVibe\'s assistant · OpenVibe.Chat',description: 'Chat with Vibe, OpenVibe\'s free assistant: it searches news, deals, jobs, rentals and videos across OpenVibe, reads pages you link and helps you think.',@@@ -276,18 +303,11 @@ ${room ? thread : hello}async function renderMemory(req, res, actor, { status = 200, error = null, draft = '' } = {}) {const user = actor.user;const [notes, on, chats, allowance] = await Promise.all([memory.list(user), memory.isOn(user), vibe.list(user), allowanceOf(user)]);-- const body = `<div class="vb" id="vb">--${rail({ chats, current: null, signedIn: true, allowance, here: 'memory' })}--<div class="vb-scrim" data-vb-close hidden></div>--<section class="vb-main" aria-label="What Vibe remembers about you">--<header class="vb-head">--<button class="vb-icon vb-menu" type="button" data-vb-open aria-controls="vb-rail" aria-expanded="false" aria-label="Show your chats">${ICON.menu}</button>--<p class="vb-title" id="vb-title">Memory</p>--<a class="vb-icon vb-head-new" href="/vibe" aria-label="New chat">${ICON.new}</a>--</header>--<div class="vb-scroll" id="vb-scroll">--${error ? notice('error', error) : ''}--<div class="vb-page">++ const body = `<div class="vbk" id="vb">${thread.page({++ sidebar: rail({ chats, current: null, signedIn: true, allowance, here: 'memory' }),++ head: thread.head({ title: '', sidebar: 'vb-rail', sidebarLabel: 'Show your chats', newHref: '/vibe' }),++ label: 'What Vibe remembers about you', cls: 'is-page',++ main: `${error ? notice('error', error) : ''}<div class="vb-page"><h1>What Vibe remembers about you</h1><p class="vb-sub">Short notes Vibe keeps across your chats with it: what you asked it to remember, or a preference you told it. Only your own chats with Vibe read them, never a room it was added to. It never keeps passwords, keys or card numbers.</p><form class="vb-mem-switch" method="post" action="/vibe/memory/switch">@@@ -304,12 +324,10 @@ ${notes.length ? `<h2 class="vb-group">${notes.length} of ${memory.MAX_NOTES} no<form method="post" action="/vibe/memory/${n.id}/delete"><button type="submit" class="vb-btn vb-btn-quiet" aria-label="Delete the note: ${esc(n.text.slice(0, 60))}">Delete</button></form></li>`).join('')}</ul><form method="post" action="/vibe/memory/clear" data-vb-confirm="Delete every note? Vibe forgets them all."><button type="submit" class="vb-btn vb-danger">Forget everything</button></form>`: '<p class="vb-rail-note">Nothing yet. Tell Vibe “remember that I…” in a chat, or add a note above.</p>'}--</div>--</div>--</section>--</div>`;++</div>`,++ })}</div>`;await page(req, res, status, {-- actor, app: true, active: 'vibe', path: '/vibe/memory', title: 'Memory · Vibe', robots: 'noindex, nofollow', body,++ actor, app: true, kit: true, active: 'vibe', path: '/vibe/memory', title: 'Memory · Vibe', robots: 'noindex, nofollow', body,page: { view: 'vibe', memory: true, room: null, me: user.id, latest: 0, waiting: false, enabled: vibe.enabled(), signedIn: true },});}@@@ -25,7 +25,7 @@ t('the front page leads to Vibe: the first action, an Ask Vibe box, the questiont('the box opens Vibe with the question in it, signed out too', async () => {const vibe = await fetch(`${h.base}/vibe?ask=${encodeURIComponent('Plan a picnic for six')}`).then((r) => r.text());-- assert.match(vibe, /<textarea id="vb-input"[^>]*>Plan a picnic for six<\/textarea>/);++ assert.match(vibe, /<textarea id="ovt-text"[^>]*>Plan a picnic for six<\/textarea>/);});t.run(async () => { if (h && h.close) await h.close(); });@@@ -48,7 +48,7 @@ t('everywhere else the limits stay: 2000 characters, spam refused', async () =>t('the composer takes 12000 characters and the attach button takes text files', async () => {const page = await fetch(`${h.base}/vibe`, { headers: { cookie: `ov_token=${ann.token}` } }).then((x) => x.text());-- assert.match(page, /<textarea id="vb-input" name="message" rows="1" maxlength="12000"/);++ assert.match(page, /<textarea id="ovt-text" name="message" rows="1"[^>]*maxlength="12000"/);assert.match(page, /<input type="file" id="vb-file" accept="[^"]*text\/plain[^"]*\.csv[^"]*\.py[^"]*" hidden>/);assert.match(page, /aria-label="Add (a picture or )?a text file"/);});@@@ -166,7 +166,7 @@ t('the page: the notes with Delete, add, switch, forget everything; signed out aassert.match(page.text, /<h1>What Vibe remembers about you<\/h1>/);assert.match(page.text, /<b>Memory is on\.<\/b>/);assert.match(page.text, /<li><p>Uses kilometres<\/p><small>Added by you · /);-- assert.match(page.text, /<a href="\/vibe\/memory" title="[^"]+" aria-current="page">/, 'the rail marks Memory');++ assert.match(page.text, /<a class="ovk-sidebar-link is-here" href="\/vibe\/memory" aria-current="page">/, 'the rail marks Memory');assert.match(page.text, /noindex, nofollow/);const added = await web('POST', '/vibe/memory', ann, { text: 'Prefers short answers' });assert.deepStrictEqual([added.status, added.location], [303, '/vibe/memory']);@@@ -185,7 +185,7 @@ t('the page: the notes with Delete, add, switch, forget everything; signed out aawait web('POST', '/vibe/memory/clear', ann, {});assert.deepStrictEqual(await memory.list(userOf(ann)), []);assert.match((await web('GET', '/vibe/memory', ann)).text, /Nothing yet/);-- assert.match((await web('GET', '/vibe', ann)).text, /<a href="\/vibe\/memory" title="What Vibe remembers about you/, 'the rail links it from Vibe');++ assert.match((await web('GET', '/vibe', ann)).text, /<a class="ovk-sidebar-link" href="\/vibe\/memory">/, 'the rail links it from Vibe');assert.ok(!(await web('GET', '/vibe', null)).text.includes('href="/vibe/memory"'), 'signed out: no Memory link');});@@@ -74,8 +74,8 @@ t('the page offers Try again under the last answer only, and Copy (shown by theawait answers(s2, 2);const page = await web('GET', `/vibe/${s2}`, ann);assert.strictEqual((page.text.match(/class="vb-retry"/g) || []).length, 1, 'one Try again');-- assert.match(page.text, /<button type="button" class="vb-act" data-vb-copy hidden>Copy<\/button><form method="post" action="\/vibe\/v-[0-9a-f]{12}\/retry" class="vb-retry"><button type="submit" class="vb-act">Try again<\/button><\/form><\/div><\/div><\/li><\/ol>/, 'under the last answer');-- assert.strictEqual((page.text.match(/data-vb-copy hidden/g) || []).length, 2, 'Copy under each answer');++ assert.match(page.text, /data-ovt-copy hidden>[\s\S]{0,400}?<span>Copy<\/span><\/button><form method="post" action="\/vibe\/v-[0-9a-f]{12}\/retry" class="vb-retry"><button type="submit" class="ovt-act">Try again<\/button><\/form><\/span><\/div><\/div><\/div>\s*<\/li><\/ol>/, 'under the last answer');++ assert.strictEqual((page.text.match(/data-ovt-copy hidden/g) || []).length, 2, 'Copy under each answer');const form = await web('POST', `/vibe/${s2}/retry`, ann, {});assert.deepStrictEqual([form.status, form.location], [303, `/vibe/${s2}`]);});@@@ -148,12 +148,12 @@ t('the app page: signed out it invites a sign-in, signed in it lists the chats aconst signed = await web('GET', '/vibe', ann);assert.ok(signed.text.includes('What can I help with, Ann?'));assert.strictEqual((signed.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1 on a new chat: the greeting (the header names it in a <p>)');-- assert.ok(signed.text.includes('class="vb-chat" href="/vibe/v-'), 'the rail lists their chats');++ assert.ok(signed.text.includes('class="ovk-sidebar-item" href="/vibe/v-'), 'the rail lists their chats (the network\'s one sidebar, kit.sidebar)');// The rail leads to the rest of the person's agent: their tasks, their computers and robots, team workspaces.assert.ok(/href="https:\/\/openvibe\.actor\/tasks"[^>]*>[\s\S]*?<span>Tasks<\/span>/.test(signed.text), 'Tasks');assert.ok(/href="https:\/\/openvibe\.actor\/me\/agents"[^>]*>[\s\S]*?<span>Your agents<\/span>/.test(signed.text), 'Your agents: recurring work');-- assert.ok(/href="https:\/\/openvibe\.bot\/machines"[^>]*>[\s\S]*?<span>Computers & robots<\/span>/.test(signed.text), 'Computers & robots');-- assert.ok(/href="\/work"[^>]*>[\s\S]*?<span>Work together<\/span>/.test(signed.text), 'Work together');++ assert.ok(signed.text.includes('<a href="https://openvibe.bot/machines">Computers & robots</a>'), 'Computers & robots, at the foot');++ assert.ok(signed.text.includes('<a href="/work">Work together</a>'), 'Work together, at the foot');const form = await web('POST', '/vibe', ann, { message: 'hello from a form' });assert.strictEqual(form.status, 303);assert.ok(/^\/vibe\/v-[0-9a-f]{12}$/.test(form.location), form.location);@@@ -166,9 +166,9 @@ t('the app page: signed out it invites a sign-in, signed in it lists the chats at('Ask Vibe from any OpenVibe site: the selection and the page fill a new chat\'s box, never send it', async () => {let r = await web('GET', `/vibe?ask=${encodeURIComponent('What is <b>WHIP</b>?')}&from=${encodeURIComponent('https://openvibe.live/docs/whip#top')}`, ann);assert.strictEqual(r.status, 200);-- assert.match(r.text, /<textarea id="vb-input"[^>]*autofocus[^>]*>About this, on https:\/\/openvibe\.live\/docs\/whip:\n\nWhat is <b>WHIP<\/b>\?<\/textarea>/);++ assert.match(r.text, /<textarea id="ovt-text"[^>]*autofocus[^>]*>About this, on https:\/\/openvibe\.live\/docs\/whip:\n\nWhat is <b>WHIP<\/b>\?<\/textarea>/);r = await web('GET', `/vibe?from=${encodeURIComponent('https://evil.test/x')}`, ann);-- assert.match(r.text, /<textarea id="vb-input"[^>]*><\/textarea>/, 'only an OpenVibe site\'s address is taken');++ assert.match(r.text, /<textarea id="ovt-text"[^>]*><\/textarea>/, 'only an OpenVibe site\'s address is taken');r = await web('GET', `/vibe?ask=${encodeURIComponent('hello')}`);assert.match(r.text, /name="next" value="\/vibe\?ask=hello"/, 'signed out, the question survives the sign-in');});@@@ -178,12 +178,12 @@ t('search chats: the rail\'s box finds a chat by its name or by any message in iassert.strictEqual(made.status, 201, made.text);await answers(made.body.room.slug, 1); // Vibe's answer lists rice and lentilsconst signed = await web('GET', '/vibe', ann);-- assert.match(signed.text, /<form class="vb-search" action="\/vibe" method="get" role="search">/);-- assert.ok(!(await web('GET', '/vibe')).text.includes('vb-search'), 'signed out, there is nothing to search');++ assert.match(signed.text, /<form class="ovk-sidebar-find" role="search" method="get" action="\/vibe">/);++ assert.ok(!(await web('GET', '/vibe')).text.includes('ovk-sidebar-find'), 'signed out, there is nothing to search');let r = await web('GET', '/vibe?q=BICYCLE', ann);-- assert.ok(r.text.includes('<p class="vb-group">Chats that match</p>'));++ assert.ok(r.text.includes('<h2 class="ovk-sidebar-h">Chats that match</h2>'));assert.ok(!r.text.includes('BICYCLE'), 'the search\'s own words are never written back into the page');-- assert.match(r.text, new RegExp(`class="vb-chat vb-hit" href="/vibe/${made.body.room.slug}"><span>Find me a cheap blue bicycle near the…</span><small>Find me a cheap blue <mark>bicycle</mark> near the river</small>`));++ assert.match(r.text, new RegExp(`class="ovk-sidebar-item vb-hit" href="/vibe/${made.body.room.slug}"><span class="ovk-sidebar-t">Find me a cheap blue bicycle near the…</span><small class="vb-hit-s">Find me a cheap blue <mark>bicycle</mark> near the river</small>`));r = await web('GET', '/vibe?q=lentils', ann);assert.ok(r.text.includes('<mark>lentils</mark>'), 'a match in Vibe\'s answer counts');assert.ok((await web('GET', '/vibe?q=100%25', ann)).text.includes('No chat matches your search.'), '% is a character, not a wildcard');
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"domain": "openvibe.chat (Chat's own site: global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24) and openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost)",
"domain": "openvibe.chat (Chat's own site: global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24) and openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost)",
"contracts": "openvibe-contracts v0.222.0",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.81.0",
"sdk": "openvibe-sdk v0.81.0",
- "shared": "openvibe-shared v4.99.0",
+ "shared": "openvibe-shared v4.99.1",
"capabilities": [
"capabilities": [
"chat.message.send",
"chat.message.send",
"chat.event.publish",
"chat.event.publish",
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -1339,9 +1339,9 @@
@@ -1339,9 +1339,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.99.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.0",
- "integrity": "sha512-i8iioHF2nBboP5RHAoPKCVn6pYjfOqgDhkX4k8KIGLKqt5ItClwKL8S1giWJ7hFZc2XkuSsRHEC05nArMHqtyg==",
+ "version": "4.99.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.1",
+ "integrity": "sha512-9UhImTuFiSurZ1XnDfDeihlexwzWfCk2Pg+v6QAJ7GYf3HXdIaNoQO2XQJVu5+izf3FD+Ed0PyDYCRalSfPjOw==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
/* Vibe, OpenVibe's assistant, on openvibe.chat (server/web/vibe-pages.js): the full-window app. The chats down the left,
/* Vibe, OpenVibe's assistant, on openvibe.chat (server/web/vibe-pages.js): the full-window app. The chats down the left,
the conversation in a readable column, one composer docked at the bottom. Colours are the network theme's (chat.css
the conversation in a readable column, one composer docked at the bottom. Colours are the network theme's (chat.css
maps them to --oc-*), so every theme, light ones included, carries through. */
maps them to --oc-*), so every theme, light ones included, carries through. */
-body.oc-app-body { overflow: hidden; }
+/* The inbox is a full-window app (its own scroll box); Vibe's pages are the conversation kit's, and the page scrolls. */
+body.oc-app-body:has(.vb) { overflow: hidden; }
.vb-wrap { margin: 0; padding: 0; max-width: none; }
.vb-wrap { margin: 0; padding: 0; max-width: none; }
.vb {
.vb {
--vb-rail: 272px;
--vb-rail: 272px;
@@ -363,3 +364,34 @@ body.oc-app-body { overflow: hidden; }
@@ -363,3 +364,34 @@ body.oc-app-body { overflow: hidden; }
.vb-res-c.is-stopped { border-color: var(--border, color-mix(in srgb, var(--text-primary, #fff) 10%, transparent)); background: transparent; }
.vb-res-c.is-stopped { border-color: var(--border, color-mix(in srgb, var(--text-primary, #fff) 10%, transparent)); background: transparent; }
.vb-res-c.is-stopped > svg { stroke: var(--text-muted, #8b8b96); }
.vb-res-c.is-stopped > svg { stroke: var(--text-muted, #8b8b96); }
.vb-res-c.is-stopped .vb-res-t { color: var(--text-muted, #8b8b96); }
.vb-res-c.is-stopped .vb-res-t { color: var(--text-muted, #8b8b96); }
+
+/* ── Vibe on the network's conversation UI (openvibe-shared/thread, plan L55 C4) ───────────────────────────────────
+ The kit draws the chats (kit.sidebar), the turns, Copy, the composer and the page; these are Vibe's own parts in it:
+ its icons, the modes and attach in the composer's tools, the picture preview, the fine print, the search's snippets,
+ the chat's options and the welcome. (The inbox still draws the older .vb layout above.) */
+.vbk { --vb-col: 780px; }
+.vbk svg:not(.ovt-ic):not(.ovk-i) { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; flex: none; }
+.vbk .ovt-btn svg:not(.ovt-ic) { width: 20px; height: 20px; }
+.vbk .vb-modes { order: 0; flex-basis: auto; margin: 0; padding: 0; }
+.vbk .vb-attach { flex: none; width: 36px; height: 36px; margin: 0; }
+.vbk .vb-preview { max-width: none; margin: 0 0 8px; }
+.vbk .vb-fine { margin: 6px 4px 0; text-align: left; }
+.vbk .vb-fine a { color: var(--oc-accent); }
+.vbk .ovt-c-note:empty { display: none; }
+.vbk .vb-allow { margin: 8px 8px 0; }
+.vbk .vb-local { display: block; max-width: 220px; max-height: 180px; margin-top: 8px; border-radius: 12px; object-fit: cover; }
+/* A search: where each chat matched, under its name. */
+.vbk .ovk-sidebar-item.vb-hit { flex-wrap: wrap; row-gap: 2px; padding-block: 6px; }
+.vbk .vb-hit-s { flex-basis: 100%; min-width: 0; font-size: .78rem; line-height: 1.35; color: var(--oc-muted); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+.vbk .vb-hit-s mark { background: color-mix(in srgb, var(--oc-accent) 28%, transparent); color: inherit; border-radius: 3px; padding: 0 1px; }
+/* The chat's options (rename, delete) open from the head. */
+.vbk .vb-actions > summary { cursor: pointer; }
+.vbk .vb-pop { right: 0; }
+/* What Vibe did, its sources and how, under the answer: the kit's spacing. */
+.vbk .ovt-reply > .vb-res, .vbk .ovt-reply > .vb-sources, .vbk .ovt-reply > .vb-how { margin-top: 0; }
+.vbk .vb-retry { display: inline; margin: 0; }
+/* The welcome: Vibe's mark, the greeting, four ideas (the kit's start page puts the composer under them). */
+.vbk .vb-hello { min-height: 0; padding: 0; }
+.vbk .ovt-answer.vb-streaming { opacity: .92; }
+/* Memory: a plain page in the frame; on a wide screen its head held only New chat, which the chats column has. */
+@media (min-width: 761px) { .vbk .ovt.is-page .ovt-head { display: none; } }
@@ -1,8 +1,11 @@
@@ -1,8 +1,11 @@
-/* Vibe on openvibe.chat (server/web/vibe-pages.js): makes the app live. The page works without this file (forms post,
- the server renders every message); here a first message starts the chat in place, answers arrive over the room
- socket (/ws/chat join_room, room_message, room_ai_typing), the composer grows with its text and sends on Enter, the
- rail slides in on phones, and a draft typed while signed out survives the sign-in. A person's text is always set
- with textContent; Vibe's answers go through VibeMarkdown (public/web/vibe-md.js), which escapes everything first. */
+/* Vibe on openvibe.chat (server/web/vibe-pages.js): makes the app live. The page is the network's conversation UI
+ (openvibe-shared/thread, plan L55 C4): ov-thread.js grows the composer, sends on Enter, adds Copy, filters the chats
+ and keeps the thread at the bottom; kit-web.js opens the chats on a phone. The page works without either (forms
+ post, the server renders every message). Here: a first message starts the chat in place, answers arrive over the
+ room socket (/ws/chat join_room, room_message, room_ai_typing, room_ai_delta) and are written in as they come, a
+ picture or a text file goes with a message, the modes, Stop, Try again, and a draft typed while signed out survives
+ the sign-in. A person's text is always set with textContent; Vibe's answers go through VibeMarkdown
+ (public/web/vibe-md.js), which escapes everything first. */
(function () {
(function () {
'use strict';
'use strict';
var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;
var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;
@@ -10,44 +13,42 @@
@@ -10,44 +13,42 @@
var MD = window.VibeMarkdown;
var MD = window.VibeMarkdown;
var DRAFT = 'ov-vibe-draft';
var DRAFT = 'ov-vibe-draft';
var app = document.getElementById('vb');
var app = document.getElementById('vb');
- var scroll = document.getElementById('vb-scroll');
+ if (!app) return;
+ var box = app.querySelector('[data-ovt]');
var form = document.getElementById('vb-composer');
var form = document.getElementById('vb-composer');
- var input = document.getElementById('vb-input');
- var titleEl = document.getElementById('vb-title');
+ var input = form && form.querySelector('[data-ovt-text]');
+ var sendBtn = form && form.querySelector('[data-ovt-send]');
+ var stopBtn = form && form.querySelector('[data-ovt-stop]');
var room = cfg.room || null;
var room = cfg.room || null;
var latest = Number(cfg.latest) || 0;
var latest = Number(cfg.latest) || 0;
var ws = null, delay = 1000, sending = false, waitTimer = null;
var ws = null, delay = 1000, sending = false, waitTimer = null;
var fileInput = document.getElementById('vb-file');
var fileInput = document.getElementById('vb-file');
var previewEl = document.getElementById('vb-preview');
var previewEl = document.getElementById('vb-preview');
var picture = null; // { file, url }: a picture waiting to go with the next message
var picture = null; // { file, url }: a picture waiting to go with the next message
+ var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function token() { var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || ''); return m ? decodeURIComponent(m[1]) : undefined; }
function token() { var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || ''); return m ? decodeURIComponent(m[1]) : undefined; }
function store(fn) { try { return fn(window.localStorage); } catch (e) { return null; } }
function store(fn) { try { return fn(window.localStorage); } catch (e) { return null; } }
// The browser's IANA zone (America/New_York) with each message: Vibe's prompt then states the person's local date.
// The browser's IANA zone (America/New_York) with each message: Vibe's prompt then states the person's local date.
function timezone() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || null; } catch (e) { return null; } }
function timezone() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || null; } catch (e) { return null; } }
- function nearBottom() { return scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 160; }
- function toBottom() { scroll.scrollTop = scroll.scrollHeight; }
- function markSvg() {
- var s = el('span', 'vb-mark'); s.setAttribute('aria-hidden', 'true');
- s.innerHTML = '<svg viewBox="0 0 24 24"><path d="M5 5l7 14 7-14"/></svg>';
- return s;
- }
+ // The page scrolls as a whole (the kit has no inner scroll box).
+ function nearBottom() { return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 220; }
+ function toBottom() { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: reduce ? 'auto' : 'smooth' }); }
+ function kitEnhance(scope) { if (window.OVThread && window.OVThread.enhance) window.OVThread.enhance(scope); }
+ // The kit's composer reads its own input event (it grows and wakes Send); a box filled from here sends one too.
+ function changed() { if (input) input.dispatchEvent(new Event('input', { bubbles: true })); }
// ── The composer ──
// ── The composer ──
- function grow() { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, window.innerHeight * 0.4) + 'px'; form.classList.toggle('is-empty', !input.value.trim() && !picture); }
if (input) {
if (input) {
- input.addEventListener('input', grow);
- input.addEventListener('keydown', function (e) {
- if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && window.matchMedia('(hover: hover)').matches) { e.preventDefault(); form.requestSubmit ? form.requestSubmit() : form.submit(); }
- });
+ // A picture alone may be sent: Send wakes for it even with no words (the kit wakes it only for words).
+ input.addEventListener('input', function () { if (picture && sendBtn) sendBtn.disabled = false; });
var saved = cfg.signedIn ? store(function (s) { var v = s.getItem(DRAFT); s.removeItem(DRAFT); return v; }) : null;
var saved = cfg.signedIn ? store(function (s) { var v = s.getItem(DRAFT); s.removeItem(DRAFT); return v; }) : null;
- if (saved && !input.value) input.value = saved;
- grow();
+ if (saved && !input.value) { input.value = saved; changed(); }
if (!('ontouchstart' in window)) input.focus();
if (!('ontouchstart' in window)) input.focus();
}
}
- document.querySelectorAll('.vb-chip[data-prompt]').forEach(function (b) {
- b.addEventListener('click', function () { input.value = b.getAttribute('data-prompt'); grow(); form.requestSubmit ? form.requestSubmit() : form.submit(); });
+ [].forEach.call(document.querySelectorAll('.vb-chip[data-prompt]'), function (b) {
+ b.addEventListener('click', function () { input.value = b.getAttribute('data-prompt'); changed(); if (form.requestSubmit) form.requestSubmit(); else form.submit(); });
});
});
// ── A picture with the message: it goes to OpenVibe.Pics as the person, and Vibe sees it ──
// ── A picture with the message: it goes to OpenVibe.Pics as the person, and Vibe sees it ──
@@ -58,6 +59,7 @@
@@ -58,6 +59,7 @@
if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; }
if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; }
if (fileInput) fileInput.value = '';
if (fileInput) fileInput.value = '';
if (input) input.required = true;
if (input) input.required = true;
+ changed();
}
}
// A text file goes into the message itself, as a fenced block named after it, cut to what still fits in the box.
// A text file goes into the message itself, as a fenced block named after it, cut to what still fits in the box.
var TEXT_EXT = /\.(txt|md|markdown|csv|tsv|json|log|xml|ya?ml|toml|ini|html|css|m?js|ts|py|go|rs|java|c|h|cpp|rb|php|sh|sql)$/i;
var TEXT_EXT = /\.(txt|md|markdown|csv|tsv|json|log|xml|ya?ml|toml|ini|html|css|m?js|ts|py|go|rs|java|c|h|cpp|rb|php|sh|sql)$/i;
@@ -74,7 +76,7 @@
@@ -74,7 +76,7 @@
if (space < 200) { notice('The box is nearly full. Send what is there first.'); return; }
if (space < 200) { notice('The box is nearly full. Send what is there first.'); return; }
var cut = text.length > space;
var cut = text.length > space;
input.value = head + (cut ? text.slice(0, space - 20) + '\n… (cut to fit)' : text) + '\n```';
input.value = head + (cut ? text.slice(0, space - 20) + '\n… (cut to fit)' : text) + '\n```';
- grow(); input.focus();
+ changed(); input.focus();
if (cut) notice('The file was longer than a message: its first part is in the box.');
if (cut) notice('The file was longer than a message: its first part is in the box.');
};
};
reader.onerror = function () { notice('That file could not be read.'); };
reader.onerror = function () { notice('That file could not be read.'); };
@@ -89,12 +91,12 @@
@@ -89,12 +91,12 @@
picture = { file: file, url: URL.createObjectURL(file) };
picture = { file: file, url: URL.createObjectURL(file) };
var fig = el('figure');
var fig = el('figure');
var img = el('img'); img.src = picture.url; img.alt = 'The picture you are sending';
var img = el('img'); img.src = picture.url; img.alt = 'The picture you are sending';
- var rm = el('button', null, '\u00d7'); rm.type = 'button'; rm.setAttribute('aria-label', 'Remove the picture');
+ var rm = el('button', null, '×'); rm.type = 'button'; rm.setAttribute('aria-label', 'Remove the picture');
rm.addEventListener('click', function () { clearPicture(); input.focus(); });
rm.addEventListener('click', function () { clearPicture(); input.focus(); });
fig.appendChild(img); fig.appendChild(rm);
fig.appendChild(img); fig.appendChild(rm);
previewEl.appendChild(fig); previewEl.hidden = false;
previewEl.appendChild(fig); previewEl.hidden = false;
input.required = false;
input.required = false;
- form.classList.remove('is-empty');
+ if (sendBtn) sendBtn.disabled = false;
input.focus();
input.focus();
}
}
var attachBtn = app.querySelector('[data-vb-attach]');
var attachBtn = app.querySelector('[data-vb-attach]');
@@ -108,92 +110,81 @@
@@ -108,92 +110,81 @@
});
});
}
}
- // ── The thread ──
- function thread() {
- var t = document.getElementById('vb-thread');
- if (t) return t;
- var hello = scroll.querySelector('.vb-hello'); if (hello) hello.remove();
- app.classList.remove('vb-empty');
- t = el('ol', 'vb-thread'); t.id = 'vb-thread';
- scroll.appendChild(t);
+ // ── The thread (the kit's markup: a turn is the person's message and the answer under it) ──
+ function threadEl() {
+ var t = app.querySelector('[data-ovt-thread]');
+ // The first message: the welcome goes, and the composer docks at the bottom.
+ if (box) box.classList.remove('is-start');
+ var hello = app.querySelector('.ovt-empty-state'); if (hello) hello.hidden = true;
return t;
return t;
}
}
- function have(id) { return id && document.querySelector('.vb-turn[data-id="' + Number(id) + '"]'); }
+ function have(id) { return id && app.querySelector('[data-me="' + Number(id) + '"],[data-ai="' + Number(id) + '"]'); }
+ function lastTurn() { var t = threadEl(); return t && t.lastElementChild; }
function meTurn(text, id, localPicture) {
function meTurn(text, id, localPicture) {
- var li = el('li', 'vb-turn vb-me'); if (id) li.dataset.id = String(id);
- var b = el('div', 'vb-bubble'); b.appendChild(el('p', 'oc-sr', 'You said:'));
- String(text).split('\n').forEach(function (line, i) { if (i) b.appendChild(document.createElement('br')); b.appendChild(document.createTextNode(line)); });
+ var li = el('li', 'ovt-turn is-new'); if (id) li.setAttribute('data-me', String(id));
+ var you = el('div', 'ovt-you');
+ var b = el('div', 'ovt-bubble');
+ b.appendChild(el('span', 'oc-sr', 'You said:'));
+ b.appendChild(document.createTextNode(String(text)));
if (localPicture) { var img = el('img', 'vb-local'); img.src = localPicture; img.alt = 'Your picture'; b.appendChild(img); }
if (localPicture) { var img = el('img', 'vb-local'); img.src = localPicture; img.alt = 'Your picture'; b.appendChild(img); }
- li.appendChild(b);
+ you.appendChild(b); li.appendChild(you);
return li;
return li;
}
}
- function aiTurn(m) {
- var li = el('li', 'vb-turn vb-ai'); li.dataset.id = String(m.id);
- li.appendChild(markSvg());
- var body = el('div', 'vb-body'); body.appendChild(el('p', 'oc-sr', 'Vibe said:'));
- var md = el('div', 'vb-md'); md.innerHTML = MD ? MD.render(m.message) : '';
- if (!MD) md.textContent = m.message;
- body.appendChild(md); li.appendChild(body);
- // What Vibe did, as on the server's page (resultChips in server/web/vibe-pages.js): Undo is the same plain form.
+ // The answer side of a turn: Vibe's mark, then its reply.
+ function botOf(li) {
+ var bot = li.querySelector('.ovt-bot');
+ if (!bot) {
+ bot = el('div', 'ovt-bot');
+ var av = el('span', 'ovt-avatar'); av.setAttribute('aria-hidden', 'true');
+ bot.appendChild(av); bot.appendChild(el('div', 'ovt-reply'));
+ li.appendChild(bot);
+ }
+ return bot.querySelector('.ovt-reply');
+ }
+ // Vibe's answer, as the server draws it (answerOf in server/web/vibe-pages.js): Undo is the same plain form.
+ function fillAnswer(reply, m) {
+ reply.textContent = '';
+ reply.appendChild(el('p', 'oc-sr', 'Vibe said:'));
+ var ans = el('div', 'ovt-answer'); ans.setAttribute('data-ovt-answer', '');
+ if (MD) ans.innerHTML = MD.render(m.message); else ans.textContent = m.message;
+ reply.appendChild(ans);
var res = Array.isArray(m.results) ? m.results : [];
var res = Array.isArray(m.results) ? m.results : [];
if (res.length && room) {
if (res.length && room) {
- var box = el('div', 'vb-res');
+ var resBox = el('div', 'vb-res');
res.slice(0, 4).forEach(function (r, i) {
res.slice(0, 4).forEach(function (r, i) {
if (!r || (r.kind !== 'reminder' && r.kind !== 'timer')) return;
if (!r || (r.kind !== 'reminder' && r.kind !== 'timer')) return;
var set = r.action === 'set';
var set = r.action === 'set';
var c = el('div', 'vb-res-c' + (set ? '' : ' is-stopped'));
var c = el('div', 'vb-res-c' + (set ? '' : ' is-stopped'));
var t = el('div', 'vb-res-t');
var t = el('div', 'vb-res-t');
- var b = el('b', '', (r.kind === 'timer' ? (set ? 'Timer' : 'Timer stopped') : (set ? 'Reminder set' : 'Reminder stopped')) + ':');
- t.appendChild(b); t.appendChild(document.createTextNode(' ' + String(r.title || '')));
+ t.appendChild(el('b', '', (r.kind === 'timer' ? (set ? 'Timer' : 'Timer stopped') : (set ? 'Reminder set' : 'Reminder stopped')) + ':'));
+ t.appendChild(document.createTextNode(' ' + String(r.title || '')));
if (r.when && set) t.appendChild(el('small', '', String(r.when)));
if (r.when && set) t.appendChild(el('small', '', String(r.when)));
c.appendChild(t);
c.appendChild(t);
var a = el('div', 'vb-res-a');
var a = el('div', 'vb-res-a');
if (set) {
if (set) {
var f = el('form'); f.method = 'post'; f.action = '/vibe/' + room + '/undo';
var f = el('form'); f.method = 'post'; f.action = '/vibe/' + room + '/undo';
[['message_id', String(m.id)], ['index', String(i)]].forEach(function (kv) { var h = el('input'); h.type = 'hidden'; h.name = kv[0]; h.value = kv[1]; f.appendChild(h); });
[['message_id', String(m.id)], ['index', String(i)]].forEach(function (kv) { var h = el('input'); h.type = 'hidden'; h.name = kv[0]; h.value = kv[1]; f.appendChild(h); });
- var u = el('button', 'vb-act', 'Undo'); u.type = 'submit'; f.appendChild(u); a.appendChild(f);
+ var u = el('button', 'ovt-act', 'Undo'); u.type = 'submit'; f.appendChild(u); a.appendChild(f);
}
}
- if (r.url && /^https:\/\//.test(r.url)) { var o = el('a', 'vb-act', 'Open'); o.href = r.url; a.appendChild(o); }
+ if (r.url && /^https:\/\//.test(r.url)) { var o = el('a', 'ovt-act', 'Open'); o.href = r.url; a.appendChild(o); }
if (a.childNodes.length) c.appendChild(a);
if (a.childNodes.length) c.appendChild(a);
- box.appendChild(c);
+ resBox.appendChild(c);
});
});
- body.appendChild(box);
+ reply.appendChild(resBox);
}
}
- // Copy, and Try again (it moves to this, the newest answer, when the person's message came just before).
- var row = el('div', 'vb-acts');
- var copy = el('button', 'vb-act', 'Copy'); copy.type = 'button'; copy.setAttribute('data-vb-copy', '');
- row.appendChild(copy);
- var prev = thread().lastElementChild;
- if (room && prev && prev.classList.contains('vb-me')) {
- [].forEach.call(document.querySelectorAll('.vb-retry'), function (f) { f.remove(); });
- var form = el('form', 'vb-retry'); form.method = 'post'; form.action = '/vibe/' + room + '/retry';
- var again = el('button', 'vb-act', 'Try again'); again.type = 'submit'; form.appendChild(again);
- row.appendChild(form);
+ // Copy (ov-thread.js shows it where the clipboard works), and Try again under the newest answer to the person's
+ // last message (it moves here from an older one).
+ var meta = el('div', 'ovt-meta'); var acts = el('span', 'ovt-acts');
+ var copy = el('button', 'ovt-act'); copy.type = 'button'; copy.setAttribute('data-ovt-copy', ''); copy.hidden = true;
+ copy.appendChild(el('span', null, 'Copy'));
+ acts.appendChild(copy);
+ if (room && reply.closest('.ovt-turn').querySelector('.ovt-you')) {
+ [].forEach.call(app.querySelectorAll('.vb-retry'), function (f) { f.remove(); });
+ var again = el('form', 'vb-retry'); again.method = 'post'; again.action = '/vibe/' + room + '/retry';
+ var b = el('button', 'ovt-act', 'Try again'); b.type = 'submit'; again.appendChild(b);
+ acts.appendChild(again);
}
}
- body.appendChild(row);
- enhance(li);
- return li;
- }
- // Copy buttons: the whole answer as its text, and each code block on its own. Shown only where the clipboard works.
- function copyText(btn, text) {
- if (!navigator.clipboard) return;
- navigator.clipboard.writeText(text).then(function () {
- // A code block's button draws its label in CSS (so copying the whole answer never picks it up): a class says Copied.
- var code = btn.classList.contains('vb-copy-code');
- var was = btn.textContent;
- if (!code) btn.textContent = 'Copied';
- btn.classList.add('is-done');
- setTimeout(function () { if (!code) btn.textContent = was; btn.classList.remove('is-done'); }, 1500);
- }, function () { notice('Copying did not work here. Select the text instead.'); });
- }
- function enhance(scope) {
- if (!navigator.clipboard) return;
- [].forEach.call(scope.querySelectorAll('[data-vb-copy]'), function (b) { b.hidden = false; });
- [].forEach.call(scope.querySelectorAll('.vb-ai .vb-md pre'), function (pre) {
- if (pre.querySelector('.vb-copy-code')) return;
- var b = el('button', 'vb-copy-code'); b.type = 'button'; b.setAttribute('aria-label', 'Copy this code');
- pre.appendChild(b);
- });
+ meta.appendChild(acts); reply.appendChild(meta);
}
}
// "How Vibe did this" under an agent task's answer that arrives while the page is open (the server folds it on load).
// "How Vibe did this" under an agent task's answer that arrives while the page is open (the server folds it on load).
function attachHow(li, id) {
function attachHow(li, id) {
@@ -232,110 +223,120 @@
@@ -232,110 +223,120 @@
}
}
// The id "How Vibe did this" links to: the answer's one set of chips, never a second copy.
// The id "How Vibe did this" links to: the answer's one set of chips, never a second copy.
function sourcesId(li) {
function sourcesId(li) {
- var id = li && li.dataset ? li.dataset.id : null;
+ var id = li && li.getAttribute('data-ai');
return id ? 'vb-sources-' + String(id).replace(/[^a-zA-Z0-9_-]/g, '') : null;
return id ? 'vb-sources-' + String(id).replace(/[^a-zA-Z0-9_-]/g, '') : null;
}
}
function renderSources(li, info) {
function renderSources(li, info) {
- var body = li.querySelector('.vb-body');
- if (!info || !body || body.querySelector('.vb-sources')) return;
+ var reply = li.querySelector('.ovt-reply');
+ if (!info || !reply || reply.querySelector('.vb-sources')) return;
var links = sourceChips(info);
var links = sourceChips(info);
if (!links.length) return;
if (!links.length) return;
var ul = el('ul', 'vb-sources'); ul.setAttribute('aria-label', 'Sources');
var ul = el('ul', 'vb-sources'); ul.setAttribute('aria-label', 'Sources');
var sid = sourcesId(li); if (sid) ul.id = sid;
var sid = sourcesId(li); if (sid) ul.id = sid;
links.forEach(function (c) { var row = el('li'); row.appendChild(chipLink(c)); ul.appendChild(row); });
links.forEach(function (c) { var row = el('li'); row.appendChild(chipLink(c)); ul.appendChild(row); });
- var md = body.querySelector('.vb-md');
- if (md && md.nextSibling) body.insertBefore(ul, md.nextSibling);
- else if (md) body.appendChild(ul);
- else body.insertBefore(ul, body.firstChild);
+ var ans = reply.querySelector('.ovt-answer');
+ if (ans && ans.nextSibling) reply.insertBefore(ul, ans.nextSibling); else reply.appendChild(ul);
}
}
function stepsOf(raw) {
function stepsOf(raw) {
var h = null; try { h = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (e) { h = null; }
var h = null; try { h = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (e) { h = null; }
return h && h.steps && h.steps.length ? { lines: h.steps, sources: h.sources || [], citations: h.citations || [] } : null;
return h && h.steps && h.steps.length ? { lines: h.steps, sources: h.sources || [], citations: h.citations || [] } : null;
}
}
function renderHow(li, info) {
function renderHow(li, info) {
- var body = li.querySelector('.vb-body');
- if (!info || !body || body.querySelector('.vb-how')) return;
- renderSources(li, info);
- var det = el('details', 'vb-how'); det.appendChild(el('summary', null, 'How Vibe did this'));
- var ul = el('ul');
- (info.lines || []).forEach(function (l) { ul.appendChild(el('li', null, l)); });
- var links = sourceChips(info);
- if (links.length) {
- var src = el('li', 'vb-how-sources');
- var link = el('a', null, 'Sources (' + links.length + ') under this answer');
- var sid = sourcesId(li); if (sid) link.href = '#' + sid;
- src.appendChild(link);
- ul.appendChild(src);
- }
- if (info.actor_url && /^https:\/\/openvibe\.actor\//.test(info.actor_url)) { var li2 = el('li'); var a2 = el('a', null, 'Every step on OpenVibe Agents'); a2.href = info.actor_url; li2.appendChild(a2); ul.appendChild(li2); }
- det.appendChild(ul);
- var acts = body.querySelector('.vb-acts');
- if (acts) body.insertBefore(det, acts); else body.appendChild(det);
+ var reply = li.querySelector('.ovt-reply');
+ if (!info || !reply || reply.querySelector('.vb-how')) return;
+ renderSources(li, info);
+ var det = el('details', 'vb-how'); det.appendChild(el('summary', null, 'How Vibe did this'));
+ var ul = el('ul');
+ (info.lines || []).forEach(function (l) { ul.appendChild(el('li', null, l)); });
+ var links = sourceChips(info);
+ if (links.length) {
+ var src = el('li', 'vb-how-sources');
+ var link = el('a', null, 'Sources (' + links.length + ') under this answer');
+ var sid = sourcesId(li); if (sid) link.href = '#' + sid;
+ src.appendChild(link);
+ ul.appendChild(src);
+ }
+ if (info.actor_url && /^https:\/\/openvibe\.actor\//.test(info.actor_url)) { var li2 = el('li'); var a2 = el('a', null, 'Every step on OpenVibe Agents'); a2.href = info.actor_url; li2.appendChild(a2); ul.appendChild(li2); }
+ det.appendChild(ul);
+ var meta = reply.querySelector('.ovt-meta');
+ if (meta) reply.insertBefore(det, meta); else reply.appendChild(det);
}
}
- function clearWait() { var w = document.getElementById('vb-wait'); if (w) w.remove(); if (waitTimer) { clearTimeout(waitTimer); waitTimer = null; } }
- // Stop: what Vibe wrote so far stays as its answer (POST /api/chat/vibe/:slug/stop); shown once the chat exists (a new
- // chat's first bubble is drawn before it does, so the first words add it).
- function addStop(li) {
- if (!room || !li || li.querySelector('.vb-stop')) return;
- var stop = el('button', 'vb-act vb-stop', 'Stop'); stop.type = 'button';
- stop.addEventListener('click', function () {
- stop.disabled = true;
- fetch('/api/chat/vibe/' + encodeURIComponent(room) + '/stop', { method: 'POST', credentials: 'same-origin' }).catch(function () { stop.disabled = false; });
- });
- li.appendChild(stop);
+
+ // ── Vibe writing ──
+ // The turn that waits for Vibe: the newest one, open, its live line breathing (nothing spins).
+ function waitingTurn() { return app.querySelector('[data-ovt-thread] > .ovt-turn[data-open]'); }
+ function setStop(on) { if (stopBtn) stopBtn.hidden = !(on && room); if (sendBtn) sendBtn.hidden = !!(on && room); }
+ function clearWait() {
+ var w = waitingTurn();
+ if (w) {
+ w.removeAttribute('data-open');
+ var bot = w.querySelector('.ovt-bot');
+ if (bot && !bot.querySelector('[data-ovt-answer]:not(.vb-streaming)')) bot.remove();
+ }
+ if (waitTimer) { clearTimeout(waitTimer); waitTimer = null; }
+ setStop(false);
}
}
- function showWait() {
- if (document.getElementById('vb-wait')) return;
- var li = el('li', 'vb-turn vb-ai vb-wait'); li.id = 'vb-wait';
- li.appendChild(markSvg());
- var body = el('div', 'vb-body'); var dots = el('span', 'vb-dots'); dots.setAttribute('aria-label', 'Vibe is writing');
- dots.appendChild(el('i')); dots.appendChild(el('i')); dots.appendChild(el('i'));
- body.appendChild(dots); li.appendChild(body);
- addStop(li);
- thread().appendChild(li);
- // An answer takes up to a minute with tools; after two the dots give way to a way forward.
+ function giveUpLater() {
+ if (waitTimer) clearTimeout(waitTimer);
+ // An answer takes up to a minute with tools; after two the live line gives way to a way forward.
waitTimer = setTimeout(function () { clearWait(); notice('Vibe did not answer this time. Send your message again, or try again in a minute.'); }, 120000);
waitTimer = setTimeout(function () { clearWait(); notice('Vibe did not answer this time. Send your message again, or try again in a minute.'); }, 120000);
}
}
- // The answer as it is written (room_ai_delta: the whole text so far): the waiting bubble shows it, rendered like the
+ function showWait() {
+ var li = waitingTurn();
+ if (!li) {
+ li = lastTurn();
+ if (!li || li.querySelector('.ovt-bot')) { li = el('li', 'ovt-turn'); threadEl().appendChild(li); }
+ li.setAttribute('data-open', '');
+ var reply = botOf(li);
+ var work = el('details', 'ovt-work'); work.open = true; work.setAttribute('data-open', '');
+ var sum = el('summary'); var line = el('span', 'ovt-w-live', 'Vibe is writing…'); line.setAttribute('data-ovt-live', '');
+ sum.appendChild(line); work.appendChild(sum);
+ reply.appendChild(work);
+ }
+ setStop(true);
+ giveUpLater();
+ }
+ // The answer as it is written (room_ai_delta: the whole text so far): the waiting turn shows it, rendered like the
// finished answer, until that message arrives and takes its place. Screen readers hear the finished one only.
// finished answer, until that message arrives and takes its place. Screen readers hear the finished one only.
function streamText(text) {
function streamText(text) {
showWait();
showWait();
- var w = document.getElementById('vb-wait'); if (!w) return;
- addStop(w);
+ var w = waitingTurn(); if (!w) return;
var stick = nearBottom();
var stick = nearBottom();
- var body = w.querySelector('.vb-body');
- var md = body.querySelector('.vb-md');
- if (!md) {
- body.textContent = '';
- md = el('div', 'vb-md vb-streaming'); md.setAttribute('aria-hidden', 'true');
- body.appendChild(md);
- var dots = el('span', 'vb-dots'); dots.setAttribute('aria-label', 'Vibe is writing');
- dots.appendChild(el('i')); dots.appendChild(el('i')); dots.appendChild(el('i'));
- body.appendChild(dots);
- }
- if (MD) md.innerHTML = MD.render(String(text || '')); else md.textContent = String(text || '');
- if (waitTimer) { clearTimeout(waitTimer); waitTimer = setTimeout(function () { clearWait(); notice('Vibe did not answer this time. Send your message again, or try again in a minute.'); }, 120000); }
+ var reply = botOf(w);
+ var ans = reply.querySelector('.vb-streaming');
+ if (!ans) { ans = el('div', 'ovt-answer vb-streaming'); ans.setAttribute('aria-hidden', 'true'); reply.appendChild(ans); }
+ if (MD) ans.innerHTML = MD.render(String(text || '')); else ans.textContent = String(text || '');
+ giveUpLater();
if (stick) toBottom();
if (stick) toBottom();
}
}
function notice(text) {
function notice(text) {
- var old = scroll.querySelector('.vb-live-notice'); if (old) old.remove();
+ var old = app.querySelector('.vb-live-notice'); if (old) old.remove();
var p = el('p', 'oc-notice oc-error vb-live-notice', text); p.setAttribute('role', 'alert');
var p = el('p', 'oc-notice oc-error vb-live-notice', text); p.setAttribute('role', 'alert');
- scroll.appendChild(p); toBottom();
+ var scroll = app.querySelector('.ovt-scroll'); (scroll || app).appendChild(p); toBottom();
}
}
function add(m) {
function add(m) {
if (!m || have(m.id)) return;
if (!m || have(m.id)) return;
latest = Math.max(latest, Number(m.id) || 0);
latest = Math.max(latest, Number(m.id) || 0);
var stick = nearBottom();
var stick = nearBottom();
if (m.ai_member_id) {
if (m.ai_member_id) {
- clearWait(); var turnEl = aiTurn(m); thread().appendChild(turnEl); refreshRail();
- if (/^✅ Done:/.test(String(m.message || ''))) attachHow(turnEl, m.id);
- else if (m.how) renderHow(turnEl, stepsOf(m.how));
- }
- else {
+ // The answer takes the waiting turn's place, or the newest turn that has none yet, or a turn of its own.
+ var li = waitingTurn() || lastTurn();
+ if (!li || (li.querySelector('.ovt-bot') && !li.hasAttribute('data-open'))) { li = el('li', 'ovt-turn'); threadEl().appendChild(li); }
+ if (waitTimer) { clearTimeout(waitTimer); waitTimer = null; }
+ setStop(false);
+ li.removeAttribute('data-open');
+ li.setAttribute('data-ai', String(m.id));
+ fillAnswer(botOf(li), m);
+ li.classList.add('is-fresh');
+ kitEnhance(li);
+ refreshRail();
+ if (/^✅ Done:/.test(String(m.message || ''))) attachHow(li, m.id);
+ else if (m.how) renderHow(li, stepsOf(m.how));
+ } else {
// The line sent from here already shows (without an id until the server answered): give it its id.
// The line sent from here already shows (without an id until the server answered): give it its id.
- var pending = document.querySelector('.vb-turn.vb-me:not([data-id])');
- if (pending && Number(m.user_id) === Number(cfg.me)) pending.dataset.id = String(m.id);
- else thread().appendChild(meTurn(m.message, m.id));
+ var pending = app.querySelector('[data-ovt-thread] > .ovt-turn:not([data-me]) > .ovt-you');
+ if (pending && Number(m.user_id) === Number(cfg.me)) pending.parentNode.setAttribute('data-me', String(m.id));
+ else threadEl().appendChild(meTurn(m.message, m.id));
}
}
if (stick) toBottom();
if (stick) toBottom();
}
}
@@ -348,6 +349,11 @@
@@ -348,6 +349,11 @@
.then(function (d) { if (d && d.messages) d.messages.forEach(add); })
.then(function (d) { if (d && d.messages) d.messages.forEach(add); })
.catch(function () {});
.catch(function () {});
}
}
+ function removeMessage(id) {
+ var li = have(id); if (!li) return;
+ if (li.getAttribute('data-me') === String(Number(id))) li.remove();
+ else { var bot = li.querySelector('.ovt-bot'); if (bot) bot.remove(); li.removeAttribute('data-ai'); if (!li.querySelector('.ovt-you')) li.remove(); }
+ }
function connect() {
function connect() {
if (!room || !window.WebSocket) return;
if (!room || !window.WebSocket) return;
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
@@ -358,62 +364,43 @@
@@ -358,62 +364,43 @@
else if (m.type === 'room_message' && m.room === room) add(m.message);
else if (m.type === 'room_message' && m.room === room) add(m.message);
else if (m.type === 'room_ai_typing' && m.room === room) { showWait(); if (nearBottom()) toBottom(); }
else if (m.type === 'room_ai_typing' && m.room === room) { showWait(); if (nearBottom()) toBottom(); }
else if (m.type === 'room_ai_delta' && m.room === room) streamText(m.text);
else if (m.type === 'room_ai_delta' && m.room === room) streamText(m.text);
- else if (m.type === 'room_message_deleted' && m.room === room) { var x = have(m.id); if (x) x.remove(); }
+ else if (m.type === 'room_message_deleted' && m.room === room) removeMessage(m.id);
else if (m.type === 'auth_revoked') notice('You were signed out. Sign in again to keep chatting.');
else if (m.type === 'auth_revoked') notice('You were signed out. Sign in again to keep chatting.');
};
};
ws.onclose = function () { ws = null; if (room) { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); } };
ws.onclose = function () { ws = null; if (room) { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); } };
}
}
- // ── The rail ──
+ // ── The chats (the network's sidebar, kit.sidebar): rebuilt from data with text nodes after each answer ──
function refreshRail() {
function refreshRail() {
fetch('/api/chat/vibe?limit=100', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
fetch('/api/chat/vibe?limit=100', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
if (!d) return;
if (!d) return;
var allow = document.getElementById('vb-allow');
var allow = document.getElementById('vb-allow');
if (allow && d.allowance && !allow.hasAttribute('data-own')) allow.textContent = Math.max(0, d.allowance.per_day - d.allowance.used_today) + ' of ' + d.allowance.per_day + ' answers left today';
if (allow && d.allowance && !allow.hasAttribute('data-own')) allow.textContent = Math.max(0, d.allowance.per_day - d.allowance.used_today) + ' of ' + d.allowance.per_day + ' answers left today';
- var list = document.querySelector('.vb-rail-list'); if (!list || !d.chats) return;
- // Rebuilt from data with text nodes (the server's grouping, kept simple here: Today and Earlier).
+ var secs = app.querySelector('#vb-rail .ovk-sidebar-secs'); if (!secs || !d.chats) return;
+ if (new URLSearchParams(location.search).get('q')) return; // search results stay until the search is left
var start = new Date(); start.setUTCHours(0, 0, 0, 0);
var start = new Date(); start.setUTCHours(0, 0, 0, 0);
- list.textContent = '';
var groups = [{ label: 'Today', chats: [] }, { label: 'Earlier', chats: [] }];
var groups = [{ label: 'Today', chats: [] }, { label: 'Earlier', chats: [] }];
d.chats.forEach(function (c) { var at = Date.parse(String(c.last_message_at).indexOf('T') < 0 ? String(c.last_message_at).replace(' ', 'T') + 'Z' : c.last_message_at); (at >= start.getTime() ? groups[0] : groups[1]).chats.push(c); });
d.chats.forEach(function (c) { var at = Date.parse(String(c.last_message_at).indexOf('T') < 0 ? String(c.last_message_at).replace(' ', 'T') + 'Z' : c.last_message_at); (at >= start.getTime() ? groups[0] : groups[1]).chats.push(c); });
+ secs.textContent = '';
groups.forEach(function (g) {
groups.forEach(function (g) {
if (!g.chats.length) return;
if (!g.chats.length) return;
- list.appendChild(el('p', 'vb-group', g.label));
- var ul = el('ul', 'vb-chats');
+ var sec = el('section', 'ovk-sidebar-sec');
+ sec.appendChild(el('h2', 'ovk-sidebar-h', g.label));
g.chats.forEach(function (c) {
g.chats.forEach(function (c) {
- var li = el('li'); var a = el('a', 'vb-chat', c.name); a.href = '/vibe/' + c.slug;
+ var a = el('a', 'ovk-sidebar-item' + (c.slug === room ? ' is-here' : '')); a.href = '/vibe/' + c.slug;
if (c.slug === room) a.setAttribute('aria-current', 'page');
if (c.slug === room) a.setAttribute('aria-current', 'page');
- li.appendChild(a); ul.appendChild(li);
+ a.appendChild(el('span', 'ovk-sidebar-t', c.name));
+ sec.appendChild(a);
});
});
- list.appendChild(ul);
+ secs.appendChild(sec);
});
});
}).catch(function () {});
}).catch(function () {});
}
}
- function setRail(open) {
- app.classList.toggle('is-open', open);
- var scrim = app.querySelector('.vb-scrim'); if (scrim) scrim.hidden = !open;
- var btn = app.querySelector('[data-vb-open]'); if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
- }
- app.addEventListener('click', function (e) {
- if (e.target.closest('[data-vb-open]')) setRail(true);
- else if (e.target.closest('[data-vb-close]')) setRail(false);
- });
- document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && app.classList.contains('is-open')) setRail(false); });
- // ── Search chats: typing narrows the listed chats by name at once; Enter searches inside them too (?q=) ──
- var q = document.getElementById('vb-q');
+ // The search's words come back into the box from the address (the page never repeats them).
+ var q = app.querySelector('#vb-rail .ovk-sidebar-find input');
var searched = null;
var searched = null;
try { searched = new URLSearchParams(location.search).get('q'); } catch (e) { searched = null; }
try { searched = new URLSearchParams(location.search).get('q'); } catch (e) { searched = null; }
- if (q && searched) q.value = searched.slice(0, 80); // the page never repeats it (server/web/vibe-pages.js)
- if (q && !searched) q.addEventListener('input', function () {
- var v = q.value.trim().toLowerCase();
- var list = document.querySelector('.vb-rail-list'); if (!list) return;
- [].forEach.call(list.querySelectorAll('.vb-chats'), function (ul) {
- var shown = 0;
- [].forEach.call(ul.children, function (li) { var hit = !v || li.textContent.toLowerCase().indexOf(v) >= 0; li.hidden = !hit; if (hit) shown++; });
- ul.hidden = !shown;
- var head = ul.previousElementSibling; if (head && head.classList.contains('vb-group')) head.hidden = !shown;
- });
- });
+ if (q && searched) q.value = searched.slice(0, 80);
// Forms that undo something (Memory's "Forget everything") ask first.
// Forms that undo something (Memory's "Forget everything") ask first.
[].forEach.call(app.querySelectorAll('form[data-vb-confirm]'), function (f) { f.addEventListener('submit', function (e) { if (!window.confirm(f.getAttribute('data-vb-confirm'))) e.preventDefault(); }); });
[].forEach.call(app.querySelectorAll('form[data-vb-confirm]'), function (f) { f.addEventListener('submit', function (e) { if (!window.confirm(f.getAttribute('data-vb-confirm'))) e.preventDefault(); }); });
var del = app.querySelector('[data-vb-delete]');
var del = app.querySelector('[data-vb-delete]');
@@ -424,8 +411,18 @@
@@ -424,8 +411,18 @@
r.addEventListener('change', function () { if (r.checked && input && r.getAttribute('data-placeholder') && !form.dataset.signin) input.placeholder = r.getAttribute('data-placeholder'); input.focus(); });
r.addEventListener('change', function () { if (r.checked && input && r.getAttribute('data-placeholder') && !form.dataset.signin) input.placeholder = r.getAttribute('data-placeholder'); input.focus(); });
});
});
+ // ── Stop: what Vibe wrote so far stays as its answer (POST /api/chat/vibe/:slug/stop) ──
+ if (stopBtn) stopBtn.addEventListener('click', function (e) {
+ e.preventDefault();
+ if (!room) return;
+ stopBtn.disabled = true;
+ fetch('/api/chat/vibe/' + encodeURIComponent(room) + '/stop', { method: 'POST', credentials: 'same-origin' })
+ .catch(function () {}).then(function () { stopBtn.disabled = false; });
+ });
+
// ── Sending ──
// ── Sending ──
if (form) form.addEventListener('submit', function (e) {
if (form) form.addEventListener('submit', function (e) {
+ if (e.submitter && e.submitter === stopBtn) { e.preventDefault(); return; }
var text = input.value.trim();
var text = input.value.trim();
if (!text && !picture) { e.preventDefault(); return; }
if (!text && !picture) { e.preventDefault(); return; }
if (form.dataset.signin) {
if (form.dataset.signin) {
@@ -438,12 +435,12 @@
@@ -438,12 +435,12 @@
if (!window.fetch || sending) return;
if (!window.fetch || sending) return;
e.preventDefault();
e.preventDefault();
sending = true;
sending = true;
- var old = scroll.querySelector('.vb-live-notice'); if (old) old.remove();
+ var old = app.querySelector('.vb-live-notice'); if (old) old.remove();
var sendingPicture = picture;
var sendingPicture = picture;
var mine = meTurn(text, null, sendingPicture && sendingPicture.url);
var mine = meTurn(text, null, sendingPicture && sendingPicture.url);
- thread().appendChild(mine);
+ threadEl().appendChild(mine);
showWait(); toBottom();
showWait(); toBottom();
- input.value = ''; picture = null; if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; } if (fileInput) fileInput.value = ''; input.required = true; grow();
+ input.value = ''; picture = null; if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; } if (fileInput) fileInput.value = ''; input.required = true; changed();
var url, init;
var url, init;
var modeEl = form.querySelector('input[name="mode"]:checked'), mode = modeEl ? modeEl.value : 'chat';
var modeEl = form.querySelector('input[name="mode"]:checked'), mode = modeEl ? modeEl.value : 'chat';
if (mode !== 'chat' && !sendingPicture) {
if (mode !== 'chat' && !sendingPicture) {
@@ -458,48 +455,42 @@
@@ -458,48 +455,42 @@
url = room ? '/api/chat/rooms/' + encodeURIComponent(room) + '/messages' : '/api/chat/vibe';
url = room ? '/api/chat/rooms/' + encodeURIComponent(room) + '/messages' : '/api/chat/vibe';
init = { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, timezone: timezone() }) };
init = { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, timezone: timezone() }) };
}
}
+ function putBack(msg) { mine.remove(); clearWait(); input.value = text; if (sendingPicture) choosePicture(sendingPicture.file); changed(); notice(msg); }
fetch(url, init)
fetch(url, init)
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: null }; }); })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: null }; }); })
.then(function (x) {
.then(function (x) {
- if (!x.ok) { mine.remove(); clearWait(); input.value = text; if (sendingPicture) choosePicture(sendingPicture.file); grow(); notice((x.d && x.d.error) || 'That did not send. Try again.'); return; }
- if (x.d.message && x.d.message.id) { mine.dataset.id = String(x.d.message.id); latest = Math.max(latest, Number(x.d.message.id)); }
+ if (!x.ok) { putBack((x.d && x.d.error) || 'That did not send. Try again.'); return; }
+ if (x.d.message && x.d.message.id) { mine.setAttribute('data-me', String(x.d.message.id)); latest = Math.max(latest, Number(x.d.message.id)); }
if (!room && x.d.room) {
if (!room && x.d.room) {
room = x.d.room.slug;
room = x.d.room.slug;
history.replaceState(null, '', '/vibe/' + room);
history.replaceState(null, '', '/vibe/' + room);
- if (titleEl) titleEl.textContent = x.d.room.name;
+ var titleEl = app.querySelector('.ovt-title'); if (titleEl) titleEl.textContent = x.d.room.name;
document.title = x.d.room.name + ' · Vibe · OpenVibe.Chat';
document.title = x.d.room.name + ' · Vibe · OpenVibe.Chat';
form.action = '/vibe/' + room;
form.action = '/vibe/' + room;
+ setStop(true);
refreshRail();
refreshRail();
connect();
connect();
}
}
})
})
- .catch(function () { mine.remove(); clearWait(); input.value = text; if (sendingPicture) choosePicture(sendingPicture.file); grow(); notice('You seem to be offline. Your message is back in the box.'); })
+ .catch(function () { putBack('You seem to be offline. Your message is back in the box.'); })
.then(function () { sending = false; });
.then(function () { sending = false; });
});
});
- enhance(app);
- app.addEventListener('click', function (e) {
- var whole = e.target.closest('[data-vb-copy]');
- if (whole) { var md = whole.closest('.vb-body').querySelector('.vb-md'); if (md) copyText(whole, md.innerText.trim()); return; }
- var code = e.target.closest('.vb-copy-code');
- if (code) { var c = code.parentNode.querySelector('code'); if (c) copyText(code, c.textContent); }
- });
// Try again: Vibe's last answer goes and a new one is written (POST /api/chat/vibe/:slug/retry).
// Try again: Vibe's last answer goes and a new one is written (POST /api/chat/vibe/:slug/retry).
app.addEventListener('submit', function (e) {
app.addEventListener('submit', function (e) {
var f = e.target.closest('.vb-retry'); if (!f || !room) return;
var f = e.target.closest('.vb-retry'); if (!f || !room) return;
e.preventDefault();
e.preventDefault();
- var li = f.closest('.vb-turn');
+ var li = f.closest('.ovt-turn');
fetch('/api/chat/vibe/' + encodeURIComponent(room) + '/retry', { method: 'POST', credentials: 'same-origin' })
fetch('/api/chat/vibe/' + encodeURIComponent(room) + '/retry', { method: 'POST', credentials: 'same-origin' })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: null }; }); })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: null }; }); })
.then(function (x) {
.then(function (x) {
if (!x.ok) { notice((x.d && x.d.error) || 'That did not work. Try again in a minute.'); return; }
if (!x.ok) { notice((x.d && x.d.error) || 'That did not work. Try again in a minute.'); return; }
- if (li) li.remove();
+ if (li) { var bot = li.querySelector('.ovt-bot'); if (bot) bot.remove(); li.removeAttribute('data-ai'); }
showWait(); toBottom();
showWait(); toBottom();
})
})
.catch(function () { notice('You seem to be offline.'); });
.catch(function () { notice('You seem to be offline.'); });
});
});
if (cfg.waiting) showWait();
if (cfg.waiting) showWait();
connect();
connect();
- if (!cfg.memory) toBottom(); // the Memory page reads from the top
window.addEventListener('pagehide', function () { var w = ws; room = null; if (w) w.close(); });
window.addEventListener('pagehide', function () { var w = ws; room = null; if (w) w.close(); });
})();
})();
@@ -102,7 +102,8 @@ function contentSecurityPolicy(nonce) {
@@ -102,7 +102,8 @@ function contentSecurityPolicy(nonce) {
* prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges), counts (unread
* prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges), counts (unread
* rooms/messages for the navigation), script / callScript / ringScript (public/web/chat.js, call.js, ring.js: a call
* rooms/messages for the navigation), script / callScript / ringScript (public/web/chat.js, call.js, ring.js: a call
* coming in, on every page of a signed-in person while calls are on), app (Vibe's full-window app, server/web/vibe-pages.js:
* coming in, on every page of a signed-in person while calls are on), app (Vibe's full-window app, server/web/vibe-pages.js:
- * vibe.css + vibe-md.js + vibe.js, and no footer), appScript (the app's own script instead of vibe.js: inbox.js), feedView
+ * vibe.css + vibe-md.js + vibe.js, and no footer), kit (the network's conversation UI, openvibe-shared/thread: kit.css,
+ * thread.css, kit-web.js and ov-thread.js; Vibe's pages), appScript (the app's own script instead of vibe.js: inbox.js), feedView
* (a feed's card on the page: feed-view.css + feed-view.js, server/feeds/), scripts (more of
* (a feed's card on the page: feed-view.css + feed-view.js, server/feeds/), scripts (more of
* public/web's scripts by name, e.g. support.js), footer ('full', the default, on front pages, directories and docs;
* public/web's scripts by name, e.g. support.js), footer ('full', the default, on front pages, directories and docs;
* 'compact', one quiet line, on pages people work in: a room, a channel, a conversation, a task, settings),
* 'compact', one quiet line, on pages people work in: a room, a channel, a conversation, a task, settings),
@@ -143,6 +144,7 @@ ${INSTALL_LINKS}
@@ -143,6 +144,7 @@ ${INSTALL_LINKS}
<link rel="stylesheet" href="${ovServe.url('items.css')}">
<link rel="stylesheet" href="${ovServe.url('items.css')}">
${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}
${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}
${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
+${o.kit ? `<link rel="stylesheet" href="${ovServe.url('kit.css')}">\n<link rel="stylesheet" href="${ovServe.url('thread.css')}">` : ''}
${o.app ? `<link rel="stylesheet" href="${asset('vibe.css')}">` : ''}
${o.app ? `<link rel="stylesheet" href="${asset('vibe.css')}">` : ''}
${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}
${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}
${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}
${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}
@@ -152,6 +154,7 @@ ${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join
@@ -152,6 +154,7 @@ ${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join
${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}
${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}
${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
${o.ringScript ? `<script src="${asset('ring.js')}" defer></script>` : ''}
${o.ringScript ? `<script src="${asset('ring.js')}" defer></script>` : ''}
+${o.kit ? `<script src="${ovServe.url('kit-web.js')}" defer></script>\n<script src="${ovServe.url('ov-thread.js')}" defer></script>` : ''}
${o.app ? `<script src="${asset('vibe-md.js')}" defer></script>\n<script src="${asset(o.appScript || 'vibe.js')}" defer></script>` : ''}
${o.app ? `<script src="${asset('vibe-md.js')}" defer></script>\n<script src="${asset(o.appScript || 'vibe.js')}" defer></script>` : ''}
</head>
</head>
<body class="${[cls, o.app ? 'oc-app-body' : ''].filter(Boolean).join(' ')}"${scale}>
<body class="${[cls, o.app ? 'oc-app-body' : ''].filter(Boolean).join(' ')}"${scale}>
@@ -23,6 +23,8 @@ const vibe = require('../rooms/vibe');
@@ -23,6 +23,8 @@ const vibe = require('../rooms/vibe');
const how = require('../rooms/how');
const how = require('../rooms/how');
const memory = require('../rooms/vibe-memory');
const memory = require('../rooms/vibe-memory');
const { esc } = require('./layout');
const { esc } = require('./layout');
+const kit = require('openvibe-shared/kit');
+const thread = require('openvibe-shared/thread');
const SUGGESTIONS = [
const SUGGESTIONS = [
{ title: 'Catch me up', text: 'on today\'s tech news', prompt: 'What happened in tech news today? Give me the highlights with links.' },
{ title: 'Catch me up', text: 'on today\'s tech news', prompt: 'What happened in tech news today? Give me the highlights with links.' },
@@ -79,48 +81,52 @@ function snippet(text, q) {
@@ -79,48 +81,52 @@ function snippet(text, q) {
const MARK = '<span class="vb-mark" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 5l7 14 7-14"/></svg></span>';
const MARK = '<span class="vb-mark" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 5l7 14 7-14"/></svg></span>';
function rail({ chats, current, signedIn, allowance, query = '', here = null }) {
function rail({ chats, current, signedIn, allowance, query = '', here = null }) {
- const groups = groupChats(chats);
+ // The network's one sidebar (openvibe-shared kit.sidebar, plan L35; the conversation kit's, L55 C4): New chat, the
+ // search, Vibe's places as rows, then the chats by when they last moved. A drawer on a phone (kit-web.js).
// The search's own words are never written back into the page (a link could carry anyone's words): the box gets
// The search's own words are never written back into the page (a link could carry anyone's words): the box gets
- // them from the address in the browser, and the results say only what matched in the person's own chats.
- const found = query
- ? (chats.length
- ? `<p class="vb-group">Chats that match</p><ul class="vb-chats">${chats.map((c) => `<li><a class="vb-chat vb-hit" href="/vibe/${esc(c.slug)}"${c.slug === current ? ' aria-current="page"' : ''}><span>${esc(c.name)}</span>${c.hit ? `<small>${snippet(c.hit, query)}</small>` : ''}</a></li>`).join('')}</ul>`
- : '<p class="vb-rail-note">No chat matches your search.</p>') + '<p class="vb-rail-note"><a href="/vibe">All your chats</a></p>'
- : null;
- const list = !signedIn
- ? '<p class="vb-rail-note">Sign in to keep your chats. They are private to you.</p>'
- : found || (groups.length
- ? groups.map((g) => `<p class="vb-group">${esc(g.label)}</p><ul class="vb-chats">${g.chats.map((c) => `<li><a class="vb-chat" href="/vibe/${esc(c.slug)}"${c.slug === current ? ' aria-current="page"' : ''}>${esc(c.name)}</a></li>`).join('')}</ul>`).join('')
- : '<p class="vb-rail-note">Your chats with Vibe show up here.</p>');
+ // them from the address in the browser, and the results say only which of the person's own chats matched.
+ const item = (c) => ({ href: `/vibe/${c.slug}`, label: c.name, here: c.slug === current });
+ let sections;
+ if (!signedIn) sections = [{ title: '', items: [], empty: 'Sign in to keep your chats. They are private to you.' }];
+ else if (query) {
+ sections = chats.length ? [{ title: 'Chats that match', items: chats.map(item) }] : [{ title: '', items: [], empty: 'No chat matches your search.' }];
+ sections.push({ title: '', items: [{ href: '/vibe', label: 'All your chats' }] });
+ } else {
+ const groups = groupChats(chats);
+ sections = groups.length ? groups.map((g) => ({ title: g.label, items: g.chats.map(item) })) : [{ title: '', items: [], empty: 'Your chats with Vibe show up here.' }];
+ }
const left = allowance ? Math.max(0, allowance.per_day - allowance.used_today) : null;
const left = allowance ? Math.max(0, allowance.per_day - allowance.used_today) : null;
const own = allowance && allowance.engine && allowance.engine.own ? allowance.engine : null; // Vibe on their own model
const own = allowance && allowance.engine && allowance.engine.own ? allowance.engine : null; // Vibe on their own model
- return `<aside class="vb-rail" id="vb-rail" aria-label="Your chats with Vibe">
-<div class="vb-rail-top">
-<a class="vb-new" href="/vibe">${ICON.new}<span>New chat</span></a>
-<button class="vb-icon vb-rail-close" type="button" data-vb-close aria-label="Close the chat list">${ICON.close}</button>
-</div>
-${signedIn ? `<form class="vb-search" action="/vibe" method="get" role="search">${ICON.search}<label class="oc-sr" for="vb-q">Search your chats</label><input id="vb-q" name="q" type="search" maxlength="${vibe.SEARCH_MAX}" placeholder="Search chats" autocomplete="off" enterkeyhint="search"></form>` : ''}
-<nav class="vb-rail-list" aria-label="Chats with Vibe">${list}</nav>
-<nav class="vb-rail-more" aria-label="More of OpenVibe.Chat">
-<a href="https://openvibe.actor/tasks" title="Every task you gave an agent, one-off or recurring, with what it cost">${ICON.tasks}<span>Tasks</span></a>
-<a href="https://openvibe.actor/me/agents" title="Recurring work in plain words: an agent with standing instructions, a memory you can edit, a schedule and quiet hours">${ICON.agents}<span>Your agents</span></a>
-${signedIn ? `<a href="/vibe/memory" title="What Vibe remembers about you across your chats: see, add or delete each note"${here === 'memory' ? ' aria-current="page"' : ''}>${ICON.memory}<span>Memory</span></a>` : ''}
-<a href="https://openvibe.bot/machines" title="Your computers and robots: pair one, see its screen, set what agents may do on it">${ICON.computer}<span>Computers & robots</span></a>
-<a href="/work" title="A room where your team and Vibe work together: a work queue, alerts and a shared screen">${ICON.team}<span>Work together</span></a>
-<a href="/messages">${ICON.messages}<span>Messages</span></a>
-<a href="/rooms">${ICON.rooms}<span>Rooms and servers</span></a>
-<a href="/">${ICON.globe}<span>Global chat</span></a>
-</nav>
-${own ? `<p class="vb-allow" id="vb-allow" data-own="1">No daily limit: Vibe thinks with ${esc(own.you)}</p>` : left != null ? `<p class="vb-allow" id="vb-allow">${left} of ${allowance.per_day} answers left today</p>` : ''}
-</aside>`;
+ const allow = own ? `<p class="vb-allow" id="vb-allow" data-own="1">No daily limit: Vibe thinks with ${esc(own.you)}</p>`
+ : left != null ? `<p class="vb-allow" id="vb-allow">${left} of ${allowance.per_day} answers left today</p>` : '';
+ // Chat's other places, quietly at the foot (the network's navbar reaches them too).
+ const more = [['https://openvibe.bot/machines', 'Computers & robots'], ['/work', 'Work together'], ['/messages', 'Messages'], ['/rooms', 'Rooms and servers'], ['/', 'Global chat']];
+ const html = kit.sidebar({
+ id: 'vb-rail', label: 'Your chats with Vibe',
+ action: { href: '/vibe', label: 'New chat', icon: 'pencil' },
+ find: signedIn ? { action: '/vibe', name: 'q', placeholder: 'Search chats', label: 'Search your chats' } : null,
+ nav: [
+ { href: 'https://openvibe.actor/tasks', label: 'Tasks', icon: 'list' },
+ { href: 'https://openvibe.actor/me/agents', label: 'Your agents', icon: 'robot' },
+ ...(signedIn ? [{ href: '/vibe/memory', label: 'Memory', icon: 'book', here: here === 'memory' }] : []),
+ ],
+ sections,
+ foot: `<p class="ovt-side-foot">${more.map(([href, label]) => `<a href="${esc(href)}">${esc(label)}</a>`).join('')}</p>${allow}`,
+ });
+ if (!query) return html;
+ // A search: under each chat that matched, where it matched (the person's own words or Vibe's), the match marked.
+ return chats.reduce((out, c) => (c.hit ? out.replace(
+ new RegExp(`<a class="ovk-sidebar-item( is-here)?" href="/vibe/${c.slug}"([^>]*)><span class="ovk-sidebar-t">([^<]*)</span>`),
+ (m, here, rest, name) => `<a class="ovk-sidebar-item vb-hit${here || ''}" href="/vibe/${c.slug}"${rest}><span class="ovk-sidebar-t">${name}</span><small class="vb-hit-s">${snippet(c.hit, query)}</small>`,
+ ) : out), html);
}
}
/**
/**
- * Under each answer: Copy (shown by vibe.js; it needs the clipboard) and, under the answer to the person's last message,
- * Try again (a form, so it works without JavaScript too).
+ * Under each answer: Copy (shown by ov-thread.js where the clipboard works) and, under the answer to the person's last
+ * message, Try again (a form, so it works without JavaScript too).
*/
*/
function acts(slug) {
function acts(slug) {
- return `<div class="vb-acts"><button type="button" class="vb-act" data-vb-copy hidden>Copy</button>${slug ? `<form method="post" action="/vibe/${esc(slug)}/retry" class="vb-retry"><button type="submit" class="vb-act">Try again</button></form>` : ''}</div>`;
+ return `<div class="ovt-meta"><span class="ovt-acts"><button type="button" class="ovt-act" data-ovt-copy hidden>${thread.icon('copy')}<span>Copy</span></button>${slug ? `<form method="post" action="/vibe/${esc(slug)}/retry" class="vb-retry"><button type="submit" class="ovt-act">Try again</button></form>` : ''}</span></div>`;
}
}
const RES_ICON = {
const RES_ICON = {
@@ -146,14 +152,36 @@ function resultChips(m, slug) {
@@ -146,14 +152,36 @@ function resultChips(m, slug) {
}).join('')}</div>`;
}).join('')}</div>`;
}
}
-function turn(m, me, hows = new Map(), retrySlug = null, slug = null) {
- const ai = !!m.ai_member_id;
- if (ai) {
- const info = hows.get(Number(m.id));
- return `<li class="vb-turn vb-ai" data-id="${Number(m.id) || 0}">${MARK}<div class="vb-body"><p class="oc-sr">Vibe said:</p><div class="vb-md">${md.render(m.message)}</div>${resultChips(m, slug)}${how.chips(info, m.id)}${how.html(info, m.id)}${acts(retrySlug)}</div></li>`;
- }
+/** What the person said, for their bubble: their words (the bubble keeps line breaks) and any picture. */
+function youOf(m, me) {
const mine = me && Number(m.user_id) === Number(me.id);
const mine = me && Number(m.user_id) === Number(me.id);
- return `<li class="vb-turn vb-me" data-id="${Number(m.id) || 0}"><div class="vb-bubble"><p class="oc-sr">${mine ? 'You said:' : `${esc(m.display_name || m.username)} said:`}</p>${esc(m.message).replace(/\n/g, '<br>')}${pictures.pageHtml(m.message)}</div></li>`;
+ return { html: `<span class="oc-sr">${mine ? 'You said:' : `${esc(m.display_name || m.username)} said:`}</span>${esc(m.message)}${pictures.pageHtml(m.message)}` };
+}
+/** Vibe's answer: the rendered Markdown, what it did (reminders, timers), its sources and how, then Copy and Try again. */
+function answerOf(m, hows, retrySlug, slug) {
+ const info = hows.get(Number(m.id));
+ return `<p class="oc-sr">Vibe said:</p>${thread.answer({ html: md.render(m.message), copy: false })}${resultChips(m, slug)}${how.chips(info, m.id)}${how.html(info, m.id)}${acts(retrySlug)}`;
+}
+/** Vibe writing: the live line, open (ov-thread.js and vibe.js follow it). */
+const WRITING = thread.work({ open: true, live: 'Vibe is writing' });
+/**
+ * The chat as turns: each message of the person's with the answer that follows it. data-me / data-ai carry the two
+ * message ids, so a message that arrives over the socket is never drawn twice.
+ */
+function turnsOf(messages, me, hows, { retrySlug = null, slug = null, waiting = false } = {}) {
+ const specs = [];
+ for (const [i, m] of messages.entries()) {
+ const last = i === messages.length - 1;
+ if (m.ai_member_id) {
+ const reply = answerOf(m, hows, last ? retrySlug : null, slug);
+ const open = specs[specs.length - 1];
+ if (open && open.reply == null) { open.reply = reply; open.ai = m.id; }
+ else specs.push({ reply, ai: m.id });
+ } else specs.push({ you: youOf(m, me), me: m.id, reply: null });
+ }
+ const tail = specs[specs.length - 1];
+ if (waiting && tail && tail.reply == null) { tail.reply = WRITING; tail.open = true; }
+ return specs.map((t) => thread.turn({ you: t.you || {}, reply: t.reply, open: !!t.open, attrs: { 'data-me': t.me, 'data-ai': t.ai } }));
}
}
// The composer's modes (plan track K): Chat answers here and now; the others are agent tasks that answer here when done.
// The composer's modes (plan track K): Chat answers here and now; the others are agent tasks that answer here when done.
@@ -174,18 +202,23 @@ function composer({ slug, signedIn, enabled, prefill = '', engine = null }) {
@@ -174,18 +202,23 @@ function composer({ slug, signedIn, enabled, prefill = '', engine = null }) {
const pics = pictures.enabled();
const pics = pictures.enabled();
const label = pics ? 'Add a picture or a text file' : 'Add a text file';
const label = pics ? 'Add a picture or a text file' : 'Add a text file';
const attach = signedIn && enabled
const attach = signedIn && enabled
- ? `<input type="file" id="vb-file" accept="${pics ? 'image/jpeg,image/png,image/gif,image/webp,image/avif,' : ''}${TEXT_FILES}" hidden><button class="vb-icon vb-attach" type="button" data-vb-attach aria-label="${label}" title="${label}">${ICON.attach}</button>`
+ ? `<input type="file" id="vb-file" accept="${pics ? 'image/jpeg,image/png,image/gif,image/webp,image/avif,' : ''}${TEXT_FILES}" hidden><button class="ovt-btn vb-attach" type="button" data-vb-attach aria-label="${label}" title="${label}">${ICON.attach}</button>`
: '';
: '';
- return `<div class="vb-preview" id="vb-preview" hidden></div>
-<form class="vb-composer" id="vb-composer" method="${signedIn ? 'post' : 'get'}" action="${action}"${signedIn ? '' : ' data-signin="1"'}>
-${signedIn ? '' : `<input type="hidden" name="next" value="${slug ? `/vibe/${esc(slug)}` : (prefill ? `/vibe?ask=${encodeURIComponent(prefill)}` : '/vibe')}">`}
-${attach}
-<label class="oc-sr" for="vb-input">Message Vibe</label>
-<textarea id="vb-input" name="message" rows="${prefill ? 3 : 1}" maxlength="12000" placeholder="${signedIn ? 'Message Vibe' : 'Message Vibe (you will sign in first)'}"${enabled ? '' : ' disabled'}${prefill ? ' autofocus' : ''} required>${esc(prefill)}</textarea>
-<button class="vb-send" type="submit" aria-label="Send"${enabled ? '' : ' disabled'}>${ICON.send}</button>
-<fieldset class="vb-modes" aria-label="What Vibe does with it">${MODES.map(([value, label, hint, placeholder], i) => `<label title="${esc(hint)}"><input type="radio" name="mode" value="${value}" data-placeholder="${esc(placeholder)}"${i ? '' : ' checked'}><span>${label}</span></label>`).join('')}</fieldset>
-</form>
-<p class="vb-fine">${!enabled ? 'Vibe is resting right now. Try again later.' : engine && engine.own ? `Vibe thinks with ${esc(engine.you)} (<a href="/settings#oc-ai-key">change</a>). It can get things wrong: check what matters.` : 'Vibe can get things wrong. Check what matters.'}</p>`;
+ const modes = `<fieldset class="vb-modes" aria-label="What Vibe does with it">${MODES.map(([value, label, hint, placeholder], i) => `<label title="${esc(hint)}"><input type="radio" name="mode" value="${value}" data-placeholder="${esc(placeholder)}"${i ? '' : ' checked'}><span>${label}</span></label>`).join('')}</fieldset>`;
+ const fine = !enabled ? 'Vibe is resting right now. Try again later.' : engine && engine.own ? `Vibe thinks with ${esc(engine.you)} (<a href="/settings#oc-ai-key">change</a>). It can get things wrong: check what matters.` : 'Vibe can get things wrong. Check what matters.';
+ let html = thread.composer({
+ action, method: signedIn ? 'post' : 'get', name: 'message', id: 'vb-composer', attrs: signedIn ? {} : { 'data-signin': '1' },
+ label: 'Message Vibe', placeholder: signedIn ? 'Message Vibe' : 'Message Vibe (you will sign in first)', value: prefill, maxlength: 12000,
+ hidden: signedIn ? {} : { next: slug ? `/vibe/${slug}` : (prefill ? `/vibe?ask=${encodeURIComponent(prefill)}` : '/vibe') },
+ top: '<div class="vb-preview" id="vb-preview" hidden></div>', tools: attach + modes,
+ // Stop is drawn hidden: vibe.js shows it while Vibe writes, and it keeps what was written so far.
+ stopAction: signedIn ? '' : null,
+ foot: `<p class="vb-fine">${fine}</p>`,
+ });
+ // A box filled from "Ask Vibe" is where the cursor goes, script or not.
+ if (prefill) html = html.replace('data-ovt-text', 'data-ovt-text autofocus');
+ if (!enabled) html = html.replace('data-ovt-text', 'data-ovt-text disabled').replace('data-ovt-send aria-label', 'data-ovt-send disabled aria-label');
+ return html;
}
}
function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit, notice }) {
function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit, notice }) {
@@ -232,31 +265,25 @@ ${MARK}
@@ -232,31 +265,25 @@ ${MARK}
<p class="vb-sub">OpenVibe's assistant. I search the web and all of OpenVibe (news, deals, jobs, rentals, videos), read the pages and pictures you share, remember what you ask me to, and set up work that runs every day for you.</p>
<p class="vb-sub">OpenVibe's assistant. I search the web and all of OpenVibe (news, deals, jobs, rentals, videos), read the pages and pictures you share, remember what you ask me to, and set up work that runs every day for you.</p>
<ul class="vb-suggest">${SUGGESTIONS.map((s) => `<li><button type="button" class="vb-chip" data-prompt="${esc(s.prompt)}"><b>${esc(s.title)}</b><span>${esc(s.text)}</span></button></li>`).join('')}</ul>
<ul class="vb-suggest">${SUGGESTIONS.map((s) => `<li><button type="button" class="vb-chip" data-prompt="${esc(s.prompt)}"><b>${esc(s.title)}</b><span>${esc(s.text)}</span></button></li>`).join('')}</ul>
</div>`;
</div>`;
- const thread = room
- ? `<ol class="vb-thread" id="vb-thread">${messages.map((m, i) => turn(m, user, hows, i === messages.length - 1 && retryable ? room.slug : null, room.slug)).join('')}${waiting ? `<li class="vb-turn vb-ai vb-wait" id="vb-wait">${MARK}<div class="vb-body"><span class="vb-dots" aria-label="Vibe is writing"><i></i><i></i><i></i></span><noscript><a href="/vibe/${esc(room.slug)}">Refresh to see Vibe's answer</a></noscript></div></li>` : ''}</ol>`
- : '';
- const head = `<header class="vb-head">
-<button class="vb-icon vb-menu" type="button" data-vb-open aria-controls="vb-rail" aria-expanded="false" aria-label="Show your chats">${ICON.menu}</button>
-${room ? `<h1 class="vb-title" id="vb-title">${esc(title)}</h1>` : `<p class="vb-title" id="vb-title">${esc(title)}</p>`}
-${room ? `<details class="vb-actions"><summary class="vb-icon" aria-label="Chat options">${ICON.more}</summary><div class="vb-pop">
+ const head = thread.head({
+ title: room ? title : '', sidebar: 'vb-rail', sidebarLabel: 'Show your chats',
+ actions: room ? `<details class="vb-actions"><summary class="ovt-btn" aria-label="Chat options">${ICON.more}</summary><div class="vb-pop">
<form method="post" action="/vibe/${esc(room.slug)}/rename" class="vb-rename"><label for="vb-name">Name</label><input id="vb-name" name="name" maxlength="${vibe.TITLE_MAX}" value="${esc(room.name)}" required><button type="submit">Rename</button></form>
<form method="post" action="/vibe/${esc(room.slug)}/rename" class="vb-rename"><label for="vb-name">Name</label><input id="vb-name" name="name" maxlength="${vibe.TITLE_MAX}" value="${esc(room.name)}" required><button type="submit">Rename</button></form>
<form method="post" action="/vibe/${esc(room.slug)}/delete" data-vb-delete><button type="submit" class="vb-danger">Delete this chat</button></form>
<form method="post" action="/vibe/${esc(room.slug)}/delete" data-vb-delete><button type="submit" class="vb-danger">Delete this chat</button></form>
-</div></details>` : `<a class="vb-icon vb-head-new" href="/vibe" aria-label="New chat">${ICON.new}</a>`}
-</header>`;
- const body = `<div class="vb${room ? '' : ' vb-empty'}" id="vb">
-${rail({ chats, current: room && room.slug, signedIn: !!user, allowance, query: searching ? query : '' })}
-<div class="vb-scrim" data-vb-close hidden></div>
-<section class="vb-main" aria-label="${room ? 'Your chat with Vibe' : 'A new chat with Vibe'}">
-${head}
-<div class="vb-scroll" id="vb-scroll">
-${error ? notice('error', error) : ''}
-${room ? thread : hello}
-</div>
-<div class="vb-dock">${composer({ slug: room && room.slug, signedIn: !!user, enabled, prefill: room ? '' : prefillOf(req.query || {}), engine: allowance && allowance.engine })}</div>
-</section>
-</div>`;
+</div></details>` : '',
+ });
+ const turns = room ? turnsOf(messages, user, hows, { retrySlug: retryable ? room.slug : null, slug: room.slug, waiting: !!waiting }) : [];
+ const problem = error ? notice('error', error) : '';
+ const body = `<div class="vbk" id="vb">${thread.page({
+ sidebar: rail({ chats, current: room && room.slug, signedIn: !!user, allowance, query: searching ? query : '' }),
+ head,
+ turns: problem && room ? [thread.turn({ avatar: false, reply: problem }), ...turns] : turns,
+ empty: problem + hello,
+ composer: composer({ slug: room && room.slug, signedIn: !!user, enabled, prefill: room ? '' : prefillOf(req.query || {}), engine: allowance && allowance.engine }),
+ center: !room, label: room ? 'Your chat with Vibe' : 'A new chat with Vibe',
+ })}</div>`;
await page(req, res, status, {
await page(req, res, status, {
- actor, app: true, active: 'vibe', path: room ? `/vibe/${room.slug}` : '/vibe',
+ actor, app: true, kit: true, active: 'vibe', path: room ? `/vibe/${room.slug}` : '/vibe',
title: room ? `${room.name} · Vibe` : 'Vibe, OpenVibe\'s assistant',
title: room ? `${room.name} · Vibe` : 'Vibe, OpenVibe\'s assistant',
fullTitle: room ? undefined : 'Vibe: chat with OpenVibe\'s assistant · OpenVibe.Chat',
fullTitle: room ? undefined : 'Vibe: chat with OpenVibe\'s assistant · OpenVibe.Chat',
description: 'Chat with Vibe, OpenVibe\'s free assistant: it searches news, deals, jobs, rentals and videos across OpenVibe, reads pages you link and helps you think.',
description: 'Chat with Vibe, OpenVibe\'s free assistant: it searches news, deals, jobs, rentals and videos across OpenVibe, reads pages you link and helps you think.',
@@ -276,18 +303,11 @@ ${room ? thread : hello}
@@ -276,18 +303,11 @@ ${room ? thread : hello}
async function renderMemory(req, res, actor, { status = 200, error = null, draft = '' } = {}) {
async function renderMemory(req, res, actor, { status = 200, error = null, draft = '' } = {}) {
const user = actor.user;
const user = actor.user;
const [notes, on, chats, allowance] = await Promise.all([memory.list(user), memory.isOn(user), vibe.list(user), allowanceOf(user)]);
const [notes, on, chats, allowance] = await Promise.all([memory.list(user), memory.isOn(user), vibe.list(user), allowanceOf(user)]);
- const body = `<div class="vb" id="vb">
-${rail({ chats, current: null, signedIn: true, allowance, here: 'memory' })}
-<div class="vb-scrim" data-vb-close hidden></div>
-<section class="vb-main" aria-label="What Vibe remembers about you">
-<header class="vb-head">
-<button class="vb-icon vb-menu" type="button" data-vb-open aria-controls="vb-rail" aria-expanded="false" aria-label="Show your chats">${ICON.menu}</button>
-<p class="vb-title" id="vb-title">Memory</p>
-<a class="vb-icon vb-head-new" href="/vibe" aria-label="New chat">${ICON.new}</a>
-</header>
-<div class="vb-scroll" id="vb-scroll">
-${error ? notice('error', error) : ''}
-<div class="vb-page">
+ const body = `<div class="vbk" id="vb">${thread.page({
+ sidebar: rail({ chats, current: null, signedIn: true, allowance, here: 'memory' }),
+ head: thread.head({ title: '', sidebar: 'vb-rail', sidebarLabel: 'Show your chats', newHref: '/vibe' }),
+ label: 'What Vibe remembers about you', cls: 'is-page',
+ main: `${error ? notice('error', error) : ''}<div class="vb-page">
<h1>What Vibe remembers about you</h1>
<h1>What Vibe remembers about you</h1>
<p class="vb-sub">Short notes Vibe keeps across your chats with it: what you asked it to remember, or a preference you told it. Only your own chats with Vibe read them, never a room it was added to. It never keeps passwords, keys or card numbers.</p>
<p class="vb-sub">Short notes Vibe keeps across your chats with it: what you asked it to remember, or a preference you told it. Only your own chats with Vibe read them, never a room it was added to. It never keeps passwords, keys or card numbers.</p>
<form class="vb-mem-switch" method="post" action="/vibe/memory/switch">
<form class="vb-mem-switch" method="post" action="/vibe/memory/switch">
@@ -304,12 +324,10 @@ ${notes.length ? `<h2 class="vb-group">${notes.length} of ${memory.MAX_NOTES} no
@@ -304,12 +324,10 @@ ${notes.length ? `<h2 class="vb-group">${notes.length} of ${memory.MAX_NOTES} no
<form method="post" action="/vibe/memory/${n.id}/delete"><button type="submit" class="vb-btn vb-btn-quiet" aria-label="Delete the note: ${esc(n.text.slice(0, 60))}">Delete</button></form></li>`).join('')}</ul>
<form method="post" action="/vibe/memory/${n.id}/delete"><button type="submit" class="vb-btn vb-btn-quiet" aria-label="Delete the note: ${esc(n.text.slice(0, 60))}">Delete</button></form></li>`).join('')}</ul>
<form method="post" action="/vibe/memory/clear" data-vb-confirm="Delete every note? Vibe forgets them all."><button type="submit" class="vb-btn vb-danger">Forget everything</button></form>`
<form method="post" action="/vibe/memory/clear" data-vb-confirm="Delete every note? Vibe forgets them all."><button type="submit" class="vb-btn vb-danger">Forget everything</button></form>`
: '<p class="vb-rail-note">Nothing yet. Tell Vibe “remember that I…” in a chat, or add a note above.</p>'}
: '<p class="vb-rail-note">Nothing yet. Tell Vibe “remember that I…” in a chat, or add a note above.</p>'}
-</div>
-</div>
-</section>
-</div>`;
+</div>`,
+ })}</div>`;
await page(req, res, status, {
await page(req, res, status, {
- actor, app: true, active: 'vibe', path: '/vibe/memory', title: 'Memory · Vibe', robots: 'noindex, nofollow', body,
+ actor, app: true, kit: true, active: 'vibe', path: '/vibe/memory', title: 'Memory · Vibe', robots: 'noindex, nofollow', body,
page: { view: 'vibe', memory: true, room: null, me: user.id, latest: 0, waiting: false, enabled: vibe.enabled(), signedIn: true },
page: { view: 'vibe', memory: true, room: null, me: user.id, latest: 0, waiting: false, enabled: vibe.enabled(), signedIn: true },
});
});
}
}
@@ -25,7 +25,7 @@ t('the front page leads to Vibe: the first action, an Ask Vibe box, the question
@@ -25,7 +25,7 @@ t('the front page leads to Vibe: the first action, an Ask Vibe box, the question
t('the box opens Vibe with the question in it, signed out too', async () => {
t('the box opens Vibe with the question in it, signed out too', async () => {
const vibe = await fetch(`${h.base}/vibe?ask=${encodeURIComponent('Plan a picnic for six')}`).then((r) => r.text());
const vibe = await fetch(`${h.base}/vibe?ask=${encodeURIComponent('Plan a picnic for six')}`).then((r) => r.text());
- assert.match(vibe, /<textarea id="vb-input"[^>]*>Plan a picnic for six<\/textarea>/);
+ assert.match(vibe, /<textarea id="ovt-text"[^>]*>Plan a picnic for six<\/textarea>/);
});
});
t.run(async () => { if (h && h.close) await h.close(); });
t.run(async () => { if (h && h.close) await h.close(); });
@@ -48,7 +48,7 @@ t('everywhere else the limits stay: 2000 characters, spam refused', async () =>
@@ -48,7 +48,7 @@ t('everywhere else the limits stay: 2000 characters, spam refused', async () =>
t('the composer takes 12000 characters and the attach button takes text files', async () => {
t('the composer takes 12000 characters and the attach button takes text files', async () => {
const page = await fetch(`${h.base}/vibe`, { headers: { cookie: `ov_token=${ann.token}` } }).then((x) => x.text());
const page = await fetch(`${h.base}/vibe`, { headers: { cookie: `ov_token=${ann.token}` } }).then((x) => x.text());
- assert.match(page, /<textarea id="vb-input" name="message" rows="1" maxlength="12000"/);
+ assert.match(page, /<textarea id="ovt-text" name="message" rows="1"[^>]*maxlength="12000"/);
assert.match(page, /<input type="file" id="vb-file" accept="[^"]*text\/plain[^"]*\.csv[^"]*\.py[^"]*" hidden>/);
assert.match(page, /<input type="file" id="vb-file" accept="[^"]*text\/plain[^"]*\.csv[^"]*\.py[^"]*" hidden>/);
assert.match(page, /aria-label="Add (a picture or )?a text file"/);
assert.match(page, /aria-label="Add (a picture or )?a text file"/);
});
});
@@ -166,7 +166,7 @@ t('the page: the notes with Delete, add, switch, forget everything; signed out a
@@ -166,7 +166,7 @@ t('the page: the notes with Delete, add, switch, forget everything; signed out a
assert.match(page.text, /<h1>What Vibe remembers about you<\/h1>/);
assert.match(page.text, /<h1>What Vibe remembers about you<\/h1>/);
assert.match(page.text, /<b>Memory is on\.<\/b>/);
assert.match(page.text, /<b>Memory is on\.<\/b>/);
assert.match(page.text, /<li><p>Uses kilometres<\/p><small>Added by you · /);
assert.match(page.text, /<li><p>Uses kilometres<\/p><small>Added by you · /);
- assert.match(page.text, /<a href="\/vibe\/memory" title="[^"]+" aria-current="page">/, 'the rail marks Memory');
+ assert.match(page.text, /<a class="ovk-sidebar-link is-here" href="\/vibe\/memory" aria-current="page">/, 'the rail marks Memory');
assert.match(page.text, /noindex, nofollow/);
assert.match(page.text, /noindex, nofollow/);
const added = await web('POST', '/vibe/memory', ann, { text: 'Prefers short answers' });
const added = await web('POST', '/vibe/memory', ann, { text: 'Prefers short answers' });
assert.deepStrictEqual([added.status, added.location], [303, '/vibe/memory']);
assert.deepStrictEqual([added.status, added.location], [303, '/vibe/memory']);
@@ -185,7 +185,7 @@ t('the page: the notes with Delete, add, switch, forget everything; signed out a
@@ -185,7 +185,7 @@ t('the page: the notes with Delete, add, switch, forget everything; signed out a
await web('POST', '/vibe/memory/clear', ann, {});
await web('POST', '/vibe/memory/clear', ann, {});
assert.deepStrictEqual(await memory.list(userOf(ann)), []);
assert.deepStrictEqual(await memory.list(userOf(ann)), []);
assert.match((await web('GET', '/vibe/memory', ann)).text, /Nothing yet/);
assert.match((await web('GET', '/vibe/memory', ann)).text, /Nothing yet/);
- assert.match((await web('GET', '/vibe', ann)).text, /<a href="\/vibe\/memory" title="What Vibe remembers about you/, 'the rail links it from Vibe');
+ assert.match((await web('GET', '/vibe', ann)).text, /<a class="ovk-sidebar-link" href="\/vibe\/memory">/, 'the rail links it from Vibe');
assert.ok(!(await web('GET', '/vibe', null)).text.includes('href="/vibe/memory"'), 'signed out: no Memory link');
assert.ok(!(await web('GET', '/vibe', null)).text.includes('href="/vibe/memory"'), 'signed out: no Memory link');
});
});
@@ -74,8 +74,8 @@ t('the page offers Try again under the last answer only, and Copy (shown by the
@@ -74,8 +74,8 @@ t('the page offers Try again under the last answer only, and Copy (shown by the
await answers(s2, 2);
await answers(s2, 2);
const page = await web('GET', `/vibe/${s2}`, ann);
const page = await web('GET', `/vibe/${s2}`, ann);
assert.strictEqual((page.text.match(/class="vb-retry"/g) || []).length, 1, 'one Try again');
assert.strictEqual((page.text.match(/class="vb-retry"/g) || []).length, 1, 'one Try again');
- assert.match(page.text, /<button type="button" class="vb-act" data-vb-copy hidden>Copy<\/button><form method="post" action="\/vibe\/v-[0-9a-f]{12}\/retry" class="vb-retry"><button type="submit" class="vb-act">Try again<\/button><\/form><\/div><\/div><\/li><\/ol>/, 'under the last answer');
- assert.strictEqual((page.text.match(/data-vb-copy hidden/g) || []).length, 2, 'Copy under each answer');
+ assert.match(page.text, /data-ovt-copy hidden>[\s\S]{0,400}?<span>Copy<\/span><\/button><form method="post" action="\/vibe\/v-[0-9a-f]{12}\/retry" class="vb-retry"><button type="submit" class="ovt-act">Try again<\/button><\/form><\/span><\/div><\/div><\/div>\s*<\/li><\/ol>/, 'under the last answer');
+ assert.strictEqual((page.text.match(/data-ovt-copy hidden/g) || []).length, 2, 'Copy under each answer');
const form = await web('POST', `/vibe/${s2}/retry`, ann, {});
const form = await web('POST', `/vibe/${s2}/retry`, ann, {});
assert.deepStrictEqual([form.status, form.location], [303, `/vibe/${s2}`]);
assert.deepStrictEqual([form.status, form.location], [303, `/vibe/${s2}`]);
});
});
@@ -148,12 +148,12 @@ t('the app page: signed out it invites a sign-in, signed in it lists the chats a
@@ -148,12 +148,12 @@ t('the app page: signed out it invites a sign-in, signed in it lists the chats a
const signed = await web('GET', '/vibe', ann);
const signed = await web('GET', '/vibe', ann);
assert.ok(signed.text.includes('What can I help with, Ann?'));
assert.ok(signed.text.includes('What can I help with, Ann?'));
assert.strictEqual((signed.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1 on a new chat: the greeting (the header names it in a <p>)');
assert.strictEqual((signed.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1 on a new chat: the greeting (the header names it in a <p>)');
- assert.ok(signed.text.includes('class="vb-chat" href="/vibe/v-'), 'the rail lists their chats');
+ assert.ok(signed.text.includes('class="ovk-sidebar-item" href="/vibe/v-'), 'the rail lists their chats (the network\'s one sidebar, kit.sidebar)');
// The rail leads to the rest of the person's agent: their tasks, their computers and robots, team workspaces.
// The rail leads to the rest of the person's agent: their tasks, their computers and robots, team workspaces.
assert.ok(/href="https:\/\/openvibe\.actor\/tasks"[^>]*>[\s\S]*?<span>Tasks<\/span>/.test(signed.text), 'Tasks');
assert.ok(/href="https:\/\/openvibe\.actor\/tasks"[^>]*>[\s\S]*?<span>Tasks<\/span>/.test(signed.text), 'Tasks');
assert.ok(/href="https:\/\/openvibe\.actor\/me\/agents"[^>]*>[\s\S]*?<span>Your agents<\/span>/.test(signed.text), 'Your agents: recurring work');
assert.ok(/href="https:\/\/openvibe\.actor\/me\/agents"[^>]*>[\s\S]*?<span>Your agents<\/span>/.test(signed.text), 'Your agents: recurring work');
- assert.ok(/href="https:\/\/openvibe\.bot\/machines"[^>]*>[\s\S]*?<span>Computers & robots<\/span>/.test(signed.text), 'Computers & robots');
- assert.ok(/href="\/work"[^>]*>[\s\S]*?<span>Work together<\/span>/.test(signed.text), 'Work together');
+ assert.ok(signed.text.includes('<a href="https://openvibe.bot/machines">Computers & robots</a>'), 'Computers & robots, at the foot');
+ assert.ok(signed.text.includes('<a href="/work">Work together</a>'), 'Work together, at the foot');
const form = await web('POST', '/vibe', ann, { message: 'hello from a form' });
const form = await web('POST', '/vibe', ann, { message: 'hello from a form' });
assert.strictEqual(form.status, 303);
assert.strictEqual(form.status, 303);
assert.ok(/^\/vibe\/v-[0-9a-f]{12}$/.test(form.location), form.location);
assert.ok(/^\/vibe\/v-[0-9a-f]{12}$/.test(form.location), form.location);
@@ -166,9 +166,9 @@ t('the app page: signed out it invites a sign-in, signed in it lists the chats a
@@ -166,9 +166,9 @@ t('the app page: signed out it invites a sign-in, signed in it lists the chats a
t('Ask Vibe from any OpenVibe site: the selection and the page fill a new chat\'s box, never send it', async () => {
t('Ask Vibe from any OpenVibe site: the selection and the page fill a new chat\'s box, never send it', async () => {
let r = await web('GET', `/vibe?ask=${encodeURIComponent('What is <b>WHIP</b>?')}&from=${encodeURIComponent('https://openvibe.live/docs/whip#top')}`, ann);
let r = await web('GET', `/vibe?ask=${encodeURIComponent('What is <b>WHIP</b>?')}&from=${encodeURIComponent('https://openvibe.live/docs/whip#top')}`, ann);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.match(r.text, /<textarea id="vb-input"[^>]*autofocus[^>]*>About this, on https:\/\/openvibe\.live\/docs\/whip:\n\nWhat is <b>WHIP<\/b>\?<\/textarea>/);
+ assert.match(r.text, /<textarea id="ovt-text"[^>]*autofocus[^>]*>About this, on https:\/\/openvibe\.live\/docs\/whip:\n\nWhat is <b>WHIP<\/b>\?<\/textarea>/);
r = await web('GET', `/vibe?from=${encodeURIComponent('https://evil.test/x')}`, ann);
r = await web('GET', `/vibe?from=${encodeURIComponent('https://evil.test/x')}`, ann);
- assert.match(r.text, /<textarea id="vb-input"[^>]*><\/textarea>/, 'only an OpenVibe site\'s address is taken');
+ assert.match(r.text, /<textarea id="ovt-text"[^>]*><\/textarea>/, 'only an OpenVibe site\'s address is taken');
r = await web('GET', `/vibe?ask=${encodeURIComponent('hello')}`);
r = await web('GET', `/vibe?ask=${encodeURIComponent('hello')}`);
assert.match(r.text, /name="next" value="\/vibe\?ask=hello"/, 'signed out, the question survives the sign-in');
assert.match(r.text, /name="next" value="\/vibe\?ask=hello"/, 'signed out, the question survives the sign-in');
});
});
@@ -178,12 +178,12 @@ t('search chats: the rail\'s box finds a chat by its name or by any message in i
@@ -178,12 +178,12 @@ t('search chats: the rail\'s box finds a chat by its name or by any message in i
assert.strictEqual(made.status, 201, made.text);
assert.strictEqual(made.status, 201, made.text);
await answers(made.body.room.slug, 1); // Vibe's answer lists rice and lentils
await answers(made.body.room.slug, 1); // Vibe's answer lists rice and lentils
const signed = await web('GET', '/vibe', ann);
const signed = await web('GET', '/vibe', ann);
- assert.match(signed.text, /<form class="vb-search" action="\/vibe" method="get" role="search">/);
- assert.ok(!(await web('GET', '/vibe')).text.includes('vb-search'), 'signed out, there is nothing to search');
+ assert.match(signed.text, /<form class="ovk-sidebar-find" role="search" method="get" action="\/vibe">/);
+ assert.ok(!(await web('GET', '/vibe')).text.includes('ovk-sidebar-find'), 'signed out, there is nothing to search');
let r = await web('GET', '/vibe?q=BICYCLE', ann);
let r = await web('GET', '/vibe?q=BICYCLE', ann);
- assert.ok(r.text.includes('<p class="vb-group">Chats that match</p>'));
+ assert.ok(r.text.includes('<h2 class="ovk-sidebar-h">Chats that match</h2>'));
assert.ok(!r.text.includes('BICYCLE'), 'the search\'s own words are never written back into the page');
assert.ok(!r.text.includes('BICYCLE'), 'the search\'s own words are never written back into the page');
- assert.match(r.text, new RegExp(`class="vb-chat vb-hit" href="/vibe/${made.body.room.slug}"><span>Find me a cheap blue bicycle near the…</span><small>Find me a cheap blue <mark>bicycle</mark> near the river</small>`));
+ assert.match(r.text, new RegExp(`class="ovk-sidebar-item vb-hit" href="/vibe/${made.body.room.slug}"><span class="ovk-sidebar-t">Find me a cheap blue bicycle near the…</span><small class="vb-hit-s">Find me a cheap blue <mark>bicycle</mark> near the river</small>`));
r = await web('GET', '/vibe?q=lentils', ann);
r = await web('GET', '/vibe?q=lentils', ann);
assert.ok(r.text.includes('<mark>lentils</mark>'), 'a match in Vibe\'s answer counts');
assert.ok(r.text.includes('<mark>lentils</mark>'), 'a match in Vibe\'s answer counts');
assert.ok((await web('GET', '/vibe?q=100%25', ann)).text.includes('No chat matches your search.'), '% is a character, not a wildcard');
assert.ok((await web('GET', '/vibe?q=100%25', ann)).text.includes('No chat matches your search.'), '% is a character, not a wildcard');