nio-cat 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/nio.js +31 -21
- 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 +1 -1
- package/web/app/app.js +241 -91
- package/web/app/index.html +1 -0
- package/web/app/style.css +32 -4
- package/web/bot.css +2 -3
- package/web/index.html +148 -126
- package/web/og.png +0 -0
- 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();
|
|
@@ -787,9 +788,28 @@ function renameSheet(i) {
|
|
|
787
788
|
};
|
|
788
789
|
});
|
|
789
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
|
+
}
|
|
790
810
|
function moreSheet() {
|
|
791
811
|
const item = (k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`;
|
|
792
|
-
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(); }; });
|
|
793
813
|
}
|
|
794
814
|
|
|
795
815
|
/* ---------- Sessions */
|
|
@@ -820,6 +840,7 @@ async function manageDelete() {
|
|
|
820
840
|
manage = fail ? new Set(ids.filter((id) => state.sessions.has(id))) : null;
|
|
821
841
|
toast(fail ? `No he pogut esborrar ${fail} sessions` : `${ids.length} sessions esborrades`); render();
|
|
822
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();
|
|
823
844
|
function viewSessions() {
|
|
824
845
|
const list = [...state.sessions.values()].sort((a, b) => b.updated - a.updated);
|
|
825
846
|
const many = store.machines.length > 1, mach = machine();
|
|
@@ -835,7 +856,7 @@ function viewSessions() {
|
|
|
835
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' : ''}">
|
|
836
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>
|
|
837
858
|
<div class="title">${esc(s.title || 'Sessió nova')}</div>
|
|
838
|
-
${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>` : ''}
|
|
839
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>
|
|
840
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>`}`;
|
|
841
862
|
}
|
|
@@ -983,28 +1004,45 @@ async function newJobSheet() {
|
|
|
983
1004
|
try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
|
|
984
1005
|
const agents = (state.info?.agents || []).filter((a) => a.available && ['claude', 'codex'].includes(a.id));
|
|
985
1006
|
const prev = (jobsData?.jobs || []).filter((j) => ['pendent', 'en curs', 'feta'].includes(j.state)).slice(0, 15);
|
|
986
|
-
const sel = { agent: 'auto', mode: 'preguntar', when: 'ara' };
|
|
1007
|
+
const sel = { agent: 'auto', mode: 'preguntar', when: 'ara', mins: 0 };
|
|
987
1008
|
openSheet(`<h2>Nova feina</h2>
|
|
988
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>
|
|
989
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>
|
|
990
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>
|
|
991
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>
|
|
992
|
-
<div class="
|
|
993
|
-
|
|
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>
|
|
994
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>' : ''}
|
|
995
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) => {
|
|
996
1018
|
const x = $('#jobWhenX', sh);
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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">` : '';
|
|
1002
1035
|
const c = $('#jobCron', sh); if (c) c.onchange = () => ($('#jobCronX', sh).hidden = c.value !== 'custom');
|
|
1036
|
+
upd();
|
|
1003
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(); });
|
|
1004
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(); });
|
|
1005
1042
|
$('#jobGo', sh).onclick = async () => {
|
|
1006
1043
|
const prompt = $('#jobPrompt', sh).value.trim(); if (!prompt) return toast('Explica què ha de fer la feina');
|
|
1007
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; }
|
|
1008
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(); }
|
|
1009
1047
|
if (sel.when === 'after') body.after = $('#jobAfter', sh).value;
|
|
1010
1048
|
if (sel.when === 'cron') body.cron = $('#jobCron', sh).value === 'custom' ? $('#jobCronX', sh).value.trim() : $('#jobCron', sh).value;
|
|
@@ -1012,7 +1050,7 @@ async function newJobSheet() {
|
|
|
1012
1050
|
try {
|
|
1013
1051
|
const j = await api('/api/jobs', { method: 'POST', body });
|
|
1014
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}}' } });
|
|
1015
|
-
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();
|
|
1016
1054
|
} catch (e) { toast(e.message, 4200); $('#jobGo', sh).disabled = false; }
|
|
1017
1055
|
};
|
|
1018
1056
|
if (!isTouch) setTimeout(() => $('#jobPrompt', sh)?.focus(), 80);
|
|
@@ -1282,11 +1320,17 @@ function quickFolders(extra = []) {
|
|
|
1282
1320
|
}
|
|
1283
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>`;
|
|
1284
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
|
+
}
|
|
1285
1330
|
function folderSheet(s) {
|
|
1286
1331
|
let d = null;
|
|
1287
1332
|
const draw = () => {
|
|
1288
|
-
|
|
1289
|
-
$('#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)}
|
|
1290
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>`;
|
|
1291
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í'; }
|
|
1292
1336
|
};
|
|
@@ -1383,7 +1427,7 @@ function focusMain() {
|
|
|
1383
1427
|
if (isTouch || $('#sheet')?.open || $('#palette')?.open) return;
|
|
1384
1428
|
const r = state.route.name;
|
|
1385
1429
|
if (r === 'session') $('#msg')?.focus({ preventScroll: true });
|
|
1386
|
-
else if (r === 'mobil' &&
|
|
1430
|
+
else if (r === 'mobil' && inLocalChat()) $('#lmsg')?.focus({ preventScroll: true });
|
|
1387
1431
|
}
|
|
1388
1432
|
$('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
|
|
1389
1433
|
$('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
|
|
@@ -1463,14 +1507,13 @@ async function newSessionSheet(preset = {}) {
|
|
|
1463
1507
|
const VIEWS_ = [
|
|
1464
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>`,
|
|
1465
1509
|
() => {
|
|
1466
|
-
const
|
|
1467
|
-
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('');
|
|
1468
1511
|
return `<p class="wiz-q">A quina carpeta ha de treballar?</p>
|
|
1469
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>
|
|
1470
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>
|
|
1471
1514
|
<div class="row"><button class="chip" type="button" data-gh>${gh ? 'Torna a les carpetes' : 'Clona de GitHub'}</button>${quick}</div>
|
|
1472
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>` : '')
|
|
1473
|
-
: !fsd ? '<p class="muted small">Carregant…</p>' :
|
|
1516
|
+
: !fsd ? '<p class="muted small">Carregant…</p>' : `${crumbBar(fsd)}
|
|
1474
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>`}`;
|
|
1475
1518
|
},
|
|
1476
1519
|
() => {
|
|
@@ -1636,34 +1679,27 @@ async function loadOllama(force) {
|
|
|
1636
1679
|
paintModels();
|
|
1637
1680
|
}
|
|
1638
1681
|
function ollamaSection() {
|
|
1639
|
-
if (!machine()) return `<
|
|
1640
|
-
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>`;
|
|
1641
1684
|
}
|
|
1642
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.';
|
|
1643
1687
|
function paintModels() {
|
|
1644
1688
|
const box = $('#modelsBody'); if (!box || !state.ollama) return;
|
|
1645
1689
|
const o = state.ollama, have = new Set(o.models.map((m) => m.name));
|
|
1646
1690
|
if (!o.available) {
|
|
1647
|
-
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>`;
|
|
1648
1692
|
return;
|
|
1649
1693
|
}
|
|
1650
1694
|
const pulling = o.pulling || [];
|
|
1651
|
-
box.innerHTML = `<div class="
|
|
1652
|
-
<div class="
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
</div>
|
|
1659
|
-
<div class="stack">
|
|
1660
|
-
<section class="panel"><header><h2>Descarrega un model</h2></header><div class="body stack">
|
|
1661
|
-
<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>
|
|
1662
|
-
<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>
|
|
1663
|
-
<div class="list">${OLLAMA_PICKS.map((p) => { const busy = pulling.some((j) => j.name === p.name), got = have.has(p.name); return `
|
|
1664
|
-
<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>
|
|
1665
|
-
</div></section>
|
|
1666
|
-
</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>
|
|
1667
1703
|
</div>`;
|
|
1668
1704
|
}
|
|
1669
1705
|
const pullDone = new Set(); // descàrregues que han acabat abans que respongués la petició
|
|
@@ -1708,6 +1744,7 @@ window.NioBrains = {
|
|
|
1708
1744
|
list() {
|
|
1709
1745
|
const out = [{ id: 'rules', label: 'Ordres ràpides' }];
|
|
1710
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)` }));
|
|
1711
1748
|
if (local.engine) out.push({ id: 'local', label: `Xat local · ${LOCAL_MODELS.find((x) => x.id === local.model)?.name || local.model}` });
|
|
1712
1749
|
return out;
|
|
1713
1750
|
},
|
|
@@ -1716,6 +1753,22 @@ window.NioBrains = {
|
|
|
1716
1753
|
if (!machine()) throw new Error('No hi ha cap ordinador connectat');
|
|
1717
1754
|
return (await api('/api/ollama/chat', { method: 'POST', body: { model: id.slice(7), messages, format: 'json' } })).content;
|
|
1718
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
|
+
}
|
|
1719
1772
|
if (id === 'local') {
|
|
1720
1773
|
if (!local.engine) throw new Error('Primer obre un model a «Xat local»');
|
|
1721
1774
|
try {
|
|
@@ -1760,11 +1813,11 @@ const LOCAL_MODELS = [
|
|
|
1760
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.' },
|
|
1761
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.' }
|
|
1762
1815
|
];
|
|
1763
|
-
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 };
|
|
1764
1817
|
const stripThink = (t) => t.replace(/<think>[\s\S]*?(<\/think>|$)/g, '').trimStart();
|
|
1765
1818
|
function viewLocal() {
|
|
1766
1819
|
const hasGpu = 'gpu' in navigator, chosen = local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id);
|
|
1767
|
-
if (!
|
|
1820
|
+
if (!inLocalChat()) {
|
|
1768
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>
|
|
1769
1822
|
<h2 class="sec" style="margin-top:0">En aquest dispositiu</h2>
|
|
1770
1823
|
<div class="stack-lg" style="max-width:640px">
|
|
@@ -1774,14 +1827,15 @@ function viewLocal() {
|
|
|
1774
1827
|
</div>
|
|
1775
1828
|
${ollamaSection()}`;
|
|
1776
1829
|
}
|
|
1777
|
-
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);
|
|
1778
1831
|
return `<section class="chat">
|
|
1779
|
-
<div class="chat-head"><span>${bot('idle')}</span><div class="grow"><h1>${esc(info?.name || local.model)}</h1><div class="sub"
|
|
1780
|
-
<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>
|
|
1781
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>
|
|
1782
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>
|
|
1783
1836
|
</section>`;
|
|
1784
1837
|
}
|
|
1838
|
+
const inLocalChat = () => !!(local.engine || local.ollama);
|
|
1785
1839
|
function bindLocal() {
|
|
1786
1840
|
const feed = $('#lfeed'); if (!feed) return;
|
|
1787
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>`));
|
|
@@ -1816,6 +1870,7 @@ async function recoverLocal(text) {
|
|
|
1816
1870
|
await loadLocal(id);
|
|
1817
1871
|
}
|
|
1818
1872
|
async function loadLocal(id) {
|
|
1873
|
+
local.ollama = null;
|
|
1819
1874
|
local.loading = true; local.progress = 0; local.status = 'Preparant…'; render();
|
|
1820
1875
|
try {
|
|
1821
1876
|
const webllm = await import('https://esm.run/@mlc-ai/web-llm@0.2.86');
|
|
@@ -1838,6 +1893,11 @@ async function sendLocal() {
|
|
|
1838
1893
|
const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out); toBottom(feed);
|
|
1839
1894
|
local.busy = true; local.stopped = false; setLocalBusy(true); let acc = '';
|
|
1840
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 {
|
|
1841
1901
|
const chunks = await local.engine.chat.completions.create({
|
|
1842
1902
|
messages: [{ role: 'system', content: LOCAL_SYSTEM }, ...local.messages.slice(-8)], stream: true,
|
|
1843
1903
|
temperature: 0.7, top_p: 0.8, frequency_penalty: 0.6, presence_penalty: 0.4, repetition_penalty: 1.15, max_tokens: 700,
|
|
@@ -1847,12 +1907,13 @@ async function sendLocal() {
|
|
|
1847
1907
|
acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) toBottom(feed);
|
|
1848
1908
|
if (local.stopped) break;
|
|
1849
1909
|
}
|
|
1910
|
+
}
|
|
1850
1911
|
} catch (e) {
|
|
1851
1912
|
if (gpuLost(e) && !acc) { local.messages.pop(); out.remove(); feed.lastElementChild?.remove(); local.busy = false; local.stopped = false; setLocalBusy(false); return recoverLocal(text); }
|
|
1852
1913
|
if (!local.stopped) acc = acc || `Error: ${e.message}`; out.innerHTML = md(stripThink(acc));
|
|
1853
1914
|
}
|
|
1854
1915
|
out.classList.remove('cursor');
|
|
1855
|
-
if (local.stopped) { try { await local.engine.interruptGenerate(); } catch { /* res */ } }
|
|
1916
|
+
if (local.stopped && local.engine) { try { await local.engine.interruptGenerate(); } catch { /* res */ } }
|
|
1856
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(''); }
|
|
1857
1918
|
local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
|
|
1858
1919
|
}
|
|
@@ -1863,9 +1924,7 @@ const controlOn = () => !!(state.info?.control?.navegador?.enabled || state.info
|
|
|
1863
1924
|
// Aturada d'emergència: sempre a la vista mentre el control estigui actiu.
|
|
1864
1925
|
function renderPanic() {
|
|
1865
1926
|
syncScreenBtn();
|
|
1866
|
-
|
|
1867
|
-
if (!controlOn() || !machine()) { b?.remove(); return; }
|
|
1868
|
-
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();
|
|
1869
1928
|
}
|
|
1870
1929
|
async function panicNow() {
|
|
1871
1930
|
if (!confirm('S’aturaran tots els agents d’aquest ordinador, es rebutjaran les peticions pendents i es tancarà el navegador.')) return;
|
|
@@ -1910,7 +1969,7 @@ async function loadScreen() {
|
|
|
1910
1969
|
function syncScreenBtn() { const b = $('[data-act=screen-now]'); if (b) b.disabled = !controlOn(); }
|
|
1911
1970
|
function onScreen(msg) { if (screen && !screen.error) { screen.last = msg.shot; { const box = $('#screenBody'); if (box) box.innerHTML = screenHtml(); hydrateShots(box); } } }
|
|
1912
1971
|
/* ---------------------------------------------------------------- pantalla remota (veure i controlar l'ordinador) */
|
|
1913
|
-
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 };
|
|
1914
1973
|
const RD_KEYS = [['Escape', 'Esc'], ['Tab', 'Tab'], ['ArrowUp', '↑'], ['ArrowDown', '↓'], ['ArrowLeft', '←'], ['ArrowRight', '→'], ['Enter', '⏎'], ['Backspace', '⌫']];
|
|
1915
1974
|
const RD_MODS = [['meta', '⌘'], ['alt', '⌥'], ['ctrl', '⌃'], ['shift', '⇧']];
|
|
1916
1975
|
const RD_SPECIAL = /^(Escape|Tab|Arrow(Up|Down|Left|Right)|Home|End|PageUp|PageDown|Delete|F([1-9]|1[0-2]))$/;
|
|
@@ -1918,7 +1977,8 @@ const RD_SPECIAL = /^(Escape|Tab|Arrow(Up|Down|Left|Right)|Home|End|PageUp|PageD
|
|
|
1918
1977
|
async function remoteOpen() {
|
|
1919
1978
|
const m = machine(); if (!m || RD.on) return;
|
|
1920
1979
|
if (!(state.info?.control?.escriptori?.enabled || screen?.caps?.escriptori?.enabled)) return toast('Activa primer «Apps de l’escriptori» en aquest ordinador.', 5000);
|
|
1921
|
-
|
|
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 });
|
|
1922
1982
|
const root = document.createElement('div');
|
|
1923
1983
|
root.className = 'rd'; root.id = 'rd'; root.setAttribute('role', 'dialog'); root.setAttribute('aria-label', 'Pantalla remota');
|
|
1924
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>
|
|
@@ -1960,11 +2020,21 @@ async function rdLoop() {
|
|
|
1960
2020
|
const started = performance.now();
|
|
1961
2021
|
try {
|
|
1962
2022
|
const stage = $('#rdStage'); if (!stage) break;
|
|
1963
|
-
const w = Math.min(2400, Math.max(
|
|
1964
|
-
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' });
|
|
1965
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; }
|
|
1966
2028
|
const blob = await res.blob();
|
|
1967
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
|
+
}
|
|
1968
2038
|
const img = $('#rdImg'), old = RD.objUrl, url = URL.createObjectURL(blob);
|
|
1969
2039
|
RD.objUrl = url; img.onload = () => { if (old) URL.revokeObjectURL(old); }; img.src = url;
|
|
1970
2040
|
RD.sw = Number(res.headers.get('x-screen-w')) || RD.sw;
|
|
@@ -1977,7 +2047,7 @@ async function rdLoop() {
|
|
|
1977
2047
|
RD.err++; rdMsg(RD.err > 1 ? `${e.message || 'Sense connexió'}${/^Error|Activa|captura/.test(e.message) ? '' : ' · reconnectant…'}` : '');
|
|
1978
2048
|
await sleep(Math.min(3000, 500 * RD.err));
|
|
1979
2049
|
}
|
|
1980
|
-
await sleep(Math.max(
|
|
2050
|
+
await sleep(Math.max(0, 70 - (performance.now() - started)));
|
|
1981
2051
|
}
|
|
1982
2052
|
}
|
|
1983
2053
|
function rdSend(ev) {
|
|
@@ -2021,6 +2091,20 @@ function rdZoom(z, cx, cy) {
|
|
|
2021
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;
|
|
2022
2092
|
RD.px = ox - (ox - RD.px) * k; RD.py = oy - (oy - RD.py) * k; RD.z = z; rdView();
|
|
2023
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
|
+
}
|
|
2024
2108
|
function rdSave() {
|
|
2025
2109
|
const img = $('#rdImg'); if (!img?.src) return toast('Encara no hi ha cap imatge');
|
|
2026
2110
|
const d = new Date(), p = (n) => String(n).padStart(2, '0');
|
|
@@ -2338,7 +2422,8 @@ document.addEventListener('click', async (e) => {
|
|
|
2338
2422
|
case 'ollama-pull': return pullModel(a.dataset.name || $('#pullIn')?.value);
|
|
2339
2423
|
case 'ollama-cancel': try { await api(`/api/ollama/pull/${encodeURIComponent(a.dataset.name)}`, { method: 'DELETE' }); } catch (err) { toast(err.message); } return;
|
|
2340
2424
|
case 'ollama-del': return deleteModel(a.dataset.name);
|
|
2341
|
-
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();
|
|
2342
2427
|
case 'retry': return connect();
|
|
2343
2428
|
case 'model': { const s = state.sessions.get(state.route.id); if (s) modelSheet(s); return; }
|
|
2344
2429
|
case 'mode': { const s = state.sessions.get(state.route.id); if (s) modeSheet(s); return; }
|
|
@@ -2350,7 +2435,7 @@ document.addEventListener('click', async (e) => {
|
|
|
2350
2435
|
case 'job-new': return newJobSheet();
|
|
2351
2436
|
case 'panic': return panicNow();
|
|
2352
2437
|
case 'remote-open': remoteOpen(); return;
|
|
2353
|
-
case 'screen-now':
|
|
2438
|
+
case 'screen-now': return screenNow();
|
|
2354
2439
|
case 'mem-new': return memEdit(null);
|
|
2355
2440
|
case 'mem-all': if (mem) delete mem.hits; return loadMemory();
|
|
2356
2441
|
case 'activity-refresh': activity = null; return loadActivity();
|
|
@@ -2359,13 +2444,14 @@ document.addEventListener('click', async (e) => {
|
|
|
2359
2444
|
case 'open-sessions': return go('#/sessions');
|
|
2360
2445
|
case 'help': return go('#/ajuda');
|
|
2361
2446
|
case 'more': return moreSheet();
|
|
2447
|
+
case 'pantalla-tab': return pantallaTab();
|
|
2362
2448
|
case 'folder': { const s = state.sessions.get(state.route.id); return s && folderSheet(s); }
|
|
2363
2449
|
case 'machines': return machinesSheet();
|
|
2364
2450
|
case 'mobile-qr': return mobileQrSheet();
|
|
2365
2451
|
case 'mobile-hint-off': { const m = machine(); LS.set(`nio.mobHint.${m?.id || m?.url}`, true); return render(); }
|
|
2366
2452
|
case 'git': return gitSheet(state.route.id);
|
|
2367
2453
|
case 'lm-load': return loadLocal(local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id));
|
|
2368
|
-
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();
|
|
2369
2455
|
case 'lm-clear': local.messages = []; return render();
|
|
2370
2456
|
case 'palette': return palette();
|
|
2371
2457
|
case 'tour': return tourStart();
|
|
@@ -2438,48 +2524,112 @@ $('#side').addEventListener('click', (e) => { if (e.target.closest('a, button'))
|
|
|
2438
2524
|
addEventListener('keydown', (e) => { if (e.key === 'Escape' && document.body.classList.contains('nav-open')) setNav(false); });
|
|
2439
2525
|
mq.addEventListener('change', () => setNav(false));
|
|
2440
2526
|
/* ---------------------------------------------------------------- tutorial */
|
|
2441
|
-
const TOUR_KEY = 'nio.tour.
|
|
2442
|
-
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 = '';
|
|
2443
2530
|
const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
|
|
2531
|
+
const PAGE = '.page .top, .top';
|
|
2444
2532
|
const TSTEPS = [
|
|
2445
|
-
{ 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.
|
|
2446
|
-
{ route: 'ordinadors', target: '#addComputer', m: '#addComputer', title: '
|
|
2447
|
-
{ route: 'ordinadors', skipIf: () => !machine(), target: '.card.computer.active .c-actions', m: '.card.computer.active .c-actions', title: '
|
|
2448
|
-
{ route: '
|
|
2449
|
-
{ route: '
|
|
2450
|
-
{ route: '
|
|
2451
|
-
{ route: '
|
|
2452
|
-
{ route: '
|
|
2453
|
-
{
|
|
2454
|
-
{
|
|
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' }
|
|
2455
2550
|
];
|
|
2456
2551
|
const tval = (v) => (typeof v === 'function' ? v() : v);
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
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++;
|
|
2461
2581
|
if (i >= TSTEPS.length) return tourEnd(true);
|
|
2462
2582
|
T.i = Math.max(0, i); saveTour();
|
|
2463
|
-
const st = TSTEPS[T.i];
|
|
2464
|
-
|
|
2465
|
-
|
|
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 });
|
|
2466
2611
|
}
|
|
2467
2612
|
function tourDraw(scroll) {
|
|
2468
|
-
if (!T.on) return;
|
|
2613
|
+
if (!T.on || T.busy) return;
|
|
2469
2614
|
const st = TSTEPS[T.i], card = $('#tourCard'), busy = $('#sheet').open || $('#palette').open;
|
|
2470
2615
|
$('#tour').hidden = busy; if (busy) return;
|
|
2471
|
-
const
|
|
2472
|
-
|
|
2473
|
-
|
|
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>
|
|
2474
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>`}
|
|
2475
2624
|
<div class="tour-foot">${st.center ? '' : `<div>${T.i > 1 ? '<button class="btn sm ghost" data-tour="back" type="button">Enrere</button>' : ''}</div>`}
|
|
2476
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>`;
|
|
2477
|
-
|
|
2626
|
+
window.NioBot?.refresh();
|
|
2627
|
+
}
|
|
2628
|
+
tourPlace(scroll);
|
|
2478
2629
|
}
|
|
2479
2630
|
function tourTarget() {
|
|
2480
|
-
const st = TSTEPS[T.i], sel = st.center ? null : tval(mq.matches && 'm' in st ? st.m : st.target)
|
|
2481
|
-
|
|
2482
|
-
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);
|
|
2483
2633
|
}
|
|
2484
2634
|
function tourPlace(scroll) {
|
|
2485
2635
|
if (!T.on || $('#tour').hidden) return;
|
|
@@ -2518,18 +2668,18 @@ function tourPlace(scroll) {
|
|
|
2518
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; }
|
|
2519
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: '' });
|
|
2520
2670
|
}
|
|
2521
|
-
function tourUpdate() { if (T.on) setTimeout(() => tourDraw(false), 30); }
|
|
2671
|
+
function tourUpdate() { if (T.on && !T.busy) setTimeout(() => tourDraw(false), 30); }
|
|
2522
2672
|
$('#tour').addEventListener('click', (e) => {
|
|
2523
2673
|
const b = e.target.closest('[data-tour]'); if (!b) return;
|
|
2524
2674
|
if (b.dataset.tour === 'close') tourEnd(false);
|
|
2525
2675
|
else if (b.dataset.tour === 'next') tourGo(T.i + 1);
|
|
2526
|
-
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); }
|
|
2527
2677
|
});
|
|
2528
|
-
addEventListener('resize', () => tourPlace(false));
|
|
2529
|
-
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 });
|
|
2530
2680
|
addEventListener('keydown', (e) => {
|
|
2531
2681
|
if (!T.on || $('#sheet').open || $('#palette').open || e.target.closest?.('input, textarea')) return;
|
|
2532
|
-
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); }
|
|
2533
2683
|
});
|
|
2534
2684
|
$('#sheet').addEventListener('close', () => setTimeout(() => tourDraw(false), 30));
|
|
2535
2685
|
$('#palette').addEventListener('close', () => setTimeout(() => tourDraw(false), 30));
|