nio-cat 0.9.10 → 0.9.13
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/claude.js +4 -5
- package/src/agents/ollama.js +2 -2
- package/src/call.js +1 -1
- package/src/notes.js +220 -2
- package/src/voice.js +17 -5
- package/src/whisper.js +3 -3
- package/web/app/app.js +75 -21
- package/web/app/call.js +2 -1
- package/web/app/index.html +1 -0
- package/web/app/notes-live.js +427 -0
- package/web/app/notes.css +67 -0
- package/web/app/notes.js +22 -5
- package/web/app/style.css +34 -4
- package/web/board.css +9 -9
- package/web/index.html +4 -0
- package/web/sw.js +2 -2
package/web/app/app.js
CHANGED
|
@@ -206,8 +206,11 @@ async function api(path, opts = {}) {
|
|
|
206
206
|
const body = opts.body && typeof opts.body !== 'string' ? opts.body : opts.body ? JSON.parse(opts.body) : undefined;
|
|
207
207
|
let res;
|
|
208
208
|
try { res = await fetch(m.url + path, { ...opts, headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json', ...(opts.headers || {}) }, body: body ? JSON.stringify(body) : undefined }); }
|
|
209
|
-
catch (e) { if (e.name === 'AbortError') throw e; throw new Error(`No he pogut parlar amb ${m.name || 'l’ordinador'}. Comprova que nio hi estigui engegat i torna-ho a provar.`); }
|
|
210
|
-
const data = await res.json().catch(() =>
|
|
209
|
+
catch (e) { if (e.name === 'AbortError') throw e; throw Object.assign(new Error(`No he pogut parlar amb ${m.name || 'l’ordinador'}. Comprova que nio hi estigui engegat i torna-ho a provar.`), { unreachable: true }); }
|
|
210
|
+
const data = await res.json().catch(() => null);
|
|
211
|
+
// Un túnel mort respon una pàgina d'error de Cloudflare (502, 530…), no el JSON de nio: és com no arribar-hi.
|
|
212
|
+
if (!data && res.status >= 500) throw Object.assign(new Error(`No arribo a ${m.name || 'l’ordinador'}`), { unreachable: true });
|
|
213
|
+
if (!data) { if (!res.ok) throw new Error(`Error ${res.status}`); return {}; }
|
|
211
214
|
if (!res.ok) throw new Error(data.error || `Error ${res.status}`);
|
|
212
215
|
if (Array.isArray(data.agents)) data.agents = data.agents.filter((x) => x.id !== 'demo'); // l'agent de proves del programa no s'ensenya
|
|
213
216
|
return data;
|
|
@@ -320,11 +323,13 @@ async function connect() {
|
|
|
320
323
|
} catch (e) {
|
|
321
324
|
if (ctrl.signal.aborted) return;
|
|
322
325
|
state.connected = false;
|
|
323
|
-
|
|
326
|
+
const unreachable = e.unreachable || e.message === 'Failed to fetch' || e.name === 'TypeError' || /^Error 5\d\d$/.test(e.message);
|
|
327
|
+
state.lastError = unreachable ? (location.protocol === 'https:' && /^http:\/\/(127\.0\.0\.1|localhost)/.test(m.url) ? 'El navegador no deixa que aquesta web parli amb aquest ordinador. Prova Chrome, o engega nio amb --local.' : 'No arribo a l’ordinador') : e.message;
|
|
324
328
|
render();
|
|
325
329
|
if (e.fatal) return;
|
|
326
330
|
// Potser el túnel ha canviat d'adreça: la busquem a nio.cat (signada per l'ordinador).
|
|
327
|
-
|
|
331
|
+
// (Passa sobretot quan es torna a executar install.sh: el túnel ràpid de Cloudflare canvia d'adreça.)
|
|
332
|
+
if (unreachable && (await relocate(store.active))) { if (!ctrl.signal.aborted) connect(); return; }
|
|
328
333
|
if (m.kind === 'vps' && m.via && retry >= 2) autoReconnect(m);
|
|
329
334
|
setTimeout(() => machine() && !ctrl.signal.aborted && connect(), Math.min(15000, 1000 * 2 ** retry++));
|
|
330
335
|
}
|
|
@@ -578,7 +583,7 @@ function renderChrome() {
|
|
|
578
583
|
}
|
|
579
584
|
let notesCtl = null, callCtl = null;
|
|
580
585
|
const getCall = () => (callCtl ||= createCall({ api, esc, toast, bot, machine, isDemo, noMachine, voiceSheet, agentName: (a) => AGENT_NAMES[a] || a, sessions: () => [...state.sessions.values()], approvals: () => [...state.approvals.keys()], refresh: () => {} }));
|
|
581
|
-
const getNotes = () => (notesCtl ||= createNotes({ api, nodeAudio, toast, esc, machine, voiceOn, voiceSheet, isDemo, isPhone, openSheet, closeSheet, mobileQrSheet }));
|
|
586
|
+
const getNotes = () => (notesCtl ||= createNotes({ api, nodeAudio, toast, esc, machine, voiceOn, voiceSheet, isDemo, isPhone, openSheet, closeSheet, mobileQrSheet, nodeVersion: () => state.info?.version || '', nodeAtLeast: (v) => !state.info || verAtLeast(state.info.version, v), installCmd: INSTALL_CMD }));
|
|
582
587
|
function render() {
|
|
583
588
|
const r = state.route, view = $('#view'), board = r.name === 'pissarra';
|
|
584
589
|
if (r.name !== 'sessions') manage = null;
|
|
@@ -733,10 +738,12 @@ function noMachine(text) {
|
|
|
733
738
|
function viewSettings() {
|
|
734
739
|
const head = '<div class="top"><div><h1>Ajustos</h1><p>Avisos al mòbil, veu i Telegram. Es configuren per a cada ordinador.</p></div></div>';
|
|
735
740
|
if (!machine()) return `${head}<div class="stack-lg" style="max-width:640px">${settingsOff()}</div>`;
|
|
736
|
-
const avisos = machine() ? `<section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}${pushOn ? '<p class="small"><span class="chip ok">' + svg(ICON.check) + 'Avisos activats en aquest dispositiu</span></p>' : ''}<div class="row">${pushOn ? '<button class="btn ink grow" data-act="push-test" type="button">Envia’n un de prova</button><button class="btn" data-act="push-off" type="button">Desactiva</button>' : '<button class="btn ink grow" data-act="push" type="button">Activa els avisos</button>'}</div></div></section>` : '';
|
|
741
|
+
const avisos = window.NioAndroid && machine() ? '<section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Dins l’app d’Android, els avisos encara no hi són. Per rebre’ls, connecta Telegram aquí sota o activa’ls obrint nio.cat al navegador.</p></div></section>' : machine() ? `<section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}${pushOn ? '<p class="small"><span class="chip ok">' + svg(ICON.check) + 'Avisos activats en aquest dispositiu</span></p>' : ''}<div class="row">${pushOn ? '<button class="btn ink grow" data-act="push-test" type="button">Envia’n un de prova</button><button class="btn" data-act="push-off" type="button">Desactiva</button>' : '<button class="btn ink grow" data-act="push" type="button">Activa els avisos</button>'}</div></div></section>` : '';
|
|
737
742
|
if (machine() && pushOn === null) refreshPush();
|
|
738
743
|
if (machine() && tgState === undefined) loadTelegram();
|
|
739
|
-
|
|
744
|
+
// App d'Android: només es proposa des del navegador d'un mòbil Android (dins l'app ja no cal).
|
|
745
|
+
const android = /Android/i.test(navigator.userAgent) && !window.NioAndroid ? `<section class="panel"><header><h2>App d’Android</h2></header><div class="body stack"><p class="small">nio a pantalla completa i, sobretot, <b>classes i reunions que es continuen gravant amb la pantalla bloquejada</b>. En instal·lar-la, el navegador et demanarà permís per instal·lar apps d’aquesta font. Dins l’app, torna a connectar l’ordinador amb el QR.</p><div class="row"><a class="btn ink grow" href="/nio-android.apk" download="nio.apk">Baixa l’app (APK)</a></div></div></section>` : '';
|
|
746
|
+
return `${head}<div class="stack-lg cols">${android}${avisos}${voicePanel()}<div id="tgPanel">${telegramPanel()}</div></div>`;
|
|
740
747
|
}
|
|
741
748
|
// Ajustos sense ordinador: un sol avís a dalt i les seccions de mostra, desactivades.
|
|
742
749
|
function settingsOff() {
|
|
@@ -801,15 +808,27 @@ async function sshAdd(target, onStep, via) {
|
|
|
801
808
|
if (!last || last.error) throw new Error(last?.error || 'S’ha tallat la connexió');
|
|
802
809
|
return last;
|
|
803
810
|
}
|
|
811
|
+
// Lector de QR: BarcodeDetector si el navegador en té; si no (Firefox, l'app d'Android), jsQR, que llegeix la imatge en JavaScript.
|
|
812
|
+
let jsQRload = null;
|
|
813
|
+
const loadJsQR = () => (jsQRload ||= new Promise((ok, ko) => { const s = document.createElement('script'); s.src = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js'; s.onload = () => (window.jsQR ? ok(window.jsQR) : ko(new Error('jsQR'))); s.onerror = () => { jsQRload = null; ko(new Error('No he pogut carregar el lector de QR')); }; document.head.appendChild(s); }));
|
|
804
814
|
async function startScan(video, onText) {
|
|
805
|
-
|
|
815
|
+
let detect;
|
|
816
|
+
if ('BarcodeDetector' in window) { const det = new BarcodeDetector({ formats: ['qr_code'] }); detect = async () => (await det.detect(video))[0]?.rawValue; }
|
|
817
|
+
else {
|
|
818
|
+
const jsQR = await loadJsQR(), cv = document.createElement('canvas'), g = cv.getContext('2d', { willReadFrequently: true });
|
|
819
|
+
detect = async () => {
|
|
820
|
+
const w = Math.min(640, video.videoWidth), h = Math.round(video.videoHeight * (w / video.videoWidth)); if (!w || !h) return null;
|
|
821
|
+
cv.width = w; cv.height = h; g.drawImage(video, 0, 0, w, h);
|
|
822
|
+
return jsQR(g.getImageData(0, 0, w, h).data, w, h, { inversionAttempts: 'dontInvert' })?.data;
|
|
823
|
+
};
|
|
824
|
+
}
|
|
806
825
|
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
|
|
807
826
|
video.srcObject = stream; await video.play(); let alive = true;
|
|
808
|
-
(async () => { while (alive) { try { const
|
|
827
|
+
(async () => { while (alive) { try { const t = await detect(); if (t) onText(t); } catch { /* quadre sense QR */ } await sleep(250); } })();
|
|
809
828
|
return { stop() { alive = false; stream.getTracks().forEach((t) => t.stop()); } };
|
|
810
829
|
}
|
|
811
830
|
function addComputerSheet(tab, opts = {}) {
|
|
812
|
-
const phone = isPhone(), canScan =
|
|
831
|
+
const phone = isPhone(), canScan = !!navigator.mediaDevices?.getUserMedia;
|
|
813
832
|
const TABS = phone ? [['code', 'Codi o QR', ICON.scan], ['vps', 'Servidor', ICON.server]] : [['local', 'Aquest ordinador', ICON.machine], ['vps', 'Servidor', ICON.server], ['code', 'Tinc un codi', ICON.scan]];
|
|
814
833
|
let cur = TABS.some((t) => t[0] === tab) ? tab : TABS[0][0], poll = 0, scan = null, shown = '', hosts = [];
|
|
815
834
|
const intro = (t, d) => `<div class="add-intro"><h3>${t}</h3>${d ? `<p>${d}</p>` : ''}</div>`;
|
|
@@ -1658,8 +1677,7 @@ $('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
|
|
|
1658
1677
|
|
|
1659
1678
|
// Al mòbil, els fulls es tanquen lliscant cap avall, com els de l'iPhone: el full segueix el dit
|
|
1660
1679
|
// i, si baixa prou o es llença, marxa; si no, torna a pujar. Si el contingut té scroll, primer s'ha d'arribar a dalt.
|
|
1661
|
-
(
|
|
1662
|
-
const d = $('#sheet');
|
|
1680
|
+
function dragToClose(d, close) {
|
|
1663
1681
|
let g = null;
|
|
1664
1682
|
const scrollerOf = (t) => { for (let el = t; el && el !== d.parentElement; el = el.parentElement) { if (el.scrollHeight > el.clientHeight + 1 && /auto|scroll/.test(getComputedStyle(el).overflowY)) return el; if (el === d) break; } return null; };
|
|
1665
1683
|
const reset = () => { d.style.transition = ''; d.style.transform = ''; };
|
|
@@ -1687,21 +1705,27 @@ $('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
|
|
|
1687
1705
|
const x = g; g = null; if (!x || x.lock !== 'y') return;
|
|
1688
1706
|
const [t0, y0] = x.pts[0], [t1, y1] = x.pts[x.pts.length - 1], v = (y1 - y0) / Math.max(1, t1 - t0);
|
|
1689
1707
|
if (e.type === 'touchend' && (x.dy > Math.min(160, d.offsetHeight * 0.28) || (v > 0.5 && x.dy > 20))) {
|
|
1690
|
-
if (reduce) { reset();
|
|
1708
|
+
if (reduce) { reset(); close(); return; }
|
|
1691
1709
|
d.style.transition = 'transform .2s cubic-bezier(.4,0,1,1)'; d.style.transform = `translateY(${d.offsetHeight + 20}px)`;
|
|
1692
|
-
setTimeout(() => {
|
|
1710
|
+
setTimeout(() => { close(); requestAnimationFrame(reset); }, 200);
|
|
1693
1711
|
} else { d.style.transition = 'transform .3s cubic-bezier(.22,1,.36,1)'; d.style.transform = ''; setTimeout(reset, 300); }
|
|
1694
1712
|
};
|
|
1695
1713
|
d.addEventListener('touchend', end, { passive: true });
|
|
1696
1714
|
d.addEventListener('touchcancel', end, { passive: true });
|
|
1697
1715
|
d.addEventListener('close', reset);
|
|
1698
|
-
}
|
|
1716
|
+
}
|
|
1717
|
+
dragToClose($('#sheet'), () => closeSheet());
|
|
1718
|
+
dragToClose($('#palette'), () => $('#palette').close());
|
|
1699
1719
|
|
|
1700
|
-
// Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
|
|
1701
|
-
|
|
1720
|
+
// Claude Code no deixa «Via lliure» (bypassPermissions) quan nio corre com a root (típic en un VPS).
|
|
1721
|
+
// Des de nio-cat 0.9.12, com a root fa servir el mode auto de Claude Code; només les versions anteriors el bloquegen.
|
|
1722
|
+
const verAtLeast = (v, min) => { const a = String(v || '').split('.').map(Number), b = min.split('.').map(Number); for (let i = 0; i < 3; i++) { if ((a[i] || 0) !== b[i]) return (a[i] || 0) > b[i]; } return true; };
|
|
1723
|
+
const asRoot = (agentId) => agentId === 'claude' && state.info?.user === 'root';
|
|
1724
|
+
const modeBlocked = (agentId, mode) => mode === 'tot' && asRoot(agentId) && !verAtLeast(state.info?.version, '0.9.12');
|
|
1702
1725
|
function modeHelp(agentId, mode) {
|
|
1703
1726
|
const a = state.info?.agents?.find((x) => x.id === agentId);
|
|
1704
|
-
if (modeBlocked(agentId, mode)) return 'No disponible
|
|
1727
|
+
if (modeBlocked(agentId, mode)) return 'No disponible en aquesta versió de nio quan s’executa com a root. Actualitza nio (curl -fsSL https://www.nio.cat/install.sh | sh) o fes servir «Demana permís».';
|
|
1728
|
+
if (mode === 'tot' && asRoot(agentId)) return 'Treballa sense preguntar amb el mode auto de Claude Code (com a root no n’hi ha d’altre): un classificador revisa cada acció i atura les arriscades.';
|
|
1705
1729
|
if (mode === 'llegir') return 'Llegeix el projecte i et proposa canvis, però no toca cap fitxer.';
|
|
1706
1730
|
if (mode === 'tot') return 'Treballa sense preguntar. Fes-lo servir només en carpetes on confiïs plenament.';
|
|
1707
1731
|
if (a?.approvals) return 'Llegeix sol. Abans d’editar un fitxer o d’executar una ordre, et demana permís aquí.';
|
|
@@ -2632,7 +2656,20 @@ function voicePanel() {
|
|
|
2632
2656
|
const on = [...(v?.whisper?.ready ? ['Whisper'] : []), ...Object.entries(P).filter(([, p]) => p.key).map(([, p]) => p.name)];
|
|
2633
2657
|
return `<section class="panel"><header><h2>Veu</h2>${on.length ? `<span class="chip ok"><i class="dot on"></i>${esc(on.join(' · '))}</span>` : ''}</header><div class="body stack"><p class="small">Dicta els missatges amb el micròfon i escolta les respostes. Amb <b>Whisper</b> és gratis i l’àudio no surt de l’ordinador (${esc(machine().name)}). Si hi poses una clau d’ElevenLabs o OpenAI, també es queda a l’ordinador.</p>${v ? '' : '<p class="muted small">Aquest ordinador encara no té la veu (cal nio 0.7 o més nou).</p>'}<div class="row"><button class="btn ink" data-act="voice" type="button" ${v ? '' : 'disabled'}>${on.length ? 'Canvia la configuració' : 'Configura la veu'}</button></div></div></section>`;
|
|
2634
2658
|
}
|
|
2635
|
-
|
|
2659
|
+
// Pas a pas per fer la clau d'ElevenLabs amb només els permisos que fa servir nio.
|
|
2660
|
+
const EL_GUIDE = `<details class="el-guide" id="elGuide"><summary>Com faig la clau? Pas a pas</summary><ol>
|
|
2661
|
+
<li>Obre <a href="https://elevenlabs.io/app/developers/api-keys" target="_blank" rel="noopener">elevenlabs.io → Developers → API Keys</a> i prem <b>Create API Key</b>.</li>
|
|
2662
|
+
<li><b>Name</b>: «nio». <b>Expire After</b>: «Never» (o la data que vulguis).</li>
|
|
2663
|
+
<li>Activa <b>Restrict Key</b> i, a <b>Endpoints</b>, deixa-ho així:<ul>
|
|
2664
|
+
<li><b>Text to Speech</b> → <span class="el-on">Access</span> · per escoltar les respostes</li>
|
|
2665
|
+
<li><b>ElevenAgents</b> → <span class="el-on">Write</span> · per a la Trucada amb en Nio</li>
|
|
2666
|
+
<li><b>Speech to Text</b> → <span class="el-on">Access</span> · només si vols que ElevenLabs transcrigui en lloc de Whisper</li>
|
|
2667
|
+
<li>Tota la resta, també <b>Administration</b> → <span class="el-off">No Access</span></li></ul></li>
|
|
2668
|
+
<li><b>Usage Limits</b> (opcional): posa un màxim de crèdits, perquè no gasti més del que vols.</li>
|
|
2669
|
+
<li>Deixa activat <b>Auto-disable if leaked</b>.</li>
|
|
2670
|
+
<li>Prem <b>Create</b>, copia la clau (<span class="mono">sk_…</span>; només es veu un cop) i enganxa-la aquí sobre. Es desa a l’ordinador i no torna mai al mòbil.</li>
|
|
2671
|
+
</ol><p class="muted small">Si falta algun permís, nio et dirà quin has d’activar.</p></details>`;
|
|
2672
|
+
async function voiceSheet(opts = {}) {
|
|
2636
2673
|
await loadVoice();
|
|
2637
2674
|
if (!nodeVoice) return toast('Aquest ordinador encara no té la veu. Actualitza nio (npx nio-cat@latest).', 5000);
|
|
2638
2675
|
if (isDemo()) return toast('A la demo no hi ha veu. Amb nio instal·lat, dictes amb Whisper, gratis.', 4500);
|
|
@@ -2642,6 +2679,7 @@ async function voiceSheet() {
|
|
|
2642
2679
|
<form class="stack" id="voiceForm" style="margin-top:14px">
|
|
2643
2680
|
<details class="voice-extras"><summary>Extres opcionals · ElevenLabs i OpenAI</summary><div class="stack"><p class="muted small">Afegeix una clau per fer servir aquests serveis. Whisper funciona sense cap clau.</p>
|
|
2644
2681
|
<label class="f"><span>Clau d’ElevenLabs ${v.providers.elevenlabs.key ? `<span class="muted">(desada: ${esc(v.providers.elevenlabs.key)})</span>` : ''}</span><input type="password" id="vEl" placeholder="${v.providers.elevenlabs.key ? 'Deixa-ho buit per mantenir-la' : 'sk_…'}" value="${!v.providers.elevenlabs.key && old.key ? esc(old.key) : ''}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
|
|
2682
|
+
${EL_GUIDE}
|
|
2645
2683
|
<label class="f"><span>Clau d’OpenAI ${v.providers.openai.key ? `<span class="muted">(desada: ${esc(v.providers.openai.key)})</span>` : ''}</span><input type="password" id="vOa" placeholder="${v.providers.openai.key ? 'Deixa-ho buit per mantenir-la' : 'sk-…'}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
|
|
2646
2684
|
<div class="grid-2"><label class="f"><span>Per entendre’t</span><select id="vStt"><option value="">Automàtic</option>${v.whisper ? `<option value="whisper" ${v.stt === 'whisper' ? 'selected' : ''}>Whisper (gratis)</option>` : ''}<option value="elevenlabs" ${v.stt === 'elevenlabs' ? 'selected' : ''}>ElevenLabs Scribe</option><option value="openai" ${v.stt === 'openai' ? 'selected' : ''}>OpenAI</option></select></label>
|
|
2647
2685
|
<label class="f"><span>Per parlar</span><select id="vTts"><option value="">Automàtic</option><option value="elevenlabs" ${v.tts === 'elevenlabs' ? 'selected' : ''}>ElevenLabs</option><option value="openai" ${v.tts === 'openai' ? 'selected' : ''}>OpenAI</option></select></label></div>
|
|
@@ -2650,6 +2688,7 @@ async function voiceSheet() {
|
|
|
2650
2688
|
<button class="btn ghost" type="button" id="vOff">Treu les claus</button></div></details>
|
|
2651
2689
|
<div class="row sheet-foot"><button class="btn ink" type="submit">Desa</button></div>
|
|
2652
2690
|
</form>`, (sh) => {
|
|
2691
|
+
if (opts.guide) { const g = $('#elGuide', sh); sh.querySelector('.voice-extras').open = true; g.open = true; setTimeout(() => g.scrollIntoView({ block: 'start', behavior: 'smooth' }), 120); }
|
|
2653
2692
|
const put = async (body, msg) => { try { nodeVoice = await api('/api/voice', { method: 'PUT', body }); try { localStorage.removeItem('nio.voice'); } catch { /* res */ } closeSheet(); toast(msg); if (state.route.name === 'session') renderSession(); else render(); } catch (e) { toast(e.message, 4500); } };
|
|
2654
2693
|
$('#voiceForm', sh).onsubmit = (e) => { e.preventDefault(); const keys = {}; const el = $('#vEl', sh).value.trim(), oa = $('#vOa', sh).value.trim(); if (el) keys.elevenlabs = el; if (oa) keys.openai = oa; put({ keys, stt: $('#vStt', sh).value || null, tts: $('#vTts', sh).value || null, speak: $('#vSpeak', sh).checked }, 'Veu desada a l’ordinador'); };
|
|
2655
2694
|
$('#vOff', sh).onclick = () => { if (confirm('Es treuran les claus de veu d’aquest ordinador.')) put({ keys: { elevenlabs: null, openai: null } }, 'Claus eliminades'); };
|
|
@@ -3021,6 +3060,20 @@ if ('serviceWorker' in navigator) {
|
|
|
3021
3060
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
|
3022
3061
|
navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'open-session' && e.data.id) go(`#/s/${e.data.id}`); });
|
|
3023
3062
|
}
|
|
3063
|
+
// L'app d'Android (i la de la pantalla d'inici) es queda oberta dies sense recarregar: en tornar-hi, si hi ha una versió
|
|
3064
|
+
// nova de la web (sw.js canvia a cada canvi de l'app), es recarrega, però només si no estàs fent res a mitges.
|
|
3065
|
+
(() => {
|
|
3066
|
+
const ver = async () => { try { return (await (await fetch('/sw.js', { cache: 'no-store' })).text()).match(/nio-shell-v\d+/)?.[0] || null; } catch { return null; } };
|
|
3067
|
+
const busy = () => document.querySelector('dialog[open], .nt-ov, .call-ov, .rd, .lv-pill, .lv-rec') || [...document.querySelectorAll('textarea, input[type=text], input:not([type])')].some((t) => t.value.trim());
|
|
3068
|
+
let mine = null, hiddenAt = 0;
|
|
3069
|
+
ver().then((v) => { mine = v; });
|
|
3070
|
+
document.addEventListener('visibilitychange', async () => {
|
|
3071
|
+
if (document.hidden) { hiddenAt = Date.now(); return; }
|
|
3072
|
+
if (!mine || Date.now() - hiddenAt < 60000) return;
|
|
3073
|
+
const now = await ver();
|
|
3074
|
+
if (now && now !== mine && !document.hidden && !busy()) location.reload();
|
|
3075
|
+
});
|
|
3076
|
+
})();
|
|
3024
3077
|
addEventListener('hashchange', route);
|
|
3025
3078
|
// El mòbil congela la pàgina en segon pla i el flux queda mort sense cap error: l'ordinador envia un ping cada 15 s,
|
|
3026
3079
|
// així que si fa més de 40 s que no arriba res (o en tornar a l'app, més de 20 s), es torna a connectar sol.
|
|
@@ -3053,11 +3106,12 @@ if (T.on) tourGo(T.i); else if (!T.seen && !machine()) setTimeout(tourStart, 700
|
|
|
3053
3106
|
|
|
3054
3107
|
/* ---------------------------------------------------------------- lliscar entre pàgines (mòbil) */
|
|
3055
3108
|
(() => {
|
|
3056
|
-
|
|
3109
|
+
// Les pàgines de la barra de baix, en el mateix ordre («Pantalla» i «Més» no són pàgines: obren un full).
|
|
3110
|
+
const PAGES = ['sessions', 'notes', 'ordinadors'];
|
|
3057
3111
|
const view = $('#view');
|
|
3058
3112
|
let g = null, timer = 0;
|
|
3059
3113
|
const scrollsX = (el) => { for (; el && el !== view; el = el.parentElement) { if (el.scrollWidth > el.clientWidth + 2 && /auto|scroll/.test(getComputedStyle(el).overflowX)) return true; } return false; };
|
|
3060
|
-
const blocked = (t) => !mq.matches || document.body.classList.contains('nav-open') || $('#sheet')?.open || state.route.name === 'session' || t.closest?.('input, textarea, select, [contenteditable], [data-noswipe]') || scrollsX(t);
|
|
3114
|
+
const blocked = (t) => !mq.matches || document.body.classList.contains('nav-open') || $('#sheet')?.open || state.route.name === 'session' || t.closest?.('input, textarea, select, [contenteditable], [data-noswipe], .nt-card') || scrollsX(t); // a Notes, lliscar una targeta l'arxiva
|
|
3061
3115
|
const target = (dir) => PAGES[PAGES.indexOf(state.route.name) + dir];
|
|
3062
3116
|
const put = (x, o) => { view.style.setProperty('--sx', x + 'px'); view.style.setProperty('--so', String(o)); };
|
|
3063
3117
|
const clear = () => { view.classList.remove('swiping', 'swipe-anim'); view.style.removeProperty('--sx'); view.style.removeProperty('--so'); };
|
package/web/app/call.js
CHANGED
|
@@ -43,7 +43,7 @@ export function createCall(ctx) {
|
|
|
43
43
|
if (!m) return head + ctx.noMachine('La trucada parla amb un ordinador. Connecta’n un per trucar.');
|
|
44
44
|
const ready = demo || st?.ready, loading = !demo && !st;
|
|
45
45
|
const why = st?.error ? `<p class="call-why err">${esc(OLD.test(st.error) ? 'Aquest ordinador encara no sap trucar: actualitza nio (cal nio-cat 0.9.9 o més nou).' : st.error)}</p>`
|
|
46
|
-
: !ready && !loading ? '<p class="call-why">Per trucar cal una clau d’ElevenLabs
|
|
46
|
+
: !ready && !loading ? '<p class="call-why">Per trucar cal una clau d’ElevenLabs amb el permís «ElevenAgents → Write». Es queda a l’ordinador i el cost va al teu compte d’ElevenLabs. <button class="linkish" type="button" data-call="guide">Com faig la clau?</button></p>' : '';
|
|
47
47
|
return `${head}
|
|
48
48
|
<section class="call-hero">
|
|
49
49
|
<div class="call-face" aria-hidden="true"><span class="call-ring"></span><span class="call-ring r2"></span>${bot('idle', 'call-bot')}</div>
|
|
@@ -62,6 +62,7 @@ export function createCall(ctx) {
|
|
|
62
62
|
const a = b.dataset.call;
|
|
63
63
|
if (a === 'start') start();
|
|
64
64
|
else if (a === 'voice') ctx.voiceSheet();
|
|
65
|
+
else if (a === 'guide') ctx.voiceSheet({ guide: true });
|
|
65
66
|
});
|
|
66
67
|
|
|
67
68
|
// ---------------------------------------------------------------- pantalla de la trucada
|
package/web/app/index.html
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
33
33
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
34
34
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600&family=Caveat:wght@500;600&display=swap" rel="stylesheet">
|
|
35
|
+
<script>/* App d'Android: la pàgina va per sota de les barres del sistema; l'app en diu la mida abans de pintar. */try{var i=JSON.parse(NioAndroid.insets()),d=document.documentElement;d.style.setProperty('--safe-t',i.t+'px');d.style.setProperty('--safe-b',i.b+'px');d.classList.add('android')}catch(e){}</script>
|
|
35
36
|
<link rel="stylesheet" href="/bot.css">
|
|
36
37
|
<link rel="stylesheet" href="/board.css">
|
|
37
38
|
<link rel="stylesheet" href="/app/style.css">
|