nio-cat 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -6
- package/bin/nio.js +10 -2
- package/package.json +2 -2
- package/src/agents/claude.js +8 -0
- package/src/claude-hook.js +6 -0
- package/src/control.js +238 -0
- package/src/server.js +5 -3
- package/src/telegram.js +311 -0
- package/src/voice.js +164 -0
- package/web/app/app.js +162 -45
- package/web/app/style.css +5 -0
- package/web/board.js +11 -11
- package/web/index.html +35 -17
- package/web/privadesa.html +148 -0
package/web/app/app.js
CHANGED
|
@@ -22,6 +22,8 @@ const svg = (d) => `<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">${d}
|
|
|
22
22
|
const ICON = {
|
|
23
23
|
activity: '<path d="M3 12h4l3-7 4 14 3-7h4"/>',
|
|
24
24
|
jobs: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 9h8M8 13h5M8 17h3"/>',
|
|
25
|
+
screen: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M9 20h6M12 16v4"/><circle cx="12" cy="10" r="2"/>',
|
|
26
|
+
panic: '<circle cx="12" cy="12" r="9"/><path d="M8.5 8.5l7 7M15.5 8.5l-7 7"/>',
|
|
25
27
|
memory: '<path d="M6 4h9l3 3v13H6z"/><path d="M9 11h6M9 15h6M14 4v4h4"/>',
|
|
26
28
|
sessions: '<path d="M4 6h16M4 12h16M4 18h10"/>',
|
|
27
29
|
approvals: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
|
@@ -231,7 +233,7 @@ async function connect() {
|
|
|
231
233
|
if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
|
|
232
234
|
state.connected = true; state.lastError = null; retry = 0;
|
|
233
235
|
rememberIdentity(m.url, state.info.identity);
|
|
234
|
-
renameMachine(state.info.host, state.info.id); syncServiceWorker(); render(); brainsForBoard();
|
|
236
|
+
renameMachine(state.info.host, state.info.id); syncServiceWorker(); render(); brainsForBoard(); loadVoice().then(() => state.route.name === 'session' && renderComposer?.());
|
|
235
237
|
const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '';
|
|
236
238
|
// Alguns túnels (Cloudflare des d'alguns servidors) no deixen passar el flux en directe: sense «hello» en 5 s, es passa a consultar cada 2 s.
|
|
237
239
|
gotHello = false; let poll = false;
|
|
@@ -320,11 +322,12 @@ function handle(msg) {
|
|
|
320
322
|
break;
|
|
321
323
|
case 'event': {
|
|
322
324
|
const evs = state.events.get(msg.session);
|
|
323
|
-
if (evs) { evs.set(msg.event.seq, msg.event); if (r.name === 'session' && r.id === msg.session) putEvent(msg.event); }
|
|
325
|
+
if (evs) { const was = evs.get(msg.event.seq); evs.set(msg.event.seq, msg.event); if (r.name === 'session' && r.id === msg.session) { putEvent(msg.event); if (msg.event.kind === 'text' && !msg.event.open && (!was || was.open)) autoSpeak(msg.event); } }
|
|
324
326
|
break;
|
|
325
327
|
}
|
|
326
328
|
case 'ollama': onOllama(msg); break;
|
|
327
329
|
case 'job': onJob(msg); break;
|
|
330
|
+
case 'screen': onScreen(msg); break;
|
|
328
331
|
case 'approval':
|
|
329
332
|
if (msg.approval.state === 'pending') { state.approvals.set(msg.approval.id, msg.approval); if (!document.hidden && navigator.vibrate) navigator.vibrate(30); }
|
|
330
333
|
else if (state.approvals.has(msg.approval.id)) { markDecided(msg.approval.id, msg.approval.state); break; }
|
|
@@ -398,7 +401,7 @@ async function pairAndAdd(pair, { msg = 'Ordinador connectat', after } = {}) {
|
|
|
398
401
|
}
|
|
399
402
|
// Passa a un altre ordinador de la llista i es queda a la mateixa secció.
|
|
400
403
|
function switchMachine(i) {
|
|
401
|
-
closeSheet(); store.active = i; streamCtrl?.abort();
|
|
404
|
+
closeSheet(); store.active = i; streamCtrl?.abort(); tgState = undefined; nodeVoice = null;
|
|
402
405
|
state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; state.lastError = null;
|
|
403
406
|
connect();
|
|
404
407
|
if (state.route.name === 'session') go('#/sessions'); else render();
|
|
@@ -428,6 +431,7 @@ const VIEWS = {
|
|
|
428
431
|
feines: { label: 'Feines', icon: ICON.jobs, group: 'Feina' },
|
|
429
432
|
activitat: { label: 'Activitat', icon: ICON.activity, group: 'Feina' },
|
|
430
433
|
memoria: { label: 'Memòria', icon: ICON.memory, group: 'Ordinadors' },
|
|
434
|
+
pantalla: { label: 'Pantalla', icon: ICON.screen, group: 'Ordinadors' },
|
|
431
435
|
ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
|
|
432
436
|
models: { label: 'Models', icon: ICON.models, group: 'Ordinadors' },
|
|
433
437
|
mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
|
|
@@ -435,8 +439,8 @@ const VIEWS = {
|
|
|
435
439
|
ajustos: { label: 'Ajustos', icon: ICON.settings, group: 'Compte' },
|
|
436
440
|
ajuda: { label: 'Ajuda', icon: ICON.help, group: 'Compte' }
|
|
437
441
|
};
|
|
438
|
-
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
|
|
439
|
-
const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'feines', 'activitat', 'memoria', 'models', 'session']);
|
|
442
|
+
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria', pantalla: '#/pantalla', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
|
|
443
|
+
const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'feines', 'activitat', 'memoria', 'pantalla', 'models', 'session']);
|
|
440
444
|
function parseRoute() {
|
|
441
445
|
const h = location.hash.replace(/^#\/?/, '');
|
|
442
446
|
if (h.startsWith('s/')) return { name: 'session', id: h.slice(2) };
|
|
@@ -457,6 +461,7 @@ function route() {
|
|
|
457
461
|
|
|
458
462
|
/* ---------------------------------------------------------------- pintat general */
|
|
459
463
|
function renderChrome() {
|
|
464
|
+
renderPanic();
|
|
460
465
|
const r = state.route, n = state.approvals.size, active = r.name === 'session' ? 'sessions' : r.name;
|
|
461
466
|
let prev = '';
|
|
462
467
|
$('#nav').innerHTML = Object.entries(VIEWS).map(([k, v], i) => {
|
|
@@ -494,11 +499,12 @@ function render() {
|
|
|
494
499
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
495
500
|
else {
|
|
496
501
|
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
497
|
-
view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'ordinadors' ? viewComputers() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : r.name === 'activitat' ? viewActivity() : r.name === 'feines' ? viewJobs() : r.name === 'memoria' ? viewMemory() : r.name === 'ajustos' ? viewSettings() : r.name === 'ajuda' ? viewHelp() : viewModels()}</div>`;
|
|
502
|
+
view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'ordinadors' ? viewComputers() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : r.name === 'activitat' ? viewActivity() : r.name === 'feines' ? viewJobs() : r.name === 'memoria' ? viewMemory() : r.name === 'pantalla' ? viewScreen() : r.name === 'ajustos' ? viewSettings() : r.name === 'ajuda' ? viewHelp() : viewModels()}</div>`;
|
|
498
503
|
if (r.name === 'mobil') bindLocal();
|
|
499
504
|
if (r.name === 'models' && machine()) loadOllama();
|
|
500
505
|
if (r.name === 'activitat' && machine()) { activity = null; loadActivity(); }
|
|
501
506
|
if (r.name === 'memoria' && machine()) loadMemory();
|
|
507
|
+
if (r.name === 'pantalla' && machine()) loadScreen();
|
|
502
508
|
if (r.name === 'feines' && machine()) loadJobs();
|
|
503
509
|
hydrateShots();
|
|
504
510
|
}
|
|
@@ -568,7 +574,8 @@ function viewSettings() {
|
|
|
568
574
|
const head = '<div class="top"><div><h1>Ajustos</h1><p>Avisos al mòbil i veu.</p></div></div>';
|
|
569
575
|
const avisos = machine() ? `<section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}${pushOn ? '<p class="small"><span class="chip ok">' + svg(ICON.check) + 'Avisos activats en aquest dispositiu</span></p>' : ''}<div class="row">${pushOn ? '<button class="btn ink grow" data-act="push-test" type="button">Envia’n un de prova</button><button class="btn" data-act="push-off" type="button">Desactiva</button>' : '<button class="btn ink grow" data-act="push" type="button">Activa els avisos</button>'}</div></div></section>` : '';
|
|
570
576
|
if (machine() && pushOn === null) refreshPush();
|
|
571
|
-
|
|
577
|
+
if (machine() && tgState === undefined) loadTelegram();
|
|
578
|
+
return `${head}<div class="stack-lg" style="max-width:640px">${avisos}${voicePanel()}<div id="tgPanel">${telegramPanel()}</div></div>`;
|
|
572
579
|
}
|
|
573
580
|
function viewHelp() {
|
|
574
581
|
return `<div class="top"><div><h1>Ajuda</h1><p>Com es fa cada cosa i què mirar si no connecta.</p></div><button class="btn ink" type="button" data-act="tour">Fes el tutorial</button></div><div style="max-width:720px">${helpPanel()}</div>`;
|
|
@@ -1752,37 +1759,138 @@ async function sendLocal() {
|
|
|
1752
1759
|
local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
|
|
1753
1760
|
}
|
|
1754
1761
|
|
|
1755
|
-
/* ----------------------------------------------------------------
|
|
1756
|
-
|
|
1757
|
-
const
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
if (
|
|
1763
|
-
|
|
1764
|
-
|
|
1762
|
+
/* ---------------------------------------------------------------- Pantalla: navegador i escriptori (agent-browser, agent-desktop) */
|
|
1763
|
+
let screen = null; // { caps, last, actions }
|
|
1764
|
+
const controlOn = () => !!(state.info?.control?.navegador?.enabled || state.info?.control?.escriptori?.enabled || screen?.caps?.navegador?.enabled || screen?.caps?.escriptori?.enabled);
|
|
1765
|
+
// Aturada d'emergència: sempre a la vista mentre el control estigui actiu.
|
|
1766
|
+
function renderPanic() {
|
|
1767
|
+
syncScreenBtn();
|
|
1768
|
+
let b = $('#panicBtn');
|
|
1769
|
+
if (!controlOn() || !machine()) { b?.remove(); return; }
|
|
1770
|
+
if (!b) { b = el(`<button class="panic" id="panicBtn" type="button" data-act="panic" aria-label="Aturada d’emergència">${svg(ICON.panic)}<span>Atura-ho tot</span></button>`); document.body.appendChild(b); }
|
|
1771
|
+
}
|
|
1772
|
+
async function panicNow() {
|
|
1773
|
+
if (!confirm('S’aturaran tots els agents d’aquest ordinador, es rebutjaran les peticions pendents i es tancarà el navegador.')) return;
|
|
1774
|
+
try { const r = await api('/api/panic', { method: 'POST' }); toast(`Aturat tot (${r.stopped} agents)`); } catch (e) { toast(e.message, 4500); }
|
|
1775
|
+
}
|
|
1776
|
+
function viewScreen() {
|
|
1777
|
+
const head = `<div class="top"><div><h1>Pantalla</h1><p>Els agents poden fer servir el navegador (agent-browser) i les apps de l’escriptori (agent-desktop). Mirar passa sol; clicar, escriure o enviar sempre et demana permís, amb una captura.</p></div><button class="btn ink" data-act="screen-now" type="button" ${controlOn() ? '' : 'disabled'}>${svg(ICON.screen)}Veure pantalla ara</button></div>`;
|
|
1778
|
+
return `${head}<div id="screenBody">${screenHtml()}</div>`;
|
|
1779
|
+
}
|
|
1780
|
+
function screenHtml() {
|
|
1781
|
+
if (!screen) return `<div class="panel empty">${bot('work')}<h2>Carregant…</h2></div>`;
|
|
1782
|
+
if (screen.error) return `<div class="panel empty">${bot('sleep')}<h2>${esc(screen.error)}</h2><p>Cal nio 0.7 o més nou en aquest ordinador.</p></div>`;
|
|
1783
|
+
const c = screen.caps, row = (k, name, hint) => {
|
|
1784
|
+
const x = c[k];
|
|
1785
|
+
const st = !x.installed ? `No instal·lat · <span class="mono">${hint}</span>` : x.ready === false ? esc(x.why || 'No està a punt') : x.enabled ? 'Activat' : 'Desactivat';
|
|
1786
|
+
return `<div class="agent-row"><i class="dot ${x.enabled ? 'on' : ''}"></i><span class="grow"><b>${name}</b><div class="muted small">${st}</div></span>${x.installed ? `<button class="btn sm ${x.enabled ? '' : 'ink'}" data-ctl="${k}" data-on="${!x.enabled}" type="button">${x.enabled ? 'Desactiva' : 'Activa'}</button>` : ''}</div>`;
|
|
1787
|
+
};
|
|
1788
|
+
return `<div class="grid2">
|
|
1789
|
+
<div class="stack">
|
|
1790
|
+
<section class="panel"><header><h2>Última captura</h2>${screen.last ? `<span class="muted small">${ago(screen.last.t)} · ${esc(screen.last.from)}</span>` : ''}</header><div class="body">${screen.last ? `<img class="screen-img" alt="Captura de la pantalla de l’ordinador" data-shot="${esc(screen.last.id)}">` : '<p class="muted small">Encara no n’hi ha cap. Prem «Veure pantalla ara».</p>'}</div></section>
|
|
1791
|
+
<section class="panel"><header><h2>Accions fetes</h2></header><div class="body">${screen.actions.length ? screen.actions.map((a) => `<div class="act-row"><span class="act-t mono">${new Date(a.t).toLocaleTimeString('ca', { hour: '2-digit', minute: '2-digit' })}</span><div class="grow"><div class="mono small act-what">${esc(a.what)}</div><div class="muted small">${esc(a.title || '')}</div></div></div>`).join('') : '<p class="muted small">Cap encara.</p>'}</div></section>
|
|
1792
|
+
</div>
|
|
1793
|
+
<div class="stack">
|
|
1794
|
+
<section class="panel"><header><h2>En aquest ordinador</h2></header><div class="body">${row('navegador', 'Navegador web', 'npm i -g agent-browser && agent-browser install')}${row('escriptori', 'Apps de l’escriptori', 'npm i -g agent-desktop')}<p class="muted small" style="margin-top:10px">En activar-ho, nio dona a Claude Code i a Codex les guies oficials. Desactivat per defecte. Claude Code demana permís per a cada acció; Codex no pot preguntar, així que només segueix les normes de la guia.</p></div></section>
|
|
1795
|
+
<section class="panel"><header><h2>Aturada d’emergència</h2></header><div class="body stack"><p class="small">Atura tots els agents, rebutja el que estigui pendent i tanca el navegador.</p><button class="btn danger" data-act="panic" type="button">${svg(ICON.panic)}Atura-ho tot</button></div></section>
|
|
1796
|
+
<section class="panel"><header><h2>Vols fer-ho tu?</h2></header><div class="body"><p class="muted small">nio no és un escriptori remot. Si necessites controlar la pantalla tu mateix, fes servir <a href="https://rustdesk.com" target="_blank" rel="noopener">RustDesk</a> o <a href="https://remotedesktop.google.com" target="_blank" rel="noopener">Chrome Remote Desktop</a>.</p></div></section>
|
|
1797
|
+
</div>
|
|
1798
|
+
</div>`;
|
|
1799
|
+
}
|
|
1800
|
+
async function loadScreen() {
|
|
1801
|
+
try {
|
|
1802
|
+
const [caps, cur, log] = await Promise.all([api('/api/control'), api('/api/screen'), api('/api/audit?type=tool&limit=300')]);
|
|
1803
|
+
screen = { caps, last: cur.last, actions: log.filter((e) => /^(npx\s+)?agent-(browser|desktop)\b/.test(e.what || '')).slice(0, 30) };
|
|
1804
|
+
} catch (e) { screen = { error: e.message }; }
|
|
1805
|
+
const box = $('#screenBody'); if (box) { box.innerHTML = screenHtml(); hydrateShots(box); }
|
|
1806
|
+
renderPanic();
|
|
1807
|
+
}
|
|
1808
|
+
function syncScreenBtn() { const b = $('[data-act=screen-now]'); if (b) b.disabled = !controlOn(); }
|
|
1809
|
+
function onScreen(msg) { if (screen && !screen.error) { screen.last = msg.shot; if (state.route.name === 'pantalla') { const box = $('#screenBody'); box.innerHTML = screenHtml(); hydrateShots(box); } } }
|
|
1810
|
+
document.addEventListener('click', async (e) => {
|
|
1811
|
+
const b = e.target.closest('[data-ctl]'); if (!b) return;
|
|
1812
|
+
b.disabled = true;
|
|
1813
|
+
try { const r = await api('/api/control', { method: 'PUT', body: { [b.dataset.ctl]: b.dataset.on === 'true' } }); screen.caps = r; if (state.info) state.info.control = r; toast(b.dataset.on === 'true' ? 'Activat. Les guies ja són a Claude Code i Codex.' : 'Desactivat'); (r.notes || []).forEach((n) => toast(n, 5000)); }
|
|
1814
|
+
catch (err) { toast(err.message, 6000); }
|
|
1815
|
+
const box = $('#screenBody'); if (box) { box.innerHTML = screenHtml(); hydrateShots(box); } renderPanic();
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
/* ---------------------------------------------------------------- Telegram (bot propi, configurat a l'ordinador) */
|
|
1819
|
+
let tgState;
|
|
1820
|
+
async function loadTelegram() {
|
|
1821
|
+
try { tgState = await api('/api/telegram'); } catch { tgState = null; }
|
|
1822
|
+
const box = $('#tgPanel'); if (box) box.innerHTML = telegramPanel();
|
|
1823
|
+
}
|
|
1824
|
+
function telegramPanel() {
|
|
1825
|
+
if (!machine()) return '';
|
|
1826
|
+
const t = tgState;
|
|
1827
|
+
if (t === undefined) return '<section class="panel"><header><h2>Telegram</h2></header><div class="body"><p class="muted small">Carregant…</p></div></section>';
|
|
1828
|
+
if (t === null) return '<section class="panel"><header><h2>Telegram</h2></header><div class="body"><p class="muted small">Aquest ordinador encara no té Telegram (cal nio 0.7 o més nou).</p></div></section>';
|
|
1829
|
+
if (!t.configured) return `<section class="panel"><header><h2>Telegram</h2></header><div class="body stack">
|
|
1830
|
+
<p class="small">Parla amb els agents d’aquest ordinador des de Telegram: instruccions, notes de veu, avisos i aprovacions amb botons. El bot és teu i el testimoni es queda a l’ordinador.</p>
|
|
1831
|
+
<ol class="muted small" style="padding-left:1.2em"><li>A Telegram, obre <b>@BotFather</b> i envia <span class="mono">/newbot</span>.</li><li>Copia el testimoni que et dona i enganxa’l aquí.</li><li>Escriu qualsevol cosa al teu bot i torna aquí per autoritzar el teu xat.</li></ol>
|
|
1832
|
+
<form class="row" id="tgForm" style="flex-wrap:nowrap"><input type="password" id="tgToken" placeholder="123456:ABC…" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Testimoni del bot"><button class="btn ink" type="submit">Connecta</button></form></div></section>`;
|
|
1833
|
+
return `<section class="panel"><header><h2>Telegram</h2><span class="chip ok"><i class="dot on"></i>${esc(t.bot ? '@' + t.bot.username : 'Connectat')}</span></header><div class="body stack">
|
|
1834
|
+
${t.pending.length ? `<div class="stack"><b class="small">Volen parlar amb el bot</b>${t.pending.map((p) => `<div class="agent-row"><span class="grow"><b>${esc(p.name)}</b><div class="muted small">${p.username ? '@' + esc(p.username) + ' · ' : ''}${ago(p.t)} · xat ${esc(p.id)}</div></span><button class="btn sm ink" data-tg-allow="${esc(p.id)}" data-name="${esc(p.name)}" type="button">Autoritza</button></div>`).join('')}<p class="muted small">Autoritza només els xats que reconeguis. La resta s’ignoren.</p></div>` : ''}
|
|
1835
|
+
<div class="stack"><b class="small">Xats autoritzats</b>${t.allowed.length ? t.allowed.map((a) => `<div class="agent-row"><span class="grow"><b>${esc(a.name)}</b><div class="muted small">xat ${esc(a.id)}</div></span><button class="btn sm danger" data-tg-drop="${esc(a.id)}" type="button">Treu</button></div>`).join('') : `<p class="muted small">Encara cap. Escriu al bot ${t.bot ? '<b>@' + esc(t.bot.username) + '</b>' : ''} i el teu xat apareixerà aquí.</p>`}</div>
|
|
1836
|
+
<div class="grid-2"><label class="f"><span>Agent</span><select id="tgAgent">${['claude', 'codex'].map((a) => `<option value="${a}" ${t.agent === a ? 'selected' : ''}>${AGENT_NAMES[a]}</option>`).join('')}</select></label>
|
|
1837
|
+
<label class="f"><span>Permisos</span><select id="tgMode">${Object.entries(MODES).map(([k, m]) => `<option value="${k}" ${t.mode === k ? 'selected' : ''}>${m.label}</option>`).join('')}</select></label></div>
|
|
1838
|
+
<label class="row check"><input type="checkbox" id="tgNotify" ${t.notify ? 'checked' : ''}> <span>Envia-hi els avisos (aprovacions, feines acabades i errors)</span></label>
|
|
1839
|
+
${t.voice ? '' : '<p class="muted small">Per entendre notes de veu, configura la veu (a dalt).</p>'}
|
|
1840
|
+
<div class="row"><button class="btn sm danger" data-tg-off type="button">Desconnecta el bot</button></div></div></section>`;
|
|
1841
|
+
}
|
|
1842
|
+
document.addEventListener('submit', async (e) => {
|
|
1843
|
+
if (e.target.id !== 'tgForm') return; e.preventDefault();
|
|
1844
|
+
try { tgState = await api('/api/telegram', { method: 'PUT', body: { token: $('#tgToken').value.trim() } }); toast('Bot connectat'); } catch (err) { toast(err.message, 5000); }
|
|
1845
|
+
$('#tgPanel').innerHTML = telegramPanel();
|
|
1846
|
+
});
|
|
1847
|
+
document.addEventListener('change', async (e) => {
|
|
1848
|
+
const k = { tgAgent: 'agent', tgMode: 'mode', tgNotify: 'notify' }[e.target.id]; if (!k) return;
|
|
1849
|
+
try { tgState = await api('/api/telegram', { method: 'PUT', body: { [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value } }); toast('Desat'); } catch (err) { toast(err.message); }
|
|
1850
|
+
});
|
|
1851
|
+
document.addEventListener('click', async (e) => {
|
|
1852
|
+
const b = e.target.closest('[data-tg-allow], [data-tg-drop], [data-tg-off]'); if (!b) return;
|
|
1853
|
+
try {
|
|
1854
|
+
if (b.dataset.tgAllow) tgState = await api('/api/telegram/allow', { method: 'POST', body: { id: Number(b.dataset.tgAllow), name: b.dataset.name } });
|
|
1855
|
+
if (b.dataset.tgDrop) tgState = await api(`/api/telegram/allow/${b.dataset.tgDrop}`, { method: 'DELETE' });
|
|
1856
|
+
if (b.hasAttribute('data-tg-off')) { if (!confirm('El bot deixarà de funcionar amb aquest ordinador.')) return; tgState = await api('/api/telegram', { method: 'PUT', body: { token: null } }); }
|
|
1857
|
+
} catch (err) { return toast(err.message); }
|
|
1858
|
+
$('#tgPanel').innerHTML = telegramPanel();
|
|
1859
|
+
});
|
|
1860
|
+
|
|
1861
|
+
/* ---------------------------------------------------------------- veu (a l'ordinador) */
|
|
1862
|
+
// Les claus (ElevenLabs o OpenAI) viuen a l'ordinador. L'àudio fa: mòbil → ordinador → proveïdor.
|
|
1863
|
+
let nodeVoice = null; // estat de la veu de l'ordinador actiu (sense claus)
|
|
1864
|
+
const voiceOn = () => !!nodeVoice?.stt;
|
|
1865
|
+
async function loadVoice() { try { nodeVoice = await api('/api/voice'); } catch { nodeVoice = null; } }
|
|
1866
|
+
async function nodeAudio(path, opts) {
|
|
1867
|
+
const m = machine(); if (!m) throw new Error('No hi ha cap ordinador connectat');
|
|
1868
|
+
const res = await fetch(m.url + path, { method: 'POST', ...opts, headers: { Authorization: `Bearer ${m.token}`, ...(opts.headers || {}) } });
|
|
1869
|
+
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `Error ${res.status}`); }
|
|
1870
|
+
return res;
|
|
1765
1871
|
}
|
|
1766
1872
|
const speech = { audio: null, btn: null, cache: new Map() };
|
|
1767
|
-
const speechText = (t) => String(t).replace(/```[\s\S]*?```/g, ' (bloc de codi) ').replace(/`([^`]+)`/g, '$1').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/^#{1,4}\s+/gm, '').replace(/^\s*[-*•]\s+/gm, '').trim().slice(0,
|
|
1873
|
+
const speechText = (t) => String(t).replace(/```[\s\S]*?```/g, ' (bloc de codi) ').replace(/`([^`]+)`/g, '$1').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/^#{1,4}\s+/gm, '').replace(/^\s*[-*•]\s+/gm, '').trim().slice(0, 2500);
|
|
1768
1874
|
function stopSpeech() { speech.audio?.pause(); speech.audio = null; speech.btn?.classList.remove('on', 'wait'); speech.btn = null; }
|
|
1769
|
-
async function
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
const ev = state.events.get(state.route.id)?.get(Number(btn.closest('[data-seq]')?.dataset.seq)), text = speechText(ev?.text || '');
|
|
1773
|
-
if (!text) return;
|
|
1774
|
-
speech.btn = btn; btn.classList.add('wait');
|
|
1875
|
+
async function playText(text, btn) {
|
|
1876
|
+
text = speechText(text); if (!text) return;
|
|
1877
|
+
if (btn) { speech.btn = btn; btn.classList.add('wait'); }
|
|
1775
1878
|
try {
|
|
1776
|
-
|
|
1777
|
-
if (!url) {
|
|
1778
|
-
|
|
1779
|
-
url = URL.createObjectURL(await res.blob()); speech.cache.set(k, url);
|
|
1780
|
-
}
|
|
1781
|
-
if (speech.btn !== btn) return;
|
|
1879
|
+
let url = speech.cache.get(text);
|
|
1880
|
+
if (!url) { url = URL.createObjectURL(await (await nodeAudio('/api/voice/tts', { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) })).blob()); speech.cache.set(text, url); }
|
|
1881
|
+
if (btn && speech.btn !== btn) return;
|
|
1782
1882
|
const a = new Audio(url); speech.audio = a; a.onended = stopSpeech; await a.play();
|
|
1783
|
-
btn
|
|
1883
|
+
btn?.classList.remove('wait'); btn?.classList.add('on');
|
|
1784
1884
|
} catch (e) { stopSpeech(); toast(e.message, 4500); }
|
|
1785
1885
|
}
|
|
1886
|
+
async function speak(btn) {
|
|
1887
|
+
if (speech.btn === btn) return stopSpeech();
|
|
1888
|
+
stopSpeech();
|
|
1889
|
+
const ev = state.events.get(state.route.id)?.get(Number(btn.closest('[data-seq]')?.dataset.seq));
|
|
1890
|
+
return playText(ev?.text || '', btn);
|
|
1891
|
+
}
|
|
1892
|
+
// Lectura automàtica (si l'has activada): quan l'agent acaba una resposta a la sessió oberta.
|
|
1893
|
+
function autoSpeak(ev) { if (nodeVoice?.speak && nodeVoice?.tts && !document.hidden && ev?.text) { stopSpeech(); playText(ev.text); } }
|
|
1786
1894
|
const mic = { rec: null, chunks: [], stream: null };
|
|
1787
1895
|
async function toggleMic() {
|
|
1788
1896
|
if (!voiceOn()) return voiceSheet();
|
|
@@ -1797,8 +1905,7 @@ async function toggleMic() {
|
|
|
1797
1905
|
$('#micBtn')?.classList.remove('rec'); $('#micBtn')?.classList.add('wait');
|
|
1798
1906
|
try {
|
|
1799
1907
|
if (blob.size < 1500) throw new Error('No he sentit res. Prova-ho una altra vegada.');
|
|
1800
|
-
const
|
|
1801
|
-
const r = await (await elevenlabs('/v1/speech-to-text', { method: 'POST', body: fd })).json(), ta = $('#msg');
|
|
1908
|
+
const r = await (await nodeAudio('/api/voice/stt', { headers: { 'content-type': blob.type || 'audio/webm' }, body: blob })).json(), ta = $('#msg');
|
|
1802
1909
|
if (!r.text?.trim()) toast('No he entès res.');
|
|
1803
1910
|
else if (ta) { ta.value = `${ta.value ? `${ta.value} ` : ''}${r.text.trim()}`; ta.dispatchEvent(new Event('input')); ta.focus(); }
|
|
1804
1911
|
} catch (e) { toast(e.message, 4500); }
|
|
@@ -1807,20 +1914,28 @@ async function toggleMic() {
|
|
|
1807
1914
|
rec.start(); $('#micBtn')?.classList.add('rec');
|
|
1808
1915
|
}
|
|
1809
1916
|
function voicePanel() {
|
|
1810
|
-
|
|
1811
|
-
}
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1917
|
+
if (!machine()) return '';
|
|
1918
|
+
const v = nodeVoice, P = v?.providers || {};
|
|
1919
|
+
const on = Object.entries(P).filter(([, p]) => p.key).map(([, p]) => p.name);
|
|
1920
|
+
return `<section class="panel"><header><h2>Veu</h2>${on.length ? `<span class="chip ok"><i class="dot on"></i>${esc(on.join(' · '))}</span>` : ''}</header><div class="body stack"><p class="small">Dicta els missatges amb el micròfon i escolta les respostes. Les claus es queden a l’ordinador (${esc(machine().name)}); l’àudio va del mòbil a l’ordinador i d’allà al proveïdor.</p>${v ? '' : '<p class="muted small">Aquest ordinador encara no té la veu (cal nio 0.7 o més nou).</p>'}<div class="row"><button class="btn ink" data-act="voice" type="button" ${v ? '' : 'disabled'}>${on.length ? 'Canvia la configuració' : 'Configura la veu'}</button></div></div></section>`;
|
|
1921
|
+
}
|
|
1922
|
+
async function voiceSheet() {
|
|
1923
|
+
await loadVoice();
|
|
1924
|
+
if (!nodeVoice) return toast('Aquest ordinador encara no té la veu. Actualitza nio (npx nio-cat@latest).', 5000);
|
|
1925
|
+
const v = nodeVoice, old = LS.get('nio.voice', {});
|
|
1926
|
+
openSheet(`<h2>Veu</h2><p class="muted small">Les claus es desen a l’ordinador «${esc(machine().name)}» i no tornen mai al mòbil. Amb una sola clau n’hi ha prou.</p>
|
|
1815
1927
|
<form class="stack" id="voiceForm" style="margin-top:14px">
|
|
1816
|
-
<label class="f"><span>Clau d’
|
|
1817
|
-
<label class="f"><span>
|
|
1818
|
-
<label class="f"><span>
|
|
1819
|
-
<
|
|
1928
|
+
<label class="f"><span>Clau d’ElevenLabs ${v.providers.elevenlabs.key ? `<span class="muted">(desada: ${esc(v.providers.elevenlabs.key)})</span>` : ''}</span><input type="password" id="vEl" placeholder="${v.providers.elevenlabs.key ? 'Deixa-ho buit per mantenir-la' : 'sk_…'}" value="${!v.providers.elevenlabs.key && old.key ? esc(old.key) : ''}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
|
|
1929
|
+
<label class="f"><span>Clau d’OpenAI ${v.providers.openai.key ? `<span class="muted">(desada: ${esc(v.providers.openai.key)})</span>` : ''}</span><input type="password" id="vOa" placeholder="${v.providers.openai.key ? 'Deixa-ho buit per mantenir-la' : 'sk-…'}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
|
|
1930
|
+
<div class="grid-2"><label class="f"><span>Per entendre’t</span><select id="vStt"><option value="">Automàtic</option><option value="elevenlabs" ${v.stt === 'elevenlabs' ? 'selected' : ''}>ElevenLabs Scribe</option><option value="openai" ${v.stt === 'openai' ? 'selected' : ''}>OpenAI</option></select></label>
|
|
1931
|
+
<label class="f"><span>Per parlar</span><select id="vTts"><option value="">Automàtic</option><option value="elevenlabs" ${v.tts === 'elevenlabs' ? 'selected' : ''}>ElevenLabs</option><option value="openai" ${v.tts === 'openai' ? 'selected' : ''}>OpenAI</option></select></label></div>
|
|
1932
|
+
<label class="row check"><input type="checkbox" id="vSpeak" ${v.speak ? 'checked' : ''}> <span>Llegeix en veu alta les respostes de la sessió oberta</span></label>
|
|
1933
|
+
${old.key ? '<p class="muted small">Tenies una clau d’ElevenLabs desada en aquest navegador: la passo a l’ordinador i l’esborro d’aquí.</p>' : ''}
|
|
1934
|
+
<div class="row between sheet-foot"><button class="btn ghost" type="button" id="vOff">Treu les claus</button><button class="btn ink" type="submit">Desa</button></div>
|
|
1820
1935
|
</form>`, (sh) => {
|
|
1821
|
-
const
|
|
1822
|
-
$('#voiceForm', sh).onsubmit = (e) => { e.preventDefault(); const
|
|
1823
|
-
$('#vOff', sh).onclick = () => {
|
|
1936
|
+
const put = async (body, msg) => { try { nodeVoice = await api('/api/voice', { method: 'PUT', body }); try { localStorage.removeItem('nio.voice'); } catch { /* res */ } closeSheet(); toast(msg); if (state.route.name === 'session') renderSession(); else render(); } catch (e) { toast(e.message, 4500); } };
|
|
1937
|
+
$('#voiceForm', sh).onsubmit = (e) => { e.preventDefault(); const keys = {}; const el = $('#vEl', sh).value.trim(), oa = $('#vOa', sh).value.trim(); if (el) keys.elevenlabs = el; if (oa) keys.openai = oa; put({ keys, stt: $('#vStt', sh).value || null, tts: $('#vTts', sh).value || null, speak: $('#vSpeak', sh).checked }, 'Veu desada a l’ordinador'); };
|
|
1938
|
+
$('#vOff', sh).onclick = () => { if (confirm('Es treuran les claus de veu d’aquest ordinador.')) put({ keys: { elevenlabs: null, openai: null } }, 'Claus eliminades'); };
|
|
1824
1939
|
});
|
|
1825
1940
|
}
|
|
1826
1941
|
|
|
@@ -1881,6 +1996,8 @@ document.addEventListener('click', async (e) => {
|
|
|
1881
1996
|
case 'push-off': return disablePush();
|
|
1882
1997
|
case 'revoke': return revokeDevice(a.dataset.dev);
|
|
1883
1998
|
case 'job-new': return newJobSheet();
|
|
1999
|
+
case 'panic': return panicNow();
|
|
2000
|
+
case 'screen-now': try { const s = await api('/api/screen', { method: 'POST' }); onScreen({ shot: s }); } catch (err) { toast(err.message, 4500); } return;
|
|
1884
2001
|
case 'mem-new': return memEdit(null);
|
|
1885
2002
|
case 'mem-all': if (mem) delete mem.hits; return loadMemory();
|
|
1886
2003
|
case 'activity-refresh': activity = null; return loadActivity();
|
package/web/app/style.css
CHANGED
|
@@ -625,3 +625,8 @@ dialog.sheet.wiz.compact .sheet-in, .compact .wiz-body { flex: 0 1 auto; }
|
|
|
625
625
|
.check { gap: 10px; font-size: 14px; }
|
|
626
626
|
.check input { width: 18px; height: 18px; accent-color: var(--fg); }
|
|
627
627
|
@media (max-width: 520px) { .grid-2 { grid-template-columns: 1fr; } }
|
|
628
|
+
|
|
629
|
+
/* ---------- Aturada d'emergència (sempre a la vista amb el control actiu) ---------- */
|
|
630
|
+
.panic { position: fixed; z-index: 35; right: 16px; top: calc(66px + var(--safe-t)); display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 12px 0 9px; border: 0; border-radius: 999px; background: var(--red); color: #fff; font: 600 13px var(--font); box-shadow: 0 8px 20px -10px color-mix(in srgb, var(--red) 80%, transparent); }
|
|
631
|
+
.panic .ico { width: 17px; height: 17px; }
|
|
632
|
+
@media (min-width: 821px) { .panic { top: 16px; right: 20px; } }
|
package/web/board.js
CHANGED
|
@@ -740,21 +740,21 @@ function orgChart(spec) {
|
|
|
740
740
|
return pairs.length ? buildGraph(pairs, [], 'v') : [];
|
|
741
741
|
}
|
|
742
742
|
const TEMPLATES = {
|
|
743
|
-
agent: { say: 'Aquí tens com treballa un agent
|
|
744
|
-
aprovacio: { say: 'Flux d’aprovació: el que
|
|
745
|
-
|
|
743
|
+
agent: { say: 'Aquí tens com treballa un agent amb nio, del mòbil a l’ordinador.', edges: [['Mòbil', 'nio', 'instrucció'], ['nio', 'Claude Code', ''], ['Claude Code', 'Acció de risc?', ''], ['Acció de risc?', 'Aprovació al mòbil', 'sí'], ['Acció de risc?', 'Fa la feina', 'no'], ['Aprovació al mòbil', 'Fa la feina', 'aprovada'], ['Fa la feina', 'Registre', '']] },
|
|
744
|
+
aprovacio: { say: 'Flux d’aprovació: el que és arriscat passa per tu.', edges: [['Inici', 'L’agent vol actuar', ''], ['L’agent vol actuar', 'Només llegeix?', ''], ['Només llegeix?', 'Executa', 'sí'], ['Només llegeix?', 'Demana permís', 'no'], ['Demana permís', 'Aprovada?', ''], ['Aprovada?', 'Executa', 'sí'], ['Aprovada?', 'Ho deixa estar', 'no'], ['Executa', 'Fi', ''], ['Ho deixa estar', 'Fi', '']] },
|
|
745
|
+
revisio: { say: 'Una feina encadenada: un agent fa, l’altre revisa.', edges: [['Issue nova', 'Claude Code arregla', ''], ['Claude Code arregla', 'Tests', ''], ['Tests', 'Passen?', ''], ['Passen?', 'Codex revisa', 'sí'], ['Passen?', 'Claude Code torna-hi', 'no'], ['Claude Code torna-hi', 'Tests', ''], ['Codex revisa', 'Pull request', '']] }
|
|
746
746
|
};
|
|
747
747
|
|
|
748
748
|
/* ---------- Xat ---------- */
|
|
749
|
-
const SUGG = ['Flux:
|
|
749
|
+
const SUGG = ['Flux: Idea -> Prototip -> Proves -> Publicació', 'Mapa mental de nio: agents, ordinadors, aprovacions, memòria', 'Arbre: Web > Portada, App; App > Sessions, Feines', 'Plantilla d’aprovació', 'Ajuda'];
|
|
750
750
|
const HELP = `Puc dibuixar i editar per tu. Prova, per exemple:
|
|
751
751
|
• <b>Flux: A -> B -> C</b> (una fletxa per línia, amb <b>: etiqueta</b> si vols)
|
|
752
752
|
• <b>Mapa mental de X: a, b, c</b>
|
|
753
|
-
• <b>Organigrama:
|
|
754
|
-
• <b>Afegeix un rombe “
|
|
755
|
-
• <b>Pinta
|
|
756
|
-
• <b>Esborra
|
|
757
|
-
• <b>Plantilla agent</b>, <b>Plantilla d’aprovació</b>, <b>Plantilla
|
|
753
|
+
• <b>Organigrama: Web > Portada, App</b>
|
|
754
|
+
• <b>Afegeix un rombe “Passen?”</b> · <b>Connecta Tests amb Deploy</b>
|
|
755
|
+
• <b>Pinta Tests de verd</b> · <b>Reanomena Tests a Proves</b>
|
|
756
|
+
• <b>Esborra Deploy</b> · <b>Ordena-ho</b> · <b>Centra</b> · <b>Desfés</b>
|
|
757
|
+
• <b>Plantilla agent</b>, <b>Plantilla d’aprovació</b>, <b>Plantilla revisió</b>
|
|
758
758
|
• <b>Exporta PNG</b>, <b>SVG</b> o <b>JSON</b>`;
|
|
759
759
|
function say(html, me) {
|
|
760
760
|
B.chat.push({ me: !!me, html, t: Date.now() }); if (B.chat.length > 200) B.chat.splice(0, B.chat.length - 200);
|
|
@@ -792,7 +792,7 @@ function ruleAct(text) {
|
|
|
792
792
|
}
|
|
793
793
|
else if (/^exporta/.test(n)) { const k = /svg/.test(n) ? 'svg' : /json/.test(n) ? 'json' : 'png'; reply = exportAs(k) ? `T’he descarregat la pissarra en ${k.toUpperCase()}.` : 'Encara no hi ha res per exportar.'; }
|
|
794
794
|
else if (/plantilla|com treballa un agent|flux d.?un agent/.test(n)) {
|
|
795
|
-
const k = /aprova/.test(n) ? 'aprovacio' : /
|
|
795
|
+
const k = /aprova/.test(n) ? 'aprovacio' : /revisi|revisa/.test(n) ? 'revisio' : 'agent';
|
|
796
796
|
record(); const t = TEMPLATES[k], list = buildGraph(t.edges, [], 'h'); done(t.say, list);
|
|
797
797
|
}
|
|
798
798
|
else if (/^(mapa mental|mapa conceptual|mindmap)/.test(n)) {
|
|
@@ -1046,7 +1046,7 @@ function template() {
|
|
|
1046
1046
|
<div class="bd-stage" id="bdStage" data-tool="select">
|
|
1047
1047
|
<svg class="bd-svg" id="bdSvg" role="img" aria-label="Pissarra"><g id="bdWorld"><g id="bdEls"></g><g id="bdSel" class="bd-selg"></g></g></svg>
|
|
1048
1048
|
<textarea class="bd-edit" id="bdEdit" hidden spellcheck="false" aria-label="Edita el text"></textarea>
|
|
1049
|
-
<div class="bd-empty" id="bdEmpty"><b>Pissarra en blanc</b><span>Dibuixa amb les eines de dalt o demana-ho a en Nio: «Flux:
|
|
1049
|
+
<div class="bd-empty" id="bdEmpty"><b>Pissarra en blanc</b><span>Dibuixa amb les eines de dalt o demana-ho a en Nio: «Flux: Idea -> Prototip -> Proves».</span></div>
|
|
1050
1050
|
|
|
1051
1051
|
<div class="bd-bar bd-tools" id="bdTools" role="toolbar" aria-label="Eines">
|
|
1052
1052
|
${TOOLS.map(t => `<button type="button" class="tb" data-tool="${t[0]}" aria-pressed="${t[0] === tool}" title="${t[1]} — ${t[2]}${t[3] ? ' o ' + t[3] : ''}">${svgI(t[4])}${t[3] ? `<small>${t[3]}</small>` : ''}</button>`).join('')}
|
package/web/index.html
CHANGED
|
@@ -3,23 +3,23 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<title>nio // agents
|
|
7
|
-
<meta name="description" content="
|
|
6
|
+
<title>nio // els teus agents, des del mòbil</title>
|
|
7
|
+
<meta name="description" content="Claude Code i Codex als teus ordinadors, controlats des del mòbil. Personal, gratuït i amb les teves claus.">
|
|
8
8
|
<link rel="canonical" href="https://www.nio.cat/">
|
|
9
9
|
<meta name="theme-color" content="#f0ede7">
|
|
10
10
|
<meta property="og:type" content="website">
|
|
11
11
|
<meta property="og:site_name" content="nio">
|
|
12
12
|
<meta property="og:locale" content="ca_ES">
|
|
13
13
|
<meta property="og:url" content="https://www.nio.cat/">
|
|
14
|
-
<meta property="og:title" content="nio // agents
|
|
15
|
-
<meta property="og:description" content="
|
|
14
|
+
<meta property="og:title" content="nio // els teus agents, des del mòbil">
|
|
15
|
+
<meta property="og:description" content="Claude Code i Codex als teus ordinadors, controlats des del mòbil. Personal, gratuït i amb les teves claus.">
|
|
16
16
|
<meta property="og:image" content="https://www.nio.cat/og.png">
|
|
17
17
|
<meta property="og:image:width" content="1200">
|
|
18
18
|
<meta property="og:image:height" content="630">
|
|
19
19
|
<meta property="og:image:alt" content="nio">
|
|
20
20
|
<meta name="twitter:card" content="summary_large_image">
|
|
21
|
-
<meta name="twitter:title" content="nio // agents
|
|
22
|
-
<meta name="twitter:description" content="
|
|
21
|
+
<meta name="twitter:title" content="nio // els teus agents, des del mòbil">
|
|
22
|
+
<meta name="twitter:description" content="Claude Code i Codex als teus ordinadors, controlats des del mòbil. Personal, gratuït i amb les teves claus.">
|
|
23
23
|
<meta name="twitter:image" content="https://www.nio.cat/og.png">
|
|
24
24
|
<meta name="format-detection" content="telephone=no">
|
|
25
25
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
@@ -64,6 +64,7 @@ section p { text-wrap: pretty; }
|
|
|
64
64
|
section p + p, section p + ul, section ul + p, section p + .cmd, section .cmd + p { margin-top: 1em; }
|
|
65
65
|
main a { text-decoration-thickness: 1px; text-underline-offset: 4px; text-decoration-color: var(--accent); }
|
|
66
66
|
main a:hover { color: var(--accent); }
|
|
67
|
+
main ul.list { padding-left: 1.2em; display: grid; gap: .5em; }
|
|
67
68
|
main code { font: 500 .88em ui-monospace, "SF Mono", Menlo, monospace; padding: 2px 7px; border-radius: 7px; background: #e6e1d8; }
|
|
68
69
|
main .cmd { display: flex; align-items: center; gap: 4px; padding: 6px 6px 6px 18px; border-radius: 12px; background: #e6e1d8; }
|
|
69
70
|
main .cmd code { flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap; padding: 8px 0; background: none; border-radius: 0; font-size: 16px; scrollbar-width: none; }
|
|
@@ -102,26 +103,43 @@ main strong { font-weight: 600; }
|
|
|
102
103
|
</header>
|
|
103
104
|
|
|
104
105
|
<main class="page">
|
|
105
|
-
<h1>Els agents
|
|
106
|
-
<p class="lead">
|
|
106
|
+
<h1>Els teus agents, a les teves màquines, des del mòbil.</h1>
|
|
107
|
+
<p class="lead">nio és un comandament per a Claude Code i Codex. Treballen als teus ordinadors (el portàtil, el mini PC de casa, un servidor), amb els teus comptes i les teves claus. Tu els dones feina des del mòbil, els segueixes en directe i aproves el que és arriscat. És personal i gratuït.</p>
|
|
107
108
|
|
|
108
109
|
<section aria-labelledby="com">
|
|
109
110
|
<h2 id="com">Com funciona</h2>
|
|
110
|
-
<p><strong>1.
|
|
111
|
-
<div class="cmd"><code id="cmd">
|
|
112
|
-
<p>
|
|
113
|
-
<p><strong>2.
|
|
114
|
-
<p><strong>3.
|
|
111
|
+
<p><strong>1. A cada ordinador</strong> (Linux o Mac), una sola ordre:</p>
|
|
112
|
+
<div class="cmd"><code id="cmd">curl -fsSL https://www.nio.cat/install.sh | sh</code><button class="copy" type="button" data-copy="curl -fsSL https://www.nio.cat/install.sh | sh" aria-label="Copia l’ordre"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg><svg class="ok" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></button></div>
|
|
113
|
+
<p>Instal·la nio, el deixa engegat i et mostra un codi QR. Si només el vols provar: <code>npx nio-cat</code>.</p>
|
|
114
|
+
<p><strong>2. Al mòbil</strong>, obre <a href="/app">nio.cat/app</a> i escaneja el QR. El codi serveix un sol cop i el mòbil en rep una clau pròpia, que pots revocar quan vulguis. Afegeix-lo a la pantalla d’inici i tindràs avisos.</p>
|
|
115
|
+
<p><strong>3. Dona-li feina.</strong> Tria l’ordinador, l’agent i la carpeta, i explica què vols. Si l’ordinador es reinicia, el mòbil el torna a trobar sol.</p>
|
|
115
116
|
</section>
|
|
116
117
|
|
|
117
|
-
<section aria-labelledby="
|
|
118
|
-
<h2 id="
|
|
119
|
-
<
|
|
118
|
+
<section aria-labelledby="que">
|
|
119
|
+
<h2 id="que">Què hi pots fer</h2>
|
|
120
|
+
<ul class="list">
|
|
121
|
+
<li><strong>Sessions</strong> amb Claude Code i Codex, en directe, a qualsevol dels teus ordinadors.</li>
|
|
122
|
+
<li><strong>Aprovacions</strong>: abans d’editar, executar o clicar res, te’n demana permís amb el motiu. Tot queda registrat.</li>
|
|
123
|
+
<li><strong>Feines</strong> que es fan soles: ara, més tard o cada dia, i encadenades («quan Claude Code acabi, que Codex ho revisi»).</li>
|
|
124
|
+
<li><strong>Memòria compartida</strong>: el que recorda un agent, ho sap l’altre. Fitxers markdown al teu ordinador.</li>
|
|
125
|
+
<li><strong>Telegram i veu</strong>: parla-hi amb el teu bot o amb notes de veu, i aprova amb un botó.</li>
|
|
126
|
+
<li><strong>Navegador i escriptori</strong>: els agents poden mirar webs i apps; per actuar, sempre et demanen permís amb una captura.</li>
|
|
127
|
+
</ul>
|
|
128
|
+
</section>
|
|
129
|
+
|
|
130
|
+
<section aria-labelledby="teu">
|
|
131
|
+
<h2 id="teu">Tot és teu</h2>
|
|
132
|
+
<p>Fas servir els teus agents i les teves claus (Anthropic, OpenAI, ElevenLabs, el bot de Telegram). Les converses, la memòria i les claus es queden als teus ordinadors. nio.cat només serveix l’app i guarda, signada, l’adreça on trobar cada ordinador. Més detalls a <a href="/privadesa">privadesa</a>.</p>
|
|
133
|
+
</section>
|
|
134
|
+
|
|
135
|
+
<section aria-labelledby="prova">
|
|
136
|
+
<h2 id="prova">Prova-ho</h2>
|
|
137
|
+
<p>Obre <a href="/app">el panell</a>. Si encara no tens cap ordinador connectat, t’ensenya com fer-ho pas a pas.</p>
|
|
120
138
|
</section>
|
|
121
139
|
</main>
|
|
122
140
|
|
|
123
141
|
<footer class="footer">
|
|
124
|
-
<div class="page">© 2026 nio // un projecte de <a href="https://f.cat">f.cat</a></div>
|
|
142
|
+
<div class="page">© 2026 nio // un projecte de <a href="https://f.cat">f.cat</a> · <a href="mailto:hola@nio.cat">hola@nio.cat</a> · <a href="/privadesa">Privadesa</a></div>
|
|
125
143
|
</footer>
|
|
126
144
|
<script src="/bot.js" defer></script>
|
|
127
145
|
<script>
|