openvibers/openvibe.chat Commits

The Dock lists a person's Vibe conversations and where each began (#428)

OP OpenVibers committed 5 days ago · e1ef34d15d · 6 files, +54 −13

* The Dock lists a person's Vibe conversations and where each began

The Vibe tab opened on a blank middle with no way back to a chat. Chat now
answers GET /api/vibe/conversations with the person's own conversations, newest
first: each with its name, the last line, when Vibe last answered and the
OpenVibe site the chat began on.

A conversation remembers that site in a new column, so a chat started on Today
shows where it began. The Dock's reads of Vibe are allowed from any OpenVibe site
with the person's own token, like the DM list.

* Merge remote-tracking branch 'origin/main' into auto/dock-v2-chat

* A Vibe conversation remembers any OpenVibe site it began on

Changes

ddiff --git a/migrations/0050_vibe_origin_site.sql b/migrations/0050_vibe_origin_site.sql
nnew file mode 100644
iindex 0000000..63e5246
---- /dev/null
++++ b/migrations/0050_vibe_origin_site.sql
@@@ -0,0 +1,3 @@
++-- phase: expand
++-- A Vibe conversation remembers the OpenVibe site where it began for the Dock's cross-site list.
++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS vibe_origin_site text COLLATE "C";
ddiff --git a/server/app.js b/server/app.js
iindex f58610d..7b72948 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -133,6 +133,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
/** The Bearer-only DM reads and writes the shared navbar and Dock make from any OpenVibe site. */
// /api/dm/users/search: the share sheet's "OpenVibe Chat" (Shared 4.144.0) starts a conversation by a person's name.
const bearerDmPath = (p) => p === '/api/dm/unread' || p === '/api/dm/conversations' || p.startsWith('/api/dm/conversations/') || p === '/api/dm/users/search';
++ const bearerDockVibePath = (p) => p.startsWith('/api/vibe/') || p.startsWith('/api/chat/vibe/') || p === '/api/chat/vibe/picture' || /^\/api\/chat\/rooms\/[^/]+\/pictures$/.test(p);
// ── Internal (loopback; never routed by nginx) ─────────────
app.use('/internal/chat', express.json({ limit: '64kb' }), createInternalIngress({ chatServer }));
@@@ -277,7 +278,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
// echoed with no Allow-Credentials, so the Bearer is the whole key, a browser refuses a credentialed read or
// preflight, and a foreign web origin is still refused.
const origin = req.headers.origin;
-- if (origin && bearerDmPath(req.path) && !isAllowedOrigin(origin) && FIRST_PARTY.has(origin)) return openCors(req, res, next);
++ if (origin && (bearerDmPath(req.path) || bearerDockVibePath(req.path)) && !isAllowedOrigin(origin) && FIRST_PARTY.has(origin)) return openCors(req, res, next);
return allowlistedCors(req, res, next);
});
// A workspace room's incoming hooks check a signature over the raw body, so they read it before the JSON parser does.
ddiff --git a/server/rooms/vibe-api.js b/server/rooms/vibe-api.js
iindex 66a5fa8..6cb9071 100644
---- a/server/rooms/vibe-api.js
++++ b/server/rooms/vibe-api.js
@@@ -67,6 +67,13 @@ const wire = (m) => {
...(m.vibe_model ? { model: m.vibe_model } : {}), ...(results.length ? { results } : {}) };
};
const chatOf = (room) => (room ? { slug: room.slug, name: room.name || 'Vibe', url: `https://openvibe.chat/vibe/${encodeURIComponent(room.slug)}` } : null);
++const siteOf = (req) => {
++ try {
++ const host = new URL(String(req.get('origin') || '')).hostname.toLowerCase();
++ // Any OpenVibe site (the Dock runs on all of them): openvibe.<site>, or a subdomain of one.
++ return /^(?:[a-z0-9-]+\.)?openvibe\.[a-z]{2,15}$/.test(host) ? host : null;
++ } catch { return null; }
++};
async function latest(user) {
// The chat they last wrote in (vibe.current): an answer arriving later in an older chat does not take its place.
@@@ -84,6 +91,11 @@ router.get('/chat', personOnly, async (req, res) => {
messages: rows.filter((m) => !m.is_deleted).map((m) => ({ ...wire(m), ...(by.has(Number(m.id)) ? { answers: by.get(Number(m.id)) } : {}) })) });
});
++router.get('/conversations', personOnly, async (req, res) => {
++ const conversations = await vibe.list(req.user, { limit: req.query.limit });
++ return res.set('Cache-Control', 'private, no-store').json({ conversations, count: await vibe.count(req.user) });
++});
++
router.get('/models', personOnly, async (_req, res) => {
try { return res.set('Cache-Control', 'private, max-age=120').json(await aiClient.catalog()); }
catch { return res.status(503).json({ error: 'Models are unavailable right now' }); }
@@@ -188,14 +200,14 @@ router.post('/ask', personOnly, (req, res, next) => (req.body && req.body.models
if (last && Date.now() - moved < SIX_HOURS) room = last;
}
if (!room && b.models) {
-- room = await vibe.createRoom(req.user, message);
++ room = await vibe.createRoom(req.user, message, { site: siteOf(req) });
try {
const posted = await compare.ask(room, req.user, message, b.models, { system_prompt: b.system_prompt, temperature: b.temperature });
return res.status(201).json({ chat: chatOf(room), message: wire(posted) });
} catch (err) { await vibe.erase(room.id); throw err; }
}
if (!room) {
-- const out = await vibe.start(req.user, message, { timezone });
++ const out = await vibe.start(req.user, message, { timezone, site: siteOf(req) });
const full = await vibe.ownChat(out.room.slug, req.user);
return res.status(201).json({ chat: chatOf(full || out.room), message: wire(out.message) });
}
ddiff --git a/server/rooms/vibe-routes.js b/server/rooms/vibe-routes.js
iindex e51451b..0a96f66 100644
---- a/server/rooms/vibe-routes.js
++++ b/server/rooms/vibe-routes.js
@@@ -59,7 +59,7 @@ const modeOf = (b) => (b && b.mode && b.mode !== 'chat' ? String(b.mode) : null)
router.post('/', requireAuth, personOnly, startLimit, async (req, res) => {
try {
const b = req.body || {};
-- const out = modeOf(b) ? await vibe.startWork(req.user, b.message, modeOf(b)) : await vibe.start(req.user, b.message, { timezone: b.timezone });
++ const out = modeOf(b) ? await vibe.startWork(req.user, b.message, modeOf(b)) : await vibe.start(req.user, b.message, { timezone: b.timezone, site: 'openvibe.chat' });
res.status(201).json(out);
} catch (err) { send(res, err); }
});
ddiff --git a/server/rooms/vibe.js b/server/rooms/vibe.js
iindex b4b5615..06f634e 100644
---- a/server/rooms/vibe.js
++++ b/server/rooms/vibe.js
@@@ -97,7 +97,7 @@ async function setEngine(user, own) {
return { own: false, label: null };
}
--async function createRoom(user, title) {
++async function createRoom(user, title, { site = null } = {}) {
if (!user) fail(401, 'vibe.sign_in', 'Sign in to chat with Vibe');
if (!enabled()) fail(503, 'vibe.off', 'Vibe is resting right now. Try again later.');
const engine = await engineOf(user);
@@@ -105,8 +105,8 @@ async function createRoom(user, title) {
// The oldest chats beyond the limit go, so starting one never fails on it.
const extra = await db.all('SELECT id FROM rooms WHERE owner_id = ? AND assistant = 1 ORDER BY COALESCE(last_message_at, created_at) DESC OFFSET ?', [user.id, MAX_CHATS - 1]);
for (const r of extra) await erase(r.id);
-- const r = await db.run("INSERT INTO rooms (slug, name, kind, visibility, owner_id, owner_subject, assistant) VALUES (?, ?, 'community', 'private', ?, ?, 1)",
-- [newSlug(), titleOf(title), user.id, user.subject_id || null]);
++ const r = await db.run("INSERT INTO rooms (slug, name, kind, visibility, owner_id, owner_subject, assistant, vibe_origin_site) VALUES (?, ?, 'community', 'private', ?, ?, 1, ?)",
++ [newSlug(), titleOf(title), user.id, user.subject_id || null, site]);
const roomId = Number(r.lastInsertRowid);
await db.run("INSERT INTO room_members (room_id, user_id, role, last_seen_at) VALUES (?, ?, 'owner', ov_now())", [roomId, user.id]);
await db.run('INSERT INTO room_ai_members (room_id, name, handle, persona, memory, created_by_subject, engine, engine_subject, engine_label) VALUES (?, ?, ?, \'\', \'\', ?, ?, ?, ?)',
@@@ -116,10 +116,10 @@ async function createRoom(user, title) {
return await db.get('SELECT * FROM rooms WHERE id = ?', [id]);
}
--async function start(user, text, { timezone = null } = {}) {
++async function start(user, text, { timezone = null, site = null } = {}) {
const body = String(text || '').replace(/\r\n/g, '\n').trim();
if (user && enabled() && !body) fail(422, 'rooms.empty', 'Write something first');
-- const room = await createRoom(user, body);
++ const room = await createRoom(user, body, { site });
const id = room.id;
try {
const message = await rooms.post(room, user, body, { timezone });
@@@ -182,9 +182,20 @@ async function startWork(user, text, mode) {
async function list(user, { limit = 100 } = {}) {
if (!user) return [];
const lim = Math.min(MAX_CHATS, Math.max(1, Number(limit) || 100));
-- const rows = await db.all(`SELECT slug, name, last_message_at, created_at FROM rooms
-- WHERE owner_id = ? AND assistant = 1 AND archived_at IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?`, [user.id, lim]);
-- return rows.map((r) => ({ slug: r.slug, name: r.name, last_message_at: r.last_message_at || r.created_at, created_at: r.created_at }));
++ const rows = await db.all(`SELECT r.slug, r.name, r.last_message_at, r.created_at, r.vibe_origin_site,
++ (SELECT m.message FROM room_messages m WHERE m.room_id = r.id AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT 1) AS last_line,
++ (SELECT m.created_at FROM room_messages m WHERE m.room_id = r.id AND m.is_deleted = 0 AND m.ai_member_id IS NOT NULL ORDER BY m.id DESC LIMIT 1) AS last_answer_at
++ FROM rooms r
++ WHERE r.owner_id = ? AND r.assistant = 1 AND r.archived_at IS NULL ORDER BY COALESCE(r.last_message_at, r.created_at) DESC LIMIT ?`, [user.id, lim]);
++ return rows.map((r) => ({ slug: r.slug, name: r.name, last_message_at: r.last_message_at || r.created_at, created_at: r.created_at,
++ last_line: r.last_line ? String(r.last_line).replace(/\s+/g, ' ').slice(0, 160) : null,
++ last_answer_at: r.last_answer_at || null, site: r.vibe_origin_site || null }));
++}
++
++async function count(user) {
++ if (!user) return 0;
++ const row = await db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND assistant = 1 AND archived_at IS NULL', [user.id]);
++ return Number(row && row.n || 0);
}
/**
@@@ -272,4 +283,4 @@ async function countReply(user) {
ON CONFLICT (user_id, day) DO UPDATE SET replies = vibe_usage.replies + 1`, [user.id, today()]);
}
--module.exports = { VIBE, current, MAX_CHATS, TITLE_MAX, SEARCH_MAX, DEFAULT_TITLE, MODES, VibeError, enabled, isVibeRoom, titleOf, start, startWork, work, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, search, ownChat, rename, remove, retry, lastExchange, repliesToday, countReply, engineOf: async (user) => { const e = await engineOf(user); return { own: e.own, label: e.label, ...(e.own ? { you: youOf(e.cred) } : {}) }; }, setEngine };
++module.exports = { VIBE, current, MAX_CHATS, TITLE_MAX, SEARCH_MAX, DEFAULT_TITLE, MODES, VibeError, enabled, isVibeRoom, titleOf, start, startWork, work, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, count, search, ownChat, rename, remove, retry, lastExchange, repliesToday, countReply, engineOf: async (user) => { const e = await engineOf(user); return { own: e.own, label: e.label, ...(e.own ? { you: youOf(e.cred) } : {}) }; }, setEngine };
ddiff --git a/test/vibe-api.test.js b/test/vibe-api.test.js
iindex dbbb21d..1ba169b 100644
---- a/test/vibe-api.test.js
++++ b/test/vibe-api.test.js
@@@ -67,6 +67,20 @@ t('a chat is its person\'s alone, and an empty message is refused', async () =>
assert.strictEqual((await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: ' ' } })).status, 422);
});
++t('the Dock lists only your conversations with a preview and the site where each began', async () => {
++ const started = await h.http('POST', '/api/vibe/ask', { token: ann.token, headers: { origin: 'https://openvibe.today' }, body: { message: 'What is on today?', fresh: true } });
++ assert.strictEqual(started.status, 201, started.text);
++ const r = await h.http('GET', '/api/vibe/conversations', { token: ann.token });
++ assert.strictEqual(r.status, 200, r.text);
++ const row = r.body.conversations.find((c) => c.slug === started.body.chat.slug);
++ assert.deepStrictEqual([row.last_line, row.site], ['What is on today?', 'openvibe.today']);
++ assert.ok(r.body.count >= 1);
++ const short = await h.http('GET', '/api/vibe/conversations?limit=1', { token: ann.token });
++ assert.strictEqual(short.body.conversations.length, 1);
++ assert.ok(short.body.count > 1, 'the Chats row counts the full history, beyond this page');
++ assert.ok(!(await h.http('GET', '/api/vibe/conversations', { token: bob.token })).body.conversations.some((c) => c.slug === row.slug));
++});
++
t('two and three explicit models run in parallel, keep receipts, and isolate a failure', async () => {
require('../server/net/actor-limits').limits.reset();
const seen = [];