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/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
- return `${head}<div class="stack-lg" style="max-width:640px">${avisos}${voicePanel()}</div>`;
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
- /* ---------------------------------------------------------------- veu (ElevenLabs) */
1756
- // La clau viu només en aquest navegador; les crides van directes a ElevenLabs, sense passar per nio.
1757
- const VOICE_DEFAULT = { key: '', voice: 'JBFqnCBsd6RMkjVDRZzb', model: 'eleven_v3' };
1758
- const voiceCfg = () => ({ ...VOICE_DEFAULT, ...LS.get('nio.voice', {}) });
1759
- const voiceOn = () => !!voiceCfg().key;
1760
- async function elevenlabs(path, init = {}) {
1761
- const res = await fetch(`https://api.elevenlabs.io${path}`, { ...init, headers: { 'xi-api-key': voiceCfg().key, ...(init.headers || {}) } });
1762
- if (res.ok) return res;
1763
- let why = ''; try { const j = await res.json(); why = j.detail?.message || (typeof j.detail === 'string' ? j.detail : ''); } catch { /* sense cos */ }
1764
- throw new Error(res.status === 401 ? 'La clau d’ElevenLabs no és vàlida.' : res.status === 429 ? 'ElevenLabs diu que has superat el límit. Prova-ho d’aquí una estona.' : `ElevenLabs: ${why || `error ${res.status}`}`);
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, 4500);
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 speak(btn) {
1770
- if (speech.btn === btn) return stopSpeech();
1771
- stopSpeech();
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
- const c = voiceCfg(), k = `${c.voice}|${c.model}|${text}`; let url = speech.cache.get(k);
1777
- if (!url) {
1778
- const res = await elevenlabs(`/v1/text-to-speech/${encodeURIComponent(c.voice)}?output_format=mp3_44100_64`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, model_id: c.model }) });
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.classList.remove('wait'); btn.classList.add('on');
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 fd = new FormData(); fd.append('model_id', 'scribe_v1'); fd.append('file', blob, 'veu.webm');
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
- return `<section class="panel"><header><h2>Veu</h2>${voiceOn() ? '<span class="chip ok"><i class="dot on"></i>ElevenLabs</span>' : ''}</header><div class="body stack"><p class="small">Dicta els missatges amb el micròfon i escolta les respostes de l’agent, amb ElevenLabs.</p><div class="row"><button class="btn ink" data-act="voice" type="button">${voiceOn() ? 'Canvia la configuració' : 'Configura la veu'}</button></div></div></section>`;
1811
- }
1812
- function voiceSheet() {
1813
- const c = voiceCfg();
1814
- openSheet(`<h2>Veu amb ElevenLabs</h2><p class="muted small">Dicta amb el micròfon i escolta les respostes. La clau es queda només en aquest navegador: les crides van directes a ElevenLabs, nio no les veu.</p>
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’API</span><input type="password" id="vKey" value="${esc(c.key)}" placeholder="sk_…" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
1817
- <label class="f"><span>Identificador de la veu</span><input type="text" id="vVoice" value="${esc(c.voice)}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
1818
- <label class="f"><span>Model</span><select id="vModel"><option value="eleven_v3" ${c.model === 'eleven_v3' ? 'selected' : ''}>v3 (inclou català)</option><option value="eleven_multilingual_v2" ${c.model === 'eleven_multilingual_v2' ? 'selected' : ''}>Multilingual v2</option></select></label>
1819
- <div class="row between sheet-foot"><button class="btn ghost" type="button" id="vOff" ${c.key ? '' : 'disabled'}>Treu la clau</button><button class="btn ink" type="submit">Desa</button></div>
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 save = (v) => { LS.set('nio.voice', v); closeSheet(); if (state.route.name === 'session') renderSession(); else render(); };
1822
- $('#voiceForm', sh).onsubmit = (e) => { e.preventDefault(); const key = $('#vKey', sh).value.trim(); if (!key) return toast('Enganxa la clau d’API.'); save({ key, voice: $('#vVoice', sh).value.trim() || VOICE_DEFAULT.voice, model: $('#vModel', sh).value }); toast('Veu activada'); };
1823
- $('#vOff', sh).onclick = () => { save({ ...c, key: '' }); toast('Clau eliminada'); };
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 de Nio, de la dada a l’acció.', edges: [['Banc i ERP', 'Lectura', ''], ['Lectura', 'Regles', ''], ['Regles', 'Supera el llindar?', ''], ['Supera el llindar?', 'Aprovació humana', 'sí'], ['Supera el llindar?', 'Acció automàtica', 'no'], ['Aprovació humana', 'Registre', ''], ['Acció automàtica', 'Registre', '']] },
744
- aprovacio: { say: 'Flux d’aprovació: el que supera el llindar passa per una persona.', edges: [['Inici', 'Tasca detectada', ''], ['Tasca detectada', 'Import > llindar?', ''], ['Import > llindar?', 'Demana aprovació', 'sí'], ['Import > llindar?', 'Executa', 'no'], ['Demana aprovació', 'Aprovada?', ''], ['Aprovada?', 'Executa', 'sí'], ['Aprovada?', 'Arxiva amb motiu', 'no'], ['Executa', 'Fi', ''], ['Arxiva amb motiu', 'Fi', '']] },
745
- conciliacio: { say: 'Conciliació bancària, pas a pas.', edges: [['Extracte bancari', 'Creuar amb factures', ''], ['Factures ERP', 'Creuar amb factures', ''], ['Creuar amb factures', 'Quadra?', ''], ['Quadra?', 'Marcar conciliat', 'sí'], ['Quadra?', 'Excepció per revisar', 'no'], ['Excepció per revisar', 'Aprovació', '']] }
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: Comanda -> Validació -> Preparació -> Enviament', 'Mapa mental de Nio: agents, connexions, aprovacions, registre', 'Organigrama: Direcció > Finances, Operacions; Finances > Comptabilitat', 'Plantilla d’aprovació', 'Ajuda'];
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: Direcció > Finances, Operacions</b>
754
- • <b>Afegeix un rombe “Quadra?”</b> · <b>Connecta Banc amb ERP</b>
755
- • <b>Pinta Banc de verd</b> · <b>Reanomena Banc a Caixa</b>
756
- • <b>Esborra ERP</b> · <b>Ordena-ho</b> · <b>Centra</b> · <b>Desfés</b>
757
- • <b>Plantilla agent</b>, <b>Plantilla d’aprovació</b>, <b>Plantilla conciliació</b>
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' : /concilia/.test(n) ? 'conciliacio' : 'agent';
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: Banc -> ERP -> Aprovació».</span></div>
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 al mòbil</title>
7
- <meta name="description" content="Dona feina als agents d’IA del teu ordinador des del mòbil, segueix-los en directe i aprova cada pas.">
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 al mòbil">
15
- <meta property="og:description" content="Dona feina als agents d’IA del teu ordinador des del mòbil, segueix-los en directe i aprova cada pas.">
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 al mòbil">
22
- <meta name="twitter:description" content="Dona feina als agents d’IA del teu ordinador des del mòbil, segueix-los en directe i aprova cada pas.">
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 d’IA del teu ordinador, al mòbil.</h1>
106
- <p class="lead">Dona feina a Claude Code, Codex, Grok, Antigravity o Ollama des del mòbil. Treballen al teu ordinador, amb els teus comptes. Tu els segueixes en directe i aproves el que facin. És gratuït.</p>
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. Al teu ordinador.</strong> Obre el terminal a la carpeta dels teus projectes i executa:</p>
111
- <div class="cmd"><code id="cmd">npx nio-cat</code><button class="copy" type="button" data-copy="npx nio-cat" 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>
112
- <p>S’obre nio.cat ja connectat. Només cal <a href="https://nodejs.org">Node.js</a>.</p>
113
- <p><strong>2. Amb un servidor (opcional).</strong> A nio.cat, <em>Ordinadors → Afegeix ordinador → Servidor (SSH)</em>, escrius <code>usuari@servidor</code> i nio hi engega Claude Code i la resta. No cal obrir ports.</p>
114
- <p><strong>3. Al mòbil.</strong> A l’ordinador, prem <em>Continua al mòbil</em>. Al mòbil, obre <a href="/app">nio.cat/app</a>, escaneja el QR o enganxa el codi, i ja ho controles. Des de qualsevol xarxa amb <code>cloudflared</code>; sense ell, per la mateixa wifi. Cap dada no passa per servidors nostres.</p>
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="limits">
118
- <h2 id="limits">Tu decideixes</h2>
119
- <p>Cada sessió té el seu nivell de confiança: <strong>només llegir</strong>, <strong>demana permís</strong> abans d’editar o executar, o <strong>via lliure</strong>. Quan l’agent et necessita, t’arriba un avís i ho resols amb un toc.</p>
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>