nio-cat 0.9.4 → 0.9.6

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 CHANGED
@@ -8,7 +8,7 @@ Els teus agents (Claude Code i Codex) als teus ordinadors, controlats des del m
8
8
  curl -fsSL https://www.nio.cat/install.sh | sh
9
9
  ```
10
10
 
11
- Instal·la nio i `cloudflared` al teu usuari, el deixa com a servei (systemd o launchd) i mostra un codi QR. Escaneja’l amb el mòbil a [nio.cat/app](https://www.nio.cat/app). Per provar-lo sense instal·lar res: `npx nio-cat`.
11
+ Instal·la nio i `cloudflared` al teu usuari, el deixa com a servei (systemd o launchd) i obre nio al navegador amb un codi QR. Escaneja’l amb el mòbil. Per provar-lo sense instal·lar res: `npx nio-cat`, o la demo de [nio.cat/app](https://www.nio.cat/app), amb un ordinador simulat.
12
12
 
13
13
  ## Què fa
14
14
 
@@ -16,7 +16,7 @@ Instal·la nio i `cloudflared` al teu usuari, el deixa com a servei (systemd o l
16
16
  - **Aprovacions** amb el motiu, al mòbil, al push o a Telegram, i **registre** de totes les accions (`~/.nio/audit.jsonl`).
17
17
  - **Feines**: cua, cadenes («quan acabi, que Codex ho revisi») i programació (cron).
18
18
  - **Memòria compartida** a `~/.nio/memory`, amb un servidor MCP (`nio mcp`) registrat a Claude Code i Codex.
19
- - **Telegram** (el teu bot) i **veu** (ElevenLabs o OpenAI), amb les claus a l’ordinador.
19
+ - **Telegram** (el teu bot) i **veu**: dictat gratis amb Whisper a l’ordinador (whisper.cpp + ffmpeg; l’àudio no en surt), o ElevenLabs i OpenAI amb les teves claus.
20
20
  - **Navegador i escriptori** amb agent-browser i agent-desktop: mirar passa sol, actuar sempre demana permís.
21
21
 
22
22
  ## Arquitectura
package/bin/nio.js CHANGED
@@ -22,7 +22,7 @@ const { Control } = require('../src/control');
22
22
  const { Remote } = require('../src/remote');
23
23
  const { Jobs } = require('../src/jobs');
24
24
  const { addServer } = require('../src/ssh');
25
- const { pairLink, terminalQr, writePairPage, makeCode, codeLink, terminalQrSize } = require('../src/pair');
25
+ const { pairLink, writePairPage, makeCode, codeLink } = require('../src/pair');
26
26
  const { which } = require('../src/proc');
27
27
 
28
28
  const argv = process.argv.slice(2);
@@ -111,17 +111,10 @@ async function showPair(publicUrl) {
111
111
  const { code: secret } = auth.createCode({ secret: true });
112
112
  const link = pairLink(app, publicUrl, secret), code = makeCode(publicUrl, secret), qrLink = codeLink(app, code);
113
113
  const file = writePairPage(link, code, qrLink);
114
- // El QR sempre al terminal, el més gran que hi càpiga: gran (2 caràcters per mòdul), compacte (mig bloc) i,
115
- // si ni així hi cap d'ample, cap QR i només el codi. Amb poques files, el marge passa de 2 a 1 mòdul.
116
- const rows = process.stdout.rows || 999, cols = process.stdout.columns || 999;
117
- const fits = (m, big) => { const z = terminalQrSize(qrLink, m, big); return z.rows + 4 <= rows && z.cols <= cols; };
118
- const opt = [[2, true], [2, false], [1, false]].find(([m, big]) => fits(m, big)) || (terminalQrSize(qrLink, 1).cols <= cols ? [1, false] : null);
114
+ // El QR no surt al terminal (no s'hi veu bé): és al panell que s'obre al navegador.
119
115
  console.log(`\n ${c.b('Connecta el mòbil')}\n`);
120
- if (opt) {
121
- console.log(` Escaneja aquest codi amb la càmera${panelOpened ? ' (també el tens al navegador)' : ''}:\n`);
122
- console.log(terminalQr(qrLink, { margin: opt[0], big: opt[1] }));
123
- } else console.log(` La finestra és massa estreta per al QR${panelOpened ? '; el tens al navegador' : '. Fes-la més ampla i torna-ho a provar: nio pair'}.`);
124
- console.log(`\n ${opt ? 'O copia' : 'Copia'} aquest codi i enganxa'l a Sessions → Tria un ordinador:\n`);
116
+ if (panelOpened) console.log(' He obert nio al navegador: escaneja el QR que hi surt amb la càmera del mòbil.\n O, al mòbil, obre nio.cat/app → Ordinadors → Afegeix i enganxa aquest codi:\n');
117
+ else console.log(' Al mòbil, obre nio.cat/app → Ordinadors → Afegeix i enganxa aquest codi:\n');
125
118
  console.log(` ${code}\n`);
126
119
  console.log(c.dim(' Serveix per a un sol dispositiu i caduca en 15 minuts. Per a un altre: nio pair.\n'));
127
120
  if (flags.open) {
@@ -414,7 +407,7 @@ async function start() {
414
407
  --quick Túnel ràpid encara que n'hi hagi un amb nom
415
408
  --local Obre la còpia del panell d'aquest ordinador (si el navegador bloqueja nio.cat, p. ex. Safari)
416
409
  --open Obre al navegador la pàgina del codi QR
417
- nio pair Mostra el codi QR una altra vegada
410
+ nio pair Obre el navegador amb el QR per connectar el mòbil (i mostra el codi)
418
411
 
419
412
  nio ssh usuari@servidor Afegeix un servidor (VPS) per SSH: hi engega nio amb un túnel i el pots fer servir des del mòbil.
420
413
  --port <n> Port de nio al servidor (7717)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "f.cat",
package/src/control.js CHANGED
@@ -9,7 +9,7 @@ const fs = require('fs');
9
9
  const os = require('os');
10
10
  const path = require('path');
11
11
  const crypto = require('crypto');
12
- const { execFile } = require('child_process');
12
+ const { spawn } = require('child_process');
13
13
  const { which, childEnv } = require('./proc');
14
14
  const config = require('./config');
15
15
  const { HOME } = config;
@@ -46,9 +46,32 @@ function classify(command) {
46
46
  return { tool, kind: KIND[tool], sub, act: !read };
47
47
  }
48
48
 
49
+ // Executa una eina amb un temps màxim que es compleix sempre. Al Mac, agent-browser és un llançador de Node que obre
50
+ // el binari com a fill: matar només el llançador deixava el fill amb la sortida oberta i la petició penjada.
51
+ // Per això: grup de processos propi, s'acaba quan surt el procés (no quan es tanquen les sortides) i, si passa
52
+ // el temps, es mata tot el grup.
49
53
  function run(bin, args, timeout = 15000) {
50
54
  return new Promise((resolve) => {
51
- execFile(bin, args, { timeout, env: childEnv(), maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => resolve({ ok: !err, out: String(stdout || ''), err: String(stderr || err?.message || '') }));
55
+ let out = '', err = '', done = false, child, drain;
56
+ const finish = (ok, why) => {
57
+ if (done) return;
58
+ done = true;
59
+ clearTimeout(timer); clearTimeout(drain);
60
+ child?.stdout?.destroy(); child?.stderr?.destroy();
61
+ resolve({ ok, out, err: err || why || '', timedOut: why === 'temps' });
62
+ };
63
+ const timer = setTimeout(() => {
64
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { /* ja no hi és */ } }
65
+ finish(false, 'temps');
66
+ }, timeout);
67
+ try {
68
+ child = spawn(bin, args, { env: childEnv(), stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' });
69
+ } catch (e) { return finish(false, e.message); }
70
+ child.stdout.on('data', (d) => { if (out.length < 8 << 20) out += d; });
71
+ child.stderr.on('data', (d) => { if (err.length < 1 << 20) err += d; });
72
+ child.on('error', (e) => finish(false, e.message));
73
+ child.on('close', (code) => finish(code === 0));
74
+ child.on('exit', (code) => { drain = setTimeout(() => finish(code === 0), 300); });
52
75
  });
53
76
  }
54
77
 
@@ -77,7 +100,9 @@ class Control {
77
100
  let j = null;
78
101
  try { j = JSON.parse(r.out); } catch { /* text */ }
79
102
  desk.ready = j ? j.ok !== false : r.ok;
80
- desk.why = desk.ready ? '' : (j?.error?.message || r.err || r.out).split('\n')[0].slice(0, 200);
103
+ desk.why = desk.ready ? '' : r.timedOut
104
+ ? (process.platform === 'darwin' ? 'agent-desktop no respon. Dona-li permís a Configuració del Sistema → Privadesa i seguretat → Accessibilitat i torna-ho a provar.' : 'agent-desktop no respon.')
105
+ : (j?.error?.message || r.err || r.out).split('\n')[0].slice(0, 200);
81
106
  }
82
107
  const shotTool = process.platform === 'darwin' ? which('screencapture') : which('grim') || which('scrot') || which('gnome-screenshot');
83
108
  this.capsCache = {
@@ -97,7 +122,7 @@ class Control {
97
122
  const parts = [];
98
123
  const tries = kind === 'navegador' ? [['skills', 'get', 'core'], ['skills', 'get', 'agent-browser']] : [['skills', 'get', 'desktop'], ['skills', 'get', 'platform']];
99
124
  for (const args of tries) {
100
- const r = await run(bin, args, 20000);
125
+ const r = await run(bin, args, 8000);
101
126
  if (r.ok && r.out.trim()) {
102
127
  parts.push(r.out.trim().replace(/^---[\s\S]*?\n---\s*/, ''));
103
128
  if (kind === 'navegador') break;
@@ -214,7 +239,8 @@ class Control {
214
239
  this.audit.add({ type: 'control', what: `${kind === 'navegador' ? 'Control del navegador' : 'Control de l’escriptori'} ${body[kind] ? 'activat' : 'desactivat'}`, by: device?.name });
215
240
  }
216
241
  config.save(this.cfg);
217
- return { ...(await this.caps(true)), notes };
242
+ for (const kind of ['navegador', 'escriptori']) caps[kind].enabled = this.enabled(kind);
243
+ return { ...caps, notes };
218
244
  },
219
245
  },
220
246
  { method: 'POST', path: '/api/screen', handler: ({ body }) => this.screenshot(body?.from) },
package/src/pair.js CHANGED
@@ -25,7 +25,7 @@ function makeCode(url, token) {
25
25
  else bytes = Buffer.concat([Buffer.from([0]), Buffer.from(`${url}\n${token}`)]);
26
26
  return `nio-${bytes.toString('base64url')}`;
27
27
  }
28
- // Enllaç curt per al QR: una adreça de l'app amb el codi al fragment. Un QR més petit cap millor al terminal.
28
+ // Enllaç curt per al QR: una adreça de l'app amb el codi al fragment. Com més curt, més fàcil d'escanejar.
29
29
  const codeLink = (app, code) => `${app.replace(/\/$/, '')}/app#${code}`;
30
30
 
31
31
  function qr(text) {
@@ -35,47 +35,6 @@ function qr(text) {
35
35
  return q;
36
36
  }
37
37
 
38
- // QR per al terminal, compacte: cada caràcter «▀» pinta dos mòduls (dalt amb el color de la lletra, baix amb el del fons).
39
- // Ocupa la meitat d'alt i d'ample que amb dos espais per mòdul. Sempre negre sobre blanc, amb tema clar o fosc.
40
- function terminalQr(text, { color = !process.env.NO_COLOR, margin = 2, big = false } = {}) {
41
- if (big) return terminalQrBig(text, { color, margin });
42
- const q = qr(text);
43
- const n = q.getModuleCount();
44
- const M = margin;
45
- const dark = (r, c) => r >= 0 && c >= 0 && r < n && c < n && q.isDark(r, c);
46
- const lines = [];
47
- for (let r = -M; r < n + M; r += 2) {
48
- let line = '';
49
- for (let c = -M; c < n + M; c++) {
50
- const t = dark(r, c), b = r + 1 < n + M ? dark(r + 1, c) : false;
51
- if (color) line += `\x1b[${t ? 30 : 97};${b ? 40 : 107}m▀`;
52
- else line += t && b ? ' ' : t ? '▄' : b ? '▀' : '█';
53
- }
54
- lines.push(` ${line}${color ? '\x1b[0m' : ''}`);
55
- }
56
- return lines.join('\n');
57
- }
58
-
59
- // QR gran: dos espais amb color de fons per mòdul. Quadrat i molt fàcil d'escanejar, però ocupa el doble.
60
- function terminalQrBig(text, { color, margin: M }) {
61
- const q = qr(text), n = q.getModuleCount();
62
- const dark = (r, c) => r >= 0 && c >= 0 && r < n && c < n && q.isDark(r, c);
63
- const lines = [];
64
- for (let r = -M; r < n + M; r++) {
65
- let line = '', prev = null;
66
- for (let c = -M; c < n + M; c++) {
67
- const d = dark(r, c);
68
- if (color) { if (d !== prev) line += d ? '\x1b[40m' : '\x1b[107m'; line += ' '; }
69
- else line += d ? ' ' : '██';
70
- prev = d;
71
- }
72
- lines.push(` ${line}${color ? '\x1b[0m' : ''}`);
73
- }
74
- return lines.join('\n');
75
- }
76
-
77
- // SVG propi: una sola forma amb tires horitzontals de mòduls, vora de 4 mòduls i vores nítides.
78
- // (El de la llibreria dibuixa cada mòdul per separat i, en escalar-lo, deixa línies fines entre mòduls.)
79
38
  function qrSvg(text) {
80
39
  const q = qr(text), n = q.getModuleCount(), M = 4;
81
40
  let d = '';
@@ -102,17 +61,11 @@ main{width:min(420px,100% - 40px);padding:40px 0}h1{font-size:30px;font-weight:5
102
61
  .qr{background:#fffefd;border:1px solid #cec7bd;padding:18px;border-radius:16px;margin:24px 0}.qr svg{width:100%;height:auto;display:block}
103
62
  code{display:block;word-break:break-all;font:12px/1.5 ui-monospace,Menlo,monospace;background:#e6e1d8;padding:12px 14px;border-radius:12px;color:#272224}small{display:block;margin-top:20px;font-size:13px;color:#69615c}</style></head>
104
63
  <body><main><h1>Connecta el mòbil</h1><p>Escaneja el codi amb la càmera. S’obrirà nio ja connectat a aquest ordinador.</p><div class="qr">${svg}</div>
105
- <p>O copia aquest codi i enganxa'l a <b>Sessions → Tria un ordinador</b>:</p><code>${code}</code><small>Aquest codi dona accés als teus agents. Guarda'l com una contrasenya.</small></main></body></html>`;
64
+ <p>O copia aquest codi i enganxa'l a <b>nio.cat/app → Ordinadors → Afegeix</b>:</p><code>${code}</code><small>Aquest codi dona accés als teus agents. Guarda'l com una contrasenya.</small></main></body></html>`;
106
65
  const file = path.join(HOME, 'pair.html');
107
66
  fs.writeFileSync(file, html, { mode: 0o600 });
108
67
  return file;
109
68
  }
110
69
 
111
- module.exports = { pairLink, terminalQr, writePairPage, qrSvg, makeCode, codeLink };
70
+ module.exports = { pairLink, writePairPage, qrSvg, makeCode, codeLink };
112
71
 
113
- // Mida del QR al terminal: files i columnes que ocupa amb un marge donat.
114
- function terminalQrSize(text, margin = 2, big = false) {
115
- const n = qr(text).getModuleCount() + margin * 2;
116
- return big ? { rows: n, cols: n * 2 + 2 } : { rows: Math.ceil(n / 2), cols: n + 2 };
117
- }
118
- module.exports.terminalQrSize = terminalQrSize;
package/src/voice.js CHANGED
@@ -2,7 +2,9 @@
2
2
  // Veu: transcripció (STT) i lectura en veu alta (TTS) amb les claus de l'usuari.
3
3
  // Les claus viuen en aquest ordinador (~/.nio/config.json → voice) i mai no tornen a l'app.
4
4
  // L'àudio fa: mòbil → aquest ordinador → proveïdor. Proveïdors intercanviables: ElevenLabs i OpenAI.
5
+ // Per entendre't també hi ha Whisper, gratis i sense clau, que funciona a l'ordinador mateix (whisper.js).
5
6
  const config = require('./config');
7
+ const whisper = require('./whisper');
6
8
 
7
9
  const httpErr = (status, message) => Object.assign(new Error(message), { status });
8
10
  const MAX_AUDIO = 25 * 1024 * 1024;
@@ -67,11 +69,14 @@ async function errText(r) {
67
69
  }
68
70
 
69
71
  const cfgOf = (cfg) => cfg.voice || {};
70
- // Proveïdor per a cada cosa: el triat, si en té la clau; si no, qualsevol que en tingui.
72
+ // Proveïdor per a cada cosa: el triat, si en té la clau (o Whisper, si és a punt); si no, Whisper
73
+ // (gratis) per entendre't i, si tampoc, qualsevol que tingui clau.
71
74
  function pick(cfg, which) {
72
75
  const v = cfgOf(cfg), keys = v.keys || {};
73
76
  const want = v[which] || v.provider;
77
+ if (which === 'stt' && want === 'whisper' && whisper.ready(cfg)) return 'whisper';
74
78
  if (want && keys[want]) return want;
79
+ if (which === 'stt' && whisper.ready(cfg)) return 'whisper';
75
80
  return Object.keys(PROVIDERS).find((p) => keys[p]) || null;
76
81
  }
77
82
 
@@ -84,13 +89,15 @@ function status(cfg) {
84
89
  speak: !!v.speak,
85
90
  lang: v.lang || 'ca',
86
91
  voice: v.voice || {},
92
+ whisper: whisper.status(cfg),
87
93
  };
88
94
  }
89
95
 
90
96
  async function transcribe(cfg, audio, mime) {
91
97
  const p = pick(cfg, 'stt');
92
- if (!p) throw httpErr(400, 'Aquest ordinador no té cap clau de veu. Posa-la a Ajustos → Veu.');
98
+ if (!p) throw httpErr(400, 'Aquest ordinador encara no té veu. Activa Whisper (gratis) o posa una clau a Ajustos → Veu.');
93
99
  if (!audio?.length) throw httpErr(400, 'No hi ha àudio');
100
+ if (p === 'whisper') return { text: await whisper.transcribe(cfg, audio, mime, cfgOf(cfg).lang || 'ca'), provider: p };
94
101
  return { text: (await PROVIDERS[p].stt(cfg.voice.keys[p], audio, mime, cfgOf(cfg).lang || 'ca')).trim(), provider: p };
95
102
  }
96
103
 
@@ -132,7 +139,8 @@ function routes(cfg) {
132
139
  v.keys[id] = k.trim();
133
140
  }
134
141
  }
135
- for (const w of ['stt', 'tts']) if (body[w] === null || PROVIDERS[body[w]]) v[w] = body[w] || undefined;
142
+ for (const w of ['stt', 'tts']) if (body[w] === null || PROVIDERS[body[w]] || (w === 'stt' && body[w] === 'whisper')) v[w] = body[w] || undefined;
143
+ if (body.whisper && typeof body.whisper === 'object' && whisper.MODELS[body.whisper.model]) v.whisper = { ...v.whisper, model: body.whisper.model };
136
144
  if (typeof body.speak === 'boolean') v.speak = body.speak;
137
145
  if (typeof body.lang === 'string' && /^[a-z]{2}$/.test(body.lang)) v.lang = body.lang;
138
146
  if (body.voice && typeof body.voice === 'object') {
@@ -143,6 +151,16 @@ function routes(cfg) {
143
151
  return status(cfg);
144
152
  },
145
153
  },
154
+ {
155
+ // Whisper: «install» (Homebrew + model) o «download» (només el model).
156
+ method: 'POST',
157
+ path: '/api/voice/whisper',
158
+ handler: ({ body }) => {
159
+ if (body.action === 'install') whisper.install(cfg);
160
+ else whisper.download(cfg);
161
+ return status(cfg);
162
+ },
163
+ },
146
164
  {
147
165
  method: 'POST',
148
166
  path: '/api/voice/stt',
package/src/whisper.js ADDED
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+ // Whisper a l'ordinador: transcripció gratuïta i sense clau. L'àudio no surt d'aquest ordinador.
3
+ // Fa servir whisper.cpp (whisper-cli) amb ffmpeg per convertir l'àudio del navegador, o el
4
+ // «whisper» de Python (openai-whisper) si és el que hi ha. El model de whisper.cpp es baixa una
5
+ // sola vegada a ~/.nio/whisper.
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const { execFile, spawn } = require('child_process');
10
+ const { HOME } = require('./config');
11
+ const { which, childEnv } = require('./proc');
12
+
13
+ const DIR = path.join(HOME, 'whisper');
14
+ const HF = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main';
15
+ // Als Mac amb xip d'Apple, el turbo (quantitzat) va ràpid i entén molt bé el català. A la resta, el small.
16
+ const MODELS = {
17
+ 'large-v3-turbo-q5_0': { file: 'ggml-large-v3-turbo-q5_0.bin', size: 574e6, label: 'Turbo (millor)' },
18
+ 'small-q5_1': { file: 'ggml-small-q5_1.bin', size: 190e6, label: 'Small (més lleuger)' },
19
+ };
20
+ const defaultModel = () => (process.platform === 'darwin' && process.arch === 'arm64' ? 'large-v3-turbo-q5_0' : 'small-q5_1');
21
+
22
+ const job = { download: null, install: null, error: null }; // feines en curs (una de cada)
23
+
24
+ const httpErr = (status, message) => Object.assign(new Error(message), { status });
25
+ const cppBin = () => which('whisper-cli') || which('whisper-cpp');
26
+ const pyBin = () => which('whisper');
27
+ const modelId = (cfg) => (MODELS[cfg.voice?.whisper?.model] ? cfg.voice.whisper.model : defaultModel());
28
+ const modelPath = (cfg) => path.join(DIR, MODELS[modelId(cfg)].file);
29
+
30
+ function engine() {
31
+ if (cppBin() && which('ffmpeg')) return 'cpp';
32
+ if (pyBin() && which('ffmpeg')) return 'python';
33
+ return null;
34
+ }
35
+
36
+ function ready(cfg) {
37
+ const e = engine();
38
+ return e === 'python' || (e === 'cpp' && fs.existsSync(modelPath(cfg)));
39
+ }
40
+
41
+ function status(cfg) {
42
+ const e = engine(), missing = [];
43
+ if (!cppBin() && !pyBin()) missing.push('whisper-cpp');
44
+ if (!which('ffmpeg')) missing.push('ffmpeg');
45
+ return {
46
+ name: 'Whisper',
47
+ engine: e,
48
+ ready: ready(cfg),
49
+ missing,
50
+ model: modelId(cfg),
51
+ models: Object.fromEntries(Object.entries(MODELS).map(([id, m]) => [id, { label: m.label, size: m.size, have: fs.existsSync(path.join(DIR, m.file)) }])),
52
+ needsModel: e === 'cpp' && !fs.existsSync(modelPath(cfg)),
53
+ downloading: job.download ? { model: job.download.model, pct: job.download.pct } : null,
54
+ installing: !!job.install,
55
+ canInstall: process.platform === 'darwin' && !!which('brew'),
56
+ install: installHint(),
57
+ error: job.error,
58
+ };
59
+ }
60
+
61
+ function installHint() {
62
+ if (process.platform === 'darwin') return 'brew install whisper-cpp ffmpeg';
63
+ if (which('apt-get')) return 'sudo apt install ffmpeg pipx && pipx install openai-whisper';
64
+ return 'Instal·la whisper.cpp (whisper-cli) i ffmpeg';
65
+ }
66
+
67
+ // Baixa el model (una sola vegada). Torna de seguida; el progrés surt a status().
68
+ function download(cfg) {
69
+ const id = modelId(cfg), m = MODELS[id], dest = path.join(DIR, m.file);
70
+ if (fs.existsSync(dest) || job.download) return;
71
+ fs.mkdirSync(DIR, { recursive: true });
72
+ const tmp = `${dest}.part`, d = (job.download = { model: id, pct: 0 });
73
+ job.error = null;
74
+ (async () => {
75
+ const res = await fetch(`${HF}/${m.file}`);
76
+ if (!res.ok || !res.body) throw new Error(`No he pogut baixar el model (${res.status})`);
77
+ const total = Number(res.headers.get('content-length')) || m.size;
78
+ const out = fs.createWriteStream(tmp);
79
+ let got = 0;
80
+ for await (const chunk of res.body) {
81
+ got += chunk.length;
82
+ d.pct = Math.min(99, Math.round((got / total) * 100));
83
+ if (!out.write(chunk)) await new Promise((r) => out.once('drain', r));
84
+ }
85
+ await new Promise((r, j) => out.end((e) => (e ? j(e) : r())));
86
+ fs.renameSync(tmp, dest);
87
+ })()
88
+ .catch((e) => { job.error = e.message; fs.rm(tmp, { force: true }, () => {}); })
89
+ .finally(() => { job.download = null; });
90
+ }
91
+
92
+ // Al Mac amb Homebrew, nio ho pot instal·lar sol. Després baixa el model.
93
+ function install(cfg) {
94
+ if (job.install) return;
95
+ if (!(process.platform === 'darwin' && which('brew'))) throw httpErr(400, `Instal·la-ho al terminal de l’ordinador: ${installHint()}`);
96
+ job.error = null;
97
+ const want = ['whisper-cpp', 'ffmpeg'].filter((p) => (p === 'ffmpeg' ? !which('ffmpeg') : !cppBin()));
98
+ if (!want.length) return download(cfg);
99
+ const p = (job.install = spawn(which('brew'), ['install', ...want], { env: childEnv({ HOMEBREW_NO_AUTO_UPDATE: '1' }), stdio: ['ignore', 'ignore', 'pipe'] }));
100
+ let errOut = '';
101
+ p.stderr.on('data', (c) => { errOut = (errOut + c).slice(-400); });
102
+ p.on('error', (e) => { job.error = e.message; job.install = null; });
103
+ p.on('close', (code) => {
104
+ job.install = null;
105
+ if (code) job.error = `brew install ha fallat: ${errOut.trim().split('\n').pop() || code}`;
106
+ else download(cfg);
107
+ });
108
+ }
109
+
110
+ function run(bin, args, timeout = 180000) {
111
+ return new Promise((resolve, reject) => {
112
+ execFile(bin, args, { timeout, env: childEnv(), maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
113
+ if (err) reject(httpErr(502, `Whisper: ${String(stderr || err.message).trim().split('\n').pop().slice(0, 200)}`));
114
+ else resolve(String(stdout));
115
+ });
116
+ });
117
+ }
118
+
119
+ function ext(mime) {
120
+ return /ogg/.test(mime || '') ? 'ogg' : /mp4|m4a|aac/.test(mime || '') ? 'm4a' : /mpeg|mp3/.test(mime || '') ? 'mp3' : /wav/.test(mime || '') ? 'wav' : 'webm';
121
+ }
122
+
123
+ async function transcribe(cfg, audio, mime, lang = 'ca') {
124
+ const e = engine();
125
+ if (!e) throw httpErr(400, `Falta Whisper en aquest ordinador. Instal·la’l: ${installHint()}`);
126
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nio-veu-'));
127
+ try {
128
+ const input = path.join(tmp, `audio.${ext(mime)}`);
129
+ fs.writeFileSync(input, audio);
130
+ if (e === 'python') {
131
+ await run(pyBin(), [input, '--model', 'turbo', '--language', lang, '--output_format', 'txt', '--output_dir', tmp, '--fp16', 'False', '--verbose', 'False']);
132
+ return fs.readFileSync(path.join(tmp, 'audio.txt'), 'utf8').trim();
133
+ }
134
+ if (!fs.existsSync(modelPath(cfg))) {
135
+ download(cfg);
136
+ throw httpErr(409, 'Baixant el model de Whisper (només el primer cop). Torna-ho a provar d’aquí a uns minuts.');
137
+ }
138
+ const wav = path.join(tmp, 'audio.wav');
139
+ await run(which('ffmpeg'), ['-nostdin', '-loglevel', 'error', '-i', input, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', wav], 60000);
140
+ const threads = String(Math.max(1, Math.min(8, os.cpus().length - 1)));
141
+ const out = await run(cppBin(), ['-m', modelPath(cfg), '-f', wav, '-l', lang, '-t', threads, '-nt', '-np']);
142
+ return out.split('\n').map((l) => l.trim()).filter((l) => l && !/^\[(BLANK_AUDIO|Música|Music)\]$/i.test(l)).join(' ').trim();
143
+ } finally {
144
+ fs.rm(tmp, { recursive: true, force: true }, () => {});
145
+ }
146
+ }
147
+
148
+ module.exports = { status, ready, download, install, transcribe, MODELS, DIR };
package/web/app/app.js CHANGED
@@ -141,7 +141,9 @@ async function api(path, opts = {}) {
141
141
  const m = machine();
142
142
  if (!m) throw new Error('No hi ha cap ordinador connectat');
143
143
  const body = opts.body && typeof opts.body !== 'string' ? opts.body : opts.body ? JSON.parse(opts.body) : undefined;
144
- const res = await fetch(m.url + path, { ...opts, headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json', ...(opts.headers || {}) }, body: body ? JSON.stringify(body) : undefined });
144
+ let res;
145
+ try { res = await fetch(m.url + path, { ...opts, headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json', ...(opts.headers || {}) }, body: body ? JSON.stringify(body) : undefined }); }
146
+ catch (e) { if (e.name === 'AbortError') throw e; throw new Error(`No he pogut parlar amb ${m.name || 'l’ordinador'}. Comprova que nio hi estigui engegat i torna-ho a provar.`); }
145
147
  const data = await res.json().catch(() => ({}));
146
148
  if (!res.ok) throw new Error(data.error || `Error ${res.status}`);
147
149
  if (Array.isArray(data.agents)) data.agents = data.agents.filter((x) => x.id !== 'demo'); // l'agent de proves del programa no s'ensenya
@@ -371,8 +373,9 @@ const pairFrom = (text) => parsePair(text) || parseCode(text);
371
373
  // Cap pantalla ensenya adreces: cada ordinador és un nom, una icona i un estat.
372
374
  const hostOf = (u) => { try { return new URL(u).hostname; } catch { return ''; } };
373
375
  const isLoop = (u) => ['127.0.0.1', 'localhost', '[::1]'].includes(hostOf(u));
376
+ const isDemo = (m = machine()) => !!window.NioDemo?.isDemo(m);
374
377
  const machineIcon = (m) => (m.kind === 'vps' ? ICON.server : ICON.machine);
375
- const machineSub = (m) => (m.kind === 'vps' ? `Servidor${m.via ? ` · ${m.via}` : ''}` : isLoop(m.url) ? 'Aquest ordinador' : 'Ordinador');
378
+ const machineSub = (m) => (isDemo(m) ? 'Simulat dins del navegador' : m.kind === 'vps' ? `Servidor${m.via ? ` · ${m.via}` : ''}` : isLoop(m.url) ? 'Aquest ordinador' : 'Ordinador');
376
379
  function addMachine({ url, token, name, kind, via }) {
377
380
  const ms = store.machines; let i = ms.findIndex((m) => m.url === url);
378
381
  if (i >= 0) { ms[i].token = token; if (kind) ms[i].kind = kind; if (via) ms[i].via = via; }
@@ -381,6 +384,16 @@ function addMachine({ url, token, name, kind, via }) {
381
384
  state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.info = null; state.ollama = null;
382
385
  connect();
383
386
  }
387
+ // Demo: un ordinador simulat (app/demo.js) per provar-ho tot sense instal·lar res.
388
+ function startDemo() {
389
+ closeSheet();
390
+ addMachine({ url: window.NioDemo.url, token: 'demo', name: 'Ordinador de demo', kind: 'demo' });
391
+ go('#/sessions');
392
+ }
393
+ function exitDemo() {
394
+ const i = store.machines.findIndex((m) => isDemo(m));
395
+ if (i >= 0) forgetMachine(i);
396
+ }
384
397
  // El codi o l'enllaç porta un secret d'un sol ús: el canviem per una clau pròpia d'aquest dispositiu,
385
398
  // que es pot revocar des d'Ordinadors. Si és la clau principal (el panell que obre nio) o un nio antic
386
399
  // que encara no fa aparellament, el valor ja és una clau i el fem servir tal qual.
@@ -468,7 +481,7 @@ function route() {
468
481
 
469
482
  /* ---------------------------------------------------------------- pintat general */
470
483
  function renderChrome() {
471
- renderPanic();
484
+ renderPanic(); renderDemoBar();
472
485
  const r = state.route, n = state.approvals.size, active = r.name === 'session' ? 'sessions' : r.name;
473
486
  let prev = '';
474
487
  $('#nav').innerHTML = Object.entries(VIEWS).map(([k, v], i) => {
@@ -548,7 +561,7 @@ function computerCard(m, i) {
548
561
  : pr == null ? '' : pr ? '<span class="chip ok"><i class="dot on"></i>En línia</span>' : '<span class="chip"><i class="dot"></i>Fora de línia</span>';
549
562
  return `<div class="card computer ${on ? 'active' : ''}">
550
563
  <div class="row" style="flex-wrap:nowrap;gap:12px;align-items:flex-start"><span class="c-ico">${svg(machineIcon(m))}</span><div class="grow"><div class="row between"><b>${esc(m.name)}</b>${st}</div><div class="muted small">${esc(machineSub(m))}</div></div></div>
551
- <div class="row c-actions">${on ? `<button class="btn ink sm" data-act="open-sessions" type="button">Sessions</button>${isPhone() ? '' : `<button class="btn sm" data-act="mobile-qr" type="button">Continua al mòbil</button>`}` : `<button class="btn ink sm" data-machine="${i}" type="button">Obre</button>`}${m.kind === 'vps' && m.via && (!on || !state.connected) ? `<button class="btn sm" data-reconnect="${i}" type="button">Torna a connectar</button>` : ''}<button class="btn ghost sm" data-rename="${i}" type="button">Canvia el nom</button><button class="btn ghost sm" data-forget="${i}" type="button">Oblida</button></div>
564
+ <div class="row c-actions">${on ? `<button class="btn ink sm" data-act="open-sessions" type="button">Sessions</button>${isPhone() || isDemo(m) ? '' : `<button class="btn sm" data-act="mobile-qr" type="button">Continua al mòbil</button>`}` : `<button class="btn ink sm" data-machine="${i}" type="button">Obre</button>`}${m.kind === 'vps' && m.via && (!on || !state.connected) ? `<button class="btn sm" data-reconnect="${i}" type="button">Torna a connectar</button>` : ''}${isDemo(m) ? `<button class="btn ghost sm" data-act="demo-exit" type="button">Surt de la demo</button>` : `<button class="btn ghost sm" data-rename="${i}" type="button">Canvia el nom</button><button class="btn ghost sm" data-forget="${i}" type="button">Oblida</button>`}</div>
552
565
  </div>`;
553
566
  }
554
567
  // Ajuda sempre a mà: com es fa cada cosa, i què mirar si no connecta.
@@ -583,7 +596,7 @@ function helpPanel(open) {
583
596
  ])}<p>Exemple: ${k('/mode p')} passa la sessió a «Demana permís».</p>`)}
584
597
  ${q('Git i GitHub', `<p>Dins una sessió, ${k('/git')} (o Opcions → Git i GitHub) mostra els canvis, fa <b>commit</b> (en una branca nova si ets a ${k('main')}) i crea un <b>PR</b>. Cap clau no passa per nio: fa servir el ${k('git')} i el ${k('gh')} de l’ordinador.</p>`)}
585
598
  ${q('Avisos al mòbil', `<p>A <b>Ajustos → Avisos</b>, prem <b>Activa els avisos</b>. Rebràs una notificació quan un agent et demana permís o acaba la feina. L’avís arriba buit; el detall el demana el mòbil directament a l’ordinador.</p><p>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>`)}
586
- ${q('Veu', `<p>A <b>Ajustos → Veu</b> pots posar la teva clau d’ElevenLabs per <b>dictar</b> els missatges amb el micròfon i <b>escoltar</b> les respostes. La clau es queda només en aquest navegador.</p>`)}
599
+ ${q('Veu', `<p>A <b>Ajustos → Veu</b> actives <b>Whisper</b> per <b>dictar</b> els missatges amb el micròfon, gratis i sense que l’àudio surti de l’ordinador. Per <b>escoltar</b> les respostes, hi pots posar una clau d’ElevenLabs o OpenAI, que es queda a l’ordinador.</p>`)}
587
600
 
588
601
  ${grp('Eines')}
589
602
  ${q('Pantalla remota', `<p>Mira i controla l’escriptori d’un ordinador des del mòbil. A <b>Ordinadors</b>, baixa fins a la secció <b>Pantalla</b> i prem <b>Obre la pantalla</b>. S’obre a pantalla completa. Cal activar abans <b>Apps de l’escriptori</b> a la mateixa secció.</p><ul><li><b>Mirar</b>: només veus la pantalla, en directe. Amb un dit la mous quan has fet zoom.</li><li><b>Controlar</b>: toc = clic; doble toc = doble clic; mantenir premut = clic dret; dos dits = desplaçar o fer zoom. El botó <b>1 dit</b> canvia entre desplaçar i arrossegar, i el botó <b>2 dits</b> entre desplaçar l’ordinador i fer zoom amb pessics. <b>Teclat</b> obre el teclat del mòbil, i dalt hi tens ${k('⌘')} ${k('⌥')} ${k('⌃')} ${k('⇧')}, ${k('Esc')}, ${k('Tab')} i fletxes.</li><li><b>Zoom amb dos dits</b>: pessiga per apropar i arrossega amb dos dits per moure’t. Al mode Mirar sempre va així, amb doble toc per alternar entre enquadrar i apropar. Al mode Controlar, tria al botó <b>2 dits</b> si els dos dits desplacen l’ordinador o fan zoom. A l’ordinador, pessiga al trackpad o ${k('Ctrl')} + roda. Els botons <b>− 100% +</b> i <b>1:1</b> fan el mateix i funcionen en tots dos modes.</li><li><b>Desa captura</b>: baixa el fotograma actual com a imatge. <b>Captura</b> (a la secció) en fa una de sola, sense obrir la pantalla.</li><li><b>Si surt negra</b>: al Mac, dona el permís de <b>Gravació de pantalla</b> (i <b>Accessibilitat</b> per controlar) al Terminal on corre <span class="mono">nio-cat</span> i reinicia’l. A Linux, per controlar cal <span class="mono">xdotool</span>.</li></ul>`)}
@@ -644,7 +657,7 @@ function viewComputers() {
644
657
  const steps = isPhone()
645
658
  ? [['Engega nio a l’ordinador', 'Al terminal: <span class="mono">npx nio-cat</span>.'], ['Escaneja el QR', 'O escriu el codi <span class="mono">nio-…</span> que surt al terminal.'], ['Dona feina a un agent', 'Crea una sessió i segueix-la en directe.']]
646
659
  : [['Engega nio', 'Al terminal: <span class="mono">npx nio-cat</span>. S’obre aquesta web ja connectada.'], ['O afegeix un servidor', 'Amb SSH: <span class="mono">usuari@servidor</span>. nio hi fa la resta.'], ['Continua al mòbil', 'Amb un QR o un codi, quan vulguis.']];
647
- return `${head}<div class="panel empty">${bot('idle')}<h2>Connecta el teu primer ordinador</h2><ol class="stepper">${steps.map(([t, d], i) => `<li><span class="n">${i + 1}</span><div><b>${t}</b><p>${d}</p></div></li>`).join('')}</ol><button class="btn ink" data-act="add-computer" type="button">${svg(ICON.plus)}Afegeix ordinador</button></div>`;
660
+ return `${head}<div class="panel empty">${bot('idle')}<h2>Connecta el teu primer ordinador</h2><ol class="stepper">${steps.map(([t, d], i) => `<li><span class="n">${i + 1}</span><div><b>${t}</b><p>${d}</p></div></li>`).join('')}</ol><div class="row" style="justify-content:center"><button class="btn ink" data-act="add-computer" type="button">${svg(ICON.plus)}Afegeix ordinador</button><button class="btn" data-act="demo" type="button">Prova una demo</button></div><p class="muted small">La demo fa servir un ordinador simulat, aquí mateix. No cal instal·lar res.</p></div>`;
648
661
  }
649
662
  const detail = m && state.connected ? `<h2 class="sec">${esc(m.name)}</h2>
650
663
  <div class="grid2 one">
@@ -719,7 +732,7 @@ function addComputerSheet(tab, opts = {}) {
719
732
  const saved = d && store.machines.findIndex((x) => x.id === d.id);
720
733
  if (d && saved >= 0) set('saved', `${intro('Aquest ordinador ja és a la llista', 'El tens connectat com a «' + esc(d.name) + '».')}<div class="row"><button class="btn ink" type="button" data-open-saved="${saved}">Obre sessions</button></div>`);
721
734
  else if (d) set('found', `${intro('nio funciona en aquest ordinador', 'Aquest navegador encara no hi té accés. Torna a engegar nio i obrirà el panell ja connectat.')}${cmdBox('npx nio-cat')}`);
722
- else set('none', `${intro('Engega nio en aquest ordinador')}${todo(['Obre un terminal a la carpeta dels teus projectes.', `Executa:${cmdBox('npx nio-cat')}`, 'S’obrirà aquest panell ja connectat.'])}${live('Esperant que nio s’engegui…')}<p class="hint">Cal Node.js 18 o més nou.</p>`);
735
+ else set('none', `${intro('Engega nio en aquest ordinador')}${todo(['Obre un terminal a la carpeta dels teus projectes.', `Executa:${cmdBox('npx nio-cat')}`, 'S’obrirà aquest panell ja connectat.'])}${live('Esperant que nio s’engegui…')}<p class="hint">Cal Node.js 18 o més nou.</p>${store.machines.length ? '' : '<div class="or"><span>o</span></div><button class="btn block" type="button" data-close data-act="demo">Prova una demo, sense instal·lar res</button>'}`);
723
736
  if (saved < 0 || !d) poll = setTimeout(local, 3000);
724
737
  };
725
738
  const runSsh = async (target) => {
@@ -1600,7 +1613,7 @@ function modeSheet(s) {
1600
1613
  const isLocalMachine = () => { const m = machine(); try { return !!m && ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(m.url).hostname); } catch { return false; } };
1601
1614
  function mobileHint() {
1602
1615
  const m = machine(), key = `nio.mobHint.${m?.id || m?.url}`;
1603
- if (!m || isPhone() || !state.connected || LS.get(key, false)) return '';
1616
+ if (!m || isDemo(m) || isPhone() || !state.connected || LS.get(key, false)) return '';
1604
1617
  return `<div class="panel hint"><div class="row between" style="flex-wrap:wrap;gap:12px"><div><b>Vols fer-lo servir al mòbil?</b><p class="muted small" style="margin-top:2px">Escaneja un codi i continues des del telèfon, amb les mateixes sessions.</p></div><div class="row"><button class="btn ghost" type="button" data-act="mobile-hint-off">Ara no</button><button class="btn ink" type="button" data-act="mobile-qr">Mostra el QR</button></div></div></div>`;
1605
1618
  }
1606
1619
  let qrTimer = 0;
@@ -1923,6 +1936,12 @@ async function sendLocal() {
1923
1936
  let screen = null; // { caps, last, actions }
1924
1937
  const controlOn = () => !!(state.info?.control?.navegador?.enabled || state.info?.control?.escriptori?.enabled || screen?.caps?.navegador?.enabled || screen?.caps?.escriptori?.enabled);
1925
1938
  // Aturada d'emergència: sempre a la vista mentre el control estigui actiu.
1939
+ function renderDemoBar() {
1940
+ let bar = $('#demoBar');
1941
+ if (!isDemo()) return bar?.remove();
1942
+ if (bar) return;
1943
+ $('#main').insertAdjacentHTML('afterbegin', `<div class="demo-bar" id="demoBar" role="status"><span><b>Demo</b> · un ordinador simulat, res no surt del navegador.</span><span class="row"><button class="btn sm ink" type="button" data-act="add-computer">Connecta el teu</button><button class="btn sm ghost" type="button" data-act="demo-exit">Surt</button></span></div>`);
1944
+ }
1926
1945
  function renderPanic() {
1927
1946
  syncScreenBtn();
1928
1947
  $('#panicBtn')?.remove();
@@ -2330,6 +2349,7 @@ async function speak(btn) {
2330
2349
  function autoSpeak(ev) { if (nodeVoice?.speak && nodeVoice?.tts && !document.hidden && ev?.text) { stopSpeech(); playText(ev.text); } }
2331
2350
  const mic = { rec: null, chunks: [], stream: null };
2332
2351
  async function toggleMic() {
2352
+ if (isDemo()) return toast('A la demo no hi ha micròfon. Amb nio instal·lat, dictes amb Whisper, gratis.', 4500);
2333
2353
  if (!voiceOn()) return voiceSheet();
2334
2354
  if (mic.rec) return mic.rec.stop();
2335
2355
  if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Aquest navegador no pot gravar àudio.');
@@ -2353,18 +2373,20 @@ async function toggleMic() {
2353
2373
  function voicePanel() {
2354
2374
  if (!machine()) return '';
2355
2375
  const v = nodeVoice, P = v?.providers || {};
2356
- const on = Object.entries(P).filter(([, p]) => p.key).map(([, p]) => p.name);
2357
- 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>`;
2376
+ const on = [...(v?.whisper?.ready ? ['Whisper'] : []), ...Object.entries(P).filter(([, p]) => p.key).map(([, p]) => p.name)];
2377
+ 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. Amb <b>Whisper</b> és gratis i l’àudio no surt de l’ordinador (${esc(machine().name)}). Si hi poses una clau d’ElevenLabs o OpenAI, també es queda a l’ordinador.</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>`;
2358
2378
  }
2359
2379
  async function voiceSheet() {
2360
2380
  await loadVoice();
2361
2381
  if (!nodeVoice) return toast('Aquest ordinador encara no té la veu. Actualitza nio (npx nio-cat@latest).', 5000);
2382
+ if (isDemo()) return toast('A la demo no hi ha veu. Amb nio instal·lat, dictes amb Whisper, gratis.', 4500);
2362
2383
  const v = nodeVoice, old = LS.get('nio.voice', {});
2363
- 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>
2384
+ openSheet(`<h2>Veu</h2><p class="muted small">Per dictar n’hi ha prou amb Whisper, gratis i a l’ordinador «${esc(machine().name)}». Les claus són opcionals: serveixen per escoltar les respostes en veu alta. Es desen a l’ordinador i no tornen mai al mòbil.</p>
2385
+ <div id="whisperBox" style="margin-top:14px">${whisperCard(v.whisper)}</div>
2364
2386
  <form class="stack" id="voiceForm" style="margin-top:14px">
2365
2387
  <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>
2366
2388
  <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>
2367
- <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>
2389
+ <div class="grid-2"><label class="f"><span>Per entendre’t</span><select id="vStt"><option value="">Automàtic</option>${v.whisper ? `<option value="whisper" ${v.stt === 'whisper' ? 'selected' : ''}>Whisper (gratis)</option>` : ''}<option value="elevenlabs" ${v.stt === 'elevenlabs' ? 'selected' : ''}>ElevenLabs Scribe</option><option value="openai" ${v.stt === 'openai' ? 'selected' : ''}>OpenAI</option></select></label>
2368
2390
  <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>
2369
2391
  <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>
2370
2392
  ${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>' : ''}
@@ -2373,8 +2395,38 @@ async function voiceSheet() {
2373
2395
  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); } };
2374
2396
  $('#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'); };
2375
2397
  $('#vOff', sh).onclick = () => { if (confirm('Es treuran les claus de veu d’aquest ordinador.')) put({ keys: { elevenlabs: null, openai: null } }, 'Claus eliminades'); };
2398
+ // Whisper: instal·lar, baixar el model i seguir-ne el progrés mentre la fulla és oberta.
2399
+ const wbox = $('#whisperBox', sh);
2400
+ const draw = (w) => { if (w) { nodeVoice.whisper = w; wbox.innerHTML = whisperCard(w); } };
2401
+ const poll = async () => {
2402
+ if (!wbox.isConnected) return;
2403
+ try { const r = await api('/api/voice'); nodeVoice = r; draw(r.whisper); if (r.whisper?.downloading || r.whisper?.installing) setTimeout(poll, 1500); } catch { /* ho tornarem a provar en obrir */ }
2404
+ };
2405
+ wbox.onclick = async (e) => {
2406
+ const b = e.target.closest('[data-whisper]'); if (!b) return;
2407
+ b.disabled = true;
2408
+ try { const r = await api('/api/voice/whisper', { method: 'POST', body: { action: b.dataset.whisper } }); nodeVoice = r; draw(r.whisper); setTimeout(poll, 1000); } catch (err) { toast(err.message, 5000); b.disabled = false; }
2409
+ };
2410
+ wbox.onchange = async (e) => {
2411
+ if (e.target.id !== 'wModel') return;
2412
+ try { const r = await api('/api/voice', { method: 'PUT', body: { whisper: { model: e.target.value } } }); nodeVoice = r; draw(r.whisper); } catch (err) { toast(err.message); }
2413
+ };
2414
+ if (v.whisper?.downloading || v.whisper?.installing) setTimeout(poll, 1000);
2376
2415
  });
2377
2416
  }
2417
+ function whisperCard(w) {
2418
+ if (!w) return '<div class="card"><b>Whisper</b><div class="muted small">Actualitza nio a l’ordinador per dictar gratis amb Whisper (npx nio-cat@latest).</div></div>';
2419
+ const mb = (n) => `${Math.round(n / 1e6)} MB`, m = w.models?.[w.model], live = (t) => `<p class="live"><i class="pulse"></i>${t}</p>`;
2420
+ const models = `<label class="f" style="margin-top:10px"><span>Model</span><select id="wModel">${Object.entries(w.models || {}).map(([id, x]) => `<option value="${esc(id)}" ${id === w.model ? 'selected' : ''}>${esc(x.label)} · ${mb(x.size)}${x.have ? ' · baixat' : ''}</option>`).join('')}</select></label>`;
2421
+ let body;
2422
+ if (w.installing) body = live('Instal·lant whisper-cpp i ffmpeg amb Homebrew… pot trigar uns minuts.');
2423
+ else if (w.downloading) body = `${live(`Baixant el model… ${w.downloading.pct}%`)}<div class="progress"><i style="width:${w.downloading.pct}%"></i></div>`;
2424
+ else if (w.ready) body = `<p class="small">A punt. Prem el micròfon i parla: l’àudio no surt de l’ordinador.</p>${w.engine === 'cpp' ? models : ''}`;
2425
+ else if (w.missing?.length) body = w.canInstall ? `<p class="small">Falta ${esc(w.missing.join(' i '))}. nio ho pot instal·lar amb Homebrew.</p><div class="row"><button class="btn ink" type="button" data-whisper="install">Instal·la Whisper</button></div>` : `<p class="small">Instal·la-ho al terminal de l’ordinador i torna a obrir aquesta fulla:</p>${cmdBox(w.install)}`;
2426
+ else if (w.needsModel) body = `<p class="small">Només falta baixar el model, una sola vegada.</p>${models}<div class="row" style="margin-top:10px"><button class="btn ink" type="button" data-whisper="download">Baixa el model${m ? ` (${mb(m.size)})` : ''}</button></div>`;
2427
+ else body = '';
2428
+ return `<div class="card"><div class="row between"><b>Whisper · gratis</b>${w.ready ? '<span class="chip ok"><i class="dot on"></i>Actiu</span>' : ''}</div>${body}${w.error ? `<p class="small" style="color:var(--red)">${esc(w.error)}</p>` : ''}</div>`;
2429
+ }
2378
2430
 
2379
2431
  /* ---------------------------------------------------------------- accions */
2380
2432
  document.addEventListener('click', (e) => {
@@ -2442,6 +2494,8 @@ document.addEventListener('click', async (e) => {
2442
2494
  case 'activity-refresh': activity = null; return loadActivity();
2443
2495
  case 'push-test': try { const r = await api('/api/push/test', { method: 'POST' }); toast(r.subs ? 'Enviat. Arribarà en uns segons.' : 'Primer activa els avisos'); } catch (err) { toast(err.message); } return;
2444
2496
  case 'add-computer': return addComputerSheet();
2497
+ case 'demo': return startDemo();
2498
+ case 'demo-exit': return exitDemo();
2445
2499
  case 'open-sessions': return go('#/sessions');
2446
2500
  case 'help': return go('#/ajuda');
2447
2501
  case 'more': return moreSheet();
@@ -2476,7 +2530,7 @@ function commands() {
2476
2530
  if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
2477
2531
  c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
2478
2532
  [...state.sessions.values()].slice(0, 20).forEach((s) => c.push({ label: s.title || 'Sessió nova', hint: AGENT_NAMES[s.agent] || s.agent, run: () => go(`#/s/${s.id}`) }));
2479
- c.push({ label: 'Configura la veu (ElevenLabs)', hint: 'Ajustos', run: voiceSheet });
2533
+ c.push({ label: 'Configura la veu (Whisper, gratis)', hint: 'Ajustos', run: voiceSheet });
2480
2534
  c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
2481
2535
  return c;
2482
2536
  }
@@ -0,0 +1,221 @@
1
+ // nio · demo: un ordinador simulat dins del navegador, per provar l'app sense instal·lar res.
2
+ // Respon les mateixes adreces que el programa (/api/…) a les adreces «demo:…», amb sessions,
3
+ // text en directe, eines i aprovacions de mentida. No surt res del navegador i es reinicia en recarregar.
4
+ (() => {
5
+ const ROOT = '/Users/tu/projectes';
6
+ const AGENTS = [
7
+ { id: 'claude', name: 'Claude Code', beta: false, approvals: true, noModes: false, login: '', available: true, version: 'simulat' },
8
+ { id: 'codex', name: 'Codex', beta: false, approvals: true, noModes: false, login: '', available: true, version: 'simulat' },
9
+ ];
10
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11
+ const rid = () => Math.random().toString(36).slice(2, 12);
12
+ const err = (status, error) => Object.assign(new Error(error), { status });
13
+ const NOPE = 'Això no es pot fer a la demo. Instal·la nio a l’ordinador per fer-ho de debò: npx nio-cat';
14
+
15
+ const sessions = new Map(), approvals = new Map(), streams = new Set(), runs = new Map(), audit = [];
16
+ const out = (msg) => { const data = new TextEncoder().encode(`data: ${JSON.stringify(msg)}\n\n`); for (const c of streams) try { c.enqueue(data); } catch { streams.delete(c); } };
17
+
18
+ function summary(s) {
19
+ const last = [...s.events].reverse().find((e) => e.kind === 'text' || e.kind === 'user');
20
+ return { id: s.id, agent: s.agent, title: s.title, cwd: s.cwd, mode: s.mode, model: '', state: s.state, created: s.created, updated: s.updated, cost: s.cost || 0, last: last ? last.text.slice(0, 160) : '', error: '', pending: [...approvals.values()].filter((a) => a.session === s.id && a.state === 'pending').length };
21
+ }
22
+ const list = () => [...sessions.values()].sort((a, b) => b.updated - a.updated).map(summary);
23
+ const pending = () => [...approvals.values()].filter((a) => a.state === 'pending').map(({ resolve, ...a }) => a);
24
+ const touch = (s) => { s.updated = Date.now(); out({ type: 'session', session: summary(s) }); };
25
+ function push(s, kind, data = {}) {
26
+ const last = s.events[s.events.length - 1];
27
+ if (last?.open) { delete last.open; out({ type: 'event', session: s.id, event: last }); }
28
+ const ev = { seq: s.events.length + 1, t: Date.now(), kind, ...data };
29
+ s.events.push(ev); out({ type: 'event', session: s.id, event: ev }); touch(s);
30
+ return ev;
31
+ }
32
+ async function stream(s, text, run) {
33
+ const ev = push(s, 'text', { text: '', open: true });
34
+ for (const w of text.split(/(?<= )/)) {
35
+ if (run.stopped) return;
36
+ ev.text += w; out({ type: 'delta', session: s.id, seq: ev.seq, text: w });
37
+ await sleep(35 + Math.random() * 45);
38
+ }
39
+ delete ev.open; out({ type: 'event', session: s.id, event: ev }); touch(s);
40
+ }
41
+ function ask(s, tool, input) {
42
+ const a = { id: rid(), session: s.id, agent: s.agent, title: s.title, tool, input, reason: input.description || '', state: 'pending', created: Date.now() };
43
+ approvals.set(a.id, a); s.state = 'waiting';
44
+ push(s, 'approval', { approval: a.id, tool, input, reason: a.reason, state: 'pending' });
45
+ out({ type: 'approval', approval: pending().find((x) => x.id === a.id) });
46
+ return new Promise((r) => { a.resolve = r; });
47
+ }
48
+ function decide(id, decision) {
49
+ const a = approvals.get(id);
50
+ if (!a || a.state !== 'pending') return null;
51
+ a.state = decision === 'allow' ? 'allowed' : 'denied'; a.decided = Date.now(); a.by = 'Aquest navegador';
52
+ audit.unshift({ t: Date.now(), type: 'approval', session: a.session, agent: a.agent, title: a.title, tool: a.tool, what: a.input.command || a.input.file_path || a.tool, why: a.reason, decision: a.state, by: a.by, asked: a.created });
53
+ const s = sessions.get(a.session);
54
+ if (s) {
55
+ const ev = s.events.find((e) => e.kind === 'approval' && e.approval === id);
56
+ if (ev) { ev.state = a.state; out({ type: 'event', session: s.id, event: ev }); }
57
+ if (s.state === 'waiting') s.state = runs.has(s.id) ? 'running' : 'idle';
58
+ touch(s);
59
+ }
60
+ const { resolve, ...pub } = a;
61
+ out({ type: 'approval', approval: pub });
62
+ resolve?.(a.state === 'allowed' ? 'allow' : 'deny');
63
+ return pub;
64
+ }
65
+
66
+ // Què fa l'agent simulat segons el que li demanes. Sempre: llegeix, proposa un canvi, el demana i prova.
67
+ function plan(text, s) {
68
+ const t = text.toLowerCase(), dir = s.cwd.split('/').pop();
69
+ if (/test|prova/.test(t)) return { intro: `Miro quins tests hi ha a ${dir} i quins fallen.`, read: 'package.json', readOut: '{\n "scripts": { "test": "node --test" }\n}', edit: { file_path: `${s.cwd}/test/usuaris.test.js`, old_string: '', new_string: "test('crea un usuari', async () => {\n const u = await crear({ nom: 'Anna' });\n assert.equal(u.nom, 'Anna');\n});", description: 'Afegeixo un test per crear usuaris' }, bash: 'npm test', bashOut: '✓ 14 tests passen (0,8 s)', end: 'Fet. He afegit el test que faltava i **tots els tests passen**.' };
70
+ if (/fosc|dark|color|disseny|css|estil/.test(t)) return { intro: 'Faig una ullada als estils per afegir-hi el que em demanes.', read: 'src/styles.css', readOut: ':root {\n --bg: #fff;\n --text: #1a1a1a;\n}', edit: { file_path: `${s.cwd}/src/styles.css`, old_string: ':root {\n --bg: #fff;', new_string: ':root {\n --bg: #fff;\n}\n@media (prefers-color-scheme: dark) {\n :root { --bg: #141414; --text: #eee; }', description: 'Afegeixo els colors del mode fosc' }, bash: 'npm run build', bashOut: '✓ build fet en 1,9 s', end: 'Llest. El mode fosc s’activa sol quan el sistema el fa servir. Vols que hi afegeixi un botó per canviar-lo a mà?' };
71
+ if (/error|bug|falla|arregla|peta/.test(t)) return { intro: 'Busco d’on ve l’error.', read: 'src/server.js', readOut: "app.get('/usuaris/:id', (req, res) => {\n res.json(db.usuaris[req.params.id].nom);\n});", edit: { file_path: `${s.cwd}/src/server.js`, old_string: 'res.json(db.usuaris[req.params.id].nom);', new_string: "const u = db.usuaris[req.params.id];\n if (!u) return res.status(404).json({ error: 'No existeix' });\n res.json(u.nom);", description: 'Retorno un 404 quan l’usuari no existeix' }, bash: 'npm test', bashOut: '✓ 12 tests passen', end: 'Trobat: quan l’usuari no existia, el servidor petava. Ara respon un **404** i els tests passen.' };
72
+ return { intro: `Entesos. Començo per entendre com està organitzat ${dir}.`, read: 'README.md', readOut: `# ${dir}\nProjecte d’exemple de la demo de nio.`, edit: { file_path: `${s.cwd}/NOTES.md`, old_string: '', new_string: `## Pendent\n- ${text.slice(0, 80)}`, description: 'Apunto la tasca a les notes del projecte' }, bash: 'git status --short', bashOut: ' M NOTES.md', end: 'Fet. A la versió de debò, aquí l’agent treballaria als fitxers del teu ordinador i et demanaria permís abans de cada canvi.' };
73
+ }
74
+ async function work(s, text) {
75
+ const run = { stopped: false }; runs.set(s.id, run);
76
+ const p = plan(text, s), tool = (name, input, output, error) => { const id = rid(); push(s, 'tool', { id, name, input }); return { id, done: () => push(s, 'tool_result', { id, name, output, error: !!error }) }; };
77
+ const allowed = async (name, input) => s.mode === 'tot' || (await ask(s, name, input)) === 'allow';
78
+ try {
79
+ await sleep(500); await stream(s, p.intro, run); if (run.stopped) return;
80
+ const r = tool('Read', { file_path: p.read }, p.readOut); await sleep(700); r.done(); if (run.stopped) return;
81
+ if (s.mode === 'llegir') { await stream(s, 'Treballo en mode només lectura, així que no toco res. Et proposo aquest canvi:\n\n```\n' + p.edit.new_string + '\n```', run); return; }
82
+ await sleep(400);
83
+ // Un fitxer nou és «Write»; un canvi a un fitxer que ja hi és, «Edit».
84
+ const [eName, eInput] = p.edit.old_string ? ['Edit', p.edit] : ['Write', { file_path: p.edit.file_path, content: p.edit.new_string, description: p.edit.description }];
85
+ if (!(await allowed(eName, eInput))) { if (!run.stopped) await stream(s, 'D’acord, no hi toco. Què vols que faci?', run); return; }
86
+ if (run.stopped) return;
87
+ const e = tool(eName, eInput, eName === 'Write' ? 'Fitxer creat' : 'Fitxer actualitzat'); await sleep(500); e.done();
88
+ if (!(await allowed('Bash', { command: p.bash, description: 'Comprovo que tot funciona' }))) { if (!run.stopped) await stream(s, 'Entesos, no ho executo. El canvi ja és fet.', run); return; }
89
+ if (run.stopped) return;
90
+ const b = tool('Bash', { command: p.bash }, p.bashOut); await sleep(1100); b.done();
91
+ await stream(s, p.end, run);
92
+ const cost = 0.02 + Math.random() * 0.08; s.cost = (s.cost || 0) + cost; push(s, 'result', { cost, ms: 6000, turns: 3 });
93
+ } finally {
94
+ runs.delete(s.id);
95
+ for (const a of approvals.values()) if (a.session === s.id && a.state === 'pending') decide(a.id, 'deny');
96
+ s.state = 'idle'; push(s, 'status', { state: 'idle', code: run.stopped ? 130 : 0 });
97
+ }
98
+ }
99
+ function create({ agent, cwd, title, mode }) {
100
+ if (!AGENTS.some((a) => a.id === agent)) throw err(400, `A la demo només hi ha ${AGENTS.map((a) => a.name).join(' i ')}`);
101
+ const s = { id: rid(), agent, cwd: cwd || `${ROOT}/web-botiga`, title: title || '', mode: ['llegir', 'preguntar', 'tot'].includes(mode) ? mode : 'preguntar', state: 'idle', created: Date.now(), updated: Date.now(), cost: 0, events: [] };
102
+ sessions.set(s.id, s); touch(s); return s;
103
+ }
104
+ function send(s, text) {
105
+ if (runs.has(s.id)) throw err(409, 'L’agent encara està treballant. Espera que acabi o atura’l.');
106
+ text = String(text || '').trim(); if (!text) throw err(400, 'El missatge és buit');
107
+ if (!s.title) s.title = text.replace(/\s+/g, ' ').slice(0, 60);
108
+ push(s, 'user', { text }); s.state = 'running'; push(s, 'status', { state: 'running' });
109
+ work(s, text);
110
+ return summary(s);
111
+ }
112
+
113
+ // Sessions d'exemple: una d'acabada, una que espera permís i una d'antiga.
114
+ function seed() {
115
+ const ago = (min) => Date.now() - min * 60000;
116
+ const mk = (agent, dir, title, min, evs, extra = {}) => {
117
+ const s = { id: rid(), agent, cwd: `${ROOT}/${dir}`, title, mode: 'preguntar', state: 'idle', created: ago(min + 5), updated: ago(min), cost: extra.cost || 0, events: evs.map((e, i) => ({ seq: i + 1, t: ago(min + (evs.length - i) / 4), ...e })) };
118
+ sessions.set(s.id, s); return s;
119
+ };
120
+ mk('claude', 'web-botiga', 'Afegeix el mode fosc a la botiga', 12, [
121
+ { kind: 'user', text: 'Afegeix el mode fosc a la botiga' }, { kind: 'status', state: 'running' },
122
+ { kind: 'text', text: 'Miro com estan definits els colors.' },
123
+ { kind: 'tool', id: 'a1', name: 'Read', input: { file_path: 'src/styles.css' } }, { kind: 'tool_result', id: 'a1', name: 'Read', output: ':root {\n --bg: #fff;\n --text: #1a1a1a;\n}', error: false },
124
+ { kind: 'tool', id: 'a2', name: 'Edit', input: { file_path: `${ROOT}/web-botiga/src/styles.css`, old_string: '--bg: #fff;', new_string: '--bg: #fff;\n}\n@media (prefers-color-scheme: dark) {\n :root { --bg: #141414; }' } }, { kind: 'tool_result', id: 'a2', name: 'Edit', output: 'Fitxer actualitzat', error: false },
125
+ { kind: 'tool', id: 'a3', name: 'Bash', input: { command: 'npm run build' } }, { kind: 'tool_result', id: 'a3', name: 'Bash', output: '✓ build fet en 1,9 s', error: false },
126
+ { kind: 'text', text: 'Fet. El **mode fosc** s’activa sol quan el sistema el fa servir.' }, { kind: 'result', cost: 0.07, ms: 41000, turns: 4 }, { kind: 'status', state: 'idle', code: 0 },
127
+ ], { cost: 0.07 });
128
+ const w = mk('codex', 'api', 'Prepara la migració de la base de dades', 2, [
129
+ { kind: 'user', text: 'Prepara la migració de la base de dades' }, { kind: 'status', state: 'running' },
130
+ { kind: 'text', text: 'He escrit la migració que afegeix la columna «telefon» a usuaris. Ara l’he d’executar.' },
131
+ ]);
132
+ const a = { id: rid(), session: w.id, agent: 'codex', title: w.title, tool: 'Bash', input: { command: 'npm run migrate', description: 'Executo la migració a la base de dades local' }, reason: 'Executo la migració a la base de dades local', state: 'pending', created: ago(1) };
133
+ approvals.set(a.id, a); w.state = 'waiting'; runs.set(w.id, { stopped: false }); w.events.push({ seq: w.events.length + 1, t: ago(1), kind: 'approval', approval: a.id, tool: a.tool, input: a.input, reason: a.reason, state: 'pending' });
134
+ // Quan la decideixis, la sessió continua.
135
+ a.resolve = async (d) => {
136
+ const run = runs.get(w.id) || { stopped: false }; runs.set(w.id, run); w.state = 'running'; touch(w);
137
+ if (run.stopped) { runs.delete(w.id); w.state = 'idle'; push(w, 'status', { state: 'idle', code: 130 }); return; }
138
+ if (d === 'allow') { push(w, 'tool', { id: 'm1', name: 'Bash', input: { command: 'npm run migrate' } }); await sleep(900); push(w, 'tool_result', { id: 'm1', name: 'Bash', output: '✓ 1 migració aplicada', error: false }); await stream(w, 'Migració aplicada. La taula d’usuaris ja té la columna «telefon».', run); }
139
+ else await stream(w, 'D’acord, no l’executo. La tens a migrations/004_telefon.sql per quan vulguis.', run);
140
+ runs.delete(w.id); w.state = 'idle'; push(w, 'status', { state: 'idle', code: 0 });
141
+ };
142
+ mk('claude', 'api', 'Escriu tests per a /usuaris', 60 * 26, [
143
+ { kind: 'user', text: 'Escriu tests per a /usuaris' }, { kind: 'text', text: 'He afegit 6 tests a test/usuaris.test.js. Tots passen.' }, { kind: 'result', cost: 0.11, ms: 52000, turns: 5 },
144
+ ], { cost: 0.11 });
145
+ }
146
+ seed();
147
+
148
+ const MEMORY = {
149
+ 'preferencies.md': '# Preferències\n\n- Respon en català.\n- Fes commits petits i explica’ls bé.\n',
150
+ 'projectes/web-botiga.md': '# web-botiga\n\nBotiga en línia feta amb Astro. Els colors són a src/styles.css.\n',
151
+ };
152
+ const DIRS = { [ROOT]: ['web-botiga', 'api', 'notes'], [`${ROOT}/api`]: ['src', 'test'], [`${ROOT}/web-botiga`]: ['public', 'src'] };
153
+
154
+ async function route(method, p, q, body) {
155
+ let m;
156
+ if (method === 'GET') {
157
+ if (p === '/api/info') return { name: 'nio', version: 'demo', host: 'Ordinador de demo', platform: 'darwin', user: 'tu', publicUrl: null, agents: AGENTS, roots: [ROOT], vapidKey: null, id: 'demo', device: { id: 'demo', name: 'Aquest navegador', primary: true }, demo: true, control: null };
158
+ if (p === '/api/sessions') return list();
159
+ if (p === '/api/approvals') return pending();
160
+ if ((m = p.match(/^\/api\/sessions\/([\w-]+)$/))) { const s = sessions.get(m[1]); if (!s) throw err(404, 'Aquesta sessió no existeix'); const since = Number(q.get('since') || 0); return { session: summary(s), events: s.events.filter((e) => e.seq > since) }; }
161
+ if (/^\/api\/sessions\/[\w-]+\/commands$/.test(p)) return [];
162
+ if (/^\/api\/sessions\/[\w-]+\/git$/.test(p)) return { repo: false };
163
+ if (p === '/api/projects') return [{ path: ROOT, name: 'projectes', root: true, git: false }, ...DIRS[ROOT].map((n) => ({ path: `${ROOT}/${n}`, name: n, root: false, git: n !== 'notes' }))];
164
+ if (p === '/api/fs') { const d = (q.get('path') || ROOT).replace(/\/+$/, '') || ROOT; return { path: d, parent: d === '/Users/tu' ? null : d.replace(/\/[^/]+$/, '') || null, home: '/Users/tu', git: false, dirs: (DIRS[d] || []).map((n) => ({ name: n, path: `${d}/${n}`, hidden: false })) }; }
165
+ if (p === '/api/memory') return { dir: '~/.nio/memory', files: Object.entries(MEMORY).map(([path, c]) => ({ path, size: c.length, updated: Date.now() - 86400000 })) };
166
+ if ((m = p.match(/^\/api\/memory\/f\/(.+)$/))) { const path = decodeURIComponent(m[1]); if (!(path in MEMORY)) throw err(404, 'Aquest fitxer no existeix'); return { path, content: MEMORY[path], updated: Date.now() - 86400000 }; }
167
+ if (p === '/api/memory/search') { const t = (q.get('q') || '').toLowerCase(), r = []; for (const [path, c] of Object.entries(MEMORY)) c.split('\n').forEach((l, i) => l.toLowerCase().includes(t) && t && r.push({ path, line: i + 1, text: l })); return r; }
168
+ if (p === '/api/jobs') return { jobs: [], routing: [], max: 2 };
169
+ if (p === '/api/audit') return audit.slice(0, 300);
170
+ if (p === '/api/devices') return [{ id: 'demo', name: 'Aquest navegador', primary: true, created: null, lastSeen: Date.now(), current: true }];
171
+ if (p === '/api/voice') return { providers: {}, stt: null, tts: null, speak: false, lang: 'ca', voice: {}, demo: true };
172
+ if (p === '/api/control') return { navegador: { installed: false, ready: false, enabled: false }, escriptori: { installed: false, ready: false, why: 'A la demo no hi ha escriptori', enabled: false, screenshot: false }, platform: 'darwin' };
173
+ if (p === '/api/screen') return { last: null, enabled: false };
174
+ if (p === '/api/desktop/status') return { w: 0, h: 0, screen: null, accessibility: null, platform: 'darwin', input: false, capture: false, enabled: false };
175
+ if (p === '/api/telegram') return { configured: false, bot: null, running: false, allowed: [], pending: [], agent: null, cwd: ROOT, mode: 'preguntar', notify: true, voice: false };
176
+ if (p === '/api/ollama') return { available: false, host: '', models: [], running: [], pulling: [] };
177
+ if (p === '/api/ollama/models' || p === '/api/notices' || p === '/api/ssh/hosts') return [];
178
+ if (p === '/api/github') return { available: false, authed: false };
179
+ }
180
+ if (method === 'POST') {
181
+ if (p === '/api/sessions') { const s = create(body); if (body.text) send(s, body.text); return summary(s); }
182
+ if ((m = p.match(/^\/api\/sessions\/([\w-]+)\/message$/))) { const s = sessions.get(m[1]); if (!s) throw err(404, 'Aquesta sessió no existeix'); return send(s, body.text); }
183
+ if ((m = p.match(/^\/api\/sessions\/([\w-]+)\/stop$/))) { const r = runs.get(m[1]), s = sessions.get(m[1]); if (!r) return { stopped: false }; r.stopped = true; for (const a of approvals.values()) if (a.session === m[1] && a.state === 'pending') decide(a.id, 'deny'); if (s) push(s, 'info', { text: 'Has aturat l’agent' }); return { stopped: true }; }
184
+ if ((m = p.match(/^\/api\/approvals\/([\w-]+)$/))) { const a = decide(m[1], body.decision === 'allow' ? 'allow' : 'deny'); if (!a) throw err(404, 'Aquesta petició ja s’ha resolt'); return a; }
185
+ if (p === '/api/voice/stt') throw err(400, 'A la demo no hi ha micròfon. Amb nio instal·lat, dictes amb Whisper, gratis, a l’ordinador.');
186
+ }
187
+ if ((m = p.match(/^\/api\/sessions\/([\w-]+)$/))) {
188
+ const s = sessions.get(m[1]); if (!s) throw err(404, 'Aquesta sessió no existeix');
189
+ if (method === 'PATCH') { if (['llegir', 'preguntar', 'tot'].includes(body.mode)) s.mode = body.mode; if (typeof body.title === 'string') s.title = body.title.slice(0, 120); touch(s); return summary(s); }
190
+ if (method === 'DELETE') { runs.get(s.id) && (runs.get(s.id).stopped = true); sessions.delete(s.id); out({ type: 'removed', session: s.id }); return { ok: true }; }
191
+ }
192
+ if ((m = p.match(/^\/api\/memory\/f\/(.+)$/))) {
193
+ if (method === 'PUT') { MEMORY[decodeURIComponent(m[1])] = String(body.content ?? ''); return { ok: true }; }
194
+ if (method === 'DELETE') { delete MEMORY[decodeURIComponent(m[1])]; return { ok: true }; }
195
+ }
196
+ throw err(400, NOPE);
197
+ }
198
+
199
+ const json = (status, data) => new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
200
+ const realFetch = window.fetch.bind(window);
201
+ window.fetch = async (input, opts = {}) => {
202
+ const href = typeof input === 'string' ? input : input?.url || String(input);
203
+ if (!href.startsWith('demo:')) return realFetch(input, opts);
204
+ const u = new URL(href.replace(/^demo:[^/]*/, 'http://demo')), method = (opts.method || 'GET').toUpperCase();
205
+ if (u.pathname === '/api/stream') {
206
+ let ctl;
207
+ const body = new ReadableStream({
208
+ start(c) { ctl = c; streams.add(c); c.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ type: 'hello', sessions: list(), approvals: pending() })}\n\n`)); },
209
+ cancel() { streams.delete(ctl); },
210
+ });
211
+ opts.signal?.addEventListener('abort', () => { streams.delete(ctl); try { ctl.close(); } catch { /* ja tancat */ } });
212
+ return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } });
213
+ }
214
+ await sleep(60);
215
+ let body = {};
216
+ try { body = typeof opts.body === 'string' ? JSON.parse(opts.body) : {}; } catch { /* àudio o res */ }
217
+ try { return json(200, await route(method, u.pathname, u.searchParams, body)); }
218
+ catch (e) { return json(e.status || 500, { error: e.message }); }
219
+ };
220
+ window.NioDemo = { url: 'demo:nio', isDemo: (m) => !!m && String(m.url).startsWith('demo:') };
221
+ })();
@@ -72,6 +72,7 @@
72
72
 
73
73
  <script src="/bot.js"></script>
74
74
  <script src="/board.js"></script>
75
+ <script src="/app/demo.js"></script>
75
76
  <script src="/app/app.js" type="module"></script>
76
77
  </body>
77
78
  </html>
package/web/app/style.css CHANGED
@@ -712,3 +712,9 @@ html.rd-open, html.rd-open body { overflow: hidden; }
712
712
  #jobWhen button { flex: 1 0 auto; padding: 0 12px; }
713
713
  #jobTimes .chip[aria-pressed="true"] { background: var(--fg); color: var(--bg); border-color: var(--fg); }
714
714
  #jobMinsU { flex: 1; }
715
+
716
+ /* Demo: avís d'ordinador simulat */
717
+ .demo-bar { display: flex; align-items: center; justify-content: space-between; gap: 8px 16px; flex-wrap: wrap; margin: 16px 40px 0; padding: 10px 12px 10px 16px; border: 1px dashed var(--accent); border-radius: var(--radius); background: var(--panel); font-size: 13px; }
718
+ .demo-bar b { color: var(--accent); }
719
+ body.chat-mode .demo-bar, body.board-mode .demo-bar { display: none; }
720
+ @media (max-width: 820px) { .demo-bar { margin: 12px 16px 0; } }
package/web/index.html CHANGED
@@ -223,7 +223,7 @@ main { padding-bottom: 8px; }
223
223
  <article class="card c-peach"><span class="tag">Pantalla remota</span><h3>L’escriptori, a la butxaca</h3><p>Obre l’escriptori de l’ordinador des del mòbil. Amb <strong>Mirar</strong> només veus què fa l’agent; amb <strong>Controlar</strong> hi cliques, arrossegues i escrius. Pessiga amb dos dits per fer zoom. Els agents també hi poden mirar webs i apps, sempre amb permís.</p></article>
224
224
  <article class="card c-lemonsoft"><span class="tag">Pissarra</span><h3>Pensar dibuixant</h3><p>Un llenç infinit per a fluxos, mapes mentals i organigrames, amb figures, fletxes i llapis. Si li expliques a en Nio què vols, te’ls dibuixa. Exporta a PNG, SVG o JSON.</p></article>
225
225
  <article class="card c-blue"><span class="tag">Agents</span><h3>Git i GitHub</h3><p>Clona un repositori, mira els canvis, fes commit i crea un PR des del mòbil, amb el <code>git</code> i el <code>gh</code> de l’ordinador.</p></article>
226
- <article class="card c-lilac"><span class="tag">Connexió</span><h3>Telegram i veu</h3><p>Parla amb el teu bot o amb notes de veu, aprova amb un botó i escolta les respostes. Les claus es queden a l’ordinador.</p></article>
226
+ <article class="card c-lilac"><span class="tag">Connexió</span><h3>Telegram i veu</h3><p>Parla amb el teu bot o amb notes de veu, aprova amb un botó i escolta les respostes. Dictes gratis amb Whisper, que funciona a l’ordinador: l’àudio no en surt.</p></article>
227
227
  <article class="card c-green"><span class="tag">Xat local</span><h3>Ollama i xat local</h3><p>Fes servir models gratuïts a l’ordinador, o un model petit dins del navegador del mòbil, sense connexió.</p></article>
228
228
  </div>
229
229
  </section>
package/web/sw.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
2
- const SHELL = 'nio-shell-v36';
3
- const FILES = ['/app', '/app/style.css', '/app/app.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
2
+ const SHELL = 'nio-shell-v38';
3
+ const FILES = ['/app', '/app/style.css', '/app/app.js', '/app/demo.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
4
4
 
5
5
  self.addEventListener('install', (e) => {
6
6
  e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).catch(() => {}));