nio-cat 0.9.10 → 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.10",
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",
@@ -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
  }
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,8 +206,11 @@ async function api(path, opts = {}) {
206
206
  const body = opts.body && typeof opts.body !== 'string' ? opts.body : opts.body ? JSON.parse(opts.body) : undefined;
207
207
  let res;
208
208
  try { res = await fetch(m.url + path, { ...opts, headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json', ...(opts.headers || {}) }, body: body ? JSON.stringify(body) : undefined }); }
209
- catch (e) { if (e.name === 'AbortError') throw e; throw new Error(`No he pogut parlar amb ${m.name || 'l’ordinador'}. Comprova que nio hi estigui engegat i torna-ho a provar.`); }
210
- const data = await res.json().catch(() => ({}));
209
+ catch (e) { if (e.name === 'AbortError') throw e; throw Object.assign(new Error(`No he pogut parlar amb ${m.name || 'l’ordinador'}. Comprova que nio hi estigui engegat i torna-ho a provar.`), { unreachable: true }); }
210
+ const data = await res.json().catch(() => null);
211
+ // Un túnel mort respon una pàgina d'error de Cloudflare (502, 530…), no el JSON de nio: és com no arribar-hi.
212
+ if (!data && res.status >= 500) throw Object.assign(new Error(`No arribo a ${m.name || 'l’ordinador'}`), { unreachable: true });
213
+ if (!data) { if (!res.ok) throw new Error(`Error ${res.status}`); return {}; }
211
214
  if (!res.ok) throw new Error(data.error || `Error ${res.status}`);
212
215
  if (Array.isArray(data.agents)) data.agents = data.agents.filter((x) => x.id !== 'demo'); // l'agent de proves del programa no s'ensenya
213
216
  return data;
@@ -320,11 +323,13 @@ async function connect() {
320
323
  } catch (e) {
321
324
  if (ctrl.signal.aborted) return;
322
325
  state.connected = false;
323
- 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;
324
328
  render();
325
329
  if (e.fatal) return;
326
330
  // Potser el túnel ha canviat d'adreça: la busquem a nio.cat (signada per l'ordinador).
327
- 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; }
328
333
  if (m.kind === 'vps' && m.via && retry >= 2) autoReconnect(m);
329
334
  setTimeout(() => machine() && !ctrl.signal.aborted && connect(), Math.min(15000, 1000 * 2 ** retry++));
330
335
  }
@@ -736,7 +741,7 @@ function viewSettings() {
736
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>` : '';
737
742
  if (machine() && pushOn === null) refreshPush();
738
743
  if (machine() && tgState === undefined) loadTelegram();
739
- 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>`;
740
745
  }
741
746
  // Ajustos sense ordinador: un sol avís a dalt i les seccions de mostra, desactivades.
742
747
  function settingsOff() {
@@ -2632,7 +2637,20 @@ function voicePanel() {
2632
2637
  const on = [...(v?.whisper?.ready ? ['Whisper'] : []), ...Object.entries(P).filter(([, p]) => p.key).map(([, p]) => p.name)];
2633
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>`;
2634
2639
  }
2635
- 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 = {}) {
2636
2654
  await loadVoice();
2637
2655
  if (!nodeVoice) return toast('Aquest ordinador encara no té la veu. Actualitza nio (npx nio-cat@latest).', 5000);
2638
2656
  if (isDemo()) return toast('A la demo no hi ha veu. Amb nio instal·lat, dictes amb Whisper, gratis.', 4500);
@@ -2642,6 +2660,7 @@ async function voiceSheet() {
2642
2660
  <form class="stack" id="voiceForm" style="margin-top:14px">
2643
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>
2644
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}
2645
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>
2646
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>
2647
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>
@@ -2650,6 +2669,7 @@ async function voiceSheet() {
2650
2669
  <button class="btn ghost" type="button" id="vOff">Treu les claus</button></div></details>
2651
2670
  <div class="row sheet-foot"><button class="btn ink" type="submit">Desa</button></div>
2652
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); }
2653
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); } };
2654
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'); };
2655
2675
  $('#vOff', sh).onclick = () => { if (confirm('Es treuran les claus de veu d’aquest ordinador.')) put({ keys: { elevenlabs: null, openai: null } }, 'Claus eliminades'); };
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
@@ -0,0 +1,351 @@
1
+ // nio · Notes en directe: Classe o Reunió.
2
+ // El mòbil (o l'ordinador) grava trossos de 20 s i els envia en ordre a l'ordinador, que els transcriu amb Whisper.
3
+ // Cada ~4-5 minuts de veu, un model en fa apunts; en acabar, el resum de tota la classe o reunió.
4
+ // Mentrestant, aquesta vista mostra la transcripció i els apunts a mesura que arriben.
5
+
6
+ const SEG_MS = Number(globalThis.NIO_LIVE_SEG_MS) || 20000; // durada de cada tros: prou curt per veure-ho gairebé en directe, prou llarg perquè Whisper tingui context
7
+ const SILENCE = 0.003; // per sota d'aquest volum (RMS) tot el tros, no s'envia: Whisper s'inventa frases en el silenci
8
+ const KINDS = { classe: 'Classe', reunio: 'Reunió' };
9
+ const LANGS = [['ca', 'Català'], ['es', 'Castellà'], ['en', 'Anglès']];
10
+ const STATES = { rec: 'Gravant', closing: 'Acabant de transcriure…', summarizing: 'Fent el resum…', done: 'Resum fet', error: 'No s’ha pogut resumir' };
11
+ const LI = {
12
+ live: '<path d="M4 10v4M8 7v10M12 4v16M16 8v8M20 11v2"/>',
13
+ cap: '<path d="M2.5 9 12 4.5 21.5 9 12 13.5z"/><path d="M6.5 11v4.5c0 1.2 2.5 2.5 5.5 2.5s5.5-1.3 5.5-2.5V11M21.5 9v5"/>',
14
+ people: '<circle cx="9" cy="8" r="3"/><path d="M3.5 19a5.5 5.5 0 0 1 11 0"/><circle cx="17" cy="9" r="2.3"/><path d="M15.5 14.2A4.5 4.5 0 0 1 21 18.5"/>',
15
+ down: '<path d="M6 9l6 6 6-6"/>',
16
+ edit: '<path d="M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3z"/>',
17
+ refresh: '<path d="M20 12a8 8 0 1 1-2.4-5.7L20 8.5"/><path d="M20 4v4.5h-4.5"/>',
18
+ };
19
+ const fmt = (s) => { s = Math.max(0, Math.round(s)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = String(s % 60).padStart(2, '0'); return h ? `${h}:${String(m).padStart(2, '0')}:${x}` : `${m}:${x}`; };
20
+ const LSget = (k, d) => { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } };
21
+ const LSset = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* sense desar */ } };
22
+
23
+ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEngines, openEditor }) {
24
+ let view = null; // { id, el, tab }
25
+ let rec = null; // gravació en marxa en aquest dispositiu
26
+ let pill = null;
27
+
28
+ // Markdown senzill del resum: títols, llistes, caselles i negretes.
29
+ function md(src) {
30
+ const inline = (t) => esc(t).replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>').replace(/(^|[^*])\*([^*]+)\*/g, '$1<i>$2</i>');
31
+ let out = '', list = false;
32
+ const close = () => { if (list) { out += '</ul>'; list = false; } };
33
+ for (const raw of String(src || '').split('\n')) {
34
+ const l = raw.trimEnd();
35
+ let m;
36
+ if (!l.trim()) { close(); continue; }
37
+ if ((m = l.match(/^#{1,4}\s+(.*)/))) { close(); out += `<h3>${inline(m[1])}</h3>`; continue; }
38
+ if ((m = l.match(/^\s*[-*•]\s+\[( |x)\]\s+(.*)/i))) { if (!list) { out += '<ul class="lv-check">'; list = true; } out += `<li data-done="${m[1] !== ' '}">${inline(m[2])}</li>`; continue; }
39
+ if ((m = l.match(/^\s*(?:[-*•]|\d+[.)])\s+(.*)/))) { if (!list) { out += '<ul>'; list = true; } out += `<li>${inline(m[1])}</li>`; continue; }
40
+ close(); out += `<p>${inline(l)}</p>`;
41
+ }
42
+ close();
43
+ return out;
44
+ }
45
+
46
+ // ---------------------------------------------------------------- començar
47
+ async function startSheet() {
48
+ if (ctx.isDemo()) return toast('A la demo no hi ha micròfon. Amb nio instal·lat, grava una classe i Whisper la transcriu a l’ordinador.', 5000);
49
+ if (!ctx.voiceOn()) { toast('Cal activar Whisper per transcriure (gratis, a l’ordinador).', 3500); return ctx.voiceSheet(); }
50
+ if (rec) return open(rec.id);
51
+ if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Aquest navegador no pot gravar àudio.');
52
+ const kind0 = LSget('nio.live.kind', 'classe'), lang0 = LANGS.some(([k]) => k === LSget('nio.live.lang', 'ca')) ? LSget('nio.live.lang', 'ca') : 'ca';
53
+ ctx.openSheet(`<h2>Classe o reunió</h2>
54
+ <div class="stack lv-start">
55
+ <div class="seg" role="group" aria-label="Tipus">${Object.entries(KINDS).map(([k, l]) => `<button type="button" data-kind="${k}" aria-pressed="${k === kind0}">${svg(k === 'classe' ? LI.cap : LI.people)}${l}</button>`).join('')}</div>
56
+ <label class="f">Idioma<select id="lvLang">${LANGS.map(([k, l]) => `<option value="${k}" ${k === lang0 ? 'selected' : ''}>${l}</option>`).join('')}</select></label>
57
+ <label class="f">Model per als apunts i el resum<select id="lvEng"><option>Mirant quins models hi ha…</option></select></label>
58
+ <p class="small muted" id="lvWhat"></p>
59
+ <p class="small lv-warn">Deixa nio obert i la pantalla encesa: si el mòbil es bloqueja, el navegador atura el micròfon.</p>
60
+ <div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" type="button" data-go>${svg('<circle cx="12" cy="12" r="6" fill="currentColor" stroke="none"/>')}Comença a gravar</button></div>
61
+ </div>`, async (sh) => {
62
+ let kind = kind0;
63
+ const what = () => { sh.querySelector('#lvWhat').textContent = kind === 'classe'
64
+ ? 'Transcriu tota la classe en directe, en fa apunts cada pocs minuts i, quan acabis, un resum amb els conceptes clau i preguntes per repassar.'
65
+ : 'Transcriu tota la reunió en directe, en fa notes cada pocs minuts i, quan acabis, un resum amb les decisions i les tasques.'; };
66
+ what();
67
+ sh.querySelectorAll('[data-kind]').forEach((b) => { b.onclick = () => { kind = b.dataset.kind; sh.querySelectorAll('[data-kind]').forEach((x) => x.setAttribute('aria-pressed', String(x === b))); what(); }; });
68
+ sh.querySelector('[data-close]').onclick = ctx.closeSheet;
69
+ const sel = sh.querySelector('#lvEng');
70
+ loadEngines().then((list) => {
71
+ if (!list.length) { sel.innerHTML = '<option value="">Cap model: només la transcripció</option>'; return; }
72
+ const saved = LSget('nio.notes.engine', ''), auto = (list.find((e) => e.local && e.id.startsWith('ollama:') && e.params >= 3) || list.find((e) => e.id === 'claude:haiku') || list[0]).id;
73
+ const cur = list.some((e) => e.id === saved) ? saved : auto, groups = [...new Set(list.map((e) => e.group))];
74
+ sel.innerHTML = groups.map((g) => `<optgroup label="${esc(g)}">${list.filter((e) => e.group === g).map((e) => `<option value="${esc(e.id)}" ${e.id === cur ? 'selected' : ''}>${esc(e.label)}</option>`).join('')}</optgroup>`).join('');
75
+ });
76
+ sh.querySelector('[data-go]').onclick = async (ev) => {
77
+ const b = ev.currentTarget; b.disabled = true;
78
+ // El micròfon es demana dins del clic: Safari no el deixa demanar més tard.
79
+ let stream;
80
+ try { stream = await mic(); } catch { b.disabled = false; return toast('No tinc permís per al micròfon. Activa’l als ajustos del navegador.', 4500); }
81
+ const lang = sh.querySelector('#lvLang').value, engine = sel.value || '';
82
+ LSset('nio.live.kind', kind); LSset('nio.live.lang', lang); if (engine) LSset('nio.notes.engine', engine);
83
+ try {
84
+ const n = await api('/api/notes/live', { method: 'POST', body: { kind, lang, engine } });
85
+ setNote(n); ctx.closeSheet();
86
+ begin(n, stream); open(n.id);
87
+ } catch (e) { stream.getTracks().forEach((t) => t.stop()); b.disabled = false; toast(e.message, 4500); }
88
+ };
89
+ });
90
+ }
91
+ // Àudio de sala: sense cancel·lació d'eco (no hi ha altaveu) i amb guany automàtic per al professor que és lluny.
92
+ const mic = () => navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: false, noiseSuppression: true, autoGainControl: true } });
93
+
94
+ // ---------------------------------------------------------------- gravació per trossos
95
+ function begin(n, stream) {
96
+ const segs = n.live?.segs || [];
97
+ const last = segs[segs.length - 1];
98
+ rec = {
99
+ id: n.id, stream, running: true, seq: last ? last.seq + 1 : 0, base: last ? last.at + last.dur : 0, t0: Date.now(),
100
+ queue: [], sending: false, fails: 0, skipped: 0, peak: 0, level: 0, mr: null, timer: 0, lost: false, wake: null, stopping: false,
101
+ };
102
+ meter(rec);
103
+ wake(rec);
104
+ for (const t of stream.getAudioTracks()) t.onended = () => { if (rec?.stream === stream && rec.running) { rec.lost = true; draw(); } };
105
+ segment(rec);
106
+ rec.tick = setInterval(() => { drawRec(); drawPill(); }, 500);
107
+ }
108
+ const elapsed = (r) => r.base + (Date.now() - r.t0) / 1000;
109
+ function segment(r) {
110
+ if (!r.running || r.lost) return;
111
+ if (r.stream.getAudioTracks().every((t) => t.readyState === 'ended')) { r.lost = true; draw(); return; }
112
+ let mr;
113
+ try { mr = new MediaRecorder(r.stream); } catch { r.lost = true; draw(); return; }
114
+ const chunks = [], start = Date.now(), at = r.base + (start - r.t0) / 1000;
115
+ r.mr = mr; r.peak = 0;
116
+ mr.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
117
+ mr.onstop = () => {
118
+ clearTimeout(r.timer);
119
+ const dur = (Date.now() - start) / 1000, blob = new Blob(chunks, { type: mr.mimeType || 'audio/webm' });
120
+ if (blob.size > 1500 && r.peak >= SILENCE) { r.queue.push({ seq: r.seq++, at: Math.round(at * 10) / 10, dur: Math.round(dur * 10) / 10, blob }); pump(r); }
121
+ else if (dur > 2) r.skipped++;
122
+ if (r.running && !r.lost) segment(r);
123
+ else if (!r.running) finish(r);
124
+ };
125
+ mr.onerror = () => { r.lost = true; draw(); };
126
+ mr.start(1000);
127
+ r.timer = setTimeout(() => { if (mr.state === 'recording') mr.stop(); }, SEG_MS);
128
+ }
129
+ // Envia els trossos en ordre; si no arriba a l'ordinador, ho torna a provar (es queden a la memòria del mòbil).
130
+ async function pump(r) {
131
+ if (r.sending) return;
132
+ r.sending = true;
133
+ while (r.queue.length) {
134
+ const s = r.queue[0];
135
+ try {
136
+ await ctx.nodeAudio(`/api/notes/${r.id}/live/seg?seq=${s.seq}&at=${s.at}&dur=${s.dur}`, { headers: { 'content-type': s.blob.type || 'audio/webm' }, body: s.blob });
137
+ r.queue.shift(); r.fails = 0;
138
+ } catch (e) {
139
+ r.fails++; r.error = e.message; draw();
140
+ await new Promise((ok) => setTimeout(ok, Math.min(30000, 2000 * 2 ** Math.min(r.fails, 4))));
141
+ if (rec !== r && !r.stopping) break;
142
+ }
143
+ draw();
144
+ }
145
+ r.sending = false;
146
+ if (r.stopping && !r.queue.length) closeRec(r);
147
+ }
148
+ function stop() {
149
+ const r = rec; if (!r) return;
150
+ r.running = false; r.stopping = true;
151
+ if (r.mr?.state === 'recording') r.mr.stop(); else finish(r);
152
+ draw();
153
+ }
154
+ function finish(r) {
155
+ clearInterval(r.tick); clearInterval(r.meterT);
156
+ r.stream.getTracks().forEach((t) => t.stop());
157
+ r.ctx?.close?.().catch(() => {});
158
+ r.wake?.release?.().catch(() => {});
159
+ if (!r.queue.length && !r.sending) closeRec(r); else pump(r);
160
+ }
161
+ async function closeRec(r) {
162
+ if (r.closed) return; r.closed = true;
163
+ try { setNote(await api(`/api/notes/${r.id}/live/stop`, { method: 'POST' })); }
164
+ catch (e) { toast(`No he pogut acabar la gravació: ${e.message}`, 5000); }
165
+ if (rec === r) rec = null;
166
+ pill?.remove(); pill = null;
167
+ draw();
168
+ }
169
+ // El micròfon s'ha aturat (pantalla bloquejada, trucada…): es continua a la mateixa nota amb un clic.
170
+ async function resume(id) {
171
+ let stream;
172
+ try { stream = await mic(); } catch { return toast('No tinc permís per al micròfon.', 4000); }
173
+ if (rec && rec.id === id) {
174
+ rec.stream.getTracks().forEach((t) => t.stop());
175
+ rec.base = elapsed(rec); rec.t0 = Date.now(); rec.stream = stream; rec.lost = false; rec.running = true;
176
+ clearInterval(rec.meterT); rec.ctx?.close?.().catch(() => {}); meter(rec); wake(rec);
177
+ for (const t of stream.getAudioTracks()) t.onended = () => { if (rec?.stream === stream && rec.running) { rec.lost = true; draw(); } };
178
+ segment(rec); draw(); return;
179
+ }
180
+ try {
181
+ const n = await api(`/api/notes/${id}/live/resume`, { method: 'POST' });
182
+ setNote(n); begin(n, stream); draw();
183
+ } catch (e) { stream.getTracks().forEach((t) => t.stop()); toast(e.message, 4500); }
184
+ }
185
+ // Nivell del micròfon: per veure que sent i per no enviar els trossos en silenci.
186
+ function meter(r) {
187
+ try {
188
+ const AC = window.AudioContext || window.webkitAudioContext, ac = new AC(), an = ac.createAnalyser();
189
+ ac.createMediaStreamSource(r.stream).connect(an); an.fftSize = 1024;
190
+ const buf = new Float32Array(an.fftSize);
191
+ r.ctx = ac;
192
+ r.meterT = setInterval(() => {
193
+ an.getFloatTimeDomainData(buf);
194
+ let sum = 0; for (const v of buf) sum += v * v;
195
+ const rms = Math.sqrt(sum / buf.length);
196
+ r.peak = Math.max(r.peak, rms); r.level = rms;
197
+ const bar = view && view.el.querySelector('.lv-meter i'); if (bar) bar.style.transform = `scaleX(${Math.min(1, rms * 12).toFixed(3)})`;
198
+ }, 120);
199
+ } catch { r.peak = 1; } // sense AudioContext, s'envia tot
200
+ }
201
+ async function wake(r) { try { r.wake = await navigator.wakeLock?.request('screen'); } catch { /* sense bloqueig de pantalla */ } }
202
+ document.addEventListener('visibilitychange', () => {
203
+ if (document.hidden || !rec) return;
204
+ if (rec.running) wake(rec); // el bloqueig de pantalla es perd en sortir de l'app
205
+ const r = rec, tracks = () => r.stream.getAudioTracks();
206
+ if (r.running && tracks().some((t) => t.readyState === 'ended')) r.lost = true;
207
+ // A l'iPhone, en tornar, la pista pot quedar silenciada uns instants: només és «aturat» si continua així.
208
+ else if (r.running && tracks().some((t) => t.muted)) setTimeout(() => { if (rec === r && tracks().some((t) => t.muted)) { r.lost = true; draw(); } }, 1500);
209
+ draw();
210
+ });
211
+ addEventListener('beforeunload', (e) => { if (rec) { e.preventDefault(); e.returnValue = ''; } });
212
+
213
+ // ---------------------------------------------------------------- vista
214
+ function open(id) {
215
+ const n = getNote(id); if (!n?.live) return false;
216
+ if (!view || view.id !== id) {
217
+ close(true);
218
+ const el = document.createElement('div');
219
+ el.className = 'lv'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-modal', 'true');
220
+ document.body.appendChild(el);
221
+ view = { id, el, tab: n.live.state === 'done' ? 'notes' : 'tr', follow: true };
222
+ el.addEventListener('click', onClick);
223
+ history.pushState({ ...(history.state || {}), nioLive: id }, '', location.href);
224
+ }
225
+ pill?.remove(); pill = null;
226
+ draw(true);
227
+ return true;
228
+ }
229
+ function close(silent) {
230
+ if (!view) return;
231
+ view.el.remove(); view = null;
232
+ if (!silent && history.state?.nioLive) history.back();
233
+ drawPill();
234
+ }
235
+ addEventListener('popstate', () => { if (view && !history.state?.nioLive) { view.el.remove(); view = null; drawPill(); } });
236
+ function drawPill() {
237
+ if (!rec || view) { if (pill && view) { pill.remove(); pill = null; } return; }
238
+ if (!pill) {
239
+ pill = document.createElement('button');
240
+ pill.type = 'button'; pill.className = 'lv-pill';
241
+ pill.onclick = () => open(rec.id);
242
+ document.body.appendChild(pill);
243
+ }
244
+ const n = getNote(rec.id);
245
+ pill.innerHTML = `<i class="lv-dot ${rec.lost ? 'off' : ''}"></i><b>${fmt(elapsed(rec))}</b><span>${esc(rec.lost ? 'Micròfon aturat' : KINDS[n?.live?.kind] || 'Gravant')}</span>`;
246
+ }
247
+
248
+ function draw(first) {
249
+ if (!view) { drawPill(); return; }
250
+ const n = getNote(view.id); if (!n?.live) return close(true);
251
+ const L = n.live, here = rec && rec.id === n.id, el = view.el;
252
+ const tr = el.querySelector('.lv-tr'), keep = tr && !first ? tr.scrollTop : 0;
253
+ const atEnd = tr ? tr.scrollHeight - tr.scrollTop - tr.clientHeight < 60 : true;
254
+ const pend = L.segs.filter((s) => s.state === 'transcribing').length;
255
+ const dur = here ? elapsed(rec) : L.segs.length ? L.segs[L.segs.length - 1].at + L.segs[L.segs.length - 1].dur : 0;
256
+ const busy = L.state === 'closing' || L.state === 'summarizing';
257
+ el.innerHTML = `<div class="lv-in">
258
+ <header class="lv-h">
259
+ <button class="icon-btn" type="button" data-lv="close" aria-label="${here ? 'Amaga (continua gravant)' : 'Tanca'}">${svg(here ? LI.down : '<path d="m10 5-7 7 7 7M3 12h18"/>')}</button>
260
+ <div class="lv-ttl"><h2>${esc(n.title || KINDS[L.kind])}</h2><small>${KINDS[L.kind]} · ${esc(LANGS.find(([k]) => k === L.lang)?.[1] || L.lang)} · ${fmt(dur)}</small></div>
261
+ <button class="icon-btn" type="button" data-lv="edit" aria-label="Edita la nota" title="Edita la nota">${svg(LI.edit)}</button>
262
+ </header>
263
+ ${here ? recBar(n) : `<div class="lv-state" data-s="${L.state}">${busy ? '<i class="nt-spin"></i>' : ''}<span>${esc(STATES[L.state] || '')}${L.state === 'rec' ? ' en un altre dispositiu' : ''}${pend ? ` · ${pend} trossos per transcriure` : ''}</span>
264
+ ${L.state === 'rec' ? `<button class="btn sm" type="button" data-lv="resume">Continua aquí</button>` : ''}
265
+ ${L.state === 'done' || L.state === 'error' ? `<button class="btn ghost sm" type="button" data-lv="resume">Continua gravant</button><button class="btn sm" type="button" data-lv="again">${svg(LI.refresh)}Torna a resumir</button>` : ''}</div>`}
266
+ ${L.error ? `<p class="lv-err" role="alert">${esc(L.error)}</p>` : ''}
267
+ <div class="seg lv-tabs" role="tablist">${[['notes', L.state === 'done' ? 'Resum' : 'Apunts'], ['tr', 'Transcripció']].map(([k, l]) => `<button type="button" role="tab" data-tab="${k}" aria-pressed="${view.tab === k}">${l}</button>`).join('')}</div>
268
+ <div class="lv-body" data-tab="${view.tab}">
269
+ <section class="lv-notes" data-label="${L.state === 'done' ? 'Resum' : 'Apunts'}">${notesHtml(n)}</section>
270
+ <section class="lv-tr" data-label="Transcripció" aria-live="off">${trHtml(n)}</section>
271
+ </div>
272
+ </div>`;
273
+ const t2 = el.querySelector('.lv-tr');
274
+ if (t2) t2.scrollTop = (first || (atEnd && view.follow)) ? t2.scrollHeight : keep;
275
+ t2?.addEventListener('scroll', () => { if (view) view.follow = t2.scrollHeight - t2.scrollTop - t2.clientHeight < 60; }, { passive: true });
276
+ }
277
+ function recBar(n) {
278
+ const r = rec, waiting = r.queue.length;
279
+ const status = r.stopping ? (waiting ? `Enviant ${waiting} ${waiting === 1 ? 'tros' : 'trossos'}…` : 'Acabant…')
280
+ : r.lost ? 'El micròfon s’ha aturat (pantalla bloquejada o una altra app). Prem «Continua».'
281
+ : waiting > 1 ? `Sense connexió amb l’ordinador: ${waiting} trossos esperant` : 'Grava i transcriu cada 20 s';
282
+ return `<div class="lv-rec ${r.lost ? 'lost' : ''}">
283
+ <i class="lv-dot ${r.lost || r.stopping ? 'off' : ''}"></i><b class="lv-time">${fmt(elapsed(r))}</b>
284
+ <span class="lv-meter" aria-hidden="true"><i></i></span>
285
+ <span class="lv-st">${esc(status)}</span>
286
+ ${r.lost && !r.stopping ? `<button class="btn sm" type="button" data-lv="resume">Continua</button>` : ''}
287
+ ${r.stopping ? '' : `<button class="btn ink sm" type="button" data-lv="stop">${svg('<rect x="7" y="7" width="10" height="10" rx="2"/>')}Atura i resumeix</button>`}
288
+ </div>`;
289
+ }
290
+ function drawRec() {
291
+ if (!view || !rec || view.id !== rec.id) return;
292
+ const t = view.el.querySelector('.lv-time'); if (t) t.textContent = fmt(elapsed(rec));
293
+ }
294
+ const rangeTime = (n, p) => { const a = n.live.segs.find((s) => s.seq === p.from), b = n.live.segs.find((s) => s.seq === p.to); return `${fmt(a?.at || 0)}–${fmt((b?.at || 0) + (b?.dur || 0))}`; };
295
+ function notesHtml(n) {
296
+ const L = n.live, parts = L.parts.filter((p) => p.text || p.error);
297
+ const partsHtml = parts.map((p) => `<article class="lv-part"><h4>${rangeTime(n, p)}</h4>${p.text ? md(p.text) : `<p class="muted small">No s’han pogut fer aquests apunts (${esc(p.error)}). Es faran amb el resum.</p>`}</article>`).join('');
298
+ if (L.state === 'done' && n.text.trim()) {
299
+ return `<div class="lv-sum">${md(n.text)}</div>${L.summaryEngine ? `<p class="lv-by small muted">Resum fet amb ${esc(L.summaryEngine.replace(/^ollama:/, ''))}</p>` : ''}${parts.length ? `<details class="lv-parts"><summary>Apunts per trossos (${parts.length})</summary>${partsHtml}</details>` : ''}`;
300
+ }
301
+ const busy = L.state === 'closing' || L.state === 'summarizing';
302
+ return `${busy ? `<div class="lv-busy"><i class="nt-spin"></i><span>${esc(STATES[L.state])} Amb una classe llarga i un model local pot trigar uns minuts.</span></div>` : ''}
303
+ ${partsHtml || `<p class="lv-empty">${L.segs.length ? 'Els primers apunts surten quan hi hagi uns 4 minuts de veu transcrits.' : 'Quan comenci a parlar, aquí aniran sortint els apunts cada pocs minuts.'}</p>`}`;
304
+ }
305
+ function trHtml(n) {
306
+ const segs = n.live.segs;
307
+ if (!segs.length) return '<p class="lv-empty">La transcripció surt aquí cada 20 segons.</p>';
308
+ let out = '', lastMark = -999;
309
+ for (const s of segs) {
310
+ if (s.at - lastMark >= 60) { out += `<b class="lv-ts">${fmt(s.at)}</b>`; lastMark = s.at; }
311
+ out += s.state === 'transcribing' ? '<span class="lv-seg wait">…</span> '
312
+ : s.state === 'error' ? `<span class="lv-seg err" title="${esc(s.error)}">[no s’ha pogut transcriure]</span> `
313
+ : s.text ? `<span class="lv-seg">${esc(s.text)}</span> ` : '';
314
+ }
315
+ return `<p>${out}</p>`;
316
+ }
317
+ async function onClick(e) {
318
+ const tab = e.target.closest('[data-tab]');
319
+ if (tab) { view.tab = tab.dataset.tab; draw(); return; }
320
+ const a = e.target.closest('[data-lv]')?.dataset.lv; if (!a) return;
321
+ const id = view.id;
322
+ if (a === 'close') return close();
323
+ if (a === 'stop') return stop();
324
+ if (a === 'resume') return resume(id);
325
+ if (a === 'edit') { close(); setTimeout(() => openEditor(id), 60); return; }
326
+ if (a === 'again') {
327
+ const list = await loadEngines(), saved = LSget('nio.notes.engine', '');
328
+ const pick = list.length > 1 ? await chooseEngine(list, getNote(id)?.live?.engine || saved) : list[0]?.id || '';
329
+ if (pick === null) return;
330
+ try { setNote(await api(`/api/notes/${id}/live/summary`, { method: 'POST', body: { engine: pick } })); draw(); }
331
+ catch (er) { toast(er.message, 4500); }
332
+ }
333
+ }
334
+ function chooseEngine(list, cur) {
335
+ return new Promise((done) => {
336
+ const groups = [...new Set(list.map((x) => x.group))];
337
+ ctx.openSheet(`<h2>Torna a resumir</h2><div class="stack"><label class="f">Model<select id="lvAgain">${groups.map((g) => `<optgroup label="${esc(g)}">${list.filter((x) => x.group === g).map((x) => `<option value="${esc(x.id)}" ${x.id === cur ? 'selected' : ''}>${esc(x.label)}</option>`).join('')}</optgroup>`).join('')}</select></label>
338
+ <div class="row sheet-foot"><button class="btn ghost" type="button" data-no>Cancel·la</button><button class="btn ink" type="button" data-ok>Resumeix</button></div></div>`, (sh) => {
339
+ sh.querySelector('[data-no]').onclick = () => { ctx.closeSheet(); done(null); };
340
+ sh.querySelector('[data-ok]').onclick = () => { const v = sh.querySelector('#lvAgain').value; LSset('nio.notes.engine', v); ctx.closeSheet(); done(v); };
341
+ });
342
+ });
343
+ }
344
+
345
+ return {
346
+ start: startSheet,
347
+ open,
348
+ update(n) { if (view?.id === n.id) draw(); else drawPill(); },
349
+ get recording() { return !!rec; },
350
+ };
351
+ }
package/web/app/notes.css CHANGED
@@ -223,3 +223,68 @@ body:has(.nt-ov) { overflow: hidden; }
223
223
  .nt-ed { min-height: min(480px, calc(var(--vvh, 100dvh) - 48px)); max-height: min(820px, calc(var(--vvh, 100dvh) - 48px)); }
224
224
  }
225
225
  @media (prefers-reduced-motion: reduce) { .nt-card, .nt-new, .nt-pop, .nt-ai { transition: none; animation: none; } .nt-rec-dot, .nt-spin, .nt-skel { animation: none; } }
226
+
227
+ /* ---------- Classe o reunió en directe (notes-live.js) */
228
+ .nt-tools { display: flex; gap: 8px; flex-wrap: wrap; }
229
+ .nt-badge.nt-lvb { color: var(--fg); }
230
+ .nt-badge .lv-dot { width: 8px; height: 8px; }
231
+ .lv-dot { display: inline-block; width: 11px; height: 11px; border-radius: 50%; background: var(--red); flex: none; animation: nt-pulse 1.1s ease-in-out infinite; }
232
+ .lv-dot.off { background: var(--fg-muted); animation: none; }
233
+ .lv-start .seg button { height: 44px; }
234
+ .lv-start .seg .ico { width: 18px; height: 18px; }
235
+ .lv-warn { padding: 10px 12px; border-radius: 10px; background: color-mix(in srgb, var(--lemon, #f6e9a0) 45%, transparent); }
236
+
237
+ .lv { position: fixed; inset: 0; z-index: 72; display: flex; justify-content: center; background: var(--bg); overscroll-behavior: contain; }
238
+ .lv-in { display: flex; flex-direction: column; width: min(1100px, 100%); height: 100%; padding: calc(10px + var(--safe-t, 0px)) 20px calc(10px + var(--safe-b, 0px)); gap: 12px; min-height: 0; }
239
+ .lv-h { display: flex; align-items: center; gap: 8px; }
240
+ .lv-ttl { flex: 1; min-width: 0; }
241
+ .lv-ttl h2 { font-size: 20px; font-weight: 600; letter-spacing: -.03em; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
242
+ .lv-ttl small { color: var(--fg-muted); font-size: 13px; font-variant-numeric: tabular-nums; }
243
+ .lv-rec { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 12px; padding: 12px 14px; border-radius: 14px; background: color-mix(in srgb, var(--red) 9%, var(--white)); }
244
+ .lv-rec.lost { background: color-mix(in srgb, var(--lemon, #f6e9a0) 55%, var(--white)); }
245
+ .lv-time { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -.02em; }
246
+ .lv-meter { width: 64px; height: 6px; border-radius: 3px; background: color-mix(in srgb, var(--fg) 12%, transparent); overflow: hidden; }
247
+ .lv-meter i { display: block; height: 100%; background: var(--moss); transform-origin: left; transform: scaleX(0); transition: transform .12s linear; }
248
+ .lv-st { flex: 1 1 160px; min-width: 0; font-size: 13px; color: var(--fg-muted); }
249
+ .lv-rec .btn .ico { width: 15px; height: 15px; }
250
+ .lv-state { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 10px; padding: 10px 14px; border-radius: 14px; background: var(--white); border: 1px solid var(--border); font-size: 14px; }
251
+ .lv-state span { flex: 1 1 180px; }
252
+ .lv-state[data-s="done"] { background: color-mix(in srgb, var(--moss) 10%, var(--white)); }
253
+ .lv-err { padding: 10px 14px; border-radius: 12px; background: color-mix(in srgb, var(--red) 10%, var(--white)); color: var(--red); font-size: 14px; }
254
+ .lv-tabs { display: none; }
255
+ .lv-body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 16px; }
256
+ .lv-body > section { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 16px 18px; border-radius: 16px; background: var(--white); border: 1px solid var(--border); }
257
+ .lv-tr { font-size: 15px; line-height: 1.65; }
258
+ @media (min-width: 761px) { .lv-body > section::before { content: attr(data-label); display: block; margin-bottom: 10px; font-size: 12px; font-weight: 600; color: var(--fg-muted); } }
259
+ .lv-tr p { margin: 0; }
260
+ .lv-ts { display: inline-block; margin: 0 6px 0 0; padding: 0 6px; border-radius: 6px; background: var(--bg-2); color: var(--fg-muted); font: 600 11px var(--mono); vertical-align: 1px; }
261
+ .lv-tr .lv-ts:not(:first-child) { margin-top: 10px; }
262
+ .lv-seg.wait { color: var(--fg-muted); }
263
+ .lv-seg.err { color: var(--red); font-size: 13px; }
264
+ .lv-empty { color: var(--fg-muted); font-size: 14px; }
265
+ .lv-notes h3 { margin: 16px 0 6px; font-size: 16px; font-weight: 600; letter-spacing: -.01em; }
266
+ .lv-notes h3:first-child { margin-top: 0; }
267
+ .lv-notes p { margin: 0 0 8px; line-height: 1.55; }
268
+ .lv-notes ul { margin: 0 0 8px; padding-left: 20px; display: grid; gap: 4px; line-height: 1.5; }
269
+ .lv-notes ul.lv-check { list-style: none; padding-left: 0; }
270
+ .lv-notes ul.lv-check li::before { content: ''; display: inline-block; width: 13px; height: 13px; margin-right: 8px; border: 1.6px solid var(--fg-muted); border-radius: 4px; vertical-align: -2px; }
271
+ .lv-part + .lv-part { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border); }
272
+ .lv-part h4 { margin-bottom: 6px; font: 600 12px var(--mono); color: var(--fg-muted); }
273
+ .lv-busy { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; color: var(--fg-muted); font-size: 14px; }
274
+ .lv-parts { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
275
+ .lv-parts summary { cursor: pointer; font-weight: 600; font-size: 14px; margin-bottom: 10px; }
276
+ .lv-by { margin-top: 12px; }
277
+ .lv-pill { position: fixed; z-index: 66; left: 50%; bottom: calc(76px + var(--safe-b, 0px)); transform: translateX(-50%); display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 16px; border: 0; border-radius: 20px; background: var(--fg); color: var(--bg); font: inherit; font-size: 14px; box-shadow: 0 10px 30px -10px rgba(39,34,36,.6); cursor: pointer; }
278
+ .lv-pill b { font-variant-numeric: tabular-nums; }
279
+ @media (max-width: 760px) {
280
+ .lv-in { padding-left: 12px; padding-right: 12px; gap: 10px; }
281
+ .lv-tabs { display: flex; }
282
+ .lv-body { display: block; }
283
+ .lv-body > section { height: 100%; }
284
+ .lv-body[data-tab="notes"] .lv-tr, .lv-body[data-tab="tr"] .lv-notes { display: none; }
285
+ .lv-time { font-size: 19px; }
286
+ .nt-tools .btn span { white-space: nowrap; }
287
+ .nt-tools .btn.nt-live-btn span { display: inline; }
288
+ .nt-tools .btn.nt-live-btn { padding: 0 13px; }
289
+ }
290
+ @media (prefers-reduced-motion: reduce) { .lv-dot { animation: none; } .lv-meter i { transition: none; } }
package/web/app/notes.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { createLive } from './notes-live.js';
2
+
1
3
  // nio · Notes. Com Google Keep, però a l'ordinador: targetes, llistes, colors, etiquetes, veu (Whisper local)
2
4
  // i un assistent que resumeix i ordena amb un model local, Claude Code o Codex.
3
5
  // Es carrega des d'app.js, que li passa el que necessita (ctx).
@@ -24,6 +26,7 @@ const I = {
24
26
  pause: '<path d="M8 5.500v13M16 5.500v13"/>',
25
27
  phone: '<rect x="7" y="3" width="10" height="18" rx="2.500"/><path d="M11 18h2"/>',
26
28
  check: '<path d="M5 12.500l4.500 4.500L19 7.500"/>',
29
+ live: '<path d="M4 10v4M8 7v10M12 4v16M16 8v8M20 11v2"/>',
27
30
  more: '<circle cx="6" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="18" cy="12" r="1.5"/>',
28
31
  };
29
32
  const COLORS = [['', 'Sense color'], ['lemon', 'Llimona'], ['peach', 'Préssec'], ['moss', 'Molsa'], ['sky', 'Cel'], ['lilac', 'Lila'], ['rose', 'Rosa'], ['sand', 'Sorra']];
@@ -45,6 +48,8 @@ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
45
48
  export function createNotes(ctx) {
46
49
  const { api, esc, toast } = ctx;
47
50
  const $ = (s, e = document) => e.querySelector(s);
51
+ // Classe o reunió: grava i transcriu en directe, en fa apunts i en acabar el resum (notes-live.js).
52
+ const live = createLive({ ctx, api, esc, toast, svg, getNote: (id) => notes.get(id), setNote: (n) => { upsert(n); if (root) { drawFilters(); drawGrid(); } schedule(); }, loadEngines: () => loadEngines(), openEditor: (id) => openEditor(id, null) });
48
53
  let root = null, notes = new Map(), loaded = false, loading = false, engines = null;
49
54
  let view = 'notes', q = '', layout = LSget('nio.notes.layout', 'grid');
50
55
  let ed = null, aborter = null;
@@ -101,7 +106,7 @@ export function createNotes(ctx) {
101
106
  root.innerHTML = `<div class="nt">
102
107
  <div class="nt-top">
103
108
  <div class="nt-title"><h1>Notes</h1><p>Escriu, grava i deixa que un model ho ordeni. Es desa a l’ordinador.</p></div>
104
- ${ctx.isPhone() ? '' : `<div class="nt-tools"><button class="btn" type="button" data-nt="phone">${svg(I.phone)}<span>Mòbil com a micròfon</span></button></div>`}
109
+ <div class="nt-tools"><button class="btn nt-live-btn" type="button" data-nt="live">${svg(I.live)}<span>Classe o reunió</span></button>${ctx.isPhone() ? '' : `<button class="btn" type="button" data-nt="phone">${svg(I.phone)}<span>Mòbil com a micròfon</span></button>`}</div>
105
110
  </div>
106
111
  <div class="nt-bar">
107
112
  <label class="nt-search">${svg(I.search)}<input type="search" id="ntQ" placeholder="Busca a les notes" autocomplete="off" aria-label="Busca a les notes"></label>
@@ -143,6 +148,8 @@ export function createNotes(ctx) {
143
148
  const chip = (v, label, extra = '') => `<button type="button" class="nt-chip" data-nt="view" data-v="${esc(v)}" ${view === v ? 'aria-current="true"' : ''}>${extra}${esc(label)}</button>`;
144
149
  $('#ntFilters', root).innerHTML = `<div class="nt-filter-views">${chip('notes', 'Notes')}${chip('archive', 'Arxivades', svg(I.archive))}${chip('trash', nTrash ? `Paperera · ${nTrash}` : 'Paperera', svg(I.trash))}</div>${labs.length ? `<div class="nt-filter-labels"><span>Etiquetes</span>${labs.map((l) => chip(`l:${l}`, l, svg(I.tag))).join('')}</div>` : ''}`;
145
150
  }
151
+ // A la targeta, el Markdown (resums de classe, notes organitzades) es veu com a text net.
152
+ const plain = (t) => t.trim().replace(/^#{1,4}\s+/gm, '').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/^(\s*)[-*]\s+\[[ x]\]\s+/gim, '$1☐ ').replace(/^(\s*)[-*]\s+/gm, '$1• ');
146
153
  function cardHtml(n) {
147
154
  const items = n.items || [];
148
155
  const open = items.filter((i) => !i.done), doneN = items.length - open.length;
@@ -151,9 +158,10 @@ export function createNotes(ctx) {
151
158
  return `<article class="nt-card" data-id="${n.id}" data-color="${n.color}" tabindex="0" aria-label="${esc(n.title || 'Nota sense títol')}">
152
159
  ${view === 'trash' ? '' : `<button class="nt-pin ${n.pinned ? 'on' : ''}" type="button" data-nt="pin" aria-label="${n.pinned ? 'Deixa de fixar' : 'Fixa la nota'}" aria-pressed="${n.pinned}">${svg(I.pin)}</button>`}
153
160
  ${n.title ? `<h3>${esc(n.title)}</h3>` : ''}
154
- ${n.text.trim() ? `<div class="nt-txt">${esc(n.text.trim())}</div>` : ''}
161
+ ${n.text.trim() ? `<div class="nt-txt">${esc(plain(n.text))}</div>` : ''}
155
162
  ${items.length ? `<div class="nt-its">${shown.map((i) => `<div class="nt-it ${i.done ? 'done' : ''}"><input type="checkbox" data-nt="tick" data-i="${i.id}" ${i.done ? 'checked' : ''} aria-label="Fet"><span>${esc(i.text)}</span></div>`).join('')}${items.length > 8 ? `<span class="nt-more">+ ${items.length - 8} més</span>` : ''}${doneN && items.length <= 8 ? '' : ''}</div>` : ''}
156
163
  ${!n.title && !n.text.trim() && !items.length ? `<div class="nt-txt muted">${n.audio.length ? 'Nota de veu' : 'Nota buida'}</div>` : ''}
164
+ ${n.live ? liveBadge(n) : ''}
157
165
  ${n.audio.length ? `<div class="nt-badge ${err ? 'err' : ''}">${svg(I.mic)}<span>${pend ? 'Transcrivint…' : err ? 'No s’ha pogut transcriure' : `${n.audio.length} ${n.audio.length === 1 ? 'àudio' : 'àudios'}`}</span></div>` : ''}
158
166
  ${n.labels.length ? `<div class="nt-labels">${n.labels.map((l) => `<span class="nt-label">${esc(l)}</span>`).join('')}</div>` : ''}
159
167
  <div class="nt-acts">${view === 'trash'
@@ -161,6 +169,12 @@ export function createNotes(ctx) {
161
169
  : `<button type="button" data-nt="archive" title="${n.archived ? 'Desarxiva' : 'Arxiva'}">${svg(I.archive)}</button><button type="button" data-nt="trash" title="Paperera">${svg(I.trash)}</button>`}</div>
162
170
  </article>`;
163
171
  }
172
+ function liveBadge(n) {
173
+ const L = n.live, last = L.segs[L.segs.length - 1], mins = last ? Math.max(1, Math.round((last.at + last.dur) / 60)) : 0;
174
+ const kind = L.kind === 'reunio' ? 'Reunió' : 'Classe';
175
+ const st = L.state === 'rec' ? '<i class="lv-dot"></i>Gravant' : L.state === 'closing' || L.state === 'summarizing' ? 'Resumint…' : L.state === 'error' ? 'Sense resum' : `${kind}${mins ? ` · ${mins} min` : ''}`;
176
+ return `<div class="nt-badge nt-lvb ${L.state === 'error' ? 'err' : ''}">${L.state === 'rec' ? '' : svg(I.live)}<span>${st}</span></div>`;
177
+ }
164
178
  function drawGrid() {
165
179
  if (!root) return;
166
180
  if (swipe?.card) { swipe.redraw = true; return; } // no es toca la llista amb una targeta sota el dit
@@ -194,6 +208,7 @@ export function createNotes(ctx) {
194
208
  if (act === 'view') { view = t.dataset.v; drawFilters(); drawGrid(); return; }
195
209
  if (act === 'layout') { layout = layout === 'grid' ? 'list' : 'grid'; LSset('nio.notes.layout', layout); drawLayout(); return; }
196
210
  if (act === 'phone') return phoneSheet();
211
+ if (act === 'live') return live.start();
197
212
  if (act === 'new') return openEditor(null, $('#ntNew', root));
198
213
  if (act === 'newlist') { e.stopPropagation(); return openEditor(null, $('#ntNew', root), { list: true }); }
199
214
  if (act === 'newmic') { e.stopPropagation(); return openEditor(null, $('#ntNew', root), { mic: true }); }
@@ -207,7 +222,7 @@ export function createNotes(ctx) {
207
222
  if (act === 'restore') return patch(n, { trashed: false });
208
223
  if (act === 'forever') { if (confirm('Aquesta nota s’esborrarà per sempre.')) api(`/api/notes/${n.id}?forever=1`, { method: 'DELETE' }).catch((er) => toast(er.message)); return; }
209
224
  }
210
- if (card && n) return openEditor(n.id, card);
225
+ if (card && n) { if (n.live && live.open(n.id)) return; return openEditor(n.id, card); }
211
226
  }
212
227
  // Lliscar una targeta cap a qualsevol costat l'arxiva (o la desarxiva a «Arxivades»), com a Keep.
213
228
  // Segueix el dit 1:1; si passa d'un terç de l'amplada o es llença ràpid, marxa; si no, torna al lloc.
@@ -289,14 +304,16 @@ export function createNotes(ctx) {
289
304
  ed.n = mine; notes.set(n.id, mine); syncEditor();
290
305
  }
291
306
  if (root) { drawFilters(); drawGrid(); }
292
- if (n.audio?.some((a) => a.state === 'transcribing')) schedule();
307
+ live.update(n);
308
+ if (n.audio?.some((a) => a.state === 'transcribing') || liveBusy(n)) schedule();
293
309
  }
310
+ const liveBusy = (n) => !!n.live && (['closing', 'summarizing'].includes(n.live.state) || n.live.segs.some((s) => s.state === 'transcribing'));
294
311
  const reload = () => { if (root || loaded) load(); };
295
312
 
296
313
  // Per si el flux en directe s'ha perdut (mòbil en segon pla, túnel que talla): es consulta l'ordinador
297
314
  // cada poc mentre hi ha àudio transcrivint-se i, si no, de tant en tant. Només s'aplica el que ha canviat.
298
315
  let pollT = 0, polling = false;
299
- const busy = () => all().some((n) => n.audio.some((a) => a.state === 'transcribing')) || !!ed?.uploading;
316
+ const busy = () => all().some((n) => n.audio.some((a) => a.state === 'transcribing') || liveBusy(n)) || !!ed?.uploading || live.recording;
300
317
  async function refresh() {
301
318
  if (polling || inflight || loading || !loaded || ctx.isDemo?.()) return; polling = true;
302
319
  const mk = machineKey();
package/web/app/style.css CHANGED
@@ -550,7 +550,14 @@ a.kbd { text-decoration: none; color: inherit; }
550
550
  .composer .mic.wait { opacity: .5; }
551
551
 
552
552
  /* ---------- Ordre i text net ---------- */
553
- .grid2.one { grid-template-columns: minmax(0, 1fr); max-width: 640px; }
553
+ .grid2.one { grid-template-columns: minmax(0, 1fr); max-width: 760px; }
554
+ .cols { max-width: 760px; }
555
+ /* Pantalla ampla: els panells d'una sola columna (Ajustos, Ordinadors, Pantalla) es reparteixen en dues, sense forats. */
556
+ @media (min-width: 1180px) {
557
+ .grid2.one, .cols { max-width: none; }
558
+ .cols, .grid2.one > .stack { display: block; columns: 2; column-gap: 16px; }
559
+ .cols > *, .grid2.one > .stack > * { break-inside: avoid; margin: 0 0 16px; }
560
+ }
554
561
  .top h1, .top p, .panel.empty h2, .panel.empty p, .stepper p, .stepper b, .help-body p, .card p, .sheet h2, .sheet p, .tour-card h3, .tour-card p, .muted.small { text-wrap: balance; }
555
562
  @media (max-width: 820px) {
556
563
  .side nav a[href="#/models"] { display: none; }
@@ -732,7 +739,7 @@ html.rd-open, html.rd-open body { overflow: hidden; }
732
739
  #jobMinsU { flex: 1; }
733
740
 
734
741
  /* Demo: avís d'ordinador simulat */
735
- .demo-bar { display: flex; align-items: center; justify-content: space-between; gap: 8px 16px; flex-wrap: wrap; margin: 16px 40px 0; padding: 10px 12px 10px 16px; border: 1px dashed var(--accent); border-radius: var(--radius); background: var(--panel); font-size: 13px; }
742
+ .demo-bar { display: flex; align-items: center; justify-content: space-between; gap: 8px 16px; flex-wrap: wrap; margin: 16px 40px 0; max-width: 1000px; padding: 10px 12px 10px 16px; border: 1px dashed var(--accent); border-radius: var(--radius); background: var(--panel); font-size: 13px; }
736
743
  .demo-bar b { color: var(--accent); }
737
744
  body.chat-mode .demo-bar, body.board-mode .demo-bar { display: none; }
738
745
  @media (max-width: 820px) { .demo-bar { margin: 12px 16px 0; } }
@@ -814,3 +821,15 @@ dialog.sheet:has(#memForm) { width: min(780px, calc(100% - 32px)); }
814
821
  .term-card { width: 100%; text-align: left; font: inherit; color: inherit; cursor: pointer; border-style: dashed; }
815
822
  .term-card:disabled { opacity: .6; cursor: progress; }
816
823
  .term-go { margin-left: auto; display: inline-flex; align-items: center; gap: 2px; font-weight: 600; color: var(--fg); }
824
+
825
+ /* Pas a pas de la clau d'ElevenLabs */
826
+ .el-guide { margin: -4px 0 4px; padding: 10px 12px; border-radius: 10px; background: var(--bg-2); font-size: 13.5px; }
827
+ .el-guide summary { cursor: pointer; font-weight: 600; }
828
+ .el-guide ol { margin: 10px 0 6px; padding-left: 20px; display: grid; gap: 8px; line-height: 1.45; }
829
+ .el-guide ul { margin: 6px 0 0; padding-left: 16px; display: grid; gap: 4px; }
830
+ .el-guide a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
831
+ .el-on, .el-off { display: inline-block; padding: 0 6px; border-radius: 6px; font-size: 12px; font-weight: 600; }
832
+ .el-on { background: color-mix(in srgb, var(--moss) 18%, transparent); color: var(--moss); }
833
+ .el-off { background: color-mix(in srgb, var(--fg) 8%, transparent); color: var(--fg-muted); }
834
+ .linkish { border: 0; background: none; padding: 0; font: inherit; color: inherit; text-decoration: underline; text-underline-offset: 2px; cursor: pointer; }
835
+ .main:has(.nt) .demo-bar { max-width: 1160px; }
package/web/sw.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
2
- const SHELL = 'nio-shell-v51';
3
- const FILES = ['/app', '/app/style.css', '/app/notes.css', '/app/call.css', '/app/app.js', '/app/notes.js', '/app/call.js', '/app/demo.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
2
+ const SHELL = 'nio-shell-v53';
3
+ const FILES = ['/app', '/app/style.css', '/app/notes.css', '/app/call.css', '/app/app.js', '/app/notes.js', '/app/notes-live.js', '/app/call.js', '/app/demo.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
4
4
 
5
5
  self.addEventListener('install', (e) => {
6
6
  e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).catch(() => {}));