nio-cat 0.9.7 → 0.9.10
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 +4 -2
- package/package.json +1 -1
- package/src/agents/claude.js +4 -0
- package/src/call.js +246 -0
- package/src/claude-hook.js +1 -0
- package/src/hub.js +29 -1
- package/src/mcp.js +9 -1
- package/src/server.js +8 -1
- package/src/terminal.js +147 -0
- package/web/app/app.js +175 -44
- package/web/app/call.css +80 -0
- package/web/app/call.js +435 -0
- package/web/app/demo.js +1 -0
- package/web/app/index.html +1 -0
- package/web/app/notes.css +96 -25
- package/web/app/notes.js +275 -32
- package/web/app/style.css +73 -3
- package/web/sw.js +2 -2
package/web/app/app.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Sense frameworks ni compilació. També inclou un ordinador de prova que viu dins del navegador.
|
|
3
3
|
|
|
4
4
|
import { createNotes } from './notes.js';
|
|
5
|
+
import { createCall } from './call.js';
|
|
5
6
|
|
|
6
7
|
const $ = (s, e = document) => e.querySelector(s);
|
|
7
8
|
const $$ = (s, e = document) => [...e.querySelectorAll(s)];
|
|
@@ -23,11 +24,12 @@ const CHIP_ICON = {
|
|
|
23
24
|
const svg = (d) => `<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
|
|
24
25
|
const ICON = {
|
|
25
26
|
activity: '<path d="M3 12h4l3-7 4 14 3-7h4"/>',
|
|
26
|
-
jobs: '<rect x="4" y="
|
|
27
|
+
jobs: '<rect x="4" y="5" width="16" height="15" rx="3"/><path d="M8 3v4M16 3v4M4 10h16m-12 5 2 2 5-4"/>',
|
|
27
28
|
screen: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M9 20h6M12 16v4"/><circle cx="12" cy="10" r="2"/>',
|
|
28
29
|
panic: '<circle cx="12" cy="12" r="9"/><path d="M8.5 8.5l7 7M15.5 8.5l-7 7"/>',
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
call: '<path d="M6.6 3.5h2.6l1.4 4-2 1.3a12 12 0 0 0 6.6 6.6l1.3-2 4 1.4v2.6a2 2 0 0 1-2.2 2A17 17 0 0 1 4.6 5.7a2 2 0 0 1 2-2.2z"/>',
|
|
31
|
+
notes: '<path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M9 15l1-4 8-8 3 3-8 8-4 1z"/>',
|
|
32
|
+
memory: '<path d="M12 5a3 3 0 0 0-5-2 4 4 0 0 0-3 6 4 4 0 0 0 0 7 4 4 0 0 0 8 3V5zm0 0a3 3 0 0 1 5-2 4 4 0 0 1 3 6 4 4 0 0 1 0 7 4 4 0 0 1-8 3M7 8l2 2M17 8l-2 2M6 15l3-1M18 15l-3-1"/>',
|
|
31
33
|
sessions: '<path d="M4 6h16M4 12h16M4 18h10"/>',
|
|
32
34
|
approvals: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
|
33
35
|
phone: '<rect x="6" y="3" width="12" height="18" rx="3"/><path d="M10.5 17.5h3"/>',
|
|
@@ -211,14 +213,14 @@ async function api(path, opts = {}) {
|
|
|
211
213
|
return data;
|
|
212
214
|
}
|
|
213
215
|
|
|
214
|
-
let streamCtrl = null, retry = 0, gotHello = false;
|
|
216
|
+
let streamCtrl = null, retry = 0, gotHello = false, alive = 0; // alive: última vegada que ha arribat alguna cosa de l'ordinador
|
|
215
217
|
async function pollLoop(ctrl) {
|
|
216
218
|
let first = true;
|
|
217
219
|
while (!ctrl.signal.aborted) {
|
|
218
220
|
try {
|
|
219
221
|
const [list, aps] = await Promise.all([api('/api/sessions'), api('/api/approvals')]);
|
|
220
222
|
if (ctrl.signal.aborted) return;
|
|
221
|
-
state.connected = true; state.lastError = null;
|
|
223
|
+
state.connected = true; state.lastError = null; alive = Date.now();
|
|
222
224
|
if (first) { first = false; state.synced = true; const r = state.route; state.sessions = new Map(list.map((x) => [x.id, x])); state.approvals = new Map(aps.map((a) => [a.id, a])); if (r.name === 'session') openSession(r.id); else render(); }
|
|
223
225
|
else {
|
|
224
226
|
const r = state.route, seen = new Set();
|
|
@@ -295,7 +297,7 @@ async function connect() {
|
|
|
295
297
|
const res = await fetch(`${m.url}/api/stream?d=${encodeURIComponent(deviceId())}&k=${isPhone() ? 'phone' : 'desktop'}`, { headers: { Authorization: `Bearer ${m.token}` }, signal: ctrl.signal, cache: 'no-store' });
|
|
296
298
|
if (res.status === 401) throw Object.assign(new Error('La clau no és vàlida. Torna a connectar amb el codi QR.'), { fatal: true });
|
|
297
299
|
if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
|
|
298
|
-
state.connected = true; state.lastError = null; retry = 0;
|
|
300
|
+
state.connected = true; state.lastError = null; retry = 0; alive = Date.now();
|
|
299
301
|
rememberIdentity(m.url, state.info.identity);
|
|
300
302
|
renameMachine(state.info.host, state.info.id); syncServiceWorker(); render(); brainsForBoard(); loadVoice().then(() => state.route.name === 'session' && renderComposer?.()); flushQueue();
|
|
301
303
|
const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '';
|
|
@@ -304,6 +306,7 @@ async function connect() {
|
|
|
304
306
|
const hw = setTimeout(() => { if (!gotHello) { poll = true; reader.cancel().catch(() => {}); } }, 5000);
|
|
305
307
|
for (;;) {
|
|
306
308
|
const { value, done } = await reader.read().catch(() => ({ done: true })); if (done) break;
|
|
309
|
+
alive = Date.now();
|
|
307
310
|
buf += dec.decode(value, { stream: true }); let i;
|
|
308
311
|
while ((i = buf.indexOf('\n\n')) >= 0) {
|
|
309
312
|
const block = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
@@ -363,6 +366,7 @@ function renameMachine(host, id) {
|
|
|
363
366
|
|
|
364
367
|
function handle(msg) {
|
|
365
368
|
const r = state.route;
|
|
369
|
+
callCtl?.onEvent(msg);
|
|
366
370
|
switch (msg.type) {
|
|
367
371
|
case 'device':
|
|
368
372
|
if (!isPhone() && msg.device !== deviceId()) { if ($('#qrBox')) closeSheet(); toast('Mòbil connectat correctament', 2000); }
|
|
@@ -510,18 +514,19 @@ function checkPairHash() {
|
|
|
510
514
|
/* ---------------------------------------------------------------- navegació */
|
|
511
515
|
const VIEWS = {
|
|
512
516
|
sessions: { label: 'Sessions', icon: ICON.sessions, group: 'Feina' },
|
|
513
|
-
approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
|
|
514
|
-
notes: { label: 'Notes', icon: ICON.notes, group: 'Feina' },
|
|
515
517
|
feines: { label: 'Feines', icon: ICON.jobs, group: 'Feina' },
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
518
|
+
approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
|
|
519
|
+
trucada: { label: 'Trucada', icon: ICON.call, group: 'Feina' },
|
|
520
|
+
notes: { label: 'Notes', icon: ICON.notes, group: 'Organització' },
|
|
521
|
+
memoria: { label: 'Memòria', icon: ICON.memory, group: 'Organització' },
|
|
522
|
+
pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Organització' },
|
|
523
|
+
ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors i eines' },
|
|
524
|
+
mobil: { label: 'Xat local', icon: ICON.phone, group: 'Ordinadors i eines' },
|
|
525
|
+
activitat: { label: 'Activitat', icon: ICON.activity, group: 'Ordinadors i eines' },
|
|
526
|
+
ajustos: { label: 'Ajustos', icon: ICON.settings, group: 'Preferències' },
|
|
527
|
+
ajuda: { label: 'Ajuda', icon: ICON.help, group: 'Preferències' }
|
|
523
528
|
};
|
|
524
|
-
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', notes: '#/notes', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria', ordinadors: '#/ordinadors', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
|
|
529
|
+
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', trucada: '#/trucada', notes: '#/notes', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria', ordinadors: '#/ordinadors', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
|
|
525
530
|
const NEEDS_MACHINE = new Set(['session']);
|
|
526
531
|
function parseRoute() {
|
|
527
532
|
const h = location.hash.replace(/^#\/?/, '');
|
|
@@ -557,8 +562,8 @@ function renderChrome() {
|
|
|
557
562
|
const m = machine(), sc = $('#sideConn');
|
|
558
563
|
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)}`;
|
|
559
564
|
sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
|
|
560
|
-
const more = ['
|
|
561
|
-
$('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
|
|
565
|
+
const more = ['feines', 'trucada', 'memoria', 'approvals', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
|
|
566
|
+
$('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['notes', ICON.notes, 'Notes', active === 'notes'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
|
|
562
567
|
.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('')
|
|
563
568
|
.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"`)
|
|
564
569
|
+ `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
|
|
@@ -571,8 +576,9 @@ function renderChrome() {
|
|
|
571
576
|
document.title = n ? `(${n}) nio` : `nio // ${s ? s.title || 'Sessió' : VIEWS[r.name]?.label || 'Panell'}`;
|
|
572
577
|
if ('setAppBadge' in navigator) (n ? navigator.setAppBadge(n) : navigator.clearAppBadge()).catch?.(() => {});
|
|
573
578
|
}
|
|
574
|
-
let notesCtl = null;
|
|
575
|
-
const
|
|
579
|
+
let notesCtl = null, callCtl = null;
|
|
580
|
+
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 }));
|
|
576
582
|
function render() {
|
|
577
583
|
const r = state.route, view = $('#view'), board = r.name === 'pissarra';
|
|
578
584
|
if (r.name !== 'sessions') manage = null;
|
|
@@ -581,9 +587,11 @@ function render() {
|
|
|
581
587
|
document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && inLocalChat()));
|
|
582
588
|
document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' && inLocalChat()));
|
|
583
589
|
if (r.name !== 'notes' && lastName === 'notes') notesCtl?.unmount();
|
|
590
|
+
if (r.name !== 'trucada' && lastName === 'trucada') callCtl?.unmount();
|
|
584
591
|
if (!board && lastName === 'pissarra') window.NioBoard?.unmount();
|
|
585
592
|
if (board && lastName === 'pissarra' && $('#boardRoot')) return;
|
|
586
593
|
if (r.name === 'notes' && lastName === 'notes' && $('#notesRoot') && machine()) return; // l'editor no es toca quan l'app es repinta
|
|
594
|
+
if (r.name === 'trucada' && lastName === 'trucada' && $('#callRoot')?.dataset.m === String(store.active)) return; // la pàgina de la trucada no es repinta a cada esdeveniment
|
|
587
595
|
const changed = lastName !== r.name || r.name === 'session';
|
|
588
596
|
lastName = r.name;
|
|
589
597
|
if (r.name === 'session') renderSession();
|
|
@@ -591,12 +599,14 @@ function render() {
|
|
|
591
599
|
view.innerHTML = machine() ? '<div class="page" id="notesRoot"></div>' : `<div class="page"><div class="top"><div><h1>Notes</h1><p>Escriu, grava i deixa que un model ho ordeni.</p></div></div>${noMachine('Les notes es desen a l’ordinador. Connecta’n un per escriure’n i gravar-ne.')}</div>`;
|
|
592
600
|
if (machine()) getNotes().mount($('#notesRoot'));
|
|
593
601
|
}
|
|
602
|
+
else if (r.name === 'trucada') { view.innerHTML = `<div class="page" id="callRoot" data-m="${store.active}"></div>`; getCall().mount($('#callRoot')); }
|
|
594
603
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
595
604
|
else {
|
|
596
605
|
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
597
606
|
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>`;
|
|
598
607
|
if (r.name === 'mobil') bindLocal();
|
|
599
608
|
if (r.name === 'mobil' && machine() && !inLocalChat()) loadOllama();
|
|
609
|
+
if (r.name === 'sessions' && machine() && state.connected) loadTermSessions();
|
|
600
610
|
if (r.name === 'activitat' && machine()) { activity = null; loadActivity(); }
|
|
601
611
|
if (r.name === 'memoria' && machine()) loadMemory();
|
|
602
612
|
if (r.name === 'ordinadors' && machine() && state.connected) loadScreen();
|
|
@@ -611,7 +621,7 @@ function render() {
|
|
|
611
621
|
|
|
612
622
|
/* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
|
|
613
623
|
const deviceId = () => { let d = LS.get('nio.device', ''); if (!d) { d = Math.random().toString(36).slice(2, 12); LS.set('nio.device', d); } return d; };
|
|
614
|
-
const isPhone = () =>
|
|
624
|
+
const isPhone = () => navigator.userAgentData?.mobile === true || /Android|iPhone|iPod|Mobile/i.test(navigator.userAgent) || (isTouch && matchMedia('(max-width: 1024px)').matches);
|
|
615
625
|
const INSTALL_CMD = 'curl -fsSL https://www.nio.cat/install.sh | sh';
|
|
616
626
|
const cmdBox = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
|
|
617
627
|
// Estat de la resta d'ordinadors (el connectat ja té el seu flux en directe): una consulta lleugera.
|
|
@@ -655,6 +665,7 @@ function helpPanel(open) {
|
|
|
655
665
|
|
|
656
666
|
${grp('Treballar amb els agents')}
|
|
657
667
|
${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>`)}
|
|
668
|
+
${q('Continuar una sessió del terminal', `<p>Si tens Claude Code obert al terminal de l’ordinador, surt a dalt de <b>Sessions</b>, a <b>Obertes al terminal</b>, amb el terminal on corre (iTerm, Terminal, VS Code, tmux, SSH…). Toca-la i continua aquí amb tota la conversa. El terminal no es toca: el mòbil en fa una branca pròpia.</p>`)}
|
|
658
669
|
${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>`)}
|
|
659
670
|
${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>`)}
|
|
660
671
|
${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([
|
|
@@ -671,6 +682,7 @@ function helpPanel(open) {
|
|
|
671
682
|
${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>`)}
|
|
672
683
|
${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>`)}
|
|
673
684
|
${q('Veu', `<p>A <b>Ajustos → Veu</b> actives <b>Whisper</b> per <b>dictar</b> els missatges amb el micròfon, gratis i sense que l’àudio surti de l’ordinador. Per <b>escoltar</b> les respostes, hi pots posar una clau d’ElevenLabs o OpenAI, que es queda a l’ordinador.</p>`)}
|
|
685
|
+
${q('Trucada amb en Nio', `<p>A <b>Més → Trucada</b> parles amb en Nio com en una trucada, amb veu en temps real d’ElevenLabs. Et diu quins agents tens, com van les sessions i què espera el teu permís, i li pots demanar que encarregui feina, continuï una sessió o aprovi una acció (abans t’ho repeteix i espera un sí). Si una sessió acaba durant la trucada, t’ho diu.</p><ul><li>Cal una clau d’ElevenLabs a <b>Ajustos → Veu</b>. Es queda a l’ordinador: el mòbil només rep un enllaç que caduca.</li><li>La primera trucada crea l’agent «nio» al teu compte d’ElevenLabs. ElevenLabs cobra per minut.</li><li>Pots interrompre’l parlant, silenciar el micròfon i veure tota la conversa amb <b>Text</b>.</li></ul>`)}
|
|
674
686
|
|
|
675
687
|
${grp('Eines')}
|
|
676
688
|
${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>`)}
|
|
@@ -835,7 +847,7 @@ function addComputerSheet(tab, opts = {}) {
|
|
|
835
847
|
addMachine({ url: r.url, token: r.token, name: keepName || r.host, kind: 'vps', via: target });
|
|
836
848
|
if (keepName) { const ms = store.machines; ms[store.active].renamed = true; store.machines = ms; }
|
|
837
849
|
shown = 'vps-ok';
|
|
838
|
-
body.innerHTML = `${intro(`${esc(r.host)} és a punt`, r.claude ? 'Ja el pots fer servir: crea-hi una sessió amb Claude Code.' : 'Claude Code no hi és. Entra al servidor, instal·la’l i inicia sessió amb <span class="mono">claude</span>.')}<div class="row"><a class="btn ink" href="#/sessions" data-close>Obre sessions</a
|
|
850
|
+
body.innerHTML = `${intro(`${esc(r.host)} és a punt`, r.claude ? 'Ja el pots fer servir: crea-hi una sessió amb Claude Code.' : 'Claude Code no hi és. Entra al servidor, instal·la’l i inicia sessió amb <span class="mono">claude</span>.')}<div class="row"><a class="btn ink" href="#/sessions" data-close>Obre sessions</a>${isPhone() ? '' : '<button class="btn" type="button" data-act="mobile-qr">Continua al mòbil</button>'}</div>`;
|
|
839
851
|
} catch (err) { $$('li.on', steps).forEach((l) => { l.className = 'err'; }); steps.insertAdjacentHTML('beforeend', `<li class="err">${esc(err.message)}</li>`); btn.disabled = false; shown = 'vps'; }
|
|
840
852
|
};
|
|
841
853
|
const vps = () => {
|
|
@@ -911,8 +923,9 @@ async function pantallaTab() {
|
|
|
911
923
|
});
|
|
912
924
|
}
|
|
913
925
|
function moreSheet() {
|
|
914
|
-
const item = (k) => `<a class="
|
|
915
|
-
|
|
926
|
+
const item = (k) => `<a class="more-link" href="${HASH[k]}" ${state.route.name === k ? 'aria-current="page"' : ''}>${svg(VIEWS[k].icon)}<span>${VIEWS[k].label}</span>${k === 'approvals' && state.approvals.size ? `<span class="badge">${state.approvals.size}</span>` : ''}</a>`;
|
|
927
|
+
const groups = [...new Set(Object.values(VIEWS).map((v) => v.group))].map((group) => [group, Object.keys(VIEWS).filter((k) => VIEWS[k].group === group)]);
|
|
928
|
+
openSheet(`<h2>Més</h2><nav class="more-sections" aria-label="Totes les seccions">${groups.map(([title, keys]) => `<section><h3>${title}</h3><div>${keys.map(item).join('')}</div></section>`).join('')}</nav>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
|
|
916
929
|
}
|
|
917
930
|
|
|
918
931
|
/* ---------- Sessions */
|
|
@@ -944,6 +957,46 @@ async function manageDelete() {
|
|
|
944
957
|
toast(fail ? `No he pogut esborrar ${fail} sessions` : `${ids.length} sessions esborrades`); render();
|
|
945
958
|
}
|
|
946
959
|
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();
|
|
960
|
+
// Sessions de Claude Code obertes al terminal de l'ordinador: es poden continuar des d'aquí.
|
|
961
|
+
let termSessions = null, termTimer = 0, termAt = 0, termFor = '';
|
|
962
|
+
async function loadTermSessions(force) {
|
|
963
|
+
if (!machine() || !state.connected || state.route.name !== 'sessions') return;
|
|
964
|
+
const key = machine().url;
|
|
965
|
+
if (termFor !== key) { termSessions = null; termFor = key; }
|
|
966
|
+
// render() es crida sovint: només es torna a demanar si fa una estona.
|
|
967
|
+
if (!force && Date.now() - termAt < 4000) return;
|
|
968
|
+
clearTimeout(termTimer); termAt = Date.now();
|
|
969
|
+
try { const r = (await api('/api/claude-sessions')).sessions || []; if (termFor === key) termSessions = r; } catch { termSessions = termSessions || []; }
|
|
970
|
+
const box = $('#termSessions'); if (box && state.route.name === 'sessions') box.innerHTML = termSessionsHtml();
|
|
971
|
+
// Es manté al dia mentre la pàgina és oberta: el terminal canvia sense avisar.
|
|
972
|
+
if (!document.hidden) termTimer = setTimeout(() => loadTermSessions(true), 8000);
|
|
973
|
+
}
|
|
974
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden && state.route.name === 'sessions') loadTermSessions(true); });
|
|
975
|
+
const TERM_STATUS = { busy: ['Treballant', 'running'], idle: ['Esperant', ''], waiting: ['Et necessita', 'waiting'], needs_input: ['Et necessita', 'waiting'] };
|
|
976
|
+
function termSessionsHtml() {
|
|
977
|
+
if (!termSessions?.length) return '';
|
|
978
|
+
const taken = new Set([...state.sessions.values()].flatMap((s) => [s.nativeId, s.fromTerminal]).filter(Boolean));
|
|
979
|
+
const fresh = termSessions.filter((s) => !taken.has(s.sessionId));
|
|
980
|
+
if (!fresh.length) return '';
|
|
981
|
+
const where = (t) => [t?.app, t?.tmux && 'tmux', t?.screen && 'screen', t?.ssh && 'SSH'].filter(Boolean).join(' · ');
|
|
982
|
+
return `<section class="term-sessions" aria-label="Al terminal"><h2 class="sec">Obertes al terminal</h2><div class="list">${fresh.map((s) => {
|
|
983
|
+
const [label, cls] = TERM_STATUS[s.status] || [s.status, ''];
|
|
984
|
+
return `<button class="card session-card term-card" type="button" data-act="resume" data-sid="${esc(s.sessionId)}" data-cwd="${esc(s.cwd || '')}" data-sname="${esc(s.title || s.name || '')}">
|
|
985
|
+
<div class="row between"><span class="agent-tag nosq" data-agent="claude">${agentBot('claude', s.status === 'busy' ? 'work' : 'idle')}${esc(where(s.terminal) || 'Terminal')}</span><span class="chip ${cls}">${cls ? '<i class="pulse"></i>' : ''}${esc(label)}</span></div>
|
|
986
|
+
<div class="title">${esc(s.title || s.name || basename(s.cwd || '') || 'Claude Code')}</div>
|
|
987
|
+
${s.ask ? `<div class="last">${esc(plain(s.ask))}</div>` : ''}
|
|
988
|
+
<div class="meta"><span class="mono">${esc(basename(s.cwd || ''))}</span>${s.updated ? `<span>·</span><span>${ago(s.updated)}</span>` : ''}<span class="term-go">Continua aquí →</span></div>
|
|
989
|
+
</button>`;
|
|
990
|
+
}).join('')}</div></section>`;
|
|
991
|
+
}
|
|
992
|
+
function sessionCard(s) {
|
|
993
|
+
return `<${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' : ''}">
|
|
994
|
+
<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>
|
|
995
|
+
<div class="title">${esc(s.title || 'Sessió nova')}</div>
|
|
996
|
+
${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(plain(s.last))}</div>` : ''}
|
|
997
|
+
<div class="meta"><span class="mono">${esc(basename(s.cwd))}</span><span>·</span><span>${esc(MODES[s.mode]?.label || '')}</span><span>·</span><span>${ago(s.updated)}</span></div>
|
|
998
|
+
</${manage ? 'div' : 'a'}>`;
|
|
999
|
+
}
|
|
947
1000
|
function viewSessions() {
|
|
948
1001
|
if (!machine()) return `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p></div></div>${noMachine('Connecta’n un per donar feina als agents i seguir-ne les sessions en directe.')}`;
|
|
949
1002
|
const list = [...state.sessions.values()].sort((a, b) => b.updated - a.updated);
|
|
@@ -956,13 +1009,11 @@ function viewSessions() {
|
|
|
956
1009
|
return `${head}${queueList()}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p>${mach.kind === 'vps' ? 'Potser el servidor s’ha reiniciat i l’adreça ha canviat.' : 'Comprova que nio continua engegat a l’ordinador i que el túnel està obert.'}</p><div class="row" style="justify-content:center"><button class="btn" data-act="retry" type="button">${svg(ICON.refresh)}Torna-ho a provar</button>${mach.kind === 'vps' && mach.via ? `<button class="btn ink" data-reconnect="${store.active}" type="button">Torna a connectar</button>` : ''}<button class="btn ghost" data-act="new" type="button">Posa una tasca a la cua</button><a class="btn ghost" href="#/ordinadors">Ordinadors</a></div>` : ''}</div>`;
|
|
957
1010
|
}
|
|
958
1011
|
if (manage) for (const id of [...manage]) if (!state.sessions.has(id)) manage.delete(id);
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
<div class="meta"><span class="mono">${esc(basename(s.cwd))}</span><span>·</span><span>${esc(MODES[s.mode]?.label || '')}</span><span>·</span><span>${ago(s.updated)}</span></div>
|
|
965
|
-
</${manage ? 'div' : 'a'}>`).join('')}</div>` : `<div class="panel empty">${bot('idle')}<h2>Encara no hi ha cap sessió</h2><p>Tria un agent i una carpeta, i explica-li què ha de fer.</p><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>`}`;
|
|
1012
|
+
const now = Date.now(), recent = list.filter((s) => now - s.updated < DAY || s.state === 'running' || s.state === 'waiting' || s.pending), old = list.filter((s) => !recent.includes(s));
|
|
1013
|
+
const recentHtml = recent.length ? `<div class="list">${recent.map(sessionCard).join('')}</div>` : '';
|
|
1014
|
+
const oldHtml = old.length ? `<details class="old-sessions"><summary class="muted small">${old.length} sessi${old.length > 1 ? 'ons' : 'o'} anterior${old.length > 1 ? 's' : ''}</summary><div class="list">${old.map(sessionCard).join('')}</div></details>` : '';
|
|
1015
|
+
const termHtml = `<div id="termSessions">${termSessionsHtml()}</div>`;
|
|
1016
|
+
return `${head}${queueList()}${termHtml}${list.length ? `${recentHtml}${oldHtml}` : `<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>`}`;
|
|
966
1017
|
}
|
|
967
1018
|
|
|
968
1019
|
/* ---------- Aprovacions */
|
|
@@ -1021,7 +1072,7 @@ async function loadActivity() {
|
|
|
1021
1072
|
let mem = null;
|
|
1022
1073
|
function viewMemory() {
|
|
1023
1074
|
if (!machine()) return `<div class="top"><div><h1>Memòria</h1><p>El que recorden els teus agents: preferències, decisions i context dels projectes.</p></div></div>${noMachine('La memòria són fitxers a l’ordinador. Connecta’n un per veure-la i editar-la.')}`;
|
|
1024
|
-
return `<div class="top"><div><h1>Memòria</h1><p>
|
|
1075
|
+
return `<div class="top"><div><h1>Memòria</h1><p>Preferències, decisions i context que els agents recorden en aquest ordinador. Obre un fitxer per consultar-lo o editar-lo.</p></div><button class="btn ink" data-act="mem-new" type="button">${svg(ICON.plus)}Fitxer nou</button></div>
|
|
1025
1076
|
<form class="mem-search" id="memSearch" role="search"><input type="search" id="memQ" placeholder="Busca a la memòria…" aria-label="Busca a la memòria" autocomplete="off"><button class="btn ink" type="submit">Busca</button></form>
|
|
1026
1077
|
<div id="memBody">${memHtml()}</div>`;
|
|
1027
1078
|
}
|
|
@@ -1040,14 +1091,18 @@ async function loadMemory() {
|
|
|
1040
1091
|
async function memEdit(p) {
|
|
1041
1092
|
let f = { path: p || 'projectes/', content: '' };
|
|
1042
1093
|
if (p) { try { f = await api(`/api/memory/f/${encodeURIComponent(p)}`); } catch (e) { return toast(e.message); } }
|
|
1043
|
-
openSheet(`<h2>${p ? esc(p) : 'Fitxer nou'}</h2>
|
|
1044
|
-
${p ? '' : '<label class="f"><span>Nom (acaba en .md)</span><input type="text" id="memPath" value="projectes/" autocapitalize="off" spellcheck="false"></label>'}
|
|
1094
|
+
openSheet(`<h2>${p ? esc(p) : 'Fitxer nou'}</h2><form class="stack" id="memForm">
|
|
1095
|
+
${p ? '' : '<label class="f"><span>Nom (acaba en .md)</span><input type="text" id="memPath" value="projectes/" placeholder="projectes/context.md" required pattern=".+\\.md" title="Escriu un nom acabat en .md, per exemple projectes/context.md" autocapitalize="off" spellcheck="false"></label>'}
|
|
1045
1096
|
<label class="f"><span>Contingut</span><textarea class="field mono" id="memText" rows="14" spellcheck="false">${esc(f.content)}</textarea></label>
|
|
1046
|
-
<
|
|
1047
|
-
|
|
1097
|
+
<p id="memError" class="form-error" role="alert" hidden></p>
|
|
1098
|
+
<div class="row between sheet-foot">${p ? '<button class="btn danger" type="button" id="memDel">Esborra</button>' : '<span></span>'}<button class="btn ink" type="submit" id="memSave">Desa</button></div></form>`, (sh) => {
|
|
1099
|
+
$('#memForm', sh).onsubmit = async (event) => {
|
|
1100
|
+
event.preventDefault();
|
|
1048
1101
|
const path = p || $('#memPath', sh).value.trim();
|
|
1102
|
+
const button = $('#memSave', sh), error = $('#memError', sh); button.disabled = true; button.textContent = 'Desant…'; error.hidden = true;
|
|
1049
1103
|
try { await api(`/api/memory/f/${encodeURIComponent(path)}`, { method: 'PUT', body: { content: $('#memText', sh).value } }); toast('Desat'); closeSheet(); mem = null; loadMemory(); }
|
|
1050
|
-
catch (e) {
|
|
1104
|
+
catch (e) { error.textContent = `${e.message} Pots tornar a prémer «Desa».`; error.hidden = false; }
|
|
1105
|
+
finally { button.disabled = false; button.textContent = 'Desa'; }
|
|
1051
1106
|
};
|
|
1052
1107
|
if (p) $('#memDel', sh).onclick = async () => {
|
|
1053
1108
|
if (!confirm(`S’esborrarà «${p}» de la memòria. Els agents ja no ho recordaran.`)) return;
|
|
@@ -1256,7 +1311,10 @@ const hhmm = (t) => new Date(t * 1000).toLocaleString('ca', { weekday: t * 1000
|
|
|
1256
1311
|
function usageChip(agent) {
|
|
1257
1312
|
const u = state.usage?.[agent]; if (!u?.windows?.length) return '';
|
|
1258
1313
|
const tip = u.windows.map((w) => `${w.label}: ${Math.round(w.used * 100)} %${w.resetsAt ? ` · es reinicia ${hhmm(w.resetsAt)}` : ''}`).join('\n');
|
|
1259
|
-
|
|
1314
|
+
// Etiquetes curtes: «5h», «7d». La icona és un anell que s'omple amb la finestra més plena.
|
|
1315
|
+
const short = (l) => (l === 'setmana' ? '7d' : l.replace(/\s+/g, '')), top = Math.max(...u.windows.map((w) => w.used)), pct = Math.round(top * 100);
|
|
1316
|
+
const ring = `<svg class="ico ring" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5" class="rt"/><circle cx="12" cy="12" r="8.5" pathLength="100" stroke-dasharray="${Math.max(pct, 2)} 100" transform="rotate(-90 12 12)" class="rv"/></svg>`;
|
|
1317
|
+
return `<span class="chip mono usage" data-hi="${top >= 0.8 ? 1 : 0}" title="${esc(`Ús de la subscripció\n${tip}`)}">${ring}${u.windows.map((w) => `<span class="uw" data-hi="${w.used >= 0.8 ? 1 : 0}">${esc(short(w.label))} (${Math.round(w.used * 100)}%)</span>`).join('')}</span>`;
|
|
1260
1318
|
}
|
|
1261
1319
|
function renderSessionHead() {
|
|
1262
1320
|
renderChrome();
|
|
@@ -1369,6 +1427,7 @@ function putEvent(e, scroll = true) {
|
|
|
1369
1427
|
const feed = $('#feed'); if (!feed) return;
|
|
1370
1428
|
if (e.kind === 'text' && scroll && (e.open || typers.has(e.seq))) return streamText(state.route.id, e);
|
|
1371
1429
|
const stick = nearBottom(feed);
|
|
1430
|
+
if (e.kind === 'tool_result' && TITLE_TOOL.test(e.name || '')) return; // el títol ja es veu a dalt i a la línia «Títol»
|
|
1372
1431
|
if (e.kind === 'tool_result') {
|
|
1373
1432
|
const t = toolEls.get(e.id);
|
|
1374
1433
|
if (t) {
|
|
@@ -1394,12 +1453,15 @@ function putEvent(e, scroll = true) {
|
|
|
1394
1453
|
if (scroll && e.kind === 'user' && pinNext && !old) { pinNext = false; return pinMessage(feed, node); }
|
|
1395
1454
|
if (scroll) follow(feed, stick);
|
|
1396
1455
|
}
|
|
1456
|
+
// L'agent posa títol a la sessió amb l'eina sessio_titol de nio (Claude: mcp__nio__sessio_titol, Codex: nio.sessio_titol).
|
|
1457
|
+
const TITLE_TOOL = /(^|[._])sessio_titol$/;
|
|
1397
1458
|
function eventNode(e) {
|
|
1398
1459
|
const s = state.sessions.get(state.route.id);
|
|
1399
1460
|
switch (e.kind) {
|
|
1400
1461
|
case 'user': return el(`<div class="msg-user">${esc(e.text)}</div>`);
|
|
1401
|
-
case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}" data-seq="${e.seq}">${md(e.text)}${!e.open &&
|
|
1402
|
-
case 'tool':
|
|
1462
|
+
case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}" data-seq="${e.seq}">${md(e.text)}${!e.open && ttsOn() ? `<button class="speak" type="button" data-speak aria-label="Escolta la resposta">${svg(ICON.speaker)}<span>Escolta</span></button>` : ''}</div>`);
|
|
1463
|
+
case 'tool': if (TITLE_TOOL.test(e.name || '')) return el(`<div class="ev-info">Títol: «${esc(e.input?.titol || '')}»</div>`);
|
|
1464
|
+
return el(`<details class="tool"><summary><span class="name">${esc(e.name)}</span><span class="arg">${esc(toolArg(e.input))}</span><span class="st">…</span></summary><pre>${esc(prettyInput(e.input))}</pre></details>`);
|
|
1403
1465
|
case 'tool_result': return el(`<details class="tool"><summary><span class="name">${esc(e.name || 'Eina')}</span><span class="arg"></span><span class="st ${e.error ? 'err' : 'ok'}">${e.error ? '✕' : '✓'}</span></summary><pre class="out">${esc(e.output)}</pre></details>`);
|
|
1404
1466
|
case 'approval': { const a = state.approvals.get(e.approval) || {}; return el(approvalCard({ ...a, approval: e.approval, tool: e.tool, input: e.input, state: e.state && e.state !== 'pending' ? e.state : a.state || e.state }, false)); }
|
|
1405
1467
|
case 'error': return el(`<div class="ev-error" role="alert"><b>Error</b><span>${esc(e.text)}</span></div>`);
|
|
@@ -1576,10 +1638,11 @@ function renderComposer() {
|
|
|
1576
1638
|
|
|
1577
1639
|
/* ---------- Fulls */
|
|
1578
1640
|
function openSheet(html, onMount, cls) {
|
|
1579
|
-
const d = $('#sheet'); d.classList.toggle('wiz', !!cls?.startsWith('wiz')); d.classList.toggle('compact', cls === 'wiz compact'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls?.startsWith('wiz') ? '' : `<div class="grab"></div>${html}`;
|
|
1641
|
+
const d = $('#sheet'); d.classList.toggle('wiz', !!cls?.startsWith('wiz')); d.classList.toggle('compact', cls === 'wiz compact'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls?.startsWith('wiz') ? '' : `<div class="grab"></div><button class="icon-btn sheet-close" type="button" data-sheet-close aria-label="Tanca">${svg(ICON.close)}</button>${html}`;
|
|
1580
1642
|
if (!d.open) d.showModal(); raiseToasts();
|
|
1581
1643
|
if (isTouch) { if (document.activeElement?.matches?.('input, textarea, select')) document.activeElement.blur(); } else setTimeout(() => focusIn(d), 30); if (T.on) $('#tour').hidden = true;
|
|
1582
1644
|
onMount?.($('#sheetIn'));
|
|
1645
|
+
const title = si.querySelector('h2'); if (title) { title.id = 'sheetTitle'; d.setAttribute('aria-labelledby', title.id); } else { d.removeAttribute('aria-labelledby'); d.setAttribute('aria-label', 'Opcions'); }
|
|
1583
1646
|
}
|
|
1584
1647
|
const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); setTimeout(focusMain, 0); };
|
|
1585
1648
|
// A l'ordinador el cursor és sempre on s'escriu; al mòbil no, perquè no s'obri el teclat.
|
|
@@ -1590,9 +1653,50 @@ function focusMain() {
|
|
|
1590
1653
|
if (r === 'session') $('#msg')?.focus({ preventScroll: true });
|
|
1591
1654
|
else if (r === 'mobil' && inLocalChat()) $('#lmsg')?.focus({ preventScroll: true });
|
|
1592
1655
|
}
|
|
1593
|
-
$('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
|
|
1656
|
+
$('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet' || e.target.closest('[data-sheet-close]')) closeSheet(); });
|
|
1594
1657
|
$('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
|
|
1595
1658
|
|
|
1659
|
+
// Al mòbil, els fulls es tanquen lliscant cap avall, com els de l'iPhone: el full segueix el dit
|
|
1660
|
+
// 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');
|
|
1663
|
+
let g = null;
|
|
1664
|
+
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
|
+
const reset = () => { d.style.transition = ''; d.style.transform = ''; };
|
|
1666
|
+
d.addEventListener('touchstart', (e) => {
|
|
1667
|
+
g = null;
|
|
1668
|
+
if (!mq.matches || e.touches.length !== 1 || e.target.closest('input, textarea, select, [contenteditable], input[type=range], [data-noswipe]')) return;
|
|
1669
|
+
const t = e.touches[0], sc = scrollerOf(e.target);
|
|
1670
|
+
const head = !!e.target.closest('.grab, .wiz-head, h2');
|
|
1671
|
+
if (sc && sc.scrollTop > 0 && !head) return;
|
|
1672
|
+
g = { y0: t.clientY, x0: t.clientX, dy: 0, lock: null, sc: head ? null : sc, pts: [[e.timeStamp, t.clientY]] };
|
|
1673
|
+
}, { passive: true });
|
|
1674
|
+
d.addEventListener('touchmove', (e) => {
|
|
1675
|
+
if (!g) return;
|
|
1676
|
+
const t = e.touches[0], dy = t.clientY - g.y0, dx = t.clientX - g.x0;
|
|
1677
|
+
if (!g.lock) {
|
|
1678
|
+
if (Math.abs(dy) < 6 && Math.abs(dx) < 6) return;
|
|
1679
|
+
if (dy > 0 && dy > Math.abs(dx) && !(g.sc && g.sc.scrollTop > 0)) { g.lock = 'y'; d.style.transition = 'none'; }
|
|
1680
|
+
else { g = null; return; }
|
|
1681
|
+
}
|
|
1682
|
+
if (e.cancelable) e.preventDefault();
|
|
1683
|
+
g.dy = Math.max(0, dy); g.pts.push([e.timeStamp, t.clientY]); if (g.pts.length > 5) g.pts.shift();
|
|
1684
|
+
d.style.transform = `translateY(${g.dy}px)`;
|
|
1685
|
+
}, { passive: false });
|
|
1686
|
+
const end = (e) => {
|
|
1687
|
+
const x = g; g = null; if (!x || x.lock !== 'y') return;
|
|
1688
|
+
const [t0, y0] = x.pts[0], [t1, y1] = x.pts[x.pts.length - 1], v = (y1 - y0) / Math.max(1, t1 - t0);
|
|
1689
|
+
if (e.type === 'touchend' && (x.dy > Math.min(160, d.offsetHeight * 0.28) || (v > 0.5 && x.dy > 20))) {
|
|
1690
|
+
if (reduce) { reset(); closeSheet(); return; }
|
|
1691
|
+
d.style.transition = 'transform .2s cubic-bezier(.4,0,1,1)'; d.style.transform = `translateY(${d.offsetHeight + 20}px)`;
|
|
1692
|
+
setTimeout(() => { closeSheet(); requestAnimationFrame(reset); }, 200);
|
|
1693
|
+
} else { d.style.transition = 'transform .3s cubic-bezier(.22,1,.36,1)'; d.style.transform = ''; setTimeout(reset, 300); }
|
|
1694
|
+
};
|
|
1695
|
+
d.addEventListener('touchend', end, { passive: true });
|
|
1696
|
+
d.addEventListener('touchcancel', end, { passive: true });
|
|
1697
|
+
d.addEventListener('close', reset);
|
|
1698
|
+
})();
|
|
1699
|
+
|
|
1596
1700
|
// Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
|
|
1597
1701
|
const modeBlocked = (agentId, mode) => agentId === 'claude' && mode === 'tot' && state.info?.user === 'root';
|
|
1598
1702
|
function modeHelp(agentId, mode) {
|
|
@@ -2468,6 +2572,8 @@ document.addEventListener('click', async (e) => {
|
|
|
2468
2572
|
// Les claus (ElevenLabs o OpenAI) viuen a l'ordinador. L'àudio fa: mòbil → ordinador → proveïdor.
|
|
2469
2573
|
let nodeVoice = null; // estat de la veu de l'ordinador actiu (sense claus)
|
|
2470
2574
|
const voiceOn = () => !!nodeVoice?.stt;
|
|
2575
|
+
// Escoltar necessita una clau d'ElevenLabs o OpenAI: amb Whisper sol no hi ha veu per parlar.
|
|
2576
|
+
const ttsOn = () => !!nodeVoice?.tts;
|
|
2471
2577
|
async function loadVoice() { try { nodeVoice = await api('/api/voice'); } catch { nodeVoice = null; } }
|
|
2472
2578
|
async function nodeAudio(path, opts) {
|
|
2473
2579
|
const m = machine(); if (!m) throw new Error('No hi ha cap ordinador connectat');
|
|
@@ -2534,13 +2640,15 @@ async function voiceSheet() {
|
|
|
2534
2640
|
openSheet(`<h2>Veu</h2><p class="muted small">Per dictar n’hi ha prou amb Whisper, gratis i a l’ordinador «${esc(machine().name)}». Les claus són opcionals: serveixen per escoltar les respostes en veu alta. Es desen a l’ordinador i no tornen mai al mòbil.</p>
|
|
2535
2641
|
<div id="whisperBox" style="margin-top:14px">${whisperCard(v.whisper)}</div>
|
|
2536
2642
|
<form class="stack" id="voiceForm" style="margin-top:14px">
|
|
2643
|
+
<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>
|
|
2537
2644
|
<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>
|
|
2538
2645
|
<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>
|
|
2539
2646
|
<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>
|
|
2540
2647
|
<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>
|
|
2541
2648
|
<label class="row check"><input type="checkbox" id="vSpeak" ${v.speak ? 'checked' : ''}> <span>Llegeix en veu alta les respostes de la sessió oberta</span></label>
|
|
2542
2649
|
${old.key ? '<p class="muted small">Tenies una clau d’ElevenLabs desada en aquest navegador: la passo a l’ordinador i l’esborro d’aquí.</p>' : ''}
|
|
2543
|
-
<
|
|
2650
|
+
<button class="btn ghost" type="button" id="vOff">Treu les claus</button></div></details>
|
|
2651
|
+
<div class="row sheet-foot"><button class="btn ink" type="submit">Desa</button></div>
|
|
2544
2652
|
</form>`, (sh) => {
|
|
2545
2653
|
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); } };
|
|
2546
2654
|
$('#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'); };
|
|
@@ -2619,6 +2727,17 @@ document.addEventListener('click', async (e) => {
|
|
|
2619
2727
|
if (a.dataset.qdrop) { queue.drop(a.dataset.qdrop); return render(); }
|
|
2620
2728
|
switch (a.dataset.act) {
|
|
2621
2729
|
case 'new': return newSessionSheet();
|
|
2730
|
+
case 'resume': {
|
|
2731
|
+
const btn = a; btn.disabled = true;
|
|
2732
|
+
const sid = btn.dataset.sid, cwd = btn.dataset.cwd, sname = btn.dataset.sname;
|
|
2733
|
+
try {
|
|
2734
|
+
const title = sname || basename(cwd) || 'Terminal';
|
|
2735
|
+
const ns = await api('/api/sessions', { method: 'POST', body: { agent: 'claude', cwd: cwd || '', mode: 'preguntar', resumeId: sid, title } });
|
|
2736
|
+
state.sessions.set(ns.id, ns); termSessions = termSessions?.filter((x) => x.sessionId !== sid) || null;
|
|
2737
|
+
go(`#/s/${ns.id}`);
|
|
2738
|
+
} catch (err) { toast(err.message, 4200); btn.disabled = false; }
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2622
2741
|
case 'manage-on': manage = new Set(); return render();
|
|
2623
2742
|
case 'manage-off': manage = null; return render();
|
|
2624
2743
|
case 'manage-del': return manageDelete();
|
|
@@ -2903,7 +3022,19 @@ if ('serviceWorker' in navigator) {
|
|
|
2903
3022
|
navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'open-session' && e.data.id) go(`#/s/${e.data.id}`); });
|
|
2904
3023
|
}
|
|
2905
3024
|
addEventListener('hashchange', route);
|
|
2906
|
-
|
|
3025
|
+
// 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
|
+
// 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.
|
|
3027
|
+
function revive(maxIdle) {
|
|
3028
|
+
if (!machine() || isDemo(machine()) || document.hidden) return;
|
|
3029
|
+
// Desconnectat, el vigilant no talla els reintents en curs; en tornar a l'app sí que es torna a provar de seguida.
|
|
3030
|
+
if (state.connected ? Date.now() - alive < maxIdle : maxIdle >= 40000) return;
|
|
3031
|
+
retry = 0; reconTry.clear(); connect();
|
|
3032
|
+
}
|
|
3033
|
+
document.addEventListener('visibilitychange', () => revive(20000));
|
|
3034
|
+
addEventListener('pageshow', (e) => { if (e.persisted) revive(0); });
|
|
3035
|
+
addEventListener('online', () => revive(0));
|
|
3036
|
+
addEventListener('focus', () => revive(20000));
|
|
3037
|
+
setInterval(() => revive(40000), 10000);
|
|
2907
3038
|
setInterval(() => { if (!document.hidden && state.route.name === 'sessions') render(); }, 60000);
|
|
2908
3039
|
const qs = new URLSearchParams(location.search).get('s');
|
|
2909
3040
|
if (qs) history.replaceState(null, '', location.pathname + `#/s/${qs}`);
|
package/web/app/call.css
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/* nio · Trucada */
|
|
2
|
+
.call-hero { display: grid; justify-items: center; gap: 10px; max-width: 640px; margin: 8px auto 0; padding: 40px 24px 32px; border: 1px solid var(--border); border-radius: 24px; background: var(--panel); text-align: center; }
|
|
3
|
+
.call-hero h2 { margin-top: 8px; font-size: 22px; font-weight: 500; letter-spacing: -.04em; }
|
|
4
|
+
.call-hero > p { max-width: 440px; text-wrap: balance; }
|
|
5
|
+
.call-why { font-size: 14px; color: var(--fg-muted); }
|
|
6
|
+
.call-why.err { color: var(--red); }
|
|
7
|
+
.call-cta { margin-top: 14px; }
|
|
8
|
+
.call-try { max-width: 640px; margin: 0 auto; }
|
|
9
|
+
.call-try .sec { font-size: 15px; margin: 28px 0 10px; }
|
|
10
|
+
.call-ex { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
11
|
+
.call-ex span { padding: 7px 12px; border: 1px solid var(--border); border-radius: 999px; background: var(--white); font-size: 14px; }
|
|
12
|
+
.call-notes { margin: 18px 0 0; padding-left: 18px; display: grid; gap: 6px; }
|
|
13
|
+
|
|
14
|
+
/* La cara d'en Nio: anells que respiren i que creixen amb la veu (--lvl, de 0 a 1). */
|
|
15
|
+
.call-face { --lvl: 0; position: relative; display: grid; place-items: center; width: 132px; height: 132px; color: var(--fg); }
|
|
16
|
+
.call-face .call-bot { --s: 64px; position: relative; z-index: 2; }
|
|
17
|
+
.call-ring { position: absolute; inset: 0; border-radius: 50%; border: 1.5px solid currentColor; opacity: .18; transform: scale(calc(.82 + var(--lvl) * .3)); transition: transform .08s linear; animation: call-breathe 3.2s ease-in-out infinite; }
|
|
18
|
+
.call-ring.r2 { opacity: .09; transform: scale(calc(1 + var(--lvl) * .55)); animation-delay: -1.6s; }
|
|
19
|
+
.call-lvl { position: absolute; inset: 14%; border-radius: 50%; background: radial-gradient(circle, color-mix(in srgb, var(--accent) 70%, transparent), transparent 70%); opacity: calc(var(--lvl) * 1.4); transform: scale(calc(.9 + var(--lvl) * .5)); transition: transform .08s linear, opacity .08s linear; }
|
|
20
|
+
@keyframes call-breathe { 50% { opacity: .3; } }
|
|
21
|
+
|
|
22
|
+
.call-btn { display: inline-flex; align-items: center; justify-content: center; gap: 10px; border: 0; border-radius: 999px; color: #fff; cursor: pointer; transition: transform .16s var(--ease), filter .16s, box-shadow .2s; -webkit-tap-highlight-color: transparent; }
|
|
23
|
+
.call-btn .ico { width: 26px; height: 26px; fill: currentColor; stroke: none; }
|
|
24
|
+
.call-btn:active { transform: scale(.93); }
|
|
25
|
+
.call-btn:disabled { opacity: .55; cursor: progress; }
|
|
26
|
+
.call-btn.go { min-width: 72px; height: 72px; padding: 0 30px; background: #1f9d55; box-shadow: 0 12px 30px -12px #1f9d55; font-size: 17px; font-weight: 600; }
|
|
27
|
+
.call-btn.go:hover { filter: brightness(1.06); }
|
|
28
|
+
.call-btn.end { width: 76px; height: 76px; background: var(--red); box-shadow: 0 12px 30px -12px var(--red); }
|
|
29
|
+
.call-btn.end .ico { width: 32px; height: 32px; }
|
|
30
|
+
|
|
31
|
+
/* Pantalla de la trucada: fosca, com al telèfon, i la conversa com les lletres d'Apple Music. */
|
|
32
|
+
.call-ov { --lvl: 0; position: fixed; inset: 0; z-index: 90; display: grid; grid-template-rows: auto 1fr auto; color: #fff; background: #141112; overscroll-behavior: contain; overflow: hidden; }
|
|
33
|
+
.call-amb { position: absolute; inset: -20%; pointer-events: none; filter: blur(70px) saturate(1.2); opacity: calc(.5 + var(--lvl) * .45); transform: scale(calc(1 + var(--lvl) * .12)); transition: opacity .2s linear, transform .2s linear; }
|
|
34
|
+
.call-amb i { position: absolute; width: 62vmax; height: 62vmax; border-radius: 50%; animation: call-drift 22s ease-in-out infinite alternate; }
|
|
35
|
+
.call-amb i:nth-child(1) { left: -10%; top: -15%; background: radial-gradient(circle, #f46a3b 0%, transparent 62%); }
|
|
36
|
+
.call-amb i:nth-child(2) { right: -4%; top: 18%; background: radial-gradient(circle, #6d3f7e 0%, transparent 62%); animation-duration: 27s; animation-delay: -9s; }
|
|
37
|
+
.call-amb i:nth-child(3) { left: 10%; bottom: -30%; background: radial-gradient(circle, #1f5f63 0%, transparent 62%); animation-duration: 31s; animation-delay: -15s; }
|
|
38
|
+
@keyframes call-drift { 0% { transform: translate(0, 0) scale(1); } 50% { transform: translate(8vmax, 6vmax) scale(1.12); } 100% { transform: translate(-6vmax, 3vmax) scale(.94); } }
|
|
39
|
+
.call-ov[data-st="ended"] .call-amb { opacity: .28; }
|
|
40
|
+
.call-ov > :not(.call-amb) { position: relative; z-index: 1; }
|
|
41
|
+
|
|
42
|
+
.call-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: calc(14px + var(--safe-t)) 22px 6px; max-width: 760px; width: 100%; margin: 0 auto; }
|
|
43
|
+
.call-me { display: flex; align-items: center; gap: 11px; min-width: 0; }
|
|
44
|
+
.call-me .call-bot { --s: 30px; color: #fff; transform: scale(calc(1 + var(--lvl) * .25)); transition: transform .1s linear; }
|
|
45
|
+
.call-me > span { display: grid; line-height: 1.2; }
|
|
46
|
+
.call-me b { font-size: 15px; font-weight: 600; }
|
|
47
|
+
.call-me small { font-size: 13px; color: rgba(255,255,255,.62); }
|
|
48
|
+
.call-time { font-size: 14px; color: rgba(255,255,255,.62); font-variant-numeric: tabular-nums; }
|
|
49
|
+
|
|
50
|
+
.call-lyrics { overflow-y: auto; scrollbar-width: none; -webkit-mask-image: linear-gradient(transparent 0, #000 16%, #000 78%, transparent 100%); mask-image: linear-gradient(transparent 0, #000 16%, #000 78%, transparent 100%); }
|
|
51
|
+
.call-lyrics::-webkit-scrollbar { display: none; }
|
|
52
|
+
.call-lines { display: grid; gap: 26px; max-width: 760px; margin: 0 auto; padding: 26vh 24px 52vh; }
|
|
53
|
+
.call-line { font-size: clamp(27px, 7.2vw, 42px); font-weight: 700; line-height: 1.17; letter-spacing: -.035em; text-wrap: pretty; overflow-wrap: anywhere; transition: opacity .5s var(--ease), filter .5s var(--ease), transform .5s var(--ease); }
|
|
54
|
+
.call-line:not(.now) { opacity: .42; filter: blur(1.2px); transform: scale(.97); transform-origin: left center; }
|
|
55
|
+
.call-line.nio span { color: rgba(255,255,255,.32); transition: color .22s ease-out, text-shadow .4s; }
|
|
56
|
+
.call-line.nio span.on { color: #fff; }
|
|
57
|
+
.call-line.nio.now span.on { text-shadow: 0 0 22px rgba(255,255,255,.18); }
|
|
58
|
+
.call-line.tu { justify-self: end; max-width: 86%; font-size: clamp(20px, 5.4vw, 30px); font-weight: 600; text-align: right; color: rgba(255,255,255,.72); }
|
|
59
|
+
.call-line.tu:not(.now) { transform-origin: right center; }
|
|
60
|
+
.call-line.ear { display: flex; gap: 7px; padding: 8px 2px; opacity: 1; filter: none; transform: none; }
|
|
61
|
+
.call-line.ear[hidden] { display: none; }
|
|
62
|
+
.call-line.ear i { width: 11px; height: 11px; border-radius: 50%; background: rgba(255,255,255,.8); transform: scale(calc(.55 + var(--lvl) * 1.1)); transition: transform .08s linear; }
|
|
63
|
+
.call-line.ear i:nth-child(2) { transform: scale(calc(.55 + var(--lvl) * 1.5)); }
|
|
64
|
+
.call-line.info { font-size: 15px; font-weight: 500; letter-spacing: 0; color: rgba(255,255,255,.6); filter: none; transform: none; opacity: 1; background: linear-gradient(90deg, rgba(255,255,255,.45) 30%, #fff 50%, rgba(255,255,255,.45) 70%) 0 0 / 300% 100%; -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; animation: call-shimmer 1.6s linear infinite; }
|
|
65
|
+
@keyframes call-shimmer { to { background-position: -150% 0; } }
|
|
66
|
+
.call-line.err { font-size: clamp(20px, 5vw, 26px); color: #ffb3a8; }
|
|
67
|
+
.call-ov.over .call-line { opacity: .9; filter: none; transform: none; }
|
|
68
|
+
.call-ov.over .call-line.tu { opacity: .7; }
|
|
69
|
+
|
|
70
|
+
.call-bar { display: flex; align-items: center; justify-content: center; gap: 40px; padding: 12px 20px calc(26px + var(--safe-b)); }
|
|
71
|
+
.call-ctl { display: grid; place-items: center; width: 62px; height: 62px; padding: 0; border: 0; border-radius: 50%; background: rgba(255,255,255,.14); color: #fff; cursor: pointer; -webkit-backdrop-filter: blur(20px); backdrop-filter: blur(20px); transition: background .18s, color .18s, transform .16s var(--ease); -webkit-tap-highlight-color: transparent; }
|
|
72
|
+
.call-ctl .ico { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
|
73
|
+
.call-ctl:active { transform: scale(.92); }
|
|
74
|
+
.call-ctl[aria-pressed="true"] { background: #fff; color: #141112; }
|
|
75
|
+
.call-ov .call-btn.go { height: 62px; min-width: 0; padding: 0 26px; font-size: 16px; }
|
|
76
|
+
.call-ov .call-btn.go .ico { width: 22px; height: 22px; }
|
|
77
|
+
.call-ov :focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
|
|
78
|
+
body:has(.call-ov) { overflow: hidden; }
|
|
79
|
+
@media (min-width: 900px) { .call-lines { padding-inline: 40px; } }
|
|
80
|
+
@media (prefers-reduced-motion: reduce) { .call-amb i, .call-line.info, .call-ring { animation: none; } .call-line { transition: none; } }
|