nio-cat 0.9.9 → 0.9.11

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.9.9",
3
+ "version": "0.9.11",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "f.cat",
@@ -20,6 +20,8 @@ module.exports = {
20
20
  // Conversa del terminal: el primer missatge en fa una branca; després ja continua la pròpia.
21
21
  if (session.nativeId && session.forkNative) { args.push('--fork-session'); delete session.forkNative; }
22
22
  if (session.model) args.push('--model', session.model);
23
+ // Que l'agent posi un títol que resumeixi la feina (eina MCP de nio; el hub el desa).
24
+ args.push('--append-system-prompt', 'Treballes dins una sessió de nio. Quan entenguis la tasca, crida l’eina mcp__nio__sessio_titol amb un títol de 3-7 paraules que la resumeixi, en l’idioma de l’usuari; torna-la a cridar només si la feina canvia de tema.');
23
25
 
24
26
  if (session.mode === 'tot' && process.getuid?.() === 0) {
25
27
  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.');
@@ -70,11 +70,11 @@ async function pull(name, onProgress, signal) {
70
70
  }
71
71
 
72
72
  // Una resposta d'un sol cop (sense flux), per exemple per a la pissarra.
73
- async function chat({ model, messages, format, think, maxTokens, signal }) {
73
+ async function chat({ model, messages, format, think, maxTokens, numCtx, signal }) {
74
74
  const res = await fetch(`${base()}/api/chat`, {
75
75
  method: 'POST',
76
76
  headers: { 'content-type': 'application/json' },
77
- body: JSON.stringify({ model, messages, stream: false, ...(format ? { format } : {}), ...(think === false ? { think: false } : {}), options: { temperature: 0.2, ...(maxTokens ? { num_predict: maxTokens } : {}) } }),
77
+ body: JSON.stringify({ model, messages, stream: false, ...(format ? { format } : {}), ...(think === false ? { think: false } : {}), options: { temperature: 0.2, ...(maxTokens ? { num_predict: maxTokens } : {}), ...(numCtx ? { num_ctx: numCtx } : {}) } }),
78
78
  ...(signal ? { signal } : {}),
79
79
  });
80
80
  if (!res.ok) throw new Error(`Ollama ha tornat un error ${res.status}: ${await res.text()}`);
package/src/call.js CHANGED
@@ -65,7 +65,7 @@ class Call {
65
65
  if (!r.ok) {
66
66
  const d = j?.detail;
67
67
  const msg = (typeof d === 'string' ? d : d?.message || (Array.isArray(d) ? d.map((x) => x.msg).join('; ') : '') || j?.message || t || `Error ${r.status}`);
68
- throw Object.assign(httpErr(r.status === 401 ? 400 : 502, `ElevenLabs: ${clip(msg, 220)}`), { upstream: r.status });
68
+ throw Object.assign(httpErr(r.status === 401 ? 400 : 502, require('./voice').elPermHint(msg) || `ElevenLabs: ${clip(msg, 220)}`), { upstream: r.status });
69
69
  }
70
70
  return j;
71
71
  }
@@ -6,6 +6,7 @@
6
6
  const READ_ONLY = new Set([
7
7
  'Read', 'Glob', 'Grep', 'LS', 'NotebookRead', 'WebSearch', 'WebFetch',
8
8
  'TodoWrite', 'TodoRead', 'Task', 'Agent', 'ToolSearch', 'Skill', 'BashOutput',
9
+ 'mcp__nio__sessio_titol',
9
10
  ]);
10
11
 
11
12
  function reply(decision, reason) {
package/src/hub.js CHANGED
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const crypto = require('crypto');
6
6
  const { EventEmitter } = require('events');
7
7
  const { byId, binPath } = require('./agents');
8
+ const TITLE_TOOL = /(^|[._])sessio_titol$/;
8
9
  const { HOME, save } = require('./config');
9
10
  const push = require('./push');
10
11
  const usage = require('./usage');
@@ -206,7 +207,7 @@ class Hub extends EventEmitter {
206
207
  const s = this.get(sid);
207
208
  if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
208
209
  if (patch.mode && ['llegir', 'preguntar', 'tot'].includes(patch.mode)) s.mode = patch.mode;
209
- if (typeof patch.title === 'string') s.title = patch.title.slice(0, 120);
210
+ if (typeof patch.title === 'string') { s.title = patch.title.slice(0, 120); s.titleBy = 'user'; } // un nom posat a mà no el canvia l'agent
210
211
  if (typeof patch.model === 'string') s.model = patch.model.slice(0, 80);
211
212
  if (typeof patch.cwd === 'string' && path.resolve(patch.cwd) !== s.cwd) {
212
213
  const cwd = path.resolve(patch.cwd);
@@ -285,6 +286,12 @@ class Hub extends EventEmitter {
285
286
  stream: (t) => this.chunk(s, String(t)),
286
287
  tool: ({ id: tid, name, input }) => {
287
288
  toolNames.set(tid, name);
289
+ // L'agent posa títol a la sessió amb l'eina MCP sessio_titol (Claude: mcp__nio__sessio_titol, Codex: nio.sessio_titol).
290
+ if (TITLE_TOOL.test(name || '') && typeof input === 'string') try { input = JSON.parse(input); } catch { input = {}; }
291
+ if (TITLE_TOOL.test(name || '') && s.titleBy !== 'user') {
292
+ const t = String(input?.titol || '').replace(/\s+/g, ' ').trim().replace(/[.。]$/, '').slice(0, 80);
293
+ if (t && t !== s.title) { s.title = t; s.titleBy = 'agent'; this.touch(s); }
294
+ }
288
295
  const inp = summarizeInput(input);
289
296
  this.push(s, 'tool', { id: tid, name, input: inp });
290
297
  this.audit.add({ type: 'tool', session: s.id, agent: s.agent, title: s.title, cwd: s.cwd, tool: name, what: describe(name, inp), mode: s.mode });
package/src/mcp.js CHANGED
@@ -50,6 +50,14 @@ const TOOLS = [
50
50
  return `Desat a ${r.path} (${r.size} B).`;
51
51
  },
52
52
  },
53
+ {
54
+ name: 'sessio_titol',
55
+ description: 'Posa títol a la sessió de nio on treballes: 3-7 paraules que resumeixin la feina (p. ex. «Mode fosc a la botiga»), perquè l’usuari la reconegui a la llista. Crida-la quan entenguis la tasca i torna-la a cridar si la feina canvia de tema. Fora de nio no fa res.',
56
+ inputSchema: { type: 'object', properties: { titol: { type: 'string', description: 'Títol curt, sense punt final' } }, required: ['titol'], additionalProperties: false },
57
+ annotations: { readOnlyHint: true },
58
+ // El títol el posa nio quan veu passar aquesta crida (hub.js); aquí només es confirma.
59
+ run: ({ titol }) => (String(titol || '').trim() ? `Títol: ${String(titol).trim().slice(0, 80)}` : 'Falta el títol.'),
60
+ },
53
61
  ];
54
62
 
55
63
  function serve({ input = process.stdin, output = process.stdout } = {}) {
@@ -64,7 +72,7 @@ function serve({ input = process.stdin, output = process.stdout } = {}) {
64
72
  protocolVersion: params?.protocolVersion || '2025-06-18',
65
73
  capabilities: { tools: {} },
66
74
  serverInfo: { name: 'nio', version: require('../package.json').version },
67
- instructions: 'Memòria compartida de nio (fitxers markdown en aquest ordinador). Consulta-la a l’inici d’una tasca (memoria_cerca o memoria_llegeix preferencies.md) i desa-hi el que valgui la pena recordar.',
75
+ instructions: 'Memòria compartida de nio (fitxers markdown en aquest ordinador). Consulta-la a l’inici d’una tasca (memoria_cerca o memoria_llegeix preferencies.md) i desa-hi el que valgui la pena recordar. Si treballes dins una sessió de nio, posa-li un títol curt amb sessio_titol.',
68
76
  });
69
77
  case 'ping':
70
78
  return reply({});
package/src/notes.js CHANGED
@@ -49,6 +49,16 @@ class Notes {
49
49
  try { this.notes = JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { /* encara no n'hi ha */ }
50
50
  const old = Date.now() - TRASH_DAYS * 864e5;
51
51
  this.notes = this.notes.filter((n) => !n.trashed || n.trashed > old);
52
+ this.queues = new Map(); // cues per nota: «t:<id>» transcripció en ordre, «s:<id>» apunts i resum
53
+ this.parting = new Map();
54
+ // Si nio es va aturar enmig d'una classe: es reprèn el que quedava a mitges.
55
+ setImmediate(() => {
56
+ for (const n of this.notes) {
57
+ if (!n.live) continue;
58
+ for (const sg of n.live.segs) if (sg.state === 'transcribing') this.queue(`t:${n.id}`, () => this.liveTranscribe(n, sg));
59
+ if (n.live.state === 'closing' || n.live.state === 'summarizing') this.liveFinish(n);
60
+ }
61
+ });
52
62
  }
53
63
 
54
64
  save() {
@@ -85,6 +95,7 @@ class Notes {
85
95
  const n = this.get(id);
86
96
  if (!n.trashed && !forever) return this.patch(id, { trashed: true });
87
97
  for (const a of n.audio) fs.rm(this.audioFile(n, a), { force: true }, () => {});
98
+ for (const sg of n.live?.segs || []) fs.rm(this.liveFile(n, sg), { force: true }, () => {});
88
99
  this.notes = this.notes.filter((x) => x !== n);
89
100
  this.save();
90
101
  this.hub.emit('out', { type: 'note-gone', id });
@@ -136,6 +147,164 @@ class Notes {
136
147
  if (this.notes.includes(n)) { n.updated = Date.now(); this.save(); this.emit(n); }
137
148
  }
138
149
 
150
+ // ---------- Classe o reunió en directe
151
+ // El mòbil grava trossos de ~20 s i els envia en ordre. Cada tros es transcriu (en ordre, un darrere l'altre);
152
+ // cada ~4-5 minuts de veu, un model en fa apunts mentre la gravació continua; en acabar, el resum de tot.
153
+ queue(key, fn) {
154
+ const p = (this.queues.get(key) || Promise.resolve()).then(fn).catch(() => {});
155
+ this.queues.set(key, p);
156
+ return p;
157
+ }
158
+ liveFile(n, sg) { return path.join(AUDIO, `${n.id}-live-${sg.seq}.${extOf(sg.mime)}`); }
159
+
160
+ liveStart(b = {}) {
161
+ const kind = LIVE_KINDS[b.kind] ? b.kind : 'classe';
162
+ const when = new Date().toLocaleString('ca-ES', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
163
+ const title = `${LIVE_KINDS[kind]} · ${when}`;
164
+ const n = this.create({ title, labels: [kind === 'classe' ? 'classe' : 'reunió'] });
165
+ n.live = { kind, engine: str(b.engine, 80), lang: LIVE_LANGS.includes(b.lang) ? b.lang : 'ca', state: 'rec', started: Date.now(), ended: 0, segs: [], parts: [], error: '', autoTitle: title };
166
+ this.save(); this.emit(n);
167
+ return n;
168
+ }
169
+
170
+ liveResume(id) {
171
+ const n = this.get(id), L = n.live;
172
+ if (!L) throw httpErr(400, 'Aquesta nota no és d’una classe ni d’una reunió');
173
+ if (L.state === 'closing' || L.state === 'summarizing') throw httpErr(409, 'Ara mateix l’estic resumint. Espera que acabi.');
174
+ L.state = 'rec'; L.error = ''; n.updated = Date.now();
175
+ this.save(); this.emit(n);
176
+ return n;
177
+ }
178
+
179
+ liveSeg(id, buf, mime, q) {
180
+ const n = this.get(id), L = n.live;
181
+ if (!L) throw httpErr(400, 'Aquesta nota no és d’una classe ni d’una reunió');
182
+ const seq = Math.max(0, Math.floor(Number(q.seq) || 0));
183
+ if (L.segs.some((x) => x.seq === seq)) return { ok: true, seq, dup: true }; // el mòbil l'ha tornat a enviar
184
+ if (!buf?.length) throw httpErr(400, 'El tros d’àudio és buit');
185
+ if (L.segs.length >= 2000) throw httpErr(413, 'Aquesta gravació ja és massa llarga');
186
+ const sg = { seq, at: Math.max(0, Number(q.at) || 0), dur: Math.max(0, Number(q.dur) || 0), mime, state: 'transcribing', text: '', error: '' };
187
+ fs.mkdirSync(AUDIO, { recursive: true, mode: 0o700 });
188
+ fs.writeFileSync(this.liveFile(n, sg), buf);
189
+ L.segs.push(sg); L.segs.sort((a, b) => a.seq - b.seq);
190
+ n.updated = Date.now(); this.save(); this.emit(n);
191
+ this.queue(`t:${n.id}`, () => this.liveTranscribe(n, sg));
192
+ return { ok: true, seq };
193
+ }
194
+
195
+ async liveTranscribe(n, sg) {
196
+ if (!this.notes.includes(n)) return;
197
+ try {
198
+ const { text } = await voice.transcribe(this.cfg, fs.readFileSync(this.liveFile(n, sg)), sg.mime, n.live.lang);
199
+ const t = String(text || '').replace(/\s+/g, ' ').trim();
200
+ sg.text = HALLUCINATION.test(t) ? '' : t; sg.state = 'ok'; sg.error = '';
201
+ } catch (e) {
202
+ sg.state = 'error'; sg.error = String(e.message || e).slice(0, 300);
203
+ }
204
+ if (!this.notes.includes(n)) return;
205
+ n.updated = Date.now(); this.save(); this.emit(n);
206
+ this.liveParts(n);
207
+ }
208
+
209
+ // Apunts dels trossos ja transcrits, en ordre. Amb «final», també el tros que queda encara que sigui curt.
210
+ liveParts(n, final = false) {
211
+ if (this.parting.has(n.id)) return final ? this.parting.get(n.id).then(() => this.liveParts(n, true)) : this.parting.get(n.id);
212
+ const run = (async () => {
213
+ for (;;) {
214
+ const L = n.live, last = L.parts.length ? L.parts[L.parts.length - 1].to : -1, ready = [];
215
+ for (const sg of L.segs) { if (sg.seq <= last) continue; if (sg.state === 'transcribing') break; ready.push(sg); }
216
+ const text = ready.map((x) => x.text).filter(Boolean).join(' ');
217
+ if (!ready.length || (!final && text.length < PART_CHARS) || (final && ready.some((x) => x.state === 'transcribing'))) return;
218
+ const pick = await this.pickEngine(L.engine);
219
+ if (!pick) return; // sense model: al final es deixa la transcripció
220
+ const part = { from: ready[0].seq, to: ready[ready.length - 1].seq, text: '', error: '' };
221
+ if (text.trim().length > 40) {
222
+ try { part.text = (await this.ask(pick, LIVE_TASKS.part[L.kind], text, false)).text.trim(); }
223
+ catch (e) { part.error = String(e.message || e).slice(0, 200); }
224
+ }
225
+ if (!this.notes.includes(n)) return;
226
+ L.parts.push(part); n.updated = Date.now(); this.save(); this.emit(n);
227
+ }
228
+ })().finally(() => this.parting.delete(n.id));
229
+ this.parting.set(n.id, run);
230
+ return run;
231
+ }
232
+
233
+ liveStop(id) {
234
+ const n = this.get(id), L = n.live;
235
+ if (!L) throw httpErr(400, 'Aquesta nota no és d’una classe ni d’una reunió');
236
+ if (L.state === 'rec' || L.state === 'error') { L.state = 'closing'; L.ended = Date.now(); n.updated = Date.now(); this.save(); this.emit(n); this.liveFinish(n); }
237
+ return n;
238
+ }
239
+
240
+ liveSummary(id, b = {}) {
241
+ const n = this.get(id), L = n.live;
242
+ if (!L) throw httpErr(400, 'Aquesta nota no és d’una classe ni d’una reunió');
243
+ if (L.state === 'rec') throw httpErr(409, 'Primer atura la gravació');
244
+ if (L.state === 'closing' || L.state === 'summarizing') return n;
245
+ if (b.engine) L.engine = str(b.engine, 80);
246
+ L.parts = L.parts.filter((p) => !p.error); // els apunts que havien fallat es tornen a fer
247
+ L.state = 'closing'; this.save(); this.emit(n);
248
+ this.liveFinish(n);
249
+ return n;
250
+ }
251
+
252
+ // Espera la transcripció i els apunts pendents i en fa el resum final.
253
+ liveFinish(n) {
254
+ return this.queue(`s:${n.id}`, async () => {
255
+ const L = n.live;
256
+ await (this.queues.get(`t:${n.id}`) || Promise.resolve());
257
+ if (!this.notes.includes(n)) return;
258
+ L.state = 'summarizing'; L.error = ''; this.emit(n);
259
+ try {
260
+ await this.liveParts(n, true);
261
+ const transcript = L.segs.map((x) => x.text).filter(Boolean).join(' ').trim();
262
+ if (!transcript) throw new Error('No s’ha transcrit res: no hi ha res a resumir.');
263
+ const pick = await this.pickEngine(L.engine);
264
+ if (!pick) {
265
+ if (!n.text.trim()) n.text = transcript;
266
+ throw new Error('No hi ha cap model per resumir. He deixat la transcripció a la nota. Instal·la Ollama amb un model (ollama pull qwen3:4b) o Claude Code.');
267
+ }
268
+ const max = pick.id.startsWith('ollama:') ? 9000 : 60000; // els models locals petits tenen poc context
269
+ const rangeText = (p) => L.segs.filter((x) => x.seq >= p.from && x.seq <= p.to).map((x) => x.text).filter(Boolean).join(' ');
270
+ let blocks = transcript.length <= max ? [transcript] : L.parts.map((p) => p.text || rangeText(p)).filter(Boolean);
271
+ // Massa llarg per a un sol cop: es condensen els apunts per grups fins que hi caben.
272
+ while (blocks.join('\n\n').length > max && blocks.length > 1) {
273
+ const groups = [];
274
+ for (const b of blocks) { const g = groups[groups.length - 1]; if (g && g.join('\n\n').length + b.length < max * 0.8) g.push(b); else groups.push([b]); }
275
+ if (groups.length === blocks.length) groups.splice(0, groups.length, ...Array.from({ length: Math.ceil(blocks.length / 2) }, (_, i) => blocks.slice(i * 2, i * 2 + 2)));
276
+ blocks = [];
277
+ for (const g of groups) blocks.push((await this.ask(pick, LIVE_TASKS.condense, g.join('\n\n'), false)).text.trim());
278
+ }
279
+ const r = await this.ask(pick, LIVE_TASKS.final[L.kind], blocks.join('\n\n').slice(0, max), true);
280
+ const text = str(r.text, 200000).trim();
281
+ if (!text) throw new Error('El model no ha tornat cap resum. Torna-ho a provar o tria’n un altre.');
282
+ if (!this.notes.includes(n)) return;
283
+ const title = str(r.title, 120).trim();
284
+ if (title && n.title === L.autoTitle) { n.title = `${title}`; L.autoTitle = n.title; }
285
+ n.text = text; L.state = 'done'; L.summaryEngine = pick.id;
286
+ } catch (e) {
287
+ if (!this.notes.includes(n)) return;
288
+ L.state = 'error'; L.error = String(e.message || e).slice(0, 300);
289
+ }
290
+ n.updated = Date.now(); this.save(); this.emit(n);
291
+ });
292
+ }
293
+
294
+ async pickEngine(engine) {
295
+ const list = await this.engines();
296
+ // Per defecte: el model local més petit de 3 mil milions de paràmetres o més; si no, Claude ràpid; si no, el primer.
297
+ const auto = list.filter((e) => e.local && e.id.startsWith('ollama:') && e.params >= 3)[0] || list.find((e) => e.id === 'claude:haiku') || list[0];
298
+ return list.find((e) => e.id === engine) || auto || null;
299
+ }
300
+
301
+ async ask(pick, task, body, json) {
302
+ const local = pick.id.startsWith('ollama:'), wrap = !json && local;
303
+ const user = `--- TRANSCRIPCIÓ ---\n${body}\n--- FI ---\n\n${task}${wrap ? '\n\nTorna només JSON amb aquest format: {"text":"…"}. El text pot tenir salts de línia.' : ''}`;
304
+ const raw = await runModel(this.cfg, pick.id, LIVE_SYSTEM, user, json || wrap, { numCtx: local ? 8192 : 0 });
305
+ return json ? parseJson(raw) : wrap ? { text: String(parseJson(raw).text ?? '') } : { text: raw };
306
+ }
307
+
139
308
  // ---------- Motors per resumir i organitzar
140
309
  async engines() {
141
310
  const { binPath } = require('./agents');
@@ -179,6 +348,9 @@ class Notes {
179
348
  return [
180
349
  R('GET', '/api/notes', () => ({ notes: this.notes })),
181
350
  R('POST', '/api/notes', ({ body }) => this.create(body), { limit: 1024 * 1024 }),
351
+ R('POST', '/api/notes/live', ({ body }) => this.liveStart(body || {})),
352
+ R('POST', /^\/api\/notes\/([\w-]+)\/live\/seg$/, async ({ req, params, url }) => this.liveSeg(params[0], await readRaw(req), String(req.headers['content-type'] || 'audio/webm').split(';')[0], Object.fromEntries(url.searchParams)), { raw: true }),
353
+ R('POST', /^\/api\/notes\/([\w-]+)\/live\/(stop|resume|summary)$/, ({ params, body }) => (params[1] === 'stop' ? this.liveStop(params[0]) : params[1] === 'resume' ? this.liveResume(params[0]) : this.liveSummary(params[0], body || {}))),
182
354
  R('GET', '/api/notes/engines', () => this.engines().then((engines) => ({ engines }))),
183
355
  R('POST', '/api/notes/ai', ({ body }) => this.ai(body || {})),
184
356
  R('PATCH', /^\/api\/notes\/([\w-]+)$/, ({ params, body }) => this.patch(params[0], body), { limit: 1024 * 1024 }),
@@ -241,6 +413,25 @@ const ACTIONS = {
241
413
  },
242
414
  };
243
415
 
416
+ // ---------- Classe o reunió
417
+ const LIVE_KINDS = { classe: 'Classe', reunio: 'Reunió' };
418
+ const LIVE_LANGS = ['ca', 'es', 'en'];
419
+ const PART_CHARS = 3500; // ≈ 4-5 minuts de veu per tros d'apunts
420
+ // Whisper s'inventa aquestes frases quan sent silenci o soroll.
421
+ const HALLUCINATION = /^[\s.…]*$|^(gràcies( per (veure|mirar|escoltar)[^.!]*)?|subtítols[^.!]*|amara\.org[^.!]*|gracias( por (ver|mirar)[^.!]*)?|subtítulos[^.!]*|thank you( for watching)?|thanks for watching)[.!…\s]*$/i;
422
+ const LIVE_SYSTEM = 'Ets l’assistent de notes de nio. Treballes amb la transcripció automàtica d’una classe o d’una reunió: pot tenir paraules mal transcrites, que pots corregir quan el context ho deixa clar. No t’inventis res que no s’hi digui. Respon en l’idioma de la transcripció (si dubtes, en català). No facis servir cap eina.';
423
+ const LIVE_TASKS = {
424
+ part: {
425
+ classe: 'Escriu els apunts d’aquest tros de classe: de 3 a 8 punts curts amb els conceptes, definicions, exemples, fórmules i dades que s’hi expliquen. Cada punt comença amb «- ». Només els punts, sense introducció.',
426
+ reunio: 'Escriu les notes d’aquest tros de reunió: de 3 a 8 punts curts amb els temes tractats, les decisions i les tasques (amb qui se’n fa càrrec, si es diu). Cada punt comença amb «- ». Només els punts, sense introducció.',
427
+ },
428
+ condense: 'Són apunts seguits d’una mateixa classe o reunió. Fes-ne uns de sols, sense repeticions i amb tot el que és important: com a màxim 12 punts. Cada punt comença amb «- ». Només els punts.',
429
+ final: {
430
+ classe: 'Fes el resum de tota la classe. Torna només JSON amb aquest format: {"title":"…","text":"…"}. «title»: el tema de la classe, en menys de 8 paraules. «text»: Markdown amb aquests apartats: «## Resum» (un paràgraf de 4 a 6 frases), «## Conceptes clau» (llista «- **concepte**: explicació curta»), «## Punts importants» (llista), «## Per repassar» (de 3 a 5 preguntes que podrien sortir a l’examen) i, només si se’n parla, «## Deures i dates».',
431
+ reunio: 'Fes el resum de tota la reunió. Torna només JSON amb aquest format: {"title":"…","text":"…"}. «title»: el tema principal, en menys de 8 paraules. «text»: Markdown amb aquests apartats: «## Resum» (de 3 a 5 frases), «## Decisions» (llista), «## Tasques» (llista «- [ ] qui: què»; si no es diu qui, només què) i «## Temes oberts» (llista). Deixa fora els apartats que quedarien buits.',
432
+ },
433
+ };
434
+
244
435
  function parseJson(raw) {
245
436
  const t = String(raw || '').replace(/^```(?:json)?\s*|\s*```$/g, '').trim();
246
437
  try { return JSON.parse(t); } catch { /* potser hi ha text al voltant */ }
@@ -264,14 +455,14 @@ function runCli(bin, args, input, { cwd, timeout = 180000 } = {}) {
264
455
  });
265
456
  }
266
457
 
267
- async function runModel(cfg, engine, system, user, json) {
458
+ async function runModel(cfg, engine, system, user, json, { numCtx = 0 } = {}) {
268
459
  const { binPath } = require('./agents');
269
460
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nio-nota-'));
270
461
  try {
271
462
  if (engine.startsWith('ollama:')) {
272
463
  // Qwen3 «pensa» abans de respondre (minuts en un model petit): «/no_think» ho desactiva. Si la resposta porta un bloc <think>, es treu.
273
464
  const model = engine.slice(7), soft = /qwen3/i.test(model) ? '\n\n/no_think' : '';
274
- const out = await ollama.chat({ model, messages: [{ role: 'system', content: system }, { role: 'user', content: user + soft }], format: json ? 'json' : undefined, think: false, maxTokens: 3000, signal: AbortSignal.timeout(150000) }).catch((e) => { throw e.name === 'TimeoutError' ? httpErr(504, 'El model ha trigat massa. Prova-ho amb un de més petit o ràpid.') : e; });
465
+ const out = await ollama.chat({ model, messages: [{ role: 'system', content: system }, { role: 'user', content: user + soft }], format: json ? 'json' : undefined, think: false, maxTokens: 3000, numCtx: numCtx || undefined, signal: AbortSignal.timeout(150000) }).catch((e) => { throw e.name === 'TimeoutError' ? httpErr(504, 'El model ha trigat massa. Prova-ho amb un de més petit o ràpid.') : e; });
275
466
  return out.replace(/^[\s\S]*?<\/think>\s*/i, '').trim();
276
467
  }
277
468
  if (engine === 'claude' || engine.startsWith('claude:')) {
package/src/voice.js CHANGED
@@ -58,11 +58,21 @@ const PROVIDERS = {
58
58
  function ext(mime) {
59
59
  return /ogg/.test(mime || '') ? 'ogg' : /mp4|m4a|aac/.test(mime || '') ? 'm4a' : /mpeg|mp3/.test(mime || '') ? 'mp3' : /wav/.test(mime || '') ? 'wav' : 'webm';
60
60
  }
61
+ // Una clau restringida a la qual falta un permís: es diu quin interruptor cal activar a ElevenLabs.
62
+ const EL_PERMS = { text_to_speech: 'Text to Speech → Access', speech_to_text: 'Speech to Text → Access', convai_write: 'ElevenAgents → Write', convai_read: 'ElevenAgents → Write' };
63
+ function elPermHint(msg) {
64
+ const m = String(msg || '').match(/missing the permission (\w+)/i);
65
+ if (!m) return null;
66
+ const what = EL_PERMS[m[1]] || m[1];
67
+ return `A la clau d’ElevenLabs li falta el permís «${what}». Edita-la a elevenlabs.io (Developers → API Keys) i activa’l, o crea’n una de nova amb aquest permís.`;
68
+ }
69
+
61
70
  async function errText(r) {
62
71
  const t = await r.text().catch(() => '');
63
72
  try {
64
73
  const j = JSON.parse(t);
65
- return (j.detail?.message || j.detail || j.error?.message || j.error || t).toString().slice(0, 200);
74
+ const msg = (j.detail?.message || j.detail || j.error?.message || j.error || t).toString();
75
+ return elPermHint(msg) || msg.slice(0, 200);
66
76
  } catch {
67
77
  return `${r.status} ${t.slice(0, 160)}`;
68
78
  }
@@ -93,12 +103,14 @@ function status(cfg) {
93
103
  };
94
104
  }
95
105
 
96
- async function transcribe(cfg, audio, mime) {
106
+ async function transcribe(cfg, audio, mime, lang) {
97
107
  const p = pick(cfg, 'stt');
108
+ lang = lang || cfgOf(cfg).lang || 'ca';
109
+ const l = lang === 'auto' ? '' : lang; // «auto»: que el proveïdor ho endevini
98
110
  if (!p) throw httpErr(400, 'Aquest ordinador encara no té veu. Activa Whisper (gratis) o posa una clau a Ajustos → Veu.');
99
111
  if (!audio?.length) throw httpErr(400, 'No hi ha àudio');
100
- if (p === 'whisper') return { text: await whisper.transcribe(cfg, audio, mime, cfgOf(cfg).lang || 'ca'), provider: p };
101
- return { text: (await PROVIDERS[p].stt(cfg.voice.keys[p], audio, mime, cfgOf(cfg).lang || 'ca')).trim(), provider: p };
112
+ if (p === 'whisper') return { text: await whisper.transcribe(cfg, audio, mime, lang), provider: p };
113
+ return { text: (await PROVIDERS[p].stt(cfg.voice.keys[p], audio, mime, l)).trim(), provider: p };
102
114
  }
103
115
 
104
116
  async function speak(cfg, text) {
@@ -179,4 +191,4 @@ function routes(cfg) {
179
191
  ];
180
192
  }
181
193
 
182
- module.exports = { routes, transcribe, speak, status, pick, PROVIDERS };
194
+ module.exports = { routes, transcribe, speak, status, pick, PROVIDERS, elPermHint };
package/src/whisper.js CHANGED
@@ -128,7 +128,7 @@ async function transcribe(cfg, audio, mime, lang = 'ca') {
128
128
  const input = path.join(tmp, `audio.${ext(mime)}`);
129
129
  fs.writeFileSync(input, audio);
130
130
  if (e === 'python') {
131
- await run(pyBin(), [input, '--model', 'turbo', '--language', lang, '--output_format', 'txt', '--output_dir', tmp, '--fp16', 'False', '--verbose', 'False']);
131
+ await run(pyBin(), [input, '--model', 'turbo', ...(lang === 'auto' ? [] : ['--language', lang]), '--output_format', 'txt', '--output_dir', tmp, '--fp16', 'False', '--verbose', 'False']);
132
132
  return fs.readFileSync(path.join(tmp, 'audio.txt'), 'utf8').trim();
133
133
  }
134
134
  if (!fs.existsSync(modelPath(cfg))) {
package/web/app/app.js CHANGED
@@ -206,21 +206,24 @@ async function api(path, opts = {}) {
206
206
  const body = opts.body && typeof opts.body !== 'string' ? opts.body : opts.body ? JSON.parse(opts.body) : undefined;
207
207
  let res;
208
208
  try { res = await fetch(m.url + path, { ...opts, headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json', ...(opts.headers || {}) }, body: body ? JSON.stringify(body) : undefined }); }
209
- catch (e) { if (e.name === 'AbortError') throw e; throw new Error(`No he pogut parlar amb ${m.name || 'l’ordinador'}. Comprova que nio hi estigui engegat i torna-ho a provar.`); }
210
- const data = await res.json().catch(() => ({}));
209
+ catch (e) { if (e.name === 'AbortError') throw e; throw Object.assign(new Error(`No he pogut parlar amb ${m.name || 'l’ordinador'}. Comprova que nio hi estigui engegat i torna-ho a provar.`), { unreachable: true }); }
210
+ const data = await res.json().catch(() => null);
211
+ // Un túnel mort respon una pàgina d'error de Cloudflare (502, 530…), no el JSON de nio: és com no arribar-hi.
212
+ if (!data && res.status >= 500) throw Object.assign(new Error(`No arribo a ${m.name || 'l’ordinador'}`), { unreachable: true });
213
+ if (!data) { if (!res.ok) throw new Error(`Error ${res.status}`); return {}; }
211
214
  if (!res.ok) throw new Error(data.error || `Error ${res.status}`);
212
215
  if (Array.isArray(data.agents)) data.agents = data.agents.filter((x) => x.id !== 'demo'); // l'agent de proves del programa no s'ensenya
213
216
  return data;
214
217
  }
215
218
 
216
- let streamCtrl = null, retry = 0, gotHello = false;
219
+ let streamCtrl = null, retry = 0, gotHello = false, alive = 0; // alive: última vegada que ha arribat alguna cosa de l'ordinador
217
220
  async function pollLoop(ctrl) {
218
221
  let first = true;
219
222
  while (!ctrl.signal.aborted) {
220
223
  try {
221
224
  const [list, aps] = await Promise.all([api('/api/sessions'), api('/api/approvals')]);
222
225
  if (ctrl.signal.aborted) return;
223
- state.connected = true; state.lastError = null;
226
+ state.connected = true; state.lastError = null; alive = Date.now();
224
227
  if (first) { first = false; state.synced = true; const r = state.route; state.sessions = new Map(list.map((x) => [x.id, x])); state.approvals = new Map(aps.map((a) => [a.id, a])); if (r.name === 'session') openSession(r.id); else render(); }
225
228
  else {
226
229
  const r = state.route, seen = new Set();
@@ -297,7 +300,7 @@ async function connect() {
297
300
  const res = await fetch(`${m.url}/api/stream?d=${encodeURIComponent(deviceId())}&k=${isPhone() ? 'phone' : 'desktop'}`, { headers: { Authorization: `Bearer ${m.token}` }, signal: ctrl.signal, cache: 'no-store' });
298
301
  if (res.status === 401) throw Object.assign(new Error('La clau no és vàlida. Torna a connectar amb el codi QR.'), { fatal: true });
299
302
  if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
300
- state.connected = true; state.lastError = null; retry = 0;
303
+ state.connected = true; state.lastError = null; retry = 0; alive = Date.now();
301
304
  rememberIdentity(m.url, state.info.identity);
302
305
  renameMachine(state.info.host, state.info.id); syncServiceWorker(); render(); brainsForBoard(); loadVoice().then(() => state.route.name === 'session' && renderComposer?.()); flushQueue();
303
306
  const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '';
@@ -306,6 +309,7 @@ async function connect() {
306
309
  const hw = setTimeout(() => { if (!gotHello) { poll = true; reader.cancel().catch(() => {}); } }, 5000);
307
310
  for (;;) {
308
311
  const { value, done } = await reader.read().catch(() => ({ done: true })); if (done) break;
312
+ alive = Date.now();
309
313
  buf += dec.decode(value, { stream: true }); let i;
310
314
  while ((i = buf.indexOf('\n\n')) >= 0) {
311
315
  const block = buf.slice(0, i); buf = buf.slice(i + 2);
@@ -319,11 +323,13 @@ async function connect() {
319
323
  } catch (e) {
320
324
  if (ctrl.signal.aborted) return;
321
325
  state.connected = false;
322
- state.lastError = e.message === 'Failed to fetch' || e.name === 'TypeError' ? (location.protocol === 'https:' && /^http:\/\/(127\.0\.0\.1|localhost)/.test(m.url) ? 'El navegador no deixa que aquesta web parli amb aquest ordinador. Prova Chrome, o engega nio amb --local.' : 'No arribo a l’ordinador') : e.message;
326
+ const unreachable = e.unreachable || e.message === 'Failed to fetch' || e.name === 'TypeError' || /^Error 5\d\d$/.test(e.message);
327
+ state.lastError = unreachable ? (location.protocol === 'https:' && /^http:\/\/(127\.0\.0\.1|localhost)/.test(m.url) ? 'El navegador no deixa que aquesta web parli amb aquest ordinador. Prova Chrome, o engega nio amb --local.' : 'No arribo a l’ordinador') : e.message;
323
328
  render();
324
329
  if (e.fatal) return;
325
330
  // Potser el túnel ha canviat d'adreça: la busquem a nio.cat (signada per l'ordinador).
326
- if (state.lastError === 'No arribo a l’ordinador' && (await relocate(store.active))) { if (!ctrl.signal.aborted) connect(); return; }
331
+ // (Passa sobretot quan es torna a executar install.sh: el túnel ràpid de Cloudflare canvia d'adreça.)
332
+ if (unreachable && (await relocate(store.active))) { if (!ctrl.signal.aborted) connect(); return; }
327
333
  if (m.kind === 'vps' && m.via && retry >= 2) autoReconnect(m);
328
334
  setTimeout(() => machine() && !ctrl.signal.aborted && connect(), Math.min(15000, 1000 * 2 ** retry++));
329
335
  }
@@ -735,7 +741,7 @@ function viewSettings() {
735
741
  const avisos = machine() ? `<section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}${pushOn ? '<p class="small"><span class="chip ok">' + svg(ICON.check) + 'Avisos activats en aquest dispositiu</span></p>' : ''}<div class="row">${pushOn ? '<button class="btn ink grow" data-act="push-test" type="button">Envia’n un de prova</button><button class="btn" data-act="push-off" type="button">Desactiva</button>' : '<button class="btn ink grow" data-act="push" type="button">Activa els avisos</button>'}</div></div></section>` : '';
736
742
  if (machine() && pushOn === null) refreshPush();
737
743
  if (machine() && tgState === undefined) loadTelegram();
738
- return `${head}<div class="stack-lg" style="max-width:640px">${avisos}${voicePanel()}<div id="tgPanel">${telegramPanel()}</div></div>`;
744
+ return `${head}<div class="stack-lg cols">${avisos}${voicePanel()}<div id="tgPanel">${telegramPanel()}</div></div>`;
739
745
  }
740
746
  // Ajustos sense ordinador: un sol avís a dalt i les seccions de mostra, desactivades.
741
747
  function settingsOff() {
@@ -922,7 +928,7 @@ async function pantallaTab() {
922
928
  });
923
929
  }
924
930
  function moreSheet() {
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>`;
931
+ const item = (k) => `<a class="more-link" href="${HASH[k]}" ${state.route.name === k ? 'aria-current="page"' : ''}>${svg(VIEWS[k].icon)}<span>${VIEWS[k].label}</span>${k === 'approvals' && state.approvals.size ? `<span class="badge">${state.approvals.size}</span>` : ''}</a>`;
926
932
  const groups = [...new Set(Object.values(VIEWS).map((v) => v.group))].map((group) => [group, Object.keys(VIEWS).filter((k) => VIEWS[k].group === group)]);
927
933
  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(); }; });
928
934
  }
@@ -1310,7 +1316,10 @@ const hhmm = (t) => new Date(t * 1000).toLocaleString('ca', { weekday: t * 1000
1310
1316
  function usageChip(agent) {
1311
1317
  const u = state.usage?.[agent]; if (!u?.windows?.length) return '';
1312
1318
  const tip = u.windows.map((w) => `${w.label}: ${Math.round(w.used * 100)} %${w.resetsAt ? ` · es reinicia ${hhmm(w.resetsAt)}` : ''}`).join('\n');
1313
- return `<span class="chip mono usage" title="${esc(`Ús de la subscripció\n${tip}`)}">${svg(CHIP_ICON.usage)}${u.windows.map((w) => `<span class="uw" data-hi="${w.used >= 0.8 ? 1 : 0}">${esc(w.label)} ${Math.round(w.used * 100)}%</span>`).join('')}</span>`;
1319
+ // Etiquetes curtes: «5h», «7d». La icona és un anell que s'omple amb la finestra més plena.
1320
+ const short = (l) => (l === 'setmana' ? '7d' : l.replace(/\s+/g, '')), top = Math.max(...u.windows.map((w) => w.used)), pct = Math.round(top * 100);
1321
+ const ring = `<svg class="ico ring" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5" class="rt"/><circle cx="12" cy="12" r="8.5" pathLength="100" stroke-dasharray="${Math.max(pct, 2)} 100" transform="rotate(-90 12 12)" class="rv"/></svg>`;
1322
+ return `<span class="chip mono usage" data-hi="${top >= 0.8 ? 1 : 0}" title="${esc(`Ús de la subscripció\n${tip}`)}">${ring}${u.windows.map((w) => `<span class="uw" data-hi="${w.used >= 0.8 ? 1 : 0}">${esc(short(w.label))} (${Math.round(w.used * 100)}%)</span>`).join('')}</span>`;
1314
1323
  }
1315
1324
  function renderSessionHead() {
1316
1325
  renderChrome();
@@ -1423,6 +1432,7 @@ function putEvent(e, scroll = true) {
1423
1432
  const feed = $('#feed'); if (!feed) return;
1424
1433
  if (e.kind === 'text' && scroll && (e.open || typers.has(e.seq))) return streamText(state.route.id, e);
1425
1434
  const stick = nearBottom(feed);
1435
+ if (e.kind === 'tool_result' && TITLE_TOOL.test(e.name || '')) return; // el títol ja es veu a dalt i a la línia «Títol»
1426
1436
  if (e.kind === 'tool_result') {
1427
1437
  const t = toolEls.get(e.id);
1428
1438
  if (t) {
@@ -1448,12 +1458,15 @@ function putEvent(e, scroll = true) {
1448
1458
  if (scroll && e.kind === 'user' && pinNext && !old) { pinNext = false; return pinMessage(feed, node); }
1449
1459
  if (scroll) follow(feed, stick);
1450
1460
  }
1461
+ // L'agent posa títol a la sessió amb l'eina sessio_titol de nio (Claude: mcp__nio__sessio_titol, Codex: nio.sessio_titol).
1462
+ const TITLE_TOOL = /(^|[._])sessio_titol$/;
1451
1463
  function eventNode(e) {
1452
1464
  const s = state.sessions.get(state.route.id);
1453
1465
  switch (e.kind) {
1454
1466
  case 'user': return el(`<div class="msg-user">${esc(e.text)}</div>`);
1455
1467
  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>`);
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>`);
1468
+ case 'tool': if (TITLE_TOOL.test(e.name || '')) return el(`<div class="ev-info">Títol: «${esc(e.input?.titol || '')}»</div>`);
1469
+ 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>`);
1457
1470
  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>`);
1458
1471
  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)); }
1459
1472
  case 'error': return el(`<div class="ev-error" role="alert"><b>Error</b><span>${esc(e.text)}</span></div>`);
@@ -2624,7 +2637,20 @@ function voicePanel() {
2624
2637
  const on = [...(v?.whisper?.ready ? ['Whisper'] : []), ...Object.entries(P).filter(([, p]) => p.key).map(([, p]) => p.name)];
2625
2638
  return `<section class="panel"><header><h2>Veu</h2>${on.length ? `<span class="chip ok"><i class="dot on"></i>${esc(on.join(' · '))}</span>` : ''}</header><div class="body stack"><p class="small">Dicta els missatges amb el micròfon i escolta les respostes. Amb <b>Whisper</b> és gratis i l’àudio no surt de l’ordinador (${esc(machine().name)}). Si hi poses una clau d’ElevenLabs o OpenAI, també es queda a l’ordinador.</p>${v ? '' : '<p class="muted small">Aquest ordinador encara no té la veu (cal nio 0.7 o més nou).</p>'}<div class="row"><button class="btn ink" data-act="voice" type="button" ${v ? '' : 'disabled'}>${on.length ? 'Canvia la configuració' : 'Configura la veu'}</button></div></div></section>`;
2626
2639
  }
2627
- async function voiceSheet() {
2640
+ // Pas a pas per fer la clau d'ElevenLabs amb només els permisos que fa servir nio.
2641
+ const EL_GUIDE = `<details class="el-guide" id="elGuide"><summary>Com faig la clau? Pas a pas</summary><ol>
2642
+ <li>Obre <a href="https://elevenlabs.io/app/developers/api-keys" target="_blank" rel="noopener">elevenlabs.io → Developers → API Keys</a> i prem <b>Create API Key</b>.</li>
2643
+ <li><b>Name</b>: «nio». <b>Expire After</b>: «Never» (o la data que vulguis).</li>
2644
+ <li>Activa <b>Restrict Key</b> i, a <b>Endpoints</b>, deixa-ho així:<ul>
2645
+ <li><b>Text to Speech</b> → <span class="el-on">Access</span> · per escoltar les respostes</li>
2646
+ <li><b>ElevenAgents</b> → <span class="el-on">Write</span> · per a la Trucada amb en Nio</li>
2647
+ <li><b>Speech to Text</b> → <span class="el-on">Access</span> · només si vols que ElevenLabs transcrigui en lloc de Whisper</li>
2648
+ <li>Tota la resta, també <b>Administration</b> → <span class="el-off">No Access</span></li></ul></li>
2649
+ <li><b>Usage Limits</b> (opcional): posa un màxim de crèdits, perquè no gasti més del que vols.</li>
2650
+ <li>Deixa activat <b>Auto-disable if leaked</b>.</li>
2651
+ <li>Prem <b>Create</b>, copia la clau (<span class="mono">sk_…</span>; només es veu un cop) i enganxa-la aquí sobre. Es desa a l’ordinador i no torna mai al mòbil.</li>
2652
+ </ol><p class="muted small">Si falta algun permís, nio et dirà quin has d’activar.</p></details>`;
2653
+ async function voiceSheet(opts = {}) {
2628
2654
  await loadVoice();
2629
2655
  if (!nodeVoice) return toast('Aquest ordinador encara no té la veu. Actualitza nio (npx nio-cat@latest).', 5000);
2630
2656
  if (isDemo()) return toast('A la demo no hi ha veu. Amb nio instal·lat, dictes amb Whisper, gratis.', 4500);
@@ -2634,6 +2660,7 @@ async function voiceSheet() {
2634
2660
  <form class="stack" id="voiceForm" style="margin-top:14px">
2635
2661
  <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>
2636
2662
  <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>
2663
+ ${EL_GUIDE}
2637
2664
  <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>
2638
2665
  <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>
2639
2666
  <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>
@@ -2642,6 +2669,7 @@ async function voiceSheet() {
2642
2669
  <button class="btn ghost" type="button" id="vOff">Treu les claus</button></div></details>
2643
2670
  <div class="row sheet-foot"><button class="btn ink" type="submit">Desa</button></div>
2644
2671
  </form>`, (sh) => {
2672
+ if (opts.guide) { const g = $('#elGuide', sh); sh.querySelector('.voice-extras').open = true; g.open = true; setTimeout(() => g.scrollIntoView({ block: 'start', behavior: 'smooth' }), 120); }
2645
2673
  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); } };
2646
2674
  $('#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'); };
2647
2675
  $('#vOff', sh).onclick = () => { if (confirm('Es treuran les claus de veu d’aquest ordinador.')) put({ keys: { elevenlabs: null, openai: null } }, 'Claus eliminades'); };
@@ -3014,7 +3042,19 @@ if ('serviceWorker' in navigator) {
3014
3042
  navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'open-session' && e.data.id) go(`#/s/${e.data.id}`); });
3015
3043
  }
3016
3044
  addEventListener('hashchange', route);
3017
- document.addEventListener('visibilitychange', () => { if (!document.hidden && machine() && !state.connected) connect(); });
3045
+ // El mòbil congela la pàgina en segon pla i el flux queda mort sense cap error: l'ordinador envia un ping cada 15 s,
3046
+ // així que si fa més de 40 s que no arriba res (o en tornar a l'app, més de 20 s), es torna a connectar sol.
3047
+ function revive(maxIdle) {
3048
+ if (!machine() || isDemo(machine()) || document.hidden) return;
3049
+ // Desconnectat, el vigilant no talla els reintents en curs; en tornar a l'app sí que es torna a provar de seguida.
3050
+ if (state.connected ? Date.now() - alive < maxIdle : maxIdle >= 40000) return;
3051
+ retry = 0; reconTry.clear(); connect();
3052
+ }
3053
+ document.addEventListener('visibilitychange', () => revive(20000));
3054
+ addEventListener('pageshow', (e) => { if (e.persisted) revive(0); });
3055
+ addEventListener('online', () => revive(0));
3056
+ addEventListener('focus', () => revive(20000));
3057
+ setInterval(() => revive(40000), 10000);
3018
3058
  setInterval(() => { if (!document.hidden && state.route.name === 'sessions') render(); }, 60000);
3019
3059
  const qs = new URLSearchParams(location.search).get('s');
3020
3060
  if (qs) history.replaceState(null, '', location.pathname + `#/s/${qs}`);
package/web/app/call.js CHANGED
@@ -43,7 +43,7 @@ export function createCall(ctx) {
43
43
  if (!m) return head + ctx.noMachine('La trucada parla amb un ordinador. Connecta’n un per trucar.');
44
44
  const ready = demo || st?.ready, loading = !demo && !st;
45
45
  const why = st?.error ? `<p class="call-why err">${esc(OLD.test(st.error) ? 'Aquest ordinador encara no sap trucar: actualitza nio (cal nio-cat 0.9.9 o més nou).' : st.error)}</p>`
46
- : !ready && !loading ? '<p class="call-why">Per trucar cal una clau d’ElevenLabs. Es queda a l’ordinador i el cost va al teu compte d’ElevenLabs.</p>' : '';
46
+ : !ready && !loading ? '<p class="call-why">Per trucar cal una clau d’ElevenLabs amb el permís «ElevenAgents → Write». Es queda a l’ordinador i el cost va al teu compte d’ElevenLabs. <button class="linkish" type="button" data-call="guide">Com faig la clau?</button></p>' : '';
47
47
  return `${head}
48
48
  <section class="call-hero">
49
49
  <div class="call-face" aria-hidden="true"><span class="call-ring"></span><span class="call-ring r2"></span>${bot('idle', 'call-bot')}</div>
@@ -62,6 +62,7 @@ export function createCall(ctx) {
62
62
  const a = b.dataset.call;
63
63
  if (a === 'start') start();
64
64
  else if (a === 'voice') ctx.voiceSheet();
65
+ else if (a === 'guide') ctx.voiceSheet({ guide: true });
65
66
  });
66
67
 
67
68
  // ---------------------------------------------------------------- pantalla de la trucada