openvibers/openvibe.chat Commits

How Vibe did this, under chat answers too: the tools Vibe used (searched what, read which page, a note saved, an agent set up) are kept with its answer in plain words and folded under it, live and on the page (migration 0032) (#183)

OP OpenVibers committed 20 days ago · 13a6c7e911 · 6 files, +145 −13

Changes

ddiff --git a/migrations/0032_room_message_how.sql b/migrations/0032_room_message_how.sql
nnew file mode 100644
iindex 0000000..84bd093
---- /dev/null
++++ b/migrations/0032_room_message_how.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- "How Vibe did this" under a chat answer (consumer plan §4, the Vibe app): what Vibe did to answer in a person's own
++-- chat (searched OpenVibe or the web for what, which pages it read, a note saved, an agent set up), kept with its
++-- message as JSON { steps: [text], sources: [url] } (server/rooms/how.js). Null for every other message.
++ALTER TABLE room_messages ADD COLUMN IF NOT EXISTS how text;
ddiff --git a/public/web/vibe.js b/public/web/vibe.js
iindex 5eedd70..d25d7a5 100644
---- a/public/web/vibe.js
++++ b/public/web/vibe.js
@@@ -116,7 +116,15 @@
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)];
++ renderHow(li, d && d.how && d.how[String(id)]);
++ }).catch(function () {});
++ }
++ // A chat answer that arrives with its steps (message.how: { steps, sources }, server/rooms/how.js).
++ 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 || [] } : null;
++ }
++ function renderHow(li, info) {
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'));
@@@ -134,7 +142,6 @@
}
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() {
@@@ -180,6 +187,7 @@
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 line sent from here already shows (without an id until the server answered): give it its id.
ddiff --git a/server/rooms/ai-members.js b/server/rooms/ai-members.js
iindex 0920260..2b3dbf3 100644
---- a/server/rooms/ai-members.js
++++ b/server/rooms/ai-members.js
@@@ -283,7 +283,7 @@ function userPrompt({ member, context, transcript, askedBy, text }) {
* The answer through chat.member.turn with tools: up to MAX_TOOL_ROUNDS rounds of tool calls run here, then a turn
* with tool_choice none. → the reply text, or null when a turn failed (the caller falls back to chat.member.reply).
*/
--async function answerWithTools({ room, member, context, transcript, askedBy, text, opts, subject = null, images = [], user = null, messageId = null, memory = null, called = new Set(), onText = null }) {
++async function answerWithTools({ room, member, context, transcript, askedBy, text, opts, subject = null, images = [], user = null, messageId = null, memory = null, called = new Set(), onText = null, steps = null }) {
const tools = aiTools.definitions(member, room, { memory: memory !== null });
const vibe = Number(room.assistant) === 1;
const prompt = userPrompt({ member, context, transcript, askedBy, text });
@@@ -305,6 +305,7 @@ async function answerWithTools({ room, member, context, transcript, askedBy, tex
messages.push({ role: 'assistant', content: typeof msg.content === 'string' ? msg.content : null, tool_calls: calls });
for (const c of calls) {
called.add(c.function.name);
++ if (steps) require('./how').step(steps, c.function.name, c.function.arguments);
messages.push({ role: 'tool', tool_call_id: String(c.id || `call_${round}`).slice(0, 128), content: await aiTools.run(c.function.name, c.function.arguments, { member, contextFor, subject, room, user, messageId, memory: memory !== null, idempotencyKey: `${opts.idempotencyKey}-t${round}-${c.function.name}` }) });
}
}
@@@ -324,8 +325,8 @@ async function afterPost(room, message, user, deps = {}) {
if (!member) return null;
const rooms = deps.rooms || require('./rooms');
const broadcast = deps.broadcast || ((frame) => require('../chat/chat-server').broadcastToRoom(room.id, frame));
-- const say = async (text) => {
-- const posted = await rooms.postAsAi(room, member, text);
++ const say = async (text, how = null) => {
++ const posted = await rooms.postAsAi(room, member, text, how ? { how } : {});
broadcast({ type: 'room_message', room: room.slug, message: posted });
return posted;
};
@@@ -356,6 +357,7 @@ async function afterPost(room, message, user, deps = {}) {
const memory = vibe && Number(room.owner_id) === Number(user.id) ? await vibeMemory.forPrompt(user) : null;
let text = '';
const called = new Set(); // the tools the model called while answering
++ const steps = vibe ? { steps: [], sources: [] } : null; // "How Vibe did this" under the answer (how.js)
// In a Vibe chat the answer shows as it is written: room_ai_delta { text } (the whole text so far, at most every
// DELTA_MS), until the finished message replaces it.
let lastDelta = 0;
@@@ -369,12 +371,12 @@ async function afterPost(room, message, user, deps = {}) {
} : null;
if (config.ai.memberTools) {
const images = await picturesFor(message, history, user).catch(() => []);
-- try { text = clean(await answerWithTools({ room, member, context, transcript, askedBy, text: String(message.message).slice(0, 2000), opts, subject: user.subject_id || null, images, user, messageId: message.id, memory, called, onText }) || '', vibe ? 8000 : 2000); } catch { text = ''; }
++ try { text = clean(await answerWithTools({ room, member, context, transcript, askedBy, text: String(message.message).slice(0, 2000), opts, subject: user.subject_id || null, images, user, messageId: message.id, memory, called, onText, steps }) || '', vibe ? 8000 : 2000); } catch { text = ''; }
}
// A plain "remember that …" is kept even when the model only promised it (small models often do).
if (memory !== null && text && !called.has('remember')) {
const note = vibeMemory.fromRequest(message.message);
-- if (note) await vibeMemory.add(user, note, { source: 'vibe', roomId: room.id }).catch(() => null);
++ if (note && (await vibeMemory.add(user, note, { source: 'vibe', roomId: room.id }).catch(() => ({}))).note && steps) require('./how').step(steps, 'remember');
}
if (!text) {
const out = await aiClient.structured('chat.member.reply', {
@@@ -387,7 +389,7 @@ async function afterPost(room, message, user, deps = {}) {
if (!text) return await say(own ? 'I could not reach my model just now (its key may be out of today\'s budget, or the provider did not answer). Try again later, or the room\'s owner can switch me to OpenVibe\'s model.' : 'I could not answer just now. Try again in a minute.');
if (!own) await countReply(room);
if (vibe && !own) await require('./vibe').countReply(user);
-- return await say(text);
++ return await say(text, steps && steps.steps.length ? steps : null);
} catch (err) {
console.warn(`[Chat AI members] room ${room.id}: ${(err && err.message) || err}`);
return null;
ddiff --git a/server/rooms/how.js b/server/rooms/how.js
iindex be41229..78033db 100644
---- a/server/rooms/how.js
++++ b/server/rooms/how.js
@@@ -5,8 +5,10 @@
* 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
++ * forRoom(room) → Map(message id → info) for the room's announced tasks and Vibe's answers that kept their steps
* of(task, names) → info { lines: [text], sources: [url], actor_url } or null: plain text, escaped where it is shown
++ * ofMessage(how) → the same info from a chat answer's room_messages.how { steps, sources }, or null
++ * step(acc, tool, args) adds what a tool call did, in plain words, to { steps, sources } (ai-members.js)
* html(info) → the <details class="vb-how"> markup, escaped
*/
const config = require('../config');
@@@ -55,9 +57,44 @@ function of(t, agentNames = new Map()) {
return { lines, sources, actor_url };
}
++// A chat answer's steps (ai-members.js answerWithTools): each tool call in plain words, at most MAX_STEPS.
++const MAX_STEPS = 12;
++const quoted = (v, max = 80) => { const s = String(v || '').replace(/\s+/g, ' ').trim(); return s ? `“${s.length > max ? `${s.slice(0, max - 1)}…` : s}”` : ''; };
++const STEP = {
++ search_openvibe: (a) => `Searched OpenVibe for ${quoted(a.query)}`,
++ web_search: (a) => `Searched the web for ${quoted(a.query)}`,
++ read_page: (a) => `Read ${hostOf(String(a.url || 'a page'))}`,
++ current_time: () => 'Checked the time',
++ roll_dice: () => 'Rolled dice',
++ pick_one: () => 'Picked one at random',
++ household_pantry: () => 'Looked at the household\'s pantry',
++ remember: () => 'Saved a note to your memory',
++ forget: () => 'Deleted a note from your memory',
++ set_up_agent: (a) => `Set up an agent${a.name ? `: ${quoted(a.name, 60)}` : ''}`,
++ my_agents: () => 'Looked at your agents',
++};
++function step(acc, tool, raw = null) {
++ if (!acc || !STEP[tool] || acc.steps.length >= MAX_STEPS) return;
++ let args = {};
++ if (raw) { try { args = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { args = {}; } }
++ if (!args || typeof args !== 'object') args = {};
++ const line = STEP[tool](args);
++ if (acc.steps[acc.steps.length - 1] !== line) acc.steps.push(line); // the same step twice in a row reads once
++ if (tool === 'read_page' && /^https?:\/\//.test(String(args.url || '')) && acc.sources.length < 6 && !acc.sources.includes(args.url)) acc.sources.push(String(args.url).slice(0, 500));
++}
++function ofMessage(raw) {
++ const how = typeof raw === 'string' ? json(raw, null) : raw;
++ if (!how || !Array.isArray(how.steps) || !how.steps.length) return null;
++ return { lines: how.steps.map((l) => String(l).slice(0, 200)).slice(0, MAX_STEPS), sources: (Array.isArray(how.sources) ? how.sources : []).filter((u) => /^https?:\/\//.test(String(u || ''))).slice(0, 6), actor_url: null };
++}
++
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();
++ for (const m of await db.all('SELECT id, how FROM room_messages WHERE room_id = ? AND how IS NOT NULL AND is_deleted = 0 ORDER BY id DESC LIMIT 200', [room.id])) {
++ const info = ofMessage(m.how);
++ if (info) out.set(Number(m.id), info);
++ }
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); }
@@@ -74,4 +111,4 @@ function html(info) {
+ `${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() }; } };
++module.exports = { names, of, ofMessage, step, forRoom, html, _reset: () => { cached = { at: 0, names: new Map() }; } };
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex 944240d..83181df 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -295,11 +295,11 @@ async function post(room, user, text, { now = Date.now(), ai = true } = {}) {
}
/** A message from one of the room's AI members (server/rooms/ai-members.js): user_id 0, the member's id. */
--async function postAsAi(room, member, text, { now = Date.now() } = {}) {
++async function postAsAi(room, member, text, { now = Date.now(), how = null } = {}) {
// Vibe's answers in its own chats may run longer than a person's message (a recipe, a plan, code).
const message = String(text || '').trim().slice(0, Number(room.assistant) === 1 ? MAX_AI_TEXT_VIBE : MAX_TEXT);
const id = await db.tx(async () => {
-- const r = await db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message, ai_member_id) VALUES (?, 0, NULL, ?, ?)', [room.id, message, member.id]);
++ const r = await db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message, ai_member_id, how) VALUES (?, 0, NULL, ?, ?, ?)', [room.id, message, member.id, how ? JSON.stringify(how) : null]);
await db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = ov_now() WHERE id = ?', [room.id]);
await announceMessage(room, { subject_id: null, username: member.handle, display_name: member.name }, Number(r.lastInsertRowid), message, now);
return Number(r.lastInsertRowid);
@@@ -308,7 +308,7 @@ async function postAsAi(room, member, text, { now = Date.now() } = {}) {
}
// A message an AI member wrote (ai_member_id; user_id 0) takes the member's name and handle.
--const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.created_at, m.is_deleted, m.ai_member_id,
++const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.created_at, m.is_deleted, m.ai_member_id, m.how,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.handle ELSE u.username END AS username,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.name ELSE u.display_name END AS display_name,
u.profile_color, u.role AS user_role
ddiff --git a/test/vibe-how-steps.test.js b/test/vibe-how-steps.test.js
nnew file mode 100644
iindex 0000000..e832426
---- /dev/null
++++ b/test/vibe-how-steps.test.js
@@@ -0,0 +1,80 @@
++'use strict';
++/**
++ * "How Vibe did this" under a chat answer (server/rooms/how.js step/ofMessage, ai-members.js, migrations/0032): the
++ * tools Vibe called to answer in a person's own chat are kept with its message in plain words (searched what, read
++ * which page, a note saved) with the pages as sources; the chat page folds them under the answer, the socket's message
++ * carries them, and /how lists them; an answer with no tool call, and an AI member in a group room, keep none.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('vibe how steps');
++let h, ann, aiClient;
++let script = [];
++const toolCall = (id, name, args) => ({ id, type: 'function', function: { name, arguments: JSON.stringify(args) } });
++const say = (content) => ({ choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }] });
++const callTools = (...tc) => ({ choices: [{ index: 0, message: { role: 'assistant', content: null, tool_calls: tc }, finish_reason: 'tool_calls' }] });
++const vibeApi = (method, path, body) => h.http(method, `/api/chat/vibe${path}`, { token: ann.token, body });
++async function lastAi(slug) {
++ for (let i = 0; i < 80; i++) {
++ const msgs = (await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: ann.token })).body.messages || [];
++ const ai = msgs.filter((m) => m.ai_member_id);
++ if (ai.length) return ai[ai.length - 1];
++ await new Promise((r) => setTimeout(r, 25));
++ }
++ throw new Error('no answer');
++}
++
++t('boot', async () => {
++ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });
++ aiClient = require('../server/ai/client');
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ await h.ctx.upsertUser(h.live.users.get(ann.id));
++ aiClient._setClient({ runs: { create: async (workflow) => ({ run: { status: 'succeeded', output: workflow === 'chat.member.turn' ? (script.shift() || (() => say('Sure.')))() : { text: 'plain' } } }) } });
++});
++
++t('the tools Vibe used are kept with its answer, in plain words, with the pages it read', async () => {
++ script = [() => callTools(toolCall('c1', 'search_openvibe', { query: 'mechanical keyboard deals' }), toolCall('c2', 'read_page', { url: 'https://www.example.com/kb' })), () => say('The **Keychron K2** is $59 today.')];
++ const r = await vibeApi('POST', '', { message: 'Find me a mechanical keyboard deal' });
++ assert.strictEqual(r.status, 201, r.text);
++ const m = await lastAi(r.body.room.slug);
++ assert.deepStrictEqual(JSON.parse(m.how), { steps: ['Searched OpenVibe for “mechanical keyboard deals”', 'Read example.com'], sources: ['https://www.example.com/kb'] });
++ const page = await fetch(`${h.base}/vibe/${r.body.room.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>Searched OpenVibe for “mechanical keyboard deals”</li><li>Read example.com</li><li>Sources: <a href="https://www.example.com/kb" rel="nofollow ugc noopener" target="_blank">example.com</a></li></ul></details>'), 'folded under the answer');
++ const how = await vibeApi('GET', `/${r.body.room.slug}/how`);
++ assert.deepStrictEqual(how.body.how[String(m.id)].lines, ['Searched OpenVibe for “mechanical keyboard deals”', 'Read example.com']);
++});
++
++t('no tool call, no steps; a note kept by the safety net is a step', async () => {
++ script = [() => say('Hello!')];
++ const r = await vibeApi('POST', '', { message: 'hi there' });
++ assert.strictEqual((await lastAi(r.body.room.slug)).how, null);
++ script = [() => say('Noted.')];
++ const r2 = await vibeApi('POST', '', { message: 'Please remember that I live in Porto.' });
++ assert.deepStrictEqual(JSON.parse((await lastAi(r2.body.room.slug)).how).steps, ['Saved a note to your memory']);
++});
++
++t('an AI member in a group room keeps no steps', async () => {
++ assert.strictEqual((await h.http('POST', '/api/chat/rooms', { token: ann.token, body: { name: 'Steps Crew', visibility: 'private' } })).status, 201);
++ assert.strictEqual((await h.http('POST', '/api/chat/rooms/steps-crew/ai-members', { token: ann.token, body: { name: 'Helper' } })).status, 201);
++ script = [() => callTools(toolCall('c3', 'current_time', {})), () => say('It is noon.')];
++ await h.http('POST', '/api/chat/rooms/steps-crew/messages', { token: ann.token, body: { message: '@helper what time is it?' } });
++ assert.strictEqual((await lastAi('steps-crew')).how, null);
++});
++
++t('steps read plainly, at most twelve, the same one twice in a row once', async () => {
++ const how = require('../server/rooms/how');
++ const acc = { steps: [], sources: [] };
++ how.step(acc, 'web_search', '{"query":"who won the match"}');
++ how.step(acc, 'web_search', '{"query":"who won the match"}');
++ how.step(acc, 'set_up_agent', { name: 'Remote jobs' });
++ how.step(acc, 'no_such_tool', '{}');
++ how.step(acc, 'read_page', 'not json');
++ assert.deepStrictEqual(acc.steps, ['Searched the web for “who won the match”', 'Set up an agent: “Remote jobs”', 'Read a page']);
++ for (let i = 0; i < 30; i++) how.step(acc, 'search_openvibe', JSON.stringify({ query: `q${i}` }));
++ assert.strictEqual(acc.steps.length, 12);
++ assert.strictEqual(how.ofMessage('{"steps":[]}'), null);
++});
++
++t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); });