openvibers/openvibe.bot Commits

Machines: use their models in OpenVibe.Chat (a link, filled in) and the --ai install command (#190)

OP OpenVibers committed 17 days ago · fa017e49de · 4 files, +82 −13

* A machine whose models the owner's AI may use links to OpenVibe.Chat's "Your AI key", filled in with this computer and its first model (saved only there)

* Adding a machine offers the --ai install command (Node v0.28.0): your Vibe and AI members think with the models it runs

* A machine's models as the real Node reports them (the ai plugin under its own name, capabilities.ai.ai), found by the production proof: kind ai reaches a machine with no computer plugin, and its card shows only its models

* The machines page tests wait for each machine's declared capabilities to be saved (CI fetched the page first)

Changes

ddiff --git a/server/machine-ai.js b/server/machine-ai.js
nnew file mode 100644
iindex 0000000..385bda3
---- /dev/null
++++ b/server/machine-ai.js
@@@ -0,0 +1,25 @@
++'use strict';
++/**
++ * A machine's own models (AI on your own machines, contracts 0.219.0 kind ai), from the capabilities its OpenVibe Node
++ * declares. The Node reports each plugin's describe under the plugin's name, so the ai plugin (Node v0.27.0) arrives as
++ * capabilities.ai = { ai: { models, ready, max_tokens } }; a computer plugin that offered ai itself would put it in
++ * capabilities.computer.ai. → { models, ready, max_tokens } or null
++ */
++const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
++
++function aiOf(capabilities) {
++ if (!isObj(capabilities)) return null;
++ if (isObj(capabilities.ai) && isObj(capabilities.ai.ai)) return capabilities.ai.ai;
++ if (isObj(capabilities.computer) && isObj(capabilities.computer.ai)) return capabilities.computer.ai;
++ return null;
++}
++
++/** The computer kinds a machine offers: its computer plugin's capabilities, with its models from the ai plugin. */
++function computerCaps(capabilities) {
++ const c = isObj(capabilities) && isObj(capabilities.computer) ? capabilities.computer : null;
++ const ai = aiOf(capabilities);
++ if (!c) return ai ? { ai } : null;
++ return ai && !isObj(c.ai) ? { ...c, ai } : c;
++}
++
++module.exports = { aiOf, computerCaps };
ddiff --git a/server/realtime.js b/server/realtime.js
iindex cc1b8ad..e94f936 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -28,6 +28,7 @@ const { json, iso, prefixedId } = require('./util');
const { userPrincipal, verifyService, verifyNode, isNodeToken, PRINCIPAL_SUB, decodePayload } = require('./api/auth');
const { getProfile } = require('./profiles');
const { createVoice } = require('./voice');
++const { computerCaps } = require('./machine-ai');
const OPEN = 1;
const MAX_BACKLOG = 64; // frames a device may send before its authentication completes
@@@ -695,11 +696,13 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
});
}
-- /** What a machine's computer plugin offers, from its last status: { terminal?, files?, screen? }, or null. */
++ /**
++ * What a machine's computer plugin offers, from its last status: { terminal?, files?, screen?, … }, with its own
++ * models (ai) from the ai plugin (server/machine-ai.js), or null.
++ */
function computerOf(deviceId) {
const c = deviceConns.get(deviceId);
-- const caps = c && c.status && c.status.capabilities && c.status.capabilities.computer;
-- return caps && typeof caps === 'object' && !Array.isArray(caps) ? caps : null;
++ return computerCaps(c && c.status && c.status.capabilities);
}
/**
@@@ -775,7 +778,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
return refuse('bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Node v0.12.0 or newer, with the computer plugin running in your graphical session', summary);
}
} else if (!offered || !(onDeskOnly(kind, value) ? (offered.desk && offered.desk[kind] === true) : offered[COMPUTER_CAP[kind]])) {
-- return refuse('bot.computer_off', kind === 'desk' ? NO_DESK : `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);
++ return refuse('bot.computer_off', kind === 'desk' ? NO_DESK : kind === 'ai' ? 'this machine offers no models: it needs OpenVibe Node v0.27.0 or newer with the ai plugin (install.sh --ai) and a model server (Ollama, LM Studio or llama.cpp)' : `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);
}
// A desk other than the person's own screen needs a machine that has desks; one other than private (a named desk,
// one per agent task) needs a machine that runs several (Node v0.19.0: desk.named). Kind desk names its desk in
ddiff --git a/server/web/render.js b/server/web/render.js
iindex f8b2125..0f0035b 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -22,6 +22,7 @@
*/
const { qrSvg } = require('./qr');
const { stepHelp } = require('../routines');
++const { aiOf } = require('../machine-ai');
const social = require('openvibe-shared/social');
const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c]);
@@@ -717,16 +718,19 @@ function renderMachinesPage({ machines = [], pairing = null }) {
// The computer plugin (Node docs/computer.md): the level its owner set on the machine, and whether their agent may use it.
const LEVELS = { observe: 'Observe', ask: 'Ask first', trusted: 'Trusted', full: 'Full control' };
const computerOf = (m) => {
-- const c = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).computer;
-- if (!c || typeof c !== 'object') return null;
++ const caps = m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {};
++ // Its own models (OpenVibe Node's ai plugin, server/machine-ai.js): what it offers, and whether a model server answers.
++ const models = aiOf(caps);
++ const ai = models ? { models: Array.isArray(models.models) ? models.models.slice(0, 20).map(String) : [], ready: models.ready === true } : null;
++ const c = caps.computer;
++ if (!c || typeof c !== 'object') return ai ? { on: false, ai } : null;
const level = m.computer_level || ((c.files || c.terminal || c.screen || {}).level) || null;
const d = c.desk && typeof c.desk === 'object' ? c.desk : null;
-- return { level: LEVELS[level] ? level : null, terminal: !!c.terminal, files: !!c.files, screen: !!c.screen, probe: !!c.probe, input: !!c.input,
++ return { on: true, level: LEVELS[level] ? level : null, terminal: !!c.terminal, files: !!c.files, screen: !!c.screen, probe: !!c.probe, input: !!c.input,
screenStream: !!(c.screen && c.screen.stream === true),
// Desks for its agents (Desks plan): how many at once, which kinds, and whether sign-ins carry over.
desks: d ? { max: Number.isInteger(d.max_desks) ? d.max_desks : 1, kinds: Array.isArray(d.kinds) ? d.kinds : ['desktop'], keeps: d.profiles === true } : null,
-- // Its own models (OpenVibe Node's ai plugin): what it offers, and whether a model server answers.
-- ai: c.ai && typeof c.ai === 'object' ? { models: Array.isArray(c.ai.models) ? c.ai.models.slice(0, 20).map(String) : [], ready: c.ai.ready === true } : null };
++ ai };
};
// Vibe by voice: shown for a machine whose Node runs the voice plugin (capabilities.voice).
const voiceRow = (m) => {
@@@ -749,6 +753,14 @@ function renderMachinesPage({ machines = [], pairing = null }) {
const computerRow = (m) => {
const c = computerOf(m);
if (!c) return '';
++ const aiRow = c.ai ? `<div class="machine-ai"><span class="pill">${c.ai.ready ? 'Models' : 'Models: no server answers'}</span>${c.ai.models.length ? `<span class="muted">${esc(c.ai.models.join(', '))}</span>` : ''}`
++ + `<form method="post" action="/machines/${esc(m.id)}/ai-models"><input type="hidden" name="on" value="${m.ai_models ? '0' : '1'}">`
++ + `<button type="submit" class="button quiet" aria-pressed="${m.ai_models ? 'true' : 'false'}">${m.ai_models ? 'Stop my AI using this computer\'s models' : 'Let my AI use this computer\'s models'}</button></form>`
++ // Turned on: OpenVibe.Chat's "Your AI key" comes filled in with this computer and its first model (saved only there).
++ + (m.ai_models && c.ai.models.length ? `<a class="button quiet" href="https://openvibe.chat/settings?own=node&amp;machine=${encodeURIComponent(m.id)}&amp;model=${encodeURIComponent(c.ai.models[0])}#oc-ai-key">Use these models in OpenVibe.Chat</a>` : '')
++ + '</div>' : '';
++ // A machine that offers only its models (the ai plugin, no computer plugin): just them.
++ if (!c.on) return `<div class="machine-computer">${aiRow}</div>`;
const can = [c.terminal && 'terminal', c.files && 'files', c.screen && 'screen', c.input && 'mouse and keyboard'].filter(Boolean).join(', ');
const desks = c.desks ? `<span class="muted">${esc(deskWords(c.desks))}</span>` : '';
// The desks running now (the Node's telemetry), and the page that lists them, to watch or stop one.
@@@ -761,9 +773,7 @@ function renderMachinesPage({ machines = [], pairing = null }) {
+ `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`
+ `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`
+ (c.screenStream && m.online ? `<form method="post" action="/machines/${esc(m.id)}/screen"><button type="submit" class="button quiet">${m.computer_screen_stream === 'live' ? 'Watch my screen' : 'Show my screen'}</button></form>` : '')
-- + (c.ai ? `<div class="machine-ai"><span class="pill">${c.ai.ready ? 'Models' : 'Models: no server answers'}</span>${c.ai.models.length ? `<span class="muted">${esc(c.ai.models.join(', '))}</span>` : ''}`
-- + `<form method="post" action="/machines/${esc(m.id)}/ai-models"><input type="hidden" name="on" value="${m.ai_models ? '0' : '1'}">`
-- + `<button type="submit" class="button quiet" aria-pressed="${m.ai_models ? 'true' : 'false'}">${m.ai_models ? 'Stop my AI using this computer\'s models' : 'Let my AI use this computer\'s models'}</button></form></div>` : '')
++ + aiRow
+ (c.probe ? `<form method="post" action="/machines/${esc(m.id)}/watch-probe"><input type="hidden" name="on" value="${m.watch_probe ? '0' : '1'}">`
+ `<button type="submit" class="button quiet" aria-pressed="${m.watch_probe ? 'true' : 'false'}">${m.watch_probe ? 'Stop my watches checking from here' : 'Let my watches check things from this computer'}</button></form>` : '')
+ '</div>';
@@@ -786,6 +796,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {
<li class="card"><h2>Or make a spare computer a desk host</h2>
<pre tabindex="0" class="installer"><code>${esc(pairing.installer.replace(/ --worker$/, ' --desk-host'))}</code></pre>
<p class="muted">An old laptop, a mini PC or a spare workstation becomes a place where your agents work: each task gets a desk of its own, with its own browser, and you can watch any desk and take it over. No screen, keyboard or sign-in needed on it. It runs as its own account and only for your agents; the installer adds what desks need and sizes them to its memory. Linux. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md#desk-hosts---desk-host-node-v0200">How it works</a>.</p></li>
++<li class="card"><h2>Or use the models you run on it</h2>
++<pre tabindex="0" class="installer"><code>${esc(pairing.installer.replace(/ --worker$/, ' --ai'))}</code></pre>
++<p class="muted">If this computer runs Ollama, LM Studio or llama.cpp's server, your Vibe and your AI members can think with its models: free, and no port is opened. The installer says which server it finds. Add <code>--ai</code> to any command above to do both. Once it is paired, turn on "Let my AI use this computer's models" for it here. Node v0.28.0 or newer. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/ai.md">How it works</a>.</p></li>
<li class="card"><h2>Or enter the pairing code</h2><p><strong class="code">${esc(pairing.code)}</strong></p>
<p class="muted">Valid until <time datetime="${esc(pairing.expires_at)}">${esc(pairing.expires_at)}</time>. It works once.</p></li></ol>
<p class="muted">Reload this page once the installer says it is paired: the machine shows up above.</p>`
ddiff --git a/test/machine-computer.test.js b/test/machine-computer.test.js
iindex 28bce85..39986e9 100644
---- a/test/machine-computer.test.js
++++ b/test/machine-computer.test.js
@@@ -346,7 +346,12 @@ const { boot, check, done } = require('./helpers/app');
const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true };
const host = await machine(alex, { files: COMPUTER.files, desk: { ...desk, max_desks: 4, kinds: ['desktop', 'browser'], profiles: true } });
const mac = await machine(alex, { ...COMPUTER, desk: { ...desk, window_manager: null, max_desks: 2, kinds: ['browser'] } });
-- const html = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
++ // A machine's declared capabilities are saved as its status arrives: wait until both machines' are.
++ let html = '';
++ for (let i = 0; i < 20 && !(html.includes('sign-ins kept') && html.includes('browser desks at once')); i++) {
++ if (i) await t.wait(100);
++ html = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
++ }
assert.ok(html.includes('Desks for your agents: 4 at once, or twice as many browser desks, sign-ins kept'), 'a desk host');
assert.ok(html.includes('Desks for your agents: 4 browser desks at once'), 'a Mac: browser desks only');
const add = await fetch(`${t.base}/machines`, { method: 'POST', redirect: 'manual', body: '', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(alex) } });
@@@ -356,6 +361,9 @@ const { boot, check, done } = require('./helpers/app');
const hostCmd = /<code>(curl -fsSL [^<]+ --desk-host)<\/code>/.exec(page);
assert.ok(hostCmd, 'the desk host install command');
assert.ok(!hostCmd[1].includes('--worker'), 'a desk host never runs the job worker too');
++ assert.ok(page.includes('Or use the models you run on it'), 'AI on your own machines (Node v0.28.0 --ai)');
++ const aiCmd = /<code>(curl -fsSL [^<]+ --ai)<\/code>/.exec(page);
++ assert.ok(aiCmd && !aiCmd[1].includes('--worker'), 'the models install command');
host.c.close();
mac.c.close();
});
@@@ -418,10 +426,30 @@ const { boot, check, done } = require('./helpers/app');
const rows = await t.db.many('SELECT value FROM command_audit WHERE robot_id = $1 AND kind = $2 AND result = $3', [gpu.deviceId, 'ai', 'ack']);
assert.deepStrictEqual(rows[0].value, { model: 'llama3.1:8b', messages: 1, chars: 29 });
assert.ok(!JSON.stringify(rows).includes('secret words'), 'never the text');
++ // A real Node reports the ai plugin under its own name, and a machine may run it with no computer plugin.
++ const own = await machine(alex, null);
++ own.c.send(frame('status', { capabilities: { ai: { ai: { models: ['qwen2.5-1.5b-instruct'], ready: true, max_tokens: 256 } } }, drivers: [], faults: [] }));
++ await t.wait(300);
++ const listed = (await t.call('GET', '/api/v1/machines', { user: alex })).json.machines.find((m) => m.id === own.deviceId);
++ assert.deepStrictEqual(listed.computer.ai.models, ['qwen2.5-1.5b-instruct'], 'its models, from the ai plugin');
++ assert.strictEqual(listed.computer.terminal, null, 'and nothing of a computer plugin');
++ assert.strictEqual((await cmd(own.deviceId, { kind: 'ai', value: { model: 'qwen2.5-1.5b-instruct', messages: [{ role: 'user', content: 'hi' }] } }, { user: alex })).json.result, 'ack');
++ const noExec = await cmd(own.deviceId, { kind: 'exec', value: { argv: ['id'] } }, { user: alex });
++ assert.strictEqual(noExec.json.code, 'bot.computer_off', 'no terminal without the computer plugin');
++ let ownCard = '';
++ for (let i = 0; i < 20 && !ownCard.includes('qwen2.5-1.5b-instruct'); i++) {
++ if (i) await t.wait(100);
++ ownCard = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
++ }
++ assert.ok(ownCard.includes('qwen2.5-1.5b-instruct'), 'the card shows its models');
++ own.c.close();
// The machines page: its models and the switch.
const html = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
assert.ok(html.includes('llama3.1:8b'), 'its models');
assert.ok(html.includes("Stop my AI using this computer's models"), 'and the switch, on');
++ assert.ok(html.includes(`href="https://openvibe.chat/settings?own=node&amp;machine=${gpu.deviceId}&amp;model=llama3.1%3A8b#oc-ai-key">Use these models in OpenVibe.Chat</a>`), 'and where to use them');
++ await t.call('POST', `/api/v1/machines/${gpu.deviceId}/ai-models`, { user: alex, body: { on: false } });
++ assert.ok(!(await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text()).includes('Use these models in OpenVibe.Chat'), 'only while it is on');
gpu.c.close();
});