nio-cat 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/nio.js +29 -2
- package/package.json +1 -1
- package/src/agents/claude.js +13 -3
- package/src/hub.js +41 -6
- package/src/server.js +9 -0
- package/src/tunnel.js +22 -1
- package/web/app/app.js +248 -75
- package/web/app/index.html +3 -5
- package/web/app/style.css +92 -9
- package/web/board.css +24 -3
- package/web/board.js +10 -0
- package/web/sw.js +1 -1
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,10 +31,13 @@ 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"/>',
|
|
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"/>',
|
|
34
38
|
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/>'
|
|
35
39
|
};
|
|
40
|
+
const agentBot = (agent, mood = 'idle') => `<span class="abot" data-agent="${esc(agent)}">${bot(mood)}</span>`;
|
|
36
41
|
const AGENT_NAMES = { claude: 'Claude Code', codex: 'Codex', grok: 'Grok', antigravity: 'Antigravity', ollama: 'Ollama', demo: 'Demo', browser: 'Xat local' };
|
|
37
42
|
const MODES = {
|
|
38
43
|
llegir: { label: 'Només llegir', short: 'Llegir' },
|
|
@@ -57,9 +62,26 @@ const machine = () => store.machines[store.active] || store.machines[0] || null;
|
|
|
57
62
|
/* ---------------------------------------------------------------- utilitats */
|
|
58
63
|
function toast(msg, ms = 2800) {
|
|
59
64
|
const box = $('#toasts'), t = document.createElement('div');
|
|
60
|
-
t.className = 'toast'; t.textContent = msg;
|
|
61
|
-
while (box.children.length >=
|
|
62
|
-
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 {}
|
|
63
85
|
}
|
|
64
86
|
function ago(ts) {
|
|
65
87
|
const s = Math.round((Date.now() - ts) / 1000);
|
|
@@ -123,7 +145,7 @@ async function connect() {
|
|
|
123
145
|
const ctrl = new AbortController(); streamCtrl = ctrl;
|
|
124
146
|
try {
|
|
125
147
|
state.info = await api('/api/info');
|
|
126
|
-
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' });
|
|
127
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 });
|
|
128
150
|
if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
|
|
129
151
|
state.connected = true; state.lastError = null; retry = 0;
|
|
@@ -145,13 +167,37 @@ async function connect() {
|
|
|
145
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;
|
|
146
168
|
render();
|
|
147
169
|
if (e.fatal) return;
|
|
170
|
+
if (m.kind === 'vps' && m.via && retry >= 2) autoReconnect(m);
|
|
148
171
|
setTimeout(() => machine() && !ctrl.signal.aborted && connect(), Math.min(15000, 1000 * 2 ** retry++));
|
|
149
172
|
}
|
|
150
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
|
+
}
|
|
151
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).
|
|
152
198
|
function renameMachine(host, id) {
|
|
153
199
|
let ms = store.machines; const cur = ms[store.active]; if (!cur) return;
|
|
154
|
-
if (host) cur.name = host;
|
|
200
|
+
if (host && !cur.renamed) cur.name = host;
|
|
155
201
|
if (id) {
|
|
156
202
|
cur.id = id;
|
|
157
203
|
const keep = cur.url, list = ms.filter((m) => m === cur || m.id !== id);
|
|
@@ -163,6 +209,9 @@ function renameMachine(host, id) {
|
|
|
163
209
|
function handle(msg) {
|
|
164
210
|
const r = state.route;
|
|
165
211
|
switch (msg.type) {
|
|
212
|
+
case 'device':
|
|
213
|
+
if (!isPhone() && msg.device !== deviceId()) { if ($('#qrBox')) closeSheet(); toast('Mòbil connectat correctament', 2000); }
|
|
214
|
+
break;
|
|
166
215
|
case 'hello':
|
|
167
216
|
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
168
217
|
state.approvals = new Map(msg.approvals.map((a) => [a.id, a]));
|
|
@@ -264,9 +313,11 @@ const VIEWS = {
|
|
|
264
313
|
ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
|
|
265
314
|
models: { label: 'Models', icon: ICON.models, group: 'Ordinadors' },
|
|
266
315
|
mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
|
|
267
|
-
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' }
|
|
268
319
|
};
|
|
269
|
-
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' };
|
|
270
321
|
const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'models', 'session']);
|
|
271
322
|
function parseRoute() {
|
|
272
323
|
const h = location.hash.replace(/^#\/?/, '');
|
|
@@ -298,7 +349,7 @@ function renderChrome() {
|
|
|
298
349
|
const m = machine(), sc = $('#sideConn');
|
|
299
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)}`;
|
|
300
351
|
sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
|
|
301
|
-
const more = ['
|
|
352
|
+
const more = ['mobil', 'pissarra', 'ajustos', 'ajuda'].includes(r.name);
|
|
302
353
|
$('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['approvals', ICON.approvals, 'Aprovacions', active === 'approvals'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
|
|
303
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('')
|
|
304
355
|
+ `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
|
|
@@ -324,7 +375,7 @@ function render() {
|
|
|
324
375
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
325
376
|
else {
|
|
326
377
|
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
327
|
-
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>`;
|
|
328
379
|
if (r.name === 'mobil') bindLocal();
|
|
329
380
|
if (r.name === 'models' && machine()) loadOllama();
|
|
330
381
|
}
|
|
@@ -334,6 +385,7 @@ function render() {
|
|
|
334
385
|
}
|
|
335
386
|
|
|
336
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; };
|
|
337
389
|
const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
|
|
338
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>`;
|
|
339
391
|
function computerCard(m, i) {
|
|
@@ -341,7 +393,7 @@ function computerCard(m, i) {
|
|
|
341
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>`) : '';
|
|
342
394
|
return `<div class="card computer ${on ? 'active' : ''}">
|
|
343
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>
|
|
344
|
-
<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>
|
|
345
397
|
</div>`;
|
|
346
398
|
}
|
|
347
399
|
// Ajuda sempre a mà: com es fa cada cosa, i què mirar si no connecta.
|
|
@@ -354,9 +406,16 @@ function helpPanel(open) {
|
|
|
354
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>`)}
|
|
355
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>`)}
|
|
356
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>`)}
|
|
357
|
-
<div class="row"><button class="btn sm" type="button" data-act="tour">Fes el tutorial</button></div>
|
|
358
409
|
</div></section>`;
|
|
359
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
|
+
}
|
|
360
419
|
function viewComputers() {
|
|
361
420
|
const ms = store.machines, m = machine(), info = state.info;
|
|
362
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>`;
|
|
@@ -364,19 +423,16 @@ function viewComputers() {
|
|
|
364
423
|
const steps = isPhone()
|
|
365
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.']]
|
|
366
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.']];
|
|
367
|
-
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>`;
|
|
368
427
|
}
|
|
369
428
|
const detail = m && state.connected ? `<h2 class="sec">${esc(m.name)}</h2>
|
|
370
|
-
<div class="grid2">
|
|
429
|
+
<div class="grid2 one">
|
|
371
430
|
<div class="stack">
|
|
372
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>
|
|
373
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>` : ''}
|
|
374
433
|
</div>
|
|
375
|
-
<div class="stack">
|
|
376
|
-
<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>
|
|
377
|
-
</div>
|
|
378
434
|
</div>` : '';
|
|
379
|
-
return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}
|
|
435
|
+
return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}`;
|
|
380
436
|
}
|
|
381
437
|
|
|
382
438
|
/* ---------- Afegir un ordinador: aquest, un servidor per SSH o un codi */
|
|
@@ -388,8 +444,12 @@ async function probeLocal() {
|
|
|
388
444
|
} catch { return null; }
|
|
389
445
|
}
|
|
390
446
|
// Afegeix un servidor per SSH des de l'ordinador actiu. El progrés arriba línia a línia.
|
|
391
|
-
|
|
392
|
-
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');
|
|
393
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 }) });
|
|
394
454
|
if (!res.ok || !res.body) throw new Error((await res.json().catch(() => ({}))).error || 'Aquest ordinador no pot afegir servidors. Actualitza nio.');
|
|
395
455
|
const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '', last = null;
|
|
@@ -398,7 +458,7 @@ async function sshAdd(target, onStep) {
|
|
|
398
458
|
buf += dec.decode(value, { stream: true }); let i;
|
|
399
459
|
while ((i = buf.indexOf('\n')) >= 0) {
|
|
400
460
|
const o = JSON.parse(buf.slice(0, i)); buf = buf.slice(i + 1);
|
|
401
|
-
if (o.step) onStep(o.step); else last = o;
|
|
461
|
+
if (o.step) onStep(o.step); else if (!o.ping) last = o;
|
|
402
462
|
}
|
|
403
463
|
}
|
|
404
464
|
if (!last || last.error) throw new Error(last?.error || 'S’ha tallat la connexió');
|
|
@@ -413,56 +473,59 @@ async function startScan(video, onText) {
|
|
|
413
473
|
}
|
|
414
474
|
function addComputerSheet(tab, opts = {}) {
|
|
415
475
|
const phone = isPhone(), canScan = 'BarcodeDetector' in window && !!navigator.mediaDevices?.getUserMedia;
|
|
416
|
-
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]];
|
|
417
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>`;
|
|
418
481
|
openSheet('', (sh) => {
|
|
419
|
-
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>`;
|
|
420
483
|
const body = $('#addBody', sh);
|
|
421
484
|
const stop = () => { clearTimeout(poll); scan?.stop(); scan = null; shown = ''; };
|
|
422
485
|
const set = (key, html) => { if (key === shown) return false; shown = key; body.innerHTML = html; return true; };
|
|
423
486
|
const draw = () => {
|
|
424
|
-
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(() => {});
|
|
425
488
|
$$('#addTabs button', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.tab === cur));
|
|
426
489
|
body.scrollTop = 0;
|
|
427
490
|
if (cur === 'local') local(); else if (cur === 'vps') vps(); else code();
|
|
428
491
|
};
|
|
429
492
|
const local = async () => {
|
|
430
|
-
set('wait', '
|
|
493
|
+
set('wait', live('Mirant si nio funciona en aquest ordinador…'));
|
|
431
494
|
const d = await probeLocal(); if (cur !== 'local') return;
|
|
432
495
|
const saved = d && store.machines.findIndex((x) => x.id === d.id);
|
|
433
|
-
if (d && saved >= 0) set('saved',
|
|
434
|
-
else if (d) set('found',
|
|
435
|
-
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>`);
|
|
436
499
|
if (saved < 0 || !d) poll = setTimeout(local, 3000);
|
|
437
500
|
};
|
|
438
501
|
const runSsh = async (target) => {
|
|
439
|
-
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';
|
|
440
503
|
const add = (t) => { $$('li.on', steps).forEach((l) => { l.className = 'ok'; }); steps.insertAdjacentHTML('beforeend', `<li class="on">${esc(t)}…</li>`); };
|
|
441
504
|
try {
|
|
442
|
-
|
|
505
|
+
add(`Connectant amb ${target}`);
|
|
506
|
+
const r = await sshAdd(target, add, sshHelper());
|
|
443
507
|
LS.set('nio.lastSsh', target);
|
|
444
508
|
const old = store.machines.findIndex((x) => x.via === target && x.kind === 'vps');
|
|
445
|
-
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)); }
|
|
446
|
-
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; }
|
|
447
512
|
shown = 'vps-ok';
|
|
448
|
-
body.innerHTML =
|
|
449
|
-
} 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'; }
|
|
450
515
|
};
|
|
451
516
|
const vps = () => {
|
|
452
|
-
const m =
|
|
453
|
-
if (!m) return set('vps-no',
|
|
454
|
-
set('vps',
|
|
455
|
-
<
|
|
456
|
-
${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>` : ''}
|
|
457
|
-
<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>` : ''}
|
|
458
522
|
<ul class="steps" id="sshSteps"></ul>
|
|
459
|
-
<
|
|
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>`);
|
|
460
524
|
};
|
|
461
525
|
const code = () => {
|
|
462
|
-
set('code',
|
|
463
|
-
<form class="
|
|
464
|
-
${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="
|
|
465
|
-
<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>' : ''}`);
|
|
466
529
|
};
|
|
467
530
|
sh.onclick = async (e) => {
|
|
468
531
|
const t = e.target.closest('[data-tab]'); if (t) { cur = t.dataset.tab; return draw(); }
|
|
@@ -482,15 +545,29 @@ function addComputerSheet(tab, opts = {}) {
|
|
|
482
545
|
};
|
|
483
546
|
draw();
|
|
484
547
|
if (opts.auto && cur === 'vps') setTimeout(() => runSsh(opts.target), 50);
|
|
485
|
-
}, 'wiz');
|
|
548
|
+
}, 'wiz compact');
|
|
486
549
|
}
|
|
487
550
|
function machinesSheet() {
|
|
488
551
|
const ms = store.machines;
|
|
489
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>'}
|
|
490
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)); });
|
|
491
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
|
+
}
|
|
492
568
|
function moreSheet() {
|
|
493
|
-
|
|
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(); }; });
|
|
494
571
|
}
|
|
495
572
|
|
|
496
573
|
/* ---------- Sessions */
|
|
@@ -511,9 +588,9 @@ function viewSessions() {
|
|
|
511
588
|
}
|
|
512
589
|
return `${head}${list.length ? `<div class="list">${list.map((s) => `
|
|
513
590
|
<a class="card session-card" href="#/s/${esc(s.id)}">
|
|
514
|
-
<div class="row between"><span class="agent-tag" data-agent="${esc(s.agent)}">${esc(AGENT_NAMES[s.agent] || s.agent)}</span>${stateChip(s)}</div>
|
|
591
|
+
<div class="row between"><span class="agent-tag nosq" data-agent="${esc(s.agent)}">${agentBot(s.agent, sessionMood(s))}${esc(AGENT_NAMES[s.agent] || s.agent)}</span>${stateChip(s)}</div>
|
|
515
592
|
<div class="title">${esc(s.title || 'Sessió nova')}</div>
|
|
516
|
-
${s.last ? `<div class="last">${esc(s.last)}</div>` : ''}
|
|
593
|
+
${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(s.last)}</div>` : ''}
|
|
517
594
|
<div class="meta"><span class="mono">${esc(basename(s.cwd))}</span><span>·</span><span>${esc(MODES[s.mode]?.label || '')}</span><span>·</span><span>${ago(s.updated)}</span></div>
|
|
518
595
|
</a>`).join('')}</div>` : `<div class="panel empty">${bot('idle')}<h2>Encara no hi ha cap sessió</h2><p>Tria un agent i una carpeta, i explica-li què ha de fer.</p><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>`}`;
|
|
519
596
|
}
|
|
@@ -578,7 +655,7 @@ let feedEls = new Map(), toolEls = new Map();
|
|
|
578
655
|
function renderSession() {
|
|
579
656
|
const id = state.route.id, view = $('#view');
|
|
580
657
|
view.innerHTML = `<section class="chat">
|
|
581
|
-
<div class="chat-head"><a class="icon-btn" href="#/sessions" aria-label="Torna a les sessions">${svg(ICON.back)}</a><span id="headBot"></span><div class="grow"><h1 id="sessTitle"></h1><div class="sub" id="sessSub"></div></div><button class="icon-btn" data-act="session-more" aria-label="Opcions de la sessió">${svg(ICON.more)}</button></div>
|
|
658
|
+
<div class="chat-head"><a class="icon-btn" href="#/sessions" aria-label="Torna a les sessions">${svg(ICON.back)}</a><span id="headBot" class="abot"></span><div class="grow"><h1 id="sessTitle"></h1><div class="sub" id="sessSub"></div></div><button class="icon-btn" data-act="session-more" aria-label="Opcions de la sessió">${svg(ICON.more)}</button></div>
|
|
582
659
|
<div class="mode-bar" id="modeBar"></div>
|
|
583
660
|
<div class="feed" id="feed" aria-live="polite"></div>
|
|
584
661
|
<div class="composer" id="composer"></div>
|
|
@@ -598,10 +675,10 @@ function renderSessionHead() {
|
|
|
598
675
|
if (!bar) return;
|
|
599
676
|
$('#sessTitle').textContent = s?.title || 'Sessió nova';
|
|
600
677
|
$('#sessSub').textContent = s ? `${AGENT_NAMES[s.agent] || s.agent} · ${basename(s.cwd)}` : '';
|
|
601
|
-
const hb = $('#headBot'); if (hb) { hb.innerHTML = bot(sessionMood(s)); window.NioBot?.refresh(); }
|
|
678
|
+
const hb = $('#headBot'); if (hb) { hb.dataset.agent = s?.agent || ''; hb.innerHTML = bot(sessionMood(s)); window.NioBot?.refresh(); }
|
|
602
679
|
if (!s) { bar.innerHTML = ''; return; }
|
|
603
680
|
const ag = state.info?.agents?.find((a) => a.id === s.agent);
|
|
604
|
-
bar.innerHTML = `<span class="chip"><span class="agent-tag" data-agent="${esc(s.agent)}">${esc(AGENT_NAMES[s.agent] || s.agent)}</span></span>
|
|
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>
|
|
605
682
|
${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${esc(MODES[s.mode]?.label || s.mode)}</button>`}
|
|
606
683
|
<span class="chip mono">${esc(basename(s.cwd))}</span>
|
|
607
684
|
<button class="chip mono" data-act="model" type="button" title="Canvia de model">${esc(s.model || 'Model per defecte')}</button>
|
|
@@ -645,11 +722,11 @@ function eventNode(e) {
|
|
|
645
722
|
const s = state.sessions.get(state.route.id);
|
|
646
723
|
switch (e.kind) {
|
|
647
724
|
case 'user': return el(`<div class="msg-user">${esc(e.text)}</div>`);
|
|
648
|
-
case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}">${md(e.text)}</div>`);
|
|
725
|
+
case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}" data-seq="${e.seq}">${md(e.text)}${!e.open && voiceOn() ? `<button class="speak" type="button" data-speak aria-label="Escolta la resposta">${svg(ICON.speaker)}<span>Escolta</span></button>` : ''}</div>`);
|
|
649
726
|
case 'tool': return el(`<details class="tool"><summary><span class="name">${esc(e.name)}</span><span class="arg">${esc(toolArg(e.input))}</span><span class="st">…</span></summary><pre>${esc(prettyInput(e.input))}</pre></details>`);
|
|
650
727
|
case 'tool_result': return el(`<details class="tool"><summary><span class="name">${esc(e.name || 'Eina')}</span><span class="arg"></span><span class="st ${e.error ? 'err' : 'ok'}">${e.error ? '✕' : '✓'}</span></summary><pre class="out">${esc(e.output)}</pre></details>`);
|
|
651
728
|
case 'approval': { const a = state.approvals.get(e.approval) || {}; return el(approvalCard({ ...a, approval: e.approval, tool: e.tool, input: e.input, state: e.state && e.state !== 'pending' ? e.state : a.state || e.state }, false)); }
|
|
652
|
-
case 'error': return el(`<div class="ev-error">${esc(e.text)}</div>`);
|
|
729
|
+
case 'error': return el(`<div class="ev-error" role="alert"><b>Error</b><span>${esc(e.text)}</span></div>`);
|
|
653
730
|
case 'info': return el(`<div class="ev-info">${esc(e.text)}</div>`);
|
|
654
731
|
case 'result': {
|
|
655
732
|
const bits = [];
|
|
@@ -669,10 +746,10 @@ function renderTyping() {
|
|
|
669
746
|
const last = evs ? [...evs.values()].sort((a, b) => b.seq - a.seq).find((x) => x.kind !== 'status' && x.kind !== 'log') : null;
|
|
670
747
|
const streaming = last && last.kind === 'text' && last.open;
|
|
671
748
|
let t = $('.typing', feed);
|
|
672
|
-
if (busy && !streaming) { if (!t) { feed.appendChild(el(`<div class="typing">${
|
|
749
|
+
if (busy && !streaming) { if (!t) { feed.appendChild(el(`<div class="typing">${agentBot(s.agent, 'work')}<span>${esc(AGENT_NAMES[s.agent] || 'L’agent')} està treballant…</span></div>`)); window.NioBot?.refresh(); } }
|
|
673
750
|
else if (t) t.remove();
|
|
674
751
|
if (!busy) $$('.msg-ai.cursor', feed).forEach((n) => n.classList.remove('cursor'));
|
|
675
|
-
|
|
752
|
+
$$('.mode-bar .abot .bot, #headBot .bot').forEach((b) => { b.dataset.mood = sessionMood(s); });
|
|
676
753
|
}
|
|
677
754
|
/* ---------- Comandes amb «/» (les de nio i les de l'agent) */
|
|
678
755
|
const NIO_CMDS = [
|
|
@@ -732,7 +809,7 @@ function renderComposer() {
|
|
|
732
809
|
if (box.dataset.busy === String(busy) && $('textarea', box)) return;
|
|
733
810
|
box.dataset.busy = String(busy);
|
|
734
811
|
if (s) loadCmds(s.id).then(() => { if (state.route.id === s.id) drawSlash(); });
|
|
735
|
-
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… (/ per a comandes)'}" enterkeyhint="send" aria-label="Missatge" aria-controls="slash"></textarea><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
|
|
812
|
+
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… (/ per a comandes)'}" 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>`;
|
|
736
813
|
const ta = $('#msg'); ta.value = prev;
|
|
737
814
|
const grow = () => autogrow(ta);
|
|
738
815
|
let items = [], sel = 0;
|
|
@@ -776,15 +853,18 @@ function renderComposer() {
|
|
|
776
853
|
|
|
777
854
|
/* ---------- Fulls */
|
|
778
855
|
function openSheet(html, onMount, cls) {
|
|
779
|
-
const d = $('#sheet'); d.classList.toggle('wiz', cls === 'wiz'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls
|
|
780
|
-
if (!d.open) d.showModal(); if (T.on) $('#tour').hidden = true;
|
|
856
|
+
const d = $('#sheet'); d.classList.toggle('wiz', !!cls?.startsWith('wiz')); d.classList.toggle('compact', cls === 'wiz compact'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls?.startsWith('wiz') ? '' : `<div class="grab"></div>${html}`;
|
|
857
|
+
if (!d.open) d.showModal(); raiseToasts(); if (T.on) $('#tour').hidden = true;
|
|
781
858
|
onMount?.($('#sheetIn'));
|
|
782
859
|
}
|
|
783
860
|
const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); };
|
|
784
861
|
$('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
|
|
785
862
|
|
|
863
|
+
// Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
|
|
864
|
+
const modeBlocked = (agentId, mode) => agentId === 'claude' && mode === 'tot' && state.info?.user === 'root';
|
|
786
865
|
function modeHelp(agentId, mode) {
|
|
787
866
|
const a = state.info?.agents?.find((x) => x.id === agentId);
|
|
867
|
+
if (modeBlocked(agentId, mode)) return 'No disponible: Claude Code no ho permet quan s’executa com a root. Fes servir «Demana permís» o engega nio amb un usuari normal.';
|
|
788
868
|
if (mode === 'llegir') return 'Llegeix el projecte i et proposa canvis, però no toca cap fitxer.';
|
|
789
869
|
if (mode === 'tot') return 'Treballa sense preguntar. Fes-lo servir només en carpetes on confiïs plenament.';
|
|
790
870
|
if (a?.approvals) return 'Llegeix sol. Abans d’editar un fitxer o d’executar una ordre, et demana permís aquí.';
|
|
@@ -812,7 +892,7 @@ async function newSessionSheet(preset = {}) {
|
|
|
812
892
|
const body = () => { $('#wizBody', sh).innerHTML = VIEWS_[step](); after(); };
|
|
813
893
|
const head = () => {
|
|
814
894
|
const last = step === STEPS.length - 1;
|
|
815
|
-
$('#wizHead', sh).innerHTML = `<div class="grab"></div><div class="row between" style="flex-wrap:nowrap"><h2>Nova sessió</h2><button class="icon-btn" type="button" data-close aria-label="Tanca"
|
|
895
|
+
$('#wizHead', sh).innerHTML = `<div class="grab"></div><div class="row between" style="flex-wrap:nowrap"><h2>Nova sessió</h2><button class="icon-btn" type="button" data-close aria-label="Tanca">${svg(ICON.close)}</button></div>
|
|
816
896
|
<div class="wiz-dots">${STEPS.map((n, i) => `<button type="button" class="wiz-dot ${i === step ? 'on' : i < step ? 'done' : ''}" data-step="${i}" ${i < step ? '' : 'disabled'} aria-label="${i + 1}. ${n}"></button>`).join('')}</div>
|
|
817
897
|
<p class="muted small">Pas ${step + 1} de ${STEPS.length} · ${STEPS[step]}</p>`;
|
|
818
898
|
$('#wizFoot', sh).innerHTML = `<button class="btn ghost" type="button" ${step ? 'data-back' : 'data-close'}>${step ? 'Enrere' : 'Cancel·la'}</button><button class="btn ink" type="button" ${last ? 'data-start' : 'data-next'}>${last ? 'Comença' : 'Continua'}</button>`;
|
|
@@ -853,7 +933,7 @@ async function newSessionSheet(preset = {}) {
|
|
|
853
933
|
};
|
|
854
934
|
|
|
855
935
|
const VIEWS_ = [
|
|
856
|
-
() => `<p class="wiz-q">Quin agent vols fer servir?</p><div class="picker">${agents.map((a) => `<button type="button" class="pick ${a.available ? '' : 'off'}" data-agent-id="${esc(a.id)}" aria-pressed="${a.id === sel.agent}"><div class="row between"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="sub">${esc(a.available ? a.version || 'Disponible' : 'No instal·lat')}</div></button>`).join('')}</div>`,
|
|
936
|
+
() => `<p class="wiz-q">Quin agent vols fer servir?</p><div class="picker">${[...agents].sort((x, y) => (y.available ? 1 : 0) - (x.available ? 1 : 0)).map((a) => `<button type="button" class="pick ${a.available ? '' : 'off'}" data-agent-id="${esc(a.id)}" aria-pressed="${a.id === sel.agent}"><div class="row between"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="sub">${esc(a.available ? a.version || 'Disponible' : 'No instal·lat')}</div></button>`).join('')}</div>`,
|
|
857
937
|
() => {
|
|
858
938
|
const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
|
|
859
939
|
const quick = [...recents.map((p) => ({ path: p, name: basename(p) })), ...projects.filter((p) => !recents.includes(p.path))].slice(0, 8).map((p) => `<button type="button" class="chip" data-fs="${esc(p.path)}">${esc(p.name)}</button>`).join('');
|
|
@@ -867,10 +947,11 @@ async function newSessionSheet(preset = {}) {
|
|
|
867
947
|
},
|
|
868
948
|
() => {
|
|
869
949
|
const ag = agentOf();
|
|
950
|
+
if (modeBlocked(sel.agent, sel.mode)) sel.mode = 'preguntar';
|
|
870
951
|
const modelHtml = sel.agent === 'ollama'
|
|
871
952
|
? `<label class="f"><span>Model</span><select id="modelSel">${(models || []).map((m) => `<option ${m.name === sel.model ? 'selected' : ''}>${esc(m.name)}</option>`).join('') || `<option value="">${models == null ? 'Carregant…' : 'Cap model (ollama pull qwen3:4b)'}</option>`}</select></label>`
|
|
872
953
|
: `<label class="f"><span>Model <span class="muted">(opcional)</span></span><input type="text" id="modelIn" value="${esc(sel.model)}" placeholder="El que l’agent faci servir per defecte" autocapitalize="off" spellcheck="false"></label>`;
|
|
873
|
-
return `<p class="wiz-q">Quins permisos té?</p>${ag?.noModes ? '<p class="muted small">Aquest agent no té modes de permisos.</p>' : `<div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-mode="${k}" aria-pressed="${k === sel.mode}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(sel.agent, k))}</div></button>`).join('')}</div>`}${modelHtml}`;
|
|
954
|
+
return `<p class="wiz-q">Quins permisos té?</p>${ag?.noModes ? '<p class="muted small">Aquest agent no té modes de permisos.</p>' : `<div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-mode="${k}" aria-pressed="${k === sel.mode}" ${modeBlocked(sel.agent, k) ? 'disabled' : ''}><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(sel.agent, k))}</div></button>`).join('')}</div>`}${modelHtml}`;
|
|
874
955
|
},
|
|
875
956
|
() => `<p class="wiz-q">Què ha de fer?</p>
|
|
876
957
|
<dl class="kv"><dt>Agent</dt><dd>${esc(AGENT_NAMES[sel.agent] || sel.agent)}</dd><dt>Carpeta</dt><dd class="mono">${esc(sel.cwd)}</dd>${agentOf()?.noModes ? '' : `<dt>Permisos</dt><dd>${esc(MODES[sel.mode].label)}</dd>`}${sel.model ? `<dt>Model</dt><dd class="mono">${esc(sel.model)}</dd>` : ''}</dl>
|
|
@@ -928,14 +1009,14 @@ async function modelSheet(s) {
|
|
|
928
1009
|
const row = (v, name, note) => `<button class="card" type="button" data-model="${esc(v)}" aria-pressed="${(s.model || '') === v}"><b>${esc(name)}</b>${note ? `<div class="muted small" style="margin-top:4px">${esc(note)}</div>` : ''}</button>`;
|
|
929
1010
|
openSheet(`<h2>Model</h2><p class="muted small">${esc(AGENT_NAMES[s.agent] || s.agent)}. El canvi s’aplica a partir del pròxim missatge.</p>
|
|
930
1011
|
<div class="list">${row('', 'Per defecte', 'El que l’agent faci servir sol')}${picks.map((x) => row(...x)).join('')}</div>
|
|
931
|
-
<form class="
|
|
1012
|
+
<div class="or"><span>o un altre model</span></div><form class="field-row" id="modelForm"><input type="text" id="modelFree" placeholder="Nom exacte del model" value="${picks.some((x) => x[0] === s.model) ? '' : esc(s.model || '')}" autocapitalize="off" spellcheck="false" aria-label="Nom del model"><button class="btn ink" type="submit">Desa</button></form>`, (sh) => {
|
|
932
1013
|
const save = async (model) => { try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body: { model } }); state.sessions.set(s.id, sum); closeSheet(); renderSessionHead(); } catch (e) { toast(e.message); } };
|
|
933
1014
|
$$('[data-model]', sh).forEach((b) => (b.onclick = () => save(b.dataset.model)));
|
|
934
1015
|
$('#modelForm', sh).onsubmit = (e) => { e.preventDefault(); e.stopPropagation(); save($('#modelFree', sh).value.trim()); };
|
|
935
1016
|
});
|
|
936
1017
|
}
|
|
937
1018
|
function modeSheet(s) {
|
|
938
|
-
openSheet(`<h2>Permisos</h2><div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-m="${k}" style="${k === s.mode ? 'border-color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)' : ''}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(s.agent, k))}</div></button>`).join('')}</div><p class="muted small">El canvi s’aplica a partir del pròxim missatge.</p>`, (sh) => {
|
|
1019
|
+
openSheet(`<h2>Permisos</h2><div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-m="${k}" ${modeBlocked(s.agent, k) ? 'disabled' : ''} style="${k === s.mode ? 'border-color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)' : ''}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(s.agent, k))}</div></button>`).join('')}</div><p class="muted small">El canvi s’aplica a partir del pròxim missatge.</p>`, (sh) => {
|
|
939
1020
|
$$('[data-m]', sh).forEach((b) => (b.onclick = async () => {
|
|
940
1021
|
try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body: { mode: b.dataset.m } }); state.sessions.set(s.id, sum); closeSheet(); renderSessionHead(); }
|
|
941
1022
|
catch (e) { toast(e.message); }
|
|
@@ -1106,8 +1187,10 @@ window.NioBrains = {
|
|
|
1106
1187
|
}
|
|
1107
1188
|
if (id === 'local') {
|
|
1108
1189
|
if (!local.engine) throw new Error('Primer obre un model a «Xat local»');
|
|
1109
|
-
|
|
1110
|
-
|
|
1190
|
+
try {
|
|
1191
|
+
const r = await local.engine.chat.completions.create({ messages, temperature: 0.2 });
|
|
1192
|
+
return r.choices[0]?.message?.content || '';
|
|
1193
|
+
} catch (e) { if (gpuLost(e)) { await recoverLocal(''); throw new Error('La GPU del mòbil s’ha reiniciat. Torna-ho a provar quan el model s’hagi carregat.'); } throw e; }
|
|
1111
1194
|
}
|
|
1112
1195
|
throw new Error('Aquest model ja no està disponible');
|
|
1113
1196
|
}
|
|
@@ -1169,6 +1252,7 @@ function bindLocal() {
|
|
|
1169
1252
|
for (const m of local.messages) feed.appendChild(m.role === 'user' ? el(`<div class="msg-user">${esc(m.content)}</div>`) : el(`<div class="msg-ai">${md(stripThink(m.content))}</div>`));
|
|
1170
1253
|
feed.scrollTop = feed.scrollHeight;
|
|
1171
1254
|
const ta = $('#lmsg');
|
|
1255
|
+
if (local.draft) { ta.value = local.draft; local.draft = ''; autogrow(ta); }
|
|
1172
1256
|
ta.addEventListener('input', () => autogrow(ta));
|
|
1173
1257
|
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !isTouch) { e.preventDefault(); sendLocal(); } });
|
|
1174
1258
|
$('#lsend').onclick = () => (local.busy ? stopLocal() : sendLocal());
|
|
@@ -1187,6 +1271,15 @@ const LOCAL_SYSTEM = [
|
|
|
1187
1271
|
'Si no saps una cosa, digues «no ho sé». No t’inventis dades, noms ni enllaços.',
|
|
1188
1272
|
'No facis preguntes seguides ni allargassis la conversa. No facis servir emojis.'
|
|
1189
1273
|
].join(' ');
|
|
1274
|
+
const gpuLost = (e) => /mapAsync|GPUBuffer|GPUDevice|Instance reference|device (was )?lost|Device is lost|context lost/i.test(`${e?.message || e}`);
|
|
1275
|
+
// La GPU del mòbil es reinicia si l'app va al fons o falta memòria: es deixa l'engine mort i es torna a carregar (els pesos ja són a la memòria cau).
|
|
1276
|
+
async function recoverLocal(text) {
|
|
1277
|
+
const id = local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id);
|
|
1278
|
+
try { await local.engine?.unload?.(); } catch { /* ja és mort */ }
|
|
1279
|
+
local.engine = null; local.model = null; local.draft = text || '';
|
|
1280
|
+
toast('La GPU del mòbil s’ha reiniciat. Torno a carregar el model…', 5000);
|
|
1281
|
+
await loadLocal(id);
|
|
1282
|
+
}
|
|
1190
1283
|
async function loadLocal(id) {
|
|
1191
1284
|
local.loading = true; local.progress = 0; local.status = 'Preparant…'; render();
|
|
1192
1285
|
try {
|
|
@@ -1219,15 +1312,91 @@ async function sendLocal() {
|
|
|
1219
1312
|
acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) feed.scrollTop = feed.scrollHeight;
|
|
1220
1313
|
if (local.stopped) break;
|
|
1221
1314
|
}
|
|
1222
|
-
} catch (e) {
|
|
1315
|
+
} catch (e) {
|
|
1316
|
+
if (gpuLost(e) && !acc) { local.messages.pop(); out.remove(); feed.lastElementChild?.remove(); local.busy = false; local.stopped = false; setLocalBusy(false); return recoverLocal(text); }
|
|
1317
|
+
if (!local.stopped) acc = acc || `Error: ${e.message}`; out.innerHTML = md(stripThink(acc));
|
|
1318
|
+
}
|
|
1223
1319
|
out.classList.remove('cursor');
|
|
1224
1320
|
if (local.stopped) { try { await local.engine.interruptGenerate(); } catch { /* res */ } }
|
|
1321
|
+
if (gpuLost(acc.replace(/^Error: /, ''))) { local.messages.push({ role: 'assistant', content: 'La GPU del mòbil s’ha reiniciat enmig de la resposta.' }); local.busy = false; local.stopped = false; setLocalBusy(false); return recoverLocal(''); }
|
|
1225
1322
|
local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
|
|
1226
1323
|
}
|
|
1227
1324
|
|
|
1325
|
+
/* ---------------------------------------------------------------- veu (ElevenLabs) */
|
|
1326
|
+
// La clau viu només en aquest navegador; les crides van directes a ElevenLabs, sense passar per nio.
|
|
1327
|
+
const VOICE_DEFAULT = { key: '', voice: 'JBFqnCBsd6RMkjVDRZzb', model: 'eleven_v3' };
|
|
1328
|
+
const voiceCfg = () => ({ ...VOICE_DEFAULT, ...LS.get('nio.voice', {}) });
|
|
1329
|
+
const voiceOn = () => !!voiceCfg().key;
|
|
1330
|
+
async function elevenlabs(path, init = {}) {
|
|
1331
|
+
const res = await fetch(`https://api.elevenlabs.io${path}`, { ...init, headers: { 'xi-api-key': voiceCfg().key, ...(init.headers || {}) } });
|
|
1332
|
+
if (res.ok) return res;
|
|
1333
|
+
let why = ''; try { const j = await res.json(); why = j.detail?.message || (typeof j.detail === 'string' ? j.detail : ''); } catch { /* sense cos */ }
|
|
1334
|
+
throw new Error(res.status === 401 ? 'La clau d’ElevenLabs no és vàlida.' : res.status === 429 ? 'ElevenLabs diu que has superat el límit. Prova-ho d’aquí una estona.' : `ElevenLabs: ${why || `error ${res.status}`}`);
|
|
1335
|
+
}
|
|
1336
|
+
const speech = { audio: null, btn: null, cache: new Map() };
|
|
1337
|
+
const speechText = (t) => String(t).replace(/```[\s\S]*?```/g, ' (bloc de codi) ').replace(/`([^`]+)`/g, '$1').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/^#{1,4}\s+/gm, '').replace(/^\s*[-*•]\s+/gm, '').trim().slice(0, 4500);
|
|
1338
|
+
function stopSpeech() { speech.audio?.pause(); speech.audio = null; speech.btn?.classList.remove('on', 'wait'); speech.btn = null; }
|
|
1339
|
+
async function speak(btn) {
|
|
1340
|
+
if (speech.btn === btn) return stopSpeech();
|
|
1341
|
+
stopSpeech();
|
|
1342
|
+
const ev = state.events.get(state.route.id)?.get(Number(btn.closest('[data-seq]')?.dataset.seq)), text = speechText(ev?.text || '');
|
|
1343
|
+
if (!text) return;
|
|
1344
|
+
speech.btn = btn; btn.classList.add('wait');
|
|
1345
|
+
try {
|
|
1346
|
+
const c = voiceCfg(), k = `${c.voice}|${c.model}|${text}`; let url = speech.cache.get(k);
|
|
1347
|
+
if (!url) {
|
|
1348
|
+
const res = await elevenlabs(`/v1/text-to-speech/${encodeURIComponent(c.voice)}?output_format=mp3_44100_64`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, model_id: c.model }) });
|
|
1349
|
+
url = URL.createObjectURL(await res.blob()); speech.cache.set(k, url);
|
|
1350
|
+
}
|
|
1351
|
+
if (speech.btn !== btn) return;
|
|
1352
|
+
const a = new Audio(url); speech.audio = a; a.onended = stopSpeech; await a.play();
|
|
1353
|
+
btn.classList.remove('wait'); btn.classList.add('on');
|
|
1354
|
+
} catch (e) { stopSpeech(); toast(e.message, 4500); }
|
|
1355
|
+
}
|
|
1356
|
+
const mic = { rec: null, chunks: [], stream: null };
|
|
1357
|
+
async function toggleMic() {
|
|
1358
|
+
if (!voiceOn()) return voiceSheet();
|
|
1359
|
+
if (mic.rec) return mic.rec.stop();
|
|
1360
|
+
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Aquest navegador no pot gravar àudio.');
|
|
1361
|
+
try { mic.stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch { return toast('No tinc permís per al micròfon. Activa’l als ajustos del navegador.', 4500); }
|
|
1362
|
+
const rec = new MediaRecorder(mic.stream); mic.rec = rec; mic.chunks = [];
|
|
1363
|
+
rec.ondataavailable = (e) => { if (e.data.size) mic.chunks.push(e.data); };
|
|
1364
|
+
rec.onstop = async () => {
|
|
1365
|
+
mic.stream.getTracks().forEach((t) => t.stop()); mic.rec = null;
|
|
1366
|
+
const blob = new Blob(mic.chunks, { type: rec.mimeType || 'audio/webm' });
|
|
1367
|
+
$('#micBtn')?.classList.remove('rec'); $('#micBtn')?.classList.add('wait');
|
|
1368
|
+
try {
|
|
1369
|
+
if (blob.size < 1500) throw new Error('No he sentit res. Prova-ho una altra vegada.');
|
|
1370
|
+
const fd = new FormData(); fd.append('model_id', 'scribe_v1'); fd.append('file', blob, 'veu.webm');
|
|
1371
|
+
const r = await (await elevenlabs('/v1/speech-to-text', { method: 'POST', body: fd })).json(), ta = $('#msg');
|
|
1372
|
+
if (!r.text?.trim()) toast('No he entès res.');
|
|
1373
|
+
else if (ta) { ta.value = `${ta.value ? `${ta.value} ` : ''}${r.text.trim()}`; ta.dispatchEvent(new Event('input')); ta.focus(); }
|
|
1374
|
+
} catch (e) { toast(e.message, 4500); }
|
|
1375
|
+
$('#micBtn')?.classList.remove('wait');
|
|
1376
|
+
};
|
|
1377
|
+
rec.start(); $('#micBtn')?.classList.add('rec');
|
|
1378
|
+
}
|
|
1379
|
+
function voicePanel() {
|
|
1380
|
+
return `<section class="panel"><header><h2>Veu</h2>${voiceOn() ? '<span class="chip ok"><i class="dot on"></i>ElevenLabs</span>' : ''}</header><div class="body stack"><p class="small">Dicta els missatges amb el micròfon i escolta les respostes de l’agent, amb ElevenLabs.</p><div class="row"><button class="btn ink" data-act="voice" type="button">${voiceOn() ? 'Canvia la configuració' : 'Configura la veu'}</button></div></div></section>`;
|
|
1381
|
+
}
|
|
1382
|
+
function voiceSheet() {
|
|
1383
|
+
const c = voiceCfg();
|
|
1384
|
+
openSheet(`<h2>Veu amb ElevenLabs</h2><p class="muted small">Dicta amb el micròfon i escolta les respostes. La clau es queda només en aquest navegador: les crides van directes a ElevenLabs, nio no les veu.</p>
|
|
1385
|
+
<form class="stack" id="voiceForm" style="margin-top:14px">
|
|
1386
|
+
<label class="f"><span>Clau d’API</span><input type="password" id="vKey" value="${esc(c.key)}" placeholder="sk_…" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
|
|
1387
|
+
<label class="f"><span>Identificador de la veu</span><input type="text" id="vVoice" value="${esc(c.voice)}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
|
|
1388
|
+
<label class="f"><span>Model</span><select id="vModel"><option value="eleven_v3" ${c.model === 'eleven_v3' ? 'selected' : ''}>v3 (inclou català)</option><option value="eleven_multilingual_v2" ${c.model === 'eleven_multilingual_v2' ? 'selected' : ''}>Multilingual v2</option></select></label>
|
|
1389
|
+
<div class="row between sheet-foot"><button class="btn ghost" type="button" id="vOff" ${c.key ? '' : 'disabled'}>Treu la clau</button><button class="btn ink" type="submit">Desa</button></div>
|
|
1390
|
+
</form>`, (sh) => {
|
|
1391
|
+
const save = (v) => { LS.set('nio.voice', v); closeSheet(); if (state.route.name === 'session') renderSession(); else render(); };
|
|
1392
|
+
$('#voiceForm', sh).onsubmit = (e) => { e.preventDefault(); const key = $('#vKey', sh).value.trim(); if (!key) return toast('Enganxa la clau d’API.'); save({ key, voice: $('#vVoice', sh).value.trim() || VOICE_DEFAULT.voice, model: $('#vModel', sh).value }); toast('Veu activada'); };
|
|
1393
|
+
$('#vOff', sh).onclick = () => { save({ ...c, key: '' }); toast('Clau eliminada'); };
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1228
1397
|
/* ---------------------------------------------------------------- accions */
|
|
1229
1398
|
document.addEventListener('click', async (e) => {
|
|
1230
|
-
const a = e.target.closest('[data-act], [data-copy], [data-decide], [data-machine], [data-forget], [data-reconnect], [data-lm]');
|
|
1399
|
+
const a = e.target.closest('[data-act], [data-speak], [data-mic], [data-copy], [data-decide], [data-machine], [data-forget], [data-rename], [data-reconnect], [data-lm]');
|
|
1231
1400
|
if (!a) return;
|
|
1232
1401
|
if (a.dataset.copy) {
|
|
1233
1402
|
try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
|
|
@@ -1238,9 +1407,12 @@ document.addEventListener('click', async (e) => {
|
|
|
1238
1407
|
setTimeout(() => { a.innerHTML = html; a.classList.remove('copied'); delete a.dataset.copied; }, 1700);
|
|
1239
1408
|
return;
|
|
1240
1409
|
}
|
|
1410
|
+
if (a.dataset.speak != null) return speak(a);
|
|
1411
|
+
if (a.dataset.mic != null) return toggleMic();
|
|
1241
1412
|
if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
|
|
1242
1413
|
if (a.dataset.machine != null) return switchMachine(Number(a.dataset.machine));
|
|
1243
|
-
if (a.dataset.reconnect != null) { const m = store.machines[Number(a.dataset.reconnect)]; return addComputerSheet('vps', { target: m.via, auto: true }); }
|
|
1414
|
+
if (a.dataset.reconnect != null) { const m = store.machines[Number(a.dataset.reconnect)]; reconTry.delete(m.via); if (sshHelper()) { autoReconnect({ ...m }); return; } return addComputerSheet('vps', { target: m.via, auto: true }); }
|
|
1415
|
+
if (a.dataset.rename != null) return renameSheet(Number(a.dataset.rename));
|
|
1244
1416
|
if (a.dataset.forget != null) { if (confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) forgetMachine(Number(a.dataset.forget)); return; }
|
|
1245
1417
|
if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
|
|
1246
1418
|
switch (a.dataset.act) {
|
|
@@ -1259,7 +1431,7 @@ document.addEventListener('click', async (e) => {
|
|
|
1259
1431
|
case 'push-test': try { const r = await api('/api/push/test', { method: 'POST' }); toast(r.subs ? 'Enviat. Arribarà en uns segons.' : 'Primer activa els avisos'); } catch (err) { toast(err.message); } return;
|
|
1260
1432
|
case 'add-computer': return addComputerSheet();
|
|
1261
1433
|
case 'open-sessions': return go('#/sessions');
|
|
1262
|
-
case 'help': go('#/
|
|
1434
|
+
case 'help': return go('#/ajuda');
|
|
1263
1435
|
case 'more': return moreSheet();
|
|
1264
1436
|
case 'machines': return machinesSheet();
|
|
1265
1437
|
case 'mobile-qr': return mobileQrSheet();
|
|
@@ -1270,6 +1442,7 @@ document.addEventListener('click', async (e) => {
|
|
|
1270
1442
|
case 'lm-clear': local.messages = []; return render();
|
|
1271
1443
|
case 'palette': return palette();
|
|
1272
1444
|
case 'tour': return tourStart();
|
|
1445
|
+
case 'voice': return voiceSheet();
|
|
1273
1446
|
}
|
|
1274
1447
|
});
|
|
1275
1448
|
document.addEventListener('keydown', (e) => { const t = e.target; if ((e.key === 'Enter' || e.key === ' ') && t.classList?.contains('switch') && t.dataset.act) { e.preventDefault(); t.click(); } });
|
|
@@ -1285,11 +1458,11 @@ document.addEventListener('submit', (e) => {
|
|
|
1285
1458
|
/* ---------------------------------------------------------------- paleta d'ordres */
|
|
1286
1459
|
let palSel = 0;
|
|
1287
1460
|
function commands() {
|
|
1288
|
-
const c = Object.entries(VIEWS).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
|
|
1461
|
+
const c = Object.entries(VIEWS).filter(([k]) => !(k === 'models' && mq.matches)).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
|
|
1289
1462
|
if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
|
|
1290
1463
|
c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
|
|
1291
1464
|
[...state.sessions.values()].slice(0, 20).forEach((s) => c.push({ label: s.title || 'Sessió nova', hint: AGENT_NAMES[s.agent] || s.agent, run: () => go(`#/s/${s.id}`) }));
|
|
1292
|
-
c.push({ label: '
|
|
1465
|
+
c.push({ label: 'Configura la veu (ElevenLabs)', hint: 'Ajustos', run: voiceSheet });
|
|
1293
1466
|
c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
|
|
1294
1467
|
return c;
|
|
1295
1468
|
}
|
|
@@ -1347,12 +1520,12 @@ const TSTEPS = [
|
|
|
1347
1520
|
{ route: 'ordinadors', skipIf: () => !machine(), target: '.card.computer.active .c-actions', m: '.card.computer.active .c-actions', title: '2. Continua al mòbil', text: 'A la targeta de l’ordinador prem «Continua al mòbil»: surt un QR i un codi. Al mòbil, obre www.nio.cat/app, Ordinadors, i escaneja el QR o enganxa el codi.' },
|
|
1348
1521
|
{ route: 'sessions', skipIf: () => !machine(), target: '.top [data-act=new]', m: '.top [data-act=new]', title: '3. Dona feina a un agent', text: 'Una sessió és una conversa amb un agent dins d’una carpeta. Tries l’agent, la carpeta (qualsevol de l’ordinador), els permisos i li expliques què ha de fer.' },
|
|
1349
1522
|
{ route: 'approvals', skipIf: () => !machine(), target: '.side nav a[href="#/aprovacions"]', m: '#tabbar a[href="#/aprovacions"]', title: '4. Tu decideixes', text: 'En mode «Demana permís», abans d’editar un fitxer o executar una ordre, l’agent t’ho pregunta aquí. Si actives els avisos, t’arriba al mòbil.' },
|
|
1350
|
-
{ route: 'models', skipIf: () => !machine(), target: '.side nav a[href="#/models"]', m: '#tabbar [data-act=more]', title: 'Models al teu ordinador', text: 'Si tens Ollama, aquí descarregues models i veus quins tens. Res no surt de casa i no gasta res.' },
|
|
1523
|
+
{ route: 'models', skipIf: () => !machine() || mq.matches, target: '.side nav a[href="#/models"]', m: '#tabbar [data-act=more]', title: 'Models al teu ordinador', text: 'Si tens Ollama, aquí descarregues models i veus quins tens. Res no surt de casa i no gasta res.' },
|
|
1351
1524
|
{ route: 'mobil', target: '.side nav a[href="#/xat-local"]', m: '.page .model-card', title: 'Un model sense internet', text: 'Si no tens l’ordinador a mà, pots fer servir un model petit que funciona dins del mateix mòbil.' },
|
|
1352
1525
|
{ route: 'pissarra', target: '.side nav a[href="#/pissarra"]', m: '#bdTools', title: 'Pensa en dibuix', text: 'A la pissarra pots fer diagrames a mà alçada o demanar-me’ls escrivint, amb Ollama o amb el model del mòbil.' },
|
|
1353
|
-
{ route: '
|
|
1354
|
-
{ 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
|
|
1355
|
-
{ center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial
|
|
1526
|
+
{ route: 'ajuda', target: '#help', m: '#help', title: 'Si alguna cosa no va', text: 'A «Ajuda» tens cada pas explicat, què cal al servidor i què mirar si no connecta.' },
|
|
1527
|
+
{ target: '[data-act=palette]', m: '#tabbar [data-act=more]', title: () => (mq.matches ? 'Més coses' : 'Un últim truc'), text: () => (mq.matches ? 'A «Més» tens el xat local, la pissarra, els ajustos i l’ajuda, amb aquest tutorial.' : 'Prem ⌘K (o Ctrl+K) per anar a qualsevol lloc o obrir una sessió sense tocar el ratolí.') },
|
|
1528
|
+
{ center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial a Ajuda.', next: 'Finalitza' }
|
|
1356
1529
|
];
|
|
1357
1530
|
const tval = (v) => (typeof v === 'function' ? v() : v);
|
|
1358
1531
|
function tourStart() { T.on = true; T.seen = true; tourGo(0); }
|