nio-cat 0.2.0 → 0.3.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 +89 -10
- package/package.json +1 -1
- package/src/pair.js +42 -16
- package/src/server.js +30 -8
- package/web/app/app.js +186 -268
- package/web/app/style.css +41 -1
- package/web/index.html +0 -5
package/bin/nio.js
CHANGED
|
@@ -4,13 +4,14 @@ 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');
|
|
7
8
|
const { spawn } = require('child_process');
|
|
8
9
|
const config = require('../src/config');
|
|
9
10
|
const { Hub } = require('../src/hub');
|
|
10
11
|
const { createServer } = require('../src/server');
|
|
11
12
|
const { detectAll } = require('../src/agents');
|
|
12
13
|
const { startTunnel } = require('../src/tunnel');
|
|
13
|
-
const { pairLink, terminalQr, writePairPage } = require('../src/pair');
|
|
14
|
+
const { pairLink, terminalQr, writePairPage, makeCode } = require('../src/pair');
|
|
14
15
|
const { which } = require('../src/proc');
|
|
15
16
|
|
|
16
17
|
const argv = process.argv.slice(2);
|
|
@@ -21,11 +22,11 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
21
22
|
if (a.startsWith('--')) {
|
|
22
23
|
const [k, v] = a.slice(2).split('=');
|
|
23
24
|
if (v !== undefined) flags[k] = v;
|
|
24
|
-
else if (['port', 'url', 'app'].includes(k)) flags[k] = argv[++i];
|
|
25
|
+
else if (['port', 'url', 'app', 'local-port', 'dir'].includes(k)) flags[k] = argv[++i];
|
|
25
26
|
else flags[k] = true;
|
|
26
27
|
} else pos.push(a);
|
|
27
28
|
}
|
|
28
|
-
const cmd = ['start', 'pair', 'doctor', 'reset-token', 'help'].includes(pos[0]) ? pos.shift() : 'start';
|
|
29
|
+
const cmd = ['start', 'pair', 'doctor', 'reset-token', 'help', 'ssh'].includes(pos[0]) ? pos.shift() : 'start';
|
|
29
30
|
|
|
30
31
|
const c = {
|
|
31
32
|
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
@@ -80,13 +81,14 @@ function openDashboard() {
|
|
|
80
81
|
else console.log(` ${c.dim('Panell:')} ${link}`);
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
function showPair(publicUrl) {
|
|
84
|
-
const link = pairLink(flags.app || cfg.app, publicUrl, cfg.token);
|
|
85
|
-
const file = writePairPage(link);
|
|
84
|
+
async function showPair(publicUrl) {
|
|
85
|
+
const link = pairLink(flags.app || cfg.app, publicUrl, cfg.token), code = makeCode(publicUrl, cfg.token);
|
|
86
|
+
const file = writePairPage(link, code);
|
|
86
87
|
console.log(`\n ${c.b('Escaneja aquest codi amb el mòbil')} ${c.dim('(o obre ' + file + ')')}\n`);
|
|
87
88
|
console.log(terminalQr(link));
|
|
88
|
-
console.log(`\n ${c.
|
|
89
|
-
console.log(c.dim('
|
|
89
|
+
console.log(`\n ${c.b('Codi:')} ${code}`);
|
|
90
|
+
console.log(c.dim(' Copia\'l i enganxa\'l a Sessions → Tria un ordinador.'));
|
|
91
|
+
console.log(c.dim(' Aquest codi dona accés als teus agents. Guarda\'l com una contrasenya.\n'));
|
|
90
92
|
if (flags.open) {
|
|
91
93
|
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
92
94
|
try {
|
|
@@ -97,6 +99,75 @@ function showPair(publicUrl) {
|
|
|
97
99
|
}
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
// nio ssh usuari@servidor: engega nio al servidor (si no hi és), obre un túnel SSH i obre el panell ja connectat.
|
|
103
|
+
const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
104
|
+
function remoteScript(port, dir) {
|
|
105
|
+
return `set -e
|
|
106
|
+
command -v node >/dev/null 2>&1 || { echo NIO_NO_NODE; exit 0; }
|
|
107
|
+
up() { (exec 3<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null; }
|
|
108
|
+
if ! up; then
|
|
109
|
+
mkdir -p "$HOME/.nio"
|
|
110
|
+
if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi
|
|
111
|
+
$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 &
|
|
112
|
+
for i in $(seq 1 80); do up && break; sleep 0.5; done
|
|
113
|
+
up || { echo NIO_NO_START; tail -n 5 "$HOME/.nio/daemon.log" 2>/dev/null; exit 0; }
|
|
114
|
+
fi
|
|
115
|
+
node -e "console.log('NIO_TOKEN='+require(require('os').homedir()+'/.nio/config.json').token)"
|
|
116
|
+
`;
|
|
117
|
+
}
|
|
118
|
+
function sshBase(target) {
|
|
119
|
+
const a = ['-o', 'ServerAliveInterval=30'];
|
|
120
|
+
if (process.platform !== 'win32') a.push('-o', 'ControlMaster=auto', '-o', 'ControlPath=/tmp/nio-%C', '-o', 'ControlPersist=10m');
|
|
121
|
+
return a;
|
|
122
|
+
}
|
|
123
|
+
function waitPort(port, ms = 15000) {
|
|
124
|
+
const end = Date.now() + ms;
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
const t = () => {
|
|
127
|
+
const s = net.connect(port, '127.0.0.1');
|
|
128
|
+
s.once('connect', () => { s.destroy(); resolve(true); });
|
|
129
|
+
s.once('error', () => { s.destroy(); Date.now() > end ? resolve(false) : setTimeout(t, 300); });
|
|
130
|
+
};
|
|
131
|
+
t();
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async function sshStart() {
|
|
135
|
+
const target = pos[0];
|
|
136
|
+
if (!target || !/^[A-Za-z0-9_][\w.@:\-\[\]]*$/.test(target)) {
|
|
137
|
+
console.log(`\n Ús: ${c.b('nio ssh usuari@servidor')} ${c.dim('[--port 7717] [--local-port 7718] [--dir /ruta/al/servidor]')}\n`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
const rport = Number(flags.port || 7717), lport = Number(flags['local-port'] || 7718);
|
|
141
|
+
console.log(`\n ${c.b('nio')} ${c.dim('· connectant amb')} ${target}${c.dim('…')}`);
|
|
142
|
+
const out = await new Promise((resolve) => {
|
|
143
|
+
const p = spawn('ssh', [...sshBase(target), '-T', '--', target, 'bash -l -s'], { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
144
|
+
let buf = '';
|
|
145
|
+
p.stdout.on('data', (d) => (buf += d));
|
|
146
|
+
p.on('error', () => resolve({ code: -1, buf }));
|
|
147
|
+
p.on('close', (code) => resolve({ code, buf }));
|
|
148
|
+
p.stdin.end(remoteScript(rport, flags.dir));
|
|
149
|
+
});
|
|
150
|
+
if (out.code === -1) { console.error(c.warn('\n No trobo l’ordre ssh en aquest ordinador.\n')); process.exit(1); }
|
|
151
|
+
if (out.code !== 0 && !out.buf.includes('NIO_TOKEN=')) { console.error(c.warn('\n No he pogut entrar al servidor per SSH.\n')); process.exit(1); }
|
|
152
|
+
if (out.buf.includes('NIO_NO_NODE')) { console.error(c.warn('\n Al servidor falta Node.js 18 o més nou. Instal·la’l i torna-ho a provar.\n')); process.exit(1); }
|
|
153
|
+
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); }
|
|
154
|
+
const token = (out.buf.match(/NIO_TOKEN=(\S+)/) || [])[1];
|
|
155
|
+
if (!token) { console.error(c.warn('\n No he pogut llegir la clau del servidor.\n')); process.exit(1); }
|
|
156
|
+
|
|
157
|
+
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'] });
|
|
158
|
+
tun.on('close', () => { console.log(c.warn('\n S’ha tallat el túnel SSH.\n')); process.exit(0); });
|
|
159
|
+
const bye = () => { tun.kill(); process.exit(0); };
|
|
160
|
+
process.on('SIGINT', bye);
|
|
161
|
+
process.on('SIGTERM', bye);
|
|
162
|
+
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); }
|
|
163
|
+
|
|
164
|
+
const local = `http://127.0.0.1:${lport}`, link = pairLink(local, local, token);
|
|
165
|
+
console.log(` ${c.ok('✓')} Connectat. nio funciona al servidor i el panell és a ${local}`);
|
|
166
|
+
if (canOpenBrowser() && openUrl(link)) console.log(` ${c.dim('Panell obert al navegador.')}`);
|
|
167
|
+
else console.log(` ${c.dim('Obre aquest enllaç:')} ${link}`);
|
|
168
|
+
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'));
|
|
169
|
+
}
|
|
170
|
+
|
|
100
171
|
async function start() {
|
|
101
172
|
if (flags.port) cfg.port = Number(flags.port);
|
|
102
173
|
if (flags.app) cfg.app = flags.app;
|
|
@@ -178,6 +249,9 @@ async function start() {
|
|
|
178
249
|
|
|
179
250
|
(async () => {
|
|
180
251
|
switch (cmd) {
|
|
252
|
+
case 'ssh':
|
|
253
|
+
await sshStart();
|
|
254
|
+
break;
|
|
181
255
|
case 'doctor':
|
|
182
256
|
await doctor();
|
|
183
257
|
break;
|
|
@@ -186,7 +260,7 @@ async function start() {
|
|
|
186
260
|
console.log(' Encara no hi ha cap adreça desada. Engega nio primer.');
|
|
187
261
|
break;
|
|
188
262
|
}
|
|
189
|
-
showPair(flags.url || cfg.lastUrl);
|
|
263
|
+
await showPair(flags.url || cfg.lastUrl);
|
|
190
264
|
break;
|
|
191
265
|
case 'reset-token':
|
|
192
266
|
cfg.token = crypto.randomBytes(24).toString('base64url');
|
|
@@ -207,7 +281,12 @@ async function start() {
|
|
|
207
281
|
--open Obre al navegador la pàgina del codi QR
|
|
208
282
|
nio pair Mostra el codi QR una altra vegada
|
|
209
283
|
|
|
210
|
-
|
|
284
|
+
nio ssh usuari@servidor Fes servir nio en un servidor (VPS) per SSH, sense obrir cap port.
|
|
285
|
+
--port <n> Port de nio al servidor (7717)
|
|
286
|
+
--local-port <n> Port en aquest ordinador (7718)
|
|
287
|
+
--dir <ruta> Carpeta del servidor on s'engega (per defecte, la teva carpeta d'inici)
|
|
288
|
+
|
|
289
|
+
Servidor amb adreça pública: nio --url https://la-teva-adreça --no-open
|
|
211
290
|
Deixa'l sota tmux, pm2 o systemd perquè no s'aturi.
|
|
212
291
|
|
|
213
292
|
nio doctor Llista els agents instal·lats
|
package/package.json
CHANGED
package/src/pair.js
CHANGED
|
@@ -12,6 +12,18 @@ function pairLink(app, url, token) {
|
|
|
12
12
|
return `${app.replace(/\/$/, '')}/app#u=${u}&t=${encodeURIComponent(token)}`;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// Codi de connexió: l'adreça i la clau dins d'una sola cadena («nio-…»). No cal cap servidor.
|
|
16
|
+
// La web ja sap posar-hi l'enllaç de davant. Les adreces de Cloudflare es comprimeixen (≈ 60 caràcters).
|
|
17
|
+
const CF = '.trycloudflare.com';
|
|
18
|
+
function makeCode(url, token) {
|
|
19
|
+
const raw = Buffer.from(token, 'base64url');
|
|
20
|
+
const sub = url.startsWith('https://') && url.endsWith(CF) ? url.slice(8, -CF.length) : null;
|
|
21
|
+
const bytes = raw.length === 24 && raw.toString('base64url') === token && sub && /^[\w-]+$/.test(sub)
|
|
22
|
+
? Buffer.concat([Buffer.from([1]), raw, Buffer.from(sub)])
|
|
23
|
+
: Buffer.concat([Buffer.from([0]), Buffer.from(`${url}\n${token}`)]);
|
|
24
|
+
return `nio-${bytes.toString('base64url')}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
function qr(text) {
|
|
16
28
|
const q = qrcode(0, 'L');
|
|
17
29
|
q.addData(text);
|
|
@@ -19,34 +31,49 @@ function qr(text) {
|
|
|
19
31
|
return q;
|
|
20
32
|
}
|
|
21
33
|
|
|
22
|
-
// QR per al terminal:
|
|
23
|
-
//
|
|
34
|
+
// QR per al terminal: cada mòdul són dos espais amb color de fons (negre o blanc). No depèn de cap glif,
|
|
35
|
+
// així que no es talla amb tipus de lletra o interlineats estranys. Sempre negre sobre blanc, amb tema clar o fosc.
|
|
24
36
|
function terminalQr(text, { color = !process.env.NO_COLOR } = {}) {
|
|
25
37
|
const q = qr(text);
|
|
26
38
|
const n = q.getModuleCount();
|
|
27
39
|
const M = 2; // marge blanc al voltant, en mòduls
|
|
28
40
|
const dark = (r, c) => r >= 0 && c >= 0 && r < n && c < n && q.isDark(r, c);
|
|
29
41
|
const lines = [];
|
|
30
|
-
for (let r = -M; r < n + M; r
|
|
31
|
-
let line = '';
|
|
42
|
+
for (let r = -M; r < n + M; r++) {
|
|
43
|
+
let line = '', prev = null;
|
|
32
44
|
for (let c = -M; c < n + M; c++) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
45
|
+
const d = dark(r, c);
|
|
46
|
+
if (color) {
|
|
47
|
+
if (d !== prev) line += d ? '\x1b[40m' : '\x1b[107m';
|
|
48
|
+
line += ' ';
|
|
49
|
+
} else line += d ? ' ' : '██';
|
|
50
|
+
prev = d;
|
|
37
51
|
}
|
|
38
|
-
lines.push(
|
|
52
|
+
lines.push(` ${line}${color ? '\x1b[0m' : ''}`);
|
|
39
53
|
}
|
|
40
54
|
return lines.join('\n');
|
|
41
55
|
}
|
|
42
56
|
|
|
57
|
+
// SVG propi: una sola forma amb tires horitzontals de mòduls, vora de 4 mòduls i vores nítides.
|
|
58
|
+
// (El de la llibreria dibuixa cada mòdul per separat i, en escalar-lo, deixa línies fines entre mòduls.)
|
|
43
59
|
function qrSvg(text) {
|
|
44
|
-
|
|
60
|
+
const q = qr(text), n = q.getModuleCount(), M = 4;
|
|
61
|
+
let d = '';
|
|
62
|
+
for (let r = 0; r < n; r++) {
|
|
63
|
+
for (let c = 0; c < n; c++) {
|
|
64
|
+
if (!q.isDark(r, c)) continue;
|
|
65
|
+
let w = 1;
|
|
66
|
+
while (c + w < n && q.isDark(r, c + w)) w++;
|
|
67
|
+
d += `M${c + M} ${r + M}h${w}v1h-${w}z`;
|
|
68
|
+
c += w - 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const size = n + M * 2;
|
|
72
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" shape-rendering="crispEdges" role="img" aria-label="Codi QR"><rect width="${size}" height="${size}" fill="#fff"/><path d="${d}" fill="#000"/></svg>`;
|
|
45
73
|
}
|
|
46
74
|
|
|
47
|
-
function writePairPage(link) {
|
|
48
|
-
const
|
|
49
|
-
const svg = q.createSvgTag({ cellSize: 6, margin: 4, scalable: true });
|
|
75
|
+
function writePairPage(link, code) {
|
|
76
|
+
const svg = qrSvg(link);
|
|
50
77
|
const html = `<!doctype html><html lang="ca"><head><meta charset="utf-8">
|
|
51
78
|
<meta name="viewport" content="width=device-width,initial-scale=1"><title>nio // connecta el mòbil</title>
|
|
52
79
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600&display=swap" rel="stylesheet">
|
|
@@ -55,11 +82,10 @@ main{width:min(420px,100% - 40px);padding:40px 0}h1{font-size:30px;font-weight:5
|
|
|
55
82
|
.qr{background:#fffefd;border:1px solid #cec7bd;padding:18px;border-radius:16px;margin:24px 0}.qr svg{width:100%;height:auto;display:block}
|
|
56
83
|
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>
|
|
57
84
|
<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>
|
|
58
|
-
<p>
|
|
59
|
-
<small>Aquest enllaç dona accés als teus agents. Guarda’l com una contrasenya.</small></main></body></html>`;
|
|
85
|
+
<p>O copia aquest codi i enganxa'l a <b>Sessions → Tria un ordinador</b>:</p><code>${code}</code><small>Aquest codi dona accés als teus agents. Guarda'l com una contrasenya.</small></main></body></html>`;
|
|
60
86
|
const file = path.join(HOME, 'pair.html');
|
|
61
87
|
fs.writeFileSync(file, html, { mode: 0o600 });
|
|
62
88
|
return file;
|
|
63
89
|
}
|
|
64
90
|
|
|
65
|
-
module.exports = { pairLink, terminalQr, writePairPage, qrSvg };
|
|
91
|
+
module.exports = { pairLink, terminalQr, writePairPage, qrSvg, makeCode };
|
package/src/server.js
CHANGED
|
@@ -10,7 +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 { pairLink, qrSvg } = require('./pair');
|
|
13
|
+
const { pairLink, qrSvg, makeCode } = require('./pair');
|
|
14
14
|
|
|
15
15
|
// L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
|
|
16
16
|
const WEB_DIRS = [path.join(__dirname, '..', 'web'), path.join(__dirname, '..', '..')];
|
|
@@ -108,9 +108,32 @@ function listProjects(cfg) {
|
|
|
108
108
|
return out;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
// Qualsevol carpeta existent d'aquest ordinador. Qui té la clau ja pot engegar agents, així que no la limitem a les compartides.
|
|
112
|
+
function isDir(p) {
|
|
113
|
+
try {
|
|
114
|
+
return fs.statSync(path.resolve(p)).isDirectory();
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Subcarpetes d'una carpeta, per a l'explorador de «Nova sessió».
|
|
121
|
+
function listDir(cfg, raw) {
|
|
122
|
+
const dir = path.resolve(raw || cfg.roots[0] || os.homedir());
|
|
123
|
+
if (!isDir(dir)) throw httpErr(400, 'Aquesta carpeta no existeix');
|
|
124
|
+
let entries = [];
|
|
125
|
+
try {
|
|
126
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
127
|
+
} catch {
|
|
128
|
+
throw httpErr(403, 'No tinc permís per veure aquesta carpeta');
|
|
129
|
+
}
|
|
130
|
+
const dirs = entries
|
|
131
|
+
.filter((e) => (e.isDirectory() || (e.isSymbolicLink() && isDir(path.join(dir, e.name)))) && e.name !== 'node_modules')
|
|
132
|
+
.map((e) => ({ name: e.name, path: path.join(dir, e.name), hidden: e.name.startsWith('.') }))
|
|
133
|
+
.sort((a, b) => a.hidden - b.hidden || a.name.localeCompare(b.name, 'ca'))
|
|
134
|
+
.slice(0, 500);
|
|
135
|
+
const parent = path.dirname(dir);
|
|
136
|
+
return { path: dir, parent: parent === dir ? null : parent, home: os.homedir(), git: fs.existsSync(path.join(dir, '.git')), dirs };
|
|
114
137
|
}
|
|
115
138
|
|
|
116
139
|
// Noms de model d'Ollama: «qwen3:4b», «hf.co/usuari/model:Q4_K_M»…
|
|
@@ -149,6 +172,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
149
172
|
};
|
|
150
173
|
}
|
|
151
174
|
if (p === '/api/projects' && method === 'GET') return listProjects(cfg);
|
|
175
|
+
if (p === '/api/fs' && method === 'GET') return listDir(cfg, url.searchParams.get('path'));
|
|
152
176
|
if (p === '/api/ollama/models' && method === 'GET') return (await ollama.listModels()) || [];
|
|
153
177
|
if (p === '/api/ollama' && method === 'GET') {
|
|
154
178
|
const models = await ollama.listModels();
|
|
@@ -208,7 +232,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
208
232
|
const pub = getPublicUrl();
|
|
209
233
|
if (!pub || /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/i.test(pub)) return { link: null };
|
|
210
234
|
const link = pairLink(getApp ? getApp() : cfg.app, pub, cfg.token);
|
|
211
|
-
return { link, svg: qrSvg(link), url: pub, lan: /^http:\/\//.test(pub) };
|
|
235
|
+
return { link, code: makeCode(pub, cfg.token), svg: qrSvg(link), url: pub, lan: /^http:\/\//.test(pub) };
|
|
212
236
|
}
|
|
213
237
|
if (p === '/api/github' && method === 'GET') return github.status();
|
|
214
238
|
if (p === '/api/github/repos' && method === 'GET') return github.repos(url.searchParams.get('owner') || '');
|
|
@@ -222,8 +246,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
222
246
|
if (p === '/api/sessions' && method === 'POST') {
|
|
223
247
|
const b = await readBody(req);
|
|
224
248
|
const cwd = path.resolve(b.cwd || cfg.roots[0]);
|
|
225
|
-
if (!
|
|
226
|
-
if (!fs.existsSync(cwd)) throw httpErr(400, 'Aquesta carpeta no existeix');
|
|
249
|
+
if (!isDir(cwd)) throw httpErr(400, 'Aquesta carpeta no existeix');
|
|
227
250
|
const s = hub.create({ agent: b.agent, cwd, title: b.title, mode: b.mode, model: b.model });
|
|
228
251
|
if (b.text) hub.send(s.id, b.text, hookUrl());
|
|
229
252
|
return hub.summary(s);
|
|
@@ -245,7 +268,6 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
245
268
|
if ((r = m(/^\/api\/sessions\/([\w-]+)\/git(?:\/(commit|pr))?$/))) {
|
|
246
269
|
const s = hub.get(r[1]);
|
|
247
270
|
if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
|
|
248
|
-
if (!insideRoots(cfg, s.cwd)) throw httpErr(403, 'Aquesta carpeta no és de les compartides');
|
|
249
271
|
if (!r[2] && method === 'GET') return github.gitInfo(s.cwd);
|
|
250
272
|
if (r[2] === 'commit' && method === 'POST') return github.commit(s.cwd, await readBody(req));
|
|
251
273
|
if (r[2] === 'pr' && method === 'POST') return github.pr(s.cwd, await readBody(req));
|
package/web/app/app.js
CHANGED
|
@@ -36,7 +36,6 @@ const MODES = {
|
|
|
36
36
|
preguntar: { label: 'Demana permís', short: 'Permís' },
|
|
37
37
|
tot: { label: 'Via lliure', short: 'Lliure' }
|
|
38
38
|
};
|
|
39
|
-
const DEMO_URL = 'demo:navegador';
|
|
40
39
|
|
|
41
40
|
/* ---------------------------------------------------------------- estat */
|
|
42
41
|
const state = { route: { name: 'sessions' }, ollama: null, info: null, connected: false, lastError: null, sessions: new Map(), approvals: new Map(), events: new Map(), loaded: new Set() };
|
|
@@ -45,13 +44,12 @@ const LS = {
|
|
|
45
44
|
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* emmagatzematge bloquejat */ } }
|
|
46
45
|
};
|
|
47
46
|
const store = {
|
|
48
|
-
get machines() { return LS.get('nio.machines', []); },
|
|
47
|
+
get machines() { return LS.get('nio.machines', []).filter((m) => m.url !== 'demo:navegador'); },
|
|
49
48
|
set machines(v) { LS.set('nio.machines', v); },
|
|
50
49
|
get active() { return Number(LS.get('nio.active', 0)) || 0; },
|
|
51
50
|
set active(i) { LS.set('nio.active', i); }
|
|
52
51
|
};
|
|
53
52
|
const machine = () => store.machines[store.active] || store.machines[0] || null;
|
|
54
|
-
const isDemo = () => machine()?.url === DEMO_URL;
|
|
55
53
|
|
|
56
54
|
/* ---------------------------------------------------------------- utilitats */
|
|
57
55
|
function toast(msg, ms = 2800) {
|
|
@@ -102,174 +100,23 @@ function prettyInput(input) {
|
|
|
102
100
|
return JSON.stringify(input, null, 2);
|
|
103
101
|
}
|
|
104
102
|
|
|
105
|
-
/* ---------------------------------------------------------------- ordinador de prova (dins del navegador) */
|
|
106
|
-
const demo = (() => {
|
|
107
|
-
const listeners = new Set();
|
|
108
|
-
const sessions = new Map(), approvals = new Map(), notices = [];
|
|
109
|
-
const runs = new Map();
|
|
110
|
-
const omodels = [{ name: 'qwen3:4b', size: 2.5e9, params: '4.0B', quant: 'Q4_K_M' }], opulls = new Map();
|
|
111
|
-
const emit = (msg) => listeners.forEach((f) => f(JSON.parse(JSON.stringify(msg))));
|
|
112
|
-
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
113
|
-
const summary = (s) => {
|
|
114
|
-
const last = [...s.events].reverse().find((e) => e.kind === 'text' || e.kind === 'user');
|
|
115
|
-
return { id: s.id, agent: s.agent, title: s.title, cwd: s.cwd, mode: s.mode, model: s.model, state: s.state, created: s.created, updated: s.updated, cost: 0, last: last ? last.text.slice(0, 160) : '', pending: [...approvals.values()].filter((a) => a.session === s.id && a.state === 'pending').length };
|
|
116
|
-
};
|
|
117
|
-
const touch = (s) => { s.updated = Date.now(); emit({ type: 'session', session: summary(s) }); };
|
|
118
|
-
const push = (s, kind, data = {}) => {
|
|
119
|
-
const lastEv = s.events[s.events.length - 1];
|
|
120
|
-
if (lastEv && lastEv.open) delete lastEv.open;
|
|
121
|
-
const ev = { seq: ++s.seq, t: Date.now(), kind, ...data }; s.events.push(ev);
|
|
122
|
-
emit({ type: 'event', session: s.id, event: ev }); touch(s); return ev;
|
|
123
|
-
};
|
|
124
|
-
const chunk = (s, text) => {
|
|
125
|
-
const last = s.events[s.events.length - 1];
|
|
126
|
-
if (last && last.kind === 'text' && last.open) { last.text += text; emit({ type: 'event', session: s.id, event: last }); return; }
|
|
127
|
-
const ev = push(s, 'text', { text }); ev.open = true;
|
|
128
|
-
};
|
|
129
|
-
const pub = (a) => { const { resolve, ...r } = a; return r; };
|
|
130
|
-
function ask(s, tool, input) {
|
|
131
|
-
const a = { id: rid(), session: s.id, agent: s.agent, title: s.title, tool, input, state: 'pending', created: Date.now() };
|
|
132
|
-
const p = new Promise((res) => (a.resolve = res));
|
|
133
|
-
approvals.set(a.id, a); s.state = 'waiting';
|
|
134
|
-
push(s, 'approval', { approval: a.id, tool, input, state: 'pending' });
|
|
135
|
-
emit({ type: 'approval', approval: pub(a) });
|
|
136
|
-
notices.unshift({ id: rid(4), t: Date.now(), title: 'Demo et demana permís', body: `${tool}: ${input.command || ''}`, session: s.id });
|
|
137
|
-
return p;
|
|
138
|
-
}
|
|
139
|
-
function decide(id, decision) {
|
|
140
|
-
const a = approvals.get(id); if (!a || a.state !== 'pending') return null;
|
|
141
|
-
a.state = decision === 'allow' ? 'allowed' : 'denied';
|
|
142
|
-
const s = sessions.get(a.session);
|
|
143
|
-
if (s) { const ev = s.events.find((e) => e.kind === 'approval' && e.approval === id); if (ev) { ev.state = a.state; emit({ type: 'event', session: s.id, event: ev }); } s.state = runs.has(s.id) ? 'running' : 'idle'; touch(s); }
|
|
144
|
-
emit({ type: 'approval', approval: pub(a) }); a.resolve(a.state === 'allowed' ? 'allow' : 'deny');
|
|
145
|
-
return pub(a);
|
|
146
|
-
}
|
|
147
|
-
async function run(s, prompt) {
|
|
148
|
-
let stopped = false; runs.set(s.id, { stop: () => (stopped = true) });
|
|
149
|
-
if (s.agent === 'ollama') {
|
|
150
|
-
try {
|
|
151
|
-
for (const w of `(Resposta simulada de ${s.model || 'qwen3:4b'}.) Amb Ollama de debò, aquí veuries la resposta del model que funciona al teu ordinador, paraula per paraula.`.split(' ')) { if (stopped) throw 0; chunk(s, w + ' '); await sleep(45); }
|
|
152
|
-
push(s, 'result', { ms: 1800 });
|
|
153
|
-
} catch { push(s, 'info', { text: 'Has aturat l’agent' }); }
|
|
154
|
-
runs.delete(s.id); s.state = 'idle'; push(s, 'status', { state: 'idle', code: 0 }); return;
|
|
155
|
-
}
|
|
156
|
-
try {
|
|
157
|
-
for (const w of `Entesos. Faig una ullada al projecte per a «${prompt.slice(0, 80)}».`.split(' ')) { if (stopped) throw 0; chunk(s, w + ' '); await sleep(55); }
|
|
158
|
-
push(s, 'tool', { id: 't1', name: 'Read', input: { file_path: 'README.md' } }); await sleep(450);
|
|
159
|
-
push(s, 'tool_result', { id: 't1', name: 'Read', output: '# Projecte de prova\nAixò és un README d’exemple.' });
|
|
160
|
-
if (stopped) throw 0;
|
|
161
|
-
const input = { command: 'npm test' };
|
|
162
|
-
if (s.mode === 'llegir') push(s, 'text', { text: 'Treballo en mode només lectura, així que no executo res. El següent pas seria `npm test`.' });
|
|
163
|
-
else {
|
|
164
|
-
const ok = s.mode === 'tot' ? true : (await ask(s, 'Bash', input)) === 'allow';
|
|
165
|
-
if (stopped) throw 0;
|
|
166
|
-
push(s, 'tool', { id: 't2', name: 'Bash', input }); await sleep(380);
|
|
167
|
-
if (ok) { push(s, 'tool_result', { id: 't2', name: 'Bash', output: '✓ 12 tests passen' }); push(s, 'text', { text: 'Fet. Tots els tests passen.' }); }
|
|
168
|
-
else { push(s, 'tool_result', { id: 't2', name: 'Bash', output: 'Rebutjat des de nio', error: true }); push(s, 'text', { text: 'D’acord, no l’executo. Què vols que faci?' }); }
|
|
169
|
-
}
|
|
170
|
-
push(s, 'result', { cost: 0, ms: 2400 });
|
|
171
|
-
runs.delete(s.id); s.state = 'idle'; push(s, 'status', { state: 'idle', code: 0 });
|
|
172
|
-
} catch {
|
|
173
|
-
runs.delete(s.id); s.state = 'idle'; push(s, 'info', { text: 'Has aturat l’agent' }); push(s, 'status', { state: 'idle', code: 130 });
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
const info = () => ({ name: 'nio', version: 'prova', host: 'Ordinador de prova', platform: 'navegador', user: 'tu', publicUrl: DEMO_URL, roots: ['~/projectes'], vapidKey: null,
|
|
177
|
-
agents: [
|
|
178
|
-
{ id: 'demo', name: 'Demo', available: true, version: 'simulat', approvals: true, login: 'No cal instal·lar res' },
|
|
179
|
-
{ id: 'claude', name: 'Claude Code', available: false, approvals: true, login: 'Instal·la nio al teu ordinador' },
|
|
180
|
-
{ id: 'codex', name: 'Codex', available: false, login: 'Instal·la nio al teu ordinador' },
|
|
181
|
-
{ id: 'grok', name: 'Grok', available: false, beta: true, login: 'Instal·la nio al teu ordinador' },
|
|
182
|
-
{ id: 'antigravity', name: 'Antigravity', available: false, beta: true, login: 'Instal·la nio al teu ordinador' }
|
|
183
|
-
] });
|
|
184
|
-
async function request(method, path, body) {
|
|
185
|
-
await sleep(60);
|
|
186
|
-
const url = new URL(path, 'http://x'), p = url.pathname; let m;
|
|
187
|
-
if (p === '/api/info') return info();
|
|
188
|
-
if (p === '/api/projects') return [{ path: '~/projectes', name: 'projectes', root: true }, { path: '~/projectes/botiga', name: 'botiga', git: true }, { path: '~/projectes/web', name: 'web', git: true }];
|
|
189
|
-
if (p === '/api/ollama/models') return omodels;
|
|
190
|
-
if (p === '/api/ollama/chat') {
|
|
191
|
-
// Sense model de veritat: proposa un mapa amb les paraules clau de la petició.
|
|
192
|
-
const ask = String(body.messages?.at(-1)?.content || '').split('Petició de l’usuari:').pop().trim();
|
|
193
|
-
const words = [...new Set(ask.replace(/[^\p{L}\p{N}\s-]/gu, ' ').split(/\s+/).filter((w) => w.length > 3))].slice(0, 7);
|
|
194
|
-
await sleep(700);
|
|
195
|
-
return { content: JSON.stringify({ reply: 'Model simulat: amb Ollama de debò entendria la petició sencera. T’he fet un esquema amb les paraules clau.', actions: words.length > 1 ? [{ op: 'mindmap', center: words[0], items: words.slice(1) }] : [] }) };
|
|
196
|
-
}
|
|
197
|
-
if (p === '/api/github') return { available: false, authed: false };
|
|
198
|
-
if (/^\/api\/sessions\/[\w-]+\/git$/.test(p)) return { repo: false };
|
|
199
|
-
if (p === '/api/pair') return { link: null };
|
|
200
|
-
if (p === '/api/ollama') return { available: false, host: 'simulat', models: [], running: [], pulling: [] };
|
|
201
|
-
if (p === '/api/ollama/pull' && method === 'POST') {
|
|
202
|
-
const name = String(body.name || '').trim(); if (!/^[\w][\w.\-/]{0,100}(:[\w.\-]{1,60})?$/.test(name)) throw err(400, 'Aquest nom de model no és vàlid');
|
|
203
|
-
if (opulls.has(name) || omodels.some((m) => m.name === name)) return { ok: true, already: true };
|
|
204
|
-
const total = 1.2e9 + Math.random() * 3e9, job = { name, status: 'pulling manifest', completed: 0, total };
|
|
205
|
-
job.t = setInterval(() => {
|
|
206
|
-
job.completed = Math.min(total, job.completed + total / 18); job.status = job.completed < total ? 'pulling' : 'success';
|
|
207
|
-
emit({ type: 'ollama', name, status: job.status, completed: job.completed, total });
|
|
208
|
-
if (job.completed >= total) { clearInterval(job.t); opulls.delete(name); omodels.push({ name, size: total, params: '', quant: 'Q4_K_M' }); emit({ type: 'ollama', name, done: true }); }
|
|
209
|
-
}, 260);
|
|
210
|
-
opulls.set(name, job); return { ok: true };
|
|
211
|
-
}
|
|
212
|
-
if ((m = p.match(/^\/api\/ollama\/pull\/(.+)$/)) && method === 'DELETE') { const j = opulls.get(decodeURIComponent(m[1])); if (j) { clearInterval(j.t); opulls.delete(j.name); emit({ type: 'ollama', name: j.name, done: true, error: 'Descàrrega cancel·lada' }); } return { ok: !!j }; }
|
|
213
|
-
if ((m = p.match(/^\/api\/ollama\/models\/(.+)$/)) && method === 'DELETE') { const n = decodeURIComponent(m[1]); const i = omodels.findIndex((x) => x.name === n); if (i >= 0) omodels.splice(i, 1); return { ok: true }; }
|
|
214
|
-
if (p === '/api/sessions' && method === 'GET') return [...sessions.values()].sort((a, b) => b.updated - a.updated).map(summary);
|
|
215
|
-
if (p === '/api/sessions' && method === 'POST') {
|
|
216
|
-
if (body.agent !== 'demo' && body.agent !== 'ollama') throw err(400, 'A l’ordinador de prova només hi ha els agents Demo i Ollama');
|
|
217
|
-
const s = { id: rid(), agent: body.agent, cwd: body.cwd || '~/projectes', title: (body.text || '').replace(/\s+/g, ' ').slice(0, 60), mode: body.mode || 'preguntar', model: body.model || '', state: 'idle', created: Date.now(), updated: Date.now(), seq: 0, events: [] };
|
|
218
|
-
sessions.set(s.id, s); touch(s);
|
|
219
|
-
if (body.text) send(s.id, body.text);
|
|
220
|
-
return summary(s);
|
|
221
|
-
}
|
|
222
|
-
if ((m = p.match(/^\/api\/sessions\/([\w-]+)$/))) {
|
|
223
|
-
const s = sessions.get(m[1]); if (!s) throw err(404, 'Aquesta sessió no existeix');
|
|
224
|
-
if (method === 'GET') return { session: summary(s), events: s.events.filter((e) => e.seq > Number(url.searchParams.get('since') || 0)) };
|
|
225
|
-
if (method === 'PATCH') { if (body.mode) s.mode = body.mode; if (typeof body.title === 'string') s.title = body.title.slice(0, 120); touch(s); return summary(s); }
|
|
226
|
-
if (method === 'DELETE') { runs.get(s.id)?.stop(); sessions.delete(s.id); emit({ type: 'removed', session: s.id }); return { ok: true }; }
|
|
227
|
-
}
|
|
228
|
-
if ((m = p.match(/^\/api\/sessions\/([\w-]+)\/message$/))) return send(m[1], body.text);
|
|
229
|
-
if ((m = p.match(/^\/api\/sessions\/([\w-]+)\/stop$/))) { const r = runs.get(m[1]); r?.stop(); return { stopped: !!r }; }
|
|
230
|
-
if (p === '/api/approvals') return [...approvals.values()].filter((a) => a.state === 'pending').map(pub);
|
|
231
|
-
if ((m = p.match(/^\/api\/approvals\/([\w-]+)$/))) { const a = decide(m[1], body.decision); if (!a) throw err(404, 'Aquesta petició ja s’ha resolt'); return a; }
|
|
232
|
-
if (p === '/api/notices') return notices.slice(0, 5);
|
|
233
|
-
if (p.startsWith('/api/push')) throw err(400, 'Els avisos només funcionen amb un ordinador de veritat');
|
|
234
|
-
throw err(404, 'Aquesta ruta no existeix');
|
|
235
|
-
}
|
|
236
|
-
function send(sid, text) {
|
|
237
|
-
const s = sessions.get(sid); if (!s) throw err(404, 'Aquesta sessió no existeix');
|
|
238
|
-
if (runs.has(sid)) throw err(409, 'L’agent encara està treballant. Espera que acabi o atura’l.');
|
|
239
|
-
text = String(text || '').trim(); if (!text) throw err(400, 'El missatge és buit');
|
|
240
|
-
if (!s.title) s.title = text.slice(0, 60);
|
|
241
|
-
push(s, 'user', { text }); s.state = 'running'; push(s, 'status', { state: 'running' });
|
|
242
|
-
run(s, text); return summary(s);
|
|
243
|
-
}
|
|
244
|
-
function subscribe(fn) {
|
|
245
|
-
listeners.add(fn);
|
|
246
|
-
fn({ type: 'hello', sessions: [...sessions.values()].map(summary), approvals: [...approvals.values()].filter((a) => a.state === 'pending').map(pub) });
|
|
247
|
-
return () => listeners.delete(fn);
|
|
248
|
-
}
|
|
249
|
-
return { request, subscribe };
|
|
250
|
-
})();
|
|
251
|
-
|
|
252
103
|
/* ---------------------------------------------------------------- API */
|
|
253
104
|
async function api(path, opts = {}) {
|
|
254
105
|
const m = machine();
|
|
255
106
|
if (!m) throw new Error('No hi ha cap ordinador connectat');
|
|
256
107
|
const body = opts.body && typeof opts.body !== 'string' ? opts.body : opts.body ? JSON.parse(opts.body) : undefined;
|
|
257
|
-
if (m.url === DEMO_URL) return demo.request(opts.method || 'GET', path, body || {});
|
|
258
108
|
const res = await fetch(m.url + path, { ...opts, headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json', ...(opts.headers || {}) }, body: body ? JSON.stringify(body) : undefined });
|
|
259
109
|
const data = await res.json().catch(() => ({}));
|
|
260
110
|
if (!res.ok) throw new Error(data.error || `Error ${res.status}`);
|
|
111
|
+
if (Array.isArray(data.agents)) data.agents = data.agents.filter((x) => x.id !== 'demo'); // l'agent de proves del programa no s'ensenya
|
|
261
112
|
return data;
|
|
262
113
|
}
|
|
263
114
|
|
|
264
|
-
let streamCtrl = null,
|
|
115
|
+
let streamCtrl = null, retry = 0;
|
|
265
116
|
async function connect() {
|
|
266
|
-
streamCtrl?.abort();
|
|
117
|
+
streamCtrl?.abort();
|
|
267
118
|
const m = machine();
|
|
268
119
|
if (!m) { state.connected = false; return render(); }
|
|
269
|
-
if (m.url === DEMO_URL) {
|
|
270
|
-
state.info = await api('/api/info'); state.connected = true; state.lastError = null;
|
|
271
|
-
unsubDemo = demo.subscribe(handle); render(); brainsForBoard(); return;
|
|
272
|
-
}
|
|
273
120
|
const ctrl = new AbortController(); streamCtrl = ctrl;
|
|
274
121
|
try {
|
|
275
122
|
state.info = await api('/api/info');
|
|
@@ -344,14 +191,26 @@ function parsePair(text) {
|
|
|
344
191
|
if (!url || !token) return null;
|
|
345
192
|
return { url: url.replace(/\/$/, ''), token };
|
|
346
193
|
}
|
|
194
|
+
// Codi «nio-…»: porta l'adreça i la clau dins (vegeu makeCode al programa). Es descodifica aquí, sense servidor.
|
|
195
|
+
const CF = '.trycloudflare.com';
|
|
196
|
+
function parseCode(text) {
|
|
197
|
+
const m = String(text || '').replace(/\s+/g, '').match(/^nio-([\w-]+)$/i); if (!m) return null;
|
|
198
|
+
try {
|
|
199
|
+
const bin = atob(m[1].replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (m[1].length % 4)) % 4)), u = Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
200
|
+
if (u[0] === 1) return { url: `https://${new TextDecoder().decode(u.slice(25))}${CF}`, token: btoa(String.fromCharCode(...u.slice(1, 25))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') };
|
|
201
|
+
if (u[0] === 0) { const [url, token] = new TextDecoder().decode(u.slice(1)).split('\n'); if (url && token) return { url: url.replace(/\/$/, ''), token }; }
|
|
202
|
+
} catch { /* codi malmès */ }
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
// Un codi o un enllaç de connexió (#u=…&t=…).
|
|
206
|
+
const pairFrom = (text) => parsePair(text) || parseCode(text);
|
|
347
207
|
function addMachine({ url, token, name }) {
|
|
348
208
|
const ms = store.machines; let i = ms.findIndex((m) => m.url === url);
|
|
349
|
-
if (i >= 0) ms[i].token = token; else { ms.push({ url, token, name: name ||
|
|
209
|
+
if (i >= 0) ms[i].token = token; else { ms.push({ url, token, name: name || new URL(url).hostname }); i = ms.length - 1; }
|
|
350
210
|
store.machines = ms; store.active = i;
|
|
351
211
|
state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.info = null; state.ollama = null;
|
|
352
212
|
connect();
|
|
353
213
|
}
|
|
354
|
-
function tryDemo() { addMachine({ url: DEMO_URL, token: 'demo', name: 'Ordinador de prova' }); toast('Connectat a l’ordinador de prova'); go('#/sessions'); }
|
|
355
214
|
function checkPairHash() {
|
|
356
215
|
const pair = parsePair(location.hash); if (!pair) return false;
|
|
357
216
|
history.replaceState(null, '', location.pathname + '#/sessions');
|
|
@@ -368,6 +227,11 @@ const VIEWS = {
|
|
|
368
227
|
mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
|
|
369
228
|
pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' }
|
|
370
229
|
};
|
|
230
|
+
const NOMACHINE = {
|
|
231
|
+
approvals: { title: 'Aprovacions', lead: 'Quan un agent vol editar un fitxer o executar una ordre, t’ho demana aquí.' },
|
|
232
|
+
models: { title: 'Models', lead: 'Models d’Ollama que funcionen a l’ordinador, sense internet i sense comptes.' },
|
|
233
|
+
maquina: { title: 'Ordinador', lead: 'Els agents instal·lats, els avisos al mòbil i els ordinadors connectats.' }
|
|
234
|
+
};
|
|
371
235
|
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', mobil: '#/mobil', models: '#/models', maquina: '#/ordinador', pissarra: '#/pissarra' };
|
|
372
236
|
function parseRoute() {
|
|
373
237
|
const h = location.hash.replace(/^#\/?/, '');
|
|
@@ -417,7 +281,7 @@ function render() {
|
|
|
417
281
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
418
282
|
else {
|
|
419
283
|
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
420
|
-
view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html :
|
|
284
|
+
view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'sessions' ? (machine() ? viewSessions() : viewConnect()) : !machine() ? viewNoMachine(NOMACHINE[r.name]) : r.name === 'approvals' ? viewApprovals() : r.name === 'models' ? viewModels() : viewMachine()}</div>`;
|
|
421
285
|
if (r.name === 'mobil') bindLocal();
|
|
422
286
|
if (r.name === 'models' && machine()) loadOllama();
|
|
423
287
|
}
|
|
@@ -427,29 +291,46 @@ function render() {
|
|
|
427
291
|
}
|
|
428
292
|
|
|
429
293
|
/* ---------- Connecta */
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
294
|
+
// La connexió viu només a Sessions. Les altres pàgines, sense ordinador, hi envien.
|
|
295
|
+
const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
|
|
296
|
+
function viewNoMachine(what) {
|
|
297
|
+
return `<div class="top"><div><h1>${esc(what.title)}</h1><p>${esc(what.lead)}</p></div></div>
|
|
298
|
+
<div class="panel empty">${bot('sleep')}<h2>Sembla que no hi ha cap sessió iniciada</h2><p>Crea’n una de nova a la pàgina Sessions. Allà connectes l’ordinador i hi dones feina.</p><a class="btn ink" href="#/sessions">Ves a Sessions</a></div>`;
|
|
299
|
+
}
|
|
300
|
+
function pasteForm(open) {
|
|
301
|
+
return `<details class="paste" ${open ? 'open' : ''}><summary>Tens un codi?</summary>
|
|
302
|
+
<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>
|
|
303
|
+
<p class="muted small">Surt al terminal, sota el codi QR. Copia’l sencer.</p></details>`;
|
|
304
|
+
}
|
|
305
|
+
function viewConnect() {
|
|
306
|
+
const CMD = 'npx nio-cat', 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>`;
|
|
307
|
+
if (isPhone()) {
|
|
308
|
+
return `<section class="connect">
|
|
309
|
+
<div class="connect-head"><h1>Tria un ordinador</h1><p class="lead">Els agents treballen al teu ordinador, amb els teus comptes i els teus fitxers. Des del mòbil els dones feina i aproves el que facin.</p></div>
|
|
310
|
+
<div class="panel conn-main">
|
|
311
|
+
<ol class="stepper">
|
|
312
|
+
<li><span class="n">1</span><div><b>Engega nio a l’ordinador</b><p>Al terminal, des de la carpeta dels teus projectes.</p></div></li>
|
|
313
|
+
<li><span class="n">2</span><div><b>Escaneja el QR</b><p>Amb la càmera del mòbil. S’obrirà nio connectat.</p></div></li>
|
|
314
|
+
</ol>
|
|
315
|
+
${cmd(CMD)}
|
|
316
|
+
<p class="muted small">Per connectar-t’hi des de qualsevol xarxa cal <span class="mono">cloudflared</span>. Sense ell, el mòbil ha d’estar a la mateixa wifi.</p>
|
|
317
|
+
${pasteForm(false)}
|
|
318
|
+
</div>
|
|
319
|
+
</section>`;
|
|
320
|
+
}
|
|
447
321
|
return `<section class="connect">
|
|
448
|
-
<div class="connect-head"><h1>
|
|
449
|
-
<div class="
|
|
450
|
-
|
|
451
|
-
|
|
322
|
+
<div class="connect-head"><h1>Tria un ordinador</h1><p class="lead">Els agents treballen a l’ordinador, amb els teus comptes i els teus fitxers. Des d’aquí els dones feina, els veus treballar i aproves el que facin.</p></div>
|
|
323
|
+
<div class="choice-grid conn-main">
|
|
324
|
+
<section class="panel choice"><header><h2>Aquest ordinador</h2></header><div class="body stack">
|
|
325
|
+
<p class="small">Al terminal, des de la carpeta dels teus projectes:</p>${cmd(CMD)}
|
|
326
|
+
<p class="muted small">S’obre aquest panell ja connectat. Cal Node.js 18 o més nou.</p></div></section>
|
|
327
|
+
<section class="panel choice"><header><h2>Un altre ordinador</h2></header><div class="body stack">
|
|
328
|
+
<p class="small">Engega nio a l’altre ordinador o a un servidor. Copia aquí el codi que mostra.</p>
|
|
329
|
+
${pasteForm(true)}
|
|
330
|
+
<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')}
|
|
331
|
+
<p class="muted small">Engega nio al servidor, obre el túnel i el panell queda connectat.</p></div></section>
|
|
452
332
|
</div>
|
|
333
|
+
<p class="muted small" style="margin-top:14px">Després, al mòbil, tria l’ordinador i escaneja el codi QR que mostra el panell.</p>
|
|
453
334
|
</section>`;
|
|
454
335
|
}
|
|
455
336
|
|
|
@@ -648,8 +529,8 @@ function renderComposer() {
|
|
|
648
529
|
}
|
|
649
530
|
|
|
650
531
|
/* ---------- Fulls */
|
|
651
|
-
function openSheet(html, onMount) {
|
|
652
|
-
const d = $('#sheet'); $('#sheetIn').innerHTML = `<div class="grab"></div>${html}`;
|
|
532
|
+
function openSheet(html, onMount, cls) {
|
|
533
|
+
const d = $('#sheet'); d.classList.toggle('wiz', cls === 'wiz'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls === 'wiz' ? '' : `<div class="grab"></div>${html}`;
|
|
653
534
|
if (!d.open) d.showModal(); if (T.on) $('#tour').hidden = true;
|
|
654
535
|
onMount?.($('#sheetIn'));
|
|
655
536
|
}
|
|
@@ -664,85 +545,122 @@ function modeHelp(agentId, mode) {
|
|
|
664
545
|
if (agentId === 'codex') return 'Pot editar dins la carpeta, sense accés a internet. Codex no pot fer preguntes en aquest mode.';
|
|
665
546
|
return 'Aquest agent encara no pot demanar permís: les accions que el necessitarien es rebutgen.';
|
|
666
547
|
}
|
|
548
|
+
// Assistent de 4 passos: agent, carpeta exacta, opcions i tasca. Capçalera i peu fixos; només el cos fa scroll.
|
|
667
549
|
async function newSessionSheet(preset = {}) {
|
|
668
550
|
if (!machine()) return go('#/sessions');
|
|
669
551
|
if (!state.connected) return toast('Encara no hi ha connexió amb l’ordinador');
|
|
670
552
|
const agents = [...(state.info?.agents || [])].sort((x, y) => Number(!!y.available) - Number(!!x.available));
|
|
671
553
|
let projects = [];
|
|
672
554
|
try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
|
|
673
|
-
const first =
|
|
674
|
-
const sel = { agent: first?.id || '
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
$('#
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
$('#
|
|
687
|
-
$$('#modePick button', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.mode === sel.mode));
|
|
688
|
-
$('#modeHelp', sh).textContent = modeHelp(sel.agent, sel.mode);
|
|
555
|
+
const first = agents.find((a) => a.available);
|
|
556
|
+
const sel = { agent: first?.id || '', cwd: projects.find((p) => !p.root)?.path || projects[0]?.path || '', mode: 'preguntar', model: '', text: '', ...preset };
|
|
557
|
+
const STEPS = ['Agent', 'Carpeta', 'Opcions', 'Tasca'];
|
|
558
|
+
let step = preset.agent ? 1 : 0, fsd = null, gh = null, models = null;
|
|
559
|
+
const agentOf = () => agents.find((a) => a.id === sel.agent);
|
|
560
|
+
|
|
561
|
+
openSheet('', (sh) => {
|
|
562
|
+
const body = () => { $('#wizBody', sh).innerHTML = VIEWS_[step](); after(); };
|
|
563
|
+
const head = () => {
|
|
564
|
+
const last = step === STEPS.length - 1;
|
|
565
|
+
$('#wizHead', sh).innerHTML = `<div class="grab"></div><div class="row between" style="flex-wrap:nowrap"><h2>Nova sessió</h2><button class="icon-btn" type="button" data-close aria-label="Tanca">×</button></div>
|
|
566
|
+
<div class="wiz-dots">${STEPS.map((n, i) => `<button type="button" class="wiz-dot ${i === step ? 'on' : i < step ? 'done' : ''}" data-step="${i}" ${i < step ? '' : 'disabled'} aria-label="${i + 1}. ${n}"></button>`).join('')}</div>
|
|
567
|
+
<p class="muted small">Pas ${step + 1} de ${STEPS.length} · ${STEPS[step]}</p>`;
|
|
568
|
+
$('#wizFoot', sh).innerHTML = `<button class="btn ghost" type="button" ${step ? 'data-back' : 'data-close'}>${step ? 'Enrere' : 'Cancel·la'}</button><button class="btn ink" type="button" ${last ? 'data-start' : 'data-next'}>${last ? 'Comença' : 'Continua'}</button>`;
|
|
689
569
|
};
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
if (!sel.model) sel.model = models[0]?.name || '';
|
|
695
|
-
w.innerHTML = `<label class="f"><span>Model</span><select id="modelSel">${models.map((m) => `<option ${m.name === sel.model ? 'selected' : ''}>${esc(m.name)}</option>`).join('') || '<option value="">Cap model (ollama pull qwen3:4b)</option>'}</select></label>`;
|
|
696
|
-
$('#modelSel', sh).onchange = (e) => (sel.model = e.target.value);
|
|
697
|
-
} else if (sel.agent === 'demo') w.innerHTML = '';
|
|
698
|
-
else {
|
|
699
|
-
w.innerHTML = '<label class="f"><span>Model <span class="muted">(opcional)</span></span><input type="text" id="modelIn" placeholder="El que l’agent faci servir per defecte" autocapitalize="off" spellcheck="false"></label>';
|
|
700
|
-
$('#modelIn', sh).value = sel.model; $('#modelIn', sh).oninput = (e) => (sel.model = e.target.value.trim());
|
|
701
|
-
}
|
|
570
|
+
const draw = () => {
|
|
571
|
+
if (!$('#wizBody', sh)) sh.innerHTML = '<div class="wiz-head" id="wizHead"></div><div class="wiz-body" id="wizBody"></div><div class="wiz-foot" id="wizFoot"></div>';
|
|
572
|
+
head(); body();
|
|
573
|
+
$('#wizBody', sh).scrollTop = 0;
|
|
702
574
|
};
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
w.innerHTML = list.map((r) => `<button type="button" class="proj" data-repo="${esc(r.repo)}"><span class="ic">git</span><span class="grow"><div>${esc(r.repo)}${r.private ? ' <span class="chip beta">privat</span>' : ''}</div>${r.description ? `<div class="p">${esc(r.description)}</div>` : ''}</span></button>`).join('') || '<p class="muted small">No hi ha cap repositori amb aquest nom.</p>';
|
|
575
|
+
const after = () => {
|
|
576
|
+
if (step === 1 && !fsd) fsGo(sel.cwd);
|
|
577
|
+
if (step === 2 && sel.agent === 'ollama' && models == null) api('/api/ollama/models').catch(() => []).then((m) => { models = m; if (!sel.model) sel.model = m[0]?.name || ''; if (step === 2) body(); });
|
|
578
|
+
if (step === 3 && !isTouch) setTimeout(() => $('#firstMsg', sh)?.focus(), 60);
|
|
708
579
|
};
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
if (
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
580
|
+
|
|
581
|
+
/* --- carpeta: la carpeta oberta és la triada */
|
|
582
|
+
const fsGo = async (dir) => {
|
|
583
|
+
try { fsd = await api(`/api/fs?path=${encodeURIComponent(dir || '')}`); sel.cwd = fsd.path; gh = null; }
|
|
584
|
+
catch (e) { toast(e.message, 4200); if (!fsd) fsd = { path: sel.cwd, parent: null, home: '', dirs: [] }; }
|
|
585
|
+
if (step === 1) body();
|
|
586
|
+
};
|
|
587
|
+
const ghList = () => {
|
|
588
|
+
const q = (gh.q || '').trim().toLowerCase(), list = (gh.repos || []).filter((r) => !q || r.repo.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)).slice(0, 40);
|
|
589
|
+
return list.map((r) => `<button type="button" class="proj" data-repo="${esc(r.repo)}"><span class="ic">git</span><span class="grow"><div>${esc(r.repo)}${r.private ? ' <span class="chip beta">privat</span>' : ''}</div>${r.description ? `<div class="p">${esc(r.description)}</div>` : ''}</span></button>`).join('') || '<p class="muted small">No hi ha cap repositori amb aquest nom.</p>';
|
|
590
|
+
};
|
|
591
|
+
const ghStart = async () => {
|
|
592
|
+
gh = { msg: 'Connectant amb GitHub…', repos: null, q: '' }; body();
|
|
593
|
+
let st; try { st = await api('/api/github'); } catch (e) { gh.msg = esc(e.message); return body(); }
|
|
594
|
+
if (!st.available) gh.msg = 'No trobo l’eina <span class="mono">gh</span> en aquest ordinador. Instal·la-la des de <a href="https://cli.github.com" target="_blank" rel="noopener">cli.github.com</a> i inicia sessió amb <span class="mono">gh auth login</span>.';
|
|
595
|
+
else if (!st.authed) gh.msg = 'Falta iniciar sessió a GitHub. Al terminal de l’ordinador, executa <span class="mono">gh auth login</span>.';
|
|
596
|
+
else { gh.user = st.user; gh.msg = 'Carregant repositoris…'; body(); try { gh.repos = await api('/api/github/repos'); gh.msg = ''; } catch (e) { gh.msg = esc(e.message); } }
|
|
597
|
+
if (gh && step === 1) body();
|
|
718
598
|
};
|
|
719
|
-
$('#ghOpen', sh).onclick = ghOpen;
|
|
720
599
|
const ghClone = async (repo, btn) => {
|
|
721
600
|
btn.disabled = true; btn.querySelector('.p, div')?.insertAdjacentHTML('beforeend', ' <span class="muted">· clonant…</span>');
|
|
722
|
-
try {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
601
|
+
try { const r = await api('/api/github/clone', { method: 'POST', body: { repo } }); toast(r.existed ? 'Ja el tenies. L’he seleccionat.' : 'Repositori clonat'); await fsGo(r.path); }
|
|
602
|
+
catch (e) { btn.disabled = false; toast(e.message, 6000); }
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const VIEWS_ = [
|
|
606
|
+
() => `<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>`,
|
|
607
|
+
() => {
|
|
608
|
+
const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
|
|
609
|
+
const quick = projects.slice(0, 8).map((p) => `<button type="button" class="chip" data-fs="${esc(p.path)}">${esc(p.name)}</button>`).join('');
|
|
610
|
+
return `<p class="wiz-q">A quina carpeta ha de treballar?</p>
|
|
611
|
+
<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>
|
|
612
|
+
<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>
|
|
613
|
+
<div class="row"><button class="chip" type="button" data-gh>${gh ? 'Torna a les carpetes' : 'Clona de GitHub'}</button>${quick}</div>
|
|
614
|
+
${gh ? (gh.msg ? `<p class="muted small">${gh.msg}</p>` : '') + (gh.repos ? `<input type="search" id="ghQ" value="${esc(gh.q)}" placeholder="Cerca entre els teus repositoris${gh.user ? ' (' + esc(gh.user) + ')' : ''}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Cerca un repositori"><div class="proj-list fs-list" id="ghList">${ghList()}</div>` : '')
|
|
615
|
+
: !fsd ? '<p class="muted small">Carregant…</p>' : `<div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(fsd.parent || '')}" ${fsd.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><button class="chip" type="button" data-fs="${esc(fsd.home)}">Inici</button><span class="fs-path mono small" title="${esc(fsd.path)}">${esc(crumbs.slice(-2).join(' / ') || '/')}</span></div>
|
|
616
|
+
<div class="proj-list fs-list">${fsd.dirs.map((d) => `<button type="button" class="proj" data-fs="${esc(d.path)}"><span class="ic">›</span><span class="grow"><div ${d.hidden ? 'class="muted"' : ''}>${esc(d.name)}</div></span></button>`).join('') || '<p class="muted small">No hi ha subcarpetes. Pots triar aquesta.</p>'}</div>`}`;
|
|
617
|
+
},
|
|
618
|
+
() => {
|
|
619
|
+
const ag = agentOf();
|
|
620
|
+
const modelHtml = sel.agent === 'ollama'
|
|
621
|
+
? `<label class="f"><span>Model</span><select id="modelSel">${(models || []).map((m) => `<option ${m.name === sel.model ? 'selected' : ''}>${esc(m.name)}</option>`).join('') || `<option value="">${models == null ? 'Carregant…' : 'Cap model (ollama pull qwen3:4b)'}</option>`}</select></label>`
|
|
622
|
+
: `<label class="f"><span>Model <span class="muted">(opcional)</span></span><input type="text" id="modelIn" value="${esc(sel.model)}" placeholder="El que l’agent faci servir per defecte" autocapitalize="off" spellcheck="false"></label>`;
|
|
623
|
+
return `<p class="wiz-q">Quins permisos té?</p>${ag?.noModes ? '<p class="muted small">Aquest agent no té modes de permisos.</p>' : `<div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-mode="${k}" aria-pressed="${k === sel.mode}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(sel.agent, k))}</div></button>`).join('')}</div>`}${modelHtml}`;
|
|
624
|
+
},
|
|
625
|
+
() => `<p class="wiz-q">Què ha de fer?</p>
|
|
626
|
+
<dl class="kv"><dt>Agent</dt><dd>${esc(AGENT_NAMES[sel.agent] || sel.agent)}</dd><dt>Carpeta</dt><dd class="mono">${esc(sel.cwd)}</dd>${agentOf()?.noModes ? '' : `<dt>Permisos</dt><dd>${esc(MODES[sel.mode].label)}</dd>`}${sel.model ? `<dt>Model</dt><dd class="mono">${esc(sel.model)}</dd>` : ''}</dl>
|
|
627
|
+
<label class="f"><span>Explica-li la tasca</span><textarea class="field" id="firstMsg" rows="5" placeholder="Per exemple: troba per què fallen els tests i arregla-ho">${esc(sel.text)}</textarea></label>`
|
|
628
|
+
];
|
|
629
|
+
|
|
630
|
+
const start = async () => {
|
|
631
|
+
if (!sel.text.trim()) { $('#firstMsg', sh)?.focus(); return toast('Explica a l’agent què ha de fer'); }
|
|
632
|
+
const btn = $('[data-start]', sh); btn.disabled = true;
|
|
633
|
+
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}`); }
|
|
634
|
+
catch (e) { toast(e.message, 4200); btn.disabled = false; }
|
|
727
635
|
};
|
|
728
636
|
sh.onclick = (e) => {
|
|
729
|
-
const
|
|
730
|
-
if (
|
|
731
|
-
|
|
732
|
-
if (
|
|
733
|
-
|
|
734
|
-
|
|
637
|
+
const t = e.target;
|
|
638
|
+
if (t.closest('[data-close]')) return closeSheet();
|
|
639
|
+
if (t.closest('[data-back]')) { step = Math.max(0, step - 1); return draw(); }
|
|
640
|
+
if (t.closest('[data-next]')) {
|
|
641
|
+
if (step === 0 && !sel.agent) return toast('Tria un agent');
|
|
642
|
+
if (step === 1 && !sel.cwd) return toast('Tria una carpeta');
|
|
643
|
+
step++; return draw();
|
|
644
|
+
}
|
|
645
|
+
if (t.closest('[data-start]')) return start();
|
|
646
|
+
const st = t.closest('[data-step]'); if (st && !st.disabled) { step = +st.dataset.step; return draw(); }
|
|
647
|
+
const ag = t.closest('[data-agent-id]');
|
|
648
|
+
if (ag) { const a = agents.find((x) => x.id === ag.dataset.agentId); if (!a.available) return toast(`Primer instal·la’l a l’ordinador: ${a.login}`, 4200); if (a.id !== sel.agent) { sel.agent = a.id; sel.model = ''; models = null; } step = 1; return draw(); }
|
|
649
|
+
const fp = t.closest('[data-fs]'); if (fp && !fp.disabled) return fsGo(fp.dataset.fs);
|
|
650
|
+
if (t.closest('[data-gh]')) { if (gh) { gh = null; return body(); } return ghStart(); }
|
|
651
|
+
const rp = t.closest('[data-repo]'); if (rp) return ghClone(rp.dataset.repo, rp);
|
|
652
|
+
const mo = t.closest('[data-mode]'); if (mo) { sel.mode = mo.dataset.mode; return body(); }
|
|
735
653
|
};
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
const
|
|
739
|
-
if (
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
catch (e) { toast(e.message, 4200); $('#startBtn', sh).disabled = false; }
|
|
654
|
+
sh.onsubmit = (e) => { e.preventDefault(); if (e.target.id === 'fsForm') fsGo($('#fsIn', sh).value.trim()); };
|
|
655
|
+
sh.oninput = (e) => {
|
|
656
|
+
const id = e.target.id;
|
|
657
|
+
if (id === 'firstMsg') sel.text = e.target.value;
|
|
658
|
+
else if (id === 'modelIn') sel.model = e.target.value.trim();
|
|
659
|
+
else if (id === 'ghQ' && gh) { gh.q = e.target.value; $('#ghList', sh).innerHTML = ghList(); }
|
|
743
660
|
};
|
|
744
|
-
|
|
745
|
-
|
|
661
|
+
sh.onchange = (e) => { if (e.target.id === 'modelSel') sel.model = e.target.value; };
|
|
662
|
+
draw();
|
|
663
|
+
}, 'wiz');
|
|
746
664
|
}
|
|
747
665
|
function modeSheet(s) {
|
|
748
666
|
openSheet(`<h2>Permisos</h2><div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-m="${k}" style="${k === s.mode ? 'border-color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)' : ''}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(s.agent, k))}</div></button>`).join('')}</div><p class="muted small">El canvi s’aplica a partir del pròxim missatge.</p>`, (sh) => {
|
|
@@ -753,7 +671,6 @@ function modeSheet(s) {
|
|
|
753
671
|
});
|
|
754
672
|
}
|
|
755
673
|
/* ---------- Ordinadors i mòbil */
|
|
756
|
-
const isRealMachine = () => { const m = machine(); return !!m && m.url !== DEMO_URL; };
|
|
757
674
|
const isLocalMachine = () => { const m = machine(); try { return !!m && ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(m.url).hostname); } catch { return false; } };
|
|
758
675
|
function mobileHint() {
|
|
759
676
|
if (!isLocalMachine() || !state.connected || LS.get('nio.mobHint.off', false)) return '';
|
|
@@ -761,7 +678,7 @@ function mobileHint() {
|
|
|
761
678
|
}
|
|
762
679
|
function machinesSheet() {
|
|
763
680
|
const ms = store.machines;
|
|
764
|
-
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
|
|
681
|
+
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('')}
|
|
765
682
|
<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; });
|
|
766
683
|
}
|
|
767
684
|
function addMachineSheet() {
|
|
@@ -769,8 +686,9 @@ function addMachineSheet() {
|
|
|
769
686
|
openSheet(`<h2>Connecta un altre ordinador</h2>
|
|
770
687
|
<div class="stack-lg">
|
|
771
688
|
<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>
|
|
772
|
-
<div class="stack"><b>Un servidor (VPS)</b><p class="muted small">
|
|
773
|
-
<
|
|
689
|
+
<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>
|
|
690
|
+
<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>
|
|
691
|
+
<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>
|
|
774
692
|
</div>`);
|
|
775
693
|
}
|
|
776
694
|
let qrTimer = 0;
|
|
@@ -785,7 +703,7 @@ function mobileQrSheet() {
|
|
|
785
703
|
if (n < 8) qrTimer = setTimeout(() => paint(n + 1), 2000);
|
|
786
704
|
return;
|
|
787
705
|
}
|
|
788
|
-
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><button class="btn" type="button" data-copy="${esc(r.
|
|
706
|
+
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">Si no pots escanejar, enganxa el codi a Sessions → Tria un ordinador.</p>`;
|
|
789
707
|
};
|
|
790
708
|
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; });
|
|
791
709
|
paint();
|
|
@@ -826,7 +744,7 @@ function sessionOptions(id) {
|
|
|
826
744
|
openSheet(`<h2>${esc(s.title || 'Sessió')}</h2>
|
|
827
745
|
<dl class="kv"><dt>Agent</dt><dd>${esc(AGENT_NAMES[s.agent] || s.agent)}</dd><dt>Carpeta</dt><dd class="mono">${esc(s.cwd)}</dd><dt>Permisos</dt><dd>${esc(MODES[s.mode]?.label || '')}</dd><dt>Creada</dt><dd>${new Date(s.created).toLocaleString('ca')}</dd></dl>
|
|
828
746
|
<label class="f"><span>Nom de la sessió</span><input type="text" id="renameIn" value="${esc(s.title)}" maxlength="120"></label>
|
|
829
|
-
|
|
747
|
+
<button class="btn" data-act="git" type="button">Git i GitHub…</button>
|
|
830
748
|
<div class="row between"><button class="btn danger" id="delBtn" type="button">Esborra la sessió</button><button class="btn ink" id="renameBtn" type="button">Desa</button></div>`, (sh) => {
|
|
831
749
|
$('#renameBtn', sh).onclick = async () => { const t = $('#renameIn', sh).value.trim(); const sum = await api(`/api/sessions/${id}`, { method: 'PATCH', body: { title: t } }).catch((e) => toast(e.message)); if (sum) state.sessions.set(id, sum); closeSheet(); renderSessionHead(); };
|
|
832
750
|
$('#delBtn', sh).onclick = async () => { if (!confirm('S’esborrarà la conversa d’aquesta sessió. Els fitxers del projecte no es toquen.')) return; await api(`/api/sessions/${id}`, { method: 'DELETE' }).catch((e) => toast(e.message)); closeSheet(); go('#/sessions'); };
|
|
@@ -836,8 +754,7 @@ function sessionOptions(id) {
|
|
|
836
754
|
/* ---------- Ordinador */
|
|
837
755
|
function viewMachine() {
|
|
838
756
|
const m = machine(), info = state.info, ms = store.machines;
|
|
839
|
-
|
|
840
|
-
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><button class="btn" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar els agents</button></div>
|
|
757
|
+
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>
|
|
841
758
|
<div class="grid2">
|
|
842
759
|
<div class="stack">
|
|
843
760
|
<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>
|
|
@@ -845,9 +762,9 @@ function viewMachine() {
|
|
|
845
762
|
</div>
|
|
846
763
|
<div class="stack">
|
|
847
764
|
<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>
|
|
848
|
-
${
|
|
849
|
-
<section class="panel"><header><h2>Detalls</h2></header><div class="body"><dl class="kv"><dt>Adreça</dt><dd class="mono">${esc(m.url
|
|
850
|
-
<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
|
|
765
|
+
${'<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>'}
|
|
766
|
+
<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>
|
|
767
|
+
<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('')}
|
|
851
768
|
<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>
|
|
852
769
|
</div>
|
|
853
770
|
</div>`;
|
|
@@ -939,7 +856,7 @@ function onOllama(msg) {
|
|
|
939
856
|
window.NioBrains = {
|
|
940
857
|
list() {
|
|
941
858
|
const out = [{ id: 'rules', label: 'Ordres ràpides' }];
|
|
942
|
-
if (machine() && state.connected) (state.ollama?.models || []).forEach((m) => out.push({ id: 'ollama:' + m.name, label: `Ollama · ${m.name}
|
|
859
|
+
if (machine() && state.connected) (state.ollama?.models || []).forEach((m) => out.push({ id: 'ollama:' + m.name, label: `Ollama · ${m.name}` }));
|
|
943
860
|
if (local.engine) out.push({ id: 'local', label: `Xat local · ${LOCAL_MODELS.find((x) => x.id === local.model)?.name || local.model}` });
|
|
944
861
|
return out;
|
|
945
862
|
},
|
|
@@ -966,7 +883,6 @@ async function brainsForBoard() {
|
|
|
966
883
|
function b64uToU8(s) { const b = atob((s + '='.repeat((4 - (s.length % 4)) % 4)).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from(b, (c) => c.charCodeAt(0)); }
|
|
967
884
|
async function enablePush() {
|
|
968
885
|
try {
|
|
969
|
-
if (isDemo()) throw new Error('Els avisos només funcionen amb un ordinador de veritat');
|
|
970
886
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) throw new Error(isIOS ? 'Afegeix nio a la pantalla d’inici i obre’l des d’allà' : 'Aquest navegador no admet avisos');
|
|
971
887
|
if ((await Notification.requestPermission()) !== 'granted') throw new Error('Cal que permetis les notificacions');
|
|
972
888
|
const reg = await navigator.serviceWorker.ready;
|
|
@@ -976,7 +892,7 @@ async function enablePush() {
|
|
|
976
892
|
} catch (e) { toast(e.message, 4200); }
|
|
977
893
|
}
|
|
978
894
|
async function syncServiceWorker() {
|
|
979
|
-
const m = machine(); if (!m ||
|
|
895
|
+
const m = machine(); if (!m || !('serviceWorker' in navigator)) return;
|
|
980
896
|
try { (await navigator.serviceWorker.ready).active?.postMessage({ type: 'nio-cfg', url: m.url, token: m.token }); } catch { /* sense service worker */ }
|
|
981
897
|
}
|
|
982
898
|
|
|
@@ -1085,7 +1001,6 @@ document.addEventListener('click', async (e) => {
|
|
|
1085
1001
|
case 'ollama-cancel': try { await api(`/api/ollama/pull/${encodeURIComponent(a.dataset.name)}`, { method: 'DELETE' }); } catch (err) { toast(err.message); } return;
|
|
1086
1002
|
case 'ollama-del': return deleteModel(a.dataset.name);
|
|
1087
1003
|
case 'ollama-chat': try { state.info = await api('/api/info?refresh=1'); } catch { /* res */ } return newSessionSheet({ agent: 'ollama', model: a.dataset.name });
|
|
1088
|
-
case 'demo': return tryDemo();
|
|
1089
1004
|
case 'retry': return connect();
|
|
1090
1005
|
case 'mode': { const s = state.sessions.get(state.route.id); if (s) modeSheet(s); return; }
|
|
1091
1006
|
case 'session-more': return sessionOptions(state.route.id);
|
|
@@ -1100,7 +1015,7 @@ document.addEventListener('click', async (e) => {
|
|
|
1100
1015
|
case 'forget': {
|
|
1101
1016
|
if (!confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) return;
|
|
1102
1017
|
const list = store.machines; list.splice(store.active, 1); store.machines = list; store.active = 0;
|
|
1103
|
-
streamCtrl?.abort();
|
|
1018
|
+
streamCtrl?.abort(); state.connected = false; state.info = null; state.sessions.clear(); state.approvals.clear();
|
|
1104
1019
|
connect(); return render();
|
|
1105
1020
|
}
|
|
1106
1021
|
case 'lm-load': return loadLocal(local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id));
|
|
@@ -1115,8 +1030,8 @@ document.addEventListener('submit', (e) => {
|
|
|
1115
1030
|
if (e.target.id === 'pullForm') { e.preventDefault(); return pullModel($('#pullIn').value); }
|
|
1116
1031
|
if (e.target.id !== 'pairForm') return;
|
|
1117
1032
|
e.preventDefault();
|
|
1118
|
-
const pair =
|
|
1119
|
-
if (!pair) return toast('Aquest
|
|
1033
|
+
const pair = pairFrom($('#pairInput', e.target).value);
|
|
1034
|
+
if (!pair) return toast('Aquest codi no és de connexió. Copia’l sencer del terminal.');
|
|
1120
1035
|
closeSheet(); addMachine(pair); toast('Connectant…'); go('#/sessions');
|
|
1121
1036
|
});
|
|
1122
1037
|
|
|
@@ -1126,7 +1041,6 @@ function commands() {
|
|
|
1126
1041
|
const c = Object.entries(VIEWS).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
|
|
1127
1042
|
if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
|
|
1128
1043
|
[...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}`) }));
|
|
1129
|
-
if (!isDemo()) c.push({ label: 'Prova l’ordinador simulat', hint: 'Prova', run: tryDemo });
|
|
1130
1044
|
c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
|
|
1131
1045
|
return c;
|
|
1132
1046
|
}
|
|
@@ -1202,8 +1116,7 @@ const T = Object.assign({ on: false, i: 0, seen: false }, LS.get(TOUR_KEY, {}));
|
|
|
1202
1116
|
const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
|
|
1203
1117
|
const TSTEPS = [
|
|
1204
1118
|
{ 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 on és cada cosa.', next: 'Comencem' },
|
|
1205
|
-
{ route: 'sessions', target: () => (machine() ? '.top [data-act=new]' : '.conn-main'), m: () => (machine() ? '.top [data-act=new]' : '.conn-main .cmd'), title: () => (machine() ? 'Dona feina a un agent' : 'Primer, connecta l’ordinador'), text: () => (machine() ? 'Cada sessió és una conversa amb un agent dins d’una carpeta. Tria l’agent, els permisos i explica-li què ha de fer.' : 'Executa aquesta ordre al terminal de l’ordinador i escaneja el codi QR que apareix.
|
|
1206
|
-
{ route: 'sessions', target: () => (machine() ? null : '.try'), m: () => (machine() ? null : '.try [data-act=demo]'), skipIf: () => !!machine(), title: 'Prova-ho sense instal·lar res', text: 'L’ordinador simulat té un agent de demostració que llegeix, et demana permís i executa. Ideal per veure com funciona.' },
|
|
1119
|
+
{ route: 'sessions', target: () => (machine() ? '.top [data-act=new]' : '.conn-main'), m: () => (machine() ? '.top [data-act=new]' : '.conn-main .cmd'), title: () => (machine() ? 'Dona feina a un agent' : 'Primer, connecta l’ordinador'), text: () => (machine() ? 'Cada sessió és una conversa amb un agent dins d’una carpeta. Tria l’agent, els permisos i explica-li què ha de fer.' : 'Executa aquesta ordre al terminal de l’ordinador i escaneja el codi QR que apareix.') },
|
|
1207
1120
|
{ route: 'approvals', target: '.side nav a[href="#/aprovacions"]', m: '#mRight .icon-btn', title: '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.' },
|
|
1208
1121
|
{ route: 'mobil', target: '.side nav a[href="#/mobil"]', 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.' },
|
|
1209
1122
|
{ route: 'models', target: '.side nav a[href="#/models"]', m: '.page .top h1, .connect-head h1', title: 'Models al teu ordinador', text: 'Si tens Ollama, aquí descarregues models, veus quins tens i obres sessions amb el que vulguis. Res no surt de casa.' },
|
|
@@ -1305,12 +1218,17 @@ if ('serviceWorker' in navigator) {
|
|
|
1305
1218
|
navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'open-session' && e.data.id) go(`#/s/${e.data.id}`); });
|
|
1306
1219
|
}
|
|
1307
1220
|
addEventListener('hashchange', route);
|
|
1308
|
-
document.addEventListener('visibilitychange', () => { if (!document.hidden && machine() && !state.connected
|
|
1221
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden && machine() && !state.connected) connect(); });
|
|
1309
1222
|
setInterval(() => { if (!document.hidden && state.route.name === 'sessions') render(); }, 60000);
|
|
1310
1223
|
const qs = new URLSearchParams(location.search).get('s');
|
|
1311
1224
|
if (qs) history.replaceState(null, '', location.pathname + `#/s/${qs}`);
|
|
1312
1225
|
// Les dades del panell antic (simulació d'agents d'operacions) ja no es fan servir.
|
|
1313
1226
|
try { localStorage.removeItem('nio.panell.v1'); localStorage.removeItem('nio.tour.v1'); } catch { /* res */ }
|
|
1227
|
+
// A la còpia local (127.0.0.1, localhost) la icona té el fons groc suau, per distingir-la de www.nio.cat.
|
|
1228
|
+
if (['127.0.0.1', 'localhost', '[::1]'].includes(location.hostname)) {
|
|
1229
|
+
const ico = document.querySelector('link[rel="icon"]');
|
|
1230
|
+
if (ico) ico.href = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#f6e9a0"/><circle cx="16" cy="16" r="10.2" fill="none" stroke="#272224" stroke-width="3.6"/><rect x="11.9" y="12.6" width="2.6" height="6" rx="1.3" fill="#272224"/><rect x="17.5" y="12.6" width="2.6" height="6" rx="1.3" fill="#272224"/></svg>');
|
|
1231
|
+
}
|
|
1314
1232
|
setNav(false);
|
|
1315
1233
|
if (!checkPairHash()) state.route = parseRoute();
|
|
1316
1234
|
if (machine()) connect();
|
package/web/app/style.css
CHANGED
|
@@ -148,7 +148,7 @@ button.chip:hover { border-color: var(--fg); }
|
|
|
148
148
|
.connect { display: grid; gap: 24px; min-width: 0; }
|
|
149
149
|
.connect-head h1 { font-size: clamp(30px, 4vw, 40px); font-weight: 500; letter-spacing: -.05em; line-height: 1.08; }
|
|
150
150
|
.connect-head .lead { color: var(--fg-muted); font-size: 16px; max-width: 58ch; margin-top: 10px; }
|
|
151
|
-
.connect-grid { display: grid; grid-template-columns: minmax(0,
|
|
151
|
+
.connect-grid { display: grid; grid-template-columns: minmax(0, 720px); gap: 16px; align-items: start; }
|
|
152
152
|
.conn-main { padding: 22px; display: grid; gap: 16px; min-width: 0; }
|
|
153
153
|
.stepper { list-style: none; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
|
154
154
|
.stepper li { display: flex; gap: 10px; align-items: flex-start; }
|
|
@@ -379,3 +379,43 @@ body.board-mode .view-enter { animation: none; }
|
|
|
379
379
|
.qr-box { width: min(260px, 100%); margin: 0 auto; padding: 14px; border-radius: 16px; background: #fffefd; border: 1px solid var(--border); }
|
|
380
380
|
.qr-box svg { display: block; width: 100%; height: auto; }
|
|
381
381
|
#ghWrap input[type="search"] { margin-bottom: 4px; }
|
|
382
|
+
|
|
383
|
+
/* Connexió: dues opcions a l'ordinador; explorador de carpetes a Nova sessió */
|
|
384
|
+
.choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: start; }
|
|
385
|
+
.choice .body { min-width: 0; }
|
|
386
|
+
@media (max-width: 820px) { .choice-grid { grid-template-columns: 1fr; } }
|
|
387
|
+
.fs-bar { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
388
|
+
.fs-path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
|
|
389
|
+
.fs-list { max-height: 240px; overflow: auto; }
|
|
390
|
+
|
|
391
|
+
/* Assistent «Nova sessió»: drawer fix al telèfon, capçalera i peu fixos, cos amb scroll */
|
|
392
|
+
body:has(dialog.sheet[open]) { overflow: hidden; }
|
|
393
|
+
dialog.sheet { overscroll-behavior: contain; }
|
|
394
|
+
dialog.sheet.wiz { height: min(680px, 88dvh); overflow: hidden; }
|
|
395
|
+
dialog.sheet.wiz[open] { display: flex; flex-direction: column; }
|
|
396
|
+
dialog.sheet.wiz .sheet-in { padding: 0; gap: 0; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
397
|
+
.wiz-head { flex: none; display: grid; gap: 8px; padding: 20px 24px 12px; }
|
|
398
|
+
.wiz-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 6px 24px 18px; display: grid; gap: 14px; align-content: start; }
|
|
399
|
+
.wiz-foot { flex: none; display: flex; justify-content: space-between; gap: 10px; padding: 12px 24px calc(16px + var(--safe-b)); border-top: 1px solid var(--border); background: var(--bg); }
|
|
400
|
+
.wiz-foot .btn { min-width: 110px; justify-content: center; }
|
|
401
|
+
.wiz-q { font-size: 18px; font-weight: 500; letter-spacing: -.03em; }
|
|
402
|
+
.wiz-dots { display: flex; gap: 6px; }
|
|
403
|
+
.wiz-dot { flex: 1; height: 18px; border: 0; background: var(--border) content-box; background-clip: content-box; padding: 7px 0; border-radius: 4px; }
|
|
404
|
+
.wiz-dot.on { background-color: var(--fg); }
|
|
405
|
+
.wiz-dot.done { background-color: var(--moss); }
|
|
406
|
+
.wiz-dot:disabled { cursor: default; }
|
|
407
|
+
.wiz .card[aria-pressed="true"] { border-color: var(--fg); box-shadow: inset 0 0 0 1px var(--fg); }
|
|
408
|
+
.fs-pick { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 10px 12px; border-radius: 10px; background: var(--bg-2); }
|
|
409
|
+
.fs-pick b { overflow-wrap: anywhere; }
|
|
410
|
+
.fs-list { max-height: none; }
|
|
411
|
+
@media (max-width: 820px) {
|
|
412
|
+
dialog.sheet.wiz { height: min(92dvh, calc(var(--vvh, 100dvh) - 8px)); }
|
|
413
|
+
.wiz-head { padding: 10px 18px 8px; }
|
|
414
|
+
.wiz-body { padding: 4px 18px 14px; }
|
|
415
|
+
.wiz-foot { padding: 12px 18px calc(14px + var(--safe-b)); }
|
|
416
|
+
.wiz-foot .btn { flex: 1; }
|
|
417
|
+
}
|
|
418
|
+
#fsForm { display: flex; gap: 8px; }
|
|
419
|
+
#fsForm input { flex: 1; min-width: 0; }
|
|
420
|
+
.pair-code { display: grid; gap: 4px; text-align: center; }
|
|
421
|
+
.pair-code b { font-size: 30px; letter-spacing: .14em; font-weight: 600; cursor: copy; }
|
package/web/index.html
CHANGED
|
@@ -116,11 +116,6 @@ main strong { font-weight: 600; }
|
|
|
116
116
|
<h2 id="limits">Tu decideixes</h2>
|
|
117
117
|
<p>Cada sessió té el seu nivell de confiança: <strong>només llegir</strong>, <strong>demana permís</strong> abans d’editar o executar, o <strong>via lliure</strong>. Quan l’agent et necessita, t’arriba un avís i ho resols amb un toc.</p>
|
|
118
118
|
</section>
|
|
119
|
-
|
|
120
|
-
<section aria-labelledby="prova">
|
|
121
|
-
<h2 id="prova">Prova-ho</h2>
|
|
122
|
-
<p>Sense instal·lar res, amb un ordinador simulat: <a href="/app">obre el panell</a>. També hi trobaràs els teus models d’Ollama i un model petit que funciona dins del mateix mòbil.</p>
|
|
123
|
-
</section>
|
|
124
119
|
</main>
|
|
125
120
|
|
|
126
121
|
<footer class="footer">
|