openvibers/openvibe.chat Commits

Vibe thinks with your own AI key or computer when you choose it ("Vibe thinks with it too" beside Your AI key, GET/PUT /api/chat/vibe/engine): every Vibe chat of yours and new ones, no daily limit, back to OpenVibe's model when the key goes (migration 0035) (#208)

OP OpenVibers committed 18 days ago · f01ec10067 · 8 files, +112 −5

Changes

ddiff --git a/migrations/0035_vibe_own_engine.sql b/migrations/0035_vibe_own_engine.sql
nnew file mode 100644
iindex 0000000..a78b326
---- /dev/null
++++ b/migrations/0035_vibe_own_engine.sql
@@@ -0,0 +1,9 @@
++-- phase: expand
++-- Vibe thinks with the person's own AI key or computer when they choose it (Settings, "Your AI key", and
++-- PUT /api/chat/vibe/engine; server/rooms/vibe.js): the member of every Vibe chat of theirs, new ones too, then runs on
++-- their credential (engine own) instead of OpenVibe's model. Removing the key ends it. It goes with the account
++-- export and deletion.
++CREATE TABLE IF NOT EXISTS vibe_own_engine (
++ user_id bigint PRIMARY KEY,
++ since text COLLATE "C" NOT NULL DEFAULT ov_now()
++);
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex a6cfa35..0f16513 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -67,6 +67,7 @@ const EXPORTS = [
// What Vibe remembers about them (server/rooms/vibe-memory.js), and whether they switched it off.
['vibe_memory.json', `SELECT id, text, source, created_at FROM vibe_memory WHERE user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['vibe_memory_off.json', `SELECT since FROM vibe_memory_off WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
++ ['vibe_own_engine.json', `SELECT since FROM vibe_own_engine WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
// What they asked Vibe by voice on their machines, the last 7 days (server/chat/internal-vibe.js).
// Which of their agents' runs were posted into their Vibe chats (server/chat/internal-vibe.js).
['vibe_deliveries.json', `SELECT room_id, message_id, created_at FROM vibe_deliveries WHERE user_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
@@@ -121,6 +122,7 @@ const ERASE_AFTER_ROOMS = [
['vibe_usage', 'DELETE FROM vibe_usage WHERE user_id = ANY(@ids)'],
['vibe_memory', 'DELETE FROM vibe_memory WHERE user_id = ANY(@ids)'],
['vibe_memory_off', 'DELETE FROM vibe_memory_off WHERE user_id = ANY(@ids)'],
++ ['vibe_own_engine', 'DELETE FROM vibe_own_engine WHERE user_id = ANY(@ids)'],
['vibe_asks', 'DELETE FROM vibe_asks WHERE user_id = ANY(@ids)'],
['vibe_deliveries', 'DELETE FROM vibe_deliveries WHERE user_id = ANY(@ids)'],
// Support inboxes (server/inbox/inbox.js): the inboxes they own go with every conversation in them; in others,
ddiff --git a/server/rooms/ai-members.js b/server/rooms/ai-members.js
iindex e1c99f8..cd307a6 100644
---- a/server/rooms/ai-members.js
++++ b/server/rooms/ai-members.js
@@@ -133,6 +133,8 @@ async function remove(room, can, id) {
/** A person's key is gone (they removed it, or their account was erased): their members go back to OpenVibe's model. */
async function dropKey(subject) {
if (!subject) return 0;
++ // Vibe thinking with it (server/rooms/vibe.js setEngine) ends with it.
++ await db.run('DELETE FROM vibe_own_engine WHERE user_id IN (SELECT id FROM ctx_users WHERE subject_id = ?)', [String(subject)]);
const r = await db.run("UPDATE room_ai_members SET engine = 'openvibe', engine_subject = NULL, engine_label = NULL, updated_at = ov_now() WHERE engine_subject = ?", [String(subject)]);
return r.changes || 0;
}
ddiff --git a/server/rooms/vibe-routes.js b/server/rooms/vibe-routes.js
iindex 6e1529c..de42698 100644
---- a/server/rooms/vibe-routes.js
++++ b/server/rooms/vibe-routes.js
@@@ -92,6 +92,18 @@ router.post('/picture', requireAuth, personOnly, pictureLimit, (req, res, next)
});
// Memory, registered before /:slug (a chat's slug always starts with v-, so the two never meet).
++// Which model Vibe thinks with: OpenVibe's, or the person's own AI key or computer (Settings, "Your AI key"). { own }
++router.get('/engine', requireAuth, personOnly, async (req, res) => {
++ try { res.set('Cache-Control', cache.htmlHeaders({ private: true })).json(await vibe.engineOf(req.user)); } catch (err) { send(res, err); }
++});
++router.put('/engine', requireAuth, personOnly, manageLimit, async (req, res) => {
++ try {
++ const own = req.body && req.body.own;
++ if (typeof own !== 'boolean') return res.status(422).json({ error: 'Send { "own": true } or { "own": false }', code: 'vibe.engine' });
++ res.json(await vibe.setEngine(req.user, own));
++ } catch (err) { send(res, err); }
++});
++
router.get('/memory', requireAuth, personOnly, async (req, res) => {
try {
res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ on: await memory.isOn(req.user), notes: await memory.list(req.user), max: memory.MAX_NOTES });
ddiff --git a/server/rooms/vibe.js b/server/rooms/vibe.js
iindex 17733fc..d91c5de 100644
---- a/server/rooms/vibe.js
++++ b/server/rooms/vibe.js
@@@ -17,6 +17,10 @@
* retry(room, user) → { removed }: "Try again": Vibe's last answer goes and it answers the person's last
* message anew (in the background, like any answer; it counts against the allowance)
* repliesToday(user) / countReply(user) the person's answers from Vibe this UTC day, across all their chats
++ * engineOf(user) → { own, label }; setEngine(user, own) → the same: Vibe thinks with the person's own AI key or
++ * computer (their credential in OpenVibe.AI, server/ai/credentials.js) in every chat of theirs, new
++ * ones too, or with OpenVibe's model again; own needs a stored credential (409 otherwise). Answers
++ * on their own model do not count against the free allowance.
* 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
@@@ -31,6 +35,7 @@ const db = require('../db/database');
const config = require('../config');
const wordFilter = require('../chat/word-filter');
const rooms = require('./rooms');
++const credentials = require('../ai/credentials');
const VIBE = { name: 'Vibe', handle: 'vibe' };
const MAX_CHATS = 500;
@@@ -58,9 +63,39 @@ function titleOf(text) {
const newSlug = () => `v-${crypto.randomBytes(8).toString('hex').slice(0, 12)}`;
++/** The person's choice of model for Vibe: their own credential when they chose it and it is still stored. */
++async function engineOf(user) {
++ if (!user || !user.subject_id || !(await db.get('SELECT 1 AS own FROM vibe_own_engine WHERE user_id = ?', [user.id]))) return { own: false, label: null };
++ let cred = null;
++ try { cred = await credentials.get(user.subject_id); } catch { cred = null; }
++ return cred ? { own: true, label: credentials.memberLabel(cred, user.display_name || user.username), cred } : { own: false, label: null };
++}
++
++async function setEngine(user, own) {
++ if (!user || !user.subject_id) fail(403, 'vibe.engine', 'Sign in with an OpenVibe account to give Vibe your own model');
++ if (own) {
++ let cred;
++ try { cred = await credentials.get(user.subject_id); } catch (err) { fail(err.status || 502, err.code || 'credentials.unreachable', err.message); }
++ if (!cred) fail(409, 'vibe.no_key', 'Add your own AI key or computer in Settings first');
++ const label = credentials.memberLabel(cred, user.display_name || user.username);
++ await db.tx(async () => {
++ await db.run('INSERT INTO vibe_own_engine (user_id) VALUES (?) ON CONFLICT (user_id) DO NOTHING', [user.id]);
++ await db.run("UPDATE room_ai_members SET engine = 'own', engine_subject = ?, engine_label = ?, updated_at = ov_now() WHERE room_id IN (SELECT id FROM rooms WHERE owner_id = ? AND assistant = 1)",
++ [String(user.subject_id), label, user.id]);
++ });
++ return { own: true, label };
++ }
++ await db.tx(async () => {
++ await db.run('DELETE FROM vibe_own_engine WHERE user_id = ?', [user.id]);
++ await db.run("UPDATE room_ai_members SET engine = 'openvibe', engine_subject = NULL, engine_label = NULL, updated_at = ov_now() WHERE room_id IN (SELECT id FROM rooms WHERE owner_id = ? AND assistant = 1)", [user.id]);
++ });
++ return { own: false, label: null };
++}
++
async function createRoom(user, title) {
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);
const id = await db.tx(async () => {
// 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]);
@@@ -69,8 +104,8 @@ async function createRoom(user, title) {
[newSlug(), titleOf(title), user.id, user.subject_id || null]);
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) VALUES (?, ?, ?, '', '', ?, 'openvibe')",
-- [roomId, VIBE.name, VIBE.handle, user.subject_id || null]);
++ await db.run('INSERT INTO room_ai_members (room_id, name, handle, persona, memory, created_by_subject, engine, engine_subject, engine_label) VALUES (?, ?, ?, \'\', \'\', ?, ?, ?, ?)',
++ [roomId, VIBE.name, VIBE.handle, user.subject_id || null, engine.own ? 'own' : 'openvibe', engine.own ? String(user.subject_id) : null, engine.own ? engine.label : null]);
return roomId;
});
return await db.get('SELECT * FROM rooms WHERE id = ?', [id]);
@@@ -218,4 +253,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, 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 };
++module.exports = { VIBE, 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 }; }, setEngine };
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex b21debd..d8b9abb 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -507,10 +507,12 @@ ${await aiKeySection(actor, req.query || {})}`,
const machineValue = offered ? offered.machine : (key && key.machine) || '';
const modelValue = offered && offered.model ? offered.model : (key && key.model) || '';
const keyLine = key && key.provider === 'node' ? `on your computer <code>${esc(key.machine || '')}</code> · nothing spent` : key ? `key ending ${esc(key.key_hint || '····')} · ${esc(spent)}` : '';
++ const vibeOwn = key ? (await require('../rooms/vibe').engineOf(actor.user)).own : false;
++ const vibeBox = require('../rooms/vibe').enabled() ? `<label class="oc-check"><input type="checkbox" name="vibe" value="1"${vibeOwn || offered ? ' checked' : ''}> Vibe thinks with it too (in all your chats with Vibe; its answers then have no daily limit)</label>` : '';
return `<h2 id="oc-ai-key">Your AI key</h2>
<p class="oc-muted">AI members you add to a room, your Vibe among them, can think with your own provider and model instead of OpenVibe's: OpenAI, Anthropic, any OpenAI-compatible endpoint (OpenRouter, Groq, Together, your own server), or <strong>a model on your own computer</strong> (Ollama, LM Studio or llama.cpp through the OpenVibe Node, free, with no port opened: turn on "Let my AI use this computer's models" for it on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>). A key is kept encrypted in OpenVibe.AI and never shown again; the room's messages go to your provider or your computer when one of those members answers, and a key's replies count against your daily budget, not the room's limit.</p>
${offered ? notice('ok', `Save to let your AI members think with ${offered.model || 'a model'} on your own computer.`) : ''}
--${unreachable ? notice('error', 'OpenVibe.AI did not answer, so your key cannot be shown or changed right now.') : `${key ? `<p><strong>${esc(credentials.label(key))}</strong> <span class="oc-muted">· ${keyLine}</span></p>` : '<p class="oc-muted">No key yet.</p>'}
++${unreachable ? notice('error', 'OpenVibe.AI did not answer, so your key cannot be shown or changed right now.') : `${key ? `<p><strong>${esc(credentials.label(key))}</strong> <span class="oc-muted">· ${keyLine}${vibeOwn ? ' · Vibe thinks with it' : ''}</span></p>` : '<p class="oc-muted">No key yet.</p>'}
<form class="oc-form" method="post" action="/settings/ai-key">
<label>Provider <select name="provider">${opt('openai', 'OpenAI or OpenAI-compatible', provider)}${opt('anthropic', 'Anthropic', provider)}${opt('node', 'A model on my own computer', provider)}</select></label>
<label>Computer <input name="machine" maxlength="40" pattern="dev_[0-9A-Z]{26}" placeholder="dev_… (a model on your own computer only)" value="${esc(machineValue)}" autocomplete="off" spellcheck="false"></label>
@@@ -518,6 +520,7 @@ ${unreachable ? notice('error', 'OpenVibe.AI did not answer, so your key cannot
<label>Model <input name="model" required maxlength="120" placeholder="gpt-4o-mini, or llama3.1:8b on your computer" value="${esc(modelValue)}" autocomplete="off" spellcheck="false"></label>
<label>API key <input name="api_key" type="password" maxlength="400" autocomplete="off" spellcheck="false" placeholder="${key ? 'Leave empty to keep the current key' : 'sk-… (none for your own computer)'}"></label>
<label>Daily budget, US dollars <input name="budget_usd_per_day" type="number" min="0" max="1000" step="0.5" value="${key && key.budget_usd_per_day != null ? key.budget_usd_per_day : 1}"> <span class="oc-muted">0 means no cap</span></label>
++${vibeBox}
<p><button type="submit">${key ? 'Save' : 'Add key'}</button></p>
</form>
${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete"><button type="submit" class="oc-link">Remove my key</button> <span class="oc-muted">(AI members that use it go back to OpenVibe's model)</span></form>` : ''}`}`;
@@@ -528,6 +531,8 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">
try {
const key = await credentials.put(actor.user.subject_id, { provider: b.provider, base_url: b.base_url, api_key: b.api_key, model: b.model, budget_usd_per_day: b.budget_usd_per_day, machine: b.machine });
await require('../rooms/ai-members').relabelKey(actor.user.subject_id, key, actor.user);
++ const vibe = require('../rooms/vibe');
++ if (vibe.enabled()) await vibe.setEngine(actor.user, b.vibe === '1');
return res.redirect(303, '/settings?saved=1#oc-ai-key');
} catch (err) { return res.redirect(303, `/settings?error=${encodeURIComponent(err.message || 'Could not save your key right now')}#oc-ai-key`); }
});
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex 972db3b..708038c 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -27,7 +27,7 @@ const TABLES = [
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
-- 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'room_tasks', 'room_hooks', 'vibe_usage', 'vibe_memory', 'vibe_memory_off', 'vibe_asks', 'vibe_deliveries', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
++ 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'room_tasks', 'room_hooks', 'vibe_usage', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_asks', 'vibe_deliveries', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
];
const INDEXES = [
ddiff --git a/test/room-ai-models.test.js b/test/room-ai-models.test.js
iindex 6a7557c..f78fa5c 100644
---- a/test/room-ai-models.test.js
++++ b/test/room-ai-models.test.js
@@@ -180,6 +180,48 @@ t('a model on your own computer: the machine and no key; the settings form comes
assert.strictEqual((await api('DELETE', '/me/ai-key', cy)).status, 200);
});
++t('Vibe thinks with your own key or computer when you choose it: every chat of yours, new ones too, until the key goes', async () => {
++ const MACHINE = 'dev_01M5YGM39VC2RRGDQPRD6D4SHD';
++ const di = h.addUser('di', { subject: ids.newId('user') });
++ await h.ctx.upsertUser(h.live.users.get(di.id));
++ const vibeApi = (method, path, body) => h.http(method, `/api/chat/vibe${path}`, { token: di.token, body });
++ const memberOf = async (slug) => (await h.db.get('SELECT m.engine, m.engine_subject, m.engine_label FROM room_ai_members m JOIN rooms r ON r.id = m.room_id WHERE r.slug = ?', [slug]));
++ const routes = require('../server/rooms/vibe-routes').stack.filter((l) => l.route && l.route.path === '/engine');
++ assert.deepStrictEqual(routes.map((l) => Object.keys(l.route.methods)[0]).sort(), ['get', 'put'], 'GET and PUT /api/chat/vibe/engine');
++ assert.deepStrictEqual((await vibeApi('GET', '/engine')).body, { own: false, label: null });
++ assert.strictEqual((await vibeApi('PUT', '/engine', { own: true })).status, 409, 'a key or computer first');
++ assert.strictEqual((await vibeApi('PUT', '/engine', { own: 'yes' })).status, 422);
++ const first = await vibeApi('POST', '', { message: 'hello there' });
++ assert.strictEqual(first.status, 201, first.text);
++ assert.strictEqual((await memberOf(first.body.room.slug)).engine, 'openvibe', 'OpenVibe\'s model by default');
++ assert.strictEqual((await api('PUT', '/me/ai-key', di, { provider: 'node', machine: MACHINE, model: 'llama3.1:8b' })).status, 200);
++ const on = await vibeApi('PUT', '/engine', { own: true });
++ assert.deepStrictEqual(on.body, { own: true, label: 'llama3.1:8b on Di\'s computer' }, on.text);
++ assert.deepStrictEqual(await memberOf(first.body.room.slug), { engine: 'own', engine_subject: h.live.users.get(di.id).subject_id, engine_label: 'llama3.1:8b on Di\'s computer' }, 'the chats they have');
++ await settle(300);
++ const before = runs.length;
++ const next = await vibeApi('POST', '', { message: 'what is a mutex?' });
++ assert.strictEqual((await memberOf(next.body.room.slug)).engine, 'own', 'and new ones');
++ await settle(400);
++ const mine = runs.slice(before).filter((r) => r.credential && r.credential.subject === h.live.users.get(di.id).subject_id);
++ assert.ok(mine.length >= 1, `Vibe answered with their computer (${runs.length - before} runs)`);
++ // The settings form says so and keeps it; unchecked, Vibe goes back to OpenVibe's model.
++ const page = await web('GET', '/settings', di);
++ assert.ok(page.text.includes('name="vibe" value="1" checked') && page.text.includes('· Vibe thinks with it'), 'the box, checked');
++ const saved = await web('POST', '/settings/ai-key', di, { provider: 'node', machine: MACHINE, model: 'qwen2.5:7b' });
++ assert.strictEqual(saved.status, 303);
++ assert.deepStrictEqual((await vibeApi('GET', '/engine')).body, { own: false, label: null }, 'unchecked: OpenVibe\'s model');
++ assert.strictEqual((await memberOf(first.body.room.slug)).engine, 'openvibe');
++ await web('POST', '/settings/ai-key', di, { provider: 'node', machine: MACHINE, model: 'qwen2.5:7b', vibe: '1' });
++ assert.strictEqual((await memberOf(next.body.room.slug)).engine_label, 'qwen2.5:7b on Di\'s computer', 'checked: their computer, the new model');
++ // From openvibe.bot's link the box comes checked.
++ assert.ok((await web('GET', `/settings?own=node&machine=${MACHINE}&model=phi3`, di)).text.includes('name="vibe" value="1" checked'));
++ // The key goes: so does Vibe's use of it.
++ assert.strictEqual((await api('DELETE', '/me/ai-key', di)).status, 200);
++ assert.strictEqual((await memberOf(first.body.room.slug)).engine, 'openvibe');
++ assert.strictEqual(await h.db.get('SELECT 1 AS x FROM vibe_own_engine WHERE user_id = ?', [di.id]), undefined);
++});
++
t('a server\'s admins give a text channel AI members; others are sent to the server', async () => {
assert.strictEqual((await api('POST', '/servers', ann, { name: 'Model Guild' })).status, 201);
assert.strictEqual((await api('POST', '/servers/model-guild/join', bob)).status, 200);