openvibers/openvibe.live Commits

AI viewers may think with a model on the streamer's own computer (credential provider node through the OpenVibe Node and Bot): free, no port opened, a computer or any of theirs, with a test button (#402)

OP OpenVibers committed 17 days ago · 6b93845b28 · 6 files, +118 −10

Changes

ddiff --git a/public/fragments/dashboard.html b/public/fragments/dashboard.html
iindex 1f199658..a61bced0 100644
---- a/public/fragments/dashboard.html
++++ b/public/fragments/dashboard.html
@@@ -183,8 +183,23 @@
<select id="aiv-keymode" class="form-input" style="max-width:320px" onchange="_aivToggleKeyMode()">
<option value="shared">OpenVibe.Live shared AI (daily budget)</option>
<option value="byo">My own API key (no cap)</option>
++ <option value="computer">A model on my own computer (free)</option>
</select>
</label>
++ <div id="aiv-node-fields" style="display:none">
++ <p class="ai-bot-hint">Your viewers think with Ollama, LM Studio or llama.cpp on your own computer, through the OpenVibe Node: free, and no port is opened. Install it with <code>--ai</code> from <a href="https://openvibe.bot/machines" target="_blank" rel="noopener">openvibe.bot/machines</a>, then turn on "Let my AI use this computer's models" there.</p>
++ <label>
++ <span>Computer</span>
++ <input type="text" id="aiv-node-machine" class="form-input" placeholder="dev_… (from openvibe.bot/machines)" maxlength="30" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" style="max-width:320px">
++ </label>
++ <label class="vibe-switch"><input type="checkbox" id="aiv-node-any" onchange="_aivToggleKeyMode()"> <span>Any of my computers that is on (the fastest first, the next when one is busy)</span></label>
++ <label>
++ <span>Model</span>
++ <input type="text" id="aiv-node-model" class="form-input" placeholder="llama3.1:8b" autocomplete="off" autocapitalize="off" spellcheck="false" style="max-width:220px;">
++ <span class="ai-bot-hint">As your model server names it. Frames are described by the site's shared vision when it is on; your computer writes the chat lines.</span>
++ </label>
++ <div class="ai-bot-actions"><button class="btn btn-small btn-outline" onclick="testAivNode(this)"><i class="fa-solid fa-plug-circle-check"></i> Test my computer</button><span id="aiv-node-test-out" class="muted" style="font-size:0.8rem"></span></div>
++ </div>
<label id="aiv-budget-wrap">
<span>Daily budget</span>
<span style="display:flex;align-items:center;gap:6px">$<input type="number" id="aiv-budget" class="form-input" min="0" max="100" step="0.05" value="0.20" style="max-width:120px;"></span>
ddiff --git a/public/js/ai-viewers.js b/public/js/ai-viewers.js
iindex a7db3f76..04c3a1b2 100644
---- a/public/js/ai-viewers.js
++++ b/public/js/ai-viewers.js
@@@ -53,9 +53,12 @@
function _aivToggleKeyMode() {
const mode = $('aiv-keymode')?.value || 'shared';
-- const byo = $('aiv-byo-fields'); const budgetWrap = $('aiv-budget-wrap');
++ const byo = $('aiv-byo-fields'); const budgetWrap = $('aiv-budget-wrap'); const node = $('aiv-node-fields');
if (byo) byo.style.display = mode === 'byo' ? '' : 'none';
-- if (budgetWrap) budgetWrap.style.display = mode === 'byo' ? 'none' : '';
++ if (node) node.style.display = mode === 'computer' ? '' : 'none';
++ if (budgetWrap) budgetWrap.style.display = mode === 'shared' ? '' : 'none';
++ // Any of their computers: no id to type.
++ const machine = $('aiv-node-machine'); if (machine) machine.disabled = !!$('aiv-node-any')?.checked;
}
window._aivToggleKeyMode = _aivToggleKeyMode;
@@@ -193,7 +196,11 @@
const c = _cfg.config || {}, s = _cfg.settings || {};
renderSettings();
const set = (id, v) => { const el = $(id); if (el) el.value = v; };
-- set('aiv-keymode', c.use_shared_key ? 'shared' : 'byo');
++ const onComputer = !c.use_shared_key && s.byo && s.byo.provider === 'node';
++ set('aiv-keymode', c.use_shared_key ? 'shared' : onComputer ? 'computer' : 'byo');
++ set('aiv-node-machine', s.byo && s.byo.machine && s.byo.machine !== 'any' ? s.byo.machine : '');
++ const anyBox = $('aiv-node-any'); if (anyBox) anyBox.checked = !!(s.byo && s.byo.machine === 'any');
++ set('aiv-node-model', onComputer ? (c.byo_model || '') : '');
set('aiv-budget', ((c.daily_budget_cents ?? 20) / 100).toFixed(2));
set('aiv-byo-url', c.byo_base_url || ''); set('aiv-byo-model', c.byo_model || 'gpt-4o-mini');
for (const role of ['chat', 'vision', 'director', 'summary']) set(`aiv-byo-model-${role}`, (s.byo && s.byo[`model_${role}`]) || '');
@@@ -218,6 +225,16 @@
};
for (const role of ['chat', 'vision', 'director', 'summary']) body.settings.byo[`model_${role}`] = val(`aiv-byo-model-${role}`)?.value || '';
const keyVal = val('aiv-byo-key')?.value || ''; if (keyVal) body.byo_key = keyVal;
++ if (mode === 'computer') {
++ // A model on their own computer: the machine (or any of theirs) and the model; no key, no address.
++ const model = (val('aiv-node-model')?.value || '').trim();
++ const machine = val('aiv-node-any')?.checked ? 'any' : (val('aiv-node-machine')?.value || '').trim();
++ if (!model || !machine) { toast('Name your computer (or choose any of yours) and the model', 'error'); return; }
++ body.byo_model = model;
++ body.byo_base_url = '';
++ body.settings.byo = { provider: 'node', machine, model_chat: '', model_vision: '', model_director: '', model_summary: '' };
++ delete body.byo_key;
++ }
if (btn) btn.disabled = true;
try {
const data = await api('/ai-viewers/config', { method: 'PUT', body });
@@@ -236,6 +253,15 @@
} catch (e) { if (out) out.innerHTML = `<span style="color:#f87171">${esc(e.message)}</span>`; } finally { if (btn) btn.disabled = false; }
};
++ // The saved computer, through OpenVibe.AI and OpenVibe.Bot (save first: the test uses what is stored).
++ window.testAivNode = async function testAivNode(btn) {
++ const out = $('aiv-node-test-out'); if (btn) btn.disabled = true; if (out) out.textContent = 'Asking your computer…';
++ try {
++ const d = await api('/ai-viewers/byo/test', { method: 'POST', body: {} });
++ if (out) out.innerHTML = d.ok ? `<span style="color:#4ade80">● OK — ${esc(d.model || '')} answered in ${d.latencyMs} ms</span>` : `<span style="color:#f87171">● ${esc(d.error || 'failed')} (save first, and check the computer is on)</span>`;
++ } catch (e) { if (out) out.innerHTML = `<span style="color:#f87171">${esc(e.message)}</span>`; } finally { if (btn) btn.disabled = false; }
++ };
++
window.previewAiViewers = async function previewAiViewers(btn) {
const box = $('aiv-preview'); if (btn) btn.disabled = true;
if (box) { box.style.display = ''; box.innerHTML = '<span class="muted"><i class="fa-solid fa-spinner fa-spin"></i> Asking the director… (this is a dry run — nothing is posted)</span>'; }
ddiff --git a/server/ai/byo-credentials.js b/server/ai/byo-credentials.js
iindex 819a0ca5..3630c557 100644
---- a/server/ai/byo-credentials.js
++++ b/server/ai/byo-credentials.js
@@@ -11,6 +11,10 @@
*
* moveLocal(userId) moves a key still stored in Live the same way (scripts/move-byo-keys-to-ai.js runs it for every
* channel once). The key is never logged.
++ *
++ * Or a model on the streamer's own computer (settings_json.byo.provider node, machine dev_… or any; contracts
++ * 0.220.0/0.221.0): OpenVibe.AI reaches it through OpenVibe.Bot and their OpenVibe Node, only for them, where they let
++ * their AI use that computer's models. There is no key, no address and nothing spent; byo_in_ai is set the same way.
*/
const db = require('../db/database');
@@@ -21,10 +25,13 @@ async function subjectOf(userId) {
return r ? r.subject_id : null;
}
--/** openai | anthropic, from the streamer's settings (settings_json.byo.provider) or the address. */
++const MACHINE_RE = /^(dev_[0-9A-HJKMNP-TV-Z]{26}|any)$/;
++
++/** openai | anthropic | node, from the streamer's settings (settings_json.byo.provider) or the address. */
function providerOf(cfg) {
let extra = {};
try { extra = (JSON.parse(cfg.settings_json || '{}') || {}).byo || {}; } catch { extra = {}; }
++ if (extra.provider === 'node') return 'node';
if (extra.provider === 'anthropic' || /anthropic\.com/i.test(String(cfg.byo_base_url || extra.base_url || ''))) return 'anthropic';
return 'openai';
}
@@@ -41,6 +48,13 @@ function modelsOf(cfg) {
return models;
}
++/** The streamer's own computer for provider node: its dev_ id, or any of theirs. */
++function machineOf(cfg) {
++ let extra = {};
++ try { extra = (JSON.parse(cfg.settings_json || '{}') || {}).byo || {}; } catch { extra = {}; }
++ return MACHINE_RE.test(String(extra.machine || '')) ? String(extra.machine) : null;
++}
++
/** The https address to send with the key (the provider's own when none was set). */
function addressOf(cfg, provider) {
const raw = String(cfg.byo_base_url || '').trim().replace(/\/+$/, '');
@@@ -76,6 +90,11 @@ async function store(userId, cfg, { apiKey = null } = {}) {
const subject = await subjectOf(userId);
if (!subject) return { ok: false, status: 409, body: { code: 'credentials.no_subject', detail: 'this channel has no Network account linked' } };
const provider = providerOf(cfg);
++ if (provider === 'node') {
++ const machine = machineOf(cfg);
++ if (!machine) return { ok: false, status: 400, body: { code: 'credentials.machine', detail: 'Name your computer (its dev_ id on openvibe.bot/machines), or choose any of yours' } };
++ return await call('PUT', subject, { provider: 'node', machine, models: modelsOf(cfg) });
++ }
const put = { provider, base_url: addressOf(cfg, provider), models: modelsOf(cfg) };
if (apiKey) put.api_key = String(apiKey);
return await call('PUT', subject, put);
@@@ -92,8 +111,18 @@ async function remove(userId) {
* one, undefined to keep it. A new key, or a new address or model while the key is in AI, goes to AI; `fields` then
* gets byo_key '' and byo_in_ai. → null when Live may save, else { status, error, code } to answer with.
*/
--async function applyConfig(userId, before, byoKey, fields) {
++async function applyConfig(userId, before, byoKey, fields, byoSettings = null) {
const newKey = typeof byoKey === 'string' && byoKey.trim() !== '' ? byoKey.trim() : null;
++ // A model on the streamer's own computer: stored in AI with its machine, no key (the saved settings carry both).
++ if (byoSettings && byoSettings.provider === 'node') {
++ let settings = {};
++ try { settings = JSON.parse(before.settings_json || '{}') || {}; } catch { settings = {}; }
++ const next = { ...before, ...fields, settings_json: JSON.stringify({ ...settings, byo: { ...(settings.byo || {}), ...byoSettings } }) };
++ const r = await store(userId, next);
++ if (!r.ok) return { status: r.status >= 500 ? 502 : 400, error: (r.body && r.body.detail) || 'Could not use your computer through OpenVibe.AI', code: (r.body && r.body.code) || null };
++ fields.byo_key = ''; fields.byo_in_ai = 1;
++ return null;
++ }
if (byoKey === null && (before.byo_in_ai || before.byo_key)) {
const r = await remove(userId);
if (!r.ok && r.status !== 404) return { status: 502, error: 'Could not remove your key from OpenVibe.AI; try again', code: 'credentials.unavailable' };
@@@ -122,4 +151,4 @@ async function moveLocal(userId) {
return 'moved';
}
--module.exports = { store, remove, moveLocal, applyConfig, subjectOf, providerOf, modelsOf, addressOf };
++module.exports = { store, remove, moveLocal, applyConfig, subjectOf, providerOf, modelsOf, addressOf, machineOf };
ddiff --git a/server/ai/viewers/routes.js b/server/ai/viewers/routes.js
iindex a96ee19b..06711a2f 100644
---- a/server/ai/viewers/routes.js
++++ b/server/ai/viewers/routes.js
@@@ -157,7 +157,8 @@ router.put('/config', requireAuth, async (req, res) => {
// The key goes to OpenVibe.AI, never into Live's database (WS-O task 2, byo-credentials.js). A real new key
// (not blank, not the mask) is sent with the address and models; a change of model alone keeps AI's stored key;
// byo_key: null removes it.
-- const refused = await require('../byo-credentials').applyConfig(req.user.id, await db.getChannelAiConfig(req.user.id), body.byo_key === KEY_SENTINEL ? undefined : body.byo_key, fields);
++ const byoSettings = body.settings && body.settings.byo && typeof body.settings.byo === 'object' ? body.settings.byo : null;
++ const refused = await require('../byo-credentials').applyConfig(req.user.id, await db.getChannelAiConfig(req.user.id), body.byo_key === KEY_SENTINEL ? undefined : body.byo_key, fields, byoSettings);
if (refused) return res.status(refused.status).json({ error: refused.error, code: refused.code });
await db.upsertChannelAiConfig(req.user.id, fields);
// The daily budget on the site's AI is also a cap in OpenVibe.AI (viewer-quota.js): kept in step on every save
ddiff --git a/server/ai/viewers/settings.js b/server/ai/viewers/settings.js
iindex 3220c919..ae0a372c 100644
---- a/server/ai/viewers/settings.js
++++ b/server/ai/viewers/settings.js
@@@ -58,7 +58,7 @@ const SCHEMA = {
channel_memory: { def: '', type: 'str', max_len: 600, help: 'Running bits the bots share (maintained automatically; editable)' },
// Object-valued (validated separately)
slots: { def: {}, type: 'obj', help: 'Per-slot enable: { "<managed_stream_id>": true|false }' },
-- byo: { def: {}, type: 'obj', help: '{ provider, base_url, model, model_chat, model_vision, model_director, model_summary }' },
++ byo: { def: {}, type: 'obj', help: '{ provider, base_url, model, model_chat, model_vision, model_director, model_summary, machine }' },
runtime: { def: {}, type: 'obj', help: 'Live mod state: { paused, muted:[bot usernames] }' },
};
@@@ -111,8 +111,10 @@ function sanitize(input, limits = { maxRoster: 12, maxLinesPerMin: 12 }) {
if (out.slots) { const s = {}; for (const [id, en] of Object.entries(out.slots)) if (/^\d+$/.test(id)) s[id] = !!en; out.slots = s; }
if (out.byo) {
const b = out.byo; const clean = {};
-- for (const k of ['provider', 'base_url', 'model', 'model_chat', 'model_vision', 'model_director', 'model_summary']) if (b[k] != null) clean[k] = String(b[k]).trim().slice(0, 300);
-- if (clean.provider && !['openai', 'anthropic'].includes(clean.provider)) delete clean.provider;
++ for (const k of ['provider', 'base_url', 'model', 'model_chat', 'model_vision', 'model_director', 'model_summary', 'machine']) if (b[k] != null) clean[k] = String(b[k]).trim().slice(0, 300);
++ // node: a model on the streamer's own computer (byo-credentials.js); machine is its dev_ id or any of theirs.
++ if (clean.provider && !['openai', 'anthropic', 'node'].includes(clean.provider)) delete clean.provider;
++ if (clean.machine !== undefined && !/^(dev_[0-9A-HJKMNP-TV-Z]{26}|any)$/.test(clean.machine)) delete clean.machine;
out.byo = clean;
}
if (out.runtime) { out.runtime = { paused: !!out.runtime.paused, muted: Array.isArray(out.runtime.muted) ? out.runtime.muted.map(String).slice(0, 24) : [] }; }
ddiff --git a/test/byo-credentials.test.js b/test/byo-credentials.test.js
iindex f28026a9..439c48ef 100644
---- a/test/byo-credentials.test.js
++++ b/test/byo-credentials.test.js
@@@ -41,6 +41,11 @@ const ai = http.createServer((req, res) => {
calls.push({ method: req.method, url: req.url, body });
res.setHeader('Content-Type', 'application/json');
const m = /^\/api\/v1\/credentials\/(usr_[0-9A-Z]+)$/.exec(req.url);
++ if (m && req.method === 'PUT' && body.provider === 'node') {
++ if (body.api_key || body.base_url || !/^(dev_[0-9A-Z]{26}|any)$/.test(body.machine || '')) { res.statusCode = 422; return res.end(JSON.stringify({ code: 'input.invalid' })); }
++ stored.set(m[1], body);
++ return res.end(JSON.stringify({ subject: m[1], owner: 'live', provider: 'node', machine: body.machine, models: body.models }));
++ }
if (m && req.method === 'PUT') {
const prev = stored.get(m[1]);
if (!body.api_key && (!prev || prev.base_url !== body.base_url)) { res.statusCode = 400; return res.end(JSON.stringify({ code: 'credential.key_required', detail: 'enter the key again' })); }
@@@ -113,6 +118,36 @@ const ai = http.createServer((req, res) => {
assert.strictEqual(await byo.applyConfig(30, await db.getChannelAiConfig(30), null, fields), null);
assert.deepStrictEqual([calls[calls.length - 1].method, fields.byo_in_ai], ['DELETE', 0]);
assert.ok(!stored.has(SUBJECT));
++
++ // ── A model on the streamer's own computer (provider node): the machine, no key, no address ──
++ const MACHINE = 'dev_01M5YGM39VC2RRGDQPRD6D4SHD';
++ fields = { byo_model: 'llama3.1:8b', byo_base_url: '' };
++ assert.deepStrictEqual(await byo.applyConfig(30, await db.getChannelAiConfig(30), undefined, fields, { provider: 'node', machine: 'nope' }).then((r) => r && r.code), 'credentials.machine', 'a computer named');
++ fields = { byo_model: 'llama3.1:8b', byo_base_url: '' };
++ assert.strictEqual(await byo.applyConfig(30, await db.getChannelAiConfig(30), undefined, fields, { provider: 'node', machine: MACHINE }), null);
++ const nodePut = calls[calls.length - 1];
++ assert.deepStrictEqual([nodePut.method, nodePut.body], ['PUT', { provider: 'node', machine: MACHINE, models: { chat: 'llama3.1:8b' } }], 'no key, no address');
++ assert.deepStrictEqual([fields.byo_key, fields.byo_in_ai], ['', 1]);
++ await db.upsertChannelAiConfig(30, { ...fields, use_shared_key: 0 });
++ await require('../server/ai/viewers/settings').updateSettings(30, { byo: { provider: 'node', machine: 'any' } });
++ assert.strictEqual(byo.providerOf(await db.getChannelAiConfig(30)), 'node');
++ assert.strictEqual(byo.machineOf(await db.getChannelAiConfig(30)), 'any', 'any of their computers is kept');
++ // A model change keeps the computer; the viewers run on AI with the credential as before.
++ fields = { byo_model: 'qwen2.5:7b' };
++ assert.strictEqual(await byo.applyConfig(30, await db.getChannelAiConfig(30), undefined, fields), null);
++ assert.deepStrictEqual(calls[calls.length - 1].body, { provider: 'node', machine: 'any', models: { chat: 'qwen2.5:7b' } });
++ const k = calls.length;
++ const fromComputer = await director.quickReply({ stableText: 'roster', situationText: '', bot: { username: 'goosebot' }, streamerLine: 'say hi', provider: await budget.byoProvider(await db.getChannelAiConfig(30)), ownerUserId: 30 });
++ assert.ok(fromComputer && fromComputer.text, 'an answer');
++ assert.deepStrictEqual(calls.slice(k).find((c) => c.url.startsWith('/api/v1/runs')).body.credential, { subject: SUBJECT });
++ // The dashboard (public/js/ai-viewers.js, public/fragments/dashboard.html): the third source and what it saves.
++ const fs = require('fs');
++ const path = require('path');
++ const ui = fs.readFileSync(path.join(__dirname, '../public/js/ai-viewers.js'), 'utf8');
++ assert.ok(ui.includes("body.settings.byo = { provider: 'node', machine,") && ui.includes("? 'any' :"), 'computer mode saves provider node with the machine or any');
++ assert.ok(ui.includes("onComputer ? 'computer'"), 'and loads back as computer mode');
++ const frag = fs.readFileSync(path.join(__dirname, '../public/fragments/dashboard.html'), 'utf8');
++ assert.ok(frag.includes('<option value="computer">A model on my own computer (free)</option>') && frag.includes('id="aiv-node-machine"') && frag.includes('id="aiv-node-any"'));
} finally {
network.close(); ai.close();
}