nio-cat 0.4.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/nio.js +29 -2
- package/package.json +1 -1
- package/src/server.js +9 -0
- package/src/tunnel.js +22 -1
- package/web/app/app.js +155 -64
- package/web/app/index.html +3 -5
- package/web/app/style.css +70 -2
- package/web/board.css +24 -3
- package/web/board.js +10 -0
- package/web/sw.js +1 -1
package/bin/nio.js
CHANGED
|
@@ -9,7 +9,7 @@ const config = require('../src/config');
|
|
|
9
9
|
const { Hub } = require('../src/hub');
|
|
10
10
|
const { createServer } = require('../src/server');
|
|
11
11
|
const { detectAll } = require('../src/agents');
|
|
12
|
-
const { startTunnel } = require('../src/tunnel');
|
|
12
|
+
const { startTunnel, startNamedTunnel } = require('../src/tunnel');
|
|
13
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');
|
|
@@ -22,7 +22,7 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
22
22
|
if (a.startsWith('--')) {
|
|
23
23
|
const [k, v] = a.slice(2).split('=');
|
|
24
24
|
if (v !== undefined) flags[k] = v;
|
|
25
|
-
else if (['port', 'url', 'app', 'local-port', 'dir'].includes(k)) flags[k] = argv[++i];
|
|
25
|
+
else if (['port', 'url', 'app', 'local-port', 'dir', 'tunnel-token'].includes(k)) flags[k] = argv[++i];
|
|
26
26
|
else flags[k] = true;
|
|
27
27
|
} else pos.push(a);
|
|
28
28
|
}
|
|
@@ -153,6 +153,20 @@ async function start() {
|
|
|
153
153
|
console.log(c.dim(' Per connectar-t’hi des de qualsevol lloc, instal·la’l (a macOS: brew install cloudflared) i torna a engegar nio.'));
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
// Adreça fixa: túnel amb nom de Cloudflare. Es desa i els reinicis la mantenen.
|
|
157
|
+
if (flags['tunnel-token'] && flags.url) {
|
|
158
|
+
cfg.tunnelToken = flags['tunnel-token'];
|
|
159
|
+
cfg.fixedUrl = flags.url.replace(/\/+$/, '');
|
|
160
|
+
config.save(cfg);
|
|
161
|
+
delete flags.url;
|
|
162
|
+
}
|
|
163
|
+
if (flags['forget-fixed']) {
|
|
164
|
+
delete cfg.tunnelToken;
|
|
165
|
+
delete cfg.fixedUrl;
|
|
166
|
+
config.save(cfg);
|
|
167
|
+
}
|
|
168
|
+
const fixed = cfg.tunnelToken && cfg.fixedUrl && !flags.url && !flags['no-tunnel'] && !flags.lan ? cfg : null;
|
|
169
|
+
|
|
156
170
|
const hub = new Hub(cfg);
|
|
157
171
|
let publicUrl = flags.url || null;
|
|
158
172
|
const host = flags.lan ? '0.0.0.0' : '127.0.0.1';
|
|
@@ -188,6 +202,17 @@ async function start() {
|
|
|
188
202
|
return showPair(publicUrl);
|
|
189
203
|
}
|
|
190
204
|
|
|
205
|
+
if (fixed) {
|
|
206
|
+
const nt = startNamedTunnel(cfg.tunnelToken, () => {
|
|
207
|
+
publicUrl = cfg.fixedUrl;
|
|
208
|
+
console.log(` ${c.dim('Adreça fixa:')} ${publicUrl}`);
|
|
209
|
+
cfg.lastUrl = publicUrl;
|
|
210
|
+
config.save(cfg);
|
|
211
|
+
showPair(publicUrl);
|
|
212
|
+
});
|
|
213
|
+
if (nt) return console.log(c.dim(' Obrint el túnel fix…'));
|
|
214
|
+
}
|
|
215
|
+
|
|
191
216
|
const t = startTunnel(cfg.port, (url) => {
|
|
192
217
|
publicUrl = url;
|
|
193
218
|
console.log(` ${c.dim('Túnel:')} ${url} ${c.dim('(Cloudflare, sense cost)')}`);
|
|
@@ -242,6 +267,8 @@ async function start() {
|
|
|
242
267
|
--lan Connexió dins la mateixa wifi
|
|
243
268
|
--url <adreça> Una adreça pública teva (Tailscale o similar)
|
|
244
269
|
--no-tunnel Només des d'aquest ordinador
|
|
270
|
+
--tunnel-token <t> --url <adreça> Adreça fixa amb un túnel de Cloudflare amb nom (es desa; la propera vegada n'hi ha prou amb nio)
|
|
271
|
+
--forget-fixed Oblida l'adreça fixa i torna al túnel temporal
|
|
245
272
|
--port <n> Port local (7717 si no dius res)
|
|
246
273
|
--no-open No obris el panell al navegador d'aquest ordinador
|
|
247
274
|
--local Obre la còpia del panell d'aquest ordinador (si el navegador bloqueja nio.cat, p. ex. Safari)
|
package/package.json
CHANGED
package/src/server.js
CHANGED
|
@@ -143,6 +143,7 @@ const MODEL_NAME = /^[\w][\w.\-/]{0,100}(:[\w.\-]{1,60})?$/;
|
|
|
143
143
|
|
|
144
144
|
function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
145
145
|
const streams = new Set();
|
|
146
|
+
const seenDevices = new Map(); // mòbils que s'han connectat (per avisar l'ordinador que mostra el QR)
|
|
146
147
|
const pulls = new Map(); // descàrregues de models d'Ollama en curs
|
|
147
148
|
|
|
148
149
|
hub.on('out', (msg) => {
|
|
@@ -388,6 +389,12 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
388
389
|
connection: 'keep-alive',
|
|
389
390
|
});
|
|
390
391
|
res.write(`data: ${JSON.stringify({ type: 'hello', sessions: hub.list(), approvals: hub.pendingApprovals() })}\n\n`);
|
|
392
|
+
const dev = String(url.searchParams.get('d') || '').slice(0, 40), kind = url.searchParams.get('k') === 'phone' ? 'phone' : 'desktop';
|
|
393
|
+
if (dev && kind === 'phone' && Date.now() - (seenDevices.get(dev) || 0) > 10 * 60 * 1000) {
|
|
394
|
+
const data = `data: ${JSON.stringify({ type: 'device', device: dev, kind })}\n\n`;
|
|
395
|
+
for (const o of streams) o.write(data);
|
|
396
|
+
}
|
|
397
|
+
if (dev) seenDevices.set(dev, Date.now());
|
|
391
398
|
streams.add(res);
|
|
392
399
|
req.on('close', () => streams.delete(res));
|
|
393
400
|
return;
|
|
@@ -396,12 +403,14 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
396
403
|
if (url.pathname === '/api/ssh/add' && req.method === 'POST') {
|
|
397
404
|
res.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-store', 'x-accel-buffering': 'no' });
|
|
398
405
|
const send = (o) => res.write(JSON.stringify(o) + '\n');
|
|
406
|
+
const beat = setInterval(() => send({ ping: 1 }), 8000);
|
|
399
407
|
try {
|
|
400
408
|
const b = await readBody(req);
|
|
401
409
|
send({ done: true, ...(await addServer(String(b.target || '').trim(), { onStep: (step) => send({ step }) })) });
|
|
402
410
|
} catch (e) {
|
|
403
411
|
send({ error: e.message });
|
|
404
412
|
}
|
|
413
|
+
clearInterval(beat);
|
|
405
414
|
return res.end();
|
|
406
415
|
}
|
|
407
416
|
try {
|
package/src/tunnel.js
CHANGED
|
@@ -25,4 +25,25 @@ function startTunnel(port, onUrl) {
|
|
|
25
25
|
return child;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// Túnel amb nom (compte gratuït de Cloudflare): l'adreça no canvia entre reinicis.
|
|
29
|
+
function startNamedTunnel(token, onReady) {
|
|
30
|
+
const bin = which('cloudflared');
|
|
31
|
+
if (!bin) return null;
|
|
32
|
+
const child = spawn(bin, ['tunnel', '--no-autoupdate', 'run', '--token', token], {
|
|
33
|
+
env: childEnv(),
|
|
34
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
35
|
+
});
|
|
36
|
+
let ready = false;
|
|
37
|
+
const scan = (d) => {
|
|
38
|
+
if (!ready && /Registered tunnel connection/i.test(String(d))) {
|
|
39
|
+
ready = true;
|
|
40
|
+
onReady();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
child.stdout.on('data', scan);
|
|
44
|
+
child.stderr.on('data', scan);
|
|
45
|
+
process.on('exit', () => child.kill());
|
|
46
|
+
return child;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { startTunnel, startNamedTunnel };
|
package/web/app/app.js
CHANGED
|
@@ -19,6 +19,8 @@ const ICON = {
|
|
|
19
19
|
phone: '<rect x="6" y="3" width="12" height="18" rx="3"/><path d="M10.5 17.5h3"/>',
|
|
20
20
|
machine: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>',
|
|
21
21
|
board: '<path d="M4 20l1.2-4.4L16 4.8a2 2 0 0 1 2.9 0l.3.3a2 2 0 0 1 0 2.9L8.4 18.8z"/>',
|
|
22
|
+
settings: '<path d="M5 7h9M18 7h1M5 17h1M10 17h9"/><circle cx="16" cy="7" r="2"/><circle cx="8" cy="17" r="2"/>',
|
|
23
|
+
help: '<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"/>',
|
|
22
24
|
models: '<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/><path d="M4 7.5l8 4.5 8-4.5M12 12v9"/>',
|
|
23
25
|
trash: '<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/>',
|
|
24
26
|
download: '<path d="M12 4v11M7 10l5 5 5-5M5 20h14"/>',
|
|
@@ -29,6 +31,7 @@ const ICON = {
|
|
|
29
31
|
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
32
|
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
33
|
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"/>',
|
|
34
|
+
close: '<path d="M6 6l12 12M18 6L6 18"/>',
|
|
32
35
|
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"/>',
|
|
33
36
|
check: '<path class="tick" d="M5 12.5l4.5 4.5L19 7.5"/>',
|
|
34
37
|
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>', speaker: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5zM15.5 9a4 4 0 0 1 0 6M18 6.5a8 8 0 0 1 0 11"/>',
|
|
@@ -59,9 +62,26 @@ const machine = () => store.machines[store.active] || store.machines[0] || null;
|
|
|
59
62
|
/* ---------------------------------------------------------------- utilitats */
|
|
60
63
|
function toast(msg, ms = 2800) {
|
|
61
64
|
const box = $('#toasts'), t = document.createElement('div');
|
|
62
|
-
t.className = 'toast'; t.textContent = msg;
|
|
63
|
-
while (box.children.length >=
|
|
64
|
-
box.appendChild(t);
|
|
65
|
+
t.className = 'toast'; t.setAttribute('role', 'status'); t.textContent = msg;
|
|
66
|
+
while (box.children.length >= 3) box.firstElementChild.remove();
|
|
67
|
+
box.appendChild(t);
|
|
68
|
+
let x0 = null, dx = 0, timer = setTimeout(() => dismiss(0), ms);
|
|
69
|
+
const dismiss = (dir) => {
|
|
70
|
+
clearTimeout(timer); if (!t.isConnected) return;
|
|
71
|
+
if (!dir) { t.classList.add('out'); setTimeout(gone, 200); return; }
|
|
72
|
+
t.style.transition = 'transform .18s var(--ease), opacity .18s'; t.style.transform = `translateX(${dir * 120}%)`; t.style.opacity = '0'; setTimeout(gone, 190);
|
|
73
|
+
};
|
|
74
|
+
const gone = () => { t.remove(); if (!box.children.length) try { box.hidePopover(); } catch {} };
|
|
75
|
+
t.addEventListener('pointerdown', (e) => { x0 = e.clientX; dx = 0; clearTimeout(timer); t.setPointerCapture(e.pointerId); t.style.transition = 'none'; });
|
|
76
|
+
t.addEventListener('pointermove', (e) => { if (x0 === null) return; dx = e.clientX - x0; t.style.transform = `translateX(${dx}px)`; t.style.opacity = String(Math.max(0.2, 1 - Math.abs(dx) / 220)); });
|
|
77
|
+
const end = () => { if (x0 === null) return; x0 = null; if (Math.abs(dx) > 60) return dismiss(Math.sign(dx)); t.style.transition = 'transform .18s var(--ease), opacity .18s'; t.style.transform = ''; t.style.opacity = ''; timer = setTimeout(() => dismiss(0), 2000); };
|
|
78
|
+
t.addEventListener('pointerup', end); t.addEventListener('pointercancel', end);
|
|
79
|
+
raiseToasts();
|
|
80
|
+
}
|
|
81
|
+
// Les alertes viuen a la capa superior (popover) perquè quedin per sobre de les fulles i no es vegin borroses.
|
|
82
|
+
function raiseToasts() {
|
|
83
|
+
const box = $('#toasts'); if (!box?.children.length) return;
|
|
84
|
+
try { box.hidePopover(); } catch {} try { box.showPopover(); } catch {}
|
|
65
85
|
}
|
|
66
86
|
function ago(ts) {
|
|
67
87
|
const s = Math.round((Date.now() - ts) / 1000);
|
|
@@ -125,7 +145,7 @@ async function connect() {
|
|
|
125
145
|
const ctrl = new AbortController(); streamCtrl = ctrl;
|
|
126
146
|
try {
|
|
127
147
|
state.info = await api('/api/info');
|
|
128
|
-
const res = await fetch(`${m.url}/api/stream`, { headers: { Authorization: `Bearer ${m.token}` }, signal: ctrl.signal, cache: 'no-store' });
|
|
148
|
+
const res = await fetch(`${m.url}/api/stream?d=${encodeURIComponent(deviceId())}&k=${isPhone() ? 'phone' : 'desktop'}`, { headers: { Authorization: `Bearer ${m.token}` }, signal: ctrl.signal, cache: 'no-store' });
|
|
129
149
|
if (res.status === 401) throw Object.assign(new Error('La clau no és vàlida. Torna a connectar amb el codi QR.'), { fatal: true });
|
|
130
150
|
if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
|
|
131
151
|
state.connected = true; state.lastError = null; retry = 0;
|
|
@@ -147,13 +167,37 @@ async function connect() {
|
|
|
147
167
|
state.lastError = e.message === 'Failed to fetch' || e.name === 'TypeError' ? (location.protocol === 'https:' && /^http:\/\/(127\.0\.0\.1|localhost)/.test(m.url) ? 'El navegador no deixa que aquesta web parli amb aquest ordinador. Prova Chrome, o engega nio amb --local.' : 'No arribo a l’ordinador') : e.message;
|
|
148
168
|
render();
|
|
149
169
|
if (e.fatal) return;
|
|
170
|
+
if (m.kind === 'vps' && m.via && retry >= 2) autoReconnect(m);
|
|
150
171
|
setTimeout(() => machine() && !ctrl.signal.aborted && connect(), Math.min(15000, 1000 * 2 ** retry++));
|
|
151
172
|
}
|
|
152
173
|
}
|
|
174
|
+
// Un servidor caigut es torna a connectar sol: un altre ordinador connectat hi entra per SSH i en desa l'adreça nova.
|
|
175
|
+
const reconTry = new Map();
|
|
176
|
+
async function autoReconnect(m) {
|
|
177
|
+
const t = reconTry.get(m.via) || { at: 0, n: 0, busy: false };
|
|
178
|
+
if (t.busy || t.n >= 3 || Date.now() - t.at < 45000) return;
|
|
179
|
+
t.busy = true; t.at = Date.now(); t.n++; reconTry.set(m.via, t);
|
|
180
|
+
try {
|
|
181
|
+
let helper = null;
|
|
182
|
+
for (const h of store.machines) {
|
|
183
|
+
if (h.url === m.url || h.kind === 'vps' || h.url.startsWith('demo:')) continue;
|
|
184
|
+
try { const r = await fetch(`${h.url}/api/info`, { headers: { Authorization: `Bearer ${h.token}` }, cache: 'no-store', signal: AbortSignal.timeout(5000) }); if (r.ok) { helper = h; break; } } catch { /* prova el següent */ }
|
|
185
|
+
}
|
|
186
|
+
if (!helper) return;
|
|
187
|
+
toast(`Reconnectant amb ${m.name}…`);
|
|
188
|
+
const r = await sshAdd(m.via, () => {}, helper);
|
|
189
|
+
const ms = store.machines, i = ms.findIndex((x) => x.via === m.via && x.kind === 'vps');
|
|
190
|
+
if (i < 0) return;
|
|
191
|
+
ms[i].url = r.url; ms[i].token = r.token; store.machines = ms;
|
|
192
|
+
reconTry.delete(m.via); retry = 0;
|
|
193
|
+
if (store.active === i) connect();
|
|
194
|
+
toast(`${ms[i].name} torna a ser aquí`);
|
|
195
|
+
} catch (e) { toast(`No he pogut reconnectar: ${e.message}`); } finally { t.busy = false; }
|
|
196
|
+
}
|
|
153
197
|
// 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).
|
|
154
198
|
function renameMachine(host, id) {
|
|
155
199
|
let ms = store.machines; const cur = ms[store.active]; if (!cur) return;
|
|
156
|
-
if (host) cur.name = host;
|
|
200
|
+
if (host && !cur.renamed) cur.name = host;
|
|
157
201
|
if (id) {
|
|
158
202
|
cur.id = id;
|
|
159
203
|
const keep = cur.url, list = ms.filter((m) => m === cur || m.id !== id);
|
|
@@ -165,6 +209,9 @@ function renameMachine(host, id) {
|
|
|
165
209
|
function handle(msg) {
|
|
166
210
|
const r = state.route;
|
|
167
211
|
switch (msg.type) {
|
|
212
|
+
case 'device':
|
|
213
|
+
if (!isPhone() && msg.device !== deviceId()) { if ($('#qrBox')) closeSheet(); toast('Mòbil connectat correctament', 2000); }
|
|
214
|
+
break;
|
|
168
215
|
case 'hello':
|
|
169
216
|
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
170
217
|
state.approvals = new Map(msg.approvals.map((a) => [a.id, a]));
|
|
@@ -266,9 +313,11 @@ const VIEWS = {
|
|
|
266
313
|
ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
|
|
267
314
|
models: { label: 'Models', icon: ICON.models, group: 'Ordinadors' },
|
|
268
315
|
mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
|
|
269
|
-
pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' }
|
|
316
|
+
pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' },
|
|
317
|
+
ajustos: { label: 'Ajustos', icon: ICON.settings, group: 'Compte' },
|
|
318
|
+
ajuda: { label: 'Ajuda', icon: ICON.help, group: 'Compte' }
|
|
270
319
|
};
|
|
271
|
-
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra' };
|
|
320
|
+
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
|
|
272
321
|
const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'models', 'session']);
|
|
273
322
|
function parseRoute() {
|
|
274
323
|
const h = location.hash.replace(/^#\/?/, '');
|
|
@@ -300,7 +349,7 @@ function renderChrome() {
|
|
|
300
349
|
const m = machine(), sc = $('#sideConn');
|
|
301
350
|
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)}`;
|
|
302
351
|
sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
|
|
303
|
-
const more = ['
|
|
352
|
+
const more = ['mobil', 'pissarra', 'ajustos', 'ajuda'].includes(r.name);
|
|
304
353
|
$('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['approvals', ICON.approvals, 'Aprovacions', active === 'approvals'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
|
|
305
354
|
.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('')
|
|
306
355
|
+ `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
|
|
@@ -326,7 +375,7 @@ function render() {
|
|
|
326
375
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
327
376
|
else {
|
|
328
377
|
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
329
|
-
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>`;
|
|
378
|
+
view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'ordinadors' ? viewComputers() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : r.name === 'ajustos' ? viewSettings() : r.name === 'ajuda' ? viewHelp() : viewModels()}</div>`;
|
|
330
379
|
if (r.name === 'mobil') bindLocal();
|
|
331
380
|
if (r.name === 'models' && machine()) loadOllama();
|
|
332
381
|
}
|
|
@@ -336,6 +385,7 @@ function render() {
|
|
|
336
385
|
}
|
|
337
386
|
|
|
338
387
|
/* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
|
|
388
|
+
const deviceId = () => { let d = LS.get('nio.device', ''); if (!d) { d = Math.random().toString(36).slice(2, 12); LS.set('nio.device', d); } return d; };
|
|
339
389
|
const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
|
|
340
390
|
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>`;
|
|
341
391
|
function computerCard(m, i) {
|
|
@@ -343,7 +393,7 @@ function computerCard(m, i) {
|
|
|
343
393
|
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>`) : '';
|
|
344
394
|
return `<div class="card computer ${on ? 'active' : ''}">
|
|
345
395
|
<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>
|
|
346
|
-
<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>
|
|
396
|
+
<div class="row c-actions">${on ? `<button class="btn ink sm" data-act="open-sessions" type="button">Sessions</button>${isPhone() ? '' : `<button class="btn sm" data-act="mobile-qr" type="button">Continua al mòbil</button>`}` : `<button class="btn ink sm" data-machine="${i}" type="button">Obre</button>`}${m.kind === 'vps' && m.via && (!on || !state.connected) ? `<button class="btn sm" data-reconnect="${i}" type="button">Torna a connectar</button>` : ''}<button class="btn ghost sm" data-rename="${i}" type="button">Canvia el nom</button><button class="btn ghost sm" data-forget="${i}" type="button">Oblida</button></div>
|
|
347
397
|
</div>`;
|
|
348
398
|
}
|
|
349
399
|
// Ajuda sempre a mà: com es fa cada cosa, i què mirar si no connecta.
|
|
@@ -356,9 +406,16 @@ function helpPanel(open) {
|
|
|
356
406
|
${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>`)}
|
|
357
407
|
${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>`)}
|
|
358
408
|
${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>`)}
|
|
359
|
-
<div class="row"><button class="btn sm" type="button" data-act="tour">Fes el tutorial</button></div>
|
|
360
409
|
</div></section>`;
|
|
361
410
|
}
|
|
411
|
+
function viewSettings() {
|
|
412
|
+
const head = '<div class="top"><div><h1>Ajustos</h1><p>Avisos al mòbil i veu.</p></div></div>';
|
|
413
|
+
const avisos = machine() ? `<section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}<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>` : '';
|
|
414
|
+
return `${head}<div class="stack-lg" style="max-width:640px">${avisos}${voicePanel()}</div>`;
|
|
415
|
+
}
|
|
416
|
+
function viewHelp() {
|
|
417
|
+
return `<div class="top"><div><h1>Ajuda</h1><p>Com es fa cada cosa i què mirar si no connecta.</p></div><button class="btn ink" type="button" data-act="tour">Fes el tutorial</button></div><div style="max-width:720px">${helpPanel()}</div>`;
|
|
418
|
+
}
|
|
362
419
|
function viewComputers() {
|
|
363
420
|
const ms = store.machines, m = machine(), info = state.info;
|
|
364
421
|
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>`;
|
|
@@ -366,19 +423,16 @@ function viewComputers() {
|
|
|
366
423
|
const steps = isPhone()
|
|
367
424
|
? [['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.']]
|
|
368
425
|
: [['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.']];
|
|
369
|
-
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
|
|
426
|
+
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>`;
|
|
370
427
|
}
|
|
371
428
|
const detail = m && state.connected ? `<h2 class="sec">${esc(m.name)}</h2>
|
|
372
|
-
<div class="grid2">
|
|
429
|
+
<div class="grid2 one">
|
|
373
430
|
<div class="stack">
|
|
374
431
|
<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>
|
|
375
432
|
${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>` : ''}
|
|
376
433
|
</div>
|
|
377
|
-
<div class="stack">
|
|
378
|
-
<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>
|
|
379
|
-
</div>
|
|
380
434
|
</div>` : '';
|
|
381
|
-
return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}
|
|
435
|
+
return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}`;
|
|
382
436
|
}
|
|
383
437
|
|
|
384
438
|
/* ---------- Afegir un ordinador: aquest, un servidor per SSH o un codi */
|
|
@@ -390,8 +444,12 @@ async function probeLocal() {
|
|
|
390
444
|
} catch { return null; }
|
|
391
445
|
}
|
|
392
446
|
// Afegeix un servidor per SSH des de l'ordinador actiu. El progrés arriba línia a línia.
|
|
393
|
-
|
|
394
|
-
const
|
|
447
|
+
function sshHelper() {
|
|
448
|
+
const a = machine(), ok = (x) => x && x.kind !== 'vps' && !x.url.startsWith('demo:');
|
|
449
|
+
return ok(a) ? a : store.machines.find(ok) || null;
|
|
450
|
+
}
|
|
451
|
+
async function sshAdd(target, onStep, via) {
|
|
452
|
+
const m = via || sshHelper(); if (!m) throw new Error('Primer connecta un ordinador');
|
|
395
453
|
const res = await fetch(`${m.url}/api/ssh/add`, { method: 'POST', headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json' }, body: JSON.stringify({ target }) });
|
|
396
454
|
if (!res.ok || !res.body) throw new Error((await res.json().catch(() => ({}))).error || 'Aquest ordinador no pot afegir servidors. Actualitza nio.');
|
|
397
455
|
const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '', last = null;
|
|
@@ -400,7 +458,7 @@ async function sshAdd(target, onStep) {
|
|
|
400
458
|
buf += dec.decode(value, { stream: true }); let i;
|
|
401
459
|
while ((i = buf.indexOf('\n')) >= 0) {
|
|
402
460
|
const o = JSON.parse(buf.slice(0, i)); buf = buf.slice(i + 1);
|
|
403
|
-
if (o.step) onStep(o.step); else last = o;
|
|
461
|
+
if (o.step) onStep(o.step); else if (!o.ping) last = o;
|
|
404
462
|
}
|
|
405
463
|
}
|
|
406
464
|
if (!last || last.error) throw new Error(last?.error || 'S’ha tallat la connexió');
|
|
@@ -415,56 +473,59 @@ async function startScan(video, onText) {
|
|
|
415
473
|
}
|
|
416
474
|
function addComputerSheet(tab, opts = {}) {
|
|
417
475
|
const phone = isPhone(), canScan = 'BarcodeDetector' in window && !!navigator.mediaDevices?.getUserMedia;
|
|
418
|
-
const TABS = phone ? [['code', 'Codi o QR'], ['vps', 'Servidor
|
|
476
|
+
const TABS = phone ? [['code', 'Codi o QR', ICON.scan], ['vps', 'Servidor', ICON.server]] : [['local', 'Aquest ordinador', ICON.machine], ['vps', 'Servidor', ICON.server], ['code', 'Tinc un codi', ICON.scan]];
|
|
419
477
|
let cur = TABS.some((t) => t[0] === tab) ? tab : TABS[0][0], poll = 0, scan = null, shown = '', hosts = [];
|
|
478
|
+
const intro = (t, d) => `<div class="add-intro"><h3>${t}</h3>${d ? `<p>${d}</p>` : ''}</div>`;
|
|
479
|
+
const todo = (items) => `<ol class="todo">${items.map((x, i) => `<li><span class="n">${i + 1}</span><div>${x}</div></li>`).join('')}</ol>`;
|
|
480
|
+
const live = (t) => `<p class="live"><i class="pulse"></i>${t}</p>`;
|
|
420
481
|
openSheet('', (sh) => {
|
|
421
|
-
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"
|
|
482
|
+
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">${svg(ICON.close)}</button></div><div class="seg" id="addTabs" role="group" aria-label="Com vols connectar-lo">${TABS.map(([k, l, ic]) => `<button type="button" data-tab="${k}">${svg(ic)}<span>${l}</span></button>`).join('')}</div></div><div class="wiz-body" id="addBody"></div>`;
|
|
422
483
|
const body = $('#addBody', sh);
|
|
423
484
|
const stop = () => { clearTimeout(poll); scan?.stop(); scan = null; shown = ''; };
|
|
424
485
|
const set = (key, html) => { if (key === shown) return false; shown = key; body.innerHTML = html; return true; };
|
|
425
486
|
const draw = () => {
|
|
426
|
-
stop(); if (cur === 'vps' && !hosts.length && machine()) api('/api/ssh/hosts').then((h) => { hosts = h; if (cur === 'vps' && shown === 'vps') { shown = ''; vps(); } }).catch(() => {});
|
|
487
|
+
stop(); if (cur === 'vps' && !hosts.length && machine()) api('/api/ssh/hosts').then((h) => { hosts = h; if (cur === 'vps' && shown === 'vps' && !$('#sshSteps', sh)?.children.length) { shown = ''; vps(); } }).catch(() => {});
|
|
427
488
|
$$('#addTabs button', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.tab === cur));
|
|
428
489
|
body.scrollTop = 0;
|
|
429
490
|
if (cur === 'local') local(); else if (cur === 'vps') vps(); else code();
|
|
430
491
|
};
|
|
431
492
|
const local = async () => {
|
|
432
|
-
set('wait', '
|
|
493
|
+
set('wait', live('Mirant si nio funciona en aquest ordinador…'));
|
|
433
494
|
const d = await probeLocal(); if (cur !== 'local') return;
|
|
434
495
|
const saved = d && store.machines.findIndex((x) => x.id === d.id);
|
|
435
|
-
if (d && saved >= 0) set('saved',
|
|
436
|
-
else if (d) set('found',
|
|
437
|
-
else set('none',
|
|
496
|
+
if (d && saved >= 0) set('saved', `${intro('Aquest ordinador ja és a la llista', 'El tens connectat com a «' + esc(d.name) + '».')}<div class="row"><button class="btn ink" type="button" data-open-saved="${saved}">Obre sessions</button></div>`);
|
|
497
|
+
else if (d) set('found', `${intro('nio funciona en aquest ordinador', 'Aquest navegador encara no hi té accés. Torna a engegar nio i obrirà el panell ja connectat.')}${cmdBox('npx nio-cat')}`);
|
|
498
|
+
else set('none', `${intro('Engega nio en aquest ordinador')}${todo(['Obre un terminal a la carpeta dels teus projectes.', `Executa:${cmdBox('npx nio-cat')}`, 'S’obrirà aquest panell ja connectat.'])}${live('Esperant que nio s’engegui…')}<p class="hint">Cal Node.js 18 o més nou.</p>`);
|
|
438
499
|
if (saved < 0 || !d) poll = setTimeout(local, 3000);
|
|
439
500
|
};
|
|
440
501
|
const runSsh = async (target) => {
|
|
441
|
-
const steps = $('#sshSteps', sh), btn = $('#sshForm button', sh); if (!steps) return; btn.disabled = true; steps.innerHTML = '';
|
|
502
|
+
const steps = $('#sshSteps', sh), btn = $('#sshForm button', sh); if (!steps || !target) return; btn.disabled = true; steps.innerHTML = ''; shown = 'run';
|
|
442
503
|
const add = (t) => { $$('li.on', steps).forEach((l) => { l.className = 'ok'; }); steps.insertAdjacentHTML('beforeend', `<li class="on">${esc(t)}…</li>`); };
|
|
443
504
|
try {
|
|
444
|
-
|
|
505
|
+
add(`Connectant amb ${target}`);
|
|
506
|
+
const r = await sshAdd(target, add, sshHelper());
|
|
445
507
|
LS.set('nio.lastSsh', target);
|
|
446
508
|
const old = store.machines.findIndex((x) => x.via === target && x.kind === 'vps');
|
|
447
|
-
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)); }
|
|
448
|
-
addMachine({ url: r.url, token: r.token, name: r.host, kind: 'vps', via: target });
|
|
509
|
+
let keepName = null; if (old >= 0) { const ms = store.machines; if (ms[old].renamed) keepName = ms[old].name; ms.splice(old, 1); store.machines = ms; store.active = Math.min(store.active, Math.max(0, ms.length - 1)); }
|
|
510
|
+
addMachine({ url: r.url, token: r.token, name: keepName || r.host, kind: 'vps', via: target });
|
|
511
|
+
if (keepName) { const ms = store.machines; ms[store.active].renamed = true; store.machines = ms; }
|
|
449
512
|
shown = 'vps-ok';
|
|
450
|
-
body.innerHTML =
|
|
451
|
-
} catch (err) { $$('li.on', steps).forEach((l) => { l.className = 'err'; }); steps.insertAdjacentHTML('beforeend', `<li class="err">${esc(err.message)}</li>`); btn.disabled = false; }
|
|
513
|
+
body.innerHTML = `${intro(`${esc(r.host)} és a punt`, r.claude ? 'Ja el pots fer servir: crea-hi una sessió amb Claude Code.' : 'Claude Code no hi és. Entra al servidor, instal·la’l i inicia sessió amb <span class="mono">claude</span>.')}<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>`;
|
|
514
|
+
} catch (err) { $$('li.on', steps).forEach((l) => { l.className = 'err'; }); steps.insertAdjacentHTML('beforeend', `<li class="err">${esc(err.message)}</li>`); btn.disabled = false; shown = 'vps'; }
|
|
452
515
|
};
|
|
453
516
|
const vps = () => {
|
|
454
|
-
const m =
|
|
455
|
-
if (!m) return set('vps-no',
|
|
456
|
-
set('vps',
|
|
457
|
-
<
|
|
458
|
-
${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>` : ''}
|
|
459
|
-
<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>
|
|
517
|
+
const m = sshHelper();
|
|
518
|
+
if (!m) return set('vps-no', `${intro('Primer, un ordinador', 'El servidor s’afegeix des d’un ordinador (no des d’un altre servidor), perquè és ell qui hi entra per SSH.')}${phone ? '<p class="hint">Des del mòbil, connecta primer l’ordinador amb el codi o el QR.</p>' : '<div class="row"><button class="btn" type="button" data-tab="local">Afegeix aquest ordinador</button></div>'}`);
|
|
519
|
+
set('vps', `${intro('Un servidor amb Claude Code', `${esc(m.name)} hi entra per SSH, hi engega nio amb un túnel segur i el deixa llest per al mòbil.`)}
|
|
520
|
+
<form class="field-row" 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>
|
|
521
|
+
${hosts.length ? `<div class="hosts"><span>Del teu <span class="mono">~/.ssh/config</span></span><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></div>` : ''}
|
|
460
522
|
<ul class="steps" id="sshSteps"></ul>
|
|
461
|
-
<
|
|
523
|
+
<ul class="checks"><li>Accés per SSH sense contrasenya (clau SSH)</li><li>Node.js 18 o més nou</li><li>Claude Code instal·lat i amb sessió iniciada</li></ul>`);
|
|
462
524
|
};
|
|
463
525
|
const code = () => {
|
|
464
|
-
set('code',
|
|
465
|
-
<form class="
|
|
466
|
-
${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="
|
|
467
|
-
<p class="muted small">El codi surt al terminal, sota el QR, o a «Continua al mòbil».</p>`);
|
|
526
|
+
set('code', `${intro(phone ? 'Escaneja o escriu el codi' : 'Escriu el codi', 'Surt al terminal, sota el QR, o a «Continua al mòbil».')}
|
|
527
|
+
<form class="field-row" 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>
|
|
528
|
+
${canScan ? `<div class="or"><span>o</span></div><button class="btn block" type="button" data-scan>${svg(ICON.scan)}Escaneja un QR</button><video class="scan" id="scanVideo" playsinline muted hidden></video>` : phone ? '<p class="hint">Per escanejar, obre la càmera del mòbil i apunta al QR: s’obrirà nio.</p>' : ''}`);
|
|
468
529
|
};
|
|
469
530
|
sh.onclick = async (e) => {
|
|
470
531
|
const t = e.target.closest('[data-tab]'); if (t) { cur = t.dataset.tab; return draw(); }
|
|
@@ -484,15 +545,29 @@ function addComputerSheet(tab, opts = {}) {
|
|
|
484
545
|
};
|
|
485
546
|
draw();
|
|
486
547
|
if (opts.auto && cur === 'vps') setTimeout(() => runSsh(opts.target), 50);
|
|
487
|
-
}, 'wiz');
|
|
548
|
+
}, 'wiz compact');
|
|
488
549
|
}
|
|
489
550
|
function machinesSheet() {
|
|
490
551
|
const ms = store.machines;
|
|
491
552
|
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>'}
|
|
492
553
|
<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)); });
|
|
493
554
|
}
|
|
555
|
+
function renameSheet(i) {
|
|
556
|
+
const m = store.machines[i]; if (!m) return;
|
|
557
|
+
openSheet(`<h2>Canvia el nom</h2><p class="muted small">Només el veus tu, en aquest dispositiu. L’ordinador es continua dient «${esc(m.name)}» a la resta.</p>
|
|
558
|
+
<form class="field-row" id="renameForm"><input type="text" id="renameIn" value="${esc(m.name)}" maxlength="40" autocomplete="off" spellcheck="false" required aria-label="Nom de l’ordinador"><button class="btn ink" type="submit">Desa</button></form>`, (sh) => {
|
|
559
|
+
const inp = $('#renameIn', sh); setTimeout(() => { inp.focus(); inp.select(); }, 60);
|
|
560
|
+
$('#renameForm', sh).onsubmit = (e) => {
|
|
561
|
+
e.preventDefault(); e.stopPropagation();
|
|
562
|
+
const v = inp.value.trim(); if (!v) return;
|
|
563
|
+
const ms = store.machines; if (!ms[i]) return;
|
|
564
|
+
ms[i].name = v; ms[i].renamed = true; store.machines = ms; closeSheet(); render(); toast('Nom desat');
|
|
565
|
+
};
|
|
566
|
+
});
|
|
567
|
+
}
|
|
494
568
|
function moreSheet() {
|
|
495
|
-
|
|
569
|
+
const item = (k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`;
|
|
570
|
+
openSheet(`<h2>Més</h2><div class="list">${['mobil', 'pissarra', 'ajustos', 'ajuda'].map(item).join('')}</div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
|
|
496
571
|
}
|
|
497
572
|
|
|
498
573
|
/* ---------- Sessions */
|
|
@@ -778,8 +853,8 @@ function renderComposer() {
|
|
|
778
853
|
|
|
779
854
|
/* ---------- Fulls */
|
|
780
855
|
function openSheet(html, onMount, cls) {
|
|
781
|
-
const d = $('#sheet'); d.classList.toggle('wiz', cls === 'wiz'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls
|
|
782
|
-
if (!d.open) d.showModal(); if (T.on) $('#tour').hidden = true;
|
|
856
|
+
const d = $('#sheet'); d.classList.toggle('wiz', !!cls?.startsWith('wiz')); d.classList.toggle('compact', cls === 'wiz compact'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls?.startsWith('wiz') ? '' : `<div class="grab"></div>${html}`;
|
|
857
|
+
if (!d.open) d.showModal(); raiseToasts(); if (T.on) $('#tour').hidden = true;
|
|
783
858
|
onMount?.($('#sheetIn'));
|
|
784
859
|
}
|
|
785
860
|
const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); };
|
|
@@ -817,7 +892,7 @@ async function newSessionSheet(preset = {}) {
|
|
|
817
892
|
const body = () => { $('#wizBody', sh).innerHTML = VIEWS_[step](); after(); };
|
|
818
893
|
const head = () => {
|
|
819
894
|
const last = step === STEPS.length - 1;
|
|
820
|
-
$('#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"
|
|
895
|
+
$('#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">${svg(ICON.close)}</button></div>
|
|
821
896
|
<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>
|
|
822
897
|
<p class="muted small">Pas ${step + 1} de ${STEPS.length} · ${STEPS[step]}</p>`;
|
|
823
898
|
$('#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>`;
|
|
@@ -858,7 +933,7 @@ async function newSessionSheet(preset = {}) {
|
|
|
858
933
|
};
|
|
859
934
|
|
|
860
935
|
const VIEWS_ = [
|
|
861
|
-
() => `<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>`,
|
|
936
|
+
() => `<p class="wiz-q">Quin agent vols fer servir?</p><div class="picker">${[...agents].sort((x, y) => (y.available ? 1 : 0) - (x.available ? 1 : 0)).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>`,
|
|
862
937
|
() => {
|
|
863
938
|
const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
|
|
864
939
|
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('');
|
|
@@ -934,7 +1009,7 @@ async function modelSheet(s) {
|
|
|
934
1009
|
const row = (v, name, note) => `<button class="card" type="button" data-model="${esc(v)}" aria-pressed="${(s.model || '') === v}"><b>${esc(name)}</b>${note ? `<div class="muted small" style="margin-top:4px">${esc(note)}</div>` : ''}</button>`;
|
|
935
1010
|
openSheet(`<h2>Model</h2><p class="muted small">${esc(AGENT_NAMES[s.agent] || s.agent)}. El canvi s’aplica a partir del pròxim missatge.</p>
|
|
936
1011
|
<div class="list">${row('', 'Per defecte', 'El que l’agent faci servir sol')}${picks.map((x) => row(...x)).join('')}</div>
|
|
937
|
-
<form class="
|
|
1012
|
+
<div class="or"><span>o un altre model</span></div><form class="field-row" id="modelForm"><input type="text" id="modelFree" placeholder="Nom exacte del model" value="${picks.some((x) => x[0] === s.model) ? '' : esc(s.model || '')}" autocapitalize="off" spellcheck="false" aria-label="Nom del model"><button class="btn ink" type="submit">Desa</button></form>`, (sh) => {
|
|
938
1013
|
const save = async (model) => { try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body: { model } }); state.sessions.set(s.id, sum); closeSheet(); renderSessionHead(); } catch (e) { toast(e.message); } };
|
|
939
1014
|
$$('[data-model]', sh).forEach((b) => (b.onclick = () => save(b.dataset.model)));
|
|
940
1015
|
$('#modelForm', sh).onsubmit = (e) => { e.preventDefault(); e.stopPropagation(); save($('#modelFree', sh).value.trim()); };
|
|
@@ -1112,8 +1187,10 @@ window.NioBrains = {
|
|
|
1112
1187
|
}
|
|
1113
1188
|
if (id === 'local') {
|
|
1114
1189
|
if (!local.engine) throw new Error('Primer obre un model a «Xat local»');
|
|
1115
|
-
|
|
1116
|
-
|
|
1190
|
+
try {
|
|
1191
|
+
const r = await local.engine.chat.completions.create({ messages, temperature: 0.2 });
|
|
1192
|
+
return r.choices[0]?.message?.content || '';
|
|
1193
|
+
} catch (e) { if (gpuLost(e)) { await recoverLocal(''); throw new Error('La GPU del mòbil s’ha reiniciat. Torna-ho a provar quan el model s’hagi carregat.'); } throw e; }
|
|
1117
1194
|
}
|
|
1118
1195
|
throw new Error('Aquest model ja no està disponible');
|
|
1119
1196
|
}
|
|
@@ -1175,6 +1252,7 @@ function bindLocal() {
|
|
|
1175
1252
|
for (const m of local.messages) feed.appendChild(m.role === 'user' ? el(`<div class="msg-user">${esc(m.content)}</div>`) : el(`<div class="msg-ai">${md(stripThink(m.content))}</div>`));
|
|
1176
1253
|
feed.scrollTop = feed.scrollHeight;
|
|
1177
1254
|
const ta = $('#lmsg');
|
|
1255
|
+
if (local.draft) { ta.value = local.draft; local.draft = ''; autogrow(ta); }
|
|
1178
1256
|
ta.addEventListener('input', () => autogrow(ta));
|
|
1179
1257
|
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !isTouch) { e.preventDefault(); sendLocal(); } });
|
|
1180
1258
|
$('#lsend').onclick = () => (local.busy ? stopLocal() : sendLocal());
|
|
@@ -1193,6 +1271,15 @@ const LOCAL_SYSTEM = [
|
|
|
1193
1271
|
'Si no saps una cosa, digues «no ho sé». No t’inventis dades, noms ni enllaços.',
|
|
1194
1272
|
'No facis preguntes seguides ni allargassis la conversa. No facis servir emojis.'
|
|
1195
1273
|
].join(' ');
|
|
1274
|
+
const gpuLost = (e) => /mapAsync|GPUBuffer|GPUDevice|Instance reference|device (was )?lost|Device is lost|context lost/i.test(`${e?.message || e}`);
|
|
1275
|
+
// La GPU del mòbil es reinicia si l'app va al fons o falta memòria: es deixa l'engine mort i es torna a carregar (els pesos ja són a la memòria cau).
|
|
1276
|
+
async function recoverLocal(text) {
|
|
1277
|
+
const id = local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id);
|
|
1278
|
+
try { await local.engine?.unload?.(); } catch { /* ja és mort */ }
|
|
1279
|
+
local.engine = null; local.model = null; local.draft = text || '';
|
|
1280
|
+
toast('La GPU del mòbil s’ha reiniciat. Torno a carregar el model…', 5000);
|
|
1281
|
+
await loadLocal(id);
|
|
1282
|
+
}
|
|
1196
1283
|
async function loadLocal(id) {
|
|
1197
1284
|
local.loading = true; local.progress = 0; local.status = 'Preparant…'; render();
|
|
1198
1285
|
try {
|
|
@@ -1225,9 +1312,13 @@ async function sendLocal() {
|
|
|
1225
1312
|
acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) feed.scrollTop = feed.scrollHeight;
|
|
1226
1313
|
if (local.stopped) break;
|
|
1227
1314
|
}
|
|
1228
|
-
} catch (e) {
|
|
1315
|
+
} catch (e) {
|
|
1316
|
+
if (gpuLost(e) && !acc) { local.messages.pop(); out.remove(); feed.lastElementChild?.remove(); local.busy = false; local.stopped = false; setLocalBusy(false); return recoverLocal(text); }
|
|
1317
|
+
if (!local.stopped) acc = acc || `Error: ${e.message}`; out.innerHTML = md(stripThink(acc));
|
|
1318
|
+
}
|
|
1229
1319
|
out.classList.remove('cursor');
|
|
1230
1320
|
if (local.stopped) { try { await local.engine.interruptGenerate(); } catch { /* res */ } }
|
|
1321
|
+
if (gpuLost(acc.replace(/^Error: /, ''))) { local.messages.push({ role: 'assistant', content: 'La GPU del mòbil s’ha reiniciat enmig de la resposta.' }); local.busy = false; local.stopped = false; setLocalBusy(false); return recoverLocal(''); }
|
|
1231
1322
|
local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
|
|
1232
1323
|
}
|
|
1233
1324
|
|
|
@@ -1286,7 +1377,7 @@ async function toggleMic() {
|
|
|
1286
1377
|
rec.start(); $('#micBtn')?.classList.add('rec');
|
|
1287
1378
|
}
|
|
1288
1379
|
function voicePanel() {
|
|
1289
|
-
return `<section class="panel"
|
|
1380
|
+
return `<section class="panel"><header><h2>Veu</h2>${voiceOn() ? '<span class="chip ok"><i class="dot on"></i>ElevenLabs</span>' : ''}</header><div class="body stack"><p class="small">Dicta els missatges amb el micròfon i escolta les respostes de l’agent, amb ElevenLabs.</p><div class="row"><button class="btn ink" data-act="voice" type="button">${voiceOn() ? 'Canvia la configuració' : 'Configura la veu'}</button></div></div></section>`;
|
|
1290
1381
|
}
|
|
1291
1382
|
function voiceSheet() {
|
|
1292
1383
|
const c = voiceCfg();
|
|
@@ -1305,7 +1396,7 @@ function voiceSheet() {
|
|
|
1305
1396
|
|
|
1306
1397
|
/* ---------------------------------------------------------------- accions */
|
|
1307
1398
|
document.addEventListener('click', async (e) => {
|
|
1308
|
-
const a = e.target.closest('[data-act], [data-speak], [data-mic], [data-copy], [data-decide], [data-machine], [data-forget], [data-reconnect], [data-lm]');
|
|
1399
|
+
const a = e.target.closest('[data-act], [data-speak], [data-mic], [data-copy], [data-decide], [data-machine], [data-forget], [data-rename], [data-reconnect], [data-lm]');
|
|
1309
1400
|
if (!a) return;
|
|
1310
1401
|
if (a.dataset.copy) {
|
|
1311
1402
|
try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
|
|
@@ -1320,7 +1411,8 @@ document.addEventListener('click', async (e) => {
|
|
|
1320
1411
|
if (a.dataset.mic != null) return toggleMic();
|
|
1321
1412
|
if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
|
|
1322
1413
|
if (a.dataset.machine != null) return switchMachine(Number(a.dataset.machine));
|
|
1323
|
-
if (a.dataset.reconnect != null) { const m = store.machines[Number(a.dataset.reconnect)]; return addComputerSheet('vps', { target: m.via, auto: true }); }
|
|
1414
|
+
if (a.dataset.reconnect != null) { const m = store.machines[Number(a.dataset.reconnect)]; reconTry.delete(m.via); if (sshHelper()) { autoReconnect({ ...m }); return; } return addComputerSheet('vps', { target: m.via, auto: true }); }
|
|
1415
|
+
if (a.dataset.rename != null) return renameSheet(Number(a.dataset.rename));
|
|
1324
1416
|
if (a.dataset.forget != null) { if (confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) forgetMachine(Number(a.dataset.forget)); return; }
|
|
1325
1417
|
if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
|
|
1326
1418
|
switch (a.dataset.act) {
|
|
@@ -1339,7 +1431,7 @@ document.addEventListener('click', async (e) => {
|
|
|
1339
1431
|
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;
|
|
1340
1432
|
case 'add-computer': return addComputerSheet();
|
|
1341
1433
|
case 'open-sessions': return go('#/sessions');
|
|
1342
|
-
case 'help': go('#/
|
|
1434
|
+
case 'help': return go('#/ajuda');
|
|
1343
1435
|
case 'more': return moreSheet();
|
|
1344
1436
|
case 'machines': return machinesSheet();
|
|
1345
1437
|
case 'mobile-qr': return mobileQrSheet();
|
|
@@ -1366,11 +1458,10 @@ document.addEventListener('submit', (e) => {
|
|
|
1366
1458
|
/* ---------------------------------------------------------------- paleta d'ordres */
|
|
1367
1459
|
let palSel = 0;
|
|
1368
1460
|
function commands() {
|
|
1369
|
-
const c = Object.entries(VIEWS).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
|
|
1461
|
+
const c = Object.entries(VIEWS).filter(([k]) => !(k === 'models' && mq.matches)).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
|
|
1370
1462
|
if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
|
|
1371
1463
|
c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
|
|
1372
1464
|
[...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}`) }));
|
|
1373
|
-
c.push({ label: 'Com es fa (ajuda)', hint: 'Ajuda', run: () => { go('#/ordinadors'); setTimeout(() => $('#help')?.scrollIntoView({ block: 'start' }), 60); } });
|
|
1374
1465
|
c.push({ label: 'Configura la veu (ElevenLabs)', hint: 'Ajustos', run: voiceSheet });
|
|
1375
1466
|
c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
|
|
1376
1467
|
return c;
|
|
@@ -1429,12 +1520,12 @@ const TSTEPS = [
|
|
|
1429
1520
|
{ 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.' },
|
|
1430
1521
|
{ 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.' },
|
|
1431
1522
|
{ 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.' },
|
|
1432
|
-
{ 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.' },
|
|
1523
|
+
{ route: 'models', skipIf: () => !machine() || mq.matches, 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.' },
|
|
1433
1524
|
{ 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.' },
|
|
1434
1525
|
{ 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.' },
|
|
1435
|
-
{ route: '
|
|
1436
|
-
{ 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
|
|
1437
|
-
{ center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial
|
|
1526
|
+
{ route: 'ajuda', target: '#help', m: '#help', title: 'Si alguna cosa no va', text: 'A «Ajuda» tens cada pas explicat, què cal al servidor i què mirar si no connecta.' },
|
|
1527
|
+
{ 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 el xat local, la pissarra, els ajustos i l’ajuda, amb aquest tutorial.' : 'Prem ⌘K (o Ctrl+K) per anar a qualsevol lloc o obrir una sessió sense tocar el ratolí.') },
|
|
1528
|
+
{ center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial a Ajuda.', next: 'Finalitza' }
|
|
1438
1529
|
];
|
|
1439
1530
|
const tval = (v) => (typeof v === 'function' ? v() : v);
|
|
1440
1531
|
function tourStart() { T.on = true; T.seen = true; tourGo(0); }
|
package/web/app/index.html
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
<body>
|
|
40
40
|
<header class="mbar">
|
|
41
41
|
<button class="icon-btn burger" id="burger" type="button" aria-label="Obre el menú" aria-expanded="false" aria-controls="side"><span></span><span></span><span></span></button>
|
|
42
|
-
<a href="/" class="word" aria-label="nio">ni<span class="bot" data-mood="idle" aria-hidden="true"><span class="eyes"><i></i><i></i></span></span></a>
|
|
42
|
+
<a href="/app" class="word" aria-label="nio">ni<span class="bot" data-mood="idle" aria-hidden="true"><span class="eyes"><i></i><i></i></span></span></a>
|
|
43
43
|
<span class="here" id="here"></span>
|
|
44
44
|
<span class="grow"></span>
|
|
45
45
|
<span id="mRight"></span>
|
|
@@ -47,12 +47,10 @@
|
|
|
47
47
|
<div class="scrim" id="scrim"></div>
|
|
48
48
|
<div class="shell">
|
|
49
49
|
<aside class="side" id="side">
|
|
50
|
-
<div class="brand"><a href="/" class="word" aria-label="nio">ni<span class="bot" data-mood="idle" aria-hidden="true"><span class="eyes"><i></i><i></i></span></span></a><button class="icon-btn collapse" id="collapse" type="button" aria-label="Plega el menú" aria-expanded="true"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="4.5" width="17" height="15" rx="2.5"/><path d="M9.5 4.5v15M15.5 10l-2 2 2 2"/></svg></button></div>
|
|
50
|
+
<div class="brand"><a href="/app" class="word" aria-label="nio">ni<span class="bot" data-mood="idle" aria-hidden="true"><span class="eyes"><i></i><i></i></span></span></a><button class="icon-btn collapse" id="collapse" type="button" aria-label="Plega el menú" aria-expanded="true"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="4.5" width="17" height="15" rx="2.5"/><path d="M9.5 4.5v15M15.5 10l-2 2 2 2"/></svg></button></div>
|
|
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>
|
|
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>
|
|
56
54
|
<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>
|
|
57
55
|
</div>
|
|
58
56
|
</aside>
|
|
@@ -69,7 +67,7 @@
|
|
|
69
67
|
<div class="tour-card" id="tourCard" role="dialog" aria-modal="false" aria-labelledby="tourT"></div>
|
|
70
68
|
</div>
|
|
71
69
|
<nav class="tabbar" id="tabbar" aria-label="Navegació"></nav>
|
|
72
|
-
<div class="toasts" id="toasts" aria-live="polite"></div>
|
|
70
|
+
<div class="toasts" id="toasts" popover="manual" aria-live="polite"></div>
|
|
73
71
|
|
|
74
72
|
<script src="/bot.js"></script>
|
|
75
73
|
<script src="/board.js"></script>
|
package/web/app/style.css
CHANGED
|
@@ -82,7 +82,12 @@ kbd { font: 600 11px var(--font); padding: 1px 6px; border: 1px solid var(--bord
|
|
|
82
82
|
body.side-collapsed .shell { grid-template-columns: 72px minmax(0, 1fr); }
|
|
83
83
|
body.side-collapsed .brand .collapse { transform: translateX(4px); }
|
|
84
84
|
body.side-collapsed .brand .collapse svg { transform: scaleX(-1); }
|
|
85
|
-
body.side-collapsed .brand .word
|
|
85
|
+
body.side-collapsed .brand .word { visibility: hidden; }
|
|
86
|
+
body.side-collapsed .side nav a .l, body.side-collapsed .kbd .l, body.side-collapsed .kbd kbd, body.side-collapsed .conn span, body.side-collapsed .conn svg { display: none; }
|
|
87
|
+
body.side-collapsed .conn { padding-left: 0; justify-content: center; }
|
|
88
|
+
body.side-collapsed .side nav .grp { font-size: 0; padding: 9px 13px 5px; }
|
|
89
|
+
body.side-collapsed .side nav .grp::before { content: ''; display: block; height: 1px; background: var(--border); }
|
|
90
|
+
body.side-collapsed .side nav .grp:first-child { display: none; }
|
|
86
91
|
body.side-collapsed .side nav a .badge { position: absolute; top: 2px; right: 2px; min-width: 16px; height: 16px; font-size: 9px; padding: 0 4px; }
|
|
87
92
|
}
|
|
88
93
|
|
|
@@ -454,7 +459,6 @@ video.scan { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 14p
|
|
|
454
459
|
.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); }
|
|
455
460
|
.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; }
|
|
456
461
|
.tabbar [aria-current="page"] { color: var(--accent); }
|
|
457
|
-
.tabbar [aria-current="page"]::before { content: ""; position: absolute; top: 0; left: 50%; width: 22px; height: 3px; margin-left: -11px; border-radius: 0 0 3px 3px; background: var(--accent); }
|
|
458
462
|
.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; }
|
|
459
463
|
body:not(.no-tabs):not(.kb) .main { padding-bottom: calc(64px + var(--safe-b)); }
|
|
460
464
|
body.no-tabs .tabbar, body.kb .tabbar { display: none; }
|
|
@@ -495,3 +499,67 @@ a.kbd { text-decoration: none; color: inherit; }
|
|
|
495
499
|
.composer .mic:hover { color: var(--fg); border-color: var(--fg); }
|
|
496
500
|
.composer .mic.rec { background: var(--red); border-color: var(--red); color: #fff; animation: pulse 1.1s ease-in-out infinite; }
|
|
497
501
|
.composer .mic.wait { opacity: .5; }
|
|
502
|
+
|
|
503
|
+
/* ---------- Ordre i text net ---------- */
|
|
504
|
+
.grid2.one { grid-template-columns: minmax(0, 1fr); max-width: 640px; }
|
|
505
|
+
.top h1, .top p, .panel.empty h2, .panel.empty p, .stepper p, .stepper b, .help-body p, .card p, .sheet h2, .sheet p, .tour-card h3, .tour-card p, .muted.small { text-wrap: balance; }
|
|
506
|
+
@media (max-width: 820px) {
|
|
507
|
+
.side nav a[href="#/models"] { display: none; }
|
|
508
|
+
.top p, .panel.empty p, .stepper p, .help-body p, .sheet p, .tour-card p, .computer .muted { text-wrap: balance; }
|
|
509
|
+
.panel.empty { text-align: center; }
|
|
510
|
+
.panel.empty .stepper { text-align: left; }
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/* ---------- Afegeix un ordinador ---------- */
|
|
514
|
+
dialog.sheet.wiz.compact { height: fit-content; max-height: min(86dvh, 720px); }
|
|
515
|
+
dialog.sheet.wiz.compact[open] { display: flex; flex-direction: column; }
|
|
516
|
+
.compact .wiz-body { min-height: 330px; padding-top: 12px; gap: 18px; }
|
|
517
|
+
.compact .wiz-head { gap: 14px; }
|
|
518
|
+
.compact .wiz-head .icon-btn { margin-right: -8px; }
|
|
519
|
+
.compact .wiz-head h2 { font-size: 22px; font-weight: 500; letter-spacing: -.045em; }
|
|
520
|
+
.compact .seg button svg { width: 16px; height: 16px; }
|
|
521
|
+
.compact .seg button[aria-pressed="true"] { box-shadow: 0 1px 2px color-mix(in srgb, var(--fg) 12%, transparent); }
|
|
522
|
+
.add-intro { display: grid; gap: 4px; }
|
|
523
|
+
.add-intro h3 { font-size: 18px; font-weight: 500; letter-spacing: -.03em; line-height: 1.25; }
|
|
524
|
+
.add-intro p, .hint { font-size: 14px; color: var(--fg-muted); text-wrap: pretty; }
|
|
525
|
+
.hint { font-size: 13px; }
|
|
526
|
+
.field-row { display: flex; gap: 8px; }
|
|
527
|
+
.field-row input { flex: 1; min-width: 0; height: 46px; }
|
|
528
|
+
.field-row .btn { height: 46px; padding: 0 18px; flex: none; }
|
|
529
|
+
.hosts { display: grid; gap: 8px; font-size: 12px; font-weight: 600; letter-spacing: .02em; color: var(--fg-muted); }
|
|
530
|
+
.hosts .row { gap: 6px; }
|
|
531
|
+
.todo { list-style: none; display: grid; gap: 14px; counter-reset: t; }
|
|
532
|
+
.todo li { display: grid; grid-template-columns: 24px 1fr; gap: 12px; align-items: start; font-size: 14px; }
|
|
533
|
+
.todo .n { width: 24px; height: 24px; display: grid; place-items: center; border-radius: 50%; background: var(--bg-2); font-size: 12px; font-weight: 600; margin-top: -1px; }
|
|
534
|
+
.todo li > div { display: grid; gap: 8px; min-width: 0; }
|
|
535
|
+
.todo .cmd { margin-top: 2px; }
|
|
536
|
+
.live { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border-radius: 10px; background: var(--panel); border: 1px solid var(--border); font-size: 13px; color: var(--fg-muted); }
|
|
537
|
+
.live .pulse { width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--accent); animation: pulse 1.4s ease-in-out infinite; }
|
|
538
|
+
.checks { list-style: none; display: grid; gap: 8px; padding-top: 16px; border-top: 1px solid var(--border); font-size: 13px; color: var(--fg-muted); }
|
|
539
|
+
.checks li { display: flex; gap: 10px; align-items: baseline; }
|
|
540
|
+
.checks li::before { content: '✓'; color: var(--moss); font-weight: 700; }
|
|
541
|
+
.or { display: flex; align-items: center; gap: 12px; font-size: 12px; color: var(--fg-muted); }
|
|
542
|
+
.or::before, .or::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
|
543
|
+
.scan { width: 100%; border-radius: 12px; background: #000; aspect-ratio: 4 / 3; object-fit: cover; }
|
|
544
|
+
.sheet p { text-wrap: pretty; }
|
|
545
|
+
@media (max-width: 820px) {
|
|
546
|
+
.compact .wiz-body { min-height: 300px; }
|
|
547
|
+
.compact .seg button span { white-space: nowrap; }
|
|
548
|
+
}
|
|
549
|
+
dialog.sheet.wiz.compact .sheet-in, .compact .wiz-body { flex: 0 1 auto; }
|
|
550
|
+
.compact .wiz-body { overflow-y: auto; }
|
|
551
|
+
|
|
552
|
+
/* Alertes: capa superior (popover) i gest de lliscar */
|
|
553
|
+
.toasts[popover] { inset: auto 20px calc(20px + var(--safe-b)) auto; margin: 0; padding: 0; border: 0; background: none; color: inherit; overflow: visible; width: auto; height: auto; max-width: calc(100vw - 24px); display: none; }
|
|
554
|
+
.toasts[popover]:popover-open { display: grid; }
|
|
555
|
+
@media (max-width: 820px) { .toasts[popover] { inset: auto 12px calc(12px + var(--safe-b)) 12px; max-width: none; } body.chat-mode .toasts[popover] { bottom: calc(84px + var(--safe-b)); } }
|
|
556
|
+
.toast { touch-action: pan-y; user-select: none; -webkit-user-select: none; cursor: grab; box-shadow: 0 8px 24px color-mix(in srgb, var(--fg) 22%, transparent); }
|
|
557
|
+
.toast.out { opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s; }
|
|
558
|
+
|
|
559
|
+
/* Bot de l'agent dins la pastilla: enganxat a l'esquerra, centrat en vertical */
|
|
560
|
+
.chip:has(.abot) { padding: 0 11px 0 3px; }
|
|
561
|
+
.chip .abot { line-height: 0; align-items: center; }
|
|
562
|
+
.chip .abot .bot { --s: 20px; --bw: .15; }
|
|
563
|
+
.chip .agent-tag { gap: 7px; }
|
|
564
|
+
.sheet .list + .or { margin-top: 18px; }
|
|
565
|
+
.sheet #modelForm { margin-top: 4px; }
|
package/web/board.css
CHANGED
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
@keyframes bd-msg { from { opacity: 0; transform: translateY(6px); } }
|
|
107
107
|
.bd-chat .chips { display: flex; gap: 6px; overflow-x: auto; padding: 0 16px 10px; scrollbar-width: none; }
|
|
108
108
|
.bd-chat .chips::-webkit-scrollbar { display: none; }
|
|
109
|
-
.bd-chat .chip-s { flex: none; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding:
|
|
109
|
+
.bd-chat .chip-s { flex: none; max-width: 260px; box-sizing: border-box; height: 32px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 12px; line-height: 30px; text-align: center; border: 1px solid var(--border); border-radius: 999px; background: var(--white); font-size: 12px; font-weight: 500; cursor: pointer; }
|
|
110
110
|
.bd-chat .chip-s:hover { border-color: var(--fg); }
|
|
111
111
|
.bd-chat .cin { display: flex; gap: 8px; align-items: flex-end; padding: 12px 14px calc(14px + env(safe-area-inset-bottom)); border-top: 1px solid var(--border); }
|
|
112
112
|
.bd-chat .cin textarea { flex: 1; min-height: 42px; max-height: 140px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--white); resize: none; font: inherit; font-size: 14px; line-height: 1.4; outline: none; }
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
.bd-style { display: none; }
|
|
133
133
|
.style-open .bd-style:not([hidden]) { display: grid; left: 10px; right: 10px; top: auto; bottom: calc(70px + env(safe-area-inset-bottom)); width: auto; grid-template-columns: 1fr 1fr; }
|
|
134
134
|
.bd-help .cols { grid-template-columns: 1fr; }
|
|
135
|
-
.bd-chat { position: absolute; left: 0; right: 0; bottom: 0; z-index: 9; width: auto; height: min(
|
|
135
|
+
.bd-chat { position: absolute; top: auto; left: 0; right: 0; bottom: 0; z-index: 9; width: auto; height: min(78%, 600px); border: 0; border-top: 1px solid var(--border); border-radius: 20px 20px 0 0; transform: translateY(105%); transition: transform .34s var(--bd-ease), visibility 0s linear .34s; box-shadow: 0 -20px 50px -30px color-mix(in srgb, var(--fg) 55%, transparent); }
|
|
136
136
|
.chat-open .bd-chat { transform: none; transition: transform .34s var(--bd-ease), visibility 0s; }
|
|
137
137
|
.bd-chat header::before { content: ""; position: absolute; top: 6px; left: 50%; width: 36px; height: 4px; margin-left: -18px; border-radius: 4px; background: var(--border); }
|
|
138
138
|
.bd-empty { top: 42%; }
|
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
|
|
141
141
|
/* El llenç s'adapta a la seva amplada real (tauleta, xat obert, mòbil) */
|
|
142
142
|
@container stage (max-width: 720px) {
|
|
143
|
-
.bd-chat { position: absolute; left: 0; right: 0; bottom: 0; z-index: 9; width: auto; height: min(
|
|
143
|
+
.bd-chat { position: absolute; top: auto; left: 0; right: 0; bottom: 0; z-index: 9; width: auto; height: min(78%, 600px); border: 0; border-top: 1px solid var(--border); border-radius: 20px 20px 0 0; transform: translateY(105%); transition: transform .34s var(--bd-ease), visibility 0s linear .34s; box-shadow: 0 -20px 50px -30px color-mix(in srgb, var(--fg) 55%, transparent); }
|
|
144
144
|
.chat-open .bd-chat { transform: none; transition: transform .34s var(--bd-ease), visibility 0s; }
|
|
145
145
|
.bd-chat header::before { content: ""; position: absolute; top: 6px; left: 50%; width: 36px; height: 4px; margin-left: -18px; border-radius: 4px; background: var(--border); }
|
|
146
146
|
.chat-open .bd-tools, .chat-open .bd-right { transform: none; }
|
|
@@ -168,3 +168,24 @@
|
|
|
168
168
|
.bd-tools .sep { display: none; }
|
|
169
169
|
.bd-tools .tb { flex: 1 1 0; min-width: 30px; width: auto; height: 38px; }
|
|
170
170
|
}
|
|
171
|
+
|
|
172
|
+
/* Xat com a drawer inferior (mòbil, tauleta i llenç estret) */
|
|
173
|
+
.bd-scrim { display: none; }
|
|
174
|
+
.bd-chat header { position: relative; touch-action: none; cursor: grab; }
|
|
175
|
+
.bd-chat.dragging { transition: none !important; }
|
|
176
|
+
@media (max-width: 820px) {
|
|
177
|
+
.bd-scrim { display: block; position: absolute; inset: 0; z-index: 8; border: 0; background: color-mix(in srgb, var(--fg) 30%, transparent); opacity: 0; visibility: hidden; transition: opacity .3s, visibility 0s linear .3s; }
|
|
178
|
+
.chat-open .bd-scrim { opacity: 1; visibility: visible; transition: opacity .3s, visibility 0s; }
|
|
179
|
+
.bd-chat { max-width: 640px; margin: 0 auto; }
|
|
180
|
+
.bd-chat header { padding-top: 22px; }
|
|
181
|
+
.bd-chat .msgs { overscroll-behavior: contain; }
|
|
182
|
+
.bd-chat .cin textarea { font-size: 16px; }
|
|
183
|
+
.bd-chat .cin { padding-bottom: calc(12px + env(safe-area-inset-bottom)); }
|
|
184
|
+
}
|
|
185
|
+
@container stage (max-width: 720px) {
|
|
186
|
+
.bd-scrim { display: block; position: absolute; inset: 0; z-index: 8; border: 0; background: color-mix(in srgb, var(--fg) 30%, transparent); opacity: 0; visibility: hidden; transition: opacity .3s, visibility 0s linear .3s; }
|
|
187
|
+
.chat-open .bd-scrim { opacity: 1; visibility: visible; transition: opacity .3s, visibility 0s; }
|
|
188
|
+
.bd-chat { max-width: 640px; margin: 0 auto; }
|
|
189
|
+
.bd-chat header { padding-top: 22px; }
|
|
190
|
+
.bd-chat .cin textarea { font-size: 16px; }
|
|
191
|
+
}
|
package/web/board.js
CHANGED
|
@@ -1019,6 +1019,14 @@ function onKey(e) {
|
|
|
1019
1019
|
}
|
|
1020
1020
|
function onKeyUp(e) { if (e.key === ' ') { spaceDown = false; R && R.stage.classList.remove('space'); } }
|
|
1021
1021
|
|
|
1022
|
+
function chatSwipe(chat) {
|
|
1023
|
+
const head = chat.querySelector('header'); let y0 = null, dy = 0;
|
|
1024
|
+
const drawer = () => getComputedStyle(chat).bottom === '0px' && getComputedStyle(chat).left === '0px' && getComputedStyle(chat).top !== '0px';
|
|
1025
|
+
head.addEventListener('pointerdown', (e) => { if (e.target.closest('button') || !drawer()) return; y0 = e.clientY; dy = 0; head.setPointerCapture(e.pointerId); chat.classList.add('dragging'); });
|
|
1026
|
+
head.addEventListener('pointermove', (e) => { if (y0 === null) return; dy = Math.max(0, e.clientY - y0); chat.style.transform = `translateY(${dy}px)`; });
|
|
1027
|
+
const end = () => { if (y0 === null) return; y0 = null; chat.classList.remove('dragging'); chat.style.transform = ''; if (dy > 70) openChat(false); };
|
|
1028
|
+
head.addEventListener('pointerup', end); head.addEventListener('pointercancel', end);
|
|
1029
|
+
}
|
|
1022
1030
|
function openChat(open, focus = true, persist = true) {
|
|
1023
1031
|
if (persist) { B.opts.chat = open; save(); }
|
|
1024
1032
|
if (!R) return;
|
|
@@ -1107,6 +1115,7 @@ function template() {
|
|
|
1107
1115
|
</div>
|
|
1108
1116
|
</div>
|
|
1109
1117
|
|
|
1118
|
+
<button type="button" class="bd-scrim" data-bd="chat" aria-label="Tanca el xat" tabindex="-1"></button>
|
|
1110
1119
|
<aside class="bd-chat" id="bdChat" aria-label="Xat amb en Nio">
|
|
1111
1120
|
<header><span class="who">${window.NioBot ? NioBot.html('idle', 'hbot') : ''}<span><b>Nio</b><small>Et dibuixa el que li demanis</small></span></span><button type="button" class="ib" data-bd="chat" aria-label="Tanca el xat">${svgI(ICON.close)}</button></header>
|
|
1112
1121
|
<label class="brain"><span>Amb</span><select id="bdBrain" aria-label="Què fa servir el xat"></select></label>
|
|
@@ -1171,6 +1180,7 @@ function mount(root) {
|
|
|
1171
1180
|
on(document, 'click', e => { if (R && !e.target.closest('.bd-menuwrap')) { const m = R.root.querySelector('#bdMenu'); if (m) m.hidden = true; } });
|
|
1172
1181
|
on(window, 'resize', () => { applyView(); drawSel(); });
|
|
1173
1182
|
if (document.fonts) document.fonts.load(`500 24px Caveat`).then(() => { if (R) { B.els.forEach(el => { if (el.type === 'text' && el.text) fitText(el); }); full(); } }).catch(() => {});
|
|
1183
|
+
chatSwipe(R.chat);
|
|
1174
1184
|
full(); drawChat(); drawBrains(); setTool(tool); openChat(B.opts.chat && !matchMedia('(max-width: 820px)').matches, false, false);
|
|
1175
1185
|
window.NioBot?.refresh();
|
|
1176
1186
|
}
|
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-v14';
|
|
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) => {
|