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