openvibers/openvibe.chat Commits
Vibe: Chat · Research · Agent · Code modes in the composer; the last three are agent tasks of the chat (on the person's own Actor allowance and computer) that answer in the chat when done (#171)
Changes
@@@ -439,6 +439,12 @@ chat, and the rest of Chat (Messages, Rooms and servers, Global chat) under the(forms post; the page shows Vibe's answer once written); `public/web/vibe.js` starts a chat in place, follows the roomsocket, sends on Enter and keeps a draft typed while signed out through the sign-in.++- **Modes** (the composer's chips, plan track K): **Chat** answers here and now. **Research**, **Agent** and **Code** post++ the message without Vibe's instant answer and make it a task of the chat (`vibe.work`, the workspace queue's machinery++ in `server/rooms/workspace.js`) on the person's own OpenVibe.Actor allowance: research with sources at once; Agent on++ their own computer through Node and Bot; Code with OpenVibe.Codes on their machine through Run. Vibe says it is on it++ with the task's link, and the result comes back into the chat. `POST /api/chat/vibe { message, mode }` starts a chat++ that way; `POST /api/chat/vibe/:slug/ask { message, mode }` asks in one; the forms post `mode` too.- **A chat is a private room** (`server/rooms/vibe.js`, `migrations/0015_vibe_chats.sql`: `rooms.assistant = 1`) theperson owns, with one AI member, Vibe, who answers every message without being named (`ai-members.js` afterPost).Messages, pictures, the socket (`join_room`, `room_message`, `room_ai_typing`), export and account deletion are the@@@ -140,7 +140,7 @@ body.oc-app-body { overflow: hidden; }/* ── The composer ─────────────────────────────────────────────────────────── */.vb-dock { padding: 0 16px max(10px, env(safe-area-inset-bottom)); }.vb-composer {-- max-width: var(--vb-col); margin: 0 auto; display: flex; align-items: flex-end; gap: 8px;++ max-width: var(--vb-col); margin: 0 auto; display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px;padding: 8px 8px 8px 18px; border-radius: var(--vb-radius); border: 1px solid var(--oc-line);background: var(--oc-panel); box-shadow: 0 10px 34px rgba(0, 0, 0, .22);transition: border-color .15s, box-shadow .15s;@@@ -161,6 +161,15 @@ body.oc-app-body { overflow: hidden; }.vb-send:hover { transform: translateY(-1px); }.vb-send:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }.vb-send[disabled], .vb-composer.is-empty .vb-send { opacity: .4; cursor: default; transform: none; }++/* The modes: a row of chips under the box (Chat · Research · Agent · Code); the checked one is lit. */++.vb-modes { order: 3; flex-basis: 100%; display: flex; gap: 6px; margin: 0 0 0 -10px; padding: 2px 0 0; border: 0; min-width: 0; overflow-x: auto; scrollbar-width: none; }++.vb-modes::-webkit-scrollbar { display: none; }++.vb-modes label { position: relative; flex: none; }++.vb-modes input { position: absolute; opacity: 0; pointer-events: none; }++.vb-modes span { display: inline-flex; align-items: center; padding: 4px 11px; border-radius: 999px; border: 1px solid var(--oc-line); color: var(--oc-muted); font-size: .8rem; font-weight: 600; cursor: pointer; white-space: nowrap; transition: background-color .15s, border-color .15s, color .15s; }++.vb-modes input:checked + span { background: color-mix(in srgb, var(--oc-accent) 16%, transparent); border-color: color-mix(in srgb, var(--oc-accent) 55%, var(--oc-line)); color: var(--oc-text); }++.vb-modes input:focus-visible + span { outline: 2px solid var(--oc-accent); outline-offset: 2px; }++@media (hover: hover) { .vb-modes span:hover { color: var(--oc-text); } }.vb-fine { max-width: var(--vb-col); margin: 6px auto 0; text-align: center; font-size: .76rem; color: var(--oc-muted); }/* ── Phones and narrow windows: the rail slides in over the chat ──────────── */@@@ -204,6 +204,11 @@var del = app.querySelector('[data-vb-delete]');if (del) del.addEventListener('submit', function (e) { if (!window.confirm('Delete this chat? Its messages go too.')) e.preventDefault(); });++ // ── Modes: the box says what the chosen one will do ──++ if (form) [].forEach.call(form.querySelectorAll('input[name="mode"]'), function (r) {++ r.addEventListener('change', function () { if (r.checked && input && r.getAttribute('data-placeholder') && !form.dataset.signin) input.placeholder = r.getAttribute('data-placeholder'); input.focus(); });++ });++// ── Sending ──if (form) form.addEventListener('submit', function (e) {var text = input.value.trim();@@@ -225,7 +230,12 @@showWait(); toBottom();input.value = ''; picture = null; if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; } if (fileInput) fileInput.value = ''; input.required = true; grow();var url, init;-- if (sendingPicture) {++ var modeEl = form.querySelector('input[name="mode"]:checked'), mode = modeEl ? modeEl.value : 'chat';++ if (mode !== 'chat' && !sendingPicture) {++ // Research, Agent or Code: an agent task of this chat; Vibe says it is on it, and the result comes back here.++ url = room ? '/api/chat/vibe/' + encodeURIComponent(room) + '/ask' : '/api/chat/vibe';++ init = { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, mode: mode }) };++ } else if (sendingPicture) {// The picture is the body; the question rides in X-Caption. A new chat starts with it (/api/chat/vibe/picture).url = room ? '/api/chat/rooms/' + encodeURIComponent(room) + '/pictures' : '/api/chat/vibe/picture';init = { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': sendingPicture.file.type, 'X-Caption': encodeURIComponent(text) }, body: sendingPicture.file };@@@ -260,7 +260,7 @@ async function announceDeleted(room, ids) {}}--async function post(room, user, text, { now = Date.now() } = {}) {++async function post(room, user, text, { now = Date.now(), ai = true } = {}) {const a = await access(room, user);if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');@@@ -288,8 +288,9 @@ async function post(room, user, text, { now = Date.now() } = {}) {return Number(r.lastInsertRowid);});const row = await messageById(id);-- // A message naming one of the room's AI members (@handle) gets its answer after this one is out.-- setImmediate(() => { require('./ai-members').afterPost(room, row, user).catch(() => {}); });++ // A message naming one of the room's AI members (@handle) gets its answer after this one is out. `ai: false` posts++ // without one (a Vibe chat's Research, Agent or Code request: the answer comes from the task instead).++ if (ai) setImmediate(() => { require('./ai-members').afterPost(room, row, user).catch(() => {}); });return row;}@@@ -41,13 +41,24 @@ router.get('/', requireAuth, personOnly, async (req, res) => {} catch (err) { send(res, err); }});++const modeOf = (b) => (b && b.mode && b.mode !== 'chat' ? String(b.mode) : null);router.post('/', requireAuth, personOnly, startLimit, async (req, res) => {try {-- const out = await vibe.start(req.user, req.body && req.body.message);++ const b = req.body || {};++ const out = modeOf(b) ? await vibe.startWork(req.user, b.message, modeOf(b)) : await vibe.start(req.user, b.message);res.status(201).json(out);} catch (err) { send(res, err); }});++/** A Research, Agent or Code request in an existing chat: { message, mode }. → { message, task } */++router.post('/:slug/ask', 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(201).json(await vibe.work(room, req.user, req.body && req.body.message, req.body && req.body.mode));++ } catch (err) { send(res, err); }++});++const pictureLimit = limits('chat.vibe.picture', { minute: 10, hour: 120 });const bearerOf = (req) => { const m = /^Bearer\s+(.+)$/i.exec(String(req.get('authorization') || '')); return m ? m[1].trim() : (req.cookies && req.cookies.ov_token) || null; };router.post('/picture', requireAuth, personOnly, pictureLimit, (req, res, next) => express.raw({ type: () => true, limit: (config.pics.maxBytes || 20 * 1024 * 1024) + 1 })(req, res, (err) => {@@@ -14,6 +14,11 @@* rename(room, user, name) → the room* remove(room, user) → the chat and its messages are deleted (not archived: it was the person's own)* 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++ * (server/rooms/workspace.js) on the person's own OpenVibe.Actor allowance: research++ * with sources; Agent on their computer (Node, through Bot); Code with OpenVibe.Codes++ * on their machine (through Run). Vibe says it is on it, and posts the result here.** Limits: config.ai.vibeRepliesPerDay answers a person a day, MAX_CHATS chats kept (starting one more deletes the* oldest), and each message goes through the rooms' own post checks (length, spam, rate).@@@ -81,6 +86,55 @@ async function start(user, text) {}}++// The composer's modes beyond Chat: each a kind of task in the chat's queue.++const MODES = Object.freeze({ research: 'research', agent: 'computer', code: 'code' });++const ON_IT = {++ research: 'On it: I am researching this as an agent task, with sources. The answer comes back right here, usually within a few minutes.',++ computer: 'On it: I am doing this on your own computer, through OpenVibe.Node, at the level you set there. I will say here what I did.',++ code: 'On it: I sent this to OpenVibe.Codes on your own computer (through OpenVibe.Run). The pull request comes back here when it is ready.',++};++++async function work(room, user, text, mode) {++ const kind = MODES[String(mode || '')];++ if (!kind) fail(422, 'vibe.mode', 'Pick Chat, Research, Agent or Code');++ if (!isVibeRoom(room) || !user || Number(room.owner_id) !== Number(user.id)) fail(404, 'rooms.not_found', 'No such chat');++ const message = await rooms.post(room, user, text, { ai: false });++ const workspace = require('./workspace');++ const member = await db.get("SELECT * FROM room_ai_members WHERE room_id = ? AND handle = 'vibe'", [room.id]);++ const say = async (line) => {++ if (!member) return null;++ const posted = await rooms.postAsAi(room, member, line);++ try { require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message: posted }); } catch { /* sockets are optional */ }++ return posted;++ };++ let task;++ try {++ task = await workspace.add(room, user, { title: String(text || ''), details: String(text || ''), kind, via: 'vibe', sourceMessage: message.id });++ if (kind !== 'research') task = await workspace.runOnComputer(room, user, task.id);++ } catch (err) {++ if (!(err && err.status)) throw err;++ await say(`I could not start that: ${err.message}.`);++ return { message, task: null };++ }++ // A task Actor refused at once was already answered in the chat (workspace.announce) with Actor's reason.++ if (task.status !== 'failed') await say(`${ON_IT[kind]} ${config.web.baseUrl}${task.url}`);++ return { message, task };++}++++/** A new chat that starts with a Research, Agent or Code request. → { room, message, task } */++async function startWork(user, text, mode) {++ const body = String(text || '').replace(/\r\n/g, '\n').trim();++ if (!MODES[String(mode || '')]) fail(422, 'vibe.mode', 'Pick Chat, Research, Agent or Code');++ if (!body) fail(422, 'rooms.empty', 'Write something first');++ const room = await createRoom(user, body);++ let out;++ try { out = await work(room, user, body, mode); } catch (err) {++ if (err && err.code && String(err.code).startsWith('rooms.')) await db.tx(() => erase(room.id)); // refused first message: no empty chat++ throw err;++ }++ return { room: await rooms.publicRoom(room, { role: 'owner' }), ...out };++}++async function list(user, { limit = 100 } = {}) {if (!user) return [];const lim = Math.min(MAX_CHATS, Math.max(1, Number(limit) || 100));@@@ -125,4 +179,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, DEFAULT_TITLE, VibeError, enabled, isVibeRoom, titleOf, start, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, ownChat, rename, remove, repliesToday, countReply };++module.exports = { VIBE, MAX_CHATS, TITLE_MAX, DEFAULT_TITLE, MODES, VibeError, enabled, isVibeRoom, titleOf, start, startWork, work, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, ownChat, rename, remove, repliesToday, countReply };@@@ -54,7 +54,8 @@ function shape(t, room) {}async function mustRead(room, user) {-- if (!rooms.isWorkspace(room)) fail(404, 'work.not_workspace', 'This room has no work queue');++ // A workspace room's queue, or a Vibe chat's own (its Research, Agent and Code requests, server/rooms/vibe.js).++ if (!rooms.isWorkspace(room) && Number(room.assistant) !== 1) fail(404, 'work.not_workspace', 'This room has no work queue');const a = await rooms.access(room, user);if (!a.read) fail(404, 'rooms.not_found', 'No such room');return a;@@@ -263,13 +264,14 @@ async function announce(room, t, deps = {}) {const members = await db.all('SELECT * FROM room_ai_members WHERE room_id = ? AND enabled = 1 ORDER BY (handle = \'vibe\') DESC, id LIMIT 1', [room.id]);const member = members[0];const who = t.requester_name || t.requester_username || 'someone';++ const asked = Number(room.assistant) === 1 ? '' : ` (asked by ${who})`; // a Vibe chat has one person in itlet text;if (t.status === 'succeeded') {const body = String(t.result || '').trim();const clip = body.length > 1500 ? `${body.slice(0, 1500).replace(/\s+\S*$/, '')}…` : body;-- text = `✅ Done: "${t.title}" (asked by ${who})${clip ? `\n\n${clip}` : ''}${t.result_url ? `\n\n${t.result_url}` : ''}\n\nFull result: ${config.web.baseUrl}/r/${room.slug}/tasks/${Number(t.id)}`;++ text = `✅ Done: "${t.title}"${asked}${clip ? `\n\n${clip}` : ''}${t.result_url ? `\n\n${t.result_url}` : ''}\n\nFull result: ${config.web.baseUrl}/r/${room.slug}/tasks/${Number(t.id)}`;} else if (t.status === 'failed') {-- text = `⚠️ Could not finish "${t.title}" (asked by ${who}): ${t.error || 'it did not work out'}. ${config.web.baseUrl}/r/${room.slug}/tasks/${Number(t.id)}`;++ text = `⚠️ Could not finish "${t.title}"${asked}: ${t.error || 'it did not work out'}. ${config.web.baseUrl}/r/${room.slug}/tasks/${Number(t.id)}`;} else {text = `Cancelled: "${t.title}".`;}@@@ -86,6 +86,14 @@ function turn(m, me) {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>`;}++// The composer's modes (plan track K): Chat answers here and now; the others are agent tasks that answer here when done.++const MODES = [++ ['chat', 'Chat', 'Talk it through: Vibe answers here and now', 'Message Vibe'],++ ['research', 'Research', 'An agent researches it and answers here with its sources', 'What should I research?'],++ ['agent', 'Agent', 'An agent does it on your own computer (OpenVibe.Node), at the level you set there', 'What should I do on your computer?'],++ ['code', 'Code', 'OpenVibe.Codes writes it on your computer and opens a pull request', 'What should I build or fix? Name the repository'],++];++function composer({ slug, signedIn, enabled, prefill = '' }) {const action = !signedIn ? `/auth/login` : slug ? `/vibe/${esc(slug)}` : '/vibe';// A picture (public/web/vibe.js): it goes to OpenVibe.Pics as the person, and Vibe sees it with the question.@@@ -99,6 +107,7 @@ ${attach}<label class="oc-sr" for="vb-input">Message Vibe</label><textarea id="vb-input" name="message" rows="${prefill ? 3 : 1}" maxlength="2000" placeholder="${signedIn ? 'Message Vibe' : 'Message Vibe (you will sign in first)'}"${enabled ? '' : ' disabled'}${prefill ? ' autofocus' : ''} required>${esc(prefill)}</textarea><button class="vb-send" type="submit" aria-label="Send"${enabled ? '' : ' disabled'}>${ICON.send}</button>++<fieldset class="vb-modes" aria-label="What Vibe does with it">${MODES.map(([value, label, hint, placeholder], i) => `<label title="${esc(hint)}"><input type="radio" name="mode" value="${value}" data-placeholder="${esc(placeholder)}"${i ? '' : ' checked'}><span>${label}</span></label>`).join('')}</fieldset></form><p class="vb-fine">${enabled ? 'Vibe can get things wrong. Check what matters.' : 'Vibe is resting right now. Try again later.'}</p>`;}@@@ -186,7 +195,8 @@ ${room ? thread : hello}router.post('/vibe', formLimit, sameSite, async (req, res) => {const actor = await needUser(req, res); if (!actor) return;try {-- const out = await vibe.start(actor.user, req.body && req.body.message);++ const b = req.body || {};++ const out = b.mode && b.mode !== 'chat' ? await vibe.startWork(actor.user, b.message, b.mode) : await vibe.start(actor.user, b.message);res.redirect(303, `/vibe/${out.room.slug}`);} catch (err) {await render(req, res, { status: err.status || 500, error: err.status ? err.message : 'Something went wrong. Try again.' });@@@ -198,8 +208,12 @@ ${room ? thread : hello}const room = await vibe.ownChat(req.params.slug, actor.user);if (!room) return res.redirect(303, '/vibe');try {-- const message = await rooms.post(room, actor.user, req.body && req.body.message);-- require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });++ const b = req.body || {};++ if (b.mode && b.mode !== 'chat') await vibe.work(room, actor.user, b.message, b.mode);++ else {++ const message = await rooms.post(room, actor.user, b.message);++ require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });++ }res.redirect(303, `/vibe/${room.slug}`);} catch (err) {await render(req, res, { room, status: err.status || 500, error: err.status ? err.message : 'Your message could not be sent.' });nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++/**++ * The Vibe composer's modes (server/rooms/vibe.js work/startWork, vibe-routes.js, web/vibe-pages.js; plan track K):++ * Research, Agent and Code post the message without Vibe's instant answer and make it a task of the chat on the++ * person's own OpenVibe.Actor allowance (research at once; Agent and Code on their own computer), Vibe says it is on it++ * with the task's link, and the result comes back into the chat (without "asked by": the chat is theirs alone). Chat++ * mode answers as before; an unknown mode, someone else's chat or no sign-in is refused; the composer shows the modes.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('vibe modes');++const SITE = 'https://openvibe.chat';++let h, ann, bob, workspace;++const turns = [];++const actorCalls = [];++let actorTask = { id: 'tsk_r1', state: 'queued' };++const vibeApi = (method, path, u, body) => h.http(method, `/api/chat/vibe${path}`, { token: u && u.token, body });++const messagesOf = async (slug) => ((await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: ann.token })).body.messages || []);++const realFetch = globalThis.fetch;++++t('boot', async () => {++ h = await boot({ env: { CHAT_WEB_URL: SITE } });++ workspace = require('../server/rooms/workspace');++ workspace._setTokens({ getToken: async () => 'chat-actor-token' });++ require('../server/ai/client')._setClient({++ runs: { create: async (workflow) => { turns.push(workflow); return { run: { status: 'succeeded', output: { choices: [{ message: { content: 'Chat answer' } }] } } }; } },++ });++ 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));++ globalThis.fetch = async (url, o = {}) => {++ if (!String(url).startsWith('http://127.0.0.1:4950')) return realFetch(url, o);++ 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 };++ };++});++++t('Research in a new chat: a task on Actor as the person, Vibe says it is on it, no instant answer', async () => {++ const r = await vibeApi('POST', '', ann, { message: 'Compare the three cheapest cloud browsers for agents', mode: 'research' });++ assert.strictEqual(r.status, 201, r.text);++ const slug = r.body.room.slug;++ assert.strictEqual(r.body.task.kind, 'research');++ const create = actorCalls.find((c) => c.method === 'POST');++ assert.strictEqual(create.subject, h.live.users.get(ann.id).subject_id, 'on the person\'s own allowance');++ assert.match(create.body.task, /^Research this and write a short report for a team, with the links of your sources: Compare the three cheapest/);++ await new Promise((res) => setTimeout(res, 150));++ const msgs = await messagesOf(slug);++ assert.deepStrictEqual(msgs.map((m) => (m.ai_member_id ? 'vibe' : 'ann')), ['ann', 'vibe']);++ 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'] } };++ 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');++});++++t('Code in an existing chat: Codes on their own computer; Agent too; Chat mode answers as before', async () => {++ const start = await vibeApi('POST', '', ann, { message: 'hello' });++ const slug = start.body.room.slug;++ await new Promise((res) => setTimeout(res, 150));++ assert.strictEqual(turns.length, 1, 'Chat mode: Vibe answers at once');++ actorTask = { id: 'tsk_c1', state: 'queued' };++ actorCalls.length = 0;++ let r = await vibeApi('POST', `/${slug}/ask`, ann, { message: 'Add a dark favicon to OpenVibe.Tools', mode: 'code' });++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual([r.body.task.kind, r.body.task.status, r.body.task.runner], ['code', 'running', 'actor']);++ assert.match(actorCalls.find((c) => c.method === 'POST').body.task, /^Make this code change with OpenVibe\.Codes on my own computer/);++ actorTask = { id: 'tsk_a1', state: 'queued' };++ r = await vibeApi('POST', `/${slug}/ask`, ann, { message: 'Tidy my Downloads folder', mode: 'agent' });++ assert.deepStrictEqual([r.body.task.kind, r.body.task.status], ['computer', 'running']);++ await new Promise((res) => setTimeout(res, 150));++ const vibeLines = (await messagesOf(slug)).filter((m) => m.ai_member_id).map((m) => m.message);++ assert.ok(vibeLines.some((l) => /^On it: I sent this to OpenVibe\.Codes on your own computer/.test(l)));++ assert.ok(vibeLines.some((l) => /^On it: I am doing this on your own computer, through OpenVibe\.Node/.test(l)));++ assert.strictEqual(turns.length, 1, 'no instant answers to the tasks');++});++++t('refusals: an unknown mode, someone else\'s chat, no sign-in', async () => {++ const start = await vibeApi('POST', '', ann, { message: 'mine' });++ const slug = start.body.room.slug;++ assert.strictEqual((await vibeApi('POST', `/${slug}/ask`, ann, { message: 'x', mode: 'shell' })).status, 422);++ assert.strictEqual((await vibeApi('POST', '', ann, { message: 'x', mode: 'shell' })).status, 422);++ assert.strictEqual((await vibeApi('POST', `/${slug}/ask`, bob, { message: 'mine now', mode: 'research' })).status, 404);++ assert.strictEqual((await vibeApi('POST', `/${slug}/ask`, null, { message: 'x', mode: 'research' })).status, 401);++});++++t('the composer shows the four modes, Chat chosen, each saying what it does', async () => {++ const page = await h.http('GET', '/vibe', { headers: { cookie: `ov_token=${ann.token}` } });++ assert.match(page.text, /<fieldset class="vb-modes" aria-label="What Vibe does with it">/);++ assert.match(page.text, /value="chat" data-placeholder="Message Vibe" checked><span>Chat<\/span>/);++ ['research', 'agent', 'code'].forEach((m) => assert.match(page.text, new RegExp(`value="${m}"`)));++});++++t.run(async () => { globalThis.fetch = realFetch; if (h && h.close) await h.close(); });
@@ -439,6 +439,12 @@ chat, and the rest of Chat (Messages, Rooms and servers, Global chat) under the
@@ -439,6 +439,12 @@ chat, and the rest of Chat (Messages, Rooms and servers, Global chat) under the
(forms post; the page shows Vibe's answer once written); `public/web/vibe.js` starts a chat in place, follows the room
(forms post; the page shows Vibe's answer once written); `public/web/vibe.js` starts a chat in place, follows the room
socket, sends on Enter and keeps a draft typed while signed out through the sign-in.
socket, sends on Enter and keeps a draft typed while signed out through the sign-in.
+- **Modes** (the composer's chips, plan track K): **Chat** answers here and now. **Research**, **Agent** and **Code** post
+ the message without Vibe's instant answer and make it a task of the chat (`vibe.work`, the workspace queue's machinery
+ in `server/rooms/workspace.js`) on the person's own OpenVibe.Actor allowance: research with sources at once; Agent on
+ their own computer through Node and Bot; Code with OpenVibe.Codes on their machine through Run. Vibe says it is on it
+ with the task's link, and the result comes back into the chat. `POST /api/chat/vibe { message, mode }` starts a chat
+ that way; `POST /api/chat/vibe/:slug/ask { message, mode }` asks in one; the forms post `mode` too.
- **A chat is a private room** (`server/rooms/vibe.js`, `migrations/0015_vibe_chats.sql`: `rooms.assistant = 1`) the
- **A chat is a private room** (`server/rooms/vibe.js`, `migrations/0015_vibe_chats.sql`: `rooms.assistant = 1`) the
person owns, with one AI member, Vibe, who answers every message without being named (`ai-members.js` afterPost).
person owns, with one AI member, Vibe, who answers every message without being named (`ai-members.js` afterPost).
Messages, pictures, the socket (`join_room`, `room_message`, `room_ai_typing`), export and account deletion are the
Messages, pictures, the socket (`join_room`, `room_message`, `room_ai_typing`), export and account deletion are the
@@ -140,7 +140,7 @@ body.oc-app-body { overflow: hidden; }
@@ -140,7 +140,7 @@ body.oc-app-body { overflow: hidden; }
/* ── The composer ─────────────────────────────────────────────────────────── */
/* ── The composer ─────────────────────────────────────────────────────────── */
.vb-dock { padding: 0 16px max(10px, env(safe-area-inset-bottom)); }
.vb-dock { padding: 0 16px max(10px, env(safe-area-inset-bottom)); }
.vb-composer {
.vb-composer {
- max-width: var(--vb-col); margin: 0 auto; display: flex; align-items: flex-end; gap: 8px;
+ max-width: var(--vb-col); margin: 0 auto; display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px;
padding: 8px 8px 8px 18px; border-radius: var(--vb-radius); border: 1px solid var(--oc-line);
padding: 8px 8px 8px 18px; border-radius: var(--vb-radius); border: 1px solid var(--oc-line);
background: var(--oc-panel); box-shadow: 0 10px 34px rgba(0, 0, 0, .22);
background: var(--oc-panel); box-shadow: 0 10px 34px rgba(0, 0, 0, .22);
transition: border-color .15s, box-shadow .15s;
transition: border-color .15s, box-shadow .15s;
@@ -161,6 +161,15 @@ body.oc-app-body { overflow: hidden; }
@@ -161,6 +161,15 @@ body.oc-app-body { overflow: hidden; }
.vb-send:hover { transform: translateY(-1px); }
.vb-send:hover { transform: translateY(-1px); }
.vb-send:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
.vb-send:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
.vb-send[disabled], .vb-composer.is-empty .vb-send { opacity: .4; cursor: default; transform: none; }
.vb-send[disabled], .vb-composer.is-empty .vb-send { opacity: .4; cursor: default; transform: none; }
+/* The modes: a row of chips under the box (Chat · Research · Agent · Code); the checked one is lit. */
+.vb-modes { order: 3; flex-basis: 100%; display: flex; gap: 6px; margin: 0 0 0 -10px; padding: 2px 0 0; border: 0; min-width: 0; overflow-x: auto; scrollbar-width: none; }
+.vb-modes::-webkit-scrollbar { display: none; }
+.vb-modes label { position: relative; flex: none; }
+.vb-modes input { position: absolute; opacity: 0; pointer-events: none; }
+.vb-modes span { display: inline-flex; align-items: center; padding: 4px 11px; border-radius: 999px; border: 1px solid var(--oc-line); color: var(--oc-muted); font-size: .8rem; font-weight: 600; cursor: pointer; white-space: nowrap; transition: background-color .15s, border-color .15s, color .15s; }
+.vb-modes input:checked + span { background: color-mix(in srgb, var(--oc-accent) 16%, transparent); border-color: color-mix(in srgb, var(--oc-accent) 55%, var(--oc-line)); color: var(--oc-text); }
+.vb-modes input:focus-visible + span { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
+@media (hover: hover) { .vb-modes span:hover { color: var(--oc-text); } }
.vb-fine { max-width: var(--vb-col); margin: 6px auto 0; text-align: center; font-size: .76rem; color: var(--oc-muted); }
.vb-fine { max-width: var(--vb-col); margin: 6px auto 0; text-align: center; font-size: .76rem; color: var(--oc-muted); }
/* ── Phones and narrow windows: the rail slides in over the chat ──────────── */
/* ── Phones and narrow windows: the rail slides in over the chat ──────────── */
@@ -204,6 +204,11 @@
@@ -204,6 +204,11 @@
var del = app.querySelector('[data-vb-delete]');
var del = app.querySelector('[data-vb-delete]');
if (del) del.addEventListener('submit', function (e) { if (!window.confirm('Delete this chat? Its messages go too.')) e.preventDefault(); });
if (del) del.addEventListener('submit', function (e) { if (!window.confirm('Delete this chat? Its messages go too.')) e.preventDefault(); });
+ // ── Modes: the box says what the chosen one will do ──
+ if (form) [].forEach.call(form.querySelectorAll('input[name="mode"]'), function (r) {
+ r.addEventListener('change', function () { if (r.checked && input && r.getAttribute('data-placeholder') && !form.dataset.signin) input.placeholder = r.getAttribute('data-placeholder'); input.focus(); });
+ });
+
// ── Sending ──
// ── Sending ──
if (form) form.addEventListener('submit', function (e) {
if (form) form.addEventListener('submit', function (e) {
var text = input.value.trim();
var text = input.value.trim();
@@ -225,7 +230,12 @@
@@ -225,7 +230,12 @@
showWait(); toBottom();
showWait(); toBottom();
input.value = ''; picture = null; if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; } if (fileInput) fileInput.value = ''; input.required = true; grow();
input.value = ''; picture = null; if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; } if (fileInput) fileInput.value = ''; input.required = true; grow();
var url, init;
var url, init;
- if (sendingPicture) {
+ var modeEl = form.querySelector('input[name="mode"]:checked'), mode = modeEl ? modeEl.value : 'chat';
+ if (mode !== 'chat' && !sendingPicture) {
+ // Research, Agent or Code: an agent task of this chat; Vibe says it is on it, and the result comes back here.
+ url = room ? '/api/chat/vibe/' + encodeURIComponent(room) + '/ask' : '/api/chat/vibe';
+ init = { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, mode: mode }) };
+ } else if (sendingPicture) {
// The picture is the body; the question rides in X-Caption. A new chat starts with it (/api/chat/vibe/picture).
// The picture is the body; the question rides in X-Caption. A new chat starts with it (/api/chat/vibe/picture).
url = room ? '/api/chat/rooms/' + encodeURIComponent(room) + '/pictures' : '/api/chat/vibe/picture';
url = room ? '/api/chat/rooms/' + encodeURIComponent(room) + '/pictures' : '/api/chat/vibe/picture';
init = { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': sendingPicture.file.type, 'X-Caption': encodeURIComponent(text) }, body: sendingPicture.file };
init = { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': sendingPicture.file.type, 'X-Caption': encodeURIComponent(text) }, body: sendingPicture.file };
@@ -260,7 +260,7 @@ async function announceDeleted(room, ids) {
@@ -260,7 +260,7 @@ async function announceDeleted(room, ids) {
}
}
}
}
-async function post(room, user, text, { now = Date.now() } = {}) {
+async function post(room, user, text, { now = Date.now(), ai = true } = {}) {
const a = await access(room, user);
const a = await access(room, user);
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
@@ -288,8 +288,9 @@ async function post(room, user, text, { now = Date.now() } = {}) {
@@ -288,8 +288,9 @@ async function post(room, user, text, { now = Date.now() } = {}) {
return Number(r.lastInsertRowid);
return Number(r.lastInsertRowid);
});
});
const row = await messageById(id);
const row = await messageById(id);
- // A message naming one of the room's AI members (@handle) gets its answer after this one is out.
- setImmediate(() => { require('./ai-members').afterPost(room, row, user).catch(() => {}); });
+ // A message naming one of the room's AI members (@handle) gets its answer after this one is out. `ai: false` posts
+ // without one (a Vibe chat's Research, Agent or Code request: the answer comes from the task instead).
+ if (ai) setImmediate(() => { require('./ai-members').afterPost(room, row, user).catch(() => {}); });
return row;
return row;
}
}
@@ -41,13 +41,24 @@ router.get('/', requireAuth, personOnly, async (req, res) => {
@@ -41,13 +41,24 @@ router.get('/', requireAuth, personOnly, async (req, res) => {
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
+const modeOf = (b) => (b && b.mode && b.mode !== 'chat' ? String(b.mode) : null);
router.post('/', requireAuth, personOnly, startLimit, async (req, res) => {
router.post('/', requireAuth, personOnly, startLimit, async (req, res) => {
try {
try {
- const out = await vibe.start(req.user, req.body && req.body.message);
+ const b = req.body || {};
+ const out = modeOf(b) ? await vibe.startWork(req.user, b.message, modeOf(b)) : await vibe.start(req.user, b.message);
res.status(201).json(out);
res.status(201).json(out);
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
+/** A Research, Agent or Code request in an existing chat: { message, mode }. → { message, task } */
+router.post('/:slug/ask', 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(201).json(await vibe.work(room, req.user, req.body && req.body.message, req.body && req.body.mode));
+ } catch (err) { send(res, err); }
+});
+
const pictureLimit = limits('chat.vibe.picture', { minute: 10, hour: 120 });
const pictureLimit = limits('chat.vibe.picture', { minute: 10, hour: 120 });
const bearerOf = (req) => { const m = /^Bearer\s+(.+)$/i.exec(String(req.get('authorization') || '')); return m ? m[1].trim() : (req.cookies && req.cookies.ov_token) || null; };
const bearerOf = (req) => { const m = /^Bearer\s+(.+)$/i.exec(String(req.get('authorization') || '')); return m ? m[1].trim() : (req.cookies && req.cookies.ov_token) || null; };
router.post('/picture', requireAuth, personOnly, pictureLimit, (req, res, next) => express.raw({ type: () => true, limit: (config.pics.maxBytes || 20 * 1024 * 1024) + 1 })(req, res, (err) => {
router.post('/picture', requireAuth, personOnly, pictureLimit, (req, res, next) => express.raw({ type: () => true, limit: (config.pics.maxBytes || 20 * 1024 * 1024) + 1 })(req, res, (err) => {
@@ -14,6 +14,11 @@
@@ -14,6 +14,11 @@
* 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)
* 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
+ * message is posted without Vibe's instant answer and becomes a task of the chat
+ * (server/rooms/workspace.js) on the person's own OpenVibe.Actor allowance: research
+ * with sources; Agent on their computer (Node, through Bot); Code with OpenVibe.Codes
+ * on their machine (through Run). Vibe says it is on it, and posts the result here.
*
*
* Limits: config.ai.vibeRepliesPerDay answers a person a day, MAX_CHATS chats kept (starting one more deletes the
* Limits: config.ai.vibeRepliesPerDay answers a person a day, MAX_CHATS chats kept (starting one more deletes the
* oldest), and each message goes through the rooms' own post checks (length, spam, rate).
* oldest), and each message goes through the rooms' own post checks (length, spam, rate).
@@ -81,6 +86,55 @@ async function start(user, text) {
@@ -81,6 +86,55 @@ async function start(user, text) {
}
}
}
}
+// The composer's modes beyond Chat: each a kind of task in the chat's queue.
+const MODES = Object.freeze({ research: 'research', agent: 'computer', code: 'code' });
+const ON_IT = {
+ research: 'On it: I am researching this as an agent task, with sources. The answer comes back right here, usually within a few minutes.',
+ computer: 'On it: I am doing this on your own computer, through OpenVibe.Node, at the level you set there. I will say here what I did.',
+ code: 'On it: I sent this to OpenVibe.Codes on your own computer (through OpenVibe.Run). The pull request comes back here when it is ready.',
+};
+
+async function work(room, user, text, mode) {
+ const kind = MODES[String(mode || '')];
+ if (!kind) fail(422, 'vibe.mode', 'Pick Chat, Research, Agent or Code');
+ if (!isVibeRoom(room) || !user || Number(room.owner_id) !== Number(user.id)) fail(404, 'rooms.not_found', 'No such chat');
+ const message = await rooms.post(room, user, text, { ai: false });
+ const workspace = require('./workspace');
+ const member = await db.get("SELECT * FROM room_ai_members WHERE room_id = ? AND handle = 'vibe'", [room.id]);
+ const say = async (line) => {
+ if (!member) return null;
+ const posted = await rooms.postAsAi(room, member, line);
+ try { require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message: posted }); } catch { /* sockets are optional */ }
+ return posted;
+ };
+ let task;
+ try {
+ task = await workspace.add(room, user, { title: String(text || ''), details: String(text || ''), kind, via: 'vibe', sourceMessage: message.id });
+ if (kind !== 'research') task = await workspace.runOnComputer(room, user, task.id);
+ } catch (err) {
+ if (!(err && err.status)) throw err;
+ await say(`I could not start that: ${err.message}.`);
+ return { message, task: null };
+ }
+ // A task Actor refused at once was already answered in the chat (workspace.announce) with Actor's reason.
+ if (task.status !== 'failed') await say(`${ON_IT[kind]} ${config.web.baseUrl}${task.url}`);
+ return { message, task };
+}
+
+/** A new chat that starts with a Research, Agent or Code request. → { room, message, task } */
+async function startWork(user, text, mode) {
+ const body = String(text || '').replace(/\r\n/g, '\n').trim();
+ if (!MODES[String(mode || '')]) fail(422, 'vibe.mode', 'Pick Chat, Research, Agent or Code');
+ if (!body) fail(422, 'rooms.empty', 'Write something first');
+ const room = await createRoom(user, body);
+ let out;
+ try { out = await work(room, user, body, mode); } catch (err) {
+ if (err && err.code && String(err.code).startsWith('rooms.')) await db.tx(() => erase(room.id)); // refused first message: no empty chat
+ throw err;
+ }
+ return { room: await rooms.publicRoom(room, { role: 'owner' }), ...out };
+}
+
async function list(user, { limit = 100 } = {}) {
async function list(user, { limit = 100 } = {}) {
if (!user) return [];
if (!user) return [];
const lim = Math.min(MAX_CHATS, Math.max(1, Number(limit) || 100));
const lim = Math.min(MAX_CHATS, Math.max(1, Number(limit) || 100));
@@ -125,4 +179,4 @@ async function countReply(user) {
@@ -125,4 +179,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, DEFAULT_TITLE, VibeError, enabled, isVibeRoom, titleOf, start, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, ownChat, rename, remove, repliesToday, countReply };
+module.exports = { VIBE, MAX_CHATS, TITLE_MAX, DEFAULT_TITLE, MODES, VibeError, enabled, isVibeRoom, titleOf, start, startWork, work, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, ownChat, rename, remove, repliesToday, countReply };
@@ -54,7 +54,8 @@ function shape(t, room) {
@@ -54,7 +54,8 @@ function shape(t, room) {
}
}
async function mustRead(room, user) {
async function mustRead(room, user) {
- if (!rooms.isWorkspace(room)) fail(404, 'work.not_workspace', 'This room has no work queue');
+ // A workspace room's queue, or a Vibe chat's own (its Research, Agent and Code requests, server/rooms/vibe.js).
+ if (!rooms.isWorkspace(room) && Number(room.assistant) !== 1) fail(404, 'work.not_workspace', 'This room has no work queue');
const a = await rooms.access(room, user);
const a = await rooms.access(room, user);
if (!a.read) fail(404, 'rooms.not_found', 'No such room');
if (!a.read) fail(404, 'rooms.not_found', 'No such room');
return a;
return a;
@@ -263,13 +264,14 @@ async function announce(room, t, deps = {}) {
@@ -263,13 +264,14 @@ async function announce(room, t, deps = {}) {
const members = await db.all('SELECT * FROM room_ai_members WHERE room_id = ? AND enabled = 1 ORDER BY (handle = \'vibe\') DESC, id LIMIT 1', [room.id]);
const members = await db.all('SELECT * FROM room_ai_members WHERE room_id = ? AND enabled = 1 ORDER BY (handle = \'vibe\') DESC, id LIMIT 1', [room.id]);
const member = members[0];
const member = members[0];
const who = t.requester_name || t.requester_username || 'someone';
const who = t.requester_name || t.requester_username || 'someone';
+ const asked = Number(room.assistant) === 1 ? '' : ` (asked by ${who})`; // a Vibe chat has one person in it
let text;
let text;
if (t.status === 'succeeded') {
if (t.status === 'succeeded') {
const body = String(t.result || '').trim();
const body = String(t.result || '').trim();
const clip = body.length > 1500 ? `${body.slice(0, 1500).replace(/\s+\S*$/, '')}…` : body;
const clip = body.length > 1500 ? `${body.slice(0, 1500).replace(/\s+\S*$/, '')}…` : body;
- text = `✅ Done: "${t.title}" (asked by ${who})${clip ? `\n\n${clip}` : ''}${t.result_url ? `\n\n${t.result_url}` : ''}\n\nFull result: ${config.web.baseUrl}/r/${room.slug}/tasks/${Number(t.id)}`;
+ text = `✅ Done: "${t.title}"${asked}${clip ? `\n\n${clip}` : ''}${t.result_url ? `\n\n${t.result_url}` : ''}\n\nFull result: ${config.web.baseUrl}/r/${room.slug}/tasks/${Number(t.id)}`;
} else if (t.status === 'failed') {
} else if (t.status === 'failed') {
- text = `⚠️ Could not finish "${t.title}" (asked by ${who}): ${t.error || 'it did not work out'}. ${config.web.baseUrl}/r/${room.slug}/tasks/${Number(t.id)}`;
+ text = `⚠️ Could not finish "${t.title}"${asked}: ${t.error || 'it did not work out'}. ${config.web.baseUrl}/r/${room.slug}/tasks/${Number(t.id)}`;
} else {
} else {
text = `Cancelled: "${t.title}".`;
text = `Cancelled: "${t.title}".`;
}
}
@@ -86,6 +86,14 @@ function turn(m, me) {
@@ -86,6 +86,14 @@ function turn(m, me) {
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>`;
}
}
+// The composer's modes (plan track K): Chat answers here and now; the others are agent tasks that answer here when done.
+const MODES = [
+ ['chat', 'Chat', 'Talk it through: Vibe answers here and now', 'Message Vibe'],
+ ['research', 'Research', 'An agent researches it and answers here with its sources', 'What should I research?'],
+ ['agent', 'Agent', 'An agent does it on your own computer (OpenVibe.Node), at the level you set there', 'What should I do on your computer?'],
+ ['code', 'Code', 'OpenVibe.Codes writes it on your computer and opens a pull request', 'What should I build or fix? Name the repository'],
+];
+
function composer({ slug, signedIn, enabled, prefill = '' }) {
function composer({ slug, signedIn, enabled, prefill = '' }) {
const action = !signedIn ? `/auth/login` : slug ? `/vibe/${esc(slug)}` : '/vibe';
const action = !signedIn ? `/auth/login` : slug ? `/vibe/${esc(slug)}` : '/vibe';
// A picture (public/web/vibe.js): it goes to OpenVibe.Pics as the person, and Vibe sees it with the question.
// A picture (public/web/vibe.js): it goes to OpenVibe.Pics as the person, and Vibe sees it with the question.
@@ -99,6 +107,7 @@ ${attach}
@@ -99,6 +107,7 @@ ${attach}
<label class="oc-sr" for="vb-input">Message Vibe</label>
<label class="oc-sr" for="vb-input">Message Vibe</label>
<textarea id="vb-input" name="message" rows="${prefill ? 3 : 1}" maxlength="2000" placeholder="${signedIn ? 'Message Vibe' : 'Message Vibe (you will sign in first)'}"${enabled ? '' : ' disabled'}${prefill ? ' autofocus' : ''} required>${esc(prefill)}</textarea>
<textarea id="vb-input" name="message" rows="${prefill ? 3 : 1}" maxlength="2000" placeholder="${signedIn ? 'Message Vibe' : 'Message Vibe (you will sign in first)'}"${enabled ? '' : ' disabled'}${prefill ? ' autofocus' : ''} required>${esc(prefill)}</textarea>
<button class="vb-send" type="submit" aria-label="Send"${enabled ? '' : ' disabled'}>${ICON.send}</button>
<button class="vb-send" type="submit" aria-label="Send"${enabled ? '' : ' disabled'}>${ICON.send}</button>
+<fieldset class="vb-modes" aria-label="What Vibe does with it">${MODES.map(([value, label, hint, placeholder], i) => `<label title="${esc(hint)}"><input type="radio" name="mode" value="${value}" data-placeholder="${esc(placeholder)}"${i ? '' : ' checked'}><span>${label}</span></label>`).join('')}</fieldset>
</form>
</form>
<p class="vb-fine">${enabled ? 'Vibe can get things wrong. Check what matters.' : 'Vibe is resting right now. Try again later.'}</p>`;
<p class="vb-fine">${enabled ? 'Vibe can get things wrong. Check what matters.' : 'Vibe is resting right now. Try again later.'}</p>`;
}
}
@@ -186,7 +195,8 @@ ${room ? thread : hello}
@@ -186,7 +195,8 @@ ${room ? thread : hello}
router.post('/vibe', formLimit, sameSite, async (req, res) => {
router.post('/vibe', formLimit, sameSite, async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
try {
try {
- const out = await vibe.start(actor.user, req.body && req.body.message);
+ const b = req.body || {};
+ const out = b.mode && b.mode !== 'chat' ? await vibe.startWork(actor.user, b.message, b.mode) : await vibe.start(actor.user, b.message);
res.redirect(303, `/vibe/${out.room.slug}`);
res.redirect(303, `/vibe/${out.room.slug}`);
} catch (err) {
} catch (err) {
await render(req, res, { status: err.status || 500, error: err.status ? err.message : 'Something went wrong. Try again.' });
await render(req, res, { status: err.status || 500, error: err.status ? err.message : 'Something went wrong. Try again.' });
@@ -198,8 +208,12 @@ ${room ? thread : hello}
@@ -198,8 +208,12 @@ ${room ? thread : hello}
const room = await vibe.ownChat(req.params.slug, actor.user);
const room = await vibe.ownChat(req.params.slug, actor.user);
if (!room) return res.redirect(303, '/vibe');
if (!room) return res.redirect(303, '/vibe');
try {
try {
- const message = await rooms.post(room, actor.user, req.body && req.body.message);
- require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
+ const b = req.body || {};
+ if (b.mode && b.mode !== 'chat') await vibe.work(room, actor.user, b.message, b.mode);
+ else {
+ const message = await rooms.post(room, actor.user, b.message);
+ require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
+ }
res.redirect(303, `/vibe/${room.slug}`);
res.redirect(303, `/vibe/${room.slug}`);
} catch (err) {
} catch (err) {
await render(req, res, { room, status: err.status || 500, error: err.status ? err.message : 'Your message could not be sent.' });
await render(req, res, { room, status: err.status || 500, error: err.status ? err.message : 'Your message could not be sent.' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+/**
+ * The Vibe composer's modes (server/rooms/vibe.js work/startWork, vibe-routes.js, web/vibe-pages.js; plan track K):
+ * Research, Agent and Code post the message without Vibe's instant answer and make it a task of the chat on the
+ * person's own OpenVibe.Actor allowance (research at once; Agent and Code on their own computer), Vibe says it is on it
+ * with the task's link, and the result comes back into the chat (without "asked by": the chat is theirs alone). Chat
+ * mode answers as before; an unknown mode, someone else's chat or no sign-in is refused; the composer shows the modes.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('vibe modes');
+const SITE = 'https://openvibe.chat';
+let h, ann, bob, workspace;
+const turns = [];
+const actorCalls = [];
+let actorTask = { id: 'tsk_r1', state: 'queued' };
+const vibeApi = (method, path, u, body) => h.http(method, `/api/chat/vibe${path}`, { token: u && u.token, body });
+const messagesOf = async (slug) => ((await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: ann.token })).body.messages || []);
+const realFetch = globalThis.fetch;
+
+t('boot', async () => {
+ h = await boot({ env: { CHAT_WEB_URL: SITE } });
+ workspace = require('../server/rooms/workspace');
+ workspace._setTokens({ getToken: async () => 'chat-actor-token' });
+ require('../server/ai/client')._setClient({
+ runs: { create: async (workflow) => { turns.push(workflow); return { run: { status: 'succeeded', output: { choices: [{ message: { content: 'Chat answer' } }] } } }; } },
+ });
+ 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));
+ globalThis.fetch = async (url, o = {}) => {
+ if (!String(url).startsWith('http://127.0.0.1:4950')) return realFetch(url, o);
+ 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 };
+ };
+});
+
+t('Research in a new chat: a task on Actor as the person, Vibe says it is on it, no instant answer', async () => {
+ const r = await vibeApi('POST', '', ann, { message: 'Compare the three cheapest cloud browsers for agents', mode: 'research' });
+ assert.strictEqual(r.status, 201, r.text);
+ const slug = r.body.room.slug;
+ assert.strictEqual(r.body.task.kind, 'research');
+ const create = actorCalls.find((c) => c.method === 'POST');
+ assert.strictEqual(create.subject, h.live.users.get(ann.id).subject_id, 'on the person\'s own allowance');
+ assert.match(create.body.task, /^Research this and write a short report for a team, with the links of your sources: Compare the three cheapest/);
+ await new Promise((res) => setTimeout(res, 150));
+ const msgs = await messagesOf(slug);
+ assert.deepStrictEqual(msgs.map((m) => (m.ai_member_id ? 'vibe' : 'ann')), ['ann', 'vibe']);
+ 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'] } };
+ 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');
+});
+
+t('Code in an existing chat: Codes on their own computer; Agent too; Chat mode answers as before', async () => {
+ const start = await vibeApi('POST', '', ann, { message: 'hello' });
+ const slug = start.body.room.slug;
+ await new Promise((res) => setTimeout(res, 150));
+ assert.strictEqual(turns.length, 1, 'Chat mode: Vibe answers at once');
+ actorTask = { id: 'tsk_c1', state: 'queued' };
+ actorCalls.length = 0;
+ let r = await vibeApi('POST', `/${slug}/ask`, ann, { message: 'Add a dark favicon to OpenVibe.Tools', mode: 'code' });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual([r.body.task.kind, r.body.task.status, r.body.task.runner], ['code', 'running', 'actor']);
+ assert.match(actorCalls.find((c) => c.method === 'POST').body.task, /^Make this code change with OpenVibe\.Codes on my own computer/);
+ actorTask = { id: 'tsk_a1', state: 'queued' };
+ r = await vibeApi('POST', `/${slug}/ask`, ann, { message: 'Tidy my Downloads folder', mode: 'agent' });
+ assert.deepStrictEqual([r.body.task.kind, r.body.task.status], ['computer', 'running']);
+ await new Promise((res) => setTimeout(res, 150));
+ const vibeLines = (await messagesOf(slug)).filter((m) => m.ai_member_id).map((m) => m.message);
+ assert.ok(vibeLines.some((l) => /^On it: I sent this to OpenVibe\.Codes on your own computer/.test(l)));
+ assert.ok(vibeLines.some((l) => /^On it: I am doing this on your own computer, through OpenVibe\.Node/.test(l)));
+ assert.strictEqual(turns.length, 1, 'no instant answers to the tasks');
+});
+
+t('refusals: an unknown mode, someone else\'s chat, no sign-in', async () => {
+ const start = await vibeApi('POST', '', ann, { message: 'mine' });
+ const slug = start.body.room.slug;
+ assert.strictEqual((await vibeApi('POST', `/${slug}/ask`, ann, { message: 'x', mode: 'shell' })).status, 422);
+ assert.strictEqual((await vibeApi('POST', '', ann, { message: 'x', mode: 'shell' })).status, 422);
+ assert.strictEqual((await vibeApi('POST', `/${slug}/ask`, bob, { message: 'mine now', mode: 'research' })).status, 404);
+ assert.strictEqual((await vibeApi('POST', `/${slug}/ask`, null, { message: 'x', mode: 'research' })).status, 401);
+});
+
+t('the composer shows the four modes, Chat chosen, each saying what it does', async () => {
+ const page = await h.http('GET', '/vibe', { headers: { cookie: `ov_token=${ann.token}` } });
+ assert.match(page.text, /<fieldset class="vb-modes" aria-label="What Vibe does with it">/);
+ assert.match(page.text, /value="chat" data-placeholder="Message Vibe" checked><span>Chat<\/span>/);
+ ['research', 'agent', 'code'].forEach((m) => assert.match(page.text, new RegExp(`value="${m}"`)));
+});
+
+t.run(async () => { globalThis.fetch = realFetch; if (h && h.close) await h.close(); });