openvibers/openvibe.chat Commits

Vibe from any OpenVibe site: /api/vibe/chat and /api/vibe/ask for the Dock (L48 V2) (#283)

OP OpenVibers committed 13 days ago · 987765d00d · 3 files, +146 −0

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 07187ec..3ece351 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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.
ddiff --git a/server/rooms/vibe-api.js b/server/rooms/vibe-api.js
nnew file mode 100644
iindex 0000000..ff56519
---- /dev/null
++++ b/server/rooms/vibe-api.js
@@@ -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;
ddiff --git a/test/vibe-api.test.js b/test/vibe-api.test.js
nnew file mode 100644
iindex 0000000..849e64d
---- /dev/null
++++ b/test/vibe-api.test.js
@@@ -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(); });