nio-cat 0.9.10 → 0.9.13
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/claude.js +4 -5
- package/src/agents/ollama.js +2 -2
- package/src/call.js +1 -1
- package/src/notes.js +220 -2
- package/src/voice.js +17 -5
- package/src/whisper.js +3 -3
- package/web/app/app.js +75 -21
- package/web/app/call.js +2 -1
- package/web/app/index.html +1 -0
- package/web/app/notes-live.js +427 -0
- package/web/app/notes.css +67 -0
- package/web/app/notes.js +22 -5
- package/web/app/style.css +34 -4
- package/web/board.css +9 -9
- package/web/index.html +4 -0
- package/web/sw.js +2 -2
package/package.json
CHANGED
package/src/agents/claude.js
CHANGED
|
@@ -23,10 +23,9 @@ module.exports = {
|
|
|
23
23
|
// Que l'agent posi un títol que resumeixi la feina (eina MCP de nio; el hub el desa).
|
|
24
24
|
args.push('--append-system-prompt', 'Treballes dins una sessió de nio. Quan entenguis la tasca, crida l’eina mcp__nio__sessio_titol amb un títol de 3-7 paraules que la resumeixi, en l’idioma de l’usuari; torna-la a cridar només si la feina canvia de tema.');
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
26
|
+
// Com a root (un VPS), Claude Code no accepta bypassPermissions: «Via lliure» hi fa servir el mode auto,
|
|
27
|
+
// que també treballa sol però fa revisar cada acció a un classificador (el mateix que el Claude Code remot).
|
|
28
|
+
const root = process.getuid?.() === 0;
|
|
30
29
|
|
|
31
30
|
const env = {};
|
|
32
31
|
const gate = (extra = {}) => {
|
|
@@ -38,7 +37,7 @@ module.exports = {
|
|
|
38
37
|
if (session.mode === 'llegir') {
|
|
39
38
|
args.push('--permission-mode', 'plan');
|
|
40
39
|
} else if (session.mode === 'tot') {
|
|
41
|
-
args.push('--permission-mode', 'bypassPermissions');
|
|
40
|
+
args.push('--permission-mode', root ? 'auto' : 'bypassPermissions');
|
|
42
41
|
// Amb el control del navegador o l'escriptori actiu, les ordres que hi actuen sempre es pregunten.
|
|
43
42
|
if (control) gate({ NIO_GATE: 'tot' });
|
|
44
43
|
} else {
|
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,179 @@ 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
|
+
// El final del tros anterior fa de context: Whisper hi enganxa millor les frases i hi escriu igual els noms.
|
|
199
|
+
const prev = n.live.segs.filter((x) => x.seq < sg.seq && x.text).pop()?.text || '';
|
|
200
|
+
const { text } = await voice.transcribe(this.cfg, fs.readFileSync(this.liveFile(n, sg)), sg.mime, n.live.lang, { prompt: prev.slice(-200) });
|
|
201
|
+
const t = unloop(String(text || '').replace(/\s+/g, ' ').trim());
|
|
202
|
+
sg.text = HALLUCINATION.test(t) || (prev && t === prev) ? '' : t; sg.state = 'ok'; sg.error = '';
|
|
203
|
+
} catch (e) {
|
|
204
|
+
sg.state = 'error'; sg.error = String(e.message || e).slice(0, 300);
|
|
205
|
+
}
|
|
206
|
+
if (!this.notes.includes(n)) return;
|
|
207
|
+
n.updated = Date.now(); this.save(); this.emit(n);
|
|
208
|
+
this.liveParts(n);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Apunts dels trossos ja transcrits, en ordre. Amb «final», també el tros que queda encara que sigui curt.
|
|
212
|
+
liveParts(n, final = false) {
|
|
213
|
+
if (this.parting.has(n.id)) return final ? this.parting.get(n.id).then(() => this.liveParts(n, true)) : this.parting.get(n.id);
|
|
214
|
+
const run = (async () => {
|
|
215
|
+
for (;;) {
|
|
216
|
+
const L = n.live, last = L.parts.length ? L.parts[L.parts.length - 1].to : -1, ready = [];
|
|
217
|
+
for (const sg of L.segs) { if (sg.seq <= last) continue; if (sg.state === 'transcribing') break; ready.push(sg); }
|
|
218
|
+
const text = ready.map((x) => x.text).filter(Boolean).join(' ');
|
|
219
|
+
if (!ready.length || (!final && text.length < PART_CHARS) || (final && ready.some((x) => x.state === 'transcribing'))) return;
|
|
220
|
+
const pick = await this.pickEngine(L.engine);
|
|
221
|
+
if (!pick) return; // sense model: al final es deixa la transcripció
|
|
222
|
+
const part = { from: ready[0].seq, to: ready[ready.length - 1].seq, text: '', error: '' };
|
|
223
|
+
if (words(text) >= MIN_WORDS) {
|
|
224
|
+
try { part.text = cleanOut((await this.ask(pick, LIVE_TASKS.part[L.kind], text, false)).text); }
|
|
225
|
+
catch (e) { part.error = String(e.message || e).slice(0, 200); }
|
|
226
|
+
}
|
|
227
|
+
if (!this.notes.includes(n)) return;
|
|
228
|
+
L.parts.push(part); n.updated = Date.now(); this.save(); this.emit(n);
|
|
229
|
+
}
|
|
230
|
+
})().finally(() => this.parting.delete(n.id));
|
|
231
|
+
this.parting.set(n.id, run);
|
|
232
|
+
return run;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
liveStop(id) {
|
|
236
|
+
const n = this.get(id), L = n.live;
|
|
237
|
+
if (!L) throw httpErr(400, 'Aquesta nota no és d’una classe ni d’una reunió');
|
|
238
|
+
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); }
|
|
239
|
+
return n;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
liveSummary(id, b = {}) {
|
|
243
|
+
const n = this.get(id), L = n.live;
|
|
244
|
+
if (!L) throw httpErr(400, 'Aquesta nota no és d’una classe ni d’una reunió');
|
|
245
|
+
if (L.state === 'rec') throw httpErr(409, 'Primer atura la gravació');
|
|
246
|
+
if (L.state === 'closing' || L.state === 'summarizing') return n;
|
|
247
|
+
if (b.engine) L.engine = str(b.engine, 80);
|
|
248
|
+
L.parts = L.parts.filter((p) => !p.error); // els apunts que havien fallat es tornen a fer
|
|
249
|
+
L.state = 'closing'; this.save(); this.emit(n);
|
|
250
|
+
this.liveFinish(n);
|
|
251
|
+
return n;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Espera la transcripció i els apunts pendents i en fa el resum final.
|
|
255
|
+
liveFinish(n) {
|
|
256
|
+
return this.queue(`s:${n.id}`, async () => {
|
|
257
|
+
const L = n.live;
|
|
258
|
+
await (this.queues.get(`t:${n.id}`) || Promise.resolve());
|
|
259
|
+
if (!this.notes.includes(n)) return;
|
|
260
|
+
L.state = 'summarizing'; L.error = ''; this.emit(n);
|
|
261
|
+
try {
|
|
262
|
+
await this.liveParts(n, true);
|
|
263
|
+
const transcript = L.segs.map((x) => x.text).filter(Boolean).join(' ').trim();
|
|
264
|
+
if (!transcript) throw new Error('No s’ha transcrit res: no hi ha res a resumir.');
|
|
265
|
+
// Amb quatre paraules, el model no té res a resumir i acaba resumint les seves instruccions.
|
|
266
|
+
if (words(transcript) < MIN_WORDS) {
|
|
267
|
+
if (!n.text.trim()) n.text = transcript;
|
|
268
|
+
throw new Error('Hi ha massa poca transcripció per fer-ne un resum: l’he deixada a la nota. Comprova que el micròfon capti bé la veu.');
|
|
269
|
+
}
|
|
270
|
+
const pick = await this.pickEngine(L.engine);
|
|
271
|
+
if (!pick) {
|
|
272
|
+
if (!n.text.trim()) n.text = transcript;
|
|
273
|
+
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.');
|
|
274
|
+
}
|
|
275
|
+
const max = pick.id.startsWith('ollama:') ? 9000 : 60000; // els models locals petits tenen poc context
|
|
276
|
+
const rangeText = (p) => L.segs.filter((x) => x.seq >= p.from && x.seq <= p.to).map((x) => x.text).filter(Boolean).join(' ');
|
|
277
|
+
let blocks = transcript.length <= max ? [transcript] : L.parts.map((p) => p.text || rangeText(p)).filter(Boolean);
|
|
278
|
+
// Massa llarg per a un sol cop: es condensen els apunts per grups fins que hi caben.
|
|
279
|
+
while (blocks.join('\n\n').length > max && blocks.length > 1) {
|
|
280
|
+
const groups = [];
|
|
281
|
+
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]); }
|
|
282
|
+
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)));
|
|
283
|
+
blocks = [];
|
|
284
|
+
for (const g of groups) blocks.push(cleanOut((await this.ask(pick, LIVE_TASKS.condense, g.join('\n\n'), false)).text) || g.join('\n\n').slice(0, max / 2));
|
|
285
|
+
}
|
|
286
|
+
let r = await this.ask(pick, LIVE_TASKS.final[L.kind], blocks.join('\n\n').slice(0, max), true);
|
|
287
|
+
if (echoes(r.text) || echoes(r.title)) r = await this.ask(pick, LIVE_TASKS.final[L.kind], blocks.join('\n\n').slice(0, max), true); // un segon intent
|
|
288
|
+
if (echoes(r.text) || echoes(r.title)) {
|
|
289
|
+
if (!n.text.trim()) n.text = transcript;
|
|
290
|
+
throw new Error('El model ha resumit les instruccions en lloc de la classe. He deixat la transcripció a la nota: torna-ho a provar amb un model més gran.');
|
|
291
|
+
}
|
|
292
|
+
const text = str(r.text, 200000).trim();
|
|
293
|
+
if (!text) {
|
|
294
|
+
if (!n.text.trim()) n.text = transcript;
|
|
295
|
+
throw new Error('El model diu que la transcripció no té prou contingut per resumir-la. L’he deixada a la nota.');
|
|
296
|
+
}
|
|
297
|
+
if (!this.notes.includes(n)) return;
|
|
298
|
+
const title = str(r.title, 120).trim();
|
|
299
|
+
if (title && n.title === L.autoTitle) { n.title = `${title}`; L.autoTitle = n.title; }
|
|
300
|
+
n.text = text; L.state = 'done'; L.summaryEngine = pick.id;
|
|
301
|
+
} catch (e) {
|
|
302
|
+
if (!this.notes.includes(n)) return;
|
|
303
|
+
L.state = 'error'; L.error = String(e.message || e).slice(0, 300);
|
|
304
|
+
}
|
|
305
|
+
n.updated = Date.now(); this.save(); this.emit(n);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async pickEngine(engine) {
|
|
310
|
+
const list = await this.engines();
|
|
311
|
+
// 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.
|
|
312
|
+
const auto = list.filter((e) => e.local && e.id.startsWith('ollama:') && e.params >= 3)[0] || list.find((e) => e.id === 'claude:haiku') || list[0];
|
|
313
|
+
return list.find((e) => e.id === engine) || auto || null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async ask(pick, task, body, json) {
|
|
317
|
+
const local = pick.id.startsWith('ollama:'), wrap = !json && local;
|
|
318
|
+
const user = `--- TRANSCRIPCIÓ ---\n${body}\n--- FI ---\n\n${task}\n\nFes-ho només amb el que es diu entre «--- TRANSCRIPCIÓ ---» i «--- FI ---». No parlis d’aquestes instruccions ni de tu mateix. Si la transcripció no té prou contingut, torna el text buit.${wrap ? '\n\nTorna només JSON amb aquest format: {"text":"…"}. El text pot tenir salts de línia.' : ''}`;
|
|
319
|
+
const raw = await runModel(this.cfg, pick.id, LIVE_SYSTEM, user, json || wrap, { numCtx: local ? 8192 : 0 });
|
|
320
|
+
return json ? parseJson(raw) : wrap ? { text: String(parseJson(raw).text ?? '') } : { text: raw };
|
|
321
|
+
}
|
|
322
|
+
|
|
139
323
|
// ---------- Motors per resumir i organitzar
|
|
140
324
|
async engines() {
|
|
141
325
|
const { binPath } = require('./agents');
|
|
@@ -179,6 +363,9 @@ class Notes {
|
|
|
179
363
|
return [
|
|
180
364
|
R('GET', '/api/notes', () => ({ notes: this.notes })),
|
|
181
365
|
R('POST', '/api/notes', ({ body }) => this.create(body), { limit: 1024 * 1024 }),
|
|
366
|
+
R('POST', '/api/notes/live', ({ body }) => this.liveStart(body || {})),
|
|
367
|
+
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 }),
|
|
368
|
+
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
369
|
R('GET', '/api/notes/engines', () => this.engines().then((engines) => ({ engines }))),
|
|
183
370
|
R('POST', '/api/notes/ai', ({ body }) => this.ai(body || {})),
|
|
184
371
|
R('PATCH', /^\/api\/notes\/([\w-]+)$/, ({ params, body }) => this.patch(params[0], body), { limit: 1024 * 1024 }),
|
|
@@ -241,6 +428,37 @@ const ACTIONS = {
|
|
|
241
428
|
},
|
|
242
429
|
};
|
|
243
430
|
|
|
431
|
+
// ---------- Classe o reunió
|
|
432
|
+
const LIVE_KINDS = { classe: 'Classe', reunio: 'Reunió' };
|
|
433
|
+
const LIVE_LANGS = ['ca', 'es', 'en'];
|
|
434
|
+
const PART_CHARS = 3500; // ≈ 4-5 minuts de veu per tros d'apunts
|
|
435
|
+
// Whisper s'inventa aquestes frases quan sent silenci o soroll.
|
|
436
|
+
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;
|
|
437
|
+
const MIN_WORDS = 25; // per sota, no hi ha res a apuntar ni a resumir
|
|
438
|
+
const words = (t) => (String(t || '').match(/[\p{L}\p{N}]+/gu) || []).length;
|
|
439
|
+
// Whisper de vegades es queda encallat repetint la mateixa frase: se'n deixa una.
|
|
440
|
+
function unloop(t) {
|
|
441
|
+
let out = t;
|
|
442
|
+
for (let n = 1; n <= 12; n++) out = out.replace(new RegExp(`((?:\\S+\\s+){${n - 1}}\\S+)(?:\\s+\\1(?![\\p{L}\\p{N}])){3,}`, 'gu'), '$1');
|
|
443
|
+
return out;
|
|
444
|
+
}
|
|
445
|
+
// La resposta parla de les instruccions (el «system prompt») en lloc de la transcripció.
|
|
446
|
+
const ECHO = /assistent de notes|transcripci[óo] autom[àa]tica|no t[’']inventis|no facis servir cap eina|---\s*(TRANSCRIPCI[ÓO]|FI)\s*---|torna nom[ée]s json|en l[’']idioma de la transcripci[óo]|les meves instruccions|system prompt/i;
|
|
447
|
+
const echoes = (t) => ECHO.test(String(t || ''));
|
|
448
|
+
const cleanOut = (t) => { const v = String(t || '').trim(); return echoes(v) ? '' : v; };
|
|
449
|
+
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.';
|
|
450
|
+
const LIVE_TASKS = {
|
|
451
|
+
part: {
|
|
452
|
+
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ó.',
|
|
453
|
+
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ó.',
|
|
454
|
+
},
|
|
455
|
+
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.',
|
|
456
|
+
final: {
|
|
457
|
+
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».',
|
|
458
|
+
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.',
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
|
|
244
462
|
function parseJson(raw) {
|
|
245
463
|
const t = String(raw || '').replace(/^```(?:json)?\s*|\s*```$/g, '').trim();
|
|
246
464
|
try { return JSON.parse(t); } catch { /* potser hi ha text al voltant */ }
|
|
@@ -264,14 +482,14 @@ function runCli(bin, args, input, { cwd, timeout = 180000 } = {}) {
|
|
|
264
482
|
});
|
|
265
483
|
}
|
|
266
484
|
|
|
267
|
-
async function runModel(cfg, engine, system, user, json) {
|
|
485
|
+
async function runModel(cfg, engine, system, user, json, { numCtx = 0 } = {}) {
|
|
268
486
|
const { binPath } = require('./agents');
|
|
269
487
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nio-nota-'));
|
|
270
488
|
try {
|
|
271
489
|
if (engine.startsWith('ollama:')) {
|
|
272
490
|
// Qwen3 «pensa» abans de respondre (minuts en un model petit): «/no_think» ho desactiva. Si la resposta porta un bloc <think>, es treu.
|
|
273
491
|
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; });
|
|
492
|
+
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
493
|
return out.replace(/^[\s\S]*?<\/think>\s*/i, '').trim();
|
|
276
494
|
}
|
|
277
495
|
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, opts = {}) {
|
|
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, opts), 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
|
@@ -120,7 +120,7 @@ function ext(mime) {
|
|
|
120
120
|
return /ogg/.test(mime || '') ? 'ogg' : /mp4|m4a|aac/.test(mime || '') ? 'm4a' : /mpeg|mp3/.test(mime || '') ? 'mp3' : /wav/.test(mime || '') ? 'wav' : 'webm';
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
async function transcribe(cfg, audio, mime, lang = 'ca') {
|
|
123
|
+
async function transcribe(cfg, audio, mime, lang = 'ca', { prompt = '' } = {}) {
|
|
124
124
|
const e = engine();
|
|
125
125
|
if (!e) throw httpErr(400, `Falta Whisper en aquest ordinador. Instal·la’l: ${installHint()}`);
|
|
126
126
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nio-veu-'));
|
|
@@ -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', ...(prompt ? ['--initial_prompt', prompt] : [])]);
|
|
132
132
|
return fs.readFileSync(path.join(tmp, 'audio.txt'), 'utf8').trim();
|
|
133
133
|
}
|
|
134
134
|
if (!fs.existsSync(modelPath(cfg))) {
|
|
@@ -138,7 +138,7 @@ async function transcribe(cfg, audio, mime, lang = 'ca') {
|
|
|
138
138
|
const wav = path.join(tmp, 'audio.wav');
|
|
139
139
|
await run(which('ffmpeg'), ['-nostdin', '-loglevel', 'error', '-i', input, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', wav], 60000);
|
|
140
140
|
const threads = String(Math.max(1, Math.min(8, os.cpus().length - 1)));
|
|
141
|
-
const out = await run(cppBin(), ['-m', modelPath(cfg), '-f', wav, '-l', lang, '-t', threads, '-nt', '-np']);
|
|
141
|
+
const out = await run(cppBin(), ['-m', modelPath(cfg), '-f', wav, '-l', lang, '-t', threads, '-nt', '-np', ...(prompt ? ['--prompt', prompt] : [])]);
|
|
142
142
|
return out.split('\n').map((l) => l.trim()).filter((l) => l && !/^\[(BLANK_AUDIO|Música|Music)\]$/i.test(l)).join(' ').trim();
|
|
143
143
|
} finally {
|
|
144
144
|
fs.rm(tmp, { recursive: true, force: true }, () => {});
|