nio-cat 0.9.5 → 0.9.7
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/README.md +3 -2
- package/bin/nio.js +8 -13
- package/package.json +1 -1
- package/src/agents/claude.js +4 -1
- package/src/agents/ollama.js +3 -2
- package/src/hub.js +2 -0
- package/src/notes.js +293 -0
- package/src/pair.js +3 -50
- package/src/server.js +19 -2
- package/src/usage.js +79 -0
- package/src/voice.js +21 -3
- package/src/whisper.js +148 -0
- package/web/app/app.js +256 -53
- package/web/app/demo.js +413 -0
- package/web/app/index.html +4 -2
- package/web/app/notes.css +154 -0
- package/web/app/notes.js +504 -0
- package/web/app/style.css +32 -0
- package/web/index.html +8 -3
- package/web/sw.js +2 -2
package/web/app/app.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// nio · app. Parla directament amb el programa nio del teu ordinador (fetch + flux d'esdeveniments).
|
|
2
2
|
// Sense frameworks ni compilació. També inclou un ordinador de prova que viu dins del navegador.
|
|
3
3
|
|
|
4
|
+
import { createNotes } from './notes.js';
|
|
5
|
+
|
|
4
6
|
const $ = (s, e = document) => e.querySelector(s);
|
|
5
7
|
const $$ = (s, e = document) => [...e.querySelectorAll(s)];
|
|
6
8
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
@@ -16,7 +18,7 @@ const CHIP_ICON = {
|
|
|
16
18
|
mode: '<path d="M12 3l7 3v5c0 4.5-3 8-7 10-4-2-7-5.5-7-10V6z"/>',
|
|
17
19
|
folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
|
18
20
|
model: '<rect x="6" y="6" width="12" height="12" rx="2.5"/><path d="M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3"/>',
|
|
19
|
-
|
|
21
|
+
usage: '<path d="M4 16a8 8 0 1 1 16 0"/><path d="M12 16l4-5"/>',
|
|
20
22
|
};
|
|
21
23
|
const svg = (d) => `<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
|
|
22
24
|
const ICON = {
|
|
@@ -24,6 +26,7 @@ const ICON = {
|
|
|
24
26
|
jobs: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 9h8M8 13h5M8 17h3"/>',
|
|
25
27
|
screen: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M9 20h6M12 16v4"/><circle cx="12" cy="10" r="2"/>',
|
|
26
28
|
panic: '<circle cx="12" cy="12" r="9"/><path d="M8.5 8.5l7 7M15.5 8.5l-7 7"/>',
|
|
29
|
+
notes: '<path d="M6 4h12v16H6z"/><path d="M9 9h6M9 13h6M9 17h3"/>',
|
|
27
30
|
memory: '<path d="M6 4h9l3 3v13H6z"/><path d="M9 11h6M9 15h6M14 4v4h4"/>',
|
|
28
31
|
sessions: '<path d="M4 6h16M4 12h16M4 18h10"/>',
|
|
29
32
|
approvals: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
|
@@ -70,6 +73,64 @@ const store = {
|
|
|
70
73
|
};
|
|
71
74
|
const machine = () => store.machines[store.active] || store.machines[0] || null;
|
|
72
75
|
|
|
76
|
+
/* ---------------------------------------------------------------- cua de tasques
|
|
77
|
+
Les tasques es desen al mòbil i s'envien soles quan l'ordinador es connecta. */
|
|
78
|
+
const mkey = (m) => m?.id || m?.url;
|
|
79
|
+
const queue = {
|
|
80
|
+
get all() { return LS.get('nio.queue.v1', []); },
|
|
81
|
+
set all(v) { LS.set('nio.queue.v1', v); },
|
|
82
|
+
mine() { const k = mkey(machine()); return this.all.filter((t) => t.mid === k); },
|
|
83
|
+
add(t) { const m = machine(); this.all = [...this.all, { id: Math.random().toString(36).slice(2, 10), mid: mkey(m), at: Date.now(), ...t }]; },
|
|
84
|
+
drop(id) { this.all = this.all.filter((t) => t.id !== id); }
|
|
85
|
+
};
|
|
86
|
+
let flushing = false;
|
|
87
|
+
async function flushQueue() {
|
|
88
|
+
if (flushing || !state.connected) return;
|
|
89
|
+
const todo = queue.mine(); if (!todo.length) return;
|
|
90
|
+
flushing = true;
|
|
91
|
+
let sent = 0;
|
|
92
|
+
try {
|
|
93
|
+
for (const t of todo) {
|
|
94
|
+
if (!state.connected) break;
|
|
95
|
+
try {
|
|
96
|
+
const { id, mid, at, ...body } = t;
|
|
97
|
+
const s = await api('/api/sessions', { method: 'POST', body });
|
|
98
|
+
state.sessions.set(s.id, s); queue.drop(id); sent++;
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (e.name === 'TypeError') break; // l'ordinador s'ha apagat un altre cop: es manté a la cua
|
|
101
|
+
queue.drop(t.id); toast(`No s’ha pogut enviar «${t.title || t.text.slice(0, 30)}»: ${e.message}`, 5000); // error de veritat (p. ex. carpeta inexistent)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} finally { flushing = false; }
|
|
105
|
+
if (sent) { toast(sent === 1 ? 'S’ha enviat 1 tasca de la cua' : `S’han enviat ${sent} tasques de la cua`, 4200); render(); }
|
|
106
|
+
}
|
|
107
|
+
function queueSheet() {
|
|
108
|
+
if (!machine()) return go('#/sessions');
|
|
109
|
+
const m = machine(), last = LS.get(`nio.last.${mkey(m)}`, {});
|
|
110
|
+
const agents = Object.entries(AGENT_NAMES).filter(([k]) => !['demo', 'browser', 'ollama'].includes(k));
|
|
111
|
+
const sel = { agent: last.agent && AGENT_NAMES[last.agent] ? last.agent : 'claude', cwd: last.cwd || '', mode: last.mode || 'preguntar' };
|
|
112
|
+
openSheet(`<h2>Tasca per a quan l’ordinador estigui engegat</h2>
|
|
113
|
+
<p class="muted small">Es desa en aquest dispositiu i s’envia sola quan nio es connecti amb ${esc(m.name)}.</p>
|
|
114
|
+
<label class="f"><span>Agent</span><select id="qAgent">${agents.map(([k, n]) => `<option value="${k}" ${k === sel.agent ? 'selected' : ''}>${esc(n)}</option>`).join('')}</select></label>
|
|
115
|
+
<label class="f"><span>Carpeta de l’ordinador</span><input type="text" id="qCwd" class="mono" value="${esc(sel.cwd)}" placeholder="/Users/…/projecte" autocapitalize="off" spellcheck="false"></label>
|
|
116
|
+
<label class="f"><span>Permisos</span><select id="qMode">${Object.entries(MODES).map(([k, x]) => `<option value="${k}" ${k === sel.mode ? 'selected' : ''}>${esc(x.label)}</option>`).join('')}</select></label>
|
|
117
|
+
<label class="f"><span>Què ha de fer?</span><textarea class="field" id="qText" rows="5" placeholder="Per exemple: arregla els tests que fallen"></textarea></label>
|
|
118
|
+
<div class="row" style="justify-content:flex-end"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" type="button" id="qSave">Posa-la a la cua</button></div>`, (sh) => {
|
|
119
|
+
sh.querySelector('[data-close]').onclick = closeSheet;
|
|
120
|
+
$('#qSave', sh).onclick = () => {
|
|
121
|
+
const text = $('#qText', sh).value.trim(), cwd = $('#qCwd', sh).value.trim();
|
|
122
|
+
if (!cwd) return toast('Escriu la carpeta de l’ordinador');
|
|
123
|
+
if (!text) return toast('Explica a l’agent què ha de fer');
|
|
124
|
+
queue.add({ agent: $('#qAgent', sh).value, cwd, mode: $('#qMode', sh).value, text, title: text.slice(0, 60) });
|
|
125
|
+
closeSheet(); toast('A la cua. S’enviarà quan l’ordinador estigui connectat.', 4200); render();
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function queueList() {
|
|
130
|
+
const q = queue.mine(); if (!q.length) return '';
|
|
131
|
+
return `<h2 class="sec">A la cua (${q.length})</h2><div class="list">${q.map((t) => `<div class="card"><div class="row between"><span class="agent-tag" data-agent="${esc(t.agent)}">${esc(AGENT_NAMES[t.agent] || t.agent)}</span><button class="btn ghost sm" type="button" data-qdrop="${esc(t.id)}">Treu-la</button></div><div class="title">${esc(t.title)}</div><div class="meta"><span class="mono">${esc(basename(t.cwd))}</span><span>·</span><span>${state.connected ? 'enviant…' : 'espera l’ordinador'}</span><span>·</span><span>${ago(t.at)}</span></div></div>`).join('')}</div>`;
|
|
132
|
+
}
|
|
133
|
+
|
|
73
134
|
/* ---------------------------------------------------------------- utilitats */
|
|
74
135
|
function toast(msg, ms = 2800) {
|
|
75
136
|
const box = $('#toasts'), t = document.createElement('div');
|
|
@@ -236,7 +297,7 @@ async function connect() {
|
|
|
236
297
|
if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
|
|
237
298
|
state.connected = true; state.lastError = null; retry = 0;
|
|
238
299
|
rememberIdentity(m.url, state.info.identity);
|
|
239
|
-
renameMachine(state.info.host, state.info.id); syncServiceWorker(); render(); brainsForBoard(); loadVoice().then(() => state.route.name === 'session' && renderComposer?.());
|
|
300
|
+
renameMachine(state.info.host, state.info.id); syncServiceWorker(); render(); brainsForBoard(); loadVoice().then(() => state.route.name === 'session' && renderComposer?.()); flushQueue();
|
|
240
301
|
const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '';
|
|
241
302
|
// Alguns túnels (Cloudflare des d'alguns servidors) no deixen passar el flux en directe: sense «hello» en 5 s, es passa a consultar cada 2 s.
|
|
242
303
|
gotHello = false; let poll = false;
|
|
@@ -309,7 +370,7 @@ function handle(msg) {
|
|
|
309
370
|
case 'hello':
|
|
310
371
|
gotHello = true; state.synced = true;
|
|
311
372
|
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
312
|
-
state.approvals = new Map(msg.approvals.map((a) => [a.id, a]));
|
|
373
|
+
state.approvals = new Map(msg.approvals.map((a) => [a.id, a])); state.usage = msg.usage || state.usage; notesCtl?.reload();
|
|
313
374
|
state.loaded.clear(); state.events.clear();
|
|
314
375
|
if (r.name === 'session') openSession(r.id); else render();
|
|
315
376
|
break;
|
|
@@ -325,6 +386,7 @@ function handle(msg) {
|
|
|
325
386
|
break;
|
|
326
387
|
case 'event': {
|
|
327
388
|
const evs = state.events.get(msg.session);
|
|
389
|
+
if (msg.event.kind === 'result') api('/api/usage').then((u) => { state.usage = u; if (state.route.name === 'session') renderSessionHead(); }).catch(() => {});
|
|
328
390
|
if (evs) { const was = evs.get(msg.event.seq); evs.set(msg.event.seq, msg.event); if (r.name === 'session' && r.id === msg.session) { putEvent(msg.event); if (msg.event.kind === 'text' && !msg.event.open && (!was || was.open)) autoSpeak(msg.event); } }
|
|
329
391
|
break;
|
|
330
392
|
}
|
|
@@ -333,6 +395,8 @@ function handle(msg) {
|
|
|
333
395
|
if (e) { e.text += msg.text; if (r.name === 'session' && r.id === msg.session) streamText(msg.session, e); }
|
|
334
396
|
break;
|
|
335
397
|
}
|
|
398
|
+
case 'usage': state.usage = msg.usage; if (r.name === 'session') renderSessionHead(); break;
|
|
399
|
+
case 'note': case 'note-gone': notesCtl?.onEvent(msg); break;
|
|
336
400
|
case 'ollama': onOllama(msg); break;
|
|
337
401
|
case 'job': onJob(msg); break;
|
|
338
402
|
case 'screen': onScreen(msg); break;
|
|
@@ -373,8 +437,9 @@ const pairFrom = (text) => parsePair(text) || parseCode(text);
|
|
|
373
437
|
// Cap pantalla ensenya adreces: cada ordinador és un nom, una icona i un estat.
|
|
374
438
|
const hostOf = (u) => { try { return new URL(u).hostname; } catch { return ''; } };
|
|
375
439
|
const isLoop = (u) => ['127.0.0.1', 'localhost', '[::1]'].includes(hostOf(u));
|
|
440
|
+
const isDemo = (m = machine()) => !!window.NioDemo?.isDemo(m);
|
|
376
441
|
const machineIcon = (m) => (m.kind === 'vps' ? ICON.server : ICON.machine);
|
|
377
|
-
const machineSub = (m) => (m.kind === 'vps' ? `Servidor${m.via ? ` · ${m.via}` : ''}` : isLoop(m.url) ? 'Aquest ordinador' : 'Ordinador');
|
|
442
|
+
const machineSub = (m) => (isDemo(m) ? 'Simulat dins del navegador' : m.kind === 'vps' ? `Servidor${m.via ? ` · ${m.via}` : ''}` : isLoop(m.url) ? 'Aquest ordinador' : 'Ordinador');
|
|
378
443
|
function addMachine({ url, token, name, kind, via }) {
|
|
379
444
|
const ms = store.machines; let i = ms.findIndex((m) => m.url === url);
|
|
380
445
|
if (i >= 0) { ms[i].token = token; if (kind) ms[i].kind = kind; if (via) ms[i].via = via; }
|
|
@@ -383,6 +448,16 @@ function addMachine({ url, token, name, kind, via }) {
|
|
|
383
448
|
state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.info = null; state.ollama = null;
|
|
384
449
|
connect();
|
|
385
450
|
}
|
|
451
|
+
// Demo: un ordinador simulat (app/demo.js) per provar-ho tot sense instal·lar res.
|
|
452
|
+
function startDemo() {
|
|
453
|
+
closeSheet();
|
|
454
|
+
addMachine({ url: window.NioDemo.url, token: 'demo', name: 'Ordinador de demo', kind: 'demo' });
|
|
455
|
+
go('#/sessions');
|
|
456
|
+
}
|
|
457
|
+
function exitDemo() {
|
|
458
|
+
const i = store.machines.findIndex((m) => isDemo(m));
|
|
459
|
+
if (i >= 0) forgetMachine(i);
|
|
460
|
+
}
|
|
386
461
|
// El codi o l'enllaç porta un secret d'un sol ús: el canviem per una clau pròpia d'aquest dispositiu,
|
|
387
462
|
// que es pot revocar des d'Ordinadors. Si és la clau principal (el panell que obre nio) o un nio antic
|
|
388
463
|
// que encara no fa aparellament, el valor ja és una clau i el fem servir tal qual.
|
|
@@ -436,6 +511,7 @@ function checkPairHash() {
|
|
|
436
511
|
const VIEWS = {
|
|
437
512
|
sessions: { label: 'Sessions', icon: ICON.sessions, group: 'Feina' },
|
|
438
513
|
approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
|
|
514
|
+
notes: { label: 'Notes', icon: ICON.notes, group: 'Feina' },
|
|
439
515
|
feines: { label: 'Feines', icon: ICON.jobs, group: 'Feina' },
|
|
440
516
|
memoria: { label: 'Memòria', icon: ICON.memory, group: 'Ordinadors' },
|
|
441
517
|
ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
|
|
@@ -445,15 +521,15 @@ const VIEWS = {
|
|
|
445
521
|
ajustos: { label: 'Ajustos', icon: ICON.settings, group: 'Compte' },
|
|
446
522
|
ajuda: { label: 'Ajuda', icon: ICON.help, group: 'Compte' }
|
|
447
523
|
};
|
|
448
|
-
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria', ordinadors: '#/ordinadors', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
|
|
449
|
-
const NEEDS_MACHINE = new Set(['
|
|
524
|
+
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', notes: '#/notes', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria', ordinadors: '#/ordinadors', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
|
|
525
|
+
const NEEDS_MACHINE = new Set(['session']);
|
|
450
526
|
function parseRoute() {
|
|
451
527
|
const h = location.hash.replace(/^#\/?/, '');
|
|
452
528
|
if (h.startsWith('s/')) return { name: 'session', id: h.slice(2) };
|
|
453
529
|
if (h === 'ordinador' || h === 'pantalla') return { name: 'ordinadors' }; // adreces antigues
|
|
454
530
|
if (h === 'models') return { name: 'mobil' };
|
|
455
531
|
if (h === 'mobil') return { name: 'mobil' };
|
|
456
|
-
const k = Object.keys(HASH).find((k) => HASH[k] === '#/' + h);
|
|
532
|
+
const k = Object.keys(HASH).find((k) => HASH[k] === '#/' + h.split('?')[0]);
|
|
457
533
|
return { name: k || 'sessions' };
|
|
458
534
|
}
|
|
459
535
|
function go(hash) { if (location.hash === hash) route(); else location.hash = hash; }
|
|
@@ -463,14 +539,14 @@ function route() {
|
|
|
463
539
|
state.route = parseRoute();
|
|
464
540
|
const canon = state.route.name === 'session' ? null : HASH[state.route.name];
|
|
465
541
|
if (canon && location.hash && location.hash !== canon && /^#\/(pantalla|models|ordinador)$/.test(location.hash)) history.replaceState(null, '', canon);
|
|
466
|
-
// Sense cap ordinador,
|
|
467
|
-
if (!machine() && NEEDS_MACHINE.has(state.route.name)) { history.replaceState(null, '', '#/
|
|
542
|
+
// Sense cap ordinador, cada pàgina es veu igual i explica què hi faltaria; només una sessió concreta no té sentit.
|
|
543
|
+
if (!machine() && NEEDS_MACHINE.has(state.route.name)) { history.replaceState(null, '', '#/sessions'); state.route = { name: 'sessions' }; }
|
|
468
544
|
if (state.route.name === 'session') openSession(state.route.id); else render();
|
|
469
545
|
}
|
|
470
546
|
|
|
471
547
|
/* ---------------------------------------------------------------- pintat general */
|
|
472
548
|
function renderChrome() {
|
|
473
|
-
renderPanic();
|
|
549
|
+
renderPanic(); renderDemoBar();
|
|
474
550
|
const r = state.route, n = state.approvals.size, active = r.name === 'session' ? 'sessions' : r.name;
|
|
475
551
|
let prev = '';
|
|
476
552
|
$('#nav').innerHTML = Object.entries(VIEWS).map(([k, v], i) => {
|
|
@@ -481,7 +557,7 @@ function renderChrome() {
|
|
|
481
557
|
const m = machine(), sc = $('#sideConn');
|
|
482
558
|
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)}`;
|
|
483
559
|
sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
|
|
484
|
-
const more = ['feines', 'memoria', 'approvals', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
|
|
560
|
+
const more = ['notes', 'feines', 'memoria', 'approvals', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
|
|
485
561
|
$('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
|
|
486
562
|
.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('')
|
|
487
563
|
.replace('</a><a href="#/ordinadors"', `</a><button type="button" data-act="pantalla-tab">${svg('<path d="M5 3l14 7-6 2.2L10.8 19z"/>')}<span>Pantalla</span></button><a href="#/ordinadors"`)
|
|
@@ -495,6 +571,8 @@ function renderChrome() {
|
|
|
495
571
|
document.title = n ? `(${n}) nio` : `nio // ${s ? s.title || 'Sessió' : VIEWS[r.name]?.label || 'Panell'}`;
|
|
496
572
|
if ('setAppBadge' in navigator) (n ? navigator.setAppBadge(n) : navigator.clearAppBadge()).catch?.(() => {});
|
|
497
573
|
}
|
|
574
|
+
let notesCtl = null;
|
|
575
|
+
const getNotes = () => (notesCtl ||= createNotes({ api, nodeAudio, toast, esc, machine, voiceOn, voiceSheet, isDemo, openSheet, closeSheet, mobileQrSheet }));
|
|
498
576
|
function render() {
|
|
499
577
|
const r = state.route, view = $('#view'), board = r.name === 'pissarra';
|
|
500
578
|
if (r.name !== 'sessions') manage = null;
|
|
@@ -502,11 +580,17 @@ function render() {
|
|
|
502
580
|
document.body.classList.toggle('board-mode', board);
|
|
503
581
|
document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && inLocalChat()));
|
|
504
582
|
document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' && inLocalChat()));
|
|
583
|
+
if (r.name !== 'notes' && lastName === 'notes') notesCtl?.unmount();
|
|
505
584
|
if (!board && lastName === 'pissarra') window.NioBoard?.unmount();
|
|
506
585
|
if (board && lastName === 'pissarra' && $('#boardRoot')) return;
|
|
586
|
+
if (r.name === 'notes' && lastName === 'notes' && $('#notesRoot') && machine()) return; // l'editor no es toca quan l'app es repinta
|
|
507
587
|
const changed = lastName !== r.name || r.name === 'session';
|
|
508
588
|
lastName = r.name;
|
|
509
589
|
if (r.name === 'session') renderSession();
|
|
590
|
+
else if (r.name === 'notes') {
|
|
591
|
+
view.innerHTML = machine() ? '<div class="page" id="notesRoot"></div>' : `<div class="page"><div class="top"><div><h1>Notes</h1><p>Escriu, grava i deixa que un model ho ordeni.</p></div></div>${noMachine('Les notes es desen a l’ordinador. Connecta’n un per escriure’n i gravar-ne.')}</div>`;
|
|
592
|
+
if (machine()) getNotes().mount($('#notesRoot'));
|
|
593
|
+
}
|
|
510
594
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
511
595
|
else {
|
|
512
596
|
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
@@ -528,6 +612,7 @@ function render() {
|
|
|
528
612
|
/* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
|
|
529
613
|
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; };
|
|
530
614
|
const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
|
|
615
|
+
const INSTALL_CMD = 'curl -fsSL https://www.nio.cat/install.sh | sh';
|
|
531
616
|
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>`;
|
|
532
617
|
// Estat de la resta d'ordinadors (el connectat ja té el seu flux en directe): una consulta lleugera.
|
|
533
618
|
const reach = new Map();
|
|
@@ -550,7 +635,7 @@ function computerCard(m, i) {
|
|
|
550
635
|
: pr == null ? '' : pr ? '<span class="chip ok"><i class="dot on"></i>En línia</span>' : '<span class="chip"><i class="dot"></i>Fora de línia</span>';
|
|
551
636
|
return `<div class="card computer ${on ? 'active' : ''}">
|
|
552
637
|
<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>
|
|
553
|
-
<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>` : ''}
|
|
638
|
+
<div class="row c-actions">${on ? `<button class="btn ink sm" data-act="open-sessions" type="button">Sessions</button>${isPhone() || isDemo(m) ? '' : `<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>` : ''}${isDemo(m) ? `<button class="btn ghost sm" data-act="demo-exit" type="button">Surt de la demo</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>
|
|
554
639
|
</div>`;
|
|
555
640
|
}
|
|
556
641
|
// Ajuda sempre a mà: com es fa cada cosa, i què mirar si no connecta.
|
|
@@ -585,7 +670,7 @@ function helpPanel(open) {
|
|
|
585
670
|
])}<p>Exemple: ${k('/mode p')} passa la sessió a «Demana permís».</p>`)}
|
|
586
671
|
${q('Git i GitHub', `<p>Dins una sessió, ${k('/git')} (o Opcions → Git i GitHub) mostra els canvis, fa <b>commit</b> (en una branca nova si ets a ${k('main')}) i crea un <b>PR</b>. Cap clau no passa per nio: fa servir el ${k('git')} i el ${k('gh')} de l’ordinador.</p>`)}
|
|
587
672
|
${q('Avisos al mòbil', `<p>A <b>Ajustos → Avisos</b>, prem <b>Activa els avisos</b>. Rebràs una notificació quan un agent et demana permís o acaba la feina. L’avís arriba buit; el detall el demana el mòbil directament a l’ordinador.</p><p>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>`)}
|
|
588
|
-
${q('Veu', `<p>A <b>Ajustos → Veu</b>
|
|
673
|
+
${q('Veu', `<p>A <b>Ajustos → Veu</b> actives <b>Whisper</b> per <b>dictar</b> els missatges amb el micròfon, gratis i sense que l’àudio surti de l’ordinador. Per <b>escoltar</b> les respostes, hi pots posar una clau d’ElevenLabs o OpenAI, que es queda a l’ordinador.</p>`)}
|
|
589
674
|
|
|
590
675
|
${grp('Eines')}
|
|
591
676
|
${q('Pantalla remota', `<p>Mira i controla l’escriptori d’un ordinador des del mòbil. A <b>Ordinadors</b>, baixa fins a la secció <b>Pantalla</b> i prem <b>Obre la pantalla</b>. S’obre a pantalla completa. Cal activar abans <b>Apps de l’escriptori</b> a la mateixa secció.</p><ul><li><b>Mirar</b>: només veus la pantalla, en directe. Amb un dit la mous quan has fet zoom.</li><li><b>Controlar</b>: toc = clic; doble toc = doble clic; mantenir premut = clic dret; dos dits = desplaçar o fer zoom. El botó <b>1 dit</b> canvia entre desplaçar i arrossegar, i el botó <b>2 dits</b> entre desplaçar l’ordinador i fer zoom amb pessics. <b>Teclat</b> obre el teclat del mòbil, i dalt hi tens ${k('⌘')} ${k('⌥')} ${k('⌃')} ${k('⇧')}, ${k('Esc')}, ${k('Tab')} i fletxes.</li><li><b>Zoom amb dos dits</b>: pessiga per apropar i arrossega amb dos dits per moure’t. Al mode Mirar sempre va així, amb doble toc per alternar entre enquadrar i apropar. Al mode Controlar, tria al botó <b>2 dits</b> si els dos dits desplacen l’ordinador o fan zoom. A l’ordinador, pessiga al trackpad o ${k('Ctrl')} + roda. Els botons <b>− 100% +</b> i <b>1:1</b> fan el mateix i funcionen en tots dos modes.</li><li><b>Desa captura</b>: baixa el fotograma actual com a imatge. <b>Captura</b> (a la secció) en fa una de sola, sense obrir la pantalla.</li><li><b>Si surt negra</b>: al Mac, dona el permís de <b>Gravació de pantalla</b> (i <b>Accessibilitat</b> per controlar) al Terminal on corre <span class="mono">nio-cat</span> i reinicia’l. A Linux, per controlar cal <span class="mono">xdotool</span>.</li></ul>`)}
|
|
@@ -629,24 +714,38 @@ async function disablePush() {
|
|
|
629
714
|
try { const sub = await (await navigator.serviceWorker.ready).pushManager.getSubscription(); await sub?.unsubscribe(); } catch { /* res */ }
|
|
630
715
|
pushOn = false; toast('Avisos desactivats en aquest dispositiu'); render();
|
|
631
716
|
}
|
|
717
|
+
// Sense cap ordinador: el mateix avís a cada pàgina, amb què s'hi podria fer i on connectar-ne un.
|
|
718
|
+
function noMachine(text) {
|
|
719
|
+
return `<div class="panel empty">${bot('sleep')}<h2>No tens cap ordinador connectat</h2><p>${text}</p><div class="row" style="justify-content:center"><button class="btn ink" data-act="add-computer" type="button">${svg(ICON.plus)}Connecta un ordinador</button><button class="btn" data-act="demo" type="button">Prova una demo</button></div></div>`;
|
|
720
|
+
}
|
|
632
721
|
function viewSettings() {
|
|
633
|
-
const head = '<div class="top"><div><h1>Ajustos</h1><p>Avisos al mòbil i
|
|
722
|
+
const head = '<div class="top"><div><h1>Ajustos</h1><p>Avisos al mòbil, veu i Telegram. Es configuren per a cada ordinador.</p></div></div>';
|
|
723
|
+
if (!machine()) return `${head}<div class="stack-lg" style="max-width:640px">${settingsOff()}</div>`;
|
|
634
724
|
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>' : ''}${pushOn ? '<p class="small"><span class="chip ok">' + svg(ICON.check) + 'Avisos activats en aquest dispositiu</span></p>' : ''}<div class="row">${pushOn ? '<button class="btn ink grow" data-act="push-test" type="button">Envia’n un de prova</button><button class="btn" data-act="push-off" type="button">Desactiva</button>' : '<button class="btn ink grow" data-act="push" type="button">Activa els avisos</button>'}</div></div></section>` : '';
|
|
635
725
|
if (machine() && pushOn === null) refreshPush();
|
|
636
726
|
if (machine() && tgState === undefined) loadTelegram();
|
|
637
727
|
return `${head}<div class="stack-lg" style="max-width:640px">${avisos}${voicePanel()}<div id="tgPanel">${telegramPanel()}</div></div>`;
|
|
638
728
|
}
|
|
729
|
+
// Ajustos sense ordinador: un sol avís a dalt i les seccions de mostra, desactivades.
|
|
730
|
+
function settingsOff() {
|
|
731
|
+
const sec = (t, d, b) => `<section class="panel off" aria-disabled="true"><header><h2>${t}</h2></header><div class="body stack"><p class="small">${d}</p><div class="row"><button class="btn" type="button" disabled>${b}</button></div></div></section>`;
|
|
732
|
+
return `<div class="card off-note"><div class="grow"><b>No tens cap ordinador connectat</b><div class="muted small">Aquests ajustos es desen a l’ordinador. Connecta’n un per activar-los.</div></div><button class="btn ink sm" data-act="add-computer" type="button">Connecta un ordinador</button></div>
|
|
733
|
+
${sec('Avisos al mòbil', 'Rep un avís quan un agent et demana permís o acaba la feina.', 'Activa els avisos')}
|
|
734
|
+
${sec('Veu', 'Dicta els missatges amb el micròfon, gratis amb Whisper, i escolta les respostes en veu alta.', 'Configura la veu')}
|
|
735
|
+
${sec('Telegram', 'Parla amb els agents des del teu bot de Telegram: instruccions, notes de veu, avisos i aprovacions amb botons.', 'Connecta el bot')}`;
|
|
736
|
+
}
|
|
639
737
|
function viewHelp() {
|
|
640
738
|
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>`;
|
|
641
739
|
}
|
|
642
740
|
function viewComputers() {
|
|
643
741
|
const ms = store.machines, m = machine(), info = state.info;
|
|
644
|
-
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
|
|
742
|
+
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>${ms.length ? `<button class="btn ink" id="addComputer" data-act="add-computer" type="button">${svg(ICON.plus)}Afegeix ordinador</button>` : ''}</div>`;
|
|
645
743
|
if (!ms.length) {
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
744
|
+
const phone = isPhone();
|
|
745
|
+
const steps = phone
|
|
746
|
+
? [['Enganxa-la a l’ordinador', 'Al Terminal. Instal·la nio i el deixa engegat, també quan reinicies.'], ['Escaneja el QR', 'Surt al navegador de l’ordinador quan acaba. També hi ha un codi.'], ['Dona feina a un agent', 'Tria l’agent i la carpeta, i mira com treballa des d’aquí.']]
|
|
747
|
+
: [['Obre el Terminal', 'Al Mac, ⌘ Espai i escriu «Terminal». A Linux, Ctrl Alt T.'], ['Enganxa l’ordre', 'Instal·la nio i el deixa engegat, també quan reinicies.'], ['Escaneja el QR', 'Aquesta web es connecta sola i et mostra el QR del mòbil.']];
|
|
748
|
+
return `${head}<div class="panel empty first-run">${bot('idle')}<h2>Connecta el teu primer ordinador</h2><p>${phone ? 'nio s’instal·la a l’ordinador amb una sola ordre. Copia-la i enganxa-la allà.' : 'Una sola ordre instal·la nio en aquest ordinador i ho deixa tot a punt.'}</p>${cmdBox(INSTALL_CMD)}<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><div class="row" style="justify-content:center"><button class="btn ink" data-act="add-computer" type="button">${svg(ICON.plus)}${phone ? 'Tinc un codi o un QR' : 'Afegeix ordinador'}</button><button class="btn" data-act="demo" type="button">Prova una demo</button></div><p class="muted small">Mac o Linux. Per provar-lo sense instal·lar res, <span class="mono">npx nio-cat</span>.</p></div>`;
|
|
650
749
|
}
|
|
651
750
|
const detail = m && state.connected ? `<h2 class="sec">${esc(m.name)}</h2>
|
|
652
751
|
<div class="grid2 one">
|
|
@@ -720,8 +819,8 @@ function addComputerSheet(tab, opts = {}) {
|
|
|
720
819
|
const d = await probeLocal(); if (cur !== 'local') return;
|
|
721
820
|
const saved = d && store.machines.findIndex((x) => x.id === d.id);
|
|
722
821
|
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>`);
|
|
723
|
-
else if (d) set('found', `${intro('nio funciona en aquest ordinador', 'Aquest navegador encara no hi té accés.
|
|
724
|
-
else set('none', `${intro('
|
|
822
|
+
else if (d) set('found', `${intro('nio ja funciona en aquest ordinador', 'Aquest navegador encara no hi té accés. Escriu això al Terminal i s’obrirà el panell ja connectat:')}${cmdBox('nio pair')}`);
|
|
823
|
+
else set('none', `${intro('Instal·la nio en aquest ordinador', 'Una sola ordre ho deixa tot a punt i engegat, també quan reinicies.')}${todo(['Obre el Terminal. Al Mac, prem ⌘ Espai i escriu «Terminal».', `Enganxa aquesta ordre i prem Retorn:${cmdBox(INSTALL_CMD)}`, 'Quan acabi, aquesta pàgina es connectarà sola.'])}${live('Esperant nio…')}<p class="hint">Mac o Linux. Si ja el tens i està aturat, escriu <span class="mono">nio</span> al Terminal.</p>${store.machines.length ? '' : '<div class="or"><span>o</span></div><button class="btn block" type="button" data-close data-act="demo">Prova una demo, sense instal·lar res</button>'}`);
|
|
725
824
|
if (saved < 0 || !d) poll = setTimeout(local, 3000);
|
|
726
825
|
};
|
|
727
826
|
const runSsh = async (target) => {
|
|
@@ -749,7 +848,7 @@ function addComputerSheet(tab, opts = {}) {
|
|
|
749
848
|
<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>`);
|
|
750
849
|
};
|
|
751
850
|
const code = () => {
|
|
752
|
-
set('code', `${intro(phone ? 'Escaneja o escriu el codi' : 'Escriu el codi', 'Surt al
|
|
851
|
+
set('code', `${intro(phone ? 'Escaneja o escriu el codi' : 'Escriu el codi', 'Surt al navegador de l’ordinador quan instal·les nio, o a «Continua al mòbil».')}
|
|
753
852
|
<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>
|
|
754
853
|
${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>' : ''}`);
|
|
755
854
|
};
|
|
@@ -792,7 +891,8 @@ function renameSheet(i) {
|
|
|
792
891
|
});
|
|
793
892
|
}
|
|
794
893
|
async function pantallaTab() {
|
|
795
|
-
if (!machine()
|
|
894
|
+
if (!machine()) return openSheet(`<h2>Pantalla</h2><div class="stack"><p class="small"><b>No tens cap ordinador connectat.</b> Connecta’t a un ordinador per veure’n la pantalla i controlar-lo des del mòbil.</p><div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Ara no</button><button class="btn ink" type="button" data-act="add-computer">Connecta un ordinador</button></div></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
|
|
895
|
+
if (!state.connected) return toast(`No arribo a ${machine().name}. Comprova que nio hi estigui engegat.`, 4500);
|
|
796
896
|
let caps = state.info?.control;
|
|
797
897
|
try { caps = await api('/api/control'); if (state.info) state.info.control = caps; } catch (e) { return toast(e.message, 4500); }
|
|
798
898
|
const d = caps?.escriptori;
|
|
@@ -812,7 +912,7 @@ async function pantallaTab() {
|
|
|
812
912
|
}
|
|
813
913
|
function moreSheet() {
|
|
814
914
|
const item = (k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`;
|
|
815
|
-
openSheet(`<h2>Més</h2><div class="list">${['feines', 'memoria', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].map(item).join('')}</div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
|
|
915
|
+
openSheet(`<h2>Més</h2><div class="list">${['notes', 'feines', 'memoria', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].map(item).join('')}</div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
|
|
816
916
|
}
|
|
817
917
|
|
|
818
918
|
/* ---------- Sessions */
|
|
@@ -823,11 +923,11 @@ function stateChip(s) {
|
|
|
823
923
|
return '';
|
|
824
924
|
}
|
|
825
925
|
let manage = null; // Set d'ids seleccionats quan el mode «Gestiona» és actiu
|
|
826
|
-
const DAY = 864e5
|
|
926
|
+
const DAY = 864e5;
|
|
827
927
|
const manageBar = (list) => {
|
|
828
|
-
const n = manage.size
|
|
928
|
+
const n = manage.size;
|
|
829
929
|
return `<div class="manage-bar"><div class="row"><span class="muted small">Selecciona</span><button class="chip" type="button" data-man="all">Totes</button><button class="chip" type="button" data-man="idle">Acabades</button><button class="chip" type="button" data-man="old">Més de 30 dies</button><button class="chip" type="button" data-man="err">Amb error</button><button class="chip" type="button" data-man="none">Cap</button></div>
|
|
830
|
-
<div class="row between"><span class="muted small">${n ? `${n} seleccionades
|
|
930
|
+
<div class="row between"><span class="muted small">${n ? `${n} seleccionades` : `${list.length} sessions`}</span><span class="row"><button class="btn ghost" type="button" data-act="manage-off">Fet</button><button class="btn danger" type="button" data-act="manage-del" ${n ? '' : 'disabled'}>Esborra${n ? ` (${n})` : ''}</button></span></div></div>`;
|
|
831
931
|
};
|
|
832
932
|
function manageSelect(kind) {
|
|
833
933
|
const list = [...state.sessions.values()], now = Date.now();
|
|
@@ -845,6 +945,7 @@ async function manageDelete() {
|
|
|
845
945
|
}
|
|
846
946
|
const plain = (t) => `${t}`.replace(/```[\s\S]*?```/g, ' ').replace(/`([^`]*)`/g, '$1').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/^\s{0,3}(#{1,6}|[-*+]|\d+\.|>)\s+/gm, '').replace(/(\*\*|__|\*|_)(?=\S)([^*_]*?\S)\1/g, '$2').replace(/\*\*/g, '').replace(/\s#{1,6}\s/g, ' ').replace(/\s#{1,6}\s/g, ' ').replace(/\s+/g, ' ').trim();
|
|
847
947
|
function viewSessions() {
|
|
948
|
+
if (!machine()) return `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p></div></div>${noMachine('Connecta’n un per donar feina als agents i seguir-ne les sessions en directe.')}`;
|
|
848
949
|
const list = [...state.sessions.values()].sort((a, b) => b.updated - a.updated);
|
|
849
950
|
const many = store.machines.length > 1, mach = machine();
|
|
850
951
|
const switcher = many ? `<button class="chip switch-chip" type="button" data-act="machines"><i class="dot ${state.connected ? 'on' : ''}"></i>${esc(mach.name)}${svg(ICON.chev)}</button>` : '';
|
|
@@ -852,15 +953,15 @@ function viewSessions() {
|
|
|
852
953
|
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><span class="row">${list.length && state.connected && !manage ? '<button class="btn" data-act="manage-on" type="button">Gestiona</button>' : ''}${manage ? '' : `<button class="btn ink new-fab" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button>`}</span></div>${hint}${manage && list.length ? manageBar(list) : ''}`;
|
|
853
954
|
if (machine() && !state.synced && !state.lastError && !list.length) return `${head}<div class="list" aria-busy="true">${[0, 1, 2].map(() => '<div class="card skel-card"><i class="skel" style="width:34%"></i><i class="skel tall" style="width:78%"></i><i class="skel" style="width:56%"></i></div>').join('')}</div>`;
|
|
854
955
|
if (!state.connected && !list.length) {
|
|
855
|
-
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>`;
|
|
956
|
+
return `${head}${queueList()}<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>` : ''}<button class="btn ghost" data-act="new" type="button">Posa una tasca a la cua</button><a class="btn ghost" href="#/ordinadors">Ordinadors</a></div>` : ''}</div>`;
|
|
856
957
|
}
|
|
857
958
|
if (manage) for (const id of [...manage]) if (!state.sessions.has(id)) manage.delete(id);
|
|
858
|
-
return `${head}${list.length ? `<div class="list">${list.map((s) => `
|
|
959
|
+
return `${head}${queueList()}${list.length ? `<div class="list">${list.map((s) => `
|
|
859
960
|
<${manage ? 'div role="checkbox" tabindex="0" aria-checked="' + manage.has(s.id) + '" data-pick="' + esc(s.id) + '"' : 'a href="#/s/' + esc(s.id) + '"'} class="card session-card ${manage ? 'pick-mode' : ''} ${manage?.has(s.id) ? 'picked' : ''}">
|
|
860
961
|
<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>
|
|
861
962
|
<div class="title">${esc(s.title || 'Sessió nova')}</div>
|
|
862
963
|
${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(plain(s.last))}</div>` : ''}
|
|
863
|
-
<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
|
|
964
|
+
<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>
|
|
864
965
|
</${manage ? 'div' : '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>`}`;
|
|
865
966
|
}
|
|
866
967
|
|
|
@@ -892,6 +993,7 @@ async function revokeDevice(id) {
|
|
|
892
993
|
let activity = null, actFilter = 'tot';
|
|
893
994
|
const ACT_FILTERS = { tot: 'Tot', approval: 'Aprovacions', tool: 'Eines', job: 'Feines', device: 'Dispositius' };
|
|
894
995
|
function viewActivity() {
|
|
996
|
+
if (!machine()) return `<div class="top"><div><h1>Activitat</h1><p>Tot el que fan els agents: què, per què, quan i qui ho ha aprovat.</p></div></div>${noMachine('El registre es guarda a cada ordinador. Connecta’n un per veure què hi han fet els agents.')}`;
|
|
895
997
|
return `<div class="top"><div><h1>Activitat</h1><p>Tot el que han fet els agents en aquest ordinador: què, per què, quan i qui ho ha aprovat. Es guarda només a l’ordinador.</p></div><button class="btn" data-act="activity-refresh" type="button">${svg(ICON.refresh)}Actualitza</button></div>
|
|
896
998
|
<div class="seg act-filter" role="group">${Object.entries(ACT_FILTERS).map(([k, l]) => `<button type="button" data-filter="${k}" aria-pressed="${k === actFilter}">${l}</button>`).join('')}</div>
|
|
897
999
|
<div id="actList">${activityHtml()}</div>`;
|
|
@@ -918,6 +1020,7 @@ async function loadActivity() {
|
|
|
918
1020
|
/* ---------- Memòria compartida (fitxers markdown a l'ordinador, per a Claude Code i Codex) */
|
|
919
1021
|
let mem = null;
|
|
920
1022
|
function viewMemory() {
|
|
1023
|
+
if (!machine()) return `<div class="top"><div><h1>Memòria</h1><p>El que recorden els teus agents: preferències, decisions i context dels projectes.</p></div></div>${noMachine('La memòria són fitxers a l’ordinador. Connecta’n un per veure-la i editar-la.')}`;
|
|
921
1024
|
return `<div class="top"><div><h1>Memòria</h1><p>El que recorden els teus agents en aquest ordinador. Claude Code i Codex la llegeixen i hi escriuen (servidor MCP de nio). Són fitxers markdown a <span class="mono">~/.nio/memory</span>.</p></div><button class="btn ink" data-act="mem-new" type="button">${svg(ICON.plus)}Fitxer nou</button></div>
|
|
922
1025
|
<form class="mem-search" id="memSearch" role="search"><input type="search" id="memQ" placeholder="Busca a la memòria…" aria-label="Busca a la memòria" autocomplete="off"><button class="btn ink" type="submit">Busca</button></form>
|
|
923
1026
|
<div id="memBody">${memHtml()}</div>`;
|
|
@@ -982,6 +1085,7 @@ function jobCard(j) {
|
|
|
982
1085
|
</div>`;
|
|
983
1086
|
}
|
|
984
1087
|
function viewJobs() {
|
|
1088
|
+
if (!machine()) return `<div class="top"><div><h1>Feines</h1><p>Encarrega feines que es fan soles: ara, més tard o cada dia.</p></div></div>${noMachine('Les feines s’executen a l’ordinador. Connecta’n un per programar-ne.')}`;
|
|
985
1089
|
const head = `<div class="top"><div><h1>Feines</h1><p>Encarrega feines que es fan soles: ara, més tard o cada dia, i encadena-les («quan Claude Code acabi, que Codex ho revisi»). Les fan Claude Code o Codex a l’ordinador.</p></div><button class="btn ink" data-act="job-new" type="button">${svg(ICON.plus)}Nova feina</button></div>`;
|
|
986
1090
|
if (!jobsData) return `${head}<div id="jobsBody"><div class="panel empty">${bot('work')}<h2>Carregant…</h2></div></div>`;
|
|
987
1091
|
return `${head}<div id="jobsBody">${jobsHtml()}</div>`;
|
|
@@ -1087,6 +1191,7 @@ function approvalCard(a, showSession = true) {
|
|
|
1087
1191
|
</div>`;
|
|
1088
1192
|
}
|
|
1089
1193
|
function viewApprovals() {
|
|
1194
|
+
if (!machine()) return `<div class="top"><div><h1>Aprovacions</h1><p>Quan un agent vol editar un fitxer o executar una ordre, t’ho demana aquí.</p></div></div>${noMachine('Quan un agent d’un ordinador teu necessiti permís, la petició t’arribarà aquí.')}`;
|
|
1090
1195
|
const list = [...state.approvals.values()].sort((a, b) => a.created - b.created);
|
|
1091
1196
|
return `<div class="top"><div><h1>Aprovacions</h1><p>Quan un agent vol editar un fitxer o executar una ordre, t’ho demana aquí.</p></div></div>
|
|
1092
1197
|
${machine() && !state.synced && !state.lastError && !list.length ? '<div class="list" aria-busy="true"><div class="card skel-card"><i class="skel" style="width:40%"></i><i class="skel tall" style="width:80%"></i><i class="skel" style="width:60%"></i></div></div>' : list.length ? `<div class="list">${list.map((a) => approvalCard(a)).join('')}</div>` : `<div class="panel empty">${bot('happy')}<h2>No hi ha res pendent</h2><p>Les sessions en mode «Demana permís» et faran les peticions aquí, i també t’avisaran al mòbil.</p></div>`}`;
|
|
@@ -1136,7 +1241,8 @@ function renderSession() {
|
|
|
1136
1241
|
<div class="feed" id="feed" aria-live="polite"></div>
|
|
1137
1242
|
<div class="composer" id="composer"></div>
|
|
1138
1243
|
</section>`;
|
|
1139
|
-
feedEls = new Map(); toolEls = new Map(); typers.clear();
|
|
1244
|
+
feedEls = new Map(); toolEls = new Map(); typers.clear(); pin.node = null; pinNext = false; pendingUser = null;
|
|
1245
|
+
composerRO?.disconnect(); watchComposer($('#composer'));
|
|
1140
1246
|
renderSessionHead();
|
|
1141
1247
|
const feed = $('#feed'), evs = state.events.get(id);
|
|
1142
1248
|
if (!evs) feed.innerHTML = machine() ? '<div class="skel-feed" aria-busy="true"><i class="skel r" style="width:46%"></i><i class="skel" style="width:88%;height:64px"></i><i class="skel" style="width:70%;height:40px"></i></div>' : `<div class="ev-info">${ 'Connecta un ordinador per veure aquesta sessió.'}</div>`;
|
|
@@ -1145,6 +1251,13 @@ function renderSession() {
|
|
|
1145
1251
|
requestAnimationFrame(() => toBottom(feed));
|
|
1146
1252
|
}
|
|
1147
1253
|
function sessionMood(s) { return !s ? 'idle' : s.state === 'running' ? 'work' : s.state === 'waiting' || s.pending ? 'peek' : s.state === 'error' ? 'sleep' : 'idle'; }
|
|
1254
|
+
// Ús de la subscripció de l'agent (5 h i setmana), com a esfis. Només si l'agent l'ha informat.
|
|
1255
|
+
const hhmm = (t) => new Date(t * 1000).toLocaleString('ca', { weekday: t * 1000 - Date.now() > 864e5 ? 'short' : undefined, hour: '2-digit', minute: '2-digit' });
|
|
1256
|
+
function usageChip(agent) {
|
|
1257
|
+
const u = state.usage?.[agent]; if (!u?.windows?.length) return '';
|
|
1258
|
+
const tip = u.windows.map((w) => `${w.label}: ${Math.round(w.used * 100)} %${w.resetsAt ? ` · es reinicia ${hhmm(w.resetsAt)}` : ''}`).join('\n');
|
|
1259
|
+
return `<span class="chip mono usage" title="${esc(`Ús de la subscripció\n${tip}`)}">${svg(CHIP_ICON.usage)}${u.windows.map((w) => `<span class="uw" data-hi="${w.used >= 0.8 ? 1 : 0}">${esc(w.label)} ${Math.round(w.used * 100)}%</span>`).join('')}</span>`;
|
|
1260
|
+
}
|
|
1148
1261
|
function renderSessionHead() {
|
|
1149
1262
|
renderChrome();
|
|
1150
1263
|
const s = state.sessions.get(state.route.id), bar = $('#modeBar');
|
|
@@ -1158,7 +1271,7 @@ function renderSessionHead() {
|
|
|
1158
1271
|
${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${svg(CHIP_ICON.mode)}<span>${esc(MODES[s.mode]?.label || s.mode)}</span></button>`}
|
|
1159
1272
|
<button class="chip mono" data-act="folder" type="button" title="${esc(s.cwd)} · canvia de carpeta">${svg(CHIP_ICON.folder)}<span>${esc(basename(s.cwd))}</span></button>
|
|
1160
1273
|
<button class="chip mono" data-act="model" type="button" title="Canvia de model">${svg(CHIP_ICON.model)}<span>${esc(s.model || 'Model per defecte')}</span></button>
|
|
1161
|
-
${
|
|
1274
|
+
${usageChip(s.agent)}
|
|
1162
1275
|
${stateChip(s)}`;
|
|
1163
1276
|
}
|
|
1164
1277
|
// Alçada del camp de text segons el contingut, sense barra de desplaçament fins al límit.
|
|
@@ -1172,6 +1285,45 @@ function autogrow(ta, max = 180) {
|
|
|
1172
1285
|
const scroller = (f) => (f && /(auto|scroll)/.test(getComputedStyle(f).overflowY) ? f : document.scrollingElement);
|
|
1173
1286
|
const nearBottom = (f) => { const s = scroller(f), h = s === document.scrollingElement ? innerHeight : s.clientHeight; return s.scrollHeight - s.scrollTop - h < 140; };
|
|
1174
1287
|
const toBottom = (f) => { const s = scroller(f); s.scrollTop = s.scrollHeight; };
|
|
1288
|
+
/* En enviar un missatge, puja suaument fins a quedar just sota els xips, i a sota queda l'espai per a la resposta
|
|
1289
|
+
(com més llarga, menys espai de més). Quan la resposta ja no hi cap, la conversa segueix el final com sempre. */
|
|
1290
|
+
const pin = { node: null, until: 0 };
|
|
1291
|
+
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
1292
|
+
function feedLines(feed) {
|
|
1293
|
+
const chat = feed.closest('.chat'), bar = $('.mode-bar', chat), comp = $('.composer', chat);
|
|
1294
|
+
return { top: (bar ? bar.getBoundingClientRect().bottom : feed.getBoundingClientRect().top) + 12, bottom: comp ? comp.getBoundingClientRect().top : innerHeight };
|
|
1295
|
+
}
|
|
1296
|
+
// Posició a la pantalla sense comptar les animacions d'entrada (transform), que mourien el càlcul uns quants píxels.
|
|
1297
|
+
function layTop(n) {
|
|
1298
|
+
const op = n.offsetParent || document.body;
|
|
1299
|
+
return op.getBoundingClientRect().top + (op.clientTop || 0) + n.offsetTop - (op === document.body ? 0 : op.scrollTop);
|
|
1300
|
+
}
|
|
1301
|
+
function sizeSpace(feed) {
|
|
1302
|
+
if (!feed) return;
|
|
1303
|
+
if (!pin.node || !feed.contains(pin.node)) { pin.node = null; feed.style.paddingBottom = ''; delete feed.dataset.pad; return; }
|
|
1304
|
+
// El marge de baix original es llegeix un sol cop: treure'l per mesurar faria saltar el scroll a dalt.
|
|
1305
|
+
if (!feed.dataset.pad) feed.dataset.pad = parseFloat(getComputedStyle(feed).paddingBottom) || 24;
|
|
1306
|
+
const base = +feed.dataset.pad, last = feed.lastElementChild;
|
|
1307
|
+
const { top, bottom } = feedLines(feed), span = layTop(last) + last.offsetHeight - layTop(pin.node);
|
|
1308
|
+
feed.style.paddingBottom = `${base + Math.max(0, bottom - top - span - base)}px`;
|
|
1309
|
+
}
|
|
1310
|
+
function pinMessage(feed, node) {
|
|
1311
|
+
pin.node = node; sizeSpace(feed);
|
|
1312
|
+
const delta = layTop(node) - feedLines(feed).top;
|
|
1313
|
+
pin.until = performance.now() + 650;
|
|
1314
|
+
scroller(feed).scrollBy({ top: delta, behavior: reducedMotion.matches ? 'auto' : 'smooth' });
|
|
1315
|
+
}
|
|
1316
|
+
// Després de cada canvi a la conversa: recalcula l'espai i, si eres al final, el segueix (mai a mig de la pujada).
|
|
1317
|
+
function follow(feed, wasNear) {
|
|
1318
|
+
sizeSpace(feed); if (wasNear && performance.now() > pin.until) toBottom(feed);
|
|
1319
|
+
// I un cop més quan el navegador ha acabat de col·locar-ho tot.
|
|
1320
|
+
if (pin.node) requestAnimationFrame(() => { const near = nearBottom(feed); sizeSpace(feed); if (near && performance.now() > pin.until) toBottom(feed); });
|
|
1321
|
+
}
|
|
1322
|
+
let pinNext = false, pendingUser = null;
|
|
1323
|
+
// Si el camp de text canvia d'alçada (dues línies, el teclat…), l'espai es torna a calcular perquè el missatge no es mogui.
|
|
1324
|
+
const composerRO = typeof ResizeObserver !== 'undefined' ? new ResizeObserver((es) => { for (const x of es) { const feed = $('.feed', x.target.closest('.chat')); if (feed && pin.node) { const near = nearBottom(feed); sizeSpace(feed); if (near) toBottom(feed); } } }) : null;
|
|
1325
|
+
const watchComposer = (c) => { if (c) composerRO?.observe(c); };
|
|
1326
|
+
visualViewport?.addEventListener('resize', () => sizeSpace($('#feed') || $('#lfeed')));
|
|
1175
1327
|
// El text de l'agent surt paraula per paraula, tal com arriba, sense refer tota la bombolla a cada tros.
|
|
1176
1328
|
const typers = new Map();
|
|
1177
1329
|
function wordsAfter(text, i, n) {
|
|
@@ -1202,13 +1354,13 @@ const typeTick = (t) => function tick(now) {
|
|
|
1202
1354
|
const stick = nearBottom(feed);
|
|
1203
1355
|
t.shown = wordsAfter(e.text, t.shown, 1 + Math.floor(backlog / 80));
|
|
1204
1356
|
t.node.innerHTML = md(e.text.slice(0, t.shown));
|
|
1205
|
-
|
|
1357
|
+
follow(feed, stick);
|
|
1206
1358
|
}
|
|
1207
1359
|
}
|
|
1208
1360
|
if (!e.open && t.shown >= e.text.length) {
|
|
1209
1361
|
typers.delete(t.seq);
|
|
1210
1362
|
const node = eventNode(e); t.node.replaceWith(node); feedEls.set(e.seq, node);
|
|
1211
|
-
|
|
1363
|
+
const near = nearBottom(feed); renderTyping(); follow(feed, near);
|
|
1212
1364
|
return;
|
|
1213
1365
|
}
|
|
1214
1366
|
t.raf = requestAnimationFrame(tick);
|
|
@@ -1223,6 +1375,7 @@ function putEvent(e, scroll = true) {
|
|
|
1223
1375
|
const st = $('.st', t); st.className = `st ${e.error ? 'err' : 'ok'}`; st.textContent = e.error ? '✕' : '✓';
|
|
1224
1376
|
let pre = $('pre.out', t); if (!pre && e.output) { pre = el('<pre class="out"></pre>'); t.appendChild(pre); }
|
|
1225
1377
|
if (pre) pre.textContent = e.output;
|
|
1378
|
+
sizeSpace(feed);
|
|
1226
1379
|
return;
|
|
1227
1380
|
}
|
|
1228
1381
|
}
|
|
@@ -1232,12 +1385,14 @@ function putEvent(e, scroll = true) {
|
|
|
1232
1385
|
if (last && last.classList.contains('ev-logs')) { const pre = $('pre', last); pre.textContent += `\n${e.text}`; $('summary', last).textContent = `Registre del procés (${pre.textContent.split('\n').length} línies)`; return; }
|
|
1233
1386
|
}
|
|
1234
1387
|
const node = eventNode(e); if (!node) return;
|
|
1388
|
+
if (e.kind === 'user' && pendingUser && pendingUser.text === e.text && !feedEls.get(e.seq)) { pendingUser.node.replaceWith(node); feedEls.set(e.seq, node); pendingUser = null; renderTyping(); return; }
|
|
1235
1389
|
const old = feedEls.get(e.seq);
|
|
1236
1390
|
if (old) old.replaceWith(node); else { const typing = $('.typing', feed); typing ? feed.insertBefore(node, typing) : feed.appendChild(node); }
|
|
1237
1391
|
feedEls.set(e.seq, node);
|
|
1238
1392
|
if (e.kind === 'tool') toolEls.set(e.id, node);
|
|
1239
1393
|
renderTyping();
|
|
1240
|
-
if (scroll &&
|
|
1394
|
+
if (scroll && e.kind === 'user' && pinNext && !old) { pinNext = false; return pinMessage(feed, node); }
|
|
1395
|
+
if (scroll) follow(feed, stick);
|
|
1241
1396
|
}
|
|
1242
1397
|
function eventNode(e) {
|
|
1243
1398
|
const s = state.sessions.get(state.route.id);
|
|
@@ -1251,7 +1406,6 @@ function eventNode(e) {
|
|
|
1251
1406
|
case 'info': return el(`<div class="ev-info">${esc(e.text)}</div>`);
|
|
1252
1407
|
case 'result': {
|
|
1253
1408
|
const bits = [];
|
|
1254
|
-
if (typeof e.cost === 'number' && e.cost > 0) bits.push(`${e.cost.toFixed(3)} $`);
|
|
1255
1409
|
if (e.ms) bits.push(`${(e.ms / 1000).toFixed(1).replace('.', ',')} s`);
|
|
1256
1410
|
if (e.tokens?.output_tokens || e.tokens?.output) bits.push(`${e.tokens.output_tokens || e.tokens.output} tokens`);
|
|
1257
1411
|
if (e.error) bits.push(String(e.error));
|
|
@@ -1271,6 +1425,7 @@ function renderTyping() {
|
|
|
1271
1425
|
else if (t) t.remove();
|
|
1272
1426
|
if (!busy) $$('.msg-ai.cursor', feed).forEach((n) => n.classList.remove('cursor'));
|
|
1273
1427
|
$$('.mode-bar .abot .bot, #headBot .bot').forEach((b) => { b.dataset.mood = sessionMood(s); });
|
|
1428
|
+
if (pin.node) sizeSpace(feed);
|
|
1274
1429
|
}
|
|
1275
1430
|
/* ---------- Comandes amb «/» (les de nio i les de l'agent) */
|
|
1276
1431
|
const NIO_CMDS = [
|
|
@@ -1400,8 +1555,11 @@ function renderComposer() {
|
|
|
1400
1555
|
const c = text.match(/^\/([\w:.-]+)(?:\s+([\s\S]*))?$/), nio = c && NIO_CMDS.find((x) => x.name === c[1].toLowerCase());
|
|
1401
1556
|
ta.value = ''; grow(); drawSlash();
|
|
1402
1557
|
if (nio) return runCmd(s, nio.name, (c[2] || '').trim());
|
|
1558
|
+
// El missatge surt a la pantalla a l'instant; quan arriba l'esdeveniment real, el substitueix.
|
|
1559
|
+
const feed = $('#feed'); pinNext = true;
|
|
1560
|
+
if (feed) { const tmp = el(`<div class="msg-user">${esc(text)}</div>`), typing = $('.typing', feed); typing ? feed.insertBefore(tmp, typing) : feed.appendChild(tmp); pendingUser = { node: tmp, text }; pinNext = false; pinMessage(feed, tmp); }
|
|
1403
1561
|
try { await api(`/api/sessions/${s.id}/message`, { method: 'POST', body: { text } }); }
|
|
1404
|
-
catch (e) { ta.value = text; grow(); toast(e.message, 4200); }
|
|
1562
|
+
catch (e) { pinNext = false; pendingUser?.node.remove(); pendingUser = null; ta.value = text; grow(); toast(e.message, 4200); }
|
|
1405
1563
|
};
|
|
1406
1564
|
ta.addEventListener('keydown', (e) => {
|
|
1407
1565
|
if (items.length && !pop.hidden) {
|
|
@@ -1449,7 +1607,7 @@ function modeHelp(agentId, mode) {
|
|
|
1449
1607
|
// Assistent de 4 passos: agent, carpeta exacta, opcions i tasca. Capçalera i peu fixos; només el cos fa scroll.
|
|
1450
1608
|
async function newSessionSheet(preset = {}) {
|
|
1451
1609
|
if (!machine()) return go('#/sessions');
|
|
1452
|
-
if (!state.connected) return
|
|
1610
|
+
if (!state.connected) return queueSheet();
|
|
1453
1611
|
const agents = [...(state.info?.agents || [])].sort((x, y) => Number(!!y.available) - Number(!!x.available));
|
|
1454
1612
|
let projects = [];
|
|
1455
1613
|
try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
|
|
@@ -1536,7 +1694,10 @@ async function newSessionSheet(preset = {}) {
|
|
|
1536
1694
|
if (!sel.text.trim()) { $('#firstMsg', sh)?.focus(); return toast('Explica a l’agent què ha de fer'); }
|
|
1537
1695
|
const btn = $('[data-start]', sh); btn.disabled = true;
|
|
1538
1696
|
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}`); }
|
|
1539
|
-
catch (e) {
|
|
1697
|
+
catch (e) {
|
|
1698
|
+
if (e.name === 'TypeError') { const { text, ...rest } = sel; queue.add({ ...rest, text: text.trim(), title: text.trim().slice(0, 60) }); closeSheet(); toast('L’ordinador no respon. La tasca és a la cua.', 4200); return render(); }
|
|
1699
|
+
toast(e.message, 4200); btn.disabled = false;
|
|
1700
|
+
}
|
|
1540
1701
|
};
|
|
1541
1702
|
sh.onclick = (e) => {
|
|
1542
1703
|
const t = e.target;
|
|
@@ -1602,7 +1763,7 @@ function modeSheet(s) {
|
|
|
1602
1763
|
const isLocalMachine = () => { const m = machine(); try { return !!m && ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(m.url).hostname); } catch { return false; } };
|
|
1603
1764
|
function mobileHint() {
|
|
1604
1765
|
const m = machine(), key = `nio.mobHint.${m?.id || m?.url}`;
|
|
1605
|
-
if (!m || isPhone() || !state.connected || LS.get(key, false)) return '';
|
|
1766
|
+
if (!m || isDemo(m) || isPhone() || !state.connected || LS.get(key, false)) return '';
|
|
1606
1767
|
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>`;
|
|
1607
1768
|
}
|
|
1608
1769
|
let qrTimer = 0;
|
|
@@ -1682,7 +1843,7 @@ async function loadOllama(force) {
|
|
|
1682
1843
|
paintModels();
|
|
1683
1844
|
}
|
|
1684
1845
|
function ollamaSection() {
|
|
1685
|
-
if (!machine()) return `<h2 class="sec" style="margin-top:36px">Ollama a l’ordinador</h2><div class="stack-lg" style="max-width:640px"><div class="card"><b>
|
|
1846
|
+
if (!machine()) return `<h2 class="sec" style="margin-top:36px">Ollama a l’ordinador</h2><div class="stack-lg" style="max-width:640px"><div class="card"><b>No tens cap ordinador connectat</b><div class="muted small" style="margin-top:4px">Els models d’Ollama funcionen a l’ordinador i són més grans i capaços. Connecta’n un per veure’ls i descarregar-ne.</div><button class="btn block" style="margin-top:10px" type="button" data-act="add-computer">Connecta un ordinador</button></div></div>`;
|
|
1686
1847
|
return `<div class="row between" style="max-width:640px;margin-top:36px"><h2 class="sec" style="margin:0">Ollama a ${esc(machine().name)}</h2><button class="icon-btn" data-act="ollama-refresh" type="button" aria-label="Actualitza">${svg(ICON.refresh)}</button></div><div id="modelsBody" style="max-width:640px;margin-top:12px">${state.ollama ? '' : `<div class="card"><b>Consultant Ollama…</b></div>`}</div>`;
|
|
1687
1848
|
}
|
|
1688
1849
|
function pctOf(j) { return j.total ? Math.min(100, Math.round((j.completed / j.total) * 100)) : 0; }
|
|
@@ -1842,7 +2003,7 @@ const inLocalChat = () => !!(local.engine || local.ollama);
|
|
|
1842
2003
|
function bindLocal() {
|
|
1843
2004
|
const feed = $('#lfeed'); if (!feed) return;
|
|
1844
2005
|
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>`));
|
|
1845
|
-
toBottom(feed);
|
|
2006
|
+
pin.node = null; toBottom(feed); composerRO?.disconnect(); watchComposer($('.composer', feed.closest('.chat')));
|
|
1846
2007
|
const ta = $('#lmsg');
|
|
1847
2008
|
if (local.draft) { ta.value = local.draft; local.draft = ''; autogrow(ta); }
|
|
1848
2009
|
ta.addEventListener('input', () => autogrow(ta));
|
|
@@ -1892,14 +2053,14 @@ async function sendLocal() {
|
|
|
1892
2053
|
ta.value = ''; autogrow(ta);
|
|
1893
2054
|
const feed = $('#lfeed'); if (!local.messages.length) feed.innerHTML = '';
|
|
1894
2055
|
local.messages.push({ role: 'user', content: text });
|
|
1895
|
-
|
|
1896
|
-
const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out);
|
|
2056
|
+
const mine = el(`<div class="msg-user">${esc(text)}</div>`); feed.appendChild(mine);
|
|
2057
|
+
const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out); pinMessage(feed, mine);
|
|
1897
2058
|
local.busy = true; local.stopped = false; setLocalBusy(true); let acc = '';
|
|
1898
2059
|
try {
|
|
1899
2060
|
if (local.ollama) {
|
|
1900
2061
|
const r = await api('/api/ollama/chat', { method: 'POST', body: { model: local.ollama, messages: [{ role: 'system', content: LOCAL_SYSTEM }, ...local.messages.slice(-12)] } });
|
|
1901
2062
|
if (!local.stopped) acc = r.content || '';
|
|
1902
|
-
out.innerHTML = md(stripThink(acc));
|
|
2063
|
+
const near = nearBottom(feed); out.innerHTML = md(stripThink(acc)); follow(feed, near);
|
|
1903
2064
|
} else {
|
|
1904
2065
|
const chunks = await local.engine.chat.completions.create({
|
|
1905
2066
|
messages: [{ role: 'system', content: LOCAL_SYSTEM }, ...local.messages.slice(-8)], stream: true,
|
|
@@ -1907,7 +2068,7 @@ async function sendLocal() {
|
|
|
1907
2068
|
extra_body: { enable_thinking: false }
|
|
1908
2069
|
});
|
|
1909
2070
|
for await (const c of chunks) {
|
|
1910
|
-
acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc));
|
|
2071
|
+
acc += c.choices[0]?.delta?.content || ''; const near = nearBottom(feed); out.innerHTML = md(stripThink(acc)); follow(feed, near);
|
|
1911
2072
|
if (local.stopped) break;
|
|
1912
2073
|
}
|
|
1913
2074
|
}
|
|
@@ -1925,6 +2086,12 @@ async function sendLocal() {
|
|
|
1925
2086
|
let screen = null; // { caps, last, actions }
|
|
1926
2087
|
const controlOn = () => !!(state.info?.control?.navegador?.enabled || state.info?.control?.escriptori?.enabled || screen?.caps?.navegador?.enabled || screen?.caps?.escriptori?.enabled);
|
|
1927
2088
|
// Aturada d'emergència: sempre a la vista mentre el control estigui actiu.
|
|
2089
|
+
function renderDemoBar() {
|
|
2090
|
+
let bar = $('#demoBar');
|
|
2091
|
+
if (!isDemo()) return bar?.remove();
|
|
2092
|
+
if (bar) return;
|
|
2093
|
+
$('#main').insertAdjacentHTML('afterbegin', `<div class="demo-bar" id="demoBar" role="status"><span><b>Demo</b> · un ordinador simulat, res no surt del navegador.</span><span class="row"><button class="btn sm ink" type="button" data-act="add-computer">Connecta el teu</button><button class="btn sm ghost" type="button" data-act="demo-exit">Surt</button></span></div>`);
|
|
2094
|
+
}
|
|
1928
2095
|
function renderPanic() {
|
|
1929
2096
|
syncScreenBtn();
|
|
1930
2097
|
$('#panicBtn')?.remove();
|
|
@@ -2332,6 +2499,7 @@ async function speak(btn) {
|
|
|
2332
2499
|
function autoSpeak(ev) { if (nodeVoice?.speak && nodeVoice?.tts && !document.hidden && ev?.text) { stopSpeech(); playText(ev.text); } }
|
|
2333
2500
|
const mic = { rec: null, chunks: [], stream: null };
|
|
2334
2501
|
async function toggleMic() {
|
|
2502
|
+
if (isDemo()) return toast('A la demo no hi ha micròfon. Amb nio instal·lat, dictes amb Whisper, gratis.', 4500);
|
|
2335
2503
|
if (!voiceOn()) return voiceSheet();
|
|
2336
2504
|
if (mic.rec) return mic.rec.stop();
|
|
2337
2505
|
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Aquest navegador no pot gravar àudio.');
|
|
@@ -2355,18 +2523,20 @@ async function toggleMic() {
|
|
|
2355
2523
|
function voicePanel() {
|
|
2356
2524
|
if (!machine()) return '';
|
|
2357
2525
|
const v = nodeVoice, P = v?.providers || {};
|
|
2358
|
-
const on = Object.entries(P).filter(([, p]) => p.key).map(([, p]) => p.name);
|
|
2359
|
-
return `<section class="panel"><header><h2>Veu</h2>${on.length ? `<span class="chip ok"><i class="dot on"></i>${esc(on.join(' · '))}</span>` : ''}</header><div class="body stack"><p class="small">Dicta els missatges amb el micròfon i escolta les respostes.
|
|
2526
|
+
const on = [...(v?.whisper?.ready ? ['Whisper'] : []), ...Object.entries(P).filter(([, p]) => p.key).map(([, p]) => p.name)];
|
|
2527
|
+
return `<section class="panel"><header><h2>Veu</h2>${on.length ? `<span class="chip ok"><i class="dot on"></i>${esc(on.join(' · '))}</span>` : ''}</header><div class="body stack"><p class="small">Dicta els missatges amb el micròfon i escolta les respostes. Amb <b>Whisper</b> és gratis i l’àudio no surt de l’ordinador (${esc(machine().name)}). Si hi poses una clau d’ElevenLabs o OpenAI, també es queda a l’ordinador.</p>${v ? '' : '<p class="muted small">Aquest ordinador encara no té la veu (cal nio 0.7 o més nou).</p>'}<div class="row"><button class="btn ink" data-act="voice" type="button" ${v ? '' : 'disabled'}>${on.length ? 'Canvia la configuració' : 'Configura la veu'}</button></div></div></section>`;
|
|
2360
2528
|
}
|
|
2361
2529
|
async function voiceSheet() {
|
|
2362
2530
|
await loadVoice();
|
|
2363
2531
|
if (!nodeVoice) return toast('Aquest ordinador encara no té la veu. Actualitza nio (npx nio-cat@latest).', 5000);
|
|
2532
|
+
if (isDemo()) return toast('A la demo no hi ha veu. Amb nio instal·lat, dictes amb Whisper, gratis.', 4500);
|
|
2364
2533
|
const v = nodeVoice, old = LS.get('nio.voice', {});
|
|
2365
|
-
openSheet(`<h2>Veu</h2><p class="muted small">
|
|
2534
|
+
openSheet(`<h2>Veu</h2><p class="muted small">Per dictar n’hi ha prou amb Whisper, gratis i a l’ordinador «${esc(machine().name)}». Les claus són opcionals: serveixen per escoltar les respostes en veu alta. Es desen a l’ordinador i no tornen mai al mòbil.</p>
|
|
2535
|
+
<div id="whisperBox" style="margin-top:14px">${whisperCard(v.whisper)}</div>
|
|
2366
2536
|
<form class="stack" id="voiceForm" style="margin-top:14px">
|
|
2367
2537
|
<label class="f"><span>Clau d’ElevenLabs ${v.providers.elevenlabs.key ? `<span class="muted">(desada: ${esc(v.providers.elevenlabs.key)})</span>` : ''}</span><input type="password" id="vEl" placeholder="${v.providers.elevenlabs.key ? 'Deixa-ho buit per mantenir-la' : 'sk_…'}" value="${!v.providers.elevenlabs.key && old.key ? esc(old.key) : ''}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
|
|
2368
2538
|
<label class="f"><span>Clau d’OpenAI ${v.providers.openai.key ? `<span class="muted">(desada: ${esc(v.providers.openai.key)})</span>` : ''}</span><input type="password" id="vOa" placeholder="${v.providers.openai.key ? 'Deixa-ho buit per mantenir-la' : 'sk-…'}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
|
|
2369
|
-
<div class="grid-2"><label class="f"><span>Per entendre’t</span><select id="vStt"><option value="">Automàtic</option
|
|
2539
|
+
<div class="grid-2"><label class="f"><span>Per entendre’t</span><select id="vStt"><option value="">Automàtic</option>${v.whisper ? `<option value="whisper" ${v.stt === 'whisper' ? 'selected' : ''}>Whisper (gratis)</option>` : ''}<option value="elevenlabs" ${v.stt === 'elevenlabs' ? 'selected' : ''}>ElevenLabs Scribe</option><option value="openai" ${v.stt === 'openai' ? 'selected' : ''}>OpenAI</option></select></label>
|
|
2370
2540
|
<label class="f"><span>Per parlar</span><select id="vTts"><option value="">Automàtic</option><option value="elevenlabs" ${v.tts === 'elevenlabs' ? 'selected' : ''}>ElevenLabs</option><option value="openai" ${v.tts === 'openai' ? 'selected' : ''}>OpenAI</option></select></label></div>
|
|
2371
2541
|
<label class="row check"><input type="checkbox" id="vSpeak" ${v.speak ? 'checked' : ''}> <span>Llegeix en veu alta les respostes de la sessió oberta</span></label>
|
|
2372
2542
|
${old.key ? '<p class="muted small">Tenies una clau d’ElevenLabs desada en aquest navegador: la passo a l’ordinador i l’esborro d’aquí.</p>' : ''}
|
|
@@ -2375,8 +2545,38 @@ async function voiceSheet() {
|
|
|
2375
2545
|
const put = async (body, msg) => { try { nodeVoice = await api('/api/voice', { method: 'PUT', body }); try { localStorage.removeItem('nio.voice'); } catch { /* res */ } closeSheet(); toast(msg); if (state.route.name === 'session') renderSession(); else render(); } catch (e) { toast(e.message, 4500); } };
|
|
2376
2546
|
$('#voiceForm', sh).onsubmit = (e) => { e.preventDefault(); const keys = {}; const el = $('#vEl', sh).value.trim(), oa = $('#vOa', sh).value.trim(); if (el) keys.elevenlabs = el; if (oa) keys.openai = oa; put({ keys, stt: $('#vStt', sh).value || null, tts: $('#vTts', sh).value || null, speak: $('#vSpeak', sh).checked }, 'Veu desada a l’ordinador'); };
|
|
2377
2547
|
$('#vOff', sh).onclick = () => { if (confirm('Es treuran les claus de veu d’aquest ordinador.')) put({ keys: { elevenlabs: null, openai: null } }, 'Claus eliminades'); };
|
|
2548
|
+
// Whisper: instal·lar, baixar el model i seguir-ne el progrés mentre la fulla és oberta.
|
|
2549
|
+
const wbox = $('#whisperBox', sh);
|
|
2550
|
+
const draw = (w) => { if (w) { nodeVoice.whisper = w; wbox.innerHTML = whisperCard(w); } };
|
|
2551
|
+
const poll = async () => {
|
|
2552
|
+
if (!wbox.isConnected) return;
|
|
2553
|
+
try { const r = await api('/api/voice'); nodeVoice = r; draw(r.whisper); if (r.whisper?.downloading || r.whisper?.installing) setTimeout(poll, 1500); } catch { /* ho tornarem a provar en obrir */ }
|
|
2554
|
+
};
|
|
2555
|
+
wbox.onclick = async (e) => {
|
|
2556
|
+
const b = e.target.closest('[data-whisper]'); if (!b) return;
|
|
2557
|
+
b.disabled = true;
|
|
2558
|
+
try { const r = await api('/api/voice/whisper', { method: 'POST', body: { action: b.dataset.whisper } }); nodeVoice = r; draw(r.whisper); setTimeout(poll, 1000); } catch (err) { toast(err.message, 5000); b.disabled = false; }
|
|
2559
|
+
};
|
|
2560
|
+
wbox.onchange = async (e) => {
|
|
2561
|
+
if (e.target.id !== 'wModel') return;
|
|
2562
|
+
try { const r = await api('/api/voice', { method: 'PUT', body: { whisper: { model: e.target.value } } }); nodeVoice = r; draw(r.whisper); } catch (err) { toast(err.message); }
|
|
2563
|
+
};
|
|
2564
|
+
if (v.whisper?.downloading || v.whisper?.installing) setTimeout(poll, 1000);
|
|
2378
2565
|
});
|
|
2379
2566
|
}
|
|
2567
|
+
function whisperCard(w) {
|
|
2568
|
+
if (!w) return '<div class="card"><b>Whisper</b><div class="muted small">Actualitza nio a l’ordinador per dictar gratis amb Whisper (npx nio-cat@latest).</div></div>';
|
|
2569
|
+
const mb = (n) => `${Math.round(n / 1e6)} MB`, m = w.models?.[w.model], live = (t) => `<p class="live"><i class="pulse"></i>${t}</p>`;
|
|
2570
|
+
const models = `<label class="f" style="margin-top:10px"><span>Model</span><select id="wModel">${Object.entries(w.models || {}).map(([id, x]) => `<option value="${esc(id)}" ${id === w.model ? 'selected' : ''}>${esc(x.label)} · ${mb(x.size)}${x.have ? ' · baixat' : ''}</option>`).join('')}</select></label>`;
|
|
2571
|
+
let body;
|
|
2572
|
+
if (w.installing) body = live('Instal·lant whisper-cpp i ffmpeg amb Homebrew… pot trigar uns minuts.');
|
|
2573
|
+
else if (w.downloading) body = `${live(`Baixant el model… ${w.downloading.pct}%`)}<div class="progress"><i style="width:${w.downloading.pct}%"></i></div>`;
|
|
2574
|
+
else if (w.ready) body = `<p class="small">A punt. Prem el micròfon i parla: l’àudio no surt de l’ordinador.</p>${w.engine === 'cpp' ? models : ''}`;
|
|
2575
|
+
else if (w.missing?.length) body = w.canInstall ? `<p class="small">Falta ${esc(w.missing.join(' i '))}. nio ho pot instal·lar amb Homebrew.</p><div class="row"><button class="btn ink" type="button" data-whisper="install">Instal·la Whisper</button></div>` : `<p class="small">Instal·la-ho al terminal de l’ordinador i torna a obrir aquesta fulla:</p>${cmdBox(w.install)}`;
|
|
2576
|
+
else if (w.needsModel) body = `<p class="small">Només falta baixar el model, una sola vegada.</p>${models}<div class="row" style="margin-top:10px"><button class="btn ink" type="button" data-whisper="download">Baixa el model${m ? ` (${mb(m.size)})` : ''}</button></div>`;
|
|
2577
|
+
else body = '';
|
|
2578
|
+
return `<div class="card"><div class="row between"><b>Whisper · gratis</b>${w.ready ? '<span class="chip ok"><i class="dot on"></i>Actiu</span>' : ''}</div>${body}${w.error ? `<p class="small" style="color:var(--red)">${esc(w.error)}</p>` : ''}</div>`;
|
|
2579
|
+
}
|
|
2380
2580
|
|
|
2381
2581
|
/* ---------------------------------------------------------------- accions */
|
|
2382
2582
|
document.addEventListener('click', (e) => {
|
|
@@ -2395,7 +2595,7 @@ async function hydrateShots(root = document) {
|
|
|
2395
2595
|
}
|
|
2396
2596
|
}
|
|
2397
2597
|
document.addEventListener('click', async (e) => {
|
|
2398
|
-
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], [data-man], [data-pick]');
|
|
2598
|
+
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], [data-man], [data-pick], [data-qdrop]');
|
|
2399
2599
|
if (!a) return;
|
|
2400
2600
|
if (a.dataset.copy) {
|
|
2401
2601
|
try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
|
|
@@ -2416,6 +2616,7 @@ document.addEventListener('click', async (e) => {
|
|
|
2416
2616
|
if (a.dataset.rename != null) return renameSheet(Number(a.dataset.rename));
|
|
2417
2617
|
if (a.dataset.forget != null) { if (confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) forgetMachine(Number(a.dataset.forget)); return; }
|
|
2418
2618
|
if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
|
|
2619
|
+
if (a.dataset.qdrop) { queue.drop(a.dataset.qdrop); return render(); }
|
|
2419
2620
|
switch (a.dataset.act) {
|
|
2420
2621
|
case 'new': return newSessionSheet();
|
|
2421
2622
|
case 'manage-on': manage = new Set(); return render();
|
|
@@ -2444,6 +2645,8 @@ document.addEventListener('click', async (e) => {
|
|
|
2444
2645
|
case 'activity-refresh': activity = null; return loadActivity();
|
|
2445
2646
|
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;
|
|
2446
2647
|
case 'add-computer': return addComputerSheet();
|
|
2648
|
+
case 'demo': return startDemo();
|
|
2649
|
+
case 'demo-exit': return exitDemo();
|
|
2447
2650
|
case 'open-sessions': return go('#/sessions');
|
|
2448
2651
|
case 'help': return go('#/ajuda');
|
|
2449
2652
|
case 'more': return moreSheet();
|
|
@@ -2478,7 +2681,7 @@ function commands() {
|
|
|
2478
2681
|
if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
|
|
2479
2682
|
c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
|
|
2480
2683
|
[...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}`) }));
|
|
2481
|
-
c.push({ label: 'Configura la veu (
|
|
2684
|
+
c.push({ label: 'Configura la veu (Whisper, gratis)', hint: 'Ajustos', run: voiceSheet });
|
|
2482
2685
|
c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
|
|
2483
2686
|
return c;
|
|
2484
2687
|
}
|
|
@@ -2534,7 +2737,7 @@ const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
|
|
|
2534
2737
|
const PAGE = '.page .top, .top';
|
|
2535
2738
|
const TSTEPS = [
|
|
2536
2739
|
{ 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, Grok, Antigravity…) del teu ordinador o d’un servidor. Et faig un recorregut per tot nio, amb un clic a cada apartat. Dura uns dos minuts.', next: 'Comencem' },
|
|
2537
|
-
{ route: 'ordinadors', target: '#addComputer', m: '#addComputer', title: 'Afegeix un ordinador', text: 'Hi ha tres maneres: aquest ordinador (
|
|
2740
|
+
{ route: 'ordinadors', target: '#addComputer, .first-run [data-act=add-computer]', m: '#addComputer, .first-run [data-act=add-computer]', title: 'Afegeix un ordinador', text: 'Hi ha tres maneres: aquest ordinador (amb l’ordre d’instal·lació), un servidor per SSH (<span class="mono">usuari@servidor</span>) o un codi. nio fa la resta.' },
|
|
2538
2741
|
{ route: 'ordinadors', skipIf: () => !machine(), target: '.card.computer.active .c-actions', m: '.card.computer.active .c-actions', title: '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.' },
|
|
2539
2742
|
{ route: 'ordinadors', skipIf: () => !machine(), target: '#screenSec', m: '#screenSec', title: 'Mira i controla la pantalla', text: 'Aquí actives «Apps de l’escriptori» i el navegador dels agents. Amb l’escriptori actiu pots veure la pantalla de l’ordinador i fer-hi captures des del mòbil.' },
|
|
2540
2743
|
{ route: 'ordinadors', mobileOnly: true, tapOnly: '#tabbar [data-act=pantalla-tab]', target: '#tabbar [data-act=pantalla-tab]', m: '#tabbar [data-act=pantalla-tab]', title: 'Pantalla, a un toc', text: 'La pestanya «Pantalla» obre l’escriptori de l’ordinador al mòbil, com un escriptori remot, amb zoom. Si encara no està activat, t’ajuda a activar-lo.' },
|