nio-cat 0.9.0 → 0.9.2
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/package.json +1 -1
- package/src/agents/generic.js +28 -0
- package/src/commands.js +11 -1
- package/src/hub.js +4 -3
- package/src/jobs.js +1 -1
- package/src/remote.js +8 -2
- package/src/telegram.js +22 -3
- package/web/app/app.js +361 -101
- package/web/app/index.html +1 -0
- package/web/app/style.css +48 -4
- package/web/board.css +50 -0
- package/web/board.js +29 -5
- package/web/index.html +67 -8
- package/web/privadesa.html +1 -1
- package/web/sw.js +1 -1
package/web/app/app.js
CHANGED
|
@@ -479,9 +479,10 @@ function renderChrome() {
|
|
|
479
479
|
const m = machine(), sc = $('#sideConn');
|
|
480
480
|
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)}`;
|
|
481
481
|
sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
|
|
482
|
-
const more = ['mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
|
|
483
|
-
$('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['
|
|
482
|
+
const more = ['feines', 'memoria', 'approvals', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
|
|
483
|
+
$('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
|
|
484
484
|
.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('')
|
|
485
|
+
.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"`)
|
|
485
486
|
+ `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
|
|
486
487
|
const s = r.name === 'session' ? state.sessions.get(r.id) : null;
|
|
487
488
|
$('#here').textContent = s ? s.title || 'Sessió nova' : VIEWS[r.name]?.label || '';
|
|
@@ -496,8 +497,8 @@ function render() {
|
|
|
496
497
|
if (r.name !== 'sessions') manage = null;
|
|
497
498
|
renderChrome();
|
|
498
499
|
document.body.classList.toggle('board-mode', board);
|
|
499
|
-
document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' &&
|
|
500
|
-
document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' &&
|
|
500
|
+
document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && inLocalChat()));
|
|
501
|
+
document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' && inLocalChat()));
|
|
501
502
|
if (!board && lastName === 'pissarra') window.NioBoard?.unmount();
|
|
502
503
|
if (board && lastName === 'pissarra' && $('#boardRoot')) return;
|
|
503
504
|
const changed = lastName !== r.name || r.name === 'session';
|
|
@@ -506,9 +507,9 @@ function render() {
|
|
|
506
507
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
507
508
|
else {
|
|
508
509
|
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
509
|
-
view.innerHTML = html &&
|
|
510
|
+
view.innerHTML = html && inLocalChat() ? html : `<div class="page">${html ? html : r.name === 'ordinadors' ? viewComputers() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : r.name === 'activitat' ? viewActivity() : r.name === 'feines' ? viewJobs() : r.name === 'memoria' ? viewMemory() : r.name === 'ajuda' ? viewHelp() : viewSettings()}</div>`;
|
|
510
511
|
if (r.name === 'mobil') bindLocal();
|
|
511
|
-
if (r.name === 'mobil' && machine() && !
|
|
512
|
+
if (r.name === 'mobil' && machine() && !inLocalChat()) loadOllama();
|
|
512
513
|
if (r.name === 'activitat' && machine()) { activity = null; loadActivity(); }
|
|
513
514
|
if (r.name === 'memoria' && machine()) loadMemory();
|
|
514
515
|
if (r.name === 'ordinadors' && machine() && state.connected) loadScreen();
|
|
@@ -552,12 +553,60 @@ function computerCard(m, i) {
|
|
|
552
553
|
// Ajuda sempre a mà: com es fa cada cosa, i què mirar si no connecta.
|
|
553
554
|
function helpPanel(open) {
|
|
554
555
|
const q = (t, body) => `<details><summary>${t}</summary><div class="help-body">${body}</div></details>`;
|
|
556
|
+
const k = (t) => `<span class="mono">${t}</span>`;
|
|
557
|
+
const blk = (t) => `<pre class="cmdb"><code>${t}</code></pre>`;
|
|
558
|
+
const rows = (list) => `<div class="cmdlist">${list.map(([c, d]) => `<div><span class="mono">${c}</span><span>${d}</span></div>`).join('')}</div>`;
|
|
559
|
+
const grp = (t) => `<h3 class="help-grp">${t}</h3>`;
|
|
555
560
|
return `<section class="panel help" id="help"><header><h2>Com es fa</h2></header><div class="body">
|
|
556
|
-
${
|
|
557
|
-
${q('
|
|
558
|
-
${q('
|
|
559
|
-
${q('
|
|
560
|
-
${q('
|
|
561
|
+
${grp('Per començar')}
|
|
562
|
+
${q('Què és nio', `<p>nio et deixa donar feina als agents d’IA del teu ordinador (Claude Code, Codex, Grok, Antigravity i Ollama) des del mòbil. Els agents <b>treballen a l’ordinador o al servidor</b>, amb els teus comptes. El mòbil és el comandament: hi segueixes la feina en directe i aproves el que facin.</p>`)}
|
|
563
|
+
${q('Connectar aquest ordinador', `<p>Al terminal, des de la carpeta dels teus projectes, executa:</p>${blk('npx nio-cat')}<p>S’obre nio.cat ja connectat. Cal Node.js 18 o més nou. Pots indicar més carpetes: ${k('npx nio-cat ~/projectes')}.</p>`)}
|
|
564
|
+
${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 ${k('nio-…')}.</p><p>Al mòbil, obre <b>www.nio.cat/app</b> → Ordinadors → Afegeix ordinador, i escaneja el QR o enganxa el codi. Guarda el codi com una contrasenya: qui el té, pot manar a l’ordinador.</p><p>Per connectar-t’hi des de qualsevol xarxa cal ${k('cloudflared')} a l’ordinador (${k('brew install cloudflared')}). Sense ell, el mòbil ha d’estar a la mateixa wifi. Si reinicies nio, l’adreça del túnel canvia: torna a escanejar el codi.</p>`)}
|
|
565
|
+
${q('Afegir un servidor (VPS) amb Claude Code', `<p>Ordinadors → Afegeix ordinador → <b>Servidor (SSH)</b> → escriu ${k('usuari@servidor')}. L’ordinador hi entra, hi engega nio i el servidor surt a la llista. Després, prem <b>Continua al mòbil</b> amb el servidor actiu.</p><p>Des del terminal també es pot: ${k('npx nio-cat ssh usuari@servidor')}.</p>`)}
|
|
566
|
+
${q('Què cal al servidor', `<ul><li>Entrar-hi per SSH sense contrasenya. Si no, executa ${k('ssh-copy-id usuari@servidor')} 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 ${k('claude')} un cop.</li><li>Linux amb ${k('curl')} o ${k('wget')} (nio hi instal·la ${k('cloudflared')} sol).</li></ul>`)}
|
|
567
|
+
|
|
568
|
+
${grp('Treballar amb els agents')}
|
|
569
|
+
${q('Crear una sessió', `<p>A <b>Sessions</b>, prem <b>Nova sessió</b>: tria l’agent, la carpeta (hi ha un explorador de carpetes de l’ordinador) i el nivell de permisos. Escriu què vols i l’agent es posa a treballar. La resposta et va arribant en directe.</p><p>També pots <b>clonar un repositori de GitHub</b> des de Nova sessió: nio fa servir el ${k('gh')} de l’ordinador (cal haver fet ${k('gh auth login')}).</p>`)}
|
|
570
|
+
${q('Els tres nivells de permisos', `<ul><li><b>Només llegir</b>: l’agent llegeix i proposa, però no toca cap fitxer.</li><li><b>Demana permís</b>: llegeix sol; abans d’editar o executar alguna cosa, t’ho pregunta (Claude Code).</li><li><b>Via lliure</b>: treballa sense preguntar. Fes-ho servir només en carpetes de confiança.</li></ul><p>Es pot canviar enmig d’una sessió amb ${k('/mode')}.</p>`)}
|
|
571
|
+
${q('Aprovacions', `<p>Quan un agent vol editar un fitxer o executar una ordre, surt una targeta amb el que farà. <b>Aprova</b> o <b>rebutja</b> amb un toc. A <b>Aprovacions</b> tens totes les pendents de totes les sessions juntes.</p>`)}
|
|
572
|
+
${q('Comandes amb «/»', `<p>Al missatge d’una sessió, escriu ${k('/')} i surt la llista amb suggeriments. Les de nio funcionen sempre; les de l’agent (comandes i skills) se li envien tal qual.</p>${rows([
|
|
573
|
+
['/mode', 'Canvia els permisos: ' + k('l') + ' llegir, ' + k('p') + ' preguntar, ' + k('t') + ' tot'],
|
|
574
|
+
['/model', 'Canvia de model'],
|
|
575
|
+
['/stop', 'Atura l’agent ara mateix'],
|
|
576
|
+
['/nova', 'Nova sessió amb el mateix agent i carpeta'],
|
|
577
|
+
['/carpeta', 'Canvia la carpeta de la sessió'],
|
|
578
|
+
['/git', 'Git i GitHub: veure canvis, fer commit i crear un PR'],
|
|
579
|
+
['/nom', 'Posa nom a la sessió (' + k('/nom Feina web') + ')'],
|
|
580
|
+
['/opcions', 'Opcions de la sessió'],
|
|
581
|
+
['/ajuda', 'Llista de comandes']
|
|
582
|
+
])}<p>Exemple: ${k('/mode p')} passa la sessió a «Demana permís».</p>`)}
|
|
583
|
+
${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>`)}
|
|
584
|
+
${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>`)}
|
|
585
|
+
${q('Veu', `<p>A <b>Ajustos → Veu</b> pots posar la teva clau d’ElevenLabs per <b>dictar</b> els missatges amb el micròfon i <b>escoltar</b> les respostes. La clau es queda només en aquest navegador.</p>`)}
|
|
586
|
+
|
|
587
|
+
${grp('Eines')}
|
|
588
|
+
${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>`)}
|
|
589
|
+
${q('Pissarra', `<p>Un llenç infinit per fer diagrames a mà alçada: fluxos, mapes mentals i organigrames. Hi arribes des de <b>Més → Pissarra</b> (al mòbil) o al menú (a l’ordinador).</p><ul><li><b>Eines</b>: selecciona, mou el llenç, rectangle, el·lipse, rombe, fletxa, línia, llapis, text i goma. El cadenat manté l’eina activa.</li><li><b>Zoom amb dos dits</b>: al mòbil, pessiga sobre el llenç; amb els dos dits alhora també el mous. A l’ordinador, pessiga al trackpad o fes ${k('Ctrl')} + roda del ratolí.</li><li><b>Moure’s</b>: amb un dit, a l’espai buit (eina Selecciona), o amb ${k('Espai')} + arrossegar, o la roda del ratolí.</li><li><b>Text</b>: doble toc o doble clic per editar; doble toc a l’espai buit crea un text nou.</li><li><b>Zoom ràpid</b>: la xifra del percentatge torna al 100%; el botó de les cantonades ho enquadra tot.</li><li><b>Desfés / refés</b>: els botons de dalt a l’esquerra, o ${k('Ctrl Z')} i ${k('Ctrl ⇧ Z')}.</li><li><b>Estil</b>: color, gruix i traç (al mòbil, el botó de la paleta). Menú ⋯: exporta a PNG, SVG o JSON, i quadrícula.</li></ul>`)}
|
|
590
|
+
${q('Demanar-li el dibuix a en Nio', `<p>Prem <b>Nio</b> (dalt a la dreta) per obrir el xat de la pissarra. Descriu què vols i ho dibuixa. Exemples:</p>${rows([
|
|
591
|
+
['Flux: A -> B -> C', 'Una fletxa per línia; afegeix ' + k(': etiqueta') + ' si vols'],
|
|
592
|
+
['Mapa mental de X: a, b, c', 'Tema central amb branques'],
|
|
593
|
+
['Organigrama: Direcció > Finances, Operacions', 'Qui depèn de qui'],
|
|
594
|
+
['Afegeix un rombe “Quadra?”', 'Afegeix una figura'],
|
|
595
|
+
['Connecta Banc amb ERP', 'Uneix dues figures amb una fletxa'],
|
|
596
|
+
['Pinta Banc de verd', 'Canvia el color'],
|
|
597
|
+
['Reanomena Banc a Caixa', 'Canvia el text'],
|
|
598
|
+
['Esborra ERP', 'Treu una figura'],
|
|
599
|
+
['Ordena-ho · Centra · Desfés', 'Ordena, enquadra o desfà'],
|
|
600
|
+
['Plantilla agent', 'També ' + k('Plantilla d’aprovació') + ' i ' + k('Plantilla conciliació')],
|
|
601
|
+
['Exporta PNG', 'També ' + k('SVG') + ' o ' + k('JSON')]
|
|
602
|
+
])}<p>Al desplegable <b>Amb</b> tries què t’entén: les <b>ordres ràpides</b> (sense gastar res) o un model.</p>`)}
|
|
603
|
+
${q('Xat local', `<p>A <b>Més → Xat local</b> tens un model petit que funciona <b>dins del navegador del mòbil</b>, sense ordinador ni connexió un cop descarregat. Necessita un navegador amb WebGPU. Si la GPU del mòbil es reinicia, el xat es recupera sol.</p>`)}
|
|
604
|
+
${q('Models d’Ollama', `<p>A <b>Models</b> veus els models d’Ollama de l’ordinador, en pots descarregar de nous amb el progrés en directe, esborrar-los i obrir-hi sessions. Funcionen al teu ordinador i no gasten res.</p>`)}
|
|
605
|
+
${q('Buscador i dreceres', `<p>Prem ${k('Ctrl K')} (o ${k('⌘ K')}) per obrir el buscador d’accions: nova sessió, afegir ordinador, configurar la veu, fer el tutorial… Al mòbil, llisca cap als costats per canviar de pàgina.</p>`)}
|
|
606
|
+
|
|
607
|
+
${grp('Si alguna cosa no va')}
|
|
608
|
+
${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 ${k('npx nio-cat --local')}, o fes servir Chrome.</li><li><b>La clau no és vàlida</b>: torna a afegir l’ordinador amb el codi nou. Per invalidar tots els codis antics: ${k('npx nio-cat reset-token')}.</li><li><b>Quins agents té l’ordinador?</b> ${k('npx nio-cat doctor')} els llista.</li></ul>`)}
|
|
609
|
+
${q('Seguretat', `<ul><li>L’enllaç de connexió porta una clau aleatòria que el navegador no envia a cap servidor web.</li><li>Cap dada no passa per servidors de nio: el mòbil parla directament amb el teu ordinador.</li><li>Totes les crides demanen la clau, que només tens tu.</li></ul>`)}
|
|
561
610
|
${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>`)}
|
|
562
611
|
</div></section>`;
|
|
563
612
|
}
|
|
@@ -739,9 +788,28 @@ function renameSheet(i) {
|
|
|
739
788
|
};
|
|
740
789
|
});
|
|
741
790
|
}
|
|
791
|
+
async function pantallaTab() {
|
|
792
|
+
if (!machine() || !state.connected) return toast('Primer connecta un ordinador.', 4000);
|
|
793
|
+
let caps = state.info?.control;
|
|
794
|
+
try { caps = await api('/api/control'); if (state.info) state.info.control = caps; } catch (e) { return toast(e.message, 4500); }
|
|
795
|
+
const d = caps?.escriptori;
|
|
796
|
+
if (d?.enabled) return remoteOpen();
|
|
797
|
+
const body = !d?.installed
|
|
798
|
+
? `<p class="small">Per veure i controlar l’escriptori cal instal·lar <span class="mono">agent-desktop</span> a ${esc(machine().name)}:</p><p class="mono small">npm i -g agent-desktop</p>`
|
|
799
|
+
: d.ready === false ? `<p class="small">${esc(d.why || 'L’escriptori encara no està a punt.')}</p>`
|
|
800
|
+
: `<p class="small">Activa «Apps de l’escriptori» a ${esc(machine().name)} per veure la pantalla i controlar-la des del mòbil. Cada acció dels agents continuarà demanant la teva aprovació.</p>`;
|
|
801
|
+
openSheet(`<h2>Pantalla</h2><div class="stack">${body}<div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Ara no</button>${d?.installed && d.ready !== false ? '<button class="btn ink" id="pantOn" type="button">Activa i obre</button>' : ''}</div></div>`, (sh) => {
|
|
802
|
+
const go = sh.querySelector('#pantOn'); if (!go) return;
|
|
803
|
+
go.onclick = async () => {
|
|
804
|
+
go.disabled = true;
|
|
805
|
+
try { const r = await api('/api/control', { method: 'PUT', body: { escriptori: true } }); if (state.info) state.info.control = r; closeSheet(); remoteOpen(); }
|
|
806
|
+
catch (err) { toast(err.message, 6000); go.disabled = false; }
|
|
807
|
+
};
|
|
808
|
+
});
|
|
809
|
+
}
|
|
742
810
|
function moreSheet() {
|
|
743
811
|
const item = (k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`;
|
|
744
|
-
openSheet(`<h2>Més</h2><div class="list">${['mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].map(item).join('')}</div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
|
|
812
|
+
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(); }; });
|
|
745
813
|
}
|
|
746
814
|
|
|
747
815
|
/* ---------- Sessions */
|
|
@@ -772,6 +840,7 @@ async function manageDelete() {
|
|
|
772
840
|
manage = fail ? new Set(ids.filter((id) => state.sessions.has(id))) : null;
|
|
773
841
|
toast(fail ? `No he pogut esborrar ${fail} sessions` : `${ids.length} sessions esborrades`); render();
|
|
774
842
|
}
|
|
843
|
+
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();
|
|
775
844
|
function viewSessions() {
|
|
776
845
|
const list = [...state.sessions.values()].sort((a, b) => b.updated - a.updated);
|
|
777
846
|
const many = store.machines.length > 1, mach = machine();
|
|
@@ -787,7 +856,7 @@ function viewSessions() {
|
|
|
787
856
|
<${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' : ''}">
|
|
788
857
|
<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>
|
|
789
858
|
<div class="title">${esc(s.title || 'Sessió nova')}</div>
|
|
790
|
-
${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(s.last)}</div>` : ''}
|
|
859
|
+
${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(plain(s.last))}</div>` : ''}
|
|
791
860
|
<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>${s.cost ? `<span>·</span><span>${money(s.cost)}</span>` : ''}</div>
|
|
792
861
|
</${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>`}`;
|
|
793
862
|
}
|
|
@@ -935,28 +1004,45 @@ async function newJobSheet() {
|
|
|
935
1004
|
try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
|
|
936
1005
|
const agents = (state.info?.agents || []).filter((a) => a.available && ['claude', 'codex'].includes(a.id));
|
|
937
1006
|
const prev = (jobsData?.jobs || []).filter((j) => ['pendent', 'en curs', 'feta'].includes(j.state)).slice(0, 15);
|
|
938
|
-
const sel = { agent: 'auto', mode: 'preguntar', when: 'ara' };
|
|
1007
|
+
const sel = { agent: 'auto', mode: 'preguntar', when: 'ara', mins: 0 };
|
|
939
1008
|
openSheet(`<h2>Nova feina</h2>
|
|
940
1009
|
<label class="f"><span>Què ha de fer?</span><textarea class="field" id="jobPrompt" rows="3" placeholder="Per exemple: revisa les issues noves i fes-me’n un resum"></textarea></label>
|
|
941
1010
|
<div class="grid-2"><label class="f"><span>Agent</span><select id="jobAgent"><option value="auto">Automàtic (regles)</option>${agents.map((a) => `<option value="${a.id}">${esc(a.name)}</option>`).join('')}</select></label>
|
|
942
1011
|
<label class="f"><span>Carpeta</span><select id="jobCwd">${projects.map((p) => `<option value="${esc(p.path)}">${esc(p.name)}</option>`).join('')}</select></label></div>
|
|
943
1012
|
<div class="stack"><div class="section-title" style="margin:0">Permisos</div><div class="seg" id="jobMode" role="group">${Object.entries(MODES).map(([k, m]) => `<button type="button" data-jmode="${k}" aria-pressed="${k === sel.mode}">${m.label}</button>`).join('')}</div></div>
|
|
944
|
-
<div class="
|
|
945
|
-
|
|
1013
|
+
<div class="stack"><div class="section-title" style="margin:0">Quan es comença</div>
|
|
1014
|
+
<div class="seg" id="jobWhen" role="group" aria-label="Quan es comença"><button type="button" data-w="ara" aria-pressed="true">Ara</button><button type="button" data-w="timer" aria-pressed="false">D’aquí a…</button><button type="button" data-w="mes" aria-pressed="false">A una hora</button>${prev.length ? '<button type="button" data-w="after" aria-pressed="false">Després d’una altra</button>' : ''}<button type="button" data-w="cron" aria-pressed="false">Repetida</button></div>
|
|
1015
|
+
<div id="jobWhenX" class="stack"></div></div>
|
|
946
1016
|
${agents.some((a) => a.id === 'codex') ? '<label class="row check"><input type="checkbox" id="jobReview"> <span>Quan acabi, que Codex revisi els canvis</span></label>' : ''}
|
|
947
1017
|
<div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" id="jobGo" type="button">Posa-la a la cua</button></div>`, (sh) => {
|
|
948
1018
|
const x = $('#jobWhenX', sh);
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1019
|
+
const TIMES = [[5, '5 min'], [15, '15 min'], [30, '30 min'], [60, '1 h'], [180, '3 h'], [480, '8 h']];
|
|
1020
|
+
const hm = (t) => new Date(t).toLocaleString('ca', { weekday: 'short', hour: '2-digit', minute: '2-digit' });
|
|
1021
|
+
const upd = () => {
|
|
1022
|
+
const go = $('#jobGo', sh);
|
|
1023
|
+
if (sel.when === 'timer') { const out = $('#jobTimerOut', sh); out.innerHTML = sel.mins ? `Començarà a les <b>${hm(Date.now() + sel.mins * 60000)}</b>` : 'Tria quant ha d’esperar'; go.textContent = sel.mins ? `Envia-la d’aquí a ${fmtMins(sel.mins)}` : 'Posa-la a la cua'; }
|
|
1024
|
+
else go.textContent = { ara: 'Posa-la a la cua', mes: 'Programa-la', after: 'Encadena-la', cron: 'Programa-la' }[sel.when];
|
|
1025
|
+
};
|
|
1026
|
+
const fmtMins = (m) => (m % 60 === 0 && m >= 60 ? `${m / 60} h` : m > 60 ? `${Math.floor(m / 60)} h ${m % 60} min` : `${m} min`);
|
|
1027
|
+
const setWhen = (w) => {
|
|
1028
|
+
sel.when = w; $$('#jobWhen [data-w]', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.w === w));
|
|
1029
|
+
x.innerHTML = w === 'timer' ? `<div class="row" style="flex-wrap:wrap;gap:8px" id="jobTimes">${TIMES.map(([m, l]) => `<button type="button" class="chip" data-m="${m}" aria-pressed="${m === sel.mins}">${l}</button>`).join('')}</div>
|
|
1030
|
+
<div class="row" style="flex-wrap:nowrap"><input class="field" type="number" id="jobMinsN" min="1" max="9999" inputmode="numeric" placeholder="Altre" style="max-width:110px" aria-label="Quantitat"><select id="jobMinsU" aria-label="Unitat"><option value="1">minuts</option><option value="60">hores</option><option value="1440">dies</option></select></div>
|
|
1031
|
+
<p class="muted small" id="jobTimerOut"></p>`
|
|
1032
|
+
: w === 'mes' ? '<label class="f"><span>Dia i hora</span><input type="datetime-local" id="jobAt"></label>'
|
|
1033
|
+
: w === 'after' ? `<label class="f"><span>Quan acabi</span><select id="jobAfter">${prev.map((j) => `<option value="${esc(j.id)}">${esc(j.title)} (${esc(j.state)})</option>`).join('')}</select></label>`
|
|
1034
|
+
: w === 'cron' ? `<label class="f"><span>Cada quan</span><select id="jobCron">${CRON_PRESETS.slice(1).map(([c, l]) => `<option value="${c}">${l}</option>`).join('')}</select></label><input type="text" id="jobCronX" placeholder="min hora dia mes dia-setmana" hidden autocapitalize="off" spellcheck="false" class="mono">` : '';
|
|
954
1035
|
const c = $('#jobCron', sh); if (c) c.onchange = () => ($('#jobCronX', sh).hidden = c.value !== 'custom');
|
|
1036
|
+
upd();
|
|
955
1037
|
};
|
|
1038
|
+
$('#jobWhen', sh).addEventListener('click', (e) => { const b = e.target.closest('[data-w]'); if (b) setWhen(b.dataset.w); });
|
|
1039
|
+
x.addEventListener('click', (e) => { const b = e.target.closest('[data-m]'); if (!b) return; sel.mins = Number(b.dataset.m); $$('[data-m]', x).forEach((c) => c.setAttribute('aria-pressed', c === b)); $('#jobMinsN', sh).value = ''; upd(); });
|
|
1040
|
+
x.addEventListener('input', (e) => { if (!e.target.matches('#jobMinsN, #jobMinsU')) return; const n = Number($('#jobMinsN', sh).value); sel.mins = n > 0 ? Math.round(n * Number($('#jobMinsU', sh).value)) : 0; $$('[data-m]', x).forEach((c) => c.setAttribute('aria-pressed', false)); upd(); });
|
|
956
1041
|
sh.addEventListener('click', (e) => { const m = e.target.closest('[data-jmode]'); if (m) { sel.mode = m.dataset.jmode; $$('[data-jmode]', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.jmode === sel.mode)); } if (e.target.closest('[data-close]')) closeSheet(); });
|
|
957
1042
|
$('#jobGo', sh).onclick = async () => {
|
|
958
1043
|
const prompt = $('#jobPrompt', sh).value.trim(); if (!prompt) return toast('Explica què ha de fer la feina');
|
|
959
1044
|
const body = { prompt, agent: $('#jobAgent', sh).value, cwd: $('#jobCwd', sh).value, mode: sel.mode };
|
|
1045
|
+
if (sel.when === 'timer') { if (!sel.mins) return toast('Tria quant ha d’esperar'); body.at = Date.now() + sel.mins * 60000; }
|
|
960
1046
|
if (sel.when === 'mes') { const v = $('#jobAt', sh)?.value; if (!v) return toast('Tria el dia i l’hora'); body.at = new Date(v).getTime(); }
|
|
961
1047
|
if (sel.when === 'after') body.after = $('#jobAfter', sh).value;
|
|
962
1048
|
if (sel.when === 'cron') body.cron = $('#jobCron', sh).value === 'custom' ? $('#jobCronX', sh).value.trim() : $('#jobCron', sh).value;
|
|
@@ -964,7 +1050,7 @@ async function newJobSheet() {
|
|
|
964
1050
|
try {
|
|
965
1051
|
const j = await api('/api/jobs', { method: 'POST', body });
|
|
966
1052
|
if ($('#jobReview', sh)?.checked) await api('/api/jobs', { method: 'POST', body: { agent: 'codex', cwd: j.cwd, mode: 'llegir', after: j.id, title: `Revisió: ${j.title}`, prompt: 'Revisa els canvis que s’acaben de fer en aquesta carpeta (git diff i fitxers nous). Explica què està bé, què es pot millorar i si hi ha cap error. Context de la feina anterior:\n\n{{anterior}}' } });
|
|
967
|
-
closeSheet(); toast(body.cron ? 'Feina programada' : 'Feina a la cua'); loadJobs();
|
|
1053
|
+
closeSheet(); toast(body.cron || body.at ? 'Feina programada' : 'Feina a la cua'); loadJobs();
|
|
968
1054
|
} catch (e) { toast(e.message, 4200); $('#jobGo', sh).disabled = false; }
|
|
969
1055
|
};
|
|
970
1056
|
if (!isTouch) setTimeout(() => $('#jobPrompt', sh)?.focus(), 80);
|
|
@@ -1234,11 +1320,17 @@ function quickFolders(extra = []) {
|
|
|
1234
1320
|
}
|
|
1235
1321
|
const folderChip = (f) => `<button type="button" class="chip ${f.tag === 'fav' ? 'fav' : ''}" data-fs="${esc(f.path)}" title="${esc(f.path)}">${f.tag === 'fav' ? '★ ' : f.tag === 'last' ? '↺ ' : ''}${esc(f.name)}</button>`;
|
|
1236
1322
|
const favBtn = (p) => `<button class="chip" type="button" data-fav aria-pressed="${getFavs().includes(p)}">${getFavs().includes(p) ? '★ Preferida' : '☆ Desa com a preferida'}</button>`;
|
|
1323
|
+
function crumbBar(d) {
|
|
1324
|
+
const sep = d.path.includes('\\') && !d.path.startsWith('/') ? '\\' : '/', parts = d.path.split(/[\\/]/).filter(Boolean);
|
|
1325
|
+
const pathAt = (i) => (sep === '/' ? '/' : '') + parts.slice(0, i + 1).join(sep) + (sep === '\\' && i === 0 ? '\\' : '');
|
|
1326
|
+
const from = Math.max(0, parts.length - 3);
|
|
1327
|
+
const segs = parts.slice(from).map((n, k) => { const i = from + k, cur = i === parts.length - 1; return `<button type="button" class="crumb${cur ? ' cur' : ''}" data-fs="${esc(pathAt(i))}"${cur ? ' aria-current="page"' : ''}>${esc(n)}</button>`; });
|
|
1328
|
+
return `<div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(d.parent || '')}" ${d.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><nav class="crumbs mono small" aria-label="Camí" title="${esc(d.path)}"><button type="button" class="crumb" data-fs="${esc(d.home)}" aria-label="Carpeta d’inici">~</button>${from > 0 ? '<span class="sl">/</span><span class="sl">…</span>' : ''}${segs.map((x) => `<span class="sl">/</span>${x}`).join('')}</nav></div>`;
|
|
1329
|
+
}
|
|
1237
1330
|
function folderSheet(s) {
|
|
1238
1331
|
let d = null;
|
|
1239
1332
|
const draw = () => {
|
|
1240
|
-
|
|
1241
|
-
$('#fsBody').innerHTML = !d ? '<p class="muted small">Carregant…</p>' : `<div class="row">${favBtn(d.path)}${quickFolders().slice(0, 8).map(folderChip).join('')}</div><div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(d.parent || '')}" ${d.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><button class="chip" type="button" data-fs="${esc(d.home)}">Inici</button><span class="fs-path mono small" title="${esc(d.path)}">${esc(crumbs.slice(-2).join(' / ') || '/')}</span></div>
|
|
1333
|
+
$('#fsBody').innerHTML = !d ? '<p class="muted small">Carregant…</p>' : `<div class="row">${favBtn(d.path)}${quickFolders().filter((f) => f.tag === 'fav').map(folderChip).join('')}</div>${crumbBar(d)}
|
|
1242
1334
|
<div class="proj-list fs-list">${d.dirs.map((x) => `<button type="button" class="proj" data-fs="${esc(x.path)}"><span class="ic">›</span><span class="grow"><div ${x.hidden ? 'class="muted"' : ''}>${esc(x.name)}</div></span></button>`).join('') || '<p class="muted small">No hi ha subcarpetes. Pots triar aquesta.</p>'}</div>`;
|
|
1243
1335
|
const ok = $('#fsOk'); if (ok) { ok.disabled = !d || d.path === s.cwd; ok.textContent = d && d.path === s.cwd ? 'Ja hi és' : 'Treballa aquí'; }
|
|
1244
1336
|
};
|
|
@@ -1335,7 +1427,7 @@ function focusMain() {
|
|
|
1335
1427
|
if (isTouch || $('#sheet')?.open || $('#palette')?.open) return;
|
|
1336
1428
|
const r = state.route.name;
|
|
1337
1429
|
if (r === 'session') $('#msg')?.focus({ preventScroll: true });
|
|
1338
|
-
else if (r === 'mobil' &&
|
|
1430
|
+
else if (r === 'mobil' && inLocalChat()) $('#lmsg')?.focus({ preventScroll: true });
|
|
1339
1431
|
}
|
|
1340
1432
|
$('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
|
|
1341
1433
|
$('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
|
|
@@ -1415,14 +1507,13 @@ async function newSessionSheet(preset = {}) {
|
|
|
1415
1507
|
const VIEWS_ = [
|
|
1416
1508
|
() => `<p class="wiz-q">Quin agent vols fer servir?</p><div class="picker">${[...agents].sort((x, y) => (y.available ? 1 : 0) - (x.available ? 1 : 0)).map((a) => `<button type="button" class="pick ${a.available ? '' : 'off'}" data-agent-id="${esc(a.id)}" aria-pressed="${a.id === sel.agent}"><div class="row between"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="sub">${esc(a.available ? a.version || 'Disponible' : 'No instal·lat')}</div></button>`).join('')}</div>`,
|
|
1417
1509
|
() => {
|
|
1418
|
-
const
|
|
1419
|
-
const qf = quickFolders(projects), favN = getFavs().length, quick = qf.filter((f) => f.tag).concat(qf.filter((f) => !f.tag).slice(0, 6)).slice(0, favN + 8).map(folderChip).join('');
|
|
1510
|
+
const quick = quickFolders().filter((f) => f.tag === 'fav').map(folderChip).join('');
|
|
1420
1511
|
return `<p class="wiz-q">A quina carpeta ha de treballar?</p>
|
|
1421
1512
|
<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>
|
|
1422
1513
|
<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>' : ''}${favBtn(fsd?.path || sel.cwd)}</div>
|
|
1423
1514
|
<div class="row"><button class="chip" type="button" data-gh>${gh ? 'Torna a les carpetes' : 'Clona de GitHub'}</button>${quick}</div>
|
|
1424
1515
|
${gh ? (gh.msg ? `<p class="muted small">${gh.msg}</p>` : '') + (gh.repos ? `<input type="search" id="ghQ" value="${esc(gh.q)}" placeholder="Cerca entre els teus repositoris${gh.user ? ' (' + esc(gh.user) + ')' : ''}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Cerca un repositori"><div class="proj-list fs-list" id="ghList">${ghList()}</div>` : '')
|
|
1425
|
-
: !fsd ? '<p class="muted small">Carregant…</p>' :
|
|
1516
|
+
: !fsd ? '<p class="muted small">Carregant…</p>' : `${crumbBar(fsd)}
|
|
1426
1517
|
<div class="proj-list fs-list">${fsd.dirs.map((d) => `<button type="button" class="proj" data-fs="${esc(d.path)}"><span class="ic">›</span><span class="grow"><div ${d.hidden ? 'class="muted"' : ''}>${esc(d.name)}</div></span></button>`).join('') || '<p class="muted small">No hi ha subcarpetes. Pots triar aquesta.</p>'}</div>`}`;
|
|
1427
1518
|
},
|
|
1428
1519
|
() => {
|
|
@@ -1588,34 +1679,27 @@ async function loadOllama(force) {
|
|
|
1588
1679
|
paintModels();
|
|
1589
1680
|
}
|
|
1590
1681
|
function ollamaSection() {
|
|
1591
|
-
if (!machine()) return `<
|
|
1592
|
-
return `<div class="
|
|
1682
|
+
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>Només en un ordinador</b><div class="muted small" style="margin-top:4px">Els models d’Ollama són més grans i capaços. Connecta’n un per veure’ls i descarregar-ne.</div><a class="btn block" style="margin-top:10px" href="#/ordinadors">Ordinadors</a></div></div>`;
|
|
1683
|
+
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>`;
|
|
1593
1684
|
}
|
|
1594
1685
|
function pctOf(j) { return j.total ? Math.min(100, Math.round((j.completed / j.total) * 100)) : 0; }
|
|
1686
|
+
const OLLAMA_START = 'Engega Ollama en aquest ordinador: comprova que està instal·lat (si no, digue-m’ho abans d’instal·lar res) i executa «ollama serve» en segon pla. Quan respongui a http://localhost:11434, confirma-m’ho.';
|
|
1595
1687
|
function paintModels() {
|
|
1596
1688
|
const box = $('#modelsBody'); if (!box || !state.ollama) return;
|
|
1597
1689
|
const o = state.ollama, have = new Set(o.models.map((m) => m.name));
|
|
1598
1690
|
if (!o.available) {
|
|
1599
|
-
box.innerHTML = `<div class="
|
|
1691
|
+
box.innerHTML = `<div class="card"><div class="row between"><b>Ollama no està engegat</b><i class="dot"></i></div><div class="muted small" style="margin-top:4px">Un agent pot engegar-lo per tu amb <span class="mono">ollama serve</span>. Si no el tens, l’instal·les des d’<a href="https://ollama.com" target="_blank" rel="noopener">ollama.com</a>.</div><div class="row" style="margin-top:10px"><button class="btn ink" data-act="ollama-start" type="button">Engega’l amb un agent</button><button class="btn" data-act="ollama-refresh" type="button">Torna-ho a comprovar</button></div></div>`;
|
|
1600
1692
|
return;
|
|
1601
1693
|
}
|
|
1602
1694
|
const pulling = o.pulling || [];
|
|
1603
|
-
box.innerHTML = `<div class="
|
|
1604
|
-
<div class="
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
</div>
|
|
1611
|
-
<div class="stack">
|
|
1612
|
-
<section class="panel"><header><h2>Descarrega un model</h2></header><div class="body stack">
|
|
1613
|
-
<form class="row" id="pullForm" style="flex-wrap:nowrap"><input type="text" id="pullIn" placeholder="Nom del model, p. ex. qwen3:4b" autocapitalize="off" spellcheck="false" aria-label="Nom del model"><button class="btn ink" type="submit">${svg(ICON.download)}Descarrega</button></form>
|
|
1614
|
-
<p class="muted small">Tens tots els noms a <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a>. Les mides són aproximades.</p>
|
|
1615
|
-
<div class="list">${OLLAMA_PICKS.map((p) => { const busy = pulling.some((j) => j.name === p.name), got = have.has(p.name); return `
|
|
1616
|
-
<button class="card" type="button" ${got || busy ? 'disabled' : `data-act="ollama-pull" data-name="${p.name}"`} style="${got ? 'opacity:.6' : ''}"><div class="row between"><b class="mono" style="font-size:14px">${p.name}</b><span class="muted small">${got ? 'Instal·lat' : busy ? 'Descarregant…' : p.size}</span></div><div class="muted small" style="margin-top:4px">${p.note}</div></button>`; }).join('')}</div>
|
|
1617
|
-
</div></section>
|
|
1618
|
-
</div>
|
|
1695
|
+
box.innerHTML = `<div class="stack-lg">
|
|
1696
|
+
<div class="list">${o.models.length ? o.models.map((m) => `<div class="card model-card"><div class="row between"><b class="mono" style="font-size:14px">${esc(m.name)}</b><span class="row" style="gap:6px">${(o.running || []).includes(m.name) ? '<span class="chip waiting">A memòria</span>' : ''}<button class="btn sm" data-act="ollama-chat" data-name="${esc(m.name)}" type="button">Xat</button><button class="icon-btn" data-act="ollama-del" data-name="${esc(m.name)}" type="button" aria-label="Esborra ${esc(m.name)}">${svg(ICON.trash)}</button></span></div><div class="muted small" style="margin-top:4px">${[m.params, m.quant, bytes(m.size)].filter(Boolean).map(esc).join(' · ')}</div></div>`).join('') : '<p class="muted small">Encara no n’hi ha cap model. Tria’n un de sota.</p>'}</div>
|
|
1697
|
+
${pulling.map((j) => `<div class="card stack" data-pull="${esc(j.name)}"><div class="row between"><b class="mono" style="font-size:14px">${esc(j.name)}</b><button class="btn sm ghost" data-act="ollama-cancel" data-name="${esc(j.name)}" type="button">Cancel·la</button></div><div class="progress"><i style="width:${pctOf(j)}%"></i></div><div class="muted small mono" data-st>${esc(j.status)}${j.total ? ` · ${bytes(j.completed)} de ${bytes(j.total)}` : ''}</div></div>`).join('')}
|
|
1698
|
+
<details class="card"><summary><b>Descarrega un model</b></summary><div class="stack" style="margin-top:10px">
|
|
1699
|
+
<form class="row" id="pullForm" style="flex-wrap:nowrap"><input type="text" id="pullIn" placeholder="Nom, p. ex. qwen3:4b" autocapitalize="off" spellcheck="false" aria-label="Nom del model"><button class="btn ink" type="submit">${svg(ICON.download)}Descarrega</button></form>
|
|
1700
|
+
<div class="list">${OLLAMA_PICKS.map((p) => { const busy = pulling.some((j) => j.name === p.name), got = have.has(p.name); return `<button class="card" type="button" ${got || busy ? 'disabled' : `data-act="ollama-pull" data-name="${p.name}"`} style="${got ? 'opacity:.6' : ''}"><div class="row between"><b class="mono" style="font-size:14px">${p.name}</b><span class="muted small">${got ? 'Instal·lat' : busy ? 'Descarregant…' : p.size}</span></div><div class="muted small" style="margin-top:4px">${p.note}</div></button>`; }).join('')}</div>
|
|
1701
|
+
<p class="muted small">Tots els noms a <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a>.</p>
|
|
1702
|
+
</div></details>
|
|
1619
1703
|
</div>`;
|
|
1620
1704
|
}
|
|
1621
1705
|
const pullDone = new Set(); // descàrregues que han acabat abans que respongués la petició
|
|
@@ -1660,6 +1744,7 @@ window.NioBrains = {
|
|
|
1660
1744
|
list() {
|
|
1661
1745
|
const out = [{ id: 'rules', label: 'Ordres ràpides' }];
|
|
1662
1746
|
if (machine() && state.connected) (state.ollama?.models || []).forEach((m) => out.push({ id: 'ollama:' + m.name, label: `Ollama · ${m.name}` }));
|
|
1747
|
+
if (machine() && state.connected) (state.info?.agents || []).filter((x) => x.available && !['ollama', 'demo'].includes(x.id)).forEach((x) => out.push({ id: 'agent:' + x.id, label: `${AGENT_NAMES[x.id] || x.id} (agent)` }));
|
|
1663
1748
|
if (local.engine) out.push({ id: 'local', label: `Xat local · ${LOCAL_MODELS.find((x) => x.id === local.model)?.name || local.model}` });
|
|
1664
1749
|
return out;
|
|
1665
1750
|
},
|
|
@@ -1668,6 +1753,22 @@ window.NioBrains = {
|
|
|
1668
1753
|
if (!machine()) throw new Error('No hi ha cap ordinador connectat');
|
|
1669
1754
|
return (await api('/api/ollama/chat', { method: 'POST', body: { model: id.slice(7), messages, format: 'json' } })).content;
|
|
1670
1755
|
}
|
|
1756
|
+
if (id.startsWith('agent:')) {
|
|
1757
|
+
if (!machine()) throw new Error('No hi ha cap ordinador connectat');
|
|
1758
|
+
const cwd = (await api('/api/projects').catch(() => []))[0]?.path;
|
|
1759
|
+
const prompt = messages.map((m) => (m.role === 'system' ? m.content : m.content)).join('\n\n') + '\n\nRespon només amb el JSON demanat, sense usar cap eina.';
|
|
1760
|
+
const s = await api('/api/sessions', { method: 'POST', body: { agent: id.slice(6), cwd, mode: 'llegir', title: 'Pissarra', text: prompt } });
|
|
1761
|
+
try {
|
|
1762
|
+
for (let i = 0; i < 120; i++) {
|
|
1763
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
1764
|
+
const d = await api(`/api/sessions/${s.id}`);
|
|
1765
|
+
if (d.session.state === 'error') throw new Error(d.session.error || 'L’agent ha fallat');
|
|
1766
|
+
if (d.session.state === 'waiting') { api(`/api/sessions/${s.id}/stop`, { method: 'POST' }).catch(() => {}); throw new Error('L’agent demana permís per a una eina'); }
|
|
1767
|
+
if (d.session.state === 'idle') { const t = d.events.filter((e) => e.kind === 'text').map((e) => e.text).join('\n'); if (t) return t; }
|
|
1768
|
+
}
|
|
1769
|
+
throw new Error('L’agent ha tardat massa');
|
|
1770
|
+
} finally { api(`/api/sessions/${s.id}`, { method: 'DELETE' }).then(() => handle({ type: 'removed', session: s.id })).catch(() => {}); }
|
|
1771
|
+
}
|
|
1671
1772
|
if (id === 'local') {
|
|
1672
1773
|
if (!local.engine) throw new Error('Primer obre un model a «Xat local»');
|
|
1673
1774
|
try {
|
|
@@ -1712,11 +1813,11 @@ const LOCAL_MODELS = [
|
|
|
1712
1813
|
{ id: 'Qwen3-1.7B-q4f16_1-MLC', name: 'Qwen 3 · 1,7B', size: '≈ 1,3 GB', note: 'Va bé amb codi i raonaments curts.' },
|
|
1713
1814
|
{ id: 'Phi-3.5-mini-instruct-q4f16_1-MLC', name: 'Phi 3.5 mini · 3,8B', size: '≈ 2,2 GB', note: 'El més potent. Millor en tauleta o ordinador.' }
|
|
1714
1815
|
];
|
|
1715
|
-
const local = { engine: null, model: null, loading: false, progress: 0, status: '', messages: [], busy: false };
|
|
1816
|
+
const local = { ollama: null, engine: null, model: null, loading: false, progress: 0, status: '', messages: [], busy: false };
|
|
1716
1817
|
const stripThink = (t) => t.replace(/<think>[\s\S]*?(<\/think>|$)/g, '').trimStart();
|
|
1717
1818
|
function viewLocal() {
|
|
1718
1819
|
const hasGpu = 'gpu' in navigator, chosen = local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id);
|
|
1719
|
-
if (!
|
|
1820
|
+
if (!inLocalChat()) {
|
|
1720
1821
|
return `<div class="top"><div><h1>Xat local</h1><p>Un model dins d’aquest dispositiu, per quan no tens l’ordinador a mà. Un cop descarregat, funciona sense connexió i res no surt del dispositiu.</p></div></div>
|
|
1721
1822
|
<h2 class="sec" style="margin-top:0">En aquest dispositiu</h2>
|
|
1722
1823
|
<div class="stack-lg" style="max-width:640px">
|
|
@@ -1726,14 +1827,15 @@ function viewLocal() {
|
|
|
1726
1827
|
</div>
|
|
1727
1828
|
${ollamaSection()}`;
|
|
1728
1829
|
}
|
|
1729
|
-
const info = LOCAL_MODELS.find((m) => m.id === local.model);
|
|
1830
|
+
const info = local.ollama ? { name: local.ollama } : LOCAL_MODELS.find((m) => m.id === local.model);
|
|
1730
1831
|
return `<section class="chat">
|
|
1731
|
-
<div class="chat-head"><span>${bot('idle')}</span><div class="grow"><h1>${esc(info?.name || local.model)}</h1><div class="sub"
|
|
1732
|
-
<div class="mode-bar"><span class="chip"><span class="agent-tag" data-agent="browser">${esc(info?.name || local.model)}</span></span><span class="chip"
|
|
1832
|
+
<div class="chat-head"><span>${bot('idle')}</span><div class="grow"><h1>${esc(info?.name || local.model)}</h1><div class="sub">${local.ollama ? `Ollama a ${esc(machine()?.name || 'l’ordinador')}` : 'Model dins d’aquest dispositiu'}</div></div></div>
|
|
1833
|
+
<div class="mode-bar"><span class="chip"><span class="agent-tag" data-agent="browser">${esc(info?.name || local.model)}</span></span><span class="chip">${local.ollama ? 'A l’ordinador' : 'Al dispositiu'}</span><button class="chip" type="button" data-act="lm-change">Canvia de model</button><button class="chip" type="button" data-act="lm-clear">Comença de nou</button></div>
|
|
1733
1834
|
<div class="feed" id="lfeed">${local.messages.length ? '' : `<div class="empty">${bot('idle')}<h2>Pregunta el que vulguis</h2><p>Va bé per resumir, reescriure textos curts o buscar idees.</p></div>`}</div>
|
|
1734
1835
|
<div class="composer"><div class="inner"><textarea rows="1" id="lmsg" placeholder="Escriu al model…" enterkeyhint="send" aria-label="Missatge per al model"></textarea><button class="send" id="lsend" type="button" aria-label="Envia">${svg(ICON.send)}</button></div></div>
|
|
1735
1836
|
</section>`;
|
|
1736
1837
|
}
|
|
1838
|
+
const inLocalChat = () => !!(local.engine || local.ollama);
|
|
1737
1839
|
function bindLocal() {
|
|
1738
1840
|
const feed = $('#lfeed'); if (!feed) return;
|
|
1739
1841
|
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>`));
|
|
@@ -1768,6 +1870,7 @@ async function recoverLocal(text) {
|
|
|
1768
1870
|
await loadLocal(id);
|
|
1769
1871
|
}
|
|
1770
1872
|
async function loadLocal(id) {
|
|
1873
|
+
local.ollama = null;
|
|
1771
1874
|
local.loading = true; local.progress = 0; local.status = 'Preparant…'; render();
|
|
1772
1875
|
try {
|
|
1773
1876
|
const webllm = await import('https://esm.run/@mlc-ai/web-llm@0.2.86');
|
|
@@ -1790,6 +1893,11 @@ async function sendLocal() {
|
|
|
1790
1893
|
const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out); toBottom(feed);
|
|
1791
1894
|
local.busy = true; local.stopped = false; setLocalBusy(true); let acc = '';
|
|
1792
1895
|
try {
|
|
1896
|
+
if (local.ollama) {
|
|
1897
|
+
const r = await api('/api/ollama/chat', { method: 'POST', body: { model: local.ollama, messages: [{ role: 'system', content: LOCAL_SYSTEM }, ...local.messages.slice(-12)] } });
|
|
1898
|
+
if (!local.stopped) acc = r.content || '';
|
|
1899
|
+
out.innerHTML = md(stripThink(acc)); toBottom(feed);
|
|
1900
|
+
} else {
|
|
1793
1901
|
const chunks = await local.engine.chat.completions.create({
|
|
1794
1902
|
messages: [{ role: 'system', content: LOCAL_SYSTEM }, ...local.messages.slice(-8)], stream: true,
|
|
1795
1903
|
temperature: 0.7, top_p: 0.8, frequency_penalty: 0.6, presence_penalty: 0.4, repetition_penalty: 1.15, max_tokens: 700,
|
|
@@ -1799,12 +1907,13 @@ async function sendLocal() {
|
|
|
1799
1907
|
acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) toBottom(feed);
|
|
1800
1908
|
if (local.stopped) break;
|
|
1801
1909
|
}
|
|
1910
|
+
}
|
|
1802
1911
|
} catch (e) {
|
|
1803
1912
|
if (gpuLost(e) && !acc) { local.messages.pop(); out.remove(); feed.lastElementChild?.remove(); local.busy = false; local.stopped = false; setLocalBusy(false); return recoverLocal(text); }
|
|
1804
1913
|
if (!local.stopped) acc = acc || `Error: ${e.message}`; out.innerHTML = md(stripThink(acc));
|
|
1805
1914
|
}
|
|
1806
1915
|
out.classList.remove('cursor');
|
|
1807
|
-
if (local.stopped) { try { await local.engine.interruptGenerate(); } catch { /* res */ } }
|
|
1916
|
+
if (local.stopped && local.engine) { try { await local.engine.interruptGenerate(); } catch { /* res */ } }
|
|
1808
1917
|
if (gpuLost(acc.replace(/^Error: /, ''))) { local.messages.push({ role: 'assistant', content: 'La GPU del mòbil s’ha reiniciat enmig de la resposta.' }); local.busy = false; local.stopped = false; setLocalBusy(false); return recoverLocal(''); }
|
|
1809
1918
|
local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
|
|
1810
1919
|
}
|
|
@@ -1815,9 +1924,7 @@ const controlOn = () => !!(state.info?.control?.navegador?.enabled || state.info
|
|
|
1815
1924
|
// Aturada d'emergència: sempre a la vista mentre el control estigui actiu.
|
|
1816
1925
|
function renderPanic() {
|
|
1817
1926
|
syncScreenBtn();
|
|
1818
|
-
|
|
1819
|
-
if (!controlOn() || !machine()) { b?.remove(); return; }
|
|
1820
|
-
if (!b) { b = el(`<button class="panic" id="panicBtn" type="button" data-act="panic" aria-label="Aturada d’emergència">${svg(ICON.panic)}<span>Atura-ho tot</span></button>`); document.body.appendChild(b); }
|
|
1927
|
+
$('#panicBtn')?.remove();
|
|
1821
1928
|
}
|
|
1822
1929
|
async function panicNow() {
|
|
1823
1930
|
if (!confirm('S’aturaran tots els agents d’aquest ordinador, es rebutjaran les peticions pendents i es tancarà el navegador.')) return;
|
|
@@ -1862,7 +1969,7 @@ async function loadScreen() {
|
|
|
1862
1969
|
function syncScreenBtn() { const b = $('[data-act=screen-now]'); if (b) b.disabled = !controlOn(); }
|
|
1863
1970
|
function onScreen(msg) { if (screen && !screen.error) { screen.last = msg.shot; { const box = $('#screenBody'); if (box) box.innerHTML = screenHtml(); hydrateShots(box); } } }
|
|
1864
1971
|
/* ---------------------------------------------------------------- pantalla remota (veure i controlar l'ordinador) */
|
|
1865
|
-
const RD = { on: false, ctl: false, mods: new Set(), drag: false, q: 55, blank: 0, err: 0, objUrl: null, chain: Promise.resolve(), sw: 0, tap: null, scroll: null, errAt: 0 };
|
|
1972
|
+
const RD = { on: false, ctl: false, mods: new Set(), drag: false, q: 55, blank: 0, err: 0, hash: '', still: 0, scale: 1, objUrl: null, chain: Promise.resolve(), sw: 0, tap: null, scroll: null, errAt: 0 };
|
|
1866
1973
|
const RD_KEYS = [['Escape', 'Esc'], ['Tab', 'Tab'], ['ArrowUp', '↑'], ['ArrowDown', '↓'], ['ArrowLeft', '←'], ['ArrowRight', '→'], ['Enter', '⏎'], ['Backspace', '⌫']];
|
|
1867
1974
|
const RD_MODS = [['meta', '⌘'], ['alt', '⌥'], ['ctrl', '⌃'], ['shift', '⇧']];
|
|
1868
1975
|
const RD_SPECIAL = /^(Escape|Tab|Arrow(Up|Down|Left|Right)|Home|End|PageUp|PageDown|Delete|F([1-9]|1[0-2]))$/;
|
|
@@ -1870,12 +1977,13 @@ const RD_SPECIAL = /^(Escape|Tab|Arrow(Up|Down|Left|Right)|Home|End|PageUp|PageD
|
|
|
1870
1977
|
async function remoteOpen() {
|
|
1871
1978
|
const m = machine(); if (!m || RD.on) return;
|
|
1872
1979
|
if (!(state.info?.control?.escriptori?.enabled || screen?.caps?.escriptori?.enabled)) return toast('Activa primer «Apps de l’escriptori» en aquest ordinador.', 5000);
|
|
1873
|
-
|
|
1980
|
+
const slow = navigator.connection && (navigator.connection.saveData || /^(slow-2g|2g|3g)$/.test(navigator.connection.effectiveType || '') || navigator.connection.type === 'cellular');
|
|
1981
|
+
Object.assign(RD, { hash: '', still: 0, scale: slow ? .6 : 1, q: slow ? 45 : 55, two: 'scroll', z: 1, px: 0, py: 0, on: true, ctl: false, mods: new Set(), drag: false, blank: 0, err: 0, tap: null, scroll: null });
|
|
1874
1982
|
const root = document.createElement('div');
|
|
1875
1983
|
root.className = 'rd'; root.id = 'rd'; root.setAttribute('role', 'dialog'); root.setAttribute('aria-label', 'Pantalla remota');
|
|
1876
1984
|
root.innerHTML = `<div class="rd-bar"><button class="rd-btn" data-rd="close" type="button">Tanca</button><b class="rd-title">${esc(m.name || 'Ordinador')}</b><span class="rd-stat" id="rdStat"></span><span class="grow"></span><button class="rd-btn" data-rd="full" type="button" id="rdFull" hidden>Pantalla completa</button><div class="rd-seg" role="group" aria-label="Mode"><button data-rd="view" type="button" aria-pressed="true">Mirar</button><button data-rd="ctl" type="button" aria-pressed="false">Controlar</button></div></div>
|
|
1877
|
-
<div class="rd-stage" id="rdStage"><img id="rdImg" alt="Pantalla de l’ordinador" draggable="false"><div class="rd-msg" id="rdMsg">Connectant…</div></div>
|
|
1878
|
-
<div class="rd-keys" id="rdKeys" hidden><button class="rd-btn" data-rd="kbd" type="button">Teclat</button>${RD_MODS.map(([k, l]) => `<button class="rd-btn" data-mod="${k}" type="button" aria-pressed="false">${l}</button>`).join('')}${RD_KEYS.map(([k, l]) => `<button class="rd-btn" data-key="${k}" type="button">${l}</button>`).join('')}<button class="rd-btn" data-rd="drag" type="button" id="rdDrag">1 dit: desplaça</button></div>
|
|
1985
|
+
<div class="rd-stage" id="rdStage"><img id="rdImg" alt="Pantalla de l’ordinador" draggable="false"><div class="rd-msg" id="rdMsg">Connectant…</div><div class="rd-tools" role="group" aria-label="Zoom i captura"><button class="rd-btn" data-rd="zout" type="button" aria-label="Allunya">−</button><button class="rd-btn rd-pct" data-rd="zfit" type="button" id="rdPct" aria-label="Enquadra">100%</button><button class="rd-btn" data-rd="zin" type="button" aria-label="Apropa">+</button><button class="rd-btn" data-rd="z11" type="button" aria-label="Mida real">1:1</button><span class="rd-sep"></span><button class="rd-btn" data-rd="save" type="button">Desa captura</button></div></div>
|
|
1986
|
+
<div class="rd-keys" id="rdKeys" hidden><button class="rd-btn" data-rd="kbd" type="button">Teclat</button>${RD_MODS.map(([k, l]) => `<button class="rd-btn" data-mod="${k}" type="button" aria-pressed="false">${l}</button>`).join('')}${RD_KEYS.map(([k, l]) => `<button class="rd-btn" data-key="${k}" type="button">${l}</button>`).join('')}<button class="rd-btn" data-rd="drag" type="button" id="rdDrag">1 dit: desplaça</button><button class="rd-btn" data-rd="two" type="button" id="rdTwo">2 dits: desplaça</button></div>
|
|
1879
1987
|
<textarea class="rd-type" id="rdType" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" aria-label="Teclat de l’ordinador"></textarea>`;
|
|
1880
1988
|
document.body.appendChild(root);
|
|
1881
1989
|
document.documentElement.classList.add('rd-open');
|
|
@@ -1912,11 +2020,21 @@ async function rdLoop() {
|
|
|
1912
2020
|
const started = performance.now();
|
|
1913
2021
|
try {
|
|
1914
2022
|
const stage = $('#rdStage'); if (!stage) break;
|
|
1915
|
-
const w = Math.min(
|
|
1916
|
-
const res = await fetch(`${m.url}/api/desktop/frame?w=${w}&q=${RD.q}&_=${Date.now()}`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store' });
|
|
2023
|
+
const w = Math.min(2400, Math.max(480, Math.round(stage.clientWidth * Math.min(devicePixelRatio || 1, 2) * Math.min(RD.z, 2) * RD.scale)));
|
|
2024
|
+
const res = await fetch(`${m.url}/api/desktop/frame?w=${w}&q=${RD.q}&h=${RD.hash}&_=${Date.now()}`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store' });
|
|
1917
2025
|
if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || `Error ${res.status}`); }
|
|
2026
|
+
const t1 = performance.now();
|
|
2027
|
+
if (res.status === 204) { RD.still++; RD.err = 0; n++; await sleep(RD.still > 3 ? 320 : 120); continue; }
|
|
1918
2028
|
const blob = await res.blob();
|
|
1919
2029
|
if (!RD.on) break;
|
|
2030
|
+
RD.still = 0; RD.hash = res.headers.get('x-hash') || '';
|
|
2031
|
+
const dl = performance.now() - t1;
|
|
2032
|
+
// Si baixar el fotograma triga massa (dades, una altra xarxa), l'abaixa de mida i qualitat; si va sobrat, el torna a pujar.
|
|
2033
|
+
if (blob.size > 12000) {
|
|
2034
|
+
if (dl > 450) { if (RD.scale > .4) RD.scale = Math.max(.4, RD.scale * .8); else RD.q = Math.max(32, RD.q - 6); }
|
|
2035
|
+
else if (dl < 140 && started - (RD.adj || 0) > 1200) { if (RD.q < 55) RD.q = Math.min(55, RD.q + 4); else RD.scale = Math.min(1, RD.scale * 1.12); RD.adj = started; }
|
|
2036
|
+
if (dl > 450) RD.adj = started;
|
|
2037
|
+
}
|
|
1920
2038
|
const img = $('#rdImg'), old = RD.objUrl, url = URL.createObjectURL(blob);
|
|
1921
2039
|
RD.objUrl = url; img.onload = () => { if (old) URL.revokeObjectURL(old); }; img.src = url;
|
|
1922
2040
|
RD.sw = Number(res.headers.get('x-screen-w')) || RD.sw;
|
|
@@ -1929,7 +2047,7 @@ async function rdLoop() {
|
|
|
1929
2047
|
RD.err++; rdMsg(RD.err > 1 ? `${e.message || 'Sense connexió'}${/^Error|Activa|captura/.test(e.message) ? '' : ' · reconnectant…'}` : '');
|
|
1930
2048
|
await sleep(Math.min(3000, 500 * RD.err));
|
|
1931
2049
|
}
|
|
1932
|
-
await sleep(Math.max(
|
|
2050
|
+
await sleep(Math.max(0, 70 - (performance.now() - started)));
|
|
1933
2051
|
}
|
|
1934
2052
|
}
|
|
1935
2053
|
function rdSend(ev) {
|
|
@@ -1958,9 +2076,78 @@ function rdScroll(dx, dy, pos) {
|
|
|
1958
2076
|
if (ev.dx || ev.dy) rdSend(ev);
|
|
1959
2077
|
}, 70);
|
|
1960
2078
|
}
|
|
2079
|
+
|
|
2080
|
+
function rdView() {
|
|
2081
|
+
const img = $('#rdImg'), st = $('#rdStage'); if (!img || !st) return;
|
|
2082
|
+
const z = RD.z, mx = Math.max(0, (img.offsetWidth * z - st.clientWidth) / 2), my = Math.max(0, (img.offsetHeight * z - st.clientHeight) / 2);
|
|
2083
|
+
RD.px = Math.max(-mx, Math.min(mx, RD.px)); RD.py = Math.max(-my, Math.min(my, RD.py));
|
|
2084
|
+
if (z === 1) RD.px = RD.py = 0;
|
|
2085
|
+
img.style.transform = z === 1 ? '' : `translate(${RD.px}px,${RD.py}px) scale(${z})`;
|
|
2086
|
+
const pct = $('#rdPct'); if (pct) pct.textContent = Math.round(z * 100) + '%';
|
|
2087
|
+
}
|
|
2088
|
+
function rdZoom(z, cx, cy) {
|
|
2089
|
+
const st = $('#rdStage'); if (!st) return;
|
|
2090
|
+
z = Math.max(1, Math.min(6, z));
|
|
2091
|
+
const r = st.getBoundingClientRect(), ox = (cx ?? r.left + r.width / 2) - (r.left + r.width / 2), oy = (cy ?? r.top + r.height / 2) - (r.top + r.height / 2), k = z / RD.z;
|
|
2092
|
+
RD.px = ox - (ox - RD.px) * k; RD.py = oy - (oy - RD.py) * k; RD.z = z; rdView();
|
|
2093
|
+
}
|
|
2094
|
+
async function screenNow() {
|
|
2095
|
+
const m = machine(); if (!m) return;
|
|
2096
|
+
toast('Fent la captura…');
|
|
2097
|
+
try {
|
|
2098
|
+
const res = await fetch(`${m.url}/api/desktop/frame?w=1600&q=80&_=${Date.now()}`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store' });
|
|
2099
|
+
if (!res.ok) throw new Error(`Error ${res.status}`);
|
|
2100
|
+
const url = URL.createObjectURL(await res.blob());
|
|
2101
|
+
const d = new Date(), p = (n) => String(n).padStart(2, '0');
|
|
2102
|
+
const name = `nio-pantalla-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}.jpg`;
|
|
2103
|
+
openSheet(`<h2>Captura de la pantalla</h2><img class="screen-img" alt="Captura de la pantalla de l’ordinador" src="${url}" style="width:100%;border-radius:10px;margin:8px 0"><div class="row"><a class="btn ink" href="${url}" download="${name}">Desa la imatge</a><button class="btn" type="button" data-close>Tanca</button></div>`);
|
|
2104
|
+
} catch {
|
|
2105
|
+
try { const s = await api('/api/screen', { method: 'POST' }); onScreen({ shot: s }); toast('Captura feta'); } catch (err) { toast(`No s’ha pogut fer la captura: ${err.message}`, 5000); }
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
function rdSave() {
|
|
2109
|
+
const img = $('#rdImg'); if (!img?.src) return toast('Encara no hi ha cap imatge');
|
|
2110
|
+
const d = new Date(), p = (n) => String(n).padStart(2, '0');
|
|
2111
|
+
const a = document.createElement('a'); a.href = img.src; a.download = `nio-pantalla-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}.jpg`;
|
|
2112
|
+
document.body.appendChild(a); a.click(); a.remove(); toast('Captura desada');
|
|
2113
|
+
}
|
|
2114
|
+
function rdGestures(stage) {
|
|
2115
|
+
const pts = new Map(); let g = null, pan = null, tapT = 0;
|
|
2116
|
+
const dist = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y) || 1; };
|
|
2117
|
+
const mid = () => { const [a, b] = [...pts.values()]; return [(a.x + b.x) / 2, (a.y + b.y) / 2]; };
|
|
2118
|
+
stage.addEventListener('pointerdown', (e) => {
|
|
2119
|
+
if (e.target.closest('.rd-tools') || (RD.ctl && (RD.two !== 'zoom' || e.pointerType === 'mouse'))) return;
|
|
2120
|
+
try { stage.setPointerCapture(e.pointerId); } catch {}
|
|
2121
|
+
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
2122
|
+
if (pts.size === 2) { const [mx, my] = mid(); g = { d: dist(), z: RD.z, mx, my, px: RD.px, py: RD.py }; pan = null; }
|
|
2123
|
+
else if (pts.size === 1 && !RD.ctl) pan = { x: e.clientX, y: e.clientY, px: RD.px, py: RD.py };
|
|
2124
|
+
});
|
|
2125
|
+
stage.addEventListener('pointermove', (e) => {
|
|
2126
|
+
const p = pts.get(e.pointerId); if (!p) return; p.x = e.clientX; p.y = e.clientY;
|
|
2127
|
+
if (pts.size >= 2 && g) {
|
|
2128
|
+
const [mx, my] = mid(), st = stage.getBoundingClientRect(), z = Math.max(1, Math.min(6, g.z * dist() / g.d)), k = z / g.z;
|
|
2129
|
+
const ox = g.mx - (st.left + st.width / 2), oy = g.my - (st.top + st.height / 2);
|
|
2130
|
+
RD.z = z; RD.px = g.px * k + ox * (1 - k) + (mx - g.mx); RD.py = g.py * k + oy * (1 - k) + (my - g.my); rdView();
|
|
2131
|
+
} else if (pan && RD.z > 1 && !RD.ctl) { RD.px = pan.px + e.clientX - pan.x; RD.py = pan.py + e.clientY - pan.y; rdView(); }
|
|
2132
|
+
});
|
|
2133
|
+
const up = (e) => {
|
|
2134
|
+
if (!pts.has(e.pointerId)) return;
|
|
2135
|
+
const moved = pan && Math.hypot(e.clientX - pan.x, e.clientY - pan.y) > 8;
|
|
2136
|
+
pts.delete(e.pointerId); g = null;
|
|
2137
|
+
if (pts.size === 1 && !RD.ctl) { const [q] = pts.values(); pan = { x: q.x, y: q.y, px: RD.px, py: RD.py }; }
|
|
2138
|
+
else { pan = null; if (!pts.size && e.type === 'pointerup' && !moved && !RD.ctl) { const now = performance.now(); if (now - tapT < 320) { RD.z > 1 ? (RD.z = 1, rdView()) : rdZoom(2.5, e.clientX, e.clientY); tapT = 0; } else tapT = now; } }
|
|
2139
|
+
};
|
|
2140
|
+
stage.addEventListener('pointerup', up); stage.addEventListener('pointercancel', up);
|
|
2141
|
+
stage.addEventListener('wheel', (e) => { if (!(e.ctrlKey || e.metaKey) && RD.ctl) return; e.preventDefault(); if (e.ctrlKey || e.metaKey) rdZoom(RD.z * Math.exp(-Math.max(-40, Math.min(40, e.deltaY)) * 0.01), e.clientX, e.clientY); else if (RD.z > 1) { RD.px -= e.deltaX; RD.py -= e.deltaY; rdView(); } }, { passive: false });
|
|
2142
|
+
let gz = 1;
|
|
2143
|
+
stage.addEventListener('gesturestart', (e) => { e.preventDefault(); gz = RD.z; });
|
|
2144
|
+
stage.addEventListener('gesturechange', (e) => { e.preventDefault(); rdZoom(gz * e.scale, e.clientX, e.clientY); });
|
|
2145
|
+
window.addEventListener('resize', rdView);
|
|
2146
|
+
}
|
|
1961
2147
|
function rdBind(root) {
|
|
1962
2148
|
const stage = $('#rdStage', root), typer = $('#rdType', root);
|
|
1963
2149
|
const ptrs = new Map();
|
|
2150
|
+
rdGestures(stage);
|
|
1964
2151
|
const click = (pos, e) => {
|
|
1965
2152
|
const now = performance.now(), prev = RD.tap, near = prev && Math.hypot(e.clientX - prev.x, e.clientY - prev.y) < 28;
|
|
1966
2153
|
const count = prev && near && now - prev.t < 340 ? (prev.count >= 2 ? 3 : 2) : 1;
|
|
@@ -1968,12 +2155,13 @@ function rdBind(root) {
|
|
|
1968
2155
|
rdSend({ type: 'click', x: pos[0], y: pos[1], count });
|
|
1969
2156
|
};
|
|
1970
2157
|
stage.addEventListener('pointerdown', (e) => {
|
|
1971
|
-
if (!RD.ctl || (e.pointerType === 'mouse' && e.button !== 0)) return;
|
|
2158
|
+
if (!RD.ctl || e.target.closest('.rd-tools') || (e.pointerType === 'mouse' && e.button !== 0)) return;
|
|
1972
2159
|
try { stage.setPointerCapture(e.pointerId); } catch {}
|
|
1973
2160
|
const p = { x0: e.clientX, y0: e.clientY, x: e.clientX, y: e.clientY, touch: e.pointerType !== 'mouse', moved: false, long: false, path: [], pt: 0, lp: null };
|
|
1974
2161
|
const pos = rdPos(e); if (pos) p.path.push(pos);
|
|
1975
2162
|
if (p.touch) p.lp = setTimeout(() => { if (!p.moved && ptrs.size === 1 && ptrs.has(e.pointerId)) { p.long = true; navigator.vibrate?.(15); const q = rdPos(e); if (q) rdSend({ type: 'rclick', x: q[0], y: q[1] }); } }, 550);
|
|
1976
2163
|
ptrs.set(e.pointerId, p);
|
|
2164
|
+
if (RD.two === 'zoom' && p.touch && ptrs.size > 1) for (const q of ptrs.values()) { q.moved = q.zoomed = true; clearTimeout(q.lp); }
|
|
1977
2165
|
if (!typer.matches(':focus') && !p.touch) typer.focus({ preventScroll: true });
|
|
1978
2166
|
e.preventDefault();
|
|
1979
2167
|
});
|
|
@@ -1981,7 +2169,7 @@ function rdBind(root) {
|
|
|
1981
2169
|
const p = ptrs.get(e.pointerId); if (!p) return;
|
|
1982
2170
|
const dx = e.clientX - p.x, dy = e.clientY - p.y; p.x = e.clientX; p.y = e.clientY;
|
|
1983
2171
|
if (!p.moved && Math.hypot(p.x - p.x0, p.y - p.y0) > 8) { p.moved = true; clearTimeout(p.lp); }
|
|
1984
|
-
if (!p.moved || p.long) return;
|
|
2172
|
+
if (!p.moved || p.long || p.zoomed) return;
|
|
1985
2173
|
if (p.touch && (ptrs.size > 1 || !RD.drag)) { if (ptrs.size > 1 && e.isPrimary === false) return; rdScroll(dx, dy, rdPos(e)); return; }
|
|
1986
2174
|
const now = performance.now(), pos = rdPos(e);
|
|
1987
2175
|
if (pos && now - p.pt > 22) { p.path.push(pos); p.pt = now; }
|
|
@@ -1989,7 +2177,7 @@ function rdBind(root) {
|
|
|
1989
2177
|
const end = (e) => {
|
|
1990
2178
|
const p = ptrs.get(e.pointerId); if (!p) return;
|
|
1991
2179
|
ptrs.delete(e.pointerId); clearTimeout(p.lp);
|
|
1992
|
-
if (p.long) return;
|
|
2180
|
+
if (p.long || p.zoomed) return;
|
|
1993
2181
|
const pos = rdPos(e);
|
|
1994
2182
|
if (!p.moved) { if (pos && e.type === 'pointerup') click(pos, e); return; }
|
|
1995
2183
|
if (p.touch && (ptrs.size > 0 || !RD.drag)) return;
|
|
@@ -2043,6 +2231,12 @@ function rdBind(root) {
|
|
|
2043
2231
|
rdHint(); break;
|
|
2044
2232
|
}
|
|
2045
2233
|
case 'kbd': typer.focus(); break;
|
|
2234
|
+
case 'zin': rdZoom(RD.z * 1.5); break;
|
|
2235
|
+
case 'zout': rdZoom(RD.z / 1.5); break;
|
|
2236
|
+
case 'zfit': RD.z = 1; rdView(); break;
|
|
2237
|
+
case 'z11': { const im = $('#rdImg'); rdZoom(RD.sw && im.offsetWidth ? RD.sw / im.offsetWidth : 2); break; }
|
|
2238
|
+
case 'save': rdSave(); break;
|
|
2239
|
+
case 'two': RD.two = RD.two === 'zoom' ? 'scroll' : 'zoom'; b.textContent = RD.two === 'zoom' ? '2 dits: zoom' : '2 dits: desplaça'; break;
|
|
2046
2240
|
case 'drag': RD.drag = !RD.drag; b.textContent = RD.drag ? '1 dit: arrossega' : '1 dit: desplaça'; break;
|
|
2047
2241
|
}
|
|
2048
2242
|
});
|
|
@@ -2228,7 +2422,8 @@ document.addEventListener('click', async (e) => {
|
|
|
2228
2422
|
case 'ollama-pull': return pullModel(a.dataset.name || $('#pullIn')?.value);
|
|
2229
2423
|
case 'ollama-cancel': try { await api(`/api/ollama/pull/${encodeURIComponent(a.dataset.name)}`, { method: 'DELETE' }); } catch (err) { toast(err.message); } return;
|
|
2230
2424
|
case 'ollama-del': return deleteModel(a.dataset.name);
|
|
2231
|
-
case 'ollama-
|
|
2425
|
+
case 'ollama-start': return newSessionSheet({ text: OLLAMA_START });
|
|
2426
|
+
case 'ollama-chat': local.ollama = a.dataset.name; local.engine = null; local.model = null; local.messages = []; return render();
|
|
2232
2427
|
case 'retry': return connect();
|
|
2233
2428
|
case 'model': { const s = state.sessions.get(state.route.id); if (s) modelSheet(s); return; }
|
|
2234
2429
|
case 'mode': { const s = state.sessions.get(state.route.id); if (s) modeSheet(s); return; }
|
|
@@ -2240,7 +2435,7 @@ document.addEventListener('click', async (e) => {
|
|
|
2240
2435
|
case 'job-new': return newJobSheet();
|
|
2241
2436
|
case 'panic': return panicNow();
|
|
2242
2437
|
case 'remote-open': remoteOpen(); return;
|
|
2243
|
-
case 'screen-now':
|
|
2438
|
+
case 'screen-now': return screenNow();
|
|
2244
2439
|
case 'mem-new': return memEdit(null);
|
|
2245
2440
|
case 'mem-all': if (mem) delete mem.hits; return loadMemory();
|
|
2246
2441
|
case 'activity-refresh': activity = null; return loadActivity();
|
|
@@ -2249,13 +2444,14 @@ document.addEventListener('click', async (e) => {
|
|
|
2249
2444
|
case 'open-sessions': return go('#/sessions');
|
|
2250
2445
|
case 'help': return go('#/ajuda');
|
|
2251
2446
|
case 'more': return moreSheet();
|
|
2447
|
+
case 'pantalla-tab': return pantallaTab();
|
|
2252
2448
|
case 'folder': { const s = state.sessions.get(state.route.id); return s && folderSheet(s); }
|
|
2253
2449
|
case 'machines': return machinesSheet();
|
|
2254
2450
|
case 'mobile-qr': return mobileQrSheet();
|
|
2255
2451
|
case 'mobile-hint-off': { const m = machine(); LS.set(`nio.mobHint.${m?.id || m?.url}`, true); return render(); }
|
|
2256
2452
|
case 'git': return gitSheet(state.route.id);
|
|
2257
2453
|
case 'lm-load': return loadLocal(local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id));
|
|
2258
|
-
case 'lm-change': await local.engine?.unload?.(); local.engine = null; local.model = null; return render();
|
|
2454
|
+
case 'lm-change': await local.engine?.unload?.(); local.engine = null; local.model = null; local.ollama = null; local.messages = []; return render();
|
|
2259
2455
|
case 'lm-clear': local.messages = []; return render();
|
|
2260
2456
|
case 'palette': return palette();
|
|
2261
2457
|
case 'tour': return tourStart();
|
|
@@ -2328,48 +2524,112 @@ $('#side').addEventListener('click', (e) => { if (e.target.closest('a, button'))
|
|
|
2328
2524
|
addEventListener('keydown', (e) => { if (e.key === 'Escape' && document.body.classList.contains('nav-open')) setNav(false); });
|
|
2329
2525
|
mq.addEventListener('change', () => setNav(false));
|
|
2330
2526
|
/* ---------------------------------------------------------------- tutorial */
|
|
2331
|
-
const TOUR_KEY = 'nio.tour.
|
|
2332
|
-
const T = Object.assign({ on: false, i: 0, seen: false }, LS.get(TOUR_KEY, {}));
|
|
2527
|
+
const TOUR_KEY = 'nio.tour.v3';
|
|
2528
|
+
const T = Object.assign({ on: false, i: 0, seen: false, busy: false, pos: null, drawn: '' }, LS.get(TOUR_KEY, {}));
|
|
2529
|
+
T.busy = false; T.drawn = '';
|
|
2333
2530
|
const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
|
|
2531
|
+
const PAGE = '.page .top, .top';
|
|
2334
2532
|
const TSTEPS = [
|
|
2335
|
-
{ 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.
|
|
2336
|
-
{ route: 'ordinadors', target: '#addComputer', m: '#addComputer', title: '
|
|
2337
|
-
{ route: 'ordinadors', skipIf: () => !machine(), target: '.card.computer.active .c-actions', m: '.card.computer.active .c-actions', title: '
|
|
2338
|
-
{ route: '
|
|
2339
|
-
{ route: '
|
|
2340
|
-
{ route: '
|
|
2341
|
-
{ route: '
|
|
2342
|
-
{ route: '
|
|
2343
|
-
{
|
|
2344
|
-
{
|
|
2533
|
+
{ 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' },
|
|
2534
|
+
{ route: 'ordinadors', target: '#addComputer', m: '#addComputer', title: '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.' },
|
|
2535
|
+
{ 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.' },
|
|
2536
|
+
{ 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.' },
|
|
2537
|
+
{ 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.' },
|
|
2538
|
+
{ route: 'sessions', skipIf: () => !machine(), target: '.top [data-act=new]', m: '.top [data-act=new]', title: 'Dona feina a un agent', text: 'Una sessió és una conversa amb un agent dins d’una carpeta. Tries l’agent, la carpeta (les preferides o la que vulguis), els permisos i li expliques què ha de fer.' },
|
|
2539
|
+
{ route: 'sessions', skipIf: () => !machine(), target: '.session-card, .panel.empty', m: '.session-card, .panel.empty', title: 'Les teves sessions', text: 'Cada targeta té el robot del color de l’agent i diu què està fent: treballant, esperant-te o acabat. Toca’n una per continuar la conversa.' },
|
|
2540
|
+
{ route: 'approvals', skipIf: () => !machine(), target: PAGE, m: PAGE, title: 'Tu decideixes', text: 'En mode «Demana permís», abans d’editar un fitxer o executar una ordre, l’agent t’ho pregunta aquí. Al mòbil hi arribes amb la campana de dalt, i si actives els avisos t’arriba la notificació.' },
|
|
2541
|
+
{ route: 'feines', skipIf: () => !machine(), target: PAGE, m: PAGE, title: 'Feines programades', text: 'Programa que un agent faci una cosa per tu: d’aquí a una estona, cada dia o quan toqui. Tries l’agent, la carpeta i el moment, com un temporitzador.' },
|
|
2542
|
+
{ route: 'memoria', skipIf: () => !machine(), target: PAGE, m: PAGE, title: 'Memòria compartida', text: 'Notes en text pla que tots els agents poden llegir i escriure: preferències, decisions, context dels projectes. Així no repeteixes res a cada agent.' },
|
|
2543
|
+
{ route: 'mobil', target: PAGE, m: PAGE, title: 'Xat local', text: 'Un xat senzill amb un model que funciona dins del mòbil (sense internet) o amb els models d’Ollama de l’ordinador. No toca cap carpeta: només preguntes i respostes.' },
|
|
2544
|
+
{ route: 'pissarra', target: '#bdTools', m: '#bdTools', title: 'Pensa en dibuix', text: 'A la pissarra pots fer diagrames a mà alçada o demanar-me’ls escrivint, amb Ollama, amb un agent o amb el model del mòbil.' },
|
|
2545
|
+
{ route: 'activitat', skipIf: () => !machine(), target: PAGE, m: PAGE, title: 'Activitat', text: 'El registre de tot el que han fet els agents i de tot el que has aprovat o rebutjat, perquè sempre sàpigues què ha passat.' },
|
|
2546
|
+
{ route: 'ajustos', target: PAGE, m: PAGE, title: 'Ajustos', text: 'Aspecte, avisos, Telegram i les opcions de l’ordinador. Tot el que canvia el comportament de nio és aquí.' },
|
|
2547
|
+
{ route: 'ajuda', target: '#help', m: '#help', title: 'Si alguna cosa no va', text: 'A «Ajuda» tens cada pas explicat, què cal al servidor i què mirar si no connecta. També hi pots repetir aquest tutorial.' },
|
|
2548
|
+
{ route: 'sessions', desktopOnly: true, target: '[data-act=palette]', m: '[data-act=palette]', title: 'Un últim truc', text: 'Prem ⌘K (o Ctrl+K) per anar a qualsevol lloc o obrir una sessió sense tocar el ratolí.' },
|
|
2549
|
+
{ center: true, route: 'sessions', mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial a Ajuda.', next: 'Finalitza' }
|
|
2345
2550
|
];
|
|
2346
2551
|
const tval = (v) => (typeof v === 'function' ? v() : v);
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2552
|
+
const tskip = (st) => st.skipIf?.() || (st.mobileOnly && !mq.matches) || (st.desktopOnly && mq.matches);
|
|
2553
|
+
const tpause = (ms) => new Promise((ok) => setTimeout(ok, reduce ? 0 : ms));
|
|
2554
|
+
const twait = (find, ms = 3000) => new Promise((done) => { const t0 = performance.now(); const tick = () => { const e = find(); if (e || performance.now() - t0 > ms) done(e || null); else setTimeout(tick, 70); }; tick(); });
|
|
2555
|
+
const tvisible = (sel) => { const e = sel && document.querySelector(sel); return e && e.getClientRects().length && !e.closest('[inert]') ? e : null; };
|
|
2556
|
+
let tourRun = 0;
|
|
2557
|
+
function tourStart() { T.on = true; T.seen = true; $('#tour').hidden = false; tourGo(0); }
|
|
2558
|
+
function tourEnd(silent) { tourRun++; T.on = false; T.seen = true; T.busy = false; saveTour(); $('#tourTap').classList.remove('on', 'press'); $('#tour').hidden = true; if (!silent) toast('Tutorial tancat. El tens a Ajuda quan vulguis.'); }
|
|
2559
|
+
// El control que tocaria una persona per arribar a l'apartat: menú lateral, pestanya, campana o «Més».
|
|
2560
|
+
function tourVia(route) {
|
|
2561
|
+
const h = HASH[route];
|
|
2562
|
+
if (!mq.matches) return tvisible(`.side nav a[href="${h}"]`);
|
|
2563
|
+
return tvisible(`#tabbar a[href="${h}"]`) || (route === 'approvals' ? tvisible(`.mbar a[href="${h}"]`) : null) || tvisible('#tabbar [data-act=more]');
|
|
2564
|
+
}
|
|
2565
|
+
// Un cercle es mou fins al control i el prem, com un clic de veritat.
|
|
2566
|
+
async function tourTap(e, run) {
|
|
2567
|
+
const dot = $('#tourTap'), r = e.getBoundingClientRect();
|
|
2568
|
+
const x = r.left + Math.min(r.width / 2, 60), y = r.top + r.height / 2;
|
|
2569
|
+
if (!T.pos) { dot.classList.add('nomove'); dot.style.translate = `${innerWidth / 2}px ${innerHeight - 140}px`; void dot.offsetWidth; dot.classList.remove('nomove'); }
|
|
2570
|
+
dot.classList.add('on');
|
|
2571
|
+
dot.style.translate = `${x}px ${y}px`; T.pos = [x, y];
|
|
2572
|
+
await tpause(620); if (run !== tourRun) return false;
|
|
2573
|
+
dot.classList.add('press'); e.classList.add('tour-pressed');
|
|
2574
|
+
await tpause(190); if (run !== tourRun) return false;
|
|
2575
|
+
dot.classList.remove('press'); e.classList.remove('tour-pressed');
|
|
2576
|
+
return true;
|
|
2577
|
+
}
|
|
2578
|
+
async function tourGo(i) {
|
|
2579
|
+
const run = ++tourRun;
|
|
2580
|
+
while (i < TSTEPS.length && tskip(TSTEPS[i])) i++;
|
|
2351
2581
|
if (i >= TSTEPS.length) return tourEnd(true);
|
|
2352
2582
|
T.i = Math.max(0, i); saveTour();
|
|
2353
|
-
const st = TSTEPS[T.i];
|
|
2354
|
-
|
|
2355
|
-
|
|
2583
|
+
const st = TSTEPS[T.i], card = $('#tourCard'), spot = $('#tourSpot'), dot = $('#tourTap');
|
|
2584
|
+
$('#tour').hidden = false;
|
|
2585
|
+
T.busy = true; card.classList.add('swap');
|
|
2586
|
+
if (st.tapOnly) {
|
|
2587
|
+
// Només es mostra el toc (la pestanya obriria la pantalla remota).
|
|
2588
|
+
const c = tvisible(st.tapOnly);
|
|
2589
|
+
if (c) { spot.classList.add('fade'); if (!await tourTap(c, run)) return; dot.classList.remove('on'); spot.classList.remove('fade'); }
|
|
2590
|
+
} else if (st.route && state.route.name !== st.route) {
|
|
2591
|
+
const via = tourVia(st.route);
|
|
2592
|
+
spot.classList.add('fade');
|
|
2593
|
+
if (via && !await tourTap(via, run)) return;
|
|
2594
|
+
// Si és dins de «Més», s'obre el menú un instant com faria el dit.
|
|
2595
|
+
if (via?.dataset?.act === 'more') await tpause(380);
|
|
2596
|
+
if (run !== tourRun) return;
|
|
2597
|
+
go(HASH[st.route]);
|
|
2598
|
+
window.scrollTo({ top: 0, behavior: 'instant' });
|
|
2599
|
+
await twait(() => state.route.name === st.route, 1500);
|
|
2600
|
+
dot.classList.remove('on');
|
|
2601
|
+
if (run !== tourRun) return;
|
|
2602
|
+
}
|
|
2603
|
+
const find = () => (st.center ? null : tvisible(tval(mq.matches && 'm' in st ? st.m : st.target)));
|
|
2604
|
+
if (!st.center) await twait(find, 3200);
|
|
2605
|
+
if (run !== tourRun) return;
|
|
2606
|
+
T.busy = false; T.drawn = '';
|
|
2607
|
+
tourDraw(true);
|
|
2608
|
+
spot.classList.remove('fade');
|
|
2609
|
+
card.classList.remove('swap');
|
|
2610
|
+
if (run === tourRun) card.querySelector('[data-tour=next]')?.focus({ preventScroll: true });
|
|
2356
2611
|
}
|
|
2357
2612
|
function tourDraw(scroll) {
|
|
2358
|
-
if (!T.on) return;
|
|
2613
|
+
if (!T.on || T.busy) return;
|
|
2359
2614
|
const st = TSTEPS[T.i], card = $('#tourCard'), busy = $('#sheet').open || $('#palette').open;
|
|
2360
2615
|
$('#tour').hidden = busy; if (busy) return;
|
|
2361
|
-
const
|
|
2362
|
-
|
|
2363
|
-
|
|
2616
|
+
const key = `${T.i}|${mq.matches}`;
|
|
2617
|
+
if (T.drawn !== key) {
|
|
2618
|
+
T.drawn = key;
|
|
2619
|
+
const n = TSTEPS.length - 2, total = TSTEPS.slice(1, -1).filter((x) => !tskip(x)), pos = total.indexOf(st) + 1;
|
|
2620
|
+
card.className = 'tour-card' + (st.center ? ' center' : '');
|
|
2621
|
+
card.innerHTML = `<div class="tour-head">${bot(st.mood || 'idle')}${st.center ? '' : `<span class="tour-step">Pas ${pos} de ${total.length}</span>`}<button class="tour-x" data-tour="close" type="button" aria-label="Tanca el tutorial">×</button></div>
|
|
2364
2622
|
<h3 id="tourT">${tval(st.title)}</h3><p>${tval(st.text)}</p>
|
|
2623
|
+
${st.center ? '' : `<div class="tour-dots" aria-hidden="true">${total.map((_, k) => `<i class="${k + 1 === pos ? 'on' : k + 1 < pos ? 'done' : ''}"></i>`).join('')}</div>`}
|
|
2365
2624
|
<div class="tour-foot">${st.center ? '' : `<div>${T.i > 1 ? '<button class="btn sm ghost" data-tour="back" type="button">Enrere</button>' : ''}</div>`}
|
|
2366
2625
|
<div class="row">${T.i === 0 ? '<button class="btn sm ghost" data-tour="close" type="button">Ara no</button>' : ''}<button class="btn sm ink" data-tour="next" type="button">${tval(st.next) || 'Següent'}</button></div></div>`;
|
|
2367
|
-
|
|
2626
|
+
window.NioBot?.refresh();
|
|
2627
|
+
}
|
|
2628
|
+
tourPlace(scroll);
|
|
2368
2629
|
}
|
|
2369
2630
|
function tourTarget() {
|
|
2370
|
-
const st = TSTEPS[T.i], sel = st.center ? null : tval(mq.matches && 'm' in st ? st.m : st.target)
|
|
2371
|
-
|
|
2372
|
-
return e;
|
|
2631
|
+
const st = TSTEPS[T.i], sel = st.center ? null : tval(mq.matches && 'm' in st ? st.m : st.target);
|
|
2632
|
+
return tvisible(sel);
|
|
2373
2633
|
}
|
|
2374
2634
|
function tourPlace(scroll) {
|
|
2375
2635
|
if (!T.on || $('#tour').hidden) return;
|
|
@@ -2408,18 +2668,18 @@ function tourPlace(scroll) {
|
|
|
2408
2668
|
if (r.width < 260 && r.right + gap + cw < innerWidth - 12 && (top < 12 || r.bottom + ch > innerHeight)) { left = r.right + gap; top = r.top + r.height / 2 - ch / 2; }
|
|
2409
2669
|
Object.assign(card.style, { left: Math.min(Math.max(12, left), innerWidth - cw - 12) + 'px', top: Math.min(Math.max(12, top), innerHeight - ch - 12) + 'px', bottom: '' });
|
|
2410
2670
|
}
|
|
2411
|
-
function tourUpdate() { if (T.on) setTimeout(() => tourDraw(false), 30); }
|
|
2671
|
+
function tourUpdate() { if (T.on && !T.busy) setTimeout(() => tourDraw(false), 30); }
|
|
2412
2672
|
$('#tour').addEventListener('click', (e) => {
|
|
2413
2673
|
const b = e.target.closest('[data-tour]'); if (!b) return;
|
|
2414
2674
|
if (b.dataset.tour === 'close') tourEnd(false);
|
|
2415
2675
|
else if (b.dataset.tour === 'next') tourGo(T.i + 1);
|
|
2416
|
-
else if (b.dataset.tour === 'back') { let i = T.i - 1; while (i > 0 && TSTEPS[i]
|
|
2676
|
+
else if (b.dataset.tour === 'back') { let i = T.i - 1; while (i > 0 && tskip(TSTEPS[i])) i--; tourGo(i); }
|
|
2417
2677
|
});
|
|
2418
|
-
addEventListener('resize', () => tourPlace(false));
|
|
2419
|
-
addEventListener('scroll', () => tourPlace(false), { passive: true });
|
|
2678
|
+
addEventListener('resize', () => { if (T.on && !T.busy) tourPlace(false); });
|
|
2679
|
+
addEventListener('scroll', () => { if (T.on && !T.busy) tourPlace(false); }, { passive: true });
|
|
2420
2680
|
addEventListener('keydown', (e) => {
|
|
2421
2681
|
if (!T.on || $('#sheet').open || $('#palette').open || e.target.closest?.('input, textarea')) return;
|
|
2422
|
-
if (e.key === 'Escape') tourEnd(false); else if (e.key === 'ArrowRight') tourGo(T.i + 1); else if (e.key === 'ArrowLeft' && T.i > 1)
|
|
2682
|
+
if (e.key === 'Escape') tourEnd(false); else if (e.key === 'ArrowRight') tourGo(T.i + 1); else if (e.key === 'ArrowLeft' && T.i > 1) { let i = T.i - 1; while (i > 0 && tskip(TSTEPS[i])) i--; tourGo(i); }
|
|
2423
2683
|
});
|
|
2424
2684
|
$('#sheet').addEventListener('close', () => setTimeout(() => tourDraw(false), 30));
|
|
2425
2685
|
$('#palette').addEventListener('close', () => setTimeout(() => tourDraw(false), 30));
|