nio-cat 0.3.5 → 0.4.3
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 +23 -70
- package/package.json +1 -1
- package/src/commands.js +51 -0
- package/src/proc.js +1 -0
- package/src/server.js +26 -0
- package/src/ssh.js +111 -0
- package/web/app/app.js +318 -140
- package/web/app/index.html +2 -0
- package/web/app/style.css +53 -0
- package/web/index.html +4 -2
- package/web/sw.js +1 -1
package/web/app/app.js
CHANGED
|
@@ -28,6 +28,8 @@ const ICON = {
|
|
|
28
28
|
more: '<circle cx="5" cy="12" r="1.3" fill="currentColor"/><circle cx="12" cy="12" r="1.3" fill="currentColor"/><circle cx="19" cy="12" r="1.3" fill="currentColor"/>',
|
|
29
29
|
refresh: '<path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/>', back: '<path d="M15 5l-7 7 7 7"/>',
|
|
30
30
|
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
|
|
31
|
+
server: '<rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>',
|
|
32
|
+
scan: '<path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3M4 12h16"/>',
|
|
31
33
|
check: '<path class="tick" d="M5 12.5l4.5 4.5L19 7.5"/>',
|
|
32
34
|
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/>'
|
|
33
35
|
};
|
|
@@ -217,38 +219,60 @@ function parseCode(text) {
|
|
|
217
219
|
}
|
|
218
220
|
// Un codi o un enllaç de connexió (#u=…&t=…).
|
|
219
221
|
const pairFrom = (text) => parsePair(text) || parseCode(text);
|
|
220
|
-
|
|
222
|
+
// Cap pantalla ensenya adreces: cada ordinador és un nom, una icona i un estat.
|
|
223
|
+
const hostOf = (u) => { try { return new URL(u).hostname; } catch { return ''; } };
|
|
224
|
+
const isLoop = (u) => ['127.0.0.1', 'localhost', '[::1]'].includes(hostOf(u));
|
|
225
|
+
const machineIcon = (m) => (m.kind === 'vps' ? ICON.server : ICON.machine);
|
|
226
|
+
const machineSub = (m) => (m.kind === 'vps' ? `Servidor${m.via ? ` · ${m.via}` : ''}` : isLoop(m.url) ? 'Aquest ordinador' : 'Ordinador');
|
|
227
|
+
function addMachine({ url, token, name, kind, via }) {
|
|
221
228
|
const ms = store.machines; let i = ms.findIndex((m) => m.url === url);
|
|
222
|
-
if (i >= 0) ms[i].token = token;
|
|
229
|
+
if (i >= 0) { ms[i].token = token; if (kind) ms[i].kind = kind; if (via) ms[i].via = via; }
|
|
230
|
+
else { ms.push({ url, token, name: name || hostOf(url), ...(kind ? { kind } : {}), ...(via ? { via } : {}) }); i = ms.length - 1; }
|
|
223
231
|
store.machines = ms; store.active = i;
|
|
224
232
|
state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.info = null; state.ollama = null;
|
|
225
233
|
connect();
|
|
226
234
|
}
|
|
235
|
+
// Passa a un altre ordinador de la llista i es queda a la mateixa secció.
|
|
236
|
+
function switchMachine(i) {
|
|
237
|
+
closeSheet(); store.active = i; streamCtrl?.abort();
|
|
238
|
+
state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; state.lastError = null;
|
|
239
|
+
connect();
|
|
240
|
+
if (state.route.name === 'session') go('#/sessions'); else render();
|
|
241
|
+
}
|
|
242
|
+
function forgetMachine(i) {
|
|
243
|
+
const ms = store.machines, was = i === store.active; ms.splice(i, 1); store.machines = ms;
|
|
244
|
+
if (was) store.active = 0; else if (i < store.active) store.active = store.active - 1;
|
|
245
|
+
if (was) { streamCtrl?.abort(); state.connected = false; state.info = null; state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); connect(); }
|
|
246
|
+
if (!ms.length) go('#/ordinadors'); else render();
|
|
247
|
+
}
|
|
227
248
|
function checkPairHash() {
|
|
228
249
|
const pair = parsePair(location.hash) || parseCode(location.hash); if (!pair) return false;
|
|
250
|
+
const hashAtStart = location.hash;
|
|
229
251
|
history.replaceState(null, '', location.pathname + '#/sessions');
|
|
230
252
|
state.route = { name: 'sessions' };
|
|
231
|
-
|
|
253
|
+
const wantQr = new URLSearchParams(location.hash.replace(/^#/, '')).get('qr') === '1' || /[#&]qr=1\b/.test(hashAtStart);
|
|
254
|
+
addMachine(pair); render(); toast('Ordinador connectat');
|
|
255
|
+
// Quan nio obre el panell, el QR per al mòbil surt tot seguit, sense cap pestanya nova.
|
|
256
|
+
if (wantQr && !isPhone()) setTimeout(mobileQrSheet, 500);
|
|
257
|
+
return true;
|
|
232
258
|
}
|
|
233
259
|
|
|
234
260
|
/* ---------------------------------------------------------------- navegació */
|
|
235
261
|
const VIEWS = {
|
|
236
262
|
sessions: { label: 'Sessions', icon: ICON.sessions, group: 'Feina' },
|
|
237
263
|
approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
|
|
238
|
-
|
|
239
|
-
|
|
264
|
+
ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
|
|
265
|
+
models: { label: 'Models', icon: ICON.models, group: 'Ordinadors' },
|
|
240
266
|
mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
|
|
241
267
|
pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' }
|
|
242
268
|
};
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
models: { title: 'Models', lead: 'Models d’Ollama que funcionen a l’ordinador, sense internet i sense comptes.' },
|
|
246
|
-
maquina: { title: 'Ordinador', lead: 'Els agents instal·lats, els avisos al mòbil i els ordinadors connectats.' }
|
|
247
|
-
};
|
|
248
|
-
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', mobil: '#/mobil', models: '#/models', maquina: '#/ordinador', pissarra: '#/pissarra' };
|
|
269
|
+
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra' };
|
|
270
|
+
const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'models', 'session']);
|
|
249
271
|
function parseRoute() {
|
|
250
272
|
const h = location.hash.replace(/^#\/?/, '');
|
|
251
273
|
if (h.startsWith('s/')) return { name: 'session', id: h.slice(2) };
|
|
274
|
+
if (h === 'ordinador') return { name: 'ordinadors' }; // adreces antigues
|
|
275
|
+
if (h === 'mobil') return { name: 'mobil' };
|
|
252
276
|
const k = Object.keys(HASH).find((k) => HASH[k] === '#/' + h);
|
|
253
277
|
return { name: k || 'sessions' };
|
|
254
278
|
}
|
|
@@ -257,6 +281,8 @@ let lastName = null;
|
|
|
257
281
|
function route() {
|
|
258
282
|
if (checkPairHash()) return;
|
|
259
283
|
state.route = parseRoute();
|
|
284
|
+
// Sense cap ordinador, tot porta al mateix lloc: la pàgina Ordinadors.
|
|
285
|
+
if (!machine() && NEEDS_MACHINE.has(state.route.name)) { history.replaceState(null, '', '#/ordinadors'); state.route = { name: 'ordinadors' }; }
|
|
260
286
|
if (state.route.name === 'session') openSession(state.route.id); else render();
|
|
261
287
|
}
|
|
262
288
|
|
|
@@ -269,10 +295,13 @@ function renderChrome() {
|
|
|
269
295
|
prev = v.group;
|
|
270
296
|
return `${head}<a href="${HASH[k]}" style="--i:${i}" title="${v.label}" ${k === active ? 'aria-current="page"' : ''}>${svg(v.icon)}<span class="l">${v.label}</span>${k === 'approvals' && n ? `<span class="badge">${n}</span>` : ''}</a>`;
|
|
271
297
|
}).join('');
|
|
272
|
-
const m = machine();
|
|
273
|
-
|
|
274
|
-
sc.
|
|
275
|
-
|
|
298
|
+
const m = machine(), sc = $('#sideConn');
|
|
299
|
+
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
|
+
sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
|
|
301
|
+
const more = ['models', 'mobil', 'pissarra'].includes(r.name);
|
|
302
|
+
$('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['approvals', ICON.approvals, 'Aprovacions', active === 'approvals'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
|
|
303
|
+
.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
|
+
+ `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
|
|
276
305
|
const s = r.name === 'session' ? state.sessions.get(r.id) : null;
|
|
277
306
|
$('#here').textContent = s ? s.title || 'Sessió nova' : VIEWS[r.name]?.label || '';
|
|
278
307
|
$('#mRight').innerHTML = r.name === 'session'
|
|
@@ -286,6 +315,7 @@ function render() {
|
|
|
286
315
|
renderChrome();
|
|
287
316
|
document.body.classList.toggle('board-mode', board);
|
|
288
317
|
document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && !!local.engine));
|
|
318
|
+
document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' && !!local.engine));
|
|
289
319
|
if (!board && lastName === 'pissarra') window.NioBoard?.unmount();
|
|
290
320
|
if (board && lastName === 'pissarra' && $('#boardRoot')) return;
|
|
291
321
|
const changed = lastName !== r.name || r.name === 'session';
|
|
@@ -294,7 +324,7 @@ function render() {
|
|
|
294
324
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
295
325
|
else {
|
|
296
326
|
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
297
|
-
view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === '
|
|
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>`;
|
|
298
328
|
if (r.name === 'mobil') bindLocal();
|
|
299
329
|
if (r.name === 'models' && machine()) loadOllama();
|
|
300
330
|
}
|
|
@@ -303,48 +333,164 @@ function render() {
|
|
|
303
333
|
tourUpdate();
|
|
304
334
|
}
|
|
305
335
|
|
|
306
|
-
/* ----------
|
|
307
|
-
// La connexió viu només a Sessions. Les altres pàgines, sense ordinador, hi envien.
|
|
336
|
+
/* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
|
|
308
337
|
const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
<
|
|
316
|
-
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
338
|
+
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
|
+
function computerCard(m, i) {
|
|
340
|
+
const on = i === store.active;
|
|
341
|
+
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
|
+
return `<div class="card computer ${on ? 'active' : ''}">
|
|
343
|
+
<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>
|
|
345
|
+
</div>`;
|
|
346
|
+
}
|
|
347
|
+
// Ajuda sempre a mà: com es fa cada cosa, i què mirar si no connecta.
|
|
348
|
+
function helpPanel(open) {
|
|
349
|
+
const q = (t, body) => `<details><summary>${t}</summary><div class="help-body">${body}</div></details>`;
|
|
350
|
+
return `<section class="panel help" id="help"><header><h2>Com es fa</h2></header><div class="body">
|
|
351
|
+
${q('Connectar aquest ordinador', `<p>Al terminal, des de la carpeta dels teus projectes, executa <span class="mono">npx nio-cat</span>. S’obre nio.cat ja connectat. Cal Node.js 18 o més nou.</p>`)}
|
|
352
|
+
${q('Fer-lo servir des del mòbil', `<p>A l’ordinador, a Ordinadors, prem <b>Continua al mòbil</b>. Surt un QR i un codi <span class="mono">nio-…</span>.</p><p>Al mòbil, obre <b>www.nio.cat/app</b> → Ordinadors → Afegeix ordinador, i escaneja el QR o enganxa el codi.</p><p>Per connectar-t’hi des de qualsevol xarxa cal <span class="mono">cloudflared</span> a l’ordinador (<span class="mono">brew install cloudflared</span>). Sense ell, el mòbil ha d’estar a la mateixa wifi.</p>`)}
|
|
353
|
+
${q('Afegir un servidor (VPS) amb Claude Code', `<p>Ordinadors → Afegeix ordinador → <b>Servidor (SSH)</b> → escriu <span class="mono">usuari@servidor</span>. L’ordinador hi entra, hi engega nio i el servidor surt a la llista.</p><p>Després, prem <b>Continua al mòbil</b> amb el servidor actiu per fer-lo servir des del mòbil.</p>`)}
|
|
354
|
+
${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
|
+
${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
|
+
${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
|
+
</div></section>`;
|
|
359
|
+
}
|
|
360
|
+
function viewComputers() {
|
|
361
|
+
const ms = store.machines, m = machine(), info = state.info;
|
|
362
|
+
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>`;
|
|
363
|
+
if (!ms.length) {
|
|
364
|
+
const steps = isPhone()
|
|
365
|
+
? [['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
|
+
: [['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>${helpPanel()}`;
|
|
333
368
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
<div class="
|
|
337
|
-
<section class="panel
|
|
338
|
-
|
|
339
|
-
<p class="muted small">S’obre aquest panell ja connectat. Cal Node.js 18 o més nou.</p></div></section>
|
|
340
|
-
<section class="panel choice"><header><h2>Un altre ordinador</h2></header><div class="body stack">
|
|
341
|
-
<p class="small">Engega nio a l’altre ordinador o a un servidor. Copia aquí el codi que mostra.</p>
|
|
342
|
-
${pasteForm(true)}
|
|
343
|
-
<p class="small"><b>Un servidor (VPS) per SSH.</b> Des d'aquest ordinador, sense obrir cap port:</p>${cmd('npx nio-cat ssh usuari@servidor')}
|
|
344
|
-
<p class="muted small">Engega nio al servidor, obre el túnel i el panell queda connectat.</p></div></section>
|
|
369
|
+
const detail = m && state.connected ? `<h2 class="sec">${esc(m.name)}</h2>
|
|
370
|
+
<div class="grid2">
|
|
371
|
+
<div class="stack">
|
|
372
|
+
<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
|
+
${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>` : ''}
|
|
345
374
|
</div>
|
|
346
|
-
<
|
|
347
|
-
|
|
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
|
+
</div>` : '';
|
|
379
|
+
return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}${helpPanel()}`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* ---------- Afegir un ordinador: aquest, un servidor per SSH o un codi */
|
|
383
|
+
async function probeLocal() {
|
|
384
|
+
try {
|
|
385
|
+
const c = new AbortController(); setTimeout(() => c.abort(), 1500);
|
|
386
|
+
const d = await (await fetch('http://127.0.0.1:7717/api/hello', { signal: c.signal, cache: 'no-store' })).json();
|
|
387
|
+
return d?.nio ? d : null;
|
|
388
|
+
} catch { return null; }
|
|
389
|
+
}
|
|
390
|
+
// Afegeix un servidor per SSH des de l'ordinador actiu. El progrés arriba línia a línia.
|
|
391
|
+
async function sshAdd(target, onStep) {
|
|
392
|
+
const m = machine(); if (!m) throw new Error('Primer connecta un ordinador');
|
|
393
|
+
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
|
+
if (!res.ok || !res.body) throw new Error((await res.json().catch(() => ({}))).error || 'Aquest ordinador no pot afegir servidors. Actualitza nio.');
|
|
395
|
+
const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '', last = null;
|
|
396
|
+
for (;;) {
|
|
397
|
+
const { value, done } = await reader.read(); if (done) break;
|
|
398
|
+
buf += dec.decode(value, { stream: true }); let i;
|
|
399
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
400
|
+
const o = JSON.parse(buf.slice(0, i)); buf = buf.slice(i + 1);
|
|
401
|
+
if (o.step) onStep(o.step); else last = o;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (!last || last.error) throw new Error(last?.error || 'S’ha tallat la connexió');
|
|
405
|
+
return last;
|
|
406
|
+
}
|
|
407
|
+
async function startScan(video, onText) {
|
|
408
|
+
const det = new BarcodeDetector({ formats: ['qr_code'] });
|
|
409
|
+
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
|
|
410
|
+
video.srcObject = stream; await video.play(); let alive = true;
|
|
411
|
+
(async () => { while (alive) { try { const r = await det.detect(video); if (r[0]) onText(r[0].rawValue); } catch { /* quadre sense QR */ } await sleep(250); } })();
|
|
412
|
+
return { stop() { alive = false; stream.getTracks().forEach((t) => t.stop()); } };
|
|
413
|
+
}
|
|
414
|
+
function addComputerSheet(tab, opts = {}) {
|
|
415
|
+
const phone = isPhone(), canScan = 'BarcodeDetector' in window && !!navigator.mediaDevices?.getUserMedia;
|
|
416
|
+
const TABS = phone ? [['code', 'Codi o QR'], ['vps', 'Servidor (SSH)']] : [['local', 'Aquest ordinador'], ['vps', 'Servidor (SSH)'], ['code', 'Tinc un codi']];
|
|
417
|
+
let cur = TABS.some((t) => t[0] === tab) ? tab : TABS[0][0], poll = 0, scan = null, shown = '', hosts = [];
|
|
418
|
+
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">×</button></div><div class="seg" id="addTabs" role="group">${TABS.map(([k, l]) => `<button type="button" data-tab="${k}">${l}</button>`).join('')}</div></div><div class="wiz-body" id="addBody"></div>`;
|
|
420
|
+
const body = $('#addBody', sh);
|
|
421
|
+
const stop = () => { clearTimeout(poll); scan?.stop(); scan = null; shown = ''; };
|
|
422
|
+
const set = (key, html) => { if (key === shown) return false; shown = key; body.innerHTML = html; return true; };
|
|
423
|
+
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(() => {});
|
|
425
|
+
$$('#addTabs button', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.tab === cur));
|
|
426
|
+
body.scrollTop = 0;
|
|
427
|
+
if (cur === 'local') local(); else if (cur === 'vps') vps(); else code();
|
|
428
|
+
};
|
|
429
|
+
const local = async () => {
|
|
430
|
+
set('wait', '<p class="muted small">Mirant si nio funciona en aquest ordinador…</p>');
|
|
431
|
+
const d = await probeLocal(); if (cur !== 'local') return;
|
|
432
|
+
const saved = d && store.machines.findIndex((x) => x.id === d.id);
|
|
433
|
+
if (d && saved >= 0) set('saved', `<p class="wiz-q">Aquest ordinador ja és a la llista</p><p class="muted small">${esc(d.name)}</p><div class="row"><button class="btn ink" type="button" data-open-saved="${saved}">Obre</button></div>`);
|
|
434
|
+
else if (d) set('found', `<p class="wiz-q">nio funciona en aquest ordinador</p><p class="muted small">Aquest navegador encara no hi té accés. Torna a engegar nio perquè obri el panell ja connectat:</p>${cmdBox('npx nio-cat')}`);
|
|
435
|
+
else set('none', `<p class="wiz-q">Engega nio en aquest ordinador</p><p class="muted small">Al terminal, des de la carpeta dels teus projectes:</p>${cmdBox('npx nio-cat')}<p class="muted small">S’obrirà aquest panell ja connectat. Cal Node.js 18 o més nou.</p>`);
|
|
436
|
+
if (saved < 0 || !d) poll = setTimeout(local, 3000);
|
|
437
|
+
};
|
|
438
|
+
const runSsh = async (target) => {
|
|
439
|
+
const steps = $('#sshSteps', sh), btn = $('#sshForm button', sh); if (!steps) return; btn.disabled = true; steps.innerHTML = '';
|
|
440
|
+
const add = (t) => { $$('li.on', steps).forEach((l) => { l.className = 'ok'; }); steps.insertAdjacentHTML('beforeend', `<li class="on">${esc(t)}…</li>`); };
|
|
441
|
+
try {
|
|
442
|
+
const r = await sshAdd(target, add);
|
|
443
|
+
LS.set('nio.lastSsh', target);
|
|
444
|
+
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 });
|
|
447
|
+
shown = 'vps-ok';
|
|
448
|
+
body.innerHTML = `<p class="wiz-q">${esc(r.host)} és a punt</p>${r.claude ? '<p class="muted small">Ja el pots fer servir: crea-hi una sessió amb Claude Code.</p>' : '<p class="muted small">Claude Code no hi és. Entra al servidor, instal·la’l i inicia sessió amb <span class="mono">claude</span>.</p>'}<div class="row"><a class="btn ink" href="#/sessions" data-close>Obre sessions</a><button class="btn" type="button" data-act="mobile-qr">Continua al mòbil</button></div>`;
|
|
449
|
+
} catch (err) { $$('li.on', steps).forEach((l) => { l.className = 'err'; }); steps.insertAdjacentHTML('beforeend', `<li class="err">${esc(err.message)}</li>`); btn.disabled = false; }
|
|
450
|
+
};
|
|
451
|
+
const vps = () => {
|
|
452
|
+
const m = machine();
|
|
453
|
+
if (!m) return set('vps-no', `<p class="wiz-q">Primer, un ordinador</p><p class="muted small">El servidor s’afegeix des d’un ordinador que ja tinguis connectat, perquè és ell qui hi entra per SSH.</p>${phone ? '' : '<button class="btn" type="button" data-tab="local">Afegeix aquest ordinador</button>'}`);
|
|
454
|
+
set('vps', `<p class="wiz-q">Un servidor amb Claude Code</p>
|
|
455
|
+
<p class="muted small">${esc(m.name)} hi entra per SSH, hi engega nio amb un túnel segur i el deixa llest per al mòbil.</p>
|
|
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>
|
|
458
|
+
<ul class="steps" id="sshSteps"></ul>
|
|
459
|
+
<p class="muted small">Cal entrar-hi sense contrasenya (clau SSH) i que el servidor tingui Node.js 18 o més nou. Claude Code hi ha d’estar instal·lat i amb sessió iniciada.</p>`);
|
|
460
|
+
};
|
|
461
|
+
const code = () => {
|
|
462
|
+
set('code', `<p class="wiz-q">${phone ? 'Escaneja o escriu el codi' : 'Escriu el codi'}</p>
|
|
463
|
+
<form class="pair-form" id="pairForm"><input type="text" id="pairInput" placeholder="nio-…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi de connexió"><button class="btn ink" type="submit">Connecta</button></form>
|
|
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="muted small">Per escanejar, obre la càmera del mòbil i apunta al QR: s’obrirà nio.</p>' : ''}
|
|
465
|
+
<p class="muted small">El codi surt al terminal, sota el QR, o a «Continua al mòbil».</p>`);
|
|
466
|
+
};
|
|
467
|
+
sh.onclick = async (e) => {
|
|
468
|
+
const t = e.target.closest('[data-tab]'); if (t) { cur = t.dataset.tab; return draw(); }
|
|
469
|
+
const hb = e.target.closest('[data-host]'); if (hb) { $('#sshIn', sh).value = hb.dataset.host; return $('#sshForm', sh).requestSubmit(); }
|
|
470
|
+
const os = e.target.closest('[data-open-saved]'); if (os) { switchMachine(+os.dataset.openSaved); return go('#/sessions'); }
|
|
471
|
+
if (e.target.closest('[data-close]')) { stop(); return closeSheet(); }
|
|
472
|
+
if (e.target.closest('[data-scan]')) {
|
|
473
|
+
const v = $('#scanVideo', sh); v.hidden = false;
|
|
474
|
+
try { scan = await startScan(v, (text) => { const pair = pairFrom(text); if (pair) { stop(); closeSheet(); addMachine(pair); toast('Connectant…'); go('#/sessions'); } }); }
|
|
475
|
+
catch { v.hidden = true; toast('No puc obrir la càmera. Escriu el codi.'); }
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
sh.onsubmit = async (e) => {
|
|
479
|
+
if (e.target.id !== 'sshForm') return;
|
|
480
|
+
e.preventDefault(); e.stopPropagation();
|
|
481
|
+
runSsh($('#sshIn', sh).value.trim());
|
|
482
|
+
};
|
|
483
|
+
draw();
|
|
484
|
+
if (opts.auto && cur === 'vps') setTimeout(() => runSsh(opts.target), 50);
|
|
485
|
+
}, 'wiz');
|
|
486
|
+
}
|
|
487
|
+
function machinesSheet() {
|
|
488
|
+
const ms = store.machines;
|
|
489
|
+
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
|
+
<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
|
+
}
|
|
492
|
+
function moreSheet() {
|
|
493
|
+
openSheet(`<h2>Més</h2><div class="list">${['models', 'mobil', 'pissarra'].map((k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`).join('')}<a class="card row" href="#/ordinadors" data-act="help" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.1"/></svg><b>Com es fa</b></a><button class="card row" type="button" data-act="tour" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17.2v.1"/></svg><b>Tutorial</b></button></div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
|
|
348
494
|
}
|
|
349
495
|
|
|
350
496
|
/* ---------- Sessions */
|
|
@@ -361,7 +507,7 @@ function viewSessions() {
|
|
|
361
507
|
const hint = mobileHint();
|
|
362
508
|
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}`;
|
|
363
509
|
if (!state.connected && !list.length) {
|
|
364
|
-
return `${head}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p
|
|
510
|
+
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>`;
|
|
365
511
|
}
|
|
366
512
|
return `${head}${list.length ? `<div class="list">${list.map((s) => `
|
|
367
513
|
<a class="card session-card" href="#/s/${esc(s.id)}">
|
|
@@ -528,23 +674,103 @@ function renderTyping() {
|
|
|
528
674
|
if (!busy) $$('.msg-ai.cursor', feed).forEach((n) => n.classList.remove('cursor'));
|
|
529
675
|
const hb = $('#headBot .bot'); if (hb) hb.dataset.mood = sessionMood(s);
|
|
530
676
|
}
|
|
677
|
+
/* ---------- Comandes amb «/» (les de nio i les de l'agent) */
|
|
678
|
+
const NIO_CMDS = [
|
|
679
|
+
{ name: 'mode', hint: 'Canvia els permisos', args: [['llegir', 'Només llegir', 'l'], ['preguntar', 'Demana permís', 'p'], ['tot', 'Via lliure', 't']] },
|
|
680
|
+
{ name: 'model', hint: 'Canvia de model' },
|
|
681
|
+
{ name: 'stop', hint: 'Atura l’agent' },
|
|
682
|
+
{ name: 'nova', hint: 'Nova sessió amb el mateix agent i carpeta' },
|
|
683
|
+
{ name: 'git', hint: 'Git i GitHub: commit i PR' },
|
|
684
|
+
{ name: 'nom', hint: 'Posa nom a la sessió', free: true },
|
|
685
|
+
{ name: 'opcions', hint: 'Opcions de la sessió' },
|
|
686
|
+
{ name: 'ajuda', hint: 'Llista de comandes' }
|
|
687
|
+
];
|
|
688
|
+
const cmdCache = new Map();
|
|
689
|
+
async function loadCmds(id) {
|
|
690
|
+
if (cmdCache.has(id) || !machine()) return;
|
|
691
|
+
cmdCache.set(id, []);
|
|
692
|
+
try { cmdCache.set(id, await api(`/api/sessions/${id}/commands`)); } catch { /* sense comandes de l'agent */ }
|
|
693
|
+
}
|
|
694
|
+
const agentCmds = (id) => cmdCache.get(id) || [];
|
|
695
|
+
function pickMode(arg) {
|
|
696
|
+
const a = String(arg || '').trim().toLowerCase(); if (!a) return null;
|
|
697
|
+
const m = NIO_CMDS[0].args.find(([k, , al]) => k === a || al === a || k.startsWith(a) || (a === 'lliure' && k === 'tot'));
|
|
698
|
+
return m ? m[0] : null;
|
|
699
|
+
}
|
|
700
|
+
async function runCmd(s, name, arg) {
|
|
701
|
+
const patch = async (body, msg) => { try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body }); state.sessions.set(s.id, sum); renderSessionHead(); if (msg) toast(msg); } catch (e) { toast(e.message, 4200); } };
|
|
702
|
+
switch (name) {
|
|
703
|
+
case 'mode': { const m = pickMode(arg); if (!arg) return modeSheet(s); if (!m) return toast('Els permisos són: llegir (l), preguntar (p) o tot (t)'); return patch({ mode: m }, `Permisos: ${MODES[m].label}`); }
|
|
704
|
+
case 'model': return arg ? patch({ model: arg }, `Model: ${arg}`) : modelSheet(s);
|
|
705
|
+
case 'stop': try { await api(`/api/sessions/${s.id}/stop`, { method: 'POST' }); toast('Aturat'); } catch (e) { toast(e.message); } return;
|
|
706
|
+
case 'nova': return newSessionSheet({ agent: s.agent, cwd: s.cwd, mode: s.mode });
|
|
707
|
+
case 'git': return gitSheet(s.id);
|
|
708
|
+
case 'nom': return arg ? patch({ title: arg }, 'Nom desat') : toast('Escriu el nom: /nom Arregla els tests');
|
|
709
|
+
case 'opcions': return sessionOptions(s.id);
|
|
710
|
+
case 'ajuda': return helpCmdsSheet(s);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
function helpCmdsSheet(s) {
|
|
714
|
+
const ac = agentCmds(s.id);
|
|
715
|
+
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; });
|
|
716
|
+
}
|
|
717
|
+
// Llista de suggeriments segons el que hi ha escrit: «/», «/mo», «/mode l».
|
|
718
|
+
function slashItems(s, v) {
|
|
719
|
+
const m = v.match(/^\/([\w:.-]*)$/), a = v.match(/^\/mode\s+(\S*)$/i);
|
|
720
|
+
if (a) { const q = a[1].toLowerCase(); return NIO_CMDS[0].args.filter(([k, , al]) => !q || k.startsWith(q) || al === q).map(([k, h, al]) => ({ fill: `/mode ${k}`, run: ['mode', k], label: `/mode ${k}`, hint: `${h} · ${al}`, tag: 'nio' })); }
|
|
721
|
+
if (!m) return [];
|
|
722
|
+
const q = m[1].toLowerCase();
|
|
723
|
+
const nio = NIO_CMDS.map((c) => ({ ...c, tag: 'nio' }));
|
|
724
|
+
const ag = agentCmds(s.id).map((c) => ({ name: c.name, hint: c.hint, tag: AGENT_NAMES[s.agent] || s.agent, agent: true }));
|
|
725
|
+
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));
|
|
726
|
+
return [...starts, ...has].slice(0, 40).map((c) => ({ ...c, label: `/${c.name}` }));
|
|
727
|
+
}
|
|
531
728
|
function renderComposer() {
|
|
532
729
|
const box = $('#composer'); if (!box) return;
|
|
533
730
|
const s = state.sessions.get(state.route.id), busy = !!s && (s.state === 'running' || s.state === 'waiting');
|
|
534
731
|
const prev = $('textarea', box)?.value || '';
|
|
535
732
|
if (box.dataset.busy === String(busy) && $('textarea', box)) return;
|
|
536
733
|
box.dataset.busy = String(busy);
|
|
537
|
-
|
|
734
|
+
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>`;
|
|
538
736
|
const ta = $('#msg'); ta.value = prev;
|
|
539
737
|
const grow = () => autogrow(ta);
|
|
540
|
-
|
|
738
|
+
let items = [], sel = 0;
|
|
739
|
+
const pop = $('#slash');
|
|
740
|
+
function drawSlash() {
|
|
741
|
+
const cur = state.sessions.get(state.route.id); if (!cur || !pop.isConnected) return;
|
|
742
|
+
items = ta.value.includes('\n') ? [] : slashItems(cur, ta.value);
|
|
743
|
+
pop.hidden = !items.length; if (!items.length) return;
|
|
744
|
+
sel = Math.min(sel, items.length - 1);
|
|
745
|
+
pop.innerHTML = items.map((c, i) => `<button type="button" role="option" data-i="${i}" aria-selected="${i === sel}"><span class="n mono">${esc(c.label)}</span><span class="h">${esc(c.hint || '')}</span><span class="t ${c.agent ? 'ag' : ''}">${esc(c.tag)}</span></button>`).join('');
|
|
746
|
+
$('[aria-selected="true"]', pop)?.scrollIntoView({ block: 'nearest' });
|
|
747
|
+
}
|
|
748
|
+
const pick = (i) => {
|
|
749
|
+
const c = items[i]; if (!c) return; const cur = state.sessions.get(state.route.id);
|
|
750
|
+
if (c.run) { ta.value = ''; grow(); drawSlash(); return runCmd(cur, ...c.run); }
|
|
751
|
+
if (c.args || c.free || c.agent) { ta.value = `/${c.name} `; grow(); sel = 0; drawSlash(); ta.focus(); return; }
|
|
752
|
+
ta.value = ''; grow(); drawSlash(); runCmd(cur, c.name, '');
|
|
753
|
+
};
|
|
754
|
+
ta.addEventListener('input', () => { grow(); sel = 0; drawSlash(); });
|
|
755
|
+
pop.addEventListener('pointerdown', (e) => { const b = e.target.closest('[data-i]'); if (b) { e.preventDefault(); pick(+b.dataset.i); } });
|
|
541
756
|
const send = async () => {
|
|
542
757
|
const text = ta.value.trim(); if (!text || !s) return;
|
|
543
|
-
|
|
758
|
+
const c = text.match(/^\/([\w:.-]+)(?:\s+([\s\S]*))?$/), nio = c && NIO_CMDS.find((x) => x.name === c[1].toLowerCase());
|
|
759
|
+
ta.value = ''; grow(); drawSlash();
|
|
760
|
+
if (nio) return runCmd(s, nio.name, (c[2] || '').trim());
|
|
544
761
|
try { await api(`/api/sessions/${s.id}/message`, { method: 'POST', body: { text } }); }
|
|
545
762
|
catch (e) { ta.value = text; grow(); toast(e.message, 4200); }
|
|
546
763
|
};
|
|
547
|
-
ta.addEventListener('keydown', (e) => {
|
|
764
|
+
ta.addEventListener('keydown', (e) => {
|
|
765
|
+
if (items.length && !pop.hidden) {
|
|
766
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); sel = (sel + 1) % items.length; return drawSlash(); }
|
|
767
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); sel = (sel - 1 + items.length) % items.length; return drawSlash(); }
|
|
768
|
+
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey && !isTouch && !e.isComposing && !/^\/\S+\s/.test(ta.value))) { e.preventDefault(); return pick(sel); }
|
|
769
|
+
if (e.key === 'Escape') { e.preventDefault(); pop.hidden = true; items = []; return; }
|
|
770
|
+
}
|
|
771
|
+
if (e.key === 'Enter' && !e.shiftKey && !isTouch && !e.isComposing) { e.preventDefault(); send(); }
|
|
772
|
+
});
|
|
773
|
+
grow(); drawSlash();
|
|
548
774
|
$('#sendBtn').onclick = async () => { if (busy) { try { await api(`/api/sessions/${s.id}/stop`, { method: 'POST' }); } catch (e) { toast(e.message); } } else send(); };
|
|
549
775
|
}
|
|
550
776
|
|
|
@@ -573,7 +799,11 @@ async function newSessionSheet(preset = {}) {
|
|
|
573
799
|
let projects = [];
|
|
574
800
|
try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
|
|
575
801
|
const first = agents.find((a) => a.available);
|
|
576
|
-
|
|
802
|
+
// Continuïtat: l'última tria d'aquest ordinador (agent, carpeta, permisos) i les carpetes recents.
|
|
803
|
+
const lastKey = `nio.last.${machine()?.id || machine()?.url}`, last = LS.get(lastKey, {});
|
|
804
|
+
const lastAgent = agents.find((a) => a.id === last.agent && a.available);
|
|
805
|
+
const sel = { agent: lastAgent?.id || first?.id || '', cwd: last.cwd || projects.find((p) => !p.root)?.path || projects[0]?.path || '', mode: last.mode || 'preguntar', model: '', text: '', ...preset };
|
|
806
|
+
const recents = (last.recent || []).filter((p) => p);
|
|
577
807
|
const STEPS = ['Agent', 'Carpeta', 'Opcions', 'Tasca'];
|
|
578
808
|
let step = preset.agent ? 1 : 0, fsd = null, gh = null, models = null;
|
|
579
809
|
const agentOf = () => agents.find((a) => a.id === sel.agent);
|
|
@@ -626,7 +856,7 @@ async function newSessionSheet(preset = {}) {
|
|
|
626
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>`,
|
|
627
857
|
() => {
|
|
628
858
|
const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
|
|
629
|
-
const quick = projects.slice(0, 8).map((p) => `<button type="button" class="chip" data-fs="${esc(p.path)}">${esc(p.name)}</button>`).join('');
|
|
859
|
+
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('');
|
|
630
860
|
return `<p class="wiz-q">A quina carpeta ha de treballar?</p>
|
|
631
861
|
<form class="pair-form" id="fsForm"><input type="text" id="fsIn" value="${esc(fsd?.path || sel.cwd)}" placeholder="/ruta/exacta/de/la/carpeta" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Ruta de la carpeta"><button class="btn" type="submit">Obre</button></form>
|
|
632
862
|
<div class="fs-pick"><span class="muted small">Carpeta triada</span><b class="mono small">${esc(fsd?.path || sel.cwd || '—')}</b>${fsd?.git ? '<span class="chip">git</span>' : ''}</div>
|
|
@@ -650,7 +880,7 @@ async function newSessionSheet(preset = {}) {
|
|
|
650
880
|
const start = async () => {
|
|
651
881
|
if (!sel.text.trim()) { $('#firstMsg', sh)?.focus(); return toast('Explica a l’agent què ha de fer'); }
|
|
652
882
|
const btn = $('[data-start]', sh); btn.disabled = true;
|
|
653
|
-
try { const { text, ...rest } = sel; const s = await api('/api/sessions', { method: 'POST', body: { ...rest, text: text.trim() } }); state.sessions.set(s.id, s); closeSheet(); go(`#/s/${s.id}`); }
|
|
883
|
+
try { const { text, ...rest } = sel; const s = await api('/api/sessions', { method: 'POST', body: { ...rest, text: text.trim() } }); state.sessions.set(s.id, s); LS.set(lastKey, { agent: sel.agent, cwd: sel.cwd, mode: sel.mode, recent: [sel.cwd, ...recents.filter((p) => p !== sel.cwd)].slice(0, 6) }); closeSheet(); go(`#/s/${s.id}`); }
|
|
654
884
|
catch (e) { toast(e.message, 4200); btn.disabled = false; }
|
|
655
885
|
};
|
|
656
886
|
sh.onclick = (e) => {
|
|
@@ -715,24 +945,10 @@ function modeSheet(s) {
|
|
|
715
945
|
/* ---------- Ordinadors i mòbil */
|
|
716
946
|
const isLocalMachine = () => { const m = machine(); try { return !!m && ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(m.url).hostname); } catch { return false; } };
|
|
717
947
|
function mobileHint() {
|
|
718
|
-
|
|
948
|
+
const m = machine(), key = `nio.mobHint.${m?.id || m?.url}`;
|
|
949
|
+
if (!m || isPhone() || !state.connected || LS.get(key, false)) return '';
|
|
719
950
|
return `<div class="panel hint"><div class="row between" style="flex-wrap:wrap;gap:12px"><div><b>Vols fer-lo servir al mòbil?</b><p class="muted small" style="margin-top:2px">Escaneja un codi i continues des del telèfon, amb les mateixes sessions.</p></div><div class="row"><button class="btn ghost" type="button" data-act="mobile-hint-off">Ara no</button><button class="btn ink" type="button" data-act="mobile-qr">Mostra el QR</button></div></div></div>`;
|
|
720
951
|
}
|
|
721
|
-
function machinesSheet() {
|
|
722
|
-
const ms = store.machines;
|
|
723
|
-
openSheet(`<h2>Ordinadors</h2><div class="stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><i class="dot ${i === store.active && state.connected ? 'on' : ''}"></i><span class="grow"><b>${esc(x.name)}</b><div class="muted small mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.url)}</div></span>${i === store.active ? '<span class="chip">Actiu</span>' : ''}</button>`).join('')}
|
|
724
|
-
<div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button><button class="btn ink" type="button" data-act="add-machine">${svg(ICON.plus)}Connecta’n un altre</button></div></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
|
|
725
|
-
}
|
|
726
|
-
function addMachineSheet() {
|
|
727
|
-
const cmd = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
|
|
728
|
-
openSheet(`<h2>Connecta un altre ordinador</h2>
|
|
729
|
-
<div class="stack-lg">
|
|
730
|
-
<div class="stack"><b>Un ordinador</b><p class="muted small">Al terminal de l’altre ordinador, des de la carpeta dels projectes:</p>${cmd('npx nio-cat')}<p class="muted small">Escaneja el QR que surt, o enganxa aquí sota l’enllaç.</p></div>
|
|
731
|
-
<div class="stack"><b>Un servidor (VPS) per SSH</b><p class="muted small">Des de l’ordinador on tens aquest panell. No cal obrir cap port al servidor, només Node.js 18 o més nou:</p>${cmd('npx nio-cat ssh usuari@servidor')}<p class="muted small">Engega nio al servidor, obre un túnel SSH i et mostra el panell connectat.</p></div>
|
|
732
|
-
<div class="stack"><b>Un servidor amb adreça pública</b><p class="muted small">Si el servidor té una adreça amb HTTPS (Tailscale, Cloudflare o Caddy):</p>${cmd('npx nio-cat --url https://la-teva-adreça --no-open')}<p class="muted small">Deixa’l sota <span class="mono">tmux</span>, <span class="mono">pm2</span> o <span class="mono">systemd</span>. Copia aquí el codi que mostra.</p></div>
|
|
733
|
-
<form class="pair-form" id="pairForm"><input type="text" id="pairInput" placeholder="nio-… o enllaç" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi o enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
|
|
734
|
-
</div>`);
|
|
735
|
-
}
|
|
736
952
|
let qrTimer = 0;
|
|
737
953
|
function mobileQrSheet() {
|
|
738
954
|
clearTimeout(qrTimer);
|
|
@@ -741,11 +957,11 @@ function mobileQrSheet() {
|
|
|
741
957
|
let r; try { r = await api('/api/pair'); } catch (e) { box.innerHTML = `<p class="muted small">${esc(e.message)}</p>`; return; }
|
|
742
958
|
if (!$('#qrBox')) return;
|
|
743
959
|
if (!r.link) {
|
|
744
|
-
box.innerHTML =
|
|
960
|
+
box.innerHTML = n < 8 ? '<p class="muted small">Esperant el túnel…</p>' : `<p class="wiz-q">Falta una adreça per al mòbil</p><p class="muted small">Per connectar-t’hi des de qualsevol xarxa, instal·la <span class="mono">cloudflared</span> a l’ordinador i torna a engegar nio:</p>${cmdBox('brew install cloudflared')}<p class="muted small">Sense ell, el mòbil només es connecta per la mateixa wifi: engega nio amb <span class="mono">npx nio-cat --lan</span>.</p>`;
|
|
745
961
|
if (n < 8) qrTimer = setTimeout(() => paint(n + 1), 2000);
|
|
746
962
|
return;
|
|
747
963
|
}
|
|
748
|
-
box.innerHTML = `<div class="qr-box">${r.svg}</div><p class="muted small">Escaneja-ho amb la càmera del mòbil.${r.lan ? ' El mòbil ha d’estar a la mateixa wifi.' : ''}</p><div class="row"><button class="btn" type="button" data-copy="${esc(r.code)}">${svg(ICON.copy)}Copia el codi</button></div><p class="muted small">
|
|
964
|
+
box.innerHTML = `<div class="qr-box">${r.svg}</div><p class="muted small">Escaneja-ho amb la càmera del mòbil.${r.lan ? ' El mòbil ha d’estar a la mateixa wifi.' : ''}</p><div class="row"><button class="btn" type="button" data-copy="${esc(r.code)}">${svg(ICON.copy)}Copia el codi</button></div><p class="muted small">Al mòbil: www.nio.cat/app → Ordinadors → Afegeix ordinador. Escaneja el QR o enganxa el codi.</p>`;
|
|
749
965
|
};
|
|
750
966
|
openSheet(`<h2>Continua al mòbil</h2><div class="stack" id="qrBox"><p class="muted small">Preparant el codi…</p></div><div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
|
|
751
967
|
paint();
|
|
@@ -793,25 +1009,6 @@ function sessionOptions(id) {
|
|
|
793
1009
|
});
|
|
794
1010
|
}
|
|
795
1011
|
|
|
796
|
-
/* ---------- Ordinador */
|
|
797
|
-
function viewMachine() {
|
|
798
|
-
const m = machine(), info = state.info, ms = store.machines;
|
|
799
|
-
return `<div class="top"><div><h1>${esc(m.name)}</h1><p class="conn"><i class="dot ${state.connected ? 'on' : ''}"></i><span>${state.connected ? 'Connectat' : esc(state.lastError || 'Connectant…')}</span></p></div><div class="row"><button class="btn" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar els agents</button><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div></div>
|
|
800
|
-
<div class="grid2">
|
|
801
|
-
<div class="stack">
|
|
802
|
-
<section class="panel"><header><h2>Agents</h2></header><div class="body">${info ? info.agents.map((a) => `<div class="agent-row"><i class="dot ${a.available ? 'on' : ''}"></i><div class="grow"><div class="row"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="muted small mono">${esc(a.available ? a.version || 'Disponible' : a.login)}</div></div></div>`).join('') : '<p class="muted">Sense connexió.</p>'}</div></section>
|
|
803
|
-
${info?.roots?.length ? `<section class="panel"><header><h2>Carpetes compartides</h2></header><div class="body mono small">${info.roots.map(esc).join('<br>')}</div></section>` : ''}
|
|
804
|
-
</div>
|
|
805
|
-
<div class="stack">
|
|
806
|
-
<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>
|
|
807
|
-
${'<section class="panel"><header><h2>Continua al mòbil</h2></header><div class="body stack"><p class="small">Escaneja un codi i obre aquest ordinador des del telèfon.</p><div class="row"><button class="btn ink grow" data-act="mobile-qr" type="button">Mostra el QR</button></div></div></section>'}
|
|
808
|
-
<section class="panel"><header><h2>Detalls</h2></header><div class="body"><dl class="kv"><dt>Adreça</dt><dd class="mono">${esc(m.url)}</dd>${info ? `<dt>Sistema</dt><dd>${esc(info.platform)} · ${esc(info.user)}</dd><dt>Versió</dt><dd>${esc(info.version)}</dd>` : ''}</dl></div></section>
|
|
809
|
-
<section class="panel"><header><h2>Ordinadors</h2></header><div class="body stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><i class="dot ${i === store.active && state.connected ? 'on' : ''}"></i><span class="grow"><b>${esc(x.name)}</b><div class="muted small mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.url)}</div></span>${i === store.active ? '<span class="chip">actiu</span>' : ''}</button>`).join('')}
|
|
810
|
-
<div class="row"><button class="btn" data-act="add-machine" type="button">${svg(ICON.plus)}Connecta’n un altre</button><button class="btn danger" data-act="forget" type="button">Oblida aquest</button></div></div></section>
|
|
811
|
-
</div>
|
|
812
|
-
</div>`;
|
|
813
|
-
}
|
|
814
|
-
|
|
815
1012
|
/* ---------- Models d'Ollama */
|
|
816
1013
|
const OLLAMA_PICKS = [
|
|
817
1014
|
{ name: 'qwen3:4b', size: '2,5 GB', note: 'Ràpid i bo per a gairebé tot.' },
|
|
@@ -1030,7 +1227,7 @@ async function sendLocal() {
|
|
|
1030
1227
|
|
|
1031
1228
|
/* ---------------------------------------------------------------- accions */
|
|
1032
1229
|
document.addEventListener('click', async (e) => {
|
|
1033
|
-
const a = e.target.closest('[data-act], [data-copy], [data-decide], [data-machine], [data-lm]');
|
|
1230
|
+
const a = e.target.closest('[data-act], [data-copy], [data-decide], [data-machine], [data-forget], [data-reconnect], [data-lm]');
|
|
1034
1231
|
if (!a) return;
|
|
1035
1232
|
if (a.dataset.copy) {
|
|
1036
1233
|
try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
|
|
@@ -1042,7 +1239,9 @@ document.addEventListener('click', async (e) => {
|
|
|
1042
1239
|
return;
|
|
1043
1240
|
}
|
|
1044
1241
|
if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
|
|
1045
|
-
if (a.dataset.machine != null)
|
|
1242
|
+
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 }); }
|
|
1244
|
+
if (a.dataset.forget != null) { if (confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) forgetMachine(Number(a.dataset.forget)); return; }
|
|
1046
1245
|
if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
|
|
1047
1246
|
switch (a.dataset.act) {
|
|
1048
1247
|
case 'new': return newSessionSheet();
|
|
@@ -1058,17 +1257,14 @@ document.addEventListener('click', async (e) => {
|
|
|
1058
1257
|
case 'refresh-agents': try { state.info = await api('/api/info?refresh=1'); render(); toast('Agents actualitzats'); } catch (err) { toast(err.message); } return;
|
|
1059
1258
|
case 'push': return enablePush();
|
|
1060
1259
|
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;
|
|
1061
|
-
case 'add-
|
|
1260
|
+
case 'add-computer': return addComputerSheet();
|
|
1261
|
+
case 'open-sessions': return go('#/sessions');
|
|
1262
|
+
case 'help': go('#/ordinadors'); setTimeout(() => { const h = $('#help'); h?.scrollIntoView({ block: 'start' }); $('details', h)?.setAttribute('open', ''); }, 60); return;
|
|
1263
|
+
case 'more': return moreSheet();
|
|
1062
1264
|
case 'machines': return machinesSheet();
|
|
1063
1265
|
case 'mobile-qr': return mobileQrSheet();
|
|
1064
|
-
case 'mobile-hint-off': LS.set(
|
|
1266
|
+
case 'mobile-hint-off': { const m = machine(); LS.set(`nio.mobHint.${m?.id || m?.url}`, true); return render(); }
|
|
1065
1267
|
case 'git': return gitSheet(state.route.id);
|
|
1066
|
-
case 'forget': {
|
|
1067
|
-
if (!confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) return;
|
|
1068
|
-
const list = store.machines; list.splice(store.active, 1); store.machines = list; store.active = 0;
|
|
1069
|
-
streamCtrl?.abort(); state.connected = false; state.info = null; state.sessions.clear(); state.approvals.clear();
|
|
1070
|
-
connect(); return render();
|
|
1071
|
-
}
|
|
1072
1268
|
case 'lm-load': return loadLocal(local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id));
|
|
1073
1269
|
case 'lm-change': await local.engine?.unload?.(); local.engine = null; local.model = null; return render();
|
|
1074
1270
|
case 'lm-clear': local.messages = []; return render();
|
|
@@ -1091,7 +1287,9 @@ let palSel = 0;
|
|
|
1091
1287
|
function commands() {
|
|
1092
1288
|
const c = Object.entries(VIEWS).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
|
|
1093
1289
|
if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
|
|
1290
|
+
c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
|
|
1094
1291
|
[...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: 'Com es fa (ajuda)', hint: 'Ajuda', run: () => { go('#/ordinadors'); setTimeout(() => $('#help')?.scrollIntoView({ block: 'start' }), 60); } });
|
|
1095
1293
|
c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
|
|
1096
1294
|
return c;
|
|
1097
1295
|
}
|
|
@@ -1139,42 +1337,22 @@ $('#scrim').addEventListener('click', () => setNav(false));
|
|
|
1139
1337
|
$('#side').addEventListener('click', (e) => { if (e.target.closest('a, button')) setNav(false); });
|
|
1140
1338
|
addEventListener('keydown', (e) => { if (e.key === 'Escape' && document.body.classList.contains('nav-open')) setNav(false); });
|
|
1141
1339
|
mq.addEventListener('change', () => setNav(false));
|
|
1142
|
-
(() => {
|
|
1143
|
-
const side = $('#side'); let x0 = null, y0 = 0, dx = 0, mode = null;
|
|
1144
|
-
addEventListener('touchstart', (e) => {
|
|
1145
|
-
if (!mq.matches || $('#sheet').open || state.route.name === 'pissarra') return;
|
|
1146
|
-
const t = e.touches[0], open = document.body.classList.contains('nav-open');
|
|
1147
|
-
if (open || t.clientX < 22) { x0 = t.clientX; y0 = t.clientY; dx = 0; mode = open ? 'close' : 'open'; }
|
|
1148
|
-
}, { passive: true });
|
|
1149
|
-
addEventListener('touchmove', (e) => {
|
|
1150
|
-
if (x0 == null) return;
|
|
1151
|
-
const t = e.touches[0]; dx = t.clientX - x0;
|
|
1152
|
-
if (Math.abs(t.clientY - y0) > Math.abs(dx) && Math.abs(dx) < 12) { x0 = null; return; }
|
|
1153
|
-
const w = side.offsetWidth, pos = mode === 'open' ? Math.min(0, -w + Math.max(0, dx)) : Math.min(0, dx);
|
|
1154
|
-
side.classList.add('dragging'); side.style.transform = `translateX(${pos}px)`;
|
|
1155
|
-
}, { passive: true });
|
|
1156
|
-
addEventListener('touchend', () => {
|
|
1157
|
-
if (x0 == null) return;
|
|
1158
|
-
side.classList.remove('dragging'); side.style.transform = '';
|
|
1159
|
-
if (mode === 'open' && dx > 60) setNav(true); else if (mode === 'close' && dx < -60) setNav(false);
|
|
1160
|
-
x0 = null;
|
|
1161
|
-
});
|
|
1162
|
-
})();
|
|
1163
|
-
|
|
1164
1340
|
/* ---------------------------------------------------------------- tutorial */
|
|
1165
1341
|
const TOUR_KEY = 'nio.tour.v2';
|
|
1166
1342
|
const T = Object.assign({ on: false, i: 0, seen: false }, LS.get(TOUR_KEY, {}));
|
|
1167
1343
|
const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
|
|
1168
1344
|
const TSTEPS = [
|
|
1169
|
-
{ center: true, mood: 'happy', title: 'Hola, sóc en Nio.', text: 'Faig de pont entre el teu mòbil i els agents d’IA del teu ordinador. En un minut t’ensenyo
|
|
1170
|
-
{ route: '
|
|
1171
|
-
{ route: '
|
|
1172
|
-
{ route: '
|
|
1173
|
-
{ route: '
|
|
1345
|
+
{ center: true, mood: 'happy', title: 'Hola, sóc en Nio.', text: 'Faig de pont entre el teu mòbil i els agents d’IA (Claude Code, Codex…) del teu ordinador o d’un servidor. En un minut t’ensenyo com es fa.', next: 'Comencem' },
|
|
1346
|
+
{ route: 'ordinadors', target: '#addComputer', m: '#addComputer', title: '1. Afegeix un ordinador', text: 'Hi ha tres maneres: aquest ordinador (<span class="mono">npx nio-cat</span>), un servidor per SSH (<span class="mono">usuari@servidor</span>) o un codi. nio fa la resta.' },
|
|
1347
|
+
{ 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
|
+
{ 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
|
+
{ 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.' },
|
|
1351
|
+
{ 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.' },
|
|
1174
1352
|
{ 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.' },
|
|
1175
|
-
{ route: '
|
|
1176
|
-
{ target: '[data-act=palette]', m: '#
|
|
1177
|
-
{ center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial al
|
|
1353
|
+
{ route: 'ordinadors', target: '#help', m: '#help', title: 'Si alguna cosa no va', text: 'A «Com es fa» tens cada pas explicat, què cal al servidor i què mirar si no connecta.' },
|
|
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 els models, el xat local, la pissarra, l’ajuda i aquest tutorial.' : 'Prem ⌘K (o Ctrl+K) per anar a qualsevol lloc o obrir una sessió sense tocar el ratolí.') },
|
|
1355
|
+
{ center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial al menú i l’ajuda a Ordinadors.', next: 'Finalitza' }
|
|
1178
1356
|
];
|
|
1179
1357
|
const tval = (v) => (typeof v === 'function' ? v() : v);
|
|
1180
1358
|
function tourStart() { T.on = true; T.seen = true; tourGo(0); }
|