nio-cat 0.9.11 → 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/notes.js +36 -9
- package/src/voice.js +2 -2
- package/src/whisper.js +3 -3
- package/web/app/app.js +50 -16
- package/web/app/index.html +1 -0
- package/web/app/notes-live.js +86 -10
- package/web/app/notes.css +2 -0
- package/web/app/style.css +13 -2
- package/web/board.css +9 -9
- package/web/index.html +4 -0
- package/web/sw.js +1 -1
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/notes.js
CHANGED
|
@@ -195,9 +195,11 @@ class Notes {
|
|
|
195
195
|
async liveTranscribe(n, sg) {
|
|
196
196
|
if (!this.notes.includes(n)) return;
|
|
197
197
|
try {
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
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 = '';
|
|
201
203
|
} catch (e) {
|
|
202
204
|
sg.state = 'error'; sg.error = String(e.message || e).slice(0, 300);
|
|
203
205
|
}
|
|
@@ -218,8 +220,8 @@ class Notes {
|
|
|
218
220
|
const pick = await this.pickEngine(L.engine);
|
|
219
221
|
if (!pick) return; // sense model: al final es deixa la transcripció
|
|
220
222
|
const part = { from: ready[0].seq, to: ready[ready.length - 1].seq, text: '', error: '' };
|
|
221
|
-
if (text
|
|
222
|
-
try { part.text = (await this.ask(pick, LIVE_TASKS.part[L.kind], text, false)).text
|
|
223
|
+
if (words(text) >= MIN_WORDS) {
|
|
224
|
+
try { part.text = cleanOut((await this.ask(pick, LIVE_TASKS.part[L.kind], text, false)).text); }
|
|
223
225
|
catch (e) { part.error = String(e.message || e).slice(0, 200); }
|
|
224
226
|
}
|
|
225
227
|
if (!this.notes.includes(n)) return;
|
|
@@ -260,6 +262,11 @@ class Notes {
|
|
|
260
262
|
await this.liveParts(n, true);
|
|
261
263
|
const transcript = L.segs.map((x) => x.text).filter(Boolean).join(' ').trim();
|
|
262
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
|
+
}
|
|
263
270
|
const pick = await this.pickEngine(L.engine);
|
|
264
271
|
if (!pick) {
|
|
265
272
|
if (!n.text.trim()) n.text = transcript;
|
|
@@ -274,11 +281,19 @@ class Notes {
|
|
|
274
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]); }
|
|
275
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)));
|
|
276
283
|
blocks = [];
|
|
277
|
-
for (const g of groups) blocks.push((await this.ask(pick, LIVE_TASKS.condense, g.join('\n\n'), false)).text.
|
|
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.');
|
|
278
291
|
}
|
|
279
|
-
const r = await this.ask(pick, LIVE_TASKS.final[L.kind], blocks.join('\n\n').slice(0, max), true);
|
|
280
292
|
const text = str(r.text, 200000).trim();
|
|
281
|
-
if (!text)
|
|
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
|
+
}
|
|
282
297
|
if (!this.notes.includes(n)) return;
|
|
283
298
|
const title = str(r.title, 120).trim();
|
|
284
299
|
if (title && n.title === L.autoTitle) { n.title = `${title}`; L.autoTitle = n.title; }
|
|
@@ -300,7 +315,7 @@ class Notes {
|
|
|
300
315
|
|
|
301
316
|
async ask(pick, task, body, json) {
|
|
302
317
|
const local = pick.id.startsWith('ollama:'), wrap = !json && local;
|
|
303
|
-
const user = `--- TRANSCRIPCIÓ ---\n${body}\n--- FI ---\n\n${task}
|
|
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.' : ''}`;
|
|
304
319
|
const raw = await runModel(this.cfg, pick.id, LIVE_SYSTEM, user, json || wrap, { numCtx: local ? 8192 : 0 });
|
|
305
320
|
return json ? parseJson(raw) : wrap ? { text: String(parseJson(raw).text ?? '') } : { text: raw };
|
|
306
321
|
}
|
|
@@ -419,6 +434,18 @@ const LIVE_LANGS = ['ca', 'es', 'en'];
|
|
|
419
434
|
const PART_CHARS = 3500; // ≈ 4-5 minuts de veu per tros d'apunts
|
|
420
435
|
// Whisper s'inventa aquestes frases quan sent silenci o soroll.
|
|
421
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; };
|
|
422
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.';
|
|
423
450
|
const LIVE_TASKS = {
|
|
424
451
|
part: {
|
package/src/voice.js
CHANGED
|
@@ -103,13 +103,13 @@ function status(cfg) {
|
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
async function transcribe(cfg, audio, mime, lang) {
|
|
106
|
+
async function transcribe(cfg, audio, mime, lang, opts = {}) {
|
|
107
107
|
const p = pick(cfg, 'stt');
|
|
108
108
|
lang = lang || cfgOf(cfg).lang || 'ca';
|
|
109
109
|
const l = lang === 'auto' ? '' : lang; // «auto»: que el proveïdor ho endevini
|
|
110
110
|
if (!p) throw httpErr(400, 'Aquest ordinador encara no té veu. Activa Whisper (gratis) o posa una clau a Ajustos → Veu.');
|
|
111
111
|
if (!audio?.length) throw httpErr(400, 'No hi ha àudio');
|
|
112
|
-
if (p === 'whisper') return { text: await whisper.transcribe(cfg, audio, mime, lang), provider: p };
|
|
112
|
+
if (p === 'whisper') return { text: await whisper.transcribe(cfg, audio, mime, lang, opts), provider: p };
|
|
113
113
|
return { text: (await PROVIDERS[p].stt(cfg.voice.keys[p], audio, mime, l)).trim(), provider: p };
|
|
114
114
|
}
|
|
115
115
|
|
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', ...(lang === 'auto' ? [] : ['--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 }, () => {});
|
package/web/app/app.js
CHANGED
|
@@ -583,7 +583,7 @@ function renderChrome() {
|
|
|
583
583
|
}
|
|
584
584
|
let notesCtl = null, callCtl = null;
|
|
585
585
|
const getCall = () => (callCtl ||= createCall({ api, esc, toast, bot, machine, isDemo, noMachine, voiceSheet, agentName: (a) => AGENT_NAMES[a] || a, sessions: () => [...state.sessions.values()], approvals: () => [...state.approvals.keys()], refresh: () => {} }));
|
|
586
|
-
const getNotes = () => (notesCtl ||= createNotes({ api, nodeAudio, toast, esc, machine, voiceOn, voiceSheet, isDemo, isPhone, openSheet, closeSheet, mobileQrSheet }));
|
|
586
|
+
const getNotes = () => (notesCtl ||= createNotes({ api, nodeAudio, toast, esc, machine, voiceOn, voiceSheet, isDemo, isPhone, openSheet, closeSheet, mobileQrSheet, nodeVersion: () => state.info?.version || '', nodeAtLeast: (v) => !state.info || verAtLeast(state.info.version, v), installCmd: INSTALL_CMD }));
|
|
587
587
|
function render() {
|
|
588
588
|
const r = state.route, view = $('#view'), board = r.name === 'pissarra';
|
|
589
589
|
if (r.name !== 'sessions') manage = null;
|
|
@@ -738,10 +738,12 @@ function noMachine(text) {
|
|
|
738
738
|
function viewSettings() {
|
|
739
739
|
const head = '<div class="top"><div><h1>Ajustos</h1><p>Avisos al mòbil, veu i Telegram. Es configuren per a cada ordinador.</p></div></div>';
|
|
740
740
|
if (!machine()) return `${head}<div class="stack-lg" style="max-width:640px">${settingsOff()}</div>`;
|
|
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>` : '';
|
|
741
|
+
const avisos = window.NioAndroid && machine() ? '<section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Dins l’app d’Android, els avisos encara no hi són. Per rebre’ls, connecta Telegram aquí sota o activa’ls obrint nio.cat al navegador.</p></div></section>' : 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>` : '';
|
|
742
742
|
if (machine() && pushOn === null) refreshPush();
|
|
743
743
|
if (machine() && tgState === undefined) loadTelegram();
|
|
744
|
-
|
|
744
|
+
// App d'Android: només es proposa des del navegador d'un mòbil Android (dins l'app ja no cal).
|
|
745
|
+
const android = /Android/i.test(navigator.userAgent) && !window.NioAndroid ? `<section class="panel"><header><h2>App d’Android</h2></header><div class="body stack"><p class="small">nio a pantalla completa i, sobretot, <b>classes i reunions que es continuen gravant amb la pantalla bloquejada</b>. En instal·lar-la, el navegador et demanarà permís per instal·lar apps d’aquesta font. Dins l’app, torna a connectar l’ordinador amb el QR.</p><div class="row"><a class="btn ink grow" href="/nio-android.apk" download="nio.apk">Baixa l’app (APK)</a></div></div></section>` : '';
|
|
746
|
+
return `${head}<div class="stack-lg cols">${android}${avisos}${voicePanel()}<div id="tgPanel">${telegramPanel()}</div></div>`;
|
|
745
747
|
}
|
|
746
748
|
// Ajustos sense ordinador: un sol avís a dalt i les seccions de mostra, desactivades.
|
|
747
749
|
function settingsOff() {
|
|
@@ -806,15 +808,27 @@ async function sshAdd(target, onStep, via) {
|
|
|
806
808
|
if (!last || last.error) throw new Error(last?.error || 'S’ha tallat la connexió');
|
|
807
809
|
return last;
|
|
808
810
|
}
|
|
811
|
+
// Lector de QR: BarcodeDetector si el navegador en té; si no (Firefox, l'app d'Android), jsQR, que llegeix la imatge en JavaScript.
|
|
812
|
+
let jsQRload = null;
|
|
813
|
+
const loadJsQR = () => (jsQRload ||= new Promise((ok, ko) => { const s = document.createElement('script'); s.src = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js'; s.onload = () => (window.jsQR ? ok(window.jsQR) : ko(new Error('jsQR'))); s.onerror = () => { jsQRload = null; ko(new Error('No he pogut carregar el lector de QR')); }; document.head.appendChild(s); }));
|
|
809
814
|
async function startScan(video, onText) {
|
|
810
|
-
|
|
815
|
+
let detect;
|
|
816
|
+
if ('BarcodeDetector' in window) { const det = new BarcodeDetector({ formats: ['qr_code'] }); detect = async () => (await det.detect(video))[0]?.rawValue; }
|
|
817
|
+
else {
|
|
818
|
+
const jsQR = await loadJsQR(), cv = document.createElement('canvas'), g = cv.getContext('2d', { willReadFrequently: true });
|
|
819
|
+
detect = async () => {
|
|
820
|
+
const w = Math.min(640, video.videoWidth), h = Math.round(video.videoHeight * (w / video.videoWidth)); if (!w || !h) return null;
|
|
821
|
+
cv.width = w; cv.height = h; g.drawImage(video, 0, 0, w, h);
|
|
822
|
+
return jsQR(g.getImageData(0, 0, w, h).data, w, h, { inversionAttempts: 'dontInvert' })?.data;
|
|
823
|
+
};
|
|
824
|
+
}
|
|
811
825
|
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
|
|
812
826
|
video.srcObject = stream; await video.play(); let alive = true;
|
|
813
|
-
(async () => { while (alive) { try { const
|
|
827
|
+
(async () => { while (alive) { try { const t = await detect(); if (t) onText(t); } catch { /* quadre sense QR */ } await sleep(250); } })();
|
|
814
828
|
return { stop() { alive = false; stream.getTracks().forEach((t) => t.stop()); } };
|
|
815
829
|
}
|
|
816
830
|
function addComputerSheet(tab, opts = {}) {
|
|
817
|
-
const phone = isPhone(), canScan =
|
|
831
|
+
const phone = isPhone(), canScan = !!navigator.mediaDevices?.getUserMedia;
|
|
818
832
|
const TABS = phone ? [['code', 'Codi o QR', ICON.scan], ['vps', 'Servidor', ICON.server]] : [['local', 'Aquest ordinador', ICON.machine], ['vps', 'Servidor', ICON.server], ['code', 'Tinc un codi', ICON.scan]];
|
|
819
833
|
let cur = TABS.some((t) => t[0] === tab) ? tab : TABS[0][0], poll = 0, scan = null, shown = '', hosts = [];
|
|
820
834
|
const intro = (t, d) => `<div class="add-intro"><h3>${t}</h3>${d ? `<p>${d}</p>` : ''}</div>`;
|
|
@@ -1663,8 +1677,7 @@ $('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
|
|
|
1663
1677
|
|
|
1664
1678
|
// Al mòbil, els fulls es tanquen lliscant cap avall, com els de l'iPhone: el full segueix el dit
|
|
1665
1679
|
// i, si baixa prou o es llença, marxa; si no, torna a pujar. Si el contingut té scroll, primer s'ha d'arribar a dalt.
|
|
1666
|
-
(
|
|
1667
|
-
const d = $('#sheet');
|
|
1680
|
+
function dragToClose(d, close) {
|
|
1668
1681
|
let g = null;
|
|
1669
1682
|
const scrollerOf = (t) => { for (let el = t; el && el !== d.parentElement; el = el.parentElement) { if (el.scrollHeight > el.clientHeight + 1 && /auto|scroll/.test(getComputedStyle(el).overflowY)) return el; if (el === d) break; } return null; };
|
|
1670
1683
|
const reset = () => { d.style.transition = ''; d.style.transform = ''; };
|
|
@@ -1692,21 +1705,27 @@ $('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
|
|
|
1692
1705
|
const x = g; g = null; if (!x || x.lock !== 'y') return;
|
|
1693
1706
|
const [t0, y0] = x.pts[0], [t1, y1] = x.pts[x.pts.length - 1], v = (y1 - y0) / Math.max(1, t1 - t0);
|
|
1694
1707
|
if (e.type === 'touchend' && (x.dy > Math.min(160, d.offsetHeight * 0.28) || (v > 0.5 && x.dy > 20))) {
|
|
1695
|
-
if (reduce) { reset();
|
|
1708
|
+
if (reduce) { reset(); close(); return; }
|
|
1696
1709
|
d.style.transition = 'transform .2s cubic-bezier(.4,0,1,1)'; d.style.transform = `translateY(${d.offsetHeight + 20}px)`;
|
|
1697
|
-
setTimeout(() => {
|
|
1710
|
+
setTimeout(() => { close(); requestAnimationFrame(reset); }, 200);
|
|
1698
1711
|
} else { d.style.transition = 'transform .3s cubic-bezier(.22,1,.36,1)'; d.style.transform = ''; setTimeout(reset, 300); }
|
|
1699
1712
|
};
|
|
1700
1713
|
d.addEventListener('touchend', end, { passive: true });
|
|
1701
1714
|
d.addEventListener('touchcancel', end, { passive: true });
|
|
1702
1715
|
d.addEventListener('close', reset);
|
|
1703
|
-
}
|
|
1716
|
+
}
|
|
1717
|
+
dragToClose($('#sheet'), () => closeSheet());
|
|
1718
|
+
dragToClose($('#palette'), () => $('#palette').close());
|
|
1704
1719
|
|
|
1705
|
-
// Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
|
|
1706
|
-
|
|
1720
|
+
// Claude Code no deixa «Via lliure» (bypassPermissions) quan nio corre com a root (típic en un VPS).
|
|
1721
|
+
// Des de nio-cat 0.9.12, com a root fa servir el mode auto de Claude Code; només les versions anteriors el bloquegen.
|
|
1722
|
+
const verAtLeast = (v, min) => { const a = String(v || '').split('.').map(Number), b = min.split('.').map(Number); for (let i = 0; i < 3; i++) { if ((a[i] || 0) !== b[i]) return (a[i] || 0) > b[i]; } return true; };
|
|
1723
|
+
const asRoot = (agentId) => agentId === 'claude' && state.info?.user === 'root';
|
|
1724
|
+
const modeBlocked = (agentId, mode) => mode === 'tot' && asRoot(agentId) && !verAtLeast(state.info?.version, '0.9.12');
|
|
1707
1725
|
function modeHelp(agentId, mode) {
|
|
1708
1726
|
const a = state.info?.agents?.find((x) => x.id === agentId);
|
|
1709
|
-
if (modeBlocked(agentId, mode)) return 'No disponible
|
|
1727
|
+
if (modeBlocked(agentId, mode)) return 'No disponible en aquesta versió de nio quan s’executa com a root. Actualitza nio (curl -fsSL https://www.nio.cat/install.sh | sh) o fes servir «Demana permís».';
|
|
1728
|
+
if (mode === 'tot' && asRoot(agentId)) return 'Treballa sense preguntar amb el mode auto de Claude Code (com a root no n’hi ha d’altre): un classificador revisa cada acció i atura les arriscades.';
|
|
1710
1729
|
if (mode === 'llegir') return 'Llegeix el projecte i et proposa canvis, però no toca cap fitxer.';
|
|
1711
1730
|
if (mode === 'tot') return 'Treballa sense preguntar. Fes-lo servir només en carpetes on confiïs plenament.';
|
|
1712
1731
|
if (a?.approvals) return 'Llegeix sol. Abans d’editar un fitxer o d’executar una ordre, et demana permís aquí.';
|
|
@@ -3041,6 +3060,20 @@ if ('serviceWorker' in navigator) {
|
|
|
3041
3060
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
|
3042
3061
|
navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'open-session' && e.data.id) go(`#/s/${e.data.id}`); });
|
|
3043
3062
|
}
|
|
3063
|
+
// L'app d'Android (i la de la pantalla d'inici) es queda oberta dies sense recarregar: en tornar-hi, si hi ha una versió
|
|
3064
|
+
// nova de la web (sw.js canvia a cada canvi de l'app), es recarrega, però només si no estàs fent res a mitges.
|
|
3065
|
+
(() => {
|
|
3066
|
+
const ver = async () => { try { return (await (await fetch('/sw.js', { cache: 'no-store' })).text()).match(/nio-shell-v\d+/)?.[0] || null; } catch { return null; } };
|
|
3067
|
+
const busy = () => document.querySelector('dialog[open], .nt-ov, .call-ov, .rd, .lv-pill, .lv-rec') || [...document.querySelectorAll('textarea, input[type=text], input:not([type])')].some((t) => t.value.trim());
|
|
3068
|
+
let mine = null, hiddenAt = 0;
|
|
3069
|
+
ver().then((v) => { mine = v; });
|
|
3070
|
+
document.addEventListener('visibilitychange', async () => {
|
|
3071
|
+
if (document.hidden) { hiddenAt = Date.now(); return; }
|
|
3072
|
+
if (!mine || Date.now() - hiddenAt < 60000) return;
|
|
3073
|
+
const now = await ver();
|
|
3074
|
+
if (now && now !== mine && !document.hidden && !busy()) location.reload();
|
|
3075
|
+
});
|
|
3076
|
+
})();
|
|
3044
3077
|
addEventListener('hashchange', route);
|
|
3045
3078
|
// 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
3079
|
// 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.
|
|
@@ -3073,11 +3106,12 @@ if (T.on) tourGo(T.i); else if (!T.seen && !machine()) setTimeout(tourStart, 700
|
|
|
3073
3106
|
|
|
3074
3107
|
/* ---------------------------------------------------------------- lliscar entre pàgines (mòbil) */
|
|
3075
3108
|
(() => {
|
|
3076
|
-
|
|
3109
|
+
// Les pàgines de la barra de baix, en el mateix ordre («Pantalla» i «Més» no són pàgines: obren un full).
|
|
3110
|
+
const PAGES = ['sessions', 'notes', 'ordinadors'];
|
|
3077
3111
|
const view = $('#view');
|
|
3078
3112
|
let g = null, timer = 0;
|
|
3079
3113
|
const scrollsX = (el) => { for (; el && el !== view; el = el.parentElement) { if (el.scrollWidth > el.clientWidth + 2 && /auto|scroll/.test(getComputedStyle(el).overflowX)) return true; } return false; };
|
|
3080
|
-
const blocked = (t) => !mq.matches || document.body.classList.contains('nav-open') || $('#sheet')?.open || state.route.name === 'session' || t.closest?.('input, textarea, select, [contenteditable], [data-noswipe]') || scrollsX(t);
|
|
3114
|
+
const blocked = (t) => !mq.matches || document.body.classList.contains('nav-open') || $('#sheet')?.open || state.route.name === 'session' || t.closest?.('input, textarea, select, [contenteditable], [data-noswipe], .nt-card') || scrollsX(t); // a Notes, lliscar una targeta l'arxiva
|
|
3081
3115
|
const target = (dir) => PAGES[PAGES.indexOf(state.route.name) + dir];
|
|
3082
3116
|
const put = (x, o) => { view.style.setProperty('--sx', x + 'px'); view.style.setProperty('--so', String(o)); };
|
|
3083
3117
|
const clear = () => { view.classList.remove('swiping', 'swipe-anim'); view.style.removeProperty('--sx'); view.style.removeProperty('--so'); };
|
package/web/app/index.html
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
33
33
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
34
34
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600&family=Caveat:wght@500;600&display=swap" rel="stylesheet">
|
|
35
|
+
<script>/* App d'Android: la pàgina va per sota de les barres del sistema; l'app en diu la mida abans de pintar. */try{var i=JSON.parse(NioAndroid.insets()),d=document.documentElement;d.style.setProperty('--safe-t',i.t+'px');d.style.setProperty('--safe-b',i.b+'px');d.classList.add('android')}catch(e){}</script>
|
|
35
36
|
<link rel="stylesheet" href="/bot.css">
|
|
36
37
|
<link rel="stylesheet" href="/board.css">
|
|
37
38
|
<link rel="stylesheet" href="/app/style.css">
|
package/web/app/notes-live.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
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
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
8
|
const KINDS = { classe: 'Classe', reunio: 'Reunió' };
|
|
9
|
+
const LIVE_MIN = '0.9.11', OLD = /ruta no existeix|Error 404/i; // un nio antic, sense classes ni reunions
|
|
10
|
+
// Dins l'app d'Android, grava un servei natiu: continua amb la pantalla bloquejada (android/LiveService.java).
|
|
11
|
+
const NA = typeof window !== 'undefined' && window.NioAndroid?.liveStart ? window.NioAndroid : null;
|
|
9
12
|
const LANGS = [['ca', 'Català'], ['es', 'Castellà'], ['en', 'Anglès']];
|
|
10
13
|
const STATES = { rec: 'Gravant', closing: 'Acabant de transcriure…', summarizing: 'Fent el resum…', done: 'Resum fet', error: 'No s’ha pogut resumir' };
|
|
11
14
|
const LI = {
|
|
@@ -44,11 +47,28 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
|
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
// ---------------------------------------------------------------- començar
|
|
50
|
+
// L’ordinador té un nio d’abans de les classes: cal actualitzar-lo allà (la web ja és nova).
|
|
51
|
+
function oldSheet() {
|
|
52
|
+
const v = ctx.nodeVersion(), cmd = ctx.installCmd;
|
|
53
|
+
ctx.openSheet(`<h2>Cal actualitzar nio</h2>
|
|
54
|
+
<div class="stack">
|
|
55
|
+
<p>${v ? `Aquest ordinador té nio ${esc(v)}` : 'Aquest ordinador té un nio antic'} i «Classe o reunió» necessita la ${LIVE_MIN} o més nova.</p>
|
|
56
|
+
<p class="small muted">Obre un terminal a l’ordinador i executa:</p>
|
|
57
|
+
<pre class="mono small" style="white-space:pre-wrap;user-select:all">${esc(cmd)}</pre>
|
|
58
|
+
<p class="small muted">Quan acabi, torna aquí i prova-ho de nou.</p>
|
|
59
|
+
<div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button><button class="btn ink" type="button" data-copy>Copia l’ordre</button></div>
|
|
60
|
+
</div>`, (sh) => {
|
|
61
|
+
sh.querySelector('[data-close]').onclick = ctx.closeSheet;
|
|
62
|
+
sh.querySelector('[data-copy]').onclick = () => navigator.clipboard?.writeText(cmd).then(() => toast('Ordre copiada'), () => toast('No l’he pogut copiar'));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
47
66
|
async function startSheet() {
|
|
48
67
|
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);
|
|
68
|
+
if (!ctx.nodeAtLeast(LIVE_MIN)) return oldSheet();
|
|
49
69
|
if (!ctx.voiceOn()) { toast('Cal activar Whisper per transcriure (gratis, a l’ordinador).', 3500); return ctx.voiceSheet(); }
|
|
50
70
|
if (rec) return open(rec.id);
|
|
51
|
-
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Aquest navegador no pot gravar àudio.');
|
|
71
|
+
if (!NA && (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder)) return toast('Aquest navegador no pot gravar àudio.');
|
|
52
72
|
const kind0 = LSget('nio.live.kind', 'classe'), lang0 = LANGS.some(([k]) => k === LSget('nio.live.lang', 'ca')) ? LSget('nio.live.lang', 'ca') : 'ca';
|
|
53
73
|
ctx.openSheet(`<h2>Classe o reunió</h2>
|
|
54
74
|
<div class="stack lv-start">
|
|
@@ -56,7 +76,7 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
|
|
|
56
76
|
<label class="f">Idioma<select id="lvLang">${LANGS.map(([k, l]) => `<option value="${k}" ${k === lang0 ? 'selected' : ''}>${l}</option>`).join('')}</select></label>
|
|
57
77
|
<label class="f">Model per als apunts i el resum<select id="lvEng"><option>Mirant quins models hi ha…</option></select></label>
|
|
58
78
|
<p class="small muted" id="lvWhat"></p>
|
|
59
|
-
<p class="small lv-warn"
|
|
79
|
+
<p class="small lv-warn">${NA ? 'Pots bloquejar el mòbil: l’app continua gravant i ho envia a l’ordinador.' : 'Deixa nio obert i la pantalla encesa: si el mòbil es bloqueja, el navegador atura el micròfon. Amb l’app d’Android, no cal.'}</p>
|
|
60
80
|
<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
81
|
</div>`, async (sh) => {
|
|
62
82
|
let kind = kind0;
|
|
@@ -75,6 +95,16 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
|
|
|
75
95
|
});
|
|
76
96
|
sh.querySelector('[data-go]').onclick = async (ev) => {
|
|
77
97
|
const b = ev.currentTarget; b.disabled = true;
|
|
98
|
+
if (NA) {
|
|
99
|
+
const lang = sh.querySelector('#lvLang').value, engine = sel.value || '';
|
|
100
|
+
LSset('nio.live.kind', kind); LSset('nio.live.lang', lang); if (engine) LSset('nio.notes.engine', engine);
|
|
101
|
+
try {
|
|
102
|
+
const n = await api('/api/notes/live', { method: 'POST', body: { kind, lang, engine } });
|
|
103
|
+
setNote(n); ctx.closeSheet(); open(n.id);
|
|
104
|
+
await nativeStart(n);
|
|
105
|
+
} catch (e) { b.disabled = false; if (OLD.test(e.message)) return oldSheet(); toast(e.message, 4500); }
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
78
108
|
// El micròfon es demana dins del clic: Safari no el deixa demanar més tard.
|
|
79
109
|
let stream;
|
|
80
110
|
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); }
|
|
@@ -84,7 +114,7 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
|
|
|
84
114
|
const n = await api('/api/notes/live', { method: 'POST', body: { kind, lang, engine } });
|
|
85
115
|
setNote(n); ctx.closeSheet();
|
|
86
116
|
begin(n, stream); open(n.id);
|
|
87
|
-
} catch (e) { stream.getTracks().forEach((t) => t.stop()); b.disabled = false; toast(e.message, 4500); }
|
|
117
|
+
} catch (e) { stream.getTracks().forEach((t) => t.stop()); b.disabled = false; if (OLD.test(e.message)) return oldSheet(); toast(e.message, 4500); }
|
|
88
118
|
};
|
|
89
119
|
});
|
|
90
120
|
}
|
|
@@ -105,7 +135,46 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
|
|
|
105
135
|
segment(rec);
|
|
106
136
|
rec.tick = setInterval(() => { drawRec(); drawPill(); }, 500);
|
|
107
137
|
}
|
|
108
|
-
const elapsed = (r) => r.base + (Date.now() - r.t0) / 1000;
|
|
138
|
+
const elapsed = (r) => (r.native ? r.st.elapsed || 0 : r.base + (Date.now() - r.t0) / 1000);
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------- gravació nativa (app d'Android)
|
|
141
|
+
async function nativeStart(n) {
|
|
142
|
+
const m = ctx.machine(), segs = n.live?.segs || [], last = segs[segs.length - 1];
|
|
143
|
+
const cfg = { url: m.url, token: m.token, id: n.id, seq: last ? last.seq + 1 : 0, base: last ? last.at + last.dur : 0, segMs: SEG_MS, kind: KINDS[n.live?.kind] || 'Classe' };
|
|
144
|
+
let r = NA.liveStart(JSON.stringify(cfg));
|
|
145
|
+
if (r === 'perm') {
|
|
146
|
+
// L'app demana el permís del micròfon i avisa quan l'usuari ha respost.
|
|
147
|
+
r = await new Promise((done) => addEventListener('nio-native', function on(e) { if (!e.detail?.live) return; removeEventListener('nio-native', on); done(e.detail.live === 'started' ? 'ok' : 'denied'); }));
|
|
148
|
+
}
|
|
149
|
+
if (r !== 'ok') { toast(r === 'denied' ? 'Sense permís per al micròfon no puc gravar. Activa’l a Ajustos → Apps → nio → Permisos.' : 'No he pogut començar a gravar.', 5000); draw(); return; }
|
|
150
|
+
nativeWatch(n.id, m.url);
|
|
151
|
+
// HyperOS (Xiaomi) i altres aturen les apps en segon pla: es demana que no ho faci amb nio.
|
|
152
|
+
try { if (NA.batteryOk && !NA.batteryOk() && !LSget('nio.live.battery', false)) { LSset('nio.live.battery', true); toast('Perquè no s’aturi amb la pantalla bloquejada, permet que nio funcioni sense restriccions de bateria.', 6000); NA.batteryHelp(); } } catch { /* res */ }
|
|
153
|
+
}
|
|
154
|
+
function nativeWatch(id, url) {
|
|
155
|
+
if (rec?.native) clearInterval(rec.tick);
|
|
156
|
+
const r = rec = { native: true, id, url, running: true, stopping: false, lost: false, queue: [], st: {} };
|
|
157
|
+
const tick = () => {
|
|
158
|
+
let st = {}; try { st = JSON.parse(NA.liveStatus()); } catch { /* res */ }
|
|
159
|
+
r.st = st; r.lost = !!st.lost; r.stopping = !!st.stopping;
|
|
160
|
+
// Si el túnel ha canviat d'adreça, els trossos pendents van a la nova.
|
|
161
|
+
const m = ctx.machine(); if (m && m.url !== r.url) { r.url = m.url; try { NA.liveTarget(m.url, m.token); } catch { /* res */ } }
|
|
162
|
+
if (!st.active) { clearInterval(r.tick); if (rec === r) { rec = null; pill?.remove(); pill = null; draw(); } return; }
|
|
163
|
+
const bar = view && view.el.querySelector('.lv-meter i'); if (bar) bar.style.transform = `scaleX(${Math.min(1, (st.level || 0) * 1.2).toFixed(3)})`;
|
|
164
|
+
// La vista es redibuixa quan canvia l'estat (o si encara no mostra la gravació d'aquí).
|
|
165
|
+
if (view?.id === id) { const st2 = view.el.querySelector('.lv-st'); if (!st2 || st2.textContent !== nativeText(r)) draw(); }
|
|
166
|
+
drawRec(); drawPill();
|
|
167
|
+
};
|
|
168
|
+
r.tick = setInterval(tick, 500); tick();
|
|
169
|
+
}
|
|
170
|
+
function nativeText(r) {
|
|
171
|
+
const q = r.st.queue || 0;
|
|
172
|
+
return r.stopping ? (q ? `Enviant ${q} ${q === 1 ? 'tros' : 'trossos'}…` : 'Acabant…')
|
|
173
|
+
: r.lost ? 'El micròfon és ocupat (una trucada?). Tornarà a gravar sol quan quedi lliure.'
|
|
174
|
+
: q > 1 ? `Sense connexió amb l’ordinador: ${q} trossos esperant` : 'Grava amb la pantalla bloquejada i transcriu cada 20 s';
|
|
175
|
+
}
|
|
176
|
+
// Si l'app es torna a obrir amb una gravació en marxa, se'n recupera l'estat.
|
|
177
|
+
if (NA) { try { const st = JSON.parse(NA.liveStatus()); if (st.active && st.id) setTimeout(() => nativeWatch(st.id, ctx.machine()?.url), 0); } catch { /* res */ } }
|
|
109
178
|
function segment(r) {
|
|
110
179
|
if (!r.running || r.lost) return;
|
|
111
180
|
if (r.stream.getAudioTracks().every((t) => t.readyState === 'ended')) { r.lost = true; draw(); return; }
|
|
@@ -117,8 +186,8 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
|
|
|
117
186
|
mr.onstop = () => {
|
|
118
187
|
clearTimeout(r.timer);
|
|
119
188
|
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++;
|
|
189
|
+
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 }); if (r.quiet) { r.quiet = 0; draw(); } pump(r); }
|
|
190
|
+
else if (dur > 2) { r.skipped++; r.quiet = (r.quiet || 0) + 1; if (r.quiet === 2) draw(); } // trossos en silenci seguits: el micròfon no capta la veu
|
|
122
191
|
if (r.running && !r.lost) segment(r);
|
|
123
192
|
else if (!r.running) finish(r);
|
|
124
193
|
};
|
|
@@ -147,6 +216,7 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
|
|
|
147
216
|
}
|
|
148
217
|
function stop() {
|
|
149
218
|
const r = rec; if (!r) return;
|
|
219
|
+
if (r.native) { NA.liveStop(); r.stopping = true; draw(); return; }
|
|
150
220
|
r.running = false; r.stopping = true;
|
|
151
221
|
if (r.mr?.state === 'recording') r.mr.stop(); else finish(r);
|
|
152
222
|
draw();
|
|
@@ -168,6 +238,11 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
|
|
|
168
238
|
}
|
|
169
239
|
// El micròfon s'ha aturat (pantalla bloquejada, trucada…): es continua a la mateixa nota amb un clic.
|
|
170
240
|
async function resume(id) {
|
|
241
|
+
if (NA) {
|
|
242
|
+
if (rec?.native && rec.id === id) return; // l'app ja hi torna sola quan el micròfon queda lliure
|
|
243
|
+
try { const n = await api(`/api/notes/${id}/live/resume`, { method: 'POST' }); setNote(n); await nativeStart(n); draw(); } catch (e) { toast(e.message, 4500); }
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
171
246
|
let stream;
|
|
172
247
|
try { stream = await mic(); } catch { return toast('No tinc permís per al micròfon.', 4000); }
|
|
173
248
|
if (rec && rec.id === id) {
|
|
@@ -275,15 +350,16 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
|
|
|
275
350
|
t2?.addEventListener('scroll', () => { if (view) view.follow = t2.scrollHeight - t2.scrollTop - t2.clientHeight < 60; }, { passive: true });
|
|
276
351
|
}
|
|
277
352
|
function recBar(n) {
|
|
278
|
-
const r = rec, waiting = r.queue.length;
|
|
279
|
-
const status = r.stopping ? (waiting ? `Enviant ${waiting} ${waiting === 1 ? 'tros' : 'trossos'}…` : 'Acabant…')
|
|
353
|
+
const r = rec, waiting = r.native ? r.st.queue || 0 : r.queue.length;
|
|
354
|
+
const status = r.native ? nativeText(r) : r.stopping ? (waiting ? `Enviant ${waiting} ${waiting === 1 ? 'tros' : 'trossos'}…` : 'Acabant…')
|
|
280
355
|
: 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`
|
|
356
|
+
: waiting > 1 ? `Sense connexió amb l’ordinador: ${waiting} trossos esperant`
|
|
357
|
+
: r.quiet >= 2 ? 'No sento res: apropa el micròfon o tria’n un altre a la icona del navegador' : 'Grava i transcriu cada 20 s';
|
|
282
358
|
return `<div class="lv-rec ${r.lost ? 'lost' : ''}">
|
|
283
359
|
<i class="lv-dot ${r.lost || r.stopping ? 'off' : ''}"></i><b class="lv-time">${fmt(elapsed(r))}</b>
|
|
284
360
|
<span class="lv-meter" aria-hidden="true"><i></i></span>
|
|
285
361
|
<span class="lv-st">${esc(status)}</span>
|
|
286
|
-
${r.lost && !r.stopping ? `<button class="btn sm" type="button" data-lv="resume">Continua</button>` : ''}
|
|
362
|
+
${r.lost && !r.stopping && !r.native ? `<button class="btn sm" type="button" data-lv="resume">Continua</button>` : ''}
|
|
287
363
|
${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
364
|
</div>`;
|
|
289
365
|
}
|
package/web/app/notes.css
CHANGED
|
@@ -173,6 +173,8 @@ body:has(.nt-ov) { overflow: hidden; }
|
|
|
173
173
|
.nt-ai-f { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
|
|
174
174
|
|
|
175
175
|
@media (max-width: 700px) {
|
|
176
|
+
/* Els desplegables de la nota (color, etiqueta, assistent) ocupen l'amplada, amb marge: no se surten pels costats. */
|
|
177
|
+
.nt-pop { left: 10px !important; right: 10px; max-width: none; min-width: 0; }
|
|
176
178
|
.page:has(.nt) { padding-inline: 14px; }
|
|
177
179
|
.nt-title h1 { font-size: 26px; } .nt-title p { display: none; }
|
|
178
180
|
.nt-top { margin-bottom: 12px; } .nt-tools .btn span { display: none; } .nt-tools .btn { padding: 0 11px; }
|
package/web/app/style.css
CHANGED
|
@@ -304,6 +304,15 @@ dialog.sheet[open] { animation: pop .22s var(--ease); }
|
|
|
304
304
|
#palette li { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: 10px; cursor: pointer; font-size: 14px; }
|
|
305
305
|
#palette li[aria-selected="true"] { background: var(--bg-2); }
|
|
306
306
|
#palette li span { color: var(--fg-muted); font-size: 12px; }
|
|
307
|
+
/* Al mòbil, la paleta és un full de baix com els altres, i es tanca lliscant cap avall. */
|
|
308
|
+
@media (max-width: 820px) {
|
|
309
|
+
#palette { width: 100%; max-width: none; margin: auto 0 0; border-radius: 20px 20px 0 0; padding-bottom: var(--safe-b); max-height: 82dvh; }
|
|
310
|
+
#palette[open] { display: flex; flex-direction: column; animation: sheet-up .3s var(--ease); }
|
|
311
|
+
#palette::before { content: ''; display: block; width: 36px; height: 4px; margin: 8px auto 2px; border-radius: 4px; background: var(--border); flex: none; }
|
|
312
|
+
#palette input { border-radius: 0; flex: none; }
|
|
313
|
+
#palette ul { max-height: none; flex: 1; min-height: 0; }
|
|
314
|
+
#palette li { min-height: 46px; align-items: center; font-size: 15px; }
|
|
315
|
+
}
|
|
307
316
|
|
|
308
317
|
.tour { position: fixed; inset: 0; z-index: 40; pointer-events: none; }
|
|
309
318
|
.tour-spot { position: fixed; border-radius: 12px; box-shadow: 0 0 0 9999px color-mix(in srgb, var(--fg) 42%, transparent), 0 0 0 2px var(--white) inset; transition: left .35s var(--ease), top .35s var(--ease), width .35s var(--ease), height .35s var(--ease); }
|
|
@@ -458,7 +467,9 @@ dialog.sheet.wiz .sheet-in { padding: 0; gap: 0; flex: 1; min-height: 0; display
|
|
|
458
467
|
.fs-pick b { overflow-wrap: anywhere; }
|
|
459
468
|
.fs-list { max-height: none; }
|
|
460
469
|
@media (max-width: 820px) {
|
|
461
|
-
|
|
470
|
+
/* Al mòbil, els assistents (Nova sessió, Afegeix ordinador…) fan l'alçada del contingut, no la de tota la pantalla. */
|
|
471
|
+
dialog.sheet.wiz { height: auto; max-height: min(92dvh, calc(var(--vvh, 100dvh) - 8px - var(--safe-t))); }
|
|
472
|
+
dialog.sheet.wiz .sheet-in, dialog.sheet.wiz .wiz-body { flex: 0 1 auto; }
|
|
462
473
|
.wiz-head { padding: 10px 18px 8px; }
|
|
463
474
|
.wiz-body { padding: 4px 18px 14px; }
|
|
464
475
|
.wiz-foot { padding: 12px 18px calc(14px + var(--safe-b)); }
|
|
@@ -724,7 +735,7 @@ html.rd-open, html.rd-open body { overflow: hidden; }
|
|
|
724
735
|
@media (max-width: 560px) { .rd-bar { padding: 8px 10px; gap: 8px; } .rd-stat, #rdFull { display: none; } .rd-seg button { padding: 9px 11px; } }
|
|
725
736
|
|
|
726
737
|
.rd-stage img { transform-origin: center; will-change: transform; }
|
|
727
|
-
.rd-tools { position: absolute; left: 50%; bottom: calc(14px +
|
|
738
|
+
.rd-tools { position: absolute; left: 50%; bottom: calc(14px + var(--safe-b, 0px)); transform: translateX(-50%); display: flex; align-items: center; gap: 6px; padding: 6px; border-radius: 14px; background: rgba(22, 22, 23, .86); border: 1px solid #36363a; backdrop-filter: blur(8px); z-index: 2; max-width: calc(100% - 20px); }
|
|
728
739
|
.rd-tools .rd-btn { padding: 10px 12px; min-width: 40px; }
|
|
729
740
|
.rd-pct { min-width: 56px !important; font-variant-numeric: tabular-nums; }
|
|
730
741
|
.rd-sep { width: 1px; align-self: stretch; background: #36363a; }
|
package/web/board.css
CHANGED
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
.bd-chat .chips::-webkit-scrollbar { display: none; }
|
|
110
110
|
.bd-chat .chip-s { flex: none; scroll-snap-align: start; max-width: 260px; box-sizing: border-box; height: 32px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 12px; line-height: 30px; text-align: center; border: 1px solid var(--border); border-radius: 999px; background: var(--white); font-size: 12px; font-weight: 500; cursor: pointer; }
|
|
111
111
|
.bd-chat .chip-s:hover { border-color: var(--fg); }
|
|
112
|
-
.bd-chat .cin { display: flex; gap: 8px; align-items: flex-end; padding: 12px 14px calc(14px +
|
|
112
|
+
.bd-chat .cin { display: flex; gap: 8px; align-items: flex-end; padding: 12px 14px calc(14px + var(--safe-b, 0px)); border-top: 1px solid var(--border); }
|
|
113
113
|
.bd-chat .cin textarea { flex: 1; min-height: 42px; max-height: 140px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--white); resize: none; font: inherit; font-size: 14px; line-height: 1.4; outline: none; }
|
|
114
114
|
.bd-chat .cin textarea:focus { border-color: var(--fg); }
|
|
115
115
|
.bd-chat .send { display: grid; place-items: center; width: 42px; height: 42px; flex: none; border: 0; border-radius: 12px; background: var(--fg); color: var(--bg); cursor: pointer; }
|
|
@@ -118,9 +118,9 @@
|
|
|
118
118
|
|
|
119
119
|
@media (max-width: 1100px) { .board-root { --chat-w: 320px; } }
|
|
120
120
|
@media (max-width: 820px) {
|
|
121
|
-
.board-root { height: calc(100dvh - 56px -
|
|
121
|
+
.board-root { height: calc(100dvh - 56px - var(--safe-t, 0px)); }
|
|
122
122
|
.chat-open .bd-tools, .chat-open .bd-right { transform: none; }
|
|
123
|
-
.bd-tools { top: auto; bottom: calc(12px +
|
|
123
|
+
.bd-tools { top: auto; bottom: calc(12px + var(--safe-b, 0px)); left: 10px; right: 10px; transform: none; overflow-x: auto; scrollbar-width: none; }
|
|
124
124
|
.bd-tools::-webkit-scrollbar { display: none; }
|
|
125
125
|
.bd .tb { width: 40px; height: 40px; flex: none; }
|
|
126
126
|
.bd .tb small { display: none; }
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
.bd .chat-t > span:not(.bot) { display: none; }
|
|
132
132
|
.only-m { display: grid !important; }
|
|
133
133
|
.bd-style { display: none; }
|
|
134
|
-
.style-open .bd-style:not([hidden]) { display: grid; left: 10px; right: 10px; top: auto; bottom: calc(70px +
|
|
134
|
+
.style-open .bd-style:not([hidden]) { display: grid; left: 10px; right: 10px; top: auto; bottom: calc(70px + var(--safe-b, 0px)); width: auto; grid-template-columns: 1fr 1fr; }
|
|
135
135
|
.bd-help .cols { grid-template-columns: 1fr; }
|
|
136
136
|
.bd-chat { position: absolute; top: auto; left: 0; right: 0; bottom: 0; z-index: 9; width: auto; height: min(78%, 600px); border: 0; border-top: 1px solid var(--border); border-radius: 20px 20px 0 0; transform: translateY(105%); transition: transform .34s var(--bd-ease), visibility 0s linear .34s; box-shadow: 0 -20px 50px -30px color-mix(in srgb, var(--fg) 55%, transparent); }
|
|
137
137
|
.chat-open .bd-chat { transform: none; transition: transform .34s var(--bd-ease), visibility 0s; }
|
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
.chat-open .bd-chat { transform: none; transition: transform .34s var(--bd-ease), visibility 0s; }
|
|
146
146
|
.bd-chat header::before { content: ""; position: absolute; top: 6px; left: 50%; width: 36px; height: 4px; margin-left: -18px; border-radius: 4px; background: var(--border); }
|
|
147
147
|
.chat-open .bd-tools, .chat-open .bd-right { transform: none; }
|
|
148
|
-
.bd-tools { top: auto; bottom: calc(12px +
|
|
148
|
+
.bd-tools { top: auto; bottom: calc(12px + var(--safe-b, 0px)); left: 10px; right: 10px; transform: none; overflow-x: auto; scrollbar-width: none; justify-content: flex-start; }
|
|
149
149
|
.bd-tools::-webkit-scrollbar { display: none; }
|
|
150
150
|
.bd .tb { flex: none; }
|
|
151
151
|
.bd .tb small { display: none; }
|
|
@@ -156,7 +156,7 @@
|
|
|
156
156
|
.bd .chat-t > span:not(.bot) { display: none; }
|
|
157
157
|
.bd .tb.only-m { display: grid !important; }
|
|
158
158
|
.bd-style { display: none; }
|
|
159
|
-
.style-open .bd-style:not([hidden]) { display: grid; left: 10px; right: 10px; top: auto; bottom: calc(70px +
|
|
159
|
+
.style-open .bd-style:not([hidden]) { display: grid; left: 10px; right: 10px; top: auto; bottom: calc(70px + var(--safe-b, 0px)); width: auto; grid-template-columns: 1fr 1fr; }
|
|
160
160
|
}
|
|
161
161
|
@container stage (max-width: 440px) {
|
|
162
162
|
.bd .tb[data-tool="hand"], .bd .tb[data-tool="line"], .bd .tb[data-bd="lock"] { display: none; }
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
.bd-chat header { padding-top: 22px; }
|
|
182
182
|
.bd-chat .msgs { overscroll-behavior: contain; }
|
|
183
183
|
.bd-chat .cin textarea { font-size: 16px; }
|
|
184
|
-
.bd-chat .cin { padding-bottom: calc(12px +
|
|
184
|
+
.bd-chat .cin { padding-bottom: calc(12px + var(--safe-b, 0px)); }
|
|
185
185
|
}
|
|
186
186
|
@container stage (max-width: 720px) {
|
|
187
187
|
.bd-scrim { display: block; position: absolute; inset: 0; z-index: 8; border: 0; background: color-mix(in srgb, var(--fg) 30%, transparent); opacity: 0; visibility: hidden; transition: opacity .3s, visibility 0s linear .3s; }
|
|
@@ -232,10 +232,10 @@
|
|
|
232
232
|
/* Mòbil en horitzontal: poc d'alçada */
|
|
233
233
|
@media (max-height: 480px) and (max-width: 940px) {
|
|
234
234
|
.bd-chat { height: min(92%, 600px); }
|
|
235
|
-
.bd-tools { bottom: calc(6px +
|
|
235
|
+
.bd-tools { bottom: calc(6px + var(--safe-b, 0px)); left: auto; right: auto; max-width: calc(100% - 20px); }
|
|
236
236
|
.bd-tools { left: 50%; transform: translateX(-50%); }
|
|
237
237
|
.bd .tb { height: 40px; }
|
|
238
|
-
.style-open .bd-style:not([hidden]) { bottom: calc(58px +
|
|
238
|
+
.style-open .bd-style:not([hidden]) { bottom: calc(58px + var(--safe-b, 0px)); max-height: calc(100% - 130px); overflow-y: auto; grid-template-columns: repeat(2, 1fr); }
|
|
239
239
|
.bd-help-in { padding: 14px; }
|
|
240
240
|
.bd-empty { display: none; }
|
|
241
241
|
}
|
package/web/index.html
CHANGED
|
@@ -218,6 +218,7 @@ main { padding-bottom: 8px; }
|
|
|
218
218
|
<section aria-labelledby="que">
|
|
219
219
|
<div class="sec-head"><span class="eyebrow">Què hi pots fer</span><h2 id="que">Tot el que necessites per treballar amb agents</h2></div>
|
|
220
220
|
<div class="grid">
|
|
221
|
+
<article class="card big c-lemon"><div class="big-l"><span class="tag">Notes</span><h3>Classe o reunió, apuntada sola</h3><p>Grava amb el micròfon del mòbil o de l’ordinador i Whisper ho transcriu a l’ordinador mentre es parla. Cada 4 o 5 minuts, un model en fa apunts; quan acabes, surt el resum de tot.</p><p class="muted">Tries l’idioma (català, castellà o anglès) i el model. L’àudio no surt dels teus ordinadors.</p></div><dl class="rows stack"><div><dt><strong>En directe</strong></dt><dd>La transcripció apareix cada 20 segons, al costat dels apunts.</dd></div><div><dt><strong>Classe</strong></dt><dd>Conceptes clau, punts importants i preguntes per repassar.</dd></div><div><dt><strong>Reunió</strong></dt><dd>Resum, decisions, tasques amb qui se’n fa càrrec i temes oberts.</dd></div><div><dt><strong>App d’Android</strong></dt><dd>Continua gravant amb la pantalla bloquejada, i si es talla la connexió guarda els trossos fins que torna.</dd></div></dl></article>
|
|
221
222
|
<article class="card c-blue"><span class="tag">Agents</span><h3>Sessions en directe</h3><p>Claude Code, Codex, Grok i Antigravity a qualsevol dels teus ordinadors. Tries la carpeta i els permisos, i la resposta arriba mentre treballa.</p></article>
|
|
222
223
|
<article class="card c-rose"><span class="tag">Seguretat</span><h3>Aprovacions</h3><p>Abans d’editar, executar o clicar res, te’n demana permís amb el motiu. Ho aproves o ho rebutges amb un toc, i tot queda registrat.</p></article>
|
|
223
224
|
<article class="card c-lilac"><span class="tag">Connexió</span><h3>Avisos al mòbil</h3><p>Una notificació quan un agent et demana permís o acaba. Arriba buida: el detall el demana el mòbil directament a l’ordinador.</p></article>
|
|
@@ -230,6 +231,9 @@ main { padding-bottom: 8px; }
|
|
|
230
231
|
<article class="card c-blue"><span class="tag">Agents</span><h3>Git i GitHub</h3><p>Clona un repositori, mira els canvis, fes commit i crea un PR des del mòbil, amb el <code>git</code> i el <code>gh</code> de l’ordinador.</p></article>
|
|
231
232
|
<article class="card c-lilac"><span class="tag">Connexió</span><h3>Telegram i veu</h3><p>Parla amb el teu bot o amb notes de veu, aprova amb un botó i escolta les respostes. Dictes gratis amb Whisper, que funciona a l’ordinador: l’àudio no en surt.</p></article>
|
|
232
233
|
<article class="card c-green"><span class="tag">Xat local</span><h3>Ollama i xat local</h3><p>Fes servir models gratuïts a l’ordinador, o un model petit dins del navegador del mòbil, sense connexió.</p></article>
|
|
234
|
+
<article class="card c-peach"><span class="tag">Notes</span><h3>Notes i veu</h3><p>Notes amb llistes, colors i etiquetes, guardades a l’ordinador. Dicta-les o adjunta-hi àudio i es transcriu; llisca una nota per arxivar-la. Des de l’ordinador, pots fer servir el mòbil com a micròfon.</p></article>
|
|
235
|
+
<article class="card c-rose"><span class="tag">Agents</span><h3>Truca a en Nio</h3><p>Parla amb en Nio en temps real (ElevenLabs, amb la teva clau): et diu com van les sessions, li encarregues feina i aproves de paraula. La conversa surt en directe, paraula per paraula.</p></article>
|
|
236
|
+
<article class="card c-blue"><span class="tag">Agents</span><h3>Continua les sessions del terminal</h3><p>Les converses de Claude Code obertes al terminal de l’ordinador surten a Sessions. Toca’n una i continua-la des del mòbil amb tot l’historial.</p></article>
|
|
233
237
|
</div>
|
|
234
238
|
</section>
|
|
235
239
|
|
package/web/sw.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
|
|
2
|
-
const SHELL = 'nio-shell-
|
|
2
|
+
const SHELL = 'nio-shell-v59';
|
|
3
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) => {
|