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)
Changes
nnew file mode 100644@@@ -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()++);@@@ -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,@@@ -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;}@@@ -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 });@@@ -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 };@@@ -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`); }});@@@ -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 = [@@@ -180,6 +180,48 @@ t('a model on your own computer: the machine and no key; the settings form comesassert.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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -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()
+);
@@ -67,6 +67,7 @@ const EXPORTS = [
@@ -67,6 +67,7 @@ const EXPORTS = [
// What Vibe remembers about them (server/rooms/vibe-memory.js), and whether they switched it off.
// 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.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_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).
// 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).
// 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}`],
['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 = [
@@ -121,6 +122,7 @@ const ERASE_AFTER_ROOMS = [
['vibe_usage', 'DELETE FROM vibe_usage WHERE user_id = ANY(@ids)'],
['vibe_usage', 'DELETE FROM vibe_usage WHERE user_id = ANY(@ids)'],
['vibe_memory', 'DELETE FROM vibe_memory 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_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_asks', 'DELETE FROM vibe_asks WHERE user_id = ANY(@ids)'],
['vibe_deliveries', 'DELETE FROM vibe_deliveries 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,
// Support inboxes (server/inbox/inbox.js): the inboxes they own go with every conversation in them; in others,
@@ -133,6 +133,8 @@ async function remove(room, can, id) {
@@ -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. */
/** 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) {
async function dropKey(subject) {
if (!subject) return 0;
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)]);
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;
return r.changes || 0;
}
}
@@ -92,6 +92,18 @@ router.post('/picture', requireAuth, personOnly, pictureLimit, (req, res, next)
@@ -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).
// 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) => {
router.get('/memory', requireAuth, personOnly, async (req, res) => {
try {
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 });
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 });
@@ -17,6 +17,10 @@
@@ -17,6 +17,10 @@
* retry(room, user) → { removed }: "Try again": Vibe's last answer goes and it answers the person's last
* 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)
* 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
* 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
* 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
* 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
* (server/rooms/workspace.js) on the person's own OpenVibe.Actor allowance: research
@@ -31,6 +35,7 @@ const db = require('../db/database');
@@ -31,6 +35,7 @@ const db = require('../db/database');
const config = require('../config');
const config = require('../config');
const wordFilter = require('../chat/word-filter');
const wordFilter = require('../chat/word-filter');
const rooms = require('./rooms');
const rooms = require('./rooms');
+const credentials = require('../ai/credentials');
const VIBE = { name: 'Vibe', handle: 'vibe' };
const VIBE = { name: 'Vibe', handle: 'vibe' };
const MAX_CHATS = 500;
const MAX_CHATS = 500;
@@ -58,9 +63,39 @@ function titleOf(text) {
@@ -58,9 +63,39 @@ function titleOf(text) {
const newSlug = () => `v-${crypto.randomBytes(8).toString('hex').slice(0, 12)}`;
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) {
async function createRoom(user, title) {
if (!user) fail(401, 'vibe.sign_in', 'Sign in to chat with Vibe');
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.');
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 () => {
const id = await db.tx(async () => {
// The oldest chats beyond the limit go, so starting one never fails on it.
// 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]);
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) {
@@ -69,8 +104,8 @@ async function createRoom(user, title) {
[newSlug(), titleOf(title), user.id, user.subject_id || null]);
[newSlug(), titleOf(title), user.id, user.subject_id || null]);
const roomId = Number(r.lastInsertRowid);
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_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 roomId;
});
});
return await db.get('SELECT * FROM rooms WHERE id = ?', [id]);
return await db.get('SELECT * FROM rooms WHERE id = ?', [id]);
@@ -218,4 +253,4 @@ async function countReply(user) {
@@ -218,4 +253,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, 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 };
@@ -507,10 +507,12 @@ ${await aiKeySection(actor, req.query || {})}`,
@@ -507,10 +507,12 @@ ${await aiKeySection(actor, req.query || {})}`,
const machineValue = offered ? offered.machine : (key && key.machine) || '';
const machineValue = offered ? offered.machine : (key && key.machine) || '';
const modelValue = offered && offered.model ? offered.model : (key && key.model) || '';
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 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>
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>
<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.`) : ''}
${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">
<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>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>
<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
@@ -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>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>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>
<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>
<p><button type="submit">${key ? 'Save' : 'Add key'}</button></p>
</form>
</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>` : ''}`}`;
${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">
@@ -528,6 +531,8 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">
try {
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 });
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);
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');
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`); }
} catch (err) { return res.redirect(303, `/settings?error=${encodeURIComponent(err.message || 'Could not save your key right now')}#oc-ai-key`); }
});
});
@@ -27,7 +27,7 @@ const TABLES = [
@@ -27,7 +27,7 @@ const TABLES = [
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'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',
'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_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',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
];
];
const INDEXES = [
const INDEXES = [
@@ -180,6 +180,48 @@ t('a model on your own computer: the machine and no key; the settings form comes
@@ -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);
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 () => {
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', ann, { name: 'Model Guild' })).status, 201);
assert.strictEqual((await api('POST', '/servers/model-guild/join', bob)).status, 200);
assert.strictEqual((await api('POST', '/servers/model-guild/join', bob)).status, 200);