nio-cat 0.9.11 → 0.9.14
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/telegram.js +18 -1
- package/src/voice.js +2 -2
- package/src/whisper.js +3 -3
- package/web/app/android.js +37 -0
- package/web/app/app.js +119 -26
- package/web/app/index.html +2 -0
- package/web/app/notes-live.js +86 -10
- package/web/app/notes.css +8 -0
- package/web/app/notes.js +36 -11
- package/web/app/style.css +17 -2
- package/web/board.css +9 -9
- package/web/index.html +4 -0
- package/web/sw.js +2 -2
package/package.json
CHANGED
package/src/agents/claude.js
CHANGED
|
@@ -23,10 +23,9 @@ module.exports = {
|
|
|
23
23
|
// Que l'agent posi un títol que resumeixi la feina (eina MCP de nio; el hub el desa).
|
|
24
24
|
args.push('--append-system-prompt', 'Treballes dins una sessió de nio. Quan entenguis la tasca, crida l’eina mcp__nio__sessio_titol amb un títol de 3-7 paraules que la resumeixi, en l’idioma de l’usuari; torna-la a cridar només si la feina canvia de tema.');
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
26
|
+
// Com a root (un VPS), Claude Code no accepta bypassPermissions: «Via lliure» hi fa servir el mode auto,
|
|
27
|
+
// que també treballa sol però fa revisar cada acció a un classificador (el mateix que el Claude Code remot).
|
|
28
|
+
const root = process.getuid?.() === 0;
|
|
30
29
|
|
|
31
30
|
const env = {};
|
|
32
31
|
const gate = (extra = {}) => {
|
|
@@ -38,7 +37,7 @@ module.exports = {
|
|
|
38
37
|
if (session.mode === 'llegir') {
|
|
39
38
|
args.push('--permission-mode', 'plan');
|
|
40
39
|
} else if (session.mode === 'tot') {
|
|
41
|
-
args.push('--permission-mode', 'bypassPermissions');
|
|
40
|
+
args.push('--permission-mode', root ? 'auto' : 'bypassPermissions');
|
|
42
41
|
// Amb el control del navegador o l'escriptori actiu, les ordres que hi actuen sempre es pregunten.
|
|
43
42
|
if (control) gate({ NIO_GATE: 'tot' });
|
|
44
43
|
} else {
|
package/src/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/telegram.js
CHANGED
|
@@ -86,7 +86,13 @@ class Telegram {
|
|
|
86
86
|
signal: this.ctrl.signal,
|
|
87
87
|
});
|
|
88
88
|
const j = await r.json();
|
|
89
|
-
if (!j.ok)
|
|
89
|
+
if (!j.ok) {
|
|
90
|
+
// 409: un altre ordinador (o programa) llegeix el mateix bot. Telegram només en deixa un per bot.
|
|
91
|
+
if (j.error_code === 409 && !this.conflict) this.audit.add({ type: 'telegram', what: 'Un altre ordinador fa servir aquest mateix bot: cada ordinador en necessita un de propi' });
|
|
92
|
+
if (j.error_code === 409) this.conflict = Date.now();
|
|
93
|
+
throw new Error(j.description || r.status);
|
|
94
|
+
}
|
|
95
|
+
this.conflict = 0;
|
|
90
96
|
for (const u of j.result) {
|
|
91
97
|
this.offset = u.update_id + 1;
|
|
92
98
|
await this.onUpdate(u).catch((e) => this.audit.add({ type: 'telegram', what: `Error: ${e.message}` }));
|
|
@@ -247,6 +253,7 @@ class Telegram {
|
|
|
247
253
|
configured: !!t.token,
|
|
248
254
|
bot: this.me ? { username: this.me.username, name: this.me.first_name } : null,
|
|
249
255
|
running: this.running,
|
|
256
|
+
conflict: !!this.conflict && Date.now() - this.conflict < 5 * 60e3,
|
|
250
257
|
allowed: t.allowed || [],
|
|
251
258
|
pending: (t.pending || []).filter((p) => !this.allowed(p.id)),
|
|
252
259
|
agent: t.agent || null,
|
|
@@ -281,6 +288,16 @@ class Telegram {
|
|
|
281
288
|
t.token = prev;
|
|
282
289
|
throw httpErr(400, `Telegram no accepta aquest testimoni: ${e.message}`);
|
|
283
290
|
}
|
|
291
|
+
// Un bot només el pot llegir un ordinador alhora: si ja el fa servir un altre, cal un bot nou per a aquest.
|
|
292
|
+
// (Sense «offset», aquesta consulta no dona per llegit cap missatge.)
|
|
293
|
+
if (tok !== prev) {
|
|
294
|
+
const busy = await this.call('getUpdates', { timeout: 0, limit: 1 }).then(() => false, (e) => /conflict|409/i.test(e.message));
|
|
295
|
+
if (busy) {
|
|
296
|
+
t.token = prev;
|
|
297
|
+
this.me = null;
|
|
298
|
+
throw httpErr(409, 'Aquest bot ja el fa servir un altre ordinador. Telegram només deixa llegir un bot des d’un lloc: crea’n un altre a @BotFather (/newbot) per a aquest ordinador.');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
284
301
|
this.stop();
|
|
285
302
|
this.offset = undefined;
|
|
286
303
|
}
|
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 }, () => {});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// nio dins l'app d'Android: l'únic lloc on la web s'adapta a l'app.
|
|
2
|
+
// La resta del codi fa servir les APIs estàndard del navegador i no sap que és dins una app; aquí es tradueixen
|
|
3
|
+
// a la part nativa (window.NioAndroid, a android/src/cat/nio/app/MainActivity.java) les que el WebView no té.
|
|
4
|
+
// Fora de l'app, aquest fitxer no fa res. Per afegir-hi una cosa nova: un mètode a Bridge (Java) i el pont aquí.
|
|
5
|
+
(() => {
|
|
6
|
+
const NA = window.NioAndroid;
|
|
7
|
+
if (!NA) return;
|
|
8
|
+
document.documentElement.classList.add('android');
|
|
9
|
+
|
|
10
|
+
// Descàrregues (<a download href="blob:…">: exportar la pissarra, desar una captura…) → carpeta Baixades del mòbil.
|
|
11
|
+
const base64 = (blob) => new Promise((ok, ko) => { const r = new FileReader(); r.onload = () => ok(String(r.result).split(',')[1] || ''); r.onerror = ko; r.readAsDataURL(blob); });
|
|
12
|
+
async function save(href, name) {
|
|
13
|
+
try { const blob = await (await fetch(href)).blob(); NA.saveFile(name || 'nio', blob.type || 'application/octet-stream', await base64(blob)); }
|
|
14
|
+
catch { NA.saveFile('', '', ''); } // l'app avisa que no s'ha pogut desar
|
|
15
|
+
}
|
|
16
|
+
const isBlob = (a) => a.hasAttribute('download') && /^(blob|data):/.test(a.href);
|
|
17
|
+
if (NA.saveFile) { // les versions de l'app anteriors a la 1.0.2 no ho tenen
|
|
18
|
+
const click = HTMLAnchorElement.prototype.click;
|
|
19
|
+
HTMLAnchorElement.prototype.click = function () { if (isBlob(this)) return void save(this.href, this.getAttribute('download')); return click.call(this); };
|
|
20
|
+
document.addEventListener('click', (e) => { const a = e.target.closest?.('a[download]'); if (a && isBlob(a)) { e.preventDefault(); save(a.href, a.getAttribute('download')); } }, true);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Copiar: el porta-retalls del WebView no sempre deixa escriure; el d'Android sí.
|
|
24
|
+
if (!NA.copy) { /* app antiga */ } else if (navigator.clipboard) navigator.clipboard.writeText = (t) => Promise.resolve(NA.copy(String(t)));
|
|
25
|
+
else Object.defineProperty(navigator, 'clipboard', { value: { writeText: (t) => Promise.resolve(NA.copy(String(t))) }, configurable: true });
|
|
26
|
+
|
|
27
|
+
// Pantalla encesa: el WebView no sempre té navigator.wakeLock; l'app manté la pantalla encesa mentre hi ha algun bloqueig.
|
|
28
|
+
if (NA.keepAwake) {
|
|
29
|
+
const held = new Set();
|
|
30
|
+
const lock = { request: async () => {
|
|
31
|
+
const s = { released: false, type: 'screen', onrelease: null, addEventListener() {}, removeEventListener() {},
|
|
32
|
+
async release() { if (s.released) return; s.released = true; held.delete(s); if (!held.size) NA.keepAwake(false); s.onrelease?.(); } };
|
|
33
|
+
held.add(s); NA.keepAwake(true); return s;
|
|
34
|
+
} };
|
|
35
|
+
try { Object.defineProperty(navigator, 'wakeLock', { value: lock, configurable: true }); } catch { /* es queda el del WebView */ }
|
|
36
|
+
}
|
|
37
|
+
})();
|
package/web/app/app.js
CHANGED
|
@@ -224,7 +224,7 @@ async function pollLoop(ctrl) {
|
|
|
224
224
|
const [list, aps] = await Promise.all([api('/api/sessions'), api('/api/approvals')]);
|
|
225
225
|
if (ctrl.signal.aborted) return;
|
|
226
226
|
state.connected = true; state.lastError = null; alive = Date.now();
|
|
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(); }
|
|
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(); saveSnap(); }
|
|
228
228
|
else {
|
|
229
229
|
const r = state.route, seen = new Set();
|
|
230
230
|
for (const x of list) {
|
|
@@ -290,14 +290,53 @@ async function relocate(i) {
|
|
|
290
290
|
} catch { return false; }
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
/* ---------- Instantània de cada ordinador
|
|
294
|
+
En canviar d'ordinador es pinta a l'instant el que ja en sabíem (sessions, aprovacions, ús i info) i es posa al dia
|
|
295
|
+
quan arriba l'«hello». Mentre la llista d'ordinadors és oberta, els altres es refresquen en segon pla. */
|
|
296
|
+
const SNAP_KEY = 'nio.snap';
|
|
297
|
+
const snaps = new Map(Object.entries(LS.get(SNAP_KEY, {}) || {}));
|
|
298
|
+
let snapT = 0;
|
|
299
|
+
const persistSnaps = () => LS.set(SNAP_KEY, Object.fromEntries([...snaps].filter(([k]) => store.machines.some((m) => mkey(m) === k))));
|
|
300
|
+
function saveSnap() {
|
|
301
|
+
const m = machine(); if (!m || isDemo(m) || !state.synced) return;
|
|
302
|
+
clearTimeout(snapT);
|
|
303
|
+
snapT = setTimeout(() => {
|
|
304
|
+
if (mkey(machine()) !== mkey(m) || !state.synced) return;
|
|
305
|
+
snaps.set(mkey(m), { t: Date.now(), info: state.info, usage: state.usage || null, approvals: [...state.approvals.values()], sessions: [...state.sessions.values()].sort((a, b) => b.updated - a.updated).slice(0, 80) });
|
|
306
|
+
persistSnaps();
|
|
307
|
+
}, 500);
|
|
308
|
+
}
|
|
309
|
+
function restoreSnap(m) {
|
|
310
|
+
const s = snaps.get(mkey(m)); if (!s) return false;
|
|
311
|
+
state.info = s.info || null; state.usage = s.usage || null;
|
|
312
|
+
state.sessions = new Map((s.sessions || []).map((x) => [x.id, x])); state.approvals = new Map((s.approvals || []).map((a) => [a.id, a]));
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
const snapBusy = new Set();
|
|
316
|
+
async function refreshSnaps() {
|
|
317
|
+
await Promise.all(store.machines.map(async (m) => {
|
|
318
|
+
const k = mkey(m);
|
|
319
|
+
if (m === machine() || isDemo(m) || snapBusy.has(k) || Date.now() - (snaps.get(k)?.t || 0) < 15000) return;
|
|
320
|
+
snapBusy.add(k);
|
|
321
|
+
try {
|
|
322
|
+
const get = (p) => fetch(`${m.url}${p}`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store', signal: AbortSignal.timeout(6000) }).then((r) => { if (!r.ok) throw new Error(r.status); return r.json(); });
|
|
323
|
+
const [info, sessions, approvals, usage] = await Promise.all([get('/api/info'), get('/api/sessions'), get('/api/approvals'), get('/api/usage').catch(() => null)]);
|
|
324
|
+
if (m === machine()) return;
|
|
325
|
+
snaps.set(k, { t: Date.now(), info, usage, approvals, sessions: sessions.sort((a, b) => b.updated - a.updated).slice(0, 80) }); persistSnaps();
|
|
326
|
+
} catch { /* no hi arribo: es queda la d'abans */ } finally { snapBusy.delete(k); }
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
329
|
+
|
|
293
330
|
async function connect() {
|
|
294
331
|
streamCtrl?.abort(); state.synced = false;
|
|
295
332
|
const m = machine();
|
|
296
333
|
if (!m) { state.connected = false; return render(); }
|
|
297
334
|
const ctrl = new AbortController(); streamCtrl = ctrl;
|
|
298
335
|
try {
|
|
299
|
-
|
|
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' });
|
|
336
|
+
// La info i el flux en paral·lel: el canvi d'ordinador no espera dues anades i tornades seguides.
|
|
337
|
+
const [info, res] = await Promise.all([api('/api/info'), fetch(`${m.url}/api/stream?d=${encodeURIComponent(deviceId())}&k=${isPhone() ? 'phone' : 'desktop'}`, { headers: { Authorization: `Bearer ${m.token}` }, signal: ctrl.signal, cache: 'no-store' })]);
|
|
338
|
+
if (ctrl.signal.aborted) return;
|
|
339
|
+
state.info = info;
|
|
301
340
|
if (res.status === 401) throw Object.assign(new Error('La clau no és vàlida. Torna a connectar amb el codi QR.'), { fatal: true });
|
|
302
341
|
if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
|
|
303
342
|
state.connected = true; state.lastError = null; retry = 0; alive = Date.now();
|
|
@@ -372,6 +411,7 @@ function renameMachine(host, id) {
|
|
|
372
411
|
function handle(msg) {
|
|
373
412
|
const r = state.route;
|
|
374
413
|
callCtl?.onEvent(msg);
|
|
414
|
+
if (/^(session|removed|approval|usage)$/.test(msg.type)) saveSnap();
|
|
375
415
|
switch (msg.type) {
|
|
376
416
|
case 'device':
|
|
377
417
|
if (!isPhone() && msg.device !== deviceId()) { if ($('#qrBox')) closeSheet(); toast('Mòbil connectat correctament', 2000); }
|
|
@@ -382,6 +422,7 @@ function handle(msg) {
|
|
|
382
422
|
state.approvals = new Map(msg.approvals.map((a) => [a.id, a])); state.usage = msg.usage || state.usage; notesCtl?.reload();
|
|
383
423
|
state.loaded.clear(); state.events.clear();
|
|
384
424
|
if (r.name === 'session') openSession(r.id); else render();
|
|
425
|
+
saveSnap(); if (store.machines.length > 1) setTimeout(refreshSnaps, 4000);
|
|
385
426
|
break;
|
|
386
427
|
case 'session':
|
|
387
428
|
state.sessions.set(msg.session.id, msg.session);
|
|
@@ -493,8 +534,10 @@ async function pairAndAdd(pair, { msg = 'Ordinador connectat', after } = {}) {
|
|
|
493
534
|
}
|
|
494
535
|
// Passa a un altre ordinador de la llista i es queda a la mateixa secció.
|
|
495
536
|
function switchMachine(i) {
|
|
496
|
-
closeSheet(); store.active = i; streamCtrl?.abort(); tgState = undefined; nodeVoice = null;
|
|
497
|
-
state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; state.lastError = null;
|
|
537
|
+
closeSheet(); store.active = i; streamCtrl?.abort(); tgState = undefined; tgOthers = []; nodeVoice = null;
|
|
538
|
+
state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; state.usage = null; state.lastError = null;
|
|
539
|
+
restoreSnap(machine()); // el que ja en sabíem, a l'instant; l'«hello» ho posa al dia
|
|
540
|
+
if (state.route.name === 'notes' && !notesCtl?.open) lastName = null; // les notes de l'altre ordinador es tornen a muntar (a l'instant si ja les coneixíem)
|
|
498
541
|
connect();
|
|
499
542
|
if (state.route.name === 'session') go('#/sessions'); else render();
|
|
500
543
|
}
|
|
@@ -576,14 +619,14 @@ function renderChrome() {
|
|
|
576
619
|
const s = r.name === 'session' ? state.sessions.get(r.id) : null;
|
|
577
620
|
$('#here').textContent = s ? s.title || 'Sessió nova' : VIEWS[r.name]?.label || '';
|
|
578
621
|
$('#mRight').innerHTML = r.name === 'session'
|
|
579
|
-
?
|
|
622
|
+
? `${usageChip(s?.agent, true)}<button class="icon-btn" data-act="session-more" aria-label="Opcions de la sessió">${svg(ICON.more)}</button>`
|
|
580
623
|
: `<a class="icon-btn" href="#/aprovacions" aria-label="${n ? `Aprovacions, ${n} pendents` : 'Aprovacions'}">${svg(ICON.bell)}${n ? `<span class="badge">${n}</span>` : ''}</a>`;
|
|
581
624
|
document.title = n ? `(${n}) nio` : `nio // ${s ? s.title || 'Sessió' : VIEWS[r.name]?.label || 'Panell'}`;
|
|
582
625
|
if ('setAppBadge' in navigator) (n ? navigator.setAppBadge(n) : navigator.clearAppBadge()).catch?.(() => {});
|
|
583
626
|
}
|
|
584
627
|
let notesCtl = null, callCtl = null;
|
|
585
628
|
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 }));
|
|
629
|
+
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
630
|
function render() {
|
|
588
631
|
const r = state.route, view = $('#view'), board = r.name === 'pissarra';
|
|
589
632
|
if (r.name !== 'sessions') manage = null;
|
|
@@ -738,10 +781,12 @@ function noMachine(text) {
|
|
|
738
781
|
function viewSettings() {
|
|
739
782
|
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
783
|
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>` : '';
|
|
784
|
+
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
785
|
if (machine() && pushOn === null) refreshPush();
|
|
743
786
|
if (machine() && tgState === undefined) loadTelegram();
|
|
744
|
-
|
|
787
|
+
// App d'Android: només es proposa des del navegador d'un mòbil Android (dins l'app ja no cal).
|
|
788
|
+
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>` : '';
|
|
789
|
+
return `${head}<div class="stack-lg cols">${android}${avisos}${voicePanel()}<div id="tgPanel">${telegramPanel()}</div></div>`;
|
|
745
790
|
}
|
|
746
791
|
// Ajustos sense ordinador: un sol avís a dalt i les seccions de mostra, desactivades.
|
|
747
792
|
function settingsOff() {
|
|
@@ -806,15 +851,27 @@ async function sshAdd(target, onStep, via) {
|
|
|
806
851
|
if (!last || last.error) throw new Error(last?.error || 'S’ha tallat la connexió');
|
|
807
852
|
return last;
|
|
808
853
|
}
|
|
854
|
+
// Lector de QR: BarcodeDetector si el navegador en té; si no (Firefox, l'app d'Android), jsQR, que llegeix la imatge en JavaScript.
|
|
855
|
+
let jsQRload = null;
|
|
856
|
+
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
857
|
async function startScan(video, onText) {
|
|
810
|
-
|
|
858
|
+
let detect;
|
|
859
|
+
if ('BarcodeDetector' in window) { const det = new BarcodeDetector({ formats: ['qr_code'] }); detect = async () => (await det.detect(video))[0]?.rawValue; }
|
|
860
|
+
else {
|
|
861
|
+
const jsQR = await loadJsQR(), cv = document.createElement('canvas'), g = cv.getContext('2d', { willReadFrequently: true });
|
|
862
|
+
detect = async () => {
|
|
863
|
+
const w = Math.min(640, video.videoWidth), h = Math.round(video.videoHeight * (w / video.videoWidth)); if (!w || !h) return null;
|
|
864
|
+
cv.width = w; cv.height = h; g.drawImage(video, 0, 0, w, h);
|
|
865
|
+
return jsQR(g.getImageData(0, 0, w, h).data, w, h, { inversionAttempts: 'dontInvert' })?.data;
|
|
866
|
+
};
|
|
867
|
+
}
|
|
811
868
|
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
|
|
812
869
|
video.srcObject = stream; await video.play(); let alive = true;
|
|
813
|
-
(async () => { while (alive) { try { const
|
|
870
|
+
(async () => { while (alive) { try { const t = await detect(); if (t) onText(t); } catch { /* quadre sense QR */ } await sleep(250); } })();
|
|
814
871
|
return { stop() { alive = false; stream.getTracks().forEach((t) => t.stop()); } };
|
|
815
872
|
}
|
|
816
873
|
function addComputerSheet(tab, opts = {}) {
|
|
817
|
-
const phone = isPhone(), canScan =
|
|
874
|
+
const phone = isPhone(), canScan = !!navigator.mediaDevices?.getUserMedia;
|
|
818
875
|
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
876
|
let cur = TABS.some((t) => t[0] === tab) ? tab : TABS[0][0], poll = 0, scan = null, shown = '', hosts = [];
|
|
820
877
|
const intro = (t, d) => `<div class="add-intro"><h3>${t}</h3>${d ? `<p>${d}</p>` : ''}</div>`;
|
|
@@ -891,6 +948,7 @@ function addComputerSheet(tab, opts = {}) {
|
|
|
891
948
|
}
|
|
892
949
|
function machinesSheet() {
|
|
893
950
|
const ms = store.machines;
|
|
951
|
+
refreshSnaps();
|
|
894
952
|
openSheet(`<h2>Ordinadors</h2>${ms.length ? `<div class="stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><span class="c-ico">${svg(machineIcon(x))}</span><span class="grow"><b>${esc(x.name)}</b><div class="muted small">${esc(machineSub(x))}</div></span>${i === store.active ? `<span class="chip ok"><i class="dot ${state.connected ? 'on' : ''}"></i>Actiu</span>` : ''}</button>`).join('')}</div>` : '<p class="muted small">Encara no hi ha cap ordinador.</p>'}
|
|
895
953
|
<div class="row between sheet-foot">${ms.length ? '<a class="btn ghost" href="#/ordinadors" data-close>Gestiona</a>' : '<button class="btn ghost" type="button" data-close>Tanca</button>'}<button class="btn ink" type="button" data-act="add-computer">${svg(ICON.plus)}Afegeix ordinador</button></div>`, (sh) => { $$('[data-close]', sh).forEach((b) => (b.onclick = closeSheet)); });
|
|
896
954
|
}
|
|
@@ -1313,12 +1371,14 @@ function renderSession() {
|
|
|
1313
1371
|
function sessionMood(s) { return !s ? 'idle' : s.state === 'running' ? 'work' : s.state === 'waiting' || s.pending ? 'peek' : s.state === 'error' ? 'sleep' : 'idle'; }
|
|
1314
1372
|
// Ús de la subscripció de l'agent (5 h i setmana), com a esfis. Només si l'agent l'ha informat.
|
|
1315
1373
|
const hhmm = (t) => new Date(t * 1000).toLocaleString('ca', { weekday: t * 1000 - Date.now() > 864e5 ? 'short' : undefined, hour: '2-digit', minute: '2-digit' });
|
|
1316
|
-
|
|
1374
|
+
// mini: la versió de la barra de dalt del mòbil (l'anell i la finestra més plena; tocant-la, el detall).
|
|
1375
|
+
function usageChip(agent, mini) {
|
|
1317
1376
|
const u = state.usage?.[agent]; if (!u?.windows?.length) return '';
|
|
1318
1377
|
const tip = u.windows.map((w) => `${w.label}: ${Math.round(w.used * 100)} %${w.resetsAt ? ` · es reinicia ${hhmm(w.resetsAt)}` : ''}`).join('\n');
|
|
1319
1378
|
// Etiquetes curtes: «5h», «7d». La icona és un anell que s'omple amb la finestra més plena.
|
|
1320
1379
|
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
1380
|
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>`;
|
|
1381
|
+
if (mini) { const w = u.windows.find((x) => x.used === top); return `<button class="chip mono usage mini" type="button" data-act="usage" data-hi="${top >= 0.8 ? 1 : 0}" aria-label="${esc(`Ús de la subscripció. ${tip.replace(/\n/g, '. ')}`)}">${ring}<span class="uw" data-hi="${top >= 0.8 ? 1 : 0}">${esc(short(w.label))} ${pct}%</span></button>`; }
|
|
1322
1382
|
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>`;
|
|
1323
1383
|
}
|
|
1324
1384
|
function renderSessionHead() {
|
|
@@ -1663,8 +1723,7 @@ $('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
|
|
|
1663
1723
|
|
|
1664
1724
|
// Al mòbil, els fulls es tanquen lliscant cap avall, com els de l'iPhone: el full segueix el dit
|
|
1665
1725
|
// 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');
|
|
1726
|
+
function dragToClose(d, close) {
|
|
1668
1727
|
let g = null;
|
|
1669
1728
|
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
1729
|
const reset = () => { d.style.transition = ''; d.style.transform = ''; };
|
|
@@ -1692,21 +1751,27 @@ $('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
|
|
|
1692
1751
|
const x = g; g = null; if (!x || x.lock !== 'y') return;
|
|
1693
1752
|
const [t0, y0] = x.pts[0], [t1, y1] = x.pts[x.pts.length - 1], v = (y1 - y0) / Math.max(1, t1 - t0);
|
|
1694
1753
|
if (e.type === 'touchend' && (x.dy > Math.min(160, d.offsetHeight * 0.28) || (v > 0.5 && x.dy > 20))) {
|
|
1695
|
-
if (reduce) { reset();
|
|
1754
|
+
if (reduce) { reset(); close(); return; }
|
|
1696
1755
|
d.style.transition = 'transform .2s cubic-bezier(.4,0,1,1)'; d.style.transform = `translateY(${d.offsetHeight + 20}px)`;
|
|
1697
|
-
setTimeout(() => {
|
|
1756
|
+
setTimeout(() => { close(); requestAnimationFrame(reset); }, 200);
|
|
1698
1757
|
} else { d.style.transition = 'transform .3s cubic-bezier(.22,1,.36,1)'; d.style.transform = ''; setTimeout(reset, 300); }
|
|
1699
1758
|
};
|
|
1700
1759
|
d.addEventListener('touchend', end, { passive: true });
|
|
1701
1760
|
d.addEventListener('touchcancel', end, { passive: true });
|
|
1702
1761
|
d.addEventListener('close', reset);
|
|
1703
|
-
}
|
|
1762
|
+
}
|
|
1763
|
+
dragToClose($('#sheet'), () => closeSheet());
|
|
1764
|
+
dragToClose($('#palette'), () => $('#palette').close());
|
|
1704
1765
|
|
|
1705
|
-
// Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
|
|
1706
|
-
|
|
1766
|
+
// Claude Code no deixa «Via lliure» (bypassPermissions) quan nio corre com a root (típic en un VPS).
|
|
1767
|
+
// 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.
|
|
1768
|
+
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; };
|
|
1769
|
+
const asRoot = (agentId) => agentId === 'claude' && state.info?.user === 'root';
|
|
1770
|
+
const modeBlocked = (agentId, mode) => mode === 'tot' && asRoot(agentId) && !verAtLeast(state.info?.version, '0.9.12');
|
|
1707
1771
|
function modeHelp(agentId, mode) {
|
|
1708
1772
|
const a = state.info?.agents?.find((x) => x.id === agentId);
|
|
1709
|
-
if (modeBlocked(agentId, mode)) return 'No disponible
|
|
1773
|
+
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».';
|
|
1774
|
+
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
1775
|
if (mode === 'llegir') return 'Llegeix el projecte i et proposa canvis, però no toca cap fitxer.';
|
|
1711
1776
|
if (mode === 'tot') return 'Treballa sense preguntar. Fes-lo servir només en carpetes on confiïs plenament.';
|
|
1712
1777
|
if (a?.approvals) return 'Llegeix sol. Abans d’editar un fitxer o d’executar una ordre, et demana permís aquí.';
|
|
@@ -2532,9 +2597,16 @@ document.addEventListener('click', async (e) => {
|
|
|
2532
2597
|
|
|
2533
2598
|
/* ---------------------------------------------------------------- Telegram (bot propi, configurat a l'ordinador) */
|
|
2534
2599
|
let tgState;
|
|
2600
|
+
let tgOthers = []; // bots dels altres ordinadors: [{ name, bot, allowed }]
|
|
2535
2601
|
async function loadTelegram() {
|
|
2536
2602
|
try { tgState = await api('/api/telegram'); } catch { tgState = null; }
|
|
2537
2603
|
const box = $('#tgPanel'); if (box) box.innerHTML = telegramPanel();
|
|
2604
|
+
const mine = machine(), others = store.machines.filter((m) => m !== mine && mkey(m) !== mkey(mine) && !isDemo(m));
|
|
2605
|
+
const got = await Promise.all(others.map((m) => fetch(`${m.url}/api/telegram`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store', signal: AbortSignal.timeout(5000) })
|
|
2606
|
+
.then((r) => (r.ok ? r.json() : null)).then((t) => (t?.configured ? { name: m.name, bot: t.bot?.username || '', allowed: t.allowed || [] } : null)).catch(() => null)));
|
|
2607
|
+
if (machine() !== mine && mkey(machine()) !== mkey(mine)) return;
|
|
2608
|
+
tgOthers = got.filter(Boolean);
|
|
2609
|
+
if (tgOthers.length && $('#tgPanel')) $('#tgPanel').innerHTML = telegramPanel();
|
|
2538
2610
|
}
|
|
2539
2611
|
function telegramPanel() {
|
|
2540
2612
|
if (!machine()) return '';
|
|
@@ -2543,9 +2615,11 @@ function telegramPanel() {
|
|
|
2543
2615
|
if (t === null) return '<section class="panel"><header><h2>Telegram</h2></header><div class="body"><p class="muted small">Aquest ordinador encara no té Telegram (cal nio 0.7 o més nou).</p></div></section>';
|
|
2544
2616
|
if (!t.configured) return `<section class="panel"><header><h2>Telegram</h2></header><div class="body stack">
|
|
2545
2617
|
<p class="small">Parla amb els agents d’aquest ordinador des de Telegram: instruccions, notes de veu, avisos i aprovacions amb botons. El bot és teu i el testimoni es queda a l’ordinador.</p>
|
|
2546
|
-
|
|
2618
|
+
${tgOthers.length ? `<p class="small tg-note">Cada ordinador necessita <b>el seu bot</b>: Telegram només deixa llegir un bot des d’un lloc. ${tgOthers.map((o) => `<b>${esc(o.name)}</b> ja fa servir ${o.bot ? '@' + esc(o.bot) : 'un bot'}`).join(', ')}; crea’n un altre per a aquest${tgOthers.some((o) => o.allowed.length) ? ' i els teus xats hi quedaran autoritzats sols' : ''}.</p>` : ''}
|
|
2619
|
+
<ol class="muted small" style="padding-left:1.2em"><li>A Telegram, obre <b>@BotFather</b> i envia <span class="mono">/newbot</span>${tgOthers.length ? ' (un nom diferent, p. ex. amb el nom de l’ordinador)' : ''}.</li><li>Copia el testimoni que et dona i enganxa’l aquí.</li><li>Escriu qualsevol cosa al teu bot i torna aquí per autoritzar el teu xat.</li></ol>
|
|
2547
2620
|
<form class="row" id="tgForm" style="flex-wrap:nowrap"><input type="password" id="tgToken" placeholder="123456:ABC…" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Testimoni del bot"><button class="btn ink" type="submit">Connecta</button></form></div></section>`;
|
|
2548
2621
|
return `<section class="panel"><header><h2>Telegram</h2><span class="chip ok"><i class="dot on"></i>${esc(t.bot ? '@' + t.bot.username : 'Connectat')}</span></header><div class="body stack">
|
|
2622
|
+
${t.conflict ? '<p class="small form-error">Un altre ordinador llegeix aquest mateix bot i es reparteixen els missatges. Cada ordinador necessita el seu: desconnecta’l d’un dels dos i crea’n un altre a @BotFather.</p>' : ''}
|
|
2549
2623
|
${t.pending.length ? `<div class="stack"><b class="small">Volen parlar amb el bot</b>${t.pending.map((p) => `<div class="agent-row"><span class="grow"><b>${esc(p.name)}</b><div class="muted small">${p.username ? '@' + esc(p.username) + ' · ' : ''}${ago(p.t)} · xat ${esc(p.id)}</div></span><button class="btn sm ink" data-tg-allow="${esc(p.id)}" data-name="${esc(p.name)}" type="button">Autoritza</button></div>`).join('')}<p class="muted small">Autoritza només els xats que reconeguis. La resta s’ignoren.</p></div>` : ''}
|
|
2550
2624
|
<div class="stack"><b class="small">Xats autoritzats</b>${t.allowed.length ? t.allowed.map((a) => `<div class="agent-row"><span class="grow"><b>${esc(a.name)}</b><div class="muted small">xat ${esc(a.id)}</div></span><button class="btn sm danger" data-tg-drop="${esc(a.id)}" type="button">Treu</button></div>`).join('') : `<p class="muted small">Encara cap. Escriu al bot ${t.bot ? '<b>@' + esc(t.bot.username) + '</b>' : ''} i el teu xat apareixerà aquí.</p>`}</div>
|
|
2551
2625
|
<div class="grid-2"><label class="f"><span>Agent</span><select id="tgAgent">${['claude', 'codex'].map((a) => `<option value="${a}" ${t.agent === a ? 'selected' : ''}>${AGENT_NAMES[a]}</option>`).join('')}</select></label>
|
|
@@ -2556,7 +2630,10 @@ function telegramPanel() {
|
|
|
2556
2630
|
}
|
|
2557
2631
|
document.addEventListener('submit', async (e) => {
|
|
2558
2632
|
if (e.target.id !== 'tgForm') return; e.preventDefault();
|
|
2559
|
-
try { tgState = await api('/api/telegram', { method: 'PUT', body: { token: $('#tgToken').value.trim() } }); toast('Bot connectat'); } catch (err) { toast(err.message,
|
|
2633
|
+
try { tgState = await api('/api/telegram', { method: 'PUT', body: { token: $('#tgToken').value.trim() } }); toast('Bot connectat'); } catch (err) { toast(err.message, 7000); }
|
|
2634
|
+
// Els xats que ja parlen amb els bots dels altres ordinadors també poden parlar amb aquest.
|
|
2635
|
+
const known = new Map(tgOthers.flatMap((o) => o.allowed).map((a) => [String(a.id), a]));
|
|
2636
|
+
if (tgState?.configured) for (const a of known.values()) if (!tgState.allowed.some((x) => String(x.id) === String(a.id))) tgState = await api('/api/telegram/allow', { method: 'POST', body: { id: Number(a.id), name: a.name } }).catch(() => tgState);
|
|
2560
2637
|
$('#tgPanel').innerHTML = telegramPanel();
|
|
2561
2638
|
});
|
|
2562
2639
|
document.addEventListener('change', async (e) => {
|
|
@@ -2771,6 +2848,7 @@ document.addEventListener('click', async (e) => {
|
|
|
2771
2848
|
case 'model': { const s = state.sessions.get(state.route.id); if (s) modelSheet(s); return; }
|
|
2772
2849
|
case 'mode': { const s = state.sessions.get(state.route.id); if (s) modeSheet(s); return; }
|
|
2773
2850
|
case 'session-more': return sessionOptions(state.route.id);
|
|
2851
|
+
case 'usage': { const u = state.usage?.[state.sessions.get(state.route.id)?.agent]; if (u?.windows) toast(`Ús de la subscripció · ${u.windows.map((w) => `${w.label}: ${Math.round(w.used * 100)} %${w.resetsAt ? ` (es reinicia ${hhmm(w.resetsAt)})` : ''}`).join(' · ')}`, 5000); return; }
|
|
2774
2852
|
case 'refresh-agents': try { state.info = await api('/api/info?refresh=1'); render(); toast('Agents actualitzats'); } catch (err) { toast(err.message); } return;
|
|
2775
2853
|
case 'push': return enablePush();
|
|
2776
2854
|
case 'push-off': return disablePush();
|
|
@@ -3041,6 +3119,20 @@ if ('serviceWorker' in navigator) {
|
|
|
3041
3119
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
|
3042
3120
|
navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'open-session' && e.data.id) go(`#/s/${e.data.id}`); });
|
|
3043
3121
|
}
|
|
3122
|
+
// 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ó
|
|
3123
|
+
// 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.
|
|
3124
|
+
(() => {
|
|
3125
|
+
const ver = async () => { try { return (await (await fetch('/sw.js', { cache: 'no-store' })).text()).match(/nio-shell-v\d+/)?.[0] || null; } catch { return null; } };
|
|
3126
|
+
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());
|
|
3127
|
+
let mine = null, hiddenAt = 0;
|
|
3128
|
+
ver().then((v) => { mine = v; });
|
|
3129
|
+
document.addEventListener('visibilitychange', async () => {
|
|
3130
|
+
if (document.hidden) { hiddenAt = Date.now(); return; }
|
|
3131
|
+
if (!mine || Date.now() - hiddenAt < 60000) return;
|
|
3132
|
+
const now = await ver();
|
|
3133
|
+
if (now && now !== mine && !document.hidden && !busy()) location.reload();
|
|
3134
|
+
});
|
|
3135
|
+
})();
|
|
3044
3136
|
addEventListener('hashchange', route);
|
|
3045
3137
|
// 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
3138
|
// 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.
|
|
@@ -3067,17 +3159,18 @@ if (['127.0.0.1', 'localhost', '[::1]'].includes(location.hostname)) {
|
|
|
3067
3159
|
}
|
|
3068
3160
|
setNav(false);
|
|
3069
3161
|
if (!checkPairHash()) state.route = parseRoute();
|
|
3070
|
-
if (machine()) connect();
|
|
3162
|
+
if (machine()) { restoreSnap(machine()); connect(); }
|
|
3071
3163
|
if (state.route.name === 'session') openSession(state.route.id); else render();
|
|
3072
3164
|
if (T.on) tourGo(T.i); else if (!T.seen && !machine()) setTimeout(tourStart, 700);
|
|
3073
3165
|
|
|
3074
3166
|
/* ---------------------------------------------------------------- lliscar entre pàgines (mòbil) */
|
|
3075
3167
|
(() => {
|
|
3076
|
-
|
|
3168
|
+
// Les pàgines de la barra de baix, en el mateix ordre («Pantalla» i «Més» no són pàgines: obren un full).
|
|
3169
|
+
const PAGES = ['sessions', 'notes', 'ordinadors'];
|
|
3077
3170
|
const view = $('#view');
|
|
3078
3171
|
let g = null, timer = 0;
|
|
3079
3172
|
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);
|
|
3173
|
+
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
3174
|
const target = (dir) => PAGES[PAGES.indexOf(state.route.name) + dir];
|
|
3082
3175
|
const put = (x, o) => { view.style.setProperty('--sx', x + 'px'); view.style.setProperty('--so', String(o)); };
|
|
3083
3176
|
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,8 @@
|
|
|
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>
|
|
36
|
+
<script src="/app/android.js"></script>
|
|
35
37
|
<link rel="stylesheet" href="/bot.css">
|
|
36
38
|
<link rel="stylesheet" href="/board.css">
|
|
37
39
|
<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
|
@@ -145,6 +145,12 @@ body:has(.nt-ov) { overflow: hidden; }
|
|
|
145
145
|
.nt-more-sheet button.danger { color: var(--red); }
|
|
146
146
|
.nt-more-sheet button.muted { color: var(--fg-muted); text-align: center; }
|
|
147
147
|
|
|
148
|
+
/* Al mòbil, el panell «Més» i els desplegables són fulls de baix que segueixen el dit: el navegador no s'ho queda per desplaçar. */
|
|
149
|
+
.nt-more-sheet, .nt-pop-sheet { touch-action: none; }
|
|
150
|
+
.nt-pop.nt-pop-sheet { position: absolute; z-index: 6; left: 0 !important; right: 0; bottom: 0 !important; top: auto; min-width: 0; max-width: none; max-height: 75% !important; overflow: auto; overscroll-behavior: contain; padding: 6px 12px calc(14px + var(--safe-b)); border: 0; border-radius: 18px 18px 0 0; box-shadow: 0 -8px 30px rgba(39,34,36,.14); animation: nt-sheet-up .28s cubic-bezier(.22,1,.36,1); }
|
|
151
|
+
.nt-pop-sheet input, .nt-pop-sheet select { touch-action: manipulation; }
|
|
152
|
+
@keyframes nt-sheet-up { from { transform: translateY(100%); } }
|
|
153
|
+
|
|
148
154
|
/* finestretes */
|
|
149
155
|
.nt-pop { position: absolute; z-index: 5; min-width: 220px; max-width: min(340px, calc(100% - 16px)); padding: 8px; border: 1px solid var(--border); border-radius: 14px; background: var(--white); box-shadow: 0 18px 50px -14px rgba(39,34,36,.5); animation: nt-pop .18s var(--ease); transform-origin: bottom left; }
|
|
150
156
|
@keyframes nt-pop { from { opacity: 0; transform: translateY(6px) scale(.97); } }
|
|
@@ -173,6 +179,8 @@ body:has(.nt-ov) { overflow: hidden; }
|
|
|
173
179
|
.nt-ai-f { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
|
|
174
180
|
|
|
175
181
|
@media (max-width: 700px) {
|
|
182
|
+
/* Els desplegables de la nota (color, etiqueta, assistent) ocupen l'amplada, amb marge: no se surten pels costats. */
|
|
183
|
+
.nt-pop { left: 10px !important; right: 10px; max-width: none; min-width: 0; }
|
|
176
184
|
.page:has(.nt) { padding-inline: 14px; }
|
|
177
185
|
.nt-title h1 { font-size: 26px; } .nt-title p { display: none; }
|
|
178
186
|
.nt-top { margin-bottom: 12px; } .nt-tools .btn span { display: none; } .nt-tools .btn { padding: 0 11px; }
|
package/web/app/notes.js
CHANGED
|
@@ -54,6 +54,7 @@ export function createNotes(ctx) {
|
|
|
54
54
|
let view = 'notes', q = '', layout = LSget('nio.notes.layout', 'grid');
|
|
55
55
|
let ed = null, aborter = null;
|
|
56
56
|
const historyNotes = new Map();
|
|
57
|
+
const byMachine = new Map(); // ordinador -> notes que en coneixem
|
|
57
58
|
const pendingSaves = new Set();
|
|
58
59
|
let loadError = '';
|
|
59
60
|
const machineKey = () => ctx.machine()?.id || ctx.machine()?.url;
|
|
@@ -87,6 +88,7 @@ export function createNotes(ctx) {
|
|
|
87
88
|
const r = await api('/api/notes');
|
|
88
89
|
if (requestedMachine !== machineKey()) { loading = false; if (root) load(); return; }
|
|
89
90
|
notes = new Map(r.notes.map((n) => [n.id, n])); loaded = true;
|
|
91
|
+
byMachine.set(requestedMachine, notes);
|
|
90
92
|
for (const entry of pendingSaves) if (entry.id && entry.machineKey === machineKey()) notes.set(entry.id, entry.n);
|
|
91
93
|
if (ed?.id) notes.set(ed.id, ed.n);
|
|
92
94
|
}
|
|
@@ -101,7 +103,12 @@ export function createNotes(ctx) {
|
|
|
101
103
|
// ---------------------------------------------------------------- pàgina
|
|
102
104
|
function mount(el) {
|
|
103
105
|
root = el; aborter?.abort(); aborter = new AbortController();
|
|
104
|
-
if (notesMachine !== machineKey()) {
|
|
106
|
+
if (notesMachine !== machineKey()) {
|
|
107
|
+
// Les notes de l'ordinador d'abans es queden a la memòria: si hi tornes, surten a l'instant i es posen al dia.
|
|
108
|
+
if (loaded) byMachine.set(notesMachine, notes);
|
|
109
|
+
notesMachine = machineKey(); engines = null; q = ''; view = 'notes';
|
|
110
|
+
notes = byMachine.get(notesMachine) || new Map(); loaded = byMachine.has(notesMachine);
|
|
111
|
+
}
|
|
105
112
|
if (history.state?.nioNote) { const { nioNote, ...rest } = history.state; history.replaceState(rest, '', location.href); }
|
|
106
113
|
root.innerHTML = `<div class="nt">
|
|
107
114
|
<div class="nt-top">
|
|
@@ -491,11 +498,11 @@ export function createNotes(ctx) {
|
|
|
491
498
|
title.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); ($('.nt-ed-text', c) || $('.nt-item input[type=text]', c))?.focus(); } });
|
|
492
499
|
c.addEventListener('click', onEditorClick);
|
|
493
500
|
drawBody(); drawAudio(); drawLabels(); drawMode();
|
|
494
|
-
bindMoreDrag($('#edMore', c), $('#edScrim', c));
|
|
501
|
+
bindMoreDrag($('#edMore', c), $('#edScrim', c), () => closeMore(true));
|
|
495
502
|
}
|
|
496
503
|
// El panell «Més» segueix el dit cap avall i es tanca si baixa prou o es llença; si no, torna a pujar.
|
|
497
504
|
let moreDraggedAt = 0;
|
|
498
|
-
function bindMoreDrag(sheet, scrim) {
|
|
505
|
+
function bindMoreDrag(sheet, scrim, close) {
|
|
499
506
|
let g = null;
|
|
500
507
|
sheet.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse') return; g = { y0: e.clientY, dy: 0, pid: e.pointerId, pts: [[e.timeStamp, e.clientY]], moved: false }; }, { passive: true });
|
|
501
508
|
sheet.addEventListener('pointermove', (e) => {
|
|
@@ -512,7 +519,7 @@ export function createNotes(ctx) {
|
|
|
512
519
|
const d = g; g = null; if (!d.moved) return;
|
|
513
520
|
moreDraggedAt = Date.now();
|
|
514
521
|
const [t0, y0] = d.pts[0], [t1, y1] = d.pts[d.pts.length - 1], v = (y1 - y0) / Math.max(1, t1 - t0);
|
|
515
|
-
if (d.dy > sheet.offsetHeight * 0.3 || (v > 0.4 && d.dy > 12))
|
|
522
|
+
if (d.dy > sheet.offsetHeight * 0.3 || (v > 0.4 && d.dy > 12)) close();
|
|
516
523
|
else { sheet.style.transition = ''; scrim.style.transition = ''; sheet.style.transform = ''; scrim.style.opacity = ''; }
|
|
517
524
|
};
|
|
518
525
|
sheet.addEventListener('pointerup', end); sheet.addEventListener('pointercancel', end);
|
|
@@ -575,7 +582,7 @@ export function createNotes(ctx) {
|
|
|
575
582
|
async function onEditorClick(ev) {
|
|
576
583
|
const b = ev.target.closest('[data-e]'), c = ed?.card; if (!b || !ed) { if (ed?.pop && !ev.target.closest('.nt-pop')) closePop(); if (ed && !ev.target.closest('.nt-more-sheet')) closeMore(true); return; }
|
|
577
584
|
const a = b.dataset.e;
|
|
578
|
-
if (ev.target.closest('.nt-more-sheet') && Date.now() - moreDraggedAt < 350) return; // era un arrossegament, no un toc
|
|
585
|
+
if (ev.target.closest('.nt-more-sheet, .nt-pop-sheet') && Date.now() - moreDraggedAt < 350) return; // era un arrossegament, no un toc
|
|
579
586
|
if (a === 'more-close') return closeMore(true);
|
|
580
587
|
if (a !== 'more' && !ev.target.closest('.nt-more-sheet')) closeMore(true);
|
|
581
588
|
if (a === 'retry-save') { b.disabled = true; await flush(ed); b.disabled = false; return; }
|
|
@@ -624,10 +631,28 @@ export function createNotes(ctx) {
|
|
|
624
631
|
s.style.transform = 'translateY(105%)'; sc.style.opacity = '0';
|
|
625
632
|
setTimeout(hide, 220);
|
|
626
633
|
}
|
|
627
|
-
function closePop() {
|
|
634
|
+
function closePop(animated) {
|
|
635
|
+
const p = ed?.pop; if (!p) return; ed.pop = null;
|
|
636
|
+
const sc = p.previousElementSibling?.classList.contains('nt-pop-scrim') ? p.previousElementSibling : null;
|
|
637
|
+
if (!animated || reduce || !p.classList.contains('nt-pop-sheet')) { p.remove(); sc?.remove(); return; }
|
|
638
|
+
p.style.transition = 'transform .22s cubic-bezier(.4,0,1,1)'; p.style.transform = 'translateY(105%)';
|
|
639
|
+
if (sc) { sc.style.transition = 'opacity .22s'; sc.style.opacity = '0'; }
|
|
640
|
+
setTimeout(() => { p.remove(); sc?.remove(); }, 220);
|
|
641
|
+
}
|
|
642
|
+
// Al mòbil, els desplegables (color, etiqueta, assistent) són fulls de baix: a l'amplada, amb nansa, i es tanquen lliscant cap avall.
|
|
643
|
+
const phone = matchMedia('(max-width: 700px)');
|
|
644
|
+
const popGrab = (p) => (p.classList.contains('nt-pop-sheet') ? '<div class="nt-more-grab"></div>' : '');
|
|
628
645
|
function pop(anchor, html) {
|
|
629
646
|
closePop();
|
|
630
647
|
const p = document.createElement('div'); p.className = 'nt-pop'; p.innerHTML = html;
|
|
648
|
+
if (phone.matches) {
|
|
649
|
+
const sc = document.createElement('div'); sc.className = 'nt-more-scrim nt-pop-scrim';
|
|
650
|
+
sc.addEventListener('click', () => closePop(true));
|
|
651
|
+
p.classList.add('nt-pop-sheet'); p.setAttribute('role', 'dialog'); p.insertAdjacentHTML('afterbegin', '<div class="nt-more-grab"></div>');
|
|
652
|
+
ed.card.append(sc, p); ed.pop = p;
|
|
653
|
+
bindMoreDrag(p, sc, () => closePop(true));
|
|
654
|
+
return p;
|
|
655
|
+
}
|
|
631
656
|
ed.card.append(p); ed.pop = p;
|
|
632
657
|
const a = anchor.getBoundingClientRect(), r = ed.card.getBoundingClientRect(), w = p.offsetWidth;
|
|
633
658
|
p.style.maxHeight = `${Math.max(80, a.top - Math.max(r.top, window.visualViewport?.offsetTop || 0) - 16)}px`;
|
|
@@ -636,14 +661,14 @@ export function createNotes(ctx) {
|
|
|
636
661
|
}
|
|
637
662
|
function popColor(b) {
|
|
638
663
|
const p = pop(b, `<div class="nt-colors">${COLORS.map(([k, l]) => `<button type="button" class="nt-dot" data-color="${k}" aria-label="${l}" title="${l}" ${ed.n.color === k ? 'aria-pressed="true"' : ''}></button>`).join('')}</div>`);
|
|
639
|
-
p.addEventListener('click', (e) => { const d = e.target.closest('.nt-dot'); if (d) { set({ color: d.dataset.color }); closePop(); } });
|
|
664
|
+
p.addEventListener('click', (e) => { const d = Date.now() - moreDraggedAt < 350 ? null : e.target.closest('.nt-dot'); if (d) { set({ color: d.dataset.color }); closePop(); } });
|
|
640
665
|
}
|
|
641
666
|
function popLabel(b) {
|
|
642
667
|
const mine = new Set(ed.n.labels), labs = labelsOf().filter((l) => !mine.has(l));
|
|
643
668
|
const p = pop(b, `<form class="nt-lf"><input type="text" placeholder="Nova etiqueta" maxlength="30" aria-label="Nova etiqueta" autocomplete="off"><button class="btn ink sm" type="submit">Afegeix</button></form>${labs.length ? `<div class="nt-lsug">${labs.map((l) => `<button type="button" class="nt-chip" data-l="${esc(l)}">${esc(l)}</button>`).join('')}</div>` : ''}`);
|
|
644
669
|
const add = (l) => { l = l.trim().toLowerCase(); if (!l || ed.n.labels.includes(l) || ed.n.labels.length >= 12) return; set({ labels: [...ed.n.labels, l] }); drawLabels(); closePop(); };
|
|
645
670
|
$('form', p).onsubmit = (e) => { e.preventDefault(); add($('input', p).value); };
|
|
646
|
-
p.addEventListener('click', (e) => { const c = e.target.closest('[data-l]'); if (c) add(c.dataset.l); });
|
|
671
|
+
p.addEventListener('click', (e) => { const c = Date.now() - moreDraggedAt < 350 ? null : e.target.closest('[data-l]'); if (c) add(c.dataset.l); });
|
|
647
672
|
setTimeout(() => $('input', p).focus(), 30);
|
|
648
673
|
}
|
|
649
674
|
|
|
@@ -698,19 +723,19 @@ export function createNotes(ctx) {
|
|
|
698
723
|
async function popAi(b) {
|
|
699
724
|
const p = pop(b, '<p class="muted small nt-pad">Mirant quins models hi ha…</p>');
|
|
700
725
|
const list = await loadEngines(); if (ed?.pop !== p) return;
|
|
701
|
-
if (!list.length) { p.innerHTML = '<p class="small nt-pad">No hi ha cap model en aquest ordinador.<br>Instal·la <b>Ollama</b> i un model (<span class="mono">ollama pull qwen3:4b</span>) o <b>Claude Code</b>.</p>'; return; }
|
|
726
|
+
if (!list.length) { p.innerHTML = popGrab(p) + '<p class="small nt-pad">No hi ha cap model en aquest ordinador.<br>Instal·la <b>Ollama</b> i un model (<span class="mono">ollama pull qwen3:4b</span>) o <b>Claude Code</b>.</p>'; return; }
|
|
702
727
|
// 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.
|
|
703
728
|
const auto = (list.find((e) => e.local && e.id.startsWith('ollama:') && e.params >= 3) || list.find((e) => e.id === 'claude:haiku') || list[0]).id;
|
|
704
729
|
const saved0 = LSget('nio.notes.engine', ''), saved = list.some((e) => e.id === saved0) ? saved0 : auto, groups = [...new Set(list.map((e) => e.group))];
|
|
705
730
|
const hasText = !!ed.n.text.trim() || !!ed.n.items?.length;
|
|
706
|
-
p.innerHTML = `<label class="nt-pad small"><span class="muted">Model</span><select id="aiEng" class="nt-sel">${groups.map((g) => `<optgroup label="${esc(g)}">${list.filter((e) => e.group === g).map((e) => `<option value="${esc(e.id)}" ${e.id === saved ? 'selected' : ''}>${esc(e.label)}</option>`).join('')}</optgroup>`).join('')}</select></label>
|
|
731
|
+
p.innerHTML = popGrab(p) + `<label class="nt-pad small"><span class="muted">Model</span><select id="aiEng" class="nt-sel">${groups.map((g) => `<optgroup label="${esc(g)}">${list.filter((e) => e.group === g).map((e) => `<option value="${esc(e.id)}" ${e.id === saved ? 'selected' : ''}>${esc(e.label)}</option>`).join('')}</optgroup>`).join('')}</select></label>
|
|
707
732
|
<div class="nt-acts-list">${ACTIONS.map(([k, l, d]) => `<button type="button" data-ai="${k}" ${hasText ? '' : 'disabled'}><b>${l}</b><span>${d}</span></button>`).join('')}</div>
|
|
708
733
|
<form class="nt-lf" id="aiForm"><input type="text" placeholder="O demana-li qualsevol cosa…" maxlength="300" aria-label="Instrucció pròpia" ${hasText ? '' : 'disabled'}><button class="btn ink sm" type="submit" ${hasText ? '' : 'disabled'}>Envia</button></form>
|
|
709
734
|
<p class="nt-note small muted" id="aiNote"></p>`;
|
|
710
735
|
const eng = $('#aiEng', p), note = $('#aiNote', p);
|
|
711
736
|
const upd = () => { const e = list.find((x) => x.id === eng.value); note.textContent = e?.local ? 'Es queda a l’ordinador: cap dada surt de casa.' : 'Aquest text s’envia al servei del model (Anthropic, OpenAI o núvol d’Ollama).'; };
|
|
712
737
|
eng.onchange = () => { LSset('nio.notes.engine', eng.value); upd(); }; upd();
|
|
713
|
-
p.addEventListener('click', (e) => { const x = e.target.closest('[data-ai]'); if (x && !x.disabled) runAi(x.dataset.ai, eng.value); });
|
|
738
|
+
p.addEventListener('click', (e) => { const x = Date.now() - moreDraggedAt < 350 ? null : e.target.closest('[data-ai]'); if (x && !x.disabled) runAi(x.dataset.ai, eng.value); });
|
|
714
739
|
$('#aiForm', p).onsubmit = (e) => { e.preventDefault(); const v = $('input', p).value.trim(); if (v) runAi('custom', eng.value, v); };
|
|
715
740
|
}
|
|
716
741
|
async function runAi(action, engine, prompt) {
|
package/web/app/style.css
CHANGED
|
@@ -141,6 +141,10 @@ button.chip:hover { border-color: var(--fg); }
|
|
|
141
141
|
.chip.usage .ring .rv { stroke: var(--moss); }
|
|
142
142
|
.chip.usage[data-hi="1"] .ring .rv { stroke: var(--red); }
|
|
143
143
|
.chip.usage .uw[data-hi="1"] { color: var(--red); font-weight: 600; }
|
|
144
|
+
/* Al mòbil, l'ús va a la barra de dalt (a la barra de xips quedava fora de la pantalla, per la dreta). */
|
|
145
|
+
.chip.usage.mini { height: 32px; padding: 0 10px 0 8px; font-size: 12px; white-space: nowrap; flex: none; cursor: pointer; }
|
|
146
|
+
#mRight { display: flex; align-items: center; gap: 4px; }
|
|
147
|
+
@media (max-width: 820px) { .mode-bar .chip.usage { display: none; } }
|
|
144
148
|
.chip.beta { height: 20px; padding: 0 7px; font-size: 10px; color: var(--fg-muted); }
|
|
145
149
|
.pulse { width: 6px; height: 6px; border-radius: 50%; background: currentColor; animation: pulse 1.2s ease-in-out infinite; }
|
|
146
150
|
@keyframes pulse { 50% { opacity: .3; } }
|
|
@@ -304,6 +308,15 @@ dialog.sheet[open] { animation: pop .22s var(--ease); }
|
|
|
304
308
|
#palette li { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: 10px; cursor: pointer; font-size: 14px; }
|
|
305
309
|
#palette li[aria-selected="true"] { background: var(--bg-2); }
|
|
306
310
|
#palette li span { color: var(--fg-muted); font-size: 12px; }
|
|
311
|
+
/* Al mòbil, la paleta és un full de baix com els altres, i es tanca lliscant cap avall. */
|
|
312
|
+
@media (max-width: 820px) {
|
|
313
|
+
#palette { width: 100%; max-width: none; margin: auto 0 0; border-radius: 20px 20px 0 0; padding-bottom: var(--safe-b); max-height: 82dvh; }
|
|
314
|
+
#palette[open] { display: flex; flex-direction: column; animation: sheet-up .3s var(--ease); }
|
|
315
|
+
#palette::before { content: ''; display: block; width: 36px; height: 4px; margin: 8px auto 2px; border-radius: 4px; background: var(--border); flex: none; }
|
|
316
|
+
#palette input { border-radius: 0; flex: none; }
|
|
317
|
+
#palette ul { max-height: none; flex: 1; min-height: 0; }
|
|
318
|
+
#palette li { min-height: 46px; align-items: center; font-size: 15px; }
|
|
319
|
+
}
|
|
307
320
|
|
|
308
321
|
.tour { position: fixed; inset: 0; z-index: 40; pointer-events: none; }
|
|
309
322
|
.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 +471,9 @@ dialog.sheet.wiz .sheet-in { padding: 0; gap: 0; flex: 1; min-height: 0; display
|
|
|
458
471
|
.fs-pick b { overflow-wrap: anywhere; }
|
|
459
472
|
.fs-list { max-height: none; }
|
|
460
473
|
@media (max-width: 820px) {
|
|
461
|
-
|
|
474
|
+
/* Al mòbil, els assistents (Nova sessió, Afegeix ordinador…) fan l'alçada del contingut, no la de tota la pantalla. */
|
|
475
|
+
dialog.sheet.wiz { height: auto; max-height: min(92dvh, calc(var(--vvh, 100dvh) - 8px - var(--safe-t))); }
|
|
476
|
+
dialog.sheet.wiz .sheet-in, dialog.sheet.wiz .wiz-body { flex: 0 1 auto; }
|
|
462
477
|
.wiz-head { padding: 10px 18px 8px; }
|
|
463
478
|
.wiz-body { padding: 4px 18px 14px; }
|
|
464
479
|
.wiz-foot { padding: 12px 18px calc(14px + var(--safe-b)); }
|
|
@@ -724,7 +739,7 @@ html.rd-open, html.rd-open body { overflow: hidden; }
|
|
|
724
739
|
@media (max-width: 560px) { .rd-bar { padding: 8px 10px; gap: 8px; } .rd-stat, #rdFull { display: none; } .rd-seg button { padding: 9px 11px; } }
|
|
725
740
|
|
|
726
741
|
.rd-stage img { transform-origin: center; will-change: transform; }
|
|
727
|
-
.rd-tools { position: absolute; left: 50%; bottom: calc(14px +
|
|
742
|
+
.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
743
|
.rd-tools .rd-btn { padding: 10px 12px; min-width: 40px; }
|
|
729
744
|
.rd-pct { min-width: 56px !important; font-variant-numeric: tabular-nums; }
|
|
730
745
|
.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,6 +1,6 @@
|
|
|
1
1
|
// nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
|
|
2
|
-
const SHELL = 'nio-shell-
|
|
3
|
-
const FILES = ['/app', '/app/style.css', '/app/notes.css', '/app/call.css', '/app/app.js', '/app/notes.js', '/app/notes-live.js', '/app/call.js', '/app/demo.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
|
|
2
|
+
const SHELL = 'nio-shell-v61';
|
|
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', '/app/android.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
|
|
4
4
|
|
|
5
5
|
self.addEventListener('install', (e) => {
|
|
6
6
|
e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).catch(() => {}));
|