nio-cat 0.9.5 → 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 +2 -2
- package/bin/nio.js +5 -12
- package/package.json +1 -1
- package/src/pair.js +3 -50
- package/src/voice.js +21 -3
- package/src/whisper.js +148 -0
- package/web/app/app.js +64 -12
- package/web/app/demo.js +221 -0
- package/web/app/index.html +1 -0
- package/web/app/style.css +6 -0
- package/web/index.html +1 -1
- package/web/sw.js +2 -2
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
|
|
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
|
|
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,
|
|
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
|
|
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 (
|
|
121
|
-
|
|
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
|
|
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
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.
|
|
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>
|
|
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,
|
|
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,
|
|
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é
|
|
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
|
@@ -373,8 +373,9 @@ const pairFrom = (text) => parsePair(text) || parseCode(text);
|
|
|
373
373
|
// Cap pantalla ensenya adreces: cada ordinador és un nom, una icona i un estat.
|
|
374
374
|
const hostOf = (u) => { try { return new URL(u).hostname; } catch { return ''; } };
|
|
375
375
|
const isLoop = (u) => ['127.0.0.1', 'localhost', '[::1]'].includes(hostOf(u));
|
|
376
|
+
const isDemo = (m = machine()) => !!window.NioDemo?.isDemo(m);
|
|
376
377
|
const machineIcon = (m) => (m.kind === 'vps' ? ICON.server : ICON.machine);
|
|
377
|
-
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');
|
|
378
379
|
function addMachine({ url, token, name, kind, via }) {
|
|
379
380
|
const ms = store.machines; let i = ms.findIndex((m) => m.url === url);
|
|
380
381
|
if (i >= 0) { ms[i].token = token; if (kind) ms[i].kind = kind; if (via) ms[i].via = via; }
|
|
@@ -383,6 +384,16 @@ function addMachine({ url, token, name, kind, via }) {
|
|
|
383
384
|
state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.info = null; state.ollama = null;
|
|
384
385
|
connect();
|
|
385
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
|
+
}
|
|
386
397
|
// El codi o l'enllaç porta un secret d'un sol ús: el canviem per una clau pròpia d'aquest dispositiu,
|
|
387
398
|
// que es pot revocar des d'Ordinadors. Si és la clau principal (el panell que obre nio) o un nio antic
|
|
388
399
|
// que encara no fa aparellament, el valor ja és una clau i el fem servir tal qual.
|
|
@@ -470,7 +481,7 @@ function route() {
|
|
|
470
481
|
|
|
471
482
|
/* ---------------------------------------------------------------- pintat general */
|
|
472
483
|
function renderChrome() {
|
|
473
|
-
renderPanic();
|
|
484
|
+
renderPanic(); renderDemoBar();
|
|
474
485
|
const r = state.route, n = state.approvals.size, active = r.name === 'session' ? 'sessions' : r.name;
|
|
475
486
|
let prev = '';
|
|
476
487
|
$('#nav').innerHTML = Object.entries(VIEWS).map(([k, v], i) => {
|
|
@@ -550,7 +561,7 @@ function computerCard(m, i) {
|
|
|
550
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>';
|
|
551
562
|
return `<div class="card computer ${on ? 'active' : ''}">
|
|
552
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>
|
|
553
|
-
<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>` : ''}
|
|
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>
|
|
554
565
|
</div>`;
|
|
555
566
|
}
|
|
556
567
|
// Ajuda sempre a mà: com es fa cada cosa, i què mirar si no connecta.
|
|
@@ -585,7 +596,7 @@ function helpPanel(open) {
|
|
|
585
596
|
])}<p>Exemple: ${k('/mode p')} passa la sessió a «Demana permís».</p>`)}
|
|
586
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>`)}
|
|
587
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>`)}
|
|
588
|
-
${q('Veu', `<p>A <b>Ajustos → Veu</b>
|
|
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>`)}
|
|
589
600
|
|
|
590
601
|
${grp('Eines')}
|
|
591
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>`)}
|
|
@@ -646,7 +657,7 @@ function viewComputers() {
|
|
|
646
657
|
const steps = isPhone()
|
|
647
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.']]
|
|
648
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.']];
|
|
649
|
-
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>`;
|
|
650
661
|
}
|
|
651
662
|
const detail = m && state.connected ? `<h2 class="sec">${esc(m.name)}</h2>
|
|
652
663
|
<div class="grid2 one">
|
|
@@ -721,7 +732,7 @@ function addComputerSheet(tab, opts = {}) {
|
|
|
721
732
|
const saved = d && store.machines.findIndex((x) => x.id === d.id);
|
|
722
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>`);
|
|
723
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')}`);
|
|
724
|
-
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>'}`);
|
|
725
736
|
if (saved < 0 || !d) poll = setTimeout(local, 3000);
|
|
726
737
|
};
|
|
727
738
|
const runSsh = async (target) => {
|
|
@@ -1602,7 +1613,7 @@ function modeSheet(s) {
|
|
|
1602
1613
|
const isLocalMachine = () => { const m = machine(); try { return !!m && ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(m.url).hostname); } catch { return false; } };
|
|
1603
1614
|
function mobileHint() {
|
|
1604
1615
|
const m = machine(), key = `nio.mobHint.${m?.id || m?.url}`;
|
|
1605
|
-
if (!m || isPhone() || !state.connected || LS.get(key, false)) return '';
|
|
1616
|
+
if (!m || isDemo(m) || isPhone() || !state.connected || LS.get(key, false)) return '';
|
|
1606
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>`;
|
|
1607
1618
|
}
|
|
1608
1619
|
let qrTimer = 0;
|
|
@@ -1925,6 +1936,12 @@ async function sendLocal() {
|
|
|
1925
1936
|
let screen = null; // { caps, last, actions }
|
|
1926
1937
|
const controlOn = () => !!(state.info?.control?.navegador?.enabled || state.info?.control?.escriptori?.enabled || screen?.caps?.navegador?.enabled || screen?.caps?.escriptori?.enabled);
|
|
1927
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
|
+
}
|
|
1928
1945
|
function renderPanic() {
|
|
1929
1946
|
syncScreenBtn();
|
|
1930
1947
|
$('#panicBtn')?.remove();
|
|
@@ -2332,6 +2349,7 @@ async function speak(btn) {
|
|
|
2332
2349
|
function autoSpeak(ev) { if (nodeVoice?.speak && nodeVoice?.tts && !document.hidden && ev?.text) { stopSpeech(); playText(ev.text); } }
|
|
2333
2350
|
const mic = { rec: null, chunks: [], stream: null };
|
|
2334
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);
|
|
2335
2353
|
if (!voiceOn()) return voiceSheet();
|
|
2336
2354
|
if (mic.rec) return mic.rec.stop();
|
|
2337
2355
|
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Aquest navegador no pot gravar àudio.');
|
|
@@ -2355,18 +2373,20 @@ async function toggleMic() {
|
|
|
2355
2373
|
function voicePanel() {
|
|
2356
2374
|
if (!machine()) return '';
|
|
2357
2375
|
const v = nodeVoice, P = v?.providers || {};
|
|
2358
|
-
const on = Object.entries(P).filter(([, p]) => p.key).map(([, p]) => p.name);
|
|
2359
|
-
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.
|
|
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>`;
|
|
2360
2378
|
}
|
|
2361
2379
|
async function voiceSheet() {
|
|
2362
2380
|
await loadVoice();
|
|
2363
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);
|
|
2364
2383
|
const v = nodeVoice, old = LS.get('nio.voice', {});
|
|
2365
|
-
openSheet(`<h2>Veu</h2><p class="muted small">
|
|
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>
|
|
2366
2386
|
<form class="stack" id="voiceForm" style="margin-top:14px">
|
|
2367
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>
|
|
2368
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>
|
|
2369
|
-
<div class="grid-2"><label class="f"><span>Per entendre’t</span><select id="vStt"><option value="">Automàtic</option
|
|
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>
|
|
2370
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>
|
|
2371
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>
|
|
2372
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>' : ''}
|
|
@@ -2375,8 +2395,38 @@ async function voiceSheet() {
|
|
|
2375
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); } };
|
|
2376
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'); };
|
|
2377
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);
|
|
2378
2415
|
});
|
|
2379
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
|
+
}
|
|
2380
2430
|
|
|
2381
2431
|
/* ---------------------------------------------------------------- accions */
|
|
2382
2432
|
document.addEventListener('click', (e) => {
|
|
@@ -2444,6 +2494,8 @@ document.addEventListener('click', async (e) => {
|
|
|
2444
2494
|
case 'activity-refresh': activity = null; return loadActivity();
|
|
2445
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;
|
|
2446
2496
|
case 'add-computer': return addComputerSheet();
|
|
2497
|
+
case 'demo': return startDemo();
|
|
2498
|
+
case 'demo-exit': return exitDemo();
|
|
2447
2499
|
case 'open-sessions': return go('#/sessions');
|
|
2448
2500
|
case 'help': return go('#/ajuda');
|
|
2449
2501
|
case 'more': return moreSheet();
|
|
@@ -2478,7 +2530,7 @@ function commands() {
|
|
|
2478
2530
|
if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
|
|
2479
2531
|
c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
|
|
2480
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}`) }));
|
|
2481
|
-
c.push({ label: 'Configura la veu (
|
|
2533
|
+
c.push({ label: 'Configura la veu (Whisper, gratis)', hint: 'Ajustos', run: voiceSheet });
|
|
2482
2534
|
c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
|
|
2483
2535
|
return c;
|
|
2484
2536
|
}
|
package/web/app/demo.js
ADDED
|
@@ -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
|
+
})();
|
package/web/app/index.html
CHANGED
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.
|
|
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-
|
|
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(() => {}));
|