openvibers/openvibe.chat Commits

Vibe: "How Vibe did this" folded under an agent task's answer (agent and model, the check, time, cost, sources, every step on Actor); Chat now reads Actor's cost as cost.usd (it read total_usd, so every task's cost was null) (#175)

OP OpenVibers committed 20 days ago · bc69e5b7ae · 9 files, +156 −8

Changes

ddiff --git a/migrations/0030_room_task_how.sql b/migrations/0030_room_task_how.sql
nnew file mode 100644
iindex 0000000..947eebf
---- /dev/null
++++ b/migrations/0030_room_task_how.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- "How Vibe did this" (consumer plan §4, the Vibe app): what OpenVibe.Actor says about a task it finished, kept with the
++-- task so the answer in the chat can show it (server/rooms/how.js): JSON { agent, model, class, checked: { by, reason },
++-- actor_task }. Null for tasks finished before this, and for tasks someone in the room ran by hand.
++ALTER TABLE room_tasks ADD COLUMN IF NOT EXISTS how text;
ddiff --git a/public/web/vibe.css b/public/web/vibe.css
iindex 1e81bd2..f410f1c 100644
---- a/public/web/vibe.css
++++ b/public/web/vibe.css
@@@ -36,6 +36,14 @@ body.oc-app-body { overflow: hidden; }
.vb-icon:hover { background: var(--oc-panel-2); color: var(--oc-text); }
.vb-icon:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 1px; }
.vb-rail-close, .vb-menu { display: none; }
++.vb-how { margin-top: 10px; font-size: .86rem; color: var(--oc-muted); }
++.vb-how summary { cursor: pointer; width: fit-content; padding: 2px 0; font-weight: 600; list-style: none; }
++.vb-how summary::-webkit-details-marker { display: none; }
++.vb-how summary::before { content: "▸"; display: inline-block; margin-right: 6px; transition: transform .15s; }
++.vb-how[open] summary::before { transform: rotate(90deg); }
++.vb-how summary:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; border-radius: 4px; }
++.vb-how ul { margin: 6px 0 0; padding: 8px 12px; list-style: none; border-left: 2px solid var(--oc-line); display: grid; gap: 4px; overflow-wrap: anywhere; }
++.vb-how a { color: var(--oc-accent); }
.vb-search { position: relative; margin: 0 2px 6px; }
.vb-search svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--oc-muted); pointer-events: none; }
.vb-search input {
ddiff --git a/public/web/vibe.js b/public/web/vibe.js
iindex 3eeabca..2ea7a69 100644
---- a/public/web/vibe.js
++++ b/public/web/vibe.js
@@@ -112,6 +112,30 @@
body.appendChild(md); li.appendChild(body);
return li;
}
++ // "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;
++ fetch('/api/chat/vibe/' + encodeURIComponent(room) + '/how', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
++ var info = d && d.how && d.how[String(id)];
++ var body = li.querySelector('.vb-body');
++ if (!info || !body || body.querySelector('.vb-how')) return;
++ 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)); });
++ if (info.sources && info.sources.length) {
++ var src = el('li', null, 'Sources: ');
++ info.sources.forEach(function (u, i) {
++ if (!/^https?:\/\//.test(u)) return;
++ if (i) src.appendChild(document.createTextNode(', '));
++ var a = el('a', null, u.replace(/^https?:\/\/(www\.)?/, '').split('/')[0]); a.href = u; a.rel = 'nofollow ugc noopener'; a.target = '_blank';
++ src.appendChild(a);
++ });
++ 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); body.appendChild(det);
++ }).catch(function () {});
++ }
function clearWait() { var w = document.getElementById('vb-wait'); if (w) w.remove(); if (waitTimer) { clearTimeout(waitTimer); waitTimer = null; } }
function showWait() {
if (document.getElementById('vb-wait')) return;
@@@ -133,7 +157,10 @@
if (!m || have(m.id)) return;
latest = Math.max(latest, Number(m.id) || 0);
var stick = nearBottom();
-- if (m.ai_member_id) { clearWait(); thread().appendChild(aiTurn(m)); refreshRail(); }
++ 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 {
// 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])');
ddiff --git a/server/rooms/how.js b/server/rooms/how.js
nnew file mode 100644
iindex 0000000..be41229
---- /dev/null
++++ b/server/rooms/how.js
@@@ -0,0 +1,77 @@
++'use strict';
++/**
++ * "How Vibe did this" (consumer plan §4, the Vibe app): under a finished agent task's answer in a Vibe chat, folded,
++ * what ran it and with which model, the check, how long it took, what it cost, its sources, and a link to every step
++ * on OpenVibe.Actor. Built from the task row (its `how`, kept by workspace.js from Actor's result) and Actor's public
++ * agent catalog for the agents' names (read at most once an hour; an agent's id stands in while Actor is away).
++ *
++ * forRoom(room) → Map(message id → info) for the room's announced tasks
++ * of(task, names) → info { lines: [text], sources: [url], actor_url } or null: plain text, escaped where it is shown
++ * html(info) → the <details class="vb-how"> markup, escaped
++ */
++const config = require('../config');
++const db = require('../db/database');
++
++const NAMES_TTL_MS = 3_600_000;
++let cached = { at: 0, names: new Map() };
++
++/** Actor's agent names (GET /api/v1/agents is public), cached for an hour; a failed read keeps what it had. */
++async function names({ fetchImpl = globalThis.fetch, now = Date.now() } = {}) {
++ if (now - cached.at < NAMES_TTL_MS) return cached.names;
++ cached = { at: now, names: cached.names };
++ try {
++ const res = await fetchImpl(`${config.actor.internalUrl}/api/v1/agents`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(3000) });
++ const body = res.ok ? await res.json() : null;
++ if (body && Array.isArray(body.agents)) cached.names = new Map(body.agents.filter((a) => a && a.id && a.name).map((a) => [String(a.id), String(a.name)]));
++ } catch { /* Actor away: the ids stand in */ }
++ return cached.names;
++}
++
++const DONE_AS = { research: 'Researched', web: 'Looked up', computer: 'Done on your computer', code: 'Coded on your computer' };
++const atOf = (ts) => { const s = String(ts || ''); const t = Date.parse(s.includes('T') ? s : `${s.replace(' ', 'T')}Z`); return Number.isFinite(t) ? t : null; };
++const json = (v, dflt) => { try { const x = JSON.parse(v || ''); return x == null ? dflt : x; } catch { return dflt; } };
++
++function of(t, agentNames = new Map()) {
++ if (!t || t.status !== 'succeeded') return null;
++ const how = json(t.how, {});
++ const sources = json(t.sources, []).filter((u) => /^https?:\/\//.test(String(u || ''))).slice(0, 6);
++ const agent = how.agent ? (agentNames.get(String(how.agent)) || String(how.agent)) : null;
++ const lines = [`${DONE_AS[t.kind] || 'Done'}${agent ? ` by ${agent}` : ''}${how.model ? ` (${how.model})` : ''}`];
++ const checked = how.checked && typeof how.checked === 'object' ? how.checked : null;
++ if (checked && checked.by && checked.by !== 'none') lines.push(`Checked by a second model (${checked.by})${checked.reason ? `: ${String(checked.reason).slice(0, 200)}` : ''}`);
++ const bits = [];
++ const started = atOf(t.started_at), finished = atOf(t.finished_at);
++ if (started != null && finished != null && finished >= started) {
++ const mins = Math.round((finished - started) / 60_000);
++ bits.push(mins < 1 ? 'Took under a minute' : `Took ${mins} min`);
++ }
++ if (t.cost_usd != null && Number.isFinite(Number(t.cost_usd))) {
++ const usd = Number(t.cost_usd);
++ bits.push(`cost $${usd < 0.01 ? usd.toFixed(4) : usd.toFixed(2)} of your daily allowance`);
++ }
++ if (bits.length) lines.push(bits.join(' · '));
++ const actor_url = how.actor_task ? `https://openvibe.actor/tasks/${encodeURIComponent(String(how.actor_task))}` : null;
++ if (lines.length === 1 && !agent && !sources.length && !actor_url) return null; // nothing to say beyond "Done"
++ return { lines, sources, actor_url };
++}
++
++async function forRoom(room) {
++ const rows = await db.all('SELECT * FROM room_tasks WHERE room_id = ? AND posted_message IS NOT NULL AND status = ? ORDER BY id DESC LIMIT 200', [room.id, 'succeeded']);
++ const out = new Map();
++ if (!rows.length) return out;
++ const agentNames = await names();
++ for (const t of rows) { const info = of(t, agentNames); if (info) out.set(Number(t.posted_message), info); }
++ return out;
++}
++
++const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++const hostOf = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return u; } };
++
++function html(info) {
++ if (!info) return '';
++ return `<details class="vb-how"><summary>How Vibe did this</summary><ul>${info.lines.map((l) => `<li>${esc(l)}</li>`).join('')}`
++ + `${info.sources.length ? `<li>Sources: ${info.sources.map((u) => `<a href="${esc(u)}" rel="nofollow ugc noopener" target="_blank">${esc(hostOf(u))}</a>`).join(', ')}</li>` : ''}`
++ + `${info.actor_url ? `<li><a href="${esc(info.actor_url)}">Every step on OpenVibe Agents</a></li>` : ''}</ul></details>`;
++}
++
++module.exports = { names, of, forRoom, html, _reset: () => { cached = { at: 0, names: new Map() }; } };
ddiff --git a/server/rooms/vibe-routes.js b/server/rooms/vibe-routes.js
iindex db44565..eb631e4 100644
---- a/server/rooms/vibe-routes.js
++++ b/server/rooms/vibe-routes.js
@@@ -8,6 +8,8 @@
* POST /picture the body is a picture (Content-Type image/*), an optional question in X-Caption (URI-encoded) →
* 201 { room, message, picture }: a new chat that starts with it (it goes to OpenVibe.Pics as the
* person, like a room's picture), and Vibe answers seeing it
++ * GET /:slug/how { how: { <message id>: { lines, sources, actor_url } } }: "How Vibe did this" for the agent tasks
++ * answered in the chat (server/rooms/how.js)
* PATCH /:slug { name } → { room }
* DELETE /:slug 204: the chat and its messages are deleted
*/
@@@ -18,6 +20,7 @@ const { requireAuth } = require('../auth/auth');
const { limits } = require('../net/actor-limits');
const rooms = require('./rooms');
const vibe = require('./vibe');
++const how = require('./how');
const router = express.Router();
const startLimit = limits('chat.vibe.start', { minute: 10, hour: 120 });
@@@ -77,6 +80,14 @@ router.post('/picture', requireAuth, personOnly, pictureLimit, (req, res, next)
}
});
++router.get('/:slug/how', requireAuth, personOnly, 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: 'vibe.not_found' });
++ return res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ how: Object.fromEntries(await how.forRoom(room)) });
++ } catch (err) { return 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/workspace.js b/server/rooms/workspace.js
iindex 7221bf7..4fe3ae5 100644
---- a/server/rooms/workspace.js
++++ b/server/rooms/workspace.js
@@@ -249,9 +249,12 @@ async function poll(deps = {}) {
if (!DONE.has(state)) continue;
const r = task.result || {};
const sources = (Array.isArray(r.sources) ? r.sources : []).map((s) => (typeof s === 'string' ? s : s && s.url)).filter((u) => /^https?:\/\//.test(String(u || ''))).slice(0, 12);
-- const cost = task.cost && Number.isFinite(Number(task.cost.total_usd)) ? Number(task.cost.total_usd) : null;
++ // Actor's task (actor.task wire): cost.usd is what the task spent; the result names its agent, model and check.
++ const cost = task.cost && Number.isFinite(Number(task.cost.usd)) ? Number(task.cost.usd) : null;
++ const checked = r.checked && typeof r.checked === 'object' ? { by: oneLine(r.checked.by, 40) || null, reason: oneLine(r.checked.reason, 300) || null } : null;
++ const how = state === 'succeeded' ? JSON.stringify({ agent: oneLine(r.agent, 60) || null, model: oneLine(r.model, 80) || null, class: oneLine(r.class, 40) || null, checked, actor_task: String(task.id || t.actor_task) }) : null;
await setStatus(room, t.id, {
-- status: state, result: state === 'succeeded' ? clean(r.answer, LIMITS.result) || null : null, sources: JSON.stringify(sources),
++ status: state, result: state === 'succeeded' ? clean(r.answer, LIMITS.result) || null : null, sources: JSON.stringify(sources), how,
error: state === 'succeeded' ? null : oneLine((task.error && (task.error.detail || task.error.code)) || state, LIMITS.error), cost_usd: cost, finished_at: nowText(),
});
await announce(room, await row(room, t.id), deps).catch(() => {});
ddiff --git a/server/web/vibe-pages.js b/server/web/vibe-pages.js
iindex 116a52b..aaf863b 100644
---- a/server/web/vibe-pages.js
++++ b/server/web/vibe-pages.js
@@@ -16,6 +16,7 @@ const md = require('../../public/web/vibe-md.js');
const pictures = require('../rooms/pictures');
const rooms = require('../rooms/rooms');
const vibe = require('../rooms/vibe');
++const how = require('../rooms/how');
const { esc } = require('./layout');
const SUGGESTIONS = [
@@@ -104,9 +105,9 @@ ${left != null ? `<p class="vb-allow" id="vb-allow">${left} of ${allowance.per_d
</aside>`;
}
--function turn(m, me) {
++function turn(m, me, hows = new Map()) {
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></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)))}</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>`;
}
@@@ -164,6 +165,7 @@ function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit,
const chats = user ? (searching ? await vibe.search(user, query) : await vibe.list(user)) : [];
const allowance = user ? await allowanceOf(user) : null;
const messages = room ? await rooms.history(room, { limit: 200 }) : [];
++ const hows = room ? await how.forRoom(room) : new Map(); // "How Vibe did this" under agent tasks' answers
const enabled = vibe.enabled();
const title = room ? room.name : 'Vibe';
const waiting = room && messages.length && !messages[messages.length - 1].ai_member_id;
@@@ -174,7 +176,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)).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) => 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>`
: '';
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>
ddiff --git a/test/vibe-modes.test.js b/test/vibe-modes.test.js
iindex e261125..f52a6fa 100644
---- a/test/vibe-modes.test.js
++++ b/test/vibe-modes.test.js
@@@ -32,6 +32,8 @@ t('boot', async () => {
for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));
globalThis.fetch = async (url, o = {}) => {
if (!String(url).startsWith('http://127.0.0.1:4950')) return realFetch(url, o);
++ // Actor's public agent catalog: the names "How Vibe did this" shows.
++ if (String(url).endsWith('/api/v1/agents')) return { ok: true, status: 200, json: async () => ({ agents: [{ id: 'openvibe-runtime', name: 'OpenVibe runtime' }] }) };
actorCalls.push({ url: String(url), method: o.method || 'GET', subject: o.headers['X-OV-Subject'], body: o.body ? JSON.parse(o.body) : null });
return { ok: true, status: (o.method || 'GET') === 'POST' ? 201 : 200, json: async () => actorTask };
};
@@@ -51,12 +53,25 @@ t('Research in a new chat: a task on Actor as the person, Vibe says it is on it,
assert.match(msgs[1].message, new RegExp(`^On it: I am researching this[\\s\\S]*${SITE}/r/${slug}/tasks/${r.body.task.id}$`));
assert.strictEqual(turns.length, 0, 'no instant answer');
// Actor finishes: the answer comes back into the chat, without "asked by".
-- actorTask = { id: 'tsk_r1', state: 'succeeded', result: { answer: 'Browser Use Cloud is cheapest at $0.02 an hour.', sources: ['https://browser-use.com/cloud-browsers'] } };
++ actorTask = {
++ id: 'tsk_r1', state: 'succeeded', cost: { usd: 0.0042, free_allowance_used: 0.0042 },
++ result: { answer: 'Browser Use Cloud is cheapest at $0.02 an hour.', sources: ['https://browser-use.com/cloud-browsers'], agent: 'openvibe-runtime', model: 'deepseek-v4-flash', class: 'research', checked: { by: 'qwen', cross_family: true, reason: 'The sources back the answer.' } },
++ };
await workspace.poll();
const after = await messagesOf(slug);
const done = after[after.length - 1];
assert.ok(done.ai_member_id && /^✅ Done: "Compare the three cheapest cloud browsers for agents"\n\nBrowser Use Cloud/.test(done.message), done.message);
assert.ok(!/asked by/.test(done.message), 'a Vibe chat has one person in it');
++ // "How Vibe did this", folded under the answer: what ran it, the check, time and cost, the sources, every step on Actor.
++ const page = await fetch(`${h.base}/vibe/${slug}`, { headers: { cookie: `ov_token=${ann.token}` } }).then((x) => x.text());
++ assert.ok(page.includes('<details class="vb-how"><summary>How Vibe did this</summary><ul><li>Researched by OpenVibe runtime (deepseek-v4-flash)</li>'
++ + '<li>Checked by a second model (qwen): The sources back the answer.</li><li>Took under a minute · cost $0.0042 of your daily allowance</li>'
++ + '<li>Sources: <a href="https://browser-use.com/cloud-browsers" rel="nofollow ugc noopener" target="_blank">browser-use.com</a></li>'
++ + '<li><a href="https://openvibe.actor/tasks/tsk_r1">Every step on OpenVibe Agents</a></li></ul></details>'), 'the fold under the answer');
++ const how = await vibeApi('GET', `/${slug}/how`, ann);
++ assert.strictEqual(how.status, 200);
++ assert.strictEqual(how.body.how[String(done.id)].actor_url, 'https://openvibe.actor/tasks/tsk_r1', 'the same, for an answer that arrives while the page is open');
++ assert.strictEqual((await vibeApi('GET', `/${slug}/how`, bob)).status, 404, 'someone else\'s chat is not there');
});
t('Code in an existing chat: Codes on their own computer; Agent too; Chat mode answers as before', async () => {
ddiff --git a/test/workspace.test.js b/test/workspace.test.js
iindex d259662..a50e988 100644
---- a/test/workspace.test.js
++++ b/test/workspace.test.js
@@@ -74,7 +74,7 @@ t('a quick task runs on Actor as the person who asked; its result comes back fro
assert.strictEqual(create.body.idempotency_key, `chat-room-task-${task.id}`);
assert.strictEqual((await api('POST', `/rooms/staff/tasks/${task.id}/claim`, staff2)).status, 409, 'Actor runs it: nobody claims it');
// Actor finishes; the poller stores the result and Vibe posts it in the room.
-- actorTask = { id: 'tsk_1', state: 'succeeded', result: { answer: 'Three large images block the first paint.', sources: ['https://example.com/a', { url: 'https://example.com/b' }, 'javascript:alert(1)'] }, cost: { total_usd: 0.0031 } };
++ actorTask = { id: 'tsk_1', state: 'succeeded', result: { answer: 'Three large images block the first paint.', sources: ['https://example.com/a', { url: 'https://example.com/b' }, 'javascript:alert(1)'] }, cost: { usd: 0.0031, free_allowance_used: 0.0031 } };
await workspace.poll();
r = await api('GET', `/rooms/staff/tasks/${task.id}`, staff2);
assert.strictEqual(r.body.task.status, 'succeeded');