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 +1 -1
- package/src/agents/ollama.js +2 -2
- package/src/call.js +1 -1
- package/src/notes.js +193 -2
- package/src/voice.js +17 -5
- package/src/whisper.js +1 -1
- package/web/app/app.js +26 -6
- package/web/app/call.js +2 -1
- package/web/app/notes-live.js +351 -0
- package/web/app/notes.css +65 -0
- package/web/app/notes.js +22 -5
- package/web/app/style.css +21 -2
- package/web/sw.js +2 -2
package/package.json
CHANGED
package/src/agents/ollama.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
101
|
-
return { text: (await PROVIDERS[p].stt(cfg.voice.keys[p], audio, mime,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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-
|
|
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(() => {}));
|