nio-cat 0.9.7 → 0.9.9
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 +2 -0
- package/src/call.js +246 -0
- package/src/hub.js +21 -0
- package/src/server.js +8 -1
- package/src/terminal.js +147 -0
- package/web/app/app.js +149 -38
- 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 +57 -0
- 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"/>',
|
|
@@ -363,6 +365,7 @@ function renameMachine(host, id) {
|
|
|
363
365
|
|
|
364
366
|
function handle(msg) {
|
|
365
367
|
const r = state.route;
|
|
368
|
+
callCtl?.onEvent(msg);
|
|
366
369
|
switch (msg.type) {
|
|
367
370
|
case 'device':
|
|
368
371
|
if (!isPhone() && msg.device !== deviceId()) { if ($('#qrBox')) closeSheet(); toast('Mòbil connectat correctament', 2000); }
|
|
@@ -510,18 +513,19 @@ function checkPairHash() {
|
|
|
510
513
|
/* ---------------------------------------------------------------- navegació */
|
|
511
514
|
const VIEWS = {
|
|
512
515
|
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
516
|
feines: { label: 'Feines', icon: ICON.jobs, group: 'Feina' },
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
517
|
+
approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
|
|
518
|
+
trucada: { label: 'Trucada', icon: ICON.call, group: 'Feina' },
|
|
519
|
+
notes: { label: 'Notes', icon: ICON.notes, group: 'Organització' },
|
|
520
|
+
memoria: { label: 'Memòria', icon: ICON.memory, group: 'Organització' },
|
|
521
|
+
pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Organització' },
|
|
522
|
+
ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors i eines' },
|
|
523
|
+
mobil: { label: 'Xat local', icon: ICON.phone, group: 'Ordinadors i eines' },
|
|
524
|
+
activitat: { label: 'Activitat', icon: ICON.activity, group: 'Ordinadors i eines' },
|
|
525
|
+
ajustos: { label: 'Ajustos', icon: ICON.settings, group: 'Preferències' },
|
|
526
|
+
ajuda: { label: 'Ajuda', icon: ICON.help, group: 'Preferències' }
|
|
523
527
|
};
|
|
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' };
|
|
528
|
+
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
529
|
const NEEDS_MACHINE = new Set(['session']);
|
|
526
530
|
function parseRoute() {
|
|
527
531
|
const h = location.hash.replace(/^#\/?/, '');
|
|
@@ -557,8 +561,8 @@ function renderChrome() {
|
|
|
557
561
|
const m = machine(), sc = $('#sideConn');
|
|
558
562
|
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
563
|
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']]
|
|
564
|
+
const more = ['feines', 'trucada', 'memoria', 'approvals', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
|
|
565
|
+
$('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['notes', ICON.notes, 'Notes', active === 'notes'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
|
|
562
566
|
.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
567
|
.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
568
|
+ `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
|
|
@@ -571,8 +575,9 @@ function renderChrome() {
|
|
|
571
575
|
document.title = n ? `(${n}) nio` : `nio // ${s ? s.title || 'Sessió' : VIEWS[r.name]?.label || 'Panell'}`;
|
|
572
576
|
if ('setAppBadge' in navigator) (n ? navigator.setAppBadge(n) : navigator.clearAppBadge()).catch?.(() => {});
|
|
573
577
|
}
|
|
574
|
-
let notesCtl = null;
|
|
575
|
-
const
|
|
578
|
+
let notesCtl = null, callCtl = null;
|
|
579
|
+
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: () => {} }));
|
|
580
|
+
const getNotes = () => (notesCtl ||= createNotes({ api, nodeAudio, toast, esc, machine, voiceOn, voiceSheet, isDemo, isPhone, openSheet, closeSheet, mobileQrSheet }));
|
|
576
581
|
function render() {
|
|
577
582
|
const r = state.route, view = $('#view'), board = r.name === 'pissarra';
|
|
578
583
|
if (r.name !== 'sessions') manage = null;
|
|
@@ -581,9 +586,11 @@ function render() {
|
|
|
581
586
|
document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && inLocalChat()));
|
|
582
587
|
document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' && inLocalChat()));
|
|
583
588
|
if (r.name !== 'notes' && lastName === 'notes') notesCtl?.unmount();
|
|
589
|
+
if (r.name !== 'trucada' && lastName === 'trucada') callCtl?.unmount();
|
|
584
590
|
if (!board && lastName === 'pissarra') window.NioBoard?.unmount();
|
|
585
591
|
if (board && lastName === 'pissarra' && $('#boardRoot')) return;
|
|
586
592
|
if (r.name === 'notes' && lastName === 'notes' && $('#notesRoot') && machine()) return; // l'editor no es toca quan l'app es repinta
|
|
593
|
+
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
594
|
const changed = lastName !== r.name || r.name === 'session';
|
|
588
595
|
lastName = r.name;
|
|
589
596
|
if (r.name === 'session') renderSession();
|
|
@@ -591,12 +598,14 @@ function render() {
|
|
|
591
598
|
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
599
|
if (machine()) getNotes().mount($('#notesRoot'));
|
|
593
600
|
}
|
|
601
|
+
else if (r.name === 'trucada') { view.innerHTML = `<div class="page" id="callRoot" data-m="${store.active}"></div>`; getCall().mount($('#callRoot')); }
|
|
594
602
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
595
603
|
else {
|
|
596
604
|
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
597
605
|
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
606
|
if (r.name === 'mobil') bindLocal();
|
|
599
607
|
if (r.name === 'mobil' && machine() && !inLocalChat()) loadOllama();
|
|
608
|
+
if (r.name === 'sessions' && machine() && state.connected) loadTermSessions();
|
|
600
609
|
if (r.name === 'activitat' && machine()) { activity = null; loadActivity(); }
|
|
601
610
|
if (r.name === 'memoria' && machine()) loadMemory();
|
|
602
611
|
if (r.name === 'ordinadors' && machine() && state.connected) loadScreen();
|
|
@@ -611,7 +620,7 @@ function render() {
|
|
|
611
620
|
|
|
612
621
|
/* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
|
|
613
622
|
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 = () =>
|
|
623
|
+
const isPhone = () => navigator.userAgentData?.mobile === true || /Android|iPhone|iPod|Mobile/i.test(navigator.userAgent) || (isTouch && matchMedia('(max-width: 1024px)').matches);
|
|
615
624
|
const INSTALL_CMD = 'curl -fsSL https://www.nio.cat/install.sh | sh';
|
|
616
625
|
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
626
|
// Estat de la resta d'ordinadors (el connectat ja té el seu flux en directe): una consulta lleugera.
|
|
@@ -655,6 +664,7 @@ function helpPanel(open) {
|
|
|
655
664
|
|
|
656
665
|
${grp('Treballar amb els agents')}
|
|
657
666
|
${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>`)}
|
|
667
|
+
${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
668
|
${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
669
|
${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
670
|
${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 +681,7 @@ function helpPanel(open) {
|
|
|
671
681
|
${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
682
|
${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
683
|
${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>`)}
|
|
684
|
+
${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
685
|
|
|
675
686
|
${grp('Eines')}
|
|
676
687
|
${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 +846,7 @@ function addComputerSheet(tab, opts = {}) {
|
|
|
835
846
|
addMachine({ url: r.url, token: r.token, name: keepName || r.host, kind: 'vps', via: target });
|
|
836
847
|
if (keepName) { const ms = store.machines; ms[store.active].renamed = true; store.machines = ms; }
|
|
837
848
|
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
|
|
849
|
+
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
850
|
} 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
851
|
};
|
|
841
852
|
const vps = () => {
|
|
@@ -911,8 +922,9 @@ async function pantallaTab() {
|
|
|
911
922
|
});
|
|
912
923
|
}
|
|
913
924
|
function moreSheet() {
|
|
914
|
-
const item = (k) => `<a class="
|
|
915
|
-
|
|
925
|
+
const item = (k) => `<a class="more-link" href="${HASH[k]}" ${state.route.name === k ? 'aria-current="page"' : ''}><span>${VIEWS[k].label}</span>${k === 'approvals' && state.approvals.size ? `<span class="badge">${state.approvals.size}</span>` : ''}</a>`;
|
|
926
|
+
const groups = [...new Set(Object.values(VIEWS).map((v) => v.group))].map((group) => [group, Object.keys(VIEWS).filter((k) => VIEWS[k].group === group)]);
|
|
927
|
+
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
928
|
}
|
|
917
929
|
|
|
918
930
|
/* ---------- Sessions */
|
|
@@ -944,6 +956,46 @@ async function manageDelete() {
|
|
|
944
956
|
toast(fail ? `No he pogut esborrar ${fail} sessions` : `${ids.length} sessions esborrades`); render();
|
|
945
957
|
}
|
|
946
958
|
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();
|
|
959
|
+
// Sessions de Claude Code obertes al terminal de l'ordinador: es poden continuar des d'aquí.
|
|
960
|
+
let termSessions = null, termTimer = 0, termAt = 0, termFor = '';
|
|
961
|
+
async function loadTermSessions(force) {
|
|
962
|
+
if (!machine() || !state.connected || state.route.name !== 'sessions') return;
|
|
963
|
+
const key = machine().url;
|
|
964
|
+
if (termFor !== key) { termSessions = null; termFor = key; }
|
|
965
|
+
// render() es crida sovint: només es torna a demanar si fa una estona.
|
|
966
|
+
if (!force && Date.now() - termAt < 4000) return;
|
|
967
|
+
clearTimeout(termTimer); termAt = Date.now();
|
|
968
|
+
try { const r = (await api('/api/claude-sessions')).sessions || []; if (termFor === key) termSessions = r; } catch { termSessions = termSessions || []; }
|
|
969
|
+
const box = $('#termSessions'); if (box && state.route.name === 'sessions') box.innerHTML = termSessionsHtml();
|
|
970
|
+
// Es manté al dia mentre la pàgina és oberta: el terminal canvia sense avisar.
|
|
971
|
+
if (!document.hidden) termTimer = setTimeout(() => loadTermSessions(true), 8000);
|
|
972
|
+
}
|
|
973
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden && state.route.name === 'sessions') loadTermSessions(true); });
|
|
974
|
+
const TERM_STATUS = { busy: ['Treballant', 'running'], idle: ['Esperant', ''], waiting: ['Et necessita', 'waiting'], needs_input: ['Et necessita', 'waiting'] };
|
|
975
|
+
function termSessionsHtml() {
|
|
976
|
+
if (!termSessions?.length) return '';
|
|
977
|
+
const taken = new Set([...state.sessions.values()].flatMap((s) => [s.nativeId, s.fromTerminal]).filter(Boolean));
|
|
978
|
+
const fresh = termSessions.filter((s) => !taken.has(s.sessionId));
|
|
979
|
+
if (!fresh.length) return '';
|
|
980
|
+
const where = (t) => [t?.app, t?.tmux && 'tmux', t?.screen && 'screen', t?.ssh && 'SSH'].filter(Boolean).join(' · ');
|
|
981
|
+
return `<section class="term-sessions" aria-label="Al terminal"><h2 class="sec">Obertes al terminal</h2><div class="list">${fresh.map((s) => {
|
|
982
|
+
const [label, cls] = TERM_STATUS[s.status] || [s.status, ''];
|
|
983
|
+
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 || '')}">
|
|
984
|
+
<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>
|
|
985
|
+
<div class="title">${esc(s.title || s.name || basename(s.cwd || '') || 'Claude Code')}</div>
|
|
986
|
+
${s.ask ? `<div class="last">${esc(plain(s.ask))}</div>` : ''}
|
|
987
|
+
<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>
|
|
988
|
+
</button>`;
|
|
989
|
+
}).join('')}</div></section>`;
|
|
990
|
+
}
|
|
991
|
+
function sessionCard(s) {
|
|
992
|
+
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' : ''}">
|
|
993
|
+
<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>
|
|
994
|
+
<div class="title">${esc(s.title || 'Sessió nova')}</div>
|
|
995
|
+
${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(plain(s.last))}</div>` : ''}
|
|
996
|
+
<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>
|
|
997
|
+
</${manage ? 'div' : 'a'}>`;
|
|
998
|
+
}
|
|
947
999
|
function viewSessions() {
|
|
948
1000
|
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
1001
|
const list = [...state.sessions.values()].sort((a, b) => b.updated - a.updated);
|
|
@@ -956,13 +1008,11 @@ function viewSessions() {
|
|
|
956
1008
|
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
1009
|
}
|
|
958
1010
|
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>`}`;
|
|
1011
|
+
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));
|
|
1012
|
+
const recentHtml = recent.length ? `<div class="list">${recent.map(sessionCard).join('')}</div>` : '';
|
|
1013
|
+
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>` : '';
|
|
1014
|
+
const termHtml = `<div id="termSessions">${termSessionsHtml()}</div>`;
|
|
1015
|
+
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
1016
|
}
|
|
967
1017
|
|
|
968
1018
|
/* ---------- Aprovacions */
|
|
@@ -1021,7 +1071,7 @@ async function loadActivity() {
|
|
|
1021
1071
|
let mem = null;
|
|
1022
1072
|
function viewMemory() {
|
|
1023
1073
|
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>
|
|
1074
|
+
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
1075
|
<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
1076
|
<div id="memBody">${memHtml()}</div>`;
|
|
1027
1077
|
}
|
|
@@ -1040,14 +1090,18 @@ async function loadMemory() {
|
|
|
1040
1090
|
async function memEdit(p) {
|
|
1041
1091
|
let f = { path: p || 'projectes/', content: '' };
|
|
1042
1092
|
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>'}
|
|
1093
|
+
openSheet(`<h2>${p ? esc(p) : 'Fitxer nou'}</h2><form class="stack" id="memForm">
|
|
1094
|
+
${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
1095
|
<label class="f"><span>Contingut</span><textarea class="field mono" id="memText" rows="14" spellcheck="false">${esc(f.content)}</textarea></label>
|
|
1046
|
-
<
|
|
1047
|
-
|
|
1096
|
+
<p id="memError" class="form-error" role="alert" hidden></p>
|
|
1097
|
+
<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) => {
|
|
1098
|
+
$('#memForm', sh).onsubmit = async (event) => {
|
|
1099
|
+
event.preventDefault();
|
|
1048
1100
|
const path = p || $('#memPath', sh).value.trim();
|
|
1101
|
+
const button = $('#memSave', sh), error = $('#memError', sh); button.disabled = true; button.textContent = 'Desant…'; error.hidden = true;
|
|
1049
1102
|
try { await api(`/api/memory/f/${encodeURIComponent(path)}`, { method: 'PUT', body: { content: $('#memText', sh).value } }); toast('Desat'); closeSheet(); mem = null; loadMemory(); }
|
|
1050
|
-
catch (e) {
|
|
1103
|
+
catch (e) { error.textContent = `${e.message} Pots tornar a prémer «Desa».`; error.hidden = false; }
|
|
1104
|
+
finally { button.disabled = false; button.textContent = 'Desa'; }
|
|
1051
1105
|
};
|
|
1052
1106
|
if (p) $('#memDel', sh).onclick = async () => {
|
|
1053
1107
|
if (!confirm(`S’esborrarà «${p}» de la memòria. Els agents ja no ho recordaran.`)) return;
|
|
@@ -1398,7 +1452,7 @@ function eventNode(e) {
|
|
|
1398
1452
|
const s = state.sessions.get(state.route.id);
|
|
1399
1453
|
switch (e.kind) {
|
|
1400
1454
|
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 &&
|
|
1455
|
+
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>`);
|
|
1402
1456
|
case 'tool': 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
1457
|
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
1458
|
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)); }
|
|
@@ -1576,10 +1630,11 @@ function renderComposer() {
|
|
|
1576
1630
|
|
|
1577
1631
|
/* ---------- Fulls */
|
|
1578
1632
|
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}`;
|
|
1633
|
+
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
1634
|
if (!d.open) d.showModal(); raiseToasts();
|
|
1581
1635
|
if (isTouch) { if (document.activeElement?.matches?.('input, textarea, select')) document.activeElement.blur(); } else setTimeout(() => focusIn(d), 30); if (T.on) $('#tour').hidden = true;
|
|
1582
1636
|
onMount?.($('#sheetIn'));
|
|
1637
|
+
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
1638
|
}
|
|
1584
1639
|
const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); setTimeout(focusMain, 0); };
|
|
1585
1640
|
// A l'ordinador el cursor és sempre on s'escriu; al mòbil no, perquè no s'obri el teclat.
|
|
@@ -1590,9 +1645,50 @@ function focusMain() {
|
|
|
1590
1645
|
if (r === 'session') $('#msg')?.focus({ preventScroll: true });
|
|
1591
1646
|
else if (r === 'mobil' && inLocalChat()) $('#lmsg')?.focus({ preventScroll: true });
|
|
1592
1647
|
}
|
|
1593
|
-
$('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
|
|
1648
|
+
$('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet' || e.target.closest('[data-sheet-close]')) closeSheet(); });
|
|
1594
1649
|
$('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
|
|
1595
1650
|
|
|
1651
|
+
// Al mòbil, els fulls es tanquen lliscant cap avall, com els de l'iPhone: el full segueix el dit
|
|
1652
|
+
// 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.
|
|
1653
|
+
(() => {
|
|
1654
|
+
const d = $('#sheet');
|
|
1655
|
+
let g = null;
|
|
1656
|
+
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; };
|
|
1657
|
+
const reset = () => { d.style.transition = ''; d.style.transform = ''; };
|
|
1658
|
+
d.addEventListener('touchstart', (e) => {
|
|
1659
|
+
g = null;
|
|
1660
|
+
if (!mq.matches || e.touches.length !== 1 || e.target.closest('input, textarea, select, [contenteditable], input[type=range], [data-noswipe]')) return;
|
|
1661
|
+
const t = e.touches[0], sc = scrollerOf(e.target);
|
|
1662
|
+
const head = !!e.target.closest('.grab, .wiz-head, h2');
|
|
1663
|
+
if (sc && sc.scrollTop > 0 && !head) return;
|
|
1664
|
+
g = { y0: t.clientY, x0: t.clientX, dy: 0, lock: null, sc: head ? null : sc, pts: [[e.timeStamp, t.clientY]] };
|
|
1665
|
+
}, { passive: true });
|
|
1666
|
+
d.addEventListener('touchmove', (e) => {
|
|
1667
|
+
if (!g) return;
|
|
1668
|
+
const t = e.touches[0], dy = t.clientY - g.y0, dx = t.clientX - g.x0;
|
|
1669
|
+
if (!g.lock) {
|
|
1670
|
+
if (Math.abs(dy) < 6 && Math.abs(dx) < 6) return;
|
|
1671
|
+
if (dy > 0 && dy > Math.abs(dx) && !(g.sc && g.sc.scrollTop > 0)) { g.lock = 'y'; d.style.transition = 'none'; }
|
|
1672
|
+
else { g = null; return; }
|
|
1673
|
+
}
|
|
1674
|
+
if (e.cancelable) e.preventDefault();
|
|
1675
|
+
g.dy = Math.max(0, dy); g.pts.push([e.timeStamp, t.clientY]); if (g.pts.length > 5) g.pts.shift();
|
|
1676
|
+
d.style.transform = `translateY(${g.dy}px)`;
|
|
1677
|
+
}, { passive: false });
|
|
1678
|
+
const end = (e) => {
|
|
1679
|
+
const x = g; g = null; if (!x || x.lock !== 'y') return;
|
|
1680
|
+
const [t0, y0] = x.pts[0], [t1, y1] = x.pts[x.pts.length - 1], v = (y1 - y0) / Math.max(1, t1 - t0);
|
|
1681
|
+
if (e.type === 'touchend' && (x.dy > Math.min(160, d.offsetHeight * 0.28) || (v > 0.5 && x.dy > 20))) {
|
|
1682
|
+
if (reduce) { reset(); closeSheet(); return; }
|
|
1683
|
+
d.style.transition = 'transform .2s cubic-bezier(.4,0,1,1)'; d.style.transform = `translateY(${d.offsetHeight + 20}px)`;
|
|
1684
|
+
setTimeout(() => { closeSheet(); requestAnimationFrame(reset); }, 200);
|
|
1685
|
+
} else { d.style.transition = 'transform .3s cubic-bezier(.22,1,.36,1)'; d.style.transform = ''; setTimeout(reset, 300); }
|
|
1686
|
+
};
|
|
1687
|
+
d.addEventListener('touchend', end, { passive: true });
|
|
1688
|
+
d.addEventListener('touchcancel', end, { passive: true });
|
|
1689
|
+
d.addEventListener('close', reset);
|
|
1690
|
+
})();
|
|
1691
|
+
|
|
1596
1692
|
// Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
|
|
1597
1693
|
const modeBlocked = (agentId, mode) => agentId === 'claude' && mode === 'tot' && state.info?.user === 'root';
|
|
1598
1694
|
function modeHelp(agentId, mode) {
|
|
@@ -2468,6 +2564,8 @@ document.addEventListener('click', async (e) => {
|
|
|
2468
2564
|
// Les claus (ElevenLabs o OpenAI) viuen a l'ordinador. L'àudio fa: mòbil → ordinador → proveïdor.
|
|
2469
2565
|
let nodeVoice = null; // estat de la veu de l'ordinador actiu (sense claus)
|
|
2470
2566
|
const voiceOn = () => !!nodeVoice?.stt;
|
|
2567
|
+
// Escoltar necessita una clau d'ElevenLabs o OpenAI: amb Whisper sol no hi ha veu per parlar.
|
|
2568
|
+
const ttsOn = () => !!nodeVoice?.tts;
|
|
2471
2569
|
async function loadVoice() { try { nodeVoice = await api('/api/voice'); } catch { nodeVoice = null; } }
|
|
2472
2570
|
async function nodeAudio(path, opts) {
|
|
2473
2571
|
const m = machine(); if (!m) throw new Error('No hi ha cap ordinador connectat');
|
|
@@ -2534,13 +2632,15 @@ async function voiceSheet() {
|
|
|
2534
2632
|
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
2633
|
<div id="whisperBox" style="margin-top:14px">${whisperCard(v.whisper)}</div>
|
|
2536
2634
|
<form class="stack" id="voiceForm" style="margin-top:14px">
|
|
2635
|
+
<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
2636
|
<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
2637
|
<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
2638
|
<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
2639
|
<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
2640
|
<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
2641
|
${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
|
-
<
|
|
2642
|
+
<button class="btn ghost" type="button" id="vOff">Treu les claus</button></div></details>
|
|
2643
|
+
<div class="row sheet-foot"><button class="btn ink" type="submit">Desa</button></div>
|
|
2544
2644
|
</form>`, (sh) => {
|
|
2545
2645
|
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
2646
|
$('#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 +2719,17 @@ document.addEventListener('click', async (e) => {
|
|
|
2619
2719
|
if (a.dataset.qdrop) { queue.drop(a.dataset.qdrop); return render(); }
|
|
2620
2720
|
switch (a.dataset.act) {
|
|
2621
2721
|
case 'new': return newSessionSheet();
|
|
2722
|
+
case 'resume': {
|
|
2723
|
+
const btn = a; btn.disabled = true;
|
|
2724
|
+
const sid = btn.dataset.sid, cwd = btn.dataset.cwd, sname = btn.dataset.sname;
|
|
2725
|
+
try {
|
|
2726
|
+
const title = sname || basename(cwd) || 'Terminal';
|
|
2727
|
+
const ns = await api('/api/sessions', { method: 'POST', body: { agent: 'claude', cwd: cwd || '', mode: 'preguntar', resumeId: sid, title } });
|
|
2728
|
+
state.sessions.set(ns.id, ns); termSessions = termSessions?.filter((x) => x.sessionId !== sid) || null;
|
|
2729
|
+
go(`#/s/${ns.id}`);
|
|
2730
|
+
} catch (err) { toast(err.message, 4200); btn.disabled = false; }
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2622
2733
|
case 'manage-on': manage = new Set(); return render();
|
|
2623
2734
|
case 'manage-off': manage = null; return render();
|
|
2624
2735
|
case 'manage-del': return manageDelete();
|
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; } }
|