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)

OP OpenVibers committed 20 days ago · bf6485334f · 6 files, +205 −4

Changes

ddiff --git a/public/web/vibe.css b/public/web/vibe.css
iindex e2ad2b0..35e6c09 100644
---- a/public/web/vibe.css
++++ b/public/web/vibe.css
@@@ -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; }
ddiff --git a/public/web/vibe.js b/public/web/vibe.js
iindex 698bd61..3123ec5 100644
---- a/public/web/vibe.js
++++ b/public/web/vibe.js
@@@ -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
ddiff --git a/server/rooms/vibe-routes.js b/server/rooms/vibe-routes.js
iindex 5617f05..a629f6e 100644
---- a/server/rooms/vibe-routes.js
++++ b/server/rooms/vibe-routes.js
@@@ -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);
ddiff --git a/server/rooms/vibe.js b/server/rooms/vibe.js
iindex 06ec0d1..8350917 100644
---- a/server/rooms/vibe.js
++++ b/server/rooms/vibe.js
@@@ -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 };
ddiff --git a/server/web/vibe-pages.js b/server/web/vibe-pages.js
iindex 9d34506..559e2f0 100644
---- a/server/web/vibe-pages.js
++++ b/server/web/vibe-pages.js
@@@ -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} 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 }); }
});
++ 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);
ddiff --git a/test/vibe-retry.test.js b/test/vibe-retry.test.js
nnew file mode 100644
iindex 0000000..3bd09c2
---- /dev/null
++++ b/test/vibe-retry.test.js
@@@ -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(); });