openvibers/openvibe.bot Commits
Machines: use their models in OpenVibe.Chat (a link, filled in) and the --ai install command (#190)
Changes
nnew file mode 100644@@@ -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 };@@@ -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 = () => Datreturn 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@@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };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&machine=${encodeURIComponent(m.id)}&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>`@@@ -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&machine=${gpu.deviceId}&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();});
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -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 };
@@ -28,6 +28,7 @@ const { json, iso, prefixedId } = require('./util');
@@ -28,6 +28,7 @@ const { json, iso, prefixedId } = require('./util');
const { userPrincipal, verifyService, verifyNode, isNodeToken, PRINCIPAL_SUB, decodePayload } = require('./api/auth');
const { userPrincipal, verifyService, verifyNode, isNodeToken, PRINCIPAL_SUB, decodePayload } = require('./api/auth');
const { getProfile } = require('./profiles');
const { getProfile } = require('./profiles');
const { createVoice } = require('./voice');
const { createVoice } = require('./voice');
+const { computerCaps } = require('./machine-ai');
const OPEN = 1;
const OPEN = 1;
const MAX_BACKLOG = 64; // frames a device may send before its authentication completes
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
@@ -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) {
function computerOf(deviceId) {
const c = deviceConns.get(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
@@ -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);
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]])) {
} 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,
// 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
// one per agent task) needs a machine that runs several (Node v0.19.0: desk.named). Kind desk names its desk in
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
*/
*/
const { qrSvg } = require('./qr');
const { qrSvg } = require('./qr');
const { stepHelp } = require('../routines');
const { stepHelp } = require('../routines');
+const { aiOf } = require('../machine-ai');
const social = require('openvibe-shared/social');
const social = require('openvibe-shared/social');
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c]);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c]);
@@ -717,16 +718,19 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -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.
// 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 LEVELS = { observe: 'Observe', ask: 'Ask first', trusted: 'Trusted', full: 'Full control' };
const computerOf = (m) => {
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 level = m.computer_level || ((c.files || c.terminal || c.screen || {}).level) || null;
const d = c.desk && typeof c.desk === 'object' ? c.desk : 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),
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 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,
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).
// Vibe by voice: shown for a machine whose Node runs the voice plugin (capabilities.voice).
const voiceRow = (m) => {
const voiceRow = (m) => {
@@ -749,6 +753,14 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -749,6 +753,14 @@ function renderMachinesPage({ machines = [], pairing = null }) {
const computerRow = (m) => {
const computerRow = (m) => {
const c = computerOf(m);
const c = computerOf(m);
if (!c) return '';
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&machine=${encodeURIComponent(m.id)}&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 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>` : '';
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.
// 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 }) {
@@ -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'}">`
+ `<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>`
+ `<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.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'}">`
+ (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>` : '')
+ `<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>';
+ '</div>';
@@ -786,6 +796,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -786,6 +796,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {
<li class="card"><h2>Or make a spare computer a desk host</h2>
<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>
<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>
<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>
<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">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>`
<p class="muted">Reload this page once the installer says it is paired: the machine shows up above.</p>`
@@ -346,7 +346,12 @@ const { boot, check, done } = require('./helpers/app');
@@ -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 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 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 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 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');
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) } });
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');
@@ -356,6 +361,9 @@ const { boot, check, done } = require('./helpers/app');
const hostCmd = /<code>(curl -fsSL [^<]+ --desk-host)<\/code>/.exec(page);
const hostCmd = /<code>(curl -fsSL [^<]+ --desk-host)<\/code>/.exec(page);
assert.ok(hostCmd, 'the desk host install command');
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(!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();
host.c.close();
mac.c.close();
mac.c.close();
});
});
@@ -418,10 +426,30 @@ const { boot, check, done } = require('./helpers/app');
@@ -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']);
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.deepStrictEqual(rows[0].value, { model: 'llama3.1:8b', messages: 1, chars: 29 });
assert.ok(!JSON.stringify(rows).includes('secret words'), 'never the text');
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.
// The machines page: its models and the switch.
const html = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
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('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("Stop my AI using this computer's models"), 'and the switch, on');
+ assert.ok(html.includes(`href="https://openvibe.chat/settings?own=node&machine=${gpu.deviceId}&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();
gpu.c.close();
});
});