nio-cat 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "MIT",
6
6
  "author": "f.cat",
@@ -15,10 +15,15 @@ module.exports = {
15
15
 
16
16
  start(ctx) {
17
17
  const { session, prompt, cfg } = ctx;
18
- const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose'];
18
+ const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--include-partial-messages'];
19
19
  if (session.nativeId) args.push('--resume', session.nativeId);
20
20
  if (session.model) args.push('--model', session.model);
21
21
 
22
+ if (session.mode === 'tot' && process.getuid?.() === 0) {
23
+ ctx.fail('Claude Code no deixa el mode «Via lliure» quan s’executa com a root. Canvia els permisos a «Demana permís» o engega nio amb un usuari normal.');
24
+ return { kill() {} };
25
+ }
26
+
22
27
  const env = {};
23
28
  if (session.mode === 'llegir') {
24
29
  args.push('--permission-mode', 'plan');
@@ -65,9 +70,14 @@ function handle(ctx, line) {
65
70
  if (ev.model) ctx.meta({ model: ev.model });
66
71
  }
67
72
  break;
73
+ case 'stream_event': {
74
+ const d = ev.event?.type === 'content_block_delta' ? ev.event.delta : null;
75
+ if (d?.type === 'text_delta' && d.text) ctx.stream(d.text);
76
+ break;
77
+ }
68
78
  case 'assistant':
69
79
  for (const b of ev.message?.content || []) {
70
- if (b.type === 'text' && b.text) ctx.text(b.text);
80
+ if (b.type === 'text' && b.text) ctx.endStream(b.text);
71
81
  else if (b.type === 'tool_use') ctx.tool({ id: b.id, name: b.name, input: b.input });
72
82
  }
73
83
  break;
@@ -91,6 +101,6 @@ function handle(ctx, line) {
91
101
  });
92
102
  break;
93
103
  default:
94
- break; // stream_event, rate_limit_event, etc.
104
+ break; // rate_limit_event, etc.
95
105
  }
96
106
  }
package/src/hub.js CHANGED
@@ -72,6 +72,7 @@ class Hub extends EventEmitter {
72
72
  updated: s.updated,
73
73
  cost: s.cost || 0,
74
74
  last: lastText ? clip(lastText.text, 160) : '',
75
+ error: s.state === 'error' ? clip([...s.events].reverse().find((e) => e.kind === 'error')?.text || '', 200) : '',
75
76
  pending: [...this.approvals.values()].filter((a) => a.session === s.id && a.state === 'pending').length,
76
77
  };
77
78
  }
@@ -116,8 +117,20 @@ class Hub extends EventEmitter {
116
117
  this.persist(s);
117
118
  return;
118
119
  }
119
- const ev = this.push(s, 'text', { text });
120
- ev.open = true;
120
+ this.push(s, 'text', { text, open: true });
121
+ }
122
+
123
+ // Tanca el bloc de text en curs amb el text definitiu (o l'afegeix si no hi havia streaming).
124
+ endStream(s, text) {
125
+ const last = s.events[s.events.length - 1];
126
+ if (last && last.kind === 'text' && last.open) {
127
+ last.text = text;
128
+ delete last.open;
129
+ this.emit('out', { type: 'event', session: s.id, event: last });
130
+ this.touch(s);
131
+ return;
132
+ }
133
+ this.push(s, 'text', { text });
121
134
  }
122
135
 
123
136
  // ---------- sessions ----------
@@ -194,6 +207,8 @@ class Hub extends EventEmitter {
194
207
  const key = id(16);
195
208
  const toolNames = new Map();
196
209
  let finished = false;
210
+ let reported = false;
211
+ const tail = [];
197
212
  const ctx = {
198
213
  session: s,
199
214
  prompt: text,
@@ -212,6 +227,7 @@ class Hub extends EventEmitter {
212
227
  this.touch(s);
213
228
  },
214
229
  text: (t) => this.push(s, 'text', { text: String(t) }),
230
+ endStream: (t) => this.endStream(s, String(t)),
215
231
  stream: (t) => this.chunk(s, String(t)),
216
232
  tool: ({ id: tid, name, input }) => {
217
233
  toolNames.set(tid, name);
@@ -223,10 +239,19 @@ class Hub extends EventEmitter {
223
239
  if (typeof r.cost === 'number') s.cost = (s.cost || 0) + r.cost;
224
240
  this.push(s, 'result', r);
225
241
  },
226
- error: (msg) => this.push(s, 'error', { text: clip(typeof msg === 'string' ? msg : JSON.stringify(msg), 1200) }),
242
+ error: (msg) => {
243
+ reported = true;
244
+ this.push(s, 'error', { text: clip(typeof msg === 'string' ? msg : JSON.stringify(msg), 1200) });
245
+ },
246
+ fail: (msg) => {
247
+ ctx.error(msg);
248
+ queueMicrotask(() => ctx.done(1));
249
+ },
227
250
  log: (line) => {
228
- // Les línies de stderr només es guarden; el mòbil les mostra plegades.
251
+ // Les línies de stderr es guarden (el mòbil les mostra plegades) i les últimes expliquen un error.
229
252
  if (/^\s*$/.test(line)) return;
253
+ tail.push(line.trim());
254
+ if (tail.length > 4) tail.shift();
230
255
  this.push(s, 'log', { text: clip(line, 600) });
231
256
  },
232
257
  ask: ({ tool, input }) => this.requestApproval({ session: s.id, tool, input }),
@@ -238,8 +263,9 @@ class Hub extends EventEmitter {
238
263
  if (a.session === sid && a.state === 'pending') this.decide(a.id, 'deny', 'La sessió s’ha acabat');
239
264
  }
240
265
  if (err) this.push(s, 'error', { text: `No s’ha pogut engegar: ${err.message}` });
241
- else if (code && code !== 0 && code !== 130 && code !== 143) {
242
- this.push(s, 'error', { text: `L’agent s’ha aturat amb un error (codi ${code})` });
266
+ else if (code && code !== 0 && code !== 130 && code !== 143 && !reported) {
267
+ const why = explain(tail);
268
+ this.push(s, 'error', { text: why || `${agentName(s)} s’ha aturat amb un error (codi ${code})${tail.length ? `:\n${tail.join('\n')}` : ''}` });
243
269
  }
244
270
  s.state = code === 130 || code === 143 ? 'idle' : code && code !== 0 ? 'error' : 'idle';
245
271
  this.push(s, 'status', { state: s.state, code });
@@ -342,6 +368,15 @@ class Hub extends EventEmitter {
342
368
  }
343
369
  }
344
370
 
371
+ // Causes conegudes d'una sortida amb error, explicades en català.
372
+ function explain(lines) {
373
+ const t = lines.join('\n');
374
+ if (/cannot be used with root/i.test(t)) return 'Claude Code no deixa el mode «Via lliure» quan s’executa com a root. Canvia els permisos a «Demana permís» o engega nio amb un usuari normal.';
375
+ if (/not logged in|please run \/login|invalid api key|authentication/i.test(t)) return 'L’agent no té la sessió iniciada en aquest ordinador. Entra-hi per terminal i inicia sessió (per exemple, executant «claude»).';
376
+ if (/rate limit|usage limit|quota|overloaded|429/i.test(t)) return `L’agent ha arribat al límit d’ús o està saturat. Torna-ho a provar d’aquí una estona.\n${lines.slice(-1)[0] || ''}`.trim();
377
+ return '';
378
+ }
379
+
345
380
  function agentName(s) {
346
381
  return (s && byId[s.agent]?.name) || 'L’agent';
347
382
  }
package/web/app/app.js CHANGED
@@ -31,8 +31,10 @@ const ICON = {
31
31
  server: '<rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>',
32
32
  scan: '<path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3M4 12h16"/>',
33
33
  check: '<path class="tick" d="M5 12.5l4.5 4.5L19 7.5"/>',
34
+ mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>', speaker: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5zM15.5 9a4 4 0 0 1 0 6M18 6.5a8 8 0 0 1 0 11"/>',
34
35
  copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/>'
35
36
  };
37
+ const agentBot = (agent, mood = 'idle') => `<span class="abot" data-agent="${esc(agent)}">${bot(mood)}</span>`;
36
38
  const AGENT_NAMES = { claude: 'Claude Code', codex: 'Codex', grok: 'Grok', antigravity: 'Antigravity', ollama: 'Ollama', demo: 'Demo', browser: 'Xat local' };
37
39
  const MODES = {
38
40
  llegir: { label: 'Només llegir', short: 'Llegir' },
@@ -376,7 +378,7 @@ function viewComputers() {
376
378
  <section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}<div class="row"><button class="btn ink grow" data-act="push" type="button">Activa els avisos</button><button class="btn" data-act="push-test" type="button">Envia’n un de prova</button></div></div></section>
377
379
  </div>
378
380
  </div>` : '';
379
- return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}${helpPanel()}`;
381
+ return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}${voicePanel()}${helpPanel()}`;
380
382
  }
381
383
 
382
384
  /* ---------- Afegir un ordinador: aquest, un servidor per SSH o un codi */
@@ -490,7 +492,7 @@ function machinesSheet() {
490
492
  <div class="row between sheet-foot">${ms.length ? '<a class="btn ghost" href="#/ordinadors" data-close>Gestiona</a>' : '<button class="btn ghost" type="button" data-close>Tanca</button>'}<button class="btn ink" type="button" data-act="add-computer">${svg(ICON.plus)}Afegeix ordinador</button></div>`, (sh) => { $$('[data-close]', sh).forEach((b) => (b.onclick = closeSheet)); });
491
493
  }
492
494
  function moreSheet() {
493
- openSheet(`<h2>Més</h2><div class="list">${['models', 'mobil', 'pissarra'].map((k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`).join('')}<a class="card row" href="#/ordinadors" data-act="help" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.1"/></svg><b>Com es fa</b></a><button class="card row" type="button" data-act="tour" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17.2v.1"/></svg><b>Tutorial</b></button></div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
495
+ openSheet(`<h2>Més</h2><div class="list">${['models', 'mobil', 'pissarra'].map((k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`).join('')}<button class="card row" type="button" data-act="voice" style="gap:12px">${svg(ICON.mic)}<b>Veu</b></button><a class="card row" href="#/ordinadors" data-act="help" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.1"/></svg><b>Com es fa</b></a><button class="card row" type="button" data-act="tour" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17.2v.1"/></svg><b>Tutorial</b></button></div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
494
496
  }
495
497
 
496
498
  /* ---------- Sessions */
@@ -511,9 +513,9 @@ function viewSessions() {
511
513
  }
512
514
  return `${head}${list.length ? `<div class="list">${list.map((s) => `
513
515
  <a class="card session-card" href="#/s/${esc(s.id)}">
514
- <div class="row between"><span class="agent-tag" data-agent="${esc(s.agent)}">${esc(AGENT_NAMES[s.agent] || s.agent)}</span>${stateChip(s)}</div>
516
+ <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>
515
517
  <div class="title">${esc(s.title || 'Sessió nova')}</div>
516
- ${s.last ? `<div class="last">${esc(s.last)}</div>` : ''}
518
+ ${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(s.last)}</div>` : ''}
517
519
  <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>
518
520
  </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>`}`;
519
521
  }
@@ -578,7 +580,7 @@ let feedEls = new Map(), toolEls = new Map();
578
580
  function renderSession() {
579
581
  const id = state.route.id, view = $('#view');
580
582
  view.innerHTML = `<section class="chat">
581
- <div class="chat-head"><a class="icon-btn" href="#/sessions" aria-label="Torna a les sessions">${svg(ICON.back)}</a><span id="headBot"></span><div class="grow"><h1 id="sessTitle"></h1><div class="sub" id="sessSub"></div></div><button class="icon-btn" data-act="session-more" aria-label="Opcions de la sessió">${svg(ICON.more)}</button></div>
583
+ <div class="chat-head"><a class="icon-btn" href="#/sessions" aria-label="Torna a les sessions">${svg(ICON.back)}</a><span id="headBot" class="abot"></span><div class="grow"><h1 id="sessTitle"></h1><div class="sub" id="sessSub"></div></div><button class="icon-btn" data-act="session-more" aria-label="Opcions de la sessió">${svg(ICON.more)}</button></div>
582
584
  <div class="mode-bar" id="modeBar"></div>
583
585
  <div class="feed" id="feed" aria-live="polite"></div>
584
586
  <div class="composer" id="composer"></div>
@@ -598,10 +600,10 @@ function renderSessionHead() {
598
600
  if (!bar) return;
599
601
  $('#sessTitle').textContent = s?.title || 'Sessió nova';
600
602
  $('#sessSub').textContent = s ? `${AGENT_NAMES[s.agent] || s.agent} · ${basename(s.cwd)}` : '';
601
- const hb = $('#headBot'); if (hb) { hb.innerHTML = bot(sessionMood(s)); window.NioBot?.refresh(); }
603
+ const hb = $('#headBot'); if (hb) { hb.dataset.agent = s?.agent || ''; hb.innerHTML = bot(sessionMood(s)); window.NioBot?.refresh(); }
602
604
  if (!s) { bar.innerHTML = ''; return; }
603
605
  const ag = state.info?.agents?.find((a) => a.id === s.agent);
604
- bar.innerHTML = `<span class="chip"><span class="agent-tag" data-agent="${esc(s.agent)}">${esc(AGENT_NAMES[s.agent] || s.agent)}</span></span>
606
+ bar.innerHTML = `<span class="chip"><span class="agent-tag nosq" data-agent="${esc(s.agent)}">${agentBot(s.agent, sessionMood(s))}${esc(AGENT_NAMES[s.agent] || s.agent)}</span></span>
605
607
  ${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${esc(MODES[s.mode]?.label || s.mode)}</button>`}
606
608
  <span class="chip mono">${esc(basename(s.cwd))}</span>
607
609
  <button class="chip mono" data-act="model" type="button" title="Canvia de model">${esc(s.model || 'Model per defecte')}</button>
@@ -645,11 +647,11 @@ function eventNode(e) {
645
647
  const s = state.sessions.get(state.route.id);
646
648
  switch (e.kind) {
647
649
  case 'user': return el(`<div class="msg-user">${esc(e.text)}</div>`);
648
- case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}">${md(e.text)}</div>`);
650
+ case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}" data-seq="${e.seq}">${md(e.text)}${!e.open && voiceOn() ? `<button class="speak" type="button" data-speak aria-label="Escolta la resposta">${svg(ICON.speaker)}<span>Escolta</span></button>` : ''}</div>`);
649
651
  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>`);
650
652
  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>`);
651
653
  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)); }
652
- case 'error': return el(`<div class="ev-error">${esc(e.text)}</div>`);
654
+ case 'error': return el(`<div class="ev-error" role="alert"><b>Error</b><span>${esc(e.text)}</span></div>`);
653
655
  case 'info': return el(`<div class="ev-info">${esc(e.text)}</div>`);
654
656
  case 'result': {
655
657
  const bits = [];
@@ -669,10 +671,10 @@ function renderTyping() {
669
671
  const last = evs ? [...evs.values()].sort((a, b) => b.seq - a.seq).find((x) => x.kind !== 'status' && x.kind !== 'log') : null;
670
672
  const streaming = last && last.kind === 'text' && last.open;
671
673
  let t = $('.typing', feed);
672
- if (busy && !streaming) { if (!t) { feed.appendChild(el(`<div class="typing">${bot('work')}<span>${esc(AGENT_NAMES[s.agent] || 'L’agent')} està treballant…</span></div>`)); window.NioBot?.refresh(); } }
674
+ if (busy && !streaming) { if (!t) { feed.appendChild(el(`<div class="typing">${agentBot(s.agent, 'work')}<span>${esc(AGENT_NAMES[s.agent] || 'L’agent')} està treballant…</span></div>`)); window.NioBot?.refresh(); } }
673
675
  else if (t) t.remove();
674
676
  if (!busy) $$('.msg-ai.cursor', feed).forEach((n) => n.classList.remove('cursor'));
675
- const hb = $('#headBot .bot'); if (hb) hb.dataset.mood = sessionMood(s);
677
+ $$('.mode-bar .abot .bot, #headBot .bot').forEach((b) => { b.dataset.mood = sessionMood(s); });
676
678
  }
677
679
  /* ---------- Comandes amb «/» (les de nio i les de l'agent) */
678
680
  const NIO_CMDS = [
@@ -732,7 +734,7 @@ function renderComposer() {
732
734
  if (box.dataset.busy === String(busy) && $('textarea', box)) return;
733
735
  box.dataset.busy = String(busy);
734
736
  if (s) loadCmds(s.id).then(() => { if (state.route.id === s.id) drawSlash(); });
735
- box.innerHTML = `<div class="slash" id="slash" role="listbox" hidden></div><div class="inner"><textarea rows="1" id="msg" placeholder="${busy ? 'L’agent està treballant…' : 'Escriu a l’agent… (/ per a comandes)'}" enterkeyhint="send" aria-label="Missatge" aria-controls="slash"></textarea><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
737
+ box.innerHTML = `<div class="slash" id="slash" role="listbox" hidden></div><div class="inner"><textarea rows="1" id="msg" placeholder="${busy ? 'L’agent està treballant…' : 'Escriu a l’agent… (/ per a comandes)'}" enterkeyhint="send" aria-label="Missatge" aria-controls="slash"></textarea><button class="mic" id="micBtn" type="button" data-mic aria-label="Dicta amb el micròfon">${svg(ICON.mic)}</button><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
736
738
  const ta = $('#msg'); ta.value = prev;
737
739
  const grow = () => autogrow(ta);
738
740
  let items = [], sel = 0;
@@ -783,8 +785,11 @@ function openSheet(html, onMount, cls) {
783
785
  const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); };
784
786
  $('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
785
787
 
788
+ // Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
789
+ const modeBlocked = (agentId, mode) => agentId === 'claude' && mode === 'tot' && state.info?.user === 'root';
786
790
  function modeHelp(agentId, mode) {
787
791
  const a = state.info?.agents?.find((x) => x.id === agentId);
792
+ if (modeBlocked(agentId, mode)) return 'No disponible: Claude Code no ho permet quan s’executa com a root. Fes servir «Demana permís» o engega nio amb un usuari normal.';
788
793
  if (mode === 'llegir') return 'Llegeix el projecte i et proposa canvis, però no toca cap fitxer.';
789
794
  if (mode === 'tot') return 'Treballa sense preguntar. Fes-lo servir només en carpetes on confiïs plenament.';
790
795
  if (a?.approvals) return 'Llegeix sol. Abans d’editar un fitxer o d’executar una ordre, et demana permís aquí.';
@@ -867,10 +872,11 @@ async function newSessionSheet(preset = {}) {
867
872
  },
868
873
  () => {
869
874
  const ag = agentOf();
875
+ if (modeBlocked(sel.agent, sel.mode)) sel.mode = 'preguntar';
870
876
  const modelHtml = sel.agent === 'ollama'
871
877
  ? `<label class="f"><span>Model</span><select id="modelSel">${(models || []).map((m) => `<option ${m.name === sel.model ? 'selected' : ''}>${esc(m.name)}</option>`).join('') || `<option value="">${models == null ? 'Carregant…' : 'Cap model (ollama pull qwen3:4b)'}</option>`}</select></label>`
872
878
  : `<label class="f"><span>Model <span class="muted">(opcional)</span></span><input type="text" id="modelIn" value="${esc(sel.model)}" placeholder="El que l’agent faci servir per defecte" autocapitalize="off" spellcheck="false"></label>`;
873
- return `<p class="wiz-q">Quins permisos té?</p>${ag?.noModes ? '<p class="muted small">Aquest agent no té modes de permisos.</p>' : `<div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-mode="${k}" aria-pressed="${k === sel.mode}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(sel.agent, k))}</div></button>`).join('')}</div>`}${modelHtml}`;
879
+ return `<p class="wiz-q">Quins permisos té?</p>${ag?.noModes ? '<p class="muted small">Aquest agent no té modes de permisos.</p>' : `<div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-mode="${k}" aria-pressed="${k === sel.mode}" ${modeBlocked(sel.agent, k) ? 'disabled' : ''}><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(sel.agent, k))}</div></button>`).join('')}</div>`}${modelHtml}`;
874
880
  },
875
881
  () => `<p class="wiz-q">Què ha de fer?</p>
876
882
  <dl class="kv"><dt>Agent</dt><dd>${esc(AGENT_NAMES[sel.agent] || sel.agent)}</dd><dt>Carpeta</dt><dd class="mono">${esc(sel.cwd)}</dd>${agentOf()?.noModes ? '' : `<dt>Permisos</dt><dd>${esc(MODES[sel.mode].label)}</dd>`}${sel.model ? `<dt>Model</dt><dd class="mono">${esc(sel.model)}</dd>` : ''}</dl>
@@ -935,7 +941,7 @@ async function modelSheet(s) {
935
941
  });
936
942
  }
937
943
  function modeSheet(s) {
938
- openSheet(`<h2>Permisos</h2><div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-m="${k}" style="${k === s.mode ? 'border-color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)' : ''}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(s.agent, k))}</div></button>`).join('')}</div><p class="muted small">El canvi s’aplica a partir del pròxim missatge.</p>`, (sh) => {
944
+ openSheet(`<h2>Permisos</h2><div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-m="${k}" ${modeBlocked(s.agent, k) ? 'disabled' : ''} style="${k === s.mode ? 'border-color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)' : ''}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(s.agent, k))}</div></button>`).join('')}</div><p class="muted small">El canvi s’aplica a partir del pròxim missatge.</p>`, (sh) => {
939
945
  $$('[data-m]', sh).forEach((b) => (b.onclick = async () => {
940
946
  try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body: { mode: b.dataset.m } }); state.sessions.set(s.id, sum); closeSheet(); renderSessionHead(); }
941
947
  catch (e) { toast(e.message); }
@@ -1225,9 +1231,81 @@ async function sendLocal() {
1225
1231
  local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
1226
1232
  }
1227
1233
 
1234
+ /* ---------------------------------------------------------------- veu (ElevenLabs) */
1235
+ // La clau viu només en aquest navegador; les crides van directes a ElevenLabs, sense passar per nio.
1236
+ const VOICE_DEFAULT = { key: '', voice: 'JBFqnCBsd6RMkjVDRZzb', model: 'eleven_v3' };
1237
+ const voiceCfg = () => ({ ...VOICE_DEFAULT, ...LS.get('nio.voice', {}) });
1238
+ const voiceOn = () => !!voiceCfg().key;
1239
+ async function elevenlabs(path, init = {}) {
1240
+ const res = await fetch(`https://api.elevenlabs.io${path}`, { ...init, headers: { 'xi-api-key': voiceCfg().key, ...(init.headers || {}) } });
1241
+ if (res.ok) return res;
1242
+ let why = ''; try { const j = await res.json(); why = j.detail?.message || (typeof j.detail === 'string' ? j.detail : ''); } catch { /* sense cos */ }
1243
+ throw new Error(res.status === 401 ? 'La clau d’ElevenLabs no és vàlida.' : res.status === 429 ? 'ElevenLabs diu que has superat el límit. Prova-ho d’aquí una estona.' : `ElevenLabs: ${why || `error ${res.status}`}`);
1244
+ }
1245
+ const speech = { audio: null, btn: null, cache: new Map() };
1246
+ const speechText = (t) => String(t).replace(/```[\s\S]*?```/g, ' (bloc de codi) ').replace(/`([^`]+)`/g, '$1').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/^#{1,4}\s+/gm, '').replace(/^\s*[-*•]\s+/gm, '').trim().slice(0, 4500);
1247
+ function stopSpeech() { speech.audio?.pause(); speech.audio = null; speech.btn?.classList.remove('on', 'wait'); speech.btn = null; }
1248
+ async function speak(btn) {
1249
+ if (speech.btn === btn) return stopSpeech();
1250
+ stopSpeech();
1251
+ const ev = state.events.get(state.route.id)?.get(Number(btn.closest('[data-seq]')?.dataset.seq)), text = speechText(ev?.text || '');
1252
+ if (!text) return;
1253
+ speech.btn = btn; btn.classList.add('wait');
1254
+ try {
1255
+ const c = voiceCfg(), k = `${c.voice}|${c.model}|${text}`; let url = speech.cache.get(k);
1256
+ if (!url) {
1257
+ const res = await elevenlabs(`/v1/text-to-speech/${encodeURIComponent(c.voice)}?output_format=mp3_44100_64`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, model_id: c.model }) });
1258
+ url = URL.createObjectURL(await res.blob()); speech.cache.set(k, url);
1259
+ }
1260
+ if (speech.btn !== btn) return;
1261
+ const a = new Audio(url); speech.audio = a; a.onended = stopSpeech; await a.play();
1262
+ btn.classList.remove('wait'); btn.classList.add('on');
1263
+ } catch (e) { stopSpeech(); toast(e.message, 4500); }
1264
+ }
1265
+ const mic = { rec: null, chunks: [], stream: null };
1266
+ async function toggleMic() {
1267
+ if (!voiceOn()) return voiceSheet();
1268
+ if (mic.rec) return mic.rec.stop();
1269
+ if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Aquest navegador no pot gravar àudio.');
1270
+ try { mic.stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch { return toast('No tinc permís per al micròfon. Activa’l als ajustos del navegador.', 4500); }
1271
+ const rec = new MediaRecorder(mic.stream); mic.rec = rec; mic.chunks = [];
1272
+ rec.ondataavailable = (e) => { if (e.data.size) mic.chunks.push(e.data); };
1273
+ rec.onstop = async () => {
1274
+ mic.stream.getTracks().forEach((t) => t.stop()); mic.rec = null;
1275
+ const blob = new Blob(mic.chunks, { type: rec.mimeType || 'audio/webm' });
1276
+ $('#micBtn')?.classList.remove('rec'); $('#micBtn')?.classList.add('wait');
1277
+ try {
1278
+ if (blob.size < 1500) throw new Error('No he sentit res. Prova-ho una altra vegada.');
1279
+ const fd = new FormData(); fd.append('model_id', 'scribe_v1'); fd.append('file', blob, 'veu.webm');
1280
+ const r = await (await elevenlabs('/v1/speech-to-text', { method: 'POST', body: fd })).json(), ta = $('#msg');
1281
+ if (!r.text?.trim()) toast('No he entès res.');
1282
+ else if (ta) { ta.value = `${ta.value ? `${ta.value} ` : ''}${r.text.trim()}`; ta.dispatchEvent(new Event('input')); ta.focus(); }
1283
+ } catch (e) { toast(e.message, 4500); }
1284
+ $('#micBtn')?.classList.remove('wait');
1285
+ };
1286
+ rec.start(); $('#micBtn')?.classList.add('rec');
1287
+ }
1288
+ function voicePanel() {
1289
+ return `<section class="panel" style="margin-top:28px"><header><h2>Veu</h2>${voiceOn() ? '<span class="chip ok"><i class="dot on"></i>ElevenLabs</span>' : ''}</header><div class="body stack"><p class="small">Dicta els missatges amb el micròfon i escolta les respostes de l’agent, amb ElevenLabs.</p><div class="row"><button class="btn ink" data-act="voice" type="button">${voiceOn() ? 'Canvia la configuració' : 'Configura la veu'}</button></div></div></section>`;
1290
+ }
1291
+ function voiceSheet() {
1292
+ const c = voiceCfg();
1293
+ openSheet(`<h2>Veu amb ElevenLabs</h2><p class="muted small">Dicta amb el micròfon i escolta les respostes. La clau es queda només en aquest navegador: les crides van directes a ElevenLabs, nio no les veu.</p>
1294
+ <form class="stack" id="voiceForm" style="margin-top:14px">
1295
+ <label class="f"><span>Clau d’API</span><input type="password" id="vKey" value="${esc(c.key)}" placeholder="sk_…" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
1296
+ <label class="f"><span>Identificador de la veu</span><input type="text" id="vVoice" value="${esc(c.voice)}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
1297
+ <label class="f"><span>Model</span><select id="vModel"><option value="eleven_v3" ${c.model === 'eleven_v3' ? 'selected' : ''}>v3 (inclou català)</option><option value="eleven_multilingual_v2" ${c.model === 'eleven_multilingual_v2' ? 'selected' : ''}>Multilingual v2</option></select></label>
1298
+ <div class="row between sheet-foot"><button class="btn ghost" type="button" id="vOff" ${c.key ? '' : 'disabled'}>Treu la clau</button><button class="btn ink" type="submit">Desa</button></div>
1299
+ </form>`, (sh) => {
1300
+ const save = (v) => { LS.set('nio.voice', v); closeSheet(); if (state.route.name === 'session') renderSession(); else render(); };
1301
+ $('#voiceForm', sh).onsubmit = (e) => { e.preventDefault(); const key = $('#vKey', sh).value.trim(); if (!key) return toast('Enganxa la clau d’API.'); save({ key, voice: $('#vVoice', sh).value.trim() || VOICE_DEFAULT.voice, model: $('#vModel', sh).value }); toast('Veu activada'); };
1302
+ $('#vOff', sh).onclick = () => { save({ ...c, key: '' }); toast('Clau eliminada'); };
1303
+ });
1304
+ }
1305
+
1228
1306
  /* ---------------------------------------------------------------- accions */
1229
1307
  document.addEventListener('click', async (e) => {
1230
- const a = e.target.closest('[data-act], [data-copy], [data-decide], [data-machine], [data-forget], [data-reconnect], [data-lm]');
1308
+ const a = e.target.closest('[data-act], [data-speak], [data-mic], [data-copy], [data-decide], [data-machine], [data-forget], [data-reconnect], [data-lm]');
1231
1309
  if (!a) return;
1232
1310
  if (a.dataset.copy) {
1233
1311
  try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
@@ -1238,6 +1316,8 @@ document.addEventListener('click', async (e) => {
1238
1316
  setTimeout(() => { a.innerHTML = html; a.classList.remove('copied'); delete a.dataset.copied; }, 1700);
1239
1317
  return;
1240
1318
  }
1319
+ if (a.dataset.speak != null) return speak(a);
1320
+ if (a.dataset.mic != null) return toggleMic();
1241
1321
  if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
1242
1322
  if (a.dataset.machine != null) return switchMachine(Number(a.dataset.machine));
1243
1323
  if (a.dataset.reconnect != null) { const m = store.machines[Number(a.dataset.reconnect)]; return addComputerSheet('vps', { target: m.via, auto: true }); }
@@ -1270,6 +1350,7 @@ document.addEventListener('click', async (e) => {
1270
1350
  case 'lm-clear': local.messages = []; return render();
1271
1351
  case 'palette': return palette();
1272
1352
  case 'tour': return tourStart();
1353
+ case 'voice': return voiceSheet();
1273
1354
  }
1274
1355
  });
1275
1356
  document.addEventListener('keydown', (e) => { const t = e.target; if ((e.key === 'Enter' || e.key === ' ') && t.classList?.contains('switch') && t.dataset.act) { e.preventDefault(); t.click(); } });
@@ -1290,6 +1371,7 @@ function commands() {
1290
1371
  c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
1291
1372
  [...state.sessions.values()].slice(0, 20).forEach((s) => c.push({ label: s.title || 'Sessió nova', hint: AGENT_NAMES[s.agent] || s.agent, run: () => go(`#/s/${s.id}`) }));
1292
1373
  c.push({ label: 'Com es fa (ajuda)', hint: 'Ajuda', run: () => { go('#/ordinadors'); setTimeout(() => $('#help')?.scrollIntoView({ block: 'start' }), 60); } });
1374
+ c.push({ label: 'Configura la veu (ElevenLabs)', hint: 'Ajustos', run: voiceSheet });
1293
1375
  c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
1294
1376
  return c;
1295
1377
  }
package/web/app/style.css CHANGED
@@ -104,7 +104,7 @@ kbd { font: 600 11px var(--font); padding: 1px 6px; border: 1px solid var(--bord
104
104
  .icon-btn { position: relative; display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 12px; background: none; color: var(--fg); }
105
105
  .icon-btn:hover { background: color-mix(in srgb, var(--bg-2) 80%, transparent); }
106
106
  .icon-btn .badge { position: absolute; top: 3px; right: 1px; min-width: 18px; height: 18px; font-size: 10px; }
107
- input[type="text"], input[type="url"], input[type="search"], select, textarea.field { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--white); outline: none; }
107
+ input[type="text"], input[type="password"], input[type="url"], input[type="search"], select, textarea.field { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--white); outline: none; }
108
108
  textarea.field { height: auto; padding: 10px 12px; resize: vertical; line-height: 1.45; }
109
109
  input:focus, select:focus, textarea:focus { border-color: var(--fg); }
110
110
  select { appearance: none; -webkit-appearance: none; padding-right: 40px; cursor: pointer; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2369615c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; background-size: 16px 16px; }
@@ -134,8 +134,13 @@ button.chip:hover { border-color: var(--fg); }
134
134
  @keyframes pulse { 50% { opacity: .3; } }
135
135
  .agent-tag { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 600; }
136
136
  .agent-tag::before { content: ""; width: 8px; height: 8px; border-radius: 3px; background: var(--c, var(--fg-muted)); }
137
- [data-agent="claude"] { --c: var(--accent); } [data-agent="codex"] { --c: var(--moss); } [data-agent="grok"] { --c: var(--fg); }
138
- [data-agent="antigravity"] { --c: var(--blue); } [data-agent="ollama"] { --c: #b08a5a; } [data-agent="demo"] { --c: var(--lemon); } [data-agent="browser"] { --c: #8a5cf6; }
137
+ [data-agent="claude"] { --c: #e8602c; } [data-agent="codex"] { --c: #2b5bd8; } [data-agent="grok"] { --c: var(--fg); }
138
+ [data-agent="antigravity"] { --c: #0f9d8a; } [data-agent="ollama"] { --c: #b08a5a; } [data-agent="demo"] { --c: var(--lemon); } [data-agent="browser"] { --c: #8a5cf6; }
139
+ .agent-tag.nosq::before { display: none; }
140
+ .abot { display: inline-flex; color: var(--c, var(--fg)); }
141
+ .abot .bot { --s: 20px; --bw: .15; }
142
+ .chat-head .abot .bot { --s: 30px; --bw: .14; }
143
+ .typing .abot .bot { --s: 22px; --bw: .15; }
139
144
  .empty { padding: 48px 20px; text-align: center; color: var(--fg-muted); display: grid; justify-items: center; gap: 8px; }
140
145
  .empty .bot { --s: 56px; --bw: .13; color: var(--fg); margin-bottom: 8px; }
141
146
  .empty h2 { font-size: 20px; font-weight: 500; letter-spacing: -.04em; color: var(--fg); }
@@ -175,6 +180,7 @@ button.chip:hover { border-color: var(--fg); }
175
180
 
176
181
  /* ---------- Sessions ---------- */
177
182
  .session-card .title { font-size: 16px; font-weight: 500; letter-spacing: -.02em; margin-top: 8px; }
183
+ .session-card .last.err { color: var(--red); }
178
184
  .session-card .last { color: var(--fg-muted); font-size: 14px; margin-top: 4px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
179
185
  .session-card .meta { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; font-size: 12px; color: var(--fg-muted); }
180
186
  .fab { position: fixed; right: 24px; bottom: calc(24px + var(--safe-b)); z-index: 15; display: inline-flex; align-items: center; gap: 8px; height: 50px; padding: 0 20px 0 16px; border: 0; border-radius: 16px; background: var(--fg); color: var(--bg); font-weight: 600; box-shadow: 0 14px 30px -14px color-mix(in srgb, var(--fg) 70%, transparent); transition: transform .15s; }
@@ -183,7 +189,6 @@ button.chip:hover { border-color: var(--fg); }
183
189
  /* ---------- Xat de sessió ---------- */
184
190
  .chat { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
185
191
  .chat-head { display: flex; align-items: center; gap: 12px; padding: 14px 24px; border-bottom: 1px solid var(--border); }
186
- .chat-head .bot { --s: 30px; --bw: .14; }
187
192
  .chat-head h1 { font-size: 17px; font-weight: 600; letter-spacing: -.03em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
188
193
  .chat-head .sub { font-size: 12px; color: var(--fg-muted); }
189
194
  .mode-bar { display: flex; gap: 6px; padding: 10px 24px; overflow-x: auto; scrollbar-width: none; border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); }
@@ -210,12 +215,11 @@ button.chip:hover { border-color: var(--fg); }
210
215
  .tool pre { margin: 0 12px 12px; padding: 10px 12px; max-height: 320px; overflow: auto; border-radius: 8px; background: var(--code); font: 12px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; }
211
216
  .ev-info, .ev-result { text-align: center; font-size: 12px; color: var(--fg-muted); }
212
217
  .ev-result { display: flex; justify-content: center; gap: 6px; }
213
- .ev-error { padding: 10px 14px; border-radius: 10px; border: 1px solid color-mix(in srgb, var(--red) 35%, transparent); background: color-mix(in srgb, var(--red) 6%, var(--white)); color: var(--red); font-size: 14px; white-space: pre-wrap; }
218
+ .ev-error { display: grid; gap: 2px; padding: 10px 14px; border-radius: 10px; border: 1px solid color-mix(in srgb, var(--red) 35%, transparent); background: color-mix(in srgb, var(--red) 6%, var(--white)); color: var(--red); font-size: 14px; white-space: pre-wrap; }
214
219
  .ev-logs { font-size: 12px; color: var(--fg-muted); }
215
220
  .ev-logs summary { cursor: pointer; }
216
221
  .ev-logs pre { margin-top: 6px; padding: 10px; border-radius: 8px; background: var(--code); font: 11px/1.5 var(--mono); max-height: 220px; overflow: auto; white-space: pre-wrap; }
217
222
  .typing { display: flex; align-items: center; gap: 10px; color: var(--fg-muted); font-size: 13px; }
218
- .typing .bot { --s: 22px; --bw: .15; color: var(--fg); }
219
223
  @keyframes rise { from { opacity: 0; transform: translateY(6px); } }
220
224
 
221
225
  .approval { padding: 16px; border-radius: var(--radius); border: 1.5px solid var(--accent); background: var(--white); display: grid; gap: 10px; animation: rise .25s var(--ease); }
@@ -345,7 +349,7 @@ body.board-mode .view-enter { animation: none; }
345
349
  body.chat-mode .mbar { position: fixed; left: 0; right: 0; transform: translateY(var(--vvt, 0px)); }
346
350
  body.chat-mode .chat { position: fixed; left: 0; right: 0; top: calc(56px + var(--safe-t) + var(--vvt, 0px)); height: calc(var(--vvh, 100dvh) - 56px - var(--safe-t)); }
347
351
  body.chat-mode.kb .composer { padding-bottom: 10px; }
348
- .composer textarea, input[type="text"], input[type="url"], input[type="search"], select, textarea.field { font-size: 16px; }
352
+ .composer textarea, input[type="text"], input[type="password"], input[type="url"], input[type="search"], select, textarea.field { font-size: 16px; }
349
353
  .chat-head { display: none; }
350
354
  .mode-bar { padding: 10px 16px; }
351
355
  .feed { padding: 18px 16px 24px; }
@@ -449,7 +453,8 @@ video.scan { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 14p
449
453
  .burger { display: none; }
450
454
  .tabbar { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; padding: 6px 8px calc(6px + var(--safe-b)); background: color-mix(in srgb, var(--bg) 94%, transparent); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); border-top: 1px solid var(--border); }
451
455
  .tabbar > * { flex: 1; position: relative; display: grid; justify-items: center; gap: 2px; padding: 6px 0; border: 0; background: none; border-radius: 10px; font-size: 11px; font-weight: 600; color: var(--fg-muted); text-decoration: none; }
452
- .tabbar [aria-current="page"] { color: var(--fg); }
456
+ .tabbar [aria-current="page"] { color: var(--accent); }
457
+ .tabbar [aria-current="page"]::before { content: ""; position: absolute; top: 0; left: 50%; width: 22px; height: 3px; margin-left: -11px; border-radius: 0 0 3px 3px; background: var(--accent); }
453
458
  .tabbar .badge { position: absolute; top: 2px; left: calc(50% + 6px); min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff; font-size: 10px; line-height: 16px; text-align: center; }
454
459
  body:not(.no-tabs):not(.kb) .main { padding-bottom: calc(64px + var(--safe-b)); }
455
460
  body.no-tabs .tabbar, body.kb .tabbar { display: none; }
@@ -480,3 +485,13 @@ a.kbd { text-decoration: none; color: inherit; }
480
485
  .slash .t { font-size: 11px; padding: 1px 7px; border-radius: 6px; background: var(--bg-2); color: var(--fg-muted); }
481
486
  .slash .t.ag { background: color-mix(in srgb, var(--lemon) 60%, var(--bg)); color: var(--fg); }
482
487
  @media (max-width: 820px) { .slash { left: 12px; right: 12px; } .slash .h { grid-column: 1 / -1; grid-row: 2; } }
488
+ /* ---------- Veu ---------- */
489
+ .ev-error b { font-size: 12px; letter-spacing: .02em; text-transform: uppercase; }
490
+ .speak { display: inline-flex; align-items: center; gap: 6px; margin-top: 8px; height: 28px; padding: 0 10px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--fg-muted); font-size: 12px; font-weight: 600; }
491
+ .speak:hover { color: var(--fg); border-color: var(--fg); }
492
+ .speak .ico { width: 15px; height: 15px; }
493
+ .speak.wait { opacity: .6; } .speak.on { color: var(--accent); border-color: var(--accent); }
494
+ .composer .mic { flex: none; display: grid; place-items: center; width: 46px; height: 46px; border: 1px solid var(--border); border-radius: 14px; background: var(--white); color: var(--fg-muted); }
495
+ .composer .mic:hover { color: var(--fg); border-color: var(--fg); }
496
+ .composer .mic.rec { background: var(--red); border-color: var(--red); color: #fff; animation: pulse 1.1s ease-in-out infinite; }
497
+ .composer .mic.wait { opacity: .5; }
package/web/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
2
- const SHELL = 'nio-shell-v3';
2
+ const SHELL = 'nio-shell-v5';
3
3
  const FILES = ['/app', '/app/style.css', '/app/app.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
4
4
 
5
5
  self.addEventListener('install', (e) => {