nio-cat 0.3.4 → 0.4.1
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/bin/nio.js +18 -66
- package/package.json +1 -1
- package/src/config.js +1 -0
- package/src/proc.js +1 -0
- package/src/server.js +21 -0
- package/src/ssh.js +111 -0
- package/web/app/app.js +240 -137
- package/web/app/index.html +2 -0
- package/web/app/style.css +42 -0
- package/web/index.html +4 -2
- package/web/sw.js +1 -1
package/bin/nio.js
CHANGED
|
@@ -4,13 +4,13 @@ const os = require('os');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const crypto = require('crypto');
|
|
7
|
-
const net = require('net');
|
|
8
7
|
const { spawn } = require('child_process');
|
|
9
8
|
const config = require('../src/config');
|
|
10
9
|
const { Hub } = require('../src/hub');
|
|
11
10
|
const { createServer } = require('../src/server');
|
|
12
11
|
const { detectAll } = require('../src/agents');
|
|
13
12
|
const { startTunnel } = require('../src/tunnel');
|
|
13
|
+
const { addServer } = require('../src/ssh');
|
|
14
14
|
const { pairLink, terminalQr, writePairPage, makeCode, codeLink, terminalQrSize } = require('../src/pair');
|
|
15
15
|
const { which } = require('../src/proc');
|
|
16
16
|
|
|
@@ -111,73 +111,26 @@ async function showPair(publicUrl) {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
// nio ssh usuari@servidor: engega nio al servidor
|
|
115
|
-
const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
116
|
-
function remoteScript(port, dir) {
|
|
117
|
-
return `set -e
|
|
118
|
-
command -v node >/dev/null 2>&1 || { echo NIO_NO_NODE; exit 0; }
|
|
119
|
-
up() { (exec 3<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null; }
|
|
120
|
-
if ! up; then
|
|
121
|
-
mkdir -p "$HOME/.nio"
|
|
122
|
-
if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi
|
|
123
|
-
$S nohup npx --yes nio-cat@latest --no-open --no-tunnel --port ${port} ${dir ? shq(dir) : '"$HOME"'} > "$HOME/.nio/daemon.log" 2>&1 < /dev/null &
|
|
124
|
-
for i in $(seq 1 80); do up && break; sleep 0.5; done
|
|
125
|
-
up || { echo NIO_NO_START; tail -n 5 "$HOME/.nio/daemon.log" 2>/dev/null; exit 0; }
|
|
126
|
-
fi
|
|
127
|
-
node -e "console.log('NIO_TOKEN='+require(require('os').homedir()+'/.nio/config.json').token)"
|
|
128
|
-
`;
|
|
129
|
-
}
|
|
130
|
-
function sshBase(target) {
|
|
131
|
-
const a = ['-o', 'ServerAliveInterval=30'];
|
|
132
|
-
if (process.platform !== 'win32') a.push('-o', 'ControlMaster=auto', '-o', 'ControlPath=/tmp/nio-%C', '-o', 'ControlPersist=10m');
|
|
133
|
-
return a;
|
|
134
|
-
}
|
|
135
|
-
function waitPort(port, ms = 15000) {
|
|
136
|
-
const end = Date.now() + ms;
|
|
137
|
-
return new Promise((resolve) => {
|
|
138
|
-
const t = () => {
|
|
139
|
-
const s = net.connect(port, '127.0.0.1');
|
|
140
|
-
s.once('connect', () => { s.destroy(); resolve(true); });
|
|
141
|
-
s.once('error', () => { s.destroy(); Date.now() > end ? resolve(false) : setTimeout(t, 300); });
|
|
142
|
-
};
|
|
143
|
-
t();
|
|
144
|
-
});
|
|
145
|
-
}
|
|
114
|
+
// nio ssh usuari@servidor: engega nio al servidor amb un túnel i obre el panell amb el servidor ja afegit.
|
|
146
115
|
async function sshStart() {
|
|
147
116
|
const target = pos[0];
|
|
148
|
-
if (!target
|
|
149
|
-
console.log(`\n Ús: ${c.b('nio ssh usuari@servidor')} ${c.dim('[--port 7717] [--
|
|
117
|
+
if (!target) {
|
|
118
|
+
console.log(`\n Ús: ${c.b('nio ssh usuari@servidor')} ${c.dim('[--port 7717] [--dir /ruta/al/servidor]')}\n`);
|
|
150
119
|
process.exit(1);
|
|
151
120
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
165
|
-
if (out.buf.includes('NIO_NO_START')) { console.error(c.warn('\n nio no s’ha engegat al servidor:\n') + out.buf.replace('NIO_NO_START', '').trim() + '\n'); process.exit(1); }
|
|
166
|
-
const token = (out.buf.match(/NIO_TOKEN=(\S+)/) || [])[1];
|
|
167
|
-
if (!token) { console.error(c.warn('\n No he pogut llegir la clau del servidor.\n')); process.exit(1); }
|
|
168
|
-
|
|
169
|
-
const tun = spawn('ssh', [...sshBase(target), '-N', '-T', '-o', 'ExitOnForwardFailure=yes', '-L', `127.0.0.1:${lport}:127.0.0.1:${rport}`, '--', target], { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
170
|
-
tun.on('close', () => { console.log(c.warn('\n S’ha tallat el túnel SSH.\n')); process.exit(0); });
|
|
171
|
-
const bye = () => { tun.kill(); process.exit(0); };
|
|
172
|
-
process.on('SIGINT', bye);
|
|
173
|
-
process.on('SIGTERM', bye);
|
|
174
|
-
if (!(await waitPort(lport))) { console.error(c.warn(`\n No puc obrir el port ${lport} en aquest ordinador. Prova amb --local-port 7719.\n`)); tun.kill(); process.exit(1); }
|
|
175
|
-
|
|
176
|
-
const local = `http://127.0.0.1:${lport}`, link = pairLink(local, local, token);
|
|
177
|
-
console.log(` ${c.ok('✓')} Connectat. nio funciona al servidor i el panell és a ${local}`);
|
|
178
|
-
if (canOpenBrowser() && openUrl(link)) console.log(` ${c.dim('Panell obert al navegador.')}`);
|
|
179
|
-
else console.log(` ${c.dim('Obre aquest enllaç:')} ${link}`);
|
|
180
|
-
console.log(c.dim('\n Deixa aquesta finestra oberta mentre facis servir el servidor. Amb Ctrl+C es tanca el túnel (nio continua al servidor).\n'));
|
|
121
|
+
console.log(`\n ${c.b('nio')} ${c.dim('· afegint')} ${target}${c.dim('…')}`);
|
|
122
|
+
let r;
|
|
123
|
+
try {
|
|
124
|
+
r = await addServer(target, { port: Number(flags.port || 7717), dir: flags.dir, batch: false, onStep: (t) => console.log(` ${c.dim('·')} ${t}…`) });
|
|
125
|
+
} catch (e) {
|
|
126
|
+
console.error(c.warn(`\n ${e.message}\n`));
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
const app = flags.app || cfg.app, link = pairLink(app, r.url, r.token), code = makeCode(r.url, r.token);
|
|
130
|
+
console.log(` ${c.ok('✓')} ${r.host} és a punt${r.claude ? '' : c.warn(' (Claude Code no hi és: instal·la’l i inicia sessió al servidor)')}`);
|
|
131
|
+
console.log(`\n ${c.b('Codi:')} ${code}`);
|
|
132
|
+
console.log(c.dim(' Enganxa’l a nio.cat → Ordinadors → Afegeix, al mòbil o a l’ordinador.\n'));
|
|
133
|
+
if (canOpenBrowser() && openUrl(link)) console.log(c.dim(' He obert nio.cat amb el servidor ja afegit.\n'));
|
|
181
134
|
}
|
|
182
135
|
|
|
183
136
|
async function start() {
|
|
@@ -294,9 +247,8 @@ async function start() {
|
|
|
294
247
|
--open Obre al navegador la pàgina del codi QR
|
|
295
248
|
nio pair Mostra el codi QR una altra vegada
|
|
296
249
|
|
|
297
|
-
nio ssh usuari@servidor
|
|
250
|
+
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.
|
|
298
251
|
--port <n> Port de nio al servidor (7717)
|
|
299
|
-
--local-port <n> Port en aquest ordinador (7718)
|
|
300
252
|
--dir <ruta> Carpeta del servidor on s'engega (per defecte, la teva carpeta d'inici)
|
|
301
253
|
|
|
302
254
|
Servidor amb adreça pública: nio --url https://la-teva-adreça --no-open
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -37,6 +37,7 @@ function load() {
|
|
|
37
37
|
}
|
|
38
38
|
cfg = { ...DEFAULTS, ...cfg };
|
|
39
39
|
if (!cfg.token) cfg.token = crypto.randomBytes(24).toString('base64url');
|
|
40
|
+
if (!cfg.id) cfg.id = crypto.randomBytes(8).toString('hex'); // identifica aquest ordinador, sigui quina sigui l'adreça
|
|
40
41
|
if (!cfg.vapid) cfg.vapid = makeVapid();
|
|
41
42
|
save(cfg);
|
|
42
43
|
return cfg;
|
package/src/proc.js
CHANGED
|
@@ -6,6 +6,7 @@ const path = require('path');
|
|
|
6
6
|
const { spawn, execFile } = require('child_process');
|
|
7
7
|
|
|
8
8
|
const EXTRA_DIRS = [
|
|
9
|
+
path.join(os.homedir(), '.nio', 'bin'),
|
|
9
10
|
path.join(os.homedir(), '.local', 'bin'),
|
|
10
11
|
path.join(os.homedir(), '.npm-global', 'bin'),
|
|
11
12
|
path.join(os.homedir(), '.bun', 'bin'),
|
package/src/server.js
CHANGED
|
@@ -10,6 +10,7 @@ const ollama = require('./agents/ollama');
|
|
|
10
10
|
const { save } = require('./config');
|
|
11
11
|
const { httpErr } = require('./hub');
|
|
12
12
|
const github = require('./github');
|
|
13
|
+
const { addServer, knownHosts } = require('./ssh');
|
|
13
14
|
const { pairLink, qrSvg, makeCode, codeLink } = require('./pair');
|
|
14
15
|
|
|
15
16
|
// L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
|
|
@@ -169,8 +170,10 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
169
170
|
agents: await detectAll(cfg, url.searchParams.has('refresh')),
|
|
170
171
|
roots: cfg.roots,
|
|
171
172
|
vapidKey: cfg.vapid.publicKey,
|
|
173
|
+
id: cfg.id,
|
|
172
174
|
};
|
|
173
175
|
}
|
|
176
|
+
if (p === '/api/ssh/hosts' && method === 'GET') return knownHosts();
|
|
174
177
|
if (p === '/api/projects' && method === 'GET') return listProjects(cfg);
|
|
175
178
|
if (p === '/api/fs' && method === 'GET') return listDir(cfg, url.searchParams.get('path'));
|
|
176
179
|
if (p === '/api/ollama/models' && method === 'GET') return (await ollama.listModels()) || [];
|
|
@@ -360,6 +363,12 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
360
363
|
}
|
|
361
364
|
}
|
|
362
365
|
|
|
366
|
+
// Sense clau: només diu que nio és aquí (la web ho fa servir per detectar «Aquest ordinador»).
|
|
367
|
+
if (url.pathname === '/api/hello' && req.method === 'GET') {
|
|
368
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
369
|
+
return res.end(JSON.stringify({ nio: true, id: cfg.id, name: os.hostname() }));
|
|
370
|
+
}
|
|
371
|
+
|
|
363
372
|
if (url.pathname.startsWith('/api/')) {
|
|
364
373
|
if (!tokenOk(cfg, req)) {
|
|
365
374
|
res.writeHead(401, { 'content-type': 'application/json' });
|
|
@@ -377,6 +386,18 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
377
386
|
req.on('close', () => streams.delete(res));
|
|
378
387
|
return;
|
|
379
388
|
}
|
|
389
|
+
// Afegir un servidor per SSH: el progrés va en línies JSON (una per pas) i l'última és el resultat.
|
|
390
|
+
if (url.pathname === '/api/ssh/add' && req.method === 'POST') {
|
|
391
|
+
res.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-store', 'x-accel-buffering': 'no' });
|
|
392
|
+
const send = (o) => res.write(JSON.stringify(o) + '\n');
|
|
393
|
+
try {
|
|
394
|
+
const b = await readBody(req);
|
|
395
|
+
send({ done: true, ...(await addServer(String(b.target || '').trim(), { onStep: (step) => send({ step }) })) });
|
|
396
|
+
} catch (e) {
|
|
397
|
+
send({ error: e.message });
|
|
398
|
+
}
|
|
399
|
+
return res.end();
|
|
400
|
+
}
|
|
380
401
|
try {
|
|
381
402
|
const out = await api(req, res, url);
|
|
382
403
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
package/src/ssh.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Afegeix un servidor (VPS) per SSH: hi entra, hi engega nio amb un túnel de Cloudflare i torna l'adreça i la clau.
|
|
3
|
+
// El servidor queda connectat directament al mòbil i al panell; aquest ordinador no ha de continuar enmig.
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { spawn } = require('child_process');
|
|
8
|
+
|
|
9
|
+
const TARGET = /^[A-Za-z0-9_][\w.@:\-\[\]]*$/;
|
|
10
|
+
const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
11
|
+
|
|
12
|
+
function remoteScript(port, dir) {
|
|
13
|
+
return `set -e
|
|
14
|
+
export PATH="$HOME/.nio/bin:$HOME/.local/bin:$PATH"
|
|
15
|
+
echo "NIO_STEP=Entrant al servidor"
|
|
16
|
+
command -v node >/dev/null 2>&1 || { echo NIO_NO_NODE; exit 0; }
|
|
17
|
+
up() { (exec 3<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null; }
|
|
18
|
+
mkdir -p "$HOME/.nio/bin"
|
|
19
|
+
if ! command -v cloudflared >/dev/null 2>&1 && [ "$(uname -s)" = Linux ]; then
|
|
20
|
+
echo "NIO_STEP=Instal·lant cloudflared"
|
|
21
|
+
case "$(uname -m)" in x86_64) A=amd64;; aarch64|arm64) A=arm64;; *) A=;; esac
|
|
22
|
+
if [ -n "$A" ]; then
|
|
23
|
+
U="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$A"
|
|
24
|
+
{ curl -fsSL -o "$HOME/.nio/bin/cloudflared" "$U" 2>/dev/null || wget -qO "$HOME/.nio/bin/cloudflared" "$U" 2>/dev/null; } && chmod +x "$HOME/.nio/bin/cloudflared" || rm -f "$HOME/.nio/bin/cloudflared"
|
|
25
|
+
fi
|
|
26
|
+
fi
|
|
27
|
+
if ! up; then
|
|
28
|
+
echo "NIO_STEP=Engegant nio"
|
|
29
|
+
node -e "const f=require('os').homedir()+'/.nio/config.json',fs=require('fs');try{const c=JSON.parse(fs.readFileSync(f));delete c.lastUrl;fs.writeFileSync(f,JSON.stringify(c,null,2))}catch{}"
|
|
30
|
+
if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi
|
|
31
|
+
$S nohup npx --yes nio-cat@latest --no-open --port ${port} ${dir ? shq(dir) : '"$HOME"'} > "$HOME/.nio/daemon.log" 2>&1 < /dev/null &
|
|
32
|
+
for i in $(seq 1 80); do up && break; sleep 0.5; done
|
|
33
|
+
up || { echo NIO_NO_START; tail -n 5 "$HOME/.nio/daemon.log" 2>/dev/null; exit 0; }
|
|
34
|
+
fi
|
|
35
|
+
echo "NIO_STEP=Obrint el túnel"
|
|
36
|
+
URL=
|
|
37
|
+
for i in $(seq 1 60); do
|
|
38
|
+
URL=$(node -e "try{console.log(JSON.parse(require('fs').readFileSync(require('os').homedir()+'/.nio/config.json')).lastUrl||'')}catch{}")
|
|
39
|
+
[ -n "$URL" ] && break
|
|
40
|
+
sleep 1
|
|
41
|
+
done
|
|
42
|
+
[ -n "$URL" ] || { echo NIO_NO_TUNNEL; exit 0; }
|
|
43
|
+
node -e "console.log('NIO_TOKEN='+JSON.parse(require('fs').readFileSync(require('os').homedir()+'/.nio/config.json')).token)"
|
|
44
|
+
echo "NIO_URL=$URL"
|
|
45
|
+
echo "NIO_HOST=$(hostname)"
|
|
46
|
+
if command -v claude >/dev/null 2>&1; then echo NIO_CLAUDE=1; else echo NIO_CLAUDE=0; fi
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// batch: sense preguntes (des del panell). Sense batch (terminal), ssh pot demanar la contrasenya.
|
|
51
|
+
function addServer(target, { port = 7717, dir, batch = true, onStep = () => {} } = {}) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
if (!TARGET.test(String(target || ''))) return reject(new Error('Escriu l’usuari i el servidor, per exemple root@el-meu-servidor.com'));
|
|
54
|
+
const args = ['-o', 'ConnectTimeout=12', '-o', 'StrictHostKeyChecking=accept-new', ...(batch ? ['-o', 'BatchMode=yes'] : []), '-T', '--', target, 'bash -l -s'];
|
|
55
|
+
let p;
|
|
56
|
+
try { p = spawn('ssh', args, { stdio: ['pipe', 'pipe', batch ? 'pipe' : 'inherit'] }); } catch { return reject(new Error('No trobo l’ordre ssh en aquest ordinador')); }
|
|
57
|
+
let buf = '', err = '', done = false;
|
|
58
|
+
const out = {};
|
|
59
|
+
const line = (l) => {
|
|
60
|
+
const m = l.match(/^NIO_(STEP|TOKEN|URL|HOST|CLAUDE)=(.*)$/);
|
|
61
|
+
if (!m) return;
|
|
62
|
+
if (m[1] === 'STEP') onStep(m[2]);
|
|
63
|
+
else out[m[1].toLowerCase()] = m[2].trim();
|
|
64
|
+
};
|
|
65
|
+
p.stdout.on('data', (d) => {
|
|
66
|
+
buf += d;
|
|
67
|
+
let i;
|
|
68
|
+
while ((i = buf.indexOf('\n')) >= 0) { line(buf.slice(0, i)); buf = buf.slice(i + 1); }
|
|
69
|
+
if (/NIO_NO_(NODE|START|TUNNEL)/.test(buf + '')) out.flag = (buf.match(/NIO_NO_\w+/) || [])[0];
|
|
70
|
+
});
|
|
71
|
+
if (batch) p.stderr.on('data', (d) => (err += d));
|
|
72
|
+
const fail = (m) => { if (!done) { done = true; reject(new Error(m)); } };
|
|
73
|
+
p.on('error', () => fail('No trobo l’ordre ssh en aquest ordinador'));
|
|
74
|
+
p.on('close', (code) => {
|
|
75
|
+
if (buf) line(buf);
|
|
76
|
+
if (done) return;
|
|
77
|
+
done = true;
|
|
78
|
+
const text = `${err}\n${buf}`;
|
|
79
|
+
if (/NIO_NO_NODE/.test(text)) return reject(new Error('Al servidor falta Node.js 18 o més nou. Instal·la’l i torna-ho a provar.'));
|
|
80
|
+
if (/NIO_NO_START/.test(text)) return reject(new Error('nio no s’ha pogut engegar al servidor. Mira ~/.nio/daemon.log allà.'));
|
|
81
|
+
if (/NIO_NO_TUNNEL/.test(text)) return reject(new Error('El túnel no s’ha obert. Al servidor cal Linux amb curl o wget (per instal·lar cloudflared), o una adreça pròpia amb --url.'));
|
|
82
|
+
if (out.token && out.url) return resolve({ url: out.url, token: out.token, host: out.host || target, claude: out.claude === '1' });
|
|
83
|
+
if (/Permission denied|publickey/i.test(err)) return reject(new Error(`No puc entrar sense contrasenya. Al terminal d’aquest ordinador executa: ssh-copy-id ${target}`));
|
|
84
|
+
if (/Could not resolve|Connection (refused|timed out)|No route|timed out/i.test(err)) return reject(new Error('No arribo al servidor. Comprova l’adreça i que el SSH hi està obert.'));
|
|
85
|
+
reject(new Error((err.trim().split('\n').pop() || 'No he pogut afegir el servidor') + (code ? ` (ssh ${code})` : '')));
|
|
86
|
+
});
|
|
87
|
+
p.stdin.end(remoteScript(port, dir));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Servidors que ja tens a ~/.ssh/config (sense comodins), perquè el panell els ofereixi i no calgui escriure'ls.
|
|
92
|
+
function knownHosts() {
|
|
93
|
+
let txt = '';
|
|
94
|
+
try { txt = fs.readFileSync(path.join(os.homedir(), '.ssh', 'config'), 'utf8'); } catch { return []; }
|
|
95
|
+
const out = [];
|
|
96
|
+
let cur = null;
|
|
97
|
+
for (const raw of txt.split('\n')) {
|
|
98
|
+
const l = raw.trim();
|
|
99
|
+
const h = l.match(/^Host\s+(.+)$/i);
|
|
100
|
+
if (h) {
|
|
101
|
+
cur = null;
|
|
102
|
+
for (const name of h[1].split(/\s+/)) if (TARGET.test(name) && !/[*?!]/.test(name)) { cur = { target: name, host: '' }; out.push(cur); }
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const hn = l.match(/^HostName\s+(\S+)/i);
|
|
106
|
+
if (hn && cur) cur.host = hn[1];
|
|
107
|
+
}
|
|
108
|
+
return out.slice(0, 30);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { addServer, remoteScript, knownHosts, TARGET };
|
package/web/app/app.js
CHANGED
|
@@ -28,6 +28,8 @@ const ICON = {
|
|
|
28
28
|
more: '<circle cx="5" cy="12" r="1.3" fill="currentColor"/><circle cx="12" cy="12" r="1.3" fill="currentColor"/><circle cx="19" cy="12" r="1.3" fill="currentColor"/>',
|
|
29
29
|
refresh: '<path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/>', back: '<path d="M15 5l-7 7 7 7"/>',
|
|
30
30
|
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
|
|
31
|
+
server: '<rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>',
|
|
32
|
+
scan: '<path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3M4 12h16"/>',
|
|
31
33
|
check: '<path class="tick" d="M5 12.5l4.5 4.5L19 7.5"/>',
|
|
32
34
|
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/>'
|
|
33
35
|
};
|
|
@@ -125,7 +127,7 @@ async function connect() {
|
|
|
125
127
|
if (res.status === 401) throw Object.assign(new Error('La clau no és vàlida. Torna a connectar amb el codi QR.'), { fatal: true });
|
|
126
128
|
if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
|
|
127
129
|
state.connected = true; state.lastError = null; retry = 0;
|
|
128
|
-
renameMachine(state.info.host); syncServiceWorker(); render(); brainsForBoard();
|
|
130
|
+
renameMachine(state.info.host, state.info.id); syncServiceWorker(); render(); brainsForBoard();
|
|
129
131
|
const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '';
|
|
130
132
|
for (;;) {
|
|
131
133
|
const { value, done } = await reader.read(); if (done) break;
|
|
@@ -146,7 +148,17 @@ async function connect() {
|
|
|
146
148
|
setTimeout(() => machine() && !ctrl.signal.aborted && connect(), Math.min(15000, 1000 * 2 ** retry++));
|
|
147
149
|
}
|
|
148
150
|
}
|
|
149
|
-
|
|
151
|
+
// Posa nom i identificador a l'ordinador actiu i fusiona els duplicats (el mateix ordinador desat amb dues adreces: wifi i 127.0.0.1).
|
|
152
|
+
function renameMachine(host, id) {
|
|
153
|
+
let ms = store.machines; const cur = ms[store.active]; if (!cur) return;
|
|
154
|
+
if (host) cur.name = host;
|
|
155
|
+
if (id) {
|
|
156
|
+
cur.id = id;
|
|
157
|
+
const keep = cur.url, list = ms.filter((m) => m === cur || m.id !== id);
|
|
158
|
+
if (list.length !== ms.length) { ms = list; store.active = ms.findIndex((m) => m.url === keep); }
|
|
159
|
+
}
|
|
160
|
+
store.machines = ms;
|
|
161
|
+
}
|
|
150
162
|
|
|
151
163
|
function handle(msg) {
|
|
152
164
|
const r = state.route;
|
|
@@ -207,13 +219,32 @@ function parseCode(text) {
|
|
|
207
219
|
}
|
|
208
220
|
// Un codi o un enllaç de connexió (#u=…&t=…).
|
|
209
221
|
const pairFrom = (text) => parsePair(text) || parseCode(text);
|
|
210
|
-
|
|
222
|
+
// Cap pantalla ensenya adreces: cada ordinador és un nom, una icona i un estat.
|
|
223
|
+
const hostOf = (u) => { try { return new URL(u).hostname; } catch { return ''; } };
|
|
224
|
+
const isLoop = (u) => ['127.0.0.1', 'localhost', '[::1]'].includes(hostOf(u));
|
|
225
|
+
const machineIcon = (m) => (m.kind === 'vps' ? ICON.server : ICON.machine);
|
|
226
|
+
const machineSub = (m) => (m.kind === 'vps' ? `Servidor${m.via ? ` · ${m.via}` : ''}` : isLoop(m.url) ? 'Aquest ordinador' : 'Ordinador');
|
|
227
|
+
function addMachine({ url, token, name, kind, via }) {
|
|
211
228
|
const ms = store.machines; let i = ms.findIndex((m) => m.url === url);
|
|
212
|
-
if (i >= 0) ms[i].token = token;
|
|
229
|
+
if (i >= 0) { ms[i].token = token; if (kind) ms[i].kind = kind; if (via) ms[i].via = via; }
|
|
230
|
+
else { ms.push({ url, token, name: name || hostOf(url), ...(kind ? { kind } : {}), ...(via ? { via } : {}) }); i = ms.length - 1; }
|
|
213
231
|
store.machines = ms; store.active = i;
|
|
214
232
|
state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.info = null; state.ollama = null;
|
|
215
233
|
connect();
|
|
216
234
|
}
|
|
235
|
+
// Passa a un altre ordinador de la llista i es queda a la mateixa secció.
|
|
236
|
+
function switchMachine(i) {
|
|
237
|
+
closeSheet(); store.active = i; streamCtrl?.abort();
|
|
238
|
+
state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; state.lastError = null;
|
|
239
|
+
connect();
|
|
240
|
+
if (state.route.name === 'session') go('#/sessions'); else render();
|
|
241
|
+
}
|
|
242
|
+
function forgetMachine(i) {
|
|
243
|
+
const ms = store.machines, was = i === store.active; ms.splice(i, 1); store.machines = ms;
|
|
244
|
+
if (was) store.active = 0; else if (i < store.active) store.active = store.active - 1;
|
|
245
|
+
if (was) { streamCtrl?.abort(); state.connected = false; state.info = null; state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); connect(); }
|
|
246
|
+
if (!ms.length) go('#/ordinadors'); else render();
|
|
247
|
+
}
|
|
217
248
|
function checkPairHash() {
|
|
218
249
|
const pair = parsePair(location.hash) || parseCode(location.hash); if (!pair) return false;
|
|
219
250
|
history.replaceState(null, '', location.pathname + '#/sessions');
|
|
@@ -225,20 +256,18 @@ function checkPairHash() {
|
|
|
225
256
|
const VIEWS = {
|
|
226
257
|
sessions: { label: 'Sessions', icon: ICON.sessions, group: 'Feina' },
|
|
227
258
|
approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
|
|
228
|
-
|
|
229
|
-
|
|
259
|
+
ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
|
|
260
|
+
models: { label: 'Models', icon: ICON.models, group: 'Ordinadors' },
|
|
230
261
|
mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
|
|
231
262
|
pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' }
|
|
232
263
|
};
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
models: { title: 'Models', lead: 'Models d’Ollama que funcionen a l’ordinador, sense internet i sense comptes.' },
|
|
236
|
-
maquina: { title: 'Ordinador', lead: 'Els agents instal·lats, els avisos al mòbil i els ordinadors connectats.' }
|
|
237
|
-
};
|
|
238
|
-
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', mobil: '#/mobil', models: '#/models', maquina: '#/ordinador', pissarra: '#/pissarra' };
|
|
264
|
+
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra' };
|
|
265
|
+
const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'models', 'session']);
|
|
239
266
|
function parseRoute() {
|
|
240
267
|
const h = location.hash.replace(/^#\/?/, '');
|
|
241
268
|
if (h.startsWith('s/')) return { name: 'session', id: h.slice(2) };
|
|
269
|
+
if (h === 'ordinador') return { name: 'ordinadors' }; // adreces antigues
|
|
270
|
+
if (h === 'mobil') return { name: 'mobil' };
|
|
242
271
|
const k = Object.keys(HASH).find((k) => HASH[k] === '#/' + h);
|
|
243
272
|
return { name: k || 'sessions' };
|
|
244
273
|
}
|
|
@@ -247,6 +276,8 @@ let lastName = null;
|
|
|
247
276
|
function route() {
|
|
248
277
|
if (checkPairHash()) return;
|
|
249
278
|
state.route = parseRoute();
|
|
279
|
+
// Sense cap ordinador, tot porta al mateix lloc: la pàgina Ordinadors.
|
|
280
|
+
if (!machine() && NEEDS_MACHINE.has(state.route.name)) { history.replaceState(null, '', '#/ordinadors'); state.route = { name: 'ordinadors' }; }
|
|
250
281
|
if (state.route.name === 'session') openSession(state.route.id); else render();
|
|
251
282
|
}
|
|
252
283
|
|
|
@@ -259,10 +290,13 @@ function renderChrome() {
|
|
|
259
290
|
prev = v.group;
|
|
260
291
|
return `${head}<a href="${HASH[k]}" style="--i:${i}" title="${v.label}" ${k === active ? 'aria-current="page"' : ''}>${svg(v.icon)}<span class="l">${v.label}</span>${k === 'approvals' && n ? `<span class="badge">${n}</span>` : ''}</a>`;
|
|
261
292
|
}).join('');
|
|
262
|
-
const m = machine();
|
|
263
|
-
|
|
264
|
-
sc.
|
|
265
|
-
|
|
293
|
+
const m = machine(), sc = $('#sideConn');
|
|
294
|
+
sc.innerHTML = m ? `<i class="dot ${state.connected ? 'on' : ''}"></i><span>${esc(m.name)}</span>${svg(ICON.chev)}` : `<i class="dot"></i><span>Afegeix un ordinador</span>${svg(ICON.plus)}`;
|
|
295
|
+
sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
|
|
296
|
+
const more = ['models', 'mobil', 'pissarra'].includes(r.name);
|
|
297
|
+
$('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['approvals', ICON.approvals, 'Aprovacions', active === 'approvals'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
|
|
298
|
+
.map(([k, ic, l, on]) => `<a href="${HASH[k]}" ${on ? 'aria-current="page"' : ''}>${svg(ic)}<span>${l}</span>${k === 'approvals' && n ? `<b class="badge">${n}</b>` : ''}</a>`).join('')
|
|
299
|
+
+ `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
|
|
266
300
|
const s = r.name === 'session' ? state.sessions.get(r.id) : null;
|
|
267
301
|
$('#here').textContent = s ? s.title || 'Sessió nova' : VIEWS[r.name]?.label || '';
|
|
268
302
|
$('#mRight').innerHTML = r.name === 'session'
|
|
@@ -276,6 +310,7 @@ function render() {
|
|
|
276
310
|
renderChrome();
|
|
277
311
|
document.body.classList.toggle('board-mode', board);
|
|
278
312
|
document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && !!local.engine));
|
|
313
|
+
document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' && !!local.engine));
|
|
279
314
|
if (!board && lastName === 'pissarra') window.NioBoard?.unmount();
|
|
280
315
|
if (board && lastName === 'pissarra' && $('#boardRoot')) return;
|
|
281
316
|
const changed = lastName !== r.name || r.name === 'session';
|
|
@@ -284,7 +319,7 @@ function render() {
|
|
|
284
319
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
285
320
|
else {
|
|
286
321
|
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
287
|
-
view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === '
|
|
322
|
+
view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'ordinadors' ? viewComputers() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : viewModels()}</div>`;
|
|
288
323
|
if (r.name === 'mobil') bindLocal();
|
|
289
324
|
if (r.name === 'models' && machine()) loadOllama();
|
|
290
325
|
}
|
|
@@ -293,48 +328,164 @@ function render() {
|
|
|
293
328
|
tourUpdate();
|
|
294
329
|
}
|
|
295
330
|
|
|
296
|
-
/* ----------
|
|
297
|
-
// La connexió viu només a Sessions. Les altres pàgines, sense ordinador, hi envien.
|
|
331
|
+
/* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
|
|
298
332
|
const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
<
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
333
|
+
const cmdBox = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
|
|
334
|
+
function computerCard(m, i) {
|
|
335
|
+
const on = i === store.active;
|
|
336
|
+
const st = on ? (state.connected ? '<span class="chip ok"><i class="dot on"></i>Connectat</span>' : `<span class="chip">${esc(state.lastError ? 'Sense connexió' : 'Connectant…')}</span>`) : '';
|
|
337
|
+
return `<div class="card computer ${on ? 'active' : ''}">
|
|
338
|
+
<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>
|
|
339
|
+
<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-forget="${i}" type="button">Oblida</button></div>
|
|
340
|
+
</div>`;
|
|
341
|
+
}
|
|
342
|
+
// Ajuda sempre a mà: com es fa cada cosa, i què mirar si no connecta.
|
|
343
|
+
function helpPanel(open) {
|
|
344
|
+
const q = (t, body) => `<details><summary>${t}</summary><div class="help-body">${body}</div></details>`;
|
|
345
|
+
return `<section class="panel help" id="help"><header><h2>Com es fa</h2></header><div class="body">
|
|
346
|
+
${q('Connectar aquest ordinador', `<p>Al terminal, des de la carpeta dels teus projectes, executa <span class="mono">npx nio-cat</span>. S’obre nio.cat ja connectat. Cal Node.js 18 o més nou.</p>`)}
|
|
347
|
+
${q('Fer-lo servir des del mòbil', `<p>A l’ordinador, a Ordinadors, prem <b>Continua al mòbil</b>. Surt un QR i un codi <span class="mono">nio-…</span>.</p><p>Al mòbil, obre <b>www.nio.cat/app</b> → Ordinadors → Afegeix ordinador, i escaneja el QR o enganxa el codi.</p><p>Per connectar-t’hi des de qualsevol xarxa cal <span class="mono">cloudflared</span> a l’ordinador (<span class="mono">brew install cloudflared</span>). Sense ell, el mòbil ha d’estar a la mateixa wifi.</p>`)}
|
|
348
|
+
${q('Afegir un servidor (VPS) amb Claude Code', `<p>Ordinadors → Afegeix ordinador → <b>Servidor (SSH)</b> → escriu <span class="mono">usuari@servidor</span>. L’ordinador hi entra, hi engega nio i el servidor surt a la llista.</p><p>Després, prem <b>Continua al mòbil</b> amb el servidor actiu per fer-lo servir des del mòbil.</p>`)}
|
|
349
|
+
${q('Què cal al servidor', `<ul><li>Entrar-hi per SSH sense contrasenya. Si no, executa <span class="mono">ssh-copy-id usuari@servidor</span> a l’ordinador.</li><li>Node.js 18 o més nou.</li><li>Claude Code instal·lat i amb sessió iniciada: entra al servidor i executa <span class="mono">claude</span> un cop.</li><li>Linux amb <span class="mono">curl</span> o <span class="mono">wget</span> (nio hi instal·la <span class="mono">cloudflared</span> sol).</li></ul>`)}
|
|
350
|
+
${q('No es connecta', `<ul><li><b>«No arribo a l’ordinador»</b>: comprova que nio continua engegat al terminal.</li><li><b>El servidor ha deixat de respondre</b>: si s’ha reiniciat, l’adreça canvia. Prem <b>Torna a connectar</b> a la seva targeta.</li><li><b>Safari no connecta</b>: obre nio amb <span class="mono">npx nio-cat --local</span>, o fes servir Chrome.</li><li><b>La clau no és vàlida</b>: torna a afegir l’ordinador amb el codi nou.</li></ul>`)}
|
|
351
|
+
${q('D’on es gasta', `<p>nio no fa cap crida pel seu compte. Cada agent treballa a l’ordinador o al servidor amb el teu compte (Claude, Codex, Grok…). Ollama funciona al teu ordinador i no gasta res.</p>`)}
|
|
352
|
+
<div class="row"><button class="btn sm" type="button" data-act="tour">Fes el tutorial</button></div>
|
|
353
|
+
</div></section>`;
|
|
354
|
+
}
|
|
355
|
+
function viewComputers() {
|
|
356
|
+
const ms = store.machines, m = machine(), info = state.info;
|
|
357
|
+
const head = `<div class="top"><div><h1>Ordinadors</h1><p>On treballen els agents: el teu ordinador o un servidor. Tria’n un i dona-li feina.</p></div><button class="btn ink" id="addComputer" data-act="add-computer" type="button">${svg(ICON.plus)}Afegeix ordinador</button></div>`;
|
|
358
|
+
if (!ms.length) {
|
|
359
|
+
const steps = isPhone()
|
|
360
|
+
? [['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.']]
|
|
361
|
+
: [['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.']];
|
|
362
|
+
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>${helpPanel()}`;
|
|
323
363
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
<div class="
|
|
327
|
-
<section class="panel
|
|
328
|
-
|
|
329
|
-
<p class="muted small">S’obre aquest panell ja connectat. Cal Node.js 18 o més nou.</p></div></section>
|
|
330
|
-
<section class="panel choice"><header><h2>Un altre ordinador</h2></header><div class="body stack">
|
|
331
|
-
<p class="small">Engega nio a l’altre ordinador o a un servidor. Copia aquí el codi que mostra.</p>
|
|
332
|
-
${pasteForm(true)}
|
|
333
|
-
<p class="small"><b>Un servidor (VPS) per SSH.</b> Des d'aquest ordinador, sense obrir cap port:</p>${cmd('npx nio-cat ssh usuari@servidor')}
|
|
334
|
-
<p class="muted small">Engega nio al servidor, obre el túnel i el panell queda connectat.</p></div></section>
|
|
364
|
+
const detail = m && state.connected ? `<h2 class="sec">${esc(m.name)}</h2>
|
|
365
|
+
<div class="grid2">
|
|
366
|
+
<div class="stack">
|
|
367
|
+
<section class="panel"><header><h2>Agents</h2><button class="btn sm ghost" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar</button></header><div class="body">${info ? info.agents.map((a) => `<div class="agent-row"><i class="dot ${a.available ? 'on' : ''}"></i><div class="grow"><div class="row"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="muted small mono">${esc(a.available ? a.version || 'Disponible' : a.login)}</div></div></div>`).join('') : '<p class="muted">Carregant…</p>'}</div></section>
|
|
368
|
+
${info ? `<section class="panel"><header><h2>Sistema</h2></header><div class="body"><dl class="kv"><dt>Sistema</dt><dd>${esc(info.platform)} · ${esc(info.user)}</dd><dt>Versió de nio</dt><dd>${esc(info.version)}</dd></dl></div></section>` : ''}
|
|
335
369
|
</div>
|
|
336
|
-
<
|
|
337
|
-
|
|
370
|
+
<div class="stack">
|
|
371
|
+
<section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}<div class="row"><button class="btn ink grow" data-act="push" type="button">Activa els avisos</button><button class="btn" data-act="push-test" type="button">Envia’n un de prova</button></div></div></section>
|
|
372
|
+
</div>
|
|
373
|
+
</div>` : '';
|
|
374
|
+
return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}${helpPanel()}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/* ---------- Afegir un ordinador: aquest, un servidor per SSH o un codi */
|
|
378
|
+
async function probeLocal() {
|
|
379
|
+
try {
|
|
380
|
+
const c = new AbortController(); setTimeout(() => c.abort(), 1500);
|
|
381
|
+
const d = await (await fetch('http://127.0.0.1:7717/api/hello', { signal: c.signal, cache: 'no-store' })).json();
|
|
382
|
+
return d?.nio ? d : null;
|
|
383
|
+
} catch { return null; }
|
|
384
|
+
}
|
|
385
|
+
// Afegeix un servidor per SSH des de l'ordinador actiu. El progrés arriba línia a línia.
|
|
386
|
+
async function sshAdd(target, onStep) {
|
|
387
|
+
const m = machine(); if (!m) throw new Error('Primer connecta un ordinador');
|
|
388
|
+
const res = await fetch(`${m.url}/api/ssh/add`, { method: 'POST', headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json' }, body: JSON.stringify({ target }) });
|
|
389
|
+
if (!res.ok || !res.body) throw new Error((await res.json().catch(() => ({}))).error || 'Aquest ordinador no pot afegir servidors. Actualitza nio.');
|
|
390
|
+
const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '', last = null;
|
|
391
|
+
for (;;) {
|
|
392
|
+
const { value, done } = await reader.read(); if (done) break;
|
|
393
|
+
buf += dec.decode(value, { stream: true }); let i;
|
|
394
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
395
|
+
const o = JSON.parse(buf.slice(0, i)); buf = buf.slice(i + 1);
|
|
396
|
+
if (o.step) onStep(o.step); else last = o;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (!last || last.error) throw new Error(last?.error || 'S’ha tallat la connexió');
|
|
400
|
+
return last;
|
|
401
|
+
}
|
|
402
|
+
async function startScan(video, onText) {
|
|
403
|
+
const det = new BarcodeDetector({ formats: ['qr_code'] });
|
|
404
|
+
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
|
|
405
|
+
video.srcObject = stream; await video.play(); let alive = true;
|
|
406
|
+
(async () => { while (alive) { try { const r = await det.detect(video); if (r[0]) onText(r[0].rawValue); } catch { /* quadre sense QR */ } await sleep(250); } })();
|
|
407
|
+
return { stop() { alive = false; stream.getTracks().forEach((t) => t.stop()); } };
|
|
408
|
+
}
|
|
409
|
+
function addComputerSheet(tab, opts = {}) {
|
|
410
|
+
const phone = isPhone(), canScan = 'BarcodeDetector' in window && !!navigator.mediaDevices?.getUserMedia;
|
|
411
|
+
const TABS = phone ? [['code', 'Codi o QR'], ['vps', 'Servidor (SSH)']] : [['local', 'Aquest ordinador'], ['vps', 'Servidor (SSH)'], ['code', 'Tinc un codi']];
|
|
412
|
+
let cur = TABS.some((t) => t[0] === tab) ? tab : TABS[0][0], poll = 0, scan = null, shown = '', hosts = [];
|
|
413
|
+
openSheet('', (sh) => {
|
|
414
|
+
sh.innerHTML = `<div class="wiz-head"><div class="grab"></div><div class="row between" style="flex-wrap:nowrap"><h2>Afegeix un ordinador</h2><button class="icon-btn" type="button" data-close aria-label="Tanca">×</button></div><div class="seg" id="addTabs" role="group">${TABS.map(([k, l]) => `<button type="button" data-tab="${k}">${l}</button>`).join('')}</div></div><div class="wiz-body" id="addBody"></div>`;
|
|
415
|
+
const body = $('#addBody', sh);
|
|
416
|
+
const stop = () => { clearTimeout(poll); scan?.stop(); scan = null; shown = ''; };
|
|
417
|
+
const set = (key, html) => { if (key === shown) return false; shown = key; body.innerHTML = html; return true; };
|
|
418
|
+
const draw = () => {
|
|
419
|
+
stop(); if (cur === 'vps' && !hosts.length && machine()) api('/api/ssh/hosts').then((h) => { hosts = h; if (cur === 'vps' && shown === 'vps') { shown = ''; vps(); } }).catch(() => {});
|
|
420
|
+
$$('#addTabs button', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.tab === cur));
|
|
421
|
+
body.scrollTop = 0;
|
|
422
|
+
if (cur === 'local') local(); else if (cur === 'vps') vps(); else code();
|
|
423
|
+
};
|
|
424
|
+
const local = async () => {
|
|
425
|
+
set('wait', '<p class="muted small">Mirant si nio funciona en aquest ordinador…</p>');
|
|
426
|
+
const d = await probeLocal(); if (cur !== 'local') return;
|
|
427
|
+
const saved = d && store.machines.findIndex((x) => x.id === d.id);
|
|
428
|
+
if (d && saved >= 0) set('saved', `<p class="wiz-q">Aquest ordinador ja és a la llista</p><p class="muted small">${esc(d.name)}</p><div class="row"><button class="btn ink" type="button" data-open-saved="${saved}">Obre</button></div>`);
|
|
429
|
+
else if (d) set('found', `<p class="wiz-q">nio funciona en aquest ordinador</p><p class="muted small">Aquest navegador encara no hi té accés. Torna a engegar nio perquè obri el panell ja connectat:</p>${cmdBox('npx nio-cat')}`);
|
|
430
|
+
else set('none', `<p class="wiz-q">Engega nio en aquest ordinador</p><p class="muted small">Al terminal, des de la carpeta dels teus projectes:</p>${cmdBox('npx nio-cat')}<p class="muted small">S’obrirà aquest panell ja connectat. Cal Node.js 18 o més nou.</p>`);
|
|
431
|
+
if (saved < 0 || !d) poll = setTimeout(local, 3000);
|
|
432
|
+
};
|
|
433
|
+
const runSsh = async (target) => {
|
|
434
|
+
const steps = $('#sshSteps', sh), btn = $('#sshForm button', sh); if (!steps) return; btn.disabled = true; steps.innerHTML = '';
|
|
435
|
+
const add = (t) => { $$('li.on', steps).forEach((l) => { l.className = 'ok'; }); steps.insertAdjacentHTML('beforeend', `<li class="on">${esc(t)}…</li>`); };
|
|
436
|
+
try {
|
|
437
|
+
const r = await sshAdd(target, add);
|
|
438
|
+
LS.set('nio.lastSsh', target);
|
|
439
|
+
const old = store.machines.findIndex((x) => x.via === target && x.kind === 'vps');
|
|
440
|
+
if (old >= 0) { const ms = store.machines; ms.splice(old, 1); store.machines = ms; store.active = Math.min(store.active, Math.max(0, ms.length - 1)); }
|
|
441
|
+
addMachine({ url: r.url, token: r.token, name: r.host, kind: 'vps', via: target });
|
|
442
|
+
shown = 'vps-ok';
|
|
443
|
+
body.innerHTML = `<p class="wiz-q">${esc(r.host)} és a punt</p>${r.claude ? '<p class="muted small">Ja el pots fer servir: crea-hi una sessió amb Claude Code.</p>' : '<p class="muted small">Claude Code no hi és. Entra al servidor, instal·la’l i inicia sessió amb <span class="mono">claude</span>.</p>'}<div class="row"><a class="btn ink" href="#/sessions" data-close>Obre sessions</a><button class="btn" type="button" data-act="mobile-qr">Continua al mòbil</button></div>`;
|
|
444
|
+
} catch (err) { $$('li.on', steps).forEach((l) => { l.className = 'err'; }); steps.insertAdjacentHTML('beforeend', `<li class="err">${esc(err.message)}</li>`); btn.disabled = false; }
|
|
445
|
+
};
|
|
446
|
+
const vps = () => {
|
|
447
|
+
const m = machine();
|
|
448
|
+
if (!m) return set('vps-no', `<p class="wiz-q">Primer, un ordinador</p><p class="muted small">El servidor s’afegeix des d’un ordinador que ja tinguis connectat, perquè és ell qui hi entra per SSH.</p>${phone ? '' : '<button class="btn" type="button" data-tab="local">Afegeix aquest ordinador</button>'}`);
|
|
449
|
+
set('vps', `<p class="wiz-q">Un servidor amb Claude Code</p>
|
|
450
|
+
<p class="muted small">${esc(m.name)} hi entra per SSH, hi engega nio amb un túnel segur i el deixa llest per al mòbil.</p>
|
|
451
|
+
${hosts.length ? `<div class="row">${hosts.map((h) => `<button class="chip" type="button" data-host="${esc(h.target)}" title="${esc(h.host)}">${esc(h.target)}</button>`).join('')}</div>` : ''}
|
|
452
|
+
<form class="pair-form" id="sshForm"><input type="text" id="sshIn" value="${esc(opts.target || LS.get('nio.lastSsh', ''))}" placeholder="usuari@servidor" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Usuari i servidor"><button class="btn ink" type="submit">Afegeix</button></form>
|
|
453
|
+
<ul class="steps" id="sshSteps"></ul>
|
|
454
|
+
<p class="muted small">Cal entrar-hi sense contrasenya (clau SSH) i que el servidor tingui Node.js 18 o més nou. Claude Code hi ha d’estar instal·lat i amb sessió iniciada.</p>`);
|
|
455
|
+
};
|
|
456
|
+
const code = () => {
|
|
457
|
+
set('code', `<p class="wiz-q">${phone ? 'Escaneja o escriu el codi' : 'Escriu el codi'}</p>
|
|
458
|
+
<form class="pair-form" id="pairForm"><input type="text" id="pairInput" placeholder="nio-…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi de connexió"><button class="btn ink" type="submit">Connecta</button></form>
|
|
459
|
+
${canScan ? `<button class="btn" type="button" data-scan>${svg(ICON.scan)}Escaneja un QR</button><video class="scan" id="scanVideo" playsinline muted hidden></video>` : phone ? '<p class="muted small">Per escanejar, obre la càmera del mòbil i apunta al QR: s’obrirà nio.</p>' : ''}
|
|
460
|
+
<p class="muted small">El codi surt al terminal, sota el QR, o a «Continua al mòbil».</p>`);
|
|
461
|
+
};
|
|
462
|
+
sh.onclick = async (e) => {
|
|
463
|
+
const t = e.target.closest('[data-tab]'); if (t) { cur = t.dataset.tab; return draw(); }
|
|
464
|
+
const hb = e.target.closest('[data-host]'); if (hb) { $('#sshIn', sh).value = hb.dataset.host; return $('#sshForm', sh).requestSubmit(); }
|
|
465
|
+
const os = e.target.closest('[data-open-saved]'); if (os) { switchMachine(+os.dataset.openSaved); return go('#/sessions'); }
|
|
466
|
+
if (e.target.closest('[data-close]')) { stop(); return closeSheet(); }
|
|
467
|
+
if (e.target.closest('[data-scan]')) {
|
|
468
|
+
const v = $('#scanVideo', sh); v.hidden = false;
|
|
469
|
+
try { scan = await startScan(v, (text) => { const pair = pairFrom(text); if (pair) { stop(); closeSheet(); addMachine(pair); toast('Connectant…'); go('#/sessions'); } }); }
|
|
470
|
+
catch { v.hidden = true; toast('No puc obrir la càmera. Escriu el codi.'); }
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
sh.onsubmit = async (e) => {
|
|
474
|
+
if (e.target.id !== 'sshForm') return;
|
|
475
|
+
e.preventDefault(); e.stopPropagation();
|
|
476
|
+
runSsh($('#sshIn', sh).value.trim());
|
|
477
|
+
};
|
|
478
|
+
draw();
|
|
479
|
+
if (opts.auto && cur === 'vps') setTimeout(() => runSsh(opts.target), 50);
|
|
480
|
+
}, 'wiz');
|
|
481
|
+
}
|
|
482
|
+
function machinesSheet() {
|
|
483
|
+
const ms = store.machines;
|
|
484
|
+
openSheet(`<h2>Ordinadors</h2>${ms.length ? `<div class="stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><span class="c-ico">${svg(machineIcon(x))}</span><span class="grow"><b>${esc(x.name)}</b><div class="muted small">${esc(machineSub(x))}</div></span>${i === store.active ? `<span class="chip ok"><i class="dot ${state.connected ? 'on' : ''}"></i>Actiu</span>` : ''}</button>`).join('')}</div>` : '<p class="muted small">Encara no hi ha cap ordinador.</p>'}
|
|
485
|
+
<div class="row between sheet-foot">${ms.length ? '<a class="btn ghost" href="#/ordinadors" data-close>Gestiona</a>' : '<button class="btn ghost" type="button" data-close>Tanca</button>'}<button class="btn ink" type="button" data-act="add-computer">${svg(ICON.plus)}Afegeix ordinador</button></div>`, (sh) => { $$('[data-close]', sh).forEach((b) => (b.onclick = closeSheet)); });
|
|
486
|
+
}
|
|
487
|
+
function moreSheet() {
|
|
488
|
+
openSheet(`<h2>Més</h2><div class="list">${['models', 'mobil', 'pissarra'].map((k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`).join('')}<a class="card row" href="#/ordinadors" data-act="help" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.1"/></svg><b>Com es fa</b></a><button class="card row" type="button" data-act="tour" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17.2v.1"/></svg><b>Tutorial</b></button></div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
|
|
338
489
|
}
|
|
339
490
|
|
|
340
491
|
/* ---------- Sessions */
|
|
@@ -351,7 +502,7 @@ function viewSessions() {
|
|
|
351
502
|
const hint = mobileHint();
|
|
352
503
|
const head = `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p>${switcher}</div><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>${hint}`;
|
|
353
504
|
if (!state.connected && !list.length) {
|
|
354
|
-
return `${head}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p
|
|
505
|
+
return `${head}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p>${mach.kind === 'vps' ? 'Potser el servidor s’ha reiniciat i l’adreça ha canviat.' : 'Comprova que nio continua engegat a l’ordinador i que el túnel està obert.'}</p><div class="row" style="justify-content:center"><button class="btn" data-act="retry" type="button">${svg(ICON.refresh)}Torna-ho a provar</button>${mach.kind === 'vps' && mach.via ? `<button class="btn ink" data-reconnect="${store.active}" type="button">Torna a connectar</button>` : ''}<a class="btn ghost" href="#/ordinadors">Ordinadors</a></div>` : ''}</div>`;
|
|
355
506
|
}
|
|
356
507
|
return `${head}${list.length ? `<div class="list">${list.map((s) => `
|
|
357
508
|
<a class="card session-card" href="#/s/${esc(s.id)}">
|
|
@@ -563,7 +714,11 @@ async function newSessionSheet(preset = {}) {
|
|
|
563
714
|
let projects = [];
|
|
564
715
|
try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
|
|
565
716
|
const first = agents.find((a) => a.available);
|
|
566
|
-
|
|
717
|
+
// Continuïtat: l'última tria d'aquest ordinador (agent, carpeta, permisos) i les carpetes recents.
|
|
718
|
+
const lastKey = `nio.last.${machine()?.id || machine()?.url}`, last = LS.get(lastKey, {});
|
|
719
|
+
const lastAgent = agents.find((a) => a.id === last.agent && a.available);
|
|
720
|
+
const sel = { agent: lastAgent?.id || first?.id || '', cwd: last.cwd || projects.find((p) => !p.root)?.path || projects[0]?.path || '', mode: last.mode || 'preguntar', model: '', text: '', ...preset };
|
|
721
|
+
const recents = (last.recent || []).filter((p) => p);
|
|
567
722
|
const STEPS = ['Agent', 'Carpeta', 'Opcions', 'Tasca'];
|
|
568
723
|
let step = preset.agent ? 1 : 0, fsd = null, gh = null, models = null;
|
|
569
724
|
const agentOf = () => agents.find((a) => a.id === sel.agent);
|
|
@@ -616,7 +771,7 @@ async function newSessionSheet(preset = {}) {
|
|
|
616
771
|
() => `<p class="wiz-q">Quin agent vols fer servir?</p><div class="picker">${agents.map((a) => `<button type="button" class="pick ${a.available ? '' : 'off'}" data-agent-id="${esc(a.id)}" aria-pressed="${a.id === sel.agent}"><div class="row between"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="sub">${esc(a.available ? a.version || 'Disponible' : 'No instal·lat')}</div></button>`).join('')}</div>`,
|
|
617
772
|
() => {
|
|
618
773
|
const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
|
|
619
|
-
const quick = projects.slice(0, 8).map((p) => `<button type="button" class="chip" data-fs="${esc(p.path)}">${esc(p.name)}</button>`).join('');
|
|
774
|
+
const quick = [...recents.map((p) => ({ path: p, name: basename(p) })), ...projects.filter((p) => !recents.includes(p.path))].slice(0, 8).map((p) => `<button type="button" class="chip" data-fs="${esc(p.path)}">${esc(p.name)}</button>`).join('');
|
|
620
775
|
return `<p class="wiz-q">A quina carpeta ha de treballar?</p>
|
|
621
776
|
<form class="pair-form" id="fsForm"><input type="text" id="fsIn" value="${esc(fsd?.path || sel.cwd)}" placeholder="/ruta/exacta/de/la/carpeta" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Ruta de la carpeta"><button class="btn" type="submit">Obre</button></form>
|
|
622
777
|
<div class="fs-pick"><span class="muted small">Carpeta triada</span><b class="mono small">${esc(fsd?.path || sel.cwd || '—')}</b>${fsd?.git ? '<span class="chip">git</span>' : ''}</div>
|
|
@@ -640,7 +795,7 @@ async function newSessionSheet(preset = {}) {
|
|
|
640
795
|
const start = async () => {
|
|
641
796
|
if (!sel.text.trim()) { $('#firstMsg', sh)?.focus(); return toast('Explica a l’agent què ha de fer'); }
|
|
642
797
|
const btn = $('[data-start]', sh); btn.disabled = true;
|
|
643
|
-
try { const { text, ...rest } = sel; const s = await api('/api/sessions', { method: 'POST', body: { ...rest, text: text.trim() } }); state.sessions.set(s.id, s); closeSheet(); go(`#/s/${s.id}`); }
|
|
798
|
+
try { const { text, ...rest } = sel; const s = await api('/api/sessions', { method: 'POST', body: { ...rest, text: text.trim() } }); state.sessions.set(s.id, s); LS.set(lastKey, { agent: sel.agent, cwd: sel.cwd, mode: sel.mode, recent: [sel.cwd, ...recents.filter((p) => p !== sel.cwd)].slice(0, 6) }); closeSheet(); go(`#/s/${s.id}`); }
|
|
644
799
|
catch (e) { toast(e.message, 4200); btn.disabled = false; }
|
|
645
800
|
};
|
|
646
801
|
sh.onclick = (e) => {
|
|
@@ -705,24 +860,10 @@ function modeSheet(s) {
|
|
|
705
860
|
/* ---------- Ordinadors i mòbil */
|
|
706
861
|
const isLocalMachine = () => { const m = machine(); try { return !!m && ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(m.url).hostname); } catch { return false; } };
|
|
707
862
|
function mobileHint() {
|
|
708
|
-
|
|
863
|
+
const m = machine(), key = `nio.mobHint.${m?.id || m?.url}`;
|
|
864
|
+
if (!m || isPhone() || !state.connected || LS.get(key, false)) return '';
|
|
709
865
|
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>`;
|
|
710
866
|
}
|
|
711
|
-
function machinesSheet() {
|
|
712
|
-
const ms = store.machines;
|
|
713
|
-
openSheet(`<h2>Ordinadors</h2><div class="stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><i class="dot ${i === store.active && state.connected ? 'on' : ''}"></i><span class="grow"><b>${esc(x.name)}</b><div class="muted small mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.url)}</div></span>${i === store.active ? '<span class="chip">Actiu</span>' : ''}</button>`).join('')}
|
|
714
|
-
<div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button><button class="btn ink" type="button" data-act="add-machine">${svg(ICON.plus)}Connecta’n un altre</button></div></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
|
|
715
|
-
}
|
|
716
|
-
function addMachineSheet() {
|
|
717
|
-
const cmd = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
|
|
718
|
-
openSheet(`<h2>Connecta un altre ordinador</h2>
|
|
719
|
-
<div class="stack-lg">
|
|
720
|
-
<div class="stack"><b>Un ordinador</b><p class="muted small">Al terminal de l’altre ordinador, des de la carpeta dels projectes:</p>${cmd('npx nio-cat')}<p class="muted small">Escaneja el QR que surt, o enganxa aquí sota l’enllaç.</p></div>
|
|
721
|
-
<div class="stack"><b>Un servidor (VPS) per SSH</b><p class="muted small">Des de l’ordinador on tens aquest panell. No cal obrir cap port al servidor, només Node.js 18 o més nou:</p>${cmd('npx nio-cat ssh usuari@servidor')}<p class="muted small">Engega nio al servidor, obre un túnel SSH i et mostra el panell connectat.</p></div>
|
|
722
|
-
<div class="stack"><b>Un servidor amb adreça pública</b><p class="muted small">Si el servidor té una adreça amb HTTPS (Tailscale, Cloudflare o Caddy):</p>${cmd('npx nio-cat --url https://la-teva-adreça --no-open')}<p class="muted small">Deixa’l sota <span class="mono">tmux</span>, <span class="mono">pm2</span> o <span class="mono">systemd</span>. Copia aquí el codi que mostra.</p></div>
|
|
723
|
-
<form class="pair-form" id="pairForm"><input type="text" id="pairInput" placeholder="nio-… o enllaç" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi o enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
|
|
724
|
-
</div>`);
|
|
725
|
-
}
|
|
726
867
|
let qrTimer = 0;
|
|
727
868
|
function mobileQrSheet() {
|
|
728
869
|
clearTimeout(qrTimer);
|
|
@@ -731,11 +872,11 @@ function mobileQrSheet() {
|
|
|
731
872
|
let r; try { r = await api('/api/pair'); } catch (e) { box.innerHTML = `<p class="muted small">${esc(e.message)}</p>`; return; }
|
|
732
873
|
if (!$('#qrBox')) return;
|
|
733
874
|
if (!r.link) {
|
|
734
|
-
box.innerHTML =
|
|
875
|
+
box.innerHTML = n < 8 ? '<p class="muted small">Esperant el túnel…</p>' : `<p class="wiz-q">Falta una adreça per al mòbil</p><p class="muted small">Per connectar-t’hi des de qualsevol xarxa, instal·la <span class="mono">cloudflared</span> a l’ordinador i torna a engegar nio:</p>${cmdBox('brew install cloudflared')}<p class="muted small">Sense ell, el mòbil només es connecta per la mateixa wifi: engega nio amb <span class="mono">npx nio-cat --lan</span>.</p>`;
|
|
735
876
|
if (n < 8) qrTimer = setTimeout(() => paint(n + 1), 2000);
|
|
736
877
|
return;
|
|
737
878
|
}
|
|
738
|
-
box.innerHTML = `<div class="qr-box">${r.svg}</div><p class="muted small">Escaneja-ho amb la càmera del mòbil.${r.lan ? ' El mòbil ha d’estar a la mateixa wifi.' : ''}</p><div class="row"><button class="btn" type="button" data-copy="${esc(r.code)}">${svg(ICON.copy)}Copia el codi</button></div><p class="muted small">
|
|
879
|
+
box.innerHTML = `<div class="qr-box">${r.svg}</div><p class="muted small">Escaneja-ho amb la càmera del mòbil.${r.lan ? ' El mòbil ha d’estar a la mateixa wifi.' : ''}</p><div class="row"><button class="btn" type="button" data-copy="${esc(r.code)}">${svg(ICON.copy)}Copia el codi</button></div><p class="muted small">Al mòbil: www.nio.cat/app → Ordinadors → Afegeix ordinador. Escaneja el QR o enganxa el codi.</p>`;
|
|
739
880
|
};
|
|
740
881
|
openSheet(`<h2>Continua al mòbil</h2><div class="stack" id="qrBox"><p class="muted small">Preparant el codi…</p></div><div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
|
|
741
882
|
paint();
|
|
@@ -783,25 +924,6 @@ function sessionOptions(id) {
|
|
|
783
924
|
});
|
|
784
925
|
}
|
|
785
926
|
|
|
786
|
-
/* ---------- Ordinador */
|
|
787
|
-
function viewMachine() {
|
|
788
|
-
const m = machine(), info = state.info, ms = store.machines;
|
|
789
|
-
return `<div class="top"><div><h1>${esc(m.name)}</h1><p class="conn"><i class="dot ${state.connected ? 'on' : ''}"></i><span>${state.connected ? 'Connectat' : esc(state.lastError || 'Connectant…')}</span></p></div><div class="row"><button class="btn" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar els agents</button><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div></div>
|
|
790
|
-
<div class="grid2">
|
|
791
|
-
<div class="stack">
|
|
792
|
-
<section class="panel"><header><h2>Agents</h2></header><div class="body">${info ? info.agents.map((a) => `<div class="agent-row"><i class="dot ${a.available ? 'on' : ''}"></i><div class="grow"><div class="row"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="muted small mono">${esc(a.available ? a.version || 'Disponible' : a.login)}</div></div></div>`).join('') : '<p class="muted">Sense connexió.</p>'}</div></section>
|
|
793
|
-
${info?.roots?.length ? `<section class="panel"><header><h2>Carpetes compartides</h2></header><div class="body mono small">${info.roots.map(esc).join('<br>')}</div></section>` : ''}
|
|
794
|
-
</div>
|
|
795
|
-
<div class="stack">
|
|
796
|
-
<section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}<div class="row"><button class="btn ink grow" data-act="push" type="button">Activa els avisos</button><button class="btn" data-act="push-test" type="button">Envia’n un de prova</button></div></div></section>
|
|
797
|
-
${'<section class="panel"><header><h2>Continua al mòbil</h2></header><div class="body stack"><p class="small">Escaneja un codi i obre aquest ordinador des del telèfon.</p><div class="row"><button class="btn ink grow" data-act="mobile-qr" type="button">Mostra el QR</button></div></div></section>'}
|
|
798
|
-
<section class="panel"><header><h2>Detalls</h2></header><div class="body"><dl class="kv"><dt>Adreça</dt><dd class="mono">${esc(m.url)}</dd>${info ? `<dt>Sistema</dt><dd>${esc(info.platform)} · ${esc(info.user)}</dd><dt>Versió</dt><dd>${esc(info.version)}</dd>` : ''}</dl></div></section>
|
|
799
|
-
<section class="panel"><header><h2>Ordinadors</h2></header><div class="body stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><i class="dot ${i === store.active && state.connected ? 'on' : ''}"></i><span class="grow"><b>${esc(x.name)}</b><div class="muted small mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.url)}</div></span>${i === store.active ? '<span class="chip">actiu</span>' : ''}</button>`).join('')}
|
|
800
|
-
<div class="row"><button class="btn" data-act="add-machine" type="button">${svg(ICON.plus)}Connecta’n un altre</button><button class="btn danger" data-act="forget" type="button">Oblida aquest</button></div></div></section>
|
|
801
|
-
</div>
|
|
802
|
-
</div>`;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
927
|
/* ---------- Models d'Ollama */
|
|
806
928
|
const OLLAMA_PICKS = [
|
|
807
929
|
{ name: 'qwen3:4b', size: '2,5 GB', note: 'Ràpid i bo per a gairebé tot.' },
|
|
@@ -1020,7 +1142,7 @@ async function sendLocal() {
|
|
|
1020
1142
|
|
|
1021
1143
|
/* ---------------------------------------------------------------- accions */
|
|
1022
1144
|
document.addEventListener('click', async (e) => {
|
|
1023
|
-
const a = e.target.closest('[data-act], [data-copy], [data-decide], [data-machine], [data-lm]');
|
|
1145
|
+
const a = e.target.closest('[data-act], [data-copy], [data-decide], [data-machine], [data-forget], [data-reconnect], [data-lm]');
|
|
1024
1146
|
if (!a) return;
|
|
1025
1147
|
if (a.dataset.copy) {
|
|
1026
1148
|
try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
|
|
@@ -1032,7 +1154,9 @@ document.addEventListener('click', async (e) => {
|
|
|
1032
1154
|
return;
|
|
1033
1155
|
}
|
|
1034
1156
|
if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
|
|
1035
|
-
if (a.dataset.machine != null)
|
|
1157
|
+
if (a.dataset.machine != null) return switchMachine(Number(a.dataset.machine));
|
|
1158
|
+
if (a.dataset.reconnect != null) { const m = store.machines[Number(a.dataset.reconnect)]; return addComputerSheet('vps', { target: m.via, auto: true }); }
|
|
1159
|
+
if (a.dataset.forget != null) { if (confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) forgetMachine(Number(a.dataset.forget)); return; }
|
|
1036
1160
|
if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
|
|
1037
1161
|
switch (a.dataset.act) {
|
|
1038
1162
|
case 'new': return newSessionSheet();
|
|
@@ -1048,17 +1172,14 @@ document.addEventListener('click', async (e) => {
|
|
|
1048
1172
|
case 'refresh-agents': try { state.info = await api('/api/info?refresh=1'); render(); toast('Agents actualitzats'); } catch (err) { toast(err.message); } return;
|
|
1049
1173
|
case 'push': return enablePush();
|
|
1050
1174
|
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;
|
|
1051
|
-
case 'add-
|
|
1175
|
+
case 'add-computer': return addComputerSheet();
|
|
1176
|
+
case 'open-sessions': return go('#/sessions');
|
|
1177
|
+
case 'help': go('#/ordinadors'); setTimeout(() => { const h = $('#help'); h?.scrollIntoView({ block: 'start' }); $('details', h)?.setAttribute('open', ''); }, 60); return;
|
|
1178
|
+
case 'more': return moreSheet();
|
|
1052
1179
|
case 'machines': return machinesSheet();
|
|
1053
1180
|
case 'mobile-qr': return mobileQrSheet();
|
|
1054
|
-
case 'mobile-hint-off': LS.set(
|
|
1181
|
+
case 'mobile-hint-off': { const m = machine(); LS.set(`nio.mobHint.${m?.id || m?.url}`, true); return render(); }
|
|
1055
1182
|
case 'git': return gitSheet(state.route.id);
|
|
1056
|
-
case 'forget': {
|
|
1057
|
-
if (!confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) return;
|
|
1058
|
-
const list = store.machines; list.splice(store.active, 1); store.machines = list; store.active = 0;
|
|
1059
|
-
streamCtrl?.abort(); state.connected = false; state.info = null; state.sessions.clear(); state.approvals.clear();
|
|
1060
|
-
connect(); return render();
|
|
1061
|
-
}
|
|
1062
1183
|
case 'lm-load': return loadLocal(local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id));
|
|
1063
1184
|
case 'lm-change': await local.engine?.unload?.(); local.engine = null; local.model = null; return render();
|
|
1064
1185
|
case 'lm-clear': local.messages = []; return render();
|
|
@@ -1081,7 +1202,9 @@ let palSel = 0;
|
|
|
1081
1202
|
function commands() {
|
|
1082
1203
|
const c = Object.entries(VIEWS).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
|
|
1083
1204
|
if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
|
|
1205
|
+
c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
|
|
1084
1206
|
[...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}`) }));
|
|
1207
|
+
c.push({ label: 'Com es fa (ajuda)', hint: 'Ajuda', run: () => { go('#/ordinadors'); setTimeout(() => $('#help')?.scrollIntoView({ block: 'start' }), 60); } });
|
|
1085
1208
|
c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
|
|
1086
1209
|
return c;
|
|
1087
1210
|
}
|
|
@@ -1129,42 +1252,22 @@ $('#scrim').addEventListener('click', () => setNav(false));
|
|
|
1129
1252
|
$('#side').addEventListener('click', (e) => { if (e.target.closest('a, button')) setNav(false); });
|
|
1130
1253
|
addEventListener('keydown', (e) => { if (e.key === 'Escape' && document.body.classList.contains('nav-open')) setNav(false); });
|
|
1131
1254
|
mq.addEventListener('change', () => setNav(false));
|
|
1132
|
-
(() => {
|
|
1133
|
-
const side = $('#side'); let x0 = null, y0 = 0, dx = 0, mode = null;
|
|
1134
|
-
addEventListener('touchstart', (e) => {
|
|
1135
|
-
if (!mq.matches || $('#sheet').open || state.route.name === 'pissarra') return;
|
|
1136
|
-
const t = e.touches[0], open = document.body.classList.contains('nav-open');
|
|
1137
|
-
if (open || t.clientX < 22) { x0 = t.clientX; y0 = t.clientY; dx = 0; mode = open ? 'close' : 'open'; }
|
|
1138
|
-
}, { passive: true });
|
|
1139
|
-
addEventListener('touchmove', (e) => {
|
|
1140
|
-
if (x0 == null) return;
|
|
1141
|
-
const t = e.touches[0]; dx = t.clientX - x0;
|
|
1142
|
-
if (Math.abs(t.clientY - y0) > Math.abs(dx) && Math.abs(dx) < 12) { x0 = null; return; }
|
|
1143
|
-
const w = side.offsetWidth, pos = mode === 'open' ? Math.min(0, -w + Math.max(0, dx)) : Math.min(0, dx);
|
|
1144
|
-
side.classList.add('dragging'); side.style.transform = `translateX(${pos}px)`;
|
|
1145
|
-
}, { passive: true });
|
|
1146
|
-
addEventListener('touchend', () => {
|
|
1147
|
-
if (x0 == null) return;
|
|
1148
|
-
side.classList.remove('dragging'); side.style.transform = '';
|
|
1149
|
-
if (mode === 'open' && dx > 60) setNav(true); else if (mode === 'close' && dx < -60) setNav(false);
|
|
1150
|
-
x0 = null;
|
|
1151
|
-
});
|
|
1152
|
-
})();
|
|
1153
|
-
|
|
1154
1255
|
/* ---------------------------------------------------------------- tutorial */
|
|
1155
1256
|
const TOUR_KEY = 'nio.tour.v2';
|
|
1156
1257
|
const T = Object.assign({ on: false, i: 0, seen: false }, LS.get(TOUR_KEY, {}));
|
|
1157
1258
|
const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
|
|
1158
1259
|
const TSTEPS = [
|
|
1159
|
-
{ center: true, mood: 'happy', title: 'Hola, sóc en Nio.', text: 'Faig de pont entre el teu mòbil i els agents d’IA del teu ordinador. En un minut t’ensenyo
|
|
1160
|
-
{ route: '
|
|
1161
|
-
{ route: '
|
|
1162
|
-
{ route: '
|
|
1163
|
-
{ route: '
|
|
1260
|
+
{ center: true, mood: 'happy', title: 'Hola, sóc en Nio.', text: 'Faig de pont entre el teu mòbil i els agents d’IA (Claude Code, Codex…) del teu ordinador o d’un servidor. En un minut t’ensenyo com es fa.', next: 'Comencem' },
|
|
1261
|
+
{ route: 'ordinadors', target: '#addComputer', m: '#addComputer', title: '1. Afegeix un ordinador', text: 'Hi ha tres maneres: aquest ordinador (<span class="mono">npx nio-cat</span>), un servidor per SSH (<span class="mono">usuari@servidor</span>) o un codi. nio fa la resta.' },
|
|
1262
|
+
{ route: 'ordinadors', skipIf: () => !machine(), target: '.card.computer.active .c-actions', m: '.card.computer.active .c-actions', title: '2. Continua al mòbil', text: 'A la targeta de l’ordinador prem «Continua al mòbil»: surt un QR i un codi. Al mòbil, obre www.nio.cat/app, Ordinadors, i escaneja el QR o enganxa el codi.' },
|
|
1263
|
+
{ route: 'sessions', skipIf: () => !machine(), target: '.top [data-act=new]', m: '.top [data-act=new]', title: '3. Dona feina a un agent', text: 'Una sessió és una conversa amb un agent dins d’una carpeta. Tries l’agent, la carpeta (qualsevol de l’ordinador), els permisos i li expliques què ha de fer.' },
|
|
1264
|
+
{ route: 'approvals', skipIf: () => !machine(), target: '.side nav a[href="#/aprovacions"]', m: '#tabbar a[href="#/aprovacions"]', title: '4. Tu decideixes', text: 'En mode «Demana permís», abans d’editar un fitxer o executar una ordre, l’agent t’ho pregunta aquí. Si actives els avisos, t’arriba al mòbil.' },
|
|
1265
|
+
{ route: 'models', skipIf: () => !machine(), target: '.side nav a[href="#/models"]', m: '#tabbar [data-act=more]', title: 'Models al teu ordinador', text: 'Si tens Ollama, aquí descarregues models i veus quins tens. Res no surt de casa i no gasta res.' },
|
|
1266
|
+
{ route: 'mobil', target: '.side nav a[href="#/xat-local"]', m: '.page .model-card', title: 'Un model sense internet', text: 'Si no tens l’ordinador a mà, pots fer servir un model petit que funciona dins del mateix mòbil.' },
|
|
1164
1267
|
{ route: 'pissarra', target: '.side nav a[href="#/pissarra"]', m: '#bdTools', title: 'Pensa en dibuix', text: 'A la pissarra pots fer diagrames a mà alçada o demanar-me’ls escrivint, amb Ollama o amb el model del mòbil.' },
|
|
1165
|
-
{ route: '
|
|
1166
|
-
{ target: '[data-act=palette]', m: '#
|
|
1167
|
-
{ center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial al
|
|
1268
|
+
{ route: 'ordinadors', target: '#help', m: '#help', title: 'Si alguna cosa no va', text: 'A «Com es fa» tens cada pas explicat, què cal al servidor i què mirar si no connecta.' },
|
|
1269
|
+
{ target: '[data-act=palette]', m: '#tabbar [data-act=more]', title: () => (mq.matches ? 'Més coses' : 'Un últim truc'), text: () => (mq.matches ? 'A «Més» tens els models, el xat local, la pissarra, l’ajuda i aquest tutorial.' : 'Prem ⌘K (o Ctrl+K) per anar a qualsevol lloc o obrir una sessió sense tocar el ratolí.') },
|
|
1270
|
+
{ center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial al menú i l’ajuda a Ordinadors.', next: 'Finalitza' }
|
|
1168
1271
|
];
|
|
1169
1272
|
const tval = (v) => (typeof v === 'function' ? v() : v);
|
|
1170
1273
|
function tourStart() { T.on = true; T.seen = true; tourGo(0); }
|
package/web/app/index.html
CHANGED
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
<nav id="nav" aria-label="Seccions"></nav>
|
|
52
52
|
<div class="foot">
|
|
53
53
|
<div class="conn" id="sideConn"></div>
|
|
54
|
+
<a class="kbd" href="#/ordinadors" data-act="help" title="Com es fa"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.1"/></svg><span class="l">Com es fa</span></a>
|
|
54
55
|
<button class="kbd" data-act="tour" type="button" title="Tutorial"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17.2v.1"/></svg><span class="l">Tutorial</span></button>
|
|
55
56
|
<button class="kbd" data-act="palette" type="button" title="Cerca o fes una acció (⌘K)"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg><span class="l">Cerca o fes una acció</span><kbd>⌘K</kbd></button>
|
|
56
57
|
</div>
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
<div class="tour-spot" id="tourSpot"></div>
|
|
68
69
|
<div class="tour-card" id="tourCard" role="dialog" aria-modal="false" aria-labelledby="tourT"></div>
|
|
69
70
|
</div>
|
|
71
|
+
<nav class="tabbar" id="tabbar" aria-label="Navegació"></nav>
|
|
70
72
|
<div class="toasts" id="toasts" aria-live="polite"></div>
|
|
71
73
|
|
|
72
74
|
<script src="/bot.js"></script>
|
package/web/app/style.css
CHANGED
|
@@ -427,3 +427,45 @@ a.card, a.card:hover { text-decoration: none; color: inherit; }
|
|
|
427
427
|
.copied .tick { stroke-dasharray: 24; stroke-dashoffset: 24; animation: tick .38s var(--ease) forwards; }
|
|
428
428
|
@keyframes tick { to { stroke-dashoffset: 0; } }
|
|
429
429
|
@media (prefers-reduced-motion: reduce) { .copied .tick { animation: none; stroke-dashoffset: 0; } }
|
|
430
|
+
|
|
431
|
+
/* Ordinadors: targetes per nom, estat i accions; passos d'un servidor; barra inferior al mòbil */
|
|
432
|
+
.computers { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; margin-bottom: 8px; }
|
|
433
|
+
.card.computer { display: grid; gap: 14px; cursor: default; }
|
|
434
|
+
.card.computer.active { border-color: var(--fg); box-shadow: inset 0 0 0 1px var(--fg); }
|
|
435
|
+
.c-ico { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; background: var(--bg-2); }
|
|
436
|
+
.c-actions { gap: 8px; }
|
|
437
|
+
.chip.ok { color: var(--moss); }
|
|
438
|
+
.sec { font-size: 18px; font-weight: 500; letter-spacing: -.03em; margin: 28px 0 12px; }
|
|
439
|
+
.steps { list-style: none; display: grid; gap: 6px; font-size: 14px; }
|
|
440
|
+
.steps li { display: flex; gap: 8px; align-items: baseline; color: var(--fg-muted); }
|
|
441
|
+
.steps li::before { content: '✓'; color: var(--moss); font-weight: 700; }
|
|
442
|
+
.steps li.on { color: var(--fg); }
|
|
443
|
+
.steps li.on::before { content: '…'; color: var(--fg-muted); }
|
|
444
|
+
.steps li.err { color: var(--red); }
|
|
445
|
+
.steps li.err::before { content: '✕'; color: var(--red); }
|
|
446
|
+
video.scan { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 14px; background: #000; }
|
|
447
|
+
.tabbar { display: none; }
|
|
448
|
+
@media (max-width: 820px) {
|
|
449
|
+
.burger { display: none; }
|
|
450
|
+
.tabbar { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; padding: 6px 8px calc(6px + var(--safe-b)); background: color-mix(in srgb, var(--bg) 94%, transparent); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); border-top: 1px solid var(--border); }
|
|
451
|
+
.tabbar > * { flex: 1; position: relative; display: grid; justify-items: center; gap: 2px; padding: 6px 0; border: 0; background: none; border-radius: 10px; font-size: 11px; font-weight: 600; color: var(--fg-muted); text-decoration: none; }
|
|
452
|
+
.tabbar [aria-current="page"] { color: var(--fg); }
|
|
453
|
+
.tabbar .badge { position: absolute; top: 2px; left: calc(50% + 6px); min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff; font-size: 10px; line-height: 16px; text-align: center; }
|
|
454
|
+
body:not(.no-tabs):not(.kb) .main { padding-bottom: calc(64px + var(--safe-b)); }
|
|
455
|
+
body.no-tabs .tabbar, body.kb .tabbar { display: none; }
|
|
456
|
+
.computers { grid-template-columns: 1fr; }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/* Ajuda («Com es fa») */
|
|
460
|
+
.help { margin-top: 28px; }
|
|
461
|
+
.help details { border-bottom: 1px solid var(--border); }
|
|
462
|
+
.help details:last-of-type { border-bottom: 0; margin-bottom: 14px; }
|
|
463
|
+
.help summary { cursor: pointer; padding: 12px 0; font-weight: 600; list-style: none; display: flex; justify-content: space-between; gap: 12px; }
|
|
464
|
+
.help summary::-webkit-details-marker { display: none; }
|
|
465
|
+
.help summary::after { content: '+'; color: var(--fg-muted); font-weight: 400; }
|
|
466
|
+
.help details[open] summary::after { content: '−'; }
|
|
467
|
+
.help-body { display: grid; gap: 8px; padding: 0 0 14px; font-size: 14px; color: var(--fg-muted); }
|
|
468
|
+
.help-body ul { padding-left: 18px; display: grid; gap: 4px; }
|
|
469
|
+
.help-body b { color: var(--fg); font-weight: 600; }
|
|
470
|
+
.empty .stepper { text-align: left; max-width: 640px; margin: 16px auto 18px; }
|
|
471
|
+
a.kbd { text-decoration: none; color: inherit; }
|
package/web/index.html
CHANGED
|
@@ -107,9 +107,11 @@ main strong { font-weight: 600; }
|
|
|
107
107
|
|
|
108
108
|
<section aria-labelledby="com">
|
|
109
109
|
<h2 id="com">Com funciona</h2>
|
|
110
|
-
<p>Obre el terminal a la carpeta dels teus projectes i executa:</p>
|
|
110
|
+
<p><strong>1. Al teu ordinador.</strong> Obre el terminal a la carpeta dels teus projectes i executa:</p>
|
|
111
111
|
<div class="cmd"><code id="cmd">npx nio-cat</code><button class="copy" type="button" data-copy="npx nio-cat" aria-label="Copia l’ordre"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg><svg class="ok" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></button></div>
|
|
112
|
-
<p>
|
|
112
|
+
<p>S’obre nio.cat ja connectat. Només cal <a href="https://nodejs.org">Node.js</a>.</p>
|
|
113
|
+
<p><strong>2. Amb un servidor (opcional).</strong> A nio.cat, <em>Ordinadors → Afegeix ordinador → Servidor (SSH)</em>, escrius <code>usuari@servidor</code> i nio hi engega Claude Code i la resta. No cal obrir ports.</p>
|
|
114
|
+
<p><strong>3. Al mòbil.</strong> A l’ordinador, prem <em>Continua al mòbil</em>. Al mòbil, obre <a href="/app">nio.cat/app</a>, escaneja el QR o enganxa el codi, i ja ho controles. Des de qualsevol xarxa amb <code>cloudflared</code>; sense ell, per la mateixa wifi. Cap dada no passa per servidors nostres.</p>
|
|
113
115
|
</section>
|
|
114
116
|
|
|
115
117
|
<section aria-labelledby="limits">
|
package/web/sw.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
|
|
2
|
-
const SHELL = 'nio-shell-
|
|
2
|
+
const SHELL = 'nio-shell-v3';
|
|
3
3
|
const FILES = ['/app', '/app/style.css', '/app/app.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
|
|
4
4
|
|
|
5
5
|
self.addEventListener('install', (e) => {
|