openvibers/openvibe.chat Commits
Copy and Try again under Vibe's answers: Copy takes the whole answer or one code block; Try again replaces the answer to your last message with a new one (counted like any answer; a form without JavaScript) (#185)
Changes
@@@ -141,6 +141,25 @@ body.oc-app-body { overflow: hidden; }/* An answer as it is written (vibe.js streamText): the text, then the dots, small. */.vb-wait .vb-streaming + .vb-dots { margin-top: 6px; transform: scale(.8); transform-origin: left center; }++/* Under an answer: Copy and Try again, quiet until hovered; a code block's own Copy in its corner. */++.vb-acts { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }++.vb-acts form { display: contents; }++.vb-act {++ padding: 4px 10px; border-radius: 8px; border: 1px solid transparent; background: transparent; color: var(--oc-muted);++ font: inherit; font-size: .8rem; cursor: pointer;++}++.vb-act:hover { color: var(--oc-text); border-color: var(--oc-line); background: var(--oc-panel-2); }++.vb-act:focus-visible, .vb-copy-code:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 1px; }++.vb-act.is-done, .vb-copy-code.is-done { color: var(--oc-green, var(--oc-accent)); }++.vb-md pre { position: relative; }++.vb-copy-code {++ position: absolute; top: 6px; right: 6px; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--oc-line);++ background: var(--oc-panel); color: var(--oc-muted); font: inherit; font-size: .75rem; cursor: pointer;++}++.vb-copy-code:hover { color: var(--oc-text); }++.vb-copy-code::before { content: 'Copy'; }++.vb-copy-code.is-done::before { content: 'Copied'; }++/* ── A new chat ───────────────────────────────────────────────────────────── */.vb-hello { max-width: var(--vb-col); margin: 0 auto; min-height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 24px 0 8px; }.vb-hello .vb-mark { width: 52px; height: 52px; margin-bottom: 14px; }@@@ -132,8 +132,42 @@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);++ // 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);++ }++ 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);++ });++ }// "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) {if (!room) return;@@@ -357,6 +391,27 @@.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');++ 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();++ 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 top@@@ -12,6 +12,8 @@* answered in the chat (server/rooms/how.js)* PATCH /:slug { name } → { room }* DELETE /:slug 204: the chat and its messages are deleted++ * POST /:slug/retry 202 { removed }: Vibe's last answer goes and it answers the person's last message anew (409++ * when the last two messages are not that exchange)* GET /memory { on, notes: [{ id, text, source, created_at }], max }: what Vibe remembers about the person* (server/rooms/vibe-memory.js)* POST /memory { text } → 201 { note } (a note of their own), 422 when it is empty, too long, secret-looking,@@@ -126,6 +128,14 @@ router.get('/:slug/how', requireAuth, personOnly, async (req, res) => {} catch (err) { return send(res, err); }});++router.post('/:slug/retry', requireAuth, personOnly, startLimit, async (req, res) => {++ try {++ const room = await vibe.ownChat(req.params.slug, req.user);++ if (!room) return res.status(404).json({ error: 'No such chat', code: 'rooms.not_found' });++ res.status(202).json(await vibe.retry(room, req.user));++ } catch (err) { send(res, err); }++});++router.patch('/:slug', requireAuth, personOnly, manageLimit, async (req, res) => {try {const room = await vibe.ownChat(req.params.slug, req.user);@@@ -14,6 +14,8 @@* ownChat(slug, user) → the room when it is one of `user`'s Vibe chats, else null* rename(room, user, name) → the room* remove(room, user) → the chat and its messages are deleted (not archived: it was the person's own)++ * retry(room, user) → { removed }: "Try again": Vibe's last answer goes and it answers the person's last++ * message anew (in the background, like any answer; it counts against the allowance)* repliesToday(user) / countReply(user) the person's answers from Vibe this UTC day, across all their chats* work(room, user, text, mode) → { message, task }: a Research, Agent or Code request (the composer's modes): the* message is posted without Vibe's instant answer and becomes a task of the chat@@@ -188,6 +190,24 @@ async function remove(room, user) {await db.tx(() => erase(room.id));}++/** The chat's last two messages, when they are the person's message and then Vibe's answer to it, else null. */++async function lastExchange(room, user) {++ const [answer, asked] = await db.all('SELECT * FROM room_messages WHERE room_id = ? AND is_deleted = 0 ORDER BY id DESC LIMIT 2', [room.id]);++ if (!answer || !answer.ai_member_id || !asked || asked.ai_member_id || Number(asked.user_id) !== Number(user.id)) return null;++ return { answer, asked };++}++++async function retry(room, user) {++ if (!isVibeRoom(room) || !user || Number(room.owner_id) !== Number(user.id)) fail(404, 'rooms.not_found', 'No such chat');++ if (!enabled()) fail(503, 'vibe.off', 'Vibe is resting right now. Try again later.');++ const last = await lastExchange(room, user);++ if (!last) fail(409, 'vibe.nothing_to_retry', 'Only Vibe\'s answer to your last message can be tried again');++ await db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ? AND is_deleted = 0', [user.id, last.answer.id]);++ try { require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id: Number(last.answer.id) }); } catch { /* sockets are optional */ }++ setImmediate(() => { require('./ai-members').afterPost(room, last.asked, user).catch(() => {}); });++ return { removed: Number(last.answer.id) };++}++async function repliesToday(user) {const row = await db.get('SELECT replies FROM vibe_usage WHERE user_id = ? AND day = ?', [user.id, today()]);return Number(row && row.replies) || 0;@@@ -197,4 +217,4 @@ async function countReply(user) {ON CONFLICT (user_id, day) DO UPDATE SET replies = vibe_usage.replies + 1`, [user.id, today()]);}--module.exports = { VIBE, MAX_CHATS, TITLE_MAX, SEARCH_MAX, DEFAULT_TITLE, MODES, VibeError, enabled, isVibeRoom, titleOf, start, startWork, work, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, search, ownChat, rename, remove, repliesToday, countReply };++module.exports = { VIBE, MAX_CHATS, TITLE_MAX, SEARCH_MAX, DEFAULT_TITLE, MODES, VibeError, enabled, isVibeRoom, titleOf, start, startWork, work, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, search, ownChat, rename, remove, retry, lastExchange, repliesToday, countReply };@@@ -11,6 +11,7 @@* POST /vibe/:slug form: a message in the chat* POST /vibe/:slug/rename form: { name }* POST /vibe/:slug/delete form: the chat and its messages go++ * POST /vibe/:slug/retry form: "Try again" on Vibe's last answer (rooms/vibe.js retry)* GET /vibe/memory what Vibe remembers about the person (server/rooms/vibe-memory.js), with forms to add a* note (POST /vibe/memory), delete one (POST /vibe/memory/:id/delete), forget everything* (POST /vibe/memory/clear) and switch the memory off or on (POST /vibe/memory/switch)@@@ -113,9 +114,17 @@ ${left != null ? `<p class="vb-allow" id="vb-allow">${left} of ${allowance.per_d</aside>`;}--function turn(m, me, hows = new Map()) {++/**++ * 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).++ */++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>`;++}++++function turn(m, me, hows = new Map(), retrySlug = null) {const ai = !!m.ai_member_id;-- if (ai) 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>${how.html(hows.get(Number(m.id)))}</div></li>`;++ if (ai) 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>${how.html(hows.get(Number(m.id)))}${acts(retrySlug)}</div></li>`;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>`;}@@@ -183,6 +192,8 @@ function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit,const enabled = vibe.enabled();const title = room ? room.name : 'Vibe';const waiting = room && messages.length && !messages[messages.length - 1].ai_member_id;++ // Try again goes under the last answer when it answers the person's last message.++ const retryable = room && messages.length > 1 && !!messages[messages.length - 1].ai_member_id && !messages[messages.length - 2].ai_member_id && user && Number(messages[messages.length - 2].user_id) === Number(user.id);const hello = `<div class="vb-hello">${MARK}<h1>${user ? `What can I help with${user.display_name || user.username ? `, ${esc(String(user.display_name || user.username).split(' ')[0])}` : ''}?` : 'Hi, I\'m Vibe. What can I help with?'}</h1>@@@ -190,7 +201,7 @@ ${MARK}<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) => turn(m, user, hows)).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>`++ ? `<ol class="vb-thread" id="vb-thread">${messages.map((m, i) => turn(m, user, hows, i === messages.length - 1 && retryable ? room.slug : null)).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>@@@ -346,6 +357,13 @@ ${notes.length ? `<h2 class="vb-group">${notes.length} of ${memory.MAX_NOTES} notry { await vibe.rename(room, actor.user, req.body && req.body.name); res.redirect(303, `/vibe/${room.slug}`); } catch (err) { await render(req, res, { room, status: err.status || 500, error: err.message }); }});++ router.post('/vibe/:slug/retry', formLimit, sameSite, async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = await vibe.ownChat(req.params.slug, actor.user);++ if (!room) return res.redirect(303, '/vibe');++ try { await vibe.retry(room, actor.user); res.redirect(303, `/vibe/${room.slug}`); } catch (err) { await render(req, res, { room, status: err.status || 500, error: err.status ? err.message : 'That did not work. Try again in a minute.' }); }++ });++router.post('/vibe/:slug/delete', formLimit, sameSite, async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const room = await vibe.ownChat(req.params.slug, actor.user);nnew file mode 100644@@@ -0,0 +1,79 @@++'use strict';++/**++ * Copy and Try again under Vibe's answers (server/rooms/vibe.js retry, vibe-routes.js, web/vibe-pages.js): Try again++ * removes Vibe's answer to the person's last message and writes a new one (it counts like any answer); it is offered++ * under that answer only; nothing else can be tried again; another person's chat is not there; the form works too.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('vibe retry');++const SITE = 'https://openvibe.chat';++let h, ann, bob, aiClient, vibe;++let n = 0;++const vibeApi = (method, path, u, body) => h.http(method, `/api/chat/vibe${path}`, { token: u.token, body });++const web = async (method, path, u, fields) => {++ const headers = { cookie: `ov_token=${u.token}`, ...(fields ? { 'content-type': 'application/x-www-form-urlencoded', origin: SITE } : {}) };++ const r = await fetch(`${h.base}${path}`, { method, headers, body: fields ? new URLSearchParams(fields).toString() : undefined, redirect: 'manual' });++ return { status: r.status, text: await r.text(), location: r.headers.get('location') };++};++async function answers(slug, count) {++ for (let i = 0; i < 80; i++) {++ const got = ((await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: ann.token })).body.messages || []).filter((m) => m.ai_member_id);++ if (got.length >= count) return got;++ await new Promise((r) => setTimeout(r, 25));++ }++ throw new Error(`fewer than ${count} answers`);++}++++t('boot', async () => {++ h = await boot({ env: { CHAT_WEB_URL: SITE } });++ aiClient = require('../server/ai/client');++ vibe = require('../server/rooms/vibe');++ ann = h.addUser('ann', { subject: ids.newId('user') });++ bob = h.addUser('bob', { subject: ids.newId('user') });++ for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));++ aiClient._setClient({ runs: { create: async () => { n++; return { run: { status: 'succeeded', output: { choices: [{ message: { content: `Answer ${n}\n\n\`\`\`js\nconsole.log(${n});\n\`\`\`` } }] } } }; } } });++});++++let slug;++t('Try again removes the answer and writes a new one, counted like any answer', async () => {++ const r = await vibeApi('POST', '', ann, { message: 'Write me a one-line program' });++ slug = r.body.room.slug;++ const [first] = await answers(slug, 1);++ const used = await vibe.repliesToday({ id: ann.id });++ const again = await vibeApi('POST', `/${slug}/retry`, ann);++ assert.deepStrictEqual([again.status, again.body.removed], [202, first.id]);++ const after = await answers(slug, 1);++ for (let i = 0; i < 80 && (after.length !== 1 || after[0].id === first.id); i++) { await new Promise((res) => setTimeout(res, 25)); after.splice(0, after.length, ...(await answers(slug, 1))); }++ assert.strictEqual(after.length, 1, 'one answer: the first went');++ assert.notStrictEqual(after[0].id, first.id);++ assert.match(after[0].message, /^Answer 2/);++ assert.strictEqual(await vibe.repliesToday({ id: ann.id }), used + 1);++});++++t('only the answer to the person\'s last message; another person\'s chat is not there', async () => {++ const room = await h.db.get('SELECT * FROM rooms WHERE slug = ?', [slug]);++ await require('../server/rooms/rooms').post(room, h.live.users.get(ann.id), 'and another thing', { ai: false });++ const r = await vibeApi('POST', `/${slug}/retry`, ann);++ assert.deepStrictEqual([r.status, r.body.code], [409, 'vibe.nothing_to_retry'], 'the last message is the person\'s: nothing to try again');++ assert.strictEqual((await vibeApi('POST', `/${slug}/retry`, bob)).status, 404);++});++++t('the page offers Try again under the last answer only, and Copy (shown by the script); the form works', async () => {++ const r = await vibeApi('POST', '', ann, { message: 'Two answers please' });++ const s2 = r.body.room.slug;++ await answers(s2, 1);++ const room = await h.db.get('SELECT * FROM rooms WHERE slug = ?', [s2]);++ await require('../server/rooms/rooms').post(room, h.live.users.get(ann.id), 'again', {});++ await 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');++ const form = await web('POST', `/vibe/${s2}/retry`, ann, {});++ assert.deepStrictEqual([form.status, form.location], [303, `/vibe/${s2}`]);++});++++t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); });
@@ -141,6 +141,25 @@ body.oc-app-body { overflow: hidden; }
@@ -141,6 +141,25 @@ body.oc-app-body { overflow: hidden; }
/* An answer as it is written (vibe.js streamText): the text, then the dots, small. */
/* An answer as it is written (vibe.js streamText): the text, then the dots, small. */
.vb-wait .vb-streaming + .vb-dots { margin-top: 6px; transform: scale(.8); transform-origin: left center; }
.vb-wait .vb-streaming + .vb-dots { margin-top: 6px; transform: scale(.8); transform-origin: left center; }
+/* Under an answer: Copy and Try again, quiet until hovered; a code block's own Copy in its corner. */
+.vb-acts { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
+.vb-acts form { display: contents; }
+.vb-act {
+ padding: 4px 10px; border-radius: 8px; border: 1px solid transparent; background: transparent; color: var(--oc-muted);
+ font: inherit; font-size: .8rem; cursor: pointer;
+}
+.vb-act:hover { color: var(--oc-text); border-color: var(--oc-line); background: var(--oc-panel-2); }
+.vb-act:focus-visible, .vb-copy-code:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 1px; }
+.vb-act.is-done, .vb-copy-code.is-done { color: var(--oc-green, var(--oc-accent)); }
+.vb-md pre { position: relative; }
+.vb-copy-code {
+ position: absolute; top: 6px; right: 6px; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--oc-line);
+ background: var(--oc-panel); color: var(--oc-muted); font: inherit; font-size: .75rem; cursor: pointer;
+}
+.vb-copy-code:hover { color: var(--oc-text); }
+.vb-copy-code::before { content: 'Copy'; }
+.vb-copy-code.is-done::before { content: 'Copied'; }
+
/* ── A new chat ───────────────────────────────────────────────────────────── */
/* ── A new chat ───────────────────────────────────────────────────────────── */
.vb-hello { max-width: var(--vb-col); margin: 0 auto; min-height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 24px 0 8px; }
.vb-hello { max-width: var(--vb-col); margin: 0 auto; min-height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 24px 0 8px; }
.vb-hello .vb-mark { width: 52px; height: 52px; margin-bottom: 14px; }
.vb-hello .vb-mark { width: 52px; height: 52px; margin-bottom: 14px; }
@@ -132,8 +132,42 @@
@@ -132,8 +132,42 @@
var md = el('div', 'vb-md'); md.innerHTML = MD ? MD.render(m.message) : '';
var md = el('div', 'vb-md'); md.innerHTML = MD ? MD.render(m.message) : '';
if (!MD) md.textContent = m.message;
if (!MD) md.textContent = m.message;
body.appendChild(md); li.appendChild(body);
body.appendChild(md); li.appendChild(body);
+ // 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);
+ }
+ body.appendChild(row);
+ enhance(li);
return 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);
+ });
+ }
// "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) {
if (!room) return;
if (!room) return;
@@ -357,6 +391,27 @@
@@ -357,6 +391,27 @@
.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).
+ app.addEventListener('submit', function (e) {
+ var f = e.target.closest('.vb-retry'); if (!f || !room) return;
+ e.preventDefault();
+ var li = f.closest('.vb-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();
+ showWait(); toBottom();
+ })
+ .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
if (!cfg.memory) toBottom(); // the Memory page reads from the top
@@ -12,6 +12,8 @@
@@ -12,6 +12,8 @@
* answered in the chat (server/rooms/how.js)
* answered in the chat (server/rooms/how.js)
* PATCH /:slug { name } → { room }
* PATCH /:slug { name } → { room }
* DELETE /:slug 204: the chat and its messages are deleted
* DELETE /:slug 204: the chat and its messages are deleted
+ * POST /:slug/retry 202 { removed }: Vibe's last answer goes and it answers the person's last message anew (409
+ * when the last two messages are not that exchange)
* GET /memory { on, notes: [{ id, text, source, created_at }], max }: what Vibe remembers about the person
* GET /memory { on, notes: [{ id, text, source, created_at }], max }: what Vibe remembers about the person
* (server/rooms/vibe-memory.js)
* (server/rooms/vibe-memory.js)
* POST /memory { text } → 201 { note } (a note of their own), 422 when it is empty, too long, secret-looking,
* POST /memory { text } → 201 { note } (a note of their own), 422 when it is empty, too long, secret-looking,
@@ -126,6 +128,14 @@ router.get('/:slug/how', requireAuth, personOnly, async (req, res) => {
@@ -126,6 +128,14 @@ router.get('/:slug/how', requireAuth, personOnly, async (req, res) => {
} catch (err) { return send(res, err); }
} catch (err) { return send(res, err); }
});
});
+router.post('/:slug/retry', requireAuth, personOnly, startLimit, async (req, res) => {
+ try {
+ const room = await vibe.ownChat(req.params.slug, req.user);
+ if (!room) return res.status(404).json({ error: 'No such chat', code: 'rooms.not_found' });
+ res.status(202).json(await vibe.retry(room, req.user));
+ } catch (err) { send(res, err); }
+});
+
router.patch('/:slug', requireAuth, personOnly, manageLimit, async (req, res) => {
router.patch('/:slug', requireAuth, personOnly, manageLimit, async (req, res) => {
try {
try {
const room = await vibe.ownChat(req.params.slug, req.user);
const room = await vibe.ownChat(req.params.slug, req.user);
@@ -14,6 +14,8 @@
@@ -14,6 +14,8 @@
* ownChat(slug, user) → the room when it is one of `user`'s Vibe chats, else null
* ownChat(slug, user) → the room when it is one of `user`'s Vibe chats, else null
* rename(room, user, name) → the room
* rename(room, user, name) → the room
* remove(room, user) → the chat and its messages are deleted (not archived: it was the person's own)
* remove(room, user) → the chat and its messages are deleted (not archived: it was the person's own)
+ * retry(room, user) → { removed }: "Try again": Vibe's last answer goes and it answers the person's last
+ * message anew (in the background, like any answer; it counts against the allowance)
* repliesToday(user) / countReply(user) the person's answers from Vibe this UTC day, across all their chats
* repliesToday(user) / countReply(user) the person's answers from Vibe this UTC day, across all their chats
* work(room, user, text, mode) → { message, task }: a Research, Agent or Code request (the composer's modes): the
* work(room, user, text, mode) → { message, task }: a Research, Agent or Code request (the composer's modes): the
* message is posted without Vibe's instant answer and becomes a task of the chat
* message is posted without Vibe's instant answer and becomes a task of the chat
@@ -188,6 +190,24 @@ async function remove(room, user) {
@@ -188,6 +190,24 @@ async function remove(room, user) {
await db.tx(() => erase(room.id));
await db.tx(() => erase(room.id));
}
}
+/** The chat's last two messages, when they are the person's message and then Vibe's answer to it, else null. */
+async function lastExchange(room, user) {
+ const [answer, asked] = await db.all('SELECT * FROM room_messages WHERE room_id = ? AND is_deleted = 0 ORDER BY id DESC LIMIT 2', [room.id]);
+ if (!answer || !answer.ai_member_id || !asked || asked.ai_member_id || Number(asked.user_id) !== Number(user.id)) return null;
+ return { answer, asked };
+}
+
+async function retry(room, user) {
+ if (!isVibeRoom(room) || !user || Number(room.owner_id) !== Number(user.id)) fail(404, 'rooms.not_found', 'No such chat');
+ if (!enabled()) fail(503, 'vibe.off', 'Vibe is resting right now. Try again later.');
+ const last = await lastExchange(room, user);
+ if (!last) fail(409, 'vibe.nothing_to_retry', 'Only Vibe\'s answer to your last message can be tried again');
+ await db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ? AND is_deleted = 0', [user.id, last.answer.id]);
+ try { require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id: Number(last.answer.id) }); } catch { /* sockets are optional */ }
+ setImmediate(() => { require('./ai-members').afterPost(room, last.asked, user).catch(() => {}); });
+ return { removed: Number(last.answer.id) };
+}
+
async function repliesToday(user) {
async function repliesToday(user) {
const row = await db.get('SELECT replies FROM vibe_usage WHERE user_id = ? AND day = ?', [user.id, today()]);
const row = await db.get('SELECT replies FROM vibe_usage WHERE user_id = ? AND day = ?', [user.id, today()]);
return Number(row && row.replies) || 0;
return Number(row && row.replies) || 0;
@@ -197,4 +217,4 @@ async function countReply(user) {
@@ -197,4 +217,4 @@ async function countReply(user) {
ON CONFLICT (user_id, day) DO UPDATE SET replies = vibe_usage.replies + 1`, [user.id, today()]);
ON CONFLICT (user_id, day) DO UPDATE SET replies = vibe_usage.replies + 1`, [user.id, today()]);
}
}
-module.exports = { VIBE, MAX_CHATS, TITLE_MAX, SEARCH_MAX, DEFAULT_TITLE, MODES, VibeError, enabled, isVibeRoom, titleOf, start, startWork, work, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, search, ownChat, rename, remove, repliesToday, countReply };
+module.exports = { VIBE, MAX_CHATS, TITLE_MAX, SEARCH_MAX, DEFAULT_TITLE, MODES, VibeError, enabled, isVibeRoom, titleOf, start, startWork, work, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, search, ownChat, rename, remove, retry, lastExchange, repliesToday, countReply };
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
* POST /vibe/:slug form: a message in the chat
* POST /vibe/:slug form: a message in the chat
* POST /vibe/:slug/rename form: { name }
* POST /vibe/:slug/rename form: { name }
* POST /vibe/:slug/delete form: the chat and its messages go
* POST /vibe/:slug/delete form: the chat and its messages go
+ * POST /vibe/:slug/retry form: "Try again" on Vibe's last answer (rooms/vibe.js retry)
* GET /vibe/memory what Vibe remembers about the person (server/rooms/vibe-memory.js), with forms to add a
* GET /vibe/memory what Vibe remembers about the person (server/rooms/vibe-memory.js), with forms to add a
* note (POST /vibe/memory), delete one (POST /vibe/memory/:id/delete), forget everything
* note (POST /vibe/memory), delete one (POST /vibe/memory/:id/delete), forget everything
* (POST /vibe/memory/clear) and switch the memory off or on (POST /vibe/memory/switch)
* (POST /vibe/memory/clear) and switch the memory off or on (POST /vibe/memory/switch)
@@ -113,9 +114,17 @@ ${left != null ? `<p class="vb-allow" id="vb-allow">${left} of ${allowance.per_d
@@ -113,9 +114,17 @@ ${left != null ? `<p class="vb-allow" id="vb-allow">${left} of ${allowance.per_d
</aside>`;
</aside>`;
}
}
-function turn(m, me, hows = new Map()) {
+/**
+ * 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).
+ */
+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>`;
+}
+
+function turn(m, me, hows = new Map(), retrySlug = null) {
const ai = !!m.ai_member_id;
const ai = !!m.ai_member_id;
- if (ai) 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>${how.html(hows.get(Number(m.id)))}</div></li>`;
+ if (ai) 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>${how.html(hows.get(Number(m.id)))}${acts(retrySlug)}</div></li>`;
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 `<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>`;
}
}
@@ -183,6 +192,8 @@ function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit,
@@ -183,6 +192,8 @@ function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit,
const enabled = vibe.enabled();
const enabled = vibe.enabled();
const title = room ? room.name : 'Vibe';
const title = room ? room.name : 'Vibe';
const waiting = room && messages.length && !messages[messages.length - 1].ai_member_id;
const waiting = room && messages.length && !messages[messages.length - 1].ai_member_id;
+ // Try again goes under the last answer when it answers the person's last message.
+ const retryable = room && messages.length > 1 && !!messages[messages.length - 1].ai_member_id && !messages[messages.length - 2].ai_member_id && user && Number(messages[messages.length - 2].user_id) === Number(user.id);
const hello = `<div class="vb-hello">
const hello = `<div class="vb-hello">
${MARK}
${MARK}
<h1>${user ? `What can I help with${user.display_name || user.username ? `, ${esc(String(user.display_name || user.username).split(' ')[0])}` : ''}?` : 'Hi, I\'m Vibe. What can I help with?'}</h1>
<h1>${user ? `What can I help with${user.display_name || user.username ? `, ${esc(String(user.display_name || user.username).split(' ')[0])}` : ''}?` : 'Hi, I\'m Vibe. What can I help with?'}</h1>
@@ -190,7 +201,7 @@ ${MARK}
@@ -190,7 +201,7 @@ ${MARK}
<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
const thread = room
- ? `<ol class="vb-thread" id="vb-thread">${messages.map((m) => turn(m, user, hows)).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>`
+ ? `<ol class="vb-thread" id="vb-thread">${messages.map((m, i) => turn(m, user, hows, i === messages.length - 1 && retryable ? room.slug : null)).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">
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>
<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>
@@ -346,6 +357,13 @@ ${notes.length ? `<h2 class="vb-group">${notes.length} of ${memory.MAX_NOTES} no
@@ -346,6 +357,13 @@ ${notes.length ? `<h2 class="vb-group">${notes.length} of ${memory.MAX_NOTES} no
try { await vibe.rename(room, actor.user, req.body && req.body.name); res.redirect(303, `/vibe/${room.slug}`); } catch (err) { await render(req, res, { room, status: err.status || 500, error: err.message }); }
try { await vibe.rename(room, actor.user, req.body && req.body.name); res.redirect(303, `/vibe/${room.slug}`); } catch (err) { await render(req, res, { room, status: err.status || 500, error: err.message }); }
});
});
+ router.post('/vibe/:slug/retry', formLimit, sameSite, async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = await vibe.ownChat(req.params.slug, actor.user);
+ if (!room) return res.redirect(303, '/vibe');
+ try { await vibe.retry(room, actor.user); res.redirect(303, `/vibe/${room.slug}`); } catch (err) { await render(req, res, { room, status: err.status || 500, error: err.status ? err.message : 'That did not work. Try again in a minute.' }); }
+ });
+
router.post('/vibe/:slug/delete', formLimit, sameSite, async (req, res) => {
router.post('/vibe/:slug/delete', formLimit, sameSite, async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const room = await vibe.ownChat(req.params.slug, actor.user);
const room = await vibe.ownChat(req.params.slug, actor.user);
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+'use strict';
+/**
+ * Copy and Try again under Vibe's answers (server/rooms/vibe.js retry, vibe-routes.js, web/vibe-pages.js): Try again
+ * removes Vibe's answer to the person's last message and writes a new one (it counts like any answer); it is offered
+ * under that answer only; nothing else can be tried again; another person's chat is not there; the form works too.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('vibe retry');
+const SITE = 'https://openvibe.chat';
+let h, ann, bob, aiClient, vibe;
+let n = 0;
+const vibeApi = (method, path, u, body) => h.http(method, `/api/chat/vibe${path}`, { token: u.token, body });
+const web = async (method, path, u, fields) => {
+ const headers = { cookie: `ov_token=${u.token}`, ...(fields ? { 'content-type': 'application/x-www-form-urlencoded', origin: SITE } : {}) };
+ const r = await fetch(`${h.base}${path}`, { method, headers, body: fields ? new URLSearchParams(fields).toString() : undefined, redirect: 'manual' });
+ return { status: r.status, text: await r.text(), location: r.headers.get('location') };
+};
+async function answers(slug, count) {
+ for (let i = 0; i < 80; i++) {
+ const got = ((await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: ann.token })).body.messages || []).filter((m) => m.ai_member_id);
+ if (got.length >= count) return got;
+ await new Promise((r) => setTimeout(r, 25));
+ }
+ throw new Error(`fewer than ${count} answers`);
+}
+
+t('boot', async () => {
+ h = await boot({ env: { CHAT_WEB_URL: SITE } });
+ aiClient = require('../server/ai/client');
+ vibe = require('../server/rooms/vibe');
+ ann = h.addUser('ann', { subject: ids.newId('user') });
+ bob = h.addUser('bob', { subject: ids.newId('user') });
+ for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));
+ aiClient._setClient({ runs: { create: async () => { n++; return { run: { status: 'succeeded', output: { choices: [{ message: { content: `Answer ${n}\n\n\`\`\`js\nconsole.log(${n});\n\`\`\`` } }] } } }; } } });
+});
+
+let slug;
+t('Try again removes the answer and writes a new one, counted like any answer', async () => {
+ const r = await vibeApi('POST', '', ann, { message: 'Write me a one-line program' });
+ slug = r.body.room.slug;
+ const [first] = await answers(slug, 1);
+ const used = await vibe.repliesToday({ id: ann.id });
+ const again = await vibeApi('POST', `/${slug}/retry`, ann);
+ assert.deepStrictEqual([again.status, again.body.removed], [202, first.id]);
+ const after = await answers(slug, 1);
+ for (let i = 0; i < 80 && (after.length !== 1 || after[0].id === first.id); i++) { await new Promise((res) => setTimeout(res, 25)); after.splice(0, after.length, ...(await answers(slug, 1))); }
+ assert.strictEqual(after.length, 1, 'one answer: the first went');
+ assert.notStrictEqual(after[0].id, first.id);
+ assert.match(after[0].message, /^Answer 2/);
+ assert.strictEqual(await vibe.repliesToday({ id: ann.id }), used + 1);
+});
+
+t('only the answer to the person\'s last message; another person\'s chat is not there', async () => {
+ const room = await h.db.get('SELECT * FROM rooms WHERE slug = ?', [slug]);
+ await require('../server/rooms/rooms').post(room, h.live.users.get(ann.id), 'and another thing', { ai: false });
+ const r = await vibeApi('POST', `/${slug}/retry`, ann);
+ assert.deepStrictEqual([r.status, r.body.code], [409, 'vibe.nothing_to_retry'], 'the last message is the person\'s: nothing to try again');
+ assert.strictEqual((await vibeApi('POST', `/${slug}/retry`, bob)).status, 404);
+});
+
+t('the page offers Try again under the last answer only, and Copy (shown by the script); the form works', async () => {
+ const r = await vibeApi('POST', '', ann, { message: 'Two answers please' });
+ const s2 = r.body.room.slug;
+ await answers(s2, 1);
+ const room = await h.db.get('SELECT * FROM rooms WHERE slug = ?', [s2]);
+ await require('../server/rooms/rooms').post(room, h.live.users.get(ann.id), 'again', {});
+ await 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');
+ const form = await web('POST', `/vibe/${s2}/retry`, ann, {});
+ assert.deepStrictEqual([form.status, form.location], [303, `/vibe/${s2}`]);
+});
+
+t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); });