openvibers/openvibe.chat Commits
Vibe from any OpenVibe site: /api/vibe/chat and /api/vibe/ask for the Dock (L48 V2) (#283)
Changes
@@@ -257,6 +257,7 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {const limits = require('./net/actor-limits').limits.attach(metrics.registry);app.use('/api/chat', limits.identify, limits.reads('chat.read'));app.use('/api/dm', limits.identify, limits.reads('chat.dm.read'));++ app.use('/api/vibe', limits.identify, limits.reads('chat.vibe.read'));app.use('/api/tts', limits.identify, limits.reads('chat.tts.read', { skip: (req) => req.path.startsWith('/audio/') }));app.use('/api/sounds', limits.identify, limits.reads('chat.sounds.read', { skip: (req) => req.path.startsWith('/file/') }));app.use('/api/emotes', limits.identify, limits.reads('chat.emotes.read', { skip: (req) => req.path.startsWith('/file/') }));@@@ -281,6 +282,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {app.use('/api/chat/ai', require('./chat/ai-routes'));app.use('/api/chat', require('./chat/routes'));app.use('/api/dm', require('./chat/dm-routes'));++ // Vibe from any OpenVibe site (the Dock, plan L48 V2): ask, and read the answer, with the person's own token.++ app.use('/api/vibe', require('./rooms/vibe-api'));app.use('/api/tts', require('./chat/tts-routes'));app.use('/api/sounds', require('./chat/sounds-routes'));// Custom emotes (Live's /api/emotes paths, plan T3): bytes go to OpenVibe.Media, rows are Chat's.nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++/**++ * Vibe from any OpenVibe site (plan L48, V2; the owner, 2026-11-01: "access your agent or the AI or the conversation++ * from across all websites… use a voice feature to tell it to set a reminder… have a conversation with your Vibe AI").++ * The Dock (openvibe-shared/dock.js) calls these with the person's own Network token, as it calls /api/dm: the same++ * conversations as openvibe.chat/vibe, so a chat started on Live carries on on Chat and back.++ *++ * GET /api/vibe/chat?slug=&after= the chat (by slug, else the latest) and its messages: { chat, messages }, or++ * { chat: null } before the first. after = a message id: only what came after it++ * (the Dock polls this for Vibe's answer).++ * POST /api/vibe/ask { message, slug?, timezone?, fresh? }++ * post into that chat, else the latest one if it moved in the last 6 hours, else++ * a new chat (fresh: always new). Vibe answers in the background, as on Chat.++ * → { chat, message }++ */++const express = require('express');++const { requireAuth } = require('../auth/auth');++const { limits } = require('../net/actor-limits');++const rooms = require('./rooms');++const vibe = require('./vibe');++++const router = express.Router();++router.use(express.json({ limit: '32kb' }));++router.use(requireAuth);++++const SIX_HOURS = 6 * 3600 * 1000;++const asked = limits('chat.vibe.ask', { minute: 12, hour: 300 });++const zone = (tz) => { try { new Intl.DateTimeFormat('en-US', { timeZone: String(tz) }); return String(tz).slice(0, 64); } catch { return null; } };++const at = (ts) => { const d = new Date(String(ts || '').includes('T') ? ts : `${String(ts || '').replace(' ', 'T')}Z`); return Number.isNaN(d.getTime()) ? null : d.toISOString(); };++const wire = (m) => ({ id: Number(m.id), from: m.ai_member_id ? 'vibe' : 'you', text: String(m.message || ''), at: at(m.created_at) });++const chatOf = (room) => (room ? { slug: room.slug, name: room.name || 'Vibe', url: `https://openvibe.chat/vibe/${encodeURIComponent(room.slug)}` } : null);++++async function latest(user) {++ const list = await vibe.list(user, { limit: 1 });++ const first = Array.isArray(list) ? list[0] : (list && list.chats ? list.chats[0] : null);++ return first ? await vibe.ownChat(first.slug, user) : null;++}++++router.get('/chat', async (req, res) => {++ if (req.authSource === 'api_token' || req.authSource === 'dock') return res.status(403).json({ error: 'Vibe is for the person themself' });++ const room = req.query.slug ? await vibe.ownChat(String(req.query.slug), req.user) : await latest(req.user);++ if (!room) return res.set('Cache-Control', 'private, no-store').json({ chat: null, messages: [] });++ const after = Number(req.query.after) || null;++ const rows = await rooms.history(room, after ? { after, limit: 50 } : { limit: 40 });++ return res.set('Cache-Control', 'private, no-store').json({ chat: chatOf(room), messages: rows.filter((m) => !m.is_deleted).map(wire) });++});++++router.post('/ask', asked, async (req, res) => {++ if (req.authSource === 'api_token' || req.authSource === 'dock') return res.status(403).json({ error: 'Vibe is for the person themself' });++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const message = String(b.message || '').replace(/\r\n/g, '\n').trim();++ if (!message) return res.status(422).json({ error: 'Write something first' });++ if (message.length > 8000) return res.status(422).json({ error: 'That is longer than Vibe reads in one message' });++ const timezone = zone(b.timezone);++ try {++ let room = b.slug ? await vibe.ownChat(String(b.slug), req.user) : null;++ if (!room && !b.fresh) {++ const last = await latest(req.user);++ const moved = last ? Date.parse(at(last.updated_at || last.last_message_at || last.created_at) || 0) : 0;++ if (last && Date.now() - moved < SIX_HOURS) room = last;++ }++ if (!room) {++ const out = await vibe.start(req.user, message, { timezone });++ const full = await vibe.ownChat(out.room.slug, req.user);++ return res.status(201).json({ chat: chatOf(full || out.room), message: wire(out.message) });++ }++ const posted = await rooms.post(room, req.user, message, { timezone });++ try { require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message: posted }); } catch { /* the socket is optional */ }++ return res.status(201).json({ chat: chatOf(room), message: wire(posted) });++ } catch (err) {++ return res.status(err.status || 500).json({ error: err.status ? err.message : 'Your message could not be sent' });++ }++});++++module.exports = router;nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++/**++ * Vibe from any OpenVibe site (plan L48, V2; server/rooms/vibe-api.js): the Dock asks Vibe and reads its answer with++ * the person's own token. Nothing before the first chat; a question starts one and Vibe answers it in the background;++ * the next question within six hours carries the same chat on; fresh starts a new one; a chat is its person's alone;++ * a visitor gets nothing.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('vibe api');++let h, ann, bob, aiClient;++const say = (content) => ({ choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }] });++const wait = (ms) => new Promise((r) => setTimeout(r, ms));++async function answerAfter(token, slug, id) {++ for (let i = 0; i < 60; i++) {++ const r = await h.http('GET', `/api/vibe/chat?slug=${encodeURIComponent(slug)}&after=${id}`, { token });++ const v = (r.body.messages || []).find((m) => m.from === 'vibe');++ if (v) return v;++ await wait(100);++ }++ return null;++}++++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') });++ bob = h.addUser('bob', { subject: ids.newId('user') });++ for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));++ aiClient._setClient({ runs: { create: async (workflow) => ({ run: { status: 'succeeded', output: workflow === 'chat.member.turn' ? say('Hello from Vibe.') : { text: 'Hello from Vibe.' } } }) } });++});++++t('nothing before the first chat; a visitor gets nothing at all', async () => {++ const r = await h.http('GET', '/api/vibe/chat', { token: ann.token });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.body, { chat: null, messages: [] });++ assert.strictEqual((await h.http('GET', '/api/vibe/chat')).status, 401);++ assert.strictEqual((await h.http('POST', '/api/vibe/ask', { body: { message: 'hi' } })).status, 401);++});++++t('a question starts a chat and Vibe answers it in the background; the next one carries the same chat on', async () => {++ const first = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'What can you do?', timezone: 'Europe/Paris' } });++ assert.strictEqual(first.status, 201, first.text);++ const { chat, message } = first.body;++ assert.ok(chat.slug && /^https:\/\/openvibe\.chat\/vibe\//.test(chat.url));++ assert.deepStrictEqual([message.from, message.text], ['you', 'What can you do?']);++ const answer = await answerAfter(ann.token, chat.slug, message.id);++ assert.ok(answer && /Hello from Vibe/.test(answer.text), 'Vibe answered');++ const again = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'And reminders?' } });++ assert.strictEqual(again.body.chat.slug, chat.slug, 'within six hours: the same chat');++ const fresh = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'Something new', fresh: true } });++ assert.notStrictEqual(fresh.body.chat.slug, chat.slug, 'fresh: a new chat');++ const latest = await h.http('GET', '/api/vibe/chat', { token: ann.token });++ assert.strictEqual(latest.body.chat.slug, fresh.body.chat.slug, 'the latest chat first');++});++++t('a chat is its person\'s alone, and an empty message is refused', async () => {++ const mine = (await h.http('GET', '/api/vibe/chat', { token: ann.token })).body.chat.slug;++ const peek = await h.http('GET', `/api/vibe/chat?slug=${mine}`, { token: bob.token });++ assert.deepStrictEqual(peek.body, { chat: null, messages: [] }, 'another person sees nothing of it');++ const into = await h.http('POST', '/api/vibe/ask', { token: bob.token, body: { message: 'hi', slug: mine } });++ assert.notStrictEqual(into.body.chat.slug, mine, 'naming someone else\'s chat starts their own instead');++ assert.strictEqual((await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: ' ' } })).status, 422);++});++++t('done', async () => { aiClient._setClient(null); await h.close(); });
@@ -257,6 +257,7 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
@@ -257,6 +257,7 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
const limits = require('./net/actor-limits').limits.attach(metrics.registry);
const limits = require('./net/actor-limits').limits.attach(metrics.registry);
app.use('/api/chat', limits.identify, limits.reads('chat.read'));
app.use('/api/chat', limits.identify, limits.reads('chat.read'));
app.use('/api/dm', limits.identify, limits.reads('chat.dm.read'));
app.use('/api/dm', limits.identify, limits.reads('chat.dm.read'));
+ app.use('/api/vibe', limits.identify, limits.reads('chat.vibe.read'));
app.use('/api/tts', limits.identify, limits.reads('chat.tts.read', { skip: (req) => req.path.startsWith('/audio/') }));
app.use('/api/tts', limits.identify, limits.reads('chat.tts.read', { skip: (req) => req.path.startsWith('/audio/') }));
app.use('/api/sounds', limits.identify, limits.reads('chat.sounds.read', { skip: (req) => req.path.startsWith('/file/') }));
app.use('/api/sounds', limits.identify, limits.reads('chat.sounds.read', { skip: (req) => req.path.startsWith('/file/') }));
app.use('/api/emotes', limits.identify, limits.reads('chat.emotes.read', { skip: (req) => req.path.startsWith('/file/') }));
app.use('/api/emotes', limits.identify, limits.reads('chat.emotes.read', { skip: (req) => req.path.startsWith('/file/') }));
@@ -281,6 +282,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
@@ -281,6 +282,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
app.use('/api/chat/ai', require('./chat/ai-routes'));
app.use('/api/chat/ai', require('./chat/ai-routes'));
app.use('/api/chat', require('./chat/routes'));
app.use('/api/chat', require('./chat/routes'));
app.use('/api/dm', require('./chat/dm-routes'));
app.use('/api/dm', require('./chat/dm-routes'));
+ // Vibe from any OpenVibe site (the Dock, plan L48 V2): ask, and read the answer, with the person's own token.
+ app.use('/api/vibe', require('./rooms/vibe-api'));
app.use('/api/tts', require('./chat/tts-routes'));
app.use('/api/tts', require('./chat/tts-routes'));
app.use('/api/sounds', require('./chat/sounds-routes'));
app.use('/api/sounds', require('./chat/sounds-routes'));
// Custom emotes (Live's /api/emotes paths, plan T3): bytes go to OpenVibe.Media, rows are Chat's.
// Custom emotes (Live's /api/emotes paths, plan T3): bytes go to OpenVibe.Media, rows are Chat's.
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+/**
+ * Vibe from any OpenVibe site (plan L48, V2; the owner, 2026-11-01: "access your agent or the AI or the conversation
+ * from across all websites… use a voice feature to tell it to set a reminder… have a conversation with your Vibe AI").
+ * The Dock (openvibe-shared/dock.js) calls these with the person's own Network token, as it calls /api/dm: the same
+ * conversations as openvibe.chat/vibe, so a chat started on Live carries on on Chat and back.
+ *
+ * GET /api/vibe/chat?slug=&after= the chat (by slug, else the latest) and its messages: { chat, messages }, or
+ * { chat: null } before the first. after = a message id: only what came after it
+ * (the Dock polls this for Vibe's answer).
+ * POST /api/vibe/ask { message, slug?, timezone?, fresh? }
+ * post into that chat, else the latest one if it moved in the last 6 hours, else
+ * a new chat (fresh: always new). Vibe answers in the background, as on Chat.
+ * → { chat, message }
+ */
+const express = require('express');
+const { requireAuth } = require('../auth/auth');
+const { limits } = require('../net/actor-limits');
+const rooms = require('./rooms');
+const vibe = require('./vibe');
+
+const router = express.Router();
+router.use(express.json({ limit: '32kb' }));
+router.use(requireAuth);
+
+const SIX_HOURS = 6 * 3600 * 1000;
+const asked = limits('chat.vibe.ask', { minute: 12, hour: 300 });
+const zone = (tz) => { try { new Intl.DateTimeFormat('en-US', { timeZone: String(tz) }); return String(tz).slice(0, 64); } catch { return null; } };
+const at = (ts) => { const d = new Date(String(ts || '').includes('T') ? ts : `${String(ts || '').replace(' ', 'T')}Z`); return Number.isNaN(d.getTime()) ? null : d.toISOString(); };
+const wire = (m) => ({ id: Number(m.id), from: m.ai_member_id ? 'vibe' : 'you', text: String(m.message || ''), at: at(m.created_at) });
+const chatOf = (room) => (room ? { slug: room.slug, name: room.name || 'Vibe', url: `https://openvibe.chat/vibe/${encodeURIComponent(room.slug)}` } : null);
+
+async function latest(user) {
+ const list = await vibe.list(user, { limit: 1 });
+ const first = Array.isArray(list) ? list[0] : (list && list.chats ? list.chats[0] : null);
+ return first ? await vibe.ownChat(first.slug, user) : null;
+}
+
+router.get('/chat', async (req, res) => {
+ if (req.authSource === 'api_token' || req.authSource === 'dock') return res.status(403).json({ error: 'Vibe is for the person themself' });
+ const room = req.query.slug ? await vibe.ownChat(String(req.query.slug), req.user) : await latest(req.user);
+ if (!room) return res.set('Cache-Control', 'private, no-store').json({ chat: null, messages: [] });
+ const after = Number(req.query.after) || null;
+ const rows = await rooms.history(room, after ? { after, limit: 50 } : { limit: 40 });
+ return res.set('Cache-Control', 'private, no-store').json({ chat: chatOf(room), messages: rows.filter((m) => !m.is_deleted).map(wire) });
+});
+
+router.post('/ask', asked, async (req, res) => {
+ if (req.authSource === 'api_token' || req.authSource === 'dock') return res.status(403).json({ error: 'Vibe is for the person themself' });
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const message = String(b.message || '').replace(/\r\n/g, '\n').trim();
+ if (!message) return res.status(422).json({ error: 'Write something first' });
+ if (message.length > 8000) return res.status(422).json({ error: 'That is longer than Vibe reads in one message' });
+ const timezone = zone(b.timezone);
+ try {
+ let room = b.slug ? await vibe.ownChat(String(b.slug), req.user) : null;
+ if (!room && !b.fresh) {
+ const last = await latest(req.user);
+ const moved = last ? Date.parse(at(last.updated_at || last.last_message_at || last.created_at) || 0) : 0;
+ if (last && Date.now() - moved < SIX_HOURS) room = last;
+ }
+ if (!room) {
+ const out = await vibe.start(req.user, message, { timezone });
+ const full = await vibe.ownChat(out.room.slug, req.user);
+ return res.status(201).json({ chat: chatOf(full || out.room), message: wire(out.message) });
+ }
+ const posted = await rooms.post(room, req.user, message, { timezone });
+ try { require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message: posted }); } catch { /* the socket is optional */ }
+ return res.status(201).json({ chat: chatOf(room), message: wire(posted) });
+ } catch (err) {
+ return res.status(err.status || 500).json({ error: err.status ? err.message : 'Your message could not be sent' });
+ }
+});
+
+module.exports = router;
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+/**
+ * Vibe from any OpenVibe site (plan L48, V2; server/rooms/vibe-api.js): the Dock asks Vibe and reads its answer with
+ * the person's own token. Nothing before the first chat; a question starts one and Vibe answers it in the background;
+ * the next question within six hours carries the same chat on; fresh starts a new one; a chat is its person's alone;
+ * a visitor gets nothing.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('vibe api');
+let h, ann, bob, aiClient;
+const say = (content) => ({ choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }] });
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+async function answerAfter(token, slug, id) {
+ for (let i = 0; i < 60; i++) {
+ const r = await h.http('GET', `/api/vibe/chat?slug=${encodeURIComponent(slug)}&after=${id}`, { token });
+ const v = (r.body.messages || []).find((m) => m.from === 'vibe');
+ if (v) return v;
+ await wait(100);
+ }
+ return null;
+}
+
+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') });
+ bob = h.addUser('bob', { subject: ids.newId('user') });
+ for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));
+ aiClient._setClient({ runs: { create: async (workflow) => ({ run: { status: 'succeeded', output: workflow === 'chat.member.turn' ? say('Hello from Vibe.') : { text: 'Hello from Vibe.' } } }) } });
+});
+
+t('nothing before the first chat; a visitor gets nothing at all', async () => {
+ const r = await h.http('GET', '/api/vibe/chat', { token: ann.token });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.body, { chat: null, messages: [] });
+ assert.strictEqual((await h.http('GET', '/api/vibe/chat')).status, 401);
+ assert.strictEqual((await h.http('POST', '/api/vibe/ask', { body: { message: 'hi' } })).status, 401);
+});
+
+t('a question starts a chat and Vibe answers it in the background; the next one carries the same chat on', async () => {
+ const first = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'What can you do?', timezone: 'Europe/Paris' } });
+ assert.strictEqual(first.status, 201, first.text);
+ const { chat, message } = first.body;
+ assert.ok(chat.slug && /^https:\/\/openvibe\.chat\/vibe\//.test(chat.url));
+ assert.deepStrictEqual([message.from, message.text], ['you', 'What can you do?']);
+ const answer = await answerAfter(ann.token, chat.slug, message.id);
+ assert.ok(answer && /Hello from Vibe/.test(answer.text), 'Vibe answered');
+ const again = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'And reminders?' } });
+ assert.strictEqual(again.body.chat.slug, chat.slug, 'within six hours: the same chat');
+ const fresh = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'Something new', fresh: true } });
+ assert.notStrictEqual(fresh.body.chat.slug, chat.slug, 'fresh: a new chat');
+ const latest = await h.http('GET', '/api/vibe/chat', { token: ann.token });
+ assert.strictEqual(latest.body.chat.slug, fresh.body.chat.slug, 'the latest chat first');
+});
+
+t('a chat is its person\'s alone, and an empty message is refused', async () => {
+ const mine = (await h.http('GET', '/api/vibe/chat', { token: ann.token })).body.chat.slug;
+ const peek = await h.http('GET', `/api/vibe/chat?slug=${mine}`, { token: bob.token });
+ assert.deepStrictEqual(peek.body, { chat: null, messages: [] }, 'another person sees nothing of it');
+ const into = await h.http('POST', '/api/vibe/ask', { token: bob.token, body: { message: 'hi', slug: mine } });
+ assert.notStrictEqual(into.body.chat.slug, mine, 'naming someone else\'s chat starts their own instead');
+ assert.strictEqual((await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: ' ' } })).status, 422);
+});
+
+t('done', async () => { aiClient._setClient(null); await h.close(); });