nio-cat 0.4.4 → 0.5.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 +29 -6
- package/package.json +1 -1
- package/src/agents/claude.js +3 -1
- package/src/commands.js +12 -1
- package/src/hub.js +27 -0
- package/src/server.js +9 -0
- package/src/tunnel.js +22 -1
- package/web/app/app.js +184 -69
- package/web/app/index.html +3 -5
- package/web/app/style.css +80 -6
- 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';
|
|
@@ -173,10 +187,6 @@ async function start() {
|
|
|
173
187
|
if (ip) {
|
|
174
188
|
publicUrl = publicUrl || `http://${ip}:${cfg.port}`;
|
|
175
189
|
console.log(` ${c.dim('Xarxa:')} ${publicUrl} ${c.dim('(només la mateixa wifi)')}`);
|
|
176
|
-
if (!flags.url) {
|
|
177
|
-
// A la xarxa local, l'app es carrega des del mateix daemon (mateix origen).
|
|
178
|
-
flags.app = publicUrl;
|
|
179
|
-
}
|
|
180
190
|
}
|
|
181
191
|
}
|
|
182
192
|
|
|
@@ -188,6 +198,17 @@ async function start() {
|
|
|
188
198
|
return showPair(publicUrl);
|
|
189
199
|
}
|
|
190
200
|
|
|
201
|
+
if (fixed) {
|
|
202
|
+
const nt = startNamedTunnel(cfg.tunnelToken, () => {
|
|
203
|
+
publicUrl = cfg.fixedUrl;
|
|
204
|
+
console.log(` ${c.dim('Adreça fixa:')} ${publicUrl}`);
|
|
205
|
+
cfg.lastUrl = publicUrl;
|
|
206
|
+
config.save(cfg);
|
|
207
|
+
showPair(publicUrl);
|
|
208
|
+
});
|
|
209
|
+
if (nt) return console.log(c.dim(' Obrint el túnel fix…'));
|
|
210
|
+
}
|
|
211
|
+
|
|
191
212
|
const t = startTunnel(cfg.port, (url) => {
|
|
192
213
|
publicUrl = url;
|
|
193
214
|
console.log(` ${c.dim('Túnel:')} ${url} ${c.dim('(Cloudflare, sense cost)')}`);
|
|
@@ -242,6 +263,8 @@ async function start() {
|
|
|
242
263
|
--lan Connexió dins la mateixa wifi
|
|
243
264
|
--url <adreça> Una adreça pública teva (Tailscale o similar)
|
|
244
265
|
--no-tunnel Només des d'aquest ordinador
|
|
266
|
+
--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)
|
|
267
|
+
--forget-fixed Oblida l'adreça fixa i torna al túnel temporal
|
|
245
268
|
--port <n> Port local (7717 si no dius res)
|
|
246
269
|
--no-open No obris el panell al navegador d'aquest ordinador
|
|
247
270
|
--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/agents/claude.js
CHANGED
|
@@ -77,7 +77,7 @@ function handle(ctx, line) {
|
|
|
77
77
|
}
|
|
78
78
|
case 'assistant':
|
|
79
79
|
for (const b of ev.message?.content || []) {
|
|
80
|
-
if (b.type === 'text' && b.text) ctx.endStream(b.text);
|
|
80
|
+
if (b.type === 'text' && b.text) { ctx.said = true; ctx.endStream(b.text); }
|
|
81
81
|
else if (b.type === 'tool_use') ctx.tool({ id: b.id, name: b.name, input: b.input });
|
|
82
82
|
}
|
|
83
83
|
break;
|
|
@@ -93,6 +93,8 @@ function handle(ctx, line) {
|
|
|
93
93
|
break;
|
|
94
94
|
case 'result':
|
|
95
95
|
if (ev.session_id) ctx.setNative(ev.session_id);
|
|
96
|
+
// Les comandes locals (/cost, /context…) no passen pel model: la resposta només és aquí.
|
|
97
|
+
if (!ctx.said && !ev.is_error && !ev.num_turns) ctx.endStream(typeof ev.result === 'string' && ev.result.trim() ? ev.result : 'Fet.');
|
|
96
98
|
ctx.result({
|
|
97
99
|
cost: ev.total_cost_usd,
|
|
98
100
|
ms: ev.duration_ms,
|
package/src/commands.js
CHANGED
|
@@ -23,8 +23,18 @@ function listDir(dir) {
|
|
|
23
23
|
try { return fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Comandes internes de Claude Code que també funcionen en mode headless (les altres, com /help o /status, no hi són).
|
|
27
|
+
const CLAUDE_BUILTIN = [
|
|
28
|
+
{ name: 'usage', hint: 'Ús de la subscripció i límits' },
|
|
29
|
+
{ name: 'context', hint: 'Mostra com s’omple el context' },
|
|
30
|
+
{ name: 'compact', hint: 'Resumeix la conversa per alliberar context' },
|
|
31
|
+
{ name: 'init', hint: 'Crea un CLAUDE.md al projecte' },
|
|
32
|
+
{ name: 'review', hint: 'Revisa els canvis de codi' },
|
|
33
|
+
{ name: 'security-review', hint: 'Revisió de seguretat dels canvis' },
|
|
34
|
+
];
|
|
35
|
+
|
|
26
36
|
function claudeCommands(cwd) {
|
|
27
|
-
const out = new Map();
|
|
37
|
+
const out = new Map(CLAUDE_BUILTIN.map((c) => [c.name, { ...c, kind: 'comanda' }]));
|
|
28
38
|
const bases = [path.join(os.homedir(), '.claude'), cwd && path.join(cwd, '.claude')].filter(Boolean);
|
|
29
39
|
for (const base of bases) {
|
|
30
40
|
// Comandes: commands/nom.md i commands/espai/nom.md (→ «espai:nom»)
|
|
@@ -45,6 +55,7 @@ function claudeCommands(cwd) {
|
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
function agentCommands(agent, cwd) {
|
|
58
|
+
if (agent === 'codex') return [{ name: 'status', hint: 'Compte, model i carpeta de la sessió', kind: 'comanda' }];
|
|
48
59
|
return agent === 'claude' ? claudeCommands(cwd) : [];
|
|
49
60
|
}
|
|
50
61
|
|
package/src/hub.js
CHANGED
|
@@ -163,12 +163,38 @@ class Hub extends EventEmitter {
|
|
|
163
163
|
this.emit('out', { type: 'removed', session: sid });
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// Codex no té /status en mode headless: ho resolem aquí amb el que sí que sap dir.
|
|
167
|
+
codexStatus(s, bp) {
|
|
168
|
+
const { execFile } = require('child_process');
|
|
169
|
+
execFile(bp, ['login', 'status'], { timeout: 8000 }, (err, out, errOut) => {
|
|
170
|
+
const rows = String(out || errOut || '').split('\n').map((l) => l.trim()).filter(Boolean), login = rows.find((l) => /logged|log in|login/i.test(l) && !/^warning/i.test(l)) || rows[0] || (err ? 'No he pogut llegir el compte' : '');
|
|
171
|
+
const lines = [`**Compte:** ${login}`, `**Model:** ${s.model || s.modelUsed || 'el de per defecte'}`, `**Carpeta:** \`${s.cwd}\``, `**Permisos:** ${s.mode}`, `**Conversa:** ${s.nativeId ? 'continua la de Codex' : 'encara no ha començat'}`];
|
|
172
|
+
this.push(s, 'text', { text: lines.join('\n\n') });
|
|
173
|
+
this.touch(s);
|
|
174
|
+
});
|
|
175
|
+
return this.summary(s);
|
|
176
|
+
}
|
|
177
|
+
|
|
166
178
|
update(sid, patch) {
|
|
167
179
|
const s = this.get(sid);
|
|
168
180
|
if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
|
|
169
181
|
if (patch.mode && ['llegir', 'preguntar', 'tot'].includes(patch.mode)) s.mode = patch.mode;
|
|
170
182
|
if (typeof patch.title === 'string') s.title = patch.title.slice(0, 120);
|
|
171
183
|
if (typeof patch.model === 'string') s.model = patch.model.slice(0, 80);
|
|
184
|
+
if (typeof patch.cwd === 'string' && path.resolve(patch.cwd) !== s.cwd) {
|
|
185
|
+
const cwd = path.resolve(patch.cwd);
|
|
186
|
+
if (s.state === 'running' || s.state === 'waiting') throw httpErr(409, 'Espera que l’agent acabi o atura’l abans de canviar de carpeta');
|
|
187
|
+
let ok = false;
|
|
188
|
+
try { ok = fs.statSync(cwd).isDirectory(); } catch { /* no existeix */ }
|
|
189
|
+
if (!ok) throw httpErr(400, 'Aquesta carpeta no existeix');
|
|
190
|
+
// Claude Code desa cada conversa dins la carpeta del projecte: la copiem perquè l'agent la recordi.
|
|
191
|
+
if (s.agent === 'claude' && s.nativeId) {
|
|
192
|
+
const enc = (d) => path.join(require('os').homedir(), '.claude', 'projects', d.replace(/[^a-zA-Z0-9]/g, '-'), `${s.nativeId}.jsonl`);
|
|
193
|
+
try { fs.mkdirSync(path.dirname(enc(cwd)), { recursive: true }); fs.copyFileSync(enc(s.cwd), enc(cwd)); } catch { s.nativeId = null; }
|
|
194
|
+
}
|
|
195
|
+
s.cwd = cwd;
|
|
196
|
+
this.push(s, 'info', { text: `Carpeta canviada a ${cwd}` });
|
|
197
|
+
}
|
|
172
198
|
this.touch(s);
|
|
173
199
|
return this.summary(s);
|
|
174
200
|
}
|
|
@@ -201,6 +227,7 @@ class Hub extends EventEmitter {
|
|
|
201
227
|
|
|
202
228
|
if (!s.title) s.title = text.replace(/\s+/g, ' ').slice(0, 60);
|
|
203
229
|
this.push(s, 'user', { text });
|
|
230
|
+
if (s.agent === 'codex' && /^\/status\s*$/i.test(text)) return this.codexStatus(s, bp);
|
|
204
231
|
s.state = 'running';
|
|
205
232
|
this.push(s, 'status', { state: 'running' });
|
|
206
233
|
|
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 */
|
|
@@ -507,7 +582,7 @@ function viewSessions() {
|
|
|
507
582
|
const many = store.machines.length > 1, mach = machine();
|
|
508
583
|
const switcher = many ? `<button class="chip switch-chip" type="button" data-act="machines"><i class="dot ${state.connected ? 'on' : ''}"></i>${esc(mach.name)}${svg(ICON.chev)}</button>` : '';
|
|
509
584
|
const hint = mobileHint();
|
|
510
|
-
const head = `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p>${switcher}</div><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>${hint}`;
|
|
585
|
+
const head = `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p>${switcher}</div><button class="btn ink new-fab" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>${hint}`;
|
|
511
586
|
if (!state.connected && !list.length) {
|
|
512
587
|
return `${head}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p>${mach.kind === 'vps' ? 'Potser el servidor s’ha reiniciat i l’adreça ha canviat.' : 'Comprova que nio continua engegat a l’ordinador i que el túnel està obert.'}</p><div class="row" style="justify-content:center"><button class="btn" data-act="retry" type="button">${svg(ICON.refresh)}Torna-ho a provar</button>${mach.kind === 'vps' && mach.via ? `<button class="btn ink" data-reconnect="${store.active}" type="button">Torna a connectar</button>` : ''}<a class="btn ghost" href="#/ordinadors">Ordinadors</a></div>` : ''}</div>`;
|
|
513
588
|
}
|
|
@@ -605,7 +680,7 @@ function renderSessionHead() {
|
|
|
605
680
|
const ag = state.info?.agents?.find((a) => a.id === s.agent);
|
|
606
681
|
bar.innerHTML = `<span class="chip"><span class="agent-tag nosq" data-agent="${esc(s.agent)}">${agentBot(s.agent, sessionMood(s))}${esc(AGENT_NAMES[s.agent] || s.agent)}</span></span>
|
|
607
682
|
${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${esc(MODES[s.mode]?.label || s.mode)}</button>`}
|
|
608
|
-
<
|
|
683
|
+
<button class="chip mono" data-act="folder" type="button" title="${esc(s.cwd)} · canvia de carpeta">${esc(basename(s.cwd))}</button>
|
|
609
684
|
<button class="chip mono" data-act="model" type="button" title="Canvia de model">${esc(s.model || 'Model per defecte')}</button>
|
|
610
685
|
${s.cost ? `<span class="chip mono">${s.cost.toFixed(2)} $</span>` : ''}
|
|
611
686
|
${stateChip(s)}`;
|
|
@@ -682,6 +757,7 @@ const NIO_CMDS = [
|
|
|
682
757
|
{ name: 'model', hint: 'Canvia de model' },
|
|
683
758
|
{ name: 'stop', hint: 'Atura l’agent' },
|
|
684
759
|
{ name: 'nova', hint: 'Nova sessió amb el mateix agent i carpeta' },
|
|
760
|
+
{ name: 'carpeta', hint: 'Canvia la carpeta de la sessió' },
|
|
685
761
|
{ name: 'git', hint: 'Git i GitHub: commit i PR' },
|
|
686
762
|
{ name: 'nom', hint: 'Posa nom a la sessió', free: true },
|
|
687
763
|
{ name: 'opcions', hint: 'Opcions de la sessió' },
|
|
@@ -706,12 +782,33 @@ async function runCmd(s, name, arg) {
|
|
|
706
782
|
case 'model': return arg ? patch({ model: arg }, `Model: ${arg}`) : modelSheet(s);
|
|
707
783
|
case 'stop': try { await api(`/api/sessions/${s.id}/stop`, { method: 'POST' }); toast('Aturat'); } catch (e) { toast(e.message); } return;
|
|
708
784
|
case 'nova': return newSessionSheet({ agent: s.agent, cwd: s.cwd, mode: s.mode });
|
|
785
|
+
case 'carpeta': return folderSheet(s);
|
|
709
786
|
case 'git': return gitSheet(s.id);
|
|
710
787
|
case 'nom': return arg ? patch({ title: arg }, 'Nom desat') : toast('Escriu el nom: /nom Arregla els tests');
|
|
711
788
|
case 'opcions': return sessionOptions(s.id);
|
|
712
789
|
case 'ajuda': return helpCmdsSheet(s);
|
|
713
790
|
}
|
|
714
791
|
}
|
|
792
|
+
function folderSheet(s) {
|
|
793
|
+
let d = null;
|
|
794
|
+
const draw = () => {
|
|
795
|
+
const crumbs = d ? d.path.split(/[\\/]/).filter(Boolean) : [];
|
|
796
|
+
$('#fsBody').innerHTML = !d ? '<p class="muted small">Carregant…</p>' : `<div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(d.parent || '')}" ${d.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><button class="chip" type="button" data-fs="${esc(d.home)}">Inici</button><span class="fs-path mono small" title="${esc(d.path)}">${esc(crumbs.slice(-2).join(' / ') || '/')}</span></div>
|
|
797
|
+
<div class="proj-list fs-list">${d.dirs.map((x) => `<button type="button" class="proj" data-fs="${esc(x.path)}"><span class="ic">›</span><span class="grow"><div ${x.hidden ? 'class="muted"' : ''}>${esc(x.name)}</div></span></button>`).join('') || '<p class="muted small">No hi ha subcarpetes. Pots triar aquesta.</p>'}</div>`;
|
|
798
|
+
const ok = $('#fsOk'); if (ok) { ok.disabled = !d || d.path === s.cwd; ok.textContent = d && d.path === s.cwd ? 'Ja hi és' : 'Treballa aquí'; }
|
|
799
|
+
};
|
|
800
|
+
const go = async (p) => { try { d = await api(`/api/fs?path=${encodeURIComponent(p || '')}`); } catch (e) { toast(e.message, 4200); } draw(); };
|
|
801
|
+
openSheet(`<h2>Canvia de carpeta</h2><p class="muted small">Ara: <span class="mono">${esc(s.cwd)}</span>. La conversa continua i l’agent la recorda.</p><div id="fsBody"></div><div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" id="fsOk" type="button" disabled>Treballa aquí</button></div>`, (sh) => {
|
|
802
|
+
sh.onclick = async (e) => {
|
|
803
|
+
if (e.target.closest('[data-close]')) return closeSheet();
|
|
804
|
+
const f = e.target.closest('[data-fs]'); if (f && !f.disabled) return go(f.dataset.fs);
|
|
805
|
+
if (e.target.closest('#fsOk') && d) {
|
|
806
|
+
try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body: { cwd: d.path } }); state.sessions.set(s.id, sum); closeSheet(); renderSessionHead(); cmdCache.delete(s.id); toast(`Carpeta: ${basename(sum.cwd)}`); } catch (er) { toast(er.message, 4200); }
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
draw(); go(s.cwd);
|
|
810
|
+
});
|
|
811
|
+
}
|
|
715
812
|
function helpCmdsSheet(s) {
|
|
716
813
|
const ac = agentCmds(s.id);
|
|
717
814
|
openSheet(`<h2>Comandes</h2><p class="muted small">Escriu <span class="mono">/</span> al missatge per veure-les.</p><div class="list">${NIO_CMDS.map((c) => `<div class="card"><b class="mono">/${c.name}</b>${c.args ? ' <span class="muted small mono">l · p · t</span>' : ''}<div class="muted small">${esc(c.hint)}</div></div>`).join('')}</div>${ac.length ? `<p class="muted small">De ${esc(AGENT_NAMES[s.agent] || s.agent)}: ${ac.length} comandes i skills. Se li envien a l’agent tal qual.</p>` : ''}<div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
|
|
@@ -723,7 +820,7 @@ function slashItems(s, v) {
|
|
|
723
820
|
if (!m) return [];
|
|
724
821
|
const q = m[1].toLowerCase();
|
|
725
822
|
const nio = NIO_CMDS.map((c) => ({ ...c, tag: 'nio' }));
|
|
726
|
-
const ag = agentCmds(s.id).map((c) => ({ name: c.name, hint: c.hint, tag: AGENT_NAMES[s.agent] || s.agent, agent: true }));
|
|
823
|
+
const ag = agentCmds(s.id).map((c) => ({ name: c.name, hint: c.hint, tag: (AGENT_NAMES[s.agent] || s.agent).split(' ')[0], agent: true }));
|
|
727
824
|
const all = [...nio, ...ag], starts = all.filter((c) => c.name.toLowerCase().startsWith(q)), has = all.filter((c) => !c.name.toLowerCase().startsWith(q) && c.name.toLowerCase().includes(q));
|
|
728
825
|
return [...starts, ...has].slice(0, 40).map((c) => ({ ...c, label: `/${c.name}` }));
|
|
729
826
|
}
|
|
@@ -734,7 +831,7 @@ function renderComposer() {
|
|
|
734
831
|
if (box.dataset.busy === String(busy) && $('textarea', box)) return;
|
|
735
832
|
box.dataset.busy = String(busy);
|
|
736
833
|
if (s) loadCmds(s.id).then(() => { if (state.route.id === s.id) drawSlash(); });
|
|
737
|
-
box.innerHTML = `<div class="slash" id="slash" role="listbox" hidden></div><div class="inner"><textarea rows="1" id="msg" placeholder="${busy ? 'L’agent està treballant…' : 'Escriu a l’agent…
|
|
834
|
+
box.innerHTML = `<div class="slash" id="slash" role="listbox" hidden></div><div class="inner"><textarea rows="1" id="msg" placeholder="${busy ? 'L’agent està treballant…' : 'Escriu a l’agent…'}" enterkeyhint="send" aria-label="Missatge" aria-controls="slash"></textarea><button class="mic" id="micBtn" type="button" data-mic aria-label="Dicta amb el micròfon">${svg(ICON.mic)}</button><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
|
|
738
835
|
const ta = $('#msg'); ta.value = prev;
|
|
739
836
|
const grow = () => autogrow(ta);
|
|
740
837
|
let items = [], sel = 0;
|
|
@@ -754,7 +851,8 @@ function renderComposer() {
|
|
|
754
851
|
ta.value = ''; grow(); drawSlash(); runCmd(cur, c.name, '');
|
|
755
852
|
};
|
|
756
853
|
ta.addEventListener('input', () => { grow(); sel = 0; drawSlash(); });
|
|
757
|
-
pop.addEventListener('
|
|
854
|
+
pop.addEventListener('mousedown', (e) => e.preventDefault());
|
|
855
|
+
pop.addEventListener('click', (e) => { const b = e.target.closest('[data-i]'); if (b) pick(+b.dataset.i); });
|
|
758
856
|
const send = async () => {
|
|
759
857
|
const text = ta.value.trim(); if (!text || !s) return;
|
|
760
858
|
const c = text.match(/^\/([\w:.-]+)(?:\s+([\s\S]*))?$/), nio = c && NIO_CMDS.find((x) => x.name === c[1].toLowerCase());
|
|
@@ -778,8 +876,8 @@ function renderComposer() {
|
|
|
778
876
|
|
|
779
877
|
/* ---------- Fulls */
|
|
780
878
|
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;
|
|
879
|
+
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}`;
|
|
880
|
+
if (!d.open) d.showModal(); raiseToasts(); if (T.on) $('#tour').hidden = true;
|
|
783
881
|
onMount?.($('#sheetIn'));
|
|
784
882
|
}
|
|
785
883
|
const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); };
|
|
@@ -817,7 +915,7 @@ async function newSessionSheet(preset = {}) {
|
|
|
817
915
|
const body = () => { $('#wizBody', sh).innerHTML = VIEWS_[step](); after(); };
|
|
818
916
|
const head = () => {
|
|
819
917
|
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"
|
|
918
|
+
$('#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
919
|
<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
920
|
<p class="muted small">Pas ${step + 1} de ${STEPS.length} · ${STEPS[step]}</p>`;
|
|
823
921
|
$('#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 +956,7 @@ async function newSessionSheet(preset = {}) {
|
|
|
858
956
|
};
|
|
859
957
|
|
|
860
958
|
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>`,
|
|
959
|
+
() => `<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
960
|
() => {
|
|
863
961
|
const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
|
|
864
962
|
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 +1032,7 @@ async function modelSheet(s) {
|
|
|
934
1032
|
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
1033
|
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
1034
|
<div class="list">${row('', 'Per defecte', 'El que l’agent faci servir sol')}${picks.map((x) => row(...x)).join('')}</div>
|
|
937
|
-
<form class="
|
|
1035
|
+
<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
1036
|
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
1037
|
$$('[data-model]', sh).forEach((b) => (b.onclick = () => save(b.dataset.model)));
|
|
940
1038
|
$('#modelForm', sh).onsubmit = (e) => { e.preventDefault(); e.stopPropagation(); save($('#modelFree', sh).value.trim()); };
|
|
@@ -1112,8 +1210,10 @@ window.NioBrains = {
|
|
|
1112
1210
|
}
|
|
1113
1211
|
if (id === 'local') {
|
|
1114
1212
|
if (!local.engine) throw new Error('Primer obre un model a «Xat local»');
|
|
1115
|
-
|
|
1116
|
-
|
|
1213
|
+
try {
|
|
1214
|
+
const r = await local.engine.chat.completions.create({ messages, temperature: 0.2 });
|
|
1215
|
+
return r.choices[0]?.message?.content || '';
|
|
1216
|
+
} 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
1217
|
}
|
|
1118
1218
|
throw new Error('Aquest model ja no està disponible');
|
|
1119
1219
|
}
|
|
@@ -1175,6 +1275,7 @@ function bindLocal() {
|
|
|
1175
1275
|
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
1276
|
feed.scrollTop = feed.scrollHeight;
|
|
1177
1277
|
const ta = $('#lmsg');
|
|
1278
|
+
if (local.draft) { ta.value = local.draft; local.draft = ''; autogrow(ta); }
|
|
1178
1279
|
ta.addEventListener('input', () => autogrow(ta));
|
|
1179
1280
|
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !isTouch) { e.preventDefault(); sendLocal(); } });
|
|
1180
1281
|
$('#lsend').onclick = () => (local.busy ? stopLocal() : sendLocal());
|
|
@@ -1193,6 +1294,15 @@ const LOCAL_SYSTEM = [
|
|
|
1193
1294
|
'Si no saps una cosa, digues «no ho sé». No t’inventis dades, noms ni enllaços.',
|
|
1194
1295
|
'No facis preguntes seguides ni allargassis la conversa. No facis servir emojis.'
|
|
1195
1296
|
].join(' ');
|
|
1297
|
+
const gpuLost = (e) => /mapAsync|GPUBuffer|GPUDevice|Instance reference|device (was )?lost|Device is lost|context lost/i.test(`${e?.message || e}`);
|
|
1298
|
+
// 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).
|
|
1299
|
+
async function recoverLocal(text) {
|
|
1300
|
+
const id = local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id);
|
|
1301
|
+
try { await local.engine?.unload?.(); } catch { /* ja és mort */ }
|
|
1302
|
+
local.engine = null; local.model = null; local.draft = text || '';
|
|
1303
|
+
toast('La GPU del mòbil s’ha reiniciat. Torno a carregar el model…', 5000);
|
|
1304
|
+
await loadLocal(id);
|
|
1305
|
+
}
|
|
1196
1306
|
async function loadLocal(id) {
|
|
1197
1307
|
local.loading = true; local.progress = 0; local.status = 'Preparant…'; render();
|
|
1198
1308
|
try {
|
|
@@ -1225,9 +1335,13 @@ async function sendLocal() {
|
|
|
1225
1335
|
acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) feed.scrollTop = feed.scrollHeight;
|
|
1226
1336
|
if (local.stopped) break;
|
|
1227
1337
|
}
|
|
1228
|
-
} catch (e) {
|
|
1338
|
+
} catch (e) {
|
|
1339
|
+
if (gpuLost(e) && !acc) { local.messages.pop(); out.remove(); feed.lastElementChild?.remove(); local.busy = false; local.stopped = false; setLocalBusy(false); return recoverLocal(text); }
|
|
1340
|
+
if (!local.stopped) acc = acc || `Error: ${e.message}`; out.innerHTML = md(stripThink(acc));
|
|
1341
|
+
}
|
|
1229
1342
|
out.classList.remove('cursor');
|
|
1230
1343
|
if (local.stopped) { try { await local.engine.interruptGenerate(); } catch { /* res */ } }
|
|
1344
|
+
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
1345
|
local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
|
|
1232
1346
|
}
|
|
1233
1347
|
|
|
@@ -1286,7 +1400,7 @@ async function toggleMic() {
|
|
|
1286
1400
|
rec.start(); $('#micBtn')?.classList.add('rec');
|
|
1287
1401
|
}
|
|
1288
1402
|
function voicePanel() {
|
|
1289
|
-
return `<section class="panel"
|
|
1403
|
+
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
1404
|
}
|
|
1291
1405
|
function voiceSheet() {
|
|
1292
1406
|
const c = voiceCfg();
|
|
@@ -1305,7 +1419,7 @@ function voiceSheet() {
|
|
|
1305
1419
|
|
|
1306
1420
|
/* ---------------------------------------------------------------- accions */
|
|
1307
1421
|
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]');
|
|
1422
|
+
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
1423
|
if (!a) return;
|
|
1310
1424
|
if (a.dataset.copy) {
|
|
1311
1425
|
try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
|
|
@@ -1320,7 +1434,8 @@ document.addEventListener('click', async (e) => {
|
|
|
1320
1434
|
if (a.dataset.mic != null) return toggleMic();
|
|
1321
1435
|
if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
|
|
1322
1436
|
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 }); }
|
|
1437
|
+
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 }); }
|
|
1438
|
+
if (a.dataset.rename != null) return renameSheet(Number(a.dataset.rename));
|
|
1324
1439
|
if (a.dataset.forget != null) { if (confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) forgetMachine(Number(a.dataset.forget)); return; }
|
|
1325
1440
|
if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
|
|
1326
1441
|
switch (a.dataset.act) {
|
|
@@ -1339,8 +1454,9 @@ document.addEventListener('click', async (e) => {
|
|
|
1339
1454
|
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
1455
|
case 'add-computer': return addComputerSheet();
|
|
1341
1456
|
case 'open-sessions': return go('#/sessions');
|
|
1342
|
-
case 'help': go('#/
|
|
1457
|
+
case 'help': return go('#/ajuda');
|
|
1343
1458
|
case 'more': return moreSheet();
|
|
1459
|
+
case 'folder': { const s = state.sessions.get(state.route.id); return s && folderSheet(s); }
|
|
1344
1460
|
case 'machines': return machinesSheet();
|
|
1345
1461
|
case 'mobile-qr': return mobileQrSheet();
|
|
1346
1462
|
case 'mobile-hint-off': { const m = machine(); LS.set(`nio.mobHint.${m?.id || m?.url}`, true); return render(); }
|
|
@@ -1366,11 +1482,10 @@ document.addEventListener('submit', (e) => {
|
|
|
1366
1482
|
/* ---------------------------------------------------------------- paleta d'ordres */
|
|
1367
1483
|
let palSel = 0;
|
|
1368
1484
|
function commands() {
|
|
1369
|
-
const c = Object.entries(VIEWS).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
|
|
1485
|
+
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
1486
|
if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
|
|
1371
1487
|
c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
|
|
1372
1488
|
[...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
1489
|
c.push({ label: 'Configura la veu (ElevenLabs)', hint: 'Ajustos', run: voiceSheet });
|
|
1375
1490
|
c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
|
|
1376
1491
|
return c;
|
|
@@ -1429,12 +1544,12 @@ const TSTEPS = [
|
|
|
1429
1544
|
{ 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
1545
|
{ 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
1546
|
{ 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.' },
|
|
1547
|
+
{ 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
1548
|
{ 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
1549
|
{ 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
|
|
1550
|
+
{ 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.' },
|
|
1551
|
+
{ 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í.') },
|
|
1552
|
+
{ center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial a Ajuda.', next: 'Finalitza' }
|
|
1438
1553
|
];
|
|
1439
1554
|
const tval = (v) => (typeof v === 'function' ? v() : v);
|
|
1440
1555
|
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; }
|
|
@@ -478,13 +482,14 @@ a.kbd { text-decoration: none; color: inherit; }
|
|
|
478
482
|
/* Comandes amb «/» */
|
|
479
483
|
.composer { position: relative; }
|
|
480
484
|
.slash { position: absolute; left: 24px; right: 24px; bottom: 100%; max-width: 760px; margin: 0 auto 6px; max-height: min(320px, 45dvh); overflow-y: auto; overscroll-behavior: contain; display: grid; padding: 6px; border: 1px solid var(--border); border-radius: 14px; background: var(--white); box-shadow: 0 10px 30px rgba(39, 34, 36, .12); z-index: 5; }
|
|
481
|
-
.slash button { display: grid; grid-template-columns: auto 1fr auto; gap:
|
|
485
|
+
.slash button { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto; gap: 2px 12px; align-items: center; text-align: left; padding: 9px 10px; border: 0; border-radius: 9px; background: none; width: 100%; }
|
|
482
486
|
.slash button[aria-selected="true"] { background: var(--bg-2); }
|
|
483
|
-
.slash .n { font-weight: 600; }
|
|
487
|
+
.slash .n { font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
484
488
|
.slash .h { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg-muted); font-size: 13px; }
|
|
485
|
-
.slash .t {
|
|
489
|
+
.slash .t { display: inline-flex; align-items: center; justify-content: center; height: 20px; padding: 0 8px; border-radius: 10px; background: var(--bg-2); color: var(--fg-muted); font-size: 11px; font-weight: 600; line-height: 1; white-space: nowrap; }
|
|
490
|
+
.slash button[aria-selected="true"] .t { background: var(--white); }
|
|
486
491
|
.slash .t.ag { background: color-mix(in srgb, var(--lemon) 60%, var(--bg)); color: var(--fg); }
|
|
487
|
-
@media (max-width: 820px) { .slash { left: 12px; right: 12px; } .slash .h { grid-column: 1 / -1; grid-row: 2; } }
|
|
492
|
+
@media (max-width: 820px) { .slash { left: 12px; right: 12px; } .slash button { grid-template-columns: minmax(0, 1fr) auto; } .slash .h { grid-column: 1 / -1; grid-row: 2; } .slash .t { grid-column: 2; grid-row: 1; } }
|
|
488
493
|
/* ---------- Veu ---------- */
|
|
489
494
|
.ev-error b { font-size: 12px; letter-spacing: .02em; text-transform: uppercase; }
|
|
490
495
|
.speak { display: inline-flex; align-items: center; gap: 6px; margin-top: 8px; height: 28px; padding: 0 10px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--fg-muted); font-size: 12px; font-weight: 600; }
|
|
@@ -495,3 +500,72 @@ a.kbd { text-decoration: none; color: inherit; }
|
|
|
495
500
|
.composer .mic:hover { color: var(--fg); border-color: var(--fg); }
|
|
496
501
|
.composer .mic.rec { background: var(--red); border-color: var(--red); color: #fff; animation: pulse 1.1s ease-in-out infinite; }
|
|
497
502
|
.composer .mic.wait { opacity: .5; }
|
|
503
|
+
|
|
504
|
+
/* ---------- Ordre i text net ---------- */
|
|
505
|
+
.grid2.one { grid-template-columns: minmax(0, 1fr); max-width: 640px; }
|
|
506
|
+
.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; }
|
|
507
|
+
@media (max-width: 820px) {
|
|
508
|
+
.side nav a[href="#/models"] { display: none; }
|
|
509
|
+
.top p, .panel.empty p, .stepper p, .help-body p, .sheet p, .tour-card p, .computer .muted { text-wrap: balance; }
|
|
510
|
+
.panel.empty { text-align: center; }
|
|
511
|
+
.panel.empty .stepper { text-align: left; }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/* ---------- Afegeix un ordinador ---------- */
|
|
515
|
+
dialog.sheet.wiz.compact { height: fit-content; max-height: min(86dvh, 720px); }
|
|
516
|
+
dialog.sheet.wiz.compact[open] { display: flex; flex-direction: column; }
|
|
517
|
+
.compact .wiz-body { min-height: 330px; padding-top: 12px; gap: 18px; }
|
|
518
|
+
.compact .wiz-head { gap: 14px; }
|
|
519
|
+
.compact .wiz-head .icon-btn { margin-right: -8px; }
|
|
520
|
+
.compact .wiz-head h2 { font-size: 22px; font-weight: 500; letter-spacing: -.045em; }
|
|
521
|
+
.compact .seg button svg { width: 16px; height: 16px; }
|
|
522
|
+
.compact .seg button[aria-pressed="true"] { box-shadow: 0 1px 2px color-mix(in srgb, var(--fg) 12%, transparent); }
|
|
523
|
+
.add-intro { display: grid; gap: 4px; }
|
|
524
|
+
.add-intro h3 { font-size: 18px; font-weight: 500; letter-spacing: -.03em; line-height: 1.25; }
|
|
525
|
+
.add-intro p, .hint { font-size: 14px; color: var(--fg-muted); text-wrap: pretty; }
|
|
526
|
+
.hint { font-size: 13px; }
|
|
527
|
+
.field-row { display: flex; gap: 8px; }
|
|
528
|
+
.field-row input { flex: 1; min-width: 0; height: 46px; }
|
|
529
|
+
.field-row .btn { height: 46px; padding: 0 18px; flex: none; }
|
|
530
|
+
.hosts { display: grid; gap: 8px; font-size: 12px; font-weight: 600; letter-spacing: .02em; color: var(--fg-muted); }
|
|
531
|
+
.hosts .row { gap: 6px; }
|
|
532
|
+
.todo { list-style: none; display: grid; gap: 14px; counter-reset: t; }
|
|
533
|
+
.todo li { display: grid; grid-template-columns: 24px 1fr; gap: 12px; align-items: start; font-size: 14px; }
|
|
534
|
+
.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; }
|
|
535
|
+
.todo li > div { display: grid; gap: 8px; min-width: 0; }
|
|
536
|
+
.todo .cmd { margin-top: 2px; }
|
|
537
|
+
.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); }
|
|
538
|
+
.live .pulse { width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--accent); animation: pulse 1.4s ease-in-out infinite; }
|
|
539
|
+
.checks { list-style: none; display: grid; gap: 8px; padding-top: 16px; border-top: 1px solid var(--border); font-size: 13px; color: var(--fg-muted); }
|
|
540
|
+
.checks li { display: flex; gap: 10px; align-items: baseline; }
|
|
541
|
+
.checks li::before { content: '✓'; color: var(--moss); font-weight: 700; }
|
|
542
|
+
.or { display: flex; align-items: center; gap: 12px; font-size: 12px; color: var(--fg-muted); }
|
|
543
|
+
.or::before, .or::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
|
544
|
+
.scan { width: 100%; border-radius: 12px; background: #000; aspect-ratio: 4 / 3; object-fit: cover; }
|
|
545
|
+
.sheet p { text-wrap: pretty; }
|
|
546
|
+
@media (max-width: 820px) {
|
|
547
|
+
.compact .wiz-body { min-height: 300px; }
|
|
548
|
+
.compact .seg button span { white-space: nowrap; }
|
|
549
|
+
}
|
|
550
|
+
dialog.sheet.wiz.compact .sheet-in, .compact .wiz-body { flex: 0 1 auto; }
|
|
551
|
+
.compact .wiz-body { overflow-y: auto; }
|
|
552
|
+
|
|
553
|
+
/* Alertes: capa superior (popover) i gest de lliscar */
|
|
554
|
+
.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; }
|
|
555
|
+
.toasts[popover]:popover-open { display: grid; }
|
|
556
|
+
@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)); } }
|
|
557
|
+
.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); }
|
|
558
|
+
.toast.out { opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s; }
|
|
559
|
+
|
|
560
|
+
/* Bot de l'agent dins la pastilla: enganxat a l'esquerra, centrat en vertical */
|
|
561
|
+
.chip:has(.abot) { padding: 0 11px 0 3px; }
|
|
562
|
+
.chip .abot { line-height: 0; align-items: center; }
|
|
563
|
+
.chip .abot .bot { --s: 20px; --bw: .15; }
|
|
564
|
+
.chip .agent-tag { gap: 7px; }
|
|
565
|
+
.sheet .list + .or { margin-top: 18px; }
|
|
566
|
+
.sheet #modelForm { margin-top: 4px; }
|
|
567
|
+
|
|
568
|
+
@media (max-width: 820px) {
|
|
569
|
+
.new-fab { position: fixed; right: 16px; bottom: calc(72px + var(--safe-b)); z-index: 25; height: 48px; padding: 0 18px 0 14px; border-radius: 16px; box-shadow: 0 14px 30px -14px color-mix(in srgb, var(--fg) 70%, transparent); }
|
|
570
|
+
body:not(.no-tabs):not(.kb) .main:has(.new-fab) { padding-bottom: calc(130px + var(--safe-b)); }
|
|
571
|
+
}
|
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-v15';
|
|
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) => {
|